studio-engine 0.81.1 → 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: 2737532cb60e04de35ec057552292f8307aa863eec2413751cdf4080b079d712
4
- data.tar.gz: cd3a407b3e61463686f245a4e93a30fdbdd67250a2fd6ca09a9a2abbbd507e46
3
+ metadata.gz: ad8be51b6366f71e083ae5bb7ce69e83e08330b826f6acb6027692e0857f9b9a
4
+ data.tar.gz: 216b21e17a79e2f635bfbdbb3996607cfa767ca1e06cac11e63b337c9864771a
5
5
  SHA512:
6
- metadata.gz: 912655e55dd7e8a93eace9c7a237390144087b72611d88a08075ea8190961a0ba3f0266e8c724c093505cf3fc69596bb6eacce5019347a1c4d9bffd02552d13a
7
- data.tar.gz: cbf461654e119d53e2d7c626b34014d3769f0a536a0d46ecd32c4af25255ff981997e4a8c14ab2d784c27966c5f7085be608250b974e2f1d46f8e14b06c513e8
6
+ metadata.gz: 06e416fbb35ec33fdaa8c0cceb76d994196dcb6898992cc31d1135f67f3e0804dab3c0e7eb4230bfe162a78adc348423d17e9cc7d484556b04eac0b46f18a7c7
7
+ data.tar.gz: 63334a9f39dde930606ebf7b23943287ff4d94bce68afcd4718c30cfc8838c1b52cef6f2f791474ec744db3ebfb66a6ef14cc5eebd1aa4894adbf651385666b4
data/CHANGELOG.md CHANGED
@@ -4,6 +4,157 @@ 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
+
44
+ ## 0.82.0 — 2026-09-30
45
+
46
+ ### Added
47
+
48
+ - **A public user page at `/u/:username`, and the helpers that link to it.** It
49
+ shows the user's avatar and username and nothing else (no name, email or
50
+ wallet), and it is where a click on a username in any Studio app should lead.
51
+ See [`docs/PUBLIC_USER_PAGE.md`](docs/PUBLIC_USER_PAGE.md).
52
+ - **Link preview.** The page calls `link_preview image: user.avatar, title:
53
+ username`, so it unfurls with the avatar when one is set and with the site
54
+ identity's image when not. The avatar URL is permanent (a public service's own
55
+ URL, else Rails' storage proxy), never signed and expiring. With
56
+ `Studio::LinkPreviewBots` included, preview bots get the slim page carrying it.
57
+ - **Lookup.** Case-insensitive, with an exact spelling first. It reads
58
+ `username` only, never the email or the slug, so it cannot reveal whether an
59
+ address has an account. A `User#public_profile_visible?` that answers false
60
+ hides an account. Every miss gets the same friendly page with a `404` status.
61
+ - **Public.** The page skips `require_authentication` and renders in the host's
62
+ own layout.
63
+ - **Helpers.** `studio_user_profile_path(user)`, `studio_user_profile_url(user)`
64
+ and `link_to_user_profile(user, text = nil, **html_options, &block)` take a user
65
+ or a username. They answer `nil` (the link helper renders a plain `<span>`)
66
+ when the route is not drawn or the user has no username, so any view can call
67
+ them. The route helper is `studio_public_user_path(username:)`; the rules are
68
+ `Studio::PublicUser`.
69
+ - `components/_avatar` gains an `xl` size and an optional `alt:` local. Existing
70
+ calls render the same.
71
+ - **Opt-in:** `config.draw_public_user_routes` defaults to `false`, so an app that
72
+ owns `/u` is not broken. No consumer owns `/u` or the helper name today.
73
+
74
+ **Adopting, per app** (this release changes none of them on its own):
75
+
76
+ 1. **cyvasse, turf-monster** (already have `users.username` with a
77
+ `lower(username)` index): set `config.draw_public_user_routes = true` in
78
+ `config/initializers/studio.rb`. Define `User#public_profile_visible?` if some
79
+ accounts must not have a page (cyvasse's merged-away accounts, say). Then
80
+ point username links at `link_to_user_profile(user)`.
81
+ 2. **mcritchie-studio, mcritchie-industries** (no `username` column): add
82
+ `users.username` with a unique `lower(username)` index, backfill it
83
+ (`Studio::UsernameGenerator.generate` drafts one), then do step 1. Do not
84
+ reuse `slug`: it is keyed on the email.
85
+ 3. For an avatar unfurl on a private bucket, nothing more is needed: the proxy
86
+ URL serves it. An app with a public-read service gets that service's URL
87
+ automatically.
88
+
89
+ - **Site identity and link previews: a core primitive for every app.** Each app
90
+ now has a site identity, `Studio::SiteIdentity`: a title, a description and an
91
+ image. The operator edits all three at `/admin/link_preview`, beside a live card
92
+ drawn the way an unfurl draws it (image, domain, title, description). Every page
93
+ unfurls with it unless the page overrides it, and a missing override image falls
94
+ back to the site image. Lifted from turf-monster's `OgHelper`, `SiteSetting` and
95
+ `OgImageAttachable`. See [`docs/LINK_PREVIEW.md`](docs/LINK_PREVIEW.md).
96
+ - **Reusable copy.** `Studio.site_identity(base_url:)` (and `studio_site_identity`
97
+ in a view) returns `{ title:, description:, image_url: }`: the operator's saved
98
+ value, then the drafted `config.site_title` / `config.site_description`, then
99
+ `Studio.app_name`. Read it for a meta description, share text or an email footer.
100
+ - **One page override.** `link_preview image:, title:, description:` from any
101
+ view. `image:` takes a URL, a path, or an Active Storage attachment, so
102
+ `link_preview image: user.avatar` works whether or not the user has one.
103
+ turf-monster's `content_for(:title)`, `content_for(:meta_description)` and
104
+ `content_for(:og_image)` keys are honoured beneath it.
105
+ - **Permanent image URLs.** A public service answers its own URL; any other
106
+ service goes through Rails' storage proxy, never a signed, expiring URL. Name a
107
+ public service with `config.link_preview_image_service`.
108
+ - **Preview bots get a slim page.** `include Studio::LinkPreviewBots` serves the
109
+ known fetchers (iMessage, Facebook, X, Discord, Slack, LinkedIn, WhatsApp,
110
+ Telegram, Applebot, Skype, Reddit, Embedly) the page's head tags and a one-card
111
+ body, under Apple LinkPresentation's 1 MiB limit (1,048,000 bytes previews,
112
+ 1,049,000 fails). People and in-app browsers always get the full page.
113
+ - **Opt-in by installation, and duplicate-safe.** Under the new default
114
+ `config.link_preview_tags = :auto`, the head emits the tags only once the app
115
+ has installed the `studio_site_identities` table AND no template under its
116
+ `app/views` writes its own `og:title` or `og:image` (scanned once per process,
117
+ logged when it holds the tags off). So turf-monster and cyvasse, which write
118
+ their own, get no second set even after `studio_engine:install:migrations`
119
+ brings the table in. Set `true` to override the scan, `false` to keep the head
120
+ silent. The slim bot page also keeps only the first of any duplicated tag.
121
+ - **One adoption step.** `bin/rails g studio:site_identity --title "..."
122
+ --description "..."` copies only this migration (in the form
123
+ `studio_engine:install:migrations` writes, so that task skips it later), writes
124
+ the drafted copy into `config/initializers/studio.rb`, and includes
125
+ `Studio::LinkPreviewBots` in `ApplicationController`. `--own-tags` also sets
126
+ `config.link_preview_tags = false`. It is idempotent.
127
+ - **Drafted defaults.** An agent drafts the title and description when setting an
128
+ app up; the operator edits them on the page, and a saved value wins.
129
+ `Studio::SiteIdentity.seed!(title:, description:)` carries a draft into the row
130
+ from `db/seeds.rb` or a release task, filling only blank fields.
131
+ - `/admin/link_preview` is drawn by default (`config.draw_link_preview_routes`);
132
+ no consumer owns `admin_link_preview_path` or `admin_link_preview_image_path`.
133
+ It needs Active Storage for the upload; without it the page still edits the
134
+ words.
135
+
136
+ **Adopting, per app** (each is one short change; this release changes none of
137
+ them on its own):
138
+
139
+ 1. **mcritchie-studio, mcritchie-industries** (no og tags of their own):
140
+ `bin/rails g studio:site_identity --title "..." --description "..."`, then
141
+ `bin/rails db:migrate`. The tags come from `layouts/studio/head`, which both
142
+ layouts already render. Optionally add a 1200 × 630 `public/og.png` as the
143
+ last-resort image. Set the image at `/admin/link_preview`.
144
+ 2. **cyvasse** (writes og tags in `layouts/_seo.html.erb`): run the generator with
145
+ `--own-tags` and migrate. Then in `_seo`, replace the og:/twitter: tags with
146
+ `link_preview title: page.title, description: page.description, image: og_image`
147
+ (keep description, robots, canonical and JSON-LD, which are SEO, not preview),
148
+ and delete `config.link_preview_tags = false`.
149
+ 3. **turf-monster** (writes og tags through `OgHelper` and
150
+ `layouts/_link_preview_meta`): run the generator with `--own-tags`, set
151
+ `config.link_preview_image_service = OgImageAttachable::PUBLIC_OG_SERVICE`, and
152
+ migrate. Copy `SiteSetting`'s title, description and image into
153
+ `Studio::SiteIdentity`. Then delete the `_link_preview_meta` renders, `OgHelper`'s
154
+ resolution, `SiteSetting`'s og fields and the app-local `LinkPreviewBot` (the
155
+ engine concern replaces it; contest pages keep `content_for(:og_image)` or move
156
+ to `link_preview`), and delete `config.link_preview_tags = false`.
157
+
7
158
  ## 0.81.1 — 2026-09-30
8
159
 
9
160
  ### Fixed
data/README.md CHANGED
@@ -38,6 +38,8 @@ resolved.
38
38
  - **Sluggable concern**: `before_save :set_slug` with `to_param` for human-readable URLs
39
39
  - **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
40
40
  - **Geo**: `Studio::GeoDetection` places every visitor (IP → country + subdivision, session-cached), `Studio::GeoSetting` stores the operator's blocked countries and regions, `require_geo_allowed` locks whichever surfaces an app chooses, and the shared badge + `/admin/geo` manager ship with it. See [`docs/GEO.md`](docs/GEO.md).
41
+ - **Site identity and link previews**: `Studio::SiteIdentity` holds the app's title, description and image, edited at `/admin/link_preview` beside a live unfurl card and read anywhere through `Studio.site_identity`. Every page unfurls with it unless it calls `link_preview image:, title:, description:`, and `Studio::LinkPreviewBots` serves preview fetchers a slim page under iMessage's 1 MiB limit. Adopt with `bin/rails g studio:site_identity`. See [`docs/LINK_PREVIEW.md`](docs/LINK_PREVIEW.md).
42
+ - **Public user page**: `/u/:username` shows a user's avatar and username (nothing else), and unfurls with their avatar, falling back to the site image. Link any username to it with `link_to_user_profile(user)` or `studio_user_profile_path(user)`. Opt in with `config.draw_public_user_routes = true`. See [`docs/PUBLIC_USER_PAGE.md`](docs/PUBLIC_USER_PAGE.md).
41
43
  - **Transactional emails**: `Studio::EmailCatalog` — every email an app sends, its type, a live preview, and its banner — plus the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
42
44
 
43
45
  ## Configuration
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # Serve link-preview fetchers (iMessage, Slack, Discord, X, WhatsApp...) a
5
+ # SLIM document — the page's own identity tags and a one-card body — instead
6
+ # of the full page.
7
+ #
8
+ # class ApplicationController < ActionController::Base
9
+ # include Studio::LinkPreviewBots
10
+ # end
11
+ #
12
+ # WHY: Apple's LinkPresentation (the iMessage unfurler) aborts any HTML page
13
+ # over 1 MiB (WebKitErrorDomain 102, "Frame load interrupted"; measured
14
+ # 2026-09-30: 1,048,000 bytes previewed, 1,049,000 failed). A page carrying a
15
+ # big inline script or template passes that without anyone noticing, and every
16
+ # link to it stops previewing in Messages while Discord keeps working.
17
+ #
18
+ # HOW: the action and its view run as usual — so a page's `link_preview`
19
+ # override is set — and the full page renders. Then, for a known fetcher only,
20
+ # the body is replaced by Studio::LinkPreview.slim_document, which lifts the
21
+ # <title>, meta and icon tags out of that page's <head> and drops every script,
22
+ # style and template. Built from the rendered page, the slim document cannot
23
+ # drift from what a person's page says, and it works under ANY layout.
24
+ #
25
+ # DUPLICATE-SAFE: a tag the page emits twice (an app's own og tags and the
26
+ # engine's) reaches the fetcher once, the first occurrence.
27
+ #
28
+ # People, in-app browsers and unknown agents are never matched and always get
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.
38
+ module LinkPreviewBots
39
+ extend ActiveSupport::Concern
40
+
41
+ SLIM_HEADER = "X-Studio-Link-Preview"
42
+
43
+ included do
44
+ after_action :serve_link_preview_slim_document
45
+
46
+ helper_method :link_preview_bot_request? if respond_to?(:helper_method)
47
+ end
48
+
49
+ # A preview fetcher reading a page. No format check: an unfurler often sends
50
+ # `Accept: */*`, which Rails reads as Mime::ALL rather than html, though the
51
+ # response it gets is HTML — the after_action checks the RESPONSE instead.
52
+ def link_preview_bot_request?
53
+ (request.get? || request.head?) && Studio::LinkPreview.bot?(request.user_agent)
54
+ end
55
+
56
+ private
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
+
76
+ def serve_link_preview_slim_document
77
+ return unless link_preview_bot_request?
78
+ return unless response.status == 200
79
+ return unless response.media_type == "text/html"
80
+
81
+ body = response.body
82
+ return unless body.is_a?(String) && !body.empty?
83
+
84
+ response.body = Studio::LinkPreview.slim_document(body, url: studio_link_preview_page_url)
85
+ response.headers[SLIM_HEADER] = "slim"
86
+ # A shared cache must never hand the slim page to a person.
87
+ response.headers["Vary"] = [response.headers["Vary"], "User-Agent"].compact.join(", ")
88
+ rescue StandardError => e
89
+ # The full page is still a valid (if large) answer; never 500 a fetcher.
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)
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # /u/:username — the public page a username links to in every Studio app.
5
+ #
6
+ # It shows the avatar and the username, and nothing else: no name, no email,
7
+ # no wallet. Its link preview is the avatar when one is set, else the site
8
+ # identity's image (Studio::LinkPreviewHelper#link_preview does the fallback).
9
+ #
10
+ # Public by design, so it skips the host's sign-in gate (mcritchie-industries
11
+ # and cyvasse gate every controller through Studio::ErrorHandling). It inherits
12
+ # the host's ApplicationController for the same reason ProfilesController
13
+ # does: the page renders inside the app's own layout, navbar and theme, and a
14
+ # host that includes Studio::LinkPreviewBots serves unfurlers the slim page.
15
+ #
16
+ # An unknown username gets a friendly page with a real 404 status. Every miss
17
+ # looks the same (unknown, hidden, or an app with no username column), and the
18
+ # lookup never touches the email, so the page cannot be used to learn whether
19
+ # an account exists behind an address. Drawn only when the host opts in:
20
+ # Studio.draw_public_user_routes.
21
+ class PublicUsersController < ::ApplicationController
22
+ skip_before_action :require_authentication, raise: false
23
+
24
+ def show
25
+ @public_user = Studio::PublicUser.find(params[:username])
26
+ return if @public_user
27
+
28
+ @requested_username = params[:username].to_s
29
+ render :not_found, status: :not_found
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # /admin/link_preview — the operator edits the SITE IDENTITY (Studio::
5
+ # SiteIdentity): the image, title and description an unfurl shows for any page
6
+ # that does not name its own, and the copy Studio.site_identity hands any
7
+ # other reader. All three on one page, beside a live card drawn the way an
8
+ # unfurl draws it.
9
+ #
10
+ # A plain host-inherited controller whose view is a bare content wrapper, like
11
+ # /admin/geo and /admin/emails, so it renders inside each host's application
12
+ # layout. Lifted from turf-monster's admin dashboard (update_link_preview and
13
+ # update_link_preview_image), which ran it in production first.
14
+ #
15
+ # The image and the words post SEPARATELY: the cropper submits a form holding
16
+ # only the image, the text form holds only the words. So #update changes only
17
+ # the fields a request actually carried.
18
+ class SiteIdentitiesController < ApplicationController
19
+ before_action :require_admin
20
+
21
+ IMAGE_TYPES = Studio::SiteIdentity::IMAGE_TYPES
22
+ MAX_IMAGE_BYTES = Studio::SiteIdentity::MAX_IMAGE_BYTES
23
+
24
+ def edit
25
+ @installed = Studio::SiteIdentity.table_ready?
26
+ @setting = @installed ? Studio::SiteIdentity.current : nil
27
+ @uploads_available = @installed && @setting.respond_to?(:image)
28
+ @default_image_url = default_image_url
29
+ @static_image_url = static_image_url
30
+ @domain = preview_domain
31
+ end
32
+
33
+ def update
34
+ return redirect_to(admin_link_preview_path, alert: not_installed_message, status: :see_other) unless installed?
35
+
36
+ attrs = params.fetch(:site_identity, {})
37
+ file = attrs[:image]
38
+
39
+ if attrs.key?(:image) && !valid_image?(file)
40
+ message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, WebP or GIF under 8 MB."
41
+ return redirect_to admin_link_preview_path, alert: message, status: :see_other
42
+ end
43
+
44
+ setting = Studio::SiteIdentity.current!
45
+ rescue_and_log(target: setting) do
46
+ setting.title = attrs[:title].to_s.strip.presence if attrs.key?(:title)
47
+ setting.description = attrs[:description].to_s.strip.presence if attrs.key?(:description)
48
+ setting.save!
49
+ setting.image.attach(file) if file.present?
50
+ # An attach does not touch the row, so after_commit does not fire for it.
51
+ Studio::SiteIdentity.bust_cache!
52
+ end
53
+
54
+ redirect_to admin_link_preview_path, status: :see_other,
55
+ notice: file.present? ? "Default link-preview image updated." : "Link-preview defaults updated."
56
+ rescue ActiveRecord::RecordInvalid => e
57
+ redirect_to admin_link_preview_path, status: :see_other, alert: e.record.errors.full_messages.to_sentence
58
+ rescue StandardError
59
+ redirect_to admin_link_preview_path, status: :see_other, alert: "Couldn't save the link preview. Please try again."
60
+ end
61
+
62
+ # DELETE /admin/link_preview/image — drop the uploaded default, so previews
63
+ # fall back to the static image (or none).
64
+ def destroy_image
65
+ return redirect_to(admin_link_preview_path, alert: not_installed_message, status: :see_other) unless installed?
66
+
67
+ setting = Studio::SiteIdentity.current
68
+ if setting&.persisted? && setting.image_attached?
69
+ rescue_and_log(target: setting) do
70
+ setting.image.purge
71
+ Studio::SiteIdentity.bust_cache!
72
+ end
73
+ end
74
+
75
+ redirect_to admin_link_preview_path, status: :see_other, notice: "Default link-preview image removed."
76
+ rescue StandardError
77
+ redirect_to admin_link_preview_path, status: :see_other, alert: "Couldn't remove the image. Please try again."
78
+ end
79
+
80
+ private
81
+
82
+ def installed?
83
+ Studio::SiteIdentity.table_ready?
84
+ end
85
+
86
+ def not_installed_message
87
+ "Install the site identity table first: bin/rails g studio:site_identity && bin/rails db:migrate"
88
+ end
89
+
90
+ def valid_image?(file)
91
+ file.respond_to?(:content_type) && IMAGE_TYPES.include?(file.content_type.to_s) &&
92
+ file.respond_to?(:size) && file.size.to_i.positive? && file.size <= MAX_IMAGE_BYTES
93
+ end
94
+
95
+ # The uploaded image as the card shows it: the same URL the head emits.
96
+ def default_image_url
97
+ stored = Studio::SiteIdentity.stored
98
+ Studio::LinkPreview.absolute_url(stored[:image_url] || stored[:image_path], base_url: preview_base_url)
99
+ rescue StandardError
100
+ nil
101
+ end
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
+
115
+ def static_image_url
116
+ Studio::LinkPreview.absolute_url(Studio::SiteIdentity.static_image, base_url: preview_base_url)
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # View-side link preview: the ONE override a page uses, and the tags the head
5
+ # renders. The policy (which rung wins) is Studio::LinkPreview.resolve; this
6
+ # module only gathers the rungs, and it is where the request's base URL and
7
+ # the asset paths are known.
8
+ #
9
+ # Method names are prefixed (`link_preview`, `studio_link_preview*`) for the
10
+ # reason Studio::GeoHelper gives: every helper module is included into every
11
+ # view, so an unprefixed name would collide with a host's own helper.
12
+ module LinkPreviewHelper
13
+ OVERRIDE_KEYS = %i[image title description image_alt].freeze
14
+
15
+ # THE PAGE OVERRIDE. Call it from any view (the layout renders later, so it
16
+ # sees the call):
17
+ #
18
+ # <% link_preview image: @contest.banner, title: @contest.name,
19
+ # description: @contest.tagline %>
20
+ #
21
+ # `image:` takes a URL, a root-relative path, or an Active Storage
22
+ # attachment/blob (a user's avatar). An image that is nil, blank, or an
23
+ # attachment with nothing attached falls back to the operator's default, so
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.
27
+ # Only the keys passed are set; a second call overrides just its own keys.
28
+ # Returns nil, so `<%= link_preview ... %>` prints nothing either.
29
+ def link_preview(**overrides)
30
+ unknown = overrides.keys - OVERRIDE_KEYS
31
+ raise ArgumentError, "link_preview takes #{OVERRIDE_KEYS.join(", ")}; got #{unknown.join(", ")}" if unknown.any?
32
+
33
+ (@studio_link_preview_overrides ||= {}).merge!(overrides)
34
+ nil
35
+ end
36
+
37
+ # The resolved preview for this 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.
46
+ #
47
+ # Rungs, most specific first: the page's `link_preview`, then the page's
48
+ # own content_for(:title) / content_for(:meta_description) /
49
+ # content_for(:og_image) (turf-monster's existing keys, honoured so its
50
+ # pages keep their overrides on adoption), then the site identity.
51
+ def studio_link_preview
52
+ overrides = @studio_link_preview_overrides || {}
53
+ stored = studio_site_identity_stored
54
+ override_image = studio_link_preview_image_location(overrides[:image])
55
+
56
+ preview = Studio::LinkPreview.resolve(
57
+ site_name: Studio.app_name,
58
+ titles: [overrides[:title], studio_link_preview_content(:title), stored[:title], Studio.site_title],
59
+ descriptions: [overrides[:description], studio_link_preview_content(:meta_description),
60
+ stored[:description], Studio.site_description],
61
+ page_images: [override_image, studio_link_preview_content(:og_image)],
62
+ default_image: stored[:image_url] || stored[:image_path],
63
+ static_image: Studio::SiteIdentity.static_image
64
+ )
65
+
66
+ width, height = studio_link_preview_image_size(preview[:image_source], overrides[:image], override_image, stored)
67
+
68
+ preview.merge(
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]),
73
+ site_name: Studio.app_name.to_s,
74
+ url: studio_link_preview_page_url
75
+ )
76
+ end
77
+
78
+ # The app's IDENTITY COPY — { title:, description:, image_url: } — for any
79
+ # view that wants the words, not the tags: a meta description, share text,
80
+ # an email footer. The site-wide answer, ignoring this page's overrides.
81
+ # Studio.site_identity is the same call outside a view.
82
+ def studio_site_identity
83
+ Studio.site_identity(base_url: studio_request_base_url)
84
+ end
85
+
86
+ # The og:/twitter: tags for this page. layouts/studio/_head renders this
87
+ # when Studio.link_preview_tags? says so; an app that set
88
+ # `link_preview_tags = false` can render it wherever it likes.
89
+ def studio_link_preview_tags
90
+ render "layouts/studio/link_preview_tags", preview: studio_link_preview
91
+ rescue StandardError => e
92
+ Rails.logger&.warn("[studio.link_preview] tags skipped: #{e.class}: #{e.message}")
93
+ "".html_safe
94
+ end
95
+
96
+ private
97
+
98
+ def studio_site_identity_stored
99
+ Studio::SiteIdentity.stored
100
+ rescue StandardError => e
101
+ Rails.logger&.warn("[studio.link_preview] site identity unavailable: #{e.class}: #{e.message}")
102
+ {}
103
+ end
104
+
105
+ def studio_link_preview_image_location(image)
106
+ return nil if image.nil?
107
+ return image.to_s if image.is_a?(String) || image.is_a?(Symbol)
108
+
109
+ location = Studio::SiteIdentity.image_location(image)
110
+ location && (location[:url] || location[:path])
111
+ rescue StandardError
112
+ nil
113
+ end
114
+
115
+ # content_for holds ESCAPED text in a SafeBuffer, and the resolver's strip
116
+ # returns a plain String the tag partial escapes again ("Pass &amp;amp; Run").
117
+ # Hand the resolver the plain text instead.
118
+ def studio_link_preview_content(key)
119
+ value = content_for(key)
120
+ value && CGI.unescapeHTML(value.to_str)
121
+ end
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.
125
+ def studio_request_base_url
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
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # Links to the public user page (/u/:username). Clicking a username anywhere
5
+ # in a Studio app should land here, so every app reaches it the same way:
6
+ #
7
+ # <%= link_to_user_profile(entry.user) %> # text: display_name
8
+ # <%= link_to_user_profile(user, "@#{user.username}", class: "font-semibold") %>
9
+ # <%= link_to_user_profile(user) do %> ...avatar... <% end %>
10
+ # studio_user_profile_path(user) # => "/u/alex", or nil
11
+ #
12
+ # The path helpers answer nil, and link_to_user_profile renders its text in a
13
+ # plain <span> with no link, whenever there is no page to go to: the app has
14
+ # not drawn the route (Studio.draw_public_user_routes), or the user has no
15
+ # username. So a view can call them for every user without asking first.
16
+ #
17
+ # Prefixed names for the reason Studio::GeoHelper gives: every helper module is
18
+ # included into every view. The ROUTE helper is studio_public_user_path; these
19
+ # take a user rather than a username, which is why they are named apart.
20
+ module PublicUserHelper
21
+ def studio_user_profile_path(user, **options)
22
+ studio_user_profile_location(:path, user, options)
23
+ end
24
+
25
+ def studio_user_profile_url(user, **options)
26
+ studio_user_profile_location(:url, user, options)
27
+ end
28
+
29
+ def link_to_user_profile(user, name = nil, html_options = nil, **options, &block)
30
+ html_options, name = name, nil if name.is_a?(Hash)
31
+ html_options = (html_options || {}).merge(options)
32
+ path = studio_user_profile_path(user)
33
+
34
+ content = block ? capture(&block) : (name || studio_user_profile_label(user))
35
+ if path
36
+ html_options[:data] = { public_user_link: "" }.merge(html_options[:data] || {})
37
+ link_to(content, path, html_options)
38
+ else
39
+ content_tag(:span, content, html_options)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def studio_user_profile_location(kind, user, options)
46
+ return nil unless respond_to?(:"studio_public_user_#{kind}")
47
+
48
+ username = Studio::PublicUser.username_for(user)
49
+ return nil if username.nil?
50
+
51
+ public_send(:"studio_public_user_#{kind}", username: username, **options)
52
+ end
53
+
54
+ def studio_user_profile_label(user)
55
+ return user.to_s if user.is_a?(String) || user.is_a?(Symbol)
56
+
57
+ label = user.display_name if user.respond_to?(:display_name)
58
+ label.presence || Studio::PublicUser.username_for(user).to_s
59
+ end
60
+ end
61
+ end