Files
PicHost/server/api/images/upload.post.ts
T
O96u 6fc054328d feat: activity logs, gallery storage filter, and upload rate limits
Add per-user activity logs with storage backend tracking, gallery
storage filter, and IP/token upload rate limiting. Fix storage delete
modal flash, pin local backend in filter order, and link settings
version to GitHub releases.
2026-08-26 17:09:38 +08:00

153 lines
4.3 KiB
TypeScript

import { readMultipartFormData } from 'h3'
import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image'
import { requireUploadAuth, verifyApiUploadToken, getUploadUserId, canUseCustomUploadFolder } from '../../utils/access'
import { MAX_FILES_PER_UPLOAD } from '../../utils/constants'
import {
detectMimeFromSignature,
isAllowedMimeType
} from '../../utils/file-signature'
import {
mimeMatchesDeclared,
processSingleImageUpload
} from '../../utils/process-image-upload'
import { createApiError } from '../../utils/api-error'
import {
DEFAULT_FOLDER,
isValidFolderName,
normalizeFolderName
} from '../../utils/image-key'
import { checkUploadRateLimit } from '../../utils/rate-limit'
function readFormText(
formData: { name?: string, data?: Buffer | Uint8Array }[],
name: string
): string {
const part = formData.find(item => item.name === name && item.data?.length)
if (!part?.data) return ''
return new TextDecoder().decode(part.data).trim()
}
export default defineEventHandler(async (event) => {
await requireUploadAuth(event)
const formData = await readMultipartFormData(event)
if (!formData?.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
}
const tokenField = formData.find(part => part.name === 'token')
const formToken = tokenField?.data?.length
? new TextDecoder().decode(tokenField.data).trim()
: ''
checkUploadRateLimit(event, formToken)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event)
const query = getQuery(event)
const folderFromForm = readFormText(formData, 'folder') || readFormText(formData, 'type')
const folderFromQuery = typeof query.folder === 'string'
? query.folder
: typeof query.type === 'string'
? query.type
: ''
const requestedFolder = normalizeFolderName(folderFromForm || folderFromQuery)
const canCustomFolder = await canUseCustomUploadFolder(event)
if (!canCustomFolder && requestedFolder && requestedFolder !== DEFAULT_FOLDER) {
createApiError(
event,
'FORBIDDEN',
'当前账号不支持自定义上传目录',
403
)
}
const folder = canCustomFolder ? requestedFolder : ''
if (folder && !isValidFolderName(folder)) {
createApiError(
event,
'INVALID_REQUEST',
'无效的 folder:仅允许字母数字开头,可含 - _,最长 32 位,且不能是 api/stats 等保留名',
400
)
}
// 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图)
const fileParts = formData.filter(
part => (part.name === 'image' || part.name === 'file' || part.name === 'files')
&& part.data?.length
)
if (!fileParts.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
}
if (fileParts.length > MAX_FILES_PER_UPLOAD) {
createApiError(
event,
'TOO_MANY_FILES',
`每次最多上传 ${MAX_FILES_PER_UPLOAD} 张图片`,
400
)
}
const items: ImageItem[] = []
const errors: UploadErrorItem[] = []
for (const part of fileParts) {
const originalName = part.filename ?? 'image'
const bytes = new Uint8Array(part.data)
const declaredMime = part.type?.toLowerCase() ?? ''
const detectedMime = detectMimeFromSignature(bytes)
if (detectedMime && !mimeMatchesDeclared(declaredMime, detectedMime)) {
errors.push({
name: originalName,
error: {
code: 'INVALID_FILE_SIGNATURE',
message: '文件类型与内容不匹配'
}
})
continue
}
if (declaredMime && isAllowedMimeType(declaredMime) && !detectedMime) {
errors.push({
name: originalName,
error: {
code: 'INVALID_FILE_SIGNATURE',
message: '无法识别的图片格式'
}
})
continue
}
const result = await processSingleImageUpload(event, {
bytes,
filename: part.filename,
source,
prefix: folder || DEFAULT_FOLDER,
userId: uploadUserId
})
if ('error' in result) {
errors.push(result.error)
} else {
items.push(result.item)
}
}
const response: UploadResponse = {
success: items.length > 0,
items,
errors
}
if (!items.length && errors.length) {
setResponseStatus(event, 400)
}
return response
})