This repository has no description
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 if loggedInUser != nil {
410 vouches, err = db.GetNetworkVouchTimeline(s.db, loggedInUser.Did, profile.UserDid, page)
411 if err != nil {
412 l.Error("failed to get vouch timeline", "err", err)
413 s.pages.Error500(w)
414 return
415 }
416 }
417
418 var suggestions []models.VouchSuggestion
419 if loggedInUser != nil && loggedInUser.Did == profile.UserDid {
420 suggestions, err = db.GetVouchSuggestions(s.db, profile.UserDid, 5)
421 if err != nil {
422 l.Error("failed to get vouch suggestions", "err", err)
423 }
424
425 if len(suggestions) > 0 {
426 suggestionDids := make([]syntax.DID, len(suggestions))
427 for i, s := range suggestions {
428 suggestionDids[i] = syntax.DID(s.Did)
429 }
430 relationships, err := db.GetVouchRelationshipsBatch(s.db, syntax.DID(loggedInUser.Did), suggestionDids)
431 if err != nil {
432 l.Error("failed to get vouch relationships for suggestions", "err", err)
433 } else {
434 for i := range suggestions {
435 suggestions[i].VouchRelationship = relationships[suggestions[i].Did]
436 }
437 }
438 }
439 }
440
441 var pullAts, issueAts []syntax.ATURI
442 for _, v := range vouches {
443 for _, ev := range v.Evidences {
444 switch ev.Collection().String() {
445 case tangled.RepoPullNSID:
446 pullAts = append(pullAts, ev)
447 case tangled.RepoIssueNSID:
448 issueAts = append(issueAts, ev)
449 }
450 }
451 }
452
453 evidencePulls := make(map[syntax.ATURI]*models.Pull)
454 if len(pullAts) > 0 {
455 pulls, err := db.GetPulls(s.db, orm.FilterIn("at_uri", pullAts))
456 if err != nil {
457 l.Error("failed to get evidence pulls", "err", err)
458 } else {
459 for _, p := range pulls {
460 evidencePulls[p.AtUri()] = p
461 }
462 }
463 }
464
465 evidenceIssues := make(map[syntax.ATURI]*models.Issue)
466 if len(issueAts) > 0 {
467 issues, err := db.GetIssues(s.db, orm.FilterIn("at_uri", issueAts))
468 if err != nil {
469 l.Error("failed to get evidence issues", "err", err)
470 } else {
471 for i := range issues {
472 evidenceIssues[issues[i].AtUri()] = &issues[i]
473 }
474 }
475 }
476
477 err = s.pages.ProfileVouches(w, pages.ProfileVouchesParams{
478 LoggedInUser: loggedInUser,
479 Vouches: vouches,
480 Suggestions: suggestions,
481 Card: profile,
482 Page: page,
483 EvidencePulls: evidencePulls,
484 EvidenceIssues: evidenceIssues,
485 })
486 if err != nil {
487 l.Error("failed to render page", "err", err)
488 }
489}
490
491type FollowsPageParams struct {
492 Follows []pages.FollowCard
493 Card *pages.ProfileCard
494}
495
496func (s *State) followPage(
497 r *http.Request,
498 fetchFollows func(db.Execer, string) ([]models.Follow, error),
499 extractDid func(models.Follow) string,
500) (*FollowsPageParams, error) {
501 l := s.logger.With("handler", "reposPage")
502
503 profile, err := s.profile(r)
504 if err != nil {
505 return nil, err
506 }
507 l = l.With("profileDid", profile.UserDid)
508
509 loggedInUser := s.oauth.GetMultiAccountUser(r)
510 params := FollowsPageParams{
511 Card: profile,
512 }
513
514 follows, err := fetchFollows(s.db, profile.UserDid)
515 if err != nil {
516 l.Error("failed to fetch follows", "err", err)
517 return ¶ms, err
518 }
519
520 if len(follows) == 0 {
521 return ¶ms, nil
522 }
523
524 followDids := make([]string, 0, len(follows))
525 for _, follow := range follows {
526 followDids = append(followDids, extractDid(follow))
527 }
528
529 profiles, err := db.GetProfiles(s.db, orm.FilterIn("did", followDids))
530 if err != nil {
531 l.Error("failed to get profiles", "followDids", followDids, "err", err)
532 return ¶ms, err
533 }
534
535 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
536 if err != nil {
537 l.Error("getting follow counts", "followDids", followDids, "err", err)
538 }
539
540 loggedInUserFollowing := make(map[string]struct{})
541 if loggedInUser != nil {
542 following, err := db.GetFollowing(s.db, loggedInUser.Did)
543 if err != nil {
544 l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did)
545 return ¶ms, err
546 }
547 loggedInUserFollowing = make(map[string]struct{}, len(following))
548 for _, follow := range following {
549 loggedInUserFollowing[follow.SubjectDid] = struct{}{}
550 }
551 }
552
553 followCards := make([]pages.FollowCard, len(follows))
554 for i, did := range followDids {
555 followStats := followStatsMap[did]
556 followStatus := models.IsNotFollowing
557 if _, exists := loggedInUserFollowing[did]; exists {
558 followStatus = models.IsFollowing
559 } else if loggedInUser != nil && loggedInUser.Did == did {
560 followStatus = models.IsSelf
561 }
562
563 var profile *models.Profile
564 if p, exists := profiles[did]; exists {
565 profile = p
566 } else {
567 profile = &models.Profile{}
568 profile.Did = did
569 }
570 followCards[i] = pages.FollowCard{
571 LoggedInUser: loggedInUser,
572 UserDid: did,
573 FollowStatus: followStatus,
574 FollowersCount: followStats.Followers,
575 FollowingCount: followStats.Following,
576 Profile: profile,
577 }
578 }
579
580 params.Follows = followCards
581
582 return ¶ms, nil
583}
584
585func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
586 followPage, err := s.followPage(r, db.GetFollowers, func(f models.Follow) string { return f.UserDid })
587 if err != nil {
588 s.pages.Notice(w, "all-followers", "Failed to load followers")
589 return
590 }
591
592 s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{
593 LoggedInUser: s.oauth.GetMultiAccountUser(r),
594 Followers: followPage.Follows,
595 Card: followPage.Card,
596 })
597}
598
599func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
600 followPage, err := s.followPage(r, db.GetFollowing, func(f models.Follow) string { return f.SubjectDid })
601 if err != nil {
602 s.pages.Notice(w, "all-following", "Failed to load following")
603 return
604 }
605
606 s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{
607 LoggedInUser: s.oauth.GetMultiAccountUser(r),
608 Following: followPage.Follows,
609 Card: followPage.Card,
610 })
611}
612
613func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
614 ident, ok := r.Context().Value("resolvedId").(identity.Identity)
615 if !ok {
616 s.pages.Error404(w)
617 return
618 }
619
620 feed, err := s.getProfileFeed(r.Context(), &ident)
621 if err != nil {
622 s.pages.Error500(w)
623 return
624 }
625
626 if feed == nil {
627 return
628 }
629
630 atom, err := feed.ToAtom()
631 if err != nil {
632 s.pages.Error500(w)
633 return
634 }
635
636 w.Header().Set("content-type", "application/atom+xml")
637 w.Write([]byte(atom))
638}
639
640func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) {
641 timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
642 if err != nil {
643 return nil, err
644 }
645
646 author := &feeds.Author{
647 Name: fmt.Sprintf("@%s", id.Handle),
648 }
649
650 feed := feeds.Feed{
651 Title: fmt.Sprintf("%s's timeline", author.Name),
652 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.BaseUrl(), id.Handle), Type: "text/html", Rel: "alternate"},
653 Items: make([]*feeds.Item, 0),
654 Updated: time.UnixMilli(0),
655 Author: author,
656 }
657
658 for _, byMonth := range timeline.ByMonth {
659 if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil {
660 return nil, err
661 }
662 if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil {
663 return nil, err
664 }
665 if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil {
666 return nil, err
667 }
668 }
669
670 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
671 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
672 })
673
674 if len(feed.Items) > 0 {
675 feed.Updated = feed.Items[0].Created
676 }
677
678 return &feed, nil
679}
680
681func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error {
682 for _, pull := range pulls {
683 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
684 if err != nil {
685 return err
686 }
687
688 // Add pull request creation item
689 feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author))
690 }
691 return nil
692}
693
694func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*models.Issue, author *feeds.Author) error {
695 for _, issue := range issues {
696 owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did)
697 if err != nil {
698 return err
699 }
700
701 feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author))
702 }
703 return nil
704}
705
706func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []models.RepoEvent, author *feeds.Author) error {
707 for _, repo := range repos {
708 item, err := s.createRepoItem(ctx, repo, author)
709 if err != nil {
710 return err
711 }
712 feed.Items = append(feed.Items, item)
713 }
714 return nil
715}
716
717func (s *State) createPullRequestItem(pull *models.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
718 return &feeds.Item{
719 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
720 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.BaseUrl(), owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
721 Created: pull.Created,
722 Author: author,
723 }
724}
725
726func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
727 return &feeds.Item{
728 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name),
729 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.BaseUrl(), owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
730 Created: issue.Created,
731 Author: author,
732 }
733}
734
735func (s *State) createRepoItem(ctx context.Context, repo models.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
736 var title string
737 if repo.Source != nil {
738 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
739 if err != nil {
740 return nil, err
741 }
742 title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name)
743 } else {
744 title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
745 }
746
747 return &feeds.Item{
748 Title: title,
749 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.BaseUrl(), author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix
750 Created: repo.Repo.Created,
751 Author: author,
752 }, nil
753}
754
755func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
756 l := s.logger.With("handler", "UpdateProfileBio")
757 user := s.oauth.GetMultiAccountUser(r)
758
759 err := r.ParseForm()
760 if err != nil {
761 l.Error("invalid profile update form", "err", err)
762 s.pages.Notice(w, "update-profile", "Invalid form.")
763 return
764 }
765
766 profile, err := db.GetProfile(s.db, user.Did)
767 if err != nil {
768 l.Error("getting profile data", "did", user.Did, "err", err)
769 }
770 if profile == nil {
771 profile = &models.Profile{Did: user.Did}
772 }
773
774 profile.Description = r.FormValue("description")
775 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
776 profile.Location = r.FormValue("location")
777 profile.Pronouns = r.FormValue("pronouns")
778 rawPreferredHandle := strings.TrimSpace(r.FormValue("preferredHandle"))
779 if rawPreferredHandle != "" {
780 h, err := syntax.ParseHandle(rawPreferredHandle)
781 if err != nil {
782 s.pages.Notice(w, "update-profile", "Invalid handle format.")
783 return
784 }
785
786 ident, err := s.idResolver.ResolveIdent(r.Context(), user.Did)
787 if err != nil || !slices.Contains(ident.AlsoKnownAs, "at://"+rawPreferredHandle) {
788 s.pages.Notice(w, "update-profile", "Handle not found in your DID document.")
789 return
790 }
791 profile.PreferredHandle = h
792 } else {
793 profile.PreferredHandle = ""
794 }
795
796 var links [5]string
797 for i := range 5 {
798 iLink := r.FormValue(fmt.Sprintf("link%d", i))
799 links[i] = iLink
800 }
801 profile.Links = links
802
803 // Parse stats (exactly 2)
804 stat0 := r.FormValue("stat0")
805 stat1 := r.FormValue("stat1")
806
807 profile.Stats[0].Kind = models.ParseVanityStatKind(stat0)
808 profile.Stats[1].Kind = models.ParseVanityStatKind(stat1)
809
810 if err := db.ValidateProfile(s.db, profile); err != nil {
811 l.Error("invalid profile", "err", err)
812 s.pages.Notice(w, "update-profile", err.Error())
813 return
814 }
815
816 s.updateProfile(profile, w, r)
817}
818
819func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
820 l := s.logger.With("handler", "UpdateProfilePins")
821 user := s.oauth.GetMultiAccountUser(r)
822
823 err := r.ParseForm()
824 if err != nil {
825 l.Error("invalid profile update form", "err", err)
826 s.pages.Notice(w, "update-profile", "Invalid form.")
827 return
828 }
829
830 profile, err := db.GetProfile(s.db, user.Did)
831 if err != nil {
832 l.Error("getting profile data", "did", user.Did, "err", err)
833 }
834 if profile == nil {
835 profile = &models.Profile{Did: user.Did}
836 }
837
838 i := 0
839 var pinnedRepos [6]string
840 for key, values := range r.Form {
841 if i >= 6 {
842 l.Warn("too many pinned repos")
843 s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.")
844 return
845 }
846 if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 {
847 pinnedRepos[i] = values[0]
848 i++
849 }
850 }
851 profile.PinnedRepos = pinnedRepos
852
853 s.updateProfile(profile, w, r)
854}
855
856func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) {
857 l := s.logger.With("handler", "updateProfile")
858 user := s.oauth.GetMultiAccountUser(r)
859
860 client, err := s.oauth.AuthorizedClient(r)
861 if err != nil {
862 l.Error("failed to get authorized client", "err", err)
863 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
864 return
865 }
866
867 var pinnedRepoStrings []string
868 for _, r := range profile.PinnedRepos {
869 if r != "" {
870 pinnedRepoStrings = append(pinnedRepoStrings, r)
871 }
872 }
873
874 var vanityStats []string
875 for _, v := range profile.Stats {
876 vanityStats = append(vanityStats, string(v.Kind))
877 }
878
879 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
880 var cid *string
881 var existingAvatar *lexutil.LexBlob
882 if ex != nil {
883 cid = ex.Cid
884 if rec, ok := ex.Value.Val.(*tangled.ActorProfile); ok {
885 existingAvatar = rec.Avatar
886 }
887 }
888
889 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
890 Collection: tangled.ActorProfileNSID,
891 Repo: user.Did,
892 Rkey: "self",
893 Record: &lexutil.LexiconTypeDecoder{
894 Val: &tangled.ActorProfile{
895 Avatar: existingAvatar,
896 Bluesky: profile.IncludeBluesky,
897 Description: &profile.Description,
898 Links: profile.Links[:],
899 Location: &profile.Location,
900 PinnedRepositories: pinnedRepoStrings,
901 Stats: vanityStats[:],
902 Pronouns: &profile.Pronouns,
903 PreferredHandle: (*string)(&profile.PreferredHandle),
904 }},
905 SwapRecord: cid,
906 })
907 if err != nil {
908 l.Error("failed to update profile on PDS", "err", err)
909 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.")
910 return
911 }
912
913 tx, err := s.db.BeginTx(r.Context(), nil)
914 if err != nil {
915 l.Error("failed to start transaction", "err", err)
916 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
917 return
918 }
919
920 if err := db.UpsertProfile(tx, profile); err != nil {
921 l.Error("failed to update profile in DB", "err", err)
922 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
923 return
924 }
925
926 if s.rdb != nil {
927 ctx := r.Context()
928 pipe := s.rdb.Pipeline()
929 didKey := fmt.Sprintf(cache.PreferredHandleByDid, profile.Did)
930 if profile.PreferredHandle != "" {
931 pipe.Set(ctx, didKey, string(profile.PreferredHandle), cache.PreferredHandleTTL)
932 pipe.Set(ctx, fmt.Sprintf(cache.PreferredHandleByHandle, string(profile.PreferredHandle)), profile.Did, cache.PreferredHandleTTL)
933 } else {
934 pipe.Del(ctx, didKey)
935 }
936 if _, execErr := pipe.Exec(ctx); execErr != nil {
937 l.Warn("failed to update preferred handle cache", "err", execErr)
938 }
939 }
940
941 s.notifier.UpdateProfile(r.Context(), profile)
942
943 s.pages.HxRedirect(w, "/"+user.Did)
944}
945
946func (s *State) ProfilePopover(w http.ResponseWriter, r *http.Request) {
947 l := s.logger.With("handler", "ProfilePopover")
948
949 did := r.URL.Query().Get("did")
950 if did == "" {
951 l.Warn("missing did param")
952 w.WriteHeader(http.StatusBadRequest)
953 return
954 }
955
956 profile, err := db.GetProfile(s.db, did)
957 if err != nil {
958 l.Error("failed to get profile", "did", did, "err", err)
959 w.WriteHeader(http.StatusInternalServerError)
960 return
961 }
962 if profile == nil {
963 profile = &models.Profile{Did: did}
964 }
965
966 followStats, err := db.GetFollowerFollowingCount(s.db, did)
967 if err != nil {
968 l.Error("failed to get follower stats", "did", did, "err", err)
969 w.WriteHeader(http.StatusInternalServerError)
970 return
971 }
972
973 loggedInUser := s.oauth.GetMultiAccountUser(r)
974 followStatus := models.IsNotFollowing
975 var vouchRelationship *models.VouchRelationship
976
977 if loggedInUser != nil {
978 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
979 vouchRelationship, _ = db.GetVouchRelationship(s.db, syntax.DID(loggedInUser.Did), syntax.DID(did))
980 }
981
982 s.pages.ProfilePopoverFragment(w, pages.ProfilePopoverParams{
983 LoggedInUser: loggedInUser,
984 UserDid: did,
985 Profile: profile,
986 FollowStatus: followStatus,
987 VouchRelationship: vouchRelationship,
988 Stats: pages.ProfilePopoverStats{
989 FollowersCount: followStats.Followers,
990 FollowingCount: followStats.Following,
991 },
992 })
993}
994
995func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
996 l := s.logger.With("handler", "EditBioFragment")
997 user := s.oauth.GetMultiAccountUser(r)
998
999 profile, err := db.GetProfile(s.db, user.Did)
1000 if err != nil {
1001 l.Error("getting profile data", "did", user.Did, "err", err)
1002 }
1003 if profile == nil {
1004 profile = &models.Profile{Did: user.Did}
1005 }
1006
1007 var alsoKnownAs []string
1008 ident, err := s.idResolver.ResolveIdent(r.Context(), user.Did)
1009 if err == nil {
1010 alsoKnownAs = ident.AlsoKnownAs
1011 }
1012
1013 s.pages.EditBioFragment(w, pages.EditBioParams{
1014 LoggedInUser: user,
1015 Profile: profile,
1016 AlsoKnownAs: alsoKnownAs,
1017 })
1018}
1019
1020func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) {
1021 l := s.logger.With("handler", "EditPinsFragment")
1022 user := s.oauth.GetMultiAccountUser(r)
1023
1024 profile, err := db.GetProfile(s.db, user.Did)
1025 if err != nil {
1026 l.Error("getting profile data", "did", user.Did, "err", err)
1027 }
1028 if profile == nil {
1029 profile = &models.Profile{Did: user.Did}
1030 }
1031
1032 repos, err := db.GetRepos(s.db, orm.FilterEq("did", user.Did))
1033 if err != nil {
1034 l.Error("getting repos", "did", user.Did, "err", err)
1035 }
1036
1037 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did)
1038 if err != nil {
1039 l.Error("getting collaborating repos", "did", user.Did, "err", err)
1040 }
1041
1042 allRepos := []pages.PinnedRepo{}
1043
1044 for _, r := range repos {
1045 allRepos = append(allRepos, pages.PinnedRepo{
1046 IsPinned: profile.MatchesPinnedRepo(r),
1047 Repo: r,
1048 })
1049 }
1050 for _, r := range collaboratingRepos {
1051 allRepos = append(allRepos, pages.PinnedRepo{
1052 IsPinned: profile.MatchesPinnedRepo(r),
1053 Repo: r,
1054 })
1055 }
1056
1057 s.pages.EditPinsFragment(w, pages.EditPinsParams{
1058 LoggedInUser: user,
1059 Profile: profile,
1060 AllRepos: allRepos,
1061 })
1062}
1063
1064func (s *State) UploadProfileAvatar(w http.ResponseWriter, r *http.Request) {
1065 l := s.logger.With("handler", "UploadProfileAvatar")
1066 user := s.oauth.GetMultiAccountUser(r)
1067 l = l.With("did", user.Did)
1068
1069 // Parse multipart form (10MB max)
1070 if err := r.ParseMultipartForm(10 << 20); err != nil {
1071 l.Error("failed to parse form", "err", err)
1072 s.pages.Notice(w, "avatar-error", "Failed to parse form")
1073 return
1074 }
1075
1076 file, header, err := r.FormFile("avatar")
1077 if err != nil {
1078 l.Error("failed to read avatar file", "err", err)
1079 s.pages.Notice(w, "avatar-error", "Failed to read avatar file")
1080 return
1081 }
1082 defer file.Close()
1083
1084 if header.Size > 5000000 {
1085 l.Warn("avatar file too large", "size", header.Size)
1086 s.pages.Notice(w, "avatar-error", "Avatar file too large (max 5MB)")
1087 return
1088 }
1089
1090 contentType := header.Header.Get("Content-Type")
1091 if contentType != "image/png" && contentType != "image/jpeg" {
1092 l.Warn("invalid image type", "contentType", contentType)
1093 s.pages.Notice(w, "avatar-error", "Invalid image type (only PNG and JPEG allowed)")
1094 return
1095 }
1096
1097 client, err := s.oauth.AuthorizedClient(r)
1098 if err != nil {
1099 l.Error("failed to get PDS client", "err", err)
1100 s.pages.Notice(w, "avatar-error", "Failed to connect to your PDS")
1101 return
1102 }
1103
1104 uploadBlobResp, err := xrpc.RepoUploadBlob(r.Context(), client, file, header.Header.Get("Content-Type"))
1105 if err != nil {
1106 l.Error("failed to upload avatar blob", "err", err)
1107 s.pages.Notice(w, "avatar-error", "Failed to upload avatar to your PDS")
1108 return
1109 }
1110
1111 l.Info("uploaded avatar blob", "cid", uploadBlobResp.Blob.Ref.String())
1112
1113 // get current profile record from PDS to get its CID for swap
1114 getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
1115 if err != nil {
1116 l.Error("failed to get current profile record", "err", err)
1117 s.pages.Notice(w, "avatar-error", "Failed to get current profile from your PDS")
1118 return
1119 }
1120
1121 var profileRecord *tangled.ActorProfile
1122 if getRecordResp.Value != nil {
1123 if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok {
1124 profileRecord = val
1125 } else {
1126 l.Warn("profile record type assertion failed, creating new record")
1127 profileRecord = &tangled.ActorProfile{}
1128 }
1129 } else {
1130 l.Warn("no existing profile record, creating new record")
1131 profileRecord = &tangled.ActorProfile{}
1132 }
1133
1134 profileRecord.Avatar = uploadBlobResp.Blob
1135
1136 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1137 Collection: tangled.ActorProfileNSID,
1138 Repo: user.Did,
1139 Rkey: "self",
1140 Record: &lexutil.LexiconTypeDecoder{Val: profileRecord},
1141 SwapRecord: getRecordResp.Cid,
1142 })
1143
1144 if err != nil {
1145 l.Error("failed to update profile record", "err", err)
1146 s.pages.Notice(w, "avatar-error", "Failed to update profile on your PDS")
1147 return
1148 }
1149
1150 l.Info("successfully updated profile with avatar")
1151
1152 profile, err := db.GetProfile(s.db, user.Did)
1153 if err != nil {
1154 l.Warn("getting profile data from DB", "err", err)
1155 }
1156 if profile == nil {
1157 profile = &models.Profile{Did: user.Did}
1158 }
1159 profile.Avatar = uploadBlobResp.Blob.Ref.String()
1160
1161 tx, err := s.db.BeginTx(r.Context(), nil)
1162 if err != nil {
1163 l.Error("failed to start transaction", "err", err)
1164 s.pages.HxRefresh(w)
1165 w.WriteHeader(http.StatusOK)
1166 return
1167 }
1168
1169 err = db.UpsertProfile(tx, profile)
1170 if err != nil {
1171 l.Error("failed to update profile in DB", "err", err)
1172 s.pages.HxRefresh(w)
1173 w.WriteHeader(http.StatusOK)
1174 return
1175 }
1176
1177 s.pages.HxRedirect(w, r.Header.Get("Referer"))
1178}
1179
1180func (s *State) RemoveProfileAvatar(w http.ResponseWriter, r *http.Request) {
1181 l := s.logger.With("handler", "RemoveProfileAvatar")
1182 user := s.oauth.GetMultiAccountUser(r)
1183 l = l.With("did", user.Did)
1184
1185 client, err := s.oauth.AuthorizedClient(r)
1186 if err != nil {
1187 l.Error("failed to get PDS client", "err", err)
1188 s.pages.Notice(w, "avatar-error", "Failed to connect to your PDS")
1189 return
1190 }
1191
1192 getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
1193 if err != nil {
1194 l.Error("failed to get current profile record", "err", err)
1195 s.pages.Notice(w, "avatar-error", "Failed to get current profile from your PDS")
1196 return
1197 }
1198
1199 var profileRecord *tangled.ActorProfile
1200 if getRecordResp.Value != nil {
1201 if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok {
1202 profileRecord = val
1203 } else {
1204 l.Warn("profile record type assertion failed")
1205 profileRecord = &tangled.ActorProfile{}
1206 }
1207 } else {
1208 l.Warn("no existing profile record")
1209 profileRecord = &tangled.ActorProfile{}
1210 }
1211
1212 profileRecord.Avatar = nil
1213
1214 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1215 Collection: tangled.ActorProfileNSID,
1216 Repo: user.Did,
1217 Rkey: "self",
1218 Record: &lexutil.LexiconTypeDecoder{Val: profileRecord},
1219 SwapRecord: getRecordResp.Cid,
1220 })
1221
1222 if err != nil {
1223 l.Error("failed to update profile record", "err", err)
1224 s.pages.Notice(w, "avatar-error", "Failed to remove avatar from your PDS")
1225 return
1226 }
1227
1228 l.Info("successfully removed avatar from PDS")
1229
1230 profile, err := db.GetProfile(s.db, user.Did)
1231 if err != nil {
1232 l.Warn("getting profile data from DB", "err", err)
1233 }
1234 if profile == nil {
1235 profile = &models.Profile{Did: user.Did}
1236 }
1237 profile.Avatar = ""
1238
1239 tx, err := s.db.BeginTx(r.Context(), nil)
1240 if err != nil {
1241 l.Error("failed to start transaction", "err", err)
1242 s.pages.HxRefresh(w)
1243 w.WriteHeader(http.StatusOK)
1244 return
1245 }
1246
1247 err = db.UpsertProfile(tx, profile)
1248 if err != nil {
1249 l.Error("failed to update profile in DB", "err", err)
1250 s.pages.HxRefresh(w)
1251 w.WriteHeader(http.StatusOK)
1252 return
1253 }
1254
1255 s.pages.HxRedirect(w, r.Header.Get("Referer"))
1256}
1257
1258func (s *State) UpdateProfilePunchcardSetting(w http.ResponseWriter, r *http.Request) {
1259 l := s.logger.With("handler", "UpdateProfilePunchcardSetting")
1260 err := r.ParseForm()
1261 if err != nil {
1262 l.Error("invalid profile update form", "err", err)
1263 return
1264 }
1265 user := s.oauth.GetMultiAccountUser(r)
1266
1267 hideOthers := false
1268 hideMine := false
1269
1270 if r.Form.Get("hideMine") == "on" {
1271 hideMine = true
1272 }
1273 if r.Form.Get("hideOthers") == "on" {
1274 hideOthers = true
1275 }
1276
1277 err = db.UpsertPunchcardPreference(s.db, user.Did, hideMine, hideOthers)
1278 if err != nil {
1279 l.Error("failed to update punchcard preferences", "err", err)
1280 return
1281 }
1282
1283 s.pages.HxRefresh(w)
1284}