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 "log/slog" 12 "net/http" 13 "net/url" 14 "path/filepath" 15 "slices" 16 "strconv" 17 "strings" 18 "time" 19 20 comatproto "github.com/bluesky-social/indigo/api/atproto" 21 lexutil "github.com/bluesky-social/indigo/lex/util" 22 "tangled.sh/tangled.sh/core/api/tangled" 23 "tangled.sh/tangled.sh/core/appview/commitverify" 24 "tangled.sh/tangled.sh/core/appview/config" 25 "tangled.sh/tangled.sh/core/appview/db" 26 "tangled.sh/tangled.sh/core/appview/notify" 27 "tangled.sh/tangled.sh/core/appview/oauth" 28 "tangled.sh/tangled.sh/core/appview/pages" 29 "tangled.sh/tangled.sh/core/appview/pages/markup" 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 "tangled.sh/tangled.sh/core/eventconsumer" 33 "tangled.sh/tangled.sh/core/idresolver" 34 "tangled.sh/tangled.sh/core/knotclient" 35 "tangled.sh/tangled.sh/core/patchutil" 36 "tangled.sh/tangled.sh/core/rbac" 37 "tangled.sh/tangled.sh/core/tid" 38 "tangled.sh/tangled.sh/core/types" 39 "tangled.sh/tangled.sh/core/xrpc/serviceauth" 40 41 securejoin "github.com/cyphar/filepath-securejoin" 42 "github.com/go-chi/chi/v5" 43 "github.com/go-git/go-git/v5/plumbing" 44 45 "github.com/bluesky-social/indigo/atproto/syntax" 46) 47 48type Repo struct { 49 repoResolver *reporesolver.RepoResolver 50 idResolver *idresolver.Resolver 51 config *config.Config 52 oauth *oauth.OAuth 53 pages *pages.Pages 54 spindlestream *eventconsumer.Consumer 55 db *db.DB 56 enforcer *rbac.Enforcer 57 notifier notify.Notifier 58 logger *slog.Logger 59 serviceAuth *serviceauth.ServiceAuth 60} 61 62func New( 63 oauth *oauth.OAuth, 64 repoResolver *reporesolver.RepoResolver, 65 pages *pages.Pages, 66 spindlestream *eventconsumer.Consumer, 67 idResolver *idresolver.Resolver, 68 db *db.DB, 69 config *config.Config, 70 notifier notify.Notifier, 71 enforcer *rbac.Enforcer, 72 logger *slog.Logger, 73) *Repo { 74 return &Repo{oauth: oauth, 75 repoResolver: repoResolver, 76 pages: pages, 77 idResolver: idResolver, 78 config: config, 79 spindlestream: spindlestream, 80 db: db, 81 notifier: notifier, 82 enforcer: enforcer, 83 logger: logger, 84 } 85} 86 87func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 88 refParam := chi.URLParam(r, "ref") 89 f, err := rp.repoResolver.Resolve(r) 90 if err != nil { 91 log.Println("failed to get repo and knot", err) 92 return 93 } 94 95 var uri string 96 if rp.config.Core.Dev { 97 uri = "http" 98 } else { 99 uri = "https" 100 } 101 url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam)) 102 103 http.Redirect(w, r, url, http.StatusFound) 104} 105 106func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 107 f, err := rp.repoResolver.Resolve(r) 108 if err != nil { 109 log.Println("failed to fully resolve repo", err) 110 return 111 } 112 113 page := 1 114 if r.URL.Query().Get("page") != "" { 115 page, err = strconv.Atoi(r.URL.Query().Get("page")) 116 if err != nil { 117 page = 1 118 } 119 } 120 121 ref := chi.URLParam(r, "ref") 122 123 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 124 if err != nil { 125 log.Println("failed to create unsigned client", err) 126 return 127 } 128 129 repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 130 if err != nil { 131 rp.pages.Error503(w) 132 log.Println("failed to reach knotserver", err) 133 return 134 } 135 136 tagResult, err := us.Tags(f.OwnerDid(), f.Name) 137 if err != nil { 138 rp.pages.Error503(w) 139 log.Println("failed to reach knotserver", err) 140 return 141 } 142 143 tagMap := make(map[string][]string) 144 for _, tag := range tagResult.Tags { 145 hash := tag.Hash 146 if tag.Tag != nil { 147 hash = tag.Tag.Target.String() 148 } 149 tagMap[hash] = append(tagMap[hash], tag.Name) 150 } 151 152 branchResult, err := us.Branches(f.OwnerDid(), f.Name) 153 if err != nil { 154 rp.pages.Error503(w) 155 log.Println("failed to reach knotserver", err) 156 return 157 } 158 159 for _, branch := range branchResult.Branches { 160 hash := branch.Hash 161 tagMap[hash] = append(tagMap[hash], branch.Name) 162 } 163 164 user := rp.oauth.GetUser(r) 165 166 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) 167 if err != nil { 168 log.Println("failed to fetch email to did mapping", err) 169 } 170 171 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits) 172 if err != nil { 173 log.Println(err) 174 } 175 176 repoInfo := f.RepoInfo(user) 177 178 var shas []string 179 for _, c := range repolog.Commits { 180 shas = append(shas, c.Hash.String()) 181 } 182 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 183 if err != nil { 184 log.Println(err) 185 // non-fatal 186 } 187 188 rp.pages.RepoLog(w, pages.RepoLogParams{ 189 LoggedInUser: user, 190 TagMap: tagMap, 191 RepoInfo: repoInfo, 192 RepoLogResponse: *repolog, 193 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 194 VerifiedCommits: vc, 195 Pipelines: pipelines, 196 }) 197} 198 199func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 200 f, err := rp.repoResolver.Resolve(r) 201 if err != nil { 202 log.Println("failed to get repo and knot", err) 203 w.WriteHeader(http.StatusBadRequest) 204 return 205 } 206 207 user := rp.oauth.GetUser(r) 208 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 209 RepoInfo: f.RepoInfo(user), 210 }) 211} 212 213func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 214 f, err := rp.repoResolver.Resolve(r) 215 if err != nil { 216 log.Println("failed to get repo and knot", err) 217 w.WriteHeader(http.StatusBadRequest) 218 return 219 } 220 221 repoAt := f.RepoAt() 222 rkey := repoAt.RecordKey().String() 223 if rkey == "" { 224 log.Println("invalid aturi for repo", err) 225 w.WriteHeader(http.StatusInternalServerError) 226 return 227 } 228 229 user := rp.oauth.GetUser(r) 230 231 switch r.Method { 232 case http.MethodGet: 233 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 234 RepoInfo: f.RepoInfo(user), 235 }) 236 return 237 case http.MethodPut: 238 newDescription := r.FormValue("description") 239 client, err := rp.oauth.AuthorizedClient(r) 240 if err != nil { 241 log.Println("failed to get client") 242 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 243 return 244 } 245 246 // optimistic update 247 err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 248 if err != nil { 249 log.Println("failed to perferom update-description query", err) 250 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 251 return 252 } 253 254 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 255 // 256 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 257 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 258 if err != nil { 259 // failed to get record 260 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 261 return 262 } 263 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 264 Collection: tangled.RepoNSID, 265 Repo: user.Did, 266 Rkey: rkey, 267 SwapRecord: ex.Cid, 268 Record: &lexutil.LexiconTypeDecoder{ 269 Val: &tangled.Repo{ 270 Knot: f.Knot, 271 Name: f.Name, 272 Owner: user.Did, 273 CreatedAt: f.Created.Format(time.RFC3339), 274 Description: &newDescription, 275 Spindle: &f.Spindle, 276 }, 277 }, 278 }) 279 280 if err != nil { 281 log.Println("failed to perferom update-description query", err) 282 // failed to get record 283 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 284 return 285 } 286 287 newRepoInfo := f.RepoInfo(user) 288 newRepoInfo.Description = newDescription 289 290 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 291 RepoInfo: newRepoInfo, 292 }) 293 return 294 } 295} 296 297func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 298 f, err := rp.repoResolver.Resolve(r) 299 if err != nil { 300 log.Println("failed to fully resolve repo", err) 301 return 302 } 303 ref := chi.URLParam(r, "ref") 304 protocol := "http" 305 if !rp.config.Core.Dev { 306 protocol = "https" 307 } 308 309 var diffOpts types.DiffOpts 310 if d := r.URL.Query().Get("diff"); d == "split" { 311 diffOpts.Split = true 312 } 313 314 if !plumbing.IsHash(ref) { 315 rp.pages.Error404(w) 316 return 317 } 318 319 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref)) 320 if err != nil { 321 rp.pages.Error503(w) 322 log.Println("failed to reach knotserver", err) 323 return 324 } 325 326 body, err := io.ReadAll(resp.Body) 327 if err != nil { 328 log.Printf("Error reading response body: %v", err) 329 return 330 } 331 332 var result types.RepoCommitResponse 333 err = json.Unmarshal(body, &result) 334 if err != nil { 335 log.Println("failed to parse response:", err) 336 return 337 } 338 339 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 340 if err != nil { 341 log.Println("failed to get email to did mapping:", err) 342 } 343 344 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 345 if err != nil { 346 log.Println(err) 347 } 348 349 user := rp.oauth.GetUser(r) 350 repoInfo := f.RepoInfo(user) 351 pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 352 if err != nil { 353 log.Println(err) 354 // non-fatal 355 } 356 var pipeline *db.Pipeline 357 if p, ok := pipelines[result.Diff.Commit.This]; ok { 358 pipeline = &p 359 } 360 361 rp.pages.RepoCommit(w, pages.RepoCommitParams{ 362 LoggedInUser: user, 363 RepoInfo: f.RepoInfo(user), 364 RepoCommitResponse: result, 365 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 366 VerifiedCommit: vc, 367 Pipeline: pipeline, 368 DiffOpts: diffOpts, 369 }) 370} 371 372func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 373 f, err := rp.repoResolver.Resolve(r) 374 if err != nil { 375 log.Println("failed to fully resolve repo", err) 376 return 377 } 378 379 ref := chi.URLParam(r, "ref") 380 treePath := chi.URLParam(r, "*") 381 protocol := "http" 382 if !rp.config.Core.Dev { 383 protocol = "https" 384 } 385 386 // if the tree path has a trailing slash, let's strip it 387 // so we don't 404 388 treePath = strings.TrimSuffix(treePath, "/") 389 390 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 391 if err != nil { 392 rp.pages.Error503(w) 393 log.Println("failed to reach knotserver", err) 394 return 395 } 396 397 // uhhh so knotserver returns a 500 if the entry isn't found in 398 // the requested tree path, so let's stick to not-OK here. 399 // we can fix this once we build out the xrpc apis for these operations. 400 if resp.StatusCode != http.StatusOK { 401 rp.pages.Error404(w) 402 return 403 } 404 405 body, err := io.ReadAll(resp.Body) 406 if err != nil { 407 log.Printf("Error reading response body: %v", err) 408 return 409 } 410 411 var result types.RepoTreeResponse 412 err = json.Unmarshal(body, &result) 413 if err != nil { 414 log.Println("failed to parse response:", err) 415 return 416 } 417 418 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 419 // so we can safely redirect to the "parent" (which is the same file). 420 unescapedTreePath, _ := url.PathUnescape(treePath) 421 if len(result.Files) == 0 && result.Parent == unescapedTreePath { 422 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 423 return 424 } 425 426 user := rp.oauth.GetUser(r) 427 428 var breadcrumbs [][]string 429 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 430 if treePath != "" { 431 for idx, elem := range strings.Split(treePath, "/") { 432 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 433 } 434 } 435 436 sortFiles(result.Files) 437 438 rp.pages.RepoTree(w, pages.RepoTreeParams{ 439 LoggedInUser: user, 440 BreadCrumbs: breadcrumbs, 441 TreePath: treePath, 442 RepoInfo: f.RepoInfo(user), 443 RepoTreeResponse: result, 444 }) 445} 446 447func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 448 f, err := rp.repoResolver.Resolve(r) 449 if err != nil { 450 log.Println("failed to get repo and knot", err) 451 return 452 } 453 454 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 455 if err != nil { 456 log.Println("failed to create unsigned client", err) 457 return 458 } 459 460 result, err := us.Tags(f.OwnerDid(), f.Name) 461 if err != nil { 462 rp.pages.Error503(w) 463 log.Println("failed to reach knotserver", err) 464 return 465 } 466 467 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 468 if err != nil { 469 log.Println("failed grab artifacts", err) 470 return 471 } 472 473 // convert artifacts to map for easy UI building 474 artifactMap := make(map[plumbing.Hash][]db.Artifact) 475 for _, a := range artifacts { 476 artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 477 } 478 479 var danglingArtifacts []db.Artifact 480 for _, a := range artifacts { 481 found := false 482 for _, t := range result.Tags { 483 if t.Tag != nil { 484 if t.Tag.Hash == a.Tag { 485 found = true 486 } 487 } 488 } 489 490 if !found { 491 danglingArtifacts = append(danglingArtifacts, a) 492 } 493 } 494 495 user := rp.oauth.GetUser(r) 496 rp.pages.RepoTags(w, pages.RepoTagsParams{ 497 LoggedInUser: user, 498 RepoInfo: f.RepoInfo(user), 499 RepoTagsResponse: *result, 500 ArtifactMap: artifactMap, 501 DanglingArtifacts: danglingArtifacts, 502 }) 503} 504 505func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 506 f, err := rp.repoResolver.Resolve(r) 507 if err != nil { 508 log.Println("failed to get repo and knot", err) 509 return 510 } 511 512 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 513 if err != nil { 514 log.Println("failed to create unsigned client", err) 515 return 516 } 517 518 result, err := us.Branches(f.OwnerDid(), f.Name) 519 if err != nil { 520 rp.pages.Error503(w) 521 log.Println("failed to reach knotserver", err) 522 return 523 } 524 525 sortBranches(result.Branches) 526 527 user := rp.oauth.GetUser(r) 528 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 529 LoggedInUser: user, 530 RepoInfo: f.RepoInfo(user), 531 RepoBranchesResponse: *result, 532 }) 533} 534 535func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 536 f, err := rp.repoResolver.Resolve(r) 537 if err != nil { 538 log.Println("failed to get repo and knot", err) 539 return 540 } 541 542 ref := chi.URLParam(r, "ref") 543 filePath := chi.URLParam(r, "*") 544 protocol := "http" 545 if !rp.config.Core.Dev { 546 protocol = "https" 547 } 548 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)) 549 if err != nil { 550 rp.pages.Error503(w) 551 log.Println("failed to reach knotserver", err) 552 return 553 } 554 555 if resp.StatusCode == http.StatusNotFound { 556 rp.pages.Error404(w) 557 return 558 } 559 560 body, err := io.ReadAll(resp.Body) 561 if err != nil { 562 log.Printf("Error reading response body: %v", err) 563 return 564 } 565 566 var result types.RepoBlobResponse 567 err = json.Unmarshal(body, &result) 568 if err != nil { 569 log.Println("failed to parse response:", err) 570 return 571 } 572 573 var breadcrumbs [][]string 574 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 575 if filePath != "" { 576 for idx, elem := range strings.Split(filePath, "/") { 577 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 578 } 579 } 580 581 showRendered := false 582 renderToggle := false 583 584 if markup.GetFormat(result.Path) == markup.FormatMarkdown { 585 renderToggle = true 586 showRendered = r.URL.Query().Get("code") != "true" 587 } 588 589 var unsupported bool 590 var isImage bool 591 var isVideo bool 592 var contentSrc string 593 594 if result.IsBinary { 595 ext := strings.ToLower(filepath.Ext(result.Path)) 596 switch ext { 597 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 598 isImage = true 599 case ".mp4", ".webm", ".ogg", ".mov", ".avi": 600 isVideo = true 601 default: 602 unsupported = true 603 } 604 605 // fetch the actual binary content like in RepoBlobRaw 606 607 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath) 608 contentSrc = blobURL 609 if !rp.config.Core.Dev { 610 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 611 } 612 } 613 614 user := rp.oauth.GetUser(r) 615 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 616 LoggedInUser: user, 617 RepoInfo: f.RepoInfo(user), 618 RepoBlobResponse: result, 619 BreadCrumbs: breadcrumbs, 620 ShowRendered: showRendered, 621 RenderToggle: renderToggle, 622 Unsupported: unsupported, 623 IsImage: isImage, 624 IsVideo: isVideo, 625 ContentSrc: contentSrc, 626 }) 627} 628 629func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 630 f, err := rp.repoResolver.Resolve(r) 631 if err != nil { 632 log.Println("failed to get repo and knot", err) 633 w.WriteHeader(http.StatusBadRequest) 634 return 635 } 636 637 ref := chi.URLParam(r, "ref") 638 filePath := chi.URLParam(r, "*") 639 640 protocol := "http" 641 if !rp.config.Core.Dev { 642 protocol = "https" 643 } 644 645 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 646 647 req, err := http.NewRequest("GET", blobURL, nil) 648 if err != nil { 649 log.Println("failed to create request", err) 650 return 651 } 652 653 // forward the If-None-Match header 654 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 655 req.Header.Set("If-None-Match", clientETag) 656 } 657 658 client := &http.Client{} 659 resp, err := client.Do(req) 660 if err != nil { 661 log.Println("failed to reach knotserver", err) 662 rp.pages.Error503(w) 663 return 664 } 665 defer resp.Body.Close() 666 667 // forward 304 not modified 668 if resp.StatusCode == http.StatusNotModified { 669 w.WriteHeader(http.StatusNotModified) 670 return 671 } 672 673 if resp.StatusCode != http.StatusOK { 674 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 675 w.WriteHeader(resp.StatusCode) 676 _, _ = io.Copy(w, resp.Body) 677 return 678 } 679 680 contentType := resp.Header.Get("Content-Type") 681 body, err := io.ReadAll(resp.Body) 682 if err != nil { 683 log.Printf("error reading response body from knotserver: %v", err) 684 w.WriteHeader(http.StatusInternalServerError) 685 return 686 } 687 688 if strings.Contains(contentType, "text/plain") { 689 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 690 w.Write(body) 691 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 692 w.Header().Set("Content-Type", contentType) 693 w.Write(body) 694 } else { 695 w.WriteHeader(http.StatusUnsupportedMediaType) 696 w.Write([]byte("unsupported content type")) 697 return 698 } 699} 700 701// modify the spindle configured for this repo 702func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 703 user := rp.oauth.GetUser(r) 704 l := rp.logger.With("handler", "EditSpindle") 705 l = l.With("did", user.Did) 706 l = l.With("handle", user.Handle) 707 708 errorId := "operation-error" 709 fail := func(msg string, err error) { 710 l.Error(msg, "err", err) 711 rp.pages.Notice(w, errorId, msg) 712 } 713 714 f, err := rp.repoResolver.Resolve(r) 715 if err != nil { 716 fail("Failed to resolve repo. Try again later", err) 717 return 718 } 719 720 repoAt := f.RepoAt() 721 rkey := repoAt.RecordKey().String() 722 if rkey == "" { 723 fail("Failed to resolve repo. Try again later", err) 724 return 725 } 726 727 newSpindle := r.FormValue("spindle") 728 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 729 client, err := rp.oauth.AuthorizedClient(r) 730 if err != nil { 731 fail("Failed to authorize. Try again later.", err) 732 return 733 } 734 735 if !removingSpindle { 736 // ensure that this is a valid spindle for this user 737 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 738 if err != nil { 739 fail("Failed to find spindles. Try again later.", err) 740 return 741 } 742 743 if !slices.Contains(validSpindles, newSpindle) { 744 fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 745 return 746 } 747 } 748 749 spindlePtr := &newSpindle 750 if removingSpindle { 751 spindlePtr = nil 752 } 753 754 // optimistic update 755 err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 756 if err != nil { 757 fail("Failed to update spindle. Try again later.", err) 758 return 759 } 760 761 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 762 if err != nil { 763 fail("Failed to update spindle, no record found on PDS.", err) 764 return 765 } 766 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 767 Collection: tangled.RepoNSID, 768 Repo: user.Did, 769 Rkey: rkey, 770 SwapRecord: ex.Cid, 771 Record: &lexutil.LexiconTypeDecoder{ 772 Val: &tangled.Repo{ 773 Knot: f.Knot, 774 Name: f.Name, 775 Owner: user.Did, 776 CreatedAt: f.Created.Format(time.RFC3339), 777 Description: &f.Description, 778 Spindle: spindlePtr, 779 }, 780 }, 781 }) 782 783 if err != nil { 784 fail("Failed to update spindle, unable to save to PDS.", err) 785 return 786 } 787 788 if !removingSpindle { 789 // add this spindle to spindle stream 790 rp.spindlestream.AddSource( 791 context.Background(), 792 eventconsumer.NewSpindleSource(newSpindle), 793 ) 794 } 795 796 rp.pages.HxRefresh(w) 797} 798 799func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 800 user := rp.oauth.GetUser(r) 801 l := rp.logger.With("handler", "AddCollaborator") 802 l = l.With("did", user.Did) 803 l = l.With("handle", user.Handle) 804 805 f, err := rp.repoResolver.Resolve(r) 806 if err != nil { 807 l.Error("failed to get repo and knot", "err", err) 808 return 809 } 810 811 errorId := "add-collaborator-error" 812 fail := func(msg string, err error) { 813 l.Error(msg, "err", err) 814 rp.pages.Notice(w, errorId, msg) 815 } 816 817 collaborator := r.FormValue("collaborator") 818 if collaborator == "" { 819 fail("Invalid form.", nil) 820 return 821 } 822 823 // remove a single leading `@`, to make @handle work with ResolveIdent 824 collaborator = strings.TrimPrefix(collaborator, "@") 825 826 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 827 if err != nil { 828 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) 829 return 830 } 831 832 if collaboratorIdent.DID.String() == user.Did { 833 fail("You seem to be adding yourself as a collaborator.", nil) 834 return 835 } 836 l = l.With("collaborator", collaboratorIdent.Handle) 837 l = l.With("knot", f.Knot) 838 839 // announce this relation into the firehose, store into owners' pds 840 client, err := rp.oauth.AuthorizedClient(r) 841 if err != nil { 842 fail("Failed to write to PDS.", err) 843 return 844 } 845 846 // emit a record 847 currentUser := rp.oauth.GetUser(r) 848 rkey := tid.TID() 849 createdAt := time.Now() 850 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 851 Collection: tangled.RepoCollaboratorNSID, 852 Repo: currentUser.Did, 853 Rkey: rkey, 854 Record: &lexutil.LexiconTypeDecoder{ 855 Val: &tangled.RepoCollaborator{ 856 Subject: collaboratorIdent.DID.String(), 857 Repo: string(f.RepoAt()), 858 CreatedAt: createdAt.Format(time.RFC3339), 859 }}, 860 }) 861 // invalid record 862 if err != nil { 863 fail("Failed to write record to PDS.", err) 864 return 865 } 866 l = l.With("at-uri", resp.Uri) 867 l.Info("wrote record to PDS") 868 869 l.Info("adding to knot") 870 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 871 if err != nil { 872 fail("Failed to add to knot.", err) 873 return 874 } 875 876 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 877 if err != nil { 878 fail("Failed to add to knot.", err) 879 return 880 } 881 882 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String()) 883 if err != nil { 884 fail("Knot was unreachable.", err) 885 return 886 } 887 888 if ksResp.StatusCode != http.StatusNoContent { 889 fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 890 return 891 } 892 893 tx, err := rp.db.BeginTx(r.Context(), nil) 894 if err != nil { 895 fail("Failed to add collaborator.", err) 896 return 897 } 898 defer func() { 899 tx.Rollback() 900 err = rp.enforcer.E.LoadPolicy() 901 if err != nil { 902 fail("Failed to add collaborator.", err) 903 } 904 }() 905 906 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 907 if err != nil { 908 fail("Failed to add collaborator permissions.", err) 909 return 910 } 911 912 err = db.AddCollaborator(rp.db, db.Collaborator{ 913 Did: syntax.DID(currentUser.Did), 914 Rkey: rkey, 915 SubjectDid: collaboratorIdent.DID, 916 RepoAt: f.RepoAt(), 917 Created: createdAt, 918 }) 919 if err != nil { 920 fail("Failed to add collaborator.", err) 921 return 922 } 923 924 err = tx.Commit() 925 if err != nil { 926 fail("Failed to add collaborator.", err) 927 return 928 } 929 930 err = rp.enforcer.E.SavePolicy() 931 if err != nil { 932 fail("Failed to update collaborator permissions.", err) 933 return 934 } 935 936 rp.pages.HxRefresh(w) 937} 938 939func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 940 user := rp.oauth.GetUser(r) 941 942 f, err := rp.repoResolver.Resolve(r) 943 if err != nil { 944 log.Println("failed to get repo and knot", err) 945 return 946 } 947 948 // remove record from pds 949 xrpcClient, err := rp.oauth.AuthorizedClient(r) 950 if err != nil { 951 log.Println("failed to get authorized client", err) 952 return 953 } 954 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 955 Collection: tangled.RepoNSID, 956 Repo: user.Did, 957 Rkey: f.Rkey, 958 }) 959 if err != nil { 960 log.Printf("failed to delete record: %s", err) 961 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 962 return 963 } 964 log.Println("removed repo record ", f.RepoAt().String()) 965 966 client, err := rp.oauth.ServiceClient( 967 r, 968 oauth.WithService(f.Knot), 969 oauth.WithLxm(tangled.RepoDeleteNSID), 970 oauth.WithDev(rp.config.Core.Dev), 971 ) 972 if err != nil { 973 log.Println("failed to connect to knot server:", err) 974 return 975 } 976 977 err = tangled.RepoDelete( 978 r.Context(), 979 client, 980 &tangled.RepoDelete_Input{ 981 Did: f.OwnerDid(), 982 Name: f.Name, 983 }, 984 ) 985 if err != nil { 986 xe, parseErr := xrpcerr.Unmarshal(err.Error()) 987 if parseErr != nil { 988 log.Printf("failed to delete repo from knot %s: %s", f.Knot, err) 989 } else { 990 log.Printf("failed to delete repo from knot %s: %s", f.Knot, xe.Error()) 991 } 992 // Continue anyway since we want to clean up local state 993 } else { 994 log.Println("removed repo from knot ", f.Knot) 995 } 996 997 tx, err := rp.db.BeginTx(r.Context(), nil) 998 if err != nil { 999 log.Println("failed to start tx") 1000 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1001 return 1002 } 1003 defer func() { 1004 tx.Rollback() 1005 err = rp.enforcer.E.LoadPolicy() 1006 if err != nil { 1007 log.Println("failed to rollback policies") 1008 } 1009 }() 1010 1011 // remove collaborator RBAC 1012 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 1013 if err != nil { 1014 rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 1015 return 1016 } 1017 for _, c := range repoCollaborators { 1018 did := c[0] 1019 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 1020 } 1021 log.Println("removed collaborators") 1022 1023 // remove repo RBAC 1024 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 1025 if err != nil { 1026 rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 1027 return 1028 } 1029 1030 // remove repo from db 1031 err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 1032 if err != nil { 1033 rp.pages.Notice(w, "settings-delete", "Failed to update appview") 1034 return 1035 } 1036 log.Println("removed repo from db") 1037 1038 err = tx.Commit() 1039 if err != nil { 1040 log.Println("failed to commit changes", err) 1041 http.Error(w, err.Error(), http.StatusInternalServerError) 1042 return 1043 } 1044 1045 err = rp.enforcer.E.SavePolicy() 1046 if err != nil { 1047 log.Println("failed to update ACLs", err) 1048 http.Error(w, err.Error(), http.StatusInternalServerError) 1049 return 1050 } 1051 1052 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 1053} 1054 1055func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1056 f, err := rp.repoResolver.Resolve(r) 1057 if err != nil { 1058 log.Println("failed to get repo and knot", err) 1059 return 1060 } 1061 1062 branch := r.FormValue("branch") 1063 if branch == "" { 1064 http.Error(w, "malformed form", http.StatusBadRequest) 1065 return 1066 } 1067 1068 client, err := rp.oauth.ServiceClient( 1069 r, 1070 oauth.WithService(f.Knot), 1071 oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1072 oauth.WithDev(rp.config.Core.Dev), 1073 ) 1074 if err != nil { 1075 log.Println("failed to connect to knot server:", err) 1076 rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1077 return 1078 } 1079 1080 xe := tangled.RepoSetDefaultBranch( 1081 r.Context(), 1082 client, 1083 &tangled.RepoSetDefaultBranch_Input{ 1084 Repo: f.RepoAt().String(), 1085 DefaultBranch: branch, 1086 }, 1087 ) 1088 if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1089 log.Println("xrpc failed", "err", xe) 1090 rp.pages.Notice(w, noticeId, err.Error()) 1091 return 1092 } 1093 1094 w.Write(fmt.Append(nil, "default branch set to: ", branch)) 1095} 1096 1097func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1098 user := rp.oauth.GetUser(r) 1099 l := rp.logger.With("handler", "Secrets") 1100 l = l.With("handle", user.Handle) 1101 l = l.With("did", user.Did) 1102 1103 f, err := rp.repoResolver.Resolve(r) 1104 if err != nil { 1105 log.Println("failed to get repo and knot", err) 1106 return 1107 } 1108 1109 if f.Spindle == "" { 1110 log.Println("empty spindle cannot add/rm secret", err) 1111 return 1112 } 1113 1114 lxm := tangled.RepoAddSecretNSID 1115 if r.Method == http.MethodDelete { 1116 lxm = tangled.RepoRemoveSecretNSID 1117 } 1118 1119 spindleClient, err := rp.oauth.ServiceClient( 1120 r, 1121 oauth.WithService(f.Spindle), 1122 oauth.WithLxm(lxm), 1123 oauth.WithExp(60), 1124 oauth.WithDev(rp.config.Core.Dev), 1125 ) 1126 if err != nil { 1127 log.Println("failed to create spindle client", err) 1128 return 1129 } 1130 1131 key := r.FormValue("key") 1132 if key == "" { 1133 w.WriteHeader(http.StatusBadRequest) 1134 return 1135 } 1136 1137 switch r.Method { 1138 case http.MethodPut: 1139 errorId := "add-secret-error" 1140 1141 value := r.FormValue("value") 1142 if value == "" { 1143 w.WriteHeader(http.StatusBadRequest) 1144 return 1145 } 1146 1147 err = tangled.RepoAddSecret( 1148 r.Context(), 1149 spindleClient, 1150 &tangled.RepoAddSecret_Input{ 1151 Repo: f.RepoAt().String(), 1152 Key: key, 1153 Value: value, 1154 }, 1155 ) 1156 if err != nil { 1157 l.Error("Failed to add secret.", "err", err) 1158 rp.pages.Notice(w, errorId, "Failed to add secret.") 1159 return 1160 } 1161 1162 case http.MethodDelete: 1163 errorId := "operation-error" 1164 1165 err = tangled.RepoRemoveSecret( 1166 r.Context(), 1167 spindleClient, 1168 &tangled.RepoRemoveSecret_Input{ 1169 Repo: f.RepoAt().String(), 1170 Key: key, 1171 }, 1172 ) 1173 if err != nil { 1174 l.Error("Failed to delete secret.", "err", err) 1175 rp.pages.Notice(w, errorId, "Failed to delete secret.") 1176 return 1177 } 1178 } 1179 1180 rp.pages.HxRefresh(w) 1181} 1182 1183type tab = map[string]any 1184 1185var ( 1186 // would be great to have ordered maps right about now 1187 settingsTabs []tab = []tab{ 1188 {"Name": "general", "Icon": "sliders-horizontal"}, 1189 {"Name": "access", "Icon": "users"}, 1190 {"Name": "pipelines", "Icon": "layers-2"}, 1191 } 1192) 1193 1194func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1195 tabVal := r.URL.Query().Get("tab") 1196 if tabVal == "" { 1197 tabVal = "general" 1198 } 1199 1200 switch tabVal { 1201 case "general": 1202 rp.generalSettings(w, r) 1203 1204 case "access": 1205 rp.accessSettings(w, r) 1206 1207 case "pipelines": 1208 rp.pipelineSettings(w, r) 1209 } 1210 1211 // user := rp.oauth.GetUser(r) 1212 // repoCollaborators, err := f.Collaborators(r.Context()) 1213 // if err != nil { 1214 // log.Println("failed to get collaborators", err) 1215 // } 1216 1217 // isCollaboratorInviteAllowed := false 1218 // if user != nil { 1219 // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1220 // if err == nil && ok { 1221 // isCollaboratorInviteAllowed = true 1222 // } 1223 // } 1224 1225 // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1226 // if err != nil { 1227 // log.Println("failed to create unsigned client", err) 1228 // return 1229 // } 1230 1231 // result, err := us.Branches(f.OwnerDid(), f.Name) 1232 // if err != nil { 1233 // log.Println("failed to reach knotserver", err) 1234 // return 1235 // } 1236 1237 // // all spindles that this user is a member of 1238 // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1239 // if err != nil { 1240 // log.Println("failed to fetch spindles", err) 1241 // return 1242 // } 1243 1244 // var secrets []*tangled.RepoListSecrets_Secret 1245 // if f.Spindle != "" { 1246 // if spindleClient, err := rp.oauth.ServiceClient( 1247 // r, 1248 // oauth.WithService(f.Spindle), 1249 // oauth.WithLxm(tangled.RepoListSecretsNSID), 1250 // oauth.WithDev(rp.config.Core.Dev), 1251 // ); err != nil { 1252 // log.Println("failed to create spindle client", err) 1253 // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1254 // log.Println("failed to fetch secrets", err) 1255 // } else { 1256 // secrets = resp.Secrets 1257 // } 1258 // } 1259 1260 // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1261 // LoggedInUser: user, 1262 // RepoInfo: f.RepoInfo(user), 1263 // Collaborators: repoCollaborators, 1264 // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1265 // Branches: result.Branches, 1266 // Spindles: spindles, 1267 // CurrentSpindle: f.Spindle, 1268 // Secrets: secrets, 1269 // }) 1270} 1271 1272func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1273 f, err := rp.repoResolver.Resolve(r) 1274 user := rp.oauth.GetUser(r) 1275 1276 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1277 if err != nil { 1278 log.Println("failed to create unsigned client", err) 1279 return 1280 } 1281 1282 result, err := us.Branches(f.OwnerDid(), f.Name) 1283 if err != nil { 1284 rp.pages.Error503(w) 1285 log.Println("failed to reach knotserver", err) 1286 return 1287 } 1288 1289 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1290 LoggedInUser: user, 1291 RepoInfo: f.RepoInfo(user), 1292 Branches: result.Branches, 1293 Tabs: settingsTabs, 1294 Tab: "general", 1295 }) 1296} 1297 1298func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1299 f, err := rp.repoResolver.Resolve(r) 1300 user := rp.oauth.GetUser(r) 1301 1302 repoCollaborators, err := f.Collaborators(r.Context()) 1303 if err != nil { 1304 log.Println("failed to get collaborators", err) 1305 } 1306 1307 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1308 LoggedInUser: user, 1309 RepoInfo: f.RepoInfo(user), 1310 Tabs: settingsTabs, 1311 Tab: "access", 1312 Collaborators: repoCollaborators, 1313 }) 1314} 1315 1316func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1317 f, err := rp.repoResolver.Resolve(r) 1318 user := rp.oauth.GetUser(r) 1319 1320 // all spindles that the repo owner is a member of 1321 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1322 if err != nil { 1323 log.Println("failed to fetch spindles", err) 1324 return 1325 } 1326 1327 var secrets []*tangled.RepoListSecrets_Secret 1328 if f.Spindle != "" { 1329 if spindleClient, err := rp.oauth.ServiceClient( 1330 r, 1331 oauth.WithService(f.Spindle), 1332 oauth.WithLxm(tangled.RepoListSecretsNSID), 1333 oauth.WithExp(60), 1334 oauth.WithDev(rp.config.Core.Dev), 1335 ); err != nil { 1336 log.Println("failed to create spindle client", err) 1337 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1338 log.Println("failed to fetch secrets", err) 1339 } else { 1340 secrets = resp.Secrets 1341 } 1342 } 1343 1344 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 1345 return strings.Compare(a.Key, b.Key) 1346 }) 1347 1348 var dids []string 1349 for _, s := range secrets { 1350 dids = append(dids, s.CreatedBy) 1351 } 1352 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 1353 1354 // convert to a more manageable form 1355 var niceSecret []map[string]any 1356 for id, s := range secrets { 1357 when, _ := time.Parse(time.RFC3339, s.CreatedAt) 1358 niceSecret = append(niceSecret, map[string]any{ 1359 "Id": id, 1360 "Key": s.Key, 1361 "CreatedAt": when, 1362 "CreatedBy": resolvedIdents[id].Handle.String(), 1363 }) 1364 } 1365 1366 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 1367 LoggedInUser: user, 1368 RepoInfo: f.RepoInfo(user), 1369 Tabs: settingsTabs, 1370 Tab: "pipelines", 1371 Spindles: spindles, 1372 CurrentSpindle: f.Spindle, 1373 Secrets: niceSecret, 1374 }) 1375} 1376 1377func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1378 ref := chi.URLParam(r, "ref") 1379 1380 user := rp.oauth.GetUser(r) 1381 f, err := rp.repoResolver.Resolve(r) 1382 if err != nil { 1383 log.Printf("failed to resolve source repo: %v", err) 1384 return 1385 } 1386 1387 switch r.Method { 1388 case http.MethodPost: 1389 client, err := rp.oauth.ServiceClient( 1390 r, 1391 oauth.WithService(f.Knot), 1392 oauth.WithLxm(tangled.RepoForkSyncNSID), 1393 oauth.WithDev(rp.config.Core.Dev), 1394 ) 1395 if err != nil { 1396 rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1397 return 1398 } 1399 1400 repoInfo := f.RepoInfo(user) 1401 if repoInfo.Source == nil { 1402 rp.pages.Notice(w, "repo", "This repository is not a fork.") 1403 return 1404 } 1405 1406 err = tangled.RepoForkSync( 1407 r.Context(), 1408 client, 1409 &tangled.RepoForkSync_Input{ 1410 Did: user.Did, 1411 Name: f.Name, 1412 Source: repoInfo.Source.RepoAt().String(), 1413 Branch: ref, 1414 }, 1415 ) 1416 if err != nil { 1417 xe, parseErr := xrpcerr.Unmarshal(err.Error()) 1418 if parseErr != nil { 1419 log.Printf("failed to sync repository fork: %s", err) 1420 rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1421 } else { 1422 log.Printf("failed to sync repository fork: %s", xe.Error()) 1423 rp.pages.Notice(w, "repo", fmt.Sprintf("Failed to sync repository fork: %s", xe.Message)) 1424 } 1425 return 1426 } 1427 1428 rp.pages.HxRefresh(w) 1429 return 1430 } 1431} 1432 1433func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 1434 user := rp.oauth.GetUser(r) 1435 f, err := rp.repoResolver.Resolve(r) 1436 if err != nil { 1437 log.Printf("failed to resolve source repo: %v", err) 1438 return 1439 } 1440 1441 switch r.Method { 1442 case http.MethodGet: 1443 user := rp.oauth.GetUser(r) 1444 knots, err := rp.enforcer.GetKnotsForUser(user.Did) 1445 if err != nil { 1446 rp.pages.Notice(w, "repo", "Invalid user account.") 1447 return 1448 } 1449 1450 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1451 LoggedInUser: user, 1452 Knots: knots, 1453 RepoInfo: f.RepoInfo(user), 1454 }) 1455 1456 case http.MethodPost: 1457 l := rp.logger.With("handler", "ForkRepo") 1458 1459 targetKnot := r.FormValue("knot") 1460 if targetKnot == "" { 1461 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") 1462 return 1463 } 1464 l = l.With("targetKnot", targetKnot) 1465 1466 ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1467 if err != nil || !ok { 1468 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1469 return 1470 } 1471 1472 // choose a name for a fork 1473 forkName := f.Name 1474 // this check is *only* to see if the forked repo name already exists 1475 // in the user's account. 1476 existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1477 if err != nil { 1478 if errors.Is(err, sql.ErrNoRows) { 1479 // no existing repo with this name found, we can use the name as is 1480 } else { 1481 log.Println("error fetching existing repo from db", err) 1482 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1483 return 1484 } 1485 } else if existingRepo != nil { 1486 // repo with this name already exists, append random string 1487 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1488 } 1489 l = l.With("forkName", forkName) 1490 1491 uri := "https" 1492 if rp.config.Core.Dev { 1493 uri = "http" 1494 } 1495 1496 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1497 l = l.With("cloneUrl", forkSourceUrl) 1498 1499 sourceAt := f.RepoAt().String() 1500 1501 // create an atproto record for this fork 1502 rkey := tid.TID() 1503 repo := &db.Repo{ 1504 Did: user.Did, 1505 Name: forkName, 1506 Knot: targetKnot, 1507 Rkey: rkey, 1508 Source: sourceAt, 1509 } 1510 1511 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1512 if err != nil { 1513 l.Error("failed to create xrpcclient", "err", err) 1514 rp.pages.Notice(w, "repo", "Failed to fork repository.") 1515 return 1516 } 1517 1518 createdAt := time.Now().Format(time.RFC3339) 1519 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1520 Collection: tangled.RepoNSID, 1521 Repo: user.Did, 1522 Rkey: rkey, 1523 Record: &lexutil.LexiconTypeDecoder{ 1524 Val: &tangled.Repo{ 1525 Knot: repo.Knot, 1526 Name: repo.Name, 1527 CreatedAt: createdAt, 1528 Owner: user.Did, 1529 Source: &sourceAt, 1530 }}, 1531 }) 1532 if err != nil { 1533 l.Error("failed to write to PDS", "err", err) 1534 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1535 return 1536 } 1537 1538 aturi := atresp.Uri 1539 l = l.With("aturi", aturi) 1540 l.Info("wrote to PDS") 1541 1542 tx, err := rp.db.BeginTx(r.Context(), nil) 1543 if err != nil { 1544 l.Info("txn failed", "err", err) 1545 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1546 return 1547 } 1548 1549 // The rollback function reverts a few things on failure: 1550 // - the pending txn 1551 // - the ACLs 1552 // - the atproto record created 1553 rollback := func() { 1554 err1 := tx.Rollback() 1555 err2 := rp.enforcer.E.LoadPolicy() 1556 err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 1557 1558 // ignore txn complete errors, this is okay 1559 if errors.Is(err1, sql.ErrTxDone) { 1560 err1 = nil 1561 } 1562 1563 if errs := errors.Join(err1, err2, err3); errs != nil { 1564 l.Error("failed to rollback changes", "errs", errs) 1565 return 1566 } 1567 } 1568 defer rollback() 1569 1570 client, err := rp.oauth.ServiceClient( 1571 r, 1572 oauth.WithService(targetKnot), 1573 oauth.WithLxm(tangled.RepoCreateNSID), 1574 oauth.WithDev(rp.config.Core.Dev), 1575 ) 1576 if err != nil { 1577 l.Error("could not create service client", "err", err) 1578 rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1579 return 1580 } 1581 1582 err = tangled.RepoCreate( 1583 r.Context(), 1584 client, 1585 &tangled.RepoCreate_Input{ 1586 Rkey: rkey, 1587 Source: &forkSourceUrl, 1588 }, 1589 ) 1590 if err := xrpcclient.HandleXrpcErr(err); err != nil { 1591 rp.pages.Notice(w, "repo", err.Error()) 1592 return 1593 } 1594 1595 err = db.AddRepo(tx, repo) 1596 if err != nil { 1597 log.Println(err) 1598 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1599 return 1600 } 1601 1602 // acls 1603 p, _ := securejoin.SecureJoin(user.Did, forkName) 1604 err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1605 if err != nil { 1606 log.Println(err) 1607 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1608 return 1609 } 1610 1611 err = tx.Commit() 1612 if err != nil { 1613 log.Println("failed to commit changes", err) 1614 http.Error(w, err.Error(), http.StatusInternalServerError) 1615 return 1616 } 1617 1618 err = rp.enforcer.E.SavePolicy() 1619 if err != nil { 1620 log.Println("failed to update ACLs", err) 1621 http.Error(w, err.Error(), http.StatusInternalServerError) 1622 return 1623 } 1624 1625 // reset the ATURI because the transaction completed successfully 1626 aturi = "" 1627 1628 rp.notifier.NewRepo(r.Context(), repo) 1629 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1630 } 1631} 1632 1633// this is used to rollback changes made to the PDS 1634// 1635// it is a no-op if the provided ATURI is empty 1636func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 1637 if aturi == "" { 1638 return nil 1639 } 1640 1641 parsed := syntax.ATURI(aturi) 1642 1643 collection := parsed.Collection().String() 1644 repo := parsed.Authority().String() 1645 rkey := parsed.RecordKey().String() 1646 1647 _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 1648 Collection: collection, 1649 Repo: repo, 1650 Rkey: rkey, 1651 }) 1652 return err 1653} 1654 1655func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1656 user := rp.oauth.GetUser(r) 1657 f, err := rp.repoResolver.Resolve(r) 1658 if err != nil { 1659 log.Println("failed to get repo and knot", err) 1660 return 1661 } 1662 1663 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1664 if err != nil { 1665 log.Printf("failed to create unsigned client for %s", f.Knot) 1666 rp.pages.Error503(w) 1667 return 1668 } 1669 1670 result, err := us.Branches(f.OwnerDid(), f.Name) 1671 if err != nil { 1672 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1673 log.Println("failed to reach knotserver", err) 1674 return 1675 } 1676 branches := result.Branches 1677 1678 sortBranches(branches) 1679 1680 var defaultBranch string 1681 for _, b := range branches { 1682 if b.IsDefault { 1683 defaultBranch = b.Name 1684 } 1685 } 1686 1687 base := defaultBranch 1688 head := defaultBranch 1689 1690 params := r.URL.Query() 1691 queryBase := params.Get("base") 1692 queryHead := params.Get("head") 1693 if queryBase != "" { 1694 base = queryBase 1695 } 1696 if queryHead != "" { 1697 head = queryHead 1698 } 1699 1700 tags, err := us.Tags(f.OwnerDid(), f.Name) 1701 if err != nil { 1702 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1703 log.Println("failed to reach knotserver", err) 1704 return 1705 } 1706 1707 repoinfo := f.RepoInfo(user) 1708 1709 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 1710 LoggedInUser: user, 1711 RepoInfo: repoinfo, 1712 Branches: branches, 1713 Tags: tags.Tags, 1714 Base: base, 1715 Head: head, 1716 }) 1717} 1718 1719func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 1720 user := rp.oauth.GetUser(r) 1721 f, err := rp.repoResolver.Resolve(r) 1722 if err != nil { 1723 log.Println("failed to get repo and knot", err) 1724 return 1725 } 1726 1727 var diffOpts types.DiffOpts 1728 if d := r.URL.Query().Get("diff"); d == "split" { 1729 diffOpts.Split = true 1730 } 1731 1732 // if user is navigating to one of 1733 // /compare/{base}/{head} 1734 // /compare/{base}...{head} 1735 base := chi.URLParam(r, "base") 1736 head := chi.URLParam(r, "head") 1737 if base == "" && head == "" { 1738 rest := chi.URLParam(r, "*") // master...feature/xyz 1739 parts := strings.SplitN(rest, "...", 2) 1740 if len(parts) == 2 { 1741 base = parts[0] 1742 head = parts[1] 1743 } 1744 } 1745 1746 base, _ = url.PathUnescape(base) 1747 head, _ = url.PathUnescape(head) 1748 1749 if base == "" || head == "" { 1750 log.Printf("invalid comparison") 1751 rp.pages.Error404(w) 1752 return 1753 } 1754 1755 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1756 if err != nil { 1757 log.Printf("failed to create unsigned client for %s", f.Knot) 1758 rp.pages.Error503(w) 1759 return 1760 } 1761 1762 branches, err := us.Branches(f.OwnerDid(), f.Name) 1763 if err != nil { 1764 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1765 log.Println("failed to reach knotserver", err) 1766 return 1767 } 1768 1769 tags, err := us.Tags(f.OwnerDid(), f.Name) 1770 if err != nil { 1771 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1772 log.Println("failed to reach knotserver", err) 1773 return 1774 } 1775 1776 formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1777 if err != nil { 1778 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1779 log.Println("failed to compare", err) 1780 return 1781 } 1782 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1783 1784 repoinfo := f.RepoInfo(user) 1785 1786 rp.pages.RepoCompare(w, pages.RepoCompareParams{ 1787 LoggedInUser: user, 1788 RepoInfo: repoinfo, 1789 Branches: branches.Branches, 1790 Tags: tags.Tags, 1791 Base: base, 1792 Head: head, 1793 Diff: &diff, 1794 DiffOpts: diffOpts, 1795 }) 1796 1797}