feat: 支持上传图片时自动转换为 WebP 格式
continuous-integration/drone/push Build is passing

This commit is contained in:
seahi committed 2026-09-26 14:31:30 +08:00
1 parent 3fdb94795b
commit fe8ea56d9b
8 files changed
+335 -10

No files matched your search

@@ -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 = '';
@@ -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 = '';
+152
View File
@@ -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(['<svg/>'], '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();
});
});
+82
View File
@@ -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<File> {
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<Blob | null>((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<File> {
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;
}
+18
View File
@@ -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;
+24 -2
View File
@@ -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();
};
}}
>
<button type="button" class="upload-box" onclick={() => fileInput.click()}>
<button
type="button"
class="upload-box"
disabled={isUploading}
onclick={() => fileInput.click()}
>
<Upload size={48} stroke-width={1.5} aria-hidden="true" />
<h3>{isUploading ? '正在上传...' : '点击或拖拽文件上传'}</h3>
<p>存储于 Cloudflare R2</p>
+21
View File
@@ -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: '数据库未连接' });
+28
View File
@@ -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 @@
</div>
</div>
<div class="settings-card full-width">
<h3>图片上传</h3>
<form
method="POST"
action="?/updateUploads"
use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
};
}}
class="alias-form"
>
<label class="toggle-label">
<input type="checkbox" name="upload_auto_webp" value="true" bind:checked={autoWebp} />
<span class="toggle-text">上传图片时自动转换为 WebP</span>
</label>
<p class="desc">
默认关闭。开启后,后台各入口上传的 JPEG / PNG 图片会在浏览器中以 80% 质量转换为
WebP,保持原尺寸及透明背景。GIF、动态 PNG 和已有 WebP 保持原格式。
仅影响新上传图片;直接调用上传 API 的客户端需自行转换。
</p>
<div class="form-actions">
<Button type="submit" variant="primary">保存上传设置</Button>
</div>
</form>
</div>
<div class="settings-card full-width">
<h3>API 与 AI 集成</h3>
<p class="desc">