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