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