feat: add server file attachment pipeline
This commit is contained in:
73
scripts/verify-attachment-upload-download.mjs
Normal file
73
scripts/verify-attachment-upload-download.mjs
Normal file
@@ -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");
|
||||
45
src/app/api/v1/attachments/[attachmentId]/download/route.ts
Normal file
45
src/app/api/v1/attachments/[attachmentId]/download/route.ts
Normal file
@@ -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),
|
||||
});
|
||||
}
|
||||
86
src/app/api/v1/projects/[projectId]/attachments/route.ts
Normal file
86
src/app/api/v1/projects/[projectId]/attachments/route.ts
Normal file
@@ -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`,
|
||||
});
|
||||
}
|
||||
76
src/lib/boss-attachments.ts
Normal file
76
src/lib/boss-attachments.ts
Normal file
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -1921,6 +1921,11 @@ function normalizeMessageAttachment(raw: Partial<MessageAttachment>): 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<UserAttachmentStorageConfig>,
|
||||
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";
|
||||
}
|
||||
|
||||
56
src/lib/boss-storage-server-file.ts
Normal file
56
src/lib/boss-storage-server-file.ts
Normal file
@@ -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<StoredAttachmentRecord> {
|
||||
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);
|
||||
}
|
||||
38
src/lib/boss-storage.ts
Normal file
38
src/lib/boss-storage.ts
Normal file
@@ -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<StoredAttachmentRecord>;
|
||||
}
|
||||
|
||||
const serverFileProvider: AttachmentStorageProvider = {
|
||||
backend: "server_file",
|
||||
async storeAttachment(params) {
|
||||
return storeServerFileAttachment(params);
|
||||
},
|
||||
};
|
||||
|
||||
export function getAttachmentStorageProvider(
|
||||
config: Pick<UserAttachmentStorageConfig, "mode" | "ossProvider">,
|
||||
) {
|
||||
if (config.mode === "server_file") {
|
||||
return serverFileProvider;
|
||||
}
|
||||
|
||||
throw new Error("ATTACHMENT_STORAGE_MODE_NOT_SUPPORTED");
|
||||
}
|
||||
Reference in New Issue
Block a user