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