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,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
|
||||
}
|
||||
}
|
||||
@@ -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: '失败项重试完成' })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)!
|
||||
}
|
||||
@@ -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);
|
||||
`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user