This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / repo / repo.go
41 kB 1571 lines
1package repo 2 3import ( 4 "context" 5 "database/sql" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "net/url" 11 "slices" 12 "strings" 13 "time" 14 15 "tangled.org/core/appview/cloudflare" 16 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/appview/compat113" 19 "tangled.org/core/appview/config" 20 "tangled.org/core/appview/db" 21 "tangled.org/core/appview/models" 22 "tangled.org/core/appview/notify" 23 "tangled.org/core/appview/oauth" 24 "tangled.org/core/appview/pages" 25 "tangled.org/core/appview/pagination" 26 "tangled.org/core/appview/reporesolver" 27 "tangled.org/core/appview/sites" 28 "tangled.org/core/appview/validator" 29 xrpcclient "tangled.org/core/appview/xrpcclient" 30 "tangled.org/core/eventconsumer" 31 "tangled.org/core/idresolver" 32 "tangled.org/core/ogre" 33 "tangled.org/core/orm" 34 "tangled.org/core/rbac" 35 "tangled.org/core/tid" 36 "tangled.org/core/xrpc/serviceauth" 37 38 comatproto "github.com/bluesky-social/indigo/api/atproto" 39 "github.com/bluesky-social/indigo/atproto/atclient" 40 "github.com/bluesky-social/indigo/atproto/syntax" 41 lexutil "github.com/bluesky-social/indigo/lex/util" 42 43 "github.com/go-chi/chi/v5" 44) 45 46type Repo struct { 47 repoResolver *reporesolver.RepoResolver 48 idResolver *idresolver.Resolver 49 config *config.Config 50 oauth *oauth.OAuth 51 pages *pages.Pages 52 spindlestream *eventconsumer.Consumer 53 db *db.DB 54 enforcer *rbac.Enforcer 55 notifier notify.Notifier 56 logger *slog.Logger 57 serviceAuth *serviceauth.ServiceAuth 58 validator *validator.Validator 59 cfClient *cloudflare.Client 60 ogreClient *ogre.Client 61} 62 63func New( 64 oauth *oauth.OAuth, 65 repoResolver *reporesolver.RepoResolver, 66 pages *pages.Pages, 67 spindlestream *eventconsumer.Consumer, 68 idResolver *idresolver.Resolver, 69 db *db.DB, 70 config *config.Config, 71 notifier notify.Notifier, 72 enforcer *rbac.Enforcer, 73 logger *slog.Logger, 74 validator *validator.Validator, 75 cfClient *cloudflare.Client, 76) *Repo { 77 return &Repo{ 78 oauth: oauth, 79 repoResolver: repoResolver, 80 pages: pages, 81 idResolver: idResolver, 82 config: config, 83 spindlestream: spindlestream, 84 db: db, 85 notifier: notifier, 86 enforcer: enforcer, 87 logger: logger, 88 validator: validator, 89 cfClient: cfClient, 90 ogreClient: ogre.NewClient(config.Ogre.Host), 91 } 92} 93 94// modify the spindle configured for this repo 95func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 96 user := rp.oauth.GetMultiAccountUser(r) 97 l := rp.logger.With("handler", "EditSpindle") 98 l = l.With("did", user.Did) 99 100 errorId := "operation-error" 101 fail := func(msg string, err error) { 102 l.Error(msg, "err", err) 103 rp.pages.Notice(w, errorId, msg) 104 } 105 106 f, err := rp.repoResolver.Resolve(r) 107 if err != nil { 108 fail("Failed to resolve repo. Try again later", err) 109 return 110 } 111 112 newSpindle := r.FormValue("spindle") 113 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 114 client, err := rp.oauth.AuthorizedClient(r) 115 if err != nil { 116 fail("Failed to authorize. Try again later.", err) 117 return 118 } 119 120 if !removingSpindle { 121 // ensure that this is a valid spindle for this user 122 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 123 if err != nil { 124 fail("Failed to find spindles. Try again later.", err) 125 return 126 } 127 128 if !slices.Contains(validSpindles, newSpindle) { 129 fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 130 return 131 } 132 } 133 134 newRepo := *f 135 newRepo.Spindle = newSpindle 136 record := newRepo.AsRecord() 137 138 spindlePtr := &newSpindle 139 if removingSpindle { 140 spindlePtr = nil 141 newRepo.Spindle = "" 142 } 143 144 // optimistic update 145 err = db.UpdateSpindle(rp.db, newRepo.RepoDid, spindlePtr) 146 if err != nil { 147 fail("Failed to update spindle. Try again later.", err) 148 return 149 } 150 151 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 152 if err != nil { 153 fail("Failed to update spindle, no record found on PDS.", err) 154 return 155 } 156 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 157 Collection: tangled.RepoNSID, 158 Repo: newRepo.Did, 159 Rkey: newRepo.Rkey, 160 SwapRecord: ex.Cid, 161 Record: &lexutil.LexiconTypeDecoder{ 162 Val: &record, 163 }, 164 }) 165 166 if err != nil { 167 fail("Failed to update spindle, unable to save to PDS.", err) 168 return 169 } 170 171 if !removingSpindle { 172 // add this spindle to spindle stream 173 rp.spindlestream.AddSource( 174 context.Background(), 175 eventconsumer.NewSpindleSource(newSpindle), 176 ) 177 } 178 179 rp.pages.HxRefresh(w) 180} 181 182func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) { 183 user := rp.oauth.GetMultiAccountUser(r) 184 l := rp.logger.With("handler", "AddLabel") 185 l = l.With("did", user.Did) 186 187 f, err := rp.repoResolver.Resolve(r) 188 if err != nil { 189 l.Error("failed to get repo and knot", "err", err) 190 return 191 } 192 193 errorId := "add-label-error" 194 fail := func(msg string, err error) { 195 l.Error(msg, "err", err) 196 rp.pages.Notice(w, errorId, msg) 197 } 198 199 // get form values for label definition 200 name := r.FormValue("name") 201 concreteType := r.FormValue("valueType") 202 valueFormat := r.FormValue("valueFormat") 203 enumValues := r.FormValue("enumValues") 204 scope := r.Form["scope"] 205 color := r.FormValue("color") 206 multiple := r.FormValue("multiple") == "true" 207 208 var variants []string 209 for part := range strings.SplitSeq(enumValues, ",") { 210 if part = strings.TrimSpace(part); part != "" { 211 variants = append(variants, part) 212 } 213 } 214 215 if concreteType == "" { 216 concreteType = "null" 217 } 218 219 format := models.ValueTypeFormatAny 220 if valueFormat == "did" { 221 format = models.ValueTypeFormatDid 222 } 223 224 valueType := models.ValueType{ 225 Type: models.ConcreteType(concreteType), 226 Format: format, 227 Enum: variants, 228 } 229 230 label := models.LabelDefinition{ 231 Did: user.Did, 232 Rkey: tid.TID(), 233 Name: name, 234 ValueType: valueType, 235 Scope: scope, 236 Color: &color, 237 Multiple: multiple, 238 Created: time.Now(), 239 } 240 if err := rp.validator.ValidateLabelDefinition(&label); err != nil { 241 fail(err.Error(), err) 242 return 243 } 244 245 // announce this relation into the firehose, store into owners' pds 246 client, err := rp.oauth.AuthorizedClient(r) 247 if err != nil { 248 fail(err.Error(), err) 249 return 250 } 251 252 // emit a labelRecord 253 labelRecord := label.AsRecord() 254 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 255 Collection: tangled.LabelDefinitionNSID, 256 Repo: label.Did, 257 Rkey: label.Rkey, 258 Record: &lexutil.LexiconTypeDecoder{ 259 Val: &labelRecord, 260 }, 261 }) 262 // invalid record 263 if err != nil { 264 fail("Failed to write record to PDS.", err) 265 return 266 } 267 268 aturi := resp.Uri 269 l = l.With("at-uri", aturi) 270 l.Info("wrote label record to PDS") 271 272 // update the repo to subscribe to this label 273 newRepo := *f 274 newRepo.Labels = append(newRepo.Labels, aturi) 275 repoRecord := newRepo.AsRecord() 276 277 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 278 if err != nil { 279 fail("Failed to update labels, no record found on PDS.", err) 280 return 281 } 282 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 283 Collection: tangled.RepoNSID, 284 Repo: newRepo.Did, 285 Rkey: newRepo.Rkey, 286 SwapRecord: ex.Cid, 287 Record: &lexutil.LexiconTypeDecoder{ 288 Val: &repoRecord, 289 }, 290 }) 291 if err != nil { 292 fail("Failed to update labels for repo.", err) 293 return 294 } 295 296 tx, err := rp.db.BeginTx(r.Context(), nil) 297 if err != nil { 298 fail("Failed to add label.", err) 299 return 300 } 301 302 rollback := func() { 303 err1 := tx.Rollback() 304 err2 := rollbackRecord(context.Background(), aturi, client) 305 306 // ignore txn complete errors, this is okay 307 if errors.Is(err1, sql.ErrTxDone) { 308 err1 = nil 309 } 310 311 if errs := errors.Join(err1, err2); errs != nil { 312 l.Error("failed to rollback changes", "errs", errs) 313 return 314 } 315 } 316 defer rollback() 317 318 _, err = db.AddLabelDefinition(tx, &label) 319 if err != nil { 320 fail("Failed to add label.", err) 321 return 322 } 323 324 if err = db.SubscribeLabel(tx, &models.RepoLabel{ 325 RepoDid: syntax.DID(f.RepoDid), 326 LabelAt: label.AtUri(), 327 }); err != nil { 328 fail("Failed to subscribe to label.", err) 329 return 330 } 331 332 err = tx.Commit() 333 if err != nil { 334 fail("Failed to add label.", err) 335 return 336 } 337 338 // clear aturi when everything is successful 339 aturi = "" 340 341 rp.pages.HxRefresh(w) 342} 343 344func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) { 345 user := rp.oauth.GetMultiAccountUser(r) 346 l := rp.logger.With("handler", "DeleteLabel") 347 l = l.With("did", user.Did) 348 349 f, err := rp.repoResolver.Resolve(r) 350 if err != nil { 351 l.Error("failed to get repo and knot", "err", err) 352 return 353 } 354 355 errorId := "label-operation" 356 fail := func(msg string, err error) { 357 l.Error(msg, "err", err) 358 rp.pages.Notice(w, errorId, msg) 359 } 360 361 // get form values 362 labelId := r.FormValue("label-id") 363 364 label, err := db.GetLabelDefinition(rp.db, orm.FilterEq("id", labelId)) 365 if err != nil { 366 fail("Failed to find label definition.", err) 367 return 368 } 369 370 client, err := rp.oauth.AuthorizedClient(r) 371 if err != nil { 372 fail(err.Error(), err) 373 return 374 } 375 376 // delete label record from PDS 377 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 378 Collection: tangled.LabelDefinitionNSID, 379 Repo: label.Did, 380 Rkey: label.Rkey, 381 }) 382 if err != nil { 383 fail("Failed to delete label record from PDS.", err) 384 return 385 } 386 387 // update repo record to remove the label reference 388 newRepo := *f 389 var updated []string 390 removedAt := label.AtUri().String() 391 for _, l := range newRepo.Labels { 392 if l != removedAt { 393 updated = append(updated, l) 394 } 395 } 396 newRepo.Labels = updated 397 repoRecord := newRepo.AsRecord() 398 399 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 400 if err != nil { 401 fail("Failed to update labels, no record found on PDS.", err) 402 return 403 } 404 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 405 Collection: tangled.RepoNSID, 406 Repo: newRepo.Did, 407 Rkey: newRepo.Rkey, 408 SwapRecord: ex.Cid, 409 Record: &lexutil.LexiconTypeDecoder{ 410 Val: &repoRecord, 411 }, 412 }) 413 if err != nil { 414 fail("Failed to update repo record.", err) 415 return 416 } 417 418 // transaction for DB changes 419 tx, err := rp.db.BeginTx(r.Context(), nil) 420 if err != nil { 421 fail("Failed to delete label.", err) 422 return 423 } 424 defer tx.Rollback() 425 426 err = db.UnsubscribeLabel( 427 tx, 428 orm.FilterEq("repo_did", f.RepoDid), 429 orm.FilterEq("label_at", removedAt), 430 ) 431 if err != nil { 432 fail("Failed to unsubscribe label.", err) 433 return 434 } 435 436 err = db.DeleteLabelDefinition(tx, orm.FilterEq("id", label.Id)) 437 if err != nil { 438 fail("Failed to delete label definition.", err) 439 return 440 } 441 442 err = tx.Commit() 443 if err != nil { 444 fail("Failed to delete label.", err) 445 return 446 } 447 448 // everything succeeded 449 rp.pages.HxRefresh(w) 450} 451 452func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { 453 user := rp.oauth.GetMultiAccountUser(r) 454 l := rp.logger.With("handler", "SubscribeLabel") 455 l = l.With("did", user.Did) 456 457 f, err := rp.repoResolver.Resolve(r) 458 if err != nil { 459 l.Error("failed to get repo and knot", "err", err) 460 return 461 } 462 463 if err := r.ParseForm(); err != nil { 464 l.Error("invalid form", "err", err) 465 return 466 } 467 468 errorId := "default-label-operation" 469 fail := func(msg string, err error) { 470 l.Error(msg, "err", err) 471 rp.pages.Notice(w, errorId, msg) 472 } 473 474 labelAts := r.Form["label"] 475 _, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts)) 476 if err != nil { 477 fail("Failed to subscribe to label.", err) 478 return 479 } 480 481 newRepo := *f 482 newRepo.Labels = append(newRepo.Labels, labelAts...) 483 484 // dedup 485 slices.Sort(newRepo.Labels) 486 newRepo.Labels = slices.Compact(newRepo.Labels) 487 488 repoRecord := newRepo.AsRecord() 489 490 client, err := rp.oauth.AuthorizedClient(r) 491 if err != nil { 492 fail(err.Error(), err) 493 return 494 } 495 496 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey) 497 if err != nil { 498 fail("Failed to update labels, no record found on PDS.", err) 499 return 500 } 501 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 502 Collection: tangled.RepoNSID, 503 Repo: newRepo.Did, 504 Rkey: newRepo.Rkey, 505 SwapRecord: ex.Cid, 506 Record: &lexutil.LexiconTypeDecoder{ 507 Val: &repoRecord, 508 }, 509 }) 510 511 tx, err := rp.db.Begin() 512 if err != nil { 513 fail("Failed to subscribe to label.", err) 514 return 515 } 516 defer tx.Rollback() 517 518 for _, l := range labelAts { 519 err = db.SubscribeLabel(tx, &models.RepoLabel{ 520 RepoDid: syntax.DID(f.RepoDid), 521 LabelAt: syntax.ATURI(l), 522 }) 523 if err != nil { 524 fail("Failed to subscribe to label.", err) 525 return 526 } 527 } 528 529 if err := tx.Commit(); err != nil { 530 fail("Failed to subscribe to label.", err) 531 return 532 } 533 534 // everything succeeded 535 rp.pages.HxRefresh(w) 536} 537 538func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { 539 user := rp.oauth.GetMultiAccountUser(r) 540 l := rp.logger.With("handler", "UnsubscribeLabel") 541 l = l.With("did", user.Did) 542 543 f, err := rp.repoResolver.Resolve(r) 544 if err != nil { 545 l.Error("failed to get repo and knot", "err", err) 546 return 547 } 548 549 if err := r.ParseForm(); err != nil { 550 l.Error("invalid form", "err", err) 551 return 552 } 553 554 errorId := "default-label-operation" 555 fail := func(msg string, err error) { 556 l.Error(msg, "err", err) 557 rp.pages.Notice(w, errorId, msg) 558 } 559 560 labelAts := r.Form["label"] 561 _, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts)) 562 if err != nil { 563 fail("Failed to unsubscribe to label.", err) 564 return 565 } 566 567 // update repo record to remove the label reference 568 newRepo := *f 569 var updated []string 570 for _, l := range newRepo.Labels { 571 if !slices.Contains(labelAts, l) { 572 updated = append(updated, l) 573 } 574 } 575 newRepo.Labels = updated 576 repoRecord := newRepo.AsRecord() 577 578 client, err := rp.oauth.AuthorizedClient(r) 579 if err != nil { 580 fail(err.Error(), err) 581 return 582 } 583 584 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey) 585 if err != nil { 586 fail("Failed to update labels, no record found on PDS.", err) 587 return 588 } 589 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 590 Collection: tangled.RepoNSID, 591 Repo: newRepo.Did, 592 Rkey: newRepo.Rkey, 593 SwapRecord: ex.Cid, 594 Record: &lexutil.LexiconTypeDecoder{ 595 Val: &repoRecord, 596 }, 597 }) 598 599 err = db.UnsubscribeLabel( 600 rp.db, 601 orm.FilterEq("repo_did", f.RepoDid), 602 orm.FilterIn("label_at", labelAts), 603 ) 604 if err != nil { 605 fail("Failed to unsubscribe label.", err) 606 return 607 } 608 609 // everything succeeded 610 rp.pages.HxRefresh(w) 611} 612 613func (rp *Repo) LabelPanel(w http.ResponseWriter, r *http.Request) { 614 l := rp.logger.With("handler", "LabelPanel") 615 616 f, err := rp.repoResolver.Resolve(r) 617 if err != nil { 618 l.Error("failed to get repo and knot", "err", err) 619 return 620 } 621 622 subjectStr := r.FormValue("subject") 623 subject, err := syntax.ParseATURI(subjectStr) 624 if err != nil { 625 l.Error("failed to get repo and knot", "err", err) 626 return 627 } 628 629 labelDefs, err := db.GetLabelDefinitions( 630 rp.db, 631 orm.FilterIn("at_uri", f.Labels), 632 orm.FilterContains("scope", subject.Collection().String()), 633 ) 634 if err != nil { 635 l.Error("failed to fetch label defs", "err", err) 636 return 637 } 638 639 defs := make(map[string]*models.LabelDefinition) 640 for _, l := range labelDefs { 641 defs[l.AtUri().String()] = &l 642 } 643 644 states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject)) 645 if err != nil { 646 l.Error("failed to build label state", "err", err) 647 return 648 } 649 state := states[subject] 650 651 user := rp.oauth.GetMultiAccountUser(r) 652 rp.pages.LabelPanel(w, pages.LabelPanelParams{ 653 LoggedInUser: user, 654 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 655 Defs: defs, 656 Subject: subject.String(), 657 State: state, 658 }) 659} 660 661func (rp *Repo) EditLabelPanel(w http.ResponseWriter, r *http.Request) { 662 l := rp.logger.With("handler", "EditLabelPanel") 663 664 f, err := rp.repoResolver.Resolve(r) 665 if err != nil { 666 l.Error("failed to get repo and knot", "err", err) 667 return 668 } 669 670 subjectStr := r.FormValue("subject") 671 subject, err := syntax.ParseATURI(subjectStr) 672 if err != nil { 673 l.Error("failed to get repo and knot", "err", err) 674 return 675 } 676 677 labelDefs, err := db.GetLabelDefinitions( 678 rp.db, 679 orm.FilterIn("at_uri", f.Labels), 680 orm.FilterContains("scope", subject.Collection().String()), 681 ) 682 if err != nil { 683 l.Error("failed to fetch labels", "err", err) 684 return 685 } 686 687 defs := make(map[string]*models.LabelDefinition) 688 for _, l := range labelDefs { 689 defs[l.AtUri().String()] = &l 690 } 691 692 states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject)) 693 if err != nil { 694 l.Error("failed to build label state", "err", err) 695 return 696 } 697 state := states[subject] 698 699 user := rp.oauth.GetMultiAccountUser(r) 700 rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ 701 LoggedInUser: user, 702 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 703 Defs: defs, 704 Subject: subject.String(), 705 State: state, 706 }) 707} 708 709func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 710 user := rp.oauth.GetMultiAccountUser(r) 711 l := rp.logger.With("handler", "AddCollaborator") 712 l = l.With("did", user.Did) 713 714 f, err := rp.repoResolver.Resolve(r) 715 if err != nil { 716 l.Error("failed to get repo and knot", "err", err) 717 return 718 } 719 720 errorId := "add-collaborator-error" 721 fail := func(msg string, err error) { 722 l.Error(msg, "err", err) 723 rp.pages.Notice(w, errorId, msg) 724 } 725 726 collaborator := r.FormValue("collaborator") 727 if collaborator == "" { 728 fail("Invalid form.", nil) 729 return 730 } 731 732 // remove a single leading `@`, to make @handle work with ResolveIdent 733 collaborator = strings.TrimPrefix(collaborator, "@") 734 735 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 736 if err != nil { 737 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) 738 return 739 } 740 741 if collaboratorIdent.DID.String() == user.Did { 742 fail("You seem to be adding yourself as a collaborator.", nil) 743 return 744 } 745 l = l.With("collaborator", collaboratorIdent.Handle) 746 l = l.With("knot", f.Knot) 747 748 // announce this relation into the firehose, store into owners' pds 749 client, err := rp.oauth.AuthorizedClient(r) 750 if err != nil { 751 fail("Failed to write to PDS.", err) 752 return 753 } 754 755 // emit a record 756 currentUser := rp.oauth.GetMultiAccountUser(r) 757 rkey := tid.TID() 758 createdAt := time.Now() 759 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 760 Collection: tangled.RepoCollaboratorNSID, 761 Repo: currentUser.Did, 762 Rkey: rkey, 763 Record: compat113.Collaborator(repoCollaboratorRecord(f, collaboratorIdent.DID.String(), createdAt)), 764 }) 765 // invalid record 766 if err != nil { 767 fail("Failed to write record to PDS.", err) 768 return 769 } 770 771 aturi := resp.Uri 772 l = l.With("at-uri", aturi) 773 l.Info("wrote record to PDS") 774 775 tx, err := rp.db.BeginTx(r.Context(), nil) 776 if err != nil { 777 fail("Failed to add collaborator.", err) 778 return 779 } 780 781 rollback := func() { 782 err1 := tx.Rollback() 783 err2 := rp.enforcer.E.LoadPolicy() 784 err3 := rollbackRecord(context.Background(), aturi, client) 785 786 // ignore txn complete errors, this is okay 787 if errors.Is(err1, sql.ErrTxDone) { 788 err1 = nil 789 } 790 791 if errs := errors.Join(err1, err2, err3); errs != nil { 792 l.Error("failed to rollback changes", "errs", errs) 793 return 794 } 795 } 796 defer rollback() 797 798 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.RepoIdentifier()) 799 if err != nil { 800 fail("Failed to add collaborator permissions.", err) 801 return 802 } 803 804 err = db.AddCollaborator(tx, models.Collaborator{ 805 Did: syntax.DID(currentUser.Did), 806 Rkey: rkey, 807 SubjectDid: collaboratorIdent.DID, 808 RepoDid: syntax.DID(f.RepoDid), 809 Created: createdAt, 810 }) 811 if err != nil { 812 fail("Failed to add collaborator.", err) 813 return 814 } 815 816 err = tx.Commit() 817 if err != nil { 818 fail("Failed to add collaborator.", err) 819 return 820 } 821 822 err = rp.enforcer.E.SavePolicy() 823 if err != nil { 824 fail("Failed to update collaborator permissions.", err) 825 return 826 } 827 828 // clear aturi to when everything is successful 829 aturi = "" 830 831 rp.pages.HxRefresh(w) 832} 833 834func (rp *Repo) RenameRepo(w http.ResponseWriter, r *http.Request) { 835 l := rp.logger.With("handler", "RenameRepo") 836 noticeId := "rename-repo-error" 837 838 user := rp.oauth.GetMultiAccountUser(r) 839 f, err := rp.repoResolver.Resolve(r) 840 if err != nil { 841 l.Error("failed to get repo and knot", "err", err) 842 rp.pages.Notice(w, noticeId, "Failed to load repository.") 843 return 844 } 845 l = l.With("did", user.Did, "rkey", f.Rkey, "oldName", f.Name) 846 847 if f.RepoDid == "" { 848 rp.pages.Notice(w, noticeId, "This repository's knot has not completed the DID migration; rename is unavailable.") 849 return 850 } 851 852 if !compat113.KnotSupports114(r.Context(), f.Knot, rp.config.Core.Dev) { 853 rp.pages.Notice(w, noticeId, "This repository's knot is below v1.14 and does not yet support renames. Ask the knot operator to upgrade.") 854 return 855 } 856 857 newName, err := validateRenameInput(f.Name, f.Rkey, r.FormValue("name")) 858 if err != nil { 859 rp.pages.Notice(w, noticeId, err.Error()) 860 return 861 } 862 newRkey := strings.ToLower(newName) 863 l = l.With("newName", newName, "newRkey", newRkey) 864 865 atpClient, err := rp.oauth.AuthorizedClient(r) 866 if err != nil { 867 l.Error("failed to get authorized client", "err", err) 868 rp.pages.Notice(w, noticeId, "Failed to authorize. Try again later.") 869 return 870 } 871 872 newRepo := *f 873 newRepo.Name = newName 874 newRepo.Rkey = newRkey 875 newRepo.Created = time.Now() 876 record := newRepo.AsRecord() 877 878 if newRkey == f.Rkey { 879 ex, err := comatproto.RepoGetRecord(r.Context(), atpClient, "", tangled.RepoNSID, f.Did, f.Rkey) 880 if err != nil { 881 l.Error("failed to fetch existing record", "err", err) 882 rp.pages.Notice(w, noticeId, "Failed to read repository record from PDS.") 883 return 884 } 885 886 _, err = comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 887 Collection: tangled.RepoNSID, 888 Repo: f.Did, 889 Rkey: f.Rkey, 890 SwapRecord: ex.Cid, 891 Record: &lexutil.LexiconTypeDecoder{ 892 Val: &record, 893 }, 894 }) 895 if err != nil { 896 l.Error("failed to update display name on PDS", "err", err) 897 rp.pages.Notice(w, noticeId, "Failed to save display name to PDS.") 898 return 899 } 900 l.Info("updated display name on PDS") 901 902 if err := db.UpdateRepoDisplayName(rp.db, f.Did, f.Rkey, newName); err != nil { 903 l.Error("optimistic display name update failed", "err", err) 904 } 905 } else { 906 ex, getErr := comatproto.RepoGetRecord(r.Context(), atpClient, "", tangled.RepoNSID, f.Did, newRkey) 907 switch { 908 case getErr != nil: 909 _, err = comatproto.RepoCreateRecord(r.Context(), atpClient, &comatproto.RepoCreateRecord_Input{ 910 Collection: tangled.RepoNSID, 911 Repo: f.Did, 912 Rkey: &newRkey, 913 Record: &lexutil.LexiconTypeDecoder{Val: &record}, 914 }) 915 if err != nil { 916 l.Error("failed to write rename to PDS", "err", err) 917 rp.pages.Notice(w, noticeId, "Failed to save renamed repository to PDS.") 918 return 919 } 920 l.Info("wrote rename-create to PDS; old record retained as alias") 921 922 default: 923 existing, ok := ex.Value.Val.(*tangled.Repo) 924 if !ok || existing.RepoDid == nil || *existing.RepoDid != f.RepoDid { 925 rp.pages.Notice(w, noticeId, fmt.Sprintf("You already have a repository named %q.", newRkey)) 926 return 927 } 928 _, err = comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 929 Collection: tangled.RepoNSID, 930 Repo: f.Did, 931 Rkey: newRkey, 932 SwapRecord: ex.Cid, 933 Record: &lexutil.LexiconTypeDecoder{Val: &record}, 934 }) 935 if err != nil { 936 l.Error("failed to rewrite rename-back record on PDS", "err", err) 937 rp.pages.Notice(w, noticeId, "Failed to save renamed repository to PDS.") 938 return 939 } 940 l.Info("rewrote rename-back record on PDS over prior alias") 941 } 942 943 tx, err := rp.db.Begin() 944 if err != nil { 945 l.Error("failed to begin rename tx", "err", err) 946 rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid)) 947 return 948 } 949 defer tx.Rollback() 950 951 if err := db.RenameRepo(tx, f.Did, f.Rkey, newRkey, newName); err != nil { 952 l.Error("optimistic rename failed", "err", err) 953 rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid)) 954 return 955 } 956 if err := db.RecordRepoRename(tx, f.Did, f.Rkey, f.RepoDid); err != nil { 957 l.Error("failed to record rename history", "err", err) 958 } 959 if err := db.DeleteRepoRename(tx, f.Did, newRkey); err != nil { 960 l.Error("failed to clear stale rename hint", "err", err) 961 } 962 if err := tx.Commit(); err != nil { 963 l.Error("failed to commit rename tx", "err", err) 964 rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid)) 965 return 966 } 967 } 968 969 oldRepo := *f 970 rp.notifier.RenameRepo(r.Context(), syntax.DID(user.Did), &oldRepo, &newRepo) 971 972 if newRkey != f.Rkey { 973 rp.migrateSiteOnRename(r.Context(), f, newRkey) 974 } 975 976 rp.pages.HxLocation(w, fmt.Sprintf("/%s", f.RepoDid)) 977} 978 979func validateRenameInput(currentName, currentRkey, raw string) (string, error) { 980 newName := strings.TrimSpace(raw) 981 if newName == "" { 982 return "", errors.New("Repository name cannot be empty.") 983 } 984 if err := models.ValidateRepoName(newName); err != nil { 985 return "", err 986 } 987 newName = models.StripGitExt(newName) 988 if newName == currentName { 989 if _, tidErr := syntax.ParseTID(currentRkey); tidErr == nil { 990 return newName, nil 991 } 992 return "", errors.New("New name matches the current name.") 993 } 994 return newName, nil 995} 996 997func (rp *Repo) migrateSiteOnRename(ctx context.Context, oldRepo *models.Repo, newRkey string) { 998 l := rp.logger.With("handler", "migrateSiteOnRename", "repo_did", oldRepo.RepoDid) 999 1000 siteConfig, err := db.GetRepoSiteConfig(rp.db, oldRepo.RepoDid) 1001 if err != nil || siteConfig == nil { 1002 return 1003 } 1004 1005 if !rp.cfClient.Enabled() { 1006 return 1007 } 1008 1009 ownerClaim, _ := db.GetActiveDomainClaimForDid(rp.db, oldRepo.Did) 1010 1011 go func() { 1012 bgCtx := context.Background() 1013 oldRkey := oldRepo.Rkey 1014 1015 if err := sites.Delete(bgCtx, rp.cfClient, oldRepo.Did, oldRkey); err != nil { 1016 l.Error("sites: failed to delete old R2 prefix", "oldRkey", oldRkey, "err", err) 1017 } 1018 1019 newRepo := *oldRepo 1020 newRepo.Rkey = newRkey 1021 if deployErr := sites.Deploy(bgCtx, rp.cfClient, rp.config, &newRepo, siteConfig.Branch, siteConfig.Dir); deployErr != nil { 1022 l.Error("sites: redeploy after rename failed", "err", deployErr) 1023 } 1024 1025 if ownerClaim != nil { 1026 if err := sites.DeleteDomainMapping(bgCtx, rp.cfClient, ownerClaim.Domain, oldRkey); err != nil { 1027 l.Error("sites: failed to remove old KV mapping", "oldRkey", oldRkey, "err", err) 1028 } 1029 if err := sites.PutDomainMapping(bgCtx, rp.cfClient, ownerClaim.Domain, oldRepo.Did, newRkey, siteConfig.IsIndex); err != nil { 1030 l.Error("sites: failed to write new KV mapping", "newRkey", newRkey, "err", err) 1031 } 1032 } 1033 1034 l.Info("sites: migrated on rename", "oldRkey", oldRkey, "newRkey", newRkey) 1035 }() 1036} 1037 1038func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 1039 user := rp.oauth.GetMultiAccountUser(r) 1040 l := rp.logger.With("handler", "DeleteRepo") 1041 1042 noticeId := "operation-error" 1043 f, err := rp.repoResolver.Resolve(r) 1044 if err != nil { 1045 l.Error("failed to get repo and knot", "err", err) 1046 return 1047 } 1048 1049 // remove record from pds 1050 atpClient, err := rp.oauth.AuthorizedClient(r) 1051 if err != nil { 1052 l.Error("failed to get authorized client", "err", err) 1053 return 1054 } 1055 _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1056 Collection: tangled.RepoNSID, 1057 Repo: user.Did, 1058 Rkey: f.Rkey, 1059 }) 1060 if err != nil { 1061 l.Error("failed to delete record", "err", err) 1062 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 1063 return 1064 } 1065 l.Info("removed repo record", "aturi", f.RepoAt().String()) 1066 1067 client, err := rp.oauth.ServiceClient( 1068 r, 1069 oauth.WithService(f.Knot), 1070 oauth.WithLxm(tangled.RepoDeleteNSID), 1071 oauth.WithDev(rp.config.Core.Dev), 1072 ) 1073 if err != nil { 1074 l.Error("failed to connect to knot server", "err", err) 1075 return 1076 } 1077 1078 err = tangled.RepoDelete( 1079 r.Context(), 1080 client, 1081 &tangled.RepoDelete_Input{ 1082 Did: f.Did, 1083 Name: f.Name, 1084 Rkey: f.Rkey, 1085 }, 1086 ) 1087 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1088 l.Error("failed to call XRPC repo.delete", "xrpcerr", xrpcerr, "err", err) 1089 rp.pages.Notice(w, noticeId, xrpcerr.Error()) 1090 return 1091 } 1092 l.Info("deleted repo from knot") 1093 1094 tx, err := rp.db.BeginTx(r.Context(), nil) 1095 if err != nil { 1096 l.Error("failed to start tx") 1097 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1098 return 1099 } 1100 defer func() { 1101 tx.Rollback() 1102 err = rp.enforcer.E.LoadPolicy() 1103 if err != nil { 1104 l.Error("failed to rollback policies") 1105 } 1106 }() 1107 1108 // remove collaborator RBAC 1109 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.RepoIdentifier(), f.Knot) 1110 if err != nil { 1111 rp.pages.Notice(w, noticeId, "Failed to remove collaborators") 1112 return 1113 } 1114 for _, c := range repoCollaborators { 1115 did := c[0] 1116 rp.enforcer.RemoveCollaborator(did, f.Knot, f.RepoIdentifier()) 1117 } 1118 l.Info("removed collaborators") 1119 1120 // remove repo RBAC 1121 err = rp.enforcer.RemoveRepo(f.Did, f.Knot, f.RepoIdentifier()) 1122 if err != nil { 1123 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 1124 return 1125 } 1126 1127 // remove repo from db 1128 err = db.RemoveRepo(tx, f.Did, f.Rkey) 1129 if err != nil { 1130 rp.pages.Notice(w, noticeId, "Failed to update appview") 1131 return 1132 } 1133 l.Info("removed repo from db") 1134 1135 err = tx.Commit() 1136 if err != nil { 1137 l.Error("failed to commit changes", "err", err) 1138 http.Error(w, err.Error(), http.StatusInternalServerError) 1139 return 1140 } 1141 1142 err = rp.enforcer.E.SavePolicy() 1143 if err != nil { 1144 l.Error("failed to update ACLs", "err", err) 1145 http.Error(w, err.Error(), http.StatusInternalServerError) 1146 return 1147 } 1148 1149 rp.notifier.DeleteRepo(r.Context(), f) 1150 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.Did)) 1151} 1152 1153func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1154 l := rp.logger.With("handler", "SyncRepoFork") 1155 1156 ref := chi.URLParam(r, "ref") 1157 ref, _ = url.PathUnescape(ref) 1158 1159 user := rp.oauth.GetMultiAccountUser(r) 1160 f, err := rp.repoResolver.Resolve(r) 1161 if err != nil { 1162 l.Error("failed to resolve source repo", "err", err) 1163 return 1164 } 1165 1166 switch r.Method { 1167 case http.MethodPost: 1168 client, err := rp.oauth.ServiceClient( 1169 r, 1170 oauth.WithService(f.Knot), 1171 oauth.WithLxm(tangled.RepoForkSyncNSID), 1172 oauth.WithDev(rp.config.Core.Dev), 1173 ) 1174 if err != nil { 1175 rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1176 return 1177 } 1178 1179 if f.Source == "" { 1180 rp.pages.Notice(w, "repo", "This repository is not a fork.") 1181 return 1182 } 1183 1184 err = tangled.RepoForkSync( 1185 r.Context(), 1186 client, 1187 &tangled.RepoForkSync_Input{ 1188 Did: user.Did, 1189 Name: f.Name, 1190 Source: f.Source, 1191 Branch: ref, 1192 }, 1193 ) 1194 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1195 l.Error("failed to call XRPC repo.forkSync", "xrpcerr", xrpcerr, "err", err) 1196 rp.pages.Notice(w, "repo", err.Error()) 1197 return 1198 } 1199 1200 rp.pages.HxRefresh(w) 1201 return 1202 } 1203} 1204 1205func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 1206 l := rp.logger.With("handler", "ForkRepo") 1207 1208 user := rp.oauth.GetMultiAccountUser(r) 1209 f, err := rp.repoResolver.Resolve(r) 1210 if err != nil { 1211 l.Error("failed to resolve source repo", "err", err) 1212 return 1213 } 1214 1215 switch r.Method { 1216 case http.MethodGet: 1217 user := rp.oauth.GetMultiAccountUser(r) 1218 knots, err := rp.enforcer.GetKnotsForUser(user.Did) 1219 if err != nil { 1220 rp.pages.Notice(w, "repo", "Invalid user account.") 1221 return 1222 } 1223 1224 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1225 LoggedInUser: user, 1226 Knots: knots, 1227 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1228 }) 1229 1230 case http.MethodPost: 1231 l := rp.logger.With("handler", "ForkRepo") 1232 1233 targetKnot := r.FormValue("knot") 1234 if targetKnot == "" { 1235 rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1236 return 1237 } 1238 l = l.With("targetKnot", targetKnot) 1239 1240 ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1241 if err != nil || !ok { 1242 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1243 return 1244 } 1245 1246 // choose a name for a fork 1247 forkName := strings.ToLower(r.FormValue("repo_name")) 1248 if forkName == "" { 1249 rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 1250 return 1251 } 1252 1253 // this check is *only* to see if the forked repo name already exists 1254 // in the user's account. 1255 existingRepo, err := db.GetRepo( 1256 rp.db, 1257 orm.FilterEq("did", user.Did), 1258 orm.FilterEq("name", forkName), 1259 ) 1260 if err != nil { 1261 if !errors.Is(err, sql.ErrNoRows) { 1262 l.Error("error fetching existing repo from db", "err", err) 1263 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1264 return 1265 } 1266 } else if existingRepo != nil { 1267 // repo with this name already exists 1268 rp.pages.Notice(w, "repo", "A repository with this name already exists.") 1269 return 1270 } 1271 l = l.With("forkName", forkName) 1272 1273 uri := "https" 1274 if rp.config.Core.Dev { 1275 uri = "http" 1276 } 1277 1278 forkSourceUrl := fmt.Sprintf("%s://%s/%s", uri, f.Knot, f.RepoIdentifier()) 1279 l = l.With("cloneUrl", forkSourceUrl) 1280 1281 rkey := strings.ToLower(forkName) 1282 1283 // TODO: this could coordinate better with the knot to receive a clone status 1284 client, err := rp.oauth.ServiceClient( 1285 r, 1286 oauth.WithService(targetKnot), 1287 oauth.WithLxm(tangled.RepoCreateNSID), 1288 oauth.WithDev(rp.config.Core.Dev), 1289 oauth.WithTimeout(time.Second*20), 1290 ) 1291 if err != nil { 1292 l.Error("could not create service client", "err", err) 1293 rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1294 return 1295 } 1296 1297 forkInput := &tangled.RepoCreate_Input{ 1298 Rkey: rkey, 1299 Name: rkey, 1300 Source: &forkSourceUrl, 1301 } 1302 createResp, err := tangled.RepoCreate( 1303 r.Context(), 1304 client, 1305 forkInput, 1306 ) 1307 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1308 l.Error("failed to call XRPC repo.create", "xrpcerr", xrpcerr, "err", err) 1309 rp.pages.Notice(w, "repo", xrpcerr.Error()) 1310 return 1311 } 1312 1313 var repoDid string 1314 if createResp != nil && createResp.RepoDid != nil { 1315 repoDid = *createResp.RepoDid 1316 } 1317 if repoDid == "" { 1318 l.Error("knot returned empty repo DID for fork") 1319 rp.pages.Notice(w, "repo", "Knot failed to mint a repo DID. The knot may need to be upgraded.") 1320 return 1321 } 1322 1323 forkSource := f.RepoAt().String() 1324 if f.RepoDid != "" { 1325 forkSource = f.RepoDid 1326 } 1327 1328 repo := &models.Repo{ 1329 Did: user.Did, 1330 Name: rkey, 1331 Knot: targetKnot, 1332 Rkey: rkey, 1333 Source: forkSource, 1334 Description: f.Description, 1335 Created: time.Now(), 1336 Labels: rp.config.Label.DefaultLabelDefs, 1337 RepoDid: repoDid, 1338 } 1339 record := repo.AsRecord() 1340 1341 cleanupKnot := func() { 1342 go func() { 1343 delays := []time.Duration{0, 2 * time.Second, 5 * time.Second} 1344 for attempt, delay := range delays { 1345 time.Sleep(delay) 1346 deleteClient, dErr := rp.oauth.ServiceClient( 1347 r, 1348 oauth.WithService(targetKnot), 1349 oauth.WithLxm(tangled.RepoDeleteNSID), 1350 oauth.WithDev(rp.config.Core.Dev), 1351 ) 1352 if dErr != nil { 1353 l.Error("failed to create delete client for knot cleanup", "attempt", attempt+1, "err", dErr) 1354 continue 1355 } 1356 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 1357 if dErr := tangled.RepoDelete(ctx, deleteClient, &tangled.RepoDelete_Input{ 1358 Did: user.Did, 1359 Name: forkName, 1360 Rkey: rkey, 1361 }); dErr != nil { 1362 cancel() 1363 l.Error("failed to clean up fork on knot after rollback", "attempt", attempt+1, "err", dErr) 1364 continue 1365 } 1366 cancel() 1367 l.Info("successfully cleaned up fork on knot after rollback", "attempt", attempt+1) 1368 return 1369 } 1370 l.Error("exhausted retries for knot cleanup, fork may be orphaned", 1371 "did", user.Did, "fork", forkName, "knot", targetKnot) 1372 }() 1373 } 1374 1375 atpClient, err := rp.oauth.AuthorizedClient(r) 1376 if err != nil { 1377 l.Error("failed to create xrpcclient", "err", err) 1378 cleanupKnot() 1379 rp.pages.Notice(w, "repo", "Failed to fork repository.") 1380 return 1381 } 1382 1383 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 1384 Collection: tangled.RepoNSID, 1385 Repo: user.Did, 1386 Rkey: rkey, 1387 Record: &lexutil.LexiconTypeDecoder{ 1388 Val: &record, 1389 }, 1390 }) 1391 if err != nil { 1392 l.Error("failed to write to PDS", "err", err) 1393 cleanupKnot() 1394 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1395 return 1396 } 1397 1398 aturi := atresp.Uri 1399 l = l.With("aturi", aturi) 1400 l.Info("wrote to PDS") 1401 1402 tx, err := rp.db.BeginTx(r.Context(), nil) 1403 if err != nil { 1404 l.Info("txn failed", "err", err) 1405 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1406 return 1407 } 1408 1409 rollback := func() { 1410 err1 := tx.Rollback() 1411 err2 := rp.enforcer.E.LoadPolicy() 1412 err3 := rollbackRecord(context.Background(), aturi, atpClient) 1413 1414 if errors.Is(err1, sql.ErrTxDone) { 1415 err1 = nil 1416 } 1417 1418 if errs := errors.Join(err1, err2, err3); errs != nil { 1419 l.Error("failed to rollback changes", "errs", errs) 1420 } 1421 1422 if aturi != "" { 1423 cleanupKnot() 1424 } 1425 } 1426 defer rollback() 1427 1428 err = db.AddRepo(tx, repo) 1429 if err != nil { 1430 l.Error("failed to AddRepo", "err", err) 1431 rp.pages.Notice(w, "repo", "Failed to save repository information.") 1432 return 1433 } 1434 1435 rbacPath := repo.RepoIdentifier() 1436 err = rp.enforcer.AddRepo(user.Did, targetKnot, rbacPath) 1437 if err != nil { 1438 l.Error("failed to add ACLs", "err", err) 1439 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1440 return 1441 } 1442 1443 err = tx.Commit() 1444 if err != nil { 1445 l.Error("failed to commit changes", "err", err) 1446 http.Error(w, err.Error(), http.StatusInternalServerError) 1447 return 1448 } 1449 1450 err = rp.enforcer.E.SavePolicy() 1451 if err != nil { 1452 l.Error("failed to update ACLs", "err", err) 1453 http.Error(w, err.Error(), http.StatusInternalServerError) 1454 return 1455 } 1456 1457 aturi = "" 1458 1459 rp.notifier.NewRepo(r.Context(), repo) 1460 if repoDid != "" { 1461 rp.pages.HxLocation(w, fmt.Sprintf("/%s", repoDid)) 1462 } else { 1463 rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName)) 1464 } 1465 } 1466} 1467 1468func (rp *Repo) Stars(w http.ResponseWriter, r *http.Request) { 1469 l := rp.logger.With("handler", "Stars") 1470 1471 user := rp.oauth.GetMultiAccountUser(r) 1472 f, err := rp.repoResolver.Resolve(r) 1473 if err != nil { 1474 l.Error("failed to resolve source repo", "err", err) 1475 return 1476 } 1477 1478 page := pagination.FromContext(r.Context()) 1479 if page.Limit > 30 || page.Limit <= 0 { 1480 page.Limit = 30 1481 } 1482 1483 starrers, err := db.GetStars(rp.db, string(f.RepoDid), page) 1484 if err != nil { 1485 l.Error("failed to fetch starrers", "err", err, "repoDid", f.RepoDid) 1486 return 1487 } 1488 1489 totalCount, err := db.GetStarCount(rp.db, models.StarSubjectRepo, string(f.RepoDid)) 1490 if err != nil { 1491 l.Error("failed to fetch star count", "err", err, "repoDid", f.RepoDid) 1492 return 1493 } 1494 1495 rp.pages.RepoStars(w, pages.RepoStarsParams{ 1496 LoggedInUser: user, 1497 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1498 Starrers: starrers, 1499 Page: page, 1500 TotalCount: totalCount, 1501 }) 1502} 1503 1504func (rp *Repo) Forks(w http.ResponseWriter, r *http.Request) { 1505 l := rp.logger.With("handler", "Forks") 1506 1507 user := rp.oauth.GetMultiAccountUser(r) 1508 f, err := rp.repoResolver.Resolve(r) 1509 if err != nil { 1510 l.Error("failed to resolve source repo", "err", err) 1511 return 1512 } 1513 1514 var forks []models.Repo 1515 totalCount := 0 1516 page := pagination.FromContext(r.Context()) 1517 if f.RepoDid != "" { 1518 forks, err = db.GetReposPaginated(rp.db, page, orm.FilterEq("source", f.RepoDid)) 1519 if err != nil { 1520 l.Error("failed to fetch forks", "err", err, "repoAt", f.RepoAt()) 1521 return 1522 } 1523 1524 totalCount, err = db.GetForkCount(rp.db, f.RepoDid) 1525 if err != nil { 1526 l.Error("failed to fetch fork count", "err", err, "repoAt", f.RepoAt()) 1527 return 1528 } 1529 } 1530 1531 err = rp.pages.RepoForks(w, pages.RepoForksParams{ 1532 LoggedInUser: user, 1533 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1534 Forks: forks, 1535 Page: page, 1536 TotalCount: totalCount, 1537 }) 1538 if err != nil { 1539 l.Error("failed to render page", "err", err) 1540 } 1541} 1542 1543// this is used to rollback changes made to the PDS 1544// 1545// it is a no-op if the provided ATURI is empty 1546func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 1547 if aturi == "" { 1548 return nil 1549 } 1550 1551 parsed := syntax.ATURI(aturi) 1552 1553 collection := parsed.Collection().String() 1554 repo := parsed.Authority().String() 1555 rkey := parsed.RecordKey().String() 1556 1557 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 1558 Collection: collection, 1559 Repo: repo, 1560 Rkey: rkey, 1561 }) 1562 return err 1563} 1564 1565func repoCollaboratorRecord(f *models.Repo, subject string, createdAt time.Time) *tangled.RepoCollaborator { 1566 return &tangled.RepoCollaborator{ 1567 Subject: subject, 1568 CreatedAt: createdAt.Format(time.RFC3339), 1569 Repo: f.RepoDid, 1570 } 1571}