Go · 15477 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 "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