Files
verdande/internal/httpapi/notes_handlers.go
T

287 lines
8.8 KiB
Go
Raw Normal View History

package httpapi
import (
"archive/zip"
"fmt"
"io"
"net/http"
"strings"
"time"
"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
}
// Everything they can see, not only the loose ones. Filing a note in a project
// is how it gets shared, and a list that dropped it at that moment made sharing
// look like deleting.
found, err := s.db.AllNotes(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)
}
// No title. It is the note's first line and is derived on every save — a field
// here would be a promise this server cannot keep.
type noteBody struct {
ProjectID *string `json:"project_id"`
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.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.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)})
}
// handleExportNotes writes every note this person can see as a zip of Markdown
// files, one per note.
//
// A zip of plain files rather than one bundle in a format of our own: what comes
// out is what was stored, and it opens in Obsidian, in a text editor, in anything.
// That is the promise the whole design was arranged around — the note on disk is
// already the file you would export — and this is where it is either kept or not.
func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
notes, err := s.db.AllNotes(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "export notes", err)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition",
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
zw := zip.NewWriter(w)
defer zw.Close()
// Names collide: two notes called "Møde" are two notes, and a zip with one entry
// twice is a zip that loses one of them.
used := map[string]int{}
for _, n := range notes {
name := noteFilename(n.Title)
used[name]++
if used[name] > 1 {
name = fmt.Sprintf("%s (%d)", name, used[name])
}
f, err := zw.CreateHeader(&zip.FileHeader{
Name: name + ".md",
Method: zip.Deflate,
Modified: n.UpdatedAt,
})
if err != nil {
// The response is already streaming, so there is no status left to send.
// Stopping leaves a truncated zip, which a zip tool says is truncated —
// better than a complete-looking file with a note missing from it.
s.log.Error("export notes", "err", err, "note", n.ID)
return
}
if _, err := io.WriteString(f, n.Body); err != nil {
s.log.Error("export notes", "err", err, "note", n.ID)
return
}
}
}
// noteFilename makes a title safe to be a filename on any of the three systems
// people actually use, without turning it into something unrecognisable.
func noteFilename(title string) string {
name := strings.TrimSpace(title)
if name == "" {
name = "uden titel"
}
// Windows refuses these outright; a slash would make directories on the others.
name = strings.Map(func(r rune) rune {
if strings.ContainsRune(`/\:*?"<>|`, r) || r < 0x20 {
return '-'
}
return r
}, name)
// Long titles are the first line of a note, which can be a whole sentence.
if len([]rune(name)) > 80 {
name = string([]rune(name)[:80])
}
return strings.TrimRight(name, " .")
}