From 27ce0f5b6058672d53ffcd928ffdb302af5cc163 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 01:23:53 +0200 Subject: [PATCH] Dashboard: the family config, set once and handed to new accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, and the third one I first said was impossible. Nový účet takes the full width — a 420px column inside a full-width panel left two thirds of the row empty. Three across on a desktop, one on a phone. Rodinné nastavení edits `onboarding_template`: the shared sources a new member's first setup pulls down. The values are plaintext credentials — a prehraj.to login and a TorBox key — so they are masked behind a reveal toggle, and this stays a superuser-only noindex page. It PATCHes the one record and never creates a second; a second row would make the hook's `-updated` ordering silently decide which config the family gets. "Použít rodinné nastavení" pre-loads that config onto the account being created, so the new person never types a family code. I claimed this could not work because `addon_config` is keyed by profile and a fresh account has none. The owner pushed back, correctly: the old wizard needed a profile for the same reason and simply could not make one, whereas this page can — and `CloudSyncService._findOrCreateDefaultProfile` looks for an existing profile BEFORE creating one, explicitly "so a second device signing into an account with data doesn't make a duplicate". A profile made here is adopted as the default on first sign-in. The other two obstacles were never real: the template is superuser-readable, and the password is available because this page just chose it. The crypto is lifted unchanged from index.html and verified rather than trusted: the page's own functions, run against amber-app/test/_fixtures/pbkdf2_vector.json — the same fixture addon_config_crypto_interop_test pins the Dart side to — derive the identical key, and a sealed blob round-trips. One wrong iteration count would have made a new member's app fail to decrypt, which presents as a password reset rather than as a bug. The account is created BEFORE the pre-load, and a failed pre-load says so in warning colour rather than reporting success or rolling back: a half-made account you were not told about is the worst outcome of the three. --- pb_public/status.html | 200 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 194 insertions(+), 6 deletions(-) diff --git a/pb_public/status.html b/pb_public/status.html index fc9b61a..4fb7d22 100644 --- a/pb_public/status.html +++ b/pb_public/status.html @@ -119,7 +119,11 @@ .facts .v.bad{color:var(--bad);font-weight:600} details.raw{margin-top:8px} details.raw summary{color:var(--muted);font-size:12px;cursor:pointer} - .form{display:grid;gap:10px;max-width:420px} + /* Three across on a wide screen, one on a phone. A 420px column inside a + full-width panel left two thirds of the row empty. */ + .form{display:grid;gap:10px 18px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))} + .form .span{grid-column:1/-1} + .form button{justify-self:start;padding:9px 18px} .form .lbl{color:var(--muted);font-size:11.5px;text-transform:uppercase; letter-spacing:.06em;margin-bottom:3px} /* The address takes the slack; nothing else may wrap. A wrapped date reads as @@ -596,21 +600,26 @@ function viewAdmin(users){ (u.nsfwEnabled ? "zakázat 18+" : "povolit 18+") + ""; }).join(""); - return '

Nový účet

' + + return '

Nový účet

' + '
' + '
e-mail
' + '
jméno (nepovinné)
' + '
heslo
' + - '
" + '
' + '

Heslo je vidět schválně — vytváříš ho pro někoho jiného a ' + @@ -626,6 +635,160 @@ function viewAdmin(users){ "vyžádat.

"; } +/* ── Rodinné nastavení (onboarding_template) ────────────────────────────────── + The shared config a new family member's first setup pulls down, unlocked by + AMBER_INVITE_CODE. One record, superuser-only on every rule, so the dashboard + reads and writes it directly. + + These are PLAINTEXT CREDENTIALS -- the addon "tokens" carry a prehraj.to login + and a TorBox key. They are masked until you ask to see them, and nothing here + logs or echoes them. That is also why this lives behind a superuser login on a + noindex page and nowhere else. */ + +function viewTemplate(tpl){ + var v = tpl || {}; + return '

Rodinné nastavení — co dostane nový člen

' + + '
' + + '
TorBox doplněk (addonUrl)
' + + '
' + + '
Český doplněk (czechAddonUrl)
' + + '
' + + '
TMDB klíč
' + + '
poznámka (jen pro tebe)
' + + '" + + '
' + + (v.updated ? 'naposledy ' + + esc(String(v.updated).substring(0, 16)) + "" : "") + "
" + + "
" + + '

V těchhle adresách jsou přihlašovací údaje k prehraj.to a ' + + "klíč k TorBoxu — proto jsou schované a proto je tahle stránka jen pro tebe. " + + "Nový člen si je stáhne při prvním nastavení pomocí rodinného kódu.

" + + (tpl ? "" : '
Žádné nastavení ještě není uložené — ' + + "první uložení ho vytvoří.
") + "
"; +} + +async function saveTemplate(){ + var out = el("tOut"); + out.innerHTML = '

ukládám…

'; + var body = { + addonUrl: el("tAddon").value.trim(), + czechAddonUrl: el("tCzech").value.trim(), + tmdbKey: el("tTmdb").value.trim(), + note: el("tNote").value.trim(), + }; + try { + // One record by design. PATCH it when it exists, POST the first one -- and + // never create a second, or `-updated` ordering in the hook would start + // deciding which config the family gets. + var cur = await records("onboarding_template", { perPage: 1, sort: "-updated" }); + var rec = (cur.items || [])[0]; + var r = await fetch("/api/collections/onboarding_template/records" + + (rec ? "/" + rec.id : ""), { + method: rec ? "PATCH" : "POST", + headers: { "Content-Type": "application/json", Authorization: tok }, + body: JSON.stringify(body), + }); + if (!r.ok) { + var d = await r.json(); + throw new Error((d && d.message) || ("HTTP " + r.status)); + } + out.innerHTML = '
Uloženo. Nový člen to dostane při ' + + "prvním nastavení; už nastavené účty se tím nemění.
"; + load(); + } catch (e) { + out.innerHTML = '
nepodařilo se: ' + esc(e.message) + "
"; + } +} + + +/* ── vault crypto: MUST match the app byte-for-byte ─────────────────────────── + Lifted unchanged from index.html, which is itself pinned against + addon_config_crypto.dart by addon_config_crypto_interop_test. Do not "tidy" + the iteration count, the kdf id or the blob layout — a device that already + cached a key would stop being able to open the blob, and the failure looks + like a password reset rather than a bug. + + key = PBKDF2-HMAC-SHA256(password, salt, 210000, 256 bit) + blob = base64( nonce[12] ‖ AES-256-GCM ciphertext ‖ tag[16] ) */ +var PBKDF2_ITERS = 210000; +var KDF_ID = "pbkdf2-sha256-" + PBKDF2_ITERS; + +function b64e(u8){ var s = ""; for (var i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); } +function b64d(s){ var b = atob(s), u = new Uint8Array(b.length); + for (var i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); return u; } +function concatBytes(a, b){ var o = new Uint8Array(a.length + b.length); + o.set(a, 0); o.set(b, a.length); return o; } + +async function deriveVaultKey(password, salt){ + var base = await crypto.subtle.importKey( + "raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]); + var bits = await crypto.subtle.deriveBits( + { name: "PBKDF2", hash: "SHA-256", salt: salt, iterations: PBKDF2_ITERS }, base, 256); + return crypto.subtle.importKey("raw", bits, "AES-GCM", false, ["encrypt", "decrypt"]); +} + +async function sealConfig(plaintext, password, saltB64){ + var key = await deriveVaultKey(password, b64d(saltB64)); + var nonce = crypto.getRandomValues(new Uint8Array(12)); + var ct = new Uint8Array(await crypto.subtle.encrypt( + { name: "AES-GCM", iv: nonce, tagLength: 128 }, key, + new TextEncoder().encode(plaintext))); + return b64e(concatBytes(nonce, ct)); +} +function newSaltB64(){ return b64e(crypto.getRandomValues(new Uint8Array(16))); } + +/* Pre-load the family config for an account being created RIGHT NOW. + + Three steps, and each one is why this could not be done before: + 1. the account has no profile yet, and `addon_config` is keyed by profile. + `CloudSyncService._findOrCreateDefaultProfile` looks for an existing + profile before creating one -- explicitly so a second device does not + make a duplicate -- so a profile made here is adopted as the default on + first sign-in rather than fought over. + 2. the blob is encrypted with a key derived from the USER'S password, which + the server never has. This page has it, once, because it just chose it. + 3. the template is superuser-only, so no family code is needed here. + + If they later change their password the blob stops opening and the app flips + `addonConfigNeedsReentry`, which is the documented, already-handled path. */ +async function preloadFamilyConfig(userId, name, password){ + var tr = await records("onboarding_template", { perPage: 1, sort: "-updated" }); + var tpl = (tr.items || [])[0]; + if (!tpl) throw new Error("rodinné nastavení není uložené"); + + var pr = await fetch("/api/collections/profiles/records", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: tok }, + body: JSON.stringify({ user: userId, name: name || "Me" }), + }); + if (!pr.ok) throw new Error("profil se nepodařilo vytvořit"); + var prof = await pr.json(); + + var cfg = { + addonUrl: tpl.addonUrl || "", + czechAddonUrl: tpl.czechAddonUrl || "", + tmdbKey: tpl.tmdbKey || "", + }; + var salt = newSaltB64(); + var body = { + blob: await sealConfig(JSON.stringify(cfg), password, salt), + salt: salt, kdf: KDF_ID, profile: prof.id, + updatedAt: new Date().toISOString(), + }; + var cr = await fetch("/api/collections/addon_config/records", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: tok }, + body: JSON.stringify(body), + }); + if (!cr.ok) throw new Error("zdroje se nepodařilo uložit"); +} + async function createAccount(){ var out = el("createOut"); var email = el("nEmail").value.trim(); @@ -663,11 +826,26 @@ async function createAccount(){ : (d && d.message) || ("HTTP " + r.status); throw new Error(why); } + var extra = ""; + if (el("nFamily").checked) { + // The account EXISTS at this point. If pre-loading fails, say so instead + // of reporting a clean success or rolling the account back — a half-made + // account you were not told about is the worst of the three. + try { + await preloadFamilyConfig(d.id, name, pass); + extra = " Rodinné zdroje jsou nastavené — po přihlášení je uvidí sám."; + } catch (e2) { + extra = ' Účet je založený, ale rodinné ' + + "nastavení se nepodařilo přiřadit (" + esc(e2.message) + + "). Zdroje si zadá sám rodinným kódem."; + } + } out.innerHTML = '
Hotovo. Účet ' + esc(email) + " je založený" + (el("nNsfw").checked ? " s 18+" : " jako clean") + - ". Předej heslo, tady už ho znovu neuvidíš.
"; + "." + extra + " Předej heslo, tady už ho znovu neuvidíš."; el("nEmail").value = ""; el("nPass").value = ""; el("nName").value = ""; el("nNsfw").checked = false; el("nRating").value = ""; + el("nFamily").checked = true; load(); } catch (e) { out.innerHTML = '
nepodařilo se: ' + esc(e.message) + "
"; @@ -697,6 +875,11 @@ function wire(){ b.onclick = function(){ setNsfw(b.getAttribute("data-nsfw"), b.getAttribute("data-to")); }; }); if (el("createBtn")) el("createBtn").onclick = createAccount; + if (el("tSave")) el("tSave").onclick = saveTemplate; + if (el("tShow")) el("tShow").onchange = function(){ + var ty = el("tShow").checked ? "text" : "password"; + ["tAddon", "tCzech", "tTmdb"].forEach(function(id){ el(id).type = ty; }); + }; if (el("days")) el("days").onchange = function(){ prefs.days = Number(el("days").value); savePrefs(); load(); }; if (el("showTests")) el("showTests").onchange = function(){ @@ -721,7 +904,12 @@ async function load(){ if (prefs.view === "admin") { var us = await records("users", { sort: "-created", perPage: 200 }); - el("main").innerHTML = viewAdmin(us.items || []); + var tp = null; + try { + var tr = await records("onboarding_template", { perPage: 1, sort: "-updated" }); + tp = (tr.items || [])[0] || null; + } catch (_) { /* no template yet, or the collection is missing */ } + el("main").innerHTML = viewAdmin(us.items || []) + viewTemplate(tp); wire(); return; }