@meith/web 0.33.4 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/(board)/subscriptions/page.tsx +2 -2
- package/app/(board)/thread/[slug]/reply/page.tsx +5 -1
- package/app/admin/settings/page.tsx +9 -0
- package/app/admin/system/backups/[name]/route.ts +11 -0
- package/app/admin/system/backups/page.tsx +298 -0
- package/app/admin/system/page.tsx +4 -1
- package/app/auth/passkey/verify/route.ts +32 -2
- package/app/install/page.tsx +45 -2
- package/next.config.mjs +1 -0
- package/package.json +50 -49
- package/src/components/admin/backup-forms.tsx +87 -0
- package/src/components/install/restore-form.tsx +154 -0
- 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/backup-admin-actions.ts +115 -0
- package/src/server/backup-admin.ts +157 -0
- package/src/server/backup-download.ts +66 -0
- 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 +42 -0
- package/src/server/install-restore.ts +186 -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
- package/src/view/admin-nav.ts +1 -0
- package/src/view/admin-panel-copy.ts +16 -0
- package/src/view/install-copy.ts +35 -0
- package/src/view/setting-groups.ts +2 -0
|
@@ -1,37 +1,70 @@
|
|
|
1
1
|
import 'server-only'
|
|
2
2
|
|
|
3
|
+
import { createHmac } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
import { env, timingSafeEqualString } from '@meith/core'
|
|
6
|
+
|
|
3
7
|
export type PasskeyPurpose = 'register' | 'authenticate' | 'second-factor' | 'credential-proof'
|
|
4
8
|
|
|
9
|
+
export const PASSKEY_CHALLENGE_TTL_MS = 10 * 60 * 1000
|
|
10
|
+
|
|
11
|
+
export const PASSKEY_CHALLENGE_TTL_SECONDS = PASSKEY_CHALLENGE_TTL_MS / 1000
|
|
12
|
+
|
|
13
|
+
const CLOCK_SKEW_MS = 60_000
|
|
14
|
+
|
|
5
15
|
export interface PasskeyChallenge {
|
|
6
16
|
readonly challenge: string
|
|
17
|
+
readonly issuedAt: number
|
|
7
18
|
readonly userId?: number
|
|
8
19
|
readonly sessionId?: number
|
|
9
20
|
readonly provedAt?: number
|
|
10
21
|
}
|
|
11
22
|
|
|
23
|
+
function signature(purpose: PasskeyPurpose, payload: string): string {
|
|
24
|
+
return createHmac('sha256', env.AUTH_SECRET ?? '')
|
|
25
|
+
.update(`${purpose}:${payload}`)
|
|
26
|
+
.digest('base64url')
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
export function packChallenge(
|
|
13
30
|
purpose: PasskeyPurpose,
|
|
14
31
|
challenge: string,
|
|
15
|
-
binding: Omit<PasskeyChallenge, 'challenge'> = {},
|
|
32
|
+
binding: Omit<PasskeyChallenge, 'challenge' | 'issuedAt'> = {},
|
|
33
|
+
now: number = Date.now(),
|
|
16
34
|
): string {
|
|
17
|
-
|
|
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) {
|
package/src/view/admin-nav.ts
CHANGED
|
@@ -115,6 +115,7 @@ export const ADMIN_SECTIONS: PanelNav = [
|
|
|
115
115
|
titleKey: 'adminNav.admin-system.title',
|
|
116
116
|
icon: 'system',
|
|
117
117
|
blurbKey: 'adminNav.admin-system.blurb',
|
|
118
|
+
children: [{ href: '/admin/system/backups', titleKey: 'adminNav.admin-system-backups.title' }],
|
|
118
119
|
},
|
|
119
120
|
{
|
|
120
121
|
href: '/admin/log',
|
|
@@ -75,6 +75,22 @@ export function webhookFormsCopy(t: Translator = untranslated()): Readonly<Recor
|
|
|
75
75
|
)
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
export function backupFormsCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
|
|
79
|
+
return {
|
|
80
|
+
...copyFor(
|
|
81
|
+
[
|
|
82
|
+
'adminPanel.backup.now',
|
|
83
|
+
'adminPanel.backup.queued',
|
|
84
|
+
'adminPanel.backup.already',
|
|
85
|
+
'adminPanel.backup.delete',
|
|
86
|
+
'adminPanel.backup.test',
|
|
87
|
+
],
|
|
88
|
+
t,
|
|
89
|
+
),
|
|
90
|
+
...patternCopy(['adminPanel.backup.deleted', 'adminPanel.backup.reachable'], t),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
export function systemFormsCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
|
|
79
95
|
return {
|
|
80
96
|
...copyFor(
|
package/src/view/install-copy.ts
CHANGED
|
@@ -96,3 +96,38 @@ export function installFormCopy(
|
|
|
96
96
|
'install.mail.testNoteTail': testNoteTail,
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
export function installRestoreCopy(
|
|
101
|
+
t: Translator = untranslated(),
|
|
102
|
+
): Readonly<Record<string, string>> {
|
|
103
|
+
return {
|
|
104
|
+
...copyFor(
|
|
105
|
+
[
|
|
106
|
+
'install.restore.title',
|
|
107
|
+
'install.restore.hint',
|
|
108
|
+
'install.restore.pick',
|
|
109
|
+
'install.restore.confirm',
|
|
110
|
+
'install.restore.submit',
|
|
111
|
+
'install.restore.restoring',
|
|
112
|
+
'install.restore.pendingNote',
|
|
113
|
+
'install.restore.idleNote',
|
|
114
|
+
'install.restore.notRestored',
|
|
115
|
+
'install.restore.noneLocal',
|
|
116
|
+
'install.restore.doneTitle',
|
|
117
|
+
'install.restore.noUploads',
|
|
118
|
+
'install.restore.next',
|
|
119
|
+
'install.restore.signIn',
|
|
120
|
+
],
|
|
121
|
+
t,
|
|
122
|
+
),
|
|
123
|
+
...patternCopy(
|
|
124
|
+
[
|
|
125
|
+
'install.restore.noneAnywhere',
|
|
126
|
+
'install.restore.doneDetail',
|
|
127
|
+
'install.restore.migrated',
|
|
128
|
+
'install.restore.skipped',
|
|
129
|
+
],
|
|
130
|
+
t,
|
|
131
|
+
),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -12,6 +12,7 @@ export const GROUP_LABELS: Record<SettingGroup, string> = {
|
|
|
12
12
|
federation: 'Sign-in providers',
|
|
13
13
|
antispam: 'Anti-spam',
|
|
14
14
|
push: 'Push',
|
|
15
|
+
backup: 'Backups',
|
|
15
16
|
legal: 'Legal',
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -27,6 +28,7 @@ export const GROUP_ORDER: readonly SettingGroup[] = [
|
|
|
27
28
|
'federation',
|
|
28
29
|
'antispam',
|
|
29
30
|
'push',
|
|
31
|
+
'backup',
|
|
30
32
|
'legal',
|
|
31
33
|
]
|
|
32
34
|
|