@@ -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 | +} |