This commit is contained in:
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 = '';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '数据库未连接' });
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user