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