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