Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* @file Initializes and adds any custom user styles to the post
*/
import { apiInitializer } from "discourse/lib/api";
import { withPluginApi } from "discourse/lib/plugin-api";

/**
* Adds the inline styles for the style tag inside a given post
Expand Down Expand Up @@ -30,6 +30,14 @@ function addClassStyleCode(post) {
});
}

export default apiInitializer((api) => {
api.decorateCookedElement(addClassStyleCode, { id: "add class style code" });
});
export default {
name: "bbcode-class-styles",
after: "inject-objects",
initialize() {
withPluginApi((api) => {
api.decorateCookedElement(addClassStyleCode, {
id: "add class style code",
});
});
},
};
115 changes: 115 additions & 0 deletions assets/javascripts/discourse/initializers/bbcode-css-upload-urls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* @file Resolves upload:// references left behind in bbcode-generated CSS by
* client-side cooking (e.g. the composer preview): the server resolves these
* at cook time, but the preview's markdown-it engine has no upload lookup, so
* it cooks the literal upload:// text through unresolved.
*/
import {
lookupCachedUploadUrl,
lookupUncachedUploadUrls,
MISSING,
} from "pretty-text/upload-short-url";
import { ajax } from "discourse/lib/ajax";
import discourseDebounce from "discourse/lib/debounce";
import { withPluginApi } from "discourse/lib/plugin-api";

const UPLOAD_URL_REGEX = /url\(\s*(['"]?)(upload:\/\/[^'")]+)\1\s*\)/gi;

let queuedShortUrls;
let queuePromise;
let queueResolve;

function queuePop() {
lookupUncachedUploadUrls(queuedShortUrls, ajax).then(queueResolve);
queuedShortUrls = queueResolve = null;
}

// Batches short-url lookups across a debounce window (matching core's own
// composer-preview resolver) so multiple decorated posts on the same page
// share one request instead of firing one each.
function resolveUncachedShortUrls(shortUrls) {
if (!queuedShortUrls) {
queuedShortUrls = [...shortUrls];
queuePromise = new Promise((resolve) => (queueResolve = resolve));
discourseDebounce(null, queuePop, 450);
} else {
queuedShortUrls.push(...shortUrls);
}
return queuePromise;
}

function cssOf(node) {
return node.tagName === "STYLE"
? node.textContent
: node.getAttribute("style");
}

function setCssOf(node, css) {
if (node.tagName === "STYLE") {
node.textContent = css;
} else {
node.setAttribute("style", css);
}
}

/**
* @param {HTMLElement} post the post itself
*/
async function resolveCssUploadUrls(post) {
const nodes = [
...post.querySelectorAll("style[data-rendered-class]"),
...post.querySelectorAll("[style*='upload://']"),
];

const shortUrls = new Set();
nodes.forEach((node) => {
for (const match of cssOf(node).matchAll(UPLOAD_URL_REGEX)) {
shortUrls.add(match[2]);
}
});

if (shortUrls.size === 0) {
return;
}

const uncached = [...shortUrls].filter(
(url) => !lookupCachedUploadUrl(url).url
);
if (uncached.length > 0) {
await resolveUncachedShortUrls(uncached);
}

const resolved = new Map();
shortUrls.forEach((url) => {
const { url: resolvedUrl } = lookupCachedUploadUrl(url);
if (resolvedUrl && resolvedUrl !== MISSING) {
resolved.set(url, resolvedUrl);
}
});

if (resolved.size === 0) {
return;
}

const replaced = (css) =>
css.replace(UPLOAD_URL_REGEX, (fullMatch, quote, shortUrl) => {
const url = resolved.get(shortUrl);
return url ? `url(${quote}${url}${quote})` : fullMatch;
});

nodes.forEach((node) => setCssOf(node, replaced(cssOf(node))));
}

export default {
name: "bbcode-css-upload-urls",
// must run after class-tag CSS is materialized into a live <style> tag, since
// this patches that derived node rather than the <template> it comes from
after: ["inject-objects", "bbcode-class-styles"],
initialize() {
withPluginApi((api) => {
api.decorateCookedElement(resolveCssUploadUrls, {
id: "resolve bbcode css upload urls",
});
});
},
};
10 changes: 10 additions & 0 deletions assets/stylesheets/common/containment.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
%bbcode-post-containment {
clip-path: border-box;
contain: layout;
position: relative;
}

.cooked,
.d-editor-preview {
@extend %bbcode-post-containment;
}
5 changes: 1 addition & 4 deletions assets/stylesheets/common/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
@import "blockquote";
@import "border-bg";
@import "check";
@import "containment";
@import "divide";
@import "fieldset";
@import "heightrestrict";
Expand All @@ -26,7 +27,3 @@
@import "spoiler";
@import "tabs";
@import "textmessage";

.d-editor-preview {
position: relative;
}
125 changes: 125 additions & 0 deletions lib/bb_code/css_hotlinked_media.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# frozen_string_literal: true

# Rehosts external images referenced through bbcode-generated CSS (the [class]/
# [animation] style block, and raw style="" attributes from [div]/[tab]/
# [accordion]) the same way core rehosts <img src>: prepend onto
# HotlinkedMedia.extract_candidates so core's own PullHotlinkedImages job
# downloads and books them, then rewrite the cooked doc to point at the local
# upload once downloaded.
module ::BbCode
module CssHotlinkedMedia
STYLE_TEMPLATE_SELECTOR = 'template[data-bbcode-plus="class"]'
URL_FN_REGEX = /url\(\s*(['"]?)([^'")]+)\1\s*\)/i

# Duck-types just enough of a Nokogiri node to satisfy
# HotlinkedMedia.download_src_for (node["src"]) and callers that branch on
# node.name, e.g. plugins/chat's job.
CandidateNode =
Struct.new(:url) do
def [](key)
url if key == "src"
end

def name
"bbcode-css-url"
end
end

module ExtendExtractCandidates
def extract_candidates(html)
doc = html.is_a?(Nokogiri::XML::Node) ? html : Nokogiri::HTML5.fragment(html)
super(doc).to_a + ::BbCode::CssHotlinkedMedia.extract_candidates(doc)
end
end

# Keeps post.raw in sync the same way core does for img src/href: once a
# CSS url() has a downloaded PostHotlinkedMedia record, point the raw
# source at it too (as an upload:// short-url, which rewrite_css above
# already knows how to resolve back into a real URL on every future cook).
module RewriteRawCssUrls
def replace_hotlinked_image_urls(raw:, &blk)
rewritten = super

rewritten.gsub(URL_FN_REGEX) do |match|
quote = Regexp.last_match(1)
src = Regexp.last_match(2)
upload = blk.call(src)
upload ? "url(#{quote}#{upload.short_url}#{quote})" : match
end
end
end

def self.each_css_node(doc)
doc.css(STYLE_TEMPLATE_SELECTOR).each { |node| yield node, node.text }
doc.css("[style]").each { |node| yield node, node["style"] }
end

# Remote urls referenced from bbcode CSS in +doc_or_html+, as candidate
# nodes for ::HotlinkedMedia.extract_candidates to append to its own list.
def self.extract_candidates(doc_or_html)
doc =
doc_or_html.is_a?(Nokogiri::XML::Node) ? doc_or_html : Nokogiri::HTML5.fragment(doc_or_html)

urls = Set.new
each_css_node(doc) { |_node, css| css.scan(URL_FN_REGEX) { |_, url| urls << url } }
urls
.reject { |url| url.start_with?("upload://") }
.select { |url| ::HotlinkedMedia.remote_src?(url) }
.map { |url| CandidateNode.new(url) }
end

# Rewrites +doc+ in place: upload:// references resolve unconditionally
# (the same scheme core resolves for img src/a href), and hotlinked
# external urls resolve using already-downloaded PostHotlinkedMedia
# records on +post+. Returns whether anything changed.
def self.rewrite_doc!(doc, post)
return false unless doc.at_css("#{STYLE_TEMPLATE_SELECTOR}, [style]")

hotlinked_map = post.post_hotlinked_media.includes(:upload).index_by(&:url)
changed = false

each_css_node(doc) do |node, css|
new_css = rewrite_css(css, hotlinked_map, post)
next if new_css == css

if node.name == "template"
node.content = new_css
else
node["style"] = new_css
end
changed = true
end

changed
end

def self.rewrite_css(css, hotlinked_map, post)
css.gsub(URL_FN_REGEX) do |match|
quote = Regexp.last_match(1)
url = Regexp.last_match(2)
upload = resolve_upload(url, hotlinked_map)

if upload
cooked_url = UrlHelper.cook_url(upload.url, secure: post.should_secure_uploads?)
"url(#{quote}#{cooked_url}#{quote})"
else
match
end
end
end

# The upload a CSS url() should resolve to: either an upload:// short-url
# (the same scheme core resolves for img src/a href), or an external url
# already hotlinked and downloaded onto +post+.
def self.resolve_upload(url, hotlinked_map)
if url.start_with?("upload://")
sha1 = Upload.sha1_from_short_url(url)
Upload.find_by(sha1: sha1) if sha1.present?
else
record = hotlinked_map[PostHotlinkedMedia.normalize_src(url)]
record.upload if record&.downloaded?
end
end
private_class_method :resolve_upload
end
end
6 changes: 6 additions & 0 deletions plugin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ module ::BbCode
end

require_relative "lib/bb_code/engine"
require_relative "lib/bb_code/css_hotlinked_media"

after_initialize do
# Code which should run after Rails has finished booting
Expand All @@ -37,6 +38,11 @@ module ::BbCode
MessageBus.subscribe(::BbCode::ENGINE_RESET_CHANNEL) { PrettyText.reset_context }
end

::HotlinkedMedia.singleton_class.prepend(::BbCode::CssHotlinkedMedia::ExtendExtractCandidates)
::InlineUploads.singleton_class.prepend(::BbCode::CssHotlinkedMedia::RewriteRawCssUrls)

on(:post_process_cooked) { |doc, post| ::BbCode::CssHotlinkedMedia.rewrite_doc!(doc, post) }

# overrides the default normalize_whitespaces function in discourse/lib/text_cleaner.rb
# adds discourse_normalize_whitespace setting (defaults to false)
# when true, normalize_whitespace runs as normal
Expand Down
Loading