studio-engine 0.81.1 → 0.82.0
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 +114 -0
- data/README.md +2 -0
- data/app/controllers/concerns/studio/link_preview_bots.rb +67 -0
- data/app/controllers/studio/public_users_controller.rb +32 -0
- data/app/controllers/studio/site_identities_controller.rb +107 -0
- data/app/helpers/studio/link_preview_helper.rb +113 -0
- data/app/helpers/studio/public_user_helper.rb +61 -0
- data/app/models/studio/site_identity.rb +227 -0
- data/app/views/components/_avatar.html.erb +5 -3
- data/app/views/layouts/studio/_head.html.erb +6 -0
- data/app/views/layouts/studio/_link_preview_tags.html.erb +26 -0
- data/app/views/studio/public_users/not_found.html.erb +15 -0
- data/app/views/studio/public_users/show.html.erb +28 -0
- data/app/views/studio/site_identities/edit.html.erb +233 -0
- data/db/migrate/20260930120000_create_studio_site_identities.rb +30 -0
- data/lib/generators/studio/site_identity/site_identity_generator.rb +135 -0
- data/lib/studio/link_preview.rb +244 -0
- data/lib/studio/public_user.rb +65 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +140 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: baeae6743d5d1c8b5f2157367e1ca7df20e40318cfbeed9961360df5cd3a86b8
|
|
4
|
+
data.tar.gz: 965e67b42b3e04a07b4607dee0994b5b69c8f76dccb648ae4947e26c5d43c5e1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 881ca49a5a383eceae0cebc3a8fd4ecfe1ddafdefe4591e4afed541c205b74346d2b590c54dc1635d7c3c2e96b61714a9304de2ce9886016a1c765a440b2eda9
|
|
7
|
+
data.tar.gz: 1df9079552520f39f9659ce644044342253eed53853fe1145fe0e0ed35c5dd5517d21312557de8ac55494c91acb68912d9b8fa7981275ed3b309e271fb662868
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,120 @@ 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.0 — 2026-09-30
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **A public user page at `/u/:username`, and the helpers that link to it.** It
|
|
12
|
+
shows the user's avatar and username and nothing else (no name, email or
|
|
13
|
+
wallet), and it is where a click on a username in any Studio app should lead.
|
|
14
|
+
See [`docs/PUBLIC_USER_PAGE.md`](docs/PUBLIC_USER_PAGE.md).
|
|
15
|
+
- **Link preview.** The page calls `link_preview image: user.avatar, title:
|
|
16
|
+
username`, so it unfurls with the avatar when one is set and with the site
|
|
17
|
+
identity's image when not. The avatar URL is permanent (a public service's own
|
|
18
|
+
URL, else Rails' storage proxy), never signed and expiring. With
|
|
19
|
+
`Studio::LinkPreviewBots` included, preview bots get the slim page carrying it.
|
|
20
|
+
- **Lookup.** Case-insensitive, with an exact spelling first. It reads
|
|
21
|
+
`username` only, never the email or the slug, so it cannot reveal whether an
|
|
22
|
+
address has an account. A `User#public_profile_visible?` that answers false
|
|
23
|
+
hides an account. Every miss gets the same friendly page with a `404` status.
|
|
24
|
+
- **Public.** The page skips `require_authentication` and renders in the host's
|
|
25
|
+
own layout.
|
|
26
|
+
- **Helpers.** `studio_user_profile_path(user)`, `studio_user_profile_url(user)`
|
|
27
|
+
and `link_to_user_profile(user, text = nil, **html_options, &block)` take a user
|
|
28
|
+
or a username. They answer `nil` (the link helper renders a plain `<span>`)
|
|
29
|
+
when the route is not drawn or the user has no username, so any view can call
|
|
30
|
+
them. The route helper is `studio_public_user_path(username:)`; the rules are
|
|
31
|
+
`Studio::PublicUser`.
|
|
32
|
+
- `components/_avatar` gains an `xl` size and an optional `alt:` local. Existing
|
|
33
|
+
calls render the same.
|
|
34
|
+
- **Opt-in:** `config.draw_public_user_routes` defaults to `false`, so an app that
|
|
35
|
+
owns `/u` is not broken. No consumer owns `/u` or the helper name today.
|
|
36
|
+
|
|
37
|
+
**Adopting, per app** (this release changes none of them on its own):
|
|
38
|
+
|
|
39
|
+
1. **cyvasse, turf-monster** (already have `users.username` with a
|
|
40
|
+
`lower(username)` index): set `config.draw_public_user_routes = true` in
|
|
41
|
+
`config/initializers/studio.rb`. Define `User#public_profile_visible?` if some
|
|
42
|
+
accounts must not have a page (cyvasse's merged-away accounts, say). Then
|
|
43
|
+
point username links at `link_to_user_profile(user)`.
|
|
44
|
+
2. **mcritchie-studio, mcritchie-industries** (no `username` column): add
|
|
45
|
+
`users.username` with a unique `lower(username)` index, backfill it
|
|
46
|
+
(`Studio::UsernameGenerator.generate` drafts one), then do step 1. Do not
|
|
47
|
+
reuse `slug`: it is keyed on the email.
|
|
48
|
+
3. For an avatar unfurl on a private bucket, nothing more is needed: the proxy
|
|
49
|
+
URL serves it. An app with a public-read service gets that service's URL
|
|
50
|
+
automatically.
|
|
51
|
+
|
|
52
|
+
- **Site identity and link previews: a core primitive for every app.** Each app
|
|
53
|
+
now has a site identity, `Studio::SiteIdentity`: a title, a description and an
|
|
54
|
+
image. The operator edits all three at `/admin/link_preview`, beside a live card
|
|
55
|
+
drawn the way an unfurl draws it (image, domain, title, description). Every page
|
|
56
|
+
unfurls with it unless the page overrides it, and a missing override image falls
|
|
57
|
+
back to the site image. Lifted from turf-monster's `OgHelper`, `SiteSetting` and
|
|
58
|
+
`OgImageAttachable`. See [`docs/LINK_PREVIEW.md`](docs/LINK_PREVIEW.md).
|
|
59
|
+
- **Reusable copy.** `Studio.site_identity(base_url:)` (and `studio_site_identity`
|
|
60
|
+
in a view) returns `{ title:, description:, image_url: }`: the operator's saved
|
|
61
|
+
value, then the drafted `config.site_title` / `config.site_description`, then
|
|
62
|
+
`Studio.app_name`. Read it for a meta description, share text or an email footer.
|
|
63
|
+
- **One page override.** `link_preview image:, title:, description:` from any
|
|
64
|
+
view. `image:` takes a URL, a path, or an Active Storage attachment, so
|
|
65
|
+
`link_preview image: user.avatar` works whether or not the user has one.
|
|
66
|
+
turf-monster's `content_for(:title)`, `content_for(:meta_description)` and
|
|
67
|
+
`content_for(:og_image)` keys are honoured beneath it.
|
|
68
|
+
- **Permanent image URLs.** A public service answers its own URL; any other
|
|
69
|
+
service goes through Rails' storage proxy, never a signed, expiring URL. Name a
|
|
70
|
+
public service with `config.link_preview_image_service`.
|
|
71
|
+
- **Preview bots get a slim page.** `include Studio::LinkPreviewBots` serves the
|
|
72
|
+
known fetchers (iMessage, Facebook, X, Discord, Slack, LinkedIn, WhatsApp,
|
|
73
|
+
Telegram, Applebot, Skype, Reddit, Embedly) the page's head tags and a one-card
|
|
74
|
+
body, under Apple LinkPresentation's 1 MiB limit (1,048,000 bytes previews,
|
|
75
|
+
1,049,000 fails). People and in-app browsers always get the full page.
|
|
76
|
+
- **Opt-in by installation, and duplicate-safe.** Under the new default
|
|
77
|
+
`config.link_preview_tags = :auto`, the head emits the tags only once the app
|
|
78
|
+
has installed the `studio_site_identities` table AND no template under its
|
|
79
|
+
`app/views` writes its own `og:title` or `og:image` (scanned once per process,
|
|
80
|
+
logged when it holds the tags off). So turf-monster and cyvasse, which write
|
|
81
|
+
their own, get no second set even after `studio_engine:install:migrations`
|
|
82
|
+
brings the table in. Set `true` to override the scan, `false` to keep the head
|
|
83
|
+
silent. The slim bot page also keeps only the first of any duplicated tag.
|
|
84
|
+
- **One adoption step.** `bin/rails g studio:site_identity --title "..."
|
|
85
|
+
--description "..."` copies only this migration (in the form
|
|
86
|
+
`studio_engine:install:migrations` writes, so that task skips it later), writes
|
|
87
|
+
the drafted copy into `config/initializers/studio.rb`, and includes
|
|
88
|
+
`Studio::LinkPreviewBots` in `ApplicationController`. `--own-tags` also sets
|
|
89
|
+
`config.link_preview_tags = false`. It is idempotent.
|
|
90
|
+
- **Drafted defaults.** An agent drafts the title and description when setting an
|
|
91
|
+
app up; the operator edits them on the page, and a saved value wins.
|
|
92
|
+
`Studio::SiteIdentity.seed!(title:, description:)` carries a draft into the row
|
|
93
|
+
from `db/seeds.rb` or a release task, filling only blank fields.
|
|
94
|
+
- `/admin/link_preview` is drawn by default (`config.draw_link_preview_routes`);
|
|
95
|
+
no consumer owns `admin_link_preview_path` or `admin_link_preview_image_path`.
|
|
96
|
+
It needs Active Storage for the upload; without it the page still edits the
|
|
97
|
+
words.
|
|
98
|
+
|
|
99
|
+
**Adopting, per app** (each is one short change; this release changes none of
|
|
100
|
+
them on its own):
|
|
101
|
+
|
|
102
|
+
1. **mcritchie-studio, mcritchie-industries** (no og tags of their own):
|
|
103
|
+
`bin/rails g studio:site_identity --title "..." --description "..."`, then
|
|
104
|
+
`bin/rails db:migrate`. The tags come from `layouts/studio/head`, which both
|
|
105
|
+
layouts already render. Optionally add a 1200 × 630 `public/og.png` as the
|
|
106
|
+
last-resort image. Set the image at `/admin/link_preview`.
|
|
107
|
+
2. **cyvasse** (writes og tags in `layouts/_seo.html.erb`): run the generator with
|
|
108
|
+
`--own-tags` and migrate. Then in `_seo`, replace the og:/twitter: tags with
|
|
109
|
+
`link_preview title: page.title, description: page.description, image: og_image`
|
|
110
|
+
(keep description, robots, canonical and JSON-LD, which are SEO, not preview),
|
|
111
|
+
and delete `config.link_preview_tags = false`.
|
|
112
|
+
3. **turf-monster** (writes og tags through `OgHelper` and
|
|
113
|
+
`layouts/_link_preview_meta`): run the generator with `--own-tags`, set
|
|
114
|
+
`config.link_preview_image_service = OgImageAttachable::PUBLIC_OG_SERVICE`, and
|
|
115
|
+
migrate. Copy `SiteSetting`'s title, description and image into
|
|
116
|
+
`Studio::SiteIdentity`. Then delete the `_link_preview_meta` renders, `OgHelper`'s
|
|
117
|
+
resolution, `SiteSetting`'s og fields and the app-local `LinkPreviewBot` (the
|
|
118
|
+
engine concern replaces it; contest pages keep `content_for(:og_image)` or move
|
|
119
|
+
to `link_preview`), and delete `config.link_preview_tags = false`.
|
|
120
|
+
|
|
7
121
|
## 0.81.1 — 2026-09-30
|
|
8
122
|
|
|
9
123
|
### 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,67 @@
|
|
|
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
|
+
module LinkPreviewBots
|
|
31
|
+
extend ActiveSupport::Concern
|
|
32
|
+
|
|
33
|
+
SLIM_HEADER = "X-Studio-Link-Preview"
|
|
34
|
+
|
|
35
|
+
included do
|
|
36
|
+
after_action :serve_link_preview_slim_document
|
|
37
|
+
|
|
38
|
+
helper_method :link_preview_bot_request? if respond_to?(:helper_method)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A preview fetcher reading a page. No format check: an unfurler often sends
|
|
42
|
+
# `Accept: */*`, which Rails reads as Mime::ALL rather than html, though the
|
|
43
|
+
# response it gets is HTML — the after_action checks the RESPONSE instead.
|
|
44
|
+
def link_preview_bot_request?
|
|
45
|
+
(request.get? || request.head?) && Studio::LinkPreview.bot?(request.user_agent)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def serve_link_preview_slim_document
|
|
51
|
+
return unless link_preview_bot_request?
|
|
52
|
+
return unless response.status == 200
|
|
53
|
+
return unless response.media_type == "text/html"
|
|
54
|
+
|
|
55
|
+
body = response.body
|
|
56
|
+
return unless body.is_a?(String) && !body.empty?
|
|
57
|
+
|
|
58
|
+
response.body = Studio::LinkPreview.slim_document(body, url: request.original_url)
|
|
59
|
+
response.headers[SLIM_HEADER] = "slim"
|
|
60
|
+
# A shared cache must never hand the slim page to a person.
|
|
61
|
+
response.headers["Vary"] = [response.headers["Vary"], "User-Agent"].compact.join(", ")
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
# 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}")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
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,107 @@
|
|
|
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 = request.host
|
|
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: request.base_url)
|
|
99
|
+
rescue StandardError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def static_image_url
|
|
104
|
+
Studio::LinkPreview.absolute_url(Studio::SiteIdentity.static_image, base_url: request.base_url)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
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].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
|
+
# Only the keys passed are set; a second call overrides just its own keys.
|
|
26
|
+
# Returns nil, so `<%= link_preview ... %>` prints nothing either.
|
|
27
|
+
def link_preview(**overrides)
|
|
28
|
+
unknown = overrides.keys - OVERRIDE_KEYS
|
|
29
|
+
raise ArgumentError, "link_preview takes #{OVERRIDE_KEYS.join(", ")}; got #{unknown.join(", ")}" if unknown.any?
|
|
30
|
+
|
|
31
|
+
(@studio_link_preview_overrides ||= {}).merge!(overrides)
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 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
|
+
#
|
|
39
|
+
# Rungs, most specific first: the page's `link_preview`, then the page's
|
|
40
|
+
# own content_for(:title) / content_for(:meta_description) /
|
|
41
|
+
# content_for(:og_image) (turf-monster's existing keys, honoured so its
|
|
42
|
+
# pages keep their overrides on adoption), then the site identity.
|
|
43
|
+
def studio_link_preview
|
|
44
|
+
overrides = @studio_link_preview_overrides || {}
|
|
45
|
+
stored = studio_site_identity_stored
|
|
46
|
+
|
|
47
|
+
preview = Studio::LinkPreview.resolve(
|
|
48
|
+
site_name: Studio.app_name,
|
|
49
|
+
titles: [overrides[:title], studio_link_preview_content(:title), stored[:title], Studio.site_title],
|
|
50
|
+
descriptions: [overrides[:description], studio_link_preview_content(:meta_description),
|
|
51
|
+
stored[:description], Studio.site_description],
|
|
52
|
+
page_images: [studio_link_preview_image_location(overrides[:image]), studio_link_preview_content(:og_image)],
|
|
53
|
+
default_image: stored[:image_url] || stored[:image_path],
|
|
54
|
+
static_image: Studio::SiteIdentity.static_image
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
preview.merge(
|
|
58
|
+
image: Studio::LinkPreview.absolute_url(preview[:image], base_url: studio_request_base_url) || preview[:image],
|
|
59
|
+
site_name: Studio.app_name.to_s,
|
|
60
|
+
url: (request.original_url if respond_to?(:request) && request)
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The app's IDENTITY COPY — { title:, description:, image_url: } — for any
|
|
65
|
+
# view that wants the words, not the tags: a meta description, share text,
|
|
66
|
+
# an email footer. The site-wide answer, ignoring this page's overrides.
|
|
67
|
+
# Studio.site_identity is the same call outside a view.
|
|
68
|
+
def studio_site_identity
|
|
69
|
+
Studio.site_identity(base_url: studio_request_base_url)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The og:/twitter: tags for this page. layouts/studio/_head renders this
|
|
73
|
+
# when Studio.link_preview_tags? says so; an app that set
|
|
74
|
+
# `link_preview_tags = false` can render it wherever it likes.
|
|
75
|
+
def studio_link_preview_tags
|
|
76
|
+
render "layouts/studio/link_preview_tags", preview: studio_link_preview
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
Rails.logger&.warn("[studio.link_preview] tags skipped: #{e.class}: #{e.message}")
|
|
79
|
+
"".html_safe
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def studio_site_identity_stored
|
|
85
|
+
Studio::SiteIdentity.stored
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
Rails.logger&.warn("[studio.link_preview] site identity unavailable: #{e.class}: #{e.message}")
|
|
88
|
+
{}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def studio_link_preview_image_location(image)
|
|
92
|
+
return nil if image.nil?
|
|
93
|
+
return image.to_s if image.is_a?(String) || image.is_a?(Symbol)
|
|
94
|
+
|
|
95
|
+
location = Studio::SiteIdentity.image_location(image)
|
|
96
|
+
location && (location[:url] || location[:path])
|
|
97
|
+
rescue StandardError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# content_for holds ESCAPED text in a SafeBuffer, and the resolver's strip
|
|
102
|
+
# returns a plain String the tag partial escapes again ("Pass &amp; Run").
|
|
103
|
+
# Hand the resolver the plain text instead.
|
|
104
|
+
def studio_link_preview_content(key)
|
|
105
|
+
value = content_for(key)
|
|
106
|
+
value && CGI.unescapeHTML(value.to_str)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def studio_request_base_url
|
|
110
|
+
respond_to?(:request) && request ? request.base_url : nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
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
|