feat: 增加文件上传服务端验证与安全存储逻辑
This commit is contained in:
1 parent
964efa2cad
commit
02334b7663
5 files changed
+443
-190
No files matched your search
@@ -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\//));
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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: '删除文件失败,请稍后重试' });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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})`;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user