No description
  • Kotlin 54.5%
  • Python 23%
  • JavaScript 17.3%
  • CSS 3%
  • HTML 2.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-14 20:19:10 +00:00
.forgejo/workflows feat: step to the previous/next journal entry from beside the date 2026-09-14 12:01:21 -06:00
android fix(android): sync when the app is opened, and don't say "Synced" before it has 2026-09-14 14:17:30 -06:00
backend fix: don't name a method for a builtin its own class annotates with 2026-09-10 11:48:55 -06:00
frontend feat: step to the previous/next journal entry from beside the date 2026-09-14 12:01:21 -06:00
.dockerignore Initial commit: stylus journaling app (FastAPI + Postgres + PWA) 2026-06-07 22:18:03 -06:00
.env.example Move transcription config to an admin UI panel (no env vars) 2026-06-17 00:48:07 -06:00
.gitignore feat(android): scaffold the native client 2026-08-05 22:32:41 -06:00
CLAUDE.md feat: run the full-system backup with the app stopped, in its own container 2026-09-10 11:39:59 -06:00
docker-compose.portainer.test.yml feat: run the full-system backup with the app stopped, in its own container 2026-09-10 11:39:59 -06:00
docker-compose.portainer.yml feat: run the full-system backup with the app stopped, in its own container 2026-09-10 11:39:59 -06:00
docker-compose.yml feat: run the full-system backup with the app stopped, in its own container 2026-09-10 11:39:59 -06:00
Dockerfile feat: run the full-system backup with the app stopped, in its own container 2026-09-10 11:39:59 -06:00
Dockerfile.agent feat: run the full-system backup with the app stopped, in its own container 2026-09-10 11:39:59 -06:00
README.md feat: run the full-system backup with the app stopped, in its own container 2026-09-10 11:39:59 -06:00

Journal

A journaling web app (PWA) for writing/drawing with a stylus and typing, with multiple users, multiple notebooks per user, and several page backgrounds (lined, graph, blank). Data is stored on a central server. Users sign in with Google or any OpenID Connect provider, and can back up their notebooks to their own Google Drive or their own Nextcloud (configured per user under ⚙ Backups).

  • Backend: FastAPI + async SQLAlchemy + Postgres
  • Frontend: vanilla JS PWA (no build step), HTML canvas + Pointer Events (pressure-sensitive stylus support), served by the same FastAPI server
  • Auth: OpenID Connect (Authorization Code flow) — Google built-in, plus any number of additional OIDC providers (Authentik, Keycloak, Auth0, …) configured via OIDC_PROVIDERS. The app issues its own session token after login.
  • Backup: two kinds. Per user, exports all of a user's notebooks/pages as JSON to Google Drive (least-privilege drive.file scope — the app only touches files it creates) or Nextcloud. Server-wide, an admin-configured full-system backup of the database and upload volume to a remote Borg repository.

Page content model

Pages store vector content (not images), so they stay small and re-render crisply at any zoom:

{ "width": 1240, "height": 1754,
  "strokes": [ { "color": "#1a1a1a", "size": 2, "points": [[x, y, pressure], ...] } ],
  "texts":   [ { "x": 120, "y": 80, "size": 18, "color": "#1a1a1a", "text": "..." } ],
  "doc": "<p>rich-text HTML for word-processor mode</p>", "wordMode": false }

Each page can also act as a word processor: flip the ✎ toggle on a page to type and format flowing rich text (bold/italic/underline, fonts, sizes, headings, colors, alignment, and lists), stored as sanitized HTML in doc. Flip it off and that text becomes a background you can draw over with the stylus. The page grows taller to fit the typed document; strokes are vectors, so re-rendering at the new height stays lossless.

Prerequisites

  • Docker + Docker Compose (runs the whole app — web + Postgres).
  • (Optional) Python 3.12+ if you want to run the backend on the host instead of in a container.

1. Configure Google OAuth

  1. Go to https://console.cloud.google.com/apis/credentials.
  2. Create credentials → OAuth client ID → Web application.
  3. Add an Authorized redirect URI: http://localhost:8000/api/auth/google/callback
  4. Enable the Google Drive API for the project (APIs & Services → Library).
  5. On the OAuth consent screen, add the scope https://www.googleapis.com/auth/drive.file and add your Google account as a test user (while the app is in "Testing" mode).
  6. Copy .env.example to .env and fill in GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (and set a long random JWT_SECRET).
cp .env.example .env
# edit .env

Other OIDC providers (optional)

Google is optional. To offer other login providers, set OIDC_PROVIDERS to a JSON array — endpoints are discovered from each issuer's /.well-known/openid-configuration:

OIDC_PROVIDERS='[{"name":"authentik","display_name":"Authentik","client_id":"…","client_secret":"…","issuer":"https://auth.example.com/application/o/journal/"}]'

Register the redirect URI {BASE_URL}/api/auth/oidc/<name>/callback (e.g. http://localhost:8000/api/auth/oidc/authentik/callback) with each provider.

Signing in from the Android app

The native client can't hold a client secret, so it gets its own public client registration using PKCE, separate from the web one. Add its client id as native_client_id on the provider entry:

OIDC_PROVIDERS='[{"name":"authentik","display_name":"Authentik","client_id":"…","client_secret":"…","issuer":"https://auth.example.com/application/o/journal/","native_client_id":"journal-android"}]'

Authentik gives each application its own issuer, so a separate native application also means a separate iss on its tokens. Point native_issuer at it:

… ,"issuer":"https://auth.example.com/application/o/journal/","native_client_id":"…","native_issuer":"https://auth.example.com/application/o/journal-android/"

Leave native_issuer out only if both registrations genuinely share an issuer (a provider configured with a single global issuer). It is not cosmetic — the issuer check is only worth something if it's exact.

In the provider, register a public/native client with the redirect URI com.thenymans.journal:/oauth (and com.thenymans.journal.debug:/oauth for debug builds). Leave native_client_id blank to keep native sign-in off.

The app fetches GET /api/auth/native/providers to discover the issuer and client id, so providers can change without shipping a new build. It runs the OIDC flow itself and posts the resulting id_token to POST /api/auth/native, which verifies it against the provider's published keys (signature, issuer, audience, expiry) before issuing a session token — that token arrives from the client rather than over the server's own TLS call, so its signature is the only thing proving who it belongs to.

Using Google for Drive backup but not for login: keep GOOGLE_CLIENT_ID/ GOOGLE_CLIENT_SECRET set but add GOOGLE_LOGIN_ENABLED=false. The Google button disappears from the login screen; signed-in users can still Connect Google Drive from ⚙ Backups.

docker compose up --build -d

This builds the app image and starts two containers: web (FastAPI serving the API and the frontend) and db (Postgres). Compose substitutes the values from your .env into the web service, and web waits for the database to be healthy before starting. Tables are created automatically on first boot.

The app is now at http://localhost:8000 (API docs at http://localhost:8000/docs).

docker compose logs -f web   # follow app logs
docker compose down          # stop (add -v to also wipe the database volume)

Alternative: run the backend on the host

Postgres is still exposed on localhost:5432, so you can run just the DB in Docker and the app on your machine:

docker compose up -d db
.venv/bin/pip install -r backend/requirements.txt
.venv/bin/uvicorn app.main:app --reload --app-dir backend

Note: this project's .venv is Python 3.14, which lacks prebuilt wheels for some deps. If pip install tries to compile from source, add --only-binary=:all:. The Docker image avoids this by using Python 3.13.

3. Use it

  1. Open http://localhost:8000 and click Sign in with Google.
  2. Create a notebook, then + Add page. Each page picks its own paper from the dropdown in its header: lined / graph / blank on a full A4-proportioned sheet, or lined (mobile) / blank (mobile) on a compact sheet sized for a phone. Because a page is always scaled to the width of its column, the compact sheet's half-width means its ruling — and anything written on it — renders twice as large; a full sheet squeezes ~43 ruled lines into a phone's width, which is far too fine to write on. New pages default to the compact sheet on a phone and the full one on a tablet or desktop (decided by the viewport's short side, so rotating doesn't change it). Switching an existing page between the two rescales what's on it, so nothing moves off the sheet.
  3. Draw with a stylus or mouse (stylus pressure varies stroke width), switch to the eraser or text tool, change color/size. The eraser rubs out the part of a line you touch (it splits the stroke) rather than deleting the whole line. On a touchscreen, pinch with two fingers to zoom/pan; ⤢ resets the view. Changes autosave every few seconds; Save forces a save.
  4. Reload the page — your strokes and text persist (server storage).
  5. Back on the notebooks screen, open ⚙ Backups:
    • Google Drive — click Back up to Drive to export everything to a journal-backup.json in your Drive.
    • Nextcloud — enter your server URL, username, and a Nextcloud app password (Settings → Security → Devices & sessions), pick a folder, and Save & verify. Then Back up now uploads journal-backup.json over WebDAV.
    • Automatic backups — enable a schedule (15 min / hourly / 6 h / daily / weekly) and tick Drive and/or Nextcloud. A server-side scheduler runs due backups even when the app is closed.
    • Restore — Restore from Drive / Restore from Nextcloud downloads your journal-backup.json and replaces your current notebooks with it (confirmation required, since it overwrites).
    • Back up on close — tick "back up when I close the app" to fire a backup to every configured location when you close/leave the app. It uploads the local export (including not-yet-synced edits) via a keepalive request that survives unload; for large journals over the ~64 KB keepalive limit it falls back to a server-side backup of synced data. Throttled to once a minute; needs a connection.

Each user only sees their own notebooks.

Full-system backup (Borg)

The per-user backups above export one account's notebooks as JSON — the right shape for "I want my journal somewhere else", the wrong shape for "the host is gone". The full-system backup is the other one: an admin points the server at a remote Borg repository, and each run writes a single archive holding everything needed to rebuild the deployment.

journal-20260830T040000Z
  db/journal.dump          pg_dump --format=custom (every account, notebook,
                           page, memory, share and server setting)
  data/attachments/…       the DATA_DIR volume, verbatim
  data/memories/…  data/memory_items/…  data/memory_photos/…

Borg deduplicates, so a nightly run costs roughly what changed that day — a dump that is 99% identical to yesterday's stores as the 1%.

The app goes down while it runs

A backup or a restore is carried out by a separate container (journal-borg), and the first thing it does is stop journal-web. Nothing is writing the upload volume while it is archived, and a restore replaces the files and the database with no requests in flight against either. The app is started again as soon as the run finishes — including when it fails, because a backup that didn't work should not also be an outage.

So a run means a few minutes of downtime, and the panel says so: it keeps polling while the server is unreachable and shows the whole log of the run once the app is back. Kicking one off from the browser closes the page's own connection to the server partway through, which is expected — don't take "reconnecting…" for a failure. The database container stays up throughout (pg_dump and pg_restore need it), so nothing else in the stack is disturbed.

journal-borg needs to start and stop containers, which it does through a docker-socket-proxy service in the same stack, restricted to CONTAINERS=1 and POST=1 — enough to list, inspect, start and stop, and not enough to create a container, exec into one, or mount anything. The agent never gets the Docker socket itself; it holds your repository's SSH key, and that is exactly the process that shouldn't have root on the host.

It works out which containers to stop from the Compose labels — containers in its own project labelled journal.role=web — so a test stack and a production stack on one host never touch each other. There is a Containers to stop field in the panel for deployments where those labels don't exist; leave it blank otherwise.

Setting it up

Everything is configured in the app, under ⚙ Settings → Admin → Full-system backup (admins only — the first account to register is bootstrapped as admin). Nothing goes in the environment or the compose file.

  1. On the backup host, give the app its own account (e.g. borg) with borg installed and somewhere to keep the repository.
  2. In the panel, fill in:
    • Repository — ssh://borg@backup.example.com:22/./journal (an absolute path works too, for a repo on a volume mounted into the container).
    • Passphrase — the repository is encrypted with it. The encryption key is stored inside the repository (repokey-blake2), so this passphrase is the only thing you need to read the backups back. Keep a copy somewhere that isn't this server.
    • SSH key — click Generate a key for me and the server makes its own Ed25519 keypair; the private half goes straight into the database and never leaves the server, and the panel shows you the public line to add to the backup account's ~/.ssh/authorized_keys. (You can paste an existing private key instead — it's checked on save, and a passphrase-protected one is refused, because backups run unattended with no way to answer a prompt. The public line is shown for a pasted key too.)
    • Archive prefix, compression, schedule, and how many daily / weekly / monthly archives to keep.
  3. Save, install the public key on the backup host, then Test connection. If the repository doesn't exist yet the app offers to create it. Then Back up now for a first run.

Whichever way the key got there, it is kept in the database rather than on the data volume, and is written to a private temp directory for the duration of one borg command and deleted afterwards — so it can never be swept into an archive by the backup it authenticates.

The host key is accepted on first connection and recorded in the database; a changed host key afterwards is refused. Pruning only ever matches this app's own archive prefix, so a repository shared with other backups is safe.

The scheduler queues due backups on the same loop as the per-user ones; the agent picks them up. The panel shows the last run — including a failure, with borg's own message. A backup nobody can see the state of is a backup nobody knows is broken.

A scheduled run stops the app the same way a manual one does, so pick an hour when nobody is using it.

Restoring

Show archives lists what's in the repository, newest first; Restore on a row replaces this server with that archive. It asks you to type RESTORE first, because it is exactly as destructive as it sounds:

  • Every account's notebooks, pages, memories and files go back to the archive's state. Anything created since is gone.
  • Files are swapped in a directory at a time (an atomic rename each), then the database is restored in one transaction — so a restore that fails partway leaves the database exactly as it was, rather than half-replaced.
  • The app is stopped for the whole thing and started again at the end, so there are no requests in flight against a half-replaced deployment. The page you started it from will lose contact with the server partway through and pick the run back up when it returns; reload once it says the restore finished.
  • Devices may push data back. The web and Android clients are offline-first: a device still holding newer local records will sync them up again afterwards. If you want the restored state to stand, sign those devices out (or clear their data) before restoring.
  • Restore an archive written by the same build or an older one. The startup schema sync re-runs afterwards, so an older dump is brought up to the current schema — but a table added after the archive was written isn't in the dump and keeps whatever it currently holds.

To restore by hand instead (on the backup host, or anywhere with borg):

export BORG_PASSPHRASE='…'
borg list ssh://borg@backup.example.com/./journal
borg extract ssh://borg@backup.example.com/./journal::journal-20260830T040000Z
# → ./db/journal.dump and ./data/…
pg_restore --clean --if-exists --no-owner --no-acl -h HOST -U journal -d journal db/journal.dump
# then copy ./data/* onto the journal-files volume

Use a pg_restore whose major version matches the server (16). The agent image pins its client to that major for the same reason — pg_restore 17 opens a restore with a SET that Postgres 16 rejects.

Requirements

Nothing to install on the host. borg and ssh ship in both images; the Postgres 16 client ships in the agent image, which is the only one that dumps or restores. The agent needs temp space in /tmp for one database dump during a run, and a restore needs room on the data volume for a second copy of the files while they are swapped in. The stack needs the socket-proxy service and the journal.role=web label on the web service — both are in the compose files already.

Project layout

docker-compose.yml            # local dev: builds web + db + borg agent + socket proxy
docker-compose.portainer.yml  # production stack for Portainer (pulls prebuilt images)
.forgejo/workflows/deploy.yml # CI: test -> build/push both images -> redeploy in Portainer
Dockerfile                    # app image (Python 3.13, serves API + frontend)
Dockerfile.agent              # backup agent image (borg + ssh + pg_client-16)
.dockerignore
.env.example                  # config template
backend/app/
  main.py               # FastAPI app; serves API + static frontend
  config.py models.py schemas.py database.py security.py deps.py google.py
  backups.py            # per-user export/restore (Drive, Nextcloud)
  borg.py               # borg CLI wrapper
  system_backup.py      # full-system backup: repo access, archive layout, pg_dump/restore
  system_jobs.py        # the backup/restore queue web and the agent share
  ssh_keys.py           # generates/validates the SSH key used to reach the repo
  scheduler.py          # runs per-user backups, queues the system one, when due
  routers/auth.py notebooks.py pages.py backup.py
backend/agent/           # runs in journal-borg, never in journal-web
  main.py               # claim a job, run it, put the app back
  runner.py             # stop the app -> back up / restore -> start the app
  containers.py         # the Docker calls, through the scoped socket proxy
  db.py                 # a connection it can drop for the length of a pg_restore
frontend/
  index.html app.js style.css manifest.webmanifest sw.js icon.svg

Journal notebooks (calendar)

When creating a notebook you can choose its type:

  • Notebook — a flat ordered list of pages (the default).
  • Journal — organized by a calendar. Opening it shows a month grid with a dot on days that have content; tap a day to view/add that day's pages (same drawing/typing editor) and to upload photos/files for that day.

Opening a journal jumps straight to the current entry. The journal day rolls over at 4 AM, not midnight: an entry started at 1 AM belongs to the evening that's ending, so until 4 AM "write today's entry" opens the previous date (the welcome screen says so while that's in effect). The calendar itself still rings the real date. The cutoff is DAY_ROLLOVER_HOUR in frontend/tz.js.

Each journal has a home icon in the sidebar that puts a one-tap shortcut to that journal's current entry on the welcome screen, labelled with the journal's name so several are told apart. It's on by default for new journals; tap the icon to turn it off. Plain notebooks aren't offered there.

Files are stored on a server-side volume (DATA_DIR, mounted at /data in Docker) and served via an authenticated endpoint. File upload/view needs a connection (pages-per-day still work offline). Backups include attachments — each file is gzipped and uploaded to Drive/Nextcloud incrementally (named by content hash, so unchanged files are skipped); restore re-downloads them.

Offline support

The app is offline-first. All notebook/page reads and writes go to a local IndexedDB store first (frontend/db.js + frontend/data.js), so you can view, create, draw, type, and delete with no connection. A small badge (bottom-left) shows Synced / Syncing / N pending / Offline.

A sync engine pushes local changes and pulls server changes whenever you're online (on load, on reconnect, on tab focus, and every 30 s). The transport is idempotent, client-UUID-keyed upserts (PUT /api/notebooks/{id}, PUT /api/pages/{id}) plus idempotent deletes, so a queued change can be retried safely. Conflicts use last-write-wins at the page level (the vector page model makes this clean); a locally-edited record is never overwritten by a pull until it's synced.

Notes / limits (this iteration):

  • Sync happens while the app is open (foreground). True background sync while the app is closed isn't done — fine on Android/desktop where the app syncs on reopen; the server-side auto-backup scheduler is unaffected.
  • Remote deletions propagate to a device on its next pull (clean local copies are pruned); locally-unsynced edits are always kept.
  • Login itself needs network once (Google OAuth); after that the session works offline using the cached profile until the token expires.

Deployment (Forgejo Actions → Portainer)

Pushing to main runs .forgejo/workflows/deploy.yml, which:

  1. test — installs deps and imports the app (smoke test).
  2. build — builds the root Dockerfile and pushes git.thenymans.com/brian/journal:latest (and a :<sha> tag) to the registry.
  3. deploy — tells Portainer to pull the new image and redeploys the stack, sending it the contents of docker-compose.portainer.yml.

One-time setup

  1. Create the stack in Portainer from docker-compose.portainer.yml, filling in its environment variables (POSTGRES_PASSWORD, JWT_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI, FRONTEND_URL). These live in Portainer — the pipeline reads the stack's existing env and re-sends it unchanged on each deploy, so you manage/update them in Portainer, not in CI. Note the stack's numeric id (from the URL).

  2. In the Forgejo repo, add these Actions secrets (just these):

    Secret Purpose
    REGISTRY_USER / REGISTRY_PASSWORD Push images to git.thenymans.com
    PORTAINER_TOKEN Portainer API key (X-API-Key)
    PORTAINER_STACK_ID Numeric id of the stack created in step 1
    PORTAINER_ENDPOINT_ID Portainer environment id (e.g. 3)
  3. Add the public GOOGLE_REDIRECT_URI (set in Portainer) to your Google OAuth client's Authorized redirect URIs (the localhost one only works for local dev).

The web service publishes port 8000; put your reverse proxy in front of it and point FRONTEND_URL / GOOGLE_REDIRECT_URI at the public hostname.

Notes / next steps

Deferred from this first slice: Alembic migrations (tables are auto-created on startup), page reorder drag-and-drop, undo/redo, stroke smoothing, image insertion, PDF/PNG export, real-time multi-device sync, and notebook sharing.