Add Cloudron packaging for Maubot
This commit is contained in:
48
maubot-src/maubot/management/api/__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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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.
|
||||
Reference in New Issue
Block a user