2026-08-24 07:36:55 +02:00
|
|
|
package httpapi
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
2026-09-08 11:58:47 +02:00
|
|
|
"strings"
|
2026-08-24 07:36:55 +02:00
|
|
|
|
|
|
|
|
"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
|
|
|
|
|
}
|
2026-09-07 22:28:58 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 07:36:55 +02:00
|
|
|
out := make([]noteShareJSON, 0, len(shares))
|
|
|
|
|
already := map[string]bool{}
|
|
|
|
|
for _, sh := range shares {
|
2026-09-07 22:28:58 +02:00
|
|
|
// 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.
|
2026-08-24 07:36:55 +02:00
|
|
|
out = append(out, noteShareJSON{
|
2026-09-07 22:28:58 +02:00
|
|
|
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],
|
2026-08-24 07:36:55 +02:00
|
|
|
})
|
|
|
|
|
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})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 11:58:47 +02:00
|
|
|
// De inviterede står ved siden af dem, der er kommet. Uden dem er en
|
|
|
|
|
// invitation sendt i går usynlig, og så sender man den igen — endnu et link til
|
|
|
|
|
// den samme indbakke, og ingen af dem til at trække tilbage.
|
|
|
|
|
invites, err := s.db.ListNoteInvites(r.Context(), n.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "list note invites", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
pending := make([]noteInviteJSON, 0, len(invites))
|
|
|
|
|
for _, i := range invites {
|
|
|
|
|
pending = append(pending, noteInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 22:28:58 +02:00
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"shares": out,
|
|
|
|
|
"candidates": cand,
|
2026-09-08 11:58:47 +02:00
|
|
|
"invites": pending,
|
2026-09-07 22:28:58 +02:00
|
|
|
"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"`
|
2026-08-24 07:36:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type noteShareJSON struct {
|
|
|
|
|
User personJSON `json:"user"`
|
|
|
|
|
Role string `json:"role"`
|
2026-09-07 22:28:58 +02:00
|
|
|
// 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"`
|
2026-08-24 07:36:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type shareNoteRequest struct {
|
|
|
|
|
UserID string `json:"user_id"`
|
2026-09-08 11:58:47 +02:00
|
|
|
// Email deles der med, når personen ikke er at finde i listen — enten fordi
|
|
|
|
|
// man kender adressen bedre end navnet, eller fordi der ikke er nogen konto at
|
|
|
|
|
// finde endnu.
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
Role string `json:"role"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// noteInviteJSON er en deling, der venter på en konto. Der er ikke noget navn og
|
|
|
|
|
// ingen farve at vise: adressen er alt, hvad der findes om personen indtil de
|
|
|
|
|
// dukker op.
|
|
|
|
|
type noteInviteJSON struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
Role string `json:"role"`
|
2026-08-24 07:36:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleShareNote grants a person a role on a note, or changes the role they hold.
|
2026-09-08 11:58:47 +02:00
|
|
|
//
|
|
|
|
|
// Der er to måder at sige, hvem det er. `user_id` er den, man har peget på i
|
|
|
|
|
// listen. `email` er den, man kender adressen på — og det er den, der også virker,
|
|
|
|
|
// når personen ikke har nogen konto: så bliver delingen til en invitation, der
|
|
|
|
|
// venter, og bliver til en rigtig deling i det øjeblik, kontoen bliver oprettet.
|
|
|
|
|
//
|
|
|
|
|
// Det er ikke en ny magt til nogen. Enhver, der ejer et projekt, kunne i forvejen
|
|
|
|
|
// invitere en fremmed ind på instansen pr. e-mail; det her er den samme handling
|
|
|
|
|
// på en note, der ellers krævede tre skridt og en ventetid: invitér til instansen,
|
|
|
|
|
// vent på at de opretter sig, find noten frem, del den.
|
2026-08-24 07:36:55 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-08 11:58:47 +02:00
|
|
|
|
|
|
|
|
me := userFrom(r.Context())
|
|
|
|
|
email := store.NormalizeEmail(req.Email)
|
|
|
|
|
|
|
|
|
|
if req.UserID == "" && email == "" {
|
2026-08-24 07:36:55 +02:00
|
|
|
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-08 11:58:47 +02:00
|
|
|
|
|
|
|
|
// En adresse, der viser sig at høre til en konto, er den konto. At sende dem
|
|
|
|
|
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en blindgyde
|
|
|
|
|
// — samme regel som en projektinvitation følger.
|
|
|
|
|
if req.UserID == "" {
|
|
|
|
|
if !strings.Contains(email, "@") {
|
|
|
|
|
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if email == store.NormalizeEmail(me.Email) {
|
|
|
|
|
writeFieldErrors(w, map[string]string{"email": "you already have this note"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
existing, err := s.db.UserByEmail(r.Context(), email)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.inviteToNote(w, r, n, email, role)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
req.UserID = existing.ID
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 07:36:55 +02:00
|
|
|
// 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.
|
2026-09-08 11:58:47 +02:00
|
|
|
if req.UserID == me.ID {
|
2026-08-24 07:36:55 +02:00
|
|
|
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-08 11:58:47 +02:00
|
|
|
person, err := s.db.PersonByID(r.Context(), req.UserID)
|
|
|
|
|
if err != nil {
|
2026-08-24 07:36:55 +02:00
|
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 11:58:47 +02:00
|
|
|
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, me.ID); err != nil {
|
2026-08-24 07:36:55 +02:00
|
|
|
s.storeError(w, r, "share note", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-08 11:58:47 +02:00
|
|
|
s.notifyNoteShared(r, n, req.UserID)
|
2026-09-07 22:28:58 +02:00
|
|
|
|
|
|
|
|
// 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.
|
2026-09-08 11:58:47 +02:00
|
|
|
followed, err := s.db.SyncLinkedShares(r.Context(), me.ID)
|
2026-09-07 22:28:58 +02:00
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "sync linked shares", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-08 11:58:47 +02:00
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"follows": followsOf(followed, n.ID),
|
|
|
|
|
// Personen sendes med retur, fordi fladen kan have delt med en adresse og
|
|
|
|
|
// derfor ikke selv ved, hvem den ramte.
|
|
|
|
|
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
|
|
|
|
|
"role": string(role),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// inviteToNote sends a link that creates an account and the share at once.
|
|
|
|
|
//
|
|
|
|
|
// Linket vises i svaret, når der ikke er nogen post at sende det med. En
|
|
|
|
|
// invitation, der forsvandt, fordi instansen ikke har en SMTP-server, ville se ud
|
|
|
|
|
// som om den var sendt — og først blive opdaget som ikke-sendt af den, der ventede
|
|
|
|
|
// på den.
|
|
|
|
|
func (s *Server) inviteToNote(w http.ResponseWriter, r *http.Request, n *store.Note, email string, role store.Role) {
|
|
|
|
|
me := userFrom(r.Context())
|
|
|
|
|
|
|
|
|
|
// En anden invitation til den samme note i den samme indbakke er ikke en
|
|
|
|
|
// invitation mere: det er to links, der gør det samme, og kun det ene kan
|
|
|
|
|
// trækkes tilbage ad gangen.
|
|
|
|
|
pending, err := s.db.PendingNoteInvite(r.Context(), n.ID, email)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "pending note invite", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if pending {
|
|
|
|
|
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited to this note")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
token, inv, err := s.db.CreateNoteInvite(r.Context(), email, n.ID, role, me.ID, s.cfg.InviteTTL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "create note invite", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
link := s.cfg.BaseURL + "/invite?token=" + token
|
|
|
|
|
|
|
|
|
|
emailed := false
|
|
|
|
|
if s.mail.Configured() {
|
|
|
|
|
if err := s.mail.SendNoteInvite(r.Context(), email, me.Name, n.Title, link, s.cfg.InviteTTL); err != nil {
|
|
|
|
|
s.log.Error("send note invite", "err", err, "to", email)
|
|
|
|
|
} else {
|
|
|
|
|
emailed = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
|
|
|
"invited": noteInviteJSON{ID: inv.ID, Email: inv.Email, Role: string(inv.Role)},
|
|
|
|
|
"link": link,
|
|
|
|
|
"emailed": emailed,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleDeleteNoteInvite withdraws an invitation before it is used.
|
|
|
|
|
//
|
|
|
|
|
// Det er den eneste vej tilbage. Kun aftrykket af tokenet er gemt, så et link, der
|
|
|
|
|
// er sendt til den forkerte adresse, kan ikke findes frem og gøres ugyldigt — det
|
|
|
|
|
// kan kun rækken, det hører til.
|
|
|
|
|
func (s *Server) handleDeleteNoteInvite(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.DeleteNoteInvite(r.Context(), n.ID, chi.URLParam(r, "inviteID")); err != nil {
|
|
|
|
|
s.storeError(w, r, "delete note invite", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
2026-08-24 07:36:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
2026-09-07 22:28:58 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-08-24 07:36:55 +02:00
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}
|