mirror of
https://gitlab.com/crafty-controller/crafty-4.git
synced 2024-08-30 18:23:09 +00:00
Merge branch feature/external-frontend to feature/api-v2 without the frontend
This commit is contained in:
parent
3e5c8c9205
commit
1aa0d65cf7
6
.gitignore
vendored
6
.gitignore
vendored
@ -18,8 +18,10 @@ env.bak/
|
|||||||
venv.bak/
|
venv.bak/
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
servers/
|
/servers/
|
||||||
backups/
|
/backups/
|
||||||
|
/docker/servers/
|
||||||
|
/docker/backups/
|
||||||
session.lock
|
session.lock
|
||||||
.header
|
.header
|
||||||
default.json
|
default.json
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import typing as t
|
||||||
|
|
||||||
from app.classes.controllers.roles_controller import Roles_Controller
|
from app.classes.controllers.roles_controller import Roles_Controller
|
||||||
from app.classes.models.servers import helper_servers
|
from app.classes.models.servers import helper_servers
|
||||||
@ -91,7 +92,7 @@ class Servers_Controller:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_authorized_servers(user_id):
|
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)
|
user_roles = helper_users.user_role_query(user_id)
|
||||||
for us in user_roles:
|
for us in user_roles:
|
||||||
role_servers = Permissions_Servers.get_role_servers_from_role_id(us.role_id)
|
role_servers = Permissions_Servers.get_role_servers_from_role_id(us.role_id)
|
||||||
@ -100,6 +101,20 @@ class Servers_Controller:
|
|||||||
|
|
||||||
return server_data
|
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
|
@staticmethod
|
||||||
def get_all_servers_stats():
|
def get_all_servers_stats():
|
||||||
return helper_servers.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):
|
def get_authorized_servers_stats_api_key(api_key: ApiKeys):
|
||||||
server_data = []
|
server_data = []
|
||||||
authorized_servers = Servers_Controller.get_authorized_servers(
|
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:
|
for s in authorized_servers:
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import typing
|
||||||
|
|
||||||
from app.classes.models.users import helper_users
|
from app.classes.models.users import helper_users
|
||||||
from app.classes.models.crafty_permissions import (
|
from app.classes.models.crafty_permissions import (
|
||||||
@ -16,6 +17,48 @@ class Users_Controller:
|
|||||||
self.users_helper = users_helper
|
self.users_helper = users_helper
|
||||||
self.authentication = authentication
|
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
|
# Users Methods
|
||||||
# **********************************************************************************
|
# **********************************************************************************
|
||||||
@ -23,6 +66,10 @@ class Users_Controller:
|
|||||||
def get_all_users():
|
def get_all_users():
|
||||||
return helper_users.get_all_users()
|
return helper_users.get_all_users()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_all_user_ids():
|
||||||
|
return helper_users.get_all_user_ids()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_id_by_name(username):
|
def get_id_by_name(username):
|
||||||
return helper_users.get_user_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)
|
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(
|
def add_user(
|
||||||
self,
|
self,
|
||||||
username,
|
username,
|
||||||
@ -159,7 +217,7 @@ class Users_Controller:
|
|||||||
return token_data["user_id"]
|
return token_data["user_id"]
|
||||||
|
|
||||||
def get_user_by_api_token(self, token: str):
|
def get_user_by_api_token(self, token: str):
|
||||||
_, _, user = self.authentication.check(token)
|
_, _, user = self.authentication.check_err(token)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def get_api_key_by_token(self, token: str):
|
def get_api_key_by_token(self, token: str):
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import typing
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from peewee import (
|
from peewee import (
|
||||||
ForeignKeyField,
|
ForeignKeyField,
|
||||||
@ -45,21 +46,24 @@ class Permissions_Crafty:
|
|||||||
# **********************************************************************************
|
# **********************************************************************************
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_permissions_list():
|
def get_permissions_list():
|
||||||
permissions_list = []
|
permissions_list: typing.List[Enum_Permissions_Crafty] = []
|
||||||
for member in Enum_Permissions_Crafty.__members__.items():
|
for member in Enum_Permissions_Crafty.__members__.items():
|
||||||
permissions_list.append(member[1])
|
permissions_list.append(member[1])
|
||||||
return permissions_list
|
return permissions_list
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_permissions(permissions_mask):
|
def get_permissions(permissions_mask):
|
||||||
permissions_list = []
|
permissions_list: typing.List[Enum_Permissions_Crafty] = []
|
||||||
for member in Enum_Permissions_Crafty.__members__.items():
|
for member in Enum_Permissions_Crafty.__members__.items():
|
||||||
if Permissions_Crafty.has_permission(permissions_mask, member[1]):
|
if Permissions_Crafty.has_permission(permissions_mask, member[1]):
|
||||||
permissions_list.append(member[1])
|
permissions_list.append(member[1])
|
||||||
return permissions_list
|
return permissions_list
|
||||||
|
|
||||||
@staticmethod
|
@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
|
result = False
|
||||||
if permission_mask[permission_tested.value] == "1":
|
if permission_mask[permission_tested.value] == "1":
|
||||||
result = True
|
result = True
|
||||||
|
@ -394,7 +394,7 @@ class helpers_management:
|
|||||||
return dir_list
|
return dir_list
|
||||||
|
|
||||||
def add_excluded_backup_dir(self, server_id: int, dir_to_add: str):
|
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:
|
if dir_to_add not in dir_list:
|
||||||
dir_list.append(dir_to_add)
|
dir_list.append(dir_to_add)
|
||||||
excluded_dirs = ",".join(dir_list)
|
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):
|
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:
|
if dir_to_del in dir_list:
|
||||||
dir_list.remove(dir_to_del)
|
dir_list.remove(dir_to_del)
|
||||||
excluded_dirs = ",".join(dir_list)
|
excluded_dirs = ",".join(dir_list)
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
import logging
|
import logging
|
||||||
|
import typing
|
||||||
from peewee import (
|
from peewee import (
|
||||||
ForeignKeyField,
|
ForeignKeyField,
|
||||||
CharField,
|
CharField,
|
||||||
@ -51,14 +52,14 @@ class Permissions_Servers:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_permissions_list():
|
def get_permissions_list():
|
||||||
permissions_list = []
|
permissions_list: typing.List[Enum_Permissions_Server] = []
|
||||||
for member in Enum_Permissions_Server.__members__.items():
|
for member in Enum_Permissions_Server.__members__.items():
|
||||||
permissions_list.append(member[1])
|
permissions_list.append(member[1])
|
||||||
return permissions_list
|
return permissions_list
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_permissions(permissions_mask):
|
def get_permissions(permissions_mask):
|
||||||
permissions_list = []
|
permissions_list: typing.List[Enum_Permissions_Server] = []
|
||||||
for member in Enum_Permissions_Server.__members__.items():
|
for member in Enum_Permissions_Server.__members__.items():
|
||||||
if Permissions_Servers.has_permission(permissions_mask, member[1]):
|
if Permissions_Servers.has_permission(permissions_mask, member[1]):
|
||||||
permissions_list.append(member[1])
|
permissions_list.append(member[1])
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import datetime
|
import datetime
|
||||||
from typing import Optional, Union
|
import typing as t
|
||||||
|
|
||||||
from peewee import (
|
from peewee import (
|
||||||
ForeignKeyField,
|
ForeignKeyField,
|
||||||
@ -45,6 +45,15 @@ class Users(BaseModel):
|
|||||||
table_name = "users"
|
table_name = "users"
|
||||||
|
|
||||||
|
|
||||||
|
PUBLIC_USER_ATTRS: t.Final = [
|
||||||
|
"user_id",
|
||||||
|
"created",
|
||||||
|
"username",
|
||||||
|
"enabled",
|
||||||
|
"superuser",
|
||||||
|
"lang", # maybe remove?
|
||||||
|
]
|
||||||
|
|
||||||
# **********************************************************************************
|
# **********************************************************************************
|
||||||
# API Keys Class
|
# API Keys Class
|
||||||
# **********************************************************************************
|
# **********************************************************************************
|
||||||
@ -90,6 +99,11 @@ class helper_users:
|
|||||||
query = Users.select().where(Users.username != "system")
|
query = Users.select().where(Users.username != "system")
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_all_user_ids():
|
||||||
|
query = Users.select(Users.user_id).where(Users.username != "system")
|
||||||
|
return query
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_user_lang_by_id(user_id):
|
def get_user_lang_by_id(user_id):
|
||||||
return Users.get(Users.user_id == user_id).lang
|
return Users.get(Users.user_id == user_id).lang
|
||||||
@ -153,7 +167,7 @@ class helper_users:
|
|||||||
self,
|
self,
|
||||||
username: str,
|
username: str,
|
||||||
password: str = None,
|
password: str = None,
|
||||||
email: Optional[str] = None,
|
email: t.Optional[str] = None,
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
superuser: bool = False,
|
superuser: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
@ -177,7 +191,7 @@ class helper_users:
|
|||||||
def add_rawpass_user(
|
def add_rawpass_user(
|
||||||
username: str,
|
username: str,
|
||||||
password: str = None,
|
password: str = None,
|
||||||
email: Optional[str] = None,
|
email: t.Optional[str] = None,
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
superuser: bool = False,
|
superuser: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
@ -212,7 +226,7 @@ class helper_users:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_super_user_list():
|
def get_super_user_list():
|
||||||
final_users = []
|
final_users: t.List[int] = []
|
||||||
super_users = Users.select().where(
|
super_users = Users.select().where(
|
||||||
Users.superuser == True # pylint: disable=singleton-comparison
|
Users.superuser == True # pylint: disable=singleton-comparison
|
||||||
)
|
)
|
||||||
@ -224,8 +238,7 @@ class helper_users:
|
|||||||
def remove_user(self, user_id):
|
def remove_user(self, user_id):
|
||||||
with self.database.atomic():
|
with self.database.atomic():
|
||||||
User_Roles.delete().where(User_Roles.user_id == user_id).execute()
|
User_Roles.delete().where(User_Roles.user_id == user_id).execute()
|
||||||
user = Users.get(Users.user_id == user_id)
|
return Users.delete().where(Users.user_id == user_id).execute()
|
||||||
return user.delete_instance()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def set_support_path(user_id, support_path):
|
def set_support_path(user_id, support_path):
|
||||||
@ -284,7 +297,7 @@ class helper_users:
|
|||||||
).execute()
|
).execute()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def add_user_roles(user: Union[dict, Users]):
|
def add_user_roles(user: t.Union[dict, Users]):
|
||||||
if isinstance(user, dict):
|
if isinstance(user, dict):
|
||||||
user_id = user["user_id"]
|
user_id = user["user_id"]
|
||||||
else:
|
else:
|
||||||
@ -329,6 +342,10 @@ class helper_users:
|
|||||||
def remove_roles_from_role_id(role_id):
|
def remove_roles_from_role_id(role_id):
|
||||||
User_Roles.delete().where(User_Roles.role_id == role_id).execute()
|
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
|
# ApiKeys Methods
|
||||||
# **********************************************************************************
|
# **********************************************************************************
|
||||||
@ -346,8 +363,8 @@ class helper_users:
|
|||||||
name: str,
|
name: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
superuser: bool = False,
|
superuser: bool = False,
|
||||||
server_permissions_mask: Optional[str] = None,
|
server_permissions_mask: t.Optional[str] = None,
|
||||||
crafty_permissions_mask: Optional[str] = None,
|
crafty_permissions_mask: t.Optional[str] = None,
|
||||||
):
|
):
|
||||||
return ApiKeys.insert(
|
return ApiKeys.insert(
|
||||||
{
|
{
|
||||||
|
@ -34,7 +34,7 @@ class Authentication:
|
|||||||
|
|
||||||
def check_no_iat(self, token) -> Optional[Dict[str, Any]]:
|
def check_no_iat(self, token) -> Optional[Dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
return jwt.decode(token, self.secret, algorithms=["HS256"])
|
return jwt.decode(str(token), self.secret, algorithms=["HS256"])
|
||||||
except PyJWTError as error:
|
except PyJWTError as error:
|
||||||
logger.debug("Error while checking JWT token: ", exc_info=error)
|
logger.debug("Error while checking JWT token: ", exc_info=error)
|
||||||
return None
|
return None
|
||||||
@ -44,7 +44,7 @@ class Authentication:
|
|||||||
token,
|
token,
|
||||||
) -> Optional[Tuple[Optional[ApiKeys], Dict[str, Any], Dict[str, Any]]]:
|
) -> Optional[Tuple[Optional[ApiKeys], Dict[str, Any], Dict[str, Any]]]:
|
||||||
try:
|
try:
|
||||||
data = jwt.decode(token, self.secret, algorithms=["HS256"])
|
data = jwt.decode(str(token), self.secret, algorithms=["HS256"])
|
||||||
except PyJWTError as error:
|
except PyJWTError as error:
|
||||||
logger.debug("Error while checking JWT token: ", exc_info=error)
|
logger.debug("Error while checking JWT token: ", exc_info=error)
|
||||||
return None
|
return None
|
||||||
@ -65,5 +65,17 @@ class Authentication:
|
|||||||
else:
|
else:
|
||||||
return None
|
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:
|
def check_bool(self, token) -> bool:
|
||||||
return self.check(token) is not None
|
return self.check(token) is not None
|
||||||
|
@ -1,483 +1,483 @@
|
|||||||
# pylint: skip-file
|
# pylint: skip-file
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
import typing as t
|
import typing as t
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
import peewee
|
import peewee
|
||||||
from playhouse.migrate import (
|
from playhouse.migrate import (
|
||||||
SqliteMigrator,
|
SqliteMigrator,
|
||||||
Operation,
|
Operation,
|
||||||
SQL,
|
SQL,
|
||||||
SqliteDatabase,
|
SqliteDatabase,
|
||||||
make_index_name,
|
make_index_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.classes.shared.console import Console
|
from app.classes.shared.console import Console
|
||||||
from app.classes.shared.helpers import Helpers
|
from app.classes.shared.helpers import Helpers
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MIGRATE_TABLE = "migratehistory"
|
MIGRATE_TABLE = "migratehistory"
|
||||||
MIGRATE_TEMPLATE = '''# Generated by database migrator
|
MIGRATE_TEMPLATE = '''# Generated by database migrator
|
||||||
import peewee
|
import peewee
|
||||||
|
|
||||||
def migrate(migrator, db):
|
def migrate(migrator, db):
|
||||||
"""
|
"""
|
||||||
Write your migrations here.
|
Write your migrations here.
|
||||||
"""
|
"""
|
||||||
{migrate}
|
{migrate}
|
||||||
|
|
||||||
def rollback(migrator, db):
|
def rollback(migrator, db):
|
||||||
"""
|
"""
|
||||||
Write your rollback migrations here.
|
Write your rollback migrations here.
|
||||||
"""
|
"""
|
||||||
{rollback}'''
|
{rollback}'''
|
||||||
|
|
||||||
|
|
||||||
class MigrateHistory(peewee.Model):
|
class MigrateHistory(peewee.Model):
|
||||||
"""
|
"""
|
||||||
Presents the migration history in a database.
|
Presents the migration history in a database.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = peewee.CharField(unique=True)
|
name = peewee.CharField(unique=True)
|
||||||
migrated_at = peewee.DateTimeField(default=datetime.utcnow)
|
migrated_at = peewee.DateTimeField(default=datetime.utcnow)
|
||||||
|
|
||||||
# noinspection PyTypeChecker
|
# noinspection PyTypeChecker
|
||||||
def __unicode__(self) -> str:
|
def __unicode__(self) -> str:
|
||||||
"""
|
"""
|
||||||
String representation of this migration
|
String representation of this migration
|
||||||
"""
|
"""
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table_name = MIGRATE_TABLE
|
table_name = MIGRATE_TABLE
|
||||||
|
|
||||||
|
|
||||||
def get_model(method):
|
def get_model(method):
|
||||||
"""
|
"""
|
||||||
Convert string to model class.
|
Convert string to model class.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(method)
|
@wraps(method)
|
||||||
def wrapper(migrator, model, *args, **kwargs):
|
def wrapper(migrator, model, *args, **kwargs):
|
||||||
if isinstance(model, str):
|
if isinstance(model, str):
|
||||||
return method(migrator, migrator.table_dict[model], *args, **kwargs)
|
return method(migrator, migrator.table_dict[model], *args, **kwargs)
|
||||||
return method(migrator, model, *args, **kwargs)
|
return method(migrator, model, *args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyProtectedMember
|
# noinspection PyProtectedMember
|
||||||
class Migrator(object):
|
class Migrator(object):
|
||||||
def __init__(self, database: t.Union[peewee.Database, peewee.Proxy]):
|
def __init__(self, database: t.Union[peewee.Database, peewee.Proxy]):
|
||||||
"""
|
"""
|
||||||
Initializes the migrator
|
Initializes the migrator
|
||||||
"""
|
"""
|
||||||
if isinstance(database, peewee.Proxy):
|
if isinstance(database, peewee.Proxy):
|
||||||
database = database.obj
|
database = database.obj
|
||||||
self.database: SqliteDatabase = database
|
self.database: SqliteDatabase = database
|
||||||
self.table_dict: t.Dict[str, peewee.Model] = {}
|
self.table_dict: t.Dict[str, peewee.Model] = {}
|
||||||
self.operations: t.List[t.Union[Operation, callable]] = []
|
self.operations: t.List[t.Union[Operation, t.Callable]] = []
|
||||||
self.migrator = SqliteMigrator(database)
|
self.migrator = SqliteMigrator(database)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""
|
"""
|
||||||
Runs operations.
|
Runs operations.
|
||||||
"""
|
"""
|
||||||
for op in self.operations:
|
for op in self.operations:
|
||||||
if isinstance(op, Operation):
|
if isinstance(op, Operation):
|
||||||
op.run()
|
op.run()
|
||||||
else:
|
else:
|
||||||
op()
|
op()
|
||||||
self.clean()
|
self.clean()
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""
|
"""
|
||||||
Cleans the operations.
|
Cleans the operations.
|
||||||
"""
|
"""
|
||||||
self.operations = list()
|
self.operations = list()
|
||||||
|
|
||||||
def sql(self, sql: str, *params):
|
def sql(self, sql: str, *params):
|
||||||
"""
|
"""
|
||||||
Executes raw SQL.
|
Executes raw SQL.
|
||||||
"""
|
"""
|
||||||
self.operations.append(SQL(sql, *params))
|
self.operations.append(SQL(sql, *params))
|
||||||
|
|
||||||
def create_table(self, model: peewee.Model) -> peewee.Model:
|
def create_table(self, model: peewee.Model) -> peewee.Model:
|
||||||
"""
|
"""
|
||||||
Creates model and table in database.
|
Creates model and table in database.
|
||||||
"""
|
"""
|
||||||
self.table_dict[model._meta.table_name] = model
|
self.table_dict[model._meta.table_name] = model
|
||||||
model._meta.database = self.database
|
model._meta.database = self.database
|
||||||
self.operations.append(model.create_table)
|
self.operations.append(model.create_table)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
@get_model
|
@get_model
|
||||||
def drop_table(self, model: peewee.Model):
|
def drop_table(self, model: peewee.Model):
|
||||||
"""
|
"""
|
||||||
Drops model and table from database.
|
Drops model and table from database.
|
||||||
"""
|
"""
|
||||||
del self.table_dict[model._meta.table_name]
|
del self.table_dict[model._meta.table_name]
|
||||||
self.operations.append(lambda: model.drop_table(cascade=False))
|
self.operations.append(lambda: model.drop_table(cascade=False))
|
||||||
|
|
||||||
@get_model
|
@get_model
|
||||||
def add_columns(self, model: peewee.Model, **fields: peewee.Field) -> peewee.Model:
|
def add_columns(self, model: peewee.Model, **fields: peewee.Field) -> peewee.Model:
|
||||||
"""
|
"""
|
||||||
Creates new fields.
|
Creates new fields.
|
||||||
"""
|
"""
|
||||||
for name, field in fields.items():
|
for name, field in fields.items():
|
||||||
model._meta.add_field(name, field)
|
model._meta.add_field(name, field)
|
||||||
self.operations.append(
|
self.operations.append(
|
||||||
self.migrator.add_column(
|
self.migrator.add_column(
|
||||||
model._meta.table_name, field.column_name, field
|
model._meta.table_name, field.column_name, field
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if field.unique:
|
if field.unique:
|
||||||
self.operations.append(
|
self.operations.append(
|
||||||
self.migrator.add_index(
|
self.migrator.add_index(
|
||||||
model._meta.table_name, (field.column_name,), unique=True
|
model._meta.table_name, (field.column_name,), unique=True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
@get_model
|
@get_model
|
||||||
def drop_columns(self, model: peewee.Model, names: str) -> peewee.Model:
|
def drop_columns(self, model: peewee.Model, names: str) -> peewee.Model:
|
||||||
"""
|
"""
|
||||||
Removes fields from model.
|
Removes fields from model.
|
||||||
"""
|
"""
|
||||||
fields = [field for field in model._meta.fields.values() if field.name in names]
|
fields = [field for field in model._meta.fields.values() if field.name in names]
|
||||||
for field in fields:
|
for field in fields:
|
||||||
self.__del_field__(model, field)
|
self.__del_field__(model, field)
|
||||||
if field.unique:
|
if field.unique:
|
||||||
# Drop unique index
|
# Drop unique index
|
||||||
index_name = make_index_name(
|
index_name = make_index_name(
|
||||||
model._meta.table_name, [field.column_name]
|
model._meta.table_name, [field.column_name]
|
||||||
)
|
)
|
||||||
self.operations.append(
|
self.operations.append(
|
||||||
self.migrator.drop_index(model._meta.table_name, index_name)
|
self.migrator.drop_index(model._meta.table_name, index_name)
|
||||||
)
|
)
|
||||||
self.operations.append(
|
self.operations.append(
|
||||||
self.migrator.drop_column(
|
self.migrator.drop_column(
|
||||||
model._meta.table_name, field.column_name, cascade=False
|
model._meta.table_name, field.column_name, cascade=False
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
def __del_field__(self, model: peewee.Model, field: peewee.Field):
|
def __del_field__(self, model: peewee.Model, field: peewee.Field):
|
||||||
"""
|
"""
|
||||||
Deletes field from model.
|
Deletes field from model.
|
||||||
"""
|
"""
|
||||||
model._meta.remove_field(field.name)
|
model._meta.remove_field(field.name)
|
||||||
delattr(model, field.name)
|
delattr(model, field.name)
|
||||||
if isinstance(field, peewee.ForeignKeyField):
|
if isinstance(field, peewee.ForeignKeyField):
|
||||||
obj_id_name = field.column_name
|
obj_id_name = field.column_name
|
||||||
if field.column_name == field.name:
|
if field.column_name == field.name:
|
||||||
obj_id_name += "_id"
|
obj_id_name += "_id"
|
||||||
delattr(model, obj_id_name)
|
delattr(model, obj_id_name)
|
||||||
delattr(field.rel_model, field.backref)
|
delattr(field.rel_model, field.backref)
|
||||||
|
|
||||||
@get_model
|
@get_model
|
||||||
def rename_column(
|
def rename_column(
|
||||||
self, model: peewee.Model, old_name: str, new_name: str
|
self, model: peewee.Model, old_name: str, new_name: str
|
||||||
) -> peewee.Model:
|
) -> peewee.Model:
|
||||||
"""
|
"""
|
||||||
Renames field in model.
|
Renames field in model.
|
||||||
"""
|
"""
|
||||||
field = model._meta.fields[old_name]
|
field = model._meta.fields[old_name]
|
||||||
if isinstance(field, peewee.ForeignKeyField):
|
if isinstance(field, peewee.ForeignKeyField):
|
||||||
old_name = field.column_name
|
old_name = field.column_name
|
||||||
self.__del_field__(model, field)
|
self.__del_field__(model, field)
|
||||||
field.name = field.column_name = new_name
|
field.name = field.column_name = new_name
|
||||||
model._meta.add_field(new_name, field)
|
model._meta.add_field(new_name, field)
|
||||||
if isinstance(field, peewee.ForeignKeyField):
|
if isinstance(field, peewee.ForeignKeyField):
|
||||||
field.column_name = new_name = field.column_name + "_id"
|
field.column_name = new_name = field.column_name + "_id"
|
||||||
self.operations.append(
|
self.operations.append(
|
||||||
self.migrator.rename_column(model._meta.table_name, old_name, new_name)
|
self.migrator.rename_column(model._meta.table_name, old_name, new_name)
|
||||||
)
|
)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
@get_model
|
@get_model
|
||||||
def rename_table(self, model: peewee.Model, new_name: str) -> peewee.Model:
|
def rename_table(self, model: peewee.Model, new_name: str) -> peewee.Model:
|
||||||
"""
|
"""
|
||||||
Renames table in database.
|
Renames table in database.
|
||||||
"""
|
"""
|
||||||
old_name = model._meta.table_name
|
old_name = model._meta.table_name
|
||||||
del self.table_dict[model._meta.table_name]
|
del self.table_dict[model._meta.table_name]
|
||||||
model._meta.table_name = new_name
|
model._meta.table_name = new_name
|
||||||
self.table_dict[model._meta.table_name] = model
|
self.table_dict[model._meta.table_name] = model
|
||||||
self.operations.append(self.migrator.rename_table(old_name, new_name))
|
self.operations.append(self.migrator.rename_table(old_name, new_name))
|
||||||
return model
|
return model
|
||||||
|
|
||||||
@get_model
|
@get_model
|
||||||
def add_index(
|
def add_index(
|
||||||
self, model: peewee.Model, *columns: str, unique=False
|
self, model: peewee.Model, *columns: str, unique=False
|
||||||
) -> peewee.Model:
|
) -> peewee.Model:
|
||||||
"""Create indexes."""
|
"""Create indexes."""
|
||||||
model._meta.indexes.append((columns, unique))
|
model._meta.indexes.append((columns, unique))
|
||||||
columns_ = []
|
columns_ = []
|
||||||
for col in columns:
|
for col in columns:
|
||||||
field = model._meta.fields.get(col)
|
field = model._meta.fields.get(col)
|
||||||
|
|
||||||
if len(columns) == 1:
|
if len(columns) == 1:
|
||||||
field.unique = unique
|
field.unique = unique
|
||||||
field.index = not unique
|
field.index = not unique
|
||||||
|
|
||||||
if isinstance(field, peewee.ForeignKeyField):
|
if isinstance(field, peewee.ForeignKeyField):
|
||||||
col = col + "_id"
|
col = col + "_id"
|
||||||
|
|
||||||
columns_.append(col)
|
columns_.append(col)
|
||||||
self.operations.append(
|
self.operations.append(
|
||||||
self.migrator.add_index(model._meta.table_name, columns_, unique=unique)
|
self.migrator.add_index(model._meta.table_name, columns_, unique=unique)
|
||||||
)
|
)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
@get_model
|
@get_model
|
||||||
def drop_index(self, model: peewee.Model, *columns: str) -> peewee.Model:
|
def drop_index(self, model: peewee.Model, *columns: str) -> peewee.Model:
|
||||||
"""Drop indexes."""
|
"""Drop indexes."""
|
||||||
columns_ = []
|
columns_ = []
|
||||||
for col in columns:
|
for col in columns:
|
||||||
field = model._meta.fields.get(col)
|
field = model._meta.fields.get(col)
|
||||||
if not field:
|
if not field:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if len(columns) == 1:
|
if len(columns) == 1:
|
||||||
field.unique = field.index = False
|
field.unique = field.index = False
|
||||||
|
|
||||||
if isinstance(field, peewee.ForeignKeyField):
|
if isinstance(field, peewee.ForeignKeyField):
|
||||||
col = col + "_id"
|
col = col + "_id"
|
||||||
columns_.append(col)
|
columns_.append(col)
|
||||||
index_name = make_index_name(model._meta.table_name, columns_)
|
index_name = make_index_name(model._meta.table_name, columns_)
|
||||||
model._meta.indexes = [
|
model._meta.indexes = [
|
||||||
(cols, _) for (cols, _) in model._meta.indexes if columns != cols
|
(cols, _) for (cols, _) in model._meta.indexes if columns != cols
|
||||||
]
|
]
|
||||||
self.operations.append(
|
self.operations.append(
|
||||||
self.migrator.drop_index(model._meta.table_name, index_name)
|
self.migrator.drop_index(model._meta.table_name, index_name)
|
||||||
)
|
)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
@get_model
|
@get_model
|
||||||
def add_not_null(self, model: peewee.Model, *names: str) -> peewee.Model:
|
def add_not_null(self, model: peewee.Model, *names: str) -> peewee.Model:
|
||||||
"""Add not null."""
|
"""Add not null."""
|
||||||
for name in names:
|
for name in names:
|
||||||
field = model._meta.fields[name]
|
field = model._meta.fields[name]
|
||||||
field.null = False
|
field.null = False
|
||||||
self.operations.append(
|
self.operations.append(
|
||||||
self.migrator.add_not_null(model._meta.table_name, field.column_name)
|
self.migrator.add_not_null(model._meta.table_name, field.column_name)
|
||||||
)
|
)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
@get_model
|
@get_model
|
||||||
def drop_not_null(self, model: peewee.Model, *names: str) -> peewee.Model:
|
def drop_not_null(self, model: peewee.Model, *names: str) -> peewee.Model:
|
||||||
"""Drop not null."""
|
"""Drop not null."""
|
||||||
for name in names:
|
for name in names:
|
||||||
field = model._meta.fields[name]
|
field = model._meta.fields[name]
|
||||||
field.null = True
|
field.null = True
|
||||||
self.operations.append(
|
self.operations.append(
|
||||||
self.migrator.drop_not_null(model._meta.table_name, field.column_name)
|
self.migrator.drop_not_null(model._meta.table_name, field.column_name)
|
||||||
)
|
)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
@get_model
|
@get_model
|
||||||
def add_default(
|
def add_default(
|
||||||
self, model: peewee.Model, name: str, default: t.Any
|
self, model: peewee.Model, name: str, default: t.Any
|
||||||
) -> peewee.Model:
|
) -> peewee.Model:
|
||||||
"""Add default."""
|
"""Add default."""
|
||||||
field = model._meta.fields[name]
|
field = model._meta.fields[name]
|
||||||
model._meta.defaults[field] = field.default = default
|
model._meta.defaults[field] = field.default = default
|
||||||
self.operations.append(
|
self.operations.append(
|
||||||
self.migrator.apply_default(model._meta.table_name, name, field)
|
self.migrator.apply_default(model._meta.table_name, name, field)
|
||||||
)
|
)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyProtectedMember
|
# noinspection PyProtectedMember
|
||||||
class MigrationManager(object):
|
class MigrationManager(object):
|
||||||
filemask = re.compile(r"[\d]+_[^\.]+\.py$")
|
filemask = re.compile(r"[\d]+_[^\.]+\.py$")
|
||||||
|
|
||||||
def __init__(self, database: t.Union[peewee.Database, peewee.Proxy], helper):
|
def __init__(self, database: t.Union[peewee.Database, peewee.Proxy], helper):
|
||||||
"""
|
"""
|
||||||
Initializes the migration manager.
|
Initializes the migration manager.
|
||||||
"""
|
"""
|
||||||
if not isinstance(database, (peewee.Database, peewee.Proxy)):
|
if not isinstance(database, (peewee.Database, peewee.Proxy)):
|
||||||
raise RuntimeError("Invalid database: {}".format(database))
|
raise RuntimeError("Invalid database: {}".format(database))
|
||||||
self.database = database
|
self.database = database
|
||||||
self.helper = helper
|
self.helper = helper
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def model(self) -> t.Type[MigrateHistory]:
|
def model(self) -> t.Type[MigrateHistory]:
|
||||||
"""
|
"""
|
||||||
Initialize and cache the MigrationHistory model.
|
Initialize and cache the MigrationHistory model.
|
||||||
"""
|
"""
|
||||||
MigrateHistory._meta.database = self.database
|
MigrateHistory._meta.database = self.database
|
||||||
MigrateHistory._meta.table_name = "migratehistory"
|
MigrateHistory._meta.table_name = "migratehistory"
|
||||||
MigrateHistory._meta.schema = None
|
MigrateHistory._meta.schema = None
|
||||||
MigrateHistory.create_table(True)
|
MigrateHistory.create_table(True)
|
||||||
return MigrateHistory
|
return MigrateHistory
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def done(self) -> t.List[str]:
|
def done(self) -> t.List[str]:
|
||||||
"""
|
"""
|
||||||
Scans migrations in the database.
|
Scans migrations in the database.
|
||||||
"""
|
"""
|
||||||
return [mm.name for mm in self.model.select().order_by(self.model.id)]
|
return [mm.name for mm in self.model.select().order_by(self.model.id)]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def todo(self):
|
def todo(self):
|
||||||
"""
|
"""
|
||||||
Scans migrations in the file system.
|
Scans migrations in the file system.
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(self.helper.migration_dir):
|
if not os.path.exists(self.helper.migration_dir):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Migration directory: {} does not exist.".format(
|
"Migration directory: {} does not exist.".format(
|
||||||
self.helper.migration_dir
|
self.helper.migration_dir
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
os.makedirs(self.helper.migration_dir)
|
os.makedirs(self.helper.migration_dir)
|
||||||
return sorted(
|
return sorted(
|
||||||
f[:-3]
|
f[:-3]
|
||||||
for f in os.listdir(self.helper.migration_dir)
|
for f in os.listdir(self.helper.migration_dir)
|
||||||
if self.filemask.match(f)
|
if self.filemask.match(f)
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def diff(self) -> t.List[str]:
|
def diff(self) -> t.List[str]:
|
||||||
"""
|
"""
|
||||||
Calculates difference between the filesystem and the database.
|
Calculates difference between the filesystem and the database.
|
||||||
"""
|
"""
|
||||||
done = set(self.done)
|
done = set(self.done)
|
||||||
return [name for name in self.todo if name not in done]
|
return [name for name in self.todo if name not in done]
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def migrator(self) -> Migrator:
|
def migrator(self) -> Migrator:
|
||||||
"""
|
"""
|
||||||
Create migrator and setup it with fake migrations.
|
Create migrator and setup it with fake migrations.
|
||||||
"""
|
"""
|
||||||
migrator = Migrator(self.database)
|
migrator = Migrator(self.database)
|
||||||
for name in self.done:
|
for name in self.done:
|
||||||
self.up_one(name, migrator, True)
|
self.up_one(name, migrator, True)
|
||||||
return migrator
|
return migrator
|
||||||
|
|
||||||
def compile(self, name, migrate="", rollback=""):
|
def compile(self, name, migrate="", rollback=""):
|
||||||
"""
|
"""
|
||||||
Compiles a migration.
|
Compiles a migration.
|
||||||
"""
|
"""
|
||||||
name = datetime.utcnow().strftime("%Y%m%d%H%M%S") + "_" + name
|
name = datetime.utcnow().strftime("%Y%m%d%H%M%S") + "_" + name
|
||||||
filename = name + ".py"
|
filename = name + ".py"
|
||||||
path = os.path.join(self.helper.migration_dir, filename)
|
path = os.path.join(self.helper.migration_dir, filename)
|
||||||
with open(path, "w") as f:
|
with open(path, "w") as f:
|
||||||
f.write(
|
f.write(
|
||||||
MIGRATE_TEMPLATE.format(
|
MIGRATE_TEMPLATE.format(
|
||||||
migrate=migrate, rollback=rollback, name=filename
|
migrate=migrate, rollback=rollback, name=filename
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return name
|
return name
|
||||||
|
|
||||||
def create(self, name: str = "auto", auto: bool = False) -> t.Optional[str]:
|
def create(self, name: str = "auto", auto: bool = False) -> t.Optional[str]:
|
||||||
"""
|
"""
|
||||||
Creates a migration.
|
Creates a migration.
|
||||||
"""
|
"""
|
||||||
migrate = rollback = ""
|
migrate = rollback = ""
|
||||||
if auto:
|
if auto:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
logger.info('Creating migration "{}"'.format(name))
|
logger.info('Creating migration "{}"'.format(name))
|
||||||
name = self.compile(name, migrate, rollback)
|
name = self.compile(name, migrate, rollback)
|
||||||
logger.info('Migration has been created as "{}"'.format(name))
|
logger.info('Migration has been created as "{}"'.format(name))
|
||||||
return name
|
return name
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
"""Clear migrations."""
|
"""Clear migrations."""
|
||||||
self.model.delete().execute()
|
self.model.delete().execute()
|
||||||
|
|
||||||
def up(self, name: t.Optional[str] = None):
|
def up(self, name: t.Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Runs all unapplied migrations.
|
Runs all unapplied migrations.
|
||||||
"""
|
"""
|
||||||
logger.info("Starting migrations")
|
logger.info("Starting migrations")
|
||||||
Console.info("Starting migrations")
|
Console.info("Starting migrations")
|
||||||
|
|
||||||
done = []
|
done = []
|
||||||
diff = self.diff
|
diff = self.diff
|
||||||
if not diff:
|
if not diff:
|
||||||
logger.info("There is nothing to migrate")
|
logger.info("There is nothing to migrate")
|
||||||
Console.info("There is nothing to migrate")
|
Console.info("There is nothing to migrate")
|
||||||
return done
|
return done
|
||||||
|
|
||||||
migrator = self.migrator
|
migrator = self.migrator
|
||||||
for mname in diff:
|
for mname in diff:
|
||||||
done.append(self.up_one(mname, self.migrator))
|
done.append(self.up_one(mname, self.migrator))
|
||||||
if name and name == mname:
|
if name and name == mname:
|
||||||
break
|
break
|
||||||
|
|
||||||
return done
|
return done
|
||||||
|
|
||||||
def read(self, name: str):
|
def read(self, name: str):
|
||||||
"""
|
"""
|
||||||
Reads a migration from a file.
|
Reads a migration from a file.
|
||||||
"""
|
"""
|
||||||
call_params = dict()
|
call_params = dict()
|
||||||
if Helpers.is_os_windows() and sys.version_info >= (3, 0):
|
if Helpers.is_os_windows() and sys.version_info >= (3, 0):
|
||||||
# if system is windows - force utf-8 encoding
|
# if system is windows - force utf-8 encoding
|
||||||
call_params["encoding"] = "utf-8"
|
call_params["encoding"] = "utf-8"
|
||||||
with open(
|
with open(
|
||||||
os.path.join(self.helper.migration_dir, name + ".py"), **call_params
|
os.path.join(self.helper.migration_dir, name + ".py"), **call_params
|
||||||
) as f:
|
) as f:
|
||||||
code = f.read()
|
code = f.read()
|
||||||
scope = {}
|
scope = {}
|
||||||
code = compile(code, "<string>", "exec", dont_inherit=True)
|
code = compile(code, "<string>", "exec", dont_inherit=True)
|
||||||
exec(code, scope, None)
|
exec(code, scope, None)
|
||||||
return scope.get("migrate", lambda m, d: None), scope.get(
|
return scope.get("migrate", lambda m, d: None), scope.get(
|
||||||
"rollback", lambda m, d: None
|
"rollback", lambda m, d: None
|
||||||
)
|
)
|
||||||
|
|
||||||
def up_one(
|
def up_one(
|
||||||
self, name: str, migrator: Migrator, fake: bool = False, rollback: bool = False
|
self, name: str, migrator: Migrator, fake: bool = False, rollback: bool = False
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Runs a migration with a given name.
|
Runs a migration with a given name.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
migrate_fn, rollback_fn = self.read(name)
|
migrate_fn, rollback_fn = self.read(name)
|
||||||
if fake:
|
if fake:
|
||||||
migrate_fn(migrator, self.database)
|
migrate_fn(migrator, self.database)
|
||||||
migrator.clean()
|
migrator.clean()
|
||||||
return name
|
return name
|
||||||
with self.database.transaction():
|
with self.database.transaction():
|
||||||
if rollback:
|
if rollback:
|
||||||
logger.info('Rolling back "{}"'.format(name))
|
logger.info('Rolling back "{}"'.format(name))
|
||||||
rollback_fn(migrator, self.database)
|
rollback_fn(migrator, self.database)
|
||||||
migrator.run()
|
migrator.run()
|
||||||
self.model.delete().where(self.model.name == name).execute()
|
self.model.delete().where(self.model.name == name).execute()
|
||||||
else:
|
else:
|
||||||
logger.info('Migrate "{}"'.format(name))
|
logger.info('Migrate "{}"'.format(name))
|
||||||
migrate_fn(migrator, self.database)
|
migrate_fn(migrator, self.database)
|
||||||
migrator.run()
|
migrator.run()
|
||||||
if name not in self.done:
|
if name not in self.done:
|
||||||
self.model.create(name=name)
|
self.model.create(name=name)
|
||||||
|
|
||||||
logger.info('Done "{}"'.format(name))
|
logger.info('Done "{}"'.format(name))
|
||||||
return name
|
return name
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.database.rollback()
|
self.database.rollback()
|
||||||
operation_name = "Rollback" if rollback else "Migration"
|
operation_name = "Rollback" if rollback else "Migration"
|
||||||
logger.exception("{} failed: {}".format(operation_name, name))
|
logger.exception("{} failed: {}".format(operation_name, name))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def down(self):
|
def down(self):
|
||||||
"""
|
"""
|
||||||
Rolls back migrations.
|
Rolls back migrations.
|
||||||
"""
|
"""
|
||||||
if not self.done:
|
if not self.done:
|
||||||
raise RuntimeError("No migrations are found.")
|
raise RuntimeError("No migrations are found.")
|
||||||
|
|
||||||
name = self.done[-1]
|
name = self.done[-1]
|
||||||
|
|
||||||
migrator = self.migrator
|
migrator = self.migrator
|
||||||
self.up_one(name, migrator, False, True)
|
self.up_one(name, migrator, False, True)
|
||||||
logger.warning("Rolled back migration: {}".format(name))
|
logger.warning("Rolled back migration: {}".format(name))
|
||||||
|
@ -101,6 +101,17 @@ class TasksManager:
|
|||||||
elif command == "restart_server":
|
elif command == "restart_server":
|
||||||
svr.restart_threaded_server(user_id)
|
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":
|
elif command == "backup_server":
|
||||||
svr.backup_server()
|
svr.backup_server()
|
||||||
|
|
||||||
|
7
app/classes/web/base_api_handler.py
Normal file
7
app/classes/web/base_api_handler.py
Normal file
@ -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
|
@ -1,14 +1,39 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Union, List, Optional, Tuple, Dict, Any
|
import re
|
||||||
|
import typing as t
|
||||||
|
import orjson
|
||||||
import bleach
|
import bleach
|
||||||
import tornado.web
|
import tornado.web
|
||||||
|
|
||||||
|
from app.classes.models.crafty_permissions import Enum_Permissions_Crafty
|
||||||
from app.classes.models.users import ApiKeys
|
from app.classes.models.users import ApiKeys
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bearer_pattern = re.compile(r"^Bearer ", flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
class BaseHandler(tornado.web.RequestHandler):
|
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)}
|
nobleach = {bool, type(None)}
|
||||||
redactables = ("pass", "api")
|
redactables = ("pass", "api")
|
||||||
@ -30,12 +55,12 @@ class BaseHandler(tornado.web.RequestHandler):
|
|||||||
)
|
)
|
||||||
return remote_ip
|
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(
|
def get_current_user(
|
||||||
self,
|
self,
|
||||||
) -> Optional[Tuple[Optional[ApiKeys], Dict[str, Any], Dict[str, Any]]]:
|
) -> t.Tuple[t.Optional[ApiKeys], t.Dict[str, t.Any], t.Dict[str, t.Any]]:
|
||||||
return self.controller.authentication.check(self.get_cookie("token"))
|
return self.controller.authentication.check_err(self.get_cookie("token"))
|
||||||
|
|
||||||
def autobleach(self, name, text):
|
def autobleach(self, name, text):
|
||||||
for r in self.redactables:
|
for r in self.redactables:
|
||||||
@ -53,15 +78,15 @@ class BaseHandler(tornado.web.RequestHandler):
|
|||||||
def get_argument(
|
def get_argument(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
default: Union[
|
default: t.Union[
|
||||||
None, str, tornado.web._ArgDefaultMarker
|
None, str, tornado.web._ArgDefaultMarker
|
||||||
] = tornado.web._ARG_DEFAULT,
|
] = tornado.web._ARG_DEFAULT,
|
||||||
strip: bool = True,
|
strip: bool = True,
|
||||||
) -> Optional[str]:
|
) -> t.Optional[str]:
|
||||||
arg = self._get_argument(name, default, self.request.arguments, strip)
|
arg = self._get_argument(name, default, self.request.arguments, strip)
|
||||||
return self.autobleach(name, arg)
|
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):
|
if not isinstance(strip, bool):
|
||||||
raise AssertionError
|
raise AssertionError
|
||||||
args = self._get_arguments(name, self.request.arguments, strip)
|
args = self._get_arguments(name, self.request.arguments, strip)
|
||||||
@ -69,3 +94,117 @@ class BaseHandler(tornado.web.RequestHandler):
|
|||||||
for arg in args:
|
for arg in args:
|
||||||
args_ret += self.autobleach(name, arg)
|
args_ret += self.autobleach(name, arg)
|
||||||
return args_ret
|
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
|
||||||
|
@ -300,6 +300,8 @@ class PanelHandler(BaseHandler):
|
|||||||
else None,
|
else None,
|
||||||
"superuser": superuser,
|
"superuser": superuser,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# http://en.gravatar.com/site/implement/images/#rating
|
||||||
if self.helper.get_setting("allow_nsfw_profile_pictures"):
|
if self.helper.get_setting("allow_nsfw_profile_pictures"):
|
||||||
rating = "x"
|
rating = "x"
|
||||||
else:
|
else:
|
||||||
|
55
app/classes/web/routes/api/api_handlers.py
Normal file
55
app/classes/web/routes/api/api_handlers.py
Normal file
@ -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,
|
||||||
|
),
|
||||||
|
]
|
21
app/classes/web/routes/api/auth/invalidate_tokens.py
Normal file
21
app/classes/web/routes/api/auth/invalidate_tokens.py
Normal file
@ -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"})
|
101
app/classes/web/routes/api/auth/login.py
Normal file
101
app/classes/web/routes/api/auth/login.py
Normal file
@ -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},
|
||||||
|
)
|
2
app/classes/web/routes/api/auth/register.py
Normal file
2
app/classes/web/routes/api/auth/register.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# nothing here yet
|
||||||
|
# sometime implement configurable self service account creation?
|
0
app/classes/web/routes/api/roles/index.py
Normal file
0
app/classes/web/routes/api/roles/index.py
Normal file
0
app/classes/web/routes/api/roles/role/index.py
Normal file
0
app/classes/web/routes/api/roles/role/index.py
Normal file
0
app/classes/web/routes/api/roles/role/users.py
Normal file
0
app/classes/web/routes/api/roles/role/users.py
Normal file
20
app/classes/web/routes/api/servers/index.py
Normal file
20
app/classes/web/routes/api/servers/index.py
Normal file
@ -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()
|
91
app/classes/web/routes/api/servers/server/action.py
Normal file
91
app/classes/web/routes/api/servers/server/action.py
Normal file
@ -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"},
|
||||||
|
)
|
163
app/classes/web/routes/api/servers/server/index.py
Normal file
163
app/classes/web/routes/api/servers/server/index.py
Normal file
@ -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"},
|
||||||
|
)
|
73
app/classes/web/routes/api/servers/server/logs.py
Normal file
73
app/classes/web/routes/api/servers/server/logs.py
Normal file
@ -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}<br />")
|
||||||
|
else:
|
||||||
|
self.finish_json(200, {"status": "ok", "data": lines})
|
23
app/classes/web/routes/api/servers/server/public.py
Normal file
23
app/classes/web/routes/api/servers/server/public.py
Normal file
@ -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"]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
27
app/classes/web/routes/api/servers/server/stats.py
Normal file
27
app/classes/web/routes/api/servers/server/stats.py
Normal file
@ -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)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
31
app/classes/web/routes/api/servers/server/users.py
Normal file
31
app/classes/web/routes/api/servers/server/users.py
Normal file
@ -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)),
|
||||||
|
},
|
||||||
|
)
|
166
app/classes/web/routes/api/users/index.py
Normal file
166
app/classes/web/routes/api/users/index.py
Normal file
@ -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(),
|
||||||
|
)
|
211
app/classes/web/routes/api/users/user/index.py
Normal file
211
app/classes/web/routes/api/users/user/index.py
Normal file
@ -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"})
|
49
app/classes/web/routes/api/users/user/pfp.py
Normal file
49
app/classes/web/routes/api/users/user/pfp.py
Normal file
@ -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})
|
35
app/classes/web/routes/api/users/user/public.py
Normal file
35
app/classes/web/routes/api/users/user/public.py
Normal file
@ -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},
|
||||||
|
)
|
@ -17,6 +17,7 @@ from app.classes.web.file_handler import FileHandler
|
|||||||
from app.classes.web.public_handler import PublicHandler
|
from app.classes.web.public_handler import PublicHandler
|
||||||
from app.classes.web.panel_handler import PanelHandler
|
from app.classes.web.panel_handler import PanelHandler
|
||||||
from app.classes.web.default_handler import DefaultHandler
|
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.server_handler import ServerHandler
|
||||||
from app.classes.web.ajax_handler import AjaxHandler
|
from app.classes.web.ajax_handler import AjaxHandler
|
||||||
from app.classes.web.api_handler import (
|
from app.classes.web.api_handler import (
|
||||||
@ -150,7 +151,7 @@ class Webserver:
|
|||||||
(r"/ws", SocketHandler, handler_args),
|
(r"/ws", SocketHandler, handler_args),
|
||||||
(r"/upload", UploadHandler, handler_args),
|
(r"/upload", UploadHandler, handler_args),
|
||||||
(r"/status", StatusHandler, handler_args),
|
(r"/status", StatusHandler, handler_args),
|
||||||
# API Routes
|
# API Routes V1
|
||||||
(r"/api/v1/stats/servers", ServersStats, handler_args),
|
(r"/api/v1/stats/servers", ServersStats, handler_args),
|
||||||
(r"/api/v1/stats/node", NodeStats, handler_args),
|
(r"/api/v1/stats/node", NodeStats, handler_args),
|
||||||
(r"/api/v1/server/send_command", SendCommand, 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/list_servers", ListServers, handler_args),
|
||||||
(r"/api/v1/users/create_user", CreateUser, handler_args),
|
(r"/api/v1/users/create_user", CreateUser, handler_args),
|
||||||
(r"/api/v1/users/delete_user", DeleteUser, handler_args),
|
(r"/api/v1/users/delete_user", DeleteUser, handler_args),
|
||||||
|
# API Routes V2
|
||||||
|
*api_handlers(handler_args),
|
||||||
]
|
]
|
||||||
|
|
||||||
app = tornado.web.Application(
|
app = tornado.web.Application(
|
||||||
@ -194,7 +197,7 @@ class Webserver:
|
|||||||
static_path=os.path.join(self.helper.webroot, "static"),
|
static_path=os.path.join(self.helper.webroot, "static"),
|
||||||
debug=debug_errors,
|
debug=debug_errors,
|
||||||
cookie_secret=cookie_secret,
|
cookie_secret=cookie_secret,
|
||||||
xsrf_cookies=True,
|
xsrf_cookies=False,
|
||||||
autoreload=False,
|
autoreload=False,
|
||||||
log_function=self.log_function,
|
log_function=self.log_function,
|
||||||
default_handler_class=HTTPHandler,
|
default_handler_class=HTTPHandler,
|
||||||
|
@ -1,26 +1,27 @@
|
|||||||
{
|
{
|
||||||
"http_port": 8000,
|
"http_port": 8000,
|
||||||
"https_port": 8443,
|
"https_port": 8443,
|
||||||
"language": "en_EN",
|
"language": "en_EN",
|
||||||
"cookie_expire": 30,
|
"cookie_expire": 30,
|
||||||
"cookie_secret": "random",
|
"cookie_secret": "random",
|
||||||
"apikey_secret": "random",
|
"apikey_secret": "random",
|
||||||
"show_errors": true,
|
"show_errors": true,
|
||||||
"history_max_age": 7,
|
"history_max_age": 7,
|
||||||
"stats_update_frequency": 30,
|
"stats_update_frequency": 30,
|
||||||
"delete_default_json": false,
|
"delete_default_json": false,
|
||||||
"show_contribute_link": true,
|
"show_contribute_link": true,
|
||||||
"virtual_terminal_lines": 70,
|
"virtual_terminal_lines": 70,
|
||||||
"max_log_lines": 700,
|
"max_log_lines": 700,
|
||||||
"max_audit_entries": 300,
|
"max_audit_entries": 300,
|
||||||
"disabled_language_files": [
|
"disabled_language_files": [
|
||||||
"lol_EN.json",
|
"lol_EN.json",
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
"stream_size_GB": 1,
|
"stream_size_GB": 1,
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"help",
|
"help",
|
||||||
"chunk"
|
"chunk"
|
||||||
],
|
],
|
||||||
"allow_nsfw_profile_pictures": false
|
"allow_nsfw_profile_pictures": false,
|
||||||
}
|
"enable_user_self_delete": false
|
||||||
|
}
|
||||||
|
@ -702,4 +702,4 @@
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
|
@ -17,3 +17,5 @@ requests==2.26
|
|||||||
termcolor==1.1
|
termcolor==1.1
|
||||||
tornado==6.0
|
tornado==6.0
|
||||||
tzlocal==4.0
|
tzlocal==4.0
|
||||||
|
jsonschema==4.4.0
|
||||||
|
orjson==3.6.7
|
||||||
|
Loading…
Reference in New Issue
Block a user