@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
@@ -1,37 +1,70 @@
1
1
  import 'server-only'
2
2
 
3
+ import { createHmac } from 'node:crypto'
4
+
5
+ import { env, timingSafeEqualString } from '@meith/core'
6
+
3
7
  export type PasskeyPurpose = 'register' | 'authenticate' | 'second-factor' | 'credential-proof'
4
8
 
9
+ export const PASSKEY_CHALLENGE_TTL_MS = 10 * 60 * 1000
10
+
11
+ export const PASSKEY_CHALLENGE_TTL_SECONDS = PASSKEY_CHALLENGE_TTL_MS / 1000
12
+
13
+ const CLOCK_SKEW_MS = 60_000
14
+
5
15
  export interface PasskeyChallenge {
6
16
  readonly challenge: string
17
+ readonly issuedAt: number
7
18
  readonly userId?: number
8
19
  readonly sessionId?: number
9
20
  readonly provedAt?: number
10
21
  }
11
22
 
23
+ function signature(purpose: PasskeyPurpose, payload: string): string {
24
+ return createHmac('sha256', env.AUTH_SECRET ?? '')
25
+ .update(`${purpose}:${payload}`)
26
+ .digest('base64url')
27
+ }
28
+
12
29
  export function packChallenge(
13
30
  purpose: PasskeyPurpose,
14
31
  challenge: string,
15
- binding: Omit<PasskeyChallenge, 'challenge'> = {},
32
+ binding: Omit<PasskeyChallenge, 'challenge' | 'issuedAt'> = {},
33
+ now: number = Date.now(),
16
34
  ): string {
17
- return `${purpose}:${encodeURIComponent(JSON.stringify({ challenge, ...binding }))}`
35
+ const payload = Buffer.from(JSON.stringify({ challenge, issuedAt: now, ...binding })).toString(
36
+ 'base64url',
37
+ )
38
+ return `${purpose}:${payload}.${signature(purpose, payload)}`
18
39
  }
19
40
 
20
41
  export function unpackChallenge(
21
42
  raw: string | undefined,
22
43
  purpose: PasskeyPurpose,
44
+ now: number = Date.now(),
23
45
  ): PasskeyChallenge | null {
24
46
  if (raw === undefined) return null
25
47
 
26
48
  const separator = raw.indexOf(':')
27
49
  if (separator < 0 || raw.slice(0, separator) !== purpose) return null
28
50
 
51
+ const body = raw.slice(separator + 1)
52
+ const dot = body.indexOf('.')
53
+ if (dot < 0) return null
54
+
55
+ const payload = body.slice(0, dot)
56
+ const provided = body.slice(dot + 1)
57
+ if (!timingSafeEqualString(provided, signature(purpose, payload))) return null
58
+
29
59
  try {
30
- const value = JSON.parse(decodeURIComponent(raw.slice(separator + 1))) as Record<
60
+ const value = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record<
31
61
  string,
32
62
  unknown
33
63
  >
34
64
  if (typeof value.challenge !== 'string' || value.challenge === '') return null
65
+ if (typeof value.issuedAt !== 'number' || !Number.isInteger(value.issuedAt)) return null
66
+ if (now - value.issuedAt > PASSKEY_CHALLENGE_TTL_MS) return null
67
+ if (value.issuedAt - now > CLOCK_SKEW_MS) return null
35
68
  if (value.userId !== undefined && !Number.isInteger(value.userId)) return null
36
69
  if (value.sessionId !== undefined && !Number.isInteger(value.sessionId)) return null
37
70
  if (value.provedAt !== undefined && !Number.isInteger(value.provedAt)) return null
@@ -32,6 +32,12 @@ export async function resolvePollScope(actor: Actor, threadId: number): Promise<
32
32
  }
33
33
  if (!authorizer.can(actor, 'thread.view', scope)) return null
34
34
 
35
+ if (
36
+ (located.visibility === 'deleted' && !authorizer.can(actor, 'content.viewDeleted', scope)) ||
37
+ (located.visibility === 'unapproved' && !authorizer.can(actor, 'content.viewUnapproved', scope))
38
+ )
39
+ return null
40
+
35
41
  const wroteIt = actor.userId !== null && located.authorUserId === actor.userId
36
42
  const editsOwn = wroteIt && authorizer.can(actor, 'post.editOwn', scope)
37
43
  const mayEditOthers = authorizer.can(actor, 'post.editOthers', scope)
@@ -1,7 +1,7 @@
1
1
  import 'server-only'
2
2
 
3
3
  import { foldIdentifier } from '@meith/accounts'
4
- import { logger } from '@meith/core'
4
+ import { audienceFilterIn, authorFilterAdmits, logger } from '@meith/core'
5
5
  import { extractMentions, extractQuotedAuthors, vocabularyOptions } from '@meith/markdown'
6
6
 
7
7
  import { postLink } from '@/view/post-link'
@@ -15,8 +15,10 @@ const MAX_RECIPIENTS_PER_KIND = 10
15
15
  export interface NewPostNotice {
16
16
  readonly postId: number
17
17
  readonly threadId: number
18
+ readonly forumId: number
18
19
  readonly threadSlug: string
19
20
  readonly threadTitle: string
21
+ readonly threadAuthorId: number | null
20
22
  readonly message: string
21
23
  readonly authorUsername: string
22
24
  readonly visibility: 'visible' | 'unapproved'
@@ -35,7 +37,7 @@ export async function notifyPostAudience(notice: NewPostNotice): Promise<void> {
35
37
  const href = postLink(`/thread/${notice.threadId}-${notice.threadSlug}`, notice.postId)
36
38
  const data = { byUsername: notice.authorUsername, threadTitle: notice.threadTitle }
37
39
 
38
- const { accountStore } = getContainer()
40
+ const { accountStore, actorSource, authorizer } = getContainer()
39
41
  const told = new Set<string>([foldIdentifier(notice.authorUsername)])
40
42
 
41
43
  for (const [kind, names] of [
@@ -52,6 +54,12 @@ export async function notifyPostAudience(notice: NewPostNotice): Promise<void> {
52
54
  const account = await accountStore.accounts.findByUsernameLower(folded)
53
55
  if (account === null) continue
54
56
 
57
+ const recipient = await actorSource.buildForUser(account.id)
58
+ if (recipient === null) continue
59
+ const audience = await authorizer.threadAudience(recipient)
60
+ const filter = audienceFilterIn(audience, notice.forumId)
61
+ if (!authorFilterAdmits(filter, notice.threadAuthorId)) continue
62
+
55
63
  await service.raise({
56
64
  userId: account.id,
57
65
  kind,
@@ -36,9 +36,13 @@ export async function resolvePostScope(
36
36
  const scope = {
37
37
  ...(await moderatorTargetFor(actor, target.forum.id, matrix)),
38
38
  ownerId: target.post.authorUserId,
39
+ threadAuthorId: target.thread.authorUserId,
39
40
  }
40
41
  if (!authorizer.can(actor, 'thread.view', scope)) return null
41
42
 
43
+ const visibleStates = authorizer.contentScope(actor, scope)
44
+ if (!visibleStates.states.includes(target.thread.visibility)) return null
45
+
42
46
  const isOwn = actor.userId !== null && target.post.authorUserId === actor.userId
43
47
  const editsOthers = authorizer.can(actor, 'post.editOthers', scope)
44
48
  const moderates = authorizer.can(actor, 'content.viewUnapproved', scope)
@@ -162,8 +162,10 @@ export async function submitReply(
162
162
  await notifyPostAudience({
163
163
  postId: created.postId,
164
164
  threadId: created.threadId,
165
+ forumId,
165
166
  threadSlug: created.slug,
166
167
  threadTitle: target.title,
168
+ threadAuthorId: target.authorUserId,
167
169
  message: draft.body,
168
170
  authorUsername: profile.username,
169
171
  visibility: created.visibility,
@@ -201,8 +201,10 @@ export async function submitThread(
201
201
  await notifyPostAudience({
202
202
  postId: created.postId,
203
203
  threadId: created.threadId,
204
+ forumId: forum.id,
204
205
  threadSlug: created.slug,
205
206
  threadTitle: draft.subject ?? input.title,
207
+ threadAuthorId: userId,
206
208
  message: draft.body,
207
209
  authorUsername: profile.username,
208
210
  visibility: created.visibility,
@@ -38,6 +38,12 @@ export async function rateThreadAction(form: FormData): Promise<void> {
38
38
  }
39
39
  if (!authorizer.can(actor, 'thread.view', target))
40
40
  throw new ValidationError(msg('error.app.thread-exist'))
41
+ if (
42
+ (located?.visibility === 'deleted' && !authorizer.can(actor, 'content.viewDeleted', target)) ||
43
+ (located?.visibility === 'unapproved' &&
44
+ !authorizer.can(actor, 'content.viewUnapproved', target))
45
+ )
46
+ throw new ValidationError(msg('error.app.thread-exist'))
41
47
  const recorded = await new ThreadRatingService(polls).rate({
42
48
  threadId,
43
49
  userId: actor.userId,
@@ -12,6 +12,7 @@ import { recordAuthEvent } from './auth-events'
12
12
  import type { FormState } from './auth-form-state'
13
13
  import { getContainer } from './container'
14
14
  import { getActor } from './context'
15
+ import { requireFreshCredentialProof } from './credential-proof'
15
16
  import { assertDemoAccountChangeable } from './demo'
16
17
  import { formStateReporter } from './form-state-reporter'
17
18
  import { text } from './form-values'
@@ -59,6 +60,8 @@ async function requireOwnFactor(): Promise<Owner> {
59
60
  }
60
61
 
61
62
  export async function beginTwoFactorAction(_prev: FormState, _form: FormData): Promise<FormState> {
63
+ const actor = await getActor()
64
+ if (actor.userId !== null) await requireFreshCredentialProof(actor.userId)
62
65
  try {
63
66
  const { userId } = await requireOwnAccount()
64
67
 
@@ -91,6 +94,8 @@ export async function abandonTwoFactorAction(
91
94
  }
92
95
 
93
96
  export async function confirmTwoFactorAction(_prev: FormState, form: FormData): Promise<FormState> {
97
+ const actor = await getActor()
98
+ if (actor.userId !== null) await requireFreshCredentialProof(actor.userId)
94
99
  try {
95
100
  const { userId } = await requireOwnAccount()
96
101
 
@@ -17,7 +17,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
17
17
 
18
18
  import { activeDefinitions } from './plugin-host'
19
19
 
20
- export const CODE_VERSION = '0.33.4'
20
+ export const CODE_VERSION = '0.35.0'
21
21
 
22
22
  export interface UpgradeApplied {
23
23
  readonly plugins: readonly string[]
@@ -447,15 +447,15 @@ async function queueMassMailBatch(
447
447
  bulk: ReturnType<typeof requireUserBulk>,
448
448
  massMailId: number,
449
449
  ): Promise<{ state: FormState; sent: number; queued: number }> {
450
- const chunk = await bulk.claimMassMailChunk(massMailId, MASS_MAIL_CHUNK)
451
-
452
- for (const recipient of chunk.recipients) {
453
- await drivers().queue.enqueue(
454
- 'admin.mass_mail',
455
- { massMailId, userId: recipient.userId, email: recipient.email },
456
- { dedupeKey: `mass-mail:${massMailId}:${recipient.userId}` },
457
- )
458
- }
450
+ const chunk = await bulk.claimMassMailChunk(massMailId, MASS_MAIL_CHUNK, async (recipients) => {
451
+ for (const recipient of recipients) {
452
+ await drivers().queue.enqueue(
453
+ 'admin.mass_mail',
454
+ { massMailId, userId: recipient.userId, email: recipient.email },
455
+ { dedupeKey: `mass-mail:${massMailId}:${recipient.userId}` },
456
+ )
457
+ }
458
+ })
459
459
 
460
460
  const total = (await bulk.readMassMail(massMailId))?.queuedCount ?? 0
461
461
 
@@ -23,6 +23,7 @@ import { formStateReporter } from './form-state-reporter'
23
23
  import { text } from './form-values'
24
24
  import { getTranslator, tr } from './i18n'
25
25
  import { boardRendering } from './markdown-pipeline'
26
+ import { notificationService } from './notifications'
26
27
  import { emitEvent } from './plugin-view'
27
28
  import { profileFieldService, submittedFields, viewerFieldContext } from './profile-fields'
28
29
  import { setSessionCookie } from './session-cookies'
@@ -170,6 +171,7 @@ export async function changePasswordAction(_prev: FormState, form: FormData): Pr
170
171
  if (admin !== null) await admin.endAllFor(userId)
171
172
 
172
173
  await revokeFeedToken(userId)
174
+ await notificationService()?.unsubscribeAllFromPush(userId)
173
175
 
174
176
  await recordAuthEvent({ userId, kind: 'password_changed' })
175
177
  } catch (err) {
@@ -115,6 +115,7 @@ export const ADMIN_SECTIONS: PanelNav = [
115
115
  titleKey: 'adminNav.admin-system.title',
116
116
  icon: 'system',
117
117
  blurbKey: 'adminNav.admin-system.blurb',
118
+ children: [{ href: '/admin/system/backups', titleKey: 'adminNav.admin-system-backups.title' }],
118
119
  },
119
120
  {
120
121
  href: '/admin/log',
@@ -75,6 +75,22 @@ export function webhookFormsCopy(t: Translator = untranslated()): Readonly<Recor
75
75
  )
76
76
  }
77
77
 
78
+ export function backupFormsCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
79
+ return {
80
+ ...copyFor(
81
+ [
82
+ 'adminPanel.backup.now',
83
+ 'adminPanel.backup.queued',
84
+ 'adminPanel.backup.already',
85
+ 'adminPanel.backup.delete',
86
+ 'adminPanel.backup.test',
87
+ ],
88
+ t,
89
+ ),
90
+ ...patternCopy(['adminPanel.backup.deleted', 'adminPanel.backup.reachable'], t),
91
+ }
92
+ }
93
+
78
94
  export function systemFormsCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
79
95
  return {
80
96
  ...copyFor(
@@ -96,3 +96,38 @@ export function installFormCopy(
96
96
  'install.mail.testNoteTail': testNoteTail,
97
97
  }
98
98
  }
99
+
100
+ export function installRestoreCopy(
101
+ t: Translator = untranslated(),
102
+ ): Readonly<Record<string, string>> {
103
+ return {
104
+ ...copyFor(
105
+ [
106
+ 'install.restore.title',
107
+ 'install.restore.hint',
108
+ 'install.restore.pick',
109
+ 'install.restore.confirm',
110
+ 'install.restore.submit',
111
+ 'install.restore.restoring',
112
+ 'install.restore.pendingNote',
113
+ 'install.restore.idleNote',
114
+ 'install.restore.notRestored',
115
+ 'install.restore.noneLocal',
116
+ 'install.restore.doneTitle',
117
+ 'install.restore.noUploads',
118
+ 'install.restore.next',
119
+ 'install.restore.signIn',
120
+ ],
121
+ t,
122
+ ),
123
+ ...patternCopy(
124
+ [
125
+ 'install.restore.noneAnywhere',
126
+ 'install.restore.doneDetail',
127
+ 'install.restore.migrated',
128
+ 'install.restore.skipped',
129
+ ],
130
+ t,
131
+ ),
132
+ }
133
+ }
@@ -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