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