Add accounts and multi-tenancy

Every event, profile and photo is now scoped to a signed-in account, so
separate people can track separate puppies on one server.

Server:
- users + sessions tables; bcrypt passwords; random session tokens stored
  hashed and set as an HttpOnly cookie. Middleware gates /api/* behind a
  valid session.
- register/login/logout/me endpoints. Registration requires a shared invite
  code (-invite-code / PUPPY_INVITE_CODE); empty disables it.
- events, config and photos are keyed by user_id; the sync upsert guards
  against cross-user overwrites and reads are scoped, so accounts are isolated.
  Photos live under photos/<user_id>/ and are only served to their owner.
- in-place schema migration adds user_id and reshapes config; legacy
  single-tenant data (including imported events.json) is parked ownerless and
  adopted by the first account to register.

Client:
- login/register gate in front of the app; the tracker only boots once the
  session check resolves. localStorage is namespaced per user.
- 401s bounce back to login; an offline reload falls back to the last cached
  session so offline-first still works. Logout clears the session and reloads.

Deployment:
- module.nix gains inviteCodeFile (secret via EnvironmentFile) and
  secureCookies options.

Verified end to end (curl + a headless-browser run of the auth flow):
isolation between accounts, invite enforcement, first-user adoption, photo
ownership, and session persistence across reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Alexander Heldt
2026-07-09 18:20:36 +00:00
parent d95cd52086
commit 706d8d8d9f
11 changed files with 896 additions and 90 deletions
+162 -44
View File
@@ -54,12 +54,12 @@ func newConfigStore(db *sql.DB) *ConfigStore {
return &ConfigStore{db: db}
}
func (cs *ConfigStore) get() Config {
func (cs *ConfigStore) get(userID string) Config {
var c Config
// The profile lives in a single row (id = 1). A missing row is the
// pre-configuration state, so a zero-value Config is the right answer.
// One profile row per user. A missing row is the pre-configuration state,
// so a zero-value Config is the right answer.
err := cs.db.QueryRow(
`SELECT name, birthday, updated FROM config WHERE id = 1`,
`SELECT name, birthday, updated FROM config WHERE user_id = ?`, userID,
).Scan(&c.Name, &c.Birthday, &c.UpdatedAt)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.Printf("config get: %v", err)
@@ -67,22 +67,22 @@ func (cs *ConfigStore) get() Config {
return c
}
// merge applies an incoming config with last-write-wins by UpdatedAt and
// returns the resulting stored config (which the caller sends back).
func (cs *ConfigStore) merge(in Config) (Config, error) {
// merge applies an incoming config for one user with last-write-wins by
// UpdatedAt and returns the resulting stored config (which the caller sends back).
func (cs *ConfigStore) merge(userID string, in Config) (Config, error) {
// The upsert's WHERE clause enforces last-write-wins: the incoming row only
// replaces the stored one when it is strictly newer.
_, err := cs.db.Exec(`
INSERT INTO config (id, name, birthday, updated)
VALUES (1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
INSERT INTO config (user_id, name, birthday, updated)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
name = excluded.name, birthday = excluded.birthday, updated = excluded.updated
WHERE excluded.updated > config.updated`,
in.Name, in.Birthday, in.UpdatedAt)
userID, in.Name, in.Birthday, in.UpdatedAt)
if err != nil {
return Config{}, err
}
return cs.get(), nil
return cs.get(userID), nil
}
type Store struct {
@@ -93,9 +93,10 @@ func newStore(db *sql.DB) *Store {
return &Store{db: db}
}
// sync merges client events into the store using last-write-wins by UpdatedAt,
// then returns the full merged set (including tombstones, which must propagate).
func (s *Store) sync(client []Event) ([]Event, error) {
// sync merges one user's client events into the store using last-write-wins by
// UpdatedAt, then returns that user's full merged set (tombstones included, as
// they must propagate).
func (s *Store) sync(userID string, client []Event) ([]Event, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
@@ -104,14 +105,18 @@ func (s *Store) sync(client []Event) ([]Event, error) {
// The WHERE clause on the upsert is the last-write-wins rule: an incoming
// event only overwrites the stored one when its updatedAt is strictly newer.
// The `events.user_id = excluded.user_id` guard means one user can never
// clobber another's row even if a client forges a colliding event ID —
// the row stays put and, because reads are scoped, stays invisible to them.
stmt, err := tx.Prepare(`
INSERT INTO events (id, type, at, note, photo_id, weight, updated, deleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO events (id, type, at, note, photo_id, weight, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type, at = excluded.at, note = excluded.note,
photo_id = excluded.photo_id, weight = excluded.weight,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > events.updated`)
WHERE excluded.updated > events.updated
AND events.user_id = excluded.user_id`)
if err != nil {
return nil, err
}
@@ -122,7 +127,7 @@ func (s *Store) sync(client []Event) ([]Event, error) {
continue
}
if _, err := stmt.Exec(
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.UpdatedAt, ce.Deleted,
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.UpdatedAt, ce.Deleted, userID,
); err != nil {
return nil, err
}
@@ -130,13 +135,14 @@ func (s *Store) sync(client []Event) ([]Event, error) {
if err := tx.Commit(); err != nil {
return nil, err
}
return s.all()
return s.all(userID)
}
// all returns every stored event, tombstones included.
func (s *Store) all() ([]Event, error) {
// all returns one user's events, tombstones included.
func (s *Store) all(userID string) ([]Event, error) {
rows, err := s.db.Query(
`SELECT id, type, at, note, photo_id, weight, updated, deleted FROM events`)
`SELECT id, type, at, note, photo_id, weight, updated, deleted
FROM events WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
@@ -175,6 +181,9 @@ func openDB(path string) (*sql.DB, error) {
return nil, err
}
}
// Fresh-install schema. Every row is scoped to a user_id; the empty string
// is the "ownerless" bucket that legacy single-tenant data lands in until
// the first account adopts it (see Auth.adopt).
schema := `
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
@@ -184,21 +193,105 @@ func openDB(path string) (*sql.DB, error) {
photo_id TEXT NOT NULL DEFAULT '',
weight REAL NOT NULL DEFAULT 0,
updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY CHECK (id = 1),
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
birthday TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password TEXT NOT NULL,
created INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created INTEGER NOT NULL,
expires INTEGER NOT NULL
);`
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, err
}
if err := migrateSchema(db); err != nil {
db.Close()
return nil, err
}
return db, nil
}
// migrateSchema upgrades a single-tenant database (from before accounts existed)
// in place: it adds events.user_id and rewrites the config table from its
// old single-row (id = 1) shape to one keyed by user_id. Pre-accounts data ends
// up ownerless (user_id = ”), ready for the first account to adopt. It is a
// no-op on a fresh DB, where openDB already created the current schema.
func migrateSchema(db *sql.DB) error {
has, err := columnExists(db, "events", "user_id")
if err != nil {
return err
}
if !has {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN user_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id)`); err != nil {
return err
}
}
oldConfig, err := columnExists(db, "config", "id")
if err != nil {
return err
}
if oldConfig {
// Rebuild config keyed by user_id, moving the lone id=1 row into the
// ownerless bucket.
stmts := []string{
`ALTER TABLE config RENAME TO config_old`,
`CREATE TABLE config (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
birthday TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0
)`,
`INSERT INTO config (user_id, name, birthday, updated)
SELECT '', name, birthday, updated FROM config_old WHERE id = 1`,
`DROP TABLE config_old`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
return err
}
}
}
return nil
}
// columnExists reports whether the given table has a column of the given name.
// A missing table reports false (no error), which is what fresh installs want.
func columnExists(db *sql.DB, table, col string) (bool, error) {
rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return false, err
}
if name == col {
return true, nil
}
}
return false, rows.Err()
}
// migrateJSON imports a pre-SQLite events.json / config.json sitting in dataDir
// into an otherwise-empty database, then renames each file to *.imported so the
// import runs exactly once. It is a no-op when the DB already holds data or the
@@ -232,8 +325,10 @@ func importEvents(db *sql.DB, path string) error {
if err != nil && !errors.Is(err, io.EOF) {
return err
}
// Imported as ownerless (user_id = ""); the first account to register adopts
// them. Mirrors how in-place schema migration parks legacy rows.
store := newStore(db)
if _, err := store.sync(evs); err != nil {
if _, err := store.sync("", evs); err != nil {
return err
}
log.Printf("migrated %d events from %s", len(evs), path)
@@ -266,7 +361,8 @@ func importConfig(db *sql.DB, path string) error {
// Nothing meaningful to import; leave config empty.
return os.Rename(path, path+".imported")
}
if _, err := newConfigStore(db).merge(c); err != nil {
// Ownerless until the first account adopts it (see importEvents).
if _, err := newConfigStore(db).merge("", c); err != nil {
return err
}
log.Printf("migrated config from %s", path)
@@ -292,6 +388,10 @@ func main() {
addr := flag.String("addr", ":8080", "listen address (e.g. :8080 or 0.0.0.0:8080)")
dataPath := flag.String("data", "puppy.db", "path to SQLite database file")
staticDir := flag.String("static", "", "directory of static files to serve")
inviteCode := flag.String("invite-code", os.Getenv("PUPPY_INVITE_CODE"),
"shared secret required to register (env PUPPY_INVITE_CODE); empty disables registration")
secureCookies := flag.Bool("secure-cookies", false,
"mark session cookies Secure (enable when served over HTTPS / behind a TLS proxy)")
flag.Parse()
db, err := openDB(*dataPath)
@@ -313,9 +413,19 @@ func main() {
log.Fatalf("mkdir photos: %v", err)
}
auth := newAuth(db, *inviteCode, *secureCookies, photosDir)
if *inviteCode == "" {
log.Print("WARNING: no invite code set — registration is disabled (set -invite-code / PUPPY_INVITE_CODE)")
}
mux := http.NewServeMux()
mux.HandleFunc("/api/events/sync", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/api/register", auth.handleRegister)
mux.HandleFunc("/api/login", auth.handleLogin)
mux.HandleFunc("/api/logout", auth.handleLogout)
mux.HandleFunc("/api/me", auth.requireUser(auth.handleMe))
mux.HandleFunc("/api/events/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -325,7 +435,7 @@ func main() {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
merged, err := store.sync(req.Events)
merged, err := store.sync(userID(r), req.Events)
if err != nil {
log.Printf("sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
@@ -337,11 +447,11 @@ func main() {
Events: merged,
ServerNow: time.Now().UnixMilli(),
})
})
}))
// GET /api/config — return the shared puppy profile.
// GET /api/config — return the caller's puppy profile.
// PUT /api/config — update it (last-write-wins by updatedAt).
mux.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
writeConfig := func(c Config) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
@@ -349,7 +459,7 @@ func main() {
}
switch r.Method {
case http.MethodGet:
writeConfig(configStore.get())
writeConfig(configStore.get(userID(r)))
case http.MethodPut, http.MethodPost:
var in Config
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil {
@@ -364,7 +474,7 @@ func main() {
http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest)
return
}
merged, err := configStore.merge(in)
merged, err := configStore.merge(userID(r), in)
if err != nil {
log.Printf("config save: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
@@ -374,7 +484,7 @@ func main() {
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
}))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
@@ -383,7 +493,7 @@ func main() {
// POST /api/photos — multipart upload with form fields `id` (UUID) and
// `file` (JPEG). The client generates the ID so the event referencing
// the photo can be written before the upload round-trips.
mux.HandleFunc("/api/photos", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/api/photos", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -405,7 +515,14 @@ func main() {
}
defer file.Close()
dstPath := filepath.Join(photosDir, id+".jpg")
// Photos live under the owner's directory so a photo can only ever be
// read back by the account that uploaded it.
userDir := filepath.Join(photosDir, userID(r))
if err := os.MkdirAll(userDir, 0o755); err != nil {
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
return
}
dstPath := filepath.Join(userDir, id+".jpg")
tmp := dstPath + ".tmp"
dst, err := os.Create(tmp)
if err != nil {
@@ -432,11 +549,12 @@ func main() {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
})
}))
// GET /api/photos/<id> — serves the JPEG. Photos are immutable per ID
// so we mark them as long-lived; both browser and SW can cache freely.
mux.HandleFunc("/api/photos/", func(w http.ResponseWriter, r *http.Request) {
// GET /api/photos/<id> — serves the caller's own JPEG. Photos are immutable
// per ID so we mark them as long-lived; the private cache keeps them per
// user. Serving only from the caller's directory makes ownership implicit.
mux.HandleFunc("/api/photos/", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -446,7 +564,7 @@ func main() {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
path := filepath.Join(photosDir, id+".jpg")
path := filepath.Join(photosDir, userID(r), id+".jpg")
f, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
@@ -459,9 +577,9 @@ func main() {
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
http.ServeContent(w, r, path, stat.ModTime(), f)
})
}))
if *staticDir != "" {
fileServer := http.FileServer(http.Dir(*staticDir))