@meith/web 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/(board)/subscriptions/page.tsx +2 -2
- package/app/(board)/thread/[slug]/reply/page.tsx +5 -1
- package/app/auth/passkey/verify/route.ts +32 -2
- package/app/install/page.tsx +17 -2
- package/package.json +50 -50
- package/src/components/install/unlock-form.tsx +65 -0
- package/src/server/antispam.ts +18 -0
- package/src/server/api/content.ts +4 -1
- package/src/server/api/subscriptions.ts +2 -2
- package/src/server/attachment-upload-actions.ts +4 -0
- package/src/server/auth-actions.ts +3 -1
- package/src/server/content-actions.ts +45 -18
- package/src/server/credential-proof-actions.ts +4 -0
- package/src/server/credential-proof.ts +11 -0
- package/src/server/federation-actions.ts +5 -0
- package/src/server/fixture-thread-repo.ts +3 -1
- package/src/server/install-actions.ts +25 -1
- package/src/server/install-restore-actions.ts +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)
|
|
@@ -209,7 +239,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|
|
209
239
|
await setSessionCookie(login.sessionToken, login.expiresAt)
|
|
210
240
|
|
|
211
241
|
if (pending.remember) {
|
|
212
|
-
const remembered = await (await configuredSessions()).
|
|
242
|
+
const remembered = await (await configuredSessions()).issueRemember(pending.userId)
|
|
213
243
|
await setRememberCookie(remembered.rememberToken, remembered.rememberExpiresAt)
|
|
214
244
|
}
|
|
215
245
|
|
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.0",
|
|
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.0",
|
|
47
|
+
"@meith/admin": "0.35.0",
|
|
48
|
+
"@meith/antispam": "0.35.0",
|
|
49
|
+
"@meith/api": "0.35.0",
|
|
50
|
+
"@meith/attachments": "0.35.0",
|
|
51
|
+
"@meith/authorization": "0.35.0",
|
|
52
|
+
"@meith/avatars": "0.35.0",
|
|
53
|
+
"@meith/backup": "0.35.0",
|
|
54
|
+
"@meith/board-digest": "0.35.0",
|
|
55
|
+
"@meith/core": "0.35.0",
|
|
56
|
+
"@meith/db": "0.35.0",
|
|
57
|
+
"@meith/demo": "0.35.0",
|
|
58
|
+
"@meith/drafts": "0.35.0",
|
|
59
|
+
"@meith/drivers": "0.35.0",
|
|
60
|
+
"@meith/events": "0.35.0",
|
|
61
|
+
"@meith/forums": "0.35.0",
|
|
62
|
+
"@meith/groups": "0.35.0",
|
|
63
|
+
"@meith/i18n": "0.35.0",
|
|
64
|
+
"@meith/import": "0.35.0",
|
|
65
|
+
"@meith/install": "0.35.0",
|
|
66
|
+
"@meith/mail": "0.35.0",
|
|
67
|
+
"@meith/markdown": "0.35.0",
|
|
68
|
+
"@meith/marketplace": "0.35.0",
|
|
69
|
+
"@meith/messages": "0.35.0",
|
|
70
|
+
"@meith/moderation": "0.35.0",
|
|
71
|
+
"@meith/notifications": "0.35.0",
|
|
72
|
+
"@meith/plugin-calendar": "0.35.0",
|
|
73
|
+
"@meith/plugin-dues": "0.35.0",
|
|
74
|
+
"@meith/plugin-kit": "0.35.0",
|
|
75
|
+
"@meith/polls": "0.35.0",
|
|
76
|
+
"@meith/posts": "0.35.0",
|
|
77
|
+
"@meith/profile-fields": "0.35.0",
|
|
78
|
+
"@meith/relations": "0.35.0",
|
|
79
|
+
"@meith/reputation": "0.35.0",
|
|
80
|
+
"@meith/runtime": "0.35.0",
|
|
81
|
+
"@meith/search": "0.35.0",
|
|
82
|
+
"@meith/settings": "0.35.0",
|
|
83
|
+
"@meith/signatures": "0.35.0",
|
|
84
|
+
"@meith/subscriptions": "0.35.0",
|
|
85
|
+
"@meith/tasks": "0.35.0",
|
|
86
|
+
"@meith/theme-clubhouse": "0.35.0",
|
|
87
|
+
"@meith/theme-default": "0.35.0",
|
|
88
|
+
"@meith/theme-kit": "0.35.0",
|
|
89
|
+
"@meith/theme-midnight": "0.35.0",
|
|
90
|
+
"@meith/theme-phasebook": "0.35.0",
|
|
91
|
+
"@meith/theme-raidframe": "0.35.0",
|
|
92
|
+
"@meith/threads": "0.35.0",
|
|
93
|
+
"@meith/ui": "0.35.0",
|
|
94
|
+
"@meith/upgrade": "0.35.0"
|
|
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
|
+
}
|
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', [], {
|
|
@@ -24,6 +24,7 @@ import { revokeFeedToken } from './feed-token'
|
|
|
24
24
|
import { formStateReporter } from './form-state-reporter'
|
|
25
25
|
import { getTranslator, tr } from './i18n'
|
|
26
26
|
import { termsAcceptance } from './legal'
|
|
27
|
+
import { notificationService } from './notifications'
|
|
27
28
|
import { emitEvent, filterView } from './plugin-view'
|
|
28
29
|
import { profileFieldService, registrationFieldContext, submittedFields } from './profile-fields'
|
|
29
30
|
import {
|
|
@@ -297,7 +298,7 @@ async function completeSignIn(
|
|
|
297
298
|
|
|
298
299
|
if (remember) {
|
|
299
300
|
const sessions = await configuredSessions()
|
|
300
|
-
const remembered = await sessions.
|
|
301
|
+
const remembered = await sessions.issueRemember(login.account.id)
|
|
301
302
|
await setRememberCookie(remembered.rememberToken, remembered.rememberExpiresAt)
|
|
302
303
|
}
|
|
303
304
|
|
|
@@ -479,6 +480,7 @@ export async function confirmResetAction(_prev: FormState, form: FormData): Prom
|
|
|
479
480
|
try {
|
|
480
481
|
const { userId } = await identity.redeemPasswordReset(token, password)
|
|
481
482
|
await revokeFeedToken(userId)
|
|
483
|
+
await notificationService()?.unsubscribeAllFromPush(userId)
|
|
482
484
|
await recordAuthEvent({ userId, kind: 'password_reset' })
|
|
483
485
|
} catch (err) {
|
|
484
486
|
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'))
|
|
@@ -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.0'
|
|
21
21
|
|
|
22
22
|
export interface UpgradeApplied {
|
|
23
23
|
readonly plugins: readonly string[]
|
|
@@ -447,15 +447,15 @@ async function queueMassMailBatch(
|
|
|
447
447
|
bulk: ReturnType<typeof requireUserBulk>,
|
|
448
448
|
massMailId: number,
|
|
449
449
|
): Promise<{ state: FormState; sent: number; queued: number }> {
|
|
450
|
-
const chunk = await bulk.claimMassMailChunk(massMailId, MASS_MAIL_CHUNK)
|
|
451
|
-
|
|
452
|
-
|
|
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) {
|