@@ -0,0 +1,387 @@ |
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | + |
| 3 | +package profile |
| 4 | + |
| 5 | +import ( |
| 6 | + "bytes" |
| 7 | + "context" |
| 8 | + "errors" |
| 9 | + "html/template" |
| 10 | + "io" |
| 11 | + "net/url" |
| 12 | + "path" |
| 13 | + "strconv" |
| 14 | + "strings" |
| 15 | + "time" |
| 16 | + |
| 17 | + "github.com/jackc/pgx/v5" |
| 18 | + "github.com/jackc/pgx/v5/pgtype" |
| 19 | + "golang.org/x/net/html" |
| 20 | + |
| 21 | + "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 22 | + mdrender "github.com/tenseleyFlow/shithub/internal/markdown" |
| 23 | + orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" |
| 24 | + repogit "github.com/tenseleyFlow/shithub/internal/repos/git" |
| 25 | + reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| 26 | + usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 27 | + "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 28 | +) |
| 29 | + |
| 30 | +const ( |
| 31 | + profileContribWeeks = 53 |
| 32 | + profileContribRepoLimit = 80 |
| 33 | + profileContribMaxPerRepo = 2000 |
| 34 | + profileReadmeMaxBytes = 1 * 1024 * 1024 |
| 35 | +) |
| 36 | + |
| 37 | +type profileOrgBadge struct { |
| 38 | + Slug string |
| 39 | + DisplayName string |
| 40 | + AvatarURL string |
| 41 | +} |
| 42 | + |
| 43 | +type profileReadme struct { |
| 44 | + Owner string |
| 45 | + Repo string |
| 46 | + Path string |
| 47 | + HTML template.HTML |
| 48 | +} |
| 49 | + |
| 50 | +type contributionCalendar struct { |
| 51 | + Total int |
| 52 | + Weeks []contributionWeek |
| 53 | + Years []int |
| 54 | + CurrentYear int |
| 55 | + MonthLabel string |
| 56 | + MonthCommitCount int |
| 57 | + MonthRepoCount int |
| 58 | + HasRepositoryData bool |
| 59 | +} |
| 60 | + |
| 61 | +type contributionWeek struct { |
| 62 | + MonthLabel string |
| 63 | + Days []contributionDay |
| 64 | +} |
| 65 | + |
| 66 | +type contributionDay struct { |
| 67 | + Date string |
| 68 | + Title string |
| 69 | + Count int |
| 70 | + Level int |
| 71 | + IsFuture bool |
| 72 | + IsInWindow bool |
| 73 | +} |
| 74 | + |
| 75 | +func (h *Handlers) visibleUserRepos(ctx context.Context, userID int64, viewer middleware.CurrentUser) []reposdb.Repo { |
| 76 | + rows, err := reposdb.New().ListReposForOwnerUser(ctx, h.d.Pool, pgtype.Int8{Int64: userID, Valid: true}) |
| 77 | + if err != nil { |
| 78 | + h.d.Logger.WarnContext(ctx, "profile overview: list repos", "user_id", userID, "error", err) |
| 79 | + return nil |
| 80 | + } |
| 81 | + actor := policy.AnonymousActor() |
| 82 | + if !viewer.IsAnonymous() { |
| 83 | + actor = viewer.PolicyActor() |
| 84 | + } |
| 85 | + deps := policy.Deps{Pool: h.d.Pool} |
| 86 | + out := make([]reposdb.Repo, 0, len(rows)) |
| 87 | + for _, repo := range rows { |
| 88 | + if policy.IsVisibleTo(ctx, deps, actor, policy.NewRepoRefFromRepo(repo)) { |
| 89 | + out = append(out, repo) |
| 90 | + } |
| 91 | + } |
| 92 | + return out |
| 93 | +} |
| 94 | + |
| 95 | +func (h *Handlers) profileOrganizations(ctx context.Context, userID int64) []profileOrgBadge { |
| 96 | + rows, err := orgsdb.New().ListOrgsForUser(ctx, h.d.Pool, userID) |
| 97 | + if err != nil { |
| 98 | + h.d.Logger.WarnContext(ctx, "profile overview: list orgs", "user_id", userID, "error", err) |
| 99 | + return nil |
| 100 | + } |
| 101 | + out := make([]profileOrgBadge, 0, len(rows)) |
| 102 | + for _, row := range rows { |
| 103 | + label := row.DisplayName |
| 104 | + if label == "" { |
| 105 | + label = row.Slug |
| 106 | + } |
| 107 | + out = append(out, profileOrgBadge{ |
| 108 | + Slug: row.Slug, |
| 109 | + DisplayName: label, |
| 110 | + AvatarURL: "/avatars/" + url.PathEscape(row.Slug), |
| 111 | + }) |
| 112 | + } |
| 113 | + return out |
| 114 | +} |
| 115 | + |
| 116 | +func (h *Handlers) profileReadme(ctx context.Context, user usersdb.User, viewer middleware.CurrentUser) (profileReadme, bool) { |
| 117 | + if h.d.RepoFS == nil { |
| 118 | + return profileReadme{}, false |
| 119 | + } |
| 120 | + repo, err := reposdb.New().GetRepoByOwnerUserAndName(ctx, h.d.Pool, reposdb.GetRepoByOwnerUserAndNameParams{ |
| 121 | + OwnerUserID: pgtype.Int8{Int64: user.ID, Valid: true}, |
| 122 | + Name: user.Username, |
| 123 | + }) |
| 124 | + if err != nil { |
| 125 | + if !errors.Is(err, pgx.ErrNoRows) { |
| 126 | + h.d.Logger.WarnContext(ctx, "profile overview: load profile readme repo", "user_id", user.ID, "error", err) |
| 127 | + } |
| 128 | + return profileReadme{}, false |
| 129 | + } |
| 130 | + actor := policy.AnonymousActor() |
| 131 | + if !viewer.IsAnonymous() { |
| 132 | + actor = viewer.PolicyActor() |
| 133 | + } |
| 134 | + if !policy.IsVisibleTo(ctx, policy.Deps{Pool: h.d.Pool}, actor, policy.NewRepoRefFromRepo(repo)) { |
| 135 | + return profileReadme{}, false |
| 136 | + } |
| 137 | + gitDir, err := h.d.RepoFS.RepoPath(user.Username, repo.Name) |
| 138 | + if err != nil { |
| 139 | + h.d.Logger.WarnContext(ctx, "profile overview: readme repo path", "repo_id", repo.ID, "error", err) |
| 140 | + return profileReadme{}, false |
| 141 | + } |
| 142 | + entries, err := repogit.LsTree(ctx, gitDir, repo.DefaultBranch, "") |
| 143 | + if err != nil { |
| 144 | + h.d.Logger.WarnContext(ctx, "profile overview: readme tree", "repo_id", repo.ID, "error", err) |
| 145 | + return profileReadme{}, false |
| 146 | + } |
| 147 | + for _, entry := range entries { |
| 148 | + if entry.Kind != repogit.EntryBlob || !strings.HasPrefix(strings.ToLower(entry.Name), "readme") { |
| 149 | + continue |
| 150 | + } |
| 151 | + body, err := repogit.ReadBlobBytes(ctx, gitDir, repo.DefaultBranch, entry.Name, profileReadmeMaxBytes) |
| 152 | + if err != nil { |
| 153 | + h.d.Logger.WarnContext(ctx, "profile overview: readme blob", "repo_id", repo.ID, "path", entry.Name, "error", err) |
| 154 | + return profileReadme{}, false |
| 155 | + } |
| 156 | + html := "" |
| 157 | + lower := strings.ToLower(entry.Name) |
| 158 | + if strings.HasSuffix(lower, ".md") || strings.HasSuffix(lower, ".markdown") { |
| 159 | + rendered, err := mdrender.RenderDocumentHTML(body) |
| 160 | + if err != nil { |
| 161 | + h.d.Logger.WarnContext(ctx, "profile overview: render readme", "repo_id", repo.ID, "error", err) |
| 162 | + return profileReadme{}, false |
| 163 | + } |
| 164 | + html = rewriteProfileMarkdownRelativeURLs( |
| 165 | + rendered, |
| 166 | + profileCodeRouteBase(user.Username, repo.Name, "blob", repo.DefaultBranch, ""), |
| 167 | + profileCodeRouteBase(user.Username, repo.Name, "blob", repo.DefaultBranch, ""), |
| 168 | + profileCodeRouteBase(user.Username, repo.Name, "raw", repo.DefaultBranch, ""), |
| 169 | + profileCodeRouteBase(user.Username, repo.Name, "raw", repo.DefaultBranch, ""), |
| 170 | + ) |
| 171 | + } else { |
| 172 | + html = "<pre class=\"shithub-readme-plain\">" + template.HTMLEscapeString(string(body)) + "</pre>" |
| 173 | + } |
| 174 | + return profileReadme{ |
| 175 | + Owner: user.Username, |
| 176 | + Repo: repo.Name, |
| 177 | + Path: entry.Name, |
| 178 | + HTML: template.HTML(html), //nolint:gosec // sanitized by markdown renderer or escaped above. |
| 179 | + }, true |
| 180 | + } |
| 181 | + return profileReadme{}, false |
| 182 | +} |
| 183 | + |
| 184 | +func (h *Handlers) contributionCalendar(ctx context.Context, user usersdb.User, repos []reposdb.Repo) contributionCalendar { |
| 185 | + now := time.Now().UTC() |
| 186 | + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) |
| 187 | + windowStart := today.AddDate(-1, 0, 1) |
| 188 | + gridStart := windowStart.AddDate(0, 0, -int(windowStart.Weekday())) |
| 189 | + windowEnd := today.Add(24 * time.Hour) |
| 190 | + |
| 191 | + counts := map[string]int{} |
| 192 | + reposWithMonthActivity := map[int64]struct{}{} |
| 193 | + if h.d.RepoFS != nil && len(repos) > 0 { |
| 194 | + emails := h.verifiedEmails(ctx, user.ID) |
| 195 | + for i, repo := range repos { |
| 196 | + if i >= profileContribRepoLimit { |
| 197 | + break |
| 198 | + } |
| 199 | + gitDir, err := h.d.RepoFS.RepoPath(user.Username, repo.Name) |
| 200 | + if err != nil { |
| 201 | + continue |
| 202 | + } |
| 203 | + commits, err := repogit.Log(ctx, gitDir, repogit.LogOptions{ |
| 204 | + Ref: repo.DefaultBranch, |
| 205 | + MaxCount: profileContribMaxPerRepo, |
| 206 | + Since: windowStart, |
| 207 | + Until: windowEnd, |
| 208 | + }) |
| 209 | + if err != nil { |
| 210 | + continue |
| 211 | + } |
| 212 | + for _, commit := range commits { |
| 213 | + if len(emails) > 0 { |
| 214 | + if _, ok := emails[strings.ToLower(strings.TrimSpace(commit.AuthorEmail))]; !ok { |
| 215 | + continue |
| 216 | + } |
| 217 | + } |
| 218 | + day := time.Date(commit.AuthorWhen.UTC().Year(), commit.AuthorWhen.UTC().Month(), commit.AuthorWhen.UTC().Day(), 0, 0, 0, 0, time.UTC) |
| 219 | + if day.Before(windowStart) || day.After(today) { |
| 220 | + continue |
| 221 | + } |
| 222 | + key := day.Format("2006-01-02") |
| 223 | + counts[key]++ |
| 224 | + if day.Year() == today.Year() && day.Month() == today.Month() { |
| 225 | + reposWithMonthActivity[repo.ID] = struct{}{} |
| 226 | + } |
| 227 | + } |
| 228 | + } |
| 229 | + } |
| 230 | + |
| 231 | + weeks := make([]contributionWeek, 0, profileContribWeeks) |
| 232 | + total := 0 |
| 233 | + monthCommitCount := 0 |
| 234 | + for w := 0; w < profileContribWeeks; w++ { |
| 235 | + week := contributionWeek{Days: make([]contributionDay, 0, 7)} |
| 236 | + for d := 0; d < 7; d++ { |
| 237 | + day := gridStart.AddDate(0, 0, w*7+d) |
| 238 | + key := day.Format("2006-01-02") |
| 239 | + count := counts[key] |
| 240 | + inWindow := !day.Before(windowStart) && !day.After(today) |
| 241 | + if inWindow { |
| 242 | + total += count |
| 243 | + if day.Year() == today.Year() && day.Month() == today.Month() { |
| 244 | + monthCommitCount += count |
| 245 | + } |
| 246 | + } |
| 247 | + if d == 0 && (day.Day() <= 7 || w == 0) { |
| 248 | + week.MonthLabel = day.Format("Jan") |
| 249 | + } |
| 250 | + week.Days = append(week.Days, contributionDay{ |
| 251 | + Date: key, |
| 252 | + Title: contributionDayTitle(count, day), |
| 253 | + Count: count, |
| 254 | + Level: contributionLevel(count), |
| 255 | + IsFuture: day.After(today), |
| 256 | + IsInWindow: inWindow, |
| 257 | + }) |
| 258 | + } |
| 259 | + weeks = append(weeks, week) |
| 260 | + } |
| 261 | + years := []int{today.Year(), today.Year() - 1, today.Year() - 2, today.Year() - 3} |
| 262 | + return contributionCalendar{ |
| 263 | + Total: total, |
| 264 | + Weeks: weeks, |
| 265 | + Years: years, |
| 266 | + CurrentYear: today.Year(), |
| 267 | + MonthLabel: today.Format("January 2006"), |
| 268 | + MonthCommitCount: monthCommitCount, |
| 269 | + MonthRepoCount: len(reposWithMonthActivity), |
| 270 | + HasRepositoryData: h.d.RepoFS != nil && len(repos) > 0, |
| 271 | + } |
| 272 | +} |
| 273 | + |
| 274 | +func (h *Handlers) verifiedEmails(ctx context.Context, userID int64) map[string]struct{} { |
| 275 | + rows, err := h.q.ListUserEmailsForUser(ctx, h.d.Pool, userID) |
| 276 | + if err != nil { |
| 277 | + return nil |
| 278 | + } |
| 279 | + out := make(map[string]struct{}, len(rows)) |
| 280 | + for _, row := range rows { |
| 281 | + if row.Verified { |
| 282 | + out[strings.ToLower(strings.TrimSpace(row.Email))] = struct{}{} |
| 283 | + } |
| 284 | + } |
| 285 | + return out |
| 286 | +} |
| 287 | + |
| 288 | +func contributionLevel(count int) int { |
| 289 | + switch { |
| 290 | + case count <= 0: |
| 291 | + return 0 |
| 292 | + case count < 3: |
| 293 | + return 1 |
| 294 | + case count < 6: |
| 295 | + return 2 |
| 296 | + case count < 10: |
| 297 | + return 3 |
| 298 | + default: |
| 299 | + return 4 |
| 300 | + } |
| 301 | +} |
| 302 | + |
| 303 | +func contributionDayTitle(count int, day time.Time) string { |
| 304 | + if count == 0 { |
| 305 | + return "No contributions on " + day.Format("January 2") + "." |
| 306 | + } |
| 307 | + if count == 1 { |
| 308 | + return "1 contribution on " + day.Format("January 2") + "." |
| 309 | + } |
| 310 | + return strconv.Itoa(count) + " contributions on " + day.Format("January 2") + "." |
| 311 | +} |
| 312 | + |
| 313 | +func profileCodeRouteBase(owner, repoName, route, ref, dir string) string { |
| 314 | + base := "/" + url.PathEscape(owner) + "/" + url.PathEscape(repoName) + "/" + route + "/" + escapeProfilePathSegments(ref) |
| 315 | + if dir != "" { |
| 316 | + base += "/" + escapeProfilePathSegments(dir) |
| 317 | + } |
| 318 | + return base |
| 319 | +} |
| 320 | + |
| 321 | +func escapeProfilePathSegments(p string) string { |
| 322 | + parts := strings.Split(p, "/") |
| 323 | + for i := range parts { |
| 324 | + parts[i] = url.PathEscape(parts[i]) |
| 325 | + } |
| 326 | + return strings.Join(parts, "/") |
| 327 | +} |
| 328 | + |
| 329 | +func rewriteProfileMarkdownRelativeURLs(fragment, linkBase, linkRoot, imageBase, imageRoot string) string { |
| 330 | + if fragment == "" { |
| 331 | + return "" |
| 332 | + } |
| 333 | + z := html.NewTokenizer(strings.NewReader(fragment)) |
| 334 | + var out bytes.Buffer |
| 335 | + for { |
| 336 | + tt := z.Next() |
| 337 | + if tt == html.ErrorToken { |
| 338 | + if z.Err() == io.EOF { |
| 339 | + return out.String() |
| 340 | + } |
| 341 | + return fragment |
| 342 | + } |
| 343 | + tok := z.Token() |
| 344 | + switch tt { |
| 345 | + case html.StartTagToken, html.SelfClosingTagToken: |
| 346 | + rewriteProfileMarkdownTokenURLs(&tok, linkBase, linkRoot, imageBase, imageRoot) |
| 347 | + } |
| 348 | + out.WriteString(tok.String()) |
| 349 | + } |
| 350 | +} |
| 351 | + |
| 352 | +func rewriteProfileMarkdownTokenURLs(tok *html.Token, linkBase, linkRoot, imageBase, imageRoot string) { |
| 353 | + switch tok.Data { |
| 354 | + case "a": |
| 355 | + rewriteProfileAttr(tok, "href", linkBase, linkRoot) |
| 356 | + case "img": |
| 357 | + rewriteProfileAttr(tok, "src", imageBase, imageRoot) |
| 358 | + } |
| 359 | +} |
| 360 | + |
| 361 | +func rewriteProfileAttr(tok *html.Token, key, base, root string) { |
| 362 | + for i := range tok.Attr { |
| 363 | + if tok.Attr[i].Key == key { |
| 364 | + tok.Attr[i].Val = rewriteProfileRelativeMarkdownURL(tok.Attr[i].Val, base, root) |
| 365 | + } |
| 366 | + } |
| 367 | +} |
| 368 | + |
| 369 | +func rewriteProfileRelativeMarkdownURL(raw, base, root string) string { |
| 370 | + if raw == "" || base == "" || root == "" || strings.TrimSpace(raw) != raw { |
| 371 | + return raw |
| 372 | + } |
| 373 | + if strings.HasPrefix(raw, "#") || strings.HasPrefix(raw, "//") { |
| 374 | + return raw |
| 375 | + } |
| 376 | + u, err := url.Parse(raw) |
| 377 | + if err != nil || u.IsAbs() || u.Host != "" || strings.HasPrefix(u.Path, "/") || u.Path == "" { |
| 378 | + return raw |
| 379 | + } |
| 380 | + next := path.Clean(path.Clean(base) + "/" + u.Path) |
| 381 | + if next != root && !strings.HasPrefix(next, root+"/") { |
| 382 | + return raw |
| 383 | + } |
| 384 | + u.Path = next |
| 385 | + u.RawPath = "" |
| 386 | + return u.String() |
| 387 | +} |