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