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