Validate screenshot MIME and filename metadata

This commit is contained in:
ik
2026-09-03 08:17:00 +07:00
parent b73580c720
commit a7bfc7ee8d
5 changed files with 25 additions and 5 deletions
+1 -1
View File
@@ -182,7 +182,7 @@ def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File()) ->
raise HTTPException(status_code=409, detail="screenshot already uploaded")
raw = screenshot.file.read(settings.screenshot_max_bytes + 1)
try:
report.screenshot_key = upload_screenshot(raw)
report.screenshot_key = upload_screenshot(raw, filename=screenshot.filename, content_type=screenshot.content_type)
except ScreenshotError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
db.commit()
+10 -1
View File
@@ -17,6 +17,14 @@ class ScreenshotError(ValueError):
ALLOWED_FORMATS = {"JPEG": ("jpg", "image/jpeg"), "PNG": ("png", "image/png"), "WEBP": ("webp", "image/webp")}
ALLOWED_UPLOADS = {"image/jpeg": {".jpg", ".jpeg"}, "image/png": {".png"}, "image/webp": {".webp"}}
def validate_upload_metadata(filename: str | None, content_type: str | None) -> None:
mime = (content_type or "").lower()
suffix = f".{(filename or '').rsplit('.', 1)[-1].lower()}" if "." in (filename or "") else ""
if mime not in ALLOWED_UPLOADS or suffix not in ALLOWED_UPLOADS[mime]:
raise ScreenshotError("filename extension and MIME type must match JPEG, PNG or WebP")
@lru_cache
@@ -50,7 +58,8 @@ def prepare_image(raw: bytes) -> tuple[bytes, str, str]:
raise ScreenshotError("file is not a valid image") from exc
def upload_screenshot(raw: bytes) -> str:
def upload_screenshot(raw: bytes, *, filename: str | None = None, content_type: str | None = None) -> str:
validate_upload_metadata(filename, content_type)
body, extension, mime = prepare_image(raw)
key = f"reports/{uuid.uuid4()}.{extension}"
s3 = client()
+1 -1
View File
@@ -118,7 +118,7 @@ def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None:
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 91, "y": 92, "weight_g": 4200}).json()
monkeypatch.setattr("app.main.upload_screenshot", lambda raw: "reports/test.jpg" if raw == b"image-bytes" else "unexpected")
monkeypatch.setattr("app.main.upload_screenshot", lambda raw, **metadata: "reports/test.jpg" if raw == b"image-bytes" and metadata == {"filename": "catch.jpg", "content_type": "image/jpeg"} else "unexpected")
response = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
assert response.status_code == 204
duplicate = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
+12 -1
View File
@@ -5,7 +5,7 @@ import io
import pytest
from PIL import Image
from app.storage import ScreenshotError, prepare_image
from app.storage import ScreenshotError, prepare_image, validate_upload_metadata
def test_prepare_image_removes_metadata() -> None:
@@ -26,3 +26,14 @@ def test_prepare_image_removes_metadata() -> None:
def test_prepare_image_rejects_non_image() -> None:
with pytest.raises(ScreenshotError, match="valid image"):
prepare_image(b"not an image")
def test_upload_metadata_requires_matching_supported_mime_and_extension() -> None:
validate_upload_metadata("catch.jpeg", "image/jpeg")
validate_upload_metadata("catch.webp", "image/webp")
with pytest.raises(ScreenshotError, match="extension and MIME"):
validate_upload_metadata("catch.exe", "image/jpeg")
with pytest.raises(ScreenshotError, match="extension and MIME"):
validate_upload_metadata("catch.png", "image/jpeg")
with pytest.raises(ScreenshotError, match="extension and MIME"):
validate_upload_metadata("catch.jpg", "application/octet-stream")
+1 -1
View File
@@ -33,7 +33,7 @@
- [x] Показать скриншот, данные улова и причину решения; реализовать действия «одобрить» и «отклонить» (проверено в браузере на desktop и 390 px).
- [x] Добавить удаление пользовательского сообщения администратором с аудитом действия (обезличивание записи, удаление объекта MinIO, миграция `0006`).
- [x] Заменить in-memory rate limit на общее хранилище, пригодное для нескольких API-процессов и перезапусков (PostgreSQL, HMAC-отпечаток без хранения исходного IP, миграция `0007`).
- [ ] Валидировать одновременно содержимое, MIME, расширение и лимит изображения; добавить тесты каждого отказа.
- [x] Валидировать одновременно содержимое, MIME, расширение и лимит изображения; добавить тесты каждого отказа.
- [ ] Добавить сквозной тест: отправка → pending → модерация → появление одобренного улова в публичной статистике.
- [ ] Добавить понятные состояния успеха и ошибок загрузки в форму, включая отдельную ошибку скриншота без потери уже созданной заявки.