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