diff --git a/.gitignore b/.gitignore index 63559f2b..b512c09d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -node_modules -ApiPublishTest.html \ No newline at end of file +node_modules \ No newline at end of file diff --git a/README.md b/README.md index 0f7522e1..05b7c37a 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ spee.ch is a single-serving site that reads and publishes images to and from the * a successfull request returns the resolve results for the claim at that name in JSON format * /api/claim_list/:name * a successfull request returns a list of claims at that claim name in JSON format +* /api/isClaimAvailable/:name + * a successfull request returns a boolean: `true` if the name is still available, `false` if the name has already been published to by spee.ch. #### POST * /api/publish diff --git a/controllers/publishController.js b/controllers/publishController.js index a9088181..06e5f0eb 100644 --- a/controllers/publishController.js +++ b/controllers/publishController.js @@ -19,48 +19,98 @@ function upsert (Model, values, condition) { }); } +function checkNameAvailability (name) { + const deferred = 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 + lbryApi + .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); + }); + } else { + resolve(true); + } + }) + .catch(error => { + reject(error); + }); + }); + return deferred; +}; + module.exports = { - publish: (publishParams, fileName, fileType) => { + publish (publishParams, fileName, fileType) { const deferred = new Promise((resolve, reject) => { - // 1. publish the file - lbryApi - .publishClaim(publishParams) - .then(result => { - logger.info(`Successfully published ${fileName}`, result); - // 2. update old record of create new one (update is in case the claim has been published before by this daemon) - upsert( - db.File, - { - name : publishParams.name, - claimId : result.claim_id, - outpoint: `${result.txid}:${result.nout}`, - height : 0, - fileName, - filePath: publishParams.file_path, - fileType, - nsfw : publishParams.metadata.nsfw, - }, - { - name : publishParams.name, - claimId: result.claim_id, - } - ).then(() => { - // resolve the promise with the result from lbryApi.publishClaim; - resolve(result); - }) - .catch(error => { - logger.error('Sequelize findOne error', error); - // reject the promise - reject(error); - }); - }) - .catch(error => { - // delete the local file - publishHelpers.deleteTemporaryFile(publishParams.file_path); - // reject the promise - reject(error); - }); + // 1. make sure the name is available + checkNameAvailability(publishParams.name) + .then(result => { + if (result === true) { + // 2. publish the file + lbryApi + .publishClaim(publishParams) + .then(result => { + logger.info(`Successfully published ${fileName}`, result); + // 3. update old record or create new one (update is in case the claim has been published before by this daemon) + upsert( + db.File, + { + name : publishParams.name, + claimId : result.claim_id, + address : publishParams.claim_address, + outpoint: `${result.txid}:${result.nout}`, + height : 0, + fileName, + filePath: publishParams.file_path, + fileType, + nsfw : publishParams.metadata.nsfw, + }, + { + name : publishParams.name, + claimId: result.claim_id, + } + ).then(() => { + // resolve the promise with the result from lbryApi.publishClaim; + resolve(result); + }) + .catch(error => { + logger.error('Sequelize findOne error', error); + // reject the promise + reject(error); + }); + }) + .catch(error => { + // delete the local file + publishHelpers.deleteTemporaryFile(publishParams.file_path); + // reject the promise + reject(error); + }); + } else { + const err = new Error('That name has already been claimed by spee.ch. Please choose a new claim name.'); + reject(err); + } + }) + .catch(error => { + reject(error); + }); }); return deferred; }, + checkNameAvailability (name) { + return checkNameAvailability(name); + }, }; diff --git a/controllers/serveController.js b/controllers/serveController.js index 97087a20..a836c67b 100644 --- a/controllers/serveController.js +++ b/controllers/serveController.js @@ -18,6 +18,7 @@ function updateFileIfNeeded (uri, claimName, claimId, localOutpoint, localHeight // logger.debug('resolved result:', result); const resolvedOutpoint = `${result.claim.txid}:${result.claim.nout}`; const resolvedHeight = result.claim.height; + const resolvedAddress = result.claim.address; logger.debug('database outpoint:', localOutpoint); logger.debug('resolved outpoint:', resolvedOutpoint); // 2. if the outpoint's match, no further work needed @@ -29,7 +30,7 @@ function updateFileIfNeeded (uri, claimName, claimId, localOutpoint, localHeight // 2. get the resolved claim } else { logger.debug(`local outpoint did not match for ${uri}. Initiating update.`); - getClaimAndUpdate(uri, resolvedHeight); + getClaimAndUpdate(uri, resolvedAddress, resolvedHeight); } }) .catch(error => { @@ -37,7 +38,7 @@ function updateFileIfNeeded (uri, claimName, claimId, localOutpoint, localHeight }); } -function getClaimAndUpdate (uri, height) { +function getClaimAndUpdate (uri, address, height) { // 1. get the claim lbryApi .getClaim(uri) @@ -47,7 +48,8 @@ function getClaimAndUpdate (uri, height) { db.File .update({ outpoint, - height, // note: height is coming from 'resolve', not 'get'. + height, // note: height is coming from the 'resolve', not 'get'. + address, // note: address is coming from the 'resolve', not 'get'. fileName: file_name, filePath: download_path, fileType: mime_type, @@ -70,7 +72,7 @@ function getClaimAndUpdate (uri, height) { }); } -function getClaimAndHandleResponse (uri, height, resolve, reject) { +function getClaimAndHandleResponse (uri, address, height, resolve, reject) { lbryApi .getClaim(uri) .then(({ name, claim_id, outpoint, file_name, download_path, mime_type, metadata }) => { @@ -80,8 +82,9 @@ function getClaimAndHandleResponse (uri, height, resolve, reject) { .create({ name, claimId : claim_id, + address, // note: comes from parent 'resolve,' not this 'get' call outpoint, - height, + height, // note: comes from parent 'resolve,' not this 'get' call fileName: file_name, filePath: download_path, fileType: mime_type, @@ -120,6 +123,7 @@ module.exports = { const claimId = freePublicClaimList[0].claim_id; const uri = `${name}#${claimId}`; const height = freePublicClaimList[0].height; + const address = freePublicClaimList[0].address; // 2. check to see if the file is available locally db.File .findOne({ where: { name, claimId } }) @@ -133,7 +137,7 @@ module.exports = { // 3. otherwise use daemon to retrieve it } else { // get the claim and serve it - getClaimAndHandleResponse(uri, height, resolve, reject); + getClaimAndHandleResponse(uri, address, height, resolve, reject); } }) .catch(error => { @@ -175,7 +179,7 @@ module.exports = { // 4. check to see if the claim is free & public if (isFreePublicClaim(result.claim)) { // 5. get claim and serve - getClaimAndHandleResponse(uri, result.claim.height, resolve, reject); + getClaimAndHandleResponse(uri, result.claim.address, result.claim.height, resolve, reject); } else { reject(null); } diff --git a/controllers/statsController.js b/controllers/statsController.js index 0cd3da95..2996afd5 100644 --- a/controllers/statsController.js +++ b/controllers/statsController.js @@ -5,7 +5,7 @@ const db = require('../models'); const googleApiKey = config.get('AnalyticsConfig.GoogleId'); module.exports = { - postToStats: (action, url, ipAddress, result) => { + postToStats (action, url, ipAddress, result) { logger.silly(`creating ${action} record for statistics db`); // make sure the result is a string if (result && (typeof result !== 'string')) { @@ -27,7 +27,7 @@ module.exports = { logger.error('sequelize error', error); }); }, - sendGoogleAnalytics: (action, ip, originalUrl) => { + sendGoogleAnalytics (action, ip, originalUrl) { const visitorId = ip.replace(/\./g, '-'); const visitor = ua(googleApiKey, visitorId, { strictCidFormat: false, https: true }); switch (action) { @@ -55,7 +55,7 @@ module.exports = { default: break; } }, - getStatsSummary: () => { + getStatsSummary () { logger.debug('retrieving site statistics'); const deferred = new Promise((resolve, reject) => { // get the raw statistics data diff --git a/helpers/functions/getAllFreePublicClaims.js b/helpers/functions/getAllFreePublicClaims.js index 58d35157..4a902691 100644 --- a/helpers/functions/getAllFreePublicClaims.js +++ b/helpers/functions/getAllFreePublicClaims.js @@ -28,7 +28,7 @@ function orderClaims (claimsListArray) { return claimsListArray; } -module.exports = claimName => { +module.exports = (claimName) => { const deferred = new Promise((resolve, reject) => { // make a call to the daemon to get the claims list lbryApi diff --git a/helpers/libraries/errorHandlers.js b/helpers/libraries/errorHandlers.js index 4d922c3b..e07632a5 100644 --- a/helpers/libraries/errorHandlers.js +++ b/helpers/libraries/errorHandlers.js @@ -3,7 +3,7 @@ const { postToStats } = require('../../controllers/statsController.js'); module.exports = { handleRequestError (action, originalUrl, ip, error, res) { - logger.error('Request Error >>', error); + logger.error('Request Error >>', error.message); if (error.response) { postToStats(action, originalUrl, ip, error.response.data.error.messsage); res.status(error.response.status).send(error.response.data.error.message); @@ -12,7 +12,7 @@ module.exports = { res.status(503).send('Connection refused. The daemon may not be running.'); } else { postToStats(action, originalUrl, ip, error); - res.status(400).send(JSON.stringify(error)); + res.status(400).send(error.message); } }, handlePublishError (error) { @@ -23,7 +23,7 @@ module.exports = { logger.error('Publish Error:', error.response.data.error); return error.response.data.error.message; } else { - logger.error('Unhandled Publish Error:', error); + logger.error('Unhandled Publish Error:', error.message); return error; } }, diff --git a/helpers/libraries/lbryApi.js b/helpers/libraries/lbryApi.js index b240b6c5..8e744322 100644 --- a/helpers/libraries/lbryApi.js +++ b/helpers/libraries/lbryApi.js @@ -2,6 +2,23 @@ const axios = require('axios'); const logger = require('winston'); module.exports = { + getWalletList () { + logger.debug('getting wallet list'); + const deferred = 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); + }); + }); + return deferred; + }, publishClaim (publishParams) { logger.debug(`Publishing claim to "${publishParams.name}"`); const deferred = new Promise((resolve, reject) => { diff --git a/helpers/libraries/publishHelpers.js b/helpers/libraries/publishHelpers.js index 045f5ee6..c2e10091 100644 --- a/helpers/libraries/publishHelpers.js +++ b/helpers/libraries/publishHelpers.js @@ -3,7 +3,7 @@ const config = require('config'); const fs = require('fs'); module.exports = { - createPublishParams: (name, filePath, license, nsfw) => { + createPublishParams (name, filePath, license, nsfw) { logger.debug(`Creating Publish Parameters for "${name}"`); // const payAddress = config.get('WalletConfig.LbryPayAddress'); const claimAddress = config.get('WalletConfig.LbryClaimAddress'); @@ -40,7 +40,7 @@ module.exports = { logger.debug('publishParams:', publishParams); return publishParams; }, - deleteTemporaryFile: (filePath) => { + deleteTemporaryFile (filePath) { fs.unlink(filePath, err => { if (err) throw err; logger.debug(`successfully deleted ${filePath}`); diff --git a/models/file.js b/models/file.js index cc6cd325..fc303c93 100644 --- a/models/file.js +++ b/models/file.js @@ -10,6 +10,10 @@ module.exports = (sequelize, { STRING, BOOLEAN, INTEGER }) => { type : STRING, allowNull: false, }, + address: { + type : STRING, + allowNull: false, + }, outpoint: { type : STRING, allowNull: false, diff --git a/public/assets/css/allStyle.css b/public/assets/css/allStyle.css index a1e4da2b..1feb4c45 100644 --- a/public/assets/css/allStyle.css +++ b/public/assets/css/allStyle.css @@ -9,6 +9,7 @@ margin-bottom: 2px; padding-bottom: 2px; border-bottom: 1px lightgrey solid; + margin-top: 2em; } .main { @@ -42,6 +43,7 @@ footer { .panel { overflow: auto; word-wrap: break-word; + margin-bottom: 1em; } .col-left, .col-right { diff --git a/public/assets/js/claimPublish.js b/public/assets/js/claimPublish.js index 70d90dc6..e43424aa 100644 --- a/public/assets/js/claimPublish.js +++ b/public/assets/js/claimPublish.js @@ -83,7 +83,7 @@ document.getElementById('publish-submit').addEventListener('click', function(eve event.preventDefault(); var name = document.getElementById('publish-name').value; var invalidCharacters = /[^A-Za-z0-9,-]/.exec(name); - // validate 'name' + // validate 'name' field if (invalidCharacters) { alert(invalidCharacters + ' is not allowed. A-Z, a-z, 0-9, and "-" only.'); return; @@ -91,28 +91,44 @@ document.getElementById('publish-submit').addEventListener('click', function(eve alert("You must enter a name for your claim"); return; } - // make sure a file was selected - if (stagedFiles) { - // make sure only 1 file was selected - if (stagedFiles.length > 1) { - alert("Only one file is allowed at a time"); - return; - } - // make sure the content type is acceptable - switch (stagedFiles[0].type) { - case "image/png": - case "image/jpeg": - case "image/gif": - case "video/mp4": - uploader.submitFiles(stagedFiles); - break; - default: - alert("Only .png, .jpeg, .gif, and .mp4 files are currently supported"); - break; - } - } else { + // make sure only 1 file was selected + if (!stagedFiles) { alert("Please select a file"); + return; + } else if (stagedFiles.length > 1) { + alert("Only one file is allowed at a time"); + return; } + // make sure the content type is acceptable + switch (stagedFiles[0].type) { + case "image/png": + case "image/jpeg": + case "image/gif": + case "video/mp4": + break; + default: + alert("Only .png, .jpeg, .gif, and .mp4 files are currently supported"); + return; + } + // make sure the name is 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) { + uploader.submitFiles(stagedFiles); + } else { + alert("That name has already been claimed by spee.ch. Please choose a different name."); + } + } else { + console.log("request to check claim name failed with status:", this.status); + }; + } + }; + xhttp.send(); }) /* socketio-file-upload listeners */ diff --git a/routes/api-routes.js b/routes/api-routes.js index 411c7343..a49c5e58 100644 --- a/routes/api-routes.js +++ b/routes/api-routes.js @@ -9,14 +9,14 @@ const { postToStats, sendGoogleAnalytics } = require('../controllers/statsContro module.exports = app => { // route to run a claim_list request on the daemon - app.get('/api/claim_list/:claim', ({ ip, originalUrl, params }, res) => { + app.get('/api/claim_list/:name', ({ ip, originalUrl, params }, res) => { // google analytics sendGoogleAnalytics('serve', ip, originalUrl); // log logger.verbose(`GET request on ${originalUrl} from ${ip}`); // serve the content lbryApi - .getClaimsList(params.claim) + .getClaimsList(params.name) .then(claimsList => { postToStats('serve', originalUrl, ip, 'success'); res.status(200).json(claimsList); @@ -25,6 +25,25 @@ module.exports = app => { errorHandlers.handleRequestError('publish', originalUrl, ip, error, res); }); }); + // route to check whether spee.ch has published to a claim + app.get('/api/isClaimAvailable/:name', ({ ip, originalUrl, params }, res) => { + // log + logger.verbose(`GET request on ${originalUrl} from ${ip}`); + // send response + publishController + .checkNameAvailability(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`); + res.status(200).json(false); + } + }) + .catch(error => { + res.status(500).json(error); + }); + }); // route to run a resolve request on the daemon app.get('/api/resolve/:uri', ({ ip, originalUrl, params }, res) => { // google analytics