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