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