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