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