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