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