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