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