diff --git a/.gitignore b/.gitignore index 27b557c..e0fbf9b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ __pycache__/ +node_modules/ .cache *.egg-info/ *.egg .installed.cfg +db.sqlite3 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b016fb0 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +test-run: + cd tests/interface; mitama run + +watch-css: + npm run watch-css + +build-css: + npm run build-css diff --git a/mitama/app/__init__.py b/mitama/app/__init__.py index f4c60a6..bc0dcb8 100644 --- a/mitama/app/__init__.py +++ b/mitama/app/__init__.py @@ -1,3 +1,97 @@ from jinja2 import * from .app import App -from .meta import BaseMetadata +from .router import Router +from .builder import Builder +from .registry import AppRegistry +from mitama.http import Request, Response +from pathlib import Path +from mimetypes import add_type, guess_type +from abc import ABCMeta, abstractmethod +import os + +add_type('application/json', '.map') + +class Controller(): + '''MVCのControllerの基底クラス + + メソッドを記述すると、それをリクエスト処理に利用できる。 + ルーティング時にメソッドを特に指定しない場合はhandleメソッドが実行される。 + 非同期関数として定義すること。非同期なので、別プロセスを中継するとかもやりやすい(かもしれない)。 + :param app: Controllerを起動するAppのインスタンスの参照 + :param view: Controllerが利用するJinja2のEnvironmentインスタンス + ''' + app = None + view = None + async def handle(self, request: Request): + '''リクエストハンドラ + + ルーティング時に特にメソッド名を指定しない場合にはこのメソッドが起動する。 + 独自にメソッドを定義する場合にも、このメソッドと同じインターフェースを実装しなければならない。 + :param request: mitama.http.Requestのインスタンス + :return: mitama.http.Responseのインスタンス + ''' + pass + +class Middleware(metaclass = ABCMeta): + '''Requestを加工するMiddlewareの抽象クラス + + processメソッドによって受け取ったRequestを変更し、handler(次のMiddleware、またはControllerのメソッド)に受け渡す。 + :param app: Middlewareを起動するAppのインスタンスの参照 + :param view: Middlewareが利用するJinja2のEnvironmentインスタンス + ''' + app = None + view = None + @abstractmethod + async def process(self, request: Request, handler): + '''Middlewareのメイン処理 + + Middlewareは必ずこのメソッドを実装しなければならない。 + :param request: mitama.http.Requestのインスタンス + :param handler: requestを引数に受け取る関数(Middleware.process、またはControllerのリクエストハンドラ) + ''' + pass + +class StaticFileController(Controller): + '''静的ファイルを配信するController + + デフォルトではアプリのパッケージ内の :file:`static/` の中身を配信する。 + ''' + def __init__(self, *paths): + '''初期化処理 + + 初期化するとき、引数にディレクトリのパスを列挙すると、そのディレクトリが配信される。 + 何も指定されなかった場合、アプリ内の :file:`static/` が配信される。 + :param *paths: 配信するディレクトリ + ''' + super().__init__() + self.paths = list(paths) + def __connected__(self): + app_mod_dir = Path(os.path.dirname(__file__)) + self.view = Environment( + enable_async=True, + loader = FileSystemLoader([ + self.app.project_dir, + app_mod_dir / 'templates', + app_mod_dir / '../http/templates' + ]) + ) + if len(self.paths) == 0: + self.paths.append(self.app.install_dir / 'static') + async def handle(self, req: Request): + for path in self.paths: + filename = path / req.params['path'] + if filename.is_file(): + mime = guess_type(str(filename)) or 'application/octet-stream' + with open(filename) as f: + return Response(body = f.read(), headers={ + 'content-type': mime[0] + }) + for path in self.paths: + filename = path / '404.html' + if filename.is_file(): + with open(filename) as f: + return await Response(text = f.read(), status = 404, headers = { + 'content-type': 'text/html' + }) + template = self.view.get_template('404.html') + return await Response.render(template, req, status = 404) diff --git a/mitama/app/app.py b/mitama/app/app.py index 60a27de..cbfb03a 100644 --- a/mitama/app/app.py +++ b/mitama/app/app.py @@ -1,14 +1,114 @@ #!/usr/bin/python from aiohttp import web +from yarl import URL +from jinja2 import Environment, FileSystemLoader +from pathlib import Path +import magic +import os +from base64 import b64encode +from mitama.app.noimage import load_noimage_app +from mitama.hook import HookRegistry +from mitama.http import Request, Response + +def dataurl(blob): + f = magic.Magic(mime = True, uncompress = True) + mime = f.from_buffer(blob) + return 'data:'+mime+';base64,'+b64encode(blob).decode() class App: - def __init__(self, meta): - self.app = web.Application( - middlewares = [ - web.normalize_path_middleware(append_slash = True) - ] - ) - self.meta = meta - self.name = meta.name + template_dir = 'templates' + instances = list() + description = "" + name = "" + @property + def icon(self): + return load_noimage_app() + def __init__(self, **kwargs): + self.app = web.Application(client_max_size = kwargs["client_max_size"] if "client_max_size" in kwargs else 100*1024*1024) + self.screen_name = kwargs['name'] + self.path = kwargs['path'] + self.project_dir = Path(kwargs['project_dir']) if 'project_dir' in kwargs else None + self.project_root_dir = Path(kwargs['project_root_dir']) if 'project_dir' in kwargs else None + self.install_dir = Path(kwargs['install_dir']) if 'project_dir' in kwargs else Path(os.path.dirname(__file__)) / '../http/' + for instance in self.instances: + instance.app = self + instance.view = self.view + if hasattr(instance, '__connected__'): + instance.__connected__() + hook_registry = HookRegistry() + if hasattr(self, 'create_user'): + hook_registry.add_create_user_hook(self.create_user) + if hasattr(self, 'create_group'): + hook_registry.add_create_group_hook(self.create_group) + if hasattr(self, 'update_user'): + hook_registry.add_update_user_hook(self.update_user) + if hasattr(self, 'update_group'): + hook_registry.add_update_group_hook(self.update_group) + if hasattr(self, 'delete_user'): + hook_registry.add_delete_user_hook(self.delete_user) + if hasattr(self, 'delete_group'): + hook_registry.add_delete_group_hook(self.delete_group) + async def handle(request): + if not isinstance(request, Request): + request = Request.from_request(request) + result = await self.router.match(request) + if result: + request, handle = result + return await handle(request) + else: + return await self.error(request, 404) + self.app.router.add_route('*', '/{tail:.*}', handle) def __getattr__(self, name): return getattr(self.app, name) + def set_middleware(self, middlewares): + self.app.middlewares.extend(middlewares) + def convert_fullurl(self, req, url): + scheme= req.scheme + hostname = req.host + path = self.path + if path[0] != '/': + path = '/' + path + if path[-1] == '/': + path = path[0:-2] + if url[0] != '/': + url = '/' + url + return scheme + "://" + hostname + path + url + def convert_url(self, url): + path = self.path + if path[0] != '/': + path = '/' + path + if path[-1] == '/': + path = path[0:-2] + if url[0] != '/': + url = '/' + url + return path + url + def revert_url(self, url): + path = self.path + if path[0] != '/': + path = '/' + path + if path[-1] == '/': + path = path[0:-2] + url = str(url.path) + url = URL(url[len(path):]) + return url + @property + def view(self): + self._view= Environment( + enable_async = True, + loader = FileSystemLoader(self.install_dir / self.template_dir) + ) + def filter_user(arg): + return [user for user in arg if user.__class__.__name__ == "User"] + def filter_group(arg): + return [group for group in arg if group.__class__.__name__ == "Group"] + self._view.filters["user"] = filter_user + self._view.filters["group"] = filter_group + self._view.globals.update( + url = self.convert_url, + fullurl = self.convert_fullurl, + dataurl = dataurl + ) + return self._view + async def error(self, request, code): + template = self.view.get_template(str(code) + '.html') + return await Response.render(template, request) diff --git a/mitama/app/builder.py b/mitama/app/builder.py new file mode 100644 index 0000000..9aeef19 --- /dev/null +++ b/mitama/app/builder.py @@ -0,0 +1,26 @@ +import inspect +import os + +class Builder(object): + '''Appにメタ情報を入力して生成するビルダー + + プロジェクトディレクトリのパスやインストール先、プロジェクト内のアプリ用ディレクトリのパスをAppに設定し、インスタンスを返却します。 + 特にこれを弄るケースは想定していませんが、独自の挙動を付けたかったら継承して作っても良いかもしれません。 + アプリのパッケージ直下のAppBuilderが起動されるので、:file:`__init__.py` に :samp:`class AppBuilder(Builder)` を定義してください。 + ''' + app = None + def __init__(self): + self.data = {} + pass + def set_path(self, path): + self.data['path'] = path + def set_name(self, name): + self.data['name'] = name + def set_project_dir(self, path): + self.data['project_dir'] = path + def set_project_root_dir(self, path): + self.data['project_root_dir'] = path + def build(self): + install_dir = os.path.dirname(inspect.getfile(self.__class__)) + self.data['install_dir'] = install_dir + return self.app(**self.data) diff --git a/mitama/app/meta.py b/mitama/app/meta.py deleted file mode 100644 index 608bafc..0000000 --- a/mitama/app/meta.py +++ /dev/null @@ -1,6 +0,0 @@ -from mitama.extra import _Singleton - -class BaseMetadata(_Singleton): - pass - - diff --git a/mitama/app/method.py b/mitama/app/method.py new file mode 100644 index 0000000..2ef4c59 --- /dev/null +++ b/mitama/app/method.py @@ -0,0 +1,92 @@ +from mitama.app.router import Route + +def view(path, handler): + '''GET, POSTのルーティング先を指定 + + ブラウザで見れる一般的なルーティング先を作成します。 + :param path: マッチするパス + :param handler: リクエストハンドラ + :return: Routeインスタンス + ''' + return Route(['GET', 'POST'], path, handler) + +def any(path, handler): + '''メソッドを考慮しないルーティング先を指定 + + メソッドに関係なくマッチするルーティング先を作成します。 + :param path: マッチするパス + :param handler: リクエストハンドラ + :return: Routeインスタンス + ''' + return Route(['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTION', 'HEAD'], path, handler) + +def post(path, handler): + '''POSTのルーティング先を指定 + + POSTメソッドのルーティング先を作成します。 + :param path: マッチするパス + :param handler: リクエストハンドラ + :return: Routeインスタンス + ''' + return Route(['POST'], path, handler) + +def patch(path, handler): + '''PATCHのルーティング先を指定 + + PATCHメソッドのルーティング先を作成します。 + :param path: マッチするパス + :param handler: リクエストハンドラ + :return: Routeインスタンス + ''' + return Route(['PATCH'], path, handler) + +def head(path, handler): + '''HEADのルーティング先を指定 + + HEADメソッドのルーティング先を作成します。 + :param path: マッチするパス + :param handler: リクエストハンドラ + :return: Routeインスタンス + ''' + return Route(['HEAD'], path, handler) + +def option(path, handler): + '''OPTIONのルーティング先を指定 + + OPTIONメソッドのルーティング先を作成します。 + :param path: マッチするパス + :param handler: リクエストハンドラ + :return: Routeインスタンス + ''' + return Route(['OPTION'], path, handler) + +def put(path, handler): + '''PUTのルーティング先を指定 + + PUTメソッドのルーティング先を作成します。 + :param path: マッチするパス + :param handler: リクエストハンドラ + :return: Routeインスタンス + ''' + return Route(['PUT'], path, handler) + +def get(path, handler): + '''GETのルーティング先を指定 + + GETメソッドのルーティング先を作成します。 + :param path: マッチするパス + :param handler: リクエストハンドラ + :return: Routeインスタンス + ''' + return Route(['GET'], path, handler) + +def delete(path, handler): + '''DELETEのルーティング先を指定 + + DELETEメソッドのルーティング先を作成します。 + :param path: マッチするパス + :param handler: リクエストハンドラ + :return: Routeインスタンス + ''' + return Route(['DELETE'], path, handler) + diff --git a/mitama/app/middlewares.py b/mitama/app/middlewares.py new file mode 100644 index 0000000..963b9b9 --- /dev/null +++ b/mitama/app/middlewares.py @@ -0,0 +1,21 @@ +from mitama.http import Response +from mitama.auth import AuthorizationError, check_jwt +from mitama.app import Middleware +import urllib + +class SessionMiddleware(Middleware): + '''ログイン判定ミドルウェア + + ログインしていないユーザーがアクセスした場合、/login?redirect_to=にリダイレクトします。 + ''' + async def process(self, request, handler): + sess = await request.session() + try: + if 'jwt_token' in sess: + request.user = check_jwt(sess['jwt_token']) + else: + return Response.redirect('/login?redirect_to='+urllib.parse.quote(str(request.url), safe='')) + except Exception as err: + return Response.redirect('/login?redirect_to='+urllib.parse.quote(str(request.url), safe='')) + return await handler(request) + diff --git a/mitama/app/noimage.py b/mitama/app/noimage.py new file mode 100644 index 0000000..01924b6 --- /dev/null +++ b/mitama/app/noimage.py @@ -0,0 +1,40 @@ +from pathlib import Path +import magic, os +from mitama.conf import get_from_project_dir +from base64 import b64encode + +def load_noimage_app(): + '''アプリのNoImage画像を取得します''' + config = get_from_project_dir() + if (config._project_dir / "noimage_app").is_file(): + noimage_app_path = config._project_dir / "static/noimage_app.png" + else: + noimage_app_path = Path(os.path.dirname(__file__)) / "static/noimage_app.png" + with open(noimage_app_path, "rb") as f: + noimage_app = f.read() + return noimage_app + +def load_noimage_user(): + '''ユーザーのNoImage画像を取得します''' + config = get_from_project_dir() + if (config._project_dir / "noimage_user").is_file(): + noimage_user_path = (config._project_dir / "static/noimage_user.png") + else: + noimage_user_path = (Path(os.path.dirname(__file__)) / "static/noimage_user.png") + + with open(noimage_user_path, "rb") as f: + noimage_user = f.read() + return noimage_user + +def load_noimage_group(): + '''グループのNoImage画像を取得します''' + config = get_from_project_dir() + if (config._project_dir / "noimage_group").is_file(): + noimage_group_path = (config._project_dir / "static/noimage_group.png") + else: + noimage_group_path = (Path(os.path.dirname(__file__)) / "static/noimage_group.png") + + with open(noimage_group_path, "rb") as f: + noimage_group = f.read() + return noimage_group + diff --git a/mitama/app/registry.py b/mitama/app/registry.py new file mode 100644 index 0000000..ab0a0d8 --- /dev/null +++ b/mitama/app/registry.py @@ -0,0 +1,56 @@ +from mitama.extra import _Singleton +from mitama.app.router import Router +from mitama.conf import get_from_project_dir +import sys +import os +import importlib + +class AppRegistry(_Singleton): + '''稼働しているアプリのレジストリ + + サーバー内で稼働しているアプリのパスやパッケージ名が登録されているレジストリです。 + mitama.jsonを読み込んでアプリを起動するクラスでもあります。 + dictっぽくアプリの取得や配信の停止などが可能です。 + ''' + _map = dict() + _server = None + def append(self, app): + self._map.append(app) + def __iter__(self): + for app in self._map.values(): + yield app + def __setitem__(self, path, app): + self._map[path] = app + def __getitem__(self, path): + return self._map[path] + def __delitem__(self, path): + del self._map[path] + def reset(self): + '''アプリの一覧をリセットします''' + self._map = dict() + def load_config(self): + '''アプリの一覧をmitama.jsonから読み込み、配信します''' + config = get_from_project_dir() + sys.path.append(str(config._project_dir)) + for app_name in config.apps: + _app = config.apps[app_name] + app_dir = config._project_dir / app_name + if not app_dir.is_dir(): + os.mkdir(app_dir) + init = importlib.__import__(app_name, fromlist = ['init_app']) + builder = init.AppBuilder() + builder.set_project_dir(config._project_dir / app_name) + builder.set_project_root_dir(config._project_dir) + builder.set_path(_app['path']) + builder.set_name(app_name) + app = builder.build() + self[_app['path']] = app + if self._server != None: + self._server.load_routes() + def router(self): + '''アプリの情報に基づいてルーティングエンジンを生成します''' + router = Router() + for k in self._map.keys(): + app_router = self._map[k].router.clone(prefix = k) + router.add_route(app_router) + return router diff --git a/mitama/app/router.py b/mitama/app/router.py new file mode 100644 index 0000000..8e785ff --- /dev/null +++ b/mitama/app/router.py @@ -0,0 +1,199 @@ +from mitama.http import Request, Response +import copy +import re + +class RoutingError(Exception): + pass + +class Router(): + '''ルーティングエンジン + + 手軽に実装できて必要最低限なものを目指したので、遅かったりして嫌いだったら無理にこれを使う必要はありません。 + 自前のものを適用したい場合は、とりあえず:samp:`async hoge.match(Request): -> Response` といったインターフェースを実装したメソッドを作ってください。 + routesの中にRouterインスタンスを指定することもできます。 + ''' + app = None + def __init__(self, routes = [], middlewares = [], prefix = ''): + '''初期化処理 + + :param routes: Router、またはRouteのリスト + :param middlewares: Middlewareのリスト + :param prefix: 指定すると、パスの先頭がprefixと一致する場合のみマッチする + ''' + self.routes = routes + self.middlewares = list() + self.prefix = prefix + for middleware in middlewares: + self.middlewares.append(middleware) + self.i = 0 + def add_route(self, route): + '''ルーティング先を追加します + + mitama.app.methodの関数で生成したRouteインスタンスを与えてください。 + :param route: Routeインスタンス + ''' + self.routes.append(route) + def add_routes(self, routes): + '''複数のルーティング先を追加します + + mitama.app.methodの関数で生成したRouteインスタンスを与えてください。 + :param routes: Routeインスタンスのリスト + ''' + self.routes.extend(routes) + def add_middleware(self, middleware): + '''ミドルウェアを登録します + + Middlewareクラスを与えると、このルーターのインスタンス内でマッチした場合にミドルウェアが順番に起動します。 + :param middleware: Middlewareのインスタンス + ''' + self.middlewares.append(middleware) + def add_middlewares(self, middlewares): + '''複数のミドルウェアを登録します + + Middlewareクラスのリストを与えると、このルーターのインスタンス内でマッチした場合にミドルウェアが順番に起動します。 + :param middlewares: Middlewareのインスタンスのリスト + ''' + self.middlewares.extend(middlewares) + def clone(self, prefix = None): + return Router( + routes = copy.copy(self.routes), + prefix = self.prefix if prefix == None else prefix + ) + async def match(self, request): + method = request.method + path = request.subpath if hasattr(request, 'subpath') else request.path + if self.prefix != '' and self.prefix != '/': + if path[:len(self.prefix)] != self.prefix: + return False + else: + path = path[len(self.prefix):] + request.subpath = path + for route in self.routes: + result = await route.match(request) + if result != False: + request, result = result + def get_response_handler(result): + i = 0 + async def handle(request): + nonlocal i + if i>=len(self.middlewares) or len(self.middlewares) == 0: + if callable(result): + return await result(request) + elif callable(getattr(result, 'handle')): + return await result.handle(request) + else: + raise RoutingError('Unsupported interface object. Only callables and Controller instances are supported.') + else: + middleware = self.middlewares[i] + i += 1 + return await middleware.process(request, handle) + return handle + handler = get_response_handler(result) + return request, handler + return False + +class Route(): + def __init__(self, methods, path, handler): + self.methods = methods + self.path = Path(path) + self.handler = handler + pass + async def match(self, request): + method = request.method + path = request.subpath if hasattr(request, 'subpath') else request.path + args = self.path.match(path) + if method in self.methods and args != False: + request.params = args + return request, self.handler + else: + return False + +def _re_flatten(p): + if '(' not in p: + return p + return re.sub( + r'(\\*)(\(\?P<[^>]+>|\((?!\?))', + lambda m: m.group(0) if len(m.group(1)) % 2 else m.group(1) + '(?:', + p + ) + +class Path(): + rule_syntax = re.compile('(\\\\*)(?:(?:<([a-zA-Z_][a-zA-Z_0-9]*)?(?::([a-zA-Z_]*)(?::((?:\\\\.|[^\\\\>])+)?)?)?>))') + filters = { + 're': lambda conf: (_re_flatten(conf or '[^/]+'), None, None), + 'int': lambda conf: (r'-?\d+', int, lambda x: str(int(x))), + 'float': lambda conf: (r'-?[\d.]+', float, lambda x: str(float(x))), + 'path': lambda conf: (r'.+?', None, None) + } + default_filter = 're' + def __init__(self, path): + self.raw = path + self.builder = [] + anons = 0 + pattern = '' + keys = [] + filters = [] + is_static = True + for key, mode, conf in self._itertoken(path): + if mode: + is_static = False + if mode == 'default': + mode = self.default_filter + mask, in_filter, out_filter = self.filters[mode](conf) + if not key: + pattern+='(?:%s)' % mask + key = 'anon%d' % anons + anons += 1 + else: + pattern += '(?P<%s>%s)' % (key, mask) + keys.append(key) + if in_filter: + filters.append((key, in_filter)) + elif key: + pattern += re.escape(key) + + try: + re_pattern = re.compile('^(%s)$' % pattern) + re_match = re_pattern.match + except re.error as e: + raise RouteSyntaxError('') + + if filters: + def getargs(path): + url_args = re_match(path).groupdict() + for name, wildcard_filter in filters: + try: + url_args[name] = wildcard_filter(url_args[name]) + except ValueError: + raise RoutingError() + return url_args + elif re_pattern.groupindex: + def getargs(path): + return re_match(path).groupdict() + else: + getargs = None + flatpat = _re_flatten(pattern) + self.rule = (path, re.compile('(^%s$)' % flatpat), getargs) + def _itertoken(self, path): + offset = 0 + prefix = '' + for match in self.rule_syntax.finditer(path): + prefix += path[offset:match.start()] + g = match.groups() + if len(g[0]) % 2: + prefix += match.group(0)[len(g[0]):] + offset = match.end() + continue + if prefix: + yield prefix, None, None + name, filtr, conf = g[1:4] + yield name, filtr or 'default', conf or None + offset, prefix = match.end(), '' + if offset<=len(path) or prefix: + yield prefix + path[offset:], None, None + def match(self, target): + path, flatpat, getargs = self.rule + if flatpat.match(target) != None: + return getargs(target) if getargs else {} + else: + return False diff --git a/mitama/app/static/noimage_app.png b/mitama/app/static/noimage_app.png new file mode 100644 index 0000000..21f0d87 Binary files /dev/null and b/mitama/app/static/noimage_app.png differ diff --git a/mitama/app/static/noimage_group.png b/mitama/app/static/noimage_group.png new file mode 100644 index 0000000..ab3c7b4 Binary files /dev/null and b/mitama/app/static/noimage_group.png differ diff --git a/mitama/app/static/noimage_user.png b/mitama/app/static/noimage_user.png new file mode 100644 index 0000000..4ae1fff Binary files /dev/null and b/mitama/app/static/noimage_user.png differ diff --git a/mitama/auth.py b/mitama/auth.py index 98cdef6..e328030 100644 --- a/mitama/auth.py +++ b/mitama/auth.py @@ -1,9 +1,9 @@ #!/usr/bin/python '''パスワード認証 - * Mitamaではパスワードのハッシュ化にはBCRYPTアルゴリズムの$2yプリフィクスのものを利用します - * 理由は、前回バージョンの第三版がPHPのpassword_hash関数を使用しており、移植性を保ちたかったからです。 - * しょうもなくてすみません +Mitamaではパスワードのハッシュ化にはBCRYPTアルゴリズムの$2yプリフィクスのものを利用します +理由は、前回バージョンの第三版がPHPのpassword_hash関数を使用しており、移植性を保ちたかったからです。 +しょうもなくてすみません ''' import bcrypt @@ -20,6 +20,12 @@ class AuthorizationError(Exception): pass def password_auth(screen_name, password): + '''ログイン名とパスワードで認証します + + :param screen_name: ログイン名 + :param password: パスワード + :return: Userインスタンス + ''' user = User.query.filter(User.screen_name == screen_name).first() password = base64.b64encode( hashlib.sha256( @@ -32,6 +38,11 @@ def password_auth(screen_name, password): raise AuthorizationError('Wrong password') def password_hash(password): + '''パスワードをハッシュ化します + + :param password: パスワードのプレーンテキスト + :return: パスワードハッシュ + ''' salt = bcrypt.gensalt() password = base64.b64encode( hashlib.sha256( @@ -41,10 +52,15 @@ def password_hash(password): return bcrypt.hashpw(password, salt) def get_jwt(user): + '''UserインスタンスからJWTを生成します + + :param user: Userインスタンス + :return: JWT + ''' nonce = ''.join([str(random.randint(0,9)) for i in range(16)]) result = jwt.encode( { - 'id': user.id, + 'id': user._id, 'nonce': nonce }, secret, @@ -53,9 +69,17 @@ def get_jwt(user): return result.decode() def check_jwt(token): - result = jwt.decode( - token, - secret, - algorithm='HS256' - ) - return User.query.filter(User.id == result['id']).first() + '''JWTからUserインスタンスを取得します + + :param token: JWT + :return: Userインスタンス + ''' + try: + result = jwt.decode( + token, + secret, + algorithm='HS256' + ) + except jwt.exceptions.InvalidTokenError as err: + raise AuthorizationError('Invalid token.') + return User.query.filter(User._id == result['id']).first() diff --git a/mitama/command/__init__.py b/mitama/command/__init__.py index d9f4fe0..0b7fb13 100755 --- a/mitama/command/__init__.py +++ b/mitama/command/__init__.py @@ -11,6 +11,10 @@ import sys def exec(): + '''コマンドを起動します + + コマンドライン引数からサブコマンドのインスタンスを生成し、起動します。 + ''' subcmd = sys.argv[1] m = importlib.import_module('.' + subcmd, 'mitama.command') cmd = m.Command() diff --git a/mitama/command/mkapp.py b/mitama/command/mkapp.py new file mode 100644 index 0000000..db9683b --- /dev/null +++ b/mitama/command/mkapp.py @@ -0,0 +1,24 @@ +#!/usr/bin/python +'''アプリ作成コマンド + + * アプリのテンプレートを作成する + +''' + +import os +from pathlib import Path +import shutil +from .init import init_project_dir + +class Command: + def handle(self, argv = None): + try: + project_name = argv[0] + except IndexError: + raise IndexError( + 'No app name given to command arguments.' + ) + current_dir = Path(os.getcwd()) + project_dir = current_dir / project_name + src = Path(os.path.dirname(__file__)) / '../skeleton' + shutil.copytree(src, project_dir, symlinks=False) diff --git a/mitama/command/run.py b/mitama/command/run.py index b7443dd..09e3bcc 100755 --- a/mitama/command/run.py +++ b/mitama/command/run.py @@ -7,12 +7,9 @@ * マイグレーションの実行の実装 ''' -from mitama.conf import get_from_project_dir from mitama.http.server import Server import mitama.nodes -import os -import sys -import importlib +from mitama.app import AppRegistry class Command: def handle(self, argv = None): @@ -21,12 +18,7 @@ def handle(self, argv = None): except IndexError: port = '8080' server = Server(port) - config = get_from_project_dir() - sys.path.append(str(config._project_dir)) - for app_name in config.apps: - _app = config.apps[app_name] - init = importlib.__import__(_app['include'], fromlist = ['init_app']) - init.init_app(app_name) - app = importlib.__import__(_app['include'] + '.main', fromlist=['app']) - server.add_app(app.app.app, _app['path']) + registry = AppRegistry() + registry.load_config() + server.registry(registry) server.run() diff --git a/mitama/conf.py b/mitama/conf.py index 4ea001b..d957177 100755 --- a/mitama/conf.py +++ b/mitama/conf.py @@ -1,11 +1,8 @@ #!/usr/bin/python '''configの実装 - * 柔軟性を上げたかったので、デフォルト値を持ったオブジェクトのプロパティをdictの値で更新する方式をとった - * とりあえず、プロジェクトフォルダ直下のmitama.jsonを読む仕様にしている。 - -Todo: - * コマンドから生成するmitama.jsonの値はconfigのデフォルト値を使うので、mitama.conf.Configにエクスポート関数を用意したい +柔軟性を上げたかったので、デフォルト値を持ったオブジェクトのプロパティをdictの値で更新する方式をとった +とりあえず、プロジェクトフォルダ直下のmitama.jsonを読む仕様にしている。 ''' import os @@ -32,6 +29,11 @@ def to_dict(self): def get_from_project_dir(): + '''プロジェクトフォルダを返します + + App起動時にBuilderからメタ情報が登録されるので、基本的にはアプリから触る必要はないはずです。 + :return: Configインスタンス + ''' path = Path(os.getcwd()) with open(path / 'mitama.json') as f: data = f.read() diff --git a/mitama/db/__init__.py b/mitama/db/__init__.py index c03aef7..aa0c4dd 100644 --- a/mitama/db/__init__.py +++ b/mitama/db/__init__.py @@ -1,19 +1,21 @@ #!/usr/bin/python '''データベース - * データベースの接続とか抽象化の処理を書きます - * Databaseはシングルトンの接続のインスタンスを生成するクラスです - * 各アプリにはDatabaseを継承したクラスを定義してもらい、そいつのModelプロパティのベースクラスからモデルを作ってもらいます。 +データベースの接続とか抽象化の処理を書きます +Databaseはシングルトンの接続のインスタンスを生成するクラスです +各アプリにはDatabaseを継承したクラスを定義してもらい、そいつのModelプロパティのベースクラスからモデルを作ってもらいます。 ''' from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative.api import DeclarativeMeta from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.orm.exc import UnmappedClassError +from sqlalchemy.sql import func from sqlalchemy import orm from .model import Model from .driver.sqlite3 import get_engine, get_app_engine from mitama.extra import _Singleton +import inspect class _QueryProperty: def __init__(self, db): @@ -29,7 +31,7 @@ def __get__(self, obj, type): except UnmappedClassError: return None -class Database(_Singleton): +class _Database(_Singleton): engine = None session = None def __init__(self, model = None, metadata = None, query_class = orm.Query): @@ -64,7 +66,7 @@ def session(self): def create_all(self): self.Model.metadata.create_all(self.engine) -class _CoreDatabase(Database): +class _CoreDatabase(_Database): def __init__(self, engine = None): super().__init__() if self.engine == None: @@ -73,3 +75,16 @@ def __init__(self, engine = None): else: self.set_engine(engine) +class BaseDatabase(_Database): + '''アプリで利用するデータベースの操作を行うクラス + + アプリからデータベースを使うたい場合、このクラスを継承したクラスをアプリ内に定義します。 + ''' + def __init__(self, engine = None): + super().__init__() + if self.engine == None: + if engine == None: + package_name = inspect.getmodule(self.__class__).__package__ + self.set_engine(get_app_engine(package_name)) + else: + self.set_engine(engine) diff --git a/mitama/db/model.py b/mitama/db/model.py index 35ccd00..f27b9f6 100755 --- a/mitama/db/model.py +++ b/mitama/db/model.py @@ -9,9 +9,33 @@ ''' from sqlalchemy.orm import class_mapper -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.ext.declarative import declarative_base, declared_attr +from sqlalchemy.types import TypeDecorator +from mitama.db.types import Column, Integer, String, Node, Group, LargeBinary +from mitama.extra import _classproperty +import re -class Model: +class Model(): + _id = Column(Integer, primary_key = True) + @_classproperty + def type(cls): + class Type(TypeDecorator): + impl = Integer + def process_bind_param(self, value, dialect): + if value == None: + return None + else: + return value._id + def process_result_value(self, value, dialect): + if value == None: + return None + else: + user = cls.retrieve(value) + return user + return Type + @declared_attr + def __tablename__(cls): + return re.sub("(.[A-Z])",lambda x:x.group(1)[0] + "_" +x.group(1)[1], cls.__name__).lower() def create(self): self.query.session.add(self) self.query.session.commit() @@ -20,3 +44,16 @@ def update(self): def delete(self): self.query.session.delete(self) self.query.session.commit() + @classmethod + def list(cls, cond = None): + if cond != None: + return cls.query.filter(cond).all() + else: + return cls.query.filter().all() + @classmethod + def retrieve(cls, id = None): + if id != None: + node = cls.query.filter(cls._id == id).one() + else: + raise Exception('Id not given') + return node diff --git a/mitama/db/types.py b/mitama/db/types.py index 54945e9..4f888be 100755 --- a/mitama/db/types.py +++ b/mitama/db/types.py @@ -14,28 +14,40 @@ class User(TypeDecorator): impl = Integer def process_bind_param(self, value, dialect): - return value.id + if value == None: + return None + else: + return value._id def process_result_value(self, value, dialect): from mitama.nodes import User - user = User.retrieve(value) - return user + if value == None: + return None + else: + user = User.retrieve(value) + return user class Group(TypeDecorator): impl = Integer def process_bind_param(self, value, dialect): - return value.id + if value == None: + return None + else: + return value._id def process_result_value(self, value, dialect): from mitama.nodes import Group - group = Group.retrieve(value) - return group + if value == None: + return None + else: + group = Group.retrieve(value) + return group class Node(TypeDecorator): impl = Integer def process_bind_param(self, value, dialect): if value.__class__.__name__ == 'Group': - return value.id * 2 + return value._id * 2 elif value.__class__.__name__ == 'User': - return value.id * 2 - 1 + return value._id * 2 - 1 else: raise TypeError('Appending object must be Group or User instance') def process_result_value(self, value, dialect): diff --git a/mitama/extra.py b/mitama/extra.py index e5d9bdb..ff09022 100644 --- a/mitama/extra.py +++ b/mitama/extra.py @@ -7,3 +7,12 @@ def __new__(cls, *args, **kwargs): return cls._instance +class _classproperty: + def __init__(self, fget = None, doc = None): + self.fget = fget + self.__doc__ = self.fget.__doc__ if doc == None else doc + def __get__(self, obj, cls=None): + if cls is None: + cls = type(obj) + return self.fget(cls) + diff --git a/mitama/hook.py b/mitama/hook.py new file mode 100644 index 0000000..951b4e5 --- /dev/null +++ b/mitama/hook.py @@ -0,0 +1,40 @@ +from mitama.extra import _Singleton + +class HookRegistry(_Singleton): + def __init__(self): + self.create_user_hooks = list() + self.create_group_hooks = list() + self.update_user_hooks = list() + self.update_group_hooks = list() + self.delete_user_hooks = list() + self.delete_group_hooks = list() + def create_user(self, target): + for func in self.create_user_hooks: + func(target) + def create_group(self, target): + for func in self.create_group_hooks: + func(target) + def update_user(self, target): + for func in self.update_user_hooks: + func(target) + def update_group(self, target): + for func in self.update_group_hooks: + func(target) + def delete_user(self, target): + for func in self.delete_user_hooks: + func(target) + def delete_group(self, target): + for func in self.delete_group_hooks: + func(target) + def add_create_user_hook(self, func): + self.create_user_hooks.append(func) + def add_create_group_hook(self, func): + self.create_group_hooks.append(func) + def add_update_user_hook(self, func): + self.update_user_hooks.append(func) + def add_update_group_hook(self, func): + self.update_group_hooks.append(func) + def add_delete_user_hook(self, func): + self.delete_user_hooks.append(func) + def add_delete_group_hook(self, func): + self.delete_group_hooks.append(func) diff --git a/mitama/http/__init__.py b/mitama/http/__init__.py index 98ce219..59ce5ef 100755 --- a/mitama/http/__init__.py +++ b/mitama/http/__init__.py @@ -1,28 +1,100 @@ #!/usr/bin/python '''HTTP関連 - * サーバーの実装です。 +サーバーの実装です。 ''' from aiohttp import web -from aiohttp_session import get_session from abc import ABCMeta, abstractmethod +import re class Response(web.Response): + '''レスポンスのクラス + + aiohttpのweb.Responseを拡張したものです。 + ''' @classmethod - def render(cls, template, values = {}, **kwargs): + async def render(cls, template, request, values = {}, **kwargs): + '''HTMLを描画するレスポンスを返却します + + Jinja2のテンプレートにデータを入れてHTMLを生成し、Responseインスタンスを作成して返却します。 + テンプレート内部からはRequest内の非同期関数を呼び出すことができます。 + :param template: Jinja2テンプレート + :param request: Requestインスタンス + :param values: プレースホルダに入力するデータの辞書 + :param **kwargs: aiohttp.web.Responseに受け渡す、statusやheadersなどのデータ + ''' if 'content_type' not in kwargs: kwargs['content_type'] = 'text/html' - return cls(text = template.render(values), **kwargs) - pass + values['request'] = request + body = await template.render_async(values) + return cls(text = body, **kwargs) + @classmethod + def redirect(cls, uri, status=302): + '''リダイレクトします + + :param uri: リダイレクト先のURL + :param status: リダイレクト時のステータスコード + ''' + return cls(headers = { + 'Location': uri + }, status = status) class StreamResponse(web.StreamResponse): + '''動画とかをレスポンスで返したいときはこれを使う(んだと思う)''' pass class Request(web.Request): - pass + '''リクエストのクラス -class Controller(metaclass = ABCMeta): - @abstractmethod - async def handle(self, req: Request): - pass + aiohttpのweb.Requestを拡張したものです。 + ''' + __dict_style = re.compile('([a-zA-Z0-9_\-.]+)\[([a-zA-Z0-9_\-.]*)\]') + async def session(self): + '''セッション情報を取得します + + :return: セッションの辞書データ + ''' + sess = self.get('mitama_session') + if sess is None: + storage = self.get('mitama_session_storage') + sess = await storage.load_session(self) + self['mitama_session'] = sess + return sess + @classmethod + def from_request(cls, request): + return cls( + message = request._message, + payload = request._payload, + protocol = request._protocol, + payload_writer = request._payload_writer, + task = request._task, + loop = request._loop, + state = request._state, + client_max_size = request._client_max_size, + host = request.host, + remote = request.remote + ) + async def post(self): + '''リクエストボディのデータを取得します + :return: リクエストボディのデータ + ''' + post = await super().post() + data = dict() + for k,v in post.items(): + if not isinstance(v, web.FileField) and len(v) == 0: + continue + match = self.__dict_style.match(k) + if match!=None: + name, key = match.group(1,2) + if key == '': + if name not in data: + data[name] = list() + data[name].append(v) + else: + if name not in data: + data[name] = dict() + data[name][key] = v + else: + data[k] = v + return data diff --git a/mitama/http/auth.py b/mitama/http/auth.py deleted file mode 100644 index 155d73d..0000000 --- a/mitama/http/auth.py +++ /dev/null @@ -1,7 +0,0 @@ - -from mitama.http import get_session - -async def get_login_state(request): - sess = await get_session(request) - return check_jwt(sess['jwt_token']) - diff --git a/mitama/http/method.py b/mitama/http/method.py deleted file mode 100644 index 52c6e31..0000000 --- a/mitama/http/method.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/python -from aiohttp.web import get -from aiohttp.web import post -from aiohttp.web import put -from aiohttp.web import patch -from aiohttp.web import delete -from aiohttp.web import view -from aiohttp.web import route -from aiohttp.web import head -from aiohttp.web import static diff --git a/mitama/http/server.py b/mitama/http/server.py index d7fcba8..f6f9d16 100755 --- a/mitama/http/server.py +++ b/mitama/http/server.py @@ -1,37 +1,58 @@ #!/usr/bin/python -'''httpサーバー - - * てめえら喜べ、非同期だ。 - * ルーティングは完全にaiohttpのやつに頼る - * アプリケーションごとにルーティングを定義したフォルダを作ってもらうはずなので、それを少し調整して一つの配列にマージして使う - * ここではわりとしっかり目のMVC2のControllerを定義してやって、そのインターフェースをアプリに強制させる方式をとりたい -''' +'''httpサーバー''' +import base64, re from cryptography import fernet -import base64 from aiohttp import web -from aiohttp_session import setup -from aiohttp_session.cookie_storage import EncryptedCookieStorage -from . import Controller +from mitama.http.session import SessionMiddleware, EncryptedCookieStorage +from mitama.conf import get_from_project_dir +from mitama.auth import AuthorizationError +from mitama.auth import password_hash, password_auth, get_jwt +from mitama.app import App, Middleware + +class PathMiddleware(Middleware): + async def process(self, request, handler): + path_to_check = [] + if '?' in request.raw_path: + path, query = request.raw_path.split('?', 1) + query = '?' + query + else: + query = '' + path = request.raw_path + path_to_check.append(re.sub('//+', '/', path) + query) + if not request.path.endswith('/'): + path_to_check.append(path + '/' + query) + path_to_check.append(re.sub('//+', '/', path + '/') + query) + for path in path_to_check: + resolves, request = await self._check_request_solves(request, path) + if resolves: + raise redirect_class(request.raw_path + query) + return await handler(request) -class Server: - apps = dict() +class Server(): def __init__(self, port=8080): self.port = port - def add_app(self, app, _path): - self.apps[_path] = app - def run(self): - if '/' in self.apps: - app = self.apps['/'] - else: - app = web.Application(middlewares = [ - web.normalize_path_middleware(append_slash = True) - ]) fernet_key = fernet.Fernet.generate_key() secret_key = base64.urlsafe_b64decode(fernet_key) - setup(app, EncryptedCookieStorage(secret_key)) - for k in self.apps: - if k == '/': - continue - app.add_subapp(k, self.apps[k]) - web.run_app(app, port=self.port, access_log = None) + self.session_middleware = SessionMiddleware(EncryptedCookieStorage(secret_key)) + def registry(self, registry): + self.registry = registry + self.registry._server = self + def load_routes(self): + self.router = self.registry.router() + self.router.add_middlewares([ + self.session_middleware, + ]) + if hasattr(self, '_app'): + self._app.router = self.router + def run(self): + self.load_routes() + class _App(App): + router = self.router + config = get_from_project_dir() + self._app = _App( + name = '_mitama', + path = '/' + ) + web.run_app(self._app.app, port = self.port, access_log = None) + diff --git a/mitama/http/session.py b/mitama/http/session.py new file mode 100644 index 0000000..9a3043a --- /dev/null +++ b/mitama/http/session.py @@ -0,0 +1,170 @@ +import time +import json +import base64 +from cryptography import fernet +from mitama.app import Middleware +from collections.abc import MutableMapping +from mitama.http import Response + + +class Session(MutableMapping): + def __init__(self, identity, *, data, new, max_age = None): + self._changed = False + self._mapping = {} + self._identity = identity if data != {} else None + self._new = new if data != {} else True + self._max_age = max_age + created = data.get('created', None) if data else None + session_data = data.get('session', None) if data else None + now = int(time.time()) + age = now - created if created else now + if max_age is not None and age > max_age: + session_data = None + if self._new or created is None: + self._created = now + else: + self._created = created + + if session_data is not None: + self._mapping.update(session_data) + @property + def new(self): + return self._new + @property + def identity(self): + return self._identity + @property + def created(self): + return self._created + @property + def empty(self): + return not bool(self._mapping) + @property + def max_age(self): + return self._max_age + @max_age.setter + def max_age(self, value): + self._max_age = value + def changed(self): + self._changed = True + def invalidate(self): + self._changed = True + self._mapping = {} + def set_new_identity(self, identity): + if not self._new: + raise RuntimeError("Can't change identity for a session which is not new") + self._identity = identity + def __len__(self): + return len(self._mapping) + def __iter__(self): + return iter(self._mapping) + def __containers__(self, key): + return key in self._mapping + def __getitem__(self, key): + return self._mapping[key] + def __setitem__(self, key, value): + self._mapping[key] = value + self._changed = True + def __delitem__(self, key): + del self._mapping[key] + self._changed = True + +class EncryptedCookieStorage(): + def __init__(self, secret_key, *, cookie_name = 'AIOHTTP_SESSION', + domain = None, max_age = None, path = '/', + secure = None, httponly = True, encoder = json.dumps, + decoder = json.loads): + self._cookie_name = cookie_name + self._cookie_params = dict(domain = domain, + max_age = max_age, + path = path, + secure = secure, + httponly = httponly) + self._max_age = max_age + self._encoder = encoder + self._decoder = decoder + if isinstance(secret_key, str): + pass + elif isinstance(secret_key, (bytes, bytearray)): + secret_key = base64.urlsafe_b64encode(secret_key) + self._fernet = fernet.Fernet(secret_key) + @property + def cookie_name(self): + return self._cookie_name + @property + def max_age(self): + return self._max_age + @property + def cookie_params(self): + return self._cookie_params + def _get_session_data(self, session): + if not session.empty: + data = { + 'created': session.created, + 'session': session._mapping + } + else: + data = {} + return data + async def load_session(self, request): + cookie = self.load_cookie(request) + if cookie is None: + return Session(None, data = None, new = True, max_age = self.max_age) + else: + try: + data = self._decoder( + self._fernet.decrypt( + cookie.encode('utf-8'), + ttl = self._max_age + ).decode('utf-8') + ) + return Session(None, data = data, new = False, max_age = self.max_age) + except: + return Session(None, data = None, new = True, max_age = self.max_age) + async def save_session(self, request, response, session): + if session.empty: + return self.save_cookie(response, '', max_age = session.max_age) + cookie_data = self._encoder( + self._get_session_data(session) + ).encode('utf-8') + self.save_cookie( + response, + self._fernet.encrypt(cookie_data).decode('utf-8'), + max_age = session.max_age + ) + def load_cookie(self, request): + cookie = request.cookies.get(self._cookie_name) + return cookie + def save_cookie(self, response, cookie_data, *, max_age = None): + params = dict(self._cookie_params) + if max_age is not None: + params['max_age'] = max_age + params['expires'] = time.strftime('%a, %d-%b-%Y %T GMT', time.gmtime(time.time() + max_age)) + if not cookie_data: + response.del_cookie( + self._cookie_name, + domain = params['domain'], + path = params['path'] + ) + else: + response.set_cookie(self._cookie_name, cookie_data, **params) + + +class SessionMiddleware(Middleware): + def __init__(self, storage): + self.storage = storage + async def process(self, request, handler): + request['mitama_session_storage'] = self.storage + raise_response = False + response = await handler(request) + if not isinstance(response, Response): + return response + if response.prepared: + raise RuntimeError('Cannot save session data into prepared response') + session = request.get('mitama_session') + if session is not None: + if session._changed: + await self.storage.save_session(request, response, session) + if raise_response: + raise response + return response diff --git a/mitama/http/templates/403.html b/mitama/http/templates/403.html new file mode 100644 index 0000000..6dc514e --- /dev/null +++ b/mitama/http/templates/403.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} +{% block header %} +{% endblock %} +{% block content %} +
+

403 Forbidden

+

アクセス権がありません。

+
+{% endblock %} diff --git a/mitama/http/templates/404.html b/mitama/http/templates/404.html new file mode 100644 index 0000000..52f1403 --- /dev/null +++ b/mitama/http/templates/404.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} +{% block header %} +{% endblock %} +{% block content %} +
+

404 Not Found

+

お探しのページは見つかりませんでした。

+
+{% endblock %} diff --git a/mitama/http/templates/base.html b/mitama/http/templates/base.html new file mode 100644 index 0000000..f7da9a5 --- /dev/null +++ b/mitama/http/templates/base.html @@ -0,0 +1,17 @@ + + + + + + + + +{% block header %} +
+ +
+{% endblock %} +{% block content %} +{% endblock %} + + diff --git a/mitama/nodes.py b/mitama/nodes.py index 5326198..be05e8a 100755 --- a/mitama/nodes.py +++ b/mitama/nodes.py @@ -10,39 +10,55 @@ ''' from sqlalchemy.ext.declarative import declarative_base -from mitama.db import _CoreDatabase -from mitama.db.types import Column, Integer, String, Node, Group +from mitama.db import _CoreDatabase, func, orm +from mitama.db.types import Column, Integer, String, Node, Group, LargeBinary +from mitama.hook import HookRegistry +from mitama.app.noimage import load_noimage_user, load_noimage_group db = _CoreDatabase() +hook_registry = HookRegistry() class Relation(db.Model): - __tablename__ = 'mitama_relation' - id = Column(Integer, primary_key = True) parent = Column(Group) child = Column(Node) -class User(db.Model): - __tablename__ = 'mitama_user' - id = Column(Integer, primary_key = True) +class Node(object): + _icon = Column(LargeBinary) name = Column(String(255)) screen_name = Column(String(255)) - password = Column(String(255)) - @staticmethod - def retrieve(id = None, screen_name = None): + @property + def id(self): + return self._id + @classmethod + def retrieve(cls, id = None, screen_name = None): if id != None: - user = User.query.filter(User.id == id).first() + node = cls.query.filter(cls._id == id).first() elif screen_name != None: - user = User.query.filter(User.screen_name == screen_name).first() + node = cls.query.filter(cls.screen_name == screen_name).first() else: - raise Exception() - return user - def is_ancester(self, node): - if node.__class__.__name__ != 'Group' and node.__class__.__name__ != 'User': + raise Exception('') + return node + def icon_to_dataurl(self): + f = magic.Magic(mime = True, uncompress = True) + mime = f.from_buffer(self._icon) + return 'data:'+mime+';base64,'+b64encode(self.icon).decode() + def parents(self): + rels = Relation.query.filter(Relation.child == self).all() + parent = list() + for rel in rels: + parent.append(rel.parent) + return parent + def is_ancestor(self, node): + if not isinstance(node, Group) and not isinstance(node, User): raise TypeError('Checking object must be Group or User instance') layer = self.parents() while len(layer) > 0: - if node in layer: + if isinstance(node, Group) and node in layer: return True + else: + for node_ in layer: + if isinstance(node, User) and node_.is_in(node): + return True layer_ = list() for node_ in layer: layer_.extend( @@ -50,29 +66,65 @@ def is_ancester(self, node): ) layer = layer_ return False - def parents(self): - rels = Relation.query.filter(Relation.child == self).all() - parent = list() - for rel in rels: - parent.append(rel.parent) - return parent -class Group(db.Model): +class User(Node, db.Model): + '''ユーザーのモデルクラスです + + :param _id: 固有のID + :param screen_name: ログイン名 + :param name: 名前 + :param password: パスワード + :param icon: アイコン + ''' + __tablename__ = 'mitama_user' + password = Column(String(255)) + @property + def icon(self): + if self._icon != None: + return self._icon + else: + return load_noimage_user() + @icon.setter + def icon(self, value): + self._icon = value + def delete(self): + '''ユーザーを削除します''' + hook_registry.delete_user(self) + super().delete() + def update(self): + '''ユーザー情報を更新します''' + super().update() + hook_registry.update_user(self) + def create(self): + '''ユーザーを作成します''' + super().create() + hook_registry.create_user(self) + +class Group(Node, db.Model): + '''グループのモデルクラスです + + :param _id: 固有のID + :param screen_name: ドメイン名 + :param name: 名前 + :param icon: アイコン + ''' __tablename__ = 'mitama_group' - id = Column(Integer, primary_key = True) - name = Column(String(255)) - screen_name = Column(String(255)) - @staticmethod - def retrieve(id = None, screen_name = None): - if id != None: - group = Group.query.filter(Group.id == id).first() - elif screen_name != None: - group = Group.query.filter(Group.screen_name == screen_name).first() + @property + def icon(self): + if self._icon != None: + return self._icon else: - raise Exception('') - return group + return load_noimage_group() + @icon.setter + def icon(self, value): + self._icon = value + @classmethod + def tree(cls): + noparent = [rel.child._id for rel in db.session.query(Relation.child).group_by(Relation.child) if isinstance(rel.child, Group)] + groups = [group for group in Group.query.filter().all() if group._id not in noparent and isinstance(group, Group)] + return groups def append(self, node): - if node.__class__.__name__ != 'Group' and node.__class__.__name__ != 'User': + if not isinstance(node, Group) and not isinstance(node, User): raise TypeError('Appending object must be Group or User instance') rel = Relation() rel.parent = self @@ -80,52 +132,31 @@ def append(self, node): rel.create() def append_all(self, nodes): for node in nodes: - if node.__class__.__name__ != 'Group' and node.__class__.__name__ != 'User': + if not isinstance(node, Group) and not isinstance(node, User): raise TypeError('Appending object must be Group or User instance') rel = Relation() rel.parent = self rel.child = node - db.session.add(rel) - db.session.commit() + Relation.query.session.add(rel) + Relation.query.session.commit() def remove(self, node): - if node.__class__.__name__ != 'Group' and node.__class__.__name__ != 'User': + if not isinstance(node, Group) and not isinstance(node, User): raise TypeError('Removing object must be Group or User instance') - rel = Relation.query.filter(Relation.parent == self and Relation.child == node).first() - db.session.delete(rel) - db.session.commit() + rel = Relation.query.filter(Relation.parent == self).filter(Relation.child == node).first() + rel.delete() def remove_all(self, nodes): for node in nodes: - if node.__class__.__name__ != 'Group' and node.__class__.__name__ != 'User': + if not isinstance(node, Group) and not isinstance(node, User): raise TypeError('Appending object must be Group or User instance') - rels = Relation.query.filter(Relation.parent == self and Relation.child in nodes).all() - db.session.delete(rels) - db.session.commit() - def parents(self): - rels = Relation.query.filter(Relation.child == self).all() - parent = list() - for rel in rels: - parent.append(rel.parent) - return parent + rels = Relation.query.filter(Relation.parent == self).filter(Relation.child in nodes).all() + Relation.query.session.delete(rels) + Relation.query.session.commit() def children(self): rels = Relation.query.filter(Relation.parent == self).all() children = list() for rel in rels: children.append(rel.child) return children - def is_ancester(self, node): - if node.__class__.__name__ != 'Group' and node.__class__.__name__ != 'User': - raise TypeError('Checking object must be Group or User instance') - layer = self.parents() - while len(layer) > 0: - if node in layer: - return True - layer_ = list() - for node_ in layer: - layer_.extend( - node_.parents() - ) - layer = layer_ - return False def is_descendant(self, node): if node.__class__.__name__ != 'Group' and node.__class__.__name__ != 'User': raise TypeError('Checking object must be Group or User instance') @@ -142,9 +173,23 @@ def is_descendant(self, node): layer = layer_ return False def is_in(self, node): - if node.__class__ != 'Group' and node.__class__ != 'User': + if not isinstance(node, Group) and not isinstance(node, User): raise TypeError('Checking object must be Group or User instance') - rels = Relation.query.filter(Relation.parent == self and Relation.node == node).all() - return rels.len()!=0 + rels = Relation.query.filter(Relation.parent == self).filter(Relation.child == node).all() + return len(rels) != 0 + def delete(self): + '''グループを削除します''' + hook_registry.delete_group(self) + super().delete() + def update(self): + '''グループの情報を更新します''' + super().update() + hook_registry.update_group(self) + def create(self): + '''グループを作成します''' + super().create() + hook_registry.create_group(self) + + db.create_all() diff --git a/mitama/permission.py b/mitama/permission.py index 3a391eb..e0bb6ef 100644 --- a/mitama/permission.py +++ b/mitama/permission.py @@ -5,40 +5,114 @@ from sqlalchemy.ext.declarative import declared_attr class PermissionMixin(object): + '''パーミッションのモデルの実装を支援します + + ホワイトリスト方式の許可システムを実装する上で役に立つ機能をまとめたクラスです。 + このクラスとBaseDatabase.Modelを継承したクラスを定義するとパーミッションシステムを実現できます。 + + .. code-block:: python + + class SomePermission(PermissionMixin, db.Model): + pass + + 継承先のクラスで :samp:`target` プロパティを定義した場合、特定のものに対してのみ許可する仕様にすることができます。 + + .. code-block:: python + + class SomePermission(PermissionMixin, db.Model): + target = Column(User) + + targetがUser、またはGroupの場合、targetUpPropagate、 targetDownPropagateを指定すれば、targetに対しても伝播をチェックすることができます。 + + :param _id: 固有のID + :param node: 許可するUser、またはGroupのインスタンス + :param targetUpPropagate: targetがUser、またはGroupの場合の上向き伝播 + :param targetDownPropagate: targetがUser、またはGroupの場合の下向き伝播 + :param upPropagate: 許可対象のUser、またはGroupの場合の上向き伝播 + :param downPropagate: 許可対象のUser、またはGroupの場合の下向き伝播 + ''' @declared_attr def __tablename__(cls): return '__'+cls.__name__.lower()+'_permission' - id = Column(Integer, primary_key = True) node = Column(Node) + targetUpPropagate = False + targetDownPropagate = False upPropagate = False downPropagate = False @classmethod - def accept(cls, node): + def accept(cls, node, target = None): + '''UserまたはGroupに許可します + + :param node: UserまたはGroupのインスタンス + :param target: 許可対象 + ''' perm = cls() perm.node = node + if hasattr(cls, 'target') and target != None: + perm.target = target perm.create() @classmethod - def forbit(cls, node): - perm = cls.query.filter(cls.node == node).first() - perm.delete() + def forbit(cls, node, target = None): + '''UserまたはGroupの許可を取りやめます + + :param node: UserまたはGroupのインスタンス + :param target: 許可対象 + ''' + if hasattr(cls, 'target'): + perm = cls.query.filter(cls.node == node).filter(cls.target == target).first() + else: + perm = cls.query.filter(cls.node == node).first() + if perm!=None: + perm.delete() @classmethod - def is_accepted(cls, node): - if cls.query.filter(cls.node == node).count() != 0: - return True + def is_accepted(cls, node, target = None): + '''UserまたはGroupが許可されているか確認します + + :param node: UserまたはGroupのインスタンス + :param target: 許可対象 + ''' + perms = cls.query.filter(cls.node == node).all() + for perm in perms: + if perm.is_target(target) or perm.is_target(None): + return True if node.__class__.__name__ == 'User': parents = node.parents() for group in parents: - if cls.is_accepted(group): - return True - if cls.upPropagate: - for node_ in cls.query.all(): - if node_.node.is_ancester(node): - return True - if cls.downPropagate: - for node_ in cls.query.all(): - if node_.node.is_descendant(node): + if cls.is_accepted(group, target): return True + for node_ in cls.query.all(): + if node_.node == None: + continue + if node_.upPropagate and not isinstance(node_.node, User) and node_.node.is_ancestor(node) and node_.is_target(target): + return True + if node_.downPropagate and not isinstance(node_.node, User) and node_.node.is_descendant(node) and node_.is_target(target): + return True + return False + def is_target(self, target = None): + if not hasattr(self, 'target'): + return True + if self.target == None: + return True + if self.target == target: + return True + if isinstance(target, User) or isinstance(target, Group): + if self.targetUpPropagate: + return self.target.is_ancestor(target) + elif self.targetDownPropagate: + return self.target.is_descendant(target) + else: + return self.target == target + elif self.target == target: + return True return False @classmethod - def is_forbidden(cls, node): - return not cls.is_accepted(node) + def is_forbidden(cls, node, target = None): + '''UserまたはGroupが許可されていないか確認します + + :param node: UserまたはGroupのインスタンス + :param target: 許可対象 + ''' + if not hasattr(cls, 'target'): + return not cls.is_accepted(node) + else: + return not cls.is_accepted(node, target) diff --git a/mitama/portal/__init__.py b/mitama/portal/__init__.py index 5dc64b8..b8cd466 100644 --- a/mitama/portal/__init__.py +++ b/mitama/portal/__init__.py @@ -1,19 +1,7 @@ -from mitama.app import BaseMetadata -from mitama.db import get_app_engine, Database as BaseDatabase +from mitama.app import Builder +from .main import App +import os -class Metadata(BaseMetadata): - pass +class AppBuilder(Builder): + app = App -class Database(BaseDatabase): - def __init__(self, engine = None): - super().__init__() - meta = Metadata() - if self.engine == None: - if engine == None: - self.set_engine(get_app_engine(meta.name)) - else: - self.set_engine(engine) - -def init_app(name): - meta = Metadata() - meta.name = name diff --git a/mitama/portal/controller.py b/mitama/portal/controller.py new file mode 100644 index 0000000..32e12f8 --- /dev/null +++ b/mitama/portal/controller.py @@ -0,0 +1,463 @@ +from mitama.app import Controller, AppRegistry +from mitama.http import Response +from mitama.nodes import User, Group +from mitama.auth import password_hash, password_auth, get_jwt, AuthorizationError +from mitama.app.noimage import load_noimage_group, load_noimage_user +import json +import traceback +from uuid import uuid4 +from .model import Invite, CreateUserPermission, UpdateUserPermission, DeleteUserPermission, CreateGroupPermission, UpdateGroupPermission, DeleteGroupPermission, Admin + +class SessionController(Controller): + async def login(self, request): + template = self.view.get_template('login.html') + if request.method == 'POST': + try: + post = await request.post() + result = password_auth(post['screen_name'], post['password']) + sess = await request.session() + sess['jwt_token'] = get_jwt(result) + redirect_to = request.query.get('redirect_to', '/') + return Response.redirect( + redirect_to + ) + except AuthorizationError as err: + error = 'パスワード、またはログイン名が間違っています' + return await Response.render( + template, + request, + { + 'error':error + }, + status = 401 + ) + return await Response.render( + template, + request, + status = 401 + ) + + async def logout(self, request): + sess = await request.session() + sess['jwt_token'] = None + redirect_to = request.query.get('redirect_to', '/') + return Response.redirect(redirect_to) + +class RegisterController(Controller): + async def signup(self, request): + sess = await request.session() + template = self.view.get_template('signup.html') + invite = Invite.query.filter(Invite.token == request.query["token"]).first() + if request.method == "POST": + try: + data = await request.post() + user = User() + user.password = password_hash(data['password']) + if invite.editable: + user.screen_name = data['screen_name'] + user.name = data['name'] + user.icon = data['icon'].file.read() if "icon" in data else invite.icon + else: + user.screen_name = invite.screen_name + user.name = invite.name + user.icon = invite.icon + user.create() + sess["jwt_token"] = get_jwt(user) + return Response.redirect( + self.app.convert_url('/') + ) + except Exception as err: + error = str(err) + return await Response.render(template, request, { + 'error': error, + "name": data["name"], + "screen_name": data["screen_name"], + "password": data["password"], + "icon": data["icon"].file.read(), + 'editable': invite.editable + }) + return await Response.render(template, request, { + "icon": invite.icon, + "name": invite.name, + "screen_name": invite.screen_name, + 'editable': invite.editable + }) + async def setup(self, request): + sess = await request.session() + template = self.app.view.get_template('setup.html') + if request.method == 'POST': + try: + data = await request.post() + user = User() + user.screen_name = data['screen_name'] + user.name = data['name'] + user.password = password_hash(data['password']) + user.icon = data["icon"].file.read() if 'icon' in data else load_noimage_user() + user.create() + Admin.accept(user) + CreateUserPermission.accept(user) + UpdateUserPermission.accept(user) + DeleteUserPermission.accept(user) + CreateGroupPermission.accept(user) + UpdateGroupPermission.accept(user) + DeleteGroupPermission.accept(user) + UpdateUserPermission.accept(user, user) + sess["jwt_token"] = get_jwt(user) + return Response.redirect( + self.app.convert_url("/") + ) + except Exception as err: + error = str(err) + return await Response.render(template, request, { + 'error': error + }) + return await Response.render(template, request) +# HomeControllerではユーザー定義のダッシュボード的なのを作れるようにしたいけど、時間的にパス +''' +class HomeController(Controller): + async def handle(self, request): + template = self.view.get_template('home.html') + return await Response.render(template, request) +''' + +class UsersController(Controller): + async def create(self, req): + if CreateUserPermission.is_forbidden(req.user): + return await self.app.error(req, 403) + template = self.view.get_template('user/create.html') + invites = Invite.list() + if req.method == 'POST': + post = await req.post() + try: + icon = post["icon"].file.read() if "icon" in post else load_noimage_user() + invite = Invite() + invite.name = post['name'] + invite.screen_name = post['screen_name'] + invite.icon = icon + invite.token = str(uuid4()) + invite.editable = 'editable' in post + invite.create() + invites = Invites.list() + return await Response.render(template, req, { + 'invites': invites, + "icon": load_noimage_user() + }) + except Exception as err: + error = str(err) + return await Response.render(template, req, { + 'invites': invites, + "name": post["name"], + "screen_name": post["screen_name"], + "icon": icon, + 'error': error + }) + return await Response.render(template, req, { + 'invites': invites, + "icon": load_noimage_user() + }) + async def cancel(self, req): + if CreateUserPermission.is_forbidden(req.user): + return await self.app.error(req, 403) + invite = Invite.retrieve(req.params['id']) + invite.delete() + return Response.redirect(self.app.convert_url('/users/invite')) + async def retrieve(self, req): + template = self.view.get_template('user/retrieve.html') + user = User.retrieve(screen_name = req.params["id"]) + return await Response.render(template, req, { + "user": user, + 'updatable': UpdateUserPermission.is_accepted(req.user, user) + }) + async def update(self, req): + template = self.view.get_template('user/update.html') + user = User.retrieve(screen_name = req.params["id"]) + if UpdateUserPermission.is_forbidden(req.user, user): + return await self.app.error(req, 403) + if req.method == "POST": + post = await req.post() + try: + icon = post["icon"].file.read() if "icon" in post else user.icon + user.screen_name = post["screen_name"] + user.name = post["name"] + user.icon = icon + user.update() + if Admin.is_accepted(req.user): + if 'user_create' in post: + CreateUserPermission.accept(user) + else: + CreateUserPermission.forbit(user) + if 'user_update' in post: + UpdateUserPermission.accept(user) + else: + UpdateUserPermission.forbit(user) + if 'user_delete' in post: + DeleteUserPermission.accept(user) + else: + DeleteUserPermission.forbit(user) + if 'group_create' in post: + CreateGroupPermission.accept(user) + else: + CreateGroupPermission.forbit(user) + if 'group_update' in post: + UpdateGroupPermission.accept(user) + else: + UpdateGroupPermission.forbit(user) + if 'group_delete' in post: + DeleteGroupPermission.accept(user) + else: + DeleteGroupPermission.forbit(user) + if 'admin' in post: + Admin.accept(user) + else: + Admin.forbit(user) + return await Response.render(template, req, { + "message": "変更を保存しました", + "user": user, + "screen_name": user.screen_name, + "name": user.name, + "icon": user.icon, + }) + except Exception as err: + error = str(err) + return await Response.render(template, req, { + "error": error, + "user": user, + "screen_name": post["screen_name"], + "name": post["name"], + "icon": icon, + }) + return await Response.render(template, req, { + "user": user, + "screen_name": user.screen_name, + "name": user.name, + "icon": user.icon, + }) + async def delete(self, req): + if DeleteUserPermission.is_forbidden(req.user): + return await self.app.error(req, 403) + template = self.view.get_template('user/delete.html') + return await Response.render(template, req) + async def list(self, req): + template = self.view.get_template('user/list.html') + users = User.list() + return await Response.render(template, req, { + 'users': users, + 'create_permission': CreateUserPermission.is_accepted(req.user), + }) + +class GroupsController(Controller): + async def create(self, req): + if CreateGroupPermission.is_forbidden(req.user): + return await self.app.error(request, 403) + template = self.view.get_template('group/create.html') + groups = Group.list() + if req.method == 'POST': + post = await req.post() + try: + group = Group() + group.name = post['name'] + group.screen_name = post['screen_name'] + group.icon = post['icon'].file.read() if "icon" in post else None + group.create() + if "parent" in post and post['parent'] != '': + Group.retrieve(int(post['parent'])).append(group) + group.append(req.user) + UpdateGroupPermission.accept(req.user, group) + return Response.redirect(self.app.convert_url("/groups")) + except Exception as err: + error = str(err) + return await Response.render(template, req, { + 'groups': groups, + "icon": post["icon"].file.read() if "icon" in post else None, + 'error': error + }) + return await Response.render(template, req, { + 'groups': groups, + "icon": load_noimage_group() + }) + async def retrieve(self, req): + template = self.view.get_template('group/retrieve.html') + group = Group.retrieve(screen_name = req.params["id"]) + return await Response.render(template, req, { + "group": group, + 'updatable': UpdateGroupPermission.is_accepted(req.user, group) + }) + async def update(self, req): + template = self.view.get_template('group/update.html') + group = Group.retrieve(screen_name = req.params["id"]) + groups = list() + for g in Group.list(): + if not (group.is_ancestor(g) or group.is_descendant(g) or g==group): + groups.append(g) + users = list() + for u in User.list(): + if not group.is_in(u): + users.append(u) + if UpdateGroupPermission.is_forbidden(req.user, group): + return await self.app.error(req, 403) + if req.method == "POST": + post = await req.post() + try: + print(post) + icon = post["icon"].file.read() if "icon" in post else group.icon + group.screen_name = post["screen_name"] + group.name = post["name"] + group.icon = icon + group.update() + if Admin.is_accepted(req.user): + if 'user_create' in post: + CreateUserPermission.accept(group) + else: + CreateUserPermission.forbit(group) + if 'user_update' in post: + UpdateUserPermission.accept(group) + else: + UpdateUserPermission.forbit(group) + if 'user_delete' in post: + DeleteUserPermission.accept(group) + else: + DeleteUserPermission.forbit(group) + if 'group_create' in post: + CreateGroupPermission.accept(group) + else: + CreateGroupPermission.forbit(group) + if 'group_update' in post: + UpdateGroupPermission.accept(group) + else: + UpdateGroupPermission.forbit(group) + if 'group_delete' in post: + DeleteGroupPermission.accept(group) + else: + DeleteGroupPermission.forbit(group) + if 'admin' in post: + Admin.accept(group) + else: + Admin.forbit(group) + return await Response.render(template, req, { + "message": "変更を保存しました", + "group": group, + "screen_name": group.screen_name, + "name": group.name, + 'all_groups': groups, + 'all_users': users, + "icon": group.icon, + }) + except Exception as err: + error = str(err) + return await Response.render(template, req, { + "error": error, + 'all_groups': groups, + 'all_users': users, + "group": group, + "screen_name": post["screen_name"], + "name": post["name"], + "icon": icon, + }) + return await Response.render(template, req, { + "group": group, + 'all_groups': groups, + 'all_users': users, + "screen_name": group.screen_name, + "name": group.name, + "icon": group.icon, + }) + async def append(self, req): + post = await req.post() + try: + group = Group.retrieve(screen_name = req.params['id']) + nodes = list() + if 'user' in post: + for uid in post['user']: + try: + nodes.append(User.retrieve(int(uid))) + except Exception as err: + pass + if 'group' in post: + for gid in post['group']: + try: + nodes.append(Group.retrieve(int(gid))) + except Exception as err: + pass + group.append_all(nodes) + except Exception as err: + pass + finally: + return Response.redirect(self.app.convert_url('/groups/'+group.screen_name+'/settings')) + async def remove(self, req): + try: + group = Group.retrieve(screen_name = req.params['id']) + cid = int(req.params['cid']) + if cid % 2 == 0: + child = Group.retrieve(cid / 2) + else: + child = User.retrieve((cid + 1) / 2) + group.remove(child) + except Exception as err: + pass + finally: + return Response.redirect(self.app.convert_url('/groups/'+group.screen_name+'/settings')) + async def accept(self, req): + group = Group.retrieve(screen_name = req.params['id']) + if UpdateGroupPermission.is_forbidden(req.user, group): + return await self.app.error(req, 403) + user = User.retrieve(int(req.params['cid'])) + UpdateGroupPermission.accept(user, group) + return Response.redirect(self.app.convert_url('/groups/'+group.screen_name+'/settings')) + async def forbit(self, req): + group = Group.retrieve(screen_name = req.params['id']) + if UpdateGroupPermission.is_forbidden(req.user, group): + return await self.app.error(req, 403) + user = User.retrieve(int(req.params['cid'])) + UpdateGroupPermission.forbit(user, group) + return Response.redirect(self.app.convert_url('/groups/'+group.screen_name+'/settings')) + async def delete(self, req): + if DeleteGroupPermission.is_forbidden(req.user): + return await self.app.error(req, 403) + template = self.view.get_template('group/delete.html') + return await Response.render(template, req) + async def list(self, req): + template = self.view.get_template('group/list.html') + groups = Group.tree() + return await Response.render(template, req, { + 'groups': groups, + 'create_permission': CreateGroupPermission.is_accepted(req.user), + }) + +class AppsController(Controller): + async def update(self, req): + if Admin.is_forbidden(req.user): + return await self.app.error(req, 403) + template = self.view.get_template('apps/update.html') + apps = AppRegistry() + if req.method == "POST": + apps.reset() + post = await req.post() + try: + prefix = post["prefix"] + data = dict() + data["apps"] = dict() + for package, path in prefix.items(): + data["apps"][package] = { + "path": path + } + with open(self.app.project_root_dir / "mitama.json", 'w') as f: + f.write(json.dumps(data)) + apps.load_config() + return await Response.render(template, req, { + 'message': '変更を保存しました', + "apps": apps, + }) + except Exception as err: + return await Response.render(template, req, { + "apps": apps, + 'error': str(err) + }) + return await Response.render(template, req, { + "apps": apps + }) + async def list(self, req): + template = self.view.get_template('apps/list.html') + apps = AppRegistry() + return await Response.render(template, req, { + "apps": apps, + }) diff --git a/mitama/portal/main.py b/mitama/portal/main.py index 5669557..b5a9339 100644 --- a/mitama/portal/main.py +++ b/mitama/portal/main.py @@ -1,7 +1,96 @@ -from . import Metadata, urls -from mitama.app import App +import os +from pathlib import Path +from .controller import * +from .middleware import * +from mitama.app import App as BaseApp, Router, StaticFileController +from mitama.app.method import * +from mitama.app.middlewares import SessionMiddleware +from .model import UpdateUserPermission, CreateUserPermission, DeleteUserPermission, CreateGroupPermission, UpdateGroupPermission, DeleteGroupPermission, Admin -meta = Metadata() -app = App(meta) +import urllib + +#home = HomeController() +sess = SessionController() +reg = RegisterController() +users = UsersController() +groups = GroupsController() +apps = AppsController() +init_mid = InitializeMiddleware() +sess_mid = SessionMiddleware() +static = StaticFileController() + +class App(BaseApp): + name = "Mitama Portal" + description = "Mitamaのアプリポータルです。他のアプリを確認できる他、配信の設定やグループの編集、ユーザーの招待ができます。" + instances = [ + #home, + reg, + sess, + users, + groups, + apps, + static, + init_mid, + sess_mid + ] + @property + def view(self): + view = super().view + view.globals.update( + user_create_permission = CreateUserPermission.is_accepted, + user_update_permission = UpdateUserPermission.is_accepted, + user_delete_permission = DeleteUserPermission.is_accepted, + group_create_permission = CreateGroupPermission.is_accepted, + group_update_permission = UpdateGroupPermission.is_accepted, + group_delete_permission = DeleteGroupPermission.is_accepted, + is_admin = Admin.is_accepted + ) + return view + @property + def router(self): + return Router([ + view('/static/', static), + view('/setup', reg.setup), + view('/signup', reg.signup), + view('/login', sess.login), + Router([ + view('/', groups.list), + view('/logout', sess.logout), + view('/users', users.list), + view('/users/invite', users.create), + view('/users/invite//delete', users.cancel), + view('/users/', users.retrieve), + view('/users//settings', users.update), + view('/users//delete', users.delete), + view('/groups', groups.list), + view('/groups/create', groups.create), + view('/groups/', groups.retrieve), + post('/groups//append', groups.append), + view('/groups//remove/', groups.remove), + view('/groups//accept//update', groups.accept), + view('/groups//forbit//update', groups.forbit), + view('/groups//settings', groups.update), + view('/groups//delete', groups.delete), + view('/apps', apps.list), + view('/apps/settings', apps.update), + ], middlewares = [ + init_mid, + sess_mid + ]) + ]) + def delete_user(self, user): + CreateUserPermission.forbit(user) + UpdateUserPermission.forbit(user) + DeleteUserPermission.forbit(user) + CreateGroupPermission.forbit(user) + UpdateGroupPermission.forbit(user) + DeleteGroupPermission.forbit(user) + def delete_group(self, group): + CreateUserPermission.forbit(group) + UpdateUserPermission.forbit(group) + DeleteUserPermission.forbit(group) + CreateGroupPermission.forbit(group) + UpdateGroupPermission.forbit(group) + DeleteGroupPermission.forbit(group) + pass -app.router.add_routes(urls.urls) diff --git a/mitama/portal/middleware.py b/mitama/portal/middleware.py new file mode 100644 index 0000000..bc66ab2 --- /dev/null +++ b/mitama/portal/middleware.py @@ -0,0 +1,22 @@ +from mitama.http import Response +#from mitama.auth import AuthorizationError +from mitama.nodes import User +from mitama.app import Middleware + +''' +class SessionMiddleware(Middleware): + async def process(self, request, handler): + try: + request.user = await get_login_state(request) + except AuthorizationError: + return Response.redirect(self.app.convert_url('/login?redirect_to='+urllib.parse.quote(str(request.url), safe=''))) + return await handler(request) +''' + +class InitializeMiddleware(Middleware): + async def process(self, request, handler): + if User.query.count() == 0: + return Response.redirect(self.app.convert_url('/setup')) + else: + return await handler(request) + diff --git a/mitama/portal/model.py b/mitama/portal/model.py index 629dafe..afd7321 100644 --- a/mitama/portal/model.py +++ b/mitama/portal/model.py @@ -1,6 +1,56 @@ from mitama.db.types import * -from . import Database +from mitama.nodes import User, Group +from mitama.db import BaseDatabase +from base64 import b64encode +import magic +from mitama.permission import PermissionMixin + +class Database(BaseDatabase): + pass db = Database() +class Invite(db.Model): + icon = Column(LargeBinary) + screen_name = Column(String) + name = Column(String) + token = Column(String, unique = True) + editable = Column(Boolean) + def icon_to_dataurl(self): + f = magic.Magic(mime = True, uncompress = True) + mime = f.from_buffer(self.icon) + return 'data:'+mime+';base64,'+b64encode(self.icon).decode() + +class CreateUserPermission(PermissionMixin, db.Model): + upPropagate = True + pass + +class UpdateUserPermission(PermissionMixin, db.Model): + upPropagate = True + targetDownPropagate = True + target = Column(User.type, nullable = True) + pass + +class DeleteUserPermission(PermissionMixin, db.Model): + upPropagate = True + pass + +class CreateGroupPermission(PermissionMixin, db.Model): + upPropagate = True + pass + +class UpdateGroupPermission(PermissionMixin, db.Model): + upPropagate = True + targetDownPropagate = True + target = Column(Group.type, nullable = True) + pass + +class DeleteGroupPermission(PermissionMixin, db.Model): + upPropagate = True + pass + +class Admin(PermissionMixin, db.Model): + upPropagate = True + pass +db.create_all() diff --git a/mitama/portal/static/entrance-style.css b/mitama/portal/static/entrance-style.css new file mode 100644 index 0000000..247e52c --- /dev/null +++ b/mitama/portal/static/entrance-style.css @@ -0,0 +1,148 @@ +@charset "UTF-8"; +/* + * 色定義 +*/ +:root { + --mtm-blue: #49a3e0; + --base-background: #efefef; + --header-text: #fff; + --main-color: var(--mtm-blue); + --header-background: var(--main-color); + --main-background: #ffffff; + --main-decoration: #cccccc; + --main-text: #444444; + --main-text-lighter: #777777; + --accent-text: #b33e5c; + --accent-background: #b33e5c; +} + +/* + * 共通スタイル + */ +body { + margin: 0; + background: var(--base-background); + color: var(--main-text); + font-size: 14px; +} + +a { + color: var(--accent-text); +} + +button { + display: flex; + background: var(--accent-background); + color: var(--main-background); + font-weight: bold; + height: 32px; + width: auto; + padding: 0 8px; + border: none; + border-radius: 4px; + justify-content: center; + align-items: center; +} + +.user-icon { + border-radius: 50%; +} + +.icon { + object-fit: cover; +} + +/* + * レスポンシブ + */ +body { + background: #fff; +} + +header { + height: 56px; + padding: 8px; +} +header #logo { + height: 56px; +} + +#content { + padding: 64px 0; + text-align: center; +} +#content p > span { + display: inline-block; +} +#content form { + text-align: left; + padding: 24px; + max-width: 400px; + margin: 0 auto; +} +#content form .main { + width: 320px; + margin: 0 auto; +} +#content form input { + height: 32px; + line-height: 32px; + padding: 0 8px; + border: none; + border-bottom-width: 2px; + border-bottom-style: solid; + border-bottom-color: #0000; + outline: none; + transition: all 0.2s; + width: 100%; +} +#content form input:focus { + border-bottom-color: var(--accent-background); +} +#content form #image-form { + display: grid; + grid-template-columns: 80px 1fr; + grid-gap: 16px; + margin: 16px 0; + align-items: center; +} +#content form #image-form input { + display: none; +} +#content form #image-form img { + display: block; + width: 80px; + height: 80px; + border-radius: 40px; +} +#content form #image-form .button { + display: flex; + background: var(--accent-background); + color: var(--main-background); + font-weight: bold; + height: 32px; + max-width: 160px; + border-radius: 4px; + justify-content: center; + align-items: center; +} +#content form dl { + margin: 0px 0 32px; +} +#content form dl dt { + font-size: 0.8rem; + font-weight: bold; +} +#content form dl dd { + margin: 8px 0 16px; +} +#content form .submit { + display: flex; + justify-content: center; + margin-top: 32px; +} +#content form .submit button { + width: 120px; +} + +/*# sourceMappingURL=entrance-style.css.map */ diff --git a/mitama/portal/static/entrance-style.css.map b/mitama/portal/static/entrance-style.css.map new file mode 100644 index 0000000..cadb4fd --- /dev/null +++ b/mitama/portal/static/entrance-style.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["../../../scss/base.scss","../../../scss/entrance-style.scss"],"names":[],"mappings":";AAAA;AAAA;AAAA;AAGA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGJ;AAAA;AAAA;AAGA;EACI;EACA;EACA;EACA;;;AAGJ;EACI;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACI;;;AAEJ;EACI;;;AAGJ;AAAA;AAAA;ACnDA;EACI;;;AAEJ;EACI;EACA;;AACA;EACI;;;AAGR;EACI;EACA;;AACA;EACI;;AAEJ;EACI;EACA;EACA;EACA;;AACA;EACI;EACA;;AAEJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;;AAGR;EACI;EACA;EACA;EACA;EACA;;AACA;EACI;;AAEJ;EACI;EACA;EACA;EACA;;AAEJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGR;EACI;;AACA;EACI;EACA;;AAEJ;EACI;;AAGR;EACI;EACA;EACA;;AACA;EACI","file":"entrance-style.css"} \ No newline at end of file diff --git a/mitama/portal/static/logo.svg b/mitama/portal/static/logo.svg new file mode 100755 index 0000000..c349979 --- /dev/null +++ b/mitama/portal/static/logo.svg @@ -0,0 +1,87 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/mitama/portal/static/mypage.svg b/mitama/portal/static/mypage.svg new file mode 100755 index 0000000..5f67bea --- /dev/null +++ b/mitama/portal/static/mypage.svg @@ -0,0 +1,57 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/mitama/portal/static/noimage_team.svg b/mitama/portal/static/noimage_team.svg new file mode 100644 index 0000000..bef286f --- /dev/null +++ b/mitama/portal/static/noimage_team.svg @@ -0,0 +1,63 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/mitama/portal/static/noimage_user.svg b/mitama/portal/static/noimage_user.svg new file mode 100644 index 0000000..de295e1 --- /dev/null +++ b/mitama/portal/static/noimage_user.svg @@ -0,0 +1,63 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/mitama/portal/static/settings.svg b/mitama/portal/static/settings.svg new file mode 100755 index 0000000..6a762ee --- /dev/null +++ b/mitama/portal/static/settings.svg @@ -0,0 +1,49 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/mitama/portal/static/spritesheet.svg b/mitama/portal/static/spritesheet.svg new file mode 100644 index 0000000..fc52e07 --- /dev/null +++ b/mitama/portal/static/spritesheet.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + diff --git a/mitama/portal/static/style.css b/mitama/portal/static/style.css new file mode 100644 index 0000000..b21b8da --- /dev/null +++ b/mitama/portal/static/style.css @@ -0,0 +1,802 @@ +@charset "UTF-8"; +/* + * 色定義 +*/ +:root { + --mtm-blue: #49a3e0; + --base-background: #efefef; + --header-text: #fff; + --main-color: var(--mtm-blue); + --header-background: var(--main-color); + --main-background: #ffffff; + --main-decoration: #cccccc; + --main-text: #444444; + --main-text-lighter: #777777; + --accent-text: #b33e5c; + --accent-background: #b33e5c; +} + +/* + * 共通スタイル + */ +body { + margin: 0; + background: var(--base-background); + color: var(--main-text); + font-size: 14px; +} + +a { + color: var(--accent-text); +} + +button { + display: flex; + background: var(--accent-background); + color: var(--main-background); + font-weight: bold; + height: 32px; + width: auto; + padding: 0 8px; + border: none; + border-radius: 4px; + justify-content: center; + align-items: center; +} + +.user-icon { + border-radius: 50%; +} + +.icon { + object-fit: cover; +} + +/* + * レスポンシブ + */ +header { + height: 56px; + display: grid; + grid-template-columns: 1fr auto; + background: var(--header-background); + position: sticky; + top: 0; + color: var(--header-text); +} +header #logo { + height: 40px; + display: block; +} +header > div { + padding: 8px 16px; +} +header #user-menu { + display: grid; + grid-gap: 8px; + grid-template-columns: 32px 1fr; + align-items: center; + height: 40px; + font-weight: bold; + background: #0001; + width: 160px; +} +header #user-menu .user-icon { + width: 32px; + height: 32px; +} +header menu { + display: flex; + opacity: 0; + flex-direction: column; + justify-content: flex-end; + position: absolute; + top: -20px; + pointer-events: none; + width: 200px; + right: 0; + display: block; + margin: 0; + padding: 16px; + transition: all 0.2s; + background: var(--main-background); + box-shadow: 0 0 10px #0004; +} +header menu > div { + border-bottom: 1px solid var(--main-decoration); + padding-bottom: 8px; + margin-bottom: 8px; +} +header menu div:last-child { + border: none; + padding-bottom: 0; + margin-bottom: 0; +} +header menu a { + display: grid; + grid-template-columns: 32px 1fr; + grid-gap: 8px; + width: 100%; + height: 40px; + justify-content: center; + align-items: center; + text-decoration: none; + padding: 0 4px; + font-weight: bold; + color: var(--main-text); + transition: all 0.2s; +} +header menu a svg { + width: 32px; + height: 32px; + fill: var(--main-text); + transition: all 0.2s; +} +header menu a:hover { + color: var(--main-color); +} +header menu a:hover svg { + fill: var(--main-color); +} +header #user-menu:hover > menu { + opacity: 1; + pointer-events: all; + top: 0px; +} + +#content { + margin-top: 16px; + margin-left: auto; + margin-right: auto; + background: var(--main-background); +} +#content .mini-title { + font-size: 0.8rem; + font-weight: bold; + height: 40px; + line-height: 40px; + margin: 0; + padding: 8px 16px; +} +#content .mini-title.dark { + border-bottom: 1px solid #eee; + background-color: #00000005; +} +#content .thin-title { + font-size: 0.8rem; + font-weight: bold; +} +#content .thin-title.between { + display: flex; + justify-content: space-between; + align-items: center; +} +#content .tool-box { + display: flex; + padding: 16px; + justify-content: flex-end; +} +#content .tab-container { + display: grid; +} +#content .tab-container .tab-header a { + height: 32px; + box-sizing: border-box; + text-decoration: none; + font-weight: bold; + font-size: 0.8rem; +} +#content .profile-container { + display: grid; + grid-template-columns: 80px 1fr 80px; + grid-gap: 8px; + padding: 24px; + max-width: 800px; + margin: 0 auto; +} +#content .profile-container .user-icon, #content .profile-container .icon { + width: 80px; + height: 80px; + box-sizing: border-box; + border: 4px solid #0001; +} +#content .profile-container .icon { + border-radius: 8px; +} +#content .profile-container .detail { + padding: 12px; + display: flex; + flex-direction: column; + justify-content: space-around; +} +#content .profile-container .detail .name { + font-size: 1.1rem; + color: var(--main-text); + margin: 0; +} +#content .profile-container .detail .screen-name { + font-size: 1rem; + color: var(--main-text-lighter); +} +#content .profile-container .action { + grid-column: 3/4; +} +#content .profile-container .action button { + width: 100%; +} +#content .profile-container .group-list { + grid-column: 1/3; +} +#content form #image-form { + display: grid; + grid-template-columns: 80px 1fr; + grid-gap: 16px; + align-items: center; +} +#content form #image-form input { + display: none; +} +#content form #image-form img { + display: block; + width: 80px; + height: 80px; + border-radius: 40px; +} +#content form #image-form .button { + display: flex; + background: var(--accent-background); + color: var(--main-background); + font-weight: bold; + height: 32px; + width: 160px; + border-radius: 4px; + justify-content: center; + align-items: center; +} +#content form input[type=text], +#content form input[type=password], +#content form input[type=email] { + height: 32px; + line-height: 32px; + padding: 0 8px; + box-sizing: border-box; + display: block; + border: none; + border-bottom: 2px solid #0000; + outline: none; + transition: border-bottom 0.2s; +} +#content form input[type=text]:focus, +#content form input[type=password]:focus, +#content form input[type=email]:focus { + border-bottom: 2px solid var(--accent-background); +} +@media screen and (min-width: 901px) { + #content form input[type=text], +#content form input[type=password], +#content form input[type=email] { + width: 200px; + } +} +@media screen and (max-width: 900px) { + #content form input[type=text], +#content form input[type=password], +#content form input[type=email] { + width: 100%; + } +} +#content form dl dt { + font-weight: bold; + font-size: 0.8rem; +} +#content form dl dd { + margin: 0; + padding: 16px 24px 32px; +} +#content form button[type=submit] { + width: 160px; +} +#content form .permission label { + display: grid; + grid-template-columns: 24px 1fr; + height: 32px; +} +#content form .permission label input[type=checkbox] { + width: 12px; + height: 12px; + margin: 10px 6px; +} +#content form .permission label div { + line-height: 32px; +} +@media screen and (min-width: 901px) { + #content form { + padding: 32px; + } +} +#content .item .icon { + border-radius: 50%; +} +#content .no-item { + color: var(--main-text-lighter); + font-size: 0.8rem; + display: flex; + justify-content: center; + align-items: center; + padding: 24px 0; +} +#content .member-append-list .item-container { + display: grid; + grid-template-columns: 24px 1fr; +} +#content .member-append-list .item-container input[type=checkbox] { + margin: 30px 6px; + width: 12px; + height: 12px; +} +#content .member-append-list .item { + display: grid; + grid-template-columns: 56px 1fr; + grid-gap: 16px; + text-decoration: none; + color: var(--main-text); + padding: 8px; +} +#content .member-append-list .item .user-icon, #content .member-append-list .item .icon { + display: block; + width: 56px; + height: 56px; + box-sizing: border-box; + border: 2px solid #0001; +} +#content .member-append-list .item .user-icon { + border-radius: 28px; +} +#content .member-append-list .item .icon { + border-radius: 4px; +} +#content .member-append-list .item .detail { + display: flex; + justify-content: space-around; + flex-direction: column; + padding: 8px 0; +} +#content .member-append-list .item .detail .name { + font-size: 1rem; + font-weight: bold; +} +#content .member-append-list .item .detail .screen-name { + font-size: 0.85rem; + color: var(--main-text-lighter); +} +#content .group-list .item { + display: grid; + grid-template-columns: auto 1fr; +} +#content .group-list .item .branch { + grid-column: 1/2; + width: 0px; +} +#content .group-list .item .profile { + display: grid; + text-decoration: none; + color: var(--main-text); + grid-column: 2/3; + grid-template-columns: 56px 1fr; + grid-gap: 16px; + padding: 8px; +} +#content .group-list .item .profile .icon { + width: 56px; + height: 56px; + border-radius: 4px; + border: 2px solid #0001; + box-sizing: border-box; +} +#content .group-list .item .profile .detail { + display: flex; + justify-content: space-around; + flex-direction: column; + padding: 8px 0; +} +#content .group-list .item .profile .detail .name { + font-size: 1rem; + font-weight: bold; +} +#content .group-list .item .profile .detail .screen-name { + font-size: 0.85rem; + color: var(--main-text-lighter); +} +#content .group-list .children { + grid-column: 2/3; + position: relative; +} +#content .group-list .children::before { + content: ""; + display: block; + position: absolute; + width: 2px; + height: calc(100% - 45px); + left: 16px; + background: var(--main-color); +} +#content .group-list .children .branch { + width: 24px; + display: block; + position: relative; +} +#content .group-list .children .branch::before { + content: ""; + display: block; + position: absolute; + box-sizing: border-box; + border-left: 2px solid var(--main-color); + border-bottom: 2px solid var(--main-color); + width: 8px; + height: 40px; + left: 16px; + top: -8px; + border-bottom-left-radius: 4px; +} +#content .user-list h4, +#content .invite-list h4 { + margin: 16px 0 8px; + font-size: 0.75rem; +} +#content .user-list .item, +#content .invite-list .item { + display: grid; + grid-template-columns: 56px 1fr; + grid-gap: 16px; + text-decoration: none; + color: var(--main-text); + padding: 8px; +} +#content .user-list .item .user-icon, #content .user-list .item .icon, +#content .invite-list .item .user-icon, +#content .invite-list .item .icon { + display: block; + width: 56px; + height: 56px; + box-sizing: border-box; + border: 2px solid #0001; +} +#content .user-list .item .user-icon, +#content .invite-list .item .user-icon { + border-radius: 28px; +} +#content .user-list .item .icon, +#content .invite-list .item .icon { + border-radius: 4px; +} +#content .user-list .item .detail, +#content .invite-list .item .detail { + display: flex; + justify-content: space-around; + flex-direction: column; + padding: 8px 0; +} +#content .user-list .item .detail .name, +#content .invite-list .item .detail .name { + font-size: 1rem; + font-weight: bold; +} +#content .user-list .item .detail .screen-name, +#content .invite-list .item .detail .screen-name { + font-size: 0.85rem; + color: var(--main-text-lighter); +} +#content .user-list .item .detail .buttons, +#content .invite-list .item .detail .buttons { + text-align: right; + font-size: 0.8rem; +} +#content .user-list .item .detail .buttons a, +#content .invite-list .item .detail .buttons a { + display: inline-block; +} +#content .user-list .item .detail .buttons input, +#content .invite-list .item .detail .buttons input { + display: block; + position: absolute; + overflow: hidden; + opacity: 0; +} +#content .member-control-list h4 { + margin: 16px 0 8px; + font-size: 0.75rem; +} +#content .member-control-list .item { + display: grid; + grid-template-columns: 56px 1fr auto; + grid-gap: 16px; + text-decoration: none; + color: var(--main-text); + padding: 8px; +} +#content .member-control-list .item .user-icon, #content .member-control-list .item .icon { + display: block; + width: 56px; + height: 56px; + box-sizing: border-box; + border: 2px solid #0001; +} +#content .member-control-list .item .user-icon { + border-radius: 28px; +} +#content .member-control-list .item .icon { + border-radius: 4px; +} +#content .member-control-list .item .detail { + display: flex; + justify-content: space-around; + flex-direction: column; + padding: 8px 0; +} +#content .member-control-list .item .detail .name { + font-size: 1rem; + font-weight: bold; +} +#content .member-control-list .item .detail .screen-name { + font-size: 0.85rem; + color: var(--main-text-lighter); +} +#content .member-control-list .item .actions { + display: flex; + justify-content: center; + align-items: center; +} +#content .member-control-list .item .actions a { + margin-left: 4px; +} +#content .group-list .item { + display: grid; + grid-template-columns: auto 1fr; +} +#content .group-list .item .branch { + grid-column: 1/2; + width: 0px; +} +#content .group-list .item .profile { + display: grid; + text-decoration: none; + color: var(--main-text); + grid-column: 2/3; + grid-template-columns: 56px 1fr; + grid-gap: 16px; + padding: 8px; +} +#content .group-list .item .profile .icon { + width: 56px; + height: 56px; + border-radius: 4px; + border: 2px solid #0001; + box-sizing: border-box; +} +#content .group-list .item .profile .detail { + display: flex; + justify-content: space-around; + flex-direction: column; + padding: 8px 0; +} +#content .group-list .item .profile .detail .name { + font-size: 1rem; + font-weight: bold; +} +#content .group-list .item .profile .detail .screen-name { + font-size: 0.85rem; + color: var(--main-text-lighter); +} +#content .group-list .children { + grid-column: 2/3; +} +#content .group-list .children .branch { + width: 24px; + display: block; + position: relative; +} +#content .group-list .children .branch::before { + content: ""; + display: block; + position: absolute; + box-sizing: border-box; + border-left: 2px solid var(--main-color); + border-bottom: 2px solid var(--main-color); + width: 8px; + height: 40px; + left: 16px; + top: -8px; + border-bottom-left-radius: 4px; +} +#content .group-list .children .item + .item > .branch::after { + content: ""; + display: block; + position: absolute; + box-sizing: border-box; + border-left: 2px solid var(--main-color); + width: 8px; + height: 38px; + left: 16px; + top: -38px; +} +#content .app-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + justify-content: start; +} +#content .app-list .item { + display: grid; + text-decoration: none; + color: var(--main-text); + grid-template-columns: 56px 1fr; + grid-gap: 16px; + padding: 8px; +} +#content .app-list .item .icon { + width: 56px; + height: 56px; + border-radius: 4px; + border: 2px solid #0001; + box-sizing: border-box; +} +#content .app-list .item .detail { + display: flex; + justify-content: space-around; + flex-direction: column; + padding: 8px 0; +} +#content .app-list .item .detail .name { + font-size: 1rem; + font-weight: bold; +} +#content .app-list .item .detail .screen-name { + font-size: 0.85rem; + color: var(--main-text-lighter); +} +#content #modal-switch { + display: none; +} +#content #modal-switch:checked ~ .modal { + display: flex; +} +#content .modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + height: 100vh; + display: none; +} +#content .modal .background { + display: block; + background: #0003; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; +} +#content .modal .inner { + padding: 24px; + background: #fff; + margin: 100px auto; + width: calc(100% - 64px); + max-width: 600px; + position: absolute; + left: 0; + right: 0; + overflow-y: scroll; + z-index: 2; +} +#content .modal input[type=search] { + width: 100%; + box-sizing: border-box; + height: 32px; + line-height: 32px; + border: none; + outline: none; + border-bottom-width: 2px; + border-bottom-style: solid; + border-bottom-color: #0000; + transition: all 0.2s; +} +#content .modal input[type=search]:focus { + border-bottom-color: var(--accent-background); +} +#content .modal .modal-submit { + display: flex; + justify-content: center; + margin: 16px 24px; +} +#content .modal .modal-submit button { + width: 120px; + margin: 0 8px; +} +@media screen and (min-width: 901px) { + #content .modal .two-columns { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 16px; + } + #content .modal .two-columns h4 { + margin: 8px 0 4px; + } +} +@media screen and (min-width: 901px) { + #content { + max-width: 1200px; + width: calc(100% - 100px); + margin-left: auto; + margin-right: auto; + } + #content section { + padding: 32px; + } + #content .inner { + padding-top: 32px; + padding-bottom: 32px; + } + #content .tight { + padding-left: 32px; + padding-right: 32px; + } + #content .tab-container { + grid-template-columns: 240px 1fr; + } + #content .tab-container .tab-header { + padding: 32px 16px; + } + #content .tab-container .tab-header a { + height: 32px; + line-height: 32px; + padding: 0 16px; + display: block; + background: var(--main-background); + color: var(--main-color); + font-weight: bold; + border-left-width: 4px; + border-left-style: solid; + border-left-color: #0000; + } + #content .tab-container .tab-header a.focus { + border-left-color: var(--main-color); + } + #content .left-main { + display: grid; + grid-template-columns: 1fr 400px; + } +} +@media screen and (max-width: 900px) { + #content { + max-width: 900px; + width: 100%; + } + #content .inner { + padding: 8px; + } + #content .tab-container { + grid-template-rows: 32px 1fr; + grid-gap: 8px; + } + #content .tab-header { + height: 32px; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + } + #content .tab-header a { + display: flex; + border-top-width: 4px; + border-top-style: solid; + border-top-color: var(--main-background); + color: var(--main-text); + justify-content: center; + align-items: center; + } + #content .tab-header a.focus { + border-top-color: var(--main-color); + } +} + +/*# sourceMappingURL=style.css.map */ diff --git a/mitama/portal/static/style.css.map b/mitama/portal/static/style.css.map new file mode 100644 index 0000000..e55c5d0 --- /dev/null +++ b/mitama/portal/static/style.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["../../../scss/base.scss","../../../scss/header.scss","../../../scss/main.scss","../../../scss/form.scss","../../../scss/list.scss","../../../scss/modal.scss"],"names":[],"mappings":";AAAA;AAAA;AAAA;AAGA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGJ;AAAA;AAAA;AAGA;EACI;EACA;EACA;EACA;;;AAGJ;EACI;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACI;;;AAEJ;EACI;;;AAGJ;AAAA;AAAA;ACpDA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;EACA;;AAEJ;EACI;;AAEJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;EACA;;AAGR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;EACA;EACA;;AAEJ;EACI;EACA;EACA;;AAEJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;EACA;EACA;EACA;;AAEJ;EACI;;AACA;EACI;;AAKhB;EACI;EACA;EACA;;;ACrFR;EACI;EACA;EACA;EACA;;AACA;EACI;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;EACA;;AAGR;EACI;EACA;;AACA;EACI;EACA;EACA;;AAGR;EACI;EACA;EACA;;AAEJ;EACI;;AAEI;EACI;EACA;EACA;EACA;EACA;;AAIZ;EACI;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;EACA;EACA;EACA;;AAEJ;EACI;;AAEJ;EACI;EACA;EACA;EACA;;AACA;EACI;EACA;EACA;;AAEJ;EACI;EACA;;AAGR;EACI;;AACA;EACI;;AAGR;EACI;;AChFR;EACI;EACA;EACA;EACA;;AACA;EACI;;AAEJ;EACI;EACA;EACA;EACA;;AAEJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGR;AAAA;AAAA;EAGI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;AAAA;AAAA;EACI;;AHgBR;EG7BA;AAAA;AAAA;IAgBQ;;;AHkBR;EGlCA;AAAA;AAAA;IAmBQ;;;AAIJ;EACI;EACA;;AAEJ;EACI;EACA;;AAGR;EACI;;AAGA;EACI;EACA;EACA;;AACA;EACI;EACA;EACA;;AAEJ;EACI;;AHjBZ;EGxDJ;IA8EQ;;;AC9ER;EACI;;AAEJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAGA;EACI;EACA;;AACA;EACI;EACA;EACA;;AAGR;EACI;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;EACA;EACA;EACA;EACA;;AAEJ;EACI;;AAEJ;EACI;;AAEJ;EACI;EACA;EACA;EACA;;AACA;EACI;EACA;;AAEJ;EACI;EACA;;AAOZ;EACI;EACA;;AACA;EACI;EACA;;AAEJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;EACA;EACA;EACA;EACA;;AAEJ;EACI;EACA;EACA;EACA;;AACA;EACI;EACA;;AAEJ;EACI;EACA;;AAKhB;EACI;EACA;;AACA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEJ;EACI;EACA;EACA;;AACA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAOZ;AAAA;EACI;EACA;;AAEJ;AAAA;EACI;EACA;EACA;EACA;EACA;EACA;;AACA;AAAA;AAAA;EACI;EACA;EACA;EACA;EACA;;AAEJ;AAAA;EACI;;AAEJ;AAAA;EACI;;AAEJ;AAAA;EACI;EACA;EACA;EACA;;AACA;AAAA;EACI;EACA;;AAEJ;AAAA;EACI;EACA;;AAEJ;AAAA;EACI;EACA;;AACA;AAAA;EACI;;AAEJ;AAAA;EACI;EACA;EACA;EACA;;AAQhB;EACI;EACA;;AAEJ;EACI;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;EACA;EACA;EACA;EACA;;AAEJ;EACI;;AAEJ;EACI;;AAEJ;EACI;EACA;EACA;EACA;;AACA;EACI;EACA;;AAEJ;EACI;EACA;;AAGR;EACI;EACA;EACA;;AACA;EACI;;AAMZ;EACI;EACA;;AACA;EACI;EACA;;AAEJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;EACA;EACA;EACA;EACA;;AAEJ;EACI;EACA;EACA;EACA;;AACA;EACI;EACA;;AAEJ;EACI;EACA;;AAKhB;EACI;;AACA;EACI;EACA;EACA;;AACA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAIJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAKhB;EACI;EACA;EACA;;AACA;EACI;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;EACA;EACA;EACA;EACA;;AAEJ;EACI;EACA;EACA;EACA;;AACA;EACI;EACA;;AAEJ;EACI;EACA;;ACjVhB;EACI;;AACA;EACI;;AAGR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;;AAGR;EACI;EACA;EACA;;AACA;EACI;EACA;;ALDR;EKKI;IACI;IACA;IACA;;EACA;IACI;;;ALVZ;EExDJ;IAwFQ;IACA;IACA;IACA;;EACA;IACI;;EAEJ;IACI;IACA;;EAEJ;IACI;IACA;;EAEJ;IACI;;EACA;IACI;;EACA;IACI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;EACA;IACI;;EAKhB;IACI;IACA;;;AFjER;EE7DJ;IAkIQ;IACA;;EACA;IACI;;EAEJ;IACI;IACA;;EAEJ;IACI;IACA;IACA;;EACA;IACI;IACA;IACA;IACA;IACA;IACA;IACA;;EACA;IACI","file":"style.css"} \ No newline at end of file diff --git a/mitama/portal/static/white-logo.svg b/mitama/portal/static/white-logo.svg new file mode 100644 index 0000000..06122c4 --- /dev/null +++ b/mitama/portal/static/white-logo.svg @@ -0,0 +1,57 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/mitama/portal/templates/403.html b/mitama/portal/templates/403.html new file mode 100644 index 0000000..6dc514e --- /dev/null +++ b/mitama/portal/templates/403.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} +{% block header %} +{% endblock %} +{% block content %} +
+

403 Forbidden

+

アクセス権がありません。

+
+{% endblock %} diff --git a/mitama/portal/templates/404.html b/mitama/portal/templates/404.html new file mode 100644 index 0000000..52f1403 --- /dev/null +++ b/mitama/portal/templates/404.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} +{% block header %} +{% endblock %} +{% block content %} +
+

404 Not Found

+

お探しのページは見つかりませんでした。

+
+{% endblock %} diff --git a/mitama/portal/templates/apps/list.html b/mitama/portal/templates/apps/list.html new file mode 100644 index 0000000..a1cf073 --- /dev/null +++ b/mitama/portal/templates/apps/list.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %} +{% block header %} + {% include 'header.html' %} +{% endblock %} +{% block content %} +
+
+ +
+ {% if is_admin %} + + {% endif %} + +
+
+
+{% endblock %} diff --git a/mitama/portal/templates/apps/update.html b/mitama/portal/templates/apps/update.html new file mode 100644 index 0000000..8ffee7a --- /dev/null +++ b/mitama/portal/templates/apps/update.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} +{% block header %} + {% include 'header.html' %} +{% endblock %} +{% block content %} +{% if error==None %} +{% set post = request.post() %} +{% else %} +{% set post = {} %} +{% endif%} +
+

アプリ設定

+
+
+ {% for app in apps %} +
+ +
+
{{ app.name }}
+
{{ app.screen_name }}
+
+
プリフィクス
+ +
+
+
+ {% endfor %} +
+ + {% if error %} +

{{ error }}

+ {% endif %} + {% if message %} +

{{ message}}

+ {% endif %} +
+
+{% endblock %} + diff --git a/mitama/portal/templates/base.html b/mitama/portal/templates/base.html index cd69f50..59cbfdb 100644 --- a/mitama/portal/templates/base.html +++ b/mitama/portal/templates/base.html @@ -3,7 +3,12 @@ - + {% if entrance == True %} + + {% else %} + + {% endif %} + {{ title }} | Mitama {% block header %} diff --git a/mitama/portal/templates/entrance-header.html b/mitama/portal/templates/entrance-header.html new file mode 100644 index 0000000..2e249c9 --- /dev/null +++ b/mitama/portal/templates/entrance-header.html @@ -0,0 +1,5 @@ + diff --git a/mitama/portal/templates/group/create.html b/mitama/portal/templates/group/create.html new file mode 100644 index 0000000..b5a7dcf --- /dev/null +++ b/mitama/portal/templates/group/create.html @@ -0,0 +1,48 @@ +{% extends 'base.html' %} +{% block header %} + {% include 'header.html' %} +{% endblock %} +{% block content %} +{% set post = request.post() %} +
+

チームを作成

+
+
+
アイコン
+
+
+ + + +
+
+
ドメイン名
+
+ +
+
名前
+
+ +
+
所属グループ
+
+ +
+
+ +

{{ error }}

+
+
+{% endblock %} diff --git a/mitama/portal/templates/group/delete.html b/mitama/portal/templates/group/delete.html new file mode 100644 index 0000000..0b09a93 --- /dev/null +++ b/mitama/portal/templates/group/delete.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} +{% block header %} + {% include 'header.html' %} +{% endblock %} +{% block content %} +
+ +
+{% endblock %} diff --git a/mitama/portal/templates/group/list.html b/mitama/portal/templates/group/list.html new file mode 100644 index 0000000..c34521c --- /dev/null +++ b/mitama/portal/templates/group/list.html @@ -0,0 +1,48 @@ +{% extends 'base.html' %} +{% block header %} + {% include 'header.html' %} +{% endblock %} +{% block content %} +
+
+ +
+ {% if create_permission %} + + {% endif %} +
+
+ {% if groups|length %} + {% for group in groups recursive %} +
+
+ + +
+
{{ group.name }}
+
{{ group.screen_name }}
+
+
+ {% set children = group.children()|group %} + {% if children.length != 0: %} +
+ {{ loop(children) }} +
+ {% endif %} +
+ {% endfor %} + {% else%} +
グループは存在しません
+ {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/mitama/portal/templates/group/retrieve.html b/mitama/portal/templates/group/retrieve.html new file mode 100644 index 0000000..a5c5459 --- /dev/null +++ b/mitama/portal/templates/group/retrieve.html @@ -0,0 +1,52 @@ +{% extends 'base.html' %} +{% block header %} + {% include 'header.html' %} +{% endblock %} +{% block content %} +
+
+
+ +
+

{{group.name}}

+
{{group.screen_name}}
+
+
+ {% if group_update_permission(request.user, group) %} + + {% endif %} +
+
+
+ {% for g in group.children()|group %} + + {% endfor %} +
+
+
+
+

メンバー

+
+ {% for user in group.children()|user %} + + +
+
{{ user.name }}
+
{{ user.screen_name }}
+
+
+ {% endfor %} +
+
+
+ +{% endblock %} diff --git a/mitama/portal/templates/group/update.html b/mitama/portal/templates/group/update.html new file mode 100644 index 0000000..1f35463 --- /dev/null +++ b/mitama/portal/templates/group/update.html @@ -0,0 +1,213 @@ +{% extends 'base.html' %} +{% block header %} + {% include 'header.html' %} +{% endblock %} +{% block content %} +{% if error == None %} +{% set post = request.post() %} +{% else %} +{% set post = {} %} +{% endif %} +
+

{{ group.name }}の設定

+
+ + +
+{% endblock %} diff --git a/mitama/portal/templates/header.html b/mitama/portal/templates/header.html index b77c8ac..a82b07c 100644 --- a/mitama/portal/templates/header.html +++ b/mitama/portal/templates/header.html @@ -1,3 +1,31 @@ diff --git a/mitama/portal/templates/home.html b/mitama/portal/templates/home.html index a039aa7..f223755 100644 --- a/mitama/portal/templates/home.html +++ b/mitama/portal/templates/home.html @@ -1,7 +1,8 @@ {% extends 'base.html' %} {% block header %} - {% extends 'header.html' %} + {% include 'header.html' %} {% endblock %} {% block content %} -
+
+
{% endblock %} diff --git a/mitama/portal/templates/login.html b/mitama/portal/templates/login.html index 6951b49..d5d6cb4 100644 --- a/mitama/portal/templates/login.html +++ b/mitama/portal/templates/login.html @@ -1,12 +1,19 @@ +{% set entrance = True %} {% extends 'base.html' %} {% block header %} + {% include "entrance-header.html" %} {% endblock %} {% block content %}
-
- - - + +
+

ログイン

+ + +
+
+ +

{{ error }}

diff --git a/mitama/portal/templates/setup.html b/mitama/portal/templates/setup.html new file mode 100644 index 0000000..64403ce --- /dev/null +++ b/mitama/portal/templates/setup.html @@ -0,0 +1,50 @@ +{% set entrance = True %} +{% extends 'base.html' %} +{% block header %} + {% include "entrance-header.html" %} +{% endblock %} +{% block content %} +{% set post = request.post() %} +
+

ようこそ

+

+ さっそく最初のユーザーを作り、 + Mitamaを始めましょう +

+
+
+
アイコン
+
+
+ + + +
+
+
ログイン名
+
+ +
+
名前
+
+ +
+
パスワード
+
+ +
+
+
+ +
+

{{ error }}

+
+
+{% endblock %} diff --git a/mitama/portal/templates/signup.html b/mitama/portal/templates/signup.html new file mode 100644 index 0000000..eb6d52c --- /dev/null +++ b/mitama/portal/templates/signup.html @@ -0,0 +1,47 @@ +{% set entrance = True %} +{% extends 'base.html' %} +{% block header %} + {% include "entrance-header.html" %} +{% endblock %} +{% block content %} +
+

Mitamaに参加する

+
+
+
アイコン
+
+
+ + + +
+
+
ログイン名
+
+ +
+
名前
+
+ +
+
パスワード
+
+ +
+
+
+ +
+

{{ error }}

+
+
+{% endblock %} diff --git a/mitama/portal/templates/user/create.html b/mitama/portal/templates/user/create.html new file mode 100644 index 0000000..ec338e5 --- /dev/null +++ b/mitama/portal/templates/user/create.html @@ -0,0 +1,78 @@ +{% extends 'base.html' %} +{% block header %} + {% include 'header.html' %} +{% endblock %} +{% block content %} +{% if error == None %} +{% set post = request.post() %} +{% else %} +{% set post = {} %} +{% endif %} + +
+

ユーザーを招待

+
+
+
+
アイコン
+
+
+ + + +
+
+
ログイン名
+
+ +
+
名前
+
+ +
+
+ + {% if error %} +

{{ error }}

+ {% endif %} +
+
+

招待中ユーザー

+
+ {% if invites|length %} + {% for invite in invites %} +
+ +
+
{{invite.name}}
+
{{invite.screen_name}}
+ +
+
+ {% endfor %} + {% else%} +
招待しているユーザーはいません
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/mitama/portal/templates/user/delete.html b/mitama/portal/templates/user/delete.html new file mode 100644 index 0000000..0b09a93 --- /dev/null +++ b/mitama/portal/templates/user/delete.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} +{% block header %} + {% include 'header.html' %} +{% endblock %} +{% block content %} +
+ +
+{% endblock %} diff --git a/mitama/portal/templates/user/list.html b/mitama/portal/templates/user/list.html new file mode 100644 index 0000000..8f21921 --- /dev/null +++ b/mitama/portal/templates/user/list.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} +{% block header %} + {% include 'header.html' %} +{% endblock %} +{% block content %} +
+
+ +
+ {% if create_permission %} + + {% endif %} +
+
+ {% if users|length %} + {% for user in users %} + + +
+
{{ user.name }}
+
{{ user.screen_name }}
+
+
+ {% endfor %} + {% else%} +
ユーザーはいません
+ {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/mitama/portal/templates/user/retrieve.html b/mitama/portal/templates/user/retrieve.html new file mode 100644 index 0000000..5e52601 --- /dev/null +++ b/mitama/portal/templates/user/retrieve.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} +{% block header %} + {% include 'header.html' %} +{% endblock %} +{% block content %} +
+
+
+ +
+

{{user.name}}

+
{{user.screen_name}}
+
+
+ {% if user_update_permission(request.user, user) %} + + {% endif %} +
+
+
+

所属

+
+ {% for g in user.parents() %} + + {% endfor %} +
+
+
+
+{% endblock %} diff --git a/mitama/portal/templates/user/update.html b/mitama/portal/templates/user/update.html new file mode 100644 index 0000000..64419fd --- /dev/null +++ b/mitama/portal/templates/user/update.html @@ -0,0 +1,108 @@ +{% extends 'base.html' %} +{% block header %} + {% include 'header.html' %} +{% endblock %} +{% block content %} +{% if error == None %} +{% set post = request.post() %} +{% else %} +{% set post = {} %} +{% endif %} +
+ {% if request.user.id == user.id %} +

設定

+ {% else %} +

{{ user.name }}の設定

+ {% endif %} +
+
+
+
アイコン
+
+
+ + + +
+
+
ログイン名
+
+ +
+
名前
+
+ +
+ {% if is_admin(request.user) %} +
権限の設定
+
+ + + + + + + +
+ {% endif %} +
+ + {% if error %} +

{{ error }}

+ {% endif %} + {% if message %} +

{{ message}}

+ {% endif %} +
+
+
+{% endblock %} diff --git a/mitama/portal/views.py b/mitama/portal/views.py deleted file mode 100644 index f587497..0000000 --- a/mitama/portal/views.py +++ /dev/null @@ -1,34 +0,0 @@ -from mitama.http import Response, get_session -from mitama.app import Template, PackageLoader, Environment -from mitama.auth import password_auth, get_jwt, AuthorizationError - -env = Environment(loader = PackageLoader(__package__, './templates')) - -async def home(request): - template = env.get_template('home.html') - return Response.render(template) - -async def login(request): - data = {} - if request.method == 'POST': - post = await request.post() - screen_name = post.get('screen_name', '') - password = post.get('password', '') - data['screen_name'] = screen_name - data['password'] = password - try: - result = password_auth(screen_name, password) - sess = await get_session(request) - sess['jwt_token'] = get_jwt(result) - redirect_to = request.query.get('redirect_to', '') - return Response( - headers={ - 'Location': redirect_to - }, - status = 200 - ) - except AuthorizationError as err: - data['error'] = 'パスワード、またはログイン名が間違っています' - template = env.get_template('login.html') - return Response.render(template, data, status = 401) - diff --git a/mitama/skeleton/__init__.py b/mitama/skeleton/__init__.py index 1539eab..2a6d270 100644 --- a/mitama/skeleton/__init__.py +++ b/mitama/skeleton/__init__.py @@ -14,6 +14,10 @@ def __init__(self, engine = None): else: self.set_engine(engine) -def init_app(name): +def init_app(name, path, include, **kwargs): meta = Metadata() - meta.name = name \ No newline at end of file + meta.name = name + meta.path = path + meta.include = include + for k in kwargs.keys(): + meta.setattr(k, kwargs[k]) diff --git a/mitama/skeleton/main.py b/mitama/skeleton/main.py index 5669557..82a2203 100644 --- a/mitama/skeleton/main.py +++ b/mitama/skeleton/main.py @@ -4,4 +4,5 @@ meta = Metadata() app = App(meta) + app.router.add_routes(urls.urls) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5e86e95 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1106 @@ +{ + "name": "mitama", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@sindresorhus/is": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", + "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", + "dev": true + }, + "@szmarczak/http-timer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", + "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", + "dev": true, + "requires": { + "defer-to-connect": "^1.0.1" + } + }, + "@types/color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", + "dev": true + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "ansi-align": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz", + "integrity": "sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw==", + "dev": true, + "requires": { + "string-width": "^3.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "anymatch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", + "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "binary-extensions": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz", + "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==", + "dev": true + }, + "boxen": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-4.2.0.tgz", + "integrity": "sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ==", + "dev": true, + "requires": { + "ansi-align": "^3.0.0", + "camelcase": "^5.3.1", + "chalk": "^3.0.0", + "cli-boxes": "^2.2.0", + "string-width": "^4.1.0", + "term-size": "^2.1.0", + "type-fest": "^0.8.1", + "widest-line": "^3.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "cacheable-request": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", + "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", + "dev": true, + "requires": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^3.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^4.1.0", + "responselike": "^1.0.2" + }, + "dependencies": { + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true + } + } + }, + "chokidar": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.2.tgz", + "integrity": "sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A==", + "dev": true, + "requires": { + "anymatch": "~3.1.1", + "braces": "~3.0.2", + "fsevents": "~2.1.2", + "glob-parent": "~5.1.0", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.4.0" + } + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "dev": true + }, + "clone-response": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", + "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", + "dev": true, + "requires": { + "mimic-response": "^1.0.0" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "configstore": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", + "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", + "dev": true, + "requires": { + "dot-prop": "^5.2.0", + "graceful-fs": "^4.1.2", + "make-dir": "^3.0.0", + "unique-string": "^2.0.0", + "write-file-atomic": "^3.0.0", + "xdg-basedir": "^4.0.0" + } + }, + "crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "decompress-response": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", + "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", + "dev": true, + "requires": { + "mimic-response": "^1.0.0" + } + }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true + }, + "defer-to-connect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", + "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", + "dev": true + }, + "dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "requires": { + "is-obj": "^2.0.0" + } + }, + "duplexer3": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", + "dev": true + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "escape-goat": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", + "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==", + "dev": true + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "fsevents": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz", + "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==", + "dev": true, + "optional": true + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "glob-parent": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", + "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "global-dirs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-2.0.1.tgz", + "integrity": "sha512-5HqUqdhkEovj2Of/ms3IeS/EekcO54ytHRLV4PEY2rhRwrHXLQjeVEES0Lhka0xwNDtGYn58wyC4s5+MHsOO6A==", + "dev": true, + "requires": { + "ini": "^1.3.5" + } + }, + "got": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", + "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", + "dev": true, + "requires": { + "@sindresorhus/is": "^0.14.0", + "@szmarczak/http-timer": "^1.1.2", + "cacheable-request": "^6.0.0", + "decompress-response": "^3.3.0", + "duplexer3": "^0.1.4", + "get-stream": "^4.1.0", + "lowercase-keys": "^1.0.1", + "mimic-response": "^1.0.1", + "p-cancelable": "^1.0.0", + "to-readable-stream": "^1.0.0", + "url-parse-lax": "^3.0.0" + } + }, + "graceful-fs": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-yarn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz", + "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==", + "dev": true + }, + "http-cache-semantics": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", + "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", + "dev": true + }, + "ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha1-SMptcvbGo68Aqa1K5odr44ieKwk=", + "dev": true + }, + "import-lazy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", + "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=", + "dev": true + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "dev": true + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "requires": { + "ci-info": "^2.0.0" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-installed-globally": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz", + "integrity": "sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g==", + "dev": true, + "requires": { + "global-dirs": "^2.0.1", + "is-path-inside": "^3.0.1" + } + }, + "is-npm": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz", + "integrity": "sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true + }, + "is-path-inside": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz", + "integrity": "sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==", + "dev": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "is-yarn-global": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz", + "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==", + "dev": true + }, + "json-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", + "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=", + "dev": true + }, + "keyv": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", + "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", + "dev": true, + "requires": { + "json-buffer": "3.0.0" + } + }, + "latest-version": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz", + "integrity": "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==", + "dev": true, + "requires": { + "package-json": "^6.3.0" + } + }, + "lowercase-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", + "dev": true + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "nodemon": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.4.tgz", + "integrity": "sha512-Ltced+hIfTmaS28Zjv1BM552oQ3dbwPqI4+zI0SLgq+wpJhSyqgYude/aZa/3i31VCQWMfXJVxvu86abcam3uQ==", + "dev": true, + "requires": { + "chokidar": "^3.2.2", + "debug": "^3.2.6", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.0.4", + "pstree.remy": "^1.1.7", + "semver": "^5.7.1", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.2", + "update-notifier": "^4.0.0" + }, + "dependencies": { + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "normalize-url": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz", + "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "p-cancelable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", + "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", + "dev": true + }, + "package-json": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz", + "integrity": "sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==", + "dev": true, + "requires": { + "got": "^9.6.0", + "registry-auth-token": "^4.0.0", + "registry-url": "^5.0.0", + "semver": "^6.2.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "picomatch": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", + "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", + "dev": true + }, + "prepend-http": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", + "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", + "dev": true + }, + "pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "pupa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.0.1.tgz", + "integrity": "sha512-hEJH0s8PXLY/cdXh66tNEQGndDrIKNqNC5xmrysZy3i5C3oEoLna7YAOad+7u125+zH1HNXUmGEkrhb3c2VriA==", + "dev": true, + "requires": { + "escape-goat": "^2.0.0" + } + }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + } + }, + "readdirp": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.4.0.tgz", + "integrity": "sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "registry-auth-token": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.0.tgz", + "integrity": "sha512-P+lWzPrsgfN+UEpDS3U8AQKg/UjZX6mQSJueZj3EK+vNESoqBSpBUD3gmu4sF9lOsjXWjF11dQKUqemf3veq1w==", + "dev": true, + "requires": { + "rc": "^1.2.8" + } + }, + "registry-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-5.1.0.tgz", + "integrity": "sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==", + "dev": true, + "requires": { + "rc": "^1.2.8" + } + }, + "responselike": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", + "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", + "dev": true, + "requires": { + "lowercase-keys": "^1.0.0" + } + }, + "sass": { + "version": "1.26.11", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.26.11.tgz", + "integrity": "sha512-W1l/+vjGjIamsJ6OnTe0K37U2DBO/dgsv2Z4c89XQ8ZOO6l/VwkqwLSqoYzJeJs6CLuGSTRWc91GbQFL3lvrvw==", + "dev": true, + "requires": { + "chokidar": ">=2.0.0 <4.0.0" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "semver-diff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", + "integrity": "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==", + "dev": true, + "requires": { + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + }, + "term-size": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.0.tgz", + "integrity": "sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw==", + "dev": true + }, + "to-readable-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", + "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, + "requires": { + "nopt": "~1.0.10" + }, + "dependencies": { + "nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=", + "dev": true, + "requires": { + "abbrev": "1" + } + } + } + }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "requires": { + "is-typedarray": "^1.0.0" + } + }, + "undefsafe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.3.tgz", + "integrity": "sha512-nrXZwwXrD/T/JXeygJqdCO6NZZ1L66HrxM/Z7mIq2oPanoN0F1nLx3lwJMu6AwJY69hdixaFQOuoYsMjE5/C2A==", + "dev": true, + "requires": { + "debug": "^2.2.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "requires": { + "crypto-random-string": "^2.0.0" + } + }, + "update-notifier": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-4.1.1.tgz", + "integrity": "sha512-9y+Kds0+LoLG6yN802wVXoIfxYEwh3FlZwzMwpCZp62S2i1/Jzeqb9Eeeju3NSHccGGasfGlK5/vEHbAifYRDg==", + "dev": true, + "requires": { + "boxen": "^4.2.0", + "chalk": "^3.0.0", + "configstore": "^5.0.1", + "has-yarn": "^2.1.0", + "import-lazy": "^2.1.0", + "is-ci": "^2.0.0", + "is-installed-globally": "^0.3.1", + "is-npm": "^4.0.0", + "is-yarn-global": "^0.3.0", + "latest-version": "^5.0.0", + "pupa": "^2.0.1", + "semver-diff": "^3.1.1", + "xdg-basedir": "^4.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "url-parse-lax": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", + "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", + "dev": true, + "requires": { + "prepend-http": "^2.0.0" + } + }, + "widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dev": true, + "requires": { + "string-width": "^4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a8fd728 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "mitama", + "version": "1.0.0", + "description": "", + "main": "index.js", + "directories": { + "example": "examples", + "test": "tests" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build-css": "sass scss/style.scss mitama/portal/static/style.css; sass scss/entrance-style.scss mitama/portal/static/entrance-style.css", + "watch-css": "nodemon -e scss -x \"npm run build-css\"" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/mitama-org/mitama.git" + }, + "author": "", + "license": "GPL-3.0-or-later", + "bugs": { + "url": "https://github.com/mitama-org/mitama/issues" + }, + "homepage": "https://github.com/mitama-org/mitama#readme", + "devDependencies": { + "nodemon": "^2.0.4", + "sass": "^1.26.11" + } +} diff --git a/scss/base.scss b/scss/base.scss new file mode 100644 index 0000000..b9753d7 --- /dev/null +++ b/scss/base.scss @@ -0,0 +1,65 @@ +/* + * 色定義 +*/ +:root{ + --mtm-blue: #49a3e0; + --base-background: #efefef; + --header-text: #fff; + --main-color: var(--mtm-blue);; + --header-background: var(--main-color); + --main-background: #ffffff; + --main-decoration: #cccccc; + --main-text: #444444; + --main-text-lighter: #777777; + --accent-text: #b33e5c; + --accent-background: #b33e5c; +} + +/* + * 共通スタイル + */ +body{ + margin: 0; + background: var(--base-background); + color: var(--main-text); + font-size: 14px; +} + +a { + color: var(--accent-text); +} + +button { + display: flex; + background: var(--accent-background); + color: var(--main-background); + font-weight: bold; + height: 32px; + width: auto; + padding: 0 8px; + border: none; + border-radius: 4px; + justify-content: center; + align-items: center; +} + +.user-icon{ + border-radius: 50%; +} +.icon { + object-fit: cover; +} + +/* + * レスポンシブ + */ +@mixin pc{ + @media screen and (min-width: 901px) { + @content + } +} +@mixin sp { + @media screen and (max-width: 900px) { + @content + } +} diff --git a/scss/entrance-style.scss b/scss/entrance-style.scss new file mode 100644 index 0000000..15b6c6d --- /dev/null +++ b/scss/entrance-style.scss @@ -0,0 +1,88 @@ +@import './base.scss'; +body{ + background: #fff; +} +header{ + height: 56px; + padding: 8px; + #logo{ + height: 56px; + } +} +#content{ + padding: 64px 0; + text-align: center; + p>span{ + display: inline-block; + } + form{ + text-align: left; + padding: 24px; + max-width: 400px; + margin: 0 auto; + .main{ + width: 320px; + margin: 0 auto; + } + input{ + height: 32px; + line-height:32px; + padding: 0 8px; + border: none; + border-bottom-width: 2px; + border-bottom-style: solid; + border-bottom-color: #0000; + outline: none; + transition: all 0.2s; + width: 100%; + &:focus{ + border-bottom-color: var(--accent-background); + } + } + #image-form { + display: grid; + grid-template-columns: 80px 1fr; + grid-gap: 16px; + margin: 16px 0; + align-items: center; + input{ + display: none; + } + img { + display: block; + width: 80px; + height: 80px; + border-radius: 40px; + } + .button { + display: flex; + background: var(--accent-background); + color: var(--main-background); + font-weight: bold; + height: 32px; + max-width: 160px; + border-radius: 4px; + justify-content: center; + align-items: center; + } + } + dl{ + margin: 0px 0 32px; + dt{ + font-size: 0.8rem; + font-weight: bold; + } + dd{ + margin:8px 0 16px; + } + } + .submit{ + display: flex; + justify-content: center; + margin-top: 32px; + button{ + width: 120px; + } + } + } +} diff --git a/scss/form.scss b/scss/form.scss new file mode 100644 index 0000000..ecdfbe5 --- /dev/null +++ b/scss/form.scss @@ -0,0 +1,81 @@ +form { + #image-form { + display: grid; + grid-template-columns: 80px 1fr; + grid-gap: 16px; + align-items: center; + input{ + display: none; + } + img { + display: block; + width: 80px; + height: 80px; + border-radius: 40px; + } + .button { + display: flex; + background: var(--accent-background); + color: var(--main-background); + font-weight: bold; + height: 32px; + width: 160px; + border-radius: 4px; + justify-content: center; + align-items: center; + } + } + input[type = 'text'], + input[type = 'password'], + input[type = 'email'] { + height: 32px; + line-height: 32px; + padding: 0 8px; + box-sizing: border-box; + display: block; + border: none; + border-bottom: 2px solid #0000; + outline: none; + transition: border-bottom 0.2s; + &:focus { + border-bottom: 2px solid var(--accent-background); + } + @include pc{ + width: 200px; + } + @include sp{ + width: 100%; + } + } + dl{ + dt{ + font-weight: bold; + font-size: 0.8rem; + } + dd{ + margin: 0; + padding: 16px 24px 32px; + } + } + button[type='submit'] { + width: 160px; + } + .permission{ + label{ + display: grid; + grid-template-columns: 24px 1fr; + height: 32px; + input[type='checkbox'] { + width: 12px; + height: 12px; + margin: 10px 6px; + } + div{ + line-height: 32px; + } + } + } + @include pc{ + padding: 32px; + } +} diff --git a/scss/header.scss b/scss/header.scss new file mode 100644 index 0000000..6645d0c --- /dev/null +++ b/scss/header.scss @@ -0,0 +1,88 @@ +header{ + height: 56px; + display: grid; + grid-template-columns: 1fr auto; + background: var(--header-background); + position: sticky; + top: 0; + color: var(--header-text); + #logo{ + height: 40px; + display: block; + } + & > div { + padding: 8px 16px; + } + #user-menu{ + display: grid; + grid-gap: 8px; + grid-template-columns: 32px 1fr; + align-items: center; + height: 40px; + font-weight: bold; + background: #0001; + width: 160px; + .user-icon{ + width: 32px; + height: 32px; + } + } + menu { + display: flex; + opacity: 0; + flex-direction: column; + justify-content: flex-end; + position: absolute; + top: -20px; + pointer-events: none; + width: 200px; + right: 0; + display: block; + margin: 0; + padding: 16px; + transition: all 0.2s; + background: var(--main-background); + box-shadow: 0 0 10px #0004; + & > div { + border-bottom: 1px solid var(--main-decoration); + padding-bottom: 8px; + margin-bottom: 8px; + } + & div:last-child { + border: none; + padding-bottom: 0; + margin-bottom: 0; + } + a { + display: grid; + grid-template-columns: 32px 1fr; + grid-gap: 8px; + width: 100%; + height:40px; + justify-content: center; + align-items: center; + text-decoration: none; + padding: 0 4px; + font-weight: bold; + color: var(--main-text); + transition: all 0.2s; + svg { + width: 32px; + height: 32px; + fill: var(--main-text); + transition: all 0.2s; + } + &:hover{ + color: var(--main-color); + svg{ + fill: var(--main-color); + } + } + } + } + #user-menu:hover > menu { + opacity: 1; + pointer-events: all; + top: 0px; + } +} diff --git a/scss/list.scss b/scss/list.scss new file mode 100644 index 0000000..d9d5103 --- /dev/null +++ b/scss/list.scss @@ -0,0 +1,342 @@ +.item .icon { + border-radius: 50%; +} +.no-item{ + color: var(--main-text-lighter); + font-size: 0.8rem; + display: flex; + justify-content: center; + align-items: center; + padding: 24px 0; +} +.member-append-list{ + .item-container{ + display: grid; + grid-template-columns: 24px 1fr; + input[type='checkbox'] { + margin: 30px 6px; + width: 12px; + height: 12px; + } + } + .item { + display: grid; + grid-template-columns: 56px 1fr; + grid-gap: 16px; + text-decoration: none; + color: var(--main-text); + padding: 8px; + .user-icon, .icon{ + display: block; + width: 56px; + height: 56px; + box-sizing: border-box; + border: 2px solid #0001; + } + .user-icon { + border-radius: 28px; + } + .icon { + border-radius: 4px; + } + .detail { + display: flex; + justify-content: space-around; + flex-direction: column; + padding: 8px 0; + .name{ + font-size: 1rem; + font-weight: bold; + } + .screen-name{ + font-size: 0.85rem; + color: var(--main-text-lighter); + } + } + } +} + +.group-list { + .item{ + display: grid; + grid-template-columns: auto 1fr; + .branch { + grid-column: 1/2; + width: 0px; + } + .profile{ + display: grid; + text-decoration: none; + color: var(--main-text); + grid-column: 2/3; + grid-template-columns: 56px 1fr; + grid-gap: 16px; + padding: 8px; + .icon { + width: 56px; + height: 56px; + border-radius: 4px; + border: 2px solid #0001; + box-sizing: border-box; + } + .detail { + display: flex; + justify-content: space-around; + flex-direction: column; + padding: 8px 0; + .name{ + font-size: 1rem; + font-weight: bold; + } + .screen-name{ + font-size: 0.85rem; + color: var(--main-text-lighter); + } + } + } + } + .children { + grid-column: 2/3; + position: relative; + &::before{ + content: ''; + display: block; + position: absolute; + width: 2px; + height: calc(100% - 45px); + left: 16px; + background: var(--main-color); + } + .branch { + width: 24px; + display: block; + position: relative; + &::before { + content: ""; + display: block; + position: absolute; + box-sizing: border-box; + border-left: 2px solid var(--main-color); + border-bottom: 2px solid var(--main-color); + width: 8px; + height: 40px; + left: 16px; + top: -8px; + border-bottom-left-radius: 4px; + } + } + } +} +.user-list, +.invite-list{ + h4{ + margin: 16px 0 8px; + font-size: 0.75rem; + } + .item { + display: grid; + grid-template-columns: 56px 1fr; + grid-gap: 16px; + text-decoration: none; + color: var(--main-text); + padding: 8px; + .user-icon, .icon{ + display: block; + width: 56px; + height: 56px; + box-sizing: border-box; + border: 2px solid #0001; + } + .user-icon { + border-radius: 28px; + } + .icon { + border-radius: 4px; + } + .detail { + display: flex; + justify-content: space-around; + flex-direction: column; + padding: 8px 0; + .name{ + font-size: 1rem; + font-weight: bold; + } + .screen-name{ + font-size: 0.85rem; + color: var(--main-text-lighter); + } + .buttons{ + text-align: right; + font-size: 0.8rem; + a{ + display: inline-block; + } + input{ + display: block; + position: absolute; + overflow: hidden; + opacity: 0 + } + } + } + } +} + +.member-control-list{ + h4{ + margin: 16px 0 8px; + font-size: 0.75rem; + } + .item { + display: grid; + grid-template-columns: 56px 1fr auto; + grid-gap: 16px; + text-decoration: none; + color: var(--main-text); + padding: 8px; + .user-icon, .icon{ + display: block; + width: 56px; + height: 56px; + box-sizing: border-box; + border: 2px solid #0001; + } + .user-icon { + border-radius: 28px; + } + .icon { + border-radius: 4px; + } + .detail { + display: flex; + justify-content: space-around; + flex-direction: column; + padding: 8px 0; + .name{ + font-size: 1rem; + font-weight: bold; + } + .screen-name{ + font-size: 0.85rem; + color: var(--main-text-lighter); + } + } + .actions{ + display: flex; + justify-content: center; + align-items: center; + a{ + margin-left: 4px; + } + } + } +} +.group-list { + .item{ + display: grid; + grid-template-columns: auto 1fr; + .branch { + grid-column: 1/2; + width: 0px; + } + .profile{ + display: grid; + text-decoration: none; + color: var(--main-text); + grid-column: 2/3; + grid-template-columns: 56px 1fr; + grid-gap: 16px; + padding: 8px; + .icon { + width: 56px; + height: 56px; + border-radius: 4px; + border: 2px solid #0001; + box-sizing: border-box; + } + .detail { + display: flex; + justify-content: space-around; + flex-direction: column; + padding: 8px 0; + .name{ + font-size: 1rem; + font-weight: bold; + } + .screen-name{ + font-size: 0.85rem; + color: var(--main-text-lighter); + } + } + } + } + .children { + grid-column: 2/3; + .branch { + width: 24px; + display: block; + position: relative; + &::before { + content: ""; + display: block; + position: absolute; + box-sizing: border-box; + border-left: 2px solid var(--main-color); + border-bottom: 2px solid var(--main-color); + width: 8px; + height: 40px; + left: 16px; + top: -8px; + border-bottom-left-radius: 4px; + } + } + .item + .item >.branch { + &::after{ + content: ""; + display: block; + position: absolute; + box-sizing: border-box; + border-left: 2px solid var(--main-color); + width: 8px; + height: 38px; + left: 16px; + top: -38px; + } + } + } +} +.app-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + justify-content: start; + .item { + display: grid; + text-decoration: none; + color: var(--main-text); + grid-template-columns: 56px 1fr; + grid-gap: 16px; + padding: 8px; + .icon { + width: 56px; + height: 56px; + border-radius: 4px; + border: 2px solid #0001; + box-sizing: border-box; + } + .detail { + display: flex; + justify-content: space-around; + flex-direction: column; + padding: 8px 0; + .name{ + font-size: 1rem; + font-weight: bold; + } + .screen-name{ + font-size: 0.85rem; + color: var(--main-text-lighter); + } + } + } +} diff --git a/scss/main.scss b/scss/main.scss new file mode 100644 index 0000000..29e546a --- /dev/null +++ b/scss/main.scss @@ -0,0 +1,158 @@ +#content{ + margin-top: 16px; + margin-left: auto; + margin-right: auto; + background: var(--main-background); + .mini-title{ + font-size: 0.8rem; + font-weight: bold; + height: 40px; + line-height: 40px; + margin: 0; + padding: 8px 16px; + &.dark { + border-bottom: 1px solid #eee; + background-color: #00000005; + } + } + .thin-title{ + font-size: 0.8rem; + font-weight: bold; + &.between{ + display: flex; + justify-content: space-between; + align-items: center; + } + } + .tool-box{ + display: flex; + padding: 16px; + justify-content: flex-end; + } + .tab-container{ + display: grid; + .tab-header{ + a{ + height: 32px; + box-sizing: border-box; + text-decoration: none; + font-weight:bold; + font-size: 0.8rem; + } + } + } + .profile-container{ + display:grid; + grid-template-columns: 80px 1fr 80px; + grid-gap: 8px; + padding: 24px; + max-width: 800px; + margin: 0 auto; + .user-icon, .icon { + width: 80px; + height:80px; + box-sizing:border-box; + border: 4px solid #0001; + } + .icon{ + border-radius: 8px; + } + .detail { + padding: 12px; + display: flex; + flex-direction: column; + justify-content: space-around; + .name { + font-size: 1.1rem; + color: var(--main-text); + margin: 0; + } + .screen-name { + font-size: 1rem; + color: var(--main-text-lighter); + } + } + .action{ + grid-column: 3/4; + button{ + width: 100%; + } + } + .group-list { + grid-column: 1/3; + } + } + @import './form.scss'; + @import './list.scss'; + @import './modal.scss'; + @include pc{ + max-width: 1200px; + width: calc(100% - 100px); + margin-left: auto; + margin-right: auto; + section{ + padding: 32px; + } + .inner { + padding-top: 32px; + padding-bottom: 32px; + } + .tight { + padding-left: 32px; + padding-right: 32px; + } + .tab-container{ + grid-template-columns: 240px 1fr; + .tab-header{ + padding: 32px 16px; + a { + height: 32px; + line-height: 32px; + padding: 0 16px; + display: block; + background: var(--main-background); + color: var(--main-color); + font-weight: bold; + border-left-width: 4px; + border-left-style: solid; + border-left-color: #0000; + &.focus { + border-left-color: var(--main-color); + } + } + } + } + .left-main{ + display: grid; + grid-template-columns: 1fr 400px; + } + } + @include sp { + max-width: 900px; + width: 100%; + .inner { + padding: 8px; + } + .tab-container{ + grid-template-rows: 32px 1fr; + grid-gap: 8px; + } + .tab-header{ + height: 32px; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + a { + display: flex; + border-top-width: 4px; + border-top-style: solid; + border-top-color: var(--main-background); + color: var(--main-text); + justify-content: center; + align-items: center; + &.focus{ + border-top-color: var(--main-color); + } + } + } + } +} diff --git a/scss/modal.scss b/scss/modal.scss new file mode 100644 index 0000000..b818e13 --- /dev/null +++ b/scss/modal.scss @@ -0,0 +1,71 @@ +#modal-switch { + display: none; + &:checked ~ .modal { + display: flex; + } +} +.modal { + position: fixed; + top:0; + left:0; + right:0; + bottom:0; + width: 100vw; + height: 100vh; + display: none; + .background{ + display: block; + background: #0003; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + } + .inner{ + padding: 24px; + background: #fff; + margin: 100px auto; + width: calc(100% - 64px); + max-width: 600px; + position: absolute; + left: 0; + right: 0; + overflow-y: scroll; + z-index: 2; + } + input[type='search'] { + width: 100%; + box-sizing: border-box; + height: 32px; + line-height: 32px; + border: none; + outline: none; + border-bottom-width: 2px; + border-bottom-style: solid; + border-bottom-color: #0000; + transition: all 0.2s; + &:focus{ + border-bottom-color: var(--accent-background); + } + } + .modal-submit{ + display: flex; + justify-content: center; + margin:16px 24px; + button{ + width: 120px; + margin: 0 8px; + } + } + @include pc{ + .two-columns{ + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 16px; + h4{ + margin: 8px 0 4px; + } + } + } +} diff --git a/scss/style.scss b/scss/style.scss new file mode 100644 index 0000000..b652b51 --- /dev/null +++ b/scss/style.scss @@ -0,0 +1,3 @@ +@import './base.scss'; +@import './header.scss'; +@import './main.scss'; diff --git a/setup.py b/setup.py index 3fbbf1f..83d5193 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name = 'mitama', version = '1.0.0', - install_requires = ['sqlalchemy', 'aiohttp', 'bcrypt', 'aiohttp_session', 'pyjwt', 'jinja2', 'cryptography'], + install_requires = ['sqlalchemy', 'aiohttp', 'bcrypt', 'aiohttp_session', 'pyjwt', 'jinja2', 'cryptography', 'python-magic'], extra_requires = { 'develop': ['pytest'] }, diff --git a/tests/interface/app1/__init__.py b/tests/interface/app1/__init__.py index 5dc64b8..2a6d270 100644 --- a/tests/interface/app1/__init__.py +++ b/tests/interface/app1/__init__.py @@ -14,6 +14,10 @@ def __init__(self, engine = None): else: self.set_engine(engine) -def init_app(name): +def init_app(name, path, include, **kwargs): meta = Metadata() meta.name = name + meta.path = path + meta.include = include + for k in kwargs.keys(): + meta.setattr(k, kwargs[k]) diff --git a/tests/interface/bone/__init__.py b/tests/interface/bone/__init__.py new file mode 100644 index 0000000..1539eab --- /dev/null +++ b/tests/interface/bone/__init__.py @@ -0,0 +1,19 @@ +from mitama.app import BaseMetadata +from mitama.db import get_app_engine, Database as BaseDatabase + +class Metadata(BaseMetadata): + pass + +class Database(BaseDatabase): + def __init__(self, engine = None): + super().__init__() + meta = Metadata() + if self.engine == None: + if engine == None: + self.set_engine(get_app_engine(meta.name)) + else: + self.set_engine(engine) + +def init_app(name): + meta = Metadata() + meta.name = name \ No newline at end of file diff --git a/tests/interface/bone/main.py b/tests/interface/bone/main.py new file mode 100644 index 0000000..82a2203 --- /dev/null +++ b/tests/interface/bone/main.py @@ -0,0 +1,8 @@ +from . import Metadata, urls +from mitama.app import App + +meta = Metadata() +app = App(meta) + + +app.router.add_routes(urls.urls) diff --git a/tests/interface/bone/model.py b/tests/interface/bone/model.py new file mode 100644 index 0000000..629dafe --- /dev/null +++ b/tests/interface/bone/model.py @@ -0,0 +1,6 @@ +from mitama.db.types import * +from . import Database + +db = Database() + + diff --git a/tests/interface/bone/templates/welcome.html b/tests/interface/bone/templates/welcome.html new file mode 100644 index 0000000..983bfcb --- /dev/null +++ b/tests/interface/bone/templates/welcome.html @@ -0,0 +1,10 @@ + + + + + Welcome to kanoke + + +

Welcome!

+ + diff --git a/mitama/portal/urls.py b/tests/interface/bone/urls.py similarity index 52% rename from mitama/portal/urls.py rename to tests/interface/bone/urls.py index 49f4497..44c7be1 100644 --- a/mitama/portal/urls.py +++ b/tests/interface/bone/urls.py @@ -2,6 +2,5 @@ from . import views urls = [ - view('/', views.home), - view('/login', views.login) + view('/', views.welcome) ] diff --git a/tests/interface/bone/views.py b/tests/interface/bone/views.py new file mode 100644 index 0000000..7a8acc6 --- /dev/null +++ b/tests/interface/bone/views.py @@ -0,0 +1,8 @@ +from mitama.http import Response +from mitama.app import Template, PackageLoader, Environment + +env = Environment(loader = PackageLoader(__package__, './templates')) + +async def welcome(request): + template = env.get_template('welcome.html') + return Response.render(template) diff --git a/tests/interface/db.sqlite3 b/tests/interface/db.sqlite3 deleted file mode 100644 index dcc6503..0000000 Binary files a/tests/interface/db.sqlite3 and /dev/null differ diff --git a/tests/interface/mitama.json b/tests/interface/mitama.json index abd8f51..2f0b8f1 100644 --- a/tests/interface/mitama.json +++ b/tests/interface/mitama.json @@ -1,13 +1 @@ -{ - "apps": { - "app1": { - "include": "app1", - "path": "/home" - }, - "portal": { - "include": "mitama.portal", - "path": "/" - } - } - -} +{"apps": {"mitama.portal": {"path": "/"}}} diff --git a/tests/unit/auth.py b/tests/unit/auth.py index 3b2db22..2af0843 100644 --- a/tests/unit/auth.py +++ b/tests/unit/auth.py @@ -1,15 +1,15 @@ -from mitama.db import Database +from mitama.db import _Database from mitama.db.driver.sqlite3 import get_test_engine import bcrypt def test_auth(): - db = Database() + db = _Database() db.set_engine(get_test_engine()) from mitama.nodes import User from mitama.auth import password_hash, password_auth db.create_all() user = User() - user.id = 123 + user._id = 123 user.name = 'someone' user.screen_name = 'somebody' user.password = password_hash('somephrase') diff --git a/tests/unit/model.py b/tests/unit/model.py index 54602fa..a90cc47 100644 --- a/tests/unit/model.py +++ b/tests/unit/model.py @@ -23,15 +23,14 @@ def test_model(): db = _CoreDatabase(engine) class Todo(db.Model): __tablename__ = 'test_todo' - id = Column(Integer, primary_key = True) title = Column(String(255)) description = Column(String(255)) datetime = Column(DateTime) db.create_all() session = create_session(engine) - session.execute('insert into test_todo (id, title, description, datetime) values (123, "test todo", "this is the test", datetime("2020-08-04 12:00:00"))') + session.execute('insert into test_todo (_id, title, description, datetime) values (123, "test todo", "this is the test", datetime("2020-08-04 12:00:00"))') test_todo = Todo.query.first() - assert test_todo.id == 123 + assert test_todo._id == 123 assert test_todo.title == 'test todo' assert test_todo.description == 'this is the test' assert test_todo.datetime == datetime(2020, 8, 4, 12) diff --git a/tests/unit/nodes.py b/tests/unit/nodes.py index f90a4e1..1da113a 100644 --- a/tests/unit/nodes.py +++ b/tests/unit/nodes.py @@ -23,15 +23,15 @@ def test_nodes(): from mitama.nodes import User, Group db.create_all() session = create_session(engine) - session.execute('insert into mitama_user (id, name, screen_name, password) values (123, "test_user", "test_user_screen", "test_user_password")') - session.execute('insert into mitama_group (id, name, screen_name) values (456, "test_group", "test_group_screen")') + session.execute('insert into mitama_user (_id, name, screen_name, password) values (123, "test_user", "test_user_screen", "test_user_password")') + session.execute('insert into mitama_group (_id, name, screen_name) values (456, "test_group", "test_group_screen")') test_user = User.query.first() test_group = Group.query.first() - assert test_user.id == 123 + assert test_user._id == 123 assert test_user.name == 'test_user' assert test_user.screen_name == 'test_user_screen' assert test_user.password == 'test_user_password' - assert test_group.id == 456 + assert test_group._id == 456 assert test_group.name == 'test_group' assert test_group.screen_name == 'test_group_screen' diff --git a/tests/unit/permission.py b/tests/unit/permission.py index 53891bc..cc1a713 100644 --- a/tests/unit/permission.py +++ b/tests/unit/permission.py @@ -1,5 +1,6 @@ from mitama.db.driver.sqlite3 import get_test_engine from mitama.db import _CoreDatabase +from mitama.db import types engine = get_test_engine() db = _CoreDatabase(engine) @@ -17,6 +18,24 @@ class FugaPermission(PermissionMixin, db.Model): downPropagate = True pass +class HogeTargetedPermission(PermissionMixin, db.Model): + target = types.Column(types.Group) + targetUpPropagate = True + pass + +class PiyoTargetedPermission(PermissionMixin, db.Model): + target = types.Column(types.Group) + pass + +class FugaTargetedPermission(PermissionMixin, db.Model): + target = types.Column(types.Group) + targetDownPropagate = True + pass + +class HogePiyoTargetedPermission(PermissionMixin, db.Model): + target = types.Column(types.Group, nullable = True) + pass + db.create_all() u = list() @@ -78,3 +97,60 @@ def test_down_propagate(): assert FugaPermission.is_forbidden(u[0]) assert FugaPermission.is_forbidden(u[1]) +def test_target_up_propagate(): + HogeTargetedPermission.accept(u[2], g[2]) + assert HogeTargetedPermission.is_accepted(u[2], g[0]) + assert HogeTargetedPermission.is_accepted(u[2], g[1]) + assert HogeTargetedPermission.is_accepted(u[2], u[0]) + assert HogeTargetedPermission.is_accepted(u[2], u[1]) + assert HogeTargetedPermission.is_accepted(u[2], g[2]) + assert HogeTargetedPermission.is_forbidden(u[2], u[3]) + assert HogeTargetedPermission.is_forbidden(u[2], u[4]) + assert HogeTargetedPermission.is_forbidden(u[2], g[3]) + assert HogeTargetedPermission.is_forbidden(u[2], g[4]) + +def test_target_propagate(): + PiyoTargetedPermission.accept(u[2], g[2]) + assert PiyoTargetedPermission.is_forbidden(u[2], g[0]) + assert PiyoTargetedPermission.is_forbidden(u[2], g[1]) + assert PiyoTargetedPermission.is_forbidden(u[2], u[0]) + assert PiyoTargetedPermission.is_forbidden(u[2], u[1]) + assert PiyoTargetedPermission.is_accepted(u[2], g[2]) + assert PiyoTargetedPermission.is_forbidden(u[2], u[3]) + assert PiyoTargetedPermission.is_forbidden(u[2], u[4]) + assert PiyoTargetedPermission.is_forbidden(u[2], g[3]) + assert PiyoTargetedPermission.is_forbidden(u[2], g[4]) + +def test_target_down_propagate(): + FugaTargetedPermission.accept(u[2], g[2]) + assert FugaTargetedPermission.is_forbidden(u[2], g[0]) + assert FugaTargetedPermission.is_forbidden(u[2], g[1]) + assert FugaTargetedPermission.is_forbidden(u[2], u[0]) + assert FugaTargetedPermission.is_forbidden(u[2], u[1]) + assert FugaTargetedPermission.is_accepted(u[2], g[2]) + assert FugaTargetedPermission.is_accepted(u[2], u[3]) + assert FugaTargetedPermission.is_accepted(u[2], u[4]) + assert FugaTargetedPermission.is_accepted(u[2], g[3]) + assert FugaTargetedPermission.is_accepted(u[2], g[4]) + +def test_target_none_propagate(): + HogePiyoTargetedPermission.accept(u[3]) + assert HogePiyoTargetedPermission.is_accepted(u[3], g[0]) + assert HogePiyoTargetedPermission.is_accepted(u[3], u[0]) + assert HogePiyoTargetedPermission.is_accepted(u[3], u[1]) + assert HogePiyoTargetedPermission.is_accepted(u[3], g[2]) + assert HogePiyoTargetedPermission.is_accepted(u[3], u[2]) + assert HogePiyoTargetedPermission.is_accepted(u[3], u[4]) + assert HogePiyoTargetedPermission.is_accepted(u[3], g[3]) + assert HogePiyoTargetedPermission.is_accepted(u[3], g[4]) + assert HogePiyoTargetedPermission.is_forbidden(u[4], g[0]) + assert HogePiyoTargetedPermission.is_forbidden(u[4], g[1]) + assert HogePiyoTargetedPermission.is_forbidden(u[4], u[0]) + assert HogePiyoTargetedPermission.is_forbidden(u[4], u[1]) + assert HogePiyoTargetedPermission.is_forbidden(u[4], g[2]) + assert HogePiyoTargetedPermission.is_forbidden(u[4], u[2]) + assert HogePiyoTargetedPermission.is_forbidden(u[4], u[4]) + assert HogePiyoTargetedPermission.is_forbidden(u[4], g[3]) + assert HogePiyoTargetedPermission.is_forbidden(u[4], g[4]) + + diff --git a/tests/unit/rels.py b/tests/unit/rels.py index cb58278..ff60992 100644 --- a/tests/unit/rels.py +++ b/tests/unit/rels.py @@ -26,16 +26,16 @@ if i > 0: g[i-1].append(g[i]) -def test_ancester(): - assert g[4].is_ancester(g[0]) - assert g[4].is_ancester(g[1]) - assert g[4].is_ancester(g[2]) - assert g[4].is_ancester(g[3]) - assert not g[4].is_ancester(g[4]) - assert not g[0].is_ancester(g[4]) - assert not g[1].is_ancester(g[4]) - assert not g[2].is_ancester(g[4]) - assert not g[3].is_ancester(g[4]) +def test_ancestor(): + assert g[4].is_ancestor(g[0]) + assert g[4].is_ancestor(g[1]) + assert g[4].is_ancestor(g[2]) + assert g[4].is_ancestor(g[3]) + assert not g[4].is_ancestor(g[4]) + assert not g[0].is_ancestor(g[4]) + assert not g[1].is_ancestor(g[4]) + assert not g[2].is_ancestor(g[4]) + assert not g[3].is_ancestor(g[4]) def test_descendant(): assert g[0].is_descendant(g[4]) diff --git a/tests/unit/routing.py b/tests/unit/routing.py new file mode 100644 index 0000000..13821dc --- /dev/null +++ b/tests/unit/routing.py @@ -0,0 +1,23 @@ +from mitama.app.router import Router, Route, Path +from mitama.app.method import * + +def test_router(): + path_a=Path('/huga/') + path_e=Path('/huga/') + path_f=Path('/huga/') + path_a_=Path('/huga//hoge') + path_b=Path('/hogera/') + path_b_=Path('/hogera/') + assert path_a.match('/huga/boke0') + assert path_e.match('/huga/0') + assert path_f.match('/huga/0.1') + assert not path_a.match('/huga/boke0/hoge') + assert path_a_.match('/huga/boke0/hoge') + assert path_b.match('/hogera/var/www/html') == { + 'path': 'var/www/html' + } + assert path_b_.match('/hogera/hogera.py') == { + 'path': 'hogera.py' + } + + diff --git a/tests/unit/type.py b/tests/unit/type.py index a712ab0..80d357f 100644 --- a/tests/unit/type.py +++ b/tests/unit/type.py @@ -15,12 +15,10 @@ def test_nodes(): from mitama.nodes import User, Group class Profile(db.Model): __tablename__ = 'test_profile' - id = Column(types.Integer, primary_key = True) description = Column(types.String(255)) user = Column(types.User) class GroupProfile(db.Model): __tablename__ = 'test_group_profile' - id = Column(types.Integer, primary_key = True) description = Column(types.String(255)) group = Column(types.Group) db.create_all()