diff --git a/auth/authentication.js b/auth/authentication.js index 6c6cf441..631aee66 100644 --- a/auth/authentication.js +++ b/auth/authentication.js @@ -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; diff --git a/controllers/publishController.js b/controllers/publishController.js index 2ef07bdf..698ac7b7 100644 --- a/controllers/publishController.js +++ b/controllers/publishController.js @@ -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, @@ -39,17 +38,17 @@ module.exports = { nsfw : publishParams.metadata.nsfw, }; const claimRecord = { - name : publishParams.name, - claimId : publishResults.claim_id, - title : publishParams.metadata.title, - description : publishParams.metadata.description, - address : publishParams.claim_address, - outpoint : `${publishResults.txid}:${publishResults.nout}`, - height : 0, - contentType : fileType, - nsfw : publishParams.metadata.nsfw, - certificateId: user.channelClaimId, - amount : publishParams.bid, + name : publishParams.name, + claimId : publishResults.claim_id, + title : publishParams.metadata.title, + description: publishParams.metadata.description, + address : publishParams.claim_address, + outpoint : `${publishResults.txid}:${publishResults.nout}`, + height : 0, + contentType: fileType, + nsfw : publishParams.metadata.nsfw, + certificateId, + amount : publishParams.bid, }; const upsertCriteria = { name : publishParams.name, diff --git a/helpers/errorHandlers.js b/helpers/errorHandlers.js index d1ac23cc..af549e30 100644 --- a/helpers/errorHandlers.js +++ b/helpers/errorHandlers.js @@ -30,8 +30,16 @@ module.exports = { logger.error('Publish Error:', useObjectPropertiesIfNoKeys(error)); if (error.code === 'ECONNREFUSED') { return 'Connection refused. The daemon may not be running.'; - } else if (error.response.data.error) { - return error.response.data.error.message; + } 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; } diff --git a/helpers/publishHelpers.js b/helpers/publishHelpers.js index 2db37d46..7eecc00d 100644 --- a/helpers/publishHelpers.js +++ b/helpers/publishHelpers.js @@ -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) { diff --git a/package.json b/package.json index e1aa4a85..820b2af6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/assets/js/validationFunctions.js b/public/assets/js/validationFunctions.js index 7d4f2e00..34ce67d6 100644 --- a/public/assets/js/validationFunctions.js +++ b/public/assets/js/validationFunctions.js @@ -2,46 +2,56 @@ // validation function which checks the proposed file's type, size, and name function validateFile(file) { - if (!file) { - throw new Error('no file provided'); - } - if (/'/.test(file.name)) { - throw new Error('apostrophes are not allowed in the file name'); - } - // validate size and type - switch (file.type) { - case 'image/jpeg': - case 'image/jpg': - case 'image/png': - case 'image/gif': - if (file.size > 50000000){ - throw new Error('Sorry, images are limited to 50 megabytes.'); - } - break; - case 'video/mp4': - if (file.size > 50000000){ - throw new Error('Sorry, videos are limited to 50 megabytes.'); - } - break; - default: - throw new Error(file.type + ' is not a supported file type. Only, .jpeg, .png, .gif, and .mp4 files are currently supported.') - } + if (!file) { + console.log('no file found'); + throw new Error('no file provided'); + } + if (/'/.test(file.name)) { + console.log('file name had apostrophe in it'); + throw new Error('apostrophes are not allowed in the file name'); + } + // validate size and type + switch (file.type) { + case 'image/jpeg': + case 'image/jpg': + case 'image/png': + if (file.size > 10000000){ + console.log('file was too big'); + throw new Error('Sorry, images are limited to 10 megabytes.'); + } + break; + case 'image/gif': + if (file.size > 50000000){ + console.log('file was too big'); + throw new Error('Sorry, .gifs are limited to 50 megabytes.'); + } + break; + case 'video/mp4': + if (file.size > 50000000){ + console.log('file was too big'); + throw new Error('Sorry, videos are limited to 50 megabytes.'); + } + break; + default: + console.log('file type is not supported'); + throw new Error(file.type + ' is not a supported file type. Only, .jpeg, .png, .gif, and .mp4 files are currently supported.') + } } // validation function that checks to make sure the claim name is valid function validateClaimName (name) { - // ensure a name was entered - if (name.length < 1) { - throw new NameError("You must enter a name for your url"); - } - // validate the characters in the 'name' field - const invalidCharacters = /[^A-Za-z0-9,-]/g.exec(name); - if (invalidCharacters) { - throw new NameError('"' + invalidCharacters + '" characters are not allowed in the url.'); - } + // ensure a name was entered + if (name.length < 1) { + throw new NameError("You must enter a name for your url"); + } + // validate the characters in the 'name' field + const invalidCharacters = /[^A-Za-z0-9,-]/g.exec(name); + if (invalidCharacters) { + throw new NameError('"' + invalidCharacters + '" characters are not allowed in the url.'); + } } function validateChannelName (name) { - name = name.substring(name.indexOf('@') + 1); + name = name.substring(name.indexOf('@') + 1); // ensure a name was entered if (name.length < 1) { throw new ChannelNameError("You must enter a name for your channel"); @@ -60,9 +70,9 @@ function validatePassword (password) { } function cleanseClaimName(name) { - name = name.replace(/\s+/g, '-'); // replace spaces with dashes - name = name.replace(/[^A-Za-z0-9-]/g, ''); // remove all characters that are not A-Z, a-z, 0-9, or '-' - return name; + name = name.replace(/\s+/g, '-'); // replace spaces with dashes + name = name.replace(/[^A-Za-z0-9-]/g, ''); // remove all characters that are not A-Z, a-z, 0-9, or '-' + return name; } // validation functions to check claim & channel name eligibility as the inputs change @@ -99,14 +109,14 @@ function checkAvailability(name, successDisplayElement, errorDisplayElement, val // check to make sure it is available isNameAvailable(name, apiUrl) .then(result => { - console.log('result:', result) - if (result === true) { + console.log('result:', result) + if (result === true) { hideError(errorDisplayElement); showSuccess(successDisplayElement) - } else { + } else { hideSuccess(successDisplayElement); showError(errorDisplayElement, errorMessage); - } + } }) .catch(error => { hideSuccess(successDisplayElement); @@ -119,58 +129,69 @@ function checkAvailability(name, successDisplayElement, errorDisplayElement, val } function checkClaimName(name){ - const successDisplayElement = document.getElementById('input-success-claim-name'); - const errorDisplayElement = document.getElementById('input-error-claim-name'); - checkAvailability(name, successDisplayElement, errorDisplayElement, validateClaimName, isNameAvailable, 'Sorry, that url ending has been taken by another user', '/api/isClaimAvailable/'); + const successDisplayElement = document.getElementById('input-success-claim-name'); + const errorDisplayElement = document.getElementById('input-error-claim-name'); + checkAvailability(name, successDisplayElement, errorDisplayElement, validateClaimName, isNameAvailable, 'Sorry, that url ending has been taken', '/api/isClaimAvailable/'); } function checkChannelName(name){ const successDisplayElement = document.getElementById('input-success-channel-name'); const errorDisplayElement = document.getElementById('input-error-channel-name'); name = `@${name}`; - checkAvailability(name, successDisplayElement, errorDisplayElement, validateChannelName, isNameAvailable, 'Sorry, that Channel has been taken by another user', '/api/isChannelAvailable/'); + checkAvailability(name, successDisplayElement, errorDisplayElement, validateChannelName, isNameAvailable, 'Sorry, that Channel name has been taken by another user', '/api/isChannelAvailable/'); } // validation function which checks all aspects of the publish submission function validateFilePublishSubmission(stagedFiles, claimName, channelName){ - return new Promise(function (resolve, reject) { - // 1. make sure only 1 file was selected - if (!stagedFiles) { - return reject(new FileError("Please select a file")); - } else if (stagedFiles.length > 1) { - return reject(new FileError("Only one file is allowed at a time")); - } - // 2. validate the file's name, type, and size - try { - validateFile(stagedFiles[0]); - } catch (error) { - return reject(error); - } - // 3. validate that a channel was chosen - if (channelName === 'new' || channelName === 'login') { - return reject(new ChannelNameError("Please select a valid channel")); + console.log(`validating publish submission > name: ${claimName} channel: ${channelName} file:`, stagedFiles); + return new Promise(function (resolve, reject) { + // 1. make sure 1 file was staged + if (!stagedFiles) { + reject(new FileError("Please select a file")); + return; + } else if (stagedFiles.length > 1) { + reject(new FileError("Only one file is allowed at a time")); + return; + } + // 2. validate the file's name, type, and size + try { + validateFile(stagedFiles[0]); + } catch (error) { + reject(error); + return; + } + // 3. validate that a channel was chosen + if (channelName === 'new' || channelName === 'login') { + reject(new ChannelNameError("Please log in to a channel")); + return; }; - // 4. validate the claim name - try { - validateClaimName(claimName); - } catch (error) { - return reject(error); - } - // if all validation passes, check availability of the name - isNameAvailable(claimName, '/api/isClaimAvailable/') - .then(() => { - resolve(); - }) - .catch(error => { - reject(error); - }); - }); + // 4. validate the claim name + try { + validateClaimName(claimName); + } catch (error) { + reject(error); + return; + } + // if all validation passes, check availability of the name (note: do we need to re-validate channel name vs. credentials as well?) + return isNameAvailable(claimName, '/api/isClaimAvailable/') + .then(result => { + if (result) { + resolve(); + } else { + reject(new NameError('that url ending is already taken')); + } + }) + .catch(error => { + reject(error); + }); + }); } -// validation function which checks all aspects of the publish submission -function validateNewChannelSubmission(channelName, password){ +// validation function which checks all aspects of a new channel submission +function validateNewChannelSubmission(userName, password){ + const channelName = `@${userName}`; return new Promise(function (resolve, reject) { - // 1. validate name + // 1. validate name try { validateChannelName(channelName); } catch (error) { @@ -184,12 +205,17 @@ function validateNewChannelSubmission(channelName, password){ } // 3. if all validation passes, check availability of the name isNameAvailable(channelName, '/api/isChannelAvailable/') // validate the availability - .then(() => { - console.log('channel is avaliable'); - resolve(); + .then(result => { + if (result) { + console.log('channel is available'); + resolve(); + } else { + console.log('channel is not available'); + reject(new ChannelNameError('that channel name has already been taken')); + } }) .catch( error => { - console.log('error: channel is not avaliable'); + console.log('error evaluating channel name availability', error); reject(error); }); }); diff --git a/routes/api-routes.js b/routes/api-routes.js index e88d4668..cf8c0af3 100644 --- a/routes/api-routes.js +++ b/routes/api-routes.js @@ -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); }) - // create publish parameters object .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 diff --git a/routes/sockets-routes.js b/routes/sockets-routes.js index c7dadd54..1cf5dae9 100644 --- a/routes/sockets-routes.js +++ b/routes/sockets-routes.js @@ -1,6 +1,6 @@ const logger = require('winston'); -const publishController = require('../controllers/publishController.js'); -const publishHelpers = require('../helpers/publishHelpers.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'); @@ -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.cannel; + 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 => { postToStats('PUBLISH', '/', null, null, null, 'success'); socket.emit('publish-complete', { name: publishParams.name, result });