diff --git a/example-config.yaml b/example-config.yaml
index 384d877..d4701b8 100644
--- a/example-config.yaml
+++ b/example-config.yaml
@@ -48,6 +48,19 @@ registration_secrets:
admins:
root: ""
+# API feature switches.
+api_features:
+ login: true
+ plugin: true
+ plugin_upload: true
+ instance: true
+ instance_database: true
+ client: true
+ client_proxy: true
+ client_auth: true
+ dev_open: true
+ log: true
+
# Python logging configuration.
#
# See section 16.7.2 of the Python documentation for more info:
diff --git a/maubot/__main__.py b/maubot/__main__.py
index 4d6d525..2a6a3f2 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_mgmt_api, stop as stop_mgmt_api, init_log_listener
+from .management.api import init as init_mgmt_api
from .__meta__ import __version__
parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.",
@@ -43,7 +43,13 @@ config.load()
config.update()
logging.config.dictConfig(copy.deepcopy(config["logging"]))
-init_log_listener()
+
+stop_log_listener = None
+if config["api_features.log"]:
+ from .management.api.log import init as init_log_listener, stop_all as stop_log_listener
+
+ init_log_listener()
+
log = logging.getLogger("maubot.init")
log.info(f"Initializing maubot {__version__}")
@@ -88,8 +94,9 @@ except KeyboardInterrupt:
loop.run_until_complete(asyncio.gather(*[client.stop() for client in Client.cache.values()],
loop=loop))
db_session.commit()
- log.debug("Closing websockets")
- loop.run_until_complete(stop_mgmt_api())
+ if stop_log_listener is not None:
+ log.debug("Closing websockets")
+ loop.run_until_complete(stop_log_listener())
log.debug("Stopping server")
try:
loop.run_until_complete(asyncio.wait_for(server.stop(), 5, loop=loop))
diff --git a/maubot/config.py b/maubot/config.py
index b36a5c5..f3b56e3 100644
--- a/maubot/config.py
+++ b/maubot/config.py
@@ -56,6 +56,16 @@ class Config(BaseFileConfig):
password = self._new_token()
base["admins"][username] = bcrypt.hashpw(password.encode("utf-8"),
bcrypt.gensalt()).decode("utf-8")
+ copy("api_features.login")
+ copy("api_features.plugin")
+ copy("api_features.plugin_upload")
+ copy("api_features.instance")
+ copy("api_features.instance_database")
+ copy("api_features.client")
+ copy("api_features.client_proxy")
+ copy("api_features.client_auth")
+ copy("api_features.dev_open")
+ copy("api_features.log")
copy("logging")
def is_admin(self, user: str) -> bool:
diff --git a/maubot/instance.py b/maubot/instance.py
index 3d51bbd..fd6fa57 100644
--- a/maubot/instance.py
+++ b/maubot/instance.py
@@ -77,7 +77,8 @@ class PluginInstance:
"started": self.started,
"primary_user": self.primary_user,
"config": self.db_instance.config,
- "database": self.inst_db is not None,
+ "database": (self.inst_db is not None
+ and self.mb_config["api_features.instance_database"]),
}
def get_db_tables(self) -> Dict[str, sql.Table]:
diff --git a/maubot/management/api/__init__.py b/maubot/management/api/__init__.py
index 667d0fe..1f0d420 100644
--- a/maubot/management/api/__init__.py
+++ b/maubot/management/api/__init__.py
@@ -15,21 +15,24 @@
# along with this program. If not, see .
from aiohttp import web
from asyncio import AbstractEventLoop
+import importlib
from ...config import Config
-from .base import routes, set_config, set_loop
+from .base import routes, get_config, set_config, set_loop
from .middleware import auth, error
-from . import auth, plugin, instance, instance_database, client, client_proxy, client_auth, dev_open
-from .log import stop_all as stop_log_sockets, init as init_log_listener
+
+
+@routes.get("/features")
+def features(_: web.Request) -> web.Response:
+ return web.json_response(get_config()["api_features"])
def init(cfg: Config, loop: AbstractEventLoop) -> web.Application:
set_config(cfg)
set_loop(loop)
- app = web.Application(loop=loop, middlewares=[auth, error], client_max_size=100*1024*1024)
+ for pkg, enabled in cfg["api_features"].items():
+ if enabled:
+ importlib.import_module(f"maubot.management.api.{pkg}")
+ app = web.Application(loop=loop, middlewares=[auth, error], client_max_size=100 * 1024 * 1024)
app.add_routes(routes)
return app
-
-
-async def stop() -> None:
- await stop_log_sockets()
diff --git a/maubot/management/api/auth.py b/maubot/management/api/auth.py
index e754809..aab1c4e 100644
--- a/maubot/management/api/auth.py
+++ b/maubot/management/api/auth.py
@@ -15,7 +15,6 @@
# along with this program. If not, see .
from typing import Optional
from time import time
-import json
from aiohttp import web
@@ -71,22 +70,3 @@ async def ping(request: web.Request) -> web.Response:
if not get_config().is_admin(user):
return resp.invalid_token
return resp.pong(user)
-
-
-@routes.post("/auth/login")
-async def login(request: web.Request) -> web.Response:
- try:
- data = await request.json()
- except json.JSONDecodeError:
- return resp.body_not_json
- secret = data.get("secret")
- if secret and get_config()["server.unshared_secret"] == secret:
- user = data.get("user") or "root"
- return resp.logged_in(create_token(user))
-
- username = data.get("username")
- password = data.get("password")
- if get_config().check_password(username, password):
- return resp.logged_in(create_token(username))
-
- return resp.bad_auth
diff --git a/maubot/management/api/client_proxy.py b/maubot/management/api/client_proxy.py
index 8dbb650..d34f236 100644
--- a/maubot/management/api/client_proxy.py
+++ b/maubot/management/api/client_proxy.py
@@ -14,6 +14,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
from aiohttp import web, client as http
+
from ...client import Client
from .base import routes
from .responses import resp
diff --git a/maubot/management/api/login.py b/maubot/management/api/login.py
new file mode 100644
index 0000000..1229e6b
--- /dev/null
+++ b/maubot/management/api/login.py
@@ -0,0 +1,40 @@
+# 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 json
+
+from aiohttp import web
+
+from .base import routes, get_config
+from .responses import resp
+from .auth import create_token
+
+@routes.post("/auth/login")
+async def login(request: web.Request) -> web.Response:
+ try:
+ data = await request.json()
+ except json.JSONDecodeError:
+ return resp.body_not_json
+ secret = data.get("secret")
+ if secret and get_config()["server.unshared_secret"] == secret:
+ user = data.get("user") or "root"
+ return resp.logged_in(create_token(user))
+
+ username = data.get("username")
+ password = data.get("password")
+ if get_config().check_password(username, password):
+ return resp.logged_in(create_token(username))
+
+ return resp.bad_auth
diff --git a/maubot/management/api/plugin.py b/maubot/management/api/plugin.py
index d0c79f8..92bd744 100644
--- a/maubot/management/api/plugin.py
+++ b/maubot/management/api/plugin.py
@@ -13,18 +13,13 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from io import BytesIO
-from time import time
import traceback
-import os.path
-import re
from aiohttp import web
-from packaging.version import Version
-from ...loader import PluginLoader, ZippedPluginLoader, MaubotZipImportError
+from ...loader import PluginLoader, MaubotZipImportError
from .responses import resp
-from .base import routes, get_config
+from .base import routes
@routes.get("/plugins")
@@ -67,85 +62,3 @@ async def reload_plugin(request: web.Request) -> web.Response:
return resp.plugin_reload_error(str(e), traceback.format_exc())
await plugin.start_instances()
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: Version) -> web.Response:
- path = os.path.join(get_config()["plugin_directories.upload"], f"{pid}-v{version}.mbp")
- with open(path, "wb") as p:
- p.write(content)
- try:
- plugin = ZippedPluginLoader.get(path)
- except MaubotZipImportError as e:
- ZippedPluginLoader.trash(path)
- return resp.plugin_import_error(str(e), traceback.format_exc())
- return resp.created(plugin.to_dict())
-
-
-async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes,
- new_version: Version) -> web.Response:
- dirname = os.path.dirname(plugin.path)
- old_filename = os.path.basename(plugin.path)
- if str(plugin.meta.version) in old_filename:
- replacement = (new_version if plugin.meta.version != new_version
- else f"{new_version}-ts{int(time())}")
- filename = re.sub(f"{re.escape(str(plugin.meta.version))}(-ts[0-9]+)?",
- replacement, old_filename)
- else:
- filename = old_filename.rstrip(".mbp")
- filename = f"{filename}-v{new_version}.mbp"
- path = os.path.join(dirname, filename)
- with open(path, "wb") as p:
- p.write(content)
- old_path = plugin.path
- await plugin.stop_instances()
- try:
- await plugin.reload(new_path=path)
- except MaubotZipImportError as e:
- try:
- await plugin.reload(new_path=old_path)
- await plugin.start_instances()
- except MaubotZipImportError:
- pass
- return resp.plugin_import_error(str(e), traceback.format_exc())
- await plugin.start_instances()
- ZippedPluginLoader.trash(old_path, reason="update")
- return resp.updated(plugin.to_dict())
diff --git a/maubot/management/api/plugin_upload.py b/maubot/management/api/plugin_upload.py
new file mode 100644
index 0000000..c82fdcb
--- /dev/null
+++ b/maubot/management/api/plugin_upload.py
@@ -0,0 +1,109 @@
+# 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 .
+from io import BytesIO
+from time import time
+import traceback
+import os.path
+import re
+
+from aiohttp import web
+from packaging.version import Version
+
+from ...loader import PluginLoader, ZippedPluginLoader, MaubotZipImportError
+from .responses import resp
+from .base import routes, get_config
+
+
+@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: Version) -> web.Response:
+ path = os.path.join(get_config()["plugin_directories.upload"], f"{pid}-v{version}.mbp")
+ with open(path, "wb") as p:
+ p.write(content)
+ try:
+ plugin = ZippedPluginLoader.get(path)
+ except MaubotZipImportError as e:
+ ZippedPluginLoader.trash(path)
+ return resp.plugin_import_error(str(e), traceback.format_exc())
+ return resp.created(plugin.to_dict())
+
+
+async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes,
+ new_version: Version) -> web.Response:
+ dirname = os.path.dirname(plugin.path)
+ old_filename = os.path.basename(plugin.path)
+ if str(plugin.meta.version) in old_filename:
+ replacement = (new_version if plugin.meta.version != new_version
+ else f"{new_version}-ts{int(time())}")
+ filename = re.sub(f"{re.escape(str(plugin.meta.version))}(-ts[0-9]+)?",
+ replacement, old_filename)
+ else:
+ filename = old_filename.rstrip(".mbp")
+ filename = f"{filename}-v{new_version}.mbp"
+ path = os.path.join(dirname, filename)
+ with open(path, "wb") as p:
+ p.write(content)
+ old_path = plugin.path
+ await plugin.stop_instances()
+ try:
+ await plugin.reload(new_path=path)
+ except MaubotZipImportError as e:
+ try:
+ await plugin.reload(new_path=old_path)
+ await plugin.start_instances()
+ except MaubotZipImportError:
+ pass
+ return resp.plugin_import_error(str(e), traceback.format_exc())
+ await plugin.start_instances()
+ ZippedPluginLoader.trash(old_path, reason="update")
+ return resp.updated(plugin.to_dict())
diff --git a/maubot/management/frontend/src/api.js b/maubot/management/frontend/src/api.js
index aee7fd4..33cd551 100644
--- a/maubot/management/frontend/src/api.js
+++ b/maubot/management/frontend/src/api.js
@@ -61,7 +61,12 @@ export async function login(username, password) {
return await resp.json()
}
+let features = null
+
export async function ping() {
+ if (!features) {
+ await remoteGetFeatures()
+ }
const response = await fetch(`${BASE_PATH}/auth/ping`, {
method: "POST",
headers: getHeaders(),
@@ -75,6 +80,12 @@ export async function ping() {
throw json
}
+export const remoteGetFeatures = async () => {
+ features = await defaultGet("/features")
+}
+
+export const getFeatures = () => features
+
export async function openLogSocket() {
let protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const url = `${protocol}//${window.location.host}${BASE_PATH}/logs`
@@ -211,7 +222,9 @@ export const deleteClient = id => defaultDelete("client", id)
export default {
BASE_PATH,
- login, ping, openLogSocket, debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled,
+ login, ping, getFeatures, remoteGetFeatures,
+ openLogSocket,
+ debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled,
getInstances, getInstance, putInstance, deleteInstance,
getInstanceDatabase, queryInstanceDatabase,
getPlugins, getPlugin, uploadPlugin, deletePlugin,
diff --git a/maubot/management/frontend/src/pages/Login.js b/maubot/management/frontend/src/pages/Login.js
index 5b97f14..6e24af0 100644
--- a/maubot/management/frontend/src/pages/Login.js
+++ b/maubot/management/frontend/src/pages/Login.js
@@ -44,6 +44,14 @@ class Login extends Component {
}
render() {
+ if (!api.getFeatures().login) {
+ return
+
+
Maubot Manager
+
Login has been disabled in the maubot config.
+
+
+ }
return
Maubot Manager
diff --git a/maubot/management/frontend/src/pages/Main.js b/maubot/management/frontend/src/pages/Main.js
index 63f419c..210b4f0 100644
--- a/maubot/management/frontend/src/pages/Main.js
+++ b/maubot/management/frontend/src/pages/Main.js
@@ -33,6 +33,8 @@ class Main extends Component {
async componentWillMount() {
if (localStorage.accessToken) {
await this.ping()
+ } else {
+ await api.remoteGetFeatures()
}
this.setState({ pinged: true })
}
diff --git a/maubot/management/frontend/src/pages/dashboard/BaseMainView.js b/maubot/management/frontend/src/pages/dashboard/BaseMainView.js
index 47c1cd8..0fe44b7 100644
--- a/maubot/management/frontend/src/pages/dashboard/BaseMainView.js
+++ b/maubot/management/frontend/src/pages/dashboard/BaseMainView.js
@@ -1,5 +1,6 @@
import React, { Component } from "react"
import { Link } from "react-router-dom"
+import api from "../../api"
class BaseMainView extends Component {
constructor(props) {
@@ -74,7 +75,7 @@ class BaseMainView extends Component {
)
- renderLogButton = (filter) => !this.isNew &&
+ renderLogButton = (filter) => !this.isNew && api.getFeatures().log &&
}
diff --git a/maubot/management/frontend/src/pages/dashboard/Instance.js b/maubot/management/frontend/src/pages/dashboard/Instance.js
index f68a07b..e80e0bc 100644
--- a/maubot/management/frontend/src/pages/dashboard/Instance.js
+++ b/maubot/management/frontend/src/pages/dashboard/Instance.js
@@ -157,13 +157,24 @@ class Instance extends BaseMainView {
this.setState({ started })}/>
- this.setState({ primary_user: id })}/>
- this.setState({ type: id })}/>
+ {api.getFeatures().client ? (
+ this.setState({ primary_user: id })}/>
+ ) : (
+
+ )}
+ {api.getFeatures().plugin ? (
+ this.setState({ type: id })}/>
+ ) : (
+
+ )}
{!this.isNew &&
this.setState({ config })}
@@ -190,10 +201,11 @@ class Instance extends BaseMainView {
View database
)}
+ {api.getFeatures().log &&
+ }
}
{this.state.error}
diff --git a/maubot/management/frontend/src/pages/dashboard/Plugin.js b/maubot/management/frontend/src/pages/dashboard/Plugin.js
index e3ea809..568a804 100644
--- a/maubot/management/frontend/src/pages/dashboard/Plugin.js
+++ b/maubot/management/frontend/src/pages/dashboard/Plugin.js
@@ -78,6 +78,7 @@ class Plugin extends BaseMainView {
}
+ {api.getFeatures().plugin_upload &&
evt.target.parentElement.classList.add("drag")}
onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/>
{this.state.uploading && }
-
+ }
{!this.isNew &&
)
+ renderDisabled = (thing = "path") => (
+
+ The {thing} API has been disabled in the maubot config.
+
+ )
+
renderMain() {
return
@@ -157,31 +181,31 @@ class Dashboard extends Component {