feat: 支持高清大图上传与处理
This commit is contained in:
@@ -53,6 +53,7 @@ sh scripts/backup.sh
|
|||||||
|
|
||||||
- 修改会话密钥和后台初始密码。
|
- 修改会话密钥和后台初始密码。
|
||||||
- 配置域名、HTTPS 和反向代理。
|
- 配置域名、HTTPS 和反向代理。
|
||||||
|
- 反向代理的请求体上限至少设置为 `20m`,否则高清图片会在到达应用前被代理拒绝。
|
||||||
- 域名启用 HTTPS 后,把 `NUXT_SESSION_COOKIE_SECURE` 改为 `true`。
|
- 域名启用 HTTPS 后,把 `NUXT_SESSION_COOKIE_SECURE` 改为 `true`。
|
||||||
- 配置每日备份及恢复演练。
|
- 配置每日备份及恢复演练。
|
||||||
- 将 `.env.docker` 仅保存在服务器,不提交版本库。
|
- 将 `.env.docker` 仅保存在服务器,不提交版本库。
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ async function uploadImage(event: Event) {
|
|||||||
<button type="button" :class="{ active: editor.isActive('link') }" title="添加或编辑链接" aria-label="添加或编辑链接" @click="setLink">链接</button>
|
<button type="button" :class="{ active: editor.isActive('link') }" title="添加或编辑链接" aria-label="添加或编辑链接" @click="setLink">链接</button>
|
||||||
<button type="button" title="插入图片地址" aria-label="插入图片地址" @click="insertImageUrl">图片地址</button>
|
<button type="button" title="插入图片地址" aria-label="插入图片地址" @click="insertImageUrl">图片地址</button>
|
||||||
<button type="button" title="上传图片" aria-label="上传图片" :disabled="imageBusy" @click="imageInput?.click()">{{ imageBusy ? '上传中…' : '上传图片' }}</button>
|
<button type="button" title="上传图片" aria-label="上传图片" :disabled="imageBusy" @click="imageInput?.click()">{{ imageBusy ? '上传中…' : '上传图片' }}</button>
|
||||||
<input ref="imageInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp,image/gif" @change="uploadImage">
|
<input ref="imageInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp" @change="uploadImage">
|
||||||
<button type="button" :class="{ active: editor.isActive('codeBlock') }" title="代码块" aria-label="代码块" @click="editor.chain().focus().toggleCodeBlock().run()">代码</button>
|
<button type="button" :class="{ active: editor.isActive('codeBlock') }" title="代码块" aria-label="代码块" @click="editor.chain().focus().toggleCodeBlock().run()">代码</button>
|
||||||
<button type="button" title="分隔线" aria-label="分隔线" @click="editor.chain().focus().setHorizontalRule().run()">分隔线</button>
|
<button type="button" title="分隔线" aria-label="分隔线" @click="editor.chain().focus().setHorizontalRule().run()">分隔线</button>
|
||||||
<button type="button" title="清除格式" aria-label="清除格式" @click="editor.chain().focus().clearNodes().unsetAllMarks().run()">清除格式</button>
|
<button type="button" title="清除格式" aria-label="清除格式" @click="editor.chain().focus().clearNodes().unsetAllMarks().run()">清除格式</button>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const formatBytes = (bytes: number) => bytes < 1024 * 1024 ? `${Math.ceil(bytes
|
|||||||
<div v-if="loading" class="admin-loading">正在加载素材…</div>
|
<div v-if="loading" class="admin-loading">正在加载素材…</div>
|
||||||
<div v-else class="media-admin-body">
|
<div v-else class="media-admin-body">
|
||||||
<section v-if="view === 'active'" class="media-upload-panel editor-panel">
|
<section v-if="view === 'active'" class="media-upload-panel editor-panel">
|
||||||
<div><PhUploadSimple :size="30" /><strong>上传新图片</strong><small>支持 JPG、PNG、WebP;最大 8MB、4000 万像素。Logo 建议使用透明 PNG,宽度 600–1600px</small></div>
|
<div><PhUploadSimple :size="30" /><strong>上传新图片</strong><small>支持 JPG、PNG、WebP;最大 20MB、6400 万像素,最长边不超过 12000px。系统保留原图并生成最长边 3200px 的高清展示版</small></div>
|
||||||
<div class="media-upload-meta"><input v-model="uploadMeta.altText" placeholder="ALT 描述(建议填写)"><input v-model="uploadMeta.source" placeholder="素材来源"><input v-model="uploadMeta.copyright" placeholder="版权/授权说明"><select v-model="uploadMeta.authorizationStatus"><option value="pending">待核验</option><option value="authorized">已授权</option><option value="restricted">限制使用</option></select></div>
|
<div class="media-upload-meta"><input v-model="uploadMeta.altText" placeholder="ALT 描述(建议填写)"><input v-model="uploadMeta.source" placeholder="素材来源"><input v-model="uploadMeta.copyright" placeholder="版权/授权说明"><select v-model="uploadMeta.authorizationStatus"><option value="pending">待核验</option><option value="authorized">已授权</option><option value="restricted">限制使用</option></select></div>
|
||||||
<label class="media-upload-button"><input type="file" accept="image/jpeg,image/png,image/webp" :disabled="busy" @change="upload">{{ busy ? '处理中…' : '选择并上传图片' }}</label>
|
<label class="media-upload-button"><input type="file" accept="image/jpeg,image/png,image/webp" :disabled="busy" @change="upload">{{ busy ? '处理中…' : '选择并上传图片' }}</label>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ docker compose logs --tail=100 web
|
|||||||
|
|
||||||
容器必须显示 `healthy`。随后通过 Nginx 配置域名、HTTPS、请求体大小限制与访问日志。
|
容器必须显示 `healthy`。随后通过 Nginx 配置域名、HTTPS、请求体大小限制与访问日志。
|
||||||
|
|
||||||
|
图片上传允许单张最大 20 MB。Nginx 站点配置需要同步设置请求体上限,并为大图处理保留合理超时:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
client_max_body_size 20m;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
```
|
||||||
|
|
||||||
|
修改后执行 `nginx -t`,确认通过再平滑重载 Nginx。若使用其他反向代理,也要把对应上传上限设置为至少 20 MB。
|
||||||
|
|
||||||
## 3. 数据目录
|
## 3. 数据目录
|
||||||
|
|
||||||
- SQLite:Docker 数据卷中的 `/app/data/db/huiyu.sqlite`
|
- SQLite:Docker 数据卷中的 `/app/data/db/huiyu.sqlite`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { mkdir, rm, writeFile } from 'node:fs/promises'
|
|||||||
import { extname } from 'node:path'
|
import { extname } from 'node:path'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { MAX_IMAGE_BYTES, MAX_IMAGE_HEIGHT, MAX_IMAGE_PIXELS, MAX_IMAGE_WIDTH, isSharpPixelLimitError } from '../../utils/image-validation'
|
import { LARGE_IMAGE_EDGE, MAX_IMAGE_BYTES, MAX_IMAGE_HEIGHT, MAX_IMAGE_MB, MAX_IMAGE_PIXELS, MAX_IMAGE_WIDTH, isSharpPixelLimitError } from '../../utils/image-validation'
|
||||||
import { mediaUrl, safeUploadPath, serializeMedia, type MediaAssetRow } from '../../utils/media'
|
import { mediaUrl, safeUploadPath, serializeMedia, type MediaAssetRow } from '../../utils/media'
|
||||||
import { useDatabase } from '../../utils/db'
|
import { useDatabase } from '../../utils/db'
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ const metadataSchema = z.object({
|
|||||||
copyright: z.string().trim().max(200).default(''),
|
copyright: z.string().trim().max(200).default(''),
|
||||||
authorizationStatus: z.enum(['pending', 'authorized', 'restricted']).default('pending'),
|
authorizationStatus: z.enum(['pending', 'authorized', 'restricted']).default('pending'),
|
||||||
})
|
})
|
||||||
|
const pixelLimitLabel = `${MAX_IMAGE_PIXELS / 10_000} 万像素`
|
||||||
|
|
||||||
function detectImageType(data: Buffer) {
|
function detectImageType(data: Buffer) {
|
||||||
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg'
|
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg'
|
||||||
@@ -31,7 +32,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const parts = await readMultipartFormData(event)
|
const parts = await readMultipartFormData(event)
|
||||||
const file = parts?.find(part => part.name === 'file' && part.filename)
|
const file = parts?.find(part => part.name === 'file' && part.filename)
|
||||||
if (!file?.data?.length) throw createError({ statusCode: 400, statusMessage: '请选择图片文件' })
|
if (!file?.data?.length) throw createError({ statusCode: 400, statusMessage: '请选择图片文件' })
|
||||||
if (file.data.length > MAX_IMAGE_BYTES) throw createError({ statusCode: 413, statusMessage: '单张图片不能超过 8MB' })
|
if (file.data.length > MAX_IMAGE_BYTES) throw createError({ statusCode: 413, statusMessage: `单张图片不能超过 ${MAX_IMAGE_MB}MB` })
|
||||||
|
|
||||||
const detectedType = detectImageType(file.data)
|
const detectedType = detectImageType(file.data)
|
||||||
if (!detectedType || !(detectedType in allowedTypes)) throw createError({ statusCode: 415, statusMessage: '仅支持 JPG、PNG、WebP 图片' })
|
if (!detectedType || !(detectedType in allowedTypes)) throw createError({ statusCode: 415, statusMessage: '仅支持 JPG、PNG、WebP 图片' })
|
||||||
@@ -43,14 +44,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
try { imageMetadata = await sharp(file.data, { limitInputPixels: MAX_IMAGE_PIXELS }).metadata() }
|
try { imageMetadata = await sharp(file.data, { limitInputPixels: MAX_IMAGE_PIXELS }).metadata() }
|
||||||
catch (error) {
|
catch (error) {
|
||||||
if (isSharpPixelLimitError(error)) {
|
if (isSharpPixelLimitError(error)) {
|
||||||
throw createError({ statusCode: 413, statusMessage: '图片像素过大,请压缩至 10000×10000 且不超过 4000 万像素后上传' })
|
throw createError({ statusCode: 413, statusMessage: `图片像素过大,请压缩至 ${MAX_IMAGE_WIDTH}×${MAX_IMAGE_HEIGHT} 且不超过 ${pixelLimitLabel} 后上传` })
|
||||||
}
|
}
|
||||||
throw createError({ statusCode: 415, statusMessage: '图片内容损坏或无法解析' })
|
throw createError({ statusCode: 415, statusMessage: '图片内容损坏或无法解析' })
|
||||||
}
|
}
|
||||||
const width = imageMetadata.width || 0
|
const width = imageMetadata.width || 0
|
||||||
const height = imageMetadata.height || 0
|
const height = imageMetadata.height || 0
|
||||||
if (!width || !height || width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT || width * height > MAX_IMAGE_PIXELS) {
|
if (!width || !height || width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT || width * height > MAX_IMAGE_PIXELS) {
|
||||||
throw createError({ statusCode: 413, statusMessage: '图片尺寸不能超过 10000×10000 或 4000 万像素' })
|
throw createError({ statusCode: 413, statusMessage: `图片尺寸不能超过 ${MAX_IMAGE_WIDTH}×${MAX_IMAGE_HEIGHT} 或 ${pixelLimitLabel}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = Object.fromEntries((parts || []).filter(part => !part.filename && part.name).map(part => [part.name!, part.data.toString('utf8')]))
|
const fields = Object.fromEntries((parts || []).filter(part => !part.filename && part.name).map(part => [part.name!, part.data.toString('utf8')]))
|
||||||
@@ -66,8 +67,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
await Promise.all(writtenPaths.map(path => mkdir(safeUploadPath(path).replace(/\/[^/]+$/, ''), { recursive: true })))
|
await Promise.all(writtenPaths.map(path => mkdir(safeUploadPath(path).replace(/\/[^/]+$/, ''), { recursive: true })))
|
||||||
try {
|
try {
|
||||||
await writeFile(safeUploadPath(originalPath), file.data, { flag: 'wx' })
|
await writeFile(safeUploadPath(originalPath), file.data, { flag: 'wx' })
|
||||||
await sharp(file.data).rotate().resize({ width: 2400, height: 2400, fit: 'inside', withoutEnlargement: true }).webp({ quality: 84 }).toFile(safeUploadPath(derivedPath))
|
await sharp(file.data, { limitInputPixels: MAX_IMAGE_PIXELS }).rotate().resize({ width: LARGE_IMAGE_EDGE, height: LARGE_IMAGE_EDGE, fit: 'inside', withoutEnlargement: true }).webp({ quality: 88 }).toFile(safeUploadPath(derivedPath))
|
||||||
await sharp(file.data).rotate().resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true }).webp({ quality: 78 }).toFile(safeUploadPath(thumbnailPath))
|
await sharp(file.data, { limitInputPixels: MAX_IMAGE_PIXELS }).rotate().resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true }).webp({ quality: 80 }).toFile(safeUploadPath(thumbnailPath))
|
||||||
|
|
||||||
const timestamp = now.toISOString()
|
const timestamp = now.toISOString()
|
||||||
const db = useDatabase()
|
const db = useDatabase()
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
export const MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
export const MAX_IMAGE_MB = 20
|
||||||
export const MAX_IMAGE_WIDTH = 10_000
|
export const MAX_IMAGE_BYTES = MAX_IMAGE_MB * 1024 * 1024
|
||||||
export const MAX_IMAGE_HEIGHT = 10_000
|
export const MAX_IMAGE_WIDTH = 12_000
|
||||||
export const MAX_IMAGE_PIXELS = 40_000_000
|
export const MAX_IMAGE_HEIGHT = 12_000
|
||||||
|
export const MAX_IMAGE_PIXELS = 64_000_000
|
||||||
|
export const LARGE_IMAGE_EDGE = 3_200
|
||||||
|
|
||||||
export function isSharpPixelLimitError(error: unknown) {
|
export function isSharpPixelLimitError(error: unknown) {
|
||||||
return error instanceof Error && /exceeds pixel limit/i.test(error.message)
|
return error instanceof Error && /exceeds pixel limit/i.test(error.message)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { MAX_IMAGE_PIXELS, isSharpPixelLimitError } from '../server/utils/image-validation'
|
import { LARGE_IMAGE_EDGE, MAX_IMAGE_BYTES, MAX_IMAGE_PIXELS, isSharpPixelLimitError } from '../server/utils/image-validation'
|
||||||
|
|
||||||
describe('图片上传校验', () => {
|
describe('图片上传校验', () => {
|
||||||
it('识别 Sharp 像素上限错误', () => {
|
it('识别 Sharp 像素上限错误', () => {
|
||||||
@@ -8,6 +8,8 @@ describe('图片上传校验', () => {
|
|||||||
|
|
||||||
it('不把普通解析错误误判为像素过大', () => {
|
it('不把普通解析错误误判为像素过大', () => {
|
||||||
expect(isSharpPixelLimitError(new Error('Input buffer contains unsupported image format'))).toBe(false)
|
expect(isSharpPixelLimitError(new Error('Input buffer contains unsupported image format'))).toBe(false)
|
||||||
expect(MAX_IMAGE_PIXELS).toBe(40_000_000)
|
expect(MAX_IMAGE_BYTES).toBe(20 * 1024 * 1024)
|
||||||
|
expect(MAX_IMAGE_PIXELS).toBe(64_000_000)
|
||||||
|
expect(LARGE_IMAGE_EDGE).toBe(3_200)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user