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