Merge pull request #492 from lbryio/465-social-share-with-extensions
465 social share with extensions
This commit is contained in:
commit
536dfc9ccd
14 changed files with 141 additions and 80 deletions
|
@ -101,9 +101,9 @@ var createAssetMetaTags = function createAssetMetaTags(_ref3) {
|
||||||
defaultThumbnail = _ref3.defaultThumbnail;
|
defaultThumbnail = _ref3.defaultThumbnail;
|
||||||
var claimData = asset.claimData;
|
var claimData = asset.claimData;
|
||||||
var contentType = claimData.contentType;
|
var contentType = claimData.contentType;
|
||||||
var embedUrl = "".concat(siteHost, "/").concat(claimData.claimId, "/").concat(claimData.name);
|
var videoEmbedUrl = "".concat(siteHost, "/video-embed/").concat(claimData.name, "/").concat(claimData.claimId);
|
||||||
var showUrl = "".concat(siteHost, "/").concat(claimData.claimId, "/").concat(claimData.name);
|
var showUrl = "".concat(siteHost, "/").concat(claimData.claimId, "/").concat(claimData.name);
|
||||||
var source = "".concat(siteHost, "/").concat(claimData.claimId, "/").concat(claimData.name, ".").concat(claimData.fileExt);
|
var source = "".concat(siteHost, "/asset/").concat(claimData.name, "/").concat(claimData.claimId);
|
||||||
var ogTitle = claimData.title || claimData.name;
|
var ogTitle = claimData.title || claimData.name;
|
||||||
var ogDescription = claimData.description || defaultDescription;
|
var ogDescription = claimData.description || defaultDescription;
|
||||||
var ogThumbnailContentType = determineOgThumbnailContentType(claimData.thumbnail);
|
var ogThumbnailContentType = determineOgThumbnailContentType(claimData.thumbnail);
|
||||||
|
@ -154,7 +154,7 @@ var createAssetMetaTags = function createAssetMetaTags(_ref3) {
|
||||||
});
|
});
|
||||||
metaTags.push({
|
metaTags.push({
|
||||||
property: 'og:type',
|
property: 'og:type',
|
||||||
content: 'video'
|
content: 'video.other'
|
||||||
});
|
});
|
||||||
metaTags.push({
|
metaTags.push({
|
||||||
property: 'twitter:card',
|
property: 'twitter:card',
|
||||||
|
@ -162,7 +162,7 @@ var createAssetMetaTags = function createAssetMetaTags(_ref3) {
|
||||||
});
|
});
|
||||||
metaTags.push({
|
metaTags.push({
|
||||||
property: 'twitter:player',
|
property: 'twitter:player',
|
||||||
content: embedUrl
|
content: videoEmbedUrl
|
||||||
});
|
});
|
||||||
metaTags.push({
|
metaTags.push({
|
||||||
property: 'twitter:player:width',
|
property: 'twitter:player:width',
|
||||||
|
|
|
@ -46,9 +46,9 @@ const createChannelMetaTags = ({siteHost, siteTitle, siteTwitter, channel}) => {
|
||||||
const createAssetMetaTags = ({siteHost, siteTitle, siteTwitter, asset, defaultDescription, defaultThumbnail}) => {
|
const createAssetMetaTags = ({siteHost, siteTitle, siteTwitter, asset, defaultDescription, defaultThumbnail}) => {
|
||||||
const { claimData } = asset;
|
const { claimData } = asset;
|
||||||
const { contentType } = claimData;
|
const { contentType } = claimData;
|
||||||
const embedUrl = `${siteHost}/${claimData.claimId}/${claimData.name}`;
|
const videoEmbedUrl = `${siteHost}/video-embed/${claimData.name}/${claimData.claimId}`;
|
||||||
const showUrl = `${siteHost}/${claimData.claimId}/${claimData.name}`;
|
const showUrl = `${siteHost}/${claimData.claimId}/${claimData.name}`;
|
||||||
const source = `${siteHost}/${claimData.claimId}/${claimData.name}.${claimData.fileExt}`;
|
const source = `${siteHost}/asset/${claimData.name}/${claimData.claimId}`;
|
||||||
const ogTitle = claimData.title || claimData.name;
|
const ogTitle = claimData.title || claimData.name;
|
||||||
const ogDescription = claimData.description || defaultDescription;
|
const ogDescription = claimData.description || defaultDescription;
|
||||||
const ogThumbnailContentType = determineOgThumbnailContentType(claimData.thumbnail);
|
const ogThumbnailContentType = determineOgThumbnailContentType(claimData.thumbnail);
|
||||||
|
@ -68,9 +68,9 @@ const createAssetMetaTags = ({siteHost, siteTitle, siteTwitter, asset, defaultDe
|
||||||
metaTags.push({property: 'og:video:type', content: contentType});
|
metaTags.push({property: 'og:video:type', content: contentType});
|
||||||
metaTags.push({property: 'og:image', content: ogThumbnail});
|
metaTags.push({property: 'og:image', content: ogThumbnail});
|
||||||
metaTags.push({property: 'og:image:type', content: ogThumbnailContentType});
|
metaTags.push({property: 'og:image:type', content: ogThumbnailContentType});
|
||||||
metaTags.push({property: 'og:type', content: 'video'});
|
metaTags.push({property: 'og:type', content: 'video.other'});
|
||||||
metaTags.push({property: 'twitter:card', content: 'player'});
|
metaTags.push({property: 'twitter:card', content: 'player'});
|
||||||
metaTags.push({property: 'twitter:player', content: embedUrl});
|
metaTags.push({property: 'twitter:player', content: videoEmbedUrl});
|
||||||
metaTags.push({property: 'twitter:player:width', content: 600});
|
metaTags.push({property: 'twitter:player:width', content: 600});
|
||||||
metaTags.push({property: 'twitter:text:player_width', content: 600});
|
metaTags.push({property: 'twitter:text:player_width', content: 600});
|
||||||
metaTags.push({property: 'twitter:player:height', content: 337});
|
metaTags.push({property: 'twitter:player:height', content: 337});
|
||||||
|
|
9
server/controllers/assets/constants/request_types.js
Normal file
9
server/controllers/assets/constants/request_types.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
const EMBED = 'EMBED';
|
||||||
|
const BROWSER = 'BROWSER';
|
||||||
|
const SOCIAL = 'SOCIAL';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
EMBED,
|
||||||
|
BROWSER,
|
||||||
|
SOCIAL,
|
||||||
|
};
|
17
server/controllers/assets/serveAsset/index.js
Normal file
17
server/controllers/assets/serveAsset/index.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
const { sendGAServeEvent } = require('../../../utils/googleAnalytics');
|
||||||
|
const getClaimIdAndServeAsset = require('../utils/getClaimIdAndServeAsset.js');
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
route to serve an asset directly
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
const serveAsset = ({ headers, ip, originalUrl, params: { claimName, claimId } }, res) => {
|
||||||
|
// send google analytics
|
||||||
|
sendGAServeEvent(headers, ip, originalUrl);
|
||||||
|
// get the claim Id and then serve the asset
|
||||||
|
getClaimIdAndServeAsset(null, null, claimName, claimId, originalUrl, ip, res);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = serveAsset;
|
|
@ -3,11 +3,10 @@ const handleShowRender = require('../../../render/build/handleShowRender.js');
|
||||||
|
|
||||||
const lbryUri = require('../utils/lbryUri.js');
|
const lbryUri = require('../utils/lbryUri.js');
|
||||||
|
|
||||||
const determineResponseType = require('../utils/determineResponseType.js');
|
const determineRequestType = require('../utils/determineRequestType.js');
|
||||||
const getClaimIdAndServeAsset = require('../utils/getClaimIdAndServeAsset.js');
|
const getClaimIdAndServeAsset = require('../utils/getClaimIdAndServeAsset.js');
|
||||||
const logRequestData = require('../utils/logRequestData.js');
|
|
||||||
|
|
||||||
const SERVE = 'SERVE';
|
const { EMBED } = require('../constants/request_types.js');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
||||||
|
@ -15,7 +14,7 @@ const SERVE = 'SERVE';
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const serverAssetByClaim = (req, res) => {
|
const serveByClaim = (req, res) => {
|
||||||
const { headers, ip, originalUrl, params } = req;
|
const { headers, ip, originalUrl, params } = req;
|
||||||
// decide if this is a show request
|
// decide if this is a show request
|
||||||
let hasFileExtension;
|
let hasFileExtension;
|
||||||
|
@ -24,13 +23,11 @@ const serverAssetByClaim = (req, res) => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({success: false, message: error.message});
|
return res.status(400).json({success: false, message: error.message});
|
||||||
}
|
}
|
||||||
let responseType = determineResponseType(hasFileExtension, headers);
|
// determine request type
|
||||||
if (responseType !== SERVE) {
|
let requestType = determineRequestType(hasFileExtension, headers);
|
||||||
|
if (requestType !== EMBED) {
|
||||||
return handleShowRender(req, res);
|
return handleShowRender(req, res);
|
||||||
}
|
}
|
||||||
// handle serve request
|
|
||||||
// send google analytics
|
|
||||||
sendGAServeEvent(headers, ip, originalUrl);
|
|
||||||
// parse the claim
|
// parse the claim
|
||||||
let claimName;
|
let claimName;
|
||||||
try {
|
try {
|
||||||
|
@ -38,10 +35,10 @@ const serverAssetByClaim = (req, res) => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({success: false, message: error.message});
|
return res.status(400).json({success: false, message: error.message});
|
||||||
}
|
}
|
||||||
// log the request data for debugging
|
// send google analytics
|
||||||
logRequestData(responseType, claimName, null, null);
|
sendGAServeEvent(headers, ip, originalUrl);
|
||||||
// get the claim Id and then serve the asset
|
// get the claim Id and then serve the asset
|
||||||
getClaimIdAndServeAsset(null, null, claimName, null, originalUrl, ip, res);
|
getClaimIdAndServeAsset(null, null, claimName, null, originalUrl, ip, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = serverAssetByClaim;
|
module.exports = serveByClaim;
|
||||||
|
|
|
@ -3,12 +3,11 @@ const handleShowRender = require('../../../render/build/handleShowRender.js');
|
||||||
|
|
||||||
const lbryUri = require('../utils/lbryUri.js');
|
const lbryUri = require('../utils/lbryUri.js');
|
||||||
|
|
||||||
const determineResponseType = require('../utils/determineResponseType.js');
|
const determineRequestType = require('../utils/determineRequestType.js');
|
||||||
const getClaimIdAndServeAsset = require('../utils/getClaimIdAndServeAsset.js');
|
const getClaimIdAndServeAsset = require('../utils/getClaimIdAndServeAsset.js');
|
||||||
const flipClaimNameAndId = require('../utils/flipClaimNameAndId.js');
|
const flipClaimNameAndId = require('../utils/flipClaimNameAndId.js');
|
||||||
const logRequestData = require('../utils/logRequestData.js');
|
|
||||||
|
|
||||||
const SERVE = 'SERVE';
|
const { EMBED } = require('../constants/request_types.js');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
||||||
|
@ -16,22 +15,20 @@ const SERVE = 'SERVE';
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const serverAssetByIdentifierAndClaim = (req, res) => {
|
const serverByIdentifierAndClaim = (req, res) => {
|
||||||
const { headers, ip, originalUrl, params } = req;
|
const { headers, ip, originalUrl, params } = req;
|
||||||
// decide if this is a show request
|
// parse request
|
||||||
let hasFileExtension;
|
let hasFileExtension;
|
||||||
try {
|
try {
|
||||||
({ hasFileExtension } = lbryUri.parseModifier(params.claim));
|
({ hasFileExtension } = lbryUri.parseModifier(params.claim));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({success: false, message: error.message});
|
return res.status(400).json({success: false, message: error.message});
|
||||||
}
|
}
|
||||||
let responseType = determineResponseType(hasFileExtension, headers);
|
// determine request type
|
||||||
if (responseType !== SERVE) {
|
let requestType = determineRequestType(hasFileExtension, headers);
|
||||||
|
if (requestType !== EMBED) {
|
||||||
return handleShowRender(req, res);
|
return handleShowRender(req, res);
|
||||||
}
|
}
|
||||||
// handle serve request
|
|
||||||
// send google analytics
|
|
||||||
sendGAServeEvent(headers, ip, originalUrl);
|
|
||||||
// parse the claim
|
// parse the claim
|
||||||
let claimName;
|
let claimName;
|
||||||
try {
|
try {
|
||||||
|
@ -50,10 +47,10 @@ const serverAssetByIdentifierAndClaim = (req, res) => {
|
||||||
if (!isChannel) {
|
if (!isChannel) {
|
||||||
[claimId, claimName] = flipClaimNameAndId(claimId, claimName);
|
[claimId, claimName] = flipClaimNameAndId(claimId, claimName);
|
||||||
}
|
}
|
||||||
// log the request data for debugging
|
// send google analytics
|
||||||
logRequestData(responseType, claimName, channelName, claimId);
|
sendGAServeEvent(headers, ip, originalUrl);
|
||||||
// get the claim Id and then serve the asset
|
// get the claim Id and then serve the asset
|
||||||
getClaimIdAndServeAsset(channelName, channelClaimId, claimName, claimId, originalUrl, ip, res);
|
getClaimIdAndServeAsset(channelName, channelClaimId, claimName, claimId, originalUrl, ip, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = serverAssetByIdentifierAndClaim;
|
module.exports = serverByIdentifierAndClaim;
|
||||||
|
|
54
server/controllers/assets/utils/determineRequestType.js
Normal file
54
server/controllers/assets/utils/determineRequestType.js
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
const logger = require('winston');
|
||||||
|
const { EMBED, BROWSER, SOCIAL } = require('../constants/request_types.js');
|
||||||
|
|
||||||
|
function headersMatchesSocialBotList (headers) {
|
||||||
|
const userAgent = headers['user-agent'];
|
||||||
|
const socialBotList = {
|
||||||
|
'facebookexternalhit': 1,
|
||||||
|
'Twitterbot' : 1,
|
||||||
|
};
|
||||||
|
return socialBotList[userAgent];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientAcceptsHtml ({accept}) {
|
||||||
|
return accept && accept.match(/text\/html/);
|
||||||
|
};
|
||||||
|
|
||||||
|
function requestIsFromBrowser (headers) {
|
||||||
|
return headers['user-agent'] && headers['user-agent'].match(/Mozilla/);
|
||||||
|
};
|
||||||
|
|
||||||
|
function clientWantsAsset ({accept, range}) {
|
||||||
|
const imageIsWanted = accept && accept.match(/image\/.*/) && !accept.match(/text\/html/) && !accept.match(/text\/\*/);
|
||||||
|
const videoIsWanted = accept && range;
|
||||||
|
return imageIsWanted || videoIsWanted;
|
||||||
|
};
|
||||||
|
|
||||||
|
const determineRequestType = (hasFileExtension, headers) => {
|
||||||
|
let responseType;
|
||||||
|
logger.debug('headers:', headers);
|
||||||
|
// return early with 'show' if headers match the list
|
||||||
|
if (headersMatchesSocialBotList(headers)) {
|
||||||
|
return SOCIAL;
|
||||||
|
}
|
||||||
|
// if request is not from a social bot...
|
||||||
|
if (hasFileExtension) {
|
||||||
|
// assume embed,
|
||||||
|
responseType = EMBED;
|
||||||
|
// but change to browser if client accepts html.
|
||||||
|
if (clientAcceptsHtml(headers)) {
|
||||||
|
responseType = BROWSER;
|
||||||
|
}
|
||||||
|
// if request does not have file extentsion...
|
||||||
|
} else {
|
||||||
|
// assume browser,
|
||||||
|
responseType = BROWSER;
|
||||||
|
// but change to embed if someone embeded a show url...
|
||||||
|
if (clientWantsAsset(headers) && requestIsFromBrowser(headers)) {
|
||||||
|
responseType = EMBED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return responseType;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = determineRequestType;
|
|
@ -1,37 +0,0 @@
|
||||||
const logger = require('winston');
|
|
||||||
|
|
||||||
const SERVE = 'SERVE';
|
|
||||||
const SHOW = 'SHOW';
|
|
||||||
|
|
||||||
function clientAcceptsHtml ({accept}) {
|
|
||||||
return accept && accept.match(/text\/html/);
|
|
||||||
};
|
|
||||||
|
|
||||||
function requestIsFromBrowser (headers) {
|
|
||||||
return headers['user-agent'] && headers['user-agent'].match(/Mozilla/);
|
|
||||||
};
|
|
||||||
|
|
||||||
function clientWantsAsset ({accept, range}) {
|
|
||||||
const imageIsWanted = accept && accept.match(/image\/.*/) && !accept.match(/text\/html/) && !accept.match(/text\/\*/);
|
|
||||||
const videoIsWanted = accept && range;
|
|
||||||
return imageIsWanted || videoIsWanted;
|
|
||||||
};
|
|
||||||
|
|
||||||
const determineResponseType = (hasFileExtension, headers) => {
|
|
||||||
let responseType;
|
|
||||||
if (hasFileExtension) {
|
|
||||||
responseType = SERVE; // assume a serve request if file extension is present
|
|
||||||
if (clientAcceptsHtml(headers)) { // if the request comes from a browser, change it to a show request
|
|
||||||
responseType = SHOW;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
responseType = SHOW;
|
|
||||||
if (clientWantsAsset(headers) && requestIsFromBrowser(headers)) { // this is in case someone embeds a show url
|
|
||||||
logger.debug('Show request came from browser but wants an image/video. Changing response to serve...');
|
|
||||||
responseType = SERVE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return responseType;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = determineResponseType;
|
|
|
@ -17,6 +17,7 @@ const getClaimIdAndServeAsset = (channelName, channelClaimId, claimName, claimId
|
||||||
getClaimId(channelName, channelClaimId, claimName, claimId)
|
getClaimId(channelName, channelClaimId, claimName, claimId)
|
||||||
.then(fullClaimId => {
|
.then(fullClaimId => {
|
||||||
claimId = fullClaimId;
|
claimId = fullClaimId;
|
||||||
|
logger.debug('FULL CLAIM ID:', fullClaimId);
|
||||||
return db.Blocked.isNotBlocked(fullClaimId, claimName);
|
return db.Blocked.isNotBlocked(fullClaimId, claimName);
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
const { details: { host } } = require('@config/siteConfig');
|
const { details: { host } } = require('@config/siteConfig');
|
||||||
|
|
||||||
const sendEmbedPage = ({ params }, res) => {
|
const sendVideoEmbedPage = ({ params }, res) => {
|
||||||
const claimId = params.claimId;
|
const claimId = params.claimId;
|
||||||
const name = params.name;
|
const name = params.name;
|
||||||
// get and render the content
|
// get and render the content
|
||||||
res.status(200).render('embed', { layout: 'embed', host, claimId, name });
|
res.status(200).render('embed', { layout: 'embed', host, claimId, name });
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = sendEmbedPage;
|
module.exports = sendVideoEmbedPage;
|
|
@ -29,7 +29,7 @@ module.exports = (app) => {
|
||||||
app.get('/api/claim/data/:claimName/:claimId', claimData);
|
app.get('/api/claim/data/:claimName/:claimId', claimData);
|
||||||
app.get('/api/claim/get/:name/:claimId', claimGet);
|
app.get('/api/claim/get/:name/:claimId', claimGet);
|
||||||
app.get('/api/claim/list/:name', claimList);
|
app.get('/api/claim/list/:name', claimList);
|
||||||
app.post('/api/claim/long-id', claimLongId);
|
app.post('/api/claim/long-id', claimLongId); // should be a get
|
||||||
app.post('/api/claim/publish', multipartMiddleware, claimPublish);
|
app.post('/api/claim/publish', multipartMiddleware, claimPublish);
|
||||||
app.get('/api/claim/resolve/:name/:claimId', claimResolve);
|
app.get('/api/claim/resolve/:name/:claimId', claimResolve);
|
||||||
app.get('/api/claim/short-id/:longId/:name', claimShortId);
|
app.get('/api/claim/short-id/:longId/:name', claimShortId);
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
const serveAssetByClaim = require('../../controllers/assets/serveByClaim');
|
const serveByClaim = require('../../controllers/assets/serveByClaim');
|
||||||
const serveAssetByIdentifierAndClaim = require('../../controllers/assets/serveByIdentifierAndClaim');
|
const serveByIdentifierAndClaim = require('../../controllers/assets/serveByIdentifierAndClaim');
|
||||||
|
const serveAsset = require('../../controllers/assets/serveAsset');
|
||||||
|
|
||||||
module.exports = (app, db) => {
|
module.exports = (app) => {
|
||||||
app.get('/:identifier/:claim', serveAssetByIdentifierAndClaim);
|
app.get('/asset/:claimName/:claimId/', serveAsset);
|
||||||
app.get('/:claim', serveAssetByClaim);
|
app.get('/:identifier/:claim', serveByIdentifierAndClaim);
|
||||||
|
app.get('/:claim', serveByClaim);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
const handlePageRequest = require('../../controllers/pages/sendReactApp');
|
const handlePageRequest = require('../../controllers/pages/sendReactApp');
|
||||||
const handleEmbedRequest = require('../../controllers/pages/sendEmbedPage');
|
const handleVideoEmbedRequest = require('../../controllers/pages/sendVideoEmbedPage');
|
||||||
const redirect = require('../../controllers/utils/redirect');
|
const redirect = require('../../controllers/utils/redirect');
|
||||||
|
|
||||||
module.exports = (app) => {
|
module.exports = (app) => {
|
||||||
|
@ -10,5 +10,5 @@ module.exports = (app) => {
|
||||||
app.get('/popular', handlePageRequest);
|
app.get('/popular', handlePageRequest);
|
||||||
app.get('/new', handlePageRequest);
|
app.get('/new', handlePageRequest);
|
||||||
app.get('/multisite', handlePageRequest);
|
app.get('/multisite', handlePageRequest);
|
||||||
app.get('/embed/:claimId/:name', handleEmbedRequest); // route to send embedable video player (for twitter)
|
app.get('/video-embed/:name/:claimId', handleVideoEmbedRequest); // for twitter
|
||||||
};
|
};
|
||||||
|
|
21
test/test.html
Normal file
21
test/test.html
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport"
|
||||||
|
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
|
<title>Document</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img src="https://media.giphy.com/media/vwEHGjx71HSytx5mY8/giphy-facebook_s.jpg" alt="test embed"/>
|
||||||
|
<p>no identifier, no ending</p>
|
||||||
|
<img src="https://dev1.spee.ch/typingcat" alt="no identifier, no ending"/>
|
||||||
|
<p>no identifier, yes ending</p>
|
||||||
|
<img src="https://dev1.spee.ch/typingcat.gif" alt="no identifier, yes ending"/>
|
||||||
|
<p>yes identifier, no ending</p>
|
||||||
|
<img src="https://dev1.spee.ch/8/typingcat" alt="yes identifier, no ending"/>
|
||||||
|
<p>yes identifier, yes ending</p>
|
||||||
|
<img src="https://dev1.spee.ch/8/typingcat.gif" alt="yes identifier, yes ending"/>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in a new issue