Add Cloudron packaging for Maubot

This commit is contained in:
Your Name
2025-11-13 21:00:23 -06:00
commit 1af2e64aae
179 changed files with 24749 additions and 0 deletions

View File

View File

@@ -0,0 +1,48 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from asyncio import AbstractEventLoop
import importlib
from aiohttp import web
from ...config import Config
from .auth import check_token
from .base import get_config, routes, set_config
from .middleware import auth, error
@routes.get("/features")
def features(request: web.Request) -> web.Response:
data = get_config()["api_features"]
err = check_token(request)
if err is None:
return web.json_response(data)
else:
return web.json_response(
{
"login": data["login"],
}
)
def init(cfg: Config, loop: AbstractEventLoop) -> web.Application:
set_config(cfg)
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

View File

@@ -0,0 +1,76 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
from time import time
from aiohttp import web
from mautrix.types import UserID
from mautrix.util.signed_token import sign_token, verify_token
from .base import get_config, routes
from .responses import resp
def is_valid_token(token: str) -> bool:
data = verify_token(get_config()["server.unshared_secret"], token)
if not data:
return False
return get_config().is_admin(data.get("user_id", None))
def create_token(user: UserID) -> str:
return sign_token(
get_config()["server.unshared_secret"],
{
"user_id": user,
"created_at": int(time()),
},
)
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", "")
else:
token = token[len("Bearer ") :]
return token
def check_token(request: web.Request) -> web.Response | None:
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)
if not data:
return resp.invalid_token
user = data.get("user_id", None)
if not get_config().is_admin(user):
return resp.invalid_token
return resp.pong(user, get_config()["api_features"])

View File

@@ -0,0 +1,40 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
from aiohttp import web
from ...__meta__ import __version__
from ...config import Config
routes: web.RouteTableDef = web.RouteTableDef()
_config: Config | None = None
def set_config(config: Config) -> None:
global _config
_config = config
def get_config() -> Config:
return _config
@routes.get("/version")
async def version(_: web.Request) -> web.Response:
return web.json_response({"version": __version__})

View File

@@ -0,0 +1,217 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
from json import JSONDecodeError
import logging
from aiohttp import web
from mautrix.client import Client as MatrixClient
from mautrix.errors import MatrixConnectionError, MatrixInvalidToken, MatrixRequestError
from mautrix.types import FilterID, SyncToken, UserID
from ...client import Client
from .base import routes
from .responses import resp
log = logging.getLogger("maubot.server.client")
@routes.get("/clients")
async def get_clients(_: web.Request) -> web.Response:
return resp.found([client.to_dict() for client in Client.cache.values()])
@routes.get("/client/{id}")
async def get_client(request: web.Request) -> web.Response:
user_id = request.match_info.get("id", None)
client = await Client.get(user_id)
if not client:
return resp.client_not_found
return resp.found(client.to_dict())
async def _create_client(user_id: UserID | None, data: dict) -> web.Response:
homeserver = data.get("homeserver", None)
access_token = data.get("access_token", None)
device_id = data.get("device_id", None)
new_client = MatrixClient(
mxid="@not:a.mxid",
base_url=homeserver,
token=access_token,
client_session=Client.http_client,
)
try:
whoami = await new_client.whoami()
except MatrixInvalidToken as e:
return resp.bad_client_access_token
except MatrixRequestError:
log.warning(f"Failed to get whoami from {homeserver} for new client", exc_info=True)
return resp.bad_client_access_details
except MatrixConnectionError:
log.warning(f"Failed to connect to {homeserver} for new client", exc_info=True)
return resp.bad_client_connection_details
if user_id is None:
existing_client = await Client.get(whoami.user_id)
if existing_client is not None:
return resp.user_exists
elif whoami.user_id != user_id:
return resp.mxid_mismatch(whoami.user_id)
elif whoami.device_id and device_id and whoami.device_id != device_id:
return resp.device_id_mismatch(whoami.device_id)
client = await Client.get(
whoami.user_id, homeserver=homeserver, access_token=access_token, device_id=device_id
)
client.enabled = data.get("enabled", True)
client.sync = data.get("sync", True)
await client.update_autojoin(data.get("autojoin", True), save=False)
await client.update_online(data.get("online", True), save=False)
client.displayname = data.get("displayname", "disable")
client.avatar_url = data.get("avatar_url", "disable")
await client.update()
await client.start()
return resp.created(client.to_dict())
async def _update_client(client: Client, data: dict, is_login: bool = False) -> web.Response:
try:
await client.update_access_details(
data.get("access_token"), data.get("homeserver"), data.get("device_id")
)
except MatrixInvalidToken:
return resp.bad_client_access_token
except MatrixRequestError:
log.warning(
f"Failed to get whoami from homeserver to update client details", exc_info=True
)
return resp.bad_client_access_details
except MatrixConnectionError:
log.warning(f"Failed to connect to homeserver to update client details", exc_info=True)
return resp.bad_client_connection_details
except ValueError as e:
str_err = str(e)
if str_err.startswith("MXID mismatch"):
return resp.mxid_mismatch(str(e)[len("MXID mismatch: ") :])
elif str_err.startswith("Device ID mismatch"):
return resp.device_id_mismatch(str(e)[len("Device ID mismatch: ") :])
await client.update_avatar_url(data.get("avatar_url"), save=False)
await client.update_displayname(data.get("displayname"), save=False)
await client.update_started(data.get("started"))
await client.update_enabled(data.get("enabled"), save=False)
await client.update_autojoin(data.get("autojoin"), save=False)
await client.update_online(data.get("online"), save=False)
await client.update_sync(data.get("sync"), save=False)
await client.update()
return resp.updated(client.to_dict(), is_login=is_login)
async def _create_or_update_client(
user_id: UserID, data: dict, is_login: bool = False
) -> web.Response:
client = await Client.get(user_id)
if not client:
return await _create_client(user_id, data)
else:
return await _update_client(client, data, is_login=is_login)
@routes.post("/client/new")
async def create_client(request: web.Request) -> web.Response:
try:
data = await request.json()
except JSONDecodeError:
return resp.body_not_json
return await _create_client(None, data)
@routes.put("/client/{id}")
async def update_client(request: web.Request) -> web.Response:
user_id = request.match_info["id"]
try:
data = await request.json()
except JSONDecodeError:
return resp.body_not_json
return await _create_or_update_client(user_id, data)
@routes.delete("/client/{id}")
async def delete_client(request: web.Request) -> web.Response:
user_id = request.match_info["id"]
client = await Client.get(user_id)
if not client:
return resp.client_not_found
if len(client.references) > 0:
return resp.client_in_use
if client.started:
await client.stop()
await client.delete()
return resp.deleted
@routes.post("/client/{id}/clearcache")
async def clear_client_cache(request: web.Request) -> web.Response:
user_id = request.match_info["id"]
client = await Client.get(user_id)
if not client:
return resp.client_not_found
await client.clear_cache()
return resp.ok
@routes.post("/client/{id}/verify")
async def verify_client(request: web.Request) -> web.Response:
user_id = request.match_info["id"]
client = await Client.get(user_id)
if not client:
return resp.client_not_found
try:
req = await request.json()
except Exception:
return resp.body_not_json
try:
await client.crypto.verify_with_recovery_key(req["recovery_key"])
client.trust_state = await client.crypto.resolve_trust(
client.crypto.own_identity,
allow_fetch=False,
)
log.debug(f"Trust state after verifying {client.id}: {client.trust_state}")
except Exception as e:
log.exception("Failed to verify client with recovery key")
return resp.internal_crypto_error(str(e))
return resp.ok
@routes.post("/client/{id}/generate_recovery_key")
async def generate_recovery_key(request: web.Request) -> web.Response:
user_id = request.match_info["id"]
client = await Client.get(user_id)
if not client:
return resp.client_not_found
try:
keys = await client.crypto.get_own_cross_signing_public_keys()
if keys:
return resp.client_has_keys
key = await client.crypto.generate_recovery_key()
client.trust_state = await client.crypto.resolve_trust(
client.crypto.own_identity,
allow_fetch=False,
)
log.debug(f"Trust state generating recovery key for {client.id}: {client.trust_state}")
except Exception as e:
log.exception("Failed to generate recovery key for client")
return resp.internal_crypto_error(str(e))
return resp.created({"success": True, "recovery_key": key})

View File

@@ -0,0 +1,273 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import NamedTuple
from http import HTTPStatus
from json import JSONDecodeError
import asyncio
import hashlib
import hmac
import random
import string
from aiohttp import web
from yarl import URL
from mautrix.api import Method, Path, SynapseAdminPath
from mautrix.client import ClientAPI
from mautrix.errors import MatrixRequestError
from mautrix.types import LoginResponse, LoginType
from .base import get_config, routes
from .client import _create_client, _create_or_update_client
from .responses import resp
def known_homeservers() -> dict[str, dict[str, str]]:
return get_config()["homeservers"]
@routes.get("/client/auth/servers")
async def get_known_servers(_: web.Request) -> web.Response:
return web.json_response({key: value["url"] for key, value in known_homeservers().items()})
class AuthRequestInfo(NamedTuple):
server_name: str
client: ClientAPI
secret: str
username: str
password: str
user_type: str
device_name: str
update_client: bool
sso: bool
truthy_strings = ("1", "true", "yes")
async def read_client_auth_request(
request: web.Request,
) -> tuple[AuthRequestInfo | None, web.Response | None]:
server_name = request.match_info.get("server", None)
server = known_homeservers().get(server_name, None)
if not server:
return None, resp.server_not_found
try:
body = await request.json()
except JSONDecodeError:
return None, resp.body_not_json
sso = request.query.get("sso", "").lower() in truthy_strings
try:
username = body["username"]
password = body["password"]
except KeyError:
if not sso:
return None, resp.username_or_password_missing
username = password = None
try:
base_url = server["url"]
except KeyError:
return None, resp.invalid_server
return (
AuthRequestInfo(
server_name=server_name,
client=ClientAPI(base_url=base_url),
secret=server.get("secret"),
username=username,
password=password,
user_type=body.get("user_type", "bot"),
device_name=body.get("device_name", "Maubot"),
update_client=request.query.get("update_client", "").lower() in truthy_strings,
sso=sso,
),
None,
)
def generate_mac(
secret: str,
nonce: str,
username: str,
password: str,
admin: bool = False,
user_type: str = None,
) -> str:
mac = hmac.new(key=secret.encode("utf-8"), digestmod=hashlib.sha1)
mac.update(nonce.encode("utf-8"))
mac.update(b"\x00")
mac.update(username.encode("utf-8"))
mac.update(b"\x00")
mac.update(password.encode("utf-8"))
mac.update(b"\x00")
mac.update(b"admin" if admin else b"notadmin")
if user_type is not None:
mac.update(b"\x00")
mac.update(user_type.encode("utf8"))
return mac.hexdigest()
@routes.post("/client/auth/{server}/register")
async def register(request: web.Request) -> web.Response:
req, err = await read_client_auth_request(request)
if err is not None:
return err
if req.sso:
return resp.registration_no_sso
elif not req.secret:
return resp.registration_secret_not_found
path = SynapseAdminPath.v1.register
res = await req.client.api.request(Method.GET, path)
content = {
"nonce": res["nonce"],
"username": req.username,
"password": req.password,
"admin": False,
"user_type": req.user_type,
}
content["mac"] = generate_mac(**content, secret=req.secret)
try:
raw_res = await req.client.api.request(Method.POST, path, content=content)
except MatrixRequestError as e:
return web.json_response(
{
"errcode": e.errcode,
"error": e.message,
"http_status": e.http_status,
},
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
login_res = LoginResponse.deserialize(raw_res)
if req.update_client:
return await _create_client(
login_res.user_id,
{
"homeserver": str(req.client.api.base_url),
"access_token": login_res.access_token,
"device_id": login_res.device_id,
},
)
return web.json_response(login_res.serialize())
@routes.post("/client/auth/{server}/login")
async def login(request: web.Request) -> web.Response:
req, err = await read_client_auth_request(request)
if err is not None:
return err
if req.sso:
return await _do_sso(req)
else:
return await _do_login(req)
async def _do_sso(req: AuthRequestInfo) -> web.Response:
flows = await req.client.get_login_flows()
if not flows.supports_type(LoginType.SSO):
return resp.sso_not_supported
waiter_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=16))
cfg = get_config()
public_url = (
URL(cfg["server.public_url"])
/ "_matrix/maubot/v1/client/auth_external_sso/complete"
/ waiter_id
)
sso_url = req.client.api.base_url.with_path(str(Path.v3.login.sso.redirect)).with_query(
{"redirectUrl": str(public_url)}
)
sso_waiters[waiter_id] = req, asyncio.get_running_loop().create_future()
return web.json_response({"sso_url": str(sso_url), "id": waiter_id})
async def _do_login(req: AuthRequestInfo, login_token: str | None = None) -> web.Response:
device_id = "".join(random.choices(string.ascii_uppercase + string.digits, k=8))
device_id = f"maubot_{device_id}"
try:
if req.sso:
res = await req.client.login(
token=login_token,
login_type=LoginType.TOKEN,
device_id=device_id,
store_access_token=False,
initial_device_display_name=req.device_name,
)
else:
res = await req.client.login(
identifier=req.username,
login_type=LoginType.PASSWORD,
password=req.password,
device_id=device_id,
initial_device_display_name=req.device_name,
store_access_token=False,
)
except MatrixRequestError as e:
return web.json_response(
{
"errcode": e.errcode,
"error": e.message,
},
status=e.http_status,
)
if req.update_client:
return await _create_or_update_client(
res.user_id,
{
"homeserver": str(req.client.api.base_url),
"access_token": res.access_token,
"device_id": res.device_id,
},
is_login=True,
)
return web.json_response(res.serialize())
sso_waiters: dict[str, tuple[AuthRequestInfo, asyncio.Future]] = {}
@routes.post("/client/auth/{server}/sso/{id}/wait")
async def wait_sso(request: web.Request) -> web.Response:
waiter_id = request.match_info["id"]
req, fut = sso_waiters[waiter_id]
try:
login_token = await fut
finally:
sso_waiters.pop(waiter_id, None)
return await _do_login(req, login_token)
@routes.get("/client/auth_external_sso/complete/{id}")
async def complete_sso(request: web.Request) -> web.Response:
try:
_, fut = sso_waiters[request.match_info["id"]]
except KeyError:
return web.Response(status=404, text="Invalid session ID\n")
if fut.cancelled():
return web.Response(status=200, text="The login was cancelled from the Maubot client\n")
elif fut.done():
return web.Response(status=200, text="The login token was already received\n")
try:
fut.set_result(request.query["loginToken"])
except KeyError:
return web.Response(status=400, text="Missing loginToken query parameter\n")
except asyncio.InvalidStateError:
return web.Response(status=500, text="Invalid state\n")
return web.Response(
status=200,
text="Login token received, please return to your Maubot client. "
"This tab can be closed.\n",
)

View File

@@ -0,0 +1,56 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from aiohttp import client as http, web
from ...client import Client
from .base import routes
from .responses import resp
PROXY_CHUNK_SIZE = 32 * 1024
@routes.view("/proxy/{id}/{path:_matrix/.+}")
async def proxy(request: web.Request) -> web.StreamResponse:
user_id = request.match_info.get("id", None)
client = await Client.get(user_id)
if not client:
return resp.client_not_found
path = request.match_info.get("path", None)
query = request.query.copy()
try:
del query["access_token"]
except KeyError:
pass
headers = request.headers.copy()
del headers["Host"]
headers["Authorization"] = f"Bearer {client.access_token}"
if "X-Forwarded-For" not in headers:
peer = request.transport.get_extra_info("peername")
if peer is not None:
host, port = peer
headers["X-Forwarded-For"] = f"{host}:{port}"
data = await request.read()
async with http.request(
request.method, f"{client.homeserver}/{path}", headers=headers, params=query, data=data
) as proxy_resp:
response = web.StreamResponse(status=proxy_resp.status, headers=proxy_resp.headers)
await response.prepare(request)
async for chunk in proxy_resp.content.iter_chunked(PROXY_CHUNK_SIZE):
await response.write(chunk)
await response.write_eof()
return response

View File

@@ -0,0 +1,57 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from string import Template
import asyncio
import re
from aiohttp import web
from ruamel.yaml import YAML
from .base import routes
enabled = False
@routes.get("/debug/open")
async def check_enabled(_: web.Request) -> web.Response:
return web.json_response({"enabled": enabled})
try:
yaml = YAML()
with open(".dev-open-cfg.yaml", "r") as file:
cfg = yaml.load(file)
editor_command = Template(cfg["editor"])
pathmap = [(re.compile(item["find"]), item["replace"]) for item in cfg["pathmap"]]
@routes.post("/debug/open")
async def open_file(request: web.Request) -> web.Response:
data = await request.json()
try:
path = data["path"]
for find, replace in pathmap:
path = find.sub(replace, path)
cmd = editor_command.substitute(path=path, line=data["line"])
except (KeyError, ValueError):
return web.Response(status=400)
res = await asyncio.create_subprocess_shell(cmd)
stdout, stderr = await res.communicate()
return web.json_response({"return": res.returncode, "stdout": stdout, "stderr": stderr})
enabled = True
except Exception:
pass

View File

@@ -0,0 +1,97 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from json import JSONDecodeError
from aiohttp import web
from ...client import Client
from ...instance import PluginInstance
from ...loader import PluginLoader
from .base import routes
from .responses import resp
@routes.get("/instances")
async def get_instances(_: web.Request) -> web.Response:
return resp.found([instance.to_dict() for instance in PluginInstance.cache.values()])
@routes.get("/instance/{id}")
async def get_instance(request: web.Request) -> web.Response:
instance_id = request.match_info["id"].lower()
instance = await PluginInstance.get(instance_id)
if not instance:
return resp.instance_not_found
return resp.found(instance.to_dict())
async def _create_instance(instance_id: str, data: dict) -> web.Response:
plugin_type = data.get("type")
primary_user = data.get("primary_user")
if not plugin_type:
return resp.plugin_type_required
elif not primary_user:
return resp.primary_user_required
elif not await Client.get(primary_user):
return resp.primary_user_not_found
try:
PluginLoader.find(plugin_type)
except KeyError:
return resp.plugin_type_not_found
instance = await PluginInstance.get(instance_id, type=plugin_type, primary_user=primary_user)
instance.enabled = data.get("enabled", True)
instance.config_str = data.get("config") or ""
await instance.update()
await instance.load()
await instance.start()
return resp.created(instance.to_dict())
async def _update_instance(instance: PluginInstance, data: dict) -> web.Response:
if not await instance.update_primary_user(data.get("primary_user")):
return resp.primary_user_not_found
await instance.update_id(data.get("id"))
await instance.update_enabled(data.get("enabled"))
await instance.update_config(data.get("config"))
await instance.update_started(data.get("started"))
await instance.update_type(data.get("type"))
return resp.updated(instance.to_dict())
@routes.put("/instance/{id}")
async def update_instance(request: web.Request) -> web.Response:
instance_id = request.match_info["id"].lower()
instance = await PluginInstance.get(instance_id)
try:
data = await request.json()
except JSONDecodeError:
return resp.body_not_json
if not instance:
return await _create_instance(instance_id, data)
else:
return await _update_instance(instance, data)
@routes.delete("/instance/{id}")
async def delete_instance(request: web.Request) -> web.Response:
instance_id = request.match_info["id"].lower()
instance = await PluginInstance.get(instance_id)
if not instance:
return resp.instance_not_found
if instance.started:
await instance.stop()
await instance.delete()
return resp.deleted

View File

@@ -0,0 +1,161 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
from datetime import datetime
from aiohttp import web
from asyncpg import PostgresError
import aiosqlite
from mautrix.util.async_db import Database
from ...instance import PluginInstance
from ...lib.optionalalchemy import Engine, IntegrityError, OperationalError, asc, desc
from .base import routes
from .responses import resp
@routes.get("/instance/{id}/database")
async def get_database(request: web.Request) -> web.Response:
instance_id = request.match_info["id"].lower()
instance = await PluginInstance.get(instance_id)
if not instance:
return resp.instance_not_found
elif not instance.inst_db:
return resp.plugin_has_no_database
return web.json_response(await instance.get_db_tables())
@routes.get("/instance/{id}/database/{table}")
async def get_table(request: web.Request) -> web.Response:
instance_id = request.match_info["id"].lower()
instance = await PluginInstance.get(instance_id)
if not instance:
return resp.instance_not_found
elif not instance.inst_db:
return resp.plugin_has_no_database
tables = await instance.get_db_tables()
try:
table = tables[request.match_info.get("table", "")]
except KeyError:
return resp.table_not_found
try:
order = [tuple(order.split(":")) for order in request.query.getall("order")]
order = [
(
(asc if sort.lower() == "asc" else desc)(table.columns[column])
if sort
else table.columns[column]
)
for column, sort in order
]
except KeyError:
order = []
limit = int(request.query.get("limit", "100"))
if isinstance(instance.inst_db, Engine):
return _execute_query_sqlalchemy(instance, table.select().order_by(*order).limit(limit))
@routes.post("/instance/{id}/database/query")
async def query(request: web.Request) -> web.Response:
instance_id = request.match_info["id"].lower()
instance = await PluginInstance.get(instance_id)
if not instance:
return resp.instance_not_found
elif not instance.inst_db:
return resp.plugin_has_no_database
data = await request.json()
try:
sql_query = data["query"]
except KeyError:
return resp.query_missing
rows_as_dict = data.get("rows_as_dict", False)
if isinstance(instance.inst_db, Engine):
return _execute_query_sqlalchemy(instance, sql_query, rows_as_dict)
elif isinstance(instance.inst_db, Database):
try:
return await _execute_query_asyncpg(instance, sql_query, rows_as_dict)
except (PostgresError, aiosqlite.Error) as e:
return resp.sql_error(e, sql_query)
else:
return resp.unsupported_plugin_database
def check_type(val):
if isinstance(val, datetime):
return val.isoformat()
return val
async def _execute_query_asyncpg(
instance: PluginInstance, sql_query: str, rows_as_dict: bool = False
) -> web.Response:
data = {"ok": True, "query": sql_query}
if sql_query.upper().startswith("SELECT"):
res = await instance.inst_db.fetch(sql_query)
data["rows"] = [
(
{key: check_type(value) for key, value in row.items()}
if rows_as_dict
else [check_type(value) for value in row]
)
for row in res
]
if len(res) > 0:
# TODO can we find column names when there are no rows?
data["columns"] = list(res[0].keys())
else:
res = await instance.inst_db.execute(sql_query)
if isinstance(res, str):
data["status_msg"] = res
elif isinstance(res, aiosqlite.Cursor):
data["rowcount"] = res.rowcount
# data["inserted_primary_key"] = res.lastrowid
else:
data["status_msg"] = "unknown status"
return web.json_response(data)
def _execute_query_sqlalchemy(
instance: PluginInstance, sql_query: str, rows_as_dict: bool = False
) -> web.Response:
assert isinstance(instance.inst_db, Engine)
try:
res = instance.inst_db.execute(sql_query)
except IntegrityError as e:
return resp.sql_integrity_error(e, sql_query)
except OperationalError as e:
return resp.sql_operational_error(e, sql_query)
data = {
"ok": True,
"query": str(sql_query),
}
if res.returns_rows:
data["rows"] = [
(
{key: check_type(value) for key, value in row.items()}
if rows_as_dict
else [check_type(value) for value in row]
)
for row in res
]
data["columns"] = res.keys()
else:
data["rowcount"] = res.rowcount
if res.is_insert:
data["inserted_primary_key"] = res.inserted_primary_key
return web.json_response(data)

View File

@@ -0,0 +1,172 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
from collections import deque
from datetime import datetime
import asyncio
import logging
from aiohttp import web, web_ws
from mautrix.util import background_task
from .auth import is_valid_token
from .base import routes
BUILTIN_ATTRS = {
"args",
"asctime",
"created",
"exc_info",
"exc_text",
"filename",
"funcName",
"levelname",
"levelno",
"lineno",
"module",
"msecs",
"message",
"msg",
"name",
"pathname",
"process",
"processName",
"relativeCreated",
"stack_info",
"thread",
"threadName",
}
INCLUDE_ATTRS = {
"filename",
"funcName",
"levelname",
"levelno",
"lineno",
"module",
"name",
"pathname",
}
EXCLUDE_ATTRS = BUILTIN_ATTRS - INCLUDE_ATTRS
MAX_LINES = 2048
class LogCollector(logging.Handler):
lines: deque[dict]
formatter: logging.Formatter
listeners: list[web.WebSocketResponse]
loop: asyncio.AbstractEventLoop
def __init__(self, level=logging.NOTSET) -> None:
super().__init__(level)
self.lines = deque(maxlen=MAX_LINES)
self.formatter = logging.Formatter()
self.listeners = []
def emit(self, record: logging.LogRecord) -> None:
try:
self._emit(record)
except Exception as e:
print("Logging error:", e)
def _emit(self, record: logging.LogRecord) -> None:
# JSON conversion based on Marsel Mavletkulov's json-log-formatter (MIT license)
# https://github.com/marselester/json-log-formatter
content = {
name: value for name, value in record.__dict__.items() if name not in EXCLUDE_ATTRS
}
content["id"] = str(record.relativeCreated)
content["msg"] = record.getMessage()
content["time"] = datetime.fromtimestamp(record.created)
if record.exc_info:
content["exc_info"] = self.formatter.formatException(record.exc_info)
for name, value in content.items():
if isinstance(value, datetime):
content[name] = value.astimezone().isoformat()
asyncio.run_coroutine_threadsafe(self.send(content), loop=self.loop)
self.lines.append(content)
async def send(self, record: dict) -> None:
for ws in self.listeners:
try:
await ws.send_json(record)
except Exception as e:
print("Log sending error:", e)
handler = LogCollector()
log = logging.getLogger("maubot.server.websocket")
sockets = []
def init(loop: asyncio.AbstractEventLoop) -> None:
logging.root.addHandler(handler)
handler.loop = loop
async def stop_all() -> None:
log.debug("Closing log listener websockets")
logging.root.removeHandler(handler)
for socket in sockets:
try:
await socket.close(code=1012)
except Exception:
pass
@routes.get("/logs")
async def log_websocket(request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse()
await ws.prepare(request)
sockets.append(ws)
log.debug(f"Connection from {request.remote} opened")
authenticated = False
async def close_if_not_authenticated():
await asyncio.sleep(5)
if not authenticated:
await ws.close(code=4000)
log.debug(f"Connection from {request.remote} terminated due to no authentication")
background_task.create(close_if_not_authenticated())
try:
msg: web_ws.WSMessage
async for msg in ws:
if msg.type != web.WSMsgType.TEXT:
continue
if is_valid_token(msg.data):
await ws.send_json({"auth_success": True})
await ws.send_json({"history": list(handler.lines)})
if not authenticated:
log.debug(f"Connection from {request.remote} authenticated")
handler.listeners.append(ws)
authenticated = True
elif not authenticated:
await ws.send_json({"auth_success": False})
except Exception:
try:
await ws.close()
except Exception:
pass
if authenticated:
handler.listeners.remove(ws)
log.debug(f"Connection from {request.remote} closed")
sockets.remove(ws)
return ws

View File

@@ -0,0 +1,41 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import json
from aiohttp import web
from .auth import create_token
from .base import get_config, routes
from .responses import resp
@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

View File

@@ -0,0 +1,78 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from typing import Awaitable, Callable
import base64
import logging
from aiohttp import web
from .auth import check_token
from .base import get_config
from .responses import resp
Handler = Callable[[web.Request], Awaitable[web.Response]]
log = logging.getLogger("maubot.server")
@web.middleware
async def auth(request: web.Request, handler: Handler) -> web.Response:
subpath = request.path[len("/_matrix/maubot/v1") :]
if (
subpath.startswith("/auth/")
or subpath.startswith("/client/auth_external_sso/complete/")
or subpath == "/features"
or subpath == "/logs"
):
return await handler(request)
err = check_token(request)
if err is not None:
return err
return await handler(request)
@web.middleware
async def error(request: web.Request, handler: Handler) -> web.Response:
try:
return await handler(request)
except web.HTTPException as ex:
if ex.status_code == 404:
return resp.path_not_found
elif ex.status_code == 405:
return resp.method_not_allowed
return web.json_response(
{
"httpexception": {
"headers": {key: value for key, value in ex.headers.items()},
"class": type(ex).__name__,
"body": ex.text or base64.b64encode(ex.body),
},
"error": f"Unhandled HTTP {ex.status}: {ex.text[:128] or 'non-text response'}",
"errcode": f"unhandled_http_{ex.status}",
},
status=ex.status,
)
except Exception:
log.exception("Error in handler")
return resp.internal_server_error
req_no = 0
def get_req_no():
global req_no
req_no += 1
return req_no

View File

@@ -0,0 +1,64 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import traceback
from aiohttp import web
from ...loader import MaubotZipImportError, PluginLoader
from .base import routes
from .responses import resp
@routes.get("/plugins")
async def get_plugins(_) -> web.Response:
return resp.found([plugin.to_dict() for plugin in PluginLoader.id_cache.values()])
@routes.get("/plugin/{id}")
async def get_plugin(request: web.Request) -> web.Response:
plugin_id = request.match_info["id"]
plugin = PluginLoader.id_cache.get(plugin_id)
if not plugin:
return resp.plugin_not_found
return resp.found(plugin.to_dict())
@routes.delete("/plugin/{id}")
async def delete_plugin(request: web.Request) -> web.Response:
plugin_id = request.match_info["id"]
plugin = PluginLoader.id_cache.get(plugin_id)
if not plugin:
return resp.plugin_not_found
elif len(plugin.references) > 0:
return resp.plugin_in_use
await plugin.delete()
return resp.deleted
@routes.post("/plugin/{id}/reload")
async def reload_plugin(request: web.Request) -> web.Response:
plugin_id = request.match_info["id"]
plugin = PluginLoader.id_cache.get(plugin_id)
if not plugin:
return resp.plugin_not_found
await plugin.stop_instances()
try:
await plugin.reload()
except MaubotZipImportError as e:
return resp.plugin_reload_error(str(e), traceback.format_exc())
await plugin.start_instances()
return resp.ok

View File

@@ -0,0 +1,145 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from io import BytesIO
from time import time
import logging
import os.path
import re
import traceback
from aiohttp import web
from packaging.version import Version
from ...loader import DatabaseType, MaubotZipImportError, PluginLoader, ZippedPluginLoader
from .base import get_config, routes
from .responses import resp
try:
import sqlalchemy
has_alchemy = True
except ImportError:
has_alchemy = False
log = logging.getLogger("maubot.server.upload")
@routes.put("/plugin/{id}")
async def put_plugin(request: web.Request) -> web.Response:
plugin_id = request.match_info["id"]
content = await request.read()
file = BytesIO(content)
try:
pid, version, db_type = ZippedPluginLoader.verify_meta(file)
except MaubotZipImportError as e:
return resp.plugin_import_error(str(e), traceback.format_exc())
if db_type == DatabaseType.SQLALCHEMY and not has_alchemy:
return resp.sqlalchemy_not_installed
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, db_type = ZippedPluginLoader.verify_meta(file)
except MaubotZipImportError as e:
return resp.plugin_import_error(str(e), traceback.format_exc())
if db_type == DatabaseType.SQLALCHEMY and not has_alchemy:
return resp.sqlalchemy_not_installed
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 = (
str(new_version)
if plugin.meta.version != new_version
else f"{new_version}-ts{int(time() * 1000)}"
)
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:
log.exception(f"Error loading updated version of {plugin.meta.id}, rolling back")
try:
await plugin.reload(new_path=old_path)
await plugin.start_instances()
except MaubotZipImportError:
log.warning(f"Failed to roll back update of {plugin.meta.id}", exc_info=True)
finally:
ZippedPluginLoader.trash(path, reason="failed_update")
return resp.plugin_import_error(str(e), traceback.format_exc())
try:
await plugin.start_instances()
except Exception as e:
log.exception(f"Error starting {plugin.meta.id} instances after update, rolling back")
try:
await plugin.stop_instances()
await plugin.reload(new_path=old_path)
await plugin.start_instances()
except Exception:
log.warning(f"Failed to roll back update of {plugin.meta.id}", exc_info=True)
finally:
ZippedPluginLoader.trash(path, reason="failed_update")
return resp.plugin_reload_error(str(e), traceback.format_exc())
log.debug(f"Successfully updated {plugin.meta.id}, moving old version to trash")
ZippedPluginLoader.trash(old_path, reason="update")
return resp.updated(plugin.to_dict())

View File

@@ -0,0 +1,510 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING
from http import HTTPStatus
from aiohttp import web
from asyncpg import PostgresError
import aiosqlite
if TYPE_CHECKING:
from sqlalchemy.exc import IntegrityError, OperationalError
class _Response:
@property
def body_not_json(self) -> web.Response:
return web.json_response(
{
"error": "Request body is not JSON",
"errcode": "body_not_json",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def plugin_type_required(self) -> web.Response:
return web.json_response(
{
"error": "Plugin type is required when creating plugin instances",
"errcode": "plugin_type_required",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def primary_user_required(self) -> web.Response:
return web.json_response(
{
"error": "Primary user is required when creating plugin instances",
"errcode": "primary_user_required",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def bad_client_access_token(self) -> web.Response:
return web.json_response(
{
"error": "Invalid access token",
"errcode": "bad_client_access_token",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def bad_client_access_details(self) -> web.Response:
return web.json_response(
{
"error": "Invalid homeserver or access token",
"errcode": "bad_client_access_details",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def bad_client_connection_details(self) -> web.Response:
return web.json_response(
{
"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,
)
def device_id_mismatch(self, found: str) -> web.Response:
return web.json_response(
{
"error": (
"The Matrix device ID of the client and the device ID of the access token "
f"don't match. Access token is for device {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 username_or_password_missing(self) -> web.Response:
return web.json_response(
{
"error": "Username or password missing",
"errcode": "username_or_password_missing",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def query_missing(self) -> web.Response:
return web.json_response(
{
"error": "Query missing",
"errcode": "query_missing",
},
status=HTTPStatus.BAD_REQUEST,
)
@staticmethod
def sql_error(error: PostgresError | aiosqlite.Error, query: str) -> web.Response:
return web.json_response(
{
"ok": False,
"query": query,
"error": str(error),
"errcode": "sql_error",
},
status=HTTPStatus.BAD_REQUEST,
)
@staticmethod
def sql_operational_error(error: OperationalError, query: str) -> web.Response:
return web.json_response(
{
"ok": False,
"query": query,
"error": str(error.orig),
"full_error": str(error),
"errcode": "sql_operational_error",
},
status=HTTPStatus.BAD_REQUEST,
)
@staticmethod
def sql_integrity_error(error: IntegrityError, query: str) -> web.Response:
return web.json_response(
{
"ok": False,
"query": query,
"error": str(error.orig),
"full_error": str(error),
"errcode": "sql_integrity_error",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def bad_auth(self) -> web.Response:
return web.json_response(
{
"error": "Invalid username or password",
"errcode": "invalid_auth",
},
status=HTTPStatus.UNAUTHORIZED,
)
@property
def no_token(self) -> web.Response:
return web.json_response(
{
"error": "Authorization token missing",
"errcode": "auth_token_missing",
},
status=HTTPStatus.UNAUTHORIZED,
)
@property
def invalid_token(self) -> web.Response:
return web.json_response(
{
"error": "Invalid authorization token",
"errcode": "auth_token_invalid",
},
status=HTTPStatus.UNAUTHORIZED,
)
@property
def plugin_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Plugin not found",
"errcode": "plugin_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def client_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Client not found",
"errcode": "client_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def primary_user_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Client for given primary user not found",
"errcode": "primary_user_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def instance_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Plugin instance not found",
"errcode": "instance_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def plugin_type_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Given plugin type not found",
"errcode": "plugin_type_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def path_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Resource not found",
"errcode": "resource_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def server_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Registration target server not found",
"errcode": "server_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def registration_secret_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Config does not have a registration secret for that server",
"errcode": "registration_secret_not_found",
},
status=HTTPStatus.NOT_FOUND,
)
@property
def registration_no_sso(self) -> web.Response:
return web.json_response(
{
"error": "The register operation is only for registering with a password",
"errcode": "registration_no_sso",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def sso_not_supported(self) -> web.Response:
return web.json_response(
{
"error": "That server does not seem to support single sign-on",
"errcode": "sso_not_supported",
},
status=HTTPStatus.FORBIDDEN,
)
@property
def plugin_has_no_database(self) -> web.Response:
return web.json_response(
{
"error": "Given plugin does not have a database",
"errcode": "plugin_has_no_database",
}
)
@property
def unsupported_plugin_database(self) -> web.Response:
return web.json_response(
{
"error": "The database type is not supported by this API",
"errcode": "unsupported_plugin_database",
}
)
@property
def sqlalchemy_not_installed(self) -> web.Response:
return web.json_response(
{
"error": "This plugin requires a legacy database, but SQLAlchemy is not installed",
"errcode": "unsupported_plugin_database",
},
status=HTTPStatus.NOT_IMPLEMENTED,
)
@property
def table_not_found(self) -> web.Response:
return web.json_response(
{
"error": "Given table not found in plugin database",
"errcode": "table_not_found",
}
)
@property
def method_not_allowed(self) -> web.Response:
return web.json_response(
{
"error": "Method not allowed",
"errcode": "method_not_allowed",
},
status=HTTPStatus.METHOD_NOT_ALLOWED,
)
@property
def user_exists(self) -> web.Response:
return web.json_response(
{
"error": "There is already a client with the user ID of that token",
"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(
{
"error": "Plugin instances of this type still exist",
"errcode": "plugin_in_use",
},
status=HTTPStatus.PRECONDITION_FAILED,
)
@property
def client_in_use(self) -> web.Response:
return web.json_response(
{
"error": "Plugin instances with this client as their primary user still exist",
"errcode": "client_in_use",
},
status=HTTPStatus.PRECONDITION_FAILED,
)
@staticmethod
def plugin_import_error(error: str, stacktrace: str) -> web.Response:
return web.json_response(
{
"error": error,
"stacktrace": stacktrace,
"errcode": "plugin_invalid",
},
status=HTTPStatus.BAD_REQUEST,
)
@staticmethod
def plugin_reload_error(error: str, stacktrace: str) -> web.Response:
return web.json_response(
{
"error": error,
"stacktrace": stacktrace,
"errcode": "plugin_reload_fail",
},
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
@property
def internal_server_error(self) -> web.Response:
return web.json_response(
{
"error": "Internal server error",
"errcode": "internal_server_error",
},
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
@property
def invalid_server(self) -> web.Response:
return web.json_response(
{
"error": "Invalid registration server object in maubot configuration",
"errcode": "invalid_server",
},
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
@property
def unsupported_plugin_loader(self) -> web.Response:
return web.json_response(
{
"error": "Existing plugin with same ID uses unsupported plugin loader",
"errcode": "unsupported_plugin_loader",
},
status=HTTPStatus.BAD_REQUEST,
)
@property
def client_has_keys(self) -> web.Response:
return web.json_response(
{
"error": "Client already has cross-signing keys",
"errcode": "client_has_keys",
},
status=HTTPStatus.CONFLICT,
)
def internal_crypto_error(self, message: str) -> web.Response:
return web.json_response(
{
"error": f"Internal crypto error: {message}",
"errcode": "internal_crypto_error",
},
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
@property
def not_implemented(self) -> web.Response:
return web.json_response(
{
"error": "Not implemented",
"errcode": "not_implemented",
},
status=HTTPStatus.NOT_IMPLEMENTED,
)
@property
def ok(self) -> web.Response:
return web.json_response(
{"success": True},
status=HTTPStatus.OK,
)
@property
def deleted(self) -> web.Response:
return web.Response(status=HTTPStatus.NO_CONTENT)
@staticmethod
def found(data: dict) -> web.Response:
return web.json_response(data, status=HTTPStatus.OK)
@staticmethod
def updated(data: dict, is_login: bool = False) -> web.Response:
return web.json_response(data, status=HTTPStatus.ACCEPTED if is_login else HTTPStatus.OK)
def logged_in(self, token: str) -> web.Response:
return self.found({"token": token})
def pong(self, user: str, features: dict) -> web.Response:
return self.found({"username": user, "features": features})
@staticmethod
def created(data: dict) -> web.Response:
return web.json_response(data, status=HTTPStatus.CREATED)
resp = _Response()

View File

@@ -0,0 +1,2 @@
# Maubot Management API
This document has been moved to docs.mau.fi: <https://docs.mau.fi/maubot/management-api.html>

View File

@@ -0,0 +1,728 @@
openapi: 3.0.0
info:
title: Maubot Management
version: 0.1.0
description: The API to manage a [maubot](https://github.com/maubot/maubot) instance
license:
name: GNU Affero General Public License version 3
url: 'https://github.com/maubot/maubot/blob/master/LICENSE'
security:
- bearer: []
servers:
- url: /_matrix/maubot/v1
paths:
/auth/login:
post:
operationId: login
summary: Log in with the unshared secret or username+password
tags: [Authentication]
requestBody:
content:
application/json:
schema:
type: object
description: Set either username+password or secret.
properties:
secret:
type: string
description: The unshared server secret for root login
username:
type: string
description: The username for normal login
password:
type: string
description: The password for normal login
responses:
200:
description: Logged in successfully
content:
application/json:
schema:
type: object
properties:
token:
type: string
401:
description: Invalid credentials
/auth/ping:
post:
operationId: ping
summary: Check if the given token is valid
tags: [Authentication]
responses:
200:
description: Token is OK
content:
application/json:
schema:
type: object
properties:
username:
type: string
401:
description: Token is not OK
/plugins:
get:
operationId: get_plugins
summary: Get the list of installed plugins
tags: [Plugins]
responses:
200:
description: The list of plugins
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Plugin'
401:
$ref: '#/components/responses/Unauthorized'
/plugins/upload:
post:
operationId: upload_plugin
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
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'
409:
description: Plugin already exists and allow_override was not specified.
'/plugin/{id}':
parameters:
- name: id
in: path
description: The ID of the plugin to get
required: true
schema:
type: string
get:
operationId: get_plugin
summary: Get information about a specific plugin
tags: [Plugins]
responses:
200:
description: Plugin found
content:
application/json:
schema:
$ref: '#/components/schemas/Plugin'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/PluginNotFound'
delete:
operationId: delete_plugin
summary: Delete a plugin
description: Delete a plugin. All instances of the plugin must be deleted before deleting the plugin.
tags: [Plugins]
responses:
204:
description: Plugin deleted
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/PluginNotFound'
412:
description: One or more plugin instances of this type exist
content:
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
in: path
description: The ID of the plugin to get
required: true
schema:
type: string
post:
operationId: reload_plugin
summary: Reload a plugin from disk
tags: [Plugins]
responses:
200:
description: Plugin reloaded
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/PluginNotFound'
/instances:
get:
operationId: get_instances
summary: Get all plugin instances
tags: [Plugin instances]
responses:
200:
description: The list of instances
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/PluginInstance'
401:
$ref: '#/components/responses/Unauthorized'
'/instance/{id}':
parameters:
- name: id
in: path
description: The ID of the instance to get
required: true
schema:
type: string
get:
operationId: get_instance
summary: Get information about a specific plugin instance
tags: [Plugin instances]
responses:
200:
description: Plugin instance found
content:
application/json:
schema:
$ref: '#/components/schemas/PluginInstance'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/InstanceNotFound'
delete:
operationId: delete_instance
summary: Delete a specific plugin instance
tags: [Plugin instances]
responses:
204:
description: Plugin instance deleted
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/InstanceNotFound'
put:
operationId: update_instance
summary: Create a plugin instance or edit the details of an existing plugin instance
tags: [Plugin instances]
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PluginInstance'
responses:
200:
description: Plugin instance edited
201:
description: Plugin instance created
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
404:
description: The referenced client or plugin type could not be found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'/clients':
get:
operationId: get_clients
summary: Get the list of Matrix clients
tags: [Clients]
responses:
200:
description: The list of plugins
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/MatrixClient'
401:
$ref: '#/components/responses/Unauthorized'
/client/new:
post:
operationId: create_client
summary: Create a Matrix client
tags: [Clients]
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
responses:
201:
description: Client created
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
409:
description: There is already a client with the user ID of that token.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'/client/{id}':
parameters:
- name: id
in: path
description: The Matrix user ID of the client to get
required: true
schema:
type: string
get:
operationId: get_client
summary: Get information about a specific Matrix client
tags: [Clients]
responses:
200:
description: Client found
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
put:
operationId: update_client
summary: Create or update a Matrix client
tags: [Clients]
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
responses:
202:
description: Client updated
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
201:
description: Client created
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
delete:
operationId: delete_client
summary: Delete a Matrix client
tags: [Clients]
responses:
204:
description: Client deleted
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
412:
description: One or more plugin instances with this as their primary client exist
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'/client/{id}/clearcache':
parameters:
- name: id
in: path
description: The Matrix user ID of the client to change
required: true
schema:
type: string
put:
operationId: clear_client_cache
summary: Clear the sync/state cache of a Matrix client
tags: [Clients]
responses:
200:
description: Cache cleared
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
/client/auth/servers:
get:
operationId: get_client_auth_servers
summary: Get the list of servers you can register or log in on via the maubot server
tags: [Clients]
responses:
200:
description: OK
content:
application/json:
schema:
type: object
description: Key-value map from server name to homeserver URL
additionalProperties:
type: string
description: The homeserver URL
example:
maunium.net: https://maunium.net
example.com: https://matrix.example.org
401:
$ref: '#/components/responses/Unauthorized'
'/client/auth/{server}/register':
parameters:
- name: server
in: path
description: The server name to register the account on.
required: true
schema:
type: string
- name: update_client
in: query
description: Should maubot store the access details in a Client instead of returning them?
required: false
schema:
type: boolean
post:
operationId: client_auth_register
summary: |
Register a new account on the given Matrix server using the shared registration
secret configured into the maubot server.
tags: [Clients]
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixAuthentication'
responses:
200:
description: Registration successful
content:
application/json:
schema:
type: object
properties:
access_token:
type: string
example: syt_123_456_789
user_id:
type: string
example: '@putkiteippi:maunium.net'
device_id:
type: string
example: maubot_F00BAR12
201:
description: Client created (when update_client is true)
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
401:
$ref: '#/components/responses/Unauthorized'
409:
description: |
There is already a client with the user ID of that token.
This should usually not happen, because the user ID was just created.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
500:
$ref: '#/components/responses/MatrixServerError'
'/client/auth/{server}/login':
parameters:
- name: server
in: path
description: The server name to log in to.
required: true
schema:
type: string
- name: update_client
in: query
description: Should maubot store the access details in a Client instead of returning them?
required: false
schema:
type: boolean
post:
operationId: client_auth_login
summary: Log in to the given Matrix server via the maubot server
tags: [Clients]
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixAuthentication'
responses:
200:
description: Login successful
content:
application/json:
schema:
type: object
properties:
user_id:
type: string
example: '@putkiteippi:maunium.net'
access_token:
type: string
example: syt_123_456_789
device_id:
type: string
example: maubot_F00BAR12
201:
description: Client created (when update_client is true)
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
202:
description: Client updated (when update_client is true)
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
401:
$ref: '#/components/responses/Unauthorized'
500:
$ref: '#/components/responses/MatrixServerError'
components:
responses:
Unauthorized:
description: Invalid or missing access token
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
PluginNotFound:
description: Plugin not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
ClientNotFound:
description: Client not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
InstanceNotFound:
description: Plugin instance not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
BadRequest:
description: Bad request (e.g. bad request body)
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
MatrixServerError:
description: The Matrix server returned an error
content:
application/json:
schema:
type: object
properties:
errcode:
type: string
description: The `errcode` returned by the server.
error:
type: string
description: The human-readable error returned by the server.
http_status:
type: integer
description: The HTTP status returned by the server.
securitySchemes:
bearer:
type: http
scheme: bearer
description: Required authentication for all endpoints
schemas:
Error:
type: object
properties:
error:
type: string
description: A human-readable error message
errcode:
type: string
description: A simple error code
Plugin:
type: object
properties:
id:
type: string
example: xyz.maubot.jesaribot
version:
type: string
example: 2.0.0
instances:
type: array
items:
$ref: '#/components/schemas/PluginInstance'
PluginInstance:
type: object
properties:
id:
type: string
example: jesaribot
type:
type: string
example: xyz.maubot.jesaribot
enabled:
type: boolean
example: true
started:
type: boolean
example: true
primary_user:
type: string
example: '@putkiteippi:maunium.net'
config:
type: string
example: "YAML"
MatrixClient:
type: object
properties:
id:
type: string
example: '@putkiteippi:maunium.net'
readOnly: true
description: The Matrix user ID of this client.
homeserver:
type: string
example: 'https://maunium.net'
description: The homeserver URL for this client.
access_token:
type: string
description: The Matrix access token for this client.
device_id:
type: string
description: The Matrix device ID corresponding to the access token.
fingerprint:
type: string
description: The encryption device fingerprint for verification.
enabled:
type: boolean
example: true
description: Whether or not this client is enabled.
started:
type: boolean
example: true
description: Whether or not this client and its instances have been started.
sync:
type: boolean
example: true
description: Whether or not syncing is enabled on this client.
sync_ok:
type: boolean
example: true
description: Whether or not the previous sync was successful on this client.
autojoin:
type: boolean
example: true
description: Whether or not this client should automatically join rooms when invited.
displayname:
type: string
example: J. E. Saarinen
description: The display name for this client.
avatar_url:
type: string
example: 'mxc://maunium.net/FsPQQTntCCqhJMFtwArmJdaU'
description: The content URI of the avatar for this client.
instances:
type: array
readOnly: true
items:
$ref: '#/components/schemas/PluginInstance'
MatrixAuthentication:
type: object
properties:
username:
type: string
example: putkiteippi
description: The user ID localpart to register/log in as.
password:
type: string
example: p455w0rd
description: The password for/of the user.

View File

@@ -0,0 +1,67 @@
{
"extends": "react-app",
"plugins": [
"import"
],
"rules": {
"indent": ["error", 4, {
"ignoredNodes": [
"JSXAttribute",
"JSXSpreadAttribute"
],
"FunctionDeclaration": {"parameters": "first"},
"FunctionExpression": {"parameters": "first"},
"CallExpression": {"arguments": "first"},
"ArrayExpression": "first",
"ObjectExpression": "first",
"ImportDeclaration": "first"
}],
"react/jsx-indent-props": ["error", "first"],
"object-curly-newline": ["error", {
"consistent": true
}],
"object-curly-spacing": ["error", "always", {
"arraysInObjects": false,
"objectsInObjects": false
}],
"array-bracket-spacing": ["error", "never"],
"one-var": ["error", {
"initialized": "never",
"uninitialized": "always"
}],
"one-var-declaration-per-line": ["error", "initializations"],
"quotes": ["error", "double"],
"semi": ["error", "never"],
"comma-dangle": ["error", "always-multiline"],
"max-len": ["warn", 100],
"space-before-function-paren": ["error", {
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}],
"func-style": ["warn", "declaration", {"allowArrowFunctions": true}],
"id-length": ["warn", {"max": 40, "exceptions": ["i", "j", "x", "y", "_"]}],
"arrow-body-style": ["error", "as-needed"],
"new-cap": ["warn", {
"newIsCap": true,
"capIsNew": true
}],
"no-empty": ["error", {
"allowEmptyCatch": true
}],
"eol-last": ["error", "always"],
"no-console": "off",
"import/no-nodejs-modules": "error",
"import/order": ["warn", {
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index"
],
"newlines-between": "never"
}]
}
}

View File

@@ -0,0 +1,5 @@
/node_modules
/build
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -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

View File

@@ -0,0 +1,40 @@
{
"name": "maubot-manager",
"version": "0.1.1",
"private": true,
"author": "Tulir Asokan",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
"url": "git+https://github.com/maubot/maubot.git"
},
"bugs": {
"url": "https://github.com/maubot/maubot/issues"
},
"homepage": ".",
"dependencies": {
"react": "^17.0.2",
"react-ace": "^9.4.1",
"react-contextmenu": "^2.14.0",
"react-dom": "^17.0.2",
"react-json-tree": "^0.16.1",
"react-router-dom": "^5.3.0",
"react-scripts": "5.0.0",
"react-select": "^5.2.1",
"sass": "^1.34.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"browserslist": [
"last 2 firefox versions",
"last 2 and_ff versions",
"last 2 chrome versions",
"last 2 and_chr versions",
"last 1 safari versions",
"last 1 ios_saf versions"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,37 @@
<!--
maubot - A plugin-based Matrix bot system.
Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#50D367">
<meta property="og:title" content="Maubot Manager"/>
<meta property="og:description" content="Maubot management interface"/>
<meta property="og:image" content="%PUBLIC_URL%/favicon.png"/>
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<title>Maubot Manager</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,15 @@
{
"short_name": "Maubot",
"name": "Maubot Manager",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 48x48 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#50D367",
"background_color": "#FAFAFA"
}

View File

@@ -0,0 +1,276 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
let BASE_PATH = "/_matrix/maubot/v1"
export function setBasePath(basePath) {
BASE_PATH = basePath
}
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, suffix = undefined) {
const resp = await fetch(`${BASE_PATH}/${type}/${id || entry.id}${suffix || ""}`, {
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()
}
let features = null
export async function ping() {
const response = await fetch(`${BASE_PATH}/auth/ping`, {
method: "POST",
headers: getHeaders(),
})
const json = await response.json()
if (json.username) {
features = json.features
return json.username
} else if (json.errcode === "auth_token_missing" || json.errcode === "auth_token_invalid") {
if (!features) {
await remoteGetFeatures()
}
return null
}
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`
const wrapper = {
socket: null,
connected: false,
authenticated: false,
onLog: data => undefined,
onHistory: history => undefined,
fails: -1,
}
const openHandler = () => {
wrapper.socket.send(localStorage.accessToken)
wrapper.connected = true
}
const messageHandler = evt => {
// TODO use logs
const data = JSON.parse(evt.data)
if (data.auth_success !== undefined) {
if (data.auth_success) {
console.info("Websocket connection authentication successful")
wrapper.authenticated = true
wrapper.fails = -1
} else {
console.info("Websocket connection authentication failed")
}
} else if (data.history) {
wrapper.onHistory(data.history)
} else {
wrapper.onLog(data)
}
}
const closeHandler = evt => {
if (evt) {
if (evt.code === 4000) {
console.error("Websocket connection failed: access token invalid or not provided")
} else if (evt.code === 1012) {
console.info("Websocket connection closed: server is restarting")
}
}
wrapper.connected = false
wrapper.socket = null
wrapper.fails++
const SECOND = 1000
setTimeout(() => {
wrapper.socket = new WebSocket(url)
wrapper.socket.onopen = openHandler
wrapper.socket.onmessage = messageHandler
wrapper.socket.onclose = closeHandler
}, Math.min(wrapper.fails * 5 * SECOND, 30 * SECOND))
}
closeHandler()
return wrapper
}
let _debugOpenFileEnabled = undefined
export const debugOpenFileEnabled = () => _debugOpenFileEnabled
export const updateDebugOpenFileEnabled = async () => {
const resp = await defaultGet("/debug/open")
_debugOpenFileEnabled = resp["enabled"] || false
}
export async function debugOpenFile(path, line) {
const resp = await fetch(`${BASE_PATH}/debug/open`, {
headers: getHeaders(),
body: JSON.stringify({ path, line }),
method: "POST",
})
return await resp.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 getInstanceDatabase = id => defaultGet(`/instance/${id}/database`)
export const queryInstanceDatabase = async (id, query) => {
const resp = await fetch(`${BASE_PATH}/instance/${id}/database/query`, {
headers: getHeaders(),
body: JSON.stringify({ query }),
method: "POST",
})
return await resp.json()
}
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}/proxy/${id}/_matrix/media/v3/upload`, {
headers: getHeaders(mime),
body: data,
method: "POST",
})
return await resp.json()
}
export function getAvatarURL({ id, avatar_url }) {
if (!avatar_url?.startsWith("mxc://")) {
return null
}
avatar_url = avatar_url.substring("mxc://".length)
// Note: the maubot backend will replace the query param with an authorization header
return `${BASE_PATH}/proxy/${id}/_matrix/client/v1/media/download/${avatar_url}?access_token=${
localStorage.accessToken}`
}
export const putClient = client => defaultPut("client", client)
export const deleteClient = id => defaultDelete("client", id)
export async function clearClientCache(id) {
const resp = await fetch(`${BASE_PATH}/client/${id}/clearcache`, {
headers: getHeaders(),
method: "POST",
})
return await resp.json()
}
export async function verifyClient(id, recovery_key) {
const resp = await fetch(`${BASE_PATH}/client/${id}/verify`, {
headers: getHeaders(),
method: "POST",
body: JSON.stringify({ recovery_key }),
})
return await resp.json()
}
export async function generateRecoveryKey(id) {
const resp = await fetch(`${BASE_PATH}/client/${id}/generate_recovery_key`, {
headers: getHeaders(),
method: "POST",
})
return await resp.json()
}
export const getClientAuthServers = () => defaultGet("/client/auth/servers")
export async function doClientAuth(server, type, username, password) {
const resp = await fetch(`${BASE_PATH}/client/auth/${server}/${type}`, {
headers: getHeaders(),
body: JSON.stringify({ username, password }),
method: "POST",
})
return await resp.json()
}
// eslint-disable-next-line import/no-anonymous-default-export
export default {
login, ping, setBasePath, getFeatures, remoteGetFeatures,
openLogSocket,
debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled,
getInstances, getInstance, putInstance, deleteInstance,
getInstanceDatabase, queryInstanceDatabase,
getPlugins, getPlugin, uploadPlugin, deletePlugin,
getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient, clearClientCache,
verifyClient, generateRecoveryKey,
getClientAuthServers, doClientAuth,
}

View File

@@ -0,0 +1,71 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import React from "react"
import Select from "react-select"
import CreatableSelect from "react-select/creatable"
import Switch from "./Switch"
export const PrefTable = ({ children, wrapperClass }) => {
if (wrapperClass) {
return (
<div className={wrapperClass}>
<div className="preference-table">
{children}
</div>
</div>
)
}
return (
<div className="preference-table">
{children}
</div>
)
}
export const PrefRow =
({ name, fullWidth = false, labelFor = undefined, changed = false, children }) => (
<div className={`entry ${fullWidth ? "full-width" : ""} ${changed ? "changed" : ""}`}>
<label htmlFor={labelFor}>{name}</label>
<div className="value">{children}</div>
</div>
)
export const PrefInput = ({ rowName, value, origValue, fullWidth = false, ...args }) => (
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}
changed={origValue !== undefined && value !== origValue}>
<input {...args} value={value} id={rowName}/>
</PrefRow>
)
export const PrefSwitch = ({ rowName, active, origActive, fullWidth = false, ...args }) => (
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}
changed={origActive !== undefined && active !== origActive}>
<Switch {...args} active={active} id={rowName}/>
</PrefRow>
)
export const PrefSelect = ({
rowName, value, origValue, fullWidth = false, creatable = false, ...args
}) => (
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}
changed={origValue !== undefined && value.id !== origValue}>
{creatable
? <CreatableSelect className="select" {...args} id={rowName} value={value}/>
: <Select className="select" {...args} id={rowName} value={value}/>}
</PrefRow>
)
export default PrefTable

View File

@@ -0,0 +1,28 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import React from "react"
import { Route, Redirect } from "react-router-dom"
const PrivateRoute = ({ component, render, authed, to = "/login", ...args }) => (
<Route
{...args}
render={(props) => authed === true
? (component ? React.createElement(component, props) : render())
: <Redirect to={{ pathname: to }}/>}
/>
)
export default PrivateRoute

View File

@@ -0,0 +1,11 @@
import React from "react"
const Spinner = (props) => (
<div {...props} className={`spinner ${props["className"] || ""}`}>
<svg viewBox="25 25 50 50">
<circle cx="50" cy="50" r="20" fill="none" strokeWidth="2" strokeMiterlimit="10"/>
</svg>
</div>
)
export default Spinner

View File

@@ -0,0 +1,59 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import React, { Component } from "react"
class Switch extends Component {
constructor(props) {
super(props)
this.state = {
active: props.active,
}
}
componentDidUpdate(prevProps) {
if (prevProps.active !== this.props.active) {
this.setState({
active: this.props.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 (
<div className="switch" data-active={this.state.active} onClick={this.toggle}
tabIndex="0" onKeyPress={this.toggleKeyboard} id={this.props.id}>
<div className="box">
<span className="text">
<span className="on">{this.props.onText || "On"}</span>
<span className="off">{this.props.offText || "Off"}</span>
</span>
</div>
</div>
)
}
}
export default Switch

View File

@@ -0,0 +1,21 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import React from "react"
import ReactDOM from "react-dom"
import "./style/index.sass"
import App from "./pages/Main"
ReactDOM.render(<App/>, document.getElementById("root"))

View File

@@ -0,0 +1,72 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
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 evt => {
evt.preventDefault()
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() {
if (!api.getFeatures().login) {
return <div className="login-wrapper">
<div className="login errored">
<h1>Maubot Manager</h1>
<div className="error">Login has been disabled in the maubot config.</div>
</div>
</div>
}
return <div className="login-wrapper">
<form className={`login ${this.state.error && "errored"}`} onSubmit={this.login}>
<h1>Maubot Manager</h1>
<input type="text" placeholder="Username" value={this.state.username}
name="username" onChange={this.inputChanged}/>
<input type="password" placeholder="Password" value={this.state.password}
name="password" onChange={this.inputChanged}/>
<button onClick={this.login} type="submit">
{this.state.loading ? <Spinner/> : "Log in"}
</button>
{this.state.error && <div className="error">{this.state.error}</div>}
</form>
</div>
}
}
export default Login

View File

@@ -0,0 +1,91 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
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 componentDidMount() {
await this.getBasePath()
if (localStorage.accessToken) {
await this.ping()
} else {
await api.remoteGetFeatures()
}
this.setState({ pinged: true })
}
async getBasePath() {
try {
const resp = await fetch(process.env.PUBLIC_URL + "/paths.json", {
headers: { "Content-Type": "application/json" },
})
const apiPaths = await resp.json()
api.setBasePath(apiPaths.api_path)
} catch (err) {
console.error("Failed to get API path:", err)
}
}
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 <Spinner className="maubot-loading"/>
}
return <Router>
<div className={`maubot-wrapper ${this.state.authed ? "authenticated" : ""}`}>
<Switch>
<PrivateRoute path="/login" render={() => <Login onLogin={this.login}/>}
authed={!this.state.authed} to="/"/>
<PrivateRoute path="/" component={Dashboard} authed={this.state.authed}/>
</Switch>
</div>
</Router>
}
}
export default Main

View File

@@ -0,0 +1,98 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import React, { Component } from "react"
import { Link } from "react-router-dom"
import api from "../../api"
class BaseMainView extends Component {
constructor(props) {
super(props)
this.state = Object.assign(this.initialState, props.entry)
}
UNSAFE_componentWillReceiveProps(nextProps) {
const newState = Object.assign(this.initialState, nextProps.entry)
for (const key of this.entryKeys) {
if (this.props.entry[key] === nextProps.entry[key]) {
newState[key] = this.state[key]
}
}
this.setState(newState)
}
delete = async () => {
if (!window.confirm(`Are you sure you want to delete ${this.state.id}?`)) {
return
}
this.setState({ deleting: true })
const resp = await this.deleteFunc(this.state.id)
if (resp.success) {
this.props.history.push("/")
this.props.onDelete()
} else {
this.setState({ deleting: false, error: resp.error })
}
}
get entryKeys() {
return []
}
get initialState() {
return {}
}
get hasInstances() {
return this.state.instances && this.state.instances.length > 0
}
get isNew() {
return !this.props.entry.id
}
inputChange = event => {
if (!event.target.name) {
return
}
this.setState({ [event.target.name]: event.target.value })
}
async readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.onload = evt => resolve(evt.target.result)
reader.onerror = err => reject(err)
})
}
renderInstances = () => !this.isNew && (
<div className="instances">
<h3>{this.hasInstances ? "Instances" : "No instances :("}</h3>
{this.state.instances.map(instance => (
<Link className="instance" key={instance.id} to={`/instance/${instance.id}`}>
{instance.id}
</Link>
))}
</div>
)
renderLogButton = (filter) => !this.isNew && api.getFeatures().log && <div className="buttons">
<button className="open-log" onClick={() => this.props.openLog(filter)}>View logs</button>
</div>
}
export default BaseMainView

View File

@@ -0,0 +1,392 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import React from "react"
import { NavLink, withRouter } from "react-router-dom"
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import { ReactComponent as UploadButton } from "../../res/upload.svg"
import { ReactComponent as NoAvatarIcon } from "../../res/bot.svg"
import { PrefTable, PrefSwitch, PrefInput, PrefSelect } from "../../components/PreferenceTable"
import Spinner from "../../components/Spinner"
import api from "../../api"
import BaseMainView from "./BaseMainView"
const ClientListEntry = ({ entry }) => {
const classes = ["client", "entry"]
if (!entry.enabled) {
classes.push("disabled")
} else if (!entry.started) {
classes.push("stopped")
}
const avatarMXC = entry.avatar_url === "disable"
? entry.remote_avatar_url
: entry.avatar_url
const avatarURL = avatarMXC && api.getAvatarURL({
id: entry.id,
avatar_url: avatarMXC,
})
const displayname = (
entry.displayname === "disable"
? entry.remote_displayname
: entry.displayname
) || entry.id
return (
<NavLink className={classes.join(" ")} to={`/client/${entry.id}`}>
{avatarURL
? <img className='avatar' src={avatarURL} alt=""/>
: <NoAvatarIcon className='avatar'/>}
<span className="displayname">{displayname}</span>
<ChevronRight className='chevron'/>
</NavLink>
)
}
class Client extends BaseMainView {
static ListEntry = ClientListEntry
constructor(props) {
super(props)
this.deleteFunc = api.deleteClient
this.recovery_key = null
this.homeserverOptions = []
}
get entryKeys() {
return ["id", "displayname", "homeserver", "avatar_url", "access_token", "device_id",
"sync", "autojoin", "online", "enabled", "started", "trust_state"]
}
get initialState() {
return {
id: "",
displayname: "disable",
homeserver: "",
avatar_url: "disable",
access_token: "",
device_id: "",
fingerprint: null,
sync: true,
autojoin: true,
enabled: true,
online: true,
started: false,
trust_state: null,
instances: [],
uploadingAvatar: false,
saving: false,
deleting: false,
verifying: false,
startingOrStopping: false,
clearingCache: false,
error: "",
}
}
get clientInState() {
const client = Object.assign({}, this.state)
delete client.uploadingAvatar
delete client.saving
delete client.deleting
delete client.startingOrStopping
delete client.clearingCache
delete client.error
delete client.instances
return client
}
get selectedHomeserver() {
return this.state.homeserver
? this.homeserverEntry([this.props.ctx.homeserversByURL[this.state.homeserver],
this.state.homeserver])
: {}
}
homeserverEntry = ([serverName, serverURL]) => serverURL && {
id: serverURL,
value: serverURL,
label: serverName || serverURL,
}
componentDidUpdate(prevProps) {
this.updateHomeserverOptions()
}
updateHomeserverOptions() {
this.homeserverOptions = Object
.entries(this.props.ctx.homeserversByName)
.map(this.homeserverEntry)
}
isValidHomeserver(value) {
try {
return Boolean(new URL(value))
} catch (err) {
return false
}
}
avatarUpload = async event => {
const file = event.target.files[0]
this.setState({
uploadingAvatar: true,
})
const data = await this.readFile(file)
const resp = await api.uploadAvatar(this.state.id, data, file.type)
this.setState({
uploadingAvatar: false,
avatar_url: resp.content_uri,
})
}
save = async () => {
this.setState({ saving: true })
const resp = await api.putClient(this.clientInState)
if (resp.id) {
if (this.isNew) {
this.props.history.push(`/client/${resp.id}`)
} else {
this.setState({ saving: false, error: "" })
}
this.props.onChange(resp)
} else {
this.setState({ saving: false, error: resp.error })
}
}
startOrStop = async () => {
this.setState({ startingOrStopping: true })
const resp = await api.putClient({
id: this.props.entry.id,
started: !this.props.entry.started,
})
if (resp.id) {
this.props.onChange(resp)
this.setState({ startingOrStopping: false, error: "" })
} else {
this.setState({ startingOrStopping: false, error: resp.error })
}
}
clearCache = async () => {
this.setState({ clearingCache: true })
const resp = await api.clearClientCache(this.props.entry.id)
if (resp.success) {
this.setState({ clearingCache: false, error: "" })
} else {
this.setState({ clearingCache: false, error: resp.error })
}
}
verifyRecoveryKey = async () => {
const recoveryKey = window.prompt("Enter recovery key")
if (!recoveryKey) {
return
}
this.setState({ verifying: true })
const resp = await api.verifyClient(this.props.entry.id, recoveryKey)
if (resp.success) {
this.setState({ verifying: false, error: "" })
} else {
this.setState({ verifying: false, error: resp.error })
}
}
generateRecoveryKey = async () => {
this.setState({ verifying: true })
const resp = await api.generateRecoveryKey(this.props.entry.id)
if (resp.success) {
this.recovery_key = resp.recovery_key
this.setState({ verifying: false, error: "" })
} else {
this.setState({ verifying: false, error: resp.error })
}
}
get loading() {
return this.state.saving || this.state.startingOrStopping
|| this.clearingCache || this.state.deleting || this.state.verifying
}
renderStartedContainer = () => {
let text
if (this.props.entry.started) {
if (this.props.entry.sync_ok) {
text = "Started"
} else {
text = "Erroring"
}
} else if (this.props.entry.enabled) {
text = "Stopped"
} else {
text = "Disabled"
}
return <div className="started-container">
<span className={`started ${this.props.entry.started}
${this.props.entry.sync_ok ? "sync_ok" : "sync_error"}
${this.props.entry.enabled ? "" : "disabled"}`}/>
<span className="text">{text}</span>
</div>
}
get avatarMXC() {
if (this.state.avatar_url === "disable") {
return this.props.entry.remote_avatar_url
} else if (!this.state.avatar_url?.startsWith("mxc://")) {
return null
}
return this.state.avatar_url
}
get avatarURL() {
return api.getAvatarURL({
id: this.state.id,
avatar_url: this.avatarMXC,
})
}
renderSidebar = () => !this.isNew && (
<div className="sidebar">
<div className={`avatar-container ${this.avatarMXC ? "" : "no-avatar"}
${this.state.uploadingAvatar ? "uploading" : ""}`}>
{this.avatarMXC && <img className="avatar" src={this.avatarURL} alt="Avatar"/>}
<UploadButton className="upload"/>
<input className="file-selector" type="file" accept="image/png, image/jpeg"
onChange={this.avatarUpload} disabled={this.state.uploadingAvatar}
onDragEnter={evt => evt.target.parentElement.classList.add("drag")}
onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/>
{this.state.uploadingAvatar && <Spinner/>}
</div>
{this.renderStartedContainer()}
{(this.props.entry.started || this.props.entry.enabled) && <>
<button className="save" onClick={this.startOrStop} disabled={this.loading}>
{this.state.startingOrStopping ? <Spinner/>
: (this.props.entry.started ? "Stop" : "Start")}
</button>
<button className="clearcache" onClick={this.clearCache} disabled={this.loading}>
{this.state.clearingCache ? <Spinner/> : "Clear cache"}
</button>
</>}
</div>
)
renderPreferences = () => (
<PrefTable>
<PrefInput rowName="User ID" type="text" disabled={!this.isNew} fullWidth={true}
name={this.isNew ? "id" : ""} className="id"
value={this.state.id} origValue={this.props.entry.id}
placeholder="@fancybot:example.com" onChange={this.inputChange}/>
{api.getFeatures().client_auth ? (
<PrefSelect rowName="Homeserver" options={this.homeserverOptions} fullWidth={true}
isSearchable={true} value={this.selectedHomeserver}
origValue={this.props.entry.homeserver}
onChange={({ value }) => this.setState({ homeserver: value })}
creatable={true} isValidNewOption={this.isValidHomeserver}/>
) : (
<PrefInput rowName="Homeserver" type="text" name="homeserver" fullWidth={true}
value={this.state.homeserver} origValue={this.props.entry.homeserver}
placeholder="https://example.com" onChange={this.inputChange}/>
)}
<PrefInput rowName="Access token" type="text" name="access_token"
value={this.state.access_token} origValue={this.props.entry.access_token}
placeholder="syt_bWF1Ym90_tUleVHiGyLKwXLaAMqlm_0afdcq"
onChange={this.inputChange}/>
<PrefInput rowName="Device ID" type="text" name="device_id"
value={this.state.device_id || ""}
origValue={this.props.entry.device_id || ""}
placeholder="maubot_F00BAR12" onChange={this.inputChange}/>
{this.props.entry.fingerprint && <>
<PrefInput
rowName="E2EE device fingerprint" type="text" disabled={true}
value={this.props.entry.fingerprint} className="fingerprint"
/>
<PrefInput
rowName="Trust state" type="text" disabled={true}
value={this.props.entry.trust_state || ""} className="trust-state"
/>
</>}
<PrefInput rowName="Display name" type="text" name="displayname"
value={this.state.displayname} origValue={this.props.entry.displayname}
placeholder="My fancy bot" onChange={this.inputChange}/>
<PrefInput rowName="Avatar URL" type="text" name="avatar_url"
value={this.state.avatar_url} origValue={this.props.entry.avatar_url}
placeholder="mxc://example.com/mbmwyoTvPhEQPiCskcUsppko"
onChange={this.inputChange}/>
<PrefSwitch rowName="Sync"
active={this.state.sync} origActive={this.props.entry.sync}
onToggle={sync => this.setState({ sync })}/>
<PrefSwitch rowName="Autojoin"
active={this.state.autojoin} origActive={this.props.entry.autojoin}
onToggle={autojoin => this.setState({ autojoin })}/>
<PrefSwitch rowName="Enabled"
active={this.state.enabled} origActive={this.props.entry.enabled}
onToggle={enabled => this.setState({
enabled,
started: enabled && this.state.started,
})}/>
<PrefSwitch rowName="Online"
active={this.state.online} origActive={this.props.entry.online}
onToggle={online => this.setState({ online })} />
</PrefTable>
)
renderPrefButtons = () => <>
<div className="buttons">
{!this.isNew && (
<button className={`delete ${this.hasInstances ? "disabled-bg" : ""}`}
onClick={this.delete} disabled={this.loading || this.hasInstances}
title={this.hasInstances ? "Can't delete client that is in use" : ""}>
{this.state.deleting ? <Spinner/> : "Delete"}
</button>
)}
<button className="save" onClick={this.save} disabled={this.loading}>
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
</button>
</div>
{this.renderLogButton(this.state.id)}
<div className="error">{this.state.error}</div>
</>
renderVerifyButtons = () => <>
<div className="buttons">
<button className="verify" onClick={this.verifyRecoveryKey} disabled={this.loading}>
{this.state.verifying ? <Spinner/> : "Verify"}
</button>
<button className="verify" onClick={this.generateRecoveryKey} disabled={this.loading}>
{this.state.verifying ? <Spinner/> : "Generate recovery key"}
</button>
</div>
{this.recovery_key && <div className="recovery-key">
<strong>Recovery key:</strong> <code>{this.recovery_key}</code>
</div>}
</>
render() {
return <>
<div className="client">
{this.renderSidebar()}
<div className="info">
{this.renderPreferences()}
{this.renderPrefButtons()}
{this.props.entry.fingerprint && this.renderVerifyButtons()}
{this.renderInstances()}
</div>
</div>
</>
}
}
export default withRouter(Client)

View File

@@ -0,0 +1,30 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import React, { Component } from "react"
class Home extends Component {
render() {
return <>
<div className="home">
See sidebar to get started
</div>
<div className="buttons">
</div>
</>
}
}
export default Home

View File

@@ -0,0 +1,233 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import React from "react"
import { Link, NavLink, Route, Switch, withRouter } from "react-router-dom"
import AceEditor from "react-ace"
import "ace-builds/src-noconflict/mode-yaml"
import "ace-builds/src-noconflict/theme-github"
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import { ReactComponent as NoAvatarIcon } from "../../res/bot.svg"
import PrefTable, { PrefInput, PrefSelect, PrefSwitch } from "../../components/PreferenceTable"
import api from "../../api"
import Spinner from "../../components/Spinner"
import BaseMainView from "./BaseMainView"
import InstanceDatabase from "./InstanceDatabase"
const InstanceListEntry = ({ entry }) => (
<NavLink className="instance entry" to={`/instance/${entry.id}`}>
<span className="id">{entry.id}</span>
<ChevronRight className='chevron'/>
</NavLink>
)
class Instance extends BaseMainView {
static ListEntry = InstanceListEntry
constructor(props) {
super(props)
this.deleteFunc = api.deleteInstance
this.updateClientOptions()
}
get entryKeys() {
return ["id", "primary_user", "enabled", "started", "type", "config", "database_engine"]
}
get initialState() {
return {
id: "",
primary_user: "",
enabled: true,
started: true,
type: "",
config: "",
database_engine: "",
saving: false,
deleting: false,
error: "",
}
}
get instanceInState() {
const instance = Object.assign({}, this.state)
delete instance.saving
delete instance.deleting
delete instance.error
return instance
}
componentDidUpdate(prevProps) {
this.updateClientOptions()
}
getAvatarMXC(client) {
return client.avatar_url === "disable" ? client.remote_avatar_url : client.avatar_url
}
getAvatarURL(client) {
return api.getAvatarURL({
id: client.id,
avatar_url: this.getAvatarMXC(client),
})
}
clientSelectEntry = client => client && {
id: client.id,
value: client.id,
label: (
<div className="select-client">
{this.getAvatarMXC(client)
? <img className="avatar" src={this.getAvatarURL(client)} alt=""/>
: <NoAvatarIcon className='avatar'/>}
<span className="displayname">{
(client.displayname === "disable"
? client.remote_displayname
: client.displayname
) || client.id
}</span>
</div>
),
}
updateClientOptions() {
this.clientOptions = Object.values(this.props.ctx.clients).map(this.clientSelectEntry)
}
save = async () => {
this.setState({ saving: true })
const resp = await api.putInstance(this.instanceInState, this.props.entry
? this.props.entry.id : undefined)
if (resp.id) {
if (this.isNew) {
this.props.history.push(`/instance/${resp.id}`)
} else {
if (resp.id !== this.props.entry.id) {
this.props.history.replace(`/instance/${resp.id}`)
}
this.setState({ saving: false, error: "" })
}
this.props.onChange(resp)
} else {
this.setState({ saving: false, error: resp.error })
}
}
get selectedClientEntry() {
return this.state.primary_user
? this.clientSelectEntry(this.props.ctx.clients[this.state.primary_user])
: {}
}
get selectedPluginEntry() {
return {
id: this.state.type,
value: this.state.type,
label: this.state.type,
}
}
get typeOptions() {
return Object.values(this.props.ctx.plugins).map(plugin => plugin && {
id: plugin.id,
value: plugin.id,
label: plugin.id,
})
}
get loading() {
return this.state.deleting || this.state.saving
}
get isValid() {
return this.state.id && this.state.primary_user && this.state.type
}
render() {
return <Switch>
<Route path="/instance/:id/database" render={this.renderDatabase}/>
<Route render={this.renderMain}/>
</Switch>
}
renderDatabase = () => <InstanceDatabase instanceID={this.props.entry.id}/>
renderMain = () => <div className="instance">
<PrefTable>
<PrefInput rowName="ID" type="text" name="id" value={this.state.id}
placeholder="fancybotinstance" onChange={this.inputChange}
disabled={!this.isNew} fullWidth={true} className="id"/>
<PrefSwitch rowName="Enabled"
active={this.state.enabled} origActive={this.props.entry.enabled}
onToggle={enabled => this.setState({ enabled })}/>
<PrefSwitch rowName="Running"
active={this.state.started} origActive={this.props.entry.started}
onToggle={started => this.setState({ started })}/>
{api.getFeatures().client ? (
<PrefSelect rowName="Primary user" options={this.clientOptions}
isSearchable={false} value={this.selectedClientEntry}
origValue={this.props.entry.primary_user}
onChange={({ id }) => this.setState({ primary_user: id })}/>
) : (
<PrefInput rowName="Primary user" type="text" name="primary_user"
value={this.state.primary_user} placeholder="@user:example.com"
onChange={this.inputChange}/>
)}
{api.getFeatures().plugin ? (
<PrefSelect rowName="Type" options={this.typeOptions} isSearchable={false}
value={this.selectedPluginEntry} origValue={this.props.entry.type}
onChange={({ id }) => this.setState({ type: id })}/>
) : (
<PrefInput rowName="Type" type="text" name="type" value={this.state.type}
placeholder="xyz.maubot.example" onChange={this.inputChange}/>
)}
</PrefTable>
{!this.isNew && Boolean(this.props.entry.base_config) &&
<AceEditor mode="yaml" theme="github" onChange={config => this.setState({ config })}
name="config" value={this.state.config}
editorProps={{
fontSize: "10pt",
$blockScrolling: true,
}}/>}
<div className="buttons">
{!this.isNew && (
<button className="delete" onClick={this.delete} disabled={this.loading}>
{this.state.deleting ? <Spinner/> : "Delete"}
</button>
)}
<button className={`save ${this.isValid ? "" : "disabled-bg"}`}
onClick={this.save} disabled={this.loading || !this.isValid}>
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
</button>
</div>
{!this.isNew && <div className="buttons">
{this.props.entry.database && (
<Link className="button open-database"
to={`/instance/${this.state.id}/database`}>
View database
</Link>
)}
{api.getFeatures().log &&
<button className="open-log"
onClick={() => this.props.openLog(`instance.${this.state.id}`)}>
View logs
</button>}
</div>}
<div className="error">{this.state.error}</div>
</div>
}
export default withRouter(Instance)

View File

@@ -0,0 +1,332 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import React, { Component } from "react"
import { NavLink, Link, withRouter } from "react-router-dom"
import { ContextMenu, ContextMenuTrigger, MenuItem } from "react-contextmenu"
import { ReactComponent as ChevronLeft } from "../../res/chevron-left.svg"
import { ReactComponent as OrderDesc } from "../../res/sort-down.svg"
import { ReactComponent as OrderAsc } from "../../res/sort-up.svg"
import api from "../../api"
import Spinner from "../../components/Spinner"
// eslint-disable-next-line no-extend-native
Map.prototype.map = function(func) {
const res = []
for (const [key, value] of this) {
res.push(func(value, key, this))
}
return res
}
class InstanceDatabase extends Component {
constructor(props) {
super(props)
this.state = {
tables: null,
header: null,
content: null,
query: "",
selectedTable: null,
error: null,
prevQuery: null,
statusMsg: null,
rowCount: null,
insertedPrimaryKey: null,
}
this.order = new Map()
}
async componentDidMount() {
const tables = new Map(Object.entries(await api.getInstanceDatabase(this.props.instanceID)))
for (const [name, table] of tables) {
table.name = name
table.columns = new Map(Object.entries(table.columns))
for (const [columnName, column] of table.columns) {
column.name = columnName
column.sort = null
}
}
this.setState({ tables })
this.checkLocationTable()
}
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
this.order = new Map()
this.setState({ header: null, content: null })
this.checkLocationTable()
}
}
checkLocationTable() {
const prefix = `/instance/${this.props.instanceID}/database/`
if (this.props.location.pathname.startsWith(prefix)) {
const table = this.props.location.pathname.substr(prefix.length)
this.setState({ selectedTable: table })
this.buildSQLQuery(table)
}
}
getSortQueryParams(table) {
const order = []
for (const [column, sort] of Array.from(this.order.entries()).reverse()) {
order.push(`order=${column}:${sort}`)
}
return order
}
buildSQLQuery(table = this.state.selectedTable, resetContent = true) {
let query = `SELECT * FROM "${table}"`
if (this.order.size > 0) {
const order = Array.from(this.order.entries()).reverse()
.map(([column, sort]) => `${column} ${sort}`)
query += ` ORDER BY ${order.join(", ")}`
}
query += " LIMIT 100"
this.setState({ query }, () => this.reloadContent(resetContent))
}
reloadContent = async (resetContent = true) => {
this.setState({ loading: true })
const res = await api.queryInstanceDatabase(this.props.instanceID, this.state.query)
this.setState({ loading: false })
if (resetContent) {
this.setState({
prevQuery: null,
rowCount: null,
insertedPrimaryKey: null,
statusMsg: null,
error: null,
})
}
if (!res.ok) {
this.setState({
error: res.error,
})
} else if (res.rows) {
this.setState({
header: res.columns,
content: res.rows,
})
} else {
this.setState({
prevQuery: res.query,
rowCount: res.rowcount,
insertedPrimaryKey: res.inserted_primary_key,
statusMsg: res.status_msg,
})
this.buildSQLQuery(this.state.selectedTable, false)
}
}
toggleSort(column) {
const oldSort = this.order.get(column) || "auto"
this.order.delete(column)
switch (oldSort) {
case "auto":
this.order.set(column, "DESC")
break
case "DESC":
this.order.set(column, "ASC")
break
case "ASC":
default:
break
}
this.buildSQLQuery()
}
getSortIcon(column) {
switch (this.order.get(column)) {
case "DESC":
return <OrderDesc/>
case "ASC":
return <OrderAsc/>
default:
return null
}
}
getColumnInfo(columnName) {
const table = this.state.tables.get(this.state.selectedTable)
if (!table) {
return null
}
const column = table.columns.get(columnName)
if (!column) {
return null
}
if (column.primary) {
return <span className="meta">&nbsp;(pk)</span>
} else if (column.unique) {
return <span className="meta">&nbsp;(u)</span>
}
return null
}
getColumnType(columnName) {
const table = this.state.tables.get(this.state.selectedTable)
if (!table) {
return null
}
const column = table.columns.get(columnName)
if (!column) {
return null
}
return column.type
}
deleteRow = async (_, data) => {
const values = this.state.content[data.row]
const keys = this.state.header
const condition = []
for (const [index, key] of Object.entries(keys)) {
const val = values[index]
condition.push(`${key}='${this.sqlEscape(val.toString())}'`)
}
const query = `DELETE FROM "${this.state.selectedTable}" WHERE ${condition.join(" AND ")}`
const res = await api.queryInstanceDatabase(this.props.instanceID, query)
this.setState({
prevQuery: `DELETE FROM "${this.state.selectedTable}" ...`,
rowCount: res.rowcount,
})
await this.reloadContent(false)
}
editCell = async (evt, data) => {
console.log("Edit", data)
}
collectContextMeta = props => ({
row: props.row,
col: props.col,
})
// eslint-disable-next-line no-control-regex
sqlEscape = str => str.replace(/[\0\x08\x09\x1a\n\r"'\\%]/g, char => {
switch (char) {
case "\0":
return "\\0"
case "\x08":
return "\\b"
case "\x09":
return "\\t"
case "\x1a":
return "\\z"
case "\n":
return "\\n"
case "\r":
return "\\r"
case "\"":
case "'":
case "\\":
case "%":
return "\\" + char
default:
return char
}
})
renderTable = () => <div className="table">
{this.state.header ? <>
<table>
<thead>
<tr>
{this.state.header.map(column => (
<td key={column}>
<span onClick={() => this.toggleSort(column)}
title={this.getColumnType(column)}>
<strong>{column}</strong>
{this.getColumnInfo(column)}
{this.getSortIcon(column)}
</span>
</td>
))}
</tr>
</thead>
<tbody>
{this.state.content.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, colIndex) => (
<ContextMenuTrigger key={colIndex} id="database_table_menu"
renderTag="td" row={rowIndex} col={colIndex}
collect={this.collectContextMeta}>
{cell}
</ContextMenuTrigger>
))}
</tr>
))}
</tbody>
</table>
<ContextMenu id="database_table_menu">
<MenuItem onClick={this.deleteRow}>Delete row</MenuItem>
<MenuItem disabled onClick={this.editCell}>Edit cell</MenuItem>
</ContextMenu>
</> : this.state.loading ? <Spinner/> : null}
</div>
renderContent() {
return <>
<div className="tables">
{this.state.tables.map((_, tbl) => (
<NavLink key={tbl} to={`/instance/${this.props.instanceID}/database/${tbl}`}>
{tbl}
</NavLink>
))}
</div>
<div className="query">
<input type="text" value={this.state.query} name="query"
onChange={evt => this.setState({ query: evt.target.value })}/>
<button type="submit" onClick={this.reloadContent}>Query</button>
</div>
{this.state.error && <div className="error">
{this.state.error}
</div>}
{this.state.prevQuery && <div className="prev-query">
<p>
Executed <span className="query">{this.state.prevQuery}</span> - {
this.state.statusMsg
|| <>affected <strong>{this.state.rowCount} rows</strong>.</>
}
</p>
{this.state.insertedPrimaryKey && <p className="inserted-primary-key">
Inserted primary key: {this.state.insertedPrimaryKey}
</p>}
</div>}
{this.renderTable()}
</>
}
render() {
return <div className="instance-database">
<div className="topbar">
<Link className="topbar" to={`/instance/${this.props.instanceID}`}>
<ChevronLeft/>
Back
</Link>
</div>
{this.state.tables
? this.renderContent()
: <Spinner className="maubot-loading"/>}
</div>
}
}
export default withRouter(InstanceDatabase)

View File

@@ -0,0 +1,191 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import React, { PureComponent } from "react"
import { Link } from "react-router-dom"
import { JSONTree } from "react-json-tree"
import api from "../../api"
import Modal from "./Modal"
class LogEntry extends PureComponent {
static contextType = Modal.Context
renderName() {
const line = this.props.line
if (line.nameLink) {
const modal = this.context
return <>
<Link to={line.nameLink} onClick={modal.close}>
{line.name}
</Link>
{line.nameSuffix}
</>
}
return line.name
}
renderContent() {
if (this.props.line.matrix_http_request) {
const req = this.props.line.matrix_http_request
return <>
{req.method} {req.url || req.path}
<div className="content">
{Object.entries(req.content || {}).length > 0
&& <JSONTree data={{ content: req.content }} hideRoot={true}/>}
</div>
</>
}
return this.props.line.msg
}
onClickOpen(path, line) {
return () => {
if (api.debugOpenFileEnabled()) {
api.debugOpenFile(path, line)
}
return false
}
}
renderTimeTitle() {
return this.props.line.time.toDateString()
}
renderTime() {
return <a className="time" title={this.renderTimeTitle()}
href={`file:///${this.props.line.pathname}:${this.props.line.lineno}`}
onClick={this.onClickOpen(this.props.line.pathname, this.props.line.lineno)}>
{this.props.line.time.toLocaleTimeString("en-GB")}
</a>
}
renderLevelName() {
return <span className="level">
{this.props.line.levelname}
</span>
}
get unfocused() {
return this.props.focus && this.props.line.name !== this.props.focus
? "unfocused"
: ""
}
renderRow(content) {
return (
<div className={`row ${this.props.line.levelname.toLowerCase()} ${this.unfocused}`}>
{this.renderTime()}
{this.renderLevelName()}
<span className="logger">{this.renderName()}</span>
<span className="text">{content}</span>
</div>
)
}
renderExceptionInfo() {
if (!api.debugOpenFileEnabled()) {
return this.props.line.exc_info
}
const fileLinks = []
let str = this.props.line.exc_info.replace(
/File "(.+)", line ([0-9]+), in (.+)/g,
(_, file, line, method) => {
fileLinks.push(
<a href={`file:///${file}:${line}`} onClick={this.onClickOpen(file, line)}>
File "{file}", line {line}, in {method}
</a>,
)
return "||EDGE||"
})
fileLinks.reverse()
const result = []
let key = 0
for (const part of str.split("||EDGE||")) {
result.push(<React.Fragment key={key++}>
{part}
{fileLinks.pop()}
</React.Fragment>)
}
return result
}
render() {
return <>
{this.renderRow(this.renderContent())}
{this.props.line.exc_info && this.renderRow(this.renderExceptionInfo())}
</>
}
}
class Log extends PureComponent {
constructor(props) {
super(props)
this.linesRef = React.createRef()
this.linesBottomRef = React.createRef()
}
getSnapshotBeforeUpdate() {
if (this.linesRef.current && this.linesBottomRef.current) {
return Log.isVisible(this.linesRef.current, this.linesBottomRef.current)
}
return false
}
componentDidUpdate(_1, _2, wasVisible) {
if (wasVisible) {
Log.scrollParentToChild(this.linesRef.current, this.linesBottomRef.current)
}
}
componentDidMount() {
if (this.linesRef.current && this.linesBottomRef.current) {
Log.scrollParentToChild(this.linesRef.current, this.linesBottomRef.current)
}
}
static scrollParentToChild(parent, child) {
const parentRect = parent.getBoundingClientRect()
const childRect = child.getBoundingClientRect()
if (!Log.isVisible(parent, child)) {
parent.scrollBy({ top: (childRect.top + parent.scrollTop) - parentRect.top })
}
}
static isVisible(parent, child) {
const parentRect = parent.getBoundingClientRect()
const childRect = child.getBoundingClientRect()
return (childRect.top >= parentRect.top)
&& (childRect.top <= parentRect.top + parent.clientHeight)
}
render() {
return (
<div className="log" ref={this.linesRef}>
<div className="lines">
{this.props.lines.map(data => <LogEntry key={data.id} line={data}
focus={this.props.focus}/>)}
</div>
<div ref={this.linesBottomRef}/>
</div>
)
}
}
export default Log

View File

@@ -0,0 +1,52 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import React, { Component, createContext } from "react"
const rem = 16
class Modal extends Component {
static Context = createContext(null)
constructor(props) {
super(props)
this.state = {
open: false,
}
this.wrapper = { clientWidth: 9001 }
}
open = () => this.setState({ open: true })
close = () => this.setState({ open: false })
isOpen = () => this.state.open
render() {
return this.state.open && (
<div className="modal-wrapper-wrapper" ref={ref => this.wrapper = ref}
onClick={() => this.wrapper.clientWidth > 45 * rem && this.close()}>
<div className="modal-wrapper" onClick={evt => evt.stopPropagation()}>
<button className="close" onClick={this.close}>Close</button>
<div className="modal">
<Modal.Context.Provider value={this}>
{this.props.children}
</Modal.Context.Provider>
</div>
</div>
</div>
)
}
}
export default Modal

View File

@@ -0,0 +1,104 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
import React from "react"
import { NavLink, withRouter } 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 }) => (
<NavLink className="plugin entry" to={`/plugin/${entry.id}`}>
<span className="id">{entry.id}</span>
<ChevronRight className='chevron'/>
</NavLink>
)
class Plugin extends BaseMainView {
static ListEntry = PluginListEntry
constructor(props) {
super(props)
this.deleteFunc = api.deletePlugin
}
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 <div className="plugin">
{!this.isNew && <PrefTable>
<PrefInput rowName="ID" type="text" value={this.state.id} disabled={true}
className="id"/>
<PrefInput rowName="Version" type="text" value={this.state.version}
disabled={true}/>
</PrefTable>}
{api.getFeatures().plugin_upload &&
<div className={`upload-box ${this.state.uploading ? "uploading" : ""}`}>
<UploadButton className="upload"/>
<input className="file-selector" type="file" accept="application/zip+mbp"
onChange={this.upload} disabled={this.state.uploading || this.state.deleting}
onDragEnter={evt => evt.target.parentElement.classList.add("drag")}
onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/>
{this.state.uploading && <Spinner/>}
</div>}
{!this.isNew && <div className="buttons">
<button className={`delete ${this.hasInstances ? "disabled-bg" : ""}`}
onClick={this.delete} disabled={this.loading || this.hasInstances}
title={this.hasInstances ? "Can't delete plugin that is in use" : ""}>
{this.state.deleting ? <Spinner/> : "Delete"}
</button>
</div>}
{this.renderLogButton("loader.zip")}
<div className="error">{this.state.error}</div>
{this.renderInstances()}
</div>
}
}
export default withRouter(Plugin)

View File

@@ -0,0 +1,272 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
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"
import Home from "./Home"
import Log from "./Log"
import Modal from "./Modal"
class Dashboard extends Component {
constructor(props) {
super(props)
this.state = {
instances: {},
clients: {},
plugins: {},
homeserversByName: {},
homeserversByURL: {},
sidebarOpen: false,
modalOpen: false,
logFocus: null,
logLines: [],
}
this.logModal = {
open: () => undefined,
isOpen: () => false,
}
window.maubot = this
}
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
this.setState({ sidebarOpen: false })
}
}
async componentDidMount() {
const [instanceList, clientList, pluginList, homeservers] = await Promise.all([
api.getInstances(), api.getClients(), api.getPlugins(), api.getClientAuthServers(),
api.updateDebugOpenFileEnabled()])
const instances = {}
if (api.getFeatures().instance) {
for (const instance of instanceList) {
instances[instance.id] = instance
}
}
const clients = {}
if (api.getFeatures().client) {
for (const client of clientList) {
clients[client.id] = client
}
}
const plugins = {}
if (api.getFeatures().plugin) {
for (const plugin of pluginList) {
plugins[plugin.id] = plugin
}
}
const homeserversByName = homeservers
const homeserversByURL = {}
if (api.getFeatures().client_auth) {
for (const [key, value] of Object.entries(homeservers)) {
homeserversByURL[value] = key
}
}
this.setState({ instances, clients, plugins, homeserversByName, homeserversByURL })
await this.enableLogs()
}
async enableLogs() {
if (!api.getFeatures().log) {
return
}
const logs = await api.openLogSocket()
const processEntry = (entry) => {
entry.time = new Date(entry.time)
entry.nameSuffix = ""
if (entry.name.startsWith("maubot.client.")) {
if (entry.name.endsWith(".crypto")) {
entry.name = entry.name.slice(0, -".crypto".length)
entry.nameSuffix = "/crypto"
}
entry.name = `client/${entry.name.slice("maubot.client.".length)}`
entry.nameLink = `/${entry.name}`
} else if (entry.name.startsWith("maubot.instance.")) {
entry.name = `instance/${entry.name.slice("maubot.instance.".length)}`
entry.nameLink = `/${entry.name}`
} else if (entry.name.startsWith("maubot.instance_db.")) {
entry.nameSuffix = "/db"
entry.name = `instance/${entry.name.slice("maubot.instance_db.".length)}`
entry.nameLink = `/${entry.name}`
}
}
logs.onHistory = history => {
for (const data of history) {
processEntry(data)
}
this.setState({
logLines: history,
})
}
logs.onLog = data => {
processEntry(data)
this.setState({
logLines: this.state.logLines.concat(data),
})
}
}
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 typeName = field.slice(0, -1)
if (!api.getFeatures()[typeName]) {
return this.renderDisabled(typeName)
}
const entry = this.state[field][id]
if (!entry) {
return this.renderNotFound(typeName)
}
return React.createElement(type, {
entry,
onDelete: () => this.delete(field, id),
onChange: newEntry => this.add(field, newEntry, id),
openLog: this.openLog,
ctx: this.state,
})
}
openLog = filter => {
this.setState({
logFocus: typeof filter === "string" ? filter : null,
})
this.logModal.open()
}
renderNotFound = (thing = "path") => (
<div className="not-found">
Oops! I'm afraid that {thing} couldn't be found.
</div>
)
renderDisabled = (thing = "path") => (
<div className="not-found">
The {thing} API has been disabled in the maubot config.
</div>
)
renderMain() {
return <div className={`dashboard ${this.state.sidebarOpen ? "sidebar-open" : ""}`}>
<Link to="/" className="title">
<img src="favicon.png" alt=""/>
Maubot Manager
</Link>
<div className="user">
<span>{localStorage.username}</span>
</div>
<nav className="sidebar">
<div className="buttons">
{api.getFeatures().log && <button className="open-log" onClick={this.openLog}>
<span>View logs</span>
</button>}
</div>
{api.getFeatures().instance && <div className="instances list">
<div className="title">
<h2>Instances</h2>
<Link to="/new/instance"><Plus/></Link>
</div>
{this.renderList("instances", Instance.ListEntry)}
</div>}
{api.getFeatures().client && <div className="clients list">
<div className="title">
<h2>Clients</h2>
<Link to="/new/client"><Plus/></Link>
</div>
{this.renderList("clients", Client.ListEntry)}
</div>}
{api.getFeatures().plugin && <div className="plugins list">
<div className="title">
<h2>Plugins</h2>
{api.getFeatures().plugin_upload && <Link to="/new/plugin"><Plus/></Link>}
</div>
{this.renderList("plugins", Plugin.ListEntry)}
</div>}
</nav>
<div className="topbar"
onClick={evt => this.setState({ sidebarOpen: !this.state.sidebarOpen })}>
<div className={`hamburger ${this.state.sidebarOpen ? "active" : ""}`}>
<span/><span/><span/>
</div>
</div>
<main className="view">
<Switch>
<Route path="/" exact render={() => <Home openLog={this.openLog}/>}/>
<Route path="/new/instance" render={() =>
<Instance onChange={newEntry => this.add("instances", newEntry)}
entry={{}} ctx={this.state}/>}/>
<Route path="/new/client" render={() =>
<Client onChange={newEntry => this.add("clients", newEntry)}
entry={{}} ctx={this.state}/>}/>
<Route path="/new/plugin" render={() =>
<Plugin onChange={newEntry => this.add("plugins", newEntry)}
entry={{}} ctx={this.state}/>}/>
<Route path="/instance/:id" render={({ match }) =>
this.renderView("instances", Instance, match.params.id)}/>
<Route path="/client/:id" render={({ match }) =>
this.renderView("clients", Client, match.params.id)}/>
<Route path="/plugin/:id" render={({ match }) =>
this.renderView("plugins", Plugin, match.params.id)}/>
<Route render={() => this.renderNotFound()}/>
</Switch>
</main>
</div>
}
renderModal() {
return <Modal ref={ref => this.logModal = ref}>
<Log lines={this.state.logLines} focus={this.state.logFocus}/>
</Modal>
}
render() {
return <>
{this.renderMain()}
{this.renderModal()}
</>
}
}
export default withRouter(Dashboard)

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24">
<path fill="#000000" d="M12,2A2,2 0 0,1 14,4C14,4.74 13.6,5.39 13,5.73V7H14A7,7 0 0,1 21,14H22A1,1 0 0,1 23,15V18A1,1 0 0,1 22,19H21V20A2,2 0 0,1 19,22H5A2,2 0 0,1 3,20V19H2A1,1 0 0,1 1,18V15A1,1 0 0,1 2,14H3A7,7 0 0,1 10,7H11V5.73C10.4,5.39 10,4.74 10,4A2,2 0 0,1 12,2M7.5,13A2.5,2.5 0 0,0 5,15.5A2.5,2.5 0 0,0 7.5,18A2.5,2.5 0 0,0 10,15.5A2.5,2.5 0 0,0 7.5,13M16.5,13A2.5,2.5 0 0,0 14,15.5A2.5,2.5 0 0,0 16.5,18A2.5,2.5 0 0,0 19,15.5A2.5,2.5 0 0,0 16.5,13Z" />
</svg>

After

Width:  |  Height:  |  Size: 753 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 184 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 185 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24">
<path fill="#000000" d="M17,13H13V17H11V13H7V11H11V7H13V11H17M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>

After

Width:  |  Height:  |  Size: 432 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7 10l5 5 5-5z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 153 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7 14l5-5 5 5z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 153 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24">
<path fill="#000000" d="M9,16V10H5L12,3L19,10H15V16H9M5,20V18H19V20H5Z" />
</svg>

After

Width:  |  Height:  |  Size: 365 B

View File

@@ -0,0 +1,6 @@
const proxy = require("http-proxy-middleware")
module.exports = function(app) {
app.use(proxy("/_matrix/maubot/v1", { target: process.env.PROXY || "http://localhost:29316" }))
app.use(proxy("/_matrix/maubot/v1/logs", { target: process.env.PROXY || "http://localhost:29316", ws: true }))
}

View File

@@ -0,0 +1,39 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
body
font-family: $font-stack
margin: 0
padding: 0
font-size: 16px
#root
position: fixed
top: 0
bottom: 0
right: 0
left: 0
.maubot-wrapper
position: absolute
top: 0
bottom: 0
left: 0
right: 0
background-color: $background-dark
.maubot-loading
margin-top: 10rem
width: 10rem

View File

@@ -0,0 +1,120 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
=button($width: null, $height: null, $padding: .375rem 1rem)
font-family: $font-stack
padding: $padding
width: $width
height: $height
background-color: $background
border: none
border-radius: .25rem
color: $text-color
box-sizing: border-box
font-size: 1rem
&.disabled-bg
background-color: $background-dark
&:not(:disabled)
cursor: pointer
&:hover
background-color: darken($background, 10%)
=link-button()
display: inline-block
text-align: center
text-decoration: none
=main-color-button()
background-color: $primary
color: $inverted-text-color
&:hover:not(:disabled)
background-color: $primary-dark
&:disabled.disabled-bg
background-color: $background-dark !important
color: $text-color
.button
+button
&.main-color
+main-color-button
=button-group()
width: 100%
display: flex
> button, > .button
flex: 1
&:first-child
margin-right: .5rem
&:last-child
margin-left: .5rem
&:first-child:last-child
margin: 0
=vertical-button-group()
display: flex
flex-direction: column
> button, > .button
flex: 1
border-radius: 0
&:first-of-type
border-radius: .25rem .25rem 0 0
&:last-of-type
border-radius: 0 0 .25rem .25rem
&:first-of-type:last-of-type
border-radius: .25rem
=input($width: null, $height: null, $vertical-padding: .375rem, $horizontal-padding: 1rem, $font-size: 1rem)
font-family: $font-stack
border: 1px solid $border-color
background-color: $background
color: $text-color
width: $width
height: $height
box-sizing: border-box
border-radius: .25rem
padding: $vertical-padding $horizontal-padding
font-size: $font-size
resize: vertical
&:hover, &:focus
border-color: $primary
&:focus
border-width: 2px
padding: calc(#{$vertical-padding} - 1px) calc(#{$horizontal-padding} - 1px)
.input, .textarea
+input
input
font-family: $font-stack
=notification($border: $error-dark, $background: transparentize($error-light, 0.5))
padding: 1rem
border-radius: .25rem
border: 2px solid $border
background-color: $background

View File

@@ -0,0 +1,30 @@
@font-face
font-family: 'Raleway'
font-style: normal
font-weight: 300
src: local('Raleway Light'), local('Raleway-Light'), url('../../fonts/raleway-light.woff2') format('woff2')
@font-face
font-family: 'Raleway'
font-style: normal
font-weight: 400
src: local('Raleway'), local('Raleway-Regular'), url('../../fonts/raleway-regular.woff2') format('woff2')
@font-face
font-family: 'Raleway'
font-style: normal
font-weight: 700
src: local('Raleway Bold'), local('Raleway-Bold'), url('../../fonts/raleway-bold.woff2') format('woff2')
@font-face
font-family: 'Fira Code'
src: url('../../fonts/firacode-regular.woff2') format('woff2')
font-weight: 400
font-style: normal
@font-face
font-family: 'Fira Code'
src: url('../../fonts/firacode-bold.woff2') format('woff2')
font-weight: 700
font-style: normal

View File

@@ -0,0 +1,33 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
$primary: #00C853
$primary-dark: #009624
$primary-light: #5EFC82
$secondary: #00B8D4
$secondary-dark: #0088A3
$secondary-light: #62EBFF
$error: #B71C1C
$error-dark: #7F0000
$error-light: #F05545
$warning: orange
$border-color: #DDD
$text-color: #212121
$background: #FAFAFA
$background-dark: #E7E7E7
$inverted-text-color: $background
$font-stack: Raleway, sans-serif

View File

@@ -0,0 +1,33 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
@import lib/spinner
@import lib/contextmenu
@import base/fonts
@import base/vars
@import base/body
@import base/elements
@import lib/preferencetable
@import lib/switch
@import pages/mixins/upload-container
@import pages/mixins/instancelist
@import pages/login
@import pages/dashboard
@import pages/modal
@import pages/log

View File

@@ -0,0 +1,80 @@
.react-contextmenu {
background-color: #fff;
background-clip: padding-box;
border: 1px solid rgba(0, 0, 0, .15);
border-radius: .25rem;
color: #373a3c;
font-size: 16px;
margin: 2px 0 0;
min-width: 160px;
outline: none;
opacity: 0;
padding: 5px 0;
pointer-events: none;
text-align: left;
transition: opacity 250ms ease !important;
}
.react-contextmenu.react-contextmenu--visible {
opacity: 1;
pointer-events: auto;
z-index: 9999;
}
.react-contextmenu-item {
background: 0 0;
border: 0;
color: #373a3c;
cursor: pointer;
font-weight: 400;
line-height: 1.5;
padding: 3px 20px;
text-align: inherit;
white-space: nowrap;
}
.react-contextmenu-item.react-contextmenu-item--active,
.react-contextmenu-item.react-contextmenu-item--selected {
color: #fff;
background-color: #20a0ff;
border-color: #20a0ff;
text-decoration: none;
}
.react-contextmenu-item.react-contextmenu-item--disabled,
.react-contextmenu-item.react-contextmenu-item--disabled:hover {
background-color: transparent;
border-color: rgba(0, 0, 0, .15);
color: #878a8c;
}
.react-contextmenu-item--divider {
border-bottom: 1px solid rgba(0, 0, 0, .15);
cursor: inherit;
margin-bottom: 3px;
padding: 2px 0;
}
.react-contextmenu-item--divider:hover {
background-color: transparent;
border-color: rgba(0, 0, 0, .15);
}
.react-contextmenu-item.react-contextmenu-submenu {
padding: 0;
}
.react-contextmenu-item.react-contextmenu-submenu > .react-contextmenu-item {
}
.react-contextmenu-item.react-contextmenu-submenu > .react-contextmenu-item:after {
content: "";
display: inline-block;
position: absolute;
right: 7px;
}
.example-multiple-targets::after {
content: attr(data-count);
display: block;
}

View File

@@ -0,0 +1,85 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
.preference-table
display: flex
width: 100%
flex-wrap: wrap
> .entry
display: block
@media screen and (max-width: 55rem)
width: calc(100% - 1rem)
width: calc(50% - 1rem)
margin: .5rem
&.full-width
width: 100%
&.changed > label
font-weight: bold
&:after
content: "*"
> label, > .value
display: block
width: 100%
> label
font-size: 0.875rem
padding-bottom: .25rem
font-weight: lighter
> .value
> .switch
width: auto
height: 2rem
> .select
height: 2.5rem
box-sizing: border-box
> input
border: none
height: 2rem
width: 100%
color: $text-color
box-sizing: border-box
padding: .375rem 0 calc(.375rem + 1px)
background-color: $background
font-size: 1rem
border-bottom: 1px solid $background
&.id:disabled
font-family: "Fira Code", monospace
font-weight: bold
&:not(:disabled)
border-bottom: 1px dotted $primary
&:hover
border-bottom: 1px solid $primary
&:focus
border-bottom: 2px solid $primary
padding-bottom: .375rem

View File

@@ -0,0 +1,65 @@
$green: #008744
$blue: #0057e7
$red: #d62d20
$yellow: #ffa700
.spinner
position: relative
margin: 0 auto
width: 5rem
&:before
content: ""
display: block
padding-top: 100%
svg
animation: rotate 2s linear infinite
height: 100%
transform-origin: center center
width: 100%
position: absolute
top: 0
bottom: 0
left: 0
right: 0
margin: auto
circle
stroke-dasharray: 1, 200
stroke-dashoffset: 0
animation: dash 1.5s ease-in-out infinite, color 6s ease-in-out infinite
stroke-linecap: round
=white-spinner()
circle
stroke: white !important
=thick-spinner($thickness: 5)
svg > circle
stroke-width: $thickness
@keyframes rotate
100%
transform: rotate(360deg)
@keyframes dash
0%
stroke-dasharray: 1, 200
stroke-dashoffset: 0
50%
stroke-dasharray: 89, 200
stroke-dashoffset: -35px
100%
stroke-dasharray: 89, 200
stroke-dashoffset: -124px
@keyframes color
100%, 0%
stroke: $red
40%
stroke: $blue
66%
stroke: $green
80%, 90%
stroke: $yellow

View File

@@ -0,0 +1,77 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
.switch
display: flex
width: 100%
height: 2rem
cursor: pointer
border: 1px solid $error-light
border-radius: .25rem
background-color: $background
box-sizing: border-box
> .box
display: flex
box-sizing: border-box
width: 50%
height: 100%
transition: .5s
text-align: center
border-radius: .15rem 0 0 .15rem
background-color: $error-light
color: $inverted-text-color
align-items: center
> .text
box-sizing: border-box
width: 100%
text-align: center
vertical-align: middle
color: $inverted-text-color
font-size: 1rem
user-select: none
.on
display: none
.off
display: inline
&[data-active=true]
border: 1px solid $primary
> .box
background-color: $primary
transform: translateX(100%)
border-radius: 0 .15rem .15rem 0
.on
display: inline
.off
display: none

View File

@@ -0,0 +1,62 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
> div.avatar-container
+upload-box
width: 8rem
height: 8rem
border-radius: 50%
@media screen and (max-width: 40rem)
margin: 0 auto 1rem
> img.avatar
position: absolute
display: block
max-width: 8rem
max-height: 8rem
user-select: none
> svg.upload
visibility: hidden
width: 6rem
height: 6rem
> input.file-selector
width: 8rem
height: 8rem
&:not(.uploading)
&:hover, &.drag
> img.avatar
opacity: .25
> svg.upload
visibility: visible
&.no-avatar
> img.avatar
visibility: hidden
> svg.upload
visibility: visible
opacity: .5
&.uploading
> img.avatar
opacity: .25

View File

@@ -0,0 +1,48 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
> div.client
display: flex
> div.sidebar
vertical-align: top
text-align: center
width: 8rem
margin-right: 1rem
> *
margin-bottom: 1rem
@import avatar
@import started
> div.info
vertical-align: top
flex: 1
> div.instances
+instancelist
input.fingerprint
font-family: "Fira Code", monospace
font-size: 0.8em
@media screen and (max-width: 40rem)
flex-wrap: wrap
> div.sidebar, > div.info
width: 100%
margin-right: 0

View File

@@ -0,0 +1,46 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
> div.started-container
display: inline-flex
> span.started
display: inline-block
height: 0
width: 0
border-radius: 50%
margin: .5rem
&.true
&.sync_ok
background-color: $primary
box-shadow: 0 0 .75rem .75rem $primary
&.sync_error
background-color: $warning
box-shadow: 0 0 .75rem .75rem $warning
&.false
background-color: $error-light
box-shadow: 0 0 .75rem .75rem $error-light
&.disabled
background-color: $border-color
box-shadow: 0 0 .75rem .75rem $border-color
> span.text
display: inline-block
margin-left: 1rem

View File

@@ -0,0 +1,19 @@
.dashboard {
grid-template:
[row1-start] "title main" 3.5rem [row1-end]
[row2-start] "user main" 2.5rem [row2-end]
[row3-start] "sidebar main" auto [row3-end]
/ 15rem auto;
}
@media screen and (max-width: 35rem) {
.dashboard {
grid-template:
[row1-start] "title topbar" 3.5rem [row1-end]
[row2-start] "user main" 2.5rem [row2-end]
[row3-start] "sidebar main" auto [row3-end]
/ 15rem 100%;
overflow-x: hidden;
}
}

View File

@@ -0,0 +1,129 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
@import "dashboard-grid"
.dashboard
display: grid
height: 100%
max-width: 60rem
margin: auto
box-shadow: 0 .5rem .5rem rgba(0, 0, 0, 0.5)
background-color: $background
> a.title
grid-area: title
background-color: white
display: flex
align-items: center
justify-content: center
font-size: 1.35rem
font-weight: bold
color: $text-color
text-decoration: none
> img
max-width: 2rem
margin-right: .5rem
> div.user
grid-area: user
background-color: white
border-bottom: 1px solid $border-color
display: flex
align-items: center
justify-content: center
span
display: flex
align-items: center
justify-content: center
background-color: $primary
color: $inverted-text-color
margin: .375rem .5rem
width: 100%
height: calc(100% - .375rem)
box-sizing: border-box
border-radius: .25rem
@import sidebar
@import topbar
@media screen and (max-width: 35rem)
&:not(.sidebar-open) > *
transform: translateX(-15rem)
> *
transition: transform 0.4s
> main.view
grid-area: main
border-left: 1px solid $border-color
overflow-y: auto
@import client/index
@import instance
@import instance-database
@import plugin
> div
margin: 2rem 4rem
@media screen and (max-width: 50rem)
margin: 1rem
> div.not-found, > div.home
text-align: center
margin-top: 5rem
font-size: 1.5rem
div.buttons
+button-group
display: flex
margin: 1rem .5rem
width: calc(100% - 1rem)
button.open-log, a.open-database
+button
+main-color-button
a.open-database
+link-button
div.error
+notification($error)
margin: 1rem .5rem
&:empty
display: none
button.delete
background-color: $error-light !important
&:hover
background-color: $error !important
button.save, button.clearcache, button.delete, button.verify
+button
+main-color-button
width: 100%
height: 2.5rem
padding: 0
> .spinner
+thick-spinner
+white-spinner
width: 2rem

View File

@@ -0,0 +1,101 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
> div.instance-database
margin: 0
> div.topbar
background-color: $primary-light
display: flex
justify-items: center
align-items: center
> a
display: flex
justify-items: center
align-items: center
text-decoration: none
user-select: none
height: 2.5rem
width: 100%
> *:not(.topbar)
margin: 2rem 4rem
@media screen and (max-width: 50rem)
margin: 1rem
> div.tables
display: flex
flex-wrap: wrap
> a
+link-button
color: black
flex: 1
border-bottom: 2px solid $primary
padding: .25rem
margin: .25rem
&:hover
background-color: $primary-light
border-bottom: 2px solid $primary-dark
&.active
background-color: $primary
> div.query
display: flex
> input
+input
font-family: "Fira Code", monospace
flex: 1
margin-right: .5rem
> button
+button
+main-color-button
> div.prev-query
+notification($primary, $primary-light)
span.query
font-family: "Fira Code", monospace
p
margin: 0
> div.table
overflow-x: auto
overflow-y: hidden
table
font-family: "Fira Code", monospace
width: 100%
box-sizing: border-box
> thead
> tr > td > span
align-items: center
justify-items: center
display: flex
cursor: pointer
user-select: none

View File

@@ -0,0 +1,35 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
> div.instance
> div.preference-table
.select-client
display: flex
align-items: center
img.avatar, svg.avatar
max-height: 1.375rem
border-radius: 50%
margin-right: .5rem
> div.ace_editor
z-index: 0
height: 15rem !important
width: calc(100% - 1rem) !important
font-size: 12px
font-family: "Fira Code", monospace
margin: .75rem .5rem 1.5rem

View File

@@ -0,0 +1,89 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
div.log
height: 100%
width: 100%
overflow: auto
div.log > div.lines
text-align: left
font-size: 12px
max-height: 100%
min-width: 100%
font-family: "Fira Code", monospace
display: table
div.log > div.lines > div.row
display: table-row
white-space: pre
&.trace
opacity: .75
&.debug, &.trace
background-color: $background
&:nth-child(odd)
background-color: $background-dark
&.info
background-color: #AAFAFA
&:nth-child(odd)
background-color: #66FAFA
&.warning, &.warn
background-color: #FABB77
&:nth-child(odd)
background-color: #FAAA55
&.error
background-color: #FAAAAA
&:nth-child(odd)
background-color: #FA9999
&.fatal
background-color: #CC44CC
&:nth-child(odd)
background-color: #AA44AA
&.unfocused
opacity: .25
> span
padding: .125rem .25rem
display: table-cell
&:first-child
padding-left: 0
&:last-child
padding-right: 0
a
color: inherit
text-decoration: none
&:hover
text-decoration: underline
> span.text
> div.content > *
background-color: inherit !important
margin: 0 !important

View File

@@ -0,0 +1,62 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
.maubot-wrapper:not(.authenticated)
background-color: $primary
text-align: center
.login
width: 25rem
height: 23rem
display: inline-block
box-sizing: border-box
background-color: white
border-radius: .25rem
margin-top: 3rem
@media screen and (max-width: 27rem)
margin: 3rem 1rem 0
width: calc(100% - 2rem)
h1
color: $primary
margin: 3rem 0
input, button
margin: .5rem 2.5rem
height: 3rem
width: calc(100% - 5rem)
box-sizing: border-box
input
+input
button
+button($width: calc(100% - 5rem), $height: 3rem, $padding: 0)
+main-color-button
.spinner
+white-spinner
+thick-spinner
width: 2rem
&.errored
height: 26.5rem
.error
+notification($error)
margin: .5rem 2.5rem

View File

@@ -0,0 +1,40 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
=instancelist()
margin: 1rem 0
display: flex
flex-wrap: wrap
> h3
margin: .5rem
width: 100%
> a.instance
display: block
width: calc(50% - 1rem)
padding: .375rem .5rem
margin: .5rem
background-color: white
border-radius: .25rem
color: $text-color
text-decoration: none
box-sizing: border-box
border: 1px solid $primary
&:hover
background-color: $primary

View File

@@ -0,0 +1,43 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
=upload-box()
position: relative
overflow: hidden
display: flex
align-items: center
justify-content: center
> svg.upload
position: absolute
display: block
padding: 1rem
user-select: none
> input.file-selector
position: absolute
user-select: none
opacity: 0
> div.spinner
+thick-spinner
&:not(.uploading)
> input.file-selector
cursor: pointer

View File

@@ -0,0 +1,71 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
div.modal-wrapper-wrapper
z-index: 9001
position: fixed
top: 0
bottom: 0
left: 0
right: 0
background-color: rgba(0, 0, 0, 0.5)
--modal-margin: 2.5rem
--button-height: 0rem
@media screen and (max-width: 45rem)
--modal-margin: 1rem
--button-height: 2.5rem
@media screen and (max-width: 35rem)
--modal-margin: 0rem
--button-height: 3rem
button.close
+button
display: none
width: 100%
height: var(--button-height)
border-radius: .25rem .25rem 0 0
@media screen and (max-width: 45rem)
display: block
@media screen and (max-width: 35rem)
border-radius: 0
div.modal-wrapper
width: calc(100% - 2 * var(--modal-margin))
height: calc(100% - 2 * var(--modal-margin) - var(--button-height))
margin: var(--modal-margin)
border-radius: .25rem
@media screen and (max-width: 35rem)
border-radius: 0
div.modal
padding: 1rem
height: 100%
width: 100%
background-color: $background
box-sizing: border-box
border-radius: .25rem
@media screen and (max-width: 45rem)
border-radius: 0 0 .25rem .25rem
@media screen and (max-width: 35rem)
border-radius: 0
padding: .5rem

View File

@@ -0,0 +1,53 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
> .plugin
> .upload-box
+upload-box
width: calc(100% - 1rem)
height: 10rem
margin: .5rem
border-radius: .5rem
box-sizing: border-box
border: .25rem dotted $primary
> svg.upload
width: 8rem
height: 8rem
opacity: .5
> input.file-selector
width: 100%
height: 100%
&:not(.uploading):hover, &:not(.uploading).drag
border: .25rem solid $primary
background-color: $primary-light
> svg.upload
opacity: 1
&.uploading
> svg.upload
visibility: hidden
> input.file-selector
cursor: default
> div.instances
+instancelist

View File

@@ -0,0 +1,79 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
> nav.sidebar
grid-area: sidebar
background-color: white
padding: .5rem
overflow-y: auto
div.buttons
margin-bottom: 1.5rem
button
+button
background-color: white
width: 100%
div.list
&:not(:last-of-type)
margin-bottom: 1.5rem
div.title
h2
display: inline-block
margin: 0 0 .25rem 0
font-size: 1.25rem
a
display: inline-block
float: right
a.entry
display: block
color: $text-color
text-decoration: none
padding: .25rem
border-radius: .25rem
height: 2rem
box-sizing: border-box
white-space: nowrap
overflow-x: hidden
&:not(:hover) > svg.chevron
display: none
> svg.chevron
float: right
&:hover
background-color: $primary-light
&.active
background-color: $primary
color: white
&.client
img.avatar, svg.avatar
max-height: 1.5rem
border-radius: 100%
vertical-align: middle
span.displayname, span.id
margin-left: .25rem
vertical-align: middle

View File

@@ -0,0 +1,79 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
> .topbar
background-color: $primary
display: flex
justify-items: center
align-items: center
padding: 0 .75rem
@media screen and (min-width: calc(35rem + 1px))
display: none
// Hamburger menu based on "Pure CSS Hamburger fold-out menu" codepen by Erik Terwan (MIT license)
// https://codepen.io/erikterwan/pen/EVzeRP
> .topbar
user-select: none
> .topbar > .hamburger
display: block
user-select: none
cursor: pointer
> span
display: block
width: 29px
height: 4px
margin-bottom: 5px
position: relative
background: white
border-radius: 3px
user-select: none
z-index: 1
transform-origin: 4px 0
transition: transform 0.4s cubic-bezier(0.77, 0.2, 0.05, 1.0), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1.0), opacity 0.55s ease
&:nth-of-type(1)
transform-origin: 0 0
&:nth-of-type(3)
transform-origin: 0 100%
transform: translateY(2px)
transition: transform 0.4s cubic-bezier(0.77, 0.2, 0.05, 1.0)
&.active
transform: translateX(1px) translateY(4px)
&.active > span
opacity: 1
&:nth-of-type(1)
transform: rotate(45deg) translate(-2px, -1px)
&:nth-of-type(2)
opacity: 0
transform: rotate(0deg) scale(0.2, 0.2)
&:nth-of-type(3)
transform: rotate(-45deg) translate(0, -1px)

File diff suppressed because it is too large Load Diff