Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/chat-sessions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { MarkdownRenderer } from "@/components/MarkdownRenderer";
import { FilePreviewDialog } from "@/components/FilePreviewDialog";
import { getFileUrl } from "@/lib/ossUpload";
import {
attachmentPreviewUrl,
sessionAttachmentMime,
type SessionAttachment,
} from "@/lib/sessionAttachments";
Expand Down Expand Up @@ -873,7 +874,7 @@ export default function ChatSessionsPage() {
filename: attachment.filename,
originalname: attachment.filename,
mimetype: sessionAttachmentMime(attachment),
sourceUrl: getFileUrl(attachment.objectKey),
sourceUrl: attachmentPreviewUrl(getFileUrl(attachment.objectKey)),
})
}
>
Expand Down
23 changes: 19 additions & 4 deletions lib/sessionAttachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,27 @@ export function normalizeSessionAttachments(raw: unknown): SessionAttachment[] {
return out;
}

/** 预览对话框要 MIME;上传时浏览器可能给空串,用扩展名补。 */
/** content_type 有时是给气泡看的标签("PDF"),不是 MIME。 */
function isMimeType(value: string): boolean {
return /^[\w.+-]+\/[\w.+-]+$/.test(value);
}

/**
* 对象存储的签名响应带 Content-Disposition: attachment。
* iframe 直接跟着 302 走会下载,预览要改走同域 inline 流。
*/
export function attachmentPreviewUrl(url: string): string {
if (!url.startsWith("/api/oss/")) return url;
return `${url}${url.includes("?") ? "&" : "?"}inline=1`;
}

/** 预览对话框要 MIME;标签、空串、octet-stream 都按扩展名补。 */
export function sessionAttachmentMime(attachment: SessionAttachment): string {
if (attachment.contentType && attachment.contentType !== "application/octet-stream") {
return attachment.contentType;
const stored = (attachment.contentType || "").split(";")[0].trim();
if (stored && stored !== "application/octet-stream" && isMimeType(stored)) {
return stored;
}
const name = attachment.filename.toLowerCase();
const ext = Object.keys(MIME_BY_EXT).find((suffix) => name.endsWith(suffix));
return (ext && MIME_BY_EXT[ext]) || attachment.contentType || "application/octet-stream";
return (ext && MIME_BY_EXT[ext]) || stored || "application/octet-stream";
}
20 changes: 19 additions & 1 deletion pages/api/oss/[...key].ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Readable } from "node:stream";
import type { NextApiRequest, NextApiResponse } from "next";
import { getUserIdFromRequest } from "@/lib/auth";
import { ossClient } from "@/lib/ossClient";
Expand Down Expand Up @@ -26,8 +27,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}

try {
const { url } = await ossClient.sign({ objectKey });
// 签名 URL 上的 Content-Disposition 是 attachment。预览 iframe 跟着 302
// 会触发下载,所以 inline=1 时由我们把字节流回去并改成 inline。
if (req.query.inline === "1") {
const { url } = await ossClient.sign({ objectKey, expiresIn: 600 });
const upstream = await fetch(url);
if (!upstream.ok || !upstream.body) {
return res.status(502).json({ error: "Failed to fetch file" });
}
const contentType = (upstream.headers.get("content-type") || "application/octet-stream")
.split(";")[0]
.trim();
res.setHeader("Content-Type", contentType || "application/octet-stream");
res.setHeader("Content-Disposition", "inline");
res.setHeader("Cache-Control", "private, max-age=600");
Readable.fromWeb(upstream.body).pipe(res);
return;
}

const { url } = await ossClient.sign({ objectKey });
return res.redirect(302, url);
} catch (error: any) {
console.error("[OSS Download] Error:", {
Expand Down
20 changes: 20 additions & 0 deletions test/sessionAttachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import { test } from "node:test";
import {
attachmentPreviewUrl,
normalizeSessionAttachments,
sessionAttachmentMime,
} from "../lib/sessionAttachments.ts";
Expand Down Expand Up @@ -47,6 +48,25 @@ test("空值和非法输入都当成没有附件", () => {
assert.deepEqual(normalizeSessionAttachments({}), []);
});

test("会话里记下的是展示标签时,预览仍按扩展名识别 PDF", () => {
assert.equal(
sessionAttachmentMime({
filename: "双中心线试验圆角.pdf",
objectKey: "attachments/202609/双中心线试验圆角_1f7749.pdf",
contentType: "PDF",
}),
"application/pdf"
);
});

test("会话附件预览走 inline,避免对象存储的 attachment 头触发下载", () => {
assert.equal(
attachmentPreviewUrl("/api/oss/attachments/202609/a.pdf"),
"/api/oss/attachments/202609/a.pdf?inline=1"
);
assert.equal(attachmentPreviewUrl("https://example.com/a.pdf"), "https://example.com/a.pdf");
});

test("预览用 MIME:有有效类型就用,否则按扩展名补", () => {
assert.equal(
sessionAttachmentMime({
Expand Down
Loading