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