No description
  • PHP 43.9%
  • JavaScript 41.2%
  • CSS 13.8%
  • Dockerfile 0.7%
  • Shell 0.4%
Find a file
nhendriks ba929e2435
All checks were successful
Build and deploy / deploy (push) Successful in 9s
Add a switch between the formatted editor and the Markdown source
2026-09-29 12:06:35 +02:00
.forgejo/workflows Publish on all interfaces so IPv6 works 2026-07-13 22:51:02 +02:00
docker Hash passwords with Argon2id and stop the env file mangling it 2026-07-13 22:38:05 +02:00
public Add a switch between the formatted editor and the Markdown source 2026-09-29 12:06:35 +02:00
src Make every note Markdown and drop the per-note switch 2026-09-29 11:51:08 +02:00
views Add a switch between the formatted editor and the Markdown source 2026-09-29 12:06:35 +02:00
.env.example Publish on all interfaces so IPv6 works 2026-07-13 22:51:02 +02:00
.gitignore Mount the password hash as a file so compose cannot mangle it 2026-07-13 22:43:30 +02:00
docker-compose.yml Publish on all interfaces so IPv6 works 2026-07-13 22:51:02 +02:00
Dockerfile Initial commit 2026-07-13 22:24:42 +02:00
README.md Add a switch between the formatted editor and the Markdown source 2026-09-29 12:06:35 +02:00

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 items toggle, 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 an Archive/ 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

  1. Testing in a browser works without root. Playwright's cached Chromium on eru.nm1 lacks system libraries; apt-get download them and dpkg -x into a scratch dir, then launch with LD_LIBRARY_PATH pointing there. Run the dev server with PHP_CLI_SERVER_WORKERS=4, or Chrome's parallel connections stall the single-threaded server and requests arrive late.
  2. 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.
  3. 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).