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