diff --git a/app/pages/setup.vue b/app/pages/setup.vue index b9c2b91..b0ec426 100644 --- a/app/pages/setup.vue +++ b/app/pages/setup.vue @@ -97,11 +97,10 @@ async function submitRestore() { if (!restoreFile.value || restoreLoading.value) return restoreLoading.value = true try { - const form = new FormData() - form.append('file', restoreFile.value) const data = await $fetch<{ job: { id: string } }>('/api/setup/restore', { method: 'POST', - body: form + body: restoreFile.value, + headers: { 'content-type': 'application/octet-stream' } }) restoreJobId.value = data.job.id restorePollTimer = setInterval(() => { diff --git a/package-lock.json b/package-lock.json index bad800a..1a4a1e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pichost", - "version": "1.5.3", + "version": "1.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pichost", - "version": "1.5.3", + "version": "1.5.4", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index bb89502..cc49a0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pichost", - "version": "1.5.3", + "version": "1.5.4", "description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.", "homepage": "https://github.com/O96u/PicHost#readme", "repository": { diff --git a/server/api/admin/backup/restore.post.ts b/server/api/admin/backup/restore.post.ts index 5e98760..c391255 100644 --- a/server/api/admin/backup/restore.post.ts +++ b/server/api/admin/backup/restore.post.ts @@ -1,4 +1,4 @@ -import type { H3Event } from 'h3' +import { readFormData, type H3Event } from 'h3' import { requireAdminAuth } from '../../../utils/access' import { createApiError } from '../../../utils/api-error' import { serializeBackupJob } from '../../../utils/backup/api' @@ -14,6 +14,7 @@ import { import { createWriteStream, promises as fs } from 'node:fs' import { basename, dirname, join } from 'node:path' import { pipeline } from 'node:stream/promises' +import { readFormDataFiles } from '../../../utils/multipart-form' interface RestoreBody { archiveName?: string @@ -75,21 +76,22 @@ async function resolveRestoreInput(event: H3Event, userId: number): Promise<{ } if (contentType.includes('multipart/form-data')) { - const form = await readMultipartFormData(event) - const filePart = form?.find(part => part.name === 'file' && part.data?.length) - if (!filePart?.data) { + const form = await readFormData(event) + const filePart = readFormDataFiles(form, ['file'])[0] + if (!filePart) { createApiError(event, 'INVALID_REQUEST', '请上传备份文件', 400) } - const conflictField = form?.find(part => part.name === 'conflictMode')?.data?.toString('utf8') - const dryRunField = form?.find(part => part.name === 'dryRun')?.data?.toString('utf8') + const conflictField = form.get('conflictMode') + const dryRunField = form.get('dryRun') const dryRun = dryRunField === 'true' + const fileBytes = new Uint8Array(await filePart.arrayBuffer()) if (dryRun) { const tempDir = getBackupStagingDir(`upload-preview-${Date.now()}`) await fs.mkdir(tempDir, { recursive: true }) const archivePath = join(tempDir, 'package.phost.tar.gz') - await fs.writeFile(archivePath, filePart.data) + await fs.writeFile(archivePath, fileBytes) return { archivePath, body: { @@ -105,7 +107,7 @@ async function resolveRestoreInput(event: H3Event, userId: number): Promise<{ await fs.mkdir(getBackupsDir(), { recursive: true }) const safeName = `upload-${Date.now()}${BACKUP_ARCHIVE_EXT}` const archivePath = join(getBackupsDir(), safeName) - await fs.writeFile(archivePath, filePart.data) + await fs.writeFile(archivePath, fileBytes) return { archivePath, body: { diff --git a/server/api/setup/restore.post.ts b/server/api/setup/restore.post.ts index 7f8c393..86b5ee5 100644 --- a/server/api/setup/restore.post.ts +++ b/server/api/setup/restore.post.ts @@ -1,25 +1,45 @@ +import { getHeader, readFormData, type H3Event } from 'h3' import { createApiError } from '../../utils/api-error' import { isInitialized } from '../../utils/auth' import { serializeBackupJob } from '../../utils/backup/api' import { startFullRestoreJob } from '../../utils/backup/runner' import { BACKUP_ARCHIVE_EXT, getBackupsDir } from '../../utils/backup/paths' +import { readFormDataFiles } from '../../utils/multipart-form' import { join } from 'node:path' -import { promises as fs } from 'node:fs' +import { createWriteStream, promises as fs } from 'node:fs' +import { pipeline } from 'node:stream/promises' + +async function saveRestoreUpload(event: H3Event, archivePath: string): Promise { + const contentType = getHeader(event, 'content-type') ?? '' + try { + if (contentType.includes('application/octet-stream')) { + await pipeline(event.node.req, createWriteStream(archivePath)) + } else { + const form = await readFormData(event) + const file = readFormDataFiles(form, ['file'])[0] + if (!file) { + createApiError(event, 'INVALID_REQUEST', '请上传备份文件', 400) + } + await fs.writeFile(archivePath, new Uint8Array(await file.arrayBuffer())) + } + const stat = await fs.stat(archivePath) + if (stat.size === 0) { + createApiError(event, 'INVALID_REQUEST', '备份文件为空', 400) + } + } catch (error) { + await fs.rm(archivePath, { force: true }).catch(() => {}) + throw error + } +} export default defineEventHandler(async (event) => { if (isInitialized()) { createApiError(event, 'FORBIDDEN', '系统已初始化,请在存储页恢复备份', 403) } - const form = await readMultipartFormData(event) - const filePart = form?.find(part => part.name === 'file' && part.data?.length) - if (!filePart?.data) { - createApiError(event, 'INVALID_REQUEST', '请上传备份文件', 400) - } - await fs.mkdir(getBackupsDir(), { recursive: true }) const archivePath = join(getBackupsDir(), `setup-restore-${Date.now()}${BACKUP_ARCHIVE_EXT}`) - await fs.writeFile(archivePath, filePart.data) + await saveRestoreUpload(event, archivePath) try { const job = startFullRestoreJob(archivePath, 'overwrite') diff --git a/server/api/site-sync/object.post.ts b/server/api/site-sync/object.post.ts index 9faa5e7..63df0a1 100644 --- a/server/api/site-sync/object.post.ts +++ b/server/api/site-sync/object.post.ts @@ -1,4 +1,4 @@ -import { readMultipartFormData } from 'h3' +import { getHeader, readFormData } from 'h3' import { requireSiteSyncAuth } from '../../utils/access' import { createApiError } from '../../utils/api-error' import { MAX_VIDEO_FILE_SIZE } from '../../utils/constants' @@ -6,23 +6,27 @@ import { findUserByUsername } from '../../utils/db' import { validateStorageKey } from '../../utils/image-key' import { getActiveBackend } from '../../utils/storage/resolver' import { recordSiteSyncArtifact } from '../../utils/site-sync/artifacts' +import { readFormDataFiles } from '../../utils/multipart-form' -function field(form: Awaited>, name: string): string { - const part = form?.find(item => item.name === name) - return part?.data?.length ? new TextDecoder().decode(part.data).trim() : '' +function field(form: FormData, name: string): string { + const value = form.get(name) + return typeof value === 'string' ? value.trim() : '' } export default defineEventHandler(async (event) => { await requireSiteSyncAuth(event) - const form = await readMultipartFormData(event) - if (!form?.length) createApiError(event, 'INVALID_REQUEST', '未收到同步数据', 400) + const contentLength = Number(getHeader(event, 'content-length') || 0) + if (contentLength > MAX_VIDEO_FILE_SIZE + 1024 * 1024) { + createApiError(event, 'FILE_TOO_LARGE', '同步文件超过 500 MB', 413) + } + const form = await readFormData(event) const key = field(form, 'key') - const file = form.find(part => part.name === 'file' && part.data?.length) - if (!validateStorageKey(key) || !file?.data) { + const file = readFormDataFiles(form, ['file'])[0] + if (!validateStorageKey(key) || !file) { createApiError(event, 'INVALID_REQUEST', '同步文件参数无效', 400) } - if (file.data.length > MAX_VIDEO_FILE_SIZE) { + if (file.size > MAX_VIDEO_FILE_SIZE) { createApiError(event, 'FILE_TOO_LARGE', '同步文件超过 500 MB', 413) } @@ -51,11 +55,11 @@ export default defineEventHandler(async (event) => { }) } - await backend.put(key, new Uint8Array(file.data), { - originalName: field(form, 'originalName') || file.filename || key.split('/').pop() || 'file', + await backend.put(key, new Uint8Array(await file.arrayBuffer()), { + originalName: field(form, 'originalName') || file.name || key.split('/').pop() || 'file', uploadedAt: field(form, 'uploadedAt') || new Date().toISOString(), contentType: file.type || field(form, 'contentType') || 'application/octet-stream', - size: file.data.length, + size: file.size, userId: owner?.id ?? null, mediaType: key.startsWith('videos/') ? 'video' diff --git a/server/utils/multipart-form.test.ts b/server/utils/multipart-form.test.ts new file mode 100644 index 0000000..4622159 --- /dev/null +++ b/server/utils/multipart-form.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { readFormData, type H3Event } from 'h3' +import { + readFormDataFieldValues, + readFormDataFiles +} from './multipart-form' + +async function parseFormData(form: FormData): Promise { + const request = new Request('http://localhost/upload', { + method: 'POST', + body: form + }) + return readFormData({ web: { request } } as unknown as H3Event) +} + +describe('web FormData helpers', () => { + it('reads text fields and files from a multipart request', async () => { + const form = new FormData() + form.append('key', ' videos/2026/10/test.mov ') + form.append('tagNames', '["a","b"]') + form.append( + 'file', + new File([new Uint8Array(1024)], 'test.mov', { type: 'video/quicktime' }) + ) + + const parsed = await parseFormData(form) + + expect(parsed.get('key')).toBe(' videos/2026/10/test.mov ') + expect(readFormDataFieldValues(parsed, 'tagNames')).toEqual(['a', 'b']) + expect(readFormDataFiles(parsed, ['file'])[0]).toMatchObject({ + name: 'test.mov', + size: 1024, + type: 'video/quicktime' + }) + }) + + it('keeps repeated field values and ignores empty files', async () => { + const form = new FormData() + form.append('tagNames', 'a,b') + form.append('tagNames', 'c') + form.append('files', new File([], 'empty.txt')) + + const parsed = await parseFormData(form) + + expect(readFormDataFieldValues(parsed, 'tagNames')).toEqual(['a,b', 'c']) + expect(readFormDataFiles(parsed, ['files'])).toEqual([]) + }) +})