Light of day
This commit is contained in:
+291
@@ -0,0 +1,291 @@
|
||||
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/<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())
|
||||
}
|
||||
Reference in New Issue
Block a user