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