Publish api fix #191

Merged
bones7242 merged 4 commits from publish-api-fix into redesign-1 2017-10-07 02:47:59 +02:00
13 changed files with 299 additions and 209 deletions

View file

@ -28,28 +28,24 @@ spee.ch is a single-serving site that reads and publishes images and videos to a
#### GET #### GET
* /api/resolve/:name * /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 * /api/claim_list/:name
* a successfull request returns a list of claims at that claim name in JSON format * example: `curl https://spee.ch/api/claim_list/doitlive`
* /api/isClaimAvailable/:name * /api/isClaimAvailable/:name (returns `true`/`false` for whether a name is available through spee.ch)
* 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/isClaimAvailable/doitlive`
#### POST #### POST
* /api/publish * /api/publish
* request parameters: * example: `curl -X POST -F 'name=MyPictureName' -F 'nsfw=false' -F 'file=@/path/to/my/picture.jpeg' https://spee.ch/api/publish`
* body (form-data): * Parameters:
* name: string (optional) * name (string)
* defaults to the file's name, sans extension * nsfw (boolean)
* names can only contain the following characters: `A-Z`, `a-z`, `_`, or `-` * file (.mp4, .jpeg, .jpg, .gif, or .png)
* license: string (optional) * license (string, optional)
* defaults to "No License Provided" * title (string, optional)
* only "Public Domain" or "Creative Commons" licenses are allowed * description (string, optional)
* nsfw: string, number, or boolean (optional) * channelName(string, optional)
* defaults `true` * channelPassword (string, optional)
* 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
## bugs ## 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 slack!

View file

@ -2,21 +2,23 @@ const db = require('../models');
const logger = require('winston'); const logger = require('winston');
module.exports = { module.exports = {
authenticateApiPublish (username, password) { authenticateChannelCredentials (channelName, userPassword) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (username === 'none') { if (!channelName) {
resolve(true); resolve(true);
return; return;
} }
const userName = channelName.substring(1);
logger.debug(`authenticateChannelCredentials > channelName: ${channelName} username: ${userName} pass: ${userPassword}`);
db.User db.User
.findOne({where: {userName: username}}) .findOne({where: { userName }})
.then(user => { .then(user => {
if (!user) { if (!user) {
logger.debug('no user found'); logger.debug('no user found');
resolve(false); resolve(false);
return; return;
} }
if (!user.validPassword(password, user.password)) { if (!user.validPassword(userPassword, user.password)) {
logger.debug('incorrect password'); logger.debug('incorrect password');
resolve(false); resolve(false);
return; return;

View file

@ -7,24 +7,23 @@ module.exports = {
publish (publishParams, fileName, fileType) { publish (publishParams, fileName, fileType) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let publishResults = {}; let publishResults = {};
// 1. make sure the name is available // 1. publish the file
publishHelpers.checkClaimNameAvailability(publishParams.name) lbryApi.publishClaim(publishParams)
// 2. publish the file // 2. upsert File record (update is in case the claim has been published before by this daemon)
.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)
.then(tx => { .then(tx => {
logger.info(`Successfully published ${fileName}`, tx); logger.info(`Successfully published ${fileName}`, tx);
publishResults = tx; publishResults = tx;
return db.Channel.findOne({where: {channelName: publishParams.channel_name}}); return db.Channel.findOne({where: {channelName: publishParams.channel_name}});
}) })
.then(user => { .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 = { const fileRecord = {
name : publishParams.name, name : publishParams.name,
claimId : publishResults.claim_id, claimId : publishResults.claim_id,
@ -42,13 +41,13 @@ module.exports = {
name : publishParams.name, name : publishParams.name,
claimId : publishResults.claim_id, claimId : publishResults.claim_id,
title : publishParams.metadata.title, title : publishParams.metadata.title,
description : publishParams.metadata.description, description: publishParams.metadata.description,
address : publishParams.claim_address, address : publishParams.claim_address,
outpoint : `${publishResults.txid}:${publishResults.nout}`, outpoint : `${publishResults.txid}:${publishResults.nout}`,
height : 0, height : 0,
contentType : fileType, contentType: fileType,
nsfw : publishParams.metadata.nsfw, nsfw : publishParams.metadata.nsfw,
certificateId: user.channelClaimId, certificateId,
amount : publishParams.bid, amount : publishParams.bid,
}; };
const upsertCriteria = { const upsertCriteria = {

View file

@ -7,7 +7,7 @@ const { postToStats, sendGoogleAnalytics } = require('../controllers/statsContro
const SERVE = 'SERVE'; const SERVE = 'SERVE';
const SHOW = 'SHOW'; const SHOW = 'SHOW';
const SHOWLITE = 'SHOWLITE'; 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) { function checkForLocalAssetByClaimId (claimId, name) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View file

@ -155,7 +155,7 @@ module.exports = {
element['directUrlLong'] = `/${element.claimId}/${element.name}.${fileExtenstion}`; element['directUrlLong'] = `/${element.claimId}/${element.name}.${fileExtenstion}`;
element['directUrlShort'] = `/${element.claimId}/${element.name}.${fileExtenstion}`; element['directUrlShort'] = `/${element.claimId}/${element.name}.${fileExtenstion}`;
element['contentType'] = element.fileType; 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); resolve(results);

View file

@ -26,6 +26,23 @@ module.exports = {
res.status(400).send(error); 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) { useObjectPropertiesIfNoKeys (err) {
return useObjectPropertiesIfNoKeys(err); return useObjectPropertiesIfNoKeys(err);
}, },

View file

@ -1,78 +1,130 @@
const logger = require('winston'); const logger = require('winston');
const config = require('config');
const fs = require('fs'); const fs = require('fs');
const db = require('../models'); const db = require('../models');
const config = require('config');
module.exports = { 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) { 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 // check file type and size
switch (file.type) { switch (file.type) {
case 'image/jpeg': case 'image/jpeg':
case 'image/jpg':
case 'image/png': 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': case 'image/gif':
if (file.size > 50000000) { 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; break;
case 'video/mp4': case 'video/mp4':
if (file.size > 50000000) { 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; break;
default: 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 return file;
const invalidCharacters = /[^A-Za-z0-9,-]/.exec(name); },
validateClaimName (claimName) {
const invalidCharacters = /[^A-Za-z0-9,-]/.exec(claimName);
if (invalidCharacters) { 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)) { 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) { switch (nsfw) {
case true: case true:
case false:
case 'true':
case 'false':
case 'on': case 'on':
case 'true':
case 1:
case '1':
return true;
case false:
case 'false':
case 'off': case 'off':
case 0: case 0:
case '0': case '0':
case 1: return false;
case '1':
break;
default: 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) { cleanseChannelName (channelName) {
logger.debug(`Creating Publish Parameters for "${name}"`); if (channelName) {
const claimAddress = config.get('WalletConfig.LbryClaimAddress'); if (channelName.indexOf('@') !== 0) {
const defaultChannel = config.get('WalletConfig.DefaultChannel'); channelName = `@${channelName}`;
// 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;
} }
} else if (nsfw === 0) {
nsfw = false;
} else {
nsfw = true;
} }
// provide defaults for title & description return channelName;
if (title === null || title === '') { },
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; title = name;
} }
// provide default for description
if (description === null || description.trim() === '') { if (description === null || description.trim() === '') {
description = `${name} published via spee.ch`; description = `${name} published via spee.ch`;
} }
// provide default for license
if (license === null || license.trim() === '') {
license = 'All Rights Reserved';
}
// create the publish params // create the publish params
const publishParams = { const publishParams = {
name, name,
@ -86,16 +138,12 @@ module.exports = {
license, license,
nsfw, nsfw,
}, },
claim_address: claimAddress, claim_address: config.get('WalletConfig.LbryClaimAddress'),
}; };
// add channel if applicable // add channel to params, if applicable
if (channel !== 'none') { if (channelName) {
publishParams['channel_name'] = channel; publishParams['channel_name'] = channelName;
} else {
publishParams['channel_name'] = defaultChannel;
} }
logger.debug('publishParams:', publishParams);
return publishParams; return publishParams;
}, },
deleteTemporaryFile (filePath) { deleteTemporaryFile (filePath) {

View file

@ -33,6 +33,7 @@
"express": "^4.15.2", "express": "^4.15.2",
"express-handlebars": "^3.0.0", "express-handlebars": "^3.0.0",
"express-session": "^1.15.5", "express-session": "^1.15.5",
"form-data": "^2.3.1",
"helmet": "^3.8.1", "helmet": "^3.8.1",
"mysql2": "^1.3.5", "mysql2": "^1.3.5",
"nodemon": "^1.11.0", "nodemon": "^1.11.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View file

@ -1,5 +1,3 @@
// validation function which checks the proposed file's type, size, and name // validation function which checks the proposed file's type, size, and name
function validateFile(file) { function validateFile(file) {
if (!file) { if (!file) {
@ -210,7 +208,7 @@ function validateNewChannelSubmission(userName, password){
console.log('channel is available'); console.log('channel is available');
resolve(); resolve();
} else { } else {
console.log('channel is not avaliable'); console.log('channel is not available');
reject(new ChannelNameError('that channel name has already been taken')); reject(new ChannelNameError('that channel name has already been taken'));
} }
}) })

View file

@ -4,10 +4,10 @@ const multipartMiddleware = multipart();
const db = require('../models'); const db = require('../models');
const { publish } = require('../controllers/publishController.js'); const { publish } = require('../controllers/publishController.js');
const { getClaimList, resolveUri } = require('../helpers/lbryApi.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 errorHandlers = require('../helpers/errorHandlers.js');
const { postToStats, sendGoogleAnalytics } = require('../controllers/statsController.js'); const { postToStats, sendGoogleAnalytics } = require('../controllers/statsController.js');
const { authenticateApiPublish } = require('../auth/authentication.js'); const { authenticateChannelCredentials } = require('../auth/authentication.js');
module.exports = (app) => { module.exports = (app) => {
// route to run a claim_list request on the daemon // 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 // route to run a publish request on the daemon
app.post('/api/publish', multipartMiddleware, ({ body, files, headers, ip, originalUrl }, res) => { app.post('/api/publish', multipartMiddleware, (req, res) => {
// google analytics logger.debug(req);
sendGoogleAnalytics('PUBLISH', headers, ip, originalUrl); const body = req.body;
// validate that a file was provided const files = req.files;
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}`);
try { try {
validateFile(file, name, license, nsfw); validateApiPublishRequest(body, files);
} catch (error) { } catch (error) {
postToStats('publish', originalUrl, ip, null, null, error.message); logger.debug('publish request rejected, insufficient request parameters');
logger.debug('rejected >>', error.message); res.status(400).json({success: false, message: error.message});
res.status(400).send(error.message);
return; return;
} }
// required inputs
const file = files.file;
const fileName = file.name; const fileName = file.name;
const filePath = file.path; const filePath = file.path;
const fileType = file.type; const fileType = file.type;
// channel authorization const name = body.name;
authenticateApiPublish(channelName, channelPassword) 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 => { .then(result => {
if (!result) { if (!result) {
res.status(401).send('Authentication failed, you do not have access to that channel'); throw new Error('Authentication failed, you do not have access to that channel');
throw new Error('authentication failed');
} }
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 // create publish parameters object
return createPublishParams(filePath, name, title, description, license, nsfw, channelName);
})
.then(publishParams => { .then(publishParams => {
logger.debug('publishParams:', publishParams);
// publish the asset
return publish(publishParams, fileName, fileType); return publish(publishParams, fileName, fileType);
}) })
// publish the asset
.then(result => { .then(result => {
postToStats('publish', originalUrl, ip, null, null, 'success'); // postToStats('publish', originalUrl, ip, null, null, 'success');
res.status(200).json(result); res.status(200).json({
success: true,
message: {
url : `spee.ch/${result.claim_id}/${name}`,
lbryTx: result,
},
});
}) })
.catch(error => { .catch(error => {
logger.error('publish api error', 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 // route to get a short claim id from long claim Id
app.get('/api/shortClaimId/:longId/:name', ({ originalUrl, ip, params }, res) => { app.get('/api/shortClaimId/:longId/:name', ({ originalUrl, ip, params }, res) => {
// serve content // serve content

View file

@ -1,7 +1,7 @@
const logger = require('winston'); const logger = require('winston');
const publishController = require('../controllers/publishController.js'); const { publish } = require('../controllers/publishController.js');
const publishHelpers = require('../helpers/publishHelpers.js'); const { createPublishParams } = require('../helpers/publishHelpers.js');
const { useObjectPropertiesIfNoKeys } = require('../helpers/errorHandlers.js'); const errorHandlers = require('../helpers/errorHandlers.js');
const { postToStats } = require('../controllers/statsController.js'); const { postToStats } = require('../controllers/statsController.js');
module.exports = (app, siofu, hostedContentPath) => { 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 NOTE: need to validate that client has the credentials to the channel they chose
otherwise they could circumvent security client side. otherwise they could circumvent security client side.
*/ */
let channelName = file.meta.channel;
if (channelName === 'none') channelName = null;
// prepare the publish parameters // 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); logger.debug(publishParams);
// publish the file // publish the file
publishController.publish(publishParams, file.name, file.meta.type) publish(publishParams, file.name, file.meta.type)
.then(result => { .then(result => {
socket.emit('publish-complete', { name: publishParams.name, result }); socket.emit('publish-complete', { name: publishParams.name, result });
postToStats('PUBLISH', '/', null, null, null, 'success'); postToStats('PUBLISH', '/', null, null, null, 'success');

View file

@ -7,7 +7,7 @@
<div class="all-claims-item"> <div class="all-claims-item">
<a href="/{{this.claimId}}/{{this.name}}"> <a href="/{{this.claimId}}/{{this.name}}">
{{#ifConditional this.fileType '===' 'video/mp4'}} {{#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}} {{else}}
<img class="all-claims-asset" src="/{{this.claimId}}/{{this.name}}.ext" /> <img class="all-claims-asset" src="/{{this.claimId}}/{{this.name}}.ext" />
{{/ifConditional}} {{/ifConditional}}