Add server with basic auth stuff

This commit is contained in:
Tulir Asokan 2020-10-31 21:53:46 +02:00
parent d3adedf3df
commit 9151f4cb6d
54 changed files with 3415 additions and 419 deletions

19
.editorconfig Normal file
View File

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

2
.gitignore vendored
View File

@ -12,3 +12,5 @@ web/lib/import-map.json
*.session
/*.json
*.bak
*.log
config.yaml

4
MANIFEST.in Normal file
View File

@ -0,0 +1,4 @@
include README.md
include LICENSE
include requirements.txt
include optional-requirements.txt

10
optional-requirements.txt Normal file
View File

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

View File

@ -1,6 +1,6 @@
aiohttp
yarl
aiohttp>=3,<4
yarl>=1,<2
pillow
telethon
telethon>=1.16
cryptg
python-magic

View File

@ -5,6 +5,19 @@ from sticker.get_version import git_tag, git_revision, version, linkified_versio
with open("requirements.txt") as reqs:
install_requires = reqs.read().splitlines()
with open("optional-requirements.txt") as reqs:
extras_require = {}
current = []
for line in reqs.read().splitlines():
if line.startswith("#/"):
extras_require[line[2:]] = current = []
elif not line or line.startswith("#"):
continue
else:
current.append(line)
extras_require["all"] = list({dep for deps in extras_require.values() for dep in deps})
try:
long_desc = open("README.md").read()
except IOError:
@ -34,6 +47,7 @@ setuptools.setup(
packages=setuptools.find_packages(),
install_requires=install_requires,
extras_require=extras_require,
python_requires="~=3.6",
classifiers=[
@ -45,9 +59,17 @@ setuptools.setup(
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
],
entry_points={"console_scripts": [
"sticker-import=sticker.import:cmd",
"sticker-pack=sticker.pack:cmd",
"sticker-server=sticker.server:cmd",
]},
package_data={"sticker.server": [
"example-config.yaml",
"frontend/index.html", "frontend/setup/index.html",
"frontend/src/*", "frontend/lib/*/*.js", "frontend/res/*", "frontend/style/*.css",
]}
)

View File

@ -117,6 +117,8 @@ async def reupload_pack(client: TelegramClient, pack: StickerSetFull, output_dir
pack_url_regex = re.compile(r"^(?:(?:https?://)?(?:t|telegram)\.(?:me|dog)/addstickers/)?"
r"([A-Za-z0-9-_]+)"
r"(?:\.json)?$")
api_id = 298751
api_hash = "cb676d6bae20553c9996996a8f52b4d7"
parser = argparse.ArgumentParser()
@ -132,7 +134,7 @@ parser.add_argument("pack", help="Sticker pack URLs to import", action="append",
async def main(args: argparse.Namespace) -> None:
await matrix.load_config(args.config)
client = TelegramClient(args.session, 298751, "cb676d6bae20553c9996996a8f52b4d7")
client = TelegramClient(args.session, api_id, api_hash)
await client.start()
if args.list:

View File

View File

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

View File

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

216
sticker/server/api/auth.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

55
sticker/server/config.py Normal file
View File

@ -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, {}),
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
sticker/server/frontend Symbolic link
View File

@ -0,0 +1 @@
../../web/

50
sticker/server/server.py Normal file
View File

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

106
sticker/server/static.py Normal file
View File

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

196
web/.eslintrc.json Normal file
View File

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

View File

@ -5,15 +5,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no">
<title>Maunium sticker picker</title>
<link rel="modulepreload" href="src/widget-api.js"/>
<link rel="modulepreload" href="src/frequently-used.js"/>
<link rel="modulepreload" href="src/spinner.js"/>
<link rel="modulepreload" href="src/widget/widget-api.js"/>
<link rel="modulepreload" href="src/widget/frequently-used.js"/>
<link rel="modulepreload" href="src/Spinner.js"/>
<link rel="modulepreload" href="lib/htm/preact.js"/>
<link rel="preload" href="packs/index.json" as="fetch" type="application/json" crossorigin/>
<link rel="stylesheet" href="style/index.css"/>
<link rel="stylesheet" href="style/widget.css"/>
<link rel="stylesheet" href="style/spinner.css"/>
<script src="src/index.js" type="module"></script>
<script src="src/widget/index.js" type="module"></script>
<script nomodule>document.body.innerText = "This sticker picker requires modern JavaScript"</script>
</head>
<body>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

5
web/lib/preact/hooks.js Normal file
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 };

View File

@ -12,7 +12,8 @@
},
"snowpack": {
"install": [
"htm/preact"
"htm/preact",
"preact/hooks"
],
"installOptions": {
"sourceMap": false,
@ -22,10 +23,15 @@
},
"dependencies": {
"htm": "^3.0.4",
"preact": "^10.4.8",
"snowpack": "^2.10.3"
"preact": "^10.5.5",
"snowpack": "^2.16.1"
},
"devDependencies": {
"node-sass": "^4.14.1"
"@babel/core": "^7.12.3",
"@babel/eslint-parser": "^7.12.1",
"eslint": "^7.12.1",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-react-hooks": "^4.2.0",
"node-sass": "^5.0.0"
}
}

23
web/setup/index.html Normal file
View File

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

30
web/src/Button.js Normal file
View File

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

View File

@ -15,7 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { html } from "../lib/htm/preact.js"
export const Spinner = ({ size = 40, noCenter = false, noMargin = false, green = false }) => {
const Spinner = ({ size = 40, noCenter = false, noMargin = false, green = false }) => {
let margin = 0
if (!isNaN(+size)) {
size = +size
@ -39,3 +39,5 @@ export const Spinner = ({ size = 40, noCenter = false, noMargin = false, green =
}
return comp
}
export default Spinner

215
web/src/setup/LoginView.js Normal file
View File

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

20
web/src/setup/index.js Normal file
View File

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

102
web/src/setup/matrix-api.js Normal file
View File

@ -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",
})

67
web/src/setup/tryGet.js Normal file
View File

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

View File

@ -13,8 +13,9 @@
//
// 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, Component } from "../lib/htm/preact.js"
import { Spinner } from "./spinner.js"
import { html, render, Component } from "../../lib/htm/preact.js"
import Spinner from "../Spinner.js"
import * as widgetAPI from "./widget-api.js"
import * as frequent from "./frequently-used.js"
@ -24,23 +25,25 @@ const PACKS_BASE_URL = "packs"
// This is updated from packs/index.json
let HOMESERVER_URL = "https://matrix-client.matrix.org"
// eslint-disable-next-line max-len
const makeThumbnailURL = mxc => `${HOMESERVER_URL}/_matrix/media/r0/thumbnail/${mxc.substr(6)}?height=128&width=128&method=scale`
// We need to detect iOS webkit because it has a bug related to scrolling non-fixed divs
// This is also used to fix scrolling to sections on Element iOS
const isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/) && navigator.userAgent.match(/AppleWebKit/)
const isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/)
&& navigator.userAgent.match(/AppleWebKit/)
export const parseQuery = str => Object.fromEntries(
str.split("&")
.map(part => part.split("="))
.map(([key, value = ""]) => [key, value]))
const query = Object.fromEntries(location.search
.substr(1).split("&")
.map(part => part.split("="))
.map(([key, value = ""]) => [key, value]))
const supportedThemes = ["light", "dark", "black"]
class App extends Component {
constructor(props) {
super(props)
this.defaultTheme = parseQuery(location.search.substr(1)).theme
this.defaultTheme = query.theme
this.state = {
packs: [],
loading: true,
@ -61,7 +64,8 @@ class App extends Component {
this.defaultTheme = "light"
}
this.stickersByID = new Map(JSON.parse(localStorage.mauFrequentlyUsedStickerCache || "[]"))
this.state.frequentlyUsed.stickers = this._getStickersByID(this.state.frequentlyUsed.stickerIDs)
this.state.frequentlyUsed.stickers = this._getStickersByID(
this.state.frequentlyUsed.stickerIDs)
this.imageObserver = null
this.packListRef = null
this.navRef = null
@ -86,12 +90,14 @@ class App extends Component {
stickers,
},
})
localStorage.mauFrequentlyUsedStickerCache = JSON.stringify(stickers.map(sticker => [sticker.id, sticker]))
localStorage.mauFrequentlyUsedStickerCache = JSON.stringify(
stickers.map(sticker => [sticker.id, sticker]))
}
setStickersPerRow(val) {
localStorage.mauStickersPerRow = val
document.documentElement.style.setProperty("--stickers-per-row", localStorage.mauStickersPerRow)
document.documentElement.style.setProperty("--stickers-per-row",
localStorage.mauStickersPerRow)
this.setState({
stickersPerRow: val,
})
@ -116,20 +122,34 @@ class App extends Component {
}
_loadPacks(disableCache = false) {
const cache = disableCache ? "no-cache" : undefined
fetch(`${PACKS_BASE_URL}/index.json`, { cache }).then(async indexRes => {
const args = {
cache: disableCache ? "no-cache" : undefined,
headers: query.user_id && query.token ? {
Authorization: `Bearer ${query.token}`,
"X-Matrix-User-ID": query.user_id,
} : {},
}
fetch(`${PACKS_BASE_URL}/index.json`, args).then(async indexRes => {
if (indexRes.status >= 400) {
this.setState({
loading: false,
error: indexRes.status !== 404 ? indexRes.statusText : null,
})
try {
const errData = await indexRes.json()
this.setState({
loading: false,
error: errData.error,
})
} catch (err) {
this.setState({
loading: false,
error: indexRes.status !== 404 ? indexRes.statusText : null,
})
}
return
}
const indexData = await indexRes.json()
HOMESERVER_URL = indexData.homeserver_url || HOMESERVER_URL
// TODO only load pack metadata when scrolled into view?
for (const packFile of indexData.packs) {
const packRes = await fetch(`${PACKS_BASE_URL}/${packFile}`, { cache })
const packRes = await fetch(`${PACKS_BASE_URL}/${packFile}`, args)
const packData = await packRes.json()
for (const sticker of packData.stickers) {
this.stickersByID.set(sticker.id, sticker)
@ -140,11 +160,15 @@ class App extends Component {
})
}
this.updateFrequentlyUsed()
}, error => this.setState({ loading: false, error }))
}, error => this.setState({
loading: false,
error,
}))
}
componentDidMount() {
document.documentElement.style.setProperty("--stickers-per-row", this.state.stickersPerRow.toString())
document.documentElement.style.setProperty("--stickers-per-row",
this.state.stickersPerRow.toString())
this._loadPacks()
this.imageObserver = new IntersectionObserver(this.observeImageIntersections, {
rootMargin: "100px",
@ -226,27 +250,37 @@ class App extends Component {
render() {
const theme = `theme-${this.state.theme}`
if (this.state.loading) {
return html`<main class="spinner ${theme}"><${Spinner} size=${80} green /></main>`
return html`
<main class="spinner ${theme}">
<${Spinner} size=${80} green />
</main>`
} else if (this.state.error) {
return html`<main class="error ${theme}">
<h1>Failed to load packs</h1>
<p>${this.state.error}</p>
</main>`
return html`
<main class="error ${theme}">
<h1>Failed to load packs</h1>
<p>${this.state.error}</p>
</main>`
} else if (this.state.packs.length === 0) {
return html`<main class="empty ${theme}"><h1>No packs found 😿</h1></main>`
return html`
<main class="empty ${theme}"><h1>No packs found 😿</h1></main>`
}
return html`<main class="has-content ${theme}">
<nav onWheel=${this.navScroll} ref=${elem => this.navRef = elem}>
<${NavBarItem} pack=${this.state.frequentlyUsed} iconOverride="recent" />
${this.state.packs.map(pack => html`<${NavBarItem} id=${pack.id} pack=${pack}/>`)}
<${NavBarItem} pack=${{ id: "settings", title: "Settings" }} iconOverride="settings" />
</nav>
<div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}" ref=${elem => this.packListRef = elem}>
<${Pack} pack=${this.state.frequentlyUsed} send=${this.sendSticker} />
${this.state.packs.map(pack => html`<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker} />`)}
<${Settings} app=${this}/>
</div>
</main>`
return html`
<main class="has-content ${theme}">
<nav onWheel=${this.navScroll} ref=${elem => this.navRef = elem}>
<${NavBarItem} pack=${this.state.frequentlyUsed} iconOverride="recent" />
${this.state.packs.map(pack => html`
<${NavBarItem} id=${pack.id} pack=${pack}/>`)}
<${NavBarItem} pack=${{ id: "settings", title: "Settings" }}
iconOverride="settings" />
</nav>
<div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}"
ref=${elem => this.packListRef = elem}>
<${Pack} pack=${this.state.frequentlyUsed} send=${this.sendSticker}/>
${this.state.packs.map(pack => html`
<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker}/>`)}
<${Settings} app=${this}/>
</div>
</main>`
}
}
@ -257,9 +291,9 @@ const Settings = ({ app }) => html`
<button onClick=${app.reloadPacks}>Reload</button>
<div>
<label for="stickers-per-row">Stickers per row: ${app.state.stickersPerRow}</label>
<input type="range" min=2 max=10 id="stickers-per-row" id="stickers-per-row"
value=${app.state.stickersPerRow}
onInput=${evt => app.setStickersPerRow(evt.target.value)} />
<input type="range" min=2 max=10 id="stickers-per-row"
value=${app.state.stickersPerRow}
onInput=${evt => app.setStickersPerRow(evt.target.value)}/>
</div>
<div>
<label for="theme">Theme: </label>
@ -278,25 +312,34 @@ const Settings = ({ app }) => html`
// open the link in the browser instead of just scrolling there, so we need to scroll manually:
const scrollToSection = (evt, id) => {
const pack = document.getElementById(`pack-${id}`)
pack.scrollIntoView({ block: "start", behavior: "instant" })
pack.scrollIntoView({
block: "start",
behavior: "instant",
})
evt.preventDefault()
}
const NavBarItem = ({ pack, iconOverride = null }) => html`
const NavBarItem = ({
pack,
iconOverride = null,
}) => html`
<a href="#pack-${pack.id}" id="nav-${pack.id}" data-pack-id=${pack.id} title=${pack.title}
onClick=${isMobileSafari ? (evt => scrollToSection(evt, pack.id)) : undefined}>
onClick=${isMobileSafari ? evt => scrollToSection(evt, pack.id) : undefined}>
<div class="sticker">
${iconOverride ? html`
<span class="icon icon-${iconOverride}"/>
<span class="icon icon-${iconOverride}" />
` : html`
<img src=${makeThumbnailURL(pack.stickers[0].url)}
alt=${pack.stickers[0].body} class="visible" />
alt=${pack.stickers[0].body} class="visible" />
`}
</div>
</a>
`
const Pack = ({ pack, send }) => html`
const Pack = ({
pack,
send,
}) => html`
<section class="stickerpack" id="pack-${pack.id}" data-pack-id=${pack.id}>
<h1>${pack.title}</h1>
<div class="sticker-list">
@ -307,9 +350,12 @@ const Pack = ({ pack, send }) => html`
</section>
`
const Sticker = ({ content, send }) => html`
const Sticker = ({
content,
send,
}) => html`
<div class="sticker" onClick=${send} data-sticker-id=${content.id}>
<img data-src=${makeThumbnailURL(content.url)} alt=${content.body} />
<img data-src=${makeThumbnailURL(content.url)} alt=${content.body}/>
</div>
`

1
web/style/button.css Normal file
View File

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

60
web/style/button.sass Normal file
View File

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

View File

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

105
web/style/setup-login.sass Normal file
View File

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

1
web/style/setup.css Normal file
View File

@ -0,0 +1 @@
body{font-family:sans-serif}

18
web/style/setup.sass Normal file
View File

@ -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
web/style/theme.css Normal file
View File

33
web/style/theme.sass Normal file
View File

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

File diff suppressed because it is too large Load Diff