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 <noreply@anthropic.com>
213 lines
6.2 KiB
Go
213 lines
6.2 KiB
Go
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)})
|
|
}
|