forked from
tangled.org/core
Monorepo for Tangled
1package state
2
3import (
4 "context"
5 "fmt"
6 "net/http"
7 "slices"
8 "strings"
9 "time"
10
11 comatproto "github.com/bluesky-social/indigo/api/atproto"
12 "github.com/bluesky-social/indigo/atproto/identity"
13 "github.com/bluesky-social/indigo/atproto/syntax"
14 lexutil "github.com/bluesky-social/indigo/lex/util"
15 "github.com/go-chi/chi/v5"
16 "github.com/gorilla/feeds"
17 "tangled.org/core/api/tangled"
18 "tangled.org/core/appview/cache"
19 "tangled.org/core/appview/db"
20 "tangled.org/core/appview/middleware"
21 "tangled.org/core/appview/models"
22 "tangled.org/core/appview/pages"
23 "tangled.org/core/appview/pagination"
24 "tangled.org/core/appview/searchquery"
25 "tangled.org/core/orm"
26 "tangled.org/core/xrpc"
27)
28
29func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
30 tabVal := r.URL.Query().Get("tab")
31 switch tabVal {
32 case "repos":
33 middleware.
34 Paginate(http.HandlerFunc(s.reposPage)).
35 ServeHTTP(w, r)
36 case "followers":
37 s.followersPage(w, r)
38 case "following":
39 s.followingPage(w, r)
40 case "starred":
41 middleware.
42 Paginate(http.HandlerFunc(s.starredPage)).
43 ServeHTTP(w, r)
44 case "strings":
45 s.stringsPage(w, r)
46 case "vouches":
47 middleware.
48 Paginate(http.HandlerFunc(s.vouchesPage)).
49 ServeHTTP(w, r)
50 default:
51 s.profileOverview(w, r)
52 }
53}
54
55func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) {
56 didOrHandle := chi.URLParam(r, "user")
57 if didOrHandle == "" {
58 return nil, fmt.Errorf("empty DID or handle")
59 }
60
61 ident, ok := r.Context().Value("resolvedId").(identity.Identity)
62 if !ok {
63 return nil, fmt.Errorf("failed to resolve ID")
64 }
65 did := ident.DID.String()
66
67 profile, err := db.GetProfile(s.db, did)
68 if err != nil {
69 return nil, fmt.Errorf("failed to get profile: %w", err)
70 }
71
72 hasProfile := profile != nil
73 if !hasProfile {
74 profile = &models.Profile{Did: did}
75 }
76
77 repoCount, err := db.CountRepos(s.db, orm.FilterEq("did", did))
78 if err != nil {
79 return nil, fmt.Errorf("failed to get repo count: %w", err)
80 }
81
82 stringCount, err := db.CountStrings(s.db, orm.FilterEq("did", did))
83 if err != nil {
84 return nil, fmt.Errorf("failed to get string count: %w", err)
85 }
86
87 starredCount, err := db.CountStars(s.db, orm.FilterEq("did", did))
88 if err != nil {
89 return nil, fmt.Errorf("failed to get starred repo count: %w", err)
90 }
91
92 followStats, err := db.GetFollowerFollowingCount(s.db, did)
93 if err != nil {
94 return nil, fmt.Errorf("failed to get follower stats: %w", err)
95 }
96
97 loggedInUser := s.oauth.GetMultiAccountUser(r)
98 followStatus := models.IsNotFollowing
99 var loggedInDid string
100 var vouchRelationship *models.VouchRelationship
101
102 if loggedInUser != nil {
103 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
104 loggedInDid = loggedInUser.Did
105 vouchRelationship, err = db.GetVouchRelationship(s.db, syntax.DID(loggedInUser.Did), syntax.DID(did))
106 }
107
108 showPunchcard := s.shouldShowPunchcard(did, loggedInDid)
109
110 var punchcard *models.Punchcard
111 if showPunchcard {
112 now := time.Now()
113 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
114 punchcard, err = db.MakePunchcard(
115 s.db,
116 orm.FilterEq("did", did),
117 orm.FilterGte("date", startOfYear.Format(time.DateOnly)),
118 orm.FilterLte("date", now.Format(time.DateOnly)),
119 )
120 if err != nil {
121 return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err)
122 }
123 }
124
125 return &pages.ProfileCard{
126 UserDid: did,
127 HasProfile: hasProfile,
128 Profile: profile,
129 FollowStatus: followStatus,
130 VouchRelationship: vouchRelationship,
131 Stats: pages.ProfileStats{
132 RepoCount: repoCount,
133 StringCount: stringCount,
134 StarredCount: starredCount,
135 FollowersCount: followStats.Followers,
136 FollowingCount: followStats.Following,
137 },
138 Punchcard: punchcard,
139 }, nil
140}
141
142func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) {
143 l := s.logger.With("handler", "profileHomePage")
144
145 profile, err := s.profile(r)
146 if err != nil {
147 l.Error("failed to build profile card", "err", err)
148 s.pages.Error500(w)
149 return
150 }
151 l = l.With("profileDid", profile.UserDid)
152
153 repos, err := db.GetRepos(
154 s.db,
155 orm.FilterEq("did", profile.UserDid),
156 )
157 if err != nil {
158 l.Error("failed to fetch repos", "err", err)
159 }
160
161 // filter out ones that are pinned
162 pinnedRepos := []models.Repo{}
163 for i, r := range repos {
164 if profile.Profile.MatchesPinnedRepo(r) {
165 pinnedRepos = append(pinnedRepos, r)
166 } else if profile.Profile.IsPinnedReposEmpty() && i < 4 {
167 pinnedRepos = append(pinnedRepos, r)
168 }
169 }
170
171 collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid)
172 if err != nil {
173 l.Error("failed to fetch collaborating repos", "err", err)
174 }
175
176 pinnedCollaboratingRepos := []models.Repo{}
177 for _, r := range collaboratingRepos {
178 if profile.Profile.MatchesPinnedRepo(r) {
179 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r)
180 }
181 }
182
183 timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid)
184 if err != nil {
185 l.Error("failed to create timeline", "err", err)
186 }
187
188 err = s.pages.ProfileOverview(w, pages.ProfileOverviewParams{
189 LoggedInUser: s.oauth.GetMultiAccountUser(r),
190 Card: profile,
191 Repos: pinnedRepos,
192 CollaboratingRepos: pinnedCollaboratingRepos,
193 ProfileTimeline: timeline,
194 })
195 if err != nil {
196 l.Error("failed to render template", "err", err)
197 }
198}
199
200func (s *State) shouldShowPunchcard(targetDid, requesterDid string) bool {
201 l := s.logger.With("helper", "shouldShowPunchcard")
202
203 targetPunchcardPreferences, err := db.GetPunchcardPreference(s.db, targetDid)
204 if err != nil {
205 l.Error("failed to get target users punchcard preferences", "err", err)
206 return true
207 }
208
209 requesterPunchcardPreferences, err := db.GetPunchcardPreference(s.db, requesterDid)
210 if err != nil {
211 l.Error("failed to get requester users punchcard preferences", "err", err)
212 return true
213 }
214
215 showPunchcard := true
216
217 // looking at their own profile
218 if targetDid == requesterDid {
219 if targetPunchcardPreferences.HideMine {
220 return false
221 }
222 return true
223 }
224
225 if targetPunchcardPreferences.HideMine || requesterPunchcardPreferences.HideOthers {
226 showPunchcard = false
227 }
228 return showPunchcard
229}
230
231func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
232 l := s.logger.With("handler", "reposPage")
233
234 profile, err := s.profile(r)
235 if err != nil {
236 l.Error("failed to build profile card", "err", err)
237 s.pages.Error500(w)
238 return
239 }
240 l = l.With("profileDid", profile.UserDid)
241
242 params := r.URL.Query()
243 page := pagination.FromContext(r.Context())
244
245 query := searchquery.Parse(params.Get("q"))
246
247 var language string
248 if lang := query.Get("language"); lang != nil {
249 language = *lang
250 }
251
252 tf := searchquery.ExtractTextFilters(query)
253
254 searchOpts := models.RepoSearchOptions{
255 Keywords: tf.Keywords,
256 Phrases: tf.Phrases,
257 NegatedKeywords: tf.NegatedKeywords,
258 NegatedPhrases: tf.NegatedPhrases,
259 Did: profile.UserDid,
260 Language: language,
261 Page: page,
262 }
263
264 var repos []models.Repo
265 var totalRepos int64
266
267 if searchOpts.HasSearchFilters() {
268 res, err := s.indexer.Repos.Search(r.Context(), searchOpts)
269 if err != nil {
270 l.Error("failed to search repos", "err", err)
271 s.pages.Error500(w)
272 return
273 }
274
275 if len(res.Hits) > 0 {
276 repos, err = db.GetRepos(s.db, orm.FilterIn("id", res.Hits))
277 if err != nil {
278 l.Error("failed to get repos by IDs", "err", err)
279 s.pages.Error500(w)
280 return
281 }
282
283 // sort repos to match search result order (by relevance)
284 repoMap := make(map[int64]models.Repo, len(repos))
285 for _, repo := range repos {
286 repoMap[repo.Id] = repo
287 }
288 repos = make([]models.Repo, 0, len(res.Hits))
289 for _, id := range res.Hits {
290 if repo, ok := repoMap[id]; ok {
291 repos = append(repos, repo)
292 }
293 }
294 }
295 totalRepos = int64(res.Total)
296 } else {
297 repos, err = db.GetReposPaginated(
298 s.db,
299 page,
300 orm.FilterEq("did", profile.UserDid),
301 )
302 if err != nil {
303 l.Error("failed to get repos", "err", err)
304 s.pages.Error500(w)
305 return
306 }
307
308 totalRepos, err = db.CountRepos(
309 s.db,
310 orm.FilterEq("did", profile.UserDid),
311 )
312 if err != nil {
313 l.Error("failed to count repos", "err", err)
314 s.pages.Error500(w)
315 return
316 }
317 }
318
319 err = s.pages.ProfileRepos(w, pages.ProfileReposParams{
320 LoggedInUser: s.oauth.GetMultiAccountUser(r),
321 Repos: repos,
322 Card: profile,
323 Page: page,
324 RepoCount: int(totalRepos),
325 FilterQuery: query.String(),
326 })
327 if err != nil {
328 l.Error("failed to render page", "err", err)
329 }
330}
331
332func (s *State) starredPage(w http.ResponseWriter, r *http.Request) {
333 l := s.logger.With("handler", "starredPage")
334
335 page := pagination.FromContext(r.Context())
336 l = l.With("page", page)
337
338 profile, err := s.profile(r)
339 if err != nil {
340 l.Error("failed to build profile card", "err", err)
341 s.pages.Error500(w)
342 return
343 }
344 l = l.With("profileDid", profile.UserDid)
345
346 stars, err := db.GetRepoStars(s.db, page, orm.FilterEq("did", profile.UserDid))
347 if err != nil {
348 l.Error("failed to get stars", "err", err)
349 s.pages.Error500(w)
350 return
351 }
352 var repos []models.Repo
353 for _, s := range stars {
354 repos = append(repos, *s.Repo)
355 }
356
357 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
358 LoggedInUser: s.oauth.GetMultiAccountUser(r),
359 Repos: repos,
360 Total: int(profile.Stats.StarredCount),
361 Card: profile,
362 Page: page,
363 })
364 if err != nil {
365 l.Error("failed to render", "err", err)
366 }
367}
368
369func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) {
370 l := s.logger.With("handler", "stringsPage")
371
372 profile, err := s.profile(r)
373 if err != nil {
374 l.Error("failed to build profile card", "err", err)
375 s.pages.Error500(w)
376 return
377 }
378 l = l.With("profileDid", profile.UserDid)
379
380 strings, err := db.GetStrings(s.db, 0, orm.FilterEq("did", profile.UserDid))
381 if err != nil {
382 l.Error("failed to get strings", "err", err)
383 s.pages.Error500(w)
384 return
385 }
386
387 err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{
388 LoggedInUser: s.oauth.GetMultiAccountUser(r),
389 Strings: strings,
390 Card: profile,
391 })
392}
393
394func (s *State) vouchesPage(w http.ResponseWriter, r *http.Request) {
395 l := s.logger.With("handler", "vouchesPage")
396
397 profile, err := s.profile(r)
398 if err != nil {
399 l.Error("failed to build profile card", "err", err)
400 s.pages.Error500(w)
401 return
402 }
403 l = l.With("profileDid", profile.UserDid)
404
405 loggedInUser := s.oauth.GetMultiAccountUser(r)
406 page := pagination.FromContext(r.Context())
407
408 var vouches []models.Vouch
409 var vouchCount int
410 if loggedInUser != nil {
411 vouches, err = db.GetNetworkVouchTimeline(s.db, loggedInUser.Did, profile.UserDid, page)
412 if err != nil {
413 l.Error("failed to get vouch timeline", "err", err)
414 s.pages.Error500(w)
415 return
416 }
417 vouchCount, err = db.CountNetworkVouchTimeline(s.db, loggedInUser.Did, profile.UserDid)
418 if err != nil {
419 l.Error("failed to count vouch timeline", "err", err)
420 s.pages.Error500(w)
421 return
422 }
423 }
424
425 var suggestions []models.VouchSuggestion
426 if loggedInUser != nil && loggedInUser.Did == profile.UserDid {
427 suggestions, err = db.GetVouchSuggestions(s.db, profile.UserDid, 5)
428 if err != nil {
429 l.Error("failed to get vouch suggestions", "err", err)
430 }
431
432 if len(suggestions) > 0 {
433 suggestionDids := make([]syntax.DID, len(suggestions))
434 for i, s := range suggestions {
435 suggestionDids[i] = syntax.DID(s.Did)
436 }
437 relationships, err := db.GetVouchRelationshipsBatch(s.db, syntax.DID(loggedInUser.Did), suggestionDids)
438 if err != nil {
439 l.Error("failed to get vouch relationships for suggestions", "err", err)
440 } else {
441 for i := range suggestions {
442 suggestions[i].VouchRelationship = relationships[suggestions[i].Did]
443 }
444 }
445 }
446 }
447
448 var pullAts, issueAts []syntax.ATURI
449 for _, v := range vouches {
450 for _, ev := range v.Evidences {
451 switch ev.Collection().String() {
452 case tangled.RepoPullNSID:
453 pullAts = append(pullAts, ev)
454 case tangled.RepoIssueNSID:
455 issueAts = append(issueAts, ev)
456 }
457 }
458 }
459
460 evidencePulls := make(map[syntax.ATURI]*models.Pull)
461 if len(pullAts) > 0 {
462 pulls, err := db.GetPulls(s.db, orm.FilterIn("at_uri", pullAts))
463 if err != nil {
464 l.Error("failed to get evidence pulls", "err", err)
465 } else {
466 for _, p := range pulls {
467 evidencePulls[p.AtUri()] = p
468 }
469 }
470 }
471
472 evidenceIssues := make(map[syntax.ATURI]*models.Issue)
473 if len(issueAts) > 0 {
474 issues, err := db.GetIssues(s.db, orm.FilterIn("at_uri", issueAts))
475 if err != nil {
476 l.Error("failed to get evidence issues", "err", err)
477 } else {
478 for i := range issues {
479 evidenceIssues[issues[i].AtUri()] = &issues[i]
480 }
481 }
482 }
483
484 err = s.pages.ProfileVouches(w, pages.ProfileVouchesParams{
485 LoggedInUser: loggedInUser,
486 Vouches: vouches,
487 Suggestions: suggestions,
488 Card: profile,
489 Page: page,
490 VouchCount: vouchCount,
491 EvidencePulls: evidencePulls,
492 EvidenceIssues: evidenceIssues,
493 })
494 if err != nil {
495 l.Error("failed to render page", "err", err)
496 }
497}
498
499type FollowsPageParams struct {
500 Follows []pages.FollowCard
501 Card *pages.ProfileCard
502}
503
504func (s *State) followPage(
505 r *http.Request,
506 fetchFollows func(db.Execer, string) ([]models.Follow, error),
507 extractDid func(models.Follow) string,
508) (*FollowsPageParams, error) {
509 l := s.logger.With("handler", "reposPage")
510
511 profile, err := s.profile(r)
512 if err != nil {
513 return nil, err
514 }
515 l = l.With("profileDid", profile.UserDid)
516
517 loggedInUser := s.oauth.GetMultiAccountUser(r)
518 params := FollowsPageParams{
519 Card: profile,
520 }
521
522 follows, err := fetchFollows(s.db, profile.UserDid)
523 if err != nil {
524 l.Error("failed to fetch follows", "err", err)
525 return ¶ms, err
526 }
527
528 if len(follows) == 0 {
529 return ¶ms, nil
530 }
531
532 followDids := make([]string, 0, len(follows))
533 for _, follow := range follows {
534 followDids = append(followDids, extractDid(follow))
535 }
536
537 profiles, err := db.GetProfiles(s.db, orm.FilterIn("did", followDids))
538 if err != nil {
539 l.Error("failed to get profiles", "followDids", followDids, "err", err)
540 return ¶ms, err
541 }
542
543 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
544 if err != nil {
545 l.Error("getting follow counts", "followDids", followDids, "err", err)
546 }
547
548 loggedInUserFollowing := make(map[string]struct{})
549 if loggedInUser != nil {
550 following, err := db.GetFollowing(s.db, loggedInUser.Did)
551 if err != nil {
552 l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did)
553 return ¶ms, err
554 }
555 loggedInUserFollowing = make(map[string]struct{}, len(following))
556 for _, follow := range following {
557 loggedInUserFollowing[follow.SubjectDid] = struct{}{}
558 }
559 }
560
561 followCards := make([]pages.FollowCard, len(follows))
562 for i, did := range followDids {
563 followStats := followStatsMap[did]
564 followStatus := models.IsNotFollowing
565 if _, exists := loggedInUserFollowing[did]; exists {
566 followStatus = models.IsFollowing
567 } else if loggedInUser != nil && loggedInUser.Did == did {
568 followStatus = models.IsSelf
569 }
570
571 var profile *models.Profile
572 if p, exists := profiles[did]; exists {
573 profile = p
574 } else {
575 profile = &models.Profile{}
576 profile.Did = did
577 }
578 followCards[i] = pages.FollowCard{
579 LoggedInUser: loggedInUser,
580 UserDid: did,
581 FollowStatus: followStatus,
582 FollowersCount: followStats.Followers,
583 FollowingCount: followStats.Following,
584 Profile: profile,
585 }
586 }
587
588 params.Follows = followCards
589
590 return ¶ms, nil
591}
592
593func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
594 followPage, err := s.followPage(r, db.GetFollowers, func(f models.Follow) string { return f.UserDid })
595 if err != nil {
596 s.pages.Notice(w, "all-followers", "Failed to load followers")
597 return
598 }
599
600 s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{
601 LoggedInUser: s.oauth.GetMultiAccountUser(r),
602 Followers: followPage.Follows,
603 Card: followPage.Card,
604 })
605}
606
607func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
608 followPage, err := s.followPage(r, db.GetFollowing, func(f models.Follow) string { return f.SubjectDid })
609 if err != nil {
610 s.pages.Notice(w, "all-following", "Failed to load following")
611 return
612 }
613
614 s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{
615 LoggedInUser: s.oauth.GetMultiAccountUser(r),
616 Following: followPage.Follows,
617 Card: followPage.Card,
618 })
619}
620
621func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
622 ident, ok := r.Context().Value("resolvedId").(identity.Identity)
623 if !ok {
624 s.pages.Error404(w)
625 return
626 }
627
628 feed, err := s.getProfileFeed(r.Context(), &ident)
629 if err != nil {
630 s.pages.Error500(w)
631 return
632 }
633
634 if feed == nil {
635 return
636 }
637
638 atom, err := feed.ToAtom()
639 if err != nil {
640 s.pages.Error500(w)
641 return
642 }
643
644 w.Header().Set("content-type", "application/atom+xml")
645 w.Write([]byte(atom))
646}
647
648func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) {
649 timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
650 if err != nil {
651 return nil, err
652 }
653
654 author := &feeds.Author{
655 Name: fmt.Sprintf("@%s", id.Handle),
656 }
657
658 feed := feeds.Feed{
659 Title: fmt.Sprintf("%s's timeline", author.Name),
660 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.BaseUrl(), id.Handle), Type: "text/html", Rel: "alternate"},
661 Items: make([]*feeds.Item, 0),
662 Updated: time.UnixMilli(0),
663 Author: author,
664 }
665
666 for _, byMonth := range timeline.ByMonth {
667 if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil {
668 return nil, err
669 }
670 if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil {
671 return nil, err
672 }
673 if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil {
674 return nil, err
675 }
676 }
677
678 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
679 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
680 })
681
682 if len(feed.Items) > 0 {
683 feed.Updated = feed.Items[0].Created
684 }
685
686 return &feed, nil
687}
688
689func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error {
690 for _, pull := range pulls {
691 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
692 if err != nil {
693 return err
694 }
695
696 // Add pull request creation item
697 feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author))
698 }
699 return nil
700}
701
702func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*models.Issue, author *feeds.Author) error {
703 for _, issue := range issues {
704 owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did)
705 if err != nil {
706 return err
707 }
708
709 feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author))
710 }
711 return nil
712}
713
714func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []models.RepoEvent, author *feeds.Author) error {
715 for _, repo := range repos {
716 item, err := s.createRepoItem(ctx, repo, author)
717 if err != nil {
718 return err
719 }
720 feed.Items = append(feed.Items, item)
721 }
722 return nil
723}
724
725func (s *State) createPullRequestItem(pull *models.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
726 return &feeds.Item{
727 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
728 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.BaseUrl(), owner.Handle, pull.Repo.Rkey, pull.PullId), Type: "text/html", Rel: "alternate"},
729 Created: pull.Created,
730 Author: author,
731 }
732}
733
734func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
735 return &feeds.Item{
736 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name),
737 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.BaseUrl(), owner.Handle, issue.Repo.Rkey, issue.IssueId), Type: "text/html", Rel: "alternate"},
738 Created: issue.Created,
739 Author: author,
740 }
741}
742
743func (s *State) createRepoItem(ctx context.Context, repo models.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
744 var title string
745 if repo.Source != nil {
746 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
747 if err != nil {
748 return nil, err
749 }
750 title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name)
751 } else {
752 title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
753 }
754
755 return &feeds.Item{
756 Title: title,
757 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.BaseUrl(), author.Name[1:], repo.Repo.Rkey), Type: "text/html", Rel: "alternate"}, // Remove @ prefix
758 Created: repo.Repo.Created,
759 Author: author,
760 }, nil
761}
762
763func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
764 l := s.logger.With("handler", "UpdateProfileBio")
765 user := s.oauth.GetMultiAccountUser(r)
766
767 err := r.ParseForm()
768 if err != nil {
769 l.Error("invalid profile update form", "err", err)
770 s.pages.Notice(w, "update-profile", "Invalid form.")
771 return
772 }
773
774 profile, err := db.GetProfile(s.db, user.Did)
775 if err != nil {
776 l.Error("getting profile data", "did", user.Did, "err", err)
777 }
778 if profile == nil {
779 profile = &models.Profile{Did: user.Did}
780 }
781
782 profile.Description = r.FormValue("description")
783 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
784 profile.Location = r.FormValue("location")
785 profile.Pronouns = r.FormValue("pronouns")
786 rawPreferredHandle := strings.TrimSpace(r.FormValue("preferredHandle"))
787 if rawPreferredHandle != "" {
788 h, err := syntax.ParseHandle(rawPreferredHandle)
789 if err != nil {
790 s.pages.Notice(w, "update-profile", "Invalid handle format.")
791 return
792 }
793
794 ident, err := s.idResolver.ResolveIdent(r.Context(), user.Did)
795 if err != nil || !slices.Contains(ident.AlsoKnownAs, "at://"+rawPreferredHandle) {
796 s.pages.Notice(w, "update-profile", "Handle not found in your DID document.")
797 return
798 }
799 profile.PreferredHandle = h
800 } else {
801 profile.PreferredHandle = ""
802 }
803
804 var links [5]string
805 for i := range 5 {
806 iLink := r.FormValue(fmt.Sprintf("link%d", i))
807 links[i] = iLink
808 }
809 profile.Links = links
810
811 // Parse stats (exactly 2)
812 stat0 := r.FormValue("stat0")
813 stat1 := r.FormValue("stat1")
814
815 profile.Stats[0].Kind = models.ParseVanityStatKind(stat0)
816 profile.Stats[1].Kind = models.ParseVanityStatKind(stat1)
817
818 if err := db.ValidateProfile(s.db, profile); err != nil {
819 l.Error("invalid profile", "err", err)
820 s.pages.Notice(w, "update-profile", err.Error())
821 return
822 }
823
824 s.updateProfile(profile, w, r)
825}
826
827func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
828 l := s.logger.With("handler", "UpdateProfilePins")
829 user := s.oauth.GetMultiAccountUser(r)
830
831 err := r.ParseForm()
832 if err != nil {
833 l.Error("invalid profile update form", "err", err)
834 s.pages.Notice(w, "update-profile", "Invalid form.")
835 return
836 }
837
838 profile, err := db.GetProfile(s.db, user.Did)
839 if err != nil {
840 l.Error("getting profile data", "did", user.Did, "err", err)
841 }
842 if profile == nil {
843 profile = &models.Profile{Did: user.Did}
844 }
845
846 i := 0
847 var pinnedRepos [6]string
848 for key, values := range r.Form {
849 if i >= 6 {
850 l.Warn("too many pinned repos")
851 s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.")
852 return
853 }
854 if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 {
855 pinnedRepos[i] = values[0]
856 i++
857 }
858 }
859 profile.PinnedRepos = pinnedRepos
860
861 s.updateProfile(profile, w, r)
862}
863
864func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) {
865 l := s.logger.With("handler", "updateProfile")
866 user := s.oauth.GetMultiAccountUser(r)
867
868 client, err := s.oauth.AuthorizedClient(r)
869 if err != nil {
870 l.Error("failed to get authorized client", "err", err)
871 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
872 return
873 }
874
875 var pinnedRepoStrings []string
876 for _, r := range profile.PinnedRepos {
877 if r != "" {
878 pinnedRepoStrings = append(pinnedRepoStrings, r)
879 }
880 }
881
882 var vanityStats []string
883 for _, v := range profile.Stats {
884 vanityStats = append(vanityStats, string(v.Kind))
885 }
886
887 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
888 var cid *string
889 var existingAvatar *lexutil.LexBlob
890 if ex != nil {
891 cid = ex.Cid
892 if rec, ok := ex.Value.Val.(*tangled.ActorProfile); ok {
893 existingAvatar = rec.Avatar
894 }
895 }
896
897 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
898 Collection: tangled.ActorProfileNSID,
899 Repo: user.Did,
900 Rkey: "self",
901 Record: &lexutil.LexiconTypeDecoder{
902 Val: &tangled.ActorProfile{
903 Avatar: existingAvatar,
904 Bluesky: profile.IncludeBluesky,
905 Description: &profile.Description,
906 Links: profile.Links[:],
907 Location: &profile.Location,
908 PinnedRepositories: pinnedRepoStrings,
909 Stats: vanityStats[:],
910 Pronouns: &profile.Pronouns,
911 PreferredHandle: (*string)(&profile.PreferredHandle),
912 }},
913 SwapRecord: cid,
914 })
915 if err != nil {
916 l.Error("failed to update profile on PDS", "err", err)
917 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.")
918 return
919 }
920
921 tx, err := s.db.BeginTx(r.Context(), nil)
922 if err != nil {
923 l.Error("failed to start transaction", "err", err)
924 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
925 return
926 }
927
928 if err := db.UpsertProfile(tx, profile); err != nil {
929 l.Error("failed to update profile in DB", "err", err)
930 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
931 return
932 }
933
934 if s.rdb != nil {
935 ctx := r.Context()
936 pipe := s.rdb.Pipeline()
937 didKey := fmt.Sprintf(cache.PreferredHandleByDid, profile.Did)
938 if profile.PreferredHandle != "" {
939 pipe.Set(ctx, didKey, string(profile.PreferredHandle), cache.PreferredHandleTTL)
940 pipe.Set(ctx, fmt.Sprintf(cache.PreferredHandleByHandle, string(profile.PreferredHandle)), profile.Did, cache.PreferredHandleTTL)
941 } else {
942 pipe.Del(ctx, didKey)
943 }
944 if _, execErr := pipe.Exec(ctx); execErr != nil {
945 l.Warn("failed to update preferred handle cache", "err", execErr)
946 }
947 }
948
949 s.notifier.UpdateProfile(r.Context(), profile)
950
951 s.pages.HxRedirect(w, "/"+user.Did)
952}
953
954func (s *State) ProfilePopover(w http.ResponseWriter, r *http.Request) {
955 l := s.logger.With("handler", "ProfilePopover")
956
957 did := r.URL.Query().Get("did")
958 if did == "" {
959 l.Warn("missing did param")
960 w.WriteHeader(http.StatusBadRequest)
961 return
962 }
963
964 profile, err := db.GetProfile(s.db, did)
965 if err != nil {
966 l.Error("failed to get profile", "did", did, "err", err)
967 w.WriteHeader(http.StatusInternalServerError)
968 return
969 }
970 if profile == nil {
971 profile = &models.Profile{Did: did}
972 }
973
974 followStats, err := db.GetFollowerFollowingCount(s.db, did)
975 if err != nil {
976 l.Error("failed to get follower stats", "did", did, "err", err)
977 w.WriteHeader(http.StatusInternalServerError)
978 return
979 }
980
981 loggedInUser := s.oauth.GetMultiAccountUser(r)
982 followStatus := models.IsNotFollowing
983 var vouchRelationship *models.VouchRelationship
984
985 if loggedInUser != nil {
986 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
987 vouchRelationship, _ = db.GetVouchRelationship(s.db, syntax.DID(loggedInUser.Did), syntax.DID(did))
988 }
989
990 s.pages.ProfilePopoverFragment(w, pages.ProfilePopoverParams{
991 LoggedInUser: loggedInUser,
992 UserDid: did,
993 Profile: profile,
994 FollowStatus: followStatus,
995 VouchRelationship: vouchRelationship,
996 Stats: pages.ProfilePopoverStats{
997 FollowersCount: followStats.Followers,
998 FollowingCount: followStats.Following,
999 },
1000 })
1001}
1002
1003func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
1004 l := s.logger.With("handler", "EditBioFragment")
1005 user := s.oauth.GetMultiAccountUser(r)
1006
1007 profile, err := db.GetProfile(s.db, user.Did)
1008 if err != nil {
1009 l.Error("getting profile data", "did", user.Did, "err", err)
1010 }
1011 if profile == nil {
1012 profile = &models.Profile{Did: user.Did}
1013 }
1014
1015 var alsoKnownAs []string
1016 ident, err := s.idResolver.ResolveIdent(r.Context(), user.Did)
1017 if err == nil {
1018 alsoKnownAs = ident.AlsoKnownAs
1019 }
1020
1021 s.pages.EditBioFragment(w, pages.EditBioParams{
1022 LoggedInUser: user,
1023 Profile: profile,
1024 AlsoKnownAs: alsoKnownAs,
1025 })
1026}
1027
1028func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) {
1029 l := s.logger.With("handler", "EditPinsFragment")
1030 user := s.oauth.GetMultiAccountUser(r)
1031
1032 profile, err := db.GetProfile(s.db, user.Did)
1033 if err != nil {
1034 l.Error("getting profile data", "did", user.Did, "err", err)
1035 }
1036 if profile == nil {
1037 profile = &models.Profile{Did: user.Did}
1038 }
1039
1040 repos, err := db.GetRepos(s.db, orm.FilterEq("did", user.Did))
1041 if err != nil {
1042 l.Error("getting repos", "did", user.Did, "err", err)
1043 }
1044
1045 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did)
1046 if err != nil {
1047 l.Error("getting collaborating repos", "did", user.Did, "err", err)
1048 }
1049
1050 allRepos := []pages.PinnedRepo{}
1051
1052 for _, r := range repos {
1053 allRepos = append(allRepos, pages.PinnedRepo{
1054 IsPinned: profile.MatchesPinnedRepo(r),
1055 Repo: r,
1056 })
1057 }
1058 for _, r := range collaboratingRepos {
1059 allRepos = append(allRepos, pages.PinnedRepo{
1060 IsPinned: profile.MatchesPinnedRepo(r),
1061 Repo: r,
1062 })
1063 }
1064
1065 s.pages.EditPinsFragment(w, pages.EditPinsParams{
1066 LoggedInUser: user,
1067 Profile: profile,
1068 AllRepos: allRepos,
1069 })
1070}
1071
1072func (s *State) UploadProfileAvatar(w http.ResponseWriter, r *http.Request) {
1073 l := s.logger.With("handler", "UploadProfileAvatar")
1074 user := s.oauth.GetMultiAccountUser(r)
1075 l = l.With("did", user.Did)
1076
1077 // Parse multipart form (10MB max)
1078 if err := r.ParseMultipartForm(10 << 20); err != nil {
1079 l.Error("failed to parse form", "err", err)
1080 s.pages.Notice(w, "avatar-error", "Failed to parse form")
1081 return
1082 }
1083
1084 file, header, err := r.FormFile("avatar")
1085 if err != nil {
1086 l.Error("failed to read avatar file", "err", err)
1087 s.pages.Notice(w, "avatar-error", "Failed to read avatar file")
1088 return
1089 }
1090 defer file.Close()
1091
1092 if header.Size > 5000000 {
1093 l.Warn("avatar file too large", "size", header.Size)
1094 s.pages.Notice(w, "avatar-error", "Avatar file too large (max 5MB)")
1095 return
1096 }
1097
1098 contentType := header.Header.Get("Content-Type")
1099 if contentType != "image/png" && contentType != "image/jpeg" {
1100 l.Warn("invalid image type", "contentType", contentType)
1101 s.pages.Notice(w, "avatar-error", "Invalid image type (only PNG and JPEG allowed)")
1102 return
1103 }
1104
1105 client, err := s.oauth.AuthorizedClient(r)
1106 if err != nil {
1107 l.Error("failed to get PDS client", "err", err)
1108 s.pages.Notice(w, "avatar-error", "Failed to connect to your PDS")
1109 return
1110 }
1111
1112 uploadBlobResp, err := xrpc.RepoUploadBlob(r.Context(), client, file, header.Header.Get("Content-Type"))
1113 if err != nil {
1114 l.Error("failed to upload avatar blob", "err", err)
1115 s.pages.Notice(w, "avatar-error", "Failed to upload avatar to your PDS")
1116 return
1117 }
1118
1119 l.Info("uploaded avatar blob", "cid", uploadBlobResp.Blob.Ref.String())
1120
1121 // get current profile record from PDS to get its CID for swap
1122 getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
1123 if err != nil {
1124 l.Error("failed to get current profile record", "err", err)
1125 s.pages.Notice(w, "avatar-error", "Failed to get current profile from your PDS")
1126 return
1127 }
1128
1129 var profileRecord *tangled.ActorProfile
1130 if getRecordResp.Value != nil {
1131 if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok {
1132 profileRecord = val
1133 } else {
1134 l.Warn("profile record type assertion failed, creating new record")
1135 profileRecord = &tangled.ActorProfile{}
1136 }
1137 } else {
1138 l.Warn("no existing profile record, creating new record")
1139 profileRecord = &tangled.ActorProfile{}
1140 }
1141
1142 profileRecord.Avatar = uploadBlobResp.Blob
1143
1144 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1145 Collection: tangled.ActorProfileNSID,
1146 Repo: user.Did,
1147 Rkey: "self",
1148 Record: &lexutil.LexiconTypeDecoder{Val: profileRecord},
1149 SwapRecord: getRecordResp.Cid,
1150 })
1151
1152 if err != nil {
1153 l.Error("failed to update profile record", "err", err)
1154 s.pages.Notice(w, "avatar-error", "Failed to update profile on your PDS")
1155 return
1156 }
1157
1158 l.Info("successfully updated profile with avatar")
1159
1160 profile, err := db.GetProfile(s.db, user.Did)
1161 if err != nil {
1162 l.Warn("getting profile data from DB", "err", err)
1163 }
1164 if profile == nil {
1165 profile = &models.Profile{Did: user.Did}
1166 }
1167 profile.Avatar = uploadBlobResp.Blob.Ref.String()
1168
1169 tx, err := s.db.BeginTx(r.Context(), nil)
1170 if err != nil {
1171 l.Error("failed to start transaction", "err", err)
1172 s.pages.HxRefresh(w)
1173 w.WriteHeader(http.StatusOK)
1174 return
1175 }
1176
1177 err = db.UpsertProfile(tx, profile)
1178 if err != nil {
1179 l.Error("failed to update profile in DB", "err", err)
1180 s.pages.HxRefresh(w)
1181 w.WriteHeader(http.StatusOK)
1182 return
1183 }
1184
1185 s.pages.HxRedirect(w, r.Header.Get("Referer"))
1186}
1187
1188func (s *State) RemoveProfileAvatar(w http.ResponseWriter, r *http.Request) {
1189 l := s.logger.With("handler", "RemoveProfileAvatar")
1190 user := s.oauth.GetMultiAccountUser(r)
1191 l = l.With("did", user.Did)
1192
1193 client, err := s.oauth.AuthorizedClient(r)
1194 if err != nil {
1195 l.Error("failed to get PDS client", "err", err)
1196 s.pages.Notice(w, "avatar-error", "Failed to connect to your PDS")
1197 return
1198 }
1199
1200 getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
1201 if err != nil {
1202 l.Error("failed to get current profile record", "err", err)
1203 s.pages.Notice(w, "avatar-error", "Failed to get current profile from your PDS")
1204 return
1205 }
1206
1207 var profileRecord *tangled.ActorProfile
1208 if getRecordResp.Value != nil {
1209 if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok {
1210 profileRecord = val
1211 } else {
1212 l.Warn("profile record type assertion failed")
1213 profileRecord = &tangled.ActorProfile{}
1214 }
1215 } else {
1216 l.Warn("no existing profile record")
1217 profileRecord = &tangled.ActorProfile{}
1218 }
1219
1220 profileRecord.Avatar = nil
1221
1222 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1223 Collection: tangled.ActorProfileNSID,
1224 Repo: user.Did,
1225 Rkey: "self",
1226 Record: &lexutil.LexiconTypeDecoder{Val: profileRecord},
1227 SwapRecord: getRecordResp.Cid,
1228 })
1229
1230 if err != nil {
1231 l.Error("failed to update profile record", "err", err)
1232 s.pages.Notice(w, "avatar-error", "Failed to remove avatar from your PDS")
1233 return
1234 }
1235
1236 l.Info("successfully removed avatar from PDS")
1237
1238 profile, err := db.GetProfile(s.db, user.Did)
1239 if err != nil {
1240 l.Warn("getting profile data from DB", "err", err)
1241 }
1242 if profile == nil {
1243 profile = &models.Profile{Did: user.Did}
1244 }
1245 profile.Avatar = ""
1246
1247 tx, err := s.db.BeginTx(r.Context(), nil)
1248 if err != nil {
1249 l.Error("failed to start transaction", "err", err)
1250 s.pages.HxRefresh(w)
1251 w.WriteHeader(http.StatusOK)
1252 return
1253 }
1254
1255 err = db.UpsertProfile(tx, profile)
1256 if err != nil {
1257 l.Error("failed to update profile in DB", "err", err)
1258 s.pages.HxRefresh(w)
1259 w.WriteHeader(http.StatusOK)
1260 return
1261 }
1262
1263 s.pages.HxRedirect(w, r.Header.Get("Referer"))
1264}
1265
1266func (s *State) UpdateProfilePunchcardSetting(w http.ResponseWriter, r *http.Request) {
1267 l := s.logger.With("handler", "UpdateProfilePunchcardSetting")
1268 err := r.ParseForm()
1269 if err != nil {
1270 l.Error("invalid profile update form", "err", err)
1271 return
1272 }
1273 user := s.oauth.GetMultiAccountUser(r)
1274
1275 hideOthers := false
1276 hideMine := false
1277
1278 if r.Form.Get("hideMine") == "on" {
1279 hideMine = true
1280 }
1281 if r.Form.Get("hideOthers") == "on" {
1282 hideOthers = true
1283 }
1284
1285 err = db.UpsertPunchcardPreference(s.db, user.Did, hideMine, hideOthers)
1286 if err != nil {
1287 l.Error("failed to update punchcard preferences", "err", err)
1288 return
1289 }
1290
1291 s.pages.HxRefresh(w)
1292}