Comments and attachments close out phase 2. Files are stored content-addressed by hash, so the path never contains anything the uploader chose — a filename is attacker-controlled text, and building a path from it is how "../" becomes a write outside the data directory. Everything downloads as application/octet-stream: an uploaded SVG served inline would run its own script on this origin with the session cookie attached. Web Push is written directly against RFC 8030/8291/8292 rather than pulled in — it is three specs' worth of well-defined crypto and about two hundred lines, and the alternatives carry a JWT and crypto stack each larger than the file. Phase 4 imports Todoist's CSV and exports it back. Todoist numbers priorities the opposite way to how it displays them — its 4 is P1 — so the two are converted rather than copied; getting it backwards silently inverts the urgency of every imported task. Nesting is positional, so the reader keeps an indent stack and the writer never sorts. The round trip is tested as the brief asks: a CSV in, out, and back produces an identical project, and two exports of one project are byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
5.9 KiB
Go
192 lines
5.9 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/kristianwind/verdande/internal/push"
|
|
"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, "list notifications", err)
|
|
return
|
|
}
|
|
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
|
|
if err != nil {
|
|
s.internal(w, "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, "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,
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *Server) pushToUser(userID, title, body, projectID string) {
|
|
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
|
|
defer cancel()
|
|
|
|
subs, err := s.db.ListPushSubscriptions(ctx, userID)
|
|
if err != nil || len(subs) == 0 {
|
|
return
|
|
}
|
|
public, private, err := s.vapidKeys(ctx)
|
|
if err != nil {
|
|
s.log.Warn("vapid keys", "err", err)
|
|
return
|
|
}
|
|
|
|
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
|
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: "mailto:" + s.cfg.SMTP.From,
|
|
})
|
|
if err == nil {
|
|
continue
|
|
}
|
|
s.log.Debug("web push failed", "err", err, "endpoint", sub.Endpoint)
|
|
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
|
|
s.log.Warn("record push failure", "err", recErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
|
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
|
|
}
|
|
|
|
// --- 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, "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
|
|
}
|
|
|
|
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, "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
|
|
}
|
|
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
|
|
s.internal(w, "delete push subscription", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|