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