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