// Package api exposes the /api/v1 sync endpoints and serves the embedded web app. package api import ( "bytes" "encoding/json" "io" "io/fs" "log" "net/http" "strconv" "strings" "time" "tefter/server/importer" "tefter/server/store" ) type Server struct { Store *store.Store Version string WebFS fs.FS // frontend dist root, may be nil (API-only) } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/v1/health", s.handleHealth) mux.Handle("GET /api/v1/changes", s.auth(s.handleChanges)) mux.Handle("POST /api/v1/notes/batch", s.auth(s.handleBatch)) mux.Handle("GET /api/v1/notes/{id}/history", s.auth(s.handleHistory)) mux.Handle("POST /api/v1/import/simplenote", s.auth(s.handleImport)) mux.HandleFunc("OPTIONS /api/v1/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) if s.WebFS != nil { mux.HandleFunc("/", s.handleStatic) } return withCORS(mux) } // withCORS allows the desktop (wails://) and dev-server origins to call the API. func withCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") w.Header().Set("Access-Control-Max-Age", "86400") } next.ServeHTTP(w, r) }) } func (s *Server) auth(next http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tok, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !ok || !s.Store.CheckToken(strings.TrimSpace(tok)) { jsonError(w, http.StatusUnauthorized, "invalid token") return } next(w, r) }) } func jsonError(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"ok": true, "version": s.Version, "schema": store.SchemaVersion}) } func (s *Server) handleChanges(w http.ResponseWriter, r *http.Request) { since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) notes, cursor, more, err := s.Store.Changes(since, limit) if err != nil { log.Printf("changes: %v", err) jsonError(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, map[string]any{"notes": notes, "cursor": cursor, "more": more}) } func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { var body struct { Notes []store.IncomingNote `json:"notes"` } if err := json.NewDecoder(io.LimitReader(r.Body, 64<<20)).Decode(&body); err != nil { jsonError(w, http.StatusBadRequest, "bad json: "+err.Error()) return } results, err := s.Store.ApplyBatch(body.Notes, time.Now()) if err != nil { log.Printf("batch: %v", err) jsonError(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, map[string]any{"results": results}) } func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) { revs, err := s.Store.History(r.PathValue("id")) if err != nil { log.Printf("history: %v", err) jsonError(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, map[string]any{"revisions": revs}) } func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(64 << 20); err != nil { jsonError(w, http.StatusBadRequest, "bad multipart form: "+err.Error()) return } f, _, err := r.FormFile("file") if err != nil { jsonError(w, http.StatusBadRequest, "missing 'file' field") return } defer f.Close() data, err := io.ReadAll(f) if err != nil { jsonError(w, http.StatusBadRequest, "read upload: "+err.Error()) return } sum, err := importer.ImportZip(s.Store, bytes.NewReader(data), int64(len(data))) if err != nil { jsonError(w, http.StatusBadRequest, "import: "+err.Error()) return } log.Printf("import via API: %s", sum) writeJSON(w, sum) } // handleStatic serves the embedded PWA: exact file if present, index.html otherwise. func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/") if path == "" { path = "index.html" } if f, err := s.WebFS.Open(path); err == nil { f.Close() if path == "sw.js" { w.Header().Set("Cache-Control", "no-cache") } http.ServeFileFS(w, r, s.WebFS, path) return } // SPA fallback w.Header().Set("Cache-Control", "no-cache") http.ServeFileFS(w, r, s.WebFS, "index.html") }