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 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 985 if err != nil { 986 log.Println("failed to create unsigned client", err) 987 return 988 } 989 990 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 991 if err != nil { 992 log.Println("failed to reach knotserver", err) 993 return 994 } 995 defer resp.Body.Close() 996 997 body, err := io.ReadAll(resp.Body) 998 if err != nil { 999 log.Printf("Error reading response body: %v", err) 1000 } 1001 1002 var result types.RepoBranchesResponse 1003 err = json.Unmarshal(body, &result) 1004 if err != nil { 1005 log.Println("failed to parse response:", err) 1006 } 1007 1008 s.pages.RepoSettings(w, pages.RepoSettingsParams{ 1009 LoggedInUser: user, 1010 RepoInfo: f.RepoInfo(s, user), 1011 Collaborators: repoCollaborators, 1012 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1013 Branches: result.Branches, 1014 }) 1015 } 1016} 1017 1018type FullyResolvedRepo struct { 1019 Knot string 1020 OwnerId identity.Identity 1021 RepoName string 1022 RepoAt syntax.ATURI 1023 Description string 1024 CreatedAt string 1025 Ref string 1026 CurrentDir string 1027} 1028 1029func (f *FullyResolvedRepo) OwnerDid() string { 1030 return f.OwnerId.DID.String() 1031} 1032 1033func (f *FullyResolvedRepo) OwnerHandle() string { 1034 return f.OwnerId.Handle.String() 1035} 1036 1037func (f *FullyResolvedRepo) OwnerSlashRepo() string { 1038 handle := f.OwnerId.Handle 1039 1040 var p string 1041 if handle != "" && !handle.IsInvalidHandle() { 1042 p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 1043 } else { 1044 p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 1045 } 1046 1047 return p 1048} 1049 1050func (f *FullyResolvedRepo) DidSlashRepo() string { 1051 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 1052 return p 1053} 1054 1055func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 1056 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 1057 if err != nil { 1058 return nil, err 1059 } 1060 1061 var collaborators []pages.Collaborator 1062 for _, item := range repoCollaborators { 1063 // currently only two roles: owner and member 1064 var role string 1065 if item[3] == "repo:owner" { 1066 role = "owner" 1067 } else if item[3] == "repo:collaborator" { 1068 role = "collaborator" 1069 } else { 1070 continue 1071 } 1072 1073 did := item[0] 1074 1075 c := pages.Collaborator{ 1076 Did: did, 1077 Handle: "", 1078 Role: role, 1079 } 1080 collaborators = append(collaborators, c) 1081 } 1082 1083 // populate all collborators with handles 1084 identsToResolve := make([]string, len(collaborators)) 1085 for i, collab := range collaborators { 1086 identsToResolve[i] = collab.Did 1087 } 1088 1089 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve) 1090 for i, resolved := range resolvedIdents { 1091 if resolved != nil { 1092 collaborators[i].Handle = resolved.Handle.String() 1093 } 1094 } 1095 1096 return collaborators, nil 1097} 1098 1099func (f *FullyResolvedRepo) RepoInfo(s *State, u *oauth.User) repoinfo.RepoInfo { 1100 isStarred := false 1101 if u != nil { 1102 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) 1103 } 1104 1105 starCount, err := db.GetStarCount(s.db, f.RepoAt) 1106 if err != nil { 1107 log.Println("failed to get star count for ", f.RepoAt) 1108 } 1109 issueCount, err := db.GetIssueCount(s.db, f.RepoAt) 1110 if err != nil { 1111 log.Println("failed to get issue count for ", f.RepoAt) 1112 } 1113 pullCount, err := db.GetPullCount(s.db, f.RepoAt) 1114 if err != nil { 1115 log.Println("failed to get issue count for ", f.RepoAt) 1116 } 1117 source, err := db.GetRepoSource(s.db, f.RepoAt) 1118 if errors.Is(err, sql.ErrNoRows) { 1119 source = "" 1120 } else if err != nil { 1121 log.Println("failed to get repo source for ", f.RepoAt, err) 1122 } 1123 1124 var sourceRepo *db.Repo 1125 if source != "" { 1126 sourceRepo, err = db.GetRepoByAtUri(s.db, source) 1127 if err != nil { 1128 log.Println("failed to get repo by at uri", err) 1129 } 1130 } 1131 1132 var sourceHandle *identity.Identity 1133 if sourceRepo != nil { 1134 sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did) 1135 if err != nil { 1136 log.Println("failed to resolve source repo", err) 1137 } 1138 } 1139 1140 knot := f.Knot 1141 var disableFork bool 1142 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 1143 if err != nil { 1144 log.Printf("failed to create unsigned client for %s: %v", knot, err) 1145 } else { 1146 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 1147 if err != nil { 1148 log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 1149 } else { 1150 defer resp.Body.Close() 1151 body, err := io.ReadAll(resp.Body) 1152 if err != nil { 1153 log.Printf("error reading branch response body: %v", err) 1154 } else { 1155 var branchesResp types.RepoBranchesResponse 1156 if err := json.Unmarshal(body, &branchesResp); err != nil { 1157 log.Printf("error parsing branch response: %v", err) 1158 } else { 1159 disableFork = false 1160 } 1161 1162 if len(branchesResp.Branches) == 0 { 1163 disableFork = true 1164 } 1165 } 1166 } 1167 } 1168 1169 repoInfo := repoinfo.RepoInfo{ 1170 OwnerDid: f.OwnerDid(), 1171 OwnerHandle: f.OwnerHandle(), 1172 Name: f.RepoName, 1173 RepoAt: f.RepoAt, 1174 Description: f.Description, 1175 Ref: f.Ref, 1176 IsStarred: isStarred, 1177 Knot: knot, 1178 Roles: RolesInRepo(s, u, f), 1179 Stats: db.RepoStats{ 1180 StarCount: starCount, 1181 IssueCount: issueCount, 1182 PullCount: pullCount, 1183 }, 1184 DisableFork: disableFork, 1185 CurrentDir: f.CurrentDir, 1186 } 1187 1188 if sourceRepo != nil { 1189 repoInfo.Source = sourceRepo 1190 repoInfo.SourceHandle = sourceHandle.Handle.String() 1191 } 1192 1193 return repoInfo 1194} 1195 1196func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 1197 user := s.oauth.GetUser(r) 1198 f, err := s.fullyResolvedRepo(r) 1199 if err != nil { 1200 log.Println("failed to get repo and knot", err) 1201 return 1202 } 1203 1204 issueId := chi.URLParam(r, "issue") 1205 issueIdInt, err := strconv.Atoi(issueId) 1206 if err != nil { 1207 http.Error(w, "bad issue id", http.StatusBadRequest) 1208 log.Println("failed to parse issue id", err) 1209 return 1210 } 1211 1212 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt) 1213 if err != nil { 1214 log.Println("failed to get issue and comments", err) 1215 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1216 return 1217 } 1218 1219 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 1220 if err != nil { 1221 log.Println("failed to resolve issue owner", err) 1222 } 1223 1224 identsToResolve := make([]string, len(comments)) 1225 for i, comment := range comments { 1226 identsToResolve[i] = comment.OwnerDid 1227 } 1228 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1229 didHandleMap := make(map[string]string) 1230 for _, identity := range resolvedIds { 1231 if !identity.Handle.IsInvalidHandle() { 1232 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1233 } else { 1234 didHandleMap[identity.DID.String()] = identity.DID.String() 1235 } 1236 } 1237 1238 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 1239 LoggedInUser: user, 1240 RepoInfo: f.RepoInfo(s, user), 1241 Issue: *issue, 1242 Comments: comments, 1243 1244 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 1245 DidHandleMap: didHandleMap, 1246 }) 1247 1248} 1249 1250func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 1251 user := s.oauth.GetUser(r) 1252 f, err := s.fullyResolvedRepo(r) 1253 if err != nil { 1254 log.Println("failed to get repo and knot", err) 1255 return 1256 } 1257 1258 issueId := chi.URLParam(r, "issue") 1259 issueIdInt, err := strconv.Atoi(issueId) 1260 if err != nil { 1261 http.Error(w, "bad issue id", http.StatusBadRequest) 1262 log.Println("failed to parse issue id", err) 1263 return 1264 } 1265 1266 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1267 if err != nil { 1268 log.Println("failed to get issue", err) 1269 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1270 return 1271 } 1272 1273 collaborators, err := f.Collaborators(r.Context(), s) 1274 if err != nil { 1275 log.Println("failed to fetch repo collaborators: %w", err) 1276 } 1277 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1278 return user.Did == collab.Did 1279 }) 1280 isIssueOwner := user.Did == issue.OwnerDid 1281 1282 // TODO: make this more granular 1283 if isIssueOwner || isCollaborator { 1284 1285 closed := tangled.RepoIssueStateClosed 1286 1287 client, err := s.oauth.AuthorizedClient(r) 1288 if err != nil { 1289 log.Println("failed to get authorized client", err) 1290 return 1291 } 1292 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1293 Collection: tangled.RepoIssueStateNSID, 1294 Repo: user.Did, 1295 Rkey: appview.TID(), 1296 Record: &lexutil.LexiconTypeDecoder{ 1297 Val: &tangled.RepoIssueState{ 1298 Issue: issue.IssueAt, 1299 State: closed, 1300 }, 1301 }, 1302 }) 1303 1304 if err != nil { 1305 log.Println("failed to update issue state", err) 1306 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1307 return 1308 } 1309 1310 err = db.CloseIssue(s.db, f.RepoAt, issueIdInt) 1311 if err != nil { 1312 log.Println("failed to close issue", err) 1313 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1314 return 1315 } 1316 1317 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1318 return 1319 } else { 1320 log.Println("user is not permitted to close issue") 1321 http.Error(w, "for biden", http.StatusUnauthorized) 1322 return 1323 } 1324} 1325 1326func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 1327 user := s.oauth.GetUser(r) 1328 f, err := s.fullyResolvedRepo(r) 1329 if err != nil { 1330 log.Println("failed to get repo and knot", err) 1331 return 1332 } 1333 1334 issueId := chi.URLParam(r, "issue") 1335 issueIdInt, err := strconv.Atoi(issueId) 1336 if err != nil { 1337 http.Error(w, "bad issue id", http.StatusBadRequest) 1338 log.Println("failed to parse issue id", err) 1339 return 1340 } 1341 1342 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1343 if err != nil { 1344 log.Println("failed to get issue", err) 1345 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1346 return 1347 } 1348 1349 collaborators, err := f.Collaborators(r.Context(), s) 1350 if err != nil { 1351 log.Println("failed to fetch repo collaborators: %w", err) 1352 } 1353 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1354 return user.Did == collab.Did 1355 }) 1356 isIssueOwner := user.Did == issue.OwnerDid 1357 1358 if isCollaborator || isIssueOwner { 1359 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 1360 if err != nil { 1361 log.Println("failed to reopen issue", err) 1362 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 1363 return 1364 } 1365 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1366 return 1367 } else { 1368 log.Println("user is not the owner of the repo") 1369 http.Error(w, "forbidden", http.StatusUnauthorized) 1370 return 1371 } 1372} 1373 1374func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { 1375 user := s.oauth.GetUser(r) 1376 f, err := s.fullyResolvedRepo(r) 1377 if err != nil { 1378 log.Println("failed to get repo and knot", err) 1379 return 1380 } 1381 1382 issueId := chi.URLParam(r, "issue") 1383 issueIdInt, err := strconv.Atoi(issueId) 1384 if err != nil { 1385 http.Error(w, "bad issue id", http.StatusBadRequest) 1386 log.Println("failed to parse issue id", err) 1387 return 1388 } 1389 1390 switch r.Method { 1391 case http.MethodPost: 1392 body := r.FormValue("body") 1393 if body == "" { 1394 s.pages.Notice(w, "issue", "Body is required") 1395 return 1396 } 1397 1398 commentId := mathrand.IntN(1000000) 1399 rkey := appview.TID() 1400 1401 err := db.NewIssueComment(s.db, &db.Comment{ 1402 OwnerDid: user.Did, 1403 RepoAt: f.RepoAt, 1404 Issue: issueIdInt, 1405 CommentId: commentId, 1406 Body: body, 1407 Rkey: rkey, 1408 }) 1409 if err != nil { 1410 log.Println("failed to create comment", err) 1411 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1412 return 1413 } 1414 1415 createdAt := time.Now().Format(time.RFC3339) 1416 commentIdInt64 := int64(commentId) 1417 ownerDid := user.Did 1418 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 1419 if err != nil { 1420 log.Println("failed to get issue at", err) 1421 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1422 return 1423 } 1424 1425 atUri := f.RepoAt.String() 1426 client, err := s.oauth.AuthorizedClient(r) 1427 if err != nil { 1428 log.Println("failed to get authorized client", err) 1429 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1430 return 1431 } 1432 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1433 Collection: tangled.RepoIssueCommentNSID, 1434 Repo: user.Did, 1435 Rkey: rkey, 1436 Record: &lexutil.LexiconTypeDecoder{ 1437 Val: &tangled.RepoIssueComment{ 1438 Repo: &atUri, 1439 Issue: issueAt, 1440 CommentId: &commentIdInt64, 1441 Owner: &ownerDid, 1442 Body: body, 1443 CreatedAt: createdAt, 1444 }, 1445 }, 1446 }) 1447 if err != nil { 1448 log.Println("failed to create comment", err) 1449 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1450 return 1451 } 1452 1453 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 1454 return 1455 } 1456} 1457 1458func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 1459 user := s.oauth.GetUser(r) 1460 f, err := s.fullyResolvedRepo(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 commentId := chi.URLParam(r, "comment_id") 1475 commentIdInt, err := strconv.Atoi(commentId) 1476 if err != nil { 1477 http.Error(w, "bad comment id", http.StatusBadRequest) 1478 log.Println("failed to parse issue id", err) 1479 return 1480 } 1481 1482 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1483 if err != nil { 1484 log.Println("failed to get issue", err) 1485 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1486 return 1487 } 1488 1489 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1490 if err != nil { 1491 http.Error(w, "bad comment id", http.StatusBadRequest) 1492 return 1493 } 1494 1495 identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid) 1496 if err != nil { 1497 log.Println("failed to resolve did") 1498 return 1499 } 1500 1501 didHandleMap := make(map[string]string) 1502 if !identity.Handle.IsInvalidHandle() { 1503 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1504 } else { 1505 didHandleMap[identity.DID.String()] = identity.DID.String() 1506 } 1507 1508 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1509 LoggedInUser: user, 1510 RepoInfo: f.RepoInfo(s, user), 1511 DidHandleMap: didHandleMap, 1512 Issue: issue, 1513 Comment: comment, 1514 }) 1515} 1516 1517func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { 1518 user := s.oauth.GetUser(r) 1519 f, err := s.fullyResolvedRepo(r) 1520 if err != nil { 1521 log.Println("failed to get repo and knot", err) 1522 return 1523 } 1524 1525 issueId := chi.URLParam(r, "issue") 1526 issueIdInt, err := strconv.Atoi(issueId) 1527 if err != nil { 1528 http.Error(w, "bad issue id", http.StatusBadRequest) 1529 log.Println("failed to parse issue id", err) 1530 return 1531 } 1532 1533 commentId := chi.URLParam(r, "comment_id") 1534 commentIdInt, err := strconv.Atoi(commentId) 1535 if err != nil { 1536 http.Error(w, "bad comment id", http.StatusBadRequest) 1537 log.Println("failed to parse issue id", err) 1538 return 1539 } 1540 1541 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1542 if err != nil { 1543 log.Println("failed to get issue", err) 1544 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1545 return 1546 } 1547 1548 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1549 if err != nil { 1550 http.Error(w, "bad comment id", http.StatusBadRequest) 1551 return 1552 } 1553 1554 if comment.OwnerDid != user.Did { 1555 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1556 return 1557 } 1558 1559 switch r.Method { 1560 case http.MethodGet: 1561 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 1562 LoggedInUser: user, 1563 RepoInfo: f.RepoInfo(s, user), 1564 Issue: issue, 1565 Comment: comment, 1566 }) 1567 case http.MethodPost: 1568 // extract form value 1569 newBody := r.FormValue("body") 1570 client, err := s.oauth.AuthorizedClient(r) 1571 if err != nil { 1572 log.Println("failed to get authorized client", err) 1573 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1574 return 1575 } 1576 rkey := comment.Rkey 1577 1578 // optimistic update 1579 edited := time.Now() 1580 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 1581 if err != nil { 1582 log.Println("failed to perferom update-description query", err) 1583 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 1584 return 1585 } 1586 1587 // rkey is optional, it was introduced later 1588 if comment.Rkey != "" { 1589 // update the record on pds 1590 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1591 if err != nil { 1592 // failed to get record 1593 log.Println(err, rkey) 1594 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 1595 return 1596 } 1597 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 1598 record, _ := data.UnmarshalJSON(value) 1599 1600 repoAt := record["repo"].(string) 1601 issueAt := record["issue"].(string) 1602 createdAt := record["createdAt"].(string) 1603 commentIdInt64 := int64(commentIdInt) 1604 1605 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1606 Collection: tangled.RepoIssueCommentNSID, 1607 Repo: user.Did, 1608 Rkey: rkey, 1609 SwapRecord: ex.Cid, 1610 Record: &lexutil.LexiconTypeDecoder{ 1611 Val: &tangled.RepoIssueComment{ 1612 Repo: &repoAt, 1613 Issue: issueAt, 1614 CommentId: &commentIdInt64, 1615 Owner: &comment.OwnerDid, 1616 Body: newBody, 1617 CreatedAt: createdAt, 1618 }, 1619 }, 1620 }) 1621 if err != nil { 1622 log.Println(err) 1623 } 1624 } 1625 1626 // optimistic update for htmx 1627 didHandleMap := map[string]string{ 1628 user.Did: user.Handle, 1629 } 1630 comment.Body = newBody 1631 comment.Edited = &edited 1632 1633 // return new comment body with htmx 1634 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1635 LoggedInUser: user, 1636 RepoInfo: f.RepoInfo(s, user), 1637 DidHandleMap: didHandleMap, 1638 Issue: issue, 1639 Comment: comment, 1640 }) 1641 return 1642 1643 } 1644 1645} 1646 1647func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 1648 user := s.oauth.GetUser(r) 1649 f, err := s.fullyResolvedRepo(r) 1650 if err != nil { 1651 log.Println("failed to get repo and knot", err) 1652 return 1653 } 1654 1655 issueId := chi.URLParam(r, "issue") 1656 issueIdInt, err := strconv.Atoi(issueId) 1657 if err != nil { 1658 http.Error(w, "bad issue id", http.StatusBadRequest) 1659 log.Println("failed to parse issue id", err) 1660 return 1661 } 1662 1663 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1664 if err != nil { 1665 log.Println("failed to get issue", err) 1666 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1667 return 1668 } 1669 1670 commentId := chi.URLParam(r, "comment_id") 1671 commentIdInt, err := strconv.Atoi(commentId) 1672 if err != nil { 1673 http.Error(w, "bad comment id", http.StatusBadRequest) 1674 log.Println("failed to parse issue id", err) 1675 return 1676 } 1677 1678 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1679 if err != nil { 1680 http.Error(w, "bad comment id", http.StatusBadRequest) 1681 return 1682 } 1683 1684 if comment.OwnerDid != user.Did { 1685 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1686 return 1687 } 1688 1689 if comment.Deleted != nil { 1690 http.Error(w, "comment already deleted", http.StatusBadRequest) 1691 return 1692 } 1693 1694 // optimistic deletion 1695 deleted := time.Now() 1696 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1697 if err != nil { 1698 log.Println("failed to delete comment") 1699 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 1700 return 1701 } 1702 1703 // delete from pds 1704 if comment.Rkey != "" { 1705 client, err := s.oauth.AuthorizedClient(r) 1706 if err != nil { 1707 log.Println("failed to get authorized client", err) 1708 s.pages.Notice(w, "issue-comment", "Failed to delete comment.") 1709 return 1710 } 1711 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1712 Collection: tangled.GraphFollowNSID, 1713 Repo: user.Did, 1714 Rkey: comment.Rkey, 1715 }) 1716 if err != nil { 1717 log.Println(err) 1718 } 1719 } 1720 1721 // optimistic update for htmx 1722 didHandleMap := map[string]string{ 1723 user.Did: user.Handle, 1724 } 1725 comment.Body = "" 1726 comment.Deleted = &deleted 1727 1728 // htmx fragment of comment after deletion 1729 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1730 LoggedInUser: user, 1731 RepoInfo: f.RepoInfo(s, user), 1732 DidHandleMap: didHandleMap, 1733 Issue: issue, 1734 Comment: comment, 1735 }) 1736 return 1737} 1738 1739func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 1740 params := r.URL.Query() 1741 state := params.Get("state") 1742 isOpen := true 1743 switch state { 1744 case "open": 1745 isOpen = true 1746 case "closed": 1747 isOpen = false 1748 default: 1749 isOpen = true 1750 } 1751 1752 page, ok := r.Context().Value("page").(pagination.Page) 1753 if !ok { 1754 log.Println("failed to get page") 1755 page = pagination.FirstPage() 1756 } 1757 1758 user := s.oauth.GetUser(r) 1759 f, err := s.fullyResolvedRepo(r) 1760 if err != nil { 1761 log.Println("failed to get repo and knot", err) 1762 return 1763 } 1764 1765 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page) 1766 if err != nil { 1767 log.Println("failed to get issues", err) 1768 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 1769 return 1770 } 1771 1772 identsToResolve := make([]string, len(issues)) 1773 for i, issue := range issues { 1774 identsToResolve[i] = issue.OwnerDid 1775 } 1776 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1777 didHandleMap := make(map[string]string) 1778 for _, identity := range resolvedIds { 1779 if !identity.Handle.IsInvalidHandle() { 1780 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1781 } else { 1782 didHandleMap[identity.DID.String()] = identity.DID.String() 1783 } 1784 } 1785 1786 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 1787 LoggedInUser: s.oauth.GetUser(r), 1788 RepoInfo: f.RepoInfo(s, user), 1789 Issues: issues, 1790 DidHandleMap: didHandleMap, 1791 FilteringByOpen: isOpen, 1792 Page: page, 1793 }) 1794 return 1795} 1796 1797func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 1798 user := s.oauth.GetUser(r) 1799 1800 f, err := s.fullyResolvedRepo(r) 1801 if err != nil { 1802 log.Println("failed to get repo and knot", err) 1803 return 1804 } 1805 1806 switch r.Method { 1807 case http.MethodGet: 1808 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1809 LoggedInUser: user, 1810 RepoInfo: f.RepoInfo(s, user), 1811 }) 1812 case http.MethodPost: 1813 title := r.FormValue("title") 1814 body := r.FormValue("body") 1815 1816 if title == "" || body == "" { 1817 s.pages.Notice(w, "issues", "Title and body are required") 1818 return 1819 } 1820 1821 tx, err := s.db.BeginTx(r.Context(), nil) 1822 if err != nil { 1823 s.pages.Notice(w, "issues", "Failed to create issue, try again later") 1824 return 1825 } 1826 1827 err = db.NewIssue(tx, &db.Issue{ 1828 RepoAt: f.RepoAt, 1829 Title: title, 1830 Body: body, 1831 OwnerDid: user.Did, 1832 }) 1833 if err != nil { 1834 log.Println("failed to create issue", err) 1835 s.pages.Notice(w, "issues", "Failed to create issue.") 1836 return 1837 } 1838 1839 issueId, err := db.GetIssueId(s.db, f.RepoAt) 1840 if err != nil { 1841 log.Println("failed to get issue id", err) 1842 s.pages.Notice(w, "issues", "Failed to create issue.") 1843 return 1844 } 1845 1846 client, err := s.oauth.AuthorizedClient(r) 1847 if err != nil { 1848 log.Println("failed to get authorized client", err) 1849 s.pages.Notice(w, "issues", "Failed to create issue.") 1850 return 1851 } 1852 atUri := f.RepoAt.String() 1853 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1854 Collection: tangled.RepoIssueNSID, 1855 Repo: user.Did, 1856 Rkey: appview.TID(), 1857 Record: &lexutil.LexiconTypeDecoder{ 1858 Val: &tangled.RepoIssue{ 1859 Repo: atUri, 1860 Title: title, 1861 Body: &body, 1862 Owner: user.Did, 1863 IssueId: int64(issueId), 1864 }, 1865 }, 1866 }) 1867 if err != nil { 1868 log.Println("failed to create issue", err) 1869 s.pages.Notice(w, "issues", "Failed to create issue.") 1870 return 1871 } 1872 1873 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 1874 if err != nil { 1875 log.Println("failed to set issue at", err) 1876 s.pages.Notice(w, "issues", "Failed to create issue.") 1877 return 1878 } 1879 1880 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1881 return 1882 } 1883} 1884 1885func (s *State) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1886 user := s.oauth.GetUser(r) 1887 f, err := s.fullyResolvedRepo(r) 1888 if err != nil { 1889 log.Printf("failed to resolve source repo: %v", err) 1890 return 1891 } 1892 1893 switch r.Method { 1894 case http.MethodPost: 1895 secret, err := db.GetRegistrationKey(s.db, f.Knot) 1896 if err != nil { 1897 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1898 return 1899 } 1900 1901 client, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1902 if err != nil { 1903 s.pages.Notice(w, "repo", "Failed to reach knot server.") 1904 return 1905 } 1906 1907 var uri string 1908 if s.config.Core.Dev { 1909 uri = "http" 1910 } else { 1911 uri = "https" 1912 } 1913 forkName := fmt.Sprintf("%s", f.RepoName) 1914 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1915 1916 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1917 if err != nil { 1918 s.pages.Notice(w, "repo", "Failed to sync repository fork.") 1919 return 1920 } 1921 1922 s.pages.HxRefresh(w) 1923 return 1924 } 1925} 1926 1927func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { 1928 user := s.oauth.GetUser(r) 1929 f, err := s.fullyResolvedRepo(r) 1930 if err != nil { 1931 log.Printf("failed to resolve source repo: %v", err) 1932 return 1933 } 1934 1935 switch r.Method { 1936 case http.MethodGet: 1937 user := s.oauth.GetUser(r) 1938 knots, err := s.enforcer.GetDomainsForUser(user.Did) 1939 if err != nil { 1940 s.pages.Notice(w, "repo", "Invalid user account.") 1941 return 1942 } 1943 1944 s.pages.ForkRepo(w, pages.ForkRepoParams{ 1945 LoggedInUser: user, 1946 Knots: knots, 1947 RepoInfo: f.RepoInfo(s, user), 1948 }) 1949 1950 case http.MethodPost: 1951 1952 knot := r.FormValue("knot") 1953 if knot == "" { 1954 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") 1955 return 1956 } 1957 1958 ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1959 if err != nil || !ok { 1960 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1961 return 1962 } 1963 1964 forkName := fmt.Sprintf("%s", f.RepoName) 1965 1966 // this check is *only* to see if the forked repo name already exists 1967 // in the user's account. 1968 existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName) 1969 if err != nil { 1970 if errors.Is(err, sql.ErrNoRows) { 1971 // no existing repo with this name found, we can use the name as is 1972 } else { 1973 log.Println("error fetching existing repo from db", err) 1974 s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1975 return 1976 } 1977 } else if existingRepo != nil { 1978 // repo with this name already exists, append random string 1979 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1980 } 1981 secret, err := db.GetRegistrationKey(s.db, knot) 1982 if err != nil { 1983 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1984 return 1985 } 1986 1987 client, err := knotclient.NewSignedClient(knot, secret, s.config.Core.Dev) 1988 if err != nil { 1989 s.pages.Notice(w, "repo", "Failed to reach knot server.") 1990 return 1991 } 1992 1993 var uri string 1994 if s.config.Core.Dev { 1995 uri = "http" 1996 } else { 1997 uri = "https" 1998 } 1999 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 2000 sourceAt := f.RepoAt.String() 2001 2002 rkey := appview.TID() 2003 repo := &db.Repo{ 2004 Did: user.Did, 2005 Name: forkName, 2006 Knot: knot, 2007 Rkey: rkey, 2008 Source: sourceAt, 2009 } 2010 2011 tx, err := s.db.BeginTx(r.Context(), nil) 2012 if err != nil { 2013 log.Println(err) 2014 s.pages.Notice(w, "repo", "Failed to save repository information.") 2015 return 2016 } 2017 defer func() { 2018 tx.Rollback() 2019 err = s.enforcer.E.LoadPolicy() 2020 if err != nil { 2021 log.Println("failed to rollback policies") 2022 } 2023 }() 2024 2025 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 2026 if err != nil { 2027 s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 2028 return 2029 } 2030 2031 switch resp.StatusCode { 2032 case http.StatusConflict: 2033 s.pages.Notice(w, "repo", "A repository with that name already exists.") 2034 return 2035 case http.StatusInternalServerError: 2036 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 2037 case http.StatusNoContent: 2038 // continue 2039 } 2040 2041 xrpcClient, err := s.oauth.AuthorizedClient(r) 2042 if err != nil { 2043 log.Println("failed to get authorized client", err) 2044 s.pages.Notice(w, "repo", "Failed to create repository.") 2045 return 2046 } 2047 2048 createdAt := time.Now().Format(time.RFC3339) 2049 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2050 Collection: tangled.RepoNSID, 2051 Repo: user.Did, 2052 Rkey: rkey, 2053 Record: &lexutil.LexiconTypeDecoder{ 2054 Val: &tangled.Repo{ 2055 Knot: repo.Knot, 2056 Name: repo.Name, 2057 CreatedAt: createdAt, 2058 Owner: user.Did, 2059 Source: &sourceAt, 2060 }}, 2061 }) 2062 if err != nil { 2063 log.Printf("failed to create record: %s", err) 2064 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 2065 return 2066 } 2067 log.Println("created repo record: ", atresp.Uri) 2068 2069 repo.AtUri = atresp.Uri 2070 err = db.AddRepo(tx, repo) 2071 if err != nil { 2072 log.Println(err) 2073 s.pages.Notice(w, "repo", "Failed to save repository information.") 2074 return 2075 } 2076 2077 // acls 2078 p, _ := securejoin.SecureJoin(user.Did, forkName) 2079 err = s.enforcer.AddRepo(user.Did, knot, p) 2080 if err != nil { 2081 log.Println(err) 2082 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 2083 return 2084 } 2085 2086 err = tx.Commit() 2087 if err != nil { 2088 log.Println("failed to commit changes", err) 2089 http.Error(w, err.Error(), http.StatusInternalServerError) 2090 return 2091 } 2092 2093 err = s.enforcer.E.SavePolicy() 2094 if err != nil { 2095 log.Println("failed to update ACLs", err) 2096 http.Error(w, err.Error(), http.StatusInternalServerError) 2097 return 2098 } 2099 2100 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2101 return 2102 } 2103}