diff --git a/action/action.py b/action/action.py new file mode 100644 index 0000000..2256e00 --- /dev/null +++ b/action/action.py @@ -0,0 +1,19 @@ + + +import exception + +class Action: + def __init__(self): + self.readonly = False + + def process(self, request): + assert False + + def propagate_exception(self, e): + if type(e) is exception.ModelWrongData: + raise exception.ActionError(e.messages) + if type(e) is exception.ModelDeny: + raise exception.HttpForbidden() + raise e + + diff --git a/action/actions.py b/action/actions.py new file mode 100644 index 0000000..3094761 --- /dev/null +++ b/action/actions.py @@ -0,0 +1,9 @@ + + +from util import mergedict +import action.user as user + + +actions = dict() +mergedict(actions, user.actions, 'user.') + diff --git a/action/user.py b/action/user.py new file mode 100644 index 0000000..4134f00 --- /dev/null +++ b/action/user.py @@ -0,0 +1,146 @@ + + +import exception +from action.action import Action + + +class UserBase(Action): + def parse_user_id(self, request): + user_id = 0 + try: + user_id = int(request.postvars.get('user_id', 0)) + except Exception: + raise exception.ActionError( request.t('Used Id incorrect') ) + if not user_id: + user_id = self.model.myrights.user_id + if not user_id: + raise exception.ActionError( request.t('Used Id incorrect') ) + return user_id + + +class UserLogin(UserBase): + def __init__(self): + super().__init__() + self.readonly = True + + def process(self, request): + login = str(request.postvars.get('login', '')) + password = str(request.postvars.get('password', '')) + if not login and not password: + request.server.sessions.close_session(request) + else: + user_id = request.model.users.check_password(login, password) + if not user_id: + raise exception.ActionError( request.t('Login or password incorrect') ) + request.server.sessions.create_session(request, user_id) + return request.answer.complete_redirect() + + +class UserCreate(UserBase): + def process(self, request): + login = str(request.postvars.get('login', '')) + password = str(request.postvars.get('password', '')) + passwordretry = str(request.postvars.get('passwordretry', '')) + if password != passwordretry: + raise exception.ActionError( request.t('Passwords mismatch') ) + name = str(request.postvars.get('name', '')) + + user = None + try: + user = request.model.users.create(login, password, name) + except Exception as e: + self.propagate_exception(e) + + request.connection.commit() + return request.answer.complete_redirect(['user', str(user.id)]) + + +class UserUpdate(UserBase): + def process(self, request): + user_id = self.parse_user_id(request) + name = str(request.postvars.get('name', '')) + + user = request.model.users.get_by_id(user_id) + if not user: + raise exception.ActionError( request.t('Used not found') ) + + try: + user.update(name) + except Exception as e: + self.propagate_exception(e) + + request.connection.commit() + return request.answer.complete_redirect(['user', str(user.id)]) + + +class UserDelete(UserBase): + def process(self, request): + user_id = self.parse_user_id(request) + password = request.postvars.get('password', '') + if not password is None: + password = str(password) + + user = request.model.users.get_by_id(user_id) + if not user: + raise exception.ActionError( request.t('Used not found') ) + + try: + user.delete(password) + except Exception as e: + self.propagate_exception(e) + + request.connection.commit() + return request.answer.complete_redirect([]) + + +class UserSetPassword(UserBase): + def process(self, request): + user_id = self.parse_user_id(request) + oldpassword = request.postvars.get('oldpassword', '') + newpassword = str(request.postvars.get('newpassword', '')) + newpasswordretry = str(request.postvars.get('newpasswordretry', '')) + if newpassword != newpasswordretry: + raise exception.ActionError( request.t('Passwords mismatch') ) + if not oldpassword is None: + oldpassword = str(oldpassword) + + user = request.model.users.get_by_id(user_id) + if not user: + raise exception.ActionError( request.t('Used not found') ) + + try: + user.change_password(newpassword, oldpassword) + except Exception as e: + self.propagate_exception(e) + + request.connection.commit() + return request.answer.complete_redirect(['user', str(user.id)]) + + +class UserSetSuperuser(UserBase): + def process(self, request): + user_id = self.parse_user_id(request) + superuser = bool(request.postvars.get('superuser', False)) + + user = request.model.users.get_by_id(user_id) + if not user: + raise exception.ActionError( request.t('User not found') ) + + try: + user.set_superuser(superuser) + except Exception as e: + self.propagate_exception(e) + + request.connection.commit() + return request.answer.complete_redirect(['user', str(user.id)]) + + +actions = { + 'login' : UserLogin(), + 'create' : UserCreate(), + 'update' : UserUpdate(), + 'delete' : UserDelete(), + 'setpassword' : UserSetPassword(), + 'setsuperuser' : UserSetSuperuser(), +} + diff --git a/answer.py b/answer.py index 1e59ff3..70f6b5c 100644 --- a/answer.py +++ b/answer.py @@ -1,14 +1,35 @@ -class Answer: - def __init__(self, request, start_response): +import html +import urllib.parse + +from translator import Translator + + +class Result: + def __init__(self, code, headers, data): + assert(type(code) is str) + assert(type(headers) is list) + assert(type(data) is list) + self.code = code + self.headers = headers + self.data = data + + def complete(self, start_response): + start_response(self.code, self.headers) + return self.data + + +class Answer(Translator): + def __init__(self, request): + super().__init__() + self.request = request self.urlprefix = self.request.server.config['urlprefix'] self.urldataprefix = self.request.server.config['urldataprefix'] - self.start_response = start_response self.status = "200 OK" - self.headers = [] + self.headers = list() self.datalist = None self.data = None @@ -17,21 +38,44 @@ class Answer: self.title = '' self.content = '' + self.errors = list() + self.fields = dict() self.template = None + + self.objects = dict() - def translate(self, text): - return text - - def t(self, text): - return self.translate(text) + def htmlescape(self, text): + return html.escape(str(text)) + def urlescape(self, text): + return urllib.parse.quote(str(text)) - def chain_title(subtitle): + def e(self, text): + return self.htmlescape(text) + def ue(self, text): + return self.urlescape(text) + def te(self, text): + return self.e( self.t(text) ) + + def encodedfield(self, name): + return self.e( self.fields.get(str(name), '') ) + + def chain_title(self, subtitle): if subtitle: if self.title: self.title = subtitle + ' | ' + self.title else: self.title = subtitle + def complete_redirect(self, path = None): + self.status = '303 See Other' + self.headers.append(('Location', self.request.get_urlpath_escaped(path))) + return Result(self.status, self.headers, list()) + + def complete_error(self, status = None): + if not status is None: + self.status = status + return Result(self.status, list(), list()) + def complete_data(self, data = None): if not data is None: self.data = data @@ -46,8 +90,7 @@ class Answer: self.datalist = [] for i in range(0, len(self.data), size): self.datalist.append(self.data[i:i + size]) - self.start_response(self.status, self.headers) - return self.datalist + return Result(self.status, self.headers, self.datalist) def complete_text(self, text = None): if not text is None: diff --git a/config.py b/config.py index 9a68921..cb8363b 100644 --- a/config.py +++ b/config.py @@ -1,14 +1,20 @@ config = { 'db': { - 'prefix': 'ew_', + 'prefix' : 'ew_', 'connection': { - 'host' : '127.0.0.1', - 'user' : 'root', - 'passwd' : 'password', - 'db' : 'earthworm', - 'charset' : 'utf8mb4', + 'host' : '127.0.0.1', + 'user' : 'root', + 'passwd' : 'password', + 'db' : 'earthworm', + 'charset' : 'utf8mb4', }, }, + + 'users': { + 'showlist' : True, + 'selfcreate' : True, + 'selfdelete' : True, + }, } diff --git a/db/cache.py b/db/cache.py new file mode 100644 index 0000000..479754c --- /dev/null +++ b/db/cache.py @@ -0,0 +1,171 @@ + +import threading + + +class CacheItem: + def __init__(self, owner, key, value): + self.owner = owner + self.key = key + self.value = value + + self.prev = None + self.next = self.owner.first + if self.next: + self.next.prev = self + else: + self.owner.last = self + self.owner.first = self + + def touch(self): + if not self.prev: + return + self.prev.next = self.next + if self.next: + self.next.prev = self.prev + else: + self.owner.last = self.prev + return self.value + + def detouch(self): + if self.prev: + self.prev.next = self.next + else: + self.owner.first = self.next + if self.next: + self.next.prev = self.prev + else: + self.owner.last = self.prev + + +class CacheTable: + def __init__(self, maxcount): + self.items = dict() + self.first = None + self.last = None + self.maxcount = maxcount + + def get(self, key): + item = self.items.get(key, None) + return item.touch() if item else None + + def set(self, key, value): + item = self.items.get(key, None) + if item: + item.value = value + item.touch() + return + self.items[key] = CacheItem(self, key, value) + while len(self.items) > self.maxcount: + del self.items[self.last.key] + self.last.detouch() + + def unset(self, key): + item = self.items.get(key, None) + if item: + del self.items[key] + item.detouch() + + def clear(self): + self.first = None + self.last = None + # remove cyclic references to help GC + for v in self.items.values(): + v.prev = None + v.next = None + self.items.clear() + self.count = 0 + + +class Cache: + def __init__(self, server): + self.server = server + self.lock = threading.Lock() + self.tables = dict() + self.maxcount = self.server.config['db']['cache']['maxcount'] + + def create_connection(self, connection): + return CacheConnection(self, connection) + + def clear(self): + with self.lock: + for t in self.tables.values(): + t.clear() + self.tables.clear() + + def build_select(self, connection, table, fields): + assert(type(fields) is dict) + where = list() + args = list() + for k, v in fields.items(): + assert(type(k) is str) + if type(v) is int: + where.append('%F=%d') + args.append(k) + args.append(v) + elif type(v) is str: + where.append('%F=%s') + args.append(k) + args.append(v) + else: + assert(False) + where = ' AND '.join(where) if where else '1' + return connection.parse('SELECT * FROM %T WHERE ' + where, table, *args) + + def select(self, connection, table, fields): + assert(type(table) is str) + sql = self.build_select(connection, table, fields) + + with self.lock: + tbl = self.tables.get(table) + if not tbl: + self.tables[table] = tbl = CacheTable(self.maxcount) + rows = tbl.get(sql) + + if rows is None: + rows = connection.query_dict(sql) + assert(type(rows) is list) + with self.lock: + tbl = self.tables.get(table) + if not tbl: + self.tables[table] = tbl = CacheTable(self.maxcount) + tbl.set(sql, rows) + return rows + + def reset(self, connection, table, fields = None): + assert(type(table) is str) + sql = None + if not fields is None: + sql = self.build_select(connection, table, fields) + with self.lock: + tbl = self.tables.get(table) + if tbl: + if sql is None: + tbl.clear() + else: + tbl.unset(sql) + + +class CacheConnection: + def __init__(self, cache, connection): + self.cache = cache + self.connection = connection + + def clear(self): + self.cache.clear() + + def select(self, table, fields): + return self.cache.select(self.connection, table, fields) + + def reset(self, table, fields = None): + self.cache.reset(self.connection, table, fields) + + def row(self, table, id): + rows = self.select(table, {'id': id}) + if len(rows) == 0: + return None + assert(len(rows) == 1) + return rows[0] + + def reset_row(self, table, id): + self.reset(table, {'id': id}) + diff --git a/db/connection.py b/db/connection.py index 318a6c0..5ffd29f 100644 --- a/db/connection.py +++ b/db/connection.py @@ -3,14 +3,10 @@ import datetime import traceback import MySQLdb.cursors -from db.parser import parse - class Cursor: def __init__(self, connection, internal): self.connection = connection - self.typebytype = self.connection.pool.server.dbtypebytype - self.typebychar = self.connection.pool.server.dbtypebychar self.internal = internal self.entered = None self.iterator = None @@ -34,31 +30,68 @@ class Cursor: data = next(self.iterator) if type(data) is dict: for k, v in data.items(): - t = self.typebytype.get( type(v) ) + t = self.connection.typebytype.get( type(v) ) if t: data[k] = t.from_db(self, v) else: orig = data data = list() for v in orig: - t = self.typebytype.get( type(v) ) + t = self.connection.typebytype.get( type(v) ) data.append(t.from_db(self, v) if t else v) return data def execute(self, sql, *args, **kvargs): - if args or kvargs: - sql = parse(self.typebychar, self.connection, sql, *args, **kvargs) - self.get_internal().execute(sql) + sql = self.connection.parse(sql, *args, **kvargs) + try: + self.get_internal().execute( sql ) + except Exception as e: + print('SQL Error in query:') + print(sql) + raise e class Connection: def __init__(self, pool, internal, readonly = True): self.pool = pool + self.server = self.pool.server + self.typebytype = self.server.dbtypebytype + self.typebychar = self.server.dbtypebychar + self.cache = self.server.dbcache.create_connection(self) self.request = None self.internal = internal self.readonly = readonly self.finished = True self.begin() + def parse(self, text, *args, **kvargs): + i = iter(text) + index = 0 + result = '' + try: + while True: + try: c = next(i) + except StopIteration: break + if c == '%': + c = next(i) + if c == '(': + field = '' + while True: + c = next(i) + if c == ')': break + field += c + c = next(i) + result += self.typebychar[c].to_db(self, kvargs[field]) + elif c == '%': + result += '%' + else: + result += self.typebychar[c].to_db(self, args[index]) + index += 1 + else: + result += c + except StopIteration: + raise Exception('unexpeted end of sql template') + return result + def cursor(self, as_dict = False, sql = None, *args, **kvargs): assert not self.finished cursorclass = MySQLdb.cursors.DictCursor if as_dict else MySQLdb.cursors.Cursor @@ -98,12 +131,13 @@ class Connection: def begin(self): assert self.finished self.finished = False + self.execute("SET sql_mode='STRICT_TRANS_TABLES'") if self.readonly: self.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") self.execute("START TRANSACTION READ ONLY, WITH CONSISTENT SNAPSHOT") else: self.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") - self.execute("START TRANSACTION READ WRITE, WITH CONSISTENT SNAPSHOT") + self.execute("START TRANSACTION READ WRITE") self.now = datetime.datetime.now(datetime.timezone.utc) def commit(self): diff --git a/db/parser.py b/db/parser.py deleted file mode 100644 index 3d4fbd9..0000000 --- a/db/parser.py +++ /dev/null @@ -1,31 +0,0 @@ - - -def parse(typebychar, connection, text, *args, **kvargs): - i = iter(text) - index = 0 - result = '' - try: - while True: - try: c = next(i) - except StopIteration: break - if c == '%': - c = next(i) - if c == '(': - field = '' - while True: - c = next(i) - if c == ')': break - field += c - c = next(i) - result += typebychar[c].to_db(connection, kvargs[field]) - elif c == '%': - result += '%' - else: - result += typebychar[c].to_db(connection, args[index]) - index += 1 - else: - result += c - except StopIteration: - raise Exception('unexpeted end of sql template') - return result - diff --git a/deps.txt b/deps.txt index 609ac52..2fac286 100644 --- a/deps.txt +++ b/deps.txt @@ -1,9 +1,26 @@ +### python builtin modules + +time + + +### modules from python minimal library + datetime hashlib threading -time traceback + +### modules from python std library + +cgi +html +urllib.parse +uuid + + +### thirdparty libraries (actual deps) + MySQLdb diff --git a/doc/plan.ods b/doc/plan.ods new file mode 100644 index 0000000..70ee2ee Binary files /dev/null and b/doc/plan.ods differ diff --git a/exception.py b/exception.py index 3ded841..fccb15a 100644 --- a/exception.py +++ b/exception.py @@ -1,7 +1,34 @@ +class HttpForbidden(Exception): + pass + class HttpNotFound(Exception): pass -class ModelNotFound(Exception): + +class ModelException(Exception): + pass + +class ModelDeny(ModelException): pass + +class ModelWrongData(ModelException): + def __init__(self, messages = None): + if messages is None: + messages = list() + if type(messages) is str: + messages = [messages] + assert(type(messages) is list) + self.messages = messages + + +class ActionError(Exception): + def __init__(self, messages = None): + if messages is None: + messages = list() + if type(messages) is str: + messages = [messages] + assert(type(messages) is list) + self.messages = messages + diff --git a/htmlbase.py b/htmlbase.py new file mode 100644 index 0000000..c6cfb11 --- /dev/null +++ b/htmlbase.py @@ -0,0 +1,8 @@ + + +class HtmlBase: + def make_link(self, answer, path, title = '', htmlcontent = ''): + assert(not title or not htmlcontent ) + return '%s%s' \ + % ( answer.request.get_urlpath_escaped(path), answer.te(title), htmlcontent) + diff --git a/install.py b/install.py new file mode 100644 index 0000000..9088d60 --- /dev/null +++ b/install.py @@ -0,0 +1,42 @@ + + +from config import config +from server import Server +from model.model import Model +from db.holder import Holder +from translator import Translator + + +class Install: + def __init__(self, server): + self.server = server + self.translator = Translator() + + def create_user(self, login, password, name, superuser = False): + print(' -- create admin') + print(' login: ' + str(login)) + print(' password: *') + print(' name: ' + str(name)) + with Holder(self.server.dbpool, readonly = False) as connection: + model = Model(connection, self.translator, superuser = True) + user = model.users.create(login, password, name) + if superuser: + user.set_superuser(True) + connection.commit() + print(' -- done') + + def reset_password(self, login, password): + print(' -- reset password') + print(' login: ' + str(login)) + print(' new password: *') + with Holder(self.server.dbpool, readonly = False) as connection: + model = Model(connection, self.translator, superuser = True) + user = model.users.get_by_login(login) + user.change_password(password) + connection.commit() + print(' -- done') + + +install = Install(Server(config)) + + diff --git a/main.py b/main.py index 58b36c8..6027c8b 100644 --- a/main.py +++ b/main.py @@ -11,11 +11,37 @@ server = Server(config) def application(env, start_response): try: - request = Request(server, env, start_response) + request = Request(server, env) if request.path is None: raise exception.HttpNotFound() - with db.holder.Holder(request.server.dbpool, readonly = request.readonly) as connection: - request.connection = connection - return request.server.pageroot.process(request, request.path) + if request.method != 'GET' and request.method != 'POST': + result = request.answer.complete_error('405 Method Not Allowed') + return result.complete(start_response) + + if request.method == 'POST': + action = request.server.actions.get(request.action) + + if not action: + raise exception.HttpForbidden() + try: + with db.holder.Holder(request.server.dbpool, readonly = action.readonly) as connection: + request.create_model(connection) + result = action.process(request) + return result.complete(start_response) + except exception.ActionError as e: + postvars = request.postvars + request = Request(server, env) + request.answer.fields.update(postvars) + request.answer.errors += e.messages + + with db.holder.Holder(request.server.dbpool, readonly = True) as connection: + request.create_model(connection) + result = request.server.pageroot.process(request, request.path, list()) + return result.complete(start_response) + except exception.HttpForbidden: + result = request.server.pageforbidden.process(request, list(), list()) + return result.complete(start_response) except exception.HttpNotFound: - return request.server.pagenotfound.process(request, list()) + result = request.server.pagenotfound.process(request, list(), list()) + return result.complete(start_response) + diff --git a/model/base.py b/model/base.py new file mode 100644 index 0000000..deeb988 --- /dev/null +++ b/model/base.py @@ -0,0 +1,13 @@ + +class ModelBase: + def __init__(self, model): + self.model = model + self.server = self.model.server + self.connection = self.model.connection + self.rights = self.model.internal_rights + self.translator = self.model.translator + + def translate(self, text): + return self.translator.translate(text) + def t(self, text): + return self.translate(text) diff --git a/model/model.py b/model/model.py new file mode 100644 index 0000000..9e96e79 --- /dev/null +++ b/model/model.py @@ -0,0 +1,16 @@ + +from model.rights import MyRights +from model.rights import InternalRights +from model.users import Users + + +class Model: + def __init__(self, connection, translator, user_id = 0, superuser = False): + self.connection = connection + self.server = self.connection.server + self.internal_rights = InternalRights(self.connection, user_id, superuser) + self.myrights = MyRights(self.internal_rights) + self.translator = translator + + self.users = Users(self) + diff --git a/model/rights.py b/model/rights.py new file mode 100644 index 0000000..478028b --- /dev/null +++ b/model/rights.py @@ -0,0 +1,150 @@ + + +class Right: + def __init__(self, row): + self.user_id = int(row['user_id']) + self.target_type = str(row['target_type']) + self.target_id = int(row['target_id']) + self.mode = str(row['mode']) + + +class MyRights: + def __init__(self, internal): + self.internal = internal + self.user_id = self.internal.user_id + + def issuperuser(self): + return self.internal.issuperuser() + + def isallowed(self, target_type, target_id, mode): + return self.internal.isallowed(target_type, target_id, mode) + + def isallowed_root(self, mode): + return self.internal.isallowed_root(mode) + + +class InternalRights: + TABLE = 'rights' + ROOT = 'root' + + def __init__(self, connection, user_id = 0, superuser = False): + assert(type(user_id) is int) + assert(type(superuser) is bool) + self.connection = connection + self.user_id = user_id + self.superuser = superuser + + def issuperuser(self): + if self.superuser: + return True + if self.user_id and self.get(self.user_id, self.ROOT, 0, self.ROOT): + return True + + def isallowed(self, target_type, target_id, mode): + if self.issuperuser(): + return True + if self.user_id and self.get(self.user_id, target_type, target_id, mode): + return True + return False + + def build_where(self, user_id = None, target_type = None, target_id = None, mode = None): + where = list() + args = list() + if user_id: + assert(type(user_id) is int) + where.append("`user_id`=%d") + args.append(user_id) + if target_type: + assert(type(target_type) is str) + where.append("`target_type`=%s") + args.append(target_type) + if target_id: + assert(type(target_id) is int) + where.append("`target_id`=%d") + args.append(target_id) + if mode: + assert(type(mode) is str) + where.append("`mode`=%s") + args.append(mode) + if not where: + return '', list() + return ' WHERE ' + ' AND '.join(where), args + + def get_list(self, user_id = None, target_type = None, target_id = None, mode = None): + where, args = self.build_where(user_id, target_type, target_id, mode) + rows = connection.query_dict('SELECT * FROM %T' + where, self.TABLE, *args) + rights = list() + for row in rows: + rights.append( Right(row) ) + return rights + + def delete_list(self, user_id = None, target_type = None, target_id = None, mode = None): + where, args = self.build_where(user_id, target_type, target_id, mode) + self.connection.execute('DELETE FROM %T' + where, self.TABLE, *args) + self.connection.cache.reset(self.TABLE) + + def reset_cache(self, user_id, target_type, target_id, mode): + assert(type(user_id) is int) + assert(type(target_type) is str) + assert(type(target_id) is int) + assert(type(mode) is str) + self.connection.cache.reset( + self.TABLE, + { 'user_id': user_id, + 'target_type': target_type, + 'target_id': target_id, + 'mode': mode }) + + def get(self, user_id, target_type, target_id, mode): + assert(type(user_id) is int) + assert(type(target_type) is str) + assert(type(target_id) is int) + assert(type(mode) is str) + rows = self.connection.cache.select( + self.TABLE, + { 'user_id': user_id, + 'target_type': target_type, + 'target_id': target_id, + 'mode': mode }) + return bool(rows) + + def set(self, user_id, target_type, target_id, mode, allowed): + assert(type(user_id) is int) + assert(type(target_type) is str) + assert(type(target_id) is int) + assert(type(mode) is str) + if not allowed: + self.connection.execute( + '''DELETE FROM %T WHERE + `user_id`=%d + AND `target_type`=%s + AND `target_id`=%d + AND `mode`=%s''', + self.TABLE, user_id, target_type, target_id, mode ) + self.reset_cache(user_id, target_type, target_id, mode) + elif not self.get(user_id, target_type, target_id, mode): + self.connection.execute( + '''INSERT INTO %T SET + `user_id`=%d, + `target_type`=%s, + `target_id`=%d, + `mode`=%s''', + self.TABLE, user_id, target_type, target_id, mode ) + self.reset_cache(user_id, target_type, target_id, mode) + + def isallowed_root(self, mode): + return self.isallowed(self.ROOT, 0, mode) + def get_list_root(self, user_id = None, mode = None): + return self.get_list(user_id, self.ROOT, 0, mode) + def delete_list_root(self, user_id = None, mode = None): + return self.delete_list_root(user_id, self.ROOT, 0, mode) + def get_root(self, user_id, mode): + return self.get(user_id, self.ROOT, 0, mode) + def set_root(self, user_id, mode, allowed): + return self.set(user_id, self.ROOT, 0, mode, allowed) + + def get_superuser(self, user_id): + return self.get_root(user_id, self.ROOT) + def set_superuser(self, user_id, allowed): + return self.set_root(user_id, self.ROOT, allowed) + diff --git a/model/user.py b/model/user.py deleted file mode 100644 index 9b1841e..0000000 --- a/model/user.py +++ /dev/null @@ -1,87 +0,0 @@ - - -import hashlib - -import exception - - -class User: - table = 'users' - - def __init__(self, connection, data): - self.connection = connection - self.id = data['id'] - self.login = data['login'] - self.password = data['password'] - self.name = data['name'] - self.email = data['email'] - - @staticmethod - def query(connection, id): - rows = connection.query_dict('SELECT * FROM %T WHERE `id`=%d', table, id) - assert len(rows) <= 1 - return User(connection, rows[0]) if rows else None; - - @staticmethod - def query_by_login(connection, login): - rows = connection.query_dict('SELECT * FROM %T WHERE `login`=%s', table, login) - assert len(rows) <= 1 - return User(connection, rows[0]) if rows else None; - - @staticmethod - def query_list(connection): - result = list() - with connection.cursor_dict('SELECT * FROM %T ORDER BY `login`', table) as cursor: - for row in cursor: - result.append(User(connection, cursor)) - return result - - - def insert(self, connection): - assert not self.id - connection.execute( - '''INSERT INTO %T SET - `login` = %s, - `name` = %s, - `email` = %s''', - table, - self.login, - self.name, - self.email ) - self.id = self.connection.insert_id() - - def update(self, connection): - assert self.id - connection.execute( - 'UPDATE SET %T `name` = %s, `email` = %s WHERE `id` = %d', - table, self.name, self.email, self.id ) - - @staticmethod - def gen_password_hash(salt, id, plain_password): - return hashlib.sha512(bytes(str(user_id) + '|' + str(salt) + '|' + password, 'utf8')).hexdigest() - - def password_hash(self, plain_password): - assert self.id - return gen_password_hash(self.connection.pool.server.salt, self.id, password) - - @staticmethod - def resetpassword(connection, id, password): - connection.execute( - 'UPDATE %T SET `password` = %s WHERE `id` = %d', - table, password, id ) - if not connection.request \ - or not connection.request.session \ - or connection.request.session.user.id != id: - connection.pool.server.remove_session_for_user(id) - - def update_password(self, connection, password = None): - assert self.id - if not password is None: - self.password = password - resetpassword(self.connection, self.id, self.password) - - def delete(self, connection): - assert self.id - connection.execute('DELETE FROM %T WHERE `id`=%d', table, self.id) - - diff --git a/model/users.py b/model/users.py new file mode 100644 index 0000000..296718b --- /dev/null +++ b/model/users.py @@ -0,0 +1,157 @@ + + +import hashlib + +import exception + +from model.base import ModelBase + + +class User(ModelBase): + def __init__(self, users, row): + self.users = users + super().__init__(self.users.model) + + self.id = int(row['id']) + self.login = str(row['login']) + self.name = str(row['name']) + self.superuser = None + if self.id == self.rights.user_id: + self._password = str(row['password']) + if self.id == self.rights.user_id or self.rights.issuperuser(): + self.superuser = self.rights.get_superuser(self.id) + + def reset_cache(self): + self.users.reset_cache(self.id, self.login) + + def change_password(self, newpassword, oldpassword = None): + if self.id == self.rights.user_id: + if oldpassword is None: + raise exception.ModelDeny() + oldhash = self.users.gen_password_hash(self.id, oldpassword) + if oldhash != self._password: + raise exception.ModelWrongData(self.t('Password incorrect')) + if self.id != self.rights.user_id and not self.rights.issuperuser(): + raise exception.ModelDeny() + hash = self.users.gen_password_hash(self.id, newpassword) + self.connection.execute('UPDATE %T SET `password`=%s WHERE `id`=%d', self.users.TABLE, hash, self.id) + self.reset_cache() + self.server.sessions.delete_by_user_id(self.id) + + def can_update(self): + return self.id == self.rights.user_id or self.rights.issuperuser() + + def update(self, name): + if self.can_update(): + self.connection.execute('UPDATE %T SET `name`=%s WHERE `id`=%d', self.users.TABLE, name, self.id) + self.reset_cache() + else: + raise exception.ModelDeny() + + def can_delete(self): + return self.server.config['users']['selfdelete'] if self.id == self.rights.user_id else self.rights.issuperuser() + + def delete(self, password = None): + if self.can_delete(): + if self.id == self.rights.user_id: + if password is None: + raise exception.ModelDeny() + hash = self.users.gen_password_hash(self.id, str(password)) + if hash != self._password: + raise exception.ModelWrongData(self.t('Password incorrect')) + self.rights.delete_list(user_id = self.id) + self.connection.execute('DELETE FROM %T WHERE `id`=%d', self.users.TABLE, self.id) + self.reset_cache() + self.server.sessions.delete_by_user_id(self.id) + else: + raise exception.ModelDeny() + + def set_superuser(self, allowed): + if id != self.rights.user_id and self.rights.issuperuser(): + self.rights.set_superuser(self.id, allowed) + else: + raise exception.ModelDeny() + + +class Users(ModelBase): + TABLE = 'users' + + def __init__(self, model): + super().__init__(model) + + def reset_cache(self, id, login): + self.connection.cache.reset_row(self.TABLE, id) + self.connection.cache.reset(self.TABLE, {'login': login}) + + def gen_password_hash(self, id, password): + salt = self.server.config['users']['salt'] + return hashlib.sha512(bytes(str(id) + '|' + str(salt) + '|' + password, 'utf8')).hexdigest() + + def verify_login(self, login): + return type(login) is str and len(login) < 200 and login.isidentifier + + def can_create(self): + return self.rights.issuperuser() or self.server.config['users']['selfcreate'] + + def create(self, login, password, name): + if not self.can_create(): + raise exception.ModelDeny() + if not self.verify_login(login): + raise exception.ModelWrongData(self.t('Login incorrect')) + if self.get_by_login(login): + raise exception.ModelWrongData(self.t('Login already exists')) + self.connection.execute("INSERT INTO %T SET `login`=%s, `password`='', `name`=%s", self.TABLE, login, name) + id = self.connection.insert_id() + hash = self.gen_password_hash(int(id), password) + self.connection.execute('UPDATE %T SET `password`=%s WHERE `id`=%d', self.TABLE, hash, id) + self.reset_cache(id, login) + return self.get_by_id(id) + + def check_password(self, login, password): + assert(type(login) is str) + assert(type(password) is str) + rows = self.connection.cache.select(self.TABLE, {'login': login}) + if not rows or len(rows) > 1: + return False + row = rows[0] + id = int(row['id']) + print(row) + print(self.gen_password_hash(id, password)) + if str(row['password']) == self.gen_password_hash(id, password): + return id + return 0 + + def get_by_id(self, id): + assert(type(id) is int) + row = None + if id == self.rights.user_id or self.rights.issuperuser() or self.server.config['users']['showprofile']: + row = self.connection.cache.row(self.TABLE, id) + if not row: + return None + return User(self, row) + + def get_by_login(self, login): + assert(type(login) is str) + rows = self.connection.cache.select(self.TABLE, {'login': login}) + if not rows or len(rows) > 1: + return None + row = rows[0] + if int(row['id']) == self.rights.user_id or self.rights.issuperuser() or self.server.config['users']['showprofile']: + return User(self, row) + return None + + def can_list(self): + return self.rights.issuperuser() or (self.server.config['users']['showlist'] and self.server.config['users']['showprofile']) + + def get_list(self): + result = list() + if self.can_list(): + rows = self.connection.query_dict('SELECT * FROM %T ORDER BY `login`', self.TABLE) + for row in rows: + result.append(User(self, row)) + elif self.rights.user_id: + current_user = self.get_by_id(self.rights.user_id) + if current_user: + result.append(current_user) + return result + diff --git a/page/error.py b/page/error.py new file mode 100644 index 0000000..ffc477a --- /dev/null +++ b/page/error.py @@ -0,0 +1,20 @@ + + +from page.page import Page +from template.common import CommonTemplate + + +class ErrorPage(Page): + def __init__(self, code, message): + super().__init__() + self.code = str(int(code)) + self.message = str(message) + + def process(self, request, path, prevpath): + answer = request.answer + answer.status = self.code + ' ' + self.message + answer.template = self.commontemplate + answer.content += '
' + str(self.code) + '
' + answer.content += '' + answer.te(self.message) + '
' + return answer.complete_content() + diff --git a/page/page.py b/page/page.py new file mode 100644 index 0000000..7967849 --- /dev/null +++ b/page/page.py @@ -0,0 +1,19 @@ + +import exception +from htmlbase import HtmlBase +from template.common import CommonTemplate + + +class Page(HtmlBase): + def __init__(self): + super().__init__() + self.commontemplate = CommonTemplate() + + def sub_process(self, request, path, prevpath): + if not path: + raise exception.HttpNotFound() + return self.process(request, path[1:], prevpath + path[0:1]) + + def process(self, request, path, prevpath): + raise exception.HttpNotFound() + diff --git a/page/root.py b/page/root.py new file mode 100644 index 0000000..904168f --- /dev/null +++ b/page/root.py @@ -0,0 +1,46 @@ + + +import exception + +from page.page import Page +from page.user import UserPage +from page.user import UserCreatePage +from page.user import UserListPage + + +class RootPage(Page): + def __init__(self): + super().__init__() + self.user = UserPage() + self.user_create = UserCreatePage() + self.users = UserListPage() + + def process(self, request, path, prevpath): + answer = request.answer + answer.template = self.commontemplate + + if path: + if path[0] == 'user': + return self.user.sub_process(request, path, prevpath) + if path[0] == 'user_create': + return self.user_create.sub_process(request, path, prevpath) + if path[0] == 'users': + return self.users.sub_process(request, path, prevpath) + raise exception.HttpNotFound() + + answer.content += '' + answer.te('root page') + '
' + answer.content += '' + answer.te('Welcome!') + '
' + + if request.model.users.can_list(): + answer.content += '' + self.make_link(answer, prevpath + ['users'], 'Users list') + '
\n' + + answer.content += 'Env: \n' + answer.e(str(request.env)) + '
' + + tables = list(v[0] for v in request.connection.query_list('SHOW TABLES')) + answer.content += 'DB tables: ' + answer.e(', '.join(tables)) + '
' + + rows = request.connection.query_dict('SELECT * FROM %T', 'test') + answer.content += 'Rows of test table: ' + answer.e(str(rows)) + '
' + + return answer.complete_content() + diff --git a/page/user.py b/page/user.py new file mode 100644 index 0000000..befa0fc --- /dev/null +++ b/page/user.py @@ -0,0 +1,174 @@ + +from page.page import Page + +import exception + + +class UserCreatePage(Page): + def process(self, request, path, prevpath): + if path: + raise exception.HttpNotFound() + if not request.model.users.can_create(): + raise exception.HttpNotFound() + answer = request.answer + answer.chain_title( answer.te('Create user') ) + answer.content += '' + answer.te('Create user') + '
\n' + answer.content += '\n' + return answer.complete_content() + + +class UserPage(Page): + def __init__(self): + super().__init__() + self.profile = UserProfilePage() + + def process(self, request, path, prevpath): + if not path: + raise exception.HttpNotFound() + + user = None + if path[0].isdecimal(): + user = request.model.users.get_by_id(int(path[0])) + else: + user = request.model.users.get_by_login(str(path[0])) + if not user: + raise exception.HttpNotFound() + + request.answer.objects['user'] = user + return self.profile.sub_process(request, path, prevpath) + + +class UserProfilePage(Page): + def __init__(self): + super().__init__() + self.edit = UserUpdatePage() + self.delete = UserDeletePage() + + def process(self, request, path, prevpath): + user = request.answer.objects['user'] + answer = request.answer + answer.chain_title( answer.e(user.login) ) + + if path: + if path[0] == 'edit': + return self.edit.sub_process(request, path, prevpath) + if path[0] == 'delete': + return self.delete.sub_process(request, path, prevpath) + raise exception.HttpNotFound() + + answer.content += '' + answer.e(user.login) + '
\n' + answer.content += '' + answer.e(user.name) + '
\n' + if user.superuser: + answer.content += '' + answer.te('Site admin') + '
\n' + if user.can_update(): + answer.content += '' + self.make_link(answer, prevpath + ['edit'], 'Edit user') + '
\n' + return answer.complete_content() + + +class UserUpdatePage(Page): + def process(self, request, path, prevpath): + if path: + raise exception.HttpNotFound() + + answer = request.answer + user = answer.objects['user'] + if not user.can_update(): + raise exception.HttpNotFound() + + answer.chain_title( answer.te('Edit user') ) + answer.content += '' + answer.te('Edit user') + '
\n' + answer.content += '' + answer.e(user.login) + '
\n' + answer.content += '\n' + + answer.content += '' + answer.te('Change password') + '
\n' + answer.content += '\n' + + if user.id != request.model.myrights.user_id and not user.superuser is None: + answer.content += '' + answer.te('Global rights') + '
\n' + answer.content += '\n' + + if user.can_delete() and prevpath: + answer.content += '' + answer.te('Deletion') + '
\n' + answer.content += '\n' + + return answer.complete_content() + + +class UserDeletePage(Page): + def process(self, request, path, prevpath): + if path: + raise exception.HttpNotFound() + + answer = request.answer + user = answer.objects['user'] + if not user.can_delete(): + raise exception.HttpNotFound() + + answer.chain_title( answer.te('Delete user') ) + answer.content += '' + answer.te('Do you really want do delete user?') + '
\n' + answer.content += '' + answer.e(user.id) + '
\n' + answer.content += '' + answer.e(user.login) + '
\n' + answer.content += '' + answer.e(user.name) + '
\n' + answer.content += '\n' + return answer.complete_content() + + +class UserListPage(Page): + def process(self, request, path, prevpath): + if path: + raise exception.HttpNotFound() + if not prevpath: + raise exception.HttpNotFound() + if not request.model.users.can_list(): + raise exception.HttpNotFound() + + users = request.model.users.get_list() + + answer = request.answer + answer.chain_title( answer.te('Users list') ) + answer.content += '' + answer.te('Users list') + '
\n' + for user in users: + url = request.get_urlpath_escaped(prevpath[:-1] + ['user', str(user.id)]) + answer.content += '' \ + + answer.e(user.id) + ' ' \ + + answer.e(user.login) + ' ' \ + + answer.e(user.name) + '
\n' + + if request.model.users.can_create(): + answer.content += '' + self.make_link(answer, prevpath[:-1] + ['user_create'], 'Create user') + '
\n' + + return answer.complete_content() + diff --git a/request.py b/request.py index 7ea9126..af79cd1 100644 --- a/request.py +++ b/request.py @@ -1,24 +1,97 @@ +import cgi +import urllib.parse + import answer +from model.model import Model + class Request: - def __init__(self, server, env, start_response): + def __init__(self, server, env): self.server = server self.env = env self.method = str(self.env["REQUEST_METHOD"]) - assert self.method == 'GET' or self.method == 'POST' - self.readonly = self.method == 'GET' + self.prefix = self.server.config['urlprefix'] + self.urlvars = dict() + self.postvars = dict() + self.action = '' self.path = None - prefix = self.server.config['urlprefix'] - path = self.env["REQUEST_URI"] - if path.startswith(prefix): - path = path[len(prefix):] - self.path = [x for x in path.split('/') if x] - + self.cookie = '' self.connection = None + self.model = None + self.user = None + self.answer = None self.session = None + + # read url vars + if 'QUERY_STRING' in self.env: + urlvars = urllib.parse.parse_qs(self.env["QUERY_STRING"]) + for k, v in urlvars.items(): + assert k not in self.urlvars + self.urlvars[k] = v + + # read post vars + if self.method == 'POST': + postvars = cgi.FieldStorage( + fp = self.env["wsgi.input"], + environ = self.env ) + duplicates = set() + for k in postvars: + kk = str(k) + if kk in self.postvars: + duplicates.add(kk) + del self.postvars[kk] + elif not kk in duplicates: + self.postvars[kk] = postvars[kk].value + + # get action + if 'action' in self.postvars: + action = self.postvars['action'] + del self.postvars['action'] + if not action is None: + self.action = str(action) + + # read cookies + self.cookie = self.env.get('HTTP_COOKIE', '') + assert(type(self.cookie) is str) + + # read path + path = self.env["REQUEST_URI"] + if path.startswith(self.prefix): + path = path[len(self.prefix):] + path_list = [x for x in path.split('/') if x] + path_list_filtered = list() + for i in path_list: + if i == '..': + if path_list_filtered: + path_list_filtered.pop(); + elif i != '.': + path_list_filtered.append(i) + self.path = path_list_filtered + + self.answer = answer.Answer(self) + self.server.sessions.attach_session(self) - self.answer = answer.Answer(self, start_response) - + def create_model(self, connection): + self.connection = connection + self.model = Model(self.connection, self.answer, self.session.user_id if self.session else 0) + if self.session: + self.user = self.model.users.get_by_id(self.session.user_id) + if not self.user: + self.server.sessions.close_session(self) + + def get_urlpath(self, path = None): + if path is None: + path = self.path + assert(type(path) is list) + return self.prefix + '/' + '/'.join(path) + + def get_urlpath_escaped(self, path = None): + return self.answer.e( self.get_urlpath(path) ) + + def translate(self, text): + return self.answer.translate(text) + def t(self, text): + return self.translate(text) diff --git a/server.py b/server.py index 33507da..c6c480f 100644 --- a/server.py +++ b/server.py @@ -3,8 +3,11 @@ import datetime import db.types import db.pool -import view.root -import view.error +import db.cache +import page.root +import page.error +import action.actions +import session class Server: @@ -15,12 +18,15 @@ class Server: config_db = config.get('db', dict()) config_db_pool = config_db.get('pool', dict()) + config_db_cache = config_db.get('cache', dict()) + config_users = config.get('users', dict()) + config_session = config.get('session', dict()) self.config = { 'urlprefix' : urlprefix, 'urldataprefix' : str(config.get('urldataprefix', urlprefix + '/data')), - 'db' : { + 'db': { 'connection' : dict(config_db.get('connection', dict())), 'prefix' : str(config_db.get('prefix', '')), 'retrytime' : float(config_db.get('retrytime', 0)), @@ -28,6 +34,21 @@ class Server: 'read' : int(config_db_pool.get('read' , 10)), 'write' : int(config_db_pool.get('write', 10)), }, + 'cache': { + 'maxcount' : int(config_db_cache.get('maxcount', 10000)), + }, + }, + + 'users': { + 'salt' : str(config_users.get('salt', 'ndina82899nda90pn0al')), + 'selfcreate' : bool(config_users.get('selfcreate', False)), + 'selfdelete' : bool(config_users.get('selfdelete', False)), + 'showlist' : bool(config_users.get('showlist', False)), + 'showprofile' : bool(config_users.get('showprofile', True)), + }, + + 'session': { + 'time' : float(config_session.get('time', 30*60)) }, } @@ -36,10 +57,23 @@ class Server: or self.config['db']['prefix'].isidentifier() assert self.config['db']['pool']['read'] > 0 assert self.config['db']['pool']['write'] > 0 + assert self.config['db']['cache']['maxcount'] > 0 + assert self.config['session']['time'] > 0 + + if self.config['urlprefix'] != '': + assert self.config['urlprefix'][0] == '/' + for i in self.config['urlprefix'][1:].split('/'): + assert i.isalnum() self.dbtypebytype = db.types.bytype self.dbtypebychar = db.types.bychar self.dbpool = db.pool.Pool(self) + self.dbcache = db.cache.Cache(self) + + self.sessions = session.SessionManager(self) + + self.pageroot = page.root.RootPage() + self.pageforbidden = page.error.ErrorPage('403', 'Forbidden') + self.pagenotfound = page.error.ErrorPage('404', 'Page Not Found') - self.pageroot = view.root.RootPage() - self.pagenotfound = view.error.ErrorPage('404', 'Page Not Found') + self.actions = action.actions.actions diff --git a/session.py b/session.py new file mode 100644 index 0000000..a7047b2 --- /dev/null +++ b/session.py @@ -0,0 +1,115 @@ + +import time +import uuid +import threading + + +class Session: + def __init__(self, manager, user_id): + self.manager = manager + self.server = manager.server + self.id = uuid.uuid4().hex + self.user_id = user_id + self.maxtime = self.server.config['session']['time'] + self.touch() + + def touch(self): + self.time = time.monotonic() + + def expired(self): + return self.time + self.maxtime < time.monotonic() + + +class SessionManager: + COOKIE='session' + + def __init__(self, server): + self.server = server + self.lock = threading.Lock() + self.sessions = dict() + self.timer = threading.Timer(server.config['session']['time']*0.1, self.remove_expired) + self.timer.start() + + def remove_expired(self): + with self.lock: + remove = list() + for id, session in self.sessions.items(): + if session.expired(): + remove.append(id) + for id in remove: + del self.sessions[id] + + def delete_by_user_id(self, user_id): + assert(type(user_id) is int) + with self.lock: + remove = list() + for id, session in self.sessions.items(): + if session.user_id == user_id: + remove.append(id) + for id in remove: + del self.sessions[id] + + def add_cookie_header(self, request): + remove = list() + j = 0 + for i in request.answer.headers: + if i[0] == 'Set-Cookie': + remove.insert(0, j) + j += 1 + for i in remove: + del request.answer.headers[i] + + value = None + if request.session: + path = self.server.config['urlprefix'] + '/' + value = '%s=%s; Path=%s; Max-Age=%d; Secure' \ + % (self.COOKIE, request.session.id, path, request.session.maxtime) + else: + value = '%s=; Expires=Thu, 01 Jan 1970 00:00:00 GMT' \ + % (self.COOKIE) + request.answer.headers.append( ('Set-Cookie', value) ) + + def create_session(self, request, user_id): + assert(type(user_id) is int) + assert(user_id) + with self.lock: + session = Session(self, user_id) + self.sessions[session.id] = session + request.session = session + self.add_cookie_header(request) + + def attach_session(self, request): + request.session = None + + # parse cookie + id = None + for i in request.cookie.split(';'): + j = i.split('=') + if len(j) == 2: + if j[0].strip() == self.COOKIE: + if not id is None: # double cookie equals no cookie + id = None + break + id = j[1].strip() + + if id: + with self.lock: + session = self.sessions.get(id) + if session: + if session.expired(): + del self.sessions[id] + else: + session.touch() + request.session = session + + if request.session or request.cookie: # need we add new or delete existant cookie + self.add_cookie_header(request) + + def close_session(self, request): + if request.session: + with self.lock: + if request.session.id in self.sessions: + del self.sessions[request.session.id] + request.session = None + if request.cookie: + self.add_cookie_header(request) diff --git a/template/common.py b/template/common.py index d457ddc..e52473e 100644 --- a/template/common.py +++ b/template/common.py @@ -2,36 +2,42 @@ from template.template import Template from template.login import LoginTemplate from template.usermenu import UsermenuTemplate +from template.errors import ErrorsTemplate class CommonTemplate(Template): def __init__(self): + super().__init__() self.login = LoginTemplate() self.usermenu = UsermenuTemplate() + self.errors = ErrorsTemplate() def wrap(self, answer): return ''' - - -' + answer.e(e) + '
\n' + return ''' +' + self.code + '
' - answer.content += '' + answer.t(self.message) + '
' - return answer.complete_content() - diff --git a/view/page.py b/view/page.py deleted file mode 100644 index 0a395dc..0000000 --- a/view/page.py +++ /dev/null @@ -1,8 +0,0 @@ - -import exception - - -class Page: - def process(self, request, path): - raise exception.HttpNotFound - diff --git a/view/root.py b/view/root.py deleted file mode 100644 index 4d0e792..0000000 --- a/view/root.py +++ /dev/null @@ -1,40 +0,0 @@ - - -import exception - -from view.page import Page -from view.user import UserPage -from view.users import UsersPage - -from template.common import CommonTemplate - - -class RootPage(Page): - def __init__(self): - self.template = CommonTemplate() - self.user = UserPage() - self.users = UsersPage() - - def process(self, request, path): - answer = request.answer - answer.template = self.template - - if request.path: - if request.path[0] == 'user': - return self.user.process(request, path[1:]) - if request.path[0] == 'users': - return self.users.process(request, path[1:]) - raise exception.HttpNotFound() - - answer.content += '' + answer.t("root page") + '
' - answer.content += '' + answer.t("Welcome!") + '
' - answer.content += '' + "Env:\n" + str(request.env) + '
' - - tables = list(v[0] for v in request.connection.query_list('SHOW TABLES')) - answer.content += 'DB tables: ' + ', '.join(tables) + '
' - - rows = request.connection.query_dict('SELECT * FROM %T', 'test') - answer.content += 'Rows of test table: ' + str(rows) + '
' - - return answer.complete_content() - diff --git a/view/user.py b/view/user.py deleted file mode 100644 index 3468b0c..0000000 --- a/view/user.py +++ /dev/null @@ -1,7 +0,0 @@ - - -from view.page import Page - - -class UserPage(Page): - pass diff --git a/view/users.py b/view/users.py deleted file mode 100644 index 3e9484a..0000000 --- a/view/users.py +++ /dev/null @@ -1,8 +0,0 @@ - - -from view.page import Page - - -class UsersPage(Page): - pass -