Spørgsmålet var, om modtageren ikke burde godkende først. Svaret er nej — men det, spørgsmålet fik øje på, var rigtigt, og værre end det lød. Tre ting gjaldt samtidig: man blev tilføjet uden at blive spurgt, man fik ikke besked, og man kunne ikke gå ud igen. En delt note gav en klokkebesked; et delt projekt gav ingenting — det stod bare i sidebjælken næste gang, man kiggede, uden at noget sagde hvornår eller fra hvem. Og handleRemoveMember lå bag ejer-kravet, så kun ejeren kunne fjerne nogen, også en selv. Hvert enkelt af de tre er til at leve med. Alle tre sammen gør en deling til noget, der sker for en. Ikke et godkendelsestrin: en invitation, der venter på et ja, betyder, at den, der delte, tror det er gjort, mens arbejdet ligger stille — og noter deles allerede uden. Det, der manglede, var besked og en udgang. `project.shared` er ny. Klokkens follow() kunne allerede føre et project_id hen til projektet, så beskeden bærer sit eget mål og krævede ingen særregel. Ruten til at fjerne et medlem er flyttet ud af ejer-gruppen, og handleren skiller nu de to ting ad: ejeren, der fjerner nogen, og den, der selv går. At pege på en andens medlemskab uden at eje projektet svarer 404 og ikke 403 — "det må du ikke" bekræfter, at medlemskabet findes. Ejeren kan stadig ikke gå fra sit eget projekt; det ville være en overdragelse, og den findes ikke endnu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
485 lines
18 KiB
Go
485 lines
18 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/kristianwind/verdande/internal/push"
|
|
"github.com/kristianwind/verdande/internal/safedial"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
type notificationJSON struct {
|
|
ID string `json:"id"`
|
|
ActorName string `json:"actor_name,omitempty"`
|
|
ProjectID string `json:"project_id,omitempty"`
|
|
TaskID string `json:"task_id,omitempty"`
|
|
NoteID string `json:"note_id,omitempty"`
|
|
Kind string `json:"kind"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body,omitempty"`
|
|
Read bool `json:"read"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
|
|
list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
|
|
if err != nil {
|
|
s.internal(w, r, "list notifications", err)
|
|
return
|
|
}
|
|
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
|
|
if err != nil {
|
|
s.internal(w, r, "count notifications", err)
|
|
return
|
|
}
|
|
|
|
out := make([]notificationJSON, 0, len(list))
|
|
for _, n := range list {
|
|
out = append(out, notificationJSON{
|
|
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
|
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
|
Read: n.ReadAt != nil, CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
|
|
}
|
|
|
|
func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
|
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
|
|
if err != nil {
|
|
s.internal(w, r, "mark notifications read", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// notify delivers one notification through every channel the person has: stored for
|
|
// the bell, pushed live to any open tab, and sent to their devices as Web Push.
|
|
//
|
|
// Never fails the request that caused it. Somebody assigning a task must not see an
|
|
// error because a push service was slow.
|
|
func (s *Server) notify(r *http.Request, n *store.Notification) {
|
|
if n.UserID == "" || n.UserID == n.ActorID {
|
|
return // nobody needs telling what they just did themselves
|
|
}
|
|
if err := s.db.CreateNotification(r.Context(), n); err != nil {
|
|
s.log.Warn("create notification", "err", err)
|
|
return
|
|
}
|
|
|
|
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
|
|
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
|
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
|
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
|
})
|
|
|
|
// Detached from the request: a push round trip to Google or Mozilla has no
|
|
// business holding up the response to the person who caused it.
|
|
go s.pushToUser(n.UserID, n.Title, n.Body, n.ProjectID)
|
|
}
|
|
|
|
// notifyProject tells everybody who can see a project, except whoever did it.
|
|
func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind, title, body string) {
|
|
members, err := s.db.ProjectMemberIDs(r.Context(), projectID)
|
|
if err != nil {
|
|
s.log.Warn("project members for notification", "err", err)
|
|
return
|
|
}
|
|
for _, userID := range members {
|
|
if userID == actorID {
|
|
continue
|
|
}
|
|
s.notify(r, &store.Notification{
|
|
UserID: userID, ActorID: actorID, ProjectID: projectID, TaskID: taskID,
|
|
Kind: kind, Title: title, Body: body,
|
|
})
|
|
}
|
|
}
|
|
|
|
// notifyAssigned tells somebody a task is now theirs.
|
|
//
|
|
// Kun ved *skiftet*, og kun til den, der får den. En opgave, der bliver rørt ti
|
|
// gange bagefter, er stadig den samme besked — og den, der uddelegerer, ved godt
|
|
// hvad de lige har gjort.
|
|
func (s *Server) notifyAssigned(r *http.Request, t *store.Task) {
|
|
if t == nil || t.AssigneeID == "" {
|
|
return
|
|
}
|
|
actor := userFrom(r.Context())
|
|
s.notify(r, &store.Notification{
|
|
UserID: t.AssigneeID, ActorID: actor.ID, ProjectID: t.ProjectID, TaskID: t.ID,
|
|
Kind: "assigned", Title: actor.Name + " gav dig en opgave", Body: t.Content,
|
|
})
|
|
}
|
|
|
|
// notifyNoteShared tells somebody that a note is now theirs to read.
|
|
//
|
|
// Én besked, når delingen sker, og ikke noget bagefter: den, der får en note delt,
|
|
// skal vide, at den er der. At den senere bliver rettet, er en anden besked — den
|
|
// skriver notifyNoteChanged — og de to må ikke sige det samme om det samme.
|
|
//
|
|
// Ikke sammenlagt med noget. En deling sker én gang pr. person pr. note, så der er
|
|
// ikke en gentagelse at folde sammen — og bliver rollen ændret bagefter, er det
|
|
// ikke en ny note at læse.
|
|
func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string) {
|
|
if n == nil || userID == "" {
|
|
return
|
|
}
|
|
actor := userFrom(r.Context())
|
|
s.notify(r, &store.Notification{
|
|
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
|
|
Kind: "note.shared", Title: actor.Name + " delte en note med dig", Body: n.Title,
|
|
})
|
|
}
|
|
|
|
// notifyProjectShared tells somebody that a project is now theirs to work in.
|
|
//
|
|
// Indtil nu skete det i tavshed. En note delt med dig gav en besked; et projekt gav
|
|
// ingenting — det stod bare i sidebjælken næste gang, du kiggede, uden at noget
|
|
// sagde hvornår eller fra hvem. Et projekt er mere end en note: der kan lægges
|
|
// opgaver til dig i det.
|
|
//
|
|
// Én besked, når delingen sker. En rolle, der ændres bagefter, er ikke et nyt
|
|
// projekt at åbne, og en invitation, der bliver taget imod, er modtagerens egen
|
|
// handling — de skal ikke have besked om det, de lige selv gjorde.
|
|
func (s *Server) notifyProjectShared(r *http.Request, project *store.Project, userID string) {
|
|
if project == nil || userID == "" {
|
|
return
|
|
}
|
|
actor := userFrom(r.Context())
|
|
if actor.ID == userID {
|
|
return
|
|
}
|
|
s.notify(r, &store.Notification{
|
|
UserID: userID, ActorID: actor.ID, ProjectID: project.ID,
|
|
Kind: "project.shared", Title: actor.Name + " delte et projekt med dig", Body: project.Name,
|
|
})
|
|
}
|
|
|
|
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
|
// it — folded into one line per note while it is unread.
|
|
//
|
|
// `except` er dem, den samme gemning allerede har sagt noget mere præcist til:
|
|
// den, der lige er blevet nævnt ved navn, skal ikke også høre, at noten er rettet.
|
|
//
|
|
// Skrevet som ét kald pr. modtager frem for ét pr. note, fordi sammenlægningen er
|
|
// pr. person: to mennesker kan have læst den forrige besked på hvert sit tidspunkt,
|
|
// og den ene skal have en ny linje, mens den anden får sin gamle rykket op.
|
|
func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note, except map[string]bool) {
|
|
if n == nil {
|
|
return
|
|
}
|
|
actor := userFrom(r.Context())
|
|
audience, err := s.db.NoteAudience(r.Context(), n.ID)
|
|
if err != nil {
|
|
s.log.Warn("note audience for notification", "err", err, "note", n.ID)
|
|
return
|
|
}
|
|
title := actor.Name + " rettede en note"
|
|
body := n.Title
|
|
for _, userID := range audience {
|
|
if userID == actor.ID || except[userID] {
|
|
continue
|
|
}
|
|
// Fold first, and only write a row when there was nothing to fold into.
|
|
// Den anden vej rundt — skriv, og ryd op bagefter — efterlader et vindue,
|
|
// hvor klokken siger to.
|
|
folded, err := s.db.BumpUnreadNoteNotification(r.Context(), userID, n.ID, actor.ID, title, body)
|
|
if err != nil {
|
|
s.log.Warn("fold note notification", "err", err, "note", n.ID)
|
|
continue
|
|
}
|
|
if folded {
|
|
// Klokken skal stadig vide det: tallet er uændret, men linjen er rykket
|
|
// op og siger nu et andet navn. Uden det her opdager en åben fane først
|
|
// den sammenlagte rettelse ved næste genindlæsning.
|
|
s.hub.PublishToUser(userID, "notification.folded", map[string]any{"note_id": n.ID})
|
|
continue
|
|
}
|
|
s.notify(r, &store.Notification{
|
|
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
|
|
Kind: "note.changed", Title: title, Body: body,
|
|
})
|
|
}
|
|
}
|
|
|
|
// PushToUser is pushToUser for callers outside this package — the reminder job,
|
|
// which has the schedule but not the keys.
|
|
func (s *Server) PushToUser(userID, title, body, projectID string) {
|
|
s.pushToUser(userID, title, body, projectID)
|
|
}
|
|
|
|
// pushOutcome is what happened, per device.
|
|
//
|
|
// It exists because "nothing arrives" was unanswerable. A push that a service
|
|
// refuses is the ordinary failure here — a stale subscription, a VAPID subject the
|
|
// service will not take, a browser that revoked permission without telling the
|
|
// server — and every one of them looked exactly like silence.
|
|
type pushOutcome struct {
|
|
Subscriptions int `json:"subscriptions"`
|
|
Sent int `json:"sent"`
|
|
Failed []pushFailure `json:"failed"`
|
|
}
|
|
|
|
// pushFailure names the service and quotes what it said.
|
|
//
|
|
// The host and not the endpoint: the rest of that URL is the credential the push
|
|
// service issued for this device, and it does not belong in an answer, a log line
|
|
// or a screenshot.
|
|
type pushFailure struct {
|
|
Service string `json:"service"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
|
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
|
|
defer cancel()
|
|
|
|
var out pushOutcome
|
|
subs, err := s.db.ListPushSubscriptions(ctx, userID)
|
|
if err != nil || len(subs) == 0 {
|
|
return out
|
|
}
|
|
out.Subscriptions = len(subs)
|
|
|
|
public, private, err := s.vapidKeys(ctx)
|
|
if err != nil {
|
|
s.log.Warn("vapid keys", "err", err)
|
|
out.Failed = append(out.Failed, pushFailure{Service: "verdande", Reason: err.Error()})
|
|
return out
|
|
}
|
|
|
|
target := "/projekt/" + projectID
|
|
if projectID == "" {
|
|
target = "/"
|
|
}
|
|
// Mærket på ikonet følger med beskeden. Talt her frem for i service-workeren,
|
|
// som ikke har nogen hukommelse mellem to opvågninger og derfor kun kunne lægge
|
|
// én til noget, den ikke kender — et mærke, der tæller sig selv op og aldrig
|
|
// ned. Fejler tællingen, sendes beskeden alligevel: en besked uden et tal er
|
|
// stadig en besked.
|
|
unread, err := s.db.UnreadNotificationCount(ctx, userID)
|
|
if err != nil {
|
|
s.log.Warn("unread count for push", "err", err)
|
|
}
|
|
payload := push.Payload{Title: title, Body: body, URL: target, Unread: unread}
|
|
subject := s.vapidSubject()
|
|
for _, sub := range subs {
|
|
err := push.Send(ctx, push.Subscription{
|
|
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
|
}, payload, push.VAPID{
|
|
Public: public, Private: private, Subject: subject,
|
|
})
|
|
if err == nil {
|
|
out.Sent++
|
|
if delErr := s.db.MarkPushDelivered(ctx, sub.Endpoint); delErr != nil {
|
|
s.log.Warn("mark push delivered", "err", delErr)
|
|
}
|
|
continue
|
|
}
|
|
// Warn, not Debug.
|
|
//
|
|
// This was the one line that would have said why no notification ever
|
|
// arrived, and at Debug it is not printed on an instance running at the
|
|
// default level. A delivery that fails silently and a delivery that does
|
|
// not happen look the same from the outside, and the difference is the
|
|
// whole diagnosis.
|
|
reason := pushReason(sub.Endpoint, err)
|
|
s.log.Warn("web push refused", "reason", reason, "service", pushHost(sub.Endpoint), "user", userID)
|
|
out.Failed = append(out.Failed, pushFailure{
|
|
Service: pushHost(sub.Endpoint),
|
|
Reason: reason,
|
|
})
|
|
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
|
|
s.log.Warn("record push failure", "err", recErr)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pushReason is what went wrong, with the endpoint taken back out of it.
|
|
//
|
|
// Go wraps a transport failure as `*url.Error`, and its message is `Post
|
|
// "https://fcm.googleapis.com/fcm/send/dQw4w9…": dial tcp: …` — the whole
|
|
// endpoint, which is the credential the push service issued for that device. It
|
|
// would have gone into the answer, the log line and any screenshot of either. A
|
|
// test written to prove the host was reported instead is what found it.
|
|
//
|
|
// Unwrapped first, so the cause is what is quoted. Then the endpoint is scrubbed
|
|
// anyway: the next error type to carry it will not come with a warning.
|
|
func pushReason(endpoint string, err error) string {
|
|
var urlErr *url.Error
|
|
if errors.As(err, &urlErr) && urlErr.Err != nil {
|
|
err = urlErr.Err
|
|
}
|
|
reason := err.Error()
|
|
if endpoint != "" {
|
|
reason = strings.ReplaceAll(reason, endpoint, pushHost(endpoint))
|
|
}
|
|
return reason
|
|
}
|
|
|
|
// pushHost is the service an endpoint belongs to, and nothing else from it.
|
|
func pushHost(endpoint string) string {
|
|
u, err := url.Parse(endpoint)
|
|
if err != nil || u.Host == "" {
|
|
return "ukendt"
|
|
}
|
|
return u.Host
|
|
}
|
|
|
|
// handlePushTest sends one notification to this account's devices and says what
|
|
// happened to it.
|
|
//
|
|
// Reported as "der kommer ikke nogen". Everything about that was invisible: the
|
|
// settings page said "on for this device" as soon as a subscription existed, and a
|
|
// refusal from the push service went into a Debug line nobody sees. There was no
|
|
// way to tell "the browser never subscribed" from "the service refused it" from
|
|
// "nothing has come due yet" — three different problems with one appearance.
|
|
//
|
|
// It goes through the same path as a real notification rather than a shortcut, so
|
|
// what it proves is the thing that actually has to work.
|
|
//
|
|
// The words come from the client, which is where the dictionaries are. A server
|
|
// that carries its own copy of "This is a test" carries it in one language, and
|
|
// this one deliberately keeps its prose in the interface — see
|
|
// `internal/i18n`. The text is only ever shown back to the person who asked for
|
|
// it, on their own devices.
|
|
func (s *Server) handlePushTest(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
}
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
title := strings.TrimSpace(req.Title)
|
|
if title == "" {
|
|
title = "verdande"
|
|
}
|
|
// Bounded: this is written into a notification on somebody's lock screen, and
|
|
// a megabyte of title is not a notification.
|
|
if len(title) > 200 {
|
|
title = title[:200]
|
|
}
|
|
body := strings.TrimSpace(req.Body)
|
|
if len(body) > 500 {
|
|
body = body[:500]
|
|
}
|
|
|
|
user := userFrom(r.Context())
|
|
writeJSON(w, http.StatusOK, s.pushToUser(user.ID, title, body, ""))
|
|
}
|
|
|
|
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
|
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
|
|
}
|
|
|
|
// vapidSubject is the "sub" claim a push service is told to reach the sender at.
|
|
//
|
|
// It has to be one the service will actually accept, and Apple is the strict one:
|
|
// it answers 403 Forbidden to a mailto whose domain is made up — which is exactly
|
|
// what the old fallback, mailto:verdande@example.invalid, was on an instance with
|
|
// no mail set up. Every push to an iPhone or a Mac was rejected, silently as far as
|
|
// the person waiting for the notification could tell.
|
|
//
|
|
// So a real address is used when there is one, and otherwise the instance's own
|
|
// https address — which is a valid VAPID subject, points at something that exists,
|
|
// and Apple accepts. Verified against web.push.apple.com: the mailto gets a 403,
|
|
// the https URL a 201.
|
|
func (s *Server) vapidSubject() string {
|
|
from := strings.TrimSpace(s.cfg.SMTP.From)
|
|
if from != "" && strings.Contains(from, "@") && !strings.HasSuffix(from, "@localhost") {
|
|
return "mailto:" + from
|
|
}
|
|
if strings.HasPrefix(s.cfg.BaseURL, "https://") || strings.HasPrefix(s.cfg.BaseURL, "http://") {
|
|
return s.cfg.BaseURL
|
|
}
|
|
return "mailto:verdande@example.invalid"
|
|
}
|
|
|
|
// --- subscription endpoints -----------------------------------------------------------
|
|
|
|
type pushSubscriptionRequest struct {
|
|
Endpoint string `json:"endpoint"`
|
|
Keys struct {
|
|
P256dh string `json:"p256dh"`
|
|
Auth string `json:"auth"`
|
|
} `json:"keys"`
|
|
}
|
|
|
|
// handlePushKey hands the browser the public half of the VAPID pair, which is what
|
|
// PushManager.subscribe needs before it will produce a subscription at all.
|
|
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
|
|
public, _, err := s.vapidKeys(r.Context())
|
|
if err != nil {
|
|
s.internal(w, r, "vapid keys", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"public_key": public})
|
|
}
|
|
|
|
func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
|
|
var req pushSubscriptionRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" {
|
|
writeFieldErrors(w, map[string]string{"endpoint": "an incomplete subscription"})
|
|
return
|
|
}
|
|
|
|
// The endpoint is an address this server will POST to, on a schedule the
|
|
// caller chooses — set a reminder a minute out and the push job fetches it,
|
|
// again every minute. Unchecked, that is a way to make the instance reach
|
|
// anything it can route to, on demand.
|
|
//
|
|
// https only: a push service is on the public internet and speaks TLS. Nothing
|
|
// legitimate needs http here, and allowing it is the difference between a
|
|
// blind request and a readable one.
|
|
if !strings.HasPrefix(req.Endpoint, "https://") {
|
|
writeFieldErrors(w, map[string]string{"endpoint": "must be an https address"})
|
|
return
|
|
}
|
|
if reason := safedial.CheckURL(req.Endpoint); reason != "" {
|
|
writeFieldErrors(w, map[string]string{"endpoint": reason})
|
|
return
|
|
}
|
|
|
|
err := s.db.SavePushSubscription(r.Context(), &store.PushSubscription{
|
|
UserID: userFrom(r.Context()).ID,
|
|
Endpoint: req.Endpoint,
|
|
P256dh: req.Keys.P256dh,
|
|
Auth: req.Keys.Auth,
|
|
UserAgent: r.UserAgent(),
|
|
})
|
|
if err != nil {
|
|
s.internal(w, r, "save push subscription", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
|
|
var req pushSubscriptionRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
// Scoped to the caller. It was not, so anybody who learned somebody else's
|
|
// endpoint could unsubscribe their device — a small thing to do to somebody
|
|
// and no reason at all to be able to.
|
|
if err := s.db.DeletePushSubscription(r.Context(), userFrom(r.Context()).ID, req.Endpoint); err != nil {
|
|
s.internal(w, r, "delete push subscription", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|