forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
at master 18 kB view raw
1package spindles 2 3import ( 4 "errors" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "slices" 9 "strings" 10 "time" 11 12 "github.com/go-chi/chi/v5" 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview/config" 15 "tangled.org/core/appview/db" 16 "tangled.org/core/appview/middleware" 17 "tangled.org/core/appview/models" 18 "tangled.org/core/appview/oauth" 19 "tangled.org/core/appview/pages" 20 "tangled.org/core/appview/serververify" 21 "tangled.org/core/appview/xrpcclient" 22 "tangled.org/core/idresolver" 23 "tangled.org/core/orm" 24 "tangled.org/core/rbac" 25 "tangled.org/core/tid" 26 27 comatproto "github.com/bluesky-social/indigo/api/atproto" 28 "github.com/bluesky-social/indigo/atproto/syntax" 29 lexutil "github.com/bluesky-social/indigo/lex/util" 30) 31 32type Spindles struct { 33 Db *db.DB 34 OAuth *oauth.OAuth 35 Pages *pages.Pages 36 Config *config.Config 37 Enforcer *rbac.Enforcer 38 IdResolver *idresolver.Resolver 39 Logger *slog.Logger 40} 41 42type tab = map[string]any 43 44var ( 45 spindlesTabs []tab = []tab{ 46 {"Name": "profile", "Icon": "user"}, 47 {"Name": "keys", "Icon": "key"}, 48 {"Name": "emails", "Icon": "mail"}, 49 {"Name": "notifications", "Icon": "bell"}, 50 {"Name": "knots", "Icon": "volleyball"}, 51 {"Name": "spindles", "Icon": "spool"}, 52 } 53) 54 55func (s *Spindles) Router() http.Handler { 56 r := chi.NewRouter() 57 58 r.With(middleware.AuthMiddleware(s.OAuth)).Get("/", s.spindles) 59 r.With(middleware.AuthMiddleware(s.OAuth)).Post("/register", s.register) 60 61 r.With(middleware.AuthMiddleware(s.OAuth)).Get("/{instance}", s.dashboard) 62 r.With(middleware.AuthMiddleware(s.OAuth)).Delete("/{instance}", s.delete) 63 64 r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/retry", s.retry) 65 r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/add", s.addMember) 66 r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/remove", s.removeMember) 67 68 return r 69} 70 71func (s *Spindles) spindles(w http.ResponseWriter, r *http.Request) { 72 user := s.OAuth.GetUser(r) 73 all, err := db.GetSpindles( 74 s.Db, 75 orm.FilterEq("owner", user.Did), 76 ) 77 if err != nil { 78 s.Logger.Error("failed to fetch spindles", "err", err) 79 w.WriteHeader(http.StatusInternalServerError) 80 return 81 } 82 83 s.Pages.Spindles(w, pages.SpindlesParams{ 84 LoggedInUser: user, 85 Spindles: all, 86 Tabs: spindlesTabs, 87 Tab: "spindles", 88 }) 89} 90 91func (s *Spindles) dashboard(w http.ResponseWriter, r *http.Request) { 92 l := s.Logger.With("handler", "dashboard") 93 94 user := s.OAuth.GetUser(r) 95 l = l.With("user", user.Did) 96 97 instance := chi.URLParam(r, "instance") 98 if instance == "" { 99 return 100 } 101 l = l.With("instance", instance) 102 103 spindles, err := db.GetSpindles( 104 s.Db, 105 orm.FilterEq("instance", instance), 106 orm.FilterEq("owner", user.Did), 107 orm.FilterIsNot("verified", "null"), 108 ) 109 if err != nil || len(spindles) != 1 { 110 l.Error("failed to get spindle", "err", err, "len(spindles)", len(spindles)) 111 http.Error(w, "Not found", http.StatusNotFound) 112 return 113 } 114 115 spindle := spindles[0] 116 members, err := s.Enforcer.GetSpindleUsersByRole("server:member", spindle.Instance) 117 if err != nil { 118 l.Error("failed to get spindle members", "err", err) 119 http.Error(w, "Not found", http.StatusInternalServerError) 120 return 121 } 122 slices.Sort(members) 123 124 repos, err := db.GetRepos( 125 s.Db, 126 0, 127 orm.FilterEq("spindle", instance), 128 ) 129 if err != nil { 130 l.Error("failed to get spindle repos", "err", err) 131 http.Error(w, "Not found", http.StatusInternalServerError) 132 return 133 } 134 135 // organize repos by did 136 repoMap := make(map[string][]models.Repo) 137 for _, r := range repos { 138 repoMap[r.Did] = append(repoMap[r.Did], r) 139 } 140 141 s.Pages.SpindleDashboard(w, pages.SpindleDashboardParams{ 142 LoggedInUser: user, 143 Spindle: spindle, 144 Members: members, 145 Repos: repoMap, 146 Tabs: spindlesTabs, 147 Tab: "spindles", 148 }) 149} 150 151// this endpoint inserts a record on behalf of the user to register that domain 152// 153// when registered, it also makes a request to see if the spindle declares this users as its owner, 154// and if so, marks the spindle as verified. 155// 156// if the spindle is not up yet, the user is free to retry verification at a later point 157func (s *Spindles) register(w http.ResponseWriter, r *http.Request) { 158 user := s.OAuth.GetUser(r) 159 l := s.Logger.With("handler", "register") 160 161 noticeId := "register-error" 162 defaultErr := "Failed to register spindle. Try again later." 163 fail := func() { 164 s.Pages.Notice(w, noticeId, defaultErr) 165 } 166 167 instance := r.FormValue("instance") 168 // Strip protocol, trailing slashes, and whitespace 169 // Rkey cannot contain slashes 170 instance = strings.TrimSpace(instance) 171 instance = strings.TrimPrefix(instance, "https://") 172 instance = strings.TrimPrefix(instance, "http://") 173 instance = strings.TrimSuffix(instance, "/") 174 if instance == "" { 175 s.Pages.Notice(w, noticeId, "Incomplete form.") 176 return 177 } 178 l = l.With("instance", instance) 179 l = l.With("user", user.Did) 180 181 tx, err := s.Db.Begin() 182 if err != nil { 183 l.Error("failed to start transaction", "err", err) 184 fail() 185 return 186 } 187 defer func() { 188 tx.Rollback() 189 s.Enforcer.E.LoadPolicy() 190 }() 191 192 err = db.AddSpindle(tx, models.Spindle{ 193 Owner: syntax.DID(user.Did), 194 Instance: instance, 195 }) 196 if err != nil { 197 l.Error("failed to insert", "err", err) 198 fail() 199 return 200 } 201 202 err = s.Enforcer.AddSpindle(instance) 203 if err != nil { 204 l.Error("failed to create spindle", "err", err) 205 fail() 206 return 207 } 208 209 // create record on pds 210 client, err := s.OAuth.AuthorizedClient(r) 211 if err != nil { 212 l.Error("failed to authorize client", "err", err) 213 fail() 214 return 215 } 216 217 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 218 var exCid *string 219 if ex != nil { 220 exCid = ex.Cid 221 } 222 223 // re-announce by registering under same rkey 224 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 225 Collection: tangled.SpindleNSID, 226 Repo: user.Did, 227 Rkey: instance, 228 Record: &lexutil.LexiconTypeDecoder{ 229 Val: &tangled.Spindle{ 230 CreatedAt: time.Now().Format(time.RFC3339), 231 }, 232 }, 233 SwapRecord: exCid, 234 }) 235 236 if err != nil { 237 l.Error("failed to put record", "err", err) 238 fail() 239 return 240 } 241 242 err = tx.Commit() 243 if err != nil { 244 l.Error("failed to commit transaction", "err", err) 245 fail() 246 return 247 } 248 249 err = s.Enforcer.E.SavePolicy() 250 if err != nil { 251 l.Error("failed to update ACL", "err", err) 252 s.Pages.HxRefresh(w) 253 return 254 } 255 256 // begin verification 257 err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 258 if err != nil { 259 l.Error("verification failed", "err", err) 260 s.Pages.HxRefresh(w) 261 return 262 } 263 264 _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 265 if err != nil { 266 l.Error("failed to mark verified", "err", err) 267 s.Pages.HxRefresh(w) 268 return 269 } 270 271 // ok 272 s.Pages.HxRefresh(w) 273} 274 275func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { 276 user := s.OAuth.GetUser(r) 277 l := s.Logger.With("handler", "delete") 278 279 noticeId := "operation-error" 280 defaultErr := "Failed to delete spindle. Try again later." 281 fail := func() { 282 s.Pages.Notice(w, noticeId, defaultErr) 283 } 284 285 instance := chi.URLParam(r, "instance") 286 if instance == "" { 287 l.Error("empty instance") 288 fail() 289 return 290 } 291 292 spindles, err := db.GetSpindles( 293 s.Db, 294 orm.FilterEq("owner", user.Did), 295 orm.FilterEq("instance", instance), 296 ) 297 if err != nil || len(spindles) != 1 { 298 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 299 fail() 300 return 301 } 302 303 if string(spindles[0].Owner) != user.Did { 304 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 305 s.Pages.Notice(w, noticeId, "Failed to delete spindle, unauthorized deletion attempt.") 306 return 307 } 308 309 tx, err := s.Db.Begin() 310 if err != nil { 311 l.Error("failed to start txn", "err", err) 312 fail() 313 return 314 } 315 defer func() { 316 tx.Rollback() 317 s.Enforcer.E.LoadPolicy() 318 }() 319 320 // remove spindle members first 321 err = db.RemoveSpindleMember( 322 tx, 323 orm.FilterEq("did", user.Did), 324 orm.FilterEq("instance", instance), 325 ) 326 if err != nil { 327 l.Error("failed to remove spindle members", "err", err) 328 fail() 329 return 330 } 331 332 err = db.DeleteSpindle( 333 tx, 334 orm.FilterEq("owner", user.Did), 335 orm.FilterEq("instance", instance), 336 ) 337 if err != nil { 338 l.Error("failed to delete spindle", "err", err) 339 fail() 340 return 341 } 342 343 // delete from enforcer 344 if spindles[0].Verified != nil { 345 err = s.Enforcer.RemoveSpindle(instance) 346 if err != nil { 347 l.Error("failed to update ACL", "err", err) 348 fail() 349 return 350 } 351 } 352 353 client, err := s.OAuth.AuthorizedClient(r) 354 if err != nil { 355 l.Error("failed to authorize client", "err", err) 356 fail() 357 return 358 } 359 360 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 361 Collection: tangled.SpindleNSID, 362 Repo: user.Did, 363 Rkey: instance, 364 }) 365 if err != nil { 366 // non-fatal 367 l.Error("failed to delete record", "err", err) 368 } 369 370 err = tx.Commit() 371 if err != nil { 372 l.Error("failed to delete spindle", "err", err) 373 fail() 374 return 375 } 376 377 err = s.Enforcer.E.SavePolicy() 378 if err != nil { 379 l.Error("failed to update ACL", "err", err) 380 s.Pages.HxRefresh(w) 381 return 382 } 383 384 shouldRedirect := r.Header.Get("shouldRedirect") 385 if shouldRedirect == "true" { 386 s.Pages.HxRedirect(w, "/settings/spindles") 387 return 388 } 389 390 w.Write([]byte{}) 391} 392 393func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) { 394 user := s.OAuth.GetUser(r) 395 l := s.Logger.With("handler", "retry") 396 397 noticeId := "operation-error" 398 defaultErr := "Failed to verify spindle. Try again later." 399 fail := func() { 400 s.Pages.Notice(w, noticeId, defaultErr) 401 } 402 403 instance := chi.URLParam(r, "instance") 404 if instance == "" { 405 l.Error("empty instance") 406 fail() 407 return 408 } 409 l = l.With("instance", instance) 410 l = l.With("user", user.Did) 411 412 spindles, err := db.GetSpindles( 413 s.Db, 414 orm.FilterEq("owner", user.Did), 415 orm.FilterEq("instance", instance), 416 ) 417 if err != nil || len(spindles) != 1 { 418 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 419 fail() 420 return 421 } 422 423 if string(spindles[0].Owner) != user.Did { 424 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 425 s.Pages.Notice(w, noticeId, "Failed to verify spindle, unauthorized verification attempt.") 426 return 427 } 428 429 // begin verification 430 err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 431 if err != nil { 432 l.Error("verification failed", "err", err) 433 434 if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 435 s.Pages.Notice(w, noticeId, "Failed to verify spindle, XRPC queries are unsupported on this spindle, consider upgrading!") 436 return 437 } 438 439 if e, ok := err.(*serververify.OwnerMismatch); ok { 440 s.Pages.Notice(w, noticeId, e.Error()) 441 return 442 } 443 444 fail() 445 return 446 } 447 448 rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 449 if err != nil { 450 l.Error("failed to mark verified", "err", err) 451 s.Pages.Notice(w, noticeId, err.Error()) 452 return 453 } 454 455 verifiedSpindle, err := db.GetSpindles( 456 s.Db, 457 orm.FilterEq("id", rowId), 458 ) 459 if err != nil || len(verifiedSpindle) != 1 { 460 l.Error("failed get new spindle", "err", err) 461 s.Pages.HxRefresh(w) 462 return 463 } 464 465 shouldRefresh := r.Header.Get("shouldRefresh") 466 if shouldRefresh == "true" { 467 s.Pages.HxRefresh(w) 468 return 469 } 470 471 w.Header().Set("HX-Reswap", "outerHTML") 472 s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]}) 473} 474 475func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) { 476 user := s.OAuth.GetUser(r) 477 l := s.Logger.With("handler", "addMember") 478 479 instance := chi.URLParam(r, "instance") 480 if instance == "" { 481 l.Error("empty instance") 482 http.Error(w, "Not found", http.StatusNotFound) 483 return 484 } 485 l = l.With("instance", instance) 486 l = l.With("user", user.Did) 487 488 spindles, err := db.GetSpindles( 489 s.Db, 490 orm.FilterEq("owner", user.Did), 491 orm.FilterEq("instance", instance), 492 ) 493 if err != nil || len(spindles) != 1 { 494 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 495 http.Error(w, "Not found", http.StatusNotFound) 496 return 497 } 498 499 noticeId := fmt.Sprintf("add-member-error-%d", spindles[0].Id) 500 defaultErr := "Failed to add member. Try again later." 501 fail := func() { 502 s.Pages.Notice(w, noticeId, defaultErr) 503 } 504 505 if string(spindles[0].Owner) != user.Did { 506 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 507 s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 508 return 509 } 510 511 member := r.FormValue("member") 512 member = strings.TrimPrefix(member, "@") 513 if member == "" { 514 l.Error("empty member") 515 s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 516 return 517 } 518 l = l.With("member", member) 519 520 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 521 if err != nil { 522 l.Error("failed to resolve member identity to handle", "err", err) 523 s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 524 return 525 } 526 if memberId.Handle.IsInvalidHandle() { 527 l.Error("failed to resolve member identity to handle") 528 s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 529 return 530 } 531 532 // write to pds 533 client, err := s.OAuth.AuthorizedClient(r) 534 if err != nil { 535 l.Error("failed to authorize client", "err", err) 536 fail() 537 return 538 } 539 540 tx, err := s.Db.Begin() 541 if err != nil { 542 l.Error("failed to start txn", "err", err) 543 fail() 544 return 545 } 546 defer func() { 547 tx.Rollback() 548 s.Enforcer.E.LoadPolicy() 549 }() 550 551 rkey := tid.TID() 552 553 // add member to db 554 if err = db.AddSpindleMember(tx, models.SpindleMember{ 555 Did: syntax.DID(user.Did), 556 Rkey: rkey, 557 Instance: instance, 558 Subject: memberId.DID, 559 }); err != nil { 560 l.Error("failed to add spindle member", "err", err) 561 fail() 562 return 563 } 564 565 if err = s.Enforcer.AddSpindleMember(instance, memberId.DID.String()); err != nil { 566 l.Error("failed to add member to ACLs") 567 fail() 568 return 569 } 570 571 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 572 Collection: tangled.SpindleMemberNSID, 573 Repo: user.Did, 574 Rkey: rkey, 575 Record: &lexutil.LexiconTypeDecoder{ 576 Val: &tangled.SpindleMember{ 577 CreatedAt: time.Now().Format(time.RFC3339), 578 Instance: instance, 579 Subject: memberId.DID.String(), 580 }, 581 }, 582 }) 583 if err != nil { 584 l.Error("failed to add record to PDS", "err", err) 585 s.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 586 return 587 } 588 589 if err = tx.Commit(); err != nil { 590 l.Error("failed to commit txn", "err", err) 591 fail() 592 return 593 } 594 595 if err = s.Enforcer.E.SavePolicy(); err != nil { 596 l.Error("failed to add member to ACLs", "err", err) 597 fail() 598 return 599 } 600 601 // success 602 s.Pages.HxRedirect(w, fmt.Sprintf("/settings/spindles/%s", instance)) 603} 604 605func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { 606 user := s.OAuth.GetUser(r) 607 l := s.Logger.With("handler", "removeMember") 608 609 noticeId := "operation-error" 610 defaultErr := "Failed to remove member. Try again later." 611 fail := func() { 612 s.Pages.Notice(w, noticeId, defaultErr) 613 } 614 615 instance := chi.URLParam(r, "instance") 616 if instance == "" { 617 l.Error("empty instance") 618 fail() 619 return 620 } 621 l = l.With("instance", instance) 622 l = l.With("user", user.Did) 623 624 spindles, err := db.GetSpindles( 625 s.Db, 626 orm.FilterEq("owner", user.Did), 627 orm.FilterEq("instance", instance), 628 ) 629 if err != nil || len(spindles) != 1 { 630 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 631 fail() 632 return 633 } 634 635 if string(spindles[0].Owner) != user.Did { 636 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 637 s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 638 return 639 } 640 641 member := r.FormValue("member") 642 member = strings.TrimPrefix(member, "@") 643 if member == "" { 644 l.Error("empty member") 645 s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 646 return 647 } 648 l = l.With("member", member) 649 650 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 651 if err != nil { 652 l.Error("failed to resolve member identity to handle", "err", err) 653 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 654 return 655 } 656 if memberId.Handle.IsInvalidHandle() { 657 l.Error("failed to resolve member identity to handle") 658 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 659 return 660 } 661 662 tx, err := s.Db.Begin() 663 if err != nil { 664 l.Error("failed to start txn", "err", err) 665 fail() 666 return 667 } 668 defer func() { 669 tx.Rollback() 670 s.Enforcer.E.LoadPolicy() 671 }() 672 673 // get the record from the DB first: 674 members, err := db.GetSpindleMembers( 675 s.Db, 676 orm.FilterEq("did", user.Did), 677 orm.FilterEq("instance", instance), 678 orm.FilterEq("subject", memberId.DID), 679 ) 680 if err != nil || len(members) != 1 { 681 l.Error("failed to get member", "err", err) 682 fail() 683 return 684 } 685 686 // remove from db 687 if err = db.RemoveSpindleMember( 688 tx, 689 orm.FilterEq("did", user.Did), 690 orm.FilterEq("instance", instance), 691 orm.FilterEq("subject", memberId.DID), 692 ); err != nil { 693 l.Error("failed to remove spindle member", "err", err) 694 fail() 695 return 696 } 697 698 // remove from enforcer 699 if err = s.Enforcer.RemoveSpindleMember(instance, memberId.DID.String()); err != nil { 700 l.Error("failed to update ACLs", "err", err) 701 fail() 702 return 703 } 704 705 client, err := s.OAuth.AuthorizedClient(r) 706 if err != nil { 707 l.Error("failed to authorize client", "err", err) 708 fail() 709 return 710 } 711 712 // remove from pds 713 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 714 Collection: tangled.SpindleMemberNSID, 715 Repo: user.Did, 716 Rkey: members[0].Rkey, 717 }) 718 if err != nil { 719 // non-fatal 720 l.Error("failed to delete record", "err", err) 721 } 722 723 // commit everything 724 if err = tx.Commit(); err != nil { 725 l.Error("failed to commit txn", "err", err) 726 fail() 727 return 728 } 729 730 // commit everything 731 if err = s.Enforcer.E.SavePolicy(); err != nil { 732 l.Error("failed to save ACLs", "err", err) 733 fail() 734 return 735 } 736 737 // ok 738 s.Pages.HxRefresh(w) 739}