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