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