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