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