mirror of
https://github.com/turt2live/matrix-dimension.git
synced 2024-09-29 20:25:58 +00:00
Re-implement the Scalar API in typescript
This is part of a rewrite for Dimension to better support integrations. Only the bare minimum scalar APIs are implemented at this point - dimension is non-functional.
This commit is contained in:
parent
7a8b27fa22
commit
826364e803
2
.gitignore
vendored
2
.gitignore
vendored
@ -8,6 +8,8 @@ db/*.db
|
||||
start.sh
|
||||
config/integrations/*_development.yaml
|
||||
config/integrations/*_production.yaml
|
||||
build/
|
||||
dimension.db
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
@ -9,3 +9,4 @@ install:
|
||||
- npm install
|
||||
script:
|
||||
- npm run build
|
||||
- npm run lint:app
|
||||
|
@ -1,13 +0,0 @@
|
||||
{
|
||||
"defaultEnv": {
|
||||
"ENV": "NODE_ENV"
|
||||
},
|
||||
"development": {
|
||||
"driver": "sqlite",
|
||||
"filename": "db/development.db"
|
||||
},
|
||||
"production": {
|
||||
"driver": "sqlite",
|
||||
"filename": "db/production.db"
|
||||
}
|
||||
}
|
@ -3,6 +3,28 @@ web:
|
||||
port: 8184
|
||||
address: '0.0.0.0'
|
||||
|
||||
# Homeserver configuration (used to proxy some requests to the homeserver for processing)
|
||||
homeserver:
|
||||
name: "t2bot.io"
|
||||
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
|
||||
@ -12,28 +34,3 @@ logging:
|
||||
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
|
@ -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
|
||||
};
|
@ -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
|
||||
};
|
866
package-lock.json
generated
866
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
46
package.json
46
package.json
@ -2,46 +2,49 @@
|
||||
"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:dev": "webpack-dev-server --inline --progress --port 8080 --host 0.0.0.0",
|
||||
"start:app": "npm run-script build && node build/app/index.js",
|
||||
"build": "rimraf build && npm run-script build:web && npm run-script build:app",
|
||||
"build:web": "webpack --progress --profile --bail",
|
||||
"build:app": "tsc -p tsconfig-app.json",
|
||||
"lint:app": "tslint --project ./tsconfig-app.json -t stylish"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/turt2live/matrix-dimension.git"
|
||||
},
|
||||
"greenkeeper": {
|
||||
"ignore": [
|
||||
"@types/node"
|
||||
]
|
||||
},
|
||||
"author": "Travis Ralston",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "^1.16.8",
|
||||
"@types/node": "^8.5.1",
|
||||
"bluebird": "^3.5.1",
|
||||
"body-parser": "^1.18.2",
|
||||
"chalk": "^2.3.0",
|
||||
"config": "^1.28.1",
|
||||
"db-migrate": "^0.10.2",
|
||||
"db-migrate-sqlite3": "^0.2.1",
|
||||
"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",
|
||||
"matrix-js-sdk": "^0.9.2",
|
||||
"matrix-js-snippets": "^0.2.5",
|
||||
"memory-cache": "^0.2.0",
|
||||
"mime": "^2.0.3",
|
||||
"moment": "^2.19.3",
|
||||
"netmask": "^1.0.6",
|
||||
"random-string": "^0.2.0",
|
||||
"request": "^2.83.0",
|
||||
"screenfull": "^3.3.2",
|
||||
"require-dir-all": "^0.4.12",
|
||||
"sequelize": "^4.27.0",
|
||||
"sequelize-typescript": "^0.6.1",
|
||||
"sqlite3": "^3.1.13",
|
||||
"url": "^0.11.0",
|
||||
"winston": "^2.4.0"
|
||||
"typescript-rest": "^1.2.0",
|
||||
"url": "^0.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"embed-video": "^2.0.0",
|
||||
"screenfull": "^3.3.2",
|
||||
"@angular/animations": "^5.0.0",
|
||||
"@angular/common": "^5.0.0",
|
||||
"@angular/compiler": "^5.0.0",
|
||||
@ -55,12 +58,11 @@
|
||||
"@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",
|
||||
"angular2-template-loader": "^0.6.2",
|
||||
"angular2-toaster": "^4.0.0",
|
||||
"angular2-ui-switch": "^1.2.0",
|
||||
"awesome-typescript-loader": "^3.4.1",
|
||||
"codelyzer": "^3.2.2",
|
||||
"codelyzer": "^4.0.2",
|
||||
"copy-webpack-plugin": "^4.2.3",
|
||||
"core-js": "^2.5.2",
|
||||
"css-loader": "^0.28.7",
|
||||
@ -73,10 +75,10 @@
|
||||
"jquery": "^3.2.1",
|
||||
"json-loader": "^0.5.4",
|
||||
"ng2-breadcrumbs": "^0.1.281",
|
||||
"ngx-modialog": "^3.0.4",
|
||||
"ngx-modialog": "^5.0.0",
|
||||
"node-sass": "^4.7.2",
|
||||
"postcss-cssnext": "^3.0.0",
|
||||
"postcss-import": "^10.0.0",
|
||||
"postcss-import": "^11.0.0",
|
||||
"postcss-loader": "^2.0.9",
|
||||
"postcss-scss": "^1.0.0",
|
||||
"raw-loader": "^0.5.1",
|
||||
@ -86,12 +88,12 @@
|
||||
"sass-loader": "^6.0.3",
|
||||
"shelljs": "^0.7.8",
|
||||
"spinkit": "^1.2.5",
|
||||
"style-loader": "^0.18.2",
|
||||
"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",
|
||||
"url-loader": "^0.6.2",
|
||||
"webpack": "^3.10.0",
|
||||
"webpack-dev-server": "^2.9.7",
|
||||
"zone.js": "^0.8.18"
|
||||
|
25
src-ts/MemoryCache.ts
Normal file
25
src-ts/MemoryCache.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import * as cache from "memory-cache";
|
||||
|
||||
export class MemoryCache {
|
||||
|
||||
private internalCache = new cache.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();
|
||||
}
|
||||
}
|
14
src-ts/api/ApiError.ts
Normal file
14
src-ts/api/ApiError.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export class ApiError {
|
||||
|
||||
public statusCode: number;
|
||||
public jsonResponse: any;
|
||||
|
||||
constructor(statusCode: number, json: any) {
|
||||
// Because typescript is just plain dumb
|
||||
// https://stackoverflow.com/questions/31626231/custom-error-class-in-typescript
|
||||
Error.apply(this, ["ApiError"]);
|
||||
|
||||
this.jsonResponse = json;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
76
src-ts/api/Webserver.ts
Normal file
76
src-ts/api/Webserver.ts
Normal file
@ -0,0 +1,76 @@
|
||||
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";
|
||||
|
||||
export default class Webserver {
|
||||
|
||||
private app: express.Application;
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
|
||||
this.configure();
|
||||
this.loadRoutes();
|
||||
}
|
||||
|
||||
private loadRoutes() {
|
||||
const apis = ["scalar", "dimension"].map(a => path.join(__dirname, a, "*.js"));
|
||||
const router = express.Router();
|
||||
Server.loadServices(router, apis);
|
||||
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.verbose("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();
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
this.app.listen(config.web.port, config.web.address);
|
||||
LogService.info("Webserver", "API and UI listening on " + config.web.address + ":" + config.web.port);
|
||||
}
|
||||
}
|
106
src-ts/api/scalar/ScalarService.ts
Normal file
106
src-ts/api/scalar/ScalarService.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { GET, Path, POST, QueryParam } from "typescript-rest";
|
||||
import * as Promise from "bluebird";
|
||||
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 { DimensionStore } from "../../db/DimensionStore";
|
||||
import { MemoryCache } from "../../MemoryCache";
|
||||
|
||||
interface RegisterRequest {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
matrix_server_name: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
@Path("/api/v1/scalar")
|
||||
export class ScalarService {
|
||||
|
||||
private static accountCache = new MemoryCache();
|
||||
|
||||
public static clearAccountCache(): void {
|
||||
ScalarService.accountCache.clear();
|
||||
}
|
||||
|
||||
public static getTokenOwner(scalarToken: string): Promise<string> {
|
||||
const cachedUserId = ScalarService.accountCache.get(scalarToken);
|
||||
if (cachedUserId) return Promise.resolve(cachedUserId);
|
||||
|
||||
return DimensionStore.getTokenOwner(scalarToken).then(user => {
|
||||
if (!user) return Promise.reject("Invalid token");
|
||||
ScalarService.accountCache.put(scalarToken, user.userId, 30 * 60 * 1000); // 30 minutes
|
||||
return Promise.resolve(user.userId);
|
||||
});
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("register")
|
||||
public register(request: RegisterRequest): Promise<ScalarRegisterResponse> {
|
||||
let userId = null;
|
||||
const mxClient = new MatrixOpenIdClient(<OpenId>request);
|
||||
return mxClient.getUserId().then(mxUserId => {
|
||||
userId = mxUserId;
|
||||
return User.findByPrimary(userId).then(user => {
|
||||
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 " + userId + " never seen before - creating");
|
||||
return User.create({userId: userId});
|
||||
}
|
||||
});
|
||||
}).then(() => {
|
||||
return Upstream.findAll();
|
||||
}).then(upstreams => {
|
||||
return Promise.all(upstreams.map(u => {
|
||||
return UserScalarToken.findAll({where: {userId: userId, upstreamId: u.id}}).then(tokens => {
|
||||
if (!tokens || tokens.length === 0) {
|
||||
LogService.info("ScalarService", "Registering " + userId + " for token at upstream " + u.id + " (" + u.name + ")");
|
||||
const client = new ScalarClient(u);
|
||||
return client.register(<OpenId>request).then(registerResponse => {
|
||||
return UserScalarToken.create({
|
||||
userId: userId,
|
||||
scalarToken: registerResponse.scalar_token,
|
||||
isDimensionToken: false,
|
||||
upstreamId: u.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}));
|
||||
}).then(() => {
|
||||
const dimensionToken = randomString({length: 25});
|
||||
return UserScalarToken.create({
|
||||
userId: userId,
|
||||
scalarToken: dimensionToken,
|
||||
isDimensionToken: true,
|
||||
});
|
||||
}).then(userToken => {
|
||||
return {scalar_token: userToken.scalarToken};
|
||||
}).catch(err => {
|
||||
LogService.error("ScalarService", err);
|
||||
throw new ApiError(401, {message: "Failed to authenticate user"});
|
||||
});
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("account")
|
||||
public getAccount(@QueryParam("scalar_token") scalarToken: string): Promise<ScalarAccountResponse> {
|
||||
return ScalarService.getTokenOwner(scalarToken).then(userId => {
|
||||
return {user_id: userId};
|
||||
}, err => {
|
||||
if (err !== "Invalid token") {
|
||||
LogService.error("ScalarService", "Error getting account information for user");
|
||||
LogService.error("ScalarService", err);
|
||||
}
|
||||
throw new ApiError(401, {message: "Invalid token"});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
81
src-ts/api/scalar/ScalarWidgetService.ts
Normal file
81
src-ts/api/scalar/ScalarWidgetService.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { GET, Path, QueryParam } from "typescript-rest";
|
||||
import * as Promise from "bluebird";
|
||||
import { LogService } from "matrix-js-snippets";
|
||||
import { ApiError } from "../ApiError";
|
||||
import { MemoryCache } 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;
|
||||
};
|
||||
}
|
||||
|
||||
@Path("/api/v1/scalar/widgets")
|
||||
export class ScalarWidgetService {
|
||||
|
||||
private static urlCache = new MemoryCache();
|
||||
|
||||
private static getUrlTitle(url: string): Promise<UrlPreviewResponse> {
|
||||
const cachedResult = ScalarWidgetService.urlCache.get(url);
|
||||
if (cachedResult) {
|
||||
cachedResult.cached_response = true;
|
||||
return Promise.resolve(cachedResult);
|
||||
}
|
||||
|
||||
const client = new MatrixLiteClient(config.homeserver.name, config.homeserver.accessToken);
|
||||
return client.getUrlPreview(url).then(preview => {
|
||||
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},
|
||||
};
|
||||
ScalarWidgetService.urlCache.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",
|
||||
},
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("title_lookup")
|
||||
public register(@QueryParam("scalar_token") scalarToken: string, @QueryParam("curl") url: string): Promise<UrlPreviewResponse> {
|
||||
return ScalarService.getTokenOwner(scalarToken).then(_userId => {
|
||||
return ScalarWidgetService.getUrlTitle(url);
|
||||
}, err => {
|
||||
if (err !== "Invalid token") {
|
||||
LogService.error("ScalarWidgetService", "Error getting account information for user");
|
||||
LogService.error("ScalarWidgetService", err);
|
||||
}
|
||||
throw new ApiError(401, {message: "Invalid token"});
|
||||
})
|
||||
}
|
||||
}
|
21
src-ts/config.ts
Normal file
21
src-ts/config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import * as config from "config";
|
||||
import { LogConfig } from "matrix-js-snippets";
|
||||
|
||||
export interface DimensionConfig {
|
||||
web: {
|
||||
port: number;
|
||||
address: string;
|
||||
};
|
||||
homeserver: {
|
||||
name: string;
|
||||
accessToken: string;
|
||||
};
|
||||
widgetBlacklist: string[];
|
||||
database: {
|
||||
file: string;
|
||||
};
|
||||
admins: string[];
|
||||
logging: LogConfig;
|
||||
}
|
||||
|
||||
export default <DimensionConfig>config;
|
75
src-ts/db/DimensionStore.ts
Normal file
75
src-ts/db/DimensionStore.ts
Normal file
@ -0,0 +1,75 @@
|
||||
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 * as Promise from "bluebird";
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
public updateSchema(): Promise<any> {
|
||||
LogService.info("DimensionStore", "Updating schema...");
|
||||
return this.sequelize.sync();
|
||||
}
|
||||
|
||||
public doesUserHaveTokensForAllUpstreams(userId: string): Promise<boolean> {
|
||||
let upstreamTokenIds: number[] = [];
|
||||
let hasDimensionToken = false;
|
||||
return UserScalarToken.findAll({where: {userId: userId}}).then(results => {
|
||||
upstreamTokenIds = results.filter(t => !t.isDimensionToken).map(t => t.upstreamId);
|
||||
hasDimensionToken = results.filter(t => t.isDimensionToken).length >= 1;
|
||||
return Upstream.findAll();
|
||||
}).then(upstreams => {
|
||||
if (!hasDimensionToken) {
|
||||
LogService.warn("DimensionStore", "User " + userId + " is missing a Dimension scalar token");
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const upstream of upstreams) {
|
||||
if (upstreamTokenIds.indexOf(upstream.id) === -1) {
|
||||
LogService.warn("DimensionStore", "User " + userId + " is missing a scalar token for upstream " + upstream.id + " (" + upstream.name + ")");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public getTokenOwner(scalarToken: string): Promise<User> {
|
||||
let user: User = null;
|
||||
return UserScalarToken.findAll({where: {isDimensionToken: true, scalarToken: scalarToken}, include: [User]}).then(tokens => {
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return Promise.reject("Invalid token");
|
||||
}
|
||||
|
||||
user = tokens[0].user;
|
||||
return this.doesUserHaveTokensForAllUpstreams(user.userId);
|
||||
}).then(hasUpstreams => {
|
||||
if (!hasUpstreams) {
|
||||
return Promise.reject("Invalid token"); // missing one or more upstreams == no validation
|
||||
}
|
||||
return Promise.resolve(user);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const DimensionStore = new _DimensionStore();
|
25
src-ts/db/models/Upstream.ts
Normal file
25
src-ts/db/models/Upstream.ts
Normal 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-ts/db/models/User.ts
Normal file
14
src-ts/db/models/User.ts
Normal 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;
|
||||
}
|
39
src-ts/db/models/UserScalarToken.ts
Normal file
39
src-ts/db/models/UserScalarToken.ts
Normal file
@ -0,0 +1,39 @@
|
||||
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;
|
||||
|
||||
@BelongsTo(() => Upstream)
|
||||
upstream: Upstream;
|
||||
}
|
15
src-ts/index.ts
Normal file
15
src-ts/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { LogService } from "matrix-js-snippets";
|
||||
import config from "./config";
|
||||
import { DimensionStore } from "./db/DimensionStore";
|
||||
import Webserver from "./api/Webserver";
|
||||
|
||||
LogService.configure(config.logging);
|
||||
LogService.info("index", "Starting voyager...");
|
||||
|
||||
const webserver = new Webserver();
|
||||
|
||||
DimensionStore.updateSchema()
|
||||
.then(() => webserver.start())
|
||||
.then(() => {
|
||||
LogService.info("index", "Dimension is ready!");
|
||||
});
|
24
src-ts/matrix/MatrixLiteClient.ts
Normal file
24
src-ts/matrix/MatrixLiteClient.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import * as Promise from "bluebird";
|
||||
import { doFederatedApiCall } from "./helpers";
|
||||
|
||||
export interface MatrixUrlPreview {
|
||||
// This is really the only parameter we care about
|
||||
"og:title"?: string;
|
||||
}
|
||||
|
||||
export class MatrixLiteClient {
|
||||
|
||||
constructor(private homeserverName: string, private accessToken: string) {
|
||||
}
|
||||
|
||||
public getUrlPreview(url: string): Promise<MatrixUrlPreview> {
|
||||
return doFederatedApiCall(
|
||||
"GET",
|
||||
this.homeserverName,
|
||||
"/_matrix/media/r0/preview_url",
|
||||
{access_token: this.accessToken, url: url}
|
||||
).then(response => {
|
||||
return response;
|
||||
});
|
||||
}
|
||||
}
|
20
src-ts/matrix/MatrixOpenIdClient.ts
Normal file
20
src-ts/matrix/MatrixOpenIdClient.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import * as Promise from "bluebird";
|
||||
import { doFederatedApiCall } from "./helpers";
|
||||
import { OpenId } from "../models/OpenId";
|
||||
|
||||
export class MatrixOpenIdClient {
|
||||
|
||||
constructor(private openId: OpenId) {
|
||||
}
|
||||
|
||||
public getUserId(): Promise<string> {
|
||||
return doFederatedApiCall(
|
||||
"GET",
|
||||
this.openId.matrix_server_name,
|
||||
"/_matrix/federation/v1/openid/userinfo",
|
||||
{access_token: this.openId.access_token}
|
||||
).then(response => {
|
||||
return response['sub'];
|
||||
});
|
||||
}
|
||||
}
|
61
src-ts/matrix/helpers.ts
Normal file
61
src-ts/matrix/helpers.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import * as dns from "dns-then";
|
||||
import * as Promise from "bluebird";
|
||||
import { LogService } from "matrix-js-snippets";
|
||||
import { MemoryCache } from "../MemoryCache";
|
||||
import * as request from "request";
|
||||
|
||||
const federationUrlCache = new MemoryCache();
|
||||
|
||||
export function getFederationUrl(serverName: string): Promise<string> {
|
||||
const cachedUrl = federationUrlCache.get(serverName);
|
||||
if (cachedUrl) {
|
||||
LogService.verbose("matrix", "Cached federation URL for " + serverName + " is " + cachedUrl);
|
||||
return Promise.resolve(cachedUrl);
|
||||
}
|
||||
|
||||
let serverUrl = null;
|
||||
let expirationMs = 4 * 60 * 60 * 1000; // default is 4 hours
|
||||
const dnsPromise = dns.resolveSrv("_matrix._tcp." + serverName);
|
||||
return Promise.resolve(dnsPromise).then(records => {
|
||||
if (records && records.length > 0) {
|
||||
serverUrl = "https://" + records[0].name + ":" + records[0].port;
|
||||
expirationMs = records[0].ttl * 1000;
|
||||
}
|
||||
}, _err => {
|
||||
// Not having the SRV record isn't bad, it just means that the server operator decided to not use SRV records.
|
||||
// When there's no SRV record we default to port 8448 (as per the federation rules) in the lower .then()
|
||||
// People tend to think that the lack of an SRV record is bad, but in reality it's only a problem if one was set and
|
||||
// it's not being found. Most people don't set up the SRV record, but some do.
|
||||
LogService.warn("matrix", "Could not find _matrix._tcp." + serverName + " DNS record. This is normal for most servers.");
|
||||
}).then(() => {
|
||||
if (!serverUrl) serverUrl = "https://" + serverName + ":8448";
|
||||
LogService.verbose("matrix", "Federation URL for " + serverName + " is " + serverUrl + " - caching for " + expirationMs + " ms");
|
||||
federationUrlCache.put(serverName, serverUrl, expirationMs);
|
||||
return serverUrl;
|
||||
});
|
||||
}
|
||||
|
||||
export function doFederatedApiCall(method: string, serverName: string, endpoint: string, query?: object, body?:object):Promise<any> {
|
||||
return getFederationUrl(serverName).then(federationUrl => {
|
||||
return new Promise((resolve, reject) => {
|
||||
request({
|
||||
method: method,
|
||||
url: federationUrl + endpoint,
|
||||
qs: query,
|
||||
json: body,
|
||||
rejectUnauthorized: false, // allow self signed certs (for federation)
|
||||
}, (err, res, _body) => {
|
||||
if (err) {
|
||||
LogService.error("matrix", "Error calling " + endpoint);
|
||||
LogService.error("matrix", err);
|
||||
reject(err);
|
||||
} else if (res.statusCode !== 200) {
|
||||
LogService.error("matrix", "Got status code " + res.statusCode + " while calling " + endpoint);
|
||||
reject(new Error("Error in request: invalid status code"));
|
||||
} else {
|
||||
resolve(res.body);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
6
src-ts/models/OpenId.ts
Normal file
6
src-ts/models/OpenId.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface OpenId {
|
||||
access_token: string;
|
||||
matrix_server_name: string;
|
||||
expires_in: number;
|
||||
token_type: 'Bearer';
|
||||
}
|
8
src-ts/models/ScalarResponses.ts
Normal file
8
src-ts/models/ScalarResponses.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface ScalarRegisterResponse {
|
||||
scalar_token: string;
|
||||
}
|
||||
|
||||
export interface ScalarAccountResponse {
|
||||
user_id: string;
|
||||
//credit: number; // present on scalar-web
|
||||
}
|
32
src-ts/scalar/ScalarClient.ts
Normal file
32
src-ts/scalar/ScalarClient.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { OpenId } from "../models/OpenId";
|
||||
import { ScalarRegisterResponse } from "../models/ScalarResponses";
|
||||
import * as Promise from "bluebird";
|
||||
import * as request from "request";
|
||||
import { LogService } from "matrix-js-snippets";
|
||||
import Upstream from "../db/models/Upstream";
|
||||
|
||||
export class ScalarClient {
|
||||
constructor(private upstream: Upstream) {
|
||||
}
|
||||
|
||||
public register(openId: OpenId): Promise<ScalarRegisterResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request({
|
||||
method: "POST",
|
||||
url: this.upstream.scalarUrl + "/register",
|
||||
json: openId,
|
||||
}, (err, res, _body) => {
|
||||
if (err) {
|
||||
LogService.error("ScalarClient", "Error registering for token");
|
||||
LogService.error("ScalarClient", err);
|
||||
reject(err);
|
||||
} else if (res.statusCode !== 200) {
|
||||
LogService.error("ScalarClient", "Got status code " + res.statusCode + " while registering for token");
|
||||
reject(new Error("Could not get token"));
|
||||
} else {
|
||||
resolve(res.body);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
5
src-ts/temp_todo.txt
Normal file
5
src-ts/temp_todo.txt
Normal file
@ -0,0 +1,5 @@
|
||||
* dimension apis
|
||||
* integration management
|
||||
* admin ui
|
||||
* delete old src
|
||||
* import from old db/config (script)
|
18
tsconfig-app.json
Normal file
18
tsconfig-app.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"target": "es2015",
|
||||
"noImplicitAny": false,
|
||||
"sourceMap": true,
|
||||
"outDir": "./build/app",
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"./src-ts/**/*"
|
||||
]
|
||||
}
|
@ -9,7 +9,7 @@
|
||||
"check-space"
|
||||
],
|
||||
"curly": false,
|
||||
"eofline": true,
|
||||
"eofline": false,
|
||||
"forin": false,
|
||||
"indent": [
|
||||
true,
|
||||
@ -107,9 +107,6 @@
|
||||
],
|
||||
"no-attribute-parameter-decorator": true,
|
||||
"no-forward-ref": true,
|
||||
"import-destructuring-spacing": true,
|
||||
"no-access-missing-member": true,
|
||||
"templates-use-public": true,
|
||||
"invoke-injectable": true
|
||||
"import-destructuring-spacing": true
|
||||
}
|
||||
}
|
@ -11,7 +11,7 @@
|
||||
border: 1px solid #eee;
|
||||
border-radius: 5px;
|
||||
margin: 7px;
|
||||
padding: 5px;
|
||||
padding: 6px;
|
||||
width: calc(325px - 14px);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@ -21,7 +21,7 @@
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(50% - 12px); // icon happens to be 24px tall
|
||||
right: 0;
|
||||
right: 1px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,7 @@ var HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
var ExtractTextPlugin = require('extract-text-webpack-plugin');
|
||||
var CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
|
||||
var isProd = process.env.npm_lifecycle_event == 'build';
|
||||
var isProd = process.env.npm_lifecycle_event === 'build';
|
||||
|
||||
module.exports = function () {
|
||||
var config = {};
|
||||
@ -21,7 +21,7 @@ module.exports = function () {
|
||||
};
|
||||
|
||||
config.output = {
|
||||
path: root('web-dist'),
|
||||
path: path.join(root('build'), 'web'),
|
||||
publicPath: isProd ? '/' : '/', //http://0.0.0.0:8080',
|
||||
filename: isProd ? 'js/[name].[hash].js' : 'js/[name].js',
|
||||
chunkFilename: isProd ? '[id].[hash].chunk.js' : '[id].chunk.js'
|
||||
|
Loading…
Reference in New Issue
Block a user