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