| 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 |