Merge pull request #14 from maubot/management-frontend

Add management UI
This commit is contained in:
Tulir Asokan 2018-11-11 01:06:57 +02:00 committed by GitHub
commit fd5672b3dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 3380 additions and 141 deletions

View File

@ -1,9 +1,15 @@
FROM node:10 AS frontend-builder
COPY ./maubot/management/frontend /frontend
RUN cd /frontend && yarn --prod && yarn build
FROM alpine:3.8
ENV UID=1337 \
GID=1337
COPY . /opt/maubot
COPY --from=frontend-builder /frontend/build /opt/maubot/frontend
WORKDIR /opt/maubot
RUN apk add --no-cache \
py3-aiohttp \

View File

@ -24,6 +24,11 @@ server:
port: 29316
# The base management API path.
base_path: /_matrix/maubot/v1
# The base path for the UI.
ui_base_path: /_matrix/maubot
# Override path from where to load UI resources.
# Set to false to using pkg_resources to find the path.
override_resource_path: /opt/maubot/frontend
# The base appservice API path. Use / for legacy appservice API and /_matrix/app/v1 for v1.
appservice_base_path: /_matrix/app/v1
# The shared secret to sign API access tokens.

View File

@ -24,6 +24,11 @@ server:
port: 29316
# The base management API path.
base_path: /_matrix/maubot/v1
# The base path for the UI.
ui_base_path: /_matrix/maubot
# Override path from where to load UI resources.
# Set to false to using pkg_resources to find the path.
override_resource_path: false
# The base appservice API path. Use / for legacy appservice API and /_matrix/app/v1 for v1.
appservice_base_path: /_matrix/app/v1
# The shared secret to sign API access tokens.

View File

@ -26,7 +26,7 @@ from .server import MaubotServer
from .client import Client, init as init_client_class
from .loader.zip import init as init_zip_loader
from .instance import init as init_plugin_instance_class
from .management.api import init as init_management
from .management.api import init as init_management_api
from .__meta__ import __version__
parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.",
@ -52,8 +52,9 @@ init_zip_loader(config)
db_session = init_db(config)
clients = init_client_class(db_session, loop)
plugins = init_plugin_instance_class(db_session, config, loop)
management_api = init_management(config, loop)
server = MaubotServer(config, management_api, loop)
management_api = init_management_api(config, loop)
server = MaubotServer(config, loop)
server.app.add_subapp(config["server.base_path"], management_api)
for plugin in plugins:
plugin.load()

View File

@ -177,13 +177,13 @@ class Client:
await self.stop()
async def update_displayname(self, displayname: str) -> None:
if not displayname or displayname == self.displayname:
if displayname is None or displayname == self.displayname:
return
self.db_instance.displayname = displayname
await self.client.set_displayname(self.displayname)
async def update_avatar_url(self, avatar_url: ContentURI) -> None:
if not avatar_url or avatar_url == self.avatar_url:
if avatar_url is None or avatar_url == self.avatar_url:
return
self.db_instance.avatar_url = avatar_url
await self.client.set_avatar_url(self.avatar_url)
@ -198,7 +198,7 @@ class Client:
client_session=self.http_client, log=self.log)
mxid = await new_client.whoami()
if mxid != self.id:
raise ValueError("MXID mismatch")
raise ValueError(f"MXID mismatch: {mxid}")
new_client.store = self.db_instance
self.stop_sync()
self.client = new_client

View File

@ -38,6 +38,9 @@ class Config(BaseFileConfig):
copy("server.hostname")
copy("server.port")
copy("server.listen")
copy("server.base_path")
copy("server.ui_base_path")
copy("server.override_resource_path")
copy("server.appservice_base_path")
shared_secret = self["server.unshared_secret"]
if shared_secret is None or shared_secret == "generate":

View File

@ -186,10 +186,27 @@ class PluginInstance:
self.db_instance.primary_user = client.id
self.client.references.remove(self)
self.client = client
self.client.references.add(self)
await self.start()
self.log.debug(f"Primary user switched to {self.client.id}")
return True
async def update_type(self, type: str) -> bool:
if not type or type == self.type:
return True
try:
loader = PluginLoader.find(type)
except KeyError:
return False
await self.stop()
self.db_instance.type = loader.id
self.loader.references.remove(self)
self.loader = loader
self.loader.references.add(self)
await self.start()
self.log.debug(f"Type switched to {self.loader.id}")
return True
async def update_started(self, started: bool) -> None:
if started is not None and started != self.started:
await (self.start() if started else self.stop())

View File

@ -13,6 +13,7 @@
#
# 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 Optional
from time import time
import json
@ -39,13 +40,31 @@ def create_token(user: UserID) -> str:
})
@routes.post("/auth/ping")
async def ping(request: web.Request) -> web.Response:
def get_token(request: web.Request) -> str:
token = request.headers.get("Authorization", "")
if not token or not token.startswith("Bearer "):
token = request.query.get("access_token", None)
else:
token = token[len("Bearer "):]
return token
def check_token(request: web.Request) -> Optional[web.Response]:
token = get_token(request)
if not token:
return resp.no_token
elif not is_valid_token(token):
return resp.invalid_token
return None
@routes.post("/auth/ping")
async def ping(request: web.Request) -> web.Response:
token = get_token(request)
if not token:
return resp.no_token
data = verify_token(get_config()["server.unshared_secret"], token[len("Bearer "):])
data = verify_token(get_config()["server.unshared_secret"], token)
if not data:
return resp.invalid_token
user = data.get("user_id", None)

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from aiohttp import web
from ...__meta__ import __version__
from ...config import Config
routes: web.RouteTableDef = web.RouteTableDef()
@ -28,3 +29,10 @@ def set_config(config: Config) -> None:
def get_config() -> Config:
return _config
@routes.get("/version")
async def version(_: web.Request) -> web.Response:
return web.json_response({
"version": __version__
})

View File

@ -20,7 +20,7 @@ from http import HTTPStatus
from aiohttp import web
from mautrix.types import UserID, SyncToken, FilterID
from mautrix.errors import MatrixRequestError, MatrixInvalidToken
from mautrix.errors import MatrixRequestError, MatrixConnectionError, MatrixInvalidToken
from mautrix.client import Client as MatrixClient
from ...db import DBClient
@ -54,12 +54,14 @@ async def _create_client(user_id: Optional[UserID], data: dict) -> web.Response:
return resp.bad_client_access_token
except MatrixRequestError:
return resp.bad_client_access_details
except MatrixConnectionError:
return resp.bad_client_connection_details
if user_id is None:
existing_client = Client.get(mxid, None)
if existing_client is not None:
return resp.user_exists
elif mxid != user_id:
return resp.mxid_mismatch
return resp.mxid_mismatch(mxid)
db_instance = DBClient(id=mxid, homeserver=homeserver, access_token=access_token,
enabled=data.get("enabled", True), next_batch=SyncToken(""),
filter_id=FilterID(""), sync=data.get("sync", True),
@ -81,8 +83,10 @@ async def _update_client(client: Client, data: dict) -> web.Response:
return resp.bad_client_access_token
except MatrixRequestError:
return resp.bad_client_access_details
except ValueError:
return resp.mxid_mismatch
except MatrixConnectionError:
return resp.bad_client_connection_details
except ValueError as e:
return resp.mxid_mismatch(str(e)[len("MXID mismatch: "):])
await client.update_avatar_url(data.get("avatar_url", None))
await client.update_displayname(data.get("displayname", None))
await client.update_started(data.get("started", None))
@ -127,3 +131,27 @@ async def delete_client(request: web.Request) -> web.Response:
await client.stop()
client.delete()
return resp.deleted
@routes.post("/client/{id}/avatar")
async def upload_avatar(request: web.Request) -> web.Response:
user_id = request.match_info.get("id", None)
client = Client.get(user_id, None)
if not client:
return resp.client_not_found
content = await request.read()
return web.json_response({
"content_uri": await client.client.upload_media(
content, request.headers.get("Content-Type", None)),
})
@routes.get("/client/{id}/avatar")
async def download_avatar(request: web.Request) -> web.Response:
user_id = request.match_info.get("id", None)
client = Client.get(user_id, None)
if not client:
return resp.client_not_found
if not client.avatar_url or client.avatar_url == "disable":
return web.Response()
return web.Response(body=await client.client.download_media(client.avatar_url))

View File

@ -70,6 +70,7 @@ async def _update_instance(instance: PluginInstance, data: dict) -> web.Response
instance.update_enabled(data.get("enabled", None))
instance.update_config(data.get("config", None))
await instance.update_started(data.get("started", None))
await instance.update_type(data.get("type", None))
instance.db.commit()
return resp.updated(instance.to_dict())

View File

@ -19,7 +19,7 @@ import logging
from aiohttp import web
from .responses import resp
from .auth import is_valid_token
from .auth import check_token
Handler = Callable[[web.Request], Awaitable[web.Response]]
@ -28,12 +28,7 @@ Handler = Callable[[web.Request], Awaitable[web.Response]]
async def auth(request: web.Request, handler: Handler) -> web.Response:
if "/auth/" in request.path:
return await handler(request)
token = request.headers.get("Authorization", "")
if not token or not token.startswith("Bearer "):
return resp.no_token
if not is_valid_token(token[len("Bearer "):]):
return resp.invalid_token
return await handler(request)
return check_token(request) or await handler(request)
log = logging.getLogger("maubot.server")

View File

@ -69,6 +69,45 @@ async def reload_plugin(request: web.Request) -> web.Response:
return resp.ok
@routes.put("/plugin/{id}")
async def put_plugin(request: web.Request) -> web.Response:
plugin_id = request.match_info.get("id", None)
content = await request.read()
file = BytesIO(content)
try:
pid, version = ZippedPluginLoader.verify_meta(file)
except MaubotZipImportError as e:
return resp.plugin_import_error(str(e), traceback.format_exc())
if pid != plugin_id:
return resp.pid_mismatch
plugin = PluginLoader.id_cache.get(plugin_id, None)
if not plugin:
return await upload_new_plugin(content, pid, version)
elif isinstance(plugin, ZippedPluginLoader):
return await upload_replacement_plugin(plugin, content, version)
else:
return resp.unsupported_plugin_loader
@routes.post("/plugins/upload")
async def upload_plugin(request: web.Request) -> web.Response:
content = await request.read()
file = BytesIO(content)
try:
pid, version = ZippedPluginLoader.verify_meta(file)
except MaubotZipImportError as e:
return resp.plugin_import_error(str(e), traceback.format_exc())
plugin = PluginLoader.id_cache.get(pid, None)
if not plugin:
return await upload_new_plugin(content, pid, version)
elif not request.query.get("allow_override"):
return resp.plugin_exists
elif isinstance(plugin, ZippedPluginLoader):
return await upload_replacement_plugin(plugin, content, version)
else:
return resp.unsupported_plugin_loader
async def upload_new_plugin(content: bytes, pid: str, version: str) -> web.Response:
path = os.path.join(get_config()["plugin_directories.upload"], f"{pid}-v{version}.mbp")
with open(path, "wb") as p:
@ -86,10 +125,10 @@ async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes,
dirname = os.path.dirname(plugin.path)
old_filename = os.path.basename(plugin.path)
if plugin.version in old_filename:
filename = old_filename.replace(plugin.version, new_version)
if filename == old_filename:
filename = re.sub(f"{re.escape(plugin.version)}(-ts[0-9]+)?",
f"{new_version}-ts{int(time())}", old_filename)
replacement = (new_version if plugin.version != new_version
else f"{new_version}-ts{int(time())}")
filename = re.sub(f"{re.escape(plugin.version)}(-ts[0-9]+)?",
replacement, old_filename)
else:
filename = old_filename.rstrip(".mbp")
filename = f"{filename}-v{new_version}.mbp"
@ -110,20 +149,3 @@ async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes,
await plugin.start_instances()
ZippedPluginLoader.trash(old_path, reason="update")
return resp.updated(plugin.to_dict())
@routes.post("/plugins/upload")
async def upload_plugin(request: web.Request) -> web.Response:
content = await request.read()
file = BytesIO(content)
try:
pid, version = ZippedPluginLoader.verify_meta(file)
except MaubotZipImportError as e:
return resp.plugin_import_error(str(e), traceback.format_exc())
plugin = PluginLoader.id_cache.get(pid, None)
if not plugin:
return await upload_new_plugin(content, pid, version)
elif isinstance(plugin, ZippedPluginLoader):
return await upload_replacement_plugin(plugin, content, version)
else:
return resp.unsupported_plugin_loader

View File

@ -55,12 +55,26 @@ class _Response:
}, status=HTTPStatus.BAD_REQUEST)
@property
def mxid_mismatch(self) -> web.Response:
def bad_client_connection_details(self) -> web.Response:
return web.json_response({
"error": "The Matrix user ID of the client and the user ID of the access token don't match",
"error": "Could not connect to homeserver",
"errcode": "bad_client_connection_details"
}, status=HTTPStatus.BAD_REQUEST)
def mxid_mismatch(self, found: str) -> web.Response:
return web.json_response({
"error": "The Matrix user ID of the client and the user ID of the access token don't "
f"match. Access token is for user {found}",
"errcode": "mxid_mismatch",
}, status=HTTPStatus.BAD_REQUEST)
@property
def pid_mismatch(self) -> web.Response:
return web.json_response({
"error": "The ID in the path does not match the ID of the uploaded plugin",
"errcode": "pid_mismatch",
}, status=HTTPStatus.BAD_REQUEST)
@property
def bad_auth(self) -> web.Response:
return web.json_response({
@ -138,6 +152,13 @@ class _Response:
"errcode": "user_exists",
}, status=HTTPStatus.CONFLICT)
@property
def plugin_exists(self) -> web.Response:
return web.json_response({
"error": "A plugin with the same ID as the uploaded plugin already exists",
"errcode": "plugin_exists"
}, status=HTTPStatus.CONFLICT)
@property
def plugin_in_use(self) -> web.Response:
return web.json_response({

View File

@ -85,6 +85,21 @@ paths:
summary: Upload a new plugin
description: Upload a new plugin. If the plugin already exists, enabled instances will be restarted.
tags: [Plugins]
parameters:
- name: allow_override
in: query
description: Set to allow overriding existing plugins
required: false
schema:
type: boolean
default: false
requestBody:
content:
application/zip:
schema:
type: string
format: binary
example: The plugin maubot archive (.mbp)
responses:
200:
description: Plugin uploaded and replaced current version successfully
@ -102,13 +117,8 @@ paths:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
requestBody:
content:
application/zip:
schema:
type: string
format: binary
example: The plugin maubot archive (.mbp)
409:
description: Plugin already exists and allow_override was not specified.
'/plugin/{id}':
parameters:
- name: id
@ -150,6 +160,39 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
put:
operationId: put_plugin
summary: Upload a new or replacement plugin
description: |
Upload a new or replacement plugin with the specified ID.
A HTTP 400 will be returned if the ID of the uploaded plugin
doesn't match the ID in the path. If the plugin already
exists, enabled instances will be restarted.
tags: [Plugins]
requestBody:
content:
application/zip:
schema:
type: string
format: binary
example: The plugin maubot archive (.mbp)
responses:
200:
description: Plugin uploaded and replaced current version successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Plugin'
201:
description: New plugin uploaded successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Plugin'
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
/plugin/{id}/reload:
parameters:
- name: id
@ -356,6 +399,45 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
'/client/{id}/avatar':
parameters:
- name: id
in: path
description: The Matrix user ID of the client to get
required: true
schema:
type: string
post:
operationId: upload_avatar
summary: Upload a profile picture for a bot
tags: [Clients]
requestBody:
content:
image/png:
schema:
type: string
format: binary
example: The avatar to upload
image/jpeg:
schema:
type: string
format: binary
example: The avatar to upload
responses:
200:
description: The avatar was uploaded successfully
content:
application/json:
schema:
type: object
properties:
content_uri:
type: string
description: The MXC URI of the uploaded avatar
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
components:
responses:

View File

@ -0,0 +1,27 @@
options:
merge-default-rules: false
formatter: html
max-warnings: 50
files:
include: 'src/style/**/*.sass'
rules:
extends-before-mixins: 2
extends-before-declarations: 2
placeholder-in-extend: 2
mixins-before-declarations:
- 2
- exclude:
- breakpoint
- mq
no-warn: 1
no-debug: 1
hex-notation:
- 2
- style: uppercase
indentation:
- 2
- size: 4
property-sort-order:
- 0

View File

@ -5,8 +5,11 @@
"dependencies": {
"node-sass": "^4.9.4",
"react": "^16.6.0",
"react-ace": "^6.2.0",
"react-dom": "^16.6.0",
"react-scripts": "2.0.5"
"react-router-dom": "^4.3.1",
"react-scripts": "2.0.5",
"react-select": "^2.1.1"
},
"scripts": {
"start": "react-scripts start",
@ -22,5 +25,10 @@
"last 2 safari versions",
"last 2 ios_saf versions"
],
"proxy": "http://localhost:29316"
"proxy": "http://localhost:29316",
"homepage": ".",
"devDependencies": {
"sass-lint": "^1.12.1",
"sass-lint-auto-fix": "^0.15.0"
}
}

View File

@ -17,18 +17,22 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png">
<link rel="stylesheet" type="text/css"
href="https://fonts.googleapis.com/css?family=Raleway:300,400,700">
<link rel="stylesheet" type="text/css"
href="https://cdn.jsdelivr.net/gh/tonsky/FiraCode@1.206/distr/fira_code.css">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#50D367">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<title>Maubot Manager</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,130 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
export const BASE_PATH = "/_matrix/maubot/v1"
function getHeaders(contentType = "application/json") {
return {
"Content-Type": contentType,
"Authorization": `Bearer ${localStorage.accessToken}`,
}
}
async function defaultDelete(type, id) {
const resp = await fetch(`${BASE_PATH}/${type}/${id}`, {
headers: getHeaders(),
method: "DELETE",
})
if (resp.status === 204) {
return {
"success": true,
}
}
return await resp.json()
}
async function defaultPut(type, entry, id = undefined) {
const resp = await fetch(`${BASE_PATH}/${type}/${id || entry.id}`, {
headers: getHeaders(),
body: JSON.stringify(entry),
method: "PUT",
})
return await resp.json()
}
async function defaultGet(path) {
const resp = await fetch(`${BASE_PATH}${path}`, { headers: getHeaders() })
return await resp.json()
}
export async function login(username, password) {
const resp = await fetch(`${BASE_PATH}/auth/login`, {
method: "POST",
body: JSON.stringify({
username,
password,
}),
})
return await resp.json()
}
export async function ping() {
const response = await fetch(`${BASE_PATH}/auth/ping`, {
method: "POST",
headers: getHeaders(),
})
const json = await response.json()
if (json.username) {
return json.username
} else if (json.errcode === "auth_token_missing" || json.errcode === "auth_token_invalid") {
return null
}
throw json
}
export const getInstances = () => defaultGet("/instances")
export const getInstance = id => defaultGet(`/instance/${id}`)
export const putInstance = (instance, id) => defaultPut("instance", instance, id)
export const deleteInstance = id => defaultDelete("instance", id)
export const getPlugins = () => defaultGet("/plugins")
export const getPlugin = id => defaultGet(`/plugin/${id}`)
export const deletePlugin = id => defaultDelete("plugin", id)
export async function uploadPlugin(data, id) {
let resp
if (id) {
resp = await fetch(`${BASE_PATH}/plugin/${id}`, {
headers: getHeaders("application/zip"),
body: data,
method: "PUT",
})
} else {
resp = await fetch(`${BASE_PATH}/plugins/upload`, {
headers: getHeaders("application/zip"),
body: data,
method: "POST",
})
}
return await resp.json()
}
export const getClients = () => defaultGet("/clients")
export const getClient = id => defaultGet(`/clients/${id}`)
export async function uploadAvatar(id, data, mime) {
const resp = await fetch(`${BASE_PATH}/client/${id}/avatar`, {
headers: getHeaders(mime),
body: data,
method: "POST",
})
return await resp.json()
}
export function getAvatarURL(id) {
return `${BASE_PATH}/client/${id}/avatar?access_token=${localStorage.accessToken}`
}
export const putClient = client => defaultPut("client", client)
export const deleteClient = id => defaultDelete("client", id)
export default {
BASE_PATH,
login, ping,
getInstances, getInstance, putInstance, deleteInstance,
getPlugins, getPlugin, uploadPlugin, deletePlugin,
getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient,
}

View File

@ -0,0 +1,62 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React from "react"
import Select from "react-select"
import Switch from "./Switch"
export const PrefTable = ({ children, wrapperClass }) => {
if (wrapperClass) {
return (
<div className={wrapperClass}>
<div className="preference-table">
{children}
</div>
</div>
)
}
return (
<div className="preference-table">
{children}
</div>
)
}
export const PrefRow = ({ name, fullWidth = false, labelFor = undefined, children }) => (
<div className={`entry ${fullWidth ? "full-width" : ""}`}>
<label htmlFor={labelFor}>{name}</label>
<div className="value">{children}</div>
</div>
)
export const PrefInput = ({ rowName, fullWidth = false, ...args }) => (
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}>
<input {...args} id={rowName}/>
</PrefRow>
)
export const PrefSwitch = ({ rowName, fullWidth = false, ...args }) => (
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}>
<Switch {...args} id={rowName}/>
</PrefRow>
)
export const PrefSelect = ({ rowName, fullWidth = false, ...args }) => (
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}>
<Select className="select" {...args} id={rowName}/>
</PrefRow>
)
export default PrefTable

View File

@ -13,21 +13,16 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react"
import React from "react"
import { Route, Redirect } from "react-router-dom"
class MaubotManager extends Component {
render() {
return (
<div className="maubot-manager">
<header>
const PrivateRoute = ({ component, render, authed, to = "/login", ...args }) => (
<Route
{...args}
render={(props) => authed === true
? (component ? React.createElement(component, props) : render())
: <Redirect to={{ pathname: to }}/>}
/>
)
</header>
<main>
</main>
</div>
)
}
}
export default MaubotManager
export default PrivateRoute

View File

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

View File

@ -0,0 +1,57 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react"
class Switch extends Component {
constructor(props) {
super(props)
this.state = {
active: props.active,
}
}
componentWillReceiveProps(nextProps) {
this.setState({
active: nextProps.active,
})
}
toggle = () => {
if (this.props.onToggle) {
this.props.onToggle(!this.state.active)
} else {
this.setState({ active: !this.state.active })
}
}
toggleKeyboard = evt => (evt.key === " " || evt.key === "Enter") && this.toggle()
render() {
return (
<div className="switch" data-active={this.state.active} onClick={this.toggle}
tabIndex="0" onKeyPress={this.toggleKeyboard} id={this.props.id}>
<div className="box">
<span className="text">
<span className="on">{this.props.onText || "On"}</span>
<span className="off">{this.props.offText || "Off"}</span>
</span>
</div>
</div>
)
}
}
export default Switch

View File

@ -16,6 +16,6 @@
import React from "react"
import ReactDOM from "react-dom"
import "./style/index.sass"
import MaubotManager from "./MaubotManager"
import App from "./pages/Main"
ReactDOM.render(<MaubotManager/>, document.getElementById("root"))
ReactDOM.render(<App/>, document.getElementById("root"))

View File

@ -0,0 +1,63 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react"
import Spinner from "../components/Spinner"
import api from "../api"
class Login extends Component {
constructor(props, context) {
super(props, context)
this.state = {
username: "",
password: "",
loading: false,
error: "",
}
}
inputChanged = event => this.setState({ [event.target.name]: event.target.value })
login = async () => {
this.setState({ loading: true })
const resp = await api.login(this.state.username, this.state.password)
if (resp.token) {
await this.props.onLogin(resp.token)
} else if (resp.error) {
this.setState({ error: resp.error, loading: false })
} else {
this.setState({ error: "Unknown error", loading: false })
console.log("Unknown error:", resp)
}
}
render() {
return <div className="login-wrapper">
<div className={`login ${this.state.error && "errored"}`}>
<h1>Maubot Manager</h1>
<input type="text" placeholder="Username" value={this.state.username}
name="username" onChange={this.inputChanged}/>
<input type="password" placeholder="Password" value={this.state.password}
name="password" onChange={this.inputChanged}/>
<button onClick={this.login}>
{this.state.loading ? <Spinner/> : "Log in"}
</button>
{this.state.error && <div className="error">{this.state.error}</div>}
</div>
</div>
}
}
export default Login

View File

@ -0,0 +1,75 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react"
import { HashRouter as Router, Switch } from "react-router-dom"
import PrivateRoute from "../components/PrivateRoute"
import Spinner from "../components/Spinner"
import api from "../api"
import Dashboard from "./dashboard"
import Login from "./Login"
class Main extends Component {
constructor(props) {
super(props)
this.state = {
pinged: false,
authed: false,
}
}
async componentWillMount() {
if (localStorage.accessToken) {
await this.ping()
}
this.setState({ pinged: true })
}
async ping() {
try {
const username = await api.ping()
if (username) {
localStorage.username = username
this.setState({ authed: true })
} else {
delete localStorage.accessToken
}
} catch (err) {
console.error(err)
}
}
login = async (token) => {
localStorage.accessToken = token
await this.ping()
}
render() {
if (!this.state.pinged) {
return <Spinner className="maubot-loading"/>
}
return <Router>
<div className={`maubot-wrapper ${this.state.authed ? "authenticated" : ""}`}>
<Switch>
<PrivateRoute path="/login" render={() => <Login onLogin={this.login}/>}
authed={!this.state.authed} to="/"/>
<PrivateRoute path="/" component={Dashboard} authed={this.state.authed}/>
</Switch>
</div>
</Router>
}
}
export default Main

View File

@ -0,0 +1,68 @@
import React, { Component } from "react"
import { Link } from "react-router-dom"
class BaseMainView extends Component {
constructor(props) {
super(props)
this.state = Object.assign(this.initialState, props.entry)
}
componentWillReceiveProps(nextProps) {
this.setState(Object.assign(this.initialState, nextProps.entry))
}
delete = async () => {
if (!window.confirm(`Are you sure you want to delete ${this.state.id}?`)) {
return
}
this.setState({ deleting: true })
const resp = await this.deleteFunc(this.state.id)
if (resp.success) {
this.props.history.push("/")
this.props.onDelete()
} else {
this.setState({ deleting: false, error: resp.error })
}
}
get initialState() {
throw Error("Not implemented")
}
get hasInstances() {
return this.state.instances && this.state.instances.length > 0
}
get isNew() {
return !this.props.entry
}
inputChange = event => {
if (!event.target.name) {
return
}
this.setState({ [event.target.name]: event.target.value })
}
async readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.onload = evt => resolve(evt.target.result)
reader.onerror = err => reject(err)
})
}
renderInstances = () => !this.isNew && (
<div className="instances">
<h3>{this.hasInstances ? "Instances" : "No instances :("}</h3>
{this.state.instances.map(instance => (
<Link className="instance" key={instance.id} to={`/instance/${instance.id}`}>
{instance.id}
</Link>
))}
</div>
)
}
export default BaseMainView

View File

@ -0,0 +1,214 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React from "react"
import { NavLink, withRouter } from "react-router-dom"
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import { ReactComponent as UploadButton } from "../../res/upload.svg"
import { PrefTable, PrefSwitch, PrefInput } from "../../components/PreferenceTable"
import Spinner from "../../components/Spinner"
import api from "../../api"
import BaseMainView from "./BaseMainView"
const ClientListEntry = ({ entry }) => {
const classes = ["client", "entry"]
if (!entry.enabled) {
classes.push("disabled")
} else if (!entry.started) {
classes.push("stopped")
}
return (
<NavLink className={classes.join(" ")} to={`/client/${entry.id}`}>
<img className="avatar" src={api.getAvatarURL(entry.id)} alt=""/>
<span className="displayname">{entry.displayname || entry.id}</span>
<ChevronRight/>
</NavLink>
)
}
class Client extends BaseMainView {
static ListEntry = ClientListEntry
constructor(props) {
super(props)
this.deleteFunc = api.deleteClient
}
get initialState() {
return {
id: "",
displayname: "",
homeserver: "",
avatar_url: "",
access_token: "",
sync: true,
autojoin: true,
enabled: true,
started: false,
instances: [],
uploadingAvatar: false,
saving: false,
deleting: false,
startingOrStopping: false,
error: "",
}
}
get clientInState() {
const client = Object.assign({}, this.state)
delete client.uploadingAvatar
delete client.saving
delete client.deleting
delete client.startingOrStopping
delete client.error
delete client.instances
return client
}
avatarUpload = async event => {
const file = event.target.files[0]
this.setState({
uploadingAvatar: true,
})
const data = await this.readFile(file)
const resp = await api.uploadAvatar(this.state.id, data, file.type)
this.setState({
uploadingAvatar: false,
avatar_url: resp.content_uri,
})
}
save = async () => {
this.setState({ saving: true })
const resp = await api.putClient(this.clientInState)
if (resp.id) {
if (this.isNew) {
this.props.history.push(`/client/${resp.id}`)
} else {
this.setState({ saving: false, error: "" })
}
this.props.onChange(resp)
} else {
this.setState({ saving: false, error: resp.error })
}
}
startOrStop = async () => {
this.setState({ startingOrStopping: true })
const resp = await api.putClient({
id: this.props.entry.id,
started: !this.props.entry.started,
})
if (resp.id) {
this.props.onChange(resp)
this.setState({ startingOrStopping: false, error: "" })
} else {
this.setState({ startingOrStopping: false, error: resp.error })
}
}
get loading() {
return this.state.saving || this.state.startingOrStopping || this.state.deleting
}
renderSidebar = () => !this.isNew && (
<div className="sidebar">
<div className={`avatar-container ${this.state.avatar_url ? "" : "no-avatar"}
${this.state.uploadingAvatar ? "uploading" : ""}`}>
<img className="avatar" src={api.getAvatarURL(this.state.id)} alt="Avatar"/>
<UploadButton className="upload"/>
<input className="file-selector" type="file" accept="image/png, image/jpeg"
onChange={this.avatarUpload} disabled={this.state.uploadingAvatar}
onDragEnter={evt => evt.target.parentElement.classList.add("drag")}
onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/>
{this.state.uploadingAvatar && <Spinner/>}
</div>
<div className="started-container">
<span className={`started ${this.props.entry.started}
${this.props.entry.enabled ? "" : "disabled"}`}/>
<span className="text">
{this.props.entry.started ? "Started" :
(this.props.entry.enabled ? "Stopped" : "Disabled")}
</span>
</div>
{(this.props.entry.started || this.props.entry.enabled) && (
<button className="save" onClick={this.startOrStop} disabled={this.loading}>
{this.state.startingOrStopping ? <Spinner/>
: (this.props.entry.started ? "Stop" : "Start")}
</button>
)}
</div>
)
renderPreferences = () => (
<PrefTable>
<PrefInput rowName="User ID" type="text" disabled={!this.isNew} fullWidth={true}
name={!this.isNew ? "id" : ""} value={this.state.id} className="id"
placeholder="@fancybot:example.com" onChange={this.inputChange}/>
<PrefInput rowName="Homeserver" type="text" name="homeserver"
value={this.state.homeserver} placeholder="https://example.com"
onChange={this.inputChange}/>
<PrefInput rowName="Access token" type="text" name="access_token"
value={this.state.access_token} onChange={this.inputChange}
placeholder="MDAxYWxvY2F0aW9uIG1hdHJpeC5sb2NhbAowMDEzaWRlbnRpZmllc"/>
<PrefInput rowName="Display name" type="text" name="displayname"
value={this.state.displayname} placeholder="My fancy bot"
onChange={this.inputChange}/>
<PrefInput rowName="Avatar URL" type="text" name="avatar_url"
value={this.state.avatar_url} onChange={this.inputChange}
placeholder="mxc://example.com/mbmwyoTvPhEQPiCskcUsppko"/>
<PrefSwitch rowName="Sync" active={this.state.sync}
onToggle={sync => this.setState({ sync })}/>
<PrefSwitch rowName="Autojoin" active={this.state.autojoin}
onToggle={autojoin => this.setState({ autojoin })}/>
<PrefSwitch rowName="Enabled" active={this.state.enabled}
onToggle={enabled => this.setState({
enabled,
started: enabled && this.state.started,
})}/>
</PrefTable>
)
renderPrefButtons = () => <>
<div className="buttons">
{!this.isNew && (
<button className={`delete ${this.hasInstances ? "disabled-bg" : ""}`}
onClick={this.delete} disabled={this.loading || this.hasInstances}
title={this.hasInstances ? "Can't delete client that is in use" : ""}>
{this.state.deleting ? <Spinner/> : "Delete"}
</button>
)}
<button className="save" onClick={this.save} disabled={this.loading}>
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
</button>
</div>
<div className="error">{this.state.error}</div>
</>
render() {
return <div className="client">
{this.renderSidebar()}
<div className="info">
{this.renderPreferences()}
{this.renderPrefButtons()}
{this.renderInstances()}
</div>
</div>
}
}
export default withRouter(Client)

View File

@ -0,0 +1,174 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React from "react"
import { NavLink, withRouter } from "react-router-dom"
import AceEditor from "react-ace"
import "brace/mode/yaml"
import "brace/theme/github"
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import PrefTable, { PrefInput, PrefSelect, PrefSwitch } from "../../components/PreferenceTable"
import api from "../../api"
import Spinner from "../../components/Spinner"
import BaseMainView from "./BaseMainView"
const InstanceListEntry = ({ entry }) => (
<NavLink className="instance entry" to={`/instance/${entry.id}`}>
<span className="id">{entry.id}</span>
<ChevronRight/>
</NavLink>
)
class Instance extends BaseMainView {
static ListEntry = InstanceListEntry
constructor(props) {
super(props)
this.deleteFunc = api.deleteInstance
this.updateClientOptions()
}
get initialState() {
return {
id: "",
primary_user: "",
enabled: true,
started: true,
type: "",
config: "",
saving: false,
deleting: false,
error: "",
}
}
get instanceInState() {
const instance = Object.assign({}, this.state)
delete instance.saving
delete instance.deleting
delete instance.error
return instance
}
componentWillReceiveProps(nextProps) {
super.componentWillReceiveProps(nextProps)
this.updateClientOptions()
}
clientSelectEntry = client => client && {
id: client.id,
value: client.id,
label: (
<div className="select-client">
<img className="avatar" src={api.getAvatarURL(client.id)} alt=""/>
<span className="displayname">{client.displayname || client.id}</span>
</div>
),
}
updateClientOptions() {
this.clientOptions = Object.values(this.props.ctx.clients).map(this.clientSelectEntry)
}
save = async () => {
this.setState({ saving: true })
const resp = await api.putInstance(this.instanceInState, this.props.entry
? this.props.entry.id : undefined)
if (resp.id) {
if (this.isNew) {
this.props.history.push(`/instance/${resp.id}`)
} else {
if (resp.id !== this.props.entry.id) {
this.props.history.replace(`/instance/${resp.id}`)
}
this.setState({ saving: false, error: "" })
}
this.props.onChange(resp)
} else {
this.setState({ saving: false, error: resp.error })
}
}
get selectedClientEntry() {
return this.state.primary_user
? this.clientSelectEntry(this.props.ctx.clients[this.state.primary_user])
: {}
}
get selectedPluginEntry() {
return {
id: this.state.type,
value: this.state.type,
label: this.state.type,
}
}
get typeOptions() {
return Object.values(this.props.ctx.plugins).map(plugin => plugin && {
id: plugin.id,
value: plugin.id,
label: plugin.id,
})
}
get loading() {
return this.state.deleting || this.state.saving
}
get isValid() {
return this.state.id && this.state.primary_user && this.state.type
}
render() {
return <div className="instance">
<PrefTable>
<PrefInput rowName="ID" type="text" name="id" value={this.state.id}
placeholder="fancybotinstance" onChange={this.inputChange}
disabled={!this.isNew} fullWidth={true} className="id"/>
<PrefSwitch rowName="Enabled" active={this.state.enabled}
onToggle={enabled => this.setState({ enabled })}/>
<PrefSwitch rowName="Running" active={this.state.started}
onToggle={started => this.setState({ started })}/>
<PrefSelect rowName="Primary user" options={this.clientOptions}
isSearchable={false} value={this.selectedClientEntry}
onChange={({ id }) => this.setState({ primary_user: id })}/>
<PrefSelect rowName="Type" options={this.typeOptions}
value={this.selectedPluginEntry}
onChange={({ id }) => this.setState({ type: id })}/>
</PrefTable>
<AceEditor mode="yaml" theme="github" onChange={config => this.setState({ config })}
name="config" value={this.state.config}
editorProps={{
fontSize: "10pt",
$blockScrolling: true,
}}/>
<div className="buttons">
{!this.isNew && (
<button className="delete" onClick={this.delete} disabled={this.loading}>
{this.state.deleting ? <Spinner/> : "Delete"}
</button>
)}
<button className={`save ${this.isValid ? "" : "disabled-bg"}`}
onClick={this.save} disabled={this.loading || !this.isValid}>
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
</button>
</div>
<div className="error">{this.state.error}</div>
</div>
}
}
export default withRouter(Instance)

View File

@ -0,0 +1,97 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React from "react"
import { NavLink } from "react-router-dom"
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import { ReactComponent as UploadButton } from "../../res/upload.svg"
import PrefTable, { PrefInput } from "../../components/PreferenceTable"
import Spinner from "../../components/Spinner"
import api from "../../api"
import BaseMainView from "./BaseMainView"
const PluginListEntry = ({ entry }) => (
<NavLink className="plugin entry" to={`/plugin/${entry.id}`}>
<span className="id">{entry.id}</span>
<ChevronRight/>
</NavLink>
)
class Plugin extends BaseMainView {
static ListEntry = PluginListEntry
get initialState() {
return {
id: "",
version: "",
instances: [],
uploading: false,
deleting: false,
error: "",
}
}
upload = async event => {
const file = event.target.files[0]
this.setState({
uploadingAvatar: true,
})
const data = await this.readFile(file)
const resp = await api.uploadPlugin(data, this.state.id)
if (resp.id) {
if (this.isNew) {
this.props.history.push(`/plugin/${resp.id}`)
} else {
this.setState({ saving: false, error: "" })
}
this.props.onChange(resp)
} else {
this.setState({ saving: false, error: resp.error })
}
}
render() {
return <div className="plugin">
{!this.isNew && <PrefTable>
<PrefInput rowName="ID" type="text" value={this.state.id} disabled={true}
className="id"/>
<PrefInput rowName="Version" type="text" value={this.state.version}
disabled={true}/>
</PrefTable>}
<div className={`upload-box ${this.state.uploading ? "uploading" : ""}`}>
<UploadButton className="upload"/>
<input className="file-selector" type="file" accept="application/zip"
onChange={this.upload} disabled={this.state.uploading || this.state.deleting}
onDragEnter={evt => evt.target.parentElement.classList.add("drag")}
onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/>
{this.state.uploading && <Spinner/>}
</div>
{!this.isNew && <div className="buttons">
<button className={`delete ${this.hasInstances ? "disabled-bg" : ""}`}
onClick={this.delete} disabled={this.loading || this.hasInstances}
title={this.hasInstances ? "Can't delete plugin that is in use" : ""}>
{this.state.deleting ? <Spinner/> : "Delete"}
</button>
</div>}
<div className="error">{this.state.error}</div>
{!this.isNew && this.renderInstances()}
</div>
}
}
export default Plugin

View File

@ -0,0 +1,163 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react"
import { Route, Switch, Link, withRouter } from "react-router-dom"
import api from "../../api"
import { ReactComponent as Plus } from "../../res/plus.svg"
import Instance from "./Instance"
import Client from "./Client"
import Plugin from "./Plugin"
class Dashboard extends Component {
constructor(props) {
super(props)
this.state = {
instances: {},
clients: {},
plugins: {},
sidebarOpen: false,
}
window.maubot = this
}
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
this.setState({ sidebarOpen: false })
}
}
async componentWillMount() {
const [instanceList, clientList, pluginList] = await Promise.all([
api.getInstances(), api.getClients(), api.getPlugins()])
const instances = {}
for (const instance of instanceList) {
instances[instance.id] = instance
}
const clients = {}
for (const client of clientList) {
clients[client.id] = client
}
const plugins = {}
for (const plugin of pluginList) {
plugins[plugin.id] = plugin
}
this.setState({ instances, clients, plugins })
}
renderList(field, type) {
return this.state[field] && Object.values(this.state[field]).map(entry =>
React.createElement(type, { key: entry.id, entry }))
}
delete(stateField, id) {
const data = Object.assign({}, this.state[stateField])
delete data[id]
this.setState({ [stateField]: data })
}
add(stateField, entry, oldID = undefined) {
const data = Object.assign({}, this.state[stateField])
if (oldID && oldID !== entry.id) {
delete data[oldID]
}
data[entry.id] = entry
this.setState({ [stateField]: data })
}
renderView(field, type, id) {
const entry = this.state[field][id]
if (!entry) {
return this.renderNotFound(field.slice(0, -1))
}
return React.createElement(type, {
entry,
onDelete: () => this.delete(field, id),
onChange: newEntry => this.add(field, newEntry, id),
ctx: this.state,
})
}
renderNotFound = (thing = "path") => (
<div className="not-found">
Oops! I'm afraid that {thing} couldn't be found.
</div>
)
render() {
return <div className={`dashboard ${this.state.sidebarOpen ? "sidebar-open" : ""}`}>
<Link to="/" className="title">
<img src="/favicon.png" alt=""/>
Maubot Manager
</Link>
<div className="user">
<span>{localStorage.username}</span>
</div>
<nav className="sidebar">
<div className="instances list">
<div className="title">
<h2>Instances</h2>
<Link to="/new/instance"><Plus/></Link>
</div>
{this.renderList("instances", Instance.ListEntry)}
</div>
<div className="clients list">
<div className="title">
<h2>Clients</h2>
<Link to="/new/client"><Plus/></Link>
</div>
{this.renderList("clients", Client.ListEntry)}
</div>
<div className="plugins list">
<div className="title">
<h2>Plugins</h2>
<Link to="/new/plugin"><Plus/></Link>
</div>
{this.renderList("plugins", Plugin.ListEntry)}
</div>
</nav>
<div className="topbar">
<div className={`hamburger ${this.state.sidebarOpen ? "active" : ""}`}
onClick={evt => this.setState({ sidebarOpen: !this.state.sidebarOpen })}>
<span/><span/><span/>
</div>
</div>
<main className="view">
<Switch>
<Route path="/" exact render={() => "Hello, World!"}/>
<Route path="/new/instance" render={() =>
<Instance onChange={newEntry => this.add("instances", newEntry)}
ctx={this.state}/>}/>
<Route path="/new/client" render={() => <Client
onChange={newEntry => this.add("clients", newEntry)}/>}/>
<Route path="/new/plugin" render={() => <Plugin
onChange={newEntry => this.add("plugins", newEntry)}/>}/>
<Route path="/instance/:id" render={({ match }) =>
this.renderView("instances", Instance, match.params.id)}/>
<Route path="/client/:id" render={({ match }) =>
this.renderView("clients", Client, match.params.id)}/>
<Route path="/plugin/:id" render={({ match }) =>
this.renderView("plugins", Plugin, match.params.id)}/>
<Route render={() => this.renderNotFound()}/>
</Switch>
</main>
</div>
}
}
export default withRouter(Dashboard)

View File

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

After

Width:  |  Height:  |  Size: 184 B

View File

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

After

Width:  |  Height:  |  Size: 432 B

View File

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

After

Width:  |  Height:  |  Size: 365 B

View File

@ -18,7 +18,6 @@ body
margin: 0
padding: 0
font-size: 16px
background-color: $background-color
#root
position: fixed
@ -27,27 +26,14 @@ body
right: 0
left: 0
//.lindeb
> header
position: absolute
top: 0
height: $header-height
left: 0
right: 0
.maubot-wrapper
position: absolute
top: 0
bottom: 0
left: 0
right: 0
background-color: $background-dark
> main
position: absolute
top: $header-height
bottom: 0
left: 0
right: 0
text-align: center
> .lindeb-content
text-align: left
display: inline-block
width: 100%
max-width: $max-width
box-sizing: border-box
padding: 0 1rem
.maubot-loading
margin-top: 10rem
width: 10rem

View File

@ -0,0 +1,120 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
=button($width: null, $height: null, $padding: .375rem 1rem)
font-family: $font-stack
padding: $padding
width: $width
height: $height
background-color: $background
border: none
border-radius: .25rem
color: $text-color
box-sizing: border-box
font-size: 1rem
&.disabled-bg
background-color: $background-dark
&:not(:disabled)
cursor: pointer
&:hover
background-color: darken($background, 10%)
=link-button()
display: inline-block
text-align: center
text-decoration: none
=main-color-button()
background-color: $primary
color: $inverted-text-color
&:hover:not(:disabled)
background-color: $primary-dark
&:disabled.disabled-bg
background-color: $background-dark !important
color: $text-color
.button
+button
&.main-color
+main-color-button
=button-group()
width: 100%
display: flex
> button, > .button
flex: 1
&:first-of-type
margin-right: .5rem
&:last-of-type
margin-left: .5rem
&:first-of-type:last-of-type
margin: 0
=vertical-button-group()
display: flex
flex-direction: column
> button, > .button
flex: 1
border-radius: 0
&:first-of-type
border-radius: .25rem .25rem 0 0
&:last-of-type
border-radius: 0 0 .25rem .25rem
&:first-of-type:last-of-type
border-radius: .25rem
=input($width: null, $height: null, $vertical-padding: .375rem, $horizontal-padding: 1rem, $font-size: 1rem)
font-family: $font-stack
border: 1px solid $border-color
background-color: $background
color: $text-color
width: $width
height: $height
box-sizing: border-box
border-radius: .25rem
padding: $vertical-padding $horizontal-padding
font-size: $font-size
resize: vertical
&:hover, &:focus
border-color: $primary
&:focus
border-width: 2px
padding: calc(#{$vertical-padding} - 1px) calc(#{$horizontal-padding} - 1px)
.input, .textarea
+input
input
font-family: $font-stack
=notification($border: $error-dark, $background: transparentize($error-light, 0.5))
padding: 1rem
border-radius: .25rem
border: 2px solid $border
background-color: $background

View File

@ -13,16 +13,20 @@
//
// 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/>.
$main-color: darken(#50D367, 10%)
$dark-color: darken($main-color, 10%)
$light-color: lighten($main-color, 10%)
$alt-color: darken(#47B9D7, 10%)
$dark-alt-color: darken($alt-color, 10%)
$border-color: #CCC
$error-color: #D35067
$primary: #00C853
$primary-dark: #009624
$primary-light: #5EFC82
$secondary: #00B8D4
$secondary-dark: #0088A3
$secondary-light: #62EBFF
$error: #B71C1C
$error-dark: #7F0000
$error-light: #F05545
$border-color: #DDD
$text-color: #212121
$background-color: #FAFAFA
$inverted-text-color: $background-color
$font-stack: sans-serif
$max-width: 42.5rem
$header-height: 3.5rem
$background: #FAFAFA
$background-dark: #E7E7E7
$inverted-text-color: $background
$font-stack: Raleway, sans-serif

View File

@ -13,5 +13,17 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
@import lib/spinner
@import base/vars
@import base/body
@import base/elements
@import lib/preferencetable
@import lib/switch
@import pages/mixins/upload-container
@import pages/mixins/instancelist
@import pages/login
@import pages/dashboard

View File

@ -0,0 +1,79 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
.preference-table
display: flex
width: 100%
flex-wrap: wrap
> .entry
display: block
@media screen and (max-width: 55rem)
width: calc(100% - 1rem)
width: calc(50% - 1rem)
margin: .5rem
&.full-width
width: 100%
> label, > .value
display: block
width: 100%
> label
font-size: 0.875rem
padding-bottom: .25rem
font-weight: lighter
> .value
> .switch
width: auto
height: 2rem
> .select
height: 2.5rem
box-sizing: border-box
> input
border: none
height: 2rem
width: 100%
color: $text-color
box-sizing: border-box
padding: .375rem 0
background-color: $background
font-size: 1rem
border-bottom: 1px solid $background
&.id:disabled
font-family: "Fira Code", monospace
font-weight: bold
&:not(:disabled)
border-bottom: 1px dotted $primary
&:hover
border-bottom: 1px solid $primary
&:focus
border-bottom: 2px solid $primary

View File

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

View File

@ -0,0 +1,77 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
.switch
display: flex
width: 100%
height: 2rem
cursor: pointer
border: 1px solid $error-light
border-radius: .25rem
background-color: $background
box-sizing: border-box
> .box
display: flex
box-sizing: border-box
width: 50%
height: 100%
transition: .5s
text-align: center
border-radius: .15rem 0 0 .15rem
background-color: $error-light
color: $inverted-text-color
align-items: center
> .text
box-sizing: border-box
width: 100%
text-align: center
vertical-align: middle
color: $inverted-text-color
font-size: 1rem
user-select: none
.on
display: none
.off
display: inline
&[data-active=true]
border: 1px solid $primary
> .box
background-color: $primary
transform: translateX(100%)
border-radius: 0 .15rem .15rem 0
.on
display: inline
.off
display: none

View File

@ -0,0 +1,62 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
> div.avatar-container
+upload-box
width: 8rem
height: 8rem
border-radius: 50%
@media screen and (max-width: 40rem)
margin: 0 auto 1rem
> img.avatar
position: absolute
display: block
max-width: 8rem
max-height: 8rem
user-select: none
> svg.upload
visibility: hidden
width: 6rem
height: 6rem
> input.file-selector
width: 8rem
height: 8rem
&:not(.uploading)
&:hover, &.drag
> img.avatar
opacity: .25
> svg.upload
visibility: visible
&.no-avatar
> img.avatar
visibility: hidden
> svg.upload
visibility: visible
opacity: .5
&.uploading
> img.avatar
opacity: .25

View File

@ -0,0 +1,44 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
> div.client
display: flex
> div.sidebar
vertical-align: top
text-align: center
width: 8rem
margin-right: 1rem
> div
margin-bottom: 1rem
@import avatar
@import started
> div.info
vertical-align: top
flex: 1
> div.instances
+instancelist
@media screen and (max-width: 40rem)
flex-wrap: wrap
> div.sidebar, > div.info
width: 100%
margin-right: 0

View File

@ -0,0 +1,41 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
> div.started-container
display: inline-flex
> span.started
display: inline-block
height: 0
width: 0
border-radius: 50%
margin: .5rem
&.true
background-color: $primary
box-shadow: 0 0 .75rem .75rem $primary
&.false
background-color: $error-light
box-shadow: 0 0 .75rem .75rem $error-light
&.disabled
background-color: $border-color
box-shadow: 0 0 .75rem .75rem $border-color
> span.text
display: inline-block
margin-left: 1rem

View File

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

View File

@ -0,0 +1,120 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
@import "dashboard-grid"
.dashboard
display: grid
height: 100%
max-width: 60rem
margin: auto
box-shadow: 0 .5rem .5rem rgba(0, 0, 0, 0.5)
background-color: $background
> a.title
grid-area: title
background-color: white
display: flex
align-items: center
justify-content: center
font-size: 1.35rem
font-weight: bold
color: $text-color
text-decoration: none
> img
max-width: 2rem
margin-right: .5rem
> div.user
grid-area: user
background-color: white
border-bottom: 1px solid $border-color
display: flex
align-items: center
justify-content: center
span
display: flex
align-items: center
justify-content: center
background-color: $primary
color: $inverted-text-color
margin: .375rem .5rem
width: 100%
height: calc(100% - .375rem)
box-sizing: border-box
border-radius: .25rem
@import sidebar
@import topbar
@media screen and (max-width: 35rem)
&:not(.sidebar-open)
> nav.sidebar, > a.title, > div.user
display: none !important
> main.view
grid-area: main
border-left: 1px solid $border-color
overflow-y: auto
@import client/index
@import instance
@import plugin
> .not-found
text-align: center
margin-top: 5rem
font-size: 1.5rem
> div:not(.not-found)
margin: 2rem 4rem
@media screen and (max-width: 50rem)
margin: 2rem 1rem
div.buttons
+button-group
display: flex
margin: 1rem .5rem
width: calc(100% - 1rem)
div.error
+notification($error)
margin: 1rem .5rem
&:empty
display: none
button.delete
background-color: $error-light !important
&:hover
background-color: $error !important
button.save, button.delete
+button
+main-color-button
width: 100%
height: 2.5rem
padding: 0
> .spinner
+thick-spinner
+white-spinner
width: 2rem

View File

@ -0,0 +1,35 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
> div.instance
> div.preference-table
.select-client
display: flex
align-items: center
img.avatar
max-height: 1.375rem
border-radius: 50%
margin-right: .5rem
> div.ace_editor
z-index: 0
height: 15rem !important
width: calc(100% - 1rem) !important
font-size: 12px
font-family: "Fira Code", monospace
margin: .75rem .5rem 1.5rem

View File

@ -0,0 +1,62 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
.maubot-wrapper:not(.authenticated)
background-color: $primary
text-align: center
.login
width: 25rem
height: 23rem
display: inline-block
box-sizing: border-box
background-color: white
border-radius: .25rem
margin-top: 3rem
@media screen and (max-width: 27rem)
margin: 3rem 1rem 0
width: calc(100% - 2rem)
h1
color: $primary
margin: 3rem 0
input, button
margin: .5rem 2.5rem
height: 3rem
width: calc(100% - 5rem)
box-sizing: border-box
input
+input
button
+button($width: calc(100% - 5rem), $height: 3rem, $padding: 0)
+main-color-button
.spinner
+white-spinner
+thick-spinner
width: 2rem
&.errored
height: 26.5rem
.error
+notification($error)
margin: .5rem 2.5rem

View File

@ -0,0 +1,40 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
=instancelist()
margin: 1rem 0
display: flex
flex-wrap: wrap
> h3
margin: .5rem
width: 100%
> a.instance
display: block
width: calc(50% - 1rem)
padding: .375rem .5rem
margin: .5rem
background-color: white
border-radius: .25rem
color: $text-color
text-decoration: none
box-sizing: border-box
border: 1px solid $primary
&:hover
background-color: $primary

View File

@ -0,0 +1,43 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
=upload-box()
position: relative
overflow: hidden
display: flex
align-items: center
justify-content: center
> svg.upload
position: absolute
display: block
padding: 1rem
user-select: none
> input.file-selector
position: absolute
user-select: none
opacity: 0
> div.spinner
+thick-spinner
&:not(.uploading)
> input.file-selector
cursor: pointer

View File

@ -0,0 +1,53 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
> .plugin
> .upload-box
+upload-box
width: calc(100% - 1rem)
height: 10rem
margin: .5rem
border-radius: .5rem
box-sizing: border-box
border: .25rem dotted $primary
> svg.upload
width: 8rem
height: 8rem
opacity: .5
> input.file-selector
width: 100%
height: 100%
&:not(.uploading):hover, &:not(.uploading).drag
border: .25rem solid $primary
background-color: $primary-light
> svg.upload
opacity: 1
&.uploading
> svg.upload
visibility: hidden
> input.file-selector
cursor: default
> div.instances
+instancelist

View File

@ -0,0 +1,69 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
> nav.sidebar
grid-area: sidebar
background-color: white
padding: .5rem
overflow-y: auto
div.list
&:not(:last-of-type)
margin-bottom: 1.5rem
div.title
h2
display: inline-block
margin: 0 0 .25rem 0
font-size: 1.25rem
a
display: inline-block
float: right
a.entry
display: block
color: $text-color
text-decoration: none
padding: .25rem
border-radius: .25rem
height: 2rem
box-sizing: border-box
&:not(:hover) > svg
display: none
> svg
float: right
&:hover
background-color: $primary-light
&.active
background-color: $primary
color: white
&.client
img.avatar
max-height: 1.5rem
border-radius: 100%
vertical-align: middle
span.displayname, span.id
margin-left: .25rem
vertical-align: middle

View File

@ -0,0 +1,74 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
.topbar
background-color: $primary
display: flex
justify-items: center
align-items: center
padding: 0 .75rem
@media screen and (min-width: calc(35rem + 1px))
display: none
// Hamburger menu based on "Pure CSS Hamburger fold-out menu" codepen by Erik Terwan (MIT license)
// https://codepen.io/erikterwan/pen/EVzeRP
.hamburger
display: block
user-select: none
cursor: pointer
> span
display: block
width: 29px
height: 4px
margin-bottom: 5px
position: relative
background: white
border-radius: 3px
z-index: 1
transform-origin: 4px 0
//transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1.0), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1.0), opacity 0.55s ease
&:nth-of-type(1)
transform-origin: 0 0
&:nth-of-type(3)
transform-origin: 0 100%
transform: translateY(2px)
&.active
transform: translateX(1px) translateY(4px)
&.active > span
opacity: 1
&:nth-of-type(1)
transform: rotate(45deg) translate(-2px, -1px)
&:nth-of-type(2)
opacity: 0
transform: rotate(0deg) scale(0.2, 0.2)
&:nth-of-type(3)
transform: rotate(-45deg) translate(0, -1px)

File diff suppressed because it is too large Load Diff

View File

@ -13,33 +13,78 @@
#
# 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 web
import logging
import asyncio
from aiohttp import web
from aiohttp.abc import AbstractAccessLogger
import pkg_resources
from mautrix.api import PathBuilder, Method
from .config import Config
from .__meta__ import __version__
class AccessLogger(AbstractAccessLogger):
def log(self, request: web.Request, response: web.Response, time: int):
self.logger.info(f'{request.remote} "{request.method} {request.path} '
f'{response.status} {response.body_length} '
f'in {round(time, 4)}s"')
class MaubotServer:
log: logging.Logger = logging.getLogger("maubot.server")
def __init__(self, config: Config, management: web.Application,
loop: asyncio.AbstractEventLoop) -> None:
def __init__(self, config: Config, loop: asyncio.AbstractEventLoop) -> None:
self.loop = loop or asyncio.get_event_loop()
self.app = web.Application(loop=self.loop)
self.config = config
path = PathBuilder(config["server.base_path"])
self.add_route(Method.GET, path.version, self.version)
self.app.add_subapp(config["server.base_path"], management)
as_path = PathBuilder(config["server.appservice_base_path"])
self.add_route(Method.PUT, as_path.transactions, self.handle_transaction)
self.runner = web.AppRunner(self.app)
self.setup_management_ui()
self.runner = web.AppRunner(self.app, access_log_class=AccessLogger)
def setup_management_ui(self) -> None:
ui_base = self.config["server.ui_base_path"]
if ui_base == "/":
ui_base = ""
directory = (self.config["server.override_resource_path"]
or pkg_resources.resource_filename("maubot", "management/frontend/build"))
self.app.router.add_static(f"{ui_base}/static", f"{directory}/static")
self.setup_static_root_files(directory, ui_base)
with open(f"{directory}/index.html", "r") as file:
index_html = file.read()
@web.middleware
async def frontend_404_middleware(request, handler):
if hasattr(handler, "__self__") and isinstance(handler.__self__, web.StaticResource):
try:
return await handler(request)
except web.HTTPNotFound:
return web.Response(body=index_html, content_type="text/html")
return await handler(request)
self.app.middlewares.append(frontend_404_middleware)
self.app.router.add_get(f"{ui_base}/", lambda _: web.Response(body=index_html,
content_type="text/html"))
self.app.router.add_get(ui_base, lambda _: web.HTTPFound(f"{ui_base}/"))
def setup_static_root_files(self, directory: str, ui_base: str) -> None:
files = {
"asset-manifest.json": "application/json",
"manifest.json": "application/json",
"favicon.png": "image/png",
}
for file, mime in files.items():
with open(f"{directory}/{file}", "rb") as stream:
data = stream.read()
self.app.router.add_get(f"{ui_base}/{file}", lambda _: web.Response(body=data,
content_type=mime))
def add_route(self, method: Method, path: PathBuilder, handler) -> None:
self.app.router.add_route(method.value, str(path), handler)