From 1aa0d65cf7e2d6d9ffd289b9f9d7bd29a24530ec Mon Sep 17 00:00:00 2001 From: luukas Date: Thu, 14 Apr 2022 15:33:53 +0300 Subject: [PATCH 1/2] Merge branch feature/external-frontend to feature/api-v2 without the frontend --- .gitignore | 6 +- app/classes/controllers/servers_controller.py | 19 +- app/classes/controllers/users_controller.py | 60 +- app/classes/models/crafty_permissions.py | 10 +- app/classes/models/management.py | 4 +- app/classes/models/server_permissions.py | 5 +- app/classes/models/users.py | 35 +- app/classes/shared/authentication.py | 16 +- app/classes/shared/migration.py | 966 +++++++++--------- app/classes/shared/tasks.py | 11 + app/classes/web/base_api_handler.py | 7 + app/classes/web/base_handler.py | 153 ++- app/classes/web/panel_handler.py | 2 + app/classes/web/routes/api/api_handlers.py | 55 + .../web/routes/api/auth/invalidate_tokens.py | 21 + app/classes/web/routes/api/auth/login.py | 101 ++ app/classes/web/routes/api/auth/register.py | 2 + app/classes/web/routes/api/roles/index.py | 0 .../web/routes/api/roles/role/index.py | 0 .../web/routes/api/roles/role/users.py | 0 app/classes/web/routes/api/servers/index.py | 20 + .../web/routes/api/servers/server/action.py | 91 ++ .../web/routes/api/servers/server/index.py | 163 +++ .../web/routes/api/servers/server/logs.py | 73 ++ .../web/routes/api/servers/server/public.py | 23 + .../web/routes/api/servers/server/stats.py | 27 + .../web/routes/api/servers/server/users.py | 31 + app/classes/web/routes/api/users/index.py | 166 +++ .../web/routes/api/users/user/index.py | 211 ++++ app/classes/web/routes/api/users/user/pfp.py | 49 + .../web/routes/api/users/user/public.py | 35 + app/classes/web/tornado_handler.py | 7 +- app/config/config.json | 51 +- app/frontend/templates/panel/dashboard.html | 2 +- requirements.txt | 2 + 35 files changed, 1883 insertions(+), 541 deletions(-) create mode 100644 app/classes/web/base_api_handler.py create mode 100644 app/classes/web/routes/api/api_handlers.py create mode 100644 app/classes/web/routes/api/auth/invalidate_tokens.py create mode 100644 app/classes/web/routes/api/auth/login.py create mode 100644 app/classes/web/routes/api/auth/register.py create mode 100644 app/classes/web/routes/api/roles/index.py create mode 100644 app/classes/web/routes/api/roles/role/index.py create mode 100644 app/classes/web/routes/api/roles/role/users.py create mode 100644 app/classes/web/routes/api/servers/index.py create mode 100644 app/classes/web/routes/api/servers/server/action.py create mode 100644 app/classes/web/routes/api/servers/server/index.py create mode 100644 app/classes/web/routes/api/servers/server/logs.py create mode 100644 app/classes/web/routes/api/servers/server/public.py create mode 100644 app/classes/web/routes/api/servers/server/stats.py create mode 100644 app/classes/web/routes/api/servers/server/users.py create mode 100644 app/classes/web/routes/api/users/index.py create mode 100644 app/classes/web/routes/api/users/user/index.py create mode 100644 app/classes/web/routes/api/users/user/pfp.py create mode 100644 app/classes/web/routes/api/users/user/public.py diff --git a/.gitignore b/.gitignore index bf21675b..0c17d6c7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,10 @@ env.bak/ venv.bak/ .idea/ -servers/ -backups/ +/servers/ +/backups/ +/docker/servers/ +/docker/backups/ session.lock .header default.json diff --git a/app/classes/controllers/servers_controller.py b/app/classes/controllers/servers_controller.py index 14124e7a..c2da2e22 100644 --- a/app/classes/controllers/servers_controller.py +++ b/app/classes/controllers/servers_controller.py @@ -1,6 +1,7 @@ import os import logging import json +import typing as t from app.classes.controllers.roles_controller import Roles_Controller from app.classes.models.servers import helper_servers @@ -91,7 +92,7 @@ class Servers_Controller: @staticmethod def get_authorized_servers(user_id): - server_data = [] + server_data: t.List[t.Dict[str, t.Any]] = [] user_roles = helper_users.user_role_query(user_id) for us in user_roles: role_servers = Permissions_Servers.get_role_servers_from_role_id(us.role_id) @@ -100,6 +101,20 @@ class Servers_Controller: return server_data + @staticmethod + def get_authorized_users(server_id: str): + user_ids: t.Set[int] = set() + roles_list = Permissions_Servers.get_roles_from_server(server_id) + for role in roles_list: + role_users = helper_users.get_users_from_role(role.role_id) + for user_role in role_users: + user_ids.add(user_role.user_id) + + for user_id in helper_users.get_super_user_list(): + user_ids.add(user_id) + + return user_ids + @staticmethod def get_all_servers_stats(): return helper_servers.get_all_servers_stats() @@ -108,7 +123,7 @@ class Servers_Controller: def get_authorized_servers_stats_api_key(api_key: ApiKeys): server_data = [] authorized_servers = Servers_Controller.get_authorized_servers( - api_key.user.user_id + api_key.user.user_id # TODO: API key authorized servers? ) for s in authorized_servers: diff --git a/app/classes/controllers/users_controller.py b/app/classes/controllers/users_controller.py index 0a7d1f63..2452ca04 100644 --- a/app/classes/controllers/users_controller.py +++ b/app/classes/controllers/users_controller.py @@ -1,5 +1,6 @@ import logging from typing import Optional +import typing from app.classes.models.users import helper_users from app.classes.models.crafty_permissions import ( @@ -16,6 +17,48 @@ class Users_Controller: self.users_helper = users_helper self.authentication = authentication + _permissions_props = { + "name": { + "type": "string", + "enum": [ + permission.name + for permission in Permissions_Crafty.get_permissions_list() + ], + }, + "quantity": {"type": "number", "minimum": 0}, + "enabled": {"type": "boolean"}, + } + self.user_jsonschema_props: typing.Final = { + "username": { + "type": "string", + "maxLength": 20, + "minLength": 4, + "pattern": "^[a-z0-9_]+$", + }, + "password": {"type": "string", "maxLength": 20, "minLength": 4}, + "email": {"type": "string", "format": "email"}, + "enabled": {"type": "boolean"}, + "lang": { + "type": "string", + "maxLength": 10, + "minLength": 2, + }, + "superuser": {"type": "boolean"}, + "permissions": { + "type": "array", + "items": { + "type": "object", + "properties": _permissions_props, + "required": ["name", "quantity", "enabled"], + }, + }, + "roles": { + "type": "array", + "items": {"type": "string"}, + }, + "hints": {"type": "boolean"}, + } + # ********************************************************************************** # Users Methods # ********************************************************************************** @@ -23,6 +66,10 @@ class Users_Controller: def get_all_users(): return helper_users.get_all_users() + @staticmethod + def get_all_user_ids(): + return helper_users.get_all_user_ids() + @staticmethod def get_id_by_name(username): return helper_users.get_user_id_by_name(username) @@ -107,6 +154,17 @@ class Users_Controller: self.users_helper.update_user(user_id, up_data) + def raw_update_user( + self, user_id: int, up_data: typing.Optional[typing.Dict[str, typing.Any]] + ): + """Directly passes the data to the model helper. + + Args: + user_id (int): The id of the user to update. + up_data (typing.Optional[typing.Dict[str, typing.Any]]): Update data. + """ + self.users_helper.update_user(user_id, up_data) + def add_user( self, username, @@ -159,7 +217,7 @@ class Users_Controller: return token_data["user_id"] def get_user_by_api_token(self, token: str): - _, _, user = self.authentication.check(token) + _, _, user = self.authentication.check_err(token) return user def get_api_key_by_token(self, token: str): diff --git a/app/classes/models/crafty_permissions.py b/app/classes/models/crafty_permissions.py index a35d4f50..986a7772 100644 --- a/app/classes/models/crafty_permissions.py +++ b/app/classes/models/crafty_permissions.py @@ -1,4 +1,5 @@ import logging +import typing from enum import Enum from peewee import ( ForeignKeyField, @@ -45,21 +46,24 @@ class Permissions_Crafty: # ********************************************************************************** @staticmethod def get_permissions_list(): - permissions_list = [] + permissions_list: typing.List[Enum_Permissions_Crafty] = [] for member in Enum_Permissions_Crafty.__members__.items(): permissions_list.append(member[1]) return permissions_list @staticmethod def get_permissions(permissions_mask): - permissions_list = [] + permissions_list: typing.List[Enum_Permissions_Crafty] = [] for member in Enum_Permissions_Crafty.__members__.items(): if Permissions_Crafty.has_permission(permissions_mask, member[1]): permissions_list.append(member[1]) return permissions_list @staticmethod - def has_permission(permission_mask, permission_tested: Enum_Permissions_Crafty): + def has_permission( + permission_mask: typing.Mapping[int, str], + permission_tested: Enum_Permissions_Crafty, + ): result = False if permission_mask[permission_tested.value] == "1": result = True diff --git a/app/classes/models/management.py b/app/classes/models/management.py index 27a4e8c5..42e302da 100644 --- a/app/classes/models/management.py +++ b/app/classes/models/management.py @@ -394,7 +394,7 @@ class helpers_management: return dir_list def add_excluded_backup_dir(self, server_id: int, dir_to_add: str): - dir_list = self.get_excluded_backup_dirs() + dir_list = self.get_excluded_backup_dirs(server_id) if dir_to_add not in dir_list: dir_list.append(dir_to_add) excluded_dirs = ",".join(dir_list) @@ -406,7 +406,7 @@ class helpers_management: ) def del_excluded_backup_dir(self, server_id: int, dir_to_del: str): - dir_list = self.get_excluded_backup_dirs() + dir_list = self.get_excluded_backup_dirs(server_id) if dir_to_del in dir_list: dir_list.remove(dir_to_del) excluded_dirs = ",".join(dir_list) diff --git a/app/classes/models/server_permissions.py b/app/classes/models/server_permissions.py index 7ba524b1..49622d38 100644 --- a/app/classes/models/server_permissions.py +++ b/app/classes/models/server_permissions.py @@ -1,5 +1,6 @@ from enum import Enum import logging +import typing from peewee import ( ForeignKeyField, CharField, @@ -51,14 +52,14 @@ class Permissions_Servers: @staticmethod def get_permissions_list(): - permissions_list = [] + permissions_list: typing.List[Enum_Permissions_Server] = [] for member in Enum_Permissions_Server.__members__.items(): permissions_list.append(member[1]) return permissions_list @staticmethod def get_permissions(permissions_mask): - permissions_list = [] + permissions_list: typing.List[Enum_Permissions_Server] = [] for member in Enum_Permissions_Server.__members__.items(): if Permissions_Servers.has_permission(permissions_mask, member[1]): permissions_list.append(member[1]) diff --git a/app/classes/models/users.py b/app/classes/models/users.py index ee44114d..e4bb30f7 100644 --- a/app/classes/models/users.py +++ b/app/classes/models/users.py @@ -1,6 +1,6 @@ import logging import datetime -from typing import Optional, Union +import typing as t from peewee import ( ForeignKeyField, @@ -45,6 +45,15 @@ class Users(BaseModel): table_name = "users" +PUBLIC_USER_ATTRS: t.Final = [ + "user_id", + "created", + "username", + "enabled", + "superuser", + "lang", # maybe remove? +] + # ********************************************************************************** # API Keys Class # ********************************************************************************** @@ -90,6 +99,11 @@ class helper_users: query = Users.select().where(Users.username != "system") return query + @staticmethod + def get_all_user_ids(): + query = Users.select(Users.user_id).where(Users.username != "system") + return query + @staticmethod def get_user_lang_by_id(user_id): return Users.get(Users.user_id == user_id).lang @@ -153,7 +167,7 @@ class helper_users: self, username: str, password: str = None, - email: Optional[str] = None, + email: t.Optional[str] = None, enabled: bool = True, superuser: bool = False, ) -> str: @@ -177,7 +191,7 @@ class helper_users: def add_rawpass_user( username: str, password: str = None, - email: Optional[str] = None, + email: t.Optional[str] = None, enabled: bool = True, superuser: bool = False, ) -> str: @@ -212,7 +226,7 @@ class helper_users: @staticmethod def get_super_user_list(): - final_users = [] + final_users: t.List[int] = [] super_users = Users.select().where( Users.superuser == True # pylint: disable=singleton-comparison ) @@ -224,8 +238,7 @@ class helper_users: def remove_user(self, user_id): with self.database.atomic(): User_Roles.delete().where(User_Roles.user_id == user_id).execute() - user = Users.get(Users.user_id == user_id) - return user.delete_instance() + return Users.delete().where(Users.user_id == user_id).execute() @staticmethod def set_support_path(user_id, support_path): @@ -284,7 +297,7 @@ class helper_users: ).execute() @staticmethod - def add_user_roles(user: Union[dict, Users]): + def add_user_roles(user: t.Union[dict, Users]): if isinstance(user, dict): user_id = user["user_id"] else: @@ -329,6 +342,10 @@ class helper_users: def remove_roles_from_role_id(role_id): User_Roles.delete().where(User_Roles.role_id == role_id).execute() + @staticmethod + def get_users_from_role(role_id): + User_Roles.select().where(User_Roles.role_id == role_id).execute() + # ********************************************************************************** # ApiKeys Methods # ********************************************************************************** @@ -346,8 +363,8 @@ class helper_users: name: str, user_id: str, superuser: bool = False, - server_permissions_mask: Optional[str] = None, - crafty_permissions_mask: Optional[str] = None, + server_permissions_mask: t.Optional[str] = None, + crafty_permissions_mask: t.Optional[str] = None, ): return ApiKeys.insert( { diff --git a/app/classes/shared/authentication.py b/app/classes/shared/authentication.py index bab36c28..24468cce 100644 --- a/app/classes/shared/authentication.py +++ b/app/classes/shared/authentication.py @@ -34,7 +34,7 @@ class Authentication: def check_no_iat(self, token) -> Optional[Dict[str, Any]]: try: - return jwt.decode(token, self.secret, algorithms=["HS256"]) + return jwt.decode(str(token), self.secret, algorithms=["HS256"]) except PyJWTError as error: logger.debug("Error while checking JWT token: ", exc_info=error) return None @@ -44,7 +44,7 @@ class Authentication: token, ) -> Optional[Tuple[Optional[ApiKeys], Dict[str, Any], Dict[str, Any]]]: try: - data = jwt.decode(token, self.secret, algorithms=["HS256"]) + data = jwt.decode(str(token), self.secret, algorithms=["HS256"]) except PyJWTError as error: logger.debug("Error while checking JWT token: ", exc_info=error) return None @@ -65,5 +65,17 @@ class Authentication: else: return None + def check_err( + self, + token, + ) -> Tuple[Optional[ApiKeys], Dict[str, Any], Dict[str, Any]]: + # Without this function there would be runtime exceptions like the following: + # "None" object is not iterable + + output = self.check(token) + if output is None: + raise Exception("Invalid token") + return output + def check_bool(self, token) -> bool: return self.check(token) is not None diff --git a/app/classes/shared/migration.py b/app/classes/shared/migration.py index 6d1bfba8..c31542a2 100644 --- a/app/classes/shared/migration.py +++ b/app/classes/shared/migration.py @@ -1,483 +1,483 @@ -# pylint: skip-file -from datetime import datetime -import logging -import typing as t -import sys -import os -import re -from functools import wraps -from functools import cached_property -import peewee -from playhouse.migrate import ( - SqliteMigrator, - Operation, - SQL, - SqliteDatabase, - make_index_name, -) - -from app.classes.shared.console import Console -from app.classes.shared.helpers import Helpers - -logger = logging.getLogger(__name__) - -MIGRATE_TABLE = "migratehistory" -MIGRATE_TEMPLATE = '''# Generated by database migrator -import peewee - -def migrate(migrator, db): - """ - Write your migrations here. - """ -{migrate} - -def rollback(migrator, db): - """ - Write your rollback migrations here. - """ -{rollback}''' - - -class MigrateHistory(peewee.Model): - """ - Presents the migration history in a database. - """ - - name = peewee.CharField(unique=True) - migrated_at = peewee.DateTimeField(default=datetime.utcnow) - - # noinspection PyTypeChecker - def __unicode__(self) -> str: - """ - String representation of this migration - """ - return self.name - - class Meta: - table_name = MIGRATE_TABLE - - -def get_model(method): - """ - Convert string to model class. - """ - - @wraps(method) - def wrapper(migrator, model, *args, **kwargs): - if isinstance(model, str): - return method(migrator, migrator.table_dict[model], *args, **kwargs) - return method(migrator, model, *args, **kwargs) - - return wrapper - - -# noinspection PyProtectedMember -class Migrator(object): - def __init__(self, database: t.Union[peewee.Database, peewee.Proxy]): - """ - Initializes the migrator - """ - if isinstance(database, peewee.Proxy): - database = database.obj - self.database: SqliteDatabase = database - self.table_dict: t.Dict[str, peewee.Model] = {} - self.operations: t.List[t.Union[Operation, callable]] = [] - self.migrator = SqliteMigrator(database) - - def run(self): - """ - Runs operations. - """ - for op in self.operations: - if isinstance(op, Operation): - op.run() - else: - op() - self.clean() - - def clean(self): - """ - Cleans the operations. - """ - self.operations = list() - - def sql(self, sql: str, *params): - """ - Executes raw SQL. - """ - self.operations.append(SQL(sql, *params)) - - def create_table(self, model: peewee.Model) -> peewee.Model: - """ - Creates model and table in database. - """ - self.table_dict[model._meta.table_name] = model - model._meta.database = self.database - self.operations.append(model.create_table) - return model - - @get_model - def drop_table(self, model: peewee.Model): - """ - Drops model and table from database. - """ - del self.table_dict[model._meta.table_name] - self.operations.append(lambda: model.drop_table(cascade=False)) - - @get_model - def add_columns(self, model: peewee.Model, **fields: peewee.Field) -> peewee.Model: - """ - Creates new fields. - """ - for name, field in fields.items(): - model._meta.add_field(name, field) - self.operations.append( - self.migrator.add_column( - model._meta.table_name, field.column_name, field - ) - ) - if field.unique: - self.operations.append( - self.migrator.add_index( - model._meta.table_name, (field.column_name,), unique=True - ) - ) - return model - - @get_model - def drop_columns(self, model: peewee.Model, names: str) -> peewee.Model: - """ - Removes fields from model. - """ - fields = [field for field in model._meta.fields.values() if field.name in names] - for field in fields: - self.__del_field__(model, field) - if field.unique: - # Drop unique index - index_name = make_index_name( - model._meta.table_name, [field.column_name] - ) - self.operations.append( - self.migrator.drop_index(model._meta.table_name, index_name) - ) - self.operations.append( - self.migrator.drop_column( - model._meta.table_name, field.column_name, cascade=False - ) - ) - return model - - def __del_field__(self, model: peewee.Model, field: peewee.Field): - """ - Deletes field from model. - """ - model._meta.remove_field(field.name) - delattr(model, field.name) - if isinstance(field, peewee.ForeignKeyField): - obj_id_name = field.column_name - if field.column_name == field.name: - obj_id_name += "_id" - delattr(model, obj_id_name) - delattr(field.rel_model, field.backref) - - @get_model - def rename_column( - self, model: peewee.Model, old_name: str, new_name: str - ) -> peewee.Model: - """ - Renames field in model. - """ - field = model._meta.fields[old_name] - if isinstance(field, peewee.ForeignKeyField): - old_name = field.column_name - self.__del_field__(model, field) - field.name = field.column_name = new_name - model._meta.add_field(new_name, field) - if isinstance(field, peewee.ForeignKeyField): - field.column_name = new_name = field.column_name + "_id" - self.operations.append( - self.migrator.rename_column(model._meta.table_name, old_name, new_name) - ) - return model - - @get_model - def rename_table(self, model: peewee.Model, new_name: str) -> peewee.Model: - """ - Renames table in database. - """ - old_name = model._meta.table_name - del self.table_dict[model._meta.table_name] - model._meta.table_name = new_name - self.table_dict[model._meta.table_name] = model - self.operations.append(self.migrator.rename_table(old_name, new_name)) - return model - - @get_model - def add_index( - self, model: peewee.Model, *columns: str, unique=False - ) -> peewee.Model: - """Create indexes.""" - model._meta.indexes.append((columns, unique)) - columns_ = [] - for col in columns: - field = model._meta.fields.get(col) - - if len(columns) == 1: - field.unique = unique - field.index = not unique - - if isinstance(field, peewee.ForeignKeyField): - col = col + "_id" - - columns_.append(col) - self.operations.append( - self.migrator.add_index(model._meta.table_name, columns_, unique=unique) - ) - return model - - @get_model - def drop_index(self, model: peewee.Model, *columns: str) -> peewee.Model: - """Drop indexes.""" - columns_ = [] - for col in columns: - field = model._meta.fields.get(col) - if not field: - continue - - if len(columns) == 1: - field.unique = field.index = False - - if isinstance(field, peewee.ForeignKeyField): - col = col + "_id" - columns_.append(col) - index_name = make_index_name(model._meta.table_name, columns_) - model._meta.indexes = [ - (cols, _) for (cols, _) in model._meta.indexes if columns != cols - ] - self.operations.append( - self.migrator.drop_index(model._meta.table_name, index_name) - ) - return model - - @get_model - def add_not_null(self, model: peewee.Model, *names: str) -> peewee.Model: - """Add not null.""" - for name in names: - field = model._meta.fields[name] - field.null = False - self.operations.append( - self.migrator.add_not_null(model._meta.table_name, field.column_name) - ) - return model - - @get_model - def drop_not_null(self, model: peewee.Model, *names: str) -> peewee.Model: - """Drop not null.""" - for name in names: - field = model._meta.fields[name] - field.null = True - self.operations.append( - self.migrator.drop_not_null(model._meta.table_name, field.column_name) - ) - return model - - @get_model - def add_default( - self, model: peewee.Model, name: str, default: t.Any - ) -> peewee.Model: - """Add default.""" - field = model._meta.fields[name] - model._meta.defaults[field] = field.default = default - self.operations.append( - self.migrator.apply_default(model._meta.table_name, name, field) - ) - return model - - -# noinspection PyProtectedMember -class MigrationManager(object): - filemask = re.compile(r"[\d]+_[^\.]+\.py$") - - def __init__(self, database: t.Union[peewee.Database, peewee.Proxy], helper): - """ - Initializes the migration manager. - """ - if not isinstance(database, (peewee.Database, peewee.Proxy)): - raise RuntimeError("Invalid database: {}".format(database)) - self.database = database - self.helper = helper - - @cached_property - def model(self) -> t.Type[MigrateHistory]: - """ - Initialize and cache the MigrationHistory model. - """ - MigrateHistory._meta.database = self.database - MigrateHistory._meta.table_name = "migratehistory" - MigrateHistory._meta.schema = None - MigrateHistory.create_table(True) - return MigrateHistory - - @property - def done(self) -> t.List[str]: - """ - Scans migrations in the database. - """ - return [mm.name for mm in self.model.select().order_by(self.model.id)] - - @property - def todo(self): - """ - Scans migrations in the file system. - """ - if not os.path.exists(self.helper.migration_dir): - logger.warning( - "Migration directory: {} does not exist.".format( - self.helper.migration_dir - ) - ) - os.makedirs(self.helper.migration_dir) - return sorted( - f[:-3] - for f in os.listdir(self.helper.migration_dir) - if self.filemask.match(f) - ) - - @property - def diff(self) -> t.List[str]: - """ - Calculates difference between the filesystem and the database. - """ - done = set(self.done) - return [name for name in self.todo if name not in done] - - @cached_property - def migrator(self) -> Migrator: - """ - Create migrator and setup it with fake migrations. - """ - migrator = Migrator(self.database) - for name in self.done: - self.up_one(name, migrator, True) - return migrator - - def compile(self, name, migrate="", rollback=""): - """ - Compiles a migration. - """ - name = datetime.utcnow().strftime("%Y%m%d%H%M%S") + "_" + name - filename = name + ".py" - path = os.path.join(self.helper.migration_dir, filename) - with open(path, "w") as f: - f.write( - MIGRATE_TEMPLATE.format( - migrate=migrate, rollback=rollback, name=filename - ) - ) - - return name - - def create(self, name: str = "auto", auto: bool = False) -> t.Optional[str]: - """ - Creates a migration. - """ - migrate = rollback = "" - if auto: - raise NotImplementedError - - logger.info('Creating migration "{}"'.format(name)) - name = self.compile(name, migrate, rollback) - logger.info('Migration has been created as "{}"'.format(name)) - return name - - def clear(self): - """Clear migrations.""" - self.model.delete().execute() - - def up(self, name: t.Optional[str] = None): - """ - Runs all unapplied migrations. - """ - logger.info("Starting migrations") - Console.info("Starting migrations") - - done = [] - diff = self.diff - if not diff: - logger.info("There is nothing to migrate") - Console.info("There is nothing to migrate") - return done - - migrator = self.migrator - for mname in diff: - done.append(self.up_one(mname, self.migrator)) - if name and name == mname: - break - - return done - - def read(self, name: str): - """ - Reads a migration from a file. - """ - call_params = dict() - if Helpers.is_os_windows() and sys.version_info >= (3, 0): - # if system is windows - force utf-8 encoding - call_params["encoding"] = "utf-8" - with open( - os.path.join(self.helper.migration_dir, name + ".py"), **call_params - ) as f: - code = f.read() - scope = {} - code = compile(code, "", "exec", dont_inherit=True) - exec(code, scope, None) - return scope.get("migrate", lambda m, d: None), scope.get( - "rollback", lambda m, d: None - ) - - def up_one( - self, name: str, migrator: Migrator, fake: bool = False, rollback: bool = False - ) -> str: - """ - Runs a migration with a given name. - """ - try: - migrate_fn, rollback_fn = self.read(name) - if fake: - migrate_fn(migrator, self.database) - migrator.clean() - return name - with self.database.transaction(): - if rollback: - logger.info('Rolling back "{}"'.format(name)) - rollback_fn(migrator, self.database) - migrator.run() - self.model.delete().where(self.model.name == name).execute() - else: - logger.info('Migrate "{}"'.format(name)) - migrate_fn(migrator, self.database) - migrator.run() - if name not in self.done: - self.model.create(name=name) - - logger.info('Done "{}"'.format(name)) - return name - - except Exception: - self.database.rollback() - operation_name = "Rollback" if rollback else "Migration" - logger.exception("{} failed: {}".format(operation_name, name)) - raise - - def down(self): - """ - Rolls back migrations. - """ - if not self.done: - raise RuntimeError("No migrations are found.") - - name = self.done[-1] - - migrator = self.migrator - self.up_one(name, migrator, False, True) - logger.warning("Rolled back migration: {}".format(name)) +# pylint: skip-file +from datetime import datetime +import logging +import typing as t +import sys +import os +import re +from functools import wraps +from functools import cached_property +import peewee +from playhouse.migrate import ( + SqliteMigrator, + Operation, + SQL, + SqliteDatabase, + make_index_name, +) + +from app.classes.shared.console import Console +from app.classes.shared.helpers import Helpers + +logger = logging.getLogger(__name__) + +MIGRATE_TABLE = "migratehistory" +MIGRATE_TEMPLATE = '''# Generated by database migrator +import peewee + +def migrate(migrator, db): + """ + Write your migrations here. + """ +{migrate} + +def rollback(migrator, db): + """ + Write your rollback migrations here. + """ +{rollback}''' + + +class MigrateHistory(peewee.Model): + """ + Presents the migration history in a database. + """ + + name = peewee.CharField(unique=True) + migrated_at = peewee.DateTimeField(default=datetime.utcnow) + + # noinspection PyTypeChecker + def __unicode__(self) -> str: + """ + String representation of this migration + """ + return self.name + + class Meta: + table_name = MIGRATE_TABLE + + +def get_model(method): + """ + Convert string to model class. + """ + + @wraps(method) + def wrapper(migrator, model, *args, **kwargs): + if isinstance(model, str): + return method(migrator, migrator.table_dict[model], *args, **kwargs) + return method(migrator, model, *args, **kwargs) + + return wrapper + + +# noinspection PyProtectedMember +class Migrator(object): + def __init__(self, database: t.Union[peewee.Database, peewee.Proxy]): + """ + Initializes the migrator + """ + if isinstance(database, peewee.Proxy): + database = database.obj + self.database: SqliteDatabase = database + self.table_dict: t.Dict[str, peewee.Model] = {} + self.operations: t.List[t.Union[Operation, t.Callable]] = [] + self.migrator = SqliteMigrator(database) + + def run(self): + """ + Runs operations. + """ + for op in self.operations: + if isinstance(op, Operation): + op.run() + else: + op() + self.clean() + + def clean(self): + """ + Cleans the operations. + """ + self.operations = list() + + def sql(self, sql: str, *params): + """ + Executes raw SQL. + """ + self.operations.append(SQL(sql, *params)) + + def create_table(self, model: peewee.Model) -> peewee.Model: + """ + Creates model and table in database. + """ + self.table_dict[model._meta.table_name] = model + model._meta.database = self.database + self.operations.append(model.create_table) + return model + + @get_model + def drop_table(self, model: peewee.Model): + """ + Drops model and table from database. + """ + del self.table_dict[model._meta.table_name] + self.operations.append(lambda: model.drop_table(cascade=False)) + + @get_model + def add_columns(self, model: peewee.Model, **fields: peewee.Field) -> peewee.Model: + """ + Creates new fields. + """ + for name, field in fields.items(): + model._meta.add_field(name, field) + self.operations.append( + self.migrator.add_column( + model._meta.table_name, field.column_name, field + ) + ) + if field.unique: + self.operations.append( + self.migrator.add_index( + model._meta.table_name, (field.column_name,), unique=True + ) + ) + return model + + @get_model + def drop_columns(self, model: peewee.Model, names: str) -> peewee.Model: + """ + Removes fields from model. + """ + fields = [field for field in model._meta.fields.values() if field.name in names] + for field in fields: + self.__del_field__(model, field) + if field.unique: + # Drop unique index + index_name = make_index_name( + model._meta.table_name, [field.column_name] + ) + self.operations.append( + self.migrator.drop_index(model._meta.table_name, index_name) + ) + self.operations.append( + self.migrator.drop_column( + model._meta.table_name, field.column_name, cascade=False + ) + ) + return model + + def __del_field__(self, model: peewee.Model, field: peewee.Field): + """ + Deletes field from model. + """ + model._meta.remove_field(field.name) + delattr(model, field.name) + if isinstance(field, peewee.ForeignKeyField): + obj_id_name = field.column_name + if field.column_name == field.name: + obj_id_name += "_id" + delattr(model, obj_id_name) + delattr(field.rel_model, field.backref) + + @get_model + def rename_column( + self, model: peewee.Model, old_name: str, new_name: str + ) -> peewee.Model: + """ + Renames field in model. + """ + field = model._meta.fields[old_name] + if isinstance(field, peewee.ForeignKeyField): + old_name = field.column_name + self.__del_field__(model, field) + field.name = field.column_name = new_name + model._meta.add_field(new_name, field) + if isinstance(field, peewee.ForeignKeyField): + field.column_name = new_name = field.column_name + "_id" + self.operations.append( + self.migrator.rename_column(model._meta.table_name, old_name, new_name) + ) + return model + + @get_model + def rename_table(self, model: peewee.Model, new_name: str) -> peewee.Model: + """ + Renames table in database. + """ + old_name = model._meta.table_name + del self.table_dict[model._meta.table_name] + model._meta.table_name = new_name + self.table_dict[model._meta.table_name] = model + self.operations.append(self.migrator.rename_table(old_name, new_name)) + return model + + @get_model + def add_index( + self, model: peewee.Model, *columns: str, unique=False + ) -> peewee.Model: + """Create indexes.""" + model._meta.indexes.append((columns, unique)) + columns_ = [] + for col in columns: + field = model._meta.fields.get(col) + + if len(columns) == 1: + field.unique = unique + field.index = not unique + + if isinstance(field, peewee.ForeignKeyField): + col = col + "_id" + + columns_.append(col) + self.operations.append( + self.migrator.add_index(model._meta.table_name, columns_, unique=unique) + ) + return model + + @get_model + def drop_index(self, model: peewee.Model, *columns: str) -> peewee.Model: + """Drop indexes.""" + columns_ = [] + for col in columns: + field = model._meta.fields.get(col) + if not field: + continue + + if len(columns) == 1: + field.unique = field.index = False + + if isinstance(field, peewee.ForeignKeyField): + col = col + "_id" + columns_.append(col) + index_name = make_index_name(model._meta.table_name, columns_) + model._meta.indexes = [ + (cols, _) for (cols, _) in model._meta.indexes if columns != cols + ] + self.operations.append( + self.migrator.drop_index(model._meta.table_name, index_name) + ) + return model + + @get_model + def add_not_null(self, model: peewee.Model, *names: str) -> peewee.Model: + """Add not null.""" + for name in names: + field = model._meta.fields[name] + field.null = False + self.operations.append( + self.migrator.add_not_null(model._meta.table_name, field.column_name) + ) + return model + + @get_model + def drop_not_null(self, model: peewee.Model, *names: str) -> peewee.Model: + """Drop not null.""" + for name in names: + field = model._meta.fields[name] + field.null = True + self.operations.append( + self.migrator.drop_not_null(model._meta.table_name, field.column_name) + ) + return model + + @get_model + def add_default( + self, model: peewee.Model, name: str, default: t.Any + ) -> peewee.Model: + """Add default.""" + field = model._meta.fields[name] + model._meta.defaults[field] = field.default = default + self.operations.append( + self.migrator.apply_default(model._meta.table_name, name, field) + ) + return model + + +# noinspection PyProtectedMember +class MigrationManager(object): + filemask = re.compile(r"[\d]+_[^\.]+\.py$") + + def __init__(self, database: t.Union[peewee.Database, peewee.Proxy], helper): + """ + Initializes the migration manager. + """ + if not isinstance(database, (peewee.Database, peewee.Proxy)): + raise RuntimeError("Invalid database: {}".format(database)) + self.database = database + self.helper = helper + + @cached_property + def model(self) -> t.Type[MigrateHistory]: + """ + Initialize and cache the MigrationHistory model. + """ + MigrateHistory._meta.database = self.database + MigrateHistory._meta.table_name = "migratehistory" + MigrateHistory._meta.schema = None + MigrateHistory.create_table(True) + return MigrateHistory + + @property + def done(self) -> t.List[str]: + """ + Scans migrations in the database. + """ + return [mm.name for mm in self.model.select().order_by(self.model.id)] + + @property + def todo(self): + """ + Scans migrations in the file system. + """ + if not os.path.exists(self.helper.migration_dir): + logger.warning( + "Migration directory: {} does not exist.".format( + self.helper.migration_dir + ) + ) + os.makedirs(self.helper.migration_dir) + return sorted( + f[:-3] + for f in os.listdir(self.helper.migration_dir) + if self.filemask.match(f) + ) + + @property + def diff(self) -> t.List[str]: + """ + Calculates difference between the filesystem and the database. + """ + done = set(self.done) + return [name for name in self.todo if name not in done] + + @cached_property + def migrator(self) -> Migrator: + """ + Create migrator and setup it with fake migrations. + """ + migrator = Migrator(self.database) + for name in self.done: + self.up_one(name, migrator, True) + return migrator + + def compile(self, name, migrate="", rollback=""): + """ + Compiles a migration. + """ + name = datetime.utcnow().strftime("%Y%m%d%H%M%S") + "_" + name + filename = name + ".py" + path = os.path.join(self.helper.migration_dir, filename) + with open(path, "w") as f: + f.write( + MIGRATE_TEMPLATE.format( + migrate=migrate, rollback=rollback, name=filename + ) + ) + + return name + + def create(self, name: str = "auto", auto: bool = False) -> t.Optional[str]: + """ + Creates a migration. + """ + migrate = rollback = "" + if auto: + raise NotImplementedError + + logger.info('Creating migration "{}"'.format(name)) + name = self.compile(name, migrate, rollback) + logger.info('Migration has been created as "{}"'.format(name)) + return name + + def clear(self): + """Clear migrations.""" + self.model.delete().execute() + + def up(self, name: t.Optional[str] = None): + """ + Runs all unapplied migrations. + """ + logger.info("Starting migrations") + Console.info("Starting migrations") + + done = [] + diff = self.diff + if not diff: + logger.info("There is nothing to migrate") + Console.info("There is nothing to migrate") + return done + + migrator = self.migrator + for mname in diff: + done.append(self.up_one(mname, self.migrator)) + if name and name == mname: + break + + return done + + def read(self, name: str): + """ + Reads a migration from a file. + """ + call_params = dict() + if Helpers.is_os_windows() and sys.version_info >= (3, 0): + # if system is windows - force utf-8 encoding + call_params["encoding"] = "utf-8" + with open( + os.path.join(self.helper.migration_dir, name + ".py"), **call_params + ) as f: + code = f.read() + scope = {} + code = compile(code, "", "exec", dont_inherit=True) + exec(code, scope, None) + return scope.get("migrate", lambda m, d: None), scope.get( + "rollback", lambda m, d: None + ) + + def up_one( + self, name: str, migrator: Migrator, fake: bool = False, rollback: bool = False + ) -> str: + """ + Runs a migration with a given name. + """ + try: + migrate_fn, rollback_fn = self.read(name) + if fake: + migrate_fn(migrator, self.database) + migrator.clean() + return name + with self.database.transaction(): + if rollback: + logger.info('Rolling back "{}"'.format(name)) + rollback_fn(migrator, self.database) + migrator.run() + self.model.delete().where(self.model.name == name).execute() + else: + logger.info('Migrate "{}"'.format(name)) + migrate_fn(migrator, self.database) + migrator.run() + if name not in self.done: + self.model.create(name=name) + + logger.info('Done "{}"'.format(name)) + return name + + except Exception: + self.database.rollback() + operation_name = "Rollback" if rollback else "Migration" + logger.exception("{} failed: {}".format(operation_name, name)) + raise + + def down(self): + """ + Rolls back migrations. + """ + if not self.done: + raise RuntimeError("No migrations are found.") + + name = self.done[-1] + + migrator = self.migrator + self.up_one(name, migrator, False, True) + logger.warning("Rolled back migration: {}".format(name)) diff --git a/app/classes/shared/tasks.py b/app/classes/shared/tasks.py index 60272f14..a54b9359 100644 --- a/app/classes/shared/tasks.py +++ b/app/classes/shared/tasks.py @@ -101,6 +101,17 @@ class TasksManager: elif command == "restart_server": svr.restart_threaded_server(user_id) + elif command == "kill_server": + try: + svr.kill() + time.sleep(5) + svr.cleanup_server_object() + svr.record_server_stats() + except Exception as e: + logger.error( + f"Could not find PID for requested termsig. Full error: {e}" + ) + elif command == "backup_server": svr.backup_server() diff --git a/app/classes/web/base_api_handler.py b/app/classes/web/base_api_handler.py new file mode 100644 index 00000000..ce5bb889 --- /dev/null +++ b/app/classes/web/base_api_handler.py @@ -0,0 +1,7 @@ +from app.classes.web.base_handler import BaseHandler + + +class BaseApiHandler(BaseHandler): + def check_xsrf_cookie(self): + # Disable XSRF protection on API routes + pass diff --git a/app/classes/web/base_handler.py b/app/classes/web/base_handler.py index 499cbb87..a3372204 100644 --- a/app/classes/web/base_handler.py +++ b/app/classes/web/base_handler.py @@ -1,14 +1,39 @@ import logging -from typing import Union, List, Optional, Tuple, Dict, Any +import re +import typing as t +import orjson import bleach import tornado.web +from app.classes.models.crafty_permissions import Enum_Permissions_Crafty from app.classes.models.users import ApiKeys logger = logging.getLogger(__name__) +bearer_pattern = re.compile(r"^Bearer ", flags=re.IGNORECASE) + class BaseHandler(tornado.web.RequestHandler): + def set_default_headers(self) -> None: + """ + Fix CORS + """ + self.set_header("Access-Control-Allow-Origin", "*") + self.set_header( + "Access-Control-Allow-Headers", + "Content-Type, x-requested-with, Authorization", + ) + self.set_header( + "Access-Control-Allow-Methods", "POST, GET, PUT, DELETE, OPTIONS" + ) + + def options(self, *_, **__): + """ + Fix CORS + """ + # no body + self.set_status(204) + self.finish() nobleach = {bool, type(None)} redactables = ("pass", "api") @@ -30,12 +55,12 @@ class BaseHandler(tornado.web.RequestHandler): ) return remote_ip - current_user: Optional[Tuple[Optional[ApiKeys], Dict[str, Any], Dict[str, Any]]] + current_user: t.Tuple[t.Optional[ApiKeys], t.Dict[str, t.Any], t.Dict[str, t.Any]] def get_current_user( self, - ) -> Optional[Tuple[Optional[ApiKeys], Dict[str, Any], Dict[str, Any]]]: - return self.controller.authentication.check(self.get_cookie("token")) + ) -> t.Tuple[t.Optional[ApiKeys], t.Dict[str, t.Any], t.Dict[str, t.Any]]: + return self.controller.authentication.check_err(self.get_cookie("token")) def autobleach(self, name, text): for r in self.redactables: @@ -53,15 +78,15 @@ class BaseHandler(tornado.web.RequestHandler): def get_argument( self, name: str, - default: Union[ + default: t.Union[ None, str, tornado.web._ArgDefaultMarker ] = tornado.web._ARG_DEFAULT, strip: bool = True, - ) -> Optional[str]: + ) -> t.Optional[str]: arg = self._get_argument(name, default, self.request.arguments, strip) return self.autobleach(name, arg) - def get_arguments(self, name: str, strip: bool = True) -> List[str]: + def get_arguments(self, name: str, strip: bool = True) -> t.List[str]: if not isinstance(strip, bool): raise AssertionError args = self._get_arguments(name, self.request.arguments, strip) @@ -69,3 +94,117 @@ class BaseHandler(tornado.web.RequestHandler): for arg in args: args_ret += self.autobleach(name, arg) return args_ret + + def access_denied(self, user: t.Optional[str], reason: t.Optional[str]): + ip = self.get_remote_ip() + route = self.request.path + if user is not None: + user_data = f"User {user} from IP {ip}" + else: + user_data = f"An unknown user from IP {ip}" + if reason: + ending = f"to the API route {route} because {reason}" + else: + ending = f"to the API route {route}" + logger.info(f"{user_data} was denied access {ending}") + self.finish_json( + 403, + { + "status": "error", + "error": "ACCESS_DENIED", + "info": "You were denied access to the requested resource", + }, + ) + + def _auth_get_api_token(self) -> t.Optional[str]: + logger.debug("Searching for specified token") + api_token = self.get_argument("token", None) + if api_token is None and self.request.headers.get("Authorization"): + api_token = bearer_pattern.sub( + "", self.request.headers.get("Authorization") + ) + elif api_token is None: + api_token = self.get_cookie("token") + return api_token + + def authenticate_user( + self, + ) -> t.Optional[ + t.Tuple[ + t.List, + t.List[Enum_Permissions_Crafty], + t.List[str], + bool, + t.Dict[str, t.Any], + ] + ]: + try: + api_key, _token_data, user = self.controller.authentication.check_err( + self._auth_get_api_token() + ) + + superuser = user["superuser"] + if api_key is not None: + superuser = superuser and api_key.superuser + + exec_user_role = set() + if superuser: + authorized_servers = self.controller.list_defined_servers() + exec_user_role.add("Super User") + exec_user_crafty_permissions = ( + self.controller.crafty_perms.list_defined_crafty_permissions() + ) + + else: + if api_key is not None: + exec_user_crafty_permissions = ( + self.controller.crafty_perms.get_api_key_permissions_list( + api_key + ) + ) + else: + exec_user_crafty_permissions = ( + self.controller.crafty_perms.get_crafty_permissions_list( + user["user_id"] + ) + ) + logger.debug(user["roles"]) + for r in user["roles"]: + role = self.controller.roles.get_role(r) + exec_user_role.add(role["role_name"]) + authorized_servers = self.controller.servers.get_authorized_servers( + user["user_id"] # TODO: API key authorized servers? + ) + + logger.debug("Checking results") + if user: + return ( + authorized_servers, + exec_user_crafty_permissions, + exec_user_role, + superuser, + user, + ) + else: + logging.debug("Auth unsuccessful") + self.access_denied(None, "the user provided an invalid token") + return None + except Exception as auth_exception: + logger.debug( + "An error occured while authenticating an API user:", + exc_info=auth_exception, + ) + self.finish_json( + 403, + { + "status": "error", + "error": "ACCESS_DENIED", + "info": "An error occured while authenticating the user", + }, + ) + return None + + def finish_json(self, status: int, data: t.Dict[str, t.Any]): + self.set_status(status) + self.set_header("Content-Type", "application/json") + self.finish(orjson.dumps(data)) # pylint: disable=no-member diff --git a/app/classes/web/panel_handler.py b/app/classes/web/panel_handler.py index 8e877652..b0b9530a 100644 --- a/app/classes/web/panel_handler.py +++ b/app/classes/web/panel_handler.py @@ -300,6 +300,8 @@ class PanelHandler(BaseHandler): else None, "superuser": superuser, } + + # http://en.gravatar.com/site/implement/images/#rating if self.helper.get_setting("allow_nsfw_profile_pictures"): rating = "x" else: diff --git a/app/classes/web/routes/api/api_handlers.py b/app/classes/web/routes/api/api_handlers.py new file mode 100644 index 00000000..bd2e72d0 --- /dev/null +++ b/app/classes/web/routes/api/api_handlers.py @@ -0,0 +1,55 @@ +from app.classes.web.routes.api.auth.invalidate_tokens import ( + ApiAuthInvalidateTokensHandler, +) +from app.classes.web.routes.api.auth.login import ApiAuthLoginHandler +from app.classes.web.routes.api.servers.index import ApiServersIndexHandler +from app.classes.web.routes.api.servers.server.action import ( + ApiServersServerActionHandler, +) +from app.classes.web.routes.api.servers.server.index import ApiServersServerIndexHandler +from app.classes.web.routes.api.servers.server.logs import ApiServersServerLogsHandler +from app.classes.web.routes.api.servers.server.public import ( + ApiServersServerPublicHandler, +) +from app.classes.web.routes.api.servers.server.stats import ApiServersServerStatsHandler +from app.classes.web.routes.api.servers.server.users import ApiServersServerUsersHandler +from app.classes.web.routes.api.users.index import ApiUsersIndexHandler +from app.classes.web.routes.api.users.user.index import ApiUsersUserIndexHandler +from app.classes.web.routes.api.users.user.pfp import ApiUsersUserPfpHandler +from app.classes.web.routes.api.users.user.public import ApiUsersUserPublicHandler + + +def api_handlers(handler_args): + return [ + # Auth routes + (r"/api/v2/auth/login", ApiAuthLoginHandler, handler_args), + ( + r"/api/v2/auth/invalidate_tokens", + ApiAuthInvalidateTokensHandler, + handler_args, + ), + # User routes + (r"/api/v2/users", ApiUsersIndexHandler, handler_args), + (r"/api/v2/users/([a-z0-9_]+)", ApiUsersUserIndexHandler, handler_args), + (r"/api/v2/users/(@me)", ApiUsersUserIndexHandler, handler_args), + (r"/api/v2/users/([a-z0-9_]+)/pfp", ApiUsersUserPfpHandler, handler_args), + (r"/api/v2/users/(@me)/pfp", ApiUsersUserPfpHandler, handler_args), + (r"/api/v2/users/([a-z0-9_]+)/public", ApiUsersUserPublicHandler, handler_args), + (r"/api/v2/users/(@me)/public", ApiUsersUserPublicHandler, handler_args), + # Server routes + (r"/api/v2/servers", ApiServersIndexHandler, handler_args), + (r"/api/v2/servers/([0-9]+)", ApiServersServerIndexHandler, handler_args), + (r"/api/v2/servers/([0-9]+)/stats", ApiServersServerStatsHandler, handler_args), + ( + r"/api/v2/servers/([0-9]+)/action/([a-z_]+)", + ApiServersServerActionHandler, + handler_args, + ), + (r"/api/v2/servers/([0-9]+)/logs", ApiServersServerLogsHandler, handler_args), + (r"/api/v2/servers/([0-9]+)/users", ApiServersServerUsersHandler, handler_args), + ( + r"/api/v2/servers/([0-9]+)/public", + ApiServersServerPublicHandler, + handler_args, + ), + ] diff --git a/app/classes/web/routes/api/auth/invalidate_tokens.py b/app/classes/web/routes/api/auth/invalidate_tokens.py new file mode 100644 index 00000000..6308afcd --- /dev/null +++ b/app/classes/web/routes/api/auth/invalidate_tokens.py @@ -0,0 +1,21 @@ +import datetime +import logging +from app.classes.shared.console import Console +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + + +class ApiAuthInvalidateTokensHandler(BaseApiHandler): + def post(self): + auth_data = self.authenticate_user() + if not auth_data: + return + + # TODO: Invalidate tokens + Console.info("invalidate_tokens") + self.controller.users.raw_update_user( + auth_data[4]["user_id"], {"valid_tokens_from": datetime.datetime.now()} + ) + + self.finish_json(200, {"status": "ok"}) diff --git a/app/classes/web/routes/api/auth/login.py b/app/classes/web/routes/api/auth/login.py new file mode 100644 index 00000000..ab209c0b --- /dev/null +++ b/app/classes/web/routes/api/auth/login.py @@ -0,0 +1,101 @@ +import logging +import json +from jsonschema import validate +from jsonschema.exceptions import ValidationError +from app.classes.models.users import Users +from app.classes.shared.authentication import Authentication +from app.classes.shared.helpers import Helpers +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + +login_schema = { + "type": "object", + "properties": { + "username": { + "type": "string", + "maxLength": 20, + "minLength": 4, + "pattern": "^[a-z0-9_]+$", + }, + "password": {"type": "string", "maxLength": 20, "minLength": 4}, + }, + "required": ["username", "password"], + "additionalProperties": False, +} + + +class ApiAuthLoginHandler(BaseApiHandler): + def post(self): + + try: + data = json.loads(self.request.body) + except json.decoder.JSONDecodeError as e: + return self.finish_json( + 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)} + ) + + try: + validate(data, login_schema) + except ValidationError as e: + return self.finish_json( + 400, + { + "status": "error", + "error": "INVALID_JSON_SCHEMA", + "error_data": str(e), + }, + ) + + username = data["username"] + password = data["password"] + + # pylint: disable=no-member + user_data = self.controller.users.get_or_none(Users.username == username) + + if user_data is None: + return self.finish_json( + 401, + {"status": "error", "error": "INCORRECT_CREDENTIALS", "token": None}, + ) + + if not user_data.enabled: + self.finish_json( + 403, {"status": "error", "error": "ACCOUNT_DISABLED", "token": None} + ) + return + + login_result = self.helper.verify_pass(password, user_data.password) + + # Valid Login + if login_result: + logger.info(f"User: {user_data} Logged in from IP: {self.get_remote_ip()}") + + # record this login + q = Users.select().where(Users.username == username.lower()).get() + q.last_ip = self.get_remote_ip() + q.last_login = Helpers.get_time_as_string() + q.save() + + # log this login + self.controller.management.add_to_audit_log( + user_data.user_id, "Logged in", 0, self.get_remote_ip() + ) + + self.finish_json( + 200, + { + "status": "ok", + "token": Authentication.generate(user_data.user_id), + "user_id": user_data.user_id, + }, + ) + else: + # log this failed login attempt + self.controller.management.add_to_audit_log( + user_data.user_id, "Tried to log in", 0, self.get_remote_ip() + ) + self.finish_json( + 401, + {"status": "error", "error": "INCORRECT_CREDENTIALS", "token": None}, + ) diff --git a/app/classes/web/routes/api/auth/register.py b/app/classes/web/routes/api/auth/register.py new file mode 100644 index 00000000..31de5f5f --- /dev/null +++ b/app/classes/web/routes/api/auth/register.py @@ -0,0 +1,2 @@ +# nothing here yet +# sometime implement configurable self service account creation? diff --git a/app/classes/web/routes/api/roles/index.py b/app/classes/web/routes/api/roles/index.py new file mode 100644 index 00000000..e69de29b diff --git a/app/classes/web/routes/api/roles/role/index.py b/app/classes/web/routes/api/roles/role/index.py new file mode 100644 index 00000000..e69de29b diff --git a/app/classes/web/routes/api/roles/role/users.py b/app/classes/web/routes/api/roles/role/users.py new file mode 100644 index 00000000..e69de29b diff --git a/app/classes/web/routes/api/servers/index.py b/app/classes/web/routes/api/servers/index.py new file mode 100644 index 00000000..ece6b9e4 --- /dev/null +++ b/app/classes/web/routes/api/servers/index.py @@ -0,0 +1,20 @@ +import logging +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + + +class ApiServersIndexHandler(BaseApiHandler): + def get(self): + auth_data = self.authenticate_user() + if not auth_data: + return + + # TODO: limit some columns for specific permissions + + self.finish_json(200, {"status": "ok", "data": auth_data[0]}) + + def post(self): + # TODO: create server + self.set_status(404) + self.finish() diff --git a/app/classes/web/routes/api/servers/server/action.py b/app/classes/web/routes/api/servers/server/action.py new file mode 100644 index 00000000..1f36a848 --- /dev/null +++ b/app/classes/web/routes/api/servers/server/action.py @@ -0,0 +1,91 @@ +import logging +import os +from app.classes.models.server_permissions import Enum_Permissions_Server +from app.classes.models.servers import Servers +from app.classes.shared.file_helpers import FileHelpers +from app.classes.shared.helpers import Helpers +from app.classes.web.base_api_handler import BaseApiHandler + + +logger = logging.getLogger(__name__) + + +class ApiServersServerActionHandler(BaseApiHandler): + def post(self, server_id: str, action: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if ( + Enum_Permissions_Server.Commands + not in self.controller.server_perms.get_user_id_permissions_list( + auth_data[4]["user_id"], server_id + ) + ): + # if the user doesn't have Commands permission, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if action == "clone_server": + return self._clone_server(server_id) + + self.controller.management.send_command( + auth_data[4]["user_id"], server_id, self.get_remote_ip(), action + ) + + self.finish_json( + 200, + {"status": "ok"}, + ) + + def _clone_server(self, server_id): + def is_name_used(name): + return Servers.select().where(Servers.server_name == name).count() != 0 + + server_data = self.controller.servers.get_server_data_by_id(server_id) + server_uuid = server_data.get("server_uuid") + new_server_name = server_data.get("server_name") + " (Copy)" + + name_counter = 1 + while is_name_used(new_server_name): + name_counter += 1 + new_server_name = server_data.get("server_name") + f" (Copy {name_counter})" + + new_server_uuid = Helpers.create_uuid() + while os.path.exists(os.path.join(self.helper.servers_dir, new_server_uuid)): + new_server_uuid = Helpers.create_uuid() + new_server_path = os.path.join(self.helper.servers_dir, new_server_uuid) + + # copy the old server + FileHelpers.copy_dir(server_data.get("path"), new_server_path) + + # TODO get old server DB data to individual variables + new_server_command = str(server_data.get("execution_command")).replace( + server_uuid, new_server_uuid + ) + new_server_log_file = str( + self.helper.get_os_understandable_path(server_data.get("log_path")) + ).replace(server_uuid, new_server_uuid) + + self.controller.servers.create_server( + new_server_name, + new_server_uuid, + new_server_path, + "", + new_server_command, + server_data.get("executable"), + new_server_log_file, + server_data.get("stop_command"), + server_data.get("type"), + server_data.get("server_port"), + ) + + self.controller.init_all_servers() + + self.finish_json( + 200, + {"status": "ok"}, + ) diff --git a/app/classes/web/routes/api/servers/server/index.py b/app/classes/web/routes/api/servers/server/index.py new file mode 100644 index 00000000..b8473a81 --- /dev/null +++ b/app/classes/web/routes/api/servers/server/index.py @@ -0,0 +1,163 @@ +import logging +import json +from jsonschema import validate +from jsonschema.exceptions import ValidationError +from playhouse.shortcuts import model_to_dict +from app.classes.models.server_permissions import Enum_Permissions_Server +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + +server_patch_schema = { + "type": "object", + "properties": { + "server_name": {"type": "string"}, + "path": {"type": "string"}, + "backup_path": {"type": "string"}, + "executable": {"type": "string"}, + "log_path": {"type": "string"}, + "execution_command": {"type": "string"}, + "auto_start": {"type": "boolean"}, + "auto_start_delay": {"type": "integer"}, + "crash_detection": {"type": "boolean"}, + "stop_command": {"type": "string"}, + "executable_update_url": {"type": "string"}, + "server_ip": {"type": "string"}, + "server_port": {"type": "integer"}, + "logs_delete_after": {"type": "integer"}, + "type": {"type": "string"}, + }, + "anyOf": [ + # Require at least one property + {"required": [name]} + for name in [ + "server_name", + "path", + "backup_path", + "executable", + "log_path", + "execution_command", + "auto_start", + "auto_start_delay", + "crash_detection", + "stop_command", + "executable_update_url", + "server_ip", + "server_port", + "logs_delete_after", + "type", + ] + ], + "additionalProperties": False, +} + + +class ApiServersServerIndexHandler(BaseApiHandler): + def get(self, server_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + server_obj = self.controller.servers.get_server_obj(server_id) + server = model_to_dict(server_obj) + + # TODO: limit some columns for specific permissions? + + self.finish_json(200, {"status": "ok", "data": server}) + + def patch(self, server_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + try: + data = json.loads(self.request.body) + except json.decoder.JSONDecodeError as e: + return self.finish_json( + 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)} + ) + + try: + validate(data, server_patch_schema) + except ValidationError as e: + return self.finish_json( + 400, + { + "status": "error", + "error": "INVALID_JSON_SCHEMA", + "error_data": str(e), + }, + ) + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if ( + Enum_Permissions_Server.Config + not in self.controller.server_perms.get_user_id_permissions_list( + auth_data[4]["user_id"], server_id + ) + ): + # if the user doesn't have Config permission, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + server_obj = self.controller.servers.get_server_obj(server_id) + for key in data: + # If we don't validate the input there could be security issues + setattr(self, key, data[key]) + self.controller.servers.update_server(server_obj) + + return self.finish_json(200, {"status": "ok"}) + + def delete(self, server_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + # DELETE /api/v2/servers/server?files=true + remove_files = self.get_query_argument("files", False) + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if ( + Enum_Permissions_Server.Config + not in self.controller.server_perms.get_user_id_permissions_list( + auth_data[4]["user_id"], server_id + ) + ): + # if the user doesn't have Config permission, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + logger.info( + ( + "Removing server and all associated files for server: " + if remove_files + else "Removing server from panel for server: " + ) + + self.controller.servers.get_server_friendly_name(server_id) + ) + + server_data = self.controller.get_server_data(server_id) + server_name = server_data["server_name"] + + self.controller.management.add_to_audit_log( + auth_data[4]["user_id"], + f"Deleted server {server_id} named {server_name}", + server_id, + self.get_remote_ip(), + ) + + self.tasks_manager.remove_all_server_tasks(server_id) + self.controller.remove_server(server_id, remove_files) + + self.finish_json( + 200, + {"status": "ok"}, + ) diff --git a/app/classes/web/routes/api/servers/server/logs.py b/app/classes/web/routes/api/servers/server/logs.py new file mode 100644 index 00000000..095992fc --- /dev/null +++ b/app/classes/web/routes/api/servers/server/logs.py @@ -0,0 +1,73 @@ +import html +import logging +import re +from app.classes.models.server_permissions import Enum_Permissions_Server +from app.classes.shared.server import ServerOutBuf +from app.classes.web.base_api_handler import BaseApiHandler + + +logger = logging.getLogger(__name__) + + +class ApiServersServerLogsHandler(BaseApiHandler): + def get(self, server_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + # GET /api/v2/servers/server/logs?file=true + read_log_file = self.get_query_argument("file", False) + # GET /api/v2/servers/server/logs?colors=true + colored_output = self.get_query_argument("colors", False) + # GET /api/v2/servers/server/logs?raw=false + disable_ansi_strip = self.get_query_argument("raw", False) + # GET /api/v2/servers/server/logs?html=false + use_html = self.get_query_argument("html", False) + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if ( + Enum_Permissions_Server.Logs + not in self.controller.server_perms.get_user_id_permissions_list( + auth_data[4]["user_id"], server_id + ) + ): + # if the user doesn't have Commands permission, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + server_data = self.controller.servers.get_server_data_by_id(server_id) + + if read_log_file: + log_lines = self.helper.get_setting("max_log_lines") + raw_lines = self.helper.tail_file( + self.helper.get_os_understandable_path(server_data["log_path"]), + log_lines, + ) + else: + raw_lines = ServerOutBuf.lines.get(server_id, []) + + lines = [] + + for line in raw_lines: + try: + if not disable_ansi_strip: + line = re.sub( + "(\033\\[(0;)?[0-9]*[A-z]?(;[0-9])?m?)|(> )", "", line + ) + line = re.sub("[A-z]{2}\b\b", "", line) + line = html.escape(line) + + if colored_output: + line = self.helper.log_colors(line) + + lines.append(line) + except Exception as e: + logger.warning(f"Skipping Log Line due to error: {e}") + + if use_html: + for line in lines: + self.write(f"{line}
") + else: + self.finish_json(200, {"status": "ok", "data": lines}) diff --git a/app/classes/web/routes/api/servers/server/public.py b/app/classes/web/routes/api/servers/server/public.py new file mode 100644 index 00000000..17f1d36c --- /dev/null +++ b/app/classes/web/routes/api/servers/server/public.py @@ -0,0 +1,23 @@ +import logging +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + + +class ApiServersServerPublicHandler(BaseApiHandler): + def get(self, server_id): + auth_data = self.authenticate_user() + if not auth_data: + return + server_obj = self.controller.servers.get_server_obj(server_id) + + self.finish_json( + 200, + { + "status": "ok", + "data": { + key: getattr(server_obj, key) + for key in ["server_id", "created", "server_name", "type"] + }, + }, + ) diff --git a/app/classes/web/routes/api/servers/server/stats.py b/app/classes/web/routes/api/servers/server/stats.py new file mode 100644 index 00000000..e2e7baad --- /dev/null +++ b/app/classes/web/routes/api/servers/server/stats.py @@ -0,0 +1,27 @@ +import logging +from playhouse.shortcuts import model_to_dict +from app.classes.web.base_api_handler import BaseApiHandler + + +logger = logging.getLogger(__name__) + + +class ApiServersServerStatsHandler(BaseApiHandler): + def get(self, server_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + self.finish_json( + 200, + { + "status": "ok", + "data": model_to_dict( + self.controller.servers.get_latest_server_stats(server_id) + ), + }, + ) diff --git a/app/classes/web/routes/api/servers/server/users.py b/app/classes/web/routes/api/servers/server/users.py new file mode 100644 index 00000000..d4656345 --- /dev/null +++ b/app/classes/web/routes/api/servers/server/users.py @@ -0,0 +1,31 @@ +import logging +from app.classes.models.crafty_permissions import Enum_Permissions_Crafty +from app.classes.web.base_api_handler import BaseApiHandler + + +logger = logging.getLogger(__name__) + + +class ApiServersServerUsersHandler(BaseApiHandler): + def get(self, server_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if Enum_Permissions_Crafty.User_Config not in auth_data[1]: + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if Enum_Permissions_Crafty.Roles_Config not in auth_data[1]: + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + self.finish_json( + 200, + { + "status": "ok", + "data": list(self.controller.servers.get_authorized_users(server_id)), + }, + ) diff --git a/app/classes/web/routes/api/users/index.py b/app/classes/web/routes/api/users/index.py new file mode 100644 index 00000000..5ef07672 --- /dev/null +++ b/app/classes/web/routes/api/users/index.py @@ -0,0 +1,166 @@ +import logging +import json +from jsonschema import validate +from jsonschema.exceptions import ValidationError +from app.classes.models.crafty_permissions import Enum_Permissions_Crafty +from app.classes.models.roles import Roles, helper_roles +from app.classes.models.users import PUBLIC_USER_ATTRS +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + + +class ApiUsersIndexHandler(BaseApiHandler): + def get(self): + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + exec_user_crafty_permissions, + _, + _, + user, + ) = auth_data + + # GET /api/v2/users?ids=true + get_only_ids = self.get_query_argument("ids", None) == "true" + + if Enum_Permissions_Crafty.User_Config in exec_user_crafty_permissions: + if get_only_ids: + data = [ + user.user_id + for user in self.controller.users.get_all_user_ids().execute() + ] + else: + data = [ + {key: getattr(user_res, key) for key in PUBLIC_USER_ATTRS} + for user_res in self.controller.users.get_all_users().execute() + ] + else: + if get_only_ids: + data = [user["user_id"]] + else: + user_res = self.controller.users.get_user_by_id(user["user_id"]) + user_res["roles"] = list( + map(helper_roles.get_role, user_res.get("roles", set())) + ) + data = [{key: user_res[key] for key in PUBLIC_USER_ATTRS}] + + self.finish_json( + 200, + { + "status": "ok", + "data": data, + }, + ) + + def post(self): + new_user_schema = { + "type": "object", + "properties": { + **self.controller.users.user_jsonschema_props, + }, + "required": ["username", "password"], + "additionalProperties": False, + } + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + exec_user_crafty_permissions, + _, + superuser, + user, + ) = auth_data + + if Enum_Permissions_Crafty.User_Config not in exec_user_crafty_permissions: + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + try: + data = json.loads(self.request.body) + except json.decoder.JSONDecodeError as e: + return self.finish_json( + 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)} + ) + + try: + validate(data, new_user_schema) + except ValidationError as e: + return self.finish_json( + 400, + { + "status": "error", + "error": "INVALID_JSON_SCHEMA", + "error_data": str(e), + }, + ) + + username = data["username"] + password = data["password"] + email = data.get("email", "default@example.com") + enabled = data.get("enabled", True) + lang = data.get("lang", self.helper.get_setting("language")) + superuser = data.get("superuser", False) + permissions = data.get("permissions", None) + roles = data.get("roles", None) + + if username.lower() in ["system", ""]: + return self.finish_json( + 400, {"status": "error", "error": "INVALID_USERNAME"} + ) + + if self.controller.users.get_id_by_name(username) is not None: + return self.finish_json(400, {"status": "error", "error": "USER_EXISTS"}) + + if roles is None: + roles = [] + else: + role_ids = [str(role_id) for role_id in Roles.select(Roles.role_id)] + roles = {role for role in roles if str(role) in role_ids} + + permissions_mask = "0" * len(Enum_Permissions_Crafty.__members__.items()) + server_quantity = { + perm.name: 0 + for perm in self.controller.crafty_perms.list_defined_crafty_permissions() + } + + if permissions is not None: + server_quantity = {} + permissions_mask = list(permissions_mask) + for permission in permissions: + server_quantity[permission["name"]] = permission["quantity"] + permissions_mask[Enum_Permissions_Crafty[permission["name"]].value] = ( + "1" if permission["enabled"] else "0" + ) + permissions_mask = "".join(permissions_mask) + + user_id = self.controller.users.add_user( + username, + password, + email, + enabled, + superuser, + ) + self.controller.users.update_user( + user_id, + {"roles": roles, "lang": lang, "hints": True}, + { + "permissions_mask": permissions_mask, + "server_quantity": server_quantity, + }, + ) + + self.controller.management.add_to_audit_log( + user["user_id"], + f"Added user {username} (UID:{user_id})", + server_id=0, + source_ip=self.get_remote_ip(), + ) + self.controller.management.add_to_audit_log( + user["user_id"], + f"Edited user {username} (UID:{user_id}) with roles {roles}", + server_id=0, + source_ip=self.get_remote_ip(), + ) diff --git a/app/classes/web/routes/api/users/user/index.py b/app/classes/web/routes/api/users/user/index.py new file mode 100644 index 00000000..00823fe9 --- /dev/null +++ b/app/classes/web/routes/api/users/user/index.py @@ -0,0 +1,211 @@ +import json +import logging + +from jsonschema import ValidationError, validate +from app.classes.models.crafty_permissions import Enum_Permissions_Crafty +from app.classes.models.roles import helper_roles +from app.classes.web.base_api_handler import BaseApiHandler + + +logger = logging.getLogger(__name__) + + +class ApiUsersUserIndexHandler(BaseApiHandler): + def get(self, user_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + exec_user_crafty_permissions, + _, + _, + user, + ) = auth_data + + if user_id in ["@me", user["user_id"]]: + user_id = user["user_id"] + res_user = user + elif Enum_Permissions_Crafty.User_Config not in exec_user_crafty_permissions: + return self.finish_json( + 400, + { + "status": "error", + "error": "NOT_AUTHORIZED", + }, + ) + else: + # has User_Config permission and isn't viewing self + res_user = self.controller.users.get_user(user_id) + + # Remove password and valid_tokens_from from the response + # as those should never be sent out to the client. + res_user.pop("password", None) + res_user.pop("valid_tokens_from", None) + res_user["roles"] = list( + map(helper_roles.get_role, res_user.get("roles", set())) + ) + + self.finish_json( + 200, + {"status": "ok", "data": res_user}, + ) + + def delete(self, user_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + exec_user_crafty_permissions, + _, + _, + user, + ) = auth_data + + if (user_id in ["@me", user["user_id"]]) and self.helper.get_setting( + "allow_self_delete", False + ): + self.controller.users.remove_user(user["user_id"]) + elif Enum_Permissions_Crafty.User_Config not in exec_user_crafty_permissions: + return self.finish_json( + 400, + { + "status": "error", + "error": "NOT_AUTHORIZED", + }, + ) + else: + # has User_Config permission + self.controller.users.remove_user(user_id) + + self.finish_json( + 200, + {"status": "ok"}, + ) + + def patch(self, user_id: str): + user_patch_schema = { + "type": "object", + "properties": { + **self.controller.users.user_jsonschema_props, + }, + "anyOf": [ + # Require at least one property + {"required": [name]} + for name in [ + "username", + "password", + "email", + "enabled", + "lang", + "superuser", + "permissions", + "roles", + "hints", + ] + ], + "additionalProperties": False, + } + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + exec_user_crafty_permissions, + _, + superuser, + user, + ) = auth_data + + try: + data = json.loads(self.request.body) + except json.decoder.JSONDecodeError as e: + return self.finish_json( + 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)} + ) + + try: + validate(data, user_patch_schema) + except ValidationError as e: + return self.finish_json( + 400, + { + "status": "error", + "error": "INVALID_JSON_SCHEMA", + "error_data": str(e), + }, + ) + + if user_id == "@me": + user_id = user["user_id"] + + if ( + Enum_Permissions_Crafty.User_Config not in exec_user_crafty_permissions + and str(user["user_id"]) != str(user_id) + ): + # If doesn't have perm can't edit other users + return self.finish_json( + 400, + { + "status": "error", + "error": "NOT_AUTHORIZED", + }, + ) + + if data.get("username", None) is not None: + if data["username"].lower() in ["system", ""]: + return self.finish_json( + 400, {"status": "error", "error": "INVALID_USERNAME"} + ) + if self.controller.users.get_id_by_name(data["username"]) is not None: + return self.finish_json( + 400, {"status": "error", "error": "USER_EXISTS"} + ) + + if data.get("superuser", None) is not None: + if str(user["user_id"]) == str(user_id): + # Checks if user is trying to change super user status of self. + # We don't want that. + return self.finish_json( + 400, {"status": "error", "error": "INVALID_SUPERUSER_MODIFY"} + ) + if not superuser: + # The user is not superuser so they can't change the superuser status + data.pop("superuser") + + if data.get("permissions", None) is not None: + if str(user["user_id"]) == str(user_id): + # Checks if user is trying to change permissions of self. + # We don't want that. + return self.finish_json( + 400, {"status": "error", "error": "INVALID_PERMISSIONS_MODIFY"} + ) + if Enum_Permissions_Crafty.User_Config not in exec_user_crafty_permissions: + # Checks if user is trying to change permissions of someone + # else without User Config permission. We don't want that. + return self.finish_json( + 400, {"status": "error", "error": "INVALID_PERMISSIONS_MODIFY"} + ) + + if data.get("roles", None) is not None: + if str(user["user_id"]) == str(user_id): + # Checks if user is trying to change roles of self. + # We don't want that. + return self.finish_json( + 400, {"status": "error", "error": "INVALID_ROLES_MODIFY"} + ) + if Enum_Permissions_Crafty.User_Config not in exec_user_crafty_permissions: + # Checks if user is trying to change roles of someone + # else without User Config permission. We don't want that. + return self.finish_json( + 400, {"status": "error", "error": "INVALID_ROLES_MODIFY"} + ) + + server_obj = self.controller.servers.get_server_obj(user_id) + for key in data: + # If we don't validate the input there could be security issues + setattr(self, key, data[key]) + self.controller.servers.update_server(server_obj) + + return self.finish_json(200, {"status": "ok"}) diff --git a/app/classes/web/routes/api/users/user/pfp.py b/app/classes/web/routes/api/users/user/pfp.py new file mode 100644 index 00000000..d1759085 --- /dev/null +++ b/app/classes/web/routes/api/users/user/pfp.py @@ -0,0 +1,49 @@ +import logging +import libgravatar +import requests +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + + +class ApiUsersUserPfpHandler(BaseApiHandler): + def get(self, user_id): + auth_data = self.authenticate_user() + if not auth_data: + return + + if user_id == "@me": + user = auth_data[4] + else: + user = self.controller.users.get_user(user_id) + + logger.debug( + f'User {auth_data[4]["user_id"]} is fetching the pfp for user {user_id}' + ) + + # http://en.gravatar.com/site/implement/images/#rating + if self.helper.get_setting("allow_nsfw_profile_pictures"): + rating = "x" + else: + rating = "g" + + # Get grvatar hash for profile pictures + if user["email"] != "default@example.com" or "": + g = libgravatar.Gravatar(libgravatar.sanitize_email(user["email"])) + url = g.get_image( + size=80, + default="404", + force_default=False, + rating=rating, + filetype_extension=False, + use_ssl=True, + ) + try: + requests.head(url).raise_for_status() + except requests.HTTPError as e: + logger.debug("Gravatar profile picture not found", exc_info=e) + else: + self.finish_json(200, {"status": "ok", "data": url}) + return + + self.finish_json(200, {"status": "ok", "data": None}) diff --git a/app/classes/web/routes/api/users/user/public.py b/app/classes/web/routes/api/users/user/public.py new file mode 100644 index 00000000..d44b3a19 --- /dev/null +++ b/app/classes/web/routes/api/users/user/public.py @@ -0,0 +1,35 @@ +import logging +from app.classes.models.roles import helper_roles +from app.classes.models.users import PUBLIC_USER_ATTRS +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + + +class ApiUsersUserPublicHandler(BaseApiHandler): + def get(self, user_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + _, + _, + _, + user, + ) = auth_data + + if user_id == "@me": + user_id = user["user_id"] + res_user = user + + res_user = {key: getattr(res_user, key) for key in PUBLIC_USER_ATTRS} + + res_user["roles"] = list( + map(helper_roles.get_role, res_user.get("roles", set())) + ) + + self.finish_json( + 200, + {"status": "ok", "data": res_user}, + ) diff --git a/app/classes/web/tornado_handler.py b/app/classes/web/tornado_handler.py index b0b1a3b0..8d629206 100644 --- a/app/classes/web/tornado_handler.py +++ b/app/classes/web/tornado_handler.py @@ -17,6 +17,7 @@ from app.classes.web.file_handler import FileHandler from app.classes.web.public_handler import PublicHandler from app.classes.web.panel_handler import PanelHandler from app.classes.web.default_handler import DefaultHandler +from app.classes.web.routes.api.api_handlers import api_handlers from app.classes.web.server_handler import ServerHandler from app.classes.web.ajax_handler import AjaxHandler from app.classes.web.api_handler import ( @@ -150,7 +151,7 @@ class Webserver: (r"/ws", SocketHandler, handler_args), (r"/upload", UploadHandler, handler_args), (r"/status", StatusHandler, handler_args), - # API Routes + # API Routes V1 (r"/api/v1/stats/servers", ServersStats, handler_args), (r"/api/v1/stats/node", NodeStats, handler_args), (r"/api/v1/server/send_command", SendCommand, handler_args), @@ -161,6 +162,8 @@ class Webserver: (r"/api/v1/list_servers", ListServers, handler_args), (r"/api/v1/users/create_user", CreateUser, handler_args), (r"/api/v1/users/delete_user", DeleteUser, handler_args), + # API Routes V2 + *api_handlers(handler_args), ] app = tornado.web.Application( @@ -194,7 +197,7 @@ class Webserver: static_path=os.path.join(self.helper.webroot, "static"), debug=debug_errors, cookie_secret=cookie_secret, - xsrf_cookies=True, + xsrf_cookies=False, autoreload=False, log_function=self.log_function, default_handler_class=HTTPHandler, diff --git a/app/config/config.json b/app/config/config.json index 20921ee7..ea2e1f0a 100644 --- a/app/config/config.json +++ b/app/config/config.json @@ -1,26 +1,27 @@ { - "http_port": 8000, - "https_port": 8443, - "language": "en_EN", - "cookie_expire": 30, - "cookie_secret": "random", - "apikey_secret": "random", - "show_errors": true, - "history_max_age": 7, - "stats_update_frequency": 30, - "delete_default_json": false, - "show_contribute_link": true, - "virtual_terminal_lines": 70, - "max_log_lines": 700, - "max_audit_entries": 300, - "disabled_language_files": [ - "lol_EN.json", - "" - ], - "stream_size_GB": 1, - "keywords": [ - "help", - "chunk" - ], - "allow_nsfw_profile_pictures": false -} \ No newline at end of file + "http_port": 8000, + "https_port": 8443, + "language": "en_EN", + "cookie_expire": 30, + "cookie_secret": "random", + "apikey_secret": "random", + "show_errors": true, + "history_max_age": 7, + "stats_update_frequency": 30, + "delete_default_json": false, + "show_contribute_link": true, + "virtual_terminal_lines": 70, + "max_log_lines": 700, + "max_audit_entries": 300, + "disabled_language_files": [ + "lol_EN.json", + "" + ], + "stream_size_GB": 1, + "keywords": [ + "help", + "chunk" + ], + "allow_nsfw_profile_pictures": false, + "enable_user_self_delete": false +} diff --git a/app/frontend/templates/panel/dashboard.html b/app/frontend/templates/panel/dashboard.html index 0d091aef..f076210b 100644 --- a/app/frontend/templates/panel/dashboard.html +++ b/app/frontend/templates/panel/dashboard.html @@ -702,4 +702,4 @@ -{% end %} \ No newline at end of file +{% end %} diff --git a/requirements.txt b/requirements.txt index 26ecc613..30149238 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,5 @@ requests==2.26 termcolor==1.1 tornado==6.0 tzlocal==4.0 +jsonschema==4.4.0 +orjson==3.6.7 From 20d32c04cec485252f66471871d694497d2a92d1 Mon Sep 17 00:00:00 2001 From: luukas Date: Thu, 14 Apr 2022 18:48:46 +0300 Subject: [PATCH 2/2] Fix login and token stuff --- app/classes/web/base_handler.py | 30 +++++++++++++++++++++--- app/classes/web/routes/api/auth/login.py | 7 +++--- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/app/classes/web/base_handler.py b/app/classes/web/base_handler.py index a3372204..dfe30b67 100644 --- a/app/classes/web/base_handler.py +++ b/app/classes/web/base_handler.py @@ -56,11 +56,25 @@ class BaseHandler(tornado.web.RequestHandler): return remote_ip current_user: t.Tuple[t.Optional[ApiKeys], t.Dict[str, t.Any], t.Dict[str, t.Any]] + """ + A variable that contains the current user's data. Please see + Please only use this with routes using the `@tornado.web.authenticated` decorator. + """ def get_current_user( self, - ) -> t.Tuple[t.Optional[ApiKeys], t.Dict[str, t.Any], t.Dict[str, t.Any]]: - return self.controller.authentication.check_err(self.get_cookie("token")) + ) -> t.Optional[ + t.Tuple[t.Optional[ApiKeys], t.Dict[str, t.Any], t.Dict[str, t.Any]] + ]: + """ + Get the token's API key, the token's payload and user data. + + Returns: + t.Optional[ApiKeys]: The API key of the token. + t.Dict[str, t.Any]: The token's payload. + t.Dict[str, t.Any]: The user's data from the database. + """ + return self.controller.authentication.check(self.get_cookie("token")) def autobleach(self, name, text): for r in self.redactables: @@ -117,8 +131,18 @@ class BaseHandler(tornado.web.RequestHandler): ) def _auth_get_api_token(self) -> t.Optional[str]: + """Get an API token from the request + + The API token is searched in the following order: + 1. The `token` query parameter + 2. The `Authorization` header + 3. The `token` cookie + + Returns: + t.Optional[str]: The API token or None if no token was found. + """ logger.debug("Searching for specified token") - api_token = self.get_argument("token", None) + api_token = self.get_query_argument("token", None) if api_token is None and self.request.headers.get("Authorization"): api_token = bearer_pattern.sub( "", self.request.headers.get("Authorization") diff --git a/app/classes/web/routes/api/auth/login.py b/app/classes/web/routes/api/auth/login.py index ab209c0b..e494be1f 100644 --- a/app/classes/web/routes/api/auth/login.py +++ b/app/classes/web/routes/api/auth/login.py @@ -3,7 +3,6 @@ import json from jsonschema import validate from jsonschema.exceptions import ValidationError from app.classes.models.users import Users -from app.classes.shared.authentication import Authentication from app.classes.shared.helpers import Helpers from app.classes.web.base_api_handler import BaseApiHandler @@ -51,7 +50,7 @@ class ApiAuthLoginHandler(BaseApiHandler): password = data["password"] # pylint: disable=no-member - user_data = self.controller.users.get_or_none(Users.username == username) + user_data = Users.get_or_none(Users.username == username) if user_data is None: return self.finish_json( @@ -79,14 +78,14 @@ class ApiAuthLoginHandler(BaseApiHandler): # log this login self.controller.management.add_to_audit_log( - user_data.user_id, "Logged in", 0, self.get_remote_ip() + user_data.user_id, "Logged in via the API", 0, self.get_remote_ip() ) self.finish_json( 200, { "status": "ok", - "token": Authentication.generate(user_data.user_id), + "token": self.controller.authentication.generate(user_data.user_id), "user_id": user_data.user_id, }, )