Upvote API for static sites
  • PHP 97.8%
  • Shell 2.2%
Find a file
Felix 56406a5a49
All checks were successful
test / test (8.1) (push) Successful in 14s
test / test (8.5) (push) Successful in 14s
chore: ignore the local backups directory
A deployment that points a backup script at ./backups leaves an untracked
directory in the checkout, one `git add .` away from being committed here.
It is deployment output, not source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ppt83fFZH6GBJRjA6xDH3
2026-09-17 11:53:46 +02:00
.forgejo/workflows chore: phpstan 2.2, and run the suite as a non-root user in ci 2026-09-09 19:56:40 +02:00
docs docs: php-fpm keeps its own access log 2026-09-09 19:34:59 +02:00
examples docs: readme, deployment guide and a copyable example 2026-09-09 19:26:18 +02:00
hooks feat: add an on_upvote hook 2026-09-09 20:41:33 +02:00
public feat: one upvote per reader per url, over http 2026-09-09 19:26:18 +02:00
src feat: add an on_upvote hook 2026-09-09 20:41:33 +02:00
tests feat: add an on_upvote hook 2026-09-09 20:41:33 +02:00
.gitignore chore: ignore the local backups directory 2026-09-17 11:53:46 +02:00
composer.json chore: resolve dev dependencies against the php floor 2026-09-09 20:02:44 +02:00
composer.lock chore: resolve dev dependencies against the php floor 2026-09-09 20:02:44 +02:00
config.example.php feat: add an on_upvote hook 2026-09-09 20:41:33 +02:00
LICENSE docs: readme, deployment guide and a copyable example 2026-09-09 19:26:18 +02:00
phpstan.neon feat: one upvote per reader per url, over http 2026-09-09 19:26:18 +02:00
phpunit.xml feat: one upvote per reader per url, over http 2026-09-09 19:26:18 +02:00
README.md feat: add an on_upvote hook 2026-09-09 20:41:33 +02:00

tsumori

Upvote API for static sites.

Useful for implementing likes / hearts / upvotes on a static site. It doesn't keep track of any personal data: no accounts, cookies, or storing anyone's IP address.

Named after 積もり, things quietly piling up.

What it does

It's a tiny API that you can use for upvoting a URL. In a nutshell:

How many upvotes does https://example.com/a-post/ have

GET  /api/upvotes/?url=https://example.com/a-post/
{"url":"https://example.com/a-post/","count":12,"upvoted":false}

Add an upvote from me for https://example.com/a-post/

POST /api/upvotes/   url=https://example.com/a-post/
{"url":"https://example.com/a-post/","count":13,"upvoted":true}
  • Stores a keyed hash of the visitor's IP address, never the address itself.
  • Uses a small SQLite DB for keeping track of upvotes.
  • No accounts, no cookies, no logging, no third-party requests.

Requirements

  • A web server that can run PHP 8.1 or newer with pdo_sqlite
  • The curl extension or allow_url_fopen, if you want URLs verified over HTTP. Without either, set verify to list or off.

Install

git clone https://git.ghostze.ro/felixdv/tsumori.git /srv/tsumori
cd /srv/tsumori
mkdir data
cp config.example.php config.php
php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'   # paste into config.php
chmod 600 config.php

config.php must be readable by the user PHP runs as, and data/ writable by it: usually www-data.

See docs/deploying.md for nginx and Caddy examples.

Configure

config.php returns an array, and every key is documented in config.example.php. The two that matter:

'secret'  => '…',                       // required, 32+ characters
'origins' => ['https://example.com'],   // sites this instance counts for

Notifications

Point hooks.on_upvote at an executable and it runs once per new upvote, with the URL as its only argument. Mail yourself, poke ntfy, append to a file. See hooks/on-upvote.example.sh.

The API

GET  /api/upvotes/?url=https://example.com/a-post/
200  {"url":"https://example.com/a-post/","count":12,"upvoted":false}

POST /api/upvotes/   url=https://example.com/a-post/
201  a new upvote
200  this reader had already upvoted, nothing changed
303  for a form submit, to <url>#upvoted
400  origin not allowed, url malformed, or url does not exist
500  the client address is missing, or the config is wrong

Worth knowing:

  • An unknown URL reads as zero upvotes rather than 404.
  • A URL over 300 characters is refused, and any other method gets a 405.
  • The key is the exact URL, so a site serving both /a-post and /a-post/ splits its count between the two.
  • Cross-origin works, including from a JSON client. The CORS grant only ever names an origin from your origins list.
  • Every response carries Cache-Control: no-store, X-Robots-Tag: noindex, nofollow and X-Content-Type-Options: nosniff.

The client side

examples/index.html is a form that posts, a fetch that reads the count, and a CSS-only confirmation for visitors without JavaScript. The comment at the top says how to run it locally. It is CC0, so copy it into your own templates and change whatever you like.

The shape it uses:

<form method="post" action="https://example.com/api/upvotes/">
  <input type="hidden" name="url" value="https://example.com/a-post/">
  <button type="submit">♥</button>
</form>

That works with no JavaScript at all. The service redirects back to <url>#upvoted, which CSS can style with #upvoted:target to say thank you.

Privacy

The only thing stored about a reader is HMAC-SHA256(secret, url + "\0" + address), truncated to sixteen bytes. The URL is part of the input, so one reader's rows on different pages cannot be recognised as belonging to each other. Addresses are never written down, and IPv6 is reduced to its /64 before hashing.

The service writes no request log, and errors name no addresses. Your web server will log, though, so see docs/deploying.md.

Development

composer install
vendor/bin/phpunit
vendor/bin/phpstan analyse

Licence

AGPL-3.0-or-later. See LICENSE.

examples/ is CC0-1.0, so the snippet you copy into your own site carries no obligations at all.