diff --git a/scripts/verify-attachment-upload-download.mjs b/scripts/verify-attachment-upload-download.mjs new file mode 100644 index 0000000..21b892c --- /dev/null +++ b/scripts/verify-attachment-upload-download.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const baseUrl = process.env.BOSS_TEST_BASE_URL || "http://127.0.0.1:3000"; +const repoRoot = process.cwd(); +const readmePath = path.join(repoRoot, "README.md"); +const readmeBytes = await readFile(readmePath); + +const loginResponse = await fetch(`${baseUrl}/api/auth/login`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({}), +}); + +if (!loginResponse.ok) { + throw new Error(`LOGIN_FAILED:${loginResponse.status}`); +} + +const setCookie = loginResponse.headers.get("set-cookie") || ""; +const cookie = setCookie.split(";")[0]; +if (!cookie) { + throw new Error("COOKIE_MISSING"); +} + +const uploadForm = new FormData(); +uploadForm.append("file", new File([readmeBytes], "README.md", { type: "text/markdown" })); + +const uploadResponse = await fetch(`${baseUrl}/api/v1/projects/boss-console/attachments`, { + method: "POST", + headers: { + cookie, + }, + body: uploadForm, +}); + +if (!uploadResponse.ok) { + throw new Error(`UPLOAD_FAILED:${uploadResponse.status}`); +} + +const uploadJson = await uploadResponse.json(); +if (!uploadJson.ok || !uploadJson.attachment?.attachmentId || !uploadJson.downloadUrl) { + throw new Error("UPLOAD_RESPONSE_INVALID"); +} +if (uploadJson.message?.kind !== "attachment") { + throw new Error("ATTACHMENT_MESSAGE_KIND_INVALID"); +} +if (!Array.isArray(uploadJson.message?.attachments) || uploadJson.message.attachments.length !== 1) { + throw new Error("ATTACHMENT_PAYLOAD_INVALID"); +} + +const downloadResponse = await fetch(`${baseUrl}${uploadJson.downloadUrl}`, { + headers: { + cookie, + }, +}); + +if (!downloadResponse.ok) { + throw new Error(`DOWNLOAD_FAILED:${downloadResponse.status}`); +} + +const downloadedBytes = Buffer.from(await downloadResponse.arrayBuffer()); +if (Buffer.compare(downloadedBytes, readmeBytes) !== 0) { + throw new Error("DOWNLOADED_CONTENT_MISMATCH"); +} + +if ((downloadResponse.headers.get("content-disposition") || "").indexOf("README.md") === -1) { + throw new Error("DOWNLOAD_HEADERS_INVALID"); +} + +console.log("OK"); diff --git a/src/app/api/v1/attachments/[attachmentId]/download/route.ts b/src/app/api/v1/attachments/[attachmentId]/download/route.ts new file mode 100644 index 0000000..ae174aa --- /dev/null +++ b/src/app/api/v1/attachments/[attachmentId]/download/route.ts @@ -0,0 +1,45 @@ +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import { Readable } from "node:stream"; +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { getAttachmentById } from "@/lib/boss-data"; +import { buildAttachmentDownloadHeaders } from "@/lib/boss-attachments"; +import { resolveServerFileAttachmentAbsolutePath } from "@/lib/boss-storage-server-file"; + +export const runtime = "nodejs"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ attachmentId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { attachmentId } = await context.params; + const record = await getAttachmentById(attachmentId); + if (!record) { + return NextResponse.json({ ok: false, message: "ATTACHMENT_NOT_FOUND" }, { status: 404 }); + } + + if (record.attachment.storageBackend !== "server_file") { + return NextResponse.json( + { ok: false, message: "UNSUPPORTED_ATTACHMENT_STORAGE_BACKEND" }, + { status: 501 }, + ); + } + + const absolutePath = resolveServerFileAttachmentAbsolutePath(record.attachment.storagePath); + try { + await stat(absolutePath); + } catch { + return NextResponse.json({ ok: false, message: "ATTACHMENT_FILE_NOT_FOUND" }, { status: 404 }); + } + + const stream = createReadStream(absolutePath); + return new NextResponse(Readable.toWeb(stream) as BodyInit, { + headers: buildAttachmentDownloadHeaders(record.attachment), + }); +} diff --git a/src/app/api/v1/projects/[projectId]/attachments/route.ts b/src/app/api/v1/projects/[projectId]/attachments/route.ts new file mode 100644 index 0000000..fa9f7df --- /dev/null +++ b/src/app/api/v1/projects/[projectId]/attachments/route.ts @@ -0,0 +1,86 @@ +import { randomBytes } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { + appendAttachmentMessage, + getAttachmentStorageConfig, + readState, + type MessageAttachment, +} from "@/lib/boss-data"; +import { detectAttachmentKind, resolveAttachmentAnalysisState } from "@/lib/boss-attachments"; +import { getAttachmentStorageProvider } from "@/lib/boss-storage"; + +export const runtime = "nodejs"; + +function randomToken(prefix: string) { + return `${prefix}-${randomBytes(4).toString("hex")}`; +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId } = await context.params; + const state = await readState(); + const project = state.projects.find((item) => item.id === projectId); + if (!project) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + + const form = await request.formData(); + const file = form.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ ok: false, message: "FILE_REQUIRED" }, { status: 400 }); + } + + const bytes = Buffer.from(await file.arrayBuffer()); + const attachmentId = randomToken("att"); + const messageId = randomToken("msg"); + const fileName = file.name || "attachment"; + const mimeType = file.type || "application/octet-stream"; + const attachmentKind = detectAttachmentKind(fileName, mimeType); + const analysisState = resolveAttachmentAnalysisState(attachmentKind, bytes.byteLength); + const storageConfig = await getAttachmentStorageConfig(session.account); + const storageProvider = getAttachmentStorageProvider(storageConfig); + const stored = await storageProvider.storeAttachment({ + account: session.account, + messageId, + attachmentId, + fileName, + mimeType, + buffer: bytes, + }); + + const attachment: MessageAttachment = { + attachmentId, + fileName, + mimeType, + fileSizeBytes: bytes.byteLength, + attachmentKind, + storageBackend: stored.storageBackend, + storagePath: stored.storagePath, + previewAvailable: attachmentKind === "image" || attachmentKind === "video" || attachmentKind === "pdf", + uploadedAt: new Date().toISOString(), + uploadedBy: session.account, + analysisState, + }; + + const message = await appendAttachmentMessage({ + projectId, + sender: "user", + senderLabel: session.displayName || "你", + attachment, + }); + + return NextResponse.json({ + ok: true, + attachment, + message, + downloadUrl: `/api/v1/attachments/${attachmentId}/download`, + }); +} diff --git a/src/lib/boss-attachments.ts b/src/lib/boss-attachments.ts new file mode 100644 index 0000000..90c8b08 --- /dev/null +++ b/src/lib/boss-attachments.ts @@ -0,0 +1,76 @@ +import path from "node:path"; +import type { + AttachmentAnalysisState, + AttachmentKind, + MessageAttachment, +} from "@/lib/boss-data"; + +const LARGE_ATTACHMENT_THRESHOLD_BYTES = 20 * 1024 * 1024; + +function extensionOf(fileName: string) { + return path.extname(fileName).toLowerCase(); +} + +export function sanitizeFileName(fileName: string) { + const normalized = path + .basename(fileName || "attachment") + .normalize("NFKC") + .replace(/[\u0000-\u001f\u007f]/g, "") + .replace(/[<>:"|?*]+/g, "-") + .replace(/[\\/]+/g, "-") + .replace(/\s+/g, " ") + .trim() + .replace(/^\.+/, ""); + return normalized || "attachment"; +} + +export function detectAttachmentKind(fileName: string, mimeType: string): AttachmentKind { + const normalizedMime = (mimeType || "").toLowerCase(); + const ext = extensionOf(fileName); + + if (normalizedMime.startsWith("image/")) return "image"; + if (normalizedMime.startsWith("video/")) return "video"; + if (normalizedMime === "application/pdf" || ext === ".pdf") return "pdf"; + if (normalizedMime.startsWith("text/")) return "text"; + if ( + normalizedMime.includes("officedocument") || + normalizedMime.includes("msword") || + normalizedMime.includes("spreadsheet") || + normalizedMime.includes("presentation") || + [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp"].includes(ext) + ) { + return "office"; + } + if ( + [".txt", ".md", ".csv", ".log", ".json", ".yaml", ".yml", ".xml", ".html", ".htm"].includes( + ext, + ) + ) { + return "text"; + } + return "binary"; +} + +export function resolveAttachmentAnalysisState( + kind: AttachmentKind, + fileSizeBytes: number, +): AttachmentAnalysisState { + if (fileSizeBytes > LARGE_ATTACHMENT_THRESHOLD_BYTES) { + return "ready_manual"; + } + if (kind === "image" || kind === "pdf" || kind === "text") { + return "queued_auto"; + } + return "ready_manual"; +} + +export function buildAttachmentDownloadHeaders(attachment: MessageAttachment) { + const safeName = sanitizeFileName(attachment.fileName); + const encodedName = encodeURIComponent(safeName); + return { + "Content-Type": attachment.mimeType || "application/octet-stream", + "Content-Disposition": `inline; filename="${safeName}"; filename*=UTF-8''${encodedName}`, + "Cache-Control": "private, no-store, max-age=0", + "X-Content-Type-Options": "nosniff", + }; +} diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index fd324f0..aa3b089 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -1921,6 +1921,11 @@ function normalizeMessageAttachment(raw: Partial): MessageAtt }; } +function buildAttachmentMessageBody(attachment: MessageAttachment) { + const sizeKb = Math.max(1, Math.round(attachment.fileSizeBytes / 1024)); + return `已发送附件:${attachment.fileName}(${attachment.attachmentKind},${sizeKb} KB)`; +} + function normalizeAttachmentStorageConfig( raw: Partial, fallback: UserAttachmentStorageConfig, @@ -4382,6 +4387,7 @@ export async function appendProjectMessage(payload: { senderLabel?: string; body?: string; kind?: MessageKind; + attachments?: MessageAttachment[]; }) { const message = await mutateState((state) => { const project = state.projects.find((item) => item.id === payload.projectId); @@ -4391,22 +4397,43 @@ export async function appendProjectMessage(payload: { if (!body && payload.kind === "text") { throw new Error("MESSAGE_BODY_REQUIRED"); } + if (payload.kind === "attachment" && (!payload.attachments || payload.attachments.length === 0)) { + throw new Error("ATTACHMENT_REQUIRED"); + } + const firstAttachment = payload.attachments?.[0]; const message: Message = { id: randomToken("msg"), sender: payload.sender ?? "user", senderLabel: payload.senderLabel ?? "你", body: body ?? - (payload.kind === "voice_intent" - ? "已提交语音转文字请求,等待主 Agent 记录语音摘要。" - : payload.kind === "image_intent" - ? "已登记图片证据上传请求,等待对象存储通道接入。" - : payload.kind === "video_intent" - ? "已登记视频证据上传请求,等待对象存储通道接入。" - : "已提交消息。"), + (payload.kind === "attachment" + ? buildAttachmentMessageBody( + firstAttachment ?? { + attachmentId: randomToken("att"), + fileName: "附件", + mimeType: "application/octet-stream", + fileSizeBytes: 0, + attachmentKind: "binary", + storageBackend: "server_file", + storagePath: "", + previewAvailable: false, + uploadedAt: nowIso(), + uploadedBy: payload.senderLabel ?? "你", + analysisState: "not_applicable", + }, + ) + : payload.kind === "voice_intent" + ? "已提交语音转文字请求,等待主 Agent 记录语音摘要。" + : payload.kind === "image_intent" + ? "已登记图片证据上传请求,等待对象存储通道接入。" + : payload.kind === "video_intent" + ? "已登记视频证据上传请求,等待对象存储通道接入。" + : "已提交消息。"), sentAt: nowIso(), kind: payload.kind ?? "text", + attachments: payload.attachments?.map((attachment) => normalizeMessageAttachment(attachment)), }; project.messages.push(message); @@ -4421,10 +4448,55 @@ export async function appendProjectMessage(payload: { return message; } +export async function appendAttachmentMessage(payload: { + projectId: string; + sender?: MessageSender; + senderLabel?: string; + attachment: MessageAttachment; + body?: string; +}) { + return appendProjectMessage({ + projectId: payload.projectId, + sender: payload.sender ?? "user", + senderLabel: payload.senderLabel ?? "你", + body: payload.body ?? buildAttachmentMessageBody(payload.attachment), + kind: "attachment", + attachments: [payload.attachment], + }); +} + function findProjectMessage(project: Project, messageId: string) { return project.messages.find((message) => message.id === messageId) ?? null; } +export function findProjectAttachment( + project: Project, + attachmentId: string, +): { message: Message; attachment: MessageAttachment } | null { + for (const message of project.messages) { + const attachment = message.attachments?.find((item) => item.attachmentId === attachmentId); + if (attachment) { + return { message, attachment }; + } + } + return null; +} + +export async function getAttachmentById(attachmentId: string) { + const state = await readState(); + for (const project of state.projects) { + const match = findProjectAttachment(project, attachmentId); + if (match) { + return { + project, + message: match.message, + attachment: match.attachment, + }; + } + } + return null; +} + function requiresForwardApproval(source: Project, target: Project) { return source.collaborationMode === "approval_required" && target.id !== "master-agent"; } diff --git a/src/lib/boss-storage-server-file.ts b/src/lib/boss-storage-server-file.ts new file mode 100644 index 0000000..0e117e9 --- /dev/null +++ b/src/lib/boss-storage-server-file.ts @@ -0,0 +1,56 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { StoreAttachmentParams, StoredAttachmentRecord } from "@/lib/boss-storage"; +import { sanitizeFileName } from "@/lib/boss-attachments"; + +function detectRuntimeRoot(startDir: string) { + let current = startDir; + while (true) { + if ( + path.basename(current) === "boss" && + path.basename(path.dirname(current)) === "code" + ) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return startDir; + } + current = parent; + } +} + +function resolveRuntimeRoot() { + if (process.env.BOSS_RUNTIME_ROOT?.trim()) { + return path.resolve(process.env.BOSS_RUNTIME_ROOT); + } + if (process.env.BOSS_STATE_FILE?.trim()) { + return path.dirname(path.dirname(path.resolve(process.env.BOSS_STATE_FILE))); + } + return detectRuntimeRoot(process.cwd()); +} + +export async function storeServerFileAttachment( + params: StoreAttachmentParams, +): Promise { + const now = new Date(); + const relativePath = path.join( + "data", + "uploads", + params.account, + String(now.getUTCFullYear()), + String(now.getUTCMonth() + 1).padStart(2, "0"), + `${params.messageId}-${sanitizeFileName(params.fileName)}`, + ); + const absolutePath = path.join(resolveRuntimeRoot(), relativePath); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, params.buffer); + return { + storageBackend: "server_file", + storagePath: relativePath, + }; +} + +export function resolveServerFileAttachmentAbsolutePath(storagePath: string) { + return path.join(resolveRuntimeRoot(), storagePath); +} diff --git a/src/lib/boss-storage.ts b/src/lib/boss-storage.ts new file mode 100644 index 0000000..c05f4b8 --- /dev/null +++ b/src/lib/boss-storage.ts @@ -0,0 +1,38 @@ +import type { AttachmentStorageBackend, UserAttachmentStorageConfig } from "@/lib/boss-data"; +import { storeServerFileAttachment } from "@/lib/boss-storage-server-file"; + +export interface StoreAttachmentParams { + account: string; + messageId: string; + attachmentId: string; + fileName: string; + mimeType: string; + buffer: Buffer; +} + +export interface StoredAttachmentRecord { + storageBackend: AttachmentStorageBackend; + storagePath: string; +} + +export interface AttachmentStorageProvider { + backend: AttachmentStorageBackend; + storeAttachment(params: StoreAttachmentParams): Promise; +} + +const serverFileProvider: AttachmentStorageProvider = { + backend: "server_file", + async storeAttachment(params) { + return storeServerFileAttachment(params); + }, +}; + +export function getAttachmentStorageProvider( + config: Pick, +) { + if (config.mode === "server_file") { + return serverFileProvider; + } + + throw new Error("ATTACHMENT_STORAGE_MODE_NOT_SUPPORTED"); +}