Merge branch 'travis/ui-refresh'

This commit is contained in:
Travis Ralston 2018-03-31 16:51:57 -06:00
commit d34bc05f60
400 changed files with 13050 additions and 7963 deletions

6
.gitignore vendored
View File

@ -8,6 +8,12 @@ db/*.db
start.sh
config/integrations/*_development.yaml
config/integrations/*_production.yaml
build/
dimension.db
src/**/*.js
src/**/*.js.map
web/**/*.js
web/**/*.js.map
# Logs
logs

View File

@ -1,11 +1,13 @@
language: node_js
node_js:
- "6"
- "8"
env:
- NODE_ENV=development
before_install:
- npm i -g npm
- npm i -g typescript
install:
- npm install
script:
- npm run build
- npm run lint

53
DEVELOPMENT.md Normal file
View File

@ -0,0 +1,53 @@
# Dimension Development
Dimension is split into two layers: the frontend (web) and backend. The frontend is responsible for interacting with the client (Riot) directly and hands off any complex work to the backend for processing.
**For help and support related to Dimension development, please visit:**
[![#dimension:t2bot.io](https://img.shields.io/badge/matrix-%23dimension:t2bot.io-brightgreen.svg)](https://matrix.to/#/#dimension:t2bot.io)
## Running
The prerequisites for development are the same as running Dimension in a production environment.
```
# Edit the configuration to your specifications.
# Be sure to add yourself as an admin!
cp config/default.yaml config/development.yaml
nano config/development.yaml
# Run the webserver (it watches for changes)
npm run start:web
# Run the backend (does not watch for changes)
npm run build:app && node build/app/index.js
```
## Backend Architecture
Integrations are defined into one of four categories:
* Simple bots - Bots that can be invited to the room and left alone (Imgur, Giphy, etc)
* Complex bots - Bots that require some sort of per-room configuration (RSS, Github, etc)
* Bridges - Application services that bridge the room in some way to an external network (IRC, Webhooks, etc)
* Widgets - Added functionality through iframes for rooms/users
The backend further breaks these categories out to redirect traffic to the correct place. For instance, the admin backend
breaks out go-neb specifically as it's configuration is fairly involved.
The backend has 3 major layers:
* The webserver (where all the requests come from)
* The data stores (where requests normally get routed to)
* The proxy (where we flip between using upstream configurations and self-hosted)
Many of the API routes are generic, however many of the integrations require additional structure that the routes cannot
provide. For example, the IRC bridge is complicated in that it needs a dedicated API in order to be configured, however
the bots can work well within their constraints.
## Frontend Architecture
The frontend app is split into two major parts: The Riot frontend and the admin section. The components are nested under
their respective categories and route. For example, the edit page for the Jitsi widget is under the Widgets directory.
The frontend is otherwise a fairly basic Angular 5 application: there's components, services, etc. The services should be
kept small and generic where possible (almost always matching the Service classes in the backend). Components are more of
a judgement call and should be split out where it makes sense. For example, it doesn't make sense to create a component
for every instance where an `ngFor` is used because the number of components would be astronomical.

View File

@ -1,64 +0,0 @@
# Integrations in Dimension
Integrations take the form of bots, bridges, widgets, and other tools that enhance user experience in particular rooms. This document goes through the various setup procedures for the different types of integrations.
Several integrations already have configuration files in the `config/integrations` folder. Each default configuration has additional information as to how to configure it. Some integrations are disabled by default and can be found in the `config/integrations/samples` folder.
## Simple Bots
A simple bot is a bot that requires no configuration by users. It simply exists in the room and provides some minor functionality. Examples of this are the Giphy, Guggy, and Wikipedia bots.
1. Create a new configuration file in `config/integrations` for the bot.
2. Put the following contents in the configuration file:
```
# This is required. Leave it as "bot" for this configuration.
type: "bot"
# We should probably make sure it is enabled too ;)
enabled: true
# This is a unique key to identify your bot. For example, "giphy".
integrationType: "YOUR_BOT_NAME"
# The matrix user ID for the bot. This is likely to be on your server.
userId: "@userID:server.com"
# The name to use in the UI for Dimension. Try to keep this short.
name: "Some Friendly Bot"
# A brief description of the bot. These are best kept under 60 characters.
about: "Use `!some command` to interact with the bot"
# A logo for your bot. We'll upload this in a moment.
avatar: "img/avatars/YOUR_BOT_NAME.png"
# This is the actual bot configuration so Dimension can control some aspects of the bot's behaviour (leaving rooms, etc).
hosted:
# The URL to the client/server API. This is the "Homeserver URL" in Riot.
homeserverUrl: "https://matrix.org"
# The access token for the bot. For information on how to get this, visit https://t2bot.io/docs/access_tokens
accessToken: "your_matrix_access_token_here"
```
3. Upload/copy the logo for the bot to the `img/avatars` folder, as mentioned above.
4. Restart Dimension
Your bot should now be in Dimension!
## Complex Bots
Complex bots are the bots that require configuration within Dimension in order to work. Examples include the Github bot or RSS bot. Currently these bots are not as easy to set up in Dimension as they could be, and require a developer to write code for Dimension to understand the requirements.
To request a particular complex bot to be supported, please open a new issue on Github.
## Bridges
Bridges allow people in matrix to talk to people not in matrix, and vice-versa. Like complex bots, bridges require additional code in Dimension in order to be supported.
To request a particular bridge to be supported, please open a new issue on Github.
## Widgets
Widgets are web applications that can be embedded in a room for users to interact with. Like bridges and complex bots, widgets require additional code in Dimension to be supported.
To request a particular widget, please open a new issue on Github.

108
README.md
View File

@ -1,16 +1,10 @@
# Dimension
![dimension](https://t2bot.io/_matrix/media/r0/download/t2l.io/b3101d429588673087f457a4bdd52f45)
[![TravisCI badge](https://travis-ci.org/turt2live/matrix-dimension.svg?branch=master)](https://travis-ci.org/turt2live/matrix-dimension)
[![#dimension:t2bot.io](https://img.shields.io/badge/matrix-%23dimension:t2bot.io-brightgreen.svg)](https://matrix.to/#/#dimension:t2bot.io)
An alternative integrations manager for [Riot](https://riot.im). Join us on matrix: [#dimension:t2l.io](https://matrix.to/#/#dimension:t2l.io)
![screenshot](https://t2bot.io/_matrix/media/v1/download/t2l.io/kWDyaWXqdsjOJgGYAMMRgGiE)
# ⚠️ Dimension is in Alpha ⚠️
Dimension supports some bridges and bots, however using Dimension in a production scenario is not recommended. Dimension uses features available in recent builds of Riot and may not work on older versions.
There are plans on the matrix.org front to better support integration managers. Those changes may require an updated homeserver and Riot when made available.
An open source integrations manager for matrix clients, like Riot.
# Configuring Riot to use Dimension
@ -22,29 +16,45 @@ Change the values in Riot's `config.json` as shown below. If you do not have a `
"integrations_widgets_urls": ["https://dimension.t2bot.io/widgets"],
```
The remaining settings should be tailored for your Riot deployment.
# Building
To create a production build of Dimension, run `npm run build`. For development environments, see the Development section below.
The remaining settings should be tailored for your Riot deployment. If you're self-hosting Dimension, replace "dimension.t2bot.io" with your Dimension URL.
# Running your own
1. Run `npm run build`
2. Copy `config/default.yaml` to `config/production.yaml` and edit `config/production.yaml`
3. Edit any integration settings in `config/integrations`
4. Run Dimension with `NODE_ENV=production node app.js`
Prerequisites:
* [NodeJS](https://nodejs.org/en/download/) 8 or higher
* npm 5 or higher (`npm install -g npm@latest`)
* A webserver running Riot or another supported client
Dimension is now available on the port/host you configured.
```bash
# Download dimension
git clone https://github.com/turt2live/matrix-dimension.git
cd matrix-dimension
# Edit the configuration to your specifications.
# Be sure to add yourself as an admin!
cp config/default.yaml config/production.yaml
nano config/production.yaml
# Run
NODE_ENV=production npm run start:app
```
If you didn't change the port, Dimension should now be running on port 8184. It's best to set up your environment so that Dimension runs on a dedicated subdomain that *is not* the same as your Riot domain. This is to help keep Riot and Dimension safe and secure.
In your Riot `config.json`, set the integration manager to be your Dimension URL. Replace `dimension.t2bot.io` in the example above (under "Configuring Riot to use Dimension") with your Dimension URLs.
After Riot has been configured to use Dimension, refresh Riot and click the "Integrations" button in the top right of the room. It should be an icon that looks like this:
![3x3 square](https://t2bot.io/_matrix/media/r0/download/t2l.io/gOgboDPEMfiYOQryYwvvHkFz)
That button should open Dimension. If you've configured everything correctly, you'll see a gear icon in the top right of the window - click this to start editing your integrations.
### Running Dimension behind nginx
1. Run `npm run build`
2. Copy `config/default.yaml` to `config/production.yaml` and edit `config/production.yaml`
3. Edit any integration settings in `config/integrations`
4. Set the host for Dimension to listen on to `localhost` or `127.0.0.1`
5. Run Dimension with `NODE_ENV=production node app.js`
6. Set up the following reverse proxy information as applicable
1. Follow the steps outlined above.
2. Set the host for Dimension to listen on to `localhost` or `127.0.0.1`
3. Restart Dimension (`CTRL+C` and run `NODE_ENV=production npm run start:app` again)
4. Set up the following reverse proxy information as applicable
```
location / {
proxy_set_header X-Forwarded-For $remote_addr;
@ -53,54 +63,30 @@ Dimension is now available on the port/host you configured.
```
Be sure to also configure any SSL offloading.
### "Could not contact integrations server" error
1. **Check that federation is enabled and working on your homeserver.** Even in a private, or non-federated environment, the federation API still needs to be accessible. If federation is a major concern, limit the servers that can use the API by IP or install Dimension on the same server as your homeserver, only exposing federation to localhost.
2. **Check your SRV records.** If you are using SRV records to point to your federation port, make sure that the hostname and port are correct, and that HTTPS is listening on that port. Dimension will use the first record it sees and will only communicate over HTTPS.
3. **Verify the homeserver information in your configuration.** The name, access token, and client/server API URL all need to be set to point towards your homeserver. It may also be necessary to set the federation URL if you're running a private server.
# Development
1. Copy `config/default.yaml` to `config/development.yaml` and make any edits
2. Run Dimension with `NODE_ENV=development node app.js`
3. Run the web app with `npm run dev`
For more information about working on Dimension, see DEVELOPMENT.md.
# Do I need an integrations manager?
Integration managers aim to ease a user's interaction with the various services a homeserver may provide. Often times the integrations manager provided by Riot.im, named Modular, is more than suitable. However, there are a few cases where running your own makes more sense:
* Wanting to self-host all aspects of your Riot install
* Wanting to self-host all aspects of your services (client, homeserver, and integrations)
* Wanting to advertise custom bots specific to your homeserver
* Corporate or closed environments where Modular's integrations won't work
* Corporate or closed environments where the default integration manager won't work
# How do integration managers work?
Integration managers sit between your users and your integrations (bots, bridges, etc). It helps guide users through the configuration of your integrations for their rooms. The integrations manager can only manage integrations it is configured for. For example, Modular can only provide configuration for the bridges and bots running on matrix.org, while Dimension can provide configuration for your own bots and bridges.
The infrastructure diagram looks something like this:
```
+-----------+ +----------------------+ +--------------------+
| |========>| |=========================>| |
| | | Integrations Manager | | Bots, bridges, etc |
| | | (Dimension) | +-------------+ | (go-neb, irc, etc) |
| Clients | | |===>| |<=====>| |
| (Riot) | +----------------------+ | Homeserver | +--------------------+
| | | (synapse) |
| |============client/server API=======>| |
+-----------+ +-------------+
```
# Common Problems / Setup Questions
Dimension uses unstable and undocumented parts of Riot and can sometimes be a bit difficult to set up. If you're running into issues, check the solutions below. If you're still having issues, come by [#dimension:t2bot.io](https://matrix.to/#/#dimension:t2bot.io) and we can help you out.
## Setting up integrations (including custom)
The INTEGRATIONS.md file in this repository explains how to add custom integrations. For assistance, please visit [#dimension:t2bot.io](https://matrix.to/#/#dimension:t2bot.io)
## "Could not contact integrations server"
1. **Check that federation is enabled and working on your homeserver.** Even in a private, or non-federated environment, the federation API still needs to be accessible. If federation is a major concern, limit the servers that can use the API by IP or install Dimension on the same server as your homeserver, only exposing federation to localhost.
2. **Check your SRV records.** If you are using SRV records to point to your federation port, make sure that the hostname and port are correct, and that HTTPS is listening on that port. Dimension will use the first record it sees and will only communicate over HTTPS.
3. **Log out of Riot and log back in.** When switching from the default integrations manager (Scalar) to Dimension the authentication tokens can change. Logging out and back in will reset this token, allowing Dimension to work. More advanced users can delete the "mx_scalar_token" localstorage key.
## Turning off matrix.org/Scalar dependency
To completely disconnect Dimension from using the matrix.org bots and bridges, remove the `vector` upstream from your config. This will force anything using the upstream (matrix.org bots and bridges) to not load.
![infrastructure](https://t2bot.io/_matrix/media/r0/download/t2l.io/3bb5674d85ee22c070e36be0d9582b4d)
# License

17
app.js
View File

@ -1,17 +0,0 @@
var log = require("./src/util/LogService");
var Dimension = require("./src/Dimension");
var DimensionStore = require("./src/storage/DimensionStore");
var DemoBot = require("./src/matrix/DemoBot");
var config = require("config");
log.info("app", "Bootstrapping Dimension...");
var db = new DimensionStore();
db.prepare().then(() => {
Dimension.start(db);
if (config.get("demobot.enabled")) {
log.info("app", "Demo bot enabled - starting up");
var bot = new DemoBot(config.get("demobot.homeserverUrl"), config.get("demobot.userId"), config.get("demobot.accessToken"));
bot.start();
}
}, err => log.error("app", err)).catch(err => log.error("app", err));

View File

@ -1,13 +0,0 @@
{
"defaultEnv": {
"ENV": "NODE_ENV"
},
"development": {
"driver": "sqlite",
"filename": "db/development.db"
},
"production": {
"driver": "sqlite",
"filename": "db/production.db"
}
}

View File

@ -1,8 +1,45 @@
# The web settings for the service (API and UI)
# The web settings for the service (API and UI).
# It is best to have this run on localhost and use a reverse proxy to access Dimension.
web:
port: 8184
address: '0.0.0.0'
# Homeserver configuration
homeserver:
# The domain name of the homeserver. This is used in many places, such as with go-neb
# setups, to identify the homeserver.
name: "t2bot.io"
# The URL that Dimension, go-neb, and other services provisioned by Dimension should
# use to access the homeserver with.
clientServerUrl: "https://t2bot.io"
# The URL that Dimension should use when trying to communicate with federated APIs on
# the homeserver. If not supplied or left empty Dimension will try to resolve the address
# through the normal federation process.
#federationUrl: "https://t2bot.io:8448"
# The access token Dimension should use for miscellaneous access to the homeserver. This
# should be for a valid user.
accessToken: "something"
# These users can modify the integrations this Dimension supports.
# To access the admin interface, open Dimension in Riot and click the settings icon.
admins:
- "@someone:domain.com"
# IPs and CIDR ranges listed here will be blocked from being widgets.
# Note: Widgets may still be embedded with restricted content, although not through Dimension directly.
widgetBlacklist:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 127.0.0.0/8
# Where the database for Dimension is
database:
file: "dimension.db"
# Settings for controlling how logging works
logging:
file: logs/dimension.log
@ -11,29 +48,4 @@ logging:
fileLevel: verbose
rotate:
size: 52428800 # bytes, default is 50mb
count: 5
# Demo bot configuration. Used purely to show how to configure a self-hosted bot in Dimension
demobot:
enabled: false
userId: "@dimension:t2bot.io"
homeserverUrl: "https://t2bot.io"
accessToken: "something"
# Upstream configuration. This should almost never change.
upstreams:
- name: vector
url: "https://scalar.vector.im/api"
# Homeserver configuration (used to proxy some requests to the homeserver for processing)
homeserver:
name: "t2bot.io"
accessToken: "something"
# IPs and CIDR ranges listed here will be blocked from being widgets.
# Note: Widgets may still be embedded with restricted content, although not through Dimension directly.
widgetBlacklist:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 127.0.0.0/8
count: 5

View File

@ -1,8 +0,0 @@
type: "complex-bot"
integrationType: "circleci"
enabled: false # disabled because the API is considered unstable/inoperable. Use at your own risk!
name: "CircleCI"
about: "Sends CircleCI build results into the room"
avatar: "img/avatars/circleci.png"
upstream:
type: "vector"

View File

@ -1,7 +0,0 @@
# All this configuration does is make "Custom Widget" available in the UI
type: "widget"
integrationType: "customwidget"
enabled: true
name: "Custom Widgets"
about: "A webpage embedded in the room."
avatar: "img/avatars/customwidget.png"

View File

@ -1,11 +0,0 @@
# All this configuration does is make "Etherpad Widget" available in the UI
type: "widget"
integrationType: "etherpad"
enabled: true
name: "Etherpad"
about: "Etherpad is a collaborative text editor. With this widget you can embed Etherpad directly in to your Matrix chat rooms so that you can securely collaborate on documents."
avatar: "img/avatars/etherpad.png"
# This is the URL people will be provided when adding a new Etherpad widget. The supported variables
# are $roomId and $padName. Both will end up being URL encoded.
defaultUrl: "https://demo.riot.im/etherpad/p/$roomId_$padName"

View File

@ -1,19 +0,0 @@
type: "bot"
integrationType: "giphy"
enabled: true
userId: "@neb_giphy:matrix.org"
name: "Giphy"
about: "Use `!giphy query` to find an animated GIF on demand"
avatar: "img/avatars/giphy.png"
# This integration can be hosted in one of two ways: self-hosted or using vector.im's version.
# By default, vector.im's version is used, but you can host your own by uncommenting the `hosted`
# section below. Be sure to comment out or remove the `upstream` section below if you're self-hosting.
# Self-hosted bots are expected to auto-respond to invites.
upstream:
type: "vector"
id: "giphy"
#hosted:
# homeserverUrl: "https://t2bot.io"
# accessToken: "your_matrix_access_token_here"

View File

@ -1,8 +0,0 @@
type: "complex-bot"
integrationType: "github"
enabled: true
name: "Github"
about: "Track changes to your projects and create new issues using `!github create`"
avatar: "img/avatars/github.png"
upstream:
type: "vector"

View File

@ -1,19 +0,0 @@
type: "bot"
integrationType: "google"
enabled: true
userId: "@_neb_google:matrix.org"
name: "Google"
about: "Use `!google image query` to find an image from Google"
avatar: "img/avatars/google.png"
# This integration can be hosted in one of two ways: self-hosted or using vector.im's version.
# By default, vector.im's version is used, but you can host your own by uncommenting the `hosted`
# section below. Be sure to comment out or remove the `upstream` section below if you're self-hosting.
# Self-hosted bots are expected to auto-respond to invites.
upstream:
type: "vector"
id: "google"
#hosted:
# homeserverUrl: "https://t2bot.io"
# accessToken: "your_matrix_access_token_here"

View File

@ -1,7 +0,0 @@
# All this configuration does is make "Google Calendar Widget" available in the UI
type: "widget"
integrationType: "googlecalendar"
enabled: true
name: "Google Calendar"
about: "Share upcoming events in your room with a Google Calendar"
avatar: "img/avatars/googlecalendar.png"

View File

@ -1,7 +0,0 @@
# All this configuration does is make "Google Docs Widget" available in the UI
type: "widget"
integrationType: "googledocs"
enabled: true
name: "Google Docs"
about: "Collaborate on and share documents using Google Docs"
avatar: "img/avatars/googledocs.png"

View File

@ -1,19 +0,0 @@
type: "bot"
integrationType: "guggy"
enabled: true
userId: "@_neb_guggy:matrix.org"
name: "Guggy"
about: "Use `!guggy sentence` to create an animated GIF from a sentence"
avatar: "img/avatars/guggy.png"
# This integration can be hosted in one of two ways: self-hosted or using vector.im's version.
# By default, vector.im's version is used, but you can host your own by uncommenting the `hosted`
# section below. Be sure to comment out or remove the `upstream` section below if you're self-hosting.
# Self-hosted bots are expected to auto-respond to invites.
upstream:
type: "vector"
id: "guggy"
#hosted:
# homeserverUrl: "https://t2bot.io"
# accessToken: "your_matrix_access_token_here"

View File

@ -1,19 +0,0 @@
type: "bot"
integrationType: "imgur"
enabled: true
userId: "@_neb_imgur:matrix.org"
name: "Imgur"
about: "Use `!imgur query` to find an image from Imgur"
avatar: "img/avatars/imgur.png"
# This integration can be hosted in one of two ways: self-hosted or using vector.im's version.
# By default, vector.im's version is used, but you can host your own by uncommenting the `hosted`
# section below. Be sure to comment out or remove the `upstream` section below if you're self-hosting.
# Self-hosted bots are expected to auto-respond to invites.
upstream:
type: "vector"
id: "imgur"
#hosted:
# homeserverUrl: "https://t2bot.io"
# accessToken: "your_matrix_access_token_here"

View File

@ -1,10 +0,0 @@
type: "bridge"
integrationType: "irc"
enabled: true
name: "IRC"
about: "Bridges IRC channels to the room"
avatar: "img/avatars/irc.png"
requirements:
joinRule: 'public'
upstream:
type: "vector"

View File

@ -1,15 +0,0 @@
# All this configuration does is make "Jitsi Widget" available in the UI
type: "widget"
integrationType: "jitsi"
enabled: true
name: "Jitsi"
about: "Add video conferencing to your room with Jitsi"
avatar: "img/avatars/jitsi.png"
# This is the domain that will be used to construct the Jitsi widget. It must be just the domain.
# The default is meet.jit.si The Riot.IM instance is at jitsi.riot.im
jitsiDomain: "jitsi.riot.im"
# This is the path to the external API script. Usually the domain can be replaced with your custom
# domain above without any other modifications.
scriptUrl: "https://jitsi.riot.im/libs/external_api.min.js"

View File

@ -1,8 +0,0 @@
type: "complex-bot"
integrationType: "rss"
enabled: true
name: "RSS Bot"
about: "Tracks any Atom/RSS feed and sends new items into this room"
avatar: "img/avatars/rssbot.png"
upstream:
type: "vector"

View File

@ -1,10 +0,0 @@
type: "bot"
integrationType: "demo"
enabled: false
userId: "@dimension:t2bot.io"
name: "Demo Bot"
about: "A bot that has no functionality. This is just a demonstration on the config."
avatar: "img/avatars/demobot.png"
hosted:
homeserverUrl: "https://t2bot.io"
accessToken: "your_matrix_access_token_here"

View File

@ -1,10 +0,0 @@
type: "bot"
integrationType: "pollbot"
enabled: false
userId: "@pollbot:t2bot.io"
name: "Poll Bot"
about: "A bot to poll users in a room. Use `!pollhelp` for more information"
avatar: "img/avatars/pollbot.png"
hosted:
homeserverUrl: "https://t2bot.io"
accessToken: "your_matrix_access_token_here"

View File

@ -1,8 +0,0 @@
type: "complex-bot"
integrationType: "travisci"
enabled: true
name: "Travis CI"
about: "Sends Travis CI build results into the room"
avatar: "img/avatars/travisci.png"
upstream:
type: "vector"

View File

@ -1,7 +0,0 @@
# All this configuration does is make "Twitch Widget" available in the UI
type: "widget"
integrationType: "twitch"
enabled: true
name: "Twitch Livestream"
about: "Embed a Twitch Livestream"
avatar: "img/avatars/twitch.png"

View File

@ -1,19 +0,0 @@
type: "bot"
integrationType: "wikipedia"
enabled: true
userId: "@_neb_wikipedia:matrix.org"
name: "Wikipedia"
about: "Use `!wikipedia query` to find something from Wikipedia"
avatar: "img/avatars/wikipedia.png"
# This integration can be hosted in one of two ways: self-hosted or using vector.im's version.
# By default, vector.im's version is used, but you can host your own by uncommenting the `hosted`
# section below. Be sure to comment out or remove the `upstream` section below if you're self-hosting.
# Self-hosted bots are expected to auto-respond to invites.
upstream:
type: "vector"
id: "wikipedia"
#hosted:
# homeserverUrl: "https://t2bot.io"
# accessToken: "your_matrix_access_token_here"

View File

@ -1,7 +0,0 @@
# All this configuration does is make "Youtube Widget" available in the UI
type: "widget"
integrationType: "youtube"
enabled: true
name: "YouTube Video"
about: "Embed a YouTube, Vimeo, or DailyMotion video"
avatar: "img/avatars/youtube.png"

View File

@ -1,86 +0,0 @@
# Adding new widgets to Dimension
Widgets are small/simple applications that can be loaded via iframes in a Riot.im room. Dimension supports "wrapped widgets" where each widget URL is held in some kind of wrapper to ensure it can make everything work for that widget. For example, Jitsi widgets are wrapped so that the Jitsi elements can be set up.
To add a new widget to Dimension:
1. Copy and existing widget's configuration file from `config/integrations` and update it accordingly. The bare minimum is:
```yaml
# These two options should never change
type: "widget"
enabled: true
integrationType: "mywidget" # This is the widget type
name: "My Widget" # The human-readable name for the widget
about: "This is a very brief description of the widget"
avatar: "img/avatars/mywidget.png" # Should be a 120x120 PNG image.
# Located at `web/public/img/avatars/mywidget.png`
```
2. In `web/app/shared/models/widget.ts` add a constant for your widget. In this example, it would be:
```typescript
export const WIDGET_MY_WIDGET = ["mywidget"];
```
3. Add a new component to configure your widget under `web/app/configs/widget` (this is best done by copy/pasting another similar widget's component and renaming the files and class name).
4. Register the component in `web/app/shared/integration.service.ts` under the `supportedIntegrationsMap` like so:
```typescript
private static supportedIntegrationsMap = {
"widget": {
"mywidget": {
component: MyWidgetConfigComponent,
types: WIDGET_MY_WIDGET,
},
},
};
```
5. Start building out the widget configuration component.
## Widget configuration component methods
Most widgets should be able to specify their entire behaviour through use of the constructor, however some do require some additional hooks to operate correctly. For example, widgets being imported from older versions of Dimension or other integration managers may have to be massaged into compatible widgets.
All widgets rely on the `data` property bag to store state information, such as a channel name or conference name. This information is automatically persisted as part of the widget. Whenever you are interacting with a widget in the context of editing or creating it be sure to use the properties under `dimension` on the widget, otherwise your changes may be overwritten.
Here's a few examples of how to use the hooks supplied by the widget configuration component. For more information on all of the hooks available, please see the bottom of `web/app/configs/widget/widget.component.ts`.
#### Expanding the widget's data prior to creation
This is common for when a widget wants to create a new widget based on a channel name (for example).
```typescript
protected onNewWidgetPrepared() {
this.newWidget.dimension.newData.channelName = "";
}
```
#### Modifying the widget URL (or other properties) before adding/saving
```typescript
// Make sure to call these methods instead of addWidget and saveWidget in the view!
public validateAndAddWidget() {
this.newWidget.dimension.newUrl = "https://somewhere.com";
this.addWidget();
}
public validateAndSaveWidget(widget: EditableWidget) {
widget.dimension.newUrl = "https://somewhere.com";
this.saveWidget(widget);
}
```
#### Converting widgets from other data sources (Scalar, older versions, etc)
```typescript
protected onWidgetsDiscovered() {
for (const widget of this.widgets) {
// Note the use of .data instead of .dimension here.
// This is because we're not editing or creating a widget - just preparing it.
if (widget.data.oldDataKey) {
widget.data.newDataKey = widget.data.oldDataKey;
}
}
}
```

View File

@ -1,174 +0,0 @@
# Dimension API
Dimension has its own API that allows for management of integrations in Riot/Matrix.
## Types of integrations
### Simple Bots
* Can only be in a room or not
* No state information held
### Complex Bots
* Simple Bots that hold state information
### Bridges
* Manage their own state through dedicated API endpoints
## Endpoints
### `GET /api/v1/dimension/integrations/{roomId}?scalar_token=your_token_here`
**Parameters**
* `{roomId}` - The room ID to get integrations for
* `scalar_token` - The scalar (dimension) token to authenticate with
**Example Response**
```
TODO
```
### `DELETE /api/v1/dimension/integrations/{roomId}/{type}/{integrationType}?scalar_token=your_token_here`
**Parameters**
* `{roomId}` - The room ID to remove the integration from
* `{type}` - The integration type (eg: `bot`, `complex-bot`, `bridge`, etc)
* `{integrationType}` - The integration subtype (eg: `irc`, `rssbot`, `giphy`, etc)
* `scalar_token` - The scalar (dimension) token to authenticate with
**Example Response**
```
TODO
```
### `PUT /api/v1/dimension/integrations/{roomId}/{type}/{integrationType}/state`
**Parameters**
* `{roomId}` - The room ID to update the integration state in
* `{type}` - The integration type (eg: `bot`, `complex-bot`, `bridge`, etc)
* `{integrationType}` - The integration subtype (eg: `irc`, `rssbot`, `giphy`, etc)
**Example Body**
```
{
"scalar_token": "your_token_here",
"state": {
// integration specific state goes here
}
}
```
### `GET /api/v1/dimension/integrations/{roomId}/{type}/{integrationType}/state?scalar_token=your_token_here`
**Parameters**
* `{roomId}` - The room ID to get the integration state in
* `{type}` - The integration type (eg: `bot`, `complex-bot`, `bridge`, etc)
* `{integrationType}` - The integration subtype (eg: `irc`, `rssbot`, `giphy`, etc)
* `scalar_token` - The scalar (dimension) token to authenticate with
**Response**
An object representing the integration-specific state. See the documentation for the desired integration for more information.
### `GET /api/v1/dimension/{type}/{integrationType}/oauth/url?redirect=your_url_here&scalar_token=your_token_here`
**Parameters**
* `{type}` - The integration type (eg: `bot`, `complex-bot`, `bridge`, etc)
* `{integrationType}` - The integration subtype (eg: `irc`, `rssbot`, `giphy`, etc)
* `redirect` - The URL to redirect to when complete
* `scalar_token` - The scalar (dimension) token to authenticate with
**Example Response**
```
{
"url": "https://github.com/somewhere?with=params"
}
```
### `DELETE /api/v1/dimension/{type}/{integrationType}/oauth?scalar_token=your_token_here`
**Parameters**
* `{type}` - The integration type (eg: `bot`, `complex-bot`, `bridge`, etc)
* `{integrationType}` - The integration subtype (eg: `irc`, `rssbot`, `giphy`, etc)
* `scalar_token` - The scalar (dimension) token to authenticate with
**Example Response**
```
{}
```
## Integration State Information
### Simple Bots
Do not hold state.
### Complex Bots
#### RSS Bot
```
{
// Mutable using state API
"feeds": [
"https://some.domain.com/feed.rss",
"https://some.domain.com/another_feed.rss"
],
// Read only. Controlled by other users.
"immutableFeeds": [
"https://some.domain.com/third_feed.rss",
"https://some.domain.com/fourth_feed.rss"
]
}
```
#### Github
```
{
// Mutable using state API
"repositories": {
"turt2live/matrix-dimension": ["push", "pull_request", "issues", "issue_comment", "pull_request_review_comment", "labels", "milestones", "assignments"],
"turt2live/matrix-voyager-bot": []
},
// Read only.
"authenticated": true
}
```
*Unauthenticated response*
```
{
// Read only.
"repositories": {},
// Read only.
"authenticated": false
}
```
### Bridges
#### IRC
```
{
// Read only
"availableNetworks": [
{"name": "Freenode", "id": "freenode"},
{"name": "EsperNet", "id": "espernet"},
{"name": "OFTC", "id": "oftc"}
],
// Read only. Use IRC API to mutate
"channels": {
"freenode": [
"#dimensiontesting",
"#dimensiontest"
],
"espernet": [],
"oftc": []
}
}
```

View File

@ -1,47 +0,0 @@
# Dimension IRC Bridge API
As with most bridges, the IRC bridge uses a dedicated set of API endpoints to manage the state of the bridge. The IRC bridge still uses the state API provided by Dimension to report basic state information, but does not allow edits through the regular API. Instead, it is expected that the IRC API be used to mutate the state.
## Getting available networks/bridged channels
Make a call to the Dimension state API: `GET /api/v1/dimension/integrations/{roomId}/bridge/irc/state?scalar_token=...`.
*Example state*
```
{
"availableNetworks": [
{"name": "Freenode", "id": "freenode"},
{"name": "Espernet", "id": "espernet"},
{"name": "OFTC", "id": "oftc"}
],
"channels": {
"freenode": [
"#dimensiontesting",
"#dimensiontest"
],
"espernet": [],
"oftc": []
}
}
```
## Getting the OPs in a channel
IRC API Endpoint: `GET /api/v1/irc/{roomId}/ops/{network}/{channel}?scalar_token=...`. The channel should not include the prefix (`#test` becomes `test`).
*Example response*
```
["turt2live", "johndoe"]
```
## Linking a new channel
IRC API Endpoint: `PUT /api/v1/irc/{roomId}/channels/{network}/{channel}?op=turt2live&scalar_token=...`. The channel should not include the prefix (`#test` becomes `test`).
A 200 OK is returned if the request to add the channel was sent. The channel will not appear in the state information until the op has approved the bridge.
## Unlinking a channel
IRC API Endpoint: `DELETE /api/v1/irc/{roomId}/channels/{network}/{channel}?scalar_token=...`. The channel should not include the prefix (`#test` becomes `test`).
A 200 OK is returned if the delete was successful.

View File

@ -1,7 +0,0 @@
#!/bin/bash
npm --version
node --version
npm install
npm run build
rm -f web.zip
zip -r web.zip web-dist/*

View File

@ -1,34 +0,0 @@
'use strict';
var dbm;
var type;
var seed;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function (options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
};
exports.up = function (db) {
return db.createTable("tokens", {
id: {type: 'int', primaryKey: true, autoIncrement: true, notNull: true},
matrixUserId: {type: 'string', notNull: true},
matrixServerName: {type: 'string', notNull: true},
matrixAccessToken: {type: 'string', notNull: true},
scalarToken: {type: 'string', notNull: true},
expires: {type: 'timestamp', notNull: true}
});
};
exports.down = function (db) {
return db.dropTable("tokens");
};
exports._meta = {
"version": 1
};

View File

@ -1,27 +0,0 @@
'use strict';
var dbm;
var type;
var seed;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function (options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
};
exports.up = function (db) {
return db.addColumn('tokens', 'upstreamToken', {type: 'string', notNull: false}); // has to be nullable, despite our best intentions
};
exports.down = function (db) {
return db.removeColumn('tokens', 'upstreamToken');
};
exports._meta = {
"version": 1
};

5807
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,96 +2,107 @@
"name": "matrix-dimension",
"version": "1.0.0",
"description": "An alternative integrations manager for Riot",
"main": "app.js",
"main": "build/app/index.js",
"license": "GPL-3.0",
"scripts": {
"dev": "webpack-dev-server --inline --progress --port 8080 --host 0.0.0.0",
"build": "rimraf web-dist && webpack --progress --profile --bail"
"start:web": "webpack-dev-server --inline --progress --port 8080 --host 0.0.0.0",
"start:app": "npm run-script build && node build/app/index.js",
"build": "npm run-script build:web && npm run-script build:app",
"build:web": "rimraf build/web && webpack --progress --profile --bail",
"build:app": "rimraf build/app && tsc -p tsconfig-app.json",
"lint": "npm run-script lint:app && npm run-script lint:web",
"lint:app": "tslint --project ./tsconfig-app.json -t stylish",
"lint:web": "tslint --project ./tsconfig.json -t stylish"
},
"repository": {
"type": "git",
"url": "git+https://github.com/turt2live/matrix-dimension.git"
},
"greenkeeper": {
"ignore": [
"@types/node"
]
},
"author": "Travis Ralston",
"dependencies": {
"bluebird": "^3.5.1",
"@types/body-parser": "^1.16.8",
"@types/node": "^8.9.5",
"body-parser": "^1.18.2",
"chalk": "^2.3.0",
"config": "^1.28.1",
"db-migrate": "^0.10.2",
"db-migrate-sqlite3": "^0.2.1",
"config": "^1.30.0",
"dns-then": "^0.1.0",
"embed-video": "^2.0.0",
"express": "^4.16.2",
"js-yaml": "^3.10.0",
"lodash": "^4.17.4",
"matrix-js-sdk": "^0.8.5",
"moment": "^2.19.3",
"express": "^4.16.3",
"git-rev-sync": "^1.10.0",
"js-yaml": "^3.11.0",
"lodash": "^4.17.5",
"matrix-js-sdk": "^0.9.2",
"matrix-js-snippets": "^0.2.5",
"memory-cache": "^0.2.0",
"mime": "^2.2.0",
"moment": "^2.21.0",
"netmask": "^1.0.6",
"random-string": "^0.2.0",
"request": "^2.83.0",
"screenfull": "^3.3.2",
"sequelize": "^4.27.0",
"request": "^2.85.0",
"require-dir-all": "^0.4.15",
"sequelize": "^4.37.3",
"sequelize-typescript": "^0.6.3",
"sqlite3": "^3.1.13",
"url": "^0.11.0",
"winston": "^2.4.0"
"typescript": "^2.7.2",
"typescript-rest": "^1.3.0",
"umzug": "^2.1.0",
"url": "^0.11.0"
},
"devDependencies": {
"@angular/animations": "^5.0.0",
"@angular/common": "^5.0.0",
"@angular/compiler": "^5.0.0",
"@angular/core": "^5.0.0",
"@angular/forms": "^5.0.0",
"@angular/http": "^5.0.0",
"@angular/platform-browser": "^5.0.0",
"@angular/platform-browser-dynamic": "^5.0.0",
"@angular/router": "^5.0.0",
"@angular/animations": "^5.2.9",
"@angular/common": "^5.2.9",
"@angular/compiler": "^5.2.9",
"@angular/core": "^5.2.9",
"@angular/forms": "^5.2.9",
"@angular/http": "^5.2.9",
"@angular/platform-browser": "^5.2.9",
"@angular/platform-browser-dynamic": "^5.2.9",
"@angular/router": "^5.2.9",
"@angularclass/hmr": "^2.1.0",
"@angularclass/hmr-loader": "^3.0.2",
"@ng-bootstrap/ng-bootstrap": "^1.0.0-beta.7",
"@types/jquery": "^3.2.16",
"@types/node": "^6.0.92",
"@fortawesome/fontawesome": "^1.1.4",
"@fortawesome/fontawesome-free-brands": "^5.0.8",
"@fortawesome/fontawesome-free-regular": "^5.0.8",
"@fortawesome/fontawesome-free-solid": "^5.0.8",
"@ng-bootstrap/ng-bootstrap": "^1.0.2",
"@types/jquery": "^3.3.1",
"angular2-template-loader": "^0.6.2",
"angular2-toaster": "^4.0.0",
"angular2-toaster": "^4.0.2",
"angular2-ui-switch": "^1.2.0",
"awesome-typescript-loader": "^3.4.1",
"codelyzer": "^3.2.2",
"copy-webpack-plugin": "^4.2.3",
"core-js": "^2.5.2",
"css-loader": "^0.28.7",
"awesome-typescript-loader": "^3.5.0",
"codelyzer": "^4.2.1",
"copy-webpack-plugin": "^4.5.1",
"core-js": "^2.5.3",
"css-loader": "^0.28.11",
"cssnano": "^3.10.0",
"embed-video": "^2.0.0",
"extract-text-webpack-plugin": "^3.0.2",
"file-loader": "^1.1.5",
"file-loader": "^1.1.11",
"goby": "^1.1.2",
"html-loader": "^0.5.1",
"html-loader": "^0.5.5",
"html-webpack-plugin": "^2.28.0",
"jquery": "^3.2.1",
"jquery": "^3.3.1",
"json-loader": "^0.5.4",
"ngx-modialog": "^3.0.4",
"node-sass": "^4.7.2",
"postcss-cssnext": "^3.0.0",
"postcss-import": "^10.0.0",
"postcss-loader": "^2.0.9",
"postcss-scss": "^1.0.0",
"ng2-breadcrumbs": "^0.1.281",
"ngx-modialog": "^5.0.0",
"node-sass": "^4.8.3",
"postcss-cssnext": "^3.1.0",
"postcss-import": "^11.1.0",
"postcss-loader": "^2.1.3",
"postcss-scss": "^1.0.4",
"raw-loader": "^0.5.1",
"reflect-metadata": "^0.1.10",
"reflect-metadata": "^0.1.12",
"rimraf": "^2.6.2",
"rxjs": "^5.5.5",
"sass-loader": "^6.0.3",
"rxjs": "^5.5.7",
"sass-loader": "^6.0.7",
"screenfull": "^3.3.2",
"shelljs": "^0.7.8",
"style-loader": "^0.18.2",
"spinkit": "^1.2.5",
"style-loader": "^0.19.1",
"ts-helpers": "^1.1.2",
"tslint": "^5.8.0",
"tslint-loader": "^3.4.3",
"typescript": "^2.6.2",
"url-loader": "^0.5.8",
"webpack": "^3.10.0",
"webpack-dev-server": "^2.9.7",
"zone.js": "^0.8.18"
"tslint": "^5.9.1",
"tslint-loader": "^3.6.0",
"url-loader": "^0.6.2",
"webpack": "^3.11.0",
"webpack-dev-server": "^2.11.2",
"zone.js": "^0.8.20"
}
}

View File

@ -1,67 +0,0 @@
var express = require("express");
var config = require("config");
var log = require("./util/LogService");
var DimensionStore = require("./storage/DimensionStore");
var bodyParser = require('body-parser');
var path = require("path");
var DimensionApi = require("./DimensionApi");
var ScalarApi = require("./ScalarApi");
var IRCApi = require("./integration/impl/irc/IRCApi");
var URL = require("url");
// TODO: Convert backend to typescript? Would avoid stubbing classes all over the place
/**
* Primary entry point for Dimension
*/
class Dimension {
/**
* Creates a new Dimension
*/
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'));
this._app.use(bodyParser.json());
// Register routes for angular app
this._app.get(['/riot', '/riot/*', '/widgets', '/widgets/*'], (req, res) => {
res.sendFile(path.join(__dirname, "..", "web-dist", "index.html"));
});
// Allow CORS
this._app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
// Logging incoming requests
this._app.use((req, res, next) => {
var parsedUrl = URL.parse(req.url, true);
if (parsedUrl.query && parsedUrl.query["scalar_token"]) {
parsedUrl.query["scalar_token"] = "redacted";
parsedUrl.search = undefined; // to trigger the URL.format to use `query`
}
log.verbose("Dimension", "Incoming: " + req.method + " " + URL.format(parsedUrl));
next();
});
DimensionApi.bootstrap(this._app, this._db);
ScalarApi.bootstrap(this._app, this._db);
IRCApi.bootstrap(this._app, this._db);
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"));
}
}
module.exports = new Dimension();

View File

@ -1,287 +0,0 @@
const IntegrationImpl = require("./integration/impl/index");
const Integrations = require("./integration/index");
const _ = require("lodash");
const log = require("./util/LogService");
const request = require("request");
const dns = require("dns-then");
const urlParse = require("url");
const Netmask = require("netmask").Netmask;
const config = require("config");
/**
* 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.delete("/api/v1/dimension/integrations/:roomId/:type/:integrationType", this._removeIntegration.bind(this));
app.put("/api/v1/dimension/integrations/:roomId/:type/:integrationType/state", this._updateIntegrationState.bind(this));
app.get("/api/v1/dimension/integrations/:roomId/:type/:integrationType/state", this._getIntegrationState.bind(this));
app.get("/api/v1/dimension/widgets/embeddable", this._checkEmbeddable.bind(this));
app.get("/api/v1/dimension/integration/:type/:integrationType", this._getIntegration.bind(this));
}
_checkEmbeddable(req, res) {
// Unauthed endpoint.
var url = req.query.url;
var parts = urlParse.parse(url);
var processed = false;
// Only allow http and https
if (parts.protocol !== "http:" && parts.protocol !== "https:") {
res.status(400).send({error: "Invalid request scheme " + parts.protocol, canEmbed: false});
processed = true;
return;
}
// Verify the address is permitted for widgets
var hostname = parts.hostname.split(":")[0];
dns.resolve4(hostname).then(addresses => {
log.verbose("DimensionApi", "Hostname " + hostname + " resolves to " + addresses);
if (addresses.length == 0) {
res.status(400).send({error: "Unrecongized address", canEmbed: false});
processed = true;
return;
}
for (var ipOrCidr of config.get("widgetBlacklist")) {
var block = new Netmask(ipOrCidr);
for (var address of addresses) {
if (block.contains(address)) {
res.status(400).send({error: "Address not allowed", canEmbed: false});
processed = true;
return;
}
}
}
}, err => {
log.verbose("DimensionApi", "Error resolving host " + hostname);
log.verbose("DimensionApi", err);
res.status(400).send({error: "DNS error", canEmbed: false});
processed = true;
}).then(() => {
if (processed) return;
// Verify that the content can actually be embedded (CORS)
request(url, (err, response) => {
if (err) {
log.verbose("DimensionApi", "Error contacting host " + hostname);
log.verbose("DimensionApi", err);
res.status(400).send({error: "Host error", canEmbed: false});
return;
}
if (response.statusCode >= 200 && response.statusCode < 300) {
// 200 OK
var headers = response.headers;
var xFrameOptions = (headers['x-frame-options'] || '').toLowerCase();
if (xFrameOptions === 'sameorigin' || xFrameOptions === 'deny') {
res.status(400).send({error: "X-Frame-Options forbids embedding", canEmbed: false});
} else res.status(200).send({canEmbed: true});
} else {
res.status(400).send({error: "Unsuccessful status code: " + response.statusCode, canEmbed: false});
}
});
});
}
_findIntegration(integrationConfig, roomId, scalarToken) {
var factory = IntegrationImpl.getFactory(integrationConfig);
if (!factory) throw new Error("Missing config factory for " + integrationConfig.name);
try {
return factory(this._db, integrationConfig, roomId, scalarToken);
} catch (err) {
throw new Error("Error using factory for " + integrationConfig.name + ". Please either fix the integration settings or disable the integration.", err);
}
}
_getIntegration(req, res) {res.setHeader("Content-Type", "application/json");
// Unauthed endpoint.
var type = req.params.type;
var integrationType = req.params.integrationType;
if (!type || !integrationType) {
res.status(400).send({error: "Missing integration type or type"});
return;
}
var byIntegrationType = Integrations.byType[type];
if (!byIntegrationType || !byIntegrationType[integrationType]) {
res.status(400).send({error: "Unknown integration"});
return;
}
var integrationConfig = byIntegrationType[integrationType];
res.status(200).send(integrationConfig);
}
_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 = [];
var remove = [];
_.forEach(integrations, integration => {
try {
promises.push(this._findIntegration(integration, roomId, scalarToken).then(builtIntegration => {
return builtIntegration.getState().then(state => {
var keys = _.keys(state);
for (var key of keys) {
integration[key] = state[key];
}
return builtIntegration.getUserId();
}).then(userId => {
integration.userId = userId;
});
}));
} catch (err) {
remove.push(integration);
log.error("DimensionApi", err);
}
});
for (var toRemove of remove) {
var idx = integrations.indexOf(toRemove);
if (idx === -1) continue;
log.warn("DimensionApi", "Disabling integration " + toRemove.name + " due to an error encountered in setup");
integrations.splice(idx, 1);
}
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.params.roomId;
var scalarToken = req.query.scalar_token;
var type = req.params.type;
var integrationType = req.params.integrationType;
if (!roomId || !scalarToken || !type || !integrationType) {
res.status(400).send({error: "Missing room, integration type, type, or token"});
return;
}
var integrationConfig = Integrations.byType[type][integrationType];
if (!integrationConfig) {
res.status(400).send({error: "Unknown integration"});
return;
}
log.info("DimensionApi", "Remove requested for " + type + " (" + integrationType + ") in room " + roomId);
this._db.checkToken(scalarToken).then(() => {
return this._findIntegration(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});
});
}
_updateIntegrationState(req, res) {
var roomId = req.params.roomId;
var scalarToken = req.body.scalar_token;
var type = req.params.type;
var integrationType = req.params.integrationType;
if (!roomId || !scalarToken || !type || !integrationType) {
res.status(400).send({error: "Missing room, integration type, type, or token"});
return;
}
var integrationConfig = Integrations.byType[type][integrationType];
if (!integrationConfig) {
res.status(400).send({error: "Unknown integration"});
return;
}
log.info("DimensionApi", "Update state requested for " + type + " (" + integrationType + ") in room " + roomId);
this._db.checkToken(scalarToken).then(() => {
return this._findIntegration(integrationConfig, roomId, scalarToken);
}).then(integration => {
return integration.updateState(req.body.state);
}).then(newState => {
res.status(200).send(newState);
}).catch(err => {
log.error("DimensionApi", err);
console.error(err);
res.status(500).send({error: err.message});
});
}
_getIntegrationState(req, res) {
var roomId = req.params.roomId;
var scalarToken = req.query.scalar_token;
var type = req.params.type;
var integrationType = req.params.integrationType;
if (!roomId || !scalarToken || !type || !integrationType) {
res.status(400).send({error: "Missing room, integration type, type, or token"});
return;
}
var integrationConfig = Integrations.byType[type][integrationType];
if (!integrationConfig) {
res.status(400).send({error: "Unknown integration"});
return;
}
log.info("DimensionApi", "State requested for " + type + " (" + integrationType + ") in room " + roomId);
this._db.checkToken(scalarToken).then(() => {
return this._findIntegration(integrationConfig, roomId, scalarToken);
}).then(integration => {
return integration.getState();
}).then(state => {
res.status(200).send(state);
}).catch(err => {
log.error("DimensionApi", err);
console.error(err);
res.status(500).send({error: err.message});
});
}
}
module.exports = new DimensionApi();

50
src/MemoryCache.ts Normal file
View File

@ -0,0 +1,50 @@
import * as memoryCache from "memory-cache";
import { LogService } from "matrix-js-snippets";
export class MemoryCache {
private internalCache = new memoryCache.Cache();
constructor() {
}
public put(key: string, value: any, timeoutMs?: number): void {
this.internalCache.put(key, value, timeoutMs);
}
public get(key: string): any {
return this.internalCache.get(key);
}
public del(key: string): void {
this.internalCache.del(key);
}
public clear(): void {
this.internalCache.clear();
}
}
class _CacheManager {
private caches: { [namespace: string]: MemoryCache } = {};
public for(namespace: string): MemoryCache {
let cache = this.caches[namespace];
if (!cache) {
LogService.info("MemoryCache", "Creating a new cache for namespace: " + namespace);
cache = this.caches[namespace] = new MemoryCache();
}
return cache;
}
}
export const Cache = new _CacheManager();
export const CACHE_INTEGRATIONS = "integrations";
export const CACHE_NEB = "neb";
export const CACHE_UPSTREAM = "upstream";
export const CACHE_SCALAR_ACCOUNTS = "scalar-accounts";
export const CACHE_WIDGET_TITLES = "widget-titles";
export const CACHE_FEDERATION = "federation";
export const CACHE_IRC_BRIDGE = "irc-bridge";

View File

@ -1,11 +0,0 @@
/**
* Serves the purpose of being a documentation endpoint
*/
class OpenID {
access_token = "";
token_type = "";
matrix_server_name = "";
expires_in = 0;
}
module.exports = OpenID;

View File

@ -1,115 +0,0 @@
var MatrixLiteClient = require("./matrix/MatrixLiteClient");
var randomString = require("random-string");
var ScalarClient = require("./scalar/ScalarClient.js");
var _ = require("lodash");
var log = require("./util/LogService");
var Promise = require("bluebird");
var UpstreamConfiguration = require("./UpstreamConfiguration");
/**
* 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));
app.get("/api/v1/scalar/widgets/title_lookup", this._getWidgetTitle.bind(this));
}
_getWidgetTitle(req, res) {
res.setHeader("Content-Type", "application/json");
var token = req.query.scalar_token;
var url = req.query.curl;
if (!token || !url) {
res.status(400).send({error: "Missing token or curl"});
return;
}
this._db.checkToken(token).then(() => {
MatrixLiteClient.getUrlPreview(url).then(preview => {
if (!preview["og:title"]) {
res.status(404).send({error:{message:"Could not locate a title for the URL"}});
return;
}
// We need to convert the preview response to what Scalar expects
res.status(200).send({
cached_response: false,
page_title_cache_item: {
expires: null, // unused
cached_response_err: null, // unused
cached_title: preview["og:title"],
}
});
}).catch(err => {
res.status(500).send({error: {message: "Failed to get preview"}});
log.error("ScalarApi", "Failed to get URL preview");
log.error("ScalarApi", err);
});
}).catch(err => {
res.status(401).send({error: {message: "Failed to authenticate token"}});
log.warn("ScalarApi", "Failed to authenticate token");
log.warn("ScalarApi", 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(e => {
res.sendStatus(401);
log.warn("ScalarApi", "Failed to authenticate token");
log.verbose("ScalarApi", e);
});
}
_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");
// TODO: Make this part more generic for other upstreams (#22)
if (!UpstreamConfiguration.hasUpstream("vector")) return Promise.resolve(null);
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();

View File

@ -1,50 +0,0 @@
var LogService = require("./util/LogService");
var _ = require("lodash");
var config = require("config");
/**
* Handles all upstream configuration information, such as URLs, tokens, and whether or not they are enabled.
*/
class UpstreamConfiguration {
/**
* Creates a new upstream configuration handler
*/
constructor() {
this._upstreams = {};
this._loadUpstreams();
}
_loadUpstreams() {
for (var upstream of config.upstreams) {
var upstreamConfig = upstream;
if (this._upstreams[upstream.name]) {
LogService.warn("UpstreamConfiguration", "Duplicate upstream " + upstream.name +" - skipping");
continue;
}
this._upstreams[upstream.name] = upstreamConfig;
LogService.info("UpstreamConfiguration", "Loaded upstream '" + upstream.name + "' as: " + JSON.stringify(upstreamConfig));
}
}
/**
* Checks if a particular upstream exists
* @param {string} name the name of the upstream
* @returns {boolean} true if it is enabled and exists
*/
hasUpstream(name) {
return !!this._upstreams[name];
}
/**
* Gets an upstream's configuration
* @param {string} name the upstream name
* @returns {{url:string}} the upstream configuration
*/
getUpstream(name) {
return _.clone(this._upstreams[name]);
}
}
module.exports = new UpstreamConfiguration();

39
src/api/ApiError.ts Normal file
View File

@ -0,0 +1,39 @@
/**
* Thrown when there is a problem with a given API call. This is special-cased in the responder
* to create a JSON error response with the status code given.
*/
export class ApiError {
/**
* The HTTP status code to return
*/
public statusCode: number;
/**
* An object to be returned as JSON to the caller
*/
public jsonResponse: object;
/**
* The internal error code to describe what went wrong
*/
public errorCode: string;
/**
* Creates a new API error
* @param {number} statusCode The HTTP status code to return
* @param {string|object} json An object to be returned as JSON or a message to be returned (which is
* then converted to JSON as {message: "your_message"})
* @param {string} errCode The internal error code to describe what went wrong
*/
constructor(statusCode: number, json: string | object, errCode = "D_UNKNOWN") {
// Because typescript is just plain dumb
// https://stackoverflow.com/questions/31626231/custom-error-class-in-typescript
Error.apply(this, ["ApiError"]);
if (typeof(json) === "string") json = {message: json};
this.jsonResponse = json;
this.statusCode = statusCode;
this.errorCode = errCode;
}
}

82
src/api/Webserver.ts Normal file
View File

@ -0,0 +1,82 @@
import * as express from "express";
import * as path from "path";
import * as bodyParser from "body-parser";
import * as URL from "url";
import { LogService } from "matrix-js-snippets";
import { Server } from "typescript-rest";
import * as _ from "lodash";
import config from "../config";
import { ApiError } from "./ApiError";
/**
* Web server for Dimension. Handles the API routes for the admin, scalar, dimension, and matrix APIs.
*/
export default class Webserver {
private app: express.Application;
constructor() {
this.app = express();
this.configure();
this.loadRoutes();
}
private loadRoutes() {
const apis = ["scalar", "dimension", "admin", "matrix"].map(a => path.join(__dirname, a, "*.js"));
const router = express.Router();
apis.forEach(a => Server.loadServices(router, [a]));
const routes = _.uniq(router.stack.map(r => r.route.path));
for (const route of routes) {
this.app.options(route, (_req, res) => res.sendStatus(200));
LogService.info("Webserver", "Registered route: " + route);
}
this.app.use(router);
// We register the default route last to make sure we don't override anything by accident.
// We'll pass off all other requests to the web app
this.app.get("*", (_req, res) => {
res.sendFile(path.join(__dirname, "..", "..", "web", "index.html"));
});
// Set up the error handler
this.app.use((err: any, _req, res, next) => {
if (err instanceof ApiError) {
// Don't do anything for 'connection reset'
if (res.headersSent) return next(err);
LogService.warn("Webserver", "Handling ApiError " + err.statusCode + " " + JSON.stringify(err.jsonResponse));
res.setHeader("Content-Type", "application/json");
res.status(err.statusCode);
res.json(err.jsonResponse);
} else next(err);
});
}
private configure() {
this.app.use(express.static(path.join(__dirname, "..", "..", "web")));
this.app.use(bodyParser.json());
this.app.use((req, _res, next) => {
const parsedUrl = URL.parse(req.url, true);
if (parsedUrl.query && parsedUrl.query["scalar_token"]) {
parsedUrl.query["scalar_token"] = "redacted";
parsedUrl.search = undefined; // to force URL.format to use `query`
}
LogService.info("Webserver", "Incoming request: " + req.method + " " + URL.format(parsedUrl));
next();
});
this.app.use((_req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
}
/**
* Starts the webserver, bootstrapping the various API handlers
*/
public start() {
this.app.listen(config.web.port, config.web.address);
LogService.info("Webserver", "API and UI listening on " + config.web.address + ":" + config.web.port);
}
}

View File

@ -0,0 +1,88 @@
import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest";
import { AdminService } from "./AdminService";
import AppService from "../../db/models/AppService";
import { AppserviceStore } from "../../db/AppserviceStore";
import { ApiError } from "../ApiError";
import { MatrixAppserviceClient } from "../../matrix/MatrixAppserviceClient";
import { LogService } from "matrix-js-snippets";
interface AppserviceResponse {
id: string;
hsToken: string;
asToken: string;
userPrefix: string;
}
interface AppserviceCreateRequest {
userPrefix: string;
}
/**
* Administrative API for managing the appservices that Dimension operates.
*/
@Path("/api/v1/dimension/admin/appservices")
export class AdminAppserviceService {
@GET
@Path("all")
public async getAppservices(@QueryParam("scalar_token") scalarToken: string): Promise<AppserviceResponse[]> {
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
return (await AppService.findAll()).map(a => this.mapAppservice(a));
}
@GET
@Path(":appserviceId")
public async getAppservice(@QueryParam("scalar_token") scalarToken: string, @PathParam("appserviceId") asId: string): Promise<AppserviceResponse> {
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
try {
const appservice = await AppserviceStore.getAppservice(asId);
return this.mapAppservice(appservice);
} catch (err) {
LogService.error("AdminAppserviceService", err);
throw new ApiError(404, "Appservice not found");
}
}
@POST
@Path("new")
public async createAppservice(@QueryParam("scalar_token") scalarToken: string, request: AppserviceCreateRequest): Promise<AppserviceResponse> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
// Trim off the @ sign if it's on the prefix
if (request.userPrefix[0] === "@") {
request.userPrefix = request.userPrefix.substring(1);
}
const appservices = await AppserviceStore.getAllByUserPrefix(request.userPrefix);
if (appservices && appservices.length > 0) {
throw new ApiError(400, "User prefix is already in use");
}
const appservice = await AppserviceStore.create(AppserviceStore.getSafeUserId(request.userPrefix));
LogService.info("AdminAppserviceService", userId + " created an application service");
return this.mapAppservice(appservice);
}
@POST
@Path(":appserviceId/test")
public async test(@QueryParam("scalar_token") scalarToken: string, @PathParam("appserviceId") asId: string): Promise<any> {
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
const appservice = await AppserviceStore.getAppservice(asId);
const client = new MatrixAppserviceClient(appservice);
const userId = await client.whoAmI();
if (userId.startsWith("@" + appservice.userPrefix)) return {}; // 200 OK
throw new ApiError(500, "User ID does not match the application service");
}
private mapAppservice(as: AppService): AppserviceResponse {
return {
id: as.id,
hsToken: as.hsToken,
asToken: as.asToken,
userPrefix: as.userPrefix,
};
}
}

View File

@ -0,0 +1,63 @@
import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest";
import { ApiError } from "../ApiError";
import { AdminService } from "./AdminService";
import { DimensionIntegrationsService } from "../dimension/DimensionIntegrationsService";
import { WidgetStore } from "../../db/WidgetStore";
import { Cache, CACHE_INTEGRATIONS } from "../../MemoryCache";
import { Integration } from "../../integrations/Integration";
import { LogService } from "matrix-js-snippets";
import { BridgeStore } from "../../db/BridgeStore";
interface SetEnabledRequest {
enabled: boolean;
}
interface SetOptionsRequest {
options: any;
}
/**
* Administrative API for managing the integrations for Dimension. This is to enable/disable integrations
* and set basic options. See the NEB APIs for configuring go-neb instances.
*/
@Path("/api/v1/dimension/admin/integrations")
export class AdminIntegrationsService {
@POST
@Path(":category/:type/options")
public async setOptions(@QueryParam("scalar_token") scalarToken: string, @PathParam("category") category: string, @PathParam("type") type: string, body: SetOptionsRequest): Promise<any> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
if (category === "widget") await WidgetStore.setOptions(type, body.options);
else throw new ApiError(400, "Unrecognized category");
LogService.info("AdminIntegrationsService", userId + " updated the integration options for " + category + "/" + type);
Cache.for(CACHE_INTEGRATIONS).clear();
return {}; // 200 OK
}
@POST
@Path(":category/:type/enabled")
public async setEnabled(@QueryParam("scalar_token") scalarToken: string, @PathParam("category") category: string, @PathParam("type") type: string, body: SetEnabledRequest): Promise<any> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
if (category === "widget") await WidgetStore.setEnabled(type, body.enabled);
else if (category === "bridge") await BridgeStore.setEnabled(type, body.enabled);
else throw new ApiError(400, "Unrecognized category");
LogService.info("AdminIntegrationsService", userId + " set " + category + "/" + type + " to " + (body.enabled ? "enabled" : "disabled"));
Cache.for(CACHE_INTEGRATIONS).clear();
return {}; // 200 OK
}
@GET
@Path(":category/all")
public async getAllIntegrations(@QueryParam("scalar_token") scalarToken: string, @PathParam("category") category: string): Promise<Integration[]> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
if (category === "widget") return await DimensionIntegrationsService.getWidgets(false);
else if (category === "bridge") return await DimensionIntegrationsService.getBridges(false, userId);
else throw new ApiError(400, "Unrecongized category");
}
}

View File

@ -0,0 +1,132 @@
import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest";
import { AdminService } from "./AdminService";
import { Cache, CACHE_INTEGRATIONS, CACHE_IRC_BRIDGE } from "../../MemoryCache";
import { LogService } from "matrix-js-snippets";
import { ApiError } from "../ApiError";
import IrcBridgeRecord from "../../db/models/IrcBridgeRecord";
import { AvailableNetworks, IrcBridge } from "../../bridges/IrcBridge";
import Upstream from "../../db/models/Upstream";
import IrcBridgeNetwork from "../../db/models/IrcBridgeNetwork";
interface CreateWithUpstream {
upstreamId: number;
}
interface CreateSelfhosted {
provisionUrl: string;
}
interface BridgeResponse {
id: number;
upstreamId?: number;
provisionUrl?: string;
isEnabled: boolean;
availableNetworks: AvailableNetworks;
}
interface SetEnabledRequest {
isEnabled: boolean;
}
/**
* Administrative API for configuring IRC bridge instances.
*/
@Path("/api/v1/dimension/admin/irc")
export class AdminIrcService {
@GET
@Path("all")
public async getBridges(@QueryParam("scalar_token") scalarToken: string): Promise<BridgeResponse[]> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
const bridges = await IrcBridgeRecord.findAll();
const client = new IrcBridge(userId);
return Promise.all(bridges.map(async b => {
return {
id: b.id,
upstreamId: b.upstreamId,
provisionUrl: b.provisionUrl,
isEnabled: b.isEnabled,
availableNetworks: await client.getNetworks(b),
};
}));
}
@GET
@Path(":bridgeId")
public async getBridge(@QueryParam("scalar_token") scalarToken: string, @PathParam("bridgeId") bridgeId: number): Promise<BridgeResponse> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
const ircBridge = await IrcBridgeRecord.findByPrimary(bridgeId);
if (!ircBridge) throw new ApiError(404, "IRC Bridge not found");
const client = new IrcBridge(userId);
return {
id: ircBridge.id,
upstreamId: ircBridge.upstreamId,
provisionUrl: ircBridge.provisionUrl,
isEnabled: ircBridge.isEnabled,
availableNetworks: await client.getNetworks(ircBridge),
};
}
@POST
@Path(":bridgeId/network/:networkId/enabled")
public async setNetworkEnabled(@QueryParam("scalar_token") scalarToken: string, @PathParam("bridgeId") bridgeId: number, @PathParam("networkId") networkId: string, request: SetEnabledRequest): Promise<any> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
const ircBridge = await IrcBridgeRecord.findByPrimary(bridgeId);
if (!ircBridge) throw new ApiError(404, "IRC Bridge not found");
const localNetworkId = IrcBridge.parseNetworkId(networkId).bridgeNetworkId;
const network = await IrcBridgeNetwork.findOne({
where: {
bridgeId: ircBridge.id,
bridgeNetworkId: localNetworkId,
},
});
if (!network) throw new ApiError(404, "Network not found");
network.isEnabled = request.isEnabled;
await network.save();
LogService.info("AdminIrcService", userId + " toggled the network '" + localNetworkId + "' on bridge " + ircBridge.id);
Cache.for(CACHE_IRC_BRIDGE).clear();
return {}; // 200 OK
}
@POST
@Path("new/upstream")
public async newConfigForUpstream(@QueryParam("scalar_token") scalarToken: string, request: CreateWithUpstream): Promise<BridgeResponse> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
const upstream = await Upstream.findByPrimary(request.upstreamId);
if (!upstream) throw new ApiError(400, "Upstream not found");
const bridge = await IrcBridgeRecord.create({
upstreamId: request.upstreamId,
isEnabled: true,
});
LogService.info("AdminIrcService", userId + " created a new IRC Bridge from upstream " + request.upstreamId);
Cache.for(CACHE_IRC_BRIDGE).clear();
Cache.for(CACHE_INTEGRATIONS).clear();
return this.getBridge(scalarToken, bridge.id);
}
@POST
@Path("new/selfhosted")
public async newSelfhosted(@QueryParam("scalar_token") scalarToken: string, request: CreateSelfhosted): Promise<BridgeResponse> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
const bridge = await IrcBridgeRecord.create({
provisionUrl: request.provisionUrl,
isEnabled: true,
});
LogService.info("AdminIrcService", userId + " created a new IRC Bridge with provisioning URL " + request.provisionUrl);
Cache.for(CACHE_IRC_BRIDGE).clear();
Cache.for(CACHE_INTEGRATIONS).clear();
return this.getBridge(scalarToken, bridge.id);
}
}

View File

@ -0,0 +1,119 @@
import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest";
import { AdminService } from "./AdminService";
import { Cache, CACHE_INTEGRATIONS, CACHE_NEB } from "../../MemoryCache";
import { NebStore } from "../../db/NebStore";
import { NebConfig } from "../../models/neb";
import { LogService } from "matrix-js-snippets";
import { ApiError } from "../ApiError";
interface CreateWithUpstream {
upstreamId: number;
}
interface CreateWithAppservice {
appserviceId: string;
adminUrl: string;
}
interface SetEnabledRequest {
enabled: boolean;
}
/**
* Administrative API for configuring go-neb instances.
*/
@Path("/api/v1/dimension/admin/neb")
export class AdminNebService {
@GET
@Path("all")
public async getNebConfigs(@QueryParam("scalar_token") scalarToken: string): Promise<NebConfig[]> {
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
const cachedConfigs = Cache.for(CACHE_NEB).get("configurations");
if (cachedConfigs) return cachedConfigs;
const configs = await NebStore.getAllConfigs();
Cache.for(CACHE_NEB).put("configurations", configs);
return configs;
}
@GET
@Path(":id/config")
public async getNebConfig(@QueryParam("scalar_token") scalarToken: string, @PathParam("id") nebId: number): Promise<NebConfig> {
const configs = await this.getNebConfigs(scalarToken); // does auth for us
const firstConfig = configs.filter(c => c.id === nebId)[0];
if (!firstConfig) throw new ApiError(404, "Configuration not found");
return firstConfig;
}
@POST
@Path(":id/integration/:type/enabled")
public async setIntegrationEnabled(@QueryParam("scalar_token") scalarToken: string, @PathParam("id") nebId: number, @PathParam("type") integrationType: string, request: SetEnabledRequest): Promise<any> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
await NebStore.setIntegrationEnabled(nebId, integrationType, request.enabled);
LogService.info("AdminNebService", userId + " set the " + integrationType + " on NEB " + nebId + " to " + (request.enabled ? "enabled" : "disabled"));
Cache.for(CACHE_NEB).clear();
Cache.for(CACHE_INTEGRATIONS).clear();
return {}; // 200 OK
}
@POST
@Path(":id/integration/:type/config")
public async setIntegrationConfig(@QueryParam("scalar_token") scalarToken: string, @PathParam("id") nebId: number, @PathParam("type") integrationType: string, newConfig: any): Promise<any> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
await NebStore.setIntegrationConfig(nebId, integrationType, newConfig);
LogService.info("AdminNebService", userId + " updated the configuration for " + integrationType + " on NEB " + nebId);
Cache.for(CACHE_NEB).clear();
Cache.for(CACHE_INTEGRATIONS).clear();
return {}; // 200 OK
}
@GET
@Path(":id/integration/:type/config")
public async getIntegrationConfig(@QueryParam("scalar_token") scalarToken: string, @PathParam("id") nebId: number, @PathParam("type") integrationType: string): Promise<any> {
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
return NebStore.getIntegrationConfig(nebId, integrationType);
}
@POST
@Path("new/upstream")
public async newConfigForUpstream(@QueryParam("scalar_token") scalarToken: string, request: CreateWithUpstream): Promise<NebConfig> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
try {
const neb = await NebStore.createForUpstream(request.upstreamId);
LogService.info("AdminNebService", userId + " created a new NEB instance from upstream " + request.upstreamId);
Cache.for(CACHE_NEB).clear();
Cache.for(CACHE_INTEGRATIONS).clear();
return neb;
} catch (err) {
LogService.error("DimensionNebAdminService", err);
throw new ApiError(500, "Error creating go-neb instance");
}
}
@POST
@Path("new/appservice")
public async newConfigForAppservice(@QueryParam("scalar_token") scalarToken: string, request: CreateWithAppservice): Promise<NebConfig> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
try {
const neb = await NebStore.createForAppservice(request.appserviceId, request.adminUrl);
LogService.info("AdminNebService", userId + " created a new NEB instance from appservice " + request.appserviceId);
Cache.for(CACHE_NEB).clear();
Cache.for(CACHE_INTEGRATIONS).clear();
return neb;
} catch (err) {
LogService.error("DimensionNebAdminService", err);
throw new ApiError(500, "Error creating go-neb instance");
}
}
}

View File

@ -0,0 +1,84 @@
import { GET, Path, QueryParam } from "typescript-rest";
import { ScalarService } from "../scalar/ScalarService";
import config from "../../config";
import { ApiError } from "../ApiError";
import { MatrixLiteClient } from "../../matrix/MatrixLiteClient";
import { CURRENT_VERSION } from "../../version";
import { getFederationUrl } from "../../matrix/helpers";
interface DimensionVersionResponse {
version: string;
}
interface DimensionConfigResponse {
admins: string[];
widgetBlacklist: string[];
homeserver: {
name: string;
userId: string;
federationUrl: string;
clientServerUrl: string;
};
}
/**
* Administrative API for general information about Dimension
*/
@Path("/api/v1/dimension/admin")
export class AdminService {
/**
* Determines if a given user is an administrator
* @param {string} userId The user ID to validate
* @returns {boolean} True if the user is an administrator
*/
public static isAdmin(userId: string) {
return config.admins.indexOf(userId) >= 0;
}
/**
* Validates the given scalar token to ensure the owner is an administrator. If the
* given scalar token does not belong to an administrator, an ApiError is raised.
* @param {string} scalarToken The scalar token to validate
* @returns {Promise<string>} Resolves to the owner's user ID if they are an administrator
* @throws {ApiError} Thrown with a status code of 401 if the owner is not an administrator
*/
public static async validateAndGetAdminTokenOwner(scalarToken: string): Promise<string> {
const userId = await ScalarService.getTokenOwner(scalarToken, true);
if (!AdminService.isAdmin(userId))
throw new ApiError(401, "You must be an administrator to use this API");
return userId;
}
@GET
@Path("check")
public async checkIfAdmin(@QueryParam("scalar_token") scalarToken: string): Promise<{}> {
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
return {}; // A 200 OK essentially means "you're an admin".
}
@GET
@Path("version")
public async getVersion(@QueryParam("scalar_token") scalarToken: string): Promise<DimensionVersionResponse> {
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
return {version: CURRENT_VERSION};
}
@GET
@Path("config")
public async getConfig(@QueryParam("scalar_token") scalarToken: string): Promise<DimensionConfigResponse> {
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
const client = new MatrixLiteClient(config.homeserver.accessToken);
return {
admins: config.admins,
widgetBlacklist: config.widgetBlacklist,
homeserver: {
name: config.homeserver.name,
userId: await client.whoAmI(),
federationUrl: await getFederationUrl(config.homeserver.name),
clientServerUrl: config.homeserver.clientServerUrl,
},
};
}
}

View File

@ -0,0 +1,69 @@
import { GET, Path, POST, QueryParam } from "typescript-rest";
import { AdminService } from "./AdminService";
import { Cache, CACHE_UPSTREAM } from "../../MemoryCache";
import Upstream from "../../db/models/Upstream";
import { LogService } from "matrix-js-snippets";
interface UpstreamRepsonse {
id: number;
name: string;
type: string;
scalarUrl: string;
apiUrl: string;
}
interface NewUpstreamRequest {
name: string;
type: string;
scalarUrl: string;
apiUrl: string;
}
/**
* Administrative API for managing the instances upstream of this instance. Particularly
* useful for configuring the Modular upstream.
*/
@Path("/api/v1/dimension/admin/upstreams")
export class AdminUpstreamService {
@GET
@Path("all")
public async getUpstreams(@QueryParam("scalar_token") scalarToken: string): Promise<UpstreamRepsonse[]> {
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
const cachedUpstreams = Cache.for(CACHE_UPSTREAM).get("upstreams");
if (cachedUpstreams) return cachedUpstreams;
const upstreams = await Upstream.findAll();
const mapped = upstreams.map(u => this.mapUpstream(u));
Cache.for(CACHE_UPSTREAM).put("upstreams", mapped);
return mapped;
}
@POST
@Path("new")
public async createUpstream(@QueryParam("scalar_token") scalarToken: string, request: NewUpstreamRequest): Promise<UpstreamRepsonse> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
const upstream = await Upstream.create({
name: request.name,
type: request.type,
scalarUrl: request.scalarUrl,
apiUrl: request.apiUrl,
});
LogService.info("AdminUpstreamService", userId + " created a new upstream");
Cache.for(CACHE_UPSTREAM).clear();
return this.mapUpstream(upstream);
}
private mapUpstream(upstream: Upstream): UpstreamRepsonse {
return {
id: upstream.id,
name: upstream.name,
type: upstream.type,
scalarUrl: upstream.scalarUrl,
apiUrl: upstream.apiUrl
};
}
}

View File

@ -0,0 +1,16 @@
import { GET, Path } from "typescript-rest";
import { LogService } from "matrix-js-snippets";
/**
* API for the health of Dimension
*/
@Path("/api/v1/dimension/health")
export class DimensionHealthService {
@GET
@Path("heartbeat")
public async heartbeat(): Promise<any> {
LogService.info("DimensionHealthService", "Heartbeat called");
return {}; // 200 OK
}
}

View File

@ -0,0 +1,150 @@
import { DELETE, GET, Path, PathParam, POST, QueryParam } from "typescript-rest";
import { ScalarService } from "../scalar/ScalarService";
import { Widget } from "../../integrations/Widget";
import { Cache, CACHE_INTEGRATIONS } from "../../MemoryCache";
import { Integration } from "../../integrations/Integration";
import { ApiError } from "../ApiError";
import { WidgetStore } from "../../db/WidgetStore";
import { SimpleBot } from "../../integrations/SimpleBot";
import { NebStore } from "../../db/NebStore";
import { ComplexBot } from "../../integrations/ComplexBot";
import { Bridge } from "../../integrations/Bridge";
import { BridgeStore } from "../../db/BridgeStore";
export interface IntegrationsResponse {
widgets: Widget[],
bots: SimpleBot[],
complexBots: ComplexBot[],
bridges: Bridge[],
}
/**
* API for managing integrations, primarily for a given room
*/
@Path("/api/v1/dimension/integrations")
export class DimensionIntegrationsService {
/**
* Gets a list of widgets
* @param {boolean} enabledOnly True to only return the enabled widgets
* @returns {Promise<Widget[]>} Resolves to the widget list
*/
public static async getWidgets(enabledOnly: boolean): Promise<Widget[]> {
const cached = Cache.for(CACHE_INTEGRATIONS).get("widgets");
if (cached) return cached;
const widgets = await WidgetStore.listAll(enabledOnly ? true : null);
Cache.for(CACHE_INTEGRATIONS).put("widgets", widgets);
return widgets;
}
/**
* Gets a list of bridges
* @param {boolean} enabledOnly True to only return the enabled bridges
* @param {string} forUserId The requesting user ID
* @param {string} inRoomId If specified, the room ID to list the bridges in
* @returns {Promise<Bridge[]>} Resolves to the bridge list
*/
public static async getBridges(enabledOnly: boolean, forUserId: string, inRoomId?: string): Promise<Bridge[]> {
return BridgeStore.listAll(forUserId, enabledOnly ? true : null, inRoomId);
}
/**
* Gets a list of simple bots
* @param {string} userId The requesting user ID
* @returns {Promise<SimpleBot[]>} Resolves to the simple bot list
*/
public static async getSimpleBots(userId: string): Promise<SimpleBot[]> {
const cached = Cache.for(CACHE_INTEGRATIONS).get("simple_bots");
if (cached) return cached;
const bots = await NebStore.listSimpleBots(userId);
Cache.for(CACHE_INTEGRATIONS).put("simple_bots", bots);
return bots;
}
/**
* Gets a list of complex bots
* @param {string} userId The requesting user ID
* @param {string} roomId The room ID to get the complex bots for
* @returns {Promise<ComplexBot[]>} Resolves to the complex bot list
*/
public static async getComplexBots(userId: string, roomId: string): Promise<ComplexBot[]> {
const cached = Cache.for(CACHE_INTEGRATIONS).get("complex_bots_" + roomId);
if (cached) return cached;
const bots = await NebStore.listComplexBots(userId, roomId);
Cache.for(CACHE_INTEGRATIONS).put("complex_bots_" + roomId, bots);
return bots;
}
@GET
@Path("room/:roomId")
public async getIntegrationsInRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string): Promise<IntegrationsResponse> {
const userId = await ScalarService.getTokenOwner(scalarToken);
return {
widgets: await DimensionIntegrationsService.getWidgets(true),
bots: await DimensionIntegrationsService.getSimpleBots(userId),
complexBots: await DimensionIntegrationsService.getComplexBots(userId, roomId),
bridges: await DimensionIntegrationsService.getBridges(true, userId, roomId),
};
}
@GET
@Path("room/:roomId/integrations/:category/:type")
public async getIntegrationInRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, @PathParam("category") category: string, @PathParam("type") integrationType: string): Promise<any> {
const roomConfig = await this.getIntegrationsInRoom(scalarToken, roomId); // does auth for us
if (category === "widget") return roomConfig.widgets.find(i => i.type === integrationType);
else if (category === "bot") return roomConfig.bots.find(i => i.type === integrationType);
else if (category === "complex-bot") return roomConfig.complexBots.find(i => i.type === integrationType);
else if (category === "bridge") return roomConfig.bridges.find(i => i.type === integrationType);
else throw new ApiError(400, "Unrecognized category");
}
@POST
@Path("room/:roomId/integrations/:category/:type/config")
public async setIntegrationConfigurationInRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, @PathParam("category") category: string, @PathParam("type") integrationType: string, newConfig: any): Promise<any> {
const userId = await ScalarService.getTokenOwner(scalarToken);
if (category === "complex-bot") await NebStore.setComplexBotConfig(userId, integrationType, roomId, newConfig);
else if (category === "bridge") await BridgeStore.setBridgeRoomConfig(userId, integrationType, roomId, newConfig);
else throw new ApiError(400, "Unrecognized category");
Cache.for(CACHE_INTEGRATIONS).clear(); // TODO: Improve which cache we invalidate
return {}; // 200 OK
}
@DELETE
@Path("room/:roomId/integrations/:category/:type")
public async removeIntegrationInRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, @PathParam("category") category: string, @PathParam("type") integrationType: string): Promise<any> {
const userId = await ScalarService.getTokenOwner(scalarToken);
if (category === "widget") throw new ApiError(400, "Widgets should be removed client-side");
else if (category === "bot") await NebStore.removeSimpleBot(integrationType, roomId, userId);
else if (category === "complex-bot") throw new ApiError(400, "Complex bots should be removed automatically");
else if (category === "bridge") throw new ApiError(400, "Bridges should be removed automatically");
else throw new ApiError(400, "Unrecognized category");
Cache.for(CACHE_INTEGRATIONS).clear(); // TODO: Improve which cache we invalidate
return {}; // 200 OK
}
@GET
@Path(":category/:type")
public async getIntegration(@PathParam("category") category: string, @PathParam("type") type: string): Promise<Integration> {
// This is intentionally an unauthed endpoint to ensure we can use it in widgets
let integrations: Integration[] = [];
if (category === "widget") integrations = await DimensionIntegrationsService.getWidgets(true);
else throw new ApiError(400, "Unsupported category");
for (const integration of integrations) {
if (integration.category === category && integration.type === type) {
return integration;
}
}
throw new ApiError(404, "Integration not found");
}
}

View File

@ -0,0 +1,65 @@
import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest";
import { LogService } from "matrix-js-snippets";
import { ScalarService } from "../scalar/ScalarService";
import { IrcBridge } from "../../bridges/IrcBridge";
import IrcBridgeRecord from "../../db/models/IrcBridgeRecord";
import { ApiError } from "../ApiError";
interface RequestLinkRequest {
op: string;
}
/**
* API for interacting with the IRC bridge
*/
@Path("/api/v1/dimension/irc")
export class DimensionIrcService {
@GET
@Path(":networkId/channel/:channel/ops")
public async getOps(@QueryParam("scalar_token") scalarToken: string, @PathParam("networkId") networkId: string, @PathParam("channel") channelNoHash: string): Promise<string[]> {
const userId = await ScalarService.getTokenOwner(scalarToken);
const parsed = IrcBridge.parseNetworkId(networkId);
const bridge = await IrcBridgeRecord.findByPrimary(parsed.bridgeId);
if (!bridge) throw new ApiError(404, "Bridge not found");
const client = new IrcBridge(userId);
const operators = await client.getOperators(bridge, parsed.bridgeNetworkId, "#" + channelNoHash);
LogService.info("DimensionIrcService", userId + " listed the operators for #" + channelNoHash + " on " + networkId);
return operators;
}
@POST
@Path(":networkId/channel/:channel/link/:roomId")
public async requestLink(@QueryParam("scalar_token") scalarToken: string, @PathParam("networkId") networkId: string, @PathParam("channel") channelNoHash: string, @PathParam("roomId") roomId: string, request: RequestLinkRequest): Promise<any> {
const userId = await ScalarService.getTokenOwner(scalarToken);
const parsed = IrcBridge.parseNetworkId(networkId);
const bridge = await IrcBridgeRecord.findByPrimary(parsed.bridgeId);
if (!bridge) throw new ApiError(404, "Bridge not found");
const client = new IrcBridge(userId);
await client.requestLink(bridge, parsed.bridgeNetworkId, "#" + channelNoHash, request.op, roomId);
LogService.info("DimensionIrcService", userId + " requested #" + channelNoHash + " on " + networkId + " to be linked to " + roomId);
return {}; // 200 OK
}
@POST
@Path(":networkId/channel/:channel/unlink/:roomId")
public async unlink(@QueryParam("scalar_token") scalarToken: string, @PathParam("networkId") networkId: string, @PathParam("channel") channelNoHash: string, @PathParam("roomId") roomId: string): Promise<any> {
const userId = await ScalarService.getTokenOwner(scalarToken);
const parsed = IrcBridge.parseNetworkId(networkId);
const bridge = await IrcBridgeRecord.findByPrimary(parsed.bridgeId);
if (!bridge) throw new ApiError(404, "Bridge not found");
const client = new IrcBridge(userId);
await client.removeLink(bridge, parsed.bridgeNetworkId, "#" + channelNoHash, roomId);
LogService.info("DimensionIrcService", userId + " unlinked #" + channelNoHash + " on " + networkId + " from " + roomId);
return {}; // 200 OK
}
}

View File

@ -0,0 +1,38 @@
import { FormParam, HeaderParam, Path, PathParam, POST } from "typescript-rest";
import Webhook from "../../db/models/Webhook";
import { ApiError } from "../ApiError";
import * as request from "request";
import { LogService } from "matrix-js-snippets";
/**
* API for proxying webhooks to other services.
*/
@Path("/api/v1/dimension/webhooks")
export class DimensionWebhookService {
@POST
@Path("/travisci/:webhookId")
public async postTravisCiWebhook(@PathParam("webhookId") webhookId: string, @FormParam("payload") payload: string, @HeaderParam("Signature") signature: string): Promise<any> {
const webhook = await Webhook.findByPrimary(webhookId).catch(() => null);
if (!webhook) throw new ApiError(404, "Webhook not found");
if (!webhook.targetUrl) throw new ApiError(400, "Webhook not configured");
return new Promise((resolve, _reject) => {
request({
method: "POST",
url: webhook.targetUrl,
form: {payload: payload},
headers: {
"Signature": signature,
},
}, (err, res, _body) => {
if (err) {
LogService.error("DimensionWebhooksService", "Error invoking travis-ci webhook");
LogService.error("DimensionWebhooksService", res.body);
throw new ApiError(500, "Internal Server Error");
} else resolve();
});
});
}
}

View File

@ -0,0 +1,75 @@
import { GET, Path, QueryParam } from "typescript-rest";
import { LogService } from "matrix-js-snippets";
import * as url from "url";
import { ApiError } from "../ApiError";
import * as dns from "dns-then";
import config from "../../config";
import { Netmask } from "netmask";
import * as request from "request";
interface EmbedCapabilityResponse {
canEmbed: boolean;
}
/**
* API for widgets
*/
@Path("/api/v1/dimension/widgets")
export class DimensionWidgetService {
@GET
@Path("embeddable")
public async isEmbeddable(@QueryParam("url") checkUrl: string): Promise<EmbedCapabilityResponse> {
LogService.info("DimensionWidgetService", "Checking to see if a url is embeddable: " + checkUrl);
const parsed = url.parse(checkUrl);
// Only allow http and https
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new ApiError(400, "Invalid scheme: " + parsed.protocol);
}
// Get the IP address we're trying to connect to so we can ensure it's not blacklisted
const hostname = parsed.hostname.split(":")[0];
let addresses = [];
try {
addresses = await dns.resolve(hostname);
} catch (err) {
LogService.error("DimensionWidgetService", err);
}
if (!addresses || addresses.length === 0) throw new ApiError(400, "Cannot resolve host " + hostname);
// Check the blacklist
for (const ipOrCidr of config.widgetBlacklist) {
const block = new Netmask(ipOrCidr);
for (const address of addresses) {
if (block.contains(address)) {
throw new ApiError(400, "Address blacklisted");
}
}
}
// Now we need to verify we can actually make the request
await new Promise((resolve, _reject) => {
request(checkUrl, (err, response) => {
if (err) {
LogService.error("DimensionWidgetService", err);
throw new ApiError(400, "Failed to contact host");
}
if (response.statusCode >= 200 && response.statusCode < 300) {
// 200 OK
const xFrameOptions = (response.headers["x-frame-options"] || '').toLowerCase();
if (xFrameOptions === "sameorigin" || xFrameOptions === "deny") {
throw new ApiError(400, "X-Frame-Options prevents embedding");
}
resolve();
} else throw new ApiError(400, "Non-success status code returned");
});
});
return {canEmbed: true};
}
}

View File

@ -0,0 +1,77 @@
import { GET, Path, PathParam, PUT, QueryParam } from "typescript-rest";
import { ApiError } from "../ApiError";
import { LogService } from "matrix-js-snippets";
import { SimplifiedMatrixEvent } from "../../models/MatrixEvent";
import { AppserviceStore } from "../../db/AppserviceStore";
interface AppServiceTransaction {
events: SimplifiedMatrixEvent[];
}
/**
* API for handling appservice traffic from a homeserver
*/
// Note: There's no actual defined prefix for this API. The following was chosen to be
// somewhat consistent with the other matrix APIs. In reality, the homeserver will just
// hit the URL given in the registration - be sure to define it to match this prefix.
// Eg: `url: "http://localhost:8184/_matrix/appservice/r0"`
@Path("/_matrix/appservice/r0")
export class MatrixAppServiceApiService {
@PUT
@Path("/transactions/:txnId")
public async onTransaction(@QueryParam("access_token") homeserverToken: string, @PathParam("txnId") txnId: string, _txn: AppServiceTransaction): Promise<any> {
try {
const appservice = await AppserviceStore.getByHomeserverToken(homeserverToken);
// We don't handle the transaction at all - we just don't want the homeserver to consider us down
LogService.verbose("MatrixAppServiceApiService", "Accepting transaction " + txnId + " for appservice " + appservice.id + " blindly");
return {}; // 200 OK
} catch (err) {
LogService.error("MatrixAppServiceApiService", err);
throw new ApiError(403, {errcode: "M_FORBIDDEN"});
}
}
@GET
@Path("/room/:alias")
public async getRoom(@QueryParam("access_token") homeserverToken: string, @PathParam("alias") roomAlias: string): Promise<any> {
try {
const appservice = await AppserviceStore.getByHomeserverToken(homeserverToken);
// We don't support room lookups
LogService.verbose("MatrixAppServiceApiService", "404ing request for room " + roomAlias + " at appservice " + appservice.id);
throw new ApiError(404, {errcode: "IO.T2BOT.DIMENSION.ROOMS_NOT_SUPPORTED"});
} catch (err) {
if (err instanceof ApiError) throw err;
LogService.error("MatrixAppServiceApiService", err);
throw new ApiError(403, {errcode: "M_FORBIDDEN"});
}
}
@GET
@Path("/user/:userId")
public async getUser(@QueryParam("access_token") homeserverToken: string, @PathParam("userId") userId: string): Promise<any> {
try {
const appservice = await AppserviceStore.getByHomeserverToken(homeserverToken);
try {
const user = await AppserviceStore.getUser(appservice.id, userId);
return {
userId: user.id,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
}
} catch (err) {
LogService.error("MatrixAppServiceApiService", err);
throw new ApiError(404, {errcode: "IO.T2BOT.DIMENSION.USER_NOT_FOUND"});
}
} catch (err) {
if (err instanceof ApiError) throw err;
LogService.error("MatrixAppServiceApiService", err);
throw new ApiError(403, {errcode: "M_FORBIDDEN"});
}
}
}

View File

@ -0,0 +1,98 @@
import { GET, Path, POST, QueryParam } from "typescript-rest";
import { MatrixOpenIdClient } from "../../matrix/MatrixOpenIdClient";
import Upstream from "../../db/models/Upstream";
import { ScalarClient } from "../../scalar/ScalarClient";
import User from "../../db/models/User";
import UserScalarToken from "../../db/models/UserScalarToken";
import { LogService } from "matrix-js-snippets";
import { ApiError } from "../ApiError";
import * as randomString from "random-string";
import { OpenId } from "../../models/OpenId";
import { ScalarAccountResponse, ScalarRegisterResponse } from "../../models/ScalarResponses";
import { Cache, CACHE_SCALAR_ACCOUNTS } from "../../MemoryCache";
import { ScalarStore } from "../../db/ScalarStore";
interface RegisterRequest {
access_token: string;
token_type: string;
matrix_server_name: string;
expires_in: number;
}
/**
* API for the minimum Scalar API we need to implement to be compatible with clients. Used for registration
* and general account management.
*/
@Path("/api/v1/scalar")
export class ScalarService {
/**
* Gets the owner of a given scalar token, throwing an ApiError if the token is invalid.
* @param {string} scalarToken The scalar token to validate
* @param {boolean} ignoreUpstreams True to consider the token valid if it is missing links to other upstreams
* @returns {Promise<string>} Resolves to the owner's user ID if the token is valid.
* @throws {ApiError} Thrown with a status code of 401 if the token is invalid.
*/
public static async getTokenOwner(scalarToken: string, ignoreUpstreams?: boolean): Promise<string> {
const cachedUserId = Cache.for(CACHE_SCALAR_ACCOUNTS).get(scalarToken);
if (cachedUserId) return cachedUserId;
try {
const user = await ScalarStore.getTokenOwner(scalarToken, ignoreUpstreams);
Cache.for(CACHE_SCALAR_ACCOUNTS).put(scalarToken, user.userId, 30 * 60 * 1000); // 30 minutes
return user.userId;
} catch (err) {
LogService.error("ScalarService", err);
throw new ApiError(401, "Invalid token");
}
}
@POST
@Path("register")
public async register(request: RegisterRequest): Promise<ScalarRegisterResponse> {
const mxClient = new MatrixOpenIdClient(<OpenId>request);
const mxUserId = await mxClient.getUserId();
const user = await User.findByPrimary(mxUserId);
if (!user) {
// There's a small chance we'll get a validation error because of:
// https://github.com/vector-im/riot-web/issues/5846
LogService.verbose("ScalarService", "User " + mxUserId + " never seen before - creating");
await User.create({userId: mxUserId});
}
const upstreams = await Upstream.findAll();
await Promise.all(upstreams.map(async upstream => {
const tokens = await UserScalarToken.findAll({where: {userId: mxUserId, upstreamId: upstream.id}});
if (!tokens || tokens.length === 0) {
LogService.info("ScalarService", "Registering " + mxUserId + " for a token at upstream " + upstream.id + " (" + upstream.name + ")");
const client = new ScalarClient(upstream);
const response = await client.register(<OpenId>request);
return UserScalarToken.create({
userId: mxUserId,
scalarToken: response.scalar_token,
isDimensionToken: false,
upstreamId: upstream.id,
});
}
}));
const dimensionToken = randomString({length: 25});
const dimensionScalarToken = await UserScalarToken.create({
userId: mxUserId,
scalarToken: dimensionToken,
isDimensionToken: true,
});
LogService.info("ScalarService", mxUserId + " has registered for a scalar token successfully");
return {scalar_token: dimensionScalarToken.scalarToken};
}
@GET
@Path("account")
public async getAccount(@QueryParam("scalar_token") scalarToken: string): Promise<ScalarAccountResponse> {
const userId = await ScalarService.getTokenOwner(scalarToken);
return {user_id: userId};
}
}

View File

@ -0,0 +1,72 @@
import { GET, Path, QueryParam } from "typescript-rest";
import { LogService } from "matrix-js-snippets";
import { Cache, CACHE_WIDGET_TITLES } from "../../MemoryCache";
import { MatrixLiteClient } from "../../matrix/MatrixLiteClient";
import config from "../../config";
import { ScalarService } from "./ScalarService";
import moment = require("moment");
interface UrlPreviewResponse {
cached_response: boolean;
page_title_cache_item: {
expires: string; // "2017-12-18T04:20:04.001806738Z"
cached_response_err: string;
cached_title: string; // the actual thing riot uses
};
error: {
message: string;
};
}
/**
* API for the minimum Scalar API for widget functionality in clients.
*/
@Path("/api/v1/scalar/widgets")
export class ScalarWidgetService {
@GET
@Path("title_lookup")
public async titleLookup(@QueryParam("scalar_token") scalarToken: string, @QueryParam("curl") url: string): Promise<UrlPreviewResponse> {
await ScalarService.getTokenOwner(scalarToken);
const cachedResult = Cache.for(CACHE_WIDGET_TITLES).get(url);
if (cachedResult) {
cachedResult.cached_response = true;
return cachedResult;
}
const client = new MatrixLiteClient(config.homeserver.accessToken);
try {
const preview = await client.getUrlPreview(url);
const expirationTime = 60 * 80 * 1000; // 1 hour
const expirationAsString = moment().add(expirationTime, "milliseconds").toISOString();
const cachedItem = {
cached_response: false, // we're not cached yet
page_title_cache_item: {
expires: expirationAsString,
cached_response_err: null,
cached_title: preview["og:title"],
},
error: {message: null},
};
Cache.for(CACHE_WIDGET_TITLES).put(url, cachedItem, expirationTime);
return cachedItem;
} catch (err) {
LogService.error("ScalarWidgetService", "Error getting URL preview");
LogService.error("ScalarWidgetService", err);
return <UrlPreviewResponse>{
// All of this is to match scalar's response :/
cached_response: false,
page_title_cache_item: {
expires: null,
cached_response_err: "Failed to get URL preview",
cached_title: null,
},
error: {
message: "Failed to get URL preview",
},
};
}
}
}

327
src/bridges/IrcBridge.ts Normal file
View File

@ -0,0 +1,327 @@
import { IrcBridgeConfiguration } from "../integrations/Bridge";
import { Cache, CACHE_IRC_BRIDGE } from "../MemoryCache";
import IrcBridgeRecord from "../db/models/IrcBridgeRecord";
import Upstream from "../db/models/Upstream";
import UserScalarToken from "../db/models/UserScalarToken";
import { LogService } from "matrix-js-snippets";
import * as request from "request";
import { ListLinksResponseItem, ListOpsResponse, QueryNetworksResponse } from "./models/irc";
import IrcBridgeNetwork from "../db/models/IrcBridgeNetwork";
import { ModularIrcResponse } from "../models/ModularResponses";
interface CachedNetwork {
ircBridgeId: number;
bridgeNetworkId: string;
bridgeUserId: string;
displayName: string;
domain: string;
isEnabled: boolean;
}
export interface AvailableNetworks {
[networkId: string]: {
name: string;
domain: string;
bridgeUserId: string;
isEnabled: boolean;
};
}
export interface LinkedChannels {
[networkId: string]: {
channelName: string;
}[];
}
export class IrcBridge {
private static getNetworkId(network: CachedNetwork): string {
return network.ircBridgeId + "-" + network.bridgeNetworkId;
}
public static parseNetworkId(networkId: string): { bridgeId: string, bridgeNetworkId: string } {
const parts = networkId.split("-");
const bridgeId = parts.splice(0, 1)[0];
const bridgeNetworkId = parts.join("-");
return {bridgeId, bridgeNetworkId};
}
constructor(private requestingUserId: string) {
}
public async hasNetworks(): Promise<boolean> {
const allNetworks = (await this.getAllNetworks()).filter(n => n.isEnabled);
return allNetworks.length > 0;
}
public async getNetworks(bridge?: IrcBridgeRecord, enabledOnly?: boolean): Promise<AvailableNetworks> {
let networks = await this.getAllNetworks();
if (bridge) networks = networks.filter(n => n.ircBridgeId === bridge.id);
if (enabledOnly) networks = networks.filter(n => n.isEnabled);
const available: AvailableNetworks = {};
networks.forEach(n => available[IrcBridge.getNetworkId(n)] = {
name: n.displayName,
domain: n.domain,
bridgeUserId: n.bridgeUserId,
isEnabled: n.isEnabled,
});
return available;
}
public async getRoomConfiguration(inRoomId: string): Promise<IrcBridgeConfiguration> {
const availableNetworks = await this.getNetworks(null, true);
const bridges = await IrcBridgeRecord.findAll({where: {isEnabled: true}});
const linkedChannels: LinkedChannels = {};
for (const bridge of bridges) {
const links = await this.fetchLinks(bridge, inRoomId);
for (const key of Object.keys(links)) {
linkedChannels[key] = links[key];
}
}
return {availableNetworks: availableNetworks, links: linkedChannels};
}
public async getOperators(bridge: IrcBridgeRecord, networkId: string, channel: string): Promise<string[]> {
const network = (await this.getAllNetworks()).find(n => n.isEnabled && n.ircBridgeId === bridge.id && n.bridgeNetworkId === networkId);
if (!network) throw new Error("Network not found");
const requestBody = {remote_room_server: network.domain, remote_room_channel: channel};
let responses: ListOpsResponse[] = [];
if (bridge.upstreamId) {
const result = await this.doUpstreamRequest<ModularIrcResponse<ListOpsResponse>>(bridge, "POST", "/bridges/irc/_matrix/provision/querylink", null, requestBody);
if (result && result.replies) responses = result.replies.map(r => r.response);
} else {
const result = await this.doProvisionRequest<ListOpsResponse>(bridge, "POST", "/_matrix/provision/querylink", null, requestBody);
if (result) responses = [result];
}
const ops: string[] = [];
for (const response of responses) {
if (!response || !response.operators) continue;
response.operators.forEach(i => ops.push(i));
}
return ops;
}
public async requestLink(bridge: IrcBridgeRecord, networkId: string, channel: string, op: string, inRoomId: string): Promise<any> {
const network = (await this.getAllNetworks()).find(n => n.isEnabled && n.ircBridgeId === bridge.id && n.bridgeNetworkId === networkId);
if (!network) throw new Error("Network not found");
const requestBody = {
remote_room_server: network.domain,
remote_room_channel: channel,
matrix_room_id: inRoomId,
op_nick: op,
user_id: this.requestingUserId,
};
if (bridge.upstreamId) {
delete requestBody["user_id"];
await this.doUpstreamRequest(bridge, "POST", "/bridges/irc/_matrix/provision/link", null, requestBody);
} else {
await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/link", null, requestBody);
}
}
public async removeLink(bridge: IrcBridgeRecord, networkId: string, channel: string, inRoomId: string): Promise<any> {
const network = (await this.getAllNetworks()).find(n => n.isEnabled && n.ircBridgeId === bridge.id && n.bridgeNetworkId === networkId);
if (!network) throw new Error("Network not found");
const requestBody = {
remote_room_server: network.domain,
remote_room_channel: channel,
matrix_room_id: inRoomId,
user_id: this.requestingUserId,
};
if (bridge.upstreamId) {
delete requestBody["user_id"];
await this.doUpstreamRequest(bridge, "POST", "/bridges/irc/_matrix/provision/unlink", null, requestBody);
} else {
await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/unlink", null, requestBody);
}
}
private async getAllNetworks(): Promise<CachedNetwork[]> {
const cached = Cache.for(CACHE_IRC_BRIDGE).get("networks");
if (cached) return cached;
const bridges = await IrcBridgeRecord.findAll();
if (!bridges) return [];
const networks: CachedNetwork[] = [];
for (const bridge of bridges) {
const bridgeNetworks = await this.fetchNetworks(bridge);
bridgeNetworks.forEach(n => networks.push(n));
}
Cache.for(CACHE_IRC_BRIDGE).put("networks", networks, 60 * 60 * 1000); // 1 hour
return networks;
}
private async fetchLinks(bridge: IrcBridgeRecord, inRoomId: string): Promise<LinkedChannels> {
const availableNetworks = await this.getNetworks(bridge, true);
const networksByDomain: { [domain: string]: { id: string, name: string, bridgeUserId: string } } = {};
for (const key of Object.keys(availableNetworks)) {
const network = availableNetworks[key];
networksByDomain[network.domain] = {
id: key,
name: network.name,
bridgeUserId: network.bridgeUserId,
};
}
let responses: ListLinksResponseItem[] = [];
if (bridge.upstreamId) {
const result = await this.doUpstreamRequest<ModularIrcResponse<ListLinksResponseItem[]>>(bridge, "GET", "/bridges/irc/_matrix/provision/listlinks/" + inRoomId);
if (result && result.replies) {
const replies = result.replies.map(r => r.response);
for (const reply of replies) reply.forEach(r => responses.push(r));
}
} else {
const result = await this.doProvisionRequest<ListLinksResponseItem[]>(bridge, "GET", "/_matrix/provision/listlinks/" + inRoomId);
if (result) responses = result;
}
const linked: LinkedChannels = {};
for (const response of responses) {
if (!response || !response.remote_room_server) continue;
const network = networksByDomain[response.remote_room_server];
if (!network) continue;
if (!linked[network.id]) linked[network.id] = [];
linked[network.id].push({
channelName: response.remote_room_channel,
});
}
return linked;
}
private async fetchNetworks(bridge: IrcBridgeRecord): Promise<CachedNetwork[]> {
let responses: QueryNetworksResponse[] = [];
if (bridge.upstreamId) {
const result = await this.doUpstreamRequest<ModularIrcResponse<QueryNetworksResponse>>(bridge, "GET", "/bridges/irc/_matrix/provision/querynetworks");
if (result && result.replies) responses = result.replies.map(r => r.response);
} else {
const result = await this.doProvisionRequest<QueryNetworksResponse>(bridge, "GET", "/_matrix/provision/querynetworks");
if (result) responses = [result];
}
const networks: CachedNetwork[] = [];
for (const response of responses) {
if (!response || !response.servers) continue;
for (const server of response.servers) {
if (!server) continue;
let existingNetwork = await IrcBridgeNetwork.findOne({
where: {
bridgeId: bridge.id,
bridgeNetworkId: server.network_id,
},
});
if (!existingNetwork) {
LogService.info("IrcBridge", "Discovered new network for bridge " + bridge.id + ": " + server.network_id);
existingNetwork = await IrcBridgeNetwork.create({
bridgeId: bridge.id,
isEnabled: false,
bridgeNetworkId: server.network_id,
bridgeUserId: server.bot_user_id,
displayName: server.desc,
domain: server.fields.domain,
});
} else {
existingNetwork.displayName = server.desc;
existingNetwork.bridgeUserId = server.bot_user_id;
existingNetwork.domain = server.fields.domain;
await existingNetwork.save();
}
networks.push({
ircBridgeId: bridge.id,
bridgeNetworkId: existingNetwork.bridgeNetworkId,
bridgeUserId: existingNetwork.bridgeUserId,
displayName: existingNetwork.displayName,
domain: existingNetwork.domain,
isEnabled: existingNetwork.isEnabled,
});
}
}
return networks;
}
private async doUpstreamRequest<T>(bridge: IrcBridgeRecord, method: string, endpoint: string, qs?: any, body?: any): Promise<T> {
const upstream = await Upstream.findByPrimary(bridge.upstreamId);
const token = await UserScalarToken.findOne({
where: {
upstreamId: upstream.id,
isDimensionToken: false,
userId: this.requestingUserId,
},
});
if (!qs) qs = {};
qs["scalar_token"] = token.scalarToken;
const apiUrl = upstream.apiUrl.endsWith("/") ? upstream.apiUrl.substring(0, upstream.apiUrl.length - 1) : upstream.apiUrl;
const url = apiUrl + (endpoint.startsWith("/") ? endpoint : "/" + endpoint);
LogService.info("IrcBridge", "Doing upstream IRC Bridge request: " + url);
return new Promise<T>((resolve, reject) => {
request({
method: method,
url: url,
qs: qs,
json: body,
}, (err, res, _body) => {
if (err) {
LogService.error("IrcBridge", "Error calling" + url);
LogService.error("IrcBridge", err);
reject(err);
} else if (res.statusCode !== 200) {
LogService.error("IrcBridge", "Got status code " + res.statusCode + " when calling " + url);
LogService.error("IrcBridge", res.body);
reject(new Error("Request failed"));
} else {
if (typeof(res.body) === "string") res.body = JSON.parse(res.body);
resolve(res.body);
}
});
});
}
private async doProvisionRequest<T>(bridge: IrcBridgeRecord, method: string, endpoint: string, qs?: any, body?: any): Promise<T> {
const provisionUrl = bridge.provisionUrl;
const apiUrl = provisionUrl.endsWith("/") ? provisionUrl.substring(0, provisionUrl.length - 1) : provisionUrl;
const url = apiUrl + (endpoint.startsWith("/") ? endpoint : "/" + endpoint);
LogService.info("IrcBridge", "Doing provision IRC Bridge request: " + url);
return new Promise<T>((resolve, reject) => {
request({
method: method,
url: url,
qs: qs,
json: body,
}, (err, res, _body) => {
if (err) {
LogService.error("IrcBridge", "Error calling" + url);
LogService.error("IrcBridge", err);
reject(err);
LogService.error("IrcBridge", "Got status code " + res.statusCode + " when calling " + url);
LogService.error("IrcBridge", res.body);
reject(new Error("Request failed"));
} else {
if (typeof(res.body) === "string") res.body = JSON.parse(res.body);
resolve(res.body);
}
});
});
}
}

20
src/bridges/models/irc.ts Normal file
View File

@ -0,0 +1,20 @@
export interface QueryNetworksResponse {
servers: {
network_id: string;
bot_user_id: string;
desc: string;
fields: {
domain: string;
};
}[];
}
export interface ListLinksResponseItem {
matrix_room_id: string;
remote_room_channel: string;
remote_room_server: string;
}
export interface ListOpsResponse {
operators: string[];
}

23
src/config.ts Normal file
View File

@ -0,0 +1,23 @@
import * as config from "config";
import { LogConfig } from "matrix-js-snippets";
export interface DimensionConfig {
web: {
port: number;
address: string;
};
homeserver: {
name: string;
accessToken: string;
clientServerUrl: string;
federationUrl: string;
};
widgetBlacklist: string[];
database: {
file: string;
};
admins: string[];
logging: LogConfig;
}
export default <DimensionConfig>config;

80
src/db/AppserviceStore.ts Normal file
View File

@ -0,0 +1,80 @@
import AppService from "./models/AppService";
import AppServiceUser from "./models/AppServiceUser";
import * as randomString from "random-string";
import { MatrixAppserviceClient } from "../matrix/MatrixAppserviceClient";
import { LogService } from "matrix-js-snippets";
export class AppserviceStore {
public static async create(userPrefix: string): Promise<AppService> {
const id = "dimension-" + randomString({length: 25});
const asToken = randomString({length: 100});
const hsToken = randomString({length: 100});
return AppService.create({
id: id,
asToken: asToken,
hsToken: hsToken,
userPrefix: userPrefix,
});
}
public static async getUser(appserviceId: string, userId: string): Promise<AppServiceUser> {
const user = await AppServiceUser.findOne({where: {appserviceId: appserviceId, id: userId}});
if (!user) throw new Error("User not found");
return user;
}
public static async getByHomeserverToken(hsToken: string): Promise<AppService> {
const appservice = await AppService.findOne({where: {hsToken: hsToken}});
if (!appservice) throw new Error("Appservice not found");
return appservice;
}
public static async getAllByUserPrefix(userPrefix: string): Promise<AppService[]> {
return AppService.findAll({where: {userPrefix: userPrefix}});
}
public static getSafeUserId(userIdOrPrefix: string): string {
// Force user IDs to be lowercase and strip out characters that aren't permitted
// https://matrix.org/docs/spec/appendices.html#user-identifiers
if (userIdOrPrefix.startsWith('@')) {
// we only change out the parts we care about. The rest will be crushed down.
userIdOrPrefix = userIdOrPrefix.replace(/@/g, '=40');
userIdOrPrefix = userIdOrPrefix.replace(/:/g, '=3A');
}
return userIdOrPrefix.toLowerCase().replace(/[^a-z0-9._\-=]/g, '.');
}
public static async registerUser(appserviceId: string, userId: string): Promise<AppServiceUser> {
const appservice = await AppService.findOne({where: {id: appserviceId}});
if (!appservice) throw new Error("Appservice not found");
LogService.info("AppserviceStore", "Registering to own " + userId + " in appservice " + appserviceId);
const client = new MatrixAppserviceClient(appservice);
const localpart = AppserviceStore.getSafeUserId(userId.substring(1).split(":")[0]);
const response = await client.registerUser(localpart);
LogService.info("AppserviceStore", "Successfully registered " + userId);
return await AppServiceUser.create({
id: userId,
appserviceId: appserviceId,
accessToken: response.access_token,
});
}
public static async getOrCreateUser(appserviceId: string, userId: string): Promise<AppServiceUser> {
const user = await AppServiceUser.findOne({where: {appserviceId: appserviceId, id: userId}});
if (!user) return AppserviceStore.registerUser(appserviceId, userId);
return user;
}
public static async getAppservice(id: string): Promise<AppService> {
const appservice = await AppService.findByPrimary(id);
if (!appservice) throw new Error("Appservice not found");
return appservice;
}
private constructor() {
}
}

62
src/db/BridgeStore.ts Normal file
View File

@ -0,0 +1,62 @@
import { Bridge } from "../integrations/Bridge";
import BridgeRecord from "./models/BridgeRecord";
import { IrcBridge } from "../bridges/IrcBridge";
export class BridgeStore {
public static async listAll(requestingUserId: string, isEnabled?: boolean, inRoomId?: string): Promise<Bridge[]> {
let conditions = {};
if (isEnabled === true || isEnabled === false) conditions = {where: {isEnabled: isEnabled}};
const allRecords = await BridgeRecord.findAll(conditions);
const enabledBridges: Bridge[] = [];
for (const bridgeRecord of allRecords) {
if (isEnabled === true || isEnabled === false) {
const isLogicallyEnabled = await BridgeStore.isLogicallyEnabled(bridgeRecord, requestingUserId);
if (isLogicallyEnabled !== isEnabled) continue;
}
const bridgeConfig = await BridgeStore.getConfiguration(bridgeRecord, requestingUserId, inRoomId);
enabledBridges.push(new Bridge(bridgeRecord, bridgeConfig));
}
return enabledBridges;
}
public static async setEnabled(type: string, isEnabled: boolean): Promise<any> {
const bridge = await BridgeRecord.findOne({where: {type: type}});
if (!bridge) throw new Error("Bridge not found");
bridge.isEnabled = isEnabled;
return bridge.save();
}
public static async setBridgeRoomConfig(_requestingUserId: string, integrationType: string, _inRoomId: string, _newConfig: any): Promise<any> {
const record = await BridgeRecord.findOne({where: {type: integrationType}});
if (!record) throw new Error("Bridge not found");
if (integrationType === "irc") {
throw new Error("IRC Bridges should be modified with the dedicated API");
} else throw new Error("Unsupported bridge");
}
private static async isLogicallyEnabled(record: BridgeRecord, requestingUserId: string): Promise<boolean> {
if (record.type === "irc") {
const irc = new IrcBridge(requestingUserId);
return irc.hasNetworks();
} else return true;
}
private static async getConfiguration(record: BridgeRecord, requestingUserId: string, inRoomId?: string): Promise<any> {
if (record.type === "irc") {
if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs
const irc = new IrcBridge(requestingUserId);
return irc.getRoomConfiguration(inRoomId);
} else return {};
}
private constructor() {
}
}

69
src/db/DimensionStore.ts Normal file
View File

@ -0,0 +1,69 @@
import { Model, Sequelize } from "sequelize-typescript";
import config from "../config";
import { LogService } from "matrix-js-snippets";
import User from "./models/User";
import UserScalarToken from "./models/UserScalarToken";
import Upstream from "./models/Upstream";
import WidgetRecord from "./models/WidgetRecord";
import * as path from "path";
import * as Umzug from "umzug";
import AppService from "./models/AppService";
import AppServiceUser from "./models/AppServiceUser";
import NebConfiguration from "./models/NebConfiguration";
import NebIntegration from "./models/NebIntegration";
import NebBotUser from "./models/NebBotUser";
import NebNotificationUser from "./models/NebNotificationUser";
import NebIntegrationConfig from "./models/NebIntegrationConfig";
import Webhook from "./models/Webhook";
import BridgeRecord from "./models/BridgeRecord";
import IrcBridgeRecord from "./models/IrcBridgeRecord";
import IrcBridgeNetwork from "./models/IrcBridgeNetwork";
class _DimensionStore {
private sequelize: Sequelize;
constructor() {
this.sequelize = new Sequelize({
dialect: 'sqlite',
database: "dimension",
storage: config.database.file,
username: "",
password: "",
logging: i => LogService.verbose("DimensionStore [SQL]", i)
});
this.sequelize.addModels(<Array<typeof Model>>[
User,
UserScalarToken,
Upstream,
WidgetRecord,
AppService,
AppServiceUser,
NebConfiguration,
NebIntegration,
NebBotUser,
NebNotificationUser,
NebIntegrationConfig,
Webhook,
BridgeRecord,
IrcBridgeRecord,
IrcBridgeNetwork,
]);
}
public updateSchema(): Promise<any> {
LogService.info("DimensionStore", "Updating schema...");
const migrator = new Umzug({
storage: "sequelize",
storageOptions: {sequelize: this.sequelize},
migrations: {
params: [this.sequelize.getQueryInterface()],
path: path.join(__dirname, "migrations"),
}
});
return migrator.up();
}
}
export const DimensionStore = new _DimensionStore();

353
src/db/NebStore.ts Normal file
View File

@ -0,0 +1,353 @@
import { NebConfig } from "../models/neb";
import NebConfiguration from "./models/NebConfiguration";
import NebIntegration from "./models/NebIntegration";
import Upstream from "./models/Upstream";
import AppService from "./models/AppService";
import { LogService } from "matrix-js-snippets";
import { NebClient } from "../neb/NebClient";
import NebBotUser from "./models/NebBotUser";
import NebNotificationUser from "./models/NebNotificationUser";
import { AppserviceStore } from "./AppserviceStore";
import config from "../config";
import { SimpleBot } from "../integrations/SimpleBot";
import { NebProxy } from "../neb/NebProxy";
import { ComplexBot } from "../integrations/ComplexBot";
export interface SupportedIntegration {
type: string;
name: string;
avatarUrl: string;
description: string;
}
export class NebStore {
private static INTEGRATIONS_MODULAR_SUPPORTED = ["giphy", "guggy", "github", "google", "imgur", "rss", "travisci", "wikipedia"];
private static INTEGRATIONS = {
// TODO: Support Circle CI
// "circleci": {
// name: "Circle CI",
// avatarUrl: "/img/avatars/circleci.png",
// description: "Announces build results from Circle CI to the room.",
// simple: false,
// },
"echo": {
name: "Echo",
avatarUrl: "/img/avatars/echo.png", // TODO: Make this image
description: "Repeats text given to it from !echo",
simple: true,
},
"giphy": {
name: "Giphy",
avatarUrl: "/img/avatars/giphy.png",
description: "Posts a GIF from Giphy using !giphy <query>",
simple: true,
},
"guggy": {
name: "Guggy",
avatarUrl: "/img/avatars/guggy.png",
description: "Send a reaction GIF using !guggy <query>",
simple: true,
},
// TODO: Support Github
// "github": {
// name: "Github",
// avatarUrl: "/img/avatars/github.png",
// description: "Github issue management and announcements for a repository",
// simple: false,
// },
"google": {
name: "Google",
avatarUrl: "/img/avatars/google.png",
description: "Searches Google Images using !google image <query>",
simple: true,
},
"imgur": {
name: "Imgur",
avatarUrl: "/img/avatars/imgur.png",
description: "Searches and posts images from Imgur using !imgur <query>",
simple: true,
},
// TODO: Support JIRA
// "jira": {
// name: "Jira",
// avatarUrl: "/img/avatars/jira.png",
// description: "Jira issue management and announcements for a project",
// simple: false,
// },
"rss": {
name: "RSS",
avatarUrl: "/img/avatars/rssbot.png",
description: "Announces changes to RSS feeds in the room",
simple: false,
},
"travisci": {
name: "Travis CI",
avatarUrl: "/img/avatars/travisci.png",
description: "Announces build results from Travis CI to the room",
simple: false,
},
"wikipedia": {
name: "Wikipedia",
avatarUrl: "/img/avatars/wikipedia.png",
description: "Searches wikipedia using !wikipedia <query>",
simple: true,
},
};
private static async listEnabledNebBots(simple: boolean): Promise<{ neb: NebConfig, integration: NebIntegration }[]> {
const nebConfigs = await NebStore.getAllConfigs();
const integrations: { neb: NebConfig, integration: NebIntegration }[] = [];
const hasTypes: string[] = [];
for (const neb of nebConfigs) {
for (const integration of neb.dbIntegrations) {
if (!integration.isEnabled) continue;
const metadata = NebStore.INTEGRATIONS[integration.type];
if (!metadata || metadata.simple !== simple) continue;
if (hasTypes.indexOf(integration.type) !== -1) continue;
integrations.push({neb, integration});
hasTypes.push(integration.type);
}
}
return integrations;
}
public static async listEnabledNebSimpleBots(): Promise<{ neb: NebConfig, integration: NebIntegration }[]> {
return NebStore.listEnabledNebBots(true);
}
public static async listEnabledNebComplexBots(): Promise<{ neb: NebConfig, integration: NebIntegration }[]> {
return NebStore.listEnabledNebBots(false);
}
public static async listSimpleBots(requestingUserId: string): Promise<SimpleBot[]> {
const rawIntegrations = await NebStore.listEnabledNebSimpleBots();
return Promise.all(rawIntegrations.map(async i => {
const proxy = new NebProxy(i.neb, requestingUserId);
return new SimpleBot(i.integration, await proxy.getBotUserId(i.integration));
}));
}
public static async listComplexBots(requestingUserId: string, roomId: string): Promise<ComplexBot[]> {
const rawIntegrations = await NebStore.listEnabledNebComplexBots();
return Promise.all(rawIntegrations.map(async i => {
const proxy = new NebProxy(i.neb, requestingUserId);
const notifUserId = await proxy.getNotificationUserId(i.integration, roomId);
const botUserId = null; // TODO: For github
const botConfig = await proxy.getServiceConfiguration(i.integration, roomId);
return new ComplexBot(i.integration, notifUserId, botUserId, botConfig);
}));
}
public static async setComplexBotConfig(requestingUserId: string, type: string, roomId: string, newConfig: any): Promise<any> {
const rawIntegrations = await NebStore.listEnabledNebComplexBots();
const integration = rawIntegrations.find(i => i.integration.type === type);
if (!integration) throw new Error("Integration not found");
const proxy = new NebProxy(integration.neb, requestingUserId);
return proxy.setServiceConfiguration(integration.integration, roomId, newConfig);
}
public static async removeSimpleBot(type: string, roomId: string, requestingUserId: string): Promise<any> {
const rawIntegrations = await NebStore.listEnabledNebSimpleBots();
const integration = rawIntegrations.find(i => i.integration.type === type);
if (!integration) throw new Error("Integration not found");
const proxy = new NebProxy(integration.neb, requestingUserId);
return proxy.removeBotFromRoom(integration.integration, roomId);
}
public static async getAllConfigs(): Promise<NebConfig[]> {
const configs = await NebConfiguration.findAll();
return Promise.all((configs || []).map(c => NebStore.getConfig(c.id)));
}
public static async getConfig(id: number): Promise<NebConfig> {
const nebConfig = await NebConfiguration.findByPrimary(id);
if (!nebConfig) throw new Error("Configuration not found");
const integrations = await NebIntegration.findAll({where: {nebId: id}});
const fullIntegrations = await NebStore.getCompleteIntegrations(nebConfig, integrations);
return new NebConfig(nebConfig, fullIntegrations);
}
public static async createForUpstream(upstreamId: number): Promise<NebConfig> {
const upstream = await Upstream.findByPrimary(upstreamId);
if (!upstream) throw new Error("Upstream not found");
const nebConfig = await NebConfiguration.create({
upstreamId: upstream.id,
});
return NebStore.getConfig(nebConfig.id);
}
public static async createForAppservice(appserviceId: string, adminUrl: string): Promise<NebConfig> {
const appservice = await AppService.findByPrimary(appserviceId);
if (!appservice) throw new Error("Appservice not found");
const nebConfig = await NebConfiguration.create({
appserviceId: appservice.id,
adminUrl: adminUrl,
});
return NebStore.getConfig(nebConfig.id);
}
public static async getOrCreateIntegration(configurationId: number, integrationType: string): Promise<NebIntegration> {
if (!NebStore.INTEGRATIONS[integrationType]) throw new Error("Integration not supported");
const nebConfig = await NebConfiguration.findByPrimary(configurationId);
if (!nebConfig) throw new Error("Configuration not found");
let integration = await NebIntegration.findOne({where: {nebId: nebConfig.id, type: integrationType}});
if (!integration) {
LogService.info("NebStore", "Creating integration " + integrationType + " for NEB " + configurationId);
integration = await NebIntegration.create({
type: integrationType,
name: NebStore.INTEGRATIONS[integrationType].name,
avatarUrl: NebStore.INTEGRATIONS[integrationType].avatarUrl,
description: NebStore.INTEGRATIONS[integrationType].description,
isEnabled: false,
isPublic: true,
nebId: configurationId,
});
}
return integration;
}
public static async getCompleteIntegrations(nebConfig: NebConfiguration, knownIntegrations: NebIntegration[]): Promise<NebIntegration[]> {
const supported = NebStore.getSupportedIntegrations(nebConfig);
const notSupported: SupportedIntegration[] = [];
for (const supportedIntegration of supported) {
let isSupported = false;
for (const integration of knownIntegrations) {
if (integration.type === supportedIntegration.type) {
isSupported = true;
break;
}
}
if (!isSupported) notSupported.push(supportedIntegration);
}
const promises = [];
for (const missingIntegration of notSupported) {
promises.push(NebStore.getOrCreateIntegration(nebConfig.id, missingIntegration.type));
}
return Promise.all(promises).then(addedIntegrations => (addedIntegrations || []).concat(knownIntegrations));
}
public static getSupportedIntegrations(nebConfig: NebConfiguration): SupportedIntegration[] {
const result = [];
for (const type of Object.keys(NebStore.INTEGRATIONS)) {
if (nebConfig.upstreamId && NebStore.INTEGRATIONS_MODULAR_SUPPORTED.indexOf(type) === -1) continue;
const integrationConfig = JSON.parse(JSON.stringify(NebStore.INTEGRATIONS[type]));
integrationConfig["type"] = type;
result.push(integrationConfig);
}
return result;
}
public static async setIntegrationEnabled(configurationId: number, integrationType: string, isEnabled: boolean): Promise<any> {
const integration = await this.getOrCreateIntegration(configurationId, integrationType);
integration.isEnabled = isEnabled;
await integration.save();
const neb = await this.getConfig(configurationId);
if (!neb.appserviceId) return; // Done - nothing to do from here
const client = new NebClient(neb);
const botUsers = await NebBotUser.findAll({where: {integrationId: integration.id}});
for (const user of botUsers) {
await client.updateUser(user.appserviceUserId, isEnabled, true);
}
const notificationUsers = await NebNotificationUser.findAll({where: {integrationId: integration.id}});
for (const user of notificationUsers) {
await client.updateUser(user.appserviceUserId, isEnabled, false);
}
}
public static async getOrCreateBotUser(configurationId: number, integrationType: string): Promise<NebBotUser> {
const neb = await NebStore.getConfig(configurationId);
if (!neb.appserviceId) throw new Error("Instance not bound to an appservice");
const integration = await this.getOrCreateIntegration(configurationId, integrationType);
const users = await NebBotUser.findAll({where: {integrationId: integration.id}});
if (!users || users.length === 0) {
const appservice = await AppserviceStore.getAppservice(neb.appserviceId);
const userId = "@" + appservice.userPrefix + "_" + integrationType + ":" + config.homeserver.name;
const appserviceUser = await AppserviceStore.getOrCreateUser(neb.appserviceId, userId);
const client = new NebClient(neb);
await client.updateUser(userId, integration.isEnabled, true); // creates the user in go-neb
const serviceId = appservice.id + "_integration_" + integrationType;
return NebBotUser.create({
serviceId: serviceId,
appserviceUserId: appserviceUser.id,
integrationId: integration.id,
});
}
return users[0];
}
public static async getOrCreateNotificationUser(configurationId: number, integrationType: string, forUserId: string): Promise<NebNotificationUser> {
const neb = await NebStore.getConfig(configurationId);
if (!neb.appserviceId) throw new Error("Instance not bound to an appservice");
const integration = await this.getOrCreateIntegration(configurationId, integrationType);
const users = await NebNotificationUser.findAll({where: {integrationId: integration.id, ownerId: forUserId}});
if (!users || users.length === 0) {
const safeUserId = AppserviceStore.getSafeUserId(forUserId);
const appservice = await AppserviceStore.getAppservice(neb.appserviceId);
const userId = "@" + appservice.userPrefix + "_" + integrationType + "_notifications_" + safeUserId + ":" + config.homeserver.name;
const appserviceUser = await AppserviceStore.getOrCreateUser(neb.appserviceId, userId);
const client = new NebClient(neb);
await client.updateUser(userId, integration.isEnabled, false); // creates the user in go-neb
const serviceId = appservice.id + "_integration_" + integrationType + "_notifications_" + safeUserId;
return NebNotificationUser.create({
serviceId: serviceId,
appserviceUserId: appserviceUser.id,
integrationId: integration.id,
ownerId: forUserId,
});
}
return users[0];
}
public static async setIntegrationConfig(configurationId: number, integrationType: string, newConfig: any): Promise<any> {
const botUser = await NebStore.getOrCreateBotUser(configurationId, integrationType);
const neb = await NebStore.getConfig(configurationId);
const client = new NebClient(neb);
return client.setServiceConfig(botUser.serviceId, botUser.appserviceUserId, integrationType, newConfig);
}
public static async getIntegrationConfig(configurationId: number, integrationType: string): Promise<any> {
const botUser = await NebStore.getOrCreateBotUser(configurationId, integrationType);
const neb = await NebStore.getConfig(configurationId);
const client = new NebClient(neb);
return client.getServiceConfig(botUser.serviceId);
}
private constructor() {
}
}

48
src/db/ScalarStore.ts Normal file
View File

@ -0,0 +1,48 @@
import UserScalarToken from "./models/UserScalarToken";
import { LogService } from "matrix-js-snippets";
import Upstream from "./models/Upstream";
import User from "./models/User";
export class ScalarStore {
public static async doesUserHaveTokensForAllUpstreams(userId: string): Promise<boolean> {
const scalarTokens = await UserScalarToken.findAll({where: {userId: userId}});
const upstreamTokenIds = scalarTokens.filter(t => !t.isDimensionToken).map(t => t.upstreamId);
const hasDimensionToken = scalarTokens.filter(t => t.isDimensionToken).length >= 1;
if (!hasDimensionToken) {
LogService.warn("ScalarStore", "User " + userId + " is missing a Dimension scalar token");
return false;
}
const upstreams = await Upstream.findAll();
for (const upstream of upstreams) {
if (upstreamTokenIds.indexOf(upstream.id) === -1) {
LogService.warn("ScalarStore", "user " + userId + " is missing a scalar token for upstream " + upstream.id + " (" + upstream.name + ")");
return false;
}
}
return true;
}
public static async getTokenOwner(scalarToken: string, ignoreUpstreams?: boolean): Promise<User> {
const tokens = await UserScalarToken.findAll({
where: {isDimensionToken: true, scalarToken: scalarToken},
include: [User]
});
if (!tokens || tokens.length === 0) throw new Error("Invalid token");
const user = tokens[0].user;
if (ignoreUpstreams) return user; // skip upstreams check
const hasAllTokens = await ScalarStore.doesUserHaveTokensForAllUpstreams(user.userId);
if (!hasAllTokens) {
throw new Error("Invalid token"); // They are missing an upstream, so we'll lie and say they are not authorized
}
return user;
}
private constructor() {
}
}

33
src/db/WidgetStore.ts Normal file
View File

@ -0,0 +1,33 @@
import WidgetRecord from "./models/WidgetRecord";
import { Widget } from "../integrations/Widget";
export class WidgetStore {
public static async listAll(isEnabled?: boolean): Promise<Widget[]> {
let conditions = {};
if (isEnabled === true || isEnabled === false) conditions = {where: {isEnabled: isEnabled}};
return (await WidgetRecord.findAll(conditions)).map(w => new Widget(w));
}
public static async setEnabled(type: string, isEnabled: boolean): Promise<any> {
const widget = await WidgetRecord.findOne({where: {type: type}});
if (!widget) throw new Error("Widget not found");
widget.isEnabled = isEnabled;
return widget.save();
}
public static async setOptions(type: string, options: any): Promise<any> {
const optionsJson = JSON.stringify(options);
const widget = await WidgetRecord.findOne({where: {type: type}});
if (!widget) throw new Error("Widget not found");
widget.optionsJson = optionsJson;
return await widget.save();
}
private constructor() {
}
}

View File

@ -0,0 +1,41 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
export default {
up: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.createTable("dimension_users", {
"userId": {type: DataType.STRING, primaryKey: true, allowNull: false},
}))
.then(() => queryInterface.createTable("dimension_upstreams", {
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"name": {type: DataType.STRING, allowNull: false},
"type": {type: DataType.STRING, allowNull: false},
"scalarUrl": {type: DataType.STRING, allowNull: false},
"apiUrl": {type: DataType.STRING, allowNull: false},
}))
.then(() => queryInterface.createTable("dimension_scalar_tokens", {
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"userId": {
type: DataType.STRING,
allowNull: false,
references: {model: "dimension_users", key: "userId"},
onUpdate: "cascade", onDelete: "cascade",
},
"scalarToken": {type: DataType.STRING, allowNull: false},
"isDimensionToken": {type: DataType.BOOLEAN, allowNull: false},
"upstreamId": {
type: DataType.INTEGER,
allowNull: true,
references: {model: "dimension_upstreams", key: "id"},
onUpdate: "cascade", onDelete: "cascade",
},
}));
},
down: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.dropTable("dimension_users"))
.then(() => queryInterface.dropTable("dimension_upstreams"))
.then(() => queryInterface.dropTable("dimension_scalar_tokens"));
}
}

View File

@ -0,0 +1,81 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
export default {
up: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.createTable("dimension_widgets", {
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"type": {type: DataType.STRING, allowNull: false},
"name": {type: DataType.STRING, allowNull: false},
"avatarUrl": {type: DataType.STRING, allowNull: false},
"description": {type: DataType.STRING, allowNull: false},
"isEnabled": {type: DataType.BOOLEAN, allowNull: false},
"isPublic": {type: DataType.BOOLEAN, allowNull: false},
"optionsJson": {type: DataType.STRING, allowNull: true},
}))
.then(() => queryInterface.bulkInsert("dimension_widgets", [
{
type: "custom",
name: "Custom Widget",
avatarUrl: "/img/avatars/customwidget.png",
isEnabled: true,
isPublic: true,
description: "A webpage embedded in the room.",
},
{
type: "etherpad",
name: "Etherpad",
avatarUrl: "/img/avatars/etherpad.png",
isEnabled: true,
isPublic: true,
description: "Collaborate on documents with members of your room.",
optionsJson: '{"defaultUrl":"https://demo.riot.im/etherpad/p/$roomId_$padName"}',
},
{
type: "googlecalendar",
name: "Google Calendar",
isEnabled: true,
isPublic: true,
avatarUrl: "/img/avatars/googlecalendar.png",
description: "Share upcoming events in your room with a Google Calendar.",
},
{
type: "googledocs",
name: "Google Docs",
isEnabled: true,
isPublic: true,
avatarUrl: "/img/avatars/googledocs.png",
description: "Collaborate on and share documents using Google Docs.",
},
{
type: "youtube",
name: "YouTube Video",
isEnabled: true,
isPublic: true,
avatarUrl: "/img/avatars/youtube.png",
description: "Embed a YouTube, Vimeo, or DailyMotion video in your room.",
},
{
type: "twitch",
name: "Twitch Livestream",
isEnabled: true,
isPublic: true,
avatarUrl: "/img/avatars/twitch.png",
description: "Embed a Twitch livestream into your room.",
},
{
type: "jitsi",
name: "Jitsi Conference",
isEnabled: true,
isPublic: true,
avatarUrl: "/img/avatars/jitsi.png",
description: "Hold a video conference with Jitsi Meet",
optionsJson: '{"jitsiDomain":"jitsi.riot.im", "scriptUrl":"https://jitsi.riot.im/libs/external_api.min.js"}',
},
]));
},
down: (queryInterface: QueryInterface) => {
return queryInterface.dropTable("dimension_widgets");
}
}

View File

@ -0,0 +1,17 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
export default {
up: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.createTable("dimension_appservice", {
"id": {type: DataType.STRING, primaryKey: true, allowNull: false},
"hsToken": {type: DataType.STRING, allowNull: false},
"asToken": {type: DataType.STRING, allowNull: false},
"userPrefix": {type: DataType.STRING, allowNull: false},
}));
},
down: (queryInterface: QueryInterface) => {
return queryInterface.dropTable("dimension_appservice");
}
}

View File

@ -0,0 +1,22 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
export default {
up: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.createTable("dimension_appservice_users", {
"id": {type: DataType.STRING, primaryKey: true, allowNull: false},
"appserviceId": {
type: DataType.STRING, allowNull: false,
references: {model: "dimension_appservice", key: "id"},
onUpdate: "cascade", onDelete: "cascade",
},
"accessToken": {type: DataType.STRING, allowNull: false},
"displayName": {type: DataType.STRING, allowNull: true},
"avatarUrl": {type: DataType.STRING, allowNull: true},
}));
},
down: (queryInterface: QueryInterface) => {
return queryInterface.dropTable("dimension_appservice_users");
}
}

View File

@ -0,0 +1,76 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
export default {
up: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.createTable("dimension_neb_configurations", {
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"appserviceId": {
type: DataType.STRING, allowNull: true,
references: {model: "dimension_appservice", key: "id"},
onUpdate: "cascade", onDelete: "cascade",
},
"upstreamId": {
type: DataType.INTEGER, allowNull: true,
references: {model: "dimension_upstreams", key: "id"},
onUpdate: "cascade", onDelete: "cascade",
},
"adminUrl": {type: DataType.STRING, allowNull: true},
}))
.then(() => queryInterface.createTable("dimension_neb_integrations", {
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"type": {type: DataType.STRING, allowNull: false},
"name": {type: DataType.STRING, allowNull: false},
"avatarUrl": {type: DataType.STRING, allowNull: false},
"description": {type: DataType.STRING, allowNull: false},
"isEnabled": {type: DataType.BOOLEAN, allowNull: false},
"isPublic": {type: DataType.BOOLEAN, allowNull: false},
"nebId": {
type: DataType.INTEGER, allowNull: false,
references: {model: "dimension_neb_configurations", key: "id"},
onUpdate: "cascade", onDelete: "cascade",
},
}))
.then(() => queryInterface.createTable("dimension_neb_bot_users", {
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"serviceId": {type: DataType.STRING, allowNull: false},
"appserviceUserId": {
type: DataType.STRING, allowNull: false,
references: {model: "dimension_appservice_users", key: "id"},
onUpdate: "cascade", onDelete: "cascade",
},
"integrationId": {
type: DataType.INTEGER, allowNull: false,
references: {model: "dimension_neb_integrations", key: "id"},
onUpdate: "cascade", onDelete: "cascade",
},
}))
.then(() => queryInterface.createTable("dimension_neb_notification_users", {
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"serviceId": {type: DataType.STRING, allowNull: false},
"ownerId": {
type: DataType.STRING, allowNull: false,
references: {model: "dimension_users", key: "userId"},
onUpdate: "cascade", onDelete: "cascade",
},
"appserviceUserId": {
type: DataType.STRING, allowNull: false,
references: {model: "dimension_appservice_users", key: "id"},
onUpdate: "cascade", onDelete: "cascade",
},
"integrationId": {
type: DataType.INTEGER, allowNull: false,
references: {model: "dimension_neb_integrations", key: "id"},
onUpdate: "cascade", onDelete: "cascade",
},
}));
},
down: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.dropTable("dimension_neb_notification_users"))
.then(() => queryInterface.dropTable("dimension_neb_bot_users"))
.then(() => queryInterface.dropTable("dimension_neb_integrations"))
.then(() => queryInterface.dropTable("dimension_neb_configurations"));
}
}

View File

@ -0,0 +1,22 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
export default {
up: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.createTable("dimension_neb_integration_config", {
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"integrationId": {
type: DataType.INTEGER, allowNull: false,
references: {model: "dimension_neb_integrations", key: "id"},
onUpdate: "cascade", onDelete: "cascade",
},
"roomId": {type: DataType.STRING, allowNull: false},
"jsonContent": {type: DataType.STRING, allowNull: false},
}));
},
down: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.dropTable("dimension_neb_integration_config"));
}
}

View File

@ -0,0 +1,22 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
export default {
up: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.createTable("dimension_webhooks", {
"hookId": {type: DataType.STRING, primaryKey: true, allowNull: false},
"ownerUserId": {
type: DataType.STRING, allowNull: false,
references: {model: "dimension_users", key: "userId"},
onUpdate: "cascade", onDelete: "cascade",
},
"purposeId": {type: DataType.STRING, allowNull: false},
"targetUrl": {type: DataType.STRING, allowNull: true},
}));
},
down: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.dropTable("dimension_webhooks"));
}
}

View File

@ -0,0 +1,30 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
export default {
up: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.createTable("dimension_bridges", {
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"type": {type: DataType.STRING, allowNull: false},
"name": {type: DataType.STRING, allowNull: false},
"avatarUrl": {type: DataType.STRING, allowNull: false},
"description": {type: DataType.STRING, allowNull: false},
"isEnabled": {type: DataType.BOOLEAN, allowNull: false},
"isPublic": {type: DataType.BOOLEAN, allowNull: false},
}))
.then(() => queryInterface.bulkInsert("dimension_bridges", [
{
type: "irc",
name: "IRC Bridge",
avatarUrl: "/img/avatars/irc.png",
isEnabled: true,
isPublic: true,
description: "Bridges IRC channels to rooms, supporting multiple networks",
},
]));
},
down: (queryInterface: QueryInterface) => {
return queryInterface.dropTable("dimension_bridges");
}
}

View File

@ -0,0 +1,36 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
export default {
up: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.createTable("dimension_irc_bridges", {
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"upstreamId": {
type: DataType.INTEGER, allowNull: true,
references: {model: "dimension_upstreams", key: "id"},
onUpdate: "cascade", onDelete: "cascade",
},
"provisionUrl": {type: DataType.STRING, allowNull: true},
"isEnabled": {type: DataType.BOOLEAN, allowNull: false},
}))
.then(() => queryInterface.createTable("dimension_irc_bridge_networks", {
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"bridgeId": {
type: DataType.INTEGER, allowNull: false,
references: {model: "dimension_irc_bridges", key: "id"},
onUpdate: "cascade", onDelete: "cascade",
},
"bridgeNetworkId": {type: DataType.STRING, allowNull: false},
"bridgeUserId": {type: DataType.STRING, allowNull: false},
"displayName": {type: DataType.STRING, allowNull: false},
"domain": {type: DataType.STRING, allowNull: false},
"isEnabled": {type: DataType.BOOLEAN, allowNull: false},
}));
},
down: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.dropTable("dimension_irc_bridges"))
.then(() => queryInterface.dropTable("dimension_irc_bridge_networks"));
}
}

View File

@ -0,0 +1,21 @@
import { Column, Model, PrimaryKey, Table } from "sequelize-typescript";
@Table({
tableName: "dimension_appservice",
underscoredAll: false,
timestamps: false,
})
export default class AppService extends Model<AppService> {
@PrimaryKey
@Column
id: string;
@Column
hsToken: string;
@Column
asToken: string;
@Column
userPrefix: string;
}

View File

@ -0,0 +1,28 @@
import { AllowNull, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript";
import AppService from "./AppService";
@Table({
tableName: "dimension_appservice_users",
underscoredAll: false,
timestamps: false,
})
export default class AppServiceUser extends Model<AppServiceUser> {
@PrimaryKey
@Column
id: string;
@Column
accessToken: string;
@AllowNull
@Column
avatarUrl?: string;
@AllowNull
@Column
displayName?: string;
@Column
@ForeignKey(() => AppService)
appserviceId: string;
}

View File

@ -0,0 +1,32 @@
import { AutoIncrement, Column, Model, PrimaryKey, Table } from "sequelize-typescript";
import { IntegrationRecord } from "./IntegrationRecord";
@Table({
tableName: "dimension_bridges",
underscoredAll: false,
timestamps: false,
})
export default class BridgeRecord extends Model<BridgeRecord> implements IntegrationRecord {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@Column
type: string;
@Column
name: string;
@Column
avatarUrl: string;
@Column
description: string;
@Column
isEnabled: boolean;
@Column
isPublic: boolean;
}

View File

@ -0,0 +1,8 @@
export interface IntegrationRecord {
type: string;
name: string;
avatarUrl: string;
description: string;
isEnabled: boolean;
isPublic: boolean;
}

View File

@ -0,0 +1,34 @@
import { AllowNull, AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript";
import IrcBridgeRecord from "./IrcBridgeRecord";
@Table({
tableName: "dimension_irc_bridge_networks",
underscoredAll: false,
timestamps: false,
})
export default class IrcBridgeNetwork extends Model<IrcBridgeNetwork> {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@AllowNull
@Column
@ForeignKey(() => IrcBridgeRecord)
bridgeId: number;
@Column
isEnabled: boolean;
@Column
bridgeNetworkId: string; // the real ID as given by /querynetworks
@Column
bridgeUserId: string;
@Column
displayName: string;
@Column
domain: string;
}

View File

@ -0,0 +1,26 @@
import { AllowNull, AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript";
import Upstream from "./Upstream";
@Table({
tableName: "dimension_irc_bridges",
underscoredAll: false,
timestamps: false,
})
export default class IrcBridgeRecord extends Model<IrcBridgeRecord> {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@AllowNull
@Column
@ForeignKey(() => Upstream)
upstreamId?: number;
@AllowNull
@Column
provisionUrl?: string;
@Column
isEnabled: boolean;
}

View File

@ -0,0 +1,26 @@
import { AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript";
import AppServiceUser from "./AppServiceUser";
import NebIntegration from "./NebIntegration";
@Table({
tableName: "dimension_neb_bot_users",
underscoredAll: false,
timestamps: false,
})
export default class NebBotUser extends Model<NebBotUser> {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@Column
serviceId: string;
@Column
@ForeignKey(() => AppServiceUser)
appserviceUserId: string;
@Column
@ForeignKey(() => NebIntegration)
integrationId: number;
}

View File

@ -0,0 +1,29 @@
import { AllowNull, AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript";
import Upstream from "./Upstream";
import AppService from "./AppService";
@Table({
tableName: "dimension_neb_configurations",
underscoredAll: false,
timestamps: false,
})
export default class NebConfiguration extends Model<NebConfiguration> {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@AllowNull
@Column
adminUrl?: string;
@AllowNull
@Column
@ForeignKey(() => AppService)
appserviceId?: string;
@AllowNull
@Column
@ForeignKey(() => Upstream)
upstreamId?: number;
}

View File

@ -0,0 +1,37 @@
import { AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript";
import { IntegrationRecord } from "./IntegrationRecord";
import NebConfiguration from "./NebConfiguration";
@Table({
tableName: "dimension_neb_integrations",
underscoredAll: false,
timestamps: false,
})
export default class NebIntegration extends Model<NebIntegration> implements IntegrationRecord {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@Column
type: string;
@Column
name: string;
@Column
avatarUrl: string;
@Column
description: string;
@Column
isEnabled: boolean;
@Column
isPublic: boolean;
@Column
@ForeignKey(() => NebConfiguration)
nebId: number;
}

View File

@ -0,0 +1,24 @@
import { AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript";
import NebIntegration from "./NebIntegration";
@Table({
tableName: "dimension_neb_integration_config",
underscoredAll: false,
timestamps: false,
})
export default class NebIntegrationConfig extends Model<NebIntegrationConfig> {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@Column
@ForeignKey(() => NebIntegration)
integrationId: string;
@Column
roomId: string;
@Column
jsonContent: string;
}

View File

@ -0,0 +1,31 @@
import { AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript";
import AppServiceUser from "./AppServiceUser";
import NebIntegration from "./NebIntegration";
import User from "./User";
@Table({
tableName: "dimension_neb_notification_users",
underscoredAll: false,
timestamps: false,
})
export default class NebNotificationUser extends Model<NebNotificationUser> {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@Column
serviceId: string;
@Column
@ForeignKey(() => User)
ownerId: string;
@Column
@ForeignKey(() => AppServiceUser)
appserviceUserId: string;
@Column
@ForeignKey(() => NebIntegration)
integrationId: number;
}

25
src/db/models/Upstream.ts Normal file
View File

@ -0,0 +1,25 @@
import { AutoIncrement, Column, Model, PrimaryKey, Table } from "sequelize-typescript";
@Table({
tableName: "dimension_upstreams",
underscoredAll: false,
timestamps: false,
})
export default class Upstream extends Model<Upstream> {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@Column
name: string;
@Column
type: string;
@Column
scalarUrl: string;
@Column
apiUrl: string;
}

14
src/db/models/User.ts Normal file
View File

@ -0,0 +1,14 @@
import { Column, Model, PrimaryKey, Table } from "sequelize-typescript";
@Table({
tableName: "dimension_users",
underscoredAll: false,
timestamps: false,
})
export default class User extends Model<User> {
// This is really just a holding class to keep foreign keys alive
@PrimaryKey
@Column
userId: string;
}

View File

@ -0,0 +1,36 @@
import {
AllowNull, AutoIncrement, BelongsTo, Column, ForeignKey, Model, PrimaryKey,
Table
} from "sequelize-typescript";
import User from "./User";
import Upstream from "./Upstream";
@Table({
tableName: "dimension_scalar_tokens",
underscoredAll: false,
timestamps: false,
})
export default class UserScalarToken extends Model<UserScalarToken> {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@Column
@ForeignKey(() => User)
userId: string;
@BelongsTo(() => User)
user: User;
@Column
scalarToken: string;
@Column
isDimensionToken: boolean;
@AllowNull
@Column
@ForeignKey(() => Upstream)
upstreamId?: number;
}

25
src/db/models/Webhook.ts Normal file
View File

@ -0,0 +1,25 @@
import { Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript";
import User from "./User";
@Table({
tableName: "dimension_webhooks",
underscoredAll: false,
timestamps: false,
})
export default class Webhook extends Model<Webhook> {
// This is really just a holding class to keep foreign keys alive
@PrimaryKey
@Column
hookId: string;
@Column
@ForeignKey(() => User)
ownerUserId: string;
@Column
purposeId: string;
@Column
targetUrl: string;
}

View File

@ -0,0 +1,35 @@
import { AutoIncrement, Column, Model, PrimaryKey, Table } from "sequelize-typescript";
import { IntegrationRecord } from "./IntegrationRecord";
@Table({
tableName: "dimension_widgets",
underscoredAll: false,
timestamps: false,
})
export default class WidgetRecord extends Model<WidgetRecord> implements IntegrationRecord {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@Column
type: string;
@Column
name: string;
@Column
avatarUrl: string;
@Column
description: string;
@Column
isEnabled: boolean;
@Column
isPublic: boolean;
@Column
optionsJson: string;
}

17
src/index.ts Normal file
View File

@ -0,0 +1,17 @@
import { LogService } from "matrix-js-snippets";
import config from "./config";
import { DimensionStore } from "./db/DimensionStore";
import Webserver from "./api/Webserver";
import { CURRENT_VERSION } from "./version";
LogService.configure(config.logging);
LogService.info("index", "Starting dimension " + CURRENT_VERSION);
async function startup() {
await DimensionStore.updateSchema();
const webserver = new Webserver();
await webserver.start();
}
startup().then(() => LogService.info("index", "Dimension is ready!"));

View File

@ -1,22 +0,0 @@
var IntegrationStub = require("./IntegrationStub");
/**
* Represents a bridge. Normally bridges have enhanced configuration and requirements over bots.
*/
class Bridge extends IntegrationStub {
/**
* Creates a new bridge
* @param bridgeConfig the configuration for the bridge
*/
constructor(bridgeConfig) {
super(bridgeConfig);
}
/*override*/
getUserId() {
return null; // bridges don't have bot users we care about
}
}
module.exports = Bridge;

View File

@ -1,18 +0,0 @@
var IntegrationStub = require("./IntegrationStub");
/**
* Represents a bot with additional configuration or setup needs. Normally indicates a bot needs
* more than a simple invite to the room.
*/
class ComplexBot extends IntegrationStub {
/**
* Creates a new complex bot
* @param botConfig the configuration for the bot
*/
constructor(botConfig) {
super(botConfig);
}
}
module.exports = ComplexBot;

View File

@ -1,44 +0,0 @@
/**
* Stub for an Integration
*/
class IntegrationStub {
constructor(botConfig) {
this._config = botConfig;
}
/**
* Gets the user ID for this bot
* @return {Promise<string>} resolves to the user ID
*/
getUserId() {
return Promise.resolve(this._config.userId);
}
/**
* Gets state information that represents how this bot is operating.
* @return {Promise<*>} resolves to the state information
*/
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");
}
/**
* Updates the state information for this integration. The data passed is an implementation detail.
* @param {*} newState the new state
* @returns {Promise<*>} resolves when completed, with the new state of the integration
*/
updateState(newState) {
return Promise.resolve({});
}
}
module.exports = IntegrationStub;

Some files were not shown because too many files have changed in this diff Show More