@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
|
@@ -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)
|
|
@@ -8,6 +8,7 @@ import { MailTestCard } from '@/components/admin/mail-test-card'
|
|
|
8
8
|
import { AdminSettingsForm } from '@/components/admin/settings-form'
|
|
9
9
|
import { PanelPage } from '@/components/shell/panel-page'
|
|
10
10
|
import { adminPageContext } from '@/server/admin'
|
|
11
|
+
import { destinationIsFromEnvironment } from '@/server/backup-admin'
|
|
11
12
|
import { boardUrlResolution } from '@/server/board-url'
|
|
12
13
|
import { faviconKey, faviconSrc } from '@/server/branding'
|
|
13
14
|
import { getTranslator, tr } from '@/server/i18n'
|
|
@@ -54,6 +55,8 @@ export default async function AdminSettingsPage({
|
|
|
54
55
|
|
|
55
56
|
const favicon = model.activeGroup === 'board' ? await faviconKey() : null
|
|
56
57
|
|
|
58
|
+
const backupFromEnvironment = model.activeGroup === 'backup' && destinationIsFromEnvironment()
|
|
59
|
+
|
|
57
60
|
return (
|
|
58
61
|
<PanelPage title={await tr('page.board-settings')} lede={t.t('adminSettings.lede')}>
|
|
59
62
|
<Card>
|
|
@@ -189,6 +192,12 @@ export default async function AdminSettingsPage({
|
|
|
189
192
|
</section>
|
|
190
193
|
)}
|
|
191
194
|
|
|
195
|
+
{backupFromEnvironment && (
|
|
196
|
+
<section className="rounded-lg border border-border bg-muted/40 p-4 text-sm">
|
|
197
|
+
<p>{t.t('adminSettings.backupFromEnvironment')}</p>
|
|
198
|
+
</section>
|
|
199
|
+
)}
|
|
200
|
+
|
|
192
201
|
{model.activeGroup === 'board' && (
|
|
193
202
|
<section className="flex flex-col gap-3">
|
|
194
203
|
<div className="flex flex-col gap-1">
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { serveBackupDownload } from '@/server/backup-download'
|
|
2
|
+
|
|
3
|
+
export const dynamic = 'force-dynamic'
|
|
4
|
+
|
|
5
|
+
export async function GET(
|
|
6
|
+
_request: Request,
|
|
7
|
+
context: { params: Promise<{ name: string }> },
|
|
8
|
+
): Promise<Response> {
|
|
9
|
+
const { name } = await context.params
|
|
10
|
+
return serveBackupDownload(name)
|
|
11
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
|
|
3
|
+
import type { BackupRunRecord } from '@meith/backup'
|
|
4
|
+
import { formatScheduleTime } from '@meith/backup'
|
|
5
|
+
import { cn } from '@meith/ui'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
DeleteBackupForm,
|
|
9
|
+
RequestBackupForm,
|
|
10
|
+
TestDestinationForm,
|
|
11
|
+
} from '@/components/admin/backup-forms'
|
|
12
|
+
import { PANEL_CARD } from '@/components/shell/panel-list'
|
|
13
|
+
import { PanelPage } from '@/components/shell/panel-page'
|
|
14
|
+
import { adminPageContext } from '@/server/admin'
|
|
15
|
+
import { buildBackupAdminView } from '@/server/backup-admin'
|
|
16
|
+
import { getTranslator, tr } from '@/server/i18n'
|
|
17
|
+
import { backupFormsCopy } from '@/view/admin-panel-copy'
|
|
18
|
+
import { settingsHref } from '@/view/admin-settings'
|
|
19
|
+
import { formatBytes } from '@/view/attachments'
|
|
20
|
+
import { formatTime } from '@/view/time'
|
|
21
|
+
|
|
22
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
23
|
+
return { title: await tr('page.backups') }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const RUN_STATUS_KEYS = {
|
|
27
|
+
queued: 'adminBackups.run.queued',
|
|
28
|
+
running: 'adminBackups.run.running',
|
|
29
|
+
done: 'adminBackups.run.done',
|
|
30
|
+
incomplete: 'adminBackups.run.incomplete',
|
|
31
|
+
failed: 'adminBackups.run.failed',
|
|
32
|
+
} as const satisfies Record<BackupRunRecord['status'], string>
|
|
33
|
+
|
|
34
|
+
const RUN_TRIGGER_KEYS = {
|
|
35
|
+
manual: 'adminBackups.trigger.manual',
|
|
36
|
+
schedule: 'adminBackups.trigger.schedule',
|
|
37
|
+
upgrade: 'adminBackups.trigger.upgrade',
|
|
38
|
+
cli: 'adminBackups.trigger.cli',
|
|
39
|
+
} as const satisfies Record<BackupRunRecord['trigger'], string>
|
|
40
|
+
|
|
41
|
+
const WEEKDAY_KEYS: Readonly<Record<number, string>> = {
|
|
42
|
+
0: 'setting.backup.weekday.option.0',
|
|
43
|
+
1: 'setting.backup.weekday.option.1',
|
|
44
|
+
2: 'setting.backup.weekday.option.2',
|
|
45
|
+
3: 'setting.backup.weekday.option.3',
|
|
46
|
+
4: 'setting.backup.weekday.option.4',
|
|
47
|
+
5: 'setting.backup.weekday.option.5',
|
|
48
|
+
6: 'setting.backup.weekday.option.6',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DOWNLOAD_LINK =
|
|
52
|
+
'inline-flex h-8 items-center justify-center rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
53
|
+
|
|
54
|
+
export default async function AdminBackupsPage({
|
|
55
|
+
searchParams,
|
|
56
|
+
}: {
|
|
57
|
+
searchParams: Promise<{ notice?: string }>
|
|
58
|
+
}) {
|
|
59
|
+
if ((await adminPageContext()) === null) return null
|
|
60
|
+
|
|
61
|
+
const now = new Date()
|
|
62
|
+
const [translator, view, query] = await Promise.all([
|
|
63
|
+
getTranslator(),
|
|
64
|
+
buildBackupAdminView(now),
|
|
65
|
+
searchParams,
|
|
66
|
+
])
|
|
67
|
+
const copy = backupFormsCopy(translator)
|
|
68
|
+
const t = translator.t.bind(translator)
|
|
69
|
+
|
|
70
|
+
if (view === null) {
|
|
71
|
+
return (
|
|
72
|
+
<PanelPage
|
|
73
|
+
title={await tr('page.backups')}
|
|
74
|
+
back={{ href: '/admin/system', label: t('adminBackups.backToSystem') }}
|
|
75
|
+
>
|
|
76
|
+
<p className="mt-2 text-sm text-muted-foreground">{t('adminBackups.sample')}</p>
|
|
77
|
+
</PanelPage>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { settings } = view
|
|
82
|
+
const available = view.capability === 'available'
|
|
83
|
+
const time = formatScheduleTime(settings.schedule)
|
|
84
|
+
const scheduleSummary =
|
|
85
|
+
settings.schedule.frequency === 'off'
|
|
86
|
+
? t('adminBackups.schedule.off')
|
|
87
|
+
: settings.schedule.frequency === 'daily'
|
|
88
|
+
? t('adminBackups.schedule.daily', { time })
|
|
89
|
+
: t('adminBackups.schedule.weekly', {
|
|
90
|
+
time,
|
|
91
|
+
weekday: t(WEEKDAY_KEYS[settings.schedule.weekday] ?? WEEKDAY_KEYS[1] ?? ''),
|
|
92
|
+
})
|
|
93
|
+
const retentionSummary =
|
|
94
|
+
(settings.retention.keepDays ?? 0) > 0
|
|
95
|
+
? t('adminBackups.retention.countAndDays', {
|
|
96
|
+
count: settings.retention.keep,
|
|
97
|
+
days: settings.retention.keepDays ?? 0,
|
|
98
|
+
})
|
|
99
|
+
: t('adminBackups.retention.count', { count: settings.retention.keep })
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<PanelPage
|
|
103
|
+
title={await tr('page.backups')}
|
|
104
|
+
back={{ href: '/admin/system', label: t('adminBackups.backToSystem') }}
|
|
105
|
+
lede={t('adminBackups.lede')}
|
|
106
|
+
gap="loose"
|
|
107
|
+
>
|
|
108
|
+
{query.notice === 'reauth' && (
|
|
109
|
+
<section role="alert" className="rounded-lg border border-border bg-muted/40 p-4 text-sm">
|
|
110
|
+
{t('adminBackups.reauth')}
|
|
111
|
+
</section>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{view.capability === 'serverless' && (
|
|
115
|
+
<section
|
|
116
|
+
role="alert"
|
|
117
|
+
className="flex flex-col gap-2 rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm"
|
|
118
|
+
>
|
|
119
|
+
<p>{t('adminBackups.serverless')}</p>
|
|
120
|
+
</section>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
<section className={cn(PANEL_CARD, 'gap-4')}>
|
|
124
|
+
<h2 className="font-heading text-lg font-semibold">{t('adminBackups.now.title')}</h2>
|
|
125
|
+
<p className="text-sm text-muted-foreground">{t('adminBackups.now.hint')}</p>
|
|
126
|
+
{view.active !== null && (
|
|
127
|
+
<p className="text-sm">
|
|
128
|
+
{view.active.status === 'running'
|
|
129
|
+
? t('adminBackups.now.running', {
|
|
130
|
+
since: formatTime(
|
|
131
|
+
view.active.startedAt ?? view.active.requestedAt,
|
|
132
|
+
now,
|
|
133
|
+
translator,
|
|
134
|
+
).label,
|
|
135
|
+
})
|
|
136
|
+
: t('adminBackups.now.queued')}
|
|
137
|
+
</p>
|
|
138
|
+
)}
|
|
139
|
+
<RequestBackupForm disabled={!available || view.active !== null} copy={copy} />
|
|
140
|
+
</section>
|
|
141
|
+
|
|
142
|
+
<section className={PANEL_CARD}>
|
|
143
|
+
<h2 className="font-heading text-lg font-semibold">{t('adminBackups.plan.title')}</h2>
|
|
144
|
+
<ul className="flex flex-col gap-1 text-sm">
|
|
145
|
+
<li>
|
|
146
|
+
<span className="text-muted-foreground">{t('adminBackups.plan.schedule')}</span>{' '}
|
|
147
|
+
{scheduleSummary}
|
|
148
|
+
{view.nextScheduled !== null && available && (
|
|
149
|
+
<span className="text-muted-foreground">
|
|
150
|
+
{' · '}
|
|
151
|
+
{t('adminBackups.plan.next', {
|
|
152
|
+
time: formatTime(view.nextScheduled, now, translator).label,
|
|
153
|
+
})}
|
|
154
|
+
</span>
|
|
155
|
+
)}
|
|
156
|
+
</li>
|
|
157
|
+
<li>
|
|
158
|
+
<span className="text-muted-foreground">{t('adminBackups.plan.retention')}</span>{' '}
|
|
159
|
+
{retentionSummary}
|
|
160
|
+
</li>
|
|
161
|
+
<li>
|
|
162
|
+
<span className="text-muted-foreground">{t('adminBackups.plan.uploads')}</span>{' '}
|
|
163
|
+
{settings.uploads === 'include'
|
|
164
|
+
? t('adminBackups.plan.uploadsIncluded')
|
|
165
|
+
: t('adminBackups.plan.uploadsSkipped')}
|
|
166
|
+
</li>
|
|
167
|
+
<li>
|
|
168
|
+
<span className="text-muted-foreground">{t('adminBackups.plan.beforeUpgrade')}</span>{' '}
|
|
169
|
+
{settings.beforeUpgrade ? t('adminBackups.plan.on') : t('adminBackups.plan.off')}
|
|
170
|
+
</li>
|
|
171
|
+
<li>
|
|
172
|
+
<span className="text-muted-foreground">{t('adminBackups.plan.ring')}</span>{' '}
|
|
173
|
+
<code className="text-xs">{view.ring}</code>
|
|
174
|
+
</li>
|
|
175
|
+
</ul>
|
|
176
|
+
<p className="text-sm text-muted-foreground">
|
|
177
|
+
<a href={settingsHref({ group: 'backup' })} className="underline">
|
|
178
|
+
{t('adminBackups.plan.change')}
|
|
179
|
+
</a>
|
|
180
|
+
</p>
|
|
181
|
+
</section>
|
|
182
|
+
|
|
183
|
+
<section className={PANEL_CARD}>
|
|
184
|
+
<h2 className="font-heading text-lg font-semibold">
|
|
185
|
+
{t('adminBackups.destination.title')}
|
|
186
|
+
</h2>
|
|
187
|
+
{view.destination.source === 'none' ? (
|
|
188
|
+
<p className="text-sm text-muted-foreground">{t('adminBackups.destination.none')}</p>
|
|
189
|
+
) : (
|
|
190
|
+
<p className="text-sm">
|
|
191
|
+
{view.destination.description === null
|
|
192
|
+
? t('adminBackups.destination.unusable')
|
|
193
|
+
: t('adminBackups.destination.shipsTo', {
|
|
194
|
+
destination: view.destination.description,
|
|
195
|
+
})}{' '}
|
|
196
|
+
<span className="text-muted-foreground">
|
|
197
|
+
{view.destination.source === 'environment'
|
|
198
|
+
? t('adminBackups.destination.fromEnvironment')
|
|
199
|
+
: t('adminBackups.destination.fromBoard')}
|
|
200
|
+
</span>
|
|
201
|
+
</p>
|
|
202
|
+
)}
|
|
203
|
+
{view.destination.problem !== null && (
|
|
204
|
+
<p className="text-sm text-destructive">{view.destination.problem}</p>
|
|
205
|
+
)}
|
|
206
|
+
{view.destination.listError !== null && (
|
|
207
|
+
<p className="text-sm text-destructive">
|
|
208
|
+
{t('adminBackups.destination.listFailed', { error: view.destination.listError })}
|
|
209
|
+
</p>
|
|
210
|
+
)}
|
|
211
|
+
{view.destination.description !== null && <TestDestinationForm copy={copy} />}
|
|
212
|
+
</section>
|
|
213
|
+
|
|
214
|
+
<section className={PANEL_CARD}>
|
|
215
|
+
<h2 className="font-heading text-lg font-semibold">{t('adminBackups.bundles.title')}</h2>
|
|
216
|
+
{view.bundles.length === 0 ? (
|
|
217
|
+
<p className="text-sm text-muted-foreground">{t('adminBackups.bundles.empty')}</p>
|
|
218
|
+
) : (
|
|
219
|
+
<ul className="flex flex-col divide-y divide-border">
|
|
220
|
+
{view.bundles.map((bundle) => (
|
|
221
|
+
<li
|
|
222
|
+
key={bundle.name}
|
|
223
|
+
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 py-3 first:pt-0 last:pb-0"
|
|
224
|
+
>
|
|
225
|
+
<span className="flex min-w-0 flex-col">
|
|
226
|
+
<span className="text-sm font-medium">
|
|
227
|
+
{bundle.takenAt === null
|
|
228
|
+
? bundle.name
|
|
229
|
+
: formatTime(bundle.takenAt, now, translator).label}
|
|
230
|
+
</span>
|
|
231
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
232
|
+
<code>{bundle.name}</code>
|
|
233
|
+
{bundle.localSize !== null &&
|
|
234
|
+
` · ${t('adminBackups.bundles.local', { size: formatBytes(bundle.localSize) })}`}
|
|
235
|
+
{bundle.remoteSize !== null &&
|
|
236
|
+
` · ${t('adminBackups.bundles.offSite', { size: formatBytes(bundle.remoteSize) })}`}
|
|
237
|
+
</span>
|
|
238
|
+
</span>
|
|
239
|
+
<span className="flex shrink-0 flex-wrap items-center gap-2">
|
|
240
|
+
<a
|
|
241
|
+
href={`/admin/system/backups/${encodeURIComponent(bundle.name)}`}
|
|
242
|
+
className={DOWNLOAD_LINK}
|
|
243
|
+
>
|
|
244
|
+
{t('adminBackups.bundles.download')}
|
|
245
|
+
</a>
|
|
246
|
+
<DeleteBackupForm name={bundle.name} copy={copy} />
|
|
247
|
+
</span>
|
|
248
|
+
</li>
|
|
249
|
+
))}
|
|
250
|
+
</ul>
|
|
251
|
+
)}
|
|
252
|
+
<p className="text-xs text-muted-foreground">{t('adminBackups.bundles.restoreHint')}</p>
|
|
253
|
+
</section>
|
|
254
|
+
|
|
255
|
+
<section className={PANEL_CARD}>
|
|
256
|
+
<h2 className="font-heading text-lg font-semibold">{t('adminBackups.runs.title')}</h2>
|
|
257
|
+
{view.runs.length === 0 ? (
|
|
258
|
+
<p className="text-sm text-muted-foreground">{t('adminBackups.runs.empty')}</p>
|
|
259
|
+
) : (
|
|
260
|
+
<ul className="flex flex-col gap-1 text-sm">
|
|
261
|
+
{view.runs.map((run) => (
|
|
262
|
+
<li key={run.id}>
|
|
263
|
+
<span
|
|
264
|
+
className={
|
|
265
|
+
run.status === 'failed' || run.status === 'incomplete'
|
|
266
|
+
? 'font-medium text-destructive'
|
|
267
|
+
: 'font-medium'
|
|
268
|
+
}
|
|
269
|
+
>
|
|
270
|
+
{t(RUN_STATUS_KEYS[run.status])}
|
|
271
|
+
</span>{' '}
|
|
272
|
+
<span className="text-muted-foreground">
|
|
273
|
+
{t(RUN_TRIGGER_KEYS[run.trigger])} ·{' '}
|
|
274
|
+
<time dateTime={run.requestedAt.toISOString()}>
|
|
275
|
+
{formatTime(run.requestedAt, now, translator).label}
|
|
276
|
+
</time>
|
|
277
|
+
{run.bundleName !== null && (
|
|
278
|
+
<>
|
|
279
|
+
{' · '}
|
|
280
|
+
<code className="text-xs">{run.bundleName}</code>
|
|
281
|
+
</>
|
|
282
|
+
)}
|
|
283
|
+
{run.sizeBytes !== null && ` · ${formatBytes(run.sizeBytes)}`}
|
|
284
|
+
{run.shipped && ` · ${t('adminBackups.runs.shipped')}`}
|
|
285
|
+
{run.skippedKeys > 0 &&
|
|
286
|
+
` · ${t('adminBackups.runs.skipped', { count: run.skippedKeys })}`}
|
|
287
|
+
</span>
|
|
288
|
+
{run.error !== null && (
|
|
289
|
+
<span className="block text-xs text-destructive">{run.error}</span>
|
|
290
|
+
)}
|
|
291
|
+
</li>
|
|
292
|
+
))}
|
|
293
|
+
</ul>
|
|
294
|
+
)}
|
|
295
|
+
</section>
|
|
296
|
+
</PanelPage>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
@@ -23,6 +23,7 @@ import { formatTime } from '@/view/time'
|
|
|
23
23
|
|
|
24
24
|
const TASK_STATUS_KEYS = {
|
|
25
25
|
healthy: 'adminSystem.taskStatus.healthy',
|
|
26
|
+
running: 'adminSystem.taskStatus.running',
|
|
26
27
|
late: 'adminSystem.taskStatus.late',
|
|
27
28
|
stale: 'adminSystem.taskStatus.stale',
|
|
28
29
|
failing: 'adminSystem.taskStatus.failing',
|
|
@@ -194,7 +195,9 @@ export default async function AdminSystemPage() {
|
|
|
194
195
|
</span>
|
|
195
196
|
<span
|
|
196
197
|
className={
|
|
197
|
-
task.status === 'healthy' ||
|
|
198
|
+
task.status === 'healthy' ||
|
|
199
|
+
task.status === 'running' ||
|
|
200
|
+
task.status === 'disabled'
|
|
198
201
|
? 'shrink-0 text-xs text-muted-foreground'
|
|
199
202
|
: 'shrink-0 text-xs font-medium text-destructive'
|
|
200
203
|
}
|
|
@@ -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
|
@@ -16,9 +16,15 @@ import { isUsableOrigin, MAIL_PRESETS, normaliseOrigin } from '@meith/settings'
|
|
|
16
16
|
import { Alert, AlertDescription, AlertTitle, Disclosure } from '@meith/ui'
|
|
17
17
|
|
|
18
18
|
import { InstallForm } from '@/components/install/install-form'
|
|
19
|
+
import { InstallRestoreForm } from '@/components/install/restore-form'
|
|
20
|
+
import { InstallUnlockForm } from '@/components/install/unlock-form'
|
|
19
21
|
import { getTranslator, tr } from '@/server/i18n'
|
|
20
22
|
import { gatherPreflight, installerIsSealed, probeMail } from '@/server/install'
|
|
21
|
-
import {
|
|
23
|
+
import { installRestoreView } from '@/server/install-restore'
|
|
24
|
+
import { installUnlocked } from '@/server/install-unlock'
|
|
25
|
+
import { formatBytes } from '@/view/attachments'
|
|
26
|
+
import { installFormCopy, installRestoreCopy } from '@/view/install-copy'
|
|
27
|
+
import { formatTime } from '@/view/time'
|
|
22
28
|
|
|
23
29
|
export async function generateMetadata(): Promise<Metadata> {
|
|
24
30
|
return { title: await tr('page.install') }
|
|
@@ -73,8 +79,11 @@ export default async function InstallPage() {
|
|
|
73
79
|
|
|
74
80
|
const checks = await gatherPreflight()
|
|
75
81
|
const ready = canProceed(checks)
|
|
82
|
+
const unlocked = ready && (await installUnlocked())
|
|
76
83
|
const mail = await probeMail()
|
|
77
84
|
const suggestedBoardUrl = await suggestBoardUrl()
|
|
85
|
+
const restore = unlocked ? await installRestoreView() : null
|
|
86
|
+
const now = new Date()
|
|
78
87
|
|
|
79
88
|
return (
|
|
80
89
|
<main className="mx-auto flex w-full max-w-2xl flex-col gap-8 px-6 py-12">
|
|
@@ -87,7 +96,41 @@ export default async function InstallPage() {
|
|
|
87
96
|
|
|
88
97
|
<Preflight checks={checks} />
|
|
89
98
|
|
|
90
|
-
{ready && (
|
|
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
|
+
|
|
111
|
+
{restore?.possible && (
|
|
112
|
+
<InstallRestoreForm
|
|
113
|
+
candidates={restore.candidates.map((candidate) => ({
|
|
114
|
+
name: candidate.name,
|
|
115
|
+
label:
|
|
116
|
+
candidate.takenAt === null
|
|
117
|
+
? candidate.name
|
|
118
|
+
: t.t('install.restore.takenAt', {
|
|
119
|
+
time: formatTime(candidate.takenAt, now, t).label,
|
|
120
|
+
}),
|
|
121
|
+
size: formatBytes(candidate.size),
|
|
122
|
+
location:
|
|
123
|
+
candidate.location === 'server'
|
|
124
|
+
? t.t('install.restore.onServer')
|
|
125
|
+
: t.t('install.restore.offSite'),
|
|
126
|
+
}))}
|
|
127
|
+
destination={restore.destination}
|
|
128
|
+
problem={restore.problem}
|
|
129
|
+
copy={installRestoreCopy(t)}
|
|
130
|
+
/>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
{unlocked && (
|
|
91
134
|
<InstallForm
|
|
92
135
|
presets={MAIL_PRESETS.map((preset) => ({
|
|
93
136
|
...preset,
|
package/next.config.mjs
CHANGED
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,54 +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/
|
|
54
|
-
"@meith/
|
|
55
|
-
"@meith/
|
|
56
|
-
"@meith/
|
|
57
|
-
"@meith/
|
|
58
|
-
"@meith/drafts": "0.
|
|
59
|
-
"@meith/
|
|
60
|
-
"@meith/
|
|
61
|
-
"@meith/
|
|
62
|
-
"@meith/
|
|
63
|
-
"@meith/
|
|
64
|
-
"@meith/
|
|
65
|
-
"@meith/
|
|
66
|
-
"@meith/
|
|
67
|
-
"@meith/
|
|
68
|
-
"@meith/
|
|
69
|
-
"@meith/
|
|
70
|
-
"@meith/
|
|
71
|
-
"@meith/
|
|
72
|
-
"@meith/plugin-
|
|
73
|
-
"@meith/plugin-
|
|
74
|
-
"@meith/
|
|
75
|
-
"@meith/
|
|
76
|
-
"@meith/
|
|
77
|
-
"@meith/
|
|
78
|
-
"@meith/
|
|
79
|
-
"@meith/
|
|
80
|
-
"@meith/
|
|
81
|
-
"@meith/
|
|
82
|
-
"@meith/
|
|
83
|
-
"@meith/
|
|
84
|
-
"@meith/
|
|
85
|
-
"@meith/
|
|
86
|
-
"@meith/theme-
|
|
87
|
-
"@meith/theme-
|
|
88
|
-
"@meith/theme-
|
|
89
|
-
"@meith/theme-
|
|
90
|
-
"@meith/theme-
|
|
91
|
-
"@meith/
|
|
92
|
-
"@meith/
|
|
93
|
-
"@meith/
|
|
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"
|
|
94
95
|
},
|
|
95
96
|
"scripts": {
|
|
96
97
|
"dev": "next dev",
|