diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/upload-protocol.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/upload-protocol.js index 4b99308a898..a41f7faaf71 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/upload-protocol.js +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/upload-protocol.js @@ -1,88 +1,152 @@ +import xss from "xss"; + +const HTML_TYPES = ["html_block", "html_inline"]; + // add image to array if src has an upload function addImage(uploads, token) { if (token.attrs) { for (let i = 0; i < token.attrs.length; i++) { if (token.attrs[i][1].indexOf("upload://") === 0) { - uploads.push([token, i]); + uploads.push({ token, srcIndex: i, origSrc: token.attrs[i][1] }); break; } } } } +function attr(name, value) { + if (value) { + return `${name}="${xss.escapeAttrValue(value)}"`; + } + + return name; +} + +function uploadLocatorString(url) { + return `___REPLACE_UPLOAD_SRC_${url}___`; +} + +function findUploadsInHtml(uploads, blockToken) { + // Slightly misusing our HTML sanitizer to look for upload:// + // image src attributes, and replace them with a placeholder. + // Note that we can't use browser DOM APIs because this needs + // to run in mini-racer. + const fakeAllowList = {}; + + let foundImage = false; + const newContent = xss(blockToken.content, { + whiteList: fakeAllowList, + allowCommentTag: true, + onTag(tag, html, options) { + // We're not using this for sanitizing, so allow all tags through + options.isWhite = true; + fakeAllowList[tag] = []; + }, + onTagAttr(tag, name, value) { + if (tag === "img" && name === "src" && value.startsWith("upload://")) { + uploads.push({ token: blockToken, srcIndex: null, origSrc: value }); + foundImage = true; + return uploadLocatorString(value); + } + return attr(name, value); + }, + }); + if (foundImage) { + blockToken.content = newContent; + } +} + +function processToken(uploads, token) { + if (token.tag === "img" || token.tag === "a") { + addImage(uploads, token); + } else if (HTML_TYPES.includes(token.type)) { + findUploadsInHtml(uploads, token); + } + + if (token.children) { + for (let j = 0; j < token.children.length; j++) { + const childToken = token.children[j]; + processToken(uploads, childToken); + } + } +} + function rule(state) { let uploads = []; for (let i = 0; i < state.tokens.length; i++) { let blockToken = state.tokens[i]; - if (blockToken.tag === "img" || blockToken.tag === "a") { - addImage(uploads, blockToken); - } - - if (!blockToken.children) { - continue; - } - - for (let j = 0; j < blockToken.children.length; j++) { - let token = blockToken.children[j]; - - if (token.tag === "img" || token.tag === "a") { - addImage(uploads, token); - } - } + processToken(uploads, blockToken); } if (uploads.length > 0) { - let srcList = uploads.map(([token, srcIndex]) => token.attrs[srcIndex][1]); + let srcList = uploads.map((u) => u.origSrc); + + // In client-side cooking, this lookup returns nothing + // This means we set data-orig-src, and let decorateCooked + // lookup the image URLs asynchronously let lookup = state.md.options.discourse.lookupUploadUrls; let longUrls = (lookup && lookup(srcList)) || {}; - uploads.forEach(([token, srcIndex]) => { - let origSrc = token.attrs[srcIndex][1]; + uploads.forEach(({ token, srcIndex, origSrc }) => { let mapped = longUrls[origSrc]; - switch (token.tag) { - case "img": - if (mapped) { - token.attrs[srcIndex][1] = mapped.url; - token.attrs.push(["data-base62-sha1", mapped.base62_sha1]); - } else { - // no point putting a transparent .png for audio/video - if (token.content.match(/\|video|\|audio/)) { - token.attrs[srcIndex][1] = state.md.options.discourse.getURL( - "/404" - ); - } else { - token.attrs[srcIndex][1] = state.md.options.discourse.getURL( - "/images/transparent.png" - ); - } + if (HTML_TYPES.includes(token.type)) { + const locator = uploadLocatorString(origSrc); + let attrs = []; - token.attrs.push(["data-orig-src", origSrc]); - } - break; - case "a": - if (mapped) { - // when secure media is enabled we want the full /secure-media-uploads/ - // url to take advantage of access control security - if ( - state.md.options.discourse.limitedSiteSettings.secureMedia && - mapped.url.indexOf("secure-media-uploads") > -1 - ) { - token.attrs[srcIndex][1] = mapped.url; - } else { - token.attrs[srcIndex][1] = mapped.short_path; - } - } else { + if (mapped) { + attrs.push( + attr("src", mapped.url), + attr("data-base62-sha1", mapped.base62_sha1) + ); + } else { + attrs.push( + attr( + "src", + state.md.options.discourse.getURL("/images/transparent.png") + ), + attr("data-orig-src", origSrc) + ); + } + + token.content = token.content.replace(locator, attrs.join(" ")); + } else if (token.tag === "img") { + if (mapped) { + token.attrs[srcIndex][1] = mapped.url; + token.attrs.push(["data-base62-sha1", mapped.base62_sha1]); + } else { + // no point putting a transparent .png for audio/video + if (token.content.match(/\|video|\|audio/)) { token.attrs[srcIndex][1] = state.md.options.discourse.getURL( "/404" ); - - token.attrs.push(["data-orig-href", origSrc]); + } else { + token.attrs[srcIndex][1] = state.md.options.discourse.getURL( + "/images/transparent.png" + ); } - break; + token.attrs.push(["data-orig-src", origSrc]); + } + } else if (token.tag === "a") { + if (mapped) { + // when secure media is enabled we want the full /secure-media-uploads/ + // url to take advantage of access control security + if ( + state.md.options.discourse.limitedSiteSettings.secureMedia && + mapped.url.indexOf("secure-media-uploads") > -1 + ) { + token.attrs[srcIndex][1] = mapped.url; + } else { + token.attrs[srcIndex][1] = mapped.short_path; + } + } else { + token.attrs[srcIndex][1] = state.md.options.discourse.getURL("/404"); + + token.attrs.push(["data-orig-href", origSrc]); + } } }); } diff --git a/spec/lib/pretty_text_spec.rb b/spec/lib/pretty_text_spec.rb index e60117d664d..50b0f411029 100644 --- a/spec/lib/pretty_text_spec.rb +++ b/spec/lib/pretty_text_spec.rb @@ -1877,6 +1877,12 @@ HTML ![upload](#{upload.short_url.gsub(".png", "")}) + Inline img + +
+ Block img +
+ [some attachment](#{upload.short_url}) [some attachment|attachment](#{upload.short_url}) @@ -1901,6 +1907,10 @@ HTML

upload

+

Inline img

+
+ Block img +

some attachment

some attachment

some attachment|random