1package repo
2
3import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "log"
10 "net/http"
11 "net/url"
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/reporesolver"
29 "tangled.sh/tangled.sh/core/knotclient"
30 "tangled.sh/tangled.sh/core/patchutil"
31 "tangled.sh/tangled.sh/core/rbac"
32 "tangled.sh/tangled.sh/core/types"
33
34 securejoin "github.com/cyphar/filepath-securejoin"
35 "github.com/go-chi/chi/v5"
36 "github.com/go-git/go-git/v5/plumbing"
37 "github.com/posthog/posthog-go"
38
39 comatproto "github.com/bluesky-social/indigo/api/atproto"
40 lexutil "github.com/bluesky-social/indigo/lex/util"
41)
42
43type Repo struct {
44 repoResolver *reporesolver.RepoResolver
45 idResolver *idresolver.Resolver
46 config *config.Config
47 oauth *oauth.OAuth
48 pages *pages.Pages
49 db *db.DB
50 enforcer *rbac.Enforcer
51 posthog posthog.Client
52}
53
54func New(
55 oauth *oauth.OAuth,
56 repoResolver *reporesolver.RepoResolver,
57 pages *pages.Pages,
58 idResolver *idresolver.Resolver,
59 db *db.DB,
60 config *config.Config,
61 posthog posthog.Client,
62 enforcer *rbac.Enforcer,
63) *Repo {
64 return &Repo{oauth: oauth,
65 repoResolver: repoResolver,
66 pages: pages,
67 idResolver: idResolver,
68 config: config,
69 db: db,
70 posthog: posthog,
71 enforcer: enforcer,
72 }
73}
74
75func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
76 ref := chi.URLParam(r, "ref")
77 f, err := rp.repoResolver.Resolve(r)
78 if err != nil {
79 log.Println("failed to fully resolve repo", err)
80 return
81 }
82
83 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
84 if err != nil {
85 log.Printf("failed to create unsigned client for %s", f.Knot)
86 rp.pages.Error503(w)
87 return
88 }
89
90 result, err := us.Index(f.OwnerDid(), f.RepoName, ref)
91 if err != nil {
92 rp.pages.Error503(w)
93 log.Println("failed to reach knotserver", err)
94 return
95 }
96
97 tagMap := make(map[string][]string)
98 for _, tag := range result.Tags {
99 hash := tag.Hash
100 if tag.Tag != nil {
101 hash = tag.Tag.Target.String()
102 }
103 tagMap[hash] = append(tagMap[hash], tag.Name)
104 }
105
106 for _, branch := range result.Branches {
107 hash := branch.Hash
108 tagMap[hash] = append(tagMap[hash], branch.Name)
109 }
110
111 slices.SortFunc(result.Branches, func(a, b types.Branch) int {
112 if a.Name == result.Ref {
113 return -1
114 }
115 if a.IsDefault {
116 return -1
117 }
118 if b.IsDefault {
119 return 1
120 }
121 if a.Commit != nil {
122 if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
123 return 1
124 } else {
125 return -1
126 }
127 }
128 return strings.Compare(a.Name, b.Name) * -1
129 })
130
131 commitCount := len(result.Commits)
132 branchCount := len(result.Branches)
133 tagCount := len(result.Tags)
134 fileCount := len(result.Files)
135
136 commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount)
137 commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))]
138 tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))]
139 branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))]
140
141 emails := uniqueEmails(commitsTrunc)
142 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
143 if err != nil {
144 log.Println("failed to get email to did map", err)
145 }
146
147 vc, err := verifiedObjectCommits(rp, emailToDidMap, commitsTrunc)
148 if err != nil {
149 log.Println(err)
150 }
151
152 user := rp.oauth.GetUser(r)
153 repoInfo := f.RepoInfo(user)
154
155 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
156 if err != nil {
157 log.Printf("failed to get registration key for %s: %s", f.Knot, err)
158 rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
159 }
160
161 signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
162 if err != nil {
163 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
164 return
165 }
166
167 var forkInfo *types.ForkInfo
168 if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) {
169 forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient)
170 if err != nil {
171 log.Printf("Failed to fetch fork information: %v", err)
172 return
173 }
174 }
175
176 repoLanguages, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, ref)
177 if err != nil {
178 log.Printf("failed to compute language percentages: %s", err)
179 // non-fatal
180 }
181
182 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
183 LoggedInUser: user,
184 RepoInfo: repoInfo,
185 TagMap: tagMap,
186 RepoIndexResponse: *result,
187 CommitsTrunc: commitsTrunc,
188 TagsTrunc: tagsTrunc,
189 ForkInfo: forkInfo,
190 BranchesTrunc: branchesTrunc,
191 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
192 VerifiedCommits: vc,
193 Languages: repoLanguages,
194 })
195 return
196}
197
198func getForkInfo(
199 repoInfo repoinfo.RepoInfo,
200 rp *Repo,
201 f *reporesolver.ResolvedRepo,
202 user *oauth.User,
203 signedClient *knotclient.SignedClient,
204) (*types.ForkInfo, error) {
205 if user == nil {
206 return nil, nil
207 }
208
209 forkInfo := types.ForkInfo{
210 IsFork: repoInfo.Source != nil,
211 Status: types.UpToDate,
212 }
213
214 if !forkInfo.IsFork {
215 forkInfo.IsFork = false
216 return &forkInfo, nil
217 }
218
219 us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev)
220 if err != nil {
221 log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot)
222 return nil, err
223 }
224
225 result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name)
226 if err != nil {
227 log.Println("failed to reach knotserver", err)
228 return nil, err
229 }
230
231 if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool {
232 return branch.Name == f.Ref
233 }) {
234 forkInfo.Status = types.MissingBranch
235 return &forkInfo, nil
236 }
237
238 newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref)
239 if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent {
240 log.Printf("failed to update tracking branch: %s", err)
241 return nil, err
242 }
243
244 hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref)
245
246 var status types.AncestorCheckResponse
247 forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef)
248 if err != nil {
249 log.Printf("failed to check if fork is ahead/behind: %s", err)
250 return nil, err
251 }
252
253 if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil {
254 log.Printf("failed to decode fork status: %s", err)
255 return nil, err
256 }
257
258 forkInfo.Status = status.Status
259 return &forkInfo, nil
260}
261
262func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
263 f, err := rp.repoResolver.Resolve(r)
264 if err != nil {
265 log.Println("failed to fully resolve repo", err)
266 return
267 }
268
269 page := 1
270 if r.URL.Query().Get("page") != "" {
271 page, err = strconv.Atoi(r.URL.Query().Get("page"))
272 if err != nil {
273 page = 1
274 }
275 }
276
277 ref := chi.URLParam(r, "ref")
278
279 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
280 if err != nil {
281 log.Println("failed to create unsigned client", err)
282 return
283 }
284
285 repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page)
286 if err != nil {
287 log.Println("failed to reach knotserver", err)
288 return
289 }
290
291 result, err := us.Tags(f.OwnerDid(), f.RepoName)
292 if err != nil {
293 log.Println("failed to reach knotserver", err)
294 return
295 }
296
297 tagMap := make(map[string][]string)
298 for _, tag := range result.Tags {
299 hash := tag.Hash
300 if tag.Tag != nil {
301 hash = tag.Tag.Target.String()
302 }
303 tagMap[hash] = append(tagMap[hash], tag.Name)
304 }
305
306 user := rp.oauth.GetUser(r)
307
308 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true)
309 if err != nil {
310 log.Println("failed to fetch email to did mapping", err)
311 }
312
313 vc, err := verifiedObjectCommits(rp, emailToDidMap, repolog.Commits)
314 if err != nil {
315 log.Println(err)
316 }
317
318 rp.pages.RepoLog(w, pages.RepoLogParams{
319 LoggedInUser: user,
320 TagMap: tagMap,
321 RepoInfo: f.RepoInfo(user),
322 RepoLogResponse: *repolog,
323 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
324 VerifiedCommits: vc,
325 })
326 return
327}
328
329func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
330 f, err := rp.repoResolver.Resolve(r)
331 if err != nil {
332 log.Println("failed to get repo and knot", err)
333 w.WriteHeader(http.StatusBadRequest)
334 return
335 }
336
337 user := rp.oauth.GetUser(r)
338 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
339 RepoInfo: f.RepoInfo(user),
340 })
341 return
342}
343
344func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
345 f, err := rp.repoResolver.Resolve(r)
346 if err != nil {
347 log.Println("failed to get repo and knot", err)
348 w.WriteHeader(http.StatusBadRequest)
349 return
350 }
351
352 repoAt := f.RepoAt
353 rkey := repoAt.RecordKey().String()
354 if rkey == "" {
355 log.Println("invalid aturi for repo", err)
356 w.WriteHeader(http.StatusInternalServerError)
357 return
358 }
359
360 user := rp.oauth.GetUser(r)
361
362 switch r.Method {
363 case http.MethodGet:
364 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
365 RepoInfo: f.RepoInfo(user),
366 })
367 return
368 case http.MethodPut:
369 user := rp.oauth.GetUser(r)
370 newDescription := r.FormValue("description")
371 client, err := rp.oauth.AuthorizedClient(r)
372 if err != nil {
373 log.Println("failed to get client")
374 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
375 return
376 }
377
378 // optimistic update
379 err = db.UpdateDescription(rp.db, string(repoAt), newDescription)
380 if err != nil {
381 log.Println("failed to perferom update-description query", err)
382 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
383 return
384 }
385
386 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
387 //
388 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
389 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
390 if err != nil {
391 // failed to get record
392 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
393 return
394 }
395 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
396 Collection: tangled.RepoNSID,
397 Repo: user.Did,
398 Rkey: rkey,
399 SwapRecord: ex.Cid,
400 Record: &lexutil.LexiconTypeDecoder{
401 Val: &tangled.Repo{
402 Knot: f.Knot,
403 Name: f.RepoName,
404 Owner: user.Did,
405 CreatedAt: f.CreatedAt,
406 Description: &newDescription,
407 },
408 },
409 })
410
411 if err != nil {
412 log.Println("failed to perferom update-description query", err)
413 // failed to get record
414 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
415 return
416 }
417
418 newRepoInfo := f.RepoInfo(user)
419 newRepoInfo.Description = newDescription
420
421 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
422 RepoInfo: newRepoInfo,
423 })
424 return
425 }
426}
427
428func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
429 f, err := rp.repoResolver.Resolve(r)
430 if err != nil {
431 log.Println("failed to fully resolve repo", err)
432 return
433 }
434 ref := chi.URLParam(r, "ref")
435 protocol := "http"
436 if !rp.config.Core.Dev {
437 protocol = "https"
438 }
439
440 if !plumbing.IsHash(ref) {
441 rp.pages.Error404(w)
442 return
443 }
444
445 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
446 if err != nil {
447 log.Println("failed to reach knotserver", err)
448 return
449 }
450
451 body, err := io.ReadAll(resp.Body)
452 if err != nil {
453 log.Printf("Error reading response body: %v", err)
454 return
455 }
456
457 var result types.RepoCommitResponse
458 err = json.Unmarshal(body, &result)
459 if err != nil {
460 log.Println("failed to parse response:", err)
461 return
462 }
463
464 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Author.Email}, true)
465 if err != nil {
466 log.Println("failed to get email to did mapping:", err)
467 }
468
469 vc, err := verifiedCommits(rp, emailToDidMap, []types.NiceDiff{*result.Diff})
470 if err != nil {
471 log.Println(err)
472 }
473
474 user := rp.oauth.GetUser(r)
475 rp.pages.RepoCommit(w, pages.RepoCommitParams{
476 LoggedInUser: user,
477 RepoInfo: f.RepoInfo(user),
478 RepoCommitResponse: result,
479 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
480 Verified: vc[result.Diff.Commit.This],
481 })
482 return
483}
484
485func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
486 f, err := rp.repoResolver.Resolve(r)
487 if err != nil {
488 log.Println("failed to fully resolve repo", err)
489 return
490 }
491
492 ref := chi.URLParam(r, "ref")
493 treePath := chi.URLParam(r, "*")
494 protocol := "http"
495 if !rp.config.Core.Dev {
496 protocol = "https"
497 }
498 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
499 if err != nil {
500 log.Println("failed to reach knotserver", err)
501 return
502 }
503
504 body, err := io.ReadAll(resp.Body)
505 if err != nil {
506 log.Printf("Error reading response body: %v", err)
507 return
508 }
509
510 var result types.RepoTreeResponse
511 err = json.Unmarshal(body, &result)
512 if err != nil {
513 log.Println("failed to parse response:", err)
514 return
515 }
516
517 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
518 // so we can safely redirect to the "parent" (which is the same file).
519 if len(result.Files) == 0 && result.Parent == treePath {
520 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
521 return
522 }
523
524 user := rp.oauth.GetUser(r)
525
526 var breadcrumbs [][]string
527 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
528 if treePath != "" {
529 for idx, elem := range strings.Split(treePath, "/") {
530 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
531 }
532 }
533
534 baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
535 baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
536
537 rp.pages.RepoTree(w, pages.RepoTreeParams{
538 LoggedInUser: user,
539 BreadCrumbs: breadcrumbs,
540 BaseTreeLink: baseTreeLink,
541 BaseBlobLink: baseBlobLink,
542 RepoInfo: f.RepoInfo(user),
543 RepoTreeResponse: result,
544 })
545 return
546}
547
548func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
549 f, err := rp.repoResolver.Resolve(r)
550 if err != nil {
551 log.Println("failed to get repo and knot", err)
552 return
553 }
554
555 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
556 if err != nil {
557 log.Println("failed to create unsigned client", err)
558 return
559 }
560
561 result, err := us.Tags(f.OwnerDid(), f.RepoName)
562 if err != nil {
563 log.Println("failed to reach knotserver", err)
564 return
565 }
566
567 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt))
568 if err != nil {
569 log.Println("failed grab artifacts", err)
570 return
571 }
572
573 // convert artifacts to map for easy UI building
574 artifactMap := make(map[plumbing.Hash][]db.Artifact)
575 for _, a := range artifacts {
576 artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
577 }
578
579 var danglingArtifacts []db.Artifact
580 for _, a := range artifacts {
581 found := false
582 for _, t := range result.Tags {
583 if t.Tag != nil {
584 if t.Tag.Hash == a.Tag {
585 found = true
586 }
587 }
588 }
589
590 if !found {
591 danglingArtifacts = append(danglingArtifacts, a)
592 }
593 }
594
595 user := rp.oauth.GetUser(r)
596 rp.pages.RepoTags(w, pages.RepoTagsParams{
597 LoggedInUser: user,
598 RepoInfo: f.RepoInfo(user),
599 RepoTagsResponse: *result,
600 ArtifactMap: artifactMap,
601 DanglingArtifacts: danglingArtifacts,
602 })
603 return
604}
605
606func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
607 f, err := rp.repoResolver.Resolve(r)
608 if err != nil {
609 log.Println("failed to get repo and knot", err)
610 return
611 }
612
613 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
614 if err != nil {
615 log.Println("failed to create unsigned client", err)
616 return
617 }
618
619 result, err := us.Branches(f.OwnerDid(), f.RepoName)
620 if err != nil {
621 log.Println("failed to reach knotserver", err)
622 return
623 }
624
625 slices.SortFunc(result.Branches, func(a, b types.Branch) int {
626 if a.IsDefault {
627 return -1
628 }
629 if b.IsDefault {
630 return 1
631 }
632 if a.Commit != nil {
633 if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
634 return 1
635 } else {
636 return -1
637 }
638 }
639 return strings.Compare(a.Name, b.Name) * -1
640 })
641
642 user := rp.oauth.GetUser(r)
643 rp.pages.RepoBranches(w, pages.RepoBranchesParams{
644 LoggedInUser: user,
645 RepoInfo: f.RepoInfo(user),
646 RepoBranchesResponse: *result,
647 })
648 return
649}
650
651func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
652 f, err := rp.repoResolver.Resolve(r)
653 if err != nil {
654 log.Println("failed to get repo and knot", err)
655 return
656 }
657
658 ref := chi.URLParam(r, "ref")
659 filePath := chi.URLParam(r, "*")
660 protocol := "http"
661 if !rp.config.Core.Dev {
662 protocol = "https"
663 }
664 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
665 if err != nil {
666 log.Println("failed to reach knotserver", err)
667 return
668 }
669
670 body, err := io.ReadAll(resp.Body)
671 if err != nil {
672 log.Printf("Error reading response body: %v", err)
673 return
674 }
675
676 var result types.RepoBlobResponse
677 err = json.Unmarshal(body, &result)
678 if err != nil {
679 log.Println("failed to parse response:", err)
680 return
681 }
682
683 var breadcrumbs [][]string
684 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
685 if filePath != "" {
686 for idx, elem := range strings.Split(filePath, "/") {
687 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
688 }
689 }
690
691 showRendered := false
692 renderToggle := false
693
694 if markup.GetFormat(result.Path) == markup.FormatMarkdown {
695 renderToggle = true
696 showRendered = r.URL.Query().Get("code") != "true"
697 }
698
699 user := rp.oauth.GetUser(r)
700 rp.pages.RepoBlob(w, pages.RepoBlobParams{
701 LoggedInUser: user,
702 RepoInfo: f.RepoInfo(user),
703 RepoBlobResponse: result,
704 BreadCrumbs: breadcrumbs,
705 ShowRendered: showRendered,
706 RenderToggle: renderToggle,
707 })
708 return
709}
710
711func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
712 f, err := rp.repoResolver.Resolve(r)
713 if err != nil {
714 log.Println("failed to get repo and knot", err)
715 return
716 }
717
718 ref := chi.URLParam(r, "ref")
719 filePath := chi.URLParam(r, "*")
720
721 protocol := "http"
722 if !rp.config.Core.Dev {
723 protocol = "https"
724 }
725 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
726 if err != nil {
727 log.Println("failed to reach knotserver", err)
728 return
729 }
730
731 body, err := io.ReadAll(resp.Body)
732 if err != nil {
733 log.Printf("Error reading response body: %v", err)
734 return
735 }
736
737 var result types.RepoBlobResponse
738 err = json.Unmarshal(body, &result)
739 if err != nil {
740 log.Println("failed to parse response:", err)
741 return
742 }
743
744 if result.IsBinary {
745 w.Header().Set("Content-Type", "application/octet-stream")
746 w.Write(body)
747 return
748 }
749
750 w.Header().Set("Content-Type", "text/plain")
751 w.Write([]byte(result.Contents))
752 return
753}
754
755func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
756 f, err := rp.repoResolver.Resolve(r)
757 if err != nil {
758 log.Println("failed to get repo and knot", err)
759 return
760 }
761
762 collaborator := r.FormValue("collaborator")
763 if collaborator == "" {
764 http.Error(w, "malformed form", http.StatusBadRequest)
765 return
766 }
767
768 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
769 if err != nil {
770 w.Write([]byte("failed to resolve collaborator did to a handle"))
771 return
772 }
773 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
774
775 // TODO: create an atproto record for this
776
777 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
778 if err != nil {
779 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
780 return
781 }
782
783 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
784 if err != nil {
785 log.Println("failed to create client to ", f.Knot)
786 return
787 }
788
789 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
790 if err != nil {
791 log.Printf("failed to make request to %s: %s", f.Knot, err)
792 return
793 }
794
795 if ksResp.StatusCode != http.StatusNoContent {
796 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err)))
797 return
798 }
799
800 tx, err := rp.db.BeginTx(r.Context(), nil)
801 if err != nil {
802 log.Println("failed to start tx")
803 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
804 return
805 }
806 defer func() {
807 tx.Rollback()
808 err = rp.enforcer.E.LoadPolicy()
809 if err != nil {
810 log.Println("failed to rollback policies")
811 }
812 }()
813
814 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
815 if err != nil {
816 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
817 return
818 }
819
820 err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
821 if err != nil {
822 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
823 return
824 }
825
826 err = tx.Commit()
827 if err != nil {
828 log.Println("failed to commit changes", err)
829 http.Error(w, err.Error(), http.StatusInternalServerError)
830 return
831 }
832
833 err = rp.enforcer.E.SavePolicy()
834 if err != nil {
835 log.Println("failed to update ACLs", err)
836 http.Error(w, err.Error(), http.StatusInternalServerError)
837 return
838 }
839
840 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String())))
841
842}
843
844func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
845 user := rp.oauth.GetUser(r)
846
847 f, err := rp.repoResolver.Resolve(r)
848 if err != nil {
849 log.Println("failed to get repo and knot", err)
850 return
851 }
852
853 // remove record from pds
854 xrpcClient, err := rp.oauth.AuthorizedClient(r)
855 if err != nil {
856 log.Println("failed to get authorized client", err)
857 return
858 }
859 repoRkey := f.RepoAt.RecordKey().String()
860 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
861 Collection: tangled.RepoNSID,
862 Repo: user.Did,
863 Rkey: repoRkey,
864 })
865 if err != nil {
866 log.Printf("failed to delete record: %s", err)
867 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
868 return
869 }
870 log.Println("removed repo record ", f.RepoAt.String())
871
872 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
873 if err != nil {
874 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
875 return
876 }
877
878 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
879 if err != nil {
880 log.Println("failed to create client to ", f.Knot)
881 return
882 }
883
884 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
885 if err != nil {
886 log.Printf("failed to make request to %s: %s", f.Knot, err)
887 return
888 }
889
890 if ksResp.StatusCode != http.StatusNoContent {
891 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
892 } else {
893 log.Println("removed repo from knot ", f.Knot)
894 }
895
896 tx, err := rp.db.BeginTx(r.Context(), nil)
897 if err != nil {
898 log.Println("failed to start tx")
899 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
900 return
901 }
902 defer func() {
903 tx.Rollback()
904 err = rp.enforcer.E.LoadPolicy()
905 if err != nil {
906 log.Println("failed to rollback policies")
907 }
908 }()
909
910 // remove collaborator RBAC
911 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
912 if err != nil {
913 rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
914 return
915 }
916 for _, c := range repoCollaborators {
917 did := c[0]
918 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
919 }
920 log.Println("removed collaborators")
921
922 // remove repo RBAC
923 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
924 if err != nil {
925 rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
926 return
927 }
928
929 // remove repo from db
930 err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName)
931 if err != nil {
932 rp.pages.Notice(w, "settings-delete", "Failed to update appview")
933 return
934 }
935 log.Println("removed repo from db")
936
937 err = tx.Commit()
938 if err != nil {
939 log.Println("failed to commit changes", err)
940 http.Error(w, err.Error(), http.StatusInternalServerError)
941 return
942 }
943
944 err = rp.enforcer.E.SavePolicy()
945 if err != nil {
946 log.Println("failed to update ACLs", err)
947 http.Error(w, err.Error(), http.StatusInternalServerError)
948 return
949 }
950
951 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
952}
953
954func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
955 f, err := rp.repoResolver.Resolve(r)
956 if err != nil {
957 log.Println("failed to get repo and knot", err)
958 return
959 }
960
961 branch := r.FormValue("branch")
962 if branch == "" {
963 http.Error(w, "malformed form", http.StatusBadRequest)
964 return
965 }
966
967 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
968 if err != nil {
969 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
970 return
971 }
972
973 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
974 if err != nil {
975 log.Println("failed to create client to ", f.Knot)
976 return
977 }
978
979 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
980 if err != nil {
981 log.Printf("failed to make request to %s: %s", f.Knot, err)
982 return
983 }
984
985 if ksResp.StatusCode != http.StatusNoContent {
986 rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
987 return
988 }
989
990 w.Write([]byte(fmt.Sprint("default branch set to: ", branch)))
991}
992
993func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
994 f, err := rp.repoResolver.Resolve(r)
995 if err != nil {
996 log.Println("failed to get repo and knot", err)
997 return
998 }
999
1000 switch r.Method {
1001 case http.MethodGet:
1002 // for now, this is just pubkeys
1003 user := rp.oauth.GetUser(r)
1004 repoCollaborators, err := f.Collaborators(r.Context())
1005 if err != nil {
1006 log.Println("failed to get collaborators", err)
1007 }
1008
1009 isCollaboratorInviteAllowed := false
1010 if user != nil {
1011 ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
1012 if err == nil && ok {
1013 isCollaboratorInviteAllowed = true
1014 }
1015 }
1016
1017 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1018 if err != nil {
1019 log.Println("failed to create unsigned client", err)
1020 return
1021 }
1022
1023 result, err := us.Branches(f.OwnerDid(), f.RepoName)
1024 if err != nil {
1025 log.Println("failed to reach knotserver", err)
1026 return
1027 }
1028
1029 rp.pages.RepoSettings(w, pages.RepoSettingsParams{
1030 LoggedInUser: user,
1031 RepoInfo: f.RepoInfo(user),
1032 Collaborators: repoCollaborators,
1033 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1034 Branches: result.Branches,
1035 })
1036 }
1037}
1038
1039func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1040 user := rp.oauth.GetUser(r)
1041 f, err := rp.repoResolver.Resolve(r)
1042 if err != nil {
1043 log.Printf("failed to resolve source repo: %v", err)
1044 return
1045 }
1046
1047 switch r.Method {
1048 case http.MethodPost:
1049 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1050 if err != nil {
1051 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %rp.", f.Knot))
1052 return
1053 }
1054
1055 client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1056 if err != nil {
1057 rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1058 return
1059 }
1060
1061 var uri string
1062 if rp.config.Core.Dev {
1063 uri = "http"
1064 } else {
1065 uri = "https"
1066 }
1067 forkName := fmt.Sprintf("%s", f.RepoName)
1068 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1069
1070 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref)
1071 if err != nil {
1072 rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
1073 return
1074 }
1075
1076 rp.pages.HxRefresh(w)
1077 return
1078 }
1079}
1080
1081func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
1082 user := rp.oauth.GetUser(r)
1083 f, err := rp.repoResolver.Resolve(r)
1084 if err != nil {
1085 log.Printf("failed to resolve source repo: %v", err)
1086 return
1087 }
1088
1089 switch r.Method {
1090 case http.MethodGet:
1091 user := rp.oauth.GetUser(r)
1092 knots, err := rp.enforcer.GetDomainsForUser(user.Did)
1093 if err != nil {
1094 rp.pages.Notice(w, "repo", "Invalid user account.")
1095 return
1096 }
1097
1098 rp.pages.ForkRepo(w, pages.ForkRepoParams{
1099 LoggedInUser: user,
1100 Knots: knots,
1101 RepoInfo: f.RepoInfo(user),
1102 })
1103
1104 case http.MethodPost:
1105
1106 knot := r.FormValue("knot")
1107 if knot == "" {
1108 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1109 return
1110 }
1111
1112 ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1113 if err != nil || !ok {
1114 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1115 return
1116 }
1117
1118 forkName := fmt.Sprintf("%s", f.RepoName)
1119
1120 // this check is *only* to see if the forked repo name already exists
1121 // in the user's account.
1122 existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName)
1123 if err != nil {
1124 if errors.Is(err, sql.ErrNoRows) {
1125 // no existing repo with this name found, we can use the name as is
1126 } else {
1127 log.Println("error fetching existing repo from db", err)
1128 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1129 return
1130 }
1131 } else if existingRepo != nil {
1132 // repo with this name already exists, append random string
1133 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1134 }
1135 secret, err := db.GetRegistrationKey(rp.db, knot)
1136 if err != nil {
1137 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %rp.", knot))
1138 return
1139 }
1140
1141 client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev)
1142 if err != nil {
1143 rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1144 return
1145 }
1146
1147 var uri string
1148 if rp.config.Core.Dev {
1149 uri = "http"
1150 } else {
1151 uri = "https"
1152 }
1153 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1154 sourceAt := f.RepoAt.String()
1155
1156 rkey := appview.TID()
1157 repo := &db.Repo{
1158 Did: user.Did,
1159 Name: forkName,
1160 Knot: knot,
1161 Rkey: rkey,
1162 Source: sourceAt,
1163 }
1164
1165 tx, err := rp.db.BeginTx(r.Context(), nil)
1166 if err != nil {
1167 log.Println(err)
1168 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1169 return
1170 }
1171 defer func() {
1172 tx.Rollback()
1173 err = rp.enforcer.E.LoadPolicy()
1174 if err != nil {
1175 log.Println("failed to rollback policies")
1176 }
1177 }()
1178
1179 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1180 if err != nil {
1181 rp.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1182 return
1183 }
1184
1185 switch resp.StatusCode {
1186 case http.StatusConflict:
1187 rp.pages.Notice(w, "repo", "A repository with that name already exists.")
1188 return
1189 case http.StatusInternalServerError:
1190 rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1191 case http.StatusNoContent:
1192 // continue
1193 }
1194
1195 xrpcClient, err := rp.oauth.AuthorizedClient(r)
1196 if err != nil {
1197 log.Println("failed to get authorized client", err)
1198 rp.pages.Notice(w, "repo", "Failed to create repository.")
1199 return
1200 }
1201
1202 createdAt := time.Now().Format(time.RFC3339)
1203 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1204 Collection: tangled.RepoNSID,
1205 Repo: user.Did,
1206 Rkey: rkey,
1207 Record: &lexutil.LexiconTypeDecoder{
1208 Val: &tangled.Repo{
1209 Knot: repo.Knot,
1210 Name: repo.Name,
1211 CreatedAt: createdAt,
1212 Owner: user.Did,
1213 Source: &sourceAt,
1214 }},
1215 })
1216 if err != nil {
1217 log.Printf("failed to create record: %s", err)
1218 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1219 return
1220 }
1221 log.Println("created repo record: ", atresp.Uri)
1222
1223 repo.AtUri = atresp.Uri
1224 err = db.AddRepo(tx, repo)
1225 if err != nil {
1226 log.Println(err)
1227 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1228 return
1229 }
1230
1231 // acls
1232 p, _ := securejoin.SecureJoin(user.Did, forkName)
1233 err = rp.enforcer.AddRepo(user.Did, knot, p)
1234 if err != nil {
1235 log.Println(err)
1236 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1237 return
1238 }
1239
1240 err = tx.Commit()
1241 if err != nil {
1242 log.Println("failed to commit changes", err)
1243 http.Error(w, err.Error(), http.StatusInternalServerError)
1244 return
1245 }
1246
1247 err = rp.enforcer.E.SavePolicy()
1248 if err != nil {
1249 log.Println("failed to update ACLs", err)
1250 http.Error(w, err.Error(), http.StatusInternalServerError)
1251 return
1252 }
1253
1254 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1255 return
1256 }
1257}
1258
1259func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
1260 user := rp.oauth.GetUser(r)
1261 f, err := rp.repoResolver.Resolve(r)
1262 if err != nil {
1263 log.Println("failed to get repo and knot", err)
1264 return
1265 }
1266
1267 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1268 if err != nil {
1269 log.Printf("failed to create unsigned client for %s", f.Knot)
1270 rp.pages.Error503(w)
1271 return
1272 }
1273
1274 result, err := us.Branches(f.OwnerDid(), f.RepoName)
1275 if err != nil {
1276 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1277 log.Println("failed to reach knotserver", err)
1278 return
1279 }
1280 branches := result.Branches
1281 sort.Slice(branches, func(i int, j int) bool {
1282 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
1283 })
1284
1285 var defaultBranch string
1286 for _, b := range branches {
1287 if b.IsDefault {
1288 defaultBranch = b.Name
1289 }
1290 }
1291
1292 base := defaultBranch
1293 head := defaultBranch
1294
1295 params := r.URL.Query()
1296 queryBase := params.Get("base")
1297 queryHead := params.Get("head")
1298 if queryBase != "" {
1299 base = queryBase
1300 }
1301 if queryHead != "" {
1302 head = queryHead
1303 }
1304
1305 tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1306 if err != nil {
1307 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1308 log.Println("failed to reach knotserver", err)
1309 return
1310 }
1311
1312 repoinfo := f.RepoInfo(user)
1313
1314 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
1315 LoggedInUser: user,
1316 RepoInfo: repoinfo,
1317 Branches: branches,
1318 Tags: tags.Tags,
1319 Base: base,
1320 Head: head,
1321 })
1322}
1323
1324func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
1325 user := rp.oauth.GetUser(r)
1326 f, err := rp.repoResolver.Resolve(r)
1327 if err != nil {
1328 log.Println("failed to get repo and knot", err)
1329 return
1330 }
1331
1332 // if user is navigating to one of
1333 // /compare/{base}/{head}
1334 // /compare/{base}...{head}
1335 base := chi.URLParam(r, "base")
1336 head := chi.URLParam(r, "head")
1337 if base == "" && head == "" {
1338 rest := chi.URLParam(r, "*") // master...feature/xyz
1339 parts := strings.SplitN(rest, "...", 2)
1340 if len(parts) == 2 {
1341 base = parts[0]
1342 head = parts[1]
1343 }
1344 }
1345
1346 base, _ = url.PathUnescape(base)
1347 head, _ = url.PathUnescape(head)
1348
1349 if base == "" || head == "" {
1350 log.Printf("invalid comparison")
1351 rp.pages.Error404(w)
1352 return
1353 }
1354
1355 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1356 if err != nil {
1357 log.Printf("failed to create unsigned client for %s", f.Knot)
1358 rp.pages.Error503(w)
1359 return
1360 }
1361
1362 branches, err := us.Branches(f.OwnerDid(), f.RepoName)
1363 if err != nil {
1364 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1365 log.Println("failed to reach knotserver", err)
1366 return
1367 }
1368
1369 tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1370 if err != nil {
1371 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1372 log.Println("failed to reach knotserver", err)
1373 return
1374 }
1375
1376 formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head)
1377 if err != nil {
1378 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1379 log.Println("failed to compare", err)
1380 return
1381 }
1382 diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
1383
1384 repoinfo := f.RepoInfo(user)
1385
1386 rp.pages.RepoCompare(w, pages.RepoCompareParams{
1387 LoggedInUser: user,
1388 RepoInfo: repoinfo,
1389 Branches: branches.Branches,
1390 Tags: tags.Tags,
1391 Base: base,
1392 Head: head,
1393 Diff: &diff,
1394 })
1395
1396}