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