Semi-Open Heart Protocol
Intro
About a week ago, the Open Heart Protocol made its way to the front page of Hacker News. I immediately thought that this looked like a lot of fun and a cool opportunity to add some more bells and whistles to this website. There is a public API, but the devs (muan and robb) also provide some code examples to self-host the backend using the Cloudflare Workers free tier. Since I am already using Cloudflare for this website, I figured I'd give this a try. Surprising absolutely no-one, I had to add some slight customizations to my setup.
One of the top comments on the HN thread is weary of abuse of the openness of the Open Heart protocol:
It seems nice but every single time I see service allowing anonymous uploads like such Iām thinking immediately: criminal use.
A brief analysis of the proposed protocol and of some existing implementations seem to agree with this at least in the abstract. While I really don't think that anyone would use a small, open API like this criminally, if we allow fully open access to our Open Heart server, we are essentially creating a free store of arbitrary strings (anything behind the /?id
part of the URL essentially becomes the database key for the emoji count), for which I am sure someone could come up with a way of creatively abusing it. Thus, for my implementation of the protocol, I wanted to lock down my server a little bit more.
What I came up with was this: My Open Heart backend is to be used exclusively for webpages on my server. I thus have to somehow double check in my worker whether a specific URL is valid or not. I do this by generating a machine-readable (JSON) sitemap as part of my org-mode publishing process. For every Open Heart POST
request to my backend, my worker then first queries this sitemap file and only increments the emoji counter if the URL in the request is found in the sitemap file. This restricts the set of possible URLs to only those that I allow in the sitemap. I also added a few other restrictions, e.g, I only allowing a specific subset of emoji, and I limit the type of HTTP requests the backend accepts.
To integrate the protocol into a personal website, the OpenHeart devs created open-heart-element
, which appears to be a web component written in TypeScript. However, since I wanted a little more control over the behavior and appearance of the buttons and since I didn't want to add a TypeScript dependency to my build system, I decided to implement my own buttons with standard HTML and some JavaScript.11tbh, this was my first non-trivial JavaScript project, both on the frontend and the backend, so please give me some grace…
The end result of all of this is that you should see a row of clickable emoji reactions next to the "Published: …" subheader underneath the title. If you click one, you are sending me an emoji response to my writing. No strings attached, no other purpose, just a neat little nudge22who remembers early Facebook poke wars? via the Internet. I love it, I wish I'd see it on many more websites, and I had a lot of fun implementing it. The rest of the post gives some insight into the gory details.
Static site generator changes
To generate the machine-readable JSON sitemap, I hook into the publishing process that I describe in detail in this post. I basically add some code to the :completion-function
which appends the URL and the date of modification for each page of the site to a lisp data structure on disk during the publishing process. At the very end of the publishing process, I then take this lisp data and generate the machine-readable sitemap. The functions that I use to append to the lisp data on disk look like this:
;; helpers for machine-readable sitemap (defvar website-sitemap-data-file (file-name-concat my-website-pages-dir "sitemap-data.el") "Path to the Emacs Lisp data file containing the input data to generate a machine-redable sitemap file") (defun load-sitemap-data () "Return the sitemap data stored on disk." (when (file-exists-p website-sitemap-data-file) (with-temp-buffer (insert-file-contents website-sitemap-data-file) (goto-char (point-min)) (read (current-buffer))))) (defun write-sitemap-data (sitemap-data) "Write sitemap data to disk." (with-temp-file website-sitemap-data-file (prin1 sitemap-data (current-buffer)))) (defun append-to-sitemap (filenames project-plist) "This function should be called as part or instead of the `:sitemap-function' during publishing. It appends to the data file `website-sitemap-data-file', which then generates machine-readable sitemap files in `website-publish-sitemaps'." ;; read any previous data (let ((sitemap-data (load-sitemap-data)) (project-base-dir (org-publish-property :base-directory project-plist))) ;; add all pages to the sitemap according to my schema (write-sitemap-data (append sitemap-data (mapcar (lambda (file) (let* ((abspath (file-name-concat project-base-dir file)) (relpath (file-relative-name abspath my-website-base-dir)) (url (file-name-concat my-website-base-url (file-name-sans-extension relpath))) (date (org-publish-find-date file project-plist))) ;; n.b.: if I wanted to, I could save other metadata here as well `((url . ,url) (lastmod . ,date)))) filenames)))))
I then call append-to-sitemap
in the :completion-function
of each subproject roughly like this:
(append-to-sitemap (org-publish-get-base-files `("blog-articles" . ,project-plist)) project-plist)
This is a little bit of a hack, since I need to massage the project-plist
argument to the :completion-function
to get accepted by org-publish-get-base-files
, but that's ok. There is another wrinkle in that I need to manually append the files in my Emacs configuration to the sitemap, because those pages are not generated through the same process as the website. But for now, I hacked all of it together so that it just works.
Another interesting aspect is that now that I am generating machine-readable sitemap files, I can also generate an XML sitemap file that is friendly to Google's indexer. The code that generates the JSON sitemap as well as the XML sitemap looks like this:
(defun publish-xml-sitemap (sitemap-data filename pub-dir) "Publish a sitemap.xml file according to the spec defined in the Sitemap protocol [1]. [1] https://www.sitemaps.org/protocol.html" (with-temp-buffer (insert "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n") (insert "<urlset xmlns=\"https://www.sitemaps.org/schemas/sitemap/0.9\">\n") (mapcar (lambda (site) (let ((lastmod (format-time-string "%Y-%m-%d" (alist-get 'lastmod site))) (url (alist-get 'url site))) (insert "<url>\n") (insert (format "<loc>%s</loc>\n" url)) (insert (format "<lastmod>%s</lastmod>\n" lastmod)) (insert "</url>\n"))) sitemap-data) (insert "</urlset>\n") (sgml-mode) (indent-region (point-min) (point-max)) (write-file (file-name-concat pub-dir filename)))) (defun publish-json-sitemap (sitemap-data filename pub-dir) "Publish a JSON sitemap according to a simple JSON format" (unless (json-available-p) (error "JSON not available!")) (with-temp-buffer (let ((json-data (mapcar (lambda (site) (list (assoc 'url site) (cons 'lastmod (format-time-string "%Y-%m-%d" (alist-get 'lastmod site))))) sitemap-data))) (insert (json-encode json-data)) (write-file (file-name-concat pub-dir filename))))) (defun website-publish-sitemaps (plist filename pub-dir) "Publish machine-readable sitemaps based on the data in `website-sitemap-data-file'." (let ((sitemap-data (sort (load-sitemap-data) (lambda (a b) ; sort the list by time (time-less-p (alist-get 'lastmod a) (alist-get 'lastmod b)))))) (publish-xml-sitemap sitemap-data "sitemap.xml" pub-dir) (publish-json-sitemap sitemap-data "sitemap.json" pub-dir)) ;; write a robots.txt that just contains the XML sitemap (with-temp-buffer (insert "Sitemap: ") (insert (file-name-concat my-website-base-url "sitemap.xml\n")) (write-file (file-name-concat pub-dir "robots.txt"))))
To get this going as part of the publishing process, I added the following to my org-publish-project-alist
:
("blog-sitemap" :base-directory ,my-website-pages-dir :exclude "*" :include (,website-sitemap-data-file) :publishing-directory ,my-website-out-dir :publishing-function website-publish-sitemaps)
Backend Code
As mentioned earlier, my backend is a Cloudflare Worker, written in JavaScript. This is my first time experimenting with a serverless platform, my first time writing backend JavaScript, and my first time writing any non-trivial JavaScript in general. Overall, I was impressed with how smooth these things seem to be going š. Everything just works and JavaScript is an incredibly fun language to work with.33Some of you may know, my day job is split between super-low-level C/C++/assembler and super-high-level Python and MATLAB…
The code is listed below. It is essentially a reinterpretation of muan's example, with the aforementioned extra checks and restrictions added. I kept the use of Cloudflare's KV storage as backend "database" of the individual emoji counts. I also added a landing page similar to what I saw in benji's backend implementation.
// landing page template import landing from "landing.html"; // allowed emojis const allowed = ["❤️", "😂", "🚀", "🏂", "👎"]; export default { async fetch(request, env, ctx) { // try to get destination URL const url = new URL(request.url); let dest_url = url.searchParams.get("id"); if (url.pathname == "/" && !dest_url) { return new Response(landing.replaceAll("{{EMOJI}}", `[${allowed}]`), {headers: { "content-type": "text/html"}}); } // set the Access-Control-Allow-Origin header let origin = null; if (request.headers.has("Origin")) { console.log(request.headers.get("Origin")); // restrict to my websites const org_hdr = request.headers.get("Origin"); const org = new URL(org_hdr); if (!(["staging.ogbe.net", "ogbe.net", "dash.cloudflare.com"].includes(org.host))) { return this.errorResponse("bad origin header", 400); } origin = org_hdr; } // also allow from my staging (debug) environment try { let dst = new URL(dest_url); if (dst.host == "staging.ogbe.net") { dst.host = "ogbe.net"; dest_url = dst.toString(); } } catch (err) { return this.errorResponse("couldn't parse destination url", 400, origin); } // check the URL against my sitemap const sitemap_response = await fetch("https://ogbe.net/sitemap.json"); const sitemap = await sitemap_response.json(); const found = sitemap.find(elem => elem["url"] === dest_url); if (!found) { return this.errorResponse("destination url not found", 400, origin); } if (!((request.method == "GET") || (request.method == "POST"))) { return this.errorResponse("bad request: only get and post allowed", 400, origin); } // list the count of all emoji on GET request if (request.method == "GET") { let resp = {}; for (let emoji of allowed) { try { resp[emoji] = await this.getCount(env, dest_url, emoji); } catch (err) { console.error(`KV returned error: ${err}`); return this.errorResponse(err.toString(), 500, origin); } } return this.successfulResponse(resp, origin); } // increment the count of one emoji on properly-formatted POST request if (request.method == "POST") { // check the payload const payload = this.ensureEmoji(await request.text()); if (!payload || !allowed.includes(payload)) { return this.errorResponse(`Supported emoji: ${allowed}`, 400, origin); } // increment the count and write to KV database try { let currentCount = await this.getCount(env, dest_url, payload); await env.HEART_COUNT.put(this.makeKey(dest_url, payload), currentCount + 1); return this.successfulResponse({[payload]: currentCount + 1}, origin); } catch (err) { console.error(`KV returned error: ${err}`); return this.errorResponse(err.toString(), 500, origin); } } }, makeKey(url, emoji) { return `${url}:${emoji}`; }, async getCount(env, url, emoji) { return Number(await(env.HEART_COUNT.get(this.makeKey(url, emoji))) || 0); }, // stolen from https://gist.github.com/muan/388430d0ed03c55662e72bb98ff28f03 ensureEmoji(emoji) { const segments = Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(emoji.trim())) const parsedEmoji = segments.length > 0 ? segments[0].segment : null if (/\p{Emoji}/u.test(parsedEmoji)) return parsedEmoji }, successfulResponse(obj, origin) { let hdrs = {"content-type": "application/json"}; if (origin) { hdrs["Access-Control-Allow-Origin"] = origin; } return new Response(JSON.stringify(obj), {headers: hdrs, status: 200}); }, errorResponse(err, code, origin) { let hdrs = {"content-type": "application/json"}; if (origin) { hdrs["Access-Control-Allow-Origin"] = origin; } return new Response(JSON.stringify({error: err}), {headers: hdrs, status: code}); } };
Client Code
On the client side, I decided not to use the example open-heart-element
web component and went with my own HTML button
-based implementation instead. All I am doing here is adding a set of buttons to the reactions
div after querying the backend for the emoji count for the current page. Each button sends the OpenHeart POST
request when clicked and if everything goes right, disables itself. Just like in the original, I use the localStorage
API to store whether this browser has liked a page already or not.
// heart buttons -------------------------------------------------------------- // inspired by https://github.com/dddddddddzzzz/open-heart-element/blob/main/src/index.ts // the API URL for this page const heart_api_url = "https://heart.ogbe.net/?id=" + document.URL; // the key to use for open heart related local storage const heart_local_key = "_open_heart"; // get a count of all emoji async function heart_get_emoji_count() { let resp = await fetch(heart_api_url, {headers: {"Accept": "application/json"}}); if (!resp.ok) { throw new Error("Issue in retrieving count."); } return await resp.json(); } // send an emoji to the backend. returns updated count for a single emoji. async function heart_send_emoji(emoji) { let resp = await fetch(heart_api_url, {headers: {"Accept": "application/json"}, method: "post", body: emoji}); if (!resp.ok) { throw new Error("Issue in updating emoji count"); } let resp_obj = await resp.json(); return resp_obj[emoji]; } // use localStorage to save whether this browser has reacted to this page with this emoji before function heart_make_key(emoji) { return `${emoji}@${encodeURIComponent(document.URL)}`; } function heart_check_for_reaction(emoji) { const reactions = (localStorage.getItem(heart_local_key) || "").split(","); return reactions.includes(heart_make_key(emoji)); } function heart_save_reaction(emoji) { const reactions = (localStorage.getItem(heart_local_key) || "").split(",").filter(s => s); reactions.push(heart_make_key(emoji)); localStorage.setItem(heart_local_key, reactions.join(",")); } function heart_add_reaction_button(emoji, count, div) { // make a new reaction button for this emoji let button = document.createElement("button"); button.textContent = count > 0 ? emoji + ` : ${count}` : emoji; button.setAttribute("emoji", emoji); if (heart_check_for_reaction(emoji)) { // if we have already reacted with this emoji, set the "disabled" attribute button.setAttribute("disabled", "true"); button.setAttribute("title", `You already added the ${emoji} reaction to this post`) } else { button.setAttribute("title", `Click to add the ${emoji} reaction to this post`) // send an emoji on click and update the count for this button button.onclick = async function(event) { let button = event.target; try { // increment the count button.setAttribute("aria-busy", "true"); let count = await heart_send_emoji(button.getAttribute("emoji")); button.textContent = emoji + ` : ${count}`; // save the fact that we incremented heart_save_reaction(emoji); button.setAttribute("aria-busy", "false"); // disable the button button.setAttribute("aria-pressed", "true"); button.setAttribute("disabled", "true"); button.setAttribute("title", `You already added the ${emoji} reaction to this post`) button.onclick = null; } catch (err) { console.log(err.toString()); } } } // add the button to some div div.appendChild(button); } document.addEventListener("DOMContentLoaded", async function (event) { // if a div with id "reactions" exist, try to add the reaction emoji buttons to it let reaction_div = document.getElementById("reactions"); if (reaction_div) { try { // get the count for all emojis. this may fail if the website is emoji_count = await heart_get_emoji_count(); // add a button for some emoji const reaction_emoji = ["❤️", "😂", "🚀", "🏂", "👎"]; for (let emoji of reaction_emoji) { heart_add_reaction_button(emoji, emoji_count[emoji], reaction_div); } } catch (err) { console.log(`Failed to add emoji reactions: ${err}`); } } });
Styling
I updated my CSS stylesheet in an attempt to make the new buttons fit in with the rest of the color scheme of the site. For this, I had to learn a few concepts about the different CSS selectors, for example, how to distinguish a disabled from an active button. I quite like the result.
#reactions { button { background-color: var(--main-bg-color); color: var(--main-fg-color); margin-right: 0.4rem; border-color: var(--main-fg-color); border-style: solid; border-width: 1px; border-radius: 4px; padding: 0.2rem; } button:not([disabled~=true]):hover, button[aria-busy~=true] { background-color: var(--code-bg-color); transition-duration: 0.2s; } button[disabled~=true] { background-color: var(--code-bg-color); border-color: var(--code-bg-color); } }
To include the reactions
div with the rest of the page, I used flexbox. This lets me display the buttons right-justified on desktop computers and just below the "Published: …" subheader on mobile devices:
.subheader { margin: 0.6rem 0; display: flex; justify-content: space-between; } @media (max-width: 760px) { /* ... -snip- */ .subheader { flex-direction: column; justify-content: flex-start; } }
Some parting notes
- This was my first time working with Cloudflare workers. They are really fun and easy to work with. Since I already used Cloudflare for DNS, it was incredibly easy to deploy my worker to my domain. Since I don't have a lot of code, I am still doing all of the editing and deployment via Cloudflare's dashboard, although I know that all of that can be automated via git CI/CD hooks. However, I don't expect for this backend to change much, so using the dashboard is fine for now.
- This was also my first time writing non-trivial JavaScript to run in the browser. I am slowly starting to understand why people enjoy frontend development…
Finally, here are some other websites I found during my search which have installed or written about the Open Heart Protocol:
- https://www.benji.dog/articles/interactions-or-reactions/
- https://tracydurnell.com/2024/04/26/adding-a-heart-reacji-to-my-wordpress-theme/
- https://alexsirac.com/openheart-protocol-installed/
- https://blog.professeurjoachim.com/billet/2024-12-15-a-small-openheart-implementation-for-kirby
Cheers!