Implement Tefter per SPEC.md: NV-style notes with server, sync, PWA, desktop
- frontend/: Svelte 4 + TS + Vite app — omnibar (search-and-create), ranked diacritic-insensitive filtering, CodeMirror 6 markdown editor with 400ms autosave, marked+DOMPurify preview with [[wiki-links]], tag filter, undo toast, light/dark, narrow-screen stacked layout, IndexedDB store, pull-then- push sync engine with conflict handling, versioned cache-first service worker + manifest (installable PWA) - server/: tefterd — Go stdlib HTTP + modernc.org/sqlite, /api/v1 sync API (changes/batch/health/import), SHA-256 hashed bearer token, LWW-with- conflict-copies push rules, subcommands: init, token rotate, import simplenote, compact, backup (VACUUM INTO); embeds the frontend bundle - desktop/: Wails v2 shell — single instance, hide-to-tray (fyne systray), global Ctrl+Shift+Space hotkey, quit-on-close flag - Simplenote import (CLI + web upload), idempotent via source_id dedupe; verified against a real 242-note export - Makefile (frontend/server/server-all/desktop/test), GitHub release workflow, README with systemd/Caddy/backup docs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
153
server/api/api.go
Normal file
153
server/api/api.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// 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("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) 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")
|
||||
}
|
||||
256
server/api/api_test.go
Normal file
256
server/api/api_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tefter/server/store"
|
||||
)
|
||||
|
||||
// client is a minimal reimplementation of the frontend sync loop (pull-then-push,
|
||||
// last-write-wins with conflict copies) used to prove two devices converge.
|
||||
type client struct {
|
||||
t *testing.T
|
||||
base string
|
||||
token string
|
||||
cursor int64
|
||||
notes map[string]*clientNote
|
||||
}
|
||||
|
||||
type clientNote struct {
|
||||
store.Note
|
||||
BaseVersion int64
|
||||
Dirty bool
|
||||
}
|
||||
|
||||
func newClient(t *testing.T, base, token string) *client {
|
||||
return &client{t: t, base: base, token: token, notes: map[string]*clientNote{}}
|
||||
}
|
||||
|
||||
func (c *client) req(method, path string, body any, out any) int {
|
||||
var rdr *bytes.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(b)
|
||||
} else {
|
||||
rdr = bytes.NewReader(nil)
|
||||
}
|
||||
req, _ := http.NewRequest(method, c.base+path, rdr)
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
c.t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if out != nil {
|
||||
if err := json.NewDecoder(res.Body).Decode(out); err != nil {
|
||||
c.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return res.StatusCode
|
||||
}
|
||||
|
||||
func (c *client) edit(id, content string) {
|
||||
n, ok := c.notes[id]
|
||||
if !ok {
|
||||
n = &clientNote{}
|
||||
n.ID = id
|
||||
c.notes[id] = n
|
||||
}
|
||||
n.Content = content
|
||||
n.Dirty = true
|
||||
}
|
||||
|
||||
func (c *client) pull() {
|
||||
for {
|
||||
var body struct {
|
||||
Notes []store.Note `json:"notes"`
|
||||
Cursor int64 `json:"cursor"`
|
||||
More bool `json:"more"`
|
||||
}
|
||||
if code := c.req("GET", fmt.Sprintf("/api/v1/changes?since=%d&limit=2", c.cursor), nil, &body); code != 200 {
|
||||
c.t.Fatalf("pull HTTP %d", code)
|
||||
}
|
||||
for _, sn := range body.Notes {
|
||||
if local, ok := c.notes[sn.ID]; ok && local.Dirty {
|
||||
continue // dirty local copy wins until pushed
|
||||
}
|
||||
c.notes[sn.ID] = &clientNote{Note: sn, BaseVersion: sn.Version}
|
||||
}
|
||||
c.cursor = body.Cursor
|
||||
if !body.More {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) push() {
|
||||
var dirty []map[string]any
|
||||
for _, n := range c.notes {
|
||||
if n.Dirty {
|
||||
dirty = append(dirty, map[string]any{
|
||||
"id": n.ID, "content": n.Content, "tags": n.Tags,
|
||||
"created_at": n.CreatedAt, "modified_at": n.ModifiedAt,
|
||||
"deleted": n.Deleted, "baseVersion": n.BaseVersion,
|
||||
})
|
||||
}
|
||||
}
|
||||
if dirty == nil {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Results []store.PushResult `json:"results"`
|
||||
}
|
||||
if code := c.req("POST", "/api/v1/notes/batch", map[string]any{"notes": dirty}, &body); code != 200 {
|
||||
c.t.Fatalf("push HTTP %d", code)
|
||||
}
|
||||
for _, r := range body.Results {
|
||||
n := c.notes[r.ID]
|
||||
switch r.Status {
|
||||
case "accepted":
|
||||
n.Dirty = false
|
||||
n.BaseVersion = r.Version
|
||||
n.Version = r.Version
|
||||
case "conflict":
|
||||
if r.ServerNote != nil {
|
||||
c.notes[r.ID] = &clientNote{Note: *r.ServerNote, BaseVersion: r.ServerNote.Version}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) sync() { c.pull(); c.push(); c.pull() }
|
||||
|
||||
func (c *client) visible() map[string]string {
|
||||
out := map[string]string{}
|
||||
for id, n := range c.notes {
|
||||
if !n.Deleted {
|
||||
out[id] = n.Content
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (*httptest.Server, string) {
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "api.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
tok, err := st.EnsureToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer((&Server{Store: st, Version: "test"}).Handler())
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, tok
|
||||
}
|
||||
|
||||
func TestAuthRequired(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
res, err := http.Get(srv.URL + "/api/v1/changes?since=0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if res.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", res.StatusCode)
|
||||
}
|
||||
res, err = http.Get(srv.URL + "/api/v1/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("health must be open, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoClientsConverge(t *testing.T) {
|
||||
srv, tok := newTestServer(t)
|
||||
a := newClient(t, srv.URL, tok)
|
||||
b := newClient(t, srv.URL, tok)
|
||||
|
||||
// A creates three notes, B pulls them.
|
||||
a.edit("n1", "groceries\nmilk")
|
||||
a.edit("n2", "ideas")
|
||||
a.edit("n3", "scratch")
|
||||
a.sync()
|
||||
b.sync()
|
||||
if len(b.visible()) != 3 {
|
||||
t.Fatalf("B should see 3 notes, sees %d", len(b.visible()))
|
||||
}
|
||||
|
||||
// Independent edits on different notes converge cleanly.
|
||||
a.edit("n1", "groceries\nmilk, bread")
|
||||
b.edit("n2", "ideas\nbuild tefter")
|
||||
a.sync()
|
||||
b.sync()
|
||||
a.sync()
|
||||
if fmt.Sprint(a.visible()) != fmt.Sprint(b.visible()) {
|
||||
t.Fatalf("divergence:\nA=%v\nB=%v", a.visible(), b.visible())
|
||||
}
|
||||
|
||||
// Concurrent edit of the SAME note → conflict copy, never silent overwrite.
|
||||
a.edit("n3", "scratch from A")
|
||||
b.edit("n3", "scratch from B")
|
||||
a.sync()
|
||||
b.sync() // B's push conflicts; server truth restored + copy created
|
||||
a.sync()
|
||||
b.sync()
|
||||
|
||||
av, bv := a.visible(), b.visible()
|
||||
if fmt.Sprint(av) != fmt.Sprint(bv) {
|
||||
t.Fatalf("divergence after conflict:\nA=%v\nB=%v", av, bv)
|
||||
}
|
||||
if len(av) != 4 {
|
||||
t.Fatalf("want 4 notes (3 + conflict copy), got %d: %v", len(av), av)
|
||||
}
|
||||
foundCopy := false
|
||||
for _, content := range av {
|
||||
if strings.Contains(content, "conflicted copy") && strings.Contains(content, "scratch from B") {
|
||||
foundCopy = true
|
||||
}
|
||||
}
|
||||
if !foundCopy {
|
||||
t.Fatalf("conflict copy missing: %v", av)
|
||||
}
|
||||
// Both edits survived somewhere — no data loss.
|
||||
joined := fmt.Sprint(av)
|
||||
if !strings.Contains(joined, "scratch from A") || !strings.Contains(joined, "scratch from B") {
|
||||
t.Fatalf("an edit was silently lost: %v", av)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteEditConflictAcrossClients(t *testing.T) {
|
||||
srv, tok := newTestServer(t)
|
||||
a := newClient(t, srv.URL, tok)
|
||||
b := newClient(t, srv.URL, tok)
|
||||
|
||||
a.edit("n1", "keep me")
|
||||
a.sync()
|
||||
b.sync()
|
||||
|
||||
// A deletes while B edits concurrently; edit must win.
|
||||
an := a.notes["n1"]
|
||||
an.Deleted = true
|
||||
an.Dirty = true
|
||||
b.edit("n1", "keep me — edited")
|
||||
b.sync() // B's edit lands first
|
||||
a.sync() // A's stale delete is dropped, server truth restored
|
||||
b.sync()
|
||||
|
||||
if fmt.Sprint(a.visible()) != fmt.Sprint(b.visible()) {
|
||||
t.Fatalf("divergence:\nA=%v\nB=%v", a.visible(), b.visible())
|
||||
}
|
||||
if a.visible()["n1"] != "keep me — edited" {
|
||||
t.Fatalf("edit did not win: %v", a.visible())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user