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