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