// PayToLaugh front end. // // 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. // // 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"]; 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) {} }); } }; // ONE WEBSITE, SEVERAL FRONT DOORS // The hostname the page was loaded from decides the look (a stylesheet), the // name in the corner, the menu and which pages exist. The settings come from // the server (`site_config`), keyed on the hostname sent with every call, and // are cached so the look is right on the first paint of the next page. // `?site=book` on any address previews another front door for this tab. // This is presentation, not permission: what an account may do is decided by // its session on the server. var Site = { told: (function () { var pick = null; try { pick = new URLSearchParams(location.search).get("site"); if (pick) sessionStorage.setItem("ptl_site_pick", pick); else pick = sessionStorage.getItem("ptl_site_pick"); } catch (e) {} return (pick || location.hostname || "").toLowerCase(); })(), key: function () { return "ptl_site:" + this.told; }, get: function () { try { return JSON.parse(localStorage.getItem(this.key()) || "null"); } catch (e) { return null; } }, set: function (cfg) { if (!cfg) return; try { localStorage.setItem(this.key(), JSON.stringify(cfg)); } catch (e) {} this.apply(cfg); }, apply: function (cfg) { cfg = cfg || this.get(); if (!cfg) return; document.documentElement.setAttribute("data-site", cfg.key || ""); var want = cfg.theme ? "/assets/site-" + cfg.theme + ".css" : ""; var link = document.getElementById("ptl-site-css"); if (want && !link) { link = document.createElement("link"); link.id = "ptl-site-css"; link.rel = "stylesheet"; link.href = want; document.head.appendChild(link); } else if (link && link.getAttribute("href") !== want) { if (want) link.href = want; else link.remove(); } }, // Where somebody with no session is sent: the texted-code page once that is // the rule, the email-only page until then. signIn: function () { return ((this.get() || {}).auth === "sms") ? "/login.html" : "/register.html"; } }; Site.apply(); async function api(action, payload) { var body = Object.assign({ action: action }, payload || {}); var tok = Session.token(); if (tok) body.session = tok; body.site = Site.told; var res; try { res = await fetch(API, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); } catch (e) { return { ok: false, error: "Could not reach the server. Check your connection and try again." }; } var outer; try { outer = await res.json(); } catch (e) { return { ok: false, error: "The server sent something unreadable." }; } var r = (outer && outer.result) ? outer.result : outer; // 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|login|join|demo)\.html/.test(location.pathname)) { location.href = Site.signIn() + "?next=" + encodeURIComponent(location.pathname + location.search); } } if (r && r.site) Site.set(r.site); return r; } function esc(s) { return String(s == null ? "" : s).replace(/[&<>\"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '\"': """, "'": "'" }[c]; }); } // 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 items = Session.menu() || [{ id: "home", label: "Home", href: "/" }]; function paint(list) { var old = document.querySelector("nav"); var site = Site.get() || { brand: ["Pay", "To", "Laugh"], home: "/" }; var b3 = site.brand || ["Pay", "To", "Laugh"]; // A page that does not exist at this front door sends the visitor home. if (current && site.pages && site.pages.indexOf(current) < 0 && site.home && location.pathname !== site.home) { location.replace(site.home); return; } 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 = Site.signIn(); }; } paint(items); // The server says what this visitor's menu is - signed in or not - and what // this front door looks like. api() stores the site settings as they arrive. api(Session.token() ? "whoami" : "site_config", {}).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() { if (!Session.token()) { location.href = Site.signIn() + "?next=" + encodeURIComponent(location.pathname + location.search); return null; } return Session.get(); } // Only ever a path on this site: a `next` from the address bar is not trusted // to be one. function safeNext(next, fallback) { return (typeof next === "string" && /^\/[A-Za-z0-9_\-\/.]*(\?[^#]*)?$/.test(next) && next.indexOf("//") < 0) ? next : (fallback || "/"); } function showMsg(id, text, kind) { var el = document.getElementById(id); if (!el) return; el.className = "msg " + (kind || "err"); el.textContent = text; } function hideMsg(id) { var el = document.getElementById(id); if (el) el.className = "msg"; } // Mirrors the server's arithmetic exactly: $0.40 per 8s clip plus $0.17 for // script and plate, at a 5x markup. If one changes, change both. function priceFor(segments) { var cost = segments * 0.40 + 0.17; 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. function resizeImage(file, maxPx, quality) { maxPx = maxPx || 1600; quality = quality || 0.85; return new Promise(function (resolve, reject) { var reader = new FileReader(); reader.onerror = function () { reject(new Error("Could not read that file.")); }; reader.onload = function () { var img = new Image(); img.onerror = function () { reject(new Error("That file is not an image we can read.")); }; img.onload = function () { var w = img.naturalWidth, h = img.naturalHeight; if (!w || !h) { reject(new Error("That image looks empty.")); return; } var scale = Math.min(1, maxPx / Math.max(w, h)); var cw = Math.round(w * scale), ch = Math.round(h * scale); var c = document.createElement("canvas"); c.width = cw; c.height = ch; var g = c.getContext("2d"); g.fillStyle = "#ffffff"; g.fillRect(0, 0, cw, ch); g.drawImage(img, 0, 0, cw, ch); var url; try { url = c.toDataURL("image/jpeg", quality); } catch (e) { reject(new Error("That image could not be processed.")); return; } resolve({ b64: url.split(",")[1], width: cw, height: ch }); }; img.src = reader.result; }; reader.readAsDataURL(file); }); }