@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,186 @@
1
+ import 'server-only'
2
+
3
+ import { mkdtemp, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ import {
8
+ type BackupDestination,
9
+ backupCapability,
10
+ backupDestinationFromEnv,
11
+ bundleTakenAt,
12
+ isBundleName,
13
+ localBundles,
14
+ openBackupDestination,
15
+ type RestoreUploadsPlan,
16
+ restoreBackup,
17
+ restoreLimits,
18
+ } from '@meith/backup'
19
+ import { env, GLOBAL_TAGS, logger, processEnvironment, ValidationError } from '@meith/core'
20
+ import {
21
+ countUsers,
22
+ getDb,
23
+ isInstalled,
24
+ migrationUrl,
25
+ runMigrations,
26
+ withInstallLock,
27
+ } from '@meith/db'
28
+ import { drivers } from '@meith/drivers'
29
+ import { msg } from '@meith/i18n'
30
+ import { backupRingDirectory } from '@meith/runtime'
31
+
32
+ import { CODE_VERSION } from './upgrade-notice'
33
+
34
+ export interface RestoreCandidate {
35
+ readonly name: string
36
+ readonly takenAt: Date | null
37
+ readonly size: number
38
+ readonly location: 'server' | 'off-site'
39
+ }
40
+
41
+ export interface InstallRestoreView {
42
+ readonly possible: boolean
43
+ readonly candidates: readonly RestoreCandidate[]
44
+ readonly ring: string
45
+ readonly destination: string | null
46
+ readonly problem: string | null
47
+ }
48
+
49
+ function offSiteDestination(): { destination: BackupDestination | null; problem: string | null } {
50
+ try {
51
+ const config = backupDestinationFromEnv(env)
52
+ return {
53
+ destination: config === undefined ? null : openBackupDestination(config),
54
+ problem: null,
55
+ }
56
+ } catch (error) {
57
+ return { destination: null, problem: error instanceof Error ? error.message : String(error) }
58
+ }
59
+ }
60
+
61
+ export async function installRestoreView(): Promise<InstallRestoreView> {
62
+ const ring = backupRingDirectory(env)
63
+ if (backupCapability(env) !== 'available') {
64
+ return { possible: false, candidates: [], ring, destination: null, problem: null }
65
+ }
66
+
67
+ const { destination, problem } = offSiteDestination()
68
+ const candidates: RestoreCandidate[] = []
69
+ const seen = new Set<string>()
70
+
71
+ for (const bundle of await localBundles(ring).catch(() => [])) {
72
+ seen.add(bundle.name)
73
+ candidates.push({ ...bundle, takenAt: bundleTakenAt(bundle.name), location: 'server' })
74
+ }
75
+
76
+ let listProblem = problem
77
+ if (destination !== null) {
78
+ try {
79
+ for (const bundle of await destination.list()) {
80
+ if (seen.has(bundle.name)) continue
81
+ candidates.push({ ...bundle, takenAt: bundleTakenAt(bundle.name), location: 'off-site' })
82
+ }
83
+ } catch (error) {
84
+ listProblem = error instanceof Error ? error.message : String(error)
85
+ logger().warn({ err: listProblem }, 'the installer could not list the off-site backups')
86
+ }
87
+ }
88
+
89
+ return {
90
+ possible: true,
91
+ candidates: candidates.sort((a, b) => b.name.localeCompare(a.name)),
92
+ ring,
93
+ destination: destination?.description ?? null,
94
+ problem: listProblem,
95
+ }
96
+ }
97
+
98
+ function uploadsPlan(): RestoreUploadsPlan {
99
+ switch (env.FILESTORE_DRIVER) {
100
+ case 's3':
101
+ return { mode: 'store', store: drivers().files, description: `the ${env.S3_BUCKET} bucket` }
102
+ case 'blob':
103
+ return { mode: 'store', store: drivers().files, description: 'the Blob store' }
104
+ default:
105
+ return { mode: 'directory', dir: env.UPLOADS_DIR }
106
+ }
107
+ }
108
+
109
+ export interface InstallRestoreOutcome {
110
+ readonly bundle: string
111
+ readonly version: string
112
+ readonly posts: number
113
+ readonly migrationsApplied: number
114
+ readonly uploads: 'restored' | 'pushed' | 'skipped' | 'none'
115
+ readonly skippedKeys: number
116
+ }
117
+
118
+ export type InstallRestoreRun =
119
+ | { readonly sealed: true }
120
+ | { readonly outcome: InstallRestoreOutcome }
121
+ | { readonly busy: true }
122
+
123
+ async function fetchCandidate(name: string, stage: string): Promise<string> {
124
+ const local = path.join(backupRingDirectory(env), name)
125
+ const known = await localBundles(backupRingDirectory(env)).catch(() => [])
126
+ if (known.some((bundle) => bundle.name === name)) return local
127
+
128
+ const { destination, problem } = offSiteDestination()
129
+ if (destination === null) {
130
+ throw new ValidationError(problem ?? msg('error.app.no-such-bundle').text)
131
+ }
132
+ const fetched = path.join(stage, name)
133
+ await destination.getToFile(name, fetched)
134
+ return fetched
135
+ }
136
+
137
+ export async function runInstallRestore(name: string): Promise<InstallRestoreRun> {
138
+ if (!isBundleName(name)) throw new ValidationError(msg('error.app.not-a-backup-bundle-name'))
139
+ if (backupCapability(env) !== 'available') {
140
+ throw new ValidationError(msg('error.app.backups-not-on-this-deployment'))
141
+ }
142
+
143
+ const url = migrationUrl(env)
144
+ const stage = await mkdtemp(path.join(tmpdir(), 'meith-install-restore-'))
145
+ try {
146
+ const run = await withInstallLock(url, async (): Promise<InstallRestoreRun> => {
147
+ const db = getDb()
148
+ if (await isInstalled(db)) return { sealed: true }
149
+ const members = await countUsers(db)
150
+ if (members !== null && members > 0) {
151
+ throw new ValidationError(msg('error.app.restore-needs-empty-board'))
152
+ }
153
+
154
+ const bundle = await fetchCandidate(name, stage)
155
+ const log = logger({ module: 'install-restore' })
156
+ const outcome = await restoreBackup({
157
+ bundle,
158
+ target: { url, variable: 'DATABASE_URL', mode: 'reset-schema' },
159
+ codeVersion: CODE_VERSION,
160
+ migrate: (target) => runMigrations({ url: target }),
161
+ uploads: uploadsPlan(),
162
+ limits: restoreLimits(processEnvironment()),
163
+ log: { info: (line) => log.info(line), warn: (line) => log.warn(line) },
164
+ })
165
+
166
+ return {
167
+ outcome: {
168
+ bundle: name,
169
+ version: outcome.manifest.version,
170
+ posts: outcome.posts,
171
+ migrationsApplied: outcome.migrationsApplied,
172
+ uploads: outcome.uploads,
173
+ skippedKeys: outcome.manifest.skippedKeys?.length ?? 0,
174
+ },
175
+ }
176
+ })
177
+ return run ?? { busy: true }
178
+ } finally {
179
+ await rm(stage, { recursive: true, force: true })
180
+ try {
181
+ await drivers().cache.invalidateTags(GLOBAL_TAGS)
182
+ } catch (error) {
183
+ logger().warn({ err: String(error) }, 'restore could not clear the cache; restart the server')
184
+ }
185
+ }
186
+ }
@@ -17,7 +17,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
17
17
 
18
18
  import { activeDefinitions } from './plugin-host'
19
19
 
20
- export const CODE_VERSION = '0.33.4'
20
+ export const CODE_VERSION = '0.34.0'
21
21
 
22
22
  export interface UpgradeApplied {
23
23
  readonly plugins: readonly string[]
@@ -115,6 +115,7 @@ export const ADMIN_SECTIONS: PanelNav = [
115
115
  titleKey: 'adminNav.admin-system.title',
116
116
  icon: 'system',
117
117
  blurbKey: 'adminNav.admin-system.blurb',
118
+ children: [{ href: '/admin/system/backups', titleKey: 'adminNav.admin-system-backups.title' }],
118
119
  },
119
120
  {
120
121
  href: '/admin/log',
@@ -75,6 +75,22 @@ export function webhookFormsCopy(t: Translator = untranslated()): Readonly<Recor
75
75
  )
76
76
  }
77
77
 
78
+ export function backupFormsCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
79
+ return {
80
+ ...copyFor(
81
+ [
82
+ 'adminPanel.backup.now',
83
+ 'adminPanel.backup.queued',
84
+ 'adminPanel.backup.already',
85
+ 'adminPanel.backup.delete',
86
+ 'adminPanel.backup.test',
87
+ ],
88
+ t,
89
+ ),
90
+ ...patternCopy(['adminPanel.backup.deleted', 'adminPanel.backup.reachable'], t),
91
+ }
92
+ }
93
+
78
94
  export function systemFormsCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
79
95
  return {
80
96
  ...copyFor(
@@ -96,3 +96,38 @@ export function installFormCopy(
96
96
  'install.mail.testNoteTail': testNoteTail,
97
97
  }
98
98
  }
99
+
100
+ export function installRestoreCopy(
101
+ t: Translator = untranslated(),
102
+ ): Readonly<Record<string, string>> {
103
+ return {
104
+ ...copyFor(
105
+ [
106
+ 'install.restore.title',
107
+ 'install.restore.hint',
108
+ 'install.restore.pick',
109
+ 'install.restore.confirm',
110
+ 'install.restore.submit',
111
+ 'install.restore.restoring',
112
+ 'install.restore.pendingNote',
113
+ 'install.restore.idleNote',
114
+ 'install.restore.notRestored',
115
+ 'install.restore.noneLocal',
116
+ 'install.restore.doneTitle',
117
+ 'install.restore.noUploads',
118
+ 'install.restore.next',
119
+ 'install.restore.signIn',
120
+ ],
121
+ t,
122
+ ),
123
+ ...patternCopy(
124
+ [
125
+ 'install.restore.noneAnywhere',
126
+ 'install.restore.doneDetail',
127
+ 'install.restore.migrated',
128
+ 'install.restore.skipped',
129
+ ],
130
+ t,
131
+ ),
132
+ }
133
+ }
@@ -12,6 +12,7 @@ export const GROUP_LABELS: Record<SettingGroup, string> = {
12
12
  federation: 'Sign-in providers',
13
13
  antispam: 'Anti-spam',
14
14
  push: 'Push',
15
+ backup: 'Backups',
15
16
  legal: 'Legal',
16
17
  }
17
18
 
@@ -27,6 +28,7 @@ export const GROUP_ORDER: readonly SettingGroup[] = [
27
28
  'federation',
28
29
  'antispam',
29
30
  'push',
31
+ 'backup',
30
32
  'legal',
31
33
  ]
32
34