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