1package state
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "log"
9 "math/rand/v2"
10 "net/http"
11 "path"
12 "slices"
13 "strconv"
14 "strings"
15 "time"
16
17 "github.com/bluesky-social/indigo/atproto/data"
18 "github.com/bluesky-social/indigo/atproto/identity"
19 "github.com/bluesky-social/indigo/atproto/syntax"
20 securejoin "github.com/cyphar/filepath-securejoin"
21 "github.com/go-chi/chi/v5"
22 "tangled.sh/tangled.sh/core/api/tangled"
23 "tangled.sh/tangled.sh/core/appview/auth"
24 "tangled.sh/tangled.sh/core/appview/db"
25 "tangled.sh/tangled.sh/core/appview/pages"
26 "tangled.sh/tangled.sh/core/types"
27
28 comatproto "github.com/bluesky-social/indigo/api/atproto"
29 lexutil "github.com/bluesky-social/indigo/lex/util"
30)
31
32func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) {
33 ref := chi.URLParam(r, "ref")
34 f, err := fullyResolvedRepo(r)
35 if err != nil {
36 log.Println("failed to fully resolve repo", err)
37 return
38 }
39
40 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
41 if err != nil {
42 log.Printf("failed to create unsigned client for %s", f.Knot)
43 s.pages.Error503(w)
44 return
45 }
46
47 resp, err := us.Index(f.OwnerDid(), f.RepoName, ref)
48 if err != nil {
49 s.pages.Error503(w)
50 log.Println("failed to reach knotserver", err)
51 return
52 }
53 defer resp.Body.Close()
54
55 body, err := io.ReadAll(resp.Body)
56 if err != nil {
57 log.Printf("Error reading response body: %v", err)
58 return
59 }
60
61 var result types.RepoIndexResponse
62 err = json.Unmarshal(body, &result)
63 if err != nil {
64 log.Printf("Error unmarshalling response body: %v", err)
65 return
66 }
67
68 tagMap := make(map[string][]string)
69 for _, tag := range result.Tags {
70 hash := tag.Hash
71 tagMap[hash] = append(tagMap[hash], tag.Name)
72 }
73
74 for _, branch := range result.Branches {
75 hash := branch.Hash
76 tagMap[hash] = append(tagMap[hash], branch.Name)
77 }
78
79 emails := uniqueEmails(result.Commits)
80
81 user := s.auth.GetUser(r)
82 s.pages.RepoIndexPage(w, pages.RepoIndexParams{
83 LoggedInUser: user,
84 RepoInfo: f.RepoInfo(s, user),
85 TagMap: tagMap,
86 RepoIndexResponse: result,
87 EmailToDidOrHandle: EmailToDidOrHandle(s, emails),
88 })
89 return
90}
91
92func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {
93 f, err := fullyResolvedRepo(r)
94 if err != nil {
95 log.Println("failed to fully resolve repo", err)
96 return
97 }
98
99 page := 1
100 if r.URL.Query().Get("page") != "" {
101 page, err = strconv.Atoi(r.URL.Query().Get("page"))
102 if err != nil {
103 page = 1
104 }
105 }
106
107 ref := chi.URLParam(r, "ref")
108
109 protocol := "http"
110 if !s.config.Dev {
111 protocol = "https"
112 }
113
114 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/log/%s?page=%d&per_page=30", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, page))
115 if err != nil {
116 log.Println("failed to reach knotserver", err)
117 return
118 }
119
120 body, err := io.ReadAll(resp.Body)
121 if err != nil {
122 log.Printf("error reading response body: %v", err)
123 return
124 }
125
126 var repolog types.RepoLogResponse
127 err = json.Unmarshal(body, &repolog)
128 if err != nil {
129 log.Println("failed to parse json response", err)
130 return
131 }
132
133 user := s.auth.GetUser(r)
134 s.pages.RepoLog(w, pages.RepoLogParams{
135 LoggedInUser: user,
136 RepoInfo: f.RepoInfo(s, user),
137 RepoLogResponse: repolog,
138 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)),
139 })
140 return
141}
142
143func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
144 f, err := fullyResolvedRepo(r)
145 if err != nil {
146 log.Println("failed to get repo and knot", err)
147 w.WriteHeader(http.StatusBadRequest)
148 return
149 }
150
151 user := s.auth.GetUser(r)
152 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
153 RepoInfo: f.RepoInfo(s, user),
154 })
155 return
156}
157
158func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) {
159 f, err := fullyResolvedRepo(r)
160 if err != nil {
161 log.Println("failed to get repo and knot", err)
162 w.WriteHeader(http.StatusBadRequest)
163 return
164 }
165
166 repoAt := f.RepoAt
167 rkey := repoAt.RecordKey().String()
168 if rkey == "" {
169 log.Println("invalid aturi for repo", err)
170 w.WriteHeader(http.StatusInternalServerError)
171 return
172 }
173
174 user := s.auth.GetUser(r)
175
176 switch r.Method {
177 case http.MethodGet:
178 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
179 RepoInfo: f.RepoInfo(s, user),
180 })
181 return
182 case http.MethodPut:
183 user := s.auth.GetUser(r)
184 newDescription := r.FormValue("description")
185 client, _ := s.auth.AuthorizedClient(r)
186
187 // optimistic update
188 err = db.UpdateDescription(s.db, string(repoAt), newDescription)
189 if err != nil {
190 log.Println("failed to perferom update-description query", err)
191 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
192 return
193 }
194
195 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
196 //
197 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
198 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, user.Did, rkey)
199 if err != nil {
200 // failed to get record
201 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
202 return
203 }
204 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
205 Collection: tangled.RepoNSID,
206 Repo: user.Did,
207 Rkey: rkey,
208 SwapRecord: ex.Cid,
209 Record: &lexutil.LexiconTypeDecoder{
210 Val: &tangled.Repo{
211 Knot: f.Knot,
212 Name: f.RepoName,
213 Owner: user.Did,
214 AddedAt: &f.AddedAt,
215 Description: &newDescription,
216 },
217 },
218 })
219
220 if err != nil {
221 log.Println("failed to perferom update-description query", err)
222 // failed to get record
223 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
224 return
225 }
226
227 newRepoInfo := f.RepoInfo(s, user)
228 newRepoInfo.Description = newDescription
229
230 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
231 RepoInfo: newRepoInfo,
232 })
233 return
234 }
235}
236
237func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) {
238 f, err := fullyResolvedRepo(r)
239 if err != nil {
240 log.Println("failed to fully resolve repo", err)
241 return
242 }
243 ref := chi.URLParam(r, "ref")
244 protocol := "http"
245 if !s.config.Dev {
246 protocol = "https"
247 }
248 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
249 if err != nil {
250 log.Println("failed to reach knotserver", err)
251 return
252 }
253
254 body, err := io.ReadAll(resp.Body)
255 if err != nil {
256 log.Printf("Error reading response body: %v", err)
257 return
258 }
259
260 var result types.RepoCommitResponse
261 err = json.Unmarshal(body, &result)
262 if err != nil {
263 log.Println("failed to parse response:", err)
264 return
265 }
266
267 user := s.auth.GetUser(r)
268 s.pages.RepoCommit(w, pages.RepoCommitParams{
269 LoggedInUser: user,
270 RepoInfo: f.RepoInfo(s, user),
271 RepoCommitResponse: result,
272 EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}),
273 })
274 return
275}
276
277func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) {
278 f, err := fullyResolvedRepo(r)
279 if err != nil {
280 log.Println("failed to fully resolve repo", err)
281 return
282 }
283
284 ref := chi.URLParam(r, "ref")
285 treePath := chi.URLParam(r, "*")
286 protocol := "http"
287 if !s.config.Dev {
288 protocol = "https"
289 }
290 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
291 if err != nil {
292 log.Println("failed to reach knotserver", err)
293 return
294 }
295
296 body, err := io.ReadAll(resp.Body)
297 if err != nil {
298 log.Printf("Error reading response body: %v", err)
299 return
300 }
301
302 var result types.RepoTreeResponse
303 err = json.Unmarshal(body, &result)
304 if err != nil {
305 log.Println("failed to parse response:", err)
306 return
307 }
308
309 user := s.auth.GetUser(r)
310
311 var breadcrumbs [][]string
312 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
313 if treePath != "" {
314 for idx, elem := range strings.Split(treePath, "/") {
315 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
316 }
317 }
318
319 baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath)
320 baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath)
321
322 s.pages.RepoTree(w, pages.RepoTreeParams{
323 LoggedInUser: user,
324 BreadCrumbs: breadcrumbs,
325 BaseTreeLink: baseTreeLink,
326 BaseBlobLink: baseBlobLink,
327 RepoInfo: f.RepoInfo(s, user),
328 RepoTreeResponse: result,
329 })
330 return
331}
332
333func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) {
334 f, err := fullyResolvedRepo(r)
335 if err != nil {
336 log.Println("failed to get repo and knot", err)
337 return
338 }
339
340 protocol := "http"
341 if !s.config.Dev {
342 protocol = "https"
343 }
344
345 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tags", protocol, f.Knot, f.OwnerDid(), f.RepoName))
346 if err != nil {
347 log.Println("failed to reach knotserver", err)
348 return
349 }
350
351 body, err := io.ReadAll(resp.Body)
352 if err != nil {
353 log.Printf("Error reading response body: %v", err)
354 return
355 }
356
357 var result types.RepoTagsResponse
358 err = json.Unmarshal(body, &result)
359 if err != nil {
360 log.Println("failed to parse response:", err)
361 return
362 }
363
364 user := s.auth.GetUser(r)
365 s.pages.RepoTags(w, pages.RepoTagsParams{
366 LoggedInUser: user,
367 RepoInfo: f.RepoInfo(s, user),
368 RepoTagsResponse: result,
369 })
370 return
371}
372
373func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) {
374 f, err := fullyResolvedRepo(r)
375 if err != nil {
376 log.Println("failed to get repo and knot", err)
377 return
378 }
379
380 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
381 if err != nil {
382 log.Println("failed to create unsigned client", err)
383 return
384 }
385
386 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
387 if err != nil {
388 log.Println("failed to reach knotserver", err)
389 return
390 }
391
392 body, err := io.ReadAll(resp.Body)
393 if err != nil {
394 log.Printf("Error reading response body: %v", err)
395 return
396 }
397
398 var result types.RepoBranchesResponse
399 err = json.Unmarshal(body, &result)
400 if err != nil {
401 log.Println("failed to parse response:", err)
402 return
403 }
404
405 user := s.auth.GetUser(r)
406 s.pages.RepoBranches(w, pages.RepoBranchesParams{
407 LoggedInUser: user,
408 RepoInfo: f.RepoInfo(s, user),
409 RepoBranchesResponse: result,
410 })
411 return
412}
413
414func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) {
415 f, err := fullyResolvedRepo(r)
416 if err != nil {
417 log.Println("failed to get repo and knot", err)
418 return
419 }
420
421 ref := chi.URLParam(r, "ref")
422 filePath := chi.URLParam(r, "*")
423 protocol := "http"
424 if !s.config.Dev {
425 protocol = "https"
426 }
427 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
428 if err != nil {
429 log.Println("failed to reach knotserver", err)
430 return
431 }
432
433 body, err := io.ReadAll(resp.Body)
434 if err != nil {
435 log.Printf("Error reading response body: %v", err)
436 return
437 }
438
439 var result types.RepoBlobResponse
440 err = json.Unmarshal(body, &result)
441 if err != nil {
442 log.Println("failed to parse response:", err)
443 return
444 }
445
446 var breadcrumbs [][]string
447 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
448 if filePath != "" {
449 for idx, elem := range strings.Split(filePath, "/") {
450 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
451 }
452 }
453
454 user := s.auth.GetUser(r)
455 s.pages.RepoBlob(w, pages.RepoBlobParams{
456 LoggedInUser: user,
457 RepoInfo: f.RepoInfo(s, user),
458 RepoBlobResponse: result,
459 BreadCrumbs: breadcrumbs,
460 })
461 return
462}
463
464func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
465 f, err := fullyResolvedRepo(r)
466 if err != nil {
467 log.Println("failed to get repo and knot", err)
468 return
469 }
470
471 ref := chi.URLParam(r, "ref")
472 filePath := chi.URLParam(r, "*")
473
474 protocol := "http"
475 if !s.config.Dev {
476 protocol = "https"
477 }
478 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
479 if err != nil {
480 log.Println("failed to reach knotserver", err)
481 return
482 }
483
484 body, err := io.ReadAll(resp.Body)
485 if err != nil {
486 log.Printf("Error reading response body: %v", err)
487 return
488 }
489
490 var result types.RepoBlobResponse
491 err = json.Unmarshal(body, &result)
492 if err != nil {
493 log.Println("failed to parse response:", err)
494 return
495 }
496
497 if result.IsBinary {
498 w.Header().Set("Content-Type", "application/octet-stream")
499 w.Write(body)
500 return
501 }
502
503 w.Header().Set("Content-Type", "text/plain")
504 w.Write([]byte(result.Contents))
505 return
506}
507
508func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
509 f, err := fullyResolvedRepo(r)
510 if err != nil {
511 log.Println("failed to get repo and knot", err)
512 return
513 }
514
515 collaborator := r.FormValue("collaborator")
516 if collaborator == "" {
517 http.Error(w, "malformed form", http.StatusBadRequest)
518 return
519 }
520
521 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator)
522 if err != nil {
523 w.Write([]byte("failed to resolve collaborator did to a handle"))
524 return
525 }
526 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
527
528 // TODO: create an atproto record for this
529
530 secret, err := db.GetRegistrationKey(s.db, f.Knot)
531 if err != nil {
532 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
533 return
534 }
535
536 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
537 if err != nil {
538 log.Println("failed to create client to ", f.Knot)
539 return
540 }
541
542 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
543 if err != nil {
544 log.Printf("failed to make request to %s: %s", f.Knot, err)
545 return
546 }
547
548 if ksResp.StatusCode != http.StatusNoContent {
549 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err)))
550 return
551 }
552
553 tx, err := s.db.BeginTx(r.Context(), nil)
554 if err != nil {
555 log.Println("failed to start tx")
556 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
557 return
558 }
559 defer func() {
560 tx.Rollback()
561 err = s.enforcer.E.LoadPolicy()
562 if err != nil {
563 log.Println("failed to rollback policies")
564 }
565 }()
566
567 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo())
568 if err != nil {
569 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
570 return
571 }
572
573 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
574 if err != nil {
575 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
576 return
577 }
578
579 err = tx.Commit()
580 if err != nil {
581 log.Println("failed to commit changes", err)
582 http.Error(w, err.Error(), http.StatusInternalServerError)
583 return
584 }
585
586 err = s.enforcer.E.SavePolicy()
587 if err != nil {
588 log.Println("failed to update ACLs", err)
589 http.Error(w, err.Error(), http.StatusInternalServerError)
590 return
591 }
592
593 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String())))
594
595}
596
597func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
598 f, err := fullyResolvedRepo(r)
599 if err != nil {
600 log.Println("failed to get repo and knot", err)
601 return
602 }
603
604 switch r.Method {
605 case http.MethodGet:
606 // for now, this is just pubkeys
607 user := s.auth.GetUser(r)
608 repoCollaborators, err := f.Collaborators(r.Context(), s)
609 if err != nil {
610 log.Println("failed to get collaborators", err)
611 }
612
613 isCollaboratorInviteAllowed := false
614 if user != nil {
615 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo())
616 if err == nil && ok {
617 isCollaboratorInviteAllowed = true
618 }
619 }
620
621 s.pages.RepoSettings(w, pages.RepoSettingsParams{
622 LoggedInUser: user,
623 RepoInfo: f.RepoInfo(s, user),
624 Collaborators: repoCollaborators,
625 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
626 })
627 }
628}
629
630type FullyResolvedRepo struct {
631 Knot string
632 OwnerId identity.Identity
633 RepoName string
634 RepoAt syntax.ATURI
635 Description string
636 AddedAt string
637}
638
639func (f *FullyResolvedRepo) OwnerDid() string {
640 return f.OwnerId.DID.String()
641}
642
643func (f *FullyResolvedRepo) OwnerHandle() string {
644 return f.OwnerId.Handle.String()
645}
646
647func (f *FullyResolvedRepo) OwnerSlashRepo() string {
648 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
649 return p
650}
651
652func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
653 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot)
654 if err != nil {
655 return nil, err
656 }
657
658 var collaborators []pages.Collaborator
659 for _, item := range repoCollaborators {
660 // currently only two roles: owner and member
661 var role string
662 if item[3] == "repo:owner" {
663 role = "owner"
664 } else if item[3] == "repo:collaborator" {
665 role = "collaborator"
666 } else {
667 continue
668 }
669
670 did := item[0]
671
672 c := pages.Collaborator{
673 Did: did,
674 Handle: "",
675 Role: role,
676 }
677 collaborators = append(collaborators, c)
678 }
679
680 // populate all collborators with handles
681 identsToResolve := make([]string, len(collaborators))
682 for i, collab := range collaborators {
683 identsToResolve[i] = collab.Did
684 }
685
686 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve)
687 for i, resolved := range resolvedIdents {
688 if resolved != nil {
689 collaborators[i].Handle = resolved.Handle.String()
690 }
691 }
692
693 return collaborators, nil
694}
695
696func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo {
697 isStarred := false
698 if u != nil {
699 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
700 }
701
702 starCount, err := db.GetStarCount(s.db, f.RepoAt)
703 if err != nil {
704 log.Println("failed to get star count for ", f.RepoAt)
705 }
706 issueCount, err := db.GetIssueCount(s.db, f.RepoAt)
707 if err != nil {
708 log.Println("failed to get issue count for ", f.RepoAt)
709 }
710 pullCount, err := db.GetPullCount(s.db, f.RepoAt)
711 if err != nil {
712 log.Println("failed to get issue count for ", f.RepoAt)
713 }
714
715 knot := f.Knot
716 if knot == "knot1.tangled.sh" {
717 knot = "tangled.sh"
718 }
719
720 return pages.RepoInfo{
721 OwnerDid: f.OwnerDid(),
722 OwnerHandle: f.OwnerHandle(),
723 Name: f.RepoName,
724 RepoAt: f.RepoAt,
725 Description: f.Description,
726 IsStarred: isStarred,
727 Knot: knot,
728 Roles: RolesInRepo(s, u, f),
729 Stats: db.RepoStats{
730 StarCount: starCount,
731 IssueCount: issueCount,
732 PullCount: pullCount,
733 },
734 }
735}
736
737func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
738 user := s.auth.GetUser(r)
739 f, err := fullyResolvedRepo(r)
740 if err != nil {
741 log.Println("failed to get repo and knot", err)
742 return
743 }
744
745 issueId := chi.URLParam(r, "issue")
746 issueIdInt, err := strconv.Atoi(issueId)
747 if err != nil {
748 http.Error(w, "bad issue id", http.StatusBadRequest)
749 log.Println("failed to parse issue id", err)
750 return
751 }
752
753 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt)
754 if err != nil {
755 log.Println("failed to get issue and comments", err)
756 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
757 return
758 }
759
760 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
761 if err != nil {
762 log.Println("failed to resolve issue owner", err)
763 }
764
765 identsToResolve := make([]string, len(comments))
766 for i, comment := range comments {
767 identsToResolve[i] = comment.OwnerDid
768 }
769 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
770 didHandleMap := make(map[string]string)
771 for _, identity := range resolvedIds {
772 if !identity.Handle.IsInvalidHandle() {
773 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
774 } else {
775 didHandleMap[identity.DID.String()] = identity.DID.String()
776 }
777 }
778
779 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
780 LoggedInUser: user,
781 RepoInfo: f.RepoInfo(s, user),
782 Issue: *issue,
783 Comments: comments,
784
785 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
786 DidHandleMap: didHandleMap,
787 })
788
789}
790
791func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
792 user := s.auth.GetUser(r)
793 f, err := fullyResolvedRepo(r)
794 if err != nil {
795 log.Println("failed to get repo and knot", err)
796 return
797 }
798
799 issueId := chi.URLParam(r, "issue")
800 issueIdInt, err := strconv.Atoi(issueId)
801 if err != nil {
802 http.Error(w, "bad issue id", http.StatusBadRequest)
803 log.Println("failed to parse issue id", err)
804 return
805 }
806
807 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
808 if err != nil {
809 log.Println("failed to get issue", err)
810 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
811 return
812 }
813
814 collaborators, err := f.Collaborators(r.Context(), s)
815 if err != nil {
816 log.Println("failed to fetch repo collaborators: %w", err)
817 }
818 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
819 return user.Did == collab.Did
820 })
821 isIssueOwner := user.Did == issue.OwnerDid
822
823 // TODO: make this more granular
824 if isIssueOwner || isCollaborator {
825
826 closed := tangled.RepoIssueStateClosed
827
828 client, _ := s.auth.AuthorizedClient(r)
829 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
830 Collection: tangled.RepoIssueStateNSID,
831 Repo: user.Did,
832 Rkey: s.TID(),
833 Record: &lexutil.LexiconTypeDecoder{
834 Val: &tangled.RepoIssueState{
835 Issue: issue.IssueAt,
836 State: &closed,
837 },
838 },
839 })
840
841 if err != nil {
842 log.Println("failed to update issue state", err)
843 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
844 return
845 }
846
847 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt)
848 if err != nil {
849 log.Println("failed to close issue", err)
850 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
851 return
852 }
853
854 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
855 return
856 } else {
857 log.Println("user is not permitted to close issue")
858 http.Error(w, "for biden", http.StatusUnauthorized)
859 return
860 }
861}
862
863func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
864 user := s.auth.GetUser(r)
865 f, err := fullyResolvedRepo(r)
866 if err != nil {
867 log.Println("failed to get repo and knot", err)
868 return
869 }
870
871 issueId := chi.URLParam(r, "issue")
872 issueIdInt, err := strconv.Atoi(issueId)
873 if err != nil {
874 http.Error(w, "bad issue id", http.StatusBadRequest)
875 log.Println("failed to parse issue id", err)
876 return
877 }
878
879 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
880 if err != nil {
881 log.Println("failed to get issue", err)
882 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
883 return
884 }
885
886 collaborators, err := f.Collaborators(r.Context(), s)
887 if err != nil {
888 log.Println("failed to fetch repo collaborators: %w", err)
889 }
890 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
891 return user.Did == collab.Did
892 })
893 isIssueOwner := user.Did == issue.OwnerDid
894
895 if isCollaborator || isIssueOwner {
896 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
897 if err != nil {
898 log.Println("failed to reopen issue", err)
899 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
900 return
901 }
902 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
903 return
904 } else {
905 log.Println("user is not the owner of the repo")
906 http.Error(w, "forbidden", http.StatusUnauthorized)
907 return
908 }
909}
910
911func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
912 user := s.auth.GetUser(r)
913 f, err := fullyResolvedRepo(r)
914 if err != nil {
915 log.Println("failed to get repo and knot", err)
916 return
917 }
918
919 issueId := chi.URLParam(r, "issue")
920 issueIdInt, err := strconv.Atoi(issueId)
921 if err != nil {
922 http.Error(w, "bad issue id", http.StatusBadRequest)
923 log.Println("failed to parse issue id", err)
924 return
925 }
926
927 switch r.Method {
928 case http.MethodPost:
929 body := r.FormValue("body")
930 if body == "" {
931 s.pages.Notice(w, "issue", "Body is required")
932 return
933 }
934
935 commentId := rand.IntN(1000000)
936 rkey := s.TID()
937
938 err := db.NewIssueComment(s.db, &db.Comment{
939 OwnerDid: user.Did,
940 RepoAt: f.RepoAt,
941 Issue: issueIdInt,
942 CommentId: commentId,
943 Body: body,
944 Rkey: rkey,
945 })
946 if err != nil {
947 log.Println("failed to create comment", err)
948 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
949 return
950 }
951
952 createdAt := time.Now().Format(time.RFC3339)
953 commentIdInt64 := int64(commentId)
954 ownerDid := user.Did
955 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
956 if err != nil {
957 log.Println("failed to get issue at", err)
958 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
959 return
960 }
961
962 atUri := f.RepoAt.String()
963 client, _ := s.auth.AuthorizedClient(r)
964 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
965 Collection: tangled.RepoIssueCommentNSID,
966 Repo: user.Did,
967 Rkey: rkey,
968 Record: &lexutil.LexiconTypeDecoder{
969 Val: &tangled.RepoIssueComment{
970 Repo: &atUri,
971 Issue: issueAt,
972 CommentId: &commentIdInt64,
973 Owner: &ownerDid,
974 Body: &body,
975 CreatedAt: &createdAt,
976 },
977 },
978 })
979 if err != nil {
980 log.Println("failed to create comment", err)
981 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
982 return
983 }
984
985 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
986 return
987 }
988}
989
990func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
991 user := s.auth.GetUser(r)
992 f, err := fullyResolvedRepo(r)
993 if err != nil {
994 log.Println("failed to get repo and knot", err)
995 return
996 }
997
998 issueId := chi.URLParam(r, "issue")
999 issueIdInt, err := strconv.Atoi(issueId)
1000 if err != nil {
1001 http.Error(w, "bad issue id", http.StatusBadRequest)
1002 log.Println("failed to parse issue id", err)
1003 return
1004 }
1005
1006 commentId := chi.URLParam(r, "comment_id")
1007 commentIdInt, err := strconv.Atoi(commentId)
1008 if err != nil {
1009 http.Error(w, "bad comment id", http.StatusBadRequest)
1010 log.Println("failed to parse issue id", err)
1011 return
1012 }
1013
1014 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1015 if err != nil {
1016 log.Println("failed to get issue", err)
1017 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1018 return
1019 }
1020
1021 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1022 if err != nil {
1023 http.Error(w, "bad comment id", http.StatusBadRequest)
1024 return
1025 }
1026
1027 identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid)
1028 if err != nil {
1029 log.Println("failed to resolve did")
1030 return
1031 }
1032
1033 didHandleMap := make(map[string]string)
1034 if !identity.Handle.IsInvalidHandle() {
1035 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1036 } else {
1037 didHandleMap[identity.DID.String()] = identity.DID.String()
1038 }
1039
1040 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1041 LoggedInUser: user,
1042 RepoInfo: f.RepoInfo(s, user),
1043 DidHandleMap: didHandleMap,
1044 Issue: issue,
1045 Comment: comment,
1046 })
1047}
1048
1049func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) {
1050 user := s.auth.GetUser(r)
1051 f, err := fullyResolvedRepo(r)
1052 if err != nil {
1053 log.Println("failed to get repo and knot", err)
1054 return
1055 }
1056
1057 issueId := chi.URLParam(r, "issue")
1058 issueIdInt, err := strconv.Atoi(issueId)
1059 if err != nil {
1060 http.Error(w, "bad issue id", http.StatusBadRequest)
1061 log.Println("failed to parse issue id", err)
1062 return
1063 }
1064
1065 commentId := chi.URLParam(r, "comment_id")
1066 commentIdInt, err := strconv.Atoi(commentId)
1067 if err != nil {
1068 http.Error(w, "bad comment id", http.StatusBadRequest)
1069 log.Println("failed to parse issue id", err)
1070 return
1071 }
1072
1073 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1074 if err != nil {
1075 log.Println("failed to get issue", err)
1076 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1077 return
1078 }
1079
1080 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1081 if err != nil {
1082 http.Error(w, "bad comment id", http.StatusBadRequest)
1083 return
1084 }
1085
1086 if comment.OwnerDid != user.Did {
1087 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1088 return
1089 }
1090
1091 switch r.Method {
1092 case http.MethodGet:
1093 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
1094 LoggedInUser: user,
1095 RepoInfo: f.RepoInfo(s, user),
1096 Issue: issue,
1097 Comment: comment,
1098 })
1099 case http.MethodPost:
1100 // extract form value
1101 newBody := r.FormValue("body")
1102 client, _ := s.auth.AuthorizedClient(r)
1103 rkey := comment.Rkey
1104
1105 // optimistic update
1106 edited := time.Now()
1107 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
1108 if err != nil {
1109 log.Println("failed to perferom update-description query", err)
1110 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
1111 return
1112 }
1113
1114 // rkey is optional, it was introduced later
1115 if comment.Rkey != "" {
1116 // update the record on pds
1117 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey)
1118 if err != nil {
1119 // failed to get record
1120 log.Println(err, rkey)
1121 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
1122 return
1123 }
1124 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
1125 record, _ := data.UnmarshalJSON(value)
1126
1127 repoAt := record["repo"].(string)
1128 issueAt := record["issue"].(string)
1129 createdAt := record["createdAt"].(string)
1130 commentIdInt64 := int64(commentIdInt)
1131
1132 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1133 Collection: tangled.RepoIssueCommentNSID,
1134 Repo: user.Did,
1135 Rkey: rkey,
1136 SwapRecord: ex.Cid,
1137 Record: &lexutil.LexiconTypeDecoder{
1138 Val: &tangled.RepoIssueComment{
1139 Repo: &repoAt,
1140 Issue: issueAt,
1141 CommentId: &commentIdInt64,
1142 Owner: &comment.OwnerDid,
1143 Body: &newBody,
1144 CreatedAt: &createdAt,
1145 },
1146 },
1147 })
1148 if err != nil {
1149 log.Println(err)
1150 }
1151 }
1152
1153 // optimistic update for htmx
1154 didHandleMap := map[string]string{
1155 user.Did: user.Handle,
1156 }
1157 comment.Body = newBody
1158 comment.Edited = &edited
1159
1160 // return new comment body with htmx
1161 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1162 LoggedInUser: user,
1163 RepoInfo: f.RepoInfo(s, user),
1164 DidHandleMap: didHandleMap,
1165 Issue: issue,
1166 Comment: comment,
1167 })
1168 return
1169
1170 }
1171
1172}
1173
1174func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
1175 user := s.auth.GetUser(r)
1176 f, err := fullyResolvedRepo(r)
1177 if err != nil {
1178 log.Println("failed to get repo and knot", err)
1179 return
1180 }
1181
1182 issueId := chi.URLParam(r, "issue")
1183 issueIdInt, err := strconv.Atoi(issueId)
1184 if err != nil {
1185 http.Error(w, "bad issue id", http.StatusBadRequest)
1186 log.Println("failed to parse issue id", err)
1187 return
1188 }
1189
1190 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1191 if err != nil {
1192 log.Println("failed to get issue", err)
1193 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1194 return
1195 }
1196
1197 commentId := chi.URLParam(r, "comment_id")
1198 commentIdInt, err := strconv.Atoi(commentId)
1199 if err != nil {
1200 http.Error(w, "bad comment id", http.StatusBadRequest)
1201 log.Println("failed to parse issue id", err)
1202 return
1203 }
1204
1205 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1206 if err != nil {
1207 http.Error(w, "bad comment id", http.StatusBadRequest)
1208 return
1209 }
1210
1211 if comment.OwnerDid != user.Did {
1212 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1213 return
1214 }
1215
1216 if comment.Deleted != nil {
1217 http.Error(w, "comment already deleted", http.StatusBadRequest)
1218 return
1219 }
1220
1221 // optimistic deletion
1222 deleted := time.Now()
1223 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1224 if err != nil {
1225 log.Println("failed to delete comment")
1226 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
1227 return
1228 }
1229
1230 // delete from pds
1231 if comment.Rkey != "" {
1232 client, _ := s.auth.AuthorizedClient(r)
1233 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
1234 Collection: tangled.GraphFollowNSID,
1235 Repo: user.Did,
1236 Rkey: comment.Rkey,
1237 })
1238 if err != nil {
1239 log.Println(err)
1240 }
1241 }
1242
1243 // optimistic update for htmx
1244 didHandleMap := map[string]string{
1245 user.Did: user.Handle,
1246 }
1247 comment.Body = ""
1248 comment.Deleted = &deleted
1249
1250 // htmx fragment of comment after deletion
1251 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1252 LoggedInUser: user,
1253 RepoInfo: f.RepoInfo(s, user),
1254 DidHandleMap: didHandleMap,
1255 Issue: issue,
1256 Comment: comment,
1257 })
1258 return
1259}
1260
1261func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
1262 params := r.URL.Query()
1263 state := params.Get("state")
1264 isOpen := true
1265 switch state {
1266 case "open":
1267 isOpen = true
1268 case "closed":
1269 isOpen = false
1270 default:
1271 isOpen = true
1272 }
1273
1274 user := s.auth.GetUser(r)
1275 f, err := fullyResolvedRepo(r)
1276 if err != nil {
1277 log.Println("failed to get repo and knot", err)
1278 return
1279 }
1280
1281 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
1282 if err != nil {
1283 log.Println("failed to get issues", err)
1284 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
1285 return
1286 }
1287
1288 identsToResolve := make([]string, len(issues))
1289 for i, issue := range issues {
1290 identsToResolve[i] = issue.OwnerDid
1291 }
1292 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1293 didHandleMap := make(map[string]string)
1294 for _, identity := range resolvedIds {
1295 if !identity.Handle.IsInvalidHandle() {
1296 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1297 } else {
1298 didHandleMap[identity.DID.String()] = identity.DID.String()
1299 }
1300 }
1301
1302 s.pages.RepoIssues(w, pages.RepoIssuesParams{
1303 LoggedInUser: s.auth.GetUser(r),
1304 RepoInfo: f.RepoInfo(s, user),
1305 Issues: issues,
1306 DidHandleMap: didHandleMap,
1307 FilteringByOpen: isOpen,
1308 })
1309 return
1310}
1311
1312func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
1313 user := s.auth.GetUser(r)
1314
1315 f, err := fullyResolvedRepo(r)
1316 if err != nil {
1317 log.Println("failed to get repo and knot", err)
1318 return
1319 }
1320
1321 switch r.Method {
1322 case http.MethodGet:
1323 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1324 LoggedInUser: user,
1325 RepoInfo: f.RepoInfo(s, user),
1326 })
1327 case http.MethodPost:
1328 title := r.FormValue("title")
1329 body := r.FormValue("body")
1330
1331 if title == "" || body == "" {
1332 s.pages.Notice(w, "issues", "Title and body are required")
1333 return
1334 }
1335
1336 tx, err := s.db.BeginTx(r.Context(), nil)
1337 if err != nil {
1338 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
1339 return
1340 }
1341
1342 err = db.NewIssue(tx, &db.Issue{
1343 RepoAt: f.RepoAt,
1344 Title: title,
1345 Body: body,
1346 OwnerDid: user.Did,
1347 })
1348 if err != nil {
1349 log.Println("failed to create issue", err)
1350 s.pages.Notice(w, "issues", "Failed to create issue.")
1351 return
1352 }
1353
1354 issueId, err := db.GetIssueId(s.db, f.RepoAt)
1355 if err != nil {
1356 log.Println("failed to get issue id", err)
1357 s.pages.Notice(w, "issues", "Failed to create issue.")
1358 return
1359 }
1360
1361 client, _ := s.auth.AuthorizedClient(r)
1362 atUri := f.RepoAt.String()
1363 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1364 Collection: tangled.RepoIssueNSID,
1365 Repo: user.Did,
1366 Rkey: s.TID(),
1367 Record: &lexutil.LexiconTypeDecoder{
1368 Val: &tangled.RepoIssue{
1369 Repo: atUri,
1370 Title: title,
1371 Body: &body,
1372 Owner: user.Did,
1373 IssueId: int64(issueId),
1374 },
1375 },
1376 })
1377 if err != nil {
1378 log.Println("failed to create issue", err)
1379 s.pages.Notice(w, "issues", "Failed to create issue.")
1380 return
1381 }
1382
1383 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
1384 if err != nil {
1385 log.Println("failed to set issue at", err)
1386 s.pages.Notice(w, "issues", "Failed to create issue.")
1387 return
1388 }
1389
1390 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1391 return
1392 }
1393}