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