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