diff --git a/Dockerfile b/Dockerfile
index 710b778..ae90a34 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,9 +1,15 @@
+FROM node:10 AS frontend-builder
+
+COPY ./maubot/management/frontend /frontend
+RUN cd /frontend && yarn --prod && yarn build
+
FROM alpine:3.8
ENV UID=1337 \
GID=1337
COPY . /opt/maubot
+COPY --from=frontend-builder /frontend/build /opt/maubot/frontend
WORKDIR /opt/maubot
RUN apk add --no-cache \
py3-aiohttp \
diff --git a/docker/example-config.yaml b/docker/example-config.yaml
index 77f97f0..a273629 100644
--- a/docker/example-config.yaml
+++ b/docker/example-config.yaml
@@ -24,6 +24,11 @@ server:
port: 29316
# The base management API path.
base_path: /_matrix/maubot/v1
+ # The base path for the UI.
+ ui_base_path: /_matrix/maubot
+ # Override path from where to load UI resources.
+ # Set to false to using pkg_resources to find the path.
+ override_resource_path: /opt/maubot/frontend
# The base appservice API path. Use / for legacy appservice API and /_matrix/app/v1 for v1.
appservice_base_path: /_matrix/app/v1
# The shared secret to sign API access tokens.
diff --git a/example-config.yaml b/example-config.yaml
index b3987f1..1b02d67 100644
--- a/example-config.yaml
+++ b/example-config.yaml
@@ -24,6 +24,11 @@ server:
port: 29316
# The base management API path.
base_path: /_matrix/maubot/v1
+ # The base path for the UI.
+ ui_base_path: /_matrix/maubot
+ # Override path from where to load UI resources.
+ # Set to false to using pkg_resources to find the path.
+ override_resource_path: false
# The base appservice API path. Use / for legacy appservice API and /_matrix/app/v1 for v1.
appservice_base_path: /_matrix/app/v1
# The shared secret to sign API access tokens.
diff --git a/maubot/__main__.py b/maubot/__main__.py
index 3929095..e970c50 100644
--- a/maubot/__main__.py
+++ b/maubot/__main__.py
@@ -26,7 +26,7 @@ from .server import MaubotServer
from .client import Client, init as init_client_class
from .loader.zip import init as init_zip_loader
from .instance import init as init_plugin_instance_class
-from .management.api import init as init_management
+from .management.api import init as init_management_api
from .__meta__ import __version__
parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.",
@@ -52,8 +52,9 @@ init_zip_loader(config)
db_session = init_db(config)
clients = init_client_class(db_session, loop)
plugins = init_plugin_instance_class(db_session, config, loop)
-management_api = init_management(config, loop)
-server = MaubotServer(config, management_api, loop)
+management_api = init_management_api(config, loop)
+server = MaubotServer(config, loop)
+server.app.add_subapp(config["server.base_path"], management_api)
for plugin in plugins:
plugin.load()
diff --git a/maubot/client.py b/maubot/client.py
index ea7db0d..5b91d8c 100644
--- a/maubot/client.py
+++ b/maubot/client.py
@@ -177,13 +177,13 @@ class Client:
await self.stop()
async def update_displayname(self, displayname: str) -> None:
- if not displayname or displayname == self.displayname:
+ if displayname is None or displayname == self.displayname:
return
self.db_instance.displayname = displayname
await self.client.set_displayname(self.displayname)
async def update_avatar_url(self, avatar_url: ContentURI) -> None:
- if not avatar_url or avatar_url == self.avatar_url:
+ if avatar_url is None or avatar_url == self.avatar_url:
return
self.db_instance.avatar_url = avatar_url
await self.client.set_avatar_url(self.avatar_url)
@@ -198,7 +198,7 @@ class Client:
client_session=self.http_client, log=self.log)
mxid = await new_client.whoami()
if mxid != self.id:
- raise ValueError("MXID mismatch")
+ raise ValueError(f"MXID mismatch: {mxid}")
new_client.store = self.db_instance
self.stop_sync()
self.client = new_client
diff --git a/maubot/config.py b/maubot/config.py
index cf39d00..ea8dd3c 100644
--- a/maubot/config.py
+++ b/maubot/config.py
@@ -38,6 +38,9 @@ class Config(BaseFileConfig):
copy("server.hostname")
copy("server.port")
copy("server.listen")
+ copy("server.base_path")
+ copy("server.ui_base_path")
+ copy("server.override_resource_path")
copy("server.appservice_base_path")
shared_secret = self["server.unshared_secret"]
if shared_secret is None or shared_secret == "generate":
diff --git a/maubot/instance.py b/maubot/instance.py
index 00f8be7..c797466 100644
--- a/maubot/instance.py
+++ b/maubot/instance.py
@@ -186,10 +186,27 @@ class PluginInstance:
self.db_instance.primary_user = client.id
self.client.references.remove(self)
self.client = client
+ self.client.references.add(self)
await self.start()
self.log.debug(f"Primary user switched to {self.client.id}")
return True
+ async def update_type(self, type: str) -> bool:
+ if not type or type == self.type:
+ return True
+ try:
+ loader = PluginLoader.find(type)
+ except KeyError:
+ return False
+ await self.stop()
+ self.db_instance.type = loader.id
+ self.loader.references.remove(self)
+ self.loader = loader
+ self.loader.references.add(self)
+ await self.start()
+ self.log.debug(f"Type switched to {self.loader.id}")
+ return True
+
async def update_started(self, started: bool) -> None:
if started is not None and started != self.started:
await (self.start() if started else self.stop())
diff --git a/maubot/management/api/auth.py b/maubot/management/api/auth.py
index 34303d1..e754809 100644
--- a/maubot/management/api/auth.py
+++ b/maubot/management/api/auth.py
@@ -13,6 +13,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+from typing import Optional
from time import time
import json
@@ -39,13 +40,31 @@ def create_token(user: UserID) -> str:
})
-@routes.post("/auth/ping")
-async def ping(request: web.Request) -> web.Response:
+def get_token(request: web.Request) -> str:
token = request.headers.get("Authorization", "")
if not token or not token.startswith("Bearer "):
+ token = request.query.get("access_token", None)
+ else:
+ token = token[len("Bearer "):]
+ return token
+
+
+def check_token(request: web.Request) -> Optional[web.Response]:
+ token = get_token(request)
+ if not token:
+ return resp.no_token
+ elif not is_valid_token(token):
+ return resp.invalid_token
+ return None
+
+
+@routes.post("/auth/ping")
+async def ping(request: web.Request) -> web.Response:
+ token = get_token(request)
+ if not token:
return resp.no_token
- data = verify_token(get_config()["server.unshared_secret"], token[len("Bearer "):])
+ data = verify_token(get_config()["server.unshared_secret"], token)
if not data:
return resp.invalid_token
user = data.get("user_id", None)
diff --git a/maubot/management/api/base.py b/maubot/management/api/base.py
index d9c2077..4cf9636 100644
--- a/maubot/management/api/base.py
+++ b/maubot/management/api/base.py
@@ -15,6 +15,7 @@
# along with this program. If not, see .
from aiohttp import web
+from ...__meta__ import __version__
from ...config import Config
routes: web.RouteTableDef = web.RouteTableDef()
@@ -28,3 +29,10 @@ def set_config(config: Config) -> None:
def get_config() -> Config:
return _config
+
+
+@routes.get("/version")
+async def version(_: web.Request) -> web.Response:
+ return web.json_response({
+ "version": __version__
+ })
diff --git a/maubot/management/api/client.py b/maubot/management/api/client.py
index 872c965..2975581 100644
--- a/maubot/management/api/client.py
+++ b/maubot/management/api/client.py
@@ -20,7 +20,7 @@ from http import HTTPStatus
from aiohttp import web
from mautrix.types import UserID, SyncToken, FilterID
-from mautrix.errors import MatrixRequestError, MatrixInvalidToken
+from mautrix.errors import MatrixRequestError, MatrixConnectionError, MatrixInvalidToken
from mautrix.client import Client as MatrixClient
from ...db import DBClient
@@ -54,12 +54,14 @@ async def _create_client(user_id: Optional[UserID], data: dict) -> web.Response:
return resp.bad_client_access_token
except MatrixRequestError:
return resp.bad_client_access_details
+ except MatrixConnectionError:
+ return resp.bad_client_connection_details
if user_id is None:
existing_client = Client.get(mxid, None)
if existing_client is not None:
return resp.user_exists
elif mxid != user_id:
- return resp.mxid_mismatch
+ return resp.mxid_mismatch(mxid)
db_instance = DBClient(id=mxid, homeserver=homeserver, access_token=access_token,
enabled=data.get("enabled", True), next_batch=SyncToken(""),
filter_id=FilterID(""), sync=data.get("sync", True),
@@ -81,8 +83,10 @@ async def _update_client(client: Client, data: dict) -> web.Response:
return resp.bad_client_access_token
except MatrixRequestError:
return resp.bad_client_access_details
- except ValueError:
- return resp.mxid_mismatch
+ except MatrixConnectionError:
+ return resp.bad_client_connection_details
+ except ValueError as e:
+ return resp.mxid_mismatch(str(e)[len("MXID mismatch: "):])
await client.update_avatar_url(data.get("avatar_url", None))
await client.update_displayname(data.get("displayname", None))
await client.update_started(data.get("started", None))
@@ -127,3 +131,27 @@ async def delete_client(request: web.Request) -> web.Response:
await client.stop()
client.delete()
return resp.deleted
+
+
+@routes.post("/client/{id}/avatar")
+async def upload_avatar(request: web.Request) -> web.Response:
+ user_id = request.match_info.get("id", None)
+ client = Client.get(user_id, None)
+ if not client:
+ return resp.client_not_found
+ content = await request.read()
+ return web.json_response({
+ "content_uri": await client.client.upload_media(
+ content, request.headers.get("Content-Type", None)),
+ })
+
+
+@routes.get("/client/{id}/avatar")
+async def download_avatar(request: web.Request) -> web.Response:
+ user_id = request.match_info.get("id", None)
+ client = Client.get(user_id, None)
+ if not client:
+ return resp.client_not_found
+ if not client.avatar_url or client.avatar_url == "disable":
+ return web.Response()
+ return web.Response(body=await client.client.download_media(client.avatar_url))
diff --git a/maubot/management/api/instance.py b/maubot/management/api/instance.py
index 57cf2f3..ad7f429 100644
--- a/maubot/management/api/instance.py
+++ b/maubot/management/api/instance.py
@@ -70,6 +70,7 @@ async def _update_instance(instance: PluginInstance, data: dict) -> web.Response
instance.update_enabled(data.get("enabled", None))
instance.update_config(data.get("config", None))
await instance.update_started(data.get("started", None))
+ await instance.update_type(data.get("type", None))
instance.db.commit()
return resp.updated(instance.to_dict())
diff --git a/maubot/management/api/middleware.py b/maubot/management/api/middleware.py
index 2fefbe8..79bc26b 100644
--- a/maubot/management/api/middleware.py
+++ b/maubot/management/api/middleware.py
@@ -19,7 +19,7 @@ import logging
from aiohttp import web
from .responses import resp
-from .auth import is_valid_token
+from .auth import check_token
Handler = Callable[[web.Request], Awaitable[web.Response]]
@@ -28,12 +28,7 @@ Handler = Callable[[web.Request], Awaitable[web.Response]]
async def auth(request: web.Request, handler: Handler) -> web.Response:
if "/auth/" in request.path:
return await handler(request)
- token = request.headers.get("Authorization", "")
- if not token or not token.startswith("Bearer "):
- return resp.no_token
- if not is_valid_token(token[len("Bearer "):]):
- return resp.invalid_token
- return await handler(request)
+ return check_token(request) or await handler(request)
log = logging.getLogger("maubot.server")
diff --git a/maubot/management/api/plugin.py b/maubot/management/api/plugin.py
index 4fbb209..3113124 100644
--- a/maubot/management/api/plugin.py
+++ b/maubot/management/api/plugin.py
@@ -69,6 +69,45 @@ async def reload_plugin(request: web.Request) -> web.Response:
return resp.ok
+@routes.put("/plugin/{id}")
+async def put_plugin(request: web.Request) -> web.Response:
+ plugin_id = request.match_info.get("id", None)
+ content = await request.read()
+ file = BytesIO(content)
+ try:
+ pid, version = ZippedPluginLoader.verify_meta(file)
+ except MaubotZipImportError as e:
+ return resp.plugin_import_error(str(e), traceback.format_exc())
+ if pid != plugin_id:
+ return resp.pid_mismatch
+ plugin = PluginLoader.id_cache.get(plugin_id, None)
+ if not plugin:
+ return await upload_new_plugin(content, pid, version)
+ elif isinstance(plugin, ZippedPluginLoader):
+ return await upload_replacement_plugin(plugin, content, version)
+ else:
+ return resp.unsupported_plugin_loader
+
+
+@routes.post("/plugins/upload")
+async def upload_plugin(request: web.Request) -> web.Response:
+ content = await request.read()
+ file = BytesIO(content)
+ try:
+ pid, version = ZippedPluginLoader.verify_meta(file)
+ except MaubotZipImportError as e:
+ return resp.plugin_import_error(str(e), traceback.format_exc())
+ plugin = PluginLoader.id_cache.get(pid, None)
+ if not plugin:
+ return await upload_new_plugin(content, pid, version)
+ elif not request.query.get("allow_override"):
+ return resp.plugin_exists
+ elif isinstance(plugin, ZippedPluginLoader):
+ return await upload_replacement_plugin(plugin, content, version)
+ else:
+ return resp.unsupported_plugin_loader
+
+
async def upload_new_plugin(content: bytes, pid: str, version: str) -> web.Response:
path = os.path.join(get_config()["plugin_directories.upload"], f"{pid}-v{version}.mbp")
with open(path, "wb") as p:
@@ -86,10 +125,10 @@ async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes,
dirname = os.path.dirname(plugin.path)
old_filename = os.path.basename(plugin.path)
if plugin.version in old_filename:
- filename = old_filename.replace(plugin.version, new_version)
- if filename == old_filename:
- filename = re.sub(f"{re.escape(plugin.version)}(-ts[0-9]+)?",
- f"{new_version}-ts{int(time())}", old_filename)
+ replacement = (new_version if plugin.version != new_version
+ else f"{new_version}-ts{int(time())}")
+ filename = re.sub(f"{re.escape(plugin.version)}(-ts[0-9]+)?",
+ replacement, old_filename)
else:
filename = old_filename.rstrip(".mbp")
filename = f"{filename}-v{new_version}.mbp"
@@ -110,20 +149,3 @@ async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes,
await plugin.start_instances()
ZippedPluginLoader.trash(old_path, reason="update")
return resp.updated(plugin.to_dict())
-
-
-@routes.post("/plugins/upload")
-async def upload_plugin(request: web.Request) -> web.Response:
- content = await request.read()
- file = BytesIO(content)
- try:
- pid, version = ZippedPluginLoader.verify_meta(file)
- except MaubotZipImportError as e:
- return resp.plugin_import_error(str(e), traceback.format_exc())
- plugin = PluginLoader.id_cache.get(pid, None)
- if not plugin:
- return await upload_new_plugin(content, pid, version)
- elif isinstance(plugin, ZippedPluginLoader):
- return await upload_replacement_plugin(plugin, content, version)
- else:
- return resp.unsupported_plugin_loader
diff --git a/maubot/management/api/responses.py b/maubot/management/api/responses.py
index 9c815c2..5204927 100644
--- a/maubot/management/api/responses.py
+++ b/maubot/management/api/responses.py
@@ -55,12 +55,26 @@ class _Response:
}, status=HTTPStatus.BAD_REQUEST)
@property
- def mxid_mismatch(self) -> web.Response:
+ def bad_client_connection_details(self) -> web.Response:
return web.json_response({
- "error": "The Matrix user ID of the client and the user ID of the access token don't match",
+ "error": "Could not connect to homeserver",
+ "errcode": "bad_client_connection_details"
+ }, status=HTTPStatus.BAD_REQUEST)
+
+ def mxid_mismatch(self, found: str) -> web.Response:
+ return web.json_response({
+ "error": "The Matrix user ID of the client and the user ID of the access token don't "
+ f"match. Access token is for user {found}",
"errcode": "mxid_mismatch",
}, status=HTTPStatus.BAD_REQUEST)
+ @property
+ def pid_mismatch(self) -> web.Response:
+ return web.json_response({
+ "error": "The ID in the path does not match the ID of the uploaded plugin",
+ "errcode": "pid_mismatch",
+ }, status=HTTPStatus.BAD_REQUEST)
+
@property
def bad_auth(self) -> web.Response:
return web.json_response({
@@ -138,6 +152,13 @@ class _Response:
"errcode": "user_exists",
}, status=HTTPStatus.CONFLICT)
+ @property
+ def plugin_exists(self) -> web.Response:
+ return web.json_response({
+ "error": "A plugin with the same ID as the uploaded plugin already exists",
+ "errcode": "plugin_exists"
+ }, status=HTTPStatus.CONFLICT)
+
@property
def plugin_in_use(self) -> web.Response:
return web.json_response({
diff --git a/maubot/management/api/spec.yaml b/maubot/management/api/spec.yaml
index 75ec865..af93a65 100644
--- a/maubot/management/api/spec.yaml
+++ b/maubot/management/api/spec.yaml
@@ -85,6 +85,21 @@ paths:
summary: Upload a new plugin
description: Upload a new plugin. If the plugin already exists, enabled instances will be restarted.
tags: [Plugins]
+ parameters:
+ - name: allow_override
+ in: query
+ description: Set to allow overriding existing plugins
+ required: false
+ schema:
+ type: boolean
+ default: false
+ requestBody:
+ content:
+ application/zip:
+ schema:
+ type: string
+ format: binary
+ example: The plugin maubot archive (.mbp)
responses:
200:
description: Plugin uploaded and replaced current version successfully
@@ -102,13 +117,8 @@ paths:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
- requestBody:
- content:
- application/zip:
- schema:
- type: string
- format: binary
- example: The plugin maubot archive (.mbp)
+ 409:
+ description: Plugin already exists and allow_override was not specified.
'/plugin/{id}':
parameters:
- name: id
@@ -150,6 +160,39 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
+ put:
+ operationId: put_plugin
+ summary: Upload a new or replacement plugin
+ description: |
+ Upload a new or replacement plugin with the specified ID.
+ A HTTP 400 will be returned if the ID of the uploaded plugin
+ doesn't match the ID in the path. If the plugin already
+ exists, enabled instances will be restarted.
+ tags: [Plugins]
+ requestBody:
+ content:
+ application/zip:
+ schema:
+ type: string
+ format: binary
+ example: The plugin maubot archive (.mbp)
+ responses:
+ 200:
+ description: Plugin uploaded and replaced current version successfully
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Plugin'
+ 201:
+ description: New plugin uploaded successfully
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Plugin'
+ 400:
+ $ref: '#/components/responses/BadRequest'
+ 401:
+ $ref: '#/components/responses/Unauthorized'
/plugin/{id}/reload:
parameters:
- name: id
@@ -356,6 +399,45 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
+ '/client/{id}/avatar':
+ parameters:
+ - name: id
+ in: path
+ description: The Matrix user ID of the client to get
+ required: true
+ schema:
+ type: string
+ post:
+ operationId: upload_avatar
+ summary: Upload a profile picture for a bot
+ tags: [Clients]
+ requestBody:
+ content:
+ image/png:
+ schema:
+ type: string
+ format: binary
+ example: The avatar to upload
+ image/jpeg:
+ schema:
+ type: string
+ format: binary
+ example: The avatar to upload
+ responses:
+ 200:
+ description: The avatar was uploaded successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ content_uri:
+ type: string
+ description: The MXC URI of the uploaded avatar
+ 400:
+ $ref: '#/components/responses/BadRequest'
+ 401:
+ $ref: '#/components/responses/Unauthorized'
components:
responses:
diff --git a/maubot/management/frontend/.sass-lint.yml b/maubot/management/frontend/.sass-lint.yml
new file mode 100644
index 0000000..61fdcfc
--- /dev/null
+++ b/maubot/management/frontend/.sass-lint.yml
@@ -0,0 +1,27 @@
+options:
+ merge-default-rules: false
+ formatter: html
+ max-warnings: 50
+
+files:
+ include: 'src/style/**/*.sass'
+
+rules:
+ extends-before-mixins: 2
+ extends-before-declarations: 2
+ placeholder-in-extend: 2
+ mixins-before-declarations:
+ - 2
+ - exclude:
+ - breakpoint
+ - mq
+ no-warn: 1
+ no-debug: 1
+ hex-notation:
+ - 2
+ - style: uppercase
+ indentation:
+ - 2
+ - size: 4
+ property-sort-order:
+ - 0
diff --git a/maubot/management/frontend/package.json b/maubot/management/frontend/package.json
index c5cf653..320679b 100644
--- a/maubot/management/frontend/package.json
+++ b/maubot/management/frontend/package.json
@@ -5,8 +5,11 @@
"dependencies": {
"node-sass": "^4.9.4",
"react": "^16.6.0",
+ "react-ace": "^6.2.0",
"react-dom": "^16.6.0",
- "react-scripts": "2.0.5"
+ "react-router-dom": "^4.3.1",
+ "react-scripts": "2.0.5",
+ "react-select": "^2.1.1"
},
"scripts": {
"start": "react-scripts start",
@@ -22,5 +25,10 @@
"last 2 safari versions",
"last 2 ios_saf versions"
],
- "proxy": "http://localhost:29316"
+ "proxy": "http://localhost:29316",
+ "homepage": ".",
+ "devDependencies": {
+ "sass-lint": "^1.12.1",
+ "sass-lint-auto-fix": "^0.15.0"
+ }
}
diff --git a/maubot/management/frontend/public/index.html b/maubot/management/frontend/public/index.html
index 932bd2e..886ebf5 100644
--- a/maubot/management/frontend/public/index.html
+++ b/maubot/management/frontend/public/index.html
@@ -17,18 +17,22 @@ along with this program. If not, see .
-->
-
+
+
+
Maubot Manager
-
-
-
-
-
+
+
+
+
+
diff --git a/maubot/management/frontend/src/api.js b/maubot/management/frontend/src/api.js
new file mode 100644
index 0000000..8d7d9b6
--- /dev/null
+++ b/maubot/management/frontend/src/api.js
@@ -0,0 +1,130 @@
+// maubot - A plugin-based Matrix bot system.
+// Copyright (C) 2018 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+export const BASE_PATH = "/_matrix/maubot/v1"
+
+function getHeaders(contentType = "application/json") {
+ return {
+ "Content-Type": contentType,
+ "Authorization": `Bearer ${localStorage.accessToken}`,
+ }
+}
+
+async function defaultDelete(type, id) {
+ const resp = await fetch(`${BASE_PATH}/${type}/${id}`, {
+ headers: getHeaders(),
+ method: "DELETE",
+ })
+ if (resp.status === 204) {
+ return {
+ "success": true,
+ }
+ }
+ return await resp.json()
+}
+
+async function defaultPut(type, entry, id = undefined) {
+ const resp = await fetch(`${BASE_PATH}/${type}/${id || entry.id}`, {
+ headers: getHeaders(),
+ body: JSON.stringify(entry),
+ method: "PUT",
+ })
+ return await resp.json()
+}
+
+async function defaultGet(path) {
+ const resp = await fetch(`${BASE_PATH}${path}`, { headers: getHeaders() })
+ return await resp.json()
+}
+
+export async function login(username, password) {
+ const resp = await fetch(`${BASE_PATH}/auth/login`, {
+ method: "POST",
+ body: JSON.stringify({
+ username,
+ password,
+ }),
+ })
+ return await resp.json()
+}
+
+export async function ping() {
+ const response = await fetch(`${BASE_PATH}/auth/ping`, {
+ method: "POST",
+ headers: getHeaders(),
+ })
+ const json = await response.json()
+ if (json.username) {
+ return json.username
+ } else if (json.errcode === "auth_token_missing" || json.errcode === "auth_token_invalid") {
+ return null
+ }
+ throw json
+}
+
+export const getInstances = () => defaultGet("/instances")
+export const getInstance = id => defaultGet(`/instance/${id}`)
+export const putInstance = (instance, id) => defaultPut("instance", instance, id)
+export const deleteInstance = id => defaultDelete("instance", id)
+
+export const getPlugins = () => defaultGet("/plugins")
+export const getPlugin = id => defaultGet(`/plugin/${id}`)
+export const deletePlugin = id => defaultDelete("plugin", id)
+
+export async function uploadPlugin(data, id) {
+ let resp
+ if (id) {
+ resp = await fetch(`${BASE_PATH}/plugin/${id}`, {
+ headers: getHeaders("application/zip"),
+ body: data,
+ method: "PUT",
+ })
+ } else {
+ resp = await fetch(`${BASE_PATH}/plugins/upload`, {
+ headers: getHeaders("application/zip"),
+ body: data,
+ method: "POST",
+ })
+ }
+ return await resp.json()
+}
+
+export const getClients = () => defaultGet("/clients")
+export const getClient = id => defaultGet(`/clients/${id}`)
+
+export async function uploadAvatar(id, data, mime) {
+ const resp = await fetch(`${BASE_PATH}/client/${id}/avatar`, {
+ headers: getHeaders(mime),
+ body: data,
+ method: "POST",
+ })
+ return await resp.json()
+}
+
+export function getAvatarURL(id) {
+ return `${BASE_PATH}/client/${id}/avatar?access_token=${localStorage.accessToken}`
+}
+
+export const putClient = client => defaultPut("client", client)
+export const deleteClient = id => defaultDelete("client", id)
+
+export default {
+ BASE_PATH,
+ login, ping,
+ getInstances, getInstance, putInstance, deleteInstance,
+ getPlugins, getPlugin, uploadPlugin, deletePlugin,
+ getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient,
+}
diff --git a/maubot/management/frontend/src/components/PreferenceTable.js b/maubot/management/frontend/src/components/PreferenceTable.js
new file mode 100644
index 0000000..92dc3ce
--- /dev/null
+++ b/maubot/management/frontend/src/components/PreferenceTable.js
@@ -0,0 +1,62 @@
+// maubot - A plugin-based Matrix bot system.
+// Copyright (C) 2018 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import React from "react"
+import Select from "react-select"
+import Switch from "./Switch"
+
+export const PrefTable = ({ children, wrapperClass }) => {
+ if (wrapperClass) {
+ return (
+
+)
+
+export default Spinner
diff --git a/maubot/management/frontend/src/components/Switch.js b/maubot/management/frontend/src/components/Switch.js
new file mode 100644
index 0000000..a063abe
--- /dev/null
+++ b/maubot/management/frontend/src/components/Switch.js
@@ -0,0 +1,57 @@
+// maubot - A plugin-based Matrix bot system.
+// Copyright (C) 2018 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import React, { Component } from "react"
+
+class Switch extends Component {
+ constructor(props) {
+ super(props)
+ this.state = {
+ active: props.active,
+ }
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.setState({
+ active: nextProps.active,
+ })
+ }
+
+ toggle = () => {
+ if (this.props.onToggle) {
+ this.props.onToggle(!this.state.active)
+ } else {
+ this.setState({ active: !this.state.active })
+ }
+ }
+
+ toggleKeyboard = evt => (evt.key === " " || evt.key === "Enter") && this.toggle()
+
+ render() {
+ return (
+
+ )
+ }
+}
+
+export default Switch
diff --git a/maubot/management/frontend/src/index.js b/maubot/management/frontend/src/index.js
index 8f50972..12dd05c 100644
--- a/maubot/management/frontend/src/index.js
+++ b/maubot/management/frontend/src/index.js
@@ -16,6 +16,6 @@
import React from "react"
import ReactDOM from "react-dom"
import "./style/index.sass"
-import MaubotManager from "./MaubotManager"
+import App from "./pages/Main"
-ReactDOM.render(, document.getElementById("root"))
+ReactDOM.render(, document.getElementById("root"))
diff --git a/maubot/management/frontend/src/pages/Login.js b/maubot/management/frontend/src/pages/Login.js
new file mode 100644
index 0000000..5b97f14
--- /dev/null
+++ b/maubot/management/frontend/src/pages/Login.js
@@ -0,0 +1,63 @@
+// maubot - A plugin-based Matrix bot system.
+// Copyright (C) 2018 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import React, { Component } from "react"
+import Spinner from "../components/Spinner"
+import api from "../api"
+
+class Login extends Component {
+ constructor(props, context) {
+ super(props, context)
+ this.state = {
+ username: "",
+ password: "",
+ loading: false,
+ error: "",
+ }
+ }
+
+ inputChanged = event => this.setState({ [event.target.name]: event.target.value })
+
+ login = async () => {
+ this.setState({ loading: true })
+ const resp = await api.login(this.state.username, this.state.password)
+ if (resp.token) {
+ await this.props.onLogin(resp.token)
+ } else if (resp.error) {
+ this.setState({ error: resp.error, loading: false })
+ } else {
+ this.setState({ error: "Unknown error", loading: false })
+ console.log("Unknown error:", resp)
+ }
+ }
+
+ render() {
+ return
+
+
Maubot Manager
+
+
+
+ {this.state.error &&
{this.state.error}
}
+
+
+ }
+}
+
+export default Login
diff --git a/maubot/management/frontend/src/pages/Main.js b/maubot/management/frontend/src/pages/Main.js
new file mode 100644
index 0000000..63f419c
--- /dev/null
+++ b/maubot/management/frontend/src/pages/Main.js
@@ -0,0 +1,75 @@
+// maubot - A plugin-based Matrix bot system.
+// Copyright (C) 2018 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import React, { Component } from "react"
+import { HashRouter as Router, Switch } from "react-router-dom"
+import PrivateRoute from "../components/PrivateRoute"
+import Spinner from "../components/Spinner"
+import api from "../api"
+import Dashboard from "./dashboard"
+import Login from "./Login"
+
+class Main extends Component {
+ constructor(props) {
+ super(props)
+ this.state = {
+ pinged: false,
+ authed: false,
+ }
+ }
+
+ async componentWillMount() {
+ if (localStorage.accessToken) {
+ await this.ping()
+ }
+ this.setState({ pinged: true })
+ }
+
+ async ping() {
+ try {
+ const username = await api.ping()
+ if (username) {
+ localStorage.username = username
+ this.setState({ authed: true })
+ } else {
+ delete localStorage.accessToken
+ }
+ } catch (err) {
+ console.error(err)
+ }
+ }
+
+ login = async (token) => {
+ localStorage.accessToken = token
+ await this.ping()
+ }
+
+ render() {
+ if (!this.state.pinged) {
+ return
+ }
+ return
+
+ }
+}
+
+export default withRouter(Client)
diff --git a/maubot/management/frontend/src/pages/dashboard/Instance.js b/maubot/management/frontend/src/pages/dashboard/Instance.js
new file mode 100644
index 0000000..2c2f825
--- /dev/null
+++ b/maubot/management/frontend/src/pages/dashboard/Instance.js
@@ -0,0 +1,174 @@
+// maubot - A plugin-based Matrix bot system.
+// Copyright (C) 2018 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import React from "react"
+import { NavLink, withRouter } from "react-router-dom"
+import AceEditor from "react-ace"
+import "brace/mode/yaml"
+import "brace/theme/github"
+import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
+import PrefTable, { PrefInput, PrefSelect, PrefSwitch } from "../../components/PreferenceTable"
+import api from "../../api"
+import Spinner from "../../components/Spinner"
+import BaseMainView from "./BaseMainView"
+
+const InstanceListEntry = ({ entry }) => (
+
+ {entry.id}
+
+
+)
+
+class Instance extends BaseMainView {
+ static ListEntry = InstanceListEntry
+
+ constructor(props) {
+ super(props)
+ this.deleteFunc = api.deleteInstance
+ this.updateClientOptions()
+ }
+
+ get initialState() {
+ return {
+ id: "",
+ primary_user: "",
+ enabled: true,
+ started: true,
+ type: "",
+ config: "",
+
+ saving: false,
+ deleting: false,
+ error: "",
+ }
+ }
+
+ get instanceInState() {
+ const instance = Object.assign({}, this.state)
+ delete instance.saving
+ delete instance.deleting
+ delete instance.error
+ return instance
+ }
+
+ componentWillReceiveProps(nextProps) {
+ super.componentWillReceiveProps(nextProps)
+ this.updateClientOptions()
+ }
+
+ clientSelectEntry = client => client && {
+ id: client.id,
+ value: client.id,
+ label: (
+
+ }
+}
+
+export default withRouter(Instance)
diff --git a/maubot/management/frontend/src/pages/dashboard/Plugin.js b/maubot/management/frontend/src/pages/dashboard/Plugin.js
new file mode 100644
index 0000000..aac38cf
--- /dev/null
+++ b/maubot/management/frontend/src/pages/dashboard/Plugin.js
@@ -0,0 +1,97 @@
+// maubot - A plugin-based Matrix bot system.
+// Copyright (C) 2018 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import React from "react"
+import { NavLink } from "react-router-dom"
+import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
+import { ReactComponent as UploadButton } from "../../res/upload.svg"
+import PrefTable, { PrefInput } from "../../components/PreferenceTable"
+import Spinner from "../../components/Spinner"
+import api from "../../api"
+import BaseMainView from "./BaseMainView"
+
+const PluginListEntry = ({ entry }) => (
+
+ {entry.id}
+
+
+)
+
+
+class Plugin extends BaseMainView {
+ static ListEntry = PluginListEntry
+
+ get initialState() {
+ return {
+ id: "",
+ version: "",
+
+ instances: [],
+
+ uploading: false,
+ deleting: false,
+ error: "",
+ }
+ }
+
+ upload = async event => {
+ const file = event.target.files[0]
+ this.setState({
+ uploadingAvatar: true,
+ })
+ const data = await this.readFile(file)
+ const resp = await api.uploadPlugin(data, this.state.id)
+ if (resp.id) {
+ if (this.isNew) {
+ this.props.history.push(`/plugin/${resp.id}`)
+ } else {
+ this.setState({ saving: false, error: "" })
+ }
+ this.props.onChange(resp)
+ } else {
+ this.setState({ saving: false, error: resp.error })
+ }
+ }
+
+ render() {
+ return
+ }
+}
+
+export default Plugin
diff --git a/maubot/management/frontend/src/pages/dashboard/index.js b/maubot/management/frontend/src/pages/dashboard/index.js
new file mode 100644
index 0000000..567c443
--- /dev/null
+++ b/maubot/management/frontend/src/pages/dashboard/index.js
@@ -0,0 +1,163 @@
+// maubot - A plugin-based Matrix bot system.
+// Copyright (C) 2018 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import React, { Component } from "react"
+import { Route, Switch, Link, withRouter } from "react-router-dom"
+import api from "../../api"
+import { ReactComponent as Plus } from "../../res/plus.svg"
+import Instance from "./Instance"
+import Client from "./Client"
+import Plugin from "./Plugin"
+
+class Dashboard extends Component {
+ constructor(props) {
+ super(props)
+ this.state = {
+ instances: {},
+ clients: {},
+ plugins: {},
+ sidebarOpen: false,
+ }
+ window.maubot = this
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.location !== prevProps.location) {
+ this.setState({ sidebarOpen: false })
+ }
+ }
+
+ async componentWillMount() {
+ const [instanceList, clientList, pluginList] = await Promise.all([
+ api.getInstances(), api.getClients(), api.getPlugins()])
+ const instances = {}
+ for (const instance of instanceList) {
+ instances[instance.id] = instance
+ }
+ const clients = {}
+ for (const client of clientList) {
+ clients[client.id] = client
+ }
+ const plugins = {}
+ for (const plugin of pluginList) {
+ plugins[plugin.id] = plugin
+ }
+ this.setState({ instances, clients, plugins })
+ }
+
+ renderList(field, type) {
+ return this.state[field] && Object.values(this.state[field]).map(entry =>
+ React.createElement(type, { key: entry.id, entry }))
+ }
+
+ delete(stateField, id) {
+ const data = Object.assign({}, this.state[stateField])
+ delete data[id]
+ this.setState({ [stateField]: data })
+ }
+
+ add(stateField, entry, oldID = undefined) {
+ const data = Object.assign({}, this.state[stateField])
+ if (oldID && oldID !== entry.id) {
+ delete data[oldID]
+ }
+ data[entry.id] = entry
+ this.setState({ [stateField]: data })
+ }
+
+ renderView(field, type, id) {
+ const entry = this.state[field][id]
+ if (!entry) {
+ return this.renderNotFound(field.slice(0, -1))
+ }
+ return React.createElement(type, {
+ entry,
+ onDelete: () => this.delete(field, id),
+ onChange: newEntry => this.add(field, newEntry, id),
+ ctx: this.state,
+ })
+ }
+
+ renderNotFound = (thing = "path") => (
+
+ Oops! I'm afraid that {thing} couldn't be found.
+