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