Files
puppy-tracker/server/main.go
T
Alexander Heldt 094af3906d Show a changelog in the update banner
changelog.json is a hand-maintained, newest-first list of user-visible
changes; it is part of the SW asset cache and the build hash, so even a
changelog-only edit rolls a new version.

When the update banner appears, the app fetches changelog.json twice:
the plain URL is answered cache-first by the old controlling worker
(the running build's copy) while a cache-busting query bypasses every
SW cache and hits the network (the waiting build's copy). Entries the
fresh copy has that the cached one lacks are exactly what the update
brings, and are listed under the banner message.
2026-07-12 17:29:32 +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", "changelog.json"},
}
}
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())
}