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