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