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 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 labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1395 if err != nil { 1396 log.Println("failed to fetch labels", err) 1397 rp.pages.Error503(w) 1398 return 1399 } 1400 1401 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1402 LoggedInUser: user, 1403 RepoInfo: f.RepoInfo(user), 1404 Branches: result.Branches, 1405 Labels: labels, 1406 Tabs: settingsTabs, 1407 Tab: "general", 1408 }) 1409} 1410 1411func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1412 f, err := rp.repoResolver.Resolve(r) 1413 user := rp.oauth.GetUser(r) 1414 1415 repoCollaborators, err := f.Collaborators(r.Context()) 1416 if err != nil { 1417 log.Println("failed to get collaborators", err) 1418 } 1419 1420 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1421 LoggedInUser: user, 1422 RepoInfo: f.RepoInfo(user), 1423 Tabs: settingsTabs, 1424 Tab: "access", 1425 Collaborators: repoCollaborators, 1426 }) 1427} 1428 1429func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1430 f, err := rp.repoResolver.Resolve(r) 1431 user := rp.oauth.GetUser(r) 1432 1433 // all spindles that the repo owner is a member of 1434 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1435 if err != nil { 1436 log.Println("failed to fetch spindles", err) 1437 return 1438 } 1439 1440 var secrets []*tangled.RepoListSecrets_Secret 1441 if f.Spindle != "" { 1442 if spindleClient, err := rp.oauth.ServiceClient( 1443 r, 1444 oauth.WithService(f.Spindle), 1445 oauth.WithLxm(tangled.RepoListSecretsNSID), 1446 oauth.WithExp(60), 1447 oauth.WithDev(rp.config.Core.Dev), 1448 ); err != nil { 1449 log.Println("failed to create spindle client", err) 1450 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1451 log.Println("failed to fetch secrets", err) 1452 } else { 1453 secrets = resp.Secrets 1454 } 1455 } 1456 1457 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 1458 return strings.Compare(a.Key, b.Key) 1459 }) 1460 1461 var dids []string 1462 for _, s := range secrets { 1463 dids = append(dids, s.CreatedBy) 1464 } 1465 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 1466 1467 // convert to a more manageable form 1468 var niceSecret []map[string]any 1469 for id, s := range secrets { 1470 when, _ := time.Parse(time.RFC3339, s.CreatedAt) 1471 niceSecret = append(niceSecret, map[string]any{ 1472 "Id": id, 1473 "Key": s.Key, 1474 "CreatedAt": when, 1475 "CreatedBy": resolvedIdents[id].Handle.String(), 1476 }) 1477 } 1478 1479 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 1480 LoggedInUser: user, 1481 RepoInfo: f.RepoInfo(user), 1482 Tabs: settingsTabs, 1483 Tab: "pipelines", 1484 Spindles: spindles, 1485 CurrentSpindle: f.Spindle, 1486 Secrets: niceSecret, 1487 }) 1488} 1489 1490func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1491 ref := chi.URLParam(r, "ref") 1492 ref, _ = url.PathUnescape(ref) 1493 1494 user := rp.oauth.GetUser(r) 1495 f, err := rp.repoResolver.Resolve(r) 1496 if err != nil { 1497 log.Printf("failed to resolve source repo: %v", err) 1498 return 1499 } 1500 1501 switch r.Method { 1502 case http.MethodPost: 1503 client, err := rp.oauth.ServiceClient( 1504 r, 1505 oauth.WithService(f.Knot), 1506 oauth.WithLxm(tangled.RepoForkSyncNSID), 1507 oauth.WithDev(rp.config.Core.Dev), 1508 ) 1509 if err != nil { 1510 rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1511 return 1512 } 1513 1514 repoInfo := f.RepoInfo(user) 1515 if repoInfo.Source == nil { 1516 rp.pages.Notice(w, "repo", "This repository is not a fork.") 1517 return 1518 } 1519 1520 err = tangled.RepoForkSync( 1521 r.Context(), 1522 client, 1523 &tangled.RepoForkSync_Input{ 1524 Did: user.Did, 1525 Name: f.Name, 1526 Source: repoInfo.Source.RepoAt().String(), 1527 Branch: ref, 1528 }, 1529 ) 1530 if err := xrpcclient.HandleXrpcErr(err); err != nil { 1531 rp.pages.Notice(w, "repo", err.Error()) 1532 return 1533 } 1534 1535 rp.pages.HxRefresh(w) 1536 return 1537 } 1538} 1539 1540func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 1541 user := rp.oauth.GetUser(r) 1542 f, err := rp.repoResolver.Resolve(r) 1543 if err != nil { 1544 log.Printf("failed to resolve source repo: %v", err) 1545 return 1546 } 1547 1548 switch r.Method { 1549 case http.MethodGet: 1550 user := rp.oauth.GetUser(r) 1551 knots, err := rp.enforcer.GetKnotsForUser(user.Did) 1552 if err != nil { 1553 rp.pages.Notice(w, "repo", "Invalid user account.") 1554 return 1555 } 1556 1557 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1558 LoggedInUser: user, 1559 Knots: knots, 1560 RepoInfo: f.RepoInfo(user), 1561 }) 1562 1563 case http.MethodPost: 1564 l := rp.logger.With("handler", "ForkRepo") 1565 1566 targetKnot := r.FormValue("knot") 1567 if targetKnot == "" { 1568 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") 1569 return 1570 } 1571 l = l.With("targetKnot", targetKnot) 1572 1573 ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1574 if err != nil || !ok { 1575 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1576 return 1577 } 1578 1579 // choose a name for a fork 1580 forkName := f.Name 1581 // this check is *only* to see if the forked repo name already exists 1582 // in the user's account. 1583 existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1584 if err != nil { 1585 if errors.Is(err, sql.ErrNoRows) { 1586 // no existing repo with this name found, we can use the name as is 1587 } else { 1588 log.Println("error fetching existing repo from db", err) 1589 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1590 return 1591 } 1592 } else if existingRepo != nil { 1593 // repo with this name already exists, append random string 1594 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1595 } 1596 l = l.With("forkName", forkName) 1597 1598 uri := "https" 1599 if rp.config.Core.Dev { 1600 uri = "http" 1601 } 1602 1603 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1604 l = l.With("cloneUrl", forkSourceUrl) 1605 1606 sourceAt := f.RepoAt().String() 1607 1608 // create an atproto record for this fork 1609 rkey := tid.TID() 1610 repo := &db.Repo{ 1611 Did: user.Did, 1612 Name: forkName, 1613 Knot: targetKnot, 1614 Rkey: rkey, 1615 Source: sourceAt, 1616 Description: existingRepo.Description, 1617 Created: time.Now(), 1618 } 1619 record := repo.AsRecord() 1620 1621 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1622 if err != nil { 1623 l.Error("failed to create xrpcclient", "err", err) 1624 rp.pages.Notice(w, "repo", "Failed to fork repository.") 1625 return 1626 } 1627 1628 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1629 Collection: tangled.RepoNSID, 1630 Repo: user.Did, 1631 Rkey: rkey, 1632 Record: &lexutil.LexiconTypeDecoder{ 1633 Val: &record, 1634 }, 1635 }) 1636 if err != nil { 1637 l.Error("failed to write to PDS", "err", err) 1638 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1639 return 1640 } 1641 1642 aturi := atresp.Uri 1643 l = l.With("aturi", aturi) 1644 l.Info("wrote to PDS") 1645 1646 tx, err := rp.db.BeginTx(r.Context(), nil) 1647 if err != nil { 1648 l.Info("txn failed", "err", err) 1649 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1650 return 1651 } 1652 1653 // The rollback function reverts a few things on failure: 1654 // - the pending txn 1655 // - the ACLs 1656 // - the atproto record created 1657 rollback := func() { 1658 err1 := tx.Rollback() 1659 err2 := rp.enforcer.E.LoadPolicy() 1660 err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 1661 1662 // ignore txn complete errors, this is okay 1663 if errors.Is(err1, sql.ErrTxDone) { 1664 err1 = nil 1665 } 1666 1667 if errs := errors.Join(err1, err2, err3); errs != nil { 1668 l.Error("failed to rollback changes", "errs", errs) 1669 return 1670 } 1671 } 1672 defer rollback() 1673 1674 client, err := rp.oauth.ServiceClient( 1675 r, 1676 oauth.WithService(targetKnot), 1677 oauth.WithLxm(tangled.RepoCreateNSID), 1678 oauth.WithDev(rp.config.Core.Dev), 1679 ) 1680 if err != nil { 1681 l.Error("could not create service client", "err", err) 1682 rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1683 return 1684 } 1685 1686 err = tangled.RepoCreate( 1687 r.Context(), 1688 client, 1689 &tangled.RepoCreate_Input{ 1690 Rkey: rkey, 1691 Source: &forkSourceUrl, 1692 }, 1693 ) 1694 if err := xrpcclient.HandleXrpcErr(err); err != nil { 1695 rp.pages.Notice(w, "repo", err.Error()) 1696 return 1697 } 1698 1699 err = db.AddRepo(tx, repo) 1700 if err != nil { 1701 log.Println(err) 1702 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1703 return 1704 } 1705 1706 // acls 1707 p, _ := securejoin.SecureJoin(user.Did, forkName) 1708 err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1709 if err != nil { 1710 log.Println(err) 1711 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1712 return 1713 } 1714 1715 err = tx.Commit() 1716 if err != nil { 1717 log.Println("failed to commit changes", err) 1718 http.Error(w, err.Error(), http.StatusInternalServerError) 1719 return 1720 } 1721 1722 err = rp.enforcer.E.SavePolicy() 1723 if err != nil { 1724 log.Println("failed to update ACLs", err) 1725 http.Error(w, err.Error(), http.StatusInternalServerError) 1726 return 1727 } 1728 1729 // reset the ATURI because the transaction completed successfully 1730 aturi = "" 1731 1732 rp.notifier.NewRepo(r.Context(), repo) 1733 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1734 } 1735} 1736 1737// this is used to rollback changes made to the PDS 1738// 1739// it is a no-op if the provided ATURI is empty 1740func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 1741 if aturi == "" { 1742 return nil 1743 } 1744 1745 parsed := syntax.ATURI(aturi) 1746 1747 collection := parsed.Collection().String() 1748 repo := parsed.Authority().String() 1749 rkey := parsed.RecordKey().String() 1750 1751 _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 1752 Collection: collection, 1753 Repo: repo, 1754 Rkey: rkey, 1755 }) 1756 return err 1757} 1758 1759func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1760 user := rp.oauth.GetUser(r) 1761 f, err := rp.repoResolver.Resolve(r) 1762 if err != nil { 1763 log.Println("failed to get repo and knot", err) 1764 return 1765 } 1766 1767 scheme := "http" 1768 if !rp.config.Core.Dev { 1769 scheme = "https" 1770 } 1771 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1772 xrpcc := &indigoxrpc.Client{ 1773 Host: host, 1774 } 1775 1776 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1777 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1778 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1779 log.Println("failed to call XRPC repo.branches", xrpcerr) 1780 rp.pages.Error503(w) 1781 return 1782 } 1783 1784 var branchResult types.RepoBranchesResponse 1785 if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 1786 log.Println("failed to decode XRPC branches response", err) 1787 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1788 return 1789 } 1790 branches := branchResult.Branches 1791 1792 sortBranches(branches) 1793 1794 var defaultBranch string 1795 for _, b := range branches { 1796 if b.IsDefault { 1797 defaultBranch = b.Name 1798 } 1799 } 1800 1801 base := defaultBranch 1802 head := defaultBranch 1803 1804 params := r.URL.Query() 1805 queryBase := params.Get("base") 1806 queryHead := params.Get("head") 1807 if queryBase != "" { 1808 base = queryBase 1809 } 1810 if queryHead != "" { 1811 head = queryHead 1812 } 1813 1814 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1815 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1816 log.Println("failed to call XRPC repo.tags", xrpcerr) 1817 rp.pages.Error503(w) 1818 return 1819 } 1820 1821 var tags types.RepoTagsResponse 1822 if err := json.Unmarshal(tagBytes, &tags); err != nil { 1823 log.Println("failed to decode XRPC tags response", err) 1824 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1825 return 1826 } 1827 1828 repoinfo := f.RepoInfo(user) 1829 1830 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 1831 LoggedInUser: user, 1832 RepoInfo: repoinfo, 1833 Branches: branches, 1834 Tags: tags.Tags, 1835 Base: base, 1836 Head: head, 1837 }) 1838} 1839 1840func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 1841 user := rp.oauth.GetUser(r) 1842 f, err := rp.repoResolver.Resolve(r) 1843 if err != nil { 1844 log.Println("failed to get repo and knot", err) 1845 return 1846 } 1847 1848 var diffOpts types.DiffOpts 1849 if d := r.URL.Query().Get("diff"); d == "split" { 1850 diffOpts.Split = true 1851 } 1852 1853 // if user is navigating to one of 1854 // /compare/{base}/{head} 1855 // /compare/{base}...{head} 1856 base := chi.URLParam(r, "base") 1857 head := chi.URLParam(r, "head") 1858 if base == "" && head == "" { 1859 rest := chi.URLParam(r, "*") // master...feature/xyz 1860 parts := strings.SplitN(rest, "...", 2) 1861 if len(parts) == 2 { 1862 base = parts[0] 1863 head = parts[1] 1864 } 1865 } 1866 1867 base, _ = url.PathUnescape(base) 1868 head, _ = url.PathUnescape(head) 1869 1870 if base == "" || head == "" { 1871 log.Printf("invalid comparison") 1872 rp.pages.Error404(w) 1873 return 1874 } 1875 1876 scheme := "http" 1877 if !rp.config.Core.Dev { 1878 scheme = "https" 1879 } 1880 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1881 xrpcc := &indigoxrpc.Client{ 1882 Host: host, 1883 } 1884 1885 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1886 1887 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1888 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1889 log.Println("failed to call XRPC repo.branches", xrpcerr) 1890 rp.pages.Error503(w) 1891 return 1892 } 1893 1894 var branches types.RepoBranchesResponse 1895 if err := json.Unmarshal(branchBytes, &branches); err != nil { 1896 log.Println("failed to decode XRPC branches response", err) 1897 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1898 return 1899 } 1900 1901 tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1902 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1903 log.Println("failed to call XRPC repo.tags", xrpcerr) 1904 rp.pages.Error503(w) 1905 return 1906 } 1907 1908 var tags types.RepoTagsResponse 1909 if err := json.Unmarshal(tagBytes, &tags); err != nil { 1910 log.Println("failed to decode XRPC tags response", err) 1911 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1912 return 1913 } 1914 1915 compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 1916 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1917 log.Println("failed to call XRPC repo.compare", xrpcerr) 1918 rp.pages.Error503(w) 1919 return 1920 } 1921 1922 var formatPatch types.RepoFormatPatchResponse 1923 if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 1924 log.Println("failed to decode XRPC compare response", err) 1925 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1926 return 1927 } 1928 1929 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1930 1931 repoinfo := f.RepoInfo(user) 1932 1933 rp.pages.RepoCompare(w, pages.RepoCompareParams{ 1934 LoggedInUser: user, 1935 RepoInfo: repoinfo, 1936 Branches: branches.Branches, 1937 Tags: tags.Tags, 1938 Base: base, 1939 Head: head, 1940 Diff: &diff, 1941 DiffOpts: diffOpts, 1942 }) 1943 1944}