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