2025-07-03 17:27:40 -04:00
|
|
|
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
|
2026-08-04 15:09:48 -04:00
|
|
|
import orjson
|
|
|
|
|
from jsonschema import ValidationError, validate
|
2025-07-04 15:48:32 -04:00
|
|
|
from tornado.iostream import StreamClosedError
|
2026-04-03 13:32:47 -04:00
|
|
|
from app.classes.models.crafty_permissions import EnumPermissionsCrafty
|
2022-04-14 15:33:53 +03:00
|
|
|
from app.classes.web.base_handler import BaseHandler
|
|
|
|
|
|
2025-07-03 17:27:40 -04:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
executor = ThreadPoolExecutor(max_workers=4)
|
|
|
|
|
|
|
|
|
|
|
2022-04-14 15:33:53 +03:00
|
|
|
class BaseApiHandler(BaseHandler):
|
2022-05-10 02:08:49 +03:00
|
|
|
# {{{ Disable XSRF protection on API routes
|
2022-05-05 03:32:09 +03:00
|
|
|
def check_xsrf_cookie(self) -> None:
|
2022-04-14 15:33:53 +03:00
|
|
|
pass
|
2022-05-10 02:08:49 +03:00
|
|
|
|
|
|
|
|
# }}}
|
|
|
|
|
|
|
|
|
|
# {{{ 405 Method Not Allowed as JSON
|
|
|
|
|
def _unimplemented_method(self, *_args: str, **_kwargs: str) -> None:
|
2024-09-18 16:10:29 -04:00
|
|
|
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()
|
2025-07-03 17:27:40 -04:00
|
|
|
|
|
|
|
|
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:
|
2025-07-03 17:27:40 -04:00
|
|
|
while True:
|
2025-07-04 15:39:22 -04:00
|
|
|
chunk = await f.read(chunk_size)
|
2025-07-03 17:27:40 -04:00
|
|
|
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}",
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-04-03 13:32:47 -04:00
|
|
|
|
|
|
|
|
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
|
2026-04-03 13:32:47 -04:00
|
|
|
) -> 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
|
|
|
|
|
"""
|
2026-05-10 14:26:30 -05:00
|
|
|
if int(auth_data[4]["user_id"]) == int(user_id) or str(user_id) == "@me":
|
2026-04-03 13:32:47 -04:00
|
|
|
return True
|
2026-04-03 13:39:59 -04:00
|
|
|
if auth_data[4]["superuser"]:
|
2026-04-03 13:32:47 -04:00
|
|
|
return True
|
2026-04-03 18:10:17 -04:00
|
|
|
if EnumPermissionsCrafty.USER_CONFIG in exec_user_crafty_permissions and int(
|
2026-04-03 13:55:07 -04:00
|
|
|
user_id
|
2026-04-03 18:10:17 -04:00
|
|
|
) in self.controller.users.get_managed_users_ids(auth_data[4]["user_id"]):
|
2026-04-03 13:32:47 -04:00
|
|
|
return True
|
|
|
|
|
return False
|
2026-08-04 15:09:48 -04:00
|
|
|
|
|
|
|
|
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
|