Go · 12256 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package profile
4
5 import (
6 "context"
7 "fmt"
8 "html/template"
9 "math"
10 "net/http"
11 "net/url"
12 "sort"
13 "strings"
14 "time"
15
16 "github.com/jackc/pgx/v5/pgtype"
17
18 "github.com/tenseleyFlow/shithub/internal/auth/policy"
19 "github.com/tenseleyFlow/shithub/internal/orgs"
20 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
21 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
22 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
23 "github.com/tenseleyFlow/shithub/internal/web/middleware"
24 )
25
26 const (
27 orgHomepageRepoLimit = 10
28 orgHomepagePinnedLimit = 6
29 orgHomepagePeopleLimit = 8
30 )
31
32 type orgProfileRepo struct {
33 ID int64
34 Name string
35 Description string
36 Visibility string
37 IsArchived bool
38 IsFork bool
39 Public bool
40 Private bool
41 Source bool
42 Archived bool
43 PrimaryLanguage string
44 PrimaryLanguageColor template.CSS
45 LicenseKey string
46 StarCount int64
47 ForkCount int64
48 UpdatedAt time.Time
49 Topics []string
50 DefaultBranch string
51 ActivitySparkline template.HTML
52 }
53
54 type orgProfilePerson struct {
55 Username string
56 DisplayName string
57 Role string
58 AvatarURL string
59 }
60
61 type orgProfileLanguage struct {
62 Name string
63 Color template.CSS
64 Count int
65 Percent int
66 }
67
68 type orgProfileTopic struct {
69 Name string
70 Count int
71 }
72
73 // serveOrgProfile renders /{org}. It mirrors GitHub's organization
74 // overview shape: org nav, pinned repo cards, recent repo rows, and a
75 // right rail with people/language/topic aggregates.
76 func (h *Handlers) serveOrgProfile(w http.ResponseWriter, r *http.Request, orgID int64) {
77 ctx := r.Context()
78 q := orgsdb.New()
79 org, err := q.GetOrgByID(ctx, h.d.Pool, orgID)
80 if err != nil {
81 h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
82 return
83 }
84 if org.DeletedAt.Valid {
85 // Soft-deleted orgs render the same "unavailable" shell as
86 // suspended/deleted users so the existence-leak posture is
87 // uniform.
88 h.renderUnavailable(w, r, string(org.Slug))
89 return
90 }
91
92 viewer := middleware.CurrentUserFromContext(r.Context())
93 isOwner := false
94 isMember := false
95 if !viewer.IsAnonymous() {
96 deps := orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger}
97 isOwner, _ = orgs.IsOwner(ctx, deps, org.ID, viewer.ID)
98 isMember, _ = orgs.IsMember(ctx, deps, org.ID, viewer.ID)
99 }
100
101 repos := h.orgProfileRepos(ctx, org.ID, viewer)
102 followState := h.orgFollowState(ctx, org.ID, viewer)
103 repoRows := h.withOrgRepoActivity(ctx, string(org.Slug), limitOrgRepos(repos, orgHomepageRepoLimit))
104 pinnedRepos, pinCandidates := h.orgPinData(ctx, org.ID, string(org.Slug), repos)
105 people := h.orgProfilePeople(ctx, q, org.ID)
106 memberCount := int64(len(people))
107 teamCount := int64(0)
108 if isMember || viewer.IsSiteAdmin {
109 _ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM teams WHERE org_id = $1`, org.ID).Scan(&teamCount)
110 }
111 viewAs := "Public"
112 switch {
113 case !viewer.IsAnonymous() && viewer.IsSiteAdmin:
114 viewAs = "Site admin"
115 case isOwner:
116 viewAs = "Owner"
117 case isMember:
118 viewAs = "Member"
119 }
120
121 avatarURL := "/avatars/" + url.PathEscape(org.Slug)
122 data := map[string]any{
123 "Title": org.DisplayName,
124 "OGTitle": org.DisplayName,
125 "OGDescription": org.Description,
126 "OGImage": avatarURL,
127 "Org": org,
128 "AvatarURL": avatarURL,
129 "ActiveOrgNav": "overview",
130 "WebsiteSafe": safeWebsite(org.Website),
131 "Repos": repoRows,
132 "HasMoreRepos": len(repos) > orgHomepageRepoLimit,
133 "OrgRepositoriesURL": orgRepositoriesBaseURL(string(org.Slug)),
134 "PinnedRepos": pinnedRepos,
135 "PinCandidates": pinCandidates,
136 "PinsRemaining": profilePinsRemaining(pinCandidates, profilePinLimit),
137 "RepoCount": int64(len(repos)),
138 "TeamCount": teamCount,
139 "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(),
145 "People": limitOrgPeople(people, orgHomepagePeopleLimit),
146 "TopLanguages": orgTopLanguages(repos),
147 "TopTopics": orgTopTopics(repos),
148 "ViewAs": viewAs,
149 "IsOwner": isOwner,
150 "IsMember": isMember,
151 "CanCustomizePins": isOwner,
152 "PinsAction": "/" + url.PathEscape(org.Slug) + "/pins",
153 "CanCreateRepo": isOwner || (isMember && org.AllowMemberRepoCreate),
154 }
155 if !viewer.IsAnonymous() {
156 w.Header().Set("Cache-Control", "no-cache, private")
157 } else {
158 w.Header().Set("Cache-Control", "max-age=120")
159 }
160 if err := h.d.Render.RenderPage(w, r, "orgs/profile", data); err != nil {
161 h.d.Logger.ErrorContext(ctx, "orgs profile: render", "error", err)
162 }
163 }
164
165 func (h *Handlers) orgProfileRepos(ctx context.Context, orgID int64, viewer middleware.CurrentUser) []orgProfileRepo {
166 rows, err := reposdb.New().ListReposForOwnerOrg(ctx, h.d.Pool, pgtype.Int8{Int64: orgID, Valid: true})
167 if err != nil {
168 h.d.Logger.ErrorContext(ctx, "orgs profile: list repos", "error", err)
169 return nil
170 }
171 actor := policy.AnonymousActor()
172 if !viewer.IsAnonymous() {
173 actor = viewer.PolicyActor()
174 }
175 deps := policy.Deps{Pool: h.d.Pool}
176
177 out := make([]orgProfileRepo, 0, len(rows))
178 for _, row := range rows {
179 repoRef := policy.NewRepoRefFromRepo(row)
180 if !policy.IsVisibleTo(ctx, deps, actor, repoRef) {
181 continue
182 }
183 item := orgProfileRepo{
184 ID: row.ID,
185 Name: string(row.Name),
186 Description: row.Description,
187 Visibility: repoRef.Visibility,
188 IsArchived: repoRef.IsArchived,
189 IsFork: row.ForkOfRepoID.Valid,
190 Public: repoRef.IsPublic(),
191 Private: repoRef.IsPrivate(),
192 Source: !row.ForkOfRepoID.Valid && !repoRef.IsArchived,
193 Archived: repoRef.IsArchived,
194 LicenseKey: pgTextStringOrEmpty(row.LicenseKey),
195 StarCount: row.StarCount,
196 ForkCount: row.ForkCount,
197 UpdatedAt: row.UpdatedAt.Time,
198 Topics: h.orgRepoTopics(ctx, row.ID),
199 PrimaryLanguage: pgTextStringOrEmpty(row.PrimaryLanguage),
200 DefaultBranch: row.DefaultBranch,
201 }
202 item.PrimaryLanguageColor = template.CSS(orgLanguageColor(item.PrimaryLanguage)) //nolint:gosec // CSS value comes from server-side constants.
203 out = append(out, item)
204 }
205 return out
206 }
207
208 func (h *Handlers) withOrgRepoActivity(ctx context.Context, orgSlug string, repos []orgProfileRepo) []orgProfileRepo {
209 out := append([]orgProfileRepo(nil), repos...)
210 for i := range out {
211 out[i].ActivitySparkline = h.orgRepoActivitySparkline(ctx, orgSlug, out[i])
212 }
213 return out
214 }
215
216 func (h *Handlers) orgRepoActivitySparkline(ctx context.Context, orgSlug string, repo orgProfileRepo) template.HTML {
217 if h.d.RepoFS == nil {
218 return orgActivitySparklineSVG(nil)
219 }
220 gitDir, err := h.d.RepoFS.RepoPath(orgSlug, repo.Name)
221 if err != nil {
222 return orgActivitySparklineSVG(nil)
223 }
224 buckets, err := repogit.WeeklyCommitActivity(ctx, gitDir, repo.DefaultBranch, 52, time.Now())
225 if err != nil {
226 return orgActivitySparklineSVG(nil)
227 }
228 return orgActivitySparklineSVG(buckets)
229 }
230
231 func orgActivitySparklineSVG(buckets []int) template.HTML {
232 const (
233 width = 155.0
234 baseline = 27.0
235 top = 5.0
236 )
237 if len(buckets) < 2 {
238 buckets = make([]int, 52)
239 }
240 maxCount := 0
241 for _, count := range buckets {
242 if count > maxCount {
243 maxCount = count
244 }
245 }
246
247 step := width / float64(len(buckets)-1)
248 points := make([]string, 0, len(buckets))
249 for i, count := range buckets {
250 y := baseline
251 if maxCount > 0 && count > 0 {
252 ratio := math.Sqrt(float64(count)) / math.Sqrt(float64(maxCount))
253 y = baseline - ratio*(baseline-top)
254 }
255 points = append(points, fmt.Sprintf("%.1f %.1f", float64(i)*step, y))
256 }
257
258 var b strings.Builder
259 b.WriteString(`<svg class="shithub-org-repo-spark" viewBox="0 0 155 32" width="155" height="32" aria-hidden="true" focusable="false">`)
260 b.WriteString(`<path class="shithub-org-repo-spark-base" d="M 0 27 H 155"></path>`)
261 b.WriteString(`<polyline class="shithub-org-repo-spark-line" points="`)
262 b.WriteString(strings.Join(points, " "))
263 b.WriteString(`"></polyline>`)
264 b.WriteString(`</svg>`)
265 return template.HTML(b.String()) //nolint:gosec // SVG contains only server-generated numeric points.
266 }
267
268 func (h *Handlers) orgRepoTopics(ctx context.Context, repoID int64) []string {
269 topics, err := reposdb.New().ListRepoTopics(ctx, h.d.Pool, repoID)
270 if err != nil {
271 h.d.Logger.WarnContext(ctx, "orgs profile: list repo topics", "repo_id", repoID, "error", err)
272 return nil
273 }
274 return topics
275 }
276
277 func (h *Handlers) orgProfilePeople(ctx context.Context, q *orgsdb.Queries, orgID int64) []orgProfilePerson {
278 rows, err := q.ListOrgMembers(ctx, h.d.Pool, orgID)
279 if err != nil {
280 h.d.Logger.WarnContext(ctx, "orgs profile: list people", "org_id", orgID, "error", err)
281 return nil
282 }
283 out := make([]orgProfilePerson, 0, len(rows))
284 for _, row := range rows {
285 out = append(out, orgProfilePerson{
286 Username: row.Username,
287 DisplayName: row.DisplayName,
288 Role: string(row.Role),
289 AvatarURL: "/avatars/" + url.PathEscape(row.Username),
290 })
291 }
292 return out
293 }
294
295 func limitOrgRepos(repos []orgProfileRepo, limit int) []orgProfileRepo {
296 if len(repos) <= limit {
297 return repos
298 }
299 return repos[:limit]
300 }
301
302 func pinnedOrgRepos(repos []orgProfileRepo) []orgProfileRepo {
303 pinned := append([]orgProfileRepo(nil), repos...)
304 sort.SliceStable(pinned, func(i, j int) bool {
305 if pinned[i].StarCount != pinned[j].StarCount {
306 return pinned[i].StarCount > pinned[j].StarCount
307 }
308 return pinned[i].UpdatedAt.After(pinned[j].UpdatedAt)
309 })
310 return limitOrgRepos(pinned, orgHomepagePinnedLimit)
311 }
312
313 func limitOrgPeople(people []orgProfilePerson, limit int) []orgProfilePerson {
314 if len(people) <= limit {
315 return people
316 }
317 return people[:limit]
318 }
319
320 func orgTopLanguages(repos []orgProfileRepo) []orgProfileLanguage {
321 counts := map[string]int{}
322 for _, repo := range repos {
323 if repo.PrimaryLanguage == "" {
324 continue
325 }
326 counts[repo.PrimaryLanguage]++
327 }
328 total := 0
329 for _, n := range counts {
330 total += n
331 }
332 out := make([]orgProfileLanguage, 0, len(counts))
333 for name, n := range counts {
334 percent := 0
335 if total > 0 {
336 percent = int(float64(n) / float64(total) * 100)
337 if percent == 0 {
338 percent = 1
339 }
340 }
341 out = append(out, orgProfileLanguage{
342 Name: name,
343 Color: template.CSS(orgLanguageColor(name)), //nolint:gosec // CSS value comes from server-side constants.
344 Count: n,
345 Percent: percent,
346 })
347 }
348 sort.SliceStable(out, func(i, j int) bool {
349 if out[i].Count != out[j].Count {
350 return out[i].Count > out[j].Count
351 }
352 return out[i].Name < out[j].Name
353 })
354 if len(out) > 5 {
355 return out[:5]
356 }
357 return out
358 }
359
360 func orgTopTopics(repos []orgProfileRepo) []orgProfileTopic {
361 counts := map[string]int{}
362 for _, repo := range repos {
363 for _, topic := range repo.Topics {
364 counts[topic]++
365 }
366 }
367 out := make([]orgProfileTopic, 0, len(counts))
368 for name, n := range counts {
369 out = append(out, orgProfileTopic{Name: name, Count: n})
370 }
371 sort.SliceStable(out, func(i, j int) bool {
372 if out[i].Count != out[j].Count {
373 return out[i].Count > out[j].Count
374 }
375 return out[i].Name < out[j].Name
376 })
377 if len(out) > 8 {
378 return out[:8]
379 }
380 return out
381 }
382
383 func orgLanguageColor(name string) string {
384 switch name {
385 case "Go":
386 return "#00add8"
387 case "HTML":
388 return "#e34c26"
389 case "CSS":
390 return "#663399"
391 case "Shell":
392 return "#89e051"
393 case "PLpgSQL":
394 return "#336790"
395 case "Jinja":
396 return "#a52a22"
397 case "JavaScript":
398 return "#f1e05a"
399 case "TypeScript":
400 return "#3178c6"
401 case "Python":
402 return "#3572a5"
403 case "Java":
404 return "#b07219"
405 case "Rust":
406 return "#dea584"
407 case "Ruby":
408 return "#701516"
409 case "PHP":
410 return "#4f5d95"
411 case "C":
412 return "#555555"
413 case "C++":
414 return "#f34b7d"
415 case "Makefile":
416 return "#427819"
417 case "Dockerfile":
418 return "#384d54"
419 default:
420 return "#ededed"
421 }
422 }
423