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