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