@meith/web 0.33.4 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ 'use client'
2
+
3
+ import { useActionState } from 'react'
4
+
5
+ import {
6
+ Alert,
7
+ AlertDescription,
8
+ AlertTitle,
9
+ Card,
10
+ CardContent,
11
+ CardDescription,
12
+ CardHeader,
13
+ CardTitle,
14
+ } from '@meith/ui'
15
+ import { Button } from '@meith/ui/button'
16
+
17
+ import { useFocusOnFail } from '@/components/auth/form-controls'
18
+ import { type Copy, formatFromCopy, fromCopy } from '@/components/shell/copy'
19
+ import { type InstallRestoreState, installRestoreAction } from '@/server/install-restore-actions'
20
+
21
+ const EMPTY: InstallRestoreState = {}
22
+
23
+ export interface RestoreCandidateView {
24
+ readonly name: string
25
+ readonly label: string
26
+ readonly size: string
27
+ readonly location: string
28
+ }
29
+
30
+ export function InstallRestoreForm({
31
+ candidates,
32
+ destination,
33
+ problem,
34
+ copy,
35
+ }: {
36
+ candidates: readonly RestoreCandidateView[]
37
+ destination: string | null
38
+ problem: string | null
39
+ copy: Copy
40
+ }) {
41
+ const [state, submit, pending] = useActionState(installRestoreAction, EMPTY)
42
+ const errorRef = useFocusOnFail<HTMLDivElement>(state.error !== undefined)
43
+
44
+ if (state.outcome !== undefined) {
45
+ const { outcome } = state
46
+ return (
47
+ <Card>
48
+ <CardHeader>
49
+ <CardTitle>{fromCopy(copy, 'install.restore.doneTitle')}</CardTitle>
50
+ <CardDescription>
51
+ {formatFromCopy(copy, 'install.restore.doneDetail', {
52
+ bundle: outcome.bundle,
53
+ version: outcome.version,
54
+ posts: outcome.posts,
55
+ })}
56
+ </CardDescription>
57
+ </CardHeader>
58
+ <CardContent className="flex flex-col gap-3 text-sm">
59
+ {outcome.migrationsApplied > 0 && (
60
+ <p>
61
+ {formatFromCopy(copy, 'install.restore.migrated', {
62
+ count: outcome.migrationsApplied,
63
+ })}
64
+ </p>
65
+ )}
66
+ {outcome.uploads === 'none' && <p>{fromCopy(copy, 'install.restore.noUploads')}</p>}
67
+ {outcome.skippedKeys > 0 && (
68
+ <p className="text-destructive">
69
+ {formatFromCopy(copy, 'install.restore.skipped', { count: outcome.skippedKeys })}
70
+ </p>
71
+ )}
72
+ <p>{fromCopy(copy, 'install.restore.next')}</p>
73
+ <p>
74
+ <a href="/login" className="underline">
75
+ {fromCopy(copy, 'install.restore.signIn')}
76
+ </a>
77
+ </p>
78
+ </CardContent>
79
+ </Card>
80
+ )
81
+ }
82
+
83
+ return (
84
+ <Card aria-labelledby="install-restore">
85
+ <CardHeader>
86
+ <CardTitle id="install-restore">{fromCopy(copy, 'install.restore.title')}</CardTitle>
87
+ <CardDescription>{fromCopy(copy, 'install.restore.hint')}</CardDescription>
88
+ </CardHeader>
89
+ <CardContent className="flex flex-col gap-4">
90
+ {problem !== null && (
91
+ <Alert tone="warning">
92
+ <AlertDescription>{problem}</AlertDescription>
93
+ </Alert>
94
+ )}
95
+ {candidates.length === 0 ? (
96
+ <p className="text-sm text-muted-foreground">
97
+ {destination === null
98
+ ? fromCopy(copy, 'install.restore.noneLocal')
99
+ : formatFromCopy(copy, 'install.restore.noneAnywhere', { destination })}
100
+ </p>
101
+ ) : (
102
+ <form action={submit} className="flex flex-col gap-4">
103
+ {state.error !== undefined && (
104
+ <Alert tone="error" ref={errorRef} tabIndex={-1}>
105
+ <AlertDescription>
106
+ <AlertTitle>{fromCopy(copy, 'install.restore.notRestored')}</AlertTitle>{' '}
107
+ {state.error}
108
+ </AlertDescription>
109
+ </Alert>
110
+ )}
111
+ <fieldset className="flex flex-col gap-2">
112
+ <legend className="text-sm font-medium">
113
+ {fromCopy(copy, 'install.restore.pick')}
114
+ </legend>
115
+ {candidates.map((candidate, index) => (
116
+ <label key={candidate.name} className="flex items-start gap-2 text-sm">
117
+ <input
118
+ type="radio"
119
+ name="bundle"
120
+ value={candidate.name}
121
+ defaultChecked={index === 0}
122
+ className="mt-1 size-4"
123
+ />
124
+ <span className="flex min-w-0 flex-col">
125
+ <span className="font-medium">{candidate.label}</span>
126
+ <span className="truncate text-xs text-muted-foreground">
127
+ <code>{candidate.name}</code> · {candidate.size} · {candidate.location}
128
+ </span>
129
+ </span>
130
+ </label>
131
+ ))}
132
+ </fieldset>
133
+ <label className="flex items-start gap-2 text-sm">
134
+ <input type="checkbox" name="confirm" value="1" className="mt-1 size-4" />
135
+ <span>{fromCopy(copy, 'install.restore.confirm')}</span>
136
+ </label>
137
+ <div className="flex flex-wrap items-center gap-3">
138
+ <Button type="submit" variant="secondary" size="lg" disabled={pending}>
139
+ {pending
140
+ ? fromCopy(copy, 'install.restore.restoring')
141
+ : fromCopy(copy, 'install.restore.submit')}
142
+ </Button>
143
+ <p className="text-xs text-muted-foreground">
144
+ {pending
145
+ ? fromCopy(copy, 'install.restore.pendingNote')
146
+ : fromCopy(copy, 'install.restore.idleNote')}
147
+ </p>
148
+ </div>
149
+ </form>
150
+ )}
151
+ </CardContent>
152
+ </Card>
153
+ )
154
+ }
@@ -0,0 +1,115 @@
1
+ 'use server'
2
+
3
+ import { rm } from 'node:fs/promises'
4
+
5
+ import { revalidatePath } from 'next/cache'
6
+
7
+ import { isBundleName } from '@meith/backup'
8
+ import { ValidationError } from '@meith/core'
9
+ import { msg } from '@meith/i18n'
10
+
11
+ import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
12
+ import type { FormState } from './auth-form-state'
13
+ import {
14
+ backupsAvailable,
15
+ currentBackupSettings,
16
+ destinationFor,
17
+ localBundlePath,
18
+ requireBackupRuns,
19
+ } from './backup-admin'
20
+ import { requireConfirmation } from './confirm'
21
+ import { formStateReporter } from './form-state-reporter'
22
+ import { tr } from './i18n'
23
+
24
+ const toFormState = formStateReporter('backup-admin', 'backup action failed')
25
+
26
+ const BACKUPS_PATH = '/admin/system/backups'
27
+
28
+ function refreshBackupsScreen(): void {
29
+ revalidatePath(BACKUPS_PATH)
30
+ }
31
+
32
+ export async function requestBackupAction(): Promise<FormState> {
33
+ try {
34
+ const admin = await requireAdmin()
35
+ if (backupsAvailable() !== 'available') {
36
+ throw new ValidationError(msg('error.app.backups-not-on-this-deployment'))
37
+ }
38
+
39
+ const { queued } = await requireBackupRuns().enqueue({
40
+ trigger: 'manual',
41
+ requestedByUserId: admin.session.userId,
42
+ now: new Date(),
43
+ })
44
+
45
+ refreshBackupsScreen()
46
+ if (queued) await recordAdminAction({ action: 'backup.requested' })
47
+ return { notice: queued ? 'queued' : 'already' }
48
+ } catch (err) {
49
+ return toFormState(err)
50
+ }
51
+ }
52
+
53
+ function bundleNameFrom(form: FormData): string {
54
+ const raw = form.get('name')
55
+ const name = typeof raw === 'string' ? raw.trim() : ''
56
+ if (!isBundleName(name)) throw new ValidationError(msg('error.app.not-a-backup-bundle-name'))
57
+ return name
58
+ }
59
+
60
+ export async function deleteBackupAction(_prev: FormState, form: FormData): Promise<FormState> {
61
+ try {
62
+ await requireFreshAdmin()
63
+ const name = bundleNameFrom(form)
64
+
65
+ const confirm = requireConfirmation(form, await tr('adminBackups.confirm.delete'))
66
+ if (confirm !== null) return confirm
67
+
68
+ const local = await localBundlePath(name)
69
+ if (local !== null) await rm(local, { force: true })
70
+
71
+ let remote = false
72
+ const destination = destinationFor(await currentBackupSettings())
73
+ if (destination !== undefined) {
74
+ const listed = await destination.list()
75
+ if (listed.some((bundle) => bundle.name === name)) {
76
+ await destination.delete(name)
77
+ remote = true
78
+ }
79
+ }
80
+
81
+ if (local === null && !remote) throw new ValidationError(msg('error.app.no-such-bundle'))
82
+
83
+ refreshBackupsScreen()
84
+ await recordAdminAction({
85
+ action: 'backup.deleted',
86
+ detail: { name, local: local !== null, remote },
87
+ })
88
+ return { notice: 'deleted', values: { name } }
89
+ } catch (err) {
90
+ return toFormState(err)
91
+ }
92
+ }
93
+
94
+ export async function testBackupDestinationAction(): Promise<FormState> {
95
+ try {
96
+ await requireAdmin()
97
+ const settings = await currentBackupSettings()
98
+ if (settings.destination.problem !== null) {
99
+ throw new ValidationError(settings.destination.problem)
100
+ }
101
+ const destination = destinationFor(settings)
102
+ if (destination === undefined) {
103
+ throw new ValidationError(msg('error.app.no-off-site-destination'))
104
+ }
105
+
106
+ const bundles = await destination.list()
107
+ await recordAdminAction({
108
+ action: 'backup.destination_tested',
109
+ detail: { count: bundles.length },
110
+ })
111
+ return { notice: 'reachable', values: { count: String(bundles.length) } }
112
+ } catch (err) {
113
+ return toFormState(err)
114
+ }
115
+ }
@@ -0,0 +1,157 @@
1
+ import 'server-only'
2
+
3
+ import { stat } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+
6
+ import {
7
+ type BackupCapability,
8
+ type BackupDestination,
9
+ type BackupRunRecord,
10
+ backupCapability,
11
+ backupDestinationFromEnv,
12
+ bundleTakenAt,
13
+ isBundleName,
14
+ localBundles,
15
+ nextSlotAfter,
16
+ } from '@meith/backup'
17
+ import { env, ForbiddenError, logger } from '@meith/core'
18
+ import { getDb, PostgresBackupRunRepository } from '@meith/db'
19
+ import { msg } from '@meith/i18n'
20
+ import {
21
+ BACKUP_STALE_MS,
22
+ type BackupSettingsView,
23
+ backupDestinationFor,
24
+ backupRingDirectory,
25
+ loadBackupSettings,
26
+ } from '@meith/runtime'
27
+
28
+ import { getContainer } from './container'
29
+
30
+ export interface BackupBundleRow {
31
+ readonly name: string
32
+ readonly takenAt: Date | null
33
+ readonly localSize: number | null
34
+ readonly remoteSize: number | null
35
+ }
36
+
37
+ export interface BackupDestinationView {
38
+ readonly source: 'environment' | 'board' | 'none'
39
+ readonly description: string | null
40
+ readonly problem: string | null
41
+ readonly listError: string | null
42
+ }
43
+
44
+ export interface BackupAdminView {
45
+ readonly capability: BackupCapability
46
+ readonly ring: string
47
+ readonly destination: BackupDestinationView
48
+ readonly bundles: readonly BackupBundleRow[]
49
+ readonly runs: readonly BackupRunRecord[]
50
+ readonly active: BackupRunRecord | null
51
+ readonly settings: BackupSettingsView
52
+ readonly nextScheduled: Date | null
53
+ }
54
+
55
+ const RECENT_RUNS = 10
56
+
57
+ export function backupRuns(): PostgresBackupRunRepository | null {
58
+ return getContainer().dataSource === 'postgres' ? new PostgresBackupRunRepository(getDb()) : null
59
+ }
60
+
61
+ export function requireBackupRuns(): PostgresBackupRunRepository {
62
+ const runs = backupRuns()
63
+ if (runs === null) {
64
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-6'))
65
+ }
66
+ return runs
67
+ }
68
+
69
+ export function backupRing(): string {
70
+ return backupRingDirectory(env)
71
+ }
72
+
73
+ export function backupsAvailable(): BackupCapability {
74
+ return backupCapability(env)
75
+ }
76
+
77
+ export function destinationIsFromEnvironment(): boolean {
78
+ try {
79
+ return backupDestinationFromEnv(env) !== undefined
80
+ } catch {
81
+ return true
82
+ }
83
+ }
84
+
85
+ export async function currentBackupSettings(): Promise<BackupSettingsView> {
86
+ return loadBackupSettings(getDb(), env)
87
+ }
88
+
89
+ export function destinationFor(settings: BackupSettingsView): BackupDestination | undefined {
90
+ return backupDestinationFor(settings.destination)
91
+ }
92
+
93
+ export async function localBundlePath(name: string): Promise<string | null> {
94
+ if (!isBundleName(name)) return null
95
+ const file = path.join(backupRing(), name)
96
+ const info = await stat(file).catch(() => null)
97
+ return info?.isFile() ? file : null
98
+ }
99
+
100
+ async function mergedBundles(
101
+ ring: string,
102
+ destination: BackupDestination | undefined,
103
+ ): Promise<{ readonly bundles: readonly BackupBundleRow[]; readonly listError: string | null }> {
104
+ const local = await localBundles(ring)
105
+ const rows = new Map<string, { localSize: number | null; remoteSize: number | null }>()
106
+ for (const bundle of local) rows.set(bundle.name, { localSize: bundle.size, remoteSize: null })
107
+
108
+ let listError: string | null = null
109
+ if (destination !== undefined) {
110
+ try {
111
+ for (const bundle of await destination.list()) {
112
+ const row = rows.get(bundle.name)
113
+ if (row === undefined) rows.set(bundle.name, { localSize: null, remoteSize: bundle.size })
114
+ else rows.set(bundle.name, { ...row, remoteSize: bundle.size })
115
+ }
116
+ } catch (error) {
117
+ listError = error instanceof Error ? error.message : String(error)
118
+ logger({ module: 'backup' }).warn({ err: listError }, 'could not list the off-site backups')
119
+ }
120
+ }
121
+
122
+ const bundles = [...rows]
123
+ .map(([name, sizes]) => ({ name, takenAt: bundleTakenAt(name), ...sizes }))
124
+ .sort((a, b) => b.name.localeCompare(a.name))
125
+ return { bundles, listError }
126
+ }
127
+
128
+ export async function buildBackupAdminView(now: Date): Promise<BackupAdminView | null> {
129
+ const runs = backupRuns()
130
+ if (runs === null) return null
131
+
132
+ const settings = await currentBackupSettings()
133
+ const destination = destinationFor(settings)
134
+ const ring = backupRing()
135
+
136
+ const [{ bundles, listError }, recent, active] = await Promise.all([
137
+ mergedBundles(ring, destination),
138
+ runs.recent(RECENT_RUNS),
139
+ runs.active(now, new Date(now.getTime() - BACKUP_STALE_MS)),
140
+ ])
141
+
142
+ return {
143
+ capability: backupsAvailable(),
144
+ ring,
145
+ destination: {
146
+ source: settings.destination.source,
147
+ description: destination?.description ?? null,
148
+ problem: settings.destination.problem,
149
+ listError,
150
+ },
151
+ bundles,
152
+ runs: recent,
153
+ active,
154
+ settings,
155
+ nextScheduled: nextSlotAfter(settings.schedule, now),
156
+ }
157
+ }
@@ -0,0 +1,66 @@
1
+ import 'server-only'
2
+
3
+ import { createReadStream } from 'node:fs'
4
+ import { stat } from 'node:fs/promises'
5
+ import { Readable } from 'node:stream'
6
+
7
+ import { isBundleName } from '@meith/backup'
8
+
9
+ import { resolveAdmin } from './admin'
10
+ import { currentBackupSettings, destinationFor, localBundlePath } from './backup-admin'
11
+
12
+ export const BACKUPS_PATH = '/admin/system/backups'
13
+
14
+ export const DOWNLOAD_LINK_SECONDS = 300
15
+
16
+ function notFound(): Response {
17
+ return new Response('Not found', { status: 404 })
18
+ }
19
+
20
+ function seeOther(location: string): Response {
21
+ return new Response(null, { status: 303, headers: { Location: location } })
22
+ }
23
+
24
+ export async function serveBackupDownload(name: string): Promise<Response> {
25
+ if (!isBundleName(name)) return notFound()
26
+
27
+ const resolved = await resolveAdmin()
28
+ if ('denied' in resolved) return seeOther(BACKUPS_PATH)
29
+ if (resolved.context.needsReauth) return seeOther(`${BACKUPS_PATH}?notice=reauth`)
30
+
31
+ const local = await localBundlePath(name)
32
+ if (local !== null) {
33
+ const { size } = await stat(local)
34
+ const body = Readable.toWeb(createReadStream(local)) as unknown as BodyInit
35
+ return new Response(body, {
36
+ headers: {
37
+ 'Content-Type': 'application/gzip',
38
+ 'Content-Length': String(size),
39
+ 'Content-Disposition': `attachment; filename="${name}"`,
40
+ 'X-Content-Type-Options': 'nosniff',
41
+ 'Cache-Control': 'private, no-store',
42
+ },
43
+ })
44
+ }
45
+
46
+ const destination = destinationFor(await currentBackupSettings())
47
+ if (destination === undefined) return notFound()
48
+ const listed = await destination.list()
49
+ if (!listed.some((bundle) => bundle.name === name)) return notFound()
50
+
51
+ if (destination.downloadUrl !== undefined) {
52
+ return seeOther(await destination.downloadUrl(name, DOWNLOAD_LINK_SECONDS))
53
+ }
54
+
55
+ const opened = await destination.open(name)
56
+ if (opened === null) return notFound()
57
+ return new Response(opened.body as unknown as BodyInit, {
58
+ headers: {
59
+ 'Content-Type': 'application/gzip',
60
+ ...(opened.size === null ? {} : { 'Content-Length': String(opened.size) }),
61
+ 'Content-Disposition': `attachment; filename="${name}"`,
62
+ 'X-Content-Type-Options': 'nosniff',
63
+ 'Cache-Control': 'private, no-store',
64
+ },
65
+ })
66
+ }
@@ -0,0 +1,40 @@
1
+ 'use server'
2
+
3
+ import { isAppError, logger } from '@meith/core'
4
+
5
+ import { getTranslator } from './i18n'
6
+ import { installerIsSealed } from './install'
7
+ import { type InstallRestoreOutcome, runInstallRestore } from './install-restore'
8
+
9
+ export interface InstallRestoreState {
10
+ readonly error?: string
11
+ readonly outcome?: InstallRestoreOutcome
12
+ }
13
+
14
+ export async function installRestoreAction(
15
+ _previous: InstallRestoreState,
16
+ form: FormData,
17
+ ): Promise<InstallRestoreState> {
18
+ const t = await getTranslator()
19
+ if (await installerIsSealed()) return { error: t.t('installRestore.alreadyInstalled') }
20
+
21
+ const raw = form.get('bundle')
22
+ const name = typeof raw === 'string' ? raw.trim() : ''
23
+ if (name === '') return { error: t.t('installRestore.pickOne') }
24
+ if (form.get('confirm') !== '1') return { error: t.t('installRestore.confirmFirst') }
25
+
26
+ try {
27
+ const run = await runInstallRestore(name)
28
+ if ('sealed' in run) return { error: t.t('installRestore.alreadyInstalled') }
29
+ if ('busy' in run) return { error: t.t('installRestore.busy') }
30
+ return { outcome: run.outcome }
31
+ } catch (error) {
32
+ if (isAppError(error)) return { error: error.message }
33
+ logger({ module: 'install-restore' }).error({ err: error }, 'restore from the installer failed')
34
+ return {
35
+ error: t.t('installRestore.failed', {
36
+ error: error instanceof Error ? error.message.slice(0, 300) : String(error),
37
+ }),
38
+ }
39
+ }
40
+ }