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