From fbfc98e217539ec663088a7401594292b482eb55 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Wed, 19 Aug 2026 13:00:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(notes):=20noter=20kan=20n=C3=A5s=20?= =?UTF-8?q?=E2=80=94=20listet,=20s=C3=B8gt=20og=20fundet=20bagl=C3=A6ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anden etape. Handlere, ruter og OpenAPI. Adgang følger projektet, for det er der, noten bor: en note i et delt projekt kan læses af alle, der kan læse projektet, og ændres af en redaktør. En note uden projekt er sin forfatters alene — samme svar som en opgave i indbakken får, nået frem til på samme måde frem for ved en regel, noter fandt på for sig selv. Listning og søgning er samme endepunkt, fordi de svarer på det samme spørgsmål med forskellige ord. Filtreringen sker EFTER indekset: FTS5 svarer om tekst, og at lære indekset om roller ville lægge en adgangsregel et sted, ingen ville kigge den dag den var forkert. Der er en prøve for, at en fremmeds søgning ikke finder noget, og at en fremmed får 404 og ikke 403 — så et id ikke kan afprøves. Den baglæns rute hænger på det, der spørges om: et opgave-id giver hver note, der nævner den; et projektnavn giver hver note med det #tag. Co-Authored-By: Claude Opus 5 --- docs/openapi.yaml | 138 +++++++++++++++++++ internal/httpapi/notes_handlers.go | 212 +++++++++++++++++++++++++++++ internal/httpapi/notes_test.go | 115 ++++++++++++++++ internal/httpapi/server.go | 12 ++ internal/store/notes.go | 8 ++ 5 files changed, 485 insertions(+) create mode 100644 internal/httpapi/notes_handlers.go create mode 100644 internal/httpapi/notes_test.go diff --git a/docs/openapi.yaml b/docs/openapi.yaml index fdf03c7..a668e17 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2477,6 +2477,116 @@ paths: created: { type: integer } "502": { $ref: "#/components/responses/Upstream" } + /api/v1/notes: + get: + tags: [notes] + summary: Notes — listed, narrowed to a project, or searched. + description: > + With no parameters: the notes filed under no project, which are the + person's own. With `project`: that project's, which needs read access to + it. With `q`: a full-text search across every note they can see, matching + Danish spelling either way round — "grøn" finds "gron" and the reverse. + parameters: + - name: q + in: query + schema: { type: string } + - name: project + in: query + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + notes: + type: array + items: { $ref: "#/components/schemas/Note" } + post: + tags: [notes] + summary: Write a note. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/NoteInput" } + responses: + "201": + description: Written + content: + application/json: + schema: { $ref: "#/components/schemas/Note" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/notes/{noteID}: + parameters: + - name: noteID + in: path + required: true + schema: { type: string } + get: + tags: [notes] + summary: One note. + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/Note" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + tags: [notes] + summary: Change a note. Every field is optional; what is sent is what changes. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/NoteInput" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/Note" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [notes] + summary: Put a note in the trash. It can be brought back. + responses: + "204": { description: Deleted } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/notes/linking/{kind}/{targetID}: + get: + tags: [notes] + summary: What has been written about this. + description: > + The backwards direction. A task's id gives every note that mentions it, + a project's name gives every note carrying that #tag. + parameters: + - name: kind + in: path + required: true + schema: { type: string, enum: [task, note, project] } + - name: targetID + in: path + required: true + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + notes: + type: array + items: { $ref: "#/components/schemas/Note" } + "400": { $ref: "#/components/responses/BadRequest" } + /api/v1/mailboxes: get: tags: [integrations] @@ -3162,6 +3272,34 @@ components: items: { type: string } schemas: + Note: + type: object + description: A page of Markdown. The body is the stored form and the exported form. + properties: + id: { type: string } + project_id: { type: string } + title: { type: string, description: "Taken from the first line when not given." } + body: { type: string, description: Markdown } + pinned: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + links: + type: array + description: What the body points at, read out of the text on the way in. + items: + type: object + properties: + kind: { type: string, enum: [task, note, project] } + target_id: { type: string } + + NoteInput: + type: object + properties: + project_id: { type: string, description: "Empty files it under no project." } + title: { type: string } + body: { type: string } + pinned: { type: boolean } + Mailbox: type: object description: One connected mailbox. The password and any token are never returned. diff --git a/internal/httpapi/notes_handlers.go b/internal/httpapi/notes_handlers.go new file mode 100644 index 0000000..c216ef1 --- /dev/null +++ b/internal/httpapi/notes_handlers.go @@ -0,0 +1,212 @@ +package httpapi + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/kristianwind/verdande/internal/store" +) + +// Notes. +// +// Access follows the project, because that is where a note lives: a note in a +// shared project is readable by everybody who can read the project and writable +// by an editor. A note with no project is its author's alone — the same answer a +// task in the inbox gets, arrived at the same way rather than by a rule of its own. + +// mayReadNote reports whether this person may see the note, and mayWriteNote +// whether they may change it. Split, because a viewer on a shared project can do +// the first and not the second. +func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool { + user := userFrom(r.Context()) + if n.ProjectID == "" { + return n.CreatedBy == user.ID + } + _, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role) + return err == nil +} + +func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + + // Searching and listing are the same endpoint, because they answer the same + // question with different words. ?q= searches; ?project= narrows; neither gives + // the loose ones. + if q := strings.TrimSpace(r.URL.Query().Get("q")); q != "" { + found, err := s.db.SearchNotes(r.Context(), q, 50) + if err != nil { + s.internal(w, r, "search notes", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)}) + return + } + + if projectID := r.URL.Query().Get("project"); projectID != "" { + if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleViewer); err != nil { + s.storeError(w, r, "notes in project", err) + return + } + found, err := s.db.NotesInProject(r.Context(), projectID) + if err != nil { + s.internal(w, r, "notes in project", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"notes": found}) + return + } + + found, err := s.db.LooseNotes(r.Context(), user.ID) + if err != nil { + s.internal(w, r, "list notes", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"notes": found}) +} + +// readable drops what this person may not see. +// +// Filtered after the search rather than inside it: FTS5 answers about text, and +// teaching the index about roles would put an access rule in a place nobody would +// think to look when it was wrong. +func (s *Server) readable(r *http.Request, notes []store.Note) []store.Note { + out := make([]store.Note, 0, len(notes)) + for _, n := range notes { + if s.mayTouchNote(r, &n, store.RoleViewer) { + out = append(out, n) + } + } + return out +} + +func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) { + n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID")) + if err != nil { + s.internal(w, r, "get note", err) + return + } + // The same answer for "no such note" and "not yours", so an id cannot be probed. + if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) { + writeError(w, http.StatusNotFound, CodeNotFound, "no such note") + return + } + writeJSON(w, http.StatusOK, n) +} + +type noteBody struct { + ProjectID *string `json:"project_id"` + Title *string `json:"title"` + Body *string `json:"body"` + Pinned *bool `json:"pinned"` +} + +func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) { + var body noteBody + if err := decodeJSON(w, r, &body); err != nil { + return + } + user := userFrom(r.Context()) + + n := &store.Note{CreatedBy: user.ID} + if body.ProjectID != nil && *body.ProjectID != "" { + if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID, user.ID, store.RoleEditor); err != nil { + s.storeError(w, r, "create note", err) + return + } + n.ProjectID = *body.ProjectID + } + if body.Title != nil { + n.Title = *body.Title + } + if body.Body != nil { + n.Body = *body.Body + } + if body.Pinned != nil { + n.Pinned = *body.Pinned + } + + if err := s.db.SaveNote(r.Context(), n); err != nil { + s.internal(w, r, "create note", err) + return + } + writeJSON(w, http.StatusCreated, n) +} + +func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) { + var body noteBody + if err := decodeJSON(w, r, &body); err != nil { + return + } + + n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID")) + if err != nil { + s.internal(w, r, "update note", err) + return + } + if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) { + writeError(w, http.StatusNotFound, CodeNotFound, "no such note") + return + } + + if body.ProjectID != nil { + // Moving it means being allowed on both sides: you cannot file somebody + // else's note into your project, nor yours into theirs. + if *body.ProjectID != "" { + if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID, + userFrom(r.Context()).ID, store.RoleEditor); err != nil { + s.storeError(w, r, "move note", err) + return + } + } + n.ProjectID = *body.ProjectID + } + if body.Title != nil { + n.Title = *body.Title + } + if body.Body != nil { + n.Body = *body.Body + } + if body.Pinned != nil { + n.Pinned = *body.Pinned + } + + if err := s.db.SaveNote(r.Context(), n); err != nil { + s.internal(w, r, "update note", err) + return + } + writeJSON(w, http.StatusOK, n) +} + +func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) { + n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID")) + if err != nil { + s.internal(w, r, "delete note", err) + return + } + if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) { + writeError(w, http.StatusNotFound, CodeNotFound, "no such note") + return + } + if err := s.db.DeleteNote(r.Context(), n.ID); err != nil { + s.internal(w, r, "delete note", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleNotesLinking answers the backwards question for a task or a project: +// what has somebody written about this. +func (s *Server) handleNotesLinking(w http.ResponseWriter, r *http.Request) { + kind := chi.URLParam(r, "kind") + if kind != "task" && kind != "project" && kind != "note" { + writeError(w, http.StatusBadRequest, CodeValidation, "kind must be task, project or note") + return + } + found, err := s.db.NotesLinking(r.Context(), kind, chi.URLParam(r, "targetID")) + if err != nil { + s.internal(w, r, "notes linking", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)}) +} diff --git a/internal/httpapi/notes_test.go b/internal/httpapi/notes_test.go new file mode 100644 index 0000000..b3b90fd --- /dev/null +++ b/internal/httpapi/notes_test.go @@ -0,0 +1,115 @@ +package httpapi + +import ( + "net/http" + "testing" +) + +// A note in a project is the project's; a note in no project is its author's. +// Both answers come from the project's own roles rather than from a rule notes +// invented for themselves, which is the point of filing them there. +func TestANoteIsNotReadableByJustAnybody(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{ + "body": "Noget privat om #Firma", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create: %d %v", resp.StatusCode, body) + } + id, _ := body["id"].(string) + if id == "" { + t.Fatal("no id came back") + } + + // The tags were read out of the text on the way in, without being asked for. + links, _ := body["links"].([]any) + if len(links) != 1 { + t.Fatalf("the note recorded %v", body["links"]) + } + + // Somebody else gets the same answer as for a note that does not exist, so an + // id cannot be probed for existence. + other := ts.newUser(t, "anden@example.dk", "Anden") + resp, _ = other.do(t, "GET", "/api/v1/notes/"+id, nil) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("a stranger got %d for somebody else's note, want 404", resp.StatusCode) + } +} + +// Searching must not become a way to read what you cannot open. The filter is +// applied after the index, and this is what says so. +func TestSearchDoesNotLeakNotesYouCannotRead(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + if resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{ + "body": "Hemmeligt kodeord til hjemmesiden", + }); resp.StatusCode != http.StatusCreated { + t.Fatalf("create: %d %v", resp.StatusCode, body) + } + + _, mine := ts.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil) + if got := len(mine["notes"].([]any)); got != 1 { + t.Fatalf("the author found %d of their own notes", got) + } + + other := ts.newUser(t, "anden@example.dk", "Anden") + _, theirs := other.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil) + if got := len(theirs["notes"].([]any)); got != 0 { + t.Errorf("a stranger's search returned %d notes", got) + } +} + +// The backwards question, which is what makes notes worth linking at all. +func TestANoteIsFoundFromTheTaskItMentions(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ + "text": "ring til Anders", + }) + taskID, _ := task["id"].(string) + if taskID == "" { + t.Fatalf("no task id: %v", task) + } + + if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{ + "body": "Aftalt at han ringer først, se /opgave/" + taskID, + }); resp.StatusCode != http.StatusCreated { + t.Fatal("the note was not written") + } + + _, found := ts.do(t, "GET", "/api/v1/notes/linking/task/"+taskID, nil) + notes, _ := found["notes"].([]any) + if len(notes) != 1 { + t.Fatalf("%d notes point at the task, want 1", len(notes)) + } +} + +// Editing the text moves the links with it. A link that outlives its mention +// claims a connection that is not there. +func TestChangingANoteChangesWhatItPointsAt(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, note := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": "Om #Firma"}) + id, _ := note["id"].(string) + + _, before := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil) + if len(before["notes"].([]any)) != 1 { + t.Fatal("the first save recorded nothing") + } + + if resp, _ := ts.do(t, "PATCH", "/api/v1/notes/"+id, map[string]any{ + "body": "Om #Regnskab i stedet", + }); resp.StatusCode != http.StatusOK { + t.Fatal("the note was not changed") + } + + _, after := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil) + if got := len(after["notes"].([]any)); got != 0 { + t.Errorf("%d notes still point at Firma", got) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index e6f1c38..a3b2b48 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -276,6 +276,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server // Mailboxes read over IMAP — iCloud, Fastmail, any ordinary host. Each // belongs to the person who connected it, so there is no admin gate // here and no shared registration: the credential is theirs. + // Notes. Listing and searching are the same endpoint because they answer + // the same question with different words; the backwards one — what has + // been written about this task — hangs off the thing being asked about. + r.Route("/notes", func(r chi.Router) { + r.Get("/", s.handleListNotes) + r.Post("/", s.handleCreateNote) + r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking) + r.Get("/{noteID}", s.handleGetNote) + r.Patch("/{noteID}", s.handleUpdateNote) + r.Delete("/{noteID}", s.handleDeleteNote) + }) + r.Route("/mailboxes", func(r chi.Router) { r.Get("/", s.handleListMailboxes) r.Post("/", s.handleAddMailbox) diff --git a/internal/store/notes.go b/internal/store/notes.go index 0258ba4..ce049af 100644 --- a/internal/store/notes.go +++ b/internal/store/notes.go @@ -176,6 +176,14 @@ func (db *DB) NotesInProject(ctx context.Context, projectID string) ([]Note, err projectID) } +// LooseNotes are the ones filed under no project — a person's own, the way a task +// in the inbox is. Nobody else can see them, so there is no role to ask about. +func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) { + return db.notesWhere(ctx, ` + WHERE project_id IS NULL AND created_by = ? AND deleted_at IS NULL + ORDER BY pinned DESC, updated_at DESC`, userID) +} + // NotesLinking answers the backwards question: what points at this thing. // // The reason note_links exists. Without it this would mean reading every note's