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