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