@meith/web 0.33.4 → 0.35.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)/subscriptions/page.tsx +2 -2
- package/app/(board)/thread/[slug]/reply/page.tsx +5 -1
- 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/auth/passkey/verify/route.ts +32 -2
- package/app/install/page.tsx +45 -2
- 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/install/unlock-form.tsx +65 -0
- package/src/server/antispam.ts +18 -0
- package/src/server/api/content.ts +4 -1
- package/src/server/api/subscriptions.ts +2 -2
- package/src/server/attachment-upload-actions.ts +4 -0
- package/src/server/auth-actions.ts +3 -1
- 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/content-actions.ts +45 -18
- package/src/server/credential-proof-actions.ts +4 -0
- package/src/server/credential-proof.ts +11 -0
- package/src/server/federation-actions.ts +5 -0
- package/src/server/fixture-thread-repo.ts +3 -1
- package/src/server/install-actions.ts +25 -1
- package/src/server/install-restore-actions.ts +42 -0
- package/src/server/install-restore.ts +186 -0
- package/src/server/install-unlock.ts +55 -0
- package/src/server/message-actions.ts +1 -1
- package/src/server/passkey-challenge.ts +36 -3
- package/src/server/poll-scope.ts +6 -0
- package/src/server/post-notifications.ts +10 -2
- package/src/server/post-scope.ts +4 -0
- package/src/server/reply-core.ts +2 -0
- package/src/server/thread-core.ts +2 -0
- package/src/server/thread-rating-actions.ts +6 -0
- package/src/server/two-factor-actions.ts +5 -0
- package/src/server/upgrade-notice.ts +1 -1
- package/src/server/user-admin-actions.ts +9 -9
- package/src/server/usercp-actions.ts +2 -0
- 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,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
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { redirect } from 'next/navigation'
|
|
4
4
|
|
|
5
|
-
import { ForbiddenError, ValidationError } from '@meith/core'
|
|
5
|
+
import { ForbiddenError, logger, ValidationError } from '@meith/core'
|
|
6
6
|
import { msg } from '@meith/i18n'
|
|
7
7
|
import {
|
|
8
8
|
authorRef,
|
|
@@ -82,11 +82,15 @@ export async function quotePostAction(threadId: number, postId: number): Promise
|
|
|
82
82
|
|
|
83
83
|
const forumId = target.forum.id
|
|
84
84
|
const matrix = await authorizer.forumMatrix(actor, forumId)
|
|
85
|
+
const scope = {
|
|
86
|
+
...(await authorizer.moderatorTargetIn(actor, forumId, matrix)),
|
|
87
|
+
threadAuthorId: target.authorUserId,
|
|
88
|
+
}
|
|
89
|
+
if (!authorizer.can(actor, 'thread.view', scope)) return null
|
|
90
|
+
|
|
85
91
|
if (
|
|
86
|
-
!authorizer.can(actor, '
|
|
87
|
-
|
|
88
|
-
threadAuthorId: target.authorUserId,
|
|
89
|
-
})
|
|
92
|
+
(target.visibility === 'deleted' && !authorizer.can(actor, 'content.viewDeleted', scope)) ||
|
|
93
|
+
(target.visibility === 'unapproved' && !authorizer.can(actor, 'content.viewUnapproved', scope))
|
|
90
94
|
)
|
|
91
95
|
return null
|
|
92
96
|
|
|
@@ -151,6 +155,20 @@ function pollClosingTime(value: string): Date | null {
|
|
|
151
155
|
|
|
152
156
|
const toFormState = formStateReporter('content-actions', 'unexpected error writing content')
|
|
153
157
|
|
|
158
|
+
async function finaliseNewPost(
|
|
159
|
+
kind: 'thread' | 'reply',
|
|
160
|
+
finalise: () => Promise<void>,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
try {
|
|
163
|
+
await finalise()
|
|
164
|
+
} catch (err) {
|
|
165
|
+
logger().error(
|
|
166
|
+
{ err: String(err), kind },
|
|
167
|
+
'content saved but attaching uploads or clearing the draft failed',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
154
172
|
export interface ComposerAutosaveInput {
|
|
155
173
|
readonly forumId?: number
|
|
156
174
|
readonly threadId?: number
|
|
@@ -244,9 +262,11 @@ export async function createThreadAction(_prev: FormState, form: FormData): Prom
|
|
|
244
262
|
|
|
245
263
|
let created: Awaited<ReturnType<typeof submitThread>>
|
|
246
264
|
let resolved: Awaited<ReturnType<typeof resolveThreadTarget>>
|
|
265
|
+
let staged: Awaited<ReturnType<typeof stageAttachments>>
|
|
266
|
+
let userId: number
|
|
247
267
|
try {
|
|
248
268
|
resolved = await resolveThreadTarget(actor, forumId)
|
|
249
|
-
|
|
269
|
+
userId = actor.userId!
|
|
250
270
|
|
|
251
271
|
if (intent === 'save_draft') {
|
|
252
272
|
if (drafts === null) throw new ValidationError(msg('error.app.drafts-unavailable-board'))
|
|
@@ -256,7 +276,7 @@ export async function createThreadAction(_prev: FormState, form: FormData): Prom
|
|
|
256
276
|
|
|
257
277
|
const pollClosesAt = pollClosingTime(poll.closesAt)
|
|
258
278
|
|
|
259
|
-
|
|
279
|
+
staged = await stageAttachments(actor, resolved.scope, await submittedFiles(form))
|
|
260
280
|
|
|
261
281
|
const subscribeMode = subscribe
|
|
262
282
|
? autoWatchCadence(await autoWatchPreference(userId, 'create'))
|
|
@@ -280,7 +300,11 @@ export async function createThreadAction(_prev: FormState, form: FormData): Prom
|
|
|
280
300
|
publicVotes: poll.publicVotes,
|
|
281
301
|
},
|
|
282
302
|
})
|
|
303
|
+
} catch (err) {
|
|
304
|
+
return { ...(await toFormState(err, values)), poll }
|
|
305
|
+
}
|
|
283
306
|
|
|
307
|
+
await finaliseNewPost('thread', async () => {
|
|
284
308
|
const attached = await attachStaged(staged, { postId: created.postId, forumId, userId })
|
|
285
309
|
await claimAttachments(
|
|
286
310
|
form,
|
|
@@ -289,9 +313,7 @@ export async function createThreadAction(_prev: FormState, form: FormData): Prom
|
|
|
289
313
|
attached.length,
|
|
290
314
|
)
|
|
291
315
|
await drafts?.remove(userId, forumId, null)
|
|
292
|
-
}
|
|
293
|
-
return { ...(await toFormState(err, values)), poll }
|
|
294
|
-
}
|
|
316
|
+
})
|
|
295
317
|
|
|
296
318
|
if (created.visibility === 'unapproved') {
|
|
297
319
|
redirect(`/${resolved.forum.id}-${resolved.forum.slug}?posted=moderated`)
|
|
@@ -316,6 +338,7 @@ export async function createReplyAction(_prev: FormState, form: FormData): Promi
|
|
|
316
338
|
const { drafts } = getContainer()
|
|
317
339
|
|
|
318
340
|
let created: Awaited<ReturnType<typeof submitReply>>
|
|
341
|
+
let finalise: () => Promise<void>
|
|
319
342
|
try {
|
|
320
343
|
const resolved = await resolveReplyTarget(actor, threadId)
|
|
321
344
|
const { forumId, scope } = resolved
|
|
@@ -341,18 +364,22 @@ export async function createReplyAction(_prev: FormState, form: FormData): Promi
|
|
|
341
364
|
seenLastPostId,
|
|
342
365
|
})
|
|
343
366
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
367
|
+
finalise = async () => {
|
|
368
|
+
const attached = await attachStaged(staged, { postId: created.postId, forumId, userId })
|
|
369
|
+
await claimAttachments(
|
|
370
|
+
form,
|
|
371
|
+
{ postId: created.postId, forumId, userId },
|
|
372
|
+
scope,
|
|
373
|
+
attached.length,
|
|
374
|
+
)
|
|
375
|
+
await drafts?.remove(userId, forumId, threadId)
|
|
376
|
+
}
|
|
352
377
|
} catch (err) {
|
|
353
378
|
return toFormState(err, values)
|
|
354
379
|
}
|
|
355
380
|
|
|
381
|
+
await finaliseNewPost('reply', finalise)
|
|
382
|
+
|
|
356
383
|
const thread = `/thread/${created.threadId}-${created.slug}`
|
|
357
384
|
if (created.visibility === 'unapproved') {
|
|
358
385
|
redirect(`${thread}?posted=moderated`)
|
|
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'
|
|
|
4
4
|
|
|
5
5
|
import { verifyPassword } from '@meith/accounts'
|
|
6
6
|
|
|
7
|
+
import { limitMessage, refused, spendCredentialProofLimit } from './antispam'
|
|
7
8
|
import type { FormState } from './auth-form-state'
|
|
8
9
|
import { getContainer } from './container'
|
|
9
10
|
import { getActor } from './context'
|
|
@@ -17,6 +18,9 @@ export async function proveCredentialAction(_prev: FormState, form: FormData): P
|
|
|
17
18
|
const actor = await getActor()
|
|
18
19
|
if (actor.userId === null) return { error: await tr('credentialProof.signIn') }
|
|
19
20
|
|
|
21
|
+
const limited = await spendCredentialProofLimit(actor.userId)
|
|
22
|
+
if (refused(limited)) return { error: limitMessage(limited) }
|
|
23
|
+
|
|
20
24
|
const method = text(form, 'method')
|
|
21
25
|
let proved = false
|
|
22
26
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import 'server-only'
|
|
2
2
|
|
|
3
|
+
import { redirect } from 'next/navigation'
|
|
4
|
+
|
|
3
5
|
import { hasFreshCredentialProof, hashToken, type SessionRecord } from '@meith/accounts'
|
|
4
6
|
|
|
5
7
|
import { getContainer } from './container'
|
|
@@ -19,6 +21,15 @@ export async function currentCredentialProof(
|
|
|
19
21
|
return { session, provedAt: session.credentialProvedAt! }
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
export async function requireFreshCredentialProof(
|
|
25
|
+
userId: number,
|
|
26
|
+
next = '/usercp/security',
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
if ((await currentCredentialProof(userId)) === null) {
|
|
29
|
+
redirect(`/usercp/security/verify?next=${encodeURIComponent(next)}`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
export async function markCurrentSessionCredentialProved(
|
|
23
34
|
userId: number,
|
|
24
35
|
at = new Date(),
|
|
@@ -9,6 +9,7 @@ import { recordAuthEvent } from './auth-events'
|
|
|
9
9
|
import type { FormState } from './auth-form-state'
|
|
10
10
|
import { getContainer } from './container'
|
|
11
11
|
import { getActor } from './context'
|
|
12
|
+
import { requireFreshCredentialProof } from './credential-proof'
|
|
12
13
|
import { assertDemoAccountChangeable } from './demo'
|
|
13
14
|
import {
|
|
14
15
|
federationService,
|
|
@@ -61,6 +62,8 @@ async function requireOwnAccount(): Promise<{
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
export async function unlinkIdentityAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
65
|
+
const actor = await getActor()
|
|
66
|
+
if (actor.userId !== null) await requireFreshCredentialProof(actor.userId)
|
|
64
67
|
try {
|
|
65
68
|
const { userId, hasPassword } = await requireOwnAccount()
|
|
66
69
|
const identityId = Number(text(form, 'identityId'))
|
|
@@ -84,6 +87,8 @@ export async function unlinkIdentityAction(_prev: FormState, form: FormData): Pr
|
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
export async function removePasskeyAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
90
|
+
const actor = await getActor()
|
|
91
|
+
if (actor.userId !== null) await requireFreshCredentialProof(actor.userId)
|
|
87
92
|
try {
|
|
88
93
|
const { userId, hasPassword } = await requireOwnAccount()
|
|
89
94
|
const passkeyId = Number(text(form, 'passkeyId'))
|
|
@@ -40,7 +40,9 @@ export class FixtureThreadRepository implements ThreadRepository {
|
|
|
40
40
|
|
|
41
41
|
async locate(threadId: number): Promise<ThreadLocation | null> {
|
|
42
42
|
const row = this.rows.find((entry) => entry.id === threadId)
|
|
43
|
-
return row === undefined
|
|
43
|
+
return row === undefined
|
|
44
|
+
? null
|
|
45
|
+
: { forumId: row.forumId, authorUserId: row.authorUserId, visibility: row.visibility }
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
async findById(
|
|
@@ -20,8 +20,28 @@ import { mailConfigFromEnvironment } from '@meith/settings'
|
|
|
20
20
|
|
|
21
21
|
import { getTranslator } from './i18n'
|
|
22
22
|
import { gatherPreflight, installerIsSealed, runInstall } from './install'
|
|
23
|
+
import { grantInstallUnlock, installUnlocked, operatorSecretMatches } from './install-unlock'
|
|
23
24
|
import { sendTestMail } from './mail-test'
|
|
24
25
|
|
|
26
|
+
export interface InstallUnlockState {
|
|
27
|
+
readonly error?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function installUnlockAction(
|
|
31
|
+
_previous: InstallUnlockState,
|
|
32
|
+
form: FormData,
|
|
33
|
+
): Promise<InstallUnlockState> {
|
|
34
|
+
if (await installerIsSealed()) redirect('/')
|
|
35
|
+
|
|
36
|
+
const secret = typeof form.get('secret') === 'string' ? String(form.get('secret')) : ''
|
|
37
|
+
if (!operatorSecretMatches(secret)) {
|
|
38
|
+
return { error: (await getTranslator()).t('installUnlock.incorrect') }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await grantInstallUnlock()
|
|
42
|
+
redirect('/install')
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
export interface InstallFormState {
|
|
26
46
|
readonly errors?: Record<string, string>
|
|
27
47
|
readonly failedStep?: {
|
|
@@ -50,8 +70,12 @@ export async function installAction(
|
|
|
50
70
|
redirect('/')
|
|
51
71
|
}
|
|
52
72
|
|
|
53
|
-
const parsed = parseInstallInput(submitted)
|
|
54
73
|
const t = await getTranslator()
|
|
74
|
+
if (!(await installUnlocked())) {
|
|
75
|
+
return { errors: { form: t.t('installUnlock.required') }, values }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const parsed = parseInstallInput(submitted)
|
|
55
79
|
if (!parsed.ok) return { errors: translatedErrors(parsed.errors, t), values }
|
|
56
80
|
|
|
57
81
|
if (!canProceed(await gatherPreflight())) {
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
import { installUnlocked } from './install-unlock'
|
|
9
|
+
|
|
10
|
+
export interface InstallRestoreState {
|
|
11
|
+
readonly error?: string
|
|
12
|
+
readonly outcome?: InstallRestoreOutcome
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function installRestoreAction(
|
|
16
|
+
_previous: InstallRestoreState,
|
|
17
|
+
form: FormData,
|
|
18
|
+
): Promise<InstallRestoreState> {
|
|
19
|
+
const t = await getTranslator()
|
|
20
|
+
if (await installerIsSealed()) return { error: t.t('installRestore.alreadyInstalled') }
|
|
21
|
+
if (!(await installUnlocked())) return { error: t.t('installUnlock.required') }
|
|
22
|
+
|
|
23
|
+
const raw = form.get('bundle')
|
|
24
|
+
const name = typeof raw === 'string' ? raw.trim() : ''
|
|
25
|
+
if (name === '') return { error: t.t('installRestore.pickOne') }
|
|
26
|
+
if (form.get('confirm') !== '1') return { error: t.t('installRestore.confirmFirst') }
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const run = await runInstallRestore(name)
|
|
30
|
+
if ('sealed' in run) return { error: t.t('installRestore.alreadyInstalled') }
|
|
31
|
+
if ('busy' in run) return { error: t.t('installRestore.busy') }
|
|
32
|
+
return { outcome: run.outcome }
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (isAppError(error)) return { error: error.message }
|
|
35
|
+
logger({ module: 'install-restore' }).error({ err: error }, 'restore from the installer failed')
|
|
36
|
+
return {
|
|
37
|
+
error: t.t('installRestore.failed', {
|
|
38
|
+
error: error instanceof Error ? error.message.slice(0, 300) : String(error),
|
|
39
|
+
}),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { createHmac } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
import { cookies } from 'next/headers'
|
|
6
|
+
|
|
7
|
+
import { env, timingSafeEqualString } from '@meith/core'
|
|
8
|
+
|
|
9
|
+
const UNLOCK_COOKIE = 'fs_install_unlock'
|
|
10
|
+
|
|
11
|
+
const UNLOCK_TTL_MS = 30 * 60 * 1000
|
|
12
|
+
|
|
13
|
+
function secure(): boolean {
|
|
14
|
+
return env.NODE_ENV !== 'development'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sign(issuedAt: number): string {
|
|
18
|
+
return createHmac('sha256', env.AUTH_SECRET ?? '')
|
|
19
|
+
.update(`install-unlock:${issuedAt}`)
|
|
20
|
+
.digest('base64url')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function operatorSecretMatches(candidate: string): boolean {
|
|
24
|
+
const secret = env.AUTH_SECRET ?? ''
|
|
25
|
+
return secret !== '' && candidate !== '' && timingSafeEqualString(candidate, secret)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function grantInstallUnlock(now = Date.now()): Promise<void> {
|
|
29
|
+
const jar = await cookies()
|
|
30
|
+
jar.set(UNLOCK_COOKIE, `${now}.${sign(now)}`, {
|
|
31
|
+
httpOnly: true,
|
|
32
|
+
secure: secure(),
|
|
33
|
+
sameSite: 'strict',
|
|
34
|
+
path: '/install',
|
|
35
|
+
maxAge: UNLOCK_TTL_MS / 1000,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function installUnlocked(now = Date.now()): Promise<boolean> {
|
|
40
|
+
if ((env.AUTH_SECRET ?? '') === '') return false
|
|
41
|
+
|
|
42
|
+
const jar = await cookies()
|
|
43
|
+
const raw = jar.get(UNLOCK_COOKIE)?.value
|
|
44
|
+
if (raw === undefined) return false
|
|
45
|
+
|
|
46
|
+
const dot = raw.indexOf('.')
|
|
47
|
+
if (dot < 0) return false
|
|
48
|
+
|
|
49
|
+
const issuedAt = Number(raw.slice(0, dot))
|
|
50
|
+
if (!Number.isInteger(issuedAt) || now - issuedAt > UNLOCK_TTL_MS || issuedAt - now > 60_000) {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return timingSafeEqualString(raw.slice(dot + 1), sign(issuedAt))
|
|
55
|
+
}
|
|
@@ -91,7 +91,7 @@ export async function messageBulkAction(_prev: FormState, form: FormData): Promi
|
|
|
91
91
|
query = `moved=${await service.move(userId, copyIds, 'trash')}`
|
|
92
92
|
break
|
|
93
93
|
case 'restore':
|
|
94
|
-
query = `moved=${await service.
|
|
94
|
+
query = `moved=${await service.restore(userId, copyIds)}`
|
|
95
95
|
break
|
|
96
96
|
case 'delete':
|
|
97
97
|
query = `deleted=${await service.remove(userId, copyIds)}`
|