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