release: v1.3.0 — 整站备份恢复、跨后端同步与任务中心

This commit is contained in:
O96u committed 2026-09-07 17:05:12 +08:00
1 parent b05d572221
commit 09e1236666
72 files changed
+4312 -559

No files matched your search

+18 -1
View File
@@ -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)
### 改进
+7 -1
View File
@@ -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
+8 -3
View File
@@ -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
+8 -3
View File
@@ -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/)**。
| 分支 | 说明 |
| ---- | ---- |
+444
View File
@@ -0,0 +1,444 @@
<script setup lang="ts">
import type { BackendSyncPreview, SyncDraft } from '~/types/backup'
import type { StorageBackendItem } from '~/types/storage'
const props = defineProps<{
backends: StorageBackendItem[]
syncDraft: SyncDraft | null
highlighted?: boolean
busy: boolean
disabled?: boolean
activeJob?: { progress: number, total: number, message: string } | null
previewSync: (body: { fromBackendId: string, toBackendId: string }) => Promise<BackendSyncPreview>
}>()
const emit = defineEmits<{
sync: [body: {
fromBackendId: string
toBackendId: string
deleteSource: boolean
setDefault: boolean
}]
}>()
const { t, locale } = useI18n()
const { formatFileSize } = useFileSize()
const toast = useToast()
const fromBackendId = ref('')
const toBackendId = ref('')
const deleteSource = ref(false)
const setDefault = ref(false)
const preview = ref<BackendSyncPreview | null>(null)
const previewLoading = ref(false)
const statsLoading = ref(false)
const confirmOpen = ref(false)
const userTouched = ref(false)
let statsTimer: ReturnType<typeof setTimeout> | null = null
const backendOptions = computed(() =>
props.backends.map(backend => ({
label: backend.name,
value: backend.id
}))
)
const backendNameById = computed(() =>
new Map(props.backends.map(backend => [backend.id, backend.name]))
)
const needsMoreBackends = computed(() => props.backends.length < 2)
const syncDisabled = computed(() =>
props.disabled || props.busy || needsMoreBackends.value
)
function formatCount(value: number) {
return value.toLocaleString(locale.value)
}
function initDefaultSelection() {
if (userTouched.value || props.syncDraft) return
const defaultBackend = props.backends.find(backend => backend.isDefault)
const target = props.backends.find(backend => !backend.isDefault && backend.enabled)
if (defaultBackend && target) {
fromBackendId.value = defaultBackend.id
toBackendId.value = target.id
return
}
if (props.backends.length >= 2) {
fromBackendId.value = props.backends[0]!.id
toBackendId.value = props.backends[1]!.id
}
}
function canLoadStats(): boolean {
return !needsMoreBackends.value
&& Boolean(fromBackendId.value)
&& Boolean(toBackendId.value)
&& fromBackendId.value !== toBackendId.value
}
async function refreshStats(immediate = false) {
if (!canLoadStats()) {
preview.value = null
return
}
if (immediate) {
if (statsTimer) clearTimeout(statsTimer)
await loadStats()
return
}
scheduleStatsLoad()
}
async function loadStats() {
if (needsMoreBackends.value || !fromBackendId.value || !toBackendId.value) {
preview.value = null
return
}
if (fromBackendId.value === toBackendId.value) {
preview.value = null
return
}
statsLoading.value = true
try {
preview.value = await props.previewSync({
fromBackendId: fromBackendId.value,
toBackendId: toBackendId.value
})
} catch {
preview.value = null
} finally {
statsLoading.value = false
}
}
function scheduleStatsLoad() {
if (statsTimer) clearTimeout(statsTimer)
statsTimer = setTimeout(() => {
void loadStats()
}, 400)
}
watch(
() => props.backends,
async () => {
initDefaultSelection()
await nextTick()
await refreshStats(true)
},
{ immediate: true }
)
watch(
() => props.syncDraft,
async (draft) => {
if (!draft) return
fromBackendId.value = draft.fromBackendId
toBackendId.value = draft.toBackendId
userTouched.value = true
await refreshStats(true)
},
{ immediate: true }
)
watch([fromBackendId, toBackendId], () => {
void refreshStats()
})
onBeforeUnmount(() => {
if (statsTimer) clearTimeout(statsTimer)
})
function applyDraft(draft: SyncDraft) {
fromBackendId.value = draft.fromBackendId
toBackendId.value = draft.toBackendId
userTouched.value = true
void refreshStats(true)
}
defineExpose({ applyDraft })
function backendName(id: string) {
return backendNameById.value.get(id) ?? id
}
function validateSelection(): boolean {
if (needsMoreBackends.value) {
toast.add({ title: t('storage.backup.syncNeedTwoBackends'), color: 'warning' })
return false
}
if (!fromBackendId.value || !toBackendId.value) {
toast.add({ title: t('storage.backup.syncSelectRequired'), color: 'warning' })
return false
}
if (fromBackendId.value === toBackendId.value) {
toast.add({ title: t('storage.backup.syncSameBackend'), color: 'warning' })
return false
}
return true
}
async function submit() {
if (!validateSelection()) return
previewLoading.value = true
try {
preview.value = await props.previewSync({
fromBackendId: fromBackendId.value,
toBackendId: toBackendId.value
})
confirmOpen.value = true
} catch {
toast.add({ title: t('storage.backup.previewFailed'), color: 'error' })
} finally {
previewLoading.value = false
}
}
function confirmSync() {
emit('sync', {
fromBackendId: fromBackendId.value,
toBackendId: toBackendId.value,
deleteSource: deleteSource.value,
setDefault: setDefault.value
})
confirmOpen.value = false
}
</script>
<template>
<article
class="flex h-full flex-col rounded-xl border bg-elevated p-4 transition-colors sm:p-5"
:class="highlighted ? 'border-primary ring-2 ring-primary/25' : 'border-default'"
>
<div class="flex items-start gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-info/10 text-info">
<UIcon
name="i-lucide-arrow-left-right"
class="size-5"
/>
</div>
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ t('storage.backup.syncTitle') }}
</h3>
<p class="mt-0.5 text-sm text-muted">
{{ t('storage.backup.syncSubtitle') }}
</p>
</div>
</div>
<div class="mt-4 flex flex-1 flex-col">
<UAlert
v-if="needsMoreBackends"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:description="t('storage.backup.syncNeedTwoBackends')"
/>
<div
v-else
class="flex flex-1 flex-col space-y-3"
>
<div class="grid gap-3 sm:grid-cols-[1fr_auto_1fr] sm:items-end">
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.syncFrom') }}</label>
<USelect
v-model="fromBackendId"
:items="backendOptions"
size="sm"
class="w-full"
:disabled="syncDisabled"
@update:model-value="userTouched = true"
/>
</div>
<UIcon
name="i-lucide-arrow-right"
class="hidden size-4 shrink-0 text-muted sm:mb-2.5 sm:block"
/>
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.syncTo') }}</label>
<USelect
v-model="toBackendId"
:items="backendOptions"
size="sm"
class="w-full"
:disabled="syncDisabled"
@update:model-value="userTouched = true"
/>
</div>
</div>
<dl class="space-y-1.5 rounded-lg bg-muted/25 px-3 py-2.5 text-sm">
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted">
{{ t('storage.backup.syncPendingImages') }}
</dt>
<dd class="font-medium tabular-nums">
<template v-if="statsLoading">
…
</template>
<template v-else-if="preview">
{{ formatCount(preview.imageCount) }}
</template>
<template v-else>
—
</template>
</dd>
</div>
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted">
{{ t('storage.backup.syncPendingSize') }}
</dt>
<dd class="font-medium tabular-nums">
<template v-if="statsLoading">
…
</template>
<template v-else-if="preview">
{{ formatFileSize(preview.imageBytes) }}
</template>
<template v-else>
—
</template>
</dd>
</div>
</dl>
<div class="space-y-2 text-xs">
<label class="flex cursor-pointer items-center gap-2">
<UCheckbox
v-model="deleteSource"
:disabled="syncDisabled"
/>
<span class="text-muted">{{ t('storage.backup.syncDeleteSource') }}</span>
</label>
<label class="flex cursor-pointer items-center gap-2">
<UCheckbox
v-model="setDefault"
:disabled="syncDisabled"
/>
<span class="text-muted">{{ t('storage.backup.syncSetDefault') }}</span>
</label>
</div>
<div class="mt-auto rounded-lg bg-muted/15 px-3 py-2.5">
<p class="text-xs font-medium text-default">
{{ t('storage.backup.syncIncludesTitle') }}
</p>
<ul class="mt-2 space-y-1.5 text-xs leading-relaxed text-muted">
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-check"
class="mt-0.5 size-3.5 shrink-0 text-info"
/>
<span>{{ t('storage.backup.syncIncludesCopy') }}</span>
</li>
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-check"
class="mt-0.5 size-3.5 shrink-0 text-info"
/>
<span>{{ t('storage.backup.syncIncludesIndex') }}</span>
</li>
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-minus"
class="mt-0.5 size-3.5 shrink-0 text-muted"
/>
<span>{{ t('storage.backup.syncIncludesConfig') }}</span>
</li>
</ul>
</div>
<StorageBackupJobProgress
v-if="busy && activeJob"
class="mt-auto"
:message="activeJob.message"
:progress="activeJob.progress"
:total="activeJob.total"
/>
</div>
</div>
<div
v-if="!needsMoreBackends"
class="mt-auto border-t border-default pt-4"
>
<UButton
:label="t('storage.backup.syncPreview')"
icon="i-lucide-play"
size="sm"
:loading="previewLoading || busy"
:disabled="syncDisabled"
@click="submit"
/>
</div>
<UModal
v-model:open="confirmOpen"
:ui="{ content: 'max-w-md' }"
>
<template #content>
<div class="p-5 sm:p-6">
<h2 class="text-base font-semibold">
{{ t('storage.backup.syncConfirmTitle') }}
</h2>
<p class="mt-2 text-sm text-muted">
{{ backendName(fromBackendId) }} → {{ backendName(toBackendId) }}
</p>
<dl
v-if="preview"
class="mt-4 grid grid-cols-2 gap-3 text-sm"
>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewImages') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ formatCount(preview.imageCount) }}
</dd>
</div>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewSize') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ formatFileSize(preview.imageBytes) }}
</dd>
</div>
</dl>
<UAlert
v-if="deleteSource"
class="mt-4"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:description="t('storage.backup.syncDeleteWarning')"
/>
<div class="mt-5 flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
size="sm"
variant="outline"
color="neutral"
@click="() => { confirmOpen = false }"
/>
<UButton
:label="t('storage.backup.syncConfirm')"
size="sm"
icon="i-lucide-play"
:loading="busy"
@click="confirmSync"
/>
</div>
</div>
</template>
</UModal>
</article>
</template>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import type { BackupEstimate } from '~/types/backup'
defineProps<{
estimate: BackupEstimate | null
loading: boolean
busy: boolean
disabled?: boolean
activeJob?: { progress: number, total: number, message: string } | null
}>()
const emit = defineEmits<{
export: []
}>()
const { t } = useI18n()
const { formatFileSize } = useFileSize()
</script>
<template>
<article class="flex h-full flex-col rounded-xl border border-default bg-elevated p-4 sm:p-5">
<div class="flex items-start gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UIcon
name="i-lucide-archive"
class="size-5"
/>
</div>
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ t('storage.backup.exportTitle') }}
</h3>
<p class="mt-0.5 text-sm text-muted">
{{ t('storage.backup.exportSubtitle') }}
</p>
</div>
</div>
<div class="mt-4 flex flex-1 flex-col space-y-2">
<div
v-if="loading"
class="flex items-center gap-2 text-sm text-muted"
>
<UIcon
name="i-lucide-loader-circle"
class="size-4 animate-spin"
/>
{{ t('storage.backup.estimateLoading') }}
</div>
<dl
v-else-if="estimate"
class="space-y-1.5 rounded-lg bg-muted/25 px-3 py-2.5 text-sm"
>
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted">
{{ t('storage.backup.estimateImageCountLabel') }}
</dt>
<dd class="font-medium tabular-nums">
{{ estimate.imageCount }} · {{ formatFileSize(estimate.imageBytes) }}
</dd>
</div>
<div class="flex items-baseline justify-between gap-2">
<dt class="text-muted">
{{ t('storage.backup.estimateDatabaseSnapshotLabel') }}
</dt>
<dd class="font-medium tabular-nums">
{{ formatFileSize(estimate.databaseBytes) }}
</dd>
</div>
<div class="flex items-baseline justify-between gap-2 border-t border-default/40 pt-1.5">
<dt class="font-medium text-default">
{{ t('storage.backup.estimatePackageSizeLabel') }}
</dt>
<dd class="font-semibold tabular-nums">
{{ formatFileSize(estimate.totalBytes) }}
</dd>
</div>
<div class="flex items-baseline justify-between gap-2 border-t border-default/40 pt-1.5">
<dt class="text-muted">
{{ t('storage.backup.estimateStoragePathLabel') }}
</dt>
<dd class="truncate font-mono text-xs font-medium">
{{ t('storage.backup.estimateStoragePathValue') }}
</dd>
</div>
</dl>
<div
v-if="estimate && !loading"
class="mt-auto rounded-lg bg-muted/15 px-3 py-2.5"
>
<p class="text-xs font-medium text-default">
{{ t('storage.backup.exportIncludesTitle') }}
</p>
<ul class="mt-2 space-y-1.5 text-xs leading-relaxed text-muted">
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-check"
class="mt-0.5 size-3.5 shrink-0 text-primary"
/>
<span>{{ t('storage.backup.exportIncludesDb') }}</span>
</li>
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-check"
class="mt-0.5 size-3.5 shrink-0 text-primary"
/>
<span>{{ t('storage.backup.exportIncludesImages', { count: estimate.imageCount }) }}</span>
</li>
<li class="flex items-start gap-2">
<UIcon
name="i-lucide-minus"
class="mt-0.5 size-3.5 shrink-0 text-muted"
/>
<span>{{ t('storage.backup.exportExcludesSecrets') }}</span>
</li>
</ul>
</div>
<StorageBackupJobProgress
v-if="busy && activeJob"
class="mt-2"
:message="activeJob.message"
:progress="activeJob.progress"
:total="activeJob.total"
/>
</div>
<div class="mt-auto border-t border-default pt-4">
<UButton
:label="t('storage.backup.exportAction')"
icon="i-lucide-download"
size="sm"
:loading="busy"
:disabled="disabled || busy"
@click="emit('export')"
/>
</div>
</article>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
const props = defineProps<{
message: string
progress: number
total: number
}>()
const percent = computed(() => {
if (!props.total) return 0
return Math.min(100, Math.round((props.progress / props.total) * 100))
})
</script>
<template>
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-2 text-xs text-muted">
<span class="truncate">{{ message }}</span>
<span
v-if="total > 0"
class="shrink-0 tabular-nums"
>
{{ progress }} / {{ total }}
</span>
</div>
<div class="h-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
:style="{ width: `${percent}%` }"
/>
</div>
</div>
</template>
+257
View File
@@ -0,0 +1,257 @@
<script setup lang="ts">
import type { BackupJobItem } from '~/types/backup'
defineProps<{
jobs: BackupJobItem[]
polling: boolean
page: number
totalPages: number
total: number
pageSize: number
loading?: boolean
}>()
const emit = defineEmits<{
'retry': [jobId: string]
'download': [jobId: string]
'cancel': [jobId: string]
'view': [job: BackupJobItem]
'update:page': [page: number]
'update:pageSize': [pageSize: number]
}>()
const { t, locale } = useI18n()
function typeLabel(type: BackupJobItem['type']): string {
const map: Record<BackupJobItem['type'], string> = {
full_export: t('storage.backup.jobTypeExport'),
full_restore: t('storage.backup.jobTypeRestore'),
backend_sync: t('storage.backup.jobTypeSync')
}
return map[type]
}
function statusColor(status: BackupJobItem['status']): 'success' | 'error' | 'warning' | 'neutral' {
if (status === 'done') return 'success'
if (status === 'failed') return 'error'
if (status === 'running') return 'warning'
return 'neutral'
}
function statusLabel(status: BackupJobItem['status']): string {
const map: Record<BackupJobItem['status'], string> = {
pending: t('storage.backup.statusPending'),
running: t('storage.backup.statusRunning'),
done: t('storage.backup.statusDone'),
failed: t('storage.backup.statusFailed')
}
return map[status]
}
function progressPercent(job: BackupJobItem): number {
if (!job.total) return job.status === 'done' ? 100 : 0
return Math.min(100, Math.round((job.progress / job.total) * 100))
}
function jobMessage(job: BackupJobItem): string {
if (job.status === 'failed') {
return job.error || job.message || t('storage.backup.jobFailedUnknown')
}
if (job.status === 'running' || job.status === 'pending') {
return job.message
}
if (job.status === 'done' && job.message) {
return job.message
}
return ''
}
function canRetry(job: BackupJobItem): boolean {
return job.type === 'backend_sync' && job.status === 'failed'
}
function canCancel(job: BackupJobItem): boolean {
return job.status === 'running' || job.status === 'pending'
}
function canView(job: BackupJobItem): boolean {
return job.type === 'full_restore' && job.status === 'done' && Boolean(job.message)
}
function formatTime(value: string) {
if (!value) return '—'
return new Date(value).toLocaleString(locale.value, { hour12: false })
}
</script>
<template>
<section class="overflow-hidden rounded-xl border border-default bg-elevated">
<header class="flex items-center justify-between gap-3 border-b border-default/50 px-4 py-4 sm:px-5">
<div>
<h3 class="text-sm font-semibold">
{{ t('storage.backup.jobsTitle') }}
</h3>
<p class="mt-0.5 text-xs text-muted">
{{ t('storage.backup.jobsSubtitle') }}
</p>
</div>
<UIcon
v-if="polling"
name="i-lucide-loader-circle"
class="size-4 shrink-0 animate-spin text-muted"
/>
</header>
<div
v-if="!jobs.length && !loading"
class="flex flex-col items-center gap-2 px-4 py-12 text-center sm:px-5"
>
<UIcon
name="i-lucide-clipboard-list"
class="size-7 text-muted"
/>
<p class="text-sm text-muted">
{{ t('storage.backup.jobsEmpty') }}
</p>
</div>
<div
v-else
class="overflow-x-auto"
>
<table class="w-full min-w-[800px] text-left text-sm">
<thead>
<tr class="border-b border-default/50 text-xs text-muted">
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colTask') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colStatus') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colMessage') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colProgress') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colCreated') }}
</th>
<th class="px-4 py-3 font-medium sm:px-5">
{{ t('storage.backup.colActions') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="job in jobs"
:key="job.id"
class="border-b border-default/40 last:border-0"
>
<td class="px-4 py-3.5 whitespace-nowrap font-medium sm:px-5">
{{ typeLabel(job.type) }}
</td>
<td class="px-4 py-3.5 sm:px-5">
<UBadge
:color="statusColor(job.status)"
variant="subtle"
size="sm"
>
{{ statusLabel(job.status) }}
</UBadge>
</td>
<td class="max-w-xs px-4 py-3.5 sm:px-5">
<p
v-if="jobMessage(job)"
class="line-clamp-2 text-xs leading-relaxed"
:class="job.status === 'failed' ? 'text-error' : 'text-muted'"
:title="jobMessage(job)"
>
{{ jobMessage(job) }}
</p>
<span
v-else
class="text-xs text-dimmed"
>—</span>
</td>
<td class="min-w-32 px-4 py-3.5 sm:px-5">
<div class="flex items-center gap-2">
<div class="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all"
:class="{ 'animate-pulse': job.status === 'running' }"
:style="{ width: `${progressPercent(job)}%` }"
/>
</div>
<span class="shrink-0 text-xs tabular-nums text-muted">
<template v-if="job.total">{{ job.progress }}/{{ job.total }}</template>
<template v-else-if="job.status === 'done'">100%</template>
<template v-else>—</template>
</span>
</div>
</td>
<td class="px-4 py-3.5 whitespace-nowrap text-xs text-muted sm:px-5">
{{ formatTime(job.createdAt) }}
</td>
<td class="px-4 py-3.5 sm:px-5">
<div class="flex flex-wrap gap-1.5">
<UButton
v-if="job.downloadable"
:label="t('storage.backup.download')"
icon="i-lucide-download"
size="xs"
variant="outline"
color="neutral"
@click="emit('download', job.id)"
/>
<UButton
v-if="canView(job)"
:label="t('storage.backup.jobView')"
icon="i-lucide-eye"
size="xs"
variant="outline"
color="neutral"
@click="emit('view', job)"
/>
<UButton
v-if="canCancel(job)"
:label="t('storage.backup.cancel')"
icon="i-lucide-x"
size="xs"
variant="outline"
color="warning"
@click="emit('cancel', job.id)"
/>
<UButton
v-if="canRetry(job)"
:label="t('storage.backup.retry')"
icon="i-lucide-rotate-ccw"
size="xs"
variant="outline"
color="neutral"
@click="emit('retry', job.id)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="total > 0"
class="border-t border-default/50 px-4 py-4 sm:px-5"
>
<PaginationBar
:page="page"
:total-pages="totalPages"
:total="total"
:page-size="pageSize"
:loading="loading"
:unit="t('storage.backup.jobsUnit')"
@update:page="emit('update:page', $event)"
@update:page-size="emit('update:pageSize', $event)"
/>
</div>
</section>
</template>
@@ -0,0 +1,305 @@
<script setup lang="ts">
import type { BackupPackageItem, BackupRestorePreview } from '~/types/backup'
const props = defineProps<{
packages: BackupPackageItem[]
busy: boolean
disabled?: boolean
restoreBusy?: boolean
activeJob?: { progress: number, total: number, message: string } | null
previewRestore: (input: { file?: File, archiveName?: string }) => Promise<BackupRestorePreview>
}>()
const emit = defineEmits<{
restore: [input: { file?: File, archiveName?: string, conflictMode: 'skip' | 'overwrite' }]
}>()
const { t, locale } = useI18n()
const { formatFileSize } = useFileSize()
const toast = useToast()
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const selectedPackage = ref<string>('')
const conflictMode = ref<'skip' | 'overwrite'>('skip')
const preview = ref<BackupRestorePreview | null>(null)
const previewLoading = ref(false)
const confirmOpen = ref(false)
const conflictItems = computed(() => [
{ label: t('storage.backup.conflictSkip'), value: 'skip' },
{ label: t('storage.backup.conflictOverwrite'), value: 'overwrite' }
])
const selectMenuUi = {
content: 'min-w-[var(--reka-select-trigger-width)] w-max max-w-[min(100vw-2rem,24rem)]',
item: 'whitespace-normal',
itemLabel: 'whitespace-normal'
}
const packageOptions = computed(() =>
props.packages.map(pkg => ({
label: `${shortenName(pkg.name)} (${formatFileSize(pkg.bytes)})`,
value: pkg.name
}))
)
function shortenName(name: string) {
if (name.length <= 36) return name
return `${name.slice(0, 22)}…${name.slice(-10)}`
}
function formatTime(value: string) {
if (!value) return '—'
return new Date(value).toLocaleString(locale.value, { hour12: false })
}
function pickFile() {
fileInput.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
if (!file) return
selectedFile.value = file
selectedPackage.value = ''
preview.value = null
if (input) input.value = ''
}
function selectPackage(name: string | null) {
if (!name) {
selectedPackage.value = ''
return
}
selectedPackage.value = name
selectedFile.value = null
preview.value = null
}
async function submit() {
if (!selectedFile.value && !selectedPackage.value) {
toast.add({ title: t('storage.backup.restoreSelectRequired'), color: 'warning' })
return
}
previewLoading.value = true
try {
preview.value = await props.previewRestore({
file: selectedFile.value ?? undefined,
archiveName: selectedPackage.value || undefined
})
confirmOpen.value = true
} catch {
toast.add({ title: t('storage.backup.previewFailed'), color: 'error' })
} finally {
previewLoading.value = false
}
}
function confirmRestore() {
emit('restore', {
file: selectedFile.value ?? undefined,
archiveName: selectedPackage.value || undefined,
conflictMode: conflictMode.value
})
confirmOpen.value = false
selectedFile.value = null
selectedPackage.value = ''
preview.value = null
}
const sourceLabel = computed(() => selectedFile.value?.name || selectedPackage.value || '')
</script>
<template>
<article class="flex h-full flex-col rounded-xl border border-default bg-elevated p-4 sm:p-5">
<div class="flex items-start gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-warning/10 text-warning">
<UIcon
name="i-lucide-upload"
class="size-5"
/>
</div>
<div class="min-w-0">
<h3 class="text-base font-semibold">
{{ t('storage.backup.restoreTitle') }}
</h3>
<p class="mt-0.5 text-sm text-muted">
{{ t('storage.backup.restoreSubtitle') }}
</p>
</div>
</div>
<div class="mt-4 flex flex-1 flex-col space-y-3">
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.restoreUploadLabel') }}</label>
<button
type="button"
class="flex w-full flex-col items-center gap-1.5 rounded-lg border-2 border-dashed px-3 py-2.5 text-center transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="selectedFile
? 'border-primary/50 bg-primary/5'
: 'border-warning/40 bg-warning/5 hover:border-warning/60 hover:bg-warning/10'"
:disabled="disabled || busy"
@click="pickFile"
>
<div
class="flex size-8 items-center justify-center rounded-full"
:class="selectedFile ? 'bg-primary/15 text-primary' : 'bg-warning/15 text-warning'"
>
<UIcon
:name="selectedFile ? 'i-lucide-check' : 'i-lucide-upload'"
class="size-4.5"
/>
</div>
<span class="max-w-full truncate text-sm font-medium text-default">
{{ selectedFile?.name ?? t('storage.backup.restoreUpload') }}
</span>
<span
v-if="!selectedFile"
class="text-xs text-muted"
>
{{ t('storage.backup.restoreUploadHint') }}
</span>
</button>
</div>
<div
v-if="packages.length"
class="space-y-1.5"
>
<label class="text-xs text-muted">{{ t('storage.backup.restoreServerPackages') }}</label>
<USelect
:model-value="selectedPackage || undefined"
:items="packageOptions"
size="sm"
class="w-full"
:ui="selectMenuUi"
:placeholder="t('storage.backup.restoreServerSelectPlaceholder')"
:disabled="disabled || busy"
@update:model-value="selectPackage"
/>
</div>
<div class="space-y-2">
<label class="text-xs text-muted">{{ t('storage.backup.conflictMode') }}</label>
<URadioGroup
v-model="conflictMode"
:items="conflictItems"
:disabled="disabled || busy"
/>
</div>
<StorageBackupJobProgress
v-if="restoreBusy && activeJob"
class="mt-auto"
:message="activeJob.message"
:progress="activeJob.progress"
:total="activeJob.total"
/>
</div>
<div class="mt-auto border-t border-default pt-4">
<UButton
:label="t('storage.backup.restorePreviewConfirm')"
icon="i-lucide-search"
size="sm"
:loading="previewLoading || restoreBusy"
:disabled="disabled || busy"
@click="submit"
/>
</div>
<input
ref="fileInput"
type="file"
accept=".phost.tar.gz,.tar.gz"
class="hidden"
@change="onFileChange"
>
<UModal
v-model:open="confirmOpen"
:ui="{ content: 'max-w-md' }"
>
<template #content>
<div class="p-5 sm:p-6">
<h2 class="text-base font-semibold">
{{ t('storage.backup.restoreConfirmTitle') }}
</h2>
<p
v-if="sourceLabel"
class="mt-2 truncate font-mono text-xs text-muted"
:title="sourceLabel"
>
{{ sourceLabel }}
</p>
<dl
v-if="preview"
class="mt-4 grid grid-cols-2 gap-3 text-sm"
>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewImages') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ preview.imageCount }}
</dd>
</div>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewNew') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ preview.newImages }}
</dd>
</div>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewConflicts') }}
</dt>
<dd class="mt-0.5 font-semibold tabular-nums">
{{ preview.conflictingImages }}
</dd>
</div>
<div>
<dt class="text-xs text-muted">
{{ t('storage.backup.previewCreated') }}
</dt>
<dd class="mt-0.5 text-xs">
{{ formatTime(preview.createdAt) }}
</dd>
</div>
</dl>
<UAlert
v-if="conflictMode === 'overwrite'"
class="mt-4"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:description="t('storage.backup.restoreOverwriteWarning')"
/>
<div class="mt-5 flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
size="sm"
variant="outline"
color="neutral"
@click="() => { confirmOpen = false }"
/>
<UButton
:label="t('storage.backup.restoreConfirm')"
size="sm"
color="warning"
:loading="restoreBusy"
@click="confirmRestore"
/>
</div>
</div>
</template>
</UModal>
</article>
</template>
@@ -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')"
/>
<UButton
v-if="showMigrateIn"
:label="t('storage.backup.migrateIn')"
:title="t('storage.backup.migrateInHint')"
icon="i-lucide-import"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled"
@click="emit('migrateIn')"
/>
<UButton
v-if="showMigrateOut"
:label="t('storage.backup.migrateOut')"
:title="t('storage.backup.migrateOutHint')"
icon="i-lucide-arrow-up-from-line"
size="sm"
variant="outline"
color="neutral"
:disabled="actionsDisabled"
@click="emit('migrateOut')"
/>
<UButton
v-if="canEdit()"
:label="t('common.edit')"
@@ -0,0 +1,178 @@
<script setup lang="ts">
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<SyncDraft | null>(null)
const syncCardRef = ref<{ applyDraft: (draft: SyncDraft) => void } | null>(null)
const panelRef = ref<HTMLElement | null>(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'
})
}
</script>
<template>
<section
ref="panelRef"
class="scroll-mt-20 space-y-4 border-t border-default pt-5"
>
<header>
<h2 class="text-base font-semibold">
{{ t('storage.backup.sectionTitle') }}
</h2>
<p class="mt-1 text-sm text-muted">
{{ t('storage.backup.sectionSubtitle') }}
</p>
</header>
<div class="grid grid-cols-1 items-stretch gap-4 xl:grid-cols-3">
<StorageBackupExportCard
class="h-full"
:estimate="estimate"
:loading="loadingEstimate"
:busy="exportBusy"
:active-job="exportBusy ? activeJob : null"
:disabled="anyJobBusy && !exportBusy"
@export="onExport"
/>
<StorageBackupRestoreCard
class="h-full"
:packages="packages"
:busy="anyJobBusy"
:restore-busy="restoreBusy"
:active-job="restoreBusy ? activeJob : null"
:disabled="anyJobBusy && !restoreBusy"
:preview-restore="previewRestore"
@restore="onRestore"
/>
<StorageBackendSyncCard
ref="syncCardRef"
class="h-full"
:backends="props.backends"
:sync-draft="syncDraft"
:highlighted="syncHighlighted"
:busy="syncBusy"
:active-job="syncBusy ? activeJob : null"
:disabled="anyJobBusy && !syncBusy"
:preview-sync="previewSync"
@sync="onSync"
/>
</div>
<StorageBackupJobTable
:jobs="jobs"
:polling="polling"
:page="jobsPage"
:total-pages="jobsTotalPages"
:total="jobsTotal"
:page-size="jobsPageSize"
:loading="jobsLoading"
@update:page="setJobsPage"
@update:page-size="setJobsPageSize"
@download="onDownload"
@retry="onRetry"
@cancel="onCancel"
@view="onViewJob"
/>
</section>
</template>
+319
View File
@@ -0,0 +1,319 @@
import type {
BackupEstimate,
BackupJobItem,
BackupPackageItem,
BackupRestorePreview,
BackendSyncPreview
} from '~/types/backup'
const jobs = ref<BackupJobItem[]>([])
const jobsPage = ref(1)
const jobsPageSize = ref(10)
const jobsTotal = ref(0)
const jobsTotalPages = ref(1)
const jobsLoading = ref(false)
const activeJobState = ref<BackupJobItem | null>(null)
const estimate = ref<BackupEstimate | null>(null)
const packages = ref<BackupPackageItem[]>([])
const loadingEstimate = ref(false)
const polling = ref(false)
let pollTimer: ReturnType<typeof setInterval> | 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<BackupRestorePreview> {
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<BackendSyncPreview> {
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
}
}
+175
View File
@@ -29,6 +29,93 @@ const imageBaseUrl = ref('')
const loading = ref(false)
const domainSeparationConfirmOpen = ref(false)
const setupMode = ref<'init' | 'restore'>('init')
const restoreFile = ref<File | null>(null)
const restoreFileInput = ref<HTMLInputElement | null>(null)
const restoreLoading = ref(false)
const restoreJobId = ref<string | null>(null)
const restoreProgress = ref({ progress: 0, total: 0, message: '' })
let restorePollTimer: ReturnType<typeof setInterval> | 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() {
</div>
</div>
<div class="mb-6 flex rounded-lg border border-default p-1">
<button
type="button"
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors"
:class="setupMode === 'init' ? 'bg-primary text-inverted' : 'text-muted hover:text-default'"
@click="setupMode = 'init'"
>
{{ t('setup.modeInit') }}
</button>
<button
v-if="!isMigrate"
type="button"
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors"
:class="setupMode === 'restore' ? 'bg-primary text-inverted' : 'text-muted hover:text-default'"
@click="setupMode = 'restore'"
>
{{ t('setup.modeRestore') }}
</button>
</div>
<form
v-if="setupMode === 'init'"
class="flex w-full flex-col gap-5"
@submit.prevent="submit"
>
@@ -269,6 +377,73 @@ async function performSubmit() {
:disabled="!canSubmit"
/>
</form>
<div
v-else
class="flex w-full flex-col gap-5"
>
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.restoreUploadLabel') }}</label>
<button
type="button"
class="flex w-full flex-col items-center gap-1.5 rounded-lg border-2 border-dashed px-3 py-4 text-center transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="restoreFile
? 'border-primary/50 bg-primary/5'
: 'border-warning/40 bg-warning/5 hover:border-warning/60 hover:bg-warning/10'"
:disabled="restoreLoading"
@click="pickRestoreFile"
>
<div
class="flex size-8 items-center justify-center rounded-full"
:class="restoreFile ? 'bg-primary/15 text-primary' : 'bg-warning/15 text-warning'"
>
<UIcon
:name="restoreFile ? 'i-lucide-check' : 'i-lucide-upload'"
class="size-4.5"
/>
</div>
<span class="max-w-full truncate text-sm font-medium text-default">
{{ restoreFile?.name ?? t('storage.backup.restoreUpload') }}
</span>
<span
v-if="!restoreFile"
class="text-xs text-muted"
>
{{ t('storage.backup.restoreUploadHint') }}
</span>
</button>
</div>
<div class="rounded-lg bg-muted/15 px-3 py-2.5 text-xs leading-relaxed text-muted">
{{ t('setup.restoreHint') }}
</div>
<StorageBackupJobProgress
v-if="restoreLoading"
:message="restoreProgress.message || t('setup.restoreSubmit')"
:progress="restoreProgress.progress"
:total="restoreProgress.total"
/>
<UButton
:label="t('setup.restoreSubmit')"
icon="i-lucide-upload"
size="lg"
block
class="w-full"
:loading="restoreLoading"
:disabled="!restoreFile"
@click="submitRestore"
/>
<input
ref="restoreFileInput"
type="file"
accept=".phost.tar.gz,.tar.gz"
class="hidden"
@change="onRestoreFileChange"
>
</div>
</div>
<UModal
+57
View File
@@ -21,6 +21,53 @@ const deleteTarget = ref<StorageBackendItem | null>(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)"
/>
</div>
<StorageBackupPanel
v-if="!storageData.envOverride"
ref="backupPanelRef"
:backends="storageData.backends"
/>
<StorageSupportedBackends />
</template>
</section>
+53
View File
@@ -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<string, unknown>
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
}
+1 -1
View File
@@ -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
+12 -4
View File
@@ -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 "$@"
-2
View File
@@ -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' }
]
}
+1 -4
View File
@@ -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
+10 -10
View File
@@ -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?
+9 -7
View File
@@ -33,10 +33,18 @@ See `docker-compose.yml` in the repository.
## First-time setup
1. Open your instance URL (Docker default `http://<host>: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)
+7 -3
View File
@@ -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) |
+1 -2
View File
@@ -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: <https://o96u.github.io/PicHost/>
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: <https://o96u.github.io/PicHost/>
-129
View File
@@ -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)
+39 -4
View File
@@ -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
+1 -4
View File
@@ -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] 摘要
+10 -10
View File
@@ -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)。
## 开发时如何跳过登录?
+9 -7
View File
@@ -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`
+6 -2
View File
@@ -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) |
+1 -2
View File
@@ -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/`。在线文档:<https://o96u.github.io/PicHost/>
文档源码在 `docs-site/`;截图源文件在 `docs/screenshots/`(含 `login.png`、`setup.png`、`setup-restore.png` 等),构建前会复制到 `docs-site/public/screenshots/`。在线文档:<https://o96u.github.io/PicHost/>
-176
View File
@@ -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` | 重置密码(与迁移无关) |
+43 -7
View File
@@ -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` 与各云桶中的对象;推荐使用上文整站导出。
## 相关
Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 90 KiB

+131 -2
View File
@@ -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",
+131 -2
View File
@@ -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": "图库",
+27 -4
View File
@@ -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",
+7 -3
View File
@@ -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",
+40
View File
@@ -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
}
})
+26
View File
@@ -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
}
})
+19
View File
@@ -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) }
})
@@ -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 }
})
@@ -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))
})
@@ -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 }
})
+36
View File
@@ -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
}
})
+108
View File
@@ -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<void>
}> {
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<RestoreBody>(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
}
})
+50
View File
@@ -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<SyncBody>(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
}
})
+36
View File
@@ -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
}
})
+17
View File
@@ -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) }
})
+14
View File
@@ -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))
}
+47
View File
@@ -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)
})
+65
View File
@@ -0,0 +1,65 @@
/**
* Restore PicHost from a .phost.tar.gz backup.
*
* Usage:
* node server/cli/backup-restore.mjs <archive-path> [--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 <archive-path> [--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)
})
-166
View File
@@ -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)
})
+72
View File
@@ -0,0 +1,72 @@
/**
* Sync images between storage backends.
*
* Usage:
* node server/cli/storage-sync.mjs --from <id> --to <id> [--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 <backend-id> --to <backend-id> [--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)
})
+8
View File
@@ -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)`)
}
})
+31
View File
@@ -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
}
}
+213
View File
@@ -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<Buffer> {
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<void> {
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<void> {
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: '失败项重试完成' })
}
+66
View File
@@ -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<void> {
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<void> {
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<void> {
await mkdir(join(targetPath, '..'), { recursive: true })
checkpointDatabase()
const escaped = sqlitePathLiteral(targetPath)
getDb().exec(`VACUUM INTO '${escaped}'`)
}
export async function replaceDatabaseFromSnapshot(sourcePath: string): Promise<void> {
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)
}
}
+134
View File
@@ -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<void> {
await fs.mkdir(join(filePath, '..'), { recursive: true })
await pipeline(stream, createWriteStream(filePath))
}
export async function runFullExportJob(jobId: string, appVersion: string): Promise<void> {
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
}
}
+182
View File
@@ -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<string[]> {
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<void> {
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<BackupManifest> {
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<BackupRestorePreview> {
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<void> {
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<void> {
await fs.mkdir(join(targetPath, '..'), { recursive: true })
const readStream = createReadStream(archivePath)
const writeStream = (await import('node:fs')).createWriteStream(targetPath)
await pipeline(readStream, writeStream)
}
+15
View File
@@ -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'
+283
View File
@@ -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<string, unknown>
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<string, unknown>
} 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<string, unknown>
}): 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<string, unknown>
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<string, unknown>
}): 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()
})
}
+33
View File
@@ -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<string, unknown>
servingMode: string
publicUrl: string
quotaBytes: number | null
enabled: boolean
isDefault: boolean
sortOrder: number
}
+22
View File
@@ -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)
})
})
+19
View File
@@ -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)
}
+112
View File
@@ -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>
): 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)!
}
+21
View File
@@ -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);
`)
}
+38
View File
@@ -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<number | null> {
try {
const { statfs } = await import('node:fs/promises')
const stats = await statfs(getBackupsDir())
return Number(stats.bavail) * Number(stats.bsize)
} catch {
return null
}
}
+19
View File
@@ -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()
+35
View File
@@ -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