d95cd52086
Replace the JSON-file event and config stores with a SQLite database (modernc.org/sqlite, pure-Go so the static build keeps CGO_ENABLED=0). Last-write-wins now rides on the upsert's WHERE clause rather than a Go-side map compare; the sync protocol and HTTP handlers are unchanged. On first start the server auto-imports any legacy events.json/config.json sitting in the data dir, renaming them to *.imported. The -data flag now points at puppy.db; photos still live on the filesystem alongside it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
494 lines
14 KiB
Go
494 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"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
|
|
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() 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.
|
|
err := cs.db.QueryRow(
|
|
`SELECT name, birthday, updated FROM config WHERE id = 1`,
|
|
).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 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) {
|
|
// 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
|
|
name = excluded.name, birthday = excluded.birthday, updated = excluded.updated
|
|
WHERE excluded.updated > config.updated`,
|
|
in.Name, in.Birthday, in.UpdatedAt)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
return cs.get(), nil
|
|
}
|
|
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
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) {
|
|
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.
|
|
stmt, err := tx.Prepare(`
|
|
INSERT INTO events (id, type, at, note, photo_id, weight, updated, deleted)
|
|
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`)
|
|
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.UpdatedAt, ce.Deleted,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.all()
|
|
}
|
|
|
|
// all returns every stored event, tombstones included.
|
|
func (s *Store) all() ([]Event, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, type, at, note, photo_id, weight, updated, deleted FROM events`)
|
|
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.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
|
|
}
|
|
}
|
|
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,
|
|
updated INTEGER NOT NULL DEFAULT 0,
|
|
deleted INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE TABLE IF NOT EXISTS config (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
name TEXT NOT NULL DEFAULT '',
|
|
birthday TEXT NOT NULL DEFAULT '',
|
|
updated INTEGER NOT NULL DEFAULT 0
|
|
);`
|
|
if _, err := db.Exec(schema); err != nil {
|
|
db.Close()
|
|
return nil, err
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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")
|
|
}
|
|
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 cacheControlFS struct {
|
|
root http.FileSystem
|
|
}
|
|
|
|
func (c cacheControlFS) Open(name string) (http.File, error) { return c.root.Open(name) }
|
|
|
|
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")
|
|
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)
|
|
|
|
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
|
|
if err := os.MkdirAll(photosDir, 0o755); err != nil {
|
|
log.Fatalf("mkdir photos: %v", err)
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("/api/events/sync", 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(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(),
|
|
})
|
|
})
|
|
|
|
// GET /api/config — return the shared puppy profile.
|
|
// PUT /api/config — update it (last-write-wins by updatedAt).
|
|
mux.HandleFunc("/api/config", 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())
|
|
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(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", 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()
|
|
|
|
dstPath := filepath.Join(photosDir, 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 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) {
|
|
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, 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", "public, max-age=31536000, immutable")
|
|
http.ServeContent(w, r, path, stat.ModTime(), f)
|
|
})
|
|
|
|
if *staticDir != "" {
|
|
fileServer := http.FileServer(http.Dir(*staticDir))
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
// PWA: sw.js and manifest.json must revalidate so updates propagate.
|
|
if r.URL.Path == "/sw.js" || 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())
|
|
}
|