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