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