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