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