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