461 lines
17 KiB
Go
461 lines
17 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,
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|