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