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