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