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