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