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