超大文件上传限制

This commit is contained in:
tianmo committed 2026-10-19 15:04:41 +08:00
1 parent 885534378b
commit 3759422222
9 files changed
+246 -79

No files matched your search

+13 -6
View File
@@ -5,6 +5,7 @@ import type {
import { isUnauthorizedError } from './useAuth' import { isUnauthorizedError } from './useAuth'
import { prepareFilesForUpload } from './useClipboardImage' import { prepareFilesForUpload } from './useClipboardImage'
import { normalizeUploadTagNames } from './useUploadPreferences' import { normalizeUploadTagNames } from './useUploadPreferences'
import { uploadFormDataWithProgress } from '~/utils/upload-progress'
const MAX_FILES = 10 const MAX_FILES = 10
@@ -66,10 +67,10 @@ export function useImageUpload() {
return return
} }
progressItems.value = prepared.map((file, index) => ({ progressItems.value = prepared.map(file => ({
name: file.name, name: file.name,
status: 'uploading', status: 'uploading',
progress: Math.round(((index + 1) / prepared.length) * 50) progress: 0
})) }))
const formData = new FormData() const formData = new FormData()
@@ -82,10 +83,16 @@ export function useImageUpload() {
formData.append('tagNames', tagNames) formData.append('tagNames', tagNames)
} }
const response = await $fetch<UploadResponse>('/api/images/upload', { const response = await uploadFormDataWithProgress<UploadResponse>(
method: 'POST', '/api/images/upload',
body: formData formData,
}) (percent) => {
progressItems.value = progressItems.value.map(item => ({
...item,
progress: Math.min(99, percent)
}))
}
)
lastUploadResult.value = response lastUploadResult.value = response
+12 -5
View File
@@ -3,6 +3,7 @@ import type { FileItem, FileListResponse, FileUploadResponse } from '~/types/fil
import type { CopyFormat, UploadMediaKind } from './useUploadPreferences' import type { CopyFormat, UploadMediaKind } from './useUploadPreferences'
import { isUnauthorizedError } from './useAuth' import { isUnauthorizedError } from './useAuth'
import { normalizeUploadTagNames } from './useUploadPreferences' import { normalizeUploadTagNames } from './useUploadPreferences'
import { uploadFormDataWithProgress } from '~/utils/upload-progress'
const MAX_FILES = 10 const MAX_FILES = 10
@@ -255,13 +256,19 @@ export function useMediaUpload(kind: UploadMediaKind) {
progressItems.value = progressItems.value.map(item => ({ progressItems.value = progressItems.value.map(item => ({
...item, ...item,
status: 'uploading', status: 'uploading',
progress: 50 progress: 0
})) }))
const response = await $fetch<FileUploadResponse>(endpoint, { const response = await uploadFormDataWithProgress<FileUploadResponse>(
method: 'POST', endpoint,
body: formData formData,
}) (percent) => {
progressItems.value = progressItems.value.map(item => ({
...item,
progress: Math.min(99, percent)
}))
}
)
lastResult.value = response lastResult.value = response
accessMode.value = response.accessMode accessMode.value = response.accessMode
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { uploadFormDataWithProgress } from './upload-progress'
class FakeXhr {
static current: FakeXhr
upload = {
onprogress: null as ((event: ProgressEvent) => void) | null
}
status = 200
statusText = 'OK'
responseText = '{"ok":true}'
withCredentials = false
onload: (() => void) | null = null
onerror: (() => void) | null = null
constructor() {
FakeXhr.current = this
}
open() {}
send() {}
}
describe('uploadFormDataWithProgress', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('reports the uploaded byte percentage', async () => {
vi.stubGlobal('XMLHttpRequest', FakeXhr)
const onProgress = vi.fn()
const result = uploadFormDataWithProgress<{ ok: boolean }>(
'/upload',
new FormData(),
onProgress
)
FakeXhr.current.upload.onprogress!({
lengthComputable: true,
loaded: 40,
total: 100
} as ProgressEvent)
FakeXhr.current.onload!()
await expect(result).resolves.toEqual({ ok: true })
expect(onProgress).toHaveBeenCalledWith(40)
})
it('keeps the HTTP status and server error message', async () => {
vi.stubGlobal('XMLHttpRequest', FakeXhr)
const result = uploadFormDataWithProgress(
'/upload',
new FormData(),
() => {}
)
FakeXhr.current.status = 401
FakeXhr.current.statusText = 'Unauthorized'
FakeXhr.current.responseText = JSON.stringify({
data: { error: { message: '未登录' } }
})
FakeXhr.current.onload!()
await expect(result).rejects.toMatchObject({
statusCode: 401,
message: '未登录'
})
})
})
+60
View File
@@ -0,0 +1,60 @@
interface UploadError extends Error {
statusCode: number
statusMessage: string
data: unknown
}
function uploadErrorMessage(data: unknown, fallback: string): string {
if (!data || typeof data !== 'object') return fallback
const body = data as {
message?: unknown
error?: { message?: unknown }
data?: { error?: { message?: unknown } }
}
const message = body.data?.error?.message ?? body.error?.message ?? body.message
return typeof message === 'string' ? message : fallback
}
export function uploadFormDataWithProgress<T>(
url: string,
formData: FormData,
onProgress: (percent: number) => void
): Promise<T> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', url)
xhr.withCredentials = true
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress(Math.round((event.loaded / event.total) * 100))
}
}
xhr.onload = () => {
let data: unknown = null
try {
data = xhr.responseText ? JSON.parse(xhr.responseText) : null
} catch {
// 非 JSON 响应交给状态码和状态文本报错
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(data as T)
return
}
const error = new Error(
uploadErrorMessage(data, xhr.statusText || 'Upload failed')
) as UploadError
error.statusCode = xhr.status
error.statusMessage = xhr.statusText
error.data = data
reject(error)
}
xhr.onerror = () => reject(new Error('Network error'))
xhr.send(formData)
})
}
+15 -13
View File
@@ -1,4 +1,4 @@
import { readMultipartFormData } from 'h3' import { readFormData } from 'h3'
import type { FileItem, FileUploadResponse } from '~/types/file' import type { FileItem, FileUploadResponse } from '~/types/file'
import type { UploadErrorItem } from '~/types/image' import type { UploadErrorItem } from '~/types/image'
import { import {
@@ -11,7 +11,10 @@ import { createApiError } from '../../utils/api-error'
import { checkUploadRateLimit } from '../../utils/rate-limit' import { checkUploadRateLimit } from '../../utils/rate-limit'
import { processSingleFileUpload } from '../../utils/file-bed' import { processSingleFileUpload } from '../../utils/file-bed'
import { getFileBedAccessMode } from '../../utils/env' import { getFileBedAccessMode } from '../../utils/env'
import { readMultipartFormFieldValues } from '../../utils/multipart-form' import {
readFormDataFieldValues,
readFormDataFiles
} from '../../utils/multipart-form'
import { import {
parseTagNamesParam, parseTagNamesParam,
resolveUploadTagIds resolveUploadTagIds
@@ -20,20 +23,18 @@ import {
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireUploadAuth(event) await requireUploadAuth(event)
const formData = await readMultipartFormData(event) const formData = await readFormData(event)
if (!formData?.length) { if (!formData) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
} }
const tokenField = formData.find(part => part.name === 'token') const tokenField = formData.get('token')
const formToken = tokenField?.data?.length const formToken = typeof tokenField === 'string' ? tokenField.trim() : ''
? new TextDecoder().decode(tokenField.data).trim()
: ''
checkUploadRateLimit(event, formToken) checkUploadRateLimit(event, formToken)
const source = verifyApiUploadToken(event) ? 'api' : 'web' const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event, formToken) const uploadUserId = await getUploadUserId(event, formToken)
const tagNamesRaw = readMultipartFormFieldValues(formData, 'tagNames') const tagNamesRaw = readFormDataFieldValues(formData, 'tagNames')
const uploadTagNames = parseTagNamesParam( const uploadTagNames = parseTagNamesParam(
tagNamesRaw.length === 1 ? tagNamesRaw[0] : tagNamesRaw tagNamesRaw.length === 1 ? tagNamesRaw[0] : tagNamesRaw
) )
@@ -43,8 +44,9 @@ export default defineEventHandler(async (event) => {
} }
const uploadTagIds = resolvedTags.tagIds const uploadTagIds = resolvedTags.tagIds
const fileParts = formData.filter( const fileParts = readFormDataFiles(
part => (part.name === 'file' || part.name === 'files') && part.data?.length formData,
['file', 'files']
) )
if (!fileParts.length) { if (!fileParts.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
@@ -64,8 +66,8 @@ export default defineEventHandler(async (event) => {
for (const part of fileParts) { for (const part of fileParts) {
const result = await processSingleFileUpload(event, { const result = await processSingleFileUpload(event, {
bytes: new Uint8Array(part.data), bytes: new Uint8Array(await part.arrayBuffer()),
filename: part.filename, filename: part.name,
contentType: part.type, contentType: part.type,
source, source,
userId: uploadUserId, userId: uploadUserId,
+16 -15
View File
@@ -1,4 +1,4 @@
import { readMultipartFormData } from 'h3' import { readFormData } from 'h3'
import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image' import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image'
import { requireUploadAuth, verifyApiUploadToken, getUploadUserId } from '../../utils/access' import { requireUploadAuth, verifyApiUploadToken, getUploadUserId } from '../../utils/access'
import { MAX_FILES_PER_UPLOAD } from '../../utils/constants' import { MAX_FILES_PER_UPLOAD } from '../../utils/constants'
@@ -12,7 +12,10 @@ import {
} from '../../utils/process-image-upload' } from '../../utils/process-image-upload'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { checkUploadRateLimit } from '../../utils/rate-limit' import { checkUploadRateLimit } from '../../utils/rate-limit'
import { readMultipartFormFieldValues } from '../../utils/multipart-form' import {
readFormDataFieldValues,
readFormDataFiles
} from '../../utils/multipart-form'
import { import {
parseTagNamesParam, parseTagNamesParam,
resolveUploadTagIds resolveUploadTagIds
@@ -21,21 +24,19 @@ import {
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireUploadAuth(event) await requireUploadAuth(event)
const formData = await readMultipartFormData(event) const formData = await readFormData(event)
if (!formData?.length) { if (!formData) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
} }
const tokenField = formData.find(part => part.name === 'token') const tokenField = formData.get('token')
const formToken = tokenField?.data?.length const formToken = typeof tokenField === 'string' ? tokenField.trim() : ''
? new TextDecoder().decode(tokenField.data).trim()
: ''
checkUploadRateLimit(event, formToken) checkUploadRateLimit(event, formToken)
const source = verifyApiUploadToken(event) ? 'api' : 'web' const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event, formToken) const uploadUserId = await getUploadUserId(event, formToken)
const tagNamesRaw = readMultipartFormFieldValues(formData, 'tagNames') const tagNamesRaw = readFormDataFieldValues(formData, 'tagNames')
const uploadTagNames = parseTagNamesParam(tagNamesRaw.length === 1 ? tagNamesRaw[0] : tagNamesRaw) const uploadTagNames = parseTagNamesParam(tagNamesRaw.length === 1 ? tagNamesRaw[0] : tagNamesRaw)
const resolvedTags = resolveUploadTagIds(uploadUserId, uploadTagNames) const resolvedTags = resolveUploadTagIds(uploadUserId, uploadTagNames)
if ('message' in resolvedTags) { if ('message' in resolvedTags) {
@@ -44,9 +45,9 @@ export default defineEventHandler(async (event) => {
const uploadTagIds = resolvedTags.tagIds const uploadTagIds = resolvedTags.tagIds
// 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图) // 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图)
const fileParts = formData.filter( const fileParts = readFormDataFiles(
part => (part.name === 'image' || part.name === 'file' || part.name === 'files') formData,
&& part.data?.length ['image', 'file', 'files']
) )
if (!fileParts.length) { if (!fileParts.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
@@ -65,8 +66,8 @@ export default defineEventHandler(async (event) => {
const errors: UploadErrorItem[] = [] const errors: UploadErrorItem[] = []
for (const part of fileParts) { for (const part of fileParts) {
const originalName = part.filename ?? 'image' const originalName = part.name || 'image'
const bytes = new Uint8Array(part.data) const bytes = new Uint8Array(await part.arrayBuffer())
const declaredMime = part.type?.toLowerCase() ?? '' const declaredMime = part.type?.toLowerCase() ?? ''
const detectedMime = detectMimeFromSignature(bytes) const detectedMime = detectMimeFromSignature(bytes)
@@ -94,7 +95,7 @@ export default defineEventHandler(async (event) => {
const result = await processSingleImageUpload(event, { const result = await processSingleImageUpload(event, {
bytes, bytes,
filename: part.filename, filename: part.name,
source, source,
userId: uploadUserId, userId: uploadUserId,
tagIds: uploadTagIds tagIds: uploadTagIds
+22 -28
View File
@@ -1,4 +1,4 @@
import { readMultipartFormData } from 'h3' import { readFormData } from 'h3'
import type { FileItem, FileUploadResponse } from '~/types/file' import type { FileItem, FileUploadResponse } from '~/types/file'
import type { UploadErrorItem } from '~/types/image' import type { UploadErrorItem } from '~/types/image'
import { import {
@@ -17,7 +17,10 @@ import {
parseTranscodePreset parseTranscodePreset
} from '../../utils/transcode' } from '../../utils/transcode'
import { getFileBedAccessMode } from '../../utils/env' import { getFileBedAccessMode } from '../../utils/env'
import { readMultipartFormFieldValues } from '../../utils/multipart-form' import {
readFormDataFieldValues,
readFormDataFiles
} from '../../utils/multipart-form'
import { import {
parseTagNamesParam, parseTagNamesParam,
resolveUploadTagIds resolveUploadTagIds
@@ -26,20 +29,18 @@ import {
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireUploadAuth(event) await requireUploadAuth(event)
const formData = await readMultipartFormData(event) const formData = await readFormData(event)
if (!formData?.length) { if (!formData) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
} }
const tokenField = formData.find(part => part.name === 'token') const tokenField = formData.get('token')
const formToken = tokenField?.data?.length const formToken = typeof tokenField === 'string' ? tokenField.trim() : ''
? new TextDecoder().decode(tokenField.data).trim()
: ''
checkUploadRateLimit(event, formToken) checkUploadRateLimit(event, formToken)
const source = verifyApiUploadToken(event) ? 'api' : 'web' const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event, formToken) const uploadUserId = await getUploadUserId(event, formToken)
const tagNamesRaw = readMultipartFormFieldValues(formData, 'tagNames') const tagNamesRaw = readFormDataFieldValues(formData, 'tagNames')
const uploadTagNames = parseTagNamesParam( const uploadTagNames = parseTagNamesParam(
tagNamesRaw.length === 1 ? tagNamesRaw[0] : tagNamesRaw tagNamesRaw.length === 1 ? tagNamesRaw[0] : tagNamesRaw
) )
@@ -49,26 +50,19 @@ export default defineEventHandler(async (event) => {
} }
const uploadTagIds = resolvedTags.tagIds const uploadTagIds = resolvedTags.tagIds
const transcodeField = formData.find(part => part.name === 'transcode') const transcodeField = formData.get('transcode')
const wantTranscode = transcodeField?.data?.length const wantTranscode = typeof transcodeField === 'string'
? ['1', 'true', 'yes'].includes( && ['1', 'true', 'yes'].includes(transcodeField.trim().toLowerCase())
new TextDecoder().decode(transcodeField.data).trim().toLowerCase()
)
: false
const presetField = formData.find(part => part.name === 'transcodePreset') const presetField = formData.get('transcodePreset')
const presetRaw = presetField?.data?.length const presetRaw = typeof presetField === 'string' ? presetField.trim() : ''
? new TextDecoder().decode(presetField.data).trim()
: ''
const transcodePreset = presetRaw ? parseTranscodePreset(presetRaw) : undefined const transcodePreset = presetRaw ? parseTranscodePreset(presetRaw) : undefined
if (presetRaw && !transcodePreset) { if (presetRaw && !transcodePreset) {
createApiError(event, 'INVALID_REQUEST', '无效的转码质量档位', 400) createApiError(event, 'INVALID_REQUEST', '无效的转码质量档位', 400)
} }
const bitrateField = formData.find(part => part.name === 'transcodeBitratePercent') const bitrateField = formData.get('transcodeBitratePercent')
const bitrateRaw = bitrateField?.data?.length const bitrateRaw = typeof bitrateField === 'string' ? bitrateField.trim() : ''
? new TextDecoder().decode(bitrateField.data).trim()
: ''
const transcodeBitratePercent = bitrateRaw ? Number(bitrateRaw) : undefined const transcodeBitratePercent = bitrateRaw ? Number(bitrateRaw) : undefined
if ( if (
bitrateRaw bitrateRaw
@@ -86,9 +80,9 @@ export default defineEventHandler(async (event) => {
) )
} }
const fileParts = formData.filter( const fileParts = readFormDataFiles(
part => (part.name === 'file' || part.name === 'files' || part.name === 'video') formData,
&& part.data?.length ['file', 'files', 'video']
) )
if (!fileParts.length) { if (!fileParts.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
@@ -108,8 +102,8 @@ export default defineEventHandler(async (event) => {
for (const part of fileParts) { for (const part of fileParts) {
const result = await processSingleVideoUpload(event, { const result = await processSingleVideoUpload(event, {
bytes: new Uint8Array(part.data), bytes: new Uint8Array(await part.arrayBuffer()),
filename: part.filename, filename: part.name,
contentType: part.type, contentType: part.type,
source, source,
userId: uploadUserId, userId: uploadUserId,
+35 -11
View File
@@ -1,13 +1,10 @@
import type { MultiPartData } from 'h3' import type { MultiPartData } from 'h3'
export function readMultipartFormFieldValues( function parseFieldValues(rawValues: string[]): string[] {
formData: MultiPartData[], const values = rawValues.map(value => value.trim()).filter(Boolean)
fieldName: string if (values.length !== 1) return values
): string[] {
const field = formData.find(part => part.name === fieldName) const raw = values[0]!
if (field?.data?.length) {
const raw = new TextDecoder().decode(field.data).trim()
if (!raw) return []
try { try {
const parsed = JSON.parse(raw) as unknown const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
@@ -19,12 +16,39 @@ export function readMultipartFormFieldValues(
return raw.split(',').map(item => item.trim()).filter(Boolean) return raw.split(',').map(item => item.trim()).filter(Boolean)
} }
export function readMultipartFormFieldValues(
formData: MultiPartData[],
fieldName: string
): string[] {
const field = formData.find(part => part.name === fieldName)
if (field?.data?.length) {
return parseFieldValues([new TextDecoder().decode(field.data)])
}
const repeatFields = formData.filter( const repeatFields = formData.filter(
part => part.name === fieldName && part.data?.length part => part.name === fieldName && part.data?.length
) )
if (!repeatFields.length) return [] if (!repeatFields.length) return []
return repeatFields return parseFieldValues(
.map(part => new TextDecoder().decode(part.data!).trim()) repeatFields.map(part => new TextDecoder().decode(part.data!))
.filter(Boolean) )
}
export function readFormDataFieldValues(
formData: FormData,
fieldName: string
): string[] {
return parseFieldValues(
formData.getAll(fieldName).filter((value): value is string => typeof value === 'string')
)
}
export function readFormDataFiles(
formData: FormData,
fieldNames: string[]
): File[] {
return fieldNames
.flatMap(name => formData.getAll(name))
.filter((value): value is File => value instanceof File && value.size > 0)
} }
+1 -1
View File
@@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({ export default defineConfig({
test: { test: {
environment: 'node', environment: 'node',
include: ['server/**/*.test.ts'], include: ['server/**/*.test.ts', 'app/utils/**/*.test.ts'],
pool: 'forks', pool: 'forks',
setupFiles: ['./server/test/setup.ts'] setupFiles: ['./server/test/setup.ts']
} }