screentinker/frontend/js/i18n.js
ScreenTinker 9823aaf595 White-label: stop naming the upstream product to a reseller's customers (#292)
A partner reselling this platform reported that white-labelling changed the sidebar
title and the browser tab, and nothing else. Three fixes, in the order they matter to
them.

THE APK FILENAME, which they called the highest priority and which is a commercial
leak rather than a cosmetic one: every download landed on their customer's disk as
"ScreenTinker.apk", naming the upstream product — and where to buy it directly — to
the people they were selling to. /download/apk now resolves branding by DOMAIN, since
that route is unauthenticated and has no workspace to read, which is also exactly how
a reseller deploys: their own hostname, their own brand.

The name is sanitised through a whitelist, in lib/brand-filename.js so it can be
tested. That is security code, not cosmetics: brand_name is arbitrary operator text
landing in a Content-Disposition header, where a quote ends the filename parameter
early and a CR/LF ends the header line entirely. The tests are mostly hostile input.

ADMIN-CREATED USERS ARE VERIFIED. POST /api/admin/users left email_verified at the
schema default of 0, so every admin-provisioned user met a "Please confirm your email
address" banner they could not dismiss — and on an instance with no SMTP, could never
clear. Operators were fixing it by editing the database by hand. An address typed in
by an administrator is as verified as this system can make it. Note the test fixture
had drifted from the real schema and lacked the column entirely; adding it there is
what let the fix be tested at all.

THE HARDCODED STRINGS. Nine user-facing strings named the product — setup steps, the
empty-dashboard hint, onboarding, sign-in errors. They are translated strings, so the
substitution belongs in the translation layer: they now say {brandName}, and i18n.js
fills it in inside format(), so every t() call gets it without threading a variable
through several hundred call sites. Read at CALL time, not captured, so a workspace
switch shows the new brand rather than the one cached at module load. 43 strings across
7 locales; the default is the product's own name, so an un-branded install is unchanged.

Deliberately NOT changed, because substituting a brand there would be wrong rather than
incomplete:
  - the White Label brand_name input's placeholder, which shows the default when empty;
  - the install-statistics explanation, which describes what the upstream project can
    and cannot see, and is not about the reseller's brand;
  - the widget security warning, which describes the privileges of this software; that
    is copy worth changing deliberately rather than by regex.

Full suite 1779 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 22:55:56 -05:00

106 lines
4.1 KiB
JavaScript

// Lightweight i18n loader. Each language is its own file under ./i18n/ so a
// translator can edit one file without touching the others. English is the
// canonical source — every other locale falls back to en for any missing key.
import en from './i18n/en.js';
import es from './i18n/es.js';
import fr from './i18n/fr.js';
import de from './i18n/de.js';
import pt from './i18n/pt.js';
import hi from './i18n/hi.js';
import it from './i18n/it.js';
import ja from './i18n/ja.js';
const fallback = en;
const registry = { en, es, fr, de, pt, hi, it, ja };
let currentLang = localStorage.getItem('rd_lang') || navigator.language?.split('-')[0] || 'en';
if (!registry[currentLang]) currentLang = 'en';
function lookup(key) {
return registry[currentLang]?.[key] ?? fallback[key] ?? key;
}
// Replace {name} placeholders in a string with the matching property of vars.
// Unknown placeholders pass through unchanged so a missing var is visible
// during development rather than silently dropped.
/*
* The white-label brand name, available to EVERY string without threading it through call sites.
*
* #292: a reseller's customers were shown "ScreenTinker" in a dozen places the white-label settings
* never touched — setup instructions, the empty-dashboard hint, onboarding, error text. Those are
* translated strings, so the fix belongs in the translation layer: they say {brandName} and this
* fills it in. Threading a variable through several hundred t() calls would have been the same fix
* with several hundred chances to miss one.
*
* Read at CALL time, not captured: branding.js refreshes from the server after first paint, and a
* value captured when this module loaded would keep showing the previous workspace's brand after a
* switch. The default is the product's own name, so an un-branded install reads exactly as before.
*/
function brandName() {
try {
const n = typeof window !== 'undefined' && window.__ST_BRAND_NAME;
return (typeof n === 'string' && n.trim()) ? n.trim() : 'ScreenTinker';
} catch (e) { return 'ScreenTinker'; }
}
function format(s, vars) {
// Note there is no `if (!vars) return s` short-circuit any more: {brandName} has to resolve in
// strings that take no other variables, which is most of them.
const all = { brandName: brandName(), ...(vars || {}) };
return String(s).replace(/\{(\w+)\}/g, (m, k) => (k in all ? String(all[k]) : m));
}
export function t(key, vars) {
return format(lookup(key), vars);
}
// Plural helper: looks up `${keyBase}_one` for n===1 else `${keyBase}_other`,
// auto-injects `{n}` into vars. Use for any string that varies on a count.
export function tn(keyBase, n, vars = {}) {
const key = keyBase + (n === 1 ? '_one' : '_other');
return format(lookup(key), { n, ...vars });
}
const subscribers = new Set();
// Views and the navbar subscribe so they can rebuild themselves on language
// change. Also fires a `language-changed` CustomEvent and a hashchange so the
// existing hash router naturally re-renders the current view.
export function subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
export function setLanguage(lang) {
if (!registry[lang] || lang === currentLang) return;
currentLang = lang;
localStorage.setItem('rd_lang', lang);
document.documentElement.setAttribute('lang', lang);
subscribers.forEach((fn) => { try { fn(lang); } catch {} });
window.dispatchEvent(new CustomEvent('language-changed', { detail: { lang } }));
window.dispatchEvent(new HashChangeEvent('hashchange'));
}
export function getLanguage() {
return currentLang;
}
export function getAvailableLanguages() {
return [
{ code: 'en', name: 'English' },
{ code: 'ja', name: '日本語' },
{ code: 'es', name: 'Español' },
{ code: 'fr', name: 'Français' },
{ code: 'it', name: 'Italiano' },
{ code: 'de', name: 'Deutsch' },
{ code: 'pt', name: 'Português' },
{ code: 'hi', name: 'हिन्दी' },
];
}
// Apply the persisted language to <html lang=...> on first load so screen
// readers and CSS :lang() selectors are accurate before any user interaction.
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('lang', currentLang);
}