From 981a645cb1a976cfa117233ed8bb45d62d0a9773 Mon Sep 17 00:00:00 2001 From: besscroft Date: Thu, 26 Mar 2026 22:06:46 +0800 Subject: [PATCH 1/2] feat: add admin backup import/export with versioned backup package - add protected /api/v1/backup export, import preview, and import endpoints - introduce versioned backup contract, format adapter, and Prisma backup repository - support exporting configs, albums, images, and image-album relations - validate backup packages before import and merge data with fixed upsert rules - rebuild daily materialized data after import - add admin settings backup page, sidebar entry, and locale messages --- app/admin/settings/backup/page.tsx | 511 ++++++++++++++++++++++++ components/layout/admin/app-sidebar.tsx | 6 + hono/backup.ts | 126 ++++++ hono/index.ts | 2 + messages/en.json | 60 +++ messages/ja.json | 60 +++ messages/zh-TW.json | 60 +++ messages/zh.json | 60 +++ server/backup/format-adapter.ts | 356 +++++++++++++++++ server/backup/prisma-repository.ts | 505 +++++++++++++++++++++++ server/backup/repository.ts | 33 ++ server/backup/service.ts | 47 +++ types/backup.ts | 149 +++++++ 13 files changed, 1975 insertions(+) create mode 100644 app/admin/settings/backup/page.tsx create mode 100644 hono/backup.ts create mode 100644 server/backup/format-adapter.ts create mode 100644 server/backup/prisma-repository.ts create mode 100644 server/backup/repository.ts create mode 100644 server/backup/service.ts create mode 100644 types/backup.ts diff --git a/app/admin/settings/backup/page.tsx b/app/admin/settings/backup/page.tsx new file mode 100644 index 00000000..59e76242 --- /dev/null +++ b/app/admin/settings/backup/page.tsx @@ -0,0 +1,511 @@ +'use client' + +import { useMemo, useState } from 'react' +import { ReloadIcon } from '@radix-ui/react-icons' +import { CheckCircle2, FileJson, ShieldAlert, TriangleAlert } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { toast } from 'sonner' + +import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert' +import { Badge } from '~/components/ui/badge' +import { Button } from '~/components/ui/button' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '~/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '~/components/ui/dialog' +import { + FileUpload, + FileUploadDropzone, + FileUploadItem, + FileUploadItemDelete, + FileUploadItemMetadata, + FileUploadItemPreview, + FileUploadList, +} from '~/components/ui/file-upload' +import type { BackupImportResult, BackupPreviewData } from '~/types/backup' + +type ApiResponse = { + code: number; + message: string; + data: T; +} + +const INCLUDED_SCOPE = ['configs', 'albums', 'images', 'imageAlbumRelations'] +const EXCLUDED_SCOPE = ['user', 'session', 'account', 'two_factor', 'passkey', 'verification', 'admin_task_runs', 'daily_images'] + +function createLocalPreview(message: string): BackupPreviewData { + return { + valid: false, + format: null, + version: null, + exportedAt: null, + source: null, + scope: { + included: INCLUDED_SCOPE, + excluded: EXCLUDED_SCOPE, + }, + counts: { + configs: 0, + albums: 0, + images: 0, + imageAlbumRelations: 0, + }, + warnings: [], + issues: [{ + path: '$', + message, + }], + } +} + +function parseFileName(contentDisposition: string | null) { + if (!contentDisposition) { + return 'picimpact-backup-v1.json' + } + + const encodedMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i) + if (encodedMatch?.[1]) { + return decodeURIComponent(encodedMatch[1]) + } + + const match = contentDisposition.match(/filename="?([^"]+)"?/i) + return match?.[1] ?? 'picimpact-backup-v1.json' +} + +function StatItem({ + label, + value, +}: { + label: string; + value: number | string; +}) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +export default function BackupSettingsPage() { + const t = useTranslations('Backup') + const linkT = useTranslations('Link') + const buttonT = useTranslations('Button') + + const [files, setFiles] = useState([]) + const [previewData, setPreviewData] = useState(null) + const [parsedEnvelope, setParsedEnvelope] = useState(null) + const [importResult, setImportResult] = useState(null) + const [previewLoading, setPreviewLoading] = useState(false) + const [importLoading, setImportLoading] = useState(false) + const [exportLoading, setExportLoading] = useState(false) + const [confirmOpen, setConfirmOpen] = useState(false) + + const selectedFile = files[0] ?? null + const canImport = Boolean(previewData?.valid && parsedEnvelope) + + const scopeBadges = useMemo(() => [ + ...INCLUDED_SCOPE.map((item) => ({ item, type: 'include' as const })), + ...EXCLUDED_SCOPE.map((item) => ({ item, type: 'exclude' as const })), + ], []) + + async function downloadBackup() { + try { + setExportLoading(true) + const response = await fetch('/api/v1/backup/export', { + method: 'GET', + cache: 'no-store', + }) + + if (!response.ok) { + throw new Error('Export failed') + } + + const blob = await response.blob() + const objectUrl = window.URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = objectUrl + anchor.download = parseFileName(response.headers.get('content-disposition')) + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.URL.revokeObjectURL(objectUrl) + + toast.success(t('exportSuccess')) + } catch { + toast.error(t('exportFailed')) + } finally { + setExportLoading(false) + } + } + + async function previewImport() { + if (!selectedFile) { + toast.error(t('selectFileFirst')) + return + } + + try { + setPreviewLoading(true) + setImportResult(null) + + const fileText = await selectedFile.text() + let parsed: unknown + + try { + parsed = JSON.parse(fileText) + } catch { + setParsedEnvelope(null) + setPreviewData(createLocalPreview(t('invalidJson'))) + toast.error(t('invalidJson')) + return + } + + const response = await fetch('/api/v1/backup/import/preview', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(parsed), + }) + + const payload = await response.json() as ApiResponse + setPreviewData(payload.data) + + if (!response.ok || payload.code !== 200 || !payload.data?.valid) { + setParsedEnvelope(null) + toast.error(payload.message || t('previewFailed')) + return + } + + setParsedEnvelope(parsed) + toast.success(t('previewSuccess')) + } catch { + setParsedEnvelope(null) + setPreviewData(createLocalPreview(t('previewFailed'))) + toast.error(t('previewFailed')) + } finally { + setPreviewLoading(false) + } + } + + async function confirmImport() { + if (!parsedEnvelope || !previewData?.valid) { + toast.error(t('previewBeforeImport')) + return + } + + try { + setImportLoading(true) + const response = await fetch('/api/v1/backup/import', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(parsedEnvelope), + }) + + const payload = await response.json() as ApiResponse + if (!response.ok || payload.code !== 200) { + toast.error(payload.message || t('importFailed')) + return + } + + setImportResult(payload.data) + setFiles([]) + setParsedEnvelope(null) + setConfirmOpen(false) + toast.success(t('importSuccess')) + } catch { + toast.error(t('importFailed')) + } finally { + setImportLoading(false) + } + } + + return ( +
+
+
{linkT('backup')}
+

+ {t('pageDescription')} +

+
+ + + + {t('sensitiveTitle')} + +

{t('sensitiveDescription')}

+

{t('excludedDescription')}

+
+
+ +
+ + + {t('exportTitle')} + {t('exportDescription')} + + +
+ {scopeBadges.map(({ item, type }) => ( +
+ {item} + + {type === 'include' ? t('includedBadge') : t('excludedBadge')} + +
+ ))} +
+ + + {t('exportFormatTitle')} + {t('exportFormatDescription')} + +
+ + + +
+ + + + {t('importTitle')} + {t('importDescription')} + + + { + setFiles(nextFiles.slice(0, 1)) + setPreviewData(null) + setParsedEnvelope(null) + setImportResult(null) + }} + accept=".json,application/json" + maxFiles={1} + multiple={false} + onFileReject={() => { + toast.error(t('fileRejected')) + }} + > + +
+
+ +
+
+
{t('dropzoneTitle')}
+

{t('dropzoneDescription')}

+
+ {t('dropzoneHint')} +
+
+ + + {files.map((file) => ( + + + + + + + + ))} + +
+ +
+ + +
+ +

{t('importHint')}

+
+
+
+ + {previewData && ( + + +
+ {t('previewTitle')} + + {previewData.valid ? t('validBadge') : t('invalidBadge')} + +
+ {t('previewDescription')} +
+ +
+ + + + +
+ +
+
+
{t('previewMeta')}
+
+
{t('metaFormat')} {previewData.format ?? '-'}
+
{t('metaVersion')} {previewData.version ?? '-'}
+
{t('metaExportedAt')} {previewData.exportedAt ?? '-'}
+
{t('metaSource')} {previewData.source ? `${previewData.source.orm} / ${previewData.source.database}` : '-'}
+
+
+ +
+
{t('previewScope')}
+
+ {previewData.scope.included.map((item) => ( + {item} + ))} + {previewData.scope.excluded.map((item) => ( + {item} + ))} +
+
+
+ + {previewData.warnings.length > 0 && ( + + + {t('warningsTitle')} + +
    + {previewData.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+
+ )} + + {previewData.issues.length > 0 && ( + + + {t('issuesTitle')} + +
    + {previewData.issues.map((issue) => ( +
  • + {issue.path}: {issue.message} +
  • + ))} +
+
+
+ )} +
+
+ )} + + {importResult && ( + + +
+ + {t('resultTitle')} +
+ {t('resultDescription')} +
+ +
+ + + + +
+ +
+
+
{t('resultStats')}
+
+
{t('createdLabel')}: {importResult.entities.configs.createdCount + importResult.entities.albums.createdCount + importResult.entities.images.createdCount}
+
{t('updatedLabel')}: {importResult.entities.configs.updatedCount + importResult.entities.albums.updatedCount + importResult.entities.images.updatedCount}
+
{t('relationReplacedLabel')}: {importResult.entities.imageAlbumRelations.replacedImageCount}
+
{t('relationRemovedLabel')}: {importResult.entities.imageAlbumRelations.removedCount}
+
+
+ + + + {t('dailyRefreshTitle')} + +

{importResult.dailyRefresh.message}

+

{t('importedAtLabel')}: {importResult.importedAt}

+
+
+
+
+
+ )} + + + + + {t('confirmTitle')} + {t('confirmDescription')} + +
+
+
{selectedFile?.name ?? '-'}
+
{t('confirmCounts', { + configs: previewData?.counts.configs ?? 0, + albums: previewData?.counts.albums ?? 0, + images: previewData?.counts.images ?? 0, + relations: previewData?.counts.imageAlbumRelations ?? 0, + })}
+
+ + + {t('confirmWarningTitle')} + {t('confirmWarningDescription')} + +
+ + + + +
+
+
+ ) +} diff --git a/components/layout/admin/app-sidebar.tsx b/components/layout/admin/app-sidebar.tsx index 5679660e..fffe4976 100644 --- a/components/layout/admin/app-sidebar.tsx +++ b/components/layout/admin/app-sidebar.tsx @@ -31,6 +31,7 @@ import { LoaderPinwheelIcon } from '~/components/icons/loader-pinwheel' import { KeySquareIcon } from '~/components/icons/key-square' import { CalendarDaysIcon } from '~/components/icons/calendar-days' import { ListTodoIcon } from '~/components/icons/list-todo' +import { DownloadIcon } from '~/components/icons/download' import { AnimatedIconTrigger, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger' export function AppSidebar({ ...props }: React.ComponentProps) { @@ -103,6 +104,11 @@ export function AppSidebar({ ...props }: React.ComponentProps) { url: '/admin/settings/daily', icon: CalendarDaysIcon, }, + { + name: t('Link.backup'), + url: '/admin/settings/backup', + icon: DownloadIcon, + }, ], }, } diff --git a/hono/backup.ts b/hono/backup.ts new file mode 100644 index 00000000..a3d71183 --- /dev/null +++ b/hono/backup.ts @@ -0,0 +1,126 @@ +import 'server-only' + +import { Hono } from 'hono' +import { HTTPException } from 'hono/http-exception' + +import type { BackupPreviewData } from '~/types/backup' +import { previewBackupImport, exportBackupEnvelope, importBackupEnvelope } from '~/server/backup/service' +import { BackupValidationError } from '~/server/backup/format-adapter' + +const app = new Hono() + +function createInvalidJsonPreview(message: string): BackupPreviewData { + return { + valid: false, + format: null, + version: null, + exportedAt: null, + source: null, + scope: { + included: ['configs', 'albums', 'images', 'imageAlbumRelations'], + excluded: ['user', 'session', 'account', 'two_factor', 'passkey', 'verification', 'admin_task_runs', 'daily_images'], + }, + counts: { + configs: 0, + albums: 0, + images: 0, + imageAlbumRelations: 0, + }, + warnings: [], + issues: [{ + path: '$', + message, + }], + } +} + +function getUtcFileName() { + return `picimpact-backup-v1-${new Date().toISOString().replace(/:/g, '-')}.json` +} + +function rethrowBackupError(error: unknown): never { + if (error instanceof BackupValidationError) { + throw new HTTPException(400, { + message: error.message, + cause: error, + }) + } + + if (error instanceof HTTPException) { + throw error + } + + throw new HTTPException(500, { + message: 'Backup request failed', + cause: error, + }) +} + +app.get('/export', async (c) => { + try { + const envelope = await exportBackupEnvelope() + return c.body(JSON.stringify(envelope, null, 2), 200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Disposition': `attachment; filename="${getUtcFileName()}"`, + 'Cache-Control': 'no-store', + }) + } catch (error) { + rethrowBackupError(error) + } +}) + +app.post('/import/preview', async (c) => { + const body = await c.req.json().catch(() => null) + + if (body === null) { + return c.json({ + code: 400, + message: 'Invalid JSON body', + data: createInvalidJsonPreview('Request body must be valid JSON'), + }, 400) + } + + try { + const data = await previewBackupImport(body) + return c.json({ code: 200, message: 'Success', data }) + } catch (error) { + if (error instanceof BackupValidationError) { + return c.json({ + code: 400, + message: error.message, + data: error.preview, + }, 400) + } + + rethrowBackupError(error) + } +}) + +app.post('/import', async (c) => { + const body = await c.req.json().catch(() => null) + + if (body === null) { + return c.json({ + code: 400, + message: 'Invalid JSON body', + data: createInvalidJsonPreview('Request body must be valid JSON'), + }, 400) + } + + try { + const data = await importBackupEnvelope(body) + return c.json({ code: 200, message: 'Success', data }) + } catch (error) { + if (error instanceof BackupValidationError) { + return c.json({ + code: 400, + message: error.message, + data: error.preview, + }, 400) + } + + rethrowBackupError(error) + } +}) + +export default app diff --git a/hono/index.ts b/hono/index.ts index d8020e71..7e1935e2 100644 --- a/hono/index.ts +++ b/hono/index.ts @@ -7,6 +7,7 @@ import albums from '~/hono/albums' import openList from '~/hono/storage/open-list.ts' import daily from '~/hono/daily' import tasks from '~/hono/tasks' +import backup from '~/hono/backup' import { HTTPException } from 'hono/http-exception' const route = new Hono() @@ -27,5 +28,6 @@ route.route('/albums', albums) route.route('/storage/open-list', openList) route.route('/daily', daily) route.route('/tasks', tasks) +route.route('/backup', backup) export default route diff --git a/messages/en.json b/messages/en.json index 1071b7cc..970ec239 100644 --- a/messages/en.json +++ b/messages/en.json @@ -43,6 +43,7 @@ "authenticator": "Two-Factor Authentication", "passkey": "Passkey", "daily": "Daily Homepage", + "backup": "Backup", "tasks": "Tasks" }, "Passkey": { @@ -540,6 +541,65 @@ "detailEyebrow": "Run details", "unfinished": "Still running", "countsTitle": "Outcome counts" + }, + "Backup": { + "pageDescription": "Export and import versioned PicImpact business backups. Backups include all site configuration records and business data, but exclude authentication tables and object storage files.", + "sensitiveTitle": "Sensitive data notice", + "sensitiveDescription": "This backup includes full configuration data, including storage credentials and secret keys.", + "excludedDescription": "Authentication data, sessions, 2FA, passkeys, task runs, and the Daily materialized view are excluded.", + "exportTitle": "Export backup package", + "exportDescription": "Download a versioned logical backup package that can be imported after future ORM migrations.", + "includedBadge": "Included", + "excludedBadge": "Excluded", + "exportFormatTitle": "Backup contract", + "exportFormatDescription": "Exports JSON in the stable PicImpact backup envelope instead of a database-specific SQL dump.", + "downloadButton": "Download Backup", + "exportSuccess": "Backup download started.", + "exportFailed": "Failed to export backup.", + "importTitle": "Import backup package", + "importDescription": "Upload a PicImpact backup JSON file, validate it first, then confirm the import.", + "fileRejected": "Only one JSON backup file is allowed.", + "dropzoneTitle": "Drop a backup JSON file here", + "dropzoneDescription": "The file is parsed locally first, then validated against the server-side PicImpact backup contract.", + "dropzoneHint": "PicImpact backup only", + "removeFile": "Remove", + "previewButton": "Validate Backup", + "importButton": "Import Backup", + "importHint": "Import is disabled until the selected file passes preview validation.", + "selectFileFirst": "Select a backup file first.", + "invalidJson": "The selected file is not valid JSON.", + "previewFailed": "Failed to validate backup.", + "previewSuccess": "Backup validation passed.", + "previewBeforeImport": "Validate the backup before importing.", + "importSuccess": "Backup imported successfully.", + "importFailed": "Failed to import backup.", + "previewTitle": "Import preview", + "validBadge": "Valid", + "invalidBadge": "Invalid", + "previewDescription": "Review the parsed package metadata, included scope, warnings, and validation issues before importing.", + "previewMeta": "Package metadata", + "metaFormat": "Format:", + "metaVersion": "Version:", + "metaExportedAt": "Exported at:", + "metaSource": "Source:", + "previewScope": "Scope", + "warningsTitle": "Warnings", + "issuesTitle": "Validation issues", + "resultTitle": "Import result", + "resultDescription": "The backup package was merged into the current database using the fixed upsert strategy.", + "resultStats": "Result summary", + "createdLabel": "Created records", + "updatedLabel": "Updated records", + "relationReplacedLabel": "Images with replaced relations", + "relationRemovedLabel": "Removed relation rows", + "dailyRefreshTitle": "Daily data rebuilt", + "importedAtLabel": "Imported at", + "confirmTitle": "Confirm import", + "confirmDescription": "This will merge the backup into the current database and replace album relations for imported images.", + "confirmCounts": "Configs {configs} · Albums {albums} · Images {images} · Relations {relations}", + "confirmWarningTitle": "Before you continue", + "confirmWarningDescription": "This backup contains sensitive configuration values and does not include object storage files. Make sure the target environment is prepared.", + "confirmImportButton": "Confirm Import" } } diff --git a/messages/ja.json b/messages/ja.json index f48d39be..1dae7a23 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -43,6 +43,7 @@ "authenticator": "二要素認証", "passkey": "Passkey", "daily": "デイリーホームページ", + "backup": "Backup", "tasks": "タスクセンター" }, "Passkey": { @@ -540,6 +541,65 @@ "detailEyebrow": "実行詳細", "unfinished": "継続中", "countsTitle": "結果" + }, + "Backup": { + "pageDescription": "Export and import versioned PicImpact backups. The package includes site configuration and business data, but excludes auth tables and object storage files.", + "sensitiveTitle": "Sensitive data notice", + "sensitiveDescription": "This backup contains full configuration values, including storage credentials and secret keys.", + "excludedDescription": "Auth data, sessions, 2FA, passkeys, task runs, and the Daily materialized view are excluded.", + "exportTitle": "Export backup package", + "exportDescription": "Download a stable logical backup package that can still be imported after future ORM migrations.", + "includedBadge": "Included", + "excludedBadge": "Excluded", + "exportFormatTitle": "Backup contract", + "exportFormatDescription": "The export format is the stable PicImpact JSON envelope instead of a database-specific SQL dump.", + "downloadButton": "Download Backup", + "exportSuccess": "Backup download started.", + "exportFailed": "Failed to export backup.", + "importTitle": "Import backup package", + "importDescription": "Choose a PicImpact backup JSON file, validate it first, then confirm the import.", + "fileRejected": "Only one JSON backup file is allowed.", + "dropzoneTitle": "Drop a backup JSON file here", + "dropzoneDescription": "The file is parsed locally first, then validated against the server-side PicImpact backup contract.", + "dropzoneHint": "PicImpact backup only", + "removeFile": "Remove", + "previewButton": "Validate Backup", + "importButton": "Import Backup", + "importHint": "Import stays disabled until the selected file passes preview validation.", + "selectFileFirst": "Select a backup file first.", + "invalidJson": "The selected file is not valid JSON.", + "previewFailed": "Failed to validate backup.", + "previewSuccess": "Backup validation passed.", + "previewBeforeImport": "Validate the backup before importing.", + "importSuccess": "Backup imported successfully.", + "importFailed": "Failed to import backup.", + "previewTitle": "Import preview", + "validBadge": "Valid", + "invalidBadge": "Invalid", + "previewDescription": "Review package metadata, scope, warnings, and validation issues before importing.", + "previewMeta": "Package metadata", + "metaFormat": "Format:", + "metaVersion": "Version:", + "metaExportedAt": "Exported at:", + "metaSource": "Source:", + "previewScope": "Scope", + "warningsTitle": "Warnings", + "issuesTitle": "Validation issues", + "resultTitle": "Import result", + "resultDescription": "The backup has been merged into the current database with the fixed upsert strategy.", + "resultStats": "Result summary", + "createdLabel": "Created records", + "updatedLabel": "Updated records", + "relationReplacedLabel": "Images with replaced relations", + "relationRemovedLabel": "Removed relation rows", + "dailyRefreshTitle": "Daily data rebuilt", + "importedAtLabel": "Imported at", + "confirmTitle": "Confirm import", + "confirmDescription": "This merges the backup into the current database and replaces album relations for imported images.", + "confirmCounts": "Configs {configs} · Albums {albums} · Images {images} · Relations {relations}", + "confirmWarningTitle": "Before you continue", + "confirmWarningDescription": "This backup contains sensitive configuration values and does not include object storage files. Make sure the target environment is prepared.", + "confirmImportButton": "Confirm Import" } } diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 46e6b8df..5c3b8f0f 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -43,6 +43,7 @@ "authenticator": "雙因素驗證", "passkey": "Passkey", "daily": "Daily 首頁", + "backup": "Backup", "tasks": "任務中心" }, "Passkey": { @@ -540,6 +541,65 @@ "detailEyebrow": "任務詳情", "unfinished": "未結束", "countsTitle": "結果統計" + }, + "Backup": { + "pageDescription": "Export and import versioned PicImpact backups. These packages include full site configuration and business data, but exclude auth tables and object storage files.", + "sensitiveTitle": "Sensitive data notice", + "sensitiveDescription": "This backup contains full configuration values, including storage credentials and secret keys.", + "excludedDescription": "Auth data, sessions, 2FA, passkeys, task runs, and the Daily materialized view are excluded.", + "exportTitle": "Export backup package", + "exportDescription": "Download a stable logical backup package that stays usable after future ORM migrations.", + "includedBadge": "Included", + "excludedBadge": "Excluded", + "exportFormatTitle": "Backup contract", + "exportFormatDescription": "The export format is a stable PicImpact JSON envelope, not a database-specific SQL dump.", + "downloadButton": "Download Backup", + "exportSuccess": "Backup download started.", + "exportFailed": "Failed to export backup.", + "importTitle": "Import backup package", + "importDescription": "Choose a PicImpact backup JSON file, validate it first, then confirm the import.", + "fileRejected": "Only one JSON backup file is allowed.", + "dropzoneTitle": "Drop a backup JSON file here", + "dropzoneDescription": "The file is parsed locally first, then validated against the server-side PicImpact backup contract.", + "dropzoneHint": "PicImpact backup only", + "removeFile": "Remove", + "previewButton": "Validate Backup", + "importButton": "Import Backup", + "importHint": "Import stays disabled until the selected file passes preview validation.", + "selectFileFirst": "Select a backup file first.", + "invalidJson": "The selected file is not valid JSON.", + "previewFailed": "Failed to validate backup.", + "previewSuccess": "Backup validation passed.", + "previewBeforeImport": "Validate the backup before importing.", + "importSuccess": "Backup imported successfully.", + "importFailed": "Failed to import backup.", + "previewTitle": "Import preview", + "validBadge": "Valid", + "invalidBadge": "Invalid", + "previewDescription": "Review package metadata, scope, warnings, and validation issues before importing.", + "previewMeta": "Package metadata", + "metaFormat": "Format:", + "metaVersion": "Version:", + "metaExportedAt": "Exported at:", + "metaSource": "Source:", + "previewScope": "Scope", + "warningsTitle": "Warnings", + "issuesTitle": "Validation issues", + "resultTitle": "Import result", + "resultDescription": "The backup has been merged into the current database with the fixed upsert strategy.", + "resultStats": "Result summary", + "createdLabel": "Created records", + "updatedLabel": "Updated records", + "relationReplacedLabel": "Images with replaced relations", + "relationRemovedLabel": "Removed relation rows", + "dailyRefreshTitle": "Daily data rebuilt", + "importedAtLabel": "Imported at", + "confirmTitle": "Confirm import", + "confirmDescription": "This merges the backup into the current database and replaces album relations for imported images.", + "confirmCounts": "Configs {configs} · Albums {albums} · Images {images} · Relations {relations}", + "confirmWarningTitle": "Before you continue", + "confirmWarningDescription": "This backup contains sensitive configuration values and does not include object storage files. Make sure the target environment is prepared.", + "confirmImportButton": "Confirm Import" } } diff --git a/messages/zh.json b/messages/zh.json index 6ee87a4e..5bd62620 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -43,6 +43,7 @@ "authenticator": "双因素验证", "passkey": "Passkey", "daily": "Daily 首页", + "backup": "数据备份", "tasks": "任务中心" }, "Passkey": { @@ -540,6 +541,65 @@ "detailEyebrow": "任务详情", "unfinished": "未结束", "countsTitle": "结果统计" + }, + "Backup": { + "pageDescription": "导出和导入版本化的 PicImpact 业务备份包。备份会包含站点配置和业务数据,但不包含认证表和对象存储文件。", + "sensitiveTitle": "敏感信息提示", + "sensitiveDescription": "当前备份会包含完整的 configs 数据,其中包括存储密钥和 secret_key 等敏感信息。", + "excludedDescription": "用户、会话、2FA、Passkey、任务运行记录以及 Daily 物化视图数据不在备份范围内。", + "exportTitle": "导出备份包", + "exportDescription": "下载稳定的版本化逻辑备份包,后续切换 Prisma / Drizzle 后也可以继续导入。", + "includedBadge": "已包含", + "excludedBadge": "已排除", + "exportFormatTitle": "备份契约", + "exportFormatDescription": "导出的是稳定的 PicImpact JSON 备份包,而不是绑定数据库的 SQL dump。", + "downloadButton": "下载备份", + "exportSuccess": "已开始下载备份。", + "exportFailed": "导出备份失败。", + "importTitle": "导入备份包", + "importDescription": "选择 PicImpact 导出的 JSON 备份文件,先预览校验,然后再确认导入。", + "fileRejected": "只允许上传 1 个 JSON 备份文件。", + "dropzoneTitle": "将 JSON 备份文件拖放到这里", + "dropzoneDescription": "文件会先在本地解析,再按照 PicImpact 的服务端备份契约进行校验。", + "dropzoneHint": "仅支持 PicImpact 备份", + "removeFile": "移除", + "previewButton": "验证备份", + "importButton": "导入备份", + "importHint": "只有在预览验证通过后才可以执行正式导入。", + "selectFileFirst": "请先选择备份文件。", + "invalidJson": "选择的文件不是合法 JSON。", + "previewFailed": "备份预览校验失败。", + "previewSuccess": "备份校验通过。", + "previewBeforeImport": "请先完成备份校验再导入。", + "importSuccess": "备份导入成功。", + "importFailed": "备份导入失败。", + "previewTitle": "导入预览", + "validBadge": "可导入", + "invalidBadge": "不可导入", + "previewDescription": "在执行导入之前,请检查包信息、数据范围、风险提示和校验问题。", + "previewMeta": "包元数据", + "metaFormat": "格式:", + "metaVersion": "版本:", + "metaExportedAt": "导出时间:", + "metaSource": "来源:", + "previewScope": "数据范围", + "warningsTitle": "风险提示", + "issuesTitle": "校验问题", + "resultTitle": "导入结果", + "resultDescription": "备份已按固定的 Upsert 策略合并到当前数据库。", + "resultStats": "结果概览", + "createdLabel": "新增记录", + "updatedLabel": "更新记录", + "relationReplacedLabel": "被替换关系的图片数", + "relationRemovedLabel": "被移除的关联条数", + "dailyRefreshTitle": "Daily 数据已重建", + "importedAtLabel": "导入时间", + "confirmTitle": "确认导入", + "confirmDescription": "这会将备份包合并到当前数据库,并重建导入图片的相册关系。", + "confirmCounts": "configs {configs} · albums {albums} · images {images} · relations {relations}", + "confirmWarningTitle": "操作前说明", + "confirmWarningDescription": "该备份包含敏感配置值,同时不包含对象存储内的实际文件。请确认目标环境已准备好。", + "confirmImportButton": "确认导入" } } diff --git a/server/backup/format-adapter.ts b/server/backup/format-adapter.ts new file mode 100644 index 00000000..9ad522d0 --- /dev/null +++ b/server/backup/format-adapter.ts @@ -0,0 +1,356 @@ +import 'server-only' + +import { z } from 'zod' + +import { + BACKUP_FORMAT, + BACKUP_VERSION_V1, + type BackupEnvelopeV1, + type BackupJsonValue, + type BackupPreviewData, + type BackupPreviewCounts, + type BackupSource, + type BackupValidationIssue, +} from '~/types/backup' + +const INCLUDED_SCOPE = ['configs', 'albums', 'images', 'imageAlbumRelations'] as const +const EXCLUDED_SCOPE = [ + 'user', + 'session', + 'account', + 'two_factor', + 'passkey', + 'verification', + 'admin_task_runs', + 'daily_images', +] as const + +const emptyCounts: BackupPreviewCounts = { + configs: 0, + albums: 0, + images: 0, + imageAlbumRelations: 0, +} + +const isoDateStringSchema = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { + message: 'Expected a valid ISO datetime string', +}) + +const backupJsonSchema: z.ZodType = z.lazy(() => z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(backupJsonSchema), + z.record(z.string(), backupJsonSchema), +])) + +const backupSourceSchema: z.ZodType = z.object({ + orm: z.enum(['prisma', 'drizzle', 'unknown']), + database: z.literal('postgresql'), +}).strict() + +const backupConfigRecordSchema = z.object({ + config_key: z.string().min(1), + config_value: z.string().nullable(), + detail: z.string().nullable(), + createdAt: isoDateStringSchema, + updatedAt: isoDateStringSchema.nullable(), +}).strict() + +const backupAlbumRecordSchema = z.object({ + album_value: z.string().min(1), + name: z.string().min(1), + detail: z.string().nullable(), + theme: z.string(), + show: z.number().int(), + sort: z.number().int(), + random_show: z.number().int(), + license: z.string().nullable(), + image_sorting: z.number().int(), + daily_weight: z.number().int(), + del: z.number().int(), + createdAt: isoDateStringSchema, + updatedAt: isoDateStringSchema.nullable(), +}).strict() + +const backupImageRecordSchema = z.object({ + id: z.string().min(1), + image_name: z.string().nullable(), + url: z.string().nullable(), + preview_url: z.string().nullable(), + video_url: z.string().nullable(), + blurhash: z.string().nullable(), + exif: backupJsonSchema.nullable(), + labels: backupJsonSchema.nullable(), + width: z.number().int().nonnegative(), + height: z.number().int().nonnegative(), + lon: z.string().nullable(), + lat: z.string().nullable(), + title: z.string().nullable(), + detail: z.string().nullable(), + type: z.number().int(), + show: z.number().int(), + show_on_mainpage: z.number().int(), + sort: z.number().int(), + del: z.number().int(), + createdAt: isoDateStringSchema, + updatedAt: isoDateStringSchema.nullable(), +}).strict() + +const backupImageAlbumRelationRecordSchema = z.object({ + imageId: z.string().min(1), + album_value: z.string().min(1), +}).strict() + +const backupEnvelopeV1Schema: z.ZodType = z.object({ + format: z.literal(BACKUP_FORMAT), + version: z.literal(BACKUP_VERSION_V1), + exportedAt: isoDateStringSchema, + source: backupSourceSchema, + payload: z.object({ + configs: z.array(backupConfigRecordSchema), + albums: z.array(backupAlbumRecordSchema), + images: z.array(backupImageRecordSchema), + imageAlbumRelations: z.array(backupImageAlbumRelationRecordSchema), + }).strict(), +}).strict() + +function createPreviewData(partial?: Partial): BackupPreviewData { + return { + valid: false, + format: null, + version: null, + exportedAt: null, + source: null, + scope: { + included: [...INCLUDED_SCOPE], + excluded: [...EXCLUDED_SCOPE], + }, + counts: emptyCounts, + warnings: [], + issues: [], + ...partial, + } +} + +function zodPathToString(path: readonly PropertyKey[]): string { + if (path.length === 0) { + return '$' + } + + return path.reduce((result, segment) => { + if (typeof segment === 'number') { + return `${result}[${segment}]` + } + + const printableSegment = typeof segment === 'string' ? segment : String(segment) + + return `${result}.${printableSegment}` + }, '$') +} + +function zodIssuesToValidationIssues(error: z.ZodError): BackupValidationIssue[] { + return error.issues.map((issue) => ({ + path: zodPathToString(issue.path), + message: issue.message, + })) +} + +// eslint-disable-next-line no-unused-vars +type DuplicateKeySelector = (value: T) => string + +// eslint-disable-next-line no-unused-vars +type DuplicatePathSelector = (position: number) => string + +function appendDuplicateIssues( + issues: BackupValidationIssue[], + items: T[], + getKey: DuplicateKeySelector, + getPath: DuplicatePathSelector, + label: string, +) { + const seen = new Map() + + for (let index = 0; index < items.length; index += 1) { + const key = getKey(items[index]) + const existingIndex = seen.get(key) + + if (existingIndex !== undefined) { + issues.push({ + path: getPath(index), + message: `Duplicate ${label}: "${key}" also appears at index ${existingIndex}`, + }) + continue + } + + seen.set(key, index) + } +} + +function getPreviewCounts(envelope: BackupEnvelopeV1): BackupPreviewCounts { + return { + configs: envelope.payload.configs.length, + albums: envelope.payload.albums.length, + images: envelope.payload.images.length, + imageAlbumRelations: envelope.payload.imageAlbumRelations.length, + } +} + +function buildWarnings(envelope: BackupEnvelopeV1) { + const warnings = [ + 'This backup includes sensitive configuration values such as storage credentials and secret keys.', + 'Authentication data, sessions, passkeys, and other Better Auth tables are excluded.', + 'Object storage files are not included; only database records and URLs are backed up.', + ] + + if (envelope.source.orm === 'unknown') { + warnings.push('The backup source ORM is marked as unknown; import still uses the versioned PicImpact contract.') + } + + return warnings +} + +function appendRelationReferenceIssues(envelope: BackupEnvelopeV1, issues: BackupValidationIssue[]) { + const imageIds = new Set(envelope.payload.images.map((item) => item.id)) + const albumValues = new Set(envelope.payload.albums.map((item) => item.album_value)) + + envelope.payload.imageAlbumRelations.forEach((relation, index) => { + if (!imageIds.has(relation.imageId)) { + issues.push({ + path: `$.payload.imageAlbumRelations[${index}].imageId`, + message: `Referenced image "${relation.imageId}" does not exist in payload.images`, + }) + } + + if (!albumValues.has(relation.album_value)) { + issues.push({ + path: `$.payload.imageAlbumRelations[${index}].album_value`, + message: `Referenced album "${relation.album_value}" does not exist in payload.albums`, + }) + } + }) +} + +function createValidationError(message: string, preview: BackupPreviewData) { + return new BackupValidationError(message, preview) +} + +export class BackupValidationError extends Error { + preview: BackupPreviewData + + constructor(message: string, preview: BackupPreviewData) { + super(message) + this.name = 'BackupValidationError' + this.preview = preview + } +} + +export function parseBackupEnvelope(input: unknown) { + const baseResult = z.object({ + format: z.string().nullable().optional(), + version: z.number().int().nullable().optional(), + exportedAt: z.string().nullable().optional(), + source: backupSourceSchema.nullable().optional(), + }).safeParse(input) + + if (!baseResult.success) { + throw createValidationError('Invalid backup package', createPreviewData({ + issues: zodIssuesToValidationIssues(baseResult.error), + })) + } + + const base = baseResult.data + + if (base.format !== BACKUP_FORMAT) { + throw createValidationError('Unsupported backup format', createPreviewData({ + format: base.format ?? null, + version: base.version ?? null, + exportedAt: base.exportedAt ?? null, + source: base.source ?? null, + issues: [{ + path: '$.format', + message: `Expected "${BACKUP_FORMAT}"`, + }], + })) + } + + if (base.version !== BACKUP_VERSION_V1) { + throw createValidationError('Unsupported backup version', createPreviewData({ + format: base.format, + version: base.version ?? null, + exportedAt: base.exportedAt ?? null, + source: base.source ?? null, + issues: [{ + path: '$.version', + message: `Only backup version ${BACKUP_VERSION_V1} is supported`, + }], + })) + } + + const envelopeResult = backupEnvelopeV1Schema.safeParse(input) + + if (!envelopeResult.success) { + throw createValidationError('Invalid backup package', createPreviewData({ + format: base.format, + version: base.version, + exportedAt: base.exportedAt ?? null, + source: base.source ?? null, + issues: zodIssuesToValidationIssues(envelopeResult.error), + })) + } + + const envelope = envelopeResult.data + const semanticIssues: BackupValidationIssue[] = [] + + appendDuplicateIssues( + semanticIssues, + envelope.payload.configs, + (item) => item.config_key, + (index) => `$.payload.configs[${index}].config_key`, + 'config_key', + ) + appendDuplicateIssues( + semanticIssues, + envelope.payload.albums, + (item) => item.album_value, + (index) => `$.payload.albums[${index}].album_value`, + 'album_value', + ) + appendDuplicateIssues( + semanticIssues, + envelope.payload.images, + (item) => item.id, + (index) => `$.payload.images[${index}].id`, + 'image.id', + ) + appendDuplicateIssues( + semanticIssues, + envelope.payload.imageAlbumRelations, + (item) => `${item.imageId}::${item.album_value}`, + (index) => `$.payload.imageAlbumRelations[${index}]`, + 'image-album relation', + ) + appendRelationReferenceIssues(envelope, semanticIssues) + + const preview = createPreviewData({ + valid: semanticIssues.length === 0, + format: envelope.format, + version: envelope.version, + exportedAt: envelope.exportedAt, + source: envelope.source, + counts: getPreviewCounts(envelope), + warnings: buildWarnings(envelope), + issues: semanticIssues, + }) + + if (semanticIssues.length > 0) { + throw createValidationError('Invalid backup package', preview) + } + + return { + envelope, + preview, + } +} + diff --git a/server/backup/prisma-repository.ts b/server/backup/prisma-repository.ts new file mode 100644 index 00000000..cb91416e --- /dev/null +++ b/server/backup/prisma-repository.ts @@ -0,0 +1,505 @@ +import 'server-only' + +import { Prisma } from '@prisma/client' + +import { db } from '~/server/lib/db' +import type { + BackupAlbumRecord, + BackupConfigRecord, + BackupImageAlbumRelationRecord, + BackupImageRecord, + BackupJsonValue, + BackupPayloadV1, + BackupSource, +} from '~/types/backup' +import type { BackupRepository, BackupRepositoryImportResult } from '~/server/backup/repository' + +const IMPORT_TRANSACTION_TIMEOUT_MS = 120_000 + +function toIsoString(value: Date | null | undefined) { + return value ? value.toISOString() : null +} + +function toBackupJson(value: Prisma.JsonValue | null): BackupJsonValue | null { + return value as BackupJsonValue | null +} + +function toPrismaJson(value: BackupJsonValue | null): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput { + if (value === null) { + return Prisma.DbNull + } + + return value as Prisma.InputJsonValue +} + +function countCreatedAndUpdated(existingKeys: Set, incomingKeys: string[]) { + let createdCount = 0 + let updatedCount = 0 + + for (const key of incomingKeys) { + if (existingKeys.has(key)) { + updatedCount += 1 + } else { + createdCount += 1 + } + } + + return { + totalCount: incomingKeys.length, + createdCount, + updatedCount, + } +} + +function createRelationSet(relations: BackupImageAlbumRelationRecord[]) { + return new Set(relations.map((item) => `${item.imageId}::${item.album_value}`)) +} + +function groupRelationAlbumValues(relations: Array<{ imageId: string; album_value: string }>) { + const map = new Map>() + + for (const relation of relations) { + const relationSet = map.get(relation.imageId) ?? new Set() + relationSet.add(relation.album_value) + map.set(relation.imageId, relationSet) + } + + return map +} + +function areRelationSetsEqual(left: Set, right: Set) { + if (left.size !== right.size) { + return false + } + + for (const value of left) { + if (!right.has(value)) { + return false + } + } + + return true +} + +function mapConfigRecord(config: { + config_key: string; + config_value: string | null; + detail: string | null; + createdAt: Date; + updatedAt: Date | null; +}): BackupConfigRecord { + return { + config_key: config.config_key, + config_value: config.config_value, + detail: config.detail, + createdAt: config.createdAt.toISOString(), + updatedAt: toIsoString(config.updatedAt), + } +} + +function mapAlbumRecord(album: { + album_value: string; + name: string; + detail: string | null; + theme: string; + show: number; + sort: number; + random_show: number; + license: string | null; + image_sorting: number; + daily_weight: number; + del: number; + createdAt: Date; + updatedAt: Date | null; +}): BackupAlbumRecord { + return { + album_value: album.album_value, + name: album.name, + detail: album.detail, + theme: album.theme, + show: album.show, + sort: album.sort, + random_show: album.random_show, + license: album.license, + image_sorting: album.image_sorting, + daily_weight: album.daily_weight, + del: album.del, + createdAt: album.createdAt.toISOString(), + updatedAt: toIsoString(album.updatedAt), + } +} + +function mapImageRecord(image: { + id: string; + image_name: string | null; + url: string | null; + preview_url: string | null; + video_url: string | null; + blurhash: string | null; + exif: Prisma.JsonValue | null; + labels: Prisma.JsonValue | null; + width: number; + height: number; + lon: string | null; + lat: string | null; + title: string | null; + detail: string | null; + type: number; + show: number; + show_on_mainpage: number; + sort: number; + del: number; + createdAt: Date; + updatedAt: Date | null; +}): BackupImageRecord { + return { + id: image.id, + image_name: image.image_name, + url: image.url, + preview_url: image.preview_url, + video_url: image.video_url, + blurhash: image.blurhash, + exif: toBackupJson(image.exif), + labels: toBackupJson(image.labels), + width: image.width, + height: image.height, + lon: image.lon, + lat: image.lat, + title: image.title, + detail: image.detail, + type: image.type, + show: image.show, + show_on_mainpage: image.show_on_mainpage, + sort: image.sort, + del: image.del, + createdAt: image.createdAt.toISOString(), + updatedAt: toIsoString(image.updatedAt), + } +} + +export class PrismaBackupRepository implements BackupRepository { + getSource(): BackupSource { + return { + orm: 'prisma', + database: 'postgresql', + } + } + + async exportSnapshot(): Promise { + const [configs, albums, images, imageAlbumRelations] = await Promise.all([ + db.configs.findMany({ + orderBy: [{ config_key: 'asc' }], + select: { + config_key: true, + config_value: true, + detail: true, + createdAt: true, + updatedAt: true, + }, + }), + db.albums.findMany({ + orderBy: [{ sort: 'desc' }, { createdAt: 'asc' }, { album_value: 'asc' }], + select: { + album_value: true, + name: true, + detail: true, + theme: true, + show: true, + sort: true, + random_show: true, + license: true, + image_sorting: true, + daily_weight: true, + del: true, + createdAt: true, + updatedAt: true, + }, + }), + db.images.findMany({ + orderBy: [{ sort: 'desc' }, { createdAt: 'asc' }, { id: 'asc' }], + select: { + id: true, + image_name: true, + url: true, + preview_url: true, + video_url: true, + blurhash: true, + exif: true, + labels: true, + width: true, + height: true, + lon: true, + lat: true, + title: true, + detail: true, + type: true, + show: true, + show_on_mainpage: true, + sort: true, + del: true, + createdAt: true, + updatedAt: true, + }, + }), + db.imagesAlbumsRelation.findMany({ + orderBy: [{ imageId: 'asc' }, { album_value: 'asc' }], + select: { + imageId: true, + album_value: true, + }, + }), + ]) + + return { + configs: configs.map(mapConfigRecord), + albums: albums.map(mapAlbumRecord), + images: images.map(mapImageRecord), + imageAlbumRelations, + } + } + + async importSnapshot(snapshot: BackupPayloadV1): Promise { + const configKeys = snapshot.configs.map((item) => item.config_key) + const albumValues = snapshot.albums.map((item) => item.album_value) + const imageIds = snapshot.images.map((item) => item.id) + const importedRelationSet = createRelationSet(snapshot.imageAlbumRelations) + + const [existingConfigs, existingAlbums, existingImages, existingRelations] = await Promise.all([ + configKeys.length > 0 + ? db.configs.findMany({ + where: { config_key: { in: configKeys } }, + select: { config_key: true }, + }) + : Promise.resolve([]), + albumValues.length > 0 + ? db.albums.findMany({ + where: { album_value: { in: albumValues } }, + select: { album_value: true }, + }) + : Promise.resolve([]), + imageIds.length > 0 + ? db.images.findMany({ + where: { id: { in: imageIds } }, + select: { id: true }, + }) + : Promise.resolve([]), + imageIds.length > 0 + ? db.imagesAlbumsRelation.findMany({ + where: { imageId: { in: imageIds } }, + select: { imageId: true, album_value: true }, + }) + : Promise.resolve([]), + ]) + + const configStats = countCreatedAndUpdated( + new Set(existingConfigs.map((item) => item.config_key)), + configKeys, + ) + const albumStats = countCreatedAndUpdated( + new Set(existingAlbums.map((item) => item.album_value)), + albumValues, + ) + const imageStats = countCreatedAndUpdated( + new Set(existingImages.map((item) => item.id)), + imageIds, + ) + + const existingRelationSet = new Set(existingRelations.map((item) => `${item.imageId}::${item.album_value}`)) + const existingRelationsByImage = groupRelationAlbumValues(existingRelations) + const importedRelationsByImage = groupRelationAlbumValues(snapshot.imageAlbumRelations) + + let addedCount = 0 + let unchangedCount = 0 + let removedCount = 0 + let replacedImageCount = 0 + + for (const relationKey of importedRelationSet) { + if (existingRelationSet.has(relationKey)) { + unchangedCount += 1 + } else { + addedCount += 1 + } + } + + for (const relationKey of existingRelationSet) { + if (!importedRelationSet.has(relationKey)) { + removedCount += 1 + } + } + + for (const [imageId, importedAlbumValues] of importedRelationsByImage) { + const existingAlbumValues = existingRelationsByImage.get(imageId) + if (existingAlbumValues && !areRelationSetsEqual(existingAlbumValues, importedAlbumValues)) { + replacedImageCount += 1 + } + } + + const importedAt = new Date() + + await db.$transaction(async (tx) => { + for (const config of snapshot.configs) { + await tx.configs.upsert({ + where: { + config_key: config.config_key, + }, + update: { + config_value: config.config_value, + detail: config.detail, + createdAt: new Date(config.createdAt), + }, + create: { + config_key: config.config_key, + config_value: config.config_value, + detail: config.detail, + createdAt: new Date(config.createdAt), + updatedAt: config.updatedAt ? new Date(config.updatedAt) : undefined, + }, + }) + } + + for (const album of snapshot.albums) { + await tx.albums.upsert({ + where: { + album_value: album.album_value, + }, + update: { + name: album.name, + detail: album.detail, + theme: album.theme, + show: album.show, + sort: album.sort, + random_show: album.random_show, + license: album.license, + image_sorting: album.image_sorting, + daily_weight: album.daily_weight, + del: album.del, + createdAt: new Date(album.createdAt), + }, + create: { + album_value: album.album_value, + name: album.name, + detail: album.detail, + theme: album.theme, + show: album.show, + sort: album.sort, + random_show: album.random_show, + license: album.license, + image_sorting: album.image_sorting, + daily_weight: album.daily_weight, + del: album.del, + createdAt: new Date(album.createdAt), + updatedAt: album.updatedAt ? new Date(album.updatedAt) : undefined, + }, + }) + } + + for (const image of snapshot.images) { + await tx.images.upsert({ + where: { + id: image.id, + }, + update: { + image_name: image.image_name, + url: image.url, + preview_url: image.preview_url, + video_url: image.video_url, + blurhash: image.blurhash, + exif: toPrismaJson(image.exif), + labels: toPrismaJson(image.labels), + width: image.width, + height: image.height, + lon: image.lon, + lat: image.lat, + title: image.title, + detail: image.detail, + type: image.type, + show: image.show, + show_on_mainpage: image.show_on_mainpage, + sort: image.sort, + del: image.del, + createdAt: new Date(image.createdAt), + }, + create: { + id: image.id, + image_name: image.image_name, + url: image.url, + preview_url: image.preview_url, + video_url: image.video_url, + blurhash: image.blurhash, + exif: toPrismaJson(image.exif), + labels: toPrismaJson(image.labels), + width: image.width, + height: image.height, + lon: image.lon, + lat: image.lat, + title: image.title, + detail: image.detail, + type: image.type, + show: image.show, + show_on_mainpage: image.show_on_mainpage, + sort: image.sort, + del: image.del, + createdAt: new Date(image.createdAt), + updatedAt: image.updatedAt ? new Date(image.updatedAt) : undefined, + }, + }) + } + + if (imageIds.length > 0) { + await tx.imagesAlbumsRelation.deleteMany({ + where: { + imageId: { + in: imageIds, + }, + }, + }) + } + + if (snapshot.imageAlbumRelations.length > 0) { + await tx.imagesAlbumsRelation.createMany({ + data: snapshot.imageAlbumRelations, + skipDuplicates: true, + }) + } + + await tx.$executeRaw`REFRESH MATERIALIZED VIEW "daily_images"` + + await tx.configs.upsert({ + where: { + config_key: 'daily_last_refresh', + }, + update: { + config_value: importedAt.toISOString(), + detail: 'Daily homepage last refresh time', + createdAt: importedAt, + }, + create: { + config_key: 'daily_last_refresh', + config_value: importedAt.toISOString(), + detail: 'Daily homepage last refresh time', + createdAt: importedAt, + updatedAt: importedAt, + }, + }) + }, { + maxWait: 10_000, + timeout: IMPORT_TRANSACTION_TIMEOUT_MS, + }) + + return { + entities: { + configs: configStats, + albums: albumStats, + images: imageStats, + imageAlbumRelations: { + totalCount: snapshot.imageAlbumRelations.length, + addedCount, + unchangedCount, + replacedImageCount, + removedCount, + }, + }, + dailyRefreshAt: importedAt.toISOString(), + } + } +} diff --git a/server/backup/repository.ts b/server/backup/repository.ts new file mode 100644 index 00000000..7246e098 --- /dev/null +++ b/server/backup/repository.ts @@ -0,0 +1,33 @@ +import 'server-only' + +import type { + BackupEntityImportStats, + BackupPayloadV1, + BackupRelationImportStats, + BackupSource, +} from '~/types/backup' +import { PrismaBackupRepository } from '~/server/backup/prisma-repository' + +export type BackupRepositoryImportResult = { + entities: { + configs: BackupEntityImportStats; + albums: BackupEntityImportStats; + images: BackupEntityImportStats; + imageAlbumRelations: BackupRelationImportStats; + }; + dailyRefreshAt: string; +} + +// eslint-disable-next-line no-unused-vars +type BackupImportSnapshot = (value: BackupPayloadV1) => Promise + +export interface BackupRepository { + getSource(): BackupSource + exportSnapshot(): Promise + importSnapshot: BackupImportSnapshot +} + +export function getBackupRepository(): BackupRepository { + return new PrismaBackupRepository() +} + diff --git a/server/backup/service.ts b/server/backup/service.ts new file mode 100644 index 00000000..b5b645f1 --- /dev/null +++ b/server/backup/service.ts @@ -0,0 +1,47 @@ +import 'server-only' + +import { + BACKUP_FORMAT, + BACKUP_VERSION_V1, + type BackupEnvelopeV1, + type BackupImportResult, + type BackupPreviewData, +} from '~/types/backup' +import { parseBackupEnvelope } from '~/server/backup/format-adapter' +import { getBackupRepository } from '~/server/backup/repository' + +const repository = getBackupRepository() + +export async function exportBackupEnvelope(): Promise { + return { + format: BACKUP_FORMAT, + version: BACKUP_VERSION_V1, + exportedAt: new Date().toISOString(), + source: repository.getSource(), + payload: await repository.exportSnapshot(), + } +} + +export async function previewBackupImport(input: unknown): Promise { + return parseBackupEnvelope(input).preview +} + +export async function importBackupEnvelope(input: unknown): Promise { + const { envelope, preview } = parseBackupEnvelope(input) + const result = await repository.importSnapshot(envelope.payload) + + return { + format: BACKUP_FORMAT, + version: BACKUP_VERSION_V1, + importedAt: new Date().toISOString(), + source: envelope.source, + counts: preview.counts, + warnings: preview.warnings, + entities: result.entities, + dailyRefresh: { + refreshed: true, + refreshedAt: result.dailyRefreshAt, + message: `Daily materialized data was rebuilt at ${result.dailyRefreshAt}.`, + }, + } +} diff --git a/types/backup.ts b/types/backup.ts new file mode 100644 index 00000000..72e80378 --- /dev/null +++ b/types/backup.ts @@ -0,0 +1,149 @@ +export const BACKUP_FORMAT = 'picimpact-backup' as const +export const BACKUP_VERSION_V1 = 1 as const + +export type BackupOrm = 'prisma' | 'drizzle' | 'unknown' +export type BackupDatabase = 'postgresql' + +export type BackupJsonValue = + | string + | number + | boolean + | null + | { [key: string]: BackupJsonValue } + | BackupJsonValue[] + +export type BackupSource = { + orm: BackupOrm; + database: BackupDatabase; +} + +export type BackupConfigRecord = { + config_key: string; + config_value: string | null; + detail: string | null; + createdAt: string; + updatedAt: string | null; +} + +export type BackupAlbumRecord = { + album_value: string; + name: string; + detail: string | null; + theme: string; + show: number; + sort: number; + random_show: number; + license: string | null; + image_sorting: number; + daily_weight: number; + del: number; + createdAt: string; + updatedAt: string | null; +} + +export type BackupImageRecord = { + id: string; + image_name: string | null; + url: string | null; + preview_url: string | null; + video_url: string | null; + blurhash: string | null; + exif: BackupJsonValue | null; + labels: BackupJsonValue | null; + width: number; + height: number; + lon: string | null; + lat: string | null; + title: string | null; + detail: string | null; + type: number; + show: number; + show_on_mainpage: number; + sort: number; + del: number; + createdAt: string; + updatedAt: string | null; +} + +export type BackupImageAlbumRelationRecord = { + imageId: string; + album_value: string; +} + +export type BackupPayloadV1 = { + configs: BackupConfigRecord[]; + albums: BackupAlbumRecord[]; + images: BackupImageRecord[]; + imageAlbumRelations: BackupImageAlbumRelationRecord[]; +} + +export type BackupEnvelopeV1 = { + format: typeof BACKUP_FORMAT; + version: typeof BACKUP_VERSION_V1; + exportedAt: string; + source: BackupSource; + payload: BackupPayloadV1; +} + +export type BackupValidationIssue = { + path: string; + message: string; +} + +export type BackupPreviewCounts = { + configs: number; + albums: number; + images: number; + imageAlbumRelations: number; +} + +export type BackupPreviewScope = { + included: string[]; + excluded: string[]; +} + +export type BackupPreviewData = { + valid: boolean; + format: string | null; + version: number | null; + exportedAt: string | null; + source: BackupSource | null; + scope: BackupPreviewScope; + counts: BackupPreviewCounts; + warnings: string[]; + issues: BackupValidationIssue[]; +} + +export type BackupEntityImportStats = { + totalCount: number; + createdCount: number; + updatedCount: number; +} + +export type BackupRelationImportStats = { + totalCount: number; + addedCount: number; + unchangedCount: number; + replacedImageCount: number; + removedCount: number; +} + +export type BackupImportResult = { + format: typeof BACKUP_FORMAT; + version: typeof BACKUP_VERSION_V1; + importedAt: string; + source: BackupSource; + counts: BackupPreviewCounts; + warnings: string[]; + entities: { + configs: BackupEntityImportStats; + albums: BackupEntityImportStats; + images: BackupEntityImportStats; + imageAlbumRelations: BackupRelationImportStats; + }; + dailyRefresh: { + refreshed: boolean; + refreshedAt: string | null; + message: string; + }; +} From ab1e05869796ff84399b53c167cf3ac3716e557f Mon Sep 17 00:00:00 2001 From: besscroft Date: Thu, 26 Mar 2026 22:16:46 +0800 Subject: [PATCH 2/2] fix(i18n): update localized translations for "backup" Update the English translation of "Backup" to the clearer "Data Backup", and sync the corresponding Japanese and Traditional Chinese translations to improve UI wording clarity. --- messages/en.json | 2 +- messages/ja.json | 2 +- messages/zh-TW.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/messages/en.json b/messages/en.json index 970ec239..ece9e1ca 100644 --- a/messages/en.json +++ b/messages/en.json @@ -43,7 +43,7 @@ "authenticator": "Two-Factor Authentication", "passkey": "Passkey", "daily": "Daily Homepage", - "backup": "Backup", + "backup": "Data Backup", "tasks": "Tasks" }, "Passkey": { diff --git a/messages/ja.json b/messages/ja.json index 1dae7a23..c680c4ae 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -43,7 +43,7 @@ "authenticator": "二要素認証", "passkey": "Passkey", "daily": "デイリーホームページ", - "backup": "Backup", + "backup": "データバックアップ", "tasks": "タスクセンター" }, "Passkey": { diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 5c3b8f0f..69be0803 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -43,7 +43,7 @@ "authenticator": "雙因素驗證", "passkey": "Passkey", "daily": "Daily 首頁", - "backup": "Backup", + "backup": "資料備份", "tasks": "任務中心" }, "Passkey": {