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