studio-engine 0.82.0 → 0.82.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: baeae6743d5d1c8b5f2157367e1ca7df20e40318cfbeed9961360df5cd3a86b8
4
- data.tar.gz: 965e67b42b3e04a07b4607dee0994b5b69c8f76dccb648ae4947e26c5d43c5e1
3
+ metadata.gz: ad8be51b6366f71e083ae5bb7ce69e83e08330b826f6acb6027692e0857f9b9a
4
+ data.tar.gz: 216b21e17a79e2f635bfbdbb3996607cfa767ca1e06cac11e63b337c9864771a
5
5
  SHA512:
6
- metadata.gz: 881ca49a5a383eceae0cebc3a8fd4ecfe1ddafdefe4591e4afed541c205b74346d2b590c54dc1635d7c3c2e96b61714a9304de2ce9886016a1c765a440b2eda9
7
- data.tar.gz: 1df9079552520f39f9659ce644044342253eed53853fe1145fe0e0ed35c5dd5517d21312557de8ac55494c91acb68912d9b8fa7981275ed3b309e271fb662868
6
+ metadata.gz: 06e416fbb35ec33fdaa8c0cceb76d994196dcb6898992cc31d1135f67f3e0804dab3c0e7eb4230bfe162a78adc348423d17e9cc7d484556b04eac0b46f18a7c7
7
+ data.tar.gz: 63334a9f39dde930606ebf7b23943287ff4d94bce68afcd4718c30cfc8838c1b52cef6f2f791474ec744db3ebfb66a6ef14cc5eebd1aa4894adbf651385666b4
data/CHANGELOG.md CHANGED
@@ -4,6 +4,43 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.82.1 — 2026-09-30
8
+
9
+ ### Fixed
10
+
11
+ - **Link previews now reach iMessage from a Rails 8 app.** Rails 8's
12
+ `allow_browser versions: :modern` answered iMessage's fetcher, which sends a
13
+ Safari 9.0.1 User-Agent, with a `406`, so no preview was drawn. This was live on
14
+ mcritchie.studio and cyvasse.xyz. `include Studio::LinkPreviewBots` now exempts a
15
+ preview fetcher's GET or HEAD from every `allow_browser` guard on the controller,
16
+ with no app code. It overrides the private method Rails' guard calls, so it
17
+ covers a guard declared anywhere in the controller chain. A POST, an old browser
18
+ that is not a fetcher, and every person are still checked. An app that already
19
+ wrote `allow_browser ..., unless: :link_preview_bot_request?` (the hub, cyvasse)
20
+ keeps working and can drop the `unless:`.
21
+ - **A failed slim render is logged to `ErrorLog`.** `Studio::LinkPreviewBots` now
22
+ captures it with `ErrorLog.capture!` instead of a log line, and still serves the
23
+ fetcher the full page.
24
+ - **`rails g studio:site_identity` indents the include it writes** into
25
+ `ApplicationController`. It landed at column 0 in cyvasse.
26
+
27
+ ### Added
28
+
29
+ - **`og:image:width`, `og:image:height` and `og:image:alt`.** The size tags appear
30
+ when the image's size is known without a fetch: an analyzed Active Storage blob
31
+ (the operator's image or a page's attachment) or the static fallback file, whose
32
+ PNG, JPEG, GIF or WebP header is read. The alt is the page's new
33
+ `link_preview image_alt:`, else the card's title; `twitter:image:alt` matches.
34
+ The site identity cache key moves to `v2`, so the sizes appear without waiting
35
+ out the old entry.
36
+ - **`config.link_preview_base_url`** pins the canonical base for previews. When
37
+ set, `og:url` is that base plus the path with no query string, and relative
38
+ images are made absolute on it, so a `herokuapp.com` host or a `?utm_` link does
39
+ not split one page's share count. The default, `nil`, keeps the request's URL.
40
+ - [`docs/LINK_PREVIEW.md`](docs/LINK_PREVIEW.md) gains *The allow_browser 406
41
+ trap*, with a curl check that uses the iMessage User-Agent, and notes Applebot's
42
+ slim page as a known trade-off.
43
+
7
44
  ## 0.82.0 — 2026-09-30
8
45
 
9
46
  ### Added
@@ -27,6 +27,14 @@ module Studio
27
27
  #
28
28
  # People, in-app browsers and unknown agents are never matched and always get
29
29
  # the full page. The allow-list is Studio::LinkPreview::BOT_TOKENS.
30
+ #
31
+ # ALLOW_BROWSER: Rails 8's `allow_browser versions: :modern` (in every new
32
+ # app's ApplicationController) answers Apple's LinkPresentation — which sends a
33
+ # Safari 9.0.1 User-Agent — with a 406, so no iMessage preview. Including this
34
+ # concern exempts preview fetchers from every allow_browser guard on the
35
+ # controller, GET and HEAD only, with no app code. An app that already wrote
36
+ # `allow_browser ..., unless: :link_preview_bot_request?` keeps working: its
37
+ # guard never runs for a fetcher, so this override is never reached.
30
38
  module LinkPreviewBots
31
39
  extend ActiveSupport::Concern
32
40
 
@@ -47,6 +55,24 @@ module Studio
47
55
 
48
56
  private
49
57
 
58
+ # Rails' allow_browser macro installs
59
+ # `before_action -> { allow_browser(versions:, block:) }`, which dispatches to
60
+ # the PRIVATE instance method ActionController::AllowBrowser#allow_browser (the
61
+ # same in Rails 7.2.3 and 8.1.4). This concern is included below
62
+ # ActionController::Base, so it sits above that module in the ancestor chain and
63
+ # this method answers first: a preview fetcher's GET/HEAD skips the browser
64
+ # check, everything else goes to Rails. Overriding the method rather than
65
+ # skipping the callback covers every allow_browser declaration (this
66
+ # controller, a parent or a subclass, before or after the include), and a
67
+ # lambda callback cannot be named to skip_before_action anyway. The integration
68
+ # suite pins the method, so a Rails rename fails loudly
69
+ # (test/integration/link_preview_test.rb).
70
+ def allow_browser(*args, **kwargs, &block)
71
+ return if link_preview_bot_request?
72
+
73
+ super
74
+ end
75
+
50
76
  def serve_link_preview_slim_document
51
77
  return unless link_preview_bot_request?
52
78
  return unless response.status == 200
@@ -55,13 +81,30 @@ module Studio
55
81
  body = response.body
56
82
  return unless body.is_a?(String) && !body.empty?
57
83
 
58
- response.body = Studio::LinkPreview.slim_document(body, url: request.original_url)
84
+ response.body = Studio::LinkPreview.slim_document(body, url: studio_link_preview_page_url)
59
85
  response.headers[SLIM_HEADER] = "slim"
60
86
  # A shared cache must never hand the slim page to a person.
61
87
  response.headers["Vary"] = [response.headers["Vary"], "User-Agent"].compact.join(", ")
62
88
  rescue StandardError => e
63
89
  # The full page is still a valid (if large) answer; never 500 a fetcher.
64
- Rails.logger&.warn("[studio.link_preview] slim render skipped: #{e.class}: #{e.message}")
90
+ # The failure goes to ErrorLog (and Sentry, when the host loads it) so it
91
+ # is triaged rather than lost in a log line; never re-raised, because the
92
+ # response is already rendered and the fetcher must still get the page.
93
+ studio_link_preview_capture(e)
94
+ end
95
+
96
+ def studio_link_preview_capture(error)
97
+ ErrorLog.capture!(error)
98
+ rescue StandardError, NameError => capture_error
99
+ Rails.logger&.warn("[studio.link_preview] slim render skipped: #{error.class}: #{error.message} " \
100
+ "(ErrorLog unavailable: #{capture_error.class})")
101
+ end
102
+
103
+ # The page's canonical URL for the slim card's link: Studio.link_preview_base_url
104
+ # plus the path when the app pins one, else the URL as requested.
105
+ def studio_link_preview_page_url
106
+ Studio::LinkPreview.page_url(base_url: Studio.link_preview_base_url,
107
+ request_url: request.original_url, path: request.path)
65
108
  end
66
109
  end
67
110
  end
@@ -27,7 +27,7 @@ module Studio
27
27
  @uploads_available = @installed && @setting.respond_to?(:image)
28
28
  @default_image_url = default_image_url
29
29
  @static_image_url = static_image_url
30
- @domain = request.host
30
+ @domain = preview_domain
31
31
  end
32
32
 
33
33
  def update
@@ -95,13 +95,25 @@ module Studio
95
95
  # The uploaded image as the card shows it: the same URL the head emits.
96
96
  def default_image_url
97
97
  stored = Studio::SiteIdentity.stored
98
- Studio::LinkPreview.absolute_url(stored[:image_url] || stored[:image_path], base_url: request.base_url)
98
+ Studio::LinkPreview.absolute_url(stored[:image_url] || stored[:image_path], base_url: preview_base_url)
99
99
  rescue StandardError
100
100
  nil
101
101
  end
102
102
 
103
+ # The same base the head's tags are built on (Studio.link_preview_base_url,
104
+ # else this request's), so the card shows the domain and URL an unfurl does.
105
+ def preview_base_url
106
+ Studio::LinkPreview.base_url(pinned: Studio.link_preview_base_url, request_base_url: request.base_url)
107
+ end
108
+
109
+ def preview_domain
110
+ URI.parse(preview_base_url.to_s).host || request.host
111
+ rescue URI::InvalidURIError
112
+ request.host
113
+ end
114
+
103
115
  def static_image_url
104
- Studio::LinkPreview.absolute_url(Studio::SiteIdentity.static_image, base_url: request.base_url)
116
+ Studio::LinkPreview.absolute_url(Studio::SiteIdentity.static_image, base_url: preview_base_url)
105
117
  end
106
118
  end
107
119
  end
@@ -10,7 +10,7 @@ module Studio
10
10
  # reason Studio::GeoHelper gives: every helper module is included into every
11
11
  # view, so an unprefixed name would collide with a host's own helper.
12
12
  module LinkPreviewHelper
13
- OVERRIDE_KEYS = %i[image title description].freeze
13
+ OVERRIDE_KEYS = %i[image title description image_alt].freeze
14
14
 
15
15
  # THE PAGE OVERRIDE. Call it from any view (the layout renders later, so it
16
16
  # sees the call):
@@ -22,6 +22,8 @@ module Studio
22
22
  # attachment/blob (a user's avatar). An image that is nil, blank, or an
23
23
  # attachment with nothing attached falls back to the operator's default, so
24
24
  # a page can pass `image: user.avatar` without asking whether there is one.
25
+ # `image_alt:` is the image's text alternative (og:image:alt); without it the
26
+ # card's title stands in, which is what the picture illustrates.
25
27
  # Only the keys passed are set; a second call overrides just its own keys.
26
28
  # Returns nil, so `<%= link_preview ... %>` prints nothing either.
27
29
  def link_preview(**overrides)
@@ -33,8 +35,14 @@ module Studio
33
35
  end
34
36
 
35
37
  # The resolved preview for this page:
36
- # { title:, description:, image:, image_source:, site_name:, url: }.
37
- # `image` is absolute (or nil). Never raises: a preview must not 500 a page.
38
+ # { title:, description:, image:, image_source:, image_width:, image_height:,
39
+ # image_alt:, site_name:, url: }.
40
+ # `image` is absolute (or nil). `image_width`/`image_height` are the
41
+ # winning image's pixel size when it is KNOWN — an attachment whose blob has
42
+ # been analyzed, or the static fallback file — and nil otherwise; a URL handed
43
+ # in as a string is never fetched to measure it. `url` is the page's og:url
44
+ # (Studio.link_preview_base_url plus the path when the app pins one).
45
+ # Never raises: a preview must not 500 a page.
38
46
  #
39
47
  # Rungs, most specific first: the page's `link_preview`, then the page's
40
48
  # own content_for(:title) / content_for(:meta_description) /
@@ -43,21 +51,27 @@ module Studio
43
51
  def studio_link_preview
44
52
  overrides = @studio_link_preview_overrides || {}
45
53
  stored = studio_site_identity_stored
54
+ override_image = studio_link_preview_image_location(overrides[:image])
46
55
 
47
56
  preview = Studio::LinkPreview.resolve(
48
57
  site_name: Studio.app_name,
49
58
  titles: [overrides[:title], studio_link_preview_content(:title), stored[:title], Studio.site_title],
50
59
  descriptions: [overrides[:description], studio_link_preview_content(:meta_description),
51
60
  stored[:description], Studio.site_description],
52
- page_images: [studio_link_preview_image_location(overrides[:image]), studio_link_preview_content(:og_image)],
61
+ page_images: [override_image, studio_link_preview_content(:og_image)],
53
62
  default_image: stored[:image_url] || stored[:image_path],
54
63
  static_image: Studio::SiteIdentity.static_image
55
64
  )
56
65
 
66
+ width, height = studio_link_preview_image_size(preview[:image_source], overrides[:image], override_image, stored)
67
+
57
68
  preview.merge(
58
69
  image: Studio::LinkPreview.absolute_url(preview[:image], base_url: studio_request_base_url) || preview[:image],
70
+ image_width: width,
71
+ image_height: height,
72
+ image_alt: (overrides[:image_alt].to_s.strip.presence || preview[:title] if preview[:image]),
59
73
  site_name: Studio.app_name.to_s,
60
- url: (request.original_url if respond_to?(:request) && request)
74
+ url: studio_link_preview_page_url
61
75
  )
62
76
  end
63
77
 
@@ -106,8 +120,42 @@ module Studio
106
120
  value && CGI.unescapeHTML(value.to_str)
107
121
  end
108
122
 
123
+ # The app's pinned canonical base (Studio.link_preview_base_url), else the
124
+ # request's. Every preview URL is built on it.
109
125
  def studio_request_base_url
110
- respond_to?(:request) && request ? request.base_url : nil
126
+ Studio::LinkPreview.base_url(pinned: Studio.link_preview_base_url,
127
+ request_base_url: (request.base_url if studio_link_preview_request?))
128
+ end
129
+
130
+ def studio_link_preview_page_url
131
+ return nil unless studio_link_preview_request?
132
+
133
+ Studio::LinkPreview.page_url(base_url: Studio.link_preview_base_url,
134
+ request_url: request.original_url, path: request.path)
135
+ end
136
+
137
+ def studio_link_preview_request?
138
+ respond_to?(:request) && request ? true : false
139
+ end
140
+
141
+ # [width, height] of the image that won, when it is known without a fetch.
142
+ # The override rung counts only when the page handed in an ATTACHMENT (its
143
+ # blob's analyzed metadata); a URL string or a content_for(:og_image) is
144
+ # unmeasured, so its tags are left out rather than guessed.
145
+ def studio_link_preview_image_size(source, override, override_location, stored)
146
+ case source
147
+ when :page
148
+ return nil if override_location.nil? || override.is_a?(String) || override.is_a?(Symbol)
149
+
150
+ Studio::SiteIdentity.image_dimensions(override)
151
+ when :default
152
+ width, height = stored[:image_width], stored[:image_height]
153
+ width && height ? [width, height] : nil
154
+ when :static
155
+ Studio::SiteIdentity.static_image_dimensions
156
+ end
157
+ rescue StandardError
158
+ nil
111
159
  end
112
160
  end
113
161
  end
@@ -33,7 +33,7 @@ module Studio
33
33
 
34
34
  # Read on EVERY page render (the head's tags), edited a few times a year. So
35
35
  # the stored values are cached, and busted on any write — see .stored.
36
- CACHE_KEY_PREFIX = "studio/site_identity/v1"
36
+ CACHE_KEY_PREFIX = "studio/site_identity/v2" # v2: adds image_width/image_height
37
37
  CACHE_TTL = 1.hour
38
38
 
39
39
  TITLE_MAX = 200
@@ -194,12 +194,45 @@ module Studio
194
194
 
195
195
  def reset_static_image!
196
196
  @static_files = nil
197
+ @static_dimensions = nil
198
+ end
199
+
200
+ # [width, height] of the static fallback, read from the file's header, or
201
+ # nil when there is no local file (an absolute URL is never fetched) or its
202
+ # format is not one Studio::LinkPreview.image_dimensions reads. Memoized
203
+ # per path like static_image.
204
+ def static_image_dimensions
205
+ path = static_image
206
+ return nil if path.nil? || !path.start_with?("/") || path.start_with?("//")
207
+
208
+ @static_dimensions ||= Concurrent::Map.new
209
+ dims = @static_dimensions.compute_if_absent(path) do
210
+ Studio::LinkPreview.image_dimensions(Rails.public_path.join(path.delete_prefix("/"))) || false
211
+ end
212
+ dims || nil
213
+ end
214
+
215
+ # [width, height] from an attachment's or blob's ANALYZED metadata, or nil
216
+ # before Active Storage's analyze job has run (or for a non-image).
217
+ def image_dimensions(attachable)
218
+ return nil if attachable.nil?
219
+ return nil if attachable.respond_to?(:attached?) && !attachable.attached?
220
+
221
+ blob = attachable.respond_to?(:blob) ? attachable.blob : attachable
222
+ metadata = blob.respond_to?(:metadata) ? blob.metadata : nil
223
+ return nil unless metadata.is_a?(Hash)
224
+
225
+ width = metadata["width"] || metadata[:width]
226
+ height = metadata["height"] || metadata[:height]
227
+ width.is_a?(Integer) && height.is_a?(Integer) && width.positive? && height.positive? ? [width, height] : nil
228
+ rescue StandardError
229
+ nil
197
230
  end
198
231
 
199
232
  private
200
233
 
201
234
  def empty_stored
202
- { title: nil, description: nil, image_url: nil, image_path: nil }
235
+ { title: nil, description: nil, image_url: nil, image_path: nil, image_width: nil, image_height: nil }
203
236
  end
204
237
 
205
238
  def compute_stored
@@ -207,11 +240,14 @@ module Studio
207
240
  return empty_stored if row.nil?
208
241
 
209
242
  location = row.respond_to?(:image) ? image_location(row.image) : nil
243
+ width, height = (image_dimensions(row.image) if location)
210
244
  {
211
245
  title: row.title.presence,
212
246
  description: row.description.presence,
213
247
  image_url: location&.dig(:url),
214
- image_path: location&.dig(:path)
248
+ image_path: location&.dig(:path),
249
+ image_width: width,
250
+ image_height: height
215
251
  }
216
252
  end
217
253
  end
@@ -3,7 +3,8 @@
3
3
  Studio::LinkPreviewHelper#studio_link_preview: the page override, else the
4
4
  operator default from /admin/link_preview, else the static fallback.
5
5
  Lifted from turf-monster's layouts/_link_preview_meta. og:image is absolute
6
- and permanent; the tag is omitted rather than emitted empty. %>
6
+ and permanent; the tag is omitted rather than emitted empty. og:image:width
7
+ and og:image:height appear only when the image's size is known. %>
7
8
  <meta property="og:site_name" content="<%= preview[:site_name] %>">
8
9
  <meta property="og:type" content="website">
9
10
  <meta property="og:title" content="<%= preview[:title] %>">
@@ -15,6 +16,13 @@
15
16
  <% end %>
16
17
  <% if preview[:image] %>
17
18
  <meta property="og:image" content="<%= preview[:image] %>">
19
+ <% if preview[:image_width] && preview[:image_height] %>
20
+ <meta property="og:image:width" content="<%= preview[:image_width] %>">
21
+ <meta property="og:image:height" content="<%= preview[:image_height] %>">
22
+ <% end %>
23
+ <% if preview[:image_alt] %>
24
+ <meta property="og:image:alt" content="<%= preview[:image_alt] %>">
25
+ <% end %>
18
26
  <% end %>
19
27
  <meta name="twitter:card" content="<%= preview[:image] ? "summary_large_image" : "summary" %>">
20
28
  <meta name="twitter:title" content="<%= preview[:title] %>">
@@ -23,4 +31,7 @@
23
31
  <% end %>
24
32
  <% if preview[:image] %>
25
33
  <meta name="twitter:image" content="<%= preview[:image] %>">
34
+ <% if preview[:image_alt] %>
35
+ <meta name="twitter:image:alt" content="<%= preview[:image_alt] %>">
36
+ <% end %>
26
37
  <% end %>
@@ -82,9 +82,13 @@ module Studio
82
82
  return say_status(:skip, "#{CONTROLLER} not found", :yellow) unless File.exist?(path)
83
83
  return say_status(:identical, "#{CONTROLLER} (Studio::LinkPreviewBots)", :blue) if File.read(path).include?("Studio::LinkPreviewBots")
84
84
 
85
- inject_into_class CONTROLLER, "ApplicationController", <<~RUBY
85
+ # inject_into_class inserts the text as given, right after the class
86
+ # line, so the body's two-space indent is written here (a bare heredoc
87
+ # landed at column 0 in cyvasse).
88
+ inject_into_class CONTROLLER, "ApplicationController", <<~RUBY.gsub(/^(?=.)/, " ")
86
89
  # Preview fetchers (iMessage, Slack, Discord, X...) get a slim page under
87
- # Apple's 1 MiB limit. studio-engine docs/LINK_PREVIEW.md.
90
+ # Apple's 1 MiB limit, and are exempt from allow_browser. studio-engine
91
+ # docs/LINK_PREVIEW.md.
88
92
  include Studio::LinkPreviewBots
89
93
  RUBY
90
94
  end
@@ -135,6 +135,97 @@ module Studio
135
135
  value
136
136
  end
137
137
 
138
+ # The base URL every preview URL is built on: the app's PINNED canonical
139
+ # base (Studio.link_preview_base_url) when it set one, else the request's.
140
+ # Trailing slash dropped, so a path joins cleanly. nil when neither exists.
141
+ def base_url(pinned:, request_base_url:)
142
+ pinned_value = pinned.to_s.strip.chomp("/")
143
+ return pinned_value unless pinned_value.empty?
144
+
145
+ request_value = request_base_url.to_s.strip.chomp("/")
146
+ request_value.empty? ? nil : request_value
147
+ end
148
+
149
+ # The page's og:url. With a pinned base it is that base plus the PATH: no
150
+ # herokuapp host, no ?utm_ query, so every share of a page counts as one URL.
151
+ # Without one it is the URL as requested, as it always was.
152
+ def page_url(base_url:, request_url:, path:)
153
+ pinned_value = base_url.to_s.strip.chomp("/")
154
+ return request_url.to_s.empty? ? nil : request_url.to_s if pinned_value.empty?
155
+
156
+ "#{pinned_value}#{path.to_s.empty? ? "/" : path}"
157
+ end
158
+
159
+ # [width, height] of a PNG, GIF, JPEG or WebP file, read from its header
160
+ # only, or nil when the file is missing, unreadable or another format. Lets
161
+ # the static fallback (public/og.png) emit og:image:width/height, which
162
+ # WhatsApp and others use to lay the card out before the image arrives.
163
+ def image_dimensions(path)
164
+ File.open(path.to_s, "rb") { |file| dimensions_from(file.read(64 * 1024).to_s.b) }
165
+ rescue SystemCallError, IOError
166
+ nil
167
+ end
168
+
169
+ def dimensions_from(bytes)
170
+ dims =
171
+ if bytes.start_with?("\x89PNG\r\n\x1A\n".b) && bytes.bytesize >= 24
172
+ bytes[16, 8].unpack("NN")
173
+ elsif bytes.start_with?("GIF8") && bytes.bytesize >= 10
174
+ bytes[6, 4].unpack("vv")
175
+ elsif bytes.start_with?("\xFF\xD8".b)
176
+ jpeg_dimensions(bytes)
177
+ elsif bytes.start_with?("RIFF") && bytes[8, 4] == "WEBP"
178
+ webp_dimensions(bytes)
179
+ end
180
+ dims && dims.all? { |n| n.is_a?(Integer) && n.positive? } ? dims : nil
181
+ end
182
+
183
+ # Walk the JPEG segments to the first start-of-frame marker.
184
+ JPEG_SOF_MARKERS = [0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF].freeze
185
+
186
+ def jpeg_dimensions(bytes)
187
+ offset = 2
188
+ while offset + 9 <= bytes.bytesize
189
+ return nil unless bytes.getbyte(offset) == 0xFF
190
+
191
+ marker = bytes.getbyte(offset + 1)
192
+ if marker == 0xFF # fill byte
193
+ offset += 1
194
+ next
195
+ end
196
+ length = bytes[offset + 2, 2].unpack1("n")
197
+ if JPEG_SOF_MARKERS.include?(marker)
198
+ height, width = bytes[offset + 5, 4].unpack("nn")
199
+ return [width, height]
200
+ end
201
+ return nil if length.nil? || length < 2
202
+
203
+ offset += 2 + length
204
+ end
205
+ nil
206
+ end
207
+
208
+ def webp_dimensions(bytes)
209
+ case bytes[12, 4]
210
+ when "VP8 "
211
+ return nil if bytes.bytesize < 30
212
+
213
+ width, height = bytes[26, 4].unpack("vv")
214
+ [width & 0x3FFF, height & 0x3FFF]
215
+ when "VP8L"
216
+ return nil if bytes.bytesize < 25
217
+
218
+ bits = bytes[21, 4].unpack1("V")
219
+ [(bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1]
220
+ when "VP8X"
221
+ return nil if bytes.bytesize < 30
222
+
223
+ w = bytes[24, 3].unpack("CCC")
224
+ h = bytes[27, 3].unpack("CCC")
225
+ [(w[0] | (w[1] << 8) | (w[2] << 16)) + 1, (h[0] | (h[1] << 8) | (h[2] << 16)) + 1]
226
+ end
227
+ end
228
+
138
229
  # The whole response a preview fetcher gets: the page's own identity tags,
139
230
  # lifted out of its <head>, and a one-card body. Built FROM the rendered
140
231
  # page, so the slim document cannot drift from what a person's page says.
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.82.0"
2
+ VERSION = "0.82.1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -516,6 +516,17 @@ module Studio
516
516
  # trusted as given.
517
517
  mattr_accessor :link_preview_fallback_image, default: "/og.png"
518
518
 
519
+ # The app's CANONICAL base URL for previews, e.g. "https://cyvasse.xyz". When
520
+ # set, og:url is this base plus the page's path (no query string), and every
521
+ # relative preview image is made absolute on it — so a share through a
522
+ # herokuapp.com host or a ?utm_ link does not split one page into many URLs in
523
+ # an unfurler's share counts. nil (the default) uses the request's base URL,
524
+ # and og:url is the URL as requested. Set it in production only, or a local
525
+ # page's card points at production:
526
+ #
527
+ # config.link_preview_base_url = "https://cyvasse.xyz" if Rails.env.production?
528
+ mattr_accessor :link_preview_base_url, default: nil
529
+
519
530
  # THE SITE IDENTITY'S DRAFTED DEFAULTS — this app's title and description as
520
531
  # written in code, under whatever the operator saves at /admin/link_preview.
521
532
  # The standing convention is that an agent drafts these when it sets an app up
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.82.0
4
+ version: 0.82.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails