forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
this repo has no description
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}