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