diff --git a/assets/app.js b/assets/app.js index f854b94..711f6a0 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,24 +1,58 @@ // PayToLaugh front end. // -// NOTE ON THE TOKEN BELOW: it is public by construction. A static page cannot -// keep a secret, so the endpoint behind it is built to be safe in public -// hands. It reads and writes small records only and cannot spend money. -// Script and video generation are deliberately NOT reachable from here. +// IDENTITY +// The browser holds an opaque session token and nothing else. Every call +// carries that token; the server resolves it to an account and ignores any +// email in the payload. Sending an email address no longer proves anything, +// which is the point — it used to. // -// NOTE ON "LOGIN": an email address identifies you, it does not authenticate -// you. Anyone who knows an address can see that account. Temporary, by -// agreement, until real auth lands. +// THE MENU COMES FROM THE SERVER +// The nav is built from what the server says this session may reach, rather +// than rendered and then hidden. Hidden markup is not a permission; anyone +// can unhide it. It also means a menu changes without a deploy. +// +// THE INVOKE TOKEN BELOW IS PUBLIC by construction — a static page cannot keep +// a secret. It gates nothing on its own; the session does that. var API = "https://api.corben.world/functions/da0474ea-4e56-405c-9f0d-35c3e4037a69/invoke" + "?token=97cd624cd2bd6a74eb692a940e71851f"; var PHOTO_KINDS = ["head shot", "profile", "full body", "sitting", "playing", "sleeping", "action", "other"]; -// A plate is built from a head-on shot; these are the ones worth having. var REFERENCE_KINDS = ["head shot", "profile", "full body"]; +var Session = { + tokKey: "ptl_session", + emailKey: "ptl_email", + menuKey: "ptl_menu", + nameKey: "ptl_name", + token: function () { try { return localStorage.getItem(this.tokKey); } catch (e) { return null; } }, + get: function () { try { return localStorage.getItem(this.emailKey); } catch (e) { return null; } }, + name: function () { try { return localStorage.getItem(this.nameKey) || ""; } catch (e) { return ""; } }, + menu: function () { + try { return JSON.parse(localStorage.getItem(this.menuKey) || "null"); } + catch (e) { return null; } + }, + start: function (r, email, name) { + try { + if (r && r.session) localStorage.setItem(this.tokKey, r.session); + if (email) localStorage.setItem(this.emailKey, email); + if (name) localStorage.setItem(this.nameKey, name); + if (r && r.menu) localStorage.setItem(this.menuKey, JSON.stringify(r.menu)); + } catch (e) {} + }, + clear: function () { + var self = this; + ["tokKey", "emailKey", "menuKey", "nameKey"].forEach(function (k) { + try { localStorage.removeItem(self[k]); } catch (e) {} + }); + } +}; + async function api(action, payload) { var body = Object.assign({ action: action }, payload || {}); + var tok = Session.token(); + if (tok) body.session = tok; var res; try { res = await fetch(API, { @@ -32,24 +66,18 @@ async function api(action, payload) { var outer; try { outer = await res.json(); } catch (e) { return { ok: false, error: "The server sent something unreadable." }; } - return outer && outer.result ? outer.result : outer; -} + var r = (outer && outer.result) ? outer.result : outer; -var Session = { - key: "ptl_email", - nameKey: "ptl_name", - get: function () { try { return localStorage.getItem(this.key); } catch (e) { return null; } }, - name: function () { try { return localStorage.getItem(this.nameKey) || ""; } catch (e) { return ""; } }, - set: function (email, name) { - try { - localStorage.setItem(this.key, email); - if (name) localStorage.setItem(this.nameKey, name); - } catch (e) {} - }, - clear: function () { - try { localStorage.removeItem(this.key); localStorage.removeItem(this.nameKey); } catch (e) {} + // An expired or revoked session should land somewhere useful rather than + // failing oddly on every call afterwards. + if (r && r.signed_out) { + Session.clear(); + if (!/register\.html/.test(location.pathname)) { + location.href = "/register.html?next=" + encodeURIComponent(location.pathname); + } } -}; + return r; +} function esc(s) { return String(s == null ? "" : s).replace(/[&<>\"']/g, function (c) { @@ -57,29 +85,52 @@ function esc(s) { }); } +// Menu items come from the server. What is cached renders immediately so the +// nav does not flicker; the refresh then corrects it. function navBar(current) { var email = Session.get(); - var links = [["/", "Home", "home"], ["/pets.html", "My pets", "pets"], - ["/create.html", "New video", "create"], ["/videos.html", "My videos", "videos"]]; - var html = ''; - document.body.insertAdjacentHTML("afterbegin", html); - var b = document.getElementById("ptl-signout"); - if (b) b.onclick = function () { Session.clear(); location.href = "/register.html"; }; + var items = Session.menu() || [{ id: "home", label: "Home", href: "/" }]; + + function paint(list) { + var old = document.querySelector("nav"); + var html = ''; + if (old) old.outerHTML = html; + else document.body.insertAdjacentHTML("afterbegin", html); + var b = document.getElementById("ptl-signout"); + if (b) b.onclick = async function () { + await api("sign_out", {}); + Session.clear(); + location.href = "/register.html"; + }; + } + + paint(items); + if (Session.token()) { + api("whoami", {}).then(function (r) { + if (r && r.ok && r.menu) { + try { localStorage.setItem(Session.menuKey, JSON.stringify(r.menu)); } catch (e) {} + paint(r.menu); + } + }); + } } function requireSession() { - var e = Session.get(); - if (!e) { location.href = "/register.html?next=" + encodeURIComponent(location.pathname); return null; } - return e; + if (!Session.token()) { + location.href = "/register.html?next=" + encodeURIComponent(location.pathname); + return null; + } + return Session.get(); } function showMsg(id, text, kind) { @@ -100,9 +151,18 @@ function priceFor(segments) { return { cost: cost, price: Math.round(cost * 5 * 100) / 100, seconds: segments * 8 }; } +// Date AND time: several videos a day makes a bare date useless for telling +// them apart. +function when(s) { + if (!s) return ""; + var d = new Date(s); + if (isNaN(d)) return s; + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + + " " + d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); +} + // Shrink in the browser before upload. Phone photos run to 5-10MB, which is -// slow to send and far more detail than a reference plate needs; 1600px is -// ample. Returns base64 without the data: prefix. +// slow to send and far more detail than a reference plate needs. function resizeImage(file, maxPx, quality) { maxPx = maxPx || 1600; quality = quality || 0.85; @@ -121,7 +181,7 @@ function resizeImage(file, maxPx, quality) { c.width = cw; c.height = ch; var g = c.getContext("2d"); g.fillStyle = "#ffffff"; - g.fillRect(0, 0, cw, ch); // flatten transparency for JPEG + g.fillRect(0, 0, cw, ch); g.drawImage(img, 0, 0, cw, ch); var url; try { url = c.toDataURL("image/jpeg", quality); }