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