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