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