@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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useActionState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
6
|
+
import {
|
|
7
|
+
deleteBackupAction,
|
|
8
|
+
requestBackupAction,
|
|
9
|
+
testBackupDestinationAction,
|
|
10
|
+
} from '@/server/backup-admin-actions'
|
|
11
|
+
|
|
12
|
+
import { FormError, PendingButton, SubmitButton } from '../auth/form-controls'
|
|
13
|
+
import { ConfirmDialog } from '../shell/confirm-dialog'
|
|
14
|
+
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
15
|
+
import { Saved } from './form-bits'
|
|
16
|
+
|
|
17
|
+
const ROW_BUTTON =
|
|
18
|
+
'inline-flex h-8 items-center justify-center rounded-md border border-destructive/30 bg-destructive/10 px-2.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
19
|
+
|
|
20
|
+
export function RequestBackupForm({ disabled, copy }: { disabled: boolean; copy: Copy }) {
|
|
21
|
+
const [state, action] = useActionState(requestBackupAction, EMPTY_STATE)
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<form action={action} className="flex flex-col gap-2">
|
|
25
|
+
<FormError message={state.error} />
|
|
26
|
+
{state.notice === 'queued' && <Saved>{fromCopy(copy, 'adminPanel.backup.queued')}</Saved>}
|
|
27
|
+
{state.notice === 'already' && (
|
|
28
|
+
<p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
29
|
+
{fromCopy(copy, 'adminPanel.backup.already')}
|
|
30
|
+
</p>
|
|
31
|
+
)}
|
|
32
|
+
<div>
|
|
33
|
+
{disabled ? (
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
disabled
|
|
37
|
+
className="inline-flex h-10 cursor-not-allowed items-center rounded-md border border-border px-4 text-sm opacity-60"
|
|
38
|
+
>
|
|
39
|
+
{fromCopy(copy, 'adminPanel.backup.now')}
|
|
40
|
+
</button>
|
|
41
|
+
) : (
|
|
42
|
+
<SubmitButton className="w-auto">{fromCopy(copy, 'adminPanel.backup.now')}</SubmitButton>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
</form>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function DeleteBackupForm({ name, copy }: { name: string; copy: Copy }) {
|
|
50
|
+
const [state, action] = useActionState(deleteBackupAction, EMPTY_STATE)
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<form action={action} className="flex flex-wrap items-center gap-2">
|
|
54
|
+
<input type="hidden" name="name" value={name} />
|
|
55
|
+
<PendingButton className={ROW_BUTTON}>
|
|
56
|
+
{fromCopy(copy, 'adminPanel.backup.delete')}
|
|
57
|
+
</PendingButton>
|
|
58
|
+
{state.notice === 'deleted' && (
|
|
59
|
+
<span className="text-xs text-muted-foreground">
|
|
60
|
+
{formatFromCopy(copy, 'adminPanel.backup.deleted', { name: state.values?.name ?? name })}
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
{state.error !== undefined && <span className="text-xs text-destructive">{state.error}</span>}
|
|
64
|
+
<ConfirmDialog confirm={state.confirm} action={action} />
|
|
65
|
+
</form>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function TestDestinationForm({ copy }: { copy: Copy }) {
|
|
70
|
+
const [state, action] = useActionState(testBackupDestinationAction, EMPTY_STATE)
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<form action={action} className="flex flex-col gap-2">
|
|
74
|
+
<FormError message={state.error} />
|
|
75
|
+
{state.notice === 'reachable' && (
|
|
76
|
+
<Saved>
|
|
77
|
+
{formatFromCopy(copy, 'adminPanel.backup.reachable', {
|
|
78
|
+
count: Number(state.values?.count ?? 0),
|
|
79
|
+
})}
|
|
80
|
+
</Saved>
|
|
81
|
+
)}
|
|
82
|
+
<div>
|
|
83
|
+
<SubmitButton className="w-auto">{fromCopy(copy, 'adminPanel.backup.test')}</SubmitButton>
|
|
84
|
+
</div>
|
|
85
|
+
</form>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useActionState } from 'react'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Alert,
|
|
7
|
+
AlertDescription,
|
|
8
|
+
AlertTitle,
|
|
9
|
+
Card,
|
|
10
|
+
CardContent,
|
|
11
|
+
CardDescription,
|
|
12
|
+
CardHeader,
|
|
13
|
+
CardTitle,
|
|
14
|
+
} from '@meith/ui'
|
|
15
|
+
import { Button } from '@meith/ui/button'
|
|
16
|
+
|
|
17
|
+
import { useFocusOnFail } from '@/components/auth/form-controls'
|
|
18
|
+
import { type Copy, formatFromCopy, fromCopy } from '@/components/shell/copy'
|
|
19
|
+
import { type InstallRestoreState, installRestoreAction } from '@/server/install-restore-actions'
|
|
20
|
+
|
|
21
|
+
const EMPTY: InstallRestoreState = {}
|
|
22
|
+
|
|
23
|
+
export interface RestoreCandidateView {
|
|
24
|
+
readonly name: string
|
|
25
|
+
readonly label: string
|
|
26
|
+
readonly size: string
|
|
27
|
+
readonly location: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function InstallRestoreForm({
|
|
31
|
+
candidates,
|
|
32
|
+
destination,
|
|
33
|
+
problem,
|
|
34
|
+
copy,
|
|
35
|
+
}: {
|
|
36
|
+
candidates: readonly RestoreCandidateView[]
|
|
37
|
+
destination: string | null
|
|
38
|
+
problem: string | null
|
|
39
|
+
copy: Copy
|
|
40
|
+
}) {
|
|
41
|
+
const [state, submit, pending] = useActionState(installRestoreAction, EMPTY)
|
|
42
|
+
const errorRef = useFocusOnFail<HTMLDivElement>(state.error !== undefined)
|
|
43
|
+
|
|
44
|
+
if (state.outcome !== undefined) {
|
|
45
|
+
const { outcome } = state
|
|
46
|
+
return (
|
|
47
|
+
<Card>
|
|
48
|
+
<CardHeader>
|
|
49
|
+
<CardTitle>{fromCopy(copy, 'install.restore.doneTitle')}</CardTitle>
|
|
50
|
+
<CardDescription>
|
|
51
|
+
{formatFromCopy(copy, 'install.restore.doneDetail', {
|
|
52
|
+
bundle: outcome.bundle,
|
|
53
|
+
version: outcome.version,
|
|
54
|
+
posts: outcome.posts,
|
|
55
|
+
})}
|
|
56
|
+
</CardDescription>
|
|
57
|
+
</CardHeader>
|
|
58
|
+
<CardContent className="flex flex-col gap-3 text-sm">
|
|
59
|
+
{outcome.migrationsApplied > 0 && (
|
|
60
|
+
<p>
|
|
61
|
+
{formatFromCopy(copy, 'install.restore.migrated', {
|
|
62
|
+
count: outcome.migrationsApplied,
|
|
63
|
+
})}
|
|
64
|
+
</p>
|
|
65
|
+
)}
|
|
66
|
+
{outcome.uploads === 'none' && <p>{fromCopy(copy, 'install.restore.noUploads')}</p>}
|
|
67
|
+
{outcome.skippedKeys > 0 && (
|
|
68
|
+
<p className="text-destructive">
|
|
69
|
+
{formatFromCopy(copy, 'install.restore.skipped', { count: outcome.skippedKeys })}
|
|
70
|
+
</p>
|
|
71
|
+
)}
|
|
72
|
+
<p>{fromCopy(copy, 'install.restore.next')}</p>
|
|
73
|
+
<p>
|
|
74
|
+
<a href="/login" className="underline">
|
|
75
|
+
{fromCopy(copy, 'install.restore.signIn')}
|
|
76
|
+
</a>
|
|
77
|
+
</p>
|
|
78
|
+
</CardContent>
|
|
79
|
+
</Card>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<Card aria-labelledby="install-restore">
|
|
85
|
+
<CardHeader>
|
|
86
|
+
<CardTitle id="install-restore">{fromCopy(copy, 'install.restore.title')}</CardTitle>
|
|
87
|
+
<CardDescription>{fromCopy(copy, 'install.restore.hint')}</CardDescription>
|
|
88
|
+
</CardHeader>
|
|
89
|
+
<CardContent className="flex flex-col gap-4">
|
|
90
|
+
{problem !== null && (
|
|
91
|
+
<Alert tone="warning">
|
|
92
|
+
<AlertDescription>{problem}</AlertDescription>
|
|
93
|
+
</Alert>
|
|
94
|
+
)}
|
|
95
|
+
{candidates.length === 0 ? (
|
|
96
|
+
<p className="text-sm text-muted-foreground">
|
|
97
|
+
{destination === null
|
|
98
|
+
? fromCopy(copy, 'install.restore.noneLocal')
|
|
99
|
+
: formatFromCopy(copy, 'install.restore.noneAnywhere', { destination })}
|
|
100
|
+
</p>
|
|
101
|
+
) : (
|
|
102
|
+
<form action={submit} className="flex flex-col gap-4">
|
|
103
|
+
{state.error !== undefined && (
|
|
104
|
+
<Alert tone="error" ref={errorRef} tabIndex={-1}>
|
|
105
|
+
<AlertDescription>
|
|
106
|
+
<AlertTitle>{fromCopy(copy, 'install.restore.notRestored')}</AlertTitle>{' '}
|
|
107
|
+
{state.error}
|
|
108
|
+
</AlertDescription>
|
|
109
|
+
</Alert>
|
|
110
|
+
)}
|
|
111
|
+
<fieldset className="flex flex-col gap-2">
|
|
112
|
+
<legend className="text-sm font-medium">
|
|
113
|
+
{fromCopy(copy, 'install.restore.pick')}
|
|
114
|
+
</legend>
|
|
115
|
+
{candidates.map((candidate, index) => (
|
|
116
|
+
<label key={candidate.name} className="flex items-start gap-2 text-sm">
|
|
117
|
+
<input
|
|
118
|
+
type="radio"
|
|
119
|
+
name="bundle"
|
|
120
|
+
value={candidate.name}
|
|
121
|
+
defaultChecked={index === 0}
|
|
122
|
+
className="mt-1 size-4"
|
|
123
|
+
/>
|
|
124
|
+
<span className="flex min-w-0 flex-col">
|
|
125
|
+
<span className="font-medium">{candidate.label}</span>
|
|
126
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
127
|
+
<code>{candidate.name}</code> · {candidate.size} · {candidate.location}
|
|
128
|
+
</span>
|
|
129
|
+
</span>
|
|
130
|
+
</label>
|
|
131
|
+
))}
|
|
132
|
+
</fieldset>
|
|
133
|
+
<label className="flex items-start gap-2 text-sm">
|
|
134
|
+
<input type="checkbox" name="confirm" value="1" className="mt-1 size-4" />
|
|
135
|
+
<span>{fromCopy(copy, 'install.restore.confirm')}</span>
|
|
136
|
+
</label>
|
|
137
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
138
|
+
<Button type="submit" variant="secondary" size="lg" disabled={pending}>
|
|
139
|
+
{pending
|
|
140
|
+
? fromCopy(copy, 'install.restore.restoring')
|
|
141
|
+
: fromCopy(copy, 'install.restore.submit')}
|
|
142
|
+
</Button>
|
|
143
|
+
<p className="text-xs text-muted-foreground">
|
|
144
|
+
{pending
|
|
145
|
+
? fromCopy(copy, 'install.restore.pendingNote')
|
|
146
|
+
: fromCopy(copy, 'install.restore.idleNote')}
|
|
147
|
+
</p>
|
|
148
|
+
</div>
|
|
149
|
+
</form>
|
|
150
|
+
)}
|
|
151
|
+
</CardContent>
|
|
152
|
+
</Card>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
@@ -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 })
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { rm } from 'node:fs/promises'
|
|
4
|
+
|
|
5
|
+
import { revalidatePath } from 'next/cache'
|
|
6
|
+
|
|
7
|
+
import { isBundleName } from '@meith/backup'
|
|
8
|
+
import { ValidationError } from '@meith/core'
|
|
9
|
+
import { msg } from '@meith/i18n'
|
|
10
|
+
|
|
11
|
+
import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
|
|
12
|
+
import type { FormState } from './auth-form-state'
|
|
13
|
+
import {
|
|
14
|
+
backupsAvailable,
|
|
15
|
+
currentBackupSettings,
|
|
16
|
+
destinationFor,
|
|
17
|
+
localBundlePath,
|
|
18
|
+
requireBackupRuns,
|
|
19
|
+
} from './backup-admin'
|
|
20
|
+
import { requireConfirmation } from './confirm'
|
|
21
|
+
import { formStateReporter } from './form-state-reporter'
|
|
22
|
+
import { tr } from './i18n'
|
|
23
|
+
|
|
24
|
+
const toFormState = formStateReporter('backup-admin', 'backup action failed')
|
|
25
|
+
|
|
26
|
+
const BACKUPS_PATH = '/admin/system/backups'
|
|
27
|
+
|
|
28
|
+
function refreshBackupsScreen(): void {
|
|
29
|
+
revalidatePath(BACKUPS_PATH)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function requestBackupAction(): Promise<FormState> {
|
|
33
|
+
try {
|
|
34
|
+
const admin = await requireAdmin()
|
|
35
|
+
if (backupsAvailable() !== 'available') {
|
|
36
|
+
throw new ValidationError(msg('error.app.backups-not-on-this-deployment'))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { queued } = await requireBackupRuns().enqueue({
|
|
40
|
+
trigger: 'manual',
|
|
41
|
+
requestedByUserId: admin.session.userId,
|
|
42
|
+
now: new Date(),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
refreshBackupsScreen()
|
|
46
|
+
if (queued) await recordAdminAction({ action: 'backup.requested' })
|
|
47
|
+
return { notice: queued ? 'queued' : 'already' }
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return toFormState(err)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function bundleNameFrom(form: FormData): string {
|
|
54
|
+
const raw = form.get('name')
|
|
55
|
+
const name = typeof raw === 'string' ? raw.trim() : ''
|
|
56
|
+
if (!isBundleName(name)) throw new ValidationError(msg('error.app.not-a-backup-bundle-name'))
|
|
57
|
+
return name
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function deleteBackupAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
61
|
+
try {
|
|
62
|
+
await requireFreshAdmin()
|
|
63
|
+
const name = bundleNameFrom(form)
|
|
64
|
+
|
|
65
|
+
const confirm = requireConfirmation(form, await tr('adminBackups.confirm.delete'))
|
|
66
|
+
if (confirm !== null) return confirm
|
|
67
|
+
|
|
68
|
+
const local = await localBundlePath(name)
|
|
69
|
+
if (local !== null) await rm(local, { force: true })
|
|
70
|
+
|
|
71
|
+
let remote = false
|
|
72
|
+
const destination = destinationFor(await currentBackupSettings())
|
|
73
|
+
if (destination !== undefined) {
|
|
74
|
+
const listed = await destination.list()
|
|
75
|
+
if (listed.some((bundle) => bundle.name === name)) {
|
|
76
|
+
await destination.delete(name)
|
|
77
|
+
remote = true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (local === null && !remote) throw new ValidationError(msg('error.app.no-such-bundle'))
|
|
82
|
+
|
|
83
|
+
refreshBackupsScreen()
|
|
84
|
+
await recordAdminAction({
|
|
85
|
+
action: 'backup.deleted',
|
|
86
|
+
detail: { name, local: local !== null, remote },
|
|
87
|
+
})
|
|
88
|
+
return { notice: 'deleted', values: { name } }
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return toFormState(err)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function testBackupDestinationAction(): Promise<FormState> {
|
|
95
|
+
try {
|
|
96
|
+
await requireAdmin()
|
|
97
|
+
const settings = await currentBackupSettings()
|
|
98
|
+
if (settings.destination.problem !== null) {
|
|
99
|
+
throw new ValidationError(settings.destination.problem)
|
|
100
|
+
}
|
|
101
|
+
const destination = destinationFor(settings)
|
|
102
|
+
if (destination === undefined) {
|
|
103
|
+
throw new ValidationError(msg('error.app.no-off-site-destination'))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const bundles = await destination.list()
|
|
107
|
+
await recordAdminAction({
|
|
108
|
+
action: 'backup.destination_tested',
|
|
109
|
+
detail: { count: bundles.length },
|
|
110
|
+
})
|
|
111
|
+
return { notice: 'reachable', values: { count: String(bundles.length) } }
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return toFormState(err)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { stat } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type BackupCapability,
|
|
8
|
+
type BackupDestination,
|
|
9
|
+
type BackupRunRecord,
|
|
10
|
+
backupCapability,
|
|
11
|
+
backupDestinationFromEnv,
|
|
12
|
+
bundleTakenAt,
|
|
13
|
+
isBundleName,
|
|
14
|
+
localBundles,
|
|
15
|
+
nextSlotAfter,
|
|
16
|
+
} from '@meith/backup'
|
|
17
|
+
import { env, ForbiddenError, logger } from '@meith/core'
|
|
18
|
+
import { getDb, PostgresBackupRunRepository } from '@meith/db'
|
|
19
|
+
import { msg } from '@meith/i18n'
|
|
20
|
+
import {
|
|
21
|
+
BACKUP_STALE_MS,
|
|
22
|
+
type BackupSettingsView,
|
|
23
|
+
backupDestinationFor,
|
|
24
|
+
backupRingDirectory,
|
|
25
|
+
loadBackupSettings,
|
|
26
|
+
} from '@meith/runtime'
|
|
27
|
+
|
|
28
|
+
import { getContainer } from './container'
|
|
29
|
+
|
|
30
|
+
export interface BackupBundleRow {
|
|
31
|
+
readonly name: string
|
|
32
|
+
readonly takenAt: Date | null
|
|
33
|
+
readonly localSize: number | null
|
|
34
|
+
readonly remoteSize: number | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BackupDestinationView {
|
|
38
|
+
readonly source: 'environment' | 'board' | 'none'
|
|
39
|
+
readonly description: string | null
|
|
40
|
+
readonly problem: string | null
|
|
41
|
+
readonly listError: string | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BackupAdminView {
|
|
45
|
+
readonly capability: BackupCapability
|
|
46
|
+
readonly ring: string
|
|
47
|
+
readonly destination: BackupDestinationView
|
|
48
|
+
readonly bundles: readonly BackupBundleRow[]
|
|
49
|
+
readonly runs: readonly BackupRunRecord[]
|
|
50
|
+
readonly active: BackupRunRecord | null
|
|
51
|
+
readonly settings: BackupSettingsView
|
|
52
|
+
readonly nextScheduled: Date | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const RECENT_RUNS = 10
|
|
56
|
+
|
|
57
|
+
export function backupRuns(): PostgresBackupRunRepository | null {
|
|
58
|
+
return getContainer().dataSource === 'postgres' ? new PostgresBackupRunRepository(getDb()) : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function requireBackupRuns(): PostgresBackupRunRepository {
|
|
62
|
+
const runs = backupRuns()
|
|
63
|
+
if (runs === null) {
|
|
64
|
+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-6'))
|
|
65
|
+
}
|
|
66
|
+
return runs
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function backupRing(): string {
|
|
70
|
+
return backupRingDirectory(env)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function backupsAvailable(): BackupCapability {
|
|
74
|
+
return backupCapability(env)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function destinationIsFromEnvironment(): boolean {
|
|
78
|
+
try {
|
|
79
|
+
return backupDestinationFromEnv(env) !== undefined
|
|
80
|
+
} catch {
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function currentBackupSettings(): Promise<BackupSettingsView> {
|
|
86
|
+
return loadBackupSettings(getDb(), env)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function destinationFor(settings: BackupSettingsView): BackupDestination | undefined {
|
|
90
|
+
return backupDestinationFor(settings.destination)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function localBundlePath(name: string): Promise<string | null> {
|
|
94
|
+
if (!isBundleName(name)) return null
|
|
95
|
+
const file = path.join(backupRing(), name)
|
|
96
|
+
const info = await stat(file).catch(() => null)
|
|
97
|
+
return info?.isFile() ? file : null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function mergedBundles(
|
|
101
|
+
ring: string,
|
|
102
|
+
destination: BackupDestination | undefined,
|
|
103
|
+
): Promise<{ readonly bundles: readonly BackupBundleRow[]; readonly listError: string | null }> {
|
|
104
|
+
const local = await localBundles(ring)
|
|
105
|
+
const rows = new Map<string, { localSize: number | null; remoteSize: number | null }>()
|
|
106
|
+
for (const bundle of local) rows.set(bundle.name, { localSize: bundle.size, remoteSize: null })
|
|
107
|
+
|
|
108
|
+
let listError: string | null = null
|
|
109
|
+
if (destination !== undefined) {
|
|
110
|
+
try {
|
|
111
|
+
for (const bundle of await destination.list()) {
|
|
112
|
+
const row = rows.get(bundle.name)
|
|
113
|
+
if (row === undefined) rows.set(bundle.name, { localSize: null, remoteSize: bundle.size })
|
|
114
|
+
else rows.set(bundle.name, { ...row, remoteSize: bundle.size })
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
listError = error instanceof Error ? error.message : String(error)
|
|
118
|
+
logger({ module: 'backup' }).warn({ err: listError }, 'could not list the off-site backups')
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const bundles = [...rows]
|
|
123
|
+
.map(([name, sizes]) => ({ name, takenAt: bundleTakenAt(name), ...sizes }))
|
|
124
|
+
.sort((a, b) => b.name.localeCompare(a.name))
|
|
125
|
+
return { bundles, listError }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function buildBackupAdminView(now: Date): Promise<BackupAdminView | null> {
|
|
129
|
+
const runs = backupRuns()
|
|
130
|
+
if (runs === null) return null
|
|
131
|
+
|
|
132
|
+
const settings = await currentBackupSettings()
|
|
133
|
+
const destination = destinationFor(settings)
|
|
134
|
+
const ring = backupRing()
|
|
135
|
+
|
|
136
|
+
const [{ bundles, listError }, recent, active] = await Promise.all([
|
|
137
|
+
mergedBundles(ring, destination),
|
|
138
|
+
runs.recent(RECENT_RUNS),
|
|
139
|
+
runs.active(now, new Date(now.getTime() - BACKUP_STALE_MS)),
|
|
140
|
+
])
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
capability: backupsAvailable(),
|
|
144
|
+
ring,
|
|
145
|
+
destination: {
|
|
146
|
+
source: settings.destination.source,
|
|
147
|
+
description: destination?.description ?? null,
|
|
148
|
+
problem: settings.destination.problem,
|
|
149
|
+
listError,
|
|
150
|
+
},
|
|
151
|
+
bundles,
|
|
152
|
+
runs: recent,
|
|
153
|
+
active,
|
|
154
|
+
settings,
|
|
155
|
+
nextScheduled: nextSlotAfter(settings.schedule, now),
|
|
156
|
+
}
|
|
157
|
+
}
|