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