@meith/web 0.33.3 → 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,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
+ }
@@ -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.3'
20
+ export const CODE_VERSION = '0.34.0'
21
21
 
22
22
  export interface UpgradeApplied {
23
23
  readonly plugins: readonly string[]
@@ -486,11 +486,11 @@
486
486
  * A permalink to `#post-12` otherwise lands the post flush against the top
487
487
  * of the viewport, with its own header cropped and the previous post's footer
488
488
  * nowhere — the reader cannot tell they arrived at the right one. The
489
- * default theme's header is sticky 3.5rem on a phone, 6.25rem with its
490
- * navigation row on a desktop — so the margin clears it with room to spare.
489
+ * default theme's header is sticky and 3.5rem tall, so the margin clears it
490
+ * with room to spare.
491
491
  */
492
492
  :target {
493
- scroll-margin-block-start: 7.5rem;
493
+ scroll-margin-block-start: 5rem;
494
494
  }
495
495
 
496
496
  ::selection {
@@ -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
+ }
@@ -22,27 +22,36 @@ export interface BuiltInNavigationItem {
22
22
  readonly messageKey: string
23
23
  readonly href: string
24
24
  readonly audience: NavigationAudience
25
+ readonly shown: boolean
25
26
  }
26
27
 
27
28
  export const BUILT_IN_NAVIGATION: readonly BuiltInNavigationItem[] = [
28
- { key: 'home', messageKey: 'nav.home', href: '/', audience: 'all' },
29
- { key: 'new-posts', messageKey: 'nav.newPosts', href: '/discover/new', audience: 'all' },
29
+ { key: 'home', messageKey: 'nav.home', href: '/', audience: 'all', shown: true },
30
+ {
31
+ key: 'new-posts',
32
+ messageKey: 'nav.newPosts',
33
+ href: '/discover/new',
34
+ audience: 'all',
35
+ shown: true,
36
+ },
30
37
  {
31
38
  key: 'unanswered',
32
39
  messageKey: 'nav.unanswered',
33
40
  href: '/discover/unanswered',
34
41
  audience: 'all',
42
+ shown: false,
35
43
  },
36
44
  {
37
45
  key: 'my-posts',
38
46
  messageKey: 'nav.myPosts',
39
47
  href: '/discover/participated',
40
48
  audience: 'members',
49
+ shown: false,
41
50
  },
42
- { key: 'search', messageKey: 'nav.search', href: '/search', audience: 'all' },
43
- { key: 'online', messageKey: 'nav.online', href: '/online', audience: 'all' },
44
- { key: 'members', messageKey: 'nav.members', href: '/members', audience: 'all' },
45
- { key: 'staff', messageKey: 'nav.staff', href: '/staff', audience: 'all' },
51
+ { key: 'search', messageKey: 'nav.search', href: '/search', audience: 'all', shown: true },
52
+ { key: 'online', messageKey: 'nav.online', href: '/online', audience: 'all', shown: false },
53
+ { key: 'members', messageKey: 'nav.members', href: '/members', audience: 'all', shown: false },
54
+ { key: 'staff', messageKey: 'nav.staff', href: '/staff', audience: 'all', shown: false },
46
55
  ]
47
56
 
48
57
  const SEARCH_KEY = 'search'
@@ -64,7 +73,7 @@ export function defaultNavigationItems(): readonly NavigationItemRow[] {
64
73
  displayOrder: index * 10,
65
74
  audience: item.audience,
66
75
  newTab: false,
67
- enabled: true,
76
+ enabled: item.shown,
68
77
  visibleToGroups: [],
69
78
  }))
70
79
  }
@@ -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