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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +61 -0
- data/Gemfile +18 -0
- data/LICENSE +21 -0
- data/README.md +93 -0
- data/app/controllers/concerns/studio/error_handling.rb +149 -0
- data/app/controllers/error_logs_controller.rb +16 -0
- data/app/controllers/navbar_controller.rb +6 -0
- data/app/controllers/omniauth_callbacks_controller.rb +17 -0
- data/app/controllers/registrations_controller.rb +25 -0
- data/app/controllers/schema_controller.rb +14 -0
- data/app/controllers/sessions_controller.rb +65 -0
- data/app/controllers/theme_settings_controller.rb +35 -0
- data/app/helpers/studio_theme_helper.rb +15 -0
- data/app/jobs/error_log_cleanup_job.rb +8 -0
- data/app/models/concerns/sluggable.rb +17 -0
- data/app/models/error_log.rb +45 -0
- data/app/models/image_cache.rb +11 -0
- data/app/models/theme_setting.rb +30 -0
- data/app/views/components/_admin_dropdown.html.erb +14 -0
- data/app/views/components/_avatar.html.erb +13 -0
- data/app/views/components/_avatar_cropper.html.erb +135 -0
- data/app/views/components/_badge.html.erb +35 -0
- data/app/views/components/_card.html.erb +4 -0
- data/app/views/components/_copy_button.html.erb +10 -0
- data/app/views/components/_empty_state.html.erb +7 -0
- data/app/views/components/_google_logo.html.erb +1 -0
- data/app/views/components/_input.html.erb +13 -0
- data/app/views/components/_json_debug.html.erb +14 -0
- data/app/views/components/_progress_bar.html.erb +9 -0
- data/app/views/components/_theme_toggle.html.erb +10 -0
- data/app/views/components/_theme_toggle_morph.html.erb +15 -0
- data/app/views/components/_user_nav.html.erb +136 -0
- data/app/views/error_logs/index.html.erb +76 -0
- data/app/views/error_logs/show.html.erb +65 -0
- data/app/views/layouts/_navbar.html.erb +83 -0
- data/app/views/layouts/studio/_flash.html.erb +256 -0
- data/app/views/layouts/studio/_head.html.erb +78 -0
- data/app/views/navbar/show.html.erb +147 -0
- data/app/views/registrations/new.html.erb +80 -0
- data/app/views/schema/index.html.erb +85 -0
- data/app/views/sessions/_sso_continue.html.erb +18 -0
- data/app/views/sessions/new.html.erb +79 -0
- data/app/views/theme_settings/edit.html.erb +376 -0
- data/lib/studio/color_scale.rb +80 -0
- data/lib/studio/engine.rb +12 -0
- data/lib/studio/image_cache.rb +152 -0
- data/lib/studio/s3.rb +72 -0
- data/lib/studio/theme_resolver.rb +99 -0
- data/lib/studio/username_generator.rb +21 -0
- data/lib/studio/version.rb +3 -0
- data/lib/studio-engine.rb +5 -0
- data/lib/studio.rb +134 -0
- data/studio-engine.gemspec +30 -0
- data/tailwind/studio.tailwind.config.js +94 -0
- 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,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,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
|