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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Studio
|
|
6
|
+
module Generators
|
|
7
|
+
# Adopt the site identity + link-preview primitive in one repeatable step:
|
|
8
|
+
#
|
|
9
|
+
# bin/rails g studio:site_identity \
|
|
10
|
+
# --title "Turf Monster" \
|
|
11
|
+
# --description "Skill-based pick'em contests with transparent payouts."
|
|
12
|
+
# bin/rails db:migrate
|
|
13
|
+
#
|
|
14
|
+
# What it does, each step idempotent (a second run changes nothing):
|
|
15
|
+
#
|
|
16
|
+
# 1. copies the engine's studio_site_identities migration into
|
|
17
|
+
# db/migrate, in exactly the form `studio_engine:install:migrations`
|
|
18
|
+
# writes (so that task later sees it and skips it) — and ONLY that
|
|
19
|
+
# migration, not every pending engine migration;
|
|
20
|
+
# 2. writes the DRAFTED title and description into
|
|
21
|
+
# config/initializers/studio.rb as config.site_title /
|
|
22
|
+
# config.site_description (the standing convention: an agent drafts them
|
|
23
|
+
# when it sets an app up, Alex edits them at /admin/link_preview);
|
|
24
|
+
# 3. includes Studio::LinkPreviewBots in ApplicationController, so preview
|
|
25
|
+
# fetchers get the slim page under Apple's 1 MiB limit.
|
|
26
|
+
#
|
|
27
|
+
# --own-tags is for an app that still writes its own og tags (turf-monster,
|
|
28
|
+
# cyvasse): it sets config.link_preview_tags = false, so installing the table
|
|
29
|
+
# does not add a second set before the app's own are deleted.
|
|
30
|
+
class SiteIdentityGenerator < Rails::Generators::Base
|
|
31
|
+
MIGRATION_NAME = "create_studio_site_identities"
|
|
32
|
+
ENGINE_SCOPE = "studio_engine"
|
|
33
|
+
INITIALIZER = "config/initializers/studio.rb"
|
|
34
|
+
CONTROLLER = "app/controllers/application_controller.rb"
|
|
35
|
+
|
|
36
|
+
class_option :title, type: :string, desc: "The drafted site title (config.site_title)"
|
|
37
|
+
class_option :description, type: :string, desc: "The drafted site description (config.site_description)"
|
|
38
|
+
class_option :own_tags, type: :boolean, default: false,
|
|
39
|
+
desc: "This app still writes its own og tags: set config.link_preview_tags = false"
|
|
40
|
+
class_option :skip_bots, type: :boolean, default: false,
|
|
41
|
+
desc: "Do not include Studio::LinkPreviewBots in ApplicationController"
|
|
42
|
+
|
|
43
|
+
def copy_migration
|
|
44
|
+
existing = Dir.glob(File.join(destination_root, "db/migrate/*_#{MIGRATION_NAME}{,.#{ENGINE_SCOPE}}.rb"))
|
|
45
|
+
if existing.any?
|
|
46
|
+
say_status :identical, relative(existing.first), :blue
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
source = self.class.engine_migration
|
|
51
|
+
original_version = File.basename(source)[/\A\d+/]
|
|
52
|
+
body = "# This migration comes from #{ENGINE_SCOPE} (originally #{original_version})\n#{File.read(source)}"
|
|
53
|
+
create_file "db/migrate/#{self.class.next_version}_#{MIGRATION_NAME}.#{ENGINE_SCOPE}.rb", body
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def configure_initializer
|
|
57
|
+
path = File.join(destination_root, INITIALIZER)
|
|
58
|
+
unless File.exist?(path)
|
|
59
|
+
say_status :skip, "#{INITIALIZER} not found — add the site_title/site_description lines by hand", :yellow
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
content = File.read(path)
|
|
64
|
+
if content.include?("config.site_title")
|
|
65
|
+
say_status :identical, "#{INITIALIZER} (config.site_title already set)", :blue
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
anchor = content[/^Studio\.configure do \|(\w+)\|\n/]
|
|
70
|
+
unless anchor
|
|
71
|
+
say_status :skip, "#{INITIALIZER} has no `Studio.configure do |config|` block", :yellow
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
inject_into_file INITIALIZER, initializer_block(anchor[/\|(\w+)\|/, 1]), after: anchor
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def include_bot_concern
|
|
79
|
+
return if options[:skip_bots]
|
|
80
|
+
|
|
81
|
+
path = File.join(destination_root, CONTROLLER)
|
|
82
|
+
return say_status(:skip, "#{CONTROLLER} not found", :yellow) unless File.exist?(path)
|
|
83
|
+
return say_status(:identical, "#{CONTROLLER} (Studio::LinkPreviewBots)", :blue) if File.read(path).include?("Studio::LinkPreviewBots")
|
|
84
|
+
|
|
85
|
+
inject_into_class CONTROLLER, "ApplicationController", <<~RUBY
|
|
86
|
+
# Preview fetchers (iMessage, Slack, Discord, X...) get a slim page under
|
|
87
|
+
# Apple's 1 MiB limit. studio-engine docs/LINK_PREVIEW.md.
|
|
88
|
+
include Studio::LinkPreviewBots
|
|
89
|
+
RUBY
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def next_steps
|
|
93
|
+
say <<~TEXT
|
|
94
|
+
|
|
95
|
+
Site identity installed. Next:
|
|
96
|
+
bin/rails db:migrate
|
|
97
|
+
Visit /admin/link_preview to set the image and edit the title and description.
|
|
98
|
+
Read the copy anywhere with Studio.site_identity (or studio_site_identity in a view).
|
|
99
|
+
TEXT
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.engine_migration
|
|
103
|
+
File.expand_path("../../../../db/migrate/20260930120000_#{MIGRATION_NAME}.rb", __dir__)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.next_version
|
|
107
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def relative(path)
|
|
113
|
+
path.delete_prefix("#{destination_root}/")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def initializer_block(var)
|
|
117
|
+
title = options[:title].to_s.strip
|
|
118
|
+
description = options[:description].to_s.strip
|
|
119
|
+
lines = []
|
|
120
|
+
lines << ""
|
|
121
|
+
lines << " # ---- Site identity + link preview (studio-engine docs/LINK_PREVIEW.md) ----"
|
|
122
|
+
lines << " # The DRAFTED title and description; the operator edits them at"
|
|
123
|
+
lines << " # /admin/link_preview, and Studio.site_identity reads the result."
|
|
124
|
+
lines << (title.empty? ? " # #{var}.site_title = \"Draft the site title\"" : " #{var}.site_title = #{title.inspect}")
|
|
125
|
+
lines << (description.empty? ? " # #{var}.site_description = \"Draft one or two sentences\"" : " #{var}.site_description = #{description.inspect}")
|
|
126
|
+
if options[:own_tags]
|
|
127
|
+
lines << " # This app still writes its own og tags. Delete them, then remove this line."
|
|
128
|
+
lines << " #{var}.link_preview_tags = false"
|
|
129
|
+
end
|
|
130
|
+
lines << ""
|
|
131
|
+
lines.join("\n") + "\n"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Studio
|
|
6
|
+
# The house link-preview primitive: what an unfurl (iMessage, Slack, Discord,
|
|
7
|
+
# X, WhatsApp...) shows when someone pastes a link to this app.
|
|
8
|
+
#
|
|
9
|
+
# Deliberately PURE, like Studio::Geo: plain strings in, plain strings and
|
|
10
|
+
# hashes out, no request and no database. The pieces that need the world live
|
|
11
|
+
# beside it:
|
|
12
|
+
#
|
|
13
|
+
# Studio::SiteIdentity (model) the operator's default image, title and
|
|
14
|
+
# description, set at /admin/link_preview
|
|
15
|
+
# Studio::LinkPreviewHelper (view helper) the page override
|
|
16
|
+
# (`link_preview`) and the tags the head renders
|
|
17
|
+
# Studio::LinkPreviewBots (controller concern) the slim document preview
|
|
18
|
+
# fetchers get instead of the full page
|
|
19
|
+
#
|
|
20
|
+
# Lifted from turf-monster (OgHelper, SiteSetting, OgImageAttachable and the
|
|
21
|
+
# app-local LinkPreviewBot of task imessage-link-preview-fix), which carried
|
|
22
|
+
# all of this in production first. See docs/LINK_PREVIEW.md.
|
|
23
|
+
module LinkPreview
|
|
24
|
+
# Apple's LinkPresentation (the iMessage unfurler, which runs on the SENDER's
|
|
25
|
+
# phone) aborts any HTML page over this many bytes with WebKitErrorDomain 102,
|
|
26
|
+
# "Frame load interrupted". Measured 2026-09-30: 1,048,000 bytes previewed,
|
|
27
|
+
# 1,049,000 failed. The slim document is held under it.
|
|
28
|
+
MAX_DOCUMENT_BYTES = 1_048_576
|
|
29
|
+
|
|
30
|
+
# The preview fetchers, by User-Agent token. An ALLOW-LIST: only an agent
|
|
31
|
+
# named here gets the slim document; a person, or an agent nobody named,
|
|
32
|
+
# always gets the full page. Each token is a product's own FETCHER, not its
|
|
33
|
+
# in-app browser: Facebook's in-app browser sends FBAN/FBAV, LinkedIn's sends
|
|
34
|
+
# LinkedInApp, X's sends "Twitter for iPhone", and none of those match.
|
|
35
|
+
#
|
|
36
|
+
# iMessage has no token of its own. LinkPresentation sends an old-Safari UA
|
|
37
|
+
# suffixed "facebookexternalhit/1.1 Facebot Twitterbot/1.0", so it rides the
|
|
38
|
+
# Facebook and X tokens.
|
|
39
|
+
BOT_TOKENS = [
|
|
40
|
+
"facebookexternalhit", # Facebook, Messenger; also Apple LinkPresentation (iMessage)
|
|
41
|
+
"Facebot", # Facebook; also iMessage
|
|
42
|
+
"Twitterbot", # X; also iMessage
|
|
43
|
+
"Discordbot",
|
|
44
|
+
"Slackbot-LinkExpanding",
|
|
45
|
+
"LinkedInBot",
|
|
46
|
+
"WhatsApp/", # the fetcher sends WhatsApp/<version>
|
|
47
|
+
"TelegramBot",
|
|
48
|
+
"Applebot", # Siri / Spotlight suggestions
|
|
49
|
+
"SkypeUriPreview", # Skype and Teams
|
|
50
|
+
"redditbot",
|
|
51
|
+
"Embedly"
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
BOT_PATTERN = Regexp.union(BOT_TOKENS.map { |token| /#{Regexp.escape(token)}/i }).freeze
|
|
55
|
+
|
|
56
|
+
# What the slim document keeps from the page's head. Everything a fetcher
|
|
57
|
+
# reads for a card, and nothing it executes: no script, style or template.
|
|
58
|
+
KEPT_META_NAMES = %w[
|
|
59
|
+
description robots theme-color application-name author keywords
|
|
60
|
+
].freeze
|
|
61
|
+
KEPT_LINK_RELS = %w[
|
|
62
|
+
canonical icon shortcut\ icon apple-touch-icon apple-touch-icon-precomposed manifest mask-icon image_src
|
|
63
|
+
].freeze
|
|
64
|
+
|
|
65
|
+
module_function
|
|
66
|
+
|
|
67
|
+
# The first view template under `views_root` that writes its own og:title or
|
|
68
|
+
# og:image, or nil. Studio.link_preview_tags? asks this under :auto, so an
|
|
69
|
+
# app that still writes its own preview tags (turf-monster's
|
|
70
|
+
# layouts/_link_preview_meta, cyvasse's layouts/_seo) does not get a second
|
|
71
|
+
# set the day it installs the engine's migrations — which docs/
|
|
72
|
+
# NEW_APP_SETUP.md tells every app to do after every upgrade.
|
|
73
|
+
#
|
|
74
|
+
# Deliberately BROAD: any mention counts, a comment included. A false
|
|
75
|
+
# positive only keeps the engine's tags off (an app sets
|
|
76
|
+
# link_preview_tags = true to override); a false negative would double them.
|
|
77
|
+
OWN_TAG_PATTERN = /og:(?:title|image)\b/
|
|
78
|
+
TEMPLATE_GLOB = "**/*.{erb,haml,slim}"
|
|
79
|
+
|
|
80
|
+
def own_tag_file(views_root)
|
|
81
|
+
root = views_root.to_s
|
|
82
|
+
return nil if root.empty? || !File.directory?(root)
|
|
83
|
+
|
|
84
|
+
Dir.glob(File.join(root, TEMPLATE_GLOB)).sort.find do |path|
|
|
85
|
+
File.read(path, encoding: "UTF-8").scrub.match?(OWN_TAG_PATTERN)
|
|
86
|
+
rescue SystemCallError
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Is this User-Agent a link-preview fetcher? Blank is never a bot.
|
|
92
|
+
def bot?(user_agent)
|
|
93
|
+
ua = user_agent.to_s
|
|
94
|
+
return false if ua.strip.empty?
|
|
95
|
+
|
|
96
|
+
BOT_PATTERN.match?(ua)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# THE RESOLUTION CHAIN. Each argument is a list of rungs, most specific
|
|
100
|
+
# first; the first present rung wins.
|
|
101
|
+
#
|
|
102
|
+
# title page override(s) -> operator default -> site name
|
|
103
|
+
# description page override(s) -> operator default -> none (tag omitted)
|
|
104
|
+
# image page override(s) -> operator default -> static fallback -> none
|
|
105
|
+
#
|
|
106
|
+
# A page override with NO image (a user with no avatar) is simply a blank
|
|
107
|
+
# rung, so it falls through to the default: that is the whole override rule.
|
|
108
|
+
# `image_source` says which rung answered (:page, :default, :static, :none).
|
|
109
|
+
def resolve(site_name:, titles: [], descriptions: [], page_images: [], default_image: nil, static_image: nil)
|
|
110
|
+
page_image = first_present(page_images)
|
|
111
|
+
image, source =
|
|
112
|
+
if page_image then [page_image, :page]
|
|
113
|
+
elsif present?(default_image) then [default_image.to_s, :default]
|
|
114
|
+
elsif present?(static_image) then [static_image.to_s, :static]
|
|
115
|
+
else [nil, :none]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
title: first_present(titles) || site_name.to_s,
|
|
120
|
+
description: first_present(descriptions),
|
|
121
|
+
image: image,
|
|
122
|
+
image_source: source
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# An image URL a fetcher can follow from anywhere. Unfurlers resolve nothing
|
|
127
|
+
# relative, so a root-relative path is joined to the request's base URL; a
|
|
128
|
+
# protocol-relative one is given https. Absolute URLs pass through.
|
|
129
|
+
def absolute_url(url, base_url:)
|
|
130
|
+
value = url.to_s.strip
|
|
131
|
+
return nil if value.empty?
|
|
132
|
+
return "https:#{value}" if value.start_with?("//")
|
|
133
|
+
return "#{base_url.to_s.chomp("/")}#{value}" if value.start_with?("/")
|
|
134
|
+
|
|
135
|
+
value
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The whole response a preview fetcher gets: the page's own identity tags,
|
|
139
|
+
# lifted out of its <head>, and a one-card body. Built FROM the rendered
|
|
140
|
+
# page, so the slim document cannot drift from what a person's page says.
|
|
141
|
+
#
|
|
142
|
+
# DUPLICATE-SAFE: a meta tag named twice (an app emitting its own og tags
|
|
143
|
+
# AND the engine's) is kept once, the FIRST occurrence — the one the page
|
|
144
|
+
# put first, which is the one an app that owns its tags writes.
|
|
145
|
+
def slim_document(html, url: nil)
|
|
146
|
+
source = html.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
147
|
+
head = source[%r{<head\b[^>]*>(.*?)</head\s*>}mi, 1] || source
|
|
148
|
+
head = head.gsub(%r{<(script|style|template|noscript)\b.*?</\1\s*>}mi, "").gsub(/<!--.*?-->/m, "")
|
|
149
|
+
lang = source[/<html\b[^>]*\blang\s*=\s*["']([^"']+)["']/i, 1]
|
|
150
|
+
title = head[%r{<title\b[^>]*>(.*?)</title\s*>}mi, 1]&.strip
|
|
151
|
+
|
|
152
|
+
tags = kept_tags(head)
|
|
153
|
+
document = build_document(tags, title: title, lang: lang, url: url)
|
|
154
|
+
return document if document.bytesize < MAX_DOCUMENT_BYTES
|
|
155
|
+
|
|
156
|
+
# A head that is itself enormous (an inline data: icon, say). Keep only
|
|
157
|
+
# what an unfurl card is made of.
|
|
158
|
+
core = tags.select { |tag| tag_key(tag).to_s.match?(/\A(og:|twitter:|description\z)/) }
|
|
159
|
+
build_document(core, title: title, lang: lang, url: url)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def kept_tags(head)
|
|
163
|
+
seen = {}
|
|
164
|
+
head.scan(/<meta\b[^>]*>|<link\b[^>]*>/i).select do |tag|
|
|
165
|
+
next false unless keep_tag?(tag)
|
|
166
|
+
|
|
167
|
+
key = tag_key(tag)
|
|
168
|
+
next true if key.nil?
|
|
169
|
+
next false if seen[key]
|
|
170
|
+
|
|
171
|
+
seen[key] = true
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def keep_tag?(tag)
|
|
176
|
+
if tag.match?(/\A<link/i)
|
|
177
|
+
rel = attribute(tag, "rel").to_s.downcase.strip
|
|
178
|
+
return KEPT_LINK_RELS.include?(rel)
|
|
179
|
+
end
|
|
180
|
+
return false if attribute(tag, "charset") # the document writes its own
|
|
181
|
+
|
|
182
|
+
property = attribute(tag, "property").to_s
|
|
183
|
+
return true unless property.empty?
|
|
184
|
+
|
|
185
|
+
name = attribute(tag, "name").to_s.downcase
|
|
186
|
+
name.start_with?("twitter:") || KEPT_META_NAMES.include?(name)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The identity a duplicate is judged by: a meta's property or name, a link's
|
|
190
|
+
# rel. Case-folded, because <meta name="Description"> is the same claim.
|
|
191
|
+
def tag_key(tag)
|
|
192
|
+
if tag.match?(/\A<link/i)
|
|
193
|
+
rel = attribute(tag, "rel")
|
|
194
|
+
return rel ? "link:#{rel.downcase}:#{attribute(tag, "sizes")}" : nil
|
|
195
|
+
end
|
|
196
|
+
(attribute(tag, "property") || attribute(tag, "name"))&.downcase
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def attribute(tag, name)
|
|
200
|
+
tag[/\s#{Regexp.escape(name)}\s*=\s*"([^"]*)"/i, 1] || tag[/\s#{Regexp.escape(name)}\s*=\s*'([^']*)'/i, 1]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def build_document(tags, title:, lang:, url:)
|
|
204
|
+
og_title = content_of(tags, "og:title") || title
|
|
205
|
+
og_description = content_of(tags, "og:description") || content_of(tags, "description")
|
|
206
|
+
lang_attr = lang ? %( lang="#{ERB::Util.html_escape(lang)}") : ""
|
|
207
|
+
|
|
208
|
+
body = +""
|
|
209
|
+
body << "<h1>#{og_title}</h1>\n" if og_title
|
|
210
|
+
body << "<p>#{og_description}</p>\n" if og_description
|
|
211
|
+
body << %(<p><a href="#{ERB::Util.html_escape(url)}">#{og_title || ERB::Util.html_escape(url)}</a></p>\n) if url
|
|
212
|
+
|
|
213
|
+
<<~HTML
|
|
214
|
+
<!DOCTYPE html>
|
|
215
|
+
<html#{lang_attr}>
|
|
216
|
+
<head>
|
|
217
|
+
<meta charset="utf-8">
|
|
218
|
+
#{"<title>#{title}</title>\n" if title}#{tags.join("\n")}
|
|
219
|
+
</head>
|
|
220
|
+
<body>
|
|
221
|
+
#{body}</body>
|
|
222
|
+
</html>
|
|
223
|
+
HTML
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Already escaped: the value came out of a rendered attribute, so it is
|
|
227
|
+
# emitted back into markup exactly as the page wrote it.
|
|
228
|
+
def content_of(tags, key)
|
|
229
|
+
tag = tags.find { |t| tag_key(t) == key }
|
|
230
|
+
tag && attribute(tag, "content")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def first_present(values)
|
|
234
|
+
Array(values).each do |value|
|
|
235
|
+
return value.to_s.strip if present?(value)
|
|
236
|
+
end
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def present?(value)
|
|
241
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The public user page's two rules, in one place: which account a /u/:username
|
|
5
|
+
# URL names, and which username a link to an account carries.
|
|
6
|
+
#
|
|
7
|
+
# THE KEY IS `username` AND NOTHING ELSE. It is the one identity field a person
|
|
8
|
+
# chose to show other people. The hub apps' `slug` is keyed on the email, and a
|
|
9
|
+
# lookup by email would tell a stranger whether an address has an account, so
|
|
10
|
+
# neither ever stands in: an app without a username column serves no public
|
|
11
|
+
# pages (every lookup misses, every link helper answers nil) until it adds one.
|
|
12
|
+
#
|
|
13
|
+
# Pure lookups, no request: Studio::PublicUsersController and
|
|
14
|
+
# Studio::PublicUserHelper both call these, so the page and the links to it
|
|
15
|
+
# cannot disagree about what a username is.
|
|
16
|
+
module PublicUser
|
|
17
|
+
COLUMN = "username"
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Whether the host's User model can have a public page at all.
|
|
22
|
+
def supported?(user_class = default_user_class)
|
|
23
|
+
return false if user_class.nil?
|
|
24
|
+
|
|
25
|
+
user_class.respond_to?(:column_names) && user_class.column_names.include?(COLUMN)
|
|
26
|
+
rescue StandardError
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The account a /u/:username URL names, or nil. Case-insensitive: /u/Alex
|
|
31
|
+
# and /u/alex are the same page. An exact match wins first, because an app
|
|
32
|
+
# whose lower(username) index is not unique (cyvasse's) can hold "Alex" and
|
|
33
|
+
# "alex" as two people, and the one who is exactly named should get the page.
|
|
34
|
+
#
|
|
35
|
+
# A host that must hide an account (frozen, merged away, banned) defines
|
|
36
|
+
# `public_profile_visible?` on User; false answers nil, the same miss an
|
|
37
|
+
# unknown username gets, so the page never says why.
|
|
38
|
+
def find(username, user_class: default_user_class)
|
|
39
|
+
name = username.to_s.strip
|
|
40
|
+
return nil if name.empty? || !supported?(user_class)
|
|
41
|
+
|
|
42
|
+
user = user_class.find_by(COLUMN => name) ||
|
|
43
|
+
user_class.where(user_class.arel_table[COLUMN].lower.eq(name.downcase)).order(:id).first
|
|
44
|
+
return nil if user.nil?
|
|
45
|
+
return nil if user.respond_to?(:public_profile_visible?) && !user.public_profile_visible?
|
|
46
|
+
|
|
47
|
+
user
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The username a link to this account carries, or nil when it has none.
|
|
51
|
+
# Takes a user, or a username string as given.
|
|
52
|
+
def username_for(user_or_username)
|
|
53
|
+
value = if user_or_username.is_a?(String) || user_or_username.is_a?(Symbol)
|
|
54
|
+
user_or_username.to_s
|
|
55
|
+
elsif user_or_username.respond_to?(COLUMN)
|
|
56
|
+
user_or_username.public_send(COLUMN).to_s
|
|
57
|
+
end
|
|
58
|
+
value&.strip.presence
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def default_user_class
|
|
62
|
+
defined?(::User) ? ::User : nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -3,6 +3,7 @@ require "studio/log_rotation"
|
|
|
3
3
|
require "studio/ip_locations"
|
|
4
4
|
require "studio/geo"
|
|
5
5
|
require "studio/geo/lookup"
|
|
6
|
+
require "studio/link_preview"
|
|
6
7
|
require "studio/engine"
|
|
7
8
|
require "studio/color_scale"
|
|
8
9
|
require "studio/environment_banner"
|
|
@@ -19,6 +20,7 @@ require "studio/profile_image"
|
|
|
19
20
|
require "studio/oauth_identity"
|
|
20
21
|
require "studio/newsletter"
|
|
21
22
|
require "studio/username_generator"
|
|
23
|
+
require "studio/public_user"
|
|
22
24
|
require "studio/name_parts"
|
|
23
25
|
require "studio/s3"
|
|
24
26
|
require "studio/image_cache"
|
|
@@ -471,6 +473,125 @@ module Studio
|
|
|
471
473
|
# is loading — taking its entire route set down, not just this page.
|
|
472
474
|
mattr_accessor :draw_geo_routes, default: false
|
|
473
475
|
|
|
476
|
+
# ---- Link preview (Studio::LinkPreview, SiteIdentity, LinkPreviewHelper)
|
|
477
|
+
#
|
|
478
|
+
# What an unfurl shows when someone pastes a link to this app: the SITE
|
|
479
|
+
# IDENTITY (Studio::SiteIdentity — image, title, description) the operator sets
|
|
480
|
+
# at /admin/link_preview, which any page may override (`link_preview image:
|
|
481
|
+
# ..., title: ...`). See docs/LINK_PREVIEW.md.
|
|
482
|
+
|
|
483
|
+
# Whether layouts/studio/_head emits the og:/twitter: tags.
|
|
484
|
+
#
|
|
485
|
+
# :auto (default) emit once this app has INSTALLED the engine's
|
|
486
|
+
# studio_site_identities table — installing the
|
|
487
|
+
# migration is the adoption act — and no template under
|
|
488
|
+
# app/views writes its own og:title/og:image. An app that
|
|
489
|
+
# emits its own tags (turf-monster, cyvasse) gets no second
|
|
490
|
+
# set, even after `install:migrations` brings the table in.
|
|
491
|
+
# true always emit (the static fallback and the site name still
|
|
492
|
+
# answer with no table).
|
|
493
|
+
# false never emit from the head; the app renders
|
|
494
|
+
# `studio_link_preview_tags` itself, or owns its tags.
|
|
495
|
+
#
|
|
496
|
+
# An app whose own tags live somewhere the scan cannot see (a helper that
|
|
497
|
+
# builds them in Ruby) sets this false until its adoption deletes them.
|
|
498
|
+
mattr_accessor :link_preview_tags, default: :auto
|
|
499
|
+
|
|
500
|
+
# The Active Storage service the DEFAULT image is attached to. nil = the app's
|
|
501
|
+
# default service. Unfurlers cache the og:image URL and re-fetch it days later,
|
|
502
|
+
# so the tag must be a PERMANENT URL: a service whose `public?` is true answers
|
|
503
|
+
# its own public URL; any other service is served through Rails' storage PROXY
|
|
504
|
+
# route (a permanent URL on this app's own domain, streaming from the private
|
|
505
|
+
# bucket). An app with a public-read service names it here, e.g. turf-monster:
|
|
506
|
+
#
|
|
507
|
+
# config.link_preview_image_service = OgImageAttachable::PUBLIC_OG_SERVICE
|
|
508
|
+
#
|
|
509
|
+
# Read once, when Studio::SiteIdentity loads (has_one_attached's service
|
|
510
|
+
# is a literal fixed at class load), so set it in the initializer.
|
|
511
|
+
mattr_accessor :link_preview_image_service, default: nil
|
|
512
|
+
|
|
513
|
+
# The last image rung, when neither the page nor the operator supplied one. A
|
|
514
|
+
# root-relative path is used only when the file exists under public/, so an app
|
|
515
|
+
# without it emits no og:image rather than a broken one. An absolute URL is
|
|
516
|
+
# trusted as given.
|
|
517
|
+
mattr_accessor :link_preview_fallback_image, default: "/og.png"
|
|
518
|
+
|
|
519
|
+
# THE SITE IDENTITY'S DRAFTED DEFAULTS — this app's title and description as
|
|
520
|
+
# written in code, under whatever the operator saves at /admin/link_preview.
|
|
521
|
+
# The standing convention is that an agent drafts these when it sets an app up
|
|
522
|
+
# (`bin/rails g studio:site_identity --title "..." --description "..."` writes
|
|
523
|
+
# them here) and Alex edits them on the page. nil title means Studio.app_name;
|
|
524
|
+
# nil description means none. Read the resolved answer through
|
|
525
|
+
# Studio.site_identity, never these directly.
|
|
526
|
+
mattr_accessor :site_title, default: nil
|
|
527
|
+
mattr_accessor :site_description, default: nil
|
|
528
|
+
|
|
529
|
+
# Draw /admin/link_preview from Studio.routes. ON by default: no consumer owns
|
|
530
|
+
# these paths or helper names (admin_link_preview, admin_link_preview_image),
|
|
531
|
+
# checked 2026-09-30 across mcritchie-studio, turf-monster, cyvasse and
|
|
532
|
+
# mcritchie-industries. The page explains itself when the table is missing.
|
|
533
|
+
mattr_accessor :draw_link_preview_routes, default: true
|
|
534
|
+
|
|
535
|
+
# Draw the public user page, GET /u/:username (Studio::PublicUsersController,
|
|
536
|
+
# route helper studio_public_user_path; link to it with the view helpers
|
|
537
|
+
# studio_user_profile_path(user) / link_to_user_profile(user)). See
|
|
538
|
+
# docs/PUBLIC_USER_PAGE.md.
|
|
539
|
+
#
|
|
540
|
+
# OFF by default, like every route surface a consumer might already own. No
|
|
541
|
+
# consumer owns /u or the helper name today (checked 2026-09-30 across
|
|
542
|
+
# mcritchie-studio, mcritchie-industries, cyvasse and turf-monster), but the
|
|
543
|
+
# page also needs a `username` column, which the two hub apps do not have yet
|
|
544
|
+
# (their slug is keyed on the email, so it can never stand in). Each app's
|
|
545
|
+
# adoption turns it on:
|
|
546
|
+
#
|
|
547
|
+
# config.draw_public_user_routes = true
|
|
548
|
+
mattr_accessor :draw_public_user_routes, default: false
|
|
549
|
+
|
|
550
|
+
# THE APP'S IDENTITY COPY, resolved: { title:, description:, image_url: }.
|
|
551
|
+
# The operator's saved value (Studio::SiteIdentity, edited at
|
|
552
|
+
# /admin/link_preview) wins, then the drafted Studio.site_title /
|
|
553
|
+
# site_description, then Studio.app_name for the title. image_url is the
|
|
554
|
+
# uploaded image, else the static fallback, else nil — absolute when
|
|
555
|
+
# `base_url` is given (pass request.base_url) or the image lives on a public
|
|
556
|
+
# service. Reuse it anywhere the app needs to say what it is: a meta
|
|
557
|
+
# description, share text, an email footer. Views have `studio_site_identity`.
|
|
558
|
+
# Never raises; an app without the table answers from the drafted defaults.
|
|
559
|
+
def self.site_identity(base_url: nil)
|
|
560
|
+
Studio::SiteIdentity.resolved(base_url: base_url)
|
|
561
|
+
rescue StandardError
|
|
562
|
+
{ title: site_title.presence || app_name.to_s, description: site_description.presence, image_url: nil }
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# :auto emits when the table is installed AND no view of this app writes its
|
|
566
|
+
# own og tags (Studio.link_preview_own_tags_file); true/false are taken as
|
|
567
|
+
# given.
|
|
568
|
+
def self.link_preview_tags?
|
|
569
|
+
case link_preview_tags
|
|
570
|
+
when :auto, "auto", nil then Studio::SiteIdentity.table_ready? && link_preview_own_tags_file.nil?
|
|
571
|
+
else !!link_preview_tags
|
|
572
|
+
end
|
|
573
|
+
rescue StandardError
|
|
574
|
+
false
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# The host view that writes its own og tags, or nil — scanned once per process
|
|
578
|
+
# (app/views is fixed at deploy) and logged once when it keeps :auto off.
|
|
579
|
+
def self.link_preview_own_tags_file
|
|
580
|
+
return @link_preview_own_tags_file if defined?(@link_preview_own_tags_file)
|
|
581
|
+
|
|
582
|
+
views = defined?(Rails.root) && Rails.root ? Rails.root.join("app/views") : nil
|
|
583
|
+
found = Studio::LinkPreview.own_tag_file(views)
|
|
584
|
+
if found && defined?(Rails.logger) && Rails.logger
|
|
585
|
+
Rails.logger.info("[studio.link_preview] #{found} writes its own og tags, so the engine's head tags " \
|
|
586
|
+
"stay off under link_preview_tags = :auto. Set it to true once they are removed.")
|
|
587
|
+
end
|
|
588
|
+
@link_preview_own_tags_file = found
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def self.reset_link_preview_own_tags!
|
|
592
|
+
remove_instance_variable(:@link_preview_own_tags_file) if defined?(@link_preview_own_tags_file)
|
|
593
|
+
end
|
|
594
|
+
|
|
474
595
|
# Whether the engine configures Geocoder on boot (provider, HTTPS, timeout, and
|
|
475
596
|
# a Rails.cache-backed IP cache). An app that configures Geocoder itself sets
|
|
476
597
|
# this false; an app with no geocoder gem is unaffected either way.
|
|
@@ -1110,6 +1231,25 @@ module Studio
|
|
|
1110
1231
|
patch "admin/geo", to: "studio/geo_settings#update", as: :admin_geo_update
|
|
1111
1232
|
post "admin/geo/toggle", to: "studio/geo_settings#toggle_override", as: :admin_geo_toggle
|
|
1112
1233
|
end
|
|
1234
|
+
|
|
1235
|
+
# The link-preview default (/admin/link_preview): the image, title and
|
|
1236
|
+
# description every page unfurls with unless it overrides them. ON by
|
|
1237
|
+
# default (Studio.draw_link_preview_routes) because no consumer owns these
|
|
1238
|
+
# names; an app can still switch it off from its initializer.
|
|
1239
|
+
if Studio.draw_link_preview_routes
|
|
1240
|
+
get "admin/link_preview", to: "studio/site_identities#edit", as: :admin_link_preview
|
|
1241
|
+
patch "admin/link_preview", to: "studio/site_identities#update"
|
|
1242
|
+
delete "admin/link_preview/image", to: "studio/site_identities#destroy_image", as: :admin_link_preview_image
|
|
1243
|
+
end
|
|
1244
|
+
# The public user page (/u/:username): avatar and username only, and the
|
|
1245
|
+
# user's avatar as its link-preview image. OPT-IN — see
|
|
1246
|
+
# Studio.draw_public_user_routes. `format: false` because usernames may
|
|
1247
|
+
# carry a dot, which would otherwise be read as a format extension.
|
|
1248
|
+
if Studio.draw_public_user_routes
|
|
1249
|
+
get "u/:username", to: "studio/public_users#show", as: :studio_public_user,
|
|
1250
|
+
format: false, constraints: { username: %r{[^/]+} }
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1113
1253
|
# The living style guide. Canonical at /admin/style (StyleController#index);
|
|
1114
1254
|
# /admin/design_system redirects here but KEEPS its admin_design_system_path
|
|
1115
1255
|
# helper so a shipped host sidebar link on the old helper still resolves.
|