fix: harden attachment analysis delivery

This commit is contained in:
kris
2026-03-29 17:10:58 +08:00
parent 88ab2d011a
commit 5fb75b50b4
7 changed files with 111 additions and 12 deletions

View File

@@ -199,6 +199,10 @@ try {
"queued task should carry attachment file name",
);
assert.ok(textUpload.analysisTask.attachmentDownloadUrl, "queued task should expose attachment download url");
assert.ok(
textUpload.analysisTask.attachmentDownloadUrl.startsWith(currentServer.baseUrl),
"queued task should use the current runtime origin for attachment download",
);
const promptDownloadUrlMatch = textUpload.analysisTask.executionPrompt.match(/downloadUrl:\s+(http[^\s]+)/);
assert.ok(promptDownloadUrlMatch, "execution prompt should include attachment download url");
const unauthDownloadResponse = await fetch(textUpload.analysisTask.attachmentDownloadUrl);

View File

@@ -53,14 +53,30 @@ export async function GET(
}
if (record.attachment.storageBackend === "aliyun_oss") {
const storageConfig = await getAttachmentStorageConfig(record.attachment.uploadedBy);
if (storageConfig.mode !== "oss" || storageConfig.ossProvider !== "aliyun_oss" || !storageConfig.aliyunOss) {
const aliyunConfig =
record.attachment.storageSnapshot?.provider === "aliyun_oss"
? {
enabled: true,
accessKeyId: record.attachment.storageSnapshot.accessKeyId,
accessKeySecretEncrypted: record.attachment.storageSnapshot.accessKeySecretEncrypted,
bucket: record.attachment.storageSnapshot.bucket,
endpoint: record.attachment.storageSnapshot.endpoint,
region: record.attachment.storageSnapshot.region,
prefix: record.attachment.storageSnapshot.prefix,
}
: null;
const storageConfig = aliyunConfig ? null : await getAttachmentStorageConfig(record.attachment.uploadedBy);
const resolvedConfig =
aliyunConfig ??
(storageConfig?.mode === "oss" &&
storageConfig.ossProvider === "aliyun_oss" &&
storageConfig.aliyunOss
? storageConfig.aliyunOss
: null);
if (!resolvedConfig) {
return NextResponse.json({ ok: false, message: "ATTACHMENT_STORAGE_CONFIG_NOT_FOUND" }, { status: 404 });
}
const signedUrl = await getAliyunOssSignedDownloadUrl(
storageConfig.aliyunOss,
record.attachment.storagePath,
);
const signedUrl = await getAliyunOssSignedDownloadUrl(resolvedConfig, record.attachment.storagePath);
return NextResponse.redirect(signedUrl, {
status: 307,
headers: buildAttachmentDownloadHeaders(record.attachment),

View File

@@ -6,6 +6,13 @@ import { queueAttachmentAnalysisTask } from "@/lib/boss-master-agent";
export const runtime = "nodejs";
function resolveRequestPublicBaseUrl(request: NextRequest) {
const protocol =
request.headers.get("x-forwarded-proto") ?? request.nextUrl.protocol.replace(/:$/, "") ?? "http";
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? request.nextUrl.host;
return `${protocol}://${host}`;
}
export async function POST(
request: NextRequest,
context: { params: Promise<{ projectId: string; attachmentId: string }> },
@@ -45,6 +52,7 @@ export async function POST(
requestedBy: session.displayName || "你",
requestedByAccount: session.account,
markProcessing: true,
publicBaseUrl: resolveRequestPublicBaseUrl(request),
});
return NextResponse.json({ ok: true, taskId: task.taskId, task });

View File

@@ -18,6 +18,13 @@ function randomToken(prefix: string) {
return `${prefix}-${randomBytes(4).toString("hex")}`;
}
function resolveRequestPublicBaseUrl(request: NextRequest) {
const protocol =
request.headers.get("x-forwarded-proto") ?? request.nextUrl.protocol.replace(/:$/, "") ?? "http";
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? request.nextUrl.host;
return `${protocol}://${host}`;
}
export async function POST(
request: NextRequest,
context: { params: Promise<{ projectId: string }> },
@@ -69,6 +76,21 @@ export async function POST(
attachmentKind,
storageBackend: stored.storageBackend,
storagePath: stored.storagePath,
storageSnapshot:
stored.storageBackend === "aliyun_oss" &&
storageConfig.mode === "oss" &&
storageConfig.ossProvider === "aliyun_oss" &&
storageConfig.aliyunOss
? {
provider: "aliyun_oss",
accessKeyId: storageConfig.aliyunOss.accessKeyId,
accessKeySecretEncrypted: storageConfig.aliyunOss.accessKeySecretEncrypted,
bucket: storageConfig.aliyunOss.bucket,
endpoint: storageConfig.aliyunOss.endpoint,
region: storageConfig.aliyunOss.region,
prefix: storageConfig.aliyunOss.prefix,
}
: undefined,
previewAvailable: attachmentKind === "image" || attachmentKind === "video" || attachmentKind === "pdf",
uploadedAt: new Date().toISOString(),
uploadedBy: session.account,
@@ -90,6 +112,7 @@ export async function POST(
requestMessageId: message.id,
requestedBy: session.displayName || "你",
requestedByAccount: session.account,
publicBaseUrl: resolveRequestPublicBaseUrl(request),
});
}

View File

@@ -7,6 +7,7 @@ import type {
const LARGE_ATTACHMENT_THRESHOLD_BYTES = 20 * 1024 * 1024;
const MAX_ATTACHMENT_TEXT_EXCERPT_CHARS = 12_000;
const MAX_ATTACHMENT_TEXT_EXCERPT_SOURCE_BYTES = 2 * 1024 * 1024;
function extensionOf(fileName: string) {
return path.extname(fileName).toLowerCase();
@@ -83,6 +84,12 @@ export function canExtractAttachmentText(attachment: Pick<MessageAttachment, "at
);
}
export function canInlineAttachmentText(
attachment: Pick<MessageAttachment, "attachmentKind" | "mimeType" | "fileSizeBytes">,
) {
return canExtractAttachmentText(attachment) && attachment.fileSizeBytes <= MAX_ATTACHMENT_TEXT_EXCERPT_SOURCE_BYTES;
}
export function extractAttachmentTextExcerpt(buffer: Buffer | Uint8Array) {
const normalized = Buffer.from(buffer)
.toString("utf8")

View File

@@ -41,6 +41,15 @@ export interface MessageAttachment {
attachmentKind: AttachmentKind;
storageBackend: AttachmentStorageBackend;
storagePath: string;
storageSnapshot?: {
provider: "aliyun_oss";
accessKeyId: string;
accessKeySecretEncrypted: string;
bucket: string;
endpoint: string;
region: string;
prefix?: string;
};
previewAvailable: boolean;
uploadedAt: string;
uploadedBy: string;
@@ -1920,6 +1929,18 @@ function normalizeMessageAttachment(raw: Partial<MessageAttachment>): MessageAtt
attachmentKind: raw.attachmentKind ?? "binary",
storageBackend: raw.storageBackend ?? "server_file",
storagePath: raw.storagePath ?? "",
storageSnapshot:
raw.storageSnapshot?.provider === "aliyun_oss"
? {
provider: "aliyun_oss",
accessKeyId: raw.storageSnapshot.accessKeyId ?? "",
accessKeySecretEncrypted: raw.storageSnapshot.accessKeySecretEncrypted ?? "",
bucket: raw.storageSnapshot.bucket ?? "",
endpoint: raw.storageSnapshot.endpoint ?? "",
region: raw.storageSnapshot.region ?? "",
prefix: raw.storageSnapshot.prefix,
}
: undefined,
previewAvailable: raw.previewAvailable ?? false,
uploadedAt: raw.uploadedAt ?? nowIso(),
uploadedBy: raw.uploadedBy ?? "system",

View File

@@ -13,7 +13,7 @@ import {
updateAttachmentAnalysisResult,
updateAiAccountHealth,
} from "@/lib/boss-data";
import { canExtractAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments";
import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments";
import { readAliyunOssObjectBuffer } from "@/lib/boss-storage-aliyun-oss";
import { readServerFileAttachmentBuffer } from "@/lib/boss-storage-server-file";
@@ -236,14 +236,33 @@ async function buildAttachmentAnalysisContext(params: {
const attachment = params.attachment;
let excerpt = "";
try {
if (canExtractAttachmentText(attachment)) {
if (canInlineAttachmentText(attachment)) {
let buffer: Buffer | Uint8Array = Buffer.alloc(0);
if (attachment.storageBackend === "server_file") {
buffer = await readServerFileAttachmentBuffer(attachment.storagePath);
} else if (attachment.storageBackend === "aliyun_oss") {
const config = await getAttachmentStorageConfig(attachment.uploadedBy);
if (config.mode === "oss" && config.ossProvider === "aliyun_oss" && config.aliyunOss) {
buffer = await readAliyunOssObjectBuffer(config.aliyunOss, attachment.storagePath);
if (attachment.storageSnapshot?.provider === "aliyun_oss") {
buffer = await readAliyunOssObjectBuffer(
{
enabled: true,
accessKeyId: attachment.storageSnapshot.accessKeyId,
accessKeySecretEncrypted: attachment.storageSnapshot.accessKeySecretEncrypted,
bucket: attachment.storageSnapshot.bucket,
endpoint: attachment.storageSnapshot.endpoint,
region: attachment.storageSnapshot.region,
prefix: attachment.storageSnapshot.prefix,
},
attachment.storagePath,
);
} else {
const currentConfig = await getAttachmentStorageConfig(attachment.uploadedBy);
if (
currentConfig.mode === "oss" &&
currentConfig.ossProvider === "aliyun_oss" &&
currentConfig.aliyunOss
) {
buffer = await readAliyunOssObjectBuffer(currentConfig.aliyunOss, attachment.storagePath);
}
}
}
excerpt = extractAttachmentTextExcerpt(buffer);
@@ -311,6 +330,7 @@ export async function queueAttachmentAnalysisTask(params: {
requestedBy: string;
requestedByAccount: string;
markProcessing?: boolean;
publicBaseUrl?: string;
}) {
const record = await getProjectAttachment(params.projectId, params.attachmentId);
if (!record) {
@@ -322,7 +342,7 @@ export async function queueAttachmentAnalysisTask(params: {
const attachmentDownloadToken = randomBytes(12).toString("hex");
const attachmentDownloadExpiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
const attachmentDownloadUrl =
`${resolveBossPublicBaseUrl()}/api/v1/attachments/${record.attachment.attachmentId}/download` +
`${params.publicBaseUrl?.trim() || resolveBossPublicBaseUrl()}/api/v1/attachments/${record.attachment.attachmentId}/download` +
`?taskId=${taskId}&token=${attachmentDownloadToken}`;
const attachmentContext = await buildAttachmentAnalysisContext({
attachment: record.attachment,