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