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/pages" 21) 22 23func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 24 tabVal := r.URL.Query().Get("tab") 25 switch tabVal { 26 case "": 27 s.profilePage(w, r) 28 case "repos": 29 s.reposPage(w, r) 30 } 31} 32 33func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 34 didOrHandle := chi.URLParam(r, "user") 35 if didOrHandle == "" { 36 http.Error(w, "Bad request", http.StatusBadRequest) 37 return 38 } 39 40 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 41 if !ok { 42 s.pages.Error404(w) 43 return 44 } 45 46 profile, err := db.GetProfile(s.db, ident.DID.String()) 47 if err != nil { 48 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 49 } 50 51 repos, err := db.GetRepos( 52 s.db, 53 0, 54 db.FilterEq("did", ident.DID.String()), 55 ) 56 if err != nil { 57 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 58 } 59 60 // filter out ones that are pinned 61 pinnedRepos := []db.Repo{} 62 for i, r := range repos { 63 // if this is a pinned repo, add it 64 if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 65 pinnedRepos = append(pinnedRepos, r) 66 } 67 68 // if there are no saved pins, add the first 4 repos 69 if profile.IsPinnedReposEmpty() && i < 4 { 70 pinnedRepos = append(pinnedRepos, r) 71 } 72 } 73 74 collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 75 if err != nil { 76 log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 77 } 78 79 pinnedCollaboratingRepos := []db.Repo{} 80 for _, r := range collaboratingRepos { 81 // if this is a pinned repo, add it 82 if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 83 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 84 } 85 } 86 87 timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 88 if err != nil { 89 log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 90 } 91 92 followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 93 if err != nil { 94 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 95 } 96 97 loggedInUser := s.oauth.GetUser(r) 98 followStatus := db.IsNotFollowing 99 if loggedInUser != nil { 100 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 101 } 102 103 now := time.Now() 104 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 105 punchcard, err := db.MakePunchcard( 106 s.db, 107 db.FilterEq("did", ident.DID.String()), 108 db.FilterGte("date", startOfYear.Format(time.DateOnly)), 109 db.FilterLte("date", now.Format(time.DateOnly)), 110 ) 111 if err != nil { 112 log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 113 } 114 115 s.pages.ProfilePage(w, pages.ProfilePageParams{ 116 LoggedInUser: loggedInUser, 117 Repos: pinnedRepos, 118 CollaboratingRepos: pinnedCollaboratingRepos, 119 Card: pages.ProfileCard{ 120 UserDid: ident.DID.String(), 121 UserHandle: ident.Handle.String(), 122 Profile: profile, 123 FollowStatus: followStatus, 124 Followers: followers, 125 Following: following, 126 }, 127 Punchcard: punchcard, 128 ProfileTimeline: timeline, 129 }) 130} 131 132func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 133 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 134 if !ok { 135 s.pages.Error404(w) 136 return 137 } 138 139 profile, err := db.GetProfile(s.db, ident.DID.String()) 140 if err != nil { 141 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 142 } 143 144 repos, err := db.GetRepos( 145 s.db, 146 0, 147 db.FilterEq("did", ident.DID.String()), 148 ) 149 if err != nil { 150 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 151 } 152 153 loggedInUser := s.oauth.GetUser(r) 154 followStatus := db.IsNotFollowing 155 if loggedInUser != nil { 156 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 157 } 158 159 followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 160 if err != nil { 161 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 162 } 163 164 s.pages.ReposPage(w, pages.ReposPageParams{ 165 LoggedInUser: loggedInUser, 166 Repos: repos, 167 Card: pages.ProfileCard{ 168 UserDid: ident.DID.String(), 169 UserHandle: ident.Handle.String(), 170 Profile: profile, 171 FollowStatus: followStatus, 172 Followers: followers, 173 Following: following, 174 }, 175 }) 176} 177 178func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 179 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 180 if !ok { 181 s.pages.Error404(w) 182 return 183 } 184 185 feed, err := s.getProfileFeed(r.Context(), &ident) 186 if err != nil { 187 s.pages.Error500(w) 188 return 189 } 190 191 if feed == nil { 192 return 193 } 194 195 atom, err := feed.ToAtom() 196 if err != nil { 197 s.pages.Error500(w) 198 return 199 } 200 201 w.Header().Set("content-type", "application/atom+xml") 202 w.Write([]byte(atom)) 203} 204 205func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 206 timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 207 if err != nil { 208 return nil, err 209 } 210 211 author := &feeds.Author{ 212 Name: fmt.Sprintf("@%s", id.Handle), 213 } 214 215 feed := feeds.Feed{ 216 Title: fmt.Sprintf("%s's timeline", author.Name), 217 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"}, 218 Items: make([]*feeds.Item, 0), 219 Updated: time.UnixMilli(0), 220 Author: author, 221 } 222 223 for _, byMonth := range timeline.ByMonth { 224 if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil { 225 return nil, err 226 } 227 if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil { 228 return nil, err 229 } 230 if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil { 231 return nil, err 232 } 233 } 234 235 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 236 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 237 }) 238 239 if len(feed.Items) > 0 { 240 feed.Updated = feed.Items[0].Created 241 } 242 243 return &feed, nil 244} 245 246func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 247 for _, pull := range pulls { 248 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 249 if err != nil { 250 return err 251 } 252 253 // Add pull request creation item 254 feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author)) 255 } 256 return nil 257} 258 259func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 260 for _, issue := range issues { 261 owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 262 if err != nil { 263 return err 264 } 265 266 feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author)) 267 } 268 return nil 269} 270 271func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 272 for _, repo := range repos { 273 item, err := s.createRepoItem(ctx, repo, author) 274 if err != nil { 275 return err 276 } 277 feed.Items = append(feed.Items, item) 278 } 279 return nil 280} 281 282func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 283 return &feeds.Item{ 284 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 285 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"}, 286 Created: pull.Created, 287 Author: author, 288 } 289} 290 291func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 292 return &feeds.Item{ 293 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 294 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"}, 295 Created: issue.Created, 296 Author: author, 297 } 298} 299 300func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 301 var title string 302 if repo.Source != nil { 303 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 304 if err != nil { 305 return nil, err 306 } 307 title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name) 308 } else { 309 title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 310 } 311 312 return &feeds.Item{ 313 Title: title, 314 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 315 Created: repo.Repo.Created, 316 Author: author, 317 }, nil 318} 319 320func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 321 user := s.oauth.GetUser(r) 322 323 err := r.ParseForm() 324 if err != nil { 325 log.Println("invalid profile update form", err) 326 s.pages.Notice(w, "update-profile", "Invalid form.") 327 return 328 } 329 330 profile, err := db.GetProfile(s.db, user.Did) 331 if err != nil { 332 log.Printf("getting profile data for %s: %s", user.Did, err) 333 } 334 335 profile.Description = r.FormValue("description") 336 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 337 profile.Location = r.FormValue("location") 338 339 var links [5]string 340 for i := range 5 { 341 iLink := r.FormValue(fmt.Sprintf("link%d", i)) 342 links[i] = iLink 343 } 344 profile.Links = links 345 346 // Parse stats (exactly 2) 347 stat0 := r.FormValue("stat0") 348 stat1 := r.FormValue("stat1") 349 350 if stat0 != "" { 351 profile.Stats[0].Kind = db.VanityStatKind(stat0) 352 } 353 354 if stat1 != "" { 355 profile.Stats[1].Kind = db.VanityStatKind(stat1) 356 } 357 358 if err := db.ValidateProfile(s.db, profile); err != nil { 359 log.Println("invalid profile", err) 360 s.pages.Notice(w, "update-profile", err.Error()) 361 return 362 } 363 364 s.updateProfile(profile, w, r) 365} 366 367func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 368 user := s.oauth.GetUser(r) 369 370 err := r.ParseForm() 371 if err != nil { 372 log.Println("invalid profile update form", err) 373 s.pages.Notice(w, "update-profile", "Invalid form.") 374 return 375 } 376 377 profile, err := db.GetProfile(s.db, user.Did) 378 if err != nil { 379 log.Printf("getting profile data for %s: %s", user.Did, err) 380 } 381 382 i := 0 383 var pinnedRepos [6]syntax.ATURI 384 for key, values := range r.Form { 385 if i >= 6 { 386 log.Println("invalid pin update form", err) 387 s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.") 388 return 389 } 390 if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 { 391 aturi, err := syntax.ParseATURI(values[0]) 392 if err != nil { 393 log.Println("invalid profile update form", err) 394 s.pages.Notice(w, "update-profile", "Invalid form.") 395 return 396 } 397 pinnedRepos[i] = aturi 398 i++ 399 } 400 } 401 profile.PinnedRepos = pinnedRepos 402 403 s.updateProfile(profile, w, r) 404} 405 406func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { 407 user := s.oauth.GetUser(r) 408 tx, err := s.db.BeginTx(r.Context(), nil) 409 if err != nil { 410 log.Println("failed to start transaction", err) 411 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 412 return 413 } 414 415 client, err := s.oauth.AuthorizedClient(r) 416 if err != nil { 417 log.Println("failed to get authorized client", err) 418 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 419 return 420 } 421 422 // yeah... lexgen dose not support syntax.ATURI in the record for some reason, 423 // nor does it support exact size arrays 424 var pinnedRepoStrings []string 425 for _, r := range profile.PinnedRepos { 426 pinnedRepoStrings = append(pinnedRepoStrings, r.String()) 427 } 428 429 var vanityStats []string 430 for _, v := range profile.Stats { 431 vanityStats = append(vanityStats, string(v.Kind)) 432 } 433 434 ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 435 var cid *string 436 if ex != nil { 437 cid = ex.Cid 438 } 439 440 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 441 Collection: tangled.ActorProfileNSID, 442 Repo: user.Did, 443 Rkey: "self", 444 Record: &lexutil.LexiconTypeDecoder{ 445 Val: &tangled.ActorProfile{ 446 Bluesky: profile.IncludeBluesky, 447 Description: &profile.Description, 448 Links: profile.Links[:], 449 Location: &profile.Location, 450 PinnedRepositories: pinnedRepoStrings, 451 Stats: vanityStats[:], 452 }}, 453 SwapRecord: cid, 454 }) 455 if err != nil { 456 log.Println("failed to update profile", err) 457 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") 458 return 459 } 460 461 err = db.UpsertProfile(tx, profile) 462 if err != nil { 463 log.Println("failed to update profile", err) 464 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 465 return 466 } 467 468 s.notifier.UpdateProfile(r.Context(), profile) 469 470 s.pages.HxRedirect(w, "/"+user.Did) 471} 472 473func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 474 user := s.oauth.GetUser(r) 475 476 profile, err := db.GetProfile(s.db, user.Did) 477 if err != nil { 478 log.Printf("getting profile data for %s: %s", user.Did, err) 479 } 480 481 s.pages.EditBioFragment(w, pages.EditBioParams{ 482 LoggedInUser: user, 483 Profile: profile, 484 }) 485} 486 487func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 488 user := s.oauth.GetUser(r) 489 490 profile, err := db.GetProfile(s.db, user.Did) 491 if err != nil { 492 log.Printf("getting profile data for %s: %s", user.Did, err) 493 } 494 495 repos, err := db.GetAllReposByDid(s.db, user.Did) 496 if err != nil { 497 log.Printf("getting repos for %s: %s", user.Did, err) 498 } 499 500 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) 501 if err != nil { 502 log.Printf("getting collaborating repos for %s: %s", user.Did, err) 503 } 504 505 allRepos := []pages.PinnedRepo{} 506 507 for _, r := range repos { 508 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 509 allRepos = append(allRepos, pages.PinnedRepo{ 510 IsPinned: isPinned, 511 Repo: r, 512 }) 513 } 514 for _, r := range collaboratingRepos { 515 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 516 allRepos = append(allRepos, pages.PinnedRepo{ 517 IsPinned: isPinned, 518 Repo: r, 519 }) 520 } 521 522 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 523 LoggedInUser: user, 524 Profile: profile, 525 AllRepos: allRepos, 526 }) 527}