this repo has no description
1package state
2
3import (
4 "context"
5 "fmt"
6 "log"
7 "net/http"
8 "slices"
9 "strings"
10 "time"
11
12 comatproto "github.com/bluesky-social/indigo/api/atproto"
13 "github.com/bluesky-social/indigo/atproto/identity"
14 "github.com/bluesky-social/indigo/atproto/syntax"
15 lexutil "github.com/bluesky-social/indigo/lex/util"
16 "github.com/go-chi/chi/v5"
17 "github.com/gorilla/feeds"
18 "tangled.sh/tangled.sh/core/api/tangled"
19 "tangled.sh/tangled.sh/core/appview/db"
20 // "tangled.sh/tangled.sh/core/appview/oauth"
21 "tangled.sh/tangled.sh/core/appview/pages"
22)
23
24func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
25 tabVal := r.URL.Query().Get("tab")
26 switch tabVal {
27 case "", "overview":
28 s.profileOverview(w, r)
29 case "repos":
30 s.reposPage(w, r)
31 case "followers":
32 s.followersPage(w, r)
33 case "following":
34 s.followingPage(w, r)
35 case "starred":
36 s.starredPage(w, r)
37 }
38}
39
40func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) {
41 didOrHandle := chi.URLParam(r, "user")
42 if didOrHandle == "" {
43 return nil, fmt.Errorf("empty DID or handle")
44 }
45
46 ident, ok := r.Context().Value("resolvedId").(identity.Identity)
47 if !ok {
48 return nil, fmt.Errorf("failed to resolve ID")
49 }
50 did := ident.DID.String()
51
52 profile, err := db.GetProfile(s.db, did)
53 if err != nil {
54 return nil, fmt.Errorf("failed to get profile: %w", err)
55 }
56
57 followStats, err := db.GetFollowerFollowingCount(s.db, did)
58 if err != nil {
59 return nil, fmt.Errorf("failed to get follower stats: %w", err)
60 }
61
62 loggedInUser := s.oauth.GetUser(r)
63 followStatus := db.IsNotFollowing
64 if loggedInUser != nil {
65 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
66 }
67
68 now := time.Now()
69 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
70 punchcard, err := db.MakePunchcard(
71 s.db,
72 db.FilterEq("did", did),
73 db.FilterGte("date", startOfYear.Format(time.DateOnly)),
74 db.FilterLte("date", now.Format(time.DateOnly)),
75 )
76 if err != nil {
77 return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err)
78 }
79
80 return &pages.ProfileCard{
81 UserDid: did,
82 UserHandle: ident.Handle.String(),
83 Profile: profile,
84 FollowStatus: followStatus,
85 FollowersCount: followStats.Followers,
86 FollowingCount: followStats.Following,
87 Punchcard: punchcard,
88 }, nil
89}
90
91func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) {
92 l := s.logger.With("handler", "profileHomePage")
93
94 profile, err := s.profile(r)
95 if err != nil {
96 l.Error("failed to build profile card", "err", err)
97 s.pages.Error500(w)
98 return
99 }
100 l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
101
102 repos, err := db.GetRepos(
103 s.db,
104 0,
105 db.FilterEq("did", profile.UserDid),
106 )
107 if err != nil {
108 l.Error("failed to fetch repos", "err", err)
109 }
110
111 // filter out ones that are pinned
112 pinnedRepos := []db.Repo{}
113 for i, r := range repos {
114 // if this is a pinned repo, add it
115 if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
116 pinnedRepos = append(pinnedRepos, r)
117 }
118
119 // if there are no saved pins, add the first 4 repos
120 if profile.Profile.IsPinnedReposEmpty() && i < 4 {
121 pinnedRepos = append(pinnedRepos, r)
122 }
123 }
124
125 collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid)
126 if err != nil {
127 l.Error("failed to fetch collaborating repos", "err", err)
128 }
129
130 pinnedCollaboratingRepos := []db.Repo{}
131 for _, r := range collaboratingRepos {
132 // if this is a pinned repo, add it
133 if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
134 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r)
135 }
136 }
137
138 timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid)
139 if err != nil {
140 l.Error("failed to create timeline", "err", err)
141 }
142
143 s.pages.ProfileOverview(w, pages.ProfileOverviewParams{
144 LoggedInUser: s.oauth.GetUser(r),
145 Card: profile,
146 Repos: pinnedRepos,
147 CollaboratingRepos: pinnedCollaboratingRepos,
148 ProfileTimeline: timeline,
149 })
150}
151
152func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
153 l := s.logger.With("handler", "reposPage")
154
155 profile, err := s.profile(r)
156 if err != nil {
157 l.Error("failed to build profile card", "err", err)
158 s.pages.Error500(w)
159 return
160 }
161 l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
162
163 repos, err := db.GetRepos(
164 s.db,
165 0,
166 db.FilterEq("did", profile.UserDid),
167 )
168 if err != nil {
169 l.Error("failed to get repos", "err", err)
170 s.pages.Error500(w)
171 return
172 }
173
174 err = s.pages.ProfileRepos(w, pages.ProfileReposParams{
175 LoggedInUser: s.oauth.GetUser(r),
176 Repos: repos,
177 Card: profile,
178 })
179}
180
181func (s *State) starredPage(w http.ResponseWriter, r *http.Request) {
182 l := s.logger.With("handler", "starredPage")
183
184 profile, err := s.profile(r)
185 if err != nil {
186 l.Error("failed to build profile card", "err", err)
187 s.pages.Error500(w)
188 return
189 }
190 l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
191
192 stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid))
193 if err != nil {
194 l.Error("failed to get stars", "err", err)
195 s.pages.Error500(w)
196 return
197 }
198 var repoAts []string
199 for _, s := range stars {
200 repoAts = append(repoAts, string(s.RepoAt))
201 }
202
203 repos, err := db.GetRepos(
204 s.db,
205 0,
206 db.FilterIn("at_uri", repoAts),
207 )
208 if err != nil {
209 l.Error("failed to get repos", "err", err)
210 s.pages.Error500(w)
211 return
212 }
213
214 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
215 LoggedInUser: s.oauth.GetUser(r),
216 Repos: repos,
217 Card: profile,
218 })
219}
220
221type FollowsPageParams struct {
222 Follows []pages.FollowCard
223 Card *pages.ProfileCard
224}
225
226func (s *State) followPage(
227 r *http.Request,
228 fetchFollows func(db.Execer, string) ([]db.Follow, error),
229 extractDid func(db.Follow) string,
230) (*FollowsPageParams, error) {
231 l := s.logger.With("handler", "reposPage")
232
233 profile, err := s.profile(r)
234 if err != nil {
235 return nil, err
236 }
237 l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
238
239 loggedInUser := s.oauth.GetUser(r)
240
241 follows, err := fetchFollows(s.db, profile.UserDid)
242 if err != nil {
243 l.Error("failed to fetch follows", "err", err)
244 return nil, err
245 }
246
247 if len(follows) == 0 {
248 return nil, nil
249 }
250
251 followDids := make([]string, 0, len(follows))
252 for _, follow := range follows {
253 followDids = append(followDids, extractDid(follow))
254 }
255
256 profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
257 if err != nil {
258 l.Error("failed to get profiles", "followDids", followDids, "err", err)
259 return nil, err
260 }
261
262 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
263 if err != nil {
264 log.Printf("getting follow counts for %s: %s", followDids, err)
265 }
266
267 loggedInUserFollowing := make(map[string]struct{})
268 if loggedInUser != nil {
269 following, err := db.GetFollowing(s.db, loggedInUser.Did)
270 if err != nil {
271 l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did)
272 return nil, err
273 }
274 loggedInUserFollowing = make(map[string]struct{}, len(following))
275 for _, follow := range following {
276 loggedInUserFollowing[follow.SubjectDid] = struct{}{}
277 }
278 }
279
280 followCards := make([]pages.FollowCard, len(follows))
281 for i, did := range followDids {
282 followStats := followStatsMap[did]
283 followStatus := db.IsNotFollowing
284 if _, exists := loggedInUserFollowing[did]; exists {
285 followStatus = db.IsFollowing
286 } else if loggedInUser.Did == did {
287 followStatus = db.IsSelf
288 }
289
290 var profile *db.Profile
291 if p, exists := profiles[did]; exists {
292 profile = p
293 } else {
294 profile = &db.Profile{}
295 profile.Did = did
296 }
297 followCards[i] = pages.FollowCard{
298 UserDid: did,
299 FollowStatus: followStatus,
300 FollowersCount: followStats.Followers,
301 FollowingCount: followStats.Following,
302 Profile: profile,
303 }
304 }
305
306 return &FollowsPageParams{
307 Follows: followCards,
308 Card: profile,
309 }, nil
310}
311
312func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
313 followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
314 if err != nil {
315 s.pages.Notice(w, "all-followers", "Failed to load followers")
316 return
317 }
318
319 s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{
320 LoggedInUser: s.oauth.GetUser(r),
321 Followers: followPage.Follows,
322 Card: followPage.Card,
323 })
324}
325
326func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
327 followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
328 if err != nil {
329 s.pages.Notice(w, "all-following", "Failed to load following")
330 return
331 }
332
333 s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{
334 LoggedInUser: s.oauth.GetUser(r),
335 Following: followPage.Follows,
336 Card: followPage.Card,
337 })
338}
339
340func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
341 ident, ok := r.Context().Value("resolvedId").(identity.Identity)
342 if !ok {
343 s.pages.Error404(w)
344 return
345 }
346
347 feed, err := s.getProfileFeed(r.Context(), &ident)
348 if err != nil {
349 s.pages.Error500(w)
350 return
351 }
352
353 if feed == nil {
354 return
355 }
356
357 atom, err := feed.ToAtom()
358 if err != nil {
359 s.pages.Error500(w)
360 return
361 }
362
363 w.Header().Set("content-type", "application/atom+xml")
364 w.Write([]byte(atom))
365}
366
367func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) {
368 timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
369 if err != nil {
370 return nil, err
371 }
372
373 author := &feeds.Author{
374 Name: fmt.Sprintf("@%s", id.Handle),
375 }
376
377 feed := feeds.Feed{
378 Title: fmt.Sprintf("%s's timeline", author.Name),
379 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"},
380 Items: make([]*feeds.Item, 0),
381 Updated: time.UnixMilli(0),
382 Author: author,
383 }
384
385 for _, byMonth := range timeline.ByMonth {
386 if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil {
387 return nil, err
388 }
389 if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil {
390 return nil, err
391 }
392 if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil {
393 return nil, err
394 }
395 }
396
397 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
398 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
399 })
400
401 if len(feed.Items) > 0 {
402 feed.Updated = feed.Items[0].Created
403 }
404
405 return &feed, nil
406}
407
408func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error {
409 for _, pull := range pulls {
410 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
411 if err != nil {
412 return err
413 }
414
415 // Add pull request creation item
416 feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author))
417 }
418 return nil
419}
420
421func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
422 for _, issue := range issues {
423 owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
424 if err != nil {
425 return err
426 }
427
428 feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author))
429 }
430 return nil
431}
432
433func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error {
434 for _, repo := range repos {
435 item, err := s.createRepoItem(ctx, repo, author)
436 if err != nil {
437 return err
438 }
439 feed.Items = append(feed.Items, item)
440 }
441 return nil
442}
443
444func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
445 return &feeds.Item{
446 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
447 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
448 Created: pull.Created,
449 Author: author,
450 }
451}
452
453func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
454 return &feeds.Item{
455 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name),
456 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
457 Created: issue.Created,
458 Author: author,
459 }
460}
461
462func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
463 var title string
464 if repo.Source != nil {
465 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
466 if err != nil {
467 return nil, err
468 }
469 title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name)
470 } else {
471 title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
472 }
473
474 return &feeds.Item{
475 Title: title,
476 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix
477 Created: repo.Repo.Created,
478 Author: author,
479 }, nil
480}
481
482func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
483 user := s.oauth.GetUser(r)
484
485 err := r.ParseForm()
486 if err != nil {
487 log.Println("invalid profile update form", err)
488 s.pages.Notice(w, "update-profile", "Invalid form.")
489 return
490 }
491
492 profile, err := db.GetProfile(s.db, user.Did)
493 if err != nil {
494 log.Printf("getting profile data for %s: %s", user.Did, err)
495 }
496
497 profile.Description = r.FormValue("description")
498 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
499 profile.Location = r.FormValue("location")
500
501 var links [5]string
502 for i := range 5 {
503 iLink := r.FormValue(fmt.Sprintf("link%d", i))
504 links[i] = iLink
505 }
506 profile.Links = links
507
508 // Parse stats (exactly 2)
509 stat0 := r.FormValue("stat0")
510 stat1 := r.FormValue("stat1")
511
512 if stat0 != "" {
513 profile.Stats[0].Kind = db.VanityStatKind(stat0)
514 }
515
516 if stat1 != "" {
517 profile.Stats[1].Kind = db.VanityStatKind(stat1)
518 }
519
520 if err := db.ValidateProfile(s.db, profile); err != nil {
521 log.Println("invalid profile", err)
522 s.pages.Notice(w, "update-profile", err.Error())
523 return
524 }
525
526 s.updateProfile(profile, w, r)
527}
528
529func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
530 user := s.oauth.GetUser(r)
531
532 err := r.ParseForm()
533 if err != nil {
534 log.Println("invalid profile update form", err)
535 s.pages.Notice(w, "update-profile", "Invalid form.")
536 return
537 }
538
539 profile, err := db.GetProfile(s.db, user.Did)
540 if err != nil {
541 log.Printf("getting profile data for %s: %s", user.Did, err)
542 }
543
544 i := 0
545 var pinnedRepos [6]syntax.ATURI
546 for key, values := range r.Form {
547 if i >= 6 {
548 log.Println("invalid pin update form", err)
549 s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.")
550 return
551 }
552 if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 {
553 aturi, err := syntax.ParseATURI(values[0])
554 if err != nil {
555 log.Println("invalid profile update form", err)
556 s.pages.Notice(w, "update-profile", "Invalid form.")
557 return
558 }
559 pinnedRepos[i] = aturi
560 i++
561 }
562 }
563 profile.PinnedRepos = pinnedRepos
564
565 s.updateProfile(profile, w, r)
566}
567
568func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
569 user := s.oauth.GetUser(r)
570 tx, err := s.db.BeginTx(r.Context(), nil)
571 if err != nil {
572 log.Println("failed to start transaction", err)
573 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
574 return
575 }
576
577 client, err := s.oauth.AuthorizedClient(r)
578 if err != nil {
579 log.Println("failed to get authorized client", err)
580 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
581 return
582 }
583
584 // yeah... lexgen dose not support syntax.ATURI in the record for some reason,
585 // nor does it support exact size arrays
586 var pinnedRepoStrings []string
587 for _, r := range profile.PinnedRepos {
588 pinnedRepoStrings = append(pinnedRepoStrings, r.String())
589 }
590
591 var vanityStats []string
592 for _, v := range profile.Stats {
593 vanityStats = append(vanityStats, string(v.Kind))
594 }
595
596 ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self")
597 var cid *string
598 if ex != nil {
599 cid = ex.Cid
600 }
601
602 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
603 Collection: tangled.ActorProfileNSID,
604 Repo: user.Did,
605 Rkey: "self",
606 Record: &lexutil.LexiconTypeDecoder{
607 Val: &tangled.ActorProfile{
608 Bluesky: profile.IncludeBluesky,
609 Description: &profile.Description,
610 Links: profile.Links[:],
611 Location: &profile.Location,
612 PinnedRepositories: pinnedRepoStrings,
613 Stats: vanityStats[:],
614 }},
615 SwapRecord: cid,
616 })
617 if err != nil {
618 log.Println("failed to update profile", err)
619 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.")
620 return
621 }
622
623 err = db.UpsertProfile(tx, profile)
624 if err != nil {
625 log.Println("failed to update profile", err)
626 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
627 return
628 }
629
630 s.notifier.UpdateProfile(r.Context(), profile)
631
632 s.pages.HxRedirect(w, "/"+user.Did)
633}
634
635func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
636 user := s.oauth.GetUser(r)
637
638 profile, err := db.GetProfile(s.db, user.Did)
639 if err != nil {
640 log.Printf("getting profile data for %s: %s", user.Did, err)
641 }
642
643 s.pages.EditBioFragment(w, pages.EditBioParams{
644 LoggedInUser: user,
645 Profile: profile,
646 })
647}
648
649func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) {
650 user := s.oauth.GetUser(r)
651
652 profile, err := db.GetProfile(s.db, user.Did)
653 if err != nil {
654 log.Printf("getting profile data for %s: %s", user.Did, err)
655 }
656
657 repos, err := db.GetAllReposByDid(s.db, user.Did)
658 if err != nil {
659 log.Printf("getting repos for %s: %s", user.Did, err)
660 }
661
662 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did)
663 if err != nil {
664 log.Printf("getting collaborating repos for %s: %s", user.Did, err)
665 }
666
667 allRepos := []pages.PinnedRepo{}
668
669 for _, r := range repos {
670 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt())
671 allRepos = append(allRepos, pages.PinnedRepo{
672 IsPinned: isPinned,
673 Repo: r,
674 })
675 }
676 for _, r := range collaboratingRepos {
677 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt())
678 allRepos = append(allRepos, pages.PinnedRepo{
679 IsPinned: isPinned,
680 Repo: r,
681 })
682 }
683
684 s.pages.EditPinsFragment(w, pages.EditPinsParams{
685 LoggedInUser: user,
686 Profile: profile,
687 AllRepos: allRepos,
688 })
689}