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 738 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 739 740 req, err := http.NewRequest("GET", blobURL, nil) 741 if err != nil { 742 log.Println("failed to create request", err) 743 return 744 } 745 746 // forward the If-None-Match header 747 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 748 req.Header.Set("If-None-Match", clientETag) 749 } 750 751 client := &http.Client{} 752 resp, err := client.Do(req) 753 if err != nil { 754 log.Println("failed to reach knotserver", err) 755 rp.pages.Error503(w) 756 return 757 } 758 defer resp.Body.Close() 759 760 // forward 304 not modified 761 if resp.StatusCode == http.StatusNotModified { 762 w.WriteHeader(http.StatusNotModified) 763 return 764 } 765 766 if resp.StatusCode != http.StatusOK { 767 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 768 w.WriteHeader(resp.StatusCode) 769 _, _ = io.Copy(w, resp.Body) 770 return 771 } 772 773 contentType := resp.Header.Get("Content-Type") 774 body, err := io.ReadAll(resp.Body) 775 if err != nil { 776 log.Printf("error reading response body from knotserver: %v", err) 777 w.WriteHeader(http.StatusInternalServerError) 778 return 779 } 780 781 if strings.Contains(contentType, "text/plain") { 782 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 783 w.Write(body) 784 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 785 w.Header().Set("Content-Type", contentType) 786 w.Write(body) 787 } else { 788 w.WriteHeader(http.StatusUnsupportedMediaType) 789 w.Write([]byte("unsupported content type")) 790 return 791 } 792} 793 794// modify the spindle configured for this repo 795func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 796 user := rp.oauth.GetUser(r) 797 l := rp.logger.With("handler", "EditSpindle") 798 l = l.With("did", user.Did) 799 l = l.With("handle", user.Handle) 800 801 errorId := "operation-error" 802 fail := func(msg string, err error) { 803 l.Error(msg, "err", err) 804 rp.pages.Notice(w, errorId, msg) 805 } 806 807 f, err := rp.repoResolver.Resolve(r) 808 if err != nil { 809 fail("Failed to resolve repo. Try again later", err) 810 return 811 } 812 813 repoAt := f.RepoAt() 814 rkey := repoAt.RecordKey().String() 815 if rkey == "" { 816 fail("Failed to resolve repo. Try again later", err) 817 return 818 } 819 820 newSpindle := r.FormValue("spindle") 821 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 822 client, err := rp.oauth.AuthorizedClient(r) 823 if err != nil { 824 fail("Failed to authorize. Try again later.", err) 825 return 826 } 827 828 if !removingSpindle { 829 // ensure that this is a valid spindle for this user 830 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 831 if err != nil { 832 fail("Failed to find spindles. Try again later.", err) 833 return 834 } 835 836 if !slices.Contains(validSpindles, newSpindle) { 837 fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 838 return 839 } 840 } 841 842 spindlePtr := &newSpindle 843 if removingSpindle { 844 spindlePtr = nil 845 } 846 847 // optimistic update 848 err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 849 if err != nil { 850 fail("Failed to update spindle. Try again later.", err) 851 return 852 } 853 854 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 855 if err != nil { 856 fail("Failed to update spindle, no record found on PDS.", err) 857 return 858 } 859 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 860 Collection: tangled.RepoNSID, 861 Repo: user.Did, 862 Rkey: rkey, 863 SwapRecord: ex.Cid, 864 Record: &lexutil.LexiconTypeDecoder{ 865 Val: &tangled.Repo{ 866 Knot: f.Knot, 867 Name: f.Name, 868 Owner: user.Did, 869 CreatedAt: f.Created.Format(time.RFC3339), 870 Description: &f.Description, 871 Spindle: spindlePtr, 872 }, 873 }, 874 }) 875 876 if err != nil { 877 fail("Failed to update spindle, unable to save to PDS.", err) 878 return 879 } 880 881 if !removingSpindle { 882 // add this spindle to spindle stream 883 rp.spindlestream.AddSource( 884 context.Background(), 885 eventconsumer.NewSpindleSource(newSpindle), 886 ) 887 } 888 889 rp.pages.HxRefresh(w) 890} 891 892func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 893 user := rp.oauth.GetUser(r) 894 l := rp.logger.With("handler", "AddCollaborator") 895 l = l.With("did", user.Did) 896 l = l.With("handle", user.Handle) 897 898 f, err := rp.repoResolver.Resolve(r) 899 if err != nil { 900 l.Error("failed to get repo and knot", "err", err) 901 return 902 } 903 904 errorId := "add-collaborator-error" 905 fail := func(msg string, err error) { 906 l.Error(msg, "err", err) 907 rp.pages.Notice(w, errorId, msg) 908 } 909 910 collaborator := r.FormValue("collaborator") 911 if collaborator == "" { 912 fail("Invalid form.", nil) 913 return 914 } 915 916 // remove a single leading `@`, to make @handle work with ResolveIdent 917 collaborator = strings.TrimPrefix(collaborator, "@") 918 919 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 920 if err != nil { 921 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) 922 return 923 } 924 925 if collaboratorIdent.DID.String() == user.Did { 926 fail("You seem to be adding yourself as a collaborator.", nil) 927 return 928 } 929 l = l.With("collaborator", collaboratorIdent.Handle) 930 l = l.With("knot", f.Knot) 931 932 // announce this relation into the firehose, store into owners' pds 933 client, err := rp.oauth.AuthorizedClient(r) 934 if err != nil { 935 fail("Failed to write to PDS.", err) 936 return 937 } 938 939 // emit a record 940 currentUser := rp.oauth.GetUser(r) 941 rkey := tid.TID() 942 createdAt := time.Now() 943 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 944 Collection: tangled.RepoCollaboratorNSID, 945 Repo: currentUser.Did, 946 Rkey: rkey, 947 Record: &lexutil.LexiconTypeDecoder{ 948 Val: &tangled.RepoCollaborator{ 949 Subject: collaboratorIdent.DID.String(), 950 Repo: string(f.RepoAt()), 951 CreatedAt: createdAt.Format(time.RFC3339), 952 }}, 953 }) 954 // invalid record 955 if err != nil { 956 fail("Failed to write record to PDS.", err) 957 return 958 } 959 l = l.With("at-uri", resp.Uri) 960 l.Info("wrote record to PDS") 961 962 l.Info("adding to knot") 963 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 964 if err != nil { 965 fail("Failed to add to knot.", err) 966 return 967 } 968 969 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 970 if err != nil { 971 fail("Failed to add to knot.", err) 972 return 973 } 974 975 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String()) 976 if err != nil { 977 fail("Knot was unreachable.", err) 978 return 979 } 980 981 if ksResp.StatusCode != http.StatusNoContent { 982 fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 983 return 984 } 985 986 tx, err := rp.db.BeginTx(r.Context(), nil) 987 if err != nil { 988 fail("Failed to add collaborator.", err) 989 return 990 } 991 defer func() { 992 tx.Rollback() 993 err = rp.enforcer.E.LoadPolicy() 994 if err != nil { 995 fail("Failed to add collaborator.", err) 996 } 997 }() 998 999 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 1000 if err != nil { 1001 fail("Failed to add collaborator permissions.", err) 1002 return 1003 } 1004 1005 err = db.AddCollaborator(rp.db, db.Collaborator{ 1006 Did: syntax.DID(currentUser.Did), 1007 Rkey: rkey, 1008 SubjectDid: collaboratorIdent.DID, 1009 RepoAt: f.RepoAt(), 1010 Created: createdAt, 1011 }) 1012 if err != nil { 1013 fail("Failed to add collaborator.", err) 1014 return 1015 } 1016 1017 err = tx.Commit() 1018 if err != nil { 1019 fail("Failed to add collaborator.", err) 1020 return 1021 } 1022 1023 err = rp.enforcer.E.SavePolicy() 1024 if err != nil { 1025 fail("Failed to update collaborator permissions.", err) 1026 return 1027 } 1028 1029 rp.pages.HxRefresh(w) 1030} 1031 1032func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 1033 user := rp.oauth.GetUser(r) 1034 1035 f, err := rp.repoResolver.Resolve(r) 1036 if err != nil { 1037 log.Println("failed to get repo and knot", err) 1038 return 1039 } 1040 1041 // remove record from pds 1042 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1043 if err != nil { 1044 log.Println("failed to get authorized client", err) 1045 return 1046 } 1047 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1048 Collection: tangled.RepoNSID, 1049 Repo: user.Did, 1050 Rkey: f.Rkey, 1051 }) 1052 if err != nil { 1053 log.Printf("failed to delete record: %s", err) 1054 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 1055 return 1056 } 1057 log.Println("removed repo record ", f.RepoAt().String()) 1058 1059 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1060 if err != nil { 1061 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 1062 return 1063 } 1064 1065 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1066 if err != nil { 1067 log.Println("failed to create client to ", f.Knot) 1068 return 1069 } 1070 1071 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.Name) 1072 if err != nil { 1073 log.Printf("failed to make request to %s: %s", f.Knot, err) 1074 return 1075 } 1076 1077 if ksResp.StatusCode != http.StatusNoContent { 1078 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 1079 } else { 1080 log.Println("removed repo from knot ", f.Knot) 1081 } 1082 1083 tx, err := rp.db.BeginTx(r.Context(), nil) 1084 if err != nil { 1085 log.Println("failed to start tx") 1086 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1087 return 1088 } 1089 defer func() { 1090 tx.Rollback() 1091 err = rp.enforcer.E.LoadPolicy() 1092 if err != nil { 1093 log.Println("failed to rollback policies") 1094 } 1095 }() 1096 1097 // remove collaborator RBAC 1098 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 1099 if err != nil { 1100 rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 1101 return 1102 } 1103 for _, c := range repoCollaborators { 1104 did := c[0] 1105 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 1106 } 1107 log.Println("removed collaborators") 1108 1109 // remove repo RBAC 1110 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 1111 if err != nil { 1112 rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 1113 return 1114 } 1115 1116 // remove repo from db 1117 err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 1118 if err != nil { 1119 rp.pages.Notice(w, "settings-delete", "Failed to update appview") 1120 return 1121 } 1122 log.Println("removed repo from db") 1123 1124 err = tx.Commit() 1125 if err != nil { 1126 log.Println("failed to commit changes", err) 1127 http.Error(w, err.Error(), http.StatusInternalServerError) 1128 return 1129 } 1130 1131 err = rp.enforcer.E.SavePolicy() 1132 if err != nil { 1133 log.Println("failed to update ACLs", err) 1134 http.Error(w, err.Error(), http.StatusInternalServerError) 1135 return 1136 } 1137 1138 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 1139} 1140 1141func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1142 f, err := rp.repoResolver.Resolve(r) 1143 if err != nil { 1144 log.Println("failed to get repo and knot", err) 1145 return 1146 } 1147 1148 branch := r.FormValue("branch") 1149 if branch == "" { 1150 http.Error(w, "malformed form", http.StatusBadRequest) 1151 return 1152 } 1153 1154 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1155 if err != nil { 1156 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 1157 return 1158 } 1159 1160 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1161 if err != nil { 1162 log.Println("failed to create client to ", f.Knot) 1163 return 1164 } 1165 1166 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch) 1167 if err != nil { 1168 log.Printf("failed to make request to %s: %s", f.Knot, err) 1169 return 1170 } 1171 1172 if ksResp.StatusCode != http.StatusNoContent { 1173 rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 1174 return 1175 } 1176 1177 w.Write(fmt.Append(nil, "default branch set to: ", branch)) 1178} 1179 1180func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1181 user := rp.oauth.GetUser(r) 1182 l := rp.logger.With("handler", "Secrets") 1183 l = l.With("handle", user.Handle) 1184 l = l.With("did", user.Did) 1185 1186 f, err := rp.repoResolver.Resolve(r) 1187 if err != nil { 1188 log.Println("failed to get repo and knot", err) 1189 return 1190 } 1191 1192 if f.Spindle == "" { 1193 log.Println("empty spindle cannot add/rm secret", err) 1194 return 1195 } 1196 1197 lxm := tangled.RepoAddSecretNSID 1198 if r.Method == http.MethodDelete { 1199 lxm = tangled.RepoRemoveSecretNSID 1200 } 1201 1202 spindleClient, err := rp.oauth.ServiceClient( 1203 r, 1204 oauth.WithService(f.Spindle), 1205 oauth.WithLxm(lxm), 1206 oauth.WithExp(60), 1207 oauth.WithDev(rp.config.Core.Dev), 1208 ) 1209 if err != nil { 1210 log.Println("failed to create spindle client", err) 1211 return 1212 } 1213 1214 key := r.FormValue("key") 1215 if key == "" { 1216 w.WriteHeader(http.StatusBadRequest) 1217 return 1218 } 1219 1220 switch r.Method { 1221 case http.MethodPut: 1222 errorId := "add-secret-error" 1223 1224 value := r.FormValue("value") 1225 if value == "" { 1226 w.WriteHeader(http.StatusBadRequest) 1227 return 1228 } 1229 1230 err = tangled.RepoAddSecret( 1231 r.Context(), 1232 spindleClient, 1233 &tangled.RepoAddSecret_Input{ 1234 Repo: f.RepoAt().String(), 1235 Key: key, 1236 Value: value, 1237 }, 1238 ) 1239 if err != nil { 1240 l.Error("Failed to add secret.", "err", err) 1241 rp.pages.Notice(w, errorId, "Failed to add secret.") 1242 return 1243 } 1244 1245 case http.MethodDelete: 1246 errorId := "operation-error" 1247 1248 err = tangled.RepoRemoveSecret( 1249 r.Context(), 1250 spindleClient, 1251 &tangled.RepoRemoveSecret_Input{ 1252 Repo: f.RepoAt().String(), 1253 Key: key, 1254 }, 1255 ) 1256 if err != nil { 1257 l.Error("Failed to delete secret.", "err", err) 1258 rp.pages.Notice(w, errorId, "Failed to delete secret.") 1259 return 1260 } 1261 } 1262 1263 rp.pages.HxRefresh(w) 1264} 1265 1266type tab = map[string]any 1267 1268var ( 1269 // would be great to have ordered maps right about now 1270 settingsTabs []tab = []tab{ 1271 {"Name": "general", "Icon": "sliders-horizontal"}, 1272 {"Name": "access", "Icon": "users"}, 1273 {"Name": "pipelines", "Icon": "layers-2"}, 1274 } 1275) 1276 1277func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1278 tabVal := r.URL.Query().Get("tab") 1279 if tabVal == "" { 1280 tabVal = "general" 1281 } 1282 1283 switch tabVal { 1284 case "general": 1285 rp.generalSettings(w, r) 1286 1287 case "access": 1288 rp.accessSettings(w, r) 1289 1290 case "pipelines": 1291 rp.pipelineSettings(w, r) 1292 } 1293 1294 // user := rp.oauth.GetUser(r) 1295 // repoCollaborators, err := f.Collaborators(r.Context()) 1296 // if err != nil { 1297 // log.Println("failed to get collaborators", err) 1298 // } 1299 1300 // isCollaboratorInviteAllowed := false 1301 // if user != nil { 1302 // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1303 // if err == nil && ok { 1304 // isCollaboratorInviteAllowed = true 1305 // } 1306 // } 1307 1308 // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1309 // if err != nil { 1310 // log.Println("failed to create unsigned client", err) 1311 // return 1312 // } 1313 1314 // result, err := us.Branches(f.OwnerDid(), f.Name) 1315 // if err != nil { 1316 // log.Println("failed to reach knotserver", err) 1317 // return 1318 // } 1319 1320 // // all spindles that this user is a member of 1321 // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1322 // if err != nil { 1323 // log.Println("failed to fetch spindles", err) 1324 // return 1325 // } 1326 1327 // var secrets []*tangled.RepoListSecrets_Secret 1328 // if f.Spindle != "" { 1329 // if spindleClient, err := rp.oauth.ServiceClient( 1330 // r, 1331 // oauth.WithService(f.Spindle), 1332 // oauth.WithLxm(tangled.RepoListSecretsNSID), 1333 // oauth.WithDev(rp.config.Core.Dev), 1334 // ); err != nil { 1335 // log.Println("failed to create spindle client", err) 1336 // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1337 // log.Println("failed to fetch secrets", err) 1338 // } else { 1339 // secrets = resp.Secrets 1340 // } 1341 // } 1342 1343 // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1344 // LoggedInUser: user, 1345 // RepoInfo: f.RepoInfo(user), 1346 // Collaborators: repoCollaborators, 1347 // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1348 // Branches: result.Branches, 1349 // Spindles: spindles, 1350 // CurrentSpindle: f.Spindle, 1351 // Secrets: secrets, 1352 // }) 1353} 1354 1355func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1356 f, err := rp.repoResolver.Resolve(r) 1357 user := rp.oauth.GetUser(r) 1358 1359 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1360 if err != nil { 1361 log.Println("failed to create unsigned client", err) 1362 return 1363 } 1364 1365 result, err := us.Branches(f.OwnerDid(), f.Name) 1366 if err != nil { 1367 log.Println("failed to reach knotserver", err) 1368 return 1369 } 1370 1371 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1372 LoggedInUser: user, 1373 RepoInfo: f.RepoInfo(user), 1374 Branches: result.Branches, 1375 Tabs: settingsTabs, 1376 Tab: "general", 1377 }) 1378} 1379 1380func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1381 f, err := rp.repoResolver.Resolve(r) 1382 user := rp.oauth.GetUser(r) 1383 1384 repoCollaborators, err := f.Collaborators(r.Context()) 1385 if err != nil { 1386 log.Println("failed to get collaborators", err) 1387 } 1388 1389 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1390 LoggedInUser: user, 1391 RepoInfo: f.RepoInfo(user), 1392 Tabs: settingsTabs, 1393 Tab: "access", 1394 Collaborators: repoCollaborators, 1395 }) 1396} 1397 1398func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1399 f, err := rp.repoResolver.Resolve(r) 1400 user := rp.oauth.GetUser(r) 1401 1402 // all spindles that the repo owner is a member of 1403 spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1404 if err != nil { 1405 log.Println("failed to fetch spindles", err) 1406 return 1407 } 1408 1409 var secrets []*tangled.RepoListSecrets_Secret 1410 if f.Spindle != "" { 1411 if spindleClient, err := rp.oauth.ServiceClient( 1412 r, 1413 oauth.WithService(f.Spindle), 1414 oauth.WithLxm(tangled.RepoListSecretsNSID), 1415 oauth.WithExp(60), 1416 oauth.WithDev(rp.config.Core.Dev), 1417 ); err != nil { 1418 log.Println("failed to create spindle client", err) 1419 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1420 log.Println("failed to fetch secrets", err) 1421 } else { 1422 secrets = resp.Secrets 1423 } 1424 } 1425 1426 slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 1427 return strings.Compare(a.Key, b.Key) 1428 }) 1429 1430 var dids []string 1431 for _, s := range secrets { 1432 dids = append(dids, s.CreatedBy) 1433 } 1434 resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 1435 1436 // convert to a more manageable form 1437 var niceSecret []map[string]any 1438 for id, s := range secrets { 1439 when, _ := time.Parse(time.RFC3339, s.CreatedAt) 1440 niceSecret = append(niceSecret, map[string]any{ 1441 "Id": id, 1442 "Key": s.Key, 1443 "CreatedAt": when, 1444 "CreatedBy": resolvedIdents[id].Handle.String(), 1445 }) 1446 } 1447 1448 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 1449 LoggedInUser: user, 1450 RepoInfo: f.RepoInfo(user), 1451 Tabs: settingsTabs, 1452 Tab: "pipelines", 1453 Spindles: spindles, 1454 CurrentSpindle: f.Spindle, 1455 Secrets: niceSecret, 1456 }) 1457} 1458 1459func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1460 ref := chi.URLParam(r, "ref") 1461 1462 user := rp.oauth.GetUser(r) 1463 f, err := rp.repoResolver.Resolve(r) 1464 if err != nil { 1465 log.Printf("failed to resolve source repo: %v", err) 1466 return 1467 } 1468 1469 switch r.Method { 1470 case http.MethodPost: 1471 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1472 if err != nil { 1473 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1474 return 1475 } 1476 1477 client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1478 if err != nil { 1479 rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1480 return 1481 } 1482 1483 var uri string 1484 if rp.config.Core.Dev { 1485 uri = "http" 1486 } else { 1487 uri = "https" 1488 } 1489 forkName := fmt.Sprintf("%s", f.Name) 1490 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1491 1492 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref) 1493 if err != nil { 1494 rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1495 return 1496 } 1497 1498 rp.pages.HxRefresh(w) 1499 return 1500 } 1501} 1502 1503func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 1504 user := rp.oauth.GetUser(r) 1505 f, err := rp.repoResolver.Resolve(r) 1506 if err != nil { 1507 log.Printf("failed to resolve source repo: %v", err) 1508 return 1509 } 1510 1511 switch r.Method { 1512 case http.MethodGet: 1513 user := rp.oauth.GetUser(r) 1514 knots, err := rp.enforcer.GetKnotsForUser(user.Did) 1515 if err != nil { 1516 rp.pages.Notice(w, "repo", "Invalid user account.") 1517 return 1518 } 1519 1520 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1521 LoggedInUser: user, 1522 Knots: knots, 1523 RepoInfo: f.RepoInfo(user), 1524 }) 1525 1526 case http.MethodPost: 1527 1528 knot := r.FormValue("knot") 1529 if knot == "" { 1530 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") 1531 return 1532 } 1533 1534 ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1535 if err != nil || !ok { 1536 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1537 return 1538 } 1539 1540 forkName := fmt.Sprintf("%s", f.Name) 1541 1542 // this check is *only* to see if the forked repo name already exists 1543 // in the user's account. 1544 existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1545 if err != nil { 1546 if errors.Is(err, sql.ErrNoRows) { 1547 // no existing repo with this name found, we can use the name as is 1548 } else { 1549 log.Println("error fetching existing repo from db", err) 1550 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1551 return 1552 } 1553 } else if existingRepo != nil { 1554 // repo with this name already exists, append random string 1555 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1556 } 1557 secret, err := db.GetRegistrationKey(rp.db, knot) 1558 if err != nil { 1559 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1560 return 1561 } 1562 1563 client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev) 1564 if err != nil { 1565 rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1566 return 1567 } 1568 1569 var uri string 1570 if rp.config.Core.Dev { 1571 uri = "http" 1572 } else { 1573 uri = "https" 1574 } 1575 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1576 sourceAt := f.RepoAt().String() 1577 1578 rkey := tid.TID() 1579 repo := &db.Repo{ 1580 Did: user.Did, 1581 Name: forkName, 1582 Knot: knot, 1583 Rkey: rkey, 1584 Source: sourceAt, 1585 } 1586 1587 tx, err := rp.db.BeginTx(r.Context(), nil) 1588 if err != nil { 1589 log.Println(err) 1590 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1591 return 1592 } 1593 defer func() { 1594 tx.Rollback() 1595 err = rp.enforcer.E.LoadPolicy() 1596 if err != nil { 1597 log.Println("failed to rollback policies") 1598 } 1599 }() 1600 1601 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1602 if err != nil { 1603 rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1604 return 1605 } 1606 1607 switch resp.StatusCode { 1608 case http.StatusConflict: 1609 rp.pages.Notice(w, "repo", "A repository with that name already exists.") 1610 return 1611 case http.StatusInternalServerError: 1612 rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1613 case http.StatusNoContent: 1614 // continue 1615 } 1616 1617 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1618 if err != nil { 1619 log.Println("failed to get authorized client", err) 1620 rp.pages.Notice(w, "repo", "Failed to create repository.") 1621 return 1622 } 1623 1624 createdAt := time.Now().Format(time.RFC3339) 1625 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1626 Collection: tangled.RepoNSID, 1627 Repo: user.Did, 1628 Rkey: rkey, 1629 Record: &lexutil.LexiconTypeDecoder{ 1630 Val: &tangled.Repo{ 1631 Knot: repo.Knot, 1632 Name: repo.Name, 1633 CreatedAt: createdAt, 1634 Owner: user.Did, 1635 Source: &sourceAt, 1636 }}, 1637 }) 1638 if err != nil { 1639 log.Printf("failed to create record: %s", err) 1640 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1641 return 1642 } 1643 log.Println("created repo record: ", atresp.Uri) 1644 1645 err = db.AddRepo(tx, repo) 1646 if err != nil { 1647 log.Println(err) 1648 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1649 return 1650 } 1651 1652 // acls 1653 p, _ := securejoin.SecureJoin(user.Did, forkName) 1654 err = rp.enforcer.AddRepo(user.Did, knot, p) 1655 if err != nil { 1656 log.Println(err) 1657 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1658 return 1659 } 1660 1661 err = tx.Commit() 1662 if err != nil { 1663 log.Println("failed to commit changes", err) 1664 http.Error(w, err.Error(), http.StatusInternalServerError) 1665 return 1666 } 1667 1668 err = rp.enforcer.E.SavePolicy() 1669 if err != nil { 1670 log.Println("failed to update ACLs", err) 1671 http.Error(w, err.Error(), http.StatusInternalServerError) 1672 return 1673 } 1674 1675 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1676 return 1677 } 1678} 1679 1680func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1681 user := rp.oauth.GetUser(r) 1682 f, err := rp.repoResolver.Resolve(r) 1683 if err != nil { 1684 log.Println("failed to get repo and knot", err) 1685 return 1686 } 1687 1688 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1689 if err != nil { 1690 log.Printf("failed to create unsigned client for %s", f.Knot) 1691 rp.pages.Error503(w) 1692 return 1693 } 1694 1695 result, err := us.Branches(f.OwnerDid(), f.Name) 1696 if err != nil { 1697 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1698 log.Println("failed to reach knotserver", err) 1699 return 1700 } 1701 branches := result.Branches 1702 1703 sortBranches(branches) 1704 1705 var defaultBranch string 1706 for _, b := range branches { 1707 if b.IsDefault { 1708 defaultBranch = b.Name 1709 } 1710 } 1711 1712 base := defaultBranch 1713 head := defaultBranch 1714 1715 params := r.URL.Query() 1716 queryBase := params.Get("base") 1717 queryHead := params.Get("head") 1718 if queryBase != "" { 1719 base = queryBase 1720 } 1721 if queryHead != "" { 1722 head = queryHead 1723 } 1724 1725 tags, err := us.Tags(f.OwnerDid(), f.Name) 1726 if err != nil { 1727 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1728 log.Println("failed to reach knotserver", err) 1729 return 1730 } 1731 1732 repoinfo := f.RepoInfo(user) 1733 1734 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 1735 LoggedInUser: user, 1736 RepoInfo: repoinfo, 1737 Branches: branches, 1738 Tags: tags.Tags, 1739 Base: base, 1740 Head: head, 1741 }) 1742} 1743 1744func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 1745 user := rp.oauth.GetUser(r) 1746 f, err := rp.repoResolver.Resolve(r) 1747 if err != nil { 1748 log.Println("failed to get repo and knot", err) 1749 return 1750 } 1751 1752 var diffOpts types.DiffOpts 1753 if d := r.URL.Query().Get("diff"); d == "split" { 1754 diffOpts.Split = true 1755 } 1756 1757 // if user is navigating to one of 1758 // /compare/{base}/{head} 1759 // /compare/{base}...{head} 1760 base := chi.URLParam(r, "base") 1761 head := chi.URLParam(r, "head") 1762 if base == "" && head == "" { 1763 rest := chi.URLParam(r, "*") // master...feature/xyz 1764 parts := strings.SplitN(rest, "...", 2) 1765 if len(parts) == 2 { 1766 base = parts[0] 1767 head = parts[1] 1768 } 1769 } 1770 1771 base, _ = url.PathUnescape(base) 1772 head, _ = url.PathUnescape(head) 1773 1774 if base == "" || head == "" { 1775 log.Printf("invalid comparison") 1776 rp.pages.Error404(w) 1777 return 1778 } 1779 1780 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1781 if err != nil { 1782 log.Printf("failed to create unsigned client for %s", f.Knot) 1783 rp.pages.Error503(w) 1784 return 1785 } 1786 1787 branches, err := us.Branches(f.OwnerDid(), f.Name) 1788 if err != nil { 1789 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1790 log.Println("failed to reach knotserver", err) 1791 return 1792 } 1793 1794 tags, err := us.Tags(f.OwnerDid(), f.Name) 1795 if err != nil { 1796 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1797 log.Println("failed to reach knotserver", err) 1798 return 1799 } 1800 1801 formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1802 if err != nil { 1803 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1804 log.Println("failed to compare", err) 1805 return 1806 } 1807 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1808 1809 repoinfo := f.RepoInfo(user) 1810 1811 rp.pages.RepoCompare(w, pages.RepoCompareParams{ 1812 LoggedInUser: user, 1813 RepoInfo: repoinfo, 1814 Branches: branches.Branches, 1815 Tags: tags.Tags, 1816 Base: base, 1817 Head: head, 1818 Diff: &diff, 1819 DiffOpts: diffOpts, 1820 }) 1821 1822}