diff --git a/app.js b/app.js index 89d6ab8..7a2003b 100644 --- a/app.js +++ b/app.js @@ -7,8 +7,7 @@ var config = require("config"); log.info("app", "Bootstrapping Dimension..."); var db = new DimensionStore(); db.prepare().then(() => { - var app = new Dimension(db); - app.start(); + Dimension.start(db); if (config.get("demobot.enabled")) { log.info("app", "Demo bot enabled - starting up"); diff --git a/src/Dimension.js b/src/Dimension.js index b9711c1..ae45adf 100644 --- a/src/Dimension.js +++ b/src/Dimension.js @@ -4,29 +4,27 @@ var log = require("./util/LogService"); var DimensionStore = require("./storage/DimensionStore"); var bodyParser = require('body-parser'); var path = require("path"); -var MatrixLiteClient = require("./matrix/MatrixLiteClient"); -var randomString = require("random-string"); -var ScalarClient = require("./scalar/ScalarClient.js"); -var VectorScalarClient = require("./scalar/VectorScalarClient"); -var integrations = require("./integration"); -var _ = require("lodash"); -var UpstreamIntegration = require("./integration/UpstreamIntegration"); -var HostedIntegration = require("./integration/HostedIntegration"); -var IntegrationImpl = require("./integration/impl"); +var DimensionApi = require("./DimensionApi"); +var ScalarApi = require("./ScalarApi"); + +// TODO: Convert backend to typescript? Would avoid stubbing classes all over the place /** * Primary entry point for Dimension */ class Dimension { - // TODO: Spread the app out into other classes - // eg: ScalarApi, DimensionApi, etc - /** * Creates a new Dimension - * @param {DimensionStore} db the storage */ - constructor(db) { + constructor() { + } + + /** + * Starts the Dimension service + * @param {DimensionStore} db the store to use + */ + start(db) { this._db = db; this._app = express(); this._app.use(express.static('web-dist')); @@ -50,122 +48,12 @@ class Dimension { next(); }); - this._app.post("/api/v1/scalar/register", this._scalarRegister.bind(this)); - this._app.get("/api/v1/scalar/checkToken", this._checkScalarToken.bind(this)); + DimensionApi.bootstrap(this._app, this._db); + ScalarApi.bootstrap(this._app, this._db); - this._app.get("/api/v1/dimension/integrations/:roomId", this._getIntegrations.bind(this)); - this._app.post("/api/v1/dimension/removeIntegration", this._removeIntegration.bind(this)); - } - - start() { this._app.listen(config.get('web.port'), config.get('web.address')); log.info("Dimension", "API and UI listening on " + config.get("web.address") + ":" + config.get("web.port")); } - - _removeIntegration(req, res) { - var roomId = req.body.roomId; - var userId = req.body.userId; - var scalarToken = req.body.scalarToken; - - if (!roomId || !userId || !scalarToken) { - res.status(400).send({error: "Missing room, user, or token"}); - return; - } - - var integrationConfig = integrations.byUserId[userId]; - if (!integrationConfig) { - res.status(400).send({error: "Unknown integration"}); - return; - } - - this._db.checkToken(scalarToken).then(() => { - if (integrationConfig.upstream) { - return this._db.getUpstreamToken(scalarToken).then(upstreamToken => new UpstreamIntegration(integrationConfig, upstreamToken)); - } else return new HostedIntegration(integrationConfig); - }).then(integration => integration.leaveRoom(roomId)).then(() => { - res.status(200).send({success: true}); - }).catch(err => res.status(500).send({error: err.message})); - } - - _getIntegrations(req, res) { - res.setHeader("Content-Type", "application/json"); - - var scalarToken = req.query.scalar_token; - this._db.checkToken(scalarToken).then(() => { - var roomId = req.params.roomId; - if (!roomId) { - res.status(400).send({error: 'Missing room ID'}); - return; - } - - var results = _.map(integrations.all, i => JSON.parse(JSON.stringify(i))); - - var promises = []; - _.forEach(results, i => { - if (IntegrationImpl[i.type]) { - var confs = IntegrationImpl[i.type]; - if (confs[i.integrationType]) { - log.info("Dimension", "Using special configuration for " + i.type + " (" + i.integrationType + ")"); - - promises.push(confs[i.integrationType](this._db, i, roomId, scalarToken).then(integration => { - return integration.getUserId().then(userId=> { - i.userId = userId; - return integration.getState(); - }).then(state=> { - for (var key in state) { - i[key] = state[key]; - } - }); - })) - } else log.verbose("Dimension", "No special configuration needs for " + i.type + " (" + i.integrationType + ")"); - } else log.verbose("Dimension", "No special implementation type for " + i.type); - }); - - Promise.all(promises).then(() => res.send(_.map(results, integration => { - integration.upstream = undefined; - integration.hosted = undefined; - return integration; - }))); - }).catch(err => { - log.error("Dimension", err); - res.status(500).send({error: err}); - }); - } - - _checkScalarToken(req, res) { - var token = req.query.scalar_token; - if (!token) res.sendStatus(400); - else this._db.checkToken(token).then(() => { - res.sendStatus(200); - }).catch(() => res.sendStatus(401)); - } - - _scalarRegister(req, res) { - res.setHeader("Content-Type", "application/json"); - - var tokenInfo = req.body; - if (!tokenInfo || !tokenInfo['access_token'] || !tokenInfo['token_type'] || !tokenInfo['matrix_server_name'] || !tokenInfo['expires_in']) { - res.status(400).send({error: 'Missing OpenID'}); - return; - } - - var client = new MatrixLiteClient(tokenInfo); - var scalarToken = randomString({length: 25}); - var userId; - client.getSelfMxid().then(mxid => { - userId = mxid; - if (!mxid) throw new Error("Token does not resolve to a matrix user"); - return ScalarClient.register(tokenInfo); - }).then(upstreamToken => { - return this._db.createToken(userId, tokenInfo, scalarToken, upstreamToken); - }).then(() => { - res.setHeader("Content-Type", "application/json"); - res.send({scalar_token: scalarToken}); - }).catch(err => { - log.error("Dimension", err); - res.status(500).send({error: err.message}); - }); - } } -module.exports = Dimension; \ No newline at end of file +module.exports = new Dimension(); \ No newline at end of file diff --git a/src/DimensionApi.js b/src/DimensionApi.js new file mode 100644 index 0000000..b59336d --- /dev/null +++ b/src/DimensionApi.js @@ -0,0 +1,107 @@ +var IntegrationImpl = require("./integration/impl/index"); +var Integrations = require("./integration/index"); +var _ = require("lodash"); +var log = require("./util/LogService"); + +/** + * API handler for the Dimension API + */ +class DimensionApi { + + /** + * Creates a new Dimension API + */ + constructor() { + } + + /** + * Bootstraps the Dimension API + * @param {*} app the Express application + * @param {DimensionStore} db the store to use + */ + bootstrap(app, db) { + this._db = db; + + app.get("/api/v1/dimension/integrations/:roomId", this._getIntegrations.bind(this)); + app.post("/api/v1/dimension/removeIntegration", this._removeIntegration.bind(this)); + } + + _getIntegration(integrationConfig, roomId, scalarToken) { + var factory = IntegrationImpl.getFactory(integrationConfig); + if (!factory) throw new Error("Missing config factory for " + integrationConfig.name); + + return factory(this._db, integrationConfig, roomId, scalarToken); + } + + _getIntegrations(req, res) { + res.setHeader("Content-Type", "application/json"); + + var roomId = req.params.roomId; + if (!roomId) { + res.status(400).send({error: 'Missing room ID'}); + return; + } + + var scalarToken = req.query.scalar_token; + this._db.checkToken(scalarToken).then(() => { + var integrations = _.map(Integrations.all, i => JSON.parse(JSON.stringify(i))); // clone + + var promises = []; + _.forEach(integrations, integration => { + promises.push(this._getIntegration(integration, roomId, scalarToken).then(builtIntegration => { + return builtIntegration.getUserId().then(userId => { + integration.userId = userId; + return builtIntegration.getState(); + }).then(state => { + var keys = _.keys(state); + for (var key of keys) { + integration[key] = state[key]; + } + }); + })); + }); + + Promise.all(promises).then(() => res.send(_.map(integrations, integration => { + // Remove sensitive material + integration.upstream = undefined; + integration.hosted = undefined; + return integration; + }))); + }).catch(err => { + log.error("DimensionApi", err); + console.error(err); + res.status(500).send({error: err}); + }); + } + + _removeIntegration(req, res) { + var roomId = req.body.roomId; + var userId = req.body.userId; + var scalarToken = req.body.scalarToken; + + if (!roomId || !userId || !scalarToken) { + res.status(400).send({error: "Missing room, user, or token"}); + return; + } + + var integrationConfig = Integrations.byUserId[userId]; + if (!integrationConfig) { + res.status(400).send({error: "Unknown integration"}); + return; + } + + log.info("DimensionApi", "Remove requested for " + userId + " in room " + roomId); + + this._db.checkToken(scalarToken).then(() => { + return this._getIntegration(integrationConfig, roomId, scalarToken); + }).then(integration => integration.removeFromRoom(roomId)).then(() => { + res.status(200).send({success: true}); + }).catch(err => { + log.error("DimensionApi", err); + console.error(err); + res.status(500).send({error: err.message}); + }); + } +} + +module.exports = new DimensionApi(); diff --git a/src/ScalarApi.js b/src/ScalarApi.js new file mode 100644 index 0000000..d41ff1e --- /dev/null +++ b/src/ScalarApi.js @@ -0,0 +1,66 @@ +var MatrixLiteClient = require("./matrix/MatrixLiteClient"); +var randomString = require("random-string"); +var ScalarClient = require("./scalar/ScalarClient.js"); +var _ = require("lodash"); +var log = require("./util/LogService"); + +/** + * API handler for the Scalar API, as required by Riot + */ +class ScalarApi { + + /** + * Creates a new Scalar API + */ + constructor() { + } + + /** + * Bootstraps the Scalar API + * @param {*} app the Express application + * @param {DimensionStore} db the store to use + */ + bootstrap(app, db) { + this._db = db; + + app.post("/api/v1/scalar/register", this._scalarRegister.bind(this)); + app.get("/api/v1/scalar/checkToken", this._checkScalarToken.bind(this)); + } + + _checkScalarToken(req, res) { + var token = req.query.scalar_token; + if (!token) res.sendStatus(400); + else this._db.checkToken(token).then(() => { + res.sendStatus(200); + }).catch(() => res.sendStatus(401)); + } + + _scalarRegister(req, res) { + res.setHeader("Content-Type", "application/json"); + + var tokenInfo = req.body; + if (!tokenInfo || !tokenInfo['access_token'] || !tokenInfo['token_type'] || !tokenInfo['matrix_server_name'] || !tokenInfo['expires_in']) { + res.status(400).send({error: 'Missing OpenID'}); + return; + } + + var client = new MatrixLiteClient(tokenInfo); + var scalarToken = randomString({length: 25}); + var userId; + client.getSelfMxid().then(mxid => { + userId = mxid; + if (!mxid) throw new Error("Token does not resolve to a matrix user"); + return ScalarClient.register(tokenInfo); + }).then(upstreamToken => { + return this._db.createToken(userId, tokenInfo, scalarToken, upstreamToken); + }).then(() => { + res.setHeader("Content-Type", "application/json"); + res.send({scalar_token: scalarToken}); + }).catch(err => { + log.error("ScalarApi", err); + res.status(500).send({error: err.message}); + }); + } +} + +module.exports = new ScalarApi(); diff --git a/src/integration/HostedIntegration.js b/src/integration/HostedIntegration.js deleted file mode 100644 index a51d7c2..0000000 --- a/src/integration/HostedIntegration.js +++ /dev/null @@ -1,35 +0,0 @@ -var sdk = require("matrix-js-sdk"); -var log = require("../util/LogService"); -var StubbedIntegration = require("./StubbedIntegration"); - -/** - * Represents an integration hosted on a known homeserver - */ -class HostedIntegration extends StubbedIntegration { - - /** - * Creates a new hosted integration - * @param integrationSettings the integration settings - */ - constructor(integrationSettings) { - super(); - this._settings = integrationSettings; - this._client = sdk.createClient({ - baseUrl: this._settings.hosted.homeserverUrl, - accessToken: this._settings.hosted.accessToken, - userId: this._settings.userId, - }); - } - - /** - * Leaves a given Matrix room - * @param {string} roomId the room to leave - * @returns {Promise<>} resolves when completed - */ - leaveRoom(roomId) { - log.info("HostedIntegration", "Removing " + this._settings.userId + " from " + roomId); - return this._client.leave(roomId); - } -} - -module.exports = HostedIntegration; \ No newline at end of file diff --git a/src/integration/StubbedIntegration.js b/src/integration/StubbedIntegration.js deleted file mode 100644 index 78c399e..0000000 --- a/src/integration/StubbedIntegration.js +++ /dev/null @@ -1,7 +0,0 @@ -class StubbedIntegration { - leaveRoom(roomId) { - throw new Error("Not implemented"); - } -} - -module.exports = StubbedIntegration; \ No newline at end of file diff --git a/src/integration/UpstreamIntegration.js b/src/integration/UpstreamIntegration.js deleted file mode 100644 index a5dfe1d..0000000 --- a/src/integration/UpstreamIntegration.js +++ /dev/null @@ -1,33 +0,0 @@ -var VectorScalarClient = require("../scalar/VectorScalarClient"); -var log = require("../util/LogService"); -var StubbedIntegration = require("./StubbedIntegration"); - -/** - * An integration that is handled by an upstream Scalar instance - */ -class UpstreamIntegration extends StubbedIntegration { - - /** - * Creates a new hosted integration - * @param integrationSettings the integration settings - * @param {string} upstreamToken the upstream scalar token - */ - constructor(integrationSettings, upstreamToken) { - super(); - this._settings = integrationSettings; - this._upstreamToken = upstreamToken; - if (this._settings.upstream.type !== "vector") throw new Error("Unknown upstream type: " + this._settings.upstream.type); - } - - /** - * Leaves a given Matrix room - * @param {string} roomId the room to leave - * @returns {Promise<>} resolves when completed - */ - leaveRoom(roomId) { - log.info("UpstreamIntegration", "Removing " + this._settings.userId + " from " + roomId); - return VectorScalarClient.removeIntegration(this._settings.upstream.id, roomId, this._upstreamToken); - } -} - -module.exports = UpstreamIntegration; \ No newline at end of file diff --git a/src/integration/impl/StubbedFactory.js b/src/integration/impl/StubbedFactory.js index b1476d1..ea9ad49 100644 --- a/src/integration/impl/StubbedFactory.js +++ b/src/integration/impl/StubbedFactory.js @@ -1,3 +1,5 @@ +var IntegrationStub = require("../type/IntegrationStub"); + /** * Creates an integration using the given * @param {DimensionStore} db the database @@ -7,5 +9,5 @@ * @returns {Promise<*>} resolves to the configured integration */ module.exports = (db, integrationConfig, roomId, scalarToken) => { - throw new Error("Not implemented"); + return Promise.resolve(new IntegrationStub(integrationConfig)); }; \ No newline at end of file diff --git a/src/integration/impl/index.js b/src/integration/impl/index.js index 510bfce..b44c01d 100644 --- a/src/integration/impl/index.js +++ b/src/integration/impl/index.js @@ -1,7 +1,35 @@ +var log = require("../../util/LogService"); +var StubbedFactory = require("./StubbedFactory"); +var SimpleBotFactory = require("./simple_bot/SimpleBotFactory"); var RSSFactory = require("./rss/RSSFactory"); -module.exports = { +var mapping = { "complex-bot": { "rss": RSSFactory } +}; + +var defaultFactories = { + "complex-bot": null, + "bot": SimpleBotFactory +}; + +module.exports = { + getFactory: (integrationConfig) => { + var opts = mapping[integrationConfig.type]; + + if (!opts) { + log.verbose("IntegrationImpl", "No option set available for " + integrationConfig.type + " - will attempt defaults"); + } + + var factory = null; + if (!opts) factory = defaultFactories[integrationConfig.type]; + else factory = opts[integrationConfig.integrationType]; + if (!factory) { + log.verbose("IntegrationImpl", "No factory available for " + integrationConfig.type + " (" + integrationConfig.integrationType + ") - using stub"); + factory = StubbedFactory; + } + + return factory; + } }; \ No newline at end of file diff --git a/src/integration/impl/rss/RSSBot.js b/src/integration/impl/rss/RSSBot.js index ae2461e..3a2f375 100644 --- a/src/integration/impl/rss/RSSBot.js +++ b/src/integration/impl/rss/RSSBot.js @@ -20,15 +20,16 @@ class RSSBot extends ComplexBot { return this._backbone.getUserId(); } - getFeeds() { - return this._backbone.getFeeds(); + /*override*/ + getState() { + return this._backbone.getFeeds().then(feeds => { + return {feeds: feeds}; + }); } /*override*/ - getState() { - return this.getFeeds().then(feeds => { - return {feeds: feeds}; - }); + removeFromRoom(roomId) { + return this._backbone.removeFromRoom(roomId); } } diff --git a/src/integration/impl/rss/StubbedRssBackbone.js b/src/integration/impl/rss/StubbedRssBackbone.js index 6111272..0870ad3 100644 --- a/src/integration/impl/rss/StubbedRssBackbone.js +++ b/src/integration/impl/rss/StubbedRssBackbone.js @@ -24,6 +24,15 @@ class StubbedRssBackbone { getFeeds() { throw new Error("Not implemented"); } + + /** + * Removes the bot from the given room + * @param {string} roomId the room ID to remove the bot from + * @returns {Promise<>} resolves when completed + */ + removeFromRoom(roomId) { + throw new Error("Not implemented"); + } } module.exports = StubbedRssBackbone; \ No newline at end of file diff --git a/src/integration/impl/rss/VectorRssBackbone.js b/src/integration/impl/rss/VectorRssBackbone.js index 0155e75..0584e1c 100644 --- a/src/integration/impl/rss/VectorRssBackbone.js +++ b/src/integration/impl/rss/VectorRssBackbone.js @@ -1,6 +1,7 @@ var StubbedRssBackbone = require("./StubbedRssBackbone"); var VectorScalarClient = require("../../../scalar/VectorScalarClient"); var _ = require("lodash"); +var log = require("../../../util/LogService"); /** * Backbone for RSS bots running on vector.im through scalar @@ -40,6 +41,10 @@ class VectorRssBackbone extends StubbedRssBackbone { }); } + /*override*/ + removeFromRoom(roomId) { + return VectorScalarClient.removeIntegration(this._config.upstream.id, roomId, this._upstreamToken); + } } module.exports = VectorRssBackbone; \ No newline at end of file diff --git a/src/integration/impl/simple_bot/HostedSimpleBackbone.js b/src/integration/impl/simple_bot/HostedSimpleBackbone.js new file mode 100644 index 0000000..fffe70d --- /dev/null +++ b/src/integration/impl/simple_bot/HostedSimpleBackbone.js @@ -0,0 +1,36 @@ +var sdk = require("matrix-js-sdk"); +var log = require("../../../util/LogService"); +var IntegrationStub = require("../../type/IntegrationStub"); + +/** + * Standalone (matrix) backbone for simple bots + */ +class HostedSimpleBackbone extends IntegrationStub { + + /** + * Creates a new standalone bot backbone + * @param {*} botConfig the configuration for the bot + */ + constructor(botConfig) { + super(botConfig); + this._config = botConfig; + this._client = sdk.createClient({ + baseUrl: this._config.hosted.homeserverUrl, + accessToken: this._config.hosted.accessToken, + userId: this._config.userId, + }); + } + + /** + * Leaves a given Matrix room + * @param {string} roomId the room to leave + * @returns {Promise<>} resolves when completed + */ + /*override*/ + removeFromRoom(roomId) { + log.info("HostedSimpleBackbone", "Removing " + this._settings.userId + " from " + roomId); + return this._client.leave(roomId); + } +} + +module.exports = HostedSimpleBackbone; \ No newline at end of file diff --git a/src/integration/impl/simple_bot/SimpleBot.js b/src/integration/impl/simple_bot/SimpleBot.js new file mode 100644 index 0000000..ae2461e --- /dev/null +++ b/src/integration/impl/simple_bot/SimpleBot.js @@ -0,0 +1,35 @@ +var ComplexBot = require("../../type/ComplexBot"); + +/** + * Represents an RSS bot + */ +class RSSBot extends ComplexBot { + + /** + * Creates a new RSS bot + * @param botConfig the bot configuration + * @param backbone the backbone powering this bot + */ + constructor(botConfig, backbone) { + super(botConfig); + this._backbone = backbone; + } + + /*override*/ + getUserId() { + return this._backbone.getUserId(); + } + + getFeeds() { + return this._backbone.getFeeds(); + } + + /*override*/ + getState() { + return this.getFeeds().then(feeds => { + return {feeds: feeds}; + }); + } +} + +module.exports = RSSBot; \ No newline at end of file diff --git a/src/integration/impl/simple_bot/SimpleBotFactory.js b/src/integration/impl/simple_bot/SimpleBotFactory.js new file mode 100644 index 0000000..95f808f --- /dev/null +++ b/src/integration/impl/simple_bot/SimpleBotFactory.js @@ -0,0 +1,12 @@ +var RSSBot = require("./RSSBot"); +var VectorRssBackbone = require("./VectorRssBackbone"); + +module.exports = (db, integrationConfig, roomId, scalarToken) => { + if (integrationConfig.upstream) { + if (integrationConfig.upstream.type !== "vector") throw new Error("Unsupported upstream"); + return db.getUpstreamToken(scalarToken).then(upstreamToken => { + var backbone = new VectorRssBackbone(roomId, upstreamToken); + return new RSSBot(integrationConfig, backbone); + }); + } else throw new Error("Unsupported config"); +}; \ No newline at end of file diff --git a/src/integration/impl/simple_bot/StubbedSimpleBackbone.js b/src/integration/impl/simple_bot/StubbedSimpleBackbone.js new file mode 100644 index 0000000..15bdeed --- /dev/null +++ b/src/integration/impl/simple_bot/StubbedSimpleBackbone.js @@ -0,0 +1,3 @@ +/** + * Created by Travis on 5/28/2017. + */ diff --git a/src/integration/impl/simple_bot/VectorSimpleBackbone.js b/src/integration/impl/simple_bot/VectorSimpleBackbone.js new file mode 100644 index 0000000..e81207b --- /dev/null +++ b/src/integration/impl/simple_bot/VectorSimpleBackbone.js @@ -0,0 +1,22 @@ +/** + * Stubbed/placeholder simple bot backbone + */ +class StubbedSimpleBackbone { + + /** + * Creates a new stubbed RSS backbone + */ + constructor() { + } + + /** + * Leaves a given Matrix room + * @param {string} roomId the room to leave + * @returns {Promise<>} resolves when completed + */ + leaveRoom(roomId) { + throw new Error("Not implemented"); + } +} + +module.exports = StubbedRssBackbone; \ No newline at end of file diff --git a/src/integration/type/IntegrationStub.js b/src/integration/type/IntegrationStub.js index 93d30fb..ddda32e 100644 --- a/src/integration/type/IntegrationStub.js +++ b/src/integration/type/IntegrationStub.js @@ -21,6 +21,15 @@ class IntegrationStub { getState() { return Promise.resolve({}); } + + /** + * Removes the integration from the given room + * @param {string} roomId the room ID to remove the integration from + * @returns {Promise<>} resolves when completed + */ + removeFromRoom(roomId) { + throw new Error("Not implemented"); + } } module.exports = IntegrationStub;