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