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