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