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