@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.
@@ -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: matched.route.request === undefined ? null : await readJsonBody(request),
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 { buildFaviconSvg, loadBrandInfo } from '@/server/brand-assets'
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.29.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.29.0",
47
- "@meith/admin": "0.29.0",
48
- "@meith/antispam": "0.29.0",
49
- "@meith/api": "0.29.0",
50
- "@meith/attachments": "0.29.0",
51
- "@meith/authorization": "0.29.0",
52
- "@meith/avatars": "0.29.0",
53
- "@meith/core": "0.29.0",
54
- "@meith/db": "0.29.0",
55
- "@meith/demo": "0.29.0",
56
- "@meith/drafts": "0.29.0",
57
- "@meith/drivers": "0.29.0",
58
- "@meith/events": "0.29.0",
59
- "@meith/forums": "0.29.0",
60
- "@meith/groups": "0.29.0",
61
- "@meith/i18n": "0.29.0",
62
- "@meith/import": "0.29.0",
63
- "@meith/install": "0.29.0",
64
- "@meith/mail": "0.29.0",
65
- "@meith/markdown": "0.29.0",
66
- "@meith/marketplace": "0.29.0",
67
- "@meith/messages": "0.29.0",
68
- "@meith/moderation": "0.29.0",
69
- "@meith/notifications": "0.29.0",
70
- "@meith/plugin-calendar": "0.29.0",
71
- "@meith/plugin-dues": "0.29.0",
72
- "@meith/plugin-kit": "0.29.0",
73
- "@meith/polls": "0.29.0",
74
- "@meith/posts": "0.29.0",
75
- "@meith/profile-fields": "0.29.0",
76
- "@meith/relations": "0.29.0",
77
- "@meith/reputation": "0.29.0",
78
- "@meith/runtime": "0.29.0",
79
- "@meith/search": "0.29.0",
80
- "@meith/settings": "0.29.0",
81
- "@meith/signatures": "0.29.0",
82
- "@meith/subscriptions": "0.29.0",
83
- "@meith/tasks": "0.29.0",
84
- "@meith/theme-clubhouse": "0.29.0",
85
- "@meith/theme-default": "0.29.0",
86
- "@meith/theme-kit": "0.29.0",
87
- "@meith/theme-midnight": "0.29.0",
88
- "@meith/theme-phasebook": "0.29.0",
89
- "@meith/theme-raidframe": "0.29.0",
90
- "@meith/threads": "0.29.0",
91
- "@meith/ui": "0.29.0",
92
- "@meith/upgrade": "0.29.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: '/apple-icon.png',
48
- badge: '/icon-light-32x32.png',
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 { removeLogoAction, saveLogoAction } from '@/server/branding-actions'
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("'", '&#39;')
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 loadLogoDataUri('light')
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 { isLogoScheme, LOGO_FIELD, removeLogo, saveLogo } from './branding'
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
+ }
@@ -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 { isLogoKey, type LogoScheme, logoPath, saveSettings } from '@meith/settings'
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
- function boardHost(request: Request): string | null {
4
- const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host')
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
- const host = boardHost(request)
16
- if (host === null) return false
17
-
29
+ let presented: URL
18
30
  try {
19
- return new URL(origin).host.toLowerCase() === host
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.29.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),