diff --git a/scripts/syncDecorations.mjs b/scripts/syncDecorations.mjs index f4969b6ad..ba1e0afdf 100644 --- a/scripts/syncDecorations.mjs +++ b/scripts/syncDecorations.mjs @@ -56,7 +56,8 @@ async function headCheck(slug) { const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' }); return { slug, ok: res.ok, status: res.status }; } catch { - return { slug, ok: false, status: 0 }; + // Network/DNS/TLS failure — NOT a confirmation the file is gone. + return { slug, ok: false, status: 0, networkError: true }; } } @@ -68,7 +69,27 @@ for (let i = 0; i < slugMatches.length; i += BATCH) { results.push(...batchResults); } -const missing = results.filter((r) => !r.ok); +// Only a CONFIRMED HTTP 404 means the file is genuinely gone and safe to +// remove. A network error or any other non-ok status (5xx, 403, timeout) is +// ambiguous — the CDN may be unreachable — so refuse to remove anything and +// abort, otherwise a transient outage would wipe the whole catalog from source +// control (N119). +const transient = results.filter((r) => !r.ok && r.status !== 404); +if (transient.length > 0) { + console.error( + `Aborting: ${transient.length} decoration(s) returned a non-404 failure ` + + `(network error / server error). The CDN may be unreachable — refusing to ` + + `remove entries to avoid wiping the catalog.`, + ); + transient + .slice(0, 8) + .forEach((r) => + console.error(` ${r.slug}: ${r.networkError ? 'network error' : `HTTP ${r.status}`}`), + ); + process.exit(1); +} + +const missing = results.filter((r) => r.status === 404); const found = results.filter((r) => r.ok); if (missing.length === 0) { diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 5517cdb24..dd4674785 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -242,6 +242,7 @@ function MessageNotifications() { roomId, eventId, body, + encrypted, }: { roomName: string; roomAvatar?: string; @@ -249,6 +250,7 @@ function MessageNotifications() { roomId: string; eventId: string; body?: string; + encrypted?: boolean; }) => { const roomPath = mDirects.has(roomId) ? getDirectRoomPath(roomId, eventId) @@ -267,10 +269,17 @@ function MessageNotifications() { return; } + // N109: the OS notification subsystem fetches icon/badge OUTSIDE the page, + // so the SW can't inject auth headers and authenticated-media URLs 401. + // Use the static app logo (as invite notifications already do). + // N106: never put decrypted E2EE plaintext into the OS notification (it + // persists in the notification center / lock screen / is readable by other + // apps). For encrypted rooms show only the sender; the in-page toast above + // still shows the preview while the user is actively looking at the screen. const noti = new window.Notification(roomName, { - icon: roomAvatar, - badge: roomAvatar, - body: body ? `${username}: ${body}`.slice(0, 120) : username, + icon: LogoSVG, + badge: LogoSVG, + body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username, silent: true, }); @@ -341,6 +350,7 @@ function MessageNotifications() { roomId: room.roomId, eventId, body: (mEvent.getContent().body as string | undefined) ?? '', + encrypted: room.hasEncryptionStateEvent(), }); } diff --git a/src/app/utils/sanitize.ts b/src/app/utils/sanitize.ts index 82de8efab..490de47aa 100644 --- a/src/app/utils/sanitize.ts +++ b/src/app/utils/sanitize.ts @@ -155,6 +155,11 @@ export const sanitizeCustomHtml = (customHtml: string): string => allowProtocolRelative: false, allowedClasses: { code: ['language-*'], + // `pre` permits `class` (for `
` wrappers); without + // an allowedClasses entry, sanitize-html lets a remote sender put ARBITRARY + // class names on, activating site CSS (N100). Restrict to the same + // language-* whitelist as. + pre: ['language-*'], }, allowedStyles: { '*': {