Files
puppy-tracker/server/main.go
T
Alexander Heldt 2b4731185c Keep PWA builds coherent by bypassing the HTTP cache
The service worker populated a new build's cache with plain addAll(),
which the browser may satisfy from its HTTP cache — and static assets
were served without Cache-Control, so Safari's heuristic caching could
hold app.js for days. Together that could install a mixed build: a
fresh index.html whose buttons reference listeners a stale app.js never
registers.

Install now fetches assets with cache: "reload", and the server marks
all static assets no-cache (revalidation is a cheap 304; fast/offline
loads are the SW cache's job anyway).
2026-07-12 17:13:13 +00:00

835 lines
25 KiB
Go

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/<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
}
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
}
// SPA fallback: unknown paths -> index.html (so deep links work).
if !strings.HasPrefix(r.URL.Path, "/api/") {
// All static assets revalidate on every request (cheap 304s via
// Last-Modified). Offline/fast loads are the service worker
// cache's job; leaving these to the browser's heuristic HTTP
// caching let a stale app.js pair with a fresh index.html.
w.Header().Set("Cache-Control", "no-cache")
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())
}