package httpapi import ( "net/http" "github.com/go-chi/chi/v5" "github.com/kristianwind/verdande/internal/store" ) // markShared fills in the two fields the list needs to show a note somebody else // shared with the reader: that it is such a note, and whose it is. // // Done in one pass over the page rather than per note: one query asks which of // these ids are shared with the reader, and one asks for the owners' names and // colours. A note the reader owns is never "shared with me", however it was // reached — the group is other people's notes, not one's own. func (s *Server) markShared(r *http.Request, notes []store.Note) []store.Note { me := userFrom(r.Context()).ID if len(notes) == 0 { return notes } ids := make([]string, 0, len(notes)) for _, n := range notes { if n.CreatedBy != me { ids = append(ids, n.ID) } } shared, err := s.db.NotesSharedWith(r.Context(), me, ids) if err != nil { // The list is worth more than the chip: a note without its "shared" mark // still reads and still opens. Log nothing here — the store already did. return notes } owners := map[string]store.Person{} if len(shared) > 0 { ownerIDs := make([]string, 0, len(shared)) for _, n := range notes { if shared[n.ID] && n.CreatedBy != "" { ownerIDs = append(ownerIDs, n.CreatedBy) } } owners, _ = s.db.PeopleByIDs(r.Context(), ownerIDs) } for i := range notes { if !shared[notes[i].ID] { continue } notes[i].SharedWithMe = true if p, ok := owners[notes[i].CreatedBy]; ok { owner := p notes[i].Owner = &owner } } return notes } // handleListNoteShares is who a note is shared with. Owner only: the list of people // who can see your note is itself something only you should see. func (s *Server) handleListNoteShares(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 } // 404, not 403: telling a stranger "not yours" still confirms the note exists. if n == nil || !s.ownsNote(r, n) { writeError(w, http.StatusNotFound, CodeNotFound, "no such note") return } shares, err := s.db.ListNoteShares(r.Context(), n.ID) if err != nil { s.internal(w, r, "list note shares", err) return } // Regnet forfra, én gang, og læst to veje ud af det samme svar: hvem der har // *denne* note, fordi en anden peger på den, og hvilke noter der omvendt følger // med den her. To udregninger af det samme er to, der kan blive uenige. // // Og forfra frem for læst af basen, så panelet viser det, der gælder nu — også // når linkene er skrevet af en anden og delingerne derfor ikke er regnet om // siden. following, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID) if err != nil { s.internal(w, r, "sync linked shares", err) return } via := map[string]string{} for _, l := range following { if l.NoteID == n.ID { via[l.UserID] = l.Via } } out := make([]noteShareJSON, 0, len(shares)) already := map[string]bool{} for _, sh := range shares { // Panelet skal kunne sige forskel: en deling, nogen har valgt, tages tilbage // ved at fjerne den — en, der er fulgt med, ved at fjerne linket eller den // deling, den kom fra. En knap, der lover det første og gør det andet, er // værre end ingen knap. out = append(out, noteShareJSON{ User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor}, Role: string(sh.Role), Linked: via[sh.User.ID] != "", Via: via[sh.User.ID], }) already[sh.User.ID] = true } // The picker is filled from the same response, so the panel opens complete in // one round-trip. People already on the note are left out — they are in the list // above it, not the "add someone" menu. candidates, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID) if err != nil { s.internal(w, r, "share candidates", err) return } cand := make([]personJSON, 0, len(candidates)) for _, p := range candidates { if already[p.ID] { continue } cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor}) } writeJSON(w, http.StatusOK, map[string]any{ "shares": out, "candidates": cand, "follows": followsOf(following, n.ID), }) } // followsOf er de noter, der følger med *denne* note — én linje pr. note, ikke én // pr. note og person. Panelet spørger på vegne af noten, og svaret "Prisliste // følger med" er det samme, hvem det så end deles med. func followsOf(all []store.LinkedShare, noteID string) []followJSON { seen := map[string]bool{} out := []followJSON{} for _, l := range all { if l.ViaID != noteID || seen[l.NoteID] { continue } seen[l.NoteID] = true out = append(out, followJSON{NoteID: l.NoteID, Title: l.Title}) } return out } type followJSON struct { NoteID string `json:"note_id"` Title string `json:"title"` } type noteShareJSON struct { User personJSON `json:"user"` Role string `json:"role"` // Sat, når personen har noten, fordi en anden delt note peger på den — ikke // fordi nogen har delt netop den. `via` er den note, der peger. Linked bool `json:"linked"` Via string `json:"via,omitempty"` } type shareNoteRequest struct { UserID string `json:"user_id"` Role string `json:"role"` } // handleShareNote grants a person a role on a note, or changes the role they hold. func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) { var req shareNoteRequest if err := decodeJSON(w, r, &req); err != nil { return } n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID")) if err != nil { s.internal(w, r, "get note", err) return } if n == nil || !s.ownsNote(r, n) { writeError(w, http.StatusNotFound, CodeNotFound, "no such note") return } role := store.Role(req.Role) if role == "" { role = store.RoleViewer } if role != store.RoleViewer && role != store.RoleEditor { writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"}) return } if req.UserID == "" { writeFieldErrors(w, map[string]string{"user_id": "required"}) return } // The recipient must be a real account and not the sharer themselves; ShareNote // refuses the owner, and a made-up id should not reach it. if req.UserID == userFrom(r.Context()).ID { writeFieldErrors(w, map[string]string{"user_id": "you already have this note"}) return } if _, err := s.db.PersonByID(r.Context(), req.UserID); err != nil { writeError(w, http.StatusNotFound, CodeNotFound, "no such person") return } if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, userFrom(r.Context()).ID); err != nil { s.storeError(w, r, "share note", err) return } // Og det, noten peger på, følger med. Svaret siger hvad — en deling, der stille // tog tre noter mere med, ville være rigtig og alligevel ikke til at overskue. followed, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID) if err != nil { s.internal(w, r, "sync linked shares", err) return } writeJSON(w, http.StatusOK, map[string]any{"follows": followsOf(followed, n.ID)}) } // handleUnshareNote takes a person's access away again. func (s *Server) handleUnshareNote(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 } if n == nil || !s.ownsNote(r, n) { writeError(w, http.StatusNotFound, CodeNotFound, "no such note") return } if err := s.db.UnshareNote(r.Context(), n.ID, chi.URLParam(r, "userID")); err != nil { s.internal(w, r, "unshare note", err) return } // Det, der fulgte med, følger også med tilbage. Regnet forfra: de udledte rækker // skrives om fra bunden, så en note, der stadig kan nås fra en anden deling, // bliver stående, og en, der ikke kan, forsvinder. if _, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID); err != nil { s.internal(w, r, "sync linked shares", err) return } w.WriteHeader(http.StatusNoContent) }