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

+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)
})