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