@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.
- package/app/(board)/thread/[slug]/page.tsx +1 -0
- 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/components/moderation/thread-surgery-form.tsx +6 -5
- package/src/components/moderation/thread-tools-form.tsx +80 -57
- package/src/components/shell/header-peek-enhancer.tsx +58 -0
- package/src/components/shell/page-shell.tsx +2 -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/styles/globals.css +3 -3
- 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/navigation.ts +16 -7
- package/src/view/setting-groups.ts +2 -0
|
@@ -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.
|
|
20
|
+
export const CODE_VERSION = '0.34.0'
|
|
21
21
|
|
|
22
22
|
export interface UpgradeApplied {
|
|
23
23
|
readonly plugins: readonly string[]
|
package/src/styles/globals.css
CHANGED
|
@@ -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
|
|
490
|
-
*
|
|
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:
|
|
493
|
+
scroll-margin-block-start: 5rem;
|
|
494
494
|
}
|
|
495
495
|
|
|
496
496
|
::selection {
|
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
|
+
}
|
package/src/view/navigation.ts
CHANGED
|
@@ -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
|
-
{
|
|
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:
|
|
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
|
|