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