Files
puppy-tracker/README.md
T
Alexander Heldt 5c016ca49e Add self-service account deletion
Settings → Delete account removes the signed-in account and everything it
owns. DELETE /api/me re-checks the password (guarding an unattended session),
then wipes the user's events, config, sessions and user row in one transaction
and removes their photos/<user_id>/ directory. The client clears the account's
local cache and returns to the login screen.

Bumps the service-worker cache so clients pick up the new UI.

Verified: wrong password is rejected (401, data intact); correct password
returns 204, invalidates the session, drops all rows to zero and removes the
photo dir; the email can be re-registered afterwards. Confirmed end to end in
a headless-browser run of the Settings → delete flow.
2026-07-09 19:20:40 +00:00

133 lines
5.3 KiB
Markdown

# puppy-tracker
A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, poos, and
weight.
The browser is the primary client; a small Go server provides a shared
source-of-truth and sync between devices.
## How sync works
- Each event has a UUID and an `updatedAt` timestamp.
- Mutations (add / edit / delete) happen against `localStorage` first, so the
app keeps working when offline. Deletes are recorded as tombstones so they
can propagate.
- On app load, on `online`, on every mutation (debounced), and every 60 s, the
client POSTs its full event list to `/api/events/sync`. The server merges
it with its own copy using last-write-wins on `updatedAt` and returns the
merged set.
- The server keeps its copy in a SQLite database (`puppy.db`); events and the
shared profile are separate tables, and last-write-wins is enforced by the
upsert itself. On first start it auto-imports any legacy `events.json` /
`config.json` sitting alongside it, renaming them to `*.imported`.
- Service worker bypasses cache for `/api/*` so writes always hit the server
when online; static assets are still cached for offline use.
- The puppy's name and birthday are a per-account profile stored on the host
(`GET`/`PUT /api/config`), so a new device picks them up automatically instead
of being configured per-client. The client caches the last-seen values in
`localStorage` for offline/instant paint and reconciles with the server by
last-write-wins on `updatedAt`. The age shown in the header (in weeks and
months) is derived from the birthday.
- All data is scoped to the signed-in account (see [Accounts](#accounts)): every
event, profile and photo carries a `user_id`, and `localStorage` is namespaced
per user so two accounts on one browser never mix.
A status pill in the header shows `syncing…` / `synced 2m ago` / `pending` /
`sync error` / `offline`. Tap it to force-sync.
## Layout
```
puppy-tracker/
├── flake.nix # packages (server, static, default), devShell, nixosModule
├── module.nix # systemd unit, StateDirectory, hardening
├── server/
│ ├── go.mod
│ ├── go.sum
│ ├── main.go # SQLite store, LWW sync, static file serving
│ └── auth.go # accounts, sessions, invite-gated registration
└── src/ # the web app
├── index.html
├── app.js
├── style.css
├── sw.js
├── manifest.json
└── icon.svg
```
## Run locally
```sh
nix run # http://localhost:8080, data in $XDG_DATA_HOME/puppy-tracker
PUPPY_ADDR=:9000 nix run # custom port
# Registration needs an invite code (see Accounts). Set it in the environment:
PUPPY_INVITE_CODE=letmein nix run
# Hot-iterate (data in /tmp):
nix develop -c sh -c 'cd server && go run . -static ../src -data /tmp/puppy.db -invite-code letmein'
```
## Accounts
The app is multi-tenant: each person signs in and sees only their own puppy's
events, profile and photos.
- **Sessions.** Passwords are hashed with bcrypt; login mints a random session
token stored (hashed) in the `sessions` table and set as an `HttpOnly` cookie.
`/api/*` (except `login`/`register`/`logout`) requires a valid session.
- **Registration is invite-gated.** Sign-up requires the shared secret passed via
`-invite-code` / `PUPPY_INVITE_CODE`. With no code set, registration is
disabled (existing accounts can still log in). Share the code with whoever you
want to give an account.
- **First account adopts existing data.** When accounts are introduced on a DB
that already had single-tenant data (or that imported a legacy `events.json`),
the first account to register inherits all of it — events, profile and photos.
- **Self-service deletion.** Settings → *Delete account* removes the signed-in
account and everything it owns (`DELETE /api/me`, re-confirming the password):
events, profile, sessions and the photo directory are all wiped.
- **Serve over HTTPS in production.** Session cookies are only marked `Secure`
when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords
aren't sent in the clear.
## Use it on NixOS
In your system flake:
```nix
{
inputs.puppy-tracker.url = "path:/path/to/puppy-tracker";
outputs = { self, nixpkgs, puppy-tracker, ... }: {
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
puppy-tracker.nixosModules.default
{
services.puppy-tracker = {
enable = true;
port = 8080;
openFirewall = true;
# Registration secret, kept out of the Nix store. The file holds:
# PUPPY_INVITE_CODE=some-shared-secret
inviteCodeFile = "/run/secrets/puppy-invite-code";
# Enable once you terminate TLS in front of the service.
secureCookies = false;
};
}
];
};
};
}
```
The server runs as a `DynamicUser` systemd unit. Data is stored in a SQLite
database at `/var/lib/puppy-tracker/puppy.db` via `StateDirectory` (with photos
alongside it under `photos/`).
## Notes
- Accounts gate access, but there is no built-in TLS. If exposing publicly,
terminate TLS with a reverse proxy in front (Caddy / nginx / Tailscale Funnel)
and set `secureCookies = true`. Without HTTPS, passwords and session cookies
travel in the clear.