1package state
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 mathrand "math/rand/v2"
12 "net/http"
13 "path"
14 "slices"
15 "strconv"
16 "strings"
17 "time"
18
19 "tangled.sh/tangled.sh/core/api/tangled"
20 "tangled.sh/tangled.sh/core/appview"
21 "tangled.sh/tangled.sh/core/appview/db"
22 "tangled.sh/tangled.sh/core/appview/knotclient"
23 "tangled.sh/tangled.sh/core/appview/oauth"
24 "tangled.sh/tangled.sh/core/appview/pages"
25 "tangled.sh/tangled.sh/core/appview/pages/markup"
26 "tangled.sh/tangled.sh/core/appview/pages/repoinfo"
27 "tangled.sh/tangled.sh/core/appview/pagination"
28 "tangled.sh/tangled.sh/core/types"
29
30 "github.com/bluesky-social/indigo/atproto/data"
31 "github.com/bluesky-social/indigo/atproto/identity"
32 "github.com/bluesky-social/indigo/atproto/syntax"
33 securejoin "github.com/cyphar/filepath-securejoin"
34 "github.com/go-chi/chi/v5"
35 "github.com/go-git/go-git/v5/plumbing"
36
37 comatproto "github.com/bluesky-social/indigo/api/atproto"
38 lexutil "github.com/bluesky-social/indigo/lex/util"
39)
40
41func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) {
42 ref := chi.URLParam(r, "ref")
43 f, err := s.fullyResolvedRepo(r)
44 if err != nil {
45 log.Println("failed to fully resolve repo", err)
46 return
47 }
48
49 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
50 if err != nil {
51 log.Printf("failed to create unsigned client for %s", f.Knot)
52 s.pages.Error503(w)
53 return
54 }
55
56 resp, err := us.Index(f.OwnerDid(), f.RepoName, ref)
57 if err != nil {
58 s.pages.Error503(w)
59 log.Println("failed to reach knotserver", err)
60 return
61 }
62 defer resp.Body.Close()
63
64 body, err := io.ReadAll(resp.Body)
65 if err != nil {
66 log.Printf("Error reading response body: %v", err)
67 return
68 }
69
70 var result types.RepoIndexResponse
71 err = json.Unmarshal(body, &result)
72 if err != nil {
73 log.Printf("Error unmarshalling response body: %v", err)
74 return
75 }
76
77 tagMap := make(map[string][]string)
78 for _, tag := range result.Tags {
79 hash := tag.Hash
80 if tag.Tag != nil {
81 hash = tag.Tag.Target.String()
82 }
83 tagMap[hash] = append(tagMap[hash], tag.Name)
84 }
85
86 for _, branch := range result.Branches {
87 hash := branch.Hash
88 tagMap[hash] = append(tagMap[hash], branch.Name)
89 }
90
91 slices.SortFunc(result.Branches, func(a, b types.Branch) int {
92 if a.Name == result.Ref {
93 return -1
94 }
95 if a.IsDefault {
96 return -1
97 }
98 if b.IsDefault {
99 return 1
100 }
101 if a.Commit != nil {
102 if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
103 return 1
104 } else {
105 return -1
106 }
107 }
108 return strings.Compare(a.Name, b.Name) * -1
109 })
110
111 commitCount := len(result.Commits)
112 branchCount := len(result.Branches)
113 tagCount := len(result.Tags)
114 fileCount := len(result.Files)
115
116 commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount)
117 commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))]
118 tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))]
119 branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))]
120
121 emails := uniqueEmails(commitsTrunc)
122
123 user := s.oauth.GetUser(r)
124 repoInfo := f.RepoInfo(s, user)
125
126 forkInfo, err := getForkInfo(repoInfo, s, f, us, w, user)
127 if err != nil {
128 log.Printf("Failed to fetch fork information: %v", err)
129 return
130 }
131
132 s.pages.RepoIndexPage(w, pages.RepoIndexParams{
133 LoggedInUser: user,
134 RepoInfo: repoInfo,
135 TagMap: tagMap,
136 RepoIndexResponse: result,
137 CommitsTrunc: commitsTrunc,
138 TagsTrunc: tagsTrunc,
139 ForkInfo: *forkInfo,
140 BranchesTrunc: branchesTrunc,
141 EmailToDidOrHandle: EmailToDidOrHandle(s, emails),
142 })
143 return
144}
145
146func getForkInfo(
147 repoInfo repoinfo.RepoInfo,
148 s *State,
149 f *FullyResolvedRepo,
150 us *knotclient.UnsignedClient,
151 w http.ResponseWriter,
152 user *oauth.User,
153) (*types.ForkInfo, error) {
154 forkInfo := types.ForkInfo{
155 IsFork: repoInfo.Source != nil,
156 Status: types.UpToDate,
157 }
158
159 secret, err := db.GetRegistrationKey(s.db, f.Knot)
160 if err != nil {
161 log.Printf("failed to get registration key for %s: %s", f.Knot, err)
162 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
163 }
164
165 if !forkInfo.IsFork {
166 forkInfo.IsFork = false
167 return &forkInfo, nil
168 }
169
170 resp, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name)
171 if err != nil {
172 log.Println("failed to reach knotserver", err)
173 return nil, err
174 }
175
176 body, err := io.ReadAll(resp.Body)
177 if err != nil {
178 log.Printf("Error reading forkResponse forkBody: %v", err)
179 return nil, err
180 }
181
182 var result types.RepoBranchesResponse
183 err = json.Unmarshal(body, &result)
184 if err != nil {
185 log.Println("failed to parse forkResponse:", err)
186 return nil, err
187 }
188
189 if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool {
190 return branch.Name == f.Ref
191 }) {
192 forkInfo.Status = types.MissingBranch
193 return &forkInfo, nil
194 }
195
196 signedClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
197 if err != nil {
198 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
199 return nil, err
200 }
201
202 newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref)
203 if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent {
204 log.Printf("failed to update tracking branch: %s", err)
205 return nil, err
206 }
207
208 hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref)
209
210 var status types.AncestorCheckResponse
211 forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef)
212 if err != nil {
213 log.Printf("failed to check if fork is ahead/behind: %s", err)
214 return nil, err
215 }
216
217 if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil {
218 log.Printf("failed to decode fork status: %s", err)
219 return nil, err
220 }
221
222 forkInfo.Status = status.Status
223 return &forkInfo, nil
224}
225
226func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {
227 f, err := s.fullyResolvedRepo(r)
228 if err != nil {
229 log.Println("failed to fully resolve repo", err)
230 return
231 }
232
233 page := 1
234 if r.URL.Query().Get("page") != "" {
235 page, err = strconv.Atoi(r.URL.Query().Get("page"))
236 if err != nil {
237 page = 1
238 }
239 }
240
241 ref := chi.URLParam(r, "ref")
242
243 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
244 if err != nil {
245 log.Println("failed to create unsigned client", err)
246 return
247 }
248
249 resp, err := us.Log(f.OwnerDid(), f.RepoName, ref, page)
250 if err != nil {
251 log.Println("failed to reach knotserver", err)
252 return
253 }
254
255 body, err := io.ReadAll(resp.Body)
256 if err != nil {
257 log.Printf("error reading response body: %v", err)
258 return
259 }
260
261 var repolog types.RepoLogResponse
262 err = json.Unmarshal(body, &repolog)
263 if err != nil {
264 log.Println("failed to parse json response", err)
265 return
266 }
267
268 result, err := us.Tags(f.OwnerDid(), f.RepoName)
269 if err != nil {
270 log.Println("failed to reach knotserver", err)
271 return
272 }
273
274 tagMap := make(map[string][]string)
275 for _, tag := range result.Tags {
276 hash := tag.Hash
277 if tag.Tag != nil {
278 hash = tag.Tag.Target.String()
279 }
280 tagMap[hash] = append(tagMap[hash], tag.Name)
281 }
282
283 user := s.oauth.GetUser(r)
284 s.pages.RepoLog(w, pages.RepoLogParams{
285 LoggedInUser: user,
286 TagMap: tagMap,
287 RepoInfo: f.RepoInfo(s, user),
288 RepoLogResponse: repolog,
289 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)),
290 })
291 return
292}
293
294func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
295 f, err := s.fullyResolvedRepo(r)
296 if err != nil {
297 log.Println("failed to get repo and knot", err)
298 w.WriteHeader(http.StatusBadRequest)
299 return
300 }
301
302 user := s.oauth.GetUser(r)
303 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
304 RepoInfo: f.RepoInfo(s, user),
305 })
306 return
307}
308
309func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) {
310 f, err := s.fullyResolvedRepo(r)
311 if err != nil {
312 log.Println("failed to get repo and knot", err)
313 w.WriteHeader(http.StatusBadRequest)
314 return
315 }
316
317 repoAt := f.RepoAt
318 rkey := repoAt.RecordKey().String()
319 if rkey == "" {
320 log.Println("invalid aturi for repo", err)
321 w.WriteHeader(http.StatusInternalServerError)
322 return
323 }
324
325 user := s.oauth.GetUser(r)
326
327 switch r.Method {
328 case http.MethodGet:
329 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
330 RepoInfo: f.RepoInfo(s, user),
331 })
332 return
333 case http.MethodPut:
334 user := s.oauth.GetUser(r)
335 newDescription := r.FormValue("description")
336 client, err := s.oauth.AuthorizedClient(r)
337 if err != nil {
338 log.Println("failed to get client")
339 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
340 return
341 }
342
343 // optimistic update
344 err = db.UpdateDescription(s.db, string(repoAt), newDescription)
345 if err != nil {
346 log.Println("failed to perferom update-description query", err)
347 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
348 return
349 }
350
351 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
352 //
353 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
354 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
355 if err != nil {
356 // failed to get record
357 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
358 return
359 }
360 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
361 Collection: tangled.RepoNSID,
362 Repo: user.Did,
363 Rkey: rkey,
364 SwapRecord: ex.Cid,
365 Record: &lexutil.LexiconTypeDecoder{
366 Val: &tangled.Repo{
367 Knot: f.Knot,
368 Name: f.RepoName,
369 Owner: user.Did,
370 CreatedAt: f.CreatedAt,
371 Description: &newDescription,
372 },
373 },
374 })
375
376 if err != nil {
377 log.Println("failed to perferom update-description query", err)
378 // failed to get record
379 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
380 return
381 }
382
383 newRepoInfo := f.RepoInfo(s, user)
384 newRepoInfo.Description = newDescription
385
386 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
387 RepoInfo: newRepoInfo,
388 })
389 return
390 }
391}
392
393func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) {
394 f, err := s.fullyResolvedRepo(r)
395 if err != nil {
396 log.Println("failed to fully resolve repo", err)
397 return
398 }
399 ref := chi.URLParam(r, "ref")
400 protocol := "http"
401 if !s.config.Core.Dev {
402 protocol = "https"
403 }
404
405 if !plumbing.IsHash(ref) {
406 s.pages.Error404(w)
407 return
408 }
409
410 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
411 if err != nil {
412 log.Println("failed to reach knotserver", err)
413 return
414 }
415
416 body, err := io.ReadAll(resp.Body)
417 if err != nil {
418 log.Printf("Error reading response body: %v", err)
419 return
420 }
421
422 var result types.RepoCommitResponse
423 err = json.Unmarshal(body, &result)
424 if err != nil {
425 log.Println("failed to parse response:", err)
426 return
427 }
428
429 user := s.oauth.GetUser(r)
430 s.pages.RepoCommit(w, pages.RepoCommitParams{
431 LoggedInUser: user,
432 RepoInfo: f.RepoInfo(s, user),
433 RepoCommitResponse: result,
434 EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}),
435 })
436 return
437}
438
439func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) {
440 f, err := s.fullyResolvedRepo(r)
441 if err != nil {
442 log.Println("failed to fully resolve repo", err)
443 return
444 }
445
446 ref := chi.URLParam(r, "ref")
447 treePath := chi.URLParam(r, "*")
448 protocol := "http"
449 if !s.config.Core.Dev {
450 protocol = "https"
451 }
452 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
453 if err != nil {
454 log.Println("failed to reach knotserver", err)
455 return
456 }
457
458 body, err := io.ReadAll(resp.Body)
459 if err != nil {
460 log.Printf("Error reading response body: %v", err)
461 return
462 }
463
464 var result types.RepoTreeResponse
465 err = json.Unmarshal(body, &result)
466 if err != nil {
467 log.Println("failed to parse response:", err)
468 return
469 }
470
471 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
472 // so we can safely redirect to the "parent" (which is the same file).
473 if len(result.Files) == 0 && result.Parent == treePath {
474 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
475 return
476 }
477
478 user := s.oauth.GetUser(r)
479
480 var breadcrumbs [][]string
481 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
482 if treePath != "" {
483 for idx, elem := range strings.Split(treePath, "/") {
484 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
485 }
486 }
487
488 baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
489 baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
490
491 s.pages.RepoTree(w, pages.RepoTreeParams{
492 LoggedInUser: user,
493 BreadCrumbs: breadcrumbs,
494 BaseTreeLink: baseTreeLink,
495 BaseBlobLink: baseBlobLink,
496 RepoInfo: f.RepoInfo(s, user),
497 RepoTreeResponse: result,
498 })
499 return
500}
501
502func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) {
503 f, err := s.fullyResolvedRepo(r)
504 if err != nil {
505 log.Println("failed to get repo and knot", err)
506 return
507 }
508
509 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
510 if err != nil {
511 log.Println("failed to create unsigned client", err)
512 return
513 }
514
515 result, err := us.Tags(f.OwnerDid(), f.RepoName)
516 if err != nil {
517 log.Println("failed to reach knotserver", err)
518 return
519 }
520
521 artifacts, err := db.GetArtifact(s.db, db.FilterEq("repo_at", f.RepoAt))
522 if err != nil {
523 log.Println("failed grab artifacts", err)
524 return
525 }
526
527 // convert artifacts to map for easy UI building
528 artifactMap := make(map[plumbing.Hash][]db.Artifact)
529 for _, a := range artifacts {
530 artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
531 }
532
533 var danglingArtifacts []db.Artifact
534 for _, a := range artifacts {
535 found := false
536 for _, t := range result.Tags {
537 if t.Tag != nil {
538 if t.Tag.Hash == a.Tag {
539 found = true
540 }
541 }
542 }
543
544 if !found {
545 danglingArtifacts = append(danglingArtifacts, a)
546 }
547 }
548
549 user := s.oauth.GetUser(r)
550 s.pages.RepoTags(w, pages.RepoTagsParams{
551 LoggedInUser: user,
552 RepoInfo: f.RepoInfo(s, user),
553 RepoTagsResponse: *result,
554 ArtifactMap: artifactMap,
555 DanglingArtifacts: danglingArtifacts,
556 })
557 return
558}
559
560func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) {
561 f, err := s.fullyResolvedRepo(r)
562 if err != nil {
563 log.Println("failed to get repo and knot", err)
564 return
565 }
566
567 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
568 if err != nil {
569 log.Println("failed to create unsigned client", err)
570 return
571 }
572
573 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
574 if err != nil {
575 log.Println("failed to reach knotserver", err)
576 return
577 }
578
579 body, err := io.ReadAll(resp.Body)
580 if err != nil {
581 log.Printf("Error reading response body: %v", err)
582 return
583 }
584
585 var result types.RepoBranchesResponse
586 err = json.Unmarshal(body, &result)
587 if err != nil {
588 log.Println("failed to parse response:", err)
589 return
590 }
591
592 slices.SortFunc(result.Branches, func(a, b types.Branch) int {
593 if a.IsDefault {
594 return -1
595 }
596 if b.IsDefault {
597 return 1
598 }
599 if a.Commit != nil {
600 if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
601 return 1
602 } else {
603 return -1
604 }
605 }
606 return strings.Compare(a.Name, b.Name) * -1
607 })
608
609 user := s.oauth.GetUser(r)
610 s.pages.RepoBranches(w, pages.RepoBranchesParams{
611 LoggedInUser: user,
612 RepoInfo: f.RepoInfo(s, user),
613 RepoBranchesResponse: result,
614 })
615 return
616}
617
618func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) {
619 f, err := s.fullyResolvedRepo(r)
620 if err != nil {
621 log.Println("failed to get repo and knot", err)
622 return
623 }
624
625 ref := chi.URLParam(r, "ref")
626 filePath := chi.URLParam(r, "*")
627 protocol := "http"
628 if !s.config.Core.Dev {
629 protocol = "https"
630 }
631 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
632 if err != nil {
633 log.Println("failed to reach knotserver", err)
634 return
635 }
636
637 body, err := io.ReadAll(resp.Body)
638 if err != nil {
639 log.Printf("Error reading response body: %v", err)
640 return
641 }
642
643 var result types.RepoBlobResponse
644 err = json.Unmarshal(body, &result)
645 if err != nil {
646 log.Println("failed to parse response:", err)
647 return
648 }
649
650 var breadcrumbs [][]string
651 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
652 if filePath != "" {
653 for idx, elem := range strings.Split(filePath, "/") {
654 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
655 }
656 }
657
658 showRendered := false
659 renderToggle := false
660
661 if markup.GetFormat(result.Path) == markup.FormatMarkdown {
662 renderToggle = true
663 showRendered = r.URL.Query().Get("code") != "true"
664 }
665
666 user := s.oauth.GetUser(r)
667 s.pages.RepoBlob(w, pages.RepoBlobParams{
668 LoggedInUser: user,
669 RepoInfo: f.RepoInfo(s, user),
670 RepoBlobResponse: result,
671 BreadCrumbs: breadcrumbs,
672 ShowRendered: showRendered,
673 RenderToggle: renderToggle,
674 })
675 return
676}
677
678func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
679 f, err := s.fullyResolvedRepo(r)
680 if err != nil {
681 log.Println("failed to get repo and knot", err)
682 return
683 }
684
685 ref := chi.URLParam(r, "ref")
686 filePath := chi.URLParam(r, "*")
687
688 protocol := "http"
689 if !s.config.Core.Dev {
690 protocol = "https"
691 }
692 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
693 if err != nil {
694 log.Println("failed to reach knotserver", err)
695 return
696 }
697
698 body, err := io.ReadAll(resp.Body)
699 if err != nil {
700 log.Printf("Error reading response body: %v", err)
701 return
702 }
703
704 var result types.RepoBlobResponse
705 err = json.Unmarshal(body, &result)
706 if err != nil {
707 log.Println("failed to parse response:", err)
708 return
709 }
710
711 if result.IsBinary {
712 w.Header().Set("Content-Type", "application/octet-stream")
713 w.Write(body)
714 return
715 }
716
717 w.Header().Set("Content-Type", "text/plain")
718 w.Write([]byte(result.Contents))
719 return
720}
721
722func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
723 f, err := s.fullyResolvedRepo(r)
724 if err != nil {
725 log.Println("failed to get repo and knot", err)
726 return
727 }
728
729 collaborator := r.FormValue("collaborator")
730 if collaborator == "" {
731 http.Error(w, "malformed form", http.StatusBadRequest)
732 return
733 }
734
735 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator)
736 if err != nil {
737 w.Write([]byte("failed to resolve collaborator did to a handle"))
738 return
739 }
740 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
741
742 // TODO: create an atproto record for this
743
744 secret, err := db.GetRegistrationKey(s.db, f.Knot)
745 if err != nil {
746 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
747 return
748 }
749
750 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
751 if err != nil {
752 log.Println("failed to create client to ", f.Knot)
753 return
754 }
755
756 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
757 if err != nil {
758 log.Printf("failed to make request to %s: %s", f.Knot, err)
759 return
760 }
761
762 if ksResp.StatusCode != http.StatusNoContent {
763 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err)))
764 return
765 }
766
767 tx, err := s.db.BeginTx(r.Context(), nil)
768 if err != nil {
769 log.Println("failed to start tx")
770 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
771 return
772 }
773 defer func() {
774 tx.Rollback()
775 err = s.enforcer.E.LoadPolicy()
776 if err != nil {
777 log.Println("failed to rollback policies")
778 }
779 }()
780
781 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
782 if err != nil {
783 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
784 return
785 }
786
787 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
788 if err != nil {
789 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
790 return
791 }
792
793 err = tx.Commit()
794 if err != nil {
795 log.Println("failed to commit changes", err)
796 http.Error(w, err.Error(), http.StatusInternalServerError)
797 return
798 }
799
800 err = s.enforcer.E.SavePolicy()
801 if err != nil {
802 log.Println("failed to update ACLs", err)
803 http.Error(w, err.Error(), http.StatusInternalServerError)
804 return
805 }
806
807 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String())))
808
809}
810
811func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) {
812 user := s.oauth.GetUser(r)
813
814 f, err := s.fullyResolvedRepo(r)
815 if err != nil {
816 log.Println("failed to get repo and knot", err)
817 return
818 }
819
820 // remove record from pds
821 xrpcClient, err := s.oauth.AuthorizedClient(r)
822 if err != nil {
823 log.Println("failed to get authorized client", err)
824 return
825 }
826 repoRkey := f.RepoAt.RecordKey().String()
827 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
828 Collection: tangled.RepoNSID,
829 Repo: user.Did,
830 Rkey: repoRkey,
831 })
832 if err != nil {
833 log.Printf("failed to delete record: %s", err)
834 s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
835 return
836 }
837 log.Println("removed repo record ", f.RepoAt.String())
838
839 secret, err := db.GetRegistrationKey(s.db, f.Knot)
840 if err != nil {
841 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
842 return
843 }
844
845 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
846 if err != nil {
847 log.Println("failed to create client to ", f.Knot)
848 return
849 }
850
851 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
852 if err != nil {
853 log.Printf("failed to make request to %s: %s", f.Knot, err)
854 return
855 }
856
857 if ksResp.StatusCode != http.StatusNoContent {
858 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
859 } else {
860 log.Println("removed repo from knot ", f.Knot)
861 }
862
863 tx, err := s.db.BeginTx(r.Context(), nil)
864 if err != nil {
865 log.Println("failed to start tx")
866 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
867 return
868 }
869 defer func() {
870 tx.Rollback()
871 err = s.enforcer.E.LoadPolicy()
872 if err != nil {
873 log.Println("failed to rollback policies")
874 }
875 }()
876
877 // remove collaborator RBAC
878 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
879 if err != nil {
880 s.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
881 return
882 }
883 for _, c := range repoCollaborators {
884 did := c[0]
885 s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
886 }
887 log.Println("removed collaborators")
888
889 // remove repo RBAC
890 err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
891 if err != nil {
892 s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
893 return
894 }
895
896 // remove repo from db
897 err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName)
898 if err != nil {
899 s.pages.Notice(w, "settings-delete", "Failed to update appview")
900 return
901 }
902 log.Println("removed repo from db")
903
904 err = tx.Commit()
905 if err != nil {
906 log.Println("failed to commit changes", err)
907 http.Error(w, err.Error(), http.StatusInternalServerError)
908 return
909 }
910
911 err = s.enforcer.E.SavePolicy()
912 if err != nil {
913 log.Println("failed to update ACLs", err)
914 http.Error(w, err.Error(), http.StatusInternalServerError)
915 return
916 }
917
918 s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
919}
920
921func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
922 f, err := s.fullyResolvedRepo(r)
923 if err != nil {
924 log.Println("failed to get repo and knot", err)
925 return
926 }
927
928 branch := r.FormValue("branch")
929 if branch == "" {
930 http.Error(w, "malformed form", http.StatusBadRequest)
931 return
932 }
933
934 secret, err := db.GetRegistrationKey(s.db, f.Knot)
935 if err != nil {
936 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
937 return
938 }
939
940 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
941 if err != nil {
942 log.Println("failed to create client to ", f.Knot)
943 return
944 }
945
946 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
947 if err != nil {
948 log.Printf("failed to make request to %s: %s", f.Knot, err)
949 return
950 }
951
952 if ksResp.StatusCode != http.StatusNoContent {
953 s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
954 return
955 }
956
957 w.Write([]byte(fmt.Sprint("default branch set to: ", branch)))
958}
959
960func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
961 f, err := s.fullyResolvedRepo(r)
962 if err != nil {
963 log.Println("failed to get repo and knot", err)
964 return
965 }
966
967 switch r.Method {
968 case http.MethodGet:
969 // for now, this is just pubkeys
970 user := s.oauth.GetUser(r)
971 repoCollaborators, err := f.Collaborators(r.Context(), s)
972 if err != nil {
973 log.Println("failed to get collaborators", err)
974 }
975
976 isCollaboratorInviteAllowed := false
977 if user != nil {
978 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
979 if err == nil && ok {
980 isCollaboratorInviteAllowed = true
981 }
982 }
983
984 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
985 if err != nil {
986 log.Println("failed to create unsigned client", err)
987 return
988 }
989
990 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
991 if err != nil {
992 log.Println("failed to reach knotserver", err)
993 return
994 }
995 defer resp.Body.Close()
996
997 body, err := io.ReadAll(resp.Body)
998 if err != nil {
999 log.Printf("Error reading response body: %v", err)
1000 }
1001
1002 var result types.RepoBranchesResponse
1003 err = json.Unmarshal(body, &result)
1004 if err != nil {
1005 log.Println("failed to parse response:", err)
1006 }
1007
1008 s.pages.RepoSettings(w, pages.RepoSettingsParams{
1009 LoggedInUser: user,
1010 RepoInfo: f.RepoInfo(s, user),
1011 Collaborators: repoCollaborators,
1012 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1013 Branches: result.Branches,
1014 })
1015 }
1016}
1017
1018type FullyResolvedRepo struct {
1019 Knot string
1020 OwnerId identity.Identity
1021 RepoName string
1022 RepoAt syntax.ATURI
1023 Description string
1024 CreatedAt string
1025 Ref string
1026 CurrentDir string
1027}
1028
1029func (f *FullyResolvedRepo) OwnerDid() string {
1030 return f.OwnerId.DID.String()
1031}
1032
1033func (f *FullyResolvedRepo) OwnerHandle() string {
1034 return f.OwnerId.Handle.String()
1035}
1036
1037func (f *FullyResolvedRepo) OwnerSlashRepo() string {
1038 handle := f.OwnerId.Handle
1039
1040 var p string
1041 if handle != "" && !handle.IsInvalidHandle() {
1042 p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName)
1043 } else {
1044 p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
1045 }
1046
1047 return p
1048}
1049
1050func (f *FullyResolvedRepo) DidSlashRepo() string {
1051 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
1052 return p
1053}
1054
1055func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
1056 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
1057 if err != nil {
1058 return nil, err
1059 }
1060
1061 var collaborators []pages.Collaborator
1062 for _, item := range repoCollaborators {
1063 // currently only two roles: owner and member
1064 var role string
1065 if item[3] == "repo:owner" {
1066 role = "owner"
1067 } else if item[3] == "repo:collaborator" {
1068 role = "collaborator"
1069 } else {
1070 continue
1071 }
1072
1073 did := item[0]
1074
1075 c := pages.Collaborator{
1076 Did: did,
1077 Handle: "",
1078 Role: role,
1079 }
1080 collaborators = append(collaborators, c)
1081 }
1082
1083 // populate all collborators with handles
1084 identsToResolve := make([]string, len(collaborators))
1085 for i, collab := range collaborators {
1086 identsToResolve[i] = collab.Did
1087 }
1088
1089 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve)
1090 for i, resolved := range resolvedIdents {
1091 if resolved != nil {
1092 collaborators[i].Handle = resolved.Handle.String()
1093 }
1094 }
1095
1096 return collaborators, nil
1097}
1098
1099func (f *FullyResolvedRepo) RepoInfo(s *State, u *oauth.User) repoinfo.RepoInfo {
1100 isStarred := false
1101 if u != nil {
1102 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
1103 }
1104
1105 starCount, err := db.GetStarCount(s.db, f.RepoAt)
1106 if err != nil {
1107 log.Println("failed to get star count for ", f.RepoAt)
1108 }
1109 issueCount, err := db.GetIssueCount(s.db, f.RepoAt)
1110 if err != nil {
1111 log.Println("failed to get issue count for ", f.RepoAt)
1112 }
1113 pullCount, err := db.GetPullCount(s.db, f.RepoAt)
1114 if err != nil {
1115 log.Println("failed to get issue count for ", f.RepoAt)
1116 }
1117 source, err := db.GetRepoSource(s.db, f.RepoAt)
1118 if errors.Is(err, sql.ErrNoRows) {
1119 source = ""
1120 } else if err != nil {
1121 log.Println("failed to get repo source for ", f.RepoAt, err)
1122 }
1123
1124 var sourceRepo *db.Repo
1125 if source != "" {
1126 sourceRepo, err = db.GetRepoByAtUri(s.db, source)
1127 if err != nil {
1128 log.Println("failed to get repo by at uri", err)
1129 }
1130 }
1131
1132 var sourceHandle *identity.Identity
1133 if sourceRepo != nil {
1134 sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did)
1135 if err != nil {
1136 log.Println("failed to resolve source repo", err)
1137 }
1138 }
1139
1140 knot := f.Knot
1141 var disableFork bool
1142 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
1143 if err != nil {
1144 log.Printf("failed to create unsigned client for %s: %v", knot, err)
1145 } else {
1146 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
1147 if err != nil {
1148 log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
1149 } else {
1150 defer resp.Body.Close()
1151 body, err := io.ReadAll(resp.Body)
1152 if err != nil {
1153 log.Printf("error reading branch response body: %v", err)
1154 } else {
1155 var branchesResp types.RepoBranchesResponse
1156 if err := json.Unmarshal(body, &branchesResp); err != nil {
1157 log.Printf("error parsing branch response: %v", err)
1158 } else {
1159 disableFork = false
1160 }
1161
1162 if len(branchesResp.Branches) == 0 {
1163 disableFork = true
1164 }
1165 }
1166 }
1167 }
1168
1169 repoInfo := repoinfo.RepoInfo{
1170 OwnerDid: f.OwnerDid(),
1171 OwnerHandle: f.OwnerHandle(),
1172 Name: f.RepoName,
1173 RepoAt: f.RepoAt,
1174 Description: f.Description,
1175 Ref: f.Ref,
1176 IsStarred: isStarred,
1177 Knot: knot,
1178 Roles: RolesInRepo(s, u, f),
1179 Stats: db.RepoStats{
1180 StarCount: starCount,
1181 IssueCount: issueCount,
1182 PullCount: pullCount,
1183 },
1184 DisableFork: disableFork,
1185 CurrentDir: f.CurrentDir,
1186 }
1187
1188 if sourceRepo != nil {
1189 repoInfo.Source = sourceRepo
1190 repoInfo.SourceHandle = sourceHandle.Handle.String()
1191 }
1192
1193 return repoInfo
1194}
1195
1196func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
1197 user := s.oauth.GetUser(r)
1198 f, err := s.fullyResolvedRepo(r)
1199 if err != nil {
1200 log.Println("failed to get repo and knot", err)
1201 return
1202 }
1203
1204 issueId := chi.URLParam(r, "issue")
1205 issueIdInt, err := strconv.Atoi(issueId)
1206 if err != nil {
1207 http.Error(w, "bad issue id", http.StatusBadRequest)
1208 log.Println("failed to parse issue id", err)
1209 return
1210 }
1211
1212 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt)
1213 if err != nil {
1214 log.Println("failed to get issue and comments", err)
1215 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1216 return
1217 }
1218
1219 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
1220 if err != nil {
1221 log.Println("failed to resolve issue owner", err)
1222 }
1223
1224 identsToResolve := make([]string, len(comments))
1225 for i, comment := range comments {
1226 identsToResolve[i] = comment.OwnerDid
1227 }
1228 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1229 didHandleMap := make(map[string]string)
1230 for _, identity := range resolvedIds {
1231 if !identity.Handle.IsInvalidHandle() {
1232 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1233 } else {
1234 didHandleMap[identity.DID.String()] = identity.DID.String()
1235 }
1236 }
1237
1238 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
1239 LoggedInUser: user,
1240 RepoInfo: f.RepoInfo(s, user),
1241 Issue: *issue,
1242 Comments: comments,
1243
1244 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
1245 DidHandleMap: didHandleMap,
1246 })
1247
1248}
1249
1250func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
1251 user := s.oauth.GetUser(r)
1252 f, err := s.fullyResolvedRepo(r)
1253 if err != nil {
1254 log.Println("failed to get repo and knot", err)
1255 return
1256 }
1257
1258 issueId := chi.URLParam(r, "issue")
1259 issueIdInt, err := strconv.Atoi(issueId)
1260 if err != nil {
1261 http.Error(w, "bad issue id", http.StatusBadRequest)
1262 log.Println("failed to parse issue id", err)
1263 return
1264 }
1265
1266 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1267 if err != nil {
1268 log.Println("failed to get issue", err)
1269 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1270 return
1271 }
1272
1273 collaborators, err := f.Collaborators(r.Context(), s)
1274 if err != nil {
1275 log.Println("failed to fetch repo collaborators: %w", err)
1276 }
1277 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1278 return user.Did == collab.Did
1279 })
1280 isIssueOwner := user.Did == issue.OwnerDid
1281
1282 // TODO: make this more granular
1283 if isIssueOwner || isCollaborator {
1284
1285 closed := tangled.RepoIssueStateClosed
1286
1287 client, err := s.oauth.AuthorizedClient(r)
1288 if err != nil {
1289 log.Println("failed to get authorized client", err)
1290 return
1291 }
1292 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1293 Collection: tangled.RepoIssueStateNSID,
1294 Repo: user.Did,
1295 Rkey: appview.TID(),
1296 Record: &lexutil.LexiconTypeDecoder{
1297 Val: &tangled.RepoIssueState{
1298 Issue: issue.IssueAt,
1299 State: closed,
1300 },
1301 },
1302 })
1303
1304 if err != nil {
1305 log.Println("failed to update issue state", err)
1306 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1307 return
1308 }
1309
1310 err = db.CloseIssue(s.db, f.RepoAt, issueIdInt)
1311 if err != nil {
1312 log.Println("failed to close issue", err)
1313 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1314 return
1315 }
1316
1317 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1318 return
1319 } else {
1320 log.Println("user is not permitted to close issue")
1321 http.Error(w, "for biden", http.StatusUnauthorized)
1322 return
1323 }
1324}
1325
1326func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
1327 user := s.oauth.GetUser(r)
1328 f, err := s.fullyResolvedRepo(r)
1329 if err != nil {
1330 log.Println("failed to get repo and knot", err)
1331 return
1332 }
1333
1334 issueId := chi.URLParam(r, "issue")
1335 issueIdInt, err := strconv.Atoi(issueId)
1336 if err != nil {
1337 http.Error(w, "bad issue id", http.StatusBadRequest)
1338 log.Println("failed to parse issue id", err)
1339 return
1340 }
1341
1342 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1343 if err != nil {
1344 log.Println("failed to get issue", err)
1345 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1346 return
1347 }
1348
1349 collaborators, err := f.Collaborators(r.Context(), s)
1350 if err != nil {
1351 log.Println("failed to fetch repo collaborators: %w", err)
1352 }
1353 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1354 return user.Did == collab.Did
1355 })
1356 isIssueOwner := user.Did == issue.OwnerDid
1357
1358 if isCollaborator || isIssueOwner {
1359 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
1360 if err != nil {
1361 log.Println("failed to reopen issue", err)
1362 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
1363 return
1364 }
1365 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1366 return
1367 } else {
1368 log.Println("user is not the owner of the repo")
1369 http.Error(w, "forbidden", http.StatusUnauthorized)
1370 return
1371 }
1372}
1373
1374func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
1375 user := s.oauth.GetUser(r)
1376 f, err := s.fullyResolvedRepo(r)
1377 if err != nil {
1378 log.Println("failed to get repo and knot", err)
1379 return
1380 }
1381
1382 issueId := chi.URLParam(r, "issue")
1383 issueIdInt, err := strconv.Atoi(issueId)
1384 if err != nil {
1385 http.Error(w, "bad issue id", http.StatusBadRequest)
1386 log.Println("failed to parse issue id", err)
1387 return
1388 }
1389
1390 switch r.Method {
1391 case http.MethodPost:
1392 body := r.FormValue("body")
1393 if body == "" {
1394 s.pages.Notice(w, "issue", "Body is required")
1395 return
1396 }
1397
1398 commentId := mathrand.IntN(1000000)
1399 rkey := appview.TID()
1400
1401 err := db.NewIssueComment(s.db, &db.Comment{
1402 OwnerDid: user.Did,
1403 RepoAt: f.RepoAt,
1404 Issue: issueIdInt,
1405 CommentId: commentId,
1406 Body: body,
1407 Rkey: rkey,
1408 })
1409 if err != nil {
1410 log.Println("failed to create comment", err)
1411 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1412 return
1413 }
1414
1415 createdAt := time.Now().Format(time.RFC3339)
1416 commentIdInt64 := int64(commentId)
1417 ownerDid := user.Did
1418 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
1419 if err != nil {
1420 log.Println("failed to get issue at", err)
1421 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1422 return
1423 }
1424
1425 atUri := f.RepoAt.String()
1426 client, err := s.oauth.AuthorizedClient(r)
1427 if err != nil {
1428 log.Println("failed to get authorized client", err)
1429 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1430 return
1431 }
1432 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1433 Collection: tangled.RepoIssueCommentNSID,
1434 Repo: user.Did,
1435 Rkey: rkey,
1436 Record: &lexutil.LexiconTypeDecoder{
1437 Val: &tangled.RepoIssueComment{
1438 Repo: &atUri,
1439 Issue: issueAt,
1440 CommentId: &commentIdInt64,
1441 Owner: &ownerDid,
1442 Body: body,
1443 CreatedAt: createdAt,
1444 },
1445 },
1446 })
1447 if err != nil {
1448 log.Println("failed to create comment", err)
1449 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1450 return
1451 }
1452
1453 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
1454 return
1455 }
1456}
1457
1458func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1459 user := s.oauth.GetUser(r)
1460 f, err := s.fullyResolvedRepo(r)
1461 if err != nil {
1462 log.Println("failed to get repo and knot", err)
1463 return
1464 }
1465
1466 issueId := chi.URLParam(r, "issue")
1467 issueIdInt, err := strconv.Atoi(issueId)
1468 if err != nil {
1469 http.Error(w, "bad issue id", http.StatusBadRequest)
1470 log.Println("failed to parse issue id", err)
1471 return
1472 }
1473
1474 commentId := chi.URLParam(r, "comment_id")
1475 commentIdInt, err := strconv.Atoi(commentId)
1476 if err != nil {
1477 http.Error(w, "bad comment id", http.StatusBadRequest)
1478 log.Println("failed to parse issue id", err)
1479 return
1480 }
1481
1482 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1483 if err != nil {
1484 log.Println("failed to get issue", err)
1485 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1486 return
1487 }
1488
1489 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1490 if err != nil {
1491 http.Error(w, "bad comment id", http.StatusBadRequest)
1492 return
1493 }
1494
1495 identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid)
1496 if err != nil {
1497 log.Println("failed to resolve did")
1498 return
1499 }
1500
1501 didHandleMap := make(map[string]string)
1502 if !identity.Handle.IsInvalidHandle() {
1503 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1504 } else {
1505 didHandleMap[identity.DID.String()] = identity.DID.String()
1506 }
1507
1508 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1509 LoggedInUser: user,
1510 RepoInfo: f.RepoInfo(s, user),
1511 DidHandleMap: didHandleMap,
1512 Issue: issue,
1513 Comment: comment,
1514 })
1515}
1516
1517func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) {
1518 user := s.oauth.GetUser(r)
1519 f, err := s.fullyResolvedRepo(r)
1520 if err != nil {
1521 log.Println("failed to get repo and knot", err)
1522 return
1523 }
1524
1525 issueId := chi.URLParam(r, "issue")
1526 issueIdInt, err := strconv.Atoi(issueId)
1527 if err != nil {
1528 http.Error(w, "bad issue id", http.StatusBadRequest)
1529 log.Println("failed to parse issue id", err)
1530 return
1531 }
1532
1533 commentId := chi.URLParam(r, "comment_id")
1534 commentIdInt, err := strconv.Atoi(commentId)
1535 if err != nil {
1536 http.Error(w, "bad comment id", http.StatusBadRequest)
1537 log.Println("failed to parse issue id", err)
1538 return
1539 }
1540
1541 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1542 if err != nil {
1543 log.Println("failed to get issue", err)
1544 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1545 return
1546 }
1547
1548 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1549 if err != nil {
1550 http.Error(w, "bad comment id", http.StatusBadRequest)
1551 return
1552 }
1553
1554 if comment.OwnerDid != user.Did {
1555 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1556 return
1557 }
1558
1559 switch r.Method {
1560 case http.MethodGet:
1561 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
1562 LoggedInUser: user,
1563 RepoInfo: f.RepoInfo(s, user),
1564 Issue: issue,
1565 Comment: comment,
1566 })
1567 case http.MethodPost:
1568 // extract form value
1569 newBody := r.FormValue("body")
1570 client, err := s.oauth.AuthorizedClient(r)
1571 if err != nil {
1572 log.Println("failed to get authorized client", err)
1573 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1574 return
1575 }
1576 rkey := comment.Rkey
1577
1578 // optimistic update
1579 edited := time.Now()
1580 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
1581 if err != nil {
1582 log.Println("failed to perferom update-description query", err)
1583 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
1584 return
1585 }
1586
1587 // rkey is optional, it was introduced later
1588 if comment.Rkey != "" {
1589 // update the record on pds
1590 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
1591 if err != nil {
1592 // failed to get record
1593 log.Println(err, rkey)
1594 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
1595 return
1596 }
1597 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
1598 record, _ := data.UnmarshalJSON(value)
1599
1600 repoAt := record["repo"].(string)
1601 issueAt := record["issue"].(string)
1602 createdAt := record["createdAt"].(string)
1603 commentIdInt64 := int64(commentIdInt)
1604
1605 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1606 Collection: tangled.RepoIssueCommentNSID,
1607 Repo: user.Did,
1608 Rkey: rkey,
1609 SwapRecord: ex.Cid,
1610 Record: &lexutil.LexiconTypeDecoder{
1611 Val: &tangled.RepoIssueComment{
1612 Repo: &repoAt,
1613 Issue: issueAt,
1614 CommentId: &commentIdInt64,
1615 Owner: &comment.OwnerDid,
1616 Body: newBody,
1617 CreatedAt: createdAt,
1618 },
1619 },
1620 })
1621 if err != nil {
1622 log.Println(err)
1623 }
1624 }
1625
1626 // optimistic update for htmx
1627 didHandleMap := map[string]string{
1628 user.Did: user.Handle,
1629 }
1630 comment.Body = newBody
1631 comment.Edited = &edited
1632
1633 // return new comment body with htmx
1634 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1635 LoggedInUser: user,
1636 RepoInfo: f.RepoInfo(s, user),
1637 DidHandleMap: didHandleMap,
1638 Issue: issue,
1639 Comment: comment,
1640 })
1641 return
1642
1643 }
1644
1645}
1646
1647func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
1648 user := s.oauth.GetUser(r)
1649 f, err := s.fullyResolvedRepo(r)
1650 if err != nil {
1651 log.Println("failed to get repo and knot", err)
1652 return
1653 }
1654
1655 issueId := chi.URLParam(r, "issue")
1656 issueIdInt, err := strconv.Atoi(issueId)
1657 if err != nil {
1658 http.Error(w, "bad issue id", http.StatusBadRequest)
1659 log.Println("failed to parse issue id", err)
1660 return
1661 }
1662
1663 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1664 if err != nil {
1665 log.Println("failed to get issue", err)
1666 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1667 return
1668 }
1669
1670 commentId := chi.URLParam(r, "comment_id")
1671 commentIdInt, err := strconv.Atoi(commentId)
1672 if err != nil {
1673 http.Error(w, "bad comment id", http.StatusBadRequest)
1674 log.Println("failed to parse issue id", err)
1675 return
1676 }
1677
1678 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1679 if err != nil {
1680 http.Error(w, "bad comment id", http.StatusBadRequest)
1681 return
1682 }
1683
1684 if comment.OwnerDid != user.Did {
1685 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1686 return
1687 }
1688
1689 if comment.Deleted != nil {
1690 http.Error(w, "comment already deleted", http.StatusBadRequest)
1691 return
1692 }
1693
1694 // optimistic deletion
1695 deleted := time.Now()
1696 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1697 if err != nil {
1698 log.Println("failed to delete comment")
1699 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
1700 return
1701 }
1702
1703 // delete from pds
1704 if comment.Rkey != "" {
1705 client, err := s.oauth.AuthorizedClient(r)
1706 if err != nil {
1707 log.Println("failed to get authorized client", err)
1708 s.pages.Notice(w, "issue-comment", "Failed to delete comment.")
1709 return
1710 }
1711 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
1712 Collection: tangled.GraphFollowNSID,
1713 Repo: user.Did,
1714 Rkey: comment.Rkey,
1715 })
1716 if err != nil {
1717 log.Println(err)
1718 }
1719 }
1720
1721 // optimistic update for htmx
1722 didHandleMap := map[string]string{
1723 user.Did: user.Handle,
1724 }
1725 comment.Body = ""
1726 comment.Deleted = &deleted
1727
1728 // htmx fragment of comment after deletion
1729 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1730 LoggedInUser: user,
1731 RepoInfo: f.RepoInfo(s, user),
1732 DidHandleMap: didHandleMap,
1733 Issue: issue,
1734 Comment: comment,
1735 })
1736 return
1737}
1738
1739func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
1740 params := r.URL.Query()
1741 state := params.Get("state")
1742 isOpen := true
1743 switch state {
1744 case "open":
1745 isOpen = true
1746 case "closed":
1747 isOpen = false
1748 default:
1749 isOpen = true
1750 }
1751
1752 page, ok := r.Context().Value("page").(pagination.Page)
1753 if !ok {
1754 log.Println("failed to get page")
1755 page = pagination.FirstPage()
1756 }
1757
1758 user := s.oauth.GetUser(r)
1759 f, err := s.fullyResolvedRepo(r)
1760 if err != nil {
1761 log.Println("failed to get repo and knot", err)
1762 return
1763 }
1764
1765 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page)
1766 if err != nil {
1767 log.Println("failed to get issues", err)
1768 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
1769 return
1770 }
1771
1772 identsToResolve := make([]string, len(issues))
1773 for i, issue := range issues {
1774 identsToResolve[i] = issue.OwnerDid
1775 }
1776 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1777 didHandleMap := make(map[string]string)
1778 for _, identity := range resolvedIds {
1779 if !identity.Handle.IsInvalidHandle() {
1780 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1781 } else {
1782 didHandleMap[identity.DID.String()] = identity.DID.String()
1783 }
1784 }
1785
1786 s.pages.RepoIssues(w, pages.RepoIssuesParams{
1787 LoggedInUser: s.oauth.GetUser(r),
1788 RepoInfo: f.RepoInfo(s, user),
1789 Issues: issues,
1790 DidHandleMap: didHandleMap,
1791 FilteringByOpen: isOpen,
1792 Page: page,
1793 })
1794 return
1795}
1796
1797func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
1798 user := s.oauth.GetUser(r)
1799
1800 f, err := s.fullyResolvedRepo(r)
1801 if err != nil {
1802 log.Println("failed to get repo and knot", err)
1803 return
1804 }
1805
1806 switch r.Method {
1807 case http.MethodGet:
1808 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1809 LoggedInUser: user,
1810 RepoInfo: f.RepoInfo(s, user),
1811 })
1812 case http.MethodPost:
1813 title := r.FormValue("title")
1814 body := r.FormValue("body")
1815
1816 if title == "" || body == "" {
1817 s.pages.Notice(w, "issues", "Title and body are required")
1818 return
1819 }
1820
1821 tx, err := s.db.BeginTx(r.Context(), nil)
1822 if err != nil {
1823 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
1824 return
1825 }
1826
1827 err = db.NewIssue(tx, &db.Issue{
1828 RepoAt: f.RepoAt,
1829 Title: title,
1830 Body: body,
1831 OwnerDid: user.Did,
1832 })
1833 if err != nil {
1834 log.Println("failed to create issue", err)
1835 s.pages.Notice(w, "issues", "Failed to create issue.")
1836 return
1837 }
1838
1839 issueId, err := db.GetIssueId(s.db, f.RepoAt)
1840 if err != nil {
1841 log.Println("failed to get issue id", err)
1842 s.pages.Notice(w, "issues", "Failed to create issue.")
1843 return
1844 }
1845
1846 client, err := s.oauth.AuthorizedClient(r)
1847 if err != nil {
1848 log.Println("failed to get authorized client", err)
1849 s.pages.Notice(w, "issues", "Failed to create issue.")
1850 return
1851 }
1852 atUri := f.RepoAt.String()
1853 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1854 Collection: tangled.RepoIssueNSID,
1855 Repo: user.Did,
1856 Rkey: appview.TID(),
1857 Record: &lexutil.LexiconTypeDecoder{
1858 Val: &tangled.RepoIssue{
1859 Repo: atUri,
1860 Title: title,
1861 Body: &body,
1862 Owner: user.Did,
1863 IssueId: int64(issueId),
1864 },
1865 },
1866 })
1867 if err != nil {
1868 log.Println("failed to create issue", err)
1869 s.pages.Notice(w, "issues", "Failed to create issue.")
1870 return
1871 }
1872
1873 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
1874 if err != nil {
1875 log.Println("failed to set issue at", err)
1876 s.pages.Notice(w, "issues", "Failed to create issue.")
1877 return
1878 }
1879
1880 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1881 return
1882 }
1883}
1884
1885func (s *State) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1886 user := s.oauth.GetUser(r)
1887 f, err := s.fullyResolvedRepo(r)
1888 if err != nil {
1889 log.Printf("failed to resolve source repo: %v", err)
1890 return
1891 }
1892
1893 switch r.Method {
1894 case http.MethodPost:
1895 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1896 if err != nil {
1897 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot))
1898 return
1899 }
1900
1901 client, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
1902 if err != nil {
1903 s.pages.Notice(w, "repo", "Failed to reach knot server.")
1904 return
1905 }
1906
1907 var uri string
1908 if s.config.Core.Dev {
1909 uri = "http"
1910 } else {
1911 uri = "https"
1912 }
1913 forkName := fmt.Sprintf("%s", f.RepoName)
1914 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1915
1916 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref)
1917 if err != nil {
1918 s.pages.Notice(w, "repo", "Failed to sync repository fork.")
1919 return
1920 }
1921
1922 s.pages.HxRefresh(w)
1923 return
1924 }
1925}
1926
1927func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) {
1928 user := s.oauth.GetUser(r)
1929 f, err := s.fullyResolvedRepo(r)
1930 if err != nil {
1931 log.Printf("failed to resolve source repo: %v", err)
1932 return
1933 }
1934
1935 switch r.Method {
1936 case http.MethodGet:
1937 user := s.oauth.GetUser(r)
1938 knots, err := s.enforcer.GetDomainsForUser(user.Did)
1939 if err != nil {
1940 s.pages.Notice(w, "repo", "Invalid user account.")
1941 return
1942 }
1943
1944 s.pages.ForkRepo(w, pages.ForkRepoParams{
1945 LoggedInUser: user,
1946 Knots: knots,
1947 RepoInfo: f.RepoInfo(s, user),
1948 })
1949
1950 case http.MethodPost:
1951
1952 knot := r.FormValue("knot")
1953 if knot == "" {
1954 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1955 return
1956 }
1957
1958 ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1959 if err != nil || !ok {
1960 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1961 return
1962 }
1963
1964 forkName := fmt.Sprintf("%s", f.RepoName)
1965
1966 // this check is *only* to see if the forked repo name already exists
1967 // in the user's account.
1968 existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName)
1969 if err != nil {
1970 if errors.Is(err, sql.ErrNoRows) {
1971 // no existing repo with this name found, we can use the name as is
1972 } else {
1973 log.Println("error fetching existing repo from db", err)
1974 s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1975 return
1976 }
1977 } else if existingRepo != nil {
1978 // repo with this name already exists, append random string
1979 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1980 }
1981 secret, err := db.GetRegistrationKey(s.db, knot)
1982 if err != nil {
1983 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1984 return
1985 }
1986
1987 client, err := knotclient.NewSignedClient(knot, secret, s.config.Core.Dev)
1988 if err != nil {
1989 s.pages.Notice(w, "repo", "Failed to reach knot server.")
1990 return
1991 }
1992
1993 var uri string
1994 if s.config.Core.Dev {
1995 uri = "http"
1996 } else {
1997 uri = "https"
1998 }
1999 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
2000 sourceAt := f.RepoAt.String()
2001
2002 rkey := appview.TID()
2003 repo := &db.Repo{
2004 Did: user.Did,
2005 Name: forkName,
2006 Knot: knot,
2007 Rkey: rkey,
2008 Source: sourceAt,
2009 }
2010
2011 tx, err := s.db.BeginTx(r.Context(), nil)
2012 if err != nil {
2013 log.Println(err)
2014 s.pages.Notice(w, "repo", "Failed to save repository information.")
2015 return
2016 }
2017 defer func() {
2018 tx.Rollback()
2019 err = s.enforcer.E.LoadPolicy()
2020 if err != nil {
2021 log.Println("failed to rollback policies")
2022 }
2023 }()
2024
2025 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
2026 if err != nil {
2027 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
2028 return
2029 }
2030
2031 switch resp.StatusCode {
2032 case http.StatusConflict:
2033 s.pages.Notice(w, "repo", "A repository with that name already exists.")
2034 return
2035 case http.StatusInternalServerError:
2036 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
2037 case http.StatusNoContent:
2038 // continue
2039 }
2040
2041 xrpcClient, err := s.oauth.AuthorizedClient(r)
2042 if err != nil {
2043 log.Println("failed to get authorized client", err)
2044 s.pages.Notice(w, "repo", "Failed to create repository.")
2045 return
2046 }
2047
2048 createdAt := time.Now().Format(time.RFC3339)
2049 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
2050 Collection: tangled.RepoNSID,
2051 Repo: user.Did,
2052 Rkey: rkey,
2053 Record: &lexutil.LexiconTypeDecoder{
2054 Val: &tangled.Repo{
2055 Knot: repo.Knot,
2056 Name: repo.Name,
2057 CreatedAt: createdAt,
2058 Owner: user.Did,
2059 Source: &sourceAt,
2060 }},
2061 })
2062 if err != nil {
2063 log.Printf("failed to create record: %s", err)
2064 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
2065 return
2066 }
2067 log.Println("created repo record: ", atresp.Uri)
2068
2069 repo.AtUri = atresp.Uri
2070 err = db.AddRepo(tx, repo)
2071 if err != nil {
2072 log.Println(err)
2073 s.pages.Notice(w, "repo", "Failed to save repository information.")
2074 return
2075 }
2076
2077 // acls
2078 p, _ := securejoin.SecureJoin(user.Did, forkName)
2079 err = s.enforcer.AddRepo(user.Did, knot, p)
2080 if err != nil {
2081 log.Println(err)
2082 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
2083 return
2084 }
2085
2086 err = tx.Commit()
2087 if err != nil {
2088 log.Println("failed to commit changes", err)
2089 http.Error(w, err.Error(), http.StatusInternalServerError)
2090 return
2091 }
2092
2093 err = s.enforcer.E.SavePolicy()
2094 if err != nil {
2095 log.Println("failed to update ACLs", err)
2096 http.Error(w, err.Error(), http.StatusInternalServerError)
2097 return
2098 }
2099
2100 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
2101 return
2102 }
2103}