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