FEATURE: Use upload:// short URL for videos and audio in composer (#8760)
For consistency this PR introduces using custom markdown and short upload:// URLs for video and audio uploads, rather than just treating them as links and relying on the oneboxer. The markdown syntax for videos is ![file text|video](upload://123456.mp4) and for audio it is ![file text|audio](upload://123456.mp3). This is achieved in discourse-markdown-it by modifying the rules for images in mardown-it via md.renderer.rules.image. We return HTML instead of the token when we encounter audio or video after | and the preview renders that HTML. Also when uploading an audio or video file we insert the relevant markdown into the composer.
This commit is contained in:
parent
4646a38ae6
commit
65481858c2
|
@ -6,7 +6,7 @@ function isGUID(value) {
|
|||
);
|
||||
}
|
||||
|
||||
export function imageNameFromFileName(fileName) {
|
||||
export function markdownNameFromFileName(fileName) {
|
||||
let name = fileName.substr(0, fileName.lastIndexOf("."));
|
||||
|
||||
if (isAppleDevice() && isGUID(name)) {
|
||||
|
@ -68,7 +68,7 @@ function validateUploadedFile(file, opts) {
|
|||
}
|
||||
|
||||
if (opts.imagesOnly) {
|
||||
if (!isAnImage(name) && !isAuthorizedImage(name, staff)) {
|
||||
if (!isImage(name) && !isAuthorizedImage(name, staff)) {
|
||||
bootbox.alert(
|
||||
I18n.t("post.errors.upload_not_authorized", {
|
||||
authorized_extensions: authorizedImagesExtensions(staff)
|
||||
|
@ -193,12 +193,20 @@ export function authorizesOneOrMoreImageExtensions(staff) {
|
|||
return imagesExtensions(staff).length > 0;
|
||||
}
|
||||
|
||||
export function isAnImage(path) {
|
||||
export function isImage(path) {
|
||||
return /\.(png|jpe?g|gif|svg|ico)$/i.test(path);
|
||||
}
|
||||
|
||||
export function isVideo(path) {
|
||||
return /\.(mov|mp4|webm|m4v|3gp|ogv|avi|mpeg|ogv)$/i.test(path);
|
||||
}
|
||||
|
||||
export function isAudio(path) {
|
||||
return /\.(mp3|og[ga]|opus|wav|m4[abpr]|aac|flac)$/i.test(path);
|
||||
}
|
||||
|
||||
function uploadTypeFromFileName(fileName) {
|
||||
return isAnImage(fileName) ? "image" : "attachment";
|
||||
return isImage(fileName) ? "image" : "attachment";
|
||||
}
|
||||
|
||||
export function allowsImages(staff) {
|
||||
|
@ -220,37 +228,33 @@ export function uploadIcon(staff) {
|
|||
return allowsAttachments(staff) ? "upload" : "far-image";
|
||||
}
|
||||
|
||||
function uploadLocation(url) {
|
||||
if (Discourse.CDN) {
|
||||
url = Discourse.getURLWithCDN(url);
|
||||
return /^\/\//.test(url) ? "http:" + url : url;
|
||||
} else if (Discourse.S3BaseUrl) {
|
||||
if (url.indexOf("secure-media-uploads") === -1) {
|
||||
return "https:" + url;
|
||||
}
|
||||
return window.location.protocol + url;
|
||||
} else {
|
||||
var protocol = window.location.protocol + "//",
|
||||
hostname = window.location.hostname,
|
||||
port = window.location.port ? ":" + window.location.port : "";
|
||||
return protocol + hostname + port + url;
|
||||
}
|
||||
function imageMarkdown(upload) {
|
||||
return `![${markdownNameFromFileName(upload.original_filename)}|${
|
||||
upload.thumbnail_width
|
||||
}x${upload.thumbnail_height}](${upload.short_url || upload.url})`;
|
||||
}
|
||||
|
||||
function playableMediaMarkdown(upload, type) {
|
||||
return `![${markdownNameFromFileName(upload.original_filename)}|${type}](${
|
||||
upload.short_url
|
||||
})`;
|
||||
}
|
||||
|
||||
function attachmentMarkdown(upload) {
|
||||
return `[${upload.original_filename}|attachment](${
|
||||
upload.short_url
|
||||
}) (${I18n.toHumanSize(upload.filesize)})`;
|
||||
}
|
||||
|
||||
export function getUploadMarkdown(upload) {
|
||||
if (isAnImage(upload.original_filename)) {
|
||||
const name = imageNameFromFileName(upload.original_filename);
|
||||
return `![${name}|${upload.thumbnail_width}x${
|
||||
upload.thumbnail_height
|
||||
}](${upload.short_url || upload.url})`;
|
||||
} else if (
|
||||
/\.(mov|mp4|webm|ogv|mp3|ogg|wav|m4a)$/i.test(upload.original_filename)
|
||||
) {
|
||||
return uploadLocation(upload.url);
|
||||
if (isImage(upload.original_filename)) {
|
||||
return imageMarkdown(upload);
|
||||
} else if (isAudio(upload.original_filename)) {
|
||||
return playableMediaMarkdown(upload, "audio");
|
||||
} else if (isVideo(upload.original_filename)) {
|
||||
return playableMediaMarkdown(upload, "video");
|
||||
} else {
|
||||
return `[${upload.original_filename}|attachment](${
|
||||
upload.short_url
|
||||
}) (${I18n.toHumanSize(upload.filesize)})`;
|
||||
return attachmentMarkdown(upload);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -139,14 +139,43 @@ export function extractDataAttribute(str) {
|
|||
return [key, value];
|
||||
}
|
||||
|
||||
// videoHTML and audioHTML follow the same HTML syntax
|
||||
// as oneboxer.rb when dealing with these formats
|
||||
function videoHTML(token) {
|
||||
const src = token.attrGet("src");
|
||||
const origSrc = token.attrGet("data-orig-src");
|
||||
return `<video width="100%" height="100%" controls>
|
||||
<source src="${src}" data-orig-src="${origSrc}">
|
||||
<a href="${src}">${src}</a>
|
||||
</video>`;
|
||||
}
|
||||
|
||||
function audioHTML(token) {
|
||||
const src = token.attrGet("src");
|
||||
const origSrc = token.attrGet("data-orig-src");
|
||||
return `<audio controls>
|
||||
<source src="${src}" data-orig-src="${origSrc}">
|
||||
<a href="${src}">${src}</a>
|
||||
</audio>`;
|
||||
}
|
||||
|
||||
const IMG_SIZE_REGEX = /^([1-9]+[0-9]*)x([1-9]+[0-9]*)(\s*,\s*(x?)([1-9][0-9]{0,2}?)([%x]?))?$/;
|
||||
function renderImage(tokens, idx, options, env, slf) {
|
||||
function renderImageOrPlayableMedia(tokens, idx, options, env, slf) {
|
||||
const token = tokens[idx];
|
||||
const alt = slf.renderInlineAsText(token.children, options, env);
|
||||
|
||||
const split = alt.split("|");
|
||||
const altSplit = [];
|
||||
|
||||
// markdown-it supports returning HTML instead of continuing to render the current token
|
||||
// see https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
|
||||
if (split[1] === "video") {
|
||||
return videoHTML(token);
|
||||
} else if (split[1] === "audio") {
|
||||
return audioHTML(token);
|
||||
}
|
||||
|
||||
// parsing ![myimage|500x300]() or ![myimage|75%]() or ![myimage|500x300, 75%]
|
||||
for (let i = 0, match, data; i < split.length; ++i) {
|
||||
if ((match = split[i].match(IMG_SIZE_REGEX)) && match[1] && match[2]) {
|
||||
let width = match[1];
|
||||
|
@ -194,8 +223,11 @@ function renderImage(tokens, idx, options, env, slf) {
|
|||
return slf.renderToken(tokens, idx, options);
|
||||
}
|
||||
|
||||
function setupImageDimensions(md) {
|
||||
md.renderer.rules.image = renderImage;
|
||||
// we have taken over the ![]() syntax in markdown to
|
||||
// be able to render a video or audio URL as well as the
|
||||
// image using |video and |audio in the text inside []
|
||||
function setupImageAndPlayableMediaRenderer(md) {
|
||||
md.renderer.rules.image = renderImageOrPlayableMedia;
|
||||
}
|
||||
|
||||
function renderAttachment(tokens, idx, options, env, slf) {
|
||||
|
@ -319,7 +351,7 @@ export function setup(opts, siteSettings, state) {
|
|||
|
||||
setupUrlDecoding(opts.engine);
|
||||
setupHoister(opts.engine);
|
||||
setupImageDimensions(opts.engine);
|
||||
setupImageAndPlayableMediaRenderer(opts.engine);
|
||||
setupAttachments(opts.engine);
|
||||
setupBlockBBCode(opts.engine);
|
||||
setupInlineBBCode(opts.engine);
|
||||
|
|
|
@ -44,9 +44,16 @@ function rule(state) {
|
|||
token.attrs[srcIndex][1] = mapped.url;
|
||||
token.attrs.push(["data-base62-sha1", mapped.base62_sha1]);
|
||||
} else {
|
||||
token.attrs[srcIndex][1] = state.md.options.discourse.getURL(
|
||||
"/images/transparent.png"
|
||||
);
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
|
||||
token.attrs.push(["data-orig-src", origSrc]);
|
||||
}
|
||||
|
|
|
@ -39,43 +39,58 @@ export function resetCache() {
|
|||
_cache = {};
|
||||
}
|
||||
|
||||
function _loadCachedShortUrls($uploads) {
|
||||
$uploads.each((idx, upload) => {
|
||||
const $upload = $(upload);
|
||||
let url;
|
||||
function retrieveCachedUrl($upload, dataAttribute, callback) {
|
||||
const cachedUpload = lookupCachedUploadUrl($upload.data(dataAttribute));
|
||||
const url =
|
||||
dataAttribute === "orig-href" ? cachedUpload.short_path : cachedUpload.url;
|
||||
|
||||
if (url) {
|
||||
$upload.removeAttr(`data-${dataAttribute}`);
|
||||
if (url !== MISSING) {
|
||||
callback(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _loadCachedShortUrls($uploads) {
|
||||
$uploads.each((_idx, upload) => {
|
||||
const $upload = $(upload);
|
||||
switch (upload.tagName) {
|
||||
case "A":
|
||||
url = lookupCachedUploadUrl($upload.data("orig-href")).short_path;
|
||||
retrieveCachedUrl($upload, "orig-href", url => {
|
||||
$upload.attr("href", url);
|
||||
|
||||
if (url) {
|
||||
$upload.removeAttr("data-orig-href");
|
||||
|
||||
if (url !== MISSING) {
|
||||
$upload.attr("href", url);
|
||||
|
||||
// Replace "|attachment" with class='attachment'
|
||||
// TODO: This is a part of the cooking process now and should be
|
||||
// removed in the future.
|
||||
const content = $upload.text().split("|");
|
||||
if (content[1] === ATTACHMENT_CSS_CLASS) {
|
||||
$upload.addClass(ATTACHMENT_CSS_CLASS);
|
||||
$upload.text(content[0]);
|
||||
}
|
||||
// Replace "|attachment" with class='attachment'
|
||||
// TODO: This is a part of the cooking process now and should be
|
||||
// removed in the future.
|
||||
const content = $upload.text().split("|");
|
||||
if (content[1] === ATTACHMENT_CSS_CLASS) {
|
||||
$upload.addClass(ATTACHMENT_CSS_CLASS);
|
||||
$upload.text(content[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
case "IMG":
|
||||
url = lookupCachedUploadUrl($upload.data("orig-src")).url;
|
||||
retrieveCachedUrl($upload, "orig-src", url => {
|
||||
$upload.attr("src", url);
|
||||
});
|
||||
|
||||
if (url) {
|
||||
$upload.removeAttr("data-orig-src");
|
||||
break;
|
||||
case "SOURCE": // video tag > source tag
|
||||
retrieveCachedUrl($upload, "orig-src", url => {
|
||||
$upload.attr("src", url);
|
||||
|
||||
if (url !== MISSING) {
|
||||
$upload.attr("src", url);
|
||||
if (url.startsWith(`//${window.location.host}`)) {
|
||||
let hostRegex = new RegExp("//" + window.location.host, "g");
|
||||
url = url.replace(hostRegex, "");
|
||||
}
|
||||
}
|
||||
$upload.attr("src", window.location.origin + url);
|
||||
|
||||
// this is necessary, otherwise because of the src change the
|
||||
// video just doesn't bother loading!
|
||||
$upload.parent()[0].load();
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
@ -94,7 +109,8 @@ function _loadShortUrls($uploads, ajax) {
|
|||
}
|
||||
|
||||
export function resolveAllShortUrls(ajax) {
|
||||
const attributes = "img[data-orig-src], a[data-orig-href]";
|
||||
const attributes =
|
||||
"img[data-orig-src], a[data-orig-href], source[data-orig-src]";
|
||||
let $shortUploadUrls = $(attributes);
|
||||
|
||||
if ($shortUploadUrls.length > 0) {
|
||||
|
|
|
@ -132,6 +132,8 @@ export const DEFAULT_LIST = [
|
|||
"abbr[title]",
|
||||
"aside.quote",
|
||||
"aside[data-*]",
|
||||
"audio",
|
||||
"audio[controls]",
|
||||
"b",
|
||||
"big",
|
||||
"blockquote",
|
||||
|
@ -192,7 +194,14 @@ export const DEFAULT_LIST = [
|
|||
"strong",
|
||||
"sub",
|
||||
"sup",
|
||||
"source[src]",
|
||||
"source[data-orig-src]",
|
||||
"source[type]",
|
||||
"ul",
|
||||
"video",
|
||||
"video[height]",
|
||||
"video[width]",
|
||||
"video[controls]",
|
||||
"ruby",
|
||||
"ruby[lang]",
|
||||
"rb",
|
||||
|
|
|
@ -7,7 +7,7 @@ Dir["#{Rails.root}/lib/onebox/engine/*_onebox.rb"].sort.each { |f| require f }
|
|||
module Oneboxer
|
||||
ONEBOX_CSS_CLASS = "onebox"
|
||||
AUDIO_REGEX = /^\.(mp3|og[ga]|opus|wav|m4[abpr]|aac|flac)$/i
|
||||
VIDEO_REGEX = /^\.(mov|mp4|m4v|webm|ogv|3gp)$/i
|
||||
VIDEO_REGEX = /^\.(mov|mp4|webm|m4v|3gp|ogv|avi|mpeg|ogv)$/i
|
||||
|
||||
# keep reloaders happy
|
||||
unless defined? Oneboxer::Result
|
||||
|
@ -195,8 +195,7 @@ module Oneboxer
|
|||
<div class="onebox video-onebox">
|
||||
<video width="100%" height="100%" controls="">
|
||||
<source src='#{url}'>
|
||||
<a href='#{url}'>#{url}</a>
|
||||
</source>
|
||||
<a href='#{url}'>#{url}</a>
|
||||
</video>
|
||||
</div>
|
||||
HTML
|
||||
|
|
|
@ -973,6 +973,28 @@ QUnit.test("images", assert => {
|
|||
);
|
||||
});
|
||||
|
||||
QUnit.test("video", assert => {
|
||||
assert.cooked(
|
||||
"![baby shark|video](upload://eyPnj7UzkU0AkGkx2dx8G4YM1Jx.mp4)",
|
||||
`<p><video width="100%" height="100%" controls>
|
||||
<source src="/404" data-orig-src="upload://eyPnj7UzkU0AkGkx2dx8G4YM1Jx.mp4">
|
||||
<a href="/404">/404</a>
|
||||
</video></p>`,
|
||||
"It returns the correct video player HTML"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("audio", assert => {
|
||||
assert.cooked(
|
||||
"![young americans|audio](upload://eyPnj7UzkU0AkGkx2dx8G4YM1Jx.mp3)",
|
||||
`<p><audio controls>
|
||||
<source src="/404" data-orig-src="upload://eyPnj7UzkU0AkGkx2dx8G4YM1Jx.mp3">
|
||||
<a href="/404">/404</a>
|
||||
</audio></p>`,
|
||||
"It returns the correct audio player HTML"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("censoring", assert => {
|
||||
assert.cookedOptions(
|
||||
"Pleased to meet you, but pleeeease call me later, xyz123",
|
||||
|
|
|
@ -33,9 +33,22 @@ QUnit.module("lib:pretty-text/upload-short-url", {
|
|||
}
|
||||
];
|
||||
|
||||
const otherMediaSrcs = [
|
||||
{
|
||||
short_url: "upload://d.mp4",
|
||||
url: "/uploads/default/original/3X/c/b/4.mp4",
|
||||
short_path: "/uploads/short-url/d.mp4"
|
||||
},
|
||||
{
|
||||
short_url: "upload://e.mp3",
|
||||
url: "/uploads/default/original/3X/c/b/5.mp3",
|
||||
short_path: "/uploads/short-url/e.mp3"
|
||||
}
|
||||
];
|
||||
|
||||
// prettier-ignore
|
||||
server.post("/uploads/lookup-urls", () => { //eslint-disable-line
|
||||
return response(imageSrcs.concat(attachmentSrcs));
|
||||
return response(imageSrcs.concat(attachmentSrcs.concat(otherMediaSrcs)));
|
||||
});
|
||||
|
||||
fixture().html(
|
||||
|
@ -79,4 +92,16 @@ QUnit.test("resolveAllShortUrls", async assert => {
|
|||
url: "/uploads/default/original/3X/c/b/3.pdf",
|
||||
short_path: "/uploads/short-url/c.pdf"
|
||||
});
|
||||
|
||||
lookup = lookupCachedUploadUrl("upload://d.mp4");
|
||||
assert.deepEqual(lookup, {
|
||||
url: "/uploads/default/original/3X/c/b/4.mp4",
|
||||
short_path: "/uploads/short-url/d.mp4"
|
||||
});
|
||||
|
||||
lookup = lookupCachedUploadUrl("upload://e.mp3");
|
||||
assert.deepEqual(lookup, {
|
||||
url: "/uploads/default/original/3X/c/b/5.mp3",
|
||||
short_path: "/uploads/short-url/e.mp3"
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
validateUploadedFiles,
|
||||
authorizedExtensions,
|
||||
isAnImage,
|
||||
isImage,
|
||||
allowsImages,
|
||||
allowsAttachments,
|
||||
getUploadMarkdown
|
||||
|
@ -122,18 +122,18 @@ QUnit.test("allows valid uploads to go through", assert => {
|
|||
assert.not(bootbox.alert.calledOnce);
|
||||
});
|
||||
|
||||
QUnit.test("isAnImage", assert => {
|
||||
QUnit.test("isImage", assert => {
|
||||
["png", "jpg", "jpeg", "gif", "ico"].forEach(extension => {
|
||||
var image = "image." + extension;
|
||||
assert.ok(isAnImage(image), image + " is recognized as an image");
|
||||
assert.ok(isImage(image), image + " is recognized as an image");
|
||||
assert.ok(
|
||||
isAnImage("http://foo.bar/path/to/" + image),
|
||||
isImage("http://foo.bar/path/to/" + image),
|
||||
image + " is recognized as an image"
|
||||
);
|
||||
});
|
||||
assert.not(isAnImage("file.txt"));
|
||||
assert.not(isAnImage("http://foo.bar/path/to/file.txt"));
|
||||
assert.not(isAnImage(""));
|
||||
assert.not(isImage("file.txt"));
|
||||
assert.not(isImage("http://foo.bar/path/to/file.txt"));
|
||||
assert.not(isImage(""));
|
||||
});
|
||||
|
||||
QUnit.test("allowsImages", assert => {
|
||||
|
|
Loading…
Reference in New Issue