- PHP 43.9%
- JavaScript 41.2%
- CSS 13.8%
- Dockerfile 0.7%
- Shell 0.4%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| docker | ||
| public | ||
| src | ||
| views | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
keep.exped.nu
A self-hosted Google Keep, for one person. Card grid, checklists, colours, labels, search, pinning, archive, and an importer that swallows a Google Takeout export whole.
Installable as a PWA, and it stays signed in: the point of the remember-me token is that the app on your phone never shows you a login screen again.
Live at https://keep.exped.nu/, on fellowship, with 51 notes imported from Takeout.
What it does
- Notes and checklists. A note is prose or a checklist; the toggle splits the body by line into items, and keeps the body, so toggling back loses nothing.
- Cards show every open item of a checklist, never truncated. Ticked items collapse
behind a
+ N completed itemstoggle, on the card and in the editor: one click away, never gone. - The editor grows with the note until it reaches 80% of the screen height (leaving
backdrop to click to close it), then only the text scrolls; the colour and action bar always stays in view. This relies on
field-sizing: content, which is Chrome-only, and that is the only browser this targets. - Pinned notes get their own band across the top (PINNED / OTHERS), as in Keep. In a single grid they would flow down the first column instead, which reads as "off to the side".
- Drag to reorder, both note cards and checklist items. Notes carry a
position, so the order is yours and an edit does not shuffle a note back to the front. A card only reorders within its own band: dragging out of the pinned section would have to silently unpin it, and the pin button already makes that decision explicitly. Items drag by the⠿grip, so dragging never fights with selecting the text. - Label picker in Keep's shape: a filter box and a checkbox per existing label, plus
Create "...". It only offers to create what does not exist, so you cannot end up with a second spelling of a label you already have. - The editor has the same 3-dot menu as a card. Delete, Duplicate and Version history save the note and close the editor first, so they act on what you typed; Add label acts in place.
- Hover a card for its toolbar: background colour, archive (unarchive in the archive), and a 3-dot menu with Add label and Duplicate. The bar keeps its space while hidden, so hovering never shifts the grid; on a touch screen it is always shown. Duplicate copies content, colour, labels and flags, and puts the copy at the top.
- Version history (card menu): every save that changed the title, body or checklist mode
is a version in
note_version, and for a checklist only adding or removing items is: ticking and reordering are not. Colour, pin, archive and labels are not content and add none. Versions are kept forever and go only when the note is purged from the bin (foreign-key cascade). The dialog lists them newest first and shows the selected one side by side with the current note: removed rows red on the left, added rows green on the right. Prose is a line diff; two checklists compare by item, ignoring ticks and order, the same rule that decides when a version is saved. Markdown is compared as source. Restoring one is itself a save, so nothing is lost by restoring. When the table is first created, every existing note gets its current state as its first version. - Every note is Markdown, checklist items included; there is no switch. The editor is a
live preview in the style of Obsidian (
public/js/markdown.js): every line is shown formatted except the one the caret is on, which shows its raw markdown. Enter continues a list (and ends it on an empty item), task boxes tick with a click, links open in a new tab, copy gives the raw markdown, and Ctrl+Z/Ctrl+Y have their own undo. The lines array is the truth and every edit that crosses a line or a formatted line is applied to it, so what is saved is exactly what was typed and the version history only ever shows real changes. The</>button switches a note to its plain source (a textarea, in monospace) and back; the text moves across both ways, it saves from either, and every note opens formatted. Checklists have no source view. Checklist items work the same way on one line: formatted until you are in them, raw while you are, with pasted line breaks turned into spaces. Items drag by the grip only, so selecting text in an item never drags the row. Cards show plain text with the syntax stripped (no#,**, backticks or link brackets; task boxes become ☐/☑), and the version history compares the source. The inline renderer escapes everything before adding markup and only allows http(s) and mailto links, so a note cannot inject HTML. - Search reaches into checklist items and labels, not just the title and body.
- Archive and bin are separate. Archiving files a note away. Delete (in the card menu, or
emptying a note in the editor) moves it to the bin, where it is read-only with Restore and
Delete forever, plus Empty bin. A restored note goes back where it was, archive included.
Anything in the bin for 90 days (
Notes::BIN_DAYS) is purged on the next list request; there is no cron, the app is opened often enough. The server refuses a permanent delete of a note that is not in the bin.
Browser / installed PWA
|
NPM (TLS) fellowship.ams1.nl.exped.systems
|
keep container ── /data/keep.sqlite (the only state that matters)
Stack
PHP 8.3 + Apache, SQLite, vanilla JS. No Composer, no npm, no build step - same as
UptimeMonitor, whose conventions this follows: an App\ autoloader in src/bootstrap.php,
a small Router/View, and a schema applied idempotently on every boot rather than a
migration table.
public/index.php front controller: routes, and nothing else
public/js/app.js the frontend
public/js/markdown.js markdown rendering and the live editor, loaded before app.js
public/sw.js service worker (app shell only, see below)
src/Auth.php session + remember-me
src/Notes.php notes, checklist items, tags, search
src/Import.php Google Takeout
src/Controllers/ one per surface
views/ layout, app shell, login, error
Running it locally
ADMIN_USER=nicky \
ADMIN_PASSWORD_HASH="$(php -r 'echo password_hash("testpw", PASSWORD_ARGON2ID);')" \
KEEP_DATA_DIR=./data \
php -S 127.0.0.1:8792 -t public
Needs pdo_sqlite and dom (the latter only for the Takeout import).
Deployment
Docker Compose on fellowship.ams1.nl.exped.systems, in /opt/docker/keep.exped.nu, beside
the other stacks on that host. NPM terminates TLS.
NPM is not on fellowship, and it forwards by hostname. That matters: the hostname has an
AAAA record, so nginx connects over IPv6. The port is therefore published with no IP
binding at all (8792:80), which listens on v4 and v6 alike.
Binding it to an IPv4 literal (10.20.2.4:8792) makes Docker listen on IPv4 only, nginx
reaches the box over v6, finds nothing, and the site fails to load with ERR_FAILED while the
container sits there perfectly healthy. This host has no public interface, so publishing on
every interface keeps it on the ams1 LAN regardless.
Push to main and .forgejo/workflows/deploy.yml does the rest: pull, php -l every file,
rebuild, restart, and then poll /health until it answers. It fails the run rather than
leaving a broken container up.
First deploy needs a .env on the host (the workflow checks, and stops if it is missing):
cd /opt/docker/keep.exped.nu
cp .env.example .env
mkdir -p secrets
docker run --rm php:8.3-cli php -r 'echo password_hash("your-password", PASSWORD_ARGON2ID);' > secrets/password.hash
# Apache runs as www-data (uid 33), and that is who has to be able to read the hash.
sudo chown 33:33 secrets/password.hash && sudo chmod 400 secrets/password.hash
Owned by root instead, the container still starts and still reports healthy - and rejects every password, because PHP cannot read the file and falls back to an empty hash. The deploy checks the ownership for exactly this reason.
Auth
One account, from the environment: ADMIN_USER and ADMIN_PASSWORD_HASH (Argon2id, never
the password itself). Argon2id is memory-hard, so a stolen hash is far more expensive to attack
on a GPU than bcrypt would be; password_verify() reads the algorithm out of the hash, so an
older bcrypt hash keeps working with no migration.
The hash never passes through an environment variable, and that is load bearing. It lives
in secrets/password.hash (gitignored) and is mounted into the container, with PHP reading it
via ADMIN_PASSWORD_HASH_FILE.
The reason: an Argon2id hash is full of $ signs, and docker compose interpolates $ in the
compose file and inside env_file. Put the hash in .env and the container receives
=19=65536,t=4,p=1$... - every $-segment expanded away as an unset variable. Compose warns
about it (The "argon2id" variable is not set), the container starts, reports healthy, and
rejects every password. A remote shell does the same thing to a double-quoted command. All of
this was learned by doing it wrong, twice.
Sessions are normal PHP sessions. Staying signed in is a separate mechanism, because an
installed PWA gets killed and cold-started constantly and a session cookie will not survive
that. The remember-me cookie is a selector:validator pair:
- the selector is a public lookup key, so verifying is one indexed row read;
- the validator is random, sent to the browser, and stored only as a SHA-256 hash.
So a dump of remember_token cannot be replayed as a login. The token is rotated on every
use: a cookie someone copied off your device stops working the moment the real browser next
loads the app.
Google Takeout import
Unzip the Takeout archive, then pick the Takeout/Keep folder in the importer. The browser
uploads the folder (webkitdirectory); docker/php.ini raises max_file_uploads to 2000,
because the stock limit of 20 would silently drop the rest.
Takeout writes each note as both .json and .html. The importer prefers the JSON, which
states colour, pinned, archived and labels as data, and skips the matching .html so nothing
lands twice. Trashed notes are skipped: importing them would resurrect everything you ever
deleted.
Two gotchas, both discovered the hard way:
- PHP
basename()s an uploaded filename. Google marks a note as archived by putting it in anArchive/subfolder and nowhere else, so that folder is the only evidence - and PHP throws it away. The frontend therefore sends each file's relative path as a separate field, and the importer reads archived-ness from that. - The HTML export marks a ticked checkbox with a class and a ☑ glyph. Either counts.
The service worker
Caches the app shell (HTML, CSS, JS, icons) stale-while-revalidate, so the installed app opens
instantly and survives a bad signal. It deliberately does not cache /api/: a stale note
list is worse than a spinner, and offline writes would mean building conflict resolution for
a single user who is not editing in two places at once. Notes go straight to the server.
Next session
- Testing in a browser works without root. Playwright's cached Chromium on eru.nm1 lacks
system libraries;
apt-get downloadthem anddpkg -xinto a scratch dir, then launch withLD_LIBRARY_PATHpointing there. Run the dev server withPHP_CLI_SERVER_WORKERS=4, or Chrome's parallel connections stall the single-threaded server and requests arrive late. - The drag and drop is untested in a real browser, only the API behind it (order persists, an edit does not re-sort, an empty list is rejected). Try it on the phone too: HTML5 drag events do not fire on touch, so mobile reordering probably needs pointer events.
- Colour picker, search and archive are unexercised on a phone-sized screen.
Conventions
See the workspace CLAUDE.md: commit messages are one short line, and there are no em dashes
anywhere. Never bind a published port to an IP on this host (see the Deployment section).