forked from tangled.org/core
this repo has no description
at knot-xrpc 44 kB view raw
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 "tangled.sh/tangled.sh/core/api/tangled" 23 "tangled.sh/tangled.sh/core/appview/commitverify" 24 "tangled.sh/tangled.sh/core/appview/config" 25 "tangled.sh/tangled.sh/core/appview/db" 26 "tangled.sh/tangled.sh/core/appview/notify" 27 "tangled.sh/tangled.sh/core/appview/oauth" 28 "tangled.sh/tangled.sh/core/appview/pages" 29 "tangled.sh/tangled.sh/core/appview/pages/markup" 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 "tangled.sh/tangled.sh/core/eventconsumer" 32 "tangled.sh/tangled.sh/core/idresolver" 33 "tangled.sh/tangled.sh/core/knotclient" 34 "tangled.sh/tangled.sh/core/patchutil" 35 "tangled.sh/tangled.sh/core/rbac" 36 "tangled.sh/tangled.sh/core/tid" 37 "tangled.sh/tangled.sh/core/types" 38 xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 39 "tangled.sh/tangled.sh/core/xrpc/serviceauth" 40 41 securejoin "github.com/cyphar/filepath-securejoin" 42 "github.com/go-chi/chi/v5" 43 "github.com/go-git/go-git/v5/plumbing" 44 45 "github.com/bluesky-social/indigo/atproto/syntax" 46) 47 48type Repo struct { 49 repoResolver *reporesolver.RepoResolver 50 idResolver *idresolver.Resolver 51 config *config.Config 52 oauth *oauth.OAuth 53 pages *pages.Pages 54 spindlestream *eventconsumer.Consumer 55 db *db.DB 56 enforcer *rbac.Enforcer 57 notifier notify.Notifier 58 logger *slog.Logger 59 serviceAuth *serviceauth.ServiceAuth 60} 61 62func New( 63 oauth *oauth.OAuth, 64 repoResolver *reporesolver.RepoResolver, 65 pages *pages.Pages, 66 spindlestream *eventconsumer.Consumer, 67 idResolver *idresolver.Resolver, 68 db *db.DB, 69 config *config.Config, 70 notifier notify.Notifier, 71 enforcer *rbac.Enforcer, 72 logger *slog.Logger, 73) *Repo { 74 return &Repo{oauth: oauth, 75 repoResolver: repoResolver, 76 pages: pages, 77 idResolver: idResolver, 78 config: config, 79 spindlestream: spindlestream, 80 db: db, 81 notifier: notifier, 82 enforcer: enforcer, 83 logger: logger, 84 } 85} 86 87func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 88 refParam := chi.URLParam(r, "ref") 89 f, err := rp.repoResolver.Resolve(r) 90 if err != nil { 91 log.Println("failed to get repo and knot", err) 92 return 93 } 94 95 var uri string 96 if rp.config.Core.Dev { 97 uri = "http" 98 } else { 99 uri = "https" 100 } 101 url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.RepoName, url.PathEscape(refParam)) 102 103 http.Redirect(w, r, url, http.StatusFound) 104} 105 106func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 107 f, err := rp.repoResolver.Resolve(r) 108 if err != nil { 109 log.Println("failed to fully resolve repo", err) 110 return 111 } 112 113 page := 1 114 if r.URL.Query().Get("page") != "" { 115 page, err = strconv.Atoi(r.URL.Query().Get("page")) 116 if err != nil { 117 page = 1 118 } 119 } 120 121 ref := chi.URLParam(r, "ref") 122 123 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 124 if err != nil { 125 log.Println("failed to create unsigned client", err) 126 return 127 } 128 129 repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 130 if err != nil { 131 log.Println("failed to reach knotserver", err) 132 return 133 } 134 135 tagResult, err := us.Tags(f.OwnerDid(), f.RepoName) 136 if err != nil { 137 log.Println("failed to reach knotserver", err) 138 return 139 } 140 141 tagMap := make(map[string][]string) 142 for _, tag := range tagResult.Tags { 143 hash := tag.Hash 144 if tag.Tag != nil { 145 hash = tag.Tag.Target.String() 146 } 147 tagMap[hash] = append(tagMap[hash], tag.Name) 148 } 149 150 branchResult, err := us.Branches(f.OwnerDid(), f.RepoName) 151 if err != nil { 152 log.Println("failed to reach knotserver", err) 153 return 154 } 155 156 for _, branch := range branchResult.Branches { 157 hash := branch.Hash 158 tagMap[hash] = append(tagMap[hash], branch.Name) 159 } 160 161 user := rp.oauth.GetUser(r) 162 163 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) 164 if err != nil { 165 log.Println("failed to fetch email to did mapping", err) 166 } 167 168 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits) 169 if err != nil { 170 log.Println(err) 171 } 172 173 repoInfo := f.RepoInfo(user) 174 175 var shas []string 176 for _, c := range repolog.Commits { 177 shas = append(shas, c.Hash.String()) 178 } 179 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 180 if err != nil { 181 log.Println(err) 182 // non-fatal 183 } 184 185 rp.pages.RepoLog(w, pages.RepoLogParams{ 186 LoggedInUser: user, 187 TagMap: tagMap, 188 RepoInfo: repoInfo, 189 RepoLogResponse: *repolog, 190 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 191 VerifiedCommits: vc, 192 Pipelines: pipelines, 193 }) 194} 195 196func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 197 f, err := rp.repoResolver.Resolve(r) 198 if err != nil { 199 log.Println("failed to get repo and knot", err) 200 w.WriteHeader(http.StatusBadRequest) 201 return 202 } 203 204 user := rp.oauth.GetUser(r) 205 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 206 RepoInfo: f.RepoInfo(user), 207 }) 208} 209 210func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 211 f, err := rp.repoResolver.Resolve(r) 212 if err != nil { 213 log.Println("failed to get repo and knot", err) 214 w.WriteHeader(http.StatusBadRequest) 215 return 216 } 217 218 repoAt := f.RepoAt 219 rkey := repoAt.RecordKey().String() 220 if rkey == "" { 221 log.Println("invalid aturi for repo", err) 222 w.WriteHeader(http.StatusInternalServerError) 223 return 224 } 225 226 user := rp.oauth.GetUser(r) 227 228 switch r.Method { 229 case http.MethodGet: 230 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 231 RepoInfo: f.RepoInfo(user), 232 }) 233 return 234 case http.MethodPut: 235 newDescription := r.FormValue("description") 236 client, err := rp.oauth.AuthorizedClient(r) 237 if err != nil { 238 log.Println("failed to get client") 239 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 240 return 241 } 242 243 // optimistic update 244 err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 245 if err != nil { 246 log.Println("failed to perferom update-description query", err) 247 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 248 return 249 } 250 251 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 252 // 253 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 254 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 255 if err != nil { 256 // failed to get record 257 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 258 return 259 } 260 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 261 Collection: tangled.RepoNSID, 262 Repo: user.Did, 263 Rkey: rkey, 264 SwapRecord: ex.Cid, 265 Record: &lexutil.LexiconTypeDecoder{ 266 Val: &tangled.Repo{ 267 Knot: f.Knot, 268 Name: f.RepoName, 269 Owner: user.Did, 270 CreatedAt: f.CreatedAt, 271 Description: &newDescription, 272 Spindle: &f.Spindle, 273 }, 274 }, 275 }) 276 277 if err != nil { 278 log.Println("failed to perferom update-description query", err) 279 // failed to get record 280 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 281 return 282 } 283 284 newRepoInfo := f.RepoInfo(user) 285 newRepoInfo.Description = newDescription 286 287 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 288 RepoInfo: newRepoInfo, 289 }) 290 return 291 } 292} 293 294func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 295 f, err := rp.repoResolver.Resolve(r) 296 if err != nil { 297 log.Println("failed to fully resolve repo", err) 298 return 299 } 300 ref := chi.URLParam(r, "ref") 301 protocol := "http" 302 if !rp.config.Core.Dev { 303 protocol = "https" 304 } 305 306 var diffOpts types.DiffOpts 307 if d := r.URL.Query().Get("diff"); d == "split" { 308 diffOpts.Split = true 309 } 310 311 if !plumbing.IsHash(ref) { 312 rp.pages.Error404(w) 313 return 314 } 315 316 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 317 if err != nil { 318 log.Println("failed to reach knotserver", err) 319 return 320 } 321 322 body, err := io.ReadAll(resp.Body) 323 if err != nil { 324 log.Printf("Error reading response body: %v", err) 325 return 326 } 327 328 var result types.RepoCommitResponse 329 err = json.Unmarshal(body, &result) 330 if err != nil { 331 log.Println("failed to parse response:", err) 332 return 333 } 334 335 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 336 if err != nil { 337 log.Println("failed to get email to did mapping:", err) 338 } 339 340 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 341 if err != nil { 342 log.Println(err) 343 } 344 345 user := rp.oauth.GetUser(r) 346 repoInfo := f.RepoInfo(user) 347 pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 348 if err != nil { 349 log.Println(err) 350 // non-fatal 351 } 352 var pipeline *db.Pipeline 353 if p, ok := pipelines[result.Diff.Commit.This]; ok { 354 pipeline = &p 355 } 356 357 rp.pages.RepoCommit(w, pages.RepoCommitParams{ 358 LoggedInUser: user, 359 RepoInfo: f.RepoInfo(user), 360 RepoCommitResponse: result, 361 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 362 VerifiedCommit: vc, 363 Pipeline: pipeline, 364 DiffOpts: diffOpts, 365 }) 366} 367 368func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 369 f, err := rp.repoResolver.Resolve(r) 370 if err != nil { 371 log.Println("failed to fully resolve repo", err) 372 return 373 } 374 375 ref := chi.URLParam(r, "ref") 376 treePath := chi.URLParam(r, "*") 377 protocol := "http" 378 if !rp.config.Core.Dev { 379 protocol = "https" 380 } 381 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 382 if err != nil { 383 log.Println("failed to reach knotserver", err) 384 return 385 } 386 387 body, err := io.ReadAll(resp.Body) 388 if err != nil { 389 log.Printf("Error reading response body: %v", err) 390 return 391 } 392 393 var result types.RepoTreeResponse 394 err = json.Unmarshal(body, &result) 395 if err != nil { 396 log.Println("failed to parse response:", err) 397 return 398 } 399 400 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 401 // so we can safely redirect to the "parent" (which is the same file). 402 unescapedTreePath, _ := url.PathUnescape(treePath) 403 if len(result.Files) == 0 && result.Parent == unescapedTreePath { 404 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 405 return 406 } 407 408 user := rp.oauth.GetUser(r) 409 410 var breadcrumbs [][]string 411 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 412 if treePath != "" { 413 for idx, elem := range strings.Split(treePath, "/") { 414 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 415 } 416 } 417 418 sortFiles(result.Files) 419 420 rp.pages.RepoTree(w, pages.RepoTreeParams{ 421 LoggedInUser: user, 422 BreadCrumbs: breadcrumbs, 423 TreePath: treePath, 424 RepoInfo: f.RepoInfo(user), 425 RepoTreeResponse: result, 426 }) 427} 428 429func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 430 f, err := rp.repoResolver.Resolve(r) 431 if err != nil { 432 log.Println("failed to get repo and knot", err) 433 return 434 } 435 436 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 437 if err != nil { 438 log.Println("failed to create unsigned client", err) 439 return 440 } 441 442 result, err := us.Tags(f.OwnerDid(), f.RepoName) 443 if err != nil { 444 log.Println("failed to reach knotserver", err) 445 return 446 } 447 448 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt)) 449 if err != nil { 450 log.Println("failed grab artifacts", err) 451 return 452 } 453 454 // convert artifacts to map for easy UI building 455 artifactMap := make(map[plumbing.Hash][]db.Artifact) 456 for _, a := range artifacts { 457 artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 458 } 459 460 var danglingArtifacts []db.Artifact 461 for _, a := range artifacts { 462 found := false 463 for _, t := range result.Tags { 464 if t.Tag != nil { 465 if t.Tag.Hash == a.Tag { 466 found = true 467 } 468 } 469 } 470 471 if !found { 472 danglingArtifacts = append(danglingArtifacts, a) 473 } 474 } 475 476 user := rp.oauth.GetUser(r) 477 rp.pages.RepoTags(w, pages.RepoTagsParams{ 478 LoggedInUser: user, 479 RepoInfo: f.RepoInfo(user), 480 RepoTagsResponse: *result, 481 ArtifactMap: artifactMap, 482 DanglingArtifacts: danglingArtifacts, 483 }) 484} 485 486func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 487 f, err := rp.repoResolver.Resolve(r) 488 if err != nil { 489 log.Println("failed to get repo and knot", err) 490 return 491 } 492 493 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 494 if err != nil { 495 log.Println("failed to create unsigned client", err) 496 return 497 } 498 499 result, err := us.Branches(f.OwnerDid(), f.RepoName) 500 if err != nil { 501 log.Println("failed to reach knotserver", err) 502 return 503 } 504 505 sortBranches(result.Branches) 506 507 user := rp.oauth.GetUser(r) 508 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 509 LoggedInUser: user, 510 RepoInfo: f.RepoInfo(user), 511 RepoBranchesResponse: *result, 512 }) 513} 514 515func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 516 f, err := rp.repoResolver.Resolve(r) 517 if err != nil { 518 log.Println("failed to get repo and knot", err) 519 return 520 } 521 522 ref := chi.URLParam(r, "ref") 523 filePath := chi.URLParam(r, "*") 524 protocol := "http" 525 if !rp.config.Core.Dev { 526 protocol = "https" 527 } 528 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 529 if err != nil { 530 log.Println("failed to reach knotserver", err) 531 return 532 } 533 534 body, err := io.ReadAll(resp.Body) 535 if err != nil { 536 log.Printf("Error reading response body: %v", err) 537 return 538 } 539 540 var result types.RepoBlobResponse 541 err = json.Unmarshal(body, &result) 542 if err != nil { 543 log.Println("failed to parse response:", err) 544 return 545 } 546 547 var breadcrumbs [][]string 548 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 549 if filePath != "" { 550 for idx, elem := range strings.Split(filePath, "/") { 551 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 552 } 553 } 554 555 showRendered := false 556 renderToggle := false 557 558 if markup.GetFormat(result.Path) == markup.FormatMarkdown { 559 renderToggle = true 560 showRendered = r.URL.Query().Get("code") != "true" 561 } 562 563 var unsupported bool 564 var isImage bool 565 var isVideo bool 566 var contentSrc string 567 568 if result.IsBinary { 569 ext := strings.ToLower(filepath.Ext(result.Path)) 570 switch ext { 571 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 572 isImage = true 573 case ".mp4", ".webm", ".ogg", ".mov", ".avi": 574 isVideo = true 575 default: 576 unsupported = true 577 } 578 579 // fetch the actual binary content like in RepoBlobRaw 580 581 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 582 contentSrc = blobURL 583 if !rp.config.Core.Dev { 584 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 585 } 586 } 587 588 user := rp.oauth.GetUser(r) 589 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 590 LoggedInUser: user, 591 RepoInfo: f.RepoInfo(user), 592 RepoBlobResponse: result, 593 BreadCrumbs: breadcrumbs, 594 ShowRendered: showRendered, 595 RenderToggle: renderToggle, 596 Unsupported: unsupported, 597 IsImage: isImage, 598 IsVideo: isVideo, 599 ContentSrc: contentSrc, 600 }) 601} 602 603func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 604 f, err := rp.repoResolver.Resolve(r) 605 if err != nil { 606 log.Println("failed to get repo and knot", err) 607 w.WriteHeader(http.StatusBadRequest) 608 return 609 } 610 611 ref := chi.URLParam(r, "ref") 612 filePath := chi.URLParam(r, "*") 613 614 protocol := "http" 615 if !rp.config.Core.Dev { 616 protocol = "https" 617 } 618 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 619 resp, err := http.Get(blobURL) 620 if err != nil { 621 log.Println("failed to reach knotserver:", err) 622 rp.pages.Error503(w) 623 return 624 } 625 defer resp.Body.Close() 626 627 if resp.StatusCode != http.StatusOK { 628 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 629 w.WriteHeader(resp.StatusCode) 630 _, _ = io.Copy(w, resp.Body) 631 return 632 } 633 634 contentType := resp.Header.Get("Content-Type") 635 body, err := io.ReadAll(resp.Body) 636 if err != nil { 637 log.Printf("error reading response body from knotserver: %v", err) 638 w.WriteHeader(http.StatusInternalServerError) 639 return 640 } 641 642 if strings.Contains(contentType, "text/plain") { 643 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 644 w.Write(body) 645 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 646 w.Header().Set("Content-Type", contentType) 647 w.Write(body) 648 } else { 649 w.WriteHeader(http.StatusUnsupportedMediaType) 650 w.Write([]byte("unsupported content type")) 651 return 652 } 653} 654 655// modify the spindle configured for this repo 656func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 657 user := rp.oauth.GetUser(r) 658 l := rp.logger.With("handler", "EditSpindle") 659 l = l.With("did", user.Did) 660 l = l.With("handle", user.Handle) 661 662 errorId := "operation-error" 663 fail := func(msg string, err error) { 664 l.Error(msg, "err", err) 665 rp.pages.Notice(w, errorId, msg) 666 } 667 668 f, err := rp.repoResolver.Resolve(r) 669 if err != nil { 670 fail("Failed to resolve repo. Try again later", err) 671 return 672 } 673 674 repoAt := f.RepoAt 675 rkey := repoAt.RecordKey().String() 676 if rkey == "" { 677 fail("Failed to resolve repo. Try again later", err) 678 return 679 } 680 681 newSpindle := r.FormValue("spindle") 682 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 683 client, err := rp.oauth.AuthorizedClient(r) 684 if err != nil { 685 fail("Failed to authorize. Try again later.", err) 686 return 687 } 688 689 if !removingSpindle { 690 // ensure that this is a valid spindle for this user 691 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 692 if err != nil { 693 fail("Failed to find spindles. Try again later.", err) 694 return 695 } 696 697 if !slices.Contains(validSpindles, newSpindle) { 698 fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 699 return 700 } 701 } 702 703 spindlePtr := &newSpindle 704 if removingSpindle { 705 spindlePtr = nil 706 } 707 708 // optimistic update 709 err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 710 if err != nil { 711 fail("Failed to update spindle. Try again later.", err) 712 return 713 } 714 715 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 716 if err != nil { 717 fail("Failed to update spindle, no record found on PDS.", err) 718 return 719 } 720 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 721 Collection: tangled.RepoNSID, 722 Repo: user.Did, 723 Rkey: rkey, 724 SwapRecord: ex.Cid, 725 Record: &lexutil.LexiconTypeDecoder{ 726 Val: &tangled.Repo{ 727 Knot: f.Knot, 728 Name: f.RepoName, 729 Owner: user.Did, 730 CreatedAt: f.CreatedAt, 731 Description: &f.Description, 732 Spindle: spindlePtr, 733 }, 734 }, 735 }) 736 737 if err != nil { 738 fail("Failed to update spindle, unable to save to PDS.", err) 739 return 740 } 741 742 if !removingSpindle { 743 // add this spindle to spindle stream 744 rp.spindlestream.AddSource( 745 context.Background(), 746 eventconsumer.NewSpindleSource(newSpindle), 747 ) 748 } 749 750 rp.pages.HxRefresh(w) 751} 752 753func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 754 user := rp.oauth.GetUser(r) 755 l := rp.logger.With("handler", "AddCollaborator") 756 l = l.With("did", user.Did) 757 l = l.With("handle", user.Handle) 758 759 f, err := rp.repoResolver.Resolve(r) 760 if err != nil { 761 l.Error("failed to get repo and knot", "err", err) 762 return 763 } 764 765 errorId := "add-collaborator-error" 766 fail := func(msg string, err error) { 767 l.Error(msg, "err", err) 768 rp.pages.Notice(w, errorId, msg) 769 } 770 771 collaborator := r.FormValue("collaborator") 772 if collaborator == "" { 773 fail("Invalid form.", nil) 774 return 775 } 776 777 // remove a single leading `@`, to make @handle work with ResolveIdent 778 collaborator = strings.TrimPrefix(collaborator, "@") 779 780 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 781 if err != nil { 782 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) 783 return 784 } 785 786 if collaboratorIdent.DID.String() == user.Did { 787 fail("You seem to be adding yourself as a collaborator.", nil) 788 return 789 } 790 l = l.With("collaborator", collaboratorIdent.Handle) 791 l = l.With("knot", f.Knot) 792 793 // announce this relation into the firehose, store into owners' pds 794 client, err := rp.oauth.AuthorizedClient(r) 795 if err != nil { 796 fail("Failed to write to PDS.", err) 797 return 798 } 799 800 // emit a record 801 currentUser := rp.oauth.GetUser(r) 802 rkey := tid.TID() 803 createdAt := time.Now() 804 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 805 Collection: tangled.RepoCollaboratorNSID, 806 Repo: currentUser.Did, 807 Rkey: rkey, 808 Record: &lexutil.LexiconTypeDecoder{ 809 Val: &tangled.RepoCollaborator{ 810 Subject: collaboratorIdent.DID.String(), 811 Repo: string(f.RepoAt), 812 CreatedAt: createdAt.Format(time.RFC3339), 813 }}, 814 }) 815 // invalid record 816 if err != nil { 817 fail("Failed to write record to PDS.", err) 818 return 819 } 820 l = l.With("at-uri", resp.Uri) 821 l.Info("wrote record to PDS") 822 823 l.Info("adding to knot") 824 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 825 if err != nil { 826 fail("Failed to add to knot.", err) 827 return 828 } 829 830 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 831 if err != nil { 832 fail("Failed to add to knot.", err) 833 return 834 } 835 836 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 837 if err != nil { 838 fail("Knot was unreachable.", err) 839 return 840 } 841 842 if ksResp.StatusCode != http.StatusNoContent { 843 fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 844 return 845 } 846 847 tx, err := rp.db.BeginTx(r.Context(), nil) 848 if err != nil { 849 fail("Failed to add collaborator.", err) 850 return 851 } 852 defer func() { 853 tx.Rollback() 854 err = rp.enforcer.E.LoadPolicy() 855 if err != nil { 856 fail("Failed to add collaborator.", err) 857 } 858 }() 859 860 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 861 if err != nil { 862 fail("Failed to add collaborator permissions.", err) 863 return 864 } 865 866 err = db.AddCollaborator(rp.db, db.Collaborator{ 867 Did: syntax.DID(currentUser.Did), 868 Rkey: rkey, 869 SubjectDid: collaboratorIdent.DID, 870 RepoAt: f.RepoAt, 871 Created: createdAt, 872 }) 873 if err != nil { 874 fail("Failed to add collaborator.", err) 875 return 876 } 877 878 err = tx.Commit() 879 if err != nil { 880 fail("Failed to add collaborator.", err) 881 return 882 } 883 884 err = rp.enforcer.E.SavePolicy() 885 if err != nil { 886 fail("Failed to update collaborator permissions.", err) 887 return 888 } 889 890 rp.pages.HxRefresh(w) 891} 892 893func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 894 user := rp.oauth.GetUser(r) 895 896 f, err := rp.repoResolver.Resolve(r) 897 if err != nil { 898 log.Println("failed to get repo and knot", err) 899 return 900 } 901 902 // remove record from pds 903 xrpcClient, err := rp.oauth.AuthorizedClient(r) 904 if err != nil { 905 log.Println("failed to get authorized client", err) 906 return 907 } 908 repoRkey := f.RepoAt.RecordKey().String() 909 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 910 Collection: tangled.RepoNSID, 911 Repo: user.Did, 912 Rkey: repoRkey, 913 }) 914 if err != nil { 915 log.Printf("failed to delete record: %s", err) 916 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 917 return 918 } 919 log.Println("removed repo record ", f.RepoAt.String()) 920 921 client, err := rp.oauth.ServiceClient( 922 r, 923 oauth.WithService(f.Knot), 924 oauth.WithLxm(tangled.RepoDeleteNSID), 925 oauth.WithDev(rp.config.Core.Dev), 926 ) 927 if err != nil { 928 log.Println("failed to connect to knot server:", err) 929 return 930 } 931 932 err = tangled.RepoDelete( 933 r.Context(), 934 client, 935 &tangled.RepoDelete_Input{ 936 Did: f.OwnerDid(), 937 Name: f.RepoName, 938 }, 939 ) 940 if err != nil { 941 xe, parseErr := xrpcerr.Unmarshal(err.Error()) 942 if parseErr != nil { 943 log.Printf("failed to delete repo from knot %s: %s", f.Knot, err) 944 } else { 945 log.Printf("failed to delete repo from knot %s: %s", f.Knot, xe.Error()) 946 } 947 // Continue anyway since we want to clean up local state 948 } else { 949 log.Println("removed repo from knot ", f.Knot) 950 } 951 952 tx, err := rp.db.BeginTx(r.Context(), nil) 953 if err != nil { 954 log.Println("failed to start tx") 955 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 956 return 957 } 958 defer func() { 959 tx.Rollback() 960 err = rp.enforcer.E.LoadPolicy() 961 if err != nil { 962 log.Println("failed to rollback policies") 963 } 964 }() 965 966 // remove collaborator RBAC 967 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 968 if err != nil { 969 rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 970 return 971 } 972 for _, c := range repoCollaborators { 973 did := c[0] 974 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 975 } 976 log.Println("removed collaborators") 977 978 // remove repo RBAC 979 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 980 if err != nil { 981 rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 982 return 983 } 984 985 // remove repo from db 986 err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 987 if err != nil { 988 rp.pages.Notice(w, "settings-delete", "Failed to update appview") 989 return 990 } 991 log.Println("removed repo from db") 992 993 err = tx.Commit() 994 if err != nil { 995 log.Println("failed to commit changes", err) 996 http.Error(w, err.Error(), http.StatusInternalServerError) 997 return 998 } 999 1000 err = rp.enforcer.E.SavePolicy() 1001 if err != nil { 1002 log.Println("failed to update ACLs", err) 1003 http.Error(w, err.Error(), http.StatusInternalServerError) 1004 return 1005 } 1006 1007 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 1008} 1009 1010func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1011 f, err := rp.repoResolver.Resolve(r) 1012 if err != nil { 1013 log.Println("failed to get repo and knot", err) 1014 return 1015 } 1016 1017 branch := r.FormValue("branch") 1018 if branch == "" { 1019 http.Error(w, "malformed form", http.StatusBadRequest) 1020 return 1021 } 1022 1023 client, err := rp.oauth.ServiceClient( 1024 r, 1025 oauth.WithService(f.Knot), 1026 oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1027 oauth.WithDev(rp.config.Core.Dev), 1028 ) 1029 if err != nil { 1030 log.Println("failed to connect to knot server:", err) 1031 rp.pages.Notice(w, "repo-settings", "Failed to connect to knot server.") 1032 return 1033 } 1034 1035 err = tangled.RepoSetDefaultBranch( 1036 r.Context(), 1037 client, 1038 &tangled.RepoSetDefaultBranch_Input{ 1039 Repo: fmt.Sprintf("%s/%s", f.OwnerDid(), f.RepoName), 1040 DefaultBranch: branch, 1041 }, 1042 ) 1043 if err != nil { 1044 xe, parseErr := xrpcerr.Unmarshal(err.Error()) 1045 if parseErr != nil { 1046 log.Printf("failed to set default branch: %s", err) 1047 rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 1048 } else { 1049 log.Printf("failed to set default branch: %s", xe.Error()) 1050 rp.pages.Notice(w, "repo-settings", fmt.Sprintf("Failed to set default branch: %s", xe.Message)) 1051 } 1052 return 1053 } 1054 1055 w.Write(fmt.Append(nil, "default branch set to: ", branch)) 1056} 1057 1058func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1059 user := rp.oauth.GetUser(r) 1060 l := rp.logger.With("handler", "Secrets") 1061 l = l.With("handle", user.Handle) 1062 l = l.With("did", user.Did) 1063 1064 f, err := rp.repoResolver.Resolve(r) 1065 if err != nil { 1066 log.Println("failed to get repo and knot", err) 1067 return 1068 } 1069 1070 if f.Spindle == "" { 1071 log.Println("empty spindle cannot add/rm secret", err) 1072 return 1073 } 1074 1075 lxm := tangled.RepoAddSecretNSID 1076 if r.Method == http.MethodDelete { 1077 lxm = tangled.RepoRemoveSecretNSID 1078 } 1079 1080 spindleClient, err := rp.oauth.ServiceClient( 1081 r, 1082 oauth.WithService(f.Spindle), 1083 oauth.WithLxm(lxm), 1084 oauth.WithDev(rp.config.Core.Dev), 1085 ) 1086 if err != nil { 1087 log.Println("failed to create spindle client", err) 1088 return 1089 } 1090 1091 key := r.FormValue("key") 1092 if key == "" { 1093 w.WriteHeader(http.StatusBadRequest) 1094 return 1095 } 1096 1097 switch r.Method { 1098 case http.MethodPut: 1099 errorId := "add-secret-error" 1100 1101 value := r.FormValue("value") 1102 if value == "" { 1103 w.WriteHeader(http.StatusBadRequest) 1104 return 1105 } 1106 1107 err = tangled.RepoAddSecret( 1108 r.Context(), 1109 spindleClient, 1110 &tangled.RepoAddSecret_Input{ 1111 Repo: f.RepoAt.String(), 1112 Key: key, 1113 Value: value, 1114 }, 1115 ) 1116 if err != nil { 1117 l.Error("Failed to add secret.", "err", err) 1118 rp.pages.Notice(w, errorId, "Failed to add secret.") 1119 return 1120 } 1121 1122 case http.MethodDelete: 1123 errorId := "operation-error" 1124 1125 err = tangled.RepoRemoveSecret( 1126 r.Context(), 1127 spindleClient, 1128 &tangled.RepoRemoveSecret_Input{ 1129 Repo: f.RepoAt.String(), 1130 Key: key, 1131 }, 1132 ) 1133 if err != nil { 1134 l.Error("Failed to delete secret.", "err", err) 1135 rp.pages.Notice(w, errorId, "Failed to delete secret.") 1136 return 1137 } 1138 } 1139 1140 rp.pages.HxRefresh(w) 1141} 1142 1143type tab = map[string]any 1144 1145var ( 1146 // would be great to have ordered maps right about now 1147 settingsTabs []tab = []tab{ 1148 {"Name": "general", "Icon": "sliders-horizontal"}, 1149 {"Name": "access", "Icon": "users"}, 1150 {"Name": "pipelines", "Icon": "layers-2"}, 1151 } 1152) 1153 1154func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1155 tabVal := r.URL.Query().Get("tab") 1156 if tabVal == "" { 1157 tabVal = "general" 1158 } 1159 1160 switch tabVal { 1161 case "general": 1162 rp.generalSettings(w, r) 1163 1164 case "access": 1165 rp.accessSettings(w, r) 1166 1167 case "pipelines": 1168 rp.pipelineSettings(w, r) 1169 } 1170 1171 // user := rp.oauth.GetUser(r) 1172 // repoCollaborators, err := f.Collaborators(r.Context()) 1173 // if err != nil { 1174 // log.Println("failed to get collaborators", err) 1175 // } 1176 1177 // isCollaboratorInviteAllowed := false 1178 // if user != nil { 1179 // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1180 // if err == nil && ok { 1181 // isCollaboratorInviteAllowed = true 1182 // } 1183 // } 1184 1185 // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1186 // if err != nil { 1187 // log.Println("failed to create unsigned client", err) 1188 // return 1189 // } 1190 1191 // result, err := us.Branches(f.OwnerDid(), f.RepoName) 1192 // if err != nil { 1193 // log.Println("failed to reach knotserver", err) 1194 // return 1195 // } 1196 1197 // // all spindles that this user is a member of 1198 // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1199 // if err != nil { 1200 // log.Println("failed to fetch spindles", err) 1201 // return 1202 // } 1203 1204 // var secrets []*tangled.RepoListSecrets_Secret 1205 // if f.Spindle != "" { 1206 // if spindleClient, err := rp.oauth.ServiceClient( 1207 // r, 1208 // oauth.WithService(f.Spindle), 1209 // oauth.WithLxm(tangled.RepoListSecretsNSID), 1210 // oauth.WithDev(rp.config.Core.Dev), 1211 // ); err != nil { 1212 // log.Println("failed to create spindle client", err) 1213 // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1214 // log.Println("failed to fetch secrets", err) 1215 // } else { 1216 // secrets = resp.Secrets 1217 // } 1218 // } 1219 1220 // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1221 // LoggedInUser: user, 1222 // RepoInfo: f.RepoInfo(user), 1223 // Collaborators: repoCollaborators, 1224 // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1225 // Branches: result.Branches, 1226 // Spindles: spindles, 1227 // CurrentSpindle: f.Spindle, 1228 // Secrets: secrets, 1229 // }) 1230} 1231 1232func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1233 f, err := rp.repoResolver.Resolve(r) 1234 user := rp.oauth.GetUser(r) 1235 1236 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1237 if err != nil { 1238 log.Println("failed to create unsigned client", err) 1239 return 1240 } 1241 1242 result, err := us.Branches(f.OwnerDid(), f.RepoName) 1243 if err != nil { 1244 log.Println("failed to reach knotserver", err) 1245 return 1246 } 1247 1248 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1249 LoggedInUser: user, 1250 RepoInfo: f.RepoInfo(user), 1251 Branches: result.Branches, 1252 Tabs: settingsTabs, 1253 Tab: "general", 1254 }) 1255} 1256 1257func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1258 f, err := rp.repoResolver.Resolve(r) 1259 user := rp.oauth.GetUser(r) 1260 1261 repoCollaborators, err := f.Collaborators(r.Context()) 1262 if err != nil { 1263 log.Println("failed to get collaborators", err) 1264 } 1265 1266 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1267 LoggedInUser: user, 1268 RepoInfo: f.RepoInfo(user), 1269 Tabs: settingsTabs, 1270 Tab: "access", 1271 Collaborators: repoCollaborators, 1272 }) 1273} 1274 1275func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1276 f, err := rp.repoResolver.Resolve(r) 1277 user := rp.oauth.GetUser(r) 1278 1279 // all spindles that the repo owner is a member of 1280 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1281 if err != nil { 1282 log.Println("failed to fetch spindles", err) 1283 return 1284 } 1285 1286 var secrets []*tangled.RepoListSecrets_Secret 1287 if f.Spindle != "" { 1288 if spindleClient, err := rp.oauth.ServiceClient( 1289 r, 1290 oauth.WithService(f.Spindle), 1291 oauth.WithLxm(tangled.RepoListSecretsNSID), 1292 oauth.WithDev(rp.config.Core.Dev), 1293 ); err != nil { 1294 log.Println("failed to create spindle client", err) 1295 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1296 log.Println("failed to fetch secrets", err) 1297 } else { 1298 secrets = resp.Secrets 1299 } 1300 } 1301 1302 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 1303 return strings.Compare(a.Key, b.Key) 1304 }) 1305 1306 var dids []string 1307 for _, s := range secrets { 1308 dids = append(dids, s.CreatedBy) 1309 } 1310 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 1311 1312 // convert to a more manageable form 1313 var niceSecret []map[string]any 1314 for id, s := range secrets { 1315 when, _ := time.Parse(time.RFC3339, s.CreatedAt) 1316 niceSecret = append(niceSecret, map[string]any{ 1317 "Id": id, 1318 "Key": s.Key, 1319 "CreatedAt": when, 1320 "CreatedBy": resolvedIdents[id].Handle.String(), 1321 }) 1322 } 1323 1324 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 1325 LoggedInUser: user, 1326 RepoInfo: f.RepoInfo(user), 1327 Tabs: settingsTabs, 1328 Tab: "pipelines", 1329 Spindles: spindles, 1330 CurrentSpindle: f.Spindle, 1331 Secrets: niceSecret, 1332 }) 1333} 1334 1335func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1336 user := rp.oauth.GetUser(r) 1337 f, err := rp.repoResolver.Resolve(r) 1338 if err != nil { 1339 log.Printf("failed to resolve source repo: %v", err) 1340 return 1341 } 1342 1343 switch r.Method { 1344 case http.MethodPost: 1345 client, err := rp.oauth.ServiceClient( 1346 r, 1347 oauth.WithService(f.Knot), 1348 oauth.WithLxm(tangled.RepoForkSyncNSID), 1349 oauth.WithDev(rp.config.Core.Dev), 1350 ) 1351 if err != nil { 1352 rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1353 return 1354 } 1355 1356 repoInfo := f.RepoInfo(user) 1357 if repoInfo.Source == nil { 1358 rp.pages.Notice(w, "repo", "This repository is not a fork.") 1359 return 1360 } 1361 1362 err = tangled.RepoForkSync( 1363 r.Context(), 1364 client, 1365 &tangled.RepoForkSync_Input{ 1366 Did: user.Did, 1367 Name: f.RepoName, 1368 Source: repoInfo.Source.AtUri, 1369 Branch: f.Ref, 1370 }, 1371 ) 1372 if err != nil { 1373 xe, parseErr := xrpcerr.Unmarshal(err.Error()) 1374 if parseErr != nil { 1375 log.Printf("failed to sync repository fork: %s", err) 1376 rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1377 } else { 1378 log.Printf("failed to sync repository fork: %s", xe.Error()) 1379 rp.pages.Notice(w, "repo", fmt.Sprintf("Failed to sync repository fork: %s", xe.Message)) 1380 } 1381 return 1382 } 1383 1384 rp.pages.HxRefresh(w) 1385 return 1386 } 1387} 1388 1389func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 1390 user := rp.oauth.GetUser(r) 1391 f, err := rp.repoResolver.Resolve(r) 1392 if err != nil { 1393 log.Printf("failed to resolve source repo: %v", err) 1394 return 1395 } 1396 1397 switch r.Method { 1398 case http.MethodGet: 1399 user := rp.oauth.GetUser(r) 1400 knots, err := rp.enforcer.GetKnotsForUser(user.Did) 1401 if err != nil { 1402 rp.pages.Notice(w, "repo", "Invalid user account.") 1403 return 1404 } 1405 1406 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1407 LoggedInUser: user, 1408 Knots: knots, 1409 RepoInfo: f.RepoInfo(user), 1410 }) 1411 1412 case http.MethodPost: 1413 1414 knot := r.FormValue("knot") 1415 if knot == "" { 1416 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") 1417 return 1418 } 1419 1420 ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1421 if err != nil || !ok { 1422 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1423 return 1424 } 1425 1426 forkName := fmt.Sprintf("%s", f.RepoName) 1427 1428 // this check is *only* to see if the forked repo name already exists 1429 // in the user's account. 1430 existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1431 if err != nil { 1432 if errors.Is(err, sql.ErrNoRows) { 1433 // no existing repo with this name found, we can use the name as is 1434 } else { 1435 log.Println("error fetching existing repo from db", err) 1436 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1437 return 1438 } 1439 } else if existingRepo != nil { 1440 // repo with this name already exists, append random string 1441 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1442 } 1443 client, err := rp.oauth.ServiceClient( 1444 r, 1445 oauth.WithService(knot), 1446 oauth.WithLxm(tangled.RepoForkNSID), 1447 oauth.WithDev(rp.config.Core.Dev), 1448 ) 1449 1450 if err != nil { 1451 log.Printf("error creating client for knot server: %v", err) 1452 rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1453 return 1454 } 1455 1456 var uri string 1457 if rp.config.Core.Dev { 1458 uri = "http" 1459 } else { 1460 uri = "https" 1461 } 1462 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1463 sourceAt := f.RepoAt.String() 1464 1465 rkey := tid.TID() 1466 repo := &db.Repo{ 1467 Did: user.Did, 1468 Name: forkName, 1469 Knot: knot, 1470 Rkey: rkey, 1471 Source: sourceAt, 1472 } 1473 1474 tx, err := rp.db.BeginTx(r.Context(), nil) 1475 if err != nil { 1476 log.Println(err) 1477 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1478 return 1479 } 1480 defer func() { 1481 tx.Rollback() 1482 err = rp.enforcer.E.LoadPolicy() 1483 if err != nil { 1484 log.Println("failed to rollback policies") 1485 } 1486 }() 1487 1488 err = tangled.RepoFork( 1489 r.Context(), 1490 client, 1491 &tangled.RepoFork_Input{ 1492 Did: user.Did, 1493 Name: &forkName, 1494 Source: forkSourceUrl, 1495 }, 1496 ) 1497 1498 if err != nil { 1499 xe, err := xrpcerr.Unmarshal(err.Error()) 1500 if err != nil { 1501 log.Println(err) 1502 rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1503 return 1504 } 1505 1506 log.Println(xe.Error()) 1507 rp.pages.Notice(w, "repo", fmt.Sprintf("Failed to create repository on knot server: %s.", xe.Message)) 1508 return 1509 } 1510 1511 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1512 if err != nil { 1513 log.Println("failed to get authorized client", err) 1514 rp.pages.Notice(w, "repo", "Failed to create repository.") 1515 return 1516 } 1517 1518 createdAt := time.Now().Format(time.RFC3339) 1519 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1520 Collection: tangled.RepoNSID, 1521 Repo: user.Did, 1522 Rkey: rkey, 1523 Record: &lexutil.LexiconTypeDecoder{ 1524 Val: &tangled.Repo{ 1525 Knot: repo.Knot, 1526 Name: repo.Name, 1527 CreatedAt: createdAt, 1528 Owner: user.Did, 1529 Source: &sourceAt, 1530 }}, 1531 }) 1532 if err != nil { 1533 log.Printf("failed to create record: %s", err) 1534 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1535 return 1536 } 1537 log.Println("created repo record: ", atresp.Uri) 1538 1539 repo.AtUri = atresp.Uri 1540 err = db.AddRepo(tx, repo) 1541 if err != nil { 1542 log.Println(err) 1543 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1544 return 1545 } 1546 1547 // acls 1548 p, _ := securejoin.SecureJoin(user.Did, forkName) 1549 err = rp.enforcer.AddRepo(user.Did, knot, p) 1550 if err != nil { 1551 log.Println(err) 1552 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1553 return 1554 } 1555 1556 err = tx.Commit() 1557 if err != nil { 1558 log.Println("failed to commit changes", err) 1559 http.Error(w, err.Error(), http.StatusInternalServerError) 1560 return 1561 } 1562 1563 err = rp.enforcer.E.SavePolicy() 1564 if err != nil { 1565 log.Println("failed to update ACLs", err) 1566 http.Error(w, err.Error(), http.StatusInternalServerError) 1567 return 1568 } 1569 1570 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1571 return 1572 } 1573} 1574 1575func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1576 user := rp.oauth.GetUser(r) 1577 f, err := rp.repoResolver.Resolve(r) 1578 if err != nil { 1579 log.Println("failed to get repo and knot", err) 1580 return 1581 } 1582 1583 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1584 if err != nil { 1585 log.Printf("failed to create unsigned client for %s", f.Knot) 1586 rp.pages.Error503(w) 1587 return 1588 } 1589 1590 result, err := us.Branches(f.OwnerDid(), f.RepoName) 1591 if err != nil { 1592 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1593 log.Println("failed to reach knotserver", err) 1594 return 1595 } 1596 branches := result.Branches 1597 1598 sortBranches(branches) 1599 1600 var defaultBranch string 1601 for _, b := range branches { 1602 if b.IsDefault { 1603 defaultBranch = b.Name 1604 } 1605 } 1606 1607 base := defaultBranch 1608 head := defaultBranch 1609 1610 params := r.URL.Query() 1611 queryBase := params.Get("base") 1612 queryHead := params.Get("head") 1613 if queryBase != "" { 1614 base = queryBase 1615 } 1616 if queryHead != "" { 1617 head = queryHead 1618 } 1619 1620 tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1621 if err != nil { 1622 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1623 log.Println("failed to reach knotserver", err) 1624 return 1625 } 1626 1627 repoinfo := f.RepoInfo(user) 1628 1629 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 1630 LoggedInUser: user, 1631 RepoInfo: repoinfo, 1632 Branches: branches, 1633 Tags: tags.Tags, 1634 Base: base, 1635 Head: head, 1636 }) 1637} 1638 1639func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 1640 user := rp.oauth.GetUser(r) 1641 f, err := rp.repoResolver.Resolve(r) 1642 if err != nil { 1643 log.Println("failed to get repo and knot", err) 1644 return 1645 } 1646 1647 var diffOpts types.DiffOpts 1648 if d := r.URL.Query().Get("diff"); d == "split" { 1649 diffOpts.Split = true 1650 } 1651 1652 // if user is navigating to one of 1653 // /compare/{base}/{head} 1654 // /compare/{base}...{head} 1655 base := chi.URLParam(r, "base") 1656 head := chi.URLParam(r, "head") 1657 if base == "" && head == "" { 1658 rest := chi.URLParam(r, "*") // master...feature/xyz 1659 parts := strings.SplitN(rest, "...", 2) 1660 if len(parts) == 2 { 1661 base = parts[0] 1662 head = parts[1] 1663 } 1664 } 1665 1666 base, _ = url.PathUnescape(base) 1667 head, _ = url.PathUnescape(head) 1668 1669 if base == "" || head == "" { 1670 log.Printf("invalid comparison") 1671 rp.pages.Error404(w) 1672 return 1673 } 1674 1675 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1676 if err != nil { 1677 log.Printf("failed to create unsigned client for %s", f.Knot) 1678 rp.pages.Error503(w) 1679 return 1680 } 1681 1682 branches, err := us.Branches(f.OwnerDid(), f.RepoName) 1683 if err != nil { 1684 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1685 log.Println("failed to reach knotserver", err) 1686 return 1687 } 1688 1689 tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1690 if err != nil { 1691 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1692 log.Println("failed to reach knotserver", err) 1693 return 1694 } 1695 1696 formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 1697 if err != nil { 1698 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1699 log.Println("failed to compare", err) 1700 return 1701 } 1702 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1703 1704 repoinfo := f.RepoInfo(user) 1705 1706 rp.pages.RepoCompare(w, pages.RepoCompareParams{ 1707 LoggedInUser: user, 1708 RepoInfo: repoinfo, 1709 Branches: branches.Branches, 1710 Tags: tags.Tags, 1711 Base: base, 1712 Head: head, 1713 Diff: &diff, 1714 DiffOpts: diffOpts, 1715 }) 1716 1717}