diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..b903d77 --- /dev/null +++ b/TODO.md @@ -0,0 +1,35 @@ +Next? + +- [ ] Build Script + +What's left? + +- [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 + - [x] Per-Post AES Keys + - [x] Out-of-Sync Indicator (`updated > synced_at`) + - [x] Manual Sync Button + - [x] Count items & bytes + - [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 + - [ ] re-key library + - [ ] Fine-tuned refactoring + - [ ] Payments + - [ ] Paypal + - [ ] NMI anonymous, not stripe + - [ ] Apple, Google, Amazon, etc + - [ ] Hash'n'Cache / bundle assets (Service Workers?) diff --git a/app.js b/app.js index 148f11a..62b5ec6 100644 --- a/app.js +++ b/app.js @@ -1,910 +1,13 @@ -var Post = {}; -var PostModel = {}; - -var Blog = {}; -var BlogModel = {}; - -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"; - // Poor man's dependency tree - // (just so everybody knows what I expect to use in here) - var XTZ = window.XTZ; - var $ = window.$; - var $$ = window.$$; - var localStorage = window.localStorage; - - Blog.serialize = function (ev) { - ev.stopPropagation(); - ev.preventDefault(); - - var $form = ev.target.closest("form"); - var repo = $('input[name="repo"]', $form).value; - var gitbranch = $('input[name="gitbranch"]', $form).value; - var githost = $('select[name="githost"]', $form).value; - var blog = $('select[name="blog"]', $form).value; - - var dirty = false; - try { - new URL(repo); - } catch (e) { - // ignore - // dirty, don't save - dirty = true; - } - - if (dirty || !gitbranch) { - Post.serialize(ev); - return; - } - - var parts = BlogModel._splitRepoBranch(repo, gitbranch); - // TODO doesn't quite feel right - $('input[name="gitbranch"]', $form).value = parts.gitbranch; - if (repo.toLowerCase().startsWith("https://github.com/")) { - githost = "github"; - $('select[name="githost"]', $form).value = githost; - } - $('input[name="repo"]', $form).value = parts.repo; - - BlogModel.save({ - repo: parts.repo, - gitbranch: parts.gitbranch, - githost: githost, - blog: blog, // system (ex: Hugo) - }); - Blog._renderRepoTypeaheads(); - Post.serialize(ev); - }; - - Blog._renderRepoTypeaheads = function () { - $("#-repos").innerHTML = BlogModel.all().map(function (blog) { - var id = blog.repo; - if (blog.gitbranch) { - id += "#" + blog.gitbranch; - } - return Blog._typeaheadTmpl.replace(/{{\s*id\s*}}/, id); - }); - }; - - /** - * - * Post is the View - * - */ - Post.create = function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - - Post._deserialize(PostModel.create().uuid); - Post._renderRows(); - }; - - Post.serialize = function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - - Post._update(PostModel._current); - }; - 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"; - post._repo = ($('input[name="repo"]').value || "").replace(/\/+$/, ""); - post.blog_id = post._repo + "#" + post._gitbranch; - //post.title = $('input[name="title"]').value; - // 2021-07-01T13:59:59 => 2021-07-01T13:59:59-0600 - /* - post.created = XTZ.toUTC( - $('input[name="created"]').value, - timezone - ).toISOString(); - */ - post.updated = XTZ.toTimeZone(new Date(), post.timezone).toISOString(); - - var text = $('textarea[name="content"]').value.trim(); - post.title = PostModel._parseTitle(text); - - // skip the first line of text (which was the title) - var lines = text.split(/[\r\n]/g); - - post.content = lines.slice(1).join("\n").trim(); - // without Title - lines = post.content.split(/[\r\n]/g); - if (lines[0].startsWith(">")) { - // new way - post.description = lines[0].slice(1).trim(); - // don't trim this time (i.e. bad code block) - // TODO check that it starts with alpha - not ``` or - or [link](./), for example - post.content = lines - .slice(1) - .join("\n") - .replace(/^[\n\r]+/, ""); - $('textarea[name="description"]').value = post.description; - } else { - // old way - var inputDescription = $('textarea[name="description"]').value; - if (inputDescription && post.description) { - if (!post._dirtyDescription) { - post._dirtyDescription = post.description !== inputDescription; - } - } else { - post._dirtyDescription = false; - } - if (!post._dirtyDescription) { - post.description = PostModel._parseDescription(post); - } else { - post.description = inputDescription; - } - } - - PostModel.save(post); - }; - - Post.patch = function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - - // Example: - // If the description is empty, let the user have a chance - // to fill in the blank (despite the fact that we set the - // default value and just skip showing it) - if (!ev.target.value) { - PostModel._current[ev.target.name] = ""; - Post._serialize(PostModel._current); - return; - } - - Post._update(PostModel._current); - }; - Post._update = function (post) { - Post._serialize(post); - if (post._previous.title !== post.title) { - var cell = $('input[name="uuid"][value="' + post.uuid + '"]'); - var row = cell.closest("tr"); - row.outerHTML = Post._renderRow(post); - post._previous.title = post.title; - } - Post._rawPreview(post); - }; - - // From DB to form inputs - Post.deserialize = function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - - var parent = ev.target.closest(".js-row"); - var uuid = $('input[name="uuid"]', parent).value; - localStorage.setItem("current", uuid); - // TODO maybe current should have a more precise name, such as currentPost - PostModel._current = Post._deserialize(uuid); - }; - Post._deserialize = function (uuid) { - var post = PostModel.getOrCreate(uuid); - var blog = BlogModel.getByPost(post) || { - // deprecate - repo: post._repo, - githost: post._githost, - gitbranch: post._gitbranch, - blog: post._blog, - }; - if (blog.githost) { - $('select[name="githost"]').value = blog.githost; - } - if (blog.gitbranch) { - $('input[name="gitbranch"]').value = blog.gitbranch; - } - if (blog.blog) { - $('select[name="blog"]').value = blog.blog; - } - $('input[name="repo"]').value = blog.repo; - - //$('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 || ""; - $(".js-undelete").hidden = true; - - Post._rawPreview(post); - return post; - }; - - Post._renderRows = function () { - var uuids = PostModel.ids(); - if (!uuids.length) { - // Create first post ever on first ever page load - // (or after literally everything is deleted) - Post._deserialize(PostModel.create().uuid); - uuids = PostModel.ids(); - } - - var items = uuids - .map(PostModel.getOrCreate) - .sort(function (a, b) { - return new Date(a.updated).valueOf() - new Date(b.updated).valueOf(); - }) - .map(Post._renderRow); - if (!items.length) { - items.push( - Post._rowTmpl - .replace(/ hidden/g, "") - .replace("{{title}}", "Untitled") - .replace("{{uuid}}", "") - .replace( - "{{created}}", - "🗓" + - PostModel._toInputDatetimeLocal(new Date()).replace(/T/g, " ⏰") - ) - .replace( - "{{updated}}", - "🗓" + - PostModel._toInputDatetimeLocal(new Date()).replace(/T/g, " ⏰") - ) - ); - } - $(".js-items").innerHTML = items.join("\n"); - }; - - Post._renderRow = function (post) { - var tmpl = Post._rowTmpl - .replace(/ hidden/g, "") - .replace( - "{{title}}", - post.title.slice(0, 50).replace(/Untitled" - ) - .replace("{{uuid}}", post.uuid) - .replace( - "{{created}}", - "🗓" + - PostModel._toInputDatetimeLocal(post.created).replace(/T/g, "
⏰") - ) - .replace( - "{{updated}}", - "🗓" + - PostModel._toInputDatetimeLocal(post.updated).replace(/T/g, "
⏰") - ); - return tmpl; - }; - - Post.delete = function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - - var q = "Are you sure you want to permanently delete this draft?"; - - var parent = ev.target.closest(".js-row"); - var uuid = $('input[name="uuid"]', parent).value; - - if (!window.confirm(q)) { - return; - } - - if (!$(".js-undelete").hidden) { - // if we're deleting multiple things, we don't want to re-save on delete - Post.serialize(ev); - } - PostModel.delete(uuid); - if (uuid === PostModel._current.uuid) { - // load as a failsafe, just in case - localStorage.removeItem("current", uuid); - localStorage.setItem("current", PostModel.ids()[0]); - } else { - PostModel._current = Post._deserialize(uuid); - } - - Post._renderRows(); - $(".js-undelete").hidden = false; - }; - - Post.undelete = function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - - Post._update(PostModel._current); - $(".js-undelete").hidden = true; - Post._renderRows(); - }; - - Post._rawPreview = function (post) { - post = Post._gitNewFilePreview(post); - post = Post._liveFormPreview(post); - }; - // TODO PostModel - Post._systems = { - /* - * Example: - --- - description: "Change ME to a good search engine-friendly description" - ogimage: 'https://...' - player: 'https://www.youtube.com/embed/XXXXXXXX?rel=0' - youtube: XXXXXXXX - categories: - - Videography - permalink: /articles/CHANGE-ME-SLUG/ - --- - */ - desi: { - pathname: "/posts", - frontmatter: [ - "---", - 'title: "{{title}}"', - 'description: "{{description}}"', - 'timezone: "{{timezone}}"', - 'date: "{{created}}"', - 'updated: "{{updated}}"', - "uuid: {{uuid}}", - "categories:", - " - Web Development", - "permalink: /articles/{{slug}}/", - "---", - ], - }, - hugo: { - pathname: "/content/blog", - frontmatter: [ - "---", - 'title: "{{title}}"', - 'description: "{{description}}"', - 'date: "{{created}}"', - 'timezone: "{{timezone}}"', - //'lastmod: "{{updated}}"', // GitInfo handles this - //"uuid: {{uuid}}", - 'utterances_term: "{{title}}"', - "categories: []", - //" - Web Development", - "---", - ], - }, - bash: { - pathname: "/articles", - frontmatter: [ - // BashBlog has no frontmatter - "{{title}}", - '', - ], - }, - zola: { - pathname: "/content", - // RFC3339 - date: "iso", - frontmatter: [ - // Zola uses TOML frontmatter - "+++", - "title = {{title}}", - "description = {{description}}", - "date = {{created}}", - "updated = {{updated}}", - "draft = false", - "slug = {{slug}}", - "+++", - ], - }, - }; - // TODO auto-upgrade the oldies - Post._systems.eon = Post._systems.hugo; - Post._gitNewFilePreview = function (post) { - var blog = BlogModel.getByPost(post) || { - // deprecate - repo: post._repo, - githost: post._githost, - gitbranch: post._gitbranch, - blog: post._blog, - }; - post.slug = PostModel._toSlug(post.title); - post._filename = post.slug + ".md"; - post._template = ( - Post._systems[blog.blog] || Post._systems.hugo - ).frontmatter.join("\n"); - - // TODO Post._renderFrontmatter - var created = Post._formatFrontmatter( - "created", - post.created, - post._system - ); - var updated = Post._formatFrontmatter( - "updated", - post.updated, - post._system - ); - post._frontMatter = post._template - // TODO loop to make look nicer? - // ['title', 'timezone', 'created', 'updated', ... ] - // str = str.replace(new RegExp('{{'+key+'}}', 'g'), val) - // str = str.replace(new RegExp('"{{'+key+'}}"', 'g'), val) - .replace(/"{{title}}"/g, JSON.stringify(post.title)) - .replace(/{{title}}/g, post.title) - .replace(/"{{description}}"/g, JSON.stringify(post.description)) - .replace(/{{description}}/g, post.description) - .replace(/"{{timezone}}"/g, JSON.stringify(post.timezone)) - .replace(/{{timezone}}/g, post.timezone) - .replace(/"{{created}}"/g, JSON.stringify(created)) - .replace(/{{created}}/g, created) - .replace(/"{{updated}}"/g, JSON.stringify(updated)) - .replace(/{{updated}}/g, updated) - .replace(/"{{uuid}}"/g, JSON.stringify(post.uuid)) - .replace(/{{uuid}}/g, post.uuid) - .replace(/"{{slug}}"/g, JSON.stringify(post.slug)) - .replace(/{{slug}}/g, post.slug); - - if (post._frontMatter.trim()) { - post._filestr = post._frontMatter + "\n\n" + post.content; - } else { - post._filestr = post.content; - } - - Post._addHref(post); - - return post; - }; - Post._formatFrontmatter = function (_key, val, system) { - // 2021-07-01T13:59:59-0600 - // => 2021-07-01 1:59:59 pm - if ("Zola" === system) { - // TODO make this a property of the system, like 'pathname' - return val; - } - var parts = val.split("T"); - var date = parts[0]; - var time = parts[1]; - var times = time.replace(/([-+]\d{4}|Z)$/g, "").split(":"); - var hour = parseInt(times[0], 10) || 0; - var meridian = "am"; - if (hour >= 12) { - hour -= 12; - meridian = "pm"; - times[0] = hour; - } - times[0] = hour; - times[2] = "00"; - // 2021-07-01 + ' ' + 1:59:59 + ' ' + pm - return date + " " + times.join(":") + " " + meridian; - }; - Post._addHref = function (post) { - var blog = BlogModel.getByPost(post) || { - repo: post._repo, - githost: post._githost, - gitbranch: post._gitbranch, - blog: post._blog, - }; - var pathname = (Post._systems[blog.blog] || Post._systems.hugo).pathname; - if (!Post._systems[blog.blog]) { - console.warn( - "Warning: blog system not specified or unsupported, assuming hugo", - blog.blog - ); - } - pathname = encodeURI(pathname); - - // construct href - var href = ""; - var content = encodeURIComponent(post._filestr); - switch (blog.githost) { - case "gitea": - href = - "/_new/" + - blog.gitbranch + - "?filename=" + - pathname + - "/" + - post.slug + - ".md&value=" + - content; - break; - case "github": - /* falls through */ - case "gitlab": - /* falls through */ - default: - href = - "/new/" + - blog.gitbranch + - "?filename=" + - pathname + - "/" + - post.slug + - ".md&value=" + - content; - } - - // issue warnings if needed - switch (blog.githost) { - case "gitea": - break; - case "github": - break; - case "gitlab": - window.alert( - "GitLab doesn't have query param support yet.\n\n" + - "See https://gitlab.com/gitlab-org/gitlab/-/issues/337038" - ); - break; - default: - // TODO log error - console.warn( - "Warning: blog.githost was not specified or unsupported, assuming github", - blog.githost - ); - } - - post._href = post._repo + href; - - return post; - }; - Post._liveFormPreview = function (post) { - if (post._filename && post.content) { - $(".js-preview-container").hidden = false; - $(".js-filename").innerText = post._filename; - $(".js-preview").innerText = post._filestr; - } else { - $(".js-preview-container").hidden = true; - } - - $('textarea[name="description"]').value = post.description; - $(".js-description-length").innerText = post.description.length; - // TODO put colors in variables - if (post.description.length > 155) { - $(".js-description-length").style.color = "#F60208"; - } else if (post.description.length > 125) { - $(".js-description-length").style.color = "#FD9D19"; - } else { - $(".js-description-length").style.removeProperty("color"); - } - - $("span.js-githost").innerText = $( - 'select[name="githost"] option:checked' - ).innerText.split(" ")[0]; - // ex: https://github.com/beyondcodebootcamp/beyondcodebootcamp.com/ - - $("a.js-commit-url").href = post._href; - - $("code.js-raw-url").innerText = $("a.js-commit-url").href; - return post; - }; - - /** - * - * Post is the View - * - */ - - // TODO JSDoc - // https://gist.github.com/NickKelly1/bc372e5993d7b8399d6157d82aea790e - // https://gist.github.com/wmerfalen/73b2ad08324d839e3fe23dac7139b88a - - /** - * @typedef {{ - * title: string; - * slug: string; - * description: string; - * date: Date; - * lastmod: Date; - * }} BlissPost - * - */ - - /** - * @returns {BlissPost} - */ - PostModel.create = function () { - PostModel._current = PostModel.getOrCreate(); - localStorage.setItem("current", PostModel._current.uuid); - PostModel.save(PostModel._current); - return PostModel._current; - }; - - /** - * @param {string} uuid - * @returns {BlissPost} - */ - PostModel.getOrCreate = function (uuid) { - // Meta - var post = JSON.parse( - localStorage.getItem("post." + uuid + ".meta") || "{}" - ); - 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; - } - - // Content - post.content = localStorage.getItem("post." + post.uuid + ".data") || ""; - if (!post.description) { - post.description = PostModel._parseDescription(post); - } - if (!post.title) { - post.title = localStorage.getItem(post.uuid + ".title") || ""; - } - // TODO is there a better way to handle this? - post._previous = { title: post.title }; - - // Blog - // TODO post.blog_id - // TODO BlogsModel.get(post.blog_id) - if (!post._repo) { - post._repo = ""; - } - if (!post._gitbranch) { - post._gitbranch = "main"; - } - - return post; - }; - - PostModel.ids = function () { - return _localStorageGetIds("post.", ".meta"); - }; - - PostModel.save = function (post) { - var all = PostModel.ids(); - if (!all.includes(post.uuid)) { - all.push(post.uuid); - } - - localStorage.setItem( - "post." + post.uuid + ".meta", - JSON.stringify({ - title: post.title, - description: post.description, - uuid: post.uuid, - slug: post.slug, - 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, - }) - ); - localStorage.setItem("post." + post.uuid + ".data", post.content); - }; - - PostModel.delete = function (uuid) { - localStorage.removeItem("post." + uuid + ".meta"); - localStorage.removeItem("post." + uuid + ".content"); - }; - - PostModel._getRandomValues = function (arr) { - var len = arr.byteLength || arr.length; - var i; - for (i = 0; i < len; i += 1) { - arr[i] = Math.round(Math.random() * 255); - } - return arr; - }; - - PostModel._uuid = function () { - var rnd = new Uint8Array(18); - PostModel._getRandomValues(rnd); - var hex = [].slice - .apply(rnd) - .map(function (ch) { - return ch.toString(16); - }) - .join("") - .split(""); - hex[8] = "-"; - hex[13] = "-"; - hex[14] = "4"; - hex[18] = "-"; - hex[19] = (8 + (parseInt(hex[19], 16) % 4)).toString(16); - hex[23] = "-"; - return hex.join(""); - }; - - PostModel._uuid_sep = " "; - - PostModel._toSlug = function (str) { - return str - .toLowerCase() - .replace(/[^a-z0-9]/g, "-") - .replace(/-+/g, "-") - .replace(/^-/g, "") - .replace(/-$/g, "") - .trim(); - }; - - PostModel._toInputDatetimeLocal = function ( - d = new Date(), - tz = new Intl.DateTimeFormat().resolvedOptions().timeZone - ) { - // TODO - // It's quite reasonable that a person may create the post - // in an Eastern state on New York time and later edit the - // same post in a Western state on Mountain Time. - // - // How to we prevent the time from being shifted accidentally? - // - // ditto for updated at - /* - if ("string" === typeof d) { - return d.replace(/([+-]\d{4}|Z)$/, ''); - } - */ - d = new Date(d); - return ( - [ - String(d.getFullYear()), - String(d.getMonth() + 1).padStart(2, "0"), - String(d.getDate()).padStart(2, "0"), - ].join("-") + - "T" + - [ - String(d.getHours()).padStart(2, "0"), - String(d.getMinutes()).padStart(2, "0"), - ].join(":") - ); - }; - - PostModel._parseTitle = function (text) { - // split on newlines and grab the first as title - var title = text - .trim() - .split(/[\r\n]/g)[0] - .trim(); - // "\n\n # #1 Title #2 Article \n\n\n blah blah blah \n blah" - if (title.trim().startsWith("#")) { - title = title.replace(/^#*\s*/, ""); - } - return title; - }; - - PostModel._parseDescription = function (post) { - // 152 is the max recommended length for meta description - const MAX_META_DESC_LEN = 152; - - // Note: content has had the Title stripped by now - // (and this won't even be called if the description was indicated with '>') - var desc = - post.content.split(/[\r\n]/g).filter(function (line) { - // filter spaces, newlines, etc - return line.trim(); - })[0] || ""; - desc = desc.trim().slice(0, MAX_META_DESC_LEN); - if (MAX_META_DESC_LEN === desc.length) { - desc = desc.slice(0, desc.lastIndexOf(" ")); - desc += "..."; - } - return desc; - }; - - /** - * - * Post is the View - * - */ - BlogModel.getByPost = function (post) { - var id = post.blog_id; - // deprecate - if (post._repo) { - id = post._repo.replace(/\/$/, "") + "#" + (post._gitbranch || "main"); - } - return BlogModel.get(id); - }; - BlogModel.get = function (id) { - // repo+#+branch - var json = localStorage.getItem("blog." + id); - if (!json) { - return null; - } - - return JSON.parse(json); - }; - - BlogModel.save = function (blogObj) { - // blog.https://github.com/org/repo#main - var key = "blog." + blogObj.repo + "#" + blogObj.gitbranch; - localStorage.setItem( - key, - JSON.stringify({ - repo: blogObj.repo, - gitbranch: blogObj.gitbranch, - githost: blogObj.githost, - blog: blogObj.blog, // system (ex: Hugo) - }) - ); - }; - - BlogModel.all = function (blogObj) { - return _localStorageGetAll("blog."); - }; - - BlogModel._splitRepoBranch = function (repo, _branch) { - // TODO trim trailing /s - var parts = repo.split("#"); - repo = parts[0].replace(/\/+$/, ""); - var branch = parts[1] || ""; - if (!branch || "undefined" === branch) { - branch = _branch; - } - return { repo: repo, gitbranch: branch }; - }; - - /* - * inits - * - */ - Blog._init = function () { - Blog._typeaheadTmpl = $("#-repos").innerHTML; - Blog._renderRepoTypeaheads(); - // hotfix - BlogModel.all().forEach(function (blog) { - // https://github.com/org/repo (no #branchname) - var parts = BlogModel._splitRepoBranch(blog.repo, blog.gitbranch); - blog.repo = parts.repo; - blog.gitbranch = parts.gitbranch; - if (!blog.gitbranch) { - // TODO delete - } - BlogModel.save(blog); - }); - }; - - Post._init = function () { - // build template strings - Post._rowTmpl = $(".js-row").outerHTML; - $(".js-row").remove(); - - // show all posts - Post._renderRows(); - - // load most recent draft - Post._deserialize(PostModel._current.uuid); - }; - - PostModel._init = function () { - // TODO XXX XXX - PostModel._current = PostModel.getOrCreate(localStorage.getItem("current")); - }; + let $ = window.$; + let Tab = window.Tab; + let PostModel = window.PostModel; + let Post = window.Post; + let Blog = window.Blog; + Tab._init(); PostModel._init(); Post._init(); Blog._init(); @@ -914,7 +17,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 @@ -941,6 +45,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/auth3000.js b/auth3000.js new file mode 100644 index 0000000..eac84d7 --- /dev/null +++ b/auth3000.js @@ -0,0 +1,119 @@ +var Auth3000 = {}; + +(async function () { + "use strict"; + + 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/blog.js b/blog.js new file mode 100644 index 0000000..9396930 --- /dev/null +++ b/blog.js @@ -0,0 +1,1000 @@ +var Post = {}; +var PostModel = {}; + +var Blog = {}; +var BlogModel = {}; + +(async function () { + "use strict"; + + // Poor man's dependency tree + // (just so everybody knows what I expect to use in here) + var XTZ = window.XTZ; + var $ = window.$; + //var $$ = window.$$; + var localStorage = window.localStorage; + + 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; + } + + Blog.serialize = function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + + var $form = ev.target.closest("form"); + var repo = $('input[name="repo"]', $form).value; + var gitbranch = $('input[name="gitbranch"]', $form).value; + var githost = $('select[name="githost"]', $form).value; + var blog = $('select[name="blog"]', $form).value; + + var dirty = false; + try { + new URL(repo); // jshint ignore:line + } catch (e) { + // ignore + // dirty, don't save + dirty = true; + } + + if (dirty || !gitbranch) { + Post.serialize(ev); + return; + } + + var parts = BlogModel._splitRepoBranch(repo, gitbranch); + // TODO doesn't quite feel right + $('input[name="gitbranch"]', $form).value = parts.gitbranch; + if (repo.toLowerCase().startsWith("https://github.com/")) { + githost = "github"; + $('select[name="githost"]', $form).value = githost; + } + $('input[name="repo"]', $form).value = parts.repo; + + BlogModel.save({ + repo: parts.repo, + gitbranch: parts.gitbranch, + githost: githost, + blog: blog, // system (ex: Hugo) + }); + Blog._renderRepoTypeaheads(); + Post.serialize(ev); + }; + + Blog._renderRepoTypeaheads = function () { + $("#-repos").innerHTML = BlogModel.all().map(function (blog) { + var id = blog.repo; + if (blog.gitbranch) { + id += "#" + blog.gitbranch; + } + return Blog._typeaheadTmpl.replace(/{{\s*id\s*}}/, id); + }); + }; + + /** + * + * Post is the View + * + */ + // Hit the New Draft button + Post.create = function (ev) { + 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(); + }; + + // 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 + + // TODO refactor + post._gitbranch = $('input[name="gitbranch"]').value || "main"; + post._repo = ($('input[name="repo"]').value || "").replace(/\/+$/, ""); + post.blog_id = post._repo + "#" + post._gitbranch; + //post.title = $('input[name="title"]').value; + // 2021-07-01T13:59:59 => 2021-07-01T13:59:59-0600 + /* + post.created = XTZ.toUTC( + $('input[name="created"]').value, + timezone + ).toISOString(); + */ + + 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"); + PostModel.delete(post.uuid); + return; + } + + // skip the first line of text (which was the title) + var lines = text.split(/[\r\n]/g); + + post.content = lines.slice(1).join("\n").trim(); + // without Title + lines = post.content.split(/[\r\n]/g); + if (lines[0].startsWith(">")) { + // new way + post.description = lines[0].slice(1).trim(); + // don't trim this time (i.e. bad code block) + // TODO check that it starts with alpha - not ``` or - or [link](./), for example + post.content = lines + .slice(1) + .join("\n") + .replace(/^[\n\r]+/, ""); + } else { + // old way (TODO remove) + if (inputDescription && post.description) { + if (!post._dirtyDescription) { + post._dirtyDescription = post.description !== inputDescription; + } + } else { + post._dirtyDescription = false; + } + if (!post._dirtyDescription) { + post.description = PostModel._parseDescription(post); + } else { + 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); + }; + + Post.patch = function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + + // Example: + // If the description is empty, let the user have a chance + // to fill in the blank (despite the fact that we set the + // default value and just skip showing it) + if (!ev.target.value) { + PostModel._current[ev.target.name] = ""; + Post._serialize(PostModel._current); + return; + } + + Post._update(PostModel._current); + }; + 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 + ) { + 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); + }; + + // From Model to form inputs + Post.deserialize = function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + + var parent = ev.target.closest(".js-row"); + var uuid = $('input[name="uuid"]', parent).value; + localStorage.setItem("current", uuid); + // TODO maybe current should have a more precise name, such as currentPost + PostModel._current = Post._deserialize(uuid); + }; + Post._deserialize = function (uuid) { + var post = PostModel.getOrCreate(uuid); + var blog = BlogModel.getByPost(post) || { + // deprecate + repo: post._repo, + githost: post._githost, + gitbranch: post._gitbranch, + blog: post._blog, + }; + if (blog.githost) { + $('select[name="githost"]').value = blog.githost; + } + if (blog.gitbranch) { + $('input[name="gitbranch"]').value = blog.gitbranch; + } + if (blog.blog) { + $('select[name="blog"]').value = blog.blog; + } + $('input[name="repo"]').value = blog.repo; + + //$('input[name="title"]').value = post.title; + //$('input[name="created"]').value = PostModel._toInputDatetimeLocal(post.created); + 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); + return post; + }; + + Post._renderRows = function () { + var uuids = PostModel.ids(); + if (!uuids.length) { + // Create first post ever on first ever page load + // (or after literally everything is deleted) + Post._deserialize(PostModel.create().uuid); + uuids = PostModel.ids(); + } + + var items = uuids + .map(PostModel.getOrCreate) + .sort(function (a, b) { + return new Date(a.updated).valueOf() - new Date(b.updated).valueOf(); + }) + .map(Post._renderRow); + if (!items.length) { + items.push( + Post._rowTmpl + .replace(/ hidden/g, "") + .replace("{{title}}", "Untitled") + .replace("{{uuid}}", "") + .replace( + "{{created}}", + "🗓" + + PostModel._toInputDatetimeLocal(new Date()).replace(/T/g, " ⏰") + ) + .replace( + "{{updated}}", + "🗓" + + PostModel._toInputDatetimeLocal(new Date()).replace(/T/g, " ⏰") + ) + ); + } + $(".js-items").innerHTML = items.join("\n"); + }; + + Post._renderRow = function (post) { + let needsUpdate = ""; + if (post.sync_version && post.sync_version !== post.updated) { + needsUpdate = "⚠️ 🔄
"; + } + let title = post.title.slice(0, 50).replace(/⏰") + ) + .replace( + "{{updated}}", + "🗓" + + PostModel._toInputDatetimeLocal(post.updated).replace(/T/g, "
⏰") + ); + return tmpl; + }; + + Post.delete = function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + + var q = "Are you sure you want to permanently delete this draft?"; + + var parent = ev.target.closest(".js-row"); + var uuid = $('input[name="uuid"]', parent).value; + + if (!window.confirm(q)) { + return; + } + + if (!$(".js-undelete").hidden) { + // if we're deleting multiple things, we don't want to re-save on delete + Post.serialize(ev); + } + PostModel.delete(uuid); + if (uuid === PostModel._current.uuid) { + // load as a failsafe, just in case + localStorage.removeItem("current", uuid); + localStorage.setItem("current", PostModel.ids()[0]); + } else { + PostModel._current = Post._deserialize(uuid); + } + + Post._renderRows(); + $(".js-undelete").hidden = false; + }; + + Post.undelete = function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + + Post._update(PostModel._current); + $(".js-undelete").hidden = true; + Post._renderRows(); + }; + + Post._rawPreview = function (post) { + post = Post._gitNewFilePreview(post); + post = Post._liveFormPreview(post); + }; + // TODO PostModel + Post._systems = { + /* + * Example: + --- + description: "Change ME to a good search engine-friendly description" + ogimage: 'https://...' + player: 'https://www.youtube.com/embed/XXXXXXXX?rel=0' + youtube: XXXXXXXX + categories: + - Videography + permalink: /articles/CHANGE-ME-SLUG/ + --- + */ + desi: { + pathname: "/posts", + frontmatter: [ + "---", + 'title: "{{title}}"', + 'description: "{{description}}"', + 'timezone: "{{timezone}}"', + 'date: "{{created}}"', + 'updated: "{{updated}}"', + "uuid: {{uuid}}", + "categories:", + " - Web Development", + "permalink: /articles/{{slug}}/", + "---", + ], + }, + hugo: { + pathname: "/content/blog", + frontmatter: [ + "---", + 'title: "{{title}}"', + 'description: "{{description}}"', + 'date: "{{created}}"', + 'timezone: "{{timezone}}"', + //'lastmod: "{{updated}}"', // GitInfo handles this + //"uuid: {{uuid}}", + 'utterances_term: "{{title}}"', + "categories: []", + //" - Web Development", + "---", + ], + }, + bash: { + pathname: "/articles", + frontmatter: [ + // BashBlog has no frontmatter + "{{title}}", + '', + ], + }, + zola: { + pathname: "/content", + // RFC3339 + date: "iso", + frontmatter: [ + // Zola uses TOML frontmatter + "+++", + "title = {{title}}", + "description = {{description}}", + "date = {{created}}", + "updated = {{updated}}", + "draft = false", + "slug = {{slug}}", + "+++", + ], + }, + }; + // TODO auto-upgrade the oldies + Post._systems.eon = Post._systems.hugo; + Post._gitNewFilePreview = function (post) { + var blog = BlogModel.getByPost(post) || { + // deprecate + repo: post._repo, + githost: post._githost, + gitbranch: post._gitbranch, + blog: post._blog, + }; + post.slug = PostModel._toSlug(post.title); + post._filename = post.slug + ".md"; + post._template = ( + Post._systems[blog.blog] || Post._systems.hugo + ).frontmatter.join("\n"); + + // TODO Post._renderFrontmatter + var created = Post._formatFrontmatter( + "created", + post.created, + post._system + ); + var updated = Post._formatFrontmatter( + "updated", + post.updated, + post._system + ); + post._frontMatter = post._template + // TODO loop to make look nicer? + // ['title', 'timezone', 'created', 'updated', ... ] + // str = str.replace(new RegExp('{{'+key+'}}', 'g'), val) + // str = str.replace(new RegExp('"{{'+key+'}}"', 'g'), val) + .replace(/"{{title}}"/g, JSON.stringify(post.title)) + .replace(/{{title}}/g, post.title) + .replace(/"{{description}}"/g, JSON.stringify(post.description)) + .replace(/{{description}}/g, post.description) + .replace(/"{{timezone}}"/g, JSON.stringify(post.timezone)) + .replace(/{{timezone}}/g, post.timezone) + .replace(/"{{created}}"/g, JSON.stringify(created)) + .replace(/{{created}}/g, created) + .replace(/"{{updated}}"/g, JSON.stringify(updated)) + .replace(/{{updated}}/g, updated) + .replace(/"{{uuid}}"/g, JSON.stringify(post.uuid)) + .replace(/{{uuid}}/g, post.uuid) + .replace(/"{{slug}}"/g, JSON.stringify(post.slug)) + .replace(/{{slug}}/g, post.slug); + + if (post._frontMatter.trim()) { + post._filestr = post._frontMatter + "\n\n" + post.content; + } else { + post._filestr = post.content; + } + + Post._addHref(post); + + return post; + }; + Post._formatFrontmatter = function (_key, val, system) { + // 2021-07-01T13:59:59-0600 + // => 2021-07-01 1:59:59 pm + if ("Zola" === system) { + // TODO make this a property of the system, like 'pathname' + return val; + } + var parts = val.split("T"); + var date = parts[0]; + var time = parts[1]; + var times = time.replace(/([-+]\d{4}|Z)$/g, "").split(":"); + var hour = parseInt(times[0], 10) || 0; + var meridian = "am"; + if (hour >= 12) { + hour -= 12; + meridian = "pm"; + times[0] = hour; + } + times[0] = hour; + times[2] = "00"; + // 2021-07-01 + ' ' + 1:59:59 + ' ' + pm + return date + " " + times.join(":") + " " + meridian; + }; + Post._addHref = function (post) { + var blog = BlogModel.getByPost(post) || { + repo: post._repo, + githost: post._githost, + gitbranch: post._gitbranch, + blog: post._blog, + }; + var pathname = (Post._systems[blog.blog] || Post._systems.hugo).pathname; + if (!Post._systems[blog.blog]) { + console.debug( + "Warning: blog system not specified or unsupported, assuming hugo", + blog.blog + ); + } + pathname = encodeURI(pathname); + + // construct href + var href = ""; + var content = encodeURIComponent(post._filestr); + switch (blog.githost) { + case "gitea": + href = + "/_new/" + + blog.gitbranch + + "?filename=" + + pathname + + "/" + + post.slug + + ".md&value=" + + content; + break; + case "github": + /* falls through */ + case "gitlab": + /* falls through */ + default: + href = + "/new/" + + blog.gitbranch + + "?filename=" + + pathname + + "/" + + post.slug + + ".md&value=" + + content; + } + + // issue warnings if needed + switch (blog.githost) { + case "gitea": + break; + case "github": + break; + case "gitlab": + window.alert( + "GitLab doesn't have query param support yet.\n\n" + + "See https://gitlab.com/gitlab-org/gitlab/-/issues/337038" + ); + break; + default: + // TODO log error + console.debug( + "Warning: blog.githost was not specified or unsupported, assuming github", + blog.githost + ); + } + + post._href = post._repo + href; + + return post; + }; + Post._liveFormPreview = function (post) { + if (post._filename && post.content) { + $(".js-preview-container").hidden = false; + $(".js-filename").innerText = post._filename; + $(".js-preview").innerText = post._filestr; + } else { + $(".js-preview-container").hidden = true; + } + + $('textarea[name="description"]').value = post.description; + $(".js-description-length").innerText = post.description.length; + // TODO put colors in variables + if (post.description.length > 155) { + $(".js-description-length").style.color = "#F60208"; + } else if (post.description.length > 125) { + $(".js-description-length").style.color = "#FD9D19"; + } else { + $(".js-description-length").style.removeProperty("color"); + } + + $("span.js-githost").innerText = $( + 'select[name="githost"] option:checked' + ).innerText.split(" ")[0]; + // ex: https://github.com/beyondcodebootcamp/beyondcodebootcamp.com/ + + $("a.js-commit-url").href = post._href; + + $("code.js-raw-url").innerText = $("a.js-commit-url").href; + return post; + }; + + /** + * + * Post is the View + * + */ + + // TODO JSDoc + // https://gist.github.com/NickKelly1/bc372e5993d7b8399d6157d82aea790e + // https://gist.github.com/wmerfalen/73b2ad08324d839e3fe23dac7139b88a + + /** + * @typedef {{ + * title: string; + * slug: string; + * description: string; + * date: Date; + * lastmod: Date; + * }} BlissPost + * + */ + + /** + * @returns {BlissPost} + */ + PostModel.create = function () { + PostModel._current = PostModel.getOrCreate(); + localStorage.setItem("current", PostModel._current.uuid); + PostModel.save(PostModel._current); + 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) { + var post = PostModel.get(uuid) || { content: "" }; + post.uuid = uuid; + + if (!post.description) { + post.description = PostModel._parseDescription(post); + } + 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, + _synced: post.sync_version === post.updated, + }; + + // Blog + // TODO post.blog_id + // TODO BlogsModel.get(post.blog_id) + if (!post._repo) { + post._repo = ""; + } + if (!post._gitbranch) { + post._gitbranch = "main"; + } + + return post; + }; + + /** + * @param {string} uuid + * @returns {BlissPost?} + */ + PostModel.get = function (uuid) { + // Meta + let json = localStorage.getItem("post." + uuid + ".meta"); + if (!json) { + return null; + } + let post = JSON.parse(json); + + // Content + post.content = localStorage.getItem("post." + post.uuid + ".data") || ""; + return post; + }; + + PostModel.ids = function () { + return _localStorageGetIds("post.", ".meta"); + }; + + PostModel.save = function (post) { + // 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(post, ":version:" + d.toISOString()); + }; + + PostModel._save = function (post, version) { + 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, + slug: post.slug, + 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, + sync_version: post.sync_version, + }) + ); + localStorage.setItem("post." + post.uuid + ".data" + version, post.content); + return post; + }; + + PostModel.delete = function (uuid) { + localStorage.removeItem(`post.${uuid}.meta`); + localStorage.removeItem(`post.${uuid}.data`); + }; + + PostModel._getRandomValues = function (arr) { + var len = arr.byteLength || arr.length; + var i; + for (i = 0; i < len; i += 1) { + arr[i] = Math.round(Math.random() * 255); + } + return arr; + }; + + PostModel._uuid = function () { + var rnd = new Uint8Array(18); + PostModel._getRandomValues(rnd); + var hex = [].slice + .apply(rnd) + .map(function (ch) { + return ch.toString(16); + }) + .join("") + .split(""); + hex[8] = "-"; + hex[13] = "-"; + hex[14] = "4"; + hex[18] = "-"; + hex[19] = (8 + (parseInt(hex[19], 16) % 4)).toString(16); + hex[23] = "-"; + return hex.join(""); + }; + + PostModel._uuid_sep = " "; + + PostModel._toSlug = function (str) { + return str + .toLowerCase() + .replace(/[^a-z0-9]/g, "-") + .replace(/-+/g, "-") + .replace(/^-/g, "") + .replace(/-$/g, "") + .trim(); + }; + + PostModel._toInputDatetimeLocal = function ( + d = new Date(), + tz = new Intl.DateTimeFormat().resolvedOptions().timeZone + ) { + // TODO + // It's quite reasonable that a person may create the post + // in an Eastern state on New York time and later edit the + // same post in a Western state on Mountain Time. + // + // How to we prevent the time from being shifted accidentally? + // + // ditto for updated at + /* + if ("string" === typeof d) { + return d.replace(/([+-]\d{4}|Z)$/, ''); + } + */ + d = new Date(d); + return ( + [ + String(d.getFullYear()), + String(d.getMonth() + 1).padStart(2, "0"), + String(d.getDate()).padStart(2, "0"), + ].join("-") + + "T" + + [ + String(d.getHours()).padStart(2, "0"), + String(d.getMinutes()).padStart(2, "0"), + ].join(":") + ); + }; + + PostModel._parseTitle = function (text) { + // split on newlines and grab the first as title + var title = text + .trim() + .split(/[\r\n]/g)[0] + .trim(); + // "\n\n # #1 Title #2 Article \n\n\n blah blah blah \n blah" + if (title.trim().startsWith("#")) { + title = title.replace(/^#*\s*/, ""); + } + return title; + }; + + PostModel._parseDescription = function (post) { + // 152 is the max recommended length for meta description + const MAX_META_DESC_LEN = 152; + + // Note: content has had the Title stripped by now + // (and this won't even be called if the description was indicated with '>') + var desc = + post.content.split(/[\r\n]/g).filter(function (line) { + // filter spaces, newlines, etc + return line.trim(); + })[0] || ""; + desc = desc.trim().slice(0, MAX_META_DESC_LEN); + if (MAX_META_DESC_LEN === desc.length) { + desc = desc.slice(0, desc.lastIndexOf(" ")); + desc += "..."; + } + return desc; + }; + + /** + * + * Post is the View + * + */ + BlogModel.getByPost = function (post) { + var id = post.blog_id; + // deprecate + if (post._repo) { + id = post._repo.replace(/\/$/, "") + "#" + (post._gitbranch || "main"); + } + return BlogModel.get(id); + }; + BlogModel.get = function (id) { + // repo+#+branch + var json = localStorage.getItem("blog." + id); + if (!json) { + return null; + } + + return JSON.parse(json); + }; + + BlogModel.save = function (blogObj) { + // blog.https://github.com/org/repo#main + var key = "blog." + blogObj.repo + "#" + blogObj.gitbranch; + localStorage.setItem( + key, + JSON.stringify({ + repo: blogObj.repo, + gitbranch: blogObj.gitbranch, + githost: blogObj.githost, + blog: blogObj.blog, // system (ex: Hugo) + }) + ); + }; + + BlogModel.all = function (blogObj) { + return _localStorageGetAll("blog."); + }; + + BlogModel._splitRepoBranch = function (repo, _branch) { + // TODO trim trailing /s + var parts = repo.split("#"); + repo = parts[0].replace(/\/+$/, ""); + var branch = parts[1] || ""; + if (!branch || "undefined" === branch) { + branch = _branch; + } + return { repo: repo, gitbranch: branch }; + }; + + /* + * inits + * + */ + Blog._init = function () { + Blog._typeaheadTmpl = $("#-repos").innerHTML; + Blog._renderRepoTypeaheads(); + // hotfix + BlogModel.all().forEach(function (blog) { + // https://github.com/org/repo (no #branchname) + var parts = BlogModel._splitRepoBranch(blog.repo, blog.gitbranch); + blog.repo = parts.repo; + blog.gitbranch = parts.gitbranch; + if (!blog.gitbranch) { + // TODO delete + } + BlogModel.save(blog); + }); + }; + + Post._init = function () { + // build template strings + Post._rowTmpl = $(".js-row").outerHTML; + $(".js-row").remove(); + + // show all posts + Post._renderRows(); + + // load most recent draft + Post._deserialize(PostModel._current.uuid); + }; + + PostModel._init = function () { + // TODO XXX XXX + PostModel._current = PostModel.getOrCreate(localStorage.getItem("current")); + }; +})(); diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..540569e --- /dev/null +++ b/build.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +set -u +set -e +set -x + +# script dependencies +# webi sd + +# TODO mvp.css +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/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 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(" "); +})(); diff --git a/encoding.js b/encoding.js new file mode 100644 index 0000000..8fdacf9 --- /dev/null +++ b/encoding.js @@ -0,0 +1,24 @@ +var Encoding = {}; + +(function () { + "use strict"; + + // TODO update unibabel.js + 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/encraption.js b/encraption.js new file mode 100644 index 0000000..071f911 --- /dev/null +++ b/encraption.js @@ -0,0 +1,112 @@ +var Encraption = {}; + +(function () { + "use strict"; + + let Encoding = window.Encoding; + + Encraption.importKeyBytes = async function importKey(keyU8) { + let crypto = window.crypto; + let usages = ["encrypt", "decrypt"]; + let extractable = false; + + return await crypto.subtle.importKey( + "raw", + 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 + + 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 29e94db..fdeaa64 100644 --- a/index.html +++ b/index.html @@ -28,9 +28,10 @@ --> - - @@ -59,11 +75,15 @@

Bliss: Blog, Easy As Gist

@@ -79,7 +99,7 @@

Bliss: Blog, Easy As Gist


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

Bliss: Blog, Easy As Gist

+ +
@@ -275,6 +299,196 @@

Bliss: Blog, Easy As Gist

+
+
+ + + + + +
+
+
+
+

Stats & Usage Tier

+ + + + +

Upgrade

+ Just $10 per year! +
+ (that's less than $1 per month!) +
+ +
+ +
+ + Check out with PayPal +
+ + + + + +
+ + +
+ Go "Pro" and get up to 50mb of storage for documents up to 200kb + each, and 5,000 syncs per month. + +
+
+
+ + + + + + + + + + + + + + + + + + + + + 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/pay-with-credit-card.js b/pay-with-credit-card.js new file mode 100644 index 0000000..a7a2fc3 --- /dev/null +++ b/pay-with-credit-card.js @@ -0,0 +1,181 @@ +var CC = {}; +window.CC = CC; + +(function () { + "use strict"; + var sep = " "; + + // 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; + + // 4242-4242-4242-4242 + function formatVisa(digits, pos) { + var all = ""; + all += digits.slice(0, 4); + if (all.length < 4) { + return [all, pos]; + } + if (pos >= all.length) { + pos += 1; + } + all += sep; + + all += digits.slice(4, 8); + if (all.length < 9) { + return [all, pos]; + } + if (pos >= all.length) { + pos += 1; + } + all += sep; + + all += digits.slice(8, 12); + if (all.length < 14) { + return [all, pos]; + } + if (pos >= all.length) { + pos += 1; + } + all += sep; + + all += digits.slice(12); + return [all, pos]; + } + + // 37xx-654321-54321 + function formatAmex(digits, pos) { + var all = ""; + all += digits.slice(0, 4); + if (all.length < 4) { + return [all, pos]; + } + if (pos >= all.length) { + pos += 1; + } + all += sep; + + all += digits.slice(4, 10); + if (all.length < 11) { + return [all, pos]; + } + if (pos >= all.length) { + pos += 1; + } + all += sep; + + all += digits.slice(10); + return [all, pos]; + } + + CC.formatCardDigits = function (ev) { + // TODO handle backspace + var $cc = ev.target; + var digits = $cc.value; + var pos = $cc.selectionStart; + // 1: 4242-4242-42| + // 2: 4242-424|2-42 // 8 + // 3: 4242-4244|2-42 // 9 + // 4: 4242-4244-|242 // 10 + + // check the character before the cursor + // (ostensibly the character that was just typed) + // assuming a left-to-right writing system + // 4242-| + if (!/[0-9]/.test(ev.key)) { + return; + } + + var parts = CC._formatCardDigits(digits, pos); + $cc.value = parts[0]; + $cc.selectionStart = parts[1]; + $cc.selectionEnd = $cc.selectionStart; + }; + + CC._formatCardDigits = function (raw, pos = 0) { + var digits = ""; + + // rework position + var j = pos; + var i; + for (i = 0; i < raw.length; i += 1) { + if (raw[i].match(/[0-9]/)) { + digits += raw[i]; + continue; + } + if (i < j) { + pos -= 1; + } + } + + // https://stevemorse.org/ssn/List_of_Bank_Identification_Numbers.html + //var reVisa = /^4/; + //var reMastercard = /^5[1-5]/; + var reAmex = /^37/; + var parts; + if (reAmex.test(digits)) { + parts = formatAmex(digits, pos); + } else { + parts = formatVisa(digits, pos); + } + return parts; + }; + + CC.pay = async function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + + var $payForm = ev.target; + var $digits = $payForm.querySelector('[name="cc-digits"]'); + var card = { + name: $payForm.querySelector('[name="cc-name"]').value, + email: $payForm.querySelector('[name="cc-email"]').value, + digits: CC._formatCardDigits($digits.value)[0], + mm: $payForm.querySelector('[name="cc-mm"]').value, + yyyy: $payForm.querySelector('[name="cc-yyyy"]').value, + code: $payForm.querySelector('[name="cc-code"]').value, + }; + + $digits.value = card.digits; + + await CC._pay(card) + .then(async function (resp) { + console.log(resp); + window.alert("Thanks!"); + }) + .catch(function (err) { + console.error(err); + window.alert("Oops! Trouble! (see console)"); + }); + }; + CC._pay = async function (card) { + let resp = await window + .fetch(baseUrl + "/api/pay/FAIL", { + method: "POST", + body: JSON.stringify(card), + headers: { + //Authorization: query.access_token, + "Content-Type": "application/json", + }, + }) + .then(async function (resp) { + let body = await resp.json(); + resp.data = body; + if (!resp.ok) { + let err = new Error(body.message || "unknown error"); + err.code = body.code; + err.status = body.status; + err.detail = body.detail; + err.response = resp; + throw err; + } + return resp; + }); + return resp; + }; +})(); diff --git a/settings.js b/settings.js new file mode 100644 index 0000000..87f4aac --- /dev/null +++ b/settings.js @@ -0,0 +1,124 @@ +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.setSubscribe = function (ev) { + let name = ev.target.name; + let value = ev.target + .closest("form") + .querySelector(`[name="${name}"][type="radio"]:checked`).value; + if ("subscribe" === value) { + $("[data-checkout]").href = + "/api/redirects/paypal-checkout/subscribe/bliss-pro-yearly"; + } else { + $("[data-checkout]").href = + "/api/redirects/paypal-checkout/order/bliss-pro-yearly"; + } + }; + + 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(); +})(); diff --git a/sync.js b/sync.js new file mode 100644 index 0000000..c10f621 --- /dev/null +++ b/sync.js @@ -0,0 +1,722 @@ +var Sync = {}; + +(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 $$ = window.$$; + let Encoding = window.Encoding; + let Encraption = window.Encraption; + let Debouncer = window.Debouncer; + let Settings = window.Settings; + let Session = { + 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); + }, + }; + + function noop() {} + + function die(err) { + console.error(err); + window.alert( + "Oops! There was an unexpected error.\nIt's not your fault.\n\n" + + "Technical Details for Tech Support: \n" + + err.message + ); + throw err; + } + + async function attemptRefresh() { + 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) + ); + } + + // 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) { + 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-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", + 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(); + + window.removeEventListener("beforeunload", Session._beforeunload); + //window.removeEventListener("unload", Session._beforeunload); + clearInterval(Session._syncTimer); + + let resp = await window + .fetch(baseUrl + "/api/authn/session", { + method: "DELETE", + }) + .catch(die); + let result = await resp.json().catch(die); + console.log("logout result:", 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-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-authenticated").forEach(function ($el) { + $el.hidden = false; + }); + + let token = result.id_token || result.access_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); + }); + */ + window.document.removeEventListener("visibilitychange", doSync); + + return ev.returnValue; + }; + + async function doSync() { + // 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); + }); + } + + 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"); + + // 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(); + } + + PostModel._syncHook = async function __syncHook(post) { + let token = await Session.getToken(); + let key2048 = getKey2048(); + + 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); + + let data = item.data; + + let syncedPost = await Encraption.decrypt64(data.encrypted, postKey).catch( + function (err) { + err.data = data; + throw err; + } + ); + return syncedPost; + } + + async function docCreate(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({}), + }), + }); + 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 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(); + if (!body.uuid || !new Date(body.updated_at).valueOf()) { + // TODO + throw new Error("Sync was not successful"); + } + return body; + } + + async function _syncPost(token, key2048, post) { + post._type = "post"; + + if (!post.sync_id) { + let item = await docCreate(token); + post.sync_id = item.uuid; + PostModel.save(post); + } + + 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(); + + let usage = await docUpdate(token, postKey, post); + Settings.setUsage(usage); + + post.sync_version = post.updated; + PostModel.save(post); + } + + function showError(err) { + console.error(err); + window.alert("that's not a valid key"); + } + + function getLastDown() { + // double parsing date to guarantee a valid date or the zero date + return new Date( + new Date(localStorage.getItem("bliss:last-sync-down")).valueOf() || 0 + ); + } + + function getLastUp() { + return new Date( + new Date(localStorage.getItem("bliss:last-sync-up")).valueOf() || 0 + ); + } + + 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; + } + + // 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(), { + method: "GET", + headers: { + Authorization: "Bearer " + token, + }, + }) + .catch(die); + let usage = await resp.json().catch(die); + Settings.setUsage(usage); + + //console.debug("Docs:"); + //console.debug(docs); + + // 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() && !usage.docs.length) { + // this is a new account on its first computer + // gen 128-bit key + if (!key2048) { + let keyBytes = crypto.getRandomValues(new Uint8Array(16)); + let key64 = Encoding.bufferToBase64(keyBytes); + key2048 = await Passphrase.encode(keyBytes); + localStorage.setItem("bliss:enc-key", key64); + } + + await syncTemplatePost(token, key2048); + + // shouldn't be possible to be called thrice but... + // just in case... + if (!_cannotReDo) { + return await syncDown(true); + } + throw new Error("impossible condition: empty content after first sync"); + } + + if (!key2048) { + key2048 = await askForKey2048(); + } + + await updateLocal(usage.docs); + + // TODO make public or make... different + Post._renderRows(); + } + + async function updateLocal(docs) { + let lastSyncDown = getLastDown(); + + // poor man's forEachAsync + await docs.reduce(async function (promise, item) { + await promise; + + try { + // because this is double stringified (for now) + item.data = JSON.parse(item.data); + } catch (e) { + e.data = item.data; + console.warn(e); + return; + } + + 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 (remotePost._type && "post" !== remotePost._type) { + console.warn("couldn't handle type", remotePost._type); + console.warn(remotePost); + return; + } + + 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); + } + + // `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; + } + + 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 docs 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()); + } + + 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. + +(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! 🥳`, + }; + + // 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 + 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) { + // 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; + }) + .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) { + console.error(err); + window.alert(`Fatal Error: ${err.message}`); + }); +})(); diff --git a/tabs.js b/tabs.js new file mode 100644 index 0000000..edf3f56 --- /dev/null +++ b/tabs.js @@ -0,0 +1,66 @@ +var Tab = {}; + +(function () { + "use strict"; + + let $ = window.$; + let $$ = window.$$; + + 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.slice(0, 10) + "..." + ); + 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}`; + }); + }; +})();