The unified page becomes the site; retire the two it replaced

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.
This commit is contained in:
Claude 2026-08-07 02:10:48 +02:00
parent 00b20550c2
commit 12e7f4103f
5 changed files with 851 additions and 1617 deletions

View file

@ -2,18 +2,23 @@
// Pretty URLs for the family-facing web surfaces. // Pretty URLs for the family-facing web surfaces.
// //
// One page now does what three did: `account.html` is sign-in, profiles, // One page now does what three did: `index.html` is sign-in, profiles,
// playback preferences, sources, devices and downloads behind a persisted // playback preferences, sources, devices and downloads behind a persisted
// session. `/settings` keeps working because it is in people's muscle memory and // session. `/settings` keeps working because it is in people's muscle memory and
// in older messages — it lands on the sources section of the new page. // in older messages — it lands on the sources section of the new page.
// //
// `/tv` is the short form of `/get/tv`, because that URL gets typed on a // `/tv` is the short form of `/get/tv`, because that URL gets typed on a
// television remote inside the Downloader app, one character at a time. // television remote inside the Downloader app, one character at a time.
//
// `onboarding.html` and `settings.html` are gone rather than left lying around.
// The first offered a signup form that can now only fail — registration is
// owner-only — and a dead button is worse than no button. The second is a subset
// of the sources section.
routerAdd("GET", "/account", (e) => e.redirect(302, "/account.html")) routerAdd("GET", "/account", (e) => e.redirect(302, "/"))
// Deep-link straight to sources; the page reads the hash to pick its tab. // Deep-link straight to sources; the page reads the hash to pick its tab.
routerAdd("GET", "/settings", (e) => e.redirect(302, "/account.html#addons")) routerAdd("GET", "/settings", (e) => e.redirect(302, "/#addons"))
// Typing this on a remote is the actual cost being optimised. // Typing this on a remote is the actual cost being optimised.
routerAdd("GET", "/tv", (e) => e.redirect(302, "/get/tv")) routerAdd("GET", "/tv", (e) => e.redirect(302, "/get/tv"))

View file

@ -1,879 +0,0 @@
<!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 ({"<":"&lt;",">":"&gt;","&":"&amp;",'"':"&quot;"})[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>

File diff suppressed because it is too large Load diff

View file

@ -1,295 +0,0 @@
<!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 — vytvořit úč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; }
* { 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; display:flex; align-items:center; justify-content:center;
padding:20px; }
.card { width:100%; max-width:440px; background:var(--card); border-radius:16px;
padding:26px 22px; border:1px solid var(--line);
box-shadow:0 10px 40px rgba(0,0,0,.25); }
h1 { font-size:22px; margin:0 0 4px; color:var(--amber); }
p.sub { margin:0 0 18px; color:var(--muted); font-size:14px; line-height:1.5; }
label { display:block; font-size:13px; color:var(--muted); margin:12px 0 5px; }
input[type=email], input[type=password], input[type=text] { width:100%;
padding:12px; border-radius:10px; border:1px solid var(--line);
background:var(--bg); color:var(--fg); font-size:15px; }
input:focus { outline:2px solid var(--amber); border-color:transparent; }
.check { display:flex; gap:10px; align-items:flex-start; margin-top:16px;
font-size:14px; color:var(--muted); line-height:1.45; }
.check input { margin-top:3px; }
button.btn { width:100%; margin-top:20px; padding:13px; border:none;
border-radius:10px; font-size:15px; font-weight:700; cursor:pointer; }
.primary { background:var(--amber); color:#1b1206; }
.primary:disabled { opacity:.55; cursor:default; }
a.btn { display:block; width:100%; text-align:center; margin-top:10px; padding:13px;
border-radius:10px; font-size:15px; font-weight:700; text-decoration:none;
background:transparent; color:var(--amber); border:1px solid var(--amber2); }
.msg { margin-top:14px; font-size:14px; min-height:18px; }
.msg.err { color:var(--err); }
.msg.ok { color:var(--ok); }
.hidden { display:none; }
.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); } }
.steps { margin:14px 0 0; padding:0; list-style:none; font-size:14px;
color:var(--muted); }
.steps li { padding:6px 0; }
.steps li.done::before { content:"✓ "; color:var(--ok); }
.steps li.doing::before { content:"… "; color:var(--amber); }
.steps li.todo::before { content:"· "; }
ol.tv { margin:8px 0 0; padding-left:20px; color:var(--muted); font-size:14px;
line-height:1.7; }
ol.tv b { color:var(--fg); }
code.url { background:var(--bg); border:1px solid var(--line); border-radius:8px;
padding:3px 8px; font-family:ui-monospace,Menlo,Consolas,monospace;
font-size:13px; color:var(--amber); }
.note { margin-top:16px; font-size:12px; color:var(--muted); line-height:1.5; }
.dl { display:flex; gap:10px; margin-top:10px; }
.dl a { flex:1; margin-top:0; }
</style>
</head>
<body>
<div class="card">
<h1>Vytvořit účet</h1>
<p class="sub">Za pár kroků budeš mít Amber připravený ke sledování.</p>
<!-- Step 1: the single form -->
<div id="form">
<label for="invite">Kód pozvánky</label>
<input id="invite" type="text" autocomplete="off" spellcheck="false"
placeholder="dostaneš od Richarda">
<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="new-password">
<label for="pass2">Heslo znovu</label>
<input id="pass2" type="password" autocomplete="new-password">
<label for="pname">Jméno profilu (kdo se bude dívat?)</label>
<input id="pname" type="text" maxlength="100" placeholder="např. Máma">
<div class="check">
<input id="useTpl" type="checkbox" checked>
<label for="useTpl" style="margin:0">Použít rodinné nastavení
(doporučeno — zdroje filmů a seriálů se nastaví samy)</label>
</div>
<button id="go" class="btn primary">Vytvořit účet</button>
</div>
<!-- Step 2: progress -->
<ul id="progress" class="steps hidden">
<li id="stAcc" class="todo">Vytvořit účet</li>
<li id="stProf" class="todo">Založit profil</li>
<li id="stCfg" class="todo">Nastavit zdroje (šifruje se ve tvém prohlížeči)</li>
</ul>
<div id="msg" class="msg"></div>
<!-- Step 3: success -->
<div id="doneBox" class="hidden">
<p class="sub" style="margin-top:14px">🎉 <b style="color:var(--fg)">Hotovo!</b>
Účet je připravený. Teď přihlas televizi:</p>
<ol class="tv">
<li>Na televizi nainstaluj Amber — v prohlížeči nebo v aplikaci
<b>Downloader</b> otevři <code class="url" id="tvUrl">/get/tv</code></li>
<li>Spusť Amber a zvol <b>„Přihlásit z jiného zařízení“</b></li>
<li>Naskenuj QR kód telefonem (přihlásíš se právě vytvořeným účtem),
nebo pokračuj tlačítkem níže a opiš kód z obrazovky</li>
</ol>
<a class="btn" href="/device">Zadat kód z televize</a>
<div id="dlBox" class="hidden">
<p class="sub" style="margin:18px 0 0">Nebo stáhni Amber do počítače:</p>
<div class="dl" id="dlLinks"></div>
</div>
</div>
<p class="note">Heslo se použije jen ve tvém prohlížeči k zašifrování nastavení
— na server se heslo ani klíč nikdy neposílají. Nastavení odemkneš přihlášením
v aplikaci.</p>
</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". Key derivation is the same code the /device
// approve page ships (interop-tested against the app's fixed vectors).
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 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"]);
}
async function encryptBlob(plaintext, password){
var salt=crypto.getRandomValues(new Uint8Array(16));
var key=await deriveKey(password, salt);
var nonce=crypto.getRandomValues(new Uint8Array(12));
// WebCrypto AES-GCM returns ciphertext‖tag — exactly the app's layout.
var ct=new Uint8Array(await subtle.encrypt(
{name:"AES-GCM",iv:nonce,tagLength:128},key,new TextEncoder().encode(plaintext)));
return { blob: b64e(concat(nonce, ct)), salt: b64e(salt), kdf: KDF_ID };
}
// ── wizard ───────────────────────────────────────────────────────────────────
document.getElementById("tvUrl").textContent = location.host + "/get/tv";
var msgEl = document.getElementById("msg");
var goBtn = document.getElementById("go");
function setMsg(t, cls){ msgEl.className="msg "+(cls||""); msgEl.textContent=t; }
function mark(id, cls){ document.getElementById(id).className = cls; }
function busy(on){
goBtn.disabled = on;
goBtn.innerHTML = on ? '<span class="spin"></span>Pracuji…' : "Vytvořit účet";
}
async function api(method, path, body, token, headers){
var opt = { method:method, headers:headers||{} };
if (body){ opt.headers["Content-Type"]="application/json"; opt.body=JSON.stringify(body); }
if (token){ opt.headers["Authorization"]=token; }
var r = await fetch(path, opt);
var data = null; try { data = await r.json(); } catch(_){}
return { ok:r.ok, status:r.status, data:data };
}
var NAMES = { windows: "Windows", linux: "Linux" };
async function fillDownloads(token){
var box = document.getElementById("dlLinks");
var found = 0;
for (const p of ["windows", "linux"]){
var m = await api("GET", "/api/update/manifest?platform=" + p, null, token);
if (!m.ok || !m.data || !m.data.available) continue;
var t = await api("POST", "/api/files/token", null, token);
if (!t.ok || !t.data || !t.data.token) continue;
var a = document.createElement("a");
a.className = "btn";
a.href = m.data.downloadPath + "?token=" + encodeURIComponent(t.data.token);
a.textContent = NAMES[p];
box.appendChild(a);
found++;
}
if (found) document.getElementById("dlBox").classList.remove("hidden");
}
async function run(){
var invite = document.getElementById("invite").value.trim();
var email = document.getElementById("email").value.trim();
var pass = document.getElementById("pass").value;
var pass2 = document.getElementById("pass2").value;
var pname = document.getElementById("pname").value.trim() || "Já";
var useTpl = document.getElementById("useTpl").checked;
if (!invite){ setMsg("Zadej kód pozvánky.", "err"); return; }
if (!email){ setMsg("Zadej e-mail.", "err"); return; }
if (pass.length < 8){ setMsg("Heslo musí mít aspoň 8 znaků.", "err"); return; }
if (pass !== pass2){ setMsg("Hesla se neshodují.", "err"); return; }
busy(true); setMsg("");
document.getElementById("progress").classList.remove("hidden");
try {
// 1) create the account (invite-gated server-side)
mark("stAcc", "doing");
var created = await api("POST", "/api/collections/users/records",
{ email: email, password: pass, passwordConfirm: pass },
null, { "X-Amber-Invite": invite });
if (!created.ok){
var m = (created.data && created.data.message) || "";
if (created.status === 400 && /pozvánk/i.test(m)){
setMsg("Neplatný kód pozvánky.", "err");
} else if (created.data && created.data.data && created.data.data.email){
setMsg("Tento e-mail už je zaregistrovaný — přihlas se v aplikaci.", "err");
} else {
setMsg("Účet se nepodařilo vytvořit. " + m, "err");
}
mark("stAcc", "todo"); busy(false); return;
}
mark("stAcc", "done");
// sign in (everything below is owner-scoped)
var auth = await api("POST", "/api/collections/users/auth-with-password",
{ identity: email, password: pass });
if (!auth.ok || !auth.data || !auth.data.token){
setMsg("Účet vznikl, ale přihlášení selhalo — zkus to v aplikaci.", "err");
busy(false); return;
}
var token = auth.data.token;
var uid = auth.data.record.id;
// 2) default profile (the app adopts the earliest-created one)
mark("stProf", "doing");
var prof = await api("POST", "/api/collections/profiles/records",
{ user: uid, name: pname }, token);
if (!prof.ok){ setMsg("Profil se nepodařilo založit.", "err"); busy(false); return; }
var profileId = prof.data.id;
mark("stProf", "done");
// 3) family template → encrypt in-browser → push as addon_config
if (useTpl){
mark("stCfg", "doing");
var tpl = await api("GET", "/api/amber/onboarding-template?code=" +
encodeURIComponent(invite));
if (!tpl.ok){
// Account + profile exist; config can still be entered in-app later.
mark("stCfg", "todo");
setMsg("Rodinné nastavení se nepodařilo načíst — v aplikaci ho jde doplnit ručně.", "err");
} else {
var cfg = {
v: 1,
addonUrl: tpl.data.addonUrl || null,
adultAddonUrl: null,
czechAddonUrl: tpl.data.czechAddonUrl || null,
adultEnabled: false,
tmdbKey: tpl.data.tmdbKey || null,
};
var enc = await encryptBlob(JSON.stringify(cfg), pass);
var push = await api("POST", "/api/collections/addon_config/records", {
profile: profileId,
blob: enc.blob,
salt: enc.salt,
kdf: enc.kdf,
updatedAt: new Date().toISOString(),
}, token);
if (push.ok){ mark("stCfg", "done"); }
else {
mark("stCfg", "todo");
setMsg("Nastavení se nepodařilo uložit — v aplikaci ho jde doplnit ručně.", "err");
}
}
} else {
document.getElementById("stCfg").textContent = "Nastavení zdrojů přeskočeno (doplníš v aplikaci)";
mark("stCfg", "done");
}
// success
document.getElementById("form").classList.add("hidden");
goBtn.classList.add("hidden");
document.getElementById("doneBox").classList.remove("hidden");
fillDownloads(token);
} catch (err){
setMsg("Došlo k chybě. Zkus to prosím znovu.", "err");
busy(false);
}
}
goBtn.addEventListener("click", run);
</script>
</body>
</html>

View file

@ -1,315 +0,0 @@
<!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 — nastavení účtu</title>
<style>
:root { color-scheme: dark; --bg:#0e0f13; --card:#191b21; --fg:#f2e9d8;
--muted:#9aa0aa; --amber:#f0a63c; --amber2:#c9791b; --err:#ff6b6b; --ok:#5fd08a;
--line:#2a2d36; }
* { 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; display:flex; align-items:center; justify-content:center;
padding:20px; }
.card { width:100%; max-width:440px; background:var(--card); border-radius:16px;
padding:26px 22px; border:1px solid var(--line);
box-shadow:0 10px 40px rgba(0,0,0,.25); }
h1 { font-size:22px; margin:0 0 4px; color:var(--amber); }
p.sub { margin:0 0 18px; 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], 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; }
.hint { font-size:12px; color:var(--muted); margin:4px 0 0; line-height:1.4; }
.check { display:flex; gap:10px; align-items:flex-start; margin-top:16px;
font-size:14px; color:var(--fg); line-height:1.45; }
.check input { margin-top:3px; }
button.btn { width:100%; margin-top:20px; padding:13px; border:none;
border-radius:10px; font-size:15px; font-weight:700; cursor:pointer; }
.primary { background:var(--amber); color:#1b1206; }
.primary:disabled { opacity:.55; cursor:default; }
button.link { background:none; border:none; color:var(--muted); font-size:13px;
cursor:pointer; margin-top:14px; text-decoration:underline; padding:0; }
.msg { margin-top:14px; font-size:14px; min-height:18px; }
.msg.err { color:var(--err); }
.msg.ok { color:var(--ok); }
.hidden { display:none; }
.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); } }
.who { font-size:13px; color:var(--muted); margin:0 0 6px; }
.who b { color:var(--fg); }
.sep { height:1px; background:var(--line); margin:20px 0 4px; border:0; }
.note { margin-top:18px; font-size:12px; color:var(--muted); line-height:1.5; }
</style>
</head>
<body>
<div class="card">
<h1>Nastavení účtu</h1>
<p class="sub" id="lead">Přihlas se a uprav zdroje filmů a seriálů —
změny se do aplikace propíšou samy.</p>
<!-- Step 1: login -->
<div id="login">
<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>
<!-- Step 2: settings -->
<div id="settings" class="hidden">
<p class="who">Přihlášen jako <b id="whoEmail"></b></p>
<div id="profilePick" class="hidden">
<label for="profileSel">Profil</label>
<select id="profileSel"></select>
</div>
<label for="addonUrl">Zdroj filmů a seriálů (TorBox)</label>
<input id="addonUrl" type="text" spellcheck="false" autocapitalize="off"
placeholder="https://…">
<label for="czUrl">Český zdroj (dabing / titulky)</label>
<input id="czUrl" type="text" spellcheck="false" autocapitalize="off"
placeholder="https://…">
<label for="tmdbKey">TMDB klíč</label>
<input id="tmdbKey" type="text" spellcheck="false" autocapitalize="off"
placeholder="klíč z themoviedb.org">
<p class="hint">Necháš-li pole prázdné, daný zdroj se z účtu odebere.</p>
<!-- adult fields — rendered only for an 18+ account -->
<div id="adultBox" class="hidden">
<hr class="sep">
<label for="adultUrl">Zdroj 18+</label>
<input id="adultUrl" type="text" spellcheck="false" autocapitalize="off"
placeholder="https://…">
<div class="check">
<input id="adultEnabled" type="checkbox">
<label for="adultEnabled" style="margin:0">Zobrazit sekci 18+ v aplikaci</label>
</div>
</div>
<button id="saveBtn" class="btn primary">Uložit nastavení</button>
<button id="logoutBtn" class="link">Odhlásit se</button>
</div>
<div id="msg" class="msg"></div>
<p class="note">Heslo se použije jen ve tvém prohlížeči k dešifrování a opětovnému
zašifrování nastavení — na server se heslo ani klíč nikdy neposílají.</p>
</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". Same helpers as the onboarding page, plus a
// decrypt path (to read the existing config) and a fixed-salt encrypt.
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 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 nonce=packed.slice(0,12);
var body=packed.slice(12); // WebCrypto expects ciphertext‖tag together
var clear=await subtle.decrypt({name:"AES-GCM",iv:nonce,tagLength:128},key,body);
return new TextDecoder().decode(clear);
}
// Encrypt reusing the record's EXISTING salt, so the key stays identical to the
// one the user's devices already cached — an already-logged-in TV then decrypts
// the edited blob with no password re-entry. A brand-new record gets a fresh salt.
async function encryptWithSalt(plaintext, password, saltB64){
var salt=b64d(saltB64);
var key=await deriveKey(password, salt);
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))); }
// ── state ────────────────────────────────────────────────────────────────────
var TOKEN=null, UID=null, NSFW=false, PASSWORD=null;
var PROFILES=[]; // [{id,name}]
var CONFIGS=[]; // addon_config rows the user owns
var recordId=null, saltB64=null, cfg=null; // active config being edited
var msgEl=document.getElementById("msg");
function setMsg(t, cls){ msgEl.className="msg "+(cls||""); msgEl.textContent=t||""; }
function busy(btn, on, label){ btn.disabled=on;
btn.innerHTML = on ? '<span class="spin"></span>'+label : btn.dataset.label; }
async function api(method, path, body, token){
var opt={ method:method, headers:{} };
if (body){ opt.headers["Content-Type"]="application/json"; opt.body=JSON.stringify(body); }
if (token){ opt.headers["Authorization"]=token; }
var r=await fetch(path, opt);
var data=null; try { data=await r.json(); } catch(_){}
return { ok:r.ok, status:r.status, data:data };
}
// ── login ────────────────────────────────────────────────────────────────────
var loginBtn=document.getElementById("loginBtn");
loginBtn.dataset.label="Přihlásit se";
loginBtn.onclick=async function(){
var email=document.getElementById("email").value.trim();
var pass=document.getElementById("pass").value;
if (!email || !pass){ setMsg("Vyplň e-mail i heslo.","err"); return; }
setMsg(""); busy(loginBtn, true, "Přihlašuji…");
var res=await api("POST","/api/collections/users/auth-with-password",
{ identity:email, password:pass });
if (!res.ok){
busy(loginBtn, false);
setMsg(res.status===400 ? "Špatný e-mail nebo heslo." :
"Přihlášení selhalo. Zkus to prosím znovu.", "err");
return;
}
TOKEN=res.data.token; UID=res.data.record.id;
NSFW=res.data.record.nsfwEnabled===true; PASSWORD=pass;
document.getElementById("whoEmail").textContent=res.data.record.email||email;
await loadConfigs();
};
async function loadConfigs(){
// Both scoped to the owner by collection rules (profile.user = auth.id).
var profs=await api("GET",
"/api/collections/profiles/records?perPage=200&sort=created", null, TOKEN);
var cfgs=await api("GET",
"/api/collections/addon_config/records?perPage=200", null, TOKEN);
if (!profs.ok || !cfgs.ok){
busy(loginBtn, false);
setMsg("Nepodařilo se načíst nastavení účtu.","err"); return;
}
PROFILES=(profs.data.items||[]).map(function(p){ return {id:p.id, name:p.name||"Profil"}; });
CONFIGS=cfgs.data.items||[];
if (!PROFILES.length){
busy(loginBtn, false);
setMsg("Účet nemá žádný profil.","err"); return;
}
// A selector only when there's genuinely more than one config to choose from.
var sel=document.getElementById("profileSel");
if (CONFIGS.length>1){
sel.innerHTML="";
CONFIGS.forEach(function(c, i){
var name=(PROFILES.filter(function(p){return p.id===c.profile;})[0]||{}).name||"Profil";
var o=document.createElement("option"); o.value=String(i); o.textContent=name;
sel.appendChild(o);
});
sel.onchange=function(){ selectConfig(parseInt(sel.value,10)); };
document.getElementById("profilePick").className="";
} else {
document.getElementById("profilePick").className="hidden";
}
await selectConfig(0);
document.getElementById("login").className="hidden";
document.getElementById("settings").className="";
busy(loginBtn, false);
}
// Load config row [i] (or a fresh one for the first profile if none exist).
async function selectConfig(i){
setMsg("");
if (CONFIGS.length){
var rec=CONFIGS[i]||CONFIGS[0];
recordId=rec.id; saltB64=rec.salt;
try {
cfg=JSON.parse(await decryptBlob(rec.blob, PASSWORD, rec.salt));
} catch(_){
cfg=null;
setMsg("Nastavení se nepodařilo dešifrovat — heslo možná bylo změněno " +
"resetem. Ulož znovu a vytvoří se čerstvé.", "err");
cfg={ v:1, adultEnabled:false };
recordId=null; saltB64=newSaltB64();
}
} else {
// No config yet — a fresh record attached to the first profile on save.
cfg={ v:1, adultEnabled:false };
recordId=null; saltB64=newSaltB64();
}
renderCfg();
}
function renderCfg(){
document.getElementById("addonUrl").value=cfg.addonUrl||"";
document.getElementById("czUrl").value=cfg.czechAddonUrl||"";
document.getElementById("tmdbKey").value=cfg.tmdbKey||"";
// 18+ fields exist only for an 18+ account — a clean account never sees them,
// and their stored values are carried through untouched on save.
if (NSFW){
document.getElementById("adultUrl").value=cfg.adultAddonUrl||"";
document.getElementById("adultEnabled").checked=cfg.adultEnabled===true;
document.getElementById("adultBox").className="";
} else {
document.getElementById("adultBox").className="hidden";
}
}
// ── save ─────────────────────────────────────────────────────────────────────
var saveBtn=document.getElementById("saveBtn");
saveBtn.dataset.label="Uložit nastavení";
saveBtn.onclick=async function(){
setMsg(""); busy(saveBtn, true, "Ukládám…");
var t=function(id){ var v=document.getElementById(id).value.trim(); return v||null; };
// Start from the decrypted object so unknown keys (e.g. version) survive.
var out=Object.assign({}, cfg||{ v:1 });
out.addonUrl=t("addonUrl");
out.czechAddonUrl=t("czUrl");
out.tmdbKey=t("tmdbKey");
if (NSFW){
out.adultAddonUrl=t("adultUrl");
out.adultEnabled=document.getElementById("adultEnabled").checked;
} // else: adultAddonUrl / adultEnabled preserved from `cfg` untouched.
try {
var blob=await encryptWithSalt(JSON.stringify(out), PASSWORD, saltB64);
var body={ blob:blob, salt:saltB64, kdf:KDF_ID, updatedAt:new Date().toISOString() };
var res;
if (recordId){
res=await api("PATCH","/api/collections/addon_config/records/"+recordId, body, TOKEN);
} else {
// Attach a new record to the profile whose config we're editing (or the
// first profile when the account had none yet).
var pid=CONFIGS.length ? (CONFIGS[0].profile) : PROFILES[0].id;
body.profile=pid;
res=await api("POST","/api/collections/addon_config/records", body, TOKEN);
}
if (!res.ok){ busy(saveBtn, false); setMsg("Uložení selhalo. Zkus to znovu.","err"); return; }
recordId=res.data.id; cfg=out;
busy(saveBtn, false);
setMsg("✓ Uloženo. V aplikaci se změna projeví do chvíle (po synchronizaci).","ok");
} catch(e){
busy(saveBtn, false);
setMsg("Nepodařilo se zašifrovat nastavení.","err");
}
};
document.getElementById("logoutBtn").onclick=function(){
TOKEN=UID=PASSWORD=null; NSFW=false; recordId=saltB64=cfg=null;
document.getElementById("pass").value="";
document.getElementById("settings").className="hidden";
document.getElementById("login").className="";
setMsg("");
};
</script>
</body>
</html>