@meith/web 0.33.0 → 0.33.1

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.
@@ -53,7 +53,7 @@ export default async function AdminSystemPage() {
53
53
  )
54
54
  }
55
55
 
56
- const { mail, scheduler, volumes } = view
56
+ const { mail, scheduler, volumes, legacyPasswordHashes } = view
57
57
 
58
58
  return (
59
59
  <PanelPage
@@ -252,6 +252,20 @@ export default async function AdminSystemPage() {
252
252
  </ul>
253
253
  </section>
254
254
 
255
+ {legacyPasswordHashes > 0 && (
256
+ <section className={PANEL_CARD}>
257
+ <h2 className="font-heading text-lg font-semibold">
258
+ {translator.t('adminSystem.legacyPasswords')}
259
+ </h2>
260
+ <p className="text-sm">
261
+ {translator.t('adminSystem.legacyPasswordsCount', { count: legacyPasswordHashes })}
262
+ </p>
263
+ <p className="text-sm text-muted-foreground">
264
+ {translator.t('adminSystem.legacyPasswordsHint')}
265
+ </p>
266
+ </section>
267
+ )}
268
+
255
269
  <section className={PANEL_CARD}>
256
270
  <h2 className="font-heading text-lg font-semibold">
257
271
  {translator.t('adminSystem.migrations')}
@@ -15,12 +15,13 @@ export async function generateMetadata(): Promise<Metadata> {
15
15
  export default async function RedirectPage({
16
16
  searchParams,
17
17
  }: {
18
- searchParams: Promise<{ to?: string; message?: string }>
18
+ searchParams: Promise<{ to?: string; m?: string }>
19
19
  }) {
20
- const { to, message } = await searchParams
20
+ const { to, m } = await searchParams
21
+ const translator = await getTranslator()
21
22
  const filtered = await filterView(
22
23
  'view.redirect-notice',
23
- buildRedirectNotice(to, message),
24
+ buildRedirectNotice(to, m, translator),
24
25
  viewerRef(await getActor()),
25
26
  )
26
27
  const notice = { ...filtered, targetHref: localHref(filtered.targetHref) }
@@ -36,7 +37,7 @@ export default async function RedirectPage({
36
37
  >
37
38
  <RedirectNotice
38
39
  {...notice}
39
- copy={slotCopy(await currentTheme(), 'RedirectNotice', await getTranslator())}
40
+ copy={slotCopy(await currentTheme(), 'RedirectNotice', translator)}
40
41
  />
41
42
  </main>
42
43
  </>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
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,54 @@
43
43
  "react-dom": "19.2.8",
44
44
  "tailwindcss": "^4.3.3",
45
45
  "typescript": "7.0.2",
46
- "@meith/accounts": "0.33.0",
47
- "@meith/admin": "0.33.0",
48
- "@meith/antispam": "0.33.0",
49
- "@meith/api": "0.33.0",
50
- "@meith/attachments": "0.33.0",
51
- "@meith/authorization": "0.33.0",
52
- "@meith/avatars": "0.33.0",
53
- "@meith/board-digest": "0.33.0",
54
- "@meith/core": "0.33.0",
55
- "@meith/db": "0.33.0",
56
- "@meith/demo": "0.33.0",
57
- "@meith/drafts": "0.33.0",
58
- "@meith/drivers": "0.33.0",
59
- "@meith/events": "0.33.0",
60
- "@meith/forums": "0.33.0",
61
- "@meith/groups": "0.33.0",
62
- "@meith/i18n": "0.33.0",
63
- "@meith/import": "0.33.0",
64
- "@meith/install": "0.33.0",
65
- "@meith/mail": "0.33.0",
66
- "@meith/markdown": "0.33.0",
67
- "@meith/marketplace": "0.33.0",
68
- "@meith/messages": "0.33.0",
69
- "@meith/moderation": "0.33.0",
70
- "@meith/notifications": "0.33.0",
71
- "@meith/plugin-calendar": "0.33.0",
72
- "@meith/plugin-dues": "0.33.0",
73
- "@meith/plugin-kit": "0.33.0",
74
- "@meith/polls": "0.33.0",
75
- "@meith/posts": "0.33.0",
76
- "@meith/profile-fields": "0.33.0",
77
- "@meith/relations": "0.33.0",
78
- "@meith/reputation": "0.33.0",
79
- "@meith/runtime": "0.33.0",
80
- "@meith/search": "0.33.0",
81
- "@meith/settings": "0.33.0",
82
- "@meith/signatures": "0.33.0",
83
- "@meith/subscriptions": "0.33.0",
84
- "@meith/tasks": "0.33.0",
85
- "@meith/theme-clubhouse": "0.33.0",
86
- "@meith/theme-default": "0.33.0",
87
- "@meith/theme-kit": "0.33.0",
88
- "@meith/theme-midnight": "0.33.0",
89
- "@meith/theme-phasebook": "0.33.0",
90
- "@meith/theme-raidframe": "0.33.0",
91
- "@meith/threads": "0.33.0",
92
- "@meith/ui": "0.33.0",
93
- "@meith/upgrade": "0.33.0"
46
+ "@meith/accounts": "0.33.1",
47
+ "@meith/admin": "0.33.1",
48
+ "@meith/antispam": "0.33.1",
49
+ "@meith/api": "0.33.1",
50
+ "@meith/attachments": "0.33.1",
51
+ "@meith/authorization": "0.33.1",
52
+ "@meith/avatars": "0.33.1",
53
+ "@meith/board-digest": "0.33.1",
54
+ "@meith/core": "0.33.1",
55
+ "@meith/db": "0.33.1",
56
+ "@meith/demo": "0.33.1",
57
+ "@meith/drafts": "0.33.1",
58
+ "@meith/drivers": "0.33.1",
59
+ "@meith/events": "0.33.1",
60
+ "@meith/forums": "0.33.1",
61
+ "@meith/groups": "0.33.1",
62
+ "@meith/i18n": "0.33.1",
63
+ "@meith/import": "0.33.1",
64
+ "@meith/install": "0.33.1",
65
+ "@meith/mail": "0.33.1",
66
+ "@meith/markdown": "0.33.1",
67
+ "@meith/marketplace": "0.33.1",
68
+ "@meith/messages": "0.33.1",
69
+ "@meith/moderation": "0.33.1",
70
+ "@meith/notifications": "0.33.1",
71
+ "@meith/plugin-calendar": "0.33.1",
72
+ "@meith/plugin-dues": "0.33.1",
73
+ "@meith/plugin-kit": "0.33.1",
74
+ "@meith/polls": "0.33.1",
75
+ "@meith/posts": "0.33.1",
76
+ "@meith/profile-fields": "0.33.1",
77
+ "@meith/relations": "0.33.1",
78
+ "@meith/reputation": "0.33.1",
79
+ "@meith/runtime": "0.33.1",
80
+ "@meith/search": "0.33.1",
81
+ "@meith/settings": "0.33.1",
82
+ "@meith/signatures": "0.33.1",
83
+ "@meith/subscriptions": "0.33.1",
84
+ "@meith/tasks": "0.33.1",
85
+ "@meith/theme-clubhouse": "0.33.1",
86
+ "@meith/theme-default": "0.33.1",
87
+ "@meith/theme-kit": "0.33.1",
88
+ "@meith/theme-midnight": "0.33.1",
89
+ "@meith/theme-phasebook": "0.33.1",
90
+ "@meith/theme-raidframe": "0.33.1",
91
+ "@meith/threads": "0.33.1",
92
+ "@meith/ui": "0.33.1",
93
+ "@meith/upgrade": "0.33.1"
94
94
  },
95
95
  "scripts": {
96
96
  "dev": "next dev",
@@ -507,7 +507,7 @@ export function ThemeEditorForm({
507
507
  <p className="text-xs text-muted-foreground">
508
508
  {fromCopy(copy, 'adminTheme.validated.blurb')}
509
509
  </p>
510
- <style dangerouslySetInnerHTML={{ __html: state.preview }} />
510
+ <style>{state.preview}</style>
511
511
  <ValidatedSample copy={copy} />
512
512
  </section>
513
513
  )}
@@ -46,6 +46,31 @@ export function sniff(bytes: Uint8Array): ImageFormat | null {
46
46
  return null
47
47
  }
48
48
 
49
+ const DANGEROUS_TAG = /<\s*(script|foreignobject|iframe|embed|object|set|animate\w*)\b/i
50
+ const EVENT_HANDLER_OR_SCRIPT_SCHEME = /\son\w+\s*=|javascript:|vbscript:/i
51
+ const REFERENCING_TAG = /<\s*(use|image|feimage)\b([^>]*)>/gi
52
+ const HREF_ATTRIBUTE = /(?:xlink:href|href)\s*=\s*(\x22|\x27)([\s\S]*?)\1/i
53
+
54
+ function decodeEntities(value: string): string {
55
+ return value.replace(/&(#\d+|#x[0-9a-f]+|amp|lt|gt|quot|apos);/gi, (match, entity: string) => {
56
+ if (entity.startsWith('#')) {
57
+ const isHex = entity[1] === 'x' || entity[1] === 'X'
58
+ const code = Number.parseInt(entity.slice(isHex ? 2 : 1), isHex ? 16 : 10)
59
+ return Number.isFinite(code) ? String.fromCodePoint(code) : match
60
+ }
61
+ const named: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
62
+ return named[entity.toLowerCase()] ?? match
63
+ })
64
+ }
65
+
66
+ function hasExternalReference(decoded: string): boolean {
67
+ for (const match of decoded.matchAll(REFERENCING_TAG)) {
68
+ const href = HREF_ATTRIBUTE.exec(match[2] ?? '')?.[2]?.trim()
69
+ if (href !== undefined && href !== '' && !href.startsWith('#')) return true
70
+ }
71
+ return false
72
+ }
73
+
49
74
  function isSvg(bytes: Uint8Array): boolean {
50
75
  const decoder = new TextDecoder('utf-8', { fatal: false })
51
76
  const head = decoder
@@ -56,9 +81,14 @@ function isSvg(bytes: Uint8Array): boolean {
56
81
  const looksSvg = head.startsWith('<svg') || /^<\?xml[\s\S]{0,512}?<svg/i.test(head)
57
82
  if (!looksSvg) return false
58
83
 
59
- if (/<script|<foreignObject|\son\w+\s*=|javascript:/i.test(decoder.decode(bytes))) {
84
+ const decoded = decodeEntities(decoder.decode(bytes))
85
+
86
+ if (DANGEROUS_TAG.test(decoded) || EVENT_HANDLER_OR_SCRIPT_SCHEME.test(decoded)) {
60
87
  throw new ValidationError(msg('error.app.svg-contains-script-event-handler'))
61
88
  }
89
+ if (hasExternalReference(decoded)) {
90
+ throw new ValidationError(msg('error.app.svg-contains-external-reference'))
91
+ }
62
92
  return true
63
93
  }
64
94
 
@@ -50,6 +50,7 @@ export interface SystemHealthView {
50
50
  readonly recount: readonly RecountStateRow[]
51
51
  readonly volumes: BoardVolumes
52
52
  readonly prunableSessions: number
53
+ readonly legacyPasswordHashes: number
53
54
  }
54
55
 
55
56
  export async function buildSystemHealthView(now: Date): Promise<SystemHealthView | null> {
@@ -57,15 +58,17 @@ export async function buildSystemHealthView(now: Date): Promise<SystemHealthView
57
58
  if (repository === null) return null
58
59
 
59
60
  const maintenance = new PostgresMaintenanceRepository(getDb())
60
- const [tasks, runs, recount, volumes, prunableSessions, searchIndex, mail] = await Promise.all([
61
- repository.taskHealth(),
62
- repository.recentRuns(20),
63
- repository.recountState(),
64
- repository.volumes(),
65
- maintenance.countPrunableSessions(now),
66
- new PostgresSearchRepository(getDb()).indexProgress(),
67
- assessMailReadiness(),
68
- ])
61
+ const [tasks, runs, recount, volumes, prunableSessions, searchIndex, mail, legacyPasswordHashes] =
62
+ await Promise.all([
63
+ repository.taskHealth(),
64
+ repository.recentRuns(20),
65
+ repository.recountState(),
66
+ repository.volumes(),
67
+ maintenance.countPrunableSessions(now),
68
+ new PostgresSearchRepository(getDb()).indexProgress(),
69
+ assessMailReadiness(),
70
+ repository.legacyPasswordHashes(),
71
+ ])
69
72
 
70
73
  const scheduler = assessScheduler(tasks, now)
71
74
  const taskMessages = new Map(
@@ -86,5 +89,6 @@ export async function buildSystemHealthView(now: Date): Promise<SystemHealthView
86
89
  recount,
87
90
  volumes,
88
91
  prunableSessions,
92
+ legacyPasswordHashes,
89
93
  }
90
94
  }
@@ -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.33.0'
19
+ export const CODE_VERSION = '0.33.1'
20
20
 
21
21
  export interface UpgradeApplied {
22
22
  readonly plugins: readonly string[]
@@ -1,7 +1,19 @@
1
+ import type { Translator } from '@meith/i18n'
1
2
  import type { RedirectNoticeModel } from '@meith/theme-kit'
2
3
 
4
+ import { untranslated } from './time'
5
+
3
6
  export const REDIRECT_DELAY_SECONDS = 2
4
7
 
8
+ const DEFAULT_MESSAGE_KEY = 'redirectNotice.continuing'
9
+
10
+ const REDIRECT_MESSAGE_KEYS: Readonly<Record<string, string>> = {
11
+ continuing: DEFAULT_MESSAGE_KEY,
12
+ 'signed-out': 'redirectNotice.signedOut',
13
+ saved: 'redirectNotice.saved',
14
+ posted: 'redirectNotice.posted',
15
+ }
16
+
5
17
  export function localHref(value: string | undefined): string {
6
18
  if (value === undefined || !value.startsWith('/')) return '/'
7
19
  const origin = 'https://forum.invalid'
@@ -11,11 +23,16 @@ export function localHref(value: string | undefined): string {
11
23
 
12
24
  export function buildRedirectNotice(
13
25
  target: string | undefined,
14
- message: string | undefined,
26
+ messageKey: string | undefined,
27
+ t: Translator = untranslated(),
15
28
  ): RedirectNoticeModel {
29
+ const key =
30
+ (messageKey !== undefined ? REDIRECT_MESSAGE_KEYS[messageKey] : undefined) ??
31
+ DEFAULT_MESSAGE_KEY
32
+
16
33
  return {
17
34
  targetHref: localHref(target),
18
- message: message?.trim() || 'Continuing…',
35
+ message: t.t(key),
19
36
  delaySeconds: REDIRECT_DELAY_SECONDS,
20
37
  }
21
38
  }