diff --git a/.env.example b/.env.example index 1e0d824d..4e8f0e13 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,6 @@ MS_TO_TOKEN_EXPIRATION=4500000 DISABLE_SHOCK_ENCRYPTION=false CACHE_HEADERS_MANDATORY=true SHOCK_CACHE=true -TRUSTED_KEYS=true \ No newline at end of file +TRUSTED_KEYS=true +TORRENT_SEED_URL=https://webtorrent.shock.network +TORRENT_SEED_TOKEN=jibberish diff --git a/package.json b/package.json index 9854db5e..1003da1f 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "localtunnel": "^1.9.0", "lodash": "^4.17.20", "method-override": "^2.3.7", + "node-fetch": "^2.6.1", "node-persist": "^3.1.0", "promise": "^8.1.0", "ramda": "^0.27.1", @@ -68,6 +69,7 @@ "@types/jest": "^24.0.18", "@types/jsonwebtoken": "^8.3.7", "@types/lodash": "^4.14.141", + "@types/node-fetch": "^2.5.8", "@types/ramda": "types/npm-ramda#dist", "@types/react": "16.x.x", "@types/socket.io": "^2.1.11", diff --git a/services/gunDB/contact-api/actions.js b/services/gunDB/contact-api/actions.js index 5a904599..e1fd403b 100644 --- a/services/gunDB/contact-api/actions.js +++ b/services/gunDB/contact-api/actions.js @@ -924,6 +924,7 @@ const sendHRWithInitialMsg = async ( * @typedef {object} SpontPaymentOptions * @prop {Common.Schema.OrderTargetType} type * @prop {string=} postID + * @prop {string=} ackInfo */ /** @@ -1296,6 +1297,7 @@ const setLastSeenApp = () => * @returns {Promise<[string, Common.Schema.RawPost]>} */ const createPostNew = async (tags, title, content) => { + const SEA = require('../Mediator').mySEA /** @type {Common.Schema.RawPost} */ const newPost = { date: Date.now(), @@ -1311,6 +1313,23 @@ const createPostNew = async (tags, title, content) => { newPost.contentItems[uuid] = c }) + const mySecret = require('../Mediator').getMySecret() + + await Common.Utils.asyncForEach(content, async c => { + // @ts-expect-error + const uuid = Gun.text.random() + newPost.contentItems[uuid] = c + if ( + (c.type === 'image/embedded' || c.type === 'video/embedded') && + c.isPrivate + ) { + const encryptedMagnet = await SEA.encrypt(c.magnetURI, mySecret) + newPost.contentItems[uuid] = { ...c, magnetURI: encryptedMagnet } + } else { + newPost.contentItems[uuid] = c + } + }) + /** @type {string} */ const postID = await Common.makePromise((res, rej) => { const _n = require('../Mediator') diff --git a/services/gunDB/contact-api/jobs/onOrders.js b/services/gunDB/contact-api/jobs/onOrders.js index 08eb4e5c..ad757ccc 100644 --- a/services/gunDB/contact-api/jobs/onOrders.js +++ b/services/gunDB/contact-api/jobs/onOrders.js @@ -11,6 +11,9 @@ const { Constants: { ErrorCode }, Schema } = Common +const { assertNever } = require('assert-never') +const crypto = require('crypto') +const fetch = require('node-fetch') const LightningServices = require('../../../../utils/lightningServices') const { @@ -20,6 +23,7 @@ const { const { writeCoordinate } = require('../../../coordinates') const Key = require('../key') const Utils = require('../utils') +const { gunUUID } = require('../../../../utils') const getUser = () => require('../../Mediator').getUser() @@ -219,17 +223,178 @@ const listenerForAddr = (addr, SEA) => async (order, orderID) => { /** * @param {Common.InvoiceWhenListed} invoice */ - const onData = invoice => { + const onData = async invoice => { if (invoice.settled) { + writeCoordinate(invoice.r_hash.toString(), coord) + if (order.targetType === 'tip') { getUser() .get('postToTipCount') // CAST: Checked above. .get(/** @type {string} */ (order.ackInfo)) .set(null) // each item in the set is a tip + } else if (order.targetType === 'contentReveal') { + // ----------------------------------------- + logger.debug('Content Reveal') + + //assuming digital product that only requires to be unlocked + const postID = order.ackInfo + + if (!Common.isPopulatedString(postID)) { + logger.error(`Invalid post ID`) + logger.error(postID) + return + } + + // TODO: do this reactively + const selectedPost = await new Promise(res => { + getUser() + .get(Key.POSTS_NEW) + .get(postID) + .load(res) + }) + + logger.debug(selectedPost) + + if (Common.isPost(selectedPost)) { + logger.error('Post id provided does not correspond to a valid post') + return + } + + /** + * @type {Record} + */ + const contentsToSend = {} + const mySecret = require('../../Mediator').getMySecret() + logger.debug('SECRET OK') + let privateFound = false + await Common.Utils.asyncForEach( + Object.entries(selectedPost.contentItems), + async ([contentID, item]) => { + if ( + item.type !== 'image/embedded' && + item.type !== 'video/embedded' + ) { + return //only visual content can be private + } + if (!item.isPrivate) { + return + } + privateFound = true + const decrypted = await SEA.decrypt(item.magnetURI, mySecret) + contentsToSend[contentID] = decrypted + } + ) + if (!privateFound) { + logger.error(`Post provided does not contain private content`) + return + } + const ackData = { unlockedContents: contentsToSend } + const toSend = JSON.stringify(ackData) + const encrypted = await SEA.encrypt(toSend, secret) + const ordResponse = { + type: 'orderAck', + response: encrypted + } + logger.debug('RES READY') + + const uuid = gunUUID() + orderResponse.ackNode = uuid + + await /** @type {Promise} */ (new Promise((res, rej) => { + getUser() + .get(Key.ORDER_TO_RESPONSE) + .get(uuid) + .put(ordResponse, ack => { + if (ack.err && typeof ack.err !== 'number') { + rej( + new Error( + `Error saving encrypted orderAck to order to response usergraph: ${ack}` + ) + ) + } else { + res() + } + }) + })) + logger.debug('RES SENT CONTENT') + + // ---------------------------------------------------------------------------------- + } else if (order.targetType === 'spontaneousPayment') { + // no action required + } else if (order.targetType === 'torrentSeed') { + logger.debug('TORRENT') + const numberOfTokens = Number(order.ackInfo) + if (isNaN(numberOfTokens)) { + logger.error('ackInfo provided is not a valid number') + return + } + const seedUrl = process.env.TORRENT_SEED_URL + const seedToken = process.env.TORRENT_SEED_TOKEN + if (!seedUrl || !seedToken) { + logger.error('torrentSeed service not available') + return + } + logger.debug('SEED URL OK') + const tokens = Array(numberOfTokens) + for (let i = 0; i < numberOfTokens; i++) { + tokens[i] = crypto.randomBytes(32).toString('hex') + } + /**@param {string} token */ + const enrollToken = async token => { + const reqData = { + seed_token: seedToken, + wallet_token: token + } + // @ts-expect-error TODO + const res = await fetch(`${seedUrl}/api/enroll_token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(reqData) + }) + if (res.status !== 200) { + throw new Error('torrentSeed service currently not available') + } + } + await Promise.all(tokens.map(enrollToken)) + logger.debug('RES SEED OK') + const ackData = { seedUrl, tokens } + const toSend = JSON.stringify(ackData) + const encrypted = await SEA.encrypt(toSend, secret) + const serviceResponse = { + type: 'orderAck', + response: encrypted + } + console.log('RES SEED SENT') + + const uuid = gunUUID() + orderResponse.ackNode = uuid + + await /** @type {Promise} */ (new Promise((res, rej) => { + getUser() + .get(Key.ORDER_TO_RESPONSE) + .get(uuid) + .put(serviceResponse, ack => { + if (ack.err && typeof ack.err !== 'number') { + rej( + new Error( + `Error saving encrypted orderAck to order to response usergraph: ${ack}` + ) + ) + } else { + res() + } + }) + })) + logger.debug('RES SENT SEED') + } else if (order.targetType === 'other') { + // TODO + } else { + assertNever(order.targetType) } - writeCoordinate(invoice.r_hash.toString(), coord) stream.off() } } diff --git a/src/routes.js b/src/routes.js index f61a4857..a79b5eb7 100644 --- a/src/routes.js +++ b/src/routes.js @@ -1192,12 +1192,35 @@ module.exports = async ( app.post('/api/lnd/unifiedTrx', async (req, res) => { try { - const { type, amt, to, memo, feeLimit, postID } = req.body + const { type, amt, to, memo, feeLimit, postID, ackInfo } = req.body - if (type !== 'spont' && type !== 'post') { + if ( + type !== 'spont' && + type !== 'post' && + type !== 'spontaneousPayment' && + type !== 'tip' && + type !== 'torrentSeed' && + type !== 'contentReveal' && + type !== 'other' + ) { return res.status(415).json({ field: 'type', - errorMessage: `Only 'spont' and 'post' payments supported via this endpoint for now.` + errorMessage: `Only 'spontaneousPayment'| 'tip' | 'torrentSeed' | 'contentReveal' | 'other' payments supported via this endpoint for now.` + }) + } + + const typesThatShouldContainAckInfo = [ + 'tip', + 'torrentSeed', + 'contentReveal' + ] + + const shouldContainAckInfo = typesThatShouldContainAckInfo.includes(type) + + if (shouldContainAckInfo && !Common.isPopulatedString(ackInfo)) { + return res.status(400).json({ + field: 'ackInfo', + errorMessage: `Transactions of type ${typesThatShouldContainAckInfo} should contain an ackInfo field.` }) } @@ -1241,7 +1264,8 @@ module.exports = async ( return res.status(200).json( await GunActions.sendSpontaneousPayment(to, amt, memo, feeLimit, { type, - postID + postID, + ackInfo }) ) } catch (e) { diff --git a/src/sockets.js b/src/sockets.js index 54537af8..f1a90196 100644 --- a/src/sockets.js +++ b/src/sockets.js @@ -66,66 +66,6 @@ module.exports = ( } } - const parseJSON = data => { - try { - if (typeof data === 'string') { - return JSON.parse(data) - } - - return data - } catch (err) { - return data - } - } - - const decryptEvent = ({ eventName, data, socket }) => { - try { - const deviceId = socket.handshake.query['x-shockwallet-device-id'] - if (Encryption.isNonEncrypted(eventName)) { - return data - } - - if (!data) { - return data - } - - const parsedData = parseJSON(data) - - if (!deviceId) { - throw { - field: 'deviceId', - message: 'Please specify a device ID' - } - } - - if (!Encryption.isAuthorizedDevice({ deviceId })) { - throw { - field: 'deviceId', - message: 'Please exchange keys with the API before using the socket' - } - } - - const decryptedKey = Encryption.decryptKey({ - deviceId, - message: parsedData.encryptedKey - }) - const decryptedMessage = Encryption.decryptMessage({ - message: parsedData.encryptedData, - key: decryptedKey, - iv: parsedData.iv - }) - const decryptedData = JSON.parse(decryptedMessage) - return decryptedData - } catch (err) { - logger.error( - `[SOCKET] An error has occurred while decrypting an event (${eventName}):`, - err - ) - - return socket.emit('encryption:error', err) - } - } - const onNewInvoice = (socket, subID) => { const { lightning } = LightningServices.services logger.warn('Subscribing to invoices socket...' + subID) @@ -677,7 +617,7 @@ module.exports = ( } /** - * @param {Common.Schema.SimpleReceivedRequest[]} receivedReqs + * @param {ReadonlyArray} receivedReqs */ const onReceivedReqs = receivedReqs => { const processed = receivedReqs.map(({ id, requestorPK, timestamp }) => { diff --git a/utils/index.js b/utils/index.js index 90e80eee..516d762a 100644 --- a/utils/index.js +++ b/utils/index.js @@ -1,9 +1,21 @@ /** * @format */ +const Gun = require('gun') const { asyncFilter } = require('./helpers') -module.exports = { - asyncFilter +/** + * @returns {string} + */ +const gunUUID = () => { + // @ts-expect-error Not typed + const uuid = Gun.Text.random() + + return uuid +} + +module.exports = { + asyncFilter, + gunUUID } diff --git a/yarn.lock b/yarn.lock index 8c5b24e9..969a012d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -722,6 +722,14 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw== +"@types/node-fetch@^2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.8.tgz#e199c835d234c7eb0846f6618012e558544ee2fb" + integrity sha512-fbjI6ja0N5ZA8TV53RUqzsKNkl9fv8Oj3T7zxW7FGv1GSH7gwJaNF8dzCjrqKaxKeUpTz4yT1DaJFq/omNpGfw== + dependencies: + "@types/node" "*" + form-data "^3.0.0" + "@types/node@*": version "12.7.4" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.7.4.tgz#64db61e0359eb5a8d99b55e05c729f130a678b04" @@ -1838,7 +1846,7 @@ colour@~0.7.1: resolved "https://registry.yarnpkg.com/colour/-/colour-0.7.1.tgz#9cb169917ec5d12c0736d3e8685746df1cadf778" integrity sha1-nLFpkX7F0SwHNtPoaFdG3xyt93g= -combined-stream@^1.0.6, combined-stream@~1.0.6: +combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== @@ -2946,6 +2954,15 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= +form-data@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" + integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" @@ -4991,7 +5008,7 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -node-fetch@^2.3.0: +node-fetch@^2.3.0, node-fetch@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==