Go · 10640 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package profile owns the read-only public profile handlers:
4 // /{username}, /orgs/{org}/repositories, and /avatars/{username}.
5 // Edit-profile is S10.
6 //
7 // Route ordering is critical here: the wildcard /{username} catches any
8 // path the chi router didn't already match. The reserved-name list is the
9 // second line of defense if a future top-level route is added but not
10 // registered before the wildcard. The route-audit test in
11 // internal/web/handlers/handlers_test.go enforces both lines of defense.
12 package profile
13
14 import (
15 "errors"
16 "fmt"
17 "html/template"
18 "io"
19 "log/slog"
20 "net/http"
21 "net/url"
22 "strings"
23
24 "github.com/go-chi/chi/v5"
25 "github.com/jackc/pgx/v5"
26 "github.com/jackc/pgx/v5/pgxpool"
27
28 authpkg "github.com/tenseleyFlow/shithub/internal/auth"
29 "github.com/tenseleyFlow/shithub/internal/avatars"
30 "github.com/tenseleyFlow/shithub/internal/infra/storage"
31 "github.com/tenseleyFlow/shithub/internal/orgs"
32 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
33 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
34 "github.com/tenseleyFlow/shithub/internal/web/middleware"
35 "github.com/tenseleyFlow/shithub/internal/web/render"
36 )
37
38 // Deps wires the profile handlers.
39 type Deps struct {
40 Logger *slog.Logger
41 Render *render.Renderer
42 Pool *pgxpool.Pool
43 // RepoFS is optional for profile tests, but production passes it so
44 // org overview repo rows can render commit-activity sparklines.
45 RepoFS *storage.RepoFS
46 // ObjectStore is used to stream uploaded avatars. May be nil in tests
47 // or when S3 is not configured — falls back to identicon.
48 ObjectStore storage.ObjectStore
49 }
50
51 // Handlers is the registered profile handler set.
52 type Handlers struct {
53 d Deps
54 q *usersdb.Queries
55 }
56
57 // New constructs the handler set, validating Deps.
58 func New(d Deps) (*Handlers, error) {
59 if d.Render == nil {
60 return nil, errors.New("profile: nil Render")
61 }
62 if d.Pool == nil {
63 return nil, errors.New("profile: nil Pool")
64 }
65 return &Handlers{d: d, q: usersdb.New()}, nil
66 }
67
68 // MountAvatars registers /avatars/{username}. Belongs in the CSRF-exempt
69 // group since GETs are idempotent and benefit from CDN caching.
70 func (h *Handlers) MountAvatars(r chi.Router) {
71 r.Get("/avatars/{username}", h.serveAvatar)
72 }
73
74 // MountProfile registers the /{username} catch-all. Caller MUST pass an
75 // r that has already mounted every static top-level route — chi matches
76 // in registration order, and {username} is the catch-all.
77 func (h *Handlers) MountProfile(r chi.Router) {
78 r.Group(func(r chi.Router) {
79 r.Use(middleware.RequireUser)
80 r.Post("/{username}/pins", h.pinsUpdate)
81 })
82 r.Get("/{username}", h.serveProfile)
83 }
84
85 // ----------------------------- profile ----------------------------------
86
87 func (h *Handlers) serveProfile(w http.ResponseWriter, r *http.Request) {
88 rawName := chi.URLParam(r, "username")
89 lower := strings.ToLower(rawName)
90
91 // Defense in depth: a future top-level route that forgets to
92 // register before the wildcard would otherwise resolve here. The
93 // reserved-name list short-circuits with a 404.
94 if authpkg.IsReserved(lower) {
95 h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
96 return
97 }
98
99 // S30: principals first. The single-row lookup decides whether
100 // /{slug} dispatches to the user-profile renderer (existing) or
101 // the org-profile renderer (this sprint). On miss, fall through
102 // to username_redirects so renamed users keep redirecting.
103 if p, err := orgs.Resolve(r.Context(), h.d.Pool, lower); err == nil {
104 switch p.Kind {
105 case orgs.PrincipalOrg:
106 h.serveOrgProfile(w, r, p.ID)
107 return
108 case orgs.PrincipalUser:
109 // Fall through to the user lookup below — keeps the
110 // existing canonical-case redirect + suspension paths.
111 }
112 }
113
114 // Try direct lookup. citext makes the comparison case-insensitive.
115 user, err := h.q.GetUserByUsername(r.Context(), h.d.Pool, rawName)
116 if err != nil {
117 if errors.Is(err, pgx.ErrNoRows) {
118 h.tryRedirectOrNotFound(w, r, lower)
119 return
120 }
121 h.d.Logger.ErrorContext(r.Context(), "profile: lookup", "error", err)
122 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
123 return
124 }
125
126 // Canonical-case redirect. The DB stores the canonical casing; if
127 // the URL differs, send a 301 so URLs are consistent.
128 if rawName != user.Username {
129 http.Redirect(w, r, "/"+user.Username, http.StatusMovedPermanently)
130 return
131 }
132
133 if user.SuspendedAt.Valid || user.DeletedAt.Valid {
134 h.renderUnavailable(w, r, user.Username)
135 return
136 }
137
138 viewer := middleware.CurrentUserFromContext(r.Context())
139 isSelf := viewer.ID != 0 && viewer.ID == user.ID
140
141 // Sub-nav dispatch. Overview is the default; ?tab=repositories
142 // and ?tab=stars take their own renderers (each one is
143 // visibility-filtered independently).
144 switch r.URL.Query().Get("tab") {
145 case "stars":
146 h.serveStarsTab(w, r, user, viewer, isSelf)
147 return
148 case "repositories":
149 h.serveRepositoriesTab(w, r, user, viewer, isSelf)
150 return
151 }
152
153 // Anonymous: ETag + small max-age. Self-view: no-cache.
154 if isSelf {
155 w.Header().Set("Cache-Control", "no-cache, private")
156 } else {
157 w.Header().Set("Cache-Control", "max-age=300")
158 }
159
160 avatarURL := fmt.Sprintf("/avatars/%s", url.PathEscape(user.Username))
161 tabs := h.tabCounts(r.Context(), user.ID, viewer)
162 pinnedRepos, pinCandidates := h.userPinData(r.Context(), user)
163 data := map[string]any{
164 "Title": user.DisplayName,
165 "User": user,
166 "IsSelf": isSelf,
167 "AvatarURL": avatarURL,
168 "OGTitle": user.DisplayName + " (@" + user.Username + ")",
169 "OGDescription": ogDescription(user),
170 "OGImage": avatarURL,
171 "JoinedFormatted": user.CreatedAt.Time.Format("January 2, 2006"),
172 "WebsiteSafe": safeWebsite(user.Website),
173 "Tabs": tabs,
174 "ActiveTab": "overview",
175 "PinnedRepos": pinnedRepos,
176 "PinCandidates": pinCandidates,
177 "PinsRemaining": profilePinsRemaining(pinCandidates),
178 "CanCustomizePins": isSelf,
179 "PinsAction": "/" + url.PathEscape(user.Username) + "/pins",
180 }
181 w.Header().Set("Content-Type", "text/html; charset=utf-8")
182 if err := h.d.Render.RenderPage(w, r, "profile/view", data); err != nil {
183 h.d.Logger.ErrorContext(r.Context(), "profile: render", "error", err)
184 }
185 }
186
187 // tryRedirectOrNotFound checks the username_redirects table and 301s on
188 // hit, otherwise renders the styled 404.
189 func (h *Handlers) tryRedirectOrNotFound(w http.ResponseWriter, r *http.Request, lower string) {
190 row, err := h.q.LookupUsernameRedirect(r.Context(), h.d.Pool, lower)
191 if err != nil {
192 h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
193 return
194 }
195 http.Redirect(w, r, "/"+row.Username, http.StatusMovedPermanently)
196 }
197
198 // renderUnavailable serves the dedicated suspended-user page. Distinct
199 // from 404 (would leak existence info) and from 200 (would imply normal
200 // profile).
201 func (h *Handlers) renderUnavailable(w http.ResponseWriter, r *http.Request, username string) {
202 w.Header().Set("Cache-Control", "no-store")
203 w.Header().Set("Content-Type", "text/html; charset=utf-8")
204 w.WriteHeader(http.StatusGone) // 410 — semantically "this resource is gone but we know it existed"
205 if err := h.d.Render.RenderPage(w, r, "profile/suspended", map[string]any{
206 "Title": "Account unavailable",
207 "Username": username,
208 }); err != nil {
209 h.d.Logger.ErrorContext(r.Context(), "profile: render suspended", "error", err)
210 }
211 }
212
213 // ------------------------------ avatar ----------------------------------
214
215 // serveAvatar resolves the slug, then either streams the uploaded
216 // user/org avatar from object storage or returns the deterministic SVG
217 // identicon.
218 //
219 // Implementation notes:
220 // - Lookup-by-username happens on every request. At our scale this is
221 // fine; if the avatar route becomes hot we can add an LRU.
222 // - Missing, suspended, or deleted principals get the identicon (NOT a
223 // 404) so avatar URLs leak less existence state.
224 // - Cache-Control: long max-age + immutable. Avatar contents are
225 // content-addressed at upload time so the URL changes when the image
226 // changes, making "immutable" safe.
227 func (h *Handlers) serveAvatar(w http.ResponseWriter, r *http.Request) {
228 slug := chi.URLParam(r, "username")
229 key, seed := h.avatarKeyForSlug(r, slug)
230 if key == "" {
231 writeIdenticon(w, r, seed)
232 return
233 }
234 if h.d.ObjectStore == nil {
235 writeIdenticon(w, r, seed)
236 return
237 }
238 rc, meta, err := h.d.ObjectStore.Get(r.Context(), key)
239 if err != nil {
240 writeIdenticon(w, r, seed)
241 return
242 }
243 defer func() { _ = rc.Close() }()
244 if meta.ContentType != "" {
245 w.Header().Set("Content-Type", meta.ContentType)
246 }
247 if meta.ETag != "" {
248 w.Header().Set("ETag", meta.ETag)
249 }
250 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
251 w.WriteHeader(http.StatusOK)
252 _, _ = io.Copy(w, rc)
253 }
254
255 func (h *Handlers) avatarKeyForSlug(r *http.Request, slug string) (string, string) {
256 user, err := h.q.GetUserByUsername(r.Context(), h.d.Pool, slug)
257 if err == nil {
258 if user.AvatarObjectKey.Valid {
259 return user.AvatarObjectKey.String, user.Username
260 }
261 return "", user.Username
262 }
263 org, err := orgsdb.New().GetOrgBySlug(r.Context(), h.d.Pool, slug)
264 if err == nil {
265 if org.AvatarObjectKey.Valid {
266 return org.AvatarObjectKey.String, org.Slug
267 }
268 return "", org.Slug
269 }
270 return "", slug
271 }
272
273 func writeIdenticon(w http.ResponseWriter, _ *http.Request, username string) {
274 w.Header().Set("Content-Type", "image/svg+xml")
275 // Identicons depend ONLY on the username; cache forever.
276 w.Header().Set("Cache-Control", "public, max-age=86400")
277 // Defense in depth: forbid sniffing the body as HTML even though our
278 // SVG body never echoes user input (it only hashes the username).
279 w.Header().Set("X-Content-Type-Options", "nosniff")
280 w.WriteHeader(http.StatusOK)
281 //nolint:gosec // G705: body is server-generated SVG built from sha256(username).
282 _, _ = w.Write([]byte(avatars.Identicon(username, 460)))
283 }
284
285 // ----------------------------- helpers ----------------------------------
286
287 func ogDescription(u usersdb.User) string {
288 if u.Bio != "" {
289 return u.Bio
290 }
291 return "@" + u.Username + " on shithub"
292 }
293
294 // safeWebsite returns u.Website only when it's an http(s) URL we can
295 // safely link out to. Anything else collapses to empty so the template
296 // doesn't render a clickable junk link.
297 func safeWebsite(s string) template.URL {
298 if s == "" {
299 return ""
300 }
301 u, err := url.Parse(s)
302 if err != nil {
303 return ""
304 }
305 if u.Scheme != "http" && u.Scheme != "https" {
306 return ""
307 }
308 if u.Host == "" {
309 return ""
310 }
311 return template.URL(u.String()) //nolint:gosec // schemes vetted above.
312 }
313