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