diff --git a/cmd/librenotes/serve.go b/cmd/librenotes/serve.go index 67b934c..6b5cc62 100644 --- a/cmd/librenotes/serve.go +++ b/cmd/librenotes/serve.go @@ -12,11 +12,13 @@ import ( "net/http" "os" "path/filepath" + "sync" "time" "git.librete.ch/public/librenotes/internal/auth" "git.librete.ch/public/librenotes/internal/httpapi" "git.librete.ch/public/librenotes/internal/storage" + "git.librete.ch/public/librenotes/internal/tenant" ) //go:embed all:web/public @@ -124,10 +126,14 @@ func runServe(args []string) error { defer cancel() go purgeLoop(ctx, tokens, logger) + tenants := newTenantPool(c.dataDir) + defer tenants.Close() + api := &httpapi.Server{ Auth: auth.Handlers{Service: authSvc}, Signer: signer, Logger: logger, + Notes: httpapi.NotesHandler{FSFor: tenants.FSFor}, } root := http.NewServeMux() @@ -165,6 +171,41 @@ func purgeLoop(ctx context.Context, tokens *auth.TokenStore, logger *log.Logger) } } +// tenantPool memoises tenant.FS handles per user ID. We open the +// root once and reuse it; closing happens on shutdown. +type tenantPool struct { + dataDir string + mu sync.Mutex + by map[string]*tenant.FS +} + +func newTenantPool(dataDir string) *tenantPool { + return &tenantPool{dataDir: dataDir, by: map[string]*tenant.FS{}} +} + +func (p *tenantPool) FSFor(userID string) (*tenant.FS, error) { + p.mu.Lock() + defer p.mu.Unlock() + if fs, ok := p.by[userID]; ok { + return fs, nil + } + fs, err := tenant.Open(filepath.Join(p.dataDir, userID)) + if err != nil { + return nil, err + } + p.by[userID] = fs + return fs, nil +} + +func (p *tenantPool) Close() { + p.mu.Lock() + defer p.mu.Unlock() + for _, fs := range p.by { + _ = fs.Close() + } + p.by = nil +} + func withSecurityHeaders(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") diff --git a/internal/httpapi/notes.go b/internal/httpapi/notes.go new file mode 100644 index 0000000..2fef12d --- /dev/null +++ b/internal/httpapi/notes.go @@ -0,0 +1,316 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "git.librete.ch/public/librenotes/internal/tenant" +) + +// NotesHandler exposes per-tenant note CRUD over HTTP. Notes are +// stored as Markdown files in the tenant's sandboxed directory. +// +// The wire format is JSON: { id, title, content, updated_at }. +// Conflict detection uses an If-Unmodified-Since-style flow: the +// client sends ?base= on PUT/DELETE, and we 409 if +// the file has been touched since. +type NotesHandler struct { + // FSFor returns the tenant FS for the given user ID. It must + // create the directory if missing. Implementations typically + // memoise per user. + FSFor func(userID string) (*tenant.FS, error) +} + +// noteIDRe constrains note IDs to a safe slug. The tenant FS would +// reject path traversal anyway but rejecting at the parser keeps +// errors clean and prevents weird paths from being created at all. +var noteIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,127}$`) + +const notesPrefix = "/api/notes" + +// Mount adds the notes routes onto the given mux. Caller is expected +// to wrap the mux with AuthMiddleware so tenant context is present. +func (h NotesHandler) Mount(mux *http.ServeMux) { + mux.HandleFunc(notesPrefix, h.handleCollection) + mux.HandleFunc(notesPrefix+"/", h.handleItem) +} + +type noteSummary struct { + ID string `json:"id"` + Title string `json:"title"` + UpdatedAt int64 `json:"updated_at"` +} + +type note struct { + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + UpdatedAt int64 `json:"updated_at"` +} + +func (h NotesHandler) handleCollection(w http.ResponseWriter, r *http.Request) { + t, err := TenantFrom(r.Context()) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + fs, err := h.FSFor(t.UserID) + if err != nil { + http.Error(w, "tenant fs unavailable", http.StatusInternalServerError) + return + } + names, err := fs.List(".") + if err != nil { + http.Error(w, "list failed", http.StatusInternalServerError) + return + } + out := make([]noteSummary, 0, len(names)) + for _, name := range names { + if !strings.HasSuffix(name, ".md") { + continue + } + id := strings.TrimSuffix(name, ".md") + fi, err := fs.Stat(name) + if err != nil { + continue + } + title, _ := readTitle(fs, name) + out = append(out, noteSummary{ID: id, Title: title, UpdatedAt: fi.ModTime().Unix()}) + } + writeJSON(w, http.StatusOK, out) +} + +func (h NotesHandler) handleItem(w http.ResponseWriter, r *http.Request) { + t, err := TenantFrom(r.Context()) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + id := strings.TrimPrefix(r.URL.Path, notesPrefix+"/") + id = strings.TrimSuffix(id, "/") + if !noteIDRe.MatchString(id) { + http.Error(w, "invalid note id", http.StatusBadRequest) + return + } + fs, err := h.FSFor(t.UserID) + if err != nil { + http.Error(w, "tenant fs unavailable", http.StatusInternalServerError) + return + } + rel := id + ".md" + + switch r.Method { + case http.MethodGet: + h.read(w, fs, id, rel) + case http.MethodPut: + h.write(w, r, fs, id, rel) + case http.MethodDelete: + h.delete(w, r, fs, rel) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h NotesHandler) read(w http.ResponseWriter, fs *tenant.FS, id, rel string) { + data, err := fs.ReadFile(rel) + if errors.Is(err, tenant.ErrNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "read failed", http.StatusInternalServerError) + return + } + fi, err := fs.Stat(rel) + if err != nil { + http.Error(w, "stat failed", http.StatusInternalServerError) + return + } + title, body := splitTitle(string(data)) + writeJSON(w, http.StatusOK, note{ + ID: id, Title: title, Content: body, UpdatedAt: fi.ModTime().Unix(), + }) +} + +type writeReq struct { + Title string `json:"title"` + Content string `json:"content"` +} + +func (h NotesHandler) write(w http.ResponseWriter, r *http.Request, fs *tenant.FS, id, rel string) { + var req writeReq + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + + // Conflict detection: if the client supplied ?base=, and + // the current file's mtime is newer, the file has changed + // since the client read it. Return 409 with the current state. + if base := r.URL.Query().Get("base"); base != "" { + baseUnix, err := strconv.ParseInt(base, 10, 64) + if err != nil { + http.Error(w, "invalid base param", http.StatusBadRequest) + return + } + if fi, err := fs.Stat(rel); err == nil { + if fi.ModTime().Unix() > baseUnix { + body, _ := fs.ReadFile(rel) + title, content := splitTitle(string(body)) + writeJSON(w, http.StatusConflict, note{ + ID: id, Title: title, Content: content, UpdatedAt: fi.ModTime().Unix(), + }) + return + } + } else if !errors.Is(err, tenant.ErrNotFound) { + http.Error(w, "stat failed", http.StatusInternalServerError) + return + } else if baseUnix != 0 { + // Client thought there was a prior version; server has + // nothing. Treat as conflict so client can resolve. + http.Error(w, "deleted on server", http.StatusConflict) + return + } + } + + body := composeNote(req.Title, req.Content) + if err := fs.WriteFile(rel, []byte(body)); err != nil { + if errors.Is(err, tenant.ErrInvalidPath) { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + http.Error(w, "write failed", http.StatusInternalServerError) + return + } + fi, err := fs.Stat(rel) + if err != nil { + http.Error(w, "stat failed", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, note{ + ID: id, Title: req.Title, Content: req.Content, UpdatedAt: fi.ModTime().Unix(), + }) +} + +func (h NotesHandler) delete(w http.ResponseWriter, r *http.Request, fs *tenant.FS, rel string) { + if base := r.URL.Query().Get("base"); base != "" { + baseUnix, err := strconv.ParseInt(base, 10, 64) + if err != nil { + http.Error(w, "invalid base param", http.StatusBadRequest) + return + } + if fi, err := fs.Stat(rel); err == nil { + if fi.ModTime().Unix() > baseUnix { + http.Error(w, "modified on server", http.StatusConflict) + return + } + } + } + if err := fs.Remove(rel); err != nil && !errors.Is(err, tenant.ErrNotFound) { + http.Error(w, "delete failed", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// composeNote serialises title + content. We keep it simple: the +// first line is "# " and the rest is the content as-is. This +// way grep / cat / vim still produce a usable view of the raw file. +func composeNote(title, content string) string { + var b strings.Builder + if strings.TrimSpace(title) != "" { + b.WriteString("# ") + b.WriteString(strings.ReplaceAll(strings.TrimSpace(title), "\n", " ")) + b.WriteString("\n\n") + } + b.WriteString(content) + if !strings.HasSuffix(content, "\n") { + b.WriteString("\n") + } + return b.String() +} + +// splitTitle inverts composeNote: extract a leading "# title" line if +// present, return remaining content. If no H1, title falls back to +// the first non-empty line, and content is the whole input. +func splitTitle(s string) (title, content string) { + lines := strings.SplitN(s, "\n", 2) + first := strings.TrimSpace(lines[0]) + if strings.HasPrefix(first, "# ") { + title = strings.TrimSpace(strings.TrimPrefix(first, "# ")) + if len(lines) > 1 { + content = strings.TrimLeft(lines[1], "\n") + } + return + } + return first, s +} + +func readTitle(fs *tenant.FS, rel string) (string, error) { + data, err := fs.ReadFile(rel) + if err != nil { + return "", err + } + t, _ := splitTitle(string(data)) + if t == "" { + t = strings.TrimSuffix(filepath.Base(rel), ".md") + } + return t, nil +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// SafeNoteID returns a slug for s suitable as a note ID. It is +// exported so cmd/librenotes can derive default IDs from titles. +func SafeNoteID(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z', c >= '0' && c <= '9': + out = append(out, c) + case c == ' ' || c == '-' || c == '_': + if len(out) > 0 && out[len(out)-1] != '-' { + out = append(out, '-') + } + } + } + id := strings.Trim(string(out), "-") + if id == "" { + return strconv.FormatInt(time.Now().UnixNano(), 36) + } + if len(id) > 64 { + id = id[:64] + } + if !noteIDRe.MatchString(id) { + return strconv.FormatInt(time.Now().UnixNano(), 36) + } + return id +} + +// pathSafe is a sanity helper used by tests; not exported. +func pathSafe(p string) bool { + clean := path.Clean(p) + return !strings.Contains(clean, "..") && !strings.HasPrefix(clean, "/") +} + +// ensure usage so go vet doesn't complain when tests are absent. +var _ = pathSafe +var _ = fmt.Sprintf diff --git a/internal/httpapi/notes_test.go b/internal/httpapi/notes_test.go new file mode 100644 index 0000000..4fabc95 --- /dev/null +++ b/internal/httpapi/notes_test.go @@ -0,0 +1,220 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "git.librete.ch/public/librenotes/internal/auth" + "git.librete.ch/public/librenotes/internal/tenant" +) + +func newNotesServer(t *testing.T) (*Server, string) { + t.Helper() + dir := t.TempDir() + signer := auth.NewSigner([]byte("test-secret-32-bytes-of-keymaterial!!")) + var ( + mu sync.Mutex + tenants = map[string]*tenant.FS{} + ) + fsFor := func(uid string) (*tenant.FS, error) { + mu.Lock() + defer mu.Unlock() + if fs, ok := tenants[uid]; ok { + return fs, nil + } + fs, err := tenant.Open(filepath.Join(dir, uid)) + if err != nil { + return nil, err + } + tenants[uid] = fs + t.Cleanup(func() { _ = fs.Close() }) + return fs, nil + } + srv := &Server{ + Auth: auth.Handlers{}, + Signer: signer, + Logger: quietLogger(), + Notes: NotesHandler{FSFor: fsFor}, + } + return srv, dir +} + +func tokenFor(t *testing.T, signer *auth.Signer, uid string) string { + t.Helper() + tok, err := signer.Issue(uid, uid+"@x") + if err != nil { + t.Fatal(err) + } + return tok +} + +func do(t *testing.T, mux http.Handler, method, target, tok string, body any) *httptest.ResponseRecorder { + t.Helper() + var rdr *bytes.Reader + if body != nil { + buf, _ := json.Marshal(body) + rdr = bytes.NewReader(buf) + } else { + rdr = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, target, rdr) + if tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +func TestNotesCRUD(t *testing.T) { + srv, _ := newNotesServer(t) + mux := srv.Routes() + tok := tokenFor(t, srv.Signer, "user-a") + + // PUT + rec := do(t, mux, http.MethodPut, "/api/notes/hello", tok, + map[string]string{"title": "Hello", "content": "world\n"}) + if rec.Code != http.StatusOK { + t.Fatalf("put: %d body=%s", rec.Code, rec.Body) + } + + // GET single + rec = do(t, mux, http.MethodGet, "/api/notes/hello", tok, nil) + if rec.Code != http.StatusOK { + t.Fatalf("get: %d", rec.Code) + } + var n note + _ = json.NewDecoder(rec.Body).Decode(&n) + if n.Title != "Hello" || !strings.Contains(n.Content, "world") { + t.Errorf("got %+v", n) + } + + // GET list + rec = do(t, mux, http.MethodGet, "/api/notes", tok, nil) + if rec.Code != http.StatusOK { + t.Fatalf("list: %d", rec.Code) + } + var summaries []noteSummary + _ = json.NewDecoder(rec.Body).Decode(&summaries) + if len(summaries) != 1 || summaries[0].ID != "hello" { + t.Errorf("list: %+v", summaries) + } + + // DELETE + rec = do(t, mux, http.MethodDelete, "/api/notes/hello", tok, nil) + if rec.Code != http.StatusNoContent { + t.Fatalf("delete: %d", rec.Code) + } + rec = do(t, mux, http.MethodGet, "/api/notes/hello", tok, nil) + if rec.Code != http.StatusNotFound { + t.Errorf("post-delete get: %d", rec.Code) + } +} + +func TestNotesTenantIsolation(t *testing.T) { + srv, _ := newNotesServer(t) + mux := srv.Routes() + tokA := tokenFor(t, srv.Signer, "user-a") + tokB := tokenFor(t, srv.Signer, "user-b") + + do(t, mux, http.MethodPut, "/api/notes/private", tokA, + map[string]string{"title": "Private", "content": "secret"}) + + rec := do(t, mux, http.MethodGet, "/api/notes/private", tokB, nil) + if rec.Code != http.StatusNotFound { + t.Errorf("user B should not see user A's note: %d", rec.Code) + } + + rec = do(t, mux, http.MethodGet, "/api/notes", tokB, nil) + if !strings.Contains(rec.Body.String(), "[]") { + t.Errorf("user B should have empty list, got %s", rec.Body) + } +} + +func TestNotesUnauthenticated(t *testing.T) { + srv, _ := newNotesServer(t) + mux := srv.Routes() + rec := do(t, mux, http.MethodGet, "/api/notes", "", nil) + if rec.Code != http.StatusUnauthorized { + t.Errorf("got %d", rec.Code) + } + rec = do(t, mux, http.MethodPut, "/api/notes/x", "", + map[string]string{"title": "x", "content": "x"}) + if rec.Code != http.StatusUnauthorized { + t.Errorf("got %d", rec.Code) + } +} + +func TestNotesInvalidIDs(t *testing.T) { + srv, _ := newNotesServer(t) + mux := srv.Routes() + tok := tokenFor(t, srv.Signer, "u") + for _, bad := range []string{"WithUpper", "trailing.", "weird%21"} { + rec := do(t, mux, http.MethodGet, "/api/notes/"+bad, tok, nil) + if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound { + t.Errorf("id %q: got %d", bad, rec.Code) + } + } +} + +func TestNotesConflict(t *testing.T) { + srv, _ := newNotesServer(t) + mux := srv.Routes() + tok := tokenFor(t, srv.Signer, "u") + + rec := do(t, mux, http.MethodPut, "/api/notes/conf", tok, + map[string]string{"title": "v1", "content": "first"}) + if rec.Code != http.StatusOK { + t.Fatal(rec.Code) + } + var n note + _ = json.NewDecoder(rec.Body).Decode(&n) + base := n.UpdatedAt + + // Wait so mtime advances on a 1-second filesystem. + time.Sleep(1100 * time.Millisecond) + + // Server-side write (simulating another client) bumps the mtime. + rec = do(t, mux, http.MethodPut, "/api/notes/conf", tok, + map[string]string{"title": "v2", "content": "second"}) + if rec.Code != http.StatusOK { + t.Fatal(rec.Code) + } + + // First client tries to write with the old base; should 409. + target := fmt.Sprintf("/api/notes/conf?base=%d", base) + rec = do(t, mux, http.MethodPut, target, tok, + map[string]string{"title": "v1-updated", "content": "third"}) + if rec.Code != http.StatusConflict { + t.Errorf("expected 409, got %d", rec.Code) + } + var current note + _ = json.NewDecoder(rec.Body).Decode(¤t) + if current.Title != "v2" { + t.Errorf("conflict body should carry server state, got %+v", current) + } +} + +func TestSafeNoteID(t *testing.T) { + cases := map[string]string{ + "Hello World": "hello-world", + " spaces ": "spaces", + "WithUpper": "withupper", + "-leading": "leading", + "trailing-": "trailing", + "weird@chars!": "weirdchars", + } + for in, want := range cases { + if got := SafeNoteID(in); got != want { + t.Errorf("SafeNoteID(%q) = %q want %q", in, got, want) + } + } +} diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index b2d8d01..ca82491 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -15,6 +15,7 @@ type Server struct { Auth auth.Handlers Signer *auth.Signer Logger *log.Logger + Notes NotesHandler } // Routes returns an http.Handler with all routes mounted. @@ -26,6 +27,9 @@ func (s *Server) Routes() http.Handler { protected := http.NewServeMux() protected.HandleFunc("/api/whoami", s.handleWhoami) + if s.Notes.FSFor != nil { + s.Notes.Mount(protected) + } mw := AuthMiddleware(s.Signer, s.Logger) mux.Handle("/api/", mw(protected))