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