tenseleyflow/shithub / 681f412

Browse files

Wire profile follow controls

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
681f412572ad6d1d796c162a5b1662575b676d30
Parents
6b1f39a
Tree
82e1527

9 changed files

StatusFile+-
A internal/web/handlers/profile/follows.go 302 0
M internal/web/handlers/profile/org_profile.go 6 0
M internal/web/handlers/profile/profile.go 19 0
M internal/web/handlers/profile/profile_test.go 118 8
M internal/web/profile_wiring.go 4 0
M internal/web/static/css/shithub.css 88 0
M internal/web/templates/orgs/profile.html 12 1
A internal/web/templates/profile/follows_tab.html 51 0
M internal/web/templates/profile/view.html 9 3
internal/web/handlers/profile/follows.goadded
@@ -0,0 +1,302 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package profile
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"net/http"
9
+	"net/url"
10
+	"strconv"
11
+	"strings"
12
+
13
+	"github.com/go-chi/chi/v5"
14
+	"github.com/jackc/pgx/v5"
15
+	"github.com/jackc/pgx/v5/pgtype"
16
+
17
+	authpkg "github.com/tenseleyFlow/shithub/internal/auth"
18
+	"github.com/tenseleyFlow/shithub/internal/orgs"
19
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
20
+	"github.com/tenseleyFlow/shithub/internal/social"
21
+	socialdb "github.com/tenseleyFlow/shithub/internal/social/sqlc"
22
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
23
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
24
+)
25
+
26
+const followsPageSize = 50
27
+
28
+type followState struct {
29
+	FollowersCount int64
30
+	FollowingCount int64
31
+	IsFollowing    bool
32
+}
33
+
34
+type followListItem struct {
35
+	Kind        string
36
+	Username    string
37
+	DisplayName string
38
+	AvatarURL   string
39
+	URL         string
40
+	FollowedAt  string
41
+}
42
+
43
+func (h *Handlers) socialDeps() social.Deps {
44
+	return social.Deps{
45
+		Pool:    h.d.Pool,
46
+		Limiter: h.d.Limiter,
47
+		Logger:  h.d.Logger,
48
+		Audit:   h.d.Audit,
49
+	}
50
+}
51
+
52
+func (h *Handlers) profileFollow(w http.ResponseWriter, r *http.Request) {
53
+	h.followAction(w, r, true)
54
+}
55
+
56
+func (h *Handlers) profileUnfollow(w http.ResponseWriter, r *http.Request) {
57
+	h.followAction(w, r, false)
58
+}
59
+
60
+func (h *Handlers) followAction(w http.ResponseWriter, r *http.Request, follow bool) {
61
+	viewer := middleware.CurrentUserFromContext(r.Context())
62
+	if viewer.IsAnonymous() {
63
+		http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
64
+		return
65
+	}
66
+	rawName := chi.URLParam(r, "username")
67
+	lower := strings.ToLower(rawName)
68
+	if authpkg.IsReserved(lower) {
69
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
70
+		return
71
+	}
72
+	if err := r.ParseForm(); err != nil {
73
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
74
+		return
75
+	}
76
+
77
+	if p, err := orgs.Resolve(r.Context(), h.d.Pool, lower); err == nil {
78
+		switch p.Kind {
79
+		case orgs.PrincipalOrg:
80
+			h.followOrgAction(w, r, viewer, p.ID, follow)
81
+			return
82
+		case orgs.PrincipalUser:
83
+			h.followUserAction(w, r, viewer, rawName, follow)
84
+			return
85
+		}
86
+	}
87
+	h.followUserAction(w, r, viewer, rawName, follow)
88
+}
89
+
90
+func (h *Handlers) followUserAction(w http.ResponseWriter, r *http.Request, viewer middleware.CurrentUser, rawName string, follow bool) {
91
+	target, err := h.q.GetUserByUsername(r.Context(), h.d.Pool, rawName)
92
+	if err != nil {
93
+		if errors.Is(err, pgx.ErrNoRows) {
94
+			h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
95
+			return
96
+		}
97
+		h.d.Logger.ErrorContext(r.Context(), "profile follow: lookup user", "error", err)
98
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
99
+		return
100
+	}
101
+	if target.SuspendedAt.Valid || target.DeletedAt.Valid {
102
+		h.renderUnavailable(w, r, target.Username)
103
+		return
104
+	}
105
+	var actionErr error
106
+	if follow {
107
+		actionErr = social.FollowUser(r.Context(), h.socialDeps(), viewer.ID, target.ID)
108
+	} else {
109
+		actionErr = social.UnfollowUser(r.Context(), h.socialDeps(), viewer.ID, target.ID)
110
+	}
111
+	if actionErr != nil {
112
+		h.handleFollowError(w, r, actionErr)
113
+		return
114
+	}
115
+	redirectAfterProfileAction(w, r, "/"+target.Username)
116
+}
117
+
118
+func (h *Handlers) followOrgAction(w http.ResponseWriter, r *http.Request, viewer middleware.CurrentUser, orgID int64, follow bool) {
119
+	org, err := orgsdb.New().GetOrgByID(r.Context(), h.d.Pool, orgID)
120
+	if err != nil || org.DeletedAt.Valid {
121
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
122
+		return
123
+	}
124
+	if org.SuspendedAt.Valid {
125
+		h.d.Render.HTTPError(w, r, http.StatusGone, string(org.Slug))
126
+		return
127
+	}
128
+	var actionErr error
129
+	if follow {
130
+		actionErr = social.FollowOrg(r.Context(), h.socialDeps(), viewer.ID, org.ID)
131
+	} else {
132
+		actionErr = social.UnfollowOrg(r.Context(), h.socialDeps(), viewer.ID, org.ID)
133
+	}
134
+	if actionErr != nil {
135
+		h.handleFollowError(w, r, actionErr)
136
+		return
137
+	}
138
+	redirectAfterProfileAction(w, r, "/"+org.Slug)
139
+}
140
+
141
+func (h *Handlers) handleFollowError(w http.ResponseWriter, r *http.Request, err error) {
142
+	switch {
143
+	case errors.Is(err, social.ErrNotLoggedIn):
144
+		http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
145
+	case errors.Is(err, social.ErrCannotFollowSelf):
146
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "cannot follow yourself")
147
+	case errors.Is(err, social.ErrFollowRateLimit):
148
+		h.d.Render.HTTPError(w, r, http.StatusTooManyRequests, "rate limit")
149
+	default:
150
+		h.d.Logger.ErrorContext(r.Context(), "profile follow", "error", err)
151
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
152
+	}
153
+}
154
+
155
+func redirectAfterProfileAction(w http.ResponseWriter, r *http.Request, fallback string) {
156
+	dest := fallback
157
+	if returnTo := strings.TrimSpace(r.PostFormValue("return_to")); safeProfileReturnTo(returnTo) {
158
+		dest = returnTo
159
+	}
160
+	http.Redirect(w, r, dest, http.StatusSeeOther)
161
+}
162
+
163
+func safeProfileReturnTo(path string) bool {
164
+	if path == "" || !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
165
+		return false
166
+	}
167
+	u, err := url.Parse(path)
168
+	return err == nil && !u.IsAbs() && u.Host == "" && strings.HasPrefix(u.Path, "/")
169
+}
170
+
171
+func (h *Handlers) userFollowState(ctx context.Context, userID int64, viewer middleware.CurrentUser) followState {
172
+	q := socialdb.New()
173
+	var out followState
174
+	out.FollowersCount, _ = q.CountFollowersForUser(ctx, h.d.Pool, pgtype.Int8{Int64: userID, Valid: true})
175
+	out.FollowingCount, _ = q.CountFollowingForUser(ctx, h.d.Pool, userID)
176
+	if !viewer.IsAnonymous() && viewer.ID != userID {
177
+		out.IsFollowing, _ = social.IsFollowingUser(ctx, h.socialDeps(), viewer.ID, userID)
178
+	}
179
+	return out
180
+}
181
+
182
+func (h *Handlers) orgFollowState(ctx context.Context, orgID int64, viewer middleware.CurrentUser) followState {
183
+	q := socialdb.New()
184
+	var out followState
185
+	out.FollowersCount, _ = q.CountFollowersForOrg(ctx, h.d.Pool, pgtype.Int8{Int64: orgID, Valid: true})
186
+	if !viewer.IsAnonymous() {
187
+		out.IsFollowing, _ = social.IsFollowingOrg(ctx, h.socialDeps(), viewer.ID, orgID)
188
+	}
189
+	return out
190
+}
191
+
192
+func (h *Handlers) serveFollowersTab(w http.ResponseWriter, r *http.Request, user usersdb.User, viewer middleware.CurrentUser, isSelf bool) {
193
+	page := pageFromRequest(r)
194
+	rows, err := socialdb.New().ListFollowersForUser(r.Context(), h.d.Pool, socialdb.ListFollowersForUserParams{
195
+		FolloweeUserID: pgtype.Int8{Int64: user.ID, Valid: true},
196
+		Limit:          followsPageSize,
197
+		Offset:         int32((page - 1) * followsPageSize),
198
+	})
199
+	if err != nil {
200
+		h.d.Logger.ErrorContext(r.Context(), "profile followers: list", "error", err)
201
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
202
+		return
203
+	}
204
+	state := h.userFollowState(r.Context(), user.ID, viewer)
205
+	items := make([]followListItem, 0, len(rows))
206
+	for _, row := range rows {
207
+		items = append(items, userFollowListItem(row.Username, row.DisplayName, row.FollowedAt))
208
+	}
209
+	h.renderFollowsTab(w, r, user, isSelf, "followers", state, items, page)
210
+}
211
+
212
+func (h *Handlers) serveFollowingTab(w http.ResponseWriter, r *http.Request, user usersdb.User, viewer middleware.CurrentUser, isSelf bool) {
213
+	userRows, err := socialdb.New().ListFollowingUsersForUser(r.Context(), h.d.Pool, socialdb.ListFollowingUsersForUserParams{
214
+		FollowerUserID: user.ID,
215
+		Limit:          followsPageSize,
216
+		Offset:         0,
217
+	})
218
+	if err != nil {
219
+		h.d.Logger.ErrorContext(r.Context(), "profile following users: list", "error", err)
220
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
221
+		return
222
+	}
223
+	orgRows, err := socialdb.New().ListFollowingOrgsForUser(r.Context(), h.d.Pool, socialdb.ListFollowingOrgsForUserParams{
224
+		FollowerUserID: user.ID,
225
+		Limit:          followsPageSize,
226
+		Offset:         0,
227
+	})
228
+	if err != nil {
229
+		h.d.Logger.ErrorContext(r.Context(), "profile following orgs: list", "error", err)
230
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
231
+		return
232
+	}
233
+	state := h.userFollowState(r.Context(), user.ID, viewer)
234
+	items := make([]followListItem, 0, len(userRows)+len(orgRows))
235
+	for _, row := range userRows {
236
+		items = append(items, userFollowListItem(row.Username, row.DisplayName, row.FollowedAt))
237
+	}
238
+	for _, row := range orgRows {
239
+		items = append(items, orgFollowListItem(row.Slug, row.DisplayName, row.FollowedAt))
240
+	}
241
+	h.renderFollowsTab(w, r, user, isSelf, "following", state, items, 1)
242
+}
243
+
244
+func (h *Handlers) renderFollowsTab(w http.ResponseWriter, r *http.Request, user usersdb.User, isSelf bool, active string, state followState, items []followListItem, page int) {
245
+	displayName := user.DisplayName
246
+	if displayName == "" {
247
+		displayName = user.Username
248
+	}
249
+	data := map[string]any{
250
+		"Title":          followTabTitle(active) + " · " + user.Username,
251
+		"User":           user,
252
+		"DisplayName":    displayName,
253
+		"IsSelf":         isSelf,
254
+		"AvatarURL":      "/avatars/" + url.PathEscape(user.Username),
255
+		"Tabs":           h.tabCounts(r.Context(), user.ID, middleware.CurrentUserFromContext(r.Context())),
256
+		"ActiveTab":      active,
257
+		"FollowersCount": state.FollowersCount,
258
+		"FollowingCount": state.FollowingCount,
259
+		"Items":          items,
260
+		"Page":           page,
261
+		"HasPrev":        page > 1,
262
+		"HasNext":        len(items) == followsPageSize,
263
+	}
264
+	if err := h.d.Render.RenderPage(w, r, "profile/follows_tab", data); err != nil {
265
+		h.d.Logger.ErrorContext(r.Context(), "profile follows: render", "error", err)
266
+	}
267
+}
268
+
269
+func followTabTitle(active string) string {
270
+	switch active {
271
+	case "followers":
272
+		return "Followers"
273
+	case "following":
274
+		return "Following"
275
+	default:
276
+		return "People"
277
+	}
278
+}
279
+
280
+func pageFromRequest(r *http.Request) int {
281
+	v, _ := strconv.Atoi(r.URL.Query().Get("page"))
282
+	if v < 1 {
283
+		return 1
284
+	}
285
+	return v
286
+}
287
+
288
+func userFollowListItem(username, displayName string, followedAt pgtype.Timestamptz) followListItem {
289
+	return followListItem{
290
+		Kind: "user", Username: username, DisplayName: displayName,
291
+		AvatarURL: "/avatars/" + url.PathEscape(username), URL: "/" + username,
292
+		FollowedAt: followedAt.Time.Format("Jan 2, 2006"),
293
+	}
294
+}
295
+
296
+func orgFollowListItem(slug, displayName string, followedAt pgtype.Timestamptz) followListItem {
297
+	return followListItem{
298
+		Kind: "org", Username: slug, DisplayName: displayName,
299
+		AvatarURL: "/avatars/" + url.PathEscape(slug), URL: "/" + slug,
300
+		FollowedAt: followedAt.Time.Format("Jan 2, 2006"),
301
+	}
302
+}
internal/web/handlers/profile/org_profile.gomodified
@@ -99,6 +99,7 @@ func (h *Handlers) serveOrgProfile(w http.ResponseWriter, r *http.Request, orgID
9999
 	}
100100
 
101101
 	repos := h.orgProfileRepos(ctx, org.ID, viewer)
102
+	followState := h.orgFollowState(ctx, org.ID, viewer)
102103
 	repoRows := h.withOrgRepoActivity(ctx, string(org.Slug), limitOrgRepos(repos, orgHomepageRepoLimit))
103104
 	pinnedRepos, pinCandidates := h.orgPinData(ctx, org.ID, string(org.Slug), repos)
104105
 	people := h.orgProfilePeople(ctx, q, org.ID)
@@ -136,6 +137,11 @@ func (h *Handlers) serveOrgProfile(w http.ResponseWriter, r *http.Request, orgID
136137
 		"RepoCount":          int64(len(repos)),
137138
 		"TeamCount":          teamCount,
138139
 		"MemberCount":        memberCount,
140
+		"FollowerCount":      followState.FollowersCount,
141
+		"IsFollowing":        followState.IsFollowing,
142
+		"FollowAction":       "/" + url.PathEscape(org.Slug) + "/follow",
143
+		"UnfollowAction":     "/" + url.PathEscape(org.Slug) + "/unfollow",
144
+		"ReturnTo":           r.URL.RequestURI(),
139145
 		"People":             limitOrgPeople(people, orgHomepagePeopleLimit),
140146
 		"TopLanguages":       orgTopLanguages(repos),
141147
 		"TopTopics":          orgTopTopics(repos),
internal/web/handlers/profile/profile.gomodified
@@ -26,6 +26,8 @@ import (
2626
 	"github.com/jackc/pgx/v5/pgxpool"
2727
 
2828
 	authpkg "github.com/tenseleyFlow/shithub/internal/auth"
29
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
30
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
2931
 	"github.com/tenseleyFlow/shithub/internal/avatars"
3032
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
3133
 	"github.com/tenseleyFlow/shithub/internal/orgs"
@@ -46,6 +48,8 @@ type Deps struct {
4648
 	// ObjectStore is used to stream uploaded avatars. May be nil in tests
4749
 	// or when S3 is not configured — falls back to identicon.
4850
 	ObjectStore storage.ObjectStore
51
+	Limiter     *throttle.Limiter
52
+	Audit       *audit.Recorder
4953
 }
5054
 
5155
 // Handlers is the registered profile handler set.
@@ -77,6 +81,8 @@ func (h *Handlers) MountAvatars(r chi.Router) {
7781
 func (h *Handlers) MountProfile(r chi.Router) {
7882
 	r.Group(func(r chi.Router) {
7983
 		r.Use(middleware.RequireUser)
84
+		r.Post("/{username}/follow", h.profileFollow)
85
+		r.Post("/{username}/unfollow", h.profileUnfollow)
8086
 		r.Post("/{username}/pins", h.pinsUpdate)
8187
 	})
8288
 	r.Get("/{username}", h.serveProfile)
@@ -142,6 +148,12 @@ func (h *Handlers) serveProfile(w http.ResponseWriter, r *http.Request) {
142148
 	// and ?tab=stars take their own renderers (each one is
143149
 	// visibility-filtered independently).
144150
 	switch r.URL.Query().Get("tab") {
151
+	case "followers":
152
+		h.serveFollowersTab(w, r, user, viewer, isSelf)
153
+		return
154
+	case "following":
155
+		h.serveFollowingTab(w, r, user, viewer, isSelf)
156
+		return
145157
 	case "stars":
146158
 		h.serveStarsTab(w, r, user, viewer, isSelf)
147159
 		return
@@ -159,6 +171,7 @@ func (h *Handlers) serveProfile(w http.ResponseWriter, r *http.Request) {
159171
 
160172
 	avatarURL := fmt.Sprintf("/avatars/%s", url.PathEscape(user.Username))
161173
 	tabs := h.tabCounts(r.Context(), user.ID, viewer)
174
+	followState := h.userFollowState(r.Context(), user.ID, viewer)
162175
 	visibleRepos := h.visibleUserRepos(r.Context(), user.ID, viewer)
163176
 	pinnedRepos, pinCandidates := h.userPinData(r.Context(), user)
164177
 	readme, hasReadme := h.profileReadme(r.Context(), user, viewer)
@@ -179,6 +192,12 @@ func (h *Handlers) serveProfile(w http.ResponseWriter, r *http.Request) {
179192
 		"WebsiteSafe":      safeWebsite(user.Website),
180193
 		"Tabs":             tabs,
181194
 		"ActiveTab":        "overview",
195
+		"FollowersCount":   followState.FollowersCount,
196
+		"FollowingCount":   followState.FollowingCount,
197
+		"IsFollowing":      followState.IsFollowing,
198
+		"FollowAction":     "/" + url.PathEscape(user.Username) + "/follow",
199
+		"UnfollowAction":   "/" + url.PathEscape(user.Username) + "/unfollow",
200
+		"ReturnTo":         r.URL.RequestURI(),
182201
 		"VisibleRepoCount": len(visibleRepos),
183202
 		"Orgs":             h.profileOrganizations(r.Context(), user.ID),
184203
 		"ProfileReadme":    readme,
internal/web/handlers/profile/profile_test.gomodified
@@ -61,9 +61,10 @@ func setupProfileEnvWithDeps(t *testing.T, objectStore storage.ObjectStore, repo
6161
 	tmplFS := fstest.MapFS{
6262
 		"_layout.html":             {Data: []byte(`{{ define "layout" }}<html><head><title>{{ .Title }}</title></head><body>{{ template "page" . }}</body></html>{{ end }}`)},
6363
 		"hello.html":               {Data: []byte(`{{ define "page" }}home{{ end }}`)},
64
-		"profile/view.html":      {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }} BIO={{.User.Bio}} VISIBLE={{.VisibleRepoCount}} ORGS={{len .Orgs}} README={{.HasProfileReadme}} CONTRIB={{.Contributions.Total}} PERIOD={{.Contributions.Period}} WEEKS={{len .Contributions.Weeks}} YEARS={{len .Contributions.Years}} YEARLINKS={{range .Contributions.Years}}{{.Year}}:{{.Active}}:{{.Href}};{{end}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)},
64
+		"profile/view.html":        {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }}{{ if .IsFollowing }} FOLLOWING=1{{ end }} FOLLOWERS={{.FollowersCount}} FOLLOWINGCOUNT={{.FollowingCount}} BIO={{.User.Bio}} VISIBLE={{.VisibleRepoCount}} ORGS={{len .Orgs}} README={{.HasProfileReadme}} CONTRIB={{.Contributions.Total}} PERIOD={{.Contributions.Period}} WEEKS={{len .Contributions.Weeks}} YEARS={{len .Contributions.Years}} YEARLINKS={{range .Contributions.Years}}{{.Year}}:{{.Active}}:{{.Href}};{{end}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)},
65
+		"profile/follows_tab.html": {Data: []byte(`{{ define "page" }}FOLLOWTAB={{.ActiveTab}} USER={{.User.Username}} TOTAL={{len .Items}} ITEMS={{range .Items}}{{.Kind}}:{{.Username}};{{end}}{{ end }}`)},
6566
 		"profile/suspended.html":   {Data: []byte(`{{ define "page" }}SUSPENDED={{.Username}}{{ end }}`)},
66
-		"orgs/profile.html":      {Data: []byte(`{{ define "page" }}ORG={{.Org.Slug}} REPOS={{len .Repos}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}} MEMBERS={{.MemberCount}} PEOPLE={{len .People}} NAMES={{range .Repos}}{{.Name}};{{end}} LANGS={{range .TopLanguages}}{{.Name}}={{.Count}};{{end}} TOPICS={{range .TopTopics}}{{.Name}}={{.Count}};{{end}} VIEWAS={{.ViewAs}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)},
67
+		"orgs/profile.html":        {Data: []byte(`{{ define "page" }}ORG={{.Org.Slug}}{{ if .IsFollowing }} FOLLOWING=1{{ end }} FOLLOWERS={{.FollowerCount}} REPOS={{len .Repos}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}} MEMBERS={{.MemberCount}} PEOPLE={{len .People}} NAMES={{range .Repos}}{{.Name}};{{end}} LANGS={{range .TopLanguages}}{{.Name}}={{.Count}};{{end}} TOPICS={{range .TopTopics}}{{.Name}}={{.Count}};{{end}} VIEWAS={{.ViewAs}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)},
6768
 		"orgs/repositories.html":   {Data: []byte(`{{ define "page" }}ORGREPOS={{.Org.Slug}} ACTIVE={{.ActiveOrgNav}} TOTAL={{.RepoCount}} FILTERED={{.FilteredCount}} PAGE={{.Page}}/{{.PageCount}} TYPE={{.SelectedType}} LANG={{.SelectedLanguage}} SORT={{.SelectedSort}} PREV={{.PrevHref}} NEXT={{.NextHref}} NAMES={{range .Repos}}{{.Name}};{{end}}{{range .PaginationPages}} P{{.Number}}={{.Current}}{{end}}{{ end }}`)},
6869
 		"errors/404.html":          {Data: []byte(`{{ define "page" }}404{{ end }}`)},
6970
 		"errors/500.html":          {Data: []byte(`{{ define "page" }}500{{ end }}`)},
@@ -315,6 +316,28 @@ func (e *profileEnv) postPins(t *testing.T, path string, user usersdb.User, repo
315316
 	return resp
316317
 }
317318
 
319
+func (e *profileEnv) postFormAs(t *testing.T, path string, user usersdb.User, form url.Values) *http.Response {
320
+	t.Helper()
321
+	if form == nil {
322
+		form = url.Values{}
323
+	}
324
+	req, err := http.NewRequest(http.MethodPost, e.srv.URL+path, strings.NewReader(form.Encode()))
325
+	if err != nil {
326
+		t.Fatalf("request: %v", err)
327
+	}
328
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
329
+	if user.ID != 0 {
330
+		req.Header.Set("X-Test-User-ID", strconv.FormatInt(user.ID, 10))
331
+		req.Header.Set("X-Test-Username", user.Username)
332
+	}
333
+	resp, err := newNonRedirClient(t).Do(req)
334
+	if err != nil {
335
+		t.Fatalf("POST: %v", err)
336
+	}
337
+	t.Cleanup(func() { _ = resp.Body.Close() })
338
+	return resp
339
+}
340
+
318341
 // =============================== tests ==================================
319342
 
320343
 func TestProfile_RendersForExistingUser(t *testing.T) {
@@ -338,6 +361,93 @@ func TestProfile_RendersForExistingUser(t *testing.T) {
338361
 	}
339362
 }
340363
 
364
+func TestProfile_FollowUserRoutesUpdateCountsAndState(t *testing.T) {
365
+	env := setupProfileEnv(t)
366
+	alice := env.insertUser(t, "alice", "Alice", "")
367
+	bob := env.insertUser(t, "bob", "Bob", "")
368
+
369
+	resp := env.postFormAs(t, "/alice/follow", bob, url.Values{"return_to": []string{"/alice?tab=followers"}})
370
+	if resp.StatusCode != http.StatusSeeOther {
371
+		t.Fatalf("follow status %d, want 303", resp.StatusCode)
372
+	}
373
+	if loc := resp.Header.Get("Location"); loc != "/alice?tab=followers" {
374
+		t.Fatalf("follow redirect = %q", loc)
375
+	}
376
+	var count int
377
+	if err := env.pool.QueryRow(context.Background(),
378
+		`SELECT count(*) FROM follows WHERE follower_user_id = $1 AND followee_user_id = $2`,
379
+		bob.ID, alice.ID,
380
+	).Scan(&count); err != nil {
381
+		t.Fatalf("count follow: %v", err)
382
+	}
383
+	if count != 1 {
384
+		t.Fatalf("follow count = %d, want 1", count)
385
+	}
386
+	body := env.getAs(t, "/alice", bob)
387
+	for _, want := range []string{"FOLLOWING=1", "FOLLOWERS=1", "FOLLOWINGCOUNT=0"} {
388
+		if !strings.Contains(body, want) {
389
+			t.Fatalf("missing %q in body: %s", want, body)
390
+		}
391
+	}
392
+	followers := env.getAs(t, "/alice?tab=followers", alice)
393
+	if !strings.Contains(followers, "ITEMS=user:bob;") {
394
+		t.Fatalf("followers tab missing bob: %s", followers)
395
+	}
396
+
397
+	resp = env.postFormAs(t, "/alice/unfollow", bob, nil)
398
+	if resp.StatusCode != http.StatusSeeOther {
399
+		t.Fatalf("unfollow status %d, want 303", resp.StatusCode)
400
+	}
401
+	if err := env.pool.QueryRow(context.Background(),
402
+		`SELECT count(*) FROM follows WHERE follower_user_id = $1 AND followee_user_id = $2`,
403
+		bob.ID, alice.ID,
404
+	).Scan(&count); err != nil {
405
+		t.Fatalf("count unfollow: %v", err)
406
+	}
407
+	if count != 0 {
408
+		t.Fatalf("follow count after unfollow = %d, want 0", count)
409
+	}
410
+}
411
+
412
+func TestProfile_FollowSelfRejected(t *testing.T) {
413
+	env := setupProfileEnv(t)
414
+	alice := env.insertUser(t, "alice", "Alice", "")
415
+
416
+	resp := env.postFormAs(t, "/alice/follow", alice, nil)
417
+	if resp.StatusCode != http.StatusBadRequest {
418
+		t.Fatalf("follow self status %d, want 400", resp.StatusCode)
419
+	}
420
+}
421
+
422
+func TestProfile_FollowOrgRoutesUpdateCountsAndState(t *testing.T) {
423
+	env := setupProfileEnv(t)
424
+	owner := env.insertUser(t, "owner", "Owner", "")
425
+	bob := env.insertUser(t, "bob", "Bob", "")
426
+	env.insertOrg(t, "acme", "Acme", "", owner)
427
+
428
+	resp := env.postFormAs(t, "/acme/follow", bob, nil)
429
+	if resp.StatusCode != http.StatusSeeOther {
430
+		t.Fatalf("follow org status %d, want 303", resp.StatusCode)
431
+	}
432
+	var count int
433
+	if err := env.pool.QueryRow(context.Background(),
434
+		`SELECT count(*) FROM follows f JOIN orgs o ON o.id = f.followee_org_id
435
+		 WHERE f.follower_user_id = $1 AND o.slug = 'acme'`,
436
+		bob.ID,
437
+	).Scan(&count); err != nil {
438
+		t.Fatalf("count org follow: %v", err)
439
+	}
440
+	if count != 1 {
441
+		t.Fatalf("org follow count = %d, want 1", count)
442
+	}
443
+	body := env.getAs(t, "/acme", bob)
444
+	for _, want := range []string{"ORG=acme", "FOLLOWING=1", "FOLLOWERS=1"} {
445
+		if !strings.Contains(body, want) {
446
+			t.Fatalf("missing %q in org body: %s", want, body)
447
+		}
448
+	}
449
+}
450
+
341451
 func TestProfile_OverviewDataUsesVisibleReposAndOrganizations(t *testing.T) {
342452
 	t.Parallel()
343453
 	env := setupProfileEnv(t)
internal/web/profile_wiring.gomodified
@@ -9,6 +9,8 @@ import (
99
 
1010
 	"github.com/jackc/pgx/v5/pgxpool"
1111
 
12
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
13
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
1214
 	"github.com/tenseleyFlow/shithub/internal/infra/config"
1315
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
1416
 	profileh "github.com/tenseleyFlow/shithub/internal/web/handlers/profile"
@@ -61,5 +63,7 @@ func buildProfileHandlers(
6163
 		Pool:        pool,
6264
 		RepoFS:      repoFS,
6365
 		ObjectStore: objectStore,
66
+		Limiter:     throttle.NewLimiter(),
67
+		Audit:       audit.NewRecorder(),
6468
 	})
6569
 }
internal/web/static/css/shithub.cssmodified
@@ -1071,6 +1071,17 @@ code {
10711071
 .shithub-profile-follow-counts strong {
10721072
   color: var(--fg-default);
10731073
 }
1074
+.shithub-profile-follow-counts a {
1075
+  color: var(--fg-muted);
1076
+  text-decoration: none;
1077
+}
1078
+.shithub-profile-follow-counts a:hover {
1079
+  color: var(--accent-fg, #4493f8);
1080
+  text-decoration: none;
1081
+}
1082
+.shithub-follow-form {
1083
+  margin: 0;
1084
+}
10741085
 .shithub-profile-dot {
10751086
   color: var(--fg-muted);
10761087
 }
@@ -1474,6 +1485,11 @@ code {
14741485
     padding: 1.25rem 1rem 2rem;
14751486
     gap: 1.5rem;
14761487
   }
1488
+  .shithub-profile-tab-container {
1489
+    grid-template-columns: 1fr;
1490
+    padding: 1.25rem 1rem 2rem;
1491
+    gap: 1.25rem;
1492
+  }
14771493
   .shithub-user-profile-sidebar {
14781494
     display: grid;
14791495
     grid-template-columns: 96px minmax(0, 1fr);
@@ -2748,6 +2764,78 @@ code {
27482764
   color: var(--fg-muted);
27492765
 }
27502766
 
2767
+.shithub-profile-tab-container {
2768
+  max-width: 1280px;
2769
+  margin: 0 auto;
2770
+  padding: 2rem;
2771
+  display: grid;
2772
+  grid-template-columns: 260px minmax(0, 1fr);
2773
+  gap: 2rem;
2774
+}
2775
+.shithub-profile-tab-sidebar {
2776
+  color: var(--fg-muted);
2777
+}
2778
+.shithub-profile-tab-avatar {
2779
+  width: 96px;
2780
+  height: 96px;
2781
+  border-radius: 50%;
2782
+  border: 1px solid var(--border-default);
2783
+  background: var(--canvas-subtle);
2784
+}
2785
+.shithub-profile-tab-sidebar h1 {
2786
+  margin: 0.75rem 0 0.15rem;
2787
+  font-size: 1.25rem;
2788
+  color: var(--fg-default);
2789
+}
2790
+.shithub-profile-tab-sidebar p {
2791
+  margin: 0;
2792
+}
2793
+.shithub-follow-list-head {
2794
+  border-bottom: 1px solid var(--border-default);
2795
+  padding-bottom: 0.75rem;
2796
+}
2797
+.shithub-follow-list-head h2 {
2798
+  margin: 0;
2799
+  font-size: 1.25rem;
2800
+}
2801
+.shithub-follow-list {
2802
+  list-style: none;
2803
+  margin: 0;
2804
+  padding: 0;
2805
+}
2806
+.shithub-follow-list-row {
2807
+  display: flex;
2808
+  gap: 0.9rem;
2809
+  padding: 1rem 0;
2810
+  border-bottom: 1px solid var(--border-default);
2811
+}
2812
+.shithub-follow-avatar img {
2813
+  width: 48px;
2814
+  height: 48px;
2815
+  border-radius: 50%;
2816
+  display: block;
2817
+  border: 1px solid var(--border-default);
2818
+}
2819
+.shithub-follow-list-body {
2820
+  min-width: 0;
2821
+  display: grid;
2822
+  gap: 0.15rem;
2823
+}
2824
+.shithub-follow-list-name {
2825
+  font-weight: 600;
2826
+  color: var(--fg-default);
2827
+}
2828
+.shithub-follow-list-handle {
2829
+  color: var(--fg-muted);
2830
+}
2831
+.shithub-follow-empty {
2832
+  margin-top: 1rem;
2833
+}
2834
+.shithub-follow-empty h3 {
2835
+  margin: 0;
2836
+  font-size: 1rem;
2837
+}
2838
+
27512839
 /* Repositories tab list. */
27522840
 .shithub-repo-list { list-style: none; padding: 0; margin: 0; }
27532841
 .shithub-repo-list-row {
internal/web/templates/orgs/profile.htmlmodified
@@ -13,7 +13,17 @@
1313
         </ul>
1414
       </div>
1515
       <div class="shithub-org-hero-actions">
16
-        {{ if .IsOwner }}<a href="/organizations/{{ .Org.Slug }}/settings/profile" class="shithub-button">Settings</a>{{ else }}<button type="button" class="shithub-button" disabled>Follow</button>{{ end }}
16
+        {{ if .IsOwner }}
17
+          <a href="/organizations/{{ .Org.Slug }}/settings/profile" class="shithub-button">Settings</a>
18
+        {{ else if .Viewer.ID }}
19
+          <form method="post" action="{{ if .IsFollowing }}{{ .UnfollowAction }}{{ else }}{{ .FollowAction }}{{ end }}" class="shithub-follow-form">
20
+            <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
21
+            <input type="hidden" name="return_to" value="{{ .ReturnTo }}">
22
+            <button type="submit" class="shithub-button">{{ if .IsFollowing }}Unfollow{{ else }}Follow{{ end }}</button>
23
+          </form>
24
+        {{ else }}
25
+          <a href="/login?next={{ urlquery .ReturnTo }}" class="shithub-button">Follow</a>
26
+        {{ end }}
1727
       </div>
1828
     </div>
1929
   </header>
@@ -140,6 +150,7 @@
140150
         <p class="shithub-muted">This organization has no public members.</p>
141151
         {{ end }}
142152
         <p><strong>{{ .MemberCount }}</strong> member{{ if ne .MemberCount 1 }}s{{ end }}</p>
153
+        <p><strong>{{ .FollowerCount }}</strong> follower{{ if ne .FollowerCount 1 }}s{{ end }}</p>
143154
       </section>
144155
 
145156
       <section class="shithub-org-sidebox">
internal/web/templates/profile/follows_tab.htmladded
@@ -0,0 +1,51 @@
1
+{{ define "page" -}}
2
+<section class="shithub-profile-tab-page">
3
+  <div class="shithub-profile-tabs-shell">
4
+    {{ template "profile-tabs" . }}
5
+  </div>
6
+
7
+  <div class="shithub-profile-tab-container">
8
+    <aside class="shithub-profile-tab-sidebar" aria-label="{{ .User.Username }} profile summary">
9
+      <img class="shithub-profile-tab-avatar" src="{{ .AvatarURL }}" alt="@{{ .User.Username }}" width="96" height="96">
10
+      <h1>{{ .DisplayName }}</h1>
11
+      <p>@{{ .User.Username }}</p>
12
+      <p class="shithub-profile-follow-counts">
13
+        {{ octicon "people" }}
14
+        <a href="/{{ .User.Username }}?tab=followers"{{ if eq .ActiveTab "followers" }} aria-current="page"{{ end }}><strong>{{ .FollowersCount }}</strong> followers</a>
15
+        <span class="shithub-profile-dot" aria-hidden="true">·</span>
16
+        <a href="/{{ .User.Username }}?tab=following"{{ if eq .ActiveTab "following" }} aria-current="page"{{ end }}><strong>{{ .FollowingCount }}</strong> following</a>
17
+      </p>
18
+    </aside>
19
+
20
+    <main class="shithub-profile-tab-main">
21
+      <header class="shithub-follow-list-head">
22
+        <h2>{{ if eq .ActiveTab "followers" }}Followers{{ else }}Following{{ end }}</h2>
23
+      </header>
24
+      {{ if .Items }}
25
+      <ol class="shithub-follow-list">
26
+        {{ range .Items }}
27
+        <li class="shithub-follow-list-row">
28
+          <a href="{{ .URL }}" class="shithub-follow-avatar"><img src="{{ .AvatarURL }}" alt="" width="48" height="48"></a>
29
+          <div class="shithub-follow-list-body">
30
+            <a href="{{ .URL }}" class="shithub-follow-list-name">{{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}</a>
31
+            <span class="shithub-follow-list-handle">{{ if eq .Kind "org" }}@{{ .Username }} · organization{{ else }}@{{ .Username }}{{ end }}</span>
32
+            <span class="shithub-muted">Followed {{ .FollowedAt }}</span>
33
+          </div>
34
+        </li>
35
+        {{ end }}
36
+      </ol>
37
+      {{ else }}
38
+      <div class="shithub-empty shithub-follow-empty">
39
+        <h3>{{ if eq .ActiveTab "followers" }}No followers yet{{ else }}Not following anyone yet{{ end }}</h3>
40
+      </div>
41
+      {{ end }}
42
+      {{ if or .HasPrev .HasNext }}
43
+      <nav class="shithub-pagination" aria-label="Follows pagination">
44
+        {{ if .HasPrev }}<a href="/{{ .User.Username }}?tab={{ .ActiveTab }}&amp;page={{ sub .Page 1 }}">Previous</a>{{ else }}<span>Previous</span>{{ end }}
45
+        {{ if .HasNext }}<a href="/{{ .User.Username }}?tab={{ .ActiveTab }}&amp;page={{ add .Page 1 }}">Next</a>{{ else }}<span>Next</span>{{ end }}
46
+      </nav>
47
+      {{ end }}
48
+    </main>
49
+  </div>
50
+</section>
51
+{{- end }}
internal/web/templates/profile/view.htmlmodified
@@ -21,15 +21,21 @@
2121
 
2222
       {{ if .IsSelf }}
2323
         <a href="/settings/profile" class="shithub-button shithub-button-block">Edit profile</a>
24
+      {{ else if .Viewer.ID }}
25
+        <form method="post" action="{{ if .IsFollowing }}{{ .UnfollowAction }}{{ else }}{{ .FollowAction }}{{ end }}" class="shithub-follow-form">
26
+          <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
27
+          <input type="hidden" name="return_to" value="{{ .ReturnTo }}">
28
+          <button type="submit" class="shithub-button shithub-button-block">{{ if .IsFollowing }}Unfollow{{ else }}Follow{{ end }}</button>
29
+        </form>
2430
       {{ else }}
25
-        <button type="button" class="shithub-button shithub-button-block" disabled>Follow</button>
31
+        <a href="/login?next={{ urlquery .ReturnTo }}" class="shithub-button shithub-button-block">Follow</a>
2632
       {{ end }}
2733
 
2834
       <p class="shithub-profile-follow-counts">
2935
         {{ octicon "people" }}
30
-        <span><strong>0</strong> followers</span>
36
+        <a href="/{{ .User.Username }}?tab=followers"><strong>{{ .FollowersCount }}</strong> followers</a>
3137
         <span class="shithub-profile-dot" aria-hidden="true">·</span>
32
-        <span><strong>0</strong> following</span>
38
+        <a href="/{{ .User.Username }}?tab=following"><strong>{{ .FollowingCount }}</strong> following</a>
3339
       </p>
3440
 
3541
       <ul class="shithub-profile-vcard">