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