a1a3ccf339
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. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
5.3 KiB
Markdown
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.
|