A vibe coded tangled fork which supports pijul.
at 8fb22cb34756cc2930bb780548d45bde56cdbe65 732 lines 19 kB view raw
1package state 2 3import ( 4 "context" 5 "database/sql" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "strings" 11 "time" 12 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview" 15 "tangled.org/core/appview/config" 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/indexer" 18 "tangled.org/core/appview/mentions" 19 "tangled.org/core/appview/models" 20 "tangled.org/core/appview/notify" 21 dbnotify "tangled.org/core/appview/notify/db" 22 phnotify "tangled.org/core/appview/notify/posthog" 23 "tangled.org/core/appview/oauth" 24 "tangled.org/core/appview/pages" 25 "tangled.org/core/appview/reporesolver" 26 "tangled.org/core/appview/validator" 27 xrpcclient "tangled.org/core/appview/xrpcclient" 28 "tangled.org/core/eventconsumer" 29 "tangled.org/core/idresolver" 30 "tangled.org/core/jetstream" 31 "tangled.org/core/log" 32 tlog "tangled.org/core/log" 33 "tangled.org/core/orm" 34 "tangled.org/core/rbac" 35 "tangled.org/core/tid" 36 37 comatproto "github.com/bluesky-social/indigo/api/atproto" 38 atpclient "github.com/bluesky-social/indigo/atproto/client" 39 "github.com/bluesky-social/indigo/atproto/syntax" 40 lexutil "github.com/bluesky-social/indigo/lex/util" 41 securejoin "github.com/cyphar/filepath-securejoin" 42 "github.com/go-chi/chi/v5" 43 "github.com/posthog/posthog-go" 44) 45 46type State struct { 47 db *db.DB 48 notifier notify.Notifier 49 indexer *indexer.Indexer 50 oauth *oauth.OAuth 51 enforcer *rbac.Enforcer 52 pages *pages.Pages 53 idResolver *idresolver.Resolver 54 mentionsResolver *mentions.Resolver 55 posthog posthog.Client 56 jc *jetstream.JetstreamClient 57 config *config.Config 58 repoResolver *reporesolver.RepoResolver 59 knotstream *eventconsumer.Consumer 60 spindlestream *eventconsumer.Consumer 61 logger *slog.Logger 62 validator *validator.Validator 63} 64 65func Make(ctx context.Context, config *config.Config) (*State, error) { 66 logger := tlog.FromContext(ctx) 67 68 d, err := db.Make(ctx, config.Core.DbPath) 69 if err != nil { 70 return nil, fmt.Errorf("failed to create db: %w", err) 71 } 72 73 indexer := indexer.New(log.SubLogger(logger, "indexer")) 74 err = indexer.Init(ctx, d) 75 if err != nil { 76 return nil, fmt.Errorf("failed to create indexer: %w", err) 77 } 78 79 enforcer, err := rbac.NewEnforcer(config.Core.DbPath) 80 if err != nil { 81 return nil, fmt.Errorf("failed to create enforcer: %w", err) 82 } 83 84 res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL) 85 if err != nil { 86 logger.Error("failed to create redis resolver", "err", err) 87 res = idresolver.DefaultResolver(config.Plc.PLCURL) 88 } 89 90 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 91 if err != nil { 92 return nil, fmt.Errorf("failed to create posthog client: %w", err) 93 } 94 95 pages := pages.NewPages(config, res, d, log.SubLogger(logger, "pages")) 96 oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth")) 97 if err != nil { 98 return nil, fmt.Errorf("failed to start oauth handler: %w", err) 99 } 100 validator := validator.New(d, res, enforcer) 101 102 repoResolver := reporesolver.New(config, enforcer, d) 103 104 mentionsResolver := mentions.New(config, res, d, log.SubLogger(logger, "mentionsResolver")) 105 106 wrapper := db.DbWrapper{Execer: d} 107 jc, err := jetstream.NewJetstreamClient( 108 config.Jetstream.Endpoint, 109 "appview", 110 []string{ 111 tangled.GraphFollowNSID, 112 tangled.FeedStarNSID, 113 tangled.PublicKeyNSID, 114 tangled.RepoArtifactNSID, 115 tangled.ActorProfileNSID, 116 tangled.SpindleMemberNSID, 117 tangled.SpindleNSID, 118 tangled.StringNSID, 119 tangled.RepoIssueNSID, 120 tangled.RepoIssueCommentNSID, 121 tangled.LabelDefinitionNSID, 122 tangled.LabelOpNSID, 123 }, 124 nil, 125 tlog.SubLogger(logger, "jetstream"), 126 wrapper, 127 false, 128 129 // in-memory filter is inapplicalble to appview so 130 // we'll never log dids anyway. 131 false, 132 ) 133 if err != nil { 134 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 135 } 136 137 if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil { 138 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 139 } 140 141 ingester := appview.Ingester{ 142 Db: wrapper, 143 Enforcer: enforcer, 144 IdResolver: res, 145 Config: config, 146 Logger: log.SubLogger(logger, "ingester"), 147 Validator: validator, 148 } 149 err = jc.StartJetstream(ctx, ingester.Ingest()) 150 if err != nil { 151 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 152 } 153 154 knotstream, err := Knotstream(ctx, config, d, enforcer, posthog) 155 if err != nil { 156 return nil, fmt.Errorf("failed to start knotstream consumer: %w", err) 157 } 158 knotstream.Start(ctx) 159 160 spindlestream, err := Spindlestream(ctx, config, d, enforcer) 161 if err != nil { 162 return nil, fmt.Errorf("failed to start spindlestream consumer: %w", err) 163 } 164 spindlestream.Start(ctx) 165 166 var notifiers []notify.Notifier 167 168 // Always add the database notifier 169 notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res)) 170 171 // Add other notifiers in production only 172 if !config.Core.Dev { 173 notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 174 } 175 notifiers = append(notifiers, indexer) 176 notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify")) 177 178 state := &State{ 179 d, 180 notifier, 181 indexer, 182 oauth, 183 enforcer, 184 pages, 185 res, 186 mentionsResolver, 187 posthog, 188 jc, 189 config, 190 repoResolver, 191 knotstream, 192 spindlestream, 193 logger, 194 validator, 195 } 196 197 return state, nil 198} 199 200func (s *State) Close() error { 201 // other close up logic goes here 202 return s.db.Close() 203} 204 205func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { 206 w.Header().Set("Content-Type", "text/plain") 207 w.Header().Set("Cache-Control", "public, max-age=86400") // one day 208 209 robotsTxt := `User-agent: * 210Allow: / 211Disallow: /settings 212Disallow: /notifications 213Disallow: /login 214Disallow: /logout 215Disallow: /signup 216Disallow: /oauth 217Disallow: */settings$ 218Disallow: */settings/* 219 220Crawl-delay: 1 221 222Sitemap: https://tangled.org/sitemap.xml 223` 224 w.Write([]byte(robotsTxt)) 225} 226 227func (s *State) Sitemap(w http.ResponseWriter, r *http.Request) { 228 w.Header().Set("Content-Type", "application/xml; charset=utf-8") 229 w.Header().Set("Cache-Control", "public, max-age=3600") 230 231 // basic sitemap with static pages 232 sitemap := `<?xml version="1.0" encoding="UTF-8"?> 233<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> 234 <url> 235 <loc>https://tangled.org</loc> 236 <changefreq>daily</changefreq> 237 <priority>1.0</priority> 238 </url> 239 <url> 240 <loc>https://tangled.org/timeline</loc> 241 <changefreq>hourly</changefreq> 242 <priority>0.9</priority> 243 </url> 244 <url> 245 <loc>https://tangled.org/goodfirstissues</loc> 246 <changefreq>daily</changefreq> 247 <priority>0.8</priority> 248 </url> 249 <url> 250 <loc>https://tangled.org/terms</loc> 251 <changefreq>monthly</changefreq> 252 <priority>0.3</priority> 253 </url> 254 <url> 255 <loc>https://tangled.org/privacy</loc> 256 <changefreq>monthly</changefreq> 257 <priority>0.3</priority> 258 </url> 259 <url> 260 <loc>https://tangled.org/brand</loc> 261 <changefreq>monthly</changefreq> 262 <priority>0.5</priority> 263 </url> 264</urlset>` 265 w.Write([]byte(sitemap)) 266} 267 268// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 269const manifestJson = `{ 270 "name": "tangled", 271 "description": "tightly-knit social coding.", 272 "icons": [ 273 { 274 "src": "/favicon.svg", 275 "sizes": "144x144" 276 } 277 ], 278 "start_url": "/", 279 "id": "org.tangled", 280 281 "display": "standalone", 282 "background_color": "#111827", 283 "theme_color": "#111827" 284}` 285 286func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 287 w.Header().Set("Content-Type", "application/json") 288 w.Write([]byte(manifestJson)) 289} 290 291func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 292 user := s.oauth.GetMultiAccountUser(r) 293 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 294 LoggedInUser: user, 295 }) 296} 297 298func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 299 user := s.oauth.GetMultiAccountUser(r) 300 s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 301 LoggedInUser: user, 302 }) 303} 304 305func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 306 user := s.oauth.GetMultiAccountUser(r) 307 s.pages.Brand(w, pages.BrandParams{ 308 LoggedInUser: user, 309 }) 310} 311 312func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 313 if s.oauth.GetMultiAccountUser(r) != nil { 314 s.Timeline(w, r) 315 return 316 } 317 s.Home(w, r) 318} 319 320func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 321 user := s.oauth.GetMultiAccountUser(r) 322 323 // TODO: set this flag based on the UI 324 filtered := false 325 326 var userDid string 327 if user != nil && user.Active != nil { 328 userDid = user.Active.Did 329 } 330 timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 331 if err != nil { 332 s.logger.Error("failed to make timeline", "err", err) 333 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 334 } 335 336 repos, err := db.GetTopStarredReposLastWeek(s.db) 337 if err != nil { 338 s.logger.Error("failed to get top starred repos", "err", err) 339 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 340 return 341 } 342 343 gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 344 if err != nil { 345 // non-fatal 346 } 347 348 s.pages.Timeline(w, pages.TimelineParams{ 349 LoggedInUser: user, 350 Timeline: timeline, 351 Repos: repos, 352 GfiLabel: gfiLabel, 353 }) 354} 355 356func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 357 user := s.oauth.GetMultiAccountUser(r) 358 if user == nil { 359 return 360 } 361 362 l := s.logger.With("handler", "UpgradeBanner") 363 l = l.With("did", user.Active.Did) 364 365 regs, err := db.GetRegistrations( 366 s.db, 367 orm.FilterEq("did", user.Active.Did), 368 orm.FilterEq("needs_upgrade", 1), 369 ) 370 if err != nil { 371 l.Error("non-fatal: failed to get registrations", "err", err) 372 } 373 374 spindles, err := db.GetSpindles( 375 s.db, 376 orm.FilterEq("owner", user.Active.Did), 377 orm.FilterEq("needs_upgrade", 1), 378 ) 379 if err != nil { 380 l.Error("non-fatal: failed to get spindles", "err", err) 381 } 382 383 if regs == nil && spindles == nil { 384 return 385 } 386 387 s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{ 388 Registrations: regs, 389 Spindles: spindles, 390 }) 391} 392 393func (s *State) Home(w http.ResponseWriter, r *http.Request) { 394 // TODO: set this flag based on the UI 395 filtered := false 396 397 timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 398 if err != nil { 399 s.logger.Error("failed to make timeline", "err", err) 400 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 401 return 402 } 403 404 repos, err := db.GetTopStarredReposLastWeek(s.db) 405 if err != nil { 406 s.logger.Error("failed to get top starred repos", "err", err) 407 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 408 return 409 } 410 411 s.pages.Home(w, pages.TimelineParams{ 412 LoggedInUser: nil, 413 Timeline: timeline, 414 Repos: repos, 415 }) 416} 417 418func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 419 user := chi.URLParam(r, "user") 420 user = strings.TrimPrefix(user, "@") 421 422 if user == "" { 423 w.WriteHeader(http.StatusBadRequest) 424 return 425 } 426 427 id, err := s.idResolver.ResolveIdent(r.Context(), user) 428 if err != nil { 429 w.WriteHeader(http.StatusInternalServerError) 430 return 431 } 432 433 pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 434 if err != nil { 435 s.logger.Error("failed to get public keys", "err", err) 436 http.Error(w, "failed to get public keys", http.StatusInternalServerError) 437 return 438 } 439 440 if len(pubKeys) == 0 { 441 w.WriteHeader(http.StatusNoContent) 442 return 443 } 444 445 for _, k := range pubKeys { 446 key := strings.TrimRight(k.Key, "\n") 447 fmt.Fprintln(w, key) 448 } 449} 450 451func validateRepoName(name string) error { 452 // check for path traversal attempts 453 if name == "." || name == ".." || 454 strings.Contains(name, "/") || strings.Contains(name, "\\") { 455 return fmt.Errorf("Repository name contains invalid path characters") 456 } 457 458 // check for sequences that could be used for traversal when normalized 459 if strings.Contains(name, "./") || strings.Contains(name, "../") || 460 strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 461 return fmt.Errorf("Repository name contains invalid path sequence") 462 } 463 464 // then continue with character validation 465 for _, char := range name { 466 if !((char >= 'a' && char <= 'z') || 467 (char >= 'A' && char <= 'Z') || 468 (char >= '0' && char <= '9') || 469 char == '-' || char == '_' || char == '.') { 470 return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 471 } 472 } 473 474 // additional check to prevent multiple sequential dots 475 if strings.Contains(name, "..") { 476 return fmt.Errorf("Repository name cannot contain sequential dots") 477 } 478 479 // if all checks pass 480 return nil 481} 482 483func stripGitExt(name string) string { 484 return strings.TrimSuffix(name, ".git") 485} 486 487func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 488 switch r.Method { 489 case http.MethodGet: 490 user := s.oauth.GetMultiAccountUser(r) 491 knots, err := s.enforcer.GetKnotsForUser(user.Active.Did) 492 if err != nil { 493 s.pages.Notice(w, "repo", "Invalid user account.") 494 return 495 } 496 497 s.pages.NewRepo(w, pages.NewRepoParams{ 498 LoggedInUser: user, 499 Knots: knots, 500 }) 501 502 case http.MethodPost: 503 l := s.logger.With("handler", "NewRepo") 504 505 user := s.oauth.GetMultiAccountUser(r) 506 l = l.With("did", user.Active.Did) 507 508 // form validation 509 domain := r.FormValue("domain") 510 if domain == "" { 511 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 512 return 513 } 514 l = l.With("knot", domain) 515 516 repoName := r.FormValue("name") 517 if repoName == "" { 518 s.pages.Notice(w, "repo", "Repository name cannot be empty.") 519 return 520 } 521 522 if err := validateRepoName(repoName); err != nil { 523 s.pages.Notice(w, "repo", err.Error()) 524 return 525 } 526 repoName = stripGitExt(repoName) 527 l = l.With("repoName", repoName) 528 529 defaultBranch := r.FormValue("branch") 530 if defaultBranch == "" { 531 defaultBranch = "main" 532 } 533 l = l.With("defaultBranch", defaultBranch) 534 535 description := r.FormValue("description") 536 537 // ACL validation 538 ok, err := s.enforcer.E.Enforce(user.Active.Did, domain, domain, "repo:create") 539 if err != nil || !ok { 540 l.Info("unauthorized") 541 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 542 return 543 } 544 545 // Check for existing repos 546 existingRepo, err := db.GetRepo( 547 s.db, 548 orm.FilterEq("did", user.Active.Did), 549 orm.FilterEq("name", repoName), 550 ) 551 if err == nil && existingRepo != nil { 552 l.Info("repo exists") 553 s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot)) 554 return 555 } 556 557 // create atproto record for this repo 558 rkey := tid.TID() 559 repo := &models.Repo{ 560 Did: user.Active.Did, 561 Name: repoName, 562 Knot: domain, 563 Rkey: rkey, 564 Description: description, 565 Created: time.Now(), 566 Labels: s.config.Label.DefaultLabelDefs, 567 } 568 record := repo.AsRecord() 569 570 atpClient, err := s.oauth.AuthorizedClient(r) 571 if err != nil { 572 l.Info("PDS write failed", "err", err) 573 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 574 return 575 } 576 577 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 578 Collection: tangled.RepoNSID, 579 Repo: user.Active.Did, 580 Rkey: rkey, 581 Record: &lexutil.LexiconTypeDecoder{ 582 Val: &record, 583 }, 584 }) 585 if err != nil { 586 l.Info("PDS write failed", "err", err) 587 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 588 return 589 } 590 591 aturi := atresp.Uri 592 l = l.With("aturi", aturi) 593 l.Info("wrote to PDS") 594 595 tx, err := s.db.BeginTx(r.Context(), nil) 596 if err != nil { 597 l.Info("txn failed", "err", err) 598 s.pages.Notice(w, "repo", "Failed to save repository information.") 599 return 600 } 601 602 // The rollback function reverts a few things on failure: 603 // - the pending txn 604 // - the ACLs 605 // - the atproto record created 606 rollback := func() { 607 err1 := tx.Rollback() 608 err2 := s.enforcer.E.LoadPolicy() 609 err3 := rollbackRecord(context.Background(), aturi, atpClient) 610 611 // ignore txn complete errors, this is okay 612 if errors.Is(err1, sql.ErrTxDone) { 613 err1 = nil 614 } 615 616 if errs := errors.Join(err1, err2, err3); errs != nil { 617 l.Error("failed to rollback changes", "errs", errs) 618 return 619 } 620 } 621 defer rollback() 622 623 client, err := s.oauth.ServiceClient( 624 r, 625 oauth.WithService(domain), 626 oauth.WithLxm(tangled.RepoCreateNSID), 627 oauth.WithDev(s.config.Core.Dev), 628 ) 629 if err != nil { 630 l.Error("service auth failed", "err", err) 631 s.pages.Notice(w, "repo", "Failed to reach PDS.") 632 return 633 } 634 635 xe := tangled.RepoCreate( 636 r.Context(), 637 client, 638 &tangled.RepoCreate_Input{ 639 Rkey: rkey, 640 }, 641 ) 642 if err := xrpcclient.HandleXrpcErr(xe); err != nil { 643 l.Error("xrpc error", "xe", xe) 644 s.pages.Notice(w, "repo", err.Error()) 645 return 646 } 647 648 err = db.AddRepo(tx, repo) 649 if err != nil { 650 l.Error("db write failed", "err", err) 651 s.pages.Notice(w, "repo", "Failed to save repository information.") 652 return 653 } 654 655 // acls 656 p, _ := securejoin.SecureJoin(user.Active.Did, repoName) 657 err = s.enforcer.AddRepo(user.Active.Did, domain, p) 658 if err != nil { 659 l.Error("acl setup failed", "err", err) 660 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 661 return 662 } 663 664 err = tx.Commit() 665 if err != nil { 666 l.Error("txn commit failed", "err", err) 667 http.Error(w, err.Error(), http.StatusInternalServerError) 668 return 669 } 670 671 err = s.enforcer.E.SavePolicy() 672 if err != nil { 673 l.Error("acl save failed", "err", err) 674 http.Error(w, err.Error(), http.StatusInternalServerError) 675 return 676 } 677 678 // reset the ATURI because the transaction completed successfully 679 aturi = "" 680 681 s.notifier.NewRepo(r.Context(), repo) 682 s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Active.Did, repoName)) 683 } 684} 685 686// this is used to rollback changes made to the PDS 687// 688// it is a no-op if the provided ATURI is empty 689func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 690 if aturi == "" { 691 return nil 692 } 693 694 parsed := syntax.ATURI(aturi) 695 696 collection := parsed.Collection().String() 697 repo := parsed.Authority().String() 698 rkey := parsed.RecordKey().String() 699 700 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 701 Collection: collection, 702 Repo: repo, 703 Rkey: rkey, 704 }) 705 return err 706} 707 708func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error { 709 defaultLabels, err := db.GetLabelDefinitions(e, orm.FilterIn("at_uri", defaults)) 710 if err != nil { 711 return err 712 } 713 // already present 714 if len(defaultLabels) == len(defaults) { 715 return nil 716 } 717 718 labelDefs, err := models.FetchLabelDefs(r, defaults) 719 if err != nil { 720 return err 721 } 722 723 // Insert each label definition to the database 724 for _, labelDef := range labelDefs { 725 _, err = db.AddLabelDefinition(e, &labelDef) 726 if err != nil { 727 return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err) 728 } 729 } 730 731 return nil 732}