Server (schema v2, auto-migrates): every accepted overwrite snapshots the
superseded revision into note_history (capped at 50 per note); new
GET /api/v1/notes/{id}/history endpoint; compact purges orphaned history.
Client: notes get a synced_at stamp on every confirmed server exchange;
an info footer under the editor and a Ctrl/Cmd+I panel show created/
modified/last-synced plus the revision list. Restoring a revision applies
it as a normal edit through the sync path, so rollback is non-destructive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
165 lines
4.8 KiB
Go
165 lines
4.8 KiB
Go
// 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")
|
|
}
|