Every Web Push to an iPhone or a Mac was refused, silently as far as the person waiting for the reminder could tell. The cause was the VAPID "sub" claim: on an instance with no mail configured it fell back to mailto:[email protected], and Apple answers 403 Forbidden to a mailto whose domain is made up. Verified against web.push.apple.com on the live subscriptions — that mailto gets a 403, the instance's https URL a 201. So the subject is now a real mailto when SMTP.From is a real address, and otherwise the instance's own https base URL — which is a valid VAPID subject, points at something that exists, and every push service including Apple accepts. Two things found alongside it, both fixed: - A successful push did not clear the failure count. A subscription drops at ten failures, and all four live ones sat at nine from this bug — one flaky push from being deleted despite working. MarkPushDelivered zeroes the count and moves last_used_at on every delivery, so a subscription that recovers is not still carrying its old failures toward the cliff. - A reminder with no project made a notification whose URL was "/projekt/" — a dead link. Empty project now points at the app root. Test: the subject is a real mailto when there is one, and the https URL when there is not — including for the localhost default, which is not a real address. Co-Authored-By: Claude Opus 5 <[email protected]>
367 lines
13 KiB
Go
367 lines
13 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"`
|
|
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,
|
|
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, ProjectID: n.ProjectID, TaskID: n.TaskID, 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,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 = "/"
|
|
}
|
|
payload := push.Payload{Title: title, Body: body, URL: target}
|
|
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:[email protected], 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:[email protected]"
|
|
}
|
|
|
|
// --- 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)
|
|
}
|