Add Cloudron packaging for Maubot
0
maubot-src/maubot/management/__init__.py
Normal file
48
maubot-src/maubot/management/api/__init__.py
Normal 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
|
||||
76
maubot-src/maubot/management/api/auth.py
Normal 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"])
|
||||
40
maubot-src/maubot/management/api/base.py
Normal 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__})
|
||||
217
maubot-src/maubot/management/api/client.py
Normal 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})
|
||||
273
maubot-src/maubot/management/api/client_auth.py
Normal 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",
|
||||
)
|
||||
56
maubot-src/maubot/management/api/client_proxy.py
Normal 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
|
||||
57
maubot-src/maubot/management/api/dev_open.py
Normal 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
|
||||
97
maubot-src/maubot/management/api/instance.py
Normal 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
|
||||
161
maubot-src/maubot/management/api/instance_database.py
Normal 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)
|
||||
172
maubot-src/maubot/management/api/log.py
Normal 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
|
||||
41
maubot-src/maubot/management/api/login.py
Normal 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
|
||||
78
maubot-src/maubot/management/api/middleware.py
Normal 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
|
||||
64
maubot-src/maubot/management/api/plugin.py
Normal 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
|
||||
145
maubot-src/maubot/management/api/plugin_upload.py
Normal 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())
|
||||
510
maubot-src/maubot/management/api/responses.py
Normal 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()
|
||||
2
maubot-src/maubot/management/api/spec.md
Normal 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>
|
||||
728
maubot-src/maubot/management/api/spec.yaml
Normal 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.
|
||||
67
maubot-src/maubot/management/frontend/.eslintrc.json
Normal 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"
|
||||
}]
|
||||
}
|
||||
}
|
||||
5
maubot-src/maubot/management/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/node_modules
|
||||
/build
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
27
maubot-src/maubot/management/frontend/.sass-lint.yml
Normal 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
|
||||
40
maubot-src/maubot/management/frontend/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
BIN
maubot-src/maubot/management/frontend/public/favicon.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
37
maubot-src/maubot/management/frontend/public/index.html
Normal 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>
|
||||
15
maubot-src/maubot/management/frontend/public/manifest.json
Normal 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"
|
||||
}
|
||||
276
maubot-src/maubot/management/frontend/src/api.js
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
21
maubot-src/maubot/management/frontend/src/index.js
Normal 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"))
|
||||
72
maubot-src/maubot/management/frontend/src/pages/Login.js
Normal 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
|
||||
91
maubot-src/maubot/management/frontend/src/pages/Main.js
Normal 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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"> (pk)</span>
|
||||
} else if (column.unique) {
|
||||
return <span className="meta"> (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)
|
||||
191
maubot-src/maubot/management/frontend/src/pages/dashboard/Log.js
Normal 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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
5
maubot-src/maubot/management/frontend/src/res/bot.svg
Normal 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 |
@@ -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 |
@@ -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 |
5
maubot-src/maubot/management/frontend/src/res/plus.svg
Normal 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 |
@@ -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 |
@@ -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 |
5
maubot-src/maubot/management/frontend/src/res/upload.svg
Normal 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 |
6
maubot-src/maubot/management/frontend/src/setupProxy.js
Normal 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 }))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
33
maubot-src/maubot/management/frontend/src/style/index.sass
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||