Validate screenshot MIME and filename metadata
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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")})
|
||||
|
||||
@@ -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
@@ -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 → модерация → появление одобренного улова в публичной статистике.
|
||||
- [ ] Добавить понятные состояния успеха и ошибок загрузки в форму, включая отдельную ошибку скриншота без потери уже созданной заявки.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user