tenseleyflow/shithub / 8cbd63b

Browse files

S26: web handlers + templates for stars/watchers/star/unstar/watch

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
8cbd63b4de477164873380882ddf95afd2a2abbd
Parents
23e1749
Tree
ad5d41d

7 changed files

StatusFile+-
M internal/web/handlers/handlers.go 7 0
A internal/web/handlers/repo/social.go 236 0
M internal/web/render/render.go 5 0
M internal/web/server.go 1 0
M internal/web/static/css/shithub.css 15 0
A internal/web/templates/repo/stargazers.html 33 0
A internal/web/templates/repo/watchers.html 34 0
internal/web/handlers/handlers.gomodified
@@ -78,6 +78,10 @@ type Deps struct {
7878
 	// RepoPullsMounter registers /{owner}/{repo}/pulls* routes (S22).
7979
 	// Same auth shape as issues — reads public, writes auth-required.
8080
 	RepoPullsMounter func(chi.Router)
81
+	// RepoSocialMounter registers /{owner}/{repo}/{star,unstar,watch,
82
+	// stargazers,watchers} (S26). Stargazer/watcher GETs are public
83
+	// (subject to repo visibility); the action POSTs require auth.
84
+	RepoSocialMounter func(chi.Router)
8185
 	// GitHTTPMounter, when non-nil, registers the smart-HTTP git routes
8286
 	// (`*.git/info/refs`, `git-upload-pack`, `git-receive-pack`). MUST
8387
 	// land in a route group that bypasses CSRF, response compression,
@@ -202,6 +206,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
202206
 		if deps.RepoPullsMounter != nil {
203207
 			deps.RepoPullsMounter(r)
204208
 		}
209
+		if deps.RepoSocialMounter != nil {
210
+			deps.RepoSocialMounter(r)
211
+		}
205212
 		if deps.RepoHomeMounter != nil {
206213
 			deps.RepoHomeMounter(r)
207214
 		}
internal/web/handlers/repo/social.goadded
@@ -0,0 +1,236 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"errors"
7
+	"net/http"
8
+	"strconv"
9
+
10
+	"github.com/go-chi/chi/v5"
11
+
12
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
13
+	"github.com/tenseleyFlow/shithub/internal/social"
14
+	socialdb "github.com/tenseleyFlow/shithub/internal/social/sqlc"
15
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
16
+)
17
+
18
+// MountSocial registers the star/watch/stargazers/watchers routes.
19
+// The auth-required group is the caller's responsibility (the
20
+// stargazers / watchers GETs are public, gated only by the visibility
21
+// check inside lookupRepoForViewer).
22
+func (h *Handlers) MountSocial(r chi.Router) {
23
+	r.Get("/{owner}/{repo}/stargazers", h.stargazersList)
24
+	r.Get("/{owner}/{repo}/watchers", h.watchersList)
25
+
26
+	r.Group(func(r chi.Router) {
27
+		r.Use(middleware.RequireUser)
28
+		r.Post("/{owner}/{repo}/star", h.repoStar)
29
+		r.Post("/{owner}/{repo}/unstar", h.repoUnstar)
30
+		r.Post("/{owner}/{repo}/watch", h.repoWatch)
31
+	})
32
+}
33
+
34
+// socialDeps materializes a social.Deps from the handler-set deps.
35
+// Limiter is shared with the rest of the handler surface so the per-
36
+// user star/unstar cap composes with the existing rate-limit envelope.
37
+func (h *Handlers) socialDeps() social.Deps {
38
+	return social.Deps{
39
+		Pool:    h.d.Pool,
40
+		Limiter: h.d.Limiter,
41
+		Logger:  h.d.Logger,
42
+		Audit:   h.d.Audit,
43
+	}
44
+}
45
+
46
+// pageSize is the spec's day-1 lean: 50 rows per page on social
47
+// list pages. Aligns with the issues / PR pagination shape.
48
+const socialPageSize = 50
49
+
50
+// repoStar handles POST /{owner}/{repo}/star.
51
+func (h *Handlers) repoStar(w http.ResponseWriter, r *http.Request) {
52
+	row, owner, ok := h.authorizeSocialAction(w, r, policy.ActionStarCreate)
53
+	if !ok {
54
+		return
55
+	}
56
+	viewer := middleware.CurrentUserFromContext(r.Context())
57
+	if err := social.Star(r.Context(), h.socialDeps(), viewer.ID, row.ID, repoIsPublic(row)); err != nil {
58
+		h.handleSocialError(w, r, err)
59
+		return
60
+	}
61
+	http.Redirect(w, r, "/"+owner+"/"+row.Name, http.StatusSeeOther)
62
+}
63
+
64
+// repoUnstar handles POST /{owner}/{repo}/unstar. Same shape as star;
65
+// the orchestrator's idempotency makes it safe even if the user
66
+// already removed the star.
67
+func (h *Handlers) repoUnstar(w http.ResponseWriter, r *http.Request) {
68
+	row, owner, ok := h.authorizeSocialAction(w, r, policy.ActionStarCreate)
69
+	if !ok {
70
+		return
71
+	}
72
+	viewer := middleware.CurrentUserFromContext(r.Context())
73
+	if err := social.Unstar(r.Context(), h.socialDeps(), viewer.ID, row.ID, repoIsPublic(row)); err != nil {
74
+		h.handleSocialError(w, r, err)
75
+		return
76
+	}
77
+	http.Redirect(w, r, "/"+owner+"/"+row.Name, http.StatusSeeOther)
78
+}
79
+
80
+// repoWatch handles POST /{owner}/{repo}/watch with a level form
81
+// field. Level "default" (or empty) deletes the row (returns to the
82
+// implicit `participating` default); explicit "all" / "participating"
83
+// / "ignore" upserts the level.
84
+func (h *Handlers) repoWatch(w http.ResponseWriter, r *http.Request) {
85
+	row, owner, ok := h.authorizeSocialAction(w, r, policy.ActionWatchSet)
86
+	if !ok {
87
+		return
88
+	}
89
+	if err := r.ParseForm(); err != nil {
90
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
91
+		return
92
+	}
93
+	viewer := middleware.CurrentUserFromContext(r.Context())
94
+	level := r.PostFormValue("level")
95
+	var err error
96
+	if level == "" || level == "default" {
97
+		err = social.UnsetWatch(r.Context(), h.socialDeps(), viewer.ID, row.ID)
98
+	} else {
99
+		err = social.SetWatch(r.Context(), h.socialDeps(), viewer.ID, row.ID, social.WatchLevel(level))
100
+	}
101
+	if err != nil {
102
+		h.handleSocialError(w, r, err)
103
+		return
104
+	}
105
+	http.Redirect(w, r, "/"+owner+"/"+row.Name, http.StatusSeeOther)
106
+}
107
+
108
+// stargazersList renders /{owner}/{repo}/stargazers. Read-public on
109
+// public repos; private repos delegate to lookupRepoForViewer (which
110
+// 404s for non-collab — same shape as the rest of the repo views).
111
+func (h *Handlers) stargazersList(w http.ResponseWriter, r *http.Request) {
112
+	owner := chi.URLParam(r, "owner")
113
+	name := chi.URLParam(r, "repo")
114
+	row, err := h.lookupRepoForViewer(r.Context(), owner, name, middleware.CurrentUserFromContext(r.Context()))
115
+	if err != nil {
116
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
117
+		return
118
+	}
119
+	page := pageFromRequest(r)
120
+	q := socialdb.New()
121
+	rows, err := q.ListStargazersForRepo(r.Context(), h.d.Pool, socialdb.ListStargazersForRepoParams{
122
+		RepoID: row.ID, Limit: socialPageSize, Offset: int32((page - 1) * socialPageSize),
123
+	})
124
+	if err != nil {
125
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "list stargazers")
126
+		return
127
+	}
128
+	count, _ := q.CountStargazersForRepo(r.Context(), h.d.Pool, row.ID)
129
+	common := map[string]any{
130
+		"Title":      "Stargazers · " + row.Name,
131
+		"Owner":      owner,
132
+		"Repo":       row,
133
+		"Stargazers": rows,
134
+		"Total":      count,
135
+		"Page":       page,
136
+		"HasNext":    int64(page*socialPageSize) < count,
137
+		"HasPrev":    page > 1,
138
+	}
139
+	if err := h.d.Render.RenderPage(w, r, "repo/stargazers", common); err != nil {
140
+		h.d.Logger.ErrorContext(r.Context(), "stargazers render", "error", err)
141
+	}
142
+}
143
+
144
+// watchersList renders /{owner}/{repo}/watchers. Same gating as
145
+// stargazers.
146
+func (h *Handlers) watchersList(w http.ResponseWriter, r *http.Request) {
147
+	owner := chi.URLParam(r, "owner")
148
+	name := chi.URLParam(r, "repo")
149
+	row, err := h.lookupRepoForViewer(r.Context(), owner, name, middleware.CurrentUserFromContext(r.Context()))
150
+	if err != nil {
151
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
152
+		return
153
+	}
154
+	page := pageFromRequest(r)
155
+	q := socialdb.New()
156
+	rows, err := q.ListWatchersForRepo(r.Context(), h.d.Pool, socialdb.ListWatchersForRepoParams{
157
+		RepoID: row.ID, Limit: socialPageSize, Offset: int32((page - 1) * socialPageSize),
158
+	})
159
+	if err != nil {
160
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "list watchers")
161
+		return
162
+	}
163
+	count, _ := q.CountWatchersForRepo(r.Context(), h.d.Pool, row.ID)
164
+	common := map[string]any{
165
+		"Title":    "Watchers · " + row.Name,
166
+		"Owner":    owner,
167
+		"Repo":     row,
168
+		"Watchers": rows,
169
+		"Total":    count,
170
+		"Page":     page,
171
+		"HasNext":  int64(page*socialPageSize) < count,
172
+		"HasPrev":  page > 1,
173
+	}
174
+	if err := h.d.Render.RenderPage(w, r, "repo/watchers", common); err != nil {
175
+		h.d.Logger.ErrorContext(r.Context(), "watchers render", "error", err)
176
+	}
177
+}
178
+
179
+// authorizeSocialAction is the social-action equivalent of
180
+// loadRepoAndAuthorize: resolves owner+repo, calls policy.Can with
181
+// the action (which returns DenyVisibility on private+non-collab),
182
+// and routes the deny path through Maybe404. Returns the repo + the
183
+// raw owner string so handlers can build redirects.
184
+func (h *Handlers) authorizeSocialAction(w http.ResponseWriter, r *http.Request, action policy.Action) (repoRow, string, bool) {
185
+	ownerName := chi.URLParam(r, "owner")
186
+	repoName := chi.URLParam(r, "repo")
187
+	row, err := h.lookupRepoForViewer(r.Context(), ownerName, repoName, middleware.CurrentUserFromContext(r.Context()))
188
+	if err != nil {
189
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
190
+		return repoRow{}, "", false
191
+	}
192
+	viewer := middleware.CurrentUserFromContext(r.Context())
193
+	actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
194
+	repoRef := policy.NewRepoRefFromRepo(row)
195
+	if dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, action, repoRef); !dec.Allow {
196
+		h.d.Render.HTTPError(w, r, policy.Maybe404(dec, repoRef, actor), "")
197
+		return repoRow{}, "", false
198
+	}
199
+	return repoRow{ID: row.ID, Name: row.Name, Visibility: string(row.Visibility)}, ownerName, true
200
+}
201
+
202
+// repoRow is the slim view the social handlers need from
203
+// `lookupRepoForViewer`. Avoids importing the big reposdb.Repo shape
204
+// into the handler signature.
205
+type repoRow struct {
206
+	ID         int64
207
+	Name       string
208
+	Visibility string
209
+}
210
+
211
+func repoIsPublic(r repoRow) bool { return r.Visibility == "public" }
212
+
213
+func pageFromRequest(r *http.Request) int {
214
+	v, _ := strconv.Atoi(r.URL.Query().Get("page"))
215
+	if v < 1 {
216
+		return 1
217
+	}
218
+	return v
219
+}
220
+
221
+// handleSocialError maps the orchestrator's typed errors to status
222
+// codes / friendly messages. Mirrors the issues/PR error-mapping
223
+// shape so the rest of the handler set stays consistent.
224
+func (h *Handlers) handleSocialError(w http.ResponseWriter, r *http.Request, err error) {
225
+	switch {
226
+	case errors.Is(err, social.ErrNotLoggedIn):
227
+		http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
228
+	case errors.Is(err, social.ErrInvalidWatchLevel):
229
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "invalid watch level")
230
+	case errors.Is(err, social.ErrStarRateLimit):
231
+		h.d.Render.HTTPError(w, r, http.StatusTooManyRequests, "rate limit")
232
+	default:
233
+		h.d.Logger.ErrorContext(r.Context(), "social handler", "error", err)
234
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
235
+	}
236
+}
internal/web/render/render.gomodified
@@ -235,6 +235,11 @@ func funcMap(octicon OcticonResolver) template.FuncMap {
235235
 		"csrfToken": middleware.CSRFTokenForRequest,
236236
 		// dict builds a map for partial-template includes that need
237237
 		// multiple named values (idiomatic Go template trick).
238
+		// add / sub are tiny integer helpers used by pagination
239
+		// templates (next/prev page links). Templates can't do
240
+		// arithmetic, so the helpers earn their keep here.
241
+		"add": func(a, b int) int { return a + b },
242
+		"sub": func(a, b int) int { return a - b },
238243
 		"dict": func(values ...any) (map[string]any, error) {
239244
 			if len(values)%2 != 0 {
240245
 				return nil, fmt.Errorf("dict: odd number of args")
internal/web/server.gomodified
@@ -204,6 +204,7 @@ func Run(ctx context.Context, opts Options) error {
204204
 		// RequireUser inserted only for state-mutating verbs.
205205
 		deps.RepoIssuesMounter = repoH.MountIssues
206206
 		deps.RepoPullsMounter = repoH.MountPulls
207
+		deps.RepoSocialMounter = repoH.MountSocial
207208
 		// Lifecycle danger-zone routes — also auth-required.
208209
 		deps.RepoLifecycleMounter = func(r chi.Router) {
209210
 			r.Group(func(r chi.Router) {
internal/web/static/css/shithub.cssmodified
@@ -1384,3 +1384,18 @@ code {
13841384
 .shithub-pull-check-conclusion-stale { color: var(--fg-muted); background: var(--canvas-subtle); }
13851385
 .shithub-pull-check-conclusion-timed_out { background: #9a670022; color: #9a6700; }
13861386
 .shithub-pull-check-conclusion-action_required { background: #cf222e22; color: #cf222e; font-weight: 600; }
1387
+
1388
+/* S26 — stars / watchers list pages */
1389
+.shithub-social { padding: 1rem 0; }
1390
+.shithub-social-list { list-style: none; padding: 0; margin: 1rem 0; }
1391
+.shithub-social-list li {
1392
+  display: flex; gap: 0.6rem; align-items: baseline;
1393
+  padding: 0.5rem 0; border-bottom: 1px solid var(--border-default);
1394
+}
1395
+.shithub-social-list li:last-child { border-bottom: none; }
1396
+.shithub-social-list small { color: var(--fg-muted); }
1397
+.shithub-meta { color: var(--fg-muted); margin: 0.25rem 0 1rem; }
1398
+.shithub-empty { color: var(--fg-muted); padding: 1rem; }
1399
+.shithub-pagination {
1400
+  display: flex; gap: 0.5rem; padding: 1rem 0;
1401
+}
internal/web/templates/repo/stargazers.htmladded
@@ -0,0 +1,33 @@
1
+{{ define "page" -}}
2
+<section class="shithub-social">
3
+  <header class="shithub-code-head">
4
+    <h1>
5
+      <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Owner }}/{{ .Repo.Name }}</a>
6
+      <span class="shithub-code-sep">/</span>
7
+      Stargazers
8
+    </h1>
9
+    <p class="shithub-meta">{{ .Total }} {{ if eq .Total 1 }}stargazer{{ else }}stargazers{{ end }}</p>
10
+  </header>
11
+
12
+  {{ if .Stargazers }}
13
+  <ul class="shithub-social-list">
14
+    {{ range .Stargazers }}
15
+    <li>
16
+      <a href="/{{ .Username }}"><strong>{{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}</strong></a>
17
+      <small>@{{ .Username }}</small>
18
+      <small><time datetime="{{ .StarredAt.Time.Format "2006-01-02T15:04:05Z" }}">starred {{ relativeTime .StarredAt.Time }}</time></small>
19
+    </li>
20
+    {{ end }}
21
+  </ul>
22
+  {{ else }}
23
+  <p class="shithub-empty">No stargazers yet.</p>
24
+  {{ end }}
25
+
26
+  {{ if or .HasPrev .HasNext }}
27
+  <nav class="shithub-pagination">
28
+    {{ if .HasPrev }}<a href="?page={{ sub .Page 1 }}" class="shithub-button">Previous</a>{{ end }}
29
+    {{ if .HasNext }}<a href="?page={{ add .Page 1 }}" class="shithub-button">Next</a>{{ end }}
30
+  </nav>
31
+  {{ end }}
32
+</section>
33
+{{- end }}
internal/web/templates/repo/watchers.htmladded
@@ -0,0 +1,34 @@
1
+{{ define "page" -}}
2
+<section class="shithub-social">
3
+  <header class="shithub-code-head">
4
+    <h1>
5
+      <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Owner }}/{{ .Repo.Name }}</a>
6
+      <span class="shithub-code-sep">/</span>
7
+      Watchers
8
+    </h1>
9
+    <p class="shithub-meta">{{ .Total }} {{ if eq .Total 1 }}watcher{{ else }}watchers{{ end }}</p>
10
+  </header>
11
+
12
+  {{ if .Watchers }}
13
+  <ul class="shithub-social-list">
14
+    {{ range .Watchers }}
15
+    <li>
16
+      <a href="/{{ .Username }}"><strong>{{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}</strong></a>
17
+      <small>@{{ .Username }}</small>
18
+      <span class="shithub-pill">{{ .Level }}</span>
19
+      <small><time datetime="{{ .UpdatedAt.Time.Format "2006-01-02T15:04:05Z" }}">since {{ relativeTime .UpdatedAt.Time }}</time></small>
20
+    </li>
21
+    {{ end }}
22
+  </ul>
23
+  {{ else }}
24
+  <p class="shithub-empty">No watchers yet.</p>
25
+  {{ end }}
26
+
27
+  {{ if or .HasPrev .HasNext }}
28
+  <nav class="shithub-pagination">
29
+    {{ if .HasPrev }}<a href="?page={{ sub .Page 1 }}" class="shithub-button">Previous</a>{{ end }}
30
+    {{ if .HasNext }}<a href="?page={{ add .Page 1 }}" class="shithub-button">Next</a>{{ end }}
31
+  </nav>
32
+  {{ end }}
33
+</section>
34
+{{- end }}