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