tenseleyflow/shithub / ec17e0b

Browse files

Add profile overview data

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ec17e0b4f1594c8a706323560145d3eab507b3ff
Parents
3ded130
Tree
72f1b7d

2 changed files

StatusFile+-
A internal/web/handlers/profile/overview.go 387 0
M internal/web/handlers/profile/profile.go 14 2
internal/web/handlers/profile/overview.goadded
@@ -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
+}
internal/web/handlers/profile/profile.gomodified
@@ -159,19 +159,31 @@ func (h *Handlers) serveProfile(w http.ResponseWriter, r *http.Request) {
159159
 
160160
 	avatarURL := fmt.Sprintf("/avatars/%s", url.PathEscape(user.Username))
161161
 	tabs := h.tabCounts(r.Context(), user.ID, viewer)
162
+	visibleRepos := h.visibleUserRepos(r.Context(), user.ID, viewer)
162163
 	pinnedRepos, pinCandidates := h.userPinData(r.Context(), user)
164
+	readme, hasReadme := h.profileReadme(r.Context(), user, viewer)
165
+	displayName := user.DisplayName
166
+	if displayName == "" {
167
+		displayName = user.Username
168
+	}
163169
 	data := map[string]any{
164
-		"Title":            user.DisplayName,
170
+		"Title":            displayName,
165171
 		"User":             user,
172
+		"DisplayName":      displayName,
166173
 		"IsSelf":           isSelf,
167174
 		"AvatarURL":        avatarURL,
168
-		"OGTitle":          user.DisplayName + " (@" + user.Username + ")",
175
+		"OGTitle":          displayName + " (@" + user.Username + ")",
169176
 		"OGDescription":    ogDescription(user),
170177
 		"OGImage":          avatarURL,
171178
 		"JoinedFormatted":  user.CreatedAt.Time.Format("January 2, 2006"),
172179
 		"WebsiteSafe":      safeWebsite(user.Website),
173180
 		"Tabs":             tabs,
174181
 		"ActiveTab":        "overview",
182
+		"VisibleRepoCount": len(visibleRepos),
183
+		"Orgs":             h.profileOrganizations(r.Context(), user.ID),
184
+		"ProfileReadme":    readme,
185
+		"HasProfileReadme": hasReadme,
186
+		"Contributions":    h.contributionCalendar(r.Context(), user, visibleRepos),
175187
 		"PinnedRepos":      pinnedRepos,
176188
 		"PinCandidates":    pinCandidates,
177189
 		"PinsRemaining":    profilePinsRemaining(pinCandidates),