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