forked from tangled.org/core
this repo has no description
1package state 2 3import ( 4 "context" 5 "crypto/hmac" 6 "crypto/sha256" 7 "encoding/hex" 8 "fmt" 9 "log" 10 "log/slog" 11 "net/http" 12 "strings" 13 "time" 14 15 comatproto "github.com/bluesky-social/indigo/api/atproto" 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 securejoin "github.com/cyphar/filepath-securejoin" 19 "github.com/go-chi/chi/v5" 20 tangled "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview" 22 "tangled.sh/tangled.sh/core/appview/auth" 23 "tangled.sh/tangled.sh/core/appview/db" 24 "tangled.sh/tangled.sh/core/appview/pages" 25 "tangled.sh/tangled.sh/core/jetstream" 26 "tangled.sh/tangled.sh/core/rbac" 27) 28 29type State struct { 30 db *db.DB 31 auth *auth.Auth 32 enforcer *rbac.Enforcer 33 tidClock *syntax.TIDClock 34 pages *pages.Pages 35 resolver *appview.Resolver 36 jc *jetstream.JetstreamClient 37 config *appview.Config 38} 39 40func Make(config *appview.Config) (*State, error) { 41 d, err := db.Make(config.DbPath) 42 if err != nil { 43 return nil, err 44 } 45 46 auth, err := auth.Make(config.CookieSecret) 47 if err != nil { 48 return nil, err 49 } 50 51 enforcer, err := rbac.NewEnforcer(config.DbPath) 52 if err != nil { 53 return nil, err 54 } 55 56 clock := syntax.NewTIDClock(0) 57 58 pgs := pages.NewPages() 59 60 resolver := appview.NewResolver() 61 62 wrapper := db.DbWrapper{d} 63 jc, err := jetstream.NewJetstreamClient( 64 config.JetstreamEndpoint, 65 "appview", 66 []string{tangled.GraphFollowNSID, tangled.FeedStarNSID}, 67 nil, 68 slog.Default(), 69 wrapper, 70 false, 71 ) 72 if err != nil { 73 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 74 } 75 err = jc.StartJetstream(context.Background(), jetstreamIngester(wrapper)) 76 if err != nil { 77 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 78 } 79 80 state := &State{ 81 d, 82 auth, 83 enforcer, 84 clock, 85 pgs, 86 resolver, 87 jc, 88 config, 89 } 90 91 return state, nil 92} 93 94func (s *State) TID() string { 95 return s.tidClock.Next().String() 96} 97 98func (s *State) Login(w http.ResponseWriter, r *http.Request) { 99 ctx := r.Context() 100 101 switch r.Method { 102 case http.MethodGet: 103 err := s.pages.Login(w, pages.LoginParams{}) 104 if err != nil { 105 log.Printf("rendering login page: %s", err) 106 } 107 108 return 109 case http.MethodPost: 110 handle := strings.TrimPrefix(r.FormValue("handle"), "@") 111 appPassword := r.FormValue("app_password") 112 113 resolved, err := s.resolver.ResolveIdent(ctx, handle) 114 if err != nil { 115 log.Println("failed to resolve handle:", err) 116 s.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 117 return 118 } 119 120 atSession, err := s.auth.CreateInitialSession(ctx, resolved, appPassword) 121 if err != nil { 122 s.pages.Notice(w, "login-msg", "Invalid handle or password.") 123 return 124 } 125 sessionish := auth.CreateSessionWrapper{ServerCreateSession_Output: atSession} 126 127 err = s.auth.StoreSession(r, w, &sessionish, resolved.PDSEndpoint()) 128 if err != nil { 129 s.pages.Notice(w, "login-msg", "Failed to login, try again later.") 130 return 131 } 132 133 log.Printf("successfully saved session for %s (%s)", atSession.Handle, atSession.Did) 134 135 did := resolved.DID.String() 136 defaultKnot := "knot1.tangled.sh" 137 138 go func() { 139 log.Printf("adding %s to default knot", did) 140 err = s.enforcer.AddMember(defaultKnot, did) 141 if err != nil { 142 log.Println("failed to add user to knot1.tangled.sh: ", err) 143 return 144 } 145 err = s.enforcer.E.SavePolicy() 146 if err != nil { 147 log.Println("failed to add user to knot1.tangled.sh: ", err) 148 return 149 } 150 151 secret, err := db.GetRegistrationKey(s.db, defaultKnot) 152 if err != nil { 153 log.Println("failed to get registration key for knot1.tangled.sh") 154 return 155 } 156 signedClient, err := NewSignedClient(defaultKnot, secret, s.config.Dev) 157 resp, err := signedClient.AddMember(did) 158 if err != nil { 159 log.Println("failed to add user to knot1.tangled.sh: ", err) 160 return 161 } 162 163 if resp.StatusCode != http.StatusNoContent { 164 log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 165 return 166 } 167 }() 168 169 s.pages.HxRedirect(w, "/") 170 return 171 } 172} 173 174func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 175 s.auth.ClearSession(r, w) 176 w.Header().Set("HX-Redirect", "/login") 177 w.WriteHeader(http.StatusSeeOther) 178} 179 180func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 181 user := s.auth.GetUser(r) 182 183 timeline, err := db.MakeTimeline(s.db) 184 if err != nil { 185 log.Println(err) 186 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 187 } 188 189 var didsToResolve []string 190 for _, ev := range timeline { 191 if ev.Repo != nil { 192 didsToResolve = append(didsToResolve, ev.Repo.Did) 193 } 194 if ev.Follow != nil { 195 didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) 196 } 197 if ev.Star != nil { 198 didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did) 199 } 200 } 201 202 resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 203 didHandleMap := make(map[string]string) 204 for _, identity := range resolvedIds { 205 if !identity.Handle.IsInvalidHandle() { 206 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 207 } else { 208 didHandleMap[identity.DID.String()] = identity.DID.String() 209 } 210 } 211 212 s.pages.Timeline(w, pages.TimelineParams{ 213 LoggedInUser: user, 214 Timeline: timeline, 215 DidHandleMap: didHandleMap, 216 }) 217 218 return 219} 220 221// requires auth 222func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) { 223 switch r.Method { 224 case http.MethodGet: 225 // list open registrations under this did 226 227 return 228 case http.MethodPost: 229 session, err := s.auth.Store.Get(r, appview.SessionName) 230 if err != nil || session.IsNew { 231 log.Println("unauthorized attempt to generate registration key") 232 http.Error(w, "Forbidden", http.StatusUnauthorized) 233 return 234 } 235 236 did := session.Values[appview.SessionDid].(string) 237 238 // check if domain is valid url, and strip extra bits down to just host 239 domain := r.FormValue("domain") 240 if domain == "" { 241 http.Error(w, "Invalid form", http.StatusBadRequest) 242 return 243 } 244 245 key, err := db.GenerateRegistrationKey(s.db, domain, did) 246 247 if err != nil { 248 log.Println(err) 249 http.Error(w, "unable to register this domain", http.StatusNotAcceptable) 250 return 251 } 252 253 w.Write([]byte(key)) 254 } 255} 256 257func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 258 user := chi.URLParam(r, "user") 259 user = strings.TrimPrefix(user, "@") 260 261 if user == "" { 262 w.WriteHeader(http.StatusBadRequest) 263 return 264 } 265 266 id, err := s.resolver.ResolveIdent(r.Context(), user) 267 if err != nil { 268 w.WriteHeader(http.StatusInternalServerError) 269 return 270 } 271 272 pubKeys, err := db.GetPublicKeys(s.db, id.DID.String()) 273 if err != nil { 274 w.WriteHeader(http.StatusNotFound) 275 return 276 } 277 278 if len(pubKeys) == 0 { 279 w.WriteHeader(http.StatusNotFound) 280 return 281 } 282 283 for _, k := range pubKeys { 284 key := strings.TrimRight(k.Key, "\n") 285 w.Write([]byte(fmt.Sprintln(key))) 286 } 287} 288 289// create a signed request and check if a node responds to that 290func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) { 291 user := s.auth.GetUser(r) 292 293 domain := chi.URLParam(r, "domain") 294 if domain == "" { 295 http.Error(w, "malformed url", http.StatusBadRequest) 296 return 297 } 298 log.Println("checking ", domain) 299 300 secret, err := db.GetRegistrationKey(s.db, domain) 301 if err != nil { 302 log.Printf("no key found for domain %s: %s\n", domain, err) 303 return 304 } 305 306 client, err := NewSignedClient(domain, secret, s.config.Dev) 307 if err != nil { 308 log.Println("failed to create client to ", domain) 309 } 310 311 resp, err := client.Init(user.Did) 312 if err != nil { 313 w.Write([]byte("no dice")) 314 log.Println("domain was unreachable after 5 seconds") 315 return 316 } 317 318 if resp.StatusCode == http.StatusConflict { 319 log.Println("status conflict", resp.StatusCode) 320 w.Write([]byte("already registered, sorry!")) 321 return 322 } 323 324 if resp.StatusCode != http.StatusNoContent { 325 log.Println("status nok", resp.StatusCode) 326 w.Write([]byte("no dice")) 327 return 328 } 329 330 // verify response mac 331 signature := resp.Header.Get("X-Signature") 332 signatureBytes, err := hex.DecodeString(signature) 333 if err != nil { 334 return 335 } 336 337 expectedMac := hmac.New(sha256.New, []byte(secret)) 338 expectedMac.Write([]byte("ok")) 339 340 if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 341 log.Printf("response body signature mismatch: %x\n", signatureBytes) 342 return 343 } 344 345 tx, err := s.db.BeginTx(r.Context(), nil) 346 if err != nil { 347 log.Println("failed to start tx", err) 348 http.Error(w, err.Error(), http.StatusInternalServerError) 349 return 350 } 351 defer func() { 352 tx.Rollback() 353 err = s.enforcer.E.LoadPolicy() 354 if err != nil { 355 log.Println("failed to rollback policies") 356 } 357 }() 358 359 // mark as registered 360 err = db.Register(tx, domain) 361 if err != nil { 362 log.Println("failed to register domain", err) 363 http.Error(w, err.Error(), http.StatusInternalServerError) 364 return 365 } 366 367 // set permissions for this did as owner 368 reg, err := db.RegistrationByDomain(tx, domain) 369 if err != nil { 370 log.Println("failed to register domain", err) 371 http.Error(w, err.Error(), http.StatusInternalServerError) 372 return 373 } 374 375 // add basic acls for this domain 376 err = s.enforcer.AddDomain(domain) 377 if err != nil { 378 log.Println("failed to setup owner of domain", err) 379 http.Error(w, err.Error(), http.StatusInternalServerError) 380 return 381 } 382 383 // add this did as owner of this domain 384 err = s.enforcer.AddOwner(domain, reg.ByDid) 385 if err != nil { 386 log.Println("failed to setup owner of domain", err) 387 http.Error(w, err.Error(), http.StatusInternalServerError) 388 return 389 } 390 391 err = tx.Commit() 392 if err != nil { 393 log.Println("failed to commit changes", err) 394 http.Error(w, err.Error(), http.StatusInternalServerError) 395 return 396 } 397 398 err = s.enforcer.E.SavePolicy() 399 if err != nil { 400 log.Println("failed to update ACLs", err) 401 http.Error(w, err.Error(), http.StatusInternalServerError) 402 return 403 } 404 405 w.Write([]byte("check success")) 406} 407 408func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) { 409 domain := chi.URLParam(r, "domain") 410 if domain == "" { 411 http.Error(w, "malformed url", http.StatusBadRequest) 412 return 413 } 414 415 user := s.auth.GetUser(r) 416 reg, err := db.RegistrationByDomain(s.db, domain) 417 if err != nil { 418 w.Write([]byte("failed to pull up registration info")) 419 return 420 } 421 422 var members []string 423 if reg.Registered != nil { 424 members, err = s.enforcer.GetUserByRole("server:member", domain) 425 if err != nil { 426 w.Write([]byte("failed to fetch member list")) 427 return 428 } 429 } 430 431 var didsToResolve []string 432 for _, m := range members { 433 didsToResolve = append(didsToResolve, m) 434 } 435 didsToResolve = append(didsToResolve, reg.ByDid) 436 resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 437 didHandleMap := make(map[string]string) 438 for _, identity := range resolvedIds { 439 if !identity.Handle.IsInvalidHandle() { 440 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 441 } else { 442 didHandleMap[identity.DID.String()] = identity.DID.String() 443 } 444 } 445 446 ok, err := s.enforcer.IsServerOwner(user.Did, domain) 447 isOwner := err == nil && ok 448 449 p := pages.KnotParams{ 450 LoggedInUser: user, 451 DidHandleMap: didHandleMap, 452 Registration: reg, 453 Members: members, 454 IsOwner: isOwner, 455 } 456 457 s.pages.Knot(w, p) 458} 459 460// get knots registered by this user 461func (s *State) Knots(w http.ResponseWriter, r *http.Request) { 462 // for now, this is just pubkeys 463 user := s.auth.GetUser(r) 464 registrations, err := db.RegistrationsByDid(s.db, user.Did) 465 if err != nil { 466 log.Println(err) 467 } 468 469 s.pages.Knots(w, pages.KnotsParams{ 470 LoggedInUser: user, 471 Registrations: registrations, 472 }) 473} 474 475// list members of domain, requires auth and requires owner status 476func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) { 477 domain := chi.URLParam(r, "domain") 478 if domain == "" { 479 http.Error(w, "malformed url", http.StatusBadRequest) 480 return 481 } 482 483 // list all members for this domain 484 memberDids, err := s.enforcer.GetUserByRole("server:member", domain) 485 if err != nil { 486 w.Write([]byte("failed to fetch member list")) 487 return 488 } 489 490 w.Write([]byte(strings.Join(memberDids, "\n"))) 491 return 492} 493 494// add member to domain, requires auth and requires invite access 495func (s *State) AddMember(w http.ResponseWriter, r *http.Request) { 496 domain := chi.URLParam(r, "domain") 497 if domain == "" { 498 http.Error(w, "malformed url", http.StatusBadRequest) 499 return 500 } 501 502 memberDid := r.FormValue("member") 503 if memberDid == "" { 504 http.Error(w, "malformed form", http.StatusBadRequest) 505 return 506 } 507 508 memberIdent, err := s.resolver.ResolveIdent(r.Context(), memberDid) 509 if err != nil { 510 w.Write([]byte("failed to resolve member did to a handle")) 511 return 512 } 513 log.Printf("adding %s to %s\n", memberIdent.Handle.String(), domain) 514 515 // announce this relation into the firehose, store into owners' pds 516 client, _ := s.auth.AuthorizedClient(r) 517 currentUser := s.auth.GetUser(r) 518 addedAt := time.Now().Format(time.RFC3339) 519 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 520 Collection: tangled.KnotMemberNSID, 521 Repo: currentUser.Did, 522 Rkey: s.TID(), 523 Record: &lexutil.LexiconTypeDecoder{ 524 Val: &tangled.KnotMember{ 525 Member: memberIdent.DID.String(), 526 Domain: domain, 527 AddedAt: &addedAt, 528 }}, 529 }) 530 531 // invalid record 532 if err != nil { 533 log.Printf("failed to create record: %s", err) 534 return 535 } 536 log.Println("created atproto record: ", resp.Uri) 537 538 secret, err := db.GetRegistrationKey(s.db, domain) 539 if err != nil { 540 log.Printf("no key found for domain %s: %s\n", domain, err) 541 return 542 } 543 544 ksClient, err := NewSignedClient(domain, secret, s.config.Dev) 545 if err != nil { 546 log.Println("failed to create client to ", domain) 547 return 548 } 549 550 ksResp, err := ksClient.AddMember(memberIdent.DID.String()) 551 if err != nil { 552 log.Printf("failed to make request to %s: %s", domain, err) 553 return 554 } 555 556 if ksResp.StatusCode != http.StatusNoContent { 557 w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err))) 558 return 559 } 560 561 err = s.enforcer.AddMember(domain, memberIdent.DID.String()) 562 if err != nil { 563 w.Write([]byte(fmt.Sprint("failed to add member: ", err))) 564 return 565 } 566 567 w.Write([]byte(fmt.Sprint("added member: ", memberIdent.Handle.String()))) 568} 569 570func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) { 571} 572 573func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 574 switch r.Method { 575 case http.MethodGet: 576 user := s.auth.GetUser(r) 577 knots, err := s.enforcer.GetDomainsForUser(user.Did) 578 if err != nil { 579 s.pages.Notice(w, "repo", "Invalid user account.") 580 return 581 } 582 583 s.pages.NewRepo(w, pages.NewRepoParams{ 584 LoggedInUser: user, 585 Knots: knots, 586 }) 587 588 case http.MethodPost: 589 user := s.auth.GetUser(r) 590 591 domain := r.FormValue("domain") 592 if domain == "" { 593 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 594 return 595 } 596 597 repoName := r.FormValue("name") 598 if repoName == "" { 599 s.pages.Notice(w, "repo", "Repository name cannot be empty.") 600 return 601 } 602 603 // Check for valid repository name (GitHub-like rules) 604 // No spaces, only alphanumeric characters, dashes, and underscores 605 for _, char := range repoName { 606 if !((char >= 'a' && char <= 'z') || 607 (char >= 'A' && char <= 'Z') || 608 (char >= '0' && char <= '9') || 609 char == '-' || char == '_' || char == '.') { 610 s.pages.Notice(w, "repo", "Repository name can only contain alphanumeric characters, periods, hyphens, and underscores.") 611 return 612 } 613 } 614 615 defaultBranch := r.FormValue("branch") 616 if defaultBranch == "" { 617 defaultBranch = "main" 618 } 619 620 description := r.FormValue("description") 621 622 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 623 if err != nil || !ok { 624 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 625 return 626 } 627 628 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 629 if err == nil && existingRepo != nil { 630 s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) 631 return 632 } 633 634 secret, err := db.GetRegistrationKey(s.db, domain) 635 if err != nil { 636 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) 637 return 638 } 639 640 client, err := NewSignedClient(domain, secret, s.config.Dev) 641 if err != nil { 642 s.pages.Notice(w, "repo", "Failed to connect to knot server.") 643 return 644 } 645 646 rkey := s.TID() 647 repo := &db.Repo{ 648 Did: user.Did, 649 Name: repoName, 650 Knot: domain, 651 Rkey: rkey, 652 Description: description, 653 } 654 655 xrpcClient, _ := s.auth.AuthorizedClient(r) 656 657 addedAt := time.Now().Format(time.RFC3339) 658 atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{ 659 Collection: tangled.RepoNSID, 660 Repo: user.Did, 661 Rkey: rkey, 662 Record: &lexutil.LexiconTypeDecoder{ 663 Val: &tangled.Repo{ 664 Knot: repo.Knot, 665 Name: repoName, 666 AddedAt: &addedAt, 667 Owner: user.Did, 668 }}, 669 }) 670 if err != nil { 671 log.Printf("failed to create record: %s", err) 672 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 673 return 674 } 675 log.Println("created repo record: ", atresp.Uri) 676 677 tx, err := s.db.BeginTx(r.Context(), nil) 678 if err != nil { 679 log.Println(err) 680 s.pages.Notice(w, "repo", "Failed to save repository information.") 681 return 682 } 683 defer func() { 684 tx.Rollback() 685 err = s.enforcer.E.LoadPolicy() 686 if err != nil { 687 log.Println("failed to rollback policies") 688 } 689 }() 690 691 resp, err := client.NewRepo(user.Did, repoName, defaultBranch) 692 if err != nil { 693 s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 694 return 695 } 696 697 switch resp.StatusCode { 698 case http.StatusConflict: 699 s.pages.Notice(w, "repo", "A repository with that name already exists.") 700 return 701 case http.StatusInternalServerError: 702 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 703 case http.StatusNoContent: 704 // continue 705 } 706 707 repo.AtUri = atresp.Uri 708 err = db.AddRepo(tx, repo) 709 if err != nil { 710 log.Println(err) 711 s.pages.Notice(w, "repo", "Failed to save repository information.") 712 return 713 } 714 715 // acls 716 p, _ := securejoin.SecureJoin(user.Did, repoName) 717 err = s.enforcer.AddRepo(user.Did, domain, p) 718 if err != nil { 719 log.Println(err) 720 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 721 return 722 } 723 724 err = tx.Commit() 725 if err != nil { 726 log.Println("failed to commit changes", err) 727 http.Error(w, err.Error(), http.StatusInternalServerError) 728 return 729 } 730 731 err = s.enforcer.E.SavePolicy() 732 if err != nil { 733 log.Println("failed to update ACLs", err) 734 http.Error(w, err.Error(), http.StatusInternalServerError) 735 return 736 } 737 738 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 739 return 740 } 741} 742 743func GetAvatarUri(handle string) (string, error) { 744 return fmt.Sprintf("https://avatars.dog/%s@webp", handle), nil 745}