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