package main import ( "encoding/json" "errors" "flag" "io" "io/fs" "log" "net/http" "os" "path/filepath" "regexp" "strings" "sync" "time" ) type Event struct { ID string `json:"id"` Type string `json:"type"` At int64 `json:"at"` Note string `json:"note"` PhotoID string `json:"photoId,omitempty"` 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) } type Store struct { path string mu sync.Mutex data map[string]Event } func newStore(path string) (*Store, error) { s := &Store{path: path, data: map[string]Event{}} if err := s.load(); err != nil { return nil, err } return s, nil } func (s *Store) load() error { f, err := os.Open(s.path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil } return err } defer f.Close() var evs []Event if err := json.NewDecoder(f).Decode(&evs); err != nil { if errors.Is(err, io.EOF) { return nil } return err } for _, e := range evs { s.data[e.ID] = e } return nil } // Caller must hold s.mu. func (s *Store) saveLocked() error { if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return err } tmp := s.path + ".tmp" f, err := os.Create(tmp) if err != nil { return err } evs := make([]Event, 0, len(s.data)) for _, e := range s.data { evs = append(evs, e) } enc := json.NewEncoder(f) enc.SetIndent("", " ") if err := enc.Encode(evs); err != nil { f.Close() os.Remove(tmp) return err } if err := f.Close(); err != nil { return err } return os.Rename(tmp, s.path) } // sync merges client events into the store using last-write-wins by UpdatedAt, // then returns the full merged set. func (s *Store) sync(client []Event) ([]Event, error) { s.mu.Lock() defer s.mu.Unlock() for _, ce := range client { if ce.ID == "" { continue } existing, ok := s.data[ce.ID] if !ok || ce.UpdatedAt > existing.UpdatedAt { s.data[ce.ID] = ce } } if err := s.saveLocked(); err != nil { return nil, err } out := make([]Event, 0, len(s.data)) for _, e := range s.data { out = append(out, e) } return out, nil } 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", "events.json", "path to events JSON file") staticDir := flag.String("static", "", "directory of static files to serve") flag.Parse() store, err := newStore(*dataPath) if err != nil { log.Fatalf("load store: %v", err) } 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(), }) }) 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/ — 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()) }