tenseleyflow/shithub / bc2559a

Browse files

S29: notifications inbox + thread sub + one-click unsub web handlers

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
bc2559a35e46f992886afb72abd0b939f03160ce
Parents
14ae067
Tree
d946aaa

6 changed files

StatusFile+-
M internal/web/handlers/handlers.go 19 0
A internal/web/handlers/notifications/notifications.go 271 0
A internal/web/notif_wiring.go 64 0
M internal/web/server.go 12 0
A internal/web/templates/notifications/inbox.html 61 0
A internal/web/templates/notifications/unsubscribed.html 7 0
internal/web/handlers/handlers.gomodified
@@ -90,6 +90,16 @@ type Deps struct {
9090
 	// Both are public — visibility scoping is done inside the
9191
 	// search package via policy.VisibilityPredicate.
9292
 	SearchMounter func(chi.Router)
93
+	// NotifInboxMounter registers the per-viewer notification inbox
94
+	// + thread-subscribe + mark-read routes (S29). RequireUser is
95
+	// applied inside the wiring layer because every route in the
96
+	// set is per-recipient.
97
+	NotifInboxMounter func(chi.Router)
98
+	// NotifPublicMounter registers the unauthenticated one-click
99
+	// unsubscribe endpoint (S29). HMAC-signed URL = no session
100
+	// needed, so the route lives in the public group alongside
101
+	// /healthz / /static.
102
+	NotifPublicMounter func(chi.Router)
93103
 	// GitHTTPMounter, when non-nil, registers the smart-HTTP git routes
94104
 	// (`*.git/info/refs`, `git-upload-pack`, `git-receive-pack`). MUST
95105
 	// land in a route group that bypasses CSRF, response compression,
@@ -162,6 +172,12 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
162172
 		if deps.AvatarMounter != nil {
163173
 			deps.AvatarMounter(r)
164174
 		}
175
+		// One-click unsubscribe lands in the public group (no CSRF,
176
+		// no session) — RFC 8058 mailers click it from arbitrary
177
+		// agents.
178
+		if deps.NotifPublicMounter != nil {
179
+			deps.NotifPublicMounter(r)
180
+		}
165181
 	})
166182
 
167183
 	// Smart-HTTP git routes get their own group: NO CSRF (HTTP Basic
@@ -223,6 +239,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
223239
 		if deps.SearchMounter != nil {
224240
 			deps.SearchMounter(r)
225241
 		}
242
+		if deps.NotifInboxMounter != nil {
243
+			deps.NotifInboxMounter(r)
244
+		}
226245
 		if deps.RepoHomeMounter != nil {
227246
 			deps.RepoHomeMounter(r)
228247
 		}
internal/web/handlers/notifications/notifications.goadded
@@ -0,0 +1,271 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package notifications wires the S29 notification web surface:
4
+//
5
+//   - GET  /notifications                       inbox
6
+//   - POST /notifications/{id}/read             mark one read
7
+//   - POST /notifications/{id}/unread           mark one unread
8
+//   - POST /notifications/mark-all-read         clear unread for the viewer
9
+//   - POST /threads/{kind}/{id}/subscribe       subscribe (or override-ignore)
10
+//   - POST /threads/{kind}/{id}/unsubscribe     unsubscribe (per-thread)
11
+//   - GET  /notifications/unsubscribe           one-click HMAC-signed unsub
12
+package notifications
13
+
14
+import (
15
+	"errors"
16
+	"log/slog"
17
+	"net/http"
18
+	"strconv"
19
+
20
+	"github.com/go-chi/chi/v5"
21
+	"github.com/jackc/pgx/v5/pgxpool"
22
+
23
+	"github.com/tenseleyFlow/shithub/internal/notif"
24
+	notifdb "github.com/tenseleyFlow/shithub/internal/notif/sqlc"
25
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
26
+	"github.com/tenseleyFlow/shithub/internal/web/render"
27
+)
28
+
29
+// Deps wires the handler set.
30
+type Deps struct {
31
+	Logger         *slog.Logger
32
+	Render         *render.Renderer
33
+	Pool           *pgxpool.Pool
34
+	UnsubscribeKey []byte
35
+}
36
+
37
+// Handlers groups the notification surface handlers. Construct via New.
38
+type Handlers struct {
39
+	d Deps
40
+}
41
+
42
+// pageSize bounds inbox pagination. Same default as the search inbox.
43
+const pageSize = 25
44
+
45
+// New constructs the handler set.
46
+func New(d Deps) (*Handlers, error) {
47
+	if d.Render == nil {
48
+		return nil, errors.New("notifications: nil Render")
49
+	}
50
+	if d.Pool == nil {
51
+		return nil, errors.New("notifications: nil Pool")
52
+	}
53
+	return &Handlers{d: d}, nil
54
+}
55
+
56
+// MountAuthed registers the routes that REQUIRE a logged-in viewer.
57
+// Mounted from server.go inside a RequireUser-wrapped group.
58
+func (h *Handlers) MountAuthed(r chi.Router) {
59
+	r.Get("/notifications", h.list)
60
+	r.Post("/notifications/{id}/read", h.markRead)
61
+	r.Post("/notifications/{id}/unread", h.markUnread)
62
+	r.Post("/notifications/mark-all-read", h.markAllRead)
63
+	r.Post("/threads/{kind}/{id}/subscribe", h.subscribe)
64
+	r.Post("/threads/{kind}/{id}/unsubscribe", h.unsubscribe)
65
+}
66
+
67
+// MountPublic registers the unauthenticated one-click unsubscribe
68
+// endpoint. The HMAC-signed URL embeds the recipient ID + thread
69
+// reference + signature so we can verify without a session cookie
70
+// (RFC 8058 mailers pop links from arbitrary clients).
71
+func (h *Handlers) MountPublic(r chi.Router) {
72
+	r.Get("/notifications/unsubscribe", h.unsubscribeViaToken)
73
+}
74
+
75
+// ─── handlers ──────────────────────────────────────────────────────
76
+
77
+func (h *Handlers) list(w http.ResponseWriter, r *http.Request) {
78
+	viewer := middleware.CurrentUserFromContext(r.Context())
79
+	if viewer.IsAnonymous() {
80
+		http.Redirect(w, r, "/login?next=/notifications", http.StatusSeeOther)
81
+		return
82
+	}
83
+	page := pageFromRequest(r)
84
+	onlyUnread := r.URL.Query().Get("filter") == "unread"
85
+
86
+	q := notifdb.New()
87
+	rows, err := q.ListNotificationsForRecipient(r.Context(), h.d.Pool, notifdb.ListNotificationsForRecipientParams{
88
+		RecipientUserID: viewer.ID,
89
+		Column2:         onlyUnread,
90
+		Limit:           int32(pageSize),
91
+		Offset:          int32((page - 1) * pageSize),
92
+	})
93
+	if err != nil {
94
+		h.d.Logger.ErrorContext(r.Context(), "notifications: list", "error", err)
95
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
96
+		return
97
+	}
98
+	unreadCount, _ := q.CountUnreadForRecipient(r.Context(), h.d.Pool, viewer.ID)
99
+
100
+	data := map[string]any{
101
+		"Title":         "Notifications",
102
+		"Notifications": rows,
103
+		"UnreadCount":   unreadCount,
104
+		"Filter":        r.URL.Query().Get("filter"),
105
+		"Page":          page,
106
+		"HasPrev":       page > 1,
107
+		"HasNext":       len(rows) == pageSize,
108
+	}
109
+	if err := h.d.Render.RenderPage(w, r, "notifications/inbox", data); err != nil {
110
+		h.d.Logger.ErrorContext(r.Context(), "notifications: render", "error", err)
111
+	}
112
+}
113
+
114
+func (h *Handlers) markRead(w http.ResponseWriter, r *http.Request) {
115
+	h.setRead(w, r, true)
116
+}
117
+
118
+func (h *Handlers) markUnread(w http.ResponseWriter, r *http.Request) {
119
+	h.setRead(w, r, false)
120
+}
121
+
122
+// setRead toggles the unread flag on a single inbox row. The DB path
123
+// owns the recipient_user_id check (the SET … WHERE … filters by both
124
+// id and recipient), so a forged id from another user becomes a
125
+// silent no-op rather than an authoritative 403 — matches the
126
+// existence-leak posture used elsewhere.
127
+func (h *Handlers) setRead(w http.ResponseWriter, r *http.Request, read bool) {
128
+	viewer := middleware.CurrentUserFromContext(r.Context())
129
+	if viewer.IsAnonymous() {
130
+		h.d.Render.HTTPError(w, r, http.StatusUnauthorized, "")
131
+		return
132
+	}
133
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
134
+	if err != nil {
135
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
136
+		return
137
+	}
138
+	q := notifdb.New()
139
+	if read {
140
+		err = q.SetNotificationRead(r.Context(), h.d.Pool, notifdb.SetNotificationReadParams{
141
+			ID: id, RecipientUserID: viewer.ID,
142
+		})
143
+	} else {
144
+		err = q.SetNotificationUnread(r.Context(), h.d.Pool, notifdb.SetNotificationUnreadParams{
145
+			ID: id, RecipientUserID: viewer.ID,
146
+		})
147
+	}
148
+	if err != nil {
149
+		h.d.Logger.WarnContext(r.Context(), "notifications: set read",
150
+			"id", id, "read", read, "error", err)
151
+	}
152
+	http.Redirect(w, r, "/notifications", http.StatusSeeOther)
153
+}
154
+
155
+func (h *Handlers) markAllRead(w http.ResponseWriter, r *http.Request) {
156
+	viewer := middleware.CurrentUserFromContext(r.Context())
157
+	if viewer.IsAnonymous() {
158
+		h.d.Render.HTTPError(w, r, http.StatusUnauthorized, "")
159
+		return
160
+	}
161
+	if err := notifdb.New().MarkAllReadForRecipient(r.Context(), h.d.Pool, viewer.ID); err != nil {
162
+		h.d.Logger.WarnContext(r.Context(), "notifications: mark-all-read", "error", err)
163
+	}
164
+	http.Redirect(w, r, "/notifications", http.StatusSeeOther)
165
+}
166
+
167
+func (h *Handlers) subscribe(w http.ResponseWriter, r *http.Request) {
168
+	h.threadAction(w, r, true)
169
+}
170
+
171
+func (h *Handlers) unsubscribe(w http.ResponseWriter, r *http.Request) {
172
+	h.threadAction(w, r, false)
173
+}
174
+
175
+// threadAction toggles per-thread subscription. `subscribe=true`
176
+// inserts (or updates) a row with subscribed=true; `false` flips it
177
+// off. The fan-out worker honors the explicit row over the auto-sub
178
+// derivation.
179
+func (h *Handlers) threadAction(w http.ResponseWriter, r *http.Request, subscribed bool) {
180
+	viewer := middleware.CurrentUserFromContext(r.Context())
181
+	if viewer.IsAnonymous() {
182
+		h.d.Render.HTTPError(w, r, http.StatusUnauthorized, "")
183
+		return
184
+	}
185
+	kindStr := chi.URLParam(r, "kind")
186
+	if kindStr != "issue" && kindStr != "pr" {
187
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
188
+		return
189
+	}
190
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
191
+	if err != nil {
192
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
193
+		return
194
+	}
195
+	reason := "manual"
196
+	if !subscribed {
197
+		reason = "manual_unsubscribe"
198
+	}
199
+	if err := notifdb.New().UpsertNotificationThread(r.Context(), h.d.Pool, notifdb.UpsertNotificationThreadParams{
200
+		RecipientUserID: viewer.ID,
201
+		ThreadKind:      notifdb.NotificationThreadKind(kindStr),
202
+		ThreadID:        id,
203
+		Subscribed:      subscribed,
204
+		Reason:          reason,
205
+	}); err != nil {
206
+		h.d.Logger.WarnContext(r.Context(), "notifications: thread action",
207
+			"kind", kindStr, "id", id, "subscribed", subscribed, "error", err)
208
+	}
209
+	// Bounce back to the thread the viewer was on. Best-effort: when
210
+	// the Referer is missing, fall back to /notifications.
211
+	dest := r.Header.Get("Referer")
212
+	if dest == "" {
213
+		dest = "/notifications"
214
+	}
215
+	http.Redirect(w, r, dest, http.StatusSeeOther)
216
+}
217
+
218
+// unsubscribeViaToken handles the email's one-click List-Unsubscribe
219
+// link. The URL embeds (recipient, thread_kind, thread_id, sig) so
220
+// no session is required. We re-derive the HMAC and compare in
221
+// constant time; a mismatch returns 400.
222
+func (h *Handlers) unsubscribeViaToken(w http.ResponseWriter, r *http.Request) {
223
+	q := r.URL.Query()
224
+	uStr, tk, tiStr, sig := q.Get("u"), q.Get("tk"), q.Get("ti"), q.Get("sig")
225
+	uid, err := strconv.ParseInt(uStr, 10, 64)
226
+	if err != nil {
227
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
228
+		return
229
+	}
230
+	tid, err := strconv.ParseInt(tiStr, 10, 64)
231
+	if err != nil {
232
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
233
+		return
234
+	}
235
+	if !notif.VerifyUnsubscribe(h.d.UnsubscribeKey, uid, tk, tid, sig) {
236
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
237
+		return
238
+	}
239
+	if tk != "issue" && tk != "pr" {
240
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
241
+		return
242
+	}
243
+	if err := notifdb.New().UpsertNotificationThread(r.Context(), h.d.Pool, notifdb.UpsertNotificationThreadParams{
244
+		RecipientUserID: uid,
245
+		ThreadKind:      notifdb.NotificationThreadKind(tk),
246
+		ThreadID:        tid,
247
+		Subscribed:      false,
248
+		Reason:          "email_one_click",
249
+	}); err != nil {
250
+		h.d.Logger.WarnContext(r.Context(), "notifications: token unsubscribe",
251
+			"recipient", uid, "kind", tk, "id", tid, "error", err)
252
+	}
253
+	if err := h.d.Render.RenderPage(w, r, "notifications/unsubscribed", map[string]any{
254
+		"Title": "Unsubscribed",
255
+	}); err != nil {
256
+		h.d.Logger.ErrorContext(r.Context(), "notifications: render unsub", "error", err)
257
+	}
258
+}
259
+
260
+// pageFromRequest pulls ?page=N, defaulting to 1 on missing/invalid.
261
+func pageFromRequest(r *http.Request) int {
262
+	p := r.URL.Query().Get("page")
263
+	if p == "" {
264
+		return 1
265
+	}
266
+	n, err := strconv.Atoi(p)
267
+	if err != nil || n < 1 || n > 10000 {
268
+		return 1
269
+	}
270
+	return n
271
+}
internal/web/notif_wiring.goadded
@@ -0,0 +1,64 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package web
4
+
5
+import (
6
+	"crypto/sha256"
7
+	"encoding/base64"
8
+	"io/fs"
9
+	"log/slog"
10
+
11
+	"github.com/jackc/pgx/v5/pgxpool"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/infra/config"
14
+	notifhandlers "github.com/tenseleyFlow/shithub/internal/web/handlers/notifications"
15
+	"github.com/tenseleyFlow/shithub/internal/web/render"
16
+)
17
+
18
+// buildNotifHandlers wires the S29 notification handlers. Owns its
19
+// own renderer (same pattern as the other handler builders).
20
+func buildNotifHandlers(
21
+	cfg config.Config,
22
+	pool *pgxpool.Pool,
23
+	tmplFS fs.FS,
24
+	logger *slog.Logger,
25
+) (*notifhandlers.Handlers, error) {
26
+	rr, err := render.New(tmplFS, render.Options{Octicons: render.BuiltinOcticons()})
27
+	if err != nil {
28
+		return nil, err
29
+	}
30
+	return notifhandlers.New(notifhandlers.Deps{
31
+		Logger:         logger,
32
+		Render:         rr,
33
+		Pool:           pool,
34
+		UnsubscribeKey: notifUnsubscribeKey(cfg, logger),
35
+	})
36
+}
37
+
38
+// notifUnsubscribeKey mirrors the worker's resolver. Kept here so
39
+// the web binary doesn't have to import the worker bundle just for
40
+// this helper. The dev-fallback derivation is intentional and
41
+// logged loudly — see worker.go for the matching prod hint.
42
+func notifUnsubscribeKey(cfg config.Config, logger *slog.Logger) []byte {
43
+	if cfg.Notif.UnsubscribeKeyB64 != "" {
44
+		k, err := base64.StdEncoding.DecodeString(cfg.Notif.UnsubscribeKeyB64)
45
+		if err == nil && len(k) >= 16 {
46
+			return k
47
+		}
48
+	}
49
+	if cfg.Session.KeyB64 != "" {
50
+		seed, err := base64.StdEncoding.DecodeString(cfg.Session.KeyB64)
51
+		if err == nil && len(seed) > 0 {
52
+			sum := sha256.Sum256(append([]byte("notif-unsub:"), seed...))
53
+			if logger != nil {
54
+				logger.Warn("notif (web): deriving unsubscribe key from session secret (dev fallback)",
55
+					"hint", "set Notif.UnsubscribeKeyB64 in prod")
56
+			}
57
+			return sum[:]
58
+		}
59
+	}
60
+	if logger != nil {
61
+		logger.Warn("notif (web): no key material — using static dev key")
62
+	}
63
+	return []byte("shithub-dev-unsub-static-key-32B")
64
+}
internal/web/server.gomodified
@@ -213,6 +213,18 @@ func Run(ctx context.Context, opts Options) error {
213213
 		}
214214
 		deps.SearchMounter = searchH.Mount
215215
 
216
+		notifH, err := buildNotifHandlers(cfg, pool, deps.TemplatesFS, logger)
217
+		if err != nil {
218
+			return fmt.Errorf("notif handlers: %w", err)
219
+		}
220
+		deps.NotifInboxMounter = func(r chi.Router) {
221
+			r.Group(func(r chi.Router) {
222
+				r.Use(middleware.RequireUser)
223
+				notifH.MountAuthed(r)
224
+			})
225
+		}
226
+		deps.NotifPublicMounter = notifH.MountPublic
227
+
216228
 		// Lifecycle danger-zone routes — also auth-required.
217229
 		deps.RepoLifecycleMounter = func(r chi.Router) {
218230
 			r.Group(func(r chi.Router) {
internal/web/templates/notifications/inbox.htmladded
@@ -0,0 +1,61 @@
1
+{{ define "page" -}}
2
+<section class="shithub-notifications">
3
+  <header class="shithub-notifications-head">
4
+    <h1>Notifications</h1>
5
+    <nav class="shithub-tabs">
6
+      <a href="/notifications" class="shithub-button {{ if ne .Filter "unread" }}shithub-button-primary{{ end }}">All</a>
7
+      <a href="/notifications?filter=unread" class="shithub-button {{ if eq .Filter "unread" }}shithub-button-primary{{ end }}">Unread ({{ .UnreadCount }})</a>
8
+    </nav>
9
+    {{ if .Notifications }}
10
+    <form method="post" action="/notifications/mark-all-read" style="display:inline">
11
+      <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
12
+      <button type="submit" class="shithub-button">Mark all read</button>
13
+    </form>
14
+    {{ end }}
15
+  </header>
16
+
17
+  {{ if .Notifications }}
18
+    <ul class="shithub-notifications-list">
19
+      {{ range .Notifications }}
20
+      <li class="shithub-notification-row {{ if .Unread }}is-unread{{ end }}">
21
+        <div class="shithub-notification-meta">
22
+          <span class="shithub-pill shithub-pill-reason">{{ .Reason }}</span>
23
+          {{ if .RepoOwnerUsername }}
24
+            <a href="/{{ .RepoOwnerUsername }}/{{ .RepoName }}">{{ .RepoOwnerUsername }}/{{ .RepoName }}</a>
25
+          {{ end }}
26
+        </div>
27
+        <div class="shithub-notification-body">
28
+          {{ if .ThreadNumber }}
29
+            <a href="/{{ .RepoOwnerUsername }}/{{ .RepoName }}/{{ if eq (printf "%s" .ThreadKind.NotificationThreadKind) "pr" }}pulls{{ else }}issues{{ end }}/{{ .ThreadNumber }}">
30
+              <strong>#{{ .ThreadNumber }} {{ .ThreadTitle }}</strong>
31
+            </a>
32
+          {{ else }}
33
+            <strong>{{ .Kind }}</strong>
34
+          {{ end }}
35
+          <small>by @{{ .ActorUsername }} · {{ relativeTime .LastEventAt.Time }}</small>
36
+        </div>
37
+        <div class="shithub-notification-actions">
38
+          {{ if .Unread }}
39
+          <form method="post" action="/notifications/{{ .ID }}/read" style="display:inline">
40
+            <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
41
+            <button type="submit" class="shithub-button">Mark read</button>
42
+          </form>
43
+          {{ else }}
44
+          <form method="post" action="/notifications/{{ .ID }}/unread" style="display:inline">
45
+            <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
46
+            <button type="submit" class="shithub-button">Mark unread</button>
47
+          </form>
48
+          {{ end }}
49
+        </div>
50
+      </li>
51
+      {{ end }}
52
+    </ul>
53
+    <nav class="shithub-pagination">
54
+      {{ if .HasPrev }}<a href="?filter={{ .Filter }}&page={{ sub .Page 1 }}">&larr; Newer</a>{{ end }}
55
+      {{ if .HasNext }}<a href="?filter={{ .Filter }}&page={{ add .Page 1 }}">Older &rarr;</a>{{ end }}
56
+    </nav>
57
+  {{ else }}
58
+    <p class="shithub-empty">{{ if eq .Filter "unread" }}Nothing unread.{{ else }}No notifications.{{ end }}</p>
59
+  {{ end }}
60
+</section>
61
+{{- end }}
internal/web/templates/notifications/unsubscribed.htmladded
@@ -0,0 +1,7 @@
1
+{{ define "page" -}}
2
+<section class="shithub-notifications">
3
+  <h1>Unsubscribed</h1>
4
+  <p>You won't receive any further notifications from this thread by email.</p>
5
+  <p>Manage your notification preferences in <a href="/settings/notifications">settings</a>.</p>
6
+</section>
7
+{{- end }}