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