Add tenant-scoped notes REST API

internal/httpapi/notes.go exposes:
- GET    /api/notes            list summaries {id, title, updated_at}
- GET    /api/notes/{id}       full {id, title, content, updated_at}
- PUT    /api/notes/{id}       create/update; ?base=<unix> for
                                optimistic-locking conflict detection
- DELETE /api/notes/{id}       remove; ?base=<unix> guards against
                                deleting a row modified after the
                                client last saw it

Backed by tenant.FS so all reads/writes go through the per-user
sandbox — path traversal is rejected at parse time (regex slug)
and again by os.Root inside the FS layer.

On-disk format is plain Markdown: first line `# Title`, rest is
content. grep / cat / vim still produce a usable view of raw
files. Title round-trips through composeNote/splitTitle.

Conflict semantics: when the client supplies ?base=<unix>, the
server compares against the file's mtime. If the file is newer,
respond 409 with the current note body so the client can present
a merge UI. Same logic on DELETE returns 409 alone.

cmd/librenotes/serve.go grows a tenantPool that memoises FS
handles per user id; defer-closes them on shutdown.

Tests cover: full CRUD round-trip, cross-tenant isolation,
unauthenticated 401s, invalid IDs (regex rejection), and the
conflict path with a real mtime advance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:42:43 +02:00
co-authored by Claude Opus 4.7
parent 49ad467aa9
commit cdc7f26269
4 changed files with 581 additions and 0 deletions
+316
View File
@@ -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=<unix-seconds> 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=<unix>, 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 "# <title>" 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