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