index.html was the old landing page with a signup form, and that form can now only
fail — registration is owner-only. A dead button is worse than no button, so the
unified account page takes its place rather than sitting beside it. settings.html
goes too: it is a strict subset of the sources section, and onboarding.html was the
signup wizard.
Verified against the live backend before the swap, signed in as a throwaway account
created the way the owner now creates them (and deleted afterwards — the instance is
back to its five real accounts):
registration 403 "Only superusers can perform this action", with and without
the old invite header
template route 401 anonymously
session survives a full page reload; the password does NOT, so the
sources section re-asks for it — the intended split
prefs one row, updatedAt distinct from the server cursor, and all 13
fields present including the three the page does not render, so
the merge did not clobber what the app writes
sources (BYOC) the browser reached the addon's /encode cross-origin, minted the
prehraj.to token, built TorBox's config locally, encrypted and
saved; the stored blob contains no plaintext and carries
kdf=pbkdf2-sha256-210000
downloads all three platforms resolve to 1.1.6 · clean with a file token
Checked rather than assumed: the Czech URL is stored with /manifest.json (matching
the existing template) and the app strips it on both read and write via
_stripManifest, so minting it that way is correct and not a latent 404.
879 lines
42 KiB
HTML
879 lines
42 KiB
HTML
<!doctype html>
|
|
<html lang="cs">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<meta name="robots" content="noindex">
|
|
<title>Amber — můj účet</title>
|
|
<style>
|
|
:root { color-scheme: dark; --bg:#0e0f13; --card:#191b21; --fg:#f2e9d8;
|
|
--muted:#9aa0aa; --amber:#f0a63c; --amber2:#c9791b; --err:#ff6b6b; --ok:#5fd08a;
|
|
--line:#2a2d36; --sunk:#141620; }
|
|
* { box-sizing:border-box; }
|
|
body { margin:0; background:var(--bg); color:var(--fg);
|
|
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
|
min-height:100dvh; padding:20px 16px 48px; }
|
|
.wrap { width:100%; max-width:560px; margin:0 auto; }
|
|
.card { background:var(--card); border-radius:16px; padding:22px 20px;
|
|
border:1px solid var(--line); box-shadow:0 10px 40px rgba(0,0,0,.25);
|
|
margin-bottom:16px; }
|
|
h1 { font-size:22px; margin:0 0 4px; color:var(--amber); }
|
|
h2 { font-size:16px; margin:0 0 12px; color:var(--fg); }
|
|
p.sub { margin:0 0 16px; color:var(--muted); font-size:14px; line-height:1.5; }
|
|
label { display:block; font-size:13px; color:var(--muted); margin:14px 0 5px; }
|
|
input[type=email],input[type=password],input[type=text],input[type=number],select {
|
|
width:100%; padding:12px; border-radius:10px; border:1px solid var(--line);
|
|
background:var(--bg); color:var(--fg); font-size:15px; }
|
|
input:focus, select:focus { outline:2px solid var(--amber); border-color:transparent; }
|
|
.row { display:flex; gap:10px; } .row > * { flex:1; min-width:0; }
|
|
.hint { font-size:12px; color:var(--muted); margin:5px 0 0; line-height:1.45; }
|
|
.check { display:flex; gap:10px; align-items:flex-start; margin-top:14px;
|
|
font-size:14px; line-height:1.45; }
|
|
.check input { margin-top:3px; width:auto; }
|
|
button.btn { width:100%; margin-top:18px; padding:13px; border:none;
|
|
border-radius:10px; font-size:15px; font-weight:700; cursor:pointer; }
|
|
.primary { background:var(--amber); color:#1b1206; }
|
|
.ghost { background:transparent; color:var(--fg); border:1px solid var(--line); }
|
|
.danger { background:transparent; color:var(--err); border:1px solid var(--err); }
|
|
button:disabled { opacity:.5; cursor:default; }
|
|
button.link { background:none; border:none; color:var(--muted); font-size:13px;
|
|
cursor:pointer; text-decoration:underline; padding:0; margin-top:12px; }
|
|
.msg { margin-top:12px; font-size:14px; min-height:18px; line-height:1.45; }
|
|
.msg.err { color:var(--err); } .msg.ok { color:var(--ok); }
|
|
.hidden { display:none !important; }
|
|
.spin { display:inline-block; width:15px; height:15px; border:2px solid #1b1206;
|
|
border-top-color:transparent; border-radius:50%; animation:s .7s linear infinite;
|
|
vertical-align:-2px; margin-right:7px; }
|
|
@keyframes s { to { transform:rotate(360deg); } }
|
|
nav { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:16px; }
|
|
nav button { padding:9px 13px; border-radius:999px; border:1px solid var(--line);
|
|
background:var(--card); color:var(--muted); font-size:13.5px; cursor:pointer; }
|
|
nav button[aria-current="true"] { background:var(--amber); color:#1b1206;
|
|
border-color:transparent; font-weight:700; }
|
|
.who { display:flex; justify-content:space-between; align-items:center;
|
|
font-size:13px; color:var(--muted); margin-bottom:14px; gap:12px; }
|
|
.who b { color:var(--fg); }
|
|
.sep { height:1px; background:var(--line); margin:18px 0 2px; border:0; }
|
|
.list { list-style:none; padding:0; margin:0; }
|
|
.list li { padding:12px; border:1px solid var(--line); border-radius:10px;
|
|
margin-bottom:8px; background:var(--sunk); font-size:14px; }
|
|
.list .meta { color:var(--muted); font-size:12.5px; margin-top:3px; }
|
|
.rowbtn { display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
|
.rowbtn button { margin:0; width:auto; padding:8px 12px; font-size:13px; }
|
|
code.url { background:var(--sunk); padding:3px 7px; border-radius:6px;
|
|
font-size:13px; word-break:break-all; }
|
|
.warn { border-left:3px solid var(--amber); padding-left:12px; margin:14px 0;
|
|
font-size:13px; color:var(--muted); line-height:1.5; }
|
|
ol.steps { padding-left:20px; margin:0; font-size:14px; line-height:1.7; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
|
|
<!-- ── signed out ───────────────────────────────────────────────────────── -->
|
|
<div id="anon">
|
|
<div class="card">
|
|
<h1>Amber</h1>
|
|
<p class="sub">Přihlas se a spravuj svůj účet, profily a nastavení.
|
|
Přihlášení zůstane uložené, takže příště to bude rovnou tady.</p>
|
|
<label for="email">E-mail</label>
|
|
<input id="email" type="email" autocomplete="username" inputmode="email">
|
|
<label for="pass">Heslo</label>
|
|
<input id="pass" type="password" autocomplete="current-password">
|
|
<button id="loginBtn" class="btn primary">Přihlásit se</button>
|
|
<div id="anonMsg" class="msg"></div>
|
|
<p class="hint">Účty zakládá Richard — registrace tu není. Když se nemůžeš
|
|
dostat dovnitř, napiš mu.</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Nainstalovat na televizi</h2>
|
|
<ol class="steps">
|
|
<li>Na televizi otevři aplikaci <b>Downloader</b>.</li>
|
|
<li>Zadej <code class="url" id="tvUrl">amber.petruzalekr.cz/tv</code> —
|
|
stáhne se instalace.</li>
|
|
<li>Spusť Amber → <b>Přihlásit z jiného zařízení</b> a načti QR kód
|
|
telefonem. Na televizi nic nepíšeš.</li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── signed in ────────────────────────────────────────────────────────── -->
|
|
<div id="app" class="hidden">
|
|
<div class="who">
|
|
<span>Účet: <b id="whoEmail"></b></span>
|
|
<button id="logoutBtn" class="link" style="margin:0">Odhlásit</button>
|
|
</div>
|
|
|
|
<nav id="nav"></nav>
|
|
|
|
<!-- profiles -->
|
|
<section id="tab-profiles" class="card tab hidden">
|
|
<h2>Profily</h2>
|
|
<p class="sub">Každý profil má vlastní seznamy, rozkoukané a nastavení
|
|
přehrávání.</p>
|
|
<ul class="list" id="profileList"></ul>
|
|
<hr class="sep">
|
|
<label for="pfName">Upravit profil</label>
|
|
<select id="pfSelect"></select>
|
|
<label for="pfName2">Jméno</label>
|
|
<input id="pfName2" type="text" maxlength="40">
|
|
<label for="pfRating">Věkový strop</label>
|
|
<select id="pfRating">
|
|
<option value="">Podle účtu</option>
|
|
<option value="kids">Děti (do 7)</option>
|
|
<option value="twelve">12+</option>
|
|
<option value="fifteen">15+</option>
|
|
<option value="unrestricted">Bez omezení</option>
|
|
</select>
|
|
<div class="check">
|
|
<input id="pfChild" type="checkbox">
|
|
<label for="pfChild" style="margin:0">Dětský profil (skryje anime a 18+,
|
|
na odchod chce PIN)</label>
|
|
</div>
|
|
<label for="pfPin">Nový PIN (4 číslice, nech prázdné = beze změny)</label>
|
|
<input id="pfPin" type="text" inputmode="numeric" maxlength="4" autocomplete="off">
|
|
<button id="pfSave" class="btn primary">Uložit profil</button>
|
|
<div id="pfMsg" class="msg"></div>
|
|
</section>
|
|
|
|
<!-- playback prefs -->
|
|
<section id="tab-playback" class="card tab hidden">
|
|
<h2>Přehrávání</h2>
|
|
<p class="sub">Nastavení jazyků platí pro vybraný profil a projeví se na
|
|
všech zařízeních.</p>
|
|
<label for="prProfile">Profil</label>
|
|
<select id="prProfile"></select>
|
|
<hr class="sep">
|
|
<div class="row">
|
|
<div><label for="prAudio">Zvuk — hlavní</label>
|
|
<select id="prAudio" class="lang"></select></div>
|
|
<div><label for="prAudio2">Zvuk — záložní</label>
|
|
<select id="prAudio2" class="lang"></select></div>
|
|
</div>
|
|
<div class="row">
|
|
<div><label for="prSub">Titulky — hlavní</label>
|
|
<select id="prSub" class="lang sub"></select></div>
|
|
<div><label for="prSub2">Titulky — záložní</label>
|
|
<select id="prSub2" class="lang sub"></select></div>
|
|
</div>
|
|
<p class="hint">Čeština jako hlavní zvuk znamená, že Amber vždycky hledá
|
|
ověřený český dabing. Záložní jazyk se použije jen tehdy, když žádný
|
|
český zdroj neexistuje.</p>
|
|
<hr class="sep">
|
|
<div class="row">
|
|
<div><label for="prAnimeAudio">Anime — zvuk</label>
|
|
<select id="prAnimeAudio" class="lang"></select></div>
|
|
<div><label for="prAnimeSub">Anime — titulky</label>
|
|
<select id="prAnimeSub" class="lang sub"></select></div>
|
|
</div>
|
|
<hr class="sep">
|
|
<label for="prRes">Preferované rozlišení</label>
|
|
<select id="prRes">
|
|
<option value="">Bez preference</option>
|
|
<option value="2160">4K (2160p)</option>
|
|
<option value="1080">1080p</option>
|
|
<option value="720">720p</option>
|
|
</select>
|
|
<div class="check">
|
|
<input id="prHighest" type="checkbox">
|
|
<label for="prHighest" style="margin:0">Řadit zdroje s vyšším rozlišením výš</label>
|
|
</div>
|
|
<div class="check">
|
|
<input id="prSigns" type="checkbox">
|
|
<label for="prSigns" style="margin:0">Spojit titulky pro nápisy a dialogy</label>
|
|
</div>
|
|
<label for="prFeedback">Ptát se, jak se přehrávalo</label>
|
|
<select id="prFeedback">
|
|
<option value="off">Nikdy</option>
|
|
<option value="sometimes">Občas</option>
|
|
<option value="always">Po každém přehrání</option>
|
|
</select>
|
|
<button id="prSave" class="btn primary">Uložit přehrávání</button>
|
|
<div id="prMsg" class="msg"></div>
|
|
</section>
|
|
|
|
<!-- addons -->
|
|
<section id="tab-addons" class="card tab hidden">
|
|
<h2>Zdroje</h2>
|
|
<p class="sub">Odkud Amber bere filmy a seriály. Uloženo zašifrovaně —
|
|
klíč se počítá z tvého hesla a nikdy neopustí prohlížeč.</p>
|
|
|
|
<div id="adNeedPass">
|
|
<label for="adPass">Heslo k účtu</label>
|
|
<input id="adPass" type="password" autocomplete="current-password">
|
|
<p class="hint">Bez hesla nelze zdroje rozšifrovat ani uložit.</p>
|
|
<button id="adUnlock" class="btn primary">Odemknout zdroje</button>
|
|
</div>
|
|
|
|
<div id="adBody" class="hidden">
|
|
<div id="adSetup">
|
|
<p class="sub">Ještě tu nic není. Vyber, jak to nastavit:</p>
|
|
<button id="adFamily" class="btn ghost">Mám rodinný kód</button>
|
|
<button id="adByoc" class="btn ghost">Mám vlastní účty u služeb</button>
|
|
</div>
|
|
|
|
<!-- family code path -->
|
|
<div id="adFamilyBox" class="hidden">
|
|
<label for="adCode">Rodinný kód</label>
|
|
<input id="adCode" type="text" autocomplete="off" spellcheck="false">
|
|
<p class="hint">Kód dostaneš od Richarda. Nastaví zdroje rodiny za tebe.</p>
|
|
<button id="adCodeGo" class="btn primary">Použít rodinný kód</button>
|
|
<button class="link adBack">Zpět</button>
|
|
</div>
|
|
|
|
<!-- BYOC path -->
|
|
<div id="adByocBox" class="hidden">
|
|
<p class="sub">Amber si z tvých přihlašovacích údajů poskládá adresy
|
|
zdrojů sám. Údaje jdou přímo do doplňku, přes náš server neprojdou.</p>
|
|
|
|
<h2 style="margin-top:18px">prehraj.to</h2>
|
|
<div class="row">
|
|
<div><label for="byPtUser">Jméno</label>
|
|
<input id="byPtUser" type="text" autocomplete="off" spellcheck="false"></div>
|
|
<div><label for="byPtPass">Heslo</label>
|
|
<input id="byPtPass" type="password" autocomplete="off"></div>
|
|
</div>
|
|
<p class="hint">Povinné — český doplněk stojí na prehraj.to a bez něj
|
|
se nespustí. webshare se přidává k němu, samostatně zatím nefunguje.</p>
|
|
|
|
<h2 style="margin-top:18px">webshare.cz <span style="color:var(--muted);font-weight:400">— volitelné</span></h2>
|
|
<div class="row">
|
|
<div><label for="byWsUser">Jméno</label>
|
|
<input id="byWsUser" type="text" autocomplete="off" spellcheck="false"></div>
|
|
<div><label for="byWsPass">Heslo</label>
|
|
<input id="byWsPass" type="password" autocomplete="off"></div>
|
|
</div>
|
|
<p class="hint">Druhý český host. Buď obojí, nebo nic — půlka údajů
|
|
selže při každém hledání.</p>
|
|
|
|
<h2 style="margin-top:18px">TorBox <span style="color:var(--muted);font-weight:400">— volitelné, ale doporučené</span></h2>
|
|
<label for="byTbKey">API klíč</label>
|
|
<input id="byTbKey" type="text" autocomplete="off" spellcheck="false"
|
|
placeholder="z torbox.app → Settings → API">
|
|
<p class="hint"><b>Tohle je zdroj s největším výběrem.</b> Pro cokoli,
|
|
co není česky dabované — původní znění, novinky, seriály, 4K — je
|
|
TorBox zdaleka nejlepší a najde toho nejvíc. Bez něj zůstaneš jen
|
|
u českých zdrojů.</p>
|
|
|
|
<label for="byTmdb">TMDB klíč</label>
|
|
<input id="byTmdb" type="text" autocomplete="off" spellcheck="false">
|
|
<p class="hint">Bez něj se nenačtou plakáty ani popisy. Zdarma na
|
|
themoviedb.org.</p>
|
|
|
|
<button id="byGo" class="btn primary">Nastavit zdroje</button>
|
|
<button class="link adBack">Zpět</button>
|
|
</div>
|
|
|
|
<!-- manual edit of the current config -->
|
|
<div id="adEdit" class="hidden">
|
|
<label for="adTorbox">TorBox</label>
|
|
<input id="adTorbox" type="text" spellcheck="false" placeholder="https://…">
|
|
<label for="adCzech">Český zdroj</label>
|
|
<input id="adCzech" type="text" spellcheck="false" placeholder="https://…">
|
|
<label for="adTmdb">TMDB klíč</label>
|
|
<input id="adTmdb" type="text" spellcheck="false">
|
|
<div id="adAdultBox" class="hidden">
|
|
<hr class="sep">
|
|
<label for="adAdult">Zdroj 18+</label>
|
|
<input id="adAdult" type="text" spellcheck="false" placeholder="https://…">
|
|
</div>
|
|
<p class="hint">Prázdné pole zdroj z účtu odebere.</p>
|
|
<button id="adSave" class="btn primary">Uložit zdroje</button>
|
|
<button id="adRedo" class="link">Nastavit znovu od začátku</button>
|
|
</div>
|
|
</div>
|
|
<div id="adMsg" class="msg"></div>
|
|
</section>
|
|
|
|
<!-- devices -->
|
|
<section id="tab-devices" class="card tab hidden">
|
|
<h2>Zařízení</h2>
|
|
<p class="sub">Přihlášené televize a počítače. Odebrání zabrání dalšímu
|
|
přihlašování z toho zařízení.</p>
|
|
<ul class="list" id="deviceList"></ul>
|
|
<div id="dvMsg" class="msg"></div>
|
|
<p class="hint">Už přihlášené zařízení může dobíhat, dokud mu nevyprší
|
|
token — odebrání zastaví nová přihlášení, ne běžící relaci.</p>
|
|
</section>
|
|
|
|
<!-- downloads -->
|
|
<section id="tab-download" class="card tab hidden">
|
|
<h2>Stáhnout Amber</h2>
|
|
<p class="sub">Verze podle tvého účtu.</p>
|
|
<div id="dlList"></div>
|
|
<hr class="sep">
|
|
<h2 style="margin-top:16px">Televize</h2>
|
|
<ol class="steps">
|
|
<li>Na televizi v aplikaci <b>Downloader</b> zadej
|
|
<code class="url">amber.petruzalekr.cz/tv</code>.</li>
|
|
<li>V Amberu zvol <b>Přihlásit z jiného zařízení</b> a načti QR telefonem.</li>
|
|
</ol>
|
|
<div id="dlMsg" class="msg"></div>
|
|
</section>
|
|
|
|
<!-- account -->
|
|
<section id="tab-account" class="card tab hidden">
|
|
<h2>Účet</h2>
|
|
<label for="acEmail">E-mail</label>
|
|
<input id="acEmail" type="email" disabled>
|
|
<p class="hint">E-mail mění správce.</p>
|
|
|
|
<hr class="sep">
|
|
<label for="acRating">Výchozí věkový strop účtu</label>
|
|
<select id="acRating">
|
|
<option value="unrestricted">Bez omezení</option>
|
|
<option value="fifteen">15+</option>
|
|
<option value="twelve">12+</option>
|
|
<option value="kids">Děti (do 7)</option>
|
|
</select>
|
|
<p class="hint">Použije se pro profily, které nemají vlastní strop.</p>
|
|
<button id="acSaveRating" class="btn primary">Uložit</button>
|
|
|
|
<hr class="sep">
|
|
<h2 style="margin-top:18px">Změna hesla</h2>
|
|
<div class="warn">Heslo šifruje tvoje zdroje. Změna je proto přešifruje —
|
|
musíš mít v této relaci odemčené <b>Zdroje</b>, jinak o ně přijdeš.</div>
|
|
<label for="acOld">Současné heslo</label>
|
|
<input id="acOld" type="password" autocomplete="current-password">
|
|
<label for="acNew">Nové heslo</label>
|
|
<input id="acNew" type="password" autocomplete="new-password">
|
|
<label for="acNew2">Nové heslo znovu</label>
|
|
<input id="acNew2" type="password" autocomplete="new-password">
|
|
<button id="acChange" class="btn danger" disabled>Změnit heslo</button>
|
|
<div id="acMsg" class="msg"></div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ── crypto: MUST match the app byte-for-byte ─────────────────────────────────
|
|
// addon_config_crypto.dart: key = PBKDF2-HMAC-SHA256(password, salt, 210000,
|
|
// 256 bit); blob = base64( nonce[12] ‖ AES-256-GCM ciphertext ‖ tag[16] );
|
|
// kdf id "pbkdf2-sha256-210000". Lifted unchanged from settings.html, which is
|
|
// pinned against the Dart implementation by addon_config_crypto_interop_test.
|
|
var subtle = crypto.subtle;
|
|
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 bin=atob(s);var u8=new Uint8Array(bin.length);for(var i=0;i<bin.length;i++)u8[i]=bin.charCodeAt(i);return u8;}
|
|
function b64url(str){return btoa(unescape(encodeURIComponent(str)))
|
|
.replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");}
|
|
function concat(){var n=0,i;for(i=0;i<arguments.length;i++)n+=arguments[i].length;
|
|
var out=new Uint8Array(n),o=0;for(i=0;i<arguments.length;i++){out.set(arguments[i],o);o+=arguments[i].length;}return out;}
|
|
|
|
async function deriveKey(password, salt){
|
|
var base=await subtle.importKey("raw",new TextEncoder().encode(password),"PBKDF2",false,["deriveBits"]);
|
|
var bits=await subtle.deriveBits({name:"PBKDF2",hash:"SHA-256",salt:salt,iterations:PBKDF2_ITERS},base,256);
|
|
return subtle.importKey("raw",bits,"AES-GCM",false,["encrypt","decrypt"]);
|
|
}
|
|
async function decryptBlob(blobB64, password, saltB64){
|
|
var key=await deriveKey(password, b64d(saltB64));
|
|
var packed=b64d(blobB64);
|
|
var clear=await subtle.decrypt(
|
|
{name:"AES-GCM",iv:packed.slice(0,12),tagLength:128}, key, packed.slice(12));
|
|
return new TextDecoder().decode(clear);
|
|
}
|
|
// Reuse the record's EXISTING salt so the key stays identical to the one the
|
|
// user's devices already cached — an already-signed-in TV then decrypts the
|
|
// edited blob without re-entering a password.
|
|
async function encryptWithSalt(plaintext, password, saltB64){
|
|
var key=await deriveKey(password, b64d(saltB64));
|
|
var nonce=crypto.getRandomValues(new Uint8Array(12));
|
|
var ct=new Uint8Array(await subtle.encrypt(
|
|
{name:"AES-GCM",iv:nonce,tagLength:128},key,new TextEncoder().encode(plaintext)));
|
|
return b64e(concat(nonce, ct));
|
|
}
|
|
function newSaltB64(){ return b64e(crypto.getRandomValues(new Uint8Array(16))); }
|
|
|
|
// ── addon hosts. Public, credential-free bases; the token segment carries the
|
|
// credentials and is minted per user. ───────────────────────────────────────
|
|
var PREHRAJTO_BASE = "https://prehrajto.petruzalekr.cz";
|
|
var TORBOX_BASE = "https://torbox.petruzalekr.cz";
|
|
|
|
// ── state ────────────────────────────────────────────────────────────────────
|
|
var TOKEN=null, USER=null, PASSWORD=null;
|
|
var PROFILES=[], CONFIGS=[], PREFS={};
|
|
var cfgRec=null, cfg=null; // active addon_config row + plaintext
|
|
var LS="amber.session";
|
|
|
|
function $(id){ return document.getElementById(id); }
|
|
function setMsg(el,t,cls){ el.className="msg "+(cls||""); el.textContent=t||""; }
|
|
function busy(btn,on,label){ if(!btn.dataset.label) btn.dataset.label=btn.textContent;
|
|
btn.disabled=on; btn.innerHTML = on ? '<span class="spin"></span>'+label : btn.dataset.label; }
|
|
|
|
async function api(method, path, body, opts){
|
|
var o={ method:method, headers:{} };
|
|
if (body!==undefined && body!==null){ o.headers["Content-Type"]="application/json";
|
|
o.body=JSON.stringify(body); }
|
|
if (TOKEN && !(opts&&opts.anon)) o.headers["Authorization"]=TOKEN;
|
|
var r=await fetch(path,o); var d=null; try{ d=await r.json(); }catch(_){}
|
|
if (r.status===401 && TOKEN){ signOut(); }
|
|
return { ok:r.ok, status:r.status, data:d };
|
|
}
|
|
|
|
// ── session ──────────────────────────────────────────────────────────────────
|
|
// The token persists; the password never does. Anything needing the password
|
|
// (the addon vault) asks for it in the moment — that is the honest split
|
|
// between "stay signed in" and "hold the key to your credentials in a browser".
|
|
function saveSession(){ try{ localStorage.setItem(LS, TOKEN||""); }catch(_){} }
|
|
function signOut(){
|
|
TOKEN=null; USER=null; PASSWORD=null; cfg=null; cfgRec=null;
|
|
try{ localStorage.removeItem(LS); }catch(_){}
|
|
$("app").className="hidden"; $("anon").className="";
|
|
}
|
|
async function restore(){
|
|
var t=null; try{ t=localStorage.getItem(LS); }catch(_){}
|
|
if (!t) return false;
|
|
TOKEN=t;
|
|
var r=await api("POST","/api/collections/users/auth-refresh",null);
|
|
if (!r.ok || !r.data || !r.data.token){ TOKEN=null; return false; }
|
|
TOKEN=r.data.token; USER=r.data.record; saveSession();
|
|
return true;
|
|
}
|
|
|
|
$("loginBtn").onclick=async function(){
|
|
var email=$("email").value.trim(), pass=$("pass").value;
|
|
if(!email||!pass){ setMsg($("anonMsg"),"Vyplň e-mail i heslo.","err"); return; }
|
|
setMsg($("anonMsg"),""); busy($("loginBtn"),true,"Přihlašuji…");
|
|
var r=await api("POST","/api/collections/users/auth-with-password",
|
|
{ identity:email, password:pass }, {anon:true});
|
|
busy($("loginBtn"),false);
|
|
if(!r.ok){ setMsg($("anonMsg"), r.status===400
|
|
? "Špatný e-mail nebo heslo." : "Přihlášení selhalo.","err"); return; }
|
|
TOKEN=r.data.token; USER=r.data.record; PASSWORD=pass; saveSession();
|
|
await enter();
|
|
};
|
|
$("logoutBtn").onclick=signOut;
|
|
|
|
// ── shell ────────────────────────────────────────────────────────────────────
|
|
var TABS=[["profiles","Profily"],["playback","Přehrávání"],["addons","Zdroje"],
|
|
["devices","Zařízení"],["download","Stáhnout"],["account","Účet"]];
|
|
function showTab(id){
|
|
TABS.forEach(function(t){
|
|
$("tab-"+t[0]).className = "card tab" + (t[0]===id ? "" : " hidden");
|
|
var b=$("nav-"+t[0]); if(b) b.setAttribute("aria-current", t[0]===id?"true":"false");
|
|
});
|
|
try{ location.hash=id; }catch(_){}
|
|
}
|
|
function buildNav(){
|
|
var n=$("nav"); n.innerHTML="";
|
|
TABS.forEach(function(t){
|
|
var b=document.createElement("button");
|
|
b.id="nav-"+t[0]; b.textContent=t[1];
|
|
b.onclick=function(){ showTab(t[0]); };
|
|
n.appendChild(b);
|
|
});
|
|
}
|
|
|
|
async function enter(){
|
|
$("anon").className="hidden"; $("app").className="";
|
|
$("whoEmail").textContent=(USER&&USER.email)||"";
|
|
buildNav();
|
|
await Promise.all([loadProfiles(), loadDevices(), loadDownloads()]);
|
|
$("acEmail").value=(USER&&USER.email)||"";
|
|
$("acRating").value=(USER&&USER.ratingDefault)||"unrestricted";
|
|
renderAddonGate();
|
|
var want=(location.hash||"").replace("#","");
|
|
showTab(TABS.some(function(t){return t[0]===want;}) ? want : "profiles");
|
|
}
|
|
|
|
// ── profiles ─────────────────────────────────────────────────────────────────
|
|
var TIERS={ "":"Podle účtu", kids:"Děti (do 7)", twelve:"12+",
|
|
fifteen:"15+", unrestricted:"Bez omezení" };
|
|
async function loadProfiles(){
|
|
var r=await api("GET","/api/collections/profiles/records?perPage=200&sort=created");
|
|
PROFILES = r.ok ? (r.data.items||[]) : [];
|
|
var ul=$("profileList"); ul.innerHTML="";
|
|
PROFILES.forEach(function(p){
|
|
var li=document.createElement("li");
|
|
li.innerHTML="<b>"+esc(p.name||"Profil")+"</b>"+
|
|
'<div class="meta">'+(p.isChild?"dětský · ":"")+
|
|
(TIERS[p.maxRating||""]||p.maxRating)+"</div>";
|
|
ul.appendChild(li);
|
|
});
|
|
var sels=[$("pfSelect"), $("prProfile")];
|
|
sels.forEach(function(sel){
|
|
var keep=sel.value; sel.innerHTML="";
|
|
PROFILES.forEach(function(p){
|
|
var o=document.createElement("option"); o.value=p.id;
|
|
o.textContent=p.name||"Profil"; sel.appendChild(o);
|
|
});
|
|
if (keep) sel.value=keep;
|
|
});
|
|
if (PROFILES.length){ fillProfileForm(); await loadPrefs(); }
|
|
}
|
|
function esc(s){ return String(s).replace(/[<>&"]/g,function(c){
|
|
return ({"<":"<",">":">","&":"&",'"':"""})[c]; }); }
|
|
function currentProfile(){ return PROFILES.filter(function(p){
|
|
return p.id===$("pfSelect").value; })[0] || PROFILES[0]; }
|
|
function fillProfileForm(){
|
|
var p=currentProfile(); if(!p) return;
|
|
$("pfName2").value=p.name||""; $("pfRating").value=p.maxRating||"";
|
|
$("pfChild").checked=!!p.isChild; $("pfPin").value="";
|
|
}
|
|
$("pfSelect").onchange=fillProfileForm;
|
|
|
|
$("pfSave").onclick=async function(){
|
|
var p=currentProfile(); if(!p) return;
|
|
var pin=$("pfPin").value.trim();
|
|
if (pin && !/^\d{4}$/.test(pin)){ setMsg($("pfMsg"),"PIN musí být 4 číslice.","err"); return; }
|
|
busy($("pfSave"),true,"Ukládám…"); setMsg($("pfMsg"),"");
|
|
var r=await api("PATCH","/api/collections/profiles/records/"+p.id, {
|
|
name: $("pfName2").value.trim() || "Profil",
|
|
isChild: $("pfChild").checked,
|
|
maxRating: $("pfRating").value,
|
|
});
|
|
if (r.ok && pin){
|
|
// pinHash is a hidden field — PocketBase silently drops a client PATCH to
|
|
// it, so the PIN must go through the server route that hashes it.
|
|
var pr=await api("POST","/api/amber/set-pin",{ profileId:p.id, pin:pin });
|
|
if (!pr.ok) r={ok:false};
|
|
}
|
|
busy($("pfSave"),false);
|
|
setMsg($("pfMsg"), r.ok?"Uloženo.":"Uložení selhalo.", r.ok?"ok":"err");
|
|
if (r.ok) await loadProfiles();
|
|
};
|
|
|
|
// ── playback prefs ───────────────────────────────────────────────────────────
|
|
var LANGS=[["","Beze změny"],["cze","Čeština"],["slo","Slovenština"],
|
|
["eng","Angličtina"],["jpn","Japonština"],["ger","Němčina"],["pol","Polština"]];
|
|
function fillLangs(){
|
|
document.querySelectorAll("select.lang").forEach(function(sel){
|
|
sel.innerHTML="";
|
|
LANGS.forEach(function(l){
|
|
if (l[0]==="" ) return;
|
|
var o=document.createElement("option"); o.value=l[0]; o.textContent=l[1];
|
|
sel.appendChild(o);
|
|
});
|
|
var none=document.createElement("option");
|
|
none.value=""; none.textContent = sel.classList.contains("sub") ? "Žádné" : "Bez preference";
|
|
sel.insertBefore(none, sel.firstChild);
|
|
if (sel.classList.contains("sub")){
|
|
var off=document.createElement("option");
|
|
off.value="off"; off.textContent="Vypnuté"; sel.appendChild(off);
|
|
}
|
|
});
|
|
}
|
|
async function loadPrefs(){
|
|
var pid=$("prProfile").value || (PROFILES[0]&&PROFILES[0].id);
|
|
if(!pid) return;
|
|
var r=await api("GET","/api/collections/prefs/records?perPage=1&filter="+
|
|
encodeURIComponent('profile="'+pid+'"'));
|
|
var rec=(r.ok && r.data.items && r.data.items[0]) || null;
|
|
PREFS[pid]=rec;
|
|
var d=(rec && rec.data) || {};
|
|
$("prAudio").value=d.audioLanguage||"cze";
|
|
$("prAudio2").value=d.audioLanguage2||"eng";
|
|
$("prSub").value=d.subtitleLanguage||"cze";
|
|
$("prSub2").value=d.subtitleLanguage2||"eng";
|
|
$("prAnimeAudio").value=d.animeAudioLanguage||"jpn";
|
|
$("prAnimeSub").value=d.animeSubtitleLanguage||"eng";
|
|
$("prRes").value=d.preferredResolution ? String(d.preferredResolution) : "";
|
|
$("prHighest").checked=d.preferHighestQuality!==false;
|
|
$("prSigns").checked=!!d.combineSignsAndDialogue;
|
|
$("prFeedback").value=d.feedbackPrompt||"sometimes";
|
|
}
|
|
$("prProfile").onchange=loadPrefs;
|
|
|
|
$("prSave").onclick=async function(){
|
|
var pid=$("prProfile").value; if(!pid) return;
|
|
busy($("prSave"),true,"Ukládám…"); setMsg($("prMsg"),"");
|
|
var rec=PREFS[pid];
|
|
var base=(rec && rec.data) || {};
|
|
// Merge, never replace: the app writes fields this page does not show yet, and
|
|
// a whole-object PUT would silently drop them.
|
|
var data=Object.assign({}, base, {
|
|
appLanguage: base.appLanguage || "cs",
|
|
audioLanguage: $("prAudio").value || null,
|
|
audioLanguage2: $("prAudio2").value || null,
|
|
subtitleLanguage: $("prSub").value || null,
|
|
subtitleLanguage2: $("prSub2").value || null,
|
|
animeAudioLanguage: $("prAnimeAudio").value || null,
|
|
animeSubtitleLanguage: $("prAnimeSub").value || null,
|
|
animeAudioLanguage2: base.animeAudioLanguage2 || "eng",
|
|
animeSubtitleLanguage2: base.animeSubtitleLanguage2 || "cze",
|
|
preferredResolution: $("prRes").value ? parseInt($("prRes").value,10) : null,
|
|
preferHighestQuality: $("prHighest").checked,
|
|
combineSignsAndDialogue: $("prSigns").checked,
|
|
feedbackPrompt: $("prFeedback").value,
|
|
});
|
|
// updatedAt is the LWW clock the app compares on; the server's `updated` is
|
|
// only the pull cursor. Omitting this would make the edit lose to a stale
|
|
// device, or clobber a newer one unpredictably.
|
|
var body={ profile:pid, data:data, updatedAt:new Date().toISOString() };
|
|
var r = rec
|
|
? await api("PATCH","/api/collections/prefs/records/"+rec.id, body)
|
|
: await api("POST","/api/collections/prefs/records", body);
|
|
busy($("prSave"),false);
|
|
setMsg($("prMsg"), r.ok?"Uloženo. Zařízení si to stáhnou při dalším spuštění."
|
|
:"Uložení selhalo.", r.ok?"ok":"err");
|
|
if (r.ok) await loadPrefs();
|
|
};
|
|
|
|
// ── addons ───────────────────────────────────────────────────────────────────
|
|
function renderAddonGate(){
|
|
var have = !!PASSWORD;
|
|
$("adNeedPass").className = have ? "hidden" : "";
|
|
$("adBody").className = have ? "" : "hidden";
|
|
if (have) loadAddons();
|
|
}
|
|
$("adUnlock").onclick=async function(){
|
|
var p=$("adPass").value; if(!p){ return; }
|
|
busy($("adUnlock"),true,"Ověřuji…");
|
|
// Verify the password by authenticating, rather than by trying to decrypt —
|
|
// a wrong password and an absent config look identical to the decrypt path.
|
|
var r=await api("POST","/api/collections/users/auth-with-password",
|
|
{ identity:USER.email, password:p }, {anon:true});
|
|
busy($("adUnlock"),false);
|
|
if(!r.ok){ setMsg($("adMsg"),"Špatné heslo.","err"); return; }
|
|
TOKEN=r.data.token; USER=r.data.record; PASSWORD=p; saveSession();
|
|
setMsg($("adMsg"),""); $("adPass").value="";
|
|
renderAddonGate();
|
|
$("acChange").disabled=false;
|
|
};
|
|
|
|
async function loadAddons(){
|
|
var r=await api("GET","/api/collections/addon_config/records?perPage=200");
|
|
CONFIGS = r.ok ? (r.data.items||[]) : [];
|
|
cfgRec = CONFIGS[0] || null;
|
|
cfg=null;
|
|
if (cfgRec){
|
|
try { cfg=JSON.parse(await decryptBlob(cfgRec.blob, PASSWORD, cfgRec.salt)); }
|
|
catch(_){ cfg=null; }
|
|
}
|
|
var configured = cfg && (cfg.addonUrl || cfg.czechAddonUrl);
|
|
$("adSetup").className = configured ? "hidden" : "";
|
|
$("adEdit").className = configured ? "" : "hidden";
|
|
$("adFamilyBox").className="hidden"; $("adByocBox").className="hidden";
|
|
if (configured){
|
|
$("adTorbox").value=cfg.addonUrl||"";
|
|
$("adCzech").value=cfg.czechAddonUrl||"";
|
|
$("adTmdb").value=cfg.tmdbKey||"";
|
|
if (USER && USER.nsfwEnabled){
|
|
$("adAdultBox").className="";
|
|
$("adAdult").value=cfg.adultAddonUrl||"";
|
|
}
|
|
}
|
|
$("acChange").disabled = !PASSWORD;
|
|
}
|
|
$("adFamily").onclick=function(){ $("adSetup").className="hidden"; $("adFamilyBox").className=""; };
|
|
$("adByoc").onclick=function(){ $("adSetup").className="hidden"; $("adByocBox").className=""; };
|
|
document.querySelectorAll(".adBack").forEach(function(b){
|
|
b.onclick=function(){ $("adFamilyBox").className="hidden";
|
|
$("adByocBox").className="hidden"; $("adSetup").className=""; setMsg($("adMsg"),""); };
|
|
});
|
|
$("adRedo").onclick=function(){ $("adEdit").className="hidden"; $("adSetup").className=""; };
|
|
|
|
// family code → the shared template
|
|
$("adCodeGo").onclick=async function(){
|
|
var code=$("adCode").value.trim();
|
|
if(!code){ setMsg($("adMsg"),"Zadej kód.","err"); return; }
|
|
busy($("adCodeGo"),true,"Nastavuji…"); setMsg($("adMsg"),"");
|
|
var r=await api("GET","/api/amber/onboarding-template?code="+encodeURIComponent(code));
|
|
if(!r.ok){
|
|
busy($("adCodeGo"),false);
|
|
setMsg($("adMsg"), r.status===403 ? "Neplatný kód."
|
|
: r.status===429 ? "Moc pokusů, zkus to za chvíli."
|
|
: "Rodinné zdroje se nepodařilo načíst.","err");
|
|
return;
|
|
}
|
|
await saveConfig({ addonUrl:r.data.addonUrl||"", czechAddonUrl:r.data.czechAddonUrl||"",
|
|
tmdbKey:r.data.tmdbKey||"" }, $("adCodeGo"));
|
|
};
|
|
|
|
// BYOC → mint the URLs against the addons themselves
|
|
$("byGo").onclick=async function(){
|
|
var ptU=$("byPtUser").value.trim(), ptP=$("byPtPass").value;
|
|
var wsU=$("byWsUser").value.trim(), wsP=$("byWsPass").value;
|
|
var tb=$("byTbKey").value.trim(), tmdb=$("byTmdb").value.trim();
|
|
if(!ptU || !ptP){
|
|
setMsg($("adMsg"),"prehraj.to je zatím povinný — bez něj český doplněk nefunguje.","err");
|
|
return;
|
|
}
|
|
if ((wsU && !wsP) || (!wsU && wsP)){
|
|
setMsg($("adMsg"),"U webshare vyplň jméno i heslo, nebo obojí nech prázdné.","err");
|
|
return;
|
|
}
|
|
busy($("byGo"),true,"Nastavuji…"); setMsg($("adMsg"),"");
|
|
var out={ tmdbKey:tmdb };
|
|
try {
|
|
// Straight to the addon. Both send permissive CORS, so the credentials go
|
|
// from this browser to the addon and never through our server.
|
|
var enc=await fetch(PREHRAJTO_BASE+"/encode",{
|
|
method:"POST", headers:{"Content-Type":"application/json"},
|
|
body:JSON.stringify({ username:ptU, password:ptP,
|
|
wsUsername:wsU||"", wsPassword:wsP||"" })});
|
|
var ed=await enc.json();
|
|
if(!enc.ok || !ed.token) throw new Error(ed.error||"encode failed");
|
|
out.czechAddonUrl = PREHRAJTO_BASE+"/"+ed.token+"/manifest.json";
|
|
} catch(err){
|
|
busy($("byGo"),false);
|
|
setMsg($("adMsg"),"Český doplněk odmítl údaje: "+err.message,"err");
|
|
return;
|
|
}
|
|
if (tb){
|
|
// The TorBox addon has no /encode — its config token is base64url of the
|
|
// same JSON its own configure page builds.
|
|
out.addonUrl = TORBOX_BASE+"/"+b64url(JSON.stringify({
|
|
torboxApiKey: tb, language: "cs-CZ" }));
|
|
}
|
|
await saveConfig(out, $("byGo"));
|
|
};
|
|
|
|
async function saveConfig(next, btn){
|
|
var merged=Object.assign({}, cfg||{}, next);
|
|
var salt=(cfgRec && cfgRec.salt) || newSaltB64();
|
|
var blob=await encryptWithSalt(JSON.stringify(merged), PASSWORD, salt);
|
|
var body={ blob:blob, salt:salt, kdf:KDF_ID, updatedAt:new Date().toISOString() };
|
|
var r;
|
|
if (cfgRec){
|
|
r=await api("PATCH","/api/collections/addon_config/records/"+cfgRec.id, body);
|
|
} else {
|
|
if (!PROFILES.length){ setMsg($("adMsg"),"Účet nemá profil.","err");
|
|
if(btn) busy(btn,false); return; }
|
|
body.profile=PROFILES[0].id;
|
|
r=await api("POST","/api/collections/addon_config/records", body);
|
|
}
|
|
if(btn) busy(btn,false);
|
|
setMsg($("adMsg"), r.ok ? "Zdroje uloženy. Aplikace si je stáhne sama."
|
|
: "Uložení selhalo.", r.ok?"ok":"err");
|
|
if (r.ok) await loadAddons();
|
|
}
|
|
|
|
$("adSave").onclick=async function(){
|
|
busy($("adSave"),true,"Ukládám…");
|
|
var next={ addonUrl:$("adTorbox").value.trim(),
|
|
czechAddonUrl:$("adCzech").value.trim(),
|
|
tmdbKey:$("adTmdb").value.trim() };
|
|
if (USER && USER.nsfwEnabled) next.adultAddonUrl=$("adAdult").value.trim();
|
|
await saveConfig(next, $("adSave"));
|
|
};
|
|
|
|
// ── devices ──────────────────────────────────────────────────────────────────
|
|
async function loadDevices(){
|
|
var r=await api("GET","/api/collections/device_auth/records?perPage=200&sort=-created");
|
|
var ul=$("deviceList"); ul.innerHTML="";
|
|
var items=(r.ok && r.data.items) || [];
|
|
if(!items.length){ ul.innerHTML='<li class="meta">Zatím žádná zařízení.</li>'; return; }
|
|
items.forEach(function(d){
|
|
var li=document.createElement("li");
|
|
var wrap=document.createElement("div"); wrap.className="rowbtn";
|
|
var info=document.createElement("div");
|
|
info.innerHTML="<b>"+esc(d.deviceName||"Zařízení")+"</b>"+
|
|
'<div class="meta">'+esc(d.status||"")+" · "+String(d.created||"").slice(0,10)+"</div>";
|
|
var btn=document.createElement("button"); btn.className="danger"; btn.textContent="Odebrat";
|
|
btn.onclick=async function(){
|
|
busy(btn,true,"…");
|
|
var dr=await api("DELETE","/api/collections/device_auth/records/"+d.id);
|
|
setMsg($("dvMsg"), dr.ok?"Zařízení odebráno.":"Odebrání selhalo.", dr.ok?"ok":"err");
|
|
await loadDevices();
|
|
};
|
|
wrap.appendChild(info); wrap.appendChild(btn); li.appendChild(wrap); ul.appendChild(li);
|
|
});
|
|
}
|
|
|
|
// ── downloads ────────────────────────────────────────────────────────────────
|
|
async function loadDownloads(){
|
|
var box=$("dlList"); box.innerHTML="";
|
|
var plats=[["android","Android TV / telefon"],["windows","Windows"],["linux","Linux"]];
|
|
var any=false;
|
|
for (var i=0;i<plats.length;i++){
|
|
var p=plats[i];
|
|
var m=await api("GET","/api/update/manifest?platform="+p[0]);
|
|
var li=document.createElement("li");
|
|
li.style.cssText="padding:12px;border:1px solid var(--line);border-radius:10px;"+
|
|
"margin-bottom:8px;background:var(--sunk);font-size:14px;list-style:none";
|
|
if (m.ok && m.data && m.data.available){
|
|
// `releases.file` is a protected file: the path alone 403s, it needs a
|
|
// short-lived file token. Minted per link, exactly as the landing page does.
|
|
var t=await api("POST","/api/files/token",null);
|
|
var href=(t.ok && t.data && t.data.token)
|
|
? m.data.downloadPath+"?token="+encodeURIComponent(t.data.token) : null;
|
|
li.innerHTML='<div class="rowbtn"><div><b>'+esc(p[1])+'</b><div class="meta">'+
|
|
esc(m.data.version||"")+" · "+esc(m.data.variant||"")+'</div></div>'+
|
|
(href ? '<a class="btn primary" style="width:auto;padding:9px 14px;margin:0;'+
|
|
'text-decoration:none;display:inline-block" href="'+href+'">Stáhnout</a>'
|
|
: '<span class="meta">odkaz se nepodařilo vytvořit</span>')+'</div>';
|
|
any=true;
|
|
} else {
|
|
li.innerHTML="<b>"+esc(p[1])+'</b><div class="meta">zatím nic ke stažení</div>';
|
|
}
|
|
box.appendChild(li);
|
|
}
|
|
if (!any) setMsg($("dlMsg"),"Zatím není publikovaná žádná verze.","");
|
|
}
|
|
|
|
// ── account ──────────────────────────────────────────────────────────────────
|
|
$("acSaveRating").onclick=async function(){
|
|
busy($("acSaveRating"),true,"Ukládám…");
|
|
var r=await api("PATCH","/api/collections/users/records/"+USER.id,
|
|
{ ratingDefault: $("acRating").value });
|
|
busy($("acSaveRating"),false);
|
|
if (r.ok) USER=r.data;
|
|
setMsg($("acMsg"), r.ok?"Uloženo.":"Uložení selhalo.", r.ok?"ok":"err");
|
|
};
|
|
|
|
$("acChange").onclick=async function(){
|
|
var oldP=$("acOld").value, n1=$("acNew").value, n2=$("acNew2").value;
|
|
if (!PASSWORD){ setMsg($("acMsg"),"Nejdřív odemkni Zdroje — jinak přijdeš o nastavení zdrojů.","err"); return; }
|
|
if (n1.length<8){ setMsg($("acMsg"),"Nové heslo musí mít aspoň 8 znaků.","err"); return; }
|
|
if (n1!==n2){ setMsg($("acMsg"),"Nová hesla se neshodují.","err"); return; }
|
|
busy($("acChange"),true,"Měním…"); setMsg($("acMsg"),"");
|
|
|
|
// Re-key BEFORE changing the password: if the PATCH succeeded and the
|
|
// re-encrypt then failed, the vault would be unreadable by every device and
|
|
// the family would lose their sources. Doing it in this order means a failure
|
|
// here leaves everything exactly as it was.
|
|
var reEncrypted=[];
|
|
try {
|
|
for (var i=0;i<CONFIGS.length;i++){
|
|
var rec=CONFIGS[i];
|
|
var clear=await decryptBlob(rec.blob, oldP, rec.salt);
|
|
var salt=newSaltB64(); // new password ⇒ new key ⇒ new salt
|
|
reEncrypted.push({ id:rec.id,
|
|
blob: await encryptWithSalt(clear, n1, salt), salt: salt });
|
|
}
|
|
} catch(_){
|
|
busy($("acChange"),false);
|
|
setMsg($("acMsg"),"Současné heslo nesedí ke tvým zdrojům. Heslo jsem nezměnil.","err");
|
|
return;
|
|
}
|
|
|
|
var r=await api("PATCH","/api/collections/users/records/"+USER.id,
|
|
{ oldPassword:oldP, password:n1, passwordConfirm:n2 });
|
|
if(!r.ok){
|
|
busy($("acChange"),false);
|
|
setMsg($("acMsg"), r.status===400 ? "Současné heslo nesedí."
|
|
: "Změna hesla selhala.","err");
|
|
return;
|
|
}
|
|
// The password change invalidates the token; re-authenticate before writing.
|
|
var a=await api("POST","/api/collections/users/auth-with-password",
|
|
{ identity:USER.email, password:n1 }, {anon:true});
|
|
if (a.ok){ TOKEN=a.data.token; USER=a.data.record; PASSWORD=n1; saveSession(); }
|
|
var failed=0;
|
|
for (var j=0;j<reEncrypted.length;j++){
|
|
var e=reEncrypted[j];
|
|
var ur=await api("PATCH","/api/collections/addon_config/records/"+e.id,
|
|
{ blob:e.blob, salt:e.salt, kdf:KDF_ID, updatedAt:new Date().toISOString() });
|
|
if(!ur.ok) failed++;
|
|
}
|
|
busy($("acChange"),false);
|
|
$("acOld").value=$("acNew").value=$("acNew2").value="";
|
|
setMsg($("acMsg"), failed
|
|
? "Heslo změněno, ale "+failed+" nastavení zdrojů se nepodařilo přešifrovat — otevři Zdroje a ulož je znovu."
|
|
: "Heslo změněno a zdroje přešifrovány.", failed?"err":"ok");
|
|
await loadAddons();
|
|
};
|
|
|
|
// ── boot ─────────────────────────────────────────────────────────────────────
|
|
(async function(){
|
|
fillLangs();
|
|
$("tvUrl").textContent=location.host+"/tv";
|
|
if (await restore()) await enter();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|