Tulir Asokan
4 years ago
54 changed files with 3415 additions and 419 deletions
-
19.editorconfig
-
2.gitignore
-
4MANIFEST.in
-
10optional-requirements.txt
-
6requirements.txt
-
22setup.py
-
4sticker/import.py
-
0sticker/server/__init__.py
-
53sticker/server/__main__.py
-
38sticker/server/api/__init__.py
-
216sticker/server/api/auth.py
-
110sticker/server/api/errors.py
-
110sticker/server/api/fed_connector.py
-
52sticker/server/api/packs.py
-
19sticker/server/api/setup.py
-
55sticker/server/config.py
-
6sticker/server/database/__init__.py
-
71sticker/server/database/access_token.py
-
9sticker/server/database/base.py
-
58sticker/server/database/pack.py
-
49sticker/server/database/sticker.py
-
56sticker/server/database/upgrade.py
-
95sticker/server/database/user.py
-
74sticker/server/example-config.yaml
-
1sticker/server/frontend
-
50sticker/server/server.py
-
106sticker/server/static.py
-
196web/.eslintrc.json
-
10web/index.html
-
3web/lib/common/preact.module-9c264606.js
-
9web/lib/htm/preact.js
-
5web/lib/preact/hooks.js
-
14web/package.json
-
23web/setup/index.html
-
30web/src/Button.js
-
4web/src/Spinner.js
-
215web/src/setup/LoginView.js
-
20web/src/setup/index.js
-
102web/src/setup/matrix-api.js
-
67web/src/setup/tryGet.js
-
0web/src/widget/frequently-used.js
-
110web/src/widget/index.js
-
0web/src/widget/widget-api.js
-
1web/style/button.css
-
60web/style/button.sass
-
1web/style/setup-login.css
-
105web/style/setup-login.sass
-
1web/style/setup.css
-
18web/style/setup.sass
-
0web/style/theme.css
-
33web/style/theme.sass
-
0web/style/widget.css
-
0web/style/widget.sass
-
1478web/yarn.lock
@ -0,0 +1,19 @@ |
|||
root = true |
|||
|
|||
[*] |
|||
indent_style = space |
|||
indent_size = 4 |
|||
end_of_line = lf |
|||
charset = utf-8 |
|||
trim_trailing_whitespace = true |
|||
insert_final_newline = true |
|||
|
|||
[*.py] |
|||
max_line_length = 99 |
|||
|
|||
[*.js] |
|||
max_line_length = 100 |
|||
indent_style = tab |
|||
|
|||
[*.{json,sass}] |
|||
indent_size = 2 |
@ -0,0 +1,4 @@ |
|||
include README.md |
|||
include LICENSE |
|||
include requirements.txt |
|||
include optional-requirements.txt |
@ -0,0 +1,10 @@ |
|||
# Format: #/name defines a new extras_require group called name |
|||
# Uncommented lines after the group definition insert things into that group. |
|||
|
|||
#/server |
|||
mautrix==0.8.0rc4 |
|||
asyncpg>=0.20,<0.22 |
|||
attrs |
|||
setuptools |
|||
aiodns |
|||
ruamel.yaml |
@ -1,6 +1,6 @@ |
|||
aiohttp |
|||
yarl |
|||
aiohttp>=3,<4 |
|||
yarl>=1,<2 |
|||
pillow |
|||
telethon |
|||
telethon>=1.16 |
|||
cryptg |
|||
python-magic |
@ -0,0 +1,53 @@ |
|||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
# Copyright (C) 2020 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 mautrix.util.program import Program |
|||
from mautrix.util.async_db import Database |
|||
|
|||
from .config import Config |
|||
from .server import Server |
|||
from .database import upgrade_table, Base |
|||
from ..version import version |
|||
|
|||
|
|||
class StickerServer(Program): |
|||
module = "sticker.server" |
|||
name = "maunium-stickerpicker server" |
|||
version = version |
|||
command = "python -m sticker.server" |
|||
description = "Server for maunium-stickerpicker" |
|||
|
|||
config_class = Config |
|||
|
|||
config: Config |
|||
server: Server |
|||
database: Database |
|||
|
|||
async def start(self) -> None: |
|||
self.database = Database(url=self.config["database"], upgrade_table=upgrade_table) |
|||
Base.db = self.database |
|||
self.server = Server(self.config) |
|||
|
|||
await self.database.start() |
|||
await self.server.start() |
|||
|
|||
await super().start() |
|||
|
|||
async def stop(self) -> None: |
|||
await super().stop() |
|||
await self.server.stop() |
|||
|
|||
|
|||
StickerServer().run() |
@ -0,0 +1,38 @@ |
|||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
# Copyright (C) 2020 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 web |
|||
|
|||
from ..config import Config |
|||
from .auth import (routes as auth_routes, init as auth_init, |
|||
token_middleware, widget_secret_middleware) |
|||
from .fed_connector import init as init_fed_connector |
|||
from .packs import routes as packs_routes, init as packs_init |
|||
from .setup import routes as setup_routes |
|||
|
|||
integrations_app = web.Application() |
|||
integrations_app.add_routes(auth_routes) |
|||
|
|||
packs_app = web.Application(middlewares=[widget_secret_middleware]) |
|||
packs_app.add_routes(packs_routes) |
|||
|
|||
setup_app = web.Application(middlewares=[token_middleware]) |
|||
setup_app.add_routes(setup_routes) |
|||
|
|||
|
|||
def init(config: Config) -> None: |
|||
init_fed_connector() |
|||
auth_init(config) |
|||
packs_init(config) |
@ -0,0 +1,216 @@ |
|||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
# Copyright (C) 2020 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 Tuple, Callable, Awaitable, Optional, TYPE_CHECKING |
|||
import logging |
|||
import json |
|||
|
|||
from mautrix.client import Client |
|||
from mautrix.types import UserID |
|||
from mautrix.util.logging import TraceLogger |
|||
from aiohttp import web, hdrs, ClientError, ClientSession |
|||
from yarl import URL |
|||
|
|||
from ..database import AccessToken, User |
|||
from ..config import Config |
|||
from .errors import Error |
|||
from . import fed_connector |
|||
|
|||
if TYPE_CHECKING: |
|||
from typing import TypedDict |
|||
|
|||
|
|||
class OpenIDPayload(TypedDict): |
|||
access_token: str |
|||
token_type: str |
|||
matrix_server_name: str |
|||
expires_in: int |
|||
|
|||
|
|||
class OpenIDResponse(TypedDict): |
|||
sub: str |
|||
|
|||
Handler = Callable[[web.Request], Awaitable[web.Response]] |
|||
|
|||
log: TraceLogger = logging.getLogger("mau.api.auth") |
|||
routes = web.RouteTableDef() |
|||
config: Config |
|||
|
|||
|
|||
def get_ip(request: web.Request) -> str: |
|||
if config["server.trust_forward_headers"]: |
|||
try: |
|||
return request.headers["X-Forwarded-For"] |
|||
except KeyError: |
|||
pass |
|||
return request.remote |
|||
|
|||
|
|||
def get_auth_header(request: web.Request) -> str: |
|||
try: |
|||
auth = request.headers["Authorization"] |
|||
if not auth.startswith("Bearer "): |
|||
raise Error.invalid_auth_header |
|||
return auth[len("Bearer "):] |
|||
except KeyError: |
|||
raise Error.missing_auth_header |
|||
|
|||
|
|||
async def get_user(request: web.Request) -> Tuple[User, AccessToken]: |
|||
auth = get_auth_header(request) |
|||
try: |
|||
token_id, token_val = auth.split(":") |
|||
token_id = int(token_id) |
|||
except ValueError: |
|||
raise Error.invalid_auth_token |
|||
token = await AccessToken.get(token_id) |
|||
if not token or not token.check(token_val): |
|||
raise Error.invalid_auth_token |
|||
elif token.expired: |
|||
raise Error.auth_token_expired |
|||
await token.update_ip(get_ip(request)) |
|||
return await User.get(token.user_id), token |
|||
|
|||
|
|||
@web.middleware |
|||
async def token_middleware(request: web.Request, handler: Handler) -> web.Response: |
|||
if request.method == hdrs.METH_OPTIONS: |
|||
return await handler(request) |
|||
user, token = await get_user(request) |
|||
request["user"] = user |
|||
request["token"] = token |
|||
return await handler(request) |
|||
|
|||
|
|||
async def get_widget_user(request: web.Request) -> User: |
|||
try: |
|||
user_id = UserID(request.headers["X-Matrix-User-ID"]) |
|||
except KeyError: |
|||
raise Error.missing_user_id_header |
|||
user = await User.get(user_id) |
|||
if user is None: |
|||
raise Error.user_not_found |
|||
return user |
|||
|
|||
|
|||
@web.middleware |
|||
async def widget_secret_middleware(request: web.Request, handler: Handler) -> web.Response: |
|||
if request.method == hdrs.METH_OPTIONS: |
|||
return await handler(request) |
|||
user = await get_widget_user(request) |
|||
request["user"] = user |
|||
return await handler(request) |
|||
|
|||
|
|||
account_cors_headers = { |
|||
"Access-Control-Allow-Origin": "*", |
|||
"Access-Control-Allow-Methods": "OPTIONS, GET, POST", |
|||
"Access-Control-Allow-Headers": "Authorization, Content-Type", |
|||
} |
|||
|
|||
|
|||
@routes.get("/account") |
|||
async def get_auth(request: web.Request) -> web.Response: |
|||
user, token = await get_user(request) |
|||
return web.json_response({"user_id": token.user_id}, headers=account_cors_headers) |
|||
|
|||
|
|||
async def check_openid_token(homeserver: str, token: str) -> Optional[UserID]: |
|||
server_info = await fed_connector.resolve_server_name(homeserver) |
|||
headers = {"Host": server_info.host_header} |
|||
userinfo_url = URL.build(scheme="https", host=server_info.host, port=server_info.port, |
|||
path="/_matrix/federation/v1/openid/userinfo", |
|||
query={"access_token": token}) |
|||
try: |
|||
async with fed_connector.http.get(userinfo_url, headers=headers) as resp: |
|||
data: 'OpenIDResponse' = await resp.json() |
|||
return UserID(data["sub"]) |
|||
except (ClientError, json.JSONDecodeError, KeyError, ValueError) as e: |
|||
log.debug(f"Failed to check OpenID token from {homeserver}", exc_info=True) |
|||
return None |
|||
|
|||
|
|||
@routes.route(hdrs.METH_OPTIONS, "/account/register") |
|||
@routes.route(hdrs.METH_OPTIONS, "/account/logout") |
|||
@routes.route(hdrs.METH_OPTIONS, "/account") |
|||
async def cors_token(_: web.Request) -> web.Response: |
|||
return web.Response(status=200, headers=account_cors_headers) |
|||
|
|||
|
|||
async def resolve_client_well_known(server_name: str) -> str: |
|||
url = URL.build(scheme="https", host=server_name, port=443, path="/.well-known/matrix/client") |
|||
async with ClientSession() as sess, sess.get(url) as resp: |
|||
data = await resp.json() |
|||
return data["m.homeserver"]["base_url"] |
|||
|
|||
|
|||
@routes.post("/account/register") |
|||
async def exchange_token(request: web.Request) -> web.Response: |
|||
try: |
|||
data: 'OpenIDPayload' = await request.json() |
|||
except json.JSONDecodeError: |
|||
raise Error.request_not_json |
|||
try: |
|||
matrix_server_name = data["matrix_server_name"] |
|||
access_token = data["access_token"] |
|||
except KeyError: |
|||
raise Error.invalid_openid_payload |
|||
log.trace(f"Validating OpenID token from {matrix_server_name}") |
|||
user_id = await check_openid_token(matrix_server_name, access_token) |
|||
if user_id is None: |
|||
raise Error.invalid_openid_token |
|||
_, homeserver = Client.parse_user_id(user_id) |
|||
if homeserver != data["matrix_server_name"]: |
|||
raise Error.homeserver_mismatch |
|||
|
|||
permissions = config.get_permissions(user_id) |
|||
if not permissions.access: |
|||
raise Error.no_access |
|||
|
|||
try: |
|||
log.trace(f"Trying to resolve {matrix_server_name}'s client .well-known") |
|||
homeserver_url = await resolve_client_well_known(matrix_server_name) |
|||
log.trace(f"Got {homeserver_url} from {matrix_server_name}'s client .well-known") |
|||
except (ClientError, json.JSONDecodeError, KeyError, ValueError, TypeError): |
|||
log.trace(f"Failed to resolve {matrix_server_name}'s client .well-known", exc_info=True) |
|||
raise Error.client_well_known_error |
|||
|
|||
user = await User.get(user_id) |
|||
if user is None: |
|||
log.debug(f"Creating user {user_id} with homeserver client URL {homeserver_url}") |
|||
user = User.new(user_id, homeserver_url=homeserver_url) |
|||
await user.insert() |
|||
elif user.homeserver_url != homeserver_url: |
|||
log.debug(f"Updating {user_id}'s homeserver client URL from {user.homeserver_url} " |
|||
f"to {homeserver_url}") |
|||
await user.set_homeserver_url(homeserver_url) |
|||
token = await user.new_access_token(get_ip(request)) |
|||
return web.json_response({ |
|||
"user_id": user_id, |
|||
"token": token, |
|||
"permissions": permissions._asdict(), |
|||
}, headers=account_cors_headers) |
|||
|
|||
|
|||
@routes.post("/account/logout") |
|||
async def logout(request: web.Request) -> web.Response: |
|||
user, token = await get_user(request) |
|||
await token.delete() |
|||
return web.json_response({}, status=204, headers=account_cors_headers) |
|||
|
|||
|
|||
def init(cfg: Config) -> None: |
|||
global config |
|||
config = cfg |
@ -0,0 +1,110 @@ |
|||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
# Copyright (C) 2020 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 Dict |
|||
import json |
|||
|
|||
from aiohttp import web |
|||
|
|||
|
|||
class _ErrorMeta: |
|||
def __init__(self, *args, **kwargs) -> None: |
|||
pass |
|||
|
|||
@staticmethod |
|||
def _make_error(errcode: str, error: str) -> Dict[str, str]: |
|||
return { |
|||
"body": json.dumps({ |
|||
"error": error, |
|||
"errcode": errcode, |
|||
}).encode("utf-8"), |
|||
"content_type": "application/json", |
|||
"headers": { |
|||
"Access-Control-Allow-Origin": "*", |
|||
"Access-Control-Allow-Methods": "OPTIONS, GET, POST, PUT, DELETE, HEAD", |
|||
"Access-Control-Allow-Headers": "Authorization, Content-Type", |
|||
} |
|||
} |
|||
|
|||
@property |
|||
def request_not_json(self) -> web.HTTPException: |
|||
return web.HTTPBadRequest(**self._make_error("M_NOT_JSON", |
|||
"Request body is not valid JSON")) |
|||
|
|||
@property |
|||
def missing_auth_header(self) -> web.HTTPException: |
|||
return web.HTTPForbidden(**self._make_error("M_MISSING_TOKEN", |
|||
"Missing authorization header")) |
|||
|
|||
@property |
|||
def missing_user_id_header(self) -> web.HTTPException: |
|||
return web.HTTPForbidden(**self._make_error("NET.MAUNIUM_MISSING_USER_ID", |
|||
"Missing user ID header")) |
|||
|
|||
@property |
|||
def user_not_found(self) -> web.HTTPException: |
|||
return web.HTTPNotFound(**self._make_error("NET.MAUNIUM_USER_NOT_FOUND", |
|||
"User not found")) |
|||
|
|||
@property |
|||
def invalid_auth_header(self) -> web.HTTPException: |
|||
return web.HTTPForbidden(**self._make_error("M_UNKNOWN_TOKEN", |
|||
"Invalid authorization header")) |
|||
|
|||
@property |
|||
def invalid_auth_token(self) -> web.HTTPException: |
|||
return web.HTTPForbidden(**self._make_error("M_UNKNOWN_TOKEN", |
|||
"Invalid authorization token")) |
|||
|
|||
@property |
|||
def auth_token_expired(self) -> web.HTTPException: |
|||
return web.HTTPForbidden(**self._make_error("NET.MAUNIUM_TOKEN_EXPIRED", |
|||
"Authorization token has expired")) |
|||
|
|||
@property |
|||
def invalid_openid_payload(self) -> web.HTTPException: |
|||
return web.HTTPBadRequest(**self._make_error("M_BAD_JSON", "Missing one or more " |
|||
"fields in OpenID payload")) |
|||
|
|||
@property |
|||
def invalid_openid_token(self) -> web.HTTPException: |
|||
return web.HTTPForbidden(**self._make_error("M_UNKNOWN_TOKEN", |
|||
"Invalid OpenID token")) |
|||
|
|||
@property |
|||
def no_access(self) -> web.HTTPException: |
|||
return web.HTTPUnauthorized(**self._make_error( |
|||
"M_UNAUTHORIZED", |
|||
"You are not authorized to access this maunium-stickerpicker instance")) |
|||
|
|||
@property |
|||
def homeserver_mismatch(self) -> web.HTTPException: |
|||
return web.HTTPUnauthorized(**self._make_error( |
|||
"M_UNAUTHORIZED", "Request matrix_server_name and OpenID sub homeserver don't match")) |
|||
|
|||
@property |
|||
def pack_not_found(self) -> web.HTTPException: |
|||
return web.HTTPNotFound(**self._make_error("NET.MAUNIUM_PACK_NOT_FOUND", |
|||
"Sticker pack not found")) |
|||
|
|||
@property |
|||
def client_well_known_error(self) -> web.HTTPException: |
|||
return web.HTTPForbidden(**self._make_error("NET.MAUNIUM_CLIENT_WELL_KNOWN_ERROR", |
|||
"Failed to resolve homeserver URL " |
|||
"from client .well-known")) |
|||
|
|||
|
|||
class Error(metaclass=_ErrorMeta): |
|||
pass |
@ -0,0 +1,110 @@ |
|||
from typing import Tuple, Any, NamedTuple, Dict, Optional |
|||
from time import time |
|||
import ipaddress |
|||
import logging |
|||
import asyncio |
|||
import json |
|||
|
|||
from mautrix.util.logging import TraceLogger |
|||
from aiohttp import ClientRequest, TCPConnector, ClientSession, ClientTimeout, ClientError |
|||
from aiohttp.client_proto import ResponseHandler |
|||
from yarl import URL |
|||
import aiodns |
|||
|
|||
log: TraceLogger = logging.getLogger("mau.federation") |
|||
|
|||
|
|||
class ResolvedServerName(NamedTuple): |
|||
host_header: str |
|||
host: str |
|||
port: int |
|||
expire: int |
|||
|
|||
|
|||
class ServerNameSplit(NamedTuple): |
|||
host: str |
|||
port: Optional[int] |
|||
is_ip: bool |
|||
|
|||
|
|||
dns_resolver: aiodns.DNSResolver |
|||
http: ClientSession |
|||
server_name_cache: Dict[str, ResolvedServerName] = {} |
|||
|
|||
|
|||
class MatrixFederationTCPConnector(TCPConnector): |
|||
"""An extension to aiohttp's TCPConnector that correctly sets the TLS SNI for Matrix federation |
|||
requests, where the TCP host may not match the SNI/Host header.""" |
|||
|
|||
async def _wrap_create_connection(self, *args: Any, server_hostname: str, req: ClientRequest, |
|||
**kwargs: Any) -> Tuple[asyncio.Transport, ResponseHandler]: |
|||
split = parse_server_name(req.headers["Host"]) |
|||
return await super()._wrap_create_connection(*args, server_hostname=split.host, |
|||
req=req, **kwargs) |
|||
|
|||
|
|||
def parse_server_name(name: str) -> ServerNameSplit: |
|||
port_split = name.rsplit(":", 1) |
|||
if len(port_split) == 2 and port_split[1].isdecimal(): |
|||
name, port = port_split |
|||
else: |
|||
port = None |
|||
try: |
|||
ipaddress.ip_address(name) |
|||
is_ip = True |
|||
except ValueError: |
|||
is_ip = False |
|||
res = ServerNameSplit(host=name, port=port, is_ip=is_ip) |
|||
log.trace(f"Parsed server name {name} into {res}") |
|||
return res |
|||
|
|||
|
|||
async def resolve_server_name(server_name: str) -> ResolvedServerName: |
|||
try: |
|||
cached = server_name_cache[server_name] |
|||
if cached.expire > int(time()): |
|||
log.trace(f"Using cached server name resolution for {server_name}: {cached}") |
|||
return cached |
|||
except KeyError: |
|||
log.trace(f"No cached server name resolution for {server_name}") |
|||
|
|||
host_header = server_name |
|||
hostname, port, is_ip = parse_server_name(host_header) |
|||
ttl = 86400 |
|||
if port is None and not is_ip: |
|||
well_known_url = URL.build(scheme="https", host=host_header, port=443, |
|||
path="/.well-known/matrix/server") |
|||
try: |
|||
log.trace(f"Requesting {well_known_url} to resolve {server_name}'s .well-known") |
|||
async with http.get(well_known_url) as resp: |
|||
if resp.status == 200: |
|||
well_known_data = await resp.json() |
|||
host_header = well_known_data["m.server"] |
|||
log.debug(f"Got {host_header} from {server_name}'s .well-known") |
|||
hostname, port, is_ip = parse_server_name(host_header) |
|||
else: |
|||
log.trace(f"Got non-200 status {resp.status} from {server_name}'s .well-known") |
|||
except (ClientError, json.JSONDecodeError, KeyError, ValueError) as e: |
|||
log.debug(f"Failed to fetch .well-known for {server_name}: {e}") |
|||
if port is None and not is_ip: |
|||
log.trace(f"Querying SRV at _matrix._tcp.{host_header}") |
|||
res = await dns_resolver.query(f"_matrix._tcp.{host_header}", "SRV") |
|||
if res: |
|||
hostname = res[0].host |
|||
port = res[0].port |
|||
ttl = max(res[0].ttl, 300) |
|||
log.debug(f"Got {hostname}:{port} from {host_header}'s Matrix SRV record") |
|||
else: |
|||
log.trace(f"No SRV records found at _matrix._tcp.{host_header}") |
|||
result = ResolvedServerName(host_header=host_header, host=hostname, port=port or 8448, |
|||
expire=int(time()) + ttl) |
|||
server_name_cache[server_name] = result |
|||
log.debug(f"Resolved server name {server_name} -> {result}") |
|||
return result |
|||
|
|||
|
|||
def init(): |
|||
global http, dns_resolver |
|||
dns_resolver = aiodns.DNSResolver(loop=asyncio.get_running_loop()) |
|||
http = ClientSession(timeout=ClientTimeout(total=10), |
|||
connector=MatrixFederationTCPConnector()) |
@ -0,0 +1,52 @@ |
|||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
# Copyright (C) 2020 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 web |
|||
|
|||
from ..database import User |
|||
from ..config import Config |
|||
from .errors import Error |
|||
|
|||
routes = web.RouteTableDef() |
|||
config: Config |
|||
|
|||
|
|||
@routes.get("/index.json") |
|||
async def get_packs(req: web.Request) -> web.Response: |
|||
user: User = req["user"] |
|||
packs = await user.get_packs() |
|||
return web.json_response({ |
|||
"homeserver_url": user.homeserver_url, |
|||
"is_sticker_server": True, |
|||
"packs": [f"{pack.id}.json" for pack in packs], |
|||
}) |
|||
|
|||
|
|||
@routes.get("/{pack_id}.json") |
|||
async def get_pack(req: web.Request) -> web.Response: |
|||
user: User = req["user"] |
|||
pack = await user.get_pack(req.match_info["pack_id"]) |
|||
if pack is None: |
|||
raise Error.pack_not_found |
|||
stickers = await pack.get_stickers() |
|||
return web.json_response({ |
|||
**pack.to_dict(), |
|||
"stickers": [sticker.to_dict() for sticker in stickers], |
|||
}) |
|||
|
|||
|
|||
def init(cfg: Config) -> None: |
|||
global config |
|||
config = cfg |
@ -0,0 +1,19 @@ |
|||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
# Copyright (C) 2020 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 web |
|||
|
|||
routes = web.RouteTableDef() |
@ -0,0 +1,55 @@ |
|||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
# Copyright (C) 2020 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 NamedTuple |
|||
|
|||
from mautrix.util.config import BaseFileConfig, ConfigUpdateHelper |
|||
from mautrix.types import UserID |
|||
from mautrix.client import Client |
|||
|
|||
|
|||
class Permissions(NamedTuple): |
|||
access: bool = False |
|||
create_packs: bool = False |
|||
telegram_import: bool = False |
|||
|
|||
|
|||
class Config(BaseFileConfig): |
|||
def do_update(self, helper: ConfigUpdateHelper) -> None: |
|||
copy = helper.copy |
|||
|
|||
copy("database") |
|||
|
|||
copy("server.host") |
|||
copy("server.port") |
|||
copy("server.public_url") |
|||
copy("server.override_resource_path") |
|||
copy("server.trust_forward_headers") |
|||
|
|||
copy("telegram_import.bot_token") |
|||
copy("telegram_import.homeserver.address") |
|||
copy("telegram_import.homeserver.access_token") |
|||
|
|||
copy("permissions") |
|||
|
|||
copy("logging") |
|||
|
|||
def get_permissions(self, mxid: UserID) -> Permissions: |
|||
_, homeserver = Client.parse_user_id(mxid) |
|||
return Permissions(**{ |
|||
**self["permissions"].get("*", {}), |
|||
**self["permissions"].get(homeserver, {}), |
|||
**self["permissions"].get(mxid, {}), |
|||
}) |
@ -0,0 +1,6 @@ |
|||
from .base import Base |
|||
from .upgrade import upgrade_table |
|||
from .sticker import Sticker |
|||
from .pack import Pack |
|||
from .access_token import AccessToken |
|||
from .user import User |
@ -0,0 +1,71 @@ |
|||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
# Copyright (C) 2020 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 Optional, ClassVar |
|||
from datetime import datetime, timedelta |
|||
import hashlib |
|||
|
|||
from attr import dataclass |
|||
import asyncpg |
|||
|
|||
from mautrix.types import UserID |
|||
|
|||
from .base import Base |
|||
|
|||
|
|||
@dataclass(kw_only=True) |
|||
class AccessToken(Base): |
|||
token_expiry: ClassVar[timedelta] = timedelta(days=1) |
|||
|
|||
user_id: UserID |
|||
token_id: int |
|||
token_hash: bytes |
|||
last_seen_ip: str |
|||
last_seen_date: datetime |
|||
|
|||
@classmethod |
|||
async def get(cls, token_id: int) -> Optional['AccessToken']: |
|||
q = "SELECT user_id, token_hash, last_seen_ip, last_seen_date FROM pack WHERE token_id=$1" |
|||
row: asyncpg.Record = await cls.db.fetchrow(q, token_id) |
|||
if row is None: |
|||
return None |
|||
return cls(**row, token_id=token_id) |
|||
|
|||
async def update_ip(self, ip: str) -> None: |
|||
if self.last_seen_ip == ip and (self.last_seen_date.replace(second=0, microsecond=0) |
|||
== datetime.now().replace(second=0, microsecond=0)): |
|||
# Same IP and last seen on this minute, skip update |
|||
return |
|||
q = ("UPDATE access_token SET last_seen_ip=$3, last_seen_date=current_timestamp " |
|||
"WHERE token_id=$1 RETURNING last_seen_date") |
|||
self.last_seen_date = await self.db.fetchval(q, self.token_id, ip) |
|||
self.last_seen_ip = ip |
|||
|
|||
def check(self, token: str) -> bool: |
|||
return self.token_hash == hashlib.sha256(token.encode("utf-8")).digest() |
|||
|
|||
@property |
|||
def expired(self) -> bool: |
|||
return self.last_seen_date + self.token_expiry < datetime.now() |
|||
|
|||
async def delete(self) -> None: |
|||
await self.db.execute("DELETE FROM access_token WHERE token_id=$1", self.token_id) |
|||
|
|||
@classmethod |
|||
async def insert(cls, user_id: UserID, token: str, ip: str) -> int: |
|||
q = ("INSERT INTO access_token (user_id, token_hash, last_seen_ip, last_seen_date) " |
|||
"VALUES ($1, $2, $3, current_timestamp) RETURNING token_id") |
|||
hashed = hashlib.sha256(token.encode("utf-8")).digest() |
|||
return await cls.db.fetchval(q, user_id, hashed, ip) |
@ -0,0 +1,9 @@ |
|||
from typing import ClassVar, TYPE_CHECKING |
|||
|
|||
from mautrix.util.async_db import Database |
|||
|
|||
fake_db = Database("") if TYPE_CHECKING else None |
|||
|
|||
|
|||
class Base: |
|||
db: ClassVar[Database] = fake_db |
@ -0,0 +1,58 @@ |
|||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
# Copyright (C) 2020 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 List, Dict, Any |
|||
|
|||
from attr import dataclass |
|||
|
|||
from mautrix.types import UserID |
|||
|
|||
from .base import Base |
|||
from .sticker import Sticker |
|||
|
|||
|
|||
@dataclass(kw_only=True) |
|||
class Pack(Base): |
|||
id: str |
|||
owner: UserID |
|||
title: str |
|||
meta: Dict[str, Any] |
|||
|
|||
async def delete(self) -> None: |
|||
await self.db.execute("DELETE FROM pack WHERE id=$1", self.id) |
|||
|
|||
async def insert(self) -> None: |
|||
await self.db.execute("INSERT INTO pack (id, owner, title, meta) VALUES ($1, $2, $3, $4)", |
|||
self.id, self.owner, self.title, self.meta) |
|||
|
|||
async def get_stickers(self) -> List[Sticker]: |
|||
res = await self.db.fetch('SELECT id, url, body, meta, "order" ' |
|||
'FROM sticker WHERE pack_id=$1 ORDER BY "order"', self.id) |
|||
return [Sticker(**row, pack_id=self.id) for row in res] |
|||
|
|||
async def set_stickers(self, stickers: List[Sticker]) -> None: |
|||
data = ((sticker.id, self.id, sticker.url, sticker.body, sticker.meta, order) |
|||
for order, sticker in enumerate(stickers)) |
|||
columns = ["id", "pack_id", "url", "body", "meta", "order"] |
|||
async with self.db.acquire() as conn, conn.transaction(): |
|||
await conn.execute("DELETE FROM sticker WHERE pack_id=$1", self.id) |
|||
await conn.copy_records_to_table("sticker", records=data, columns=columns) |
|||
|
|||
def to_dict(self) -> Dict[str, Any]: |
|||
return { |
|||
**self.meta, |
|||
"title": self.title, |
|||
"id": self.id, |
|||
} |
@ -0,0 +1,49 @@ |
|||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
# Copyright (C) 2020 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 Dict, Any |
|||
|
|||
from attr import dataclass |
|||
import attr |
|||
|
|||
from mautrix.types import ContentURI |
|||
|
|||
from .base import Base |
|||
|
|||
|
|||
@dataclass(kw_only=True) |
|||
class Sticker(Base): |
|||
pack_id: str |
|||
order: int |
|||
id: str |
|||
url: ContentURI = attr.ib(order=False) |
|||
body: str = attr.ib(order=False) |
|||
meta: Dict[str, Any] = attr.ib(order=False) |
|||
|
|||
async def delete(self) -> None: |
|||
await self.db.execute("DELETE FROM sticker WHERE id=$1", self.id) |
|||
|
|||
async def insert(self) -> None: |
|||
await self.db.execute('INSERT INTO sticker (id, pack_id, url, body, meta, "order") ' |
|||
"VALUES ($1, $2, $3, $4, $5, $6)", |
|||
self.id, self.pack_id, self.url, self.body, self.meta, self.order) |
|||
|
|||
def to_dict(self) -> Dict[str, Any]: |
|||
return { |
|||
**self.meta, |
|||
"body": self.body, |
|||
"url": self.url, |
|||
"id": self.id, |
|||
} |
@ -0,0 +1,56 @@ |
|||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
# Copyright (C) 2020 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 asyncpg import Connection |
|||
|
|||
from mautrix.util.async_db.upgrade import UpgradeTable |
|||
|
|||
upgrade_table = UpgradeTable() |
|||
|
|||
|
|||
@upgrade_table.register(description="Initial revision") |
|||
async def upgrade_v1(conn: Connection) -> None: |
|||
await conn.execute("""CREATE TABLE "user" ( |
|||
id TEXT PRIMARY KEY, |
|||
widget_secret TEXT NOT NULL, |
|||
homeserver_url TEXT NOT NULL |
|||
)""") |
|||
await conn.execute("""CREATE TABLE access_token ( |
|||
token_id SERIAL PRIMARY KEY, |
|||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, |
|||
token_hash BYTEA NOT NULL, |
|||
last_seen_ip TEXT, |
|||
last_seen_date TIMESTAMP |
|||
)""") |
|||
await conn.execute("""CREATE TABLE pack ( |
|||
id TEXT PRIMARY KEY, |
|||
owner TEXT REFERENCES "user"(id) ON DELETE SET NULL, |
|||
title TEXT NOT NULL, |
|||
meta JSONB NOT NULL |
|||
)""") |
|||
await conn.execute("""CREATE TABLE user_pack ( |
|||
user_id TEXT REFERENCES "user"(id) ON DELETE CASCADE, |
|||
pack_id TEXT REFERENCES pack(id) ON DELETE CASCADE, |
|||
"order" INT NOT NULL DEFAULT 0, |
|||
PRIMARY KEY (user_id, pack_id) |
|||
)""") |
|||
await conn.execute("""CREATE TABLE sticker ( |
|||
id TEXT PRIMARY KEY, |
|||
pack_id TEXT NOT NULL REFERENCES pack(id) ON DELETE CASCADE, |
|||
url TEXT NOT NULL, |
|||
body TEXT NOT NULL, |
|||
meta JSONB NOT NULL, |
|||
"order" INT NOT NULL DEFAULT 0 |
|||
)""") |
@ -0,0 +1,95 @@ |
|||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
# Copyright (C) 2020 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 Optional, List, ClassVar |
|||
import random |
|||
import string |
|||
|
|||
from attr import dataclass |
|||
import asyncpg |
|||
|
|||
from mautrix.types import UserID |
|||
|
|||
from .base import Base |
|||
from .pack import Pack |
|||
from .access_token import AccessToken |
|||
|
|||
|
|||
@dataclass(kw_only=True) |
|||
class User(Base): |
|||
token_charset: ClassVar[str] = string.ascii_letters + string.digits |
|||
|
|||
id: UserID |
|||
widget_secret: str |
|||
homeserver_url: str |
|||
|
|||
@classmethod |
|||
def _random_token(cls) -> str: |
|||
return "".join(random.choices(cls.token_charset, k=64)) |
|||
|
|||
@classmethod |
|||
def new(cls, id: UserID, homeserver_url: str) -> 'User': |
|||
return User(id=id, widget_secret=cls._random_token(), homeserver_url=homeserver_url) |
|||
|
|||
@classmethod |
|||
async def get(cls, id: UserID) -> Optional['User']: |
|||
q = 'SELECT id, widget_secret, homeserver_url FROM "user" WHERE id=$1' |
|||
row: asyncpg.Record = await cls.db.fetchrow(q, id) |
|||
if row is None: |
|||
return None |
|||
return cls(**row) |
|||
|
|||
async def regenerate_widget_secret(self) -> None: |
|||
self.widget_secret = self._random_token() |
|||
await self.db.execute('UPDATE "user" SET widget_secret=$1 WHERE id=$2', |
|||
self.widget_secret, self.id) |
|||
|
|||
async def set_homeserver_url(self, url: str) -> None: |
|||
self.homeserver_url = url |
|||
await self.db.execute('UPDATE "user" SET homeserver_url=$1 WHERE id=$2', url, self.id) |
|||
|
|||
async def new_access_token(self, ip: str) -> str: |
|||
token = self._random_token() |
|||
token_id = await AccessToken.insert(self.id, token, ip) |
|||
return f"{token_id}:{token}" |
|||
|
|||
async def delete(self) -> None: |
|||
await self.db.execute('DELETE FROM "user" WHERE id=$1', self.id) |
|||
|
|||
async def insert(self) -> None: |
|||
q = 'INSERT INTO "user" (id, widget_secret, homeserver_url) VALUES ($1, $2, $3)' |
|||
await self.db.execute(q, self.id, self.widget_secret, self.homeserver_url) |
|||
|
|||
async def get_packs(self) -> List[Pack]: |
|||
res = await self.db.fetch("SELECT id, owner, title, meta FROM user_pack " |
|||
"LEFT JOIN pack ON pack.id=user_pack.pack_id " |
|||
'WHERE user_id=$1 ORDER BY "order"', self.id) |
|||
return [Pack(**row) for row in res] |
|||
|
|||
async def get_pack(self, pack_id: str) -> Optional[Pack]: |
|||
row = await self.db.fetchrow("SELECT id, owner, title, meta FROM user_pack " |
|||
"LEFT JOIN pack ON pack.id=user_pack.pack_id " |
|||
"WHERE user_id=$1 AND pack_id=$2", self.id, pack_id) |
|||
if row is None: |
|||
return None |
|||
return Pack(**row) |
|||
|
|||
async def set_packs(self, packs: List[Pack]) -> None: |
|||
data = ((self.id, pack.id, order) |
|||
for order, pack in enumerate(packs)) |
|||
columns = ["user_id", "pack_id", "order"] |
|||
async with self.db.acquire() as conn, conn.transaction(): |
|||
await conn.execute("DELETE FROM user_pack WHERE user_id=$1", self.id) |
|||
await conn.copy_records_to_table("user_pack", records=data, columns=columns) |
@ -0,0 +1,74 @@ |
|||
# Postgres database URL for storing sticker packs and other things. |
|||
database: postgres://username:password@hostname/dbname |
|||
|
|||
# Settings for the actual HTTP server |
|||
server: |
|||
# The IP and port to listen to. |
|||
hostname: 0.0.0.0 |
|||
port: 29329 |
|||
# Public base URL where the server is visible. |
|||
public_url: https://example.com |
|||
# Override path from where to load UI resources. |
|||
# Set to false to using pkg_resources to find the path. |
|||
override_resource_path: false |
|||
# Whether or not to trust X-Forwarded-For headers for determining the request IP. |
|||
trust_forward_headers: false |
|||
|
|||
# Telegram configuration for downloading sticker packs. In the future, this will be client-side and |
|||
# none of this configuration will be necessary. |
|||
telegram_import: |
|||
# Create your own bot at https://t.me/BotFather |
|||
bot_token: null |
|||
|
|||
# Matrix homeserver access details. This is only used for uploading Telegram-imported stickers. |
|||
homeserver: |
|||
address: https://example.com |
|||
access_token: null |
|||
|
|||
# Permissions for who is allowed to use the sticker picker. |
|||
# |
|||
# Values are objects that should contain boolean values each permission: |
|||
# access - Access the sticker picker and use existing packs. |
|||
# create_packs - Create packs by uploading images. |
|||
# telegram_import - Create packs by importing from Telegram. Images are stored on |
|||
# |
|||
# Permission keys may be user IDs, server names or "*". If a server name or user ID permission |
|||
# doesn't specify some keys, they'll be inherited from the higher level. |
|||
permissions: |
|||
"*": |
|||
access: true |
|||
create_packs: true |
|||
telegram_import: false |
|||
"example.com": |
|||
telegram_import: true |
|||
|
|||
# Python logging configuration. |
|||
# |
|||
# See Configuration dictionary schema in the Python documentation for more info: |
|||
# https://docs.python.org/3.9/library/logging.config.html#configuration-dictionary-schema |
|||
logging: |
|||
version: 1 |
|||
formatters: |
|||
colored: |
|||
(): mautrix.util.logging.ColorFormatter |
|||
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s" |
|||
normal: |
|||
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s" |
|||
handlers: |
|||
file: |
|||
class: logging.handlers.RotatingFileHandler |
|||
formatter: normal |
|||
filename: ./sticker.server.log |
|||
maxBytes: 10485760 |
|||
backupCount: 10 |
|||
console: |
|||
class: logging.StreamHandler |
|||
formatter: colored |
|||
loggers: |
|||
mau: |
|||
level: DEBUG |
|||
aiohttp: |
|||
level: INFO |
|||
root: |
|||
level: DEBUG |
|||
handlers: [file, console] |
@ -0,0 +1 @@ |
|||
../../web/ |
@ -0,0 +1,50 @@ |
|||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
# Copyright (C) 2020 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 pkg_resources import resource_filename |
|||
from aiohttp import web |
|||
|
|||
from .api import packs_app, setup_app, integrations_app, init as init_api |
|||
from .static import StaticResource |
|||
from .config import Config |
|||
|
|||
|
|||
class Server: |
|||
config: Config |
|||
runner: web.AppRunner |
|||
app: web.Application |
|||
site: web.TCPSite |
|||
|
|||
def __init__(self, config: Config) -> None: |
|||
init_api(config) |
|||
self.config = config |
|||
self.app = web.Application() |
|||
self.app.add_subapp("/_matrix/integrations/v1", integrations_app) |
|||
self.app.add_subapp("/setup/api", setup_app) |
|||
self.app.add_subapp("/packs", packs_app) |
|||
|
|||
resource_path = (config["server.override_resource_path"] |
|||
or resource_filename("sticker.server", "frontend")) |
|||
self.app.router.register_resource(StaticResource("/", resource_path, name="frontend")) |
|||
self.runner = web.AppRunner(self.app) |
|||
|
|||
async def start(self) -> None: |
|||
await self.runner.setup() |
|||
self.site = web.TCPSite(self.runner, self.config["server.host"], |
|||
self.config["server.port"]) |
|||
await self.site.start() |
|||
|
|||
async def stop(self) -> None: |
|||
await self.runner.cleanup() |
@ -0,0 +1,106 @@ |
|||
# Simplified version of aiohttp's StaticResource with support for index.html |
|||
# https://github.com/aio-libs/aiohttp/blob/v3.6.2/aiohttp/web_urldispatcher.py#L496-L678 |
|||
# Licensed under Apache 2.0 |
|||
from typing import Callable, Awaitable, Tuple, Optional, Union, Dict, Set, Iterator, Any |
|||
from pathlib import Path, PurePath |
|||
|
|||
from aiohttp.web import (Request, StreamResponse, FileResponse, ResourceRoute, AbstractResource, |
|||
AbstractRoute, UrlMappingMatchInfo, HTTPNotFound, HTTPForbidden) |
|||
from aiohttp.abc import AbstractMatchInfo |
|||
from yarl import URL |
|||
|
|||
Handler = Callable[[Request], Awaitable[StreamResponse]] |
|||
|
|||
|
|||
class StaticResource(AbstractResource): |
|||
def __init__(self, prefix: str, directory: Union[str, PurePath], *, name: Optional[str] = None, |
|||
error_path: Optional[str] = "index.html", chunk_size: int = 256 * 1024) -> None: |
|||
super().__init__(name=name) |
|||
try: |
|||
directory = Path(directory).resolve() |
|||
if not directory.is_dir(): |
|||
raise ValueError("Not a directory") |
|||
except (FileNotFoundError, ValueError) as error: |
|||
raise ValueError(f"No directory exists at '{directory}'") from error |
|||
self._directory = directory |
|||
self._chunk_size = chunk_size |
|||
self._prefix = prefix |
|||
self._error_file = (directory / error_path) if error_path else None |
|||
|
|||
self._routes = { |
|||
"GET": ResourceRoute("GET", self._handle, self), |
|||
"HEAD": ResourceRoute("HEAD", self._handle, self), |
|||
} |
|||
|
|||
@property |
|||
def canonical(self) -> str: |
|||
return self._prefix |
|||
|
|||
def add_prefix(self, prefix: str) -> None: |
|||
assert prefix.startswith("/") |
|||
assert not prefix.endswith("/") |
|||
assert len(prefix) > 1 |
|||
self._prefix = prefix + self._prefix |
|||
|
|||
def raw_match(self, prefix: str) -> bool: |
|||
return False |
|||
|
|||
def url_for(self, *, filename: Union[str, Path]) -> URL: |
|||
if isinstance(filename, Path): |
|||
filename = str(filename) |
|||
while filename.startswith("/"): |
|||
filename = filename[1:] |
|||
return URL.build(path=f"{self._prefix}/{filename}") |
|||
|
|||
def get_info(self) -> Dict[str, Any]: |
|||
return { |
|||
"directory": self._directory, |
|||
"prefix": self._prefix, |
|||
} |
|||
|
|||
def set_options_route(self, handler: Handler) -> None: |
|||
if "OPTIONS" in self._routes: |
|||
raise RuntimeError("OPTIONS route was set already") |
|||
self._routes["OPTIONS"] = ResourceRoute("OPTIONS", handler, self) |
|||
|
|||
async def resolve(self, request: Request) -> Tuple[Optional[AbstractMatchInfo], Set[str]]: |
|||
path = request.rel_url.raw_path |
|||
method = request.method |
|||
allowed_methods = set(self._routes) |
|||
if not path.startswith(self._prefix): |
|||
return None, set() |
|||
|
|||
if method not in allowed_methods: |
|||
return None, allowed_methods |
|||
|
|||
return UrlMappingMatchInfo({ |
|||
"filename": URL.build(path=path[len(self._prefix):], encoded=True).path |
|||
}, self._routes[method]), allowed_methods |
|||
|
|||
def __len__(self) -> int: |
|||
return len(self._routes) |
|||
|
|||
def __iter__(self) -> Iterator[AbstractRoute]: |
|||
return iter(self._routes.values()) |
|||
|
|||
async def _handle(self, request: Request) -> StreamResponse: |
|||
try: |
|||
filename = Path(request.match_info["filename"]) |
|||
if not filename.anchor: |
|||
filepath = (self._directory / filename).resolve() |
|||
if filepath.is_file(): |
|||
return FileResponse(filepath, chunk_size=self._chunk_size) |
|||
index_path = (self._directory / filename / "index.html").resolve() |
|||
if index_path.is_file(): |
|||
return FileResponse(index_path, chunk_size=self._chunk_size) |
|||
except (ValueError, FileNotFoundError) as error: |
|||
raise HTTPNotFound() from error |
|||
except HTTPForbidden: |
|||
raise |
|||
except Exception as error: |
|||
request.app.logger.exception("Error while trying to serve static file") |
|||
raise HTTPNotFound() from error |
|||
|
|||
def __repr__(self) -> str: |
|||
name = f"'{self.name}'" if self.name is not None else "" |
|||
return f"<StaticResource {name} {self._prefix} -> {self._directory!r}>" |
@ -0,0 +1,196 @@ |
|||
{ |
|||
"env": { |
|||
"es6": true, |
|||
"browser": true |
|||
}, |
|||
"extends": [ |
|||
"eslint:recommended", |
|||
"plugin:import/errors", |
|||
"plugin:import/warnings" |
|||
], |
|||
"parser": "@babel/eslint-parser", |
|||
"parserOptions": { |
|||
"sourceType": "module", |
|||
"requireConfigFile": false |
|||
}, |
|||
"plugins": [ |
|||
"import", |
|||
"react-hooks" |
|||
], |
|||
"rules": { |
|||
"indent": [ |
|||
"error", |
|||
"tab" |
|||
], |
|||
"linebreak-style": [ |
|||
"error", |
|||
"unix" |
|||
], |
|||
"quotes": [ |
|||
"error", |
|||
"double", |
|||
{ |
|||
"avoidEscape": true |
|||
} |
|||
], |
|||
"semi": [ |
|||
"error", |
|||
"never" |
|||
], |
|||
"comma-dangle": [ |
|||
"error", |
|||
"only-multiline" |
|||
], |
|||
"comma-spacing": [ |
|||
"error", |
|||
{ |
|||
"after": true |
|||
} |
|||
], |
|||
"eol-last": [ |
|||
"error", |
|||
"always" |
|||
], |
|||
"no-trailing-spaces": [ |
|||
"error" |
|||
], |
|||
"camelcase": [ |
|||
"error", |
|||
{ |
|||
"properties": "always" |
|||
} |
|||
], |
|||
"import/no-unresolved": "off", |
|||
"import/named": "error", |
|||
"import/namespace": "error", |
|||
"import/default": "error", |
|||
"import/export": "error", |
|||
"import/order": [ |
|||
"error", |
|||
{ |
|||
"newlines-between": "always", |
|||
"pathGroups": [ |
|||
{ |
|||
"pattern": "{.,..,../..,../../..,../../../..}/lib/**", |
|||
"group": "external" |
|||
} |
|||
], |
|||
"groups": [ |
|||
"builtin", |
|||
"external", |
|||
[ |
|||
"internal", |
|||
"sibling", |
|||
"parent" |
|||
], |
|||
"index" |
|||
] |
|||
} |
|||
], |
|||
"max-len": [ |
|||
"error", |
|||
{ |
|||
"code": 100 |
|||
} |
|||
], |
|||
"prefer-const": [ |
|||
"error", |
|||
{ |
|||
"destructuring": "all", |
|||
"ignoreReadBeforeAssign": false |
|||
} |
|||
], |
|||
"arrow-spacing": [ |
|||
"error", |
|||
{ |
|||
"before": true, |
|||
"after": true |
|||
} |
|||
], |
|||
"space-before-blocks": [ |
|||
"error", |
|||
"always" |
|||
], |
|||
"object-curly-spacing": [ |
|||
"error", |
|||
"always" |
|||
], |
|||
"array-bracket-spacing": [ |
|||
"error", |
|||
"never" |
|||
], |
|||
"space-in-parens": [ |
|||
"error", |
|||
"never" |
|||
], |
|||
"keyword-spacing": [ |
|||
"error", |
|||
{ |
|||
"before": true, |
|||
"after": true |
|||
} |
|||
], |
|||
"key-spacing": [ |
|||
"error", |
|||
{ |
|||
"afterColon": true |
|||
} |
|||
], |
|||
"template-curly-spacing": [ |
|||
"error", |
|||
"never" |
|||
], |
|||
"no-empty": [ |
|||
"error", |
|||
{ |
|||
"allowEmptyCatch": true |
|||
} |
|||
], |
|||
"arrow-body-style": [ |
|||
"error", |
|||
"as-needed" |
|||
], |
|||
"no-multiple-empty-lines": [ |
|||
"error", |
|||
{ |
|||
"max": 1, |
|||
"maxBOF": 0, |
|||
"maxEOF": 0 |
|||
} |
|||
], |
|||
"no-prototype-builtins": "off", |
|||
"dot-notation": [ |
|||
"error", |
|||
{ |
|||
"allowKeywords": true |
|||
} |
|||
], |
|||
"quote-props": [ |
|||
"error", |
|||
"as-needed" |
|||
], |
|||
"no-multi-spaces": [ |
|||
"error" |
|||
], |
|||
"space-infix-ops": [ |
|||
"error" |
|||
], |
|||
"object-curly-newline": [ |
|||
"error", |
|||
{ |
|||
"multiline": false, |
|||
"consistent": true |
|||
} |
|||
], |
|||
"no-mixed-operators": [ |
|||
"error" |
|||
], |
|||
"no-extra-parens": [ |
|||
"error", |
|||
"all", |
|||
{ |
|||
"nestedBinaryExpressions": false |
|||
} |
|||
] |
|||
} |
|||
} |
3
web/lib/common/preact.module-9c264606.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
9
web/lib/htm/preact.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,5 @@ |
|||
import { n } from '../common/preact.module-9c264606.js'; |
|||
|
|||
var t,u,r,o=0,i=[],c=n.__r,f=n.diffed,e=n.__c,a=n.unmount;function v(t,r){n.__h&&n.__h(u,t,o||r),o=0;var i=u.__H||(u.__H={__:[],__h:[]});return t>=i.__.length&&i.__.push({}),i.__[t]}function m(n){return o=1,p(k,n)}function p(n,r,o){var i=v(t++,2);return i.t=n,i.__c||(i.__=[o?o(r):k(void 0,r),function(n){var t=i.t(i.__[0],n);i.__[0]!==t&&(i.__=[t,i.__[1]],i.__c.setState({}));}],i.__c=u),i.__}function y(r,o){var i=v(t++,3);!n.__s&&j(i.__H,o)&&(i.__=r,i.__H=o,u.__H.__h.push(i));}function l(r,o){var i=v(t++,4);!n.__s&&j(i.__H,o)&&(i.__=r,i.__H=o,u.__h.push(i));}function h(n){return o=5,_(function(){return {current:n}},[])}function s(n,t,u){o=6,l(function(){"function"==typeof n?n(t()):n&&(n.current=t());},null==u?u:u.concat(n));}function _(n,u){var r=v(t++,7);return j(r.__H,u)&&(r.__=n(),r.__H=u,r.__h=n),r.__}function A(n,t){return o=8,_(function(){return n},t)}function F(n){var r=u.context[n.__c],o=v(t++,9);return o.__c=n,r?(null==o.__&&(o.__=!0,r.sub(u)),r.props.value):n.__}function T(t,u){n.useDebugValue&&n.useDebugValue(u?u(t):t);}function d(n){var r=v(t++,10),o=m();return r.__=n,u.componentDidCatch||(u.componentDidCatch=function(n){r.__&&r.__(n),o[1](n);}),[o[0],function(){o[1](void 0);}]}function q(){i.forEach(function(t){if(t.__P)try{t.__H.__h.forEach(b),t.__H.__h.forEach(g),t.__H.__h=[];}catch(u){t.__H.__h=[],n.__e(u,t.__v);}}),i=[];}n.__r=function(n){c&&c(n),t=0;var r=(u=n.__c).__H;r&&(r.__h.forEach(b),r.__h.forEach(g),r.__h=[]);},n.diffed=function(t){f&&f(t);var u=t.__c;u&&u.__H&&u.__H.__h.length&&(1!==i.push(u)&&r===n.requestAnimationFrame||((r=n.requestAnimationFrame)||function(n){var t,u=function(){clearTimeout(r),x&&cancelAnimationFrame(t),setTimeout(n);},r=setTimeout(u,100);x&&(t=requestAnimationFrame(u));})(q));},n.__c=function(t,u){u.some(function(t){try{t.__h.forEach(b),t.__h=t.__h.filter(function(n){return !n.__||g(n)});}catch(r){u.some(function(n){n.__h&&(n.__h=[]);}),u=[],n.__e(r,t.__v);}}),e&&e(t,u);},n.unmount=function(t){a&&a(t);var u=t.__c;if(u&&u.__H)try{u.__H.__.forEach(b);}catch(t){n.__e(t,u.__v);}};var x="function"==typeof requestAnimationFrame;function b(n){"function"==typeof n.__c&&n.__c();}function g(n){n.__c=n.__();}function j(n,t){return !n||n.length!==t.length||t.some(function(t,u){return t!==n[u]})}function k(n,t){return "function"==typeof t?t(n):t} |
|||
|
|||
export { A as useCallback, F as useContext, T as useDebugValue, y as useEffect, d as useErrorBoundary, s as useImperativeHandle, l as useLayoutEffect, _ as useMemo, p as useReducer, h as useRef, m as useState }; |
@ -0,0 +1,23 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="utf-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no"> |
|||
<title>Setup - Maunium sticker picker</title> |
|||
|
|||
<link rel="modulepreload" href="../lib/htm/preact.js"/> |
|||
<link rel="modulepreload" href="../lib/preact/hooks.js"/> |
|||
<link rel="modulepreload" href="src/Spinner.js"/> |
|||
<link rel="modulepreload" href="src/Button.js"/> |
|||
|
|||
<link rel="stylesheet" href="../style/setup.css"/> |
|||
<link rel="stylesheet" href="../style/setup-login.css"/> |
|||
<link rel="stylesheet" href="../style/spinner.css"/> |
|||
<link rel="stylesheet" href="../style/button.css"/> |
|||
<script src="../src/setup/index.js" type="module"></script> |
|||
<script nomodule>document.body.innerText = "This setup page requires modern JavaScript"</script> |
|||
</head> |
|||
<body> |
|||
<noscript>This setup page requires JavaScript</noscript> |
|||
</body> |
|||
</html> |
@ -0,0 +1,30 @@ |
|||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|||
// Copyright (C) 2020 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 { html } from "../lib/htm/preact.js" |
|||
|
|||
const Button = ({ |
|||
type = "button", class: className = "", children, |
|||
variant = "filled", size = "normal", |
|||
...customProps |
|||
}) => { |
|||
const props = { |
|||
class: `mau-button variant-${variant} size-${size} ${className}`, |
|||
type, ...customProps, |
|||
} |
|||
return html`<button ...${props}>${children}</button>` |
|||
} |
|||
|
|||
export default Button |
@ -0,0 +1,215 @@ |
|||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|||
// Copyright (C) 2020 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 { useEffect, useLayoutEffect, useRef, useState } from "../../lib/preact/hooks.js" |
|||
import { html } from "../../lib/htm/preact.js" |
|||
|
|||
import { |
|||
getLoginFlows, |
|||
loginMatrix, |
|||
requestIntegrationToken, |
|||
requestOpenIDToken, |
|||
resolveWellKnown, |
|||
} from "./matrix-api.js" |
|||
import Button from "../Button.js" |
|||
import Spinner from "../Spinner.js" |
|||
|
|||
const query = Object.fromEntries(location.search |
|||
.substr(1).split("&") |
|||
.map(part => part.split("=")) |
|||
.map(([key, value = ""]) => [key, value])) |
|||
|
|||
const LoginView = ({ onLoggedIn }) => { |
|||
const usernameWrapperRef = useRef() |
|||
const usernameRef = useRef() |
|||
const serverRef = useRef() |
|||
const passwordRef = useRef() |
|||
const previousServerValue = useRef() |
|||
const [loading, setLoading] = useState(false) |
|||
const [userIDFocused, setUserIDFocused] = useState(false) |
|||
const [supportedFlows, setSupportedFlows] = useState(["m.login.password"]) |
|||
const [username, setUsername] = useState("") |
|||
const [server, setServer] = useState("") |
|||
const [serverURL, setServerURL] = useState("") |
|||
const [password, setPassword] = useState("") |
|||
const [error, setError] = useState(null) |
|||
|
|||
const keyDown = evt => { |
|||
if ((evt.target.name === "username" && evt.key === ":") || evt.key === "Enter") { |
|||
if (evt.target.name === "username") { |
|||
serverRef.current.focus() |
|||
} else if (evt.target.name === "server") { |
|||
passwordRef.current.focus() |
|||
} |
|||
evt.preventDefault() |
|||
} else if (evt.target.name === "server" && !evt.target.value && evt.key === "Backspace") { |
|||
usernameRef.current.focus() |
|||
} |
|||
} |
|||
|
|||
const paste = evt => { |
|||
if (usernameRef.current.value !== "" || serverRef.current.value !== "") { |
|||
return |
|||
} |
|||
|
|||
let data = evt.clipboardData.getData("text") |
|||
if (data.startsWith("@")) { |
|||
data = data.substr(1) |
|||
} |
|||
const separator = data.indexOf(":") |
|||
if (separator === -1) { |
|||
setUsername(data) |
|||
} else { |
|||
setUsername(data.substr(0, separator)) |
|||
setServer(data.substr(separator + 1)) |
|||
serverRef.current.focus() |
|||
} |
|||
evt.preventDefault() |
|||
} |
|||
|
|||
useLayoutEffect(() => usernameRef.current.focus(), []) |
|||
const onFocus = () => setUserIDFocused(true) |
|||
const onBlur = () => { |
|||
setUserIDFocused(false) |
|||
if (previousServerValue.current !== server && server) { |
|||
previousServerValue.current = server |
|||
setSupportedFlows(null) |
|||
setError(null) |
|||
resolveWellKnown(server).then(url => { |
|||
setServerURL(url) |
|||
localStorage.mxServerName = server |
|||
localStorage.mxHomeserver = url |
|||
return getLoginFlows(url) |
|||
}).then(flows => { |
|||
setSupportedFlows(flows) |
|||
}).catch(err => { |
|||
setError(err.message) |
|||
setSupportedFlows(["m.login.password"]) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
useEffect(() => { |
|||
if (localStorage.mxHomeserver && query.loginToken) { |
|||
console.log("Found homeserver in localstorage and loginToken in query, " + |
|||
"attempting SSO token login") |
|||
setError(null) |
|||
setLoading(true) |
|||
submit(query.loginToken, localStorage.mxHomeserver) |
|||
.catch(err => console.error("Fatal error:", err)) |
|||
.finally(() => setLoading(false)) |
|||
const url = new URL(location.href) |
|||
url.searchParams.delete("loginToken") |
|||
history.replaceState({}, document.title, url.toString()) |
|||
} |
|||
}, []) |
|||
|
|||
const submit = async (token, serverURLOverride) => { |
|||
let authInfo |
|||
if (token) { |
|||
authInfo = { |
|||
type: "m.login.token", |
|||
token, |
|||
} |
|||
} else { |
|||
authInfo = { |
|||
type: "m.login.password", |
|||
identifier: { |
|||
type: "m.id.user", |
|||
user: username, |
|||
}, |
|||
password, |
|||
} |
|||
} |
|||
try { |
|||
const actualServerURL = serverURLOverride || serverURL |
|||
const [accessToken, userID, realURL] = await loginMatrix(actualServerURL, authInfo) |
|||
console.log(userID, realURL) |
|||
const openIDToken = await requestOpenIDToken(realURL, userID, accessToken) |
|||
console.log(openIDToken) |
|||
const integrationData = await requestIntegrationToken(openIDToken) |
|||
console.log(integrationData) |
|||
localStorage.mxAccessToken = accessToken |
|||
localStorage.mxUserID = userID |
|||
localStorage.accessToken = integrationData.token |
|||
onLoggedIn() |
|||
} catch (err) { |
|||
setError(err.message) |
|||
} |
|||
} |
|||
|
|||
const onSubmit = evt => { |
|||
evt.preventDefault() |
|||
setError(null) |
|||
setLoading(true) |
|||
submit() |
|||
.catch(err => console.error("Fatal error:", err)) |
|||
.finally(() => setLoading(false)) |
|||
} |
|||
|
|||
const startSSOLogin = () => { |
|||
const redir = encodeURIComponent(location.href) |
|||
location.href = `${serverURL}/_matrix/client/r0/login/sso/redirect?redirectUrl=${redir}` |
|||
} |
|||
|
|||
const usernameWrapperClick = evt => evt.target === usernameWrapperRef.current |
|||
&& usernameRef.current.focus() |
|||
|
|||
const ssoButton = html`
|
|||
<${Button} type="button" disabled=${!serverURL} onClick=${startSSOLogin} |
|||
title=${!serverURL ? "Enter your server name before using SSO" : undefined}> |
|||
${loading ? html`<${Spinner} size=30/>` : "Log in with SSO"} |
|||
</Button> |
|||
`
|
|||
|
|||
const disablePwLogin = !username || !server || !serverURL |
|||
const loginButton = html`
|
|||
<${Button} type="submit" disabled=${disablePwLogin} |
|||
title=${disablePwLogin ? "Fill out the form before submitting" : undefined}> |
|||
${loading ? html`<${Spinner} size=30/>` : "Log in"} |
|||
</Button> |
|||
`
|
|||
|
|||
return html`
|
|||
<main class="login-view"> |
|||
<form class="login-box ${error ? "has-error" : ""}" onSubmit=${onSubmit}> |
|||
<h1>Stickerpicker setup</h1> |
|||
<div class="username input ${userIDFocused ? "focus" : ""}" |
|||
ref=${usernameWrapperRef} onClick=${usernameWrapperClick}> |
|||
<span onClick=${() => usernameRef.current.focus()}>@</span> |
|||
<input type="text" placeholder="username" name="username" value=${username} |
|||
onChange=${evt => setUsername(evt.target.value)} ref=${usernameRef} |
|||
onKeyDown=${keyDown} onFocus=${onFocus} onBlur=${onBlur} |
|||
onPaste=${paste}/> |
|||
<span onClick=${() => serverRef.current.focus()}>:</span> |
|||
<input type="text" placeholder="example.com" name="server" value=${server} |
|||
onChange=${evt => setServer(evt.target.value)} ref=${serverRef} |
|||
onKeyDown=${keyDown} onFocus=${onFocus} onBlur=${onBlur}/> |
|||
</div> |
|||
<input type="password" placeholder="password" name="password" value=${password} |
|||
class="password input" ref=${passwordRef} |
|||
disabled=${supportedFlows && !supportedFlows.includes("m.login.password")} |
|||
onChange=${evt => setPassword(evt.target.value)}/> |
|||
<div class="button-group"> |
|||
${supportedFlows === null && html`<${Spinner} green size=30 />`} |
|||
${supportedFlows?.includes("m.login.sso") && ssoButton} |
|||
${supportedFlows?.includes("m.login.password") && loginButton} |
|||
</div> |
|||
${error && html`<div class="error">${error}</div>`} |
|||
</form> |
|||
</main>` |
|||
} |
|||
|
|||
export default LoginView |
@ -0,0 +1,20 @@ |
|||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|||
// Copyright (C) 2020 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 { html, render } from "../../lib/htm/preact.js" |
|||
|
|||
import LoginView from "./LoginView.js" |
|||
|
|||
render(html`<${LoginView} onLoggedIn=${() => console.log("Logged in")}/>`, document.body) |
@ -0,0 +1,102 @@ |
|||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|||
// Copyright (C) 2020 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 { tryFetch, integrationPrefix } from "./tryGet.js" |
|||
|
|||
export const resolveWellKnown = async (server) => { |
|||
try { |
|||
const resp = await fetch(`https://${server}/.well-known/matrix/client`) |
|||
const data = await resp.json() |
|||
let url = data["m.homeserver"].base_url |
|||
if (url.endsWith("/")) { |
|||
url = url.slice(0, -1) |
|||
} |
|||
return url |
|||
} catch (err) { |
|||
console.error("Resolution failed:", err) |
|||
throw new Error(`Failed to resolve Matrix URL for ${server}`) |
|||
} |
|||
} |
|||
|
|||
export const getLoginFlows = async (address) => { |
|||
const data = await tryFetch(`${address}/_matrix/client/r0/login`, {}, |
|||
{ service: address, requestType: "get login flows" }) |
|||
const flows = [] |
|||
for (const flow of data.flows) { |
|||
flows.push(flow.type) |
|||
} |
|||
return flows |
|||
} |
|||
|
|||
export const loginMatrix = async (address, authInfo) => { |
|||
const data = await tryFetch(`${address}/_matrix/client/r0/login`, { |
|||
method: "POST", |
|||
body: JSON.stringify({ |
|||
...authInfo, |
|||
/* eslint-disable camelcase */ |
|||
device_id: "maunium-stickerpicker", |
|||
initial_device_display_name: "maunium-stickerpicker", |
|||
/* eslint-enable camelcase */ |
|||
}), |
|||
headers: { |
|||
"Content-Type": "application/json", |
|||
}, |
|||
}, { |
|||
service: address, |
|||
requestType: "login", |
|||
}) |
|||
if (data.well_known && data.well_known["m.homeserver"]) { |
|||
address = data.well_known["m.homeserver"].base_url || address |
|||
if (address.endsWith("/")) { |
|||
address = address.slice(0, -1) |
|||
} |
|||
} |
|||
return [data.access_token, data.user_id, address] |
|||
} |
|||
|
|||
export const requestOpenIDToken = (address, userID, accessToken) => tryFetch( |
|||
`${address}/_matrix/client/r0/user/${userID}/openid/request_token`, |
|||
{ |
|||
method: "POST", |
|||
body: "{}", |
|||
headers: { |
|||
Authorization: `Bearer ${accessToken}`, |
|||
"Content-Type": "application/json", |
|||
}, |
|||
}, |
|||
{ |
|||
service: "OpenID", |
|||
requestType: "token", |
|||
}, |
|||
) |
|||
|
|||
export const requestIntegrationToken = tokenData => tryFetch( |
|||
`${integrationPrefix}/account/register`, { |
|||
method: "POST", |
|||
body: JSON.stringify(tokenData), |
|||
headers: { |
|||
"Content-Type": "application/json", |
|||
}, |
|||
}, |
|||
{ |
|||
service: "sticker server", |
|||
requestType: "register", |
|||
}) |
|||
|
|||
export const logout = () => tryFetch(`${integrationPrefix}/account/logout`, { method: "POST" }, { |
|||
service: "sticker server", |
|||
requestType: "logout", |
|||
}) |
@ -0,0 +1,67 @@ |
|||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|||
// Copyright (C) 2020 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 integrationPrefix = "../_matrix/integrations/v1" |
|||
|
|||
export const queryToURL = (url, query) => { |
|||
if (!Array.isArray(query)) { |
|||
query = Object.entries(query) |
|||
} |
|||
query = query.map(([key, value]) => |
|||
[key, typeof value === "string" ? value : JSON.stringify(value)]) |
|||
url = `${url}?${new URLSearchParams(query)}` |
|||
return url |
|||
} |
|||
|
|||
export const tryFetch = async (url, options, reqInfo) => { |
|||
if (options.query) { |
|||
url = queryToURL(url, options.query) |
|||
delete options.query |
|||
} |
|||
options.headers = { |
|||
Authorization: `Bearer ${localStorage.accessToken}`, |
|||
...options.headers, |
|||
} |
|||
const reqName = `${reqInfo.service} ${reqInfo.requestType}` |
|||
let resp |
|||
try { |
|||
resp = await fetch(url, options) |
|||
} catch (err) { |
|||
console.error(reqName, "request failed:", err) |
|||
throw new Error(`Failed to contact ${reqInfo.service}`) |
|||
} |
|||
if (resp.status === 502) { |
|||
console.error("Unexpected", reqName, "request bad gateway:", await resp.text()) |
|||
throw new Error(`Failed to contact ${reqInfo.service}`) |
|||
} |
|||
if (reqInfo.raw) { |
|||
return resp |
|||
} else if (resp.status === 204) { |
|||
return |
|||
} |
|||
let data |
|||
try { |
|||
data = await resp.json() |
|||
} catch (err) { |
|||
console.error(reqName, "request JSON parse failed:", err) |
|||
throw new Error(`Invalid response from ${reqInfo.service}`) |
|||
} |
|||
if (resp.status >= 400) { |
|||
console.error("Unexpected", reqName, "request status:", resp.status, data) |
|||
throw new Error(data.error || data.message || `Invalid response from ${reqInfo.service}`) |
|||
} |
|||
return data |
|||
} |
@ -0,0 +1 @@ |
|||
button.mau-button{cursor:pointer;margin:.5rem 0;border-radius:.25rem;font-size:1rem;box-sizing:border-box;padding:0}button.mau-button:disabled{cursor:default}button.mau-button.size-thick{height:3rem}button.mau-button.size-normal{height:2.5rem}button.mau-button.size-thin{height:2rem}button.mau-button.variant-filled{background-color:#2e7d32;color:#fff;border:none}button.mau-button.variant-filled:hover{background-color:#005005}button.mau-button.variant-filled:disabled{background-color:#CCC;color:#212121}button.mau-button.variant-outlined{background-color:#fff;border:2px solid #2e7d32;color:#2e7d32}button.mau-button.variant-outlined:hover{background-color:#60ad5e}button.mau-button.variant-outlined:disabled{background-color:#fff;border-color:#CCC} |
@ -0,0 +1,60 @@ |
|||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
// Copyright (C) 2020 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 theme.sass |
|||
|
|||
button.mau-button |
|||
cursor: pointer |
|||
margin: .5rem 0 |
|||
border-radius: .25rem |
|||
font-size: 1rem |
|||
box-sizing: border-box |
|||
padding: 0 |
|||
|
|||
&:disabled |
|||
cursor: default |
|||
|
|||
&.size-thick |
|||
height: 3rem |
|||
|
|||
&.size-normal |
|||
height: 2.5rem |
|||
|
|||
&.size-thin |
|||
height: 2rem |
|||
|
|||
&.variant-filled |
|||
background-color: $primary |
|||
color: $primaryContrastText |
|||
border: none |
|||
|
|||
&:hover |
|||
background-color: $primaryDark |
|||
|
|||
&:disabled |
|||
background-color: $disabled |
|||
color: $text |
|||
|
|||
&.variant-outlined |
|||
background-color: $background |
|||
border: 2px solid $primary |
|||
color: $primary |
|||
|
|||
&:hover |
|||
background-color: $primaryLight |
|||
|
|||
&:disabled |
|||
background-color: $background |
|||
border-color: $disabled |
@ -0,0 +1 @@ |
|||
main.login-view{position:fixed;top:0;bottom:0;right:0;left:0;background-color:#2e7d32;display:flex;justify-content:space-around}main.login-view form.login-box{background-color:#fff;width:25rem;height:22.5rem;padding:2.5rem 2.5rem 2rem;margin-top:3rem;border-radius:.25rem;box-sizing:border-box;display:flex;flex-direction:column}main.login-view form.login-box.has-error{min-height:27rem;height:auto;margin-bottom:auto}main.login-view form.login-box h1{color:#2e7d32;margin:.5rem auto 3rem;font-size:1.5rem}main.login-view form.login-box .input{margin:.5rem 0;border-radius:.25rem;border:1px solid #DDD;padding:1px}main.login-view form.login-box .input:hover,main.login-view form.login-box .input:focus,main.login-view form.login-box .input.focus{border-color:#2e7d32}main.login-view form.login-box .input:focus,main.login-view form.login-box .input.focus{border-width:2px;padding:0}main.login-view form.login-box .username{display:flex;cursor:text}main.login-view form.login-box .username>input{border:none;padding:.75rem .125rem;color:#212121;min-width:0;font-size:1rem}main.login-view form.login-box .username>input:last-of-type{padding-right:.5rem;border-radius:0 .25rem .25rem 0}main.login-view form.login-box .username>input:focus{outline:none}main.login-view form.login-box .username>span{user-select:none;padding:.75rem 0;color:#212121}main.login-view form.login-box .username>span:first-of-type{padding-left:.5rem}main.login-view form.login-box .password{font-size:1rem;margin:.5rem 0;border-radius:.25rem;border:1px solid #DDD;padding:calc(.75rem + 1px) 1rem;box-sizing:border-box}main.login-view form.login-box .password:hover:not(:disabled),main.login-view form.login-box .password:focus:not(:disabled){border-color:#2e7d32}main.login-view form.login-box .password:focus{padding:0.75rem calc(1rem - 1px);border-width:2px;outline:none}main.login-view form.login-box .button-group{display:flex;gap:4px}main.login-view form.login-box .button-group button{width:100%}main.login-view form.login-box .error{padding:1rem;border-radius:.25rem;border:2px solid #B71C1C;background-color:#F7A9A1;margin:.5rem 0;width:100%;box-sizing:border-box} |
@ -0,0 +1,105 @@ |
|||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
// Copyright (C) 2020 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 theme.sass |
|||
|
|||
main.login-view |
|||
position: fixed |
|||
top: 0 |
|||
bottom: 0 |
|||
right: 0 |
|||
left: 0 |
|||
background-color: $primary |
|||
display: flex |
|||
justify-content: space-around |
|||
|
|||
form.login-box |
|||
background-color: $background |
|||
width: 25rem |
|||
height: 22.5rem |
|||
padding: 2.5rem 2.5rem 2rem |
|||
margin-top: 3rem |
|||
border-radius: .25rem |
|||
box-sizing: border-box |
|||
display: flex |
|||
flex-direction: column |
|||
|
|||
&.has-error |
|||
min-height: 27rem |
|||
height: auto |
|||
margin-bottom: auto |
|||
|
|||
h1 |
|||
color: $primary |
|||
margin: .5rem auto 3rem |
|||
font-size: 1.5rem |
|||
|
|||
.input |
|||
margin: .5rem 0 |
|||
border-radius: .25rem |
|||
border: 1px solid $border |
|||
padding: 1px |
|||
|
|||
&:hover, &:focus, &.focus |
|||
border-color: $primary |
|||
|
|||
&:focus, &.focus |
|||
border-width: 2px |
|||
padding: 0 |
|||
|
|||
.username |
|||
display: flex |
|||
cursor: text |
|||
|
|||
& > input |
|||
border: none |
|||
padding: .75rem .125rem |
|||
color: $text |
|||
min-width: 0 |
|||
font-size: 1rem |
|||
|
|||
&:last-of-type |
|||
padding-right: .5rem |
|||
border-radius: 0 .25rem .25rem 0 |
|||
|
|||
&:focus |
|||
outline: none |
|||
|
|||
& > span |
|||
user-select: none |
|||
padding: .75rem 0 |
|||
color: $text |
|||
|
|||
&:first-of-type |
|||
padding-left: .5rem |
|||
|
|||
.password |
|||
@include input |
|||
|
|||
.button-group |
|||
display: flex |
|||
gap: 4px |
|||
|
|||
button |
|||
width: 100% |
|||
|
|||
.error |
|||
padding: 1rem |
|||
border-radius: .25rem |
|||
border: 2px solid $errorDark |
|||
background-color: $error |
|||
margin: .5rem 0 |
|||
width: 100% |
|||
box-sizing: border-box |
@ -0,0 +1 @@ |
|||
body{font-family:sans-serif} |
@ -0,0 +1,18 @@ |
|||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget. |
|||
// Copyright (C) 2020 Tulir Asokan |
|||
// |
|||
// This program is free software: you can redistribute it and/or modify |
|||
// it under the terms of the GNU Affero General Public License as published by |
|||
// the Free Software Foundation, either version 3 of the License, or |
|||
// (at your option) any later version. |
|||
// |
|||
// This program is distributed in the hope that it will be useful, |
|||
// but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
// GNU Affero General Public License for more details. |
|||
// |
|||
// You should have received a copy of the GNU Affero General Public License |
|||
// along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
|
|||
body |
|||
font-family: sans-serif |
@ -0,0 +1,33 @@ |
|||
// Material UI green 800 |
|||
$primary: #2e7d32 |
|||
$primaryDark: #005005 |
|||
$primaryLight: #60ad5e |
|||
// Material UI blue 700 |
|||
$secondary: #1976d2 |
|||
$secondaryDark: #004ba0 |
|||
$secondaryLight: #63a4ff |
|||
|
|||
$error: #F7A9A1 |
|||
$errorDark: #B71C1C |
|||
|
|||
$primaryContrastText: white |
|||
$background: white |
|||
$text: #212121 |
|||
$border: #DDD |
|||
$disabled: #CCC |
|||
|
|||
@mixin input |
|||
font-size: 1rem |
|||
margin: .5rem 0 |
|||
border-radius: .25rem |
|||
border: 1px solid $border |
|||
padding: calc(.75rem + 1px) 1rem |
|||
box-sizing: border-box |
|||
|
|||
&:hover:not(:disabled), &:focus:not(:disabled) |
|||
border-color: $primary |
|||
|
|||
&:focus |
|||
padding: .75rem calc(1rem - 1px) |
|||
border-width: 2px |
|||
outline: none |
1478
web/yarn.lock
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue