Files
makelore/electron/api/routes/coding-attachments.ts

110 lines
3.7 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from 'node:http';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
const SUPPORTED_IMAGE_MIMES = new Set([
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
]);
function contentType(req: IncomingMessage): string {
const value = req.headers['content-type'];
return (Array.isArray(value) ? value[0] : value)?.split(';', 1)[0]?.trim().toLowerCase() ?? '';
}
async function readBoundedBody(req: IncomingMessage): Promise<Uint8Array> {
const declared = Number(req.headers['content-length']);
if (Number.isFinite(declared) && (declared <= 0 || declared > MAX_ATTACHMENT_BYTES)) {
throw Object.assign(new Error('Attachment size is invalid'), { status: 413 });
}
const chunks: Buffer[] = [];
let byteLength = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
byteLength += buffer.byteLength;
if (byteLength > MAX_ATTACHMENT_BYTES) {
throw Object.assign(new Error('Attachment size is invalid'), { status: 413 });
}
chunks.push(buffer);
}
if (byteLength === 0) throw Object.assign(new Error('Attachment is empty'), { status: 400 });
return Buffer.concat(chunks);
}
function sendAttachmentError(res: ServerResponse, error: unknown): void {
const status = typeof (error as { status?: unknown })?.status === 'number'
? (error as { status: number }).status
: (error as NodeJS.ErrnoException)?.code === 'ENOENT'
? 404
: 500;
const message = status === 413
? '图片不能超过 16 MB。'
: status === 404
? '图片附件不存在。'
: status === 400
? '图片附件无效。'
: '图片附件暂时无法读取。';
sendJson(res, status, {
success: false,
code: status === 413
? 'CODING_ATTACHMENT_TOO_LARGE'
: status === 404
? 'CODING_ATTACHMENT_NOT_FOUND'
: status === 400
? 'CODING_ATTACHMENT_INVALID'
: 'CODING_ATTACHMENT_STORAGE_FAILED',
error: message,
});
}
export async function handleCodingAttachmentRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
const upload = url.pathname === '/api/coding/attachments' && req.method === 'POST';
const contentMatch = url.pathname.match(/^\/api\/coding\/attachments\/([^/]+)\/content$/);
const content = Boolean(contentMatch && req.method === 'GET');
if (!upload && !content) return false;
const attachments = ctx.codingProducts?.attachments;
if (!attachments) {
sendJson(res, 503, {
success: false,
code: 'CODING_CORE_UNAVAILABLE',
error: '本地编程服务暂时不可用。',
});
return true;
}
try {
if (upload) {
const mime = contentType(req);
if (!SUPPORTED_IMAGE_MIMES.has(mime)) {
throw Object.assign(new Error('Attachment MIME is invalid'), { status: 400 });
}
const attachment = await attachments.put(await readBoundedBody(req), mime);
sendJson(res, 201, attachment);
return true;
}
const attachmentId = decodeURIComponent(contentMatch?.[1] ?? '');
const record = await attachments.read(attachmentId);
if (!SUPPORTED_IMAGE_MIMES.has(record.mime)) {
throw Object.assign(new Error('Attachment MIME is invalid'), { status: 400 });
}
res.statusCode = 200;
res.setHeader('Content-Type', record.mime);
res.setHeader('Content-Length', String(record.byteLength));
res.setHeader('Cache-Control', 'private, no-store');
res.end(record.data);
return true;
} catch (error) {
sendAttachmentError(res, error);
return true;
}
}