forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
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 followStats, 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: followStats.Followers, 86 FollowingCount: followStats.Following, 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 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) 245 if err != nil { 246 log.Printf("getting follow counts for %s: %s", followDids, err) 247 } 248 249 var loggedInUserFollowing map[string]struct{} 250 if loggedInUser != nil { 251 following, err := db.GetFollowing(s.db, loggedInUser.Did) 252 if err != nil { 253 return FollowsPageParams{}, err 254 } 255 if len(following) > 0 { 256 loggedInUserFollowing = make(map[string]struct{}, len(following)) 257 for _, follow := range following { 258 loggedInUserFollowing[follow.SubjectDid] = struct{}{} 259 } 260 } 261 } 262 263 followCards := make([]pages.FollowCard, 0, len(follows)) 264 for _, did := range followDids { 265 followStats, exists := followStatsMap[did] 266 if !exists { 267 followStats = db.FollowStats{} 268 } 269 followStatus := db.IsNotFollowing 270 if loggedInUserFollowing != nil { 271 if _, exists := loggedInUserFollowing[did]; exists { 272 followStatus = db.IsFollowing 273 } else if loggedInUser.Did == did { 274 followStatus = db.IsSelf 275 } 276 } 277 var profile *db.Profile 278 if p, exists := profiles[did]; exists { 279 profile = p 280 } else { 281 profile = &db.Profile{} 282 profile.Did = did 283 } 284 followCards = append(followCards, pages.FollowCard{ 285 UserDid: did, 286 FollowStatus: followStatus, 287 FollowersCount: followStats.Followers, 288 FollowingCount: followStats.Following, 289 Profile: profile, 290 }) 291 } 292 293 return FollowsPageParams{ 294 LoggedInUser: loggedInUser, 295 Follows: followCards, 296 Card: pageWithProfile.Card, 297 }, nil 298} 299 300func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 301 followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 302 if err != nil { 303 s.pages.Notice(w, "all-followers", "Failed to load followers") 304 return 305 } 306 307 s.pages.FollowersPage(w, pages.FollowersPageParams{ 308 LoggedInUser: followPage.LoggedInUser, 309 Followers: followPage.Follows, 310 Card: followPage.Card, 311 }) 312} 313 314func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 315 followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 316 if err != nil { 317 s.pages.Notice(w, "all-following", "Failed to load following") 318 return 319 } 320 321 s.pages.FollowingPage(w, pages.FollowingPageParams{ 322 LoggedInUser: followPage.LoggedInUser, 323 Following: followPage.Follows, 324 Card: followPage.Card, 325 }) 326} 327 328func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 329 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 330 if !ok { 331 s.pages.Error404(w) 332 return 333 } 334 335 feed, err := s.getProfileFeed(r.Context(), &ident) 336 if err != nil { 337 s.pages.Error500(w) 338 return 339 } 340 341 if feed == nil { 342 return 343 } 344 345 atom, err := feed.ToAtom() 346 if err != nil { 347 s.pages.Error500(w) 348 return 349 } 350 351 w.Header().Set("content-type", "application/atom+xml") 352 w.Write([]byte(atom)) 353} 354 355func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 356 timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 357 if err != nil { 358 return nil, err 359 } 360 361 author := &feeds.Author{ 362 Name: fmt.Sprintf("@%s", id.Handle), 363 } 364 365 feed := feeds.Feed{ 366 Title: fmt.Sprintf("%s's timeline", author.Name), 367 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"}, 368 Items: make([]*feeds.Item, 0), 369 Updated: time.UnixMilli(0), 370 Author: author, 371 } 372 373 for _, byMonth := range timeline.ByMonth { 374 if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil { 375 return nil, err 376 } 377 if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil { 378 return nil, err 379 } 380 if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil { 381 return nil, err 382 } 383 } 384 385 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 386 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 387 }) 388 389 if len(feed.Items) > 0 { 390 feed.Updated = feed.Items[0].Created 391 } 392 393 return &feed, nil 394} 395 396func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 397 for _, pull := range pulls { 398 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 399 if err != nil { 400 return err 401 } 402 403 // Add pull request creation item 404 feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author)) 405 } 406 return nil 407} 408 409func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 410 for _, issue := range issues { 411 owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 412 if err != nil { 413 return err 414 } 415 416 feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author)) 417 } 418 return nil 419} 420 421func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 422 for _, repo := range repos { 423 item, err := s.createRepoItem(ctx, repo, author) 424 if err != nil { 425 return err 426 } 427 feed.Items = append(feed.Items, item) 428 } 429 return nil 430} 431 432func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 433 return &feeds.Item{ 434 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 435 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"}, 436 Created: pull.Created, 437 Author: author, 438 } 439} 440 441func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 442 return &feeds.Item{ 443 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 444 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"}, 445 Created: issue.Created, 446 Author: author, 447 } 448} 449 450func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 451 var title string 452 if repo.Source != nil { 453 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 454 if err != nil { 455 return nil, err 456 } 457 title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name) 458 } else { 459 title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 460 } 461 462 return &feeds.Item{ 463 Title: title, 464 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 465 Created: repo.Repo.Created, 466 Author: author, 467 }, nil 468} 469 470func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 471 user := s.oauth.GetUser(r) 472 473 err := r.ParseForm() 474 if err != nil { 475 log.Println("invalid profile update form", err) 476 s.pages.Notice(w, "update-profile", "Invalid form.") 477 return 478 } 479 480 profile, err := db.GetProfile(s.db, user.Did) 481 if err != nil { 482 log.Printf("getting profile data for %s: %s", user.Did, err) 483 } 484 485 profile.Description = r.FormValue("description") 486 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 487 profile.Location = r.FormValue("location") 488 489 var links [5]string 490 for i := range 5 { 491 iLink := r.FormValue(fmt.Sprintf("link%d", i)) 492 links[i] = iLink 493 } 494 profile.Links = links 495 496 // Parse stats (exactly 2) 497 stat0 := r.FormValue("stat0") 498 stat1 := r.FormValue("stat1") 499 500 if stat0 != "" { 501 profile.Stats[0].Kind = db.VanityStatKind(stat0) 502 } 503 504 if stat1 != "" { 505 profile.Stats[1].Kind = db.VanityStatKind(stat1) 506 } 507 508 if err := db.ValidateProfile(s.db, profile); err != nil { 509 log.Println("invalid profile", err) 510 s.pages.Notice(w, "update-profile", err.Error()) 511 return 512 } 513 514 s.updateProfile(profile, w, r) 515} 516 517func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 518 user := s.oauth.GetUser(r) 519 520 err := r.ParseForm() 521 if err != nil { 522 log.Println("invalid profile update form", err) 523 s.pages.Notice(w, "update-profile", "Invalid form.") 524 return 525 } 526 527 profile, err := db.GetProfile(s.db, user.Did) 528 if err != nil { 529 log.Printf("getting profile data for %s: %s", user.Did, err) 530 } 531 532 i := 0 533 var pinnedRepos [6]syntax.ATURI 534 for key, values := range r.Form { 535 if i >= 6 { 536 log.Println("invalid pin update form", err) 537 s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.") 538 return 539 } 540 if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 { 541 aturi, err := syntax.ParseATURI(values[0]) 542 if err != nil { 543 log.Println("invalid profile update form", err) 544 s.pages.Notice(w, "update-profile", "Invalid form.") 545 return 546 } 547 pinnedRepos[i] = aturi 548 i++ 549 } 550 } 551 profile.PinnedRepos = pinnedRepos 552 553 s.updateProfile(profile, w, r) 554} 555 556func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { 557 user := s.oauth.GetUser(r) 558 tx, err := s.db.BeginTx(r.Context(), nil) 559 if err != nil { 560 log.Println("failed to start transaction", err) 561 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 562 return 563 } 564 565 client, err := s.oauth.AuthorizedClient(r) 566 if err != nil { 567 log.Println("failed to get authorized client", err) 568 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 569 return 570 } 571 572 // yeah... lexgen dose not support syntax.ATURI in the record for some reason, 573 // nor does it support exact size arrays 574 var pinnedRepoStrings []string 575 for _, r := range profile.PinnedRepos { 576 pinnedRepoStrings = append(pinnedRepoStrings, r.String()) 577 } 578 579 var vanityStats []string 580 for _, v := range profile.Stats { 581 vanityStats = append(vanityStats, string(v.Kind)) 582 } 583 584 ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 585 var cid *string 586 if ex != nil { 587 cid = ex.Cid 588 } 589 590 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 591 Collection: tangled.ActorProfileNSID, 592 Repo: user.Did, 593 Rkey: "self", 594 Record: &lexutil.LexiconTypeDecoder{ 595 Val: &tangled.ActorProfile{ 596 Bluesky: profile.IncludeBluesky, 597 Description: &profile.Description, 598 Links: profile.Links[:], 599 Location: &profile.Location, 600 PinnedRepositories: pinnedRepoStrings, 601 Stats: vanityStats[:], 602 }}, 603 SwapRecord: cid, 604 }) 605 if err != nil { 606 log.Println("failed to update profile", err) 607 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") 608 return 609 } 610 611 err = db.UpsertProfile(tx, profile) 612 if err != nil { 613 log.Println("failed to update profile", err) 614 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 615 return 616 } 617 618 s.notifier.UpdateProfile(r.Context(), profile) 619 620 s.pages.HxRedirect(w, "/"+user.Did) 621} 622 623func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 624 user := s.oauth.GetUser(r) 625 626 profile, err := db.GetProfile(s.db, user.Did) 627 if err != nil { 628 log.Printf("getting profile data for %s: %s", user.Did, err) 629 } 630 631 s.pages.EditBioFragment(w, pages.EditBioParams{ 632 LoggedInUser: user, 633 Profile: profile, 634 }) 635} 636 637func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 638 user := s.oauth.GetUser(r) 639 640 profile, err := db.GetProfile(s.db, user.Did) 641 if err != nil { 642 log.Printf("getting profile data for %s: %s", user.Did, err) 643 } 644 645 repos, err := db.GetAllReposByDid(s.db, user.Did) 646 if err != nil { 647 log.Printf("getting repos for %s: %s", user.Did, err) 648 } 649 650 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) 651 if err != nil { 652 log.Printf("getting collaborating repos for %s: %s", user.Did, err) 653 } 654 655 allRepos := []pages.PinnedRepo{} 656 657 for _, r := range repos { 658 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 659 allRepos = append(allRepos, pages.PinnedRepo{ 660 IsPinned: isPinned, 661 Repo: r, 662 }) 663 } 664 for _, r := range collaboratingRepos { 665 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 666 allRepos = append(allRepos, pages.PinnedRepo{ 667 IsPinned: isPinned, 668 Repo: r, 669 }) 670 } 671 672 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 673 LoggedInUser: user, 674 Profile: profile, 675 AllRepos: allRepos, 676 }) 677}