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