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 BaseParams: pages.BaseParamsFromContext(r.Context()),
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 BaseParams: pages.BaseParamsFromContext(r.Context()),
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 BaseParams: pages.BaseParamsFromContext(r.Context()),
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 BaseParams: pages.BaseParamsFromContext(r.Context()),
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 BaseParams: pages.BaseParamsFromContext(r.Context()),
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 BaseParams: pages.BaseParamsFromContext(r.Context()),
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 BaseParams: pages.BaseParamsFromContext(r.Context()),
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 BaseParams: pages.BaseParamsFromContext(r.Context()),
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 tx, err := s.db.BeginTx(r.Context(), nil)
888 if err != nil {
889 l.Error("failed to start transaction", "err", err)
890 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
891 return
892 }
893 defer tx.Rollback()
894
895 err = db.UpsertProfile(tx, profile)
896 if err != nil {
897 l.Error("failed to update profile", "err", err)
898 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
899 return
900 }
901
902 client, err := s.oauth.AuthorizedClient(r)
903 if err != nil {
904 l.Error("failed to get authorized client", "err", err)
905 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
906 return
907 }
908
909 var pinnedRepoStrings []string
910 for _, r := range profile.PinnedRepos {
911 if r != "" {
912 pinnedRepoStrings = append(pinnedRepoStrings, r)
913 }
914 }
915
916 var vanityStats []string
917 for _, v := range profile.Stats {
918 vanityStats = append(vanityStats, string(v.Kind))
919 }
920
921 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
922 var cid *string
923 var existingAvatar *lexutil.LexBlob
924 if ex != nil {
925 cid = ex.Cid
926 if rec, ok := ex.Value.Val.(*tangled.ActorProfile); ok {
927 existingAvatar = rec.Avatar
928 }
929 }
930
931 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
932 Collection: tangled.ActorProfileNSID,
933 Repo: user.Did,
934 Rkey: "self",
935 Record: &lexutil.LexiconTypeDecoder{
936 Val: &tangled.ActorProfile{
937 Avatar: existingAvatar,
938 Bluesky: profile.IncludeBluesky,
939 Description: &profile.Description,
940 Links: profile.Links[:],
941 Location: &profile.Location,
942 PinnedRepositories: pinnedRepoStrings,
943 Stats: vanityStats[:],
944 Pronouns: &profile.Pronouns,
945 PreferredHandle: (*string)(&profile.PreferredHandle),
946 }},
947 SwapRecord: cid,
948 })
949 if err != nil {
950 l.Error("failed to update profile on PDS", "err", err)
951 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.")
952 return
953 }
954
955 if err := tx.Commit(); err != nil {
956 // db failed, but PDS operation succeed.
957 // log error and continue
958 l.Error("failed to update profile in DB", "err", err)
959 }
960
961 if s.rdb != nil {
962 ctx := r.Context()
963 pipe := s.rdb.Pipeline()
964 didKey := fmt.Sprintf(cache.PreferredHandleByDid, profile.Did)
965 if profile.PreferredHandle != "" {
966 pipe.Set(ctx, didKey, string(profile.PreferredHandle), cache.PreferredHandleTTL)
967 pipe.Set(ctx, fmt.Sprintf(cache.PreferredHandleByHandle, string(profile.PreferredHandle)), profile.Did, cache.PreferredHandleTTL)
968 } else {
969 pipe.Del(ctx, didKey)
970 }
971 if _, execErr := pipe.Exec(ctx); execErr != nil {
972 l.Warn("failed to update preferred handle cache", "err", execErr)
973 }
974 }
975
976 s.notifier.UpdateProfile(r.Context(), profile)
977
978 s.pages.HxRedirect(w, "/"+user.Did)
979}
980
981func (s *State) ProfilePopover(w http.ResponseWriter, r *http.Request) {
982 l := s.logger.With("handler", "ProfilePopover")
983
984 did := r.URL.Query().Get("did")
985 if did == "" {
986 l.Warn("missing did param")
987 w.WriteHeader(http.StatusBadRequest)
988 return
989 }
990
991 profile, err := db.GetProfile(s.db, did)
992 if err != nil {
993 l.Error("failed to get profile", "did", did, "err", err)
994 w.WriteHeader(http.StatusInternalServerError)
995 return
996 }
997 if profile == nil {
998 profile = &models.Profile{Did: did}
999 }
1000
1001 followStats, err := db.GetFollowerFollowingCount(s.db, did)
1002 if err != nil {
1003 l.Error("failed to get follower stats", "did", did, "err", err)
1004 w.WriteHeader(http.StatusInternalServerError)
1005 return
1006 }
1007
1008 loggedInUser := s.oauth.GetMultiAccountUser(r)
1009 followStatus := models.IsNotFollowing
1010 var vouchRelationship *models.VouchRelationship
1011
1012 if loggedInUser != nil {
1013 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
1014 vouchRelationship, _ = db.GetVouchRelationship(s.db, syntax.DID(loggedInUser.Did), syntax.DID(did))
1015 }
1016
1017 s.pages.ProfilePopoverFragment(w, pages.ProfilePopoverParams{
1018 BaseParams: pages.BaseParamsFromContext(r.Context()),
1019 UserDid: did,
1020 Profile: profile,
1021 FollowStatus: followStatus,
1022 VouchRelationship: vouchRelationship,
1023 Stats: pages.ProfilePopoverStats{
1024 FollowersCount: followStats.Followers,
1025 FollowingCount: followStats.Following,
1026 },
1027 })
1028}
1029
1030func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
1031 l := s.logger.With("handler", "EditBioFragment")
1032 user := s.oauth.GetMultiAccountUser(r)
1033
1034 profile, err := db.GetProfile(s.db, user.Did)
1035 if err != nil {
1036 l.Error("getting profile data", "did", user.Did, "err", err)
1037 }
1038 if profile == nil {
1039 profile = &models.Profile{Did: user.Did}
1040 }
1041
1042 var alsoKnownAs []string
1043 ident, err := s.idResolver.ResolveIdent(r.Context(), user.Did)
1044 if err == nil {
1045 alsoKnownAs = ident.AlsoKnownAs
1046 }
1047
1048 s.pages.EditBioFragment(w, pages.EditBioParams{
1049 BaseParams: pages.BaseParamsFromContext(r.Context()),
1050 Profile: profile,
1051 AlsoKnownAs: alsoKnownAs,
1052 })
1053}
1054
1055func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) {
1056 l := s.logger.With("handler", "EditPinsFragment")
1057 user := s.oauth.GetMultiAccountUser(r)
1058
1059 profile, err := db.GetProfile(s.db, user.Did)
1060 if err != nil {
1061 l.Error("getting profile data", "did", user.Did, "err", err)
1062 }
1063 if profile == nil {
1064 profile = &models.Profile{Did: user.Did}
1065 }
1066
1067 repos, err := db.GetRepos(s.db, orm.FilterEq("did", user.Did))
1068 if err != nil {
1069 l.Error("getting repos", "did", user.Did, "err", err)
1070 }
1071
1072 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did)
1073 if err != nil {
1074 l.Error("getting collaborating repos", "did", user.Did, "err", err)
1075 }
1076
1077 allRepos := []pages.PinnedRepo{}
1078
1079 for _, r := range repos {
1080 allRepos = append(allRepos, pages.PinnedRepo{
1081 IsPinned: profile.MatchesPinnedRepo(r),
1082 Repo: r,
1083 })
1084 }
1085 for _, r := range collaboratingRepos {
1086 allRepos = append(allRepos, pages.PinnedRepo{
1087 IsPinned: profile.MatchesPinnedRepo(r),
1088 Repo: r,
1089 })
1090 }
1091
1092 s.pages.EditPinsFragment(w, pages.EditPinsParams{
1093 BaseParams: pages.BaseParamsFromContext(r.Context()),
1094 Profile: profile,
1095 AllRepos: allRepos,
1096 })
1097}
1098
1099func (s *State) UploadProfileAvatar(w http.ResponseWriter, r *http.Request) {
1100 l := s.logger.With("handler", "UploadProfileAvatar")
1101 user := s.oauth.GetMultiAccountUser(r)
1102 l = l.With("did", user.Did)
1103
1104 // Parse multipart form (10MB max)
1105 if err := r.ParseMultipartForm(10 << 20); err != nil {
1106 l.Error("failed to parse form", "err", err)
1107 s.pages.Notice(w, "avatar-error", "Failed to parse form")
1108 return
1109 }
1110
1111 file, header, err := r.FormFile("avatar")
1112 if err != nil {
1113 l.Error("failed to read avatar file", "err", err)
1114 s.pages.Notice(w, "avatar-error", "Failed to read avatar file")
1115 return
1116 }
1117 defer file.Close()
1118
1119 if header.Size > 5000000 {
1120 l.Warn("avatar file too large", "size", header.Size)
1121 s.pages.Notice(w, "avatar-error", "Avatar file too large (max 5MB)")
1122 return
1123 }
1124
1125 contentType := header.Header.Get("Content-Type")
1126 if contentType != "image/png" && contentType != "image/jpeg" {
1127 l.Warn("invalid image type", "contentType", contentType)
1128 s.pages.Notice(w, "avatar-error", "Invalid image type (only PNG and JPEG allowed)")
1129 return
1130 }
1131
1132 client, err := s.oauth.AuthorizedClient(r)
1133 if err != nil {
1134 l.Error("failed to get PDS client", "err", err)
1135 s.pages.Notice(w, "avatar-error", "Failed to connect to your PDS")
1136 return
1137 }
1138
1139 uploadBlobResp, err := xrpc.RepoUploadBlob(r.Context(), client, file, header.Header.Get("Content-Type"))
1140 if err != nil {
1141 l.Error("failed to upload avatar blob", "err", err)
1142 s.pages.Notice(w, "avatar-error", "Failed to upload avatar to your PDS")
1143 return
1144 }
1145
1146 l.Info("uploaded avatar blob", "cid", uploadBlobResp.Blob.Ref.String())
1147
1148 // get current profile record from PDS to get its CID for swap
1149 getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
1150 if err != nil {
1151 l.Error("failed to get current profile record", "err", err)
1152 s.pages.Notice(w, "avatar-error", "Failed to get current profile from your PDS")
1153 return
1154 }
1155
1156 var profileRecord *tangled.ActorProfile
1157 if getRecordResp.Value != nil {
1158 if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok {
1159 profileRecord = val
1160 } else {
1161 l.Warn("profile record type assertion failed, creating new record")
1162 profileRecord = &tangled.ActorProfile{}
1163 }
1164 } else {
1165 l.Warn("no existing profile record, creating new record")
1166 profileRecord = &tangled.ActorProfile{}
1167 }
1168
1169 profileRecord.Avatar = uploadBlobResp.Blob
1170
1171 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1172 Collection: tangled.ActorProfileNSID,
1173 Repo: user.Did,
1174 Rkey: "self",
1175 Record: &lexutil.LexiconTypeDecoder{Val: profileRecord},
1176 SwapRecord: getRecordResp.Cid,
1177 })
1178
1179 if err != nil {
1180 l.Error("failed to update profile record", "err", err)
1181 s.pages.Notice(w, "avatar-error", "Failed to update profile on your PDS")
1182 return
1183 }
1184
1185 l.Info("successfully updated profile with avatar")
1186
1187 profile, err := db.GetProfile(s.db, user.Did)
1188 if err != nil {
1189 l.Warn("getting profile data from DB", "err", err)
1190 }
1191 if profile == nil {
1192 profile = &models.Profile{Did: user.Did}
1193 }
1194 profile.Avatar = uploadBlobResp.Blob.Ref.String()
1195
1196 tx, err := s.db.BeginTx(r.Context(), nil)
1197 if err != nil {
1198 l.Error("failed to start transaction", "err", err)
1199 s.pages.HxRefresh(w)
1200 w.WriteHeader(http.StatusOK)
1201 return
1202 }
1203
1204 err = db.UpsertProfile(tx, profile)
1205 if err != nil {
1206 l.Error("failed to update profile in DB", "err", err)
1207 s.pages.HxRefresh(w)
1208 w.WriteHeader(http.StatusOK)
1209 return
1210 }
1211
1212 s.pages.HxRedirect(w, r.Header.Get("Referer"))
1213}
1214
1215func (s *State) RemoveProfileAvatar(w http.ResponseWriter, r *http.Request) {
1216 l := s.logger.With("handler", "RemoveProfileAvatar")
1217 user := s.oauth.GetMultiAccountUser(r)
1218 l = l.With("did", user.Did)
1219
1220 client, err := s.oauth.AuthorizedClient(r)
1221 if err != nil {
1222 l.Error("failed to get PDS client", "err", err)
1223 s.pages.Notice(w, "avatar-error", "Failed to connect to your PDS")
1224 return
1225 }
1226
1227 getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
1228 if err != nil {
1229 l.Error("failed to get current profile record", "err", err)
1230 s.pages.Notice(w, "avatar-error", "Failed to get current profile from your PDS")
1231 return
1232 }
1233
1234 var profileRecord *tangled.ActorProfile
1235 if getRecordResp.Value != nil {
1236 if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok {
1237 profileRecord = val
1238 } else {
1239 l.Warn("profile record type assertion failed")
1240 profileRecord = &tangled.ActorProfile{}
1241 }
1242 } else {
1243 l.Warn("no existing profile record")
1244 profileRecord = &tangled.ActorProfile{}
1245 }
1246
1247 profileRecord.Avatar = nil
1248
1249 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1250 Collection: tangled.ActorProfileNSID,
1251 Repo: user.Did,
1252 Rkey: "self",
1253 Record: &lexutil.LexiconTypeDecoder{Val: profileRecord},
1254 SwapRecord: getRecordResp.Cid,
1255 })
1256
1257 if err != nil {
1258 l.Error("failed to update profile record", "err", err)
1259 s.pages.Notice(w, "avatar-error", "Failed to remove avatar from your PDS")
1260 return
1261 }
1262
1263 l.Info("successfully removed avatar from PDS")
1264
1265 profile, err := db.GetProfile(s.db, user.Did)
1266 if err != nil {
1267 l.Warn("getting profile data from DB", "err", err)
1268 }
1269 if profile == nil {
1270 profile = &models.Profile{Did: user.Did}
1271 }
1272 profile.Avatar = ""
1273
1274 tx, err := s.db.BeginTx(r.Context(), nil)
1275 if err != nil {
1276 l.Error("failed to start transaction", "err", err)
1277 s.pages.HxRefresh(w)
1278 w.WriteHeader(http.StatusOK)
1279 return
1280 }
1281
1282 err = db.UpsertProfile(tx, profile)
1283 if err != nil {
1284 l.Error("failed to update profile in DB", "err", err)
1285 s.pages.HxRefresh(w)
1286 w.WriteHeader(http.StatusOK)
1287 return
1288 }
1289
1290 s.pages.HxRedirect(w, r.Header.Get("Referer"))
1291}
1292
1293func (s *State) UpdateProfilePunchcardSetting(w http.ResponseWriter, r *http.Request) {
1294 l := s.logger.With("handler", "UpdateProfilePunchcardSetting")
1295 err := r.ParseForm()
1296 if err != nil {
1297 l.Error("invalid profile update form", "err", err)
1298 return
1299 }
1300 user := s.oauth.GetMultiAccountUser(r)
1301
1302 hideOthers := false
1303 hideMine := false
1304
1305 if r.Form.Get("hideMine") == "on" {
1306 hideMine = true
1307 }
1308 if r.Form.Get("hideOthers") == "on" {
1309 hideOthers = true
1310 }
1311
1312 err = db.UpsertPunchcardPreference(s.db, user.Did, hideMine, hideOthers)
1313 if err != nil {
1314 l.Error("failed to update punchcard preferences", "err", err)
1315 return
1316 }
1317
1318 s.pages.HxRefresh(w)
1319}