Monorepo for Tangled
at 4e5b1f20c286f8be25a3878a33ca22b6371bdce1 672 lines 19 kB view raw
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}