158 lines
5.2 KiB
TypeScript
158 lines
5.2 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 ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
|
|
const SUPPORTED_IMAGE_MIMES = new Set([
|
|
'image/png',
|
|
'image/jpeg',
|
|
'image/webp',
|
|
'image/gif',
|
|
]);
|
|
|
|
function matchesImageSignature(data: Uint8Array, mime: string): boolean {
|
|
if (mime === 'image/png') {
|
|
return data.length >= 8
|
|
&& data[0] === 0x89
|
|
&& data[1] === 0x50
|
|
&& data[2] === 0x4e
|
|
&& data[3] === 0x47
|
|
&& data[4] === 0x0d
|
|
&& data[5] === 0x0a
|
|
&& data[6] === 0x1a
|
|
&& data[7] === 0x0a;
|
|
}
|
|
if (mime === 'image/jpeg') {
|
|
return data.length >= 3
|
|
&& data[0] === 0xff
|
|
&& data[1] === 0xd8
|
|
&& data[2] === 0xff;
|
|
}
|
|
if (mime === 'image/gif') {
|
|
if (data.length < 6) return false;
|
|
const header = Buffer.from(data.subarray(0, 6)).toString('ascii');
|
|
return header === 'GIF87a' || header === 'GIF89a';
|
|
}
|
|
if (mime === 'image/webp') {
|
|
return data.length >= 12
|
|
&& Buffer.from(data.subarray(0, 4)).toString('ascii') === 'RIFF'
|
|
&& Buffer.from(data.subarray(8, 12)).toString('ascii') === 'WEBP';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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 > 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,
|
|
operation: 'upload' | 'content',
|
|
): void {
|
|
const explicitStatus = typeof (error as { status?: unknown })?.status === 'number'
|
|
? (error as { status: number }).status
|
|
: null;
|
|
const status = explicitStatus
|
|
?? (operation === 'content' || (error as NodeJS.ErrnoException)?.code === 'ENOENT' ? 404 : 500);
|
|
const message = status === 413
|
|
? '图片不能超过 16 MB。'
|
|
: status === 404
|
|
? '图片附件不存在。'
|
|
: status === 400
|
|
? '图片附件无效。'
|
|
: operation === 'upload'
|
|
? '本地数据写入失败,请检查存储后重试。'
|
|
: '图片附件暂时无法读取。';
|
|
sendJson(res, status, {
|
|
success: false,
|
|
code: status === 400 || status === 413
|
|
? 'CODING_ATTACHMENT_INVALID'
|
|
: status === 404
|
|
? 'CODING_ATTACHMENT_NOT_FOUND'
|
|
: 'CODING_STORAGE_WRITE_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 body = await readBoundedBody(req);
|
|
if (!matchesImageSignature(body, mime)) {
|
|
throw Object.assign(new Error('Attachment body is not the declared image type'), { status: 400 });
|
|
}
|
|
const attachment = await attachments.put(body, mime);
|
|
sendJson(res, 201, attachment);
|
|
return true;
|
|
}
|
|
|
|
let attachmentId: string;
|
|
try {
|
|
attachmentId = decodeURIComponent(contentMatch?.[1] ?? '');
|
|
} catch {
|
|
throw Object.assign(new Error('Attachment id is invalid'), { status: 400 });
|
|
}
|
|
if (!ATTACHMENT_ID_PATTERN.test(attachmentId)) {
|
|
throw Object.assign(new Error('Attachment id is invalid'), { status: 400 });
|
|
}
|
|
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, upload ? 'upload' : 'content');
|
|
return true;
|
|
}
|
|
}
|