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