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" },