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 "net/http" 12 "net/url" 13 "path" 14 "slices" 15 "sort" 16 "strconv" 17 "strings" 18 "time" 19 20 "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview" 22 "tangled.sh/tangled.sh/core/appview/commitverify" 23 "tangled.sh/tangled.sh/core/appview/config" 24 "tangled.sh/tangled.sh/core/appview/db" 25 "tangled.sh/tangled.sh/core/appview/idresolver" 26 "tangled.sh/tangled.sh/core/appview/oauth" 27 "tangled.sh/tangled.sh/core/appview/pages" 28 "tangled.sh/tangled.sh/core/appview/pages/markup" 29 "tangled.sh/tangled.sh/core/appview/reporesolver" 30 "tangled.sh/tangled.sh/core/eventconsumer" 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/types" 35 36 securejoin "github.com/cyphar/filepath-securejoin" 37 "github.com/go-chi/chi/v5" 38 "github.com/go-git/go-git/v5/plumbing" 39 "github.com/posthog/posthog-go" 40 41 comatproto "github.com/bluesky-social/indigo/api/atproto" 42 lexutil "github.com/bluesky-social/indigo/lex/util" 43) 44 45type Repo struct { 46 repoResolver *reporesolver.RepoResolver 47 idResolver *idresolver.Resolver 48 config *config.Config 49 oauth *oauth.OAuth 50 pages *pages.Pages 51 spindlestream *eventconsumer.Consumer 52 db *db.DB 53 enforcer *rbac.Enforcer 54 posthog posthog.Client 55} 56 57func New( 58 oauth *oauth.OAuth, 59 repoResolver *reporesolver.RepoResolver, 60 pages *pages.Pages, 61 spindlestream *eventconsumer.Consumer, 62 idResolver *idresolver.Resolver, 63 db *db.DB, 64 config *config.Config, 65 posthog posthog.Client, 66 enforcer *rbac.Enforcer, 67) *Repo { 68 return &Repo{oauth: oauth, 69 repoResolver: repoResolver, 70 pages: pages, 71 idResolver: idResolver, 72 config: config, 73 spindlestream: spindlestream, 74 db: db, 75 posthog: posthog, 76 enforcer: enforcer, 77 } 78} 79 80func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 81 f, err := rp.repoResolver.Resolve(r) 82 if err != nil { 83 log.Println("failed to fully resolve repo", err) 84 return 85 } 86 87 page := 1 88 if r.URL.Query().Get("page") != "" { 89 page, err = strconv.Atoi(r.URL.Query().Get("page")) 90 if err != nil { 91 page = 1 92 } 93 } 94 95 ref := chi.URLParam(r, "ref") 96 97 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 98 if err != nil { 99 log.Println("failed to create unsigned client", err) 100 return 101 } 102 103 repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 104 if err != nil { 105 log.Println("failed to reach knotserver", err) 106 return 107 } 108 109 result, err := us.Tags(f.OwnerDid(), f.RepoName) 110 if err != nil { 111 log.Println("failed to reach knotserver", err) 112 return 113 } 114 115 tagMap := make(map[string][]string) 116 for _, tag := range result.Tags { 117 hash := tag.Hash 118 if tag.Tag != nil { 119 hash = tag.Tag.Target.String() 120 } 121 tagMap[hash] = append(tagMap[hash], tag.Name) 122 } 123 124 user := rp.oauth.GetUser(r) 125 126 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) 127 if err != nil { 128 log.Println("failed to fetch email to did mapping", err) 129 } 130 131 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits) 132 if err != nil { 133 log.Println(err) 134 } 135 136 repoInfo := f.RepoInfo(user) 137 138 var shas []string 139 for _, c := range repolog.Commits { 140 shas = append(shas, c.Hash.String()) 141 } 142 pipelines, err := rp.getPipelineStatuses(repoInfo, shas) 143 if err != nil { 144 log.Println(err) 145 // non-fatal 146 } 147 148 rp.pages.RepoLog(w, pages.RepoLogParams{ 149 LoggedInUser: user, 150 TagMap: tagMap, 151 RepoInfo: repoInfo, 152 RepoLogResponse: *repolog, 153 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 154 VerifiedCommits: vc, 155 Pipelines: pipelines, 156 }) 157 return 158} 159 160func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 161 f, err := rp.repoResolver.Resolve(r) 162 if err != nil { 163 log.Println("failed to get repo and knot", err) 164 w.WriteHeader(http.StatusBadRequest) 165 return 166 } 167 168 user := rp.oauth.GetUser(r) 169 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 170 RepoInfo: f.RepoInfo(user), 171 }) 172 return 173} 174 175func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 176 f, err := rp.repoResolver.Resolve(r) 177 if err != nil { 178 log.Println("failed to get repo and knot", err) 179 w.WriteHeader(http.StatusBadRequest) 180 return 181 } 182 183 repoAt := f.RepoAt 184 rkey := repoAt.RecordKey().String() 185 if rkey == "" { 186 log.Println("invalid aturi for repo", err) 187 w.WriteHeader(http.StatusInternalServerError) 188 return 189 } 190 191 user := rp.oauth.GetUser(r) 192 193 switch r.Method { 194 case http.MethodGet: 195 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 196 RepoInfo: f.RepoInfo(user), 197 }) 198 return 199 case http.MethodPut: 200 newDescription := r.FormValue("description") 201 client, err := rp.oauth.AuthorizedClient(r) 202 if err != nil { 203 log.Println("failed to get client") 204 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 205 return 206 } 207 208 // optimistic update 209 err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 210 if err != nil { 211 log.Println("failed to perferom update-description query", err) 212 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 213 return 214 } 215 216 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 217 // 218 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 219 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 220 if err != nil { 221 // failed to get record 222 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 223 return 224 } 225 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 226 Collection: tangled.RepoNSID, 227 Repo: user.Did, 228 Rkey: rkey, 229 SwapRecord: ex.Cid, 230 Record: &lexutil.LexiconTypeDecoder{ 231 Val: &tangled.Repo{ 232 Knot: f.Knot, 233 Name: f.RepoName, 234 Owner: user.Did, 235 CreatedAt: f.CreatedAt, 236 Description: &newDescription, 237 }, 238 }, 239 }) 240 241 if err != nil { 242 log.Println("failed to perferom update-description query", err) 243 // failed to get record 244 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 245 return 246 } 247 248 newRepoInfo := f.RepoInfo(user) 249 newRepoInfo.Description = newDescription 250 251 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 252 RepoInfo: newRepoInfo, 253 }) 254 return 255 } 256} 257 258func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 259 f, err := rp.repoResolver.Resolve(r) 260 if err != nil { 261 log.Println("failed to fully resolve repo", err) 262 return 263 } 264 ref := chi.URLParam(r, "ref") 265 protocol := "http" 266 if !rp.config.Core.Dev { 267 protocol = "https" 268 } 269 270 if !plumbing.IsHash(ref) { 271 rp.pages.Error404(w) 272 return 273 } 274 275 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 276 if err != nil { 277 log.Println("failed to reach knotserver", err) 278 return 279 } 280 281 body, err := io.ReadAll(resp.Body) 282 if err != nil { 283 log.Printf("Error reading response body: %v", err) 284 return 285 } 286 287 var result types.RepoCommitResponse 288 err = json.Unmarshal(body, &result) 289 if err != nil { 290 log.Println("failed to parse response:", err) 291 return 292 } 293 294 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 295 if err != nil { 296 log.Println("failed to get email to did mapping:", err) 297 } 298 299 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 300 if err != nil { 301 log.Println(err) 302 } 303 304 user := rp.oauth.GetUser(r) 305 repoInfo := f.RepoInfo(user) 306 pipelines, err := rp.getPipelineStatuses(repoInfo, []string{result.Diff.Commit.This}) 307 if err != nil { 308 log.Println(err) 309 // non-fatal 310 } 311 var pipeline *db.Pipeline 312 if p, ok := pipelines[result.Diff.Commit.This]; ok { 313 pipeline = &p 314 } 315 316 rp.pages.RepoCommit(w, pages.RepoCommitParams{ 317 LoggedInUser: user, 318 RepoInfo: f.RepoInfo(user), 319 RepoCommitResponse: result, 320 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 321 VerifiedCommit: vc, 322 Pipeline: pipeline, 323 }) 324 return 325} 326 327func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 328 f, err := rp.repoResolver.Resolve(r) 329 if err != nil { 330 log.Println("failed to fully resolve repo", err) 331 return 332 } 333 334 ref := chi.URLParam(r, "ref") 335 treePath := chi.URLParam(r, "*") 336 protocol := "http" 337 if !rp.config.Core.Dev { 338 protocol = "https" 339 } 340 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 341 if err != nil { 342 log.Println("failed to reach knotserver", err) 343 return 344 } 345 346 body, err := io.ReadAll(resp.Body) 347 if err != nil { 348 log.Printf("Error reading response body: %v", err) 349 return 350 } 351 352 var result types.RepoTreeResponse 353 err = json.Unmarshal(body, &result) 354 if err != nil { 355 log.Println("failed to parse response:", err) 356 return 357 } 358 359 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 360 // so we can safely redirect to the "parent" (which is the same file). 361 if len(result.Files) == 0 && result.Parent == treePath { 362 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 363 return 364 } 365 366 user := rp.oauth.GetUser(r) 367 368 var breadcrumbs [][]string 369 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 370 if treePath != "" { 371 for idx, elem := range strings.Split(treePath, "/") { 372 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 373 } 374 } 375 376 baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 377 baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 378 379 rp.pages.RepoTree(w, pages.RepoTreeParams{ 380 LoggedInUser: user, 381 BreadCrumbs: breadcrumbs, 382 BaseTreeLink: baseTreeLink, 383 BaseBlobLink: baseBlobLink, 384 RepoInfo: f.RepoInfo(user), 385 RepoTreeResponse: result, 386 }) 387 return 388} 389 390func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 391 f, err := rp.repoResolver.Resolve(r) 392 if err != nil { 393 log.Println("failed to get repo and knot", err) 394 return 395 } 396 397 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 398 if err != nil { 399 log.Println("failed to create unsigned client", err) 400 return 401 } 402 403 result, err := us.Tags(f.OwnerDid(), f.RepoName) 404 if err != nil { 405 log.Println("failed to reach knotserver", err) 406 return 407 } 408 409 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt)) 410 if err != nil { 411 log.Println("failed grab artifacts", err) 412 return 413 } 414 415 // convert artifacts to map for easy UI building 416 artifactMap := make(map[plumbing.Hash][]db.Artifact) 417 for _, a := range artifacts { 418 artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 419 } 420 421 var danglingArtifacts []db.Artifact 422 for _, a := range artifacts { 423 found := false 424 for _, t := range result.Tags { 425 if t.Tag != nil { 426 if t.Tag.Hash == a.Tag { 427 found = true 428 } 429 } 430 } 431 432 if !found { 433 danglingArtifacts = append(danglingArtifacts, a) 434 } 435 } 436 437 user := rp.oauth.GetUser(r) 438 rp.pages.RepoTags(w, pages.RepoTagsParams{ 439 LoggedInUser: user, 440 RepoInfo: f.RepoInfo(user), 441 RepoTagsResponse: *result, 442 ArtifactMap: artifactMap, 443 DanglingArtifacts: danglingArtifacts, 444 }) 445 return 446} 447 448func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 449 f, err := rp.repoResolver.Resolve(r) 450 if err != nil { 451 log.Println("failed to get repo and knot", err) 452 return 453 } 454 455 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 456 if err != nil { 457 log.Println("failed to create unsigned client", err) 458 return 459 } 460 461 result, err := us.Branches(f.OwnerDid(), f.RepoName) 462 if err != nil { 463 log.Println("failed to reach knotserver", err) 464 return 465 } 466 467 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 468 if a.IsDefault { 469 return -1 470 } 471 if b.IsDefault { 472 return 1 473 } 474 if a.Commit != nil && b.Commit != nil { 475 if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 476 return 1 477 } else { 478 return -1 479 } 480 } 481 return strings.Compare(a.Name, b.Name) * -1 482 }) 483 484 user := rp.oauth.GetUser(r) 485 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 486 LoggedInUser: user, 487 RepoInfo: f.RepoInfo(user), 488 RepoBranchesResponse: *result, 489 }) 490 return 491} 492 493func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 494 f, err := rp.repoResolver.Resolve(r) 495 if err != nil { 496 log.Println("failed to get repo and knot", err) 497 return 498 } 499 500 ref := chi.URLParam(r, "ref") 501 filePath := chi.URLParam(r, "*") 502 protocol := "http" 503 if !rp.config.Core.Dev { 504 protocol = "https" 505 } 506 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 507 if err != nil { 508 log.Println("failed to reach knotserver", err) 509 return 510 } 511 512 body, err := io.ReadAll(resp.Body) 513 if err != nil { 514 log.Printf("Error reading response body: %v", err) 515 return 516 } 517 518 var result types.RepoBlobResponse 519 err = json.Unmarshal(body, &result) 520 if err != nil { 521 log.Println("failed to parse response:", err) 522 return 523 } 524 525 var breadcrumbs [][]string 526 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 527 if filePath != "" { 528 for idx, elem := range strings.Split(filePath, "/") { 529 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 530 } 531 } 532 533 showRendered := false 534 renderToggle := false 535 536 if markup.GetFormat(result.Path) == markup.FormatMarkdown { 537 renderToggle = true 538 showRendered = r.URL.Query().Get("code") != "true" 539 } 540 541 user := rp.oauth.GetUser(r) 542 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 543 LoggedInUser: user, 544 RepoInfo: f.RepoInfo(user), 545 RepoBlobResponse: result, 546 BreadCrumbs: breadcrumbs, 547 ShowRendered: showRendered, 548 RenderToggle: renderToggle, 549 }) 550 return 551} 552 553func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 554 f, err := rp.repoResolver.Resolve(r) 555 if err != nil { 556 log.Println("failed to get repo and knot", err) 557 return 558 } 559 560 ref := chi.URLParam(r, "ref") 561 filePath := chi.URLParam(r, "*") 562 563 protocol := "http" 564 if !rp.config.Core.Dev { 565 protocol = "https" 566 } 567 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 568 if err != nil { 569 log.Println("failed to reach knotserver", err) 570 return 571 } 572 573 body, err := io.ReadAll(resp.Body) 574 if err != nil { 575 log.Printf("Error reading response body: %v", err) 576 return 577 } 578 579 var result types.RepoBlobResponse 580 err = json.Unmarshal(body, &result) 581 if err != nil { 582 log.Println("failed to parse response:", err) 583 return 584 } 585 586 if result.IsBinary { 587 w.Header().Set("Content-Type", "application/octet-stream") 588 w.Write(body) 589 return 590 } 591 592 w.Header().Set("Content-Type", "text/plain") 593 w.Write([]byte(result.Contents)) 594 return 595} 596 597// modify the spindle configured for this repo 598func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 599 f, err := rp.repoResolver.Resolve(r) 600 if err != nil { 601 log.Println("failed to get repo and knot", err) 602 w.WriteHeader(http.StatusBadRequest) 603 return 604 } 605 606 repoAt := f.RepoAt 607 rkey := repoAt.RecordKey().String() 608 if rkey == "" { 609 log.Println("invalid aturi for repo", err) 610 w.WriteHeader(http.StatusInternalServerError) 611 return 612 } 613 614 user := rp.oauth.GetUser(r) 615 616 newSpindle := r.FormValue("spindle") 617 client, err := rp.oauth.AuthorizedClient(r) 618 if err != nil { 619 log.Println("failed to get client") 620 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 621 return 622 } 623 624 // ensure that this is a valid spindle for this user 625 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 626 if err != nil { 627 log.Println("failed to get valid spindles") 628 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 629 return 630 } 631 632 if !slices.Contains(validSpindles, newSpindle) { 633 log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles) 634 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 635 return 636 } 637 638 // optimistic update 639 err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 640 if err != nil { 641 log.Println("failed to perform update-spindle query", err) 642 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 643 return 644 } 645 646 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 647 if err != nil { 648 // failed to get record 649 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.") 650 return 651 } 652 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 653 Collection: tangled.RepoNSID, 654 Repo: user.Did, 655 Rkey: rkey, 656 SwapRecord: ex.Cid, 657 Record: &lexutil.LexiconTypeDecoder{ 658 Val: &tangled.Repo{ 659 Knot: f.Knot, 660 Name: f.RepoName, 661 Owner: user.Did, 662 CreatedAt: f.CreatedAt, 663 Description: &f.Description, 664 Spindle: &newSpindle, 665 }, 666 }, 667 }) 668 669 if err != nil { 670 log.Println("failed to perform update-spindle query", err) 671 // failed to get record 672 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, unable to save to PDS.") 673 return 674 } 675 676 // add this spindle to spindle stream 677 rp.spindlestream.AddSource( 678 context.Background(), 679 eventconsumer.NewSpindleSource(newSpindle), 680 ) 681 682 w.Write(fmt.Append(nil, "spindle set to: ", newSpindle)) 683} 684 685func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 686 f, err := rp.repoResolver.Resolve(r) 687 if err != nil { 688 log.Println("failed to get repo and knot", err) 689 return 690 } 691 692 collaborator := r.FormValue("collaborator") 693 if collaborator == "" { 694 http.Error(w, "malformed form", http.StatusBadRequest) 695 return 696 } 697 698 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 699 if err != nil { 700 w.Write([]byte("failed to resolve collaborator did to a handle")) 701 return 702 } 703 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 704 705 // TODO: create an atproto record for this 706 707 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 708 if err != nil { 709 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 710 return 711 } 712 713 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 714 if err != nil { 715 log.Println("failed to create client to ", f.Knot) 716 return 717 } 718 719 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 720 if err != nil { 721 log.Printf("failed to make request to %s: %s", f.Knot, err) 722 return 723 } 724 725 if ksResp.StatusCode != http.StatusNoContent { 726 w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err)) 727 return 728 } 729 730 tx, err := rp.db.BeginTx(r.Context(), nil) 731 if err != nil { 732 log.Println("failed to start tx") 733 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 734 return 735 } 736 defer func() { 737 tx.Rollback() 738 err = rp.enforcer.E.LoadPolicy() 739 if err != nil { 740 log.Println("failed to rollback policies") 741 } 742 }() 743 744 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 745 if err != nil { 746 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 747 return 748 } 749 750 err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 751 if err != nil { 752 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 753 return 754 } 755 756 err = tx.Commit() 757 if err != nil { 758 log.Println("failed to commit changes", err) 759 http.Error(w, err.Error(), http.StatusInternalServerError) 760 return 761 } 762 763 err = rp.enforcer.E.SavePolicy() 764 if err != nil { 765 log.Println("failed to update ACLs", err) 766 http.Error(w, err.Error(), http.StatusInternalServerError) 767 return 768 } 769 770 w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String())) 771 772} 773 774func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 775 user := rp.oauth.GetUser(r) 776 777 f, err := rp.repoResolver.Resolve(r) 778 if err != nil { 779 log.Println("failed to get repo and knot", err) 780 return 781 } 782 783 // remove record from pds 784 xrpcClient, err := rp.oauth.AuthorizedClient(r) 785 if err != nil { 786 log.Println("failed to get authorized client", err) 787 return 788 } 789 repoRkey := f.RepoAt.RecordKey().String() 790 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 791 Collection: tangled.RepoNSID, 792 Repo: user.Did, 793 Rkey: repoRkey, 794 }) 795 if err != nil { 796 log.Printf("failed to delete record: %s", err) 797 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 798 return 799 } 800 log.Println("removed repo record ", f.RepoAt.String()) 801 802 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 803 if err != nil { 804 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 805 return 806 } 807 808 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 809 if err != nil { 810 log.Println("failed to create client to ", f.Knot) 811 return 812 } 813 814 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 815 if err != nil { 816 log.Printf("failed to make request to %s: %s", f.Knot, err) 817 return 818 } 819 820 if ksResp.StatusCode != http.StatusNoContent { 821 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 822 } else { 823 log.Println("removed repo from knot ", f.Knot) 824 } 825 826 tx, err := rp.db.BeginTx(r.Context(), nil) 827 if err != nil { 828 log.Println("failed to start tx") 829 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 830 return 831 } 832 defer func() { 833 tx.Rollback() 834 err = rp.enforcer.E.LoadPolicy() 835 if err != nil { 836 log.Println("failed to rollback policies") 837 } 838 }() 839 840 // remove collaborator RBAC 841 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 842 if err != nil { 843 rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 844 return 845 } 846 for _, c := range repoCollaborators { 847 did := c[0] 848 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 849 } 850 log.Println("removed collaborators") 851 852 // remove repo RBAC 853 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 854 if err != nil { 855 rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 856 return 857 } 858 859 // remove repo from db 860 err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 861 if err != nil { 862 rp.pages.Notice(w, "settings-delete", "Failed to update appview") 863 return 864 } 865 log.Println("removed repo from db") 866 867 err = tx.Commit() 868 if err != nil { 869 log.Println("failed to commit changes", err) 870 http.Error(w, err.Error(), http.StatusInternalServerError) 871 return 872 } 873 874 err = rp.enforcer.E.SavePolicy() 875 if err != nil { 876 log.Println("failed to update ACLs", err) 877 http.Error(w, err.Error(), http.StatusInternalServerError) 878 return 879 } 880 881 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 882} 883 884func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 885 f, err := rp.repoResolver.Resolve(r) 886 if err != nil { 887 log.Println("failed to get repo and knot", err) 888 return 889 } 890 891 branch := r.FormValue("branch") 892 if branch == "" { 893 http.Error(w, "malformed form", http.StatusBadRequest) 894 return 895 } 896 897 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 898 if err != nil { 899 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 900 return 901 } 902 903 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 904 if err != nil { 905 log.Println("failed to create client to ", f.Knot) 906 return 907 } 908 909 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 910 if err != nil { 911 log.Printf("failed to make request to %s: %s", f.Knot, err) 912 return 913 } 914 915 if ksResp.StatusCode != http.StatusNoContent { 916 rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 917 return 918 } 919 920 w.Write(fmt.Append(nil, "default branch set to: ", branch)) 921} 922 923func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 924 f, err := rp.repoResolver.Resolve(r) 925 if err != nil { 926 log.Println("failed to get repo and knot", err) 927 return 928 } 929 930 switch r.Method { 931 case http.MethodGet: 932 // for now, this is just pubkeys 933 user := rp.oauth.GetUser(r) 934 repoCollaborators, err := f.Collaborators(r.Context()) 935 if err != nil { 936 log.Println("failed to get collaborators", err) 937 } 938 939 isCollaboratorInviteAllowed := false 940 if user != nil { 941 ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 942 if err == nil && ok { 943 isCollaboratorInviteAllowed = true 944 } 945 } 946 947 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 948 if err != nil { 949 log.Println("failed to create unsigned client", err) 950 return 951 } 952 953 result, err := us.Branches(f.OwnerDid(), f.RepoName) 954 if err != nil { 955 log.Println("failed to reach knotserver", err) 956 return 957 } 958 959 // all spindles that this user is a member of 960 spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 961 if err != nil { 962 log.Println("failed to fetch spindles", err) 963 return 964 } 965 966 rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 967 LoggedInUser: user, 968 RepoInfo: f.RepoInfo(user), 969 Collaborators: repoCollaborators, 970 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 971 Branches: result.Branches, 972 Spindles: spindles, 973 CurrentSpindle: f.Spindle, 974 }) 975 } 976} 977 978func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 979 user := rp.oauth.GetUser(r) 980 f, err := rp.repoResolver.Resolve(r) 981 if err != nil { 982 log.Printf("failed to resolve source repo: %v", err) 983 return 984 } 985 986 switch r.Method { 987 case http.MethodPost: 988 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 989 if err != nil { 990 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 991 return 992 } 993 994 client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 995 if err != nil { 996 rp.pages.Notice(w, "repo", "Failed to reach knot server.") 997 return 998 } 999 1000 var uri string 1001 if rp.config.Core.Dev { 1002 uri = "http" 1003 } else { 1004 uri = "https" 1005 } 1006 forkName := fmt.Sprintf("%s", f.RepoName) 1007 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1008 1009 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1010 if err != nil { 1011 rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1012 return 1013 } 1014 1015 rp.pages.HxRefresh(w) 1016 return 1017 } 1018} 1019 1020func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 1021 user := rp.oauth.GetUser(r) 1022 f, err := rp.repoResolver.Resolve(r) 1023 if err != nil { 1024 log.Printf("failed to resolve source repo: %v", err) 1025 return 1026 } 1027 1028 switch r.Method { 1029 case http.MethodGet: 1030 user := rp.oauth.GetUser(r) 1031 knots, err := rp.enforcer.GetKnotsForUser(user.Did) 1032 if err != nil { 1033 rp.pages.Notice(w, "repo", "Invalid user account.") 1034 return 1035 } 1036 1037 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1038 LoggedInUser: user, 1039 Knots: knots, 1040 RepoInfo: f.RepoInfo(user), 1041 }) 1042 1043 case http.MethodPost: 1044 1045 knot := r.FormValue("knot") 1046 if knot == "" { 1047 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") 1048 return 1049 } 1050 1051 ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1052 if err != nil || !ok { 1053 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1054 return 1055 } 1056 1057 forkName := fmt.Sprintf("%s", f.RepoName) 1058 1059 // this check is *only* to see if the forked repo name already exists 1060 // in the user's account. 1061 existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1062 if err != nil { 1063 if errors.Is(err, sql.ErrNoRows) { 1064 // no existing repo with this name found, we can use the name as is 1065 } else { 1066 log.Println("error fetching existing repo from db", err) 1067 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1068 return 1069 } 1070 } else if existingRepo != nil { 1071 // repo with this name already exists, append random string 1072 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1073 } 1074 secret, err := db.GetRegistrationKey(rp.db, knot) 1075 if err != nil { 1076 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1077 return 1078 } 1079 1080 client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev) 1081 if err != nil { 1082 rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1083 return 1084 } 1085 1086 var uri string 1087 if rp.config.Core.Dev { 1088 uri = "http" 1089 } else { 1090 uri = "https" 1091 } 1092 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1093 sourceAt := f.RepoAt.String() 1094 1095 rkey := appview.TID() 1096 repo := &db.Repo{ 1097 Did: user.Did, 1098 Name: forkName, 1099 Knot: knot, 1100 Rkey: rkey, 1101 Source: sourceAt, 1102 } 1103 1104 tx, err := rp.db.BeginTx(r.Context(), nil) 1105 if err != nil { 1106 log.Println(err) 1107 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1108 return 1109 } 1110 defer func() { 1111 tx.Rollback() 1112 err = rp.enforcer.E.LoadPolicy() 1113 if err != nil { 1114 log.Println("failed to rollback policies") 1115 } 1116 }() 1117 1118 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1119 if err != nil { 1120 rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1121 return 1122 } 1123 1124 switch resp.StatusCode { 1125 case http.StatusConflict: 1126 rp.pages.Notice(w, "repo", "A repository with that name already exists.") 1127 return 1128 case http.StatusInternalServerError: 1129 rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1130 case http.StatusNoContent: 1131 // continue 1132 } 1133 1134 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1135 if err != nil { 1136 log.Println("failed to get authorized client", err) 1137 rp.pages.Notice(w, "repo", "Failed to create repository.") 1138 return 1139 } 1140 1141 createdAt := time.Now().Format(time.RFC3339) 1142 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1143 Collection: tangled.RepoNSID, 1144 Repo: user.Did, 1145 Rkey: rkey, 1146 Record: &lexutil.LexiconTypeDecoder{ 1147 Val: &tangled.Repo{ 1148 Knot: repo.Knot, 1149 Name: repo.Name, 1150 CreatedAt: createdAt, 1151 Owner: user.Did, 1152 Source: &sourceAt, 1153 }}, 1154 }) 1155 if err != nil { 1156 log.Printf("failed to create record: %s", err) 1157 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1158 return 1159 } 1160 log.Println("created repo record: ", atresp.Uri) 1161 1162 repo.AtUri = atresp.Uri 1163 err = db.AddRepo(tx, repo) 1164 if err != nil { 1165 log.Println(err) 1166 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1167 return 1168 } 1169 1170 // acls 1171 p, _ := securejoin.SecureJoin(user.Did, forkName) 1172 err = rp.enforcer.AddRepo(user.Did, knot, p) 1173 if err != nil { 1174 log.Println(err) 1175 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1176 return 1177 } 1178 1179 err = tx.Commit() 1180 if err != nil { 1181 log.Println("failed to commit changes", err) 1182 http.Error(w, err.Error(), http.StatusInternalServerError) 1183 return 1184 } 1185 1186 err = rp.enforcer.E.SavePolicy() 1187 if err != nil { 1188 log.Println("failed to update ACLs", err) 1189 http.Error(w, err.Error(), http.StatusInternalServerError) 1190 return 1191 } 1192 1193 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1194 return 1195 } 1196} 1197 1198func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1199 user := rp.oauth.GetUser(r) 1200 f, err := rp.repoResolver.Resolve(r) 1201 if err != nil { 1202 log.Println("failed to get repo and knot", err) 1203 return 1204 } 1205 1206 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1207 if err != nil { 1208 log.Printf("failed to create unsigned client for %s", f.Knot) 1209 rp.pages.Error503(w) 1210 return 1211 } 1212 1213 result, err := us.Branches(f.OwnerDid(), f.RepoName) 1214 if err != nil { 1215 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1216 log.Println("failed to reach knotserver", err) 1217 return 1218 } 1219 branches := result.Branches 1220 sort.Slice(branches, func(i int, j int) bool { 1221 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1222 }) 1223 1224 var defaultBranch string 1225 for _, b := range branches { 1226 if b.IsDefault { 1227 defaultBranch = b.Name 1228 } 1229 } 1230 1231 base := defaultBranch 1232 head := defaultBranch 1233 1234 params := r.URL.Query() 1235 queryBase := params.Get("base") 1236 queryHead := params.Get("head") 1237 if queryBase != "" { 1238 base = queryBase 1239 } 1240 if queryHead != "" { 1241 head = queryHead 1242 } 1243 1244 tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1245 if err != nil { 1246 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1247 log.Println("failed to reach knotserver", err) 1248 return 1249 } 1250 1251 repoinfo := f.RepoInfo(user) 1252 1253 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 1254 LoggedInUser: user, 1255 RepoInfo: repoinfo, 1256 Branches: branches, 1257 Tags: tags.Tags, 1258 Base: base, 1259 Head: head, 1260 }) 1261} 1262 1263func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 1264 user := rp.oauth.GetUser(r) 1265 f, err := rp.repoResolver.Resolve(r) 1266 if err != nil { 1267 log.Println("failed to get repo and knot", err) 1268 return 1269 } 1270 1271 // if user is navigating to one of 1272 // /compare/{base}/{head} 1273 // /compare/{base}...{head} 1274 base := chi.URLParam(r, "base") 1275 head := chi.URLParam(r, "head") 1276 if base == "" && head == "" { 1277 rest := chi.URLParam(r, "*") // master...feature/xyz 1278 parts := strings.SplitN(rest, "...", 2) 1279 if len(parts) == 2 { 1280 base = parts[0] 1281 head = parts[1] 1282 } 1283 } 1284 1285 base, _ = url.PathUnescape(base) 1286 head, _ = url.PathUnescape(head) 1287 1288 if base == "" || head == "" { 1289 log.Printf("invalid comparison") 1290 rp.pages.Error404(w) 1291 return 1292 } 1293 1294 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1295 if err != nil { 1296 log.Printf("failed to create unsigned client for %s", f.Knot) 1297 rp.pages.Error503(w) 1298 return 1299 } 1300 1301 branches, err := us.Branches(f.OwnerDid(), f.RepoName) 1302 if err != nil { 1303 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1304 log.Println("failed to reach knotserver", err) 1305 return 1306 } 1307 1308 tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1309 if err != nil { 1310 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1311 log.Println("failed to reach knotserver", err) 1312 return 1313 } 1314 1315 formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 1316 if err != nil { 1317 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1318 log.Println("failed to compare", err) 1319 return 1320 } 1321 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1322 1323 repoinfo := f.RepoInfo(user) 1324 1325 rp.pages.RepoCompare(w, pages.RepoCompareParams{ 1326 LoggedInUser: user, 1327 RepoInfo: repoinfo, 1328 Branches: branches.Branches, 1329 Tags: tags.Tags, 1330 Base: base, 1331 Head: head, 1332 Diff: &diff, 1333 }) 1334 1335}