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