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