@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.
Files changed (46) hide show
  1. package/app/(board)/subscriptions/page.tsx +2 -2
  2. package/app/(board)/thread/[slug]/reply/page.tsx +5 -1
  3. package/app/admin/settings/page.tsx +9 -0
  4. package/app/admin/system/backups/[name]/route.ts +11 -0
  5. package/app/admin/system/backups/page.tsx +298 -0
  6. package/app/admin/system/page.tsx +4 -1
  7. package/app/auth/passkey/verify/route.ts +32 -2
  8. package/app/install/page.tsx +45 -2
  9. package/next.config.mjs +1 -0
  10. package/package.json +50 -49
  11. package/src/components/admin/backup-forms.tsx +87 -0
  12. package/src/components/install/restore-form.tsx +154 -0
  13. package/src/components/install/unlock-form.tsx +65 -0
  14. package/src/server/antispam.ts +18 -0
  15. package/src/server/api/content.ts +4 -1
  16. package/src/server/api/subscriptions.ts +2 -2
  17. package/src/server/attachment-upload-actions.ts +4 -0
  18. package/src/server/auth-actions.ts +3 -1
  19. package/src/server/backup-admin-actions.ts +115 -0
  20. package/src/server/backup-admin.ts +157 -0
  21. package/src/server/backup-download.ts +66 -0
  22. package/src/server/content-actions.ts +45 -18
  23. package/src/server/credential-proof-actions.ts +4 -0
  24. package/src/server/credential-proof.ts +11 -0
  25. package/src/server/federation-actions.ts +5 -0
  26. package/src/server/fixture-thread-repo.ts +3 -1
  27. package/src/server/install-actions.ts +25 -1
  28. package/src/server/install-restore-actions.ts +42 -0
  29. package/src/server/install-restore.ts +186 -0
  30. package/src/server/install-unlock.ts +55 -0
  31. package/src/server/message-actions.ts +1 -1
  32. package/src/server/passkey-challenge.ts +36 -3
  33. package/src/server/poll-scope.ts +6 -0
  34. package/src/server/post-notifications.ts +10 -2
  35. package/src/server/post-scope.ts +4 -0
  36. package/src/server/reply-core.ts +2 -0
  37. package/src/server/thread-core.ts +2 -0
  38. package/src/server/thread-rating-actions.ts +6 -0
  39. package/src/server/two-factor-actions.ts +5 -0
  40. package/src/server/upgrade-notice.ts +1 -1
  41. package/src/server/user-admin-actions.ts +9 -9
  42. package/src/server/usercp-actions.ts +2 -0
  43. package/src/view/admin-nav.ts +1 -0
  44. package/src/view/admin-panel-copy.ts +16 -0
  45. package/src/view/install-copy.ts +35 -0
  46. 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, 'thread.view', {
87
- ...(await authorizer.moderatorTargetIn(actor, forumId, matrix)),
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
- const userId = actor.userId!
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
- const staged = await stageAttachments(actor, resolved.scope, await submittedFiles(form))
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
- } catch (err) {
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
- const attached = await attachStaged(staged, { postId: created.postId, forumId, userId })
345
- await claimAttachments(
346
- form,
347
- { postId: created.postId, forumId, userId },
348
- scope,
349
- attached.length,
350
- )
351
- await drafts?.remove(userId, forumId, threadId)
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 ? null : { forumId: row.forumId, authorUserId: row.authorUserId }
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.move(userId, copyIds, 'inbox')}`
94
+ query = `moved=${await service.restore(userId, copyIds)}`
95
95
  break
96
96
  case 'delete':
97
97
  query = `deleted=${await service.remove(userId, copyIds)}`