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