Merge pull request 'Add /settings web editor for the encrypted addon config' (#9) from feature/web-settings-editor into main
Reviewed-on: #9
This commit is contained in:
commit
a98f52e3bc
2 changed files with 321 additions and 0 deletions
6
pb_hooks/settings.pb.js
Normal file
6
pb_hooks/settings.pb.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/// Pretty URL for the account settings editor: /settings → the static page.
|
||||||
|
/// The page itself (pb_public/settings.html) is a login → decrypt → edit →
|
||||||
|
/// re-encrypt flow for the client-encrypted addon_config; everything happens in
|
||||||
|
/// the browser against the normal collection API, so there's no server logic
|
||||||
|
/// here beyond this redirect.
|
||||||
|
routerAdd("GET", "/settings", (e) => e.redirect(302, "/settings.html"))
|
||||||
315
pb_public/settings.html
Normal file
315
pb_public/settings.html
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
<!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>
|
||||||
Loading…
Reference in a new issue