Files
eranos/src/lib/fontLoader.ts
T
Alex Gleason 0d3b8ed23d Harden CSS/URL handling, NWC storage, and Android backup
- Sanitize event-sourced URLs before CSS url() interpolation in
  ProfileCard banner and letter stationery background (closes H-1, H-2)
- Sanitize event-sourced font families at the parse layer and in letter
  card/detail consumers that bypass resolveStationery (closes M-6)
- Export sanitizeCssString for broader reuse
- Route NWC wallet connection URIs and active pointer through a new
  useSecureLocalStorage hook, storing in iOS Keychain / Android KeyStore
  on native (closes M-1)
- Add removeItem to secureStorage
- Add Android backup/data-extraction rules that exclude WebView storage
  and Capacitor secure-storage SharedPreferences so wallet credentials
  don't leak via Google Auto Backup (closes M-5)
- Document that GOOGLE_PLAY_SERVICE_ACCOUNT_JSON must be base64-encoded
  to match what the CI job expects (closes M-2)
2026-04-16 14:20:26 -05:00

177 lines
5.6 KiB
TypeScript

/**
* Font loading utilities.
*
* Handles loading fonts from:
* 1. Bundled fontsource packages (via dynamic import)
* 2. Remote URLs (via @font-face injection)
*
* Also manages the CSS override that applies a custom font to the document.
*/
import type { ThemeFont } from '@/themes';
import { findBundledFont, loadBundledFont, resolveCssFamily } from '@/lib/fonts';
// ─── CSS string sanitisation ──────────────────────────────────────────
/**
* Sanitize a string for safe interpolation into a double-quoted CSS context.
* Uses an allowlist approach — only Unicode letters, numbers, spaces, hyphens,
* underscores, apostrophes, and periods are permitted. Everything else is stripped.
*
* Use whenever event-sourced strings flow into a CSS declaration value
* (e.g. `font-family`) to prevent CSS-string breakout.
*/
export function sanitizeCssString(value: string): string {
return value.replace(/[^\p{L}\p{N} _\-'.]/gu, '');
}
// ─── @font-face injection for remote fonts ────────────────────────────
/** Style element ID for injected @font-face rules. */
const FONT_FACE_STYLE_ID = 'theme-font-faces';
/** Tracks which remote font URLs have already been injected. */
const injectedUrls = new Set<string>();
/**
* Inject a @font-face rule for a remote font URL.
* Idempotent — won't inject the same URL twice.
*/
function injectFontFace(family: string, url: string): void {
if (injectedUrls.has(url)) return;
let style = document.getElementById(FONT_FACE_STYLE_ID) as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = FONT_FACE_STYLE_ID;
document.head.appendChild(style);
}
const safeFamily = sanitizeCssString(family);
const rule = `
@font-face {
font-family: "${safeFamily}";
src: url("${url}");
font-display: swap;
}`;
style.textContent += rule;
injectedUrls.add(url);
}
// ─── Font override CSS ───────────────────────────────────────────────
/** Style element ID for font-family overrides. */
const FONT_OVERRIDE_STYLE_ID = 'theme-font-overrides';
/** Default font stack when no custom font is set. */
const DEFAULT_FONT_STACK = '"Inter Variable", Inter, system-ui, sans-serif';
/**
* Apply a font-family override to the document.
* The font applies globally to the html element (cascades to everything).
*
* Pass undefined to clear the override.
*/
export function applyFontOverride(font: ThemeFont | undefined): void {
let style = document.getElementById(FONT_OVERRIDE_STYLE_ID) as HTMLStyleElement | null;
if (!font) {
// No custom font — remove override
style?.remove();
return;
}
if (!style) {
style = document.createElement('style');
style.id = FONT_OVERRIDE_STYLE_ID;
document.head.appendChild(style);
}
const cssFamily = sanitizeCssString(resolveCssFamily(font.family));
style.textContent = `html { font-family: "${cssFamily}", ${DEFAULT_FONT_STACK} !important; }\n`;
}
// ─── High-level font loading ──────────────────────────────────────────
/**
* Load a single font by family name and optional URL.
* Tries bundled fonts first, falls back to remote URL injection.
*/
export async function loadFont(family: string, url?: string): Promise<void> {
// Try bundled font first
const loaded = await loadBundledFont(family);
if (loaded) return;
// Fall back to remote URL if provided
if (url) {
injectFontFace(family, url);
}
}
/**
* Load a theme font and apply the CSS override.
* This is the main entry point for applying a theme font.
*/
export async function loadAndApplyFont(font: ThemeFont | undefined): Promise<void> {
if (!font) {
applyFontOverride(undefined);
return;
}
await loadFont(font.family, font.url);
applyFontOverride(font);
}
// ─── Title Font CSS Override ──────────────────────────────────────────
/** Style element ID for title font CSS custom property. */
const TITLE_FONT_OVERRIDE_STYLE_ID = 'theme-title-font-overrides';
/**
* Apply a CSS custom property `--title-font-family` to the document.
* Components that render the profile display name read this variable.
*
* Pass undefined to clear the override.
*/
export function applyTitleFontOverride(font: ThemeFont | undefined): void {
let style = document.getElementById(TITLE_FONT_OVERRIDE_STYLE_ID) as HTMLStyleElement | null;
if (!font) {
style?.remove();
return;
}
if (!style) {
style = document.createElement('style');
style.id = TITLE_FONT_OVERRIDE_STYLE_ID;
document.head.appendChild(style);
}
const cssFamily = sanitizeCssString(resolveCssFamily(font.family));
style.textContent = `:root { --title-font-family: "${cssFamily}", ${DEFAULT_FONT_STACK}; }\n`;
}
/**
* Load a title font and apply the CSS custom property override.
*/
export async function loadAndApplyTitleFont(font: ThemeFont | undefined): Promise<void> {
if (!font) {
applyTitleFontOverride(undefined);
return;
}
await loadFont(font.family, font.url);
applyTitleFontOverride(font);
}
/**
* Resolve font URLs for publishing to Nostr.
* For bundled fonts, returns the CDN URL. For others, preserves the existing URL.
*/
export function resolveFontUrl(family: string, existingUrl?: string): string | undefined {
const bundled = findBundledFont(family);
if (bundled) return bundled.cdnUrl;
return existingUrl;
}