mc-crafty-controller/app/classes/web/base_api_handler.py

205 lines
6.9 KiB
Python
Raw Permalink Normal View History

import logging
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
2022-05-10 02:08:49 +03:00
from typing import Awaitable, Callable, Optional
2025-07-04 15:48:32 -04:00
import aiofiles
import orjson
from jsonschema import ValidationError, validate
2025-07-04 15:48:32 -04:00
from tornado.iostream import StreamClosedError
from app.classes.models.crafty_permissions import EnumPermissionsCrafty
from app.classes.web.base_handler import BaseHandler
logger = logging.getLogger(__name__)
executor = ThreadPoolExecutor(max_workers=4)
class BaseApiHandler(BaseHandler):
2022-05-10 02:08:49 +03:00
# {{{ Disable XSRF protection on API routes
def check_xsrf_cookie(self) -> None:
pass
2022-05-10 02:08:49 +03:00
# }}}
# {{{ 405 Method Not Allowed as JSON
def _unimplemented_method(self, *_args: str, **_kwargs: str) -> None:
self.finish_json(
405,
{
"status": "error",
"error": "METHOD_NOT_ALLOWED",
"error_data": "METHOD NOT ALLOWED",
},
)
2022-05-10 02:08:49 +03:00
head = _unimplemented_method # type: Callable[..., Optional[Awaitable[None]]]
get = _unimplemented_method # type: Callable[..., Optional[Awaitable[None]]]
post = _unimplemented_method # type: Callable[..., Optional[Awaitable[None]]]
delete = _unimplemented_method # type: Callable[..., Optional[Awaitable[None]]]
patch = _unimplemented_method # type: Callable[..., Optional[Awaitable[None]]]
put = _unimplemented_method # type: Callable[..., Optional[Awaitable[None]]]
# }}}
2022-05-26 00:31:58 +03:00
def options(self, *_, **__):
"""
Fix CORS
"""
# no body
self.set_status(204)
self.finish()
async def download_file(self, file_path: Path):
"""THIS METHOD HAS NO TRAVERSAL DETECTION OR PERMISSION CHECKS! MAKE SURE TO
CHECK FOR TRAVERSAL AND PERMISSION BEFORE CALLING THIS FUNCTION.
Downloads file async in chunks
Args:
file_path (Path): pathlib Path object pointing to the intended file to
download
Returns:
_type_: _description_
"""
chunk_size = 4 * 1024 * 1024 # 4 MiB
try:
self.set_header("Content-Type", "application/octet-stream")
self.set_header(
"Content-Disposition", f'attachment; filename="{file_path.name}"'
)
2025-07-04 15:39:22 -04:00
async with aiofiles.open(file_path, "rb") as f:
while True:
2025-07-04 15:39:22 -04:00
chunk = await f.read(chunk_size)
if not chunk:
break
try:
self.write(chunk)
await self.flush()
except StreamClosedError:
break
finally:
del chunk
except Exception as e:
print("Download error:", e)
return self.finish_json(
500,
{
"status": "error",
"error": "Download error",
"error_data": f"ERROR: {e}",
},
)
def can_modify_user(
2026-04-03 13:43:16 -04:00
self, exec_user_crafty_permissions: list, auth_data: tuple, user_id: int | str
) -> bool:
"""Checks if exec user has permissions to modify target user
Args:
exec_user_crafty_permissions (list): Permissions of exec user returned
from auth check
auth_data (dict): Authenticated user information from auth check
user_id (int): Target user ID
Returns:
bool: Returns true if user can edit target user
"""
if int(auth_data[4]["user_id"]) == int(user_id) or str(user_id) == "@me":
return True
2026-04-03 13:39:59 -04:00
if auth_data[4]["superuser"]:
return True
2026-04-03 18:10:17 -04:00
if EnumPermissionsCrafty.USER_CONFIG in exec_user_crafty_permissions and int(
user_id
2026-04-03 18:10:17 -04:00
) in self.controller.users.get_managed_users_ids(auth_data[4]["user_id"]):
return True
return False
def require_superuser(self, auth_data: tuple) -> bool:
"""Guard an endpoint behind superuser access.
Writes a 400 ``NOT_AUTHORIZED`` response and returns ``False`` when the
authenticated user is not a superuser; returns ``True`` otherwise.
Args:
auth_data (tuple): Authenticated user information from the auth check.
Returns:
bool: ``True`` if the user is a superuser, else ``False``.
"""
if auth_data[4]["superuser"]:
return True
self.finish_json(
400,
{
"status": "error",
"error": "NOT_AUTHORIZED",
"error_data": self.helper.translation.translate(
"validators", "insufficientPerms", auth_data[4]["lang"]
),
},
)
return False
def load_and_validate(self, schema: dict):
"""Parse the JSON request body and validate it against ``schema``.
On failure, writes the appropriate 400 response (``INVALID_JSON`` for a
malformed body, ``INVALID_JSON_SCHEMA`` for a schema mismatch) and returns
``None``. A successful parse always yields the decoded object, so callers
can treat ``None`` as "response already sent, return now".
Args:
schema (dict): JSON schema to validate the request body against.
Returns:
Optional[object]: The validated request body, or ``None`` on failure.
"""
try:
data = orjson.loads(self.request.body)
except orjson.JSONDecodeError as e:
self.finish_json(
400,
{"status": "error", "error": "INVALID_JSON", "error_data": str(e)},
)
return None
try:
validate(data, schema)
except ValidationError as e:
self.finish_json(
400,
{
"status": "error",
"error": "INVALID_JSON_SCHEMA",
"error_data": str(e),
},
)
return None
return data
def reject_traversal(self, base_dir: str, target_path: str) -> bool:
"""Guard a filesystem path against directory traversal.
Writes a 400 ``TRAVERSAL DETECTED`` response and returns ``False`` when
``target_path`` escapes ``base_dir``; returns ``True`` when the path is safe.
Args:
base_dir (str): Absolute path of the directory the target must stay within.
target_path (str): Absolute path being accessed.
Returns:
bool: ``True`` if the path is safe, else ``False``.
"""
if self.helper.validate_traversal(base_dir, target_path):
return True
self.finish_json(
400,
{
"status": "error",
"error": "TRAVERSAL DETECTED",
"error_data": "TRIED TO REACH FILES THAT ARE"
" NOT SUPPOSED TO BE ACCESSIBLE",
},
)
return False