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 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) feedFromRequest(w http.ResponseWriter, r *http.Request) *feeds.Feed { 179 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 180 if !ok { 181 s.pages.Error404(w) 182 return nil 183 } 184 185 feed, err := s.GetProfileFeed(r.Context(), ident.Handle.String(), ident.DID.String()) 186 if err != nil { 187 s.pages.Error500(w) 188 return nil 189 } 190 191 return feed 192} 193 194func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 195 feed := s.feedFromRequest(w, r) 196 if feed == nil { 197 return 198 } 199 200 atom, err := feed.ToAtom() 201 if err != nil { 202 s.pages.Error500(w) 203 return 204 } 205 206 w.Header().Set("content-type", "application/atom+xml") 207 w.Write([]byte(atom)) 208} 209 210func (s *State) GetProfileFeed(ctx context.Context, handle string, did string) (*feeds.Feed, error) { 211 timeline, err := db.MakeProfileTimeline(s.db, did) 212 if err != nil { 213 return nil, err 214 } 215 216 author := &feeds.Author{ 217 Name: fmt.Sprintf("@%s", handle), 218 } 219 feed := &feeds.Feed{ 220 Title: fmt.Sprintf("timeline feed for %s", author.Name), 221 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, handle), Type: "text/html", Rel: "alternate"}, 222 Items: make([]*feeds.Item, 0), 223 Updated: time.UnixMilli(0), 224 Author: author, 225 } 226 for _, byMonth := range timeline.ByMonth { 227 for _, pull := range byMonth.PullEvents.Items { 228 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 229 if err != nil { 230 return nil, err 231 } 232 feed.Items = append(feed.Items, &feeds.Item{ 233 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 234 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"}, 235 Created: pull.Created, 236 Author: author, 237 }) 238 for _, submission := range pull.Submissions { 239 feed.Items = append(feed.Items, &feeds.Item{ 240 Title: fmt.Sprintf("%s submitted pull request '%s' (round #%d) in @%s/%s", author.Name, pull.Title, submission.RoundNumber, owner.Handle, pull.Repo.Name), 241 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"}, 242 Created: submission.Created, 243 Author: author, 244 }) 245 } 246 } 247 for _, issue := range byMonth.IssueEvents.Items { 248 owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 249 if err != nil { 250 return nil, err 251 } 252 feed.Items = append(feed.Items, &feeds.Item{ 253 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 254 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"}, 255 Created: issue.Created, 256 Author: author, 257 }) 258 } 259 for _, repo := range byMonth.RepoEvents { 260 var title string 261 if repo.Source != nil { 262 id, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 263 if err != nil { 264 return nil, err 265 } 266 title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, id.Handle, repo.Source.Name, repo.Repo.Name) 267 } else { 268 title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 269 } 270 feed.Items = append(feed.Items, &feeds.Item{ 271 Title: title, 272 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, handle, repo.Repo.Name), Type: "text/html", Rel: "alternate"}, 273 Created: repo.Repo.Created, 274 Author: author, 275 }) 276 } 277 } 278 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 279 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 280 }) 281 if len(feed.Items) > 0 { 282 feed.Updated = feed.Items[0].Created 283 } 284 285 return feed, nil 286} 287 288func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 289 user := s.oauth.GetUser(r) 290 291 err := r.ParseForm() 292 if err != nil { 293 log.Println("invalid profile update form", err) 294 s.pages.Notice(w, "update-profile", "Invalid form.") 295 return 296 } 297 298 profile, err := db.GetProfile(s.db, user.Did) 299 if err != nil { 300 log.Printf("getting profile data for %s: %s", user.Did, err) 301 } 302 303 profile.Description = r.FormValue("description") 304 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 305 profile.Location = r.FormValue("location") 306 307 var links [5]string 308 for i := range 5 { 309 iLink := r.FormValue(fmt.Sprintf("link%d", i)) 310 links[i] = iLink 311 } 312 profile.Links = links 313 314 // Parse stats (exactly 2) 315 stat0 := r.FormValue("stat0") 316 stat1 := r.FormValue("stat1") 317 318 if stat0 != "" { 319 profile.Stats[0].Kind = db.VanityStatKind(stat0) 320 } 321 322 if stat1 != "" { 323 profile.Stats[1].Kind = db.VanityStatKind(stat1) 324 } 325 326 if err := db.ValidateProfile(s.db, profile); err != nil { 327 log.Println("invalid profile", err) 328 s.pages.Notice(w, "update-profile", err.Error()) 329 return 330 } 331 332 s.updateProfile(profile, w, r) 333} 334 335func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 336 user := s.oauth.GetUser(r) 337 338 err := r.ParseForm() 339 if err != nil { 340 log.Println("invalid profile update form", err) 341 s.pages.Notice(w, "update-profile", "Invalid form.") 342 return 343 } 344 345 profile, err := db.GetProfile(s.db, user.Did) 346 if err != nil { 347 log.Printf("getting profile data for %s: %s", user.Did, err) 348 } 349 350 i := 0 351 var pinnedRepos [6]syntax.ATURI 352 for key, values := range r.Form { 353 if i >= 6 { 354 log.Println("invalid pin update form", err) 355 s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.") 356 return 357 } 358 if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 { 359 aturi, err := syntax.ParseATURI(values[0]) 360 if err != nil { 361 log.Println("invalid profile update form", err) 362 s.pages.Notice(w, "update-profile", "Invalid form.") 363 return 364 } 365 pinnedRepos[i] = aturi 366 i++ 367 } 368 } 369 profile.PinnedRepos = pinnedRepos 370 371 s.updateProfile(profile, w, r) 372} 373 374func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { 375 user := s.oauth.GetUser(r) 376 tx, err := s.db.BeginTx(r.Context(), nil) 377 if err != nil { 378 log.Println("failed to start transaction", err) 379 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 380 return 381 } 382 383 client, err := s.oauth.AuthorizedClient(r) 384 if err != nil { 385 log.Println("failed to get authorized client", err) 386 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 387 return 388 } 389 390 // yeah... lexgen dose not support syntax.ATURI in the record for some reason, 391 // nor does it support exact size arrays 392 var pinnedRepoStrings []string 393 for _, r := range profile.PinnedRepos { 394 pinnedRepoStrings = append(pinnedRepoStrings, r.String()) 395 } 396 397 var vanityStats []string 398 for _, v := range profile.Stats { 399 vanityStats = append(vanityStats, string(v.Kind)) 400 } 401 402 ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 403 var cid *string 404 if ex != nil { 405 cid = ex.Cid 406 } 407 408 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 409 Collection: tangled.ActorProfileNSID, 410 Repo: user.Did, 411 Rkey: "self", 412 Record: &lexutil.LexiconTypeDecoder{ 413 Val: &tangled.ActorProfile{ 414 Bluesky: profile.IncludeBluesky, 415 Description: &profile.Description, 416 Links: profile.Links[:], 417 Location: &profile.Location, 418 PinnedRepositories: pinnedRepoStrings, 419 Stats: vanityStats[:], 420 }}, 421 SwapRecord: cid, 422 }) 423 if err != nil { 424 log.Println("failed to update profile", err) 425 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") 426 return 427 } 428 429 err = db.UpsertProfile(tx, profile) 430 if err != nil { 431 log.Println("failed to update profile", err) 432 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 433 return 434 } 435 436 s.notifier.UpdateProfile(r.Context(), profile) 437 438 s.pages.HxRedirect(w, "/"+user.Did) 439} 440 441func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 442 user := s.oauth.GetUser(r) 443 444 profile, err := db.GetProfile(s.db, user.Did) 445 if err != nil { 446 log.Printf("getting profile data for %s: %s", user.Did, err) 447 } 448 449 s.pages.EditBioFragment(w, pages.EditBioParams{ 450 LoggedInUser: user, 451 Profile: profile, 452 }) 453} 454 455func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 456 user := s.oauth.GetUser(r) 457 458 profile, err := db.GetProfile(s.db, user.Did) 459 if err != nil { 460 log.Printf("getting profile data for %s: %s", user.Did, err) 461 } 462 463 repos, err := db.GetAllReposByDid(s.db, user.Did) 464 if err != nil { 465 log.Printf("getting repos for %s: %s", user.Did, err) 466 } 467 468 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) 469 if err != nil { 470 log.Printf("getting collaborating repos for %s: %s", user.Did, err) 471 } 472 473 allRepos := []pages.PinnedRepo{} 474 475 for _, r := range repos { 476 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 477 allRepos = append(allRepos, pages.PinnedRepo{ 478 IsPinned: isPinned, 479 Repo: r, 480 }) 481 } 482 for _, r := range collaboratingRepos { 483 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 484 allRepos = append(allRepos, pages.PinnedRepo{ 485 IsPinned: isPinned, 486 Repo: r, 487 }) 488 } 489 490 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 491 LoggedInUser: user, 492 Profile: profile, 493 AllRepos: allRepos, 494 }) 495}