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