diff --git a/services/gunDB/Mediator/index.js b/services/gunDB/Mediator/index.js index cd15c802..341fcee2 100644 --- a/services/gunDB/Mediator/index.js +++ b/services/gunDB/Mediator/index.js @@ -7,6 +7,8 @@ const logger = require('winston') Gun.log = () => {} // @ts-ignore require('gun/lib/open') +// @ts-ignore +require('gun/lib/load') const debounce = require('lodash/debounce') const Encryption = require('../../../utils/encryptionStore') diff --git a/services/gunDB/contact-api/SimpleGUN.ts b/services/gunDB/contact-api/SimpleGUN.ts index 0929b854..eb5e4d11 100644 --- a/services/gunDB/contact-api/SimpleGUN.ts +++ b/services/gunDB/contact-api/SimpleGUN.ts @@ -50,6 +50,7 @@ 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 diff --git a/services/gunDB/contact-api/actions.js b/services/gunDB/contact-api/actions.js index 54bd41fe..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 @@ -1216,8 +1231,229 @@ 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. @@ -1285,6 +1521,8 @@ module.exports = { saveChannelsBackup, disconnect, setLastSeenApp, + createPost, + deletePost, follow, unfollow } diff --git a/services/gunDB/contact-api/getters/index.js b/services/gunDB/contact-api/getters/index.js index 9b33cb92..2bb192fb 100644 --- a/services/gunDB/contact-api/getters/index.js +++ b/services/gunDB/contact-api/getters/index.js @@ -1,9 +1,13 @@ /** * @format */ +const Common = require('shock-common') + const Key = require('../key') const Utils = require('../utils') +const Wall = require('./wall') + /** * @param {string} pub * @returns {Promise} @@ -39,4 +43,54 @@ exports.userToIncomingID = async pub => { 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 diff --git a/services/gunDB/contact-api/getters/wall.js b/services/gunDB/contact-api/getters/wall.js new file mode 100644 index 00000000..25a0b004 --- /dev/null +++ b/services/gunDB/contact-api/getters/wall.js @@ -0,0 +1,114 @@ +/** + * @format + */ +const Common = require('shock-common') +const Utils = require('../utils') +const Key = require('../key') + +/** + * @returns {Promise} + */ +const getWallTotalPages = async () => { + const totalPages = await Utils.tryAndWait( + (_, user) => + user + .get(Key.WALL) + .get(Key.NUM_OF_PAGES) + .then(), + v => typeof v !== 'number' + ) + + return typeof totalPages === 'number' ? totalPages : 0 +} + +/** + * @param {number} page + * @throws {TypeError} + * @throws {RangeError} + * @returns {Promise} + */ +const getWallPage = async page => { + const totalPages = await getWallTotalPages() + + 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( + (_, user) => + 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] + } else { + 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 837f38e9..0c130712 100644 --- a/services/gunDB/contact-api/key.js +++ b/services/gunDB/contact-api/key.js @@ -43,4 +43,15 @@ 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 e4b59ba4..5c4e99de 100644 --- a/src/routes.js +++ b/src/routes.js @@ -12,6 +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"); @@ -1689,7 +1690,7 @@ module.exports = async ( const GunEvent = Common.Constants.Event const Key = require('../services/gunDB/contact-api/key') - + app.get("/api/gun/lndchanbackups", async (req,res) => { try{ const user = require('../services/gunDB/Mediator').getUser() @@ -1839,7 +1840,54 @@ module.exports = async ( }) } }) +//////////////////////////////////////////////////////////////////////////////// + app.get(`/api/gun/wall`, async (req, res) => { + try { + const { page } = req.query; + + const pageNum = Number(page) + + if (!isARealUsableNumber(pageNum)) { + return res.status(400).json({ + field: 'page', + errorMessage: 'Not a number' + }) + } + + const totalPages = await GunGetters.getWallTotalPages() + const fetchedPage = await GunGetters.getWallPage(pageNum) + + 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