codexmobile-relay / server /app-upload.js
Codex
deploy: CodexMobile Relay
90f0300
Raw
History Blame Contribute Delete
3.97 kB
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { classifyUpload, parseHeaderValue, readBuffer, sanitizeFileName } from './http-utils.js';
import { MAX_UPLOAD_BYTES, MAX_VOICE_BYTES, UPLOAD_ROOT } from './app-config.js';
export function parseMultipartFile(buffer, contentType, fieldName = 'file') {
const boundary = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i)?.[1] || contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i)?.[2];
if (!boundary) {
throw new Error('Missing multipart boundary');
}
const acceptedNames = Array.isArray(fieldName) ? fieldName : [fieldName];
const boundaryBuffer = Buffer.from(`--${boundary}`);
let cursor = buffer.indexOf(boundaryBuffer);
while (cursor >= 0) {
cursor += boundaryBuffer.length;
if (buffer[cursor] === 45 && buffer[cursor + 1] === 45) {
break;
}
if (buffer[cursor] === 13 && buffer[cursor + 1] === 10) {
cursor += 2;
}
const nextBoundary = buffer.indexOf(boundaryBuffer, cursor);
if (nextBoundary < 0) {
break;
}
const headerEnd = buffer.indexOf(Buffer.from('\r\n\r\n'), cursor);
if (headerEnd < 0 || headerEnd > nextBoundary) {
cursor = nextBoundary;
continue;
}
const headers = buffer.slice(cursor, headerEnd).toString('utf8');
const disposition = headers.match(/^content-disposition:\s*(.+)$/im)?.[1] || '';
const name = parseHeaderValue(disposition, 'name');
const fileName = parseHeaderValue(disposition, 'filename');
const mimeType = headers.match(/^content-type:\s*(.+)$/im)?.[1]?.trim() || 'application/octet-stream';
if (acceptedNames.includes(name) && fileName) {
let contentEnd = nextBoundary;
if (buffer[contentEnd - 2] === 13 && buffer[contentEnd - 1] === 10) {
contentEnd -= 2;
}
return {
fileName: sanitizeFileName(fileName),
mimeType,
data: buffer.slice(headerEnd + 4, contentEnd)
};
}
cursor = nextBoundary;
}
throw new Error('No file field found');
}
export async function readVoiceUpload(req) {
const contentType = req.headers['content-type'] || '';
if (!contentType.toLowerCase().startsWith('multipart/form-data')) {
const error = new Error('multipart/form-data is required');
error.statusCode = 400;
throw error;
}
let body;
try {
body = await readBuffer(req, MAX_VOICE_BYTES);
} catch (error) {
const next = new Error(error.message === 'Upload too large' ? '音频超过 10MB' : '读取音频失败');
next.statusCode = error.message === 'Upload too large' ? 413 : 400;
throw next;
}
let part;
try {
part = parseMultipartFile(body, contentType, 'audio');
} catch {
const error = new Error('没有收到音频');
error.statusCode = 400;
throw error;
}
if (!part.data?.length) {
const error = new Error('没有收到音频');
error.statusCode = 400;
throw error;
}
if (!String(part.mimeType || '').toLowerCase().startsWith('audio/')) {
const error = new Error('音频格式不支持');
error.statusCode = 400;
throw error;
}
return part;
}
export async function saveUpload(req) {
const contentType = req.headers['content-type'] || '';
if (!contentType.toLowerCase().startsWith('multipart/form-data')) {
throw new Error('multipart/form-data is required');
}
const body = await readBuffer(req, MAX_UPLOAD_BYTES);
const part = parseMultipartFile(body, contentType);
const id = crypto.randomUUID();
const dateFolder = new Date().toISOString().slice(0, 10);
const filePath = path.join(UPLOAD_ROOT, dateFolder, `${id}-${part.fileName}`);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, part.data);
return {
id,
name: part.fileName,
size: part.data.length,
mimeType: part.mimeType,
path: filePath,
kind: classifyUpload(part.mimeType, part.data)
};
}