@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.
- package/app/admin/settings/page.tsx +9 -0
- package/app/admin/system/backups/[name]/route.ts +11 -0
- package/app/admin/system/backups/page.tsx +298 -0
- package/app/admin/system/page.tsx +4 -1
- package/app/install/page.tsx +29 -1
- package/next.config.mjs +1 -0
- package/package.json +50 -49
- package/src/components/admin/backup-forms.tsx +87 -0
- package/src/components/install/restore-form.tsx +154 -0
- package/src/server/backup-admin-actions.ts +115 -0
- package/src/server/backup-admin.ts +157 -0
- package/src/server/backup-download.ts +66 -0
- package/src/server/install-restore-actions.ts +40 -0
- package/src/server/install-restore.ts +186 -0
- package/src/server/upgrade-notice.ts +1 -1
- package/src/view/admin-nav.ts +1 -0
- package/src/view/admin-panel-copy.ts +16 -0
- package/src/view/install-copy.ts +35 -0
- package/src/view/setting-groups.ts +2 -0
|
@@ -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.
|
|
20
|
+
export const CODE_VERSION = '0.34.0'
|
|
21
21
|
|
|
22
22
|
export interface UpgradeApplied {
|
|
23
23
|
readonly plugins: readonly string[]
|
package/src/view/admin-nav.ts
CHANGED
|
@@ -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(
|
package/src/view/install-copy.ts
CHANGED
|
@@ -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
|
|