@meith/web 0.34.0 → 0.35.1

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 (33) 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/auth/passkey/verify/route.ts +33 -4
  4. package/app/install/page.tsx +17 -2
  5. package/package.json +50 -50
  6. package/src/components/install/unlock-form.tsx +65 -0
  7. package/src/server/admin-actions.ts +30 -21
  8. package/src/server/antispam.ts +18 -0
  9. package/src/server/api/content.ts +4 -1
  10. package/src/server/api/subscriptions.ts +2 -2
  11. package/src/server/attachment-upload-actions.ts +4 -0
  12. package/src/server/auth-actions.ts +14 -5
  13. package/src/server/content-actions.ts +45 -18
  14. package/src/server/credential-proof-actions.ts +4 -0
  15. package/src/server/credential-proof.ts +11 -0
  16. package/src/server/federation-actions.ts +5 -0
  17. package/src/server/federation.ts +3 -1
  18. package/src/server/fixture-thread-repo.ts +3 -1
  19. package/src/server/install-actions.ts +25 -1
  20. package/src/server/install-restore-actions.ts +2 -0
  21. package/src/server/install-unlock.ts +55 -0
  22. package/src/server/message-actions.ts +1 -1
  23. package/src/server/passkey-challenge.ts +36 -3
  24. package/src/server/poll-scope.ts +6 -0
  25. package/src/server/post-notifications.ts +10 -2
  26. package/src/server/post-scope.ts +4 -0
  27. package/src/server/reply-core.ts +2 -0
  28. package/src/server/thread-core.ts +2 -0
  29. package/src/server/thread-rating-actions.ts +6 -0
  30. package/src/server/two-factor-actions.ts +5 -0
  31. package/src/server/upgrade-notice.ts +1 -1
  32. package/src/server/user-admin-actions.ts +9 -9
  33. package/src/server/usercp-actions.ts +2 -0
@@ -34,8 +34,8 @@ export default async function SubscriptionsPage({
34
34
 
35
35
  if (actor.userId === null || subscriptions === null) notFound()
36
36
 
37
- const visibleForumIds = await authorizer.visibleForumIds(actor)
38
- const rows = await new SubscriptionService({ subscriptions }).list(actor.userId, visibleForumIds)
37
+ const audience = await authorizer.threadAudience(actor)
38
+ const rows = await new SubscriptionService({ subscriptions }).list(actor.userId, audience)
39
39
 
40
40
  const translator = await getTranslator()
41
41
 
@@ -55,7 +55,11 @@ export default async function ReplyPage({
55
55
  forum: await authorizer.forumMatrix(actor, target.forum.id),
56
56
  allowsAttachments: target.forum.allowAttachments,
57
57
  }
58
- if (!authorizer.can(actor, 'thread.view', scope)) notFound()
58
+ const threadViewTarget = {
59
+ ...(await authorizer.moderatorTargetIn(actor, target.forum.id, scope.forum)),
60
+ threadAuthorId: target.authorUserId,
61
+ }
62
+ if (!authorizer.can(actor, 'thread.view', threadViewTarget)) notFound()
59
63
  if (!authorizer.can(actor, 'reply.post', scope)) notFound()
60
64
 
61
65
  const moderates = authorizer.can(actor, 'content.viewUnapproved', scope)
@@ -1,7 +1,9 @@
1
1
  import type { NextRequest } from 'next/server'
2
2
 
3
+ import { RateLimiter } from '@meith/antispam'
3
4
  import { logger, statusForError, toPublicError } from '@meith/core'
4
5
 
6
+ import { rateLimitStore } from '@/server/antispam'
5
7
  import { recordAuthEvent } from '@/server/auth-events'
6
8
  import { configuredIdentity, configuredSessions } from '@/server/container'
7
9
  import { getActor } from '@/server/context'
@@ -16,7 +18,11 @@ import {
16
18
  relyingParty,
17
19
  } from '@/server/federation'
18
20
  import { tr } from '@/server/i18n'
19
- import { type PasskeyPurpose, unpackChallenge } from '@/server/passkey-challenge'
21
+ import {
22
+ PASSKEY_CHALLENGE_TTL_SECONDS,
23
+ type PasskeyPurpose,
24
+ unpackChallenge,
25
+ } from '@/server/passkey-challenge'
20
26
  import { retainedIpPrefix } from '@/server/request-fingerprint'
21
27
  import { isSafeLocalPath } from '@/server/safe-path'
22
28
  import { crossOriginRefusal, isSameOrigin } from '@/server/same-origin'
@@ -59,6 +65,26 @@ function text(value: unknown): string | null {
59
65
  return typeof value === 'string' && value !== '' ? value : null
60
66
  }
61
67
 
68
+ async function consumeChallengeOnce(challenge: string): Promise<boolean> {
69
+ const store = rateLimitStore()
70
+ if (store === null) return true
71
+
72
+ try {
73
+ const outcome = await new RateLimiter(store).consume({
74
+ scope: 'passkey',
75
+ subject: challenge,
76
+ rule: { max: 1, windowSeconds: PASSKEY_CHALLENGE_TTL_SECONDS },
77
+ })
78
+ return outcome.allowed
79
+ } catch (error) {
80
+ logger({ module: 'passkeys' }).warn(
81
+ { err: String(error) },
82
+ 'could not enforce single-use on a passkey challenge',
83
+ )
84
+ return true
85
+ }
86
+ }
87
+
62
88
  export async function POST(request: NextRequest): Promise<Response> {
63
89
  if (!isSameOrigin(request)) return crossOriginRefusal()
64
90
 
@@ -86,6 +112,10 @@ export async function POST(request: NextRequest): Promise<Response> {
86
112
  return problem(await tr('authRoute.passkey.attemptExpired'), 400)
87
113
  }
88
114
 
115
+ if (!(await consumeChallengeOnce(challengeState.challenge))) {
116
+ return problem(await tr('authRoute.passkey.attemptExpired'), 400)
117
+ }
118
+
89
119
  const clientDataJSON = text(body.clientDataJSON)
90
120
  if (clientDataJSON === null) {
91
121
  return problem(await tr('authRoute.passkey.responseIncomplete'), 400)
@@ -183,7 +213,7 @@ export async function POST(request: NextRequest): Promise<Response> {
183
213
  return problem(await tr('authRoute.passkey.signInExpired'), 403)
184
214
  }
185
215
 
186
- await identity.assertSecondFactorAttemptsLeft(pending.userId)
216
+ await identity.spendSecondFactorAttempt(pending.userId)
187
217
 
188
218
  const proved = await service.proveOwnership({
189
219
  userId: pending.userId,
@@ -194,7 +224,6 @@ export async function POST(request: NextRequest): Promise<Response> {
194
224
  })
195
225
 
196
226
  if (!proved) {
197
- await identity.recordSecondFactorFailure(pending.userId)
198
227
  await recordAuthEvent({ userId: pending.userId, kind: 'second_factor_failed' })
199
228
  return problem(await tr('authRoute.passkey.notForAccount'), 403)
200
229
  }
@@ -209,7 +238,7 @@ export async function POST(request: NextRequest): Promise<Response> {
209
238
  await setSessionCookie(login.sessionToken, login.expiresAt)
210
239
 
211
240
  if (pending.remember) {
212
- const remembered = await (await configuredSessions()).startRemembered(pending.userId)
241
+ const remembered = await (await configuredSessions()).issueRemember(pending.userId)
213
242
  await setRememberCookie(remembered.rememberToken, remembered.rememberExpiresAt)
214
243
  }
215
244
 
@@ -17,9 +17,11 @@ import { Alert, AlertDescription, AlertTitle, Disclosure } from '@meith/ui'
17
17
 
18
18
  import { InstallForm } from '@/components/install/install-form'
19
19
  import { InstallRestoreForm } from '@/components/install/restore-form'
20
+ import { InstallUnlockForm } from '@/components/install/unlock-form'
20
21
  import { getTranslator, tr } from '@/server/i18n'
21
22
  import { gatherPreflight, installerIsSealed, probeMail } from '@/server/install'
22
23
  import { installRestoreView } from '@/server/install-restore'
24
+ import { installUnlocked } from '@/server/install-unlock'
23
25
  import { formatBytes } from '@/view/attachments'
24
26
  import { installFormCopy, installRestoreCopy } from '@/view/install-copy'
25
27
  import { formatTime } from '@/view/time'
@@ -77,9 +79,10 @@ export default async function InstallPage() {
77
79
 
78
80
  const checks = await gatherPreflight()
79
81
  const ready = canProceed(checks)
82
+ const unlocked = ready && (await installUnlocked())
80
83
  const mail = await probeMail()
81
84
  const suggestedBoardUrl = await suggestBoardUrl()
82
- const restore = ready ? await installRestoreView() : null
85
+ const restore = unlocked ? await installRestoreView() : null
83
86
  const now = new Date()
84
87
 
85
88
  return (
@@ -93,6 +96,18 @@ export default async function InstallPage() {
93
96
 
94
97
  <Preflight checks={checks} />
95
98
 
99
+ {ready && !unlocked && (
100
+ <InstallUnlockForm
101
+ copy={{
102
+ title: t.t('installUnlock.title'),
103
+ lede: t.t('installUnlock.lede'),
104
+ label: t.t('installUnlock.label'),
105
+ button: t.t('installUnlock.button'),
106
+ pending: t.t('installUnlock.pending'),
107
+ }}
108
+ />
109
+ )}
110
+
96
111
  {restore?.possible && (
97
112
  <InstallRestoreForm
98
113
  candidates={restore.candidates.map((candidate) => ({
@@ -115,7 +130,7 @@ export default async function InstallPage() {
115
130
  />
116
131
  )}
117
132
 
118
- {ready && (
133
+ {unlocked && (
119
134
  <InstallForm
120
135
  presets={MAIL_PRESETS.map((preset) => ({
121
136
  ...preset,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.34.0",
3
+ "version": "0.35.1",
4
4
  "description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,55 +43,55 @@
43
43
  "react-dom": "19.2.8",
44
44
  "tailwindcss": "^4.3.3",
45
45
  "typescript": "7.0.2",
46
- "@meith/accounts": "0.34.0",
47
- "@meith/admin": "0.34.0",
48
- "@meith/antispam": "0.34.0",
49
- "@meith/api": "0.34.0",
50
- "@meith/attachments": "0.34.0",
51
- "@meith/authorization": "0.34.0",
52
- "@meith/avatars": "0.34.0",
53
- "@meith/backup": "0.34.0",
54
- "@meith/board-digest": "0.34.0",
55
- "@meith/core": "0.34.0",
56
- "@meith/db": "0.34.0",
57
- "@meith/demo": "0.34.0",
58
- "@meith/drafts": "0.34.0",
59
- "@meith/drivers": "0.34.0",
60
- "@meith/events": "0.34.0",
61
- "@meith/forums": "0.34.0",
62
- "@meith/groups": "0.34.0",
63
- "@meith/i18n": "0.34.0",
64
- "@meith/import": "0.34.0",
65
- "@meith/install": "0.34.0",
66
- "@meith/mail": "0.34.0",
67
- "@meith/markdown": "0.34.0",
68
- "@meith/marketplace": "0.34.0",
69
- "@meith/messages": "0.34.0",
70
- "@meith/moderation": "0.34.0",
71
- "@meith/notifications": "0.34.0",
72
- "@meith/plugin-calendar": "0.34.0",
73
- "@meith/plugin-dues": "0.34.0",
74
- "@meith/plugin-kit": "0.34.0",
75
- "@meith/polls": "0.34.0",
76
- "@meith/posts": "0.34.0",
77
- "@meith/profile-fields": "0.34.0",
78
- "@meith/relations": "0.34.0",
79
- "@meith/reputation": "0.34.0",
80
- "@meith/runtime": "0.34.0",
81
- "@meith/search": "0.34.0",
82
- "@meith/settings": "0.34.0",
83
- "@meith/signatures": "0.34.0",
84
- "@meith/subscriptions": "0.34.0",
85
- "@meith/tasks": "0.34.0",
86
- "@meith/theme-clubhouse": "0.34.0",
87
- "@meith/theme-default": "0.34.0",
88
- "@meith/theme-kit": "0.34.0",
89
- "@meith/theme-midnight": "0.34.0",
90
- "@meith/theme-phasebook": "0.34.0",
91
- "@meith/theme-raidframe": "0.34.0",
92
- "@meith/threads": "0.34.0",
93
- "@meith/ui": "0.34.0",
94
- "@meith/upgrade": "0.34.0"
46
+ "@meith/accounts": "0.35.1",
47
+ "@meith/admin": "0.35.1",
48
+ "@meith/antispam": "0.35.1",
49
+ "@meith/api": "0.35.1",
50
+ "@meith/attachments": "0.35.1",
51
+ "@meith/authorization": "0.35.1",
52
+ "@meith/avatars": "0.35.1",
53
+ "@meith/backup": "0.35.1",
54
+ "@meith/board-digest": "0.35.1",
55
+ "@meith/core": "0.35.1",
56
+ "@meith/db": "0.35.1",
57
+ "@meith/demo": "0.35.1",
58
+ "@meith/drafts": "0.35.1",
59
+ "@meith/drivers": "0.35.1",
60
+ "@meith/events": "0.35.1",
61
+ "@meith/forums": "0.35.1",
62
+ "@meith/groups": "0.35.1",
63
+ "@meith/i18n": "0.35.1",
64
+ "@meith/import": "0.35.1",
65
+ "@meith/install": "0.35.1",
66
+ "@meith/mail": "0.35.1",
67
+ "@meith/markdown": "0.35.1",
68
+ "@meith/marketplace": "0.35.1",
69
+ "@meith/messages": "0.35.1",
70
+ "@meith/moderation": "0.35.1",
71
+ "@meith/notifications": "0.35.1",
72
+ "@meith/plugin-calendar": "0.35.1",
73
+ "@meith/plugin-dues": "0.35.1",
74
+ "@meith/plugin-kit": "0.35.1",
75
+ "@meith/polls": "0.35.1",
76
+ "@meith/posts": "0.35.1",
77
+ "@meith/profile-fields": "0.35.1",
78
+ "@meith/relations": "0.35.1",
79
+ "@meith/reputation": "0.35.1",
80
+ "@meith/runtime": "0.35.1",
81
+ "@meith/search": "0.35.1",
82
+ "@meith/settings": "0.35.1",
83
+ "@meith/signatures": "0.35.1",
84
+ "@meith/subscriptions": "0.35.1",
85
+ "@meith/tasks": "0.35.1",
86
+ "@meith/theme-clubhouse": "0.35.1",
87
+ "@meith/theme-default": "0.35.1",
88
+ "@meith/theme-kit": "0.35.1",
89
+ "@meith/theme-midnight": "0.35.1",
90
+ "@meith/theme-phasebook": "0.35.1",
91
+ "@meith/theme-raidframe": "0.35.1",
92
+ "@meith/threads": "0.35.1",
93
+ "@meith/ui": "0.35.1",
94
+ "@meith/upgrade": "0.35.1"
95
95
  },
96
96
  "scripts": {
97
97
  "dev": "next dev",
@@ -0,0 +1,65 @@
1
+ 'use client'
2
+
3
+ import { useActionState } from 'react'
4
+
5
+ import {
6
+ Alert,
7
+ AlertDescription,
8
+ Card,
9
+ CardContent,
10
+ CardDescription,
11
+ CardHeader,
12
+ CardTitle,
13
+ } from '@meith/ui'
14
+ import { Button } from '@meith/ui/button'
15
+
16
+ import { useFocusOnFail } from '@/components/auth/form-controls'
17
+ import { type InstallUnlockState, installUnlockAction } from '@/server/install-actions'
18
+
19
+ const EMPTY: InstallUnlockState = {}
20
+
21
+ export interface InstallUnlockCopy {
22
+ readonly title: string
23
+ readonly lede: string
24
+ readonly label: string
25
+ readonly button: string
26
+ readonly pending: string
27
+ }
28
+
29
+ export function InstallUnlockForm({ copy }: { copy: InstallUnlockCopy }) {
30
+ const [state, submit, pending] = useActionState(installUnlockAction, EMPTY)
31
+ const errorRef = useFocusOnFail<HTMLDivElement>(state.error !== undefined)
32
+
33
+ return (
34
+ <Card aria-labelledby="install-unlock">
35
+ <CardHeader>
36
+ <CardTitle id="install-unlock">{copy.title}</CardTitle>
37
+ <CardDescription>{copy.lede}</CardDescription>
38
+ </CardHeader>
39
+ <CardContent>
40
+ <form action={submit} className="flex flex-col gap-4">
41
+ {state.error !== undefined && (
42
+ <Alert tone="error" ref={errorRef} tabIndex={-1}>
43
+ <AlertDescription>{state.error}</AlertDescription>
44
+ </Alert>
45
+ )}
46
+ <label className="flex flex-col gap-1 text-sm font-medium">
47
+ {copy.label}
48
+ <input
49
+ type="password"
50
+ name="secret"
51
+ required
52
+ autoComplete="off"
53
+ className="rounded-md border border-border bg-background px-3 py-2 font-mono text-sm"
54
+ />
55
+ </label>
56
+ <div>
57
+ <Button type="submit" size="lg" disabled={pending}>
58
+ {pending ? copy.pending : copy.button}
59
+ </Button>
60
+ </div>
61
+ </form>
62
+ </CardContent>
63
+ </Card>
64
+ )
65
+ }
@@ -55,28 +55,33 @@ export async function adminSignInAction(_prev: FormState, form: FormData): Promi
55
55
  const config = await boardAuthConfig()
56
56
  const attemptBucket = await adminReauthenticationBucket(actor.userId)
57
57
  const attemptSince = new Date(Date.now() - config.lockoutMinutes * 60_000)
58
- if (
59
- config.maxLoginAttempts > 0 &&
60
- (await accountStore.loginAttempts.countFailuresSince(attemptBucket, attemptSince)) >=
61
- config.maxLoginAttempts
62
- ) {
63
- throw new ForbiddenError(msg('error.accounts.too-many-failed-attempts-please'))
58
+
59
+ let attemptId: number | null = null
60
+ if (config.maxLoginAttempts > 0) {
61
+ const spent = await accountStore.loginAttempts.recordFailureAndCount(
62
+ attemptBucket,
63
+ attemptSince,
64
+ new Date(),
65
+ )
66
+ attemptId = spent.id
67
+ if (spent.count > config.maxLoginAttempts) {
68
+ throw new ForbiddenError(msg('error.accounts.too-many-failed-attempts-please'))
69
+ }
64
70
  }
65
71
 
66
- const failAttempt = async (): Promise<void> => {
67
- await accountStore.loginAttempts.record(attemptBucket, false, new Date())
72
+ const auditFailure = async (): Promise<void> => {
68
73
  await recordAdminAction({ action: 'admin.signin_failed' })
69
74
  }
70
75
 
71
76
  const password = text(form, 'password')
72
77
  if (password === '') {
73
- await failAttempt()
78
+ await auditFailure()
74
79
  throw new ValidationError(msg('error.app.enter-password'))
75
80
  }
76
81
 
77
82
  const ok = await verifyPassword(password, account.passwordHash)
78
83
  if (!ok) {
79
- await failAttempt()
84
+ await auditFailure()
80
85
  throw new ForbiddenError(msg('error.app.password-right'))
81
86
  }
82
87
 
@@ -84,11 +89,12 @@ export async function adminSignInAction(_prev: FormState, form: FormData): Promi
84
89
  const twoFactor = twoFactorService()
85
90
 
86
91
  if (twoFactor !== null && (await twoFactor.isEnrolled(actor.userId))) {
92
+ if (attemptId !== null) await accountStore.loginAttempts.removeAttempt(attemptId)
87
93
  await holdAdminSecondFactor(actor.userId, next)
88
94
  target = '/admin'
89
95
  } else {
90
96
  if (twoFactor !== null && (await twoFactorRequiredForStaff())) {
91
- await failAttempt()
97
+ await auditFailure()
92
98
  throw new ForbiddenError(msg('adminAction.twoFactorRequired'))
93
99
  }
94
100
 
@@ -133,28 +139,31 @@ export async function adminVerifySecondFactorAction(
133
139
  const config = await boardAuthConfig()
134
140
  const attemptBucket = await adminReauthenticationBucket(actor.userId)
135
141
  const attemptSince = new Date(Date.now() - config.lockoutMinutes * 60_000)
136
- if (
137
- config.maxLoginAttempts > 0 &&
138
- (await accountStore.loginAttempts.countFailuresSince(attemptBucket, attemptSince)) >=
139
- config.maxLoginAttempts
140
- ) {
141
- throw new ForbiddenError(msg('error.accounts.too-many-failed-attempts-please'))
142
+
143
+ if (config.maxLoginAttempts > 0) {
144
+ const spent = await accountStore.loginAttempts.recordFailureAndCount(
145
+ attemptBucket,
146
+ attemptSince,
147
+ new Date(),
148
+ )
149
+ if (spent.count > config.maxLoginAttempts) {
150
+ throw new ForbiddenError(msg('error.accounts.too-many-failed-attempts-please'))
151
+ }
142
152
  }
143
153
 
144
- const failAttempt = async (): Promise<void> => {
145
- await accountStore.loginAttempts.record(attemptBucket, false, new Date())
154
+ const auditFailure = async (): Promise<void> => {
146
155
  await recordAdminAction({ action: 'admin.signin_failed' })
147
156
  }
148
157
 
149
158
  const code = text(form, 'code')
150
159
  if (code === '') {
151
- await failAttempt()
160
+ await auditFailure()
152
161
  throw new ValidationError(msg('error.app.enter-code-from-authenticator-app'))
153
162
  }
154
163
 
155
164
  const outcome = await twoFactor.verify({ userId: pending.userId, code })
156
165
  if (outcome.status !== 'ok') {
157
- await failAttempt()
166
+ await auditFailure()
158
167
  throw new ForbiddenError(
159
168
  msg(outcome.status === 'replayed' ? 'adminAction.codeReplayed' : 'adminAction.codeWrong'),
160
169
  )
@@ -125,6 +125,8 @@ export function dailyLimitMessage(
125
125
  : `You have used your allowance of ${noun} for today. It resets in ${hours} hours.`
126
126
  }
127
127
 
128
+ const CREDENTIAL_PROOF_PER_HOUR = 10
129
+
128
130
  type AuthLimitSetting =
129
131
  | 'antispam.register_ip_per_hour'
130
132
  | 'antispam.reset_per_hour'
@@ -173,6 +175,22 @@ export async function spendResetLimits(
173
175
  ]
174
176
  }
175
177
 
178
+ export async function spendCredentialProofLimit(userId: number): Promise<RateLimitOutcome | null> {
179
+ const store = rateLimitStore()
180
+ if (store === null) return null
181
+
182
+ try {
183
+ return await new RateLimiter(store).consume({
184
+ scope: 'credential_proof',
185
+ subject: `u:${userId}`,
186
+ rule: { max: CREDENTIAL_PROOF_PER_HOUR, windowSeconds: HOUR },
187
+ })
188
+ } catch (error) {
189
+ logger().warn({ err: String(error) }, 'credential-proof rate limit unavailable')
190
+ return null
191
+ }
192
+ }
193
+
176
194
  export async function spendRegisterLimit(): Promise<RateLimitOutcome | null> {
177
195
  const prefix = await countingPrefix()
178
196
 
@@ -52,8 +52,11 @@ async function threadScope(actor: Actor, threadId: number): Promise<ThreadScope
52
52
  return null
53
53
  }
54
54
 
55
+ const scope = authorizer.contentScope(actor, target)
56
+ if (!scope.states.includes(located.visibility)) return null
57
+
55
58
  return {
56
- scope: authorizer.contentScope(actor, target),
59
+ scope,
57
60
  authors: authorizer.authorFilter(actor, target),
58
61
  }
59
62
  }
@@ -49,8 +49,8 @@ export const SUBSCRIPTION_HANDLERS: ApiRoutes = [
49
49
  '/subscriptions',
50
50
  async ({ actor }): Promise<ApiResult> => {
51
51
  const userId = requireUserId(actor)
52
- const visibleForumIds = await getContainer().authorizer.visibleForumIds(actor)
53
- const rows = await requireSubscriptions().list(userId, visibleForumIds)
52
+ const audience = await getContainer().authorizer.threadAudience(actor)
53
+ const rows = await requireSubscriptions().list(userId, audience)
54
54
  const wordFilter = await activeWordFilter()
55
55
 
56
56
  return {
@@ -4,6 +4,7 @@ import { ForbiddenError, ValidationError } from '@meith/core'
4
4
  import { msg } from '@meith/i18n'
5
5
 
6
6
  import { attachmentHref, attachmentModel } from '../view/attachments'
7
+ import { limitMessage, spendLimit } from './antispam'
7
8
  import { acceptSingleFile, attachmentLimits, attachmentService, canAttach } from './attachments'
8
9
  import { getContainer } from './container'
9
10
  import { getActor } from './context'
@@ -42,6 +43,9 @@ export async function uploadInlineAttachmentAction(
42
43
 
43
44
  if (!canAttach(actor, scope)) throw new ForbiddenError(msg('error.app.attach-files-forum'))
44
45
 
46
+ const limited = await spendLimit({ scope: 'upload', actor })
47
+ if (limited !== null && !limited.allowed) throw new ValidationError(limitMessage(limited))
48
+
45
49
  const accepted = await acceptSingleFile(form, attachmentLimits(scope))
46
50
 
47
51
  const objections = await filterView('attachment.upload.validate', [], {
@@ -1,6 +1,7 @@
1
1
  'use server'
2
2
 
3
3
  import { redirect } from 'next/navigation'
4
+ import { after } from 'next/server'
4
5
 
5
6
  import { type AuthConfig, foldIdentifier, hashToken, type LoginBucket } from '@meith/accounts'
6
7
  import { env, logger } from '@meith/core'
@@ -24,6 +25,7 @@ import { revokeFeedToken } from './feed-token'
24
25
  import { formStateReporter } from './form-state-reporter'
25
26
  import { getTranslator, tr } from './i18n'
26
27
  import { termsAcceptance } from './legal'
28
+ import { notificationService } from './notifications'
27
29
  import { emitEvent, filterView } from './plugin-view'
28
30
  import { profileFieldService, registrationFieldContext, submittedFields } from './profile-fields'
29
31
  import {
@@ -297,7 +299,7 @@ async function completeSignIn(
297
299
 
298
300
  if (remember) {
299
301
  const sessions = await configuredSessions()
300
- const remembered = await sessions.startRemembered(login.account.id, await deviceContext())
302
+ const remembered = await sessions.issueRemember(login.account.id)
301
303
  await setRememberCookie(remembered.rememberToken, remembered.rememberExpiresAt)
302
304
  }
303
305
 
@@ -343,12 +345,11 @@ export async function verifySecondFactorAction(
343
345
  if (service === null) return { error: await tr('authAction.secondFactorExpired') }
344
346
 
345
347
  try {
346
- await identity.assertSecondFactorAttemptsLeft(pending.userId)
348
+ await identity.spendSecondFactorAttempt(pending.userId)
347
349
 
348
350
  const outcome = await service.verify({ userId: pending.userId, code })
349
351
 
350
352
  if (outcome.status !== 'ok') {
351
- await identity.recordSecondFactorFailure(pending.userId)
352
353
  await recordAuthEvent({ userId: pending.userId, kind: 'second_factor_failed' })
353
354
  return {
354
355
  error: await tr(
@@ -439,9 +440,10 @@ export async function requestResetAction(_prev: FormState, form: FormData): Prom
439
440
  const { token, userId } = await identity.requestPasswordReset(email)
440
441
 
441
442
  if (token !== null && userId !== null) {
442
- const account = await getContainer().accountStore.accounts.findById(userId)
443
- if (account !== null) {
443
+ const deliver = async (): Promise<void> => {
444
444
  try {
445
+ const account = await getContainer().accountStore.accounts.findById(userId)
446
+ if (account === null) return
445
447
  await sendPasswordResetEmail({
446
448
  token,
447
449
  email: account.email,
@@ -455,6 +457,12 @@ export async function requestResetAction(_prev: FormState, form: FormData): Prom
455
457
  )
456
458
  }
457
459
  }
460
+
461
+ try {
462
+ after(deliver)
463
+ } catch {
464
+ await deliver()
465
+ }
458
466
  }
459
467
 
460
468
  if (token && env.NODE_ENV === 'development') {
@@ -479,6 +487,7 @@ export async function confirmResetAction(_prev: FormState, form: FormData): Prom
479
487
  try {
480
488
  const { userId } = await identity.redeemPasswordReset(token, password)
481
489
  await revokeFeedToken(userId)
490
+ await notificationService()?.unsubscribeAllFromPush(userId)
482
491
  await recordAuthEvent({ userId, kind: 'password_reset' })
483
492
  } catch (err) {
484
493
  return toFormState(err, { token })
@@ -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'))
@@ -54,7 +54,9 @@ export async function federationProvider(id: string): Promise<IdentityProvider |
54
54
  if (!isProviderKind(id)) return null
55
55
  if (getContainer().dataSource !== 'postgres') return null
56
56
 
57
- return providerFor(id, federationOptions(await getSettingsUncached()))
57
+ return providerFor(id, federationOptions(await getSettingsUncached()), {
58
+ allowPrivateHosts: env.OIDC_ALLOW_PRIVATE_HOSTS,
59
+ })
58
60
  }
59
61
 
60
62
  export async function signInProviders(): Promise<readonly ProviderButton[]> {
@@ -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())) {
@@ -5,6 +5,7 @@ import { isAppError, logger } from '@meith/core'
5
5
  import { getTranslator } from './i18n'
6
6
  import { installerIsSealed } from './install'
7
7
  import { type InstallRestoreOutcome, runInstallRestore } from './install-restore'
8
+ import { installUnlocked } from './install-unlock'
8
9
 
9
10
  export interface InstallRestoreState {
10
11
  readonly error?: string
@@ -17,6 +18,7 @@ export async function installRestoreAction(
17
18
  ): Promise<InstallRestoreState> {
18
19
  const t = await getTranslator()
19
20
  if (await installerIsSealed()) return { error: t.t('installRestore.alreadyInstalled') }
21
+ if (!(await installUnlocked())) return { error: t.t('installUnlock.required') }
20
22
 
21
23
  const raw = form.get('bundle')
22
24
  const name = typeof raw === 'string' ? raw.trim() : ''
@@ -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)}`
@@ -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.34.0'
20
+ export const CODE_VERSION = '0.35.1'
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) {