diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..eebfc91a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +**/.git +**/node_modules +**/radata \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..4ea11d88 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +DATA_FILE_NAME=data3 +PEERS=["http://gun.shock.network:8765/gun"] +MS_TO_TOKEN_EXPIRATION=4500000 +DISABLE_SHOCK_ENCRYPTION=false +CACHE_HEADERS_MANDATORY=true +SHOCK_CACHE=true \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..90e3aef2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM node:12.18.0-alpine3.9 + +WORKDIR /usr/src/app + + +ADD ./package.json /usr/src/app/package.json +ADD ./yarn.lock /usr/src/app/yarn.lock +#RUN useradd app && \ +# mkdir -p /home/app/.lnd +RUN apk update && apk upgrade && \ + apk add --no-cache bash git openssh +RUN yarn install + +ADD . /usr/src/app +RUN ls /usr/src/app + +RUN chmod +x ./docker-start.sh +#ADD ./tls.cert /usr/src/app/tls.cert +#ADD ./admin.macaroon /usr/src/app/admin.macaroon + +# && \ +# chown -R app:app /home/app && \ +# chown -R app:app /usr/src/app && \ +# chown -R app:app /start.sh + +#ARG lnd_address +#ENV LND_ADDR=$lnd_address +EXPOSE 9835 +CMD ["./docker-start.sh"] \ No newline at end of file diff --git a/docker-start.sh b/docker-start.sh new file mode 100644 index 00000000..986525b1 --- /dev/null +++ b/docker-start.sh @@ -0,0 +1,5 @@ +#!/bin/ash +node main -h 0.0.0.0 \ + -m admin.macaroon \ + -d tls.cert \ + -l $LND_ADDR \ No newline at end of file diff --git a/package.json b/package.json index 59984e1e..68b03834 100644 --- a/package.json +++ b/package.json @@ -41,11 +41,12 @@ "lodash": "^4.17.15", "method-override": "^2.3.7", "promise": "^8.0.1", + "ramda": "^0.27.0", "request": "^2.87.0", "request-promise": "^4.2.2", "response-time": "^2.3.2", "shelljs": "^0.8.2", - "shock-common": "^0.0.1", + "shock-common": "4.0.1", "socket.io": "2.1.1", "text-encoding": "^0.7.0", "tingodb": "^0.6.1", @@ -61,6 +62,7 @@ "@types/jest": "^24.0.18", "@types/jsonwebtoken": "^8.3.7", "@types/lodash": "^4.14.141", + "@types/ramda": "types/npm-ramda#dist", "@types/socket.io": "^2.1.4", "@types/uuid": "^3.4.5", "babel-eslint": "^10.0.3", @@ -78,9 +80,12 @@ "typescript": "^3.6.3" }, "lint-staged": { - "*.{js,ts}": [ + "*.js": [ "prettier --check", "eslint" + ], + "*.ts": [ + "prettier --check" ] }, "husky": { diff --git a/services/gunDB/Mediator/index.js b/services/gunDB/Mediator/index.js index 2b91bae7..98bafe55 100644 --- a/services/gunDB/Mediator/index.js +++ b/services/gunDB/Mediator/index.js @@ -2,14 +2,20 @@ * @format */ const Gun = require('gun') +// @ts-ignore +require('gun/nts') const logger = require('winston') // @ts-ignore Gun.log = () => {} // @ts-ignore require('gun/lib/open') +// @ts-ignore +require('gun/lib/load') const debounce = require('lodash/debounce') const Encryption = require('../../../utils/encryptionStore') +const Key = require('../contact-api/key') + /** @type {import('../contact-api/SimpleGUN').ISEA} */ // @ts-ignore const SEAx = require('gun/sea') @@ -265,6 +271,24 @@ const authenticate = async (alias, pass, __user) => { if (typeof ack.err === 'string') { throw new Error(ack.err) } else if (typeof ack.sea === 'object') { + // clock skew + await new Promise(res => setTimeout(res, 2000)) + + await new Promise((res, rej) => { + _user.get(Key.FOLLOWS).put( + { + unused: null + }, + ack => { + if (ack.err) { + rej(new Error(`Error initializing follows: ${ack.err}`)) + } else { + res() + } + } + ) + }) + return ack.sea.pub } else { throw new Error('Unknown error.') @@ -277,6 +301,25 @@ const authenticate = async (alias, pass, __user) => { `Tried to re-authenticate with an alias different to that of stored one, tried: ${alias} - stored: ${_currentAlias}, logoff first if need to change aliases.` ) } + + // clock skew + await new Promise(res => setTimeout(res, 2000)) + + await new Promise((res, rej) => { + _user.get(Key.FOLLOWS).put( + { + unused: null + }, + ack => { + if (ack.err) { + rej(new Error(`Error initializing follows: ${ack.err}`)) + } else { + res() + } + } + ) + }) + // move this to a subscription; implement off() ? todo API.Jobs.onAcceptedRequests(_user, mySEA) API.Jobs.onOrders(_user, gun, mySEA) @@ -310,6 +353,21 @@ const authenticate = async (alias, pass, __user) => { await new Promise(res => setTimeout(res, 5000)) + await new Promise((res, rej) => { + _user.get(Key.FOLLOWS).put( + { + unused: null + }, + ack => { + if (ack.err) { + rej(new Error(`Error initializing follows: ${ack.err}`)) + } else { + res() + } + } + ) + }) + API.Jobs.onAcceptedRequests(_user, mySEA) API.Jobs.onOrders(_user, gun, mySEA) API.Jobs.lastSeenNode(_user) @@ -328,6 +386,17 @@ const logoff = () => { } const instantiateGun = () => { + if (user) { + user.leave() + } + // @ts-ignore + user = null + if (gun) { + gun.off() + } + // @ts-ignore + gun = null + const _gun = /** @type {unknown} */ (new Gun({ axe: false, peers: Config.PEERS @@ -1217,7 +1286,7 @@ const register = async (alias, pass) => { if (typeof ack.err === 'string') { throw new Error(ack.err) - } else if (typeof ack.pub === 'string') { + } else if (typeof ack.pub === 'string' || typeof user._.sea === 'object') { const mySecret = await mySEA.secret(user._.sea.epub, user._.sea) _currentAlias = alias _currentPass = await mySEA.encrypt(pass, mySecret) diff --git a/services/gunDB/contact-api/SimpleGUN.ts b/services/gunDB/contact-api/SimpleGUN.ts index 25f9e8ba..eb5e4d11 100644 --- a/services/gunDB/contact-api/SimpleGUN.ts +++ b/services/gunDB/contact-api/SimpleGUN.ts @@ -38,6 +38,9 @@ export interface Soul { export type OpenListenerData = Primitive | null | OpenListenerDataObj export type OpenListener = (data: OpenListenerData, key: string) => void +export type LoadListenerData = OpenListenerData +export type LoadListener = (data: LoadListenerData, key: string) => void + export interface GUNNodeBase { _: Soul @@ -47,6 +50,9 @@ export interface GUNNodeBase { once(this: GUNNode, cb?: Listener): GUNNode open(this: GUNNode, cb?: OpenListener): GUNNode + load(this: GUNNode, cb?: OpenListener): GUNNode + + load(this: GUNNode, cb?: LoadListener): GUNNode off(): void user(): UserGUNNode diff --git a/services/gunDB/contact-api/actions.js b/services/gunDB/contact-api/actions.js index b4fe5bed..1998d24a 100644 --- a/services/gunDB/contact-api/actions.js +++ b/services/gunDB/contact-api/actions.js @@ -3,7 +3,8 @@ */ const uuidv1 = require('uuid/v1') const logger = require('winston') -const { Constants, Schema } = require('shock-common') +const Common = require('shock-common') +const { Constants, Schema } = Common const { ErrorCode } = Constants @@ -1133,7 +1134,21 @@ const setBio = (bio, user) => resolve() } }) - }) + }).then( + () => + new Promise((resolve, reject) => { + user + .get(Key.PROFILE) + .get(Key.BIO) + .put(bio, ack => { + if (ack.err) { + reject(new Error(ack.err)) + } else { + resolve() + } + }) + }) + ) /** * @param {string[]} mnemonicPhrase @@ -1156,7 +1171,7 @@ const saveSeedBackup = async (mnemonicPhrase, user, SEA) => { return new Promise((res, rej) => { user.get(Key.SEED_BACKUP).put(encryptedSeed, ack => { if (ack.err) { - rej(ack.err) + rej(new Error(ack.err)) } else { res() } @@ -1179,7 +1194,7 @@ const saveChannelsBackup = async (backups, user, SEA) => { return new Promise((res, rej) => { user.get(Key.CHANNELS_BACKUP).put(encryptBackups, ack => { if (ack.err) { - rej(ack.err) + rej(new Error(ack.err)) } else { res() } @@ -1216,6 +1231,275 @@ const setLastSeenApp = () => res() } }) + }).then( + () => + new Promise((res, rej) => { + require('../Mediator') + .getUser() + .get(Key.PROFILE) + .get(Key.LAST_SEEN_APP) + .put(Date.now(), ack => { + if (ack.err) { + rej(new Error(ack.err)) + } else { + res() + } + }) + }) + ) + +/** + * @param {string[]} tags + * @param {string} title + * @param {Common.Schema.ContentItem[]} content + * @returns {Promise} + */ +const createPost = async (tags, title, content) => { + if (content.length === 0) { + throw new Error(`A post must contain at least one paragraph/image/video`) + } + + const numOfPages = await (async () => { + const maybeNumOfPages = await Utils.tryAndWait( + (_, user) => + user + .get(Key.WALL) + .get(Key.NUM_OF_PAGES) + .then(), + v => typeof v !== 'number' + ) + + return typeof maybeNumOfPages === 'number' ? maybeNumOfPages : 0 + })() + + let pageIdx = Math.max(0, numOfPages - 1).toString() + + const count = await (async () => { + if (numOfPages === 0) { + return 0 + } + + const maybeCount = await Utils.tryAndWait( + (_, user) => + user + .get(Key.WALL) + .get(Key.PAGES) + .get(pageIdx) + .get(Key.COUNT) + .then(), + v => typeof v !== 'number' + ) + + return typeof maybeCount === 'number' ? maybeCount : 0 + })() + + const shouldBeNewPage = + count >= Common.Constants.Misc.NUM_OF_POSTS_PER_WALL_PAGE + + if (shouldBeNewPage) { + pageIdx = Number(pageIdx + 1).toString() + } + + await new Promise((res, rej) => { + require('../Mediator') + .getUser() + .get(Key.WALL) + .get(Key.PAGES) + .get(pageIdx) + .put( + { + [Key.COUNT]: shouldBeNewPage ? 1 : count + 1, + posts: { + unused: null + } + }, + ack => { + if (ack.err) { + rej(new Error(ack.err)) + } + + res() + } + ) + }) + + /** @type {string} */ + const postID = await new Promise((res, rej) => { + const _n = require('../Mediator') + .getUser() + .get(Key.WALL) + .get(Key.PAGES) + .get(pageIdx) + .get(Key.POSTS) + .set( + { + date: Date.now(), + status: 'publish', + tags: tags.join('-'), + title + }, + ack => { + if (ack.err) { + rej(new Error(ack.err)) + } else { + res(_n._.get) + } + } + ) + }) + + if (shouldBeNewPage || numOfPages === 0) { + await new Promise(res => { + require('../Mediator') + .getUser() + .get(Key.WALL) + .get(Key.NUM_OF_PAGES) + .put(numOfPages + 1, ack => { + if (ack.err) { + throw new Error(ack.err) + } + + res() + }) + }) + } + + const contentItems = require('../Mediator') + .getUser() + .get(Key.WALL) + .get(Key.PAGES) + .get(pageIdx) + .get(Key.POSTS) + .get(postID) + .get(Key.CONTENT_ITEMS) + + try { + await Promise.all( + content.map( + ci => + new Promise(res => { + // @ts-ignore + contentItems.set(ci, ack => { + if (ack.err) { + throw new Error(ack.err) + } + + res() + }) + }) + ) + ) + } catch (e) { + await new Promise(res => { + require('../Mediator') + .getUser() + .get(Key.WALL) + .get(Key.PAGES) + .get(pageIdx) + .get(Key.POSTS) + .get(postID) + .put(null, ack => { + if (ack.err) { + throw new Error(ack.err) + } + + res() + }) + }) + + throw e + } + + const loadedPost = await new Promise(res => { + require('../Mediator') + .getUser() + .get(Key.WALL) + .get(Key.PAGES) + .get(pageIdx) + .get(Key.POSTS) + .get(postID) + .load(data => { + res(data) + }) + }) + + /** @type {Common.Schema.User} */ + const userForPost = await Getters.getMyUser() + + /** @type {Common.Schema.Post} */ + const completePost = { + ...loadedPost, + author: userForPost, + id: postID + } + + if (!Common.Schema.isPost(completePost)) { + throw new Error( + `completePost not a Post inside Actions.createPost(): ${JSON.stringify( + createPost + )}` + ) + } + + return completePost +} + +/** + * @param {string} postId + * @returns {Promise} + */ +const deletePost = async postId => { + await new Promise(res => { + res(postId) + }) +} + +/** + * @param {string} publicKey + * @param {boolean} isPrivate Will overwrite previous private status. + * @returns {Promise} + */ +const follow = (publicKey, isPrivate) => { + /** @type {import('shock-common').Schema.Follow} */ + const newFollow = { + private: isPrivate, + status: 'ok', + user: publicKey + } + + return new Promise((res, rej) => { + require('../Mediator') + .getUser() + .get(Key.FOLLOWS) + .get(publicKey) + // @ts-ignore + .put(newFollow, ack => { + if (ack.err) { + rej(new Error(ack.err)) + } else { + res() + } + }) + }) +} + +/** + * @param {string} publicKey + * @returns {Promise} + */ +const unfollow = publicKey => + new Promise((res, rej) => { + require('../Mediator') + .getUser() + .get(Key.FOLLOWS) + .get(publicKey) + .put(null, ack => { + if (ack.err) { + rej(new Error(ack.err)) + } else { + res() + } + }) }) module.exports = { @@ -1236,5 +1520,9 @@ module.exports = { saveSeedBackup, saveChannelsBackup, disconnect, - setLastSeenApp + setLastSeenApp, + createPost, + deletePost, + follow, + unfollow } diff --git a/services/gunDB/contact-api/getters.js b/services/gunDB/contact-api/getters.js deleted file mode 100644 index cc1104f7..00000000 --- a/services/gunDB/contact-api/getters.js +++ /dev/null @@ -1,29 +0,0 @@ -const Key = require('./key') -const Utils = require('./utils') - -/** - * @param {string} pub - * @returns {Promise} - */ -exports.currentOrderAddress = async (pub) => { - const currAddr = await Utils.tryAndWait((gun) => gun.user(pub).get(Key.CURRENT_ORDER_ADDRESS).then()) - - if (typeof currAddr !== 'string') { - throw new TypeError('Expected user.currentOrderAddress to be an string') - } - - return currAddr -} - -/** - * @param {string} pub - * @returns {Promise} - */ -exports.userToIncomingID = async (pub) => { - const incomingID = await require('../Mediator').getUser().get(Key.USER_TO_INCOMING).get(pub).then() - - if (typeof incomingID === 'string') return incomingID - - return null - -} \ No newline at end of file diff --git a/services/gunDB/contact-api/getters/feed.js b/services/gunDB/contact-api/getters/feed.js new file mode 100644 index 00000000..e0cce941 --- /dev/null +++ b/services/gunDB/contact-api/getters/feed.js @@ -0,0 +1,79 @@ +/** + * @format + */ +const Common = require('shock-common') +const isFinite = require('lodash/isFinite') +const shuffle = require('lodash/shuffle') +const R = require('ramda') + +const Follows = require('./follows') +const Wall = require('./wall') + +/** + * @param {number} numberOfPublicKeyGroups + * @param {number} pageRequested + * @returns {[ number , number ]} + */ +const calculateFeedPage = (numberOfPublicKeyGroups, pageRequested) => { + // thanks to sebassdc + + return [ + (pageRequested - 1) % numberOfPublicKeyGroups, + Math.ceil(pageRequested / numberOfPublicKeyGroups) + ] +} + +/** + * @param {number} page + * @throws {TypeError} + * @throws {RangeError} + * @returns {Promise} + */ +const getFeedPage = async page => { + if (!isFinite(page)) { + throw new TypeError(`Please provide an actual number for [page]`) + } + + if (page <= 0) { + throw new RangeError(`Please provide only positive numbers for [page]`) + } + + const subbedPublicKeys = Object.values(await Follows.currentFollows()).map( + f => f.user + ) + + if (subbedPublicKeys.length === 0) { + return [] + } + + // say there are 20 public keys total + // page 1: page 1 from first 10 public keys + // page 2: page 1 from second 10 public keys + // page 3: page 2 from first 10 public keys + // page 4: page 2 from first 10 public keys + // etc + // thanks to sebassdc (github) + + const pagedPublicKeys = R.splitEvery(10, shuffle(subbedPublicKeys)) + + const [publicKeyGroupIdx, pageToRequest] = calculateFeedPage( + pagedPublicKeys.length, + page + ) + + const publicKeys = pagedPublicKeys[publicKeyGroupIdx] + + const fetchedPages = await Promise.all( + publicKeys.map(pk => Wall.getWallPage(pageToRequest, pk)) + ) + + const fetchedPostsGroups = fetchedPages.map(wp => Object.values(wp.posts)) + const fetchedPosts = R.flatten(fetchedPostsGroups) + const sortered = R.sort((a, b) => b.date - a.date, fetchedPosts) + + return sortered +} + +module.exports = { + getFeedPage +} diff --git a/services/gunDB/contact-api/getters/follows.js b/services/gunDB/contact-api/getters/follows.js new file mode 100644 index 00000000..22a49333 --- /dev/null +++ b/services/gunDB/contact-api/getters/follows.js @@ -0,0 +1,59 @@ +/** + * @format + */ +const Common = require('shock-common') +const Logger = require('winston') +const size = require('lodash/size') + +const Utils = require('../utils') +const Key = require('../key') + +/** + * @typedef {Common.Schema.Follow} Follow + */ + +/** + * @throws {TypeError} + * @returns {Promise>} + */ +exports.currentFollows = async () => { + /** + * @type {Record} + */ + const raw = await Utils.tryAndWait( + // @ts-ignore + (_, user) => new Promise(res => user.get(Key.FOLLOWS).load(res)), + v => { + if (typeof v !== 'object' || v === null) { + return true + } + + if (size(v) === 0) { + return true + } + + return false + } + ) + + if (typeof raw !== 'object' || raw === null) { + Logger.error( + `Expected user.follows to be an object but instead got: ${JSON.stringify( + raw + )}` + ) + throw new TypeError('Could not get follows, not an object') + } + + const clean = { + ...raw + } + + for (const [key, followOrNull] of Object.entries(clean)) { + if (!Common.Schema.isFollow(followOrNull)) { + delete clean[key] + } + } + + return clean +} diff --git a/services/gunDB/contact-api/getters/index.js b/services/gunDB/contact-api/getters/index.js new file mode 100644 index 00000000..3a82e410 --- /dev/null +++ b/services/gunDB/contact-api/getters/index.js @@ -0,0 +1,101 @@ +/** + * @format + */ +const Common = require('shock-common') + +const Key = require('../key') +const Utils = require('../utils') + +const Wall = require('./wall') +const Feed = require('./feed') +const User = require('./user') + +/** + * @param {string} pub + * @returns {Promise} + */ +exports.currentOrderAddress = async pub => { + const currAddr = await Utils.tryAndWait(gun => + gun + .user(pub) + .get(Key.CURRENT_ORDER_ADDRESS) + .then() + ) + + if (typeof currAddr !== 'string') { + throw new TypeError('Expected user.currentOrderAddress to be an string') + } + + return currAddr +} + +/** + * @param {string} pub + * @returns {Promise} + */ +exports.userToIncomingID = async pub => { + const incomingID = await require('../../Mediator') + .getUser() + .get(Key.USER_TO_INCOMING) + .get(pub) + .then() + + if (typeof incomingID === 'string') return incomingID + + return null +} + +/** + * @returns {Promise} + */ +const getMyUser = async () => { + const oldProfile = await Utils.tryAndWait( + (_, user) => new Promise(res => user.get(Key.PROFILE).load(res)), + v => typeof v !== 'object' + ) + + const bio = await Utils.tryAndWait( + (_, user) => user.get(Key.BIO).then(), + v => typeof v !== 'string' + ) + + const lastSeenApp = await Utils.tryAndWait( + (_, user) => user.get(Key.LAST_SEEN_APP).then(), + v => typeof v !== 'number' + ) + + const lastSeenNode = await Utils.tryAndWait( + (_, user) => user.get(Key.LAST_SEEN_NODE).then(), + v => typeof v !== 'number' + ) + + const publicKey = await Utils.tryAndWait( + (_, user) => Promise.resolve(user.is && user.is.pub), + v => typeof v !== 'string' + ) + + /** @type {Common.SchemaTypes.User} */ + const u = { + avatar: oldProfile.avatar, + // @ts-ignore + bio, + displayName: oldProfile.displayName, + // @ts-ignore + lastSeenApp, + // @ts-ignore + lastSeenNode, + // @ts-ignore + publicKey + } + + return u +} + +module.exports.getMyUser = getMyUser +module.exports.Follows = require('./follows') + +module.exports.getWallPage = Wall.getWallPage +module.exports.getWallTotalPages = Wall.getWallTotalPages + +module.exports.getFeedPage = Feed.getFeedPage +module.exports.getAnUser = User.getAnUser diff --git a/services/gunDB/contact-api/getters/user.js b/services/gunDB/contact-api/getters/user.js new file mode 100644 index 00000000..fdcd7dfb --- /dev/null +++ b/services/gunDB/contact-api/getters/user.js @@ -0,0 +1,111 @@ +/** + * @format + */ +const Common = require('shock-common') + +const Key = require('../key') +const Utils = require('../utils') + +/** + * @param {string} publicKey + * @returns {Promise} + */ +const getAnUser = async publicKey => { + const oldProfile = await Utils.tryAndWait( + g => { + const user = g.get(`~${publicKey}`) + + return new Promise(res => user.get(Key.PROFILE).load(res)) + }, + v => typeof v !== 'object' + ) + + const bio = await Utils.tryAndWait( + g => + g + .get(`~${publicKey}`) + .get(Key.BIO) + .then(), + v => typeof v !== 'string' + ) + + const lastSeenApp = await Utils.tryAndWait( + g => + g + .get(`~${publicKey}`) + .get(Key.LAST_SEEN_APP) + .then(), + v => typeof v !== 'number' + ) + + const lastSeenNode = await Utils.tryAndWait( + (_, user) => user.get(Key.LAST_SEEN_NODE).then(), + v => typeof v !== 'number' + ) + + /** @type {Common.SchemaTypes.User} */ + const u = { + avatar: oldProfile.avatar, + // @ts-ignore + bio, + displayName: oldProfile.displayName, + // @ts-ignore + lastSeenApp, + // @ts-ignore + lastSeenNode, + // @ts-ignore + publicKey + } + + return u +} + +module.exports.getAnUser = getAnUser + +/** + * @returns {Promise} + */ +const getMyUser = async () => { + const oldProfile = await Utils.tryAndWait( + (_, user) => new Promise(res => user.get(Key.PROFILE).load(res)), + v => typeof v !== 'object' + ) + + const bio = await Utils.tryAndWait( + (_, user) => user.get(Key.BIO).then(), + v => typeof v !== 'string' + ) + + const lastSeenApp = await Utils.tryAndWait( + (_, user) => user.get(Key.LAST_SEEN_APP).then(), + v => typeof v !== 'number' + ) + + const lastSeenNode = await Utils.tryAndWait( + (_, user) => user.get(Key.LAST_SEEN_NODE).then(), + v => typeof v !== 'number' + ) + + const publicKey = await Utils.tryAndWait( + (_, user) => Promise.resolve(user.is && user.is.pub), + v => typeof v !== 'string' + ) + + /** @type {Common.SchemaTypes.User} */ + const u = { + avatar: oldProfile.avatar, + // @ts-ignore + bio, + displayName: oldProfile.displayName, + // @ts-ignore + lastSeenApp, + // @ts-ignore + lastSeenNode, + // @ts-ignore + publicKey + } + + return u +} + +module.exports.getMyUser = getMyUser diff --git a/services/gunDB/contact-api/getters/wall.js b/services/gunDB/contact-api/getters/wall.js new file mode 100644 index 00000000..f639780e --- /dev/null +++ b/services/gunDB/contact-api/getters/wall.js @@ -0,0 +1,131 @@ +/** + * @format + */ +const Common = require('shock-common') + +const Utils = require('../utils') +const Key = require('../key') + +const Wall = require('./user') + +/** + * @param {string=} publicKey + * @returns {Promise} + */ +const getWallTotalPages = async publicKey => { + const totalPages = await Utils.tryAndWait( + (gun, u) => { + const user = publicKey ? gun.get(`~${publicKey}`) : u + + return user + .get(Key.WALL) + .get(Key.NUM_OF_PAGES) + .then() + }, + v => typeof v !== 'number' + ) + + return typeof totalPages === 'number' ? totalPages : 0 +} + +/** + * @param {number} page + * @param {string=} publicKey + * @throws {TypeError} + * @throws {RangeError} + * @returns {Promise} + */ +const getWallPage = async (page, publicKey) => { + const totalPages = await getWallTotalPages(publicKey) + + if (page === 0 || totalPages === 0) { + return { + count: 0, + posts: {} + } + } + + const actualPageIdx = page < 0 ? totalPages + page : page - 1 + + if (actualPageIdx > totalPages - 1) { + throw new RangeError(`Requested a page out of bounds`) + } + + /** + * @type {Common.SchemaTypes.WallPage} + */ + const thePage = await Utils.tryAndWait( + (g, u) => { + const user = publicKey ? g.get(`~${publicKey}`) : u + + return new Promise(res => { + user + .get(Key.WALL) + .get(Key.PAGES) + .get(actualPageIdx.toString()) + // @ts-ignore + .load(res) + }) + }, + maybePage => { + if (typeof maybePage !== 'object' || maybePage === null) { + return true + } + + const clean = { + ...maybePage + } + + // @ts-ignore + for (const [key, post] of Object.entries(clean.posts)) { + // delete unsuccessful writes + if (post === null) { + // @ts-ignore + delete clean.posts[key] + } else { + post.id = key + } + } + + // .load() sometimes doesn't load all data on first call + // @ts-ignore + if (Object.keys(clean.posts).length === 0) { + return true + } + + return !Common.Schema.isWallPage(clean) + } + ) + + const clean = { + ...thePage + } + + for (const [key, post] of Object.entries(clean.posts)) { + // delete unsuccessful writes + if (post === null) { + delete clean.posts[key] + clean.count-- + } else { + post.author = publicKey + ? // eslint-disable-next-line no-await-in-loop + await Wall.getAnUser(publicKey) + : // eslint-disable-next-line no-await-in-loop + await Wall.getMyUser() + post.id = key + } + } + + if (!Common.Schema.isWallPage(clean)) { + throw new Error( + `Fetched page not a wall page, instead got: ${JSON.stringify(clean)}` + ) + } + + return clean +} + +module.exports = { + getWallTotalPages, + getWallPage +} diff --git a/services/gunDB/contact-api/jobs/lastSeenNode.js b/services/gunDB/contact-api/jobs/lastSeenNode.js index 002a167c..728f933d 100644 --- a/services/gunDB/contact-api/jobs/lastSeenNode.js +++ b/services/gunDB/contact-api/jobs/lastSeenNode.js @@ -37,6 +37,15 @@ const lastSeenNode = user => { logger.error(`Error inside lastSeenNode job: ${ack.err}`) } }) + + user + .get(Key.PROFILE) + .get(Key.LAST_SEEN_NODE) + .put(Date.now(), ack => { + if (ack.err) { + logger.error(`Error inside lastSeenNode job: ${ack.err}`) + } + }) } }, LAST_SEEN_NODE_INTERVAL) } diff --git a/services/gunDB/contact-api/key.js b/services/gunDB/contact-api/key.js index be14b788..0c130712 100644 --- a/services/gunDB/contact-api/key.js +++ b/services/gunDB/contact-api/key.js @@ -42,3 +42,16 @@ exports.CHANNELS_BACKUP = 'channelsBackup' exports.LAST_SEEN_APP = 'lastSeenApp' exports.LAST_SEEN_NODE = 'lastSeenNode' + +exports.WALL = 'wall' + +exports.NUM_OF_PAGES = 'numOfPages' + +exports.PAGES = 'pages' + +exports.COUNT = 'count' + +exports.CONTENT_ITEMS = 'contentItems' +exports.FOLLOWS = 'follows' + +exports.POSTS = 'posts' diff --git a/src/routes.js b/src/routes.js index cd72d2b4..2045913d 100644 --- a/src/routes.js +++ b/src/routes.js @@ -12,7 +12,7 @@ const httpsAgent = require("https"); const responseTime = require("response-time"); const uuid = require("uuid/v4"); const Common = require('shock-common') - +const isARealUsableNumber = require('lodash/isFinite') const getListPage = require("../utils/paginate"); const auth = require("../services/auth/auth"); @@ -22,6 +22,7 @@ const LightningServices = require("../utils/lightningServices"); const GunDB = require("../services/gunDB/Mediator"); const { unprotectedRoutes, nonEncryptedRoutes } = require("../utils/protectedRoutes"); const GunActions = require("../services/gunDB/contact-api/actions") +const GunGetters = require('../services/gunDB/contact-api/getters') const DEFAULT_MAX_NUM_ROUTES_TO_QUERY = 10; const SESSION_ID = uuid(); @@ -33,6 +34,8 @@ module.exports = async ( mySocketsEvents, { serverPort, CA, CA_KEY, usetls } ) => { + const {timeout5} = require('../services/gunDB/contact-api/utils') + const Http = Axios.create({ httpsAgent: new httpsAgent.Agent({ ca: await FS.readFile(CA) @@ -227,7 +230,7 @@ module.exports = async ( const deviceId = req.headers["x-shockwallet-device-id"]; logger.debug("Decrypting route...") try { - if (nonEncryptedRoutes.includes(req.path)) { + if (nonEncryptedRoutes.includes(req.path) || process.env.DISABLE_SHOCK_ENCRYPTION === "true") { return next(); } @@ -249,7 +252,7 @@ module.exports = async ( return res.status(401).json(error); } - if (req.method === "GET") { + if (req.method === "GET" || req.method === "DELETE" || !req.body.encryptionKey && !req.body.iv) { return next(); } @@ -433,6 +436,76 @@ module.exports = async ( GunActions.saveChannelsBackup(JSON.stringify(channelBackups),user,SEA) }); + + /* + const feedObj = { + feed: [ + { + id:'bd7acbea-c1b1-46c2-aed5-3ad53abb28ba', + paragraphs:[ + "SOme text and stuff 12" + "SOme text and stuff" + ], + profilePic:"", + username:"bobni", + media:[ + { + type:'VIDEO', + ratio_x: 1024, + ratio_y: 436, + magnetUri:'magnet:?xt=urn:btih:08ada5a7a6183aae1e09d831df6748d566095a10&dn=Sintel&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fsintel.torrent', + }, + ] + }, + { + id:'3ac68afc-c605-48d3-a4f8-fbd91aa97f63', + paragraphs:[ + "SOme text and stuff" + ], + profilePic:"", + username:"bobni", + media:[ + { + type:'VIDEO', + ratio_x: 1920, + ratio_y: 804, + magnetUri:'magnet:?xt=urn:btih:c9e15763f722f23e98a29decdfae341b98d53056&dn=Cosmos+Laundromat&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fcosmos-laundromat.torrent', + }, + ] + }, + { + id:'58694a0f-3da1-471f-bd96-145571e29d72', + paragraphs:[ + "SOme text and stuff" + ], + profilePic:"", + username:"bobni", + media:[ + { + type:'VIDEO', + ratio_x: 1920, + ratio_y: 1080, + magnetUri:'magnet:?xt=urn:btih:dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c&dn=Big+Buck+Bunny&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fbig-buck-bunny.torrent', + }, + ] + } + ] + } + user.get("FEED_POC").put(JSON.stringify(feedObj), ack => { + if (ack.err) { + //rej(new Error(ack.err)) + }*/ + const feedObj = { + feed :{} + } + user.get("FEED_POC").put(feedObj, ack => { + if (ack.err) { + //rej(ack.err) + logger.log(ack.err) + } else { + logger.log(ack.err) + } + }) //register to listen for channel backups const onNewChannelBackup = () => { @@ -1712,11 +1785,9 @@ module.exports = async ( res.json(channelBackups); }); }); - + const GunEvent = Common.Constants.Event const Key = require('../services/gunDB/contact-api/key') - const { timeout5 } = require('../services/gunDB/contact-api/utils') - app.get("/api/gun/lndchanbackups", async (req,res) => { try{ const user = require('../services/gunDB/Mediator').getUser() @@ -1731,9 +1802,21 @@ module.exports = async ( res.json({ok:"err"}) } }) + app.get("/api/gun/feedpoc", async (req,res) =>{ + try{ + logger.warn("FEED POC") + const user = require('../services/gunDB/Mediator').getUser() + const feedObj = await timeout5(user.get("FEED_POC").then()) + logger.warn(feedObj) + + res.json({data:feedObj}) + } catch (err) { + //res.json({ok:"err"}) + } + }) const Events = require('../services/gunDB/contact-api/events') - + app.get(`/api/gun/${GunEvent.ON_RECEIVED_REQUESTS}`, (_, res) => { try { // spinup @@ -1854,6 +1937,169 @@ module.exports = async ( }) } }) +//////////////////////////////////////////////////////////////////////////////// + + app.get(`/api/gun/wall/:publicKey?`, async (req, res) => { + try { + const { page } = req.query; + const {publicKey} = req.params + + const pageNum = Number(page) + + if (!isARealUsableNumber(pageNum)) { + return res.status(400).json({ + field: 'page', + errorMessage: 'Not a number' + }) + } + + const totalPages = await GunGetters.getWallTotalPages(publicKey) + const fetchedPage = await GunGetters.getWallPage(pageNum, publicKey) + + return res.status(200).json({ + ...fetchedPage, + totalPages, + }) + } catch (err) { + return res.status(500).json({ + errorMessage: err.message + }) + } + }) + + app.post(`/api/gun/wall/`, async (req,res) => { + try{ + const {tags,title,contentItems} = req.body + return res.status(200).json(await GunActions.createPost( + tags, + title, + contentItems + )) + } catch(e) { + return res.status(500).json({ + errorMessage: (typeof e === 'string' ? e : e.message) + || 'Unknown error.' + }) + } + }) + + app.delete(`/api/gun/wall/:postID`, (_, res) => res.status(200).json({ + ok: 'true' + })) + ///////////////////////////////// + /** + * @template P + * @typedef {import('express-serve-static-core').RequestHandler

} RequestHandler + */ + + + const ap = /** @type {Application} */ (app); + + /** + * @typedef {object} FollowsRouteParams + * @prop {(string|undefined)=} publicKey + */ + + + /** + * @type {RequestHandler} + */ + const apiGunFollowsGet = async (_, res) => { + try { + const currFollows = await GunGetters.Follows.currentFollows() + + return res.status(200).json(currFollows) + } catch (err) { + return res.status(500).json({ + errorMessage: err.message || 'Unknown ERR at GET /api/follows' + }) + } + } + + + /** + * @type {RequestHandler} + */ + const apiGunFollowsPut = async (req, res) => { + try { + const { publicKey } = req.params; + if (!publicKey) { + throw new Error(`Missing publicKey route param.`) + } + + await GunActions.follow(req.params.publicKey, false) + + // 201 would be extraneous here. Implement it inside app.put + return res.status(200).json({ + ok: true + }) + } catch (err) { + return res.status(500).json({ + errorMessage: err.message || 'Unknown error inside /api/gun/follows/' + }) + } + } + + /** + * @type {RequestHandler} + */ + const apiGunFollowsDelete = async (req, res) => { + try { + const { publicKey } = req.params; + if (!publicKey) { + throw new Error(`Missing publicKey route param.`) + } + + await GunActions.unfollow(req.params.publicKey) + + return res.status(200).json({ + ok: true + }) + } catch (err) { + return res.status(500).json({ + errorMessage: err.message || 'Unknown error inside /api/gun/follows/' + }) + } + } + + ap.get('/api/gun/follows/', apiGunFollowsGet) + ap.get('/api/gun/follows/:publicKey', apiGunFollowsGet) + ap.put(`/api/gun/follows/:publicKey`,apiGunFollowsPut) + ap.delete(`/api/gun/follows/:publicKey`, apiGunFollowsDelete) + + /** + * @type {RequestHandler<{}>} + */ + const apiGunFeedGet = async (req, res) => { + try { + const { page: pageStr } = req.query; + const page = Number(pageStr) + + if (!isARealUsableNumber(page)) { + return res.status(400).json({ + field: 'page', + errorMessage: 'page must be a number' + }) + } + + if (page < 1) { + return res.status(400).json({ + field: page, + errorMessage: 'page must be a positive number' + }) + } + + return res.status(200).json({ + posts: await GunGetters.getFeedPage(page) + }) + } catch (err) { + return res.status(500).json({ + errorMessage: err.message || 'Unknown error inside /api/gun/follows/' + }) + } + } + + ap.get(`/api/gun/feed`, apiGunFeedGet) /** * Return app so that it can be used by express. diff --git a/src/server.js b/src/server.js index c55393d6..20b4b3c7 100644 --- a/src/server.js +++ b/src/server.js @@ -9,6 +9,7 @@ const server = program => { const Http = require('http') const Express = require('express') const Crypto = require('crypto') + const Dotenv = require('dotenv') const LightningServices = require('../utils/lightningServices') const Encryption = require('../utils/encryptionStore') const app = Express() @@ -25,6 +26,7 @@ const server = program => { // load app default configuration data const defaults = require('../config/defaults')(program.mainnet) // define useful global variables ====================================== + Dotenv.config() module.useTLS = program.usetls module.serverPort = program.serverport || defaults.serverPort module.httpsPort = module.serverPort @@ -41,6 +43,12 @@ const server = program => { logger.info('Mainnet Mode:', !!program.mainnet) + if (process.env.DISABLE_SHOCK_ENCRYPTION === 'true') { + logger.error('Encryption Mode: false') + } else { + logger.info('Encryption Mode: true') + } + const stringifyData = data => { if (typeof data === 'object') { const stringifiedData = JSON.stringify(data) @@ -60,6 +68,46 @@ const server = program => { .digest('hex') } + const cacheCheck = ({ req, res, args, send }) => { + if ( + (process.env.SHOCK_CACHE === 'true' || !process.env.SHOCK_CACHE) && + req.method === 'GET' + ) { + const dataHash = hashData(args[0]).slice(-8) + res.set('shock-cache-hash', dataHash) + + logger.debug('shock-cache-hash:', req.headers['shock-cache-hash']) + logger.debug('Data Hash:', dataHash) + if ( + !req.headers['shock-cache-hash'] && + (process.env.CACHE_HEADERS_MANDATORY === 'true' || + !process.env.CACHE_HEADERS_MANDATORY) + ) { + logger.warn( + "Request is missing 'shock-cache-hash' header, please make sure to include that in each GET request in order to benefit from reduced data usage" + ) + return { cached: false, hash: dataHash } + } + + if (req.headers['shock-cache-hash'] === dataHash) { + logger.debug('Same Hash Detected!') + args[0] = null + res.status(304) + send.apply(res, args) + return { cached: true, hash: dataHash } + } + + return { cached: false, hash: dataHash } + } + + return { cached: false, hash: null } + } + + /** + * @param {Express.Request} req + * @param {Express.Response} res + * @param {(() => void)} next + */ const modifyResponseBody = (req, res, next) => { const deviceId = req.headers['x-shockwallet-device-id'] const oldSend = res.send @@ -72,16 +120,9 @@ const server = program => { return } - const dataHash = hashData(args[0]).slice(-8) - res.set('shock-cache-hash', dataHash) + const { cached, hash } = cacheCheck({ req, res, args, send: oldSend }) - logger.debug('shock-cache-hash:', req.headers['shock-cache-hash']) - logger.debug('Data Hash:', dataHash) - if (req.headers['shock-cache-hash'] === dataHash) { - logger.debug('Same Hash Detected!') - args[0] = null - res.status(304) - oldSend.apply(res, args) + if (cached) { return } @@ -89,10 +130,10 @@ const server = program => { const authorized = Encryption.isAuthorizedDevice({ deviceId }) const encryptedMessage = authorized ? Encryption.encryptMessage({ - message: args[0], + message: args[0] ? args[0] : {}, deviceId, metadata: { - hash: dataHash + hash } }) : args[0] @@ -249,7 +290,9 @@ const server = program => { // app.use(bodyParser.json({limit: '100000mb'})); app.use(bodyParser.json({ limit: '50mb' })) app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })) - app.use(modifyResponseBody) + if (process.env.DISABLE_SHOCK_ENCRYPTION !== 'true') { + app.use(modifyResponseBody) + } serverInstance.listen(module.serverPort, module.serverhost) diff --git a/yarn.lock b/yarn.lock index 343ee681..f9da858b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -633,6 +633,10 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/ramda@types/npm-ramda#dist": + version "0.25.0" + resolved "https://codeload.github.com/types/npm-ramda/tar.gz/9529aa3c8ff70ff84afcbc0be83443c00f30ea90" + "@types/range-parser@*": version "1.2.3" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" @@ -3168,6 +3172,11 @@ ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== +immer@^6.0.6: + version "6.0.9" + resolved "https://registry.yarnpkg.com/immer/-/immer-6.0.9.tgz#b9dd69b8e69b3a12391e87db1e3ff535d1b26485" + integrity sha512-SyCYnAuiRf67Lvk0VkwFvwtDoEiCMjeamnHvRfnVDyc7re1/rQrNxuL+jJ7lA3WvdC4uznrvbmm+clJ9+XXatg== + import-fresh@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.1.0.tgz#6d33fa1dcef6df930fae003446f33415af905118" @@ -5383,6 +5392,11 @@ ramda@^0.26.1: resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== +ramda@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.27.0.tgz#915dc29865c0800bf3f69b8fd6c279898b59de43" + integrity sha512-pVzZdDpWwWqEVVLshWUHjNwuVP7SfcmPraYuqocJp1yo2U1R7P+5QAfDhdItkuoGqIBnBYrtPp7rEPqDn9HlZA== + random-bytes@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" @@ -5500,6 +5514,19 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" +redux-thunk@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== + +redux@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" + integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== + dependencies: + loose-envify "^1.4.0" + symbol-observable "^1.2.0" + regenerator-runtime@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" @@ -5876,13 +5903,17 @@ shellwords@^0.1.1: resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== -shock-common@^0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/shock-common/-/shock-common-0.0.1.tgz#72092c565ab65198da13656b5027fbd44881bf72" - integrity sha512-LV2WiJDr1E6TEWel095oLN6gxpGTmsg6CUeGB6DdLHbYEz0qSpcDG4MYp2mZGpj/DejNKwYg1EiX2qf7ArpIkQ== +shock-common@4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/shock-common/-/shock-common-4.0.1.tgz#035e7081b6e67f6721e68dcc6b4d1e4c8f2cd96d" + integrity sha512-3xAkG8lyfyZHK8trgOy2aN75uG1ZBm0MPoIEzP4hgXhyT/b80WmQzX3DqVSSmjfhq1Di0sjmNCY7O5Nf6cEmFg== dependencies: + immer "^6.0.6" lodash "^4.17.15" normalizr "^3.6.0" + redux "^4.0.5" + redux-thunk "^2.3.0" + uuid "3.x.x" signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.2" @@ -6302,6 +6333,11 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +symbol-observable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== + symbol-tree@^3.2.2: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -6672,6 +6708,11 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= +uuid@3.x.x: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + uuid@^3.3.2: version "3.3.3" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866"