forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
1package state 2 3import ( 4 "context" 5 "database/sql" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io" 10 "log" 11 mathrand "math/rand/v2" 12 "net/http" 13 "path" 14 "slices" 15 "strconv" 16 "strings" 17 "time" 18 19 "go.opentelemetry.io/otel/attribute" 20 "go.opentelemetry.io/otel/codes" 21 "tangled.sh/tangled.sh/core/api/tangled" 22 "tangled.sh/tangled.sh/core/appview" 23 "tangled.sh/tangled.sh/core/appview/auth" 24 "tangled.sh/tangled.sh/core/appview/db" 25 "tangled.sh/tangled.sh/core/appview/pages" 26 "tangled.sh/tangled.sh/core/appview/pages/markup" 27 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 28 "tangled.sh/tangled.sh/core/appview/pagination" 29 "tangled.sh/tangled.sh/core/types" 30 31 "github.com/bluesky-social/indigo/atproto/data" 32 "github.com/bluesky-social/indigo/atproto/identity" 33 "github.com/bluesky-social/indigo/atproto/syntax" 34 securejoin "github.com/cyphar/filepath-securejoin" 35 "github.com/go-chi/chi/v5" 36 "github.com/go-git/go-git/v5/plumbing" 37 38 comatproto "github.com/bluesky-social/indigo/api/atproto" 39 lexutil "github.com/bluesky-social/indigo/lex/util" 40) 41 42func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { 43 ctx, span := s.t.TraceStart(r.Context(), "RepoIndex") 44 defer span.End() 45 46 ref := chi.URLParam(r, "ref") 47 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 48 if err != nil { 49 log.Println("failed to fully resolve repo", err) 50 span.RecordError(err) 51 span.SetStatus(codes.Error, "failed to fully resolve repo") 52 return 53 } 54 55 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 56 if err != nil { 57 log.Printf("failed to create unsigned client for %s", f.Knot) 58 span.RecordError(err) 59 span.SetStatus(codes.Error, "failed to create unsigned client") 60 s.pages.Error503(w) 61 return 62 } 63 64 resp, err := us.Index(f.OwnerDid(), f.RepoName, ref) 65 if err != nil { 66 s.pages.Error503(w) 67 log.Println("failed to reach knotserver", err) 68 span.RecordError(err) 69 span.SetStatus(codes.Error, "failed to reach knotserver") 70 return 71 } 72 defer resp.Body.Close() 73 74 body, err := io.ReadAll(resp.Body) 75 if err != nil { 76 log.Printf("Error reading response body: %v", err) 77 span.RecordError(err) 78 span.SetStatus(codes.Error, "error reading response body") 79 return 80 } 81 82 var result types.RepoIndexResponse 83 err = json.Unmarshal(body, &result) 84 if err != nil { 85 log.Printf("Error unmarshalling response body: %v", err) 86 span.RecordError(err) 87 span.SetStatus(codes.Error, "error unmarshalling response body") 88 return 89 } 90 91 tagMap := make(map[string][]string) 92 for _, tag := range result.Tags { 93 hash := tag.Hash 94 if tag.Tag != nil { 95 hash = tag.Tag.Target.String() 96 } 97 tagMap[hash] = append(tagMap[hash], tag.Name) 98 } 99 100 for _, branch := range result.Branches { 101 hash := branch.Hash 102 tagMap[hash] = append(tagMap[hash], branch.Name) 103 } 104 105 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 106 if a.Name == result.Ref { 107 return -1 108 } 109 if a.IsDefault { 110 return -1 111 } 112 if b.IsDefault { 113 return 1 114 } 115 if a.Commit != nil { 116 if a.Commit.Author.When.Before(b.Commit.Author.When) { 117 return 1 118 } else { 119 return -1 120 } 121 } 122 return strings.Compare(a.Name, b.Name) * -1 123 }) 124 125 commitCount := len(result.Commits) 126 branchCount := len(result.Branches) 127 tagCount := len(result.Tags) 128 fileCount := len(result.Files) 129 130 span.SetAttributes( 131 attribute.Int("commits.count", commitCount), 132 attribute.Int("branches.count", branchCount), 133 attribute.Int("tags.count", tagCount), 134 attribute.Int("files.count", fileCount), 135 ) 136 137 commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount) 138 commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))] 139 tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))] 140 branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))] 141 142 emails := uniqueEmails(commitsTrunc) 143 144 user := s.auth.GetUser(r) 145 s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 146 LoggedInUser: user, 147 RepoInfo: f.RepoInfo(ctx, s, user), 148 TagMap: tagMap, 149 RepoIndexResponse: result, 150 CommitsTrunc: commitsTrunc, 151 TagsTrunc: tagsTrunc, 152 BranchesTrunc: branchesTrunc, 153 EmailToDidOrHandle: EmailToDidOrHandle(s, emails), 154 }) 155 return 156} 157 158func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { 159 ctx, span := s.t.TraceStart(r.Context(), "RepoLog") 160 defer span.End() 161 162 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 163 if err != nil { 164 log.Println("failed to fully resolve repo", err) 165 span.RecordError(err) 166 span.SetStatus(codes.Error, "failed to fully resolve repo") 167 return 168 } 169 170 page := 1 171 if r.URL.Query().Get("page") != "" { 172 page, err = strconv.Atoi(r.URL.Query().Get("page")) 173 if err != nil { 174 page = 1 175 } 176 } 177 178 ref := chi.URLParam(r, "ref") 179 span.SetAttributes(attribute.Int("page", page), attribute.String("ref", ref)) 180 181 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 182 if err != nil { 183 log.Println("failed to create unsigned client", err) 184 span.RecordError(err) 185 span.SetStatus(codes.Error, "failed to create unsigned client") 186 return 187 } 188 189 resp, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 190 if err != nil { 191 log.Println("failed to reach knotserver", err) 192 span.RecordError(err) 193 span.SetStatus(codes.Error, "failed to reach knotserver") 194 return 195 } 196 197 body, err := io.ReadAll(resp.Body) 198 if err != nil { 199 log.Printf("error reading response body: %v", err) 200 span.RecordError(err) 201 span.SetStatus(codes.Error, "error reading response body") 202 return 203 } 204 205 var repolog types.RepoLogResponse 206 err = json.Unmarshal(body, &repolog) 207 if err != nil { 208 log.Println("failed to parse json response", err) 209 span.RecordError(err) 210 span.SetStatus(codes.Error, "failed to parse json response") 211 return 212 } 213 214 span.SetAttributes(attribute.Int("commits.count", len(repolog.Commits))) 215 216 result, err := us.Tags(f.OwnerDid(), f.RepoName) 217 if err != nil { 218 log.Println("failed to reach knotserver", err) 219 span.RecordError(err) 220 span.SetStatus(codes.Error, "failed to reach knotserver for tags") 221 return 222 } 223 224 tagMap := make(map[string][]string) 225 for _, tag := range result.Tags { 226 hash := tag.Hash 227 if tag.Tag != nil { 228 hash = tag.Tag.Target.String() 229 } 230 tagMap[hash] = append(tagMap[hash], tag.Name) 231 } 232 233 span.SetAttributes(attribute.Int("tags.count", len(result.Tags))) 234 235 user := s.auth.GetUser(r) 236 s.pages.RepoLog(w, pages.RepoLogParams{ 237 LoggedInUser: user, 238 TagMap: tagMap, 239 RepoInfo: f.RepoInfo(ctx, s, user), 240 RepoLogResponse: repolog, 241 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)), 242 }) 243 return 244} 245 246func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 247 ctx, span := s.t.TraceStart(r.Context(), "RepoDescriptionEdit") 248 defer span.End() 249 250 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 251 if err != nil { 252 log.Println("failed to get repo and knot", err) 253 w.WriteHeader(http.StatusBadRequest) 254 return 255 } 256 257 user := s.auth.GetUser(r) 258 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 259 RepoInfo: f.RepoInfo(ctx, s, user), 260 }) 261 return 262} 263 264func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { 265 ctx, span := s.t.TraceStart(r.Context(), "RepoDescription") 266 defer span.End() 267 268 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 269 if err != nil { 270 log.Println("failed to get repo and knot", err) 271 span.RecordError(err) 272 span.SetStatus(codes.Error, "failed to resolve repo") 273 w.WriteHeader(http.StatusBadRequest) 274 return 275 } 276 277 repoAt := f.RepoAt 278 rkey := repoAt.RecordKey().String() 279 if rkey == "" { 280 log.Println("invalid aturi for repo", err) 281 span.RecordError(err) 282 span.SetStatus(codes.Error, "invalid aturi for repo") 283 w.WriteHeader(http.StatusInternalServerError) 284 return 285 } 286 287 user := s.auth.GetUser(r) 288 span.SetAttributes(attribute.String("method", r.Method)) 289 290 switch r.Method { 291 case http.MethodGet: 292 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 293 RepoInfo: f.RepoInfo(ctx, s, user), 294 }) 295 return 296 case http.MethodPut: 297 user := s.auth.GetUser(r) 298 newDescription := r.FormValue("description") 299 span.SetAttributes(attribute.String("description", newDescription)) 300 client, _ := s.auth.AuthorizedClient(r) 301 302 // optimistic update 303 err = db.UpdateDescription(ctx, s.db, string(repoAt), newDescription) 304 if err != nil { 305 log.Println("failed to perform update-description query", err) 306 span.RecordError(err) 307 span.SetStatus(codes.Error, "failed to update description in database") 308 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 309 return 310 } 311 312 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 313 // 314 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 315 ex, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoNSID, user.Did, rkey) 316 if err != nil { 317 // failed to get record 318 span.RecordError(err) 319 span.SetStatus(codes.Error, "failed to get record from PDS") 320 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 321 return 322 } 323 324 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 325 Collection: tangled.RepoNSID, 326 Repo: user.Did, 327 Rkey: rkey, 328 SwapRecord: ex.Cid, 329 Record: &lexutil.LexiconTypeDecoder{ 330 Val: &tangled.Repo{ 331 Knot: f.Knot, 332 Name: f.RepoName, 333 Owner: user.Did, 334 CreatedAt: f.CreatedAt, 335 Description: &newDescription, 336 }, 337 }, 338 }) 339 340 if err != nil { 341 log.Println("failed to perform update-description query", err) 342 span.RecordError(err) 343 span.SetStatus(codes.Error, "failed to put record to PDS") 344 // failed to get record 345 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 346 return 347 } 348 349 newRepoInfo := f.RepoInfo(ctx, s, user) 350 newRepoInfo.Description = newDescription 351 352 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 353 RepoInfo: newRepoInfo, 354 }) 355 return 356 } 357} 358 359func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { 360 ctx, span := s.t.TraceStart(r.Context(), "RepoCommit") 361 defer span.End() 362 363 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 364 if err != nil { 365 log.Println("failed to fully resolve repo", err) 366 span.RecordError(err) 367 span.SetStatus(codes.Error, "failed to fully resolve repo") 368 return 369 } 370 ref := chi.URLParam(r, "ref") 371 protocol := "http" 372 if !s.config.Dev { 373 protocol = "https" 374 } 375 376 span.SetAttributes(attribute.String("ref", ref), attribute.String("protocol", protocol)) 377 378 if !plumbing.IsHash(ref) { 379 span.SetAttributes(attribute.Bool("invalid_hash", true)) 380 s.pages.Error404(w) 381 return 382 } 383 384 requestURL := fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref) 385 span.SetAttributes(attribute.String("request_url", requestURL)) 386 387 resp, err := http.Get(requestURL) 388 if err != nil { 389 log.Println("failed to reach knotserver", err) 390 span.RecordError(err) 391 span.SetStatus(codes.Error, "failed to reach knotserver") 392 return 393 } 394 395 body, err := io.ReadAll(resp.Body) 396 if err != nil { 397 log.Printf("Error reading response body: %v", err) 398 span.RecordError(err) 399 span.SetStatus(codes.Error, "error reading response body") 400 return 401 } 402 403 var result types.RepoCommitResponse 404 err = json.Unmarshal(body, &result) 405 if err != nil { 406 log.Println("failed to parse response:", err) 407 span.RecordError(err) 408 span.SetStatus(codes.Error, "failed to parse response") 409 return 410 } 411 412 user := s.auth.GetUser(r) 413 s.pages.RepoCommit(w, pages.RepoCommitParams{ 414 LoggedInUser: user, 415 RepoInfo: f.RepoInfo(ctx, s, user), 416 RepoCommitResponse: result, 417 EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}), 418 }) 419 return 420} 421 422func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { 423 ctx, span := s.t.TraceStart(r.Context(), "RepoTree") 424 defer span.End() 425 426 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 427 if err != nil { 428 log.Println("failed to fully resolve repo", err) 429 span.RecordError(err) 430 span.SetStatus(codes.Error, "failed to fully resolve repo") 431 return 432 } 433 434 ref := chi.URLParam(r, "ref") 435 treePath := chi.URLParam(r, "*") 436 protocol := "http" 437 if !s.config.Dev { 438 protocol = "https" 439 } 440 441 span.SetAttributes( 442 attribute.String("ref", ref), 443 attribute.String("tree_path", treePath), 444 attribute.String("protocol", protocol), 445 ) 446 447 requestURL := fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath) 448 span.SetAttributes(attribute.String("request_url", requestURL)) 449 450 resp, err := http.Get(requestURL) 451 if err != nil { 452 log.Println("failed to reach knotserver", err) 453 span.RecordError(err) 454 span.SetStatus(codes.Error, "failed to reach knotserver") 455 return 456 } 457 458 body, err := io.ReadAll(resp.Body) 459 if err != nil { 460 log.Printf("Error reading response body: %v", err) 461 span.RecordError(err) 462 span.SetStatus(codes.Error, "error reading response body") 463 return 464 } 465 466 var result types.RepoTreeResponse 467 err = json.Unmarshal(body, &result) 468 if err != nil { 469 log.Println("failed to parse response:", err) 470 span.RecordError(err) 471 span.SetStatus(codes.Error, "failed to parse response") 472 return 473 } 474 475 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 476 // so we can safely redirect to the "parent" (which is the same file). 477 if len(result.Files) == 0 && result.Parent == treePath { 478 redirectURL := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent) 479 span.SetAttributes(attribute.String("redirect_url", redirectURL)) 480 http.Redirect(w, r, redirectURL, http.StatusFound) 481 return 482 } 483 484 user := s.auth.GetUser(r) 485 486 var breadcrumbs [][]string 487 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 488 if treePath != "" { 489 for idx, elem := range strings.Split(treePath, "/") { 490 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 491 } 492 } 493 494 baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 495 baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 496 497 s.pages.RepoTree(w, pages.RepoTreeParams{ 498 LoggedInUser: user, 499 BreadCrumbs: breadcrumbs, 500 BaseTreeLink: baseTreeLink, 501 BaseBlobLink: baseBlobLink, 502 RepoInfo: f.RepoInfo(ctx, s, user), 503 RepoTreeResponse: result, 504 }) 505 return 506} 507 508func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { 509 ctx, span := s.t.TraceStart(r.Context(), "RepoTags") 510 defer span.End() 511 512 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 513 if err != nil { 514 log.Println("failed to get repo and knot", err) 515 span.RecordError(err) 516 span.SetStatus(codes.Error, "failed to get repo and knot") 517 return 518 } 519 520 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 521 if err != nil { 522 log.Println("failed to create unsigned client", err) 523 span.RecordError(err) 524 span.SetStatus(codes.Error, "failed to create unsigned client") 525 return 526 } 527 528 result, err := us.Tags(f.OwnerDid(), f.RepoName) 529 if err != nil { 530 log.Println("failed to reach knotserver", err) 531 span.RecordError(err) 532 span.SetStatus(codes.Error, "failed to reach knotserver") 533 return 534 } 535 536 span.SetAttributes(attribute.Int("tags.count", len(result.Tags))) 537 538 artifacts, err := db.GetArtifact(s.db, db.Filter("repo_at", f.RepoAt)) 539 if err != nil { 540 log.Println("failed grab artifacts", err) 541 span.RecordError(err) 542 span.SetStatus(codes.Error, "failed to grab artifacts") 543 return 544 } 545 546 span.SetAttributes(attribute.Int("artifacts.count", len(artifacts))) 547 548 // convert artifacts to map for easy UI building 549 artifactMap := make(map[plumbing.Hash][]db.Artifact) 550 for _, a := range artifacts { 551 artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 552 } 553 554 var danglingArtifacts []db.Artifact 555 for _, a := range artifacts { 556 found := false 557 for _, t := range result.Tags { 558 if t.Tag != nil { 559 if t.Tag.Hash == a.Tag { 560 found = true 561 } 562 } 563 } 564 565 if !found { 566 danglingArtifacts = append(danglingArtifacts, a) 567 } 568 } 569 570 span.SetAttributes(attribute.Int("dangling_artifacts.count", len(danglingArtifacts))) 571 572 user := s.auth.GetUser(r) 573 s.pages.RepoTags(w, pages.RepoTagsParams{ 574 LoggedInUser: user, 575 RepoInfo: f.RepoInfo(ctx, s, user), 576 RepoTagsResponse: *result, 577 ArtifactMap: artifactMap, 578 DanglingArtifacts: danglingArtifacts, 579 }) 580 return 581} 582 583func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { 584 ctx, span := s.t.TraceStart(r.Context(), "RepoBranches") 585 defer span.End() 586 587 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 588 if err != nil { 589 log.Println("failed to get repo and knot", err) 590 span.RecordError(err) 591 span.SetStatus(codes.Error, "failed to get repo and knot") 592 return 593 } 594 595 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 596 if err != nil { 597 log.Println("failed to create unsigned client", err) 598 span.RecordError(err) 599 span.SetStatus(codes.Error, "failed to create unsigned client") 600 return 601 } 602 603 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 604 if err != nil { 605 log.Println("failed to reach knotserver", err) 606 span.RecordError(err) 607 span.SetStatus(codes.Error, "failed to reach knotserver") 608 return 609 } 610 611 body, err := io.ReadAll(resp.Body) 612 if err != nil { 613 log.Printf("Error reading response body: %v", err) 614 span.RecordError(err) 615 span.SetStatus(codes.Error, "error reading response body") 616 return 617 } 618 619 var result types.RepoBranchesResponse 620 err = json.Unmarshal(body, &result) 621 if err != nil { 622 log.Println("failed to parse response:", err) 623 span.RecordError(err) 624 span.SetStatus(codes.Error, "failed to parse response") 625 return 626 } 627 628 span.SetAttributes(attribute.Int("branches.count", len(result.Branches))) 629 630 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 631 if a.IsDefault { 632 return -1 633 } 634 if b.IsDefault { 635 return 1 636 } 637 if a.Commit != nil { 638 if a.Commit.Author.When.Before(b.Commit.Author.When) { 639 return 1 640 } else { 641 return -1 642 } 643 } 644 return strings.Compare(a.Name, b.Name) * -1 645 }) 646 647 user := s.auth.GetUser(r) 648 s.pages.RepoBranches(w, pages.RepoBranchesParams{ 649 LoggedInUser: user, 650 RepoInfo: f.RepoInfo(ctx, s, user), 651 RepoBranchesResponse: result, 652 }) 653 return 654} 655 656func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { 657 ctx, span := s.t.TraceStart(r.Context(), "RepoBlob") 658 defer span.End() 659 660 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 661 if err != nil { 662 log.Println("failed to get repo and knot", err) 663 span.RecordError(err) 664 span.SetStatus(codes.Error, "failed to get repo and knot") 665 return 666 } 667 668 ref := chi.URLParam(r, "ref") 669 filePath := chi.URLParam(r, "*") 670 protocol := "http" 671 if !s.config.Dev { 672 protocol = "https" 673 } 674 675 span.SetAttributes( 676 attribute.String("ref", ref), 677 attribute.String("file_path", filePath), 678 attribute.String("protocol", protocol), 679 ) 680 681 requestURL := fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 682 span.SetAttributes(attribute.String("request_url", requestURL)) 683 684 resp, err := http.Get(requestURL) 685 if err != nil { 686 log.Println("failed to reach knotserver", err) 687 span.RecordError(err) 688 span.SetStatus(codes.Error, "failed to reach knotserver") 689 return 690 } 691 692 body, err := io.ReadAll(resp.Body) 693 if err != nil { 694 log.Printf("Error reading response body: %v", err) 695 span.RecordError(err) 696 span.SetStatus(codes.Error, "error reading response body") 697 return 698 } 699 700 var result types.RepoBlobResponse 701 err = json.Unmarshal(body, &result) 702 if err != nil { 703 log.Println("failed to parse response:", err) 704 span.RecordError(err) 705 span.SetStatus(codes.Error, "failed to parse response") 706 return 707 } 708 709 var breadcrumbs [][]string 710 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 711 if filePath != "" { 712 for idx, elem := range strings.Split(filePath, "/") { 713 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 714 } 715 } 716 717 showRendered := false 718 renderToggle := false 719 720 if markup.GetFormat(result.Path) == markup.FormatMarkdown { 721 renderToggle = true 722 showRendered = r.URL.Query().Get("code") != "true" 723 } 724 725 span.SetAttributes( 726 attribute.Bool("is_binary", result.IsBinary), 727 attribute.Bool("show_rendered", showRendered), 728 attribute.Bool("render_toggle", renderToggle), 729 ) 730 731 user := s.auth.GetUser(r) 732 s.pages.RepoBlob(w, pages.RepoBlobParams{ 733 LoggedInUser: user, 734 RepoInfo: f.RepoInfo(ctx, s, user), 735 RepoBlobResponse: result, 736 BreadCrumbs: breadcrumbs, 737 ShowRendered: showRendered, 738 RenderToggle: renderToggle, 739 }) 740 return 741} 742 743func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 744 ctx, span := s.t.TraceStart(r.Context(), "RepoBlobRaw") 745 defer span.End() 746 747 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 748 if err != nil { 749 log.Println("failed to get repo and knot", err) 750 span.RecordError(err) 751 span.SetStatus(codes.Error, "failed to get repo and knot") 752 return 753 } 754 755 ref := chi.URLParam(r, "ref") 756 filePath := chi.URLParam(r, "*") 757 758 protocol := "http" 759 if !s.config.Dev { 760 protocol = "https" 761 } 762 763 span.SetAttributes( 764 attribute.String("ref", ref), 765 attribute.String("file_path", filePath), 766 attribute.String("protocol", protocol), 767 ) 768 769 requestURL := fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 770 span.SetAttributes(attribute.String("request_url", requestURL)) 771 772 resp, err := http.Get(requestURL) 773 if err != nil { 774 log.Println("failed to reach knotserver", err) 775 span.RecordError(err) 776 span.SetStatus(codes.Error, "failed to reach knotserver") 777 return 778 } 779 780 body, err := io.ReadAll(resp.Body) 781 if err != nil { 782 log.Printf("Error reading response body: %v", err) 783 span.RecordError(err) 784 span.SetStatus(codes.Error, "error reading response body") 785 return 786 } 787 788 var result types.RepoBlobResponse 789 err = json.Unmarshal(body, &result) 790 if err != nil { 791 log.Println("failed to parse response:", err) 792 span.RecordError(err) 793 span.SetStatus(codes.Error, "failed to parse response") 794 return 795 } 796 797 span.SetAttributes(attribute.Bool("is_binary", result.IsBinary)) 798 799 if result.IsBinary { 800 w.Header().Set("Content-Type", "application/octet-stream") 801 w.Write(body) 802 return 803 } 804 805 w.Header().Set("Content-Type", "text/plain") 806 w.Write([]byte(result.Contents)) 807 return 808} 809 810func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { 811 ctx, span := s.t.TraceStart(r.Context(), "AddCollaborator") 812 defer span.End() 813 814 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 815 if err != nil { 816 log.Println("failed to get repo and knot", err) 817 span.RecordError(err) 818 span.SetStatus(codes.Error, "failed to get repo and knot") 819 return 820 } 821 822 collaborator := r.FormValue("collaborator") 823 if collaborator == "" { 824 span.SetAttributes(attribute.String("error", "malformed_form")) 825 http.Error(w, "malformed form", http.StatusBadRequest) 826 return 827 } 828 829 span.SetAttributes(attribute.String("collaborator", collaborator)) 830 831 collaboratorIdent, err := s.resolver.ResolveIdent(ctx, collaborator) 832 if err != nil { 833 span.RecordError(err) 834 span.SetStatus(codes.Error, "failed to resolve collaborator") 835 w.Write([]byte("failed to resolve collaborator did to a handle")) 836 return 837 } 838 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 839 span.SetAttributes( 840 attribute.String("collaborator_did", collaboratorIdent.DID.String()), 841 attribute.String("collaborator_handle", collaboratorIdent.Handle.String()), 842 ) 843 844 // TODO: create an atproto record for this 845 846 secret, err := db.GetRegistrationKey(s.db, f.Knot) 847 if err != nil { 848 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 849 span.RecordError(err) 850 span.SetStatus(codes.Error, "no key found for domain") 851 return 852 } 853 854 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 855 if err != nil { 856 log.Println("failed to create client to ", f.Knot) 857 span.RecordError(err) 858 span.SetStatus(codes.Error, "failed to create signed client") 859 return 860 } 861 862 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 863 if err != nil { 864 log.Printf("failed to make request to %s: %s", f.Knot, err) 865 span.RecordError(err) 866 span.SetStatus(codes.Error, "failed to make request to knotserver") 867 return 868 } 869 870 if ksResp.StatusCode != http.StatusNoContent { 871 span.SetAttributes(attribute.Int("status_code", ksResp.StatusCode)) 872 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err))) 873 return 874 } 875 876 tx, err := s.db.BeginTx(ctx, nil) 877 if err != nil { 878 log.Println("failed to start tx") 879 span.RecordError(err) 880 span.SetStatus(codes.Error, "failed to start transaction") 881 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 882 return 883 } 884 defer func() { 885 tx.Rollback() 886 err = s.enforcer.E.LoadPolicy() 887 if err != nil { 888 log.Println("failed to rollback policies") 889 } 890 }() 891 892 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 893 if err != nil { 894 span.RecordError(err) 895 span.SetStatus(codes.Error, "failed to add collaborator to enforcer") 896 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 897 return 898 } 899 900 err = db.AddCollaborator(ctx, s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 901 if err != nil { 902 span.RecordError(err) 903 span.SetStatus(codes.Error, "failed to add collaborator to database") 904 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 905 return 906 } 907 908 err = tx.Commit() 909 if err != nil { 910 log.Println("failed to commit changes", err) 911 span.RecordError(err) 912 span.SetStatus(codes.Error, "failed to commit transaction") 913 http.Error(w, err.Error(), http.StatusInternalServerError) 914 return 915 } 916 917 err = s.enforcer.E.SavePolicy() 918 if err != nil { 919 log.Println("failed to update ACLs", err) 920 span.RecordError(err) 921 span.SetStatus(codes.Error, "failed to save enforcer policy") 922 http.Error(w, err.Error(), http.StatusInternalServerError) 923 return 924 } 925 926 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String()))) 927} 928 929func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { 930 ctx, span := s.t.TraceStart(r.Context(), "DeleteRepo") 931 defer span.End() 932 933 user := s.auth.GetUser(r) 934 935 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 936 if err != nil { 937 log.Println("failed to get repo and knot", err) 938 span.RecordError(err) 939 span.SetStatus(codes.Error, "failed to get repo and knot") 940 return 941 } 942 943 span.SetAttributes( 944 attribute.String("repo_name", f.RepoName), 945 attribute.String("knot", f.Knot), 946 attribute.String("owner_did", f.OwnerDid()), 947 ) 948 949 // remove record from pds 950 xrpcClient, _ := s.auth.AuthorizedClient(r) 951 repoRkey := f.RepoAt.RecordKey().String() 952 _, err = comatproto.RepoDeleteRecord(ctx, xrpcClient, &comatproto.RepoDeleteRecord_Input{ 953 Collection: tangled.RepoNSID, 954 Repo: user.Did, 955 Rkey: repoRkey, 956 }) 957 if err != nil { 958 log.Printf("failed to delete record: %s", err) 959 span.RecordError(err) 960 span.SetStatus(codes.Error, "failed to delete record from PDS") 961 s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 962 return 963 } 964 log.Println("removed repo record ", f.RepoAt.String()) 965 span.SetAttributes(attribute.String("repo_at", f.RepoAt.String())) 966 967 secret, err := db.GetRegistrationKey(s.db, f.Knot) 968 if err != nil { 969 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 970 span.RecordError(err) 971 span.SetStatus(codes.Error, "no key found for domain") 972 return 973 } 974 975 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 976 if err != nil { 977 log.Println("failed to create client to ", f.Knot) 978 span.RecordError(err) 979 span.SetStatus(codes.Error, "failed to create client") 980 return 981 } 982 983 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 984 if err != nil { 985 log.Printf("failed to make request to %s: %s", f.Knot, err) 986 span.RecordError(err) 987 span.SetStatus(codes.Error, "failed to make request to knotserver") 988 return 989 } 990 991 span.SetAttributes(attribute.Int("ks_status_code", ksResp.StatusCode)) 992 if ksResp.StatusCode != http.StatusNoContent { 993 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 994 span.SetAttributes(attribute.Bool("knot_remove_failed", true)) 995 } else { 996 log.Println("removed repo from knot ", f.Knot) 997 span.SetAttributes(attribute.Bool("knot_remove_success", true)) 998 } 999 1000 tx, err := s.db.BeginTx(ctx, nil) 1001 if err != nil { 1002 log.Println("failed to start tx") 1003 span.RecordError(err) 1004 span.SetStatus(codes.Error, "failed to start transaction") 1005 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 1006 return 1007 } 1008 defer func() { 1009 tx.Rollback() 1010 err = s.enforcer.E.LoadPolicy() 1011 if err != nil { 1012 log.Println("failed to rollback policies") 1013 span.RecordError(err) 1014 } 1015 }() 1016 1017 // remove collaborator RBAC 1018 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 1019 if err != nil { 1020 span.RecordError(err) 1021 span.SetStatus(codes.Error, "failed to get collaborators") 1022 s.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 1023 return 1024 } 1025 span.SetAttributes(attribute.Int("collaborators.count", len(repoCollaborators))) 1026 1027 for _, c := range repoCollaborators { 1028 did := c[0] 1029 s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 1030 } 1031 log.Println("removed collaborators") 1032 1033 // remove repo RBAC 1034 err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 1035 if err != nil { 1036 span.RecordError(err) 1037 span.SetStatus(codes.Error, "failed to remove repo RBAC") 1038 s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 1039 return 1040 } 1041 1042 // remove repo from db 1043 err = db.RemoveRepo(ctx, tx, f.OwnerDid(), f.RepoName) 1044 if err != nil { 1045 span.RecordError(err) 1046 span.SetStatus(codes.Error, "failed to remove repo from db") 1047 s.pages.Notice(w, "settings-delete", "Failed to update appview") 1048 return 1049 } 1050 log.Println("removed repo from db") 1051 1052 err = tx.Commit() 1053 if err != nil { 1054 log.Println("failed to commit changes", err) 1055 span.RecordError(err) 1056 span.SetStatus(codes.Error, "failed to commit transaction") 1057 http.Error(w, err.Error(), http.StatusInternalServerError) 1058 return 1059 } 1060 1061 err = s.enforcer.E.SavePolicy() 1062 if err != nil { 1063 log.Println("failed to update ACLs", err) 1064 span.RecordError(err) 1065 span.SetStatus(codes.Error, "failed to save policy") 1066 http.Error(w, err.Error(), http.StatusInternalServerError) 1067 return 1068 } 1069 1070 s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 1071} 1072 1073func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1074 ctx, span := s.t.TraceStart(r.Context(), "SetDefaultBranch") 1075 defer span.End() 1076 1077 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 1078 if err != nil { 1079 log.Println("failed to get repo and knot", err) 1080 span.RecordError(err) 1081 span.SetStatus(codes.Error, "failed to get repo and knot") 1082 return 1083 } 1084 1085 branch := r.FormValue("branch") 1086 if branch == "" { 1087 span.SetAttributes(attribute.Bool("malformed_form", true)) 1088 span.SetStatus(codes.Error, "malformed form") 1089 http.Error(w, "malformed form", http.StatusBadRequest) 1090 return 1091 } 1092 1093 span.SetAttributes( 1094 attribute.String("branch", branch), 1095 attribute.String("repo_name", f.RepoName), 1096 attribute.String("knot", f.Knot), 1097 attribute.String("owner_did", f.OwnerDid()), 1098 ) 1099 1100 secret, err := db.GetRegistrationKey(s.db, f.Knot) 1101 if err != nil { 1102 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 1103 span.RecordError(err) 1104 span.SetStatus(codes.Error, "no key found for domain") 1105 return 1106 } 1107 1108 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 1109 if err != nil { 1110 log.Println("failed to create client to ", f.Knot) 1111 span.RecordError(err) 1112 span.SetStatus(codes.Error, "failed to create client") 1113 return 1114 } 1115 1116 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 1117 if err != nil { 1118 log.Printf("failed to make request to %s: %s", f.Knot, err) 1119 span.RecordError(err) 1120 span.SetStatus(codes.Error, "failed to make request to knotserver") 1121 return 1122 } 1123 1124 span.SetAttributes(attribute.Int("ks_status_code", ksResp.StatusCode)) 1125 if ksResp.StatusCode != http.StatusNoContent { 1126 span.SetStatus(codes.Error, "failed to set default branch") 1127 s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 1128 return 1129 } 1130 1131 w.Write([]byte(fmt.Sprint("default branch set to: ", branch))) 1132} 1133 1134func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { 1135 ctx, span := s.t.TraceStart(r.Context(), "RepoSettings") 1136 defer span.End() 1137 1138 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 1139 if err != nil { 1140 log.Println("failed to get repo and knot", err) 1141 span.RecordError(err) 1142 span.SetStatus(codes.Error, "failed to get repo and knot") 1143 return 1144 } 1145 1146 span.SetAttributes( 1147 attribute.String("repo_name", f.RepoName), 1148 attribute.String("knot", f.Knot), 1149 attribute.String("owner_did", f.OwnerDid()), 1150 attribute.String("method", r.Method), 1151 ) 1152 1153 switch r.Method { 1154 case http.MethodGet: 1155 // for now, this is just pubkeys 1156 user := s.auth.GetUser(r) 1157 repoCollaborators, err := f.Collaborators(ctx, s) 1158 if err != nil { 1159 log.Println("failed to get collaborators", err) 1160 span.RecordError(err) 1161 span.SetAttributes(attribute.String("error", "failed_to_get_collaborators")) 1162 } 1163 span.SetAttributes(attribute.Int("collaborators.count", len(repoCollaborators))) 1164 1165 isCollaboratorInviteAllowed := false 1166 if user != nil { 1167 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1168 if err == nil && ok { 1169 isCollaboratorInviteAllowed = true 1170 } 1171 } 1172 span.SetAttributes(attribute.Bool("invite_allowed", isCollaboratorInviteAllowed)) 1173 1174 var branchNames []string 1175 var defaultBranch string 1176 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 1177 if err != nil { 1178 log.Println("failed to create unsigned client", err) 1179 span.RecordError(err) 1180 span.SetAttributes(attribute.String("error", "failed_to_create_unsigned_client")) 1181 } else { 1182 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 1183 if err != nil { 1184 log.Println("failed to reach knotserver", err) 1185 span.RecordError(err) 1186 span.SetAttributes(attribute.String("error", "failed_to_reach_knotserver_for_branches")) 1187 } else { 1188 defer resp.Body.Close() 1189 1190 body, err := io.ReadAll(resp.Body) 1191 if err != nil { 1192 log.Printf("Error reading response body: %v", err) 1193 span.RecordError(err) 1194 span.SetAttributes(attribute.String("error", "failed_to_read_branches_response")) 1195 } else { 1196 var result types.RepoBranchesResponse 1197 err = json.Unmarshal(body, &result) 1198 if err != nil { 1199 log.Println("failed to parse response:", err) 1200 span.RecordError(err) 1201 span.SetAttributes(attribute.String("error", "failed_to_parse_branches_response")) 1202 } else { 1203 for _, branch := range result.Branches { 1204 branchNames = append(branchNames, branch.Name) 1205 } 1206 span.SetAttributes(attribute.Int("branches.count", len(branchNames))) 1207 } 1208 } 1209 } 1210 1211 defaultBranchResp, err := us.DefaultBranch(f.OwnerDid(), f.RepoName) 1212 if err != nil { 1213 log.Println("failed to reach knotserver", err) 1214 span.RecordError(err) 1215 span.SetAttributes(attribute.String("error", "failed_to_reach_knotserver_for_default_branch")) 1216 } else { 1217 defaultBranch = defaultBranchResp.Branch 1218 span.SetAttributes(attribute.String("default_branch", defaultBranch)) 1219 } 1220 } 1221 s.pages.RepoSettings(w, pages.RepoSettingsParams{ 1222 LoggedInUser: user, 1223 RepoInfo: f.RepoInfo(ctx, s, user), 1224 Collaborators: repoCollaborators, 1225 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1226 Branches: branchNames, 1227 DefaultBranch: defaultBranch, 1228 }) 1229 } 1230} 1231 1232type FullyResolvedRepo struct { 1233 Knot string 1234 OwnerId identity.Identity 1235 RepoName string 1236 RepoAt syntax.ATURI 1237 Description string 1238 CreatedAt string 1239 Ref string 1240} 1241 1242func (f *FullyResolvedRepo) OwnerDid() string { 1243 return f.OwnerId.DID.String() 1244} 1245 1246func (f *FullyResolvedRepo) OwnerHandle() string { 1247 return f.OwnerId.Handle.String() 1248} 1249 1250func (f *FullyResolvedRepo) OwnerSlashRepo() string { 1251 handle := f.OwnerId.Handle 1252 1253 var p string 1254 if handle != "" && !handle.IsInvalidHandle() { 1255 p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 1256 } else { 1257 p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 1258 } 1259 1260 return p 1261} 1262 1263func (f *FullyResolvedRepo) DidSlashRepo() string { 1264 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 1265 return p 1266} 1267 1268func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 1269 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 1270 if err != nil { 1271 return nil, err 1272 } 1273 1274 var collaborators []pages.Collaborator 1275 for _, item := range repoCollaborators { 1276 // currently only two roles: owner and member 1277 var role string 1278 if item[3] == "repo:owner" { 1279 role = "owner" 1280 } else if item[3] == "repo:collaborator" { 1281 role = "collaborator" 1282 } else { 1283 continue 1284 } 1285 1286 did := item[0] 1287 1288 c := pages.Collaborator{ 1289 Did: did, 1290 Handle: "", 1291 Role: role, 1292 } 1293 collaborators = append(collaborators, c) 1294 } 1295 1296 // populate all collborators with handles 1297 identsToResolve := make([]string, len(collaborators)) 1298 for i, collab := range collaborators { 1299 identsToResolve[i] = collab.Did 1300 } 1301 1302 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve) 1303 for i, resolved := range resolvedIdents { 1304 if resolved != nil { 1305 collaborators[i].Handle = resolved.Handle.String() 1306 } 1307 } 1308 1309 return collaborators, nil 1310} 1311 1312func (f *FullyResolvedRepo) RepoInfo(ctx context.Context, s *State, u *auth.User) repoinfo.RepoInfo { 1313 ctx, span := s.t.TraceStart(ctx, "RepoInfo") 1314 defer span.End() 1315 1316 isStarred := false 1317 if u != nil { 1318 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) 1319 span.SetAttributes(attribute.Bool("is_starred", isStarred)) 1320 } 1321 1322 starCount, err := db.GetStarCount(s.db, f.RepoAt) 1323 if err != nil { 1324 log.Println("failed to get star count for ", f.RepoAt) 1325 span.RecordError(err) 1326 } 1327 1328 issueCount, err := db.GetIssueCount(s.db, f.RepoAt) 1329 if err != nil { 1330 log.Println("failed to get issue count for ", f.RepoAt) 1331 span.RecordError(err) 1332 } 1333 1334 pullCount, err := db.GetPullCount(s.db, f.RepoAt) 1335 if err != nil { 1336 log.Println("failed to get issue count for ", f.RepoAt) 1337 span.RecordError(err) 1338 } 1339 1340 span.SetAttributes( 1341 attribute.Int("stats.stars", starCount), 1342 attribute.Int("stats.issues.open", issueCount.Open), 1343 attribute.Int("stats.issues.closed", issueCount.Closed), 1344 attribute.Int("stats.pulls.open", pullCount.Open), 1345 attribute.Int("stats.pulls.closed", pullCount.Closed), 1346 attribute.Int("stats.pulls.merged", pullCount.Merged), 1347 ) 1348 1349 source, err := db.GetRepoSource(ctx, s.db, f.RepoAt) 1350 if errors.Is(err, sql.ErrNoRows) { 1351 source = "" 1352 } else if err != nil { 1353 log.Println("failed to get repo source for ", f.RepoAt, err) 1354 span.RecordError(err) 1355 } 1356 1357 var sourceRepo *db.Repo 1358 if source != "" { 1359 span.SetAttributes(attribute.String("source", source)) 1360 sourceRepo, err = db.GetRepoByAtUri(ctx, s.db, source) 1361 if err != nil { 1362 log.Println("failed to get repo by at uri", err) 1363 span.RecordError(err) 1364 } 1365 } 1366 1367 var sourceHandle *identity.Identity 1368 if sourceRepo != nil { 1369 sourceHandle, err = s.resolver.ResolveIdent(ctx, sourceRepo.Did) 1370 if err != nil { 1371 log.Println("failed to resolve source repo", err) 1372 span.RecordError(err) 1373 } else if sourceHandle != nil { 1374 span.SetAttributes(attribute.String("source_handle", sourceHandle.Handle.String())) 1375 } 1376 } 1377 1378 knot := f.Knot 1379 span.SetAttributes(attribute.String("knot", knot)) 1380 1381 var disableFork bool 1382 us, err := NewUnsignedClient(knot, s.config.Dev) 1383 if err != nil { 1384 log.Printf("failed to create unsigned client for %s: %v", knot, err) 1385 span.RecordError(err) 1386 } else { 1387 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 1388 if err != nil { 1389 log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 1390 span.RecordError(err) 1391 } else { 1392 defer resp.Body.Close() 1393 body, err := io.ReadAll(resp.Body) 1394 if err != nil { 1395 log.Printf("error reading branch response body: %v", err) 1396 span.RecordError(err) 1397 } else { 1398 var branchesResp types.RepoBranchesResponse 1399 if err := json.Unmarshal(body, &branchesResp); err != nil { 1400 log.Printf("error parsing branch response: %v", err) 1401 span.RecordError(err) 1402 } else { 1403 disableFork = false 1404 } 1405 1406 if len(branchesResp.Branches) == 0 { 1407 disableFork = true 1408 } 1409 span.SetAttributes( 1410 attribute.Int("branches.count", len(branchesResp.Branches)), 1411 attribute.Bool("disable_fork", disableFork), 1412 ) 1413 } 1414 } 1415 } 1416 1417 repoInfo := repoinfo.RepoInfo{ 1418 OwnerDid: f.OwnerDid(), 1419 OwnerHandle: f.OwnerHandle(), 1420 Name: f.RepoName, 1421 RepoAt: f.RepoAt, 1422 Description: f.Description, 1423 Ref: f.Ref, 1424 IsStarred: isStarred, 1425 Knot: knot, 1426 Roles: RolesInRepo(s, u, f), 1427 Stats: db.RepoStats{ 1428 StarCount: starCount, 1429 IssueCount: issueCount, 1430 PullCount: pullCount, 1431 }, 1432 DisableFork: disableFork, 1433 } 1434 1435 if sourceRepo != nil { 1436 repoInfo.Source = sourceRepo 1437 repoInfo.SourceHandle = sourceHandle.Handle.String() 1438 } 1439 1440 return repoInfo 1441} 1442 1443func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 1444 ctx, span := s.t.TraceStart(r.Context(), "RepoSingleIssue") 1445 defer span.End() 1446 1447 user := s.auth.GetUser(r) 1448 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 1449 if err != nil { 1450 log.Println("failed to get repo and knot", err) 1451 span.RecordError(err) 1452 span.SetStatus(codes.Error, "failed to resolve repo") 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 span.RecordError(err) 1462 span.SetStatus(codes.Error, "failed to parse issue id") 1463 return 1464 } 1465 1466 span.SetAttributes(attribute.Int("issue_id", issueIdInt)) 1467 1468 issue, comments, err := db.GetIssueWithComments(ctx, s.db, f.RepoAt, issueIdInt) 1469 if err != nil { 1470 log.Println("failed to get issue and comments", err) 1471 span.RecordError(err) 1472 span.SetStatus(codes.Error, "failed to get issue and comments") 1473 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1474 return 1475 } 1476 1477 span.SetAttributes( 1478 attribute.Int("comments.count", len(comments)), 1479 attribute.String("issue.title", issue.Title), 1480 attribute.String("issue.owner_did", issue.OwnerDid), 1481 ) 1482 1483 issueOwnerIdent, err := s.resolver.ResolveIdent(ctx, issue.OwnerDid) 1484 if err != nil { 1485 log.Println("failed to resolve issue owner", err) 1486 span.RecordError(err) 1487 span.SetStatus(codes.Error, "failed to resolve issue owner") 1488 } 1489 1490 identsToResolve := make([]string, len(comments)) 1491 for i, comment := range comments { 1492 identsToResolve[i] = comment.OwnerDid 1493 } 1494 resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve) 1495 didHandleMap := make(map[string]string) 1496 for _, identity := range resolvedIds { 1497 if !identity.Handle.IsInvalidHandle() { 1498 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1499 } else { 1500 didHandleMap[identity.DID.String()] = identity.DID.String() 1501 } 1502 } 1503 1504 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 1505 LoggedInUser: user, 1506 RepoInfo: f.RepoInfo(ctx, s, user), 1507 Issue: *issue, 1508 Comments: comments, 1509 1510 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 1511 DidHandleMap: didHandleMap, 1512 }) 1513} 1514 1515func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 1516 ctx, span := s.t.TraceStart(r.Context(), "CloseIssue") 1517 defer span.End() 1518 1519 user := s.auth.GetUser(r) 1520 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 1521 if err != nil { 1522 log.Println("failed to get repo and knot", err) 1523 span.RecordError(err) 1524 span.SetStatus(codes.Error, "failed to resolve repo") 1525 return 1526 } 1527 1528 issueId := chi.URLParam(r, "issue") 1529 issueIdInt, err := strconv.Atoi(issueId) 1530 if err != nil { 1531 http.Error(w, "bad issue id", http.StatusBadRequest) 1532 log.Println("failed to parse issue id", err) 1533 span.RecordError(err) 1534 span.SetStatus(codes.Error, "failed to parse issue id") 1535 return 1536 } 1537 1538 span.SetAttributes(attribute.Int("issue_id", issueIdInt)) 1539 1540 issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt) 1541 if err != nil { 1542 log.Println("failed to get issue", err) 1543 span.RecordError(err) 1544 span.SetStatus(codes.Error, "failed to get issue") 1545 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1546 return 1547 } 1548 1549 collaborators, err := f.Collaborators(ctx, s) 1550 if err != nil { 1551 log.Println("failed to fetch repo collaborators: %w", err) 1552 span.RecordError(err) 1553 span.SetStatus(codes.Error, "failed to fetch repo collaborators") 1554 } 1555 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1556 return user.Did == collab.Did 1557 }) 1558 isIssueOwner := user.Did == issue.OwnerDid 1559 1560 span.SetAttributes( 1561 attribute.Bool("is_collaborator", isCollaborator), 1562 attribute.Bool("is_issue_owner", isIssueOwner), 1563 ) 1564 1565 // TODO: make this more granular 1566 if isIssueOwner || isCollaborator { 1567 closed := tangled.RepoIssueStateClosed 1568 1569 client, _ := s.auth.AuthorizedClient(r) 1570 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 1571 Collection: tangled.RepoIssueStateNSID, 1572 Repo: user.Did, 1573 Rkey: appview.TID(), 1574 Record: &lexutil.LexiconTypeDecoder{ 1575 Val: &tangled.RepoIssueState{ 1576 Issue: issue.IssueAt, 1577 State: closed, 1578 }, 1579 }, 1580 }) 1581 1582 if err != nil { 1583 log.Println("failed to update issue state", err) 1584 span.RecordError(err) 1585 span.SetStatus(codes.Error, "failed to update issue state in PDS") 1586 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1587 return 1588 } 1589 1590 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt) 1591 if err != nil { 1592 log.Println("failed to close issue", err) 1593 span.RecordError(err) 1594 span.SetStatus(codes.Error, "failed to close issue in database") 1595 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1596 return 1597 } 1598 1599 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1600 return 1601 } else { 1602 log.Println("user is not permitted to close issue") 1603 span.SetAttributes(attribute.Bool("permission_denied", true)) 1604 http.Error(w, "for biden", http.StatusUnauthorized) 1605 return 1606 } 1607} 1608 1609func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 1610 ctx, span := s.t.TraceStart(r.Context(), "ReopenIssue") 1611 defer span.End() 1612 1613 user := s.auth.GetUser(r) 1614 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 1615 if err != nil { 1616 log.Println("failed to get repo and knot", err) 1617 span.RecordError(err) 1618 span.SetStatus(codes.Error, "failed to resolve repo") 1619 return 1620 } 1621 1622 issueId := chi.URLParam(r, "issue") 1623 issueIdInt, err := strconv.Atoi(issueId) 1624 if err != nil { 1625 http.Error(w, "bad issue id", http.StatusBadRequest) 1626 log.Println("failed to parse issue id", err) 1627 span.RecordError(err) 1628 span.SetStatus(codes.Error, "failed to parse issue id") 1629 return 1630 } 1631 1632 span.SetAttributes(attribute.Int("issue_id", issueIdInt)) 1633 1634 issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt) 1635 if err != nil { 1636 log.Println("failed to get issue", err) 1637 span.RecordError(err) 1638 span.SetStatus(codes.Error, "failed to get issue") 1639 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1640 return 1641 } 1642 1643 collaborators, err := f.Collaborators(ctx, s) 1644 if err != nil { 1645 log.Println("failed to fetch repo collaborators: %w", err) 1646 span.RecordError(err) 1647 span.SetStatus(codes.Error, "failed to fetch repo collaborators") 1648 } 1649 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1650 return user.Did == collab.Did 1651 }) 1652 isIssueOwner := user.Did == issue.OwnerDid 1653 1654 span.SetAttributes( 1655 attribute.Bool("is_collaborator", isCollaborator), 1656 attribute.Bool("is_issue_owner", isIssueOwner), 1657 ) 1658 1659 if isCollaborator || isIssueOwner { 1660 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 1661 if err != nil { 1662 log.Println("failed to reopen issue", err) 1663 span.RecordError(err) 1664 span.SetStatus(codes.Error, "failed to reopen issue") 1665 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 1666 return 1667 } 1668 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1669 return 1670 } else { 1671 log.Println("user is not the owner of the repo") 1672 span.SetAttributes(attribute.Bool("permission_denied", true)) 1673 http.Error(w, "forbidden", http.StatusUnauthorized) 1674 return 1675 } 1676} 1677 1678func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { 1679 ctx, span := s.t.TraceStart(r.Context(), "NewIssueComment") 1680 defer span.End() 1681 1682 user := s.auth.GetUser(r) 1683 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 1684 if err != nil { 1685 log.Println("failed to get repo and knot", err) 1686 span.RecordError(err) 1687 span.SetStatus(codes.Error, "failed to resolve repo") 1688 return 1689 } 1690 1691 issueId := chi.URLParam(r, "issue") 1692 issueIdInt, err := strconv.Atoi(issueId) 1693 if err != nil { 1694 http.Error(w, "bad issue id", http.StatusBadRequest) 1695 log.Println("failed to parse issue id", err) 1696 span.RecordError(err) 1697 span.SetStatus(codes.Error, "failed to parse issue id") 1698 return 1699 } 1700 1701 span.SetAttributes( 1702 attribute.Int("issue_id", issueIdInt), 1703 attribute.String("method", r.Method), 1704 ) 1705 1706 switch r.Method { 1707 case http.MethodPost: 1708 body := r.FormValue("body") 1709 if body == "" { 1710 span.SetAttributes(attribute.Bool("missing_body", true)) 1711 s.pages.Notice(w, "issue", "Body is required") 1712 return 1713 } 1714 1715 commentId := mathrand.IntN(1000000) 1716 rkey := appview.TID() 1717 1718 span.SetAttributes( 1719 attribute.Int("comment_id", commentId), 1720 attribute.String("rkey", rkey), 1721 ) 1722 1723 err := db.NewIssueComment(s.db, &db.Comment{ 1724 OwnerDid: user.Did, 1725 RepoAt: f.RepoAt, 1726 Issue: issueIdInt, 1727 CommentId: commentId, 1728 Body: body, 1729 Rkey: rkey, 1730 }) 1731 if err != nil { 1732 log.Println("failed to create comment", err) 1733 span.RecordError(err) 1734 span.SetStatus(codes.Error, "failed to create comment in database") 1735 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1736 return 1737 } 1738 1739 createdAt := time.Now().Format(time.RFC3339) 1740 commentIdInt64 := int64(commentId) 1741 ownerDid := user.Did 1742 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 1743 if err != nil { 1744 log.Println("failed to get issue at", err) 1745 span.RecordError(err) 1746 span.SetStatus(codes.Error, "failed to get issue at") 1747 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1748 return 1749 } 1750 1751 span.SetAttributes(attribute.String("issue_at", issueAt)) 1752 1753 atUri := f.RepoAt.String() 1754 client, _ := s.auth.AuthorizedClient(r) 1755 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 1756 Collection: tangled.RepoIssueCommentNSID, 1757 Repo: user.Did, 1758 Rkey: rkey, 1759 Record: &lexutil.LexiconTypeDecoder{ 1760 Val: &tangled.RepoIssueComment{ 1761 Repo: &atUri, 1762 Issue: issueAt, 1763 CommentId: &commentIdInt64, 1764 Owner: &ownerDid, 1765 Body: body, 1766 CreatedAt: createdAt, 1767 }, 1768 }, 1769 }) 1770 if err != nil { 1771 log.Println("failed to create comment", err) 1772 span.RecordError(err) 1773 span.SetStatus(codes.Error, "failed to create comment in PDS") 1774 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1775 return 1776 } 1777 1778 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 1779 return 1780 } 1781} 1782 1783func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 1784 ctx, span := s.t.TraceStart(r.Context(), "IssueComment") 1785 defer span.End() 1786 1787 user := s.auth.GetUser(r) 1788 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 1789 if err != nil { 1790 log.Println("failed to get repo and knot", err) 1791 span.RecordError(err) 1792 span.SetStatus(codes.Error, "failed to resolve repo") 1793 return 1794 } 1795 1796 issueId := chi.URLParam(r, "issue") 1797 issueIdInt, err := strconv.Atoi(issueId) 1798 if err != nil { 1799 http.Error(w, "bad issue id", http.StatusBadRequest) 1800 log.Println("failed to parse issue id", err) 1801 span.RecordError(err) 1802 span.SetStatus(codes.Error, "failed to parse issue id") 1803 return 1804 } 1805 1806 commentId := chi.URLParam(r, "comment_id") 1807 commentIdInt, err := strconv.Atoi(commentId) 1808 if err != nil { 1809 http.Error(w, "bad comment id", http.StatusBadRequest) 1810 log.Println("failed to parse issue id", err) 1811 span.RecordError(err) 1812 span.SetStatus(codes.Error, "failed to parse comment id") 1813 return 1814 } 1815 1816 span.SetAttributes( 1817 attribute.Int("issue_id", issueIdInt), 1818 attribute.Int("comment_id", commentIdInt), 1819 ) 1820 1821 issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt) 1822 if err != nil { 1823 log.Println("failed to get issue", err) 1824 span.RecordError(err) 1825 span.SetStatus(codes.Error, "failed to get issue") 1826 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1827 return 1828 } 1829 1830 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1831 if err != nil { 1832 http.Error(w, "bad comment id", http.StatusBadRequest) 1833 span.RecordError(err) 1834 span.SetStatus(codes.Error, "failed to get comment") 1835 return 1836 } 1837 1838 identity, err := s.resolver.ResolveIdent(ctx, comment.OwnerDid) 1839 if err != nil { 1840 log.Println("failed to resolve did") 1841 span.RecordError(err) 1842 span.SetStatus(codes.Error, "failed to resolve did") 1843 return 1844 } 1845 1846 didHandleMap := make(map[string]string) 1847 if !identity.Handle.IsInvalidHandle() { 1848 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1849 } else { 1850 didHandleMap[identity.DID.String()] = identity.DID.String() 1851 } 1852 1853 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1854 LoggedInUser: user, 1855 RepoInfo: f.RepoInfo(ctx, s, user), 1856 DidHandleMap: didHandleMap, 1857 Issue: issue, 1858 Comment: comment, 1859 }) 1860} 1861 1862func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { 1863 ctx, span := s.t.TraceStart(r.Context(), "EditIssueComment") 1864 defer span.End() 1865 1866 user := s.auth.GetUser(r) 1867 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 1868 if err != nil { 1869 log.Println("failed to get repo and knot", err) 1870 span.RecordError(err) 1871 span.SetStatus(codes.Error, "failed to resolve repo") 1872 return 1873 } 1874 1875 issueId := chi.URLParam(r, "issue") 1876 issueIdInt, err := strconv.Atoi(issueId) 1877 if err != nil { 1878 http.Error(w, "bad issue id", http.StatusBadRequest) 1879 log.Println("failed to parse issue id", err) 1880 span.RecordError(err) 1881 span.SetStatus(codes.Error, "failed to parse issue id") 1882 return 1883 } 1884 1885 commentId := chi.URLParam(r, "comment_id") 1886 commentIdInt, err := strconv.Atoi(commentId) 1887 if err != nil { 1888 http.Error(w, "bad comment id", http.StatusBadRequest) 1889 log.Println("failed to parse issue id", err) 1890 span.RecordError(err) 1891 span.SetStatus(codes.Error, "failed to parse comment id") 1892 return 1893 } 1894 1895 span.SetAttributes( 1896 attribute.Int("issue_id", issueIdInt), 1897 attribute.Int("comment_id", commentIdInt), 1898 attribute.String("method", r.Method), 1899 ) 1900 1901 issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt) 1902 if err != nil { 1903 log.Println("failed to get issue", err) 1904 span.RecordError(err) 1905 span.SetStatus(codes.Error, "failed to get issue") 1906 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1907 return 1908 } 1909 1910 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1911 if err != nil { 1912 http.Error(w, "bad comment id", http.StatusBadRequest) 1913 span.RecordError(err) 1914 span.SetStatus(codes.Error, "failed to get comment") 1915 return 1916 } 1917 1918 if comment.OwnerDid != user.Did { 1919 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1920 span.SetAttributes(attribute.Bool("permission_denied", true)) 1921 return 1922 } 1923 1924 switch r.Method { 1925 case http.MethodGet: 1926 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 1927 LoggedInUser: user, 1928 RepoInfo: f.RepoInfo(ctx, s, user), 1929 Issue: issue, 1930 Comment: comment, 1931 }) 1932 case http.MethodPost: 1933 // extract form value 1934 newBody := r.FormValue("body") 1935 client, _ := s.auth.AuthorizedClient(r) 1936 rkey := comment.Rkey 1937 1938 span.SetAttributes( 1939 attribute.String("new_body", newBody), 1940 attribute.String("rkey", rkey), 1941 ) 1942 1943 // optimistic update 1944 edited := time.Now() 1945 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 1946 if err != nil { 1947 log.Println("failed to perferom update-description query", err) 1948 span.RecordError(err) 1949 span.SetStatus(codes.Error, "failed to edit comment in database") 1950 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 1951 return 1952 } 1953 1954 // rkey is optional, it was introduced later 1955 if comment.Rkey != "" { 1956 // update the record on pds 1957 ex, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1958 if err != nil { 1959 // failed to get record 1960 log.Println(err, rkey) 1961 span.RecordError(err) 1962 span.SetStatus(codes.Error, "failed to get record from PDS") 1963 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 1964 return 1965 } 1966 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 1967 record, _ := data.UnmarshalJSON(value) 1968 1969 repoAt := record["repo"].(string) 1970 issueAt := record["issue"].(string) 1971 createdAt := record["createdAt"].(string) 1972 commentIdInt64 := int64(commentIdInt) 1973 1974 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 1975 Collection: tangled.RepoIssueCommentNSID, 1976 Repo: user.Did, 1977 Rkey: rkey, 1978 SwapRecord: ex.Cid, 1979 Record: &lexutil.LexiconTypeDecoder{ 1980 Val: &tangled.RepoIssueComment{ 1981 Repo: &repoAt, 1982 Issue: issueAt, 1983 CommentId: &commentIdInt64, 1984 Owner: &comment.OwnerDid, 1985 Body: newBody, 1986 CreatedAt: createdAt, 1987 }, 1988 }, 1989 }) 1990 if err != nil { 1991 log.Println(err) 1992 span.RecordError(err) 1993 span.SetStatus(codes.Error, "failed to put record to PDS") 1994 } 1995 } 1996 1997 // optimistic update for htmx 1998 didHandleMap := map[string]string{ 1999 user.Did: user.Handle, 2000 } 2001 comment.Body = newBody 2002 comment.Edited = &edited 2003 2004 // return new comment body with htmx 2005 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 2006 LoggedInUser: user, 2007 RepoInfo: f.RepoInfo(ctx, s, user), 2008 DidHandleMap: didHandleMap, 2009 Issue: issue, 2010 Comment: comment, 2011 }) 2012 return 2013 } 2014} 2015 2016func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 2017 ctx, span := s.t.TraceStart(r.Context(), "DeleteIssueComment") 2018 defer span.End() 2019 2020 user := s.auth.GetUser(r) 2021 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 2022 if err != nil { 2023 log.Println("failed to get repo and knot", err) 2024 span.RecordError(err) 2025 span.SetStatus(codes.Error, "failed to resolve repo") 2026 return 2027 } 2028 2029 issueId := chi.URLParam(r, "issue") 2030 issueIdInt, err := strconv.Atoi(issueId) 2031 if err != nil { 2032 http.Error(w, "bad issue id", http.StatusBadRequest) 2033 log.Println("failed to parse issue id", err) 2034 span.RecordError(err) 2035 span.SetStatus(codes.Error, "failed to parse issue id") 2036 return 2037 } 2038 2039 issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt) 2040 if err != nil { 2041 log.Println("failed to get issue", err) 2042 span.RecordError(err) 2043 span.SetStatus(codes.Error, "failed to get issue") 2044 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 2045 return 2046 } 2047 2048 commentId := chi.URLParam(r, "comment_id") 2049 commentIdInt, err := strconv.Atoi(commentId) 2050 if err != nil { 2051 http.Error(w, "bad comment id", http.StatusBadRequest) 2052 log.Println("failed to parse issue id", err) 2053 span.RecordError(err) 2054 span.SetStatus(codes.Error, "failed to parse comment id") 2055 return 2056 } 2057 2058 span.SetAttributes( 2059 attribute.Int("issue_id", issueIdInt), 2060 attribute.Int("comment_id", commentIdInt), 2061 ) 2062 2063 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 2064 if err != nil { 2065 http.Error(w, "bad comment id", http.StatusBadRequest) 2066 span.RecordError(err) 2067 span.SetStatus(codes.Error, "failed to get comment") 2068 return 2069 } 2070 2071 if comment.OwnerDid != user.Did { 2072 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 2073 span.SetAttributes(attribute.Bool("permission_denied", true)) 2074 return 2075 } 2076 2077 if comment.Deleted != nil { 2078 http.Error(w, "comment already deleted", http.StatusBadRequest) 2079 span.SetAttributes(attribute.Bool("already_deleted", true)) 2080 return 2081 } 2082 2083 // optimistic deletion 2084 deleted := time.Now() 2085 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 2086 if err != nil { 2087 log.Println("failed to delete comment") 2088 span.RecordError(err) 2089 span.SetStatus(codes.Error, "failed to delete comment in database") 2090 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 2091 return 2092 } 2093 2094 // delete from pds 2095 if comment.Rkey != "" { 2096 client, _ := s.auth.AuthorizedClient(r) 2097 _, err = comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 2098 Collection: tangled.GraphFollowNSID, 2099 Repo: user.Did, 2100 Rkey: comment.Rkey, 2101 }) 2102 if err != nil { 2103 log.Println(err) 2104 span.RecordError(err) 2105 span.SetStatus(codes.Error, "failed to delete record from PDS") 2106 } 2107 } 2108 2109 // optimistic update for htmx 2110 didHandleMap := map[string]string{ 2111 user.Did: user.Handle, 2112 } 2113 comment.Body = "" 2114 comment.Deleted = &deleted 2115 2116 // htmx fragment of comment after deletion 2117 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 2118 LoggedInUser: user, 2119 RepoInfo: f.RepoInfo(ctx, s, user), 2120 DidHandleMap: didHandleMap, 2121 Issue: issue, 2122 Comment: comment, 2123 }) 2124 return 2125} 2126 2127func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 2128 ctx, span := s.t.TraceStart(r.Context(), "RepoIssues") 2129 defer span.End() 2130 2131 params := r.URL.Query() 2132 state := params.Get("state") 2133 isOpen := true 2134 switch state { 2135 case "open": 2136 isOpen = true 2137 case "closed": 2138 isOpen = false 2139 default: 2140 isOpen = true 2141 } 2142 2143 span.SetAttributes( 2144 attribute.Bool("is_open", isOpen), 2145 attribute.String("state_param", state), 2146 ) 2147 2148 page, ok := r.Context().Value("page").(pagination.Page) 2149 if !ok { 2150 log.Println("failed to get page") 2151 span.SetAttributes(attribute.Bool("page_not_found", true)) 2152 page = pagination.FirstPage() 2153 } 2154 2155 user := s.auth.GetUser(r) 2156 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 2157 if err != nil { 2158 log.Println("failed to get repo and knot", err) 2159 span.RecordError(err) 2160 span.SetStatus(codes.Error, "failed to resolve repo") 2161 return 2162 } 2163 2164 issues, err := db.GetIssues(ctx, s.db, f.RepoAt, isOpen, page) 2165 if err != nil { 2166 log.Println("failed to get issues", err) 2167 span.RecordError(err) 2168 span.SetStatus(codes.Error, "failed to get issues") 2169 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 2170 return 2171 } 2172 2173 span.SetAttributes(attribute.Int("issues.count", len(issues))) 2174 2175 identsToResolve := make([]string, len(issues)) 2176 for i, issue := range issues { 2177 identsToResolve[i] = issue.OwnerDid 2178 } 2179 resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve) 2180 didHandleMap := make(map[string]string) 2181 for _, identity := range resolvedIds { 2182 if !identity.Handle.IsInvalidHandle() { 2183 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 2184 } else { 2185 didHandleMap[identity.DID.String()] = identity.DID.String() 2186 } 2187 } 2188 2189 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 2190 LoggedInUser: s.auth.GetUser(r), 2191 RepoInfo: f.RepoInfo(ctx, s, user), 2192 Issues: issues, 2193 DidHandleMap: didHandleMap, 2194 FilteringByOpen: isOpen, 2195 Page: page, 2196 }) 2197 return 2198} 2199 2200func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 2201 ctx, span := s.t.TraceStart(r.Context(), "NewIssue") 2202 defer span.End() 2203 2204 user := s.auth.GetUser(r) 2205 2206 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 2207 if err != nil { 2208 log.Println("failed to get repo and knot", err) 2209 span.RecordError(err) 2210 span.SetStatus(codes.Error, "failed to resolve repo") 2211 return 2212 } 2213 2214 span.SetAttributes(attribute.String("method", r.Method)) 2215 2216 switch r.Method { 2217 case http.MethodGet: 2218 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 2219 LoggedInUser: user, 2220 RepoInfo: f.RepoInfo(ctx, s, user), 2221 }) 2222 case http.MethodPost: 2223 title := r.FormValue("title") 2224 body := r.FormValue("body") 2225 2226 span.SetAttributes( 2227 attribute.String("title", title), 2228 attribute.String("body_length", fmt.Sprintf("%d", len(body))), 2229 ) 2230 2231 if title == "" || body == "" { 2232 span.SetAttributes(attribute.Bool("form_validation_failed", true)) 2233 s.pages.Notice(w, "issues", "Title and body are required") 2234 return 2235 } 2236 2237 tx, err := s.db.BeginTx(ctx, nil) 2238 if err != nil { 2239 span.RecordError(err) 2240 span.SetStatus(codes.Error, "failed to begin transaction") 2241 s.pages.Notice(w, "issues", "Failed to create issue, try again later") 2242 return 2243 } 2244 2245 err = db.NewIssue(tx, &db.Issue{ 2246 RepoAt: f.RepoAt, 2247 Title: title, 2248 Body: body, 2249 OwnerDid: user.Did, 2250 }) 2251 if err != nil { 2252 log.Println("failed to create issue", err) 2253 span.RecordError(err) 2254 span.SetStatus(codes.Error, "failed to create issue in database") 2255 s.pages.Notice(w, "issues", "Failed to create issue.") 2256 return 2257 } 2258 2259 issueId, err := db.GetIssueId(s.db, f.RepoAt) 2260 if err != nil { 2261 log.Println("failed to get issue id", err) 2262 span.RecordError(err) 2263 span.SetStatus(codes.Error, "failed to get issue id") 2264 s.pages.Notice(w, "issues", "Failed to create issue.") 2265 return 2266 } 2267 2268 span.SetAttributes(attribute.Int("issue_id", issueId)) 2269 2270 client, _ := s.auth.AuthorizedClient(r) 2271 atUri := f.RepoAt.String() 2272 rkey := appview.TID() 2273 span.SetAttributes(attribute.String("rkey", rkey)) 2274 2275 resp, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 2276 Collection: tangled.RepoIssueNSID, 2277 Repo: user.Did, 2278 Rkey: rkey, 2279 Record: &lexutil.LexiconTypeDecoder{ 2280 Val: &tangled.RepoIssue{ 2281 Repo: atUri, 2282 Title: title, 2283 Body: &body, 2284 Owner: user.Did, 2285 IssueId: int64(issueId), 2286 }, 2287 }, 2288 }) 2289 if err != nil { 2290 log.Println("failed to create issue", err) 2291 span.RecordError(err) 2292 span.SetStatus(codes.Error, "failed to create issue in PDS") 2293 s.pages.Notice(w, "issues", "Failed to create issue.") 2294 return 2295 } 2296 2297 span.SetAttributes(attribute.String("issue_uri", resp.Uri)) 2298 2299 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 2300 if err != nil { 2301 log.Println("failed to set issue at", err) 2302 span.RecordError(err) 2303 span.SetStatus(codes.Error, "failed to set issue URI in database") 2304 s.pages.Notice(w, "issues", "Failed to create issue.") 2305 return 2306 } 2307 2308 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 2309 return 2310 } 2311} 2312 2313func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { 2314 ctx, span := s.t.TraceStart(r.Context(), "ForkRepo") 2315 defer span.End() 2316 2317 user := s.auth.GetUser(r) 2318 f, err := s.fullyResolvedRepo(r.WithContext(ctx)) 2319 if err != nil { 2320 log.Printf("failed to resolve source repo: %v", err) 2321 span.RecordError(err) 2322 span.SetStatus(codes.Error, "failed to resolve source repo") 2323 return 2324 } 2325 2326 span.SetAttributes( 2327 attribute.String("method", r.Method), 2328 attribute.String("repo_name", f.RepoName), 2329 attribute.String("owner_did", f.OwnerDid()), 2330 attribute.String("knot", f.Knot), 2331 ) 2332 2333 switch r.Method { 2334 case http.MethodGet: 2335 user := s.auth.GetUser(r) 2336 knots, err := s.enforcer.GetDomainsForUser(user.Did) 2337 if err != nil { 2338 span.RecordError(err) 2339 span.SetStatus(codes.Error, "failed to get domains for user") 2340 s.pages.Notice(w, "repo", "Invalid user account.") 2341 return 2342 } 2343 2344 span.SetAttributes(attribute.Int("knots.count", len(knots))) 2345 2346 s.pages.ForkRepo(w, pages.ForkRepoParams{ 2347 LoggedInUser: user, 2348 Knots: knots, 2349 RepoInfo: f.RepoInfo(ctx, s, user), 2350 }) 2351 2352 case http.MethodPost: 2353 knot := r.FormValue("knot") 2354 if knot == "" { 2355 span.SetAttributes(attribute.Bool("missing_knot", true)) 2356 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") 2357 return 2358 } 2359 2360 span.SetAttributes(attribute.String("target_knot", knot)) 2361 2362 ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 2363 if err != nil || !ok { 2364 span.SetAttributes( 2365 attribute.Bool("permission_denied", true), 2366 attribute.Bool("enforce_error", err != nil), 2367 ) 2368 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 2369 return 2370 } 2371 2372 forkName := fmt.Sprintf("%s", f.RepoName) 2373 span.SetAttributes(attribute.String("fork_name", forkName)) 2374 2375 // this check is *only* to see if the forked repo name already exists 2376 // in the user's account. 2377 existingRepo, err := db.GetRepo(ctx, s.db, user.Did, f.RepoName) 2378 if err != nil { 2379 if errors.Is(err, sql.ErrNoRows) { 2380 // no existing repo with this name found, we can use the name as is 2381 span.SetAttributes(attribute.Bool("repo_name_available", true)) 2382 } else { 2383 log.Println("error fetching existing repo from db", err) 2384 span.RecordError(err) 2385 span.SetStatus(codes.Error, "failed to check for existing repo") 2386 s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2387 return 2388 } 2389 } else if existingRepo != nil { 2390 // repo with this name already exists, append random string 2391 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2392 span.SetAttributes( 2393 attribute.Bool("repo_name_conflict", true), 2394 attribute.String("adjusted_fork_name", forkName), 2395 ) 2396 } 2397 2398 secret, err := db.GetRegistrationKey(s.db, knot) 2399 if err != nil { 2400 span.RecordError(err) 2401 span.SetStatus(codes.Error, "failed to get registration key") 2402 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 2403 return 2404 } 2405 2406 client, err := NewSignedClient(knot, secret, s.config.Dev) 2407 if err != nil { 2408 span.RecordError(err) 2409 span.SetStatus(codes.Error, "failed to create signed client") 2410 s.pages.Notice(w, "repo", "Failed to reach knot server.") 2411 return 2412 } 2413 2414 var uri string 2415 if s.config.Dev { 2416 uri = "http" 2417 } else { 2418 uri = "https" 2419 } 2420 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 2421 sourceAt := f.RepoAt.String() 2422 2423 span.SetAttributes( 2424 attribute.String("fork_source_url", forkSourceUrl), 2425 attribute.String("source_at", sourceAt), 2426 ) 2427 2428 rkey := appview.TID() 2429 repo := &db.Repo{ 2430 Did: user.Did, 2431 Name: forkName, 2432 Knot: knot, 2433 Rkey: rkey, 2434 Source: sourceAt, 2435 } 2436 2437 span.SetAttributes(attribute.String("rkey", rkey)) 2438 2439 tx, err := s.db.BeginTx(ctx, nil) 2440 if err != nil { 2441 log.Println(err) 2442 span.RecordError(err) 2443 span.SetStatus(codes.Error, "failed to begin transaction") 2444 s.pages.Notice(w, "repo", "Failed to save repository information.") 2445 return 2446 } 2447 defer func() { 2448 tx.Rollback() 2449 err = s.enforcer.E.LoadPolicy() 2450 if err != nil { 2451 log.Println("failed to rollback policies") 2452 span.RecordError(err) 2453 } 2454 }() 2455 2456 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 2457 if err != nil { 2458 span.RecordError(err) 2459 span.SetStatus(codes.Error, "failed to fork repo on knot server") 2460 s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 2461 return 2462 } 2463 2464 span.SetAttributes(attribute.Int("fork_response_status", resp.StatusCode)) 2465 2466 switch resp.StatusCode { 2467 case http.StatusConflict: 2468 span.SetAttributes(attribute.Bool("name_conflict", true)) 2469 s.pages.Notice(w, "repo", "A repository with that name already exists.") 2470 return 2471 case http.StatusInternalServerError: 2472 span.SetAttributes(attribute.Bool("server_error", true)) 2473 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 2474 return 2475 case http.StatusNoContent: 2476 // continue 2477 } 2478 2479 xrpcClient, _ := s.auth.AuthorizedClient(r) 2480 2481 createdAt := time.Now().Format(time.RFC3339) 2482 atresp, err := comatproto.RepoPutRecord(ctx, xrpcClient, &comatproto.RepoPutRecord_Input{ 2483 Collection: tangled.RepoNSID, 2484 Repo: user.Did, 2485 Rkey: rkey, 2486 Record: &lexutil.LexiconTypeDecoder{ 2487 Val: &tangled.Repo{ 2488 Knot: repo.Knot, 2489 Name: repo.Name, 2490 CreatedAt: createdAt, 2491 Owner: user.Did, 2492 Source: &sourceAt, 2493 }}, 2494 }) 2495 if err != nil { 2496 log.Printf("failed to create record: %s", err) 2497 span.RecordError(err) 2498 span.SetStatus(codes.Error, "failed to create record in PDS") 2499 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 2500 return 2501 } 2502 log.Println("created repo record: ", atresp.Uri) 2503 span.SetAttributes(attribute.String("repo_uri", atresp.Uri)) 2504 2505 repo.AtUri = atresp.Uri 2506 err = db.AddRepo(ctx, tx, repo) 2507 if err != nil { 2508 log.Println(err) 2509 span.RecordError(err) 2510 span.SetStatus(codes.Error, "failed to add repo to database") 2511 s.pages.Notice(w, "repo", "Failed to save repository information.") 2512 return 2513 } 2514 2515 // acls 2516 p, _ := securejoin.SecureJoin(user.Did, forkName) 2517 err = s.enforcer.AddRepo(user.Did, knot, p) 2518 if err != nil { 2519 log.Println(err) 2520 span.RecordError(err) 2521 span.SetStatus(codes.Error, "failed to set up repository permissions") 2522 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 2523 return 2524 } 2525 2526 err = tx.Commit() 2527 if err != nil { 2528 log.Println("failed to commit changes", err) 2529 span.RecordError(err) 2530 span.SetStatus(codes.Error, "failed to commit transaction") 2531 http.Error(w, err.Error(), http.StatusInternalServerError) 2532 return 2533 } 2534 2535 err = s.enforcer.E.SavePolicy() 2536 if err != nil { 2537 log.Println("failed to update ACLs", err) 2538 span.RecordError(err) 2539 span.SetStatus(codes.Error, "failed to save policy") 2540 http.Error(w, err.Error(), http.StatusInternalServerError) 2541 return 2542 } 2543 2544 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2545 return 2546 } 2547}