- Kotlin 54.5%
- Python 23%
- JavaScript 17.3%
- CSS 3%
- HTML 2.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
Android Release / release (push) Successful in 6m25s
Reviewed-on: #28 |
||
| .forgejo/workflows | ||
| android | ||
| backend | ||
| frontend | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| CLAUDE.md | ||
| docker-compose.portainer.test.yml | ||
| docker-compose.portainer.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| Dockerfile.agent | ||
| README.md | ||
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.filescope — 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
- Go to https://console.cloud.google.com/apis/credentials.
- Create credentials → OAuth client ID → Web application.
- Add an Authorized redirect URI:
http://localhost:8000/api/auth/google/callback - Enable the Google Drive API for the project (APIs & Services → Library).
- On the OAuth consent screen, add the scope
https://www.googleapis.com/auth/drive.fileand add your Google account as a test user (while the app is in "Testing" mode). - Copy
.env.exampleto.envand fill inGOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET(and set a long randomJWT_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.
2. Run everything in Docker (recommended)
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
.venvis Python 3.14, which lacks prebuilt wheels for some deps. Ifpip installtries to compile from source, add--only-binary=:all:. The Docker image avoids this by using Python 3.13.
3. Use it
- Open http://localhost:8000 and click Sign in with Google.
- 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.
- 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.
- Reload the page — your strokes and text persist (server storage).
- Back on the notebooks screen, open ⚙ Backups:
- Google Drive — click Back up to Drive to export everything to a
journal-backup.jsonin 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.jsonover 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.jsonand 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
keepaliverequest 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.
- Google Drive — click Back up to Drive to export everything to a
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.
- On the backup host, give the app its own account (e.g.
borg) withborginstalled and somewhere to keep the repository. - 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.
- Repository —
- 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:
- test — installs deps and imports the app (smoke test).
- build — builds the root
Dockerfileand pushesgit.thenymans.com/brian/journal:latest(and a:<sha>tag) to the registry. - deploy — tells Portainer to pull the new image and redeploys the stack,
sending it the contents of
docker-compose.portainer.yml.
One-time setup
-
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). -
In the Forgejo repo, add these Actions secrets (just these):
Secret Purpose REGISTRY_USER/REGISTRY_PASSWORDPush images to git.thenymans.comPORTAINER_TOKENPortainer API key ( X-API-Key)PORTAINER_STACK_IDNumeric id of the stack created in step 1 PORTAINER_ENDPOINT_IDPortainer environment id (e.g. 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.