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