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