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