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 "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}