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