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