Publish api fix #191
13 changed files with 299 additions and 209 deletions
32
README.md
32
README.md
|
@ -28,28 +28,24 @@ spee.ch is a single-serving site that reads and publishes images and videos to a
|
|||
|
||||
#### GET
|
||||
* /api/resolve/:name
|
||||
* a successfull request returns the resolve results for the claim at that name in JSON format
|
||||
* example: `curl https://spee.ch/api/resolve/doitlive`
|
||||
* /api/claim_list/:name
|
||||
* a successfull request returns a list of claims at that claim name in JSON format
|
||||
* /api/isClaimAvailable/:name
|
||||
* a successfull request returns a boolean: `true` if the name is still available, `false` if the name has already been published to by spee.ch.
|
||||
* example: `curl https://spee.ch/api/claim_list/doitlive`
|
||||
* /api/isClaimAvailable/:name (returns `true`/`false` for whether a name is available through spee.ch)
|
||||
* example: `curl https://spee.ch/api/isClaimAvailable/doitlive`
|
||||
|
||||
#### POST
|
||||
* /api/publish
|
||||
* request parameters:
|
||||
* body (form-data):
|
||||
* name: string (optional)
|
||||
* defaults to the file's name, sans extension
|
||||
* names can only contain the following characters: `A-Z`, `a-z`, `_`, or `-`
|
||||
* license: string (optional)
|
||||
* defaults to "No License Provided"
|
||||
* only "Public Domain" or "Creative Commons" licenses are allowed
|
||||
* nsfw: string, number, or boolean (optional)
|
||||
* defaults `true`
|
||||
* nsfw can be a string ("on"/"off"), number (0 or 1), or boolean (`true`/`false`)
|
||||
* files:
|
||||
* the `files` object submitted must use "speech" or "null" as the key for the file's value object
|
||||
* a successfull request will return the transaction details resulting from your published claim in JSON format
|
||||
* example: `curl -X POST -F 'name=MyPictureName' -F 'nsfw=false' -F 'file=@/path/to/my/picture.jpeg' https://spee.ch/api/publish`
|
||||
* Parameters:
|
||||
* name (string)
|
||||
* nsfw (boolean)
|
||||
* file (.mp4, .jpeg, .jpg, .gif, or .png)
|
||||
* license (string, optional)
|
||||
* title (string, optional)
|
||||
* description (string, optional)
|
||||
* channelName(string, optional)
|
||||
* channelPassword (string, optional)
|
||||
|
||||
## bugs
|
||||
If you find a bug or experience a problem, please report your issue here on github and find us in the lbry slack!
|
||||
|
|
|
@ -2,21 +2,23 @@ const db = require('../models');
|
|||
const logger = require('winston');
|
||||
|
||||
module.exports = {
|
||||
authenticateApiPublish (username, password) {
|
||||
authenticateChannelCredentials (channelName, userPassword) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (username === 'none') {
|
||||
if (!channelName) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
const userName = channelName.substring(1);
|
||||
logger.debug(`authenticateChannelCredentials > channelName: ${channelName} username: ${userName} pass: ${userPassword}`);
|
||||
db.User
|
||||
.findOne({where: {userName: username}})
|
||||
.findOne({where: { userName }})
|
||||
.then(user => {
|
||||
if (!user) {
|
||||
logger.debug('no user found');
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (!user.validPassword(password, user.password)) {
|
||||
if (!user.validPassword(userPassword, user.password)) {
|
||||
logger.debug('incorrect password');
|
||||
resolve(false);
|
||||
return;
|
||||
|
|
|
@ -7,24 +7,23 @@ module.exports = {
|
|||
publish (publishParams, fileName, fileType) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let publishResults = {};
|
||||
// 1. make sure the name is available
|
||||
publishHelpers.checkClaimNameAvailability(publishParams.name)
|
||||
// 2. publish the file
|
||||
.then(result => {
|
||||
if (result === true) {
|
||||
return lbryApi.publishClaim(publishParams);
|
||||
} else {
|
||||
return new Error('That name is already in use by spee.ch.');
|
||||
}
|
||||
})
|
||||
// 3. upsert File record (update is in case the claim has been published before by this daemon)
|
||||
// 1. publish the file
|
||||
lbryApi.publishClaim(publishParams)
|
||||
// 2. upsert File record (update is in case the claim has been published before by this daemon)
|
||||
.then(tx => {
|
||||
logger.info(`Successfully published ${fileName}`, tx);
|
||||
publishResults = tx;
|
||||
return db.Channel.findOne({where: {channelName: publishParams.channel_name}});
|
||||
})
|
||||
.then(user => {
|
||||
if (user) { logger.debug('successfully found user in User table') } else { logger.error('user for publish not found in User table') };
|
||||
let certificateId;
|
||||
if (user) {
|
||||
certificateId = user.channelClaimId;
|
||||
logger.debug('successfully found user in User table');
|
||||
} else {
|
||||
certificateId = null;
|
||||
logger.debug('user for publish not found in User table');
|
||||
};
|
||||
const fileRecord = {
|
||||
name : publishParams.name,
|
||||
claimId : publishResults.claim_id,
|
||||
|
@ -48,7 +47,7 @@ module.exports = {
|
|||
height : 0,
|
||||
contentType: fileType,
|
||||
nsfw : publishParams.metadata.nsfw,
|
||||
certificateId: user.channelClaimId,
|
||||
certificateId,
|
||||
amount : publishParams.bid,
|
||||
};
|
||||
const upsertCriteria = {
|
||||
|
|
|
@ -7,7 +7,7 @@ const { postToStats, sendGoogleAnalytics } = require('../controllers/statsContro
|
|||
const SERVE = 'SERVE';
|
||||
const SHOW = 'SHOW';
|
||||
const SHOWLITE = 'SHOWLITE';
|
||||
const DEFAULT_THUMBNAIL = 'https://spee.ch/assets/img/content-freedom-large.png';
|
||||
const DEFAULT_THUMBNAIL = 'https://spee.ch/assets/img/video_thumb_default.png';
|
||||
|
||||
function checkForLocalAssetByClaimId (claimId, name) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
|
@ -155,7 +155,7 @@ module.exports = {
|
|||
element['directUrlLong'] = `/${element.claimId}/${element.name}.${fileExtenstion}`;
|
||||
element['directUrlShort'] = `/${element.claimId}/${element.name}.${fileExtenstion}`;
|
||||
element['contentType'] = element.fileType;
|
||||
element['thumbnail'] = 'https://spee.ch/assets/img/content-freedom-large.png';
|
||||
element['thumbnail'] = 'https://spee.ch/assets/img/video_thumb_default.png';
|
||||
});
|
||||
}
|
||||
resolve(results);
|
||||
|
|
|
@ -26,6 +26,23 @@ module.exports = {
|
|||
res.status(400).send(error);
|
||||
}
|
||||
},
|
||||
handlePublishError (error) {
|
||||
logger.error('Publish Error:', useObjectPropertiesIfNoKeys(error));
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
return 'Connection refused. The daemon may not be running.';
|
||||
} else if (error.response) {
|
||||
if (error.response.data) {
|
||||
if (error.response.data.message) {
|
||||
return error.response.data.message;
|
||||
} else if (error.response.data.error) {
|
||||
return error.response.data.error.message;
|
||||
}
|
||||
return error.response.data;
|
||||
}
|
||||
return error.response;
|
||||
} else {
|
||||
return error;
|
||||
}
|
||||
useObjectPropertiesIfNoKeys (err) {
|
||||
return useObjectPropertiesIfNoKeys(err);
|
||||
},
|
||||
|
|
|
@ -1,78 +1,130 @@
|
|||
const logger = require('winston');
|
||||
const config = require('config');
|
||||
const fs = require('fs');
|
||||
const db = require('../models');
|
||||
const config = require('config');
|
||||
|
||||
module.exports = {
|
||||
validateFile (file, name, license, nsfw) {
|
||||
validateApiPublishRequest (body, files) {
|
||||
if (!body) {
|
||||
throw new Error('no body found in request');
|
||||
}
|
||||
if (!body.name) {
|
||||
throw new Error('no name field found in request');
|
||||
}
|
||||
if (!body.nsfw) {
|
||||
throw new Error('no nsfw field found in request');
|
||||
}
|
||||
if (!files) {
|
||||
throw new Error('no files found in request');
|
||||
}
|
||||
if (!files.file) {
|
||||
throw new Error('no file with key of [file] found in request');
|
||||
}
|
||||
},
|
||||
validatePublishSubmission (file, claimName, nsfw) {
|
||||
try {
|
||||
module.exports.validateFile(file);
|
||||
module.exports.validateClaimName(claimName);
|
||||
module.exports.validateNSFW(nsfw);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
validateFile (file) {
|
||||
if (!file) {
|
||||
throw new Error('No file was submitted or the key used was incorrect. Files posted through this route must use a key of "speech" or null');
|
||||
logger.debug('publish > file validation > no file found');
|
||||
throw new Error('no file provided');
|
||||
}
|
||||
// check the file name
|
||||
if (/'/.test(file.name)) {
|
||||
logger.debug('publish > file validation > file name had apostrophe in it');
|
||||
throw new Error('apostrophes are not allowed in the file name');
|
||||
}
|
||||
// check file type and size
|
||||
switch (file.type) {
|
||||
case 'image/jpeg':
|
||||
case 'image/jpg':
|
||||
case 'image/png':
|
||||
if (file.size > 10000000) {
|
||||
logger.debug('publish > file validation > .jpeg/.jpg/.png was too big');
|
||||
throw new Error('Sorry, images are limited to 10 megabytes.');
|
||||
}
|
||||
break;
|
||||
case 'image/gif':
|
||||
if (file.size > 50000000) {
|
||||
throw new Error('Your image exceeds the 50 megabyte limit.');
|
||||
logger.debug('publish > file validation > .gif was too big');
|
||||
throw new Error('Sorry, .gifs are limited to 50 megabytes.');
|
||||
}
|
||||
break;
|
||||
case 'video/mp4':
|
||||
if (file.size > 50000000) {
|
||||
throw new Error('Your video exceeds the 50 megabyte limit.');
|
||||
logger.debug('publish > file validation > .mp4 was too big');
|
||||
throw new Error('Sorry, videos are limited to 50 megabytes.');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error('The ' + file.Type + ' content type is not supported. Only, .jpeg, .png, .gif, and .mp4 files are currently supported.');
|
||||
logger.debug('publish > file validation > unrecognized file type');
|
||||
throw new Error('The ' + file.type + ' content type is not supported. Only, .jpeg, .png, .gif, and .mp4 files are currently supported.');
|
||||
}
|
||||
// validate claim name
|
||||
const invalidCharacters = /[^A-Za-z0-9,-]/.exec(name);
|
||||
return file;
|
||||
},
|
||||
validateClaimName (claimName) {
|
||||
const invalidCharacters = /[^A-Za-z0-9,-]/.exec(claimName);
|
||||
if (invalidCharacters) {
|
||||
throw new Error('The url name you provided is not allowed. Only the following characters are allowed: A-Z, a-z, 0-9, and "-"');
|
||||
throw new Error('The claim name you provided is not allowed. Only the following characters are allowed: A-Z, a-z, 0-9, and "-"');
|
||||
}
|
||||
// validate license
|
||||
},
|
||||
validateLicense (license) {
|
||||
if ((license.indexOf('Public Domain') === -1) && (license.indexOf('Creative Commons') === -1)) {
|
||||
throw new Error('Only posts with a "Public Domain" license, or one of the Creative Commons licenses are eligible for publishing through spee.ch');
|
||||
throw new Error('Only posts with a "Public Domain" or "Creative Commons" license are eligible for publishing through spee.ch');
|
||||
}
|
||||
},
|
||||
cleanseNSFW (nsfw) {
|
||||
switch (nsfw) {
|
||||
case true:
|
||||
case false:
|
||||
case 'true':
|
||||
case 'false':
|
||||
case 'on':
|
||||
case 'true':
|
||||
case 1:
|
||||
case '1':
|
||||
return true;
|
||||
case false:
|
||||
case 'false':
|
||||
case 'off':
|
||||
case 0:
|
||||
case '0':
|
||||
case 1:
|
||||
case '1':
|
||||
break;
|
||||
return false;
|
||||
default:
|
||||
throw new Error('NSFW value was not accepted. NSFW must be set to either true, false, "on", or "off"');
|
||||
return null;
|
||||
}
|
||||
},
|
||||
createPublishParams (name, filePath, title, description, license, nsfw, channel) {
|
||||
logger.debug(`Creating Publish Parameters for "${name}"`);
|
||||
const claimAddress = config.get('WalletConfig.LbryClaimAddress');
|
||||
const defaultChannel = config.get('WalletConfig.DefaultChannel');
|
||||
// filter nsfw and ensure it is a boolean
|
||||
if (nsfw === false) {
|
||||
nsfw = false;
|
||||
} else if (typeof nsfw === 'string') {
|
||||
if (nsfw.toLowerCase === 'false' || nsfw.toLowerCase === 'off' || nsfw === '0') {
|
||||
nsfw = false;
|
||||
cleanseChannelName (channelName) {
|
||||
if (channelName) {
|
||||
if (channelName.indexOf('@') !== 0) {
|
||||
channelName = `@${channelName}`;
|
||||
}
|
||||
} else if (nsfw === 0) {
|
||||
nsfw = false;
|
||||
} else {
|
||||
nsfw = true;
|
||||
}
|
||||
// provide defaults for title & description
|
||||
if (title === null || title === '') {
|
||||
return channelName;
|
||||
},
|
||||
validateNSFW (nsfw) {
|
||||
if (nsfw === true || nsfw === false) {
|
||||
return;
|
||||
}
|
||||
throw new Error('NSFW must be set to either true or false');
|
||||
},
|
||||
createPublishParams (filePath, name, title, description, license, nsfw, channelName) {
|
||||
logger.debug(`Creating Publish Parameters`);
|
||||
// provide defaults for title
|
||||
if (title === null || title.trim() === '') {
|
||||
title = name;
|
||||
}
|
||||
// provide default for description
|
||||
if (description === null || description.trim() === '') {
|
||||
description = `${name} published via spee.ch`;
|
||||
}
|
||||
// provide default for license
|
||||
if (license === null || license.trim() === '') {
|
||||
license = 'All Rights Reserved';
|
||||
}
|
||||
// create the publish params
|
||||
const publishParams = {
|
||||
name,
|
||||
|
@ -86,16 +138,12 @@ module.exports = {
|
|||
license,
|
||||
nsfw,
|
||||
},
|
||||
claim_address: claimAddress,
|
||||
claim_address: config.get('WalletConfig.LbryClaimAddress'),
|
||||
};
|
||||
// add channel if applicable
|
||||
if (channel !== 'none') {
|
||||
publishParams['channel_name'] = channel;
|
||||
} else {
|
||||
publishParams['channel_name'] = defaultChannel;
|
||||
// add channel to params, if applicable
|
||||
if (channelName) {
|
||||
publishParams['channel_name'] = channelName;
|
||||
}
|
||||
|
||||
logger.debug('publishParams:', publishParams);
|
||||
return publishParams;
|
||||
},
|
||||
deleteTemporaryFile (filePath) {
|
||||
|
|
|
@ -33,6 +33,7 @@
|
|||
"express": "^4.15.2",
|
||||
"express-handlebars": "^3.0.0",
|
||||
"express-session": "^1.15.5",
|
||||
"form-data": "^2.3.1",
|
||||
"helmet": "^3.8.1",
|
||||
"mysql2": "^1.3.5",
|
||||
"nodemon": "^1.11.0",
|
||||
|
|
BIN
public/assets/img/video_thumb_default.png
Normal file
BIN
public/assets/img/video_thumb_default.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 58 KiB |
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
// validation function which checks the proposed file's type, size, and name
|
||||
function validateFile(file) {
|
||||
if (!file) {
|
||||
|
@ -210,7 +208,7 @@ function validateNewChannelSubmission(userName, password){
|
|||
console.log('channel is available');
|
||||
resolve();
|
||||
} else {
|
||||
console.log('channel is not avaliable');
|
||||
console.log('channel is not available');
|
||||
reject(new ChannelNameError('that channel name has already been taken'));
|
||||
}
|
||||
})
|
||||
|
|
|
@ -4,10 +4,10 @@ const multipartMiddleware = multipart();
|
|||
const db = require('../models');
|
||||
const { publish } = require('../controllers/publishController.js');
|
||||
const { getClaimList, resolveUri } = require('../helpers/lbryApi.js');
|
||||
const { createPublishParams, validateFile, checkClaimNameAvailability, checkChannelAvailability } = require('../helpers/publishHelpers.js');
|
||||
const { createPublishParams, validateApiPublishRequest, validatePublishSubmission, cleanseNSFW, cleanseChannelName, checkClaimNameAvailability, checkChannelAvailability } = require('../helpers/publishHelpers.js');
|
||||
const errorHandlers = require('../helpers/errorHandlers.js');
|
||||
const { postToStats, sendGoogleAnalytics } = require('../controllers/statsController.js');
|
||||
const { authenticateApiPublish } = require('../auth/authentication.js');
|
||||
const { authenticateChannelCredentials } = require('../auth/authentication.js');
|
||||
|
||||
module.exports = (app) => {
|
||||
// route to run a claim_list request on the daemon
|
||||
|
@ -71,52 +71,80 @@ module.exports = (app) => {
|
|||
});
|
||||
});
|
||||
// route to run a publish request on the daemon
|
||||
app.post('/api/publish', multipartMiddleware, ({ body, files, headers, ip, originalUrl }, res) => {
|
||||
// google analytics
|
||||
sendGoogleAnalytics('PUBLISH', headers, ip, originalUrl);
|
||||
// validate that a file was provided
|
||||
const file = files.speech || files.null;
|
||||
const name = body.name || file.name.substring(0, file.name.indexOf('.'));
|
||||
const title = body.title || null;
|
||||
const description = body.description || null;
|
||||
const license = body.license || 'No License Provided';
|
||||
const nsfw = body.nsfw || null;
|
||||
const channelName = body.channelName || 'none';
|
||||
const channelPassword = body.channelPassword || null;
|
||||
logger.debug(`name: ${name}, license: ${license}, nsfw: ${nsfw}`);
|
||||
app.post('/api/publish', multipartMiddleware, (req, res) => {
|
||||
logger.debug(req);
|
||||
const body = req.body;
|
||||
const files = req.files;
|
||||
try {
|
||||
validateFile(file, name, license, nsfw);
|
||||
validateApiPublishRequest(body, files);
|
||||
} catch (error) {
|
||||
postToStats('publish', originalUrl, ip, null, null, error.message);
|
||||
logger.debug('rejected >>', error.message);
|
||||
res.status(400).send(error.message);
|
||||
logger.debug('publish request rejected, insufficient request parameters');
|
||||
res.status(400).json({success: false, message: error.message});
|
||||
return;
|
||||
}
|
||||
// required inputs
|
||||
const file = files.file;
|
||||
const fileName = file.name;
|
||||
const filePath = file.path;
|
||||
const fileType = file.type;
|
||||
// channel authorization
|
||||
authenticateApiPublish(channelName, channelPassword)
|
||||
const name = body.name;
|
||||
let nsfw = body.nsfw;
|
||||
// cleanse nsfw
|
||||
nsfw = cleanseNSFW(nsfw);
|
||||
// validate file, name, license, and nsfw
|
||||
try {
|
||||
validatePublishSubmission(file, name, nsfw);
|
||||
} catch (error) {
|
||||
logger.debug('publish request rejected');
|
||||
res.status(400).json({success: false, message: error.message});
|
||||
return;
|
||||
}
|
||||
logger.debug(`name: ${name}, nsfw: ${nsfw}`);
|
||||
// optional inputs
|
||||
const license = body.license || null;
|
||||
const title = body.title || null;
|
||||
const description = body.description || null;
|
||||
let channelName = body.channelName || null;
|
||||
channelName = cleanseChannelName(channelName);
|
||||
const channelPassword = body.channelPassword || null;
|
||||
logger.debug(`license: ${license} title: "${title}" description: "${description}" channelName: "${channelName}" channelPassword: "${channelPassword}"`);
|
||||
// check channel authorization
|
||||
authenticateChannelCredentials(channelName, channelPassword)
|
||||
.then(result => {
|
||||
if (!result) {
|
||||
res.status(401).send('Authentication failed, you do not have access to that channel');
|
||||
throw new Error('authentication failed');
|
||||
throw new Error('Authentication failed, you do not have access to that channel');
|
||||
}
|
||||
return createPublishParams(name, filePath, title, description, license, nsfw, channelName);
|
||||
// make sure the claim name is available
|
||||
return checkClaimNameAvailability(name);
|
||||
})
|
||||
.then(result => {
|
||||
if (!result) {
|
||||
throw new Error('That name is already in use by spee.ch.');
|
||||
}
|
||||
// create publish parameters object
|
||||
return createPublishParams(filePath, name, title, description, license, nsfw, channelName);
|
||||
})
|
||||
.then(publishParams => {
|
||||
logger.debug('publishParams:', publishParams);
|
||||
// publish the asset
|
||||
return publish(publishParams, fileName, fileType);
|
||||
})
|
||||
// publish the asset
|
||||
.then(result => {
|
||||
postToStats('publish', originalUrl, ip, null, null, 'success');
|
||||
res.status(200).json(result);
|
||||
// postToStats('publish', originalUrl, ip, null, null, 'success');
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: {
|
||||
url : `spee.ch/${result.claim_id}/${name}`,
|
||||
lbryTx: result,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error('publish api error', error);
|
||||
res.status(400).json({success: false, message: error.message});
|
||||
});
|
||||
});
|
||||
|
||||
// route to get a short claim id from long claim Id
|
||||
app.get('/api/shortClaimId/:longId/:name', ({ originalUrl, ip, params }, res) => {
|
||||
// serve content
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
const logger = require('winston');
|
||||
const publishController = require('../controllers/publishController.js');
|
||||
const publishHelpers = require('../helpers/publishHelpers.js');
|
||||
const { useObjectPropertiesIfNoKeys } = require('../helpers/errorHandlers.js');
|
||||
const { publish } = require('../controllers/publishController.js');
|
||||
const { createPublishParams } = require('../helpers/publishHelpers.js');
|
||||
const errorHandlers = require('../helpers/errorHandlers.js');
|
||||
const { postToStats } = require('../controllers/statsController.js');
|
||||
|
||||
module.exports = (app, siofu, hostedContentPath) => {
|
||||
|
@ -40,12 +40,13 @@ module.exports = (app, siofu, hostedContentPath) => {
|
|||
NOTE: need to validate that client has the credentials to the channel they chose
|
||||
otherwise they could circumvent security client side.
|
||||
*/
|
||||
|
||||
let channelName = file.meta.channel;
|
||||
if (channelName === 'none') channelName = null;
|
||||
// prepare the publish parameters
|
||||
const publishParams = publishHelpers.createPublishParams(file.meta.name, file.pathName, file.meta.title, file.meta.description, file.meta.license, file.meta.nsfw, file.meta.channel);
|
||||
const publishParams = createPublishParams(file.pathName, file.meta.name, file.meta.title, file.meta.description, file.meta.license, file.meta.nsfw, channelName);
|
||||
logger.debug(publishParams);
|
||||
// publish the file
|
||||
publishController.publish(publishParams, file.name, file.meta.type)
|
||||
publish(publishParams, file.name, file.meta.type)
|
||||
.then(result => {
|
||||
socket.emit('publish-complete', { name: publishParams.name, result });
|
||||
postToStats('PUBLISH', '/', null, null, null, 'success');
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<div class="all-claims-item">
|
||||
<a href="/{{this.claimId}}/{{this.name}}">
|
||||
{{#ifConditional this.fileType '===' 'video/mp4'}}
|
||||
<img class="all-claims-asset" src="/assets/img/content-freedom-large.png"/>
|
||||
<img class="all-claims-asset" src="/assets/img/video_thumb_default.png"/>
|
||||
{{else}}
|
||||
<img class="all-claims-asset" src="/{{this.claimId}}/{{this.name}}.ext" />
|
||||
{{/ifConditional}}
|
||||
|
|
Loading…
Reference in a new issue