- PHP 97.8%
- Shell 2.2%
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 |
||
|---|---|---|
| .forgejo/workflows | ||
| docs | ||
| examples | ||
| hooks | ||
| public | ||
| src | ||
| tests | ||
| .gitignore | ||
| composer.json | ||
| composer.lock | ||
| config.example.php | ||
| LICENSE | ||
| phpstan.neon | ||
| phpunit.xml | ||
| README.md | ||
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
curlextension orallow_url_fopen, if you want URLs verified over HTTP. Without either, setverifytolistoroff.
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-postand/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
originslist. - Every response carries
Cache-Control: no-store,X-Robots-Tag: noindex, nofollowandX-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.