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