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