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}