mirror of
https://github.com/discourse/discourse.git
synced 2026-08-07 13:19:19 +08:00
Previously, lib/pretty_text.rb assembled the mini_racer context by transpiling and loading ~50 JS modules one-by-one at boot, with hand-written shims for their dependencies. This replaces that with a single Rolldown-built IIFE bundle, precompiled during assets:precompile and cached on disk under a digest of its inputs (via the new PrecompiledBundle, which AssetProcessor now shares). The set of discourse-core modules allowed into the server bundle is defined once, as PrettyText::BUNDLED_DISCOURSE_MODULES. It is passed to build.mjs (where a resolver plugin fails the build for anything outside the list, since the discourse package's exports map would otherwise silently resolve it) and also derives the cache-digest globs, so list changes force a rebuild and upstream renames fail the precompile loudly. The require() surface available to plugins' server-side markdown modules is preserved: the bundle re-registers the same loader modules the old context provided, including the vendored-dependency shims (markdown-it, xss, @embroider/macros, discourse/lib/loader-shim, etc.). The whole Ruby -> JS interface now goes through mini_racer's `call` on a single `__PrettyText` object, passing options as structured arguments, rather than building JS source strings for `eval` on every invocation.
866 lines
26 KiB
Ruby
Vendored
866 lines
26 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
require "nokogiri"
|
|
require "erb"
|
|
|
|
module PrettyText
|
|
DANGEROUS_BIDI_CHARACTERS = [
|
|
"\u202A",
|
|
"\u202B",
|
|
"\u202C",
|
|
"\u202D",
|
|
"\u202E",
|
|
"\u2066",
|
|
"\u2067",
|
|
"\u2068",
|
|
"\u2069",
|
|
].freeze
|
|
DANGEROUS_BIDI_REGEXP = Regexp.new(DANGEROUS_BIDI_CHARACTERS.join("|")).freeze
|
|
|
|
BLOCKED_HOTLINKED_SRC_ATTR = "data-blocked-hotlinked-src"
|
|
BLOCKED_HOTLINKED_SRCSET_ATTR = "data-blocked-hotlinked-srcset"
|
|
|
|
VIMEO_PLAYER_HOST = "player.vimeo.com"
|
|
VIMEO_PLAYER_PATH = %r{\A/video/(?<id>\d+)/?\z}
|
|
VIMEO_UNLISTED_HASH = /\A[a-zA-Z0-9]+\z/
|
|
|
|
@mutex = Mutex.new
|
|
@ctx_init = Mutex.new
|
|
|
|
def self.app_root
|
|
Rails.root
|
|
end
|
|
|
|
def self.apply_es6_file(ctx:, path:, module_name:)
|
|
source = File.read(path)
|
|
processor = AssetProcessor.new
|
|
transpiled = processor.perform(source, nil, module_name)
|
|
ctx.eval(transpiled, filename: module_name)
|
|
end
|
|
|
|
# The only modules from the `discourse` package which may be bundled into the
|
|
# server-side renderer. Additions must not transitively depend on
|
|
# browser-only APIs.
|
|
BUNDLED_DISCOURSE_MODULES = %w[
|
|
deprecation-workflow
|
|
lib/avatar-utils
|
|
lib/case-converter
|
|
lib/escape
|
|
lib/get-url
|
|
lib/object
|
|
loader
|
|
static/markdown-it/features
|
|
]
|
|
|
|
CORE_BUNDLE =
|
|
PrecompiledBundle.new(
|
|
dir: "tmp/pretty-text-processor",
|
|
filename_prefix: "pretty-text",
|
|
dependency_globs:
|
|
%w[
|
|
node_modules/.pnpm/lock.yaml
|
|
frontend/pretty-text-processor/**/*.{js,mjs,cjs,json}
|
|
frontend/pretty-text/addon/**/*.js
|
|
frontend/discourse-markdown-it/src/**/*.js
|
|
] + BUNDLED_DISCOURSE_MODULES.map { "frontend/discourse/app/#{it}.js" },
|
|
) do
|
|
Discourse::Utils.execute_command(
|
|
"pnpm",
|
|
"-C=frontend/pretty-text-processor",
|
|
"node",
|
|
"build.mjs",
|
|
"--discourse-modules=#{BUNDLED_DISCOURSE_MODULES.join(",")}",
|
|
chdir: Rails.root.to_s,
|
|
)
|
|
end
|
|
|
|
def self.load_or_build_core_bundle
|
|
CORE_BUNDLE.load_or_build
|
|
end
|
|
|
|
def self.core_bundle_source
|
|
# Never JIT-build in production; the toolchain may be absent and it would block the request.
|
|
raise <<~MSG.squish if Rails.env.production? && !CORE_BUNDLE.precompiled?
|
|
PrettyText core bundle has not been precompiled (#{CORE_BUNDLE.path}).
|
|
Run `bin/rake assets:precompile` before booting.
|
|
MSG
|
|
|
|
CORE_BUNDLE.load_or_build
|
|
end
|
|
|
|
def self.create_es6_context
|
|
ctx = MiniRacer::Context.new(timeout: 25_000, ensure_gc_after_idle: 2000)
|
|
|
|
ctx.eval("window = globalThis; window.devicePixelRatio = 2;") # hack to make code think stuff is retina
|
|
|
|
ctx.attach(
|
|
"rails.logger.info",
|
|
proc do |err|
|
|
Rails.logger.info(err.to_s)
|
|
nil
|
|
end,
|
|
)
|
|
ctx.attach(
|
|
"rails.logger.warn",
|
|
proc do |err|
|
|
Rails.logger.warn(err.to_s)
|
|
nil
|
|
end,
|
|
)
|
|
ctx.attach(
|
|
"rails.logger.error",
|
|
proc do |err|
|
|
Rails.logger.error(err.to_s)
|
|
nil
|
|
end,
|
|
)
|
|
ctx.eval <<~JS
|
|
console = {
|
|
prefix: "[PrettyText] ",
|
|
log: function(...args){ rails.logger.info(console.prefix + args.join(" ")); },
|
|
warn: function(...args){ rails.logger.warn(console.prefix + args.join(" ")); },
|
|
error: function(...args){ rails.logger.error(console.prefix + args.join(" ")); }
|
|
}
|
|
JS
|
|
|
|
ctx.eval("__PRETTY_TEXT = true")
|
|
|
|
# `__Ruby` must exist before the bundle runs — it captures it at init.
|
|
PrettyText::Helpers.instance_methods.each do |method|
|
|
ctx.attach("__Ruby.#{method}", PrettyText::Helpers.method(method))
|
|
end
|
|
|
|
ctx.eval(core_bundle_source, filename: "pretty-text.js")
|
|
ctx.call("__PrettyText.setUnicode", JSON.parse(Emoji.unicode_replacements_json))
|
|
|
|
Discourse.plugins.each do |plugin|
|
|
Dir
|
|
.glob("#{plugin.directory}/assets/javascripts/**/discourse-markdown/**/*.{js,js.es6}")
|
|
.filter { |a| File.file?(a) }
|
|
.each do |f|
|
|
module_name =
|
|
f.sub(%r{\A.+assets/javascripts/}, "discourse/plugins/#{plugin.name}/").sub(
|
|
/\.js(\.es6)?\z/,
|
|
"",
|
|
)
|
|
apply_es6_file(ctx: ctx, path: f, module_name: module_name)
|
|
end
|
|
end
|
|
|
|
DiscoursePluginRegistry.vendored_core_pretty_text.each { |vpt| ctx.eval(File.read(vpt)) }
|
|
|
|
DiscoursePluginRegistry.vendored_pretty_text.each { |vpt| ctx.eval(File.read(vpt)) }
|
|
|
|
ctx.low_memory_notification # GC to free up memory used during init
|
|
|
|
ctx
|
|
end
|
|
|
|
def self.v8
|
|
return @ctx if @ctx
|
|
|
|
# ensure we only init one of these
|
|
@ctx_init.synchronize do
|
|
return @ctx if @ctx
|
|
@ctx = create_es6_context
|
|
end
|
|
|
|
@ctx
|
|
end
|
|
|
|
def self.reset_translations
|
|
@mutex.synchronize do
|
|
v8.call("__PrettyText.resetTranslations")
|
|
v8.low_memory_notification if GlobalSetting.mini_racer_single_threaded
|
|
end
|
|
end
|
|
|
|
def self.reset_context
|
|
@ctx_init.synchronize do
|
|
@ctx&.dispose
|
|
@ctx = nil
|
|
end
|
|
end
|
|
|
|
# Acceptable options:
|
|
#
|
|
# disable_emojis - Disables the emoji markdown engine.
|
|
# features - A hash where the key is the markdown feature name and the value is a boolean to enable/disable the markdown feature.
|
|
# The hash is merged into the default features set in pretty-text.js which can be used to add new features or disable existing features.
|
|
# features_override - An array of markdown feature names to override the default markdown feature set. Currently used by plugins to customize what features should be enabled
|
|
# when rendering markdown.
|
|
# markdown_it_rules - An array of markdown rule names which will be applied to the markdown-it engine. Currently used by plugins to customize what markdown-it rules should be
|
|
# enabled when rendering markdown.
|
|
# topic_id - Topic id for the post being cooked.
|
|
# post_id - Post id for the post being cooked.
|
|
# user_id - User id for the post being cooked.
|
|
# force_quote_link - Always create the link to the quoted topic for [quote] bbcode. Normally this only happens
|
|
# if the topic_id provided is different from the [quote topic:X].
|
|
# hashtag_context - Defaults to "topic-composer" if not supplied. Controls the order of #hashtag lookup results
|
|
# based on registered hashtag contexts from the `#register_hashtag_search_param` plugin API
|
|
# method.
|
|
def self.markdown(text, opts = {})
|
|
# we use the exact same markdown converter as the client
|
|
# TODO: use the same extensions on both client and server (in particular the template for mentions)
|
|
text = text || ""
|
|
|
|
custom_emoji = {}
|
|
Emoji.custom.map { |e| custom_emoji[e.name] = e.cdn_url }
|
|
|
|
allowed_iframes =
|
|
DiscoursePluginRegistry.apply_modifier(
|
|
:pretty_text_allowed_iframes,
|
|
SiteSetting.allowed_iframes.split("|"),
|
|
)
|
|
|
|
opts[:hashtag_context] ||= "topic-composer"
|
|
|
|
# Any addition here must also be added to buildOptions in discourse-markdown-it.
|
|
opt_input = {
|
|
siteSettings: JSON.parse(SiteSetting.client_settings_json),
|
|
allowedIframes: allowed_iframes,
|
|
paths: paths,
|
|
customEmoji: custom_emoji,
|
|
customEmojiTranslation: Plugin::CustomEmoji.translations,
|
|
emojiDenyList: Emoji.denied,
|
|
censoredRegexp: WordWatcher.serialized_regexps_for_action(:censor),
|
|
watchedWordsReplace: WordWatcher.regexps_for_action(:replace),
|
|
watchedWordsLink: WordWatcher.regexps_for_action(:link),
|
|
additionalOptions: Site.markdown_additional_options,
|
|
avatar_sizes: SiteSetting.avatar_sizes,
|
|
hashtagTypesInPriorityOrder:
|
|
HashtagAutocompleteService.ordered_types_for_context(opts[:hashtag_context]),
|
|
hashtagIcons: HashtagAutocompleteService.data_source_icon_map,
|
|
}
|
|
|
|
opt_input[:disableEmojis] = true if opts[:disable_emojis]
|
|
opt_input[:features] = opts[:features] if opts[:features]
|
|
opt_input[:featuresOverride] = opts[:features_override] if opts[:features_override]
|
|
opt_input[:markdownItRules] = opts[:markdown_it_rules] if opts[:markdown_it_rules]
|
|
opt_input[:topicId] = opts[:topic_id].to_i if opts[:topic_id]
|
|
opt_input[:postId] = opts[:post_id].to_i if opts[:post_id]
|
|
opt_input[:forceQuoteLink] = opts[:force_quote_link] if opts[:force_quote_link]
|
|
opt_input[:userId] = opts[:user_id].to_i if opts[:user_id]
|
|
# Be careful disabling sanitization. We allow for custom emails
|
|
opt_input[:disableSanitizer] = true if opts[:sanitize] == false
|
|
|
|
protect do
|
|
DiscourseEvent.trigger(:markdown_context, v8)
|
|
v8.call("__PrettyText.cook", text, opt_input)
|
|
end
|
|
end
|
|
|
|
def self.paths
|
|
paths = { baseUri: Discourse.base_path, CDN: Rails.configuration.action_controller.asset_host }
|
|
|
|
if SiteSetting.Upload.enable_s3_uploads
|
|
paths[:S3CDN] = SiteSetting.Upload.s3_cdn_url if SiteSetting.Upload.s3_cdn_url.present?
|
|
paths[:S3BaseUrl] = Discourse.store.absolute_base_url
|
|
end
|
|
|
|
paths
|
|
end
|
|
|
|
def self.paths_json
|
|
paths.to_json
|
|
end
|
|
|
|
# leaving this here, cause it invokes v8, don't want to implement twice
|
|
def self.avatar_img(avatar_template, size)
|
|
protect do
|
|
v8.call("__PrettyText.avatarImg", avatar_template, size, paths, SiteSetting.avatar_sizes)
|
|
end
|
|
end
|
|
|
|
def self.sanitize(html, opts = {})
|
|
protect { v8.call("__PrettyText.sanitize", html.to_s, opts) }
|
|
end
|
|
|
|
def self.unescape_emoji(title)
|
|
return title unless SiteSetting.enable_emoji? && title
|
|
|
|
options = {
|
|
paths: paths,
|
|
emojiSet: SiteSetting.emoji_set,
|
|
emojiCDNUrl: SiteSetting.external_emoji_url.presence || "",
|
|
customEmoji: Emoji.custom.map { |e| [e.name, e.cdn_url] }.to_h,
|
|
enableEmojiShortcuts: SiteSetting.enable_emoji_shortcuts,
|
|
inlineEmoji: SiteSetting.enable_inline_emoji_translation,
|
|
}
|
|
|
|
protect { v8.call("__PrettyText.performEmojiUnescape", title, options) }
|
|
end
|
|
|
|
def self.escape_emoji(title)
|
|
return unless title
|
|
|
|
options = {
|
|
emojiShortcuts: SiteSetting.enable_emoji && SiteSetting.enable_emoji_shortcuts,
|
|
inlineEmoji: SiteSetting.enable_inline_emoji_translation,
|
|
}
|
|
|
|
protect { v8.call("__PrettyText.performEmojiEscape", title, options) }
|
|
end
|
|
|
|
def self.cook(raw, opts = {})
|
|
options = opts.dup
|
|
working_text = raw.dup
|
|
|
|
html = markdown(working_text, options)
|
|
|
|
cleanup(html, opts)
|
|
end
|
|
|
|
def self.strip_hidden_unicode_bidirectional_characters(doc)
|
|
return if !DANGEROUS_BIDI_REGEXP.match?(doc.content)
|
|
|
|
doc
|
|
.css("code,pre")
|
|
.each do |code_tag|
|
|
next if !DANGEROUS_BIDI_REGEXP.match?(code_tag.content)
|
|
|
|
DANGEROUS_BIDI_CHARACTERS.each do |bidi|
|
|
next if !code_tag.content.include?(bidi)
|
|
|
|
formatted = "<U+#{bidi.ord.to_s(16).upcase}>"
|
|
code_tag.inner_html =
|
|
code_tag.inner_html.gsub(
|
|
bidi,
|
|
"<span class=\"bidi-warning\" title=\"#{I18n.t("post.hidden_bidi_character")}\">#{formatted}</span>",
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.sanitize_hotlinked_media(doc)
|
|
return if !SiteSetting.block_hotlinked_media
|
|
|
|
allowed_pattern = allowed_src_pattern
|
|
|
|
doc
|
|
.css("img[src], source[src], source[srcset], track[src], div[data-video-src]")
|
|
.each do |el|
|
|
if el["src"] && !el["src"].match?(allowed_pattern)
|
|
el[PrettyText::BLOCKED_HOTLINKED_SRC_ATTR] = el.delete("src")
|
|
end
|
|
if el["data-video-src"] && !el["data-video-src"].match?(allowed_pattern)
|
|
el[PrettyText::BLOCKED_HOTLINKED_SRC_ATTR] = el["data-video-src"]
|
|
end
|
|
|
|
if el["srcset"]
|
|
srcs = el["srcset"].split(",").map { |e| e.split(" ", 2)[0].presence }
|
|
if srcs.any? { |src| !src.match?(allowed_pattern) }
|
|
el[PrettyText::BLOCKED_HOTLINKED_SRCSET_ATTR] = el.delete("srcset")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.add_rel_attributes_to_user_content(doc, add_nofollow)
|
|
allowlist = []
|
|
|
|
domains = SiteSetting.exclude_rel_nofollow_domains
|
|
allowlist = domains.split("|") if domains.present?
|
|
|
|
site_uri = nil
|
|
doc
|
|
.css("a")
|
|
.each do |l|
|
|
href = l["href"].to_s
|
|
l["rel"] = "noopener" if l["target"] == "_blank"
|
|
|
|
begin
|
|
uri = URI(UrlHelper.encode_component(href))
|
|
site_uri ||= URI(Discourse.base_url)
|
|
|
|
same_domain =
|
|
!uri.host.present? || uri.host == site_uri.host ||
|
|
uri.host.ends_with?(".#{site_uri.host}") ||
|
|
allowlist.any? { |u| uri.host == u || uri.host.ends_with?(".#{u}") }
|
|
|
|
l["rel"] = "noopener nofollow ugc" if add_nofollow && !same_domain
|
|
rescue URI::Error
|
|
# add a nofollow anyway
|
|
l["rel"] = "noopener nofollow ugc"
|
|
end
|
|
end
|
|
end
|
|
|
|
class DetectedLink < Struct.new(:url, :is_quote)
|
|
end
|
|
|
|
def self.extract_links(html)
|
|
links = []
|
|
doc = Nokogiri::HTML5.fragment(html)
|
|
|
|
# extract onebox links
|
|
doc
|
|
.css("aside.onebox[data-onebox-src]")
|
|
.each { |onebox| links << DetectedLink.new(onebox["data-onebox-src"], false) }
|
|
|
|
# remove href inside quotes & oneboxes & elided part
|
|
doc.css("aside.quote a, aside.onebox a, .elided a").remove
|
|
|
|
# remove hotlinked images
|
|
doc.css("a.lightbox > img, a.onebox > img").each { |img| img.parent.remove }
|
|
|
|
# extract all links
|
|
doc
|
|
.css("a")
|
|
.each do |a|
|
|
links << DetectedLink.new(a["href"], false) if a["href"].present? && a["href"][0] != "#"
|
|
end
|
|
|
|
# extract quotes
|
|
doc
|
|
.css("aside.quote[data-topic]")
|
|
.each do |aside|
|
|
if aside["data-topic"].present?
|
|
url = +"/t/#{aside["data-topic"]}"
|
|
url << "/#{aside["data-post"]}" if aside["data-post"].present?
|
|
links << DetectedLink.new(url, true)
|
|
end
|
|
end
|
|
|
|
# extract Youtube links
|
|
doc
|
|
.css("div[data-video-id]")
|
|
.each do |div|
|
|
if div["data-video-id"].present? && div["data-provider-name"].present?
|
|
base_url =
|
|
case div["data-provider-name"]
|
|
when "youtube"
|
|
"https://www.youtube.com/watch?v="
|
|
when "vimeo"
|
|
"https://vimeo.com/"
|
|
when "tiktok"
|
|
"https://m.tiktok.com/v/"
|
|
end
|
|
links << DetectedLink.new(base_url + div["data-video-id"], false)
|
|
end
|
|
end
|
|
|
|
links
|
|
end
|
|
|
|
def self.add_video_placeholder_image(doc)
|
|
doc
|
|
.css(".video-placeholder-container")
|
|
.each do |video|
|
|
video_src = video["data-video-src"]
|
|
next if video_src == "/404" || video_src.nil?
|
|
video_sha1 = File.basename(video_src, File.extname(video_src))
|
|
thumbnail = Upload.where("original_filename LIKE ?", "#{video_sha1}.%").last
|
|
if thumbnail
|
|
video["data-thumbnail-src"] = UrlHelper.absolute(
|
|
GlobalPath.upload_cdn_path(thumbnail.url),
|
|
)
|
|
video[
|
|
"data-video-base62-sha1"
|
|
] = "#{Upload.base62_sha1(video_sha1)}#{File.extname(video_src)}"
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.extract_mentions(cooked)
|
|
mentions =
|
|
cooked
|
|
.css(".mention, .mention-group")
|
|
.filter_map do |e|
|
|
if (name = e.inner_text)
|
|
User.normalize_username(name[1..-1]) if name[0] == "@"
|
|
end
|
|
end
|
|
|
|
mentions =
|
|
DiscoursePluginRegistry.apply_modifier(:pretty_text_extract_mentions, mentions, cooked)
|
|
|
|
mentions.compact!
|
|
mentions.uniq!
|
|
mentions
|
|
end
|
|
|
|
def self.excerpt(html, max_length, options = {})
|
|
return "" if html.blank?
|
|
|
|
# TODO: properly fix this HACK in ExcerptParser without introducing XSS
|
|
doc =
|
|
begin
|
|
Nokogiri::HTML5.fragment(html)
|
|
rescue ArgumentError
|
|
return ""
|
|
end
|
|
DiscourseEvent.trigger(:reduce_excerpt, doc, options)
|
|
strip_image_wrapping(doc)
|
|
strip_oneboxed_media(doc)
|
|
convert_hashtag_links_to_plaintext(doc) if options[:plain_hashtags]
|
|
|
|
html = doc.to_html
|
|
ExcerptParser.get_excerpt(html, max_length, options)
|
|
end
|
|
|
|
def self.convert_hashtag_links_to_plaintext(doc)
|
|
doc
|
|
.css("a.hashtag-cooked")
|
|
.each { |hashtag| hashtag.replace(doc.document.create_text_node("##{hashtag["data-slug"]}")) }
|
|
end
|
|
|
|
def self.strip_links(string)
|
|
return string if string.blank?
|
|
|
|
# If the user is not basic, strip links from their bio
|
|
fragment = Nokogiri::HTML5.fragment(string)
|
|
fragment.css("a").each { |a| a.replace(a.inner_html) }
|
|
fragment.to_html
|
|
end
|
|
|
|
def self.make_all_links_absolute(doc)
|
|
doc
|
|
.css("a[href]")
|
|
.each do |a|
|
|
href = a["href"].to_s
|
|
next if href.blank?
|
|
next if href.start_with?("mailto:")
|
|
next if href.start_with?(Discourse.base_url)
|
|
next if URI(href).host.present?
|
|
|
|
a["href"] = (
|
|
if href.start_with?(Discourse.base_path)
|
|
"#{Discourse.base_url_no_prefix}#{href}"
|
|
else
|
|
"#{Discourse.base_url}#{href}"
|
|
end
|
|
)
|
|
rescue URI::Error
|
|
# leave it
|
|
end
|
|
end
|
|
|
|
def self.strip_image_wrapping(doc)
|
|
doc.css(".lightbox-wrapper .meta").remove
|
|
end
|
|
|
|
def self.strip_oneboxed_media(doc)
|
|
doc.css("audio").remove
|
|
doc.css(".video-onebox,video").remove
|
|
end
|
|
|
|
def self.convert_vimeo_iframes(doc)
|
|
doc
|
|
.css("iframe[src*='player.vimeo.com']")
|
|
.each do |iframe|
|
|
vimeo_url = vimeo_url_from_iframe(iframe)
|
|
next if vimeo_url.blank?
|
|
|
|
paragraph = doc.document.create_element("p")
|
|
link = doc.document.create_element("a")
|
|
link["href"] = vimeo_url
|
|
link.content = vimeo_url
|
|
paragraph.add_child(link)
|
|
iframe.replace(paragraph)
|
|
end
|
|
end
|
|
|
|
def self.vimeo_url_from_iframe(iframe)
|
|
if iframe["data-original-href"].present?
|
|
url = UrlHelper.normalized_encode(iframe["data-original-href"])
|
|
scheme = UrlHelper.relaxed_parse(url)&.scheme
|
|
return %w[http https].include?(scheme) ? url : nil
|
|
end
|
|
|
|
uri = UrlHelper.relaxed_parse(iframe["src"].to_s)
|
|
return nil if uri.nil? || uri.host&.downcase != VIMEO_PLAYER_HOST
|
|
|
|
video_id = VIMEO_PLAYER_PATH.match(uri.path.to_s)&.[](:id)
|
|
return nil if video_id.nil?
|
|
|
|
unlisted_hash = vimeo_unlisted_hash(uri.query)
|
|
if unlisted_hash
|
|
"https://vimeo.com/#{video_id}/#{unlisted_hash}"
|
|
else
|
|
"https://vimeo.com/#{video_id}"
|
|
end
|
|
end
|
|
|
|
def self.vimeo_unlisted_hash(query)
|
|
return nil if query.blank?
|
|
|
|
hash = URI.decode_www_form(query).to_h["h"]
|
|
hash if hash&.match?(VIMEO_UNLISTED_HASH)
|
|
rescue ArgumentError
|
|
nil
|
|
end
|
|
|
|
def self.strip_secure_uploads(doc)
|
|
# images inside a lightbox or other link
|
|
doc
|
|
.css("a[href]")
|
|
.each do |a|
|
|
next if !Upload.secure_uploads_url?(a["href"])
|
|
|
|
non_image_media = %w[video audio].include?(a&.parent&.name)
|
|
target = non_image_media ? a.parent : a
|
|
if target.to_s.include?("stripped-secure-view-media") ||
|
|
target.to_s.include?("stripped-secure-view-upload")
|
|
next
|
|
end
|
|
|
|
next if a.css("img[src]").empty? && !non_image_media
|
|
|
|
if a.classes.include?("lightbox")
|
|
img = a.css("img[src]").first
|
|
srcset = img&.attributes&.[]("srcset")&.value
|
|
if srcset
|
|
# if available, use the first image from the srcset here
|
|
# so we get the optimized image instead of the possibly huge original
|
|
url = srcset.split(",").first
|
|
else
|
|
url = img["src"]
|
|
end
|
|
a.add_next_sibling secure_uploads_placeholder(
|
|
doc,
|
|
url,
|
|
width: img["width"],
|
|
height: img["height"],
|
|
)
|
|
a.remove
|
|
else
|
|
width = non_image_media ? nil : a.at_css("img").attr("width")
|
|
height = non_image_media ? nil : a.at_css("img").attr("height")
|
|
target.add_next_sibling secure_uploads_placeholder(
|
|
doc,
|
|
a["href"],
|
|
width: width,
|
|
height: height,
|
|
)
|
|
target.remove
|
|
end
|
|
end
|
|
|
|
# images by themselves or inside a onebox
|
|
doc
|
|
.css("img[src]")
|
|
.each do |img|
|
|
url =
|
|
if img.parent.classes.include?("aspect-image") && img.attributes["srcset"].present?
|
|
# we are using the first image from the srcset here so we get the
|
|
# optimized image instead of the original, because an optimized
|
|
# image may be used for the onebox thumbnail
|
|
srcset = img.attributes["srcset"].value
|
|
srcset.split(",").first
|
|
else
|
|
img["src"]
|
|
end
|
|
|
|
width = img["width"]
|
|
height = img["height"]
|
|
onebox_type = nil
|
|
|
|
if img.ancestors.css(".onebox-body").any?
|
|
if img.classes.include?("onebox-avatar-inline")
|
|
onebox_type = "avatar-inline"
|
|
else
|
|
onebox_type = "thumbnail"
|
|
end
|
|
end
|
|
|
|
# we always want this to be tiny and without any special styles
|
|
if img.classes.include?("site-icon")
|
|
onebox_type = nil
|
|
width = 16
|
|
height = 16
|
|
end
|
|
|
|
if Upload.secure_uploads_url?(url)
|
|
img.add_next_sibling secure_uploads_placeholder(
|
|
doc,
|
|
url,
|
|
onebox_type: onebox_type,
|
|
width: width,
|
|
height: height,
|
|
)
|
|
img.remove
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.secure_uploads_placeholder(doc, url, onebox_type: false, width: nil, height: nil)
|
|
notice = doc.document.create_element("div")
|
|
notice["class"] = "secure-upload-notice"
|
|
notice["data-stripped-secure-upload"] = url.to_s
|
|
notice["data-onebox-type"] = onebox_type.to_s if onebox_type
|
|
|
|
width = numeric_dimension(width)
|
|
height = numeric_dimension(height)
|
|
notice["data-width"] = width if width
|
|
notice["data-height"] = height if height
|
|
|
|
link = doc.document.create_element("a")
|
|
link["class"] = "stripped-secure-view-upload"
|
|
link["href"] = url.to_s
|
|
link.content = I18n.t("emails.view_redacted_media")
|
|
|
|
notice.add_child(
|
|
doc.document.create_text_node("#{I18n.t("emails.secure_uploads_placeholder")} "),
|
|
)
|
|
notice.add_child(link)
|
|
notice.add_child(doc.document.create_text_node("."))
|
|
notice
|
|
end
|
|
|
|
def self.numeric_dimension(value)
|
|
value.to_s[/\A\d+\z/]
|
|
end
|
|
|
|
def self.format_for_email(html, post = nil)
|
|
doc = Nokogiri::HTML5.fragment(html)
|
|
DiscourseEvent.trigger(:reduce_cooked, doc, post)
|
|
strip_secure_uploads(doc) if post&.should_secure_uploads?
|
|
strip_image_wrapping(doc)
|
|
convert_vimeo_iframes(doc)
|
|
make_all_links_absolute(doc)
|
|
doc.to_html
|
|
end
|
|
|
|
protected
|
|
|
|
class JavaScriptError < StandardError
|
|
attr_accessor :message, :backtrace
|
|
|
|
def initialize(message, backtrace)
|
|
@message = message
|
|
@backtrace = backtrace
|
|
end
|
|
end
|
|
|
|
def self.protect
|
|
rval = nil
|
|
@mutex.synchronize do
|
|
rval = yield
|
|
v8.low_memory_notification if GlobalSetting.mini_racer_single_threaded
|
|
end
|
|
rval
|
|
end
|
|
|
|
def self.cleanup(html, opts = {})
|
|
doc = Nokogiri::HTML5.fragment(html)
|
|
|
|
add_nofollow = !opts[:omit_nofollow] && SiteSetting.add_rel_nofollow_to_user_content
|
|
add_rel_attributes_to_user_content(doc, add_nofollow)
|
|
strip_hidden_unicode_bidirectional_characters(doc)
|
|
sanitize_hotlinked_media(doc)
|
|
add_video_placeholder_image(doc)
|
|
|
|
add_mentions(doc, user_id: opts[:user_id]) if SiteSetting.enable_mentions
|
|
|
|
scrubber = Loofah::Scrubber.new { |node| node.remove if node.name == "script" }
|
|
loofah_fragment = Loofah.html5_fragment(doc.to_html)
|
|
loofah_fragment.scrub!(scrubber).to_html
|
|
end
|
|
|
|
private
|
|
|
|
USER_TYPE = "user"
|
|
GROUP_TYPE = "group"
|
|
GROUP_MENTIONABLE_TYPE = "group-mentionable"
|
|
|
|
def self.add_mentions(doc, user_id: nil)
|
|
elements = doc.css("span.mention")
|
|
names = elements.map { |element| element.text[1..-1] }
|
|
|
|
mentions = lookup_mentions(names, user_id: user_id)
|
|
|
|
elements.each do |element|
|
|
name = element.text[1..-1]
|
|
name.downcase!
|
|
|
|
if type = mentions[name]
|
|
element.name = "a"
|
|
|
|
element.children = PrettyText::Helpers.format_username(element.children.text)
|
|
|
|
case type
|
|
when USER_TYPE
|
|
element["href"] = "#{Discourse.base_path}/u/#{UrlHelper.encode_component(name)}"
|
|
when GROUP_MENTIONABLE_TYPE
|
|
element["class"] = "mention-group notify"
|
|
element["href"] = "#{Discourse.base_path}/groups/#{UrlHelper.encode_component(name)}"
|
|
when GROUP_TYPE
|
|
element["class"] = "mention-group"
|
|
element["href"] = "#{Discourse.base_path}/groups/#{UrlHelper.encode_component(name)}"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.lookup_mentions(names, user_id: nil)
|
|
return {} if names.blank?
|
|
|
|
sql = <<~SQL
|
|
(
|
|
SELECT
|
|
:user_type AS type,
|
|
username_lower AS name
|
|
FROM users
|
|
WHERE username_lower IN (:names) AND staged = false
|
|
)
|
|
UNION
|
|
(
|
|
SELECT
|
|
:group_type AS type,
|
|
lower(name) AS name
|
|
FROM groups
|
|
)
|
|
UNION
|
|
(
|
|
SELECT
|
|
:group_mentionable_type AS type,
|
|
lower(name) AS name
|
|
FROM groups
|
|
WHERE lower(name) IN (:names) AND (#{Group.mentionable_sql_clause(include_public: false)})
|
|
)
|
|
ORDER BY type
|
|
SQL
|
|
|
|
user = User.find_by(id: user_id)
|
|
names.each(&:downcase!)
|
|
|
|
results =
|
|
DB.query(
|
|
sql,
|
|
names: names,
|
|
user_type: USER_TYPE,
|
|
group_type: GROUP_TYPE,
|
|
group_mentionable_type: GROUP_MENTIONABLE_TYPE,
|
|
levels: Group.alias_levels(user),
|
|
user_id: user_id,
|
|
)
|
|
|
|
mentions = {}
|
|
results.each { |result| mentions[result.name] = result.type }
|
|
mentions
|
|
end
|
|
|
|
def self.allowed_src_pattern
|
|
allowed_src_prefixes = [
|
|
Discourse.base_path,
|
|
Discourse.base_url,
|
|
GlobalSetting.s3_cdn_url,
|
|
GlobalSetting.cdn_url,
|
|
SiteSetting.external_emoji_url.presence,
|
|
*SiteSetting.block_hotlinked_media_exceptions.split("|"),
|
|
]
|
|
|
|
patterns =
|
|
allowed_src_prefixes.compact.map do |url|
|
|
pattern = Regexp.escape(url)
|
|
|
|
# If 'https://example.com' is allowed, ensure 'https://example.com.blah.com' is not
|
|
pattern += '(?:/|\z)' if !pattern.ends_with?("\/")
|
|
|
|
pattern
|
|
end
|
|
|
|
/\A(data:|#{patterns.join("|")})/
|
|
end
|
|
end
|