Go · 8921 bytes Raw Blame History
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 redirectAfterRepoAction(w, r, "/"+owner+"/"+row.Name)
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 redirectAfterRepoAction(w, r, "/"+owner+"/"+row.Name)
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 redirectAfterRepoAction(w, r, "/"+owner+"/"+row.Name)
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 "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount),
139 "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
140 }
141 if err := h.d.Render.RenderPage(w, r, "repo/stargazers", common); err != nil {
142 h.d.Logger.ErrorContext(r.Context(), "stargazers render", "error", err)
143 }
144 }
145
146 // watchersList renders /{owner}/{repo}/watchers. Same gating as
147 // stargazers.
148 func (h *Handlers) watchersList(w http.ResponseWriter, r *http.Request) {
149 owner := chi.URLParam(r, "owner")
150 name := chi.URLParam(r, "repo")
151 row, err := h.lookupRepoForViewer(r.Context(), owner, name, middleware.CurrentUserFromContext(r.Context()))
152 if err != nil {
153 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
154 return
155 }
156 page := pageFromRequest(r)
157 q := socialdb.New()
158 rows, err := q.ListWatchersForRepo(r.Context(), h.d.Pool, socialdb.ListWatchersForRepoParams{
159 RepoID: row.ID, Limit: socialPageSize, Offset: int32((page - 1) * socialPageSize),
160 })
161 if err != nil {
162 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "list watchers")
163 return
164 }
165 count, _ := q.CountWatchersForRepo(r.Context(), h.d.Pool, row.ID)
166 common := map[string]any{
167 "Title": "Watchers · " + row.Name,
168 "Owner": owner,
169 "Repo": row,
170 "Watchers": rows,
171 "Total": count,
172 "Page": page,
173 "HasNext": int64(page*socialPageSize) < count,
174 "HasPrev": page > 1,
175 "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount),
176 "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
177 }
178 if err := h.d.Render.RenderPage(w, r, "repo/watchers", common); err != nil {
179 h.d.Logger.ErrorContext(r.Context(), "watchers render", "error", err)
180 }
181 }
182
183 // authorizeSocialAction is the social-action equivalent of
184 // loadRepoAndAuthorize: resolves owner+repo, calls policy.Can with
185 // the action (which returns DenyVisibility on private+non-collab),
186 // and routes the deny path through Maybe404. Returns the repo + the
187 // raw owner string so handlers can build redirects.
188 func (h *Handlers) authorizeSocialAction(w http.ResponseWriter, r *http.Request, action policy.Action) (repoRow, string, bool) {
189 ownerName := chi.URLParam(r, "owner")
190 repoName := chi.URLParam(r, "repo")
191 row, err := h.lookupRepoForViewer(r.Context(), ownerName, repoName, middleware.CurrentUserFromContext(r.Context()))
192 if err != nil {
193 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
194 return repoRow{}, "", false
195 }
196 viewer := middleware.CurrentUserFromContext(r.Context())
197 actor := viewer.PolicyActor()
198 repoRef := policy.NewRepoRefFromRepo(row)
199 if dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, action, repoRef); !dec.Allow {
200 h.d.Render.HTTPError(w, r, policy.Maybe404(dec, repoRef, actor), "")
201 return repoRow{}, "", false
202 }
203 return repoRow{ID: row.ID, Name: row.Name, Visibility: string(row.Visibility)}, ownerName, true
204 }
205
206 // repoRow is the slim view the social handlers need from
207 // `lookupRepoForViewer`. Avoids importing the big reposdb.Repo shape
208 // into the handler signature.
209 type repoRow struct {
210 ID int64
211 Name string
212 Visibility string
213 }
214
215 func repoIsPublic(r repoRow) bool { return r.Visibility == "public" }
216
217 func pageFromRequest(r *http.Request) int {
218 v, _ := strconv.Atoi(r.URL.Query().Get("page"))
219 if v < 1 {
220 return 1
221 }
222 return v
223 }
224
225 // handleSocialError maps the orchestrator's typed errors to status
226 // codes / friendly messages. Mirrors the issues/PR error-mapping
227 // shape so the rest of the handler set stays consistent.
228 func (h *Handlers) handleSocialError(w http.ResponseWriter, r *http.Request, err error) {
229 switch {
230 case errors.Is(err, social.ErrNotLoggedIn):
231 http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
232 case errors.Is(err, social.ErrInvalidWatchLevel):
233 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "invalid watch level")
234 case errors.Is(err, social.ErrStarRateLimit):
235 h.d.Render.HTTPError(w, r, http.StatusTooManyRequests, "rate limit")
236 default:
237 h.d.Logger.ErrorContext(r.Context(), "social handler", "error", err)
238 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
239 }
240 }
241