@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.
- package/app/(board)/subscriptions/page.tsx +2 -2
- package/app/(board)/thread/[slug]/reply/page.tsx +5 -1
- package/app/auth/passkey/verify/route.ts +33 -4
- package/app/install/page.tsx +17 -2
- package/package.json +50 -50
- package/src/components/install/unlock-form.tsx +65 -0
- package/src/server/admin-actions.ts +30 -21
- package/src/server/antispam.ts +18 -0
- package/src/server/api/content.ts +4 -1
- package/src/server/api/subscriptions.ts +2 -2
- package/src/server/attachment-upload-actions.ts +4 -0
- package/src/server/auth-actions.ts +14 -5
- package/src/server/content-actions.ts +45 -18
- package/src/server/credential-proof-actions.ts +4 -0
- package/src/server/credential-proof.ts +11 -0
- package/src/server/federation-actions.ts +5 -0
- package/src/server/federation.ts +3 -1
- package/src/server/fixture-thread-repo.ts +3 -1
- package/src/server/install-actions.ts +25 -1
- package/src/server/install-restore-actions.ts +2 -0
- package/src/server/install-unlock.ts +55 -0
- package/src/server/message-actions.ts +1 -1
- package/src/server/passkey-challenge.ts +36 -3
- package/src/server/poll-scope.ts +6 -0
- package/src/server/post-notifications.ts +10 -2
- package/src/server/post-scope.ts +4 -0
- package/src/server/reply-core.ts +2 -0
- package/src/server/thread-core.ts +2 -0
- package/src/server/thread-rating-actions.ts +6 -0
- package/src/server/two-factor-actions.ts +5 -0
- package/src/server/upgrade-notice.ts +1 -1
- package/src/server/user-admin-actions.ts +9 -9
- package/src/server/usercp-actions.ts +2 -0
|
@@ -34,8 +34,8 @@ export default async function SubscriptionsPage({
|
|
|
34
34
|
|
|
35
35
|
if (actor.userId === null || subscriptions === null) notFound()
|
|
36
36
|
|
|
37
|
-
const
|
|
38
|
-
const rows = await new SubscriptionService({ subscriptions }).list(actor.userId,
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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()).
|
|
241
|
+
const remembered = await (await configuredSessions()).issueRemember(pending.userId)
|
|
213
242
|
await setRememberCookie(remembered.rememberToken, remembered.rememberExpiresAt)
|
|
214
243
|
}
|
|
215
244
|
|
package/app/install/page.tsx
CHANGED
|
@@ -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 =
|
|
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
|
-
{
|
|
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.
|
|
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.
|
|
47
|
-
"@meith/admin": "0.
|
|
48
|
-
"@meith/antispam": "0.
|
|
49
|
-
"@meith/api": "0.
|
|
50
|
-
"@meith/attachments": "0.
|
|
51
|
-
"@meith/authorization": "0.
|
|
52
|
-
"@meith/avatars": "0.
|
|
53
|
-
"@meith/backup": "0.
|
|
54
|
-
"@meith/board-digest": "0.
|
|
55
|
-
"@meith/core": "0.
|
|
56
|
-
"@meith/db": "0.
|
|
57
|
-
"@meith/demo": "0.
|
|
58
|
-
"@meith/drafts": "0.
|
|
59
|
-
"@meith/drivers": "0.
|
|
60
|
-
"@meith/events": "0.
|
|
61
|
-
"@meith/forums": "0.
|
|
62
|
-
"@meith/groups": "0.
|
|
63
|
-
"@meith/i18n": "0.
|
|
64
|
-
"@meith/import": "0.
|
|
65
|
-
"@meith/install": "0.
|
|
66
|
-
"@meith/mail": "0.
|
|
67
|
-
"@meith/markdown": "0.
|
|
68
|
-
"@meith/marketplace": "0.
|
|
69
|
-
"@meith/messages": "0.
|
|
70
|
-
"@meith/moderation": "0.
|
|
71
|
-
"@meith/notifications": "0.
|
|
72
|
-
"@meith/plugin-calendar": "0.
|
|
73
|
-
"@meith/plugin-dues": "0.
|
|
74
|
-
"@meith/plugin-kit": "0.
|
|
75
|
-
"@meith/polls": "0.
|
|
76
|
-
"@meith/posts": "0.
|
|
77
|
-
"@meith/profile-fields": "0.
|
|
78
|
-
"@meith/relations": "0.
|
|
79
|
-
"@meith/reputation": "0.
|
|
80
|
-
"@meith/runtime": "0.
|
|
81
|
-
"@meith/search": "0.
|
|
82
|
-
"@meith/settings": "0.
|
|
83
|
-
"@meith/signatures": "0.
|
|
84
|
-
"@meith/subscriptions": "0.
|
|
85
|
-
"@meith/tasks": "0.
|
|
86
|
-
"@meith/theme-clubhouse": "0.
|
|
87
|
-
"@meith/theme-default": "0.
|
|
88
|
-
"@meith/theme-kit": "0.
|
|
89
|
-
"@meith/theme-midnight": "0.
|
|
90
|
-
"@meith/theme-phasebook": "0.
|
|
91
|
-
"@meith/theme-raidframe": "0.
|
|
92
|
-
"@meith/threads": "0.
|
|
93
|
-
"@meith/ui": "0.
|
|
94
|
-
"@meith/upgrade": "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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
|
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
|
|
166
|
+
await auditFailure()
|
|
158
167
|
throw new ForbiddenError(
|
|
159
168
|
msg(outcome.status === 'replayed' ? 'adminAction.codeReplayed' : 'adminAction.codeWrong'),
|
|
160
169
|
)
|
package/src/server/antispam.ts
CHANGED
|
@@ -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
|
|
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
|
|
53
|
-
const rows = await requireSubscriptions().list(userId,
|
|
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.
|
|
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.
|
|
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
|
|
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, '
|
|
87
|
-
|
|
88
|
-
threadAuthorId: target.authorUserId,
|
|
89
|
-
})
|
|
92
|
+
(target.visibility === 'deleted' && !authorizer.can(actor, 'content.viewDeleted', scope)) ||
|
|
93
|
+
(target.visibility === 'unapproved' && !authorizer.can(actor, 'content.viewUnapproved', scope))
|
|
90
94
|
)
|
|
91
95
|
return null
|
|
92
96
|
|
|
@@ -151,6 +155,20 @@ function pollClosingTime(value: string): Date | null {
|
|
|
151
155
|
|
|
152
156
|
const toFormState = formStateReporter('content-actions', 'unexpected error writing content')
|
|
153
157
|
|
|
158
|
+
async function finaliseNewPost(
|
|
159
|
+
kind: 'thread' | 'reply',
|
|
160
|
+
finalise: () => Promise<void>,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
try {
|
|
163
|
+
await finalise()
|
|
164
|
+
} catch (err) {
|
|
165
|
+
logger().error(
|
|
166
|
+
{ err: String(err), kind },
|
|
167
|
+
'content saved but attaching uploads or clearing the draft failed',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
154
172
|
export interface ComposerAutosaveInput {
|
|
155
173
|
readonly forumId?: number
|
|
156
174
|
readonly threadId?: number
|
|
@@ -244,9 +262,11 @@ export async function createThreadAction(_prev: FormState, form: FormData): Prom
|
|
|
244
262
|
|
|
245
263
|
let created: Awaited<ReturnType<typeof submitThread>>
|
|
246
264
|
let resolved: Awaited<ReturnType<typeof resolveThreadTarget>>
|
|
265
|
+
let staged: Awaited<ReturnType<typeof stageAttachments>>
|
|
266
|
+
let userId: number
|
|
247
267
|
try {
|
|
248
268
|
resolved = await resolveThreadTarget(actor, forumId)
|
|
249
|
-
|
|
269
|
+
userId = actor.userId!
|
|
250
270
|
|
|
251
271
|
if (intent === 'save_draft') {
|
|
252
272
|
if (drafts === null) throw new ValidationError(msg('error.app.drafts-unavailable-board'))
|
|
@@ -256,7 +276,7 @@ export async function createThreadAction(_prev: FormState, form: FormData): Prom
|
|
|
256
276
|
|
|
257
277
|
const pollClosesAt = pollClosingTime(poll.closesAt)
|
|
258
278
|
|
|
259
|
-
|
|
279
|
+
staged = await stageAttachments(actor, resolved.scope, await submittedFiles(form))
|
|
260
280
|
|
|
261
281
|
const subscribeMode = subscribe
|
|
262
282
|
? autoWatchCadence(await autoWatchPreference(userId, 'create'))
|
|
@@ -280,7 +300,11 @@ export async function createThreadAction(_prev: FormState, form: FormData): Prom
|
|
|
280
300
|
publicVotes: poll.publicVotes,
|
|
281
301
|
},
|
|
282
302
|
})
|
|
303
|
+
} catch (err) {
|
|
304
|
+
return { ...(await toFormState(err, values)), poll }
|
|
305
|
+
}
|
|
283
306
|
|
|
307
|
+
await finaliseNewPost('thread', async () => {
|
|
284
308
|
const attached = await attachStaged(staged, { postId: created.postId, forumId, userId })
|
|
285
309
|
await claimAttachments(
|
|
286
310
|
form,
|
|
@@ -289,9 +313,7 @@ export async function createThreadAction(_prev: FormState, form: FormData): Prom
|
|
|
289
313
|
attached.length,
|
|
290
314
|
)
|
|
291
315
|
await drafts?.remove(userId, forumId, null)
|
|
292
|
-
}
|
|
293
|
-
return { ...(await toFormState(err, values)), poll }
|
|
294
|
-
}
|
|
316
|
+
})
|
|
295
317
|
|
|
296
318
|
if (created.visibility === 'unapproved') {
|
|
297
319
|
redirect(`/${resolved.forum.id}-${resolved.forum.slug}?posted=moderated`)
|
|
@@ -316,6 +338,7 @@ export async function createReplyAction(_prev: FormState, form: FormData): Promi
|
|
|
316
338
|
const { drafts } = getContainer()
|
|
317
339
|
|
|
318
340
|
let created: Awaited<ReturnType<typeof submitReply>>
|
|
341
|
+
let finalise: () => Promise<void>
|
|
319
342
|
try {
|
|
320
343
|
const resolved = await resolveReplyTarget(actor, threadId)
|
|
321
344
|
const { forumId, scope } = resolved
|
|
@@ -341,18 +364,22 @@ export async function createReplyAction(_prev: FormState, form: FormData): Promi
|
|
|
341
364
|
seenLastPostId,
|
|
342
365
|
})
|
|
343
366
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
367
|
+
finalise = async () => {
|
|
368
|
+
const attached = await attachStaged(staged, { postId: created.postId, forumId, userId })
|
|
369
|
+
await claimAttachments(
|
|
370
|
+
form,
|
|
371
|
+
{ postId: created.postId, forumId, userId },
|
|
372
|
+
scope,
|
|
373
|
+
attached.length,
|
|
374
|
+
)
|
|
375
|
+
await drafts?.remove(userId, forumId, threadId)
|
|
376
|
+
}
|
|
352
377
|
} catch (err) {
|
|
353
378
|
return toFormState(err, values)
|
|
354
379
|
}
|
|
355
380
|
|
|
381
|
+
await finaliseNewPost('reply', finalise)
|
|
382
|
+
|
|
356
383
|
const thread = `/thread/${created.threadId}-${created.slug}`
|
|
357
384
|
if (created.visibility === 'unapproved') {
|
|
358
385
|
redirect(`${thread}?posted=moderated`)
|
|
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'
|
|
|
4
4
|
|
|
5
5
|
import { verifyPassword } from '@meith/accounts'
|
|
6
6
|
|
|
7
|
+
import { limitMessage, refused, spendCredentialProofLimit } from './antispam'
|
|
7
8
|
import type { FormState } from './auth-form-state'
|
|
8
9
|
import { getContainer } from './container'
|
|
9
10
|
import { getActor } from './context'
|
|
@@ -17,6 +18,9 @@ export async function proveCredentialAction(_prev: FormState, form: FormData): P
|
|
|
17
18
|
const actor = await getActor()
|
|
18
19
|
if (actor.userId === null) return { error: await tr('credentialProof.signIn') }
|
|
19
20
|
|
|
21
|
+
const limited = await spendCredentialProofLimit(actor.userId)
|
|
22
|
+
if (refused(limited)) return { error: limitMessage(limited) }
|
|
23
|
+
|
|
20
24
|
const method = text(form, 'method')
|
|
21
25
|
let proved = false
|
|
22
26
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import 'server-only'
|
|
2
2
|
|
|
3
|
+
import { redirect } from 'next/navigation'
|
|
4
|
+
|
|
3
5
|
import { hasFreshCredentialProof, hashToken, type SessionRecord } from '@meith/accounts'
|
|
4
6
|
|
|
5
7
|
import { getContainer } from './container'
|
|
@@ -19,6 +21,15 @@ export async function currentCredentialProof(
|
|
|
19
21
|
return { session, provedAt: session.credentialProvedAt! }
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
export async function requireFreshCredentialProof(
|
|
25
|
+
userId: number,
|
|
26
|
+
next = '/usercp/security',
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
if ((await currentCredentialProof(userId)) === null) {
|
|
29
|
+
redirect(`/usercp/security/verify?next=${encodeURIComponent(next)}`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
export async function markCurrentSessionCredentialProved(
|
|
23
34
|
userId: number,
|
|
24
35
|
at = new Date(),
|
|
@@ -9,6 +9,7 @@ import { recordAuthEvent } from './auth-events'
|
|
|
9
9
|
import type { FormState } from './auth-form-state'
|
|
10
10
|
import { getContainer } from './container'
|
|
11
11
|
import { getActor } from './context'
|
|
12
|
+
import { requireFreshCredentialProof } from './credential-proof'
|
|
12
13
|
import { assertDemoAccountChangeable } from './demo'
|
|
13
14
|
import {
|
|
14
15
|
federationService,
|
|
@@ -61,6 +62,8 @@ async function requireOwnAccount(): Promise<{
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
export async function unlinkIdentityAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
65
|
+
const actor = await getActor()
|
|
66
|
+
if (actor.userId !== null) await requireFreshCredentialProof(actor.userId)
|
|
64
67
|
try {
|
|
65
68
|
const { userId, hasPassword } = await requireOwnAccount()
|
|
66
69
|
const identityId = Number(text(form, 'identityId'))
|
|
@@ -84,6 +87,8 @@ export async function unlinkIdentityAction(_prev: FormState, form: FormData): Pr
|
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
export async function removePasskeyAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
90
|
+
const actor = await getActor()
|
|
91
|
+
if (actor.userId !== null) await requireFreshCredentialProof(actor.userId)
|
|
87
92
|
try {
|
|
88
93
|
const { userId, hasPassword } = await requireOwnAccount()
|
|
89
94
|
const passkeyId = Number(text(form, 'passkeyId'))
|
package/src/server/federation.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
package/src/server/poll-scope.ts
CHANGED
|
@@ -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,
|
package/src/server/post-scope.ts
CHANGED
|
@@ -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)
|
package/src/server/reply-core.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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) {
|