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