| 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 |