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