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
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:
1 parent
b05d572221
commit
f17d79b236
72 files changed
+4312
-559
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user