diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b636bc..0724852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ ## [Unreleased] +## [1.3.0] - 2026-09-07 + +### 新增 + +- **站点备份**:存储页导出 `.phost.tar.gz`(含 `pichost.db` 与全部图片),支持下载与任务进度 +- **站点恢复**:存储页或 setup 未初始化时从备份包恢复,支持预检与跳过/覆盖冲突 +- **跨后端同步**:在已配置存储后端之间拷贝图片并更新索引;后端卡片快捷「迁入 / 迁出」 +- **任务中心**:`backup_jobs` 记录最近导出/恢复/同步任务,失败可重试;列表分页 +- 运维 CLI:`backup-export`、`backup-restore`、`storage-sync`(`docker exec pichost …`) + +### 移除 + +- 遗留目录迁移 CLI `migrate` / `migrate-to-single-images.mjs`(由启动索引同步与整站备份恢复替代) + +### 文档 + +- [存储](./docs-site/guide/storage.md) 补充备份与迁移说明;更新截图;移除 v1.2 迁移专页 + ## [1.2.8] - 2026-09-04 ### 改进 @@ -119,7 +137,6 @@ - CLI 迁移工具:`docker exec pichost migrate` / `migrate --apply`(`server/cli/migrate-to-single-images.mjs`) - 扫描 `data/` 下除 `images` 外所有顶层目录中的图片,输出 `data/mapping.json` - 启动时自动同步图片索引(扫描磁盘、归一化遗留 key、清理孤儿记录),并打印同步日志 -- 迁移文档:[文档站 · v1.2 迁移](https://o96u.github.io/PicHost/guide/migration) ### 改进 diff --git a/Dockerfile b/Dockerfile index 7919a23..c44922d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,10 +23,16 @@ ENV NODE_ENV=production \ DATA_DIR=/data COPY --from=build /app/.output ./.output +COPY --from=build /app/server/utils ./server/utils +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/package.json ./package.json COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs -COPY server/cli/migrate-to-single-images.mjs ./server/cli/migrate-to-single-images.mjs COPY server/cli/slider.mjs ./server/cli/slider.mjs COPY server/cli/clear-domains.mjs ./server/cli/clear-domains.mjs +COPY server/cli/backup-cli-loader.mjs ./server/cli/backup-cli-loader.mjs +COPY server/cli/backup-export.mjs ./server/cli/backup-export.mjs +COPY server/cli/backup-restore.mjs ./server/cli/backup-restore.mjs +COPY server/cli/storage-sync.mjs ./server/cli/storage-sync.mjs COPY docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh diff --git a/README.en.md b/README.en.md index e78e403..4158463 100644 --- a/README.en.md +++ b/README.en.md @@ -31,20 +31,25 @@ **main** has no public demo. **cloudflare** branch live demo: [pic.roven.cc](https://pic.roven.cc) -| API | Gallery | -| :--: | :-----: | +| API | Gallery (detail) | +| :--: | :--------------: | | ![API](docs/screenshots/api.png) | ![Gallery](docs/screenshots/gallery.png) | | Storage | Settings | | :-----: | :-------: | | ![Storage](docs/screenshots/storage.png) | ![Settings](docs/screenshots/settings-en.png) | +| First-time setup | Restore from backup | +| :--------------: | :-----------------: | +| ![Setup](docs/screenshots/setup.png) | ![Setup restore](docs/screenshots/setup-restore.png) | + ## Features - **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection - **Multi-user** — login with slider, Turnstile, or Cap verification; optional registration; users see only their images - **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS); hybrid `proxy` / `public` URLs -- **Gallery** — browse, search, filter by storage/source, grid/list views, batch delete; stats overview and source breakdown +- **Gallery** — browse, search, filter by storage/source, grid/list views, batch delete; stats overview and source breakdown; image detail modal with link formats +- **Backup & migration** — full-site export/restore (`.phost.tar.gz`), cross-backend sync; restore from backup on uninitialized `/setup` - **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible - **Zero-config Docker** — first-run web wizard, no secrets upfront diff --git a/README.md b/README.md index 87886d2..7e467f2 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ **main** 分支暂无公网演示;**cloudflare** 分支在线体验:[pic.roven.cc](https://pic.roven.cc) -| API | 图库 | +| API | 图库(详情) | | :--: | :--: | | ![API](docs/screenshots/api.png) | ![图库](docs/screenshots/gallery.png) | @@ -39,12 +39,17 @@ | :--: | :--: | | ![存储](docs/screenshots/storage.png) | ![设置](docs/screenshots/settings.png) | +| 首次设置 | 从备份恢复 | +| :--: | :--: | +| ![首次设置](docs/screenshots/setup.png) | ![从备份恢复](docs/screenshots/setup-restore.png) | + ## 特性 - **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链 - **多用户**:账号密码登录(滑块 / Turnstile / Cap 人机验证)、可选开放注册;普通用户仅见自己的图片 - **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public` -- **图库**:浏览、搜索、按存储/来源筛选、网格/列表切换、批量删除;统计概览与来源分布 +- **图库**:浏览、搜索、按存储/来源筛选、网格/列表切换、批量删除;统计概览与来源分布;点击图片查看详情与多格式链接 +- **备份与迁移**:整站导出/恢复(`.phost.tar.gz`)、跨后端同步;setup 未初始化时可从备份恢复 - **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容 - **Docker 零配置**:首次访问 Web 引导创建管理员 @@ -74,7 +79,7 @@ docker run -d \ 浏览器打开 `http://<主机IP>:6892`,按引导完成设置即可。 -**详细说明**(环境变量、升级迁移、双域名、API、Twikoo、反向代理、本地开发等)见 **[用户文档](https://o96u.github.io/PicHost/)**。 +**详细说明**(环境变量、双域名、API、Twikoo、反向代理、本地开发等)见 **[用户文档](https://o96u.github.io/PicHost/)**。 | 分支 | 说明 | | ---- | ---- | diff --git a/app/components/storage/BackendSyncCard.vue b/app/components/storage/BackendSyncCard.vue new file mode 100644 index 0000000..e5d84a4 --- /dev/null +++ b/app/components/storage/BackendSyncCard.vue @@ -0,0 +1,444 @@ + + + diff --git a/app/components/storage/BackupExportCard.vue b/app/components/storage/BackupExportCard.vue new file mode 100644 index 0000000..99e93be --- /dev/null +++ b/app/components/storage/BackupExportCard.vue @@ -0,0 +1,141 @@ + + + diff --git a/app/components/storage/BackupJobProgress.vue b/app/components/storage/BackupJobProgress.vue new file mode 100644 index 0000000..35f035c --- /dev/null +++ b/app/components/storage/BackupJobProgress.vue @@ -0,0 +1,32 @@ + + + diff --git a/app/components/storage/BackupJobTable.vue b/app/components/storage/BackupJobTable.vue new file mode 100644 index 0000000..925a095 --- /dev/null +++ b/app/components/storage/BackupJobTable.vue @@ -0,0 +1,257 @@ + + + diff --git a/app/components/storage/BackupRestoreCard.vue b/app/components/storage/BackupRestoreCard.vue new file mode 100644 index 0000000..73b00e0 --- /dev/null +++ b/app/components/storage/BackupRestoreCard.vue @@ -0,0 +1,305 @@ + + + diff --git a/app/components/storage/StorageBackendCard.vue b/app/components/storage/StorageBackendCard.vue index fc7735a..57c1a2f 100644 --- a/app/components/storage/StorageBackendCard.vue +++ b/app/components/storage/StorageBackendCard.vue @@ -5,6 +5,8 @@ const props = defineProps<{ backend: StorageBackendItem envOverride?: boolean busy?: boolean + showMigrateIn?: boolean + showMigrateOut?: boolean }>() const emit = defineEmits<{ @@ -13,6 +15,8 @@ const emit = defineEmits<{ test: [] toggleEnabled: [] delete: [] + migrateIn: [] + migrateOut: [] }>() const { t } = useI18n() @@ -129,6 +133,28 @@ function canDelete() { :disabled="actionsDisabled" @click="emit('setDefault')" /> + + +import type { BackupJobItem, SyncDraft } from '~/types/backup' +import type { StorageBackendItem } from '~/types/storage' + +const props = defineProps<{ + backends: StorageBackendItem[] +}>() + +const { t } = useI18n() +const toast = useToast() +const { + jobs, + jobsPage, + jobsPageSize, + jobsTotal, + jobsTotalPages, + jobsLoading, + estimate, + packages, + loadingEstimate, + polling, + hasActiveJob, + getActiveJob, + loadJobs, + setJobsPage, + setJobsPageSize, + loadEstimate, + startPolling, + createExport, + previewRestore, + confirmRestore, + previewSync, + startSync, + retryJob, + cancelJob, + downloadUrl +} = useBackup() + +const syncDraft = ref(null) +const syncCardRef = ref<{ applyDraft: (draft: SyncDraft) => void } | null>(null) +const panelRef = ref(null) +const syncHighlighted = ref(false) + +const activeJob = computed(() => getActiveJob()) +const exportBusy = computed(() => activeJob.value?.type === 'full_export') +const restoreBusy = computed(() => activeJob.value?.type === 'full_restore') +const syncBusy = computed(() => activeJob.value?.type === 'backend_sync') +const anyJobBusy = computed(() => Boolean(activeJob.value)) + +onMounted(async () => { + await Promise.all([loadJobs(), loadEstimate()]) + if (hasActiveJob()) { + startPolling() + } +}) + +function applySyncDraft(draft: SyncDraft) { + syncDraft.value = draft + syncCardRef.value?.applyDraft(draft) + syncHighlighted.value = true + window.setTimeout(() => { + syncHighlighted.value = false + }, 2500) + nextTick(() => { + panelRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }) + toast.add({ title: t('storage.backup.syncDraftApplied'), color: 'info' }) +} + +defineExpose({ applySyncDraft }) + +async function onExport() { + await createExport() +} + +async function onRestore(input: { + file?: File + archiveName?: string + conflictMode: 'skip' | 'overwrite' +}) { + await confirmRestore(input) +} + +async function onCancel(jobId: string) { + await cancelJob(jobId) +} + +async function onSync(body: { + fromBackendId: string + toBackendId: string + deleteSource: boolean + setDefault: boolean +}) { + await startSync(body) +} + +function onDownload(jobId: string) { + window.open(downloadUrl(jobId), '_blank') +} + +async function onRetry(jobId: string) { + await retryJob(jobId) +} + +function onViewJob(job: BackupJobItem) { + toast.add({ + title: t('storage.backup.jobTypeRestore'), + description: job.message, + color: 'info' + }) +} + + + diff --git a/app/composables/useBackup.ts b/app/composables/useBackup.ts new file mode 100644 index 0000000..2084684 --- /dev/null +++ b/app/composables/useBackup.ts @@ -0,0 +1,319 @@ +import type { + BackupEstimate, + BackupJobItem, + BackupPackageItem, + BackupRestorePreview, + BackendSyncPreview +} from '~/types/backup' + +const jobs = ref([]) +const jobsPage = ref(1) +const jobsPageSize = ref(10) +const jobsTotal = ref(0) +const jobsTotalPages = ref(1) +const jobsLoading = ref(false) +const activeJobState = ref(null) +const estimate = ref(null) +const packages = ref([]) +const loadingEstimate = ref(false) +const polling = ref(false) +let pollTimer: ReturnType | null = null + +export function useBackup() { + const toast = useToast() + const { t } = useI18n() + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + polling.value = false + } + + function hasActiveJob(): boolean { + return activeJobState.value !== null + } + + function getActiveJob(): BackupJobItem | null { + return activeJobState.value + } + + async function cancelJob(jobId: string) { + try { + await $fetch(`/api/admin/backup/jobs/${jobId}/cancel`, { + method: 'POST', + credentials: 'include' + }) + await loadJobs() + toast.add({ title: t('storage.backup.cancelDone'), color: 'success' }) + } catch (error: unknown) { + handleError(error, t('storage.backup.cancelFailed')) + } + } + + async function loadJobs(options?: { page?: number, pageSize?: number }) { + if (options?.page !== undefined) jobsPage.value = options.page + if (options?.pageSize !== undefined) jobsPageSize.value = options.pageSize + jobsLoading.value = true + try { + const data = await $fetch<{ + jobs: BackupJobItem[] + total: number + page: number + pageSize: number + totalPages: number + activeJob: BackupJobItem | null + }>('/api/admin/backup/jobs', { + credentials: 'include', + query: { + pageNum: jobsPage.value, + pageSize: jobsPageSize.value + } + }) + jobs.value = data.jobs + jobsTotal.value = data.total + jobsPage.value = data.page + jobsPageSize.value = data.pageSize + jobsTotalPages.value = data.totalPages + activeJobState.value = data.activeJob + if (!data.activeJob) { + stopPolling() + void loadEstimate().catch(() => {}) + } + return data.jobs + } finally { + jobsLoading.value = false + } + } + + async function setJobsPage(page: number) { + jobsPage.value = page + return loadJobs() + } + + async function setJobsPageSize(pageSize: number) { + jobsPageSize.value = pageSize + jobsPage.value = 1 + return loadJobs() + } + + async function refreshJobsFirstPage() { + jobsPage.value = 1 + return loadJobs() + } + + async function loadEstimate() { + loadingEstimate.value = true + try { + const data = await $fetch<{ + estimate: BackupEstimate + packages: BackupPackageItem[] + backupsDir: string + }>('/api/admin/backup/estimate', { credentials: 'include' }) + estimate.value = data.estimate + packages.value = data.packages + return data + } finally { + loadingEstimate.value = false + } + } + + function startPolling() { + if (pollTimer) return + polling.value = true + pollTimer = setInterval(() => { + void loadJobs().catch(() => {}) + }, 2000) + } + + async function createExport() { + try { + const data = await $fetch<{ job: BackupJobItem, estimate: BackupEstimate }>( + '/api/admin/backup/export', + { method: 'POST', credentials: 'include' } + ) + estimate.value = data.estimate + await refreshJobsFirstPage() + startPolling() + toast.add({ title: t('storage.backup.exportStarted'), color: 'success' }) + return data.job + } catch (error: unknown) { + handleError(error, t('storage.backup.exportFailed')) + throw error + } + } + + async function previewRestore(input: { + file?: File + archiveName?: string + }): Promise { + if (input.file) { + const form = new FormData() + form.append('file', input.file) + form.append('dryRun', 'true') + const data = await $fetch<{ preview: BackupRestorePreview }>( + '/api/admin/backup/restore', + { method: 'POST', credentials: 'include', body: form } + ) + return data.preview + } + + const data = await $fetch<{ preview: BackupRestorePreview }>( + '/api/admin/backup/restore', + { + method: 'POST', + credentials: 'include', + body: { + archiveName: input.archiveName, + dryRun: true + } + } + ) + return data.preview + } + + async function confirmRestore(input: { + file?: File + archiveName?: string + conflictMode: 'skip' | 'overwrite' + }) { + try { + if (input.file) { + const form = new FormData() + form.append('file', input.file) + form.append('conflictMode', input.conflictMode) + const data = await $fetch<{ job: BackupJobItem }>( + '/api/admin/backup/restore', + { method: 'POST', credentials: 'include', body: form } + ) + await refreshJobsFirstPage() + startPolling() + toast.add({ title: t('storage.backup.restoreStarted'), color: 'success' }) + return data.job + } + + const data = await $fetch<{ job: BackupJobItem }>( + '/api/admin/backup/restore', + { + method: 'POST', + credentials: 'include', + body: { + archiveName: input.archiveName, + conflictMode: input.conflictMode, + dryRun: false + } + } + ) + await refreshJobsFirstPage() + startPolling() + toast.add({ title: t('storage.backup.restoreStarted'), color: 'success' }) + return data.job + } catch (error: unknown) { + handleError(error, t('storage.backup.restoreFailed')) + throw error + } + } + + async function previewSync(body: { + fromBackendId: string + toBackendId: string + }): Promise { + const data = await $fetch<{ preview: BackendSyncPreview }>( + '/api/admin/storage/sync', + { + method: 'POST', + credentials: 'include', + body: { ...body, dryRun: true } + } + ) + return data.preview + } + + async function startSync(body: { + fromBackendId: string + toBackendId: string + deleteSource?: boolean + setDefault?: boolean + }) { + try { + const data = await $fetch<{ job: BackupJobItem }>( + '/api/admin/storage/sync', + { + method: 'POST', + credentials: 'include', + body + } + ) + await refreshJobsFirstPage() + startPolling() + toast.add({ title: t('storage.backup.syncStarted'), color: 'success' }) + return data.job + } catch (error: unknown) { + handleError(error, t('storage.backup.syncFailed')) + throw error + } + } + + async function retryJob(jobId: string) { + try { + await $fetch(`/api/admin/backup/jobs/${jobId}/retry`, { + method: 'POST', + credentials: 'include' + }) + await refreshJobsFirstPage() + startPolling() + toast.add({ title: t('storage.backup.retryStarted'), color: 'success' }) + } catch (error: unknown) { + handleError(error, t('storage.backup.retryFailed')) + } + } + + function downloadUrl(jobId: string): string { + return `/api/admin/backup/jobs/${jobId}/download` + } + + function handleError(error: unknown, fallback: string) { + const status = typeof error === 'object' && error !== null && 'statusCode' in error + ? (error as { statusCode: number }).statusCode + : 0 + if (status === 409) { + toast.add({ title: t('storage.backup.jobRunning'), color: 'warning' }) + return + } + toast.add({ title: fallback, color: 'error' }) + } + + onUnmounted(() => { + stopPolling() + }) + + return { + jobs, + jobsPage, + jobsPageSize, + jobsTotal, + jobsTotalPages, + jobsLoading, + estimate, + packages, + loadingEstimate, + polling, + hasActiveJob, + getActiveJob, + loadJobs, + setJobsPage, + setJobsPageSize, + loadEstimate, + startPolling, + stopPolling, + createExport, + previewRestore, + confirmRestore, + previewSync, + startSync, + retryJob, + cancelJob, + downloadUrl + } +} diff --git a/app/pages/setup.vue b/app/pages/setup.vue index 550f739..c3c4e79 100644 --- a/app/pages/setup.vue +++ b/app/pages/setup.vue @@ -29,6 +29,93 @@ const imageBaseUrl = ref('') const loading = ref(false) const domainSeparationConfirmOpen = ref(false) +const setupMode = ref<'init' | 'restore'>('init') +const restoreFile = ref(null) +const restoreFileInput = ref(null) +const restoreLoading = ref(false) +const restoreJobId = ref(null) +const restoreProgress = ref({ progress: 0, total: 0, message: '' }) +let restorePollTimer: ReturnType | null = null + +function stopRestorePolling() { + if (restorePollTimer) { + clearInterval(restorePollTimer) + restorePollTimer = null + } +} + +function pickRestoreFile() { + restoreFileInput.value?.click() +} + +function onRestoreFileChange(event: Event) { + const input = event.target as HTMLInputElement + restoreFile.value = input.files?.[0] ?? null + if (input) input.value = '' +} + +async function pollRestoreJob(jobId: string) { + try { + const data = await $fetch<{ job: { status: string, progress: number, total: number, message: string, error: string | null } }>( + `/api/setup/restore/${jobId}` + ) + restoreProgress.value = { + progress: data.job.progress, + total: data.job.total, + message: data.job.message + } + if (data.job.status === 'done') { + stopRestorePolling() + restoreLoading.value = false + toast.add({ + title: t('setup.restoreSuccess'), + description: t('setup.restoreSecretsHint'), + color: 'success' + }) + await router.replace('/') + return + } + if (data.job.status === 'failed') { + stopRestorePolling() + restoreLoading.value = false + toast.add({ + title: t('setup.restoreFailed'), + description: data.job.error ?? data.job.message, + color: 'error' + }) + } + } catch { + stopRestorePolling() + restoreLoading.value = false + toast.add({ title: t('setup.restoreFailed'), color: 'error' }) + } +} + +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 + }) + restoreJobId.value = data.job.id + restorePollTimer = setInterval(() => { + void pollRestoreJob(data.job.id) + }, 1500) + await pollRestoreJob(data.job.id) + } catch { + restoreLoading.value = false + toast.add({ title: t('setup.restoreFailed'), color: 'error' }) + } +} + +onUnmounted(() => { + stopRestorePolling() +}) + const domainSeparationWouldActivate = computed(() => willActivateDomainSeparation( domainSeparation.value, @@ -149,7 +236,28 @@ async function performSubmit() { +
+ + +
+
@@ -269,6 +377,73 @@ async function performSubmit() { :disabled="!canSubmit" />
+ +
+
+ + +
+ +
+ {{ t('setup.restoreHint') }} +
+ + + + + + +
(null) const deleteConfirmName = ref('') const deleting = ref(false) +const backupPanelRef = ref<{ applySyncDraft: (draft: { fromBackendId: string, toBackendId: string }) => void } | null>(null) + +function onMigrateIn(backend: StorageBackendItem) { + const defaultBackend = storageData.value?.backends.find(item => item.isDefault) + if (!defaultBackend || defaultBackend.id === backend.id) return + backupPanelRef.value?.applySyncDraft({ + fromBackendId: defaultBackend.id, + toBackendId: backend.id + }) +} + +function onMigrateOut(backend: StorageBackendItem) { + const backends = storageData.value?.backends ?? [] + if (backend.isDefault) { + const target = backends.find(item => !item.isDefault && item.enabled) + if (!target) return + backupPanelRef.value?.applySyncDraft({ + fromBackendId: backend.id, + toBackendId: target.id + }) + return + } + + const defaultBackend = backends.find(item => item.isDefault) + if (!defaultBackend) return + backupPanelRef.value?.applySyncDraft({ + fromBackendId: backend.id, + toBackendId: defaultBackend.id + }) +} + +function showMigrateIn(backend: StorageBackendItem): boolean { + const backends = storageData.value?.backends ?? [] + return !backend.isDefault + && backend.enabled + && backends.some(item => item.isDefault) +} + +function showMigrateOut(backend: StorageBackendItem): boolean { + const backends = storageData.value?.backends ?? [] + if (backend.usage.count === 0) return false + if (backend.isDefault) { + return backends.some(item => !item.isDefault && item.enabled) + } + return backends.some(item => item.isDefault) +} + const defaultBackendName = computed(() => { const backend = storageData.value?.backends.find(item => item.isDefault) return backend?.name ?? t('storage.backendLocal') @@ -297,14 +344,24 @@ watch(isAuthenticated, async (authed) => { :backend="backend" :env-override="storageData.envOverride" :busy="busyId === backend.id" + :show-migrate-in="showMigrateIn(backend)" + :show-migrate-out="showMigrateOut(backend)" @set-default="setDefault(backend)" @edit="openEdit(backend)" @test="testBackend(backend)" @toggle-enabled="toggleEnabled(backend)" @delete="confirmDelete(backend)" + @migrate-in="onMigrateIn(backend)" + @migrate-out="onMigrateOut(backend)" /> + + diff --git a/app/types/backup.ts b/app/types/backup.ts new file mode 100644 index 0000000..8519127 --- /dev/null +++ b/app/types/backup.ts @@ -0,0 +1,53 @@ +export type BackupJobType = 'full_export' | 'full_restore' | 'backend_sync' +export type BackupJobStatus = 'pending' | 'running' | 'done' | 'failed' + +export interface BackupJobItem { + id: string + type: BackupJobType + status: BackupJobStatus + progress: number + total: number + message: string + payload: Record + outputPath: string | null + archiveName: string | null + downloadable: boolean + error: string | null + createdAt: string + finishedAt: string | null +} + +export interface BackupEstimate { + imageCount: number + imageBytes: number + databaseBytes: number + totalBytes: number +} + +export interface BackupPackageItem { + name: string + bytes: number + modifiedAt: string +} + +export interface BackupRestorePreview { + imageCount: number + imageBytes: number + newImages: number + conflictingImages: number + formatVersion: number + appVersion: string + createdAt: string +} + +export interface BackendSyncPreview { + imageCount: number + imageBytes: number + fromBackendId: string + toBackendId: string +} + +export interface SyncDraft { + fromBackendId: string + toBackendId: string +} diff --git a/docker-compose.yml b/docker-compose.yml index 75732e3..d304ad8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: - "6892:6892" # 首次访问 Web 引导创建管理员,无需预先配置环境变量 # 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等 - # 运维:docker exec pichost reset-password | migrate [--apply] | slider | clear-domains [--site] + # 运维:docker exec pichost reset-password | slider | clear-domains | backup-export | backup-restore | storage-sync volumes: # 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data) - ./data:/data diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 170caf9..a7cde88 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -5,10 +5,6 @@ if [ "$1" = "reset-password" ]; then exec node /app/server/cli/reset-password.mjs "${@:2}" fi -if [ "$1" = "migrate" ]; then - exec node /app/server/cli/migrate-to-single-images.mjs "${@:2}" -fi - if [ "$1" = "slider" ]; then exec node /app/server/cli/slider.mjs "${@:2}" fi @@ -17,4 +13,16 @@ if [ "$1" = "clear-domains" ]; then exec node /app/server/cli/clear-domains.mjs "${@:2}" fi +if [ "$1" = "backup-export" ]; then + exec node /app/server/cli/backup-export.mjs "${@:2}" +fi + +if [ "$1" = "backup-restore" ]; then + exec node /app/server/cli/backup-restore.mjs "${@:2}" +fi + +if [ "$1" = "storage-sync" ]; then + exec node /app/server/cli/storage-sync.mjs "${@:2}" +fi + exec node /app/.output/server/index.mjs "$@" diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts index 49c7e8a..0cc61fa 100644 --- a/docs-site/.vitepress/config.ts +++ b/docs-site/.vitepress/config.ts @@ -34,7 +34,6 @@ const zhSidebar = [ text: '升级', items: [ { text: '更新日志', link: '/guide/changelog' }, - { text: 'v1.2 迁移', link: '/guide/migration' }, { text: '常见问题', link: '/guide/faq' } ] } @@ -71,7 +70,6 @@ const enSidebar = [ text: 'Upgrade', items: [ { text: 'Changelog', link: '/en/guide/changelog' }, - { text: 'v1.2 migration', link: '/en/guide/migration' }, { text: 'FAQ', link: '/en/guide/faq' } ] } diff --git a/docs-site/en/guide/changelog.md b/docs-site/en/guide/changelog.md index 3b94458..0f19b67 100644 --- a/docs-site/en/guide/changelog.md +++ b/docs-site/en/guide/changelog.md @@ -159,15 +159,12 @@ Version history and notable changes. The full log is also in the repo root [CHAN ### Changed -- All images under `data/images/`; legacy `blog/` / `twikoo/` need migration +- All images under `data/images/`; parallel top-level folders beside `images` are no longer used ### Added -- CLI: `docker exec pichost migrate` / `migrate --apply` - Startup index sync (disk scan, legacy key normalization, orphan cleanup) -See [v1.2 migration](./migration.md). - --- ## [1.1.x] summary diff --git a/docs-site/en/guide/faq.md b/docs-site/en/guide/faq.md index a6fe790..a614dc6 100644 --- a/docs-site/en/guide/faq.md +++ b/docs-site/en/guide/faq.md @@ -61,21 +61,21 @@ Recommended: run PicHost on origin; use **proxied DNS** (and optional **R2** sto - Always open admin on the **site hostname**, not mixed with `localhost` - See [Dual-domain separation](./domain-separation.md) -## Undeletable gallery items after v1.2 upgrade? +## Undeletable gallery items? -Likely **orphan index** rows (no file on disk). Startup sync removes them on restart. See [v1.2 migration](./migration.md#step-2-startup-index-sync-automatic). +Likely **orphan index** rows (no file on disk). Startup sync removes them on restart; you can also delete them from the gallery. -## Do I still need `data/blog/` after migration? +## How do I back up or migrate the full site? -After `migrate --apply`, files live under `data/images/blog/`. Remove leftover empty dirs after backup if you like; dirs with non-image files are kept by the CLI. +From v1.3.0, use **Storage → Backup & migration**, or CLI: -## Object storage only — run migrate? +```bash +docker exec pichost backup-export +docker exec pichost backup-restore /data/backups/pichost-xxx.phost.tar.gz +docker exec pichost storage-sync --from local --to s3-xxx --dry-run +``` -No. Skip the CLI; startup sync focuses on local `data/images/` scanning. - -## Replace old image URLs in posts? - -Use `data/mapping.json` from `migrate` preview. Keys are **storage paths**, not full legacy URLs. See [Migration](./migration.md#urls). +Packages include the database and all images but **not** cloud storage secrets — re-enter them on the Storage page after restore. See [Storage → Backup & migration](./storage.md#backup--migration-v130). ## Skip login during development? diff --git a/docs-site/en/guide/getting-started.md b/docs-site/en/guide/getting-started.md index a0d7219..867d2b5 100644 --- a/docs-site/en/guide/getting-started.md +++ b/docs-site/en/guide/getting-started.md @@ -33,10 +33,18 @@ See `docker-compose.yml` in the repository. ## First-time setup 1. Open your instance URL (Docker default `http://:6892`). -2. Complete `/setup` with admin username and password. +2. On `/setup`, choose **Create admin** or **Restore backup**: + - **Create admin** — username, password, optional registration and dual-domain options. + - **Restore backup** — upload a `.phost.tar.gz` full-site package (replaces current data; re-enter cloud storage secrets after restore). 3. (Optional) Configure site URL, image URL, Referer rules — see [Environment variables](./configuration.md) and [Dual-domain separation](./domain-separation.md). 4. Upload from the home page; use the top nav for **Gallery**, **Storage** (admin), **Settings**, and **API**. +| Create admin | Restore from backup | +| :----------: | :-----------------: | +| ![Setup](/screenshots/setup.png) | ![Setup restore](/screenshots/setup-restore.png) | + +For an already-initialized instance, use [Storage → Backup & migration](./storage.md#backup--migration-v130) instead of `/setup`. + ## Sign in Management pages (upload, gallery, settings, etc.) require login. Enter username and password, complete the verification challenge, then click **Sign in**. @@ -80,12 +88,6 @@ docker exec pichost clear-domains Local: `npm run clear-domains`. See [FAQ — Admin 404 after dual-domain setup](./faq.md#admin-404--locked-out-after-dual-domain-setup). -## Upgrading to v1.2.0 - -If `data/` has legacy top-level folders next to `images` (e.g. `blog/`, `twikoo/`), read the [v1.2 migration guide](./migration.md) first. - -Object-storage-only or files already under `data/images/` — pull the new image and restart. - ## Next steps - [Local development](./local-dev.md) diff --git a/docs-site/en/guide/index.md b/docs-site/en/guide/index.md index 5114215..d1089ad 100644 --- a/docs-site/en/guide/index.md +++ b/docs-site/en/guide/index.md @@ -12,16 +12,20 @@ Welcome to the PicHost user guide. This site is the single source of truth; the ## Screenshots -**main** has no public demo. Below are current product screenshots (v1.2.5+). +**main** has no public demo. Below are current product screenshots (v1.3.0+). -| API | Gallery | -| :-: | :-----: | +| API | Gallery (detail) | +| :-: | :--------------: | | ![API](/screenshots/api.png) | ![Gallery](/screenshots/gallery.png) | | Storage | Settings | | :-----: | :-------: | | ![Storage](/screenshots/storage.png) | ![Settings](/screenshots/settings-en.png) | +| First-time setup | Restore from backup | +| :--------------: | :-----------------: | +| ![Setup](/screenshots/setup.png) | ![Setup restore](/screenshots/setup-restore.png) | + | Activity log | | :----------: | | ![Activity log](/screenshots/logs.png) | diff --git a/docs-site/en/guide/local-dev.md b/docs-site/en/guide/local-dev.md index 682b96c..35d92d1 100644 --- a/docs-site/en/guide/local-dev.md +++ b/docs-site/en/guide/local-dev.md @@ -30,7 +30,6 @@ Open `http://localhost:3000/setup` to create the admin account. Data directory d | `npm run reset-password` | Reset password (optional username) | | `npm run slider` | Reset login verification to local slider | | `npm run clear-domains` | Clear site/image URL settings from DB (recover from dual-domain lockout) | -| `npm run migrate` | Preview legacy dir migration (`-- --apply` to run) | ## Dev-only variables @@ -53,4 +52,4 @@ npm run docs:build npm run docs:preview ``` -Sources live in `docs-site/`; screenshot sources are in `docs/screenshots/` (including `login.png`), copied to `docs-site/public/screenshots/` before build. Live docs: +Sources live in `docs-site/`; screenshot sources are in `docs/screenshots/` (including `login.png`, `setup.png`, `setup-restore.png`, etc.), copied to `docs-site/public/screenshots/` before build. Live docs: diff --git a/docs-site/en/guide/migration.md b/docs-site/en/guide/migration.md deleted file mode 100644 index ff640f3..0000000 --- a/docs-site/en/guide/migration.md +++ /dev/null @@ -1,129 +0,0 @@ -# v1.2.0 local disk migration guide - -From v1.2.0, **all local image files** live under `data/images/`; SQLite `keys` also start with `images/`. Legacy top-level folders next to `images` (e.g. `data/blog/`, `data/twikoo/`) are no longer used. - -Migration is **two steps**: run the **CLI** to move files on disk, then let **startup sync** align the SQLite index. - ---- - -## Migrating from other image hosts (EasyImage, Lsky, Chevereto, …) - -There is **no** dedicated “pull from URLs” or “import via vendor API” tool. Moving from **legacy PicHost** or **another self-hosted** install uses the same **local file** workflow: - -1. Locate the **image files** on the old server (copy the tree, not just the database). -2. Place them under PicHost `data/` as a **top-level folder** next to `images` (any name — it becomes `images/{name}/...`). -3. Run `migrate` / `migrate --apply`, then restart PicHost. - -| Source | Typical path on old server | After copy into PicHost | -| ------ | -------------------------- | ----------------------- | -| PicHost v1.1.x | `data/blog/`, `data/twikoo/` | Already under `data/` → run `migrate` | -| EasyImage(s) | `i/` or `upload/` | e.g. `data/easyimage/` → `migrate` | -| Lsky | `public/uploads/` etc. | e.g. `data/lsky/` → `migrate` | -| Chevereto etc. | `images/` or `content/` | e.g. `data/chevereto/` → `migrate` | -| Custom | your folder name | `data/your-folder/` → `migrate` | - -The CLI scans **every top-level directory under `data/` except `images`**, not only `blog` / `twikoo`. - -**Limits:** files only — accounts, tokens, and old public URLs are not imported. `mapping.json` maps old **storage keys**, not full old-site URLs. For S3/R2-only legacy hosts, sync blobs to `data/…` or attach the bucket in PicHost **Storage** settings. - ---- - -## Layout before and after - -### Before (typical v1.1.x) - -``` -/data -├── pichost.db -├── blog/ -│ └── 2026/08/TeKJ2fD6cB6j.webp -├── twikoo/ -└── images/ -``` - -Index keys might look like `blog/2026/08/TeKJ2fD6cB6j.webp`. - -### After (v1.2.0) - -``` -/data -├── pichost.db -├── mapping.json -└── images/ - ├── blog/ - │ └── 2026/08/TeKJ2fD6cB6j.webp - └── twikoo/ -``` - -Index key: `images/blog/2026/08/TeKJ2fD6cB6j.webp`. - ---- - -## Step 1: CLI file migration - -Run **before or after** upgrading the image; backup `data/` first. - -```bash -docker exec pichost migrate # preview + mapping.json -docker exec pichost migrate --apply # move files -``` - -Local: `npm run migrate` / `npm run migrate -- --apply`. - -| Item | Behavior | -| ---- | -------- | -| Scan | Every top-level directory under `data/` **except `images`** | -| Target | `data/images/{original-top-dir}/{relative-path}` | -| Extensions | jpg, jpeg, png, webp, gif, svg, ico | -| Sidecar | `.meta.json` next to an image is moved if present | -| Other files | Not moved; parent directory kept if anything remains | -| Mapping | `data/mapping.json` — old key → new key | - -Script: `server/cli/migrate-to-single-images.mjs`. - ---- - -## Step 2: Startup index sync (automatic) - -Runs on every start (`npm run dev`, Docker). No manual command. - -```text -[INFO] 开始同步图片索引:扫描本地磁盘 ... -[INFO] 磁盘扫描完成:... -[INFO] 开始归一化遗留图片路径 ... -[INFO] 开始校验索引与磁盘一致性,清理无文件的孤儿记录 -[INFO] 图片索引同步结束 -``` - -| Step | Purpose | -| ---- | ------- | -| Scan `data/images/` | Insert missing index rows for files on disk | -| Normalize legacy keys | `blog/...` → `images/blog/...` when files are in place | -| Purge orphans | Remove index rows with no file on disk | -| Repair Content-Type | Fix `application/octet-stream` entries | - -The CLI only **moves files**. Index alignment runs at startup — **restart** after `migrate --apply`. - ---- - -## Recommended upgrade flow - -1. Backup `data/`. -2. `docker exec pichost migrate` — review output and `mapping.json`. -3. `docker exec pichost migrate --apply`. -4. Deploy v1.2.0 and restart. -5. Check logs for successful index sync. -6. Use `mapping.json` to update external URLs if needed. - -Skip step 1 if you only use object storage or everything is already under `data/images/`. - ---- - -## URLs - -| Old key | `blog/TeKJ2fD6cB6j.webp` | -| New key | `images/blog/TeKJ2fD6cB6j.webp` | - -With “hide folder” mode, short URLs like `/TeKJ2fD6cB6j.webp` may still resolve by filename. Use `mapping.json` for bulk replacements. - -Chinese version: [v1.2 迁移指南](/guide/migration) diff --git a/docs-site/en/guide/storage.md b/docs-site/en/guide/storage.md index b63ced1..f904203 100644 --- a/docs-site/en/guide/storage.md +++ b/docs-site/en/guide/storage.md @@ -54,13 +54,48 @@ Copied links use **IMAGE_BASE_URL**. “Hide folder prefix” may shorten URLs; ## Usage & gallery filter - Per-backend usage on the Storage page -- Gallery filters by **storage backend** and **upload source**; grid/list view toggle +- Gallery filters by **storage backend** and **upload source**; grid/list view toggle; click a thumbnail for the detail modal (dimensions, storage path, link formats) -## Backup +## Backup & migration (v1.3.0+) -Back up `data/images/`, `data/pichost.db`, and bucket objects. +The **Backup & migration** panel has three action cards and a paginated **Recent jobs** table below: -Upgrading from v1.1.x with parallel folders: [v1.2 migration](./migration.md). +| Feature | Description | +| ------- | ------------- | +| **Create site backup** | Export `.phost.tar.gz` with `pichost.db` and all images (including objects on cloud backends); shows estimated size and package contents | +| **Restore from backup** | Upload locally or pick an existing package under `data/backups/`, preview, then confirm; skip or overwrite conflicting images | +| **Cross-backend sync** | Copy images between configured backends and update the index; shows pending count/size, dry-run, optional delete-source and set-default | + +![Backup & migration](/screenshots/storage.png) + +- Exports are saved under `data/backups/`; only one backup/migration job may run at a time +- Job list supports pagination; download exports, view restore summaries, retry failed sync jobs +- Packages **do not** include cloud storage secrets — re-enter them on the Storage page after restore +- Uninitialized instances can use the **Restore backup** tab on `/setup` (always overwrite mode) + +![Setup restore](/screenshots/setup-restore.png) + +### CLI + +```bash +docker exec pichost backup-export +docker exec pichost backup-restore /data/backups/pichost-xxx.phost.tar.gz +docker exec pichost backup-restore /data/backups/pichost-xxx.phost.tar.gz --overwrite +docker exec pichost storage-sync --from local --to s3-xxx --dry-run +docker exec pichost storage-sync --from local --to s3-xxx --set-default +``` + +Local development (project root): + +```bash +npm run backup-export +npm run backup-restore -- data/backups/pichost-xxx.phost.tar.gz +npm run storage-sync -- --from local --to s3-xxx --dry-run +``` + +### Manual backup + +You can still back up `data/images/`, `data/pichost.db`, and bucket objects directly; prefer the full-site export above. ## See also diff --git a/docs-site/guide/changelog.md b/docs-site/guide/changelog.md index 02e87d2..c4a548a 100644 --- a/docs-site/guide/changelog.md +++ b/docs-site/guide/changelog.md @@ -159,15 +159,12 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C ### 变更 -- 图片统一存放在 `data/images/`;遗留 `blog/`、`twikoo/` 等并列目录需迁移 +- 图片统一存放在 `data/images/`;不再使用与 `images` 并列的顶层目录 ### 新增 -- CLI:`docker exec pichost migrate` / `migrate --apply` - 启动时自动同步图片索引(扫描磁盘、归一化遗留 key、清理孤儿记录) -详见 [v1.2 迁移](./migration.md)。 - --- ## [1.1.x] 摘要 diff --git a/docs-site/guide/faq.md b/docs-site/guide/faq.md index eb8a7d0..29b0f20 100644 --- a/docs-site/guide/faq.md +++ b/docs-site/guide/faq.md @@ -62,21 +62,21 @@ docker exec pichost clear-domains - 后台请用 **网站域名** 打开,不要用 `localhost` 混用 - 见 [双域名分离](./domain-separation.md) -## 升级 v1.2 后图库有删不掉的记录? +## 图库有删不掉的记录? -可能是 **孤儿索引**(有记录无文件)。重启后启动同步会自动清理;也可手动删除仅索引项。见 [v1.2 迁移](./migration.md#第二步启动时自动同步索引)。 +可能是 **孤儿索引**(有记录无文件)。重启后启动同步会自动清理;也可在图库中手动删除。 -## 遗留 `data/blog/` 目录还要吗? +## 如何备份与迁移整站? -执行 `migrate --apply` 后文件已迁入 `data/images/blog/`。若原目录只剩空壳或非图片文件,可自行备份后删除;迁移脚本不会删除仍含非图片文件的目录。 +v1.3.0 起在 **存储** 页使用「备份与迁移」面板,或 CLI: -## 只用对象存储需要跑 migrate 吗? +```bash +docker exec pichost backup-export +docker exec pichost backup-restore /data/backups/pichost-xxx.phost.tar.gz +docker exec pichost storage-sync --from local --to s3-xxx --dry-run +``` -不需要。图片在桶内时跳过 CLI;启动同步主要面向本地 `data/images/` 磁盘扫描。 - -## 如何替换博客里的旧图片 URL? - -使用 `data/mapping.json`(`migrate` 预览时生成)批量替换旧 key。注意映射的是 **存储 key**,不是完整旧站 URL。见 [迁移指南](./migration.md#外链与直链)。 +备份包含数据库与全部图片,**不含** 云存储密钥;恢复后请在存储页重新填写。详见 [存储 → 备份与迁移](./storage.md#备份与迁移v130)。 ## 开发时如何跳过登录? diff --git a/docs-site/guide/getting-started.md b/docs-site/guide/getting-started.md index 31b7af6..e876817 100644 --- a/docs-site/guide/getting-started.md +++ b/docs-site/guide/getting-started.md @@ -33,10 +33,18 @@ docker compose up -d ## 首次设置 1. 访问实例 URL(Docker 默认 `http://<主机>:6892`)。 -2. 在 `/setup` 填写管理员用户名与密码。 +2. 在 `/setup` 选择 **创建管理员** 或 **从备份恢复**: + - **创建管理员**:填写用户名与密码,可选开放注册、双域名等。 + - **从备份恢复**:上传 `.phost.tar.gz` 整站备份包(覆盖当前数据;云存储密钥需恢复后重新填写)。 3. (可选)配置网站域名、图片域名、防盗链等 — 详见 [环境变量](./configuration.md) 与 [双域名分离](./domain-separation.md)。 4. 登录后可在首页上传图片;顶栏可进入 **图库**、**存储**(管理员)、**设置**、**API**。 +| 创建管理员 | 从备份恢复 | +| :--------: | :--------: | +| ![首次设置](/screenshots/setup.png) | ![从备份恢复](/screenshots/setup-restore.png) | + +已初始化实例请使用 [存储 → 备份与迁移](./storage.md#备份与迁移v130) 恢复,而非 setup 页。 + ## 登录 访问上传、图库、设置等管理页面前需登录。在登录卡片中填写用户名与密码,完成人机验证后点击 **登录**。 @@ -83,12 +91,6 @@ docker exec pichost clear-domains 本地:`npm run clear-domains`。详见 [常见问题 — 双域名配置后后台 404](./faq.md#双域名配置后后台-404-进不去)。 -## 升级到 v1.2.0 - -若 `data/` 下存在与 `images` 并列的遗留目录(如 `blog/`、`twikoo/`),升级前请阅读 [v1.2 迁移指南](./migration.md)。 - -仅对象存储、或图片已在 `data/images/` 下时,拉取新镜像并重启即可。 - ## 下一步 - [本地开发](./local-dev.md) — `npm run dev` 与 `.env` diff --git a/docs-site/guide/index.md b/docs-site/guide/index.md index c13bfbe..7524672 100644 --- a/docs-site/guide/index.md +++ b/docs-site/guide/index.md @@ -12,9 +12,9 @@ ## 截图预览 -**main** 分支暂无公网演示,以下为当前产品界面(v1.2.5+)。 +**main** 分支暂无公网演示,以下为当前产品界面(v1.3.0+)。 -| API | 图库 | +| API | 图库(详情) | | :-: | :-: | | ![API](/screenshots/api.png) | ![图库](/screenshots/gallery.png) | @@ -22,6 +22,10 @@ | :------: | :--------: | | ![存储管理](/screenshots/storage.png) | ![设置与偏好](/screenshots/settings.png) | +| 首次设置 | 从备份恢复 | +| :------: | :--------: | +| ![首次设置](/screenshots/setup.png) | ![从备份恢复](/screenshots/setup-restore.png) | + | 操作日志 | | :------: | | ![操作日志](/screenshots/logs.png) | diff --git a/docs-site/guide/local-dev.md b/docs-site/guide/local-dev.md index e315d03..093e03a 100644 --- a/docs-site/guide/local-dev.md +++ b/docs-site/guide/local-dev.md @@ -30,7 +30,6 @@ npm run dev | `npm run reset-password` | 重置密码(可选用户名) | | `npm run slider` | 将登录验证重置为本地滑块 | | `npm run clear-domains` | 清除数据库中的网站域 / 图片域配置(双域名锁死时恢复) | -| `npm run migrate` | 预览遗留目录迁移(加 `-- --apply` 执行) | ## 开发专用环境变量 @@ -53,4 +52,4 @@ npm run docs:build # 构建静态站点 npm run docs:preview # 预览构建结果 ``` -文档源码在 `docs-site/`;截图源文件在 `docs/screenshots/`(含 `login.png` 登录页),构建前会复制到 `docs-site/public/screenshots/`。在线文档: +文档源码在 `docs-site/`;截图源文件在 `docs/screenshots/`(含 `login.png`、`setup.png`、`setup-restore.png` 等),构建前会复制到 `docs-site/public/screenshots/`。在线文档: diff --git a/docs-site/guide/migration.md b/docs-site/guide/migration.md deleted file mode 100644 index ec8c732..0000000 --- a/docs-site/guide/migration.md +++ /dev/null @@ -1,176 +0,0 @@ -# v1.2.0 本地磁盘迁移指南 - -v1.2.0 起,**所有本地图片文件**统一放在 `data/images/` 下;SQLite `images` 表中的 `key` 也以 `images/` 开头。旧版在 `data/blog/`、`data/twikoo/` 等与 `images` **并列的顶层目录**不再符合新模型。 - -迁移分 **两步**:先用手动 CLI **搬文件**,再由服务 **启动时自动同步索引**。 - ---- - -## 从其它图床迁入(EasyImage / 兰空 / Chevereto 等) - -**没有**单独的「一键从外链拉取」或「对接某某图床 API」工具。从 **PicHost 旧版** 或其它自托管图床迁入,走的是同一套 **本地文件迁移**: - -1. 在旧图床服务器上找到图片**物理目录**(整目录拷贝,不要只拷数据库)。 -2. 把该目录放到 PicHost 的 `data/` 下,作为与 `images` **并列的顶层文件夹**(文件夹名任意,会保留为 `images/{文件夹名}/...`)。 -3. 执行 `migrate` / `migrate --apply`,再重启 PicHost。 - -### 常见来源示例 - -| 来源 | 典型图片目录(在旧服务器上) | 放入 PicHost 后示例 | -| ---- | ---------------------------- | ------------------- | -| PicHost v1.1.x | `data/blog/`、`data/twikoo/` | 已在 `data/` 下,直接 `migrate` | -| EasyImage / EasyImages | `i/` 或安装目录下的 `upload/` | 拷到 `data/easyimage/` 再 `migrate` | -| 兰空图床 (Lsky) | `public/uploads/` 等 | 拷到 `data/lsky/` 再 `migrate` | -| Chevereto 等 | `images/` 或 `content/` | 拷到 `data/chevereto/` 再 `migrate` | -| 任意自定义目录 | 你的顶层文件夹名 | 拷到 `data/你的目录名/` 再 `migrate` | - -脚本会扫描 `data/` 下**除 `images` 外所有顶层目录**里的图片,不限于 `blog` / `twikoo`。子目录结构会原样保留在 `images/{顶层名}/` 下。 - -### 操作示例(Docker) - -```bash -# 在宿主机:把从旧图床打包的目录放进挂载卷(名称自定) -# 例如:./data/easyimage/2026/01/photo.jpg - -docker exec pichost migrate # 预览 -docker exec pichost migrate --apply -# 重启容器 → 启动日志会补全 SQLite 索引 -``` - -### 说明与限制 - -- **只迁移文件**:旧图床的账号、Token、外链 URL **不会**自动导入;`mapping.json` 里的 key 是「旧磁盘相对 key → 新 key」,**不是**旧站完整 URL,替换外链需自行对照。 -- **对象存储**:若旧图床图片在 S3/R2 桶里,请用桶工具同步到本地 `data/某目录/`,或直接在 PicHost **存储**页配置同一桶(不经过本 CLI)。 -- **已直接在 `data/images/` 下**:无需 `migrate`,重启后启动同步会自动建索引。 - ---- - -## 存储结构对比 - -### 迁移前(v1.1.x 常见) - -``` -/data -├── pichost.db -├── blog/ -│ └── 2026/08/TeKJ2fD6cB6j.webp -├── twikoo/ -│ └── xxx.webp -└── images/ # 可能已有部分新图 - └── ... -``` - -SQLite 中 `key` 可能是 `blog/2026/08/TeKJ2fD6cB6j.webp`(与磁盘顶层目录一致)。 - -### 迁移后(v1.2.0) - -``` -/data -├── pichost.db -├── mapping.json # CLI 生成:旧 key → 新 key(供替换外链) -└── images/ - ├── blog/ - │ └── 2026/08/TeKJ2fD6cB6j.webp - ├── twikoo/ - │ └── xxx.webp - └── ... # 新上传:images/随机ID.webp 或 images/年/月/随机ID.webp -``` - -对应 `key`:`images/blog/2026/08/TeKJ2fD6cB6j.webp`。 - ---- - -## 第一步:CLI 迁移磁盘文件 - -在 **升级镜像之前或之后**均可执行;建议在升级前完成,并先备份整个 `data/` 目录。 - -### Docker - -```bash -# 预览:列出待迁移文件,写入 data/mapping.json(旧 key → 新 key) -docker exec pichost migrate - -# 确认无误后执行文件移动 -docker exec pichost migrate --apply -``` - -### 本地开发 - -```bash -npm run migrate # 预览 -npm run migrate -- --apply # 执行 -``` - -### 脚本行为 - -| 项目 | 说明 | -| ---- | ---- | -| 扫描范围 | `data/` 下**除 `images` 外所有顶层目录**(不限于 blog/twikoo) | -| 迁移目标 | `data/images/{原顶层目录名}/{相对路径}` | -| 图片类型 | `.jpg` `.jpeg` `.png` `.webp` `.gif` `.svg` `.ico` | -| 伴随文件 | 若存在同名的 `.meta.json` 会一并移动 | -| 非图片文件 | 不移动;若目录内仍有其它文件,**保留原目录**不删除 | -| 映射表 | `data/mapping.json`,例如 `"blog/foo.webp": "images/blog/foo.webp"` | - -脚本路径:`server/cli/migrate-to-single-images.mjs`。 - ---- - -## 第二步:启动时自动同步索引 - -每次 PicHost 启动(`npm run dev`、Docker 容器启动)会自动执行,**无需手动命令**。日志示例: - -```text -[INFO] 开始同步图片索引:扫描本地磁盘 {"path":".../data/images/"} -[INFO] 磁盘扫描完成:已补全缺失索引 {"inserted":3} -[INFO] 开始归一化遗留图片路径(如 blog/ → images/blog/) -[INFO] 遗留路径归一化完成:无需调整 -[INFO] 开始校验索引与磁盘一致性,清理无文件的孤儿记录 -[INFO] 孤儿索引清理完成:未发现孤儿记录 -[INFO] 开始修复存量 Content-Type 元数据 -[INFO] Content-Type 修复完成:无需修复 -[INFO] 图片索引同步结束 -``` - -| 步骤 | 作用 | -| ---- | ---- | -| 扫描 `data/images/` | 磁盘上有、索引里没有 → **补全** `images` 表 | -| 归一化遗留 key | 将 `blog/...` 等旧 key 更新为 `images/blog/...`(文件已在正确路径时) | -| 清理孤儿索引 | 索引里有记录、磁盘无文件 → **删除**幽灵记录(避免图库出现删不掉的项) | -| 修复 Content-Type | 修正错误的 `application/octet-stream` | - -CLI 只负责**搬文件**;索引对齐、遗留 key、孤儿记录由启动同步处理。执行 `migrate --apply` 后**重启一次**服务即可。 - ---- - -## 推荐升级流程 - -1. **备份** `data/`(含 `pichost.db` 与各目录)。 -2. `docker exec pichost migrate` 预览,检查输出与 `mapping.json`。 -3. `docker exec pichost migrate --apply` 执行迁移。 -4. 拉取 / 部署 **v1.2.0** 镜像并重启容器。 -5. 查看启动日志,确认「图片索引同步结束」无报错。 -6. 用 `mapping.json` 批量替换博客、Twikoo 等外链中的旧路径(若需要)。 - -仅使用对象存储、或图片早已全部在 `data/images/` 下时,**跳过第一步**;第二步仍会在启动时运行,通常几秒内完成且无变更日志。 - ---- - -## 外链与直链 - -| 场景 | 说明 | -| ---- | ---- | -| 旧 key | `blog/TeKJ2fD6cB6j.webp` | -| 新 key | `images/blog/TeKJ2fD6cB6j.webp` | -| 隐藏目录模式 | 若开启「隐藏 images 前缀」,短链可能为 `/TeKJ2fD6cB6j.webp`;服务按文件名反查索引 | -| 替换建议 | 以 `mapping.json` 为准;或重新在图库复制新链接 | - ---- - -## 相关命令 - -| 命令 | 说明 | -| ---- | ---- | -| `docker exec pichost migrate` | 预览磁盘迁移 | -| `docker exec pichost migrate --apply` | 执行磁盘迁移 | -| `docker exec pichost reset-password` | 重置密码(与迁移无关) | diff --git a/docs-site/guide/storage.md b/docs-site/guide/storage.md index 7933a8a..8969fd7 100644 --- a/docs-site/guide/storage.md +++ b/docs-site/guide/storage.md @@ -56,17 +56,53 @@ PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、 ## 用量与筛选 - 存储页展示各后端用量(本地扫描目录,对象存储查询 API) -- 图库可按 **存储后端**、**上传来源** 筛选,支持网格/列表视图切换 +- 图库可按 **存储后端**、**上传来源** 筛选,支持网格/列表视图切换;点击图片打开详情弹窗(尺寸、存储路径、多格式链接复制) -## 备份 +## 备份与迁移(v1.3.0+) -请同时备份: +存储页 **备份与迁移** 面板包含三张功能卡与下方 **最近任务** 列表: -- `data/images/`(本地后端) -- `data/pichost.db` -- 各云桶中的对象 +| 能力 | 说明 | +| ---- | ---- | +| **创建站点备份** | 导出 `.phost.tar.gz`,含 `pichost.db` 与全部图片(含云存储中的对象);展示预计体积与备份包含项 | +| **从备份恢复** | 本地上传或选择 `data/backups/` 内已有包,预检后确认;可选跳过/覆盖冲突图片 | +| **跨后端同步** | 在已配置后端间拷贝图片并更新索引;展示待迁移数量与体积,支持预检、删源、设默认 | -从 v1.1.x 升级且存在 `data/blog/` 等并列目录时,见 [v1.2 迁移](./migration.md)。 +![备份与迁移](/screenshots/storage.png) + +- 导出文件保存在 `data/backups/`;后端同一时间仅允许一个进行中的备份/迁移任务 +- 任务列表支持分页,可下载导出包、查看恢复结果、重试失败的同步任务 +- 备份包 **不含** 云存储 Access Key / Secret Key,恢复后请在存储页重新填写 +- 未初始化实例可在 **setup** 页 **从备份恢复** 标签上传整站包(始终覆盖模式) + +![从备份恢复(setup)](/screenshots/setup-restore.png) + +### CLI + +```bash +# 导出整站备份 +docker exec pichost backup-export + +# 从备份恢复(默认跳过冲突图片) +docker exec pichost backup-restore /data/backups/pichost-xxx.phost.tar.gz +docker exec pichost backup-restore /data/backups/pichost-xxx.phost.tar.gz --overwrite + +# 跨后端同步 +docker exec pichost storage-sync --from local --to s3-xxx --dry-run +docker exec pichost storage-sync --from local --to s3-xxx --set-default +``` + +本地开发(项目根目录): + +```bash +npm run backup-export +npm run backup-restore -- data/backups/pichost-xxx.phost.tar.gz +npm run storage-sync -- --from local --to s3-xxx --dry-run +``` + +### 手动备份 + +仍可同时备份 `data/images/`(本地后端)、`data/pichost.db` 与各云桶中的对象;推荐使用上文整站导出。 ## 相关 diff --git a/docs/screenshots/gallery.png b/docs/screenshots/gallery.png index 3cfc608..2f401e5 100644 Binary files a/docs/screenshots/gallery.png and b/docs/screenshots/gallery.png differ diff --git a/docs/screenshots/setup-restore.png b/docs/screenshots/setup-restore.png new file mode 100644 index 0000000..1bcd4f2 Binary files /dev/null and b/docs/screenshots/setup-restore.png differ diff --git a/docs/screenshots/setup.png b/docs/screenshots/setup.png index a3937a9..a3876e4 100644 Binary files a/docs/screenshots/setup.png and b/docs/screenshots/setup.png differ diff --git a/docs/screenshots/storage.png b/docs/screenshots/storage.png index 28cf7b0..231aa34 100644 Binary files a/docs/screenshots/storage.png and b/docs/screenshots/storage.png differ diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 5bd5546..7181479 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -110,7 +110,18 @@ "migrateSubmit": "Complete migration", "initSubmit": "Create and continue", "migrateSuccess": "Migration complete", - "initSuccess": "Setup complete" + "initSuccess": "Setup complete", + "restoreTitle": "Restore from backup", + "restoreSubtitle": "Upload a .phost.tar.gz to restore the full site", + "restoreHint": "Restore replaces all current data. Cloud storage secrets are not in the backup—re-enter them on the Storage page after sign-in.", + "restoreUpload": "Choose backup file", + "restoreSubmit": "Start restore", + "restoreProgress": "Restoring… {progress}/{total}", + "restoreSuccess": "Restore complete. Sign in with the admin account from the backup.", + "restoreFailed": "Restore failed", + "restoreSecretsHint": "Cloud storage secrets are not in the backup. Re-enter them on the Storage page after sign-in.", + "modeInit": "Create admin", + "modeRestore": "Restore backup" }, "register": { "title": "Register", @@ -388,7 +399,125 @@ "deleteConfirmBody": "Delete “{name}”? This cannot be undone.", "secretsRequired": "Access Key and Secret Key are required for new storage", "manageStorage": "Manage object storage", - "manageStorageHint": "Configure R2 / S3 / COS backends and URL serving" + "manageStorageHint": "Configure R2 / S3 / COS backends and URL serving", + "backup": { + "sectionTitle": "Backup & migration", + "sectionSubtitle": "Full-site backup, restore, and cross-backend image migration", + "siteTitle": "Site backup & restore", + "siteSubtitle": "For migration, disaster recovery, and full-site restore", + "siteFooterHint": "Backups include the database and images, not plain-text secret_json.", + "exportTitle": "Create site backup", + "exportSubtitle": "Package the database and all images as .phost.tar.gz", + "exportAction": "Start backup", + "exportStarted": "Backup job started", + "exportFailed": "Failed to start backup", + "exportPathHint": "Exports are saved under data/backups/", + "exportIncludesTitle": "Package includes", + "exportIncludesDb": "Database snapshot (pichost.db)", + "exportIncludesImages": "All image files ({count})", + "exportExcludesSecrets": "Excludes plain-text secret.json for cloud storage", + "exportConcurrencyHint": "Only one backup or migration job can run at a time", + "estimateLoading": "Estimating size…", + "estimateImageCountLabel": "Images", + "estimatePackageSizeLabel": "Est. package", + "estimateDatabaseSnapshotLabel": "Database snapshot", + "estimateStoragePathLabel": "Save location", + "estimateStoragePathValue": "data/backups/", + "estimateImages": "{count} images · {size}", + "estimateDatabase": "Database ~{size}", + "estimateTotal": "Total ~{size}", + "restoreTitle": "Restore from backup", + "restoreSubtitle": "Upload or pick a .phost.tar.gz on the server, preview, then confirm", + "restoreUpload": "Choose backup file", + "restoreUploadLabel": "Upload from device", + "restoreUploadHint": "Click to select a .phost.tar.gz backup", + "restoreUploadAction": "Upload & restore", + "restoreServerAction": "Server backup", + "restoreServerPackages": "Server backups", + "restoreServerSelectPlaceholder": "Select a backup", + "restorePreview": "Preview", + "restorePreviewConfirm": "Preview & confirm", + "restoreConfirm": "Confirm restore", + "restoreConfirmTitle": "Confirm site restore", + "restoreConfirmSource": "Source: {name}", + "restoreOverwriteWarning": "Overwrite mode replaces conflicting images and the database. Ensure you have a current backup.", + "restoreSelectRequired": "Select or upload a backup file", + "restoreStarted": "Restore job started", + "restoreFailed": "Restore failed", + "conflictMode": "Conflict policy", + "conflictSkip": "Skip existing images", + "conflictOverwrite": "Overwrite existing images and database", + "syncTitle": "Cross-backend sync", + "syncMigrateTitle": "Cross-backend migration", + "syncMigrateSubtitle": "Migrate images between configured backends", + "syncSubtitle": "Copy images between configured backends and update the index", + "syncFrom": "Source backend", + "syncTo": "Target backend", + "syncPendingImages": "Images to migrate", + "syncPendingSize": "Est. size", + "syncIncludesTitle": "What sync does", + "syncIncludesCopy": "Copy image files from source to target backend", + "syncIncludesIndex": "Update storage_backend_id in the index", + "syncIncludesConfig": "Site settings and other backends are unchanged", + "syncDryRun": "Dry run only", + "syncSetDefaultAfter": "Set target as default after migration", + "syncStartMigrate": "Start migration", + "syncPreviewOnly": "Preview", + "syncFooterHint": "Only image files and index are migrated; site settings are unchanged.", + "syncDeleteSource": "Delete source files after sync", + "syncSetDefault": "Set target as default after sync", + "syncPreview": "Preview & start", + "syncConfirm": "Start sync", + "syncConfirmTitle": "Confirm cross-backend sync", + "syncDeleteWarning": "Source files will be deleted. This cannot be undone.", + "syncSelectRequired": "Select source and target backends", + "syncSameBackend": "Source and target must differ", + "syncStarted": "Sync job started", + "syncFailed": "Sync failed", + "previewFailed": "Preview failed", + "previewImages": "Images", + "previewNew": "New images", + "previewConflicts": "Conflicts", + "previewCreated": "Backup created", + "previewSize": "Data size", + "jobsTitle": "Recent jobs", + "jobsSubtitle": "Track backup, restore, and migration progress", + "jobsEmpty": "No backup or migration jobs yet", + "jobsEmptyHint": "Progress appears here after export, restore, or sync starts", + "jobsUnit": "jobs", + "colTask": "Task", + "colType": "Type", + "colStatus": "Status", + "colProgress": "Progress", + "colMessage": "Message", + "colCreated": "Created", + "colActions": "Actions", + "jobTypeExport": "Site export", + "jobTypeRestore": "Site restore", + "jobTypeSync": "Cross-backend sync", + "statusPending": "Pending", + "statusRunning": "Running", + "statusDone": "Done", + "statusFailed": "Failed", + "jobFailedUnknown": "Unknown error", + "download": "Download", + "jobView": "View", + "retry": "Retry", + "retryStarted": "Retry started", + "retryFailed": "Retry failed", + "jobRunning": "Another job is already running. Try again later.", + "migrateIn": "Migrate in", + "migrateOut": "Migrate out", + "migrateInHint": "Copy images from the default backend to this one", + "migrateOutHint": "Move images out of this backend", + "syncDraftApplied": "Sync form prefilled — review and start when ready", + "syncNeedTwoBackends": "At least two storage backends are required to sync", + "selectBackend": "Select backend", + "syncConfirmRoute": "{from} → {to}", + "cancel": "Cancel", + "cancelDone": "Job cancelled", + "cancelFailed": "Failed to cancel job" + } }, "stats": { "title": "Gallery", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index a2ef923..f812646 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -110,7 +110,18 @@ "migrateSubmit": "完成迁移", "initSubmit": "创建并进入", "migrateSuccess": "账号迁移完成", - "initSuccess": "初始化完成" + "initSuccess": "初始化完成", + "restoreTitle": "从备份包恢复", + "restoreSubtitle": "上传 .phost.tar.gz 恢复整站数据", + "restoreHint": "恢复将覆盖当前全部数据。云存储密钥未包含在备份中,登录后请在存储页重新填写。", + "restoreUpload": "选择备份文件", + "restoreSubmit": "开始恢复", + "restoreProgress": "恢复中… {progress}/{total}", + "restoreSuccess": "恢复完成,请使用备份中的管理员账号登录", + "restoreFailed": "恢复失败", + "restoreSecretsHint": "云存储密钥未包含在备份中,登录后请在存储页重新填写", + "modeInit": "创建管理员", + "modeRestore": "从备份恢复" }, "register": { "title": "注册账号", @@ -388,7 +399,125 @@ "deleteConfirmBody": "确定删除「{name}」?此操作不可撤销。", "secretsRequired": "新建存储需填写 Access Key 与 Secret Key", "manageStorage": "管理对象存储", - "manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略" + "manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略", + "backup": { + "sectionTitle": "备份与迁移", + "sectionSubtitle": "整站备份恢复与跨后端图片迁移", + "siteTitle": "站点备份与恢复", + "siteSubtitle": "用于换机、灾备与整站恢复", + "siteFooterHint": "备份包含数据库与图片,不包含 secret_json 明文。", + "exportTitle": "创建站点备份", + "exportSubtitle": "打包数据库与全部图片为 .phost.tar.gz", + "exportAction": "开始备份", + "exportStarted": "备份任务已开始", + "exportFailed": "创建备份失败", + "exportPathHint": "导出文件保存在 data/backups/", + "exportIncludesTitle": "备份包含", + "exportIncludesDb": "数据库快照(pichost.db)", + "exportIncludesImages": "全部图片文件({count} 张)", + "exportExcludesSecrets": "不含云存储 secret.json 明文", + "exportConcurrencyHint": "同时仅可运行一个备份或迁移任务", + "estimateLoading": "正在估算体积…", + "estimateImageCountLabel": "图片总数", + "estimatePackageSizeLabel": "预计备份包", + "estimateDatabaseSnapshotLabel": "数据库快照", + "estimateStoragePathLabel": "保存位置", + "estimateStoragePathValue": "data/backups/", + "estimateImages": "图片 {count} 张 · {size}", + "estimateDatabase": "数据库约 {size}", + "estimateTotal": "合计约 {size}", + "restoreTitle": "从备份恢复", + "restoreSubtitle": "上传或选择服务器上的 .phost.tar.gz,预检后确认恢复", + "restoreUpload": "选择备份文件", + "restoreUploadLabel": "本地上传", + "restoreUploadHint": "点击选择 .phost.tar.gz 备份包", + "restoreUploadAction": "上传备份恢复", + "restoreServerAction": "选择服务器备份", + "restoreServerPackages": "服务器已有备份", + "restoreServerSelectPlaceholder": "选择备份包", + "restorePreview": "预检", + "restorePreviewConfirm": "预检并确认", + "restoreConfirm": "确认恢复", + "restoreConfirmTitle": "确认恢复站点", + "restoreConfirmSource": "来源:{name}", + "restoreOverwriteWarning": "覆盖模式将替换冲突图片与数据库,请确认已做好当前站点备份", + "restoreSelectRequired": "请选择或上传备份文件", + "restoreStarted": "恢复任务已开始", + "restoreFailed": "恢复失败", + "conflictMode": "冲突策略", + "conflictSkip": "跳过已存在图片", + "conflictOverwrite": "覆盖已存在图片与数据库", + "syncTitle": "跨后端同步", + "syncMigrateTitle": "跨后端迁移", + "syncMigrateSubtitle": "在已配置后端之间迁移图片", + "syncSubtitle": "在已配置的后端之间拷贝图片并更新索引", + "syncFrom": "源后端", + "syncTo": "目标后端", + "syncPendingImages": "待迁移图片", + "syncPendingSize": "预计体积", + "syncIncludesTitle": "同步说明", + "syncIncludesCopy": "拷贝源端图片文件到目标后端", + "syncIncludesIndex": "更新索引中的 storage_backend_id", + "syncIncludesConfig": "不修改站点配置与其它后端数据", + "syncDryRun": "仅预览 Dry Run", + "syncSetDefaultAfter": "迁移后设为默认后端", + "syncStartMigrate": "开始迁移", + "syncPreviewOnly": "预览", + "syncFooterHint": "仅迁移图片文件与索引,不影响站点配置。", + "syncDeleteSource": "同步后删除源端文件", + "syncSetDefault": "同步后将目标设为默认", + "syncPreview": "预检并开始", + "syncConfirm": "开始同步", + "syncConfirmTitle": "确认跨后端同步", + "syncDeleteWarning": "将删除源后端上的图片文件,此操作不可撤销", + "syncSelectRequired": "请选择源与目标后端", + "syncSameBackend": "源与目标不能相同", + "syncStarted": "同步任务已开始", + "syncFailed": "同步失败", + "previewFailed": "预检失败", + "previewImages": "图片数量", + "previewNew": "新增图片", + "previewConflicts": "冲突图片", + "previewCreated": "备份时间", + "previewSize": "数据量", + "jobsTitle": "最近任务", + "jobsSubtitle": "查看备份、恢复与迁移进度", + "jobsEmpty": "暂无备份或迁移任务", + "jobsEmptyHint": "导出、恢复或同步开始后,进度会显示在这里", + "jobsUnit": "条任务", + "colTask": "任务", + "colType": "类型", + "colStatus": "状态", + "colProgress": "进度", + "colMessage": "说明", + "colCreated": "创建时间", + "colActions": "操作", + "jobTypeExport": "站点导出", + "jobTypeRestore": "站点恢复", + "jobTypeSync": "跨后端同步", + "statusPending": "等待中", + "statusRunning": "进行中", + "statusDone": "已完成", + "statusFailed": "失败", + "jobFailedUnknown": "未知错误", + "download": "下载", + "jobView": "查看", + "retry": "重试", + "retryStarted": "重试任务已开始", + "retryFailed": "重试失败", + "jobRunning": "已有任务正在运行,请稍后再试", + "migrateIn": "迁入", + "migrateOut": "迁出", + "migrateInHint": "从默认后端拷贝图片到此后端", + "migrateOutHint": "将图片从此后端迁出", + "syncDraftApplied": "已填入同步表单,请确认后开始", + "syncNeedTwoBackends": "至少需要两个存储后端才能同步", + "selectBackend": "选择后端", + "syncConfirmRoute": "{from} → {to}", + "cancel": "取消", + "cancelDone": "任务已取消", + "cancelFailed": "取消任务失败" + } }, "stats": { "title": "图库", diff --git a/package-lock.json b/package-lock.json index bd262fd..b886ed2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,11 +17,13 @@ "cap-widget": "^0.1.57", "nuxt": "^4.4.8", "sharp": "^0.35.3", - "tailwindcss": "^4.3.1" + "tailwindcss": "^4.3.1", + "tar": "^7.5.22" }, "devDependencies": { "@nuxt/eslint": "^1.16.0", "@types/node": "^22.15.0", + "@types/tar": "^6.1.13", "eslint": "^10.6.0", "mermaid": "^11.17.2", "typescript": "^6.0.3", @@ -8772,6 +8774,27 @@ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "license": "MIT" }, + "node_modules/@types/tar": { + "version": "6.1.13", + "resolved": "https://registry.npmmirror.com/@types/tar/-/tar-6.1.13.tgz", + "integrity": "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "minipass": "^4.0.0" + } + }, + "node_modules/@types/tar/node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -18797,9 +18820,9 @@ } }, "node_modules/tar": { - "version": "7.5.20", - "resolved": "https://registry.npmmirror.com/tar/-/tar-7.5.20.tgz", - "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "version": "7.5.22", + "resolved": "https://registry.npmmirror.com/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", diff --git a/package.json b/package.json index cec5001..2f75a12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pichost", - "version": "1.2.8", + "version": "1.3.0", "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": { @@ -34,9 +34,11 @@ "test:watch": "vitest", "start": "node .output/server/index.mjs", "reset-password": "node server/cli/reset-password.mjs", - "migrate": "node server/cli/migrate-to-single-images.mjs", "slider": "node server/cli/slider.mjs", "clear-domains": "node server/cli/clear-domains.mjs", + "backup-export": "node server/cli/backup-export.mjs", + "backup-restore": "node server/cli/backup-restore.mjs", + "storage-sync": "node server/cli/storage-sync.mjs", "docs:prepare": "node docs-site/copy-screenshots.mjs", "docs:dev": "npm run docs:prepare && vitepress dev docs-site", "docs:build": "npm run docs:prepare && vitepress build docs-site", @@ -50,11 +52,13 @@ "cap-widget": "^0.1.57", "nuxt": "^4.4.8", "sharp": "^0.35.3", - "tailwindcss": "^4.3.1" + "tailwindcss": "^4.3.1", + "tar": "^7.5.22" }, "devDependencies": { "@nuxt/eslint": "^1.16.0", "@types/node": "^22.15.0", + "@types/tar": "^6.1.13", "eslint": "^10.6.0", "mermaid": "^11.17.2", "typescript": "^6.0.3", diff --git a/server/api/admin/backup/estimate.get.ts b/server/api/admin/backup/estimate.get.ts new file mode 100644 index 0000000..4e8cdc2 --- /dev/null +++ b/server/api/admin/backup/estimate.get.ts @@ -0,0 +1,40 @@ +import { requireAdminAuth } from '../../../utils/access' +import { getBackupExportEstimate } from '../../../utils/backup/stats' +import { getBackupsDir, isSafeBackupFilename } from '../../../utils/backup/paths' +import { promises as fs } from 'node:fs' +import { join } from 'node:path' + +export default defineEventHandler(async (event) => { + await requireAdminAuth(event) + const estimate = await getBackupExportEstimate() + + const packages = await (async () => { + try { + await fs.mkdir(getBackupsDir(), { recursive: true }) + const entries = await fs.readdir(getBackupsDir(), { withFileTypes: true }) + const items = await Promise.all( + entries + .filter(entry => entry.isFile() && isSafeBackupFilename(entry.name)) + .map(async (entry) => { + const full = join(getBackupsDir(), entry.name) + const fileStat = await fs.stat(full) + return { + name: entry.name, + bytes: fileStat.size, + modifiedAt: fileStat.mtime.toISOString() + } + }) + ) + items.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt)) + return items + } catch { + return [] + } + })() + + return { + estimate, + backupsDir: getBackupsDir(), + packages + } +}) diff --git a/server/api/admin/backup/export.post.ts b/server/api/admin/backup/export.post.ts new file mode 100644 index 0000000..337b827 --- /dev/null +++ b/server/api/admin/backup/export.post.ts @@ -0,0 +1,26 @@ +import { requireAdminAuth } from '../../../utils/access' +import { createApiError } from '../../../utils/api-error' +import { serializeBackupJob } from '../../../utils/backup/api' +import { getBackupExportEstimate } from '../../../utils/backup/stats' +import { startFullExportJob } from '../../../utils/backup/runner' + +export default defineEventHandler(async (event) => { + await requireAdminAuth(event) + const config = useRuntimeConfig(event) + const appVersion = String(config.appVersion ?? '0.0.0') + + const estimate = await getBackupExportEstimate() + + try { + const job = startFullExportJob(appVersion) + return { + job: serializeBackupJob(job), + estimate + } + } catch (error) { + if (error instanceof Error && error.message === 'BACKUP_JOB_RUNNING') { + createApiError(event, 'CONFLICT', '已有备份或迁移任务正在运行', 409) + } + throw error + } +}) diff --git a/server/api/admin/backup/jobs/[id].get.ts b/server/api/admin/backup/jobs/[id].get.ts new file mode 100644 index 0000000..5221efd --- /dev/null +++ b/server/api/admin/backup/jobs/[id].get.ts @@ -0,0 +1,19 @@ +import { requireAdminAuth } from '../../../../utils/access' +import { createApiError } from '../../../../utils/api-error' +import { serializeBackupJob } from '../../../../utils/backup/api' +import { getBackupJob } from '../../../../utils/backup/jobs' + +export default defineEventHandler(async (event) => { + await requireAdminAuth(event) + const id = getRouterParam(event, 'id') + if (!id) { + createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400) + } + + const job = getBackupJob(id) + if (!job) { + createApiError(event, 'INVALID_REQUEST', '任务不存在', 404) + } + + return { job: serializeBackupJob(job) } +}) diff --git a/server/api/admin/backup/jobs/[id]/cancel.post.ts b/server/api/admin/backup/jobs/[id]/cancel.post.ts new file mode 100644 index 0000000..100aeb7 --- /dev/null +++ b/server/api/admin/backup/jobs/[id]/cancel.post.ts @@ -0,0 +1,20 @@ +import { requireAdminAuth } from '../../../../../utils/access' +import { createApiError } from '../../../../../utils/api-error' +import { serializeBackupJob } from '../../../../../utils/backup/api' +import { getBackupJob } from '../../../../../utils/backup/jobs' +import { cancelBackupJob } from '../../../../../utils/backup/runner' + +export default defineEventHandler(async (event) => { + await requireAdminAuth(event) + const id = getRouterParam(event, 'id') + if (!id) { + createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400) + } + + if (!cancelBackupJob(id)) { + createApiError(event, 'INVALID_REQUEST', '只能取消进行中的任务', 400) + } + + const job = getBackupJob(id) + return { job: job ? serializeBackupJob(job) : null } +}) diff --git a/server/api/admin/backup/jobs/[id]/download.get.ts b/server/api/admin/backup/jobs/[id]/download.get.ts new file mode 100644 index 0000000..2b6b814 --- /dev/null +++ b/server/api/admin/backup/jobs/[id]/download.get.ts @@ -0,0 +1,29 @@ +import { createReadStream } from 'node:fs' +import { basename } from 'node:path' +import { requireAdminAuth } from '../../../../../utils/access' +import { createApiError } from '../../../../../utils/api-error' +import { getBackupJob } from '../../../../../utils/backup/jobs' +import { BACKUP_ARCHIVE_EXT } from '../../../../../utils/backup/paths' + +export default defineEventHandler(async (event) => { + await requireAdminAuth(event) + const id = getRouterParam(event, 'id') + if (!id) { + createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400) + } + + const job = getBackupJob(id) + if (!job || job.type !== 'full_export' || job.status !== 'done' || !job.outputPath) { + createApiError(event, 'INVALID_REQUEST', '可下载的备份不存在', 404) + } + + const filename = basename(job.outputPath) + if (!filename.endsWith(BACKUP_ARCHIVE_EXT)) { + createApiError(event, 'INVALID_REQUEST', '备份文件无效', 404) + } + + setResponseHeader(event, 'Content-Type', 'application/gzip') + setResponseHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`) + + return sendStream(event, createReadStream(job.outputPath)) +}) diff --git a/server/api/admin/backup/jobs/[id]/retry.post.ts b/server/api/admin/backup/jobs/[id]/retry.post.ts new file mode 100644 index 0000000..31f1eaa --- /dev/null +++ b/server/api/admin/backup/jobs/[id]/retry.post.ts @@ -0,0 +1,28 @@ +import { requireAdminAuth } from '../../../../../utils/access' +import { createApiError } from '../../../../../utils/api-error' +import { retryFailedBackendSync } from '../../../../../utils/backup/backend-sync' +import { serializeBackupJob } from '../../../../../utils/backup/api' +import { getBackupJob } from '../../../../../utils/backup/jobs' + +export default defineEventHandler(async (event) => { + await requireAdminAuth(event) + const id = getRouterParam(event, 'id') + if (!id) { + createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400) + } + + const job = getBackupJob(id) + if (!job || job.type !== 'backend_sync') { + createApiError(event, 'INVALID_REQUEST', '任务不存在', 404) + } + + try { + await retryFailedBackendSync(id) + } catch (error) { + const message = error instanceof Error ? error.message : '重试失败' + createApiError(event, 'INVALID_REQUEST', message, 400) + } + + const updated = getBackupJob(id) + return { job: updated ? serializeBackupJob(updated) : null } +}) diff --git a/server/api/admin/backup/jobs/index.get.ts b/server/api/admin/backup/jobs/index.get.ts new file mode 100644 index 0000000..2f939f4 --- /dev/null +++ b/server/api/admin/backup/jobs/index.get.ts @@ -0,0 +1,36 @@ +import { requireAdminAuth } from '../../../../utils/access' +import { serializeBackupJob } from '../../../../utils/backup/api' +import { + getActiveBackupJob, + getRunningBackupJob, + listBackupJobsPage, + recoverInterruptedBackupJobs +} from '../../../../utils/backup/jobs' +import { isBackupRunnerBusy } from '../../../../utils/backup/runner' + +export default defineEventHandler(async (event) => { + await requireAdminAuth(event) + if (getRunningBackupJob() && !isBackupRunnerBusy()) { + recoverInterruptedBackupJobs() + } + const query = getQuery(event) + const pageSize = Math.min(50, Math.max(1, Number(query.pageSize) || 10)) + const pageNum = Math.max(1, Number(query.pageNum) || Number(query.page) || 1) + const { jobs, total } = listBackupJobsPage(pageNum, pageSize) + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + const page = total > 0 ? Math.min(pageNum, totalPages) : 1 + const slice = page === pageNum + ? jobs + : listBackupJobsPage(page, pageSize).jobs + + const activeJob = getActiveBackupJob() + + return { + jobs: slice.map(serializeBackupJob), + total, + page, + pageSize, + totalPages, + activeJob: activeJob ? serializeBackupJob(activeJob) : null + } +}) diff --git a/server/api/admin/backup/restore.post.ts b/server/api/admin/backup/restore.post.ts new file mode 100644 index 0000000..a0c22dc --- /dev/null +++ b/server/api/admin/backup/restore.post.ts @@ -0,0 +1,108 @@ +import type { H3Event } from 'h3' +import { requireAdminAuth } from '../../../utils/access' +import { createApiError } from '../../../utils/api-error' +import { serializeBackupJob } from '../../../utils/backup/api' +import { previewFullRestore } from '../../../utils/backup/full-restore' +import { startFullRestoreJob } from '../../../utils/backup/runner' +import { + BACKUP_ARCHIVE_EXT, + getBackupsDir, + getBackupStagingDir, + isSafeBackupFilename +} from '../../../utils/backup/paths' +import { join } from 'node:path' +import { promises as fs } from 'node:fs' + +interface RestoreBody { + archiveName?: string + conflictMode?: 'skip' | 'overwrite' + dryRun?: boolean +} + +async function resolveRestoreInput(event: H3Event): Promise<{ + archivePath: string + body: RestoreBody + cleanup?: () => Promise +}> { + const contentType = getHeader(event, 'content-type') ?? '' + + 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) { + 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 dryRun = dryRunField === 'true' + + 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) + return { + archivePath, + body: { + conflictMode: conflictField === 'skip' ? 'skip' : 'overwrite', + dryRun: true + }, + cleanup: async () => { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}) + } + } + } + + 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) + return { + archivePath, + body: { + conflictMode: conflictField === 'skip' ? 'skip' : 'overwrite', + dryRun: false + } + } + } + + const body = await readBody(event) + if (!body?.archiveName || !isSafeBackupFilename(body.archiveName)) { + createApiError(event, 'INVALID_REQUEST', '请选择有效的备份文件', 400) + } + const archivePath = join(getBackupsDir(), body.archiveName) + try { + await fs.access(archivePath) + } catch { + createApiError(event, 'INVALID_REQUEST', '备份文件不存在', 400) + } + return { archivePath, body } +} + +export default defineEventHandler(async (event) => { + await requireAdminAuth(event) + + const { archivePath, body, cleanup } = await resolveRestoreInput(event) + + const conflictMode = body.conflictMode === 'skip' ? 'skip' : 'overwrite' + + if (body.dryRun) { + try { + const preview = await previewFullRestore(archivePath) + return { preview } + } finally { + await cleanup?.() + } + } + + try { + const job = startFullRestoreJob(archivePath, conflictMode) + return { job: serializeBackupJob(job) } + } catch (error) { + if (error instanceof Error && error.message === 'BACKUP_JOB_RUNNING') { + createApiError(event, 'CONFLICT', '已有备份或迁移任务正在运行', 409) + } + throw error + } +}) diff --git a/server/api/admin/storage/sync.post.ts b/server/api/admin/storage/sync.post.ts new file mode 100644 index 0000000..6d2f48c --- /dev/null +++ b/server/api/admin/storage/sync.post.ts @@ -0,0 +1,50 @@ +import { requireAdminAuth } from '../../../utils/access' +import { createApiError } from '../../../utils/api-error' +import { serializeBackupJob } from '../../../utils/backup/api' +import { previewBackendSync } from '../../../utils/backup/backend-sync' +import { startBackendSyncJob } from '../../../utils/backup/runner' + +interface SyncBody { + fromBackendId?: string + toBackendId?: string + deleteSource?: boolean + setDefault?: boolean + dryRun?: boolean +} + +export default defineEventHandler(async (event) => { + await requireAdminAuth(event) + const body = await readBody(event) + + const fromBackendId = body?.fromBackendId?.trim() ?? '' + const toBackendId = body?.toBackendId?.trim() ?? '' + if (!fromBackendId || !toBackendId) { + createApiError(event, 'INVALID_REQUEST', '请选择源与目标存储后端', 400) + } + + const options = { + fromBackendId, + toBackendId, + deleteSource: Boolean(body?.deleteSource), + setDefault: Boolean(body?.setDefault), + dryRun: Boolean(body?.dryRun) + } + + try { + if (options.dryRun) { + const preview = previewBackendSync(options) + return { preview } + } + + const job = startBackendSyncJob(options) + return { job: serializeBackupJob(job) } + } catch (error) { + if (error instanceof Error) { + if (error.message === 'BACKUP_JOB_RUNNING') { + createApiError(event, 'CONFLICT', '已有备份或迁移任务正在运行', 409) + } + createApiError(event, 'INVALID_REQUEST', error.message, 400) + } + throw error + } +}) diff --git a/server/api/setup/restore.post.ts b/server/api/setup/restore.post.ts new file mode 100644 index 0000000..7f8c393 --- /dev/null +++ b/server/api/setup/restore.post.ts @@ -0,0 +1,36 @@ +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 { join } from 'node:path' +import { promises as fs } from 'node:fs' + +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) + + try { + const job = startFullRestoreJob(archivePath, 'overwrite') + return { + job: serializeBackupJob(job), + message: '恢复任务已开始,完成后请使用备份中的管理员账号登录' + } + } catch (error) { + if (error instanceof Error && error.message === 'BACKUP_JOB_RUNNING') { + createApiError(event, 'CONFLICT', '已有备份或迁移任务正在运行', 409) + } + throw error + } +}) diff --git a/server/api/setup/restore/[id].get.ts b/server/api/setup/restore/[id].get.ts new file mode 100644 index 0000000..4126750 --- /dev/null +++ b/server/api/setup/restore/[id].get.ts @@ -0,0 +1,17 @@ +import { createApiError } from '../../../utils/api-error' +import { serializeBackupJob } from '../../../utils/backup/api' +import { getBackupJob } from '../../../utils/backup/jobs' + +export default defineEventHandler((event) => { + const id = getRouterParam(event, 'id') + if (!id) { + createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400) + } + + const job = getBackupJob(id) + if (!job || job.type !== 'full_restore') { + createApiError(event, 'INVALID_REQUEST', '任务不存在', 404) + } + + return { job: serializeBackupJob(job) } +}) diff --git a/server/cli/backup-cli-loader.mjs b/server/cli/backup-cli-loader.mjs new file mode 100644 index 0000000..9922e6a --- /dev/null +++ b/server/cli/backup-cli-loader.mjs @@ -0,0 +1,14 @@ +import { createJiti } from 'jiti' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') + +export const jiti = createJiti(import.meta.url, { + interopDefault: true, + moduleCache: false +}) + +export function loadServerModule(path) { + return jiti(join(root, path)) +} diff --git a/server/cli/backup-export.mjs b/server/cli/backup-export.mjs new file mode 100644 index 0000000..899ea28 --- /dev/null +++ b/server/cli/backup-export.mjs @@ -0,0 +1,47 @@ +/** + * Export a full PicHost site backup (.phost.tar.gz). + * + * Usage: + * node server/cli/backup-export.mjs + * + * Docker: + * docker exec pichost backup-export + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { loadServerModule } from './backup-cli-loader.mjs' + +const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts') +const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts') +const { runFullExportJob } = loadServerModule('utils/backup/full-export.ts') + +function readAppVersion() { + try { + const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) + return String(pkg.version ?? '0.0.0') + } catch { + return '0.0.0' + } +} + +async function main() { + ensureBackupSchema() + const job = createBackupJob({ + type: 'full_export', + message: 'CLI 导出' + }) + + await runFullExportJob(job.id, readAppVersion()) + const done = getBackupJob(job.id) + if (!done || done.status !== 'done') { + console.error(`[PicHost] Export failed: ${done?.error ?? done?.message ?? 'unknown error'}`) + process.exit(1) + } + + console.log(`[PicHost] Export complete: ${done.outputPath}`) +} + +main().catch((error) => { + console.error(`[PicHost] Export error: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/server/cli/backup-restore.mjs b/server/cli/backup-restore.mjs new file mode 100644 index 0000000..b5fa7ed --- /dev/null +++ b/server/cli/backup-restore.mjs @@ -0,0 +1,65 @@ +/** + * Restore PicHost from a .phost.tar.gz backup. + * + * Usage: + * node server/cli/backup-restore.mjs [--overwrite] + * + * Docker: + * docker exec pichost backup-restore /app/data/backups/pichost-xxx.phost.tar.gz + */ +import { access } from 'node:fs/promises' +import { resolve } from 'node:path' +import { loadServerModule } from './backup-cli-loader.mjs' + +const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts') +const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts') +const { previewFullRestore } = loadServerModule('utils/backup/full-restore.ts') +const { runFullRestoreJob } = loadServerModule('utils/backup/full-restore.ts') + +function usage() { + console.error('Usage: backup-restore [--overwrite]') + process.exit(1) +} + +const args = process.argv.slice(2).filter(arg => arg !== '--') +if (!args.length) usage() + +const overwrite = args.includes('--overwrite') +const archiveArg = args.find(arg => arg !== '--overwrite') +if (!archiveArg) usage() + +const archivePath = resolve(archiveArg) +const conflictMode = overwrite ? 'overwrite' : 'skip' + +async function main() { + try { + await access(archivePath) + } catch { + console.error(`[PicHost] Backup file not found: ${archivePath}`) + process.exit(1) + } + + ensureBackupSchema() + const preview = await previewFullRestore(archivePath) + console.log(`[PicHost] Preview: ${preview.imageCount} images, ${preview.newImages} new, ${preview.conflictingImages} conflicts`) + + const job = createBackupJob({ + type: 'full_restore', + message: 'CLI 恢复', + payload: { archivePath, conflictMode } + }) + + await runFullRestoreJob(job.id, archivePath, { conflictMode }) + const done = getBackupJob(job.id) + if (!done || done.status !== 'done') { + console.error(`[PicHost] Restore failed: ${done?.error ?? done?.message ?? 'unknown error'}`) + process.exit(1) + } + + console.log('[PicHost] Restore complete. Restart PicHost if it is running.') +} + +main().catch((error) => { + console.error(`[PicHost] Restore error: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/server/cli/migrate-to-single-images.mjs b/server/cli/migrate-to-single-images.mjs deleted file mode 100644 index 56a4594..0000000 --- a/server/cli/migrate-to-single-images.mjs +++ /dev/null @@ -1,166 +0,0 @@ -/** - * 将 data/ 下与 images 并列的顶层目录中的图片迁入 data/images/{原目录名}/... - * - * 用法: - * node server/cli/migrate-to-single-images.mjs # dry-run - * node server/cli/migrate-to-single-images.mjs --apply # 执行迁移 - * - * Docker: - * docker exec pichost migrate - * docker exec pichost migrate --apply - * - * 输出 data/mapping.json(旧 key → 新 key)供替换外链。执行后重启 PicHost。 - */ -import { promises as fs } from 'node:fs' -import { join, relative, dirname } from 'node:path' - -const DATA_DIR = process.env.DATA_DIR || join(process.cwd(), 'data') -const IMAGES_DIR = join(DATA_DIR, 'images') - -const IMAGE_EXT = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i -const SKIP_TOP_LEVEL_DIRS = new Set(['images']) - -const apply = process.argv.includes('--apply') - -async function exists(path) { - try { - await fs.access(path) - return true - } catch { - return false - } -} - -async function collectImageFiles(dir) { - const files = [] - const entries = await fs.readdir(dir, { withFileTypes: true }) - for (const entry of entries) { - const full = join(dir, entry.name) - if (entry.isDirectory()) { - files.push(...await collectImageFiles(full)) - } else if ( - entry.isFile() - && IMAGE_EXT.test(entry.name) - && !entry.name.endsWith('.meta.json') - ) { - files.push(full) - } - } - return files -} - -async function dirHasAnyFiles(dir) { - let entries - try { - entries = await fs.readdir(dir, { withFileTypes: true }) - } catch { - return false - } - for (const entry of entries) { - if (entry.isFile()) return true - if (entry.isDirectory() && await dirHasAnyFiles(join(dir, entry.name))) { - return true - } - } - return false -} - -async function main() { - if (!await exists(DATA_DIR)) { - console.error(`数据目录不存在: ${DATA_DIR}`) - process.exit(1) - } - - const topEntries = await fs.readdir(DATA_DIR, { withFileTypes: true }) - const sourceDirs = topEntries - .filter(entry => entry.isDirectory() && !SKIP_TOP_LEVEL_DIRS.has(entry.name)) - .map(entry => ({ name: entry.name, path: join(DATA_DIR, entry.name) })) - - if (!sourceDirs.length) { - console.log('未发现需迁移的顶层目录(data/ 下除 images 外无其它目录)') - return - } - - const mapping = {} - const plan = [] - - for (const { name, path } of sourceDirs) { - const files = await collectImageFiles(path) - for (const filePath of files) { - const rel = relative(path, filePath).replace(/\\/g, '/') - const newKey = `images/${name}/${rel}` - const newDisk = join(IMAGES_DIR, name, rel) - const oldKey = `${name}/${rel}` - mapping[oldKey] = newKey - plan.push({ from: filePath, to: newDisk, oldKey, newKey, sourceName: name }) - } - } - - if (!plan.length) { - console.log('未发现需迁移的图片(已扫描 data/ 下除 images 外的顶层目录)') - return - } - - const scanned = sourceDirs.map(dir => dir.name).join(', ') - console.log(`已扫描顶层目录: ${scanned}`) - console.log(`模式: ${apply ? 'APPLY' : 'DRY-RUN'}`) - console.log(`待迁移 ${plan.length} 个文件:`) - for (const item of plan.slice(0, 20)) { - console.log(` ${item.oldKey} → ${item.newKey}`) - } - if (plan.length > 20) { - console.log(` … 另有 ${plan.length - 20} 个`) - } - - const mappingPath = join(DATA_DIR, 'mapping.json') - await fs.writeFile(mappingPath, JSON.stringify(mapping, null, 2)) - console.log(`已写入 ${mappingPath}`) - - if (!apply) { - console.log('未执行文件移动。确认后运行:') - console.log(' docker exec pichost migrate --apply') - console.log(' 或 npm run migrate -- --apply') - return - } - - await fs.mkdir(IMAGES_DIR, { recursive: true }) - - for (const item of plan) { - await fs.mkdir(dirname(item.to), { recursive: true }) - try { - await fs.rename(item.from, item.to) - } catch (error) { - if (error?.code === 'EXDEV') { - await fs.copyFile(item.from, item.to) - await fs.unlink(item.from) - } else { - throw error - } - } - const metaFrom = item.from + '.meta.json' - const metaTo = item.to + '.meta.json' - try { - await fs.rename(metaFrom, metaTo) - } catch { - // optional meta - } - } - - const sourceNames = [...new Set(plan.map(item => item.sourceName))] - for (const name of sourceNames) { - const path = join(DATA_DIR, name) - if (await dirHasAnyFiles(path)) { - console.log(`保留 ${name}/(仍有未迁移文件)`) - continue - } - await fs.rm(path, { recursive: true, force: true }) - console.log(`已移除空目录 ${name}/`) - } - - console.log('迁移完成。请重启 PicHost。') -} - -main().catch((error) => { - console.error(error) - process.exit(1) -}) diff --git a/server/cli/storage-sync.mjs b/server/cli/storage-sync.mjs new file mode 100644 index 0000000..8ea7b92 --- /dev/null +++ b/server/cli/storage-sync.mjs @@ -0,0 +1,72 @@ +/** + * Sync images between storage backends. + * + * Usage: + * node server/cli/storage-sync.mjs --from --to [--delete-source] [--set-default] [--dry-run] + * + * Docker: + * docker exec pichost storage-sync --from local --to s3-xxx + */ +import { loadServerModule } from './backup-cli-loader.mjs' + +const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts') +const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts') +const { previewBackendSync, runBackendSyncJob } = loadServerModule('utils/backup/backend-sync.ts') + +function usage() { + console.error('Usage: storage-sync --from --to [--delete-source] [--set-default] [--dry-run]') + process.exit(1) +} + +function readArg(name) { + const index = process.argv.indexOf(name) + if (index < 0 || index + 1 >= process.argv.length) return '' + return process.argv[index + 1]?.trim() ?? '' +} + +const fromBackendId = readArg('--from') +const toBackendId = readArg('--to') +const deleteSource = process.argv.includes('--delete-source') +const setDefault = process.argv.includes('--set-default') +const dryRun = process.argv.includes('--dry-run') + +if (!fromBackendId || !toBackendId) usage() + +const options = { + fromBackendId, + toBackendId, + deleteSource, + setDefault, + dryRun +} + +async function main() { + ensureBackupSchema() + + if (dryRun) { + const preview = previewBackendSync(options) + console.log(`[PicHost] Dry run: ${preview.imageCount} images, ${preview.imageBytes} bytes`) + console.log(`[PicHost] From ${preview.fromBackendId} → ${preview.toBackendId}`) + return + } + + const job = createBackupJob({ + type: 'backend_sync', + message: 'CLI 同步', + payload: options + }) + + await runBackendSyncJob(job.id, options) + const done = getBackupJob(job.id) + if (!done || done.status !== 'done') { + console.error(`[PicHost] Sync failed: ${done?.error ?? done?.message ?? 'unknown error'}`) + process.exit(1) + } + + console.log(`[PicHost] Sync complete: ${done.progress}/${done.total}`) +} + +main().catch((error) => { + console.error(`[PicHost] Sync error: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/server/plugins/backup-jobs.ts b/server/plugins/backup-jobs.ts new file mode 100644 index 0000000..b5c19ac --- /dev/null +++ b/server/plugins/backup-jobs.ts @@ -0,0 +1,8 @@ +import { recoverInterruptedBackupJobs } from '../utils/backup/jobs' + +export default defineNitroPlugin(() => { + const recovered = recoverInterruptedBackupJobs() + if (recovered > 0) { + console.log(`[PicHost] Recovered ${recovered} interrupted backup job(s)`) + } +}) diff --git a/server/utils/backup/api.ts b/server/utils/backup/api.ts new file mode 100644 index 0000000..639ee79 --- /dev/null +++ b/server/utils/backup/api.ts @@ -0,0 +1,31 @@ +import type { BackupJob } from '../../utils/backup/jobs' +import { BACKUP_ARCHIVE_EXT } from '../../utils/backup/paths' + +export function serializeBackupJob(job: BackupJob) { + const archiveName = job.outputPath + ? job.outputPath.split(/[/\\]/).pop() + : typeof job.payload.archiveName === 'string' + ? job.payload.archiveName + : null + + return { + id: job.id, + type: job.type, + status: job.status, + progress: job.progress, + total: job.total, + message: job.message, + payload: job.payload, + outputPath: job.outputPath, + archiveName, + downloadable: Boolean( + job.type === 'full_export' + && job.status === 'done' + && job.outputPath + && archiveName?.endsWith(BACKUP_ARCHIVE_EXT) + ), + error: job.error, + createdAt: job.createdAt, + finishedAt: job.finishedAt + } +} diff --git a/server/utils/backup/backend-sync.ts b/server/utils/backup/backend-sync.ts new file mode 100644 index 0000000..ed7ee1d --- /dev/null +++ b/server/utils/backup/backend-sync.ts @@ -0,0 +1,213 @@ +import { promises as fs } from 'node:fs' +import { join } from 'node:path' +import { + createBackendInstance, + getStorageBackendRow, + setDefaultBackend +} from '../storage-backends' +import { + getImageIndexAsStored, + listImageIndexRowsByBackend, + updateImageBackendId +} from '../image-index' +import { createImageStream } from '../storage' +import { getBackupJob, markJobDone, markJobFailed, updateBackupJob } from './jobs' +import { getBackupStagingDir } from './paths' + +export interface BackendSyncOptions { + fromBackendId: string + toBackendId: string + deleteSource?: boolean + setDefault?: boolean + dryRun?: boolean +} + +export interface BackendSyncPreview { + imageCount: number + imageBytes: number + fromBackendId: string + toBackendId: string +} + +export function previewBackendSync(options: BackendSyncOptions): BackendSyncPreview { + const fromRow = getStorageBackendRow(options.fromBackendId) + const toRow = getStorageBackendRow(options.toBackendId) + if (!fromRow) { + throw new Error('源存储后端不存在') + } + if (!toRow) { + throw new Error('目标存储后端不存在') + } + if (!toRow.enabled) { + throw new Error('目标存储后端未启用') + } + if (options.fromBackendId === options.toBackendId) { + throw new Error('源与目标存储后端不能相同') + } + + const rows = listImageIndexRowsByBackend(options.fromBackendId) + const imageBytes = rows.reduce((sum, row) => sum + row.size, 0) + return { + imageCount: rows.length, + imageBytes, + fromBackendId: options.fromBackendId, + toBackendId: options.toBackendId + } +} + +async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = [] + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + return Buffer.concat(chunks) +} + +export async function runBackendSyncJob( + jobId: string, + options: BackendSyncOptions +): Promise { + const preview = previewBackendSync(options) + if (options.dryRun) { + markJobDone(jobId, { + message: '预检完成', + payload: { preview } + }) + return + } + + const fromRow = getStorageBackendRow(options.fromBackendId)! + const toRow = getStorageBackendRow(options.toBackendId)! + const fromBackend = createBackendInstance(fromRow) + const toBackend = createBackendInstance(toRow) + + const rows = listImageIndexRowsByBackend(options.fromBackendId) + const failedKeys: string[] = [] + const failedLogPath = join(getBackupStagingDir(jobId), 'failed_keys.jsonl') + + updateBackupJob(jobId, { + total: rows.length, + progress: 0, + message: '正在同步图片…' + }) + + let processed = 0 + for (const row of rows) { + try { + const stream = await createImageStream(row.key) + const bytes = await streamToBuffer(stream) + await toBackend.put(row.key, bytes, { + originalName: row.original_name, + uploadedAt: row.uploaded_at, + contentType: row.content_type, + size: row.size, + userId: row.user_id + }) + + const head = await toBackend.head(row.key) + if (!head) { + throw new Error('目标后端写入校验失败') + } + + updateImageBackendId(row.key, options.toBackendId) + + if (options.deleteSource) { + await fromBackend.delete(row.key) + } + } catch (error) { + failedKeys.push(row.key) + await fs.mkdir(join(failedLogPath, '..'), { recursive: true }).catch(() => {}) + await fs.appendFile( + failedLogPath, + `${row.key}\t${error instanceof Error ? error.message : 'unknown'}\n`, + 'utf8' + ).catch(() => {}) + } + + processed += 1 + updateBackupJob(jobId, { + progress: processed, + message: `正在同步 ${processed}/${rows.length}…` + }) + } + + if (options.setDefault) { + setDefaultBackend(options.toBackendId) + } + + if (failedKeys.length > 0) { + markJobFailed(jobId, `${failedKeys.length} 张图片同步失败`, `部分失败:${failedKeys.length}/${rows.length}`) + updateBackupJob(jobId, { + payload: { failedKeys, failedLogPath, preview } + }) + return + } + + markJobDone(jobId, { + message: '同步完成', + payload: { preview, synced: rows.length } + }) +} + +export async function retryFailedBackendSync(jobId: string): Promise { + const failedLogPath = join(getBackupStagingDir(jobId), 'failed_keys.jsonl') + let raw: string + try { + raw = await fs.readFile(failedLogPath, 'utf8') + } catch { + throw new Error('没有可重试的失败记录') + } + + const keys = raw + .split('\n') + .map(line => line.split('\t')[0]?.trim()) + .filter((key): key is string => Boolean(key)) + + const job = getBackupJob(jobId) + if (!job) throw new Error('任务不存在') + + const fromBackendId = String(job.payload.fromBackendId ?? '') + const toBackendId = String(job.payload.toBackendId ?? '') + const deleteSource = Boolean(job.payload.deleteSource) + const fromRow = getStorageBackendRow(fromBackendId) + const toRow = getStorageBackendRow(toBackendId) + if (!fromRow || !toRow) throw new Error('存储后端配置无效') + + const fromBackend = createBackendInstance(fromRow) + const toBackend = createBackendInstance(toRow) + + const stillFailed: string[] = [] + for (const key of keys) { + const row = getImageIndexAsStored(key) + if (!row) continue + try { + const stream = await createImageStream(key) + const bytes = await streamToBuffer(stream) + await toBackend.put(key, bytes, { + originalName: row.originalName, + uploadedAt: row.uploadedAt, + contentType: row.contentType, + size: row.size, + userId: row.userId + }) + updateImageBackendId(key, toBackendId) + if (deleteSource) { + await fromBackend.delete(key) + } + } catch { + stillFailed.push(key) + } + } + + if (stillFailed.length > 0) { + await fs.writeFile( + failedLogPath, + stillFailed.map(key => `${key}\tretry failed\n`).join(''), + 'utf8' + ) + throw new Error(`仍有 ${stillFailed.length} 张图片同步失败`) + } + + await fs.rm(failedLogPath, { force: true }).catch(() => {}) + markJobDone(jobId, { message: '失败项重试完成' }) +} diff --git a/server/utils/backup/db-snapshot.ts b/server/utils/backup/db-snapshot.ts new file mode 100644 index 0000000..60295d9 --- /dev/null +++ b/server/utils/backup/db-snapshot.ts @@ -0,0 +1,66 @@ +import { copyFile, mkdir, rename, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { closeDb, getDb, setDbReplacing } from '../db' +import { getDataDir } from '../data-dir' + +async function retireCurrentDatabaseFiles(dbPath: string, stamp: number): Promise { + const targets = [ + dbPath, + `${dbPath}-wal`, + `${dbPath}-shm` + ] + for (const path of targets) { + try { + await rename(path, `${path}.bak-${stamp}`) + } catch { + // Missing sidecar files are fine. + } + } +} + +async function cleanupRetiredDatabaseFiles(dbPath: string, stamp: number): Promise { + const paths = [ + dbPath, + `${dbPath}-wal`, + `${dbPath}-shm` + ] + for (const path of paths) { + await rm(`${path}.bak-${stamp}`, { force: true }).catch(() => {}) + } +} + +function sqlitePathLiteral(path: string): string { + return path.replace(/\\/g, '/').replace(/'/g, '\'\'') +} + +export function checkpointDatabase(): void { + getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)') +} + +/** Consistent snapshot while the app keeps the DB open (safe on Windows). */ +export async function copyDatabaseSnapshot(targetPath: string): Promise { + await mkdir(join(targetPath, '..'), { recursive: true }) + checkpointDatabase() + const escaped = sqlitePathLiteral(targetPath) + getDb().exec(`VACUUM INTO '${escaped}'`) +} + +export async function replaceDatabaseFromSnapshot(sourcePath: string): Promise { + const dataDir = getDataDir() + const target = join(dataDir, 'pichost.db') + const stamp = Date.now() + + checkpointDatabase() + closeDb() + setDbReplacing(true) + + try { + await retireCurrentDatabaseFiles(target, stamp) + await copyFile(sourcePath, target) + } finally { + setDbReplacing(false) + const reopened = getDb() + reopened.exec('PRAGMA journal_mode=WAL') + void cleanupRetiredDatabaseFiles(target, stamp) + } +} diff --git a/server/utils/backup/full-export.ts b/server/utils/backup/full-export.ts new file mode 100644 index 0000000..4870003 --- /dev/null +++ b/server/utils/backup/full-export.ts @@ -0,0 +1,134 @@ +import { createWriteStream, promises as fs } from 'node:fs' +import { join } from 'node:path' +import { pipeline } from 'node:stream/promises' +import * as tar from 'tar' +import { getImageTotals, listAllImageIndexRows } from '../image-index' +import { + listStorageBackendRows +} from '../storage-backends' +import { parseS3Config } from '../storage/s3' +import { createImageStream } from '../storage' +import { copyDatabaseSnapshot } from './db-snapshot' +import { markJobDone, markJobFailed, updateBackupJob } from './jobs' +import { BACKUP_FORMAT_VERSION, type BackupManifest, type StorageBackendMetaExport } from './manifest' +import { + BACKUP_ARCHIVE_EXT, + getBackupStagingDir, + getBackupsDir +} from './paths' +import type { StorageBackendRow } from '../storage/types' + +function formatArchiveName(jobId: string): string { + return `pichost-${jobId}${BACKUP_ARCHIVE_EXT}` +} + +function buildStorageBackendsMeta(): StorageBackendMetaExport[] { + return listStorageBackendRows().map((row: StorageBackendRow) => ({ + id: row.id, + name: row.name, + type: row.type, + config: row.type === 's3' ? (parseS3Config(row.config_json) ?? {}) : {}, + servingMode: row.serving_mode, + publicUrl: row.public_url, + quotaBytes: row.quota_bytes, + enabled: row.enabled === 1, + isDefault: row.is_default === 1, + sortOrder: row.sort_order + })) +} + +async function writeStreamToFile(stream: NodeJS.ReadableStream, filePath: string): Promise { + await fs.mkdir(join(filePath, '..'), { recursive: true }) + await pipeline(stream, createWriteStream(filePath)) +} + +export async function runFullExportJob(jobId: string, appVersion: string): Promise { + const stagingRoot = getBackupStagingDir(jobId) + const archiveName = formatArchiveName(jobId) + const archivePath = join(getBackupsDir(), archiveName) + + try { + await fs.rm(stagingRoot, { recursive: true, force: true }) + await fs.mkdir(stagingRoot, { recursive: true }) + await fs.mkdir(getBackupsDir(), { recursive: true }) + + const totals = getImageTotals() + const rows = listAllImageIndexRows() + updateBackupJob(jobId, { + total: totals.count + 1, + message: '正在备份数据库…' + }) + + await copyDatabaseSnapshot(join(stagingRoot, 'database', 'pichost.sqlite')) + updateBackupJob(jobId, { progress: 1, message: '正在写入备份元数据…' }) + + const manifest: BackupManifest = { + formatVersion: BACKUP_FORMAT_VERSION, + appVersion, + createdAt: new Date().toISOString(), + imageCount: totals.count, + imageBytes: totals.bytes, + includesDatabase: true + } + await fs.writeFile( + join(stagingRoot, 'manifest.json'), + JSON.stringify(manifest, null, 2), + 'utf8' + ) + + const metaDir = join(stagingRoot, 'meta') + await fs.mkdir(metaDir, { recursive: true }) + await fs.writeFile( + join(metaDir, 'storage_backends.json'), + JSON.stringify(buildStorageBackendsMeta(), null, 2), + 'utf8' + ) + await fs.writeFile( + join(metaDir, 'readme.txt'), + [ + 'PicHost site backup package.', + 'Restore via Storage → Backup & migration in the admin UI.', + 'Object storage secrets are not included; re-enter keys after restore.' + ].join('\n'), + 'utf8' + ) + + let processed = 1 + for (const row of rows) { + updateBackupJob(jobId, { + progress: processed, + message: `正在导出图片 ${processed}/${totals.count + 1}…` + }) + + const blobPath = join(stagingRoot, 'blobs', row.key) + const stream = await createImageStream(row.key) + await writeStreamToFile(stream, blobPath) + processed += 1 + updateBackupJob(jobId, { progress: processed }) + } + + updateBackupJob(jobId, { message: '正在压缩备份包…' }) + await tar.c( + { + gzip: true, + file: archivePath, + cwd: stagingRoot, + portable: true + }, + ['.'] + ) + + await fs.rm(stagingRoot, { recursive: true, force: true }) + + markJobDone(jobId, { + message: '备份完成', + outputPath: archivePath, + payload: { archiveName } + }) + } catch (error) { + await fs.rm(stagingRoot, { recursive: true, force: true }).catch(() => {}) + const message = error instanceof Error ? error.message : '导出失败' + markJobFailed(jobId, message) + throw error + } +} diff --git a/server/utils/backup/full-restore.ts b/server/utils/backup/full-restore.ts new file mode 100644 index 0000000..9c8fb1a --- /dev/null +++ b/server/utils/backup/full-restore.ts @@ -0,0 +1,182 @@ +import { createReadStream, promises as fs } from 'node:fs' +import { join, relative } from 'node:path' +import { pipeline } from 'node:stream/promises' +import * as tar from 'tar' +import { getImageIndexRow } from '../image-index' +import { getActiveBackend } from '../storage/resolver' +import { replaceDatabaseFromSnapshot } from './db-snapshot' +import { markJobDone, markJobFailed, restoreBackupJobsAfterReplace, snapshotBackupJobs, updateBackupJob } from './jobs' +import { + BACKUP_FORMAT_VERSION, + type BackupManifest, + type BackupRestorePreview +} from './manifest' +import { getBackupStagingDir } from './paths' +import { ensureDefaultBackends } from '../storage-backends' + +async function walkFiles(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }) + const files: string[] = [] + for (const entry of entries) { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + files.push(...await walkFiles(full)) + } else if (entry.isFile()) { + files.push(full) + } + } + return files +} + +async function extractArchive(archivePath: string, targetDir: string): Promise { + await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {}) + await fs.mkdir(targetDir, { recursive: true }) + await tar.x({ + file: archivePath, + cwd: targetDir, + gzip: true + }) +} + +async function readManifest(stagingRoot: string): Promise { + const raw = await fs.readFile(join(stagingRoot, 'manifest.json'), 'utf8') + const manifest = JSON.parse(raw) as BackupManifest + if (manifest.formatVersion !== BACKUP_FORMAT_VERSION) { + throw new Error(`不支持的备份格式版本:${manifest.formatVersion}`) + } + return manifest +} + +export async function previewFullRestore(archivePath: string): Promise { + const stagingRoot = getBackupStagingDir(`preview-${Date.now()}`) + try { + await extractArchive(archivePath, stagingRoot) + const manifest = await readManifest(stagingRoot) + const blobsRoot = join(stagingRoot, 'blobs') + let blobFiles: string[] = [] + try { + blobFiles = await walkFiles(blobsRoot) + } catch { + blobFiles = [] + } + + let conflictingImages = 0 + for (const file of blobFiles) { + const rel = relative(blobsRoot, file).replace(/\\/g, '/') + if (getImageIndexRow(rel)) { + conflictingImages += 1 + } + } + + return { + imageCount: manifest.imageCount, + imageBytes: manifest.imageBytes, + newImages: Math.max(0, blobFiles.length - conflictingImages), + conflictingImages, + formatVersion: manifest.formatVersion, + appVersion: manifest.appVersion, + createdAt: manifest.createdAt + } + } finally { + await fs.rm(stagingRoot, { recursive: true, force: true }).catch(() => {}) + } +} + +export async function runFullRestoreJob( + jobId: string, + archivePath: string, + options: { conflictMode: 'skip' | 'overwrite' } +): Promise { + const stagingRoot = getBackupStagingDir(jobId) + const preservedJobs = snapshotBackupJobs(50) + + try { + updateBackupJob(jobId, { message: '正在解压备份包…', total: 0, progress: 0 }) + await extractArchive(archivePath, stagingRoot) + const manifest = await readManifest(stagingRoot) + + const dbPath = join(stagingRoot, 'database', 'pichost.sqlite') + await fs.access(dbPath) + + updateBackupJob(jobId, { message: '正在恢复数据库…' }) + await replaceDatabaseFromSnapshot(dbPath) + ensureDefaultBackends() + restoreBackupJobsAfterReplace(preservedJobs) + updateBackupJob(jobId, { + status: 'running', + message: '正在恢复图片…', + progress: 0, + total: 0 + }) + + const blobsRoot = join(stagingRoot, 'blobs') + const blobFiles = await walkFiles(blobsRoot) + const total = blobFiles.length + 1 + updateBackupJob(jobId, { total, progress: 1, message: '正在恢复图片…' }) + + const backend = await getActiveBackend() + let processed = 1 + + for (const file of blobFiles) { + const key = relative(blobsRoot, file).replace(/\\/g, '/') + const indexed = getImageIndexRow(key) + + if (!indexed && options.conflictMode === 'skip') { + processed += 1 + continue + } + + const bytes = await fs.readFile(file) + const meta = indexed + ? { + originalName: indexed.original_name, + uploadedAt: indexed.uploaded_at, + contentType: indexed.content_type, + size: indexed.size, + userId: indexed.user_id + } + : { + originalName: key.split('/').pop() ?? key, + uploadedAt: new Date().toISOString(), + contentType: 'application/octet-stream', + size: bytes.length, + userId: null + } + + if (options.conflictMode === 'overwrite' || !indexed) { + await backend.put(key, bytes, meta) + } + + processed += 1 + updateBackupJob(jobId, { + progress: processed, + message: `正在恢复图片 ${processed}/${total}…` + }) + } + + await fs.rm(stagingRoot, { recursive: true, force: true }) + + markJobDone(jobId, { + message: '恢复完成,请重新登录并在存储页检查对象存储密钥', + payload: { + restoredImages: blobFiles.length, + manifest + } + }) + } catch (error) { + await fs.rm(stagingRoot, { recursive: true, force: true }).catch(() => {}) + const message = error instanceof Error ? error.message : '恢复失败' + markJobFailed(jobId, message) + throw error + } +} + +export async function streamBlobToFile( + archivePath: string, + targetPath: string +): Promise { + await fs.mkdir(join(targetPath, '..'), { recursive: true }) + const readStream = createReadStream(archivePath) + const writeStream = (await import('node:fs')).createWriteStream(targetPath) + await pipeline(readStream, writeStream) +} diff --git a/server/utils/backup/index.ts b/server/utils/backup/index.ts new file mode 100644 index 0000000..51a545e --- /dev/null +++ b/server/utils/backup/index.ts @@ -0,0 +1,15 @@ +export { ensureBackupSchema } from './schema' +export * from './jobs' +export * from './paths' +export * from './manifest' +export * from './stats' +export { previewFullRestore } from './full-restore' +export { previewBackendSync } from './backend-sync' +export { + startFullExportJob, + startFullRestoreJob, + startBackendSyncJob, + isBackupRunnerBusy, + cancelBackupJob +} from './runner' +export { retryFailedBackendSync } from './backend-sync' diff --git a/server/utils/backup/jobs.ts b/server/utils/backup/jobs.ts new file mode 100644 index 0000000..3aeb43d --- /dev/null +++ b/server/utils/backup/jobs.ts @@ -0,0 +1,283 @@ +import { randomBytes } from 'node:crypto' +import { getDb } from '../db' +import { ensureBackupSchema } from './schema' + +export type BackupJobType = 'full_export' | 'full_restore' | 'backend_sync' +export type BackupJobStatus = 'pending' | 'running' | 'done' | 'failed' + +export interface BackupJob { + id: string + type: BackupJobType + status: BackupJobStatus + progress: number + total: number + message: string + payload: Record + outputPath: string | null + error: string | null + createdAt: string + finishedAt: string | null +} + +interface BackupJobRow { + id: string + type: BackupJobType + status: BackupJobStatus + progress: number + total: number + message: string + payload_json: string + output_path: string | null + error: string | null + created_at: string + finished_at: string | null +} + +function rowToJob(row: BackupJobRow): BackupJob { + const payload = (() => { + try { + return JSON.parse(row.payload_json) as Record + } catch { + return {} + } + })() + return { + id: row.id, + type: row.type, + status: row.status, + progress: row.progress, + total: row.total, + message: row.message, + payload, + outputPath: row.output_path, + error: row.error, + createdAt: row.created_at, + finishedAt: row.finished_at + } +} + +function generateJobId(): string { + const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').slice(0, 15) + const suffix = randomBytes(3).toString('hex') + return `${stamp}-${suffix}` +} + +export function getRunningBackupJob(): BackupJob | null { + ensureBackupSchema() + const row = getDb().prepare(` + SELECT id, type, status, progress, total, message, payload_json, + output_path, error, created_at, finished_at + FROM backup_jobs + WHERE status = 'running' + LIMIT 1 + `).get() as BackupJobRow | undefined + return row ? rowToJob(row) : null +} + +export function getActiveBackupJob(): BackupJob | null { + ensureBackupSchema() + const row = getDb().prepare(` + SELECT id, type, status, progress, total, message, payload_json, + output_path, error, created_at, finished_at + FROM backup_jobs + WHERE status IN ('pending', 'running') + ORDER BY created_at DESC + LIMIT 1 + `).get() as BackupJobRow | undefined + return row ? rowToJob(row) : null +} + +export function createBackupJob(input: { + type: BackupJobType + total?: number + message?: string + payload?: Record +}): BackupJob { + ensureBackupSchema() + const id = generateJobId() + const now = new Date().toISOString() + const payloadJson = JSON.stringify(input.payload ?? {}) + + getDb().prepare(` + INSERT INTO backup_jobs + (id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at) + VALUES (?, ?, 'pending', 0, ?, ?, ?, NULL, NULL, ?, NULL) + `).run( + id, + input.type, + input.total ?? 0, + input.message ?? '', + payloadJson, + now + ) + + return getBackupJob(id)! +} + +export function getBackupJob(id: string): BackupJob | null { + ensureBackupSchema() + const row = getDb().prepare(` + SELECT id, type, status, progress, total, message, payload_json, + output_path, error, created_at, finished_at + FROM backup_jobs + WHERE id = ? + `).get(id) as BackupJobRow | undefined + return row ? rowToJob(row) : null +} + +export function listBackupJobs(limit = 20): BackupJob[] { + return listBackupJobsPage(1, limit).jobs +} + +export function countBackupJobs(): number { + ensureBackupSchema() + const row = getDb().prepare('SELECT COUNT(*) AS count FROM backup_jobs').get() as { count: number } + return row.count +} + +export function listBackupJobsPage(page = 1, pageSize = 10): { jobs: BackupJob[], total: number } { + ensureBackupSchema() + const limit = Math.min(50, Math.max(1, pageSize)) + const safePage = Math.max(1, page) + const offset = (safePage - 1) * limit + const total = countBackupJobs() + const rows = getDb().prepare(` + SELECT id, type, status, progress, total, message, payload_json, + output_path, error, created_at, finished_at + FROM backup_jobs + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `).all(limit, offset) as unknown as BackupJobRow[] + return { jobs: rows.map(rowToJob), total } +} + +/** Capture live task history before a full restore swaps the database file. */ +export function snapshotBackupJobs(limit = 50): BackupJob[] { + return listBackupJobs(limit) +} + +/** Restore task history after the database file was replaced from a backup package. */ +export function restoreBackupJobsAfterReplace(preserved: BackupJob[]): void { + ensureBackupSchema() + const db = getDb() + db.exec('BEGIN') + try { + db.prepare('DELETE FROM backup_jobs').run() + const insert = db.prepare(` + INSERT INTO backup_jobs + (id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + for (const job of preserved) { + insert.run( + job.id, + job.type, + job.status, + job.progress, + job.total, + job.message, + JSON.stringify(job.payload), + job.outputPath, + job.error, + job.createdAt, + job.finishedAt + ) + } + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} + +/** Mark jobs left in `running` after a crash/restart so new work can start. */ +export function recoverInterruptedBackupJobs(): number { + ensureBackupSchema() + const finishedAt = new Date().toISOString() + const result = getDb().prepare(` + UPDATE backup_jobs + SET status = 'failed', + error = '任务中断(服务重启或异常退出)', + message = '任务已中断,请重试', + finished_at = ? + WHERE status = 'running' + `).run(finishedAt) + return Number(result.changes ?? 0) +} + +export function updateBackupJob( + id: string, + patch: Partial<{ + status: BackupJobStatus + progress: number + total: number + message: string + payload: Record + outputPath: string | null + error: string | null + finishedAt: string | null + }> +): void { + ensureBackupSchema() + const current = getBackupJob(id) + if (!current) return + + const next = { + status: patch.status ?? current.status, + progress: patch.progress ?? current.progress, + total: patch.total ?? current.total, + message: patch.message ?? current.message, + payload: patch.payload ?? current.payload, + outputPath: patch.outputPath !== undefined ? patch.outputPath : current.outputPath, + error: patch.error !== undefined ? patch.error : current.error, + finishedAt: patch.finishedAt !== undefined ? patch.finishedAt : current.finishedAt + } + + getDb().prepare(` + UPDATE backup_jobs + SET status = ?, progress = ?, total = ?, message = ?, payload_json = ?, + output_path = ?, error = ?, finished_at = ? + WHERE id = ? + `).run( + next.status, + next.progress, + next.total, + next.message, + JSON.stringify(next.payload), + next.outputPath, + next.error, + next.finishedAt, + id + ) +} + +export function markJobRunning(id: string, message?: string): void { + updateBackupJob(id, { + status: 'running', + message: message ?? '' + }) +} + +export function markJobDone(id: string, patch?: { + message?: string + outputPath?: string | null + payload?: Record +}): void { + updateBackupJob(id, { + status: 'done', + message: patch?.message ?? '', + outputPath: patch?.outputPath, + payload: patch?.payload, + finishedAt: new Date().toISOString(), + error: null + }) +} + +export function markJobFailed(id: string, error: string, message?: string): void { + updateBackupJob(id, { + status: 'failed', + error, + message: message ?? error, + finishedAt: new Date().toISOString() + }) +} diff --git a/server/utils/backup/manifest.ts b/server/utils/backup/manifest.ts new file mode 100644 index 0000000..6e16766 --- /dev/null +++ b/server/utils/backup/manifest.ts @@ -0,0 +1,33 @@ +export const BACKUP_FORMAT_VERSION = 1 + +export interface BackupManifest { + formatVersion: number + appVersion: string + createdAt: string + imageCount: number + imageBytes: number + includesDatabase: boolean +} + +export interface BackupRestorePreview { + imageCount: number + imageBytes: number + newImages: number + conflictingImages: number + formatVersion: number + appVersion: string + createdAt: string +} + +export interface StorageBackendMetaExport { + id: string + name: string + type: string + config: Record + servingMode: string + publicUrl: string + quotaBytes: number | null + enabled: boolean + isDefault: boolean + sortOrder: number +} diff --git a/server/utils/backup/paths.test.ts b/server/utils/backup/paths.test.ts new file mode 100644 index 0000000..661f229 --- /dev/null +++ b/server/utils/backup/paths.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { BACKUP_FORMAT_VERSION } from './manifest' +import { BACKUP_ARCHIVE_EXT, isSafeBackupFilename } from './paths' + +describe('isSafeBackupFilename', () => { + it('accepts valid backup archive names', () => { + expect(isSafeBackupFilename(`pichost-abc123${BACKUP_ARCHIVE_EXT}`)).toBe(true) + }) + + it('rejects path traversal and wrong extension', () => { + expect(isSafeBackupFilename('')).toBe(false) + expect(isSafeBackupFilename('../secret.phost.tar.gz')).toBe(false) + expect(isSafeBackupFilename('backup.tar.gz')).toBe(false) + expect(isSafeBackupFilename('nested/pkg.phost.tar.gz')).toBe(false) + }) +}) + +describe('BACKUP_FORMAT_VERSION', () => { + it('is v1', () => { + expect(BACKUP_FORMAT_VERSION).toBe(1) + }) +}) diff --git a/server/utils/backup/paths.ts b/server/utils/backup/paths.ts new file mode 100644 index 0000000..f646afb --- /dev/null +++ b/server/utils/backup/paths.ts @@ -0,0 +1,19 @@ +import { join } from 'node:path' +import { getDataDir } from '../data-dir' + +export const BACKUP_ARCHIVE_EXT = '.phost.tar.gz' + +export function getBackupsDir(): string { + return join(getDataDir(), 'backups') +} + +export function getBackupStagingDir(jobId: string): string { + return join(getBackupsDir(), 'staging', jobId) +} + +export function isSafeBackupFilename(name: string): boolean { + if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) { + return false + } + return name.endsWith(BACKUP_ARCHIVE_EXT) +} diff --git a/server/utils/backup/runner.ts b/server/utils/backup/runner.ts new file mode 100644 index 0000000..892b3e8 --- /dev/null +++ b/server/utils/backup/runner.ts @@ -0,0 +1,112 @@ +import { runBackendSyncJob } from './backend-sync' +import { runFullExportJob } from './full-export' +import { runFullRestoreJob } from './full-restore' +import { + createBackupJob, + getBackupJob, + markJobFailed, + markJobRunning, + recoverInterruptedBackupJobs, + type BackupJob +} from './jobs' + +interface BackupRunnerState { + running: boolean + currentJobId: string | null +} + +const runnerState: BackupRunnerState = (globalThis as typeof globalThis & { + __pichostBackupRunner?: BackupRunnerState +}).__pichostBackupRunner ?? { + running: false, + currentJobId: null +} +;(globalThis as typeof globalThis & { + __pichostBackupRunner?: BackupRunnerState +}).__pichostBackupRunner = runnerState + +export function isBackupRunnerBusy(): boolean { + return runnerState.running +} + +function claimJobSlot(): void { + if (runnerState.running) { + throw new Error('BACKUP_JOB_RUNNING') + } + recoverInterruptedBackupJobs() +} + +export function cancelBackupJob(jobId: string): boolean { + const job = getBackupJob(jobId) + if (!job || (job.status !== 'running' && job.status !== 'pending')) { + return false + } + markJobFailed(jobId, '任务已取消') + if (runnerState.currentJobId === jobId) { + runnerState.running = false + runnerState.currentJobId = null + } + return true +} + +export function scheduleBackupJob( + job: BackupJob, + runner: () => Promise +): void { + if (runnerState.running) { + throw new Error('BACKUP_JOB_RUNNING') + } + runnerState.running = true + runnerState.currentJobId = job.id + markJobRunning(job.id) + + setImmediate(() => { + runner() + .catch(() => {}) + .finally(() => { + runnerState.running = false + runnerState.currentJobId = null + }) + }) +} + +export function startFullExportJob(appVersion: string): BackupJob { + claimJobSlot() + const job = createBackupJob({ + type: 'full_export', + message: '等待开始…' + }) + scheduleBackupJob(job, () => runFullExportJob(job.id, appVersion)) + return getBackupJob(job.id)! +} + +export function startFullRestoreJob( + archivePath: string, + conflictMode: 'skip' | 'overwrite' +): BackupJob { + claimJobSlot() + const job = createBackupJob({ + type: 'full_restore', + message: '等待开始…', + payload: { archivePath, conflictMode } + }) + scheduleBackupJob(job, () => runFullRestoreJob(job.id, archivePath, { conflictMode })) + return getBackupJob(job.id)! +} + +export function startBackendSyncJob(options: { + fromBackendId: string + toBackendId: string + deleteSource?: boolean + setDefault?: boolean + dryRun?: boolean +}): BackupJob { + claimJobSlot() + const job = createBackupJob({ + type: 'backend_sync', + message: options.dryRun ? '预检中…' : '等待开始…', + payload: options + }) + scheduleBackupJob(job, () => runBackendSyncJob(job.id, options)) + return getBackupJob(job.id)! +} diff --git a/server/utils/backup/schema.ts b/server/utils/backup/schema.ts new file mode 100644 index 0000000..81fc03f --- /dev/null +++ b/server/utils/backup/schema.ts @@ -0,0 +1,21 @@ +import { getDb } from '../db' + +export function ensureBackupSchema(): void { + getDb().exec(` + CREATE TABLE IF NOT EXISTS backup_jobs ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK(type IN ('full_export', 'full_restore', 'backend_sync')), + status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'done', 'failed')), + progress INTEGER NOT NULL DEFAULT 0, + total INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', + payload_json TEXT NOT NULL DEFAULT '{}', + output_path TEXT, + error TEXT, + created_at TEXT NOT NULL, + finished_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_backup_jobs_created_at + ON backup_jobs(created_at DESC); + `) +} diff --git a/server/utils/backup/stats.ts b/server/utils/backup/stats.ts new file mode 100644 index 0000000..4e1167e --- /dev/null +++ b/server/utils/backup/stats.ts @@ -0,0 +1,38 @@ +import { stat } from 'node:fs/promises' +import { join } from 'node:path' +import { getDataDir } from '../data-dir' +import { getImageTotals } from '../image-index' +import { getBackupsDir } from './paths' + +export async function getBackupExportEstimate(): Promise<{ + imageCount: number + imageBytes: number + databaseBytes: number + totalBytes: number +}> { + const { count, bytes } = getImageTotals() + const databaseBytes = await (async () => { + try { + const dbStat = await stat(join(getDataDir(), 'pichost.db')) + return dbStat.size + } catch { + return 0 + } + })() + return { + imageCount: count, + imageBytes: bytes, + databaseBytes, + totalBytes: bytes + databaseBytes + } +} + +export async function getAvailableDiskBytes(): Promise { + try { + const { statfs } = await import('node:fs/promises') + const stats = await statfs(getBackupsDir()) + return Number(stats.bavail) * Number(stats.bsize) + } catch { + return null + } +} diff --git a/server/utils/db.ts b/server/utils/db.ts index d239b54..cd4cc10 100644 --- a/server/utils/db.ts +++ b/server/utils/db.ts @@ -36,8 +36,27 @@ export interface ActivityLogInput { } let db: DatabaseSync | null = null +let dbReplacing = false + +export function isDbReplacing(): boolean { + return dbReplacing +} + +export function setDbReplacing(value: boolean): void { + dbReplacing = value +} + +export function closeDb(): void { + if (db) { + db.close() + db = null + } +} export function getDb(): DatabaseSync { + if (dbReplacing) { + throw new Error('DATABASE_REPLACE_IN_PROGRESS') + } if (db) return db const dataDir = getDataDir() diff --git a/server/utils/image-index.ts b/server/utils/image-index.ts index 39c8994..2557e2e 100644 --- a/server/utils/image-index.ts +++ b/server/utils/image-index.ts @@ -369,6 +369,41 @@ function buildBackendFilterSql( return ' AND backend_id = ?' } +export function getImageTotals(): { count: number, bytes: number } { + ensureStorageSchema() + const row = getDb().prepare(` + SELECT COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes + FROM images + `).get() as { count: number, bytes: number } + return { count: row.count, bytes: Number(row.bytes) } +} + +export function listAllImageIndexRows(): ImageIndexRow[] { + ensureStorageSchema() + return getDb().prepare(` + SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at + FROM images + ORDER BY key ASC + `).all() as unknown as ImageIndexRow[] +} + +export function listImageIndexRowsByBackend(backendId: string): ImageIndexRow[] { + ensureStorageSchema() + return getDb().prepare(` + SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at + FROM images + WHERE backend_id = ? + ORDER BY key ASC + `).all(backendId) as unknown as ImageIndexRow[] +} + +export function updateImageBackendId(key: string, backendId: string): void { + ensureStorageSchema() + getDb().prepare(` + UPDATE images SET backend_id = ? WHERE key = ? + `).run(backendId, key) +} + export async function countImages( userFilter?: number | 'admin', backendId?: string