forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
at knot-cli 36 kB view raw
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}