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.
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.81.1"
2
+ VERSION = "0.82.1"
3
3
  end