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