React/Redux - publish component #323

Merged
bones7242 merged 80 commits from react-upload into master 2018-01-25 22:43:20 +01:00
18 changed files with 291 additions and 196 deletions
Showing only changes of commit c5b22c287b - Show all commits

View file

@ -55,4 +55,4 @@ spee.ch is a single-serving site that reads and publishes images and videos to a
* `channelPassword` (optional,; required if `channelName` is provided)
## bugs
If you find a bug or experience a problem, please report your issue here on github and find us in the lbry slack!
If you find a bug or experience a problem, please report your issue here on github and find us in the lbry discord!

View file

@ -31,4 +31,12 @@ module.exports = {
defaultThumbnail : 'https://spee.ch/assets/img/video_thumb_default.png',
defaultDescription: 'Open-source, decentralized image and video sharing.',
},
testing: {
testChannel : '@testpublishchannel', // a channel to make test publishes in
testChannelPassword: 'password', // password for the test channel
},
api: {
apiHost: 'localhost',
apiPort: '5279',
},
};

4
constants/index.js Normal file
View file

@ -0,0 +1,4 @@
module.exports = {
PUBLISH_ANONYMOUS_CLAIM : 'PUBLISH_ANONYMOUS_CLAIM',
PUBLISH_IN_CHANNEL_CLAIM: 'PUBLISH_IN_CHANNEL_CLAIM',
};

View file

@ -18,7 +18,7 @@ module.exports = {
logger.debug(`this claim was published in channel: ${publishParams.channel_name}`);
return db.Channel.findOne({where: {channelName: publishParams.channel_name}});
} else {
logger.debug('this claim was published in channel: n/a');
logger.debug('this claim was not published in a channel');
return null;
}
})

View file

@ -1,94 +1,27 @@
const logger = require('winston');
const ua = require('universal-analytics');
const config = require('../config/speechConfig.js');
const db = require('../models');
const googleApiKey = config.analytics.googleId;
module.exports = {
postToStats (action, url, ipAddress, name, claimId, result) {
logger.debug('action:', action);
// make sure the result is a string
if (result && (typeof result !== 'string')) {
result = result.toString();
}
// make sure the ip address(es) are a string
if (ipAddress && (typeof ipAddress !== 'string')) {
ipAddress = ipAddress.toString();
}
db.File
.findOne({where: { name, claimId }})
.then(file => {
// create record in the db
let FileId;
if (file) {
FileId = file.dataValues.id;
} else {
FileId = null;
}
return db.Request
.create({
action,
url,
ipAddress,
result,
FileId,
});
})
.catch(error => {
logger.error('Sequelize error >>', error);
});
},
sendGoogleAnalytics (action, headers, ip, originalUrl) {
const visitorId = ip.replace(/\./g, '-');
const visitor = ua(googleApiKey, visitorId, { strictCidFormat: false, https: true });
let params;
switch (action) {
case 'SERVE':
params = {
ec : 'serve',
ea : originalUrl,
uip: ip,
ua : headers['user-agent'],
ul : headers['accept-language'],
};
break;
case 'PUBLISH':
params = {
ec : 'publish',
ea : originalUrl,
uip: ip,
ua : headers['user-agent'],
ul : headers['accept-language'],
};
break;
default: break;
}
visitor.event(params, (err) => {
if (err) {
logger.error('Google Analytics Event Error >>', err);
}
});
},
getTrendingClaims (startDate) {
logger.debug('retrieving trending');
return new Promise((resolve, reject) => {
// get the raw requests data
db.getTrendingFiles(startDate)
.then(fileArray => {
let claimsPromiseArray = [];
if (fileArray) {
fileArray.forEach(file => {
claimsPromiseArray.push(db.Claim.resolveClaim(file.name, file.claimId));
});
return Promise.all(claimsPromiseArray);
}
})
.then(claimsArray => {
resolve(claimsArray);
})
.catch(error => {
reject(error);
});
.then(fileArray => {
let claimsPromiseArray = [];
if (fileArray) {
fileArray.forEach(file => {
claimsPromiseArray.push(db.Claim.resolveClaim(file.name, file.claimId));
});
return Promise.all(claimsPromiseArray);
}
})
.then(claimsArray => {
resolve(claimsArray);
})
.catch(error => {
reject(error);
});
});
},
getRecentClaims () {
@ -96,13 +29,13 @@ module.exports = {
return new Promise((resolve, reject) => {
// get the raw requests data
db.File.getRecentClaims()
.then(results => {
resolve(results);
})
.catch(error => {
logger.error('sequelize error', error);
reject(error);
});
.then(results => {
resolve(results);
})
.catch(error => {
logger.error('sequelize error', error);
reject(error);
});
});
},
};

View file

@ -7,7 +7,7 @@ module.exports = {
if (error.code === 'ECONNREFUSED') {
status = 503;
message = 'Connection refused. The daemon may not be running.';
// check for errors from the deamon
// check for errors from the daemon
} else if (error.response) {
status = error.response.status || 500;
if (error.response.data) {

View file

@ -1,5 +1,62 @@
const Handlebars = require('handlebars');
const { site, analytics } = require('../config/speechConfig.js');
const { site, analytics, claim: claimDefaults } = require('../config/speechConfig.js');
function determineOgTitle (storedTitle, defaultTitle) {
return ifEmptyReturnOther(storedTitle, defaultTitle);
};
function determineOgDescription (storedDescription, defaultDescription) {
const length = 200;
let description = ifEmptyReturnOther(storedDescription, defaultDescription);
if (description.length >= length) {
description = `${description.substring(0, length)}...`;
};
return description;
};
function ifEmptyReturnOther (value, replacement) {
if (value === '') {
return replacement;
}
return value;
}
function determineContentTypeFromFileExtension (fileExtension) {
switch (fileExtension) {
case 'jpeg':
case 'jpg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'gif':
return 'image/gif';
case 'mp4':
return 'video/mp4';
default:
return 'image/jpeg';
}
};
function determineOgThumbnailContentType (thumbnail) {
if (thumbnail) {
if (thumbnail.lastIndexOf('.') !== -1) {
return determineContentTypeFromFileExtension(thumbnail.substring(thumbnail.lastIndexOf('.')));
}
}
return '';
}
function createOpenGraphDataFromClaim (claim, defaultTitle, defaultDescription) {
let openGraphData = {};
openGraphData['embedUrl'] = `${site.host}/${claim.claimId}/${claim.name}`;
openGraphData['showUrl'] = `${site.host}/${claim.claimId}/${claim.name}`;
openGraphData['source'] = `${site.host}/${claim.claimId}/${claim.name}.${claim.fileExt}`;
openGraphData['directFileUrl'] = `${site.host}/${claim.claimId}/${claim.name}.${claim.fileExt}`;
openGraphData['ogTitle'] = determineOgTitle(claim.title, defaultTitle);
openGraphData['ogDescription'] = determineOgDescription(claim.description, defaultDescription);
openGraphData['ogThumbnailContentType'] = determineOgThumbnailContentType(claim.thumbnail);
return openGraphData;
};
module.exports = {
placeCommonHeaderTags () {
@ -16,7 +73,10 @@ module.exports = {
ga('send', 'pageview');</script>`;
return new Handlebars.SafeString(gaCode);
},
addOpenGraph ({ ogTitle, contentType, ogDescription, thumbnail, showUrl, source, ogThumbnailContentType }) {
addOpenGraph (claim) {
const { ogTitle, ogDescription, showUrl, source, ogThumbnailContentType } = createOpenGraphDataFromClaim(claim, claimDefaults.defaultTitle, claimDefaults.defaultDescription);
const thumbnail = claim.thumbnail;
const contentType = claim.contentType;
const ogTitleTag = `<meta property="og:title" content="${ogTitle}" />`;
const ogUrlTag = `<meta property="og:url" content="${showUrl}" />`;
const ogSiteNameTag = `<meta property="og:site_name" content="${site.title}" />`;
@ -42,8 +102,10 @@ module.exports = {
return new Handlebars.SafeString(`${basicTags} ${ogImageTag} ${ogImageTypeTag} ${ogTypeTag}`);
}
},
addTwitterCard ({ contentType, source, embedUrl, directFileUrl }) {
addTwitterCard (claim) {
const { embedUrl, directFileUrl } = createOpenGraphDataFromClaim(claim, claimDefaults.defaultTitle, claimDefaults.defaultDescription);
const basicTwitterTags = `<meta name="twitter:site" content="@spee_ch" >`;
const contentType = claim.contentType;
if (contentType === 'video/mp4') {
const twitterName = '<meta name="twitter:card" content="player" >';
const twitterPlayer = `<meta name="twitter:player" content="${embedUrl}" >`;

View file

@ -1,5 +1,8 @@
const axios = require('axios');
const logger = require('winston');
const config = require('../config/speechConfig.js');
const { apiHost, apiPort } = config.api;
const lbryApiUri = 'http://' + apiHost + ':' + apiPort;
function handleLbrynetResponse ({ data }, resolve, reject) {
logger.debug('lbry api data:', data);
@ -22,7 +25,7 @@ module.exports = {
logger.debug(`lbryApi >> Publishing claim to "${publishParams.name}"`);
return new Promise((resolve, reject) => {
axios
.post('http://localhost:5279/lbryapi', {
.post(lbryApiUri, {
method: 'publish',
params: publishParams,
})
@ -38,7 +41,7 @@ module.exports = {
logger.debug(`lbryApi >> Getting Claim for "${uri}"`);
return new Promise((resolve, reject) => {
axios
.post('http://localhost:5279/lbryapi', {
.post(lbryApiUri, {
method: 'get',
params: { uri, timeout: 20 },
})
@ -54,7 +57,7 @@ module.exports = {
logger.debug(`lbryApi >> Getting claim_list for "${claimName}"`);
return new Promise((resolve, reject) => {
axios
.post('http://localhost:5279/lbryapi', {
.post(lbryApiUri, {
method: 'claim_list',
params: { name: claimName },
})
@ -71,7 +74,7 @@ module.exports = {
// console.log('resolving uri', uri);
return new Promise((resolve, reject) => {
axios
.post('http://localhost:5279/lbryapi', {
.post(lbryApiUri, {
method: 'resolve',
params: { uri },
})
@ -91,7 +94,7 @@ module.exports = {
logger.debug('lbryApi >> Retrieving the download directory path from lbry daemon...');
return new Promise((resolve, reject) => {
axios
.post('http://localhost:5279/lbryapi', {
.post(lbryApiUri, {
method: 'settings_get',
})
.then(({ data }) => {
@ -110,7 +113,7 @@ module.exports = {
createChannel (name) {
return new Promise((resolve, reject) => {
axios
.post('http://localhost:5279/lbryapi', {
.post(lbryApiUri, {
method: 'channel_new',
params: {
channel_name: name,

View file

@ -17,6 +17,9 @@ module.exports = {
logger.debug(`${proto}, ${value}, ${modifierSeperator}, ${modifier}`);
// Validate and process name
if (!value) {
throw new Error(`Check your url. No channel name provided before "${modifierSeperator}"`);
}
const isChannel = value.startsWith(module.exports.CHANNEL_CHAR);
const channelName = isChannel ? value : null;
let claimId;
@ -36,13 +39,13 @@ module.exports = {
let channelClaimId;
if (modifierSeperator) {
if (!modifier) {
throw new Error(`No modifier provided after separator ${modifierSeperator}.`);
throw new Error(`No modifier provided after separator "${modifierSeperator}"`);
}
if (modifierSeperator === ':') {
channelClaimId = modifier;
} else {
throw new Error(`The ${modifierSeperator} modifier is not currently supported.`);
throw new Error(`The "${modifierSeperator}" modifier is not currently supported`);
}
}
return {

View file

@ -1,3 +1,4 @@
const constants = require('../constants');
const logger = require('winston');
const fs = require('fs');
const { site, wallet } = require('../config/speechConfig.js');
@ -158,5 +159,29 @@ module.exports = {
logger.debug(`successfully deleted ${filePath}`);
});
},
addGetResultsToFileData (fileInfo, getResult) {
fileInfo.fileName = getResult.file_name;
fileInfo.filePath = getResult.download_path;
return fileInfo;
},
createFileData ({ name, claimId, outpoint, height, address, nsfw, contentType }) {
return {
name,
claimId,
outpoint,
height,
address,
fileName: '',
filePath: '',
fileType: contentType,
nsfw,
};
},
returnPublishTimingActionType (channelName) {
if (channelName) {
return constants.PUBLISH_IN_CHANNEL_CLAIM;
} else {
return constants.PUBLISH_ANONYMOUS_CLAIM;
}
},
};

95
helpers/statsHelpers.js Normal file
View file

@ -0,0 +1,95 @@
const constants = require('../constants');
const logger = require('winston');
const ua = require('universal-analytics');
const config = require('../config/speechConfig.js');
const googleApiKey = config.analytics.googleId;
const db = require('../models');
module.exports = {
createPublishTimingEventParams (publishDurration, ip, headers, label) {
return {
userTimingCategory : 'lbrynet',
userTimingVariableName: 'publish',
userTimingTime : publishDurration,
userTimingLabel : label,
uip : ip,
ua : headers['user-agent'],
ul : headers['accept-language'],
};
},
postToStats (action, url, ipAddress, name, claimId, result) {
logger.debug('action:', action);
// make sure the result is a string
if (result && (typeof result !== 'string')) {
result = result.toString();
}
// make sure the ip address(es) are a string
if (ipAddress && (typeof ipAddress !== 'string')) {
ipAddress = ipAddress.toString();
}
db.File
.findOne({where: { name, claimId }})
.then(file => {
// create record in the db
let FileId;
if (file) {
FileId = file.dataValues.id;
} else {
FileId = null;
}
return db.Request
.create({
action,
url,
ipAddress,
result,
FileId,
});
})
.catch(error => {
logger.error('Sequelize error >>', error);
});
},
sendGoogleAnalyticsEvent (action, headers, ip, originalUrl) {
const visitorId = ip.replace(/\./g, '-');
const visitor = ua(googleApiKey, visitorId, { strictCidFormat: false, https: true });
let params;
switch (action) {
case 'SERVE':
params = {
ec : 'serve',
ea : originalUrl,
uip: ip,
ua : headers['user-agent'],
ul : headers['accept-language'],
};
break;
default: break;
}
visitor.event(params, (err) => {
if (err) {
logger.error('Google Analytics Event Error >>', err);
}
});
},
sendGoogleAnalyticsTiming (action, headers, ip, originalUrl, startTime, endTime) {
const visitorId = ip.replace(/\./g, '-');
const visitor = ua(googleApiKey, visitorId, { strictCidFormat: false, https: true });
const durration = endTime - startTime;
let params;
switch (action) {
case constants.PUBLISH_ANONYMOUS_CLAIM:
case constants.PUBLISH_IN_CHANNEL_CLAIM:
logger.verbose(`${action} completed successfully in ${durration}ms`);
params = module.exports.createPublishTimingEventParams(durration, ip, headers, action);
break;
default: break;
}
visitor.timing(params, (err) => {
if (err) {
logger.error('Google Analytics Event Error >>', err);
}
logger.debug(`${action} timing event successfully sent to google analytics`);
});
},
};

View file

@ -1,8 +1,7 @@
const logger = require('winston');
const { returnShortId } = require('../helpers/sequelizeHelpers.js');
const { claim, site } = require('../config/speechConfig.js');
const { defaultTitle, defaultThumbnail, defaultDescription } = claim;
const { host } = site;
const { defaultThumbnail } = claim;
function determineFileExtensionFromContentType (contentType) {
switch (contentType) {
@ -21,68 +20,18 @@ function determineFileExtensionFromContentType (contentType) {
}
};
function determineContentTypeFromFileExtension (fileExtension) {
switch (fileExtension) {
case 'jpeg':
case 'jpg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'gif':
return 'image/gif';
case 'mp4':
return 'video/mp4';
default:
logger.debug('setting unknown file type as type image/jpeg');
return 'image/jpeg';
}
};
function ifEmptyReturnOther (value, replacement) {
if (value === '') {
return replacement;
}
return value;
}
function determineThumbnail (storedThumbnail, defaultThumbnail) {
return ifEmptyReturnOther(storedThumbnail, defaultThumbnail);
};
function determineOgTitle (storedTitle, defaultTitle) {
return ifEmptyReturnOther(storedTitle, defaultTitle);
};
function determineOgDescription (storedDescription, defaultDescription) {
return ifEmptyReturnOther(storedDescription, defaultDescription);
};
function determineOgThumbnailContentType (thumbnail) {
if (thumbnail) {
if (thumbnail.lastIndexOf('.') !== -1) {
return determineContentTypeFromFileExtension(thumbnail.substring(thumbnail.lastIndexOf('.')));
}
if (storedThumbnail === '') {
return defaultThumbnail;
}
return '';
}
function addOpengraphDataToClaim (claim) {
claim['host'] = host;
claim['embedUrl'] = `${host}/${claim.claimId}/${claim.name}`;
claim['showUrl'] = `${host}/${claim.claimId}/${claim.name}`;
claim['source'] = `${host}/${claim.claimId}/${claim.name}.${claim.fileExt}`;
claim['directFileUrl'] = `${host}/${claim.claimId}/${claim.name}.${claim.fileExt}`;
claim['ogTitle'] = determineOgTitle(claim.title, defaultTitle);
claim['ogDescription'] = determineOgDescription(claim.description, defaultDescription);
claim['ogThumbnailContentType'] = determineOgThumbnailContentType(claim.thumbnail);
return claim;
return storedThumbnail;
};
function prepareClaimData (claim) {
// logger.debug('preparing claim data based on resolved data:', claim);
claim['thumbnail'] = determineThumbnail(claim.thumbnail, defaultThumbnail);
claim['fileExt'] = determineFileExtensionFromContentType(claim.contentType);
claim = addOpengraphDataToClaim(claim);
claim['host'] = site.host;
return claim;
};
@ -307,7 +256,7 @@ module.exports = (sequelize, { STRING, BOOLEAN, INTEGER, TEXT, DECIMAL }) => {
case 1:
return resolve(result[0].claimId);
default:
logger.error(`${result.length} records found for ${claimName} from channel ${claimName}`);
logger.error(`${result.length} records found for "${claimName}" in channel "${channelClaimId}"`);
return resolve(result[0].claimId);
}
})

View file

@ -40,13 +40,11 @@ module.exports = new PassportLocalStrategy(
})
.then(([newUser, newChannel, newCertificate]) => {
logger.verbose('user and certificate successfully created');
logger.debug('user result >', newUser.dataValues);
// store the relevant newUser info to be passed back for req.User
userInfo['id'] = newUser.id;
userInfo['userName'] = newUser.userName;
logger.verbose('channel result >', newChannel.dataValues);
userInfo['channelName'] = newChannel.channelName;
userInfo['channelClaimId'] = newChannel.channelClaimId;
logger.verbose('certificate result >', newCertificate.dataValues);
// associate the instances
return Promise.all([newCertificate.setChannel(newChannel), newChannel.setUser(newUser)]);
})

View file

@ -5,30 +5,11 @@ const multipartMiddleware = multipart({uploadDir: files.uploadDirectory});
const db = require('../models');
const { checkClaimNameAvailability, checkChannelAvailability, publish } = require('../controllers/publishController.js');
const { getClaimList, resolveUri, getClaim } = require('../helpers/lbryApi.js');
const { createPublishParams, parsePublishApiRequestBody, parsePublishApiRequestFiles, parsePublishApiChannel } = require('../helpers/publishHelpers.js');
const { createPublishParams, parsePublishApiRequestBody, parsePublishApiRequestFiles, parsePublishApiChannel, addGetResultsToFileData, createFileData, returnPublishTimingActionType } = require('../helpers/publishHelpers.js');
const errorHandlers = require('../helpers/errorHandlers.js');
const { sendGoogleAnalyticsTiming } = require('../helpers/statsHelpers.js');
const { authenticateIfNoUserToken } = require('../auth/authentication.js');
function addGetResultsToFileData (fileInfo, getResult) {
fileInfo.fileName = getResult.file_name;
fileInfo.filePath = getResult.download_path;
return fileInfo;
}
function createFileData ({ name, claimId, outpoint, height, address, nsfw, contentType }) {
return {
name,
claimId,
outpoint,
height,
address,
fileName: '',
filePath: '',
fileType: contentType,
nsfw,
};
}
module.exports = (app) => {
// route to run a claim_list request on the daemon
app.get('/api/claim-list/:name', ({ ip, originalUrl, params }, res) => {
@ -122,9 +103,14 @@ module.exports = (app) => {
});
});
// route to run a publish request on the daemon
app.post('/api/claim-publish', multipartMiddleware, ({ body, files, ip, originalUrl, user }, res) => {
app.post('/api/claim-publish', multipartMiddleware, ({ body, files, headers, ip, originalUrl, user }, res) => {
logger.debug('api/claim-publish body:', body);
logger.debug('api/claim-publish files:', files);
// record the start time of the request and create variable for storing the action type
const publishStartTime = Date.now();
logger.debug('publish request started @', publishStartTime);
let timingActionType;
// define variables
let name, fileName, filePath, fileType, nsfw, license, title, description, thumbnail, channelName, channelPassword;
// validate the body and files of the request
try {
@ -153,7 +139,8 @@ module.exports = (app) => {
return createPublishParams(filePath, name, title, description, license, nsfw, thumbnail, channelName);
})
.then(publishParams => {
logger.debug('publishParams:', publishParams);
// set the timing event type for reporting
timingActionType = returnPublishTimingActionType(publishParams.channel_name);
// publish the asset
return publish(publishParams, fileName, fileType);
})
@ -166,6 +153,10 @@ module.exports = (app) => {
lbryTx: result,
},
});
// log the publish end time
const publishEndTime = Date.now();
logger.debug('publish request completed @', publishEndTime);
sendGoogleAnalyticsTiming(timingActionType, headers, ip, originalUrl, publishStartTime, publishEndTime);
})
.catch(error => {
errorHandlers.handleApiError(originalUrl, ip, error, res);

View file

@ -2,7 +2,7 @@ const logger = require('winston');
const { getClaimId, getChannelViewData, getLocalFileRecord } = require('../controllers/serveController.js');
const serveHelpers = require('../helpers/serveHelpers.js');
const { handleRequestError } = require('../helpers/errorHandlers.js');
const { postToStats } = require('../controllers/statsController.js');
const { postToStats } = require('../helpers/statsHelpers.js');
const db = require('../models');
const lbryUri = require('../helpers/lbryUri.js');

View file

@ -1,7 +1,9 @@
const chai = require('chai');
const expect = chai.expect;
const chaiHttp = require('chai-http');
const { host } = require('../../config/speechConfig.js').site;
const { site, testing } = require('../../config/speechConfig.js');
const { host } = site;
const { testChannel, testChannelPassword } = testing;
const requestTimeout = 20000;
const publishTimeout = 120000;
const fs = require('fs');
@ -82,15 +84,17 @@ describe('end-to-end', function () {
});
});
describe('publish', function () {
describe('publish requests', function () {
const publishUrl = '/api/claim-publish';
const date = new Date();
const name = `test-publish-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getTime()}`;
const filePath = './test/mock-data/bird.jpeg';
const fileName = 'byrd.jpeg';
const channelName = testChannel;
const channelPassword = testChannelPassword;
describe(publishUrl, function () {
describe('anonymous publishes', function () {
it(`should receive a status code 200 within ${publishTimeout}ms @usesLbc`, function (done) {
const date = new Date();
const name = `test-publish-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getTime()}`;
chai.request(host)
.post(publishUrl)
.type('form')
@ -104,6 +108,25 @@ describe('end-to-end', function () {
}).timeout(publishTimeout);
});
describe('in-channel publishes', function () {
it(`should receive a status code 200 within ${publishTimeout}ms @usesLbc`, function (done) {
const date = new Date();
const name = `test-publish-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getTime()}`;
chai.request(host)
.post(publishUrl)
.type('form')
.attach('file', fs.readFileSync(filePath), fileName)
.field('name', name)
.field('channelName', channelName)
.field('channelPassword', channelPassword)
.end(function (err, res) {
// expect(err).to.be.null;
expect(res).to.have.status(200);
done();
});
}).timeout(publishTimeout);
});
});

View file

@ -0,0 +1,4 @@
<div id="new-release-banner" class="row row--short row--wide">
<p style="font-size: medium"> Hi there! Spee.ch is currently undergoing maintenance, and as a result publishing may be disabled. Please visit our <a style="
color:white; text-decoration: underline" target="_blank" href="https://discord.gg/YjYbwhS">discord channel</a> for updates.</p>
</div>

View file

@ -1,3 +0,0 @@
<div id="new-release-banner" class="row row--short row--wide">
Hi there! You've stumbled upon the new version of Spee&#60;h, launching soon! Send us your feedback in <a style="color:white; text-decoration: underline" target="_blank" href="https://discord.gg/YjYbwhS">our discord</a>
</div>