From f6276907a842411163544c1d7e6a310a736594a8 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 19 Sep 2021 07:54:55 +0000 Subject: [PATCH 01/25] WIP: encrypt and sync --- app.js | 4 +- auth3000.js | 117 +++++++++++++++ index.html | 21 ++- signin.js | 403 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 540 insertions(+), 5 deletions(-) create mode 100644 auth3000.js create mode 100644 signin.js diff --git a/app.js b/app.js index 148f11a..8dd9a9e 100644 --- a/app.js +++ b/app.js @@ -702,6 +702,7 @@ function _localStorageGetAll(prefix) { _githost: post._githost, _gitbranch: post._gitbranch, _repo: post._repo, + sync_id: post.sync_id, }) ); localStorage.setItem("post." + post.uuid + ".data", post.content); @@ -914,7 +915,8 @@ function _localStorageGetAll(prefix) { function _initFromTemplate() { var pathname = window.document.location.hash.slice(1); - var url = new URL("https://example.com/" + pathname); + // base url doesn't matter - we're just using this for parsing + var url = new URL("https://ignore.me/" + pathname); var query = {}; url.searchParams.forEach(function (val, key) { query[key] = val || true; // ght = true diff --git a/auth3000.js b/auth3000.js new file mode 100644 index 0000000..5b97a9d --- /dev/null +++ b/auth3000.js @@ -0,0 +1,117 @@ +var Auth3000 = {}; + +(async function () { + Auth3000._querystringify = function (options) { + return Object.keys(options) + .filter(function (key) { + return ( + "undefined" !== typeof options[key] && + null !== options[key] && + "" !== options[key] + ); + }) + .map(function (key) { + // the values must be URI-encoded (the %20s and such) + return key + "=" + encodeURIComponent(options[key]); + }) + .join("&"); + }; + + Auth3000.parseQuerystring = function (querystring) { + var query = {}; + querystring.split("&").forEach(function (pairstring) { + var pair = pairstring.split("="); + var key = pair[0]; + var value = decodeURIComponent(pair[1]); + + query[key] = value; + }); + return query; + }; + + Auth3000.parseJwt = async function (jwt) { + var parts = jwt.split("."); + var jws = { + protected: parts[0], + payload: parts[1], + signature: parts[2], + }; + jws.header = Auth3000._urlBase64ToJson(jws.protected); + jws.claims = Auth3000._urlBase64ToJson(jws.payload); + return jws; + }; + + Auth3000.generateOidcUrl = function ( + oidcBaseUrl, + client_id, + redirect_uri, + scope, + login_hint + ) { + // a secure-enough random state value + // (all modern browsers use crypto random Math.random, not that it much matters for a client-side state cache) + var rnd = Math.random().toString(); + // transform from 0.1234... to hexidecimal + var state = parseInt(rnd.slice(2).padEnd(16, "0"), 10) + .toString(16) + .padStart(14, "0"); + // response_type=id_token requires a nonce (one-time use random value) + // response_type=token (access token) does not + var nonceRnd = Math.random().toString(); + var nonce = parseInt(nonceRnd.slice(2).padEnd(16, "0"), 10) + .toString(16) + .padStart(14, "0"); + var options = { state, client_id, redirect_uri, scope, login_hint, nonce }; + // transform from object to 'param1=escaped1¶m2=escaped2...' + var params = Auth3000._querystringify(options); + return oidcBaseUrl + "?response_type=id_token&access_type=online&" + params; + }; + + Auth3000.generateOauth2Url = function ( + authorize_url, + client_id, + redirect_uri, + scopes, + login_hint + ) { + // + // + // + + // a secure-enough random state value + // (all modern browsers use crypto random Math.random, not that it much matters for a client-side state cache) + var rnd = Math.random().toString(); + // transform from 0.1234... to hexidecimal + var state = parseInt(rnd.slice(2).padEnd(16, "0"), 10) + .toString(16) + .padStart(14, "0"); + var login = login_hint; + var scope = scopes.join(" "); + var options = { + state, + client_id, + redirect_uri, + scope, + login: login_hint, + }; + var params = Auth3000._querystringify(options); + return authorize_url + "?allow_signup=true&" + params; + }; + + // because JavaScript's Base64 implementation isn't URL-safe + Auth3000._urlBase64ToBase64 = function (str) { + var r = str % 4; + if (2 === r) { + str += "=="; + } else if (3 === r) { + str += "="; + } + return str.replace(/-/g, "+").replace(/_/g, "/"); + }; + + Auth3000._urlBase64ToJson = function (u64) { + var b64 = Auth3000._urlBase64ToBase64(u64); + var str = atob(b64); + return JSON.parse(str); + }; +})(); diff --git a/index.html b/index.html index 29e94db..f8b8a27 100644 --- a/index.html +++ b/index.html @@ -60,10 +60,11 @@

Bliss: Blog, Easy As Gist

@@ -275,6 +276,18 @@

Bliss: Blog, Easy As Gist

+ + + + diff --git a/signin.js b/signin.js new file mode 100644 index 0000000..a7bfc2c --- /dev/null +++ b/signin.js @@ -0,0 +1,403 @@ +function $(sel, el) { + return (el || document).querySelector(sel); +} + +function $$(sel, el) { + return (el || document).querySelectorAll(sel); +} + +(async function () { + "use strict"; + + // from env.js + let ENV = window.ENV; + + // scheme => 'https:' + // host => 'localhost:3000' + // pathname => '/api/authn/session/oidc/google.com' + //let baseUrl = document.location.protocol + "//" + document.location.host; + let baseUrl = ENV.BASE_API_URL; + + function noop() {} + + function die(err) { + console.error(err); + window.alert( + "Oops! There was an unexpected error on the server.\nIt's not your fault.\n\n" + + "Technical Details for Tech Support: \n" + + err.message + ); + throw err; + } + + async function attemptRefresh() { + let resp = await window + .fetch(baseUrl + "/api/authn/refresh", { method: "POST" }) + .catch(noop); + if (!resp) { + return; + } + return await resp.json().catch(die); + } + + async function importKey(key64) { + let crypto = window.crypto; + let usages = ["encrypt", "decrypt"]; + let extractable = false; + let rawKey = base64ToBuffer(key64); + + return await crypto.subtle.importKey( + "raw", + rawKey, + { name: "AES-CBC" }, + extractable, + usages + ); + } + + function base64ToBuffer(base64) { + function binaryStringToBuffer(binstr) { + var buf; + + if ("undefined" !== typeof Uint8Array) { + buf = new Uint8Array(binstr.length); + } else { + buf = []; + } + + Array.prototype.forEach.call(binstr, function (ch, i) { + buf[i] = ch.charCodeAt(0); + }); + + return buf; + } + var binstr = atob(base64); + var buf = binaryStringToBuffer(binstr); + return buf; + } + + function bufferToBase64(arr) { + function bufferToBinaryString(buf) { + var binstr = Array.prototype.map + .call(buf, function (ch) { + return String.fromCharCode(ch); + }) + .join(""); + + return binstr; + } + var binstr = bufferToBinaryString(arr); + return btoa(binstr); + } + + async function encryptObj(obj, key) { + var crypto = window.crypto; + var ivLen = 16; // the IV is always 16 bytes + console.log(key); + + function joinIvAndData(iv, data) { + var buf = new Uint8Array(iv.length + data.length); + Array.prototype.forEach.call(iv, function (byte, i) { + buf[i] = byte; + }); + Array.prototype.forEach.call(data, function (byte, i) { + buf[ivLen + i] = byte; + }); + return buf; + } + + async function _encrypt(data, key) { + // a public value that should be generated for changes each time + var initializationVector = new Uint8Array(ivLen); + + crypto.getRandomValues(initializationVector); + + return await crypto.subtle + .encrypt({ name: "AES-CBC", iv: initializationVector }, key, data) + .then(function (encrypted) { + var ciphered = joinIvAndData( + initializationVector, + new Uint8Array(encrypted) + ); + + var base64 = bufferToBase64(ciphered); + /* + .replace(/\-/g, "+") + .replace(/_/g, "/"); + while (base64.length % 4) { + base64 += "="; + } + */ + return base64; + }); + } + //return _encrypt(base64ToBuffer(b64), key); + let u8 = new TextEncoder().encode(JSON.stringify(obj)); + return await _encrypt(u8, key); + } + + async function decrypt64(b64, key) { + var crypto = window.crypto; + var ivLen = 16; // the IV is always 16 bytes + + function separateIvFromData(buf) { + var iv = new Uint8Array(ivLen); + var data = new Uint8Array(buf.length - ivLen); + Array.prototype.forEach.call(buf, function (byte, i) { + if (i < ivLen) { + iv[i] = byte; + } else { + data[i - ivLen] = byte; + } + }); + return { iv: iv, data: data }; + } + + function _decrypt(buf, key) { + var parts = separateIvFromData(buf); + + return crypto.subtle + .decrypt({ name: "AES-CBC", iv: parts.iv }, key, parts.data) + .then(function (decrypted) { + var str = new TextDecoder().decode(new Uint8Array(decrypted)); + //var base64 = bufferToBase64(new Uint8Array(decrypted)); + /* + .replace(/\-/g, "+") + .replace(/_/g, "/"); + while (base64.length % 4) { + base64 += "="; + } + */ + return JSON.parse(str); + }); + } + return _decrypt(base64ToBuffer(b64), key); + } + + async function completeOauth2SignIn(baseUrl, query) { + // nix token from browser history + window.history.pushState( + "", + document.title, + window.location.pathname + window.location.search + ); + + // Show the token for easy capture + console.info("access_token", query.access_token); + + if ("github.com" === query.issuer) { + // TODO this is moot. We could set the auth cookie at time of redirect + // and include the real (our) id_token + let resp = await window + .fetch(baseUrl + "/api/authn/session/oauth2/github.com", { + method: "POST", + body: JSON.stringify({ + timezone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + language: window.navigator.language, + }), + headers: { + Authorization: query.access_token, + "Content-Type": "application/json", + }, + }) + .catch(die); + let result = await resp.json().catch(die); + + console.info("Our bespoken token(s):"); + console.info(result); + + await doStuffWithUser(result); + } + // TODO what if it's not github? + } + + async function init() { + $(".js-logout").hidden = true; + $(".js-sign-in-github").hidden = true; + + var githubSignInUrl = Auth3000.generateOauth2Url( + "https://github.com/login/oauth/authorize", + ENV.GITHUB_CLIENT_ID, + ENV.GITHUB_REDIRECT_URI, + ["read:user", "user:email"] + ); + $(".js-github-oauth2-url").href = githubSignInUrl; + + $(".js-logout").addEventListener("click", async function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + + let resp = await window + .fetch(baseUrl + "/api/authn/session", { + method: "DELETE", + }) + .catch(die); + let result = await resp.json().catch(die); + window.alert("Logged out!"); + init(); + }); + + var querystring = document.location.hash.slice(1); + var query = Auth3000.parseQuerystring(querystring); + if (query.id_token) { + completeOidcSignIn(query); + return; + } + if (query.access_token && "bearer" === query.token_type) { + completeOauth2SignIn(baseUrl, query); + return; + } + + let result = await attemptRefresh(); + console.info("Refresh Token: (may be empty)"); + console.info(result); + + if (result.id_token || result.access_token) { + await doStuffWithUser(result); + return; + } + + $(".js-sign-in-github").hidden = false; + //$(".js-social-login").hidden = false; + return; + } + + async function doStuffWithUser(result) { + if (!result.id_token && !result.access_token) { + window.alert("No token, something went wrong."); + return; + } + $(".js-logout").hidden = false; + + let lastSync = new Date( + parseInt(localStorage.getItem("bliss:last-sync"), 10) || 0 + ); + + let resp = await window + .fetch(baseUrl + "/api/dummy?since=" + lastSync.toISOString(), { + method: "GET", + headers: { + Authorization: "Bearer " + (result.id_token || result.access_token), + }, + }) + .catch(die); + let items = await resp.json().catch(die); + console.info("Items:"); + console.info(items); + + // Use MEGA-style https://site.com/invite#priv ? + // hash(priv) => pub + let key64 = localStorage.getItem("bliss:enc-key"); + let key = await importKey(key64).catch(showError); + + function showError(err) { + console.error(err); + window.alert("that's not a valid key"); + } + if (!key) { + if (!items.length) { + let rawKeyBuf = crypto.getRandomValues(new Uint8Array(32)); + key64 = bufferToBase64(rawKeyBuf); + localStorage.setItem("bliss:enc-key", key64); + } + while (items.length && !key64) { + key64 = window.prompt("What's your encryption key?", ""); + key = await importKey(key64).catch(showError); + // TODO try to decrypt + } + } + + let pushes = []; + let receives = []; + /* + let remoteIds = PostModel.ids().reduce(function (map, id) { + let post = PostModel.getOrCreate(id); + map[post.sync_id] = true; + }, {}); + */ + + for (let item of items) { + let data; + try { + // because this is double stringified (for now) + data = JSON.parse(item.data); + } catch (e) { + console.warn("Could not parse:", err); + console.warn(item.data); + } + + // TODO decide which key to use (once we have shared projects) + let post; + try { + post = await decrypt64(data.encrypted, key); + } catch (e) { + console.warn("Could not decrypt"); + console.warn(data); + console.warn(e); + continue; + } + if (post._type && "post" !== post._type) { + console.warn("couldn't handle type", post._type); + console.warn(post); + continue; + } + + console.log("[DEBUG]", lastSync, lastSync.valueOf()); + console.log( + "[DEBUG]", + item.updated_at, + new Date(item.updated_at).valueOf() + ); + if (lastSync.valueOf() < new Date(item.updated_at).valueOf()) { + lastSync = new Date(item.updated_at); + } + + post.sync_id = item.uuid; + post.synced_at = item.updated_at; + // TODO conflict resolution if this has been updated more recently + console.log("decrypted", post.uuid); + console.log(post); + PostModel.save(post); + } + console.log("[DEBUG]", lastSync, lastSync.valueOf()); + localStorage.setItem("bliss:last-sync", lastSync.valueOf()); + + // TODO handle offline case: if new things have not been synced, sync them + + Post._saveHook = async function (post) { + if (post.sync_id) { + console.warn("can't update items yet"); + return; + } + post._type = "post"; + + let token = result.id_token || result.access_token; + let resp = await window + .fetch(baseUrl + "/api/dummy", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + data: JSON.stringify({ encrypted: await encryptObj(post, key) }), + }), + }) + .catch(die); + post.sync_id = await resp.json().uuid; + if (!result.id_token && !result.access_token) { + } + }; + + await PostModel.ids().reduce(async function (p, id) { + await p; + let post = PostModel.getOrCreate(id); + await Post._saveHook(post); + }, Promise.resolve()); + } + + init().catch(function (err) { + console.error(err); + window.alert(`Fatal Error: ${err.message}`); + }); +})(); From 6668fcfaf9984fdbca5a77f3fa0eaa8eee9e8075 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 19 Sep 2021 08:19:32 +0000 Subject: [PATCH 02/25] WIP: add TODO.md --- TODO.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..29e1db6 --- /dev/null +++ b/TODO.md @@ -0,0 +1,9 @@ +What's left? + +- [ ] Ability to show encryption key +- [ ] Draft versioning (local?) +- [ ] Update items +- [ ] Mark items deleted +- [ ] Count bytes +- [ ] Paywall +- [ ] Stripe Payments From ba48f3556b40bae97b29331b34d5b2c8608f2b3b Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 21 Sep 2021 08:11:30 +0000 Subject: [PATCH 03/25] WIP: begin hash router, show passphrase --- app.js | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 51 +++++++++++++++++-- package.json | 8 +++ signin.js | 30 +++++++----- 4 files changed, 209 insertions(+), 15 deletions(-) create mode 100644 package.json diff --git a/app.js b/app.js index 8dd9a9e..ba169ad 100644 --- a/app.js +++ b/app.js @@ -4,6 +4,83 @@ var PostModel = {}; var Blog = {}; var BlogModel = {}; +var Tab = {}; + +var Settings = {}; + +function base64ToBuffer(b64) { + let binstr = atob(b64); + let arr = binstr.split("").map(function (ch) { + return ch.charCodeAt(); + }); + return Uint8Array.from(arr); +} + +function bufferToBase64(buf) { + var binstr = buf + .reduce(function (arr, ch) { + arr.push(String.fromCharCode(ch)); + return arr; + }, []) + .join(""); + return btoa(binstr); +} + +Settings.togglePassphrase = async function (ev) { + let pass = ev.target + .closest("form") + .querySelector(".js-passphrase") + .value.trim(); + if ("[hidden]" != pass) { + ev.target.closest("form").querySelector("button").innerText = "Show"; + ev.target.closest("form").querySelector(".js-passphrase").value = + "[hidden]"; + return; + } + + let b64 = localStorage.getItem("bliss:enc-key") || ""; + let bytes = base64ToBuffer(b64); + pass = await Passphrase.encode(bytes); + + // TODO standardize controller container ma-bob + ev.target.closest("form").querySelector(".js-passphrase").value = pass; + ev.target.closest("form").querySelector("button").innerText = "Hide"; +}; + +Settings.savePassphrase = async function (ev) { + let pass = ev.target + .closest("form") + .querySelector(".js-passphrase") + .value.trim() + .split(/[\s,:-]+/) + .filter(Boolean) + .join(" "); + + let bytes; + try { + bytes = await Passphrase.decode(pass); + } catch (e) { + ev.target.closest("form").querySelector(".js-hint").innerText = e.message; + return; + } + ev.target.closest("form").querySelector(".js-hint").innerText = ""; + + let new64 = bufferToBase64(bytes); + + let current64 = localStorage.getItem("bliss:enc-key"); + if (current64 === new64) { + return; + } + + let isoNow = new Date().toISOString(); + let oldPass = await Passphrase.encode(base64ToBuffer(current64)); + localStorage.setItem(`bliss:enc-key:backup:${isoNow}`, oldPass); + + localStorage.setItem("bliss:enc-key", new64); + ev.target.closest("form").querySelector(".js-hint").innerText = + "Saved New Passphrase!"; +}; + function _localStorageGetIds(prefix, suffix) { var i; var key; @@ -45,6 +122,61 @@ function _localStorageGetAll(prefix) { var $$ = window.$$; var localStorage = window.localStorage; + Tab._init = function () { + window.addEventListener("hashchange", Tab._hashChange, false); + if ("" !== location.hash.slice(1)) { + Tab._hashChange(); + return; + } + + Tab._setToFirst(); + }; + + Tab._setToFirst = function () { + let name = $$("[data-ui]")[0].dataset.ui; + location.hash = `#${name}`; + console.log("[DEBUG] set hash to", location.hash); + }; + + Tab._hashChange = function () { + let name = location.hash.slice(1); + if (!name) { + Tab._setToFirst(); + return; + } + if (!$$(`[data-ui="${name}"]`).length) { + console.warn("something else took over the hash routing:", name); + return; + } + + // TODO requestAnimationFrame + + // switch to the visible tab + $$("[data-ui]").forEach(function ($tabBody, i) { + let tabName = $tabBody.dataset.ui; + + if (name !== tabName) { + $tabBody.hidden = true; + return; + } + + let $tabLink = $(`[data-href="#${name}"]`); + $tabLink.classList.add("active"); + $tabLink.removeAttribute("href"); + $tabBody.hidden = false; + }); + + // switch to the visible tab + $$("a[data-href]").forEach(function ($tabLink) { + let tabName = $tabLink.dataset.href.slice(1); + if (name === tabName) { + return; + } + $tabLink.classList.remove("active"); + $tabLink.href = `#${tabName}`; + }); + }; + Blog.serialize = function (ev) { ev.stopPropagation(); ev.preventDefault(); @@ -906,6 +1038,7 @@ function _localStorageGetAll(prefix) { PostModel._current = PostModel.getOrCreate(localStorage.getItem("current")); }; + Tab._init(); PostModel._init(); Post._init(); Blog._init(); @@ -943,6 +1076,8 @@ function _localStorageGetAll(prefix) { var hashless = window.document.location.href.split("#")[0]; history.replaceState({}, document.title, hashless); + console.log("[DEBUG] replaced history state", hashless); + Tab._setToFirst(); } _initFromTemplate(); })(); diff --git a/index.html b/index.html index f8b8a27..26f6857 100644 --- a/index.html +++ b/index.html @@ -31,6 +31,9 @@ @@ -59,12 +77,13 @@

Bliss: Blog, Easy As Gist

@@ -80,7 +99,7 @@

Bliss: Blog, Easy As Gist


-
+
@@ -276,6 +295,31 @@

Bliss: Blog, Easy As Gist

+
+
+
+ + + + +
+

@@ -287,6 +331,7 @@

Bliss: Blog, Easy As Gist

+ diff --git a/package.json b/package.json new file mode 100644 index 0000000..0eb0ae6 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "@root/passphrase": "^1.1.0" + }, + "devDependencies": { + "dotenv": "^10.0.0" + } +} diff --git a/signin.js b/signin.js index a7bfc2c..7305f7f 100644 --- a/signin.js +++ b/signin.js @@ -6,6 +6,8 @@ function $$(sel, el) { return (el || document).querySelectorAll(sel); } +var Encraption = {}; + (async function () { "use strict"; @@ -40,7 +42,7 @@ function $$(sel, el) { return await resp.json().catch(die); } - async function importKey(key64) { + Encraption.importKey = async function importKey(key64) { let crypto = window.crypto; let usages = ["encrypt", "decrypt"]; let extractable = false; @@ -53,7 +55,7 @@ function $$(sel, el) { extractable, usages ); - } + }; function base64ToBuffer(base64) { function binaryStringToBuffer(binstr) { @@ -90,10 +92,9 @@ function $$(sel, el) { return btoa(binstr); } - async function encryptObj(obj, key) { + Encraption.encryptObj = async function encryptObj(obj, key) { var crypto = window.crypto; var ivLen = 16; // the IV is always 16 bytes - console.log(key); function joinIvAndData(iv, data) { var buf = new Uint8Array(iv.length + data.length); @@ -134,9 +135,9 @@ function $$(sel, el) { //return _encrypt(base64ToBuffer(b64), key); let u8 = new TextEncoder().encode(JSON.stringify(obj)); return await _encrypt(u8, key); - } + }; - async function decrypt64(b64, key) { + Encraption.decrypt64 = async function decrypt64(b64, key) { var crypto = window.crypto; var ivLen = 16; // the IV is always 16 bytes @@ -172,7 +173,7 @@ function $$(sel, el) { }); } return _decrypt(base64ToBuffer(b64), key); - } + }; async function completeOauth2SignIn(baseUrl, query) { // nix token from browser history @@ -181,6 +182,9 @@ function $$(sel, el) { document.title, window.location.pathname + window.location.search ); + // TODO fire hash change event synthetically via the DOM? + console.log("[DEBUG] 2 replaced history state"); + Tab._hashChange(); // Show the token for easy capture console.info("access_token", query.access_token); @@ -288,7 +292,7 @@ function $$(sel, el) { // Use MEGA-style https://site.com/invite#priv ? // hash(priv) => pub let key64 = localStorage.getItem("bliss:enc-key"); - let key = await importKey(key64).catch(showError); + let key = await Encraption.importKey(key64).catch(showError); function showError(err) { console.error(err); @@ -296,13 +300,13 @@ function $$(sel, el) { } if (!key) { if (!items.length) { - let rawKeyBuf = crypto.getRandomValues(new Uint8Array(32)); + let rawKeyBuf = crypto.getRandomValues(new Uint8Array(16)); key64 = bufferToBase64(rawKeyBuf); localStorage.setItem("bliss:enc-key", key64); } while (items.length && !key64) { key64 = window.prompt("What's your encryption key?", ""); - key = await importKey(key64).catch(showError); + key = await Encraption.importKey(key64).catch(showError); // TODO try to decrypt } } @@ -329,7 +333,7 @@ function $$(sel, el) { // TODO decide which key to use (once we have shared projects) let post; try { - post = await decrypt64(data.encrypted, key); + post = await Encraption.decrypt64(data.encrypted, key); } catch (e) { console.warn("Could not decrypt"); console.warn(data); @@ -380,7 +384,9 @@ function $$(sel, el) { "Content-Type": "application/json", }, body: JSON.stringify({ - data: JSON.stringify({ encrypted: await encryptObj(post, key) }), + data: JSON.stringify({ + encrypted: await Encraption.encryptObj(post, key), + }), }), }) .catch(die); From a87b70ff62223cc7f3ac828f544c9bb4037fd0b2 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 23 Sep 2021 07:17:17 +0000 Subject: [PATCH 04/25] WIP: update docs --- signin.js | 68 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/signin.js b/signin.js index 7305f7f..a21890b 100644 --- a/signin.js +++ b/signin.js @@ -278,7 +278,7 @@ var Encraption = {}; ); let resp = await window - .fetch(baseUrl + "/api/dummy?since=" + lastSync.toISOString(), { + .fetch(baseUrl + "/api/user/doc?since=" + lastSync.toISOString(), { method: "GET", headers: { Authorization: "Bearer " + (result.id_token || result.access_token), @@ -368,37 +368,57 @@ var Encraption = {}; // TODO handle offline case: if new things have not been synced, sync them - Post._saveHook = async function (post) { - if (post.sync_id) { - console.warn("can't update items yet"); - return; - } - post._type = "post"; - + async function docCreate() { let token = result.id_token || result.access_token; - let resp = await window - .fetch(baseUrl + "/api/dummy", { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - data: JSON.stringify({ - encrypted: await Encraption.encryptObj(post, key), - }), + let resp = await window.fetch(baseUrl + "/api/user/doc", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + data: JSON.stringify({ + encrypted: await Encraption.encryptObj(post, key), }), - }) - .catch(die); - post.sync_id = await resp.json().uuid; - if (!result.id_token && !result.access_token) { + }), + }); + return resp.json(); + } + async function docUpdate(post) { + let token = result.id_token || result.access_token; + let resp = await window.fetch(`${baseUrl}/api/user/doc/${post.sync_id}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + data: JSON.stringify({ + encrypted: await Encraption.encryptObj(post, key), + }), + }), + }); + return resp.json(); + } + Post._syncHook = async function (post) { + post._type = "post"; + if (!post.sync_id) { + let resp = await docCreate(post); + post.sync_id = resp.uuid; + return post; } + + // TODO update last updated at? + await docUpdate(post); + return post; }; + // update all existing posts await PostModel.ids().reduce(async function (p, id) { await p; let post = PostModel.getOrCreate(id); - await Post._saveHook(post); + post = await Post._syncHook(post).catch(die); + PostModel.save(post); }, Promise.resolve()); } From 945ab18ac39039256269a9afda3b737a4c4f5409 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 25 Sep 2021 19:39:17 +0000 Subject: [PATCH 05/25] WIP: rename app.js --- app.js => blog.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app.js => blog.js (100%) diff --git a/app.js b/blog.js similarity index 100% rename from app.js rename to blog.js From 32b5582dca868bce76030279a04181024406c435 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 25 Sep 2021 22:38:21 +0000 Subject: [PATCH 06/25] WIP: refactor and sync based on update times --- TODO.md | 10 +- app.js | 107 ++++++++++++++++++++++ blog.js | 258 +++++++++++----------------------------------------- encoding.js | 23 +++++ index.html | 4 + signin.js | 129 +++++++++++++++++--------- tabs.js | 60 ++++++++++++ 7 files changed, 336 insertions(+), 255 deletions(-) create mode 100644 app.js create mode 100644 encoding.js create mode 100644 tabs.js diff --git a/TODO.md b/TODO.md index 29e1db6..8157f70 100644 --- a/TODO.md +++ b/TODO.md @@ -1,8 +1,12 @@ What's left? -- [ ] Ability to show encryption key -- [ ] Draft versioning (local?) -- [ ] Update items +- [x] Ability to show encryption key +- [x] Update items +- [x] NOT update items that are older than lastSyncUpdate +- [x] Draft versioning (local?) +- [x] General refactoring +- [ ] Deleted things marked as null +- [ ] Deleted things don't count against quotas - [ ] Mark items deleted - [ ] Count bytes - [ ] Paywall diff --git a/app.js b/app.js new file mode 100644 index 0000000..14723e7 --- /dev/null +++ b/app.js @@ -0,0 +1,107 @@ +var Settings = {}; + +(async function () { + "use strict"; + + Settings.togglePassphrase = async function (ev) { + let pass = ev.target + .closest("form") + .querySelector(".js-passphrase") + .value.trim(); + if ("[hidden]" != pass) { + ev.target.closest("form").querySelector("button").innerText = "Show"; + ev.target.closest("form").querySelector(".js-passphrase").value = + "[hidden]"; + return; + } + + let b64 = localStorage.getItem("bliss:enc-key") || ""; + let bytes = Encoding.base64ToBuffer(b64); + pass = await Passphrase.encode(bytes); + + // TODO standardize controller container ma-bob + ev.target.closest("form").querySelector(".js-passphrase").value = pass; + ev.target.closest("form").querySelector("button").innerText = "Hide"; + }; + + Settings.savePassphrase = async function (ev) { + let pass = ev.target + .closest("form") + .querySelector(".js-passphrase") + .value.trim() + .split(/[\s,:-]+/) + .filter(Boolean) + .join(" "); + + let bytes; + try { + bytes = await Passphrase.decode(pass); + } catch (e) { + ev.target.closest("form").querySelector(".js-hint").innerText = e.message; + return; + } + ev.target.closest("form").querySelector(".js-hint").innerText = ""; + + let new64 = Encoding.bufferToBase64(bytes); + + let current64 = localStorage.getItem("bliss:enc-key"); + if (current64 === new64) { + return; + } + + let isoNow = new Date().toISOString(); + let oldPass = await Passphrase.encode(base64ToBuffer(current64)); + localStorage.setItem(`bliss:enc-key:backup:${isoNow}`, oldPass); + + localStorage.setItem("bliss:enc-key", new64); + ev.target.closest("form").querySelector(".js-hint").innerText = + "Saved New Passphrase!"; + }; +})(); + +(async function () { + "use strict"; + + Tab._init(); + PostModel._init(); + Post._init(); + Blog._init(); + + // deprecated + localStorage.removeItem("all"); + + function _initFromTemplate() { + var pathname = window.document.location.hash.slice(1); + // base url doesn't matter - we're just using this for parsing + var url = new URL("https://ignore.me/" + pathname); + var query = {}; + url.searchParams.forEach(function (val, key) { + query[key] = val || true; // ght = true + }); + if (!query.h && query.ght) { + query.h = "github.com"; + } + if (!query.h || !query.o || !query.r) { + return; + } + + // https://{host}/{owner}/{repo}#{branch} + var repoUrl = "https://" + query.h + "/" + query.o + "/" + query.r; + if (query.b) { + repoUrl += "#" + query.b; + } + + if (query.ght) { + $('select[name="blog"]').value = "eon"; // TODO should be 'hugo' + } + $('input[name="repo"]').value = repoUrl; + var event = new Event("change"); + $('input[name="repo"]').dispatchEvent(event); + + var hashless = window.document.location.href.split("#")[0]; + history.replaceState({}, document.title, hashless); + console.log("[DEBUG] replaced history state", hashless); + Tab._setToFirst(); + } + _initFromTemplate(); +})(); diff --git a/blog.js b/blog.js index ba169ad..378e889 100644 --- a/blog.js +++ b/blog.js @@ -4,114 +4,6 @@ var PostModel = {}; var Blog = {}; var BlogModel = {}; -var Tab = {}; - -var Settings = {}; - -function base64ToBuffer(b64) { - let binstr = atob(b64); - let arr = binstr.split("").map(function (ch) { - return ch.charCodeAt(); - }); - return Uint8Array.from(arr); -} - -function bufferToBase64(buf) { - var binstr = buf - .reduce(function (arr, ch) { - arr.push(String.fromCharCode(ch)); - return arr; - }, []) - .join(""); - return btoa(binstr); -} - -Settings.togglePassphrase = async function (ev) { - let pass = ev.target - .closest("form") - .querySelector(".js-passphrase") - .value.trim(); - if ("[hidden]" != pass) { - ev.target.closest("form").querySelector("button").innerText = "Show"; - ev.target.closest("form").querySelector(".js-passphrase").value = - "[hidden]"; - return; - } - - let b64 = localStorage.getItem("bliss:enc-key") || ""; - let bytes = base64ToBuffer(b64); - pass = await Passphrase.encode(bytes); - - // TODO standardize controller container ma-bob - ev.target.closest("form").querySelector(".js-passphrase").value = pass; - ev.target.closest("form").querySelector("button").innerText = "Hide"; -}; - -Settings.savePassphrase = async function (ev) { - let pass = ev.target - .closest("form") - .querySelector(".js-passphrase") - .value.trim() - .split(/[\s,:-]+/) - .filter(Boolean) - .join(" "); - - let bytes; - try { - bytes = await Passphrase.decode(pass); - } catch (e) { - ev.target.closest("form").querySelector(".js-hint").innerText = e.message; - return; - } - ev.target.closest("form").querySelector(".js-hint").innerText = ""; - - let new64 = bufferToBase64(bytes); - - let current64 = localStorage.getItem("bliss:enc-key"); - if (current64 === new64) { - return; - } - - let isoNow = new Date().toISOString(); - let oldPass = await Passphrase.encode(base64ToBuffer(current64)); - localStorage.setItem(`bliss:enc-key:backup:${isoNow}`, oldPass); - - localStorage.setItem("bliss:enc-key", new64); - ev.target.closest("form").querySelector(".js-hint").innerText = - "Saved New Passphrase!"; -}; - -function _localStorageGetIds(prefix, suffix) { - var i; - var key; - var ids = []; - for (i = 0; i < localStorage.length; i += 1) { - key = localStorage.key(i); - if (prefix && !key.startsWith(prefix)) { - continue; - } - if (suffix && !key.endsWith(suffix)) { - continue; - } - ids.push(key.slice(prefix.length).slice(0, -1 * suffix.length)); - } - return ids; -} - -function _localStorageGetAll(prefix) { - var i; - var key; - var items = []; - for (i = 0; i < localStorage.length; i += 1) { - key = localStorage.key(i); - if (!key.startsWith(prefix)) { - continue; - } - items.push(JSON.parse(localStorage.getItem(key))); - } - return items; -} - (async function () { "use strict"; @@ -122,60 +14,36 @@ function _localStorageGetAll(prefix) { var $$ = window.$$; var localStorage = window.localStorage; - Tab._init = function () { - window.addEventListener("hashchange", Tab._hashChange, false); - if ("" !== location.hash.slice(1)) { - Tab._hashChange(); - return; - } - - Tab._setToFirst(); - }; - - Tab._setToFirst = function () { - let name = $$("[data-ui]")[0].dataset.ui; - location.hash = `#${name}`; - console.log("[DEBUG] set hash to", location.hash); - }; - - Tab._hashChange = function () { - let name = location.hash.slice(1); - if (!name) { - Tab._setToFirst(); - return; - } - if (!$$(`[data-ui="${name}"]`).length) { - console.warn("something else took over the hash routing:", name); - return; - } - - // TODO requestAnimationFrame - - // switch to the visible tab - $$("[data-ui]").forEach(function ($tabBody, i) { - let tabName = $tabBody.dataset.ui; - - if (name !== tabName) { - $tabBody.hidden = true; - return; + function _localStorageGetIds(prefix, suffix) { + var i; + var key; + var ids = []; + for (i = 0; i < localStorage.length; i += 1) { + key = localStorage.key(i); + if (prefix && !key.startsWith(prefix)) { + continue; } + if (suffix && !key.endsWith(suffix)) { + continue; + } + ids.push(key.slice(prefix.length).slice(0, -1 * suffix.length)); + } + return ids; + } - let $tabLink = $(`[data-href="#${name}"]`); - $tabLink.classList.add("active"); - $tabLink.removeAttribute("href"); - $tabBody.hidden = false; - }); - - // switch to the visible tab - $$("a[data-href]").forEach(function ($tabLink) { - let tabName = $tabLink.dataset.href.slice(1); - if (name === tabName) { - return; + function _localStorageGetAll(prefix) { + var i; + var key; + var items = []; + for (i = 0; i < localStorage.length; i += 1) { + key = localStorage.key(i); + if (!key.startsWith(prefix)) { + continue; } - $tabLink.classList.remove("active"); - $tabLink.href = `#${tabName}`; - }); - }; + items.push(JSON.parse(localStorage.getItem(key))); + } + return items; + } Blog.serialize = function (ev) { ev.stopPropagation(); @@ -770,9 +638,7 @@ function _localStorageGetAll(prefix) { */ PostModel.getOrCreate = function (uuid) { // Meta - var post = JSON.parse( - localStorage.getItem("post." + uuid + ".meta") || "{}" - ); + var post = PostModel.get(uuid) || {}; post.uuid = uuid || PostModel._uuid(); if (!post.timezone) { post.timezone = new Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -808,18 +674,39 @@ function _localStorageGetAll(prefix) { return post; }; + /** + * @param {string} uuid + * @returns {BlissPost?} + */ + PostModel.get = function (uuid) { + let json = localStorage.getItem("post." + uuid + ".meta"); + if (!json) { + return null; + } + return JSON.parse(json); + }; + PostModel.ids = function () { return _localStorageGetIds("post.", ".meta"); }; PostModel.save = function (post) { + return PostModel._save(save, ""); + }; + + PostModel.saveVersion = function (post) { + let d = new Date(post.updated || "1970-01-01T00:00:00.000Z"); + return PostModel._save(save, ":version:" + d.toISOString()); + }; + + PostModel._save = function (post, version) { var all = PostModel.ids(); if (!all.includes(post.uuid)) { all.push(post.uuid); } localStorage.setItem( - "post." + post.uuid + ".meta", + "post." + post.uuid + ".meta" + version, JSON.stringify({ title: post.title, description: post.description, @@ -837,7 +724,7 @@ function _localStorageGetAll(prefix) { sync_id: post.sync_id, }) ); - localStorage.setItem("post." + post.uuid + ".data", post.content); + localStorage.setItem("post." + post.uuid + ".data" + version, post.content); }; PostModel.delete = function (uuid) { @@ -1037,47 +924,4 @@ function _localStorageGetAll(prefix) { // TODO XXX XXX PostModel._current = PostModel.getOrCreate(localStorage.getItem("current")); }; - - Tab._init(); - PostModel._init(); - Post._init(); - Blog._init(); - - // deprecated - localStorage.removeItem("all"); - - function _initFromTemplate() { - var pathname = window.document.location.hash.slice(1); - // base url doesn't matter - we're just using this for parsing - var url = new URL("https://ignore.me/" + pathname); - var query = {}; - url.searchParams.forEach(function (val, key) { - query[key] = val || true; // ght = true - }); - if (!query.h && query.ght) { - query.h = "github.com"; - } - if (!query.h || !query.o || !query.r) { - return; - } - - // https://{host}/{owner}/{repo}#{branch} - var repoUrl = "https://" + query.h + "/" + query.o + "/" + query.r; - if (query.b) { - repoUrl += "#" + query.b; - } - - if (query.ght) { - $('select[name="blog"]').value = "eon"; // TODO should be 'hugo' - } - $('input[name="repo"]').value = repoUrl; - var event = new Event("change"); - $('input[name="repo"]').dispatchEvent(event); - - var hashless = window.document.location.href.split("#")[0]; - history.replaceState({}, document.title, hashless); - console.log("[DEBUG] replaced history state", hashless); - Tab._setToFirst(); - } - _initFromTemplate(); })(); diff --git a/encoding.js b/encoding.js new file mode 100644 index 0000000..102bae5 --- /dev/null +++ b/encoding.js @@ -0,0 +1,23 @@ +var Encoding = {}; + +(function () { + "use strict"; + + Encoding.base64ToBuffer = function (b64) { + let binstr = atob(b64); + let arr = binstr.split("").map(function (ch) { + return ch.charCodeAt(); + }); + return Uint8Array.from(arr); + }; + + Encoding.bufferToBase64 = function (buf) { + var binstr = buf + .reduce(function (arr, ch) { + arr.push(String.fromCharCode(ch)); + return arr; + }, []) + .join(""); + return btoa(binstr); + }; +})(); diff --git a/index.html b/index.html index 26f6857..e93d240 100644 --- a/index.html +++ b/index.html @@ -330,6 +330,10 @@

Bliss: Blog, Easy As Gist

+ + + + diff --git a/signin.js b/signin.js index a21890b..519ecf1 100644 --- a/signin.js +++ b/signin.js @@ -183,11 +183,10 @@ var Encraption = {}; window.location.pathname + window.location.search ); // TODO fire hash change event synthetically via the DOM? - console.log("[DEBUG] 2 replaced history state"); Tab._hashChange(); // Show the token for easy capture - console.info("access_token", query.access_token); + //console.debug("access_token", query.access_token); if ("github.com" === query.issuer) { // TODO this is moot. We could set the auth cookie at time of redirect @@ -207,8 +206,8 @@ var Encraption = {}; .catch(die); let result = await resp.json().catch(die); - console.info("Our bespoken token(s):"); - console.info(result); + //console.debug("Our bespoken token(s):"); + //console.debug(result); await doStuffWithUser(result); } @@ -253,8 +252,8 @@ var Encraption = {}; } let result = await attemptRefresh(); - console.info("Refresh Token: (may be empty)"); - console.info(result); + //console.debug("Refresh Token: (may be empty)"); + //console.debug(result); if (result.id_token || result.access_token) { await doStuffWithUser(result); @@ -267,18 +266,23 @@ var Encraption = {}; } async function doStuffWithUser(result) { + const ZERO_DATE = "1970-01-01:T00:00:00.000Z"; + if (!result.id_token && !result.access_token) { window.alert("No token, something went wrong."); return; } $(".js-logout").hidden = false; - let lastSync = new Date( - parseInt(localStorage.getItem("bliss:last-sync"), 10) || 0 + let lastSyncDown = new Date( + parseInt(localStorage.getItem("bliss:last-sync-down"), 10) || 0 + ); + let lastSyncUp = new Date( + parseInt(localStorage.getItem("bliss:last-sync-up"), 10) || 0 ); let resp = await window - .fetch(baseUrl + "/api/user/doc?since=" + lastSync.toISOString(), { + .fetch(baseUrl + "/api/user/doc?since=" + lastSyncDown.toISOString(), { method: "GET", headers: { Authorization: "Bearer " + (result.id_token || result.access_token), @@ -286,8 +290,8 @@ var Encraption = {}; }) .catch(die); let items = await resp.json().catch(die); - console.info("Items:"); - console.info(items); + //console.debug("Items:"); + //console.debug(items); // Use MEGA-style https://site.com/invite#priv ? // hash(priv) => pub @@ -331,40 +335,46 @@ var Encraption = {}; } // TODO decide which key to use (once we have shared projects) - let post; + let syncedPost; try { - post = await Encraption.decrypt64(data.encrypted, key); + syncedPost = await Encraption.decrypt64(data.encrypted, key); } catch (e) { console.warn("Could not decrypt"); console.warn(data); console.warn(e); continue; } - if (post._type && "post" !== post._type) { - console.warn("couldn't handle type", post._type); - console.warn(post); + if (syncedPost._type && "post" !== syncedPost._type) { + console.warn("couldn't handle type", syncedPost._type); + console.warn(syncedPost); continue; } - console.log("[DEBUG]", lastSync, lastSync.valueOf()); - console.log( - "[DEBUG]", - item.updated_at, - new Date(item.updated_at).valueOf() - ); - if (lastSync.valueOf() < new Date(item.updated_at).valueOf()) { - lastSync = new Date(item.updated_at); + if (lastSyncDown.valueOf() < new Date(item.updated_at).valueOf()) { + // updated once in localStorage at the end + lastSyncDown = new Date(item.updated_at); + } + + let localPost = PostModel.get(syncedPost.uuid); + if (localPost) { + // localPost is guaranteed to have a sync_id by all rational logic + let localUpdated = new Date(localPost.updated || ZERO_DATE).valueOf(); + let syncedUpdated = new Date(syncedPost.updated).valueOf(); + if (localUpdated > syncedUpdated) { + PostModel.saveVersion(syncedPost); + console.debug( + "Choosing winner wins strategy: local, more recent post is kept; older, synced post saved as alternate version." + ); + continue; + } } - post.sync_id = item.uuid; - post.synced_at = item.updated_at; - // TODO conflict resolution if this has been updated more recently - console.log("decrypted", post.uuid); - console.log(post); - PostModel.save(post); + // TODO don't resave items that haven't changed? + syncedPost.sync_id = item.uuid; + syncedPost.synced_at = item.updated_at; + PostModel.save(syncedPost); } - console.log("[DEBUG]", lastSync, lastSync.valueOf()); - localStorage.setItem("bliss:last-sync", lastSync.valueOf()); + localStorage.setItem("bliss:last-sync-down", lastSyncDown.valueOf()); // TODO handle offline case: if new things have not been synced, sync them @@ -382,7 +392,8 @@ var Encraption = {}; }), }), }); - return resp.json(); + let body = await resp.json(); + return body; } async function docUpdate(post) { let token = result.id_token || result.access_token; @@ -398,28 +409,56 @@ var Encraption = {}; }), }), }); - return resp.json(); + let body = await resp.json(); + return body; } Post._syncHook = async function (post) { + // Note: syncHook MUST be called on posts in ascending `updated_at` order + // (otherwise drafts will be older than `lastSyncUp` and be skipped / lost) post._type = "post"; + + let resp; + if (!post.sync_id) { + resp = await docCreate(post); + } else if ( + new Date(post.updated || ZERO_DATE).valueOf() > lastSyncUp.valueOf() + ) { + resp = await docUpdate(post); + } else { + return; + } + + if (!resp.uuid || !new Date(resp.updated_at).valueOf()) { + // TODO + throw new Error("Sync was not successful"); + } + + // Don't do this. Keep local `updated` for local sync logic + //post.updated = resp.updated_at.toISOString(); if (!post.sync_id) { - let resp = await docCreate(post); post.sync_id = resp.uuid; - return post; + PostModel.save(post); } - // TODO update last updated at? - await docUpdate(post); - return post; + lastSyncUp = new Date(post.updated); + localStorage.setItem("bliss:last-sync-up", lastSyncUp.valueOf()); }; // update all existing posts - await PostModel.ids().reduce(async function (p, id) { - await p; - let post = PostModel.getOrCreate(id); - post = await Post._syncHook(post).catch(die); - PostModel.save(post); - }, Promise.resolve()); + await PostModel.ids() + .map(function (id) { + // get posts rather than ids + return PostModel.getOrCreate(id); + }) + .sort(function (a, b) { + let aDate = new Date(a.updated || ZERO_DATE).valueOf(); + let bDate = new Date(b.updated || ZERO_DATE).valueOf(); + return aDate - bDate; + }) + .reduce(async function (p, post) { + await p; + await Post._syncHook(post).catch(die); + }, Promise.resolve()); } init().catch(function (err) { diff --git a/tabs.js b/tabs.js new file mode 100644 index 0000000..8420889 --- /dev/null +++ b/tabs.js @@ -0,0 +1,60 @@ +var Tab = {}; + +(function () { + "use strict"; + + Tab._init = function () { + window.addEventListener("hashchange", Tab._hashChange, false); + if ("" !== location.hash.slice(1)) { + Tab._hashChange(); + return; + } + + Tab._setToFirst(); + }; + + Tab._setToFirst = function () { + let name = $$("[data-ui]")[0].dataset.ui; + location.hash = `#${name}`; + console.log("[DEBUG] set hash to", location.hash); + }; + + Tab._hashChange = function () { + let name = location.hash.slice(1); + if (!name) { + Tab._setToFirst(); + return; + } + if (!$$(`[data-ui="${name}"]`).length) { + console.warn("something else took over the hash routing:", name); + return; + } + + // TODO requestAnimationFrame + + // switch to the visible tab + $$("[data-ui]").forEach(function ($tabBody, i) { + let tabName = $tabBody.dataset.ui; + + if (name !== tabName) { + $tabBody.hidden = true; + return; + } + + let $tabLink = $(`[data-href="#${name}"]`); + $tabLink.classList.add("active"); + $tabLink.removeAttribute("href"); + $tabBody.hidden = false; + }); + + // switch to the visible tab + $$("a[data-href]").forEach(function ($tabLink) { + let tabName = $tabLink.dataset.href.slice(1); + if (name === tabName) { + return; + } + $tabLink.classList.remove("active"); + $tabLink.href = `#${tabName}`; + }); + }; +})(); From 5f281dcc55bda8e83c8af303503bfcd340e65243 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 26 Sep 2021 06:59:10 +0000 Subject: [PATCH 07/25] WIP: cleanup for blog.js, mostly complete sync --- ajquery.js | 9 + app.js | 21 ++- blog.js | 90 +++++++--- encoding.js | 1 + encraption.js | 105 +++++++++++ index.html | 6 +- signin.js | 468 -------------------------------------------------- sync.js | 427 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 623 insertions(+), 504 deletions(-) create mode 100644 ajquery.js create mode 100644 encraption.js delete mode 100644 signin.js create mode 100644 sync.js diff --git a/ajquery.js b/ajquery.js new file mode 100644 index 0000000..b6c903e --- /dev/null +++ b/ajquery.js @@ -0,0 +1,9 @@ +/*jshint ignore:start*/ +function $(sel, el) { + return (el || document).querySelector(sel); +} + +function $$(sel, el) { + return (el || document).querySelectorAll(sel); +} +/*jshint ignore:end*/ diff --git a/app.js b/app.js index 14723e7..1e27581 100644 --- a/app.js +++ b/app.js @@ -3,12 +3,15 @@ var Settings = {}; (async function () { "use strict"; + let Passphrase = window.Passphrase; + let Encoding = window.Encoding; + Settings.togglePassphrase = async function (ev) { let pass = ev.target .closest("form") .querySelector(".js-passphrase") .value.trim(); - if ("[hidden]" != pass) { + if ("[hidden]" !== pass) { ev.target.closest("form").querySelector("button").innerText = "Show"; ev.target.closest("form").querySelector(".js-passphrase").value = "[hidden]"; @@ -25,7 +28,7 @@ var Settings = {}; }; Settings.savePassphrase = async function (ev) { - let pass = ev.target + let newPass = ev.target .closest("form") .querySelector(".js-passphrase") .value.trim() @@ -35,7 +38,7 @@ var Settings = {}; let bytes; try { - bytes = await Passphrase.decode(pass); + bytes = await Passphrase.decode(newPass); } catch (e) { ev.target.closest("form").querySelector(".js-hint").innerText = e.message; return; @@ -44,16 +47,14 @@ var Settings = {}; let new64 = Encoding.bufferToBase64(bytes); + let isoNow = new Date().toISOString(); let current64 = localStorage.getItem("bliss:enc-key"); if (current64 === new64) { return; } - let isoNow = new Date().toISOString(); - let oldPass = await Passphrase.encode(base64ToBuffer(current64)); - localStorage.setItem(`bliss:enc-key:backup:${isoNow}`, oldPass); - localStorage.setItem("bliss:enc-key", new64); + localStorage.setItem(`bliss:enc-key:backup:${isoNow}`, newPass); ev.target.closest("form").querySelector(".js-hint").innerText = "Saved New Passphrase!"; }; @@ -62,6 +63,12 @@ var Settings = {}; (async function () { "use strict"; + let $ = window.$; + let Tab = window.Tab; + let PostModel = window.PostModel; + let Post = window.Post; + let Blog = window.Blog; + Tab._init(); PostModel._init(); Post._init(); diff --git a/blog.js b/blog.js index 378e889..65de09e 100644 --- a/blog.js +++ b/blog.js @@ -11,7 +11,7 @@ var BlogModel = {}; // (just so everybody knows what I expect to use in here) var XTZ = window.XTZ; var $ = window.$; - var $$ = window.$$; + //var $$ = window.$$; var localStorage = window.localStorage; function _localStorageGetIds(prefix, suffix) { @@ -57,7 +57,7 @@ var BlogModel = {}; var dirty = false; try { - new URL(repo); + new URL(repo); // jshint ignore:line } catch (e) { // ignore // dirty, don't save @@ -103,6 +103,7 @@ var BlogModel = {}; * Post is the View * */ + // Hit the New Draft button Post.create = function (ev) { ev.preventDefault(); ev.stopPropagation(); @@ -111,16 +112,17 @@ var BlogModel = {}; Post._renderRows(); }; + // Hit the save button (actually every key is the save button) Post.serialize = function (ev) { ev.preventDefault(); ev.stopPropagation(); Post._update(PostModel._current); }; + + // From form inputs to Model Post._serialize = function (post) { // TODO debounce with max time - var timezone = new Intl.DateTimeFormat().resolvedOptions().timeZone; - post.timezone = post.timezone || timezone; // TODO refactor post._gitbranch = $('input[name="gitbranch"]').value || "main"; @@ -134,9 +136,9 @@ var BlogModel = {}; timezone ).toISOString(); */ - post.updated = XTZ.toTimeZone(new Date(), post.timezone).toISOString(); var text = $('textarea[name="content"]').value.trim(); + var inputDescription = $('textarea[name="description"]').value; post.title = PostModel._parseTitle(text); // skip the first line of text (which was the title) @@ -154,10 +156,8 @@ var BlogModel = {}; .slice(1) .join("\n") .replace(/^[\n\r]+/, ""); - $('textarea[name="description"]').value = post.description; } else { - // old way - var inputDescription = $('textarea[name="description"]').value; + // old way (TODO remove) if (inputDescription && post.description) { if (!post._dirtyDescription) { post._dirtyDescription = post.description !== inputDescription; @@ -171,6 +171,10 @@ var BlogModel = {}; post.description = inputDescription; } } + $('textarea[name="description"]').value = post.description; + + post = PostModel.normalize(post); + post.updated = XTZ.toTimeZone(new Date(), post.timezone).toISOString(); PostModel.save(post); }; @@ -202,7 +206,7 @@ var BlogModel = {}; Post._rawPreview(post); }; - // From DB to form inputs + // From Model to form inputs Post.deserialize = function (ev) { ev.preventDefault(); ev.stopPropagation(); @@ -632,23 +636,48 @@ var BlogModel = {}; return PostModel._current; }; + PostModel.normalize = function (post) { + if (!post.uuid) { + post.uuid = PostModel._uuid(); + } + if (!post.title) { + // ignore + } + if (!post.description) { + // ignore + } + if (!post.content) { + // ignore + } + + if (!post.slug) { + post.slug = PostModel._toSlug(post.title); + } + + if (!post.timezone) { + post.timezone = new Intl.DateTimeFormat().resolvedOptions().timeZone; + } + + let isoNow; + if (!post.created || !post.updated) { + isoNow = XTZ.toTimeZone(new Date(), post.timezone).toISOString(); + if (!post.created) { + post.created = post.updated || isoNow; + } + if (!post.updated) { + post.updated = isoNow; + } + } + return post; + }; + /** * @param {string} uuid * @returns {BlissPost} */ PostModel.getOrCreate = function (uuid) { - // Meta var post = PostModel.get(uuid) || {}; - post.uuid = uuid || PostModel._uuid(); - if (!post.timezone) { - post.timezone = new Intl.DateTimeFormat().resolvedOptions().timeZone; - } - if (!post.created) { - post.created = XTZ.toTimeZone(new Date(), post.timezone).toISOString(); - } - if (!post.updated) { - post.updated = post.created; - } + post.uuid = uuid; // Content post.content = localStorage.getItem("post." + post.uuid + ".data") || ""; @@ -658,6 +687,10 @@ var BlogModel = {}; if (!post.title) { post.title = localStorage.getItem(post.uuid + ".title") || ""; } + + // Meta / Normalize + post = PostModel.normalize(post); + // TODO is there a better way to handle this? post._previous = { title: post.title }; @@ -691,23 +724,22 @@ var BlogModel = {}; }; PostModel.save = function (post) { - return PostModel._save(save, ""); + // TODO how to not be leaky about PostModel / SyncModel + //return PostModel._save(post, "", xattrs = ['sync_id']); + return PostModel._save(post, ""); }; PostModel.saveVersion = function (post) { let d = new Date(post.updated || "1970-01-01T00:00:00.000Z"); - return PostModel._save(save, ":version:" + d.toISOString()); + return PostModel._save(post, ":version:" + d.toISOString()); }; PostModel._save = function (post, version) { - var all = PostModel.ids(); - if (!all.includes(post.uuid)) { - all.push(post.uuid); - } - localStorage.setItem( "post." + post.uuid + ".meta" + version, JSON.stringify({ + // TODO draft: true|false, + // TODO unlisted: true|false, title: post.title, description: post.description, uuid: post.uuid, @@ -715,16 +747,20 @@ var BlogModel = {}; created: post.created, updated: post.updated, timezone: post.timezone, + // TODO iterate over localStorage to upgrade blog_id: post._repo + "#" + post._gitbranch, _blog: post._blog, _githost: post._githost, _gitbranch: post._gitbranch, _repo: post._repo, + + // for syncing sync_id: post.sync_id, }) ); localStorage.setItem("post." + post.uuid + ".data" + version, post.content); + return post; }; PostModel.delete = function (uuid) { diff --git a/encoding.js b/encoding.js index 102bae5..8fdacf9 100644 --- a/encoding.js +++ b/encoding.js @@ -3,6 +3,7 @@ var Encoding = {}; (function () { "use strict"; + // TODO update unibabel.js Encoding.base64ToBuffer = function (b64) { let binstr = atob(b64); let arr = binstr.split("").map(function (ch) { diff --git a/encraption.js b/encraption.js new file mode 100644 index 0000000..5d68d7c --- /dev/null +++ b/encraption.js @@ -0,0 +1,105 @@ +var Encraption = {}; + +(function () { + "use strict"; + + let Encoding = window.Encoding; + + Encraption.importKey = async function importKey(key64) { + let crypto = window.crypto; + let usages = ["encrypt", "decrypt"]; + let extractable = false; + let rawKey = Encoding.base64ToBuffer(key64); + + return await crypto.subtle.importKey( + "raw", + rawKey, + { name: "AES-CBC" }, + extractable, + usages + ); + }; + + Encraption.encryptObj = async function encryptObj(obj, key) { + var crypto = window.crypto; + var ivLen = 16; // the IV is always 16 bytes + + function joinIvAndData(iv, data) { + var buf = new Uint8Array(iv.length + data.length); + Array.prototype.forEach.call(iv, function (byte, i) { + buf[i] = byte; + }); + Array.prototype.forEach.call(data, function (byte, i) { + buf[ivLen + i] = byte; + }); + return buf; + } + + async function _encrypt(data, key) { + // a public value that should be generated for changes each time + var initializationVector = new Uint8Array(ivLen); + + crypto.getRandomValues(initializationVector); + + return await crypto.subtle + .encrypt({ name: "AES-CBC", iv: initializationVector }, key, data) + .then(function (encrypted) { + var ciphered = joinIvAndData( + initializationVector, + new Uint8Array(encrypted) + ); + + var base64 = Encoding.bufferToBase64(ciphered); + /* + .replace(/\-/g, "+") + .replace(/_/g, "/"); + while (base64.length % 4) { + base64 += "="; + } + */ + return base64; + }); + } + //return _encrypt(Encoding.base64ToBuffer(b64), key); + let u8 = new TextEncoder().encode(JSON.stringify(obj)); + return await _encrypt(u8, key); + }; + + Encraption.decrypt64 = async function decrypt64(b64, key) { + var crypto = window.crypto; + var ivLen = 16; // the IV is always 16 bytes + + function separateIvFromData(buf) { + var iv = new Uint8Array(ivLen); + var data = new Uint8Array(buf.length - ivLen); + Array.prototype.forEach.call(buf, function (byte, i) { + if (i < ivLen) { + iv[i] = byte; + } else { + data[i - ivLen] = byte; + } + }); + return { iv: iv, data: data }; + } + + function _decrypt(buf, key) { + var parts = separateIvFromData(buf); + + return crypto.subtle + .decrypt({ name: "AES-CBC", iv: parts.iv }, key, parts.data) + .then(function (decrypted) { + var str = new TextDecoder().decode(new Uint8Array(decrypted)); + //var base64 = Encoding.bufferToBase64(new Uint8Array(decrypted)); + /* + .replace(/\-/g, "+") + .replace(/_/g, "/"); + while (base64.length % 4) { + base64 += "="; + } + */ + return JSON.parse(str); + }); + } + return _decrypt(Encoding.base64ToBuffer(b64), key); + }; +})(); diff --git a/index.html b/index.html index e93d240..61da926 100644 --- a/index.html +++ b/index.html @@ -330,13 +330,15 @@

Bliss: Blog, Easy As Gist

+ - + - + + diff --git a/signin.js b/signin.js deleted file mode 100644 index 519ecf1..0000000 --- a/signin.js +++ /dev/null @@ -1,468 +0,0 @@ -function $(sel, el) { - return (el || document).querySelector(sel); -} - -function $$(sel, el) { - return (el || document).querySelectorAll(sel); -} - -var Encraption = {}; - -(async function () { - "use strict"; - - // from env.js - let ENV = window.ENV; - - // scheme => 'https:' - // host => 'localhost:3000' - // pathname => '/api/authn/session/oidc/google.com' - //let baseUrl = document.location.protocol + "//" + document.location.host; - let baseUrl = ENV.BASE_API_URL; - - function noop() {} - - function die(err) { - console.error(err); - window.alert( - "Oops! There was an unexpected error on the server.\nIt's not your fault.\n\n" + - "Technical Details for Tech Support: \n" + - err.message - ); - throw err; - } - - async function attemptRefresh() { - let resp = await window - .fetch(baseUrl + "/api/authn/refresh", { method: "POST" }) - .catch(noop); - if (!resp) { - return; - } - return await resp.json().catch(die); - } - - Encraption.importKey = async function importKey(key64) { - let crypto = window.crypto; - let usages = ["encrypt", "decrypt"]; - let extractable = false; - let rawKey = base64ToBuffer(key64); - - return await crypto.subtle.importKey( - "raw", - rawKey, - { name: "AES-CBC" }, - extractable, - usages - ); - }; - - function base64ToBuffer(base64) { - function binaryStringToBuffer(binstr) { - var buf; - - if ("undefined" !== typeof Uint8Array) { - buf = new Uint8Array(binstr.length); - } else { - buf = []; - } - - Array.prototype.forEach.call(binstr, function (ch, i) { - buf[i] = ch.charCodeAt(0); - }); - - return buf; - } - var binstr = atob(base64); - var buf = binaryStringToBuffer(binstr); - return buf; - } - - function bufferToBase64(arr) { - function bufferToBinaryString(buf) { - var binstr = Array.prototype.map - .call(buf, function (ch) { - return String.fromCharCode(ch); - }) - .join(""); - - return binstr; - } - var binstr = bufferToBinaryString(arr); - return btoa(binstr); - } - - Encraption.encryptObj = async function encryptObj(obj, key) { - var crypto = window.crypto; - var ivLen = 16; // the IV is always 16 bytes - - function joinIvAndData(iv, data) { - var buf = new Uint8Array(iv.length + data.length); - Array.prototype.forEach.call(iv, function (byte, i) { - buf[i] = byte; - }); - Array.prototype.forEach.call(data, function (byte, i) { - buf[ivLen + i] = byte; - }); - return buf; - } - - async function _encrypt(data, key) { - // a public value that should be generated for changes each time - var initializationVector = new Uint8Array(ivLen); - - crypto.getRandomValues(initializationVector); - - return await crypto.subtle - .encrypt({ name: "AES-CBC", iv: initializationVector }, key, data) - .then(function (encrypted) { - var ciphered = joinIvAndData( - initializationVector, - new Uint8Array(encrypted) - ); - - var base64 = bufferToBase64(ciphered); - /* - .replace(/\-/g, "+") - .replace(/_/g, "/"); - while (base64.length % 4) { - base64 += "="; - } - */ - return base64; - }); - } - //return _encrypt(base64ToBuffer(b64), key); - let u8 = new TextEncoder().encode(JSON.stringify(obj)); - return await _encrypt(u8, key); - }; - - Encraption.decrypt64 = async function decrypt64(b64, key) { - var crypto = window.crypto; - var ivLen = 16; // the IV is always 16 bytes - - function separateIvFromData(buf) { - var iv = new Uint8Array(ivLen); - var data = new Uint8Array(buf.length - ivLen); - Array.prototype.forEach.call(buf, function (byte, i) { - if (i < ivLen) { - iv[i] = byte; - } else { - data[i - ivLen] = byte; - } - }); - return { iv: iv, data: data }; - } - - function _decrypt(buf, key) { - var parts = separateIvFromData(buf); - - return crypto.subtle - .decrypt({ name: "AES-CBC", iv: parts.iv }, key, parts.data) - .then(function (decrypted) { - var str = new TextDecoder().decode(new Uint8Array(decrypted)); - //var base64 = bufferToBase64(new Uint8Array(decrypted)); - /* - .replace(/\-/g, "+") - .replace(/_/g, "/"); - while (base64.length % 4) { - base64 += "="; - } - */ - return JSON.parse(str); - }); - } - return _decrypt(base64ToBuffer(b64), key); - }; - - async function completeOauth2SignIn(baseUrl, query) { - // nix token from browser history - window.history.pushState( - "", - document.title, - window.location.pathname + window.location.search - ); - // TODO fire hash change event synthetically via the DOM? - Tab._hashChange(); - - // Show the token for easy capture - //console.debug("access_token", query.access_token); - - if ("github.com" === query.issuer) { - // TODO this is moot. We could set the auth cookie at time of redirect - // and include the real (our) id_token - let resp = await window - .fetch(baseUrl + "/api/authn/session/oauth2/github.com", { - method: "POST", - body: JSON.stringify({ - timezone: new Intl.DateTimeFormat().resolvedOptions().timeZone, - language: window.navigator.language, - }), - headers: { - Authorization: query.access_token, - "Content-Type": "application/json", - }, - }) - .catch(die); - let result = await resp.json().catch(die); - - //console.debug("Our bespoken token(s):"); - //console.debug(result); - - await doStuffWithUser(result); - } - // TODO what if it's not github? - } - - async function init() { - $(".js-logout").hidden = true; - $(".js-sign-in-github").hidden = true; - - var githubSignInUrl = Auth3000.generateOauth2Url( - "https://github.com/login/oauth/authorize", - ENV.GITHUB_CLIENT_ID, - ENV.GITHUB_REDIRECT_URI, - ["read:user", "user:email"] - ); - $(".js-github-oauth2-url").href = githubSignInUrl; - - $(".js-logout").addEventListener("click", async function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - - let resp = await window - .fetch(baseUrl + "/api/authn/session", { - method: "DELETE", - }) - .catch(die); - let result = await resp.json().catch(die); - window.alert("Logged out!"); - init(); - }); - - var querystring = document.location.hash.slice(1); - var query = Auth3000.parseQuerystring(querystring); - if (query.id_token) { - completeOidcSignIn(query); - return; - } - if (query.access_token && "bearer" === query.token_type) { - completeOauth2SignIn(baseUrl, query); - return; - } - - let result = await attemptRefresh(); - //console.debug("Refresh Token: (may be empty)"); - //console.debug(result); - - if (result.id_token || result.access_token) { - await doStuffWithUser(result); - return; - } - - $(".js-sign-in-github").hidden = false; - //$(".js-social-login").hidden = false; - return; - } - - async function doStuffWithUser(result) { - const ZERO_DATE = "1970-01-01:T00:00:00.000Z"; - - if (!result.id_token && !result.access_token) { - window.alert("No token, something went wrong."); - return; - } - $(".js-logout").hidden = false; - - let lastSyncDown = new Date( - parseInt(localStorage.getItem("bliss:last-sync-down"), 10) || 0 - ); - let lastSyncUp = new Date( - parseInt(localStorage.getItem("bliss:last-sync-up"), 10) || 0 - ); - - let resp = await window - .fetch(baseUrl + "/api/user/doc?since=" + lastSyncDown.toISOString(), { - method: "GET", - headers: { - Authorization: "Bearer " + (result.id_token || result.access_token), - }, - }) - .catch(die); - let items = await resp.json().catch(die); - //console.debug("Items:"); - //console.debug(items); - - // Use MEGA-style https://site.com/invite#priv ? - // hash(priv) => pub - let key64 = localStorage.getItem("bliss:enc-key"); - let key = await Encraption.importKey(key64).catch(showError); - - function showError(err) { - console.error(err); - window.alert("that's not a valid key"); - } - if (!key) { - if (!items.length) { - let rawKeyBuf = crypto.getRandomValues(new Uint8Array(16)); - key64 = bufferToBase64(rawKeyBuf); - localStorage.setItem("bliss:enc-key", key64); - } - while (items.length && !key64) { - key64 = window.prompt("What's your encryption key?", ""); - key = await Encraption.importKey(key64).catch(showError); - // TODO try to decrypt - } - } - - let pushes = []; - let receives = []; - /* - let remoteIds = PostModel.ids().reduce(function (map, id) { - let post = PostModel.getOrCreate(id); - map[post.sync_id] = true; - }, {}); - */ - - for (let item of items) { - let data; - try { - // because this is double stringified (for now) - data = JSON.parse(item.data); - } catch (e) { - console.warn("Could not parse:", err); - console.warn(item.data); - } - - // TODO decide which key to use (once we have shared projects) - let syncedPost; - try { - syncedPost = await Encraption.decrypt64(data.encrypted, key); - } catch (e) { - console.warn("Could not decrypt"); - console.warn(data); - console.warn(e); - continue; - } - if (syncedPost._type && "post" !== syncedPost._type) { - console.warn("couldn't handle type", syncedPost._type); - console.warn(syncedPost); - continue; - } - - if (lastSyncDown.valueOf() < new Date(item.updated_at).valueOf()) { - // updated once in localStorage at the end - lastSyncDown = new Date(item.updated_at); - } - - let localPost = PostModel.get(syncedPost.uuid); - if (localPost) { - // localPost is guaranteed to have a sync_id by all rational logic - let localUpdated = new Date(localPost.updated || ZERO_DATE).valueOf(); - let syncedUpdated = new Date(syncedPost.updated).valueOf(); - if (localUpdated > syncedUpdated) { - PostModel.saveVersion(syncedPost); - console.debug( - "Choosing winner wins strategy: local, more recent post is kept; older, synced post saved as alternate version." - ); - continue; - } - } - - // TODO don't resave items that haven't changed? - syncedPost.sync_id = item.uuid; - syncedPost.synced_at = item.updated_at; - PostModel.save(syncedPost); - } - localStorage.setItem("bliss:last-sync-down", lastSyncDown.valueOf()); - - // TODO handle offline case: if new things have not been synced, sync them - - async function docCreate() { - let token = result.id_token || result.access_token; - let resp = await window.fetch(baseUrl + "/api/user/doc", { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - data: JSON.stringify({ - encrypted: await Encraption.encryptObj(post, key), - }), - }), - }); - let body = await resp.json(); - return body; - } - async function docUpdate(post) { - let token = result.id_token || result.access_token; - let resp = await window.fetch(`${baseUrl}/api/user/doc/${post.sync_id}`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - data: JSON.stringify({ - encrypted: await Encraption.encryptObj(post, key), - }), - }), - }); - let body = await resp.json(); - return body; - } - Post._syncHook = async function (post) { - // Note: syncHook MUST be called on posts in ascending `updated_at` order - // (otherwise drafts will be older than `lastSyncUp` and be skipped / lost) - post._type = "post"; - - let resp; - if (!post.sync_id) { - resp = await docCreate(post); - } else if ( - new Date(post.updated || ZERO_DATE).valueOf() > lastSyncUp.valueOf() - ) { - resp = await docUpdate(post); - } else { - return; - } - - if (!resp.uuid || !new Date(resp.updated_at).valueOf()) { - // TODO - throw new Error("Sync was not successful"); - } - - // Don't do this. Keep local `updated` for local sync logic - //post.updated = resp.updated_at.toISOString(); - if (!post.sync_id) { - post.sync_id = resp.uuid; - PostModel.save(post); - } - - lastSyncUp = new Date(post.updated); - localStorage.setItem("bliss:last-sync-up", lastSyncUp.valueOf()); - }; - - // update all existing posts - await PostModel.ids() - .map(function (id) { - // get posts rather than ids - return PostModel.getOrCreate(id); - }) - .sort(function (a, b) { - let aDate = new Date(a.updated || ZERO_DATE).valueOf(); - let bDate = new Date(b.updated || ZERO_DATE).valueOf(); - return aDate - bDate; - }) - .reduce(async function (p, post) { - await p; - await Post._syncHook(post).catch(die); - }, Promise.resolve()); - } - - init().catch(function (err) { - console.error(err); - window.alert(`Fatal Error: ${err.message}`); - }); -})(); diff --git a/sync.js b/sync.js new file mode 100644 index 0000000..27bbbf6 --- /dev/null +++ b/sync.js @@ -0,0 +1,427 @@ +(async function () { + "use strict"; + + // from env.js + let ENV = window.ENV; + + // scheme => 'https:' + // host => 'localhost:3000' + // pathname => '/api/authn/session/oidc/google.com' + //let baseUrl = document.location.protocol + "//" + document.location.host; + let baseUrl = ENV.BASE_API_URL; + + let $ = window.$; + let Encoding = window.Encoding; + let Encraption = window.Encraption; + + function noop() {} + + function die(err) { + console.error(err); + window.alert( + "Oops! There was an unexpected error on the server.\nIt's not your fault.\n\n" + + "Technical Details for Tech Support: \n" + + err.message + ); + throw err; + } + + async function attemptRefresh() { + let resp = await window + .fetch(baseUrl + "/api/authn/refresh", { method: "POST" }) + .catch(noop); + if (!resp) { + return; + } + return await resp.json().catch(die); + } + + async function completeOauth2SignIn(baseUrl, query) { + let Tab = window.Tab; + + // nix token from browser history + window.history.pushState( + "", + document.title, + window.location.pathname + window.location.search + ); + // TODO fire hash change event synthetically via the DOM? + Tab._hashChange(); + + // Show the token for easy capture + //console.debug("access_token", query.access_token); + + if ("github.com" === query.issuer) { + // TODO this is moot. We could set the auth cookie at time of redirect + // and include the real (our) id_token + let resp = await window + .fetch(baseUrl + "/api/authn/session/oauth2/github.com", { + method: "POST", + body: JSON.stringify({ + timezone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + language: window.navigator.language, + }), + headers: { + Authorization: query.access_token, + "Content-Type": "application/json", + }, + }) + .catch(die); + let result = await resp.json().catch(die); + + //console.debug("Our bespoken token(s):"); + //console.debug(result); + + await doStuffWithUser(result).catch(die); + } + // TODO what if it's not github? + } + + async function init() { + let Auth3000 = window.Auth3000; + $(".js-logout").hidden = true; + $(".js-sign-in-github").hidden = true; + + var githubSignInUrl = Auth3000.generateOauth2Url( + "https://github.com/login/oauth/authorize", + ENV.GITHUB_CLIENT_ID, + ENV.GITHUB_REDIRECT_URI, + ["read:user", "user:email"] + ); + $(".js-github-oauth2-url").href = githubSignInUrl; + + $(".js-logout").addEventListener("click", async function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + + let resp = await window + .fetch(baseUrl + "/api/authn/session", { + method: "DELETE", + }) + .catch(die); + let result = await resp.json().catch(die); + console.log(result); + window.alert("Logged out!"); + init(); + }); + + var querystring = document.location.hash.slice(1); + var query = Auth3000.parseQuerystring(querystring); + if (query.id_token) { + //completeOidcSignIn(query); + console.log("Google Sign In not implemented"); + return; + } + if (query.access_token && "bearer" === query.token_type) { + completeOauth2SignIn(baseUrl, query); + return; + } + + let result = await attemptRefresh(); + //console.debug("Refresh Token: (may be empty)"); + //console.debug(result); + + if (result.id_token || result.access_token) { + await doStuffWithUser(result).catch(die); + return; + } + + $(".js-sign-in-github").hidden = false; + //$(".js-social-login").hidden = false; + return; + } + + async function doStuffWithUser(result) { + if (!result.id_token && !result.access_token) { + window.alert("No token, something went wrong."); + return; + } + $(".js-logout").hidden = false; + let token = result.id_token || result.access_token; + await syncUserContent(token); + } + + // + // Sync Code Stuff + // + + let crypto = window.crypto; + let Passphrase = window.Passphrase; + let PostModel = window.PostModel; + let Post = window.Post; + + async function decryptPost(key, item) { + let data; + try { + // because this is double stringified (for now) + data = JSON.parse(item.data); + } catch (e) { + e.data = item.data; + throw e; + } + + // TODO decide which key to use (once we have shared projects) + let syncedPost; + try { + syncedPost = await Encraption.decrypt64(data.encrypted, key); + } catch (e) { + e.data = data; + throw e; + } + return syncedPost; + } + + async function docCreate(token, key, post) { + let resp = await window.fetch(baseUrl + "/api/user/doc", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + data: JSON.stringify({ + encrypted: await Encraption.encryptObj(post, key), + }), + }), + }); + let body = await resp.json(); + return body; + } + + async function docUpdate(token, key, post) { + let resp = await window.fetch(`${baseUrl}/api/user/doc/${post.sync_id}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + data: JSON.stringify({ + encrypted: await Encraption.encryptObj(post, key), + }), + }), + }); + let body = await resp.json(); + return body; + } + + async function _syncPost(token, key, post, lastSyncUp2) { + // Note: syncHook MUST be called on posts in ascending `updated_at` order + // (otherwise drafts will be older than `lastSyncUp2` and be skipped / lost) + post._type = "post"; + + let resp; + if (!post.sync_id) { + resp = await docCreate(token, key, post); + } else if ( + (new Date(post.updated).valueOf() || 0) > lastSyncUp2.valueOf() + ) { + resp = await docUpdate(token, key, post); + } else { + return lastSyncUp2; + } + + if (!resp.uuid || !new Date(resp.updated_at).valueOf()) { + // TODO + throw new Error("Sync was not successful"); + } + + // Don't do this. Keep local `updated` for local sync logic + //post.updated = resp.updated_at.toISOString(); + if (!post.sync_id) { + post.sync_id = resp.uuid; + PostModel.save(post); + } + + // double parse to ensure a valid date + let _lastSyncUp2 = new Date(new Date(post.updated).valueOf() || 0); + if (_lastSyncUp2.valueOf() > lastSyncUp2.valueOf()) { + lastSyncUp2 = _lastSyncUp2; + } + return lastSyncUp2; + } + + function showError(err) { + console.error(err); + window.alert("that's not a valid key"); + } + + async function syncUserContent(token, _cannotReDo) { + let howToSyncTemplate = { + title: "🎉🔥🚀 NEW! How to Sync Drafts", + description: "Congrats! Now you can sync drafts between computers!", + content: `Bliss uses secure local encryption for syncing drafts. + +(you're probably not a weirdo, but if you are, we don't even want to be able to find out 😉) + +Your browser has generated a secure, random, 128-bit encryption passphrase which you must use in order to sync drafts between computers. + +To enable 🔁 Sync on another computer: +1. Sign in to Bliss on the other computer. +2. You will be prompted for your encryption passphrase. +3. Copy and paste your passphrase from Settings + +Enjoy! 🥳`, + }; + + // Use MEGA-style https://site.com/invite#priv ? + // hash(priv) => pub + let key64 = localStorage.getItem("bliss:enc-key"); + let key; + let key2048; + let keyBytes; + if (key64) { + keyBytes = Encoding.base64ToBuffer(key64); + key2048 = await Passphrase.encode(keyBytes); + key = await Encraption.importKey(key64).catch(showError); + } + + // double parsing date to guarantee a valid date or the zero date + let lastSyncDown = new Date( + new Date(localStorage.getItem("bliss:last-sync-down")).valueOf() || 0 + ); + + let lastSyncUp = new Date( + new Date(localStorage.getItem("bliss:last-sync-up")).valueOf() || 0 + ); + + PostModel._syncHook = async function __syncHook(post) { + if (!post.title) { + // don't sync "Untitled" posts + // TODO don't save empty posts at all + return; + } + lastSyncUp = await _syncPost(token, key, post, lastSyncUp); + localStorage.setItem("bliss:last-sync-up", lastSyncUp.toISOString()); + }; + + let resp = await window + .fetch(baseUrl + "/api/user/doc?since=" + lastSyncDown.toISOString(), { + method: "GET", + headers: { + Authorization: "Bearer " + token, + }, + }) + .catch(die); + let items = await resp.json().catch(die); + //console.debug("Items:"); + //console.debug(items); + + // We should always have at least the "How to Sync Drafts" post + if (0 === lastSyncDown.valueOf() && !items.length) { + // this is a new account on its first computer + // gen 128-bit key + if (!key) { + keyBytes = crypto.getRandomValues(new Uint8Array(16)); + key64 = Encoding.bufferToBase64(keyBytes); + key2048 = await Passphrase.encode(keyBytes); + key = await Encraption.importKey(key64); + localStorage.setItem("bliss:enc-key", key64); + } + + await PostModel._syncHook(PostModel.normalize(howToSyncTemplate)); + // shouldn't be possible to be called thrice but... + // just in case... + if (!_cannotReDo) { + return syncUserContent(token, true); + } + } + + if (!key) { + // while (true) is for ninnies + for (;;) { + let key2048 = window.prompt( + "What's your encryption passphrase? (check in Settings on the system that you first used Bliss)", + "" + ); + let keyBytes = await Passphrase.decode(key2048.trim()).catch(showError); + if (!keyBytes) { + continue; + } + key64 = Encoding.bufferToBase64(keyBytes); // can't fail + key = await Encraption.importKey(key64).catch(showError); + if (!key) { + continue; + } + + // TODO try to decrypt something to ensure correctness + localStorage.setItem("bliss:enc-key", key64); + break; + } + } + + /* + let remoteIds = PostModel.ids().reduce(function (map, id) { + let post = PostModel.getOrCreate(id); + map[post.sync_id] = true; + }, {}); + */ + + // poor man's forEachAsync + await items.reduce(async function (promise, item) { + await promise; + + let syncedPost = await decryptPost(key, item).catch(function (e) { + console.warn("Could not parse or decrypt:"); + console.warn(item.data); + console.warn(e); + throw e; + }); + if (syncedPost._type && "post" !== syncedPost._type) { + console.warn("couldn't handle type", syncedPost._type); + console.warn(syncedPost); + return; + } + + if (lastSyncDown.valueOf() < new Date(item.updated_at).valueOf()) { + // updated once in localStorage at the end + lastSyncDown = new Date(item.updated_at); + } + + let localPost = PostModel.get(syncedPost.uuid); + if (localPost) { + // localPost is guaranteed to have a sync_id by all rational logic + let localUpdated = new Date(localPost.updated).valueOf() || 0; + let syncedUpdated = new Date(syncedPost.updated).valueOf() || 0; + if (localUpdated > syncedUpdated) { + PostModel.saveVersion(syncedPost); + console.debug( + "Choosing winner wins strategy: local, more recent post is kept; older, synced post saved as alternate version." + ); + return; + } + } + + // TODO don't resave items that haven't changed? + syncedPost.sync_id = item.uuid; + syncedPost.synced_at = item.updated_at; + PostModel.save(syncedPost); + }, Promise.resolve()); + localStorage.setItem("bliss:last-sync-down", lastSyncDown.toISOString()); + // TODO make public or make... different + Post._renderRows(); + + // TODO handle offline case: if new things have not been synced, sync them + + // update all existing posts + await PostModel.ids() + .map(function (id) { + // get posts rather than ids + return PostModel.getOrCreate(id); + }) + .sort(function (a, b) { + let aDate = new Date(a.updated).valueOf() || 0; + let bDate = new Date(b.updated).valueOf() || 0; + return aDate - bDate; + }) + .reduce(async function (p, post) { + await p; + await PostModel._syncHook(post, lastSyncUp).catch(die); + }, Promise.resolve()); + } + + init().catch(function (err) { + console.error(err); + window.alert(`Fatal Error: ${err.message}`); + }); +})(); From 628974f5673fc790a893bc2ec65c63e4fd5964e8 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 26 Sep 2021 06:59:25 +0000 Subject: [PATCH 08/25] WIP: update TODO.md too --- TODO.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 8157f70..b991941 100644 --- a/TODO.md +++ b/TODO.md @@ -5,9 +5,18 @@ What's left? - [x] NOT update items that are older than lastSyncUpdate - [x] Draft versioning (local?) - [x] General refactoring -- [ ] Deleted things marked as null -- [ ] Deleted things don't count against quotas -- [ ] Mark items deleted +- [x] can't save encryption key when none is present +- [x] shouldn't complain about invalid non-key +- [x] update UI when syncing posts +- [ ] don't sync Empty / Untitled - [ ] Count bytes - [ ] Paywall -- [ ] Stripe Payments +- [ ] Deleted things marked as null +- [ ] Deleted things don't count against quotas +- [ ] re-key library +- [ ] Fine-tuned refactoring +- [ ] Payments + - [ ] Paypal + - [ ] anonymous, not stripe + - [ ] Apple, Google, Amazon, etc +- [ ] Hash'n'Cache assets From 596b1554953e67019391a441ed84a0f12cd952e6 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 26 Sep 2021 07:13:02 +0000 Subject: [PATCH 09/25] WIP: update TODOS --- TODO.md | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/TODO.md b/TODO.md index b991941..4379fc6 100644 --- a/TODO.md +++ b/TODO.md @@ -1,22 +1,27 @@ What's left? -- [x] Ability to show encryption key -- [x] Update items -- [x] NOT update items that are older than lastSyncUpdate -- [x] Draft versioning (local?) -- [x] General refactoring -- [x] can't save encryption key when none is present -- [x] shouldn't complain about invalid non-key -- [x] update UI when syncing posts -- [ ] don't sync Empty / Untitled -- [ ] Count bytes -- [ ] Paywall -- [ ] Deleted things marked as null -- [ ] Deleted things don't count against quotas -- [ ] re-key library -- [ ] Fine-tuned refactoring -- [ ] Payments - - [ ] Paypal - - [ ] anonymous, not stripe - - [ ] Apple, Google, Amazon, etc -- [ ] Hash'n'Cache assets +- [x] Completed + - [x] Ability to show encryption key + - [x] Update items + - [x] NOT update items that are older than lastSyncUpdate + - [x] Draft versioning (local?) + - [x] General refactoring + - [x] can't save encryption key when none is present + - [x] shouldn't complain about invalid non-key + - [x] update UI when syncing posts + - [x] don't sync Empty / Untitled +- [ ] Release 1 + - [ ] Out-of-Sync Indicator (`updated > synced_at`) + - [ ] Manual Sync Button + - [ ] Count items & bytes + - [ ] Paywall + - [ ] Deleted things marked as null + - [ ] Deleted things don't count against quotas +- [ ] Future + - [ ] re-key library + - [ ] Fine-tuned refactoring + - [ ] Payments + - [ ] Paypal + - [ ] anonymous, not stripe + - [ ] Apple, Google, Amazon, etc + - [ ] Hash'n'Cache assets From 31f9b493c4ce189cfb733526014e493813683c58 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 30 Sep 2021 21:22:16 +0000 Subject: [PATCH 10/25] WIP: switch to per-post keying of keys --- encraption.js | 13 ++++-- sync.js | 111 +++++++++++++++++++++++++++----------------------- 2 files changed, 71 insertions(+), 53 deletions(-) diff --git a/encraption.js b/encraption.js index 5d68d7c..071f911 100644 --- a/encraption.js +++ b/encraption.js @@ -5,21 +5,28 @@ var Encraption = {}; let Encoding = window.Encoding; - Encraption.importKey = async function importKey(key64) { + Encraption.importKeyBytes = async function importKey(keyU8) { let crypto = window.crypto; let usages = ["encrypt", "decrypt"]; let extractable = false; - let rawKey = Encoding.base64ToBuffer(key64); return await crypto.subtle.importKey( "raw", - rawKey, + keyU8, { name: "AES-CBC" }, extractable, usages ); }; + Encraption.importKey = async function importKey(key64) { + let rawKey = Encoding.base64ToBuffer(key64); + let key = await Encraption.importKeyBytes(rawKey); + return key; + }; + + Encraption.importKey64 = Encraption.importKey; + Encraption.encryptObj = async function encryptObj(obj, key) { var crypto = window.crypto; var ivLen = 16; // the IV is always 16 bytes diff --git a/sync.js b/sync.js index 27bbbf6..11722f7 100644 --- a/sync.js +++ b/sync.js @@ -151,27 +151,18 @@ let Post = window.Post; async function decryptPost(key, item) { - let data; - try { - // because this is double stringified (for now) - data = JSON.parse(item.data); - } catch (e) { - e.data = item.data; - throw e; - } + let data = item.data; - // TODO decide which key to use (once we have shared projects) - let syncedPost; - try { - syncedPost = await Encraption.decrypt64(data.encrypted, key); - } catch (e) { - e.data = data; - throw e; - } + let syncedPost = await Encraption.decrypt64(data.encrypted, key).catch( + function (err) { + err.data = data; + throw err; + } + ); return syncedPost; } - async function docCreate(token, key, post) { + async function docCreate(token) { let resp = await window.fetch(baseUrl + "/api/user/doc", { method: "POST", headers: { @@ -179,12 +170,14 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - data: JSON.stringify({ - encrypted: await Encraption.encryptObj(post, key), - }), + data: JSON.stringify({}), }), }); let body = await resp.json(); + if (!body.uuid || !new Date(body.updated_at).valueOf()) { + // TODO + throw new Error("Sync was not successful"); + } return body; } @@ -202,43 +195,47 @@ }), }); let body = await resp.json(); + if (!body.uuid || !new Date(body.updated_at).valueOf()) { + // TODO + throw new Error("Sync was not successful"); + } return body; } - async function _syncPost(token, key, post, lastSyncUp2) { - // Note: syncHook MUST be called on posts in ascending `updated_at` order - // (otherwise drafts will be older than `lastSyncUp2` and be skipped / lost) + async function _syncPost(token, key2048, post, _lastSyncUp) { post._type = "post"; - let resp; - if (!post.sync_id) { - resp = await docCreate(token, key, post); - } else if ( - (new Date(post.updated).valueOf() || 0) > lastSyncUp2.valueOf() - ) { - resp = await docUpdate(token, key, post); - } else { - return lastSyncUp2; - } - - if (!resp.uuid || !new Date(resp.updated_at).valueOf()) { - // TODO - throw new Error("Sync was not successful"); + // Note: syncHook MUST be called on posts in ascending `updated_at` order + // (otherwise drafts will be older than `_lastSyncUp` and be skipped / lost) + let postUpdatedAt = new Date(post.updated).valueOf() || 0; + if (postUpdatedAt < _lastSyncUp.valueOf()) { + return _lastSyncUp; } - // Don't do this. Keep local `updated` for local sync logic - //post.updated = resp.updated_at.toISOString(); if (!post.sync_id) { - post.sync_id = resp.uuid; + let item = await docCreate(token); + post.sync_id = item.uuid; PostModel.save(post); } - // double parse to ensure a valid date - let _lastSyncUp2 = new Date(new Date(post.updated).valueOf() || 0); - if (_lastSyncUp2.valueOf() > lastSyncUp2.valueOf()) { - lastSyncUp2 = _lastSyncUp2; + let buf512 = await Passphrase.pbkdf2(key2048, post.sync_id); + let buf128 = buf512.slice(0, 16); + let key = await Encraption.importKeyBytes(buf128).catch(showError); + + // Note: We intentionally don't use the remote's `updated_at`. + // We keep local `updated` for local sync logic + // Example (of what not to do): post.updated = resp.updated_at.toISOString(); + + await docUpdate(token, key, post); + + // double parse to ensure a valid date (i.e. NaN => 0) + let updatedAt = new Date(new Date(post.updated).valueOf() || 0); + if (!updatedAt) { + console.warn("bad `updated` date:", post); + return _lastSyncUp; } - return lastSyncUp2; + + return updatedAt; } function showError(err) { @@ -273,7 +270,7 @@ Enjoy! 🥳`, if (key64) { keyBytes = Encoding.base64ToBuffer(key64); key2048 = await Passphrase.encode(keyBytes); - key = await Encraption.importKey(key64).catch(showError); + key = await Encraption.importKey64(key64).catch(showError); } // double parsing date to guarantee a valid date or the zero date @@ -291,7 +288,7 @@ Enjoy! 🥳`, // TODO don't save empty posts at all return; } - lastSyncUp = await _syncPost(token, key, post, lastSyncUp); + lastSyncUp = await _syncPost(token, key2048, post, lastSyncUp); localStorage.setItem("bliss:last-sync-up", lastSyncUp.toISOString()); }; @@ -315,7 +312,7 @@ Enjoy! 🥳`, keyBytes = crypto.getRandomValues(new Uint8Array(16)); key64 = Encoding.bufferToBase64(keyBytes); key2048 = await Passphrase.encode(keyBytes); - key = await Encraption.importKey(key64); + key = await Encraption.importKey64(key64); localStorage.setItem("bliss:enc-key", key64); } @@ -339,7 +336,7 @@ Enjoy! 🥳`, continue; } key64 = Encoding.bufferToBase64(keyBytes); // can't fail - key = await Encraption.importKey(key64).catch(showError); + key = await Encraption.importKey64(key64).catch(showError); if (!key) { continue; } @@ -361,7 +358,21 @@ Enjoy! 🥳`, await items.reduce(async function (promise, item) { await promise; - let syncedPost = await decryptPost(key, item).catch(function (e) { + try { + // because this is double stringified (for now) + item.data = JSON.parse(item.data); + } catch (e) { + e.data = item.data; + console.warn(e); + return; + } + + // TODO decide how to keyshare so that we can have shared projects + let buf512 = await Passphrase.pbkdf2(key2048, item.uuid); + let buf128 = buf512.slice(0, 16); + + let postKey = await Encraption.importKeyBytes(buf128); + let syncedPost = await decryptPost(postKey, item).catch(function (e) { console.warn("Could not parse or decrypt:"); console.warn(item.data); console.warn(e); From 5c7a6a5824b92d867a2355a4dac633b739eb5eec Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 30 Sep 2021 21:59:13 +0000 Subject: [PATCH 11/25] WIP: fixup: correct sync time comparison --- sync.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/sync.js b/sync.js index 11722f7..4873726 100644 --- a/sync.js +++ b/sync.js @@ -208,7 +208,7 @@ // Note: syncHook MUST be called on posts in ascending `updated_at` order // (otherwise drafts will be older than `_lastSyncUp` and be skipped / lost) let postUpdatedAt = new Date(post.updated).valueOf() || 0; - if (postUpdatedAt < _lastSyncUp.valueOf()) { + if (postUpdatedAt <= _lastSyncUp.valueOf()) { return _lastSyncUp; } @@ -247,6 +247,7 @@ let howToSyncTemplate = { title: "🎉🔥🚀 NEW! How to Sync Drafts", description: "Congrats! Now you can sync drafts between computers!", + //updated: new Date(0).toISOString(), content: `Bliss uses secure local encryption for syncing drafts. (you're probably not a weirdo, but if you are, we don't even want to be able to find out 😉) @@ -316,7 +317,13 @@ Enjoy! 🥳`, localStorage.setItem("bliss:enc-key", key64); } - await PostModel._syncHook(PostModel.normalize(howToSyncTemplate)); + // calling _syncPost directly as to not update sync date + await _syncPost( + token, + key2048, + PostModel.normalize(howToSyncTemplate), + new Date(0) + ); // shouldn't be possible to be called thrice but... // just in case... if (!_cannotReDo) { From b74e6971b80c350a4d5f09521c9959920d02a0f3 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 1 Oct 2021 09:47:22 +0000 Subject: [PATCH 12/25] WIP: syncing mostly works now --- app.js | 2 +- blog.js | 38 +++- deps.sh | 2 + index.html | 9 +- sync.js | 544 ++++++++++++++++++++++++++++++++++++++--------------- 5 files changed, 430 insertions(+), 165 deletions(-) create mode 100644 deps.sh diff --git a/app.js b/app.js index 1e27581..d7f6092 100644 --- a/app.js +++ b/app.js @@ -63,7 +63,7 @@ var Settings = {}; (async function () { "use strict"; - let $ = window.$; + let $ = window.$; let Tab = window.Tab; let PostModel = window.PostModel; let Post = window.Post; diff --git a/blog.js b/blog.js index 65de09e..acaf604 100644 --- a/blog.js +++ b/blog.js @@ -140,6 +140,12 @@ var BlogModel = {}; var text = $('textarea[name="content"]').value.trim(); var inputDescription = $('textarea[name="description"]').value; post.title = PostModel._parseTitle(text); + if (!post.title) { + console.log("remove (or just skip saving) empty doc"); + localStorage.removeItem(`post.${post.uuid}.meta`); + localStorage.removeItem(`post.${post.uuid}.data`); + return; + } // skip the first line of text (which was the title) var lines = text.split(/[\r\n]/g); @@ -197,11 +203,17 @@ var BlogModel = {}; }; Post._update = function (post) { Post._serialize(post); - if (post._previous.title !== post.title) { + let synced = post.sync_version === post.updated; + if ( + post._previous.title !== post.title || + post._previous._synced !== synced + ) { + console.log("[DEBUG] firing the update mechanism"); var cell = $('input[name="uuid"][value="' + post.uuid + '"]'); var row = cell.closest("tr"); row.outerHTML = Post._renderRow(post); post._previous.title = post.title; + post._previous._synced = synced; } Post._rawPreview(post); }; @@ -289,11 +301,16 @@ var BlogModel = {}; }; Post._renderRow = function (post) { + let needsUpdate = ""; + if (post.sync_version && post.sync_version !== post.updated) { + needsUpdate = "⚠️ 🔄
"; + } var tmpl = Post._rowTmpl .replace(/ hidden/g, "") .replace( "{{title}}", - post.title.slice(0, 50).replace(/Untitled" + needsUpdate + post.title.slice(0, 50).replace(/Untitled" ) .replace("{{uuid}}", post.uuid) .replace( @@ -676,11 +693,9 @@ var BlogModel = {}; * @returns {BlissPost} */ PostModel.getOrCreate = function (uuid) { - var post = PostModel.get(uuid) || {}; + var post = PostModel.get(uuid) || { content: "" }; post.uuid = uuid; - // Content - post.content = localStorage.getItem("post." + post.uuid + ".data") || ""; if (!post.description) { post.description = PostModel._parseDescription(post); } @@ -692,7 +707,10 @@ var BlogModel = {}; post = PostModel.normalize(post); // TODO is there a better way to handle this? - post._previous = { title: post.title }; + post._previous = { + title: post.title, + _synced: post.sync_version === post.updated, + }; // Blog // TODO post.blog_id @@ -712,11 +730,16 @@ var BlogModel = {}; * @returns {BlissPost?} */ PostModel.get = function (uuid) { + // Meta let json = localStorage.getItem("post." + uuid + ".meta"); if (!json) { return null; } - return JSON.parse(json); + let post = JSON.parse(json); + + // Content + post.content = localStorage.getItem("post." + post.uuid + ".data") || ""; + return post; }; PostModel.ids = function () { @@ -757,6 +780,7 @@ var BlogModel = {}; // for syncing sync_id: post.sync_id, + sync_version: post.sync_version, }) ); localStorage.setItem("post." + post.uuid + ".data" + version, post.content); diff --git a/deps.sh b/deps.sh new file mode 100644 index 0000000..941b885 --- /dev/null +++ b/deps.sh @@ -0,0 +1,2 @@ +curl -fsSL https://unpkg.com/@root/passphrase -o passphrase.js +curl -fsSL https://unpkg.com/@root/debounce -o debouncer.js diff --git a/index.html b/index.html index 61da926..b1b2d88 100644 --- a/index.html +++ b/index.html @@ -79,10 +79,10 @@

Bliss: Blog, Easy As Gist

@@ -135,6 +135,10 @@

Bliss: Blog, Easy As Gist

+ +
@@ -331,6 +335,7 @@

Bliss: Blog, Easy As Gist

+ diff --git a/sync.js b/sync.js index 4873726..d1a9a4d 100644 --- a/sync.js +++ b/sync.js @@ -1,3 +1,5 @@ +var Sync = {}; + (async function () { "use strict"; @@ -11,15 +13,25 @@ let baseUrl = ENV.BASE_API_URL; let $ = window.$; + let $$ = window.$$; let Encoding = window.Encoding; let Encraption = window.Encraption; + let Debouncer = window.Debouncer; + let Session = { + getToken: async function () { + return Session._token; + }, + _setToken: function (token) { + Session._token = token; + }, + }; function noop() {} function die(err) { console.error(err); window.alert( - "Oops! There was an unexpected error on the server.\nIt's not your fault.\n\n" + + "Oops! There was an unexpected error.\nIt's not your fault.\n\n" + "Technical Details for Tech Support: \n" + err.message ); @@ -79,8 +91,12 @@ async function init() { let Auth3000 = window.Auth3000; - $(".js-logout").hidden = true; - $(".js-sign-in-github").hidden = true; + $$(".js-authenticated").forEach(function ($el) { + $el.hidden = true; + }); + $$(".js-guest").forEach(function ($el) { + $el.hidden = true; + }); var githubSignInUrl = Auth3000.generateOauth2Url( "https://github.com/login/oauth/authorize", @@ -94,6 +110,10 @@ ev.preventDefault(); ev.stopPropagation(); + window.removeEventListener("beforeunload", Session._beforeunload); + //window.removeEventListener("unload", Session._beforeunload); + clearInterval(Session._syncTimer); + let resp = await window .fetch(baseUrl + "/api/authn/session", { method: "DELETE", @@ -126,34 +146,176 @@ return; } - $(".js-sign-in-github").hidden = false; + $$(".js-guest").forEach(function ($el) { + $el.hidden = false; + }); //$(".js-social-login").hidden = false; return; } + // + // Sync Code Stuff + // + + let crypto = window.crypto; + let Passphrase = window.Passphrase; + let PostModel = window.PostModel; + let Post = window.Post; + async function doStuffWithUser(result) { if (!result.id_token && !result.access_token) { window.alert("No token, something went wrong."); return; } - $(".js-logout").hidden = false; + $$(".js-authenticated").forEach(function ($el) { + $el.hidden = false; + }); + let token = result.id_token || result.access_token; - await syncUserContent(token); + Session._setToken(token); + + // Note: this CANNOT be an async function !! + Session._beforeunload = function (ev) { + if (0 === Sync.getSyncable().length) { + return; + } + + ev.preventDefault(); + ev.returnValue = "Some items may not be saved. Close the window anyway?"; + + /* + // start syncing, even though it may not finish... + syncUp().catch(function (err) { + console.warn("Error during sync on 'beforeunload'"); + console.warn(err); + }); + */ + + return ev.returnValue; + }; + + window.addEventListener("beforeunload", Session._beforeunload); + //window.addEventListener("unload", Session._beforeunload); + window.document.addEventListener("visibilitychange", async function () { + // fires when user switches tabs, apps, goes to homescreen, etc. + if ("hidden" !== document.visibilityState) { + return; + } + + await syncDown() + .then(syncUp) + .catch(function (err) { + console.warn("Error during sync down on 'visibilitychange'"); + console.warn(err); + }); + }); + + Session._syncTimer = setInterval(async function () { + console.log("[DEBUG] Interval is working"); + + // sync down first so that backups are created + await syncDown().catch(function (err) { + console.warn("Error during sync (download) on interval timer"); + console.warn(err); + }); + await syncUp().catch(function (err) { + console.warn("Error during sync (upload) on interval timer"); + console.warn(err); + }); + }, 5 * 60 * 1000); + + await syncUserContent(); } - // - // Sync Code Stuff - // + PostModel._syncHook = async function __syncHook(post) { + let token = await Session.getToken(); + let key2048 = getKey2048(); - let crypto = window.crypto; - let Passphrase = window.Passphrase; - let PostModel = window.PostModel; - let Post = window.Post; + if (!post.title) { + // don't sync "Untitled" posts + // TODO don't save empty posts at all + console.warn("[WARN] skipped attempt to sync empty post"); + return; + } + + // impossible condition: would require a new bug in the code + // (double parse to ensure a valid date (i.e. NaN => 0)) + let updatedAt = new Date(new Date(post.updated).valueOf() || 0); + if (!updatedAt) { + // TODO what's the sane quick fix for this? + console.warn("[WARN] bad `updated` date:", post); + } + if (updatedAt && post.sync_version === post.updated) { + console.warn( + "[WARN] skipped attempt to double sync same version of post" + ); + return; + } + + await _syncPost(token, key2048, post); + }; + + let debounceSync = Debouncer.create(async function () { + function showAndReturnError(err) { + console.error(err); + window.alert( + "Oops! The sync failed.\nIt may have been a network error.\nIt's not your fault.\n\n" + + "Technical Details for Tech Support: \n" + + err.message + ); + return err; + } + + let err = await syncUp().catch(showAndReturnError); + if (err instanceof Error) { + return; + } + await syncUp().catch(showAndReturnError); + }, 200); + window.document.body.addEventListener("click", async function (ev) { + if (!ev.target.matches(".js-sync")) { + return; + } + + ev.target.disabled = "disabled"; + // TODO make async/await + // TODO disable button, add spinner + //$('button.js-sync').disabled = true; + let result = await debounceSync().catch(Object); + ev.target.removeAttribute("disabled"); + if (result instanceof Error) { + return; + } + }); + + /* + $$('.js-sync').forEach(function ($el) { + $el.addEventListener("click", function (ev) { + }); + }) + */ + + async function getPostKey(sync_id) { + // TODO decide how to share keys so that we can have shared projects + let keyBytes; + let key64 = localStorage.getItem(`post.${sync_id}.key`); + if (key64) { + keyBytes = Encoding.base64ToBuffer(key64); + } else { + let key2048 = await getKey2048(); + let buf512 = await Passphrase.pbkdf2(key2048, sync_id); + keyBytes = buf512.slice(0, 16); + } + + return await Encraption.importKeyBytes(keyBytes); + } + + async function decryptPost(item) { + let postKey = await getPostKey(item.uuid); - async function decryptPost(key, item) { let data = item.data; - let syncedPost = await Encraption.decrypt64(data.encrypted, key).catch( + let syncedPost = await Encraption.decrypt64(data.encrypted, postKey).catch( function (err) { err.data = data; throw err; @@ -202,40 +364,24 @@ return body; } - async function _syncPost(token, key2048, post, _lastSyncUp) { + async function _syncPost(token, key2048, post) { post._type = "post"; - // Note: syncHook MUST be called on posts in ascending `updated_at` order - // (otherwise drafts will be older than `_lastSyncUp` and be skipped / lost) - let postUpdatedAt = new Date(post.updated).valueOf() || 0; - if (postUpdatedAt <= _lastSyncUp.valueOf()) { - return _lastSyncUp; - } - if (!post.sync_id) { let item = await docCreate(token); post.sync_id = item.uuid; PostModel.save(post); } - let buf512 = await Passphrase.pbkdf2(key2048, post.sync_id); - let buf128 = buf512.slice(0, 16); - let key = await Encraption.importKeyBytes(buf128).catch(showError); + let postKey = await getPostKey(post.sync_id); // Note: We intentionally don't use the remote's `updated_at`. // We keep local `updated` for local sync logic // Example (of what not to do): post.updated = resp.updated_at.toISOString(); - await docUpdate(token, key, post); - - // double parse to ensure a valid date (i.e. NaN => 0) - let updatedAt = new Date(new Date(post.updated).valueOf() || 0); - if (!updatedAt) { - console.warn("bad `updated` date:", post); - return _lastSyncUp; - } - - return updatedAt; + await docUpdate(token, postKey, post); + post.sync_version = post.updated; + PostModel.save(post); } function showError(err) { @@ -243,55 +389,64 @@ window.alert("that's not a valid key"); } - async function syncUserContent(token, _cannotReDo) { - let howToSyncTemplate = { - title: "🎉🔥🚀 NEW! How to Sync Drafts", - description: "Congrats! Now you can sync drafts between computers!", - //updated: new Date(0).toISOString(), - content: `Bliss uses secure local encryption for syncing drafts. - -(you're probably not a weirdo, but if you are, we don't even want to be able to find out 😉) - -Your browser has generated a secure, random, 128-bit encryption passphrase which you must use in order to sync drafts between computers. - -To enable 🔁 Sync on another computer: -1. Sign in to Bliss on the other computer. -2. You will be prompted for your encryption passphrase. -3. Copy and paste your passphrase from Settings - -Enjoy! 🥳`, - }; - - // Use MEGA-style https://site.com/invite#priv ? - // hash(priv) => pub - let key64 = localStorage.getItem("bliss:enc-key"); - let key; - let key2048; - let keyBytes; - if (key64) { - keyBytes = Encoding.base64ToBuffer(key64); - key2048 = await Passphrase.encode(keyBytes); - key = await Encraption.importKey64(key64).catch(showError); - } - + function getLastDown() { // double parsing date to guarantee a valid date or the zero date - let lastSyncDown = new Date( + return new Date( new Date(localStorage.getItem("bliss:last-sync-down")).valueOf() || 0 ); + } - let lastSyncUp = new Date( + function getLastUp() { + return new Date( new Date(localStorage.getItem("bliss:last-sync-up")).valueOf() || 0 ); + } - PostModel._syncHook = async function __syncHook(post) { - if (!post.title) { - // don't sync "Untitled" posts - // TODO don't save empty posts at all - return; + async function getKey2048() { + let key64 = localStorage.getItem("bliss:enc-key"); + if (!key64) { + let err = new Error("no key exists"); + err.code = "NOT_FOUND"; + throw err; + } + let keyBytes = Encoding.base64ToBuffer(key64); + let key2048 = await Passphrase.encode(keyBytes); + return key2048; + } + + async function askForKey2048() { + // while (true) is for ninnies + for (;;) { + let _key2048 = window + .prompt( + "What's your encryption passphrase? (check in Settings on the system that you first used Bliss)", + "" + ) + .trim(); + let keyBytes = await Passphrase.decode(_key2048).catch(showError); + if (!keyBytes) { + continue; + } + let key64 = Encoding.bufferToBase64(keyBytes); // can't fail + let key = await Encraption.importKey64(key64).catch(showError); + if (!key) { + continue; } - lastSyncUp = await _syncPost(token, key2048, post, lastSyncUp); - localStorage.setItem("bliss:last-sync-up", lastSyncUp.toISOString()); - }; + + // TODO try to decrypt something to ensure correctness + localStorage.setItem("bliss:enc-key", key64); + return _key2048; + } + } + + async function syncUserContent() { + await syncDown(); + await syncUp(); + } + + async function syncDown(_cannotReDo) { + let token = await Session.getToken(); + let lastSyncDown = getLastDown(); let resp = await window .fetch(baseUrl + "/api/user/doc?since=" + lastSyncDown.toISOString(), { @@ -302,64 +457,52 @@ Enjoy! 🥳`, }) .catch(die); let items = await resp.json().catch(die); + //console.debug("Items:"); //console.debug(items); + // Use MEGA-style https://site.com/invite#priv ? + // hash(priv) => pub + let key2048 = await getKey2048().catch(function (err) { + if ("NOT_FOUND" !== err.code) { + throw err; + } + return ""; + }); + // We should always have at least the "How to Sync Drafts" post if (0 === lastSyncDown.valueOf() && !items.length) { // this is a new account on its first computer // gen 128-bit key - if (!key) { - keyBytes = crypto.getRandomValues(new Uint8Array(16)); - key64 = Encoding.bufferToBase64(keyBytes); + if (!key2048) { + let keyBytes = crypto.getRandomValues(new Uint8Array(16)); + let key64 = Encoding.bufferToBase64(keyBytes); key2048 = await Passphrase.encode(keyBytes); - key = await Encraption.importKey64(key64); localStorage.setItem("bliss:enc-key", key64); } - // calling _syncPost directly as to not update sync date - await _syncPost( - token, - key2048, - PostModel.normalize(howToSyncTemplate), - new Date(0) - ); + await syncTemplatePost(token, key2048); + // shouldn't be possible to be called thrice but... // just in case... if (!_cannotReDo) { - return syncUserContent(token, true); + return await syncDown(true); } + throw new Error("impossible condition: empty content after first sync"); } - if (!key) { - // while (true) is for ninnies - for (;;) { - let key2048 = window.prompt( - "What's your encryption passphrase? (check in Settings on the system that you first used Bliss)", - "" - ); - let keyBytes = await Passphrase.decode(key2048.trim()).catch(showError); - if (!keyBytes) { - continue; - } - key64 = Encoding.bufferToBase64(keyBytes); // can't fail - key = await Encraption.importKey64(key64).catch(showError); - if (!key) { - continue; - } - - // TODO try to decrypt something to ensure correctness - localStorage.setItem("bliss:enc-key", key64); - break; - } + if (!key2048) { + key2048 = await askForKey2048(); } - /* - let remoteIds = PostModel.ids().reduce(function (map, id) { - let post = PostModel.getOrCreate(id); - map[post.sync_id] = true; - }, {}); - */ + await updateLocal(items); + + // TODO make public or make... different + Post._renderRows(); + } + + async function updateLocal(items) { + let lastSyncDown = getLastDown(); // poor man's forEachAsync await items.reduce(async function (promise, item) { @@ -374,68 +517,159 @@ Enjoy! 🥳`, return; } - // TODO decide how to keyshare so that we can have shared projects - let buf512 = await Passphrase.pbkdf2(key2048, item.uuid); - let buf128 = buf512.slice(0, 16); - - let postKey = await Encraption.importKeyBytes(buf128); - let syncedPost = await decryptPost(postKey, item).catch(function (e) { + let remotePost = await decryptPost(item).catch(function (e) { console.warn("Could not parse or decrypt:"); console.warn(item.data); console.warn(e); throw e; }); - if (syncedPost._type && "post" !== syncedPost._type) { - console.warn("couldn't handle type", syncedPost._type); - console.warn(syncedPost); + if (remotePost._type && "post" !== remotePost._type) { + console.warn("couldn't handle type", remotePost._type); + console.warn(remotePost); return; } - if (lastSyncDown.valueOf() < new Date(item.updated_at).valueOf()) { + let updatedAt = new Date(item.updated_at); + if (updatedAt.valueOf() > lastSyncDown.valueOf()) { // updated once in localStorage at the end lastSyncDown = new Date(item.updated_at); } - let localPost = PostModel.get(syncedPost.uuid); - if (localPost) { - // localPost is guaranteed to have a sync_id by all rational logic - let localUpdated = new Date(localPost.updated).valueOf() || 0; - let syncedUpdated = new Date(syncedPost.updated).valueOf() || 0; - if (localUpdated > syncedUpdated) { - PostModel.saveVersion(syncedPost); - console.debug( - "Choosing winner wins strategy: local, more recent post is kept; older, synced post saved as alternate version." - ); - return; - } + // `post.updated` may be newer than `post.sync_version` + // `remotePost.updated` may match `post.sync_version` + // TODO get downloads as the response to an upload (v1.1+) + let localPost = PostModel.get(remotePost.uuid); + if (!localPost) { + // new thing, guaranteed conflict-free + // TODO sync_version should stay local + remotePost.sync_version = remotePost.updated; + PostModel.save(remotePost); + return; } - // TODO don't resave items that haven't changed? - syncedPost.sync_id = item.uuid; - syncedPost.synced_at = item.updated_at; - PostModel.save(syncedPost); + let syncedVersion = new Date(localPost.syncVersion).valueOf() || 0; + let localUpdated = new Date(localPost.updated).valueOf() || 0; + let remoteUpdated = new Date(remotePost.updated).valueOf() || 0; + + // The local is ahead of the remote. + // The local has non-synced updates. + // The remote version doesn't match the last synced version. + if (localUpdated >= remoteUpdated && remoteUpdated !== syncedVersion) { + PostModel.saveVersion(remotePost); + console.debug( + "Choosing winner wins strategy: local, more recent post is kept; older, synced post saved as alternate version." + ); + // return because we keep the more-up-to-date local + // TODO go ahead and sync the local upwards? + return; + } + + if (remoteUpdated === localUpdated && syncedVersion === localUpdated) { + // unlikely condition, but... don't resave items that haven't changed + return; + } + + // The remote is ahead of the local. + // The local has non-synced updates. + // The remote version doesn't match the last synced version. + if (remoteUpdated >= localUpdated && localUpdated !== syncedVersion) { + PostModel.saveVersion(localPost); + console.debug( + "Choosing winner wins strategy: remote, more recent post is kept; older, local post saved as alternate version." + ); + // TODO update UI?? + // don't return because we overwrite the local with the newer remote + } + + // TODO update UI?? + // the remote was newer, it wins + remotePost.sync_version = remotePost.updated; + PostModel.save(remotePost); }, Promise.resolve()); + localStorage.setItem("bliss:last-sync-down", lastSyncDown.toISOString()); - // TODO make public or make... different - Post._renderRows(); + } + + async function syncTemplatePost(token, key2048) { + let howToSyncTemplate = { + title: "🎉🔥🚀 NEW! How to Sync Drafts", + description: "Congrats! Now you can sync drafts between computers!", + //updated: new Date(0).toISOString(), + content: `Bliss uses secure local encryption for syncing drafts. - // TODO handle offline case: if new things have not been synced, sync them +(you're probably not a weirdo, but if you are, we don't even want to be able to find out 😉) - // update all existing posts - await PostModel.ids() +Your browser has generated a secure, random, 128-bit encryption passphrase which you must use in order to sync drafts between computers. + +To enable 🔁 Sync on another computer: +1. Sign in to Bliss on the other computer. +2. You will be prompted for your encryption passphrase. +3. Copy and paste your passphrase from Settings + +Enjoy! 🥳`, + }; + + // calling _syncPost directly as to not update sync date + await _syncPost(token, key2048, PostModel.normalize(howToSyncTemplate)); + } + + Sync.getSyncable = function () { + return PostModel.ids() .map(function (id) { // get posts rather than ids - return PostModel.getOrCreate(id); + let post = PostModel.getOrCreate(id); + post.__updated_ms = new Date(post.updated).valueOf(); + if (!post.__updated_ms) { + console.warn("[WARN] post without `updated`:", post); + post.__updated_ms = 0; + } + return post; }) .sort(function (a, b) { - let aDate = new Date(a.updated).valueOf() || 0; - let bDate = new Date(b.updated).valueOf() || 0; - return aDate - bDate; + // Note: syncHook MUST be called on posts in ascending `updated_at` order + // (otherwise drafts will be older than `_lastSyncUp` and be skipped / lost) + return a.__updated_ms - b.__updated_ms; }) - .reduce(async function (p, post) { - await p; - await PostModel._syncHook(post, lastSyncUp).catch(die); - }, Promise.resolve()); + .filter(function (post) { + if (!post.title) { + // don't sync "Untitled" posts + // TODO don't save empty posts at all + return false; + } + + if (post.sync_version === post.updated) { + return false; + } + + // it's impossible for the sync_version to be ahead of the updated + // but... the impossible happens all the time in programming! + if (new Date(post.sync_version).valueOf() > post.__updated_ms) { + console.warn( + "[WARN] sync_version is ahead of updated... ¯\\_(ツ)_/¯" + ); + } + + return true; + }); + }; + + async function syncUp() { + let _lastSyncUp = getLastUp(); + + // update all existing posts + let syncable = Sync.getSyncable(); + await syncable.reduce(async function (p, post) { + await p; + await PostModel._syncHook(post); + + // Only update if newer... + if (post.__updated_ms > _lastSyncUp.valueOf()) { + _lastSyncUp = new Date(post.__updated_ms); + localStorage.setItem("bliss:last-sync-up", _lastSyncUp.toISOString()); + } + }, Promise.resolve()); + + Post._renderRows(); } init().catch(function (err) { From 504b66b4bcaeeba96c32a8eb9d886c20f0cd8ea3 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 1 Oct 2021 10:17:35 +0000 Subject: [PATCH 13/25] bugfix: only allow one untitled draft at a time --- blog.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/blog.js b/blog.js index acaf604..e4929e7 100644 --- a/blog.js +++ b/blog.js @@ -108,6 +108,14 @@ var BlogModel = {}; ev.preventDefault(); ev.stopPropagation(); + // delete old untitled drafts + PostModel.ids().forEach(function (id) { + let post = PostModel.get(id); + if (!post.title) { + PostModel.delete(post.uuid); + } + }); + // create new untitled draft Post._deserialize(PostModel.create().uuid); Post._renderRows(); }; @@ -142,8 +150,7 @@ var BlogModel = {}; post.title = PostModel._parseTitle(text); if (!post.title) { console.log("remove (or just skip saving) empty doc"); - localStorage.removeItem(`post.${post.uuid}.meta`); - localStorage.removeItem(`post.${post.uuid}.data`); + PostModel.delete(post.uuid); return; } @@ -208,7 +215,6 @@ var BlogModel = {}; post._previous.title !== post.title || post._previous._synced !== synced ) { - console.log("[DEBUG] firing the update mechanism"); var cell = $('input[name="uuid"][value="' + post.uuid + '"]'); var row = cell.closest("tr"); row.outerHTML = Post._renderRow(post); @@ -529,7 +535,7 @@ var BlogModel = {}; }; var pathname = (Post._systems[blog.blog] || Post._systems.hugo).pathname; if (!Post._systems[blog.blog]) { - console.warn( + console.debug( "Warning: blog system not specified or unsupported, assuming hugo", blog.blog ); @@ -581,7 +587,7 @@ var BlogModel = {}; break; default: // TODO log error - console.warn( + console.debug( "Warning: blog.githost was not specified or unsupported, assuming github", blog.githost ); @@ -788,8 +794,8 @@ var BlogModel = {}; }; PostModel.delete = function (uuid) { - localStorage.removeItem("post." + uuid + ".meta"); - localStorage.removeItem("post." + uuid + ".content"); + localStorage.removeItem(`post.${uuid}.meta`); + localStorage.removeItem(`post.${uuid}.data`); }; PostModel._getRandomValues = function (arr) { From 7f22a5f53d9a475168b3b880691b6e7b2b1f9c11 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 1 Oct 2021 10:17:46 +0000 Subject: [PATCH 14/25] WIP: update TODO.md --- TODO.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 4379fc6..344f562 100644 --- a/TODO.md +++ b/TODO.md @@ -11,14 +11,19 @@ What's left? - [x] update UI when syncing posts - [x] don't sync Empty / Untitled - [ ] Release 1 - - [ ] Out-of-Sync Indicator (`updated > synced_at`) - - [ ] Manual Sync Button - - [ ] Count items & bytes + - [x] Per-Post AES Keys + - [x] Out-of-Sync Indicator (`updated > synced_at`) + - [x] Manual Sync Button + - [x] Count items & bytes + - [ ] BUG: summaries `> summary of thing` are being eaten + - [ ] What to do when the current draft is out of date? (reload indicator?) - [ ] Paywall - [ ] Deleted things marked as null - [ ] Deleted things don't count against quotas - [ ] Future + - [ ] Don't save multiple Untitled - [ ] re-key library + - [ ] Per-Post AES Keys - [ ] Fine-tuned refactoring - [ ] Payments - [ ] Paypal From bd662a391784b84fd736c390f7e10f902f159a14 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 2 Oct 2021 05:51:35 +0000 Subject: [PATCH 15/25] bugfix: don't eat description, give good templates --- blog.js | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/blog.js b/blog.js index e4929e7..9396930 100644 --- a/blog.js +++ b/blog.js @@ -211,6 +211,7 @@ var BlogModel = {}; Post._update = function (post) { Post._serialize(post); let synced = post.sync_version === post.updated; + // TODO fails to update under certain conditions if ( post._previous.title !== post.title || post._previous._synced !== synced @@ -257,13 +258,19 @@ var BlogModel = {}; //$('input[name="title"]').value = post.title; //$('input[name="created"]').value = PostModel._toInputDatetimeLocal(post.created); - if (post.title || post.content) { - $('textarea[name="content"]').value = - "# " + (post.title || "Untitled") + "\n\n" + post.content; - } else { - $('textarea[name="content"]').value = ""; - } - $('textarea[name="description"]').value = post.description || ""; + let title = (post.title || "").trim() || "Untitled"; + let emptyContent = "Fascinating Markdown content goes here..."; + let emptyDesc = "Meta-description summary goes here"; + let desc = (post.description || "").trim() || emptyDesc; + let content = (post.content || "").trim() || emptyContent; + if (desc.trim() === emptyContent) { + desc = emptyDesc; + } + // TODO what about when desc.length matches content[0..desc.length] + $('textarea[name="content"]').value = `# ${title}\n\n`; + $('textarea[name="content"]').value += `> ${desc}\n\n`; + $('textarea[name="content"]').value += `${content}\n`; + $('textarea[name="description"]').value = desc; $(".js-undelete").hidden = true; Post._rawPreview(post); @@ -311,13 +318,13 @@ var BlogModel = {}; if (post.sync_version && post.sync_version !== post.updated) { needsUpdate = "⚠️ 🔄
"; } + let title = post.title.slice(0, 50).replace(/Untitled" - ) + .replace("{{title}}", needsUpdate + title) .replace("{{uuid}}", post.uuid) .replace( "{{created}}", From 0c72e4d1bf0eb3eb049fcd4a93fdaa1a62875b42 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 2 Oct 2021 07:17:45 +0000 Subject: [PATCH 16/25] feature: show usage in Settings --- app.js | 62 ------------------------------------------------------ index.html | 30 ++++++++++++++++++++++++++ sync.js | 24 ++++++++++++--------- 3 files changed, 44 insertions(+), 72 deletions(-) diff --git a/app.js b/app.js index d7f6092..62b5ec6 100644 --- a/app.js +++ b/app.js @@ -1,65 +1,3 @@ -var Settings = {}; - -(async function () { - "use strict"; - - let Passphrase = window.Passphrase; - let Encoding = window.Encoding; - - Settings.togglePassphrase = async function (ev) { - let pass = ev.target - .closest("form") - .querySelector(".js-passphrase") - .value.trim(); - if ("[hidden]" !== pass) { - ev.target.closest("form").querySelector("button").innerText = "Show"; - ev.target.closest("form").querySelector(".js-passphrase").value = - "[hidden]"; - return; - } - - let b64 = localStorage.getItem("bliss:enc-key") || ""; - let bytes = Encoding.base64ToBuffer(b64); - pass = await Passphrase.encode(bytes); - - // TODO standardize controller container ma-bob - ev.target.closest("form").querySelector(".js-passphrase").value = pass; - ev.target.closest("form").querySelector("button").innerText = "Hide"; - }; - - Settings.savePassphrase = async function (ev) { - let newPass = ev.target - .closest("form") - .querySelector(".js-passphrase") - .value.trim() - .split(/[\s,:-]+/) - .filter(Boolean) - .join(" "); - - let bytes; - try { - bytes = await Passphrase.decode(newPass); - } catch (e) { - ev.target.closest("form").querySelector(".js-hint").innerText = e.message; - return; - } - ev.target.closest("form").querySelector(".js-hint").innerText = ""; - - let new64 = Encoding.bufferToBase64(bytes); - - let isoNow = new Date().toISOString(); - let current64 = localStorage.getItem("bliss:enc-key"); - if (current64 === new64) { - return; - } - - localStorage.setItem("bliss:enc-key", new64); - localStorage.setItem(`bliss:enc-key:backup:${isoNow}`, newPass); - ev.target.closest("form").querySelector(".js-hint").innerText = - "Saved New Passphrase!"; - }; -})(); - (async function () { "use strict"; diff --git a/index.html b/index.html index b1b2d88..1d1b4a1 100644 --- a/index.html +++ b/index.html @@ -323,6 +323,35 @@

Bliss: Blog, Easy As Gist

+
+
+
+

Stats & Usage Tier

+ + + + + Go "Pro" and get up to 50mb of storage for documents up to 200kb + each, and 5,000 syncs per month. + +
+

@@ -341,6 +370,7 @@

Bliss: Blog, Easy As Gist

+ diff --git a/sync.js b/sync.js index d1a9a4d..3bdb2c0 100644 --- a/sync.js +++ b/sync.js @@ -17,6 +17,7 @@ var Sync = {}; let Encoding = window.Encoding; let Encraption = window.Encraption; let Debouncer = window.Debouncer; + let Settings = window.Settings; let Session = { getToken: async function () { return Session._token; @@ -120,7 +121,7 @@ var Sync = {}; }) .catch(die); let result = await resp.json().catch(die); - console.log(result); + console.log('logout result:', result); window.alert("Logged out!"); init(); }); @@ -379,7 +380,9 @@ var Sync = {}; // We keep local `updated` for local sync logic // Example (of what not to do): post.updated = resp.updated_at.toISOString(); - await docUpdate(token, postKey, post); + let usage = await docUpdate(token, postKey, post); + Settings.setUsage(usage); + post.sync_version = post.updated; PostModel.save(post); } @@ -456,10 +459,11 @@ var Sync = {}; }, }) .catch(die); - let items = await resp.json().catch(die); + let usage = await resp.json().catch(die); + Settings.setUsage(usage); - //console.debug("Items:"); - //console.debug(items); + //console.debug("Docs:"); + //console.debug(docs); // Use MEGA-style https://site.com/invite#priv ? // hash(priv) => pub @@ -471,7 +475,7 @@ var Sync = {}; }); // We should always have at least the "How to Sync Drafts" post - if (0 === lastSyncDown.valueOf() && !items.length) { + if (0 === lastSyncDown.valueOf() && !usage.docs.length) { // this is a new account on its first computer // gen 128-bit key if (!key2048) { @@ -495,17 +499,17 @@ var Sync = {}; key2048 = await askForKey2048(); } - await updateLocal(items); + await updateLocal(usage.docs); // TODO make public or make... different Post._renderRows(); } - async function updateLocal(items) { + async function updateLocal(docs) { let lastSyncDown = getLastDown(); // poor man's forEachAsync - await items.reduce(async function (promise, item) { + await docs.reduce(async function (promise, item) { await promise; try { @@ -565,7 +569,7 @@ var Sync = {}; } if (remoteUpdated === localUpdated && syncedVersion === localUpdated) { - // unlikely condition, but... don't resave items that haven't changed + // unlikely condition, but... don't resave docs that haven't changed return; } From 5303dabb6aacc9a9908e16be891e2221a8a64931 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 2 Oct 2021 08:33:42 +0000 Subject: [PATCH 17/25] chore: add deps --- deps/debouncer.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++ deps/passphrase.js | 1 + index.html | 4 +-- 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 deps/debouncer.js create mode 120000 deps/passphrase.js diff --git a/deps/debouncer.js b/deps/debouncer.js new file mode 100644 index 0000000..84c8418 --- /dev/null +++ b/deps/debouncer.js @@ -0,0 +1,62 @@ +var Debouncer; + +(function () { + "use strict"; + + // should work with browser, node, ...and maybe even webpack? + if ("undefined" !== typeof module) { + Debouncer = module.exports; + } else { + Debouncer = {}; + window.Debouncer = Debouncer; + } + + Debouncer.create = function _debounce(fn, delay) { + let state = { running: false, _timer: null, _promise: null }; + + function rePromise() { + // all three of these should be set synchronously + state._promise = {}; + state._promise.promise = new Promise(function (resolve, reject) { + state._promise.resolve = resolve; + state._promise.reject = reject; + }); + return state._promise; + } + + rePromise(); + + let err = new Error("debounce"); + return async function _debounce() { + let args = Array.prototype.slice.call(arguments); + if (state.running) { + throw err; + } + + let pInfo = state._promise; + if (state._timer) { + clearTimeout(state._timer); + pInfo.reject(err); + pInfo = rePromise(); + } + + state._timer = setTimeout(function () { + state.running = true; + state._timer = null; + rePromise(); + + fn.apply(null, args) + .then(function (result) { + state.running = false; + pInfo.resolve(result); + }) + .catch(function (err) { + state.running = false; + pInfo.reject(err); + }); + }, delay); + + return pInfo.promise; + }; + }; +})(); diff --git a/deps/passphrase.js b/deps/passphrase.js new file mode 120000 index 0000000..4d9e9e1 --- /dev/null +++ b/deps/passphrase.js @@ -0,0 +1 @@ +passphrase/passphrase.js \ No newline at end of file diff --git a/index.html b/index.html index 1d1b4a1..634298f 100644 --- a/index.html +++ b/index.html @@ -364,12 +364,12 @@

Bliss: Blog, Easy As Gist

- + - + From 944dc7a542a8bfb33a41e6b69919b9b7926b5a0e Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 2 Oct 2021 08:33:55 +0000 Subject: [PATCH 18/25] fixup: add settings.js --- settings.js | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 settings.js diff --git a/settings.js b/settings.js new file mode 100644 index 0000000..e51a7ae --- /dev/null +++ b/settings.js @@ -0,0 +1,110 @@ +var Settings = {}; + +(async function () { + "use strict"; + + let $ = window.$; + let Passphrase = window.Passphrase; + let Encoding = window.Encoding; + + let units = ["b", "kb", "mb", "gb"]; + + function fromBytes(n) { + // NaN's a trixy number... + n = parseInt(n, 10) || 0; + if (n < 1024) { + return n + "b"; + } + + let unit = 0; + for (;;) { + if (n < 1024) { + break; + } + n /= 1024; + unit += 1; + } + + return n.toFixed(1) + units[unit]; + } + + Settings.setUsage = function (usage) { + let $totalSize = $('.js-usage [name="total-size"]'); + let totalSize = fromBytes(usage.total_size) || 0; + $totalSize.value = $totalSize.value.replace(/.* of/, `${totalSize} of`); + + let $totalCount = $('.js-usage [name="total-count"]'); + let totalCount = usage.total_count || 0; + $totalCount.value = $totalCount.value.replace(/.* of/, `${totalCount} of`); + }; + + Settings.togglePassphrase = async function (ev) { + let pass = ev.target + .closest("form") + .querySelector(".js-passphrase") + .value.trim(); + if ("[hidden]" !== pass) { + ev.target.closest("form").querySelector("button").innerText = "Show"; + ev.target.closest("form").querySelector(".js-passphrase").value = + "[hidden]"; + return; + } + + let b64 = localStorage.getItem("bliss:enc-key") || ""; + let bytes = Encoding.base64ToBuffer(b64); + pass = await Passphrase.encode(bytes); + + // TODO standardize controller container ma-bob + ev.target.closest("form").querySelector(".js-passphrase").value = pass; + ev.target.closest("form").querySelector("button").innerText = "Hide"; + }; + + Settings.savePassphrase = async function (ev) { + let newPass = ev.target + .closest("form") + .querySelector(".js-passphrase") + .value.trim() + .split(/[\s,:-]+/) + .filter(Boolean) + .join(" "); + + let bytes; + try { + bytes = await Passphrase.decode(newPass); + } catch (e) { + ev.target.closest("form").querySelector(".js-hint").innerText = e.message; + return; + } + ev.target.closest("form").querySelector(".js-hint").innerText = ""; + + let new64 = Encoding.bufferToBase64(bytes); + + let isoNow = new Date().toISOString(); + let current64 = localStorage.getItem("bliss:enc-key"); + if (current64 === new64) { + return; + } + + localStorage.setItem("bliss:enc-key", new64); + localStorage.setItem(`bliss:enc-key:backup:${isoNow}`, newPass); + ev.target.closest("form").querySelector(".js-hint").innerText = + "Saved New Passphrase!"; + }; + + function _init() { + let PostModel = window.PostModel; + + let ids = PostModel.ids(); + let usage = { total_count: ids.length, total_size: 0 }; + + ids.forEach(function (id) { + let post = PostModel.get(id); + let size = new TextEncoder().encode(JSON.stringify(post)).byteLength; + usage.total_size += size; + }); + + Settings.setUsage(usage); + } + + _init(); +})(); From a4a4d3ad50394380a87093ec5325927f67e32f32 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 2 Oct 2021 08:37:06 +0000 Subject: [PATCH 19/25] fixup: add the real passphrase.js --- deps/passphrase.js | 228 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 227 insertions(+), 1 deletion(-) mode change 120000 => 100644 deps/passphrase.js diff --git a/deps/passphrase.js b/deps/passphrase.js deleted file mode 120000 index 4d9e9e1..0000000 --- a/deps/passphrase.js +++ /dev/null @@ -1 +0,0 @@ -passphrase/passphrase.js \ No newline at end of file diff --git a/deps/passphrase.js b/deps/passphrase.js new file mode 100644 index 0000000..0ffd006 --- /dev/null +++ b/deps/passphrase.js @@ -0,0 +1,227 @@ +var Passphrase = {}; + +(function () { + "use strict"; + let crypto = window.crypto; + + // See BIP-39 Spec at https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki + // TODO Rion says checkout bip32 and bip43 + + // allow any amount of spaces, tabs, newlines, commas, other common separators + Passphrase._sep = /[\s,:-]+/; + + // because I typo this word every time... + Passphrase._mword = "mnemonic"; + + // puts the passphrase in canonical form + // (UTF-8 NKFD, lowercase, no extra spaces) + Passphrase._normalize = function (str) { + return str.normalize("NFKD").trim().toLowerCase(); + }; + + /** + * @param {number} bitLen - The target entropy - must be 128, 160, 192, 224, + * or 256 bits. + * @returns {string} - The passphrase will be a space-delimited list of 12, + * 15, 18, 21, or 24 words from the "base2048" word list + * dictionary. + */ + Passphrase.generate = async function (bitLen = 128) { + let byteLen = bitLen / 8; + // ent + let bytes = crypto.getRandomValues(new Uint8Array(byteLen)); + return await Passphrase.encode(bytes); + }; + + /** + * @param {ArrayLike} bytes - The bytes to encode as a word list + * @returns {string} - The passphrase will be a space-delimited list of 12, + * 15, 18, 21, or 24 words from the "base2048" word list + * dictionary. + */ + Passphrase.encode = async function (bytes) { + let bitLen = 8 * bytes.length; + // cs + let sumBitLen = bitLen / 32; + + let hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + + // convert to binary string (literal '0010011110....' + let digits = bytes.reduce(function (str, n) { + return str + n.toString(2).padStart(8, "0"); + }, ""); + let checksum = hash[0].toString(2).padStart(8, "0").slice(0, sumBitLen); + digits += checksum; + + let seed = []; + for (let bit = 0; bit < bitLen + sumBitLen; bit += 11) { + // 11-bit integer (0-2047) + let i = parseInt(digits.slice(bit, bit + 11).padStart(8, "0"), 2); + seed.push(i); + } + + let words = seed.map(function (i) { + return Passphrase.base2048[i]; + }); + + return words.join(" "); + }; + + /** + * @param {string} passphrase - Same as from Passphrase.generate(...). + * @returns {boolean} - True if the leftover checksum bits (4, 5, 6, 7, or 8) + * match the expected values. + */ + Passphrase.checksum = async function (passphrase) { + await Passphrase.decode(passphrase); + return true; + }; + + /** + * @param {string} passphrase - The bytes to encode as a word list + * @returns {Uint8Array} - The byte representation of the passphrase. + */ + Passphrase.decode = async function (passphrase) { + passphrase = Passphrase._normalize(passphrase); + + // there must be 12, 15, 18, 21, or 24 words + let ints = passphrase.split(Passphrase._sep).reduce(function (arr, word) { + // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize + // 0-2047 (11-bit ints) + let index = Passphrase.base2048.indexOf(word); + if (index < 0) { + throw new Error(`passphrase.js: decode failed: unknown word '${word}'`); + } + arr.push(index); + return arr; + }, []); + + let digits = ints + .map(function (n) { + return n.toString(2).padStart(11, "0"); + }) + .join(""); + + // 128 => 4, 160 => 5, 192 => 6, 224 => 7, 256 => 8 + let sumBitLen = Math.floor(digits.length / 32); + let bitLen = digits.length - sumBitLen; + + let checksum = digits.slice(-sumBitLen); + let bytesArr = []; + for (let bit = 0; bit < bitLen; bit += 8) { + let bytestring = digits.slice(bit, bit + 8); + let n = parseInt(bytestring, 2); + if (n >= 0) { + bytesArr.push(n); + } + } + + // the original random bytes used to generate the 12-24 words + let bytes = Uint8Array.from(bytesArr); + + let hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + let expected = hash[0].toString(2).padStart(8, "0").slice(0, sumBitLen); + if (expected !== checksum) { + throw new Error( + `passphrase.js: bad checksum: expected '${expected}' but got '${checksum}'` + ); + } + + return bytes; + }; + + /** + * @param {string} passphrase - Same as from Passphrase.generate(...). + * @param {string} salt - Another passphrase (or whatever) to produce a pairwise key. + * @returns {Uint8Array} - A new key - the PBKDF2 of the passphrase + "mnemonic" + salt. + */ + Passphrase.pbkdf2 = async function (passphrase, salt = "") { + passphrase = Passphrase._normalize(passphrase); + salt = salt.normalize("NFKD"); + + let bytes = new TextEncoder().encode(passphrase); + let saltBytes = new TextEncoder().encode(Passphrase._mword + salt); + + let bitLen = 512; // 64 bytes + let iterations = 2048; // BIP-39 specified & easy for an old RPi or old phone + let hashname = "SHA-512"; + let keyAB = await Passphrase._pbkdf2( + bytes, + saltBytes, + iterations, + bitLen, + hashname + ); + + return new Uint8Array(keyAB); + }; + + // same as above, but you provide the bytes + Passphrase._pbkdf2 = async function deriveKey( + bytes, + salt, + iterations, + bitLen, + hashname + ) { + let extractable = false; + + // First, create a PBKDF2 "key" containing the password + let passphraseKey = await crypto.subtle.importKey( + "raw", + bytes, + { name: "PBKDF2" }, + extractable, + ["deriveKey"] + ); + + // Derive a key from the password + extractable = true; + let hmacKey = await crypto.subtle.deriveKey( + { name: "PBKDF2", salt: salt, iterations: iterations, hash: hashname }, + passphraseKey, + { name: "HMAC", hash: hashname, length: bitLen }, // Key we want + extractable, // Extractble + ["sign", "verify"] // For new key + ); + + // Export it so we can display it + let keyAB = await crypto.subtle.exportKey("raw", hmacKey); + return new Uint8Array(keyAB); + }; + + /** + * @param {string} passphrase - Same as from Passphrase.generate(...). + * @param {string} salt - Another passphrase (or whatever) to produce a pairwise key. + * @returns {Uint8Array} - A new pairwise key - the SHA-256 of passphrase + salt. + */ + Passphrase.sha256 = async function (passphrase, salt = "") { + passphrase = Passphrase._normalize(passphrase); + salt = salt.normalize("NFKD"); + + let passBytes = new TextEncoder().encode(passphrase); + let saltBytes = new TextEncoder().encode(salt); + let keyBytes = new Uint8Array(passBytes.length + saltBytes.length); + + // Concat passBytes + saltBytes + let pos = 0; + for (let i = 0; i < passBytes.length; i += 1) { + keyBytes[pos] = passBytes[i]; + pos += 1; + } + for (let i = 0; i < saltBytes.length; i += 1) { + keyBytes[pos] = saltBytes[i]; + pos += 1; + } + + let keyAB = await crypto.subtle.digest("SHA-256", keyBytes); + // convert from abstract buffer to concrete uint8array + return new Uint8Array(keyAB); + }; + + // Copied from https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt + Passphrase.base2048 = + "abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor actress actual adapt add addict address adjust admit adult advance advice aerobic affair afford afraid again age agent agree ahead aim air airport aisle alarm album alcohol alert alien all alley allow almost alone alpha already also alter always amateur amazing among amount amused analyst anchor ancient anger angle angry animal ankle announce annual another answer antenna antique anxiety any apart apology appear apple approve april arch arctic area arena argue arm armed armor army around arrange arrest arrive arrow art artefact artist artwork ask aspect assault asset assist assume asthma athlete atom attack attend attitude attract auction audit august aunt author auto autumn average avocado avoid awake aware away awesome awful awkward axis baby bachelor bacon badge bag balance balcony ball bamboo banana banner bar barely bargain barrel base basic basket battle beach bean beauty because become beef before begin behave behind believe below belt bench benefit best betray better between beyond bicycle bid bike bind biology bird birth bitter black blade blame blanket blast bleak bless blind blood blossom blouse blue blur blush board boat body boil bomb bone bonus book boost border boring borrow boss bottom bounce box boy bracket brain brand brass brave bread breeze brick bridge brief bright bring brisk broccoli broken bronze broom brother brown brush bubble buddy budget buffalo build bulb bulk bullet bundle bunker burden burger burst bus business busy butter buyer buzz cabbage cabin cable cactus cage cake call calm camera camp can canal cancel candy cannon canoe canvas canyon capable capital captain car carbon card cargo carpet carry cart case cash casino castle casual cat catalog catch category cattle caught cause caution cave ceiling celery cement census century cereal certain chair chalk champion change chaos chapter charge chase chat cheap check cheese chef cherry chest chicken chief child chimney choice choose chronic chuckle chunk churn cigar cinnamon circle citizen city civil claim clap clarify claw clay clean clerk clever click client cliff climb clinic clip clock clog close cloth cloud clown club clump cluster clutch coach coast coconut code coffee coil coin collect color column combine come comfort comic common company concert conduct confirm congress connect consider control convince cook cool copper copy coral core corn correct cost cotton couch country couple course cousin cover coyote crack cradle craft cram crane crash crater crawl crazy cream credit creek crew cricket crime crisp critic crop cross crouch crowd crucial cruel cruise crumble crunch crush cry crystal cube culture cup cupboard curious current curtain curve cushion custom cute cycle dad damage damp dance danger daring dash daughter dawn day deal debate debris decade december decide decline decorate decrease deer defense define defy degree delay deliver demand demise denial dentist deny depart depend deposit depth deputy derive describe desert design desk despair destroy detail detect develop device devote diagram dial diamond diary dice diesel diet differ digital dignity dilemma dinner dinosaur direct dirt disagree discover disease dish dismiss disorder display distance divert divide divorce dizzy doctor document dog doll dolphin domain donate donkey donor door dose double dove draft dragon drama drastic draw dream dress drift drill drink drip drive drop drum dry duck dumb dune during dust dutch duty dwarf dynamic eager eagle early earn earth easily east easy echo ecology economy edge edit educate effort egg eight either elbow elder electric elegant element elephant elevator elite else embark embody embrace emerge emotion employ empower empty enable enact end endless endorse enemy energy enforce engage engine enhance enjoy enlist enough enrich enroll ensure enter entire entry envelope episode equal equip era erase erode erosion error erupt escape essay essence estate eternal ethics evidence evil evoke evolve exact example excess exchange excite exclude excuse execute exercise exhaust exhibit exile exist exit exotic expand expect expire explain expose express extend extra eye eyebrow fabric face faculty fade faint faith fall false fame family famous fan fancy fantasy farm fashion fat fatal father fatigue fault favorite feature february federal fee feed feel female fence festival fetch fever few fiber fiction field figure file film filter final find fine finger finish fire firm first fiscal fish fit fitness fix flag flame flash flat flavor flee flight flip float flock floor flower fluid flush fly foam focus fog foil fold follow food foot force forest forget fork fortune forum forward fossil foster found fox fragile frame frequent fresh friend fringe frog front frost frown frozen fruit fuel fun funny furnace fury future gadget gain galaxy gallery game gap garage garbage garden garlic garment gas gasp gate gather gauge gaze general genius genre gentle genuine gesture ghost giant gift giggle ginger giraffe girl give glad glance glare glass glide glimpse globe gloom glory glove glow glue goat goddess gold good goose gorilla gospel gossip govern gown grab grace grain grant grape grass gravity great green grid grief grit grocery group grow grunt guard guess guide guilt guitar gun gym habit hair half hammer hamster hand happy harbor hard harsh harvest hat have hawk hazard head health heart heavy hedgehog height hello helmet help hen hero hidden high hill hint hip hire history hobby hockey hold hole holiday hollow home honey hood hope horn horror horse hospital host hotel hour hover hub huge human humble humor hundred hungry hunt hurdle hurry hurt husband hybrid ice icon idea identify idle ignore ill illegal illness image imitate immense immune impact impose improve impulse inch include income increase index indicate indoor industry infant inflict inform inhale inherit initial inject injury inmate inner innocent input inquiry insane insect inside inspire install intact interest into invest invite involve iron island isolate issue item ivory jacket jaguar jar jazz jealous jeans jelly jewel job join joke journey joy judge juice jump jungle junior junk just kangaroo keen keep ketchup key kick kid kidney kind kingdom kiss kit kitchen kite kitten kiwi knee knife knock know lab label labor ladder lady lake lamp language laptop large later latin laugh laundry lava law lawn lawsuit layer lazy leader leaf learn leave lecture left leg legal legend leisure lemon lend length lens leopard lesson letter level liar liberty library license life lift light like limb limit link lion liquid list little live lizard load loan lobster local lock logic lonely long loop lottery loud lounge love loyal lucky luggage lumber lunar lunch luxury lyrics machine mad magic magnet maid mail main major make mammal man manage mandate mango mansion manual maple marble march margin marine market marriage mask mass master match material math matrix matter maximum maze meadow mean measure meat mechanic medal media melody melt member memory mention menu mercy merge merit merry mesh message metal method middle midnight milk million mimic mind minimum minor minute miracle mirror misery miss mistake mix mixed mixture mobile model modify mom moment monitor monkey monster month moon moral more morning mosquito mother motion motor mountain mouse move movie much muffin mule multiply muscle museum mushroom music must mutual myself mystery myth naive name napkin narrow nasty nation nature near neck need negative neglect neither nephew nerve nest net network neutral never news next nice night noble noise nominee noodle normal north nose notable note nothing notice novel now nuclear number nurse nut oak obey object oblige obscure observe obtain obvious occur ocean october odor off offer office often oil okay old olive olympic omit once one onion online only open opera opinion oppose option orange orbit orchard order ordinary organ orient original orphan ostrich other outdoor outer output outside oval oven over own owner oxygen oyster ozone pact paddle page pair palace palm panda panel panic panther paper parade parent park parrot party pass patch path patient patrol pattern pause pave payment peace peanut pear peasant pelican pen penalty pencil people pepper perfect permit person pet phone photo phrase physical piano picnic picture piece pig pigeon pill pilot pink pioneer pipe pistol pitch pizza place planet plastic plate play please pledge pluck plug plunge poem poet point polar pole police pond pony pool popular portion position possible post potato pottery poverty powder power practice praise predict prefer prepare present pretty prevent price pride primary print priority prison private prize problem process produce profit program project promote proof property prosper protect proud provide public pudding pull pulp pulse pumpkin punch pupil puppy purchase purity purpose purse push put puzzle pyramid quality quantum quarter question quick quit quiz quote rabbit raccoon race rack radar radio rail rain raise rally ramp ranch random range rapid rare rate rather raven raw razor ready real reason rebel rebuild recall receive recipe record recycle reduce reflect reform refuse region regret regular reject relax release relief rely remain remember remind remove render renew rent reopen repair repeat replace report require rescue resemble resist resource response result retire retreat return reunion reveal review reward rhythm rib ribbon rice rich ride ridge rifle right rigid ring riot ripple risk ritual rival river road roast robot robust rocket romance roof rookie room rose rotate rough round route royal rubber rude rug rule run runway rural sad saddle sadness safe sail salad salmon salon salt salute same sample sand satisfy satoshi sauce sausage save say scale scan scare scatter scene scheme school science scissors scorpion scout scrap screen script scrub sea search season seat second secret section security seed seek segment select sell seminar senior sense sentence series service session settle setup seven shadow shaft shallow share shed shell sheriff shield shift shine ship shiver shock shoe shoot shop short shoulder shove shrimp shrug shuffle shy sibling sick side siege sight sign silent silk silly silver similar simple since sing siren sister situate six size skate sketch ski skill skin skirt skull slab slam sleep slender slice slide slight slim slogan slot slow slush small smart smile smoke smooth snack snake snap sniff snow soap soccer social sock soda soft solar soldier solid solution solve someone song soon sorry sort soul sound soup source south space spare spatial spawn speak special speed spell spend sphere spice spider spike spin spirit split spoil sponsor spoon sport spot spray spread spring spy square squeeze squirrel stable stadium staff stage stairs stamp stand start state stay steak steel stem step stereo stick still sting stock stomach stone stool story stove strategy street strike strong struggle student stuff stumble style subject submit subway success such sudden suffer sugar suggest suit summer sun sunny sunset super supply supreme sure surface surge surprise surround survey suspect sustain swallow swamp swap swarm swear sweet swift swim swing switch sword symbol symptom syrup system table tackle tag tail talent talk tank tape target task taste tattoo taxi teach team tell ten tenant tennis tent term test text thank that theme then theory there they thing this thought three thrive throw thumb thunder ticket tide tiger tilt timber time tiny tip tired tissue title toast tobacco today toddler toe together toilet token tomato tomorrow tone tongue tonight tool tooth top topic topple torch tornado tortoise toss total tourist toward tower town toy track trade traffic tragic train transfer trap trash travel tray treat tree trend trial tribe trick trigger trim trip trophy trouble truck true truly trumpet trust truth try tube tuition tumble tuna tunnel turkey turn turtle twelve twenty twice twin twist two type typical ugly umbrella unable unaware uncle uncover under undo unfair unfold unhappy uniform unique unit universe unknown unlock until unusual unveil update upgrade uphold upon upper upset urban urge usage use used useful useless usual utility vacant vacuum vague valid valley valve van vanish vapor various vast vault vehicle velvet vendor venture venue verb verify version very vessel veteran viable vibrant vicious victory video view village vintage violin virtual virus visa visit visual vital vivid vocal voice void volcano volume vote voyage wage wagon wait walk wall walnut want warfare warm warrior wash wasp waste water wave way wealth weapon wear weasel weather web wedding weekend weird welcome west wet whale what wheat wheel when where whip whisper wide width wife wild will win window wine wing wink winner winter wire wisdom wise wish witness wolf woman wonder wood wool word work world worry worth wrap wreck wrestle wrist write wrong yard year yellow you young youth zebra zero zone zoo" + .normalize("NFKD") + .split(" "); +})(); From 0568d7ad9b4ce3dfad98c912942b023b86b5debe Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 2 Oct 2021 08:41:09 +0000 Subject: [PATCH 20/25] bugfix: ignore failed refresh better --- sync.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sync.js b/sync.js index 3bdb2c0..6049753 100644 --- a/sync.js +++ b/sync.js @@ -142,7 +142,7 @@ var Sync = {}; //console.debug("Refresh Token: (may be empty)"); //console.debug(result); - if (result.id_token || result.access_token) { + if (result?.id_token || result?.access_token) { await doStuffWithUser(result).catch(die); return; } From aa3159935ce4181d2dc22c240e62dc4e042013a9 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 2 Oct 2021 09:03:28 +0000 Subject: [PATCH 21/25] security: don't show the full url hash in the logs --- tabs.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tabs.js b/tabs.js index 8420889..edf3f56 100644 --- a/tabs.js +++ b/tabs.js @@ -3,6 +3,9 @@ var Tab = {}; (function () { "use strict"; + let $ = window.$; + let $$ = window.$$; + Tab._init = function () { window.addEventListener("hashchange", Tab._hashChange, false); if ("" !== location.hash.slice(1)) { @@ -26,7 +29,10 @@ var Tab = {}; return; } if (!$$(`[data-ui="${name}"]`).length) { - console.warn("something else took over the hash routing:", name); + console.warn( + "something else took over the hash routing:", + name.slice(0, 10) + "..." + ); return; } From fbafb128af129c8cb8d51c587505aa743f38e6ac Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 2 Oct 2021 09:53:09 +0000 Subject: [PATCH 22/25] bugfix: ignore visibility change during unload --- sync.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/sync.js b/sync.js index 6049753..847b1eb 100644 --- a/sync.js +++ b/sync.js @@ -121,7 +121,7 @@ var Sync = {}; }) .catch(die); let result = await resp.json().catch(die); - console.log('logout result:', result); + console.log("logout result:", result); window.alert("Logged out!"); init(); }); @@ -191,13 +191,12 @@ var Sync = {}; console.warn(err); }); */ + window.document.removeEventListener("visibilitychange", doSync); return ev.returnValue; }; - window.addEventListener("beforeunload", Session._beforeunload); - //window.addEventListener("unload", Session._beforeunload); - window.document.addEventListener("visibilitychange", async function () { + async function doSync() { // fires when user switches tabs, apps, goes to homescreen, etc. if ("hidden" !== document.visibilityState) { return; @@ -209,7 +208,11 @@ var Sync = {}; console.warn("Error during sync down on 'visibilitychange'"); console.warn(err); }); - }); + } + + window.addEventListener("beforeunload", Session._beforeunload); + //window.addEventListener("unload", Session._beforeunload); + window.document.addEventListener("visibilitychange", doSync); Session._syncTimer = setInterval(async function () { console.log("[DEBUG] Interval is working"); From 304bc7af30667eba21b65af3b00e04ed208d3350 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 16 Dec 2021 08:41:43 +0000 Subject: [PATCH 23/25] feat(auth): use localStorage and id_token for persistent cross-domain sessions --- sync.js | 48 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/sync.js b/sync.js index 847b1eb..c10f621 100644 --- a/sync.js +++ b/sync.js @@ -22,8 +22,12 @@ var Sync = {}; getToken: async function () { return Session._token; }, + _getToken: function (token) { + return localStorage.getItem("bliss.session"); + }, _setToken: function (token) { Session._token = token; + localStorage.setItem("bliss.session", token); }, }; @@ -40,13 +44,45 @@ var Sync = {}; } async function attemptRefresh() { - let resp = await window - .fetch(baseUrl + "/api/authn/refresh", { method: "POST" }) - .catch(noop); - if (!resp) { - return; + let reqs = []; + let idToken = Session._getToken(); + // TODO skip if token is known to be expired + if (idToken) { + reqs.push( + window + .fetch(baseUrl + "/api/authn/exchange", { + method: "POST", + headers: { Authorization: `Bearer ${idToken}` }, + }) + .catch(noop) + ); } - return await resp.json().catch(die); + + // TODO skip if origins are not the same (3rd party cookies are dead) + reqs.push( + window + .fetch(baseUrl + "/api/authn/refresh", { method: "POST" }) + .catch(noop) + ); + + let resps = await Promise.all(reqs); + resps = resps.filter(Boolean); + let results = await Promise.all( + resps.map(async function (resp) { + let body = await resp.json().catch(die); + if (body.id_token) { + return body; + } + if (body.access_token) { + if (idToken) { + return { id_token: idToken }; + } + return body; + } + return null; + }) + ); + return results.filter(Boolean).pop(); } async function completeOauth2SignIn(baseUrl, query) { From b6e39f28f4f72cc2e388635d03de1535704e82b0 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 16 Dec 2021 08:44:16 +0000 Subject: [PATCH 24/25] WIP: ROLLBACK, no idea --- TODO.md | 13 +-- ajquery.js | 9 -- auth3000.js | 2 + build.sh | 55 ++++++++++++ deps.sh | 2 - index.html | 141 ++++++++++++++++++++++++++++--- pay-with-credit-card.js | 181 ++++++++++++++++++++++++++++++++++++++++ settings.js | 14 ++++ 8 files changed, 388 insertions(+), 29 deletions(-) delete mode 100644 ajquery.js create mode 100644 build.sh delete mode 100644 deps.sh create mode 100644 pay-with-credit-card.js diff --git a/TODO.md b/TODO.md index 344f562..b903d77 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,7 @@ +Next? + +- [ ] Build Script + What's left? - [x] Completed @@ -15,18 +19,17 @@ What's left? - [x] Out-of-Sync Indicator (`updated > synced_at`) - [x] Manual Sync Button - [x] Count items & bytes - - [ ] BUG: summaries `> summary of thing` are being eaten + - [x] BUG: summaries `> summary of thing` are being eaten - [ ] What to do when the current draft is out of date? (reload indicator?) +- [ ] Release 2 - [ ] Paywall - [ ] Deleted things marked as null - [ ] Deleted things don't count against quotas - [ ] Future - - [ ] Don't save multiple Untitled - [ ] re-key library - - [ ] Per-Post AES Keys - [ ] Fine-tuned refactoring - [ ] Payments - [ ] Paypal - - [ ] anonymous, not stripe + - [ ] NMI anonymous, not stripe - [ ] Apple, Google, Amazon, etc - - [ ] Hash'n'Cache assets + - [ ] Hash'n'Cache / bundle assets (Service Workers?) diff --git a/ajquery.js b/ajquery.js deleted file mode 100644 index b6c903e..0000000 --- a/ajquery.js +++ /dev/null @@ -1,9 +0,0 @@ -/*jshint ignore:start*/ -function $(sel, el) { - return (el || document).querySelector(sel); -} - -function $$(sel, el) { - return (el || document).querySelectorAll(sel); -} -/*jshint ignore:end*/ diff --git a/auth3000.js b/auth3000.js index 5b97a9d..eac84d7 100644 --- a/auth3000.js +++ b/auth3000.js @@ -1,6 +1,8 @@ var Auth3000 = {}; (async function () { + "use strict"; + Auth3000._querystringify = function (options) { return Object.keys(options) .filter(function (key) { diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..e9f5b76 --- /dev/null +++ b/build.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +set -u +set -e +set -x + +# script dependencies +# webi sd + +curl -fsSL https://unpkg.com/ajquery/ajquery.js -o deps/ajquery.js +curl -fsSL https://unpkg.com/xtz/xtz.js -o deps/xtz.js +curl -fsSL https://unpkg.com/@root/debounce -o deps/debouncer.js +curl -fsSL https://unpkg.com/@root/passphrase -o deps/passphrase.js + +rm -- *.min.js + +# create deps.xxxx.min.js +cat \ + deps/ajquery.js \ + deps/xtz.js \ + deps/debouncer.js \ + deps/passphrase.js \ + encoding.js \ + encraption.js \ + > deps.tmp.js + +uglifyjs deps.tmp.js -o deps.min.js +rm deps.tmp.js +my_deps_sum="deps.$(shasum -b deps.min.js | cut -d' ' -f1).min.js" +mv deps.min.js "${my_deps_sum}" + +# create app.xxxx.min.js +node genenv.js .env.prod +cat \ + env.js \ + deps/xtz.js \ + deps/debouncer.js \ + deps/passphrase.js \ + encoding.js \ + encraption.js \ + > deps.tmp.js + +uglifyjs deps.tmp.js -o deps.min.js +rm deps.tmp.js +my_deps_sum="deps.$(shasum -b deps.min.js | cut -d' ' -f1).min.js" +mv deps.min.js "${my_deps_sum}" + +sd -fms '' ' + +' index.html +sd -fms '' " + + + +" index.html diff --git a/deps.sh b/deps.sh deleted file mode 100644 index 941b885..0000000 --- a/deps.sh +++ /dev/null @@ -1,2 +0,0 @@ -curl -fsSL https://unpkg.com/@root/passphrase -o passphrase.js -curl -fsSL https://unpkg.com/@root/debounce -o debouncer.js diff --git a/index.html b/index.html index 634298f..fdeaa64 100644 --- a/index.html +++ b/index.html @@ -28,8 +28,6 @@ --> - -