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.
153 lines
4.3 KiB
TypeScript
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
|
|
})
|