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 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 379 if err != nil { 380 log.Println("failed to reach knotserver", err) 381 return 382 } 383 384 body, err := io.ReadAll(resp.Body) 385 if err != nil { 386 log.Printf("Error reading response body: %v", err) 387 return 388 } 389 390 var result types.RepoTreeResponse 391 err = json.Unmarshal(body, &result) 392 if err != nil { 393 log.Println("failed to parse response:", err) 394 return 395 } 396 397 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 398 // so we can safely redirect to the "parent" (which is the same file). 399 unescapedTreePath, _ := url.PathUnescape(treePath) 400 if len(result.Files) == 0 && result.Parent == unescapedTreePath { 401 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 402 return 403 } 404 405 user := rp.oauth.GetUser(r) 406 407 var breadcrumbs [][]string 408 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 409 if treePath != "" { 410 for idx, elem := range strings.Split(treePath, "/") { 411 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 412 } 413 } 414 415 sortFiles(result.Files) 416 417 rp.pages.RepoTree(w, pages.RepoTreeParams{ 418 LoggedInUser: user, 419 BreadCrumbs: breadcrumbs, 420 TreePath: treePath, 421 RepoInfo: f.RepoInfo(user), 422 RepoTreeResponse: result, 423 }) 424} 425 426func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 427 f, err := rp.repoResolver.Resolve(r) 428 if err != nil { 429 log.Println("failed to get repo and knot", err) 430 return 431 } 432 433 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 434 if err != nil { 435 log.Println("failed to create unsigned client", err) 436 return 437 } 438 439 result, err := us.Tags(f.OwnerDid(), f.Name) 440 if err != nil { 441 log.Println("failed to reach knotserver", err) 442 return 443 } 444 445 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 446 if err != nil { 447 log.Println("failed grab artifacts", err) 448 return 449 } 450 451 // convert artifacts to map for easy UI building 452 artifactMap := make(map[plumbing.Hash][]db.Artifact) 453 for _, a := range artifacts { 454 artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 455 } 456 457 var danglingArtifacts []db.Artifact 458 for _, a := range artifacts { 459 found := false 460 for _, t := range result.Tags { 461 if t.Tag != nil { 462 if t.Tag.Hash == a.Tag { 463 found = true 464 } 465 } 466 } 467 468 if !found { 469 danglingArtifacts = append(danglingArtifacts, a) 470 } 471 } 472 473 user := rp.oauth.GetUser(r) 474 rp.pages.RepoTags(w, pages.RepoTagsParams{ 475 LoggedInUser: user, 476 RepoInfo: f.RepoInfo(user), 477 RepoTagsResponse: *result, 478 ArtifactMap: artifactMap, 479 DanglingArtifacts: danglingArtifacts, 480 }) 481} 482 483func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 484 f, err := rp.repoResolver.Resolve(r) 485 if err != nil { 486 log.Println("failed to get repo and knot", err) 487 return 488 } 489 490 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 491 if err != nil { 492 log.Println("failed to create unsigned client", err) 493 return 494 } 495 496 result, err := us.Branches(f.OwnerDid(), f.Name) 497 if err != nil { 498 log.Println("failed to reach knotserver", err) 499 return 500 } 501 502 sortBranches(result.Branches) 503 504 user := rp.oauth.GetUser(r) 505 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 506 LoggedInUser: user, 507 RepoInfo: f.RepoInfo(user), 508 RepoBranchesResponse: *result, 509 }) 510} 511 512func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 513 f, err := rp.repoResolver.Resolve(r) 514 if err != nil { 515 log.Println("failed to get repo and knot", err) 516 return 517 } 518 519 ref := chi.URLParam(r, "ref") 520 filePath := chi.URLParam(r, "*") 521 protocol := "http" 522 if !rp.config.Core.Dev { 523 protocol = "https" 524 } 525 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)) 526 if err != nil { 527 log.Println("failed to reach knotserver", err) 528 return 529 } 530 531 body, err := io.ReadAll(resp.Body) 532 if err != nil { 533 log.Printf("Error reading response body: %v", err) 534 return 535 } 536 537 var result types.RepoBlobResponse 538 err = json.Unmarshal(body, &result) 539 if err != nil { 540 log.Println("failed to parse response:", err) 541 return 542 } 543 544 var breadcrumbs [][]string 545 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 546 if filePath != "" { 547 for idx, elem := range strings.Split(filePath, "/") { 548 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 549 } 550 } 551 552 showRendered := false 553 renderToggle := false 554 555 if markup.GetFormat(result.Path) == markup.FormatMarkdown { 556 renderToggle = true 557 showRendered = r.URL.Query().Get("code") != "true" 558 } 559 560 var unsupported bool 561 var isImage bool 562 var isVideo bool 563 var contentSrc string 564 565 if result.IsBinary { 566 ext := strings.ToLower(filepath.Ext(result.Path)) 567 switch ext { 568 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 569 isImage = true 570 case ".mp4", ".webm", ".ogg", ".mov", ".avi": 571 isVideo = true 572 default: 573 unsupported = true 574 } 575 576 // fetch the actual binary content like in RepoBlobRaw 577 578 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath) 579 contentSrc = blobURL 580 if !rp.config.Core.Dev { 581 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 582 } 583 } 584 585 user := rp.oauth.GetUser(r) 586 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 587 LoggedInUser: user, 588 RepoInfo: f.RepoInfo(user), 589 RepoBlobResponse: result, 590 BreadCrumbs: breadcrumbs, 591 ShowRendered: showRendered, 592 RenderToggle: renderToggle, 593 Unsupported: unsupported, 594 IsImage: isImage, 595 IsVideo: isVideo, 596 ContentSrc: contentSrc, 597 }) 598} 599 600func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 601 f, err := rp.repoResolver.Resolve(r) 602 if err != nil { 603 log.Println("failed to get repo and knot", err) 604 w.WriteHeader(http.StatusBadRequest) 605 return 606 } 607 608 ref := chi.URLParam(r, "ref") 609 filePath := chi.URLParam(r, "*") 610 611 protocol := "http" 612 if !rp.config.Core.Dev { 613 protocol = "https" 614 } 615 616 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 617 618 req, err := http.NewRequest("GET", blobURL, nil) 619 if err != nil { 620 log.Println("failed to create request", err) 621 return 622 } 623 624 // forward the If-None-Match header 625 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 626 req.Header.Set("If-None-Match", clientETag) 627 } 628 629 client := &http.Client{} 630 resp, err := client.Do(req) 631 if err != nil { 632 log.Println("failed to reach knotserver", err) 633 rp.pages.Error503(w) 634 return 635 } 636 defer resp.Body.Close() 637 638 // forward 304 not modified 639 if resp.StatusCode == http.StatusNotModified { 640 w.WriteHeader(http.StatusNotModified) 641 return 642 } 643 644 if resp.StatusCode != http.StatusOK { 645 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 646 w.WriteHeader(resp.StatusCode) 647 _, _ = io.Copy(w, resp.Body) 648 return 649 } 650 651 contentType := resp.Header.Get("Content-Type") 652 body, err := io.ReadAll(resp.Body) 653 if err != nil { 654 log.Printf("error reading response body from knotserver: %v", err) 655 w.WriteHeader(http.StatusInternalServerError) 656 return 657 } 658 659 if strings.Contains(contentType, "text/plain") { 660 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 661 w.Write(body) 662 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 663 w.Header().Set("Content-Type", contentType) 664 w.Write(body) 665 } else { 666 w.WriteHeader(http.StatusUnsupportedMediaType) 667 w.Write([]byte("unsupported content type")) 668 return 669 } 670} 671 672// modify the spindle configured for this repo 673func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 674 user := rp.oauth.GetUser(r) 675 l := rp.logger.With("handler", "EditSpindle") 676 l = l.With("did", user.Did) 677 l = l.With("handle", user.Handle) 678 679 errorId := "operation-error" 680 fail := func(msg string, err error) { 681 l.Error(msg, "err", err) 682 rp.pages.Notice(w, errorId, msg) 683 } 684 685 f, err := rp.repoResolver.Resolve(r) 686 if err != nil { 687 fail("Failed to resolve repo. Try again later", err) 688 return 689 } 690 691 repoAt := f.RepoAt() 692 rkey := repoAt.RecordKey().String() 693 if rkey == "" { 694 fail("Failed to resolve repo. Try again later", err) 695 return 696 } 697 698 newSpindle := r.FormValue("spindle") 699 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 700 client, err := rp.oauth.AuthorizedClient(r) 701 if err != nil { 702 fail("Failed to authorize. Try again later.", err) 703 return 704 } 705 706 if !removingSpindle { 707 // ensure that this is a valid spindle for this user 708 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 709 if err != nil { 710 fail("Failed to find spindles. Try again later.", err) 711 return 712 } 713 714 if !slices.Contains(validSpindles, newSpindle) { 715 fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 716 return 717 } 718 } 719 720 spindlePtr := &newSpindle 721 if removingSpindle { 722 spindlePtr = nil 723 } 724 725 // optimistic update 726 err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 727 if err != nil { 728 fail("Failed to update spindle. Try again later.", err) 729 return 730 } 731 732 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 733 if err != nil { 734 fail("Failed to update spindle, no record found on PDS.", err) 735 return 736 } 737 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 738 Collection: tangled.RepoNSID, 739 Repo: user.Did, 740 Rkey: rkey, 741 SwapRecord: ex.Cid, 742 Record: &lexutil.LexiconTypeDecoder{ 743 Val: &tangled.Repo{ 744 Knot: f.Knot, 745 Name: f.Name, 746 Owner: user.Did, 747 CreatedAt: f.Created.Format(time.RFC3339), 748 Description: &f.Description, 749 Spindle: spindlePtr, 750 }, 751 }, 752 }) 753 754 if err != nil { 755 fail("Failed to update spindle, unable to save to PDS.", err) 756 return 757 } 758 759 if !removingSpindle { 760 // add this spindle to spindle stream 761 rp.spindlestream.AddSource( 762 context.Background(), 763 eventconsumer.NewSpindleSource(newSpindle), 764 ) 765 } 766 767 rp.pages.HxRefresh(w) 768} 769 770func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 771 user := rp.oauth.GetUser(r) 772 l := rp.logger.With("handler", "AddCollaborator") 773 l = l.With("did", user.Did) 774 l = l.With("handle", user.Handle) 775 776 f, err := rp.repoResolver.Resolve(r) 777 if err != nil { 778 l.Error("failed to get repo and knot", "err", err) 779 return 780 } 781 782 errorId := "add-collaborator-error" 783 fail := func(msg string, err error) { 784 l.Error(msg, "err", err) 785 rp.pages.Notice(w, errorId, msg) 786 } 787 788 collaborator := r.FormValue("collaborator") 789 if collaborator == "" { 790 fail("Invalid form.", nil) 791 return 792 } 793 794 // remove a single leading `@`, to make @handle work with ResolveIdent 795 collaborator = strings.TrimPrefix(collaborator, "@") 796 797 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 798 if err != nil { 799 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) 800 return 801 } 802 803 if collaboratorIdent.DID.String() == user.Did { 804 fail("You seem to be adding yourself as a collaborator.", nil) 805 return 806 } 807 l = l.With("collaborator", collaboratorIdent.Handle) 808 l = l.With("knot", f.Knot) 809 810 // announce this relation into the firehose, store into owners' pds 811 client, err := rp.oauth.AuthorizedClient(r) 812 if err != nil { 813 fail("Failed to write to PDS.", err) 814 return 815 } 816 817 // emit a record 818 currentUser := rp.oauth.GetUser(r) 819 rkey := tid.TID() 820 createdAt := time.Now() 821 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 822 Collection: tangled.RepoCollaboratorNSID, 823 Repo: currentUser.Did, 824 Rkey: rkey, 825 Record: &lexutil.LexiconTypeDecoder{ 826 Val: &tangled.RepoCollaborator{ 827 Subject: collaboratorIdent.DID.String(), 828 Repo: string(f.RepoAt()), 829 CreatedAt: createdAt.Format(time.RFC3339), 830 }}, 831 }) 832 // invalid record 833 if err != nil { 834 fail("Failed to write record to PDS.", err) 835 return 836 } 837 l = l.With("at-uri", resp.Uri) 838 l.Info("wrote record to PDS") 839 840 l.Info("adding to knot") 841 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 842 if err != nil { 843 fail("Failed to add to knot.", err) 844 return 845 } 846 847 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 848 if err != nil { 849 fail("Failed to add to knot.", err) 850 return 851 } 852 853 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String()) 854 if err != nil { 855 fail("Knot was unreachable.", err) 856 return 857 } 858 859 if ksResp.StatusCode != http.StatusNoContent { 860 fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 861 return 862 } 863 864 tx, err := rp.db.BeginTx(r.Context(), nil) 865 if err != nil { 866 fail("Failed to add collaborator.", err) 867 return 868 } 869 defer func() { 870 tx.Rollback() 871 err = rp.enforcer.E.LoadPolicy() 872 if err != nil { 873 fail("Failed to add collaborator.", err) 874 } 875 }() 876 877 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 878 if err != nil { 879 fail("Failed to add collaborator permissions.", err) 880 return 881 } 882 883 err = db.AddCollaborator(rp.db, db.Collaborator{ 884 Did: syntax.DID(currentUser.Did), 885 Rkey: rkey, 886 SubjectDid: collaboratorIdent.DID, 887 RepoAt: f.RepoAt(), 888 Created: createdAt, 889 }) 890 if err != nil { 891 fail("Failed to add collaborator.", err) 892 return 893 } 894 895 err = tx.Commit() 896 if err != nil { 897 fail("Failed to add collaborator.", err) 898 return 899 } 900 901 err = rp.enforcer.E.SavePolicy() 902 if err != nil { 903 fail("Failed to update collaborator permissions.", err) 904 return 905 } 906 907 rp.pages.HxRefresh(w) 908} 909 910func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 911 user := rp.oauth.GetUser(r) 912 913 f, err := rp.repoResolver.Resolve(r) 914 if err != nil { 915 log.Println("failed to get repo and knot", err) 916 return 917 } 918 919 // remove record from pds 920 xrpcClient, err := rp.oauth.AuthorizedClient(r) 921 if err != nil { 922 log.Println("failed to get authorized client", err) 923 return 924 } 925 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 926 Collection: tangled.RepoNSID, 927 Repo: user.Did, 928 Rkey: f.Rkey, 929 }) 930 if err != nil { 931 log.Printf("failed to delete record: %s", err) 932 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 933 return 934 } 935 log.Println("removed repo record ", f.RepoAt().String()) 936 937 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 938 if err != nil { 939 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 940 return 941 } 942 943 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 944 if err != nil { 945 log.Println("failed to create client to ", f.Knot) 946 return 947 } 948 949 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.Name) 950 if err != nil { 951 log.Printf("failed to make request to %s: %s", f.Knot, err) 952 return 953 } 954 955 if ksResp.StatusCode != http.StatusNoContent { 956 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 957 } else { 958 log.Println("removed repo from knot ", f.Knot) 959 } 960 961 tx, err := rp.db.BeginTx(r.Context(), nil) 962 if err != nil { 963 log.Println("failed to start tx") 964 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 965 return 966 } 967 defer func() { 968 tx.Rollback() 969 err = rp.enforcer.E.LoadPolicy() 970 if err != nil { 971 log.Println("failed to rollback policies") 972 } 973 }() 974 975 // remove collaborator RBAC 976 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 977 if err != nil { 978 rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 979 return 980 } 981 for _, c := range repoCollaborators { 982 did := c[0] 983 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 984 } 985 log.Println("removed collaborators") 986 987 // remove repo RBAC 988 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 989 if err != nil { 990 rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 991 return 992 } 993 994 // remove repo from db 995 err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 996 if err != nil { 997 rp.pages.Notice(w, "settings-delete", "Failed to update appview") 998 return 999 } 1000 log.Println("removed repo from db") 1001 1002 err = tx.Commit() 1003 if err != nil { 1004 log.Println("failed to commit changes", err) 1005 http.Error(w, err.Error(), http.StatusInternalServerError) 1006 return 1007 } 1008 1009 err = rp.enforcer.E.SavePolicy() 1010 if err != nil { 1011 log.Println("failed to update ACLs", err) 1012 http.Error(w, err.Error(), http.StatusInternalServerError) 1013 return 1014 } 1015 1016 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 1017} 1018 1019func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1020 f, err := rp.repoResolver.Resolve(r) 1021 if err != nil { 1022 log.Println("failed to get repo and knot", err) 1023 return 1024 } 1025 1026 branch := r.FormValue("branch") 1027 if branch == "" { 1028 http.Error(w, "malformed form", http.StatusBadRequest) 1029 return 1030 } 1031 1032 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1033 if err != nil { 1034 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 1035 return 1036 } 1037 1038 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1039 if err != nil { 1040 log.Println("failed to create client to ", f.Knot) 1041 return 1042 } 1043 1044 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch) 1045 if err != nil { 1046 log.Printf("failed to make request to %s: %s", f.Knot, err) 1047 return 1048 } 1049 1050 if ksResp.StatusCode != http.StatusNoContent { 1051 rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 1052 return 1053 } 1054 1055 w.Write(fmt.Append(nil, "default branch set to: ", branch)) 1056} 1057 1058func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1059 user := rp.oauth.GetUser(r) 1060 l := rp.logger.With("handler", "Secrets") 1061 l = l.With("handle", user.Handle) 1062 l = l.With("did", user.Did) 1063 1064 f, err := rp.repoResolver.Resolve(r) 1065 if err != nil { 1066 log.Println("failed to get repo and knot", err) 1067 return 1068 } 1069 1070 if f.Spindle == "" { 1071 log.Println("empty spindle cannot add/rm secret", err) 1072 return 1073 } 1074 1075 lxm := tangled.RepoAddSecretNSID 1076 if r.Method == http.MethodDelete { 1077 lxm = tangled.RepoRemoveSecretNSID 1078 } 1079 1080 spindleClient, err := rp.oauth.ServiceClient( 1081 r, 1082 oauth.WithService(f.Spindle), 1083 oauth.WithLxm(lxm), 1084 oauth.WithExp(60), 1085 oauth.WithDev(rp.config.Core.Dev), 1086 ) 1087 if err != nil { 1088 log.Println("failed to create spindle client", err) 1089 return 1090 } 1091 1092 key := r.FormValue("key") 1093 if key == "" { 1094 w.WriteHeader(http.StatusBadRequest) 1095 return 1096 } 1097 1098 switch r.Method { 1099 case http.MethodPut: 1100 errorId := "add-secret-error" 1101 1102 value := r.FormValue("value") 1103 if value == "" { 1104 w.WriteHeader(http.StatusBadRequest) 1105 return 1106 } 1107 1108 err = tangled.RepoAddSecret( 1109 r.Context(), 1110 spindleClient, 1111 &tangled.RepoAddSecret_Input{ 1112 Repo: f.RepoAt().String(), 1113 Key: key, 1114 Value: value, 1115 }, 1116 ) 1117 if err != nil { 1118 l.Error("Failed to add secret.", "err", err) 1119 rp.pages.Notice(w, errorId, "Failed to add secret.") 1120 return 1121 } 1122 1123 case http.MethodDelete: 1124 errorId := "operation-error" 1125 1126 err = tangled.RepoRemoveSecret( 1127 r.Context(), 1128 spindleClient, 1129 &tangled.RepoRemoveSecret_Input{ 1130 Repo: f.RepoAt().String(), 1131 Key: key, 1132 }, 1133 ) 1134 if err != nil { 1135 l.Error("Failed to delete secret.", "err", err) 1136 rp.pages.Notice(w, errorId, "Failed to delete secret.") 1137 return 1138 } 1139 } 1140 1141 rp.pages.HxRefresh(w) 1142} 1143 1144type tab = map[string]any 1145 1146var ( 1147 // would be great to have ordered maps right about now 1148 settingsTabs []tab = []tab{ 1149 {"Name": "general", "Icon": "sliders-horizontal"}, 1150 {"Name": "access", "Icon": "users"}, 1151 {"Name": "pipelines", "Icon": "layers-2"}, 1152 } 1153) 1154 1155func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1156 tabVal := r.URL.Query().Get("tab") 1157 if tabVal == "" { 1158 tabVal = "general" 1159 } 1160 1161 switch tabVal { 1162 case "general": 1163 rp.generalSettings(w, r) 1164 1165 case "access": 1166 rp.accessSettings(w, r) 1167 1168 case "pipelines": 1169 rp.pipelineSettings(w, r) 1170 } 1171 1172 // user := rp.oauth.GetUser(r) 1173 // repoCollaborators, err := f.Collaborators(r.Context()) 1174 // if err != nil { 1175 // log.Println("failed to get collaborators", err) 1176 // } 1177 1178 // isCollaboratorInviteAllowed := false 1179 // if user != nil { 1180 // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1181 // if err == nil && ok { 1182 // isCollaboratorInviteAllowed = true 1183 // } 1184 // } 1185 1186 // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1187 // if err != nil { 1188 // log.Println("failed to create unsigned client", err) 1189 // return 1190 // } 1191 1192 // result, err := us.Branches(f.OwnerDid(), f.Name) 1193 // if err != nil { 1194 // log.Println("failed to reach knotserver", err) 1195 // return 1196 // } 1197 1198 // // all spindles that this user is a member of 1199 // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1200 // if err != nil { 1201 // log.Println("failed to fetch spindles", err) 1202 // return 1203 // } 1204 1205 // var secrets []*tangled.RepoListSecrets_Secret 1206 // if f.Spindle != "" { 1207 // if spindleClient, err := rp.oauth.ServiceClient( 1208 // r, 1209 // oauth.WithService(f.Spindle), 1210 // oauth.WithLxm(tangled.RepoListSecretsNSID), 1211 // oauth.WithDev(rp.config.Core.Dev), 1212 // ); err != nil { 1213 // log.Println("failed to create spindle client", err) 1214 // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1215 // log.Println("failed to fetch secrets", err) 1216 // } else { 1217 // secrets = resp.Secrets 1218 // } 1219 // } 1220 1221 // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1222 // LoggedInUser: user, 1223 // RepoInfo: f.RepoInfo(user), 1224 // Collaborators: repoCollaborators, 1225 // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1226 // Branches: result.Branches, 1227 // Spindles: spindles, 1228 // CurrentSpindle: f.Spindle, 1229 // Secrets: secrets, 1230 // }) 1231} 1232 1233func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1234 f, err := rp.repoResolver.Resolve(r) 1235 user := rp.oauth.GetUser(r) 1236 1237 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1238 if err != nil { 1239 log.Println("failed to create unsigned client", err) 1240 return 1241 } 1242 1243 result, err := us.Branches(f.OwnerDid(), f.Name) 1244 if err != nil { 1245 log.Println("failed to reach knotserver", err) 1246 return 1247 } 1248 1249 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1250 LoggedInUser: user, 1251 RepoInfo: f.RepoInfo(user), 1252 Branches: result.Branches, 1253 Tabs: settingsTabs, 1254 Tab: "general", 1255 }) 1256} 1257 1258func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1259 f, err := rp.repoResolver.Resolve(r) 1260 user := rp.oauth.GetUser(r) 1261 1262 repoCollaborators, err := f.Collaborators(r.Context()) 1263 if err != nil { 1264 log.Println("failed to get collaborators", err) 1265 } 1266 1267 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1268 LoggedInUser: user, 1269 RepoInfo: f.RepoInfo(user), 1270 Tabs: settingsTabs, 1271 Tab: "access", 1272 Collaborators: repoCollaborators, 1273 }) 1274} 1275 1276func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1277 f, err := rp.repoResolver.Resolve(r) 1278 user := rp.oauth.GetUser(r) 1279 1280 // all spindles that the repo owner is a member of 1281 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1282 if err != nil { 1283 log.Println("failed to fetch spindles", err) 1284 return 1285 } 1286 1287 var secrets []*tangled.RepoListSecrets_Secret 1288 if f.Spindle != "" { 1289 if spindleClient, err := rp.oauth.ServiceClient( 1290 r, 1291 oauth.WithService(f.Spindle), 1292 oauth.WithLxm(tangled.RepoListSecretsNSID), 1293 oauth.WithExp(60), 1294 oauth.WithDev(rp.config.Core.Dev), 1295 ); err != nil { 1296 log.Println("failed to create spindle client", err) 1297 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1298 log.Println("failed to fetch secrets", err) 1299 } else { 1300 secrets = resp.Secrets 1301 } 1302 } 1303 1304 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 1305 return strings.Compare(a.Key, b.Key) 1306 }) 1307 1308 var dids []string 1309 for _, s := range secrets { 1310 dids = append(dids, s.CreatedBy) 1311 } 1312 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 1313 1314 // convert to a more manageable form 1315 var niceSecret []map[string]any 1316 for id, s := range secrets { 1317 when, _ := time.Parse(time.RFC3339, s.CreatedAt) 1318 niceSecret = append(niceSecret, map[string]any{ 1319 "Id": id, 1320 "Key": s.Key, 1321 "CreatedAt": when, 1322 "CreatedBy": resolvedIdents[id].Handle.String(), 1323 }) 1324 } 1325 1326 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 1327 LoggedInUser: user, 1328 RepoInfo: f.RepoInfo(user), 1329 Tabs: settingsTabs, 1330 Tab: "pipelines", 1331 Spindles: spindles, 1332 CurrentSpindle: f.Spindle, 1333 Secrets: niceSecret, 1334 }) 1335} 1336 1337func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1338 ref := chi.URLParam(r, "ref") 1339 1340 user := rp.oauth.GetUser(r) 1341 f, err := rp.repoResolver.Resolve(r) 1342 if err != nil { 1343 log.Printf("failed to resolve source repo: %v", err) 1344 return 1345 } 1346 1347 switch r.Method { 1348 case http.MethodPost: 1349 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1350 if err != nil { 1351 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1352 return 1353 } 1354 1355 client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1356 if err != nil { 1357 rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1358 return 1359 } 1360 1361 var uri string 1362 if rp.config.Core.Dev { 1363 uri = "http" 1364 } else { 1365 uri = "https" 1366 } 1367 forkName := fmt.Sprintf("%s", f.Name) 1368 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1369 1370 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref) 1371 if err != nil { 1372 rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1373 return 1374 } 1375 1376 rp.pages.HxRefresh(w) 1377 return 1378 } 1379} 1380 1381func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 1382 user := rp.oauth.GetUser(r) 1383 f, err := rp.repoResolver.Resolve(r) 1384 if err != nil { 1385 log.Printf("failed to resolve source repo: %v", err) 1386 return 1387 } 1388 1389 switch r.Method { 1390 case http.MethodGet: 1391 user := rp.oauth.GetUser(r) 1392 knots, err := rp.enforcer.GetKnotsForUser(user.Did) 1393 if err != nil { 1394 rp.pages.Notice(w, "repo", "Invalid user account.") 1395 return 1396 } 1397 1398 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1399 LoggedInUser: user, 1400 Knots: knots, 1401 RepoInfo: f.RepoInfo(user), 1402 }) 1403 1404 case http.MethodPost: 1405 1406 knot := r.FormValue("knot") 1407 if knot == "" { 1408 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") 1409 return 1410 } 1411 1412 ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1413 if err != nil || !ok { 1414 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1415 return 1416 } 1417 1418 forkName := fmt.Sprintf("%s", f.Name) 1419 1420 // this check is *only* to see if the forked repo name already exists 1421 // in the user's account. 1422 existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1423 if err != nil { 1424 if errors.Is(err, sql.ErrNoRows) { 1425 // no existing repo with this name found, we can use the name as is 1426 } else { 1427 log.Println("error fetching existing repo from db", err) 1428 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1429 return 1430 } 1431 } else if existingRepo != nil { 1432 // repo with this name already exists, append random string 1433 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1434 } 1435 secret, err := db.GetRegistrationKey(rp.db, knot) 1436 if err != nil { 1437 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1438 return 1439 } 1440 1441 client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev) 1442 if err != nil { 1443 rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1444 return 1445 } 1446 1447 var uri string 1448 if rp.config.Core.Dev { 1449 uri = "http" 1450 } else { 1451 uri = "https" 1452 } 1453 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1454 sourceAt := f.RepoAt().String() 1455 1456 rkey := tid.TID() 1457 repo := &db.Repo{ 1458 Did: user.Did, 1459 Name: forkName, 1460 Knot: knot, 1461 Rkey: rkey, 1462 Source: sourceAt, 1463 } 1464 1465 tx, err := rp.db.BeginTx(r.Context(), nil) 1466 if err != nil { 1467 log.Println(err) 1468 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1469 return 1470 } 1471 defer func() { 1472 tx.Rollback() 1473 err = rp.enforcer.E.LoadPolicy() 1474 if err != nil { 1475 log.Println("failed to rollback policies") 1476 } 1477 }() 1478 1479 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1480 if err != nil { 1481 rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1482 return 1483 } 1484 1485 switch resp.StatusCode { 1486 case http.StatusConflict: 1487 rp.pages.Notice(w, "repo", "A repository with that name already exists.") 1488 return 1489 case http.StatusInternalServerError: 1490 rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1491 case http.StatusNoContent: 1492 // continue 1493 } 1494 1495 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1496 if err != nil { 1497 log.Println("failed to get authorized client", err) 1498 rp.pages.Notice(w, "repo", "Failed to create repository.") 1499 return 1500 } 1501 1502 createdAt := time.Now().Format(time.RFC3339) 1503 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1504 Collection: tangled.RepoNSID, 1505 Repo: user.Did, 1506 Rkey: rkey, 1507 Record: &lexutil.LexiconTypeDecoder{ 1508 Val: &tangled.Repo{ 1509 Knot: repo.Knot, 1510 Name: repo.Name, 1511 CreatedAt: createdAt, 1512 Owner: user.Did, 1513 Source: &sourceAt, 1514 }}, 1515 }) 1516 if err != nil { 1517 log.Printf("failed to create record: %s", err) 1518 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1519 return 1520 } 1521 log.Println("created repo record: ", atresp.Uri) 1522 1523 err = db.AddRepo(tx, repo) 1524 if err != nil { 1525 log.Println(err) 1526 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1527 return 1528 } 1529 1530 // acls 1531 p, _ := securejoin.SecureJoin(user.Did, forkName) 1532 err = rp.enforcer.AddRepo(user.Did, knot, p) 1533 if err != nil { 1534 log.Println(err) 1535 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1536 return 1537 } 1538 1539 err = tx.Commit() 1540 if err != nil { 1541 log.Println("failed to commit changes", err) 1542 http.Error(w, err.Error(), http.StatusInternalServerError) 1543 return 1544 } 1545 1546 err = rp.enforcer.E.SavePolicy() 1547 if err != nil { 1548 log.Println("failed to update ACLs", err) 1549 http.Error(w, err.Error(), http.StatusInternalServerError) 1550 return 1551 } 1552 1553 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1554 return 1555 } 1556} 1557 1558func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1559 user := rp.oauth.GetUser(r) 1560 f, err := rp.repoResolver.Resolve(r) 1561 if err != nil { 1562 log.Println("failed to get repo and knot", err) 1563 return 1564 } 1565 1566 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1567 if err != nil { 1568 log.Printf("failed to create unsigned client for %s", f.Knot) 1569 rp.pages.Error503(w) 1570 return 1571 } 1572 1573 result, err := us.Branches(f.OwnerDid(), f.Name) 1574 if err != nil { 1575 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1576 log.Println("failed to reach knotserver", err) 1577 return 1578 } 1579 branches := result.Branches 1580 1581 sortBranches(branches) 1582 1583 var defaultBranch string 1584 for _, b := range branches { 1585 if b.IsDefault { 1586 defaultBranch = b.Name 1587 } 1588 } 1589 1590 base := defaultBranch 1591 head := defaultBranch 1592 1593 params := r.URL.Query() 1594 queryBase := params.Get("base") 1595 queryHead := params.Get("head") 1596 if queryBase != "" { 1597 base = queryBase 1598 } 1599 if queryHead != "" { 1600 head = queryHead 1601 } 1602 1603 tags, err := us.Tags(f.OwnerDid(), f.Name) 1604 if err != nil { 1605 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1606 log.Println("failed to reach knotserver", err) 1607 return 1608 } 1609 1610 repoinfo := f.RepoInfo(user) 1611 1612 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 1613 LoggedInUser: user, 1614 RepoInfo: repoinfo, 1615 Branches: branches, 1616 Tags: tags.Tags, 1617 Base: base, 1618 Head: head, 1619 }) 1620} 1621 1622func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 1623 user := rp.oauth.GetUser(r) 1624 f, err := rp.repoResolver.Resolve(r) 1625 if err != nil { 1626 log.Println("failed to get repo and knot", err) 1627 return 1628 } 1629 1630 var diffOpts types.DiffOpts 1631 if d := r.URL.Query().Get("diff"); d == "split" { 1632 diffOpts.Split = true 1633 } 1634 1635 // if user is navigating to one of 1636 // /compare/{base}/{head} 1637 // /compare/{base}...{head} 1638 base := chi.URLParam(r, "base") 1639 head := chi.URLParam(r, "head") 1640 if base == "" && head == "" { 1641 rest := chi.URLParam(r, "*") // master...feature/xyz 1642 parts := strings.SplitN(rest, "...", 2) 1643 if len(parts) == 2 { 1644 base = parts[0] 1645 head = parts[1] 1646 } 1647 } 1648 1649 base, _ = url.PathUnescape(base) 1650 head, _ = url.PathUnescape(head) 1651 1652 if base == "" || head == "" { 1653 log.Printf("invalid comparison") 1654 rp.pages.Error404(w) 1655 return 1656 } 1657 1658 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1659 if err != nil { 1660 log.Printf("failed to create unsigned client for %s", f.Knot) 1661 rp.pages.Error503(w) 1662 return 1663 } 1664 1665 branches, err := us.Branches(f.OwnerDid(), f.Name) 1666 if err != nil { 1667 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1668 log.Println("failed to reach knotserver", err) 1669 return 1670 } 1671 1672 tags, err := us.Tags(f.OwnerDid(), f.Name) 1673 if err != nil { 1674 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1675 log.Println("failed to reach knotserver", err) 1676 return 1677 } 1678 1679 formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1680 if err != nil { 1681 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1682 log.Println("failed to compare", err) 1683 return 1684 } 1685 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1686 1687 repoinfo := f.RepoInfo(user) 1688 1689 rp.pages.RepoCompare(w, pages.RepoCompareParams{ 1690 LoggedInUser: user, 1691 RepoInfo: repoinfo, 1692 Branches: branches.Branches, 1693 Tags: tags.Tags, 1694 Base: base, 1695 Head: head, 1696 Diff: &diff, 1697 DiffOpts: diffOpts, 1698 }) 1699 1700}