forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
this repo has no description
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}