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/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, 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: / 211` 212 w.Write([]byte(robotsTxt)) 213} 214 215// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 216const manifestJson = `{ 217 "name": "tangled", 218 "description": "tightly-knit social coding.", 219 "icons": [ 220 { 221 "src": "/favicon.svg", 222 "sizes": "144x144" 223 } 224 ], 225 "start_url": "/", 226 "id": "org.tangled", 227 228 "display": "standalone", 229 "background_color": "#111827", 230 "theme_color": "#111827" 231}` 232 233func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 234 w.Header().Set("Content-Type", "application/json") 235 w.Write([]byte(manifestJson)) 236} 237 238func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 239 user := s.oauth.GetUser(r) 240 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 241 LoggedInUser: user, 242 }) 243} 244 245func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 246 user := s.oauth.GetUser(r) 247 s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 248 LoggedInUser: user, 249 }) 250} 251 252func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 253 user := s.oauth.GetUser(r) 254 s.pages.Brand(w, pages.BrandParams{ 255 LoggedInUser: user, 256 }) 257} 258 259func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 260 if s.oauth.GetUser(r) != nil { 261 s.Timeline(w, r) 262 return 263 } 264 s.Home(w, r) 265} 266 267func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 268 user := s.oauth.GetUser(r) 269 270 // TODO: set this flag based on the UI 271 filtered := false 272 273 var userDid string 274 if user != nil { 275 userDid = user.Did 276 } 277 timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 278 if err != nil { 279 s.logger.Error("failed to make timeline", "err", err) 280 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 281 } 282 283 repos, err := db.GetTopStarredReposLastWeek(s.db) 284 if err != nil { 285 s.logger.Error("failed to get top starred repos", "err", err) 286 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 287 return 288 } 289 290 gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 291 if err != nil { 292 // non-fatal 293 } 294 295 s.pages.Timeline(w, pages.TimelineParams{ 296 LoggedInUser: user, 297 Timeline: timeline, 298 Repos: repos, 299 GfiLabel: gfiLabel, 300 }) 301} 302 303func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 304 user := s.oauth.GetUser(r) 305 if user == nil { 306 return 307 } 308 309 l := s.logger.With("handler", "UpgradeBanner") 310 l = l.With("did", user.Did) 311 312 regs, err := db.GetRegistrations( 313 s.db, 314 orm.FilterEq("did", user.Did), 315 orm.FilterEq("needs_upgrade", 1), 316 ) 317 if err != nil { 318 l.Error("non-fatal: failed to get registrations", "err", err) 319 } 320 321 spindles, err := db.GetSpindles( 322 s.db, 323 orm.FilterEq("owner", user.Did), 324 orm.FilterEq("needs_upgrade", 1), 325 ) 326 if err != nil { 327 l.Error("non-fatal: failed to get spindles", "err", err) 328 } 329 330 if regs == nil && spindles == nil { 331 return 332 } 333 334 s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{ 335 Registrations: regs, 336 Spindles: spindles, 337 }) 338} 339 340func (s *State) Home(w http.ResponseWriter, r *http.Request) { 341 // TODO: set this flag based on the UI 342 filtered := false 343 344 timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 345 if err != nil { 346 s.logger.Error("failed to make timeline", "err", err) 347 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 348 return 349 } 350 351 repos, err := db.GetTopStarredReposLastWeek(s.db) 352 if err != nil { 353 s.logger.Error("failed to get top starred repos", "err", err) 354 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 355 return 356 } 357 358 s.pages.Home(w, pages.TimelineParams{ 359 LoggedInUser: nil, 360 Timeline: timeline, 361 Repos: repos, 362 }) 363} 364 365func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 366 user := chi.URLParam(r, "user") 367 user = strings.TrimPrefix(user, "@") 368 369 if user == "" { 370 w.WriteHeader(http.StatusBadRequest) 371 return 372 } 373 374 id, err := s.idResolver.ResolveIdent(r.Context(), user) 375 if err != nil { 376 w.WriteHeader(http.StatusInternalServerError) 377 return 378 } 379 380 pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 381 if err != nil { 382 s.logger.Error("failed to get public keys", "err", err) 383 http.Error(w, "failed to get public keys", http.StatusInternalServerError) 384 return 385 } 386 387 if len(pubKeys) == 0 { 388 w.WriteHeader(http.StatusNoContent) 389 return 390 } 391 392 for _, k := range pubKeys { 393 key := strings.TrimRight(k.Key, "\n") 394 fmt.Fprintln(w, key) 395 } 396} 397 398func validateRepoName(name string) error { 399 // check for path traversal attempts 400 if name == "." || name == ".." || 401 strings.Contains(name, "/") || strings.Contains(name, "\\") { 402 return fmt.Errorf("Repository name contains invalid path characters") 403 } 404 405 // check for sequences that could be used for traversal when normalized 406 if strings.Contains(name, "./") || strings.Contains(name, "../") || 407 strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 408 return fmt.Errorf("Repository name contains invalid path sequence") 409 } 410 411 // then continue with character validation 412 for _, char := range name { 413 if !((char >= 'a' && char <= 'z') || 414 (char >= 'A' && char <= 'Z') || 415 (char >= '0' && char <= '9') || 416 char == '-' || char == '_' || char == '.') { 417 return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 418 } 419 } 420 421 // additional check to prevent multiple sequential dots 422 if strings.Contains(name, "..") { 423 return fmt.Errorf("Repository name cannot contain sequential dots") 424 } 425 426 // if all checks pass 427 return nil 428} 429 430func stripGitExt(name string) string { 431 return strings.TrimSuffix(name, ".git") 432} 433 434func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 435 switch r.Method { 436 case http.MethodGet: 437 user := s.oauth.GetUser(r) 438 knots, err := s.enforcer.GetKnotsForUser(user.Did) 439 if err != nil { 440 s.pages.Notice(w, "repo", "Invalid user account.") 441 return 442 } 443 444 s.pages.NewRepo(w, pages.NewRepoParams{ 445 LoggedInUser: user, 446 Knots: knots, 447 }) 448 449 case http.MethodPost: 450 l := s.logger.With("handler", "NewRepo") 451 452 user := s.oauth.GetUser(r) 453 l = l.With("did", user.Did) 454 455 // form validation 456 domain := r.FormValue("domain") 457 if domain == "" { 458 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 459 return 460 } 461 l = l.With("knot", domain) 462 463 repoName := r.FormValue("name") 464 if repoName == "" { 465 s.pages.Notice(w, "repo", "Repository name cannot be empty.") 466 return 467 } 468 469 if err := validateRepoName(repoName); err != nil { 470 s.pages.Notice(w, "repo", err.Error()) 471 return 472 } 473 repoName = stripGitExt(repoName) 474 l = l.With("repoName", repoName) 475 476 defaultBranch := r.FormValue("branch") 477 if defaultBranch == "" { 478 defaultBranch = "main" 479 } 480 l = l.With("defaultBranch", defaultBranch) 481 482 description := r.FormValue("description") 483 484 // ACL validation 485 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 486 if err != nil || !ok { 487 l.Info("unauthorized") 488 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 489 return 490 } 491 492 // Check for existing repos 493 existingRepo, err := db.GetRepo( 494 s.db, 495 orm.FilterEq("did", user.Did), 496 orm.FilterEq("name", repoName), 497 ) 498 if err == nil && existingRepo != nil { 499 l.Info("repo exists") 500 s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot)) 501 return 502 } 503 504 // create atproto record for this repo 505 rkey := tid.TID() 506 repo := &models.Repo{ 507 Did: user.Did, 508 Name: repoName, 509 Knot: domain, 510 Rkey: rkey, 511 Description: description, 512 Created: time.Now(), 513 Labels: s.config.Label.DefaultLabelDefs, 514 } 515 record := repo.AsRecord() 516 517 atpClient, err := s.oauth.AuthorizedClient(r) 518 if err != nil { 519 l.Info("PDS write failed", "err", err) 520 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 521 return 522 } 523 524 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 525 Collection: tangled.RepoNSID, 526 Repo: user.Did, 527 Rkey: rkey, 528 Record: &lexutil.LexiconTypeDecoder{ 529 Val: &record, 530 }, 531 }) 532 if err != nil { 533 l.Info("PDS write failed", "err", err) 534 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 535 return 536 } 537 538 aturi := atresp.Uri 539 l = l.With("aturi", aturi) 540 l.Info("wrote to PDS") 541 542 tx, err := s.db.BeginTx(r.Context(), nil) 543 if err != nil { 544 l.Info("txn failed", "err", err) 545 s.pages.Notice(w, "repo", "Failed to save repository information.") 546 return 547 } 548 549 // The rollback function reverts a few things on failure: 550 // - the pending txn 551 // - the ACLs 552 // - the atproto record created 553 rollback := func() { 554 err1 := tx.Rollback() 555 err2 := s.enforcer.E.LoadPolicy() 556 err3 := rollbackRecord(context.Background(), aturi, atpClient) 557 558 // ignore txn complete errors, this is okay 559 if errors.Is(err1, sql.ErrTxDone) { 560 err1 = nil 561 } 562 563 if errs := errors.Join(err1, err2, err3); errs != nil { 564 l.Error("failed to rollback changes", "errs", errs) 565 return 566 } 567 } 568 defer rollback() 569 570 client, err := s.oauth.ServiceClient( 571 r, 572 oauth.WithService(domain), 573 oauth.WithLxm(tangled.RepoCreateNSID), 574 oauth.WithDev(s.config.Core.Dev), 575 ) 576 if err != nil { 577 l.Error("service auth failed", "err", err) 578 s.pages.Notice(w, "repo", "Failed to reach PDS.") 579 return 580 } 581 582 xe := tangled.RepoCreate( 583 r.Context(), 584 client, 585 &tangled.RepoCreate_Input{ 586 Rkey: rkey, 587 }, 588 ) 589 if err := xrpcclient.HandleXrpcErr(xe); err != nil { 590 l.Error("xrpc error", "xe", xe) 591 s.pages.Notice(w, "repo", err.Error()) 592 return 593 } 594 595 err = db.AddRepo(tx, repo) 596 if err != nil { 597 l.Error("db write failed", "err", err) 598 s.pages.Notice(w, "repo", "Failed to save repository information.") 599 return 600 } 601 602 // acls 603 p, _ := securejoin.SecureJoin(user.Did, repoName) 604 err = s.enforcer.AddRepo(user.Did, domain, p) 605 if err != nil { 606 l.Error("acl setup failed", "err", err) 607 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 608 return 609 } 610 611 err = tx.Commit() 612 if err != nil { 613 l.Error("txn commit failed", "err", err) 614 http.Error(w, err.Error(), http.StatusInternalServerError) 615 return 616 } 617 618 err = s.enforcer.E.SavePolicy() 619 if err != nil { 620 l.Error("acl save failed", "err", err) 621 http.Error(w, err.Error(), http.StatusInternalServerError) 622 return 623 } 624 625 // reset the ATURI because the transaction completed successfully 626 aturi = "" 627 628 s.notifier.NewRepo(r.Context(), repo) 629 s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName)) 630 } 631} 632 633// this is used to rollback changes made to the PDS 634// 635// it is a no-op if the provided ATURI is empty 636func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 637 if aturi == "" { 638 return nil 639 } 640 641 parsed := syntax.ATURI(aturi) 642 643 collection := parsed.Collection().String() 644 repo := parsed.Authority().String() 645 rkey := parsed.RecordKey().String() 646 647 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 648 Collection: collection, 649 Repo: repo, 650 Rkey: rkey, 651 }) 652 return err 653} 654 655func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error { 656 defaultLabels, err := db.GetLabelDefinitions(e, orm.FilterIn("at_uri", defaults)) 657 if err != nil { 658 return err 659 } 660 // already present 661 if len(defaultLabels) == len(defaults) { 662 return nil 663 } 664 665 labelDefs, err := models.FetchLabelDefs(r, defaults) 666 if err != nil { 667 return err 668 } 669 670 // Insert each label definition to the database 671 for _, labelDef := range labelDefs { 672 _, err = db.AddLabelDefinition(e, &labelDef) 673 if err != nil { 674 return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err) 675 } 676 } 677 678 return nil 679}