forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
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} 950 951func (f *FullyResolvedRepo) OwnerDid() string { 952 return f.OwnerId.DID.String() 953} 954 955func (f *FullyResolvedRepo) OwnerHandle() string { 956 return f.OwnerId.Handle.String() 957} 958 959func (f *FullyResolvedRepo) OwnerSlashRepo() string { 960 handle := f.OwnerId.Handle 961 962 var p string 963 if handle != "" && !handle.IsInvalidHandle() { 964 p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 965 } else { 966 p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 967 } 968 969 return p 970} 971 972func (f *FullyResolvedRepo) DidSlashRepo() string { 973 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 974 return p 975} 976 977func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 978 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 979 if err != nil { 980 return nil, err 981 } 982 983 var collaborators []pages.Collaborator 984 for _, item := range repoCollaborators { 985 // currently only two roles: owner and member 986 var role string 987 if item[3] == "repo:owner" { 988 role = "owner" 989 } else if item[3] == "repo:collaborator" { 990 role = "collaborator" 991 } else { 992 continue 993 } 994 995 did := item[0] 996 997 c := pages.Collaborator{ 998 Did: did, 999 Handle: "", 1000 Role: role, 1001 } 1002 collaborators = append(collaborators, c) 1003 } 1004 1005 // populate all collborators with handles 1006 identsToResolve := make([]string, len(collaborators)) 1007 for i, collab := range collaborators { 1008 identsToResolve[i] = collab.Did 1009 } 1010 1011 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve) 1012 for i, resolved := range resolvedIdents { 1013 if resolved != nil { 1014 collaborators[i].Handle = resolved.Handle.String() 1015 } 1016 } 1017 1018 return collaborators, nil 1019} 1020 1021func (f *FullyResolvedRepo) RepoInfo(s *State, u *oauth.User) repoinfo.RepoInfo { 1022 isStarred := false 1023 if u != nil { 1024 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) 1025 } 1026 1027 starCount, err := db.GetStarCount(s.db, f.RepoAt) 1028 if err != nil { 1029 log.Println("failed to get star count for ", f.RepoAt) 1030 } 1031 issueCount, err := db.GetIssueCount(s.db, f.RepoAt) 1032 if err != nil { 1033 log.Println("failed to get issue count for ", f.RepoAt) 1034 } 1035 pullCount, err := db.GetPullCount(s.db, f.RepoAt) 1036 if err != nil { 1037 log.Println("failed to get issue count for ", f.RepoAt) 1038 } 1039 source, err := db.GetRepoSource(s.db, f.RepoAt) 1040 if errors.Is(err, sql.ErrNoRows) { 1041 source = "" 1042 } else if err != nil { 1043 log.Println("failed to get repo source for ", f.RepoAt, err) 1044 } 1045 1046 var sourceRepo *db.Repo 1047 if source != "" { 1048 sourceRepo, err = db.GetRepoByAtUri(s.db, source) 1049 if err != nil { 1050 log.Println("failed to get repo by at uri", err) 1051 } 1052 } 1053 1054 var sourceHandle *identity.Identity 1055 if sourceRepo != nil { 1056 sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did) 1057 if err != nil { 1058 log.Println("failed to resolve source repo", err) 1059 } 1060 } 1061 1062 knot := f.Knot 1063 var disableFork bool 1064 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 1065 if err != nil { 1066 log.Printf("failed to create unsigned client for %s: %v", knot, err) 1067 } else { 1068 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 1069 if err != nil { 1070 log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 1071 } else { 1072 defer resp.Body.Close() 1073 body, err := io.ReadAll(resp.Body) 1074 if err != nil { 1075 log.Printf("error reading branch response body: %v", err) 1076 } else { 1077 var branchesResp types.RepoBranchesResponse 1078 if err := json.Unmarshal(body, &branchesResp); err != nil { 1079 log.Printf("error parsing branch response: %v", err) 1080 } else { 1081 disableFork = false 1082 } 1083 1084 if len(branchesResp.Branches) == 0 { 1085 disableFork = true 1086 } 1087 } 1088 } 1089 } 1090 1091 repoInfo := repoinfo.RepoInfo{ 1092 OwnerDid: f.OwnerDid(), 1093 OwnerHandle: f.OwnerHandle(), 1094 Name: f.RepoName, 1095 RepoAt: f.RepoAt, 1096 Description: f.Description, 1097 Ref: f.Ref, 1098 IsStarred: isStarred, 1099 Knot: knot, 1100 Roles: RolesInRepo(s, u, f), 1101 Stats: db.RepoStats{ 1102 StarCount: starCount, 1103 IssueCount: issueCount, 1104 PullCount: pullCount, 1105 }, 1106 DisableFork: disableFork, 1107 } 1108 1109 if sourceRepo != nil { 1110 repoInfo.Source = sourceRepo 1111 repoInfo.SourceHandle = sourceHandle.Handle.String() 1112 } 1113 1114 return repoInfo 1115} 1116 1117func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 1118 user := s.oauth.GetUser(r) 1119 f, err := s.fullyResolvedRepo(r) 1120 if err != nil { 1121 log.Println("failed to get repo and knot", err) 1122 return 1123 } 1124 1125 issueId := chi.URLParam(r, "issue") 1126 issueIdInt, err := strconv.Atoi(issueId) 1127 if err != nil { 1128 http.Error(w, "bad issue id", http.StatusBadRequest) 1129 log.Println("failed to parse issue id", err) 1130 return 1131 } 1132 1133 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt) 1134 if err != nil { 1135 log.Println("failed to get issue and comments", err) 1136 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1137 return 1138 } 1139 1140 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 1141 if err != nil { 1142 log.Println("failed to resolve issue owner", err) 1143 } 1144 1145 identsToResolve := make([]string, len(comments)) 1146 for i, comment := range comments { 1147 identsToResolve[i] = comment.OwnerDid 1148 } 1149 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1150 didHandleMap := make(map[string]string) 1151 for _, identity := range resolvedIds { 1152 if !identity.Handle.IsInvalidHandle() { 1153 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1154 } else { 1155 didHandleMap[identity.DID.String()] = identity.DID.String() 1156 } 1157 } 1158 1159 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 1160 LoggedInUser: user, 1161 RepoInfo: f.RepoInfo(s, user), 1162 Issue: *issue, 1163 Comments: comments, 1164 1165 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 1166 DidHandleMap: didHandleMap, 1167 }) 1168 1169} 1170 1171func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 1172 user := s.oauth.GetUser(r) 1173 f, err := s.fullyResolvedRepo(r) 1174 if err != nil { 1175 log.Println("failed to get repo and knot", err) 1176 return 1177 } 1178 1179 issueId := chi.URLParam(r, "issue") 1180 issueIdInt, err := strconv.Atoi(issueId) 1181 if err != nil { 1182 http.Error(w, "bad issue id", http.StatusBadRequest) 1183 log.Println("failed to parse issue id", err) 1184 return 1185 } 1186 1187 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1188 if err != nil { 1189 log.Println("failed to get issue", err) 1190 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1191 return 1192 } 1193 1194 collaborators, err := f.Collaborators(r.Context(), s) 1195 if err != nil { 1196 log.Println("failed to fetch repo collaborators: %w", err) 1197 } 1198 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1199 return user.Did == collab.Did 1200 }) 1201 isIssueOwner := user.Did == issue.OwnerDid 1202 1203 // TODO: make this more granular 1204 if isIssueOwner || isCollaborator { 1205 1206 closed := tangled.RepoIssueStateClosed 1207 1208 client, err := s.oauth.AuthorizedClient(r) 1209 if err != nil { 1210 log.Println("failed to get authorized client", err) 1211 return 1212 } 1213 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1214 Collection: tangled.RepoIssueStateNSID, 1215 Repo: user.Did, 1216 Rkey: appview.TID(), 1217 Record: &lexutil.LexiconTypeDecoder{ 1218 Val: &tangled.RepoIssueState{ 1219 Issue: issue.IssueAt, 1220 State: closed, 1221 }, 1222 }, 1223 }) 1224 1225 if err != nil { 1226 log.Println("failed to update issue state", err) 1227 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1228 return 1229 } 1230 1231 err = db.CloseIssue(s.db, f.RepoAt, issueIdInt) 1232 if err != nil { 1233 log.Println("failed to close issue", err) 1234 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1235 return 1236 } 1237 1238 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1239 return 1240 } else { 1241 log.Println("user is not permitted to close issue") 1242 http.Error(w, "for biden", http.StatusUnauthorized) 1243 return 1244 } 1245} 1246 1247func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 1248 user := s.oauth.GetUser(r) 1249 f, err := s.fullyResolvedRepo(r) 1250 if err != nil { 1251 log.Println("failed to get repo and knot", err) 1252 return 1253 } 1254 1255 issueId := chi.URLParam(r, "issue") 1256 issueIdInt, err := strconv.Atoi(issueId) 1257 if err != nil { 1258 http.Error(w, "bad issue id", http.StatusBadRequest) 1259 log.Println("failed to parse issue id", err) 1260 return 1261 } 1262 1263 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1264 if err != nil { 1265 log.Println("failed to get issue", err) 1266 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1267 return 1268 } 1269 1270 collaborators, err := f.Collaborators(r.Context(), s) 1271 if err != nil { 1272 log.Println("failed to fetch repo collaborators: %w", err) 1273 } 1274 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1275 return user.Did == collab.Did 1276 }) 1277 isIssueOwner := user.Did == issue.OwnerDid 1278 1279 if isCollaborator || isIssueOwner { 1280 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 1281 if err != nil { 1282 log.Println("failed to reopen issue", err) 1283 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 1284 return 1285 } 1286 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1287 return 1288 } else { 1289 log.Println("user is not the owner of the repo") 1290 http.Error(w, "forbidden", http.StatusUnauthorized) 1291 return 1292 } 1293} 1294 1295func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { 1296 user := s.oauth.GetUser(r) 1297 f, err := s.fullyResolvedRepo(r) 1298 if err != nil { 1299 log.Println("failed to get repo and knot", err) 1300 return 1301 } 1302 1303 issueId := chi.URLParam(r, "issue") 1304 issueIdInt, err := strconv.Atoi(issueId) 1305 if err != nil { 1306 http.Error(w, "bad issue id", http.StatusBadRequest) 1307 log.Println("failed to parse issue id", err) 1308 return 1309 } 1310 1311 switch r.Method { 1312 case http.MethodPost: 1313 body := r.FormValue("body") 1314 if body == "" { 1315 s.pages.Notice(w, "issue", "Body is required") 1316 return 1317 } 1318 1319 commentId := mathrand.IntN(1000000) 1320 rkey := appview.TID() 1321 1322 err := db.NewIssueComment(s.db, &db.Comment{ 1323 OwnerDid: user.Did, 1324 RepoAt: f.RepoAt, 1325 Issue: issueIdInt, 1326 CommentId: commentId, 1327 Body: body, 1328 Rkey: rkey, 1329 }) 1330 if err != nil { 1331 log.Println("failed to create comment", err) 1332 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1333 return 1334 } 1335 1336 createdAt := time.Now().Format(time.RFC3339) 1337 commentIdInt64 := int64(commentId) 1338 ownerDid := user.Did 1339 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 1340 if err != nil { 1341 log.Println("failed to get issue at", err) 1342 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1343 return 1344 } 1345 1346 atUri := f.RepoAt.String() 1347 client, err := s.oauth.AuthorizedClient(r) 1348 if err != nil { 1349 log.Println("failed to get authorized client", err) 1350 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1351 return 1352 } 1353 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1354 Collection: tangled.RepoIssueCommentNSID, 1355 Repo: user.Did, 1356 Rkey: rkey, 1357 Record: &lexutil.LexiconTypeDecoder{ 1358 Val: &tangled.RepoIssueComment{ 1359 Repo: &atUri, 1360 Issue: issueAt, 1361 CommentId: &commentIdInt64, 1362 Owner: &ownerDid, 1363 Body: body, 1364 CreatedAt: createdAt, 1365 }, 1366 }, 1367 }) 1368 if err != nil { 1369 log.Println("failed to create comment", err) 1370 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1371 return 1372 } 1373 1374 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 1375 return 1376 } 1377} 1378 1379func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 1380 user := s.oauth.GetUser(r) 1381 f, err := s.fullyResolvedRepo(r) 1382 if err != nil { 1383 log.Println("failed to get repo and knot", err) 1384 return 1385 } 1386 1387 issueId := chi.URLParam(r, "issue") 1388 issueIdInt, err := strconv.Atoi(issueId) 1389 if err != nil { 1390 http.Error(w, "bad issue id", http.StatusBadRequest) 1391 log.Println("failed to parse issue id", err) 1392 return 1393 } 1394 1395 commentId := chi.URLParam(r, "comment_id") 1396 commentIdInt, err := strconv.Atoi(commentId) 1397 if err != nil { 1398 http.Error(w, "bad comment id", http.StatusBadRequest) 1399 log.Println("failed to parse issue id", err) 1400 return 1401 } 1402 1403 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1404 if err != nil { 1405 log.Println("failed to get issue", err) 1406 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1407 return 1408 } 1409 1410 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1411 if err != nil { 1412 http.Error(w, "bad comment id", http.StatusBadRequest) 1413 return 1414 } 1415 1416 identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid) 1417 if err != nil { 1418 log.Println("failed to resolve did") 1419 return 1420 } 1421 1422 didHandleMap := make(map[string]string) 1423 if !identity.Handle.IsInvalidHandle() { 1424 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1425 } else { 1426 didHandleMap[identity.DID.String()] = identity.DID.String() 1427 } 1428 1429 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1430 LoggedInUser: user, 1431 RepoInfo: f.RepoInfo(s, user), 1432 DidHandleMap: didHandleMap, 1433 Issue: issue, 1434 Comment: comment, 1435 }) 1436} 1437 1438func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { 1439 user := s.oauth.GetUser(r) 1440 f, err := s.fullyResolvedRepo(r) 1441 if err != nil { 1442 log.Println("failed to get repo and knot", err) 1443 return 1444 } 1445 1446 issueId := chi.URLParam(r, "issue") 1447 issueIdInt, err := strconv.Atoi(issueId) 1448 if err != nil { 1449 http.Error(w, "bad issue id", http.StatusBadRequest) 1450 log.Println("failed to parse issue id", err) 1451 return 1452 } 1453 1454 commentId := chi.URLParam(r, "comment_id") 1455 commentIdInt, err := strconv.Atoi(commentId) 1456 if err != nil { 1457 http.Error(w, "bad comment id", http.StatusBadRequest) 1458 log.Println("failed to parse issue id", err) 1459 return 1460 } 1461 1462 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1463 if err != nil { 1464 log.Println("failed to get issue", err) 1465 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1466 return 1467 } 1468 1469 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1470 if err != nil { 1471 http.Error(w, "bad comment id", http.StatusBadRequest) 1472 return 1473 } 1474 1475 if comment.OwnerDid != user.Did { 1476 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1477 return 1478 } 1479 1480 switch r.Method { 1481 case http.MethodGet: 1482 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 1483 LoggedInUser: user, 1484 RepoInfo: f.RepoInfo(s, user), 1485 Issue: issue, 1486 Comment: comment, 1487 }) 1488 case http.MethodPost: 1489 // extract form value 1490 newBody := r.FormValue("body") 1491 client, err := s.oauth.AuthorizedClient(r) 1492 if err != nil { 1493 log.Println("failed to get authorized client", err) 1494 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1495 return 1496 } 1497 rkey := comment.Rkey 1498 1499 // optimistic update 1500 edited := time.Now() 1501 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 1502 if err != nil { 1503 log.Println("failed to perferom update-description query", err) 1504 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 1505 return 1506 } 1507 1508 // rkey is optional, it was introduced later 1509 if comment.Rkey != "" { 1510 // update the record on pds 1511 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1512 if err != nil { 1513 // failed to get record 1514 log.Println(err, rkey) 1515 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 1516 return 1517 } 1518 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 1519 record, _ := data.UnmarshalJSON(value) 1520 1521 repoAt := record["repo"].(string) 1522 issueAt := record["issue"].(string) 1523 createdAt := record["createdAt"].(string) 1524 commentIdInt64 := int64(commentIdInt) 1525 1526 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1527 Collection: tangled.RepoIssueCommentNSID, 1528 Repo: user.Did, 1529 Rkey: rkey, 1530 SwapRecord: ex.Cid, 1531 Record: &lexutil.LexiconTypeDecoder{ 1532 Val: &tangled.RepoIssueComment{ 1533 Repo: &repoAt, 1534 Issue: issueAt, 1535 CommentId: &commentIdInt64, 1536 Owner: &comment.OwnerDid, 1537 Body: newBody, 1538 CreatedAt: createdAt, 1539 }, 1540 }, 1541 }) 1542 if err != nil { 1543 log.Println(err) 1544 } 1545 } 1546 1547 // optimistic update for htmx 1548 didHandleMap := map[string]string{ 1549 user.Did: user.Handle, 1550 } 1551 comment.Body = newBody 1552 comment.Edited = &edited 1553 1554 // return new comment body with htmx 1555 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1556 LoggedInUser: user, 1557 RepoInfo: f.RepoInfo(s, user), 1558 DidHandleMap: didHandleMap, 1559 Issue: issue, 1560 Comment: comment, 1561 }) 1562 return 1563 1564 } 1565 1566} 1567 1568func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 1569 user := s.oauth.GetUser(r) 1570 f, err := s.fullyResolvedRepo(r) 1571 if err != nil { 1572 log.Println("failed to get repo and knot", err) 1573 return 1574 } 1575 1576 issueId := chi.URLParam(r, "issue") 1577 issueIdInt, err := strconv.Atoi(issueId) 1578 if err != nil { 1579 http.Error(w, "bad issue id", http.StatusBadRequest) 1580 log.Println("failed to parse issue id", err) 1581 return 1582 } 1583 1584 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1585 if err != nil { 1586 log.Println("failed to get issue", err) 1587 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1588 return 1589 } 1590 1591 commentId := chi.URLParam(r, "comment_id") 1592 commentIdInt, err := strconv.Atoi(commentId) 1593 if err != nil { 1594 http.Error(w, "bad comment id", http.StatusBadRequest) 1595 log.Println("failed to parse issue id", err) 1596 return 1597 } 1598 1599 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1600 if err != nil { 1601 http.Error(w, "bad comment id", http.StatusBadRequest) 1602 return 1603 } 1604 1605 if comment.OwnerDid != user.Did { 1606 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1607 return 1608 } 1609 1610 if comment.Deleted != nil { 1611 http.Error(w, "comment already deleted", http.StatusBadRequest) 1612 return 1613 } 1614 1615 // optimistic deletion 1616 deleted := time.Now() 1617 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1618 if err != nil { 1619 log.Println("failed to delete comment") 1620 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 1621 return 1622 } 1623 1624 // delete from pds 1625 if comment.Rkey != "" { 1626 client, err := s.oauth.AuthorizedClient(r) 1627 if err != nil { 1628 log.Println("failed to get authorized client", err) 1629 s.pages.Notice(w, "issue-comment", "Failed to delete comment.") 1630 return 1631 } 1632 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1633 Collection: tangled.GraphFollowNSID, 1634 Repo: user.Did, 1635 Rkey: comment.Rkey, 1636 }) 1637 if err != nil { 1638 log.Println(err) 1639 } 1640 } 1641 1642 // optimistic update for htmx 1643 didHandleMap := map[string]string{ 1644 user.Did: user.Handle, 1645 } 1646 comment.Body = "" 1647 comment.Deleted = &deleted 1648 1649 // htmx fragment of comment after deletion 1650 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1651 LoggedInUser: user, 1652 RepoInfo: f.RepoInfo(s, user), 1653 DidHandleMap: didHandleMap, 1654 Issue: issue, 1655 Comment: comment, 1656 }) 1657 return 1658} 1659 1660func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 1661 params := r.URL.Query() 1662 state := params.Get("state") 1663 isOpen := true 1664 switch state { 1665 case "open": 1666 isOpen = true 1667 case "closed": 1668 isOpen = false 1669 default: 1670 isOpen = true 1671 } 1672 1673 page, ok := r.Context().Value("page").(pagination.Page) 1674 if !ok { 1675 log.Println("failed to get page") 1676 page = pagination.FirstPage() 1677 } 1678 1679 user := s.oauth.GetUser(r) 1680 f, err := s.fullyResolvedRepo(r) 1681 if err != nil { 1682 log.Println("failed to get repo and knot", err) 1683 return 1684 } 1685 1686 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page) 1687 if err != nil { 1688 log.Println("failed to get issues", err) 1689 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 1690 return 1691 } 1692 1693 identsToResolve := make([]string, len(issues)) 1694 for i, issue := range issues { 1695 identsToResolve[i] = issue.OwnerDid 1696 } 1697 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1698 didHandleMap := make(map[string]string) 1699 for _, identity := range resolvedIds { 1700 if !identity.Handle.IsInvalidHandle() { 1701 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1702 } else { 1703 didHandleMap[identity.DID.String()] = identity.DID.String() 1704 } 1705 } 1706 1707 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 1708 LoggedInUser: s.oauth.GetUser(r), 1709 RepoInfo: f.RepoInfo(s, user), 1710 Issues: issues, 1711 DidHandleMap: didHandleMap, 1712 FilteringByOpen: isOpen, 1713 Page: page, 1714 }) 1715 return 1716} 1717 1718func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 1719 user := s.oauth.GetUser(r) 1720 1721 f, err := s.fullyResolvedRepo(r) 1722 if err != nil { 1723 log.Println("failed to get repo and knot", err) 1724 return 1725 } 1726 1727 switch r.Method { 1728 case http.MethodGet: 1729 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1730 LoggedInUser: user, 1731 RepoInfo: f.RepoInfo(s, user), 1732 }) 1733 case http.MethodPost: 1734 title := r.FormValue("title") 1735 body := r.FormValue("body") 1736 1737 if title == "" || body == "" { 1738 s.pages.Notice(w, "issues", "Title and body are required") 1739 return 1740 } 1741 1742 tx, err := s.db.BeginTx(r.Context(), nil) 1743 if err != nil { 1744 s.pages.Notice(w, "issues", "Failed to create issue, try again later") 1745 return 1746 } 1747 1748 err = db.NewIssue(tx, &db.Issue{ 1749 RepoAt: f.RepoAt, 1750 Title: title, 1751 Body: body, 1752 OwnerDid: user.Did, 1753 }) 1754 if err != nil { 1755 log.Println("failed to create issue", err) 1756 s.pages.Notice(w, "issues", "Failed to create issue.") 1757 return 1758 } 1759 1760 issueId, err := db.GetIssueId(s.db, f.RepoAt) 1761 if err != nil { 1762 log.Println("failed to get issue id", err) 1763 s.pages.Notice(w, "issues", "Failed to create issue.") 1764 return 1765 } 1766 1767 client, err := s.oauth.AuthorizedClient(r) 1768 if err != nil { 1769 log.Println("failed to get authorized client", err) 1770 s.pages.Notice(w, "issues", "Failed to create issue.") 1771 return 1772 } 1773 atUri := f.RepoAt.String() 1774 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1775 Collection: tangled.RepoIssueNSID, 1776 Repo: user.Did, 1777 Rkey: appview.TID(), 1778 Record: &lexutil.LexiconTypeDecoder{ 1779 Val: &tangled.RepoIssue{ 1780 Repo: atUri, 1781 Title: title, 1782 Body: &body, 1783 Owner: user.Did, 1784 IssueId: int64(issueId), 1785 }, 1786 }, 1787 }) 1788 if err != nil { 1789 log.Println("failed to create issue", err) 1790 s.pages.Notice(w, "issues", "Failed to create issue.") 1791 return 1792 } 1793 1794 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 1795 if err != nil { 1796 log.Println("failed to set issue at", err) 1797 s.pages.Notice(w, "issues", "Failed to create issue.") 1798 return 1799 } 1800 1801 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1802 return 1803 } 1804} 1805 1806func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { 1807 user := s.oauth.GetUser(r) 1808 f, err := s.fullyResolvedRepo(r) 1809 if err != nil { 1810 log.Printf("failed to resolve source repo: %v", err) 1811 return 1812 } 1813 1814 switch r.Method { 1815 case http.MethodGet: 1816 user := s.oauth.GetUser(r) 1817 knots, err := s.enforcer.GetDomainsForUser(user.Did) 1818 if err != nil { 1819 s.pages.Notice(w, "repo", "Invalid user account.") 1820 return 1821 } 1822 1823 s.pages.ForkRepo(w, pages.ForkRepoParams{ 1824 LoggedInUser: user, 1825 Knots: knots, 1826 RepoInfo: f.RepoInfo(s, user), 1827 }) 1828 1829 case http.MethodPost: 1830 1831 knot := r.FormValue("knot") 1832 if knot == "" { 1833 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") 1834 return 1835 } 1836 1837 ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1838 if err != nil || !ok { 1839 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1840 return 1841 } 1842 1843 forkName := fmt.Sprintf("%s", f.RepoName) 1844 1845 // this check is *only* to see if the forked repo name already exists 1846 // in the user's account. 1847 existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName) 1848 if err != nil { 1849 if errors.Is(err, sql.ErrNoRows) { 1850 // no existing repo with this name found, we can use the name as is 1851 } else { 1852 log.Println("error fetching existing repo from db", err) 1853 s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1854 return 1855 } 1856 } else if existingRepo != nil { 1857 // repo with this name already exists, append random string 1858 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1859 } 1860 secret, err := db.GetRegistrationKey(s.db, knot) 1861 if err != nil { 1862 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1863 return 1864 } 1865 1866 client, err := knotclient.NewSignedClient(knot, secret, s.config.Core.Dev) 1867 if err != nil { 1868 s.pages.Notice(w, "repo", "Failed to reach knot server.") 1869 return 1870 } 1871 1872 var uri string 1873 if s.config.Core.Dev { 1874 uri = "http" 1875 } else { 1876 uri = "https" 1877 } 1878 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1879 sourceAt := f.RepoAt.String() 1880 1881 rkey := appview.TID() 1882 repo := &db.Repo{ 1883 Did: user.Did, 1884 Name: forkName, 1885 Knot: knot, 1886 Rkey: rkey, 1887 Source: sourceAt, 1888 } 1889 1890 tx, err := s.db.BeginTx(r.Context(), nil) 1891 if err != nil { 1892 log.Println(err) 1893 s.pages.Notice(w, "repo", "Failed to save repository information.") 1894 return 1895 } 1896 defer func() { 1897 tx.Rollback() 1898 err = s.enforcer.E.LoadPolicy() 1899 if err != nil { 1900 log.Println("failed to rollback policies") 1901 } 1902 }() 1903 1904 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1905 if err != nil { 1906 s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1907 return 1908 } 1909 1910 switch resp.StatusCode { 1911 case http.StatusConflict: 1912 s.pages.Notice(w, "repo", "A repository with that name already exists.") 1913 return 1914 case http.StatusInternalServerError: 1915 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1916 case http.StatusNoContent: 1917 // continue 1918 } 1919 1920 xrpcClient, err := s.oauth.AuthorizedClient(r) 1921 if err != nil { 1922 log.Println("failed to get authorized client", err) 1923 s.pages.Notice(w, "repo", "Failed to create repository.") 1924 return 1925 } 1926 1927 createdAt := time.Now().Format(time.RFC3339) 1928 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1929 Collection: tangled.RepoNSID, 1930 Repo: user.Did, 1931 Rkey: rkey, 1932 Record: &lexutil.LexiconTypeDecoder{ 1933 Val: &tangled.Repo{ 1934 Knot: repo.Knot, 1935 Name: repo.Name, 1936 CreatedAt: createdAt, 1937 Owner: user.Did, 1938 Source: &sourceAt, 1939 }}, 1940 }) 1941 if err != nil { 1942 log.Printf("failed to create record: %s", err) 1943 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 1944 return 1945 } 1946 log.Println("created repo record: ", atresp.Uri) 1947 1948 repo.AtUri = atresp.Uri 1949 err = db.AddRepo(tx, repo) 1950 if err != nil { 1951 log.Println(err) 1952 s.pages.Notice(w, "repo", "Failed to save repository information.") 1953 return 1954 } 1955 1956 // acls 1957 p, _ := securejoin.SecureJoin(user.Did, forkName) 1958 err = s.enforcer.AddRepo(user.Did, knot, p) 1959 if err != nil { 1960 log.Println(err) 1961 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1962 return 1963 } 1964 1965 err = tx.Commit() 1966 if err != nil { 1967 log.Println("failed to commit changes", err) 1968 http.Error(w, err.Error(), http.StatusInternalServerError) 1969 return 1970 } 1971 1972 err = s.enforcer.E.SavePolicy() 1973 if err != nil { 1974 log.Println("failed to update ACLs", err) 1975 http.Error(w, err.Error(), http.StatusInternalServerError) 1976 return 1977 } 1978 1979 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1980 return 1981 } 1982}