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

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.

Raises:

  • (ArgumentError)


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.combine_options 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.

Raises:



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.message}"
    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