diff --git a/app/composables/useImageUpload.ts b/app/composables/useImageUpload.ts index caf43ab..8848049 100644 --- a/app/composables/useImageUpload.ts +++ b/app/composables/useImageUpload.ts @@ -5,6 +5,7 @@ import type { import { isUnauthorizedError } from './useAuth' import { prepareFilesForUpload } from './useClipboardImage' import { normalizeUploadTagNames } from './useUploadPreferences' +import { uploadFormDataWithProgress } from '~/utils/upload-progress' const MAX_FILES = 10 @@ -66,10 +67,10 @@ export function useImageUpload() { return } - progressItems.value = prepared.map((file, index) => ({ + progressItems.value = prepared.map(file => ({ name: file.name, status: 'uploading', - progress: Math.round(((index + 1) / prepared.length) * 50) + progress: 0 })) const formData = new FormData() @@ -82,10 +83,16 @@ export function useImageUpload() { formData.append('tagNames', tagNames) } - const response = await $fetch('/api/images/upload', { - method: 'POST', - body: formData - }) + const response = await uploadFormDataWithProgress( + '/api/images/upload', + formData, + (percent) => { + progressItems.value = progressItems.value.map(item => ({ + ...item, + progress: Math.min(99, percent) + })) + } + ) lastUploadResult.value = response diff --git a/app/composables/useMediaUpload.ts b/app/composables/useMediaUpload.ts index 03bc5ed..450b9ac 100644 --- a/app/composables/useMediaUpload.ts +++ b/app/composables/useMediaUpload.ts @@ -3,6 +3,7 @@ import type { FileItem, FileListResponse, FileUploadResponse } from '~/types/fil import type { CopyFormat, UploadMediaKind } from './useUploadPreferences' import { isUnauthorizedError } from './useAuth' import { normalizeUploadTagNames } from './useUploadPreferences' +import { uploadFormDataWithProgress } from '~/utils/upload-progress' const MAX_FILES = 10 @@ -255,13 +256,19 @@ export function useMediaUpload(kind: UploadMediaKind) { progressItems.value = progressItems.value.map(item => ({ ...item, status: 'uploading', - progress: 50 + progress: 0 })) - const response = await $fetch(endpoint, { - method: 'POST', - body: formData - }) + const response = await uploadFormDataWithProgress( + endpoint, + formData, + (percent) => { + progressItems.value = progressItems.value.map(item => ({ + ...item, + progress: Math.min(99, percent) + })) + } + ) lastResult.value = response accessMode.value = response.accessMode diff --git a/app/utils/upload-progress.test.ts b/app/utils/upload-progress.test.ts new file mode 100644 index 0000000..fbd71c2 --- /dev/null +++ b/app/utils/upload-progress.test.ts @@ -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: '未登录' + }) + }) +}) diff --git a/app/utils/upload-progress.ts b/app/utils/upload-progress.ts new file mode 100644 index 0000000..ebee8d3 --- /dev/null +++ b/app/utils/upload-progress.ts @@ -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( + url: string, + formData: FormData, + onProgress: (percent: number) => void +): Promise { + 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) + }) +} diff --git a/server/api/files/upload.post.ts b/server/api/files/upload.post.ts index bbc398f..8fd5aed 100644 --- a/server/api/files/upload.post.ts +++ b/server/api/files/upload.post.ts @@ -1,4 +1,4 @@ -import { readMultipartFormData } from 'h3' +import { readFormData } from 'h3' import type { FileItem, FileUploadResponse } from '~/types/file' import type { UploadErrorItem } from '~/types/image' import { @@ -11,7 +11,10 @@ import { createApiError } from '../../utils/api-error' import { checkUploadRateLimit } from '../../utils/rate-limit' import { processSingleFileUpload } from '../../utils/file-bed' import { getFileBedAccessMode } from '../../utils/env' -import { readMultipartFormFieldValues } from '../../utils/multipart-form' +import { + readFormDataFieldValues, + readFormDataFiles +} from '../../utils/multipart-form' import { parseTagNamesParam, resolveUploadTagIds @@ -20,20 +23,18 @@ import { export default defineEventHandler(async (event) => { await requireUploadAuth(event) - const formData = await readMultipartFormData(event) - if (!formData?.length) { + const formData = await readFormData(event) + if (!formData) { createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) } - const tokenField = formData.find(part => part.name === 'token') - const formToken = tokenField?.data?.length - ? new TextDecoder().decode(tokenField.data).trim() - : '' + const tokenField = formData.get('token') + const formToken = typeof tokenField === 'string' ? tokenField.trim() : '' checkUploadRateLimit(event, formToken) const source = verifyApiUploadToken(event) ? 'api' : 'web' 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 ) @@ -43,8 +44,9 @@ export default defineEventHandler(async (event) => { } const uploadTagIds = resolvedTags.tagIds - const fileParts = formData.filter( - part => (part.name === 'file' || part.name === 'files') && part.data?.length + const fileParts = readFormDataFiles( + formData, + ['file', 'files'] ) if (!fileParts.length) { createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) @@ -64,8 +66,8 @@ export default defineEventHandler(async (event) => { for (const part of fileParts) { const result = await processSingleFileUpload(event, { - bytes: new Uint8Array(part.data), - filename: part.filename, + bytes: new Uint8Array(await part.arrayBuffer()), + filename: part.name, contentType: part.type, source, userId: uploadUserId, diff --git a/server/api/images/upload.post.ts b/server/api/images/upload.post.ts index da79a5c..baefba7 100644 --- a/server/api/images/upload.post.ts +++ b/server/api/images/upload.post.ts @@ -1,4 +1,4 @@ -import { readMultipartFormData } from 'h3' +import { readFormData } from 'h3' import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image' import { requireUploadAuth, verifyApiUploadToken, getUploadUserId } from '../../utils/access' import { MAX_FILES_PER_UPLOAD } from '../../utils/constants' @@ -12,7 +12,10 @@ import { } from '../../utils/process-image-upload' import { createApiError } from '../../utils/api-error' import { checkUploadRateLimit } from '../../utils/rate-limit' -import { readMultipartFormFieldValues } from '../../utils/multipart-form' +import { + readFormDataFieldValues, + readFormDataFiles +} from '../../utils/multipart-form' import { parseTagNamesParam, resolveUploadTagIds @@ -21,21 +24,19 @@ import { export default defineEventHandler(async (event) => { await requireUploadAuth(event) - const formData = await readMultipartFormData(event) - if (!formData?.length) { + const formData = await readFormData(event) + if (!formData) { createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) } - const tokenField = formData.find(part => part.name === 'token') - const formToken = tokenField?.data?.length - ? new TextDecoder().decode(tokenField.data).trim() - : '' + const tokenField = formData.get('token') + const formToken = typeof tokenField === 'string' ? tokenField.trim() : '' checkUploadRateLimit(event, formToken) const source = verifyApiUploadToken(event) ? 'api' : 'web' 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 resolvedTags = resolveUploadTagIds(uploadUserId, uploadTagNames) if ('message' in resolvedTags) { @@ -44,9 +45,9 @@ export default defineEventHandler(async (event) => { const uploadTagIds = resolvedTags.tagIds // 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图) - const fileParts = formData.filter( - part => (part.name === 'image' || part.name === 'file' || part.name === 'files') - && part.data?.length + const fileParts = readFormDataFiles( + formData, + ['image', 'file', 'files'] ) if (!fileParts.length) { createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) @@ -65,8 +66,8 @@ export default defineEventHandler(async (event) => { const errors: UploadErrorItem[] = [] for (const part of fileParts) { - const originalName = part.filename ?? 'image' - const bytes = new Uint8Array(part.data) + const originalName = part.name || 'image' + const bytes = new Uint8Array(await part.arrayBuffer()) const declaredMime = part.type?.toLowerCase() ?? '' const detectedMime = detectMimeFromSignature(bytes) @@ -94,7 +95,7 @@ export default defineEventHandler(async (event) => { const result = await processSingleImageUpload(event, { bytes, - filename: part.filename, + filename: part.name, source, userId: uploadUserId, tagIds: uploadTagIds diff --git a/server/api/videos/upload.post.ts b/server/api/videos/upload.post.ts index b26f826..4097fd6 100644 --- a/server/api/videos/upload.post.ts +++ b/server/api/videos/upload.post.ts @@ -1,4 +1,4 @@ -import { readMultipartFormData } from 'h3' +import { readFormData } from 'h3' import type { FileItem, FileUploadResponse } from '~/types/file' import type { UploadErrorItem } from '~/types/image' import { @@ -17,7 +17,10 @@ import { parseTranscodePreset } from '../../utils/transcode' import { getFileBedAccessMode } from '../../utils/env' -import { readMultipartFormFieldValues } from '../../utils/multipart-form' +import { + readFormDataFieldValues, + readFormDataFiles +} from '../../utils/multipart-form' import { parseTagNamesParam, resolveUploadTagIds @@ -26,20 +29,18 @@ import { export default defineEventHandler(async (event) => { await requireUploadAuth(event) - const formData = await readMultipartFormData(event) - if (!formData?.length) { + const formData = await readFormData(event) + if (!formData) { createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) } - const tokenField = formData.find(part => part.name === 'token') - const formToken = tokenField?.data?.length - ? new TextDecoder().decode(tokenField.data).trim() - : '' + const tokenField = formData.get('token') + const formToken = typeof tokenField === 'string' ? tokenField.trim() : '' checkUploadRateLimit(event, formToken) const source = verifyApiUploadToken(event) ? 'api' : 'web' 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 ) @@ -49,26 +50,19 @@ export default defineEventHandler(async (event) => { } const uploadTagIds = resolvedTags.tagIds - const transcodeField = formData.find(part => part.name === 'transcode') - const wantTranscode = transcodeField?.data?.length - ? ['1', 'true', 'yes'].includes( - new TextDecoder().decode(transcodeField.data).trim().toLowerCase() - ) - : false + const transcodeField = formData.get('transcode') + const wantTranscode = typeof transcodeField === 'string' + && ['1', 'true', 'yes'].includes(transcodeField.trim().toLowerCase()) - const presetField = formData.find(part => part.name === 'transcodePreset') - const presetRaw = presetField?.data?.length - ? new TextDecoder().decode(presetField.data).trim() - : '' + const presetField = formData.get('transcodePreset') + const presetRaw = typeof presetField === 'string' ? presetField.trim() : '' const transcodePreset = presetRaw ? parseTranscodePreset(presetRaw) : undefined if (presetRaw && !transcodePreset) { createApiError(event, 'INVALID_REQUEST', '无效的转码质量档位', 400) } - const bitrateField = formData.find(part => part.name === 'transcodeBitratePercent') - const bitrateRaw = bitrateField?.data?.length - ? new TextDecoder().decode(bitrateField.data).trim() - : '' + const bitrateField = formData.get('transcodeBitratePercent') + const bitrateRaw = typeof bitrateField === 'string' ? bitrateField.trim() : '' const transcodeBitratePercent = bitrateRaw ? Number(bitrateRaw) : undefined if ( bitrateRaw @@ -86,9 +80,9 @@ export default defineEventHandler(async (event) => { ) } - const fileParts = formData.filter( - part => (part.name === 'file' || part.name === 'files' || part.name === 'video') - && part.data?.length + const fileParts = readFormDataFiles( + formData, + ['file', 'files', 'video'] ) if (!fileParts.length) { createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) @@ -108,8 +102,8 @@ export default defineEventHandler(async (event) => { for (const part of fileParts) { const result = await processSingleVideoUpload(event, { - bytes: new Uint8Array(part.data), - filename: part.filename, + bytes: new Uint8Array(await part.arrayBuffer()), + filename: part.name, contentType: part.type, source, userId: uploadUserId, diff --git a/server/utils/multipart-form.ts b/server/utils/multipart-form.ts index 79a5757..737b35e 100644 --- a/server/utils/multipart-form.ts +++ b/server/utils/multipart-form.ts @@ -1,22 +1,28 @@ import type { MultiPartData } from 'h3' +function parseFieldValues(rawValues: string[]): string[] { + const values = rawValues.map(value => value.trim()).filter(Boolean) + if (values.length !== 1) return values + + const raw = values[0]! + try { + const parsed = JSON.parse(raw) as unknown + if (Array.isArray(parsed)) { + return parsed.map(item => String(item).trim()).filter(Boolean) + } + } catch { + // fall through to comma-separated parsing + } + 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) { - const raw = new TextDecoder().decode(field.data).trim() - if (!raw) return [] - try { - const parsed = JSON.parse(raw) as unknown - if (Array.isArray(parsed)) { - return parsed.map(item => String(item).trim()).filter(Boolean) - } - } catch { - // fall through to comma-separated parsing - } - return raw.split(',').map(item => item.trim()).filter(Boolean) + return parseFieldValues([new TextDecoder().decode(field.data)]) } const repeatFields = formData.filter( @@ -24,7 +30,25 @@ export function readMultipartFormFieldValues( ) if (!repeatFields.length) return [] - return repeatFields - .map(part => new TextDecoder().decode(part.data!).trim()) - .filter(Boolean) + return parseFieldValues( + repeatFields.map(part => new TextDecoder().decode(part.data!)) + ) +} + +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) } diff --git a/vitest.config.ts b/vitest.config.ts index 2289f92..15316f7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { environment: 'node', - include: ['server/**/*.test.ts'], + include: ['server/**/*.test.ts', 'app/utils/**/*.test.ts'], pool: 'forks', setupFiles: ['./server/test/setup.ts'] }