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 protocol := "http"
39 if !s.config.Dev {
40 protocol = "https"
41 }
42
43 var reqUrl string
44 if ref != "" {
45 reqUrl = fmt.Sprintf("%s://%s/%s/%s/tree/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)
46 } else {
47 reqUrl = fmt.Sprintf("%s://%s/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName)
48 }
49
50 resp, err := http.Get(reqUrl)
51 if err != nil {
52 s.pages.Error503(w)
53 log.Println("failed to reach knotserver", err)
54 return
55 }
56 defer resp.Body.Close()
57
58 body, err := io.ReadAll(resp.Body)
59 if err != nil {
60 log.Printf("Error reading response body: %v", err)
61 return
62 }
63
64 var result types.RepoIndexResponse
65 err = json.Unmarshal(body, &result)
66 if err != nil {
67 log.Printf("Error unmarshalling response body: %v", err)
68 return
69 }
70
71 tagMap := make(map[string][]string)
72 for _, tag := range result.Tags {
73 hash := tag.Hash
74 tagMap[hash] = append(tagMap[hash], tag.Name)
75 }
76
77 for _, branch := range result.Branches {
78 hash := branch.Hash
79 tagMap[hash] = append(tagMap[hash], branch.Name)
80 }
81
82 user := s.auth.GetUser(r)
83 s.pages.RepoIndexPage(w, pages.RepoIndexParams{
84 LoggedInUser: user,
85 RepoInfo: f.RepoInfo(s, user),
86 TagMap: tagMap,
87 RepoIndexResponse: result,
88 })
89
90 return
91}
92
93func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {
94 f, err := fullyResolvedRepo(r)
95 if err != nil {
96 log.Println("failed to fully resolve repo", err)
97 return
98 }
99
100 page := 1
101 if r.URL.Query().Get("page") != "" {
102 page, err = strconv.Atoi(r.URL.Query().Get("page"))
103 if err != nil {
104 page = 1
105 }
106 }
107
108 ref := chi.URLParam(r, "ref")
109
110 protocol := "http"
111 if !s.config.Dev {
112 protocol = "https"
113 }
114
115 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))
116 if err != nil {
117 log.Println("failed to reach knotserver", err)
118 return
119 }
120
121 body, err := io.ReadAll(resp.Body)
122 if err != nil {
123 log.Printf("error reading response body: %v", err)
124 return
125 }
126
127 var repolog types.RepoLogResponse
128 err = json.Unmarshal(body, &repolog)
129 if err != nil {
130 log.Println("failed to parse json response", err)
131 return
132 }
133
134 user := s.auth.GetUser(r)
135 s.pages.RepoLog(w, pages.RepoLogParams{
136 LoggedInUser: user,
137 RepoInfo: f.RepoInfo(s, user),
138 RepoLogResponse: repolog,
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 })
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 resp, err := http.Get(fmt.Sprintf("http://%s/%s/%s/branches", f.Knot, f.OwnerDid(), f.RepoName))
380 if err != nil {
381 log.Println("failed to reach knotserver", err)
382 return
383 }
384
385 body, err := io.ReadAll(resp.Body)
386 if err != nil {
387 log.Printf("Error reading response body: %v", err)
388 return
389 }
390
391 var result types.RepoBranchesResponse
392 err = json.Unmarshal(body, &result)
393 if err != nil {
394 log.Println("failed to parse response:", err)
395 return
396 }
397
398 user := s.auth.GetUser(r)
399 s.pages.RepoBranches(w, pages.RepoBranchesParams{
400 LoggedInUser: user,
401 RepoInfo: f.RepoInfo(s, user),
402 RepoBranchesResponse: result,
403 })
404 return
405}
406
407func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) {
408 f, err := fullyResolvedRepo(r)
409 if err != nil {
410 log.Println("failed to get repo and knot", err)
411 return
412 }
413
414 ref := chi.URLParam(r, "ref")
415 filePath := chi.URLParam(r, "*")
416 protocol := "http"
417 if !s.config.Dev {
418 protocol = "https"
419 }
420 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
421 if err != nil {
422 log.Println("failed to reach knotserver", err)
423 return
424 }
425
426 body, err := io.ReadAll(resp.Body)
427 if err != nil {
428 log.Printf("Error reading response body: %v", err)
429 return
430 }
431
432 var result types.RepoBlobResponse
433 err = json.Unmarshal(body, &result)
434 if err != nil {
435 log.Println("failed to parse response:", err)
436 return
437 }
438
439 var breadcrumbs [][]string
440 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
441 if filePath != "" {
442 for idx, elem := range strings.Split(filePath, "/") {
443 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
444 }
445 }
446
447 user := s.auth.GetUser(r)
448 s.pages.RepoBlob(w, pages.RepoBlobParams{
449 LoggedInUser: user,
450 RepoInfo: f.RepoInfo(s, user),
451 RepoBlobResponse: result,
452 BreadCrumbs: breadcrumbs,
453 })
454 return
455}
456
457func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
458 f, err := fullyResolvedRepo(r)
459 if err != nil {
460 log.Println("failed to get repo and knot", err)
461 return
462 }
463
464 collaborator := r.FormValue("collaborator")
465 if collaborator == "" {
466 http.Error(w, "malformed form", http.StatusBadRequest)
467 return
468 }
469
470 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator)
471 if err != nil {
472 w.Write([]byte("failed to resolve collaborator did to a handle"))
473 return
474 }
475 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
476
477 // TODO: create an atproto record for this
478
479 secret, err := db.GetRegistrationKey(s.db, f.Knot)
480 if err != nil {
481 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
482 return
483 }
484
485 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
486 if err != nil {
487 log.Println("failed to create client to ", f.Knot)
488 return
489 }
490
491 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
492 if err != nil {
493 log.Printf("failed to make request to %s: %s", f.Knot, err)
494 return
495 }
496
497 if ksResp.StatusCode != http.StatusNoContent {
498 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err)))
499 return
500 }
501
502 tx, err := s.db.BeginTx(r.Context(), nil)
503 if err != nil {
504 log.Println("failed to start tx")
505 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
506 return
507 }
508 defer func() {
509 tx.Rollback()
510 err = s.enforcer.E.LoadPolicy()
511 if err != nil {
512 log.Println("failed to rollback policies")
513 }
514 }()
515
516 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo())
517 if err != nil {
518 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
519 return
520 }
521
522 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
523 if err != nil {
524 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
525 return
526 }
527
528 err = tx.Commit()
529 if err != nil {
530 log.Println("failed to commit changes", err)
531 http.Error(w, err.Error(), http.StatusInternalServerError)
532 return
533 }
534
535 err = s.enforcer.E.SavePolicy()
536 if err != nil {
537 log.Println("failed to update ACLs", err)
538 http.Error(w, err.Error(), http.StatusInternalServerError)
539 return
540 }
541
542 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String())))
543
544}
545
546func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
547 f, err := fullyResolvedRepo(r)
548 if err != nil {
549 log.Println("failed to get repo and knot", err)
550 return
551 }
552
553 switch r.Method {
554 case http.MethodGet:
555 // for now, this is just pubkeys
556 user := s.auth.GetUser(r)
557 repoCollaborators, err := f.Collaborators(r.Context(), s)
558 if err != nil {
559 log.Println("failed to get collaborators", err)
560 }
561
562 isCollaboratorInviteAllowed := false
563 if user != nil {
564 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo())
565 if err == nil && ok {
566 isCollaboratorInviteAllowed = true
567 }
568 }
569
570 s.pages.RepoSettings(w, pages.RepoSettingsParams{
571 LoggedInUser: user,
572 RepoInfo: f.RepoInfo(s, user),
573 Collaborators: repoCollaborators,
574 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
575 })
576 }
577}
578
579type FullyResolvedRepo struct {
580 Knot string
581 OwnerId identity.Identity
582 RepoName string
583 RepoAt syntax.ATURI
584 Description string
585 AddedAt string
586}
587
588func (f *FullyResolvedRepo) OwnerDid() string {
589 return f.OwnerId.DID.String()
590}
591
592func (f *FullyResolvedRepo) OwnerHandle() string {
593 return f.OwnerId.Handle.String()
594}
595
596func (f *FullyResolvedRepo) OwnerSlashRepo() string {
597 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
598 return p
599}
600
601func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
602 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot)
603 if err != nil {
604 return nil, err
605 }
606
607 var collaborators []pages.Collaborator
608 for _, item := range repoCollaborators {
609 // currently only two roles: owner and member
610 var role string
611 if item[3] == "repo:owner" {
612 role = "owner"
613 } else if item[3] == "repo:collaborator" {
614 role = "collaborator"
615 } else {
616 continue
617 }
618
619 did := item[0]
620
621 c := pages.Collaborator{
622 Did: did,
623 Handle: "",
624 Role: role,
625 }
626 collaborators = append(collaborators, c)
627 }
628
629 // populate all collborators with handles
630 identsToResolve := make([]string, len(collaborators))
631 for i, collab := range collaborators {
632 identsToResolve[i] = collab.Did
633 }
634
635 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve)
636 for i, resolved := range resolvedIdents {
637 if resolved != nil {
638 collaborators[i].Handle = resolved.Handle.String()
639 }
640 }
641
642 return collaborators, nil
643}
644
645func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo {
646 isStarred := false
647 if u != nil {
648 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
649 }
650
651 starCount, err := db.GetStarCount(s.db, f.RepoAt)
652 if err != nil {
653 log.Println("failed to get star count for ", f.RepoAt)
654 }
655 issueCount, err := db.GetIssueCount(s.db, f.RepoAt)
656 if err != nil {
657 log.Println("failed to get issue count for ", f.RepoAt)
658 }
659
660 knot := f.Knot
661 if knot == "knot1.tangled.sh" {
662 knot = "tangled.sh"
663 }
664
665 return pages.RepoInfo{
666 OwnerDid: f.OwnerDid(),
667 OwnerHandle: f.OwnerHandle(),
668 Name: f.RepoName,
669 RepoAt: f.RepoAt,
670 Description: f.Description,
671 IsStarred: isStarred,
672 Knot: knot,
673 Roles: rolesInRepo(s, u, f),
674 Stats: db.RepoStats{
675 StarCount: starCount,
676 IssueCount: issueCount,
677 },
678 }
679}
680
681func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
682 user := s.auth.GetUser(r)
683 f, err := fullyResolvedRepo(r)
684 if err != nil {
685 log.Println("failed to get repo and knot", err)
686 return
687 }
688
689 issueId := chi.URLParam(r, "issue")
690 issueIdInt, err := strconv.Atoi(issueId)
691 if err != nil {
692 http.Error(w, "bad issue id", http.StatusBadRequest)
693 log.Println("failed to parse issue id", err)
694 return
695 }
696
697 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt)
698 if err != nil {
699 log.Println("failed to get issue and comments", err)
700 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
701 return
702 }
703
704 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
705 if err != nil {
706 log.Println("failed to resolve issue owner", err)
707 }
708
709 identsToResolve := make([]string, len(comments))
710 for i, comment := range comments {
711 identsToResolve[i] = comment.OwnerDid
712 }
713 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
714 didHandleMap := make(map[string]string)
715 for _, identity := range resolvedIds {
716 if !identity.Handle.IsInvalidHandle() {
717 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
718 } else {
719 didHandleMap[identity.DID.String()] = identity.DID.String()
720 }
721 }
722
723 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
724 LoggedInUser: user,
725 RepoInfo: f.RepoInfo(s, user),
726 Issue: *issue,
727 Comments: comments,
728
729 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
730 DidHandleMap: didHandleMap,
731 })
732
733}
734
735func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
736 user := s.auth.GetUser(r)
737 f, err := fullyResolvedRepo(r)
738 if err != nil {
739 log.Println("failed to get repo and knot", err)
740 return
741 }
742
743 issueId := chi.URLParam(r, "issue")
744 issueIdInt, err := strconv.Atoi(issueId)
745 if err != nil {
746 http.Error(w, "bad issue id", http.StatusBadRequest)
747 log.Println("failed to parse issue id", err)
748 return
749 }
750
751 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
752 if err != nil {
753 log.Println("failed to get issue", err)
754 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
755 return
756 }
757
758 collaborators, err := f.Collaborators(r.Context(), s)
759 if err != nil {
760 log.Println("failed to fetch repo collaborators: %w", err)
761 }
762 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
763 return user.Did == collab.Did
764 })
765 isIssueOwner := user.Did == issue.OwnerDid
766
767 // TODO: make this more granular
768 if isIssueOwner || isCollaborator {
769
770 closed := tangled.RepoIssueStateClosed
771
772 client, _ := s.auth.AuthorizedClient(r)
773 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
774 Collection: tangled.RepoIssueStateNSID,
775 Repo: user.Did,
776 Rkey: s.TID(),
777 Record: &lexutil.LexiconTypeDecoder{
778 Val: &tangled.RepoIssueState{
779 Issue: issue.IssueAt,
780 State: &closed,
781 },
782 },
783 })
784
785 if err != nil {
786 log.Println("failed to update issue state", err)
787 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
788 return
789 }
790
791 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt)
792 if err != nil {
793 log.Println("failed to close issue", err)
794 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
795 return
796 }
797
798 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
799 return
800 } else {
801 log.Println("user is not permitted to close issue")
802 http.Error(w, "for biden", http.StatusUnauthorized)
803 return
804 }
805}
806
807func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
808 user := s.auth.GetUser(r)
809 f, err := fullyResolvedRepo(r)
810 if err != nil {
811 log.Println("failed to get repo and knot", err)
812 return
813 }
814
815 issueId := chi.URLParam(r, "issue")
816 issueIdInt, err := strconv.Atoi(issueId)
817 if err != nil {
818 http.Error(w, "bad issue id", http.StatusBadRequest)
819 log.Println("failed to parse issue id", err)
820 return
821 }
822
823 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
824 if err != nil {
825 log.Println("failed to get issue", err)
826 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
827 return
828 }
829
830 collaborators, err := f.Collaborators(r.Context(), s)
831 if err != nil {
832 log.Println("failed to fetch repo collaborators: %w", err)
833 }
834 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
835 return user.Did == collab.Did
836 })
837 isIssueOwner := user.Did == issue.OwnerDid
838
839 if isCollaborator || isIssueOwner {
840 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
841 if err != nil {
842 log.Println("failed to reopen issue", err)
843 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
844 return
845 }
846 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
847 return
848 } else {
849 log.Println("user is not the owner of the repo")
850 http.Error(w, "forbidden", http.StatusUnauthorized)
851 return
852 }
853}
854
855func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
856 user := s.auth.GetUser(r)
857 f, err := fullyResolvedRepo(r)
858 if err != nil {
859 log.Println("failed to get repo and knot", err)
860 return
861 }
862
863 issueId := chi.URLParam(r, "issue")
864 issueIdInt, err := strconv.Atoi(issueId)
865 if err != nil {
866 http.Error(w, "bad issue id", http.StatusBadRequest)
867 log.Println("failed to parse issue id", err)
868 return
869 }
870
871 switch r.Method {
872 case http.MethodPost:
873 body := r.FormValue("body")
874 if body == "" {
875 s.pages.Notice(w, "issue", "Body is required")
876 return
877 }
878
879 commentId := rand.IntN(1000000)
880
881 err := db.NewComment(s.db, &db.Comment{
882 OwnerDid: user.Did,
883 RepoAt: f.RepoAt,
884 Issue: issueIdInt,
885 CommentId: commentId,
886 Body: body,
887 })
888 if err != nil {
889 log.Println("failed to create comment", err)
890 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
891 return
892 }
893
894 createdAt := time.Now().Format(time.RFC3339)
895 commentIdInt64 := int64(commentId)
896 ownerDid := user.Did
897 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
898 if err != nil {
899 log.Println("failed to get issue at", err)
900 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
901 return
902 }
903
904 atUri := f.RepoAt.String()
905 client, _ := s.auth.AuthorizedClient(r)
906 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
907 Collection: tangled.RepoIssueCommentNSID,
908 Repo: user.Did,
909 Rkey: s.TID(),
910 Record: &lexutil.LexiconTypeDecoder{
911 Val: &tangled.RepoIssueComment{
912 Repo: &atUri,
913 Issue: issueAt,
914 CommentId: &commentIdInt64,
915 Owner: &ownerDid,
916 Body: &body,
917 CreatedAt: &createdAt,
918 },
919 },
920 })
921 if err != nil {
922 log.Println("failed to create comment", err)
923 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
924 return
925 }
926
927 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
928 return
929 }
930}
931
932func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
933 params := r.URL.Query()
934 state := params.Get("state")
935 isOpen := true
936 switch state {
937 case "open":
938 isOpen = true
939 case "closed":
940 isOpen = false
941 default:
942 isOpen = true
943 }
944
945 user := s.auth.GetUser(r)
946 f, err := fullyResolvedRepo(r)
947 if err != nil {
948 log.Println("failed to get repo and knot", err)
949 return
950 }
951
952 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
953 if err != nil {
954 log.Println("failed to get issues", err)
955 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
956 return
957 }
958
959 identsToResolve := make([]string, len(issues))
960 for i, issue := range issues {
961 identsToResolve[i] = issue.OwnerDid
962 }
963 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
964 didHandleMap := make(map[string]string)
965 for _, identity := range resolvedIds {
966 if !identity.Handle.IsInvalidHandle() {
967 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
968 } else {
969 didHandleMap[identity.DID.String()] = identity.DID.String()
970 }
971 }
972
973 s.pages.RepoIssues(w, pages.RepoIssuesParams{
974 LoggedInUser: s.auth.GetUser(r),
975 RepoInfo: f.RepoInfo(s, user),
976 Issues: issues,
977 DidHandleMap: didHandleMap,
978 FilteringByOpen: isOpen,
979 })
980 return
981}
982
983func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
984 user := s.auth.GetUser(r)
985
986 f, err := fullyResolvedRepo(r)
987 if err != nil {
988 log.Println("failed to get repo and knot", err)
989 return
990 }
991
992 switch r.Method {
993 case http.MethodGet:
994 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
995 LoggedInUser: user,
996 RepoInfo: f.RepoInfo(s, user),
997 })
998 case http.MethodPost:
999 title := r.FormValue("title")
1000 body := r.FormValue("body")
1001
1002 if title == "" || body == "" {
1003 s.pages.Notice(w, "issues", "Title and body are required")
1004 return
1005 }
1006
1007 tx, err := s.db.BeginTx(r.Context(), nil)
1008 if err != nil {
1009 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
1010 return
1011 }
1012
1013 err = db.NewIssue(tx, &db.Issue{
1014 RepoAt: f.RepoAt,
1015 Title: title,
1016 Body: body,
1017 OwnerDid: user.Did,
1018 })
1019 if err != nil {
1020 log.Println("failed to create issue", err)
1021 s.pages.Notice(w, "issues", "Failed to create issue.")
1022 return
1023 }
1024
1025 issueId, err := db.GetIssueId(s.db, f.RepoAt)
1026 if err != nil {
1027 log.Println("failed to get issue id", err)
1028 s.pages.Notice(w, "issues", "Failed to create issue.")
1029 return
1030 }
1031
1032 client, _ := s.auth.AuthorizedClient(r)
1033 atUri := f.RepoAt.String()
1034 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1035 Collection: tangled.RepoIssueNSID,
1036 Repo: user.Did,
1037 Rkey: s.TID(),
1038 Record: &lexutil.LexiconTypeDecoder{
1039 Val: &tangled.RepoIssue{
1040 Repo: atUri,
1041 Title: title,
1042 Body: &body,
1043 Owner: user.Did,
1044 IssueId: int64(issueId),
1045 },
1046 },
1047 })
1048 if err != nil {
1049 log.Println("failed to create issue", err)
1050 s.pages.Notice(w, "issues", "Failed to create issue.")
1051 return
1052 }
1053
1054 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
1055 if err != nil {
1056 log.Println("failed to set issue at", err)
1057 s.pages.Notice(w, "issues", "Failed to create issue.")
1058 return
1059 }
1060
1061 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1062 return
1063 }
1064}
1065
1066func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
1067 user := s.auth.GetUser(r)
1068 f, err := fullyResolvedRepo(r)
1069 if err != nil {
1070 log.Println("failed to get repo and knot", err)
1071 return
1072 }
1073
1074 switch r.Method {
1075 case http.MethodGet:
1076 s.pages.RepoPulls(w, pages.RepoPullsParams{
1077 LoggedInUser: user,
1078 RepoInfo: f.RepoInfo(s, user),
1079 })
1080 }
1081}
1082
1083func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) {
1084 repoName := chi.URLParam(r, "repo")
1085 knot, ok := r.Context().Value("knot").(string)
1086 if !ok {
1087 log.Println("malformed middleware")
1088 return nil, fmt.Errorf("malformed middleware")
1089 }
1090 id, ok := r.Context().Value("resolvedId").(identity.Identity)
1091 if !ok {
1092 log.Println("malformed middleware")
1093 return nil, fmt.Errorf("malformed middleware")
1094 }
1095
1096 repoAt, ok := r.Context().Value("repoAt").(string)
1097 if !ok {
1098 log.Println("malformed middleware")
1099 return nil, fmt.Errorf("malformed middleware")
1100 }
1101
1102 parsedRepoAt, err := syntax.ParseATURI(repoAt)
1103 if err != nil {
1104 log.Println("malformed repo at-uri")
1105 return nil, fmt.Errorf("malformed middleware")
1106 }
1107
1108 // pass through values from the middleware
1109 description, ok := r.Context().Value("repoDescription").(string)
1110 addedAt, ok := r.Context().Value("repoAddedAt").(string)
1111
1112 return &FullyResolvedRepo{
1113 Knot: knot,
1114 OwnerId: id,
1115 RepoName: repoName,
1116 RepoAt: parsedRepoAt,
1117 Description: description,
1118 AddedAt: addedAt,
1119 }, nil
1120}
1121
1122func rolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo {
1123 if u != nil {
1124 r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo())
1125 return pages.RolesInRepo{r}
1126 } else {
1127 return pages.RolesInRepo{}
1128 }
1129}