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