package main import ( "bytes" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "io" "io/fs" "log" "net/http" "os" "path/filepath" "regexp" "strings" "sync" "time" _ "modernc.org/sqlite" ) type Event struct { ID string `json:"id"` Type string `json:"type"` At int64 `json:"at"` Note string `json:"note"` PhotoID string `json:"photoId,omitempty"` Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events ExerciseID string `json:"exerciseId,omitempty"` // for "training" events UpdatedAt int64 `json:"updatedAt"` Deleted bool `json:"deleted,omitempty"` } // Exercise is a user-defined training exercise (e.g. "Sit", "Leash walking"): // a name plus optional instruction text. Training sessions reference one by // ExerciseID on the event. Exercises sync exactly like events: UUID ids, // last-write-wins on UpdatedAt, tombstoned deletes. type Exercise struct { ID string `json:"id"` Name string `json:"name"` Note string `json:"note"` // instructions / reminder how to do it UpdatedAt int64 `json:"updatedAt"` Deleted bool `json:"deleted,omitempty"` } var uuidRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) func validUUID(s string) bool { return uuidRE.MatchString(s) } var birthdayRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) func validBirthday(s string) bool { return s == "" || birthdayRE.MatchString(s) } // Config is the shared puppy profile (name + birthday). It lives on the host so // every client sees the same values without configuring each device. UpdatedAt // drives last-write-wins, mirroring how events sync. type Config struct { Name string `json:"name"` Birthday string `json:"birthday"` UpdatedAt int64 `json:"updatedAt"` } type ConfigStore struct { db *sql.DB } func newConfigStore(db *sql.DB) *ConfigStore { return &ConfigStore{db: db} } func (cs *ConfigStore) get(userID string) Config { var c Config // 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 user_id = ?`, userID, ).Scan(&c.Name, &c.Birthday, &c.UpdatedAt) if err != nil && !errors.Is(err, sql.ErrNoRows) { log.Printf("config get: %v", err) } return c } // 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 (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`, userID, in.Name, in.Birthday, in.UpdatedAt) if err != nil { return Config{}, err } return cs.get(userID), nil } type Store struct { db *sql.DB } func newStore(db *sql.DB) *Store { return &Store{db: db} } // 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 } defer tx.Rollback() // 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, exercise_id, 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, exercise_id = excluded.exercise_id, updated = excluded.updated, deleted = excluded.deleted WHERE excluded.updated > events.updated AND events.user_id = excluded.user_id`) if err != nil { return nil, err } defer stmt.Close() for _, ce := range client { if ce.ID == "" { continue } if _, err := stmt.Exec( ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID, ); err != nil { return nil, err } } if err := tx.Commit(); err != nil { return nil, err } return s.all(userID) } // 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, exercise_id, updated, deleted FROM events WHERE user_id = ?`, userID) if err != nil { return nil, err } defer rows.Close() out := make([]Event, 0) for rows.Next() { var e Event if err := rows.Scan( &e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.ExerciseID, &e.UpdatedAt, &e.Deleted, ); err != nil { return nil, err } out = append(out, e) } return out, rows.Err() } // ExerciseStore mirrors Store for the exercises collection: same LWW sync by // UpdatedAt, same user_id guard against cross-user id collisions, same // tombstone propagation. type ExerciseStore struct { db *sql.DB } func newExerciseStore(db *sql.DB) *ExerciseStore { return &ExerciseStore{db: db} } func (s *ExerciseStore) sync(userID string, client []Exercise) ([]Exercise, error) { tx, err := s.db.Begin() if err != nil { return nil, err } defer tx.Rollback() stmt, err := tx.Prepare(` INSERT INTO exercises (id, name, note, updated, deleted, user_id) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, note = excluded.note, updated = excluded.updated, deleted = excluded.deleted WHERE excluded.updated > exercises.updated AND exercises.user_id = excluded.user_id`) if err != nil { return nil, err } defer stmt.Close() for _, ce := range client { if ce.ID == "" { continue } if _, err := stmt.Exec( ce.ID, ce.Name, ce.Note, ce.UpdatedAt, ce.Deleted, userID, ); err != nil { return nil, err } } if err := tx.Commit(); err != nil { return nil, err } return s.all(userID) } func (s *ExerciseStore) all(userID string) ([]Exercise, error) { rows, err := s.db.Query( `SELECT id, name, note, updated, deleted FROM exercises WHERE user_id = ?`, userID) if err != nil { return nil, err } defer rows.Close() out := make([]Exercise, 0) for rows.Next() { var e Exercise if err := rows.Scan(&e.ID, &e.Name, &e.Note, &e.UpdatedAt, &e.Deleted); err != nil { return nil, err } out = append(out, e) } return out, rows.Err() } // openDB opens (creating if needed) the SQLite database and ensures the schema // exists. WAL mode plays nicely with concurrent readers during a sync write; // busy_timeout avoids spurious "database is locked" errors under contention. func openDB(path string) (*sql.DB, error) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, err } db, err := sql.Open("sqlite", path) if err != nil { return nil, err } for _, pragma := range []string{ `PRAGMA journal_mode = WAL`, `PRAGMA busy_timeout = 5000`, `PRAGMA synchronous = NORMAL`, } { if _, err := db.Exec(pragma); err != nil { db.Close() 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, type TEXT NOT NULL DEFAULT '', at INTEGER NOT NULL DEFAULT 0, note TEXT NOT NULL DEFAULT '', photo_id TEXT NOT NULL DEFAULT '', weight REAL NOT NULL DEFAULT 0, exercise_id TEXT NOT NULL DEFAULT '', updated 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 exercises ( id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '', updated INTEGER NOT NULL DEFAULT 0, deleted INTEGER NOT NULL DEFAULT 0, user_id TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_exercises_user ON exercises(user_id); CREATE TABLE IF NOT EXISTS config ( 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 } } hasExercise, err := columnExists(db, "events", "exercise_id") if err != nil { return err } if !hasExercise { if _, err := db.Exec(`ALTER TABLE events ADD COLUMN exercise_id TEXT NOT NULL DEFAULT ''`); 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 // legacy files are absent. func migrateJSON(db *sql.DB, dataDir string) error { if err := importEvents(db, filepath.Join(dataDir, "events.json")); err != nil { return err } return importConfig(db, filepath.Join(dataDir, "config.json")) } func importEvents(db *sql.DB, path string) error { var n int if err := db.QueryRow(`SELECT COUNT(*) FROM events`).Scan(&n); err != nil { return err } if n > 0 { return nil // DB already has data; never clobber it } f, err := os.Open(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil } return err } var evs []Event dec := json.NewDecoder(f) err = dec.Decode(&evs) f.Close() 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 { return err } log.Printf("migrated %d events from %s", len(evs), path) return os.Rename(path, path+".imported") } func importConfig(db *sql.DB, path string) error { var n int if err := db.QueryRow(`SELECT COUNT(*) FROM config`).Scan(&n); err != nil { return err } if n > 0 { return nil } f, err := os.Open(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil } return err } var c Config dec := json.NewDecoder(f) err = dec.Decode(&c) f.Close() if err != nil && !errors.Is(err, io.EOF) { return err } if c.UpdatedAt == 0 { // Nothing meaningful to import; leave config empty. return os.Rename(path, path+".imported") } // 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) return os.Rename(path, path+".imported") } type syncRequest struct { Events []Event `json:"events"` } type syncResponse struct { Events []Event `json:"events"` ServerNow int64 `json:"serverNow"` } type exerciseSyncRequest struct { Exercises []Exercise `json:"exercises"` } type exerciseSyncResponse struct { Exercises []Exercise `json:"exercises"` } type cacheControlFS struct { root http.FileSystem } func (c cacheControlFS) Open(name string) (http.File, error) { return c.root.Open(name) } // swVersion computes a short content hash over the static assets the service // worker caches. The hash is substituted into sw.js at serve time (see // serveSW), so the served worker changes whenever any asset changes — that byte // difference is what makes the browser install a new worker and prompt to // reload. Results are memoised and only recomputed when a file's size or modtime // changes, so the steady state is a handful of cheap stats. type swVersion struct { dir string files []string mu sync.Mutex sig string // signature of (name,size,modtime) across files hash string } func newSWVersion(dir string) *swVersion { // The assets the SW caches and that actually change between builds. sw.js // itself is excluded: it carries the placeholder, so hashing it would be // circular and it never changes except when we edit it here. return &swVersion{ dir: dir, files: []string{"index.html", "style.css", "app.js", "manifest.json", "icon.svg"}, } } func (v *swVersion) hashHex() string { v.mu.Lock() defer v.mu.Unlock() var sb strings.Builder for _, name := range v.files { if info, err := os.Stat(filepath.Join(v.dir, name)); err == nil { fmt.Fprintf(&sb, "%s:%d:%d;", name, info.Size(), info.ModTime().UnixNano()) } } sig := sb.String() if sig == v.sig && v.hash != "" { return v.hash } h := sha256.New() for _, name := range v.files { f, err := os.Open(filepath.Join(v.dir, name)) if err != nil { continue } _, _ = io.Copy(h, f) f.Close() } v.sig = sig v.hash = hex.EncodeToString(h.Sum(nil))[:12] return v.hash } // serveSW renders sw.js with the current build hash substituted for its // placeholder. no-cache lets the browser refetch and byte-compare on each // update check; the substituted hash is what actually differs between builds. func serveSW(w http.ResponseWriter, staticDir string, ver *swVersion) { data, err := os.ReadFile(filepath.Join(staticDir, "sw.js")) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } out := bytes.ReplaceAll(data, []byte("__BUILD_HASH__"), []byte(ver.hashHex())) w.Header().Set("Content-Type", "text/javascript; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write(out) } 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) if err != nil { log.Fatalf("open db: %v", err) } defer db.Close() // One-time import of any pre-SQLite JSON data sitting next to the DB. if err := migrateJSON(db, filepath.Dir(*dataPath)); err != nil { log.Fatalf("migrate json: %v", err) } store := newStore(db) configStore := newConfigStore(db) exerciseStore := newExerciseStore(db) photosDir := filepath.Join(filepath.Dir(*dataPath), "photos") if err := os.MkdirAll(photosDir, 0o755); err != nil { 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/register", auth.handleRegister) mux.HandleFunc("/api/login", auth.handleLogin) mux.HandleFunc("/api/logout", auth.handleLogout) mux.HandleFunc("/api/me", auth.requireUser(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: auth.handleMe(w, r) case http.MethodDelete: auth.handleDeleteAccount(w, r) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } })) 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 } var req syncRequest if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil { http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest) return } merged, err := store.sync(userID(r), req.Events) if err != nil { log.Printf("sync: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(syncResponse{ Events: merged, ServerNow: time.Now().UnixMilli(), }) })) // POST /api/exercises/sync — merge the caller's training exercises, same // LWW contract as /api/events/sync. mux.HandleFunc("/api/exercises/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var req exerciseSyncRequest if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil { http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest) return } merged, err := exerciseStore.sync(userID(r), req.Exercises) if err != nil { log.Printf("exercises sync: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(exerciseSyncResponse{Exercises: merged}) })) // GET /api/config — return the caller's puppy profile. // PUT /api/config — update it (last-write-wins by updatedAt). 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") _ = json.NewEncoder(w).Encode(c) } switch r.Method { case http.MethodGet: 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 { http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest) return } in.Name = strings.TrimSpace(in.Name) if len(in.Name) > 100 { in.Name = in.Name[:100] } if !validBirthday(in.Birthday) { http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest) return } merged, err := configStore.merge(userID(r), in) if err != nil { log.Printf("config save: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } writeConfig(merged) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } })) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) // 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", auth.requireUser(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } r.Body = http.MaxBytesReader(w, r.Body, 15<<20) if err := r.ParseMultipartForm(15 << 20); err != nil { http.Error(w, "parse: "+err.Error(), http.StatusBadRequest) return } id := r.FormValue("id") if !validUUID(id) { http.Error(w, "invalid id", http.StatusBadRequest) return } file, _, err := r.FormFile("file") if err != nil { http.Error(w, "no file: "+err.Error(), http.StatusBadRequest) return } defer file.Close() // 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 { http.Error(w, "create: "+err.Error(), http.StatusInternalServerError) return } if _, err := io.Copy(dst, file); err != nil { dst.Close() os.Remove(tmp) http.Error(w, "copy: "+err.Error(), http.StatusInternalServerError) return } if err := dst.Close(); err != nil { os.Remove(tmp) http.Error(w, "close: "+err.Error(), http.StatusInternalServerError) return } if err := os.Rename(tmp, dstPath); err != nil { os.Remove(tmp) http.Error(w, "rename: "+err.Error(), http.StatusInternalServerError) return } 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/ — 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 } id := strings.TrimPrefix(r.URL.Path, "/api/photos/") if !validUUID(id) { http.Error(w, "invalid id", http.StatusBadRequest) return } path := filepath.Join(photosDir, userID(r), id+".jpg") f, err := os.Open(path) if err != nil { http.NotFound(w, r) return } defer f.Close() stat, err := f.Stat() if err != nil { http.Error(w, "stat: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "image/jpeg") 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)) swVer := newSWVersion(*staticDir) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // The service worker is rendered with a per-build hash of the static // assets, so any asset change yields a byte-different sw.js — that's // what makes the browser detect an update and show the reload prompt. if r.URL.Path == "/sw.js" { serveSW(w, *staticDir, swVer) return } // PWA: manifest.json must revalidate so updates propagate. if r.URL.Path == "/manifest.json" { w.Header().Set("Cache-Control", "no-cache") } // SPA fallback: unknown paths -> index.html (so deep links work). if !strings.HasPrefix(r.URL.Path, "/api/") { candidate := filepath.Join(*staticDir, filepath.FromSlash(r.URL.Path)) if r.URL.Path != "/" { if info, err := os.Stat(candidate); err != nil || info.IsDir() { r.URL.Path = "/" } } } fileServer.ServeHTTP(w, r) }) } srv := &http.Server{ Addr: *addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, } log.Printf("puppy-tracker listening on %s (data=%s, static=%s)", *addr, *dataPath, *staticDir) log.Fatal(srv.ListenAndServe()) }