diff --git a/src/lib/components/admin/FeaturedImagePicker.svelte b/src/lib/components/admin/FeaturedImagePicker.svelte index 7faecb6..597dc63 100644 --- a/src/lib/components/admin/FeaturedImagePicker.svelte +++ b/src/lib/components/admin/FeaturedImagePicker.svelte @@ -2,6 +2,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { addToast } from '$lib/stores/toast.svelte'; + import { prepareUpload } from '$lib/uploads'; import Tabs from './Tabs.svelte'; @@ -94,15 +95,15 @@ } async function handleUpload(e: Event) { + if (isUploading) return; const input = e.target as HTMLInputElement; const file = input.files?.[0]; if (!file) return; - const formData = new FormData(); - formData.append('file', file); - isUploading = true; try { + const formData = new FormData(); + formData.append('file', await prepareUpload(file)); const res = await fetch('/admin/api/upload', { method: 'POST', body: formData @@ -118,7 +119,7 @@ } } catch (err: any) { console.error(err); - addToast('网络错误,上传失败', 'error'); + addToast(err instanceof Error ? err.message : '图片处理或上传失败', 'error'); } finally { isUploading = false; input.value = ''; diff --git a/src/lib/components/admin/MarkdownEditor.svelte b/src/lib/components/admin/MarkdownEditor.svelte index b7712fd..67be54c 100644 --- a/src/lib/components/admin/MarkdownEditor.svelte +++ b/src/lib/components/admin/MarkdownEditor.svelte @@ -21,6 +21,7 @@ Strikethrough } from '@lucide/svelte'; import { addToast } from '$lib/stores/toast.svelte'; + import { prepareUpload } from '$lib/uploads'; import { imagePreviewPlugin } from '$lib/editor/imagePreviewPlugin'; import { tableFormatterPlugin } from '$lib/editor/tableFormatterPlugin'; @@ -85,15 +86,15 @@ let isUploadingImage = $state(false); async function handleImageUpload(e: Event) { + if (isUploadingImage) return; const input = e.target as HTMLInputElement; const file = input.files?.[0]; if (!file) return; - const formData = new FormData(); - formData.append('file', file); - isUploadingImage = true; try { + const formData = new FormData(); + formData.append('file', await prepareUpload(file)); const res = await fetch('/admin/api/upload', { method: 'POST', body: formData @@ -110,7 +111,7 @@ } } catch (err: any) { console.error(err); - addToast('网络错误,上传失败', 'error'); + addToast(err instanceof Error ? err.message : '图片处理或上传失败', 'error'); } finally { isUploadingImage = false; input.value = ''; diff --git a/src/lib/uploads.test.ts b/src/lib/uploads.test.ts new file mode 100644 index 0000000..c900853 --- /dev/null +++ b/src/lib/uploads.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { convertToWebp, prepareUpload } from './uploads'; + +const pngHeader = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const png = () => new File([new Uint8Array(pngHeader)], 'image.png', { type: 'image/png' }); + +function mockCanvas(blob: Blob | null = new Blob(['webp output'], { type: 'image/webp' })) { + const drawImage = vi.fn(); + const toBlob = vi.fn((callback: BlobCallback) => callback(blob)); + const canvas = { width: 0, height: 0, getContext: () => ({ drawImage }), toBlob }; + vi.stubGlobal('document', { createElement: vi.fn(() => canvas) }); + vi.stubGlobal( + 'Image', + class { + naturalWidth = 120; + naturalHeight = 80; + decode = vi.fn().mockResolvedValue(undefined); + } + ); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test'); + const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + return { canvas, drawImage, toBlob, revoke }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('convertToWebp', () => { + it.each([ + ['photo.JPEG', 'image/jpeg', [0xff, 0xd8, 0xff]], + ['image.png', 'image/png', pngHeader] + ])('converts %s with quality 0.8 and releases browser resources', async (name, type, bytes) => { + const { canvas, drawImage, toBlob, revoke } = mockCanvas(); + const file = new File([new Uint8Array(bytes)], name, { type, lastModified: 123 }); + const output = await convertToWebp(file); + expect(output.name).toBe(name.replace(/\.[^.]+$/, '.webp')); + expect(output.type).toBe('image/webp'); + expect(output.lastModified).toBe(123); + expect(await output.text()).toBe('webp output'); + expect(toBlob).toHaveBeenCalledWith(expect.any(Function), 'image/webp', 0.8); + expect(drawImage).toHaveBeenCalledWith(expect.any(Object), 0, 0); + expect(revoke).toHaveBeenCalledWith('blob:test'); + expect(canvas.width).toBe(0); + expect(canvas.height).toBe(0); + }); + + it.each(['image/gif', 'image/webp', 'application/pdf', 'application/zip'])( + 'keeps %s byte-for-byte unchanged', + async (type) => { + const file = new File(['original'], 'file', { type }); + expect(await convertToWebp(file)).toBe(file); + } + ); + + it('preserves animated PNG', async () => { + const file = new File( + [ + new Uint8Array([ + ...pngHeader, + 0, + 0, + 0, + 8, + 97, + 99, + 84, + 76, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ]) + ], + 'animated.png', + { type: 'image/png' } + ); + expect(await convertToWebp(file)).toBe(file); + }); + + it('does not convert spoofed MIME types or mismatched extensions', async () => { + for (const file of [ + new File([''], 'image.png', { type: 'image/png' }), + new File([new Uint8Array(pngHeader)], 'image.jpg', { type: 'image/png' }) + ]) + expect(await convertToWebp(file)).toBe(file); + }); + + it.each([null, new Blob(['PNG fallback'], { type: 'image/png' })])( + 'rejects failed or unsupported WebP encoding and cleans up', + async (blob) => { + const { revoke } = mockCanvas(blob); + await expect(convertToWebp(png())).rejects.toThrow('不支持 WebP'); + expect(revoke).toHaveBeenCalledOnce(); + } + ); + + it('rejects decoded images that exceed the pixel limit', async () => { + const { toBlob, revoke } = mockCanvas(); + vi.stubGlobal( + 'Image', + class { + naturalWidth = 10000; + naturalHeight = 10000; + decode = vi.fn().mockResolvedValue(undefined); + } + ); + await expect(convertToWebp(png())).rejects.toThrow('3200 万像素'); + expect(toBlob).not.toHaveBeenCalled(); + expect(revoke).toHaveBeenCalledOnce(); + }); +}); + +describe('prepareUpload', () => { + it('reads fresh settings and converts only when enabled', async () => { + mockCanvas(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({ autoWebp: false })) + .mockResolvedValueOnce(Response.json({ autoWebp: true })); + vi.stubGlobal('fetch', fetchMock); + const file = png(); + expect(await prepareUpload(file)).toBe(file); + expect((await prepareUpload(file)).type).toBe('image/webp'); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledWith('/admin/api/upload', { cache: 'no-store' }); + }); + + it('stops uploading when settings cannot be read', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 500 }))); + await expect(prepareUpload(png())).rejects.toThrow('读取图片上传设置失败'); + }); + + it('rejects original files above 10MB before converting or fetching settings', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const file = new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'large.png', { + type: 'image/png' + }); + await expect(prepareUpload(file)).rejects.toThrow('10MB'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts new file mode 100644 index 0000000..8abc4d0 --- /dev/null +++ b/src/lib/uploads.ts @@ -0,0 +1,82 @@ +const MAX_FILE_SIZE = 10 * 1024 * 1024; +const WEBP_QUALITY = 0.8; + +// Inspect PNG chunks, rather than searching compressed pixels for an acTL byte sequence. +function isAnimatedPng(bytes: Uint8Array): boolean { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + for (let offset = 8; offset + 12 <= bytes.length;) { + const length = view.getUint32(offset); + const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8)); + if (type === 'acTL') return true; + if (type === 'IDAT' || type === 'IEND') return false; + offset += 12 + length; + } + return false; +} + +export async function convertToWebp(file: File): Promise { + const type = file.type.toLowerCase(); + const extension = file.name.split('.').pop()?.toLowerCase(); + if (!( + (type === 'image/jpeg' && /^(jpg|jpeg)$/.test(extension || '')) || + (type === 'image/png' && extension === 'png') + )) { + return file; + } + if (file.size > MAX_FILE_SIZE) throw new Error('文件大小不能超过 10MB'); + const bytes = new Uint8Array(await file.arrayBuffer()); + // Leave invalid uploads untouched so the server can reject them using the original metadata. + if (type === 'image/jpeg') { + if (bytes[0] !== 0xff || bytes[1] !== 0xd8 || bytes[2] !== 0xff) return file; + } else { + if (![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((b, i) => bytes[i] === b)) { + return file; + } + if (isAnimatedPng(bytes)) return file; + } + + const url = URL.createObjectURL(file); + const canvas = document.createElement('canvas'); + try { + const image = new Image(); + image.src = url; + try { + await image.decode(); + } catch { + throw new Error('无法读取图片,请确认文件完整或关闭自动 WebP 转换后重试'); + } + if (image.naturalWidth * image.naturalHeight > 32_000_000) { + throw new Error('图片像素过大,请缩小至 3200 万像素以内后重试'); + } + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext('2d'); + if (!context) throw new Error('当前浏览器无法处理图片'); + context.drawImage(image, 0, 0); + const blob = await new Promise((resolve) => { + canvas.toBlob(resolve, 'image/webp', WEBP_QUALITY); + }); + if (!blob || blob.type !== 'image/webp') { + throw new Error('当前浏览器不支持 WebP 转换,请更换浏览器或关闭自动转换'); + } + if (blob.size > MAX_FILE_SIZE) throw new Error('转换后的图片超过 10MB,请缩小图片后重试'); + return new File([blob], file.name.replace(/\.[^.]+$/, '.webp'), { + type: 'image/webp', + lastModified: file.lastModified + }); + } finally { + URL.revokeObjectURL(url); + canvas.width = canvas.height = 0; + } +} + +export async function prepareUpload(file: File): Promise { + if (file.size > MAX_FILE_SIZE) throw new Error('文件大小不能超过 10MB'); + if (!['image/jpeg', 'image/png'].includes(file.type.toLowerCase())) return file; + + // Read at upload time so settings saved in another tab also take effect immediately. + const response = await fetch('/admin/api/upload', { cache: 'no-store' }); + if (!response.ok) throw new Error('读取图片上传设置失败,请稍后重试'); + const settings = (await response.json()) as { autoWebp: boolean }; + return settings.autoWebp ? convertToWebp(file) : file; +} diff --git a/src/routes/admin/api/upload/+server.ts b/src/routes/admin/api/upload/+server.ts index 808bbdd..021a1fe 100644 --- a/src/routes/admin/api/upload/+server.ts +++ b/src/routes/admin/api/upload/+server.ts @@ -2,6 +2,24 @@ import { parseAndValidateUpload, storeUpload, UploadValidationError } from '$lib import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; +export const GET: RequestHandler = async ({ platform }) => { + const db = platform?.env.DB; + if (!db) return json({ error: '数据库未连接' }, { status: 500 }); + try { + const setting = await db + .prepare('SELECT value FROM settings WHERE key = ?') + .bind('upload.auto_webp') + .first<{ value: string }>(); + return json( + { autoWebp: setting?.value === 'true' }, + { headers: { 'Cache-Control': 'no-store' } } + ); + } catch (cause) { + console.error('Failed to read upload settings:', cause); + return json({ error: '读取图片上传设置失败' }, { status: 500 }); + } +}; + export const POST: RequestHandler = async ({ request, platform }) => { const db = platform?.env.DB; const r2 = platform?.env.R2; diff --git a/src/routes/admin/files/+page.svelte b/src/routes/admin/files/+page.svelte index 7f428ac..ecc5fb3 100644 --- a/src/routes/admin/files/+page.svelte +++ b/src/routes/admin/files/+page.svelte @@ -6,6 +6,7 @@ import type { ActionData, PageData } from './$types'; import { addToast } from '$lib/stores/toast.svelte'; import { confirmState } from '$lib/stores/confirm.svelte'; + import { prepareUpload } from '$lib/uploads'; let { data, form } = $props<{ data: PageData; form: ActionData }>(); @@ -92,15 +93,36 @@ method="POST" action="?/upload" enctype="multipart/form-data" - use:enhance={() => { + use:enhance={async ({ formData, cancel }) => { + if (isUploading) { + cancel(); + return; + } isUploading = true; + try { + const file = formData.get('file'); + if (file instanceof globalThis.File) { + formData.set('file', await prepareUpload(file)); + } + } catch (err) { + cancel(); + isUploading = false; + fileInput.value = ''; + addToast(err instanceof Error ? err.message : '图片处理失败', 'error'); + return; + } return async ({ update }) => { isUploading = false; await update(); }; }} > - fileInput.click()}> + fileInput.click()} + > {isUploading ? '正在上传...' : '点击或拖拽文件上传'} 存储于 Cloudflare R2 diff --git a/src/routes/admin/settings/+page.server.ts b/src/routes/admin/settings/+page.server.ts index 4761f3d..7d1ed57 100644 --- a/src/routes/admin/settings/+page.server.ts +++ b/src/routes/admin/settings/+page.server.ts @@ -28,6 +28,27 @@ export const load: PageServerLoad = async ({ locals, platform }) => { }; export const actions: Actions = { + updateUploads: async ({ request, platform }) => { + const db = platform?.env?.DB; + if (!db) return fail(500, { error: '数据库未连接' }); + + const data = await request.formData(); + const enabled = data.get('upload_auto_webp') === 'true'; + try { + await db + .prepare( + `INSERT INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP` + ) + .bind('upload.auto_webp', String(enabled)) + .run(); + return { success: true }; + } catch (err) { + console.error('Failed to update upload settings', err); + return fail(500, { error: '保存图片上传设置失败' }); + } + }, updateSiteInfo: async ({ request, platform }) => { const db = platform?.env?.DB; if (!db) return fail(500, { error: '数据库未连接' }); diff --git a/src/routes/admin/settings/+page.svelte b/src/routes/admin/settings/+page.svelte index 1153c98..081212c 100644 --- a/src/routes/admin/settings/+page.svelte +++ b/src/routes/admin/settings/+page.svelte @@ -19,6 +19,7 @@ let apiToken = $state(initialData.settings['agent.api_token'] || ''); let showApiToken = $state(false); + let autoWebp = $state(initialData.settings['upload.auto_webp'] === 'true'); function generateApiToken() { const randomPart = Array.from(crypto.getRandomValues(new Uint8Array(16))) @@ -143,6 +144,33 @@ + + 图片上传 + { + return async ({ update }) => { + await update({ reset: false }); + }; + }} + class="alias-form" + > + + + 上传图片时自动转换为 WebP + + + 默认关闭。开启后,后台各入口上传的 JPEG / PNG 图片会在浏览器中以 80% 质量转换为 + WebP,保持原尺寸及透明背景。GIF、动态 PNG 和已有 WebP 保持原格式。 + 仅影响新上传图片;直接调用上传 API 的客户端需自行转换。 + + + 保存上传设置 + + + + API 与 AI 集成
存储于 Cloudflare R2
+ 默认关闭。开启后,后台各入口上传的 JPEG / PNG 图片会在浏览器中以 80% 质量转换为 + WebP,保持原尺寸及透明背景。GIF、动态 PNG 和已有 WebP 保持原格式。 + 仅影响新上传图片;直接调用上传 API 的客户端需自行转换。 +