| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package profile |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "fmt" |
| 8 | "html/template" |
| 9 | "net/http" |
| 10 | "net/url" |
| 11 | "sort" |
| 12 | "strconv" |
| 13 | "strings" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/jackc/pgx/v5/pgtype" |
| 17 | |
| 18 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 19 | repogit "github.com/tenseleyFlow/shithub/internal/repos/git" |
| 20 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| 21 | usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 22 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 23 | ) |
| 24 | |
| 25 | const userRepositoriesPerPage = 30 |
| 26 | |
| 27 | type userRepositoryFilters struct { |
| 28 | Query string |
| 29 | Type string |
| 30 | Language string |
| 31 | Sort string |
| 32 | } |
| 33 | |
| 34 | type userRepositoryItem struct { |
| 35 | ID int64 |
| 36 | Name string |
| 37 | Description string |
| 38 | Visibility string |
| 39 | IsArchived bool |
| 40 | IsFork bool |
| 41 | Public bool |
| 42 | Private bool |
| 43 | Source bool |
| 44 | Archived bool |
| 45 | PrimaryLanguage string |
| 46 | PrimaryLanguageColor template.CSS |
| 47 | LicenseKey string |
| 48 | StarCount int64 |
| 49 | ForkCount int64 |
| 50 | UpdatedAt time.Time |
| 51 | DefaultBranch string |
| 52 | ActivitySparkline template.HTML |
| 53 | } |
| 54 | |
| 55 | // serveRepositoriesTab renders /{user}?tab=repositories with the |
| 56 | // viewer-visible subset of the user's owned repos. Visibility scoping |
| 57 | // reuses policy.IsVisibleTo so anonymous viewers and non-collab |
| 58 | // logged-in viewers see only public repos; the user themselves sees |
| 59 | // everything (including private + archived). |
| 60 | func (h *Handlers) serveRepositoriesTab(w http.ResponseWriter, r *http.Request, user usersdb.User, viewer middleware.CurrentUser, isSelf bool) { |
| 61 | ctx := r.Context() |
| 62 | filters := userRepositoryFilters{ |
| 63 | Query: strings.TrimSpace(r.URL.Query().Get("q")), |
| 64 | Type: normalizeUserRepositoryType(r.URL.Query().Get("type")), |
| 65 | Language: strings.TrimSpace(r.URL.Query().Get("language")), |
| 66 | Sort: normalizeUserRepositorySort(r.URL.Query().Get("sort")), |
| 67 | } |
| 68 | page := parseUserRepositoryPage(r.URL.Query().Get("page")) |
| 69 | |
| 70 | all, err := reposdb.New().ListReposForOwnerUser(r.Context(), h.d.Pool, |
| 71 | pgtype.Int8{Int64: user.ID, Valid: true}) |
| 72 | if err != nil { |
| 73 | h.d.Logger.ErrorContext(r.Context(), "repos tab: list", "error", err) |
| 74 | h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 75 | return |
| 76 | } |
| 77 | |
| 78 | actor := policy.AnonymousActor() |
| 79 | if !viewer.IsAnonymous() { |
| 80 | actor = viewer.PolicyActor() |
| 81 | } |
| 82 | deps := policy.Deps{Pool: h.d.Pool} |
| 83 | |
| 84 | rows := make([]userRepositoryItem, 0, len(all)) |
| 85 | for _, repo := range all { |
| 86 | ref := policy.NewRepoRefFromRepo(repo) |
| 87 | if !policy.IsVisibleTo(r.Context(), deps, actor, ref) { |
| 88 | continue |
| 89 | } |
| 90 | language := pgTextStringOrEmpty(repo.PrimaryLanguage) |
| 91 | item := userRepositoryItem{ |
| 92 | ID: repo.ID, |
| 93 | Name: string(repo.Name), |
| 94 | Description: repo.Description, |
| 95 | Visibility: string(repo.Visibility), |
| 96 | IsArchived: repo.IsArchived, |
| 97 | IsFork: repo.ForkOfRepoID.Valid, |
| 98 | Public: ref.IsPublic(), |
| 99 | Private: ref.IsPrivate(), |
| 100 | Source: !repo.ForkOfRepoID.Valid && !repo.IsArchived, |
| 101 | Archived: repo.IsArchived, |
| 102 | PrimaryLanguage: language, |
| 103 | LicenseKey: pgTextStringOrEmpty(repo.LicenseKey), |
| 104 | StarCount: repo.StarCount, |
| 105 | ForkCount: repo.ForkCount, |
| 106 | UpdatedAt: repo.UpdatedAt.Time, |
| 107 | DefaultBranch: repo.DefaultBranch, |
| 108 | } |
| 109 | item.PrimaryLanguageColor = template.CSS(orgLanguageColor(language)) //nolint:gosec // CSS value comes from server-side constants. |
| 110 | rows = append(rows, item) |
| 111 | } |
| 112 | |
| 113 | filtered := filterUserRepositories(rows, filters) |
| 114 | sortUserRepositories(filtered, filters.Sort) |
| 115 | pageRepos, currentPage, pageCount := paginateUserRepositories(filtered, page) |
| 116 | pageRepos = h.withUserRepoActivity(ctx, user.Username, pageRepos) |
| 117 | |
| 118 | if isSelf { |
| 119 | w.Header().Set("Cache-Control", "no-cache, private") |
| 120 | } else { |
| 121 | w.Header().Set("Cache-Control", "max-age=120") |
| 122 | } |
| 123 | avatarURL := fmt.Sprintf("/avatars/%s", url.PathEscape(user.Username)) |
| 124 | tabs := h.tabCounts(r.Context(), user.ID, viewer) |
| 125 | displayName := user.DisplayName |
| 126 | if displayName == "" { |
| 127 | displayName = user.Username |
| 128 | } |
| 129 | followState := h.userFollowState(r.Context(), user.ID, viewer) |
| 130 | typeFilters := userRepositoryTypeFilters(user.Username, rows, filters) |
| 131 | languageFilters := userRepositoryLanguageFilters(user.Username, rows, filters) |
| 132 | sortOptions := userRepositorySortOptions(user.Username, filters) |
| 133 | data := map[string]any{ |
| 134 | "Title": "Repositories · " + displayName, |
| 135 | "User": user, |
| 136 | "DisplayName": displayName, |
| 137 | "IsSelf": isSelf, |
| 138 | "AvatarURL": avatarURL, |
| 139 | "JoinedFormatted": user.CreatedAt.Time.Format("January 2, 2006"), |
| 140 | "WebsiteSafe": safeWebsite(user.Website), |
| 141 | "FollowersCount": followState.FollowersCount, |
| 142 | "FollowingCount": followState.FollowingCount, |
| 143 | "Orgs": h.profileOrganizations(r.Context(), user.ID), |
| 144 | "Repos": pageRepos, |
| 145 | "RepoTotal": len(rows), |
| 146 | "FilteredCount": len(filtered), |
| 147 | "HasActiveFilters": filters.Query != "" || filters.Type != "all" || filters.Language != "" || filters.Sort != "updated", |
| 148 | "RepositoryFilters": filters, |
| 149 | "SelectedType": filters.Type, |
| 150 | "SelectedTypeLabel": selectedOrgRepositoryOptionLabel(typeFilters, "All"), |
| 151 | "SelectedLanguage": filters.Language, |
| 152 | "SelectedLanguageLabel": selectedOrgRepositoryOptionLabel(languageFilters, "All languages"), |
| 153 | "SelectedSort": filters.Sort, |
| 154 | "SelectedSortLabel": selectedOrgRepositoryOptionLabel(sortOptions, "Last updated"), |
| 155 | "TypeFilters": typeFilters, |
| 156 | "LanguageFilters": languageFilters, |
| 157 | "SortOptions": sortOptions, |
| 158 | "Page": currentPage, |
| 159 | "PageCount": pageCount, |
| 160 | "PaginationPages": userRepositoryPaginationPages(user.Username, filters, currentPage, pageCount), |
| 161 | "HasPrev": currentPage > 1 && pageCount > 0, |
| 162 | "HasNext": currentPage < pageCount, |
| 163 | "PrevHref": userRepositoryURL(user.Username, filters.Query, filters.Type, filters.Language, filters.Sort, currentPage-1), |
| 164 | "NextHref": userRepositoryURL(user.Username, filters.Query, filters.Type, filters.Language, filters.Sort, currentPage+1), |
| 165 | "CanCreateRepo": isSelf, |
| 166 | "NewRepoHref": "/new?owner=" + url.QueryEscape(user.Username), |
| 167 | "Tabs": tabs, |
| 168 | "ActiveTab": "repositories", |
| 169 | } |
| 170 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 171 | if err := h.d.Render.RenderPage(w, r, "profile/repositories_tab", data); err != nil { |
| 172 | h.d.Logger.ErrorContext(r.Context(), "repos tab: render", "error", err) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func filterUserRepositories(repos []userRepositoryItem, filters userRepositoryFilters) []userRepositoryItem { |
| 177 | query := strings.ToLower(strings.TrimSpace(filters.Query)) |
| 178 | languageFilter := strings.TrimSpace(filters.Language) |
| 179 | out := make([]userRepositoryItem, 0, len(repos)) |
| 180 | for _, repo := range repos { |
| 181 | if !userRepositoryMatchesType(repo, filters.Type) { |
| 182 | continue |
| 183 | } |
| 184 | if languageFilter != "" && !strings.EqualFold(repo.PrimaryLanguage, languageFilter) { |
| 185 | continue |
| 186 | } |
| 187 | if query != "" && !userRepositoryMatchesQuery(repo, query) { |
| 188 | continue |
| 189 | } |
| 190 | out = append(out, repo) |
| 191 | } |
| 192 | return out |
| 193 | } |
| 194 | |
| 195 | func userRepositoryMatchesType(repo userRepositoryItem, typeFilter string) bool { |
| 196 | switch typeFilter { |
| 197 | case "public": |
| 198 | return repo.Public |
| 199 | case "private": |
| 200 | return repo.Private |
| 201 | case "source": |
| 202 | return repo.Source |
| 203 | case "fork": |
| 204 | return repo.IsFork |
| 205 | case "archived": |
| 206 | return repo.Archived |
| 207 | default: |
| 208 | return true |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | func userRepositoryMatchesQuery(repo userRepositoryItem, query string) bool { |
| 213 | return strings.Contains(strings.ToLower(repo.Name), query) || |
| 214 | strings.Contains(strings.ToLower(repo.Description), query) || |
| 215 | strings.Contains(strings.ToLower(repo.PrimaryLanguage), query) || |
| 216 | strings.Contains(strings.ToLower(repo.LicenseKey), query) |
| 217 | } |
| 218 | |
| 219 | func sortUserRepositories(repos []userRepositoryItem, sortKey string) { |
| 220 | sort.SliceStable(repos, func(i, j int) bool { |
| 221 | switch sortKey { |
| 222 | case "name": |
| 223 | return strings.ToLower(repos[i].Name) < strings.ToLower(repos[j].Name) |
| 224 | case "stars": |
| 225 | if repos[i].StarCount != repos[j].StarCount { |
| 226 | return repos[i].StarCount > repos[j].StarCount |
| 227 | } |
| 228 | } |
| 229 | if !repos[i].UpdatedAt.Equal(repos[j].UpdatedAt) { |
| 230 | return repos[i].UpdatedAt.After(repos[j].UpdatedAt) |
| 231 | } |
| 232 | return strings.ToLower(repos[i].Name) < strings.ToLower(repos[j].Name) |
| 233 | }) |
| 234 | } |
| 235 | |
| 236 | func normalizeUserRepositoryType(value string) string { |
| 237 | switch strings.ToLower(strings.TrimSpace(value)) { |
| 238 | case "public", "private", "fork", "archived": |
| 239 | return strings.ToLower(strings.TrimSpace(value)) |
| 240 | case "source", "sources": |
| 241 | return "source" |
| 242 | case "forks": |
| 243 | return "fork" |
| 244 | default: |
| 245 | return "all" |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | func normalizeUserRepositorySort(value string) string { |
| 250 | switch strings.ToLower(strings.TrimSpace(value)) { |
| 251 | case "name", "stars": |
| 252 | return strings.ToLower(strings.TrimSpace(value)) |
| 253 | default: |
| 254 | return "updated" |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | func parseUserRepositoryPage(value string) int { |
| 259 | page, err := strconv.Atoi(strings.TrimSpace(value)) |
| 260 | if err != nil || page < 1 { |
| 261 | return 1 |
| 262 | } |
| 263 | return page |
| 264 | } |
| 265 | |
| 266 | func paginateUserRepositories(repos []userRepositoryItem, page int) ([]userRepositoryItem, int, int) { |
| 267 | if len(repos) == 0 { |
| 268 | return nil, 1, 0 |
| 269 | } |
| 270 | pageCount := (len(repos) + userRepositoriesPerPage - 1) / userRepositoriesPerPage |
| 271 | if page > pageCount { |
| 272 | page = pageCount |
| 273 | } |
| 274 | start := (page - 1) * userRepositoriesPerPage |
| 275 | end := start + userRepositoriesPerPage |
| 276 | if end > len(repos) { |
| 277 | end = len(repos) |
| 278 | } |
| 279 | return repos[start:end], page, pageCount |
| 280 | } |
| 281 | |
| 282 | func (h *Handlers) withUserRepoActivity(ctx context.Context, ownerSlug string, repos []userRepositoryItem) []userRepositoryItem { |
| 283 | out := append([]userRepositoryItem(nil), repos...) |
| 284 | for i := range out { |
| 285 | out[i].ActivitySparkline = h.userRepoActivitySparkline(ctx, ownerSlug, out[i]) |
| 286 | } |
| 287 | return out |
| 288 | } |
| 289 | |
| 290 | func (h *Handlers) userRepoActivitySparkline(ctx context.Context, ownerSlug string, repo userRepositoryItem) template.HTML { |
| 291 | if h.d.RepoFS == nil { |
| 292 | return orgActivitySparklineSVG(nil) |
| 293 | } |
| 294 | gitDir, err := h.d.RepoFS.RepoPath(ownerSlug, repo.Name) |
| 295 | if err != nil { |
| 296 | return orgActivitySparklineSVG(nil) |
| 297 | } |
| 298 | buckets, err := repogit.WeeklyCommitActivity(ctx, gitDir, repo.DefaultBranch, 52, time.Now()) |
| 299 | if err != nil { |
| 300 | return orgActivitySparklineSVG(nil) |
| 301 | } |
| 302 | return orgActivitySparklineSVG(buckets) |
| 303 | } |
| 304 | |
| 305 | func userRepositoryURL(username, query, typeFilter, language, sortKey string, page int) string { |
| 306 | values := url.Values{} |
| 307 | values.Set("tab", "repositories") |
| 308 | if query != "" { |
| 309 | values.Set("q", query) |
| 310 | } |
| 311 | if typeFilter != "" && typeFilter != "all" { |
| 312 | values.Set("type", typeFilter) |
| 313 | } |
| 314 | if language != "" { |
| 315 | values.Set("language", language) |
| 316 | } |
| 317 | if sortKey != "" && sortKey != "updated" { |
| 318 | values.Set("sort", sortKey) |
| 319 | } |
| 320 | if page > 1 { |
| 321 | values.Set("page", strconv.Itoa(page)) |
| 322 | } |
| 323 | return "/" + url.PathEscape(username) + "?" + values.Encode() |
| 324 | } |
| 325 | |
| 326 | func userRepositoryTypeFilters(username string, repos []userRepositoryItem, filters userRepositoryFilters) []orgRepositoryFilterOption { |
| 327 | counts := map[string]int{"all": len(repos)} |
| 328 | for _, repo := range repos { |
| 329 | if repo.Public { |
| 330 | counts["public"]++ |
| 331 | } |
| 332 | if repo.Private { |
| 333 | counts["private"]++ |
| 334 | } |
| 335 | if repo.Source { |
| 336 | counts["source"]++ |
| 337 | } |
| 338 | if repo.IsFork { |
| 339 | counts["fork"]++ |
| 340 | } |
| 341 | if repo.Archived { |
| 342 | counts["archived"]++ |
| 343 | } |
| 344 | } |
| 345 | specs := []struct { |
| 346 | value string |
| 347 | label string |
| 348 | }{ |
| 349 | {value: "all", label: "All"}, |
| 350 | {value: "public", label: "Public"}, |
| 351 | {value: "private", label: "Private"}, |
| 352 | {value: "source", label: "Sources"}, |
| 353 | {value: "fork", label: "Forks"}, |
| 354 | {value: "archived", label: "Archived"}, |
| 355 | } |
| 356 | out := make([]orgRepositoryFilterOption, 0, len(specs)) |
| 357 | for _, spec := range specs { |
| 358 | if spec.value == "private" && counts[spec.value] == 0 && filters.Type != spec.value { |
| 359 | continue |
| 360 | } |
| 361 | out = append(out, orgRepositoryFilterOption{ |
| 362 | Label: spec.label, |
| 363 | Value: spec.value, |
| 364 | Href: userRepositoryURL(username, filters.Query, spec.value, filters.Language, filters.Sort, 1), |
| 365 | Selected: filters.Type == spec.value, |
| 366 | Count: counts[spec.value], |
| 367 | }) |
| 368 | } |
| 369 | return out |
| 370 | } |
| 371 | |
| 372 | func userRepositoryLanguageFilters(username string, repos []userRepositoryItem, filters userRepositoryFilters) []orgRepositoryFilterOption { |
| 373 | counts := map[string]int{} |
| 374 | for _, repo := range repos { |
| 375 | if repo.PrimaryLanguage != "" { |
| 376 | counts[repo.PrimaryLanguage]++ |
| 377 | } |
| 378 | } |
| 379 | languages := make([]string, 0, len(counts)) |
| 380 | for language := range counts { |
| 381 | languages = append(languages, language) |
| 382 | } |
| 383 | sort.SliceStable(languages, func(i, j int) bool { |
| 384 | if counts[languages[i]] != counts[languages[j]] { |
| 385 | return counts[languages[i]] > counts[languages[j]] |
| 386 | } |
| 387 | return languages[i] < languages[j] |
| 388 | }) |
| 389 | out := []orgRepositoryFilterOption{{ |
| 390 | Label: "All languages", |
| 391 | Value: "", |
| 392 | Href: userRepositoryURL(username, filters.Query, filters.Type, "", filters.Sort, 1), |
| 393 | Selected: filters.Language == "", |
| 394 | Count: len(repos), |
| 395 | }} |
| 396 | for _, language := range languages { |
| 397 | out = append(out, orgRepositoryFilterOption{ |
| 398 | Label: language, |
| 399 | Value: language, |
| 400 | Href: userRepositoryURL(username, filters.Query, filters.Type, language, filters.Sort, 1), |
| 401 | Selected: strings.EqualFold(filters.Language, language), |
| 402 | Count: counts[language], |
| 403 | }) |
| 404 | } |
| 405 | return out |
| 406 | } |
| 407 | |
| 408 | func userRepositorySortOptions(username string, filters userRepositoryFilters) []orgRepositoryFilterOption { |
| 409 | specs := []struct { |
| 410 | value string |
| 411 | label string |
| 412 | }{ |
| 413 | {value: "updated", label: "Last updated"}, |
| 414 | {value: "name", label: "Name"}, |
| 415 | {value: "stars", label: "Stars"}, |
| 416 | } |
| 417 | out := make([]orgRepositoryFilterOption, 0, len(specs)) |
| 418 | for _, spec := range specs { |
| 419 | out = append(out, orgRepositoryFilterOption{ |
| 420 | Label: spec.label, |
| 421 | Value: spec.value, |
| 422 | Href: userRepositoryURL(username, filters.Query, filters.Type, filters.Language, spec.value, 1), |
| 423 | Selected: filters.Sort == spec.value, |
| 424 | }) |
| 425 | } |
| 426 | return out |
| 427 | } |
| 428 | |
| 429 | func userRepositoryPaginationPages(username string, filters userRepositoryFilters, page, pageCount int) []orgRepositoryPageLink { |
| 430 | if pageCount <= 1 { |
| 431 | return nil |
| 432 | } |
| 433 | out := make([]orgRepositoryPageLink, 0, pageCount) |
| 434 | for i := 1; i <= pageCount; i++ { |
| 435 | out = append(out, orgRepositoryPageLink{ |
| 436 | Number: i, |
| 437 | Href: userRepositoryURL(username, filters.Query, filters.Type, filters.Language, filters.Sort, i), |
| 438 | Current: i == page, |
| 439 | }) |
| 440 | } |
| 441 | return out |
| 442 | } |
| 443 | |
| 444 | // tabCounts returns the badge counts for the profile sub-nav. Counts |
| 445 | // are best-effort: a query failure collapses to 0 so the nav still |
| 446 | // renders. The repo count is visibility-aware (private repos hidden |
| 447 | // from non-self viewers) so the badge doesn't leak the existence of |
| 448 | // hidden repos. |
| 449 | func (h *Handlers) tabCounts(ctx context.Context, userID int64, viewer middleware.CurrentUser) map[string]int64 { |
| 450 | out := map[string]int64{} |
| 451 | isSelf := !viewer.IsAnonymous() && viewer.ID == userID |
| 452 | |
| 453 | visClause := " AND visibility = 'public'" |
| 454 | if isSelf { |
| 455 | visClause = "" |
| 456 | } |
| 457 | var repoCount, starCount int64 |
| 458 | _ = h.d.Pool.QueryRow(ctx, |
| 459 | `SELECT count(*) FROM repos |
| 460 | WHERE owner_user_id = $1 AND deleted_at IS NULL`+visClause, |
| 461 | userID).Scan(&repoCount) |
| 462 | _ = h.d.Pool.QueryRow(ctx, |
| 463 | `SELECT count(*) FROM stars WHERE user_id = $1`, |
| 464 | userID).Scan(&starCount) |
| 465 | out["repositories"] = repoCount |
| 466 | out["stars"] = starCount |
| 467 | return out |
| 468 | } |
| 469 |