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