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