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