Add the start of an admin API and re-add widgets

The frontend is still broken and doesn't use these endpoints at all. A migration tool still needs to be written to pull in existing widget configurations.
This commit is contained in:
Travis Ralston 2017-12-18 21:44:01 -07:00
parent 826364e803
commit 599fb80112
15 changed files with 354 additions and 25 deletions

4
.gitignore vendored
View File

@ -10,6 +10,10 @@ config/integrations/*_development.yaml
config/integrations/*_production.yaml config/integrations/*_production.yaml
build/ build/
dimension.db dimension.db
src-ts/**/*.js
src-ts/**/*.js.map
web/**/*.js
web/**/*.js.map
# Logs # Logs
logs logs

15
package-lock.json generated
View File

@ -5478,8 +5478,7 @@
"path-parse": { "path-parse": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz",
"integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=", "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME="
"dev": true
}, },
"path-to-regexp": { "path-to-regexp": {
"version": "0.1.7", "version": "0.1.7",
@ -7524,7 +7523,6 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.4.0.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.4.0.tgz",
"integrity": "sha512-aW7sVKPufyHqOmyyLzg/J+8606v5nevBgaliIlV7nUpVMsDnoBGV/cbSLNjZAg9q0Cfd/+easKVKQ8vOu8fn1Q==", "integrity": "sha512-aW7sVKPufyHqOmyyLzg/J+8606v5nevBgaliIlV7nUpVMsDnoBGV/cbSLNjZAg9q0Cfd/+easKVKQ8vOu8fn1Q==",
"dev": true,
"requires": { "requires": {
"path-parse": "1.0.5" "path-parse": "1.0.5"
} }
@ -9583,6 +9581,17 @@
} }
} }
}, },
"umzug": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/umzug/-/umzug-2.1.0.tgz",
"integrity": "sha512-BgT+ekpItEWaG+3JjLLj6yVTxw2wIH8Cr6JyKYIzukWAx9nzGhC6BGHb/IRMjpobMM1qtIrReATwLUjKpU2iOQ==",
"requires": {
"babel-runtime": "6.26.0",
"bluebird": "3.5.1",
"lodash": "4.17.4",
"resolve": "1.4.0"
}
},
"union-value": { "union-value": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz",

View File

@ -40,11 +40,10 @@
"sequelize-typescript": "^0.6.1", "sequelize-typescript": "^0.6.1",
"sqlite3": "^3.1.13", "sqlite3": "^3.1.13",
"typescript-rest": "^1.2.0", "typescript-rest": "^1.2.0",
"umzug": "^2.1.0",
"url": "^0.11.0" "url": "^0.11.0"
}, },
"devDependencies": { "devDependencies": {
"embed-video": "^2.0.0",
"screenfull": "^3.3.2",
"@angular/animations": "^5.0.0", "@angular/animations": "^5.0.0",
"@angular/common": "^5.0.0", "@angular/common": "^5.0.0",
"@angular/compiler": "^5.0.0", "@angular/compiler": "^5.0.0",
@ -67,6 +66,7 @@
"core-js": "^2.5.2", "core-js": "^2.5.2",
"css-loader": "^0.28.7", "css-loader": "^0.28.7",
"cssnano": "^3.10.0", "cssnano": "^3.10.0",
"embed-video": "^2.0.0",
"extract-text-webpack-plugin": "^3.0.2", "extract-text-webpack-plugin": "^3.0.2",
"file-loader": "^1.1.5", "file-loader": "^1.1.5",
"goby": "^1.1.2", "goby": "^1.1.2",
@ -86,13 +86,13 @@
"rimraf": "^2.6.2", "rimraf": "^2.6.2",
"rxjs": "^5.5.5", "rxjs": "^5.5.5",
"sass-loader": "^6.0.3", "sass-loader": "^6.0.3",
"screenfull": "^3.3.2",
"shelljs": "^0.7.8", "shelljs": "^0.7.8",
"spinkit": "^1.2.5", "spinkit": "^1.2.5",
"style-loader": "^0.19.1", "style-loader": "^0.19.1",
"ts-helpers": "^1.1.2", "ts-helpers": "^1.1.2",
"tslint": "^5.8.0", "tslint": "^5.8.0",
"tslint-loader": "^3.4.3", "tslint-loader": "^3.4.3",
"typescript": "^2.6.2",
"url-loader": "^0.6.2", "url-loader": "^0.6.2",
"webpack": "^3.10.0", "webpack": "^3.10.0",
"webpack-dev-server": "^2.9.7", "webpack-dev-server": "^2.9.7",

View File

@ -22,7 +22,7 @@ export default class Webserver {
private loadRoutes() { private loadRoutes() {
const apis = ["scalar", "dimension"].map(a => path.join(__dirname, a, "*.js")); const apis = ["scalar", "dimension"].map(a => path.join(__dirname, a, "*.js"));
const router = express.Router(); const router = express.Router();
Server.loadServices(router, apis); apis.forEach(a => Server.loadServices(router, [a]));
const routes = _.uniq(router.stack.map(r => r.route.path)); const routes = _.uniq(router.stack.map(r => r.route.path));
for (const route of routes) { for (const route of routes) {
this.app.options(route, (_req, res) => res.sendStatus(200)); this.app.options(route, (_req, res) => res.sendStatus(200));

View File

@ -0,0 +1,35 @@
import { GET, Path, QueryParam } from "typescript-rest";
import * as Promise from "bluebird";
import { ScalarService } from "../scalar/ScalarService";
import config from "../../config";
import { ApiError } from "../ApiError";
interface DimensionInfoResponse {
admins: string[],
}
@Path("/api/v1/dimension/admin")
export class DimensionAdminService {
public static isAdmin(userId: string) {
return config.admins.indexOf(userId) >= 0;
}
public static validateAndGetAdminTokenOwner(scalarToken: string): Promise<string> {
return ScalarService.getTokenOwner(scalarToken).then(userId => {
if (!DimensionAdminService.isAdmin(userId))
throw new ApiError(401, {message: "You must be an administrator to use this API"});
else return userId;
}, ScalarService.invalidTokenErrorHandler);
}
@GET
@Path("info")
public getInfo(@QueryParam("scalar_token") scalarToken: string): Promise<DimensionInfoResponse> {
return DimensionAdminService.validateAndGetAdminTokenOwner(scalarToken).then(_userId => {
// Only let admins see other admins
// A 200 OK essentially means "you're an admin".
return {admins: config.admins};
});
}
}

View File

@ -0,0 +1,54 @@
import { GET, Path, QueryParam } from "typescript-rest";
import * as Promise from "bluebird";
import { ScalarService } from "../scalar/ScalarService";
import { DimensionStore } from "../../db/DimensionStore";
import { DimensionAdminService } from "./DimensionAdminService";
import { Widget } from "../../integrations/Widget";
import { MemoryCache } from "../../MemoryCache";
interface IntegrationsResponse {
widgets: Widget[],
}
@Path("/api/v1/dimension/integrations")
export class DimensionIntegrationsService {
private static integrationCache = new MemoryCache();
public static clearIntegrationCache() {
DimensionIntegrationsService.integrationCache.clear();
}
@GET
@Path("enabled")
public getEnabledIntegrations(@QueryParam("scalar_token") scalarToken: string): Promise<IntegrationsResponse> {
return ScalarService.getTokenOwner(scalarToken).then(_userId => {
return this.getIntegrations(true);
}, ScalarService.invalidTokenErrorHandler);
}
@GET
@Path("all")
public getAllIntegrations(@QueryParam("scalar_token") scalarToken: string): Promise<IntegrationsResponse> {
return DimensionAdminService.validateAndGetAdminTokenOwner(scalarToken).then(_userId => {
return this.getIntegrations(null);
});
}
private getIntegrations(isEnabledCheck?: boolean): Promise<IntegrationsResponse> {
const cachedResponse = DimensionIntegrationsService.integrationCache.get("integrations_" + isEnabledCheck);
if (cachedResponse) {
return cachedResponse;
}
const response = <IntegrationsResponse>{
widgets: [],
};
return Promise.resolve()
.then(() => DimensionStore.getWidgets(isEnabledCheck))
.then(widgets => response.widgets = widgets)
// Cache and return response
.then(() => DimensionIntegrationsService.integrationCache.put("integrations_" + isEnabledCheck, response))
.then(() => response);
}
}

View File

@ -40,6 +40,14 @@ export class ScalarService {
}); });
} }
public static invalidTokenErrorHandler(error: any): any {
if (error !== "Invalid token") {
LogService.error("ScalarWidgetService", "Error processing request");
LogService.error("ScalarWidgetService", error);
}
throw new ApiError(401, {message: "Invalid token"});
}
@POST @POST
@Path("register") @Path("register")
public register(request: RegisterRequest): Promise<ScalarRegisterResponse> { public register(request: RegisterRequest): Promise<ScalarRegisterResponse> {
@ -94,13 +102,7 @@ export class ScalarService {
public getAccount(@QueryParam("scalar_token") scalarToken: string): Promise<ScalarAccountResponse> { public getAccount(@QueryParam("scalar_token") scalarToken: string): Promise<ScalarAccountResponse> {
return ScalarService.getTokenOwner(scalarToken).then(userId => { return ScalarService.getTokenOwner(scalarToken).then(userId => {
return {user_id: userId}; return {user_id: userId};
}, err => { }, ScalarService.invalidTokenErrorHandler);
if (err !== "Invalid token") {
LogService.error("ScalarService", "Error getting account information for user");
LogService.error("ScalarService", err);
}
throw new ApiError(401, {message: "Invalid token"});
});
} }
} }

View File

@ -1,7 +1,6 @@
import { GET, Path, QueryParam } from "typescript-rest"; import { GET, Path, QueryParam } from "typescript-rest";
import * as Promise from "bluebird"; import * as Promise from "bluebird";
import { LogService } from "matrix-js-snippets"; import { LogService } from "matrix-js-snippets";
import { ApiError } from "../ApiError";
import { MemoryCache } from "../../MemoryCache"; import { MemoryCache } from "../../MemoryCache";
import { MatrixLiteClient } from "../../matrix/MatrixLiteClient"; import { MatrixLiteClient } from "../../matrix/MatrixLiteClient";
import config from "../../config"; import config from "../../config";
@ -67,15 +66,9 @@ export class ScalarWidgetService {
@GET @GET
@Path("title_lookup") @Path("title_lookup")
public register(@QueryParam("scalar_token") scalarToken: string, @QueryParam("curl") url: string): Promise<UrlPreviewResponse> { public titleLookup(@QueryParam("scalar_token") scalarToken: string, @QueryParam("curl") url: string): Promise<UrlPreviewResponse> {
return ScalarService.getTokenOwner(scalarToken).then(_userId => { return ScalarService.getTokenOwner(scalarToken).then(_userId => {
return ScalarWidgetService.getUrlTitle(url); return ScalarWidgetService.getUrlTitle(url);
}, err => { }, ScalarService.invalidTokenErrorHandler);
if (err !== "Invalid token") {
LogService.error("ScalarWidgetService", "Error getting account information for user");
LogService.error("ScalarWidgetService", err);
}
throw new ApiError(401, {message: "Invalid token"});
})
} }
} }

View File

@ -5,6 +5,10 @@ import User from "./models/User";
import UserScalarToken from "./models/UserScalarToken"; import UserScalarToken from "./models/UserScalarToken";
import Upstream from "./models/Upstream"; import Upstream from "./models/Upstream";
import * as Promise from "bluebird"; import * as Promise from "bluebird";
import WidgetRecord from "./models/WidgetRecord";
import * as path from "path";
import * as Umzug from "umzug";
import { Widget } from "../integrations/Widget";
class _DimensionStore { class _DimensionStore {
private sequelize: Sequelize; private sequelize: Sequelize;
@ -22,12 +26,23 @@ class _DimensionStore {
User, User,
UserScalarToken, UserScalarToken,
Upstream, Upstream,
WidgetRecord,
]); ]);
} }
public updateSchema(): Promise<any> { public updateSchema(): Promise<any> {
LogService.info("DimensionStore", "Updating schema..."); LogService.info("DimensionStore", "Updating schema...");
return this.sequelize.sync();
const migrator = new Umzug({
storage: "sequelize",
storageOptions: {sequelize: this.sequelize},
migrations: {
params: [this.sequelize.getQueryInterface()],
path: path.join(__dirname, "migrations"),
}
});
return migrator.up();
} }
public doesUserHaveTokensForAllUpstreams(userId: string): Promise<boolean> { public doesUserHaveTokensForAllUpstreams(userId: string): Promise<boolean> {
@ -56,7 +71,10 @@ class _DimensionStore {
public getTokenOwner(scalarToken: string): Promise<User> { public getTokenOwner(scalarToken: string): Promise<User> {
let user: User = null; let user: User = null;
return UserScalarToken.findAll({where: {isDimensionToken: true, scalarToken: scalarToken}, include: [User]}).then(tokens => { return UserScalarToken.findAll({
where: {isDimensionToken: true, scalarToken: scalarToken},
include: [User]
}).then(tokens => {
if (!tokens || tokens.length === 0) { if (!tokens || tokens.length === 0) {
return Promise.reject("Invalid token"); return Promise.reject("Invalid token");
} }
@ -70,6 +88,12 @@ class _DimensionStore {
return Promise.resolve(user); return Promise.resolve(user);
}); });
} }
public getWidgets(isEnabled?: boolean): Promise<Widget[]> {
let conditions = {};
if (isEnabled === true || isEnabled === false) conditions = {where: {isEnabled: isEnabled}};
return WidgetRecord.findAll(conditions).then(widgets => widgets.map(w => new Widget(w)));
}
} }
export const DimensionStore = new _DimensionStore(); export const DimensionStore = new _DimensionStore();

View File

@ -0,0 +1,42 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
import * as Promise from "bluebird";
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,72 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
import * as Promise from "bluebird";
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},
"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.",
},
]));
},
down: (queryInterface: QueryInterface) => {
return queryInterface.dropTable("dimension_widgets");
}
}

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,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;
}

View File

@ -0,0 +1,30 @@
import { IntegrationRecord } from "../db/models/IntegrationRecord";
export class Integration {
// These are meant to be set by the underlying integration
public category: "bot" | "complex-bot" | "bridge" | "widget";
public type: string;
public requirements: IntegrationRequirement[];
// These are meant to be set by us
public displayName: string;
public avatarUrl: string;
public description: string;
public isEnabled: boolean;
public isPublic: boolean;
constructor(record: IntegrationRecord) {
this.type = record.type;
this.displayName = record.name;
this.avatarUrl = record.avatarUrl;
this.description = record.description;
this.isEnabled = record.isEnabled;
this.isPublic = record.isPublic;
}
}
export interface IntegrationRequirement {
condition: "publicRoom" | "canSendEventTypes";
argument: any;
expectedValue: any;
}

View File

@ -0,0 +1,21 @@
import { Integration } from "./Integration";
import WidgetRecord from "../db/models/WidgetRecord";
export interface EtherpadWidgetOptions {
defaultUrl: string;
}
export class Widget extends Integration {
public options: any;
constructor(widgetRecord: WidgetRecord) {
super(widgetRecord);
this.category = "widget";
this.options = widgetRecord.optionsJson ? JSON.parse(widgetRecord.optionsJson) : {};
this.requirements = [{
condition: "canSendEventTypes",
argument: ["im.vector.widget"],
expectedValue: true,
}];
}
}