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