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