diff --git a/.gitignore b/.gitignore index 34977ee7..761ac590 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules -.idea \ No newline at end of file +.idea +config/config.json \ No newline at end of file diff --git a/auth/authentication.js b/auth/authentication.js new file mode 100644 index 00000000..6c6cf441 --- /dev/null +++ b/auth/authentication.js @@ -0,0 +1,33 @@ +const db = require('../models'); +const logger = require('winston'); + +module.exports = { + authenticateApiPublish (username, password) { + return new Promise((resolve, reject) => { + if (username === 'none') { + resolve(true); + return; + } + db.User + .findOne({where: {userName: username}}) + .then(user => { + if (!user) { + logger.debug('no user found'); + resolve(false); + return; + } + if (!user.validPassword(password, user.password)) { + logger.debug('incorrect password'); + resolve(false); + return; + } + logger.debug('user found:', user.dataValues); + resolve(true); + }) + .catch(error => { + logger.error(error); + reject(); + }); + }); + }, +}; diff --git a/config/default.json b/config/default.json index cdab2d23..e094bc44 100644 --- a/config/default.json +++ b/config/default.json @@ -1,16 +1,20 @@ { "WalletConfig": { - "LbryClaimAddress": "none" + "LbryClaimAddress": null, + "DefaultChannel": null }, "AnalyticsConfig":{ - "GoogleId": "none" + "GoogleId": null }, "Database": { "Database": "lbry", - "Username": "none", - "Password": "none" + "Username": null, + "Password": null }, "Logging": { - "LogLevel": "none" + "LogLevel": null, + "SlackWebHook": null, + "SlackErrorChannel": null, + "SlackInfoChannel": null } } \ No newline at end of file diff --git a/config/development.json b/config/development.json index 839e5f35..4beca9ff 100644 --- a/config/development.json +++ b/config/development.json @@ -1,13 +1,10 @@ { "WalletConfig": { - "LbryClaimAddress": "none" + "DefaultChannel": "@speechDev" }, "AnalyticsConfig":{ "GoogleId": "UA-100747990-1" }, - "Database": { - "MySqlConnectionUri": "none" - }, "Logging": { "LogLevel": "silly", "SlackErrorChannel": "#staging_speech-errors", diff --git a/config/loggerConfig.js b/config/loggerConfig.js index 4efbbb02..ffd4c970 100644 --- a/config/loggerConfig.js +++ b/config/loggerConfig.js @@ -12,10 +12,6 @@ module.exports = (winston, logLevel) => { ], }); - // winston.on('error', (err) => { - // console.log('unhandled exception in winston >> ', err); - // }); - winston.error('Level 0'); winston.warn('Level 1'); winston.info('Level 2'); diff --git a/config/production.json b/config/production.json index caa0b328..a5bc5074 100644 --- a/config/production.json +++ b/config/production.json @@ -1,13 +1,10 @@ { "WalletConfig": { - "LbryClaimAddress": "none" + "DefaultChannel": "@speech" }, "AnalyticsConfig":{ "GoogleId": "UA-60403362-3" }, - "Database": { - "MySqlConnectionUri": "none" - }, "Logging": { "LogLevel": "verbose", "SlackErrorChannel": "#speech-errors", diff --git a/config/slackLoggerConfig.js b/config/slackLoggerConfig.js index 5f49e001..4bd88200 100644 --- a/config/slackLoggerConfig.js +++ b/config/slackLoggerConfig.js @@ -5,23 +5,28 @@ const SLACK_INFO_CHANNEL = config.get('Logging.SlackInfoChannel'); const winstonSlackWebHook = require('winston-slack-webhook').SlackWebHook; module.exports = (winston) => { - // add a transport for errors - winston.add(winstonSlackWebHook, { - name : 'slack-errors-transport', - level : 'error', - webhookUrl: SLACK_WEB_HOOK, - channel : SLACK_ERROR_CHANNEL, - username : 'spee.ch', - iconEmoji : ':face_with_head_bandage:', - }); - winston.add(winstonSlackWebHook, { - name : 'slack-info-transport', - level : 'info', - webhookUrl: SLACK_WEB_HOOK, - channel : SLACK_INFO_CHANNEL, - username : 'spee.ch', - iconEmoji : ':nerd_face:', - }); - // send test message - winston.error('Testing slack logging... slack logging is online.'); + if (SLACK_WEB_HOOK) { + // add a transport for errors to slack + winston.add(winstonSlackWebHook, { + name : 'slack-errors-transport', + level : 'error', + webhookUrl: SLACK_WEB_HOOK, + channel : SLACK_ERROR_CHANNEL, + username : 'spee.ch', + iconEmoji : ':face_with_head_bandage:', + }); + winston.add(winstonSlackWebHook, { + name : 'slack-info-transport', + level : 'info', + webhookUrl: SLACK_WEB_HOOK, + channel : SLACK_INFO_CHANNEL, + username : 'spee.ch', + iconEmoji : ':nerd_face:', + }); + // send test message + winston.error('Slack error logging is online.'); + winston.info('Slack info logging is online.'); + } else { + winston.error('Slack logging is not enabled because no SLACK_WEB_HOOK env var provided.'); + } }; diff --git a/controllers/publishController.js b/controllers/publishController.js index fd54d347..2ef07bdf 100644 --- a/controllers/publishController.js +++ b/controllers/publishController.js @@ -8,22 +8,24 @@ module.exports = { return new Promise((resolve, reject) => { let publishResults = {}; // 1. make sure the name is available - publishHelpers.checkNameAvailability(publishParams.name) + publishHelpers.checkClaimNameAvailability(publishParams.name) // 2. publish the file .then(result => { if (result === true) { return lbryApi.publishClaim(publishParams); } else { - return new Error('That name has already been claimed by spee.ch. Please choose a new claim name.'); + 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(result => { - let fileRecord; - let upsertCriteria; - publishResults = result; - logger.info(`Successfully published ${fileName}`, publishResults); - fileRecord = { + .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') }; + const fileRecord = { name : publishParams.name, claimId : publishResults.claim_id, title : publishParams.metadata.title, @@ -36,14 +38,32 @@ module.exports = { fileType, nsfw : publishParams.metadata.nsfw, }; - upsertCriteria = { + 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, + }; + const upsertCriteria = { name : publishParams.name, claimId: publishResults.claim_id, }; - return Promise.all([db.upsert(db.File, fileRecord, upsertCriteria, 'File'), db.upsert(db.Claim, fileRecord, upsertCriteria, 'Claim')]); + // create the records + return Promise.all([db.upsert(db.File, fileRecord, upsertCriteria, 'File'), db.upsert(db.Claim, claimRecord, upsertCriteria, 'Claim')]); + }) + .then(([file, claim]) => { + logger.debug('File and Claim records successfully created'); + return Promise.all([file.setClaim(claim), claim.setFile(file)]); }) .then(() => { - logger.debug('File and Claim records successfully created'); + logger.debug('File and Claim records successfully associated'); resolve(publishResults); // resolve the promise with the result from lbryApi.publishClaim; }) .catch(error => { diff --git a/controllers/serveController.js b/controllers/serveController.js index c9fb5e81..be7de126 100644 --- a/controllers/serveController.js +++ b/controllers/serveController.js @@ -97,7 +97,7 @@ function getAssetByLongClaimId (fullClaimId, name) { } function chooseThumbnail (claimInfo, defaultThumbnail) { - if (!claimInfo.thumbnail || claimInfo.thumbnail === '') { + if (!claimInfo.thumbnail || claimInfo.thumbnail.trim() === '') { return defaultThumbnail; } return claimInfo.thumbnail; @@ -150,7 +150,7 @@ module.exports = { // 2. get all claims for that channel .then(result => { longChannelId = result; - return db.getShortChannelIdFromLongChannelId(channelName, longChannelId); + return db.getShortChannelIdFromLongChannelId(longChannelId, channelName); }) // 3. get all Claim records for this channel .then(result => { @@ -168,7 +168,12 @@ module.exports = { element['thumbnail'] = chooseThumbnail(element, DEFAULT_THUMBNAIL); }); } - return resolve(allChannelClaims); + return resolve({ + channelName, + longChannelId, + shortChannelId, + claims: allChannelClaims, + }); }) .catch(error => { reject(error); diff --git a/helpers/configVarCheck.js b/helpers/configVarCheck.js new file mode 100644 index 00000000..b4b40e51 --- /dev/null +++ b/helpers/configVarCheck.js @@ -0,0 +1,21 @@ +const config = require('config'); +const logger = require('winston'); +const fs = require('fs'); + +module.exports = function () { + // get the config file + const defaultConfigFile = JSON.parse(fs.readFileSync('./config/default.json')); + + for (let configCategoryKey in defaultConfigFile) { + if (defaultConfigFile.hasOwnProperty(configCategoryKey)) { + // get the final variables for each config category + const configVariables = config.get(configCategoryKey); + for (let configVarKey in configVariables) { + if (configVariables.hasOwnProperty(configVarKey)) { + // print each variable + logger.debug(`CONFIG CHECK: ${configCategoryKey}.${configVarKey} === ${configVariables[configVarKey]}`); + } + } + } + } +}; diff --git a/helpers/handlebarsHelpers.js b/helpers/handlebarsHelpers.js new file mode 100644 index 00000000..771cdfae --- /dev/null +++ b/helpers/handlebarsHelpers.js @@ -0,0 +1,96 @@ +const Handlebars = require('handlebars'); +const config = require('config'); + +module.exports = { + // define any extra helpers you may need + googleAnalytics () { + const googleApiKey = config.get('AnalyticsConfig.GoogleId'); + return new Handlebars.SafeString( + `` + ); + }, + addOpenGraph (title, mimeType, showUrl, source, description, thumbnail) { + let basicTags = ` + + + `; + if (mimeType === 'video/mp4') { + return new Handlebars.SafeString( + `${basicTags} + + + + + + + ` + ); + } else if (mimeType === 'image/gif') { + return new Handlebars.SafeString( + `${basicTags} + + + + ` + ); + } else { + return new Handlebars.SafeString( + `${basicTags} + + + + ` + ); + } + }, + addTwitterCard (mimeType, source, embedUrl, directFileUrl) { + let basicTwitterTags = ``; + if (mimeType === 'video/mp4') { + return new Handlebars.SafeString( + `${basicTwitterTags} + + + + + + ` + ); + } else { + return new Handlebars.SafeString( + `${basicTwitterTags} ` + ); + } + }, + ifConditional (varOne, operator, varTwo, options) { + switch (operator) { + case '===': + return (varOne === varTwo) ? options.fn(this) : options.inverse(this); + case '!==': + return (varOne !== varTwo) ? options.fn(this) : options.inverse(this); + case '<': + return (varOne < varTwo) ? options.fn(this) : options.inverse(this); + case '<=': + return (varOne <= varTwo) ? options.fn(this) : options.inverse(this); + case '>': + return (varOne > varTwo) ? options.fn(this) : options.inverse(this); + case '>=': + return (varOne >= varTwo) ? options.fn(this) : options.inverse(this); + case '&&': + return (varOne && varTwo) ? options.fn(this) : options.inverse(this); + case '||': + return (varOne || varTwo) ? options.fn(this) : options.inverse(this); + case 'mod3': + return ((parseInt(varOne) % 3) === 0) ? options.fn(this) : options.inverse(this); + default: + return options.inverse(this); + } + }, +}; diff --git a/helpers/lbryApi.js b/helpers/lbryApi.js index 3777c593..4c758690 100644 --- a/helpers/lbryApi.js +++ b/helpers/lbryApi.js @@ -1,23 +1,23 @@ const axios = require('axios'); const logger = require('winston'); +function handleResponse ({ data }, resolve, reject) { + logger.debug('handling lbry api response'); + if (data.result) { + // check for an error + if (data.result.error) { + reject(data.result.error); + return; + }; + logger.debug('data.result', data.result); + resolve(data.result); + return; + } + // fallback in case the just timed out + reject(JSON.stringify(data)); +} + module.exports = { - getWalletList () { - logger.debug('lbryApi >> getting wallet list'); - return new Promise((resolve, reject) => { - axios - .post('http://localhost:5279/lbryapi', { - method: 'wallet_list', - }) - .then(response => { - const result = response.data.result; - resolve(result); - }) - .catch(error => { - reject(error); - }); - }); - }, publishClaim (publishParams) { logger.debug(`lbryApi >> Publishing claim to "${publishParams.name}"`); return new Promise((resolve, reject) => { @@ -27,8 +27,7 @@ module.exports = { params: publishParams, }) .then(response => { - const result = response.data.result; - resolve(result); + handleResponse(response, resolve, reject); }) .catch(error => { reject(error); @@ -43,18 +42,8 @@ module.exports = { method: 'get', params: { uri, timeout: 20 }, }) - .then(({ data }) => { - // check to make sure the daemon didn't just time out - if (!data.result) { - reject(JSON.stringify(data)); - } - if (data.result.error) { - reject(data.result.error); - } - /* - note: put in a check to make sure we do not resolve until the download is actually complete (response.data.completed === true)? - */ - resolve(data.result); + .then(response => { + handleResponse(response, resolve, reject); }) .catch(error => { reject(error); @@ -69,8 +58,8 @@ module.exports = { method: 'claim_list', params: { name: claimName }, }) - .then(({ data }) => { - resolve(data.result); + .then(response => { + handleResponse(response, resolve, reject); }) .catch(error => { reject(error); @@ -94,7 +83,6 @@ module.exports = { } }) .catch(error => { - console.log('error with resolve', error); reject(error); }); }); @@ -110,7 +98,6 @@ module.exports = { if (data.result) { resolve(data.result.download_directory); } else { - // reject(new Error('Successfully connected to lbry daemon, but unable to retrieve the download directory.')); return new Error('Successfully connected to lbry daemon, but unable to retrieve the download directory.'); } }) @@ -120,4 +107,22 @@ module.exports = { }); }); }, + createChannel (name) { + return new Promise((resolve, reject) => { + axios + .post('http://localhost:5279/lbryapi', { + method: 'channel_new', + params: { + channel_name: name, + amount : 0.1, + }, + }) + .then(response => { + handleResponse(response, resolve, reject); + }) + .catch(error => { + reject(error); + }); + }); + }, }; diff --git a/helpers/publishHelpers.js b/helpers/publishHelpers.js index ab0828c4..2db37d46 100644 --- a/helpers/publishHelpers.js +++ b/helpers/publishHelpers.js @@ -2,7 +2,6 @@ const logger = require('winston'); const config = require('config'); const fs = require('fs'); const db = require('../models'); -const { getWalletList } = require('./lbryApi.js'); module.exports = { validateFile (file, name, license, nsfw) { @@ -29,10 +28,10 @@ module.exports = { // validate claim name const invalidCharacters = /[^A-Za-z0-9,-]/.exec(name); if (invalidCharacters) { - throw new Error('The claim name you provided is not allowed. Only the following characters are allowed: A-Z, a-z, 0-9, and "-"'); + throw new Error('The url name you provided is not allowed. Only the following characters are allowed: A-Z, a-z, 0-9, and "-"'); } // validate license - if ((license.indexOf('Public Domain') === -1) && (license.indexOf('Creative Commons') === -1) && (license.indecOf('CC Attribution-NonCommercial 4.0 International') === -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'); } switch (nsfw) { @@ -51,28 +50,27 @@ module.exports = { throw new Error('NSFW value was not accepted. NSFW must be set to either true, false, "on", or "off"'); } }, - createPublishParams (name, filePath, title, description, license, nsfw) { + 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 (nsfw.toLowerCase === 'false') { - nsfw = false; - } else if (nsfw.toLowerCase === 'off') { - 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 if (nsfw === '0') { - nsfw = false; } else { nsfw = true; } // provide defaults for title & description - if (title === '' || title === null) { + if (title === null || title === '') { title = name; } - if (description === '' || title === null) { + if (description === null || description.trim() === '') { description = `${name} published via spee.ch`; } // create the publish params @@ -89,8 +87,14 @@ module.exports = { nsfw, }, claim_address: claimAddress, - // change_address: changeAddress, }; + // add channel if applicable + if (channel !== 'none') { + publishParams['channel_name'] = channel; + } else { + publishParams['channel_name'] = defaultChannel; + } + logger.debug('publishParams:', publishParams); return publishParams; }, @@ -100,27 +104,23 @@ module.exports = { logger.debug(`successfully deleted ${filePath}`); }); }, - checkNameAvailability (name) { + checkClaimNameAvailability (name) { return new Promise((resolve, reject) => { // find any records where the name is used db.File.findAll({ where: { name } }) .then(result => { if (result.length >= 1) { - // filter out any results that were not published from a spee.ch wallet address - getWalletList() - .then((walletList) => { - const filteredResult = result.filter((claim) => { - return walletList.includes(claim.address); - }); - if (filteredResult.length >= 1) { - resolve(false); - } else { - resolve(true); - } - }) - .catch((error) => { - reject(error); + const claimAddress = config.get('WalletConfig.LbryClaimAddress'); + // filter out any results that were not published from spee.ch's wallet address + const filteredResult = result.filter((claim) => { + return (claim.address === claimAddress); }); + // return based on whether any non-spee.ch claims were left + if (filteredResult.length >= 1) { + resolve(false); + } else { + resolve(true); + } } else { resolve(true); } @@ -130,4 +130,19 @@ module.exports = { }); }); }, + checkChannelAvailability (name) { + return new Promise((resolve, reject) => { + // find any records where the name is used + db.Channel.findAll({ where: { channelName: name } }) + .then(result => { + if (result.length >= 1) { + return resolve(false); + } + resolve(true); + }) + .catch(error => { + reject(error); + }); + }); + }, }; diff --git a/migrations/UpdateAssociationColumns.js b/migrations/UpdateAssociationColumns.js new file mode 100644 index 00000000..22c6def9 --- /dev/null +++ b/migrations/UpdateAssociationColumns.js @@ -0,0 +1,79 @@ +module.exports = { + up: (queryInterface, Sequelize) => { + // logic for transforming into the new state + const p1 = queryInterface.removeColumn( + 'Certificate', + 'UserId' + ); + const p2 = queryInterface.addColumn( + 'Certificate', + 'ChannelId', + { + type : Sequelize.INTEGER, + allowNull: true, + } + ); + const p3 = queryInterface.addConstraint( + 'Certificate', + ['ChannelId'], + { + type : 'FOREIGN KEY', + name : 'Certificate_ibfk_1', + references: { + table: 'Channel', + field: 'id', + }, + onUpdate: 'cascade', + onDelete: 'cascade', + } + ); + const p4 = queryInterface.changeColumn( + 'Claim', + 'FileId', + { + type : Sequelize.INTEGER, + allowNull: true, + } + ); + const p5 = queryInterface.addConstraint( + 'Claim', + ['FileId'], + { + type : 'FOREIGN KEY', + name : 'Claim_ibfk_1', + references: { + table: 'File', + field: 'id', + }, + onUpdate: 'cascade', + onDelete: 'cascade', + } + ); + const p6 = queryInterface.removeColumn( + 'File', + 'UserId' + ); + + return Promise.all([p1, p2, p3, p4, p5, p6]); + }, + down: (queryInterface, Sequelize) => { + // logic for reverting the changes + const p1 = queryInterface.addColumn( + 'Certificate', + 'UserId', + { + type : Sequelize.INTEGER, + allowNull: true, + } + ); + const p2 = queryInterface.addColumn( + 'File', + 'UserId', + { + type : Sequelize.INTEGER, + allowNull: true, + } + ); + return Promise.all([p1, p2]); + }, +}; diff --git a/migrations/UpdateUserAndChannel.js b/migrations/UpdateUserAndChannel.js new file mode 100644 index 00000000..32c22097 --- /dev/null +++ b/migrations/UpdateUserAndChannel.js @@ -0,0 +1,46 @@ +module.exports = { + up: (queryInterface, Sequelize) => { + // logic for transforming into the new state + const p1 = queryInterface.addColumn( + 'User', + 'userName', + { + type : Sequelize.STRING, + allowNull: true, + } + ); + const p2 = queryInterface.removeColumn( + 'User', + 'channelName' + ); + const p3 = queryInterface.removeColumn( + 'User', + 'channelClaimId' + ); + return Promise.all([p1, p2, p3]); + }, + down: (queryInterface, Sequelize) => { + // logic for reverting the changes + const p1 = queryInterface.removeColumn( + 'User', + 'userName' + ); + const p2 = queryInterface.addColumn( + 'User', + 'channelName', + { + type : Sequelize.STRING, + allowNull: true, + } + ); + const p3 = queryInterface.addColumn( + 'User', + 'channelClaimId', + { + type : Sequelize.STRING, + allowNull: true, + } + ); + return Promise.all([p1, p2, p3]); + }, +}; diff --git a/models/certificate.js b/models/certificate.js index 702e8783..3cf35e7b 100644 --- a/models/certificate.js +++ b/models/certificate.js @@ -87,5 +87,15 @@ module.exports = (sequelize, { STRING, BOOLEAN, INTEGER, TEXT, ARRAY, DECIMAL, D freezeTableName: true, } ); + + Certificate.associate = db => { + Certificate.belongsTo(db.Channel, { + onDelete : 'cascade', + foreignKey: { + allowNull: true, + }, + }); + }; + return Certificate; }; diff --git a/models/channel.js b/models/channel.js new file mode 100644 index 00000000..9421ee52 --- /dev/null +++ b/models/channel.js @@ -0,0 +1,25 @@ +module.exports = (sequelize, { STRING }) => { + const Channel = sequelize.define( + 'Channel', + { + channelName: { + type : STRING, + allowNull: false, + }, + channelClaimId: { + type : STRING, + allowNull: false, + }, + }, + { + freezeTableName: true, + } + ); + + Channel.associate = db => { + Channel.belongsTo(db.User); + Channel.hasOne(db.Certificate); + }; + + return Channel; +}; diff --git a/models/claim.js b/models/claim.js index b6a72a92..ab35576b 100644 --- a/models/claim.js +++ b/models/claim.js @@ -140,5 +140,14 @@ module.exports = (sequelize, { STRING, BOOLEAN, INTEGER, TEXT, ARRAY, DECIMAL, D } ); + Claim.associate = db => { + Claim.belongsTo(db.File, { + onDelete : 'cascade', + foreignKey: { + allowNull: true, + }, + }); + }; + return Claim; }; diff --git a/models/file.js b/models/file.js index faabf142..c1e86772 100644 --- a/models/file.js +++ b/models/file.js @@ -52,6 +52,7 @@ module.exports = (sequelize, { STRING, BOOLEAN, INTEGER }) => { File.associate = db => { File.hasMany(db.Request); + File.hasOne(db.Claim); }; return File; diff --git a/models/index.js b/models/index.js index e4e870c5..b330fbd4 100644 --- a/models/index.js +++ b/models/index.js @@ -66,7 +66,7 @@ function getLongClaimIdFromShortClaimId (name, shortId) { function getTopFreeClaimIdByClaimName (name) { return new Promise((resolve, reject) => { db - .sequelize.query(`SELECT claimId FROM Claim WHERE name = '${name}' ORDER BY amount DESC, height ASC LIMIT 1`, { type: db.sequelize.QueryTypes.SELECT }) + .sequelize.query(`SELECT claimId FROM Claim WHERE name = '${name}' ORDER BY effectiveAmount DESC, height ASC LIMIT 1`, { type: db.sequelize.QueryTypes.SELECT }) .then(result => { switch (result.length) { case 0: @@ -190,9 +190,9 @@ db['getShortClaimIdFromLongClaimId'] = (claimId, claimName) => { }); }; -db['getShortChannelIdFromLongChannelId'] = (channelName, longChannelId) => { +db['getShortChannelIdFromLongChannelId'] = (longChannelId, channelName) => { return new Promise((resolve, reject) => { - logger.debug('finding short channel id'); + logger.debug(`finding short channel id for ${longChannelId} ${channelName}`); db .sequelize.query(`SELECT claimId, height FROM Certificate WHERE name = '${channelName}' ORDER BY height;`, { type: db.sequelize.QueryTypes.SELECT }) .then(result => { diff --git a/models/user.js b/models/user.js new file mode 100644 index 00000000..bce93d91 --- /dev/null +++ b/models/user.js @@ -0,0 +1,29 @@ +module.exports = (sequelize, { STRING }) => { + const User = sequelize.define( + 'User', + { + userName: { + type : STRING, + allowNull: false, + }, + password: { + type : STRING, + allowNull: false, + }, + }, + { + freezeTableName: true, + } + ); + + User.associate = db => { + User.hasOne(db.Channel); + }; + + User.prototype.validPassword = (givenpassword, thispassword) => { + console.log(`${givenpassword} === ${thispassword}`); + return (givenpassword === thispassword); + }; + + return User; +}; diff --git a/package.json b/package.json index 22f9872b..e1aa4a85 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,14 @@ "connect-multiparty": "^2.0.0", "express": "^4.15.2", "express-handlebars": "^3.0.0", + "express-session": "^1.15.5", + "helmet": "^3.8.1", "mysql2": "^1.3.5", "nodemon": "^1.11.0", + "passport": "^0.4.0", + "passport-local": "^1.0.0", "sequelize": "^4.1.0", + "sequelize-cli": "^3.0.0-3", "sleep": "^5.1.1", "socket.io": "^2.0.1", "socketio-file-upload": "^0.6.0", diff --git a/passport/local-login.js b/passport/local-login.js new file mode 100644 index 00000000..5ccb822c --- /dev/null +++ b/passport/local-login.js @@ -0,0 +1,34 @@ +const PassportLocalStrategy = require('passport-local').Strategy; +const db = require('../models'); +const logger = require('winston'); + +module.exports = new PassportLocalStrategy( + { + usernameField : 'username', // username key in the request body + passwordField : 'password', // password key in the request body + session : false, + passReqToCallback: true, + }, + (req, username, password, done) => { + logger.debug(`verifying loggin attempt ${username} ${password}`); + return db.User + .findOne({where: {userName: username}}) + .then(user => { + if (!user) { + logger.debug('no user found'); + return done(null, false, {message: 'Incorrect username or password.'}); + } + if (!user.validPassword(password, user.password)) { + logger.debug('incorrect password'); + return done(null, false, {message: 'Incorrect username or password.'}); + } + logger.debug('user found:', user.dataValues); + return user.getChannel().then(channel => { + return done(null, user); + }); + }) + .catch(error => { + return done(error); + }); + } +); diff --git a/passport/local-signup.js b/passport/local-signup.js new file mode 100644 index 00000000..23273180 --- /dev/null +++ b/passport/local-signup.js @@ -0,0 +1,60 @@ +const db = require('../models'); +const PassportLocalStrategy = require('passport-local').Strategy; +const lbryApi = require('../helpers/lbryApi.js'); +const logger = require('winston'); + +module.exports = new PassportLocalStrategy( + { + usernameField : 'username', // sets the custom name of parameters in the POST body message + passwordField : 'password', // sets the custom name of parameters in the POST body message + session : false, // set to false because we will use token approach to auth + passReqToCallback: true, // we want to be able to read the post body message parameters in the callback + }, + (req, username, password, done) => { + logger.debug(`new channel signup request: ${username} ${password}`); + let user; + // server-side validaton of inputs (username, password) + + // create the channel and retrieve the metadata + return lbryApi.createChannel(`@${username}`) + .then(tx => { + // create user record + const userData = { + userName: username, + password: password, + }; + logger.debug('userData >', userData); + // create user record + const channelData = { + channelName : `@${username}`, + channelClaimId: tx.claim_id, + }; + logger.debug('channelData >', channelData); + // create certificate record + const certificateData = { + claimId: tx.claim_id, + name : `@${username}`, + // address, + }; + logger.debug('certificateData >', certificateData); + // save user and certificate to db + return Promise.all([db.User.create(userData), db.Channel.create(channelData), db.Certificate.create(certificateData)]); + }) + .then(([newUser, newChannel, newCertificate]) => { + user = newUser; + logger.debug('user and certificate successfully created'); + logger.debug('user result >', newUser.dataValues); + logger.debug('user result >', newChannel.dataValues); + logger.debug('certificate result >', newCertificate.dataValues); + // associate the instances + return Promise.all([newCertificate.setChannel(newChannel), newChannel.setUser(newUser)]); + }).then(() => { + logger.debug('user and certificate successfully associated'); + return done(null, user); + }) + .catch(error => { + logger.error('signup error', error); + return done(error); + }); + } +); diff --git a/public/assets/css/BEM.css b/public/assets/css/BEM.css new file mode 100644 index 00000000..dfa8262e --- /dev/null +++ b/public/assets/css/BEM.css @@ -0,0 +1,276 @@ + +/* GENERAL */ + + +/* TEXT */ + +body, button, input, textarea, label, select, option { + font-family: serif; +} + +p { + padding-left: 0.3em; +} + +.center-text { + text-align: center; +} + +.url-text { + margin:0px; + padding:0px; +} + +/* HEADERS */ + +h1 { + font-size: x-large; +} + +h2 { + font-size: medium; + margin-top: 1em; + border-top: 1px #999 solid; + background-color: lightgray; + padding: 6px; +} + +h3 { + color: black;; +} + +.h3--secondary { + color: lightgray; +} + +h4 { + padding: 3px; +} + +/* CONTAINERS */ + +.wrapper { + margin-left: 20%; + width:60%; +} + +.full { + clear: both; +} + +.main { + float: left; + width: 65%; + +} + +.panel { + overflow: auto; + word-wrap: break-word; +} + +.sidebar { + float: right; + width: 33%; +} + +footer { + display: inline-block; + width: 100%; + margin-bottom: 2px; + padding-bottom: 2px; + border-bottom: 1px lightgrey solid; + margin-top: 2px; + padding-top: 2px; + border-top: 1px lightgrey solid; + text-align: center; + color: grey; +} + +/* COLUMNS AND ROWS */ + +.col-left, .col-right { + overflow: auto; + margin: 0px; + width: 48%; +} + +.col-left { + padding: 5px 10px 5px 0px; + float: left; +} + +.col-right { + padding: 5px 0px 5px 10px; + float: right; +} + +.row { + padding: 1em 2% 1em 2%; + margin: 0px; + +} + +.row--wide { + padding-right: 0px; + padding-left: 0px; +} + +.row--thin { + padding-top: 0.5em; + padding-bottom: 0.5em; +} + +.top-bar { + margin: 2em 0px 2px 0px; + padding: 0px 0px 2px 0px; + border-bottom: 1px lightgrey solid; + overflow: auto; + text-align: right; + vertical-align: text-bottom; +} + + +.column { + display: inline-block; + padding: 0px; + margin: 0px; +} + +.column--1 { + width: 8%; +} + +.column--2 { + width: 16%; +} + +.column--3 { + width: 24%; +} + +.column--4 { + width: 32%; +} + +.column--5 { + width: 40%; +} + +.column--6 { + width: 48%; +} + +.column--7 { + width: 56%; +} + +.column--8 { + width: 64%; +} + +.column--9 { + width: 72%; +} + +.column--10 { + width: 80%; +} + +.column--11 { + width: 88%; +} + +.column--12 { + width: 96%; +} + +/* LINKS */ + +a, a:visited { + color: blue; + text-decoration: none; +} + +/* ERROR MESSAGES */ + +.info-message { + font-weight: bold; +} + +.info-message--success { + color: green; +} + +.info-message--failure { + color: red; +} + +/* INPUT FIELDS */ + +input:-webkit-autofill { + -webkit-box-shadow: 0 0 0px 1000px white inset; +} + +.label, .input-text, .select, .textarea { + font-size: medium; + padding: 0.3em; + outline: none; + border: 0px; + background-color: white; +} + +.input-text--primary, .select--primary, .textarea--primary { + border-bottom: 1px solid blue; +} + +.input-text--primary:focus, .select--primary:focus, .textarea--primary:focus { + border-bottom: 1px solid grey; +} + +.input-checkbox, .input-textarea { + border: 1px solid grey; +} + +/* BUTTONS */ + +button { + border: 1px solid black; + padding: 0.5em; + margin: 0.5em 0.3em 0.5em 0.3em; + color: black; + background-color: white; +} + +button:hover { + border: 1px solid blue; + color: white; + background-color: blue; +} + +button:active{ + border: 1px solid blue; + color: white; + background-color: white; +} + +/* TABLES */ + +table { + width: 100%; + text-align: left; +} + +/* other */ + +.stop-float { + clear: both; +} + +.toggle-link { + float: right; +} + +.wrap-words { + word-wrap: break-word; +} \ No newline at end of file diff --git a/public/assets/css/componentStyle.css b/public/assets/css/componentStyle.css index 27ffd4b2..9c043422 100644 --- a/public/assets/css/componentStyle.css +++ b/public/assets/css/componentStyle.css @@ -38,30 +38,6 @@ margin-bottom: 1em; } -#claim-name-input-area { - border-bottom: 1px blue solid; - float: left; - margin-bottom: 1em; - font-weight: bold; -} - -.publish-input, #publish-license { - border: 1px solid lightgrey; -} - -.publish-input { - padding: 1%; - width: 90% -} - -#claim-name-input { - border: 0px; -} - -#claim-name-input:focus { - outline: none -} - /* show routes */ .show-asset { width: 100%; @@ -116,8 +92,6 @@ button.copy-button { /* learn more */ .learn-more { text-align: center; - margin-top: 2px; - padding-top: 2px; border-top: 1px solid lightgrey; } @@ -208,42 +182,4 @@ button.copy-button { word-wrap: break-word; } -@media (max-width: 750px) { - - .all-claims-asset { - width:30%; - } - .all-claims-details { - font-size: small; - } - - .show-asset-lite { - width: 100%; - } - - .top-bar-tagline { - clear: both; - text-align: left; - width: 100%; - } - -} - -@media (max-width: 475px) { - - div#publish-active-area { - margin-left: 2em; - margin-right: 2em; - } - - .all-claims-asset { - width:50%; - } - - .top-bar-right { - display: none; - } - - -} diff --git a/public/assets/css/generalStyle.css b/public/assets/css/generalStyle.css deleted file mode 100644 index 70cfcf0b..00000000 --- a/public/assets/css/generalStyle.css +++ /dev/null @@ -1,185 +0,0 @@ -body, button, input, textarea, label, select, option { - font-family: serif; -} -/* Containters */ -.wrapper { - margin-left: 20%; - width:60%; -} - -.top-bar { - width: 100%; - margin-bottom: 2px; - padding-bottom: 2px; - border-bottom: 1px lightgrey solid; - margin-top: 2em; - overflow: auto; - text-align: right; - display: inline-block; - vertical-align: text-bottom; -} - -.full { - clear: both; -} - -.main { - float: left; - width: 65%; - -} - -.sidebar { - float: right; - width: 33%; -} - -footer { - display: inline-block; - width: 100%; - margin-bottom: 2px; - padding-bottom: 2px; - border-bottom: 1px lightgrey solid; - margin-top: 2px; - padding-top: 2px; - border-top: 1px lightgrey solid; - text-align: center; - color: grey; -} - -/* panels */ - -.panel { - overflow: auto; - word-wrap: break-word; -} - -.col-left, .col-right { - overflow: auto; - margin: 0px; - width: 48%; -} - -.col-left { - padding: 5px 10px 5px 0px; - float: left; -} - -.col-right { - padding: 5px 0px 5px 10px; - float: right; -} - -/* text */ - -a, a:visited { - color: blue; - text-decoration: none; -} -h1 { - font-size: x-large; -} - -h2 { - font-size: medium; - margin-top: 1em; - border-top: 1px #999 solid; - background-color: lightgray; - padding: 6px; -} - -.subheader { - margin-top: 0px; -} - -h4 { - padding: 3px; -} - -.center-text { - text-align: center; -} - -/* other */ - -input { - padding: 0.3em; -} - -table { - width: 100%; - text-align: left; -} - -.stop-float { - clear: both; -} - -.toggle-link { - float: right; -} - -.wrap-words { - word-wrap: break-word; -} - -.input-error { - font-weight: bold; - color: red; - font-size: small; -} - -@media (max-width: 1250px) { - - .wrapper { - margin-left: 10%; - width:80%; - } - -} - -@media (max-width: 1000px) { - - .wrapper { - margin-left: 10%; - width:80%; - } - - .main { - float: none; - width: 100%; - margin-right: 0px; - padding-right: 0px; - border-right: 0px; - margin-bottom: 5px; - } - - .sidebar { - border-top: 1px solid lightgray; - float: none; - width: 100%; - } - -} - -@media (max-width: 750px ) { - .col-left, .col-right { - float: none; - margin: 0px; - padding: 0px; - width: 100%; - } - - .col-right { - padding-top: 20px; - } -} - -@media (max-width: 475px) { - - .wrapper { - margin: 0px; - width: 100%; - } - -} \ No newline at end of file diff --git a/public/assets/css/mediaQueries.css b/public/assets/css/mediaQueries.css new file mode 100644 index 00000000..6a36df91 --- /dev/null +++ b/public/assets/css/mediaQueries.css @@ -0,0 +1,59 @@ +@media (max-width: 1250px) { + .wrapper { + margin-left: 10%; + width:80%; + } +} + +@media (max-width: 1000px) { + .wrapper { + margin-left: 10%; + width:80%; + } + + .main { + float: none; + width: 100%; + margin-right: 0px; + padding-right: 0px; + border-right: 0px; + margin-bottom: 5px; + } + + .sidebar { + border-top: 1px solid lightgray; + float: none; + width: 100%; + } +} + +@media (max-width: 750px ) { + .col-left, .col-right { + float: none; + margin: 0px; + padding: 0px; + width: 100%; + } + + .col-right { + padding-top: 20px; + } + + .all-claims-asset { + width:30%; + } + + .all-claims-details { + font-size: small; + } + + .show-asset-lite { + width: 100%; + } + + .top-bar-tagline { + clear: both; + text-align: left; + width: 100%; + } +} diff --git a/public/assets/css/reset.css b/public/assets/css/reset.css new file mode 100644 index 00000000..e69de29b diff --git a/public/assets/js/authFunctions.js b/public/assets/js/authFunctions.js new file mode 100644 index 00000000..fe06c1d0 --- /dev/null +++ b/public/assets/js/authFunctions.js @@ -0,0 +1,4 @@ +function sendAuthRequest (channelName, password, url) { + const params = `username=${channelName}&password=${password}`; + return postRequest(url, params); +} \ No newline at end of file diff --git a/public/assets/js/generalFunctions.js b/public/assets/js/generalFunctions.js index 3c3a60d9..d19f35ec 100644 --- a/public/assets/js/generalFunctions.js +++ b/public/assets/js/generalFunctions.js @@ -1,3 +1,46 @@ +function getRequest (url) { + console.log('making GET request to', url) + return new Promise((resolve, reject) => { + let xhttp = new XMLHttpRequest(); + xhttp.open('GET', url, true); + xhttp.responseType = 'json'; + xhttp.onreadystatechange = () => { + if (xhttp.readyState == 4 ) { + console.log(xhttp); + if ( xhttp.status == 200) { + console.log('response:', xhttp.response); + resolve(xhttp.response); + } else { + reject('request failed with status:' + xhttp.status); + }; + } + }; + xhttp.send(); + }) +} + +function postRequest (url, params) { + console.log('making POST request to', url) + return new Promise((resolve, reject) => { + let xhttp = new XMLHttpRequest(); + xhttp.open('POST', url, true); + xhttp.responseType = 'json'; + xhttp.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); + xhttp.onreadystatechange = () => { + if (xhttp.readyState == 4 ) { + console.log(xhttp); + if ( xhttp.status == 200) { + console.log('response:', xhttp.response); + resolve(xhttp.response); + } else { + reject('request failed with status:' + xhttp.status); + }; + } + }; + xhttp.send(params); + }) +} + function toggleSection(event){ event.preventDefault(); @@ -5,14 +48,16 @@ function toggleSection(event){ var status = dataSet.open; var masterElement = document.getElementById(event.target.id||event.srcElement.id); var slaveElement = document.getElementById(dataSet.slaveelementid); + var closedLabel = dataSet.closedlabel; + var openLabel = dataSet.openlabel; if (status === "false") { slaveElement.hidden = false; - masterElement.innerText = "[close]"; + masterElement.innerText = openLabel; masterElement.dataset.open = "true"; } else { slaveElement.hidden = true; - masterElement.innerText = "[open]"; + masterElement.innerText = closedLabel; masterElement.dataset.open = "false"; } } @@ -35,38 +80,6 @@ function createProgressBar(element, size){ setInterval(addOne, 300); } -function dataURItoBlob(dataURI) { - // convert base64/URLEncoded data component to raw binary data held in a string - var byteString; - if (dataURI.split(',')[0].indexOf('base64') >= 0) - byteString = atob(dataURI.split(',')[1]); - else - byteString = unescape(dataURI.split(',')[1]); - - // separate out the mime component - var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]; - - // write the bytes of the string to a typed array - var ia = new Uint8Array(byteString.length); - for (var i = 0; i < byteString.length; i++) { - ia[i] = byteString.charCodeAt(i); - } - - return new Blob([ia], {type:mimeString}); -} - -function showError(elementId, errorMsg) { - var errorDisplay = document.getElementById(elementId); - errorDisplay.hidden = false; - errorDisplay.innerText = errorMsg; -} - -function clearError(elementId) { - var errorDisplay = document.getElementById(elementId); - errorDisplay.hidden = true; - errorDisplay.innerText = ''; -} - // Create new error objects, that prototypically inherit from the Error constructor function FileError(message) { this.name = 'FileError'; @@ -82,4 +95,20 @@ function NameError(message) { this.stack = (new Error()).stack; } NameError.prototype = Object.create(Error.prototype); -NameError.prototype.constructor = NameError; \ No newline at end of file +NameError.prototype.constructor = NameError; + +function ChannelNameError(message) { + this.name = 'ChannelNameError'; + this.message = message || 'Default Message'; + this.stack = (new Error()).stack; +} +ChannelNameError.prototype = Object.create(Error.prototype); +ChannelNameError.prototype.constructor = ChannelNameError; + +function ChannelPasswordError(message) { + this.name = 'ChannelPasswordError'; + this.message = message || 'Default Message'; + this.stack = (new Error()).stack; +} +ChannelPasswordError.prototype = Object.create(Error.prototype); +ChannelPasswordError.prototype.constructor = ChannelPasswordError; \ No newline at end of file diff --git a/public/assets/js/publishFunctions.js b/public/assets/js/publishFileFunctions.js similarity index 52% rename from public/assets/js/publishFunctions.js rename to public/assets/js/publishFileFunctions.js index 552ecd0d..c535d1a6 100644 --- a/public/assets/js/publishFunctions.js +++ b/public/assets/js/publishFileFunctions.js @@ -1,9 +1,38 @@ -// update the publish status -function updatePublishStatus(msg){ - document.getElementById('publish-status').innerHTML = msg; +/* drop zone functions */ + +function drop_handler(ev) { + ev.preventDefault(); + // if dropped items aren't files, reject them + var dt = ev.dataTransfer; + if (dt.items) { + if (dt.items[0].kind == 'file') { + var droppedFile = dt.items[0].getAsFile(); + previewAndStageFile(droppedFile); + } + } } -/* publish helper functions */ +function dragover_handler(ev) { + ev.preventDefault(); +} + +function dragend_handler(ev) { + var dt = ev.dataTransfer; + if (dt.items) { + for (var i = 0; i < dt.items.length; i++) { + dt.items.remove(i); + } + } else { + ev.dataTransfer.clearData(); + } +} + +/* publish functions */ + +// update the publish status +function updatePublishStatus(msg){ + document.getElementById('publish-status').innerHTML = msg; +} // When a file is selected for publish, validate that file and // stage it so it will be ready when the publish button is clicked. @@ -19,7 +48,7 @@ function previewAndStageFile(selectedFile){ showError('input-error-file-selection', error.message); return; } - // set the image preview, if a preview was provided + // set the image preview, if an image was provided if (selectedFile.type !== 'video/mp4') { previewReader.readAsDataURL(selectedFile); previewReader.onloadend = function () { @@ -40,49 +69,26 @@ function previewAndStageFile(selectedFile){ // Validate the publish submission and then trigger publishing. function publishSelectedImage(event) { - event.preventDefault(); - var name = document.getElementById('claim-name-input').value; - validateSubmission(stagedFiles, name) - .then(function() { + var claimName = document.getElementById('claim-name-input').value; + var channelName = document.getElementById('channel-name-select').value; + // prevent default so this script can handle submission + event.preventDefault(); + // validate, submit, and handle response + validateFilePublishSubmission(stagedFiles, claimName, channelName) + .then(() => { uploader.submitFiles(stagedFiles); }) - .catch(function(error) { - if (error.name === 'FileError'){ - showError('input-error-file-selection', error.message); + .catch(error => { + if (error.name === 'FileError') { + showError(document.getElementById('input-error-file-selection'), error.message); } else if (error.name === 'NameError') { - showError('input-error-claim-name', error.message); + showError(document.getElementById('input-error-claim-name'), error.message); + } else if (error.name === 'ChannelNameError'){ + console.log(error); + showError(document.getElementById('input-error-channel-select'), error.message); } else { - showError('input-error-publish-submit', error.message); + showError(document.getElementById('input-error-publish-submit'), error.message); } return; }) -}; - -/* drop zone functions */ - -function drop_handler(ev) { - ev.preventDefault(); - // if dropped items aren't files, reject them - var dt = ev.dataTransfer; - if (dt.items) { - if (dt.items[0].kind == 'file') { - var droppedFile = dt.items[0].getAsFile(); - previewAndStageFile(droppedFile); - } - } -} - -function dragover_handler(ev) { - ev.preventDefault(); -} - -function dragend_handler(ev) { - var dt = ev.dataTransfer; - if (dt.items) { - for (var i = 0; i < dt.items.length; i++) { - dt.items.remove(i); - } - } else { - ev.dataTransfer.clearData(); - } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/public/assets/js/validationFunctions.js b/public/assets/js/validationFunctions.js index cd9ecb47..7d4f2e00 100644 --- a/public/assets/js/validationFunctions.js +++ b/public/assets/js/validationFunctions.js @@ -27,95 +27,170 @@ function validateFile(file) { 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 not already claimed -function isNameAvailable (name) { - return new Promise(function(resolve, reject) { - // make sure the claim name is still available - var xhttp; - xhttp = new XMLHttpRequest(); - xhttp.open('GET', '/api/isClaimAvailable/' + name, true); - xhttp.responseType = 'json'; - xhttp.onreadystatechange = function() { - if (this.readyState == 4 ) { - if ( this.status == 200) { - if (this.response == true) { - resolve(); - } else { - reject( new NameError("That name has already been claimed by another user. Please choose a different name.")); - } - } else { - reject("request to check claim name failed with status:" + this.status); - }; - } - }; - xhttp.send(); - }); -} // 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 claim"); + 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 title.'); + throw new NameError('"' + invalidCharacters + '" characters are not allowed in the url.'); } } +function validateChannelName (name) { + 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"); + } + // validate the characters in the 'name' field + const invalidCharacters = /[^A-Za-z0-9,-,@]/g.exec(name); + if (invalidCharacters) { + throw new ChannelNameError('"' + invalidCharacters + '" characters are not allowed in the channel name.'); + } +} + +function validatePassword (password) { + if (password.length < 1) { + throw new ChannelPasswordError("You must enter a password for you channel"); + } +} + 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; } -// validaiton function to check claim name as the input changes -function checkClaimName(name){ - try { - // check to make sure the characters are valid - validateClaimName(name); - clearError('input-error-claim-name'); - // check to make sure it is availabe - isNameAvailable(name) - .then(function() { - document.getElementById('claim-name-available').hidden = false; - }) - .catch(function(error) { - document.getElementById('claim-name-available').hidden = true; - showError('input-error-claim-name', error.message); - }); - } catch (error) { - showError('input-error-claim-name', error.message); - document.getElementById('claim-name-available').hidden = true; - } + +// validation functions to check claim & channel name eligibility as the inputs change + +function isNameAvailable (name, apiUrl) { + const url = apiUrl + name; + return getRequest(url) } + +function showError(errorDisplay, errorMsg) { + errorDisplay.hidden = false; + errorDisplay.innerText = errorMsg; +} + +function hideError(errorDisplay) { + errorDisplay.hidden = true; + errorDisplay.innerText = ''; +} + +function showSuccess (successElement) { + successElement.hidden = false; + successElement.innerHTML = "✔"; +} + +function hideSuccess (successElement) { + successElement.hidden = true; + successElement.innerHTML = ""; +} + +function checkAvailability(name, successDisplayElement, errorDisplayElement, validateName, isNameAvailable, errorMessage, apiUrl) { + try { + // check to make sure the characters are valid + validateName(name); + // check to make sure it is available + isNameAvailable(name, apiUrl) + .then(result => { + console.log('result:', result) + if (result === true) { + hideError(errorDisplayElement); + showSuccess(successDisplayElement) + } else { + hideSuccess(successDisplayElement); + showError(errorDisplayElement, errorMessage); + } + }) + .catch(error => { + hideSuccess(successDisplayElement); + showError(errorDisplayElement, error.message); + }); + } catch (error) { + hideSuccess(successDisplayElement); + showError(errorDisplayElement, error.message); + } +} + +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/'); +} + +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/'); +} + // validation function which checks all aspects of the publish submission -function validateSubmission(stagedFiles, name){ +function validateFilePublishSubmission(stagedFiles, claimName, channelName){ return new Promise(function (resolve, reject) { - // make sure only 1 file was selected + // 1. make sure only 1 file was selected if (!stagedFiles) { - reject(new FileError("Please select a file")); + return reject(new FileError("Please select a file")); } else if (stagedFiles.length > 1) { - reject(new FileError("Only one file is allowed at a time")); + return reject(new FileError("Only one file is allowed at a time")); } - // validate the file's name, type, and size + // 2. validate the file's name, type, and size try { validateFile(stagedFiles[0]); } catch (error) { - reject(error); + return reject(error); } - // make sure the claim name has not already been used + // 3. validate that a channel was chosen + if (channelName === 'new' || channelName === 'login') { + return reject(new ChannelNameError("Please select a valid channel")); + }; + // 4. validate the claim name try { - validateClaimName(name); + validateClaimName(claimName); } catch (error) { - reject(error); + return reject(error); } - isNameAvailable(name) - .then(function() { + // if all validation passes, check availability of the name + isNameAvailable(claimName, '/api/isClaimAvailable/') + .then(() => { resolve(); }) - .catch(function(error) { + .catch(error => { reject(error); }); }); +} + +// validation function which checks all aspects of the publish submission +function validateNewChannelSubmission(channelName, password){ + return new Promise(function (resolve, reject) { + // 1. validate name + try { + validateChannelName(channelName); + } catch (error) { + return reject(error); + } + // 2. validate password + try { + validatePassword(password); + } catch (error) { + return reject(error); + } + // 3. if all validation passes, check availability of the name + isNameAvailable(channelName, '/api/isChannelAvailable/') // validate the availability + .then(() => { + console.log('channel is avaliable'); + resolve(); + }) + .catch( error => { + console.log('error: channel is not avaliable'); + reject(error); + }); + }); } \ No newline at end of file diff --git a/routes/api-routes.js b/routes/api-routes.js index ebd385a7..e88d4668 100644 --- a/routes/api-routes.js +++ b/routes/api-routes.js @@ -1,13 +1,15 @@ const logger = require('winston'); const multipart = require('connect-multiparty'); const multipartMiddleware = multipart(); +const db = require('../models'); const { publish } = require('../controllers/publishController.js'); const { getClaimList, resolveUri } = require('../helpers/lbryApi.js'); -const { createPublishParams, validateFile, checkNameAvailability } = require('../helpers/publishHelpers.js'); +const { createPublishParams, validateFile, 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'); -module.exports = (app, hostedContentPath) => { +module.exports = (app) => { // route to run a claim_list request on the daemon app.get('/api/claim_list/:name', ({ headers, ip, originalUrl, params }, res) => { // google analytics @@ -25,12 +27,12 @@ module.exports = (app, hostedContentPath) => { // route to check whether spee.ch has published to a claim app.get('/api/isClaimAvailable/:name', ({ ip, originalUrl, params }, res) => { // send response - checkNameAvailability(params.name) + checkClaimNameAvailability(params.name) .then(result => { if (result === true) { res.status(200).json(true); } else { - logger.debug(`Rejecting publish request because ${params.name} has already been published via spee.ch`); + logger.debug(`Rejecting '${params.name}' because that name has already been claimed on spee.ch`); res.status(200).json(false); } }) @@ -38,6 +40,22 @@ module.exports = (app, hostedContentPath) => { res.status(500).json(error); }); }); + // route to check whether spee.ch has published to a channel + app.get('/api/isChannelAvailable/:name', ({ params }, res) => { + checkChannelAvailability(params.name) + .then(result => { + if (result === true) { + res.status(200).json(true); + } else { + logger.debug(`Rejecting '${params.name}' because that channel has already been claimed on spee.ch`); + res.status(200).json(false); + } + }) + .catch(error => { + logger.debug('api/isChannelAvailable/ error', error); + res.status(500).json(error); + }); + }); // route to run a resolve request on the daemon app.get('/api/resolve/:uri', ({ headers, ip, originalUrl, params }, res) => { // google analytics @@ -52,7 +70,6 @@ module.exports = (app, hostedContentPath) => { errorHandlers.handleRequestError('publish', originalUrl, ip, error, res); }); }); - // route to run a publish request on the daemon app.post('/api/publish', multipartMiddleware, ({ body, files, headers, ip, originalUrl }, res) => { // google analytics @@ -60,8 +77,13 @@ module.exports = (app, hostedContentPath) => { // 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 || true; + const nsfw = body.nsfw || null; + const channelName = body.channelName || 'none'; + const channelPassword = body.channelPassword || null; + logger.debug(`name: ${name}, license: ${license}, nsfw: ${nsfw}`); try { validateFile(file, name, license, nsfw); } catch (error) { @@ -70,19 +92,54 @@ module.exports = (app, hostedContentPath) => { res.status(400).send(error.message); return; } - // prepare the publish parameters const fileName = file.name; const filePath = file.path; const fileType = file.type; - const publishParams = createPublishParams(name, filePath, license, nsfw); - // publish the file - publish(publishParams, fileName, fileType) + // channel authorization + authenticateApiPublish(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'); + } + return createPublishParams(name, filePath, title, description, license, nsfw, channelName); + }) + // create publish parameters object + .then(publishParams => { + return publish(publishParams, fileName, fileType); + }) + // publish the asset .then(result => { postToStats('publish', originalUrl, ip, null, null, 'success'); res.status(200).json(result); }) .catch(error => { - errorHandlers.handleRequestError('publish', originalUrl, ip, error, res); + logger.error('publish api error', error); }); }); + // route to get a short claim id from long claim Id + app.get('/api/shortClaimId/:longId/:name', ({ originalUrl, ip, params }, res) => { + // serve content + db.getShortClaimIdFromLongClaimId(params.longId, params.name) + .then(shortId => { + res.status(200).json(shortId); + }) + .catch(error => { + logger.error('api error getting short channel id', error); + res.status(400).json(error.message); + }); + }); + // route to get a short channel id from long channel Id + app.get('/api/shortChannelId/:longId/:name', ({ params }, res) => { + // serve content + db.getShortChannelIdFromLongChannelId(params.longId, params.name) + .then(shortId => { + console.log('sending back short channel id', shortId); + res.status(200).json(shortId); + }) + .catch(error => { + logger.error('api error getting short channel id', error); + res.status(400).json(error.message); + }); + }); }; diff --git a/routes/auth-routes.js b/routes/auth-routes.js new file mode 100644 index 00000000..39c0ef0f --- /dev/null +++ b/routes/auth-routes.js @@ -0,0 +1,15 @@ +const logger = require('winston'); +const passport = require('passport'); + +module.exports = (app) => { + // route for sign up + app.post('/signup', passport.authenticate('local-signup'), (req, res) => { + logger.debug('successful signup'); + res.status(200).json(true); + }); + // route for log in + app.post('/login', passport.authenticate('local-login'), (req, res) => { + logger.debug('successful login'); + res.status(200).json(true); + }); +}; diff --git a/routes/page-routes.js b/routes/page-routes.js index 36fd1517..855198bf 100644 --- a/routes/page-routes.js +++ b/routes/page-routes.js @@ -1,8 +1,20 @@ const errorHandlers = require('../helpers/errorHandlers.js'); -const db = require('../models'); const { postToStats, getStatsSummary, getTrendingClaims, getRecentClaims } = require('../controllers/statsController.js'); module.exports = (app) => { + // route to log out + app.get('/logout', (req, res) => { + req.logout(); + res.redirect('/'); + }); + // route to display login page + app.get('/login', (req, res) => { + if (req.user) { + res.status(200).redirect(`/${req.user.channelName}`); + } else { + res.status(200).render('login'); + } + }); // route to show 'about' page for spee.ch app.get('/about', (req, res) => { // get and render the content @@ -19,7 +31,9 @@ module.exports = (app) => { getTrendingClaims(dateTime) .then(result => { // logger.debug(result); - res.status(200).render('trending', { trendingAssets: result }); + res.status(200).render('trending', { + trendingAssets: result, + }); }) .catch(error => { errorHandlers.handleRequestError(error, res); @@ -30,21 +44,26 @@ module.exports = (app) => { getRecentClaims() .then(result => { // logger.debug(result); - res.status(200).render('new', { newClaims: result }); + res.status(200).render('new', { + newClaims: result, + }); }) .catch(error => { errorHandlers.handleRequestError(error, res); }); }); // route to show statistics for spee.ch - app.get('/stats', ({ ip, originalUrl }, res) => { + app.get('/stats', ({ ip, originalUrl, user }, res) => { // get and render the content const startDate = new Date(); startDate.setDate(startDate.getDate() - 1); getStatsSummary(startDate) .then(result => { postToStats('show', originalUrl, ip, null, null, 'success'); - res.status(200).render('statistics', result); + res.status(200).render('statistics', { + user, + result, + }); }) .catch(error => { errorHandlers.handleRequestError(error, res); @@ -60,20 +79,8 @@ module.exports = (app) => { res.status(200).render('embed', { layout: 'embed', claimId, name }); }); // route to display all free public claims at a given name - app.get('/:name/all', ({ ip, originalUrl, params }, res) => { + app.get('/:name/all', (req, res) => { // get and render the content - db - .getAllFreeClaims(params.name) - .then(orderedFreeClaims => { - if (!orderedFreeClaims) { - res.status(307).render('noClaims'); - return; - } - postToStats('show', originalUrl, ip, null, null, 'success'); - res.status(200).render('allClaims', { claims: orderedFreeClaims }); - }) - .catch(error => { - errorHandlers.handleRequestError('show', originalUrl, ip, error, res); - }); + res.status(410).send('/:name/all is no longer supported'); }); }; diff --git a/routes/serve-routes.js b/routes/serve-routes.js index b7574e4b..188a03c1 100644 --- a/routes/serve-routes.js +++ b/routes/serve-routes.js @@ -124,15 +124,12 @@ module.exports = (app) => { // 1. retrieve the channel contents getChannelContents(channelName, channelId) // 2. respond to the request - .then(channelContents => { - if (!channelContents) { + .then(result => { + logger.debug('result'); + if (!result.claims) { res.status(200).render('noChannel'); } else { - const handlebarsData = { - channelName, - channelContents, - }; - res.status(200).render('channel', handlebarsData); + res.status(200).render('channel', result); } }) .catch(error => { diff --git a/routes/sockets-routes.js b/routes/sockets-routes.js index cd653e37..c7dadd54 100644 --- a/routes/sockets-routes.js +++ b/routes/sockets-routes.js @@ -35,8 +35,15 @@ module.exports = (app, siofu, hostedContentPath) => { if (file.success) { logger.debug(`Client successfully uploaded ${file.name}`); socket.emit('publish-status', 'File upload successfully completed. Your image is being published to LBRY (this might take a second)...'); + + /* + NOTE: need to validate that client has the credentials to the channel they chose + otherwise they could circumvent security client side. + */ + // 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); + const publishParams = publishHelpers.createPublishParams(file.meta.name, file.pathName, file.meta.title, file.meta.description, file.meta.license, file.meta.nsfw, file.meta.channel); + logger.debug(publishParams); // publish the file publishController.publish(publishParams, file.name, file.meta.type) .then(result => { @@ -52,7 +59,7 @@ module.exports = (app, siofu, hostedContentPath) => { logger.error(`An error occurred in uploading the client's file`); socket.emit('publish-failure', 'File uploaded, but with errors'); postToStats('PUBLISH', '/', null, null, null, 'File uploaded, but with errors'); - // to-do: remove the file if not done automatically + // to-do: remove the file, if not done automatically } }); // handle disconnect diff --git a/speech.js b/speech.js index 21dff58c..082c64a6 100644 --- a/speech.js +++ b/speech.js @@ -4,133 +4,99 @@ const bodyParser = require('body-parser'); const siofu = require('socketio-file-upload'); const expressHandlebars = require('express-handlebars'); const Handlebars = require('handlebars'); +const handlebarsHelpers = require('./helpers/handlebarsHelpers.js'); const config = require('config'); const logger = require('winston'); const { getDownloadDirectory } = require('./helpers/lbryApi'); - +const helmet = require('helmet'); const PORT = 3000; // set port const app = express(); // create an Express application const db = require('./models'); // require our models for syncing +const passport = require('passport'); +const session = require('express-session'); // configure logging const logLevel = config.get('Logging.LogLevel'); require('./config/loggerConfig.js')(logger, logLevel); require('./config/slackLoggerConfig.js')(logger); +// check for global config variables +require('./helpers/configVarCheck.js')(); + // trust the proxy to get ip address for us app.enable('trust proxy'); + // add middleware -app.use(express.static(`${__dirname}/public`)); // 'express.static' to serve static files from public directory +app.use(helmet()); // set HTTP headers to protect against well-known web vulnerabilties app.use(express.static(`${__dirname}/public`)); // 'express.static' to serve static files from public directory app.use(bodyParser.json()); // 'body parser' for parsing application/json app.use(bodyParser.urlencoded({ extended: true })); // 'body parser' for parsing application/x-www-form-urlencoded app.use(siofu.router); // 'socketio-file-upload' router for uploading with socket.io -app.use((req, res, next) => { // custom logging middleware to log all incomming http requests +app.use((req, res, next) => { // custom logging middleware to log all incoming http requests logger.verbose(`Request on ${req.originalUrl} from ${req.ip}`); + logger.debug(req.body); next(); }); +// initialize passport +app.use(session({ secret: 'cats' })); +app.use(passport.initialize()); +app.use(passport.session()); +passport.serializeUser((user, done) => { + done(null, user.id); +}); +passport.deserializeUser((id, done) => { // this populates req.user + let userInfo = {}; + db.User.findOne({ where: { id } }) + .then(user => { + userInfo['id'] = user.id; + userInfo['userName'] = user.userName; + return user.getChannel(); + }) + .then(channel => { + userInfo['channelName'] = channel.channelName; + userInfo['channelClaimId'] = channel.channelClaimId; + return db.getShortChannelIdFromLongChannelId(channel.channelClaimId, channel.channelName); + }) + .then(shortChannelId => { + userInfo['shortChannelId'] = shortChannelId; + done(null, userInfo); + return null; + }) + .catch(error => { + logger.error('sequelize error', error); + done(error, null); + }); +}); +const localSignupStrategy = require('./passport/local-signup.js'); +const localLoginStrategy = require('./passport/local-login.js'); +passport.use('local-signup', localSignupStrategy); +passport.use('local-login', localLoginStrategy); + // configure handlebars & register it with express app const hbs = expressHandlebars.create({ defaultLayout: 'main', // sets the default layout handlebars : Handlebars, // includes basic handlebars for access to that library - helpers : { - // define any extra helpers you may need - googleAnalytics () { - const googleApiKey = config.get('AnalyticsConfig.GoogleId'); - return new Handlebars.SafeString( - `` - ); - }, - addOpenGraph (title, mimeType, showUrl, source, description, thumbnail) { - let basicTags = ` - - - `; - if (mimeType === 'video/mp4') { - return new Handlebars.SafeString( - `${basicTags} - - - - - - - ` - ); - } else if (mimeType === 'image/gif') { - return new Handlebars.SafeString( - `${basicTags} - - - - ` - ); - } else { - return new Handlebars.SafeString( - `${basicTags} - - - - ` - ); - } - }, - addTwitterCard (mimeType, source, embedUrl, directFileUrl) { - let basicTwitterTags = ``; - if (mimeType === 'video/mp4') { - return new Handlebars.SafeString( - `${basicTwitterTags} - - - - - - ` - ); - } else { - return new Handlebars.SafeString( - `${basicTwitterTags} ` - ); - } - }, - ifConditional (varOne, operator, varTwo, options) { - switch (operator) { - case '===': - return (varOne === varTwo) ? options.fn(this) : options.inverse(this); - case '!==': - return (varOne !== varTwo) ? options.fn(this) : options.inverse(this); - case '<': - return (varOne < varTwo) ? options.fn(this) : options.inverse(this); - case '<=': - return (varOne <= varTwo) ? options.fn(this) : options.inverse(this); - case '>': - return (varOne > varTwo) ? options.fn(this) : options.inverse(this); - case '>=': - return (varOne >= varTwo) ? options.fn(this) : options.inverse(this); - case '&&': - return (varOne && varTwo) ? options.fn(this) : options.inverse(this); - case '||': - return (varOne || varTwo) ? options.fn(this) : options.inverse(this); - case 'mod3': - return ((parseInt(varOne) % 3) === 0) ? options.fn(this) : options.inverse(this); - default: - return options.inverse(this); - } - }, - }, + helpers : handlebarsHelpers, // custom defined helpers }); app.engine('handlebars', hbs.engine); app.set('view engine', 'handlebars'); +// middleware to pass user info back to client (for handlebars access), if user is logged in +app.use((req, res, next) => { + if (req.user) { + logger.verbose(req.user); + res.locals.user = { + id : req.user.id, + userName : req.user.userName, + channelName : req.user.channelName, + channelClaimId: req.user.channelClaimId, + shortChannelId: req.user.shortChannelId, + }; + } + next(); +}); + // start the server db.sequelize .sync() // sync sequelize @@ -142,7 +108,8 @@ db.sequelize // add the hosted content folder at a static path app.use('/media', express.static(hostedContentPath)); // require routes & wrap in socket.io - require('./routes/api-routes.js')(app, hostedContentPath); + require('./routes/auth-routes.js')(app); + require('./routes/api-routes.js')(app); require('./routes/page-routes.js')(app); require('./routes/serve-routes.js')(app); require('./routes/home-routes.js')(app); diff --git a/views/allClaims.handlebars b/views/allClaims.handlebars deleted file mode 100644 index 9531a577..00000000 --- a/views/allClaims.handlebars +++ /dev/null @@ -1,23 +0,0 @@ -
These are all the free, public assets at that claim. You can publish more at spee.ch.
- {{#each claims}} -Below is all the free content in this channel.
- {{#each channelContents}} + {{#each this.claims}} {{> contentListItem}} {{/each}}Log in to an existing channel:
+ {{>channelLoginForm}} +Create a brand new channel:
+ {{>channelCreationForm}} +{{fileInfo.description}}> +
{{fileInfo.description}}
Name | diff --git a/views/partials/channelCreationForm.handlebars b/views/partials/channelCreationForm.handlebars new file mode 100644 index 00000000..743aa534 --- /dev/null +++ b/views/partials/channelCreationForm.handlebars @@ -0,0 +1,78 @@ + + + + +