From 02334b7663b10d28f2327c07f5317f6d8fe651c9 Mon Sep 17 00:00:00 2001 From: seaHi Date: Fri, 7 Aug 2026 12:53:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=9C=8D=E5=8A=A1=E7=AB=AF=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E4=B8=8E=E5=AE=89=E5=85=A8=E5=AD=98=E5=82=A8=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/server/uploads.test.ts | 84 ++++++++++++ src/lib/server/uploads.ts | 181 +++++++++++++++++++++++++ src/routes/admin/api/upload/+server.ts | 81 ++++------- src/routes/admin/files/+page.server.ts | 170 +++++++++++------------ src/routes/admin/files/+page.svelte | 117 ++++++++++------ 5 files changed, 443 insertions(+), 190 deletions(-) create mode 100644 src/lib/server/uploads.test.ts create mode 100644 src/lib/server/uploads.ts diff --git a/src/lib/server/uploads.test.ts b/src/lib/server/uploads.test.ts new file mode 100644 index 0000000..dc7b6a3 --- /dev/null +++ b/src/lib/server/uploads.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; +import { parseAndValidateUpload, storeUpload, UploadValidationError } from './uploads'; + +function uploadRequest(file: File): Request { + const formData = new FormData(); + formData.set('file', file); + return new Request('https://example.com/admin/api/upload', { + method: 'POST', + body: formData + }); +} + +describe('parseAndValidateUpload', () => { + it('accepts a PNG whose name, MIME type and signature agree', async () => { + const file = new File( + [new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], + 'image.png', + { type: 'image/png' } + ); + await expect(parseAndValidateUpload(uploadRequest(file))).resolves.toMatchObject({ + name: 'image.png', + type: 'image/png' + }); + }); + + it('rejects executable SVG uploads', async () => { + const file = new File([''], 'attack.svg', { + type: 'image/svg+xml' + }); + await expect(parseAndValidateUpload(uploadRequest(file))).rejects.toBeInstanceOf( + UploadValidationError + ); + }); + + it('rejects spoofed MIME types and mismatched extensions', async () => { + const fakePng = new File(['not a png'], 'image.png', { type: 'image/png' }); + await expect(parseAndValidateUpload(uploadRequest(fakePng))).rejects.toThrow( + '文件内容与声明的类型不匹配' + ); + + const wrongExtension = new File( + [new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a])], + 'image.jpg', + { type: 'image/png' } + ); + await expect(parseAndValidateUpload(uploadRequest(wrongExtension))).rejects.toThrow( + '文件扩展名与声明的类型不匹配' + ); + }); + + it('rejects an oversized multipart body before parsing it', async () => { + const request = new Request('https://example.com/admin/api/upload', { + method: 'POST', + headers: { + 'content-type': 'multipart/form-data; boundary=test', + 'content-length': String(11 * 1024 * 1024) + }, + body: 'small placeholder' + }); + await expect(parseAndValidateUpload(request)).rejects.toMatchObject({ status: 413 }); + }); +}); + +describe('storeUpload', () => { + it('removes the R2 object when the D1 metadata write fails', async () => { + const file = new File( + [new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], + 'image.png', + { type: 'image/png' } + ); + const run = vi.fn().mockRejectedValue(new Error('D1 insert failed')); + const db = { + prepare: vi.fn(() => ({ bind: vi.fn(() => ({ run })) })) + } as unknown as D1Database; + const r2 = { + put: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined) + } as unknown as R2Bucket; + + await expect(storeUpload(db, r2, file)).rejects.toThrow('D1 insert failed'); + expect(r2.put).toHaveBeenCalledOnce(); + expect(r2.delete).toHaveBeenCalledWith(expect.stringMatching(/^uploads\//)); + }); +}); diff --git a/src/lib/server/uploads.ts b/src/lib/server/uploads.ts new file mode 100644 index 0000000..fe1e98d --- /dev/null +++ b/src/lib/server/uploads.ts @@ -0,0 +1,181 @@ +const MAX_FILE_SIZE = 10 * 1024 * 1024; +const MAX_MULTIPART_SIZE = MAX_FILE_SIZE + 512 * 1024; + +type UploadType = { + extensions: readonly string[]; + canonicalExtension: string; + matchesSignature: (bytes: Uint8Array) => boolean; +}; + +const ascii = (bytes: Uint8Array, start: number, value: string): boolean => + [...value].every((character, index) => bytes[start + index] === character.charCodeAt(0)); + +const UPLOAD_TYPES: Record = { + 'image/jpeg': { + extensions: ['jpg', 'jpeg'], + canonicalExtension: 'jpg', + matchesSignature: (bytes) => bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff + }, + 'image/png': { + extensions: ['png'], + canonicalExtension: 'png', + matchesSignature: (bytes) => + bytes[0] === 0x89 && ascii(bytes, 1, 'PNG') && bytes[4] === 0x0d && bytes[5] === 0x0a + }, + 'image/gif': { + extensions: ['gif'], + canonicalExtension: 'gif', + matchesSignature: (bytes) => ascii(bytes, 0, 'GIF87a') || ascii(bytes, 0, 'GIF89a') + }, + 'image/webp': { + extensions: ['webp'], + canonicalExtension: 'webp', + matchesSignature: (bytes) => ascii(bytes, 0, 'RIFF') && ascii(bytes, 8, 'WEBP') + }, + 'application/pdf': { + extensions: ['pdf'], + canonicalExtension: 'pdf', + matchesSignature: (bytes) => ascii(bytes, 0, '%PDF-') + }, + 'application/zip': { + extensions: ['zip'], + canonicalExtension: 'zip', + matchesSignature: (bytes) => + bytes[0] === 0x50 && + bytes[1] === 0x4b && + ((bytes[2] === 0x03 && bytes[3] === 0x04) || + (bytes[2] === 0x05 && bytes[3] === 0x06) || + (bytes[2] === 0x07 && bytes[3] === 0x08)) + }, + 'audio/mpeg': { + extensions: ['mp3'], + canonicalExtension: 'mp3', + matchesSignature: (bytes) => + ascii(bytes, 0, 'ID3') || (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0) + }, + 'video/mp4': { + extensions: ['mp4'], + canonicalExtension: 'mp4', + matchesSignature: (bytes) => ascii(bytes, 4, 'ftyp') + } +}; + +export class UploadValidationError extends Error { + constructor( + message: string, + readonly status = 400 + ) { + super(message); + this.name = 'UploadValidationError'; + } +} + +async function readBoundedFormData(request: Request): Promise { + const contentType = request.headers.get('content-type'); + if (!contentType?.toLowerCase().startsWith('multipart/form-data')) { + throw new UploadValidationError('上传请求格式无效'); + } + + const contentLength = Number(request.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > MAX_MULTIPART_SIZE) { + throw new UploadValidationError('文件大小不能超过 10MB', 413); + } + + if (!request.body) throw new UploadValidationError('没有提供有效的文件'); + + const chunks: Uint8Array[] = []; + const reader = request.body.getReader(); + let total = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_MULTIPART_SIZE) { + await reader.cancel('Upload exceeds the configured size limit'); + throw new UploadValidationError('文件大小不能超过 10MB', 413); + } + chunks.push(value); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + + try { + return await new Response(new Blob([body.buffer]), { + headers: { 'content-type': contentType } + }).formData(); + } catch { + throw new UploadValidationError('无法解析上传请求'); + } +} + +export async function parseAndValidateUpload(request: Request): Promise { + const formData = await readBoundedFormData(request); + const value = formData.get('file'); + if (!(value instanceof File)) throw new UploadValidationError('没有提供有效的文件'); + if (value.size === 0) throw new UploadValidationError('不能上传空文件'); + if (value.size > MAX_FILE_SIZE) throw new UploadValidationError('文件大小不能超过 10MB', 413); + + const mimeType = value.type.trim().toLowerCase(); + const uploadType = UPLOAD_TYPES[mimeType]; + if (!uploadType) throw new UploadValidationError('不支持此文件类型'); + + const extension = value.name.split('.').pop()?.toLowerCase() ?? ''; + if (!uploadType.extensions.includes(extension)) { + throw new UploadValidationError('文件扩展名与声明的类型不匹配'); + } + + const signature = new Uint8Array(await value.slice(0, 16).arrayBuffer()); + if (!uploadType.matchesSignature(signature)) { + throw new UploadValidationError('文件内容与声明的类型不匹配'); + } + + return value; +} + +export type StoredUpload = { + filename: string; + key: string; + mimeType: string; + size: number; +}; + +export async function storeUpload(db: D1Database, r2: R2Bucket, file: File): Promise { + const mimeType = file.type.trim().toLowerCase(); + const extension = UPLOAD_TYPES[mimeType].canonicalExtension; + const compactTime = new Date().toISOString().slice(0, 19).replace(/\D/g, ''); + const date = compactTime.slice(0, 8); + const time = compactTime.slice(8, 14); + const yearMonth = `${compactTime.slice(0, 4)}-${compactTime.slice(4, 6)}`; + const randomId = crypto.randomUUID().replaceAll('-', '').slice(0, 12); + const filename = `${date}_${time}.${extension}`; + const key = `uploads/${yearMonth}/${date}_${time}_${randomId}.${extension}`; + + await r2.put(key, file.stream(), { httpMetadata: { contentType: mimeType } }); + + try { + await db + .prepare('INSERT INTO attachments (filename, key, mime_type, size) VALUES (?, ?, ?, ?)') + .bind(filename, key, mimeType, file.size) + .run(); + } catch (cause) { + try { + await r2.delete(key); + } catch (cleanupCause) { + console.error( + JSON.stringify({ + message: 'Failed to remove an R2 object after attachment metadata insert failed', + key, + error: cleanupCause instanceof Error ? cleanupCause.message : String(cleanupCause) + }) + ); + } + throw cause; + } + + return { filename, key, mimeType, size: file.size }; +} diff --git a/src/routes/admin/api/upload/+server.ts b/src/routes/admin/api/upload/+server.ts index 7fd2d8f..4ee8e0a 100644 --- a/src/routes/admin/api/upload/+server.ts +++ b/src/routes/admin/api/upload/+server.ts @@ -1,66 +1,35 @@ +import { parseAndValidateUpload, storeUpload, UploadValidationError } from '$lib/server/uploads'; import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -async function ensureAttachmentsTable(db: any) { - try { - await db.prepare(` - CREATE TABLE IF NOT EXISTS attachments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - filename TEXT NOT NULL, - key TEXT NOT NULL UNIQUE, - mime_type TEXT, - size INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `).run(); - } catch (e) { - console.error('Failed to ensure attachments table:', e); - } -} - export const POST: RequestHandler = async ({ request, platform }) => { - const db = platform?.env?.DB; - const r2 = platform?.env?.R2; - + const db = platform?.env.DB; + const r2 = platform?.env.R2; if (!db || !r2) return json({ error: '数据库或 R2 存储未连接' }, { status: 500 }); - const formData = await request.formData(); - const file = formData.get('file') as File; - - if (!file || !(file instanceof File)) { - return json({ error: '没有提供有效的文件' }, { status: 400 }); - } - - // Generate timestamp-based filename and robust physical key - const ext = file.name.split('.').pop() || ''; - const randomStr = Math.random().toString(36).substring(2, 8); - const now = new Date(); - const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; - const yyyymmdd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; - const hhmmss = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`; - const newFilename = `${yyyymmdd}_${hhmmss}.${ext}`; - const key = `uploads/${yearMonth}/${yyyymmdd}_${hhmmss}_${randomStr}.${ext}`; - try { - // Upload to R2 - await r2.put(key, file.stream(), { - httpMetadata: { contentType: file.type } + const domainResult = await db + .prepare('SELECT value FROM settings WHERE key = ?') + .bind('r2.public_domain') + .first<{ value: string }>(); + const publicDomain = (domainResult?.value || 'https://assets.yourdomain.com').replace( + /\/$/, + '' + ); + const file = await parseAndValidateUpload(request); + const stored = await storeUpload(db, r2, file); + + return json({ + success: true, + url: `${publicDomain}/${stored.key}`, + key: stored.key, + filename: stored.filename }); - - // Save metadata to DB - await ensureAttachmentsTable(db); - await db.prepare( - 'INSERT INTO attachments (filename, key, mime_type, size) VALUES (?, ?, ?, ?)' - ).bind(newFilename, key, file.type, file.size).run(); - - // Get r2Domain - const domainRes = await db.prepare('SELECT value FROM settings WHERE key = ?').bind('r2.public_domain').first(); - const r2Domain = domainRes?.value || 'https://assets.yourdomain.com'; - const url = `${r2Domain}/${key}`; - - return json({ success: true, url, key, filename: newFilename }); - } catch (err: any) { - console.error('Error uploading file API:', err); - return json({ error: '文件上传失败: ' + err.message }, { status: 500 }); + } catch (cause) { + if (cause instanceof UploadValidationError) { + return json({ error: cause.message }, { status: cause.status }); + } + console.error('Upload failed:', cause); + return json({ error: '文件上传失败,请稍后重试' }, { status: 500 }); } }; diff --git a/src/routes/admin/files/+page.server.ts b/src/routes/admin/files/+page.server.ts index d51c888..1680a84 100644 --- a/src/routes/admin/files/+page.server.ts +++ b/src/routes/admin/files/+page.server.ts @@ -1,128 +1,112 @@ +import { parseAndValidateUpload, storeUpload, UploadValidationError } from '$lib/server/uploads'; import { fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; -async function ensureAttachmentsTable(db: any) { - try { - await db.prepare(` - CREATE TABLE IF NOT EXISTS attachments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - filename TEXT NOT NULL, - key TEXT NOT NULL UNIQUE, - mime_type TEXT, - size INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `).run(); - } catch (e) { - console.error('Failed to ensure attachments table:', e); - } -} +type Attachment = { + id: number; + filename: string; + key: string; + mime_type: string | null; + size: number | null; + created_at: string; +}; export const load: PageServerLoad = async ({ platform }) => { - const db = platform?.env?.DB; - - if (!db) { - return { files: [], r2Domain: '' }; - } + const db = platform?.env.DB; + if (!db) return { files: [], r2Domain: '' }; try { - // Get R2 domain setting - const domainRes = await db.prepare('SELECT value FROM settings WHERE key = ?').bind('r2.public_domain').first(); - const r2Domain = domainRes?.value || 'https://assets.yourdomain.com'; - - const { results } = await db.prepare( - `SELECT id, filename, key, mime_type, size, created_at - FROM attachments - ORDER BY created_at DESC` - ).all(); + const domainResult = await db + .prepare('SELECT value FROM settings WHERE key = ?') + .bind('r2.public_domain') + .first<{ value: string }>(); + const { results } = await db + .prepare( + `SELECT id, filename, key, mime_type, size, created_at + FROM attachments + ORDER BY created_at DESC` + ) + .all(); return { - files: results as { id: number; filename: string; key: string; mime_type: string; size: number; created_at: string }[], - r2Domain + files: results, + r2Domain: domainResult?.value || 'https://assets.yourdomain.com' }; - } catch (err: any) { - if (err?.message?.includes('no such table')) { - await ensureAttachmentsTable(db); - return { files: [], r2Domain: 'https://assets.yourdomain.com' }; - } - console.error('Error fetching attachments:', err); + } catch (cause) { + console.error('Error fetching attachments:', cause); return { files: [], r2Domain: 'https://assets.yourdomain.com' }; } }; export const actions: Actions = { upload: async ({ request, platform }) => { - const db = platform?.env?.DB; - const r2 = platform?.env?.R2; - + const db = platform?.env.DB; + const r2 = platform?.env.R2; if (!db || !r2) return fail(500, { error: '数据库或 R2 存储未连接' }); - const formData = await request.formData(); - const file = formData.get('file') as File; - - if (!file || !(file instanceof File)) { - return fail(400, { error: '没有提供有效的文件' }); - } - - // Generate timestamp-based filename and robust physical key - const ext = file.name.split('.').pop() || ''; - const randomStr = Math.random().toString(36).substring(2, 8); - const now = new Date(); - const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; - const yyyymmdd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; - const hhmmss = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`; - const newFilename = `${yyyymmdd}_${hhmmss}.${ext}`; - const key = `uploads/${yearMonth}/${yyyymmdd}_${hhmmss}_${randomStr}.${ext}`; - try { - // Upload to R2 - await r2.put(key, file.stream(), { - httpMetadata: { contentType: file.type } - }); - - // Save metadata to DB - await ensureAttachmentsTable(db); // Defensive checking - await db.prepare( - 'INSERT INTO attachments (filename, key, mime_type, size) VALUES (?, ?, ?, ?)' - ).bind(newFilename, key, file.type, file.size).run(); - + const file = await parseAndValidateUpload(request); + await storeUpload(db, r2, file); return { success: true }; - } catch (err: any) { - console.error('Error uploading file:', err); - return fail(500, { error: '文件上传失败: ' + err.message }); + } catch (cause) { + if (cause instanceof UploadValidationError) { + return fail(cause.status, { error: cause.message }); + } + console.error('Upload failed:', cause); + return fail(500, { error: '文件上传失败,请稍后重试' }); } }, delete: async ({ request, platform }) => { - const db = platform?.env?.DB; - const r2 = platform?.env?.R2; - + const db = platform?.env.DB; + const r2 = platform?.env.R2; if (!db || !r2) return fail(500, { error: '数据库或 R2 存储未连接' }); const formData = await request.formData(); - const id = formData.get('id'); - - if (!id) return fail(400, { error: '缺少文件 ID' }); + const id = Number(formData.get('id')); + if (!Number.isInteger(id) || id <= 0) return fail(400, { error: '文件 ID 无效' }); try { - // Get key first - const fileRes = await db.prepare('SELECT key FROM attachments WHERE id = ?').bind(id).first(); - if (!fileRes) { - return fail(404, { error: '文件未找到' }); - } - - const key = fileRes.key as string; + const attachment = await db + .prepare( + 'SELECT id, filename, key, mime_type, size, created_at FROM attachments WHERE id = ?' + ) + .bind(id) + .first(); + if (!attachment) return fail(404, { error: '文件未找到' }); - // Delete from R2 - await r2.delete(key); - - // Delete from DB await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run(); - + try { + await r2.delete(attachment.key); + } catch (cause) { + try { + await db + .prepare( + `INSERT INTO attachments (id, filename, key, mime_type, size, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .bind( + attachment.id, + attachment.filename, + attachment.key, + attachment.mime_type, + attachment.size, + attachment.created_at + ) + .run(); + } catch (restoreCause) { + console.error( + 'Failed to restore attachment metadata after R2 delete failed:', + restoreCause + ); + } + throw cause; + } + return { success: true }; - } catch (err: any) { - console.error('Error deleting file:', err); - return fail(500, { error: '删除文件失败' }); + } catch (cause) { + console.error('Error deleting file:', cause); + return fail(500, { error: '删除文件失败,请稍后重试' }); } } }; diff --git a/src/routes/admin/files/+page.svelte b/src/routes/admin/files/+page.svelte index a9f830f..be8c754 100644 --- a/src/routes/admin/files/+page.svelte +++ b/src/routes/admin/files/+page.svelte @@ -1,4 +1,6 @@