Posted

Reactions to any blog post or website page

Going beyond +1

TLDR: a no-javascript means to add multiple reaction counters to any website, with no auth, no account, no friction, etc. It works, you can try it at the bottom of this very page, or of any other posts on this website. It took most of my Saturday morning to build, along with the writing of this post.

Requirements for this project.

  • Trivial to embed for site owners
  • No authentication (keeps it simple, and friction-free for readers)
  • Needs to work on static websites
  • Ideally requires no javascript
  • Deduplication using hashed user agent or IP for basic abuse prevention.
  • Uses the pocketbase instance I deployed few weeks back.

Iteration 1

<form action="https://backend.ptrottier.com/api/reaction" method="post">
    <button>🙂</button>
    <button>🫠</button>
</form>

Open questions:

  • how do we know which button is pressed?
  • what’s the idiomatic path for custom endpoints in pocketbase?

Iteration 2

Learnings:

  • buttons can have a name and value just like inputs
<form action="https://backend.ptrottier.com/api/reaction" method="post">
    <button name="reaction" value="smile" type="submit">🙂</button>
    <button name="reaction" value="melt" type="submit">🫠</button>
</form>

Open questions: can we add a count without using either JS, iframe or image generation?

Iteration 3

Learnings:

  • we can do it with CSS
<head>
    <link rel="stylesheet" href="https://backend.ptrottier.com/api/counts.css">
</head>
<form action="https://backend.ptrottier.com/api/reaction" method="post">
    <button name="reaction" value="smile">
        🙂 <span class="count" id="smile-count"></span>
    </button>
    <button name="reaction" value="melt">
        🫠 <span class="count" id="melt-count"></span>
    </button>
</form>
#smile-count::after {
    content: "5";
}
#melt-count::after {
    content: "3";
}

Iteration 4 - Database

Created a table with these columns:

  • id: autogenerated and required by pocketbase
  • reaction_type: meant to contain “smile” for example. One to eight alphabetical character.
  • url: meant to contain the location of the content that was reacted to.
  • hashed_fingerprint: used to deduplicate reactions.
  • created: auto-generated creation date.

And an index on url then hashed_fingerprint.

Iteration 5 - Pocketbase endpoint

We need two endpoints: one to increment a reaction (POST) and one to produce the CSS for a given page.

I’ll ask codex (gpt-5.4 medium) to generate them and we can then review:

This project currently is a vanilla deployment of Pocketbase.
We need to add two custom endpoints: create and read.
First, the table/collection we will play with is called reactions. 
It has 3 fields to set on creation: reaction_type (a-z{1-8}), 
url and hashed_fingerprint. 
The create endpoint will be used in such forms, 
on static blogs: 
<form action="https://backend.ptrottier.com/api/reaction" method="post">
    <button name="reaction" value="smile">
        🙂 <span class="count" id="smile-count"></span>
    </button>
    <button name="reaction" value="melt">
        🫠 <span class="count" id="melt-count"></span>
    </button>
</form> - 
(the action url to be adjusted) - 
For example, if the user clicks the smile button, an entry with
(smile, referrer-url, hashed-ip) should be created. 
The user should then be redirected to where they came from.
Now, the read endpoint works like this:
<link rel="stylesheet" href="https://backend.ptrottier.com/api/counts.css"> 
(url to be adjusted) - 
it returns a nocache css generated from the reactions for the given referrer, 
e.g. 
#smile-count::after {
    content: "5";
}
#melt-count::after {
    content: "3";
s}

Interestingly, codex started using go doc a lot. After reading doc for some time, it came up with this:

• Plan:

  - Extend main.go with an OnServe hook that registers POST /api/reaction and GET /api/counts.css.
  - Implement the POST handler to validate the reaction, derive url from Referer, hash the client IP, save a reactions
    record, and redirect back.
  - Implement the CSS handler to read the current page URL from Referer, aggregate counts by reaction_type, and return
    uncached CSS selectors like #smile-count::after { content: "5"; }.
  - Build the binary to catch API mismatches against PocketBase v0.36.9, then summarize the exact embed snippets you
    should use on the static blogs.

• I’m editing main.go now. The change is self-contained there: route registration, validation, hashing, DB access, and
  response headers.

Looks promising. I wish I had setup the /approvals level before running the command though, because it keeps prompting me.

And it was done a few minutes later, with a good suggestion for a version that does not depend on referrer, but adds some friction to the blog owner:

  POST /api/reaction now accepts the form button value from reaction (or reaction_type), validates ^[a-z]{1,8}$, uses
  the page Referer as url, stores hashed_fingerprint as the SHA-256 of the client IP, saves a record in reactions, then
  redirects back with 303 See Other (main.go:42). GET /api/counts.css reads the current page URL from Referer,
  aggregates counts by reaction_type, and returns uncached CSS like #smile-count::after { content: "5"; } (main.go:77).

  Build verification passed with go build ./....

  Use these snippets on the blog side:

  <form action="https://backend.ptrottier.com/api/reaction" method="post">
    <button name="reaction" value="smile">
      🙂 <span class="count" id="smile-count"></span>
    </button>
    <button name="reaction" value="melt">
      🫠 <span class="count" id="melt-count"></span>
    </button>
  </form>

  <link rel="stylesheet" href="https://backend.ptrottier.com/api/counts.css">

  One caveat: both endpoints depend on the browser sending a Referer. I added url query/form fallbacks, but if your blog
  or CDN uses a strict Referrer-Policy, we should adjust the embed to pass the page URL explicitly.

Now, let’s review the changes:

git status:

modified:   go.mod
modified:   go.sum
modified:   main.go

It updated pocketbase to latest.

It created the two new endpoints:

app.OnServe().BindFunc(func(se *core.ServeEvent) error {
        se.Router.POST("/api/reaction", handleCreateReaction)
        se.Router.GET("/api/counts.css", handleReactionCountsCSS)

        return se.Next()
})

It decided to handle two different ways to specify the reaction type, and to return bad request on errors:

reactionType := strings.TrimSpace(e.Request.FormValue("reaction"))
if reactionType == "" {
        reactionType = strings.TrimSpace(e.Request.FormValue("reaction_type"))
}
if !reactionTypePattern.MatchString(reactionType) {
        return e.BadRequestError("reaction must match ^[a-z]{1,8}$", map[string]any{
                "reaction": "invalid reaction value",
        })
}

I’ll drop the second way (KISS), and ask it to return always redirect, so we don’t confuse users even if the blog owner did not configure a reaction properly. We could add a page indicating “sorry, your vote could not be recorded.” in a v2.

It added 3 different ways to get the URL:

func requestPageURL(e *core.RequestEvent) string {
        pageURL := strings.TrimSpace(e.Request.Referer())
        if pageURL != "" {
                return pageURL
        }

        pageURL = strings.TrimSpace(e.Request.FormValue("url"))
        if pageURL != "" {
                return pageURL
        }

        return strings.TrimSpace(e.Request.URL.Query().Get("url"))
}

It ends with a redirect:

return e.Redirect(http.StatusSeeOther, pageURL)

Now, I’m wondering whether there’s an abuse risk here of being some kind of relay for evil links. Let’s ask GPT:

Is there a risk to be used as a kind of relay for evil links via the POST endpoint? In practice, how bad is this?

It answers:

• Yes.

  Right now the POST handler will redirect to whatever Referer, url form field, or url query param it receives. That makes it an open
  redirect if an attacker can cause a browser to submit to /api/reaction with a malicious target.

  In practice, the severity is usually moderate, not catastrophic:

  - It does not let them execute code on your domain.
  - It does let them use backend.ptrottier.com as a trusted-looking hop in phishing links.
  - Some spam filters, browsers, or users treat same-domain redirects as more trustworthy, so it has real abuse value.
  - It also lets attackers write junk rows into reactions for arbitrary URLs unless you constrain that too.

  The main practical risk is phishing/reputation, not server compromise.

  The clean fix is to stop trusting caller-provided redirect targets. Better options:

  - Only redirect to the request Referer, and only if its host is in an allowlist you control.
  - Or require an explicit url field and validate it against an allowlist of blog domains.
  - Or avoid redirects entirely and return a small success page with a manual “back” link.
  - Also validate that stored url belongs to your own sites, so the DB can’t be polluted with arbitrary URLs.

  If you want, I can patch this now so /api/reaction only accepts redirects and counts for an allowlisted set of hosts.

I think it is an acceptable risk, especially after we add the intermediate “vote recorded” page, yet I might move the backend off my ptrottier.com domain just in case there is abuse, to avoid the domain being blacklisted.

The GET seems good overall - it does no duplication yet, which is fine.

TODO

  • Deduplication.
  • Intermediate vote page.
  • Moving this to another domain.

Testing and QA

  • It works, but at the domain rather than page level. Perhaps the referrer is not enough after all.
  • Codex confirms, it’s not something we do in the code.
  • Decision: request the URL as query parameter always in the endpoints - drop usage of Referrer due to being unreliable.
  • Abuse prevention: anybody can fake votes for any URL. No solution for now.
  • Added the reactionsform hash so the user is sent back to the button they just pressed.

Astro integration

The frontmatter

const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
const reactionsUrl = new URL(canonicalUrl);
reactionsUrl.hash = 'reactionsform';

The head

<link
	rel="stylesheet"
	href={`https://backend.ptrottier.com/api/counts.css?url=${encodeURIComponent(reactionsUrl.href)}`}
/>

The form

<form
    id="reactionsform"
    action={`https://backend.ptrottier.com/api/reaction?url=${encodeURIComponent(reactionsUrl.href)}`}
    method="post"
>
    <button name="reaction" value="smile">
        🙂 <span class="count" id="smile-count"></span>
    </button>
    <button name="reaction" value="sad">
        ☹️ <span class="count" id="sad-count"></span>
    </button>
</form>

Return to listing