@meith/web 0.29.0 → 0.30.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/admin/settings/page.tsx +24 -1
- package/app/api/v1/[...path]/route.ts +19 -12
- package/app/brand/favicon/route.ts +18 -0
- package/app/icon.ts +16 -2
- package/app/layout.tsx +2 -0
- package/package.json +48 -48
- package/public/sw.js +2 -2
- package/src/components/admin/branding-forms.tsx +69 -1
- package/src/components/shell/service-worker.tsx +12 -0
- package/src/server/api/body.ts +79 -0
- package/src/server/brand-assets.ts +46 -2
- package/src/server/brand-mark.tsx +2 -2
- package/src/server/branding-actions.ts +40 -1
- package/src/server/branding.ts +33 -1
- package/src/server/same-origin.ts +29 -6
- package/src/server/upgrade-notice.ts +1 -1
- package/src/view/admin-panel-copy.ts +16 -0
|
@@ -3,16 +3,19 @@ import { redirect } from 'next/navigation'
|
|
|
3
3
|
|
|
4
4
|
import { buttonVariants, Card, CardContent, CardFooter, cn, Input } from '@meith/ui'
|
|
5
5
|
|
|
6
|
+
import { FaviconUploadForm } from '@/components/admin/branding-forms'
|
|
6
7
|
import { MailTestCard } from '@/components/admin/mail-test-card'
|
|
7
8
|
import { AdminSettingsForm } from '@/components/admin/settings-form'
|
|
8
9
|
import { PanelPage } from '@/components/shell/panel-page'
|
|
9
10
|
import { adminPageContext } from '@/server/admin'
|
|
10
11
|
import { boardUrlResolution } from '@/server/board-url'
|
|
12
|
+
import { faviconKey, faviconSrc } from '@/server/branding'
|
|
11
13
|
import { getTranslator, tr } from '@/server/i18n'
|
|
14
|
+
import { MAX_IMAGE_BYTES } from '@/server/image-upload'
|
|
12
15
|
import { assessMailReadiness } from '@/server/mail-health'
|
|
13
16
|
import { pushReadiness } from '@/server/push'
|
|
14
17
|
import { getSettings } from '@/server/settings'
|
|
15
|
-
import { mailTestCardCopy, settingsFormCopy } from '@/view/admin-panel-copy'
|
|
18
|
+
import { faviconFormsCopy, mailTestCardCopy, settingsFormCopy } from '@/view/admin-panel-copy'
|
|
16
19
|
import { buildAdminSettingsModel, DEFAULT_SETTING_GROUP, settingsHref } from '@/view/admin-settings'
|
|
17
20
|
|
|
18
21
|
export async function generateMetadata(): Promise<Metadata> {
|
|
@@ -49,6 +52,8 @@ export default async function AdminSettingsPage({
|
|
|
49
52
|
|
|
50
53
|
const push = model.activeGroup === 'push' ? await pushReadiness() : null
|
|
51
54
|
|
|
55
|
+
const favicon = model.activeGroup === 'board' ? await faviconKey() : null
|
|
56
|
+
|
|
52
57
|
return (
|
|
53
58
|
<PanelPage title={await tr('page.board-settings')} lede={t.t('adminSettings.lede')}>
|
|
54
59
|
<Card>
|
|
@@ -184,6 +189,24 @@ export default async function AdminSettingsPage({
|
|
|
184
189
|
</section>
|
|
185
190
|
)}
|
|
186
191
|
|
|
192
|
+
{model.activeGroup === 'board' && (
|
|
193
|
+
<section className="flex flex-col gap-3">
|
|
194
|
+
<div className="flex flex-col gap-1">
|
|
195
|
+
<h2 className="font-heading text-lg font-semibold">
|
|
196
|
+
{t.t('setting.board.favicon.label')}
|
|
197
|
+
</h2>
|
|
198
|
+
<p className="text-sm text-muted-foreground">
|
|
199
|
+
{t.t('setting.board.favicon.description')}
|
|
200
|
+
</p>
|
|
201
|
+
</div>
|
|
202
|
+
<FaviconUploadForm
|
|
203
|
+
src={favicon === null ? null : faviconSrc(favicon)}
|
|
204
|
+
maxKib={MAX_IMAGE_BYTES / 1024}
|
|
205
|
+
copy={faviconFormsCopy(t)}
|
|
206
|
+
/>
|
|
207
|
+
</section>
|
|
208
|
+
)}
|
|
209
|
+
|
|
187
210
|
<AdminSettingsForm groups={model.groups} copy={settingsFormCopy(t)} />
|
|
188
211
|
</PanelPage>
|
|
189
212
|
)
|
|
@@ -16,6 +16,7 @@ import type { Actor } from '@meith/authorization'
|
|
|
16
16
|
import { isAppError, metrics, statusForError, toPublicError, withSpan } from '@meith/core'
|
|
17
17
|
import { currentRequestId } from '@meith/core/logger'
|
|
18
18
|
|
|
19
|
+
import { readJsonBody } from '@/server/api/body'
|
|
19
20
|
import { JSON_MEDIA_TYPE } from '@/server/api/http'
|
|
20
21
|
import { handlerFor } from '@/server/api/registry'
|
|
21
22
|
import {
|
|
@@ -161,13 +162,30 @@ async function respondTo(
|
|
|
161
162
|
)
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
let body: Record<string, unknown> | null = null
|
|
166
|
+
if (matched.route.request !== undefined) {
|
|
167
|
+
const outcome = await readJsonBody(request)
|
|
168
|
+
if (outcome.kind === 'too-large') {
|
|
169
|
+
return fail(
|
|
170
|
+
413,
|
|
171
|
+
'payload_too_large',
|
|
172
|
+
'The request body is larger than the API allows.',
|
|
173
|
+
headers,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
if (outcome.kind === 'invalid') {
|
|
177
|
+
return fail(400, 'invalid_body', 'The request body is not valid JSON.', headers)
|
|
178
|
+
}
|
|
179
|
+
body = outcome.body
|
|
180
|
+
}
|
|
181
|
+
|
|
164
182
|
try {
|
|
165
183
|
const result = await handler({
|
|
166
184
|
actor: caller.actor,
|
|
167
185
|
token: caller.authenticated?.token ?? null,
|
|
168
186
|
params: matched.params,
|
|
169
187
|
url,
|
|
170
|
-
body
|
|
188
|
+
body,
|
|
171
189
|
})
|
|
172
190
|
|
|
173
191
|
return json(result.body, result.status, headers)
|
|
@@ -180,17 +198,6 @@ async function respondTo(
|
|
|
180
198
|
}
|
|
181
199
|
}
|
|
182
200
|
|
|
183
|
-
async function readJsonBody(request: NextRequest): Promise<Record<string, unknown> | null> {
|
|
184
|
-
try {
|
|
185
|
-
const parsed: unknown = await request.json()
|
|
186
|
-
return typeof parsed === 'object' && parsed !== null
|
|
187
|
-
? (parsed as Record<string, unknown>)
|
|
188
|
-
: null
|
|
189
|
-
} catch {
|
|
190
|
-
return null
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
201
|
export async function GET(request: NextRequest): Promise<Response> {
|
|
195
202
|
return handle(request, 'GET')
|
|
196
203
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { drivers } from '@meith/drivers'
|
|
2
|
+
|
|
3
|
+
import { faviconKey } from '@/server/branding'
|
|
4
|
+
import { imageHeaders } from '@/server/image-upload'
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic'
|
|
7
|
+
|
|
8
|
+
export async function GET(): Promise<Response> {
|
|
9
|
+
const key = await faviconKey()
|
|
10
|
+
if (key === null) return new Response(null, { status: 404 })
|
|
11
|
+
|
|
12
|
+
const bytes = await drivers().files.get(key)
|
|
13
|
+
if (bytes === undefined) return new Response(null, { status: 404 })
|
|
14
|
+
|
|
15
|
+
return new Response(bytes as unknown as BodyInit, {
|
|
16
|
+
headers: imageHeaders(key, bytes.byteLength),
|
|
17
|
+
})
|
|
18
|
+
}
|
package/app/icon.ts
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
buildFaviconSvg,
|
|
3
|
+
faviconDataUri,
|
|
4
|
+
loadBrandInfo,
|
|
5
|
+
loadFaviconAsset,
|
|
6
|
+
} from '@/server/brand-assets'
|
|
2
7
|
|
|
3
8
|
export const dynamic = 'force-dynamic'
|
|
4
9
|
|
|
5
10
|
export const contentType = 'image/svg+xml'
|
|
6
11
|
|
|
7
12
|
export default async function Icon(): Promise<Response> {
|
|
13
|
+
const asset = await loadFaviconAsset()
|
|
14
|
+
|
|
15
|
+
if (asset !== null && asset.type === 'image/svg+xml') {
|
|
16
|
+
return new Response(asset.bytes as unknown as BodyInit, {
|
|
17
|
+
headers: { 'Content-Type': 'image/svg+xml' },
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
const info = await loadBrandInfo()
|
|
22
|
+
const embedded = asset === null ? null : faviconDataUri(asset)
|
|
9
23
|
|
|
10
|
-
return new Response(buildFaviconSvg(info), {
|
|
24
|
+
return new Response(buildFaviconSvg(info, embedded), {
|
|
11
25
|
headers: { 'Content-Type': 'image/svg+xml' },
|
|
12
26
|
})
|
|
13
27
|
}
|
package/app/layout.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { CopyProvider } from '@/components/shell/copy'
|
|
|
10
10
|
import { CrashNoticeProvider } from '@/components/shell/crash-notice'
|
|
11
11
|
import { DemoBanner } from '@/components/shell/demo-banner'
|
|
12
12
|
import { GroupNameStyle } from '@/components/shell/group-name-style'
|
|
13
|
+
import { ServiceWorkerRegistrar } from '@/components/shell/service-worker'
|
|
13
14
|
import { ThemeRuntimeStyle } from '@/components/shell/theme-runtime-style'
|
|
14
15
|
import { TimezoneProbe } from '@/components/shell/timezone-probe'
|
|
15
16
|
import { crashNotice } from '@/server/error-notice'
|
|
@@ -104,6 +105,7 @@ export default async function RootLayout({
|
|
|
104
105
|
<ThemeRuntimeStyle />
|
|
105
106
|
<GroupNameStyle />
|
|
106
107
|
<TimezoneProbe />
|
|
108
|
+
<ServiceWorkerRegistrar />
|
|
107
109
|
</head>
|
|
108
110
|
<body className="font-sans antialiased">
|
|
109
111
|
<DemoBanner />
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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,53 +43,53 @@
|
|
|
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/core": "0.
|
|
54
|
-
"@meith/db": "0.
|
|
55
|
-
"@meith/demo": "0.
|
|
56
|
-
"@meith/drafts": "0.
|
|
57
|
-
"@meith/drivers": "0.
|
|
58
|
-
"@meith/events": "0.
|
|
59
|
-
"@meith/forums": "0.
|
|
60
|
-
"@meith/groups": "0.
|
|
61
|
-
"@meith/i18n": "0.
|
|
62
|
-
"@meith/import": "0.
|
|
63
|
-
"@meith/install": "0.
|
|
64
|
-
"@meith/mail": "0.
|
|
65
|
-
"@meith/markdown": "0.
|
|
66
|
-
"@meith/marketplace": "0.
|
|
67
|
-
"@meith/messages": "0.
|
|
68
|
-
"@meith/moderation": "0.
|
|
69
|
-
"@meith/notifications": "0.
|
|
70
|
-
"@meith/plugin-calendar": "0.
|
|
71
|
-
"@meith/plugin-dues": "0.
|
|
72
|
-
"@meith/plugin-kit": "0.
|
|
73
|
-
"@meith/polls": "0.
|
|
74
|
-
"@meith/posts": "0.
|
|
75
|
-
"@meith/profile-fields": "0.
|
|
76
|
-
"@meith/relations": "0.
|
|
77
|
-
"@meith/reputation": "0.
|
|
78
|
-
"@meith/runtime": "0.
|
|
79
|
-
"@meith/search": "0.
|
|
80
|
-
"@meith/settings": "0.
|
|
81
|
-
"@meith/signatures": "0.
|
|
82
|
-
"@meith/subscriptions": "0.
|
|
83
|
-
"@meith/tasks": "0.
|
|
84
|
-
"@meith/theme-clubhouse": "0.
|
|
85
|
-
"@meith/theme-default": "0.
|
|
86
|
-
"@meith/theme-kit": "0.
|
|
87
|
-
"@meith/theme-midnight": "0.
|
|
88
|
-
"@meith/theme-phasebook": "0.
|
|
89
|
-
"@meith/theme-raidframe": "0.
|
|
90
|
-
"@meith/threads": "0.
|
|
91
|
-
"@meith/ui": "0.
|
|
92
|
-
"@meith/upgrade": "0.
|
|
46
|
+
"@meith/accounts": "0.30.0",
|
|
47
|
+
"@meith/admin": "0.30.0",
|
|
48
|
+
"@meith/antispam": "0.30.0",
|
|
49
|
+
"@meith/api": "0.30.0",
|
|
50
|
+
"@meith/attachments": "0.30.0",
|
|
51
|
+
"@meith/authorization": "0.30.0",
|
|
52
|
+
"@meith/avatars": "0.30.0",
|
|
53
|
+
"@meith/core": "0.30.0",
|
|
54
|
+
"@meith/db": "0.30.0",
|
|
55
|
+
"@meith/demo": "0.30.0",
|
|
56
|
+
"@meith/drafts": "0.30.0",
|
|
57
|
+
"@meith/drivers": "0.30.0",
|
|
58
|
+
"@meith/events": "0.30.0",
|
|
59
|
+
"@meith/forums": "0.30.0",
|
|
60
|
+
"@meith/groups": "0.30.0",
|
|
61
|
+
"@meith/i18n": "0.30.0",
|
|
62
|
+
"@meith/import": "0.30.0",
|
|
63
|
+
"@meith/install": "0.30.0",
|
|
64
|
+
"@meith/mail": "0.30.0",
|
|
65
|
+
"@meith/markdown": "0.30.0",
|
|
66
|
+
"@meith/marketplace": "0.30.0",
|
|
67
|
+
"@meith/messages": "0.30.0",
|
|
68
|
+
"@meith/moderation": "0.30.0",
|
|
69
|
+
"@meith/notifications": "0.30.0",
|
|
70
|
+
"@meith/plugin-calendar": "0.30.0",
|
|
71
|
+
"@meith/plugin-dues": "0.30.0",
|
|
72
|
+
"@meith/plugin-kit": "0.30.0",
|
|
73
|
+
"@meith/polls": "0.30.0",
|
|
74
|
+
"@meith/posts": "0.30.0",
|
|
75
|
+
"@meith/profile-fields": "0.30.0",
|
|
76
|
+
"@meith/relations": "0.30.0",
|
|
77
|
+
"@meith/reputation": "0.30.0",
|
|
78
|
+
"@meith/runtime": "0.30.0",
|
|
79
|
+
"@meith/search": "0.30.0",
|
|
80
|
+
"@meith/settings": "0.30.0",
|
|
81
|
+
"@meith/signatures": "0.30.0",
|
|
82
|
+
"@meith/subscriptions": "0.30.0",
|
|
83
|
+
"@meith/tasks": "0.30.0",
|
|
84
|
+
"@meith/theme-clubhouse": "0.30.0",
|
|
85
|
+
"@meith/theme-default": "0.30.0",
|
|
86
|
+
"@meith/theme-kit": "0.30.0",
|
|
87
|
+
"@meith/theme-midnight": "0.30.0",
|
|
88
|
+
"@meith/theme-phasebook": "0.30.0",
|
|
89
|
+
"@meith/theme-raidframe": "0.30.0",
|
|
90
|
+
"@meith/threads": "0.30.0",
|
|
91
|
+
"@meith/ui": "0.30.0",
|
|
92
|
+
"@meith/upgrade": "0.30.0"
|
|
93
93
|
},
|
|
94
94
|
"scripts": {
|
|
95
95
|
"dev": "next dev",
|
package/public/sw.js
CHANGED
|
@@ -44,8 +44,8 @@ self.addEventListener('push', (event) => {
|
|
|
44
44
|
body: typeof payload.body === 'string' ? payload.body : '',
|
|
45
45
|
tag: typeof payload.id === 'number' ? `meith-${payload.id}` : 'meith',
|
|
46
46
|
data: { href: targetUrl(payload.href) },
|
|
47
|
-
icon: '/
|
|
48
|
-
badge: '/icon-
|
|
47
|
+
icon: '/brand/icon-192.png',
|
|
48
|
+
badge: '/brand/icon-192.png',
|
|
49
49
|
timestamp: Date.now(),
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -4,7 +4,12 @@ import { useActionState } from 'react'
|
|
|
4
4
|
|
|
5
5
|
import { PANEL_CARD } from '@/components/shell/panel-list'
|
|
6
6
|
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
removeFaviconAction,
|
|
9
|
+
removeLogoAction,
|
|
10
|
+
saveFaviconAction,
|
|
11
|
+
saveLogoAction,
|
|
12
|
+
} from '@/server/branding-actions'
|
|
8
13
|
|
|
9
14
|
import { FormError, PendingButton, SubmitButton } from '../auth/form-controls'
|
|
10
15
|
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
@@ -20,6 +25,69 @@ export interface LogoSlot {
|
|
|
20
25
|
const GHOST =
|
|
21
26
|
'inline-flex h-8 items-center justify-center rounded-md border border-border px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
22
27
|
|
|
28
|
+
export function FaviconUploadForm({
|
|
29
|
+
src,
|
|
30
|
+
maxKib,
|
|
31
|
+
copy,
|
|
32
|
+
}: {
|
|
33
|
+
src: string | null
|
|
34
|
+
maxKib: number
|
|
35
|
+
copy: Copy
|
|
36
|
+
}) {
|
|
37
|
+
const [saved, saveAction] = useActionState(saveFaviconAction, EMPTY_STATE)
|
|
38
|
+
const [removed, removeAction] = useActionState(removeFaviconAction, EMPTY_STATE)
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className={PANEL_CARD}>
|
|
42
|
+
<FormError message={saved.error ?? removed.error} />
|
|
43
|
+
<Saved when={saved.notice === 'saved'}>{fromCopy(copy, 'adminPanel.favicon.saved')}</Saved>
|
|
44
|
+
<Saved when={removed.notice === 'removed'}>
|
|
45
|
+
{fromCopy(copy, 'adminPanel.favicon.removed')}
|
|
46
|
+
</Saved>
|
|
47
|
+
|
|
48
|
+
<div className="flex min-h-16 items-center justify-center rounded-md border border-border bg-card p-3">
|
|
49
|
+
{src === null ? (
|
|
50
|
+
<span className="text-xs text-muted-foreground">
|
|
51
|
+
{fromCopy(copy, 'adminPanel.favicon.nothing')}
|
|
52
|
+
</span>
|
|
53
|
+
) : (
|
|
54
|
+
<img src={src} alt="" className="size-8 object-contain" />
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<form action={saveAction} className="flex flex-col gap-2">
|
|
59
|
+
<label htmlFor="favicon" className="sr-only">
|
|
60
|
+
{fromCopy(copy, 'adminPanel.branding.upload')}
|
|
61
|
+
</label>
|
|
62
|
+
<input
|
|
63
|
+
id="favicon"
|
|
64
|
+
type="file"
|
|
65
|
+
name="favicon"
|
|
66
|
+
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
|
67
|
+
required
|
|
68
|
+
className="w-full text-xs file:mr-3 file:rounded-md file:border file:border-border file:bg-background file:px-3 file:py-1.5 file:text-xs file:font-medium"
|
|
69
|
+
/>
|
|
70
|
+
<p className="text-xs text-muted-foreground">
|
|
71
|
+
{formatFromCopy(copy, 'adminPanel.favicon.formats', { maxKib })}
|
|
72
|
+
</p>
|
|
73
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
74
|
+
<span className="min-w-32">
|
|
75
|
+
<SubmitButton>{fromCopy(copy, 'adminPanel.branding.upload')}</SubmitButton>
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
</form>
|
|
79
|
+
|
|
80
|
+
{src !== null && (
|
|
81
|
+
<form action={removeAction}>
|
|
82
|
+
<PendingButton showWorking className={GHOST}>
|
|
83
|
+
{fromCopy(copy, 'admin.remove')}
|
|
84
|
+
</PendingButton>
|
|
85
|
+
</form>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
23
91
|
export function LogoUploadForm({
|
|
24
92
|
slot,
|
|
25
93
|
maxKib,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { cspNonce } from '@/server/nonce'
|
|
2
|
+
|
|
3
|
+
const REGISTER = `(function(){try{
|
|
4
|
+
if(!('serviceWorker' in navigator))return;
|
|
5
|
+
var go=function(){navigator.serviceWorker.register('/sw.js',{scope:'/'}).catch(function(){});};
|
|
6
|
+
if(document.readyState==='complete')go();
|
|
7
|
+
else window.addEventListener('load',go,{once:true});
|
|
8
|
+
}catch(e){}})();`
|
|
9
|
+
|
|
10
|
+
export async function ServiceWorkerRegistrar() {
|
|
11
|
+
return <script nonce={await cspNonce()} dangerouslySetInnerHTML={{ __html: REGISTER }} />
|
|
12
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
export const MAX_API_BODY_BYTES = 256 * 1024
|
|
4
|
+
|
|
5
|
+
export type JsonBodyOutcome =
|
|
6
|
+
| { readonly kind: 'ok'; readonly body: Record<string, unknown> | null }
|
|
7
|
+
| { readonly kind: 'too-large' }
|
|
8
|
+
| { readonly kind: 'invalid' }
|
|
9
|
+
|
|
10
|
+
async function readBounded(
|
|
11
|
+
request: Request,
|
|
12
|
+
limit: number,
|
|
13
|
+
): Promise<Uint8Array | 'too-large' | 'unreadable'> {
|
|
14
|
+
const stream = request.body
|
|
15
|
+
if (stream === null) {
|
|
16
|
+
try {
|
|
17
|
+
const whole = new Uint8Array(await request.arrayBuffer())
|
|
18
|
+
return whole.byteLength > limit ? 'too-large' : whole
|
|
19
|
+
} catch {
|
|
20
|
+
return 'unreadable'
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const reader = stream.getReader()
|
|
25
|
+
const chunks: Uint8Array[] = []
|
|
26
|
+
let total = 0
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
let step = await reader.read()
|
|
30
|
+
while (!step.done) {
|
|
31
|
+
const chunk = step.value
|
|
32
|
+
if (chunk !== undefined) {
|
|
33
|
+
total += chunk.byteLength
|
|
34
|
+
if (total > limit) {
|
|
35
|
+
await reader.cancel().catch(() => {})
|
|
36
|
+
return 'too-large'
|
|
37
|
+
}
|
|
38
|
+
chunks.push(chunk)
|
|
39
|
+
}
|
|
40
|
+
step = await reader.read()
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
return 'unreadable'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const body = new Uint8Array(total)
|
|
47
|
+
let offset = 0
|
|
48
|
+
for (const chunk of chunks) {
|
|
49
|
+
body.set(chunk, offset)
|
|
50
|
+
offset += chunk.byteLength
|
|
51
|
+
}
|
|
52
|
+
return body
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readJsonBody(
|
|
56
|
+
request: Request,
|
|
57
|
+
limit: number = MAX_API_BODY_BYTES,
|
|
58
|
+
): Promise<JsonBodyOutcome> {
|
|
59
|
+
const declared = Number(request.headers.get('content-length') ?? '')
|
|
60
|
+
if (Number.isFinite(declared) && declared > limit) return { kind: 'too-large' }
|
|
61
|
+
|
|
62
|
+
const bytes = await readBounded(request, limit)
|
|
63
|
+
if (bytes === 'too-large') return { kind: 'too-large' }
|
|
64
|
+
if (bytes === 'unreadable') return { kind: 'invalid' }
|
|
65
|
+
if (bytes.byteLength === 0) return { kind: 'ok', body: null }
|
|
66
|
+
|
|
67
|
+
let parsed: unknown
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(new TextDecoder().decode(bytes))
|
|
70
|
+
} catch {
|
|
71
|
+
return { kind: 'invalid' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
kind: 'ok',
|
|
76
|
+
body:
|
|
77
|
+
typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -6,7 +6,7 @@ import { colourToHex } from '@meith/theme-kit'
|
|
|
6
6
|
|
|
7
7
|
import { BOARD_TITLE } from '@/view/shell'
|
|
8
8
|
|
|
9
|
-
import { logoKey } from './branding'
|
|
9
|
+
import { faviconKey, logoKey } from './branding'
|
|
10
10
|
import { contentTypeFor } from './image-upload'
|
|
11
11
|
import { getSettings } from './settings'
|
|
12
12
|
import { getBoardThemeStyle } from './theme-runtime'
|
|
@@ -33,6 +33,8 @@ export interface BrandInfo {
|
|
|
33
33
|
|
|
34
34
|
const EMBEDDABLE_LOGO_TYPES = new Set(['image/png', 'image/jpeg'])
|
|
35
35
|
|
|
36
|
+
const EMBEDDABLE_FAVICON_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'])
|
|
37
|
+
|
|
36
38
|
const MAX_EMBEDDED_LOGO_BYTES = 192 * 1024
|
|
37
39
|
|
|
38
40
|
const DESCRIPTION_LIMIT = 160
|
|
@@ -104,6 +106,40 @@ export async function loadBrandInfo(): Promise<BrandInfo> {
|
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
export interface FaviconAsset {
|
|
110
|
+
readonly type: string
|
|
111
|
+
readonly bytes: Uint8Array
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function loadFaviconAsset(): Promise<FaviconAsset | null> {
|
|
115
|
+
try {
|
|
116
|
+
const key = await faviconKey()
|
|
117
|
+
if (key === null) return null
|
|
118
|
+
|
|
119
|
+
const type = contentTypeFor(key)
|
|
120
|
+
const bytes = await drivers().files.get(key)
|
|
121
|
+
if (bytes === undefined || bytes.byteLength === 0) return null
|
|
122
|
+
if (bytes.byteLength > MAX_EMBEDDED_LOGO_BYTES) return null
|
|
123
|
+
|
|
124
|
+
return { type, bytes: new Uint8Array(bytes) }
|
|
125
|
+
} catch {
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function faviconDataUri(asset: FaviconAsset): string | null {
|
|
131
|
+
if (!EMBEDDABLE_FAVICON_TYPES.has(asset.type)) return null
|
|
132
|
+
return `data:${asset.type};base64,${Buffer.from(asset.bytes).toString('base64')}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function loadMarkDataUri(): Promise<string | null> {
|
|
136
|
+
const asset = await loadFaviconAsset()
|
|
137
|
+
if (asset !== null && EMBEDDABLE_LOGO_TYPES.has(asset.type)) {
|
|
138
|
+
return `data:${asset.type};base64,${Buffer.from(asset.bytes).toString('base64')}`
|
|
139
|
+
}
|
|
140
|
+
return loadLogoDataUri('light')
|
|
141
|
+
}
|
|
142
|
+
|
|
107
143
|
export async function loadLogoDataUri(scheme: BrandScheme): Promise<string | null> {
|
|
108
144
|
try {
|
|
109
145
|
const key = (await logoKey(scheme)) ?? (await logoKey(scheme === 'light' ? 'dark' : 'light'))
|
|
@@ -131,7 +167,15 @@ function escapeXml(value: string): string {
|
|
|
131
167
|
.replaceAll("'", ''')
|
|
132
168
|
}
|
|
133
169
|
|
|
134
|
-
export function buildFaviconSvg(info: BrandInfo): string {
|
|
170
|
+
export function buildFaviconSvg(info: BrandInfo, embedded?: string | null): string {
|
|
171
|
+
if (embedded !== undefined && embedded !== null && embedded !== '') {
|
|
172
|
+
return [
|
|
173
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">`,
|
|
174
|
+
`<image href="${escapeXml(embedded)}" width="64" height="64" preserveAspectRatio="xMidYMid meet"/>`,
|
|
175
|
+
`</svg>`,
|
|
176
|
+
].join('')
|
|
177
|
+
}
|
|
178
|
+
|
|
135
179
|
const label = escapeXml(info.initials)
|
|
136
180
|
return [
|
|
137
181
|
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">`,
|
|
@@ -3,7 +3,7 @@ import 'server-only'
|
|
|
3
3
|
import { ImageResponse } from 'next/og'
|
|
4
4
|
import type { ReactElement } from 'react'
|
|
5
5
|
|
|
6
|
-
import { type BrandInfo, loadBrandInfo, loadLogoDataUri } from './brand-assets'
|
|
6
|
+
import { type BrandInfo, loadBrandInfo, loadLogoDataUri, loadMarkDataUri } from './brand-assets'
|
|
7
7
|
|
|
8
8
|
interface MarkOptions {
|
|
9
9
|
readonly size: number
|
|
@@ -94,7 +94,7 @@ function socialElement(info: BrandInfo, logo: string | null): ReactElement {
|
|
|
94
94
|
|
|
95
95
|
export async function renderBrandMark(size: number, maskable = false): Promise<ImageResponse> {
|
|
96
96
|
const info = await loadBrandInfo()
|
|
97
|
-
const logo = await
|
|
97
|
+
const logo = await loadMarkDataUri()
|
|
98
98
|
|
|
99
99
|
return new ImageResponse(markElement(info, logo, { size, maskable }), {
|
|
100
100
|
width: size,
|
|
@@ -5,7 +5,15 @@ import { msg } from '@meith/i18n'
|
|
|
5
5
|
|
|
6
6
|
import { recordAdminAction, requireAdmin } from './admin'
|
|
7
7
|
import type { FormState } from './auth-form-state'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
FAVICON_FIELD,
|
|
10
|
+
isLogoScheme,
|
|
11
|
+
LOGO_FIELD,
|
|
12
|
+
removeFavicon,
|
|
13
|
+
removeLogo,
|
|
14
|
+
saveFavicon,
|
|
15
|
+
saveLogo,
|
|
16
|
+
} from './branding'
|
|
9
17
|
import { formStateReporter } from './form-state-reporter'
|
|
10
18
|
|
|
11
19
|
const toFormState = formStateReporter('branding', 'logo write failed')
|
|
@@ -51,3 +59,34 @@ export async function removeLogoAction(_prev: FormState, form: FormData): Promis
|
|
|
51
59
|
return toFormState(err)
|
|
52
60
|
}
|
|
53
61
|
}
|
|
62
|
+
|
|
63
|
+
export async function saveFaviconAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
64
|
+
try {
|
|
65
|
+
await requireAdmin()
|
|
66
|
+
|
|
67
|
+
const file = form.get(FAVICON_FIELD)
|
|
68
|
+
if (!(file instanceof File) || file.size === 0) {
|
|
69
|
+
throw new ValidationError(msg('error.app.choose-image-first'))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await saveFavicon(file)
|
|
73
|
+
await recordAdminAction({ action: 'branding.favicon_saved', detail: { bytes: file.size } })
|
|
74
|
+
|
|
75
|
+
return { notice: 'saved' }
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return toFormState(err)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function removeFaviconAction(_prev: FormState, _form: FormData): Promise<FormState> {
|
|
82
|
+
try {
|
|
83
|
+
await requireAdmin()
|
|
84
|
+
|
|
85
|
+
await removeFavicon()
|
|
86
|
+
await recordAdminAction({ action: 'branding.favicon_removed', detail: {} })
|
|
87
|
+
|
|
88
|
+
return { notice: 'removed' }
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return toFormState(err)
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/server/branding.ts
CHANGED
|
@@ -5,7 +5,14 @@ import { cache } from 'react'
|
|
|
5
5
|
import { CacheTags } from '@meith/core'
|
|
6
6
|
import { getDb, PostgresSettingsRepository } from '@meith/db'
|
|
7
7
|
import { drivers } from '@meith/drivers'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
faviconPath,
|
|
10
|
+
isFaviconKey,
|
|
11
|
+
isLogoKey,
|
|
12
|
+
type LogoScheme,
|
|
13
|
+
logoPath,
|
|
14
|
+
saveSettings,
|
|
15
|
+
} from '@meith/settings'
|
|
9
16
|
|
|
10
17
|
import { forgetImage, storeImage } from './image-upload'
|
|
11
18
|
import { getSettings } from './settings'
|
|
@@ -53,8 +60,33 @@ export async function logoKey(scheme: LogoScheme): Promise<string | null> {
|
|
|
53
60
|
return isLogoKey(key) ? key : null
|
|
54
61
|
}
|
|
55
62
|
|
|
63
|
+
export const FAVICON_FIELD = 'favicon'
|
|
64
|
+
|
|
65
|
+
export async function saveFavicon(file: File): Promise<void> {
|
|
66
|
+
const key = await storeImage('board', 'favicon', file)
|
|
67
|
+
|
|
68
|
+
const previous = (await getSettings()).get('board.favicon')
|
|
69
|
+
await writeSetting('board.favicon', key)
|
|
70
|
+
|
|
71
|
+
if (previous !== key) await forgetImage(previous)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function removeFavicon(): Promise<void> {
|
|
75
|
+
const previous = (await getSettings()).get('board.favicon')
|
|
76
|
+
await writeSetting('board.favicon', '')
|
|
77
|
+
|
|
78
|
+
await forgetImage(previous)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function faviconKey(): Promise<string | null> {
|
|
82
|
+
const key = (await getSettings()).get('board.favicon')
|
|
83
|
+
return isFaviconKey(key) ? key : null
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
export const logoSrc = logoPath
|
|
57
87
|
|
|
88
|
+
export const faviconSrc = faviconPath
|
|
89
|
+
|
|
58
90
|
export interface BoardLogo {
|
|
59
91
|
readonly src: string
|
|
60
92
|
readonly darkSrc: string | null
|
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import 'server-only'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { env } from '@meith/core'
|
|
4
|
+
|
|
5
|
+
function canonicalOrigin(): URL | null {
|
|
6
|
+
const configured = env.APP_URL
|
|
7
|
+
if (configured === undefined || configured === '') return null
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
return new URL(configured)
|
|
11
|
+
} catch {
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function requestHost(request: Request): string | null {
|
|
17
|
+
const forwarded = env.TRUSTED_PROXY_HOPS > 0 ? request.headers.get('x-forwarded-host') : null
|
|
18
|
+
const host = forwarded ?? request.headers.get('host')
|
|
5
19
|
if (host === null) return null
|
|
6
20
|
|
|
7
21
|
const trimmed = host.trim().toLowerCase()
|
|
@@ -12,14 +26,23 @@ export function isSameOrigin(request: Request): boolean {
|
|
|
12
26
|
const origin = request.headers.get('origin')?.trim()
|
|
13
27
|
|
|
14
28
|
if (origin !== undefined && origin !== '' && origin !== 'null') {
|
|
15
|
-
|
|
16
|
-
if (host === null) return false
|
|
17
|
-
|
|
29
|
+
let presented: URL
|
|
18
30
|
try {
|
|
19
|
-
|
|
31
|
+
presented = new URL(origin)
|
|
20
32
|
} catch {
|
|
21
33
|
return false
|
|
22
34
|
}
|
|
35
|
+
|
|
36
|
+
const canonical = canonicalOrigin()
|
|
37
|
+
if (canonical !== null) {
|
|
38
|
+
return (
|
|
39
|
+
presented.host.toLowerCase() === canonical.host.toLowerCase() &&
|
|
40
|
+
presented.protocol === canonical.protocol
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const host = requestHost(request)
|
|
45
|
+
return host !== null && presented.host.toLowerCase() === host
|
|
23
46
|
}
|
|
24
47
|
|
|
25
48
|
const site = request.headers.get('sec-fetch-site')
|
|
@@ -16,7 +16,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
|
|
|
16
16
|
|
|
17
17
|
import { activeDefinitions } from './plugin-host'
|
|
18
18
|
|
|
19
|
-
export const CODE_VERSION = '0.
|
|
19
|
+
export const CODE_VERSION = '0.30.0'
|
|
20
20
|
|
|
21
21
|
export interface UpgradeApplied {
|
|
22
22
|
readonly plugins: readonly string[]
|
|
@@ -180,6 +180,22 @@ export function brandingFormsCopy(
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
export function faviconFormsCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
|
|
184
|
+
return {
|
|
185
|
+
...adminSharedCopy(t),
|
|
186
|
+
...copyFor(
|
|
187
|
+
[
|
|
188
|
+
'adminPanel.favicon.saved',
|
|
189
|
+
'adminPanel.favicon.removed',
|
|
190
|
+
'adminPanel.favicon.nothing',
|
|
191
|
+
'adminPanel.branding.upload',
|
|
192
|
+
],
|
|
193
|
+
t,
|
|
194
|
+
),
|
|
195
|
+
...patternCopy(['adminPanel.favicon.formats'], t),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
183
199
|
export function badgeFormsCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
|
|
184
200
|
return {
|
|
185
201
|
...adminSharedCopy(t),
|