Module: Studio::ImageCache
- Defined in:
- lib/studio/image_cache.rb
Defined Under Namespace
Classes: InvalidSourceURL, SourceTooLarge, UnsupportedContentType
Constant Summary collapse
- EXT_BY_TYPE =
{ "image/png" => "png", "image/jpeg" => "jpg", "image/jpg" => "jpg", "image/webp" => "webp", "image/gif" => "gif" }.freeze
- ALLOWED_CONTENT_TYPES =
EXT_BY_TYPE.keys.freeze
- MAX_REMOTE_BYTES =
Per-call cap on the bytes we’ll fetch from a remote source_url. 50MB covers high-res photos comfortably; anything larger should be uploaded via source_path (local file) to bypass this cap intentionally.
50 * 1024 * 1024
Class Method Summary collapse
-
.cache!(owner:, purpose:, key_prefix:, widths:, source_url: nil, source_path: nil, content_type: "image/png") ⇒ Object
Caches an image at S3 under a folder per owner.
- .fetch_remote(source_url) ⇒ Object
-
.validate_source_url!(url) ⇒ Object
SSRF guard for remote source_url.
Class Method Details
.cache!(owner:, purpose:, key_prefix:, widths:, source_url: nil, source_path: nil, content_type: "image/png") ⇒ Object
Caches an image at S3 under a folder per owner. Every call stores the unmodified source as variant “original”, plus one resized variant per entry in widths.
Layout:
{key_prefix}/original.{ext}
{key_prefix}/{width}.{ext}
Source: provide EITHER source_url (HTTP fetch) OR source_path (local file). source_url is recorded on each ImageCache row regardless — for source_path callers, pass the original URL too if you want it tracked.
source_url is validated against SSRF: scheme must be http/https, host must not be loopback/private/link-local/metadata-IP, and well-known internal hostnames (localhost, *.local, *.internal) are rejected. This does NOT defend against DNS rebinding — strong protection there requires resolving DNS once then passing the resolved IP to the HTTP client.
Idempotent: variants already present in ImageCache are skipped. If nothing is missing, the source is never read.
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/studio/image_cache.rb', line 43 def self.cache!(owner:, purpose:, key_prefix:, widths:, source_url: nil, source_path: nil, content_type: "image/png") raise ArgumentError, "either source_url or source_path is required" if source_url.nil? && source_path.nil? unless ALLOWED_CONTENT_TYPES.include?(content_type) raise UnsupportedContentType, "content_type #{content_type.inspect} not in allowlist (#{ALLOWED_CONTENT_TYPES.join(", ")})" end validate_source_url!(source_url) if source_url ext = EXT_BY_TYPE[content_type] requested = ["original", *widths.map(&:to_s)] existing = ::ImageCache.where(owner: owner, purpose: purpose).index_by(&:variant) missing = requested - existing.keys return existing if missing.empty? require "mini_magick" body = if source_path File.binread(source_path) else fetch_remote(source_url) end missing.each do |variant| if variant == "original" payload = body s3_key = "#{key_prefix}/original.#{ext}" else img = MiniMagick::Image.read(body) # Cap ImageMagick resources per-invocation to prevent decompression-bomb DoS. img. do |c| c.limit "memory", "256MB" c.limit "map", "512MB" c.limit "width", "16KP" # 16k pixel max width c.limit "height", "16KP" c.resize "#{variant}x" end payload = img.to_blob s3_key = "#{key_prefix}/#{variant}.#{ext}" end Studio::S3.upload( key: s3_key, body: payload, content_type: content_type, cache_control: "public, max-age=31536000, immutable" ) existing[variant] = ::ImageCache.create!( owner: owner, purpose: purpose, variant: variant, s3_key: s3_key, source_url: source_url, bytes: payload.bytesize, content_type: content_type ) end existing end |
.fetch_remote(source_url) ⇒ Object
143 144 145 146 147 148 149 150 |
# File 'lib/studio/image_cache.rb', line 143 def self.fetch_remote(source_url) require "open-uri" body = URI.open(source_url, read_timeout: 30, redirect: true).read if body.bytesize > MAX_REMOTE_BYTES raise SourceTooLarge, "remote payload #{body.bytesize} bytes exceeds cap #{MAX_REMOTE_BYTES}" end body end |
.validate_source_url!(url) ⇒ Object
SSRF guard for remote source_url. Raises InvalidSourceURL on anything that looks like an attempt to reach internal services.
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/studio/image_cache.rb', line 108 def self.validate_source_url!(url) require "uri" uri = URI.parse(url) unless %w[http https].include?(uri.scheme) raise InvalidSourceURL, "URL scheme must be http or https, got #{uri.scheme.inspect}" end host = uri.host.to_s.downcase raise InvalidSourceURL, "URL missing host: #{url.inspect}" if host.empty? # Hostname-based blocklist (catches common internal hostnames before any DNS). if host == "localhost" || host.end_with?(".local") || host.end_with?(".internal") || host.end_with?(".lan") raise InvalidSourceURL, "URL points to internal hostname: #{host.inspect}" end # If the host is a literal IP address, check ranges. bracketed = host.start_with?("[") && host.end_with?("]") ip_host = bracketed ? host[1..-2] : host ipv4_like = ip_host.match?(/\A\d{1,3}(\.\d{1,3}){3}\z/) ipv6_like = bracketed || ip_host.include?(":") if ipv4_like || ipv6_like require "ipaddr" begin ip = IPAddr.new(ip_host) rescue IPAddr::Error => e raise InvalidSourceURL, "Malformed IP host #{ip_host.inspect}: #{e.}" end if ip.loopback? || ip.private? || ip.link_local? || ip_host == "169.254.169.254" || ip.to_s == "0.0.0.0" || ip.to_s == "::" raise InvalidSourceURL, "URL points to internal/private IP: #{ip_host}" end end uri end |