This repository has no description
0

Configure Feed

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

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