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 +4 -4
- data/CHANGELOG.md +151 -0
- data/README.md +2 -0
- data/app/controllers/concerns/studio/link_preview_bots.rb +110 -0
- data/app/controllers/studio/public_users_controller.rb +32 -0
- data/app/controllers/studio/site_identities_controller.rb +119 -0
- data/app/helpers/studio/link_preview_helper.rb +161 -0
- data/app/helpers/studio/public_user_helper.rb +61 -0
- data/app/models/studio/site_identity.rb +263 -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 +37 -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 +139 -0
- data/lib/studio/link_preview.rb +335 -0
- data/lib/studio/public_user.rb +65 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +151 -0
- metadata +16 -2
|
@@ -0,0 +1,139 @@
|
|
|
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 inserts the text as given, right after the class
|
|
86
|
+
# line, so the body's two-space indent is written here (a bare heredoc
|
|
87
|
+
# landed at column 0 in cyvasse).
|
|
88
|
+
inject_into_class CONTROLLER, "ApplicationController", <<~RUBY.gsub(/^(?=.)/, " ")
|
|
89
|
+
# Preview fetchers (iMessage, Slack, Discord, X...) get a slim page under
|
|
90
|
+
# Apple's 1 MiB limit, and are exempt from allow_browser. studio-engine
|
|
91
|
+
# docs/LINK_PREVIEW.md.
|
|
92
|
+
include Studio::LinkPreviewBots
|
|
93
|
+
RUBY
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def next_steps
|
|
97
|
+
say <<~TEXT
|
|
98
|
+
|
|
99
|
+
Site identity installed. Next:
|
|
100
|
+
bin/rails db:migrate
|
|
101
|
+
Visit /admin/link_preview to set the image and edit the title and description.
|
|
102
|
+
Read the copy anywhere with Studio.site_identity (or studio_site_identity in a view).
|
|
103
|
+
TEXT
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.engine_migration
|
|
107
|
+
File.expand_path("../../../../db/migrate/20260930120000_#{MIGRATION_NAME}.rb", __dir__)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.next_version
|
|
111
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def relative(path)
|
|
117
|
+
path.delete_prefix("#{destination_root}/")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def initializer_block(var)
|
|
121
|
+
title = options[:title].to_s.strip
|
|
122
|
+
description = options[:description].to_s.strip
|
|
123
|
+
lines = []
|
|
124
|
+
lines << ""
|
|
125
|
+
lines << " # ---- Site identity + link preview (studio-engine docs/LINK_PREVIEW.md) ----"
|
|
126
|
+
lines << " # The DRAFTED title and description; the operator edits them at"
|
|
127
|
+
lines << " # /admin/link_preview, and Studio.site_identity reads the result."
|
|
128
|
+
lines << (title.empty? ? " # #{var}.site_title = \"Draft the site title\"" : " #{var}.site_title = #{title.inspect}")
|
|
129
|
+
lines << (description.empty? ? " # #{var}.site_description = \"Draft one or two sentences\"" : " #{var}.site_description = #{description.inspect}")
|
|
130
|
+
if options[:own_tags]
|
|
131
|
+
lines << " # This app still writes its own og tags. Delete them, then remove this line."
|
|
132
|
+
lines << " #{var}.link_preview_tags = false"
|
|
133
|
+
end
|
|
134
|
+
lines << ""
|
|
135
|
+
lines.join("\n") + "\n"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,335 @@
|
|
|
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 base URL every preview URL is built on: the app's PINNED canonical
|
|
139
|
+
# base (Studio.link_preview_base_url) when it set one, else the request's.
|
|
140
|
+
# Trailing slash dropped, so a path joins cleanly. nil when neither exists.
|
|
141
|
+
def base_url(pinned:, request_base_url:)
|
|
142
|
+
pinned_value = pinned.to_s.strip.chomp("/")
|
|
143
|
+
return pinned_value unless pinned_value.empty?
|
|
144
|
+
|
|
145
|
+
request_value = request_base_url.to_s.strip.chomp("/")
|
|
146
|
+
request_value.empty? ? nil : request_value
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The page's og:url. With a pinned base it is that base plus the PATH: no
|
|
150
|
+
# herokuapp host, no ?utm_ query, so every share of a page counts as one URL.
|
|
151
|
+
# Without one it is the URL as requested, as it always was.
|
|
152
|
+
def page_url(base_url:, request_url:, path:)
|
|
153
|
+
pinned_value = base_url.to_s.strip.chomp("/")
|
|
154
|
+
return request_url.to_s.empty? ? nil : request_url.to_s if pinned_value.empty?
|
|
155
|
+
|
|
156
|
+
"#{pinned_value}#{path.to_s.empty? ? "/" : path}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# [width, height] of a PNG, GIF, JPEG or WebP file, read from its header
|
|
160
|
+
# only, or nil when the file is missing, unreadable or another format. Lets
|
|
161
|
+
# the static fallback (public/og.png) emit og:image:width/height, which
|
|
162
|
+
# WhatsApp and others use to lay the card out before the image arrives.
|
|
163
|
+
def image_dimensions(path)
|
|
164
|
+
File.open(path.to_s, "rb") { |file| dimensions_from(file.read(64 * 1024).to_s.b) }
|
|
165
|
+
rescue SystemCallError, IOError
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def dimensions_from(bytes)
|
|
170
|
+
dims =
|
|
171
|
+
if bytes.start_with?("\x89PNG\r\n\x1A\n".b) && bytes.bytesize >= 24
|
|
172
|
+
bytes[16, 8].unpack("NN")
|
|
173
|
+
elsif bytes.start_with?("GIF8") && bytes.bytesize >= 10
|
|
174
|
+
bytes[6, 4].unpack("vv")
|
|
175
|
+
elsif bytes.start_with?("\xFF\xD8".b)
|
|
176
|
+
jpeg_dimensions(bytes)
|
|
177
|
+
elsif bytes.start_with?("RIFF") && bytes[8, 4] == "WEBP"
|
|
178
|
+
webp_dimensions(bytes)
|
|
179
|
+
end
|
|
180
|
+
dims && dims.all? { |n| n.is_a?(Integer) && n.positive? } ? dims : nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Walk the JPEG segments to the first start-of-frame marker.
|
|
184
|
+
JPEG_SOF_MARKERS = [0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF].freeze
|
|
185
|
+
|
|
186
|
+
def jpeg_dimensions(bytes)
|
|
187
|
+
offset = 2
|
|
188
|
+
while offset + 9 <= bytes.bytesize
|
|
189
|
+
return nil unless bytes.getbyte(offset) == 0xFF
|
|
190
|
+
|
|
191
|
+
marker = bytes.getbyte(offset + 1)
|
|
192
|
+
if marker == 0xFF # fill byte
|
|
193
|
+
offset += 1
|
|
194
|
+
next
|
|
195
|
+
end
|
|
196
|
+
length = bytes[offset + 2, 2].unpack1("n")
|
|
197
|
+
if JPEG_SOF_MARKERS.include?(marker)
|
|
198
|
+
height, width = bytes[offset + 5, 4].unpack("nn")
|
|
199
|
+
return [width, height]
|
|
200
|
+
end
|
|
201
|
+
return nil if length.nil? || length < 2
|
|
202
|
+
|
|
203
|
+
offset += 2 + length
|
|
204
|
+
end
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def webp_dimensions(bytes)
|
|
209
|
+
case bytes[12, 4]
|
|
210
|
+
when "VP8 "
|
|
211
|
+
return nil if bytes.bytesize < 30
|
|
212
|
+
|
|
213
|
+
width, height = bytes[26, 4].unpack("vv")
|
|
214
|
+
[width & 0x3FFF, height & 0x3FFF]
|
|
215
|
+
when "VP8L"
|
|
216
|
+
return nil if bytes.bytesize < 25
|
|
217
|
+
|
|
218
|
+
bits = bytes[21, 4].unpack1("V")
|
|
219
|
+
[(bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1]
|
|
220
|
+
when "VP8X"
|
|
221
|
+
return nil if bytes.bytesize < 30
|
|
222
|
+
|
|
223
|
+
w = bytes[24, 3].unpack("CCC")
|
|
224
|
+
h = bytes[27, 3].unpack("CCC")
|
|
225
|
+
[(w[0] | (w[1] << 8) | (w[2] << 16)) + 1, (h[0] | (h[1] << 8) | (h[2] << 16)) + 1]
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# The whole response a preview fetcher gets: the page's own identity tags,
|
|
230
|
+
# lifted out of its <head>, and a one-card body. Built FROM the rendered
|
|
231
|
+
# page, so the slim document cannot drift from what a person's page says.
|
|
232
|
+
#
|
|
233
|
+
# DUPLICATE-SAFE: a meta tag named twice (an app emitting its own og tags
|
|
234
|
+
# AND the engine's) is kept once, the FIRST occurrence — the one the page
|
|
235
|
+
# put first, which is the one an app that owns its tags writes.
|
|
236
|
+
def slim_document(html, url: nil)
|
|
237
|
+
source = html.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
238
|
+
head = source[%r{<head\b[^>]*>(.*?)</head\s*>}mi, 1] || source
|
|
239
|
+
head = head.gsub(%r{<(script|style|template|noscript)\b.*?</\1\s*>}mi, "").gsub(/<!--.*?-->/m, "")
|
|
240
|
+
lang = source[/<html\b[^>]*\blang\s*=\s*["']([^"']+)["']/i, 1]
|
|
241
|
+
title = head[%r{<title\b[^>]*>(.*?)</title\s*>}mi, 1]&.strip
|
|
242
|
+
|
|
243
|
+
tags = kept_tags(head)
|
|
244
|
+
document = build_document(tags, title: title, lang: lang, url: url)
|
|
245
|
+
return document if document.bytesize < MAX_DOCUMENT_BYTES
|
|
246
|
+
|
|
247
|
+
# A head that is itself enormous (an inline data: icon, say). Keep only
|
|
248
|
+
# what an unfurl card is made of.
|
|
249
|
+
core = tags.select { |tag| tag_key(tag).to_s.match?(/\A(og:|twitter:|description\z)/) }
|
|
250
|
+
build_document(core, title: title, lang: lang, url: url)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def kept_tags(head)
|
|
254
|
+
seen = {}
|
|
255
|
+
head.scan(/<meta\b[^>]*>|<link\b[^>]*>/i).select do |tag|
|
|
256
|
+
next false unless keep_tag?(tag)
|
|
257
|
+
|
|
258
|
+
key = tag_key(tag)
|
|
259
|
+
next true if key.nil?
|
|
260
|
+
next false if seen[key]
|
|
261
|
+
|
|
262
|
+
seen[key] = true
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def keep_tag?(tag)
|
|
267
|
+
if tag.match?(/\A<link/i)
|
|
268
|
+
rel = attribute(tag, "rel").to_s.downcase.strip
|
|
269
|
+
return KEPT_LINK_RELS.include?(rel)
|
|
270
|
+
end
|
|
271
|
+
return false if attribute(tag, "charset") # the document writes its own
|
|
272
|
+
|
|
273
|
+
property = attribute(tag, "property").to_s
|
|
274
|
+
return true unless property.empty?
|
|
275
|
+
|
|
276
|
+
name = attribute(tag, "name").to_s.downcase
|
|
277
|
+
name.start_with?("twitter:") || KEPT_META_NAMES.include?(name)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# The identity a duplicate is judged by: a meta's property or name, a link's
|
|
281
|
+
# rel. Case-folded, because <meta name="Description"> is the same claim.
|
|
282
|
+
def tag_key(tag)
|
|
283
|
+
if tag.match?(/\A<link/i)
|
|
284
|
+
rel = attribute(tag, "rel")
|
|
285
|
+
return rel ? "link:#{rel.downcase}:#{attribute(tag, "sizes")}" : nil
|
|
286
|
+
end
|
|
287
|
+
(attribute(tag, "property") || attribute(tag, "name"))&.downcase
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def attribute(tag, name)
|
|
291
|
+
tag[/\s#{Regexp.escape(name)}\s*=\s*"([^"]*)"/i, 1] || tag[/\s#{Regexp.escape(name)}\s*=\s*'([^']*)'/i, 1]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def build_document(tags, title:, lang:, url:)
|
|
295
|
+
og_title = content_of(tags, "og:title") || title
|
|
296
|
+
og_description = content_of(tags, "og:description") || content_of(tags, "description")
|
|
297
|
+
lang_attr = lang ? %( lang="#{ERB::Util.html_escape(lang)}") : ""
|
|
298
|
+
|
|
299
|
+
body = +""
|
|
300
|
+
body << "<h1>#{og_title}</h1>\n" if og_title
|
|
301
|
+
body << "<p>#{og_description}</p>\n" if og_description
|
|
302
|
+
body << %(<p><a href="#{ERB::Util.html_escape(url)}">#{og_title || ERB::Util.html_escape(url)}</a></p>\n) if url
|
|
303
|
+
|
|
304
|
+
<<~HTML
|
|
305
|
+
<!DOCTYPE html>
|
|
306
|
+
<html#{lang_attr}>
|
|
307
|
+
<head>
|
|
308
|
+
<meta charset="utf-8">
|
|
309
|
+
#{"<title>#{title}</title>\n" if title}#{tags.join("\n")}
|
|
310
|
+
</head>
|
|
311
|
+
<body>
|
|
312
|
+
#{body}</body>
|
|
313
|
+
</html>
|
|
314
|
+
HTML
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Already escaped: the value came out of a rendered attribute, so it is
|
|
318
|
+
# emitted back into markup exactly as the page wrote it.
|
|
319
|
+
def content_of(tags, key)
|
|
320
|
+
tag = tags.find { |t| tag_key(t) == key }
|
|
321
|
+
tag && attribute(tag, "content")
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def first_present(values)
|
|
325
|
+
Array(values).each do |value|
|
|
326
|
+
return value.to_s.strip if present?(value)
|
|
327
|
+
end
|
|
328
|
+
nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def present?(value)
|
|
332
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
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