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 @@