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