Add skeleton for admin room widget

This commit is contained in:
Will Hunt 2020-12-12 20:29:33 +00:00
parent 4abf02da2a
commit 5c278a2b53
23 changed files with 2860 additions and 136 deletions

View File

@ -39,6 +39,17 @@ logging:
# Run openssl genpkey -out passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:4096
passFile: "passkey.pem"
webhook:
port: 9000
widgets:
# The port to listen on for the widget API
port: 5000
# Public url that the widget API is reachable on
publicUrl: "https://example.com/bridgewidgets/"
# Add the widget to admin rooms
addToAdminRooms: true
bot:
displayname: "GitHub Bot"
avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d

View File

@ -9,39 +9,46 @@
"private": false,
"scripts": {
"build:web": "snowpack build",
"build:app": "tsc --project tsconfig.json",
"dev:web": "snowpack dev",
"build": "tsc --project tsconfig.json",
"build": "yarn run build:web && yarn run build:app",
"prepare": "yarn build",
"start": "node lib/App/BridgeApp.js",
"start:app": "node lib/App/BridgeApp.js",
"start:webhooks": "node lib/App/GithubWebhookApp.js",
"start:matrixsender": "node lib/App/MatrixSenderApp.js",
"start": "node --require source-map-support/register lib/App/BridgeApp.js",
"start:app": "node --require source-map-support/register lib/App/BridgeApp.js",
"start:webhooks": "node --require source-map-support/register lib/App/GithubWebhookApp.js",
"start:matrixsender": "node --require source-map-support/register lib/App/MatrixSenderApp.js",
"test": "mocha -r ts-node/register tests/*.ts",
"lint": "eslint -c .eslintrc.js src/**/*.ts"
},
"dependencies": {
"@octokit/auth-app": "^2.10.2",
"@octokit/auth-token": "^2.4.3",
"@octokit/rest": "^18.0.9",
"@prefresh/snowpack": "^2.2.0",
"@octokit/auth-app": "2.10.2",
"@octokit/auth-token": "^2.4.4",
"@octokit/rest": "18.0.9",
"axios": "^0.21.0",
"cors": "^2.8.5",
"express": "^4.17.1",
"fontsource-open-sans": "^3.1.5",
"ioredis": "^4.19.2",
"markdown-it": "^12.0.2",
"matrix-bot-sdk": "^0.5.8",
"matrix-widget-api": "^0.1.0-beta.10",
"micromatch": "^4.0.2",
"mime": "^2.4.6",
"mini.css": "^3.0.1",
"mocha": "^8.2.1",
"node-emoji": "^1.10.0",
"reflect-metadata": "^0.1.13",
"source-map-support": "^0.5.19",
"string-argv": "v0.3.1",
"uuid": "^8.3.1",
"winston": "^3.3.3",
"yaml": "^1.10.0"
},
"devDependencies": {
"@prefresh/snowpack": "^2.2.0",
"@snowpack/plugin-typescript": "^1.1.1",
"@types/chai": "^4.2.14",
"@types/cors": "^2.8.9",
"@types/express": "^4.17.9",
"@types/ioredis": "^4.17.8",
"@types/markdown-it": "^10.0.3",
@ -53,11 +60,13 @@
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.8.1",
"@typescript-eslint/parser": "^4.8.1",
"autoprefixer": "^10.1.0",
"chai": "^4.2.0",
"eslint": "^7.14.0",
"eslint-plugin-mocha": "^8.0.0",
"preact": "^10.5.7",
"snowpack": "^2.18.3",
"tailwind": "^4.0.0",
"ts-node": "^9.0.0",
"typescript": "^4.1.2"
}

View File

@ -1,17 +1,18 @@
/** @type {import("snowpack").SnowpackUserConfig } */
module.exports = {
mount: {
"web/": '/',
"web/": '/'
},
plugins: [
['@snowpack/plugin-typescript', '--project tsconfig.web.json'],
'@prefresh/snowpack',
['@snowpack/plugin-typescript', '--project tsconfig.web.json'],
],
install: [
/* ... */
],
installOptions: {
installTypes: true,
polyfillNode: true,
},
devOptions: {
/* ... */

View File

@ -15,6 +15,7 @@ import { GetUserResponse } from "./Gitlab/Types";
import { GithubInstance } from "./Github/GithubInstance";
import { MatrixMessageContent } from "./MatrixEvent";
import { ProjectsListForUserResponseData, ProjectsListForRepoResponseData } from "@octokit/types";
import { BridgeRoomState } from "./Widgets/BridgeWidgetInterface";
const md = new markdown();
@ -44,6 +45,7 @@ export interface AdminAccountData {
}
export class AdminRoom extends EventEmitter {
public static helpMessage: MatrixMessageContent;
private widgetAccessToken = `abcdef`;
static botCommands: BotCommands;
private pendingOAuthState: string|null = null;
@ -54,6 +56,8 @@ export class AdminRoom extends EventEmitter {
private tokenStore: UserTokenStore,
private config: BridgeConfig) {
super();
// TODO: Move this
this.backfillAccessToken();
}
public get userId() {
@ -64,6 +68,10 @@ export class AdminRoom extends EventEmitter {
return this.pendingOAuthState;
}
public verifyWidgetAccessToken(token: string) {
return this.widgetAccessToken === token;
}
public notificationsEnabled(type: "github"|"gitlab", instanceName?: string) {
if (type === "github") {
return this.data.github?.notifications?.enabled;
@ -140,6 +148,7 @@ export class AdminRoom extends EventEmitter {
const octokit = GithubInstance.createUserOctokit(accessToken);
me = await octokit.users.getAuthenticated();
} catch (ex) {
log.error("Failed to auth with GitHub", ex);
await this.sendNotice("Could not authenticate with GitHub. Is your token correct?");
return;
}
@ -444,6 +453,77 @@ export class AdminRoom extends EventEmitter {
// }
// });
}
public async getBridgeState(): Promise<BridgeRoomState> {
const gitHubEnabled = !!this.config.github;
const github: {enabled: boolean; tokenStored: boolean; identity: null|{name: string|null; avatarUrl: string|null}} = {
enabled: false,
tokenStored: false,
identity: null,
};
if (gitHubEnabled) {
const octokit = await this.tokenStore.getOctokitForUser(this.userId);
try {
const identity = await octokit?.users.getAuthenticated();
github.enabled = true;
github.tokenStored = !!octokit;
github.identity = {
name: identity?.data.login || null,
avatarUrl: identity?.data.avatar_url || null,
};
} catch (ex) {
log.warn(`Failed to get user identity: ${ex}`);
}
}
return {
title: "Admin Room",
github,
}
}
public async setupWidget() {
try {
const res = await this.botIntent.underlyingClient.getRoomStateEvent(this.roomId, "im.vector.modular.widgets", "bridge_control");
if (res) {
// No-op
// Validate?
return;
}
} catch (ex) {
// Didn't exist, create it.
}
const accessToken = uuid();
return this.botIntent.underlyingClient.sendStateEvent(
this.roomId,
"im.vector.modular.widgets",
"bridge_control",
{
"creatorUserId": this.botIntent.userId,
"data": {
"title": "Bridge Control"
},
"id": "bridge_control",
"name": "Bridge Control",
"type": "m.custom",
"url": `${this.config.widgets?.publicUrl}/#/?roomId=$matrix_room_id&widgetId=$matrix_widget_id&accessToken=${accessToken}`,
accessToken,
"waitForIframeLoad": true
}
);
}
private async backfillAccessToken() {
try {
const res = await this.botIntent.underlyingClient.getRoomStateEvent(this.roomId, "im.vector.modular.widgets", "bridge_control");
if (res) {
log.debug(`Stored access token for widgets for ${this.roomId}`);
this.widgetAccessToken = res.accessToken;
}
} catch (ex) {
log.info(`No widget access token for ${this.roomId}`);
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@ -41,6 +41,12 @@ interface BridgeConfigGitLab {
instances: {[name: string]: GitLabInstance};
}
interface BridgeWidgetConfig {
port: number;
addToAdminRooms: boolean;
publicUrl: string;
}
export interface BridgeConfig {
github?: BridgeConfigGitHub;
gitlab?: BridgeConfigGitLab;
@ -69,6 +75,7 @@ export interface BridgeConfig {
displayname?: string;
avatar?: string;
}
widgets?: BridgeWidgetConfig;
}
export async function parseRegistrationFile(filename: string) {

View File

@ -26,7 +26,7 @@ import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNot
import { GitLabIssueConnection } from "./Connections/GitlabIssue";
import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types"
import { GitLabClient } from "./Gitlab/Client";
// import { IGitLabWebhookMREvent } from "./Gitlab/WebhookTypes";
import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi";
const log = new LogWrapper("GithubBridge");
@ -39,6 +39,7 @@ export class GithubBridge {
private queue!: MessageQueue;
private tokenStore!: UserTokenStore;
private messageClient!: MessageSenderClient;
private widgetApi!: BridgeWidgetApi;
private connections: IConnection[] = [];
@ -169,6 +170,8 @@ export class GithubBridge {
storage,
});
this.widgetApi = new BridgeWidgetApi(this.adminRooms);
this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl);
this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent);
@ -392,7 +395,13 @@ export class GithubBridge {
for (const roomId of joinedRooms) {
log.debug("Fetching state for " + roomId);
const connections = await this.createConnectionsForRoomId(roomId);
let connections: IConnection[];
try {
connections = await this.createConnectionsForRoomId(roomId);
} catch (ex) {
log.error(`Unable to create connection for ${roomId}`, ex);
continue;
}
this.connections.push(...connections);
if (connections.length === 0) {
// TODO: Refactor this to be a connection
@ -400,7 +409,7 @@ export class GithubBridge {
const accountData = await this.as.botIntent.underlyingClient.getRoomAccountData(
BRIDGE_ROOM_TYPE, roomId,
);
const adminRoom = this.setupAdminRoom(roomId, accountData);
const adminRoom = await this.setupAdminRoom(roomId, accountData);
// Call this on startup to set the state
await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user });
} catch (ex) {
@ -410,7 +419,9 @@ export class GithubBridge {
log.info(`Room ${roomId} is connected to: ${connections.join(',')}`);
}
}
if (this.config.widgets) {
await this.widgetApi.start(this.config.widgets.port);
}
await this.as.begin();
log.info("Started bridge");
}
@ -427,7 +438,7 @@ export class GithubBridge {
}
await retry(() => this.as.botIntent.joinRoom(roomId), 5);
if (event.content.is_direct) {
const room = this.setupAdminRoom(roomId, {admin_user: event.sender});
const room = await this.setupAdminRoom(roomId, {admin_user: event.sender});
await this.as.botIntent.underlyingClient.setRoomAccountData(
BRIDGE_ROOM_TYPE, roomId, room.data,
);
@ -652,7 +663,7 @@ export class GithubBridge {
}
private setupAdminRoom(roomId: string, accountData: AdminAccountData) {
private async setupAdminRoom(roomId: string, accountData: AdminAccountData) {
const adminRoom = new AdminRoom(
roomId, accountData, this.as.botIntent, this.tokenStore, this.config,
);
@ -680,6 +691,9 @@ export class GithubBridge {
return this.as.botClient.inviteUser(adminRoom.userId, newConnection.roomId);
});
this.adminRooms.set(roomId, adminRoom);
if (this.config.widgets?.addToAdminRooms && this.config.widgets.publicUrl) {
await adminRoom.setupWidget();
}
log.info(`Setup ${roomId} as an admin room for ${adminRoom.userId}`);
return adminRoom;
}

View File

@ -0,0 +1,72 @@
import { Application, default as express, Request, Response } from "express";
import cors from "cors";
import { AdminRoom } from "../AdminRoom";
import LogWrapper from "../LogWrapper";
const log = new LogWrapper("GithubBridge");
export class BridgeWidgetApi {
private app: Application;
constructor(private adminRooms: Map<string, AdminRoom>) {
this.app = express();
this.app.use((req, _res, next) => {
log.info(`${req.method} ${req.path} ${req.ip || ''} ${req.headers["user-agent"] || ''}`);
next();
});
this.app.use('/', express.static('public'));
this.app.use(cors());
this.app.get('/widgetapi/:roomId/verify', this.getVerifyToken.bind(this));
this.app.get('/widgetapi/:roomId', this.getRoomState.bind(this));
this.app.get('/health', this.getHealth.bind(this));
}
public start(port = 5000) {
log.info(`Widget API listening on port ${port}`)
this.app.listen(port);
}
private async getRoomFromRequest(req: Request): Promise<AdminRoom|{error: string, statusCode: number}> {
const { roomId } = req.params;
const token = req.headers.authorization?.substr('Bearer '.length);
if (!token) {
return {
error: 'Access token not given',
statusCode: 400,
};
}
// Replace with actual auth
const room = this.adminRooms.get(roomId);
if (!room || !room.verifyWidgetAccessToken(token)) {
return {error: 'Unauthorized access to room', statusCode: 401}
}
return room;
}
private async getVerifyToken(req: Request, res: Response) {
const roomOrError = await this.getRoomFromRequest(req);
if (roomOrError instanceof AdminRoom) {
return res.sendStatus(204);
}
return res.status(roomOrError.statusCode).send({error: roomOrError.error});
}
private async getRoomState(req: Request, res: Response) {
const roomOrError = await this.getRoomFromRequest(req);
if (!(roomOrError instanceof AdminRoom)) {
return res.status(roomOrError.statusCode).send({error: roomOrError.error});
}
try {
return res.send(await roomOrError.getBridgeState());
} catch (ex) {
log.error(`Failed to get room state:`, ex);
return res.status(500).send({error: "An error occured when getting room state"});
}
}
private getHealth(req: Request, res: Response) {
res.status(200).send({ok: true});
}
}

View File

@ -0,0 +1,11 @@
export interface BridgeRoomState {
title: string;
github: {
enabled: boolean;
tokenStored: boolean;
identity: {
name: string|null;
avatarUrl: string|null;
}|null;
}
}

View File

@ -25,6 +25,9 @@
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
},
"include": [
"src/**/*"
],
"exclude": [
"tests/**/*",
"web/**/*"

View File

@ -1,8 +1,8 @@
{
"include": ["src", "types"],
"include": ["web"],
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"module": "commonjs",
"target": "es2019",
"moduleResolution": "node",
"jsx": "preserve",
"jsxFactory": "h",
@ -17,7 +17,10 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"importsNotUsedAsValues": "error"
"importsNotUsedAsValues": "error",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true
}
}

View File

@ -1,37 +1,93 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
function App() {
// Create the count state.
const [count, setCount] = useState(0);
// Create the counter (+1 every second).
useEffect(() => {
const timer = setTimeout(() => setCount(count + 1), 1000);
return () => clearTimeout(timer);
}, [count, setCount]);
// Return the App component.
return (
<div className="App">
<header className="App-header">
<p>
Edit <code>src/App.jsx</code> and save to reload.
</p>
<p>
Page has been open for <code>{count}</code> seconds.
</p>
<p>
<a
className="App-link"
href="https://preactjs.com"
target="_blank"
rel="noopener noreferrer"
>
Learn Preact
</a>
</p>
</header>
</div>
);
import { h, Component } from 'preact';
import WA from 'matrix-widget-api';
import BridgeAPI from './BridgeAPI';
import { BridgeRoomState } from '../src/Widgets/BridgeWidgetInterface';
import ErrorPane from './components/ErrorPane';
import AdminSettings from './components/AdminSettings';
interface IState {
error: string|null,
busy: boolean,
roomId?: string,
roomState?: BridgeRoomState;
}
export default App;
function parseFragment() {
const fragmentString = (window.location.hash || "?");
return new URLSearchParams(fragmentString.substring(Math.max(fragmentString.indexOf('?'), 0)));
}
function assertParam(fragment, name) {
const val = fragment.get(name);
if (!val) throw new Error(`${name} is not present in URL - cannot load widget`);
return val;
}
export default class App extends Component<void, IState> {
private widgetApi: WA.WidgetApi;
private bridgeApi: BridgeAPI;
constructor() {
super();
this.state = {
error: null,
busy: true,
};
}
async componentDidMount() {
try {
// Start widgeting
const qs = parseFragment();
const widgetId = assertParam(qs, 'widgetId');
const roomId = assertParam(qs, 'roomId');
const accessToken = assertParam(qs, 'accessToken');
this.bridgeApi = new BridgeAPI("http://localhost:5000", roomId, accessToken);
await this.bridgeApi.verify();
this.widgetApi = new WA.WidgetApi(widgetId);
this.widgetApi.on("ready", () => {
console.log("Widget ready:", this);
});
this.widgetApi.on(`action:${WA.WidgetApiToWidgetAction.NotifyCapabilities}`, (ev) => {
console.log(ev.detail.data.approved);
console.log(`${WA.WidgetApiToWidgetAction.NotifyCapabilities}`, ev);
})
this.widgetApi.on(`action:${WA.WidgetApiToWidgetAction.SendEvent}`, (ev) => {
console.log(`${WA.WidgetApiToWidgetAction.SendEvent}`, ev);
})
// Start the widget as soon as possible too, otherwise the client might time us out.
this.widgetApi.start();
const roomState = await this.bridgeApi.state();
console.log('Got state', roomState);
this.setState({
roomState,
roomId,
busy: false,
});
} catch (ex) {
console.error(`Bridge verifiation failed:`, ex);
this.setState({
error: ex.message,
busy: false,
});
}
}
render() {
// Return the App component.
let content;
if (this.state.error) {
content = <ErrorPane>{this.state.error}</ErrorPane>;
} else if (this.state.roomState) {
content = <AdminSettings roomState={this.state.roomState}></AdminSettings>;
} else if (this.state.busy) {
content = <div class="spinner"></div>;
} else {
content = <b>Invalid state</b>;
}
return (
<div className="App">
{content}
</div>
);
}
}

41
web/BridgeAPI.ts Normal file
View File

@ -0,0 +1,41 @@
import { BridgeRoomState } from '../src/Widgets/BridgeWidgetInterface';
export class BridgeAPIError extends Error {
constructor(msg: string, private body: Record<string, unknown>) {
super(msg);
}
}
export default class BridgeAPI {
constructor(private baseUrl: string, private roomId: string, private accessToken: string) {
}
async request(method: string, endpoint: string, body?: unknown) {
const req = await fetch(`${this.baseUrl}${endpoint}`, {
cache: 'no-cache',
method,
body: body ? JSON.stringify(body) : undefined,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.accessToken}`,
},
});
if (req.status === 204) {
return;
}
if (req.status === 200) {
return req.json();
}
const resultBody = await req.json();
throw new BridgeAPIError(resultBody?.error || 'Request failed', resultBody);
}
async verify() {
return this.request('GET', `/widgetapi/${encodeURIComponent(this.roomId)}/verify`);
}
async state(): Promise<BridgeRoomState> {
return this.request('GET', `/widgetapi/${encodeURIComponent(this.roomId)}`);
}
}

0
web/IBridgeEvents.ts Normal file
View File

View File

@ -0,0 +1,7 @@
.adminsettings > .card {
max-width: 95%;
}
.adminsettings > h1 {
text-align: center;
}

View File

@ -0,0 +1,43 @@
import { h, Component } from 'preact';
import { BridgeRoomState } from "../../src/Widgets/BridgeWidgetInterface";
import "./AdminSettings.css";
import LoginCard from './LoginCard';
interface IProps{
roomState: BridgeRoomState;
}
export default class AdminSettings extends Component<IProps> {
constructor(props) {
super(props)
}
renderGitHub() {
const githubConfig = this.props.roomState.github;
if (!githubConfig.enabled) {
return <strong>
GitHub support is not enabled in the bridge
</strong>
}
if (!githubConfig.tokenStored) {
return <strong>
You have not logged into GitHub
</strong>
}
if (!githubConfig.identity) {
return <strong>
Your token does not appear to work
</strong>;
}
return <LoginCard name={githubConfig.identity.name} avatarUrl={githubConfig.identity.avatarUrl}/>;
}
render() {
return <div class="adminsettings">
<h1>{this.props.roomState.title}</h1>
<h2>GitHub</h2>
{this.renderGitHub()}
<h2>GitLab</h2>
</div>;
}
}

View File

@ -0,0 +1,3 @@
.error-pane {
max-width: 480px;
}

View File

@ -0,0 +1,11 @@
import { h, FunctionComponent } from "preact";
import "./ErrorPane.css";
const ErrorPane: FunctionComponent<unknown> = ({ children }) => {
return <div class="card error error-pane">
<h3>Error occured during widget load</h3>
<p>{children}</p>
</div>;
};
export default ErrorPane;

View File

@ -0,0 +1,7 @@
.login-card img {
height: 15vw;
}
.login-card span {
font-size: 1.5rem;
}

View File

@ -0,0 +1,17 @@
import { h, FunctionComponent } from 'preact';
import "./LoginCard.css";
const LoginCard: FunctionComponent<{name: string; avatarUrl: string;}> = ({ name, avatarUrl }) => {
return <div class="container login-card">
<div class="row">
<div class="col-sm-2">
<img src={avatarUrl} title="GitHub avatar"/>
</div>
<div class="col-sm-9">
Logged in as <span>{name}</span>
</div>
</div>
</div>
}
export default LoginCard;

View File

@ -2,24 +2,12 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Web site created using create-snowpack-app" />
<title>Snowpack App</title>
<title>GitHub Bridge Widget</title>
</head>
<body>
<div id="root"></div>
<main id="root"></main>
<noscript>You need to enable JavaScript to run this app.</noscript>
<script type="module" src="/index.js"></script>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -1,8 +1,10 @@
import { h, render } from 'preact';
import 'preact/devtools';
import App from './App';
import "./styling.css";
import "fontsource-open-sans/files/open-sans-latin-400-normal.woff2";
const root = document.getElementById('root')
const root = document.getElementsByTagName('main')[0];
if (root) {
render(<App />, root);

7
web/styling.css Normal file
View File

@ -0,0 +1,7 @@
@import "fontsource-open-sans/400-normal.css";
@import "mini.css";
body {
font-family: 'Open Sans';
}

2459
yarn.lock

File diff suppressed because it is too large Load Diff