Spaces:
Running
Running
| 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) | |
| }; | |
| } | |