feat: 增加文件上传服务端验证与安全存储逻辑

This commit is contained in:
seahi committed 2026-08-07 12:53:43 +08:00
1 parent 964efa2cad
commit 02334b7663
5 files changed
+443 -190

No files matched your search

+84
View File
@@ -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(['<svg onload="alert(1)"></svg>'], '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\//));
});
});
+181
View File
@@ -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<string, UploadType> = {
'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<FormData> {
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<File> {
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<StoredUpload> {
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 };
}
+25 -56
View File
@@ -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 });
}
};
+77 -93
View File
@@ -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<Attachment>();
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<Attachment>();
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: '删除文件失败,请稍后重试' });
}
}
};
+76 -41
View File
@@ -1,4 +1,6 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import { addToast } from '$lib/stores/toast.svelte';
@@ -29,13 +31,16 @@
const url = `${data.r2Domain}/${file.key}`;
const isImage = file.mime_type?.startsWith('image/');
const markdown = isImage ? `![${file.filename}](${url})` : `[${file.filename}](${url})`;
navigator.clipboard.writeText(markdown).then(() => {
addToast('Markdown 链接已复制到剪贴板!', 'success');
}).catch(err => {
console.error('Failed to copy: ', err);
addToast('复制失败,请手动复制。', 'error');
});
navigator.clipboard
.writeText(markdown)
.then(() => {
addToast('Markdown 链接已复制到剪贴板!', 'success');
})
.catch((err) => {
console.error('Failed to copy: ', err);
addToast('复制失败,请手动复制。', 'error');
});
}
function handleFileChange() {
@@ -53,18 +58,30 @@
<h2>文件管理</h2>
</div>
<div class="upload-zone">
<form method="POST" action="?/upload" enctype="multipart/form-data" use:enhance={() => {
isUploading = true;
return async ({ update }) => {
isUploading = false;
await update();
};
}}>
<form
method="POST"
action="?/upload"
enctype="multipart/form-data"
use:enhance={() => {
isUploading = true;
return async ({ update }) => {
isUploading = false;
await update();
};
}}
>
<button type="button" class="upload-box" onclick={() => fileInput.click()}>
<svg viewBox="0 0 24 24" width="48" height="48" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg
viewBox="0 0 24 24"
width="48"
height="48"
stroke="currentColor"
stroke-width="1.5"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
@@ -72,10 +89,10 @@
<h3>{isUploading ? '正在上传...' : '点击或拖拽文件上传'}</h3>
<p>存储于 Cloudflare R2</p>
</button>
<input
type="file"
name="file"
bind:this={fileInput}
<input
type="file"
name="file"
bind:this={fileInput}
onchange={handleFileChange}
style="display: none;"
/>
@@ -83,7 +100,7 @@
</div>
<div class="files-grid">
{#each data.files as file}
{#each data.files as file (file.id || file)}
{@const url = `${data.r2Domain}/${file.key}`}
{@const isImage = file.mime_type?.startsWith('image/')}
<div class="file-card">
@@ -92,14 +109,23 @@
<img src={url} alt={file.filename} loading="lazy" />
{:else}
<div class="file-icon">
<svg viewBox="0 0 24 24" width="48" height="48" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg
viewBox="0 0 24 24"
width="48"
height="48"
stroke="currentColor"
stroke-width="1.5"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
</div>
{/if}
</div>
<div class="file-info">
<div class="file-name" title={file.filename}>{file.filename}</div>
<div class="file-meta">
@@ -107,21 +133,34 @@
<span>{new Date(file.created_at).toLocaleDateString()}</span>
</div>
</div>
<div class="file-actions">
<button class="action-btn copy-btn" onclick={() => copyMarkdown(file)} title="复制 Markdown">
<button
class="action-btn copy-btn"
onclick={() => copyMarkdown(file)}
title="复制 Markdown"
>
复制链接
</button>
<form method="POST" action="?/delete" use:enhance={() => {
return async ({ update }) => {
await update();
};
}}>
<form
method="POST"
action="?/delete"
use:enhance={() => {
return async ({ update }) => {
await update();
};
}}
>
<input type="hidden" name="id" value={file.id} />
<button class="action-btn delete-btn" type="submit" onclick={(e) => {
if(!confirm('确定要删除此文件吗?此操作不可恢复。')) e.preventDefault();
}} title="删除文件">
<button
class="action-btn delete-btn"
type="submit"
onclick={(e) => {
if (!confirm('确定要删除此文件吗?此操作不可恢复。')) e.preventDefault();
}}
title="删除文件"
>
删除
</button>
</form>
@@ -130,9 +169,7 @@
{/each}
{#if data.files.length === 0}
<div class="empty-state">
暂无文件,点击上方按钮上传
</div>
<div class="empty-state">暂无文件,点击上方按钮上传</div>
{/if}
</div>
@@ -152,8 +189,6 @@
font-weight: 800;
}
.upload-zone {
margin-bottom: 30px;
}
@@ -172,7 +207,7 @@
}
.upload-box:hover {
background: rgba(0,0,0,0.02);
background: rgba(0, 0, 0, 0.02);
border-color: var(--accent-color, #419b38);
color: var(--accent-color, #419b38);
}