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