forked from tangled.org/core
this repo has no description
at test-ci 12 kB view raw
1package state 2 3import ( 4 "crypto/hmac" 5 "crypto/sha256" 6 "encoding/hex" 7 "fmt" 8 "log" 9 "net/http" 10 "slices" 11 "strings" 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 "github.com/bluesky-social/indigo/atproto/identity" 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 "github.com/go-chi/chi/v5" 19 "github.com/posthog/posthog-go" 20 "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview/db" 22 "tangled.sh/tangled.sh/core/appview/pages" 23) 24 25func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 26 tabVal := r.URL.Query().Get("tab") 27 switch tabVal { 28 case "": 29 s.profilePage(w, r) 30 case "repos": 31 s.reposPage(w, r) 32 } 33} 34 35func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 36 didOrHandle := chi.URLParam(r, "user") 37 if didOrHandle == "" { 38 http.Error(w, "Bad request", http.StatusBadRequest) 39 return 40 } 41 42 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 43 if !ok { 44 s.pages.Error404(w) 45 return 46 } 47 48 profile, err := db.GetProfile(s.db, ident.DID.String()) 49 if err != nil { 50 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 51 } 52 53 repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 54 if err != nil { 55 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 56 } 57 58 // filter out ones that are pinned 59 pinnedRepos := []db.Repo{} 60 for i, r := range repos { 61 // if this is a pinned repo, add it 62 if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 63 pinnedRepos = append(pinnedRepos, r) 64 } 65 66 // if there are no saved pins, add the first 4 repos 67 if profile.IsPinnedReposEmpty() && i < 4 { 68 pinnedRepos = append(pinnedRepos, r) 69 } 70 } 71 72 collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 73 if err != nil { 74 log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 75 } 76 77 pinnedCollaboratingRepos := []db.Repo{} 78 for _, r := range collaboratingRepos { 79 // if this is a pinned repo, add it 80 if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 81 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 82 } 83 } 84 85 timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 86 if err != nil { 87 log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 88 } 89 90 var didsToResolve []string 91 for _, r := range collaboratingRepos { 92 didsToResolve = append(didsToResolve, r.Did) 93 } 94 for _, byMonth := range timeline.ByMonth { 95 for _, pe := range byMonth.PullEvents.Items { 96 didsToResolve = append(didsToResolve, pe.Repo.Did) 97 } 98 for _, ie := range byMonth.IssueEvents.Items { 99 didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did) 100 } 101 for _, re := range byMonth.RepoEvents { 102 didsToResolve = append(didsToResolve, re.Repo.Did) 103 if re.Source != nil { 104 didsToResolve = append(didsToResolve, re.Source.Did) 105 } 106 } 107 } 108 109 resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 110 didHandleMap := make(map[string]string) 111 for _, identity := range resolvedIds { 112 if !identity.Handle.IsInvalidHandle() { 113 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 114 } else { 115 didHandleMap[identity.DID.String()] = identity.DID.String() 116 } 117 } 118 119 followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 120 if err != nil { 121 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 122 } 123 124 loggedInUser := s.oauth.GetUser(r) 125 followStatus := db.IsNotFollowing 126 if loggedInUser != nil { 127 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 128 } 129 130 now := time.Now() 131 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 132 punchcard, err := db.MakePunchcard( 133 s.db, 134 db.FilterEq("did", ident.DID.String()), 135 db.FilterGte("date", startOfYear.Format(time.DateOnly)), 136 db.FilterLte("date", now.Format(time.DateOnly)), 137 ) 138 if err != nil { 139 log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 140 } 141 142 profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 143 s.pages.ProfilePage(w, pages.ProfilePageParams{ 144 LoggedInUser: loggedInUser, 145 Repos: pinnedRepos, 146 CollaboratingRepos: pinnedCollaboratingRepos, 147 DidHandleMap: didHandleMap, 148 Card: pages.ProfileCard{ 149 UserDid: ident.DID.String(), 150 UserHandle: ident.Handle.String(), 151 AvatarUri: profileAvatarUri, 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.GetAllReposByDid(s.db, ident.DID.String()) 175 if err != nil { 176 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 177 } 178 179 loggedInUser := s.oauth.GetUser(r) 180 followStatus := db.IsNotFollowing 181 if loggedInUser != nil { 182 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 183 } 184 185 followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 186 if err != nil { 187 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 188 } 189 190 profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 191 192 s.pages.ReposPage(w, pages.ReposPageParams{ 193 LoggedInUser: loggedInUser, 194 Repos: repos, 195 Card: pages.ProfileCard{ 196 UserDid: ident.DID.String(), 197 UserHandle: ident.Handle.String(), 198 AvatarUri: profileAvatarUri, 199 Profile: profile, 200 FollowStatus: followStatus, 201 Followers: followers, 202 Following: following, 203 }, 204 }) 205} 206 207func (s *State) GetAvatarUri(handle string) string { 208 secret := s.config.Avatar.SharedSecret 209 h := hmac.New(sha256.New, []byte(secret)) 210 h.Write([]byte(handle)) 211 signature := hex.EncodeToString(h.Sum(nil)) 212 return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle) 213} 214 215func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 216 user := s.oauth.GetUser(r) 217 218 err := r.ParseForm() 219 if err != nil { 220 log.Println("invalid profile update form", err) 221 s.pages.Notice(w, "update-profile", "Invalid form.") 222 return 223 } 224 225 profile, err := db.GetProfile(s.db, user.Did) 226 if err != nil { 227 log.Printf("getting profile data for %s: %s", user.Did, err) 228 } 229 230 profile.Description = r.FormValue("description") 231 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 232 profile.Location = r.FormValue("location") 233 234 var links [5]string 235 for i := range 5 { 236 iLink := r.FormValue(fmt.Sprintf("link%d", i)) 237 links[i] = iLink 238 } 239 profile.Links = links 240 241 // Parse stats (exactly 2) 242 stat0 := r.FormValue("stat0") 243 stat1 := r.FormValue("stat1") 244 245 if stat0 != "" { 246 profile.Stats[0].Kind = db.VanityStatKind(stat0) 247 } 248 249 if stat1 != "" { 250 profile.Stats[1].Kind = db.VanityStatKind(stat1) 251 } 252 253 if err := db.ValidateProfile(s.db, profile); err != nil { 254 log.Println("invalid profile", err) 255 s.pages.Notice(w, "update-profile", err.Error()) 256 return 257 } 258 259 s.updateProfile(profile, w, r) 260 return 261} 262 263func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 264 user := s.oauth.GetUser(r) 265 266 err := r.ParseForm() 267 if err != nil { 268 log.Println("invalid profile update form", err) 269 s.pages.Notice(w, "update-profile", "Invalid form.") 270 return 271 } 272 273 profile, err := db.GetProfile(s.db, user.Did) 274 if err != nil { 275 log.Printf("getting profile data for %s: %s", user.Did, err) 276 } 277 278 i := 0 279 var pinnedRepos [6]syntax.ATURI 280 for key, values := range r.Form { 281 if i >= 6 { 282 log.Println("invalid pin update form", err) 283 s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.") 284 return 285 } 286 if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 { 287 aturi, err := syntax.ParseATURI(values[0]) 288 if err != nil { 289 log.Println("invalid profile update form", err) 290 s.pages.Notice(w, "update-profile", "Invalid form.") 291 return 292 } 293 pinnedRepos[i] = aturi 294 i++ 295 } 296 } 297 profile.PinnedRepos = pinnedRepos 298 299 s.updateProfile(profile, w, r) 300 return 301} 302 303func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { 304 user := s.oauth.GetUser(r) 305 tx, err := s.db.BeginTx(r.Context(), nil) 306 if err != nil { 307 log.Println("failed to start transaction", err) 308 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 309 return 310 } 311 312 client, err := s.oauth.AuthorizedClient(r) 313 if err != nil { 314 log.Println("failed to get authorized client", err) 315 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 316 return 317 } 318 319 // yeah... lexgen dose not support syntax.ATURI in the record for some reason, 320 // nor does it support exact size arrays 321 var pinnedRepoStrings []string 322 for _, r := range profile.PinnedRepos { 323 pinnedRepoStrings = append(pinnedRepoStrings, r.String()) 324 } 325 326 var vanityStats []string 327 for _, v := range profile.Stats { 328 vanityStats = append(vanityStats, string(v.Kind)) 329 } 330 331 ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 332 var cid *string 333 if ex != nil { 334 cid = ex.Cid 335 } 336 337 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 338 Collection: tangled.ActorProfileNSID, 339 Repo: user.Did, 340 Rkey: "self", 341 Record: &lexutil.LexiconTypeDecoder{ 342 Val: &tangled.ActorProfile{ 343 Bluesky: profile.IncludeBluesky, 344 Description: &profile.Description, 345 Links: profile.Links[:], 346 Location: &profile.Location, 347 PinnedRepositories: pinnedRepoStrings, 348 Stats: vanityStats[:], 349 }}, 350 SwapRecord: cid, 351 }) 352 if err != nil { 353 log.Println("failed to update profile", err) 354 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") 355 return 356 } 357 358 err = db.UpsertProfile(tx, profile) 359 if err != nil { 360 log.Println("failed to update profile", err) 361 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 362 return 363 } 364 365 if !s.config.Core.Dev { 366 err = s.posthog.Enqueue(posthog.Capture{ 367 DistinctId: user.Did, 368 Event: "edit_profile", 369 }) 370 if err != nil { 371 log.Println("failed to enqueue posthog event:", err) 372 } 373 } 374 375 s.pages.HxRedirect(w, "/"+user.Did) 376 return 377} 378 379func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 380 user := s.oauth.GetUser(r) 381 382 profile, err := db.GetProfile(s.db, user.Did) 383 if err != nil { 384 log.Printf("getting profile data for %s: %s", user.Did, err) 385 } 386 387 s.pages.EditBioFragment(w, pages.EditBioParams{ 388 LoggedInUser: user, 389 Profile: profile, 390 }) 391} 392 393func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 394 user := s.oauth.GetUser(r) 395 396 profile, err := db.GetProfile(s.db, user.Did) 397 if err != nil { 398 log.Printf("getting profile data for %s: %s", user.Did, err) 399 } 400 401 repos, err := db.GetAllReposByDid(s.db, user.Did) 402 if err != nil { 403 log.Printf("getting repos for %s: %s", user.Did, err) 404 } 405 406 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) 407 if err != nil { 408 log.Printf("getting collaborating repos for %s: %s", user.Did, err) 409 } 410 411 allRepos := []pages.PinnedRepo{} 412 413 for _, r := range repos { 414 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 415 allRepos = append(allRepos, pages.PinnedRepo{ 416 IsPinned: isPinned, 417 Repo: r, 418 }) 419 } 420 for _, r := range collaboratingRepos { 421 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 422 allRepos = append(allRepos, pages.PinnedRepo{ 423 IsPinned: isPinned, 424 Repo: r, 425 }) 426 } 427 428 var didsToResolve []string 429 for _, r := range allRepos { 430 didsToResolve = append(didsToResolve, r.Did) 431 } 432 resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 433 didHandleMap := make(map[string]string) 434 for _, identity := range resolvedIds { 435 if !identity.Handle.IsInvalidHandle() { 436 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 437 } else { 438 didHandleMap[identity.DID.String()] = identity.DID.String() 439 } 440 } 441 442 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 443 LoggedInUser: user, 444 Profile: profile, 445 AllRepos: allRepos, 446 DidHandleMap: didHandleMap, 447 }) 448}