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