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 +4 -4
- data/CHANGELOG.md +37 -0
- data/app/controllers/concerns/studio/link_preview_bots.rb +45 -2
- data/app/controllers/studio/site_identities_controller.rb +15 -3
- data/app/helpers/studio/link_preview_helper.rb +54 -6
- data/app/models/studio/site_identity.rb +39 -3
- data/app/views/layouts/studio/_link_preview_tags.html.erb +12 -1
- data/lib/generators/studio/site_identity/site_identity_generator.rb +6 -2
- data/lib/studio/link_preview.rb +91 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +11 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad8be51b6366f71e083ae5bb7ce69e83e08330b826f6acb6027692e0857f9b9a
|
|
4
|
+
data.tar.gz: 216b21e17a79e2f635bfbdbb3996607cfa767ca1e06cac11e63b337c9864771a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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:,
|
|
37
|
-
#
|
|
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: [
|
|
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:
|
|
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
|
-
|
|
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/
|
|
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
|
|
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
|
|
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
|
data/lib/studio/link_preview.rb
CHANGED
|
@@ -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.
|
data/lib/studio/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-10-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|