mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
530 lines
23 KiB
Python
530 lines
23 KiB
Python
from __future__ import annotations
|
||
|
||
import io
|
||
import uuid
|
||
from typing import Tuple
|
||
|
||
from botocore.exceptions import ClientError
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Request as FastapiRequest
|
||
from fastapi.responses import StreamingResponse
|
||
from sqlalchemy.orm import Session
|
||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||
|
||
from app.core.config import settings
|
||
from app.core.deps import require_role
|
||
from app.core.security import decode_jwt
|
||
from app.db.session import get_db
|
||
from app.models.admin_user import AdminUser
|
||
from app.models.attachment import Attachment
|
||
from app.models.message import Message
|
||
from app.models.request import Request
|
||
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
|
||
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
|
||
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client
|
||
from app.services.security_audit import record_file_security_event
|
||
from app.services.attachment_scan import (
|
||
SCAN_STATUS_ERROR,
|
||
enqueue_attachment_scan,
|
||
ensure_attachment_download_allowed_or_4xx,
|
||
initial_scan_status_for_new_attachment,
|
||
)
|
||
from app.services.s3_storage import build_object_key, get_s3_storage
|
||
|
||
router = APIRouter()
|
||
|
||
AVATAR_MAX_SIZE_PX = 512
|
||
AVATAR_WEBP_QUALITY = 80
|
||
_AVATAR_RESAMPLE = getattr(getattr(Image, "Resampling", Image), "LANCZOS", 1)
|
||
|
||
|
||
def _max_file_bytes() -> int:
|
||
return int(settings.MAX_FILE_MB) * 1024 * 1024
|
||
|
||
|
||
def _max_case_bytes() -> int:
|
||
return int(settings.MAX_CASE_MB) * 1024 * 1024
|
||
|
||
|
||
def _validate_size_or_400(size_bytes: int) -> None:
|
||
if int(size_bytes or 0) <= 0:
|
||
raise HTTPException(status_code=400, detail="Некорректный размер файла")
|
||
if int(size_bytes) > _max_file_bytes():
|
||
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
|
||
|
||
|
||
def _uuid_or_400(raw: str | None, field_name: str) -> uuid.UUID:
|
||
if not raw:
|
||
raise HTTPException(status_code=400, detail=f'Поле "{field_name}" обязательно')
|
||
try:
|
||
return uuid.UUID(str(raw))
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail=f'Некорректный "{field_name}"')
|
||
|
||
|
||
def _ensure_case_capacity_or_400(request: Request, add_bytes: int) -> None:
|
||
current = int(request.total_attachments_bytes or 0)
|
||
if current + int(add_bytes) > _max_case_bytes():
|
||
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
|
||
|
||
|
||
def _ensure_object_key_prefix_or_400(key: str, prefix: str) -> None:
|
||
if not str(key or "").startswith(prefix):
|
||
raise HTTPException(status_code=400, detail="Некорректный ключ объекта для выбранной сущности")
|
||
|
||
|
||
def _parse_scoped_object_key(key: str) -> Tuple[str, str]:
|
||
raw = str(key or "").strip()
|
||
if not raw or "/" not in raw:
|
||
return "", ""
|
||
first = raw.split("/", 1)[0].strip().lower()
|
||
parts = raw.split("/")
|
||
if len(parts) < 3:
|
||
return first, ""
|
||
return first, parts[1].strip()
|
||
|
||
|
||
def _uuid_or_none(raw: str) -> uuid.UUID | None:
|
||
try:
|
||
return uuid.UUID(str(raw))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _client_ip(http_request: FastapiRequest) -> str | None:
|
||
if http_request is None:
|
||
return None
|
||
forwarded = str(http_request.headers.get("x-forwarded-for") or "").strip()
|
||
if forwarded:
|
||
first = forwarded.split(",")[0].strip()
|
||
if first:
|
||
return first
|
||
if http_request.client and http_request.client.host:
|
||
return str(http_request.client.host)
|
||
return None
|
||
|
||
|
||
def _read_object_bytes_or_400(storage, key: str) -> bytes:
|
||
try:
|
||
obj = storage.get_object(key)
|
||
except ClientError:
|
||
raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
|
||
body = obj.get("Body")
|
||
if hasattr(body, "read"):
|
||
data = body.read()
|
||
elif hasattr(body, "iter_chunks"):
|
||
data = b"".join(body.iter_chunks())
|
||
else:
|
||
raise HTTPException(status_code=500, detail="Не удалось прочитать объект из хранилища")
|
||
if isinstance(data, str):
|
||
data = data.encode("utf-8")
|
||
if not isinstance(data, (bytes, bytearray)) or not data:
|
||
raise HTTPException(status_code=400, detail="Пустой файл аватара")
|
||
return bytes(data)
|
||
|
||
|
||
def _write_object_bytes_or_500(storage, *, key: str, content: bytes, mime_type: str) -> None:
|
||
if hasattr(storage, "client") and hasattr(storage.client, "put_object") and hasattr(storage, "bucket"):
|
||
storage.client.put_object(
|
||
Bucket=storage.bucket,
|
||
Key=key,
|
||
Body=content,
|
||
ContentType=mime_type,
|
||
)
|
||
return
|
||
objects = getattr(storage, "objects", None)
|
||
if isinstance(objects, dict):
|
||
objects[key] = {
|
||
"size": int(len(content)),
|
||
"mime": str(mime_type or "application/octet-stream"),
|
||
"content": bytes(content),
|
||
}
|
||
return
|
||
raise HTTPException(status_code=500, detail="Хранилище не поддерживает запись объектов")
|
||
|
||
|
||
def _normalize_avatar_to_webp_or_400(storage, *, key: str) -> tuple[int, str]:
|
||
source = _read_object_bytes_or_400(storage, key)
|
||
try:
|
||
with Image.open(io.BytesIO(source)) as image:
|
||
image = ImageOps.exif_transpose(image)
|
||
image.load()
|
||
if max(image.size) > AVATAR_MAX_SIZE_PX:
|
||
image.thumbnail((AVATAR_MAX_SIZE_PX, AVATAR_MAX_SIZE_PX), resample=_AVATAR_RESAMPLE)
|
||
if image.mode != "RGB":
|
||
image = image.convert("RGB")
|
||
out = io.BytesIO()
|
||
image.save(out, format="WEBP", quality=AVATAR_WEBP_QUALITY, method=6)
|
||
optimized = out.getvalue()
|
||
except UnidentifiedImageError:
|
||
raise HTTPException(status_code=400, detail="Аватар должен быть изображением")
|
||
except OSError:
|
||
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
|
||
if not optimized:
|
||
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
|
||
_write_object_bytes_or_500(storage, key=key, content=optimized, mime_type="image/webp")
|
||
return int(len(optimized)), "image/webp"
|
||
|
||
|
||
@router.post("/init", response_model=UploadInitResponse)
|
||
def upload_init(
|
||
payload: UploadInitPayload,
|
||
http_request: FastapiRequest,
|
||
db: Session = Depends(get_db),
|
||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||
):
|
||
role = str(admin.get("role") or "").upper() or "UNKNOWN"
|
||
actor_id = str(admin.get("sub") or "").strip()
|
||
actor_ip = _client_ip(http_request)
|
||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)
|
||
|
||
try:
|
||
_validate_size_or_400(payload.size_bytes)
|
||
storage = get_s3_storage()
|
||
|
||
if payload.scope == UploadScope.REQUEST_ATTACHMENT:
|
||
request_uuid = _uuid_or_400(payload.request_id, "request_id")
|
||
request = db.get(Request, request_uuid)
|
||
if request is None:
|
||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||
_ensure_case_capacity_or_400(request, payload.size_bytes)
|
||
key = build_object_key(f"requests/{request.id}", payload.file_name)
|
||
response = UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
|
||
record_file_security_event(
|
||
db,
|
||
actor_role=role,
|
||
actor_subject=actor_id,
|
||
actor_ip=actor_ip,
|
||
action="UPLOAD_INIT",
|
||
scope=scope_name,
|
||
allowed=True,
|
||
object_key=key,
|
||
request_id=request.id,
|
||
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
|
||
responsible=responsible,
|
||
persist_now=True,
|
||
)
|
||
return response
|
||
|
||
if payload.scope == UploadScope.USER_AVATAR:
|
||
if not str(payload.mime_type or "").strip().lower().startswith("image/"):
|
||
raise HTTPException(status_code=400, detail="Для аватара поддерживаются только изображения")
|
||
target_user_id = str(payload.user_id or actor_id)
|
||
target_uuid = _uuid_or_400(target_user_id, "user_id")
|
||
if role != "ADMIN" and str(target_uuid) != actor_id:
|
||
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
|
||
user = db.get(AdminUser, target_uuid)
|
||
if user is None:
|
||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||
key = build_object_key(f"avatars/{user.id}", payload.file_name)
|
||
response = UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
|
||
record_file_security_event(
|
||
db,
|
||
actor_role=role,
|
||
actor_subject=actor_id,
|
||
actor_ip=actor_ip,
|
||
action="UPLOAD_INIT",
|
||
scope=scope_name,
|
||
allowed=True,
|
||
object_key=key,
|
||
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
|
||
responsible=responsible,
|
||
persist_now=True,
|
||
)
|
||
return response
|
||
|
||
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
|
||
except HTTPException as exc:
|
||
record_file_security_event(
|
||
db,
|
||
actor_role=role,
|
||
actor_subject=actor_id,
|
||
actor_ip=actor_ip,
|
||
action="UPLOAD_INIT",
|
||
scope=scope_name,
|
||
allowed=False,
|
||
reason=str(exc.detail),
|
||
request_id=_uuid_or_none(payload.request_id),
|
||
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
|
||
responsible=responsible,
|
||
persist_now=True,
|
||
)
|
||
raise
|
||
|
||
|
||
@router.post("/complete", response_model=UploadCompleteResponse)
|
||
def upload_complete(
|
||
payload: UploadCompletePayload,
|
||
http_request: FastapiRequest,
|
||
db: Session = Depends(get_db),
|
||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||
):
|
||
role = str(admin.get("role") or "").upper() or "UNKNOWN"
|
||
actor_id = str(admin.get("sub") or "")
|
||
actor_ip = _client_ip(http_request)
|
||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)
|
||
|
||
try:
|
||
_validate_size_or_400(payload.size_bytes)
|
||
storage = get_s3_storage()
|
||
try:
|
||
head = storage.head_object(payload.key)
|
||
except ClientError:
|
||
raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
|
||
|
||
actual_size = int(head.get("ContentLength") or payload.size_bytes)
|
||
if actual_size <= 0:
|
||
raise HTTPException(status_code=400, detail="Некорректный размер файла")
|
||
if actual_size > _max_file_bytes():
|
||
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
|
||
|
||
if payload.scope == UploadScope.REQUEST_ATTACHMENT:
|
||
request_uuid = _uuid_or_400(payload.request_id, "request_id")
|
||
request = db.get(Request, request_uuid)
|
||
if request is None:
|
||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/")
|
||
existing_row = (
|
||
db.query(Attachment)
|
||
.filter(Attachment.request_id == request.id, Attachment.s3_key == payload.key)
|
||
.first()
|
||
)
|
||
if existing_row is not None:
|
||
record_file_security_event(
|
||
db,
|
||
actor_role=role,
|
||
actor_subject=actor_id,
|
||
actor_ip=actor_ip,
|
||
action="UPLOAD_COMPLETE",
|
||
scope=scope_name,
|
||
allowed=True,
|
||
object_key=payload.key,
|
||
request_id=request.id,
|
||
details={
|
||
"mime_type": existing_row.mime_type,
|
||
"size_bytes": int(existing_row.size_bytes or 0),
|
||
"idempotent_replay": True,
|
||
},
|
||
responsible=responsible,
|
||
)
|
||
db.commit()
|
||
return UploadCompleteResponse(status="ok", attachment_id=str(existing_row.id))
|
||
_ensure_case_capacity_or_400(request, actual_size)
|
||
|
||
message_uuid = None
|
||
if payload.message_id:
|
||
message_uuid = _uuid_or_400(payload.message_id, "message_id")
|
||
message = db.get(Message, message_uuid)
|
||
if message is None or message.request_id != request.id:
|
||
raise HTTPException(status_code=400, detail="Сообщение не найдено для указанной заявки")
|
||
if bool(message.immutable):
|
||
raise HTTPException(status_code=400, detail="Нельзя прикрепить файл к зафиксированному сообщению")
|
||
|
||
row = Attachment(
|
||
request_id=request.id,
|
||
message_id=message_uuid,
|
||
file_name=payload.file_name,
|
||
mime_type=payload.mime_type,
|
||
size_bytes=actual_size,
|
||
s3_key=payload.key,
|
||
scan_status=initial_scan_status_for_new_attachment(),
|
||
responsible=responsible,
|
||
)
|
||
mark_unread_for_client(request, EVENT_ATTACHMENT)
|
||
notify_request_event(
|
||
db,
|
||
request=request,
|
||
event_type=NOTIFICATION_EVENT_ATTACHMENT,
|
||
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
|
||
actor_admin_user_id=admin.get("sub"),
|
||
body=f'Файл: {payload.file_name}',
|
||
responsible=responsible,
|
||
)
|
||
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size
|
||
request.responsible = responsible
|
||
db.add(row)
|
||
db.add(request)
|
||
record_file_security_event(
|
||
db,
|
||
actor_role=role,
|
||
actor_subject=actor_id,
|
||
actor_ip=actor_ip,
|
||
action="UPLOAD_COMPLETE",
|
||
scope=scope_name,
|
||
allowed=True,
|
||
object_key=payload.key,
|
||
request_id=request.id,
|
||
details={"mime_type": payload.mime_type, "size_bytes": int(actual_size)},
|
||
responsible=responsible,
|
||
)
|
||
db.commit()
|
||
db.refresh(row)
|
||
try:
|
||
enqueue_attachment_scan(str(row.id))
|
||
except Exception as exc:
|
||
row.scan_status = SCAN_STATUS_ERROR
|
||
row.scan_error = str(exc)[:500]
|
||
db.add(row)
|
||
db.commit()
|
||
return UploadCompleteResponse(status="ok", attachment_id=str(row.id))
|
||
|
||
if payload.scope == UploadScope.USER_AVATAR:
|
||
if not str(payload.mime_type or "").strip().lower().startswith("image/"):
|
||
raise HTTPException(status_code=400, detail="Для аватара поддерживаются только изображения")
|
||
target_user_id = str(payload.user_id or actor_id)
|
||
target_uuid = _uuid_or_400(target_user_id, "user_id")
|
||
if role != "ADMIN" and str(target_uuid) != actor_id:
|
||
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
|
||
user = db.get(AdminUser, target_uuid)
|
||
if user is None:
|
||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||
_ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/")
|
||
optimized_size, optimized_mime = _normalize_avatar_to_webp_or_400(storage, key=payload.key)
|
||
user.avatar_url = f"s3://{payload.key}"
|
||
user.responsible = responsible
|
||
db.add(user)
|
||
record_file_security_event(
|
||
db,
|
||
actor_role=role,
|
||
actor_subject=actor_id,
|
||
actor_ip=actor_ip,
|
||
action="UPLOAD_COMPLETE",
|
||
scope=scope_name,
|
||
allowed=True,
|
||
object_key=payload.key,
|
||
details={
|
||
"mime_type": optimized_mime,
|
||
"size_bytes": int(optimized_size),
|
||
"source_mime_type": payload.mime_type,
|
||
"source_size_bytes": int(actual_size),
|
||
},
|
||
responsible=responsible,
|
||
)
|
||
db.commit()
|
||
return UploadCompleteResponse(status="ok", avatar_url=user.avatar_url)
|
||
|
||
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
|
||
except HTTPException as exc:
|
||
record_file_security_event(
|
||
db,
|
||
actor_role=role,
|
||
actor_subject=actor_id,
|
||
actor_ip=actor_ip,
|
||
action="UPLOAD_COMPLETE",
|
||
scope=scope_name,
|
||
allowed=False,
|
||
reason=str(exc.detail),
|
||
object_key=payload.key,
|
||
request_id=_uuid_or_none(payload.request_id),
|
||
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
|
||
responsible=responsible,
|
||
persist_now=True,
|
||
)
|
||
raise
|
||
|
||
|
||
@router.get("/object/{object_key:path}")
|
||
def get_object_proxy(
|
||
object_key: str,
|
||
http_request: FastapiRequest,
|
||
token: str = Query(...),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
key = str(object_key or "").strip()
|
||
scope = "UNKNOWN"
|
||
scoped_uuid: uuid.UUID | None = None
|
||
actor_role = "UNKNOWN"
|
||
actor_subject = ""
|
||
actor_ip = _client_ip(http_request)
|
||
responsible = "Администратор системы"
|
||
|
||
try:
|
||
try:
|
||
claims = decode_jwt(token, settings.ADMIN_JWT_SECRET)
|
||
except Exception:
|
||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||
actor_role = str(claims.get("role") or "").upper()
|
||
actor_subject = str(claims.get("sub") or "").strip()
|
||
responsible = str(claims.get("email") or "").strip() or "Администратор системы"
|
||
if actor_role not in {"ADMIN", "LAWYER"}:
|
||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||
|
||
if not key:
|
||
raise HTTPException(status_code=400, detail="Некорректный ключ объекта")
|
||
|
||
scope, scoped_id_raw = _parse_scoped_object_key(key)
|
||
scoped_uuid = _uuid_or_none(scoped_id_raw)
|
||
if actor_role == "LAWYER":
|
||
actor_id = _uuid_or_none(claims.get("sub"))
|
||
if actor_id is None:
|
||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||
if scope == "avatars":
|
||
if scoped_uuid is None or scoped_uuid != actor_id:
|
||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||
elif scope == "requests":
|
||
if scoped_uuid is None:
|
||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||
# LAWYER can download files from own or unassigned requests only.
|
||
request = db.get(Request, scoped_uuid)
|
||
if request is None:
|
||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||
assigned = str(request.assigned_lawyer_id or "").strip()
|
||
if assigned and assigned != str(actor_id):
|
||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||
attachment = db.query(Attachment).filter(Attachment.s3_key == key).order_by(Attachment.created_at.desc()).first()
|
||
if attachment is None:
|
||
raise HTTPException(status_code=404, detail="Файл не найден")
|
||
ensure_attachment_download_allowed_or_4xx(attachment)
|
||
else:
|
||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||
elif scope == "requests":
|
||
attachment = db.query(Attachment).filter(Attachment.s3_key == key).order_by(Attachment.created_at.desc()).first()
|
||
if attachment is None:
|
||
raise HTTPException(status_code=404, detail="Файл не найден")
|
||
ensure_attachment_download_allowed_or_4xx(attachment)
|
||
|
||
try:
|
||
obj = get_s3_storage().get_object(key)
|
||
except ClientError:
|
||
raise HTTPException(status_code=404, detail="Файл не найден")
|
||
|
||
record_file_security_event(
|
||
db,
|
||
actor_role=actor_role,
|
||
actor_subject=actor_subject,
|
||
actor_ip=actor_ip,
|
||
action="DOWNLOAD_OBJECT",
|
||
scope=scope,
|
||
allowed=True,
|
||
object_key=key,
|
||
request_id=scoped_uuid if scope == "requests" else None,
|
||
details={},
|
||
responsible=responsible,
|
||
persist_now=True,
|
||
)
|
||
|
||
body = obj["Body"]
|
||
content_length = obj.get("ContentLength")
|
||
media_type = obj.get("ContentType") or "application/octet-stream"
|
||
headers = {}
|
||
if content_length is not None:
|
||
headers["Content-Length"] = str(content_length)
|
||
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers)
|
||
except HTTPException as exc:
|
||
record_file_security_event(
|
||
db,
|
||
actor_role=actor_role,
|
||
actor_subject=actor_subject,
|
||
actor_ip=actor_ip,
|
||
action="DOWNLOAD_OBJECT",
|
||
scope=scope,
|
||
allowed=False,
|
||
reason=str(exc.detail),
|
||
object_key=key or None,
|
||
request_id=scoped_uuid if scope == "requests" else None,
|
||
details={},
|
||
responsible=responsible,
|
||
persist_now=True,
|
||
)
|
||
raise
|