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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
module ImageCache
|
|
3
|
+
EXT_BY_TYPE = {
|
|
4
|
+
"image/png" => "png",
|
|
5
|
+
"image/jpeg" => "jpg",
|
|
6
|
+
"image/jpg" => "jpg",
|
|
7
|
+
"image/webp" => "webp",
|
|
8
|
+
"image/gif" => "gif"
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
ALLOWED_CONTENT_TYPES = EXT_BY_TYPE.keys.freeze
|
|
12
|
+
|
|
13
|
+
# Per-call cap on the bytes we'll fetch from a remote source_url. 50MB
|
|
14
|
+
# covers high-res photos comfortably; anything larger should be uploaded
|
|
15
|
+
# via source_path (local file) to bypass this cap intentionally.
|
|
16
|
+
MAX_REMOTE_BYTES = 50 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
class InvalidSourceURL < ArgumentError; end
|
|
19
|
+
class UnsupportedContentType < ArgumentError; end
|
|
20
|
+
class SourceTooLarge < StandardError; end
|
|
21
|
+
|
|
22
|
+
# Caches an image at S3 under a folder per owner. Every call stores the
|
|
23
|
+
# unmodified source as variant "original", plus one resized variant per
|
|
24
|
+
# entry in widths.
|
|
25
|
+
#
|
|
26
|
+
# Layout:
|
|
27
|
+
# {key_prefix}/original.{ext}
|
|
28
|
+
# {key_prefix}/{width}.{ext}
|
|
29
|
+
#
|
|
30
|
+
# Source: provide EITHER source_url (HTTP fetch) OR source_path (local
|
|
31
|
+
# file). source_url is recorded on each ImageCache row regardless — for
|
|
32
|
+
# source_path callers, pass the original URL too if you want it tracked.
|
|
33
|
+
#
|
|
34
|
+
# source_url is validated against SSRF: scheme must be http/https,
|
|
35
|
+
# host must not be loopback/private/link-local/metadata-IP, and
|
|
36
|
+
# well-known internal hostnames (localhost, *.local, *.internal) are
|
|
37
|
+
# rejected. This does NOT defend against DNS rebinding — strong
|
|
38
|
+
# protection there requires resolving DNS once then passing the
|
|
39
|
+
# resolved IP to the HTTP client.
|
|
40
|
+
#
|
|
41
|
+
# Idempotent: variants already present in ImageCache are skipped. If
|
|
42
|
+
# nothing is missing, the source is never read.
|
|
43
|
+
def self.cache!(owner:, purpose:, key_prefix:, widths:, source_url: nil, source_path: nil, content_type: "image/png")
|
|
44
|
+
raise ArgumentError, "either source_url or source_path is required" if source_url.nil? && source_path.nil?
|
|
45
|
+
|
|
46
|
+
unless ALLOWED_CONTENT_TYPES.include?(content_type)
|
|
47
|
+
raise UnsupportedContentType, "content_type #{content_type.inspect} not in allowlist (#{ALLOWED_CONTENT_TYPES.join(", ")})"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
validate_source_url!(source_url) if source_url
|
|
51
|
+
|
|
52
|
+
ext = EXT_BY_TYPE[content_type]
|
|
53
|
+
requested = ["original", *widths.map(&:to_s)]
|
|
54
|
+
|
|
55
|
+
existing = ::ImageCache.where(owner: owner, purpose: purpose).index_by(&:variant)
|
|
56
|
+
missing = requested - existing.keys
|
|
57
|
+
return existing if missing.empty?
|
|
58
|
+
|
|
59
|
+
require "mini_magick"
|
|
60
|
+
|
|
61
|
+
body = if source_path
|
|
62
|
+
File.binread(source_path)
|
|
63
|
+
else
|
|
64
|
+
fetch_remote(source_url)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
missing.each do |variant|
|
|
68
|
+
if variant == "original"
|
|
69
|
+
payload = body
|
|
70
|
+
s3_key = "#{key_prefix}/original.#{ext}"
|
|
71
|
+
else
|
|
72
|
+
img = MiniMagick::Image.read(body)
|
|
73
|
+
# Cap ImageMagick resources per-invocation to prevent decompression-bomb DoS.
|
|
74
|
+
img.combine_options do |c|
|
|
75
|
+
c.limit "memory", "256MB"
|
|
76
|
+
c.limit "map", "512MB"
|
|
77
|
+
c.limit "width", "16KP" # 16k pixel max width
|
|
78
|
+
c.limit "height", "16KP"
|
|
79
|
+
c.resize "#{variant}x"
|
|
80
|
+
end
|
|
81
|
+
payload = img.to_blob
|
|
82
|
+
s3_key = "#{key_prefix}/#{variant}.#{ext}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Studio::S3.upload(
|
|
86
|
+
key: s3_key,
|
|
87
|
+
body: payload,
|
|
88
|
+
content_type: content_type,
|
|
89
|
+
cache_control: "public, max-age=31536000, immutable"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
existing[variant] = ::ImageCache.create!(
|
|
93
|
+
owner: owner,
|
|
94
|
+
purpose: purpose,
|
|
95
|
+
variant: variant,
|
|
96
|
+
s3_key: s3_key,
|
|
97
|
+
source_url: source_url,
|
|
98
|
+
bytes: payload.bytesize,
|
|
99
|
+
content_type: content_type
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
existing
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# SSRF guard for remote source_url. Raises InvalidSourceURL on anything
|
|
107
|
+
# that looks like an attempt to reach internal services.
|
|
108
|
+
def self.validate_source_url!(url)
|
|
109
|
+
require "uri"
|
|
110
|
+
uri = URI.parse(url)
|
|
111
|
+
unless %w[http https].include?(uri.scheme)
|
|
112
|
+
raise InvalidSourceURL, "URL scheme must be http or https, got #{uri.scheme.inspect}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
host = uri.host.to_s.downcase
|
|
116
|
+
raise InvalidSourceURL, "URL missing host: #{url.inspect}" if host.empty?
|
|
117
|
+
|
|
118
|
+
# Hostname-based blocklist (catches common internal hostnames before any DNS).
|
|
119
|
+
if host == "localhost" || host.end_with?(".local") || host.end_with?(".internal") || host.end_with?(".lan")
|
|
120
|
+
raise InvalidSourceURL, "URL points to internal hostname: #{host.inspect}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# If the host is a literal IP address, check ranges.
|
|
124
|
+
bracketed = host.start_with?("[") && host.end_with?("]")
|
|
125
|
+
ip_host = bracketed ? host[1..-2] : host
|
|
126
|
+
ipv4_like = ip_host.match?(/\A\d{1,3}(\.\d{1,3}){3}\z/)
|
|
127
|
+
ipv6_like = bracketed || ip_host.include?(":")
|
|
128
|
+
if ipv4_like || ipv6_like
|
|
129
|
+
require "ipaddr"
|
|
130
|
+
begin
|
|
131
|
+
ip = IPAddr.new(ip_host)
|
|
132
|
+
rescue IPAddr::Error => e
|
|
133
|
+
raise InvalidSourceURL, "Malformed IP host #{ip_host.inspect}: #{e.message}"
|
|
134
|
+
end
|
|
135
|
+
if ip.loopback? || ip.private? || ip.link_local? || ip_host == "169.254.169.254" || ip.to_s == "0.0.0.0" || ip.to_s == "::"
|
|
136
|
+
raise InvalidSourceURL, "URL points to internal/private IP: #{ip_host}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
uri
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.fetch_remote(source_url)
|
|
144
|
+
require "open-uri"
|
|
145
|
+
body = URI.open(source_url, read_timeout: 30, redirect: true).read
|
|
146
|
+
if body.bytesize > MAX_REMOTE_BYTES
|
|
147
|
+
raise SourceTooLarge, "remote payload #{body.bytesize} bytes exceeds cap #{MAX_REMOTE_BYTES}"
|
|
148
|
+
end
|
|
149
|
+
body
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
data/lib/studio/s3.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
module S3
|
|
3
|
+
class Error < StandardError; end
|
|
4
|
+
class NotConfigured < Error; end
|
|
5
|
+
|
|
6
|
+
class << self
|
|
7
|
+
def upload(key:, body:, content_type: nil, cache_control: nil)
|
|
8
|
+
opts = { bucket: bucket, key: key, body: body }
|
|
9
|
+
opts[:content_type] = content_type if content_type
|
|
10
|
+
opts[:cache_control] = cache_control if cache_control
|
|
11
|
+
client.put_object(**opts)
|
|
12
|
+
url(key: key)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def download(key:)
|
|
16
|
+
client.get_object(bucket: bucket, key: key).body.read
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def url(key:)
|
|
20
|
+
"https://#{bucket}.s3.#{region}.amazonaws.com/#{key}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def signed_url(key:, expires_in: 3600)
|
|
24
|
+
require "aws-sdk-s3"
|
|
25
|
+
Aws::S3::Presigner.new(client: client).presigned_url(:get_object, bucket: bucket, key: key, expires_in: expires_in)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def exists?(key:)
|
|
29
|
+
client.head_object(bucket: bucket, key: key)
|
|
30
|
+
true
|
|
31
|
+
rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def delete(key:)
|
|
36
|
+
client.delete_object(bucket: bucket, key: key)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def list(prefix: nil, max: 1000)
|
|
40
|
+
resp = client.list_objects_v2(bucket: bucket, prefix: prefix, max_keys: max)
|
|
41
|
+
resp.contents.map(&:key)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def bucket
|
|
45
|
+
prefix = Studio.s3_bucket_prefix
|
|
46
|
+
raise NotConfigured, "Studio.s3_bucket_prefix not set in config/initializers/studio.rb" if prefix.nil? || prefix.empty?
|
|
47
|
+
"#{prefix}-#{environment}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def region
|
|
51
|
+
Studio.s3_region
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def client
|
|
55
|
+
@client ||= begin
|
|
56
|
+
require "aws-sdk-s3"
|
|
57
|
+
Aws::S3::Client.new(region: region)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def reset!
|
|
62
|
+
@client = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def environment
|
|
68
|
+
defined?(Rails) && Rails.env.production? ? "production" : "dev"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
class ThemeResolver
|
|
3
|
+
ROLES = %i[primary dark light success warning danger accent].freeze
|
|
4
|
+
|
|
5
|
+
attr_reader :colors
|
|
6
|
+
|
|
7
|
+
# colors: hash of role => hex string (e.g. { primary: "#8E82FE", dark: "#1A1535", ... })
|
|
8
|
+
def initialize(colors = {})
|
|
9
|
+
@colors = colors.symbolize_keys
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_css
|
|
13
|
+
dark_vars = dark_mode_vars.map { |k, v| " #{k}: #{v};" }.join("\n")
|
|
14
|
+
light_vars = light_mode_vars.map { |k, v| " #{k}: #{v};" }.join("\n")
|
|
15
|
+
palette_vars = primary_palette_vars.map { |k, v| " #{k}: #{v};" }.join("\n")
|
|
16
|
+
|
|
17
|
+
<<~CSS
|
|
18
|
+
:root, .dark {
|
|
19
|
+
#{dark_vars}
|
|
20
|
+
#{palette_vars}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
html:not(.dark) {
|
|
24
|
+
#{light_vars}
|
|
25
|
+
#{palette_vars}
|
|
26
|
+
}
|
|
27
|
+
CSS
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def dark_mode_vars
|
|
31
|
+
dark_base = colors[:dark] || "#1A1535"
|
|
32
|
+
primary = colors[:primary] || "#8E82FE"
|
|
33
|
+
border_rgb = ColorScale.lighten(dark_base, 0.30)
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
"--color-page" => dark_base,
|
|
37
|
+
"--color-surface" => ColorScale.lighten(dark_base, 0.15),
|
|
38
|
+
"--color-surface-alt" => ColorScale.darken(dark_base, 0.14),
|
|
39
|
+
"--color-inset" => ColorScale.darken(dark_base, 0.43),
|
|
40
|
+
"--color-text" => "#ffffff",
|
|
41
|
+
"--color-text-body" => "#e2e8f0",
|
|
42
|
+
"--color-text-secondary" => "#94a3b8",
|
|
43
|
+
"--color-text-muted" => "#64748b",
|
|
44
|
+
"--color-border" => ColorScale.with_opacity(border_rgb, 0.2),
|
|
45
|
+
"--color-border-strong" => ColorScale.with_opacity(border_rgb, 0.4),
|
|
46
|
+
"--color-shadow" => "transparent",
|
|
47
|
+
"--color-cta" => primary,
|
|
48
|
+
"--color-cta-hover" => ColorScale.darken(primary, 0.30),
|
|
49
|
+
"--color-success" => colors[:success] || "#4BAF50",
|
|
50
|
+
"--color-warning" => colors[:warning] || "#FF7C47",
|
|
51
|
+
"--color-danger" => colors[:danger] || "#EF4444"
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Generate --color-primary-{50..900} + RGB variants for Tailwind opacity support
|
|
56
|
+
def primary_palette_vars
|
|
57
|
+
primary = colors[:primary] || "#8E82FE"
|
|
58
|
+
scale = ColorScale.generate(primary)
|
|
59
|
+
vars = {}
|
|
60
|
+
|
|
61
|
+
scale.each do |shade, hex|
|
|
62
|
+
vars["--color-primary-#{shade}"] = hex
|
|
63
|
+
r, g, b = ColorScale.hex_to_rgb(hex)
|
|
64
|
+
vars["--color-primary-#{shade}-rgb"] = "#{r} #{g} #{b}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# DEFAULT aliases
|
|
68
|
+
r, g, b = ColorScale.hex_to_rgb(primary)
|
|
69
|
+
vars["--color-primary"] = primary
|
|
70
|
+
vars["--color-primary-rgb"] = "#{r} #{g} #{b}"
|
|
71
|
+
|
|
72
|
+
vars
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def light_mode_vars
|
|
76
|
+
light_base = colors[:light] || "#f8fafc"
|
|
77
|
+
primary = colors[:primary] || "#8E82FE"
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
"--color-page" => light_base,
|
|
81
|
+
"--color-surface" => "#ffffff",
|
|
82
|
+
"--color-surface-alt" => ColorScale.darken(light_base, 0.03),
|
|
83
|
+
"--color-inset" => ColorScale.darken(light_base, 0.08),
|
|
84
|
+
"--color-text" => "#0f172a",
|
|
85
|
+
"--color-text-body" => "#334155",
|
|
86
|
+
"--color-text-secondary" => "#64748b",
|
|
87
|
+
"--color-text-muted" => "#94a3b8",
|
|
88
|
+
"--color-border" => ColorScale.darken(light_base, 0.08),
|
|
89
|
+
"--color-border-strong" => ColorScale.darken(light_base, 0.15),
|
|
90
|
+
"--color-shadow" => "rgba(0,0,0,0.05)",
|
|
91
|
+
"--color-cta" => primary,
|
|
92
|
+
"--color-cta-hover" => ColorScale.darken(primary, 0.30),
|
|
93
|
+
"--color-success" => colors[:success] || "#4BAF50",
|
|
94
|
+
"--color-warning" => colors[:warning] || "#FF7C47",
|
|
95
|
+
"--color-danger" => colors[:danger] || "#EF4444"
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
class UsernameGenerator
|
|
3
|
+
def self.generate
|
|
4
|
+
2.times do
|
|
5
|
+
candidate = build_name
|
|
6
|
+
return candidate unless User.exists?(username: candidate)
|
|
7
|
+
end
|
|
8
|
+
"#{build_name}-#{rand(1000..9999)}"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.build_name
|
|
12
|
+
plant = Faker::Food.send(%i[vegetables fruits].sample)
|
|
13
|
+
animal = Faker::Creature::Animal.name
|
|
14
|
+
"#{sanitize(plant)}-#{sanitize(animal)}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.sanitize(str)
|
|
18
|
+
str.downcase.gsub(/[^a-z0-9]/, "-").gsub(/-+/, "-").gsub(/^-|-$/, "")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Entry point for the `studio-engine` gem. The actual code lives in
|
|
2
|
+
# `lib/studio.rb` (which exports the `Studio` module). This shim exists so
|
|
3
|
+
# `gem "studio-engine"` in a Gemfile loads correctly without consumers
|
|
4
|
+
# needing to add `require: "studio"`.
|
|
5
|
+
require_relative "studio"
|
data/lib/studio.rb
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
require "studio/version"
|
|
2
|
+
require "studio/engine"
|
|
3
|
+
require "studio/color_scale"
|
|
4
|
+
require "studio/theme_resolver"
|
|
5
|
+
require "studio/username_generator"
|
|
6
|
+
require "studio/s3"
|
|
7
|
+
require "studio/image_cache"
|
|
8
|
+
|
|
9
|
+
module Studio
|
|
10
|
+
mattr_accessor :app_name, default: "Studio"
|
|
11
|
+
mattr_accessor :session_key, default: :user_id
|
|
12
|
+
mattr_accessor :welcome_message, default: ->(user) { "Welcome, #{user.display_name}!" }
|
|
13
|
+
mattr_accessor :registration_params, default: [:name, :email, :password, :password_confirmation]
|
|
14
|
+
mattr_accessor :configure_new_user, default: ->(user) {}
|
|
15
|
+
mattr_accessor :configure_sso_user, default: ->(user) {}
|
|
16
|
+
mattr_accessor :sso_logo, default: nil
|
|
17
|
+
mattr_accessor :theme_logos, default: []
|
|
18
|
+
|
|
19
|
+
# Theme role colors (7 roles)
|
|
20
|
+
mattr_accessor :theme_primary, default: "#8E82FE"
|
|
21
|
+
mattr_accessor :theme_dark, default: "#1A1535"
|
|
22
|
+
mattr_accessor :theme_light, default: "#f8fafc"
|
|
23
|
+
mattr_accessor :theme_success, default: "#4BAF50"
|
|
24
|
+
mattr_accessor :theme_warning, default: "#FF7C47"
|
|
25
|
+
mattr_accessor :theme_danger, default: "#EF4444"
|
|
26
|
+
mattr_accessor :theme_accent, default: "#F72585"
|
|
27
|
+
|
|
28
|
+
# S3 / object storage — host apps MUST set s3_bucket_prefix explicitly in
|
|
29
|
+
# config/initializers/studio.rb before any S3-touching code runs (ImageCache,
|
|
30
|
+
# Studio::S3.upload, etc.). Default is nil so external users don't accidentally
|
|
31
|
+
# target someone else's bucket if they forget to configure.
|
|
32
|
+
#
|
|
33
|
+
# Bucket name resolves to "#{s3_bucket_prefix}-#{Rails.env.production? ? 'production' : 'dev'}".
|
|
34
|
+
mattr_accessor :s3_bucket_prefix, default: nil
|
|
35
|
+
mattr_accessor :s3_region, default: "us-east-2"
|
|
36
|
+
|
|
37
|
+
class S3ConfigError < StandardError; end
|
|
38
|
+
|
|
39
|
+
# Whether to validate the host app's User model at boot. See docs/USER_CONTRACT.md.
|
|
40
|
+
# Set to false in config/initializers/studio.rb to bypass (e.g. during migrations
|
|
41
|
+
# that intentionally break the contract).
|
|
42
|
+
mattr_accessor :validate_user_contract, default: true
|
|
43
|
+
|
|
44
|
+
# Only methods that consumers must explicitly define are checked here.
|
|
45
|
+
# Column accessors (#email, #name, #role) are NOT validated because
|
|
46
|
+
# ActiveRecord defines them lazily — they don't appear on `.instance_methods`
|
|
47
|
+
# until the schema is introspected (typically first record access). Missing
|
|
48
|
+
# columns are caught by the User table schema, not by this validator.
|
|
49
|
+
REQUIRED_USER_INSTANCE_METHODS = %i[authenticate admin? display_name].freeze
|
|
50
|
+
REQUIRED_USER_CLASS_METHODS = %i[find_by].freeze
|
|
51
|
+
|
|
52
|
+
class UserContractError < StandardError; end
|
|
53
|
+
|
|
54
|
+
def self.configure
|
|
55
|
+
yield self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Verifies that the host app's User model satisfies the engine's expected
|
|
59
|
+
# contract. Raises Studio::UserContractError with a clear pointer to
|
|
60
|
+
# docs/USER_CONTRACT.md if anything required is missing. Called from
|
|
61
|
+
# Engine#after_initialize. Opt out via Studio.validate_user_contract = false.
|
|
62
|
+
def self.validate_user_contract!(user_class)
|
|
63
|
+
return unless validate_user_contract
|
|
64
|
+
return unless user_class.is_a?(Class)
|
|
65
|
+
|
|
66
|
+
missing = []
|
|
67
|
+
REQUIRED_USER_CLASS_METHODS.each do |m|
|
|
68
|
+
missing << "User.#{m}" unless user_class.respond_to?(m)
|
|
69
|
+
end
|
|
70
|
+
REQUIRED_USER_INSTANCE_METHODS.each do |m|
|
|
71
|
+
missing << "User##{m}" unless user_class.instance_methods.include?(m)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
return if missing.empty?
|
|
75
|
+
|
|
76
|
+
raise UserContractError, <<~MSG
|
|
77
|
+
The studio-engine gem's expected User model contract is not satisfied.
|
|
78
|
+
|
|
79
|
+
Missing: #{missing.join(", ")}
|
|
80
|
+
|
|
81
|
+
See the USER_CONTRACT.md doc in the studio-engine repo for the full
|
|
82
|
+
contract + a minimal compliant example:
|
|
83
|
+
https://github.com/amcritchie/studio-engine/blob/main/docs/USER_CONTRACT.md
|
|
84
|
+
|
|
85
|
+
To bypass this check temporarily, set Studio.validate_user_contract = false
|
|
86
|
+
in config/initializers/studio.rb.
|
|
87
|
+
MSG
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.theme_config
|
|
91
|
+
{
|
|
92
|
+
primary: theme_primary,
|
|
93
|
+
dark: theme_dark,
|
|
94
|
+
light: theme_light,
|
|
95
|
+
success: theme_success,
|
|
96
|
+
warning: theme_warning,
|
|
97
|
+
danger: theme_danger,
|
|
98
|
+
accent: theme_accent
|
|
99
|
+
}.compact
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Find a logo from theme_logos by title, with fallback chain:
|
|
103
|
+
# 1. Exact title match
|
|
104
|
+
# 2. "Navbar Logo" fallback
|
|
105
|
+
# 3. First logo in the list
|
|
106
|
+
def self.logo_for(title)
|
|
107
|
+
logos = theme_logos.map { |l| l.is_a?(Hash) ? l : { file: l, title: l } }
|
|
108
|
+
entry = logos.find { |l| l[:title] == title }
|
|
109
|
+
entry ||= logos.find { |l| l[:title] == "Navbar Logo" }
|
|
110
|
+
entry ||= logos.first
|
|
111
|
+
entry ? "/#{entry[:file]}" : nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.routes(router)
|
|
115
|
+
router.instance_exec do
|
|
116
|
+
get "login", to: "sessions#new"
|
|
117
|
+
post "login", to: "sessions#create"
|
|
118
|
+
post "sso_continue", to: "sessions#sso_continue"
|
|
119
|
+
get "sso_login", to: "sessions#sso_login"
|
|
120
|
+
get "logout", to: "sessions#destroy"
|
|
121
|
+
get "signup", to: "registrations#new"
|
|
122
|
+
post "signup", to: "registrations#create"
|
|
123
|
+
get "auth/:provider/callback", to: "omniauth_callbacks#create"
|
|
124
|
+
get "auth/failure", to: "omniauth_callbacks#failure"
|
|
125
|
+
resources :error_logs, only: [:index, :show]
|
|
126
|
+
|
|
127
|
+
# Admin
|
|
128
|
+
get "admin/theme", to: "theme_settings#edit", as: :admin_theme
|
|
129
|
+
patch "admin/theme", to: "theme_settings#update", as: :admin_theme_update
|
|
130
|
+
post "admin/theme/regenerate", to: "theme_settings#regenerate", as: :admin_theme_regenerate
|
|
131
|
+
get "admin/schema", to: "schema#index", as: :admin_schema
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require_relative "lib/studio/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "studio-engine"
|
|
5
|
+
spec.version = Studio::VERSION
|
|
6
|
+
spec.authors = ["Alex McRitchie"]
|
|
7
|
+
spec.email = ["studio-engine@mcritchie.studio"]
|
|
8
|
+
spec.summary = "Shared Rails engine providing auth, SSO, error logging, theming, and S3-backed image caching"
|
|
9
|
+
spec.description = "Studio Engine is a non-isolated Rails engine that ships an opinionated authentication + SSO contract, a polymorphic ErrorLog model, a Sluggable concern, a 7-role dynamic theme system with CSS-custom-property generation, and an S3-backed ImageCache. Used in production across the McRitchie Studio + Turf Monster apps."
|
|
10
|
+
spec.homepage = "https://github.com/amcritchie/studio-engine"
|
|
11
|
+
spec.license = "MIT"
|
|
12
|
+
spec.required_ruby_version = ">= 3.0"
|
|
13
|
+
|
|
14
|
+
spec.metadata = {
|
|
15
|
+
"homepage_uri" => "https://github.com/amcritchie/studio-engine",
|
|
16
|
+
"source_code_uri" => "https://github.com/amcritchie/studio-engine",
|
|
17
|
+
"bug_tracker_uri" => "https://github.com/amcritchie/studio-engine/issues",
|
|
18
|
+
"changelog_uri" => "https://github.com/amcritchie/studio-engine/blob/main/CHANGELOG.md"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
spec.files = Dir["lib/**/*", "app/**/*", "config/**/*", "tailwind/**/*", "Gemfile", "studio-engine.gemspec", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
22
|
+
spec.require_paths = ["lib"]
|
|
23
|
+
|
|
24
|
+
spec.add_dependency "rails", ">= 7.0"
|
|
25
|
+
spec.add_dependency "tailwindcss-rails", "~> 2.7"
|
|
26
|
+
spec.add_dependency "faker", ">= 2.0"
|
|
27
|
+
spec.add_dependency "solid_queue"
|
|
28
|
+
spec.add_dependency "aws-sdk-s3", "~> 1.218"
|
|
29
|
+
spec.add_dependency "mini_magick", "~> 5.0"
|
|
30
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Shared Tailwind config for all Studio apps
|
|
2
|
+
// Apps spread from this in their own tailwind.config.js
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
darkMode: 'class',
|
|
6
|
+
theme: {
|
|
7
|
+
fontFamily: {
|
|
8
|
+
sans: ['Montserrat', 'system-ui', 'sans-serif'],
|
|
9
|
+
mono: ['ui-monospace', 'SFMono-Regular', 'monospace'],
|
|
10
|
+
},
|
|
11
|
+
extend: {
|
|
12
|
+
colors: {
|
|
13
|
+
// Theme-aware semantic tokens (reference CSS variables)
|
|
14
|
+
page: 'var(--color-page)',
|
|
15
|
+
surface: 'var(--color-surface)',
|
|
16
|
+
'surface-alt': 'var(--color-surface-alt)',
|
|
17
|
+
inset: 'var(--color-inset)',
|
|
18
|
+
|
|
19
|
+
// Dynamic primary palette (from theme role colors)
|
|
20
|
+
primary: {
|
|
21
|
+
DEFAULT: 'rgb(var(--color-primary-rgb) / <alpha-value>)',
|
|
22
|
+
50: 'rgb(var(--color-primary-50-rgb) / <alpha-value>)',
|
|
23
|
+
100: 'rgb(var(--color-primary-100-rgb) / <alpha-value>)',
|
|
24
|
+
200: 'rgb(var(--color-primary-200-rgb) / <alpha-value>)',
|
|
25
|
+
300: 'rgb(var(--color-primary-300-rgb) / <alpha-value>)',
|
|
26
|
+
400: 'rgb(var(--color-primary-400-rgb) / <alpha-value>)',
|
|
27
|
+
500: 'rgb(var(--color-primary-500-rgb) / <alpha-value>)',
|
|
28
|
+
600: 'rgb(var(--color-primary-600-rgb) / <alpha-value>)',
|
|
29
|
+
700: 'rgb(var(--color-primary-700-rgb) / <alpha-value>)',
|
|
30
|
+
800: 'rgb(var(--color-primary-800-rgb) / <alpha-value>)',
|
|
31
|
+
900: 'rgb(var(--color-primary-900-rgb) / <alpha-value>)',
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
mint: {
|
|
35
|
+
DEFAULT: '#06D6A0',
|
|
36
|
+
50: '#e6faf4',
|
|
37
|
+
100: '#b3f0de',
|
|
38
|
+
200: '#80e6c8',
|
|
39
|
+
300: '#4ddcb2',
|
|
40
|
+
400: '#1ad29c',
|
|
41
|
+
500: '#06D6A0',
|
|
42
|
+
600: '#05b888',
|
|
43
|
+
700: '#049a70',
|
|
44
|
+
800: '#037c58',
|
|
45
|
+
900: '#025e40',
|
|
46
|
+
},
|
|
47
|
+
navy: {
|
|
48
|
+
DEFAULT: '#1A1535',
|
|
49
|
+
50: '#e8e7ed',
|
|
50
|
+
100: '#b8b5c8',
|
|
51
|
+
200: '#8883a3',
|
|
52
|
+
300: '#58517e',
|
|
53
|
+
400: '#3a3359',
|
|
54
|
+
500: '#1A1535',
|
|
55
|
+
600: '#16122e',
|
|
56
|
+
700: '#120f27',
|
|
57
|
+
800: '#0e0c20',
|
|
58
|
+
900: '#0a0919',
|
|
59
|
+
},
|
|
60
|
+
violet: {
|
|
61
|
+
DEFAULT: '#8E82FE',
|
|
62
|
+
50: '#f0eeff',
|
|
63
|
+
100: '#EAE8FF',
|
|
64
|
+
200: '#b2aafe',
|
|
65
|
+
300: '#C5C0FE',
|
|
66
|
+
400: '#8E82FE',
|
|
67
|
+
500: '#8E82FE',
|
|
68
|
+
600: '#6558e5',
|
|
69
|
+
700: '#6558E0',
|
|
70
|
+
800: '#3b2cb3',
|
|
71
|
+
900: '#3D2FB5',
|
|
72
|
+
},
|
|
73
|
+
mist: '#F7F6FF',
|
|
74
|
+
lavender: '#E8E6F0',
|
|
75
|
+
slate: '#6B6580',
|
|
76
|
+
charcoal: '#2D2648',
|
|
77
|
+
midnight: '#120F28',
|
|
78
|
+
ember: '#FF8C69',
|
|
79
|
+
gold: '#FFD166',
|
|
80
|
+
magenta: '#F72585',
|
|
81
|
+
},
|
|
82
|
+
textColor: {
|
|
83
|
+
heading: 'var(--color-text)',
|
|
84
|
+
body: 'var(--color-text-body)',
|
|
85
|
+
secondary: 'var(--color-text-secondary)',
|
|
86
|
+
muted: 'var(--color-text-muted)',
|
|
87
|
+
},
|
|
88
|
+
borderColor: {
|
|
89
|
+
subtle: 'var(--color-border)',
|
|
90
|
+
strong: 'var(--color-border-strong)',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
}
|