forked from tangled.org/core
this repo has no description
1package repo 2 3import ( 4 "database/sql" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "io" 9 "log" 10 mathrand "math/rand/v2" 11 "net/http" 12 "path" 13 "slices" 14 "sort" 15 "strconv" 16 "strings" 17 "time" 18 19 "tangled.sh/tangled.sh/core/api/tangled" 20 "tangled.sh/tangled.sh/core/appview" 21 "tangled.sh/tangled.sh/core/appview/config" 22 "tangled.sh/tangled.sh/core/appview/db" 23 "tangled.sh/tangled.sh/core/appview/idresolver" 24 "tangled.sh/tangled.sh/core/appview/oauth" 25 "tangled.sh/tangled.sh/core/appview/pages" 26 "tangled.sh/tangled.sh/core/appview/pages/markup" 27 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 28 "tangled.sh/tangled.sh/core/appview/pagination" 29 "tangled.sh/tangled.sh/core/appview/reporesolver" 30 "tangled.sh/tangled.sh/core/knotclient" 31 "tangled.sh/tangled.sh/core/patchutil" 32 "tangled.sh/tangled.sh/core/rbac" 33 "tangled.sh/tangled.sh/core/types" 34 35 "github.com/bluesky-social/indigo/atproto/data" 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 db *db.DB 52 enforcer *rbac.Enforcer 53 posthog posthog.Client 54} 55 56func New( 57 oauth *oauth.OAuth, 58 repoResolver *reporesolver.RepoResolver, 59 pages *pages.Pages, 60 idResolver *idresolver.Resolver, 61 db *db.DB, 62 config *config.Config, 63 posthog posthog.Client, 64 enforcer *rbac.Enforcer, 65) *Repo { 66 return &Repo{oauth: oauth, 67 repoResolver: repoResolver, 68 pages: pages, 69 idResolver: idResolver, 70 config: config, 71 db: db, 72 posthog: posthog, 73 enforcer: enforcer, 74 } 75} 76 77func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 78 ref := chi.URLParam(r, "ref") 79 f, err := rp.repoResolver.Resolve(r) 80 if err != nil { 81 log.Println("failed to fully resolve repo", err) 82 return 83 } 84 85 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 86 if err != nil { 87 log.Printf("failed to create unsigned client for %s", f.Knot) 88 rp.pages.Error503(w) 89 return 90 } 91 92 result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 93 if err != nil { 94 rp.pages.Error503(w) 95 log.Println("failed to reach knotserver", err) 96 return 97 } 98 99 tagMap := make(map[string][]string) 100 for _, tag := range result.Tags { 101 hash := tag.Hash 102 if tag.Tag != nil { 103 hash = tag.Tag.Target.String() 104 } 105 tagMap[hash] = append(tagMap[hash], tag.Name) 106 } 107 108 for _, branch := range result.Branches { 109 hash := branch.Hash 110 tagMap[hash] = append(tagMap[hash], branch.Name) 111 } 112 113 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 114 if a.Name == result.Ref { 115 return -1 116 } 117 if a.IsDefault { 118 return -1 119 } 120 if b.IsDefault { 121 return 1 122 } 123 if a.Commit != nil { 124 if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 125 return 1 126 } else { 127 return -1 128 } 129 } 130 return strings.Compare(a.Name, b.Name) * -1 131 }) 132 133 commitCount := len(result.Commits) 134 branchCount := len(result.Branches) 135 tagCount := len(result.Tags) 136 fileCount := len(result.Files) 137 138 commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount) 139 commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))] 140 tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))] 141 branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))] 142 143 emails := uniqueEmails(commitsTrunc) 144 145 user := rp.oauth.GetUser(r) 146 repoInfo := f.RepoInfo(user) 147 148 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 149 if err != nil { 150 log.Printf("failed to get registration key for %s: %s", f.Knot, err) 151 rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 152 } 153 154 signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 155 if err != nil { 156 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 157 return 158 } 159 160 var forkInfo *types.ForkInfo 161 if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 162 forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient) 163 if err != nil { 164 log.Printf("Failed to fetch fork information: %v", err) 165 return 166 } 167 } 168 169 repoLanguages, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, ref) 170 if err != nil { 171 log.Printf("failed to compute language percentages: %s", err) 172 // non-fatal 173 } 174 175 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 176 LoggedInUser: user, 177 RepoInfo: repoInfo, 178 TagMap: tagMap, 179 RepoIndexResponse: *result, 180 CommitsTrunc: commitsTrunc, 181 TagsTrunc: tagsTrunc, 182 ForkInfo: forkInfo, 183 BranchesTrunc: branchesTrunc, 184 EmailToDidOrHandle: EmailToDidOrHandle(rp, emails), 185 Languages: repoLanguages, 186 }) 187 return 188} 189 190func getForkInfo( 191 repoInfo repoinfo.RepoInfo, 192 rp *Repo, 193 f *reporesolver.ResolvedRepo, 194 user *oauth.User, 195 signedClient *knotclient.SignedClient, 196) (*types.ForkInfo, error) { 197 if user == nil { 198 return nil, nil 199 } 200 201 forkInfo := types.ForkInfo{ 202 IsFork: repoInfo.Source != nil, 203 Status: types.UpToDate, 204 } 205 206 if !forkInfo.IsFork { 207 forkInfo.IsFork = false 208 return &forkInfo, nil 209 } 210 211 us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev) 212 if err != nil { 213 log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot) 214 return nil, err 215 } 216 217 result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name) 218 if err != nil { 219 log.Println("failed to reach knotserver", err) 220 return nil, err 221 } 222 223 if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 224 return branch.Name == f.Ref 225 }) { 226 forkInfo.Status = types.MissingBranch 227 return &forkInfo, nil 228 } 229 230 newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref) 231 if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 232 log.Printf("failed to update tracking branch: %s", err) 233 return nil, err 234 } 235 236 hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 237 238 var status types.AncestorCheckResponse 239 forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 240 if err != nil { 241 log.Printf("failed to check if fork is ahead/behind: %s", err) 242 return nil, err 243 } 244 245 if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil { 246 log.Printf("failed to decode fork status: %s", err) 247 return nil, err 248 } 249 250 forkInfo.Status = status.Status 251 return &forkInfo, nil 252} 253 254func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 255 f, err := rp.repoResolver.Resolve(r) 256 if err != nil { 257 log.Println("failed to fully resolve repo", err) 258 return 259 } 260 261 page := 1 262 if r.URL.Query().Get("page") != "" { 263 page, err = strconv.Atoi(r.URL.Query().Get("page")) 264 if err != nil { 265 page = 1 266 } 267 } 268 269 ref := chi.URLParam(r, "ref") 270 271 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 272 if err != nil { 273 log.Println("failed to create unsigned client", err) 274 return 275 } 276 277 repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 278 if err != nil { 279 log.Println("failed to reach knotserver", err) 280 return 281 } 282 283 result, err := us.Tags(f.OwnerDid(), f.RepoName) 284 if err != nil { 285 log.Println("failed to reach knotserver", err) 286 return 287 } 288 289 tagMap := make(map[string][]string) 290 for _, tag := range result.Tags { 291 hash := tag.Hash 292 if tag.Tag != nil { 293 hash = tag.Tag.Target.String() 294 } 295 tagMap[hash] = append(tagMap[hash], tag.Name) 296 } 297 298 user := rp.oauth.GetUser(r) 299 rp.pages.RepoLog(w, pages.RepoLogParams{ 300 LoggedInUser: user, 301 TagMap: tagMap, 302 RepoInfo: f.RepoInfo(user), 303 RepoLogResponse: *repolog, 304 EmailToDidOrHandle: EmailToDidOrHandle(rp, uniqueEmails(repolog.Commits)), 305 }) 306 return 307} 308 309func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 310 f, err := rp.repoResolver.Resolve(r) 311 if err != nil { 312 log.Println("failed to get repo and knot", err) 313 w.WriteHeader(http.StatusBadRequest) 314 return 315 } 316 317 user := rp.oauth.GetUser(r) 318 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 319 RepoInfo: f.RepoInfo(user), 320 }) 321 return 322} 323 324func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 325 f, err := rp.repoResolver.Resolve(r) 326 if err != nil { 327 log.Println("failed to get repo and knot", err) 328 w.WriteHeader(http.StatusBadRequest) 329 return 330 } 331 332 repoAt := f.RepoAt 333 rkey := repoAt.RecordKey().String() 334 if rkey == "" { 335 log.Println("invalid aturi for repo", err) 336 w.WriteHeader(http.StatusInternalServerError) 337 return 338 } 339 340 user := rp.oauth.GetUser(r) 341 342 switch r.Method { 343 case http.MethodGet: 344 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 345 RepoInfo: f.RepoInfo(user), 346 }) 347 return 348 case http.MethodPut: 349 user := rp.oauth.GetUser(r) 350 newDescription := r.FormValue("description") 351 client, err := rp.oauth.AuthorizedClient(r) 352 if err != nil { 353 log.Println("failed to get client") 354 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 355 return 356 } 357 358 // optimistic update 359 err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 360 if err != nil { 361 log.Println("failed to perferom update-description query", err) 362 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 363 return 364 } 365 366 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 367 // 368 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 369 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 370 if err != nil { 371 // failed to get record 372 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 373 return 374 } 375 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 376 Collection: tangled.RepoNSID, 377 Repo: user.Did, 378 Rkey: rkey, 379 SwapRecord: ex.Cid, 380 Record: &lexutil.LexiconTypeDecoder{ 381 Val: &tangled.Repo{ 382 Knot: f.Knot, 383 Name: f.RepoName, 384 Owner: user.Did, 385 CreatedAt: f.CreatedAt, 386 Description: &newDescription, 387 }, 388 }, 389 }) 390 391 if err != nil { 392 log.Println("failed to perferom update-description query", err) 393 // failed to get record 394 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 395 return 396 } 397 398 newRepoInfo := f.RepoInfo(user) 399 newRepoInfo.Description = newDescription 400 401 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 402 RepoInfo: newRepoInfo, 403 }) 404 return 405 } 406} 407 408func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 409 f, err := rp.repoResolver.Resolve(r) 410 if err != nil { 411 log.Println("failed to fully resolve repo", err) 412 return 413 } 414 ref := chi.URLParam(r, "ref") 415 protocol := "http" 416 if !rp.config.Core.Dev { 417 protocol = "https" 418 } 419 420 if !plumbing.IsHash(ref) { 421 rp.pages.Error404(w) 422 return 423 } 424 425 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 426 if err != nil { 427 log.Println("failed to reach knotserver", err) 428 return 429 } 430 431 body, err := io.ReadAll(resp.Body) 432 if err != nil { 433 log.Printf("Error reading response body: %v", err) 434 return 435 } 436 437 var result types.RepoCommitResponse 438 err = json.Unmarshal(body, &result) 439 if err != nil { 440 log.Println("failed to parse response:", err) 441 return 442 } 443 444 user := rp.oauth.GetUser(r) 445 rp.pages.RepoCommit(w, pages.RepoCommitParams{ 446 LoggedInUser: user, 447 RepoInfo: f.RepoInfo(user), 448 RepoCommitResponse: result, 449 EmailToDidOrHandle: EmailToDidOrHandle(rp, []string{result.Diff.Commit.Author.Email}), 450 }) 451 return 452} 453 454func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 455 f, err := rp.repoResolver.Resolve(r) 456 if err != nil { 457 log.Println("failed to fully resolve repo", err) 458 return 459 } 460 461 ref := chi.URLParam(r, "ref") 462 treePath := chi.URLParam(r, "*") 463 protocol := "http" 464 if !rp.config.Core.Dev { 465 protocol = "https" 466 } 467 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 468 if err != nil { 469 log.Println("failed to reach knotserver", err) 470 return 471 } 472 473 body, err := io.ReadAll(resp.Body) 474 if err != nil { 475 log.Printf("Error reading response body: %v", err) 476 return 477 } 478 479 var result types.RepoTreeResponse 480 err = json.Unmarshal(body, &result) 481 if err != nil { 482 log.Println("failed to parse response:", err) 483 return 484 } 485 486 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 487 // so we can safely redirect to the "parent" (which is the same file). 488 if len(result.Files) == 0 && result.Parent == treePath { 489 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 490 return 491 } 492 493 user := rp.oauth.GetUser(r) 494 495 var breadcrumbs [][]string 496 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 497 if treePath != "" { 498 for idx, elem := range strings.Split(treePath, "/") { 499 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 500 } 501 } 502 503 baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 504 baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 505 506 rp.pages.RepoTree(w, pages.RepoTreeParams{ 507 LoggedInUser: user, 508 BreadCrumbs: breadcrumbs, 509 BaseTreeLink: baseTreeLink, 510 BaseBlobLink: baseBlobLink, 511 RepoInfo: f.RepoInfo(user), 512 RepoTreeResponse: result, 513 }) 514 return 515} 516 517func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 518 f, err := rp.repoResolver.Resolve(r) 519 if err != nil { 520 log.Println("failed to get repo and knot", err) 521 return 522 } 523 524 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 525 if err != nil { 526 log.Println("failed to create unsigned client", err) 527 return 528 } 529 530 result, err := us.Tags(f.OwnerDid(), f.RepoName) 531 if err != nil { 532 log.Println("failed to reach knotserver", err) 533 return 534 } 535 536 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt)) 537 if err != nil { 538 log.Println("failed grab artifacts", err) 539 return 540 } 541 542 // convert artifacts to map for easy UI building 543 artifactMap := make(map[plumbing.Hash][]db.Artifact) 544 for _, a := range artifacts { 545 artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 546 } 547 548 var danglingArtifacts []db.Artifact 549 for _, a := range artifacts { 550 found := false 551 for _, t := range result.Tags { 552 if t.Tag != nil { 553 if t.Tag.Hash == a.Tag { 554 found = true 555 } 556 } 557 } 558 559 if !found { 560 danglingArtifacts = append(danglingArtifacts, a) 561 } 562 } 563 564 user := rp.oauth.GetUser(r) 565 rp.pages.RepoTags(w, pages.RepoTagsParams{ 566 LoggedInUser: user, 567 RepoInfo: f.RepoInfo(user), 568 RepoTagsResponse: *result, 569 ArtifactMap: artifactMap, 570 DanglingArtifacts: danglingArtifacts, 571 }) 572 return 573} 574 575func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 576 f, err := rp.repoResolver.Resolve(r) 577 if err != nil { 578 log.Println("failed to get repo and knot", err) 579 return 580 } 581 582 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 583 if err != nil { 584 log.Println("failed to create unsigned client", err) 585 return 586 } 587 588 result, err := us.Branches(f.OwnerDid(), f.RepoName) 589 if err != nil { 590 log.Println("failed to reach knotserver", err) 591 return 592 } 593 594 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 595 if a.IsDefault { 596 return -1 597 } 598 if b.IsDefault { 599 return 1 600 } 601 if a.Commit != nil { 602 if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 603 return 1 604 } else { 605 return -1 606 } 607 } 608 return strings.Compare(a.Name, b.Name) * -1 609 }) 610 611 user := rp.oauth.GetUser(r) 612 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 613 LoggedInUser: user, 614 RepoInfo: f.RepoInfo(user), 615 RepoBranchesResponse: *result, 616 }) 617 return 618} 619 620func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 621 f, err := rp.repoResolver.Resolve(r) 622 if err != nil { 623 log.Println("failed to get repo and knot", err) 624 return 625 } 626 627 ref := chi.URLParam(r, "ref") 628 filePath := chi.URLParam(r, "*") 629 protocol := "http" 630 if !rp.config.Core.Dev { 631 protocol = "https" 632 } 633 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 634 if err != nil { 635 log.Println("failed to reach knotserver", err) 636 return 637 } 638 639 body, err := io.ReadAll(resp.Body) 640 if err != nil { 641 log.Printf("Error reading response body: %v", err) 642 return 643 } 644 645 var result types.RepoBlobResponse 646 err = json.Unmarshal(body, &result) 647 if err != nil { 648 log.Println("failed to parse response:", err) 649 return 650 } 651 652 var breadcrumbs [][]string 653 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 654 if filePath != "" { 655 for idx, elem := range strings.Split(filePath, "/") { 656 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 657 } 658 } 659 660 showRendered := false 661 renderToggle := false 662 663 if markup.GetFormat(result.Path) == markup.FormatMarkdown { 664 renderToggle = true 665 showRendered = r.URL.Query().Get("code") != "true" 666 } 667 668 user := rp.oauth.GetUser(r) 669 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 670 LoggedInUser: user, 671 RepoInfo: f.RepoInfo(user), 672 RepoBlobResponse: result, 673 BreadCrumbs: breadcrumbs, 674 ShowRendered: showRendered, 675 RenderToggle: renderToggle, 676 }) 677 return 678} 679 680func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 681 f, err := rp.repoResolver.Resolve(r) 682 if err != nil { 683 log.Println("failed to get repo and knot", err) 684 return 685 } 686 687 ref := chi.URLParam(r, "ref") 688 filePath := chi.URLParam(r, "*") 689 690 protocol := "http" 691 if !rp.config.Core.Dev { 692 protocol = "https" 693 } 694 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 695 if err != nil { 696 log.Println("failed to reach knotserver", err) 697 return 698 } 699 700 body, err := io.ReadAll(resp.Body) 701 if err != nil { 702 log.Printf("Error reading response body: %v", err) 703 return 704 } 705 706 var result types.RepoBlobResponse 707 err = json.Unmarshal(body, &result) 708 if err != nil { 709 log.Println("failed to parse response:", err) 710 return 711 } 712 713 if result.IsBinary { 714 w.Header().Set("Content-Type", "application/octet-stream") 715 w.Write(body) 716 return 717 } 718 719 w.Header().Set("Content-Type", "text/plain") 720 w.Write([]byte(result.Contents)) 721 return 722} 723 724func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 725 f, err := rp.repoResolver.Resolve(r) 726 if err != nil { 727 log.Println("failed to get repo and knot", err) 728 return 729 } 730 731 collaborator := r.FormValue("collaborator") 732 if collaborator == "" { 733 http.Error(w, "malformed form", http.StatusBadRequest) 734 return 735 } 736 737 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 738 if err != nil { 739 w.Write([]byte("failed to resolve collaborator did to a handle")) 740 return 741 } 742 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 743 744 // TODO: create an atproto record for this 745 746 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 747 if err != nil { 748 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 749 return 750 } 751 752 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 753 if err != nil { 754 log.Println("failed to create client to ", f.Knot) 755 return 756 } 757 758 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 759 if err != nil { 760 log.Printf("failed to make request to %s: %s", f.Knot, err) 761 return 762 } 763 764 if ksResp.StatusCode != http.StatusNoContent { 765 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err))) 766 return 767 } 768 769 tx, err := rp.db.BeginTx(r.Context(), nil) 770 if err != nil { 771 log.Println("failed to start tx") 772 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 773 return 774 } 775 defer func() { 776 tx.Rollback() 777 err = rp.enforcer.E.LoadPolicy() 778 if err != nil { 779 log.Println("failed to rollback policies") 780 } 781 }() 782 783 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 784 if err != nil { 785 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 786 return 787 } 788 789 err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 790 if err != nil { 791 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 792 return 793 } 794 795 err = tx.Commit() 796 if err != nil { 797 log.Println("failed to commit changes", err) 798 http.Error(w, err.Error(), http.StatusInternalServerError) 799 return 800 } 801 802 err = rp.enforcer.E.SavePolicy() 803 if err != nil { 804 log.Println("failed to update ACLs", err) 805 http.Error(w, err.Error(), http.StatusInternalServerError) 806 return 807 } 808 809 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String()))) 810 811} 812 813func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 814 user := rp.oauth.GetUser(r) 815 816 f, err := rp.repoResolver.Resolve(r) 817 if err != nil { 818 log.Println("failed to get repo and knot", err) 819 return 820 } 821 822 // remove record from pds 823 xrpcClient, err := rp.oauth.AuthorizedClient(r) 824 if err != nil { 825 log.Println("failed to get authorized client", err) 826 return 827 } 828 repoRkey := f.RepoAt.RecordKey().String() 829 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 830 Collection: tangled.RepoNSID, 831 Repo: user.Did, 832 Rkey: repoRkey, 833 }) 834 if err != nil { 835 log.Printf("failed to delete record: %s", err) 836 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 837 return 838 } 839 log.Println("removed repo record ", f.RepoAt.String()) 840 841 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 842 if err != nil { 843 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 844 return 845 } 846 847 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 848 if err != nil { 849 log.Println("failed to create client to ", f.Knot) 850 return 851 } 852 853 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 854 if err != nil { 855 log.Printf("failed to make request to %s: %s", f.Knot, err) 856 return 857 } 858 859 if ksResp.StatusCode != http.StatusNoContent { 860 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 861 } else { 862 log.Println("removed repo from knot ", f.Knot) 863 } 864 865 tx, err := rp.db.BeginTx(r.Context(), nil) 866 if err != nil { 867 log.Println("failed to start tx") 868 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 869 return 870 } 871 defer func() { 872 tx.Rollback() 873 err = rp.enforcer.E.LoadPolicy() 874 if err != nil { 875 log.Println("failed to rollback policies") 876 } 877 }() 878 879 // remove collaborator RBAC 880 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 881 if err != nil { 882 rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 883 return 884 } 885 for _, c := range repoCollaborators { 886 did := c[0] 887 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 888 } 889 log.Println("removed collaborators") 890 891 // remove repo RBAC 892 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 893 if err != nil { 894 rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 895 return 896 } 897 898 // remove repo from db 899 err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 900 if err != nil { 901 rp.pages.Notice(w, "settings-delete", "Failed to update appview") 902 return 903 } 904 log.Println("removed repo from db") 905 906 err = tx.Commit() 907 if err != nil { 908 log.Println("failed to commit changes", err) 909 http.Error(w, err.Error(), http.StatusInternalServerError) 910 return 911 } 912 913 err = rp.enforcer.E.SavePolicy() 914 if err != nil { 915 log.Println("failed to update ACLs", err) 916 http.Error(w, err.Error(), http.StatusInternalServerError) 917 return 918 } 919 920 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 921} 922 923func (rp *Repo) SetDefaultBranch(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 branch := r.FormValue("branch") 931 if branch == "" { 932 http.Error(w, "malformed form", http.StatusBadRequest) 933 return 934 } 935 936 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 937 if err != nil { 938 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 939 return 940 } 941 942 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 943 if err != nil { 944 log.Println("failed to create client to ", f.Knot) 945 return 946 } 947 948 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 949 if err != nil { 950 log.Printf("failed to make request to %s: %s", f.Knot, err) 951 return 952 } 953 954 if ksResp.StatusCode != http.StatusNoContent { 955 rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 956 return 957 } 958 959 w.Write([]byte(fmt.Sprint("default branch set to: ", branch))) 960} 961 962func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 963 f, err := rp.repoResolver.Resolve(r) 964 if err != nil { 965 log.Println("failed to get repo and knot", err) 966 return 967 } 968 969 switch r.Method { 970 case http.MethodGet: 971 // for now, this is just pubkeys 972 user := rp.oauth.GetUser(r) 973 repoCollaborators, err := f.Collaborators(r.Context()) 974 if err != nil { 975 log.Println("failed to get collaborators", err) 976 } 977 978 isCollaboratorInviteAllowed := false 979 if user != nil { 980 ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 981 if err == nil && ok { 982 isCollaboratorInviteAllowed = true 983 } 984 } 985 986 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 987 if err != nil { 988 log.Println("failed to create unsigned client", err) 989 return 990 } 991 992 result, err := us.Branches(f.OwnerDid(), f.RepoName) 993 if err != nil { 994 log.Println("failed to reach knotserver", err) 995 return 996 } 997 998 rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 999 LoggedInUser: user, 1000 RepoInfo: f.RepoInfo(user), 1001 Collaborators: repoCollaborators, 1002 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1003 Branches: result.Branches, 1004 }) 1005 } 1006} 1007 1008func (rp *Repo) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 1009 user := rp.oauth.GetUser(r) 1010 f, err := rp.repoResolver.Resolve(r) 1011 if err != nil { 1012 log.Println("failed to get repo and knot", err) 1013 return 1014 } 1015 1016 issueId := chi.URLParam(r, "issue") 1017 issueIdInt, err := strconv.Atoi(issueId) 1018 if err != nil { 1019 http.Error(w, "bad issue id", http.StatusBadRequest) 1020 log.Println("failed to parse issue id", err) 1021 return 1022 } 1023 1024 issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) 1025 if err != nil { 1026 log.Println("failed to get issue and comments", err) 1027 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1028 return 1029 } 1030 1031 issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 1032 if err != nil { 1033 log.Println("failed to resolve issue owner", err) 1034 } 1035 1036 identsToResolve := make([]string, len(comments)) 1037 for i, comment := range comments { 1038 identsToResolve[i] = comment.OwnerDid 1039 } 1040 resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 1041 didHandleMap := make(map[string]string) 1042 for _, identity := range resolvedIds { 1043 if !identity.Handle.IsInvalidHandle() { 1044 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1045 } else { 1046 didHandleMap[identity.DID.String()] = identity.DID.String() 1047 } 1048 } 1049 1050 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 1051 LoggedInUser: user, 1052 RepoInfo: f.RepoInfo(user), 1053 Issue: *issue, 1054 Comments: comments, 1055 1056 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 1057 DidHandleMap: didHandleMap, 1058 }) 1059 1060} 1061 1062func (rp *Repo) CloseIssue(w http.ResponseWriter, r *http.Request) { 1063 user := rp.oauth.GetUser(r) 1064 f, err := rp.repoResolver.Resolve(r) 1065 if err != nil { 1066 log.Println("failed to get repo and knot", err) 1067 return 1068 } 1069 1070 issueId := chi.URLParam(r, "issue") 1071 issueIdInt, err := strconv.Atoi(issueId) 1072 if err != nil { 1073 http.Error(w, "bad issue id", http.StatusBadRequest) 1074 log.Println("failed to parse issue id", err) 1075 return 1076 } 1077 1078 issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1079 if err != nil { 1080 log.Println("failed to get issue", err) 1081 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1082 return 1083 } 1084 1085 collaborators, err := f.Collaborators(r.Context()) 1086 if err != nil { 1087 log.Println("failed to fetch repo collaborators: %w", err) 1088 } 1089 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1090 return user.Did == collab.Did 1091 }) 1092 isIssueOwner := user.Did == issue.OwnerDid 1093 1094 // TODO: make this more granular 1095 if isIssueOwner || isCollaborator { 1096 1097 closed := tangled.RepoIssueStateClosed 1098 1099 client, err := rp.oauth.AuthorizedClient(r) 1100 if err != nil { 1101 log.Println("failed to get authorized client", err) 1102 return 1103 } 1104 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1105 Collection: tangled.RepoIssueStateNSID, 1106 Repo: user.Did, 1107 Rkey: appview.TID(), 1108 Record: &lexutil.LexiconTypeDecoder{ 1109 Val: &tangled.RepoIssueState{ 1110 Issue: issue.IssueAt, 1111 State: closed, 1112 }, 1113 }, 1114 }) 1115 1116 if err != nil { 1117 log.Println("failed to update issue state", err) 1118 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1119 return 1120 } 1121 1122 err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) 1123 if err != nil { 1124 log.Println("failed to close issue", err) 1125 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1126 return 1127 } 1128 1129 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1130 return 1131 } else { 1132 log.Println("user is not permitted to close issue") 1133 http.Error(w, "for biden", http.StatusUnauthorized) 1134 return 1135 } 1136} 1137 1138func (rp *Repo) ReopenIssue(w http.ResponseWriter, r *http.Request) { 1139 user := rp.oauth.GetUser(r) 1140 f, err := rp.repoResolver.Resolve(r) 1141 if err != nil { 1142 log.Println("failed to get repo and knot", err) 1143 return 1144 } 1145 1146 issueId := chi.URLParam(r, "issue") 1147 issueIdInt, err := strconv.Atoi(issueId) 1148 if err != nil { 1149 http.Error(w, "bad issue id", http.StatusBadRequest) 1150 log.Println("failed to parse issue id", err) 1151 return 1152 } 1153 1154 issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1155 if err != nil { 1156 log.Println("failed to get issue", err) 1157 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1158 return 1159 } 1160 1161 collaborators, err := f.Collaborators(r.Context()) 1162 if err != nil { 1163 log.Println("failed to fetch repo collaborators: %w", err) 1164 } 1165 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1166 return user.Did == collab.Did 1167 }) 1168 isIssueOwner := user.Did == issue.OwnerDid 1169 1170 if isCollaborator || isIssueOwner { 1171 err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) 1172 if err != nil { 1173 log.Println("failed to reopen issue", err) 1174 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 1175 return 1176 } 1177 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1178 return 1179 } else { 1180 log.Println("user is not the owner of the repo") 1181 http.Error(w, "forbidden", http.StatusUnauthorized) 1182 return 1183 } 1184} 1185 1186func (rp *Repo) NewIssueComment(w http.ResponseWriter, r *http.Request) { 1187 user := rp.oauth.GetUser(r) 1188 f, err := rp.repoResolver.Resolve(r) 1189 if err != nil { 1190 log.Println("failed to get repo and knot", err) 1191 return 1192 } 1193 1194 issueId := chi.URLParam(r, "issue") 1195 issueIdInt, err := strconv.Atoi(issueId) 1196 if err != nil { 1197 http.Error(w, "bad issue id", http.StatusBadRequest) 1198 log.Println("failed to parse issue id", err) 1199 return 1200 } 1201 1202 switch r.Method { 1203 case http.MethodPost: 1204 body := r.FormValue("body") 1205 if body == "" { 1206 rp.pages.Notice(w, "issue", "Body is required") 1207 return 1208 } 1209 1210 commentId := mathrand.IntN(1000000) 1211 rkey := appview.TID() 1212 1213 err := db.NewIssueComment(rp.db, &db.Comment{ 1214 OwnerDid: user.Did, 1215 RepoAt: f.RepoAt, 1216 Issue: issueIdInt, 1217 CommentId: commentId, 1218 Body: body, 1219 Rkey: rkey, 1220 }) 1221 if err != nil { 1222 log.Println("failed to create comment", err) 1223 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1224 return 1225 } 1226 1227 createdAt := time.Now().Format(time.RFC3339) 1228 commentIdInt64 := int64(commentId) 1229 ownerDid := user.Did 1230 issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) 1231 if err != nil { 1232 log.Println("failed to get issue at", err) 1233 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1234 return 1235 } 1236 1237 atUri := f.RepoAt.String() 1238 client, err := rp.oauth.AuthorizedClient(r) 1239 if err != nil { 1240 log.Println("failed to get authorized client", err) 1241 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1242 return 1243 } 1244 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1245 Collection: tangled.RepoIssueCommentNSID, 1246 Repo: user.Did, 1247 Rkey: rkey, 1248 Record: &lexutil.LexiconTypeDecoder{ 1249 Val: &tangled.RepoIssueComment{ 1250 Repo: &atUri, 1251 Issue: issueAt, 1252 CommentId: &commentIdInt64, 1253 Owner: &ownerDid, 1254 Body: body, 1255 CreatedAt: createdAt, 1256 }, 1257 }, 1258 }) 1259 if err != nil { 1260 log.Println("failed to create comment", err) 1261 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1262 return 1263 } 1264 1265 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 1266 return 1267 } 1268} 1269 1270func (rp *Repo) IssueComment(w http.ResponseWriter, r *http.Request) { 1271 user := rp.oauth.GetUser(r) 1272 f, err := rp.repoResolver.Resolve(r) 1273 if err != nil { 1274 log.Println("failed to get repo and knot", err) 1275 return 1276 } 1277 1278 issueId := chi.URLParam(r, "issue") 1279 issueIdInt, err := strconv.Atoi(issueId) 1280 if err != nil { 1281 http.Error(w, "bad issue id", http.StatusBadRequest) 1282 log.Println("failed to parse issue id", err) 1283 return 1284 } 1285 1286 commentId := chi.URLParam(r, "comment_id") 1287 commentIdInt, err := strconv.Atoi(commentId) 1288 if err != nil { 1289 http.Error(w, "bad comment id", http.StatusBadRequest) 1290 log.Println("failed to parse issue id", err) 1291 return 1292 } 1293 1294 issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1295 if err != nil { 1296 log.Println("failed to get issue", err) 1297 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1298 return 1299 } 1300 1301 comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 1302 if err != nil { 1303 http.Error(w, "bad comment id", http.StatusBadRequest) 1304 return 1305 } 1306 1307 identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) 1308 if err != nil { 1309 log.Println("failed to resolve did") 1310 return 1311 } 1312 1313 didHandleMap := make(map[string]string) 1314 if !identity.Handle.IsInvalidHandle() { 1315 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1316 } else { 1317 didHandleMap[identity.DID.String()] = identity.DID.String() 1318 } 1319 1320 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1321 LoggedInUser: user, 1322 RepoInfo: f.RepoInfo(user), 1323 DidHandleMap: didHandleMap, 1324 Issue: issue, 1325 Comment: comment, 1326 }) 1327} 1328 1329func (rp *Repo) EditIssueComment(w http.ResponseWriter, r *http.Request) { 1330 user := rp.oauth.GetUser(r) 1331 f, err := rp.repoResolver.Resolve(r) 1332 if err != nil { 1333 log.Println("failed to get repo and knot", err) 1334 return 1335 } 1336 1337 issueId := chi.URLParam(r, "issue") 1338 issueIdInt, err := strconv.Atoi(issueId) 1339 if err != nil { 1340 http.Error(w, "bad issue id", http.StatusBadRequest) 1341 log.Println("failed to parse issue id", err) 1342 return 1343 } 1344 1345 commentId := chi.URLParam(r, "comment_id") 1346 commentIdInt, err := strconv.Atoi(commentId) 1347 if err != nil { 1348 http.Error(w, "bad comment id", http.StatusBadRequest) 1349 log.Println("failed to parse issue id", err) 1350 return 1351 } 1352 1353 issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1354 if err != nil { 1355 log.Println("failed to get issue", err) 1356 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1357 return 1358 } 1359 1360 comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 1361 if err != nil { 1362 http.Error(w, "bad comment id", http.StatusBadRequest) 1363 return 1364 } 1365 1366 if comment.OwnerDid != user.Did { 1367 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1368 return 1369 } 1370 1371 switch r.Method { 1372 case http.MethodGet: 1373 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 1374 LoggedInUser: user, 1375 RepoInfo: f.RepoInfo(user), 1376 Issue: issue, 1377 Comment: comment, 1378 }) 1379 case http.MethodPost: 1380 // extract form value 1381 newBody := r.FormValue("body") 1382 client, err := rp.oauth.AuthorizedClient(r) 1383 if err != nil { 1384 log.Println("failed to get authorized client", err) 1385 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 1386 return 1387 } 1388 rkey := comment.Rkey 1389 1390 // optimistic update 1391 edited := time.Now() 1392 err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 1393 if err != nil { 1394 log.Println("failed to perferom update-description query", err) 1395 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 1396 return 1397 } 1398 1399 // rkey is optional, it was introduced later 1400 if comment.Rkey != "" { 1401 // update the record on pds 1402 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1403 if err != nil { 1404 // failed to get record 1405 log.Println(err, rkey) 1406 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 1407 return 1408 } 1409 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 1410 record, _ := data.UnmarshalJSON(value) 1411 1412 repoAt := record["repo"].(string) 1413 issueAt := record["issue"].(string) 1414 createdAt := record["createdAt"].(string) 1415 commentIdInt64 := int64(commentIdInt) 1416 1417 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1418 Collection: tangled.RepoIssueCommentNSID, 1419 Repo: user.Did, 1420 Rkey: rkey, 1421 SwapRecord: ex.Cid, 1422 Record: &lexutil.LexiconTypeDecoder{ 1423 Val: &tangled.RepoIssueComment{ 1424 Repo: &repoAt, 1425 Issue: issueAt, 1426 CommentId: &commentIdInt64, 1427 Owner: &comment.OwnerDid, 1428 Body: newBody, 1429 CreatedAt: createdAt, 1430 }, 1431 }, 1432 }) 1433 if err != nil { 1434 log.Println(err) 1435 } 1436 } 1437 1438 // optimistic update for htmx 1439 didHandleMap := map[string]string{ 1440 user.Did: user.Handle, 1441 } 1442 comment.Body = newBody 1443 comment.Edited = &edited 1444 1445 // return new comment body with htmx 1446 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1447 LoggedInUser: user, 1448 RepoInfo: f.RepoInfo(user), 1449 DidHandleMap: didHandleMap, 1450 Issue: issue, 1451 Comment: comment, 1452 }) 1453 return 1454 1455 } 1456 1457} 1458 1459func (rp *Repo) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 1460 user := rp.oauth.GetUser(r) 1461 f, err := rp.repoResolver.Resolve(r) 1462 if err != nil { 1463 log.Println("failed to get repo and knot", err) 1464 return 1465 } 1466 1467 issueId := chi.URLParam(r, "issue") 1468 issueIdInt, err := strconv.Atoi(issueId) 1469 if err != nil { 1470 http.Error(w, "bad issue id", http.StatusBadRequest) 1471 log.Println("failed to parse issue id", err) 1472 return 1473 } 1474 1475 issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 1476 if err != nil { 1477 log.Println("failed to get issue", err) 1478 rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1479 return 1480 } 1481 1482 commentId := chi.URLParam(r, "comment_id") 1483 commentIdInt, err := strconv.Atoi(commentId) 1484 if err != nil { 1485 http.Error(w, "bad comment id", http.StatusBadRequest) 1486 log.Println("failed to parse issue id", err) 1487 return 1488 } 1489 1490 comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 1491 if err != nil { 1492 http.Error(w, "bad comment id", http.StatusBadRequest) 1493 return 1494 } 1495 1496 if comment.OwnerDid != user.Did { 1497 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1498 return 1499 } 1500 1501 if comment.Deleted != nil { 1502 http.Error(w, "comment already deleted", http.StatusBadRequest) 1503 return 1504 } 1505 1506 // optimistic deletion 1507 deleted := time.Now() 1508 err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 1509 if err != nil { 1510 log.Println("failed to delete comment") 1511 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 1512 return 1513 } 1514 1515 // delete from pds 1516 if comment.Rkey != "" { 1517 client, err := rp.oauth.AuthorizedClient(r) 1518 if err != nil { 1519 log.Println("failed to get authorized client", err) 1520 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 1521 return 1522 } 1523 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1524 Collection: tangled.GraphFollowNSID, 1525 Repo: user.Did, 1526 Rkey: comment.Rkey, 1527 }) 1528 if err != nil { 1529 log.Println(err) 1530 } 1531 } 1532 1533 // optimistic update for htmx 1534 didHandleMap := map[string]string{ 1535 user.Did: user.Handle, 1536 } 1537 comment.Body = "" 1538 comment.Deleted = &deleted 1539 1540 // htmx fragment of comment after deletion 1541 rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1542 LoggedInUser: user, 1543 RepoInfo: f.RepoInfo(user), 1544 DidHandleMap: didHandleMap, 1545 Issue: issue, 1546 Comment: comment, 1547 }) 1548 return 1549} 1550 1551func (rp *Repo) RepoIssues(w http.ResponseWriter, r *http.Request) { 1552 params := r.URL.Query() 1553 state := params.Get("state") 1554 isOpen := true 1555 switch state { 1556 case "open": 1557 isOpen = true 1558 case "closed": 1559 isOpen = false 1560 default: 1561 isOpen = true 1562 } 1563 1564 page, ok := r.Context().Value("page").(pagination.Page) 1565 if !ok { 1566 log.Println("failed to get page") 1567 page = pagination.FirstPage() 1568 } 1569 1570 user := rp.oauth.GetUser(r) 1571 f, err := rp.repoResolver.Resolve(r) 1572 if err != nil { 1573 log.Println("failed to get repo and knot", err) 1574 return 1575 } 1576 1577 issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 1578 if err != nil { 1579 log.Println("failed to get issues", err) 1580 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 1581 return 1582 } 1583 1584 identsToResolve := make([]string, len(issues)) 1585 for i, issue := range issues { 1586 identsToResolve[i] = issue.OwnerDid 1587 } 1588 resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 1589 didHandleMap := make(map[string]string) 1590 for _, identity := range resolvedIds { 1591 if !identity.Handle.IsInvalidHandle() { 1592 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1593 } else { 1594 didHandleMap[identity.DID.String()] = identity.DID.String() 1595 } 1596 } 1597 1598 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 1599 LoggedInUser: rp.oauth.GetUser(r), 1600 RepoInfo: f.RepoInfo(user), 1601 Issues: issues, 1602 DidHandleMap: didHandleMap, 1603 FilteringByOpen: isOpen, 1604 Page: page, 1605 }) 1606 return 1607} 1608 1609func (rp *Repo) NewIssue(w http.ResponseWriter, r *http.Request) { 1610 user := rp.oauth.GetUser(r) 1611 1612 f, err := rp.repoResolver.Resolve(r) 1613 if err != nil { 1614 log.Println("failed to get repo and knot", err) 1615 return 1616 } 1617 1618 switch r.Method { 1619 case http.MethodGet: 1620 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1621 LoggedInUser: user, 1622 RepoInfo: f.RepoInfo(user), 1623 }) 1624 case http.MethodPost: 1625 title := r.FormValue("title") 1626 body := r.FormValue("body") 1627 1628 if title == "" || body == "" { 1629 rp.pages.Notice(w, "issues", "Title and body are required") 1630 return 1631 } 1632 1633 tx, err := rp.db.BeginTx(r.Context(), nil) 1634 if err != nil { 1635 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 1636 return 1637 } 1638 1639 err = db.NewIssue(tx, &db.Issue{ 1640 RepoAt: f.RepoAt, 1641 Title: title, 1642 Body: body, 1643 OwnerDid: user.Did, 1644 }) 1645 if err != nil { 1646 log.Println("failed to create issue", err) 1647 rp.pages.Notice(w, "issues", "Failed to create issue.") 1648 return 1649 } 1650 1651 issueId, err := db.GetIssueId(rp.db, f.RepoAt) 1652 if err != nil { 1653 log.Println("failed to get issue id", err) 1654 rp.pages.Notice(w, "issues", "Failed to create issue.") 1655 return 1656 } 1657 1658 client, err := rp.oauth.AuthorizedClient(r) 1659 if err != nil { 1660 log.Println("failed to get authorized client", err) 1661 rp.pages.Notice(w, "issues", "Failed to create issue.") 1662 return 1663 } 1664 atUri := f.RepoAt.String() 1665 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1666 Collection: tangled.RepoIssueNSID, 1667 Repo: user.Did, 1668 Rkey: appview.TID(), 1669 Record: &lexutil.LexiconTypeDecoder{ 1670 Val: &tangled.RepoIssue{ 1671 Repo: atUri, 1672 Title: title, 1673 Body: &body, 1674 Owner: user.Did, 1675 IssueId: int64(issueId), 1676 }, 1677 }, 1678 }) 1679 if err != nil { 1680 log.Println("failed to create issue", err) 1681 rp.pages.Notice(w, "issues", "Failed to create issue.") 1682 return 1683 } 1684 1685 err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) 1686 if err != nil { 1687 log.Println("failed to set issue at", err) 1688 rp.pages.Notice(w, "issues", "Failed to create issue.") 1689 return 1690 } 1691 1692 if !rp.config.Core.Dev { 1693 err = rp.posthog.Enqueue(posthog.Capture{ 1694 DistinctId: user.Did, 1695 Event: "new_issue", 1696 Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, 1697 }) 1698 if err != nil { 1699 log.Println("failed to enqueue posthog event:", err) 1700 } 1701 } 1702 1703 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1704 return 1705 } 1706} 1707 1708func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1709 user := rp.oauth.GetUser(r) 1710 f, err := rp.repoResolver.Resolve(r) 1711 if err != nil { 1712 log.Printf("failed to resolve source repo: %v", err) 1713 return 1714 } 1715 1716 switch r.Method { 1717 case http.MethodPost: 1718 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1719 if err != nil { 1720 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %rp.", f.Knot)) 1721 return 1722 } 1723 1724 client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1725 if err != nil { 1726 rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1727 return 1728 } 1729 1730 var uri string 1731 if rp.config.Core.Dev { 1732 uri = "http" 1733 } else { 1734 uri = "https" 1735 } 1736 forkName := fmt.Sprintf("%s", f.RepoName) 1737 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1738 1739 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1740 if err != nil { 1741 rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1742 return 1743 } 1744 1745 rp.pages.HxRefresh(w) 1746 return 1747 } 1748} 1749 1750func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 1751 user := rp.oauth.GetUser(r) 1752 f, err := rp.repoResolver.Resolve(r) 1753 if err != nil { 1754 log.Printf("failed to resolve source repo: %v", err) 1755 return 1756 } 1757 1758 switch r.Method { 1759 case http.MethodGet: 1760 user := rp.oauth.GetUser(r) 1761 knots, err := rp.enforcer.GetDomainsForUser(user.Did) 1762 if err != nil { 1763 rp.pages.Notice(w, "repo", "Invalid user account.") 1764 return 1765 } 1766 1767 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1768 LoggedInUser: user, 1769 Knots: knots, 1770 RepoInfo: f.RepoInfo(user), 1771 }) 1772 1773 case http.MethodPost: 1774 1775 knot := r.FormValue("knot") 1776 if knot == "" { 1777 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") 1778 return 1779 } 1780 1781 ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1782 if err != nil || !ok { 1783 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1784 return 1785 } 1786 1787 forkName := fmt.Sprintf("%s", f.RepoName) 1788 1789 // this check is *only* to see if the forked repo name already exists 1790 // in the user's account. 1791 existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1792 if err != nil { 1793 if errors.Is(err, sql.ErrNoRows) { 1794 // no existing repo with this name found, we can use the name as is 1795 } else { 1796 log.Println("error fetching existing repo from db", err) 1797 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1798 return 1799 } 1800 } else if existingRepo != nil { 1801 // repo with this name already exists, append random string 1802 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1803 } 1804 secret, err := db.GetRegistrationKey(rp.db, knot) 1805 if err != nil { 1806 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %rp.", knot)) 1807 return 1808 } 1809 1810 client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev) 1811 if err != nil { 1812 rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1813 return 1814 } 1815 1816 var uri string 1817 if rp.config.Core.Dev { 1818 uri = "http" 1819 } else { 1820 uri = "https" 1821 } 1822 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1823 sourceAt := f.RepoAt.String() 1824 1825 rkey := appview.TID() 1826 repo := &db.Repo{ 1827 Did: user.Did, 1828 Name: forkName, 1829 Knot: knot, 1830 Rkey: rkey, 1831 Source: sourceAt, 1832 } 1833 1834 tx, err := rp.db.BeginTx(r.Context(), nil) 1835 if err != nil { 1836 log.Println(err) 1837 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1838 return 1839 } 1840 defer func() { 1841 tx.Rollback() 1842 err = rp.enforcer.E.LoadPolicy() 1843 if err != nil { 1844 log.Println("failed to rollback policies") 1845 } 1846 }() 1847 1848 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1849 if err != nil { 1850 rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1851 return 1852 } 1853 1854 switch resp.StatusCode { 1855 case http.StatusConflict: 1856 rp.pages.Notice(w, "repo", "A repository with that name already exists.") 1857 return 1858 case http.StatusInternalServerError: 1859 rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1860 case http.StatusNoContent: 1861 // continue 1862 } 1863 1864 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1865 if err != nil { 1866 log.Println("failed to get authorized client", err) 1867 rp.pages.Notice(w, "repo", "Failed to create repository.") 1868 return 1869 } 1870 1871 createdAt := time.Now().Format(time.RFC3339) 1872 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1873 Collection: tangled.RepoNSID, 1874 Repo: user.Did, 1875 Rkey: rkey, 1876 Record: &lexutil.LexiconTypeDecoder{ 1877 Val: &tangled.Repo{ 1878 Knot: repo.Knot, 1879 Name: repo.Name, 1880 CreatedAt: createdAt, 1881 Owner: user.Did, 1882 Source: &sourceAt, 1883 }}, 1884 }) 1885 if err != nil { 1886 log.Printf("failed to create record: %s", err) 1887 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1888 return 1889 } 1890 log.Println("created repo record: ", atresp.Uri) 1891 1892 repo.AtUri = atresp.Uri 1893 err = db.AddRepo(tx, repo) 1894 if err != nil { 1895 log.Println(err) 1896 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1897 return 1898 } 1899 1900 // acls 1901 p, _ := securejoin.SecureJoin(user.Did, forkName) 1902 err = rp.enforcer.AddRepo(user.Did, knot, p) 1903 if err != nil { 1904 log.Println(err) 1905 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1906 return 1907 } 1908 1909 err = tx.Commit() 1910 if err != nil { 1911 log.Println("failed to commit changes", err) 1912 http.Error(w, err.Error(), http.StatusInternalServerError) 1913 return 1914 } 1915 1916 err = rp.enforcer.E.SavePolicy() 1917 if err != nil { 1918 log.Println("failed to update ACLs", err) 1919 http.Error(w, err.Error(), http.StatusInternalServerError) 1920 return 1921 } 1922 1923 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1924 return 1925 } 1926} 1927 1928func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1929 user := rp.oauth.GetUser(r) 1930 f, err := rp.repoResolver.Resolve(r) 1931 if err != nil { 1932 log.Println("failed to get repo and knot", err) 1933 return 1934 } 1935 1936 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1937 if err != nil { 1938 log.Printf("failed to create unsigned client for %s", f.Knot) 1939 rp.pages.Error503(w) 1940 return 1941 } 1942 1943 result, err := us.Branches(f.OwnerDid(), f.RepoName) 1944 if err != nil { 1945 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1946 log.Println("failed to reach knotserver", err) 1947 return 1948 } 1949 branches := result.Branches 1950 sort.Slice(branches, func(i int, j int) bool { 1951 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1952 }) 1953 1954 var defaultBranch string 1955 for _, b := range branches { 1956 if b.IsDefault { 1957 defaultBranch = b.Name 1958 } 1959 } 1960 1961 base := defaultBranch 1962 head := defaultBranch 1963 1964 params := r.URL.Query() 1965 queryBase := params.Get("base") 1966 queryHead := params.Get("head") 1967 if queryBase != "" { 1968 base = queryBase 1969 } 1970 if queryHead != "" { 1971 head = queryHead 1972 } 1973 1974 tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1975 if err != nil { 1976 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1977 log.Println("failed to reach knotserver", err) 1978 return 1979 } 1980 1981 repoinfo := f.RepoInfo(user) 1982 1983 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 1984 LoggedInUser: user, 1985 RepoInfo: repoinfo, 1986 Branches: branches, 1987 Tags: tags.Tags, 1988 Base: base, 1989 Head: head, 1990 }) 1991} 1992 1993func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 1994 user := rp.oauth.GetUser(r) 1995 f, err := rp.repoResolver.Resolve(r) 1996 if err != nil { 1997 log.Println("failed to get repo and knot", err) 1998 return 1999 } 2000 2001 // if user is navigating to one of 2002 // /compare/{base}/{head} 2003 // /compare/{base}...{head} 2004 base := chi.URLParam(r, "base") 2005 head := chi.URLParam(r, "head") 2006 if base == "" && head == "" { 2007 rest := chi.URLParam(r, "*") // master...feature/xyz 2008 parts := strings.SplitN(rest, "...", 2) 2009 if len(parts) == 2 { 2010 base = parts[0] 2011 head = parts[1] 2012 } 2013 } 2014 2015 if base == "" || head == "" { 2016 log.Printf("invalid comparison") 2017 rp.pages.Error404(w) 2018 return 2019 } 2020 2021 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 2022 if err != nil { 2023 log.Printf("failed to create unsigned client for %s", f.Knot) 2024 rp.pages.Error503(w) 2025 return 2026 } 2027 2028 branches, err := us.Branches(f.OwnerDid(), f.RepoName) 2029 if err != nil { 2030 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2031 log.Println("failed to reach knotserver", err) 2032 return 2033 } 2034 2035 tags, err := us.Tags(f.OwnerDid(), f.RepoName) 2036 if err != nil { 2037 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2038 log.Println("failed to reach knotserver", err) 2039 return 2040 } 2041 2042 formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 2043 if err != nil { 2044 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2045 log.Println("failed to compare", err) 2046 return 2047 } 2048 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 2049 2050 repoinfo := f.RepoInfo(user) 2051 2052 rp.pages.RepoCompare(w, pages.RepoCompareParams{ 2053 LoggedInUser: user, 2054 RepoInfo: repoinfo, 2055 Branches: branches.Branches, 2056 Tags: tags.Tags, 2057 Base: base, 2058 Head: head, 2059 Diff: &diff, 2060 }) 2061 2062}