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