studio-engine 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/Gemfile +18 -0
  4. data/LICENSE +21 -0
  5. data/README.md +93 -0
  6. data/app/controllers/concerns/studio/error_handling.rb +149 -0
  7. data/app/controllers/error_logs_controller.rb +16 -0
  8. data/app/controllers/navbar_controller.rb +6 -0
  9. data/app/controllers/omniauth_callbacks_controller.rb +17 -0
  10. data/app/controllers/registrations_controller.rb +25 -0
  11. data/app/controllers/schema_controller.rb +14 -0
  12. data/app/controllers/sessions_controller.rb +65 -0
  13. data/app/controllers/theme_settings_controller.rb +35 -0
  14. data/app/helpers/studio_theme_helper.rb +15 -0
  15. data/app/jobs/error_log_cleanup_job.rb +8 -0
  16. data/app/models/concerns/sluggable.rb +17 -0
  17. data/app/models/error_log.rb +45 -0
  18. data/app/models/image_cache.rb +11 -0
  19. data/app/models/theme_setting.rb +30 -0
  20. data/app/views/components/_admin_dropdown.html.erb +14 -0
  21. data/app/views/components/_avatar.html.erb +13 -0
  22. data/app/views/components/_avatar_cropper.html.erb +135 -0
  23. data/app/views/components/_badge.html.erb +35 -0
  24. data/app/views/components/_card.html.erb +4 -0
  25. data/app/views/components/_copy_button.html.erb +10 -0
  26. data/app/views/components/_empty_state.html.erb +7 -0
  27. data/app/views/components/_google_logo.html.erb +1 -0
  28. data/app/views/components/_input.html.erb +13 -0
  29. data/app/views/components/_json_debug.html.erb +14 -0
  30. data/app/views/components/_progress_bar.html.erb +9 -0
  31. data/app/views/components/_theme_toggle.html.erb +10 -0
  32. data/app/views/components/_theme_toggle_morph.html.erb +15 -0
  33. data/app/views/components/_user_nav.html.erb +136 -0
  34. data/app/views/error_logs/index.html.erb +76 -0
  35. data/app/views/error_logs/show.html.erb +65 -0
  36. data/app/views/layouts/_navbar.html.erb +83 -0
  37. data/app/views/layouts/studio/_flash.html.erb +256 -0
  38. data/app/views/layouts/studio/_head.html.erb +78 -0
  39. data/app/views/navbar/show.html.erb +147 -0
  40. data/app/views/registrations/new.html.erb +80 -0
  41. data/app/views/schema/index.html.erb +85 -0
  42. data/app/views/sessions/_sso_continue.html.erb +18 -0
  43. data/app/views/sessions/new.html.erb +79 -0
  44. data/app/views/theme_settings/edit.html.erb +376 -0
  45. data/lib/studio/color_scale.rb +80 -0
  46. data/lib/studio/engine.rb +12 -0
  47. data/lib/studio/image_cache.rb +152 -0
  48. data/lib/studio/s3.rb +72 -0
  49. data/lib/studio/theme_resolver.rb +99 -0
  50. data/lib/studio/username_generator.rb +21 -0
  51. data/lib/studio/version.rb +3 -0
  52. data/lib/studio-engine.rb +5 -0
  53. data/lib/studio.rb +134 -0
  54. data/studio-engine.gemspec +30 -0
  55. data/tailwind/studio.tailwind.config.js +94 -0
  56. metadata +189 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: da34668d5a20c0af381f45d37de84594937f3687db9f90cd9abb18a88e861f26
4
+ data.tar.gz: 2ce061aa2892e42dc83609e8a07848bcfbbf1180074b4ec3579068ef12b6cf28
5
+ SHA512:
6
+ metadata.gz: 49e7e91f9f786b86e9ce38c64f7b86ddb0c07de6dc908e8d5af2c03a945347e7317c90d949a34532762e628348b14f69013bdbcf9d84564722ebd09d62269009
7
+ data.tar.gz: e570b26da2e8aea1366c3539cb85cfdd160395e686e5dd331f98150b734005be105dcd28b20f158bd9a552c1eff5e9bba50c15f9aa6783f88519004fabc208f7
data/CHANGELOG.md ADDED
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Both consumer Rails apps pin to a tag in their `Gemfile`; bumping the tag is a release.
4
+
5
+ ## v0.4.1 (2026-05-17)
6
+
7
+ Pre-public-release security hardening per `SECURITY-AUDIT-2026-05-17.md`.
8
+
9
+ ### Fixed (security)
10
+ - **SSRF guard in `Studio::ImageCache.cache!`** — `source_url` is now validated: rejects schemes other than http/https, blocks loopback / private / link-local IPs (incl. AWS metadata 169.254.169.254), and blocks `localhost` / `*.local` / `*.internal` / `*.lan` hostnames. Raises `Studio::ImageCache::InvalidSourceURL`. Does not defend against DNS rebinding.
11
+ - **MIME-type allowlist in `Studio::ImageCache.cache!`** — `content_type` must be one of `ALLOWED_CONTENT_TYPES` (image/png|jpeg|jpg|webp|gif). Raises `Studio::ImageCache::UnsupportedContentType`.
12
+ - **MiniMagick resource caps per invocation** — every resize runs with `-limit memory 256MB -limit map 512MB -limit width/height 16KP` to prevent decompression-bomb DoS.
13
+ - **Remote source size cap** — `MAX_REMOTE_BYTES = 50MB`. Raises `Studio::ImageCache::SourceTooLarge` on overage.
14
+ - **`ErrorLog.capture!` no longer stores `exception.inspect`** — Ruby's default inspect for many error subclasses includes ivar dumps that can carry secrets (API keys read into locals, request bodies). Now stores a sanitized `"#<{class}: {message[0,1000]}>"` instead.
15
+
16
+ ### Changed (breaking for misconfigured users only)
17
+ - **`Studio.s3_bucket_prefix` no longer defaults to `"mcritchie-studio"`.** Default is `nil`; host apps MUST set explicitly in `config/initializers/studio.rb`. Both current consumer apps already do this — no impact in practice.
18
+ - **`Studio.UserContractError` message** now points at the correct repo URL (`amcritchie/studio-engine`, not `amcritchie/studio`).
19
+
20
+ ### Added
21
+ - `LICENSE` file (MIT) — gemspec already declared MIT but the file was missing. Required for RubyGems listing.
22
+ - Gemspec author email changed from `alex@mcritchie.studio` (personal) to `studio-engine@mcritchie.studio` (project alias).
23
+
24
+ ## v0.4.0 (2026-05-17)
25
+
26
+ ### Changed (breaking)
27
+ - **Gem renamed from `studio` to `studio-engine`.** Repo URL is now `github.com/amcritchie/studio-engine` (was `.../studio`). Consumers must update their `Gemfile`:
28
+ ```ruby
29
+ # Before:
30
+ gem "studio", git: "https://github.com/amcritchie/studio.git", tag: "v0.3.1"
31
+ # After:
32
+ gem "studio-engine", git: "https://github.com/amcritchie/studio-engine.git", tag: "v0.4.0"
33
+ ```
34
+ - The Ruby `Studio` module name is **unchanged** — all call sites (`Studio.configure`, `Studio::ErrorHandling`, `Studio::ImageCache`, etc.) keep working without code changes.
35
+ - Gem entry point at `lib/studio-engine.rb` (a thin `require_relative "studio"` shim) ensures `gem "studio-engine"` auto-requires correctly without a `require:` option in the Gemfile.
36
+
37
+ ### Added
38
+ - `LICENSE`, gemspec `metadata` (homepage / source / bugs / changelog URIs), `spec.description`, `spec.required_ruby_version` — getting ready for RubyGems publishing.
39
+
40
+ ## v0.3.1 (2026-05-17)
41
+
42
+ ### Fixed
43
+ - `Studio.validate_user_contract!` no longer checks for `User#email` (or any column-style attribute). ActiveRecord defines column accessors lazily, so they don't appear on `.instance_methods` until first record access — leading to false-positive `Studio::UserContractError` raises at boot. Only explicitly-defined methods (`authenticate`, `admin?`, `display_name`, class `find_by`) are validated. DB columns are out of scope; missing columns fail the User table migration instead.
44
+
45
+ ## v0.3.0 (2026-05-17)
46
+
47
+ ### Added
48
+ - **Shared stage-* badge palette.** New scheme aliases on `app/views/components/_badge.html.erb`: `stage-fresh` (blue), `stage-shaping` (yellow), `stage-structured` (mint), `stage-refined` (emerald), `stage-cohered` (violet), `stage-shipped` (emerald), `stage-closed` (gray). Consumer apps' News + Content stage helpers can now resolve to a unified palette instead of each picking ad-hoc scheme names per stage. Closes ecosystem-audit Tier 1 #2.
49
+ - **`Studio.validate_user_contract!`** + boot-time validator hook in `Engine#after_initialize`. Verifies host's `User` class responds to required methods (`authenticate`, `admin?`, `email`, `display_name`, plus class `find_by`). Raises `Studio::UserContractError` with a clear pointer to `docs/USER_CONTRACT.md` when something's missing — replaces the previous cryptic `NoMethodError` at first request. Opt out per-app with `Studio.validate_user_contract = false`. Closes ecosystem-audit Tier 2 #16.
50
+ - **`docs/USER_CONTRACT.md`** — full reference for required + optional User methods, recommended DB columns, and a minimal compliant model example.
51
+ - **Sentry fan-out from `ErrorLog.capture!`.** When the host app has loaded `sentry-ruby` (and Sentry is initialized via DSN), every `ErrorLog.capture!` call also sends the exception to Sentry with `error_log_slug` as a tag for cross-reference. Apps without `sentry-ruby` are unaffected — the call is guarded with `defined?(::Sentry)`. Closes ecosystem-audit Tier 2 #15.
52
+
53
+ ### Changed
54
+ - Stage badge color schemes are additive — existing `success/danger/warning/info/violet/primary/orange/emerald/gray/neutral` schemes unchanged.
55
+
56
+ ### Notes
57
+ - Both current consumer apps (mcritchie-studio, turf-monster) already satisfy the new User contract — validator is a no-op for them.
58
+
59
+ ## v0.2.4 (pre-2026-05-17)
60
+
61
+ Last release before formal versioning. Consumer apps tracked `git: main` until this point. v0.2.4 is the snapshot at the previous commit `ef738ff` ("Studio::ImageCache.cache! accepts source_path for local files"). Anything before that is in `git log`.
data/Gemfile ADDED
@@ -0,0 +1,18 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ # nokogiri 1.18.10 has 3 CVEs (1 HIGH regex-backtracking + 2 MEDIUM
6
+ # in XSLT / xmlC14NExecute). Fix is `>= 1.19.3` but nokogiri 1.19.x
7
+ # requires Ruby 3.2+. The ecosystem currently runs on Ruby 3.1.7;
8
+ # bumping Ruby is a separate effort (across both Rails apps).
9
+ #
10
+ # **Important**: this gem does NOT declare nokogiri as a runtime
11
+ # dependency in its gemspec — nokogiri comes into the consuming
12
+ # Rails app via Rails itself. Consumers running Rails on a Ruby
13
+ # version that supports nokogiri 1.19.x will resolve it. The
14
+ # studio-engine gem's own Gemfile.lock (this repo's dev env) is the
15
+ # only place still on 1.18.10.
16
+ #
17
+ # Tracked in SECURITY-AUDIT-2026-05-17.md. When the ecosystem bumps
18
+ # to Ruby 3.2+, add: gem "nokogiri", ">= 1.19.3", group: :development
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Alex McRitchie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # Studio Engine
2
+
3
+ Shared Rails engine for McRitchie apps. Provides authentication, error handling, dynamic theming, and common concerns used by [McRitchie Studio](https://app.mcritchie.studio) and [Turf Monster](https://turf.mcritchie.studio).
4
+
5
+ > **Part of the McRitchie ecosystem** — see [`ECOSYSTEM.md`](https://github.com/amcritchie/mcritchie-studio/blob/main/docs/ECOSYSTEM.md) for the 5-repo map; [`house-burn-down.md`](https://github.com/amcritchie/mcritchie-studio/blob/main/docs/agents/system/house-burn-down.md) for fresh-Mac recovery.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ # Gemfile — pin to a tag (recommended; see Releases section)
11
+ gem "studio-engine", git: "https://github.com/amcritchie/studio-engine.git", tag: "v0.3.0"
12
+ ```
13
+
14
+ Then `bundle install`. The current release is **v0.3.0**; see [`CHANGELOG.md`](./CHANGELOG.md) for the history.
15
+
16
+ > Pinning to a tag (not `main`) is now the recommended pattern. Consumer apps that track `main` will silently inherit any engine merge — bad when one merge breaks several apps at once.
17
+
18
+ ## What It Provides
19
+
20
+ - **Authentication**: Session-based login/signup controllers and views, Google OAuth via OmniAuth, one-way SSO (hub to satellite)
21
+ - **Error handling**: `Studio::ErrorHandling` concern with `rescue_and_log`, `ErrorLog` model with `capture!`, error log viewer at `/error_logs`
22
+ - **Theme system**: Dynamic CSS custom properties generated from 7 role colors (primary, dark, light, success, accent, warning, danger). Dark/light mode toggle. Admin theme editor at `/admin/theme`.
23
+ - **Sluggable concern**: `before_save :set_slug` with `to_param` for human-readable URLs
24
+ - **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
25
+
26
+ ## Configuration
27
+
28
+ Each consuming app configures the engine in `config/initializers/studio.rb`:
29
+
30
+ ```ruby
31
+ Studio.configure do |config|
32
+ config.app_name = "My App"
33
+ config.session_key = :my_app_user_id
34
+ config.sso_logo = "/logo.svg"
35
+ config.welcome_message = ->(user) { "Welcome, #{user.display_name}!" }
36
+ config.registration_params = [:name, :email, :password, :password_confirmation]
37
+ config.theme_primary = "#4BAF50" # Override default violet
38
+ config.theme_logos = ["logo.svg"]
39
+ end
40
+ ```
41
+
42
+ ## Routes
43
+
44
+ In the consuming app's `config/routes.rb`:
45
+
46
+ ```ruby
47
+ Rails.application.routes.draw do
48
+ Studio.routes(self)
49
+ # ... app routes
50
+ end
51
+ ```
52
+
53
+ This draws: `/login`, `/signup`, `/logout`, `/sso_continue`, `/sso_login`, `/auth/:provider/callback`, `/auth/failure`, `/error_logs`, `/admin/theme` (GET, PATCH), `/admin/theme/regenerate`.
54
+
55
+ ## Overriding Views
56
+
57
+ This is a non-isolated engine -- app views at the same path automatically override engine views. For example, placing `app/views/sessions/new.html.erb` in the consuming app replaces the engine's login page.
58
+
59
+ ## Releasing
60
+
61
+ Engine releases are git tags (semver: `MAJOR.MINOR.PATCH`). Both consumer apps pin to a tag in their Gemfile — bumping the tag is the release.
62
+
63
+ 1. Make + commit changes on `main`.
64
+ 2. Update [`CHANGELOG.md`](./CHANGELOG.md) with the new version + a `### Added` / `### Changed` / `### Removed` summary. Keep entries terse.
65
+ 3. Bump `lib/studio/version.rb` to match.
66
+ 4. Commit the version bump + CHANGELOG together (`v0.X.Y: <summary>`).
67
+ 5. Tag: `git tag -a v0.X.Y -m "<one-line summary>"`.
68
+ 6. Push: `git push origin main --tags`.
69
+ 7. In each consumer app's Gemfile, update the `tag:` field. Commit + push.
70
+ 8. On consumer prod: `bundle install` runs as part of the deploy buildpack.
71
+
72
+ **Semver guide**
73
+ - **PATCH**: bug fix; no API change. Consumers can bump tag with zero diff elsewhere.
74
+ - **MINOR**: backward-compatible feature add. Consumers may opt in to new APIs.
75
+ - **MAJOR**: breaking change. Consumers will need code changes alongside the tag bump.
76
+
77
+ ## Local development (against an unreleased engine)
78
+
79
+ When iterating on engine code from a consumer app, point bundler at the local path so you don't need to push + tag for every edit:
80
+
81
+ ```bash
82
+ # in the consumer app
83
+ bundle config set --local local.studio /Users/alex/projects/studio-engine
84
+ bundle install
85
+ # ... iterate in both repos ...
86
+ bundle config unset --local local.studio # restore tag-pinned resolution
87
+ ```
88
+
89
+ Note: `bundle config local.studio` requires `branch:` in the Gemfile entry. If you frequently develop locally, change the Gemfile to `gem "studio-engine", git: "...", branch: "main"` during dev and back to `tag:` before merging.
90
+
91
+ ## Development Notes
92
+
93
+ See [CLAUDE.md](./CLAUDE.md) for detailed development context including the theme architecture, SSO protocol, color scale system, and code conventions.
@@ -0,0 +1,149 @@
1
+ module Studio
2
+ module ErrorHandling
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
7
+ rescue_from StandardError, with: :handle_unexpected_error
8
+
9
+ before_action :require_authentication
10
+
11
+ helper_method :current_user, :logged_in?, :sso_user_available?, :sso_display_name, :sso_source_app, :sso_hub_logo, :admin?
12
+ end
13
+
14
+ private
15
+
16
+ def current_user
17
+ return @current_user if defined?(@current_user)
18
+
19
+ # Try app-specific session key
20
+ @current_user = User.find_by(id: session[Studio.session_key])
21
+
22
+ # Legacy migration: old shared session[:user_id]
23
+ if @current_user.nil? && session[:user_id].present? && Studio.session_key != :user_id
24
+ @current_user = User.find_by(id: session[:user_id])
25
+ if @current_user
26
+ set_app_session(@current_user) # Migrate to new key
27
+ session.delete(:user_id) # Clean up old key
28
+ end
29
+ end
30
+
31
+ @current_user
32
+ end
33
+
34
+ def set_app_session(user)
35
+ # App-specific session (only this app reads this key)
36
+ session[Studio.session_key] = user.id
37
+
38
+ # Only update shared awareness fields if this app is the source
39
+ # (don't overwrite the other app's sso_source when logging in via sso_continue)
40
+ if session[:sso_source].blank? || session[:sso_source] == Studio.app_name
41
+ session[:sso_email] = user.email
42
+ session[:sso_name] = user.try(:name)
43
+ session[:sso_provider] = user.provider
44
+ session[:sso_uid] = user.uid
45
+ session[:sso_wallet] = user.try(:wallet_address)
46
+ session[:sso_source] = Studio.app_name
47
+ session[:sso_logo] = Studio.sso_logo
48
+ end
49
+ end
50
+
51
+ def clear_app_session
52
+ session.delete(Studio.session_key)
53
+
54
+ # Clear sso_* fields only if this app is the source
55
+ # (preserve them if the other app set them — they're still logged in there)
56
+ if session[:sso_source] == Studio.app_name
57
+ session.delete(:sso_email)
58
+ session.delete(:sso_name)
59
+ session.delete(:sso_provider)
60
+ session.delete(:sso_uid)
61
+ session.delete(:sso_wallet)
62
+ session.delete(:sso_source)
63
+ session.delete(:sso_logo)
64
+ end
65
+ end
66
+
67
+ # Cross-app awareness helpers for login page
68
+ def sso_user_available?
69
+ !logged_in? && session[:sso_email].present? && session[:sso_source] != Studio.app_name
70
+ end
71
+
72
+ def sso_display_name
73
+ session[:sso_name].presence || session[:sso_email]&.split("@")&.first&.capitalize || "User"
74
+ end
75
+
76
+ def sso_source_app
77
+ session[:sso_source]
78
+ end
79
+
80
+ def sso_hub_logo
81
+ session[:sso_logo]
82
+ end
83
+
84
+ def logged_in?
85
+ current_user.present?
86
+ end
87
+
88
+ def require_authentication
89
+ unless logged_in?
90
+ redirect_to login_path
91
+ end
92
+ end
93
+
94
+ def require_admin
95
+ return redirect_to root_path, alert: "Not authorized" unless logged_in? && current_user.admin?
96
+ end
97
+
98
+ def admin?
99
+ logged_in? && current_user.admin?
100
+ end
101
+
102
+ # Central error logging method — all controller error logging flows through here.
103
+ # Returns the ErrorLog record so callers can attach target/parent context.
104
+ def create_error_log(exception)
105
+ ErrorLog.capture!(exception)
106
+ end
107
+
108
+ # Layer 2: Opt-in per-action wrapper with target/parent context.
109
+ # Sets @_error_logged flag so Layer 1 won't double-log.
110
+ def rescue_and_log(target: nil, parent: nil)
111
+ yield
112
+ rescue ActiveRecord::RecordNotFound => e
113
+ raise e
114
+ rescue StandardError => e
115
+ error_log = create_error_log(e)
116
+ if target
117
+ error_log.target = target
118
+ error_log.target_name = target.slug
119
+ end
120
+ if parent
121
+ error_log.parent = parent
122
+ error_log.parent_name = parent.slug
123
+ end
124
+ error_log.save!
125
+ @_error_logged = true
126
+ raise e
127
+ end
128
+
129
+ # Layer 1: Catch-all for RecordNotFound — 404 redirect, no logging.
130
+ def handle_not_found(exception)
131
+ respond_to do |format|
132
+ format.html { redirect_to root_path, alert: "Not found" }
133
+ format.json { render json: { error: "Not found" }, status: :not_found }
134
+ end
135
+ end
136
+
137
+ # Layer 1: Catch-all for unexpected errors — log + friendly response.
138
+ # Skips logging if rescue_and_log already captured it.
139
+ def handle_unexpected_error(exception)
140
+ create_error_log(exception) unless @_error_logged
141
+ raise exception if Rails.env.development? || Rails.env.test?
142
+
143
+ respond_to do |format|
144
+ format.html { redirect_to root_path, alert: "Something went wrong." }
145
+ format.json { render json: { error: "Internal server error" }, status: :internal_server_error }
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,16 @@
1
+ class ErrorLogsController < ApplicationController
2
+ before_action :require_admin
3
+
4
+ def index
5
+ @error_logs = ErrorLog.order(created_at: :desc)
6
+ if params[:q].present?
7
+ @error_logs = @error_logs.where("message ILIKE :q OR target_name ILIKE :q OR parent_name ILIKE :q OR target_type ILIKE :q", q: "%#{params[:q]}%")
8
+ end
9
+ @error_logs = @error_logs.limit(100)
10
+ end
11
+
12
+ def show
13
+ @error_log = ErrorLog.find_by(slug: params[:id])
14
+ return redirect_to error_logs_path, alert: "Error log not found" unless @error_log
15
+ end
16
+ end
@@ -0,0 +1,6 @@
1
+ class NavbarController < ApplicationController
2
+ before_action :require_admin
3
+
4
+ def show
5
+ end
6
+ end
@@ -0,0 +1,17 @@
1
+ class OmniauthCallbacksController < ApplicationController
2
+ skip_before_action :require_authentication
3
+
4
+ def create
5
+ user = User.from_omniauth(request.env["omniauth.auth"])
6
+ rescue_and_log(target: user) do
7
+ set_app_session(user)
8
+ redirect_to root_path, notice: "Signed in with Google!"
9
+ end
10
+ rescue StandardError => e
11
+ redirect_to login_path, alert: "Google sign-in failed. Please try again."
12
+ end
13
+
14
+ def failure
15
+ redirect_to login_path, alert: "Google sign-in failed. Please try again."
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ class RegistrationsController < ApplicationController
2
+ skip_before_action :require_authentication
3
+
4
+ def new
5
+ @user = User.new
6
+ end
7
+
8
+ def create
9
+ @user = User.new(user_params)
10
+ Studio.configure_new_user.call(@user)
11
+ rescue_and_log(target: @user) do
12
+ @user.save!
13
+ set_app_session(@user)
14
+ redirect_to root_path, notice: Studio.welcome_message.call(@user)
15
+ end
16
+ rescue StandardError => e
17
+ render :new, status: :unprocessable_entity
18
+ end
19
+
20
+ private
21
+
22
+ def user_params
23
+ params.require(:user).permit(*Studio.registration_params)
24
+ end
25
+ end
@@ -0,0 +1,14 @@
1
+ class SchemaController < ApplicationController
2
+ before_action :require_admin
3
+
4
+ def index
5
+ conn = ActiveRecord::Base.connection
6
+ @tables = (conn.tables - %w[schema_migrations ar_internal_metadata]).sort.map do |table_name|
7
+ {
8
+ name: table_name,
9
+ columns: conn.columns(table_name),
10
+ indexes: conn.indexes(table_name)
11
+ }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,65 @@
1
+ class SessionsController < ApplicationController
2
+ skip_before_action :require_authentication
3
+
4
+ def new
5
+ end
6
+
7
+ def create
8
+ user = User.find_by(email: params[:email])
9
+ if user&.authenticate(params[:password])
10
+ set_app_session(user)
11
+ redirect_to root_path, notice: "Welcome back, #{user.display_name}!"
12
+ else
13
+ flash.now[:alert] = "Invalid email or password."
14
+ render :new, status: :unprocessable_entity
15
+ end
16
+ end
17
+
18
+ # GET /sso_login — one-click SSO entry point (linked from hub app nav)
19
+ def sso_login
20
+ return redirect_to root_path if logged_in?
21
+ return redirect_to login_path unless sso_user_available?
22
+
23
+ authenticate_sso_user!
24
+ rescue StandardError => e
25
+ create_error_log(e)
26
+ redirect_to login_path, alert: "Could not continue session. Please log in."
27
+ end
28
+
29
+ # POST /sso_continue — form-based SSO from login page button
30
+ def sso_continue
31
+ return redirect_to login_path unless sso_user_available?
32
+
33
+ authenticate_sso_user!
34
+ rescue StandardError => e
35
+ create_error_log(e)
36
+ redirect_to login_path, alert: "Could not continue session. Please log in."
37
+ end
38
+
39
+ def destroy
40
+ clear_app_session
41
+ redirect_to login_path, notice: "Logged out."
42
+ end
43
+
44
+ private
45
+
46
+ def authenticate_sso_user!
47
+ user = User.find_by(email: session[:sso_email])
48
+ unless user
49
+ user = User.new(
50
+ email: session[:sso_email],
51
+ name: session[:sso_name],
52
+ provider: session[:sso_provider],
53
+ uid: session[:sso_uid],
54
+ password: SecureRandom.hex(16)
55
+ )
56
+ Studio.configure_sso_user.call(user)
57
+ rescue_and_log(target: user) do
58
+ user.save!
59
+ end
60
+ end
61
+
62
+ set_app_session(user)
63
+ redirect_to root_path, notice: Studio.welcome_message.call(user)
64
+ end
65
+ end
@@ -0,0 +1,35 @@
1
+ class ThemeSettingsController < ApplicationController
2
+ before_action :require_admin
3
+
4
+ def edit
5
+ @theme_setting = ThemeSetting.current
6
+ @defaults = Studio.theme_config
7
+ @preview_css = Studio::ThemeResolver.new(@theme_setting.resolved_colors).to_css
8
+ end
9
+
10
+ def update
11
+ @theme_setting = ThemeSetting.find_or_initialize_by(app_name: Studio.app_name)
12
+
13
+ rescue_and_log(target: @theme_setting) do
14
+ @theme_setting.update!(theme_params)
15
+ Rails.cache.delete("studio/theme/#{Studio.app_name}")
16
+ redirect_to admin_theme_path, notice: "Theme saved."
17
+ end
18
+ rescue StandardError => e
19
+ @defaults = Studio.theme_config
20
+ @preview_css = Studio::ThemeResolver.new(@theme_setting.resolved_colors).to_css
21
+ flash.now[:alert] = "Error saving theme: #{e.message}"
22
+ render :edit, status: :unprocessable_entity
23
+ end
24
+
25
+ def regenerate
26
+ Rails.cache.delete("studio/theme/#{Studio.app_name}")
27
+ redirect_to admin_theme_path, notice: "Theme cache cleared."
28
+ end
29
+
30
+ private
31
+
32
+ def theme_params
33
+ params.require(:theme_setting).permit(:primary, :accent1, :accent2, :warning, :danger, :dark, :light)
34
+ end
35
+ end
@@ -0,0 +1,15 @@
1
+ module StudioThemeHelper
2
+ def studio_theme_css_tag
3
+ css = Rails.cache.fetch("studio/theme/#{Studio.app_name}", expires_in: 1.hour) do
4
+ colors = begin
5
+ ThemeSetting.current.resolved_colors
6
+ rescue ActiveRecord::StatementInvalid
7
+ # Table doesn't exist yet (pre-migration) — use config defaults
8
+ Studio.theme_config
9
+ end
10
+ Studio::ThemeResolver.new(colors).to_css
11
+ end
12
+
13
+ tag.style(css.html_safe, nonce: content_security_policy_nonce)
14
+ end
15
+ end
@@ -0,0 +1,8 @@
1
+ class ErrorLogCleanupJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform(days_old: 30)
5
+ count = ErrorLog.where("created_at < ?", days_old.days.ago).delete_all
6
+ Rails.logger.info "[ErrorLogCleanupJob] Deleted #{count} error logs older than #{days_old} days"
7
+ end
8
+ end
@@ -0,0 +1,17 @@
1
+ module Sluggable
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ before_save :set_slug
6
+ end
7
+
8
+ def to_param
9
+ slug
10
+ end
11
+
12
+ private
13
+
14
+ def set_slug
15
+ self.slug = name_slug
16
+ end
17
+ end
@@ -0,0 +1,45 @@
1
+ class ErrorLog < ApplicationRecord
2
+ belongs_to :target, polymorphic: true, optional: true
3
+ belongs_to :parent, polymorphic: true, optional: true
4
+
5
+ def to_param
6
+ slug
7
+ end
8
+
9
+ def inspect_field
10
+ read_attribute(:inspect)
11
+ end
12
+
13
+ def self.capture!(exception)
14
+ cleaned = Rails.backtrace_cleaner.clean(exception.backtrace || [])
15
+
16
+ # Don't store exception.inspect — Ruby's default inspect for many error
17
+ # subclasses includes ivar dumps that can carry secrets (API keys read
18
+ # from ENV into a local, request bodies, etc.). Store just class +
19
+ # truncated message instead. Apps that need richer context should
20
+ # capture it explicitly via target/parent or via Sentry's PII rules.
21
+ safe_inspect = "#<#{exception.class}: #{exception.message.to_s[0, 1000]}>"
22
+
23
+ log = create!(
24
+ message: exception.message,
25
+ inspect: safe_inspect,
26
+ backtrace: cleaned.to_json
27
+ )
28
+ log.update_column(:slug, "error-log-#{log.id}")
29
+
30
+ # Fan out to Sentry if the host app loaded sentry-ruby. Guard with
31
+ # respond_to? so we don't blow up on old SDK versions. The DB ErrorLog
32
+ # row remains the local triage view; Sentry is the paging layer.
33
+ if defined?(::Sentry) && ::Sentry.respond_to?(:capture_exception)
34
+ begin
35
+ ::Sentry.capture_exception(exception) do |scope|
36
+ scope.set_tags(error_log_slug: log.slug)
37
+ end
38
+ rescue => e
39
+ Rails.logger.warn "[ErrorLog.capture!] Sentry delivery failed: #{e.class}: #{e.message}"
40
+ end
41
+ end
42
+
43
+ log
44
+ end
45
+ end
@@ -0,0 +1,11 @@
1
+ class ImageCache < ApplicationRecord
2
+ belongs_to :owner, polymorphic: true
3
+
4
+ validates :purpose, :variant, :s3_key, presence: true
5
+ validates :s3_key, uniqueness: true
6
+ validates :variant, uniqueness: { scope: [:owner_type, :owner_id, :purpose] }
7
+
8
+ def url
9
+ Studio::S3.url(key: s3_key)
10
+ end
11
+ end