diff --git a/docs/installation.qmd b/docs/installation.qmd index e85c13b..378a9ae 100644 --- a/docs/installation.qmd +++ b/docs/installation.qmd @@ -10,7 +10,7 @@ If you use VSCode with Docker to develop in a container, the following VSCode De { "name": "Python 3", "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm", - "postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz && uv venv && uv sync", + "postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz libwebp-dev && uv venv && uv sync", "features": { "ghcr.io/va-h/devcontainers-features/uv:1": { "version": "latest" @@ -61,7 +61,7 @@ Install Docker Desktop and Docker Compose for your operating system by following For Ubuntu/Debian: ``` bash -sudo apt update && sudo apt install -y python3-dev libpq-dev +sudo apt update && sudo apt install -y python3-dev libpq-dev libwebp-dev ``` For macOS: @@ -165,7 +165,9 @@ Before running the development server, make sure the development database is run uvicorn main:app --host 0.0.0.0 --port 8000 --reload ``` -Navigate to http://localhost:8000/ +Navigate to http://localhost:8000/. + +(Note: If startup fails with a sqlalchemy/psycopg2 connection error, make sure that Docker Desktop and the database service are running and that the environment variables in the `.env` file are correctly populated, and then try again.) ## Lint types with mypy diff --git a/index.qmd b/index.qmd index 2b63ad7..6b51fb7 100644 --- a/index.qmd +++ b/index.qmd @@ -84,7 +84,7 @@ Install Docker Desktop and Coker Compose for your operating system by following For Ubuntu/Debian: ``` bash -sudo apt update && sudo apt install -y python3-dev libpq-dev +sudo apt update && sudo apt install -y python3-dev libpq-dev libwebp-dev ``` For macOS: diff --git a/main.py b/main.py index 9ca359e..9ec7448 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,7 @@ from utils.auth import get_user_with_relations, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError from utils.models import User, Organization from utils.db import get_session, set_up_db +from utils.images import MAX_FILE_SIZE, MIN_DIMENSION, MAX_DIMENSION, ALLOWED_CONTENT_TYPES logger = logging.getLogger("uvicorn.error") logger.setLevel(logging.DEBUG) @@ -246,6 +247,13 @@ async def read_dashboard( async def read_profile( params: dict = Depends(common_authenticated_parameters) ): + # Add image constraints to the template context + params.update({ + "max_file_size_mb": MAX_FILE_SIZE / (1024 * 1024), # Convert bytes to MB + "min_dimension": MIN_DIMENSION, + "max_dimension": MAX_DIMENSION, + "allowed_formats": list(ALLOWED_CONTENT_TYPES.keys()) + }) return templates.TemplateResponse(params["request"], "users/profile.html", params) diff --git a/pyproject.toml b/pyproject.toml index 1865278..d91deab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "resend<3.0.0,>=2.4.0", "bcrypt<5.0.0,>=4.2.0", "fastapi<1.0.0,>=0.115.5", + "pillow>=11.0.0", ] [dependency-groups] diff --git a/routers/user.py b/routers/user.py index 504d8d1..1baf82a 100644 --- a/routers/user.py +++ b/routers/user.py @@ -5,6 +5,7 @@ from typing import Optional from utils.models import User, DataIntegrityError from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError +from utils.images import validate_and_process_image router = APIRouter(prefix="/user", tags=["user"]) @@ -61,11 +62,19 @@ async def update_profile( user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): + # Handle avatar update + if user_profile.avatar_file: + processed_image, content_type = validate_and_process_image( + user_profile.avatar_file, + user_profile.avatar_content_type + ) + user_profile.avatar_file = processed_image + user_profile.avatar_content_type = content_type + # Update user details user.name = user_profile.name user.email = user_profile.email - - # Handle avatar update + if user_profile.avatar_file: user.avatar_data = user_profile.avatar_file user.avatar_content_type = user_profile.avatar_content_type diff --git a/templates/authentication/register.html b/templates/authentication/register.html index e508fd1..ea84753 100644 --- a/templates/authentication/register.html +++ b/templates/authentication/register.html @@ -24,7 +24,7 @@
- + User Profile
+
+
    +
  • Maximum file size: {{ max_file_size_mb }} MB
  • +
  • Minimum dimension: {{ min_dimension }}x{{ min_dimension }} pixels
  • +
  • Maximum dimension: {{ max_dimension }}x{{ max_dimension }} pixels
  • +
  • Allowed formats: {{ allowed_formats|join(', ') }}
  • +
  • Image will be cropped to a square
  • +
+
@@ -103,5 +112,53 @@

User Profile

editProfile.style.display = 'block'; } } + + document.getElementById('avatar_file').addEventListener('change', function(e) { + const file = e.target.files[0]; + if (!file) return; + + // Get constraints from your template variables + const maxSizeMB = "{{ max_file_size_mb }}"; + const minDimension = "{{ min_dimension }}"; + const maxDimension = "{{ max_dimension }}"; + const allowedFormats = "{{ allowed_formats }}"; + + + // Check file size + const fileSizeMB = file.size / (1024 * 1024); + if (fileSizeMB > maxSizeMB) { + alert(`File size must be less than ${maxSizeMB}MB`); + this.value = ''; + return; + } + + // Check file format + const fileFormat = file.type.split('/')[1]; + if (!allowedFormats.includes(fileFormat)) { + alert(`File format must be one of: ${allowedFormats.join(', ')}`); + this.value = ''; + return; + } + + // Check dimensions + const img = new Image(); + img.src = URL.createObjectURL(file); + + img.onload = function() { + URL.revokeObjectURL(this.src); + + if (this.width < minDimension || this.height < minDimension) { + alert(`Image dimensions must be at least ${minDimension}x${minDimension} pixels`); + e.target.value = ''; + return; + } + + if (this.width > maxDimension || this.height > maxDimension) { + alert(`Image dimensions must not exceed ${maxDimension}x${maxDimension} pixels`); + e.target.value = ''; + return; + } + }; + }); {% endblock %} diff --git a/tests/test_images.py b/tests/test_images.py new file mode 100644 index 0000000..e4e80d2 --- /dev/null +++ b/tests/test_images.py @@ -0,0 +1,103 @@ +import pytest +from PIL import Image +import io +from utils.images import ( + validate_and_process_image, + InvalidImageError, + MAX_FILE_SIZE, + MIN_DIMENSION, + MAX_DIMENSION +) + +def create_test_image(width: int, height: int, format: str = 'PNG') -> bytes: + """Helper function to create test images""" + image = Image.new('RGB', (width, height), color='red') + output = io.BytesIO() + image.save(output, format=format) + return output.getvalue() + +def test_webp_dependencies_are_installed(): + """Test that webp dependencies are installed""" + assert '.webp' in Image.registered_extensions(), "WebP dependencies are not installed (e.g., libwebp-dev on Linux)" + +def test_valid_square_image(): + """Test processing a valid square image""" + image_data = create_test_image(500, 500) + processed_data, content_type = validate_and_process_image(image_data, 'image/png') + + # Verify the processed image + processed_image = Image.open(io.BytesIO(processed_data)) + assert processed_image.size == (500, 500) + assert content_type == 'image/png' + +def test_valid_rectangular_image(): + """Test processing a valid rectangular image""" + image_data = create_test_image(800, 600) + processed_data, content_type = validate_and_process_image(image_data, 'image/png') + + # Verify the processed image + processed_image = Image.open(io.BytesIO(processed_data)) + assert processed_image.size == (600, 600) + assert content_type == 'image/png' + +def test_minimum_size_image(): + """Test processing an image with minimum allowed dimensions""" + image_data = create_test_image(MIN_DIMENSION, MIN_DIMENSION) + processed_data, content_type = validate_and_process_image(image_data, 'image/png') + + processed_image = Image.open(io.BytesIO(processed_data)) + assert processed_image.size == (100, 100) + +def test_too_small_image(): + """Test that too small images are rejected""" + image_data = create_test_image(MIN_DIMENSION - 1, MIN_DIMENSION - 1) + with pytest.raises(InvalidImageError) as exc_info: + validate_and_process_image(image_data, 'image/png') + assert "Image too small" in str(exc_info.value.detail) + +def test_too_large_image(): + """Test that too large images are rejected""" + image_data = create_test_image(MAX_DIMENSION + 1, MAX_DIMENSION + 1) + with pytest.raises(InvalidImageError) as exc_info: + validate_and_process_image(image_data, 'image/png') + assert "Image too large" in str(exc_info.value.detail) + +def test_invalid_file_type(): + """Test that invalid file types are rejected""" + image_data = create_test_image(500, 500) + with pytest.raises(InvalidImageError) as exc_info: + validate_and_process_image(image_data, 'image/gif') + assert "Invalid file type" in str(exc_info.value.detail) + +def test_file_too_large(): + """Test that files exceeding MAX_FILE_SIZE are rejected""" + # Create a large file that exceeds MAX_FILE_SIZE + large_image_data = b'0' * (MAX_FILE_SIZE + 1) + with pytest.raises(InvalidImageError) as exc_info: + validate_and_process_image(large_image_data, 'image/png') + assert "File too large" in str(exc_info.value.detail) + +def test_corrupt_image_data(): + """Test that corrupt image data is rejected""" + corrupt_data = b'not an image' + with pytest.raises(InvalidImageError) as exc_info: + validate_and_process_image(corrupt_data, 'image/png') + assert "Invalid image file" in str(exc_info.value.detail) + +def test_different_image_formats(): + """Test processing different valid image formats""" + formats = [ + ('JPEG', 'image/jpeg'), + ('PNG', 'image/png'), + ('WEBP', 'image/webp') + ] + + for format_name, content_type in formats: + image_data = create_test_image(500, 500, format_name) + processed_data, result_type = validate_and_process_image(image_data, content_type) + + # Verify the processed image + processed_image = Image.open(io.BytesIO(processed_data)) + assert processed_image.size == (500, 500) + # Output should match input format + assert result_type == content_type diff --git a/tests/test_user.py b/tests/test_user.py index 674a1c1..47fb9fe 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,9 +1,15 @@ from fastapi.testclient import TestClient from httpx import Response from sqlmodel import Session +from unittest.mock import patch from main import app from utils.models import User +from utils.images import InvalidImageError + +# Mock data for consistent testing +MOCK_IMAGE_DATA = b"processed fake image data" +MOCK_CONTENT_TYPE = "image/png" def test_update_profile_unauthorized(unauth_client: TestClient): @@ -23,11 +29,12 @@ def test_update_profile_unauthorized(unauth_client: TestClient): assert response.headers["location"] == app.url_path_for("read_login") -def test_update_profile_authorized(auth_client: TestClient, test_user: User, session: Session): +@patch('routers.user.validate_and_process_image') +def test_update_profile_authorized(mock_validate, auth_client: TestClient, test_user: User, session: Session): """Test that authorized users can edit their profile""" - # Create test image data - test_image_data = b"fake image data" + # Configure mock to return processed image data + mock_validate.return_value = (MOCK_IMAGE_DATA, MOCK_CONTENT_TYPE) # Update profile response: Response = auth_client.post( @@ -37,7 +44,7 @@ def test_update_profile_authorized(auth_client: TestClient, test_user: User, ses "email": "updated@example.com", }, files={ - "avatar_file": ("test_avatar.jpg", test_image_data, "image/jpeg") + "avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg") }, follow_redirects=False ) @@ -48,8 +55,11 @@ def test_update_profile_authorized(auth_client: TestClient, test_user: User, ses session.refresh(test_user) assert test_user.name == "Updated Name" assert test_user.email == "updated@example.com" - assert test_user.avatar_data == test_image_data - assert test_user.avatar_content_type == "image/jpeg" + assert test_user.avatar_data == MOCK_IMAGE_DATA + assert test_user.avatar_content_type == MOCK_CONTENT_TYPE + + # Verify mock was called correctly + mock_validate.assert_called_once() def test_update_profile_without_avatar(auth_client: TestClient, test_user: User, session: Session): @@ -110,10 +120,13 @@ def test_delete_account_success(auth_client: TestClient, test_user: User, sessio assert user is None -def test_get_avatar_authorized(auth_client: TestClient, test_user: User): +@patch('routers.user.validate_and_process_image') +def test_get_avatar_authorized(mock_validate, auth_client: TestClient, test_user: User): """Test getting user avatar""" + # Configure mock to return processed image data + mock_validate.return_value = (MOCK_IMAGE_DATA, MOCK_CONTENT_TYPE) + # First upload an avatar - test_image_data = b"fake image data" auth_client.post( app.url_path_for("update_profile"), data={ @@ -121,7 +134,7 @@ def test_get_avatar_authorized(auth_client: TestClient, test_user: User): "email": test_user.email, }, files={ - "avatar_file": ("test_avatar.jpg", test_image_data, "image/jpeg") + "avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg") }, ) @@ -130,8 +143,8 @@ def test_get_avatar_authorized(auth_client: TestClient, test_user: User): app.url_path_for("get_avatar") ) assert response.status_code == 200 - assert response.content == test_image_data - assert response.headers["content-type"] == "image/jpeg" + assert response.content == MOCK_IMAGE_DATA + assert response.headers["content-type"] == MOCK_CONTENT_TYPE def test_get_avatar_unauthorized(unauth_client: TestClient): @@ -142,3 +155,24 @@ def test_get_avatar_unauthorized(unauth_client: TestClient): ) assert response.status_code == 303 assert response.headers["location"] == app.url_path_for("read_login") + + +# Add new test for invalid image +@patch('routers.user.validate_and_process_image') +def test_update_profile_invalid_image(mock_validate, auth_client: TestClient): + """Test that invalid images are rejected""" + # Configure mock to raise InvalidImageError + mock_validate.side_effect = InvalidImageError("Invalid test image") + + response: Response = auth_client.post( + app.url_path_for("update_profile"), + data={ + "name": "Updated Name", + "email": "updated@example.com", + }, + files={ + "avatar_file": ("test_avatar.jpg", b"invalid image data", "image/jpeg") + }, + ) + assert response.status_code == 400 + assert "Invalid test image" in response.text diff --git a/utils/images.py b/utils/images.py new file mode 100644 index 0000000..67bfaf4 --- /dev/null +++ b/utils/images.py @@ -0,0 +1,86 @@ +# utils/images.py +from fastapi import HTTPException +from PIL import Image +import io +from typing import Tuple + +# Constants +MAX_FILE_SIZE = 2 * 1024 * 1024 # 2MB in bytes +ALLOWED_CONTENT_TYPES = { + 'image/jpeg': 'JPEG', + 'image/png': 'PNG', + 'image/webp': 'WEBP' +} +MIN_DIMENSION = 100 +MAX_DIMENSION = 2000 + + +class InvalidImageError(HTTPException): + """Raised when an invalid image is uploaded""" + + def __init__(self, message: str = "Invalid image file"): + super().__init__(status_code=400, detail=message) + + +def validate_and_process_image( + image_data: bytes, + content_type: str | None +) -> Tuple[bytes, str]: + """ + Validates and processes an image file. + Returns a tuple of (processed_image_data, content_type). + Ensures the image is square by center-cropping. + + Raises: + InvalidImageError: If the image is invalid or doesn't meet requirements + """ + # Check file size + if len(image_data) > MAX_FILE_SIZE: + raise InvalidImageError( + message="File too large (max 2MB)" + ) + + # Check file type + if not content_type or content_type not in ALLOWED_CONTENT_TYPES: + raise InvalidImageError( + message="Invalid file type. Must be JPEG, PNG, or WebP" + ) + + try: + # Open and validate image + image: Image.Image = Image.open(io.BytesIO(image_data)) + width, height = image.size + except Exception as e: + raise InvalidImageError( + message="Invalid image file" + ) + + # Check minimum dimensions + if width < MIN_DIMENSION or height < MIN_DIMENSION: + raise InvalidImageError( + message=f"Image too small. Minimum dimension is {MIN_DIMENSION}px" + ) + + # Check maximum dimensions + if width > MAX_DIMENSION or height > MAX_DIMENSION: + raise InvalidImageError( + message=f"Image too large. Maximum dimension is {MAX_DIMENSION}px" + ) + + # Crop to square + min_dim = min(width, height) + left = (width - min_dim) // 2 + top = (height - min_dim) // 2 + right = left + min_dim + bottom = top + min_dim + + image = image.crop((left, top, right, bottom)) + + # Get the format from the content type + output_format = ALLOWED_CONTENT_TYPES[content_type] + + # Convert back to bytes + output = io.BytesIO() + image.save(output, format=output_format) + output.seek(0) + return output.getvalue(), content_type diff --git a/uv.lock b/uv.lock index 9d54f78..e749a85 100644 --- a/uv.lock +++ b/uv.lock @@ -371,6 +371,7 @@ dependencies = [ { name = "bcrypt" }, { name = "fastapi" }, { name = "jinja2" }, + { name = "pillow" }, { name = "psycopg2" }, { name = "pydantic", extra = ["email"] }, { name = "pyjwt" }, @@ -397,6 +398,7 @@ requires-dist = [ { name = "bcrypt", specifier = ">=4.2.0,<5.0.0" }, { name = "fastapi", specifier = ">=0.115.5,<1.0.0" }, { name = "jinja2", specifier = ">=3.1.4,<4.0.0" }, + { name = "pillow", specifier = ">=11.0.0" }, { name = "psycopg2", specifier = ">=2.9.10,<3.0.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.9.2,<3.0.0" }, { name = "pyjwt", specifier = ">=2.10.1,<3.0.0" },