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