tenseleyflow/shithub / 7746dad

Browse files

S26: profile Stars tab with per-row visibility filter

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
7746dada879651bb0ad717c6f62a0814b7a2d214
Parents
0059e18
Tree
9f0ca60

3 changed files

StatusFile+-
M internal/web/handlers/profile/profile.go 8 0
A internal/web/handlers/profile/stars_tab.go 119 0
A internal/web/templates/profile/stars_tab.html 41 0
internal/web/handlers/profile/profile.gomodified
@@ -114,6 +114,14 @@ func (h *Handlers) serveProfile(w http.ResponseWriter, r *http.Request) {
114114
 	viewer := middleware.CurrentUserFromContext(r.Context())
115115
 	isSelf := viewer.ID != 0 && viewer.ID == user.ID
116116
 
117
+	// S26 Stars tab: `?tab=stars` switches to the user's starred-repos
118
+	// view. Per-row visibility filtering happens in serveStarsTab so
119
+	// private-repo stars only show to viewers who can see them.
120
+	if r.URL.Query().Get("tab") == "stars" {
121
+		h.serveStarsTab(w, r, user, viewer, isSelf)
122
+		return
123
+	}
124
+
117125
 	// Anonymous: ETag + small max-age. Self-view: no-cache.
118126
 	if isSelf {
119127
 		w.Header().Set("Cache-Control", "no-cache, private")
internal/web/handlers/profile/stars_tab.goadded
@@ -0,0 +1,119 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package profile
4
+
5
+import (
6
+	"fmt"
7
+	"net/http"
8
+	"net/url"
9
+	"strconv"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
14
+	socialdb "github.com/tenseleyFlow/shithub/internal/social/sqlc"
15
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
17
+)
18
+
19
+const starsTabPageSize = 30
20
+
21
+// serveStarsTab renders the `/{user}?tab=stars` view.
22
+//
23
+// The query returns every star (including private-repo stars); we
24
+// post-filter per-row by `policy.IsVisibleTo` so the viewer only
25
+// sees stars on repos they can read. Anonymous viewers see only
26
+// public stars; the user themselves sees everything.
27
+func (h *Handlers) serveStarsTab(w http.ResponseWriter, r *http.Request, user usersdb.User, viewer middleware.CurrentUser, isSelf bool) {
28
+	page, _ := strconv.Atoi(r.URL.Query().Get("page"))
29
+	if page < 1 {
30
+		page = 1
31
+	}
32
+
33
+	// We over-fetch a little so the per-row visibility filter can
34
+	// drop private-repo rows without leaving a short page. With most
35
+	// stars being public this is a no-op; for users with many private
36
+	// stars the filter just trims more.
37
+	const fetch = starsTabPageSize * 2
38
+	rows, err := socialdb.New().ListStarsForUser(r.Context(), h.d.Pool, socialdb.ListStarsForUserParams{
39
+		UserID: user.ID,
40
+		Limit:  fetch,
41
+		Offset: int32((page - 1) * starsTabPageSize),
42
+	})
43
+	if err != nil {
44
+		h.d.Logger.ErrorContext(r.Context(), "stars tab: list", "error", err)
45
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
46
+		return
47
+	}
48
+
49
+	actor := policy.AnonymousActor()
50
+	if !viewer.IsAnonymous() {
51
+		actor = policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
52
+	}
53
+	deps := policy.Deps{Pool: h.d.Pool}
54
+
55
+	visible := make([]map[string]any, 0, len(rows))
56
+	for _, row := range rows {
57
+		if len(visible) >= starsTabPageSize {
58
+			break
59
+		}
60
+		// Re-check visibility per row. Star is on a repo that may have
61
+		// flipped visibility between star-time and view-time.
62
+		ref := policy.RepoRef{
63
+			ID:          row.RepoID,
64
+			OwnerUserID: row.OwnerUserID.Int64,
65
+			OwnerOrgID:  row.OwnerOrgID.Int64,
66
+			Visibility:  string(row.Visibility),
67
+		}
68
+		if !policy.IsVisibleTo(r.Context(), deps, actor, ref) {
69
+			continue
70
+		}
71
+		// Resolve the owner's username for the link. The S31 org case
72
+		// adds an org-username path; for now user-owned only.
73
+		ownerName := ""
74
+		if row.OwnerUserID.Valid {
75
+			if u, err := h.q.GetUserByID(r.Context(), h.d.Pool, row.OwnerUserID.Int64); err == nil {
76
+				ownerName = u.Username
77
+			}
78
+		}
79
+		visible = append(visible, map[string]any{
80
+			"OwnerName":       ownerName,
81
+			"RepoName":        row.RepoName,
82
+			"Description":     row.Description,
83
+			"Visibility":      string(row.Visibility),
84
+			"StarCount":       row.StarCount,
85
+			"PrimaryLanguage": pgTextStringOrEmpty(row.PrimaryLanguage),
86
+			"StarredAt":       row.StarredAt.Time,
87
+		})
88
+	}
89
+
90
+	if isSelf {
91
+		w.Header().Set("Cache-Control", "no-cache, private")
92
+	} else {
93
+		w.Header().Set("Cache-Control", "max-age=120")
94
+	}
95
+
96
+	avatarURL := fmt.Sprintf("/avatars/%s", url.PathEscape(user.Username))
97
+	data := map[string]any{
98
+		"Title":     "Stars · " + user.DisplayName,
99
+		"User":      user,
100
+		"IsSelf":    isSelf,
101
+		"AvatarURL": avatarURL,
102
+		"Stars":     visible,
103
+		"Page":      page,
104
+		"HasNext":   len(visible) >= starsTabPageSize,
105
+		"HasPrev":   page > 1,
106
+	}
107
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
108
+	if err := h.d.Render.RenderPage(w, r, "profile/stars_tab", data); err != nil {
109
+		h.d.Logger.ErrorContext(r.Context(), "stars tab: render", "error", err)
110
+	}
111
+}
112
+
113
+// pgTextStringOrEmpty unwraps a nullable text column to "" when null.
114
+func pgTextStringOrEmpty(t pgtype.Text) string {
115
+	if !t.Valid {
116
+		return ""
117
+	}
118
+	return t.String
119
+}
internal/web/templates/profile/stars_tab.htmladded
@@ -0,0 +1,41 @@
1
+{{ define "page" -}}
2
+<section class="shithub-profile">
3
+  <header class="shithub-profile-header">
4
+    <img class="shithub-profile-avatar" src="{{ .AvatarURL }}" alt="" width="120" height="120">
5
+    <div class="shithub-profile-id">
6
+      <h1 class="shithub-profile-name">
7
+        {{ if .User.DisplayName }}{{ .User.DisplayName }}{{ else }}{{ .User.Username }}{{ end }}
8
+      </h1>
9
+      <p class="shithub-profile-handle">@{{ .User.Username }}</p>
10
+      <nav class="shithub-profile-tabs">
11
+        <a href="/{{ .User.Username }}" class="shithub-button">Overview</a>
12
+        <a href="?tab=stars" class="shithub-button shithub-button-primary">Stars</a>
13
+      </nav>
14
+    </div>
15
+  </header>
16
+
17
+  {{ if .Stars }}
18
+  <ul class="shithub-social-list">
19
+    {{ range .Stars }}
20
+    <li>
21
+      <a href="/{{ .OwnerName }}/{{ .RepoName }}"><strong>{{ .OwnerName }}/{{ .RepoName }}</strong></a>
22
+      {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ end }}
23
+      {{ if .PrimaryLanguage }}<small>{{ .PrimaryLanguage }}</small>{{ end }}
24
+      <small>★ {{ .StarCount }}</small>
25
+      <small><time datetime="{{ .StarredAt.Format "2006-01-02T15:04:05Z" }}">starred {{ relativeTime .StarredAt }}</time></small>
26
+      {{ if .Description }}<p class="shithub-meta">{{ .Description }}</p>{{ end }}
27
+    </li>
28
+    {{ end }}
29
+  </ul>
30
+  {{ else }}
31
+  <p class="shithub-empty">No starred repositories visible.</p>
32
+  {{ end }}
33
+
34
+  {{ if or .HasPrev .HasNext }}
35
+  <nav class="shithub-pagination">
36
+    {{ if .HasPrev }}<a href="?tab=stars&page={{ sub .Page 1 }}" class="shithub-button">Previous</a>{{ end }}
37
+    {{ if .HasNext }}<a href="?tab=stars&page={{ add .Page 1 }}" class="shithub-button">Next</a>{{ end }}
38
+  </nav>
39
+  {{ end }}
40
+</section>
41
+{{- end }}