Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
511 changes: 511 additions & 0 deletions app/admin/settings/backup/page.tsx

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions components/layout/admin/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Sidebar>) {
Expand Down Expand Up @@ -103,6 +104,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
url: '/admin/settings/daily',
icon: CalendarDaysIcon,
},
{
name: t('Link.backup'),
url: '/admin/settings/backup',
icon: DownloadIcon,
},
],
},
}
Expand Down
126 changes: 126 additions & 0 deletions hono/backup.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>().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<unknown>().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
2 changes: 2 additions & 0 deletions hono/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
60 changes: 60 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"authenticator": "Two-Factor Authentication",
"passkey": "Passkey",
"daily": "Daily Homepage",
"backup": "Data Backup",
"tasks": "Tasks"
},
"Passkey": {
Expand Down Expand Up @@ -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"
}
}

Expand Down
60 changes: 60 additions & 0 deletions messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"authenticator": "二要素認証",
"passkey": "Passkey",
"daily": "デイリーホームページ",
"backup": "データバックアップ",
"tasks": "タスクセンター"
},
"Passkey": {
Expand Down Expand Up @@ -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"
}
}

60 changes: 60 additions & 0 deletions messages/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"authenticator": "雙因素驗證",
"passkey": "Passkey",
"daily": "Daily 首頁",
"backup": "資料備份",
"tasks": "任務中心"
},
"Passkey": {
Expand Down Expand Up @@ -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"
}
}

Loading
Loading