release: v1.3.0 — 整站备份恢复、跨后端同步与任务中心
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s

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

No files matched your search

+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