Skip to content

Commit 7e315a6

Browse files
Merge pull request #69 from Promptly-Technologies-LLC/68-validation-for-uploaded-user-profile-images
68 validation for uploaded user profile images
2 parents 6e6b13b + 8173db2 commit 7e315a6

File tree

11 files changed

+320
-18
lines changed

11 files changed

+320
-18
lines changed

docs/installation.qmd

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ If you use VSCode with Docker to develop in a container, the following VSCode De
1010
{
1111
"name": "Python 3",
1212
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm",
13-
"postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz && uv venv && uv sync",
13+
"postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz libwebp-dev && uv venv && uv sync",
1414
"features": {
1515
"ghcr.io/va-h/devcontainers-features/uv:1": {
1616
"version": "latest"
@@ -61,7 +61,7 @@ Install Docker Desktop and Docker Compose for your operating system by following
6161
For Ubuntu/Debian:
6262

6363
``` bash
64-
sudo apt update && sudo apt install -y python3-dev libpq-dev
64+
sudo apt update && sudo apt install -y python3-dev libpq-dev libwebp-dev
6565
```
6666

6767
For macOS:
@@ -165,7 +165,9 @@ Before running the development server, make sure the development database is run
165165
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
166166
```
167167

168-
Navigate to http://localhost:8000/
168+
Navigate to http://localhost:8000/.
169+
170+
(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.)
169171

170172
## Lint types with mypy
171173

index.qmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Install Docker Desktop and Coker Compose for your operating system by following
8484
For Ubuntu/Debian:
8585

8686
``` bash
87-
sudo apt update && sudo apt install -y python3-dev libpq-dev
87+
sudo apt update && sudo apt install -y python3-dev libpq-dev libwebp-dev
8888
```
8989

9090
For macOS:

main.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from utils.auth import get_user_with_relations, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError
1212
from utils.models import User, Organization
1313
from utils.db import get_session, set_up_db
14+
from utils.images import MAX_FILE_SIZE, MIN_DIMENSION, MAX_DIMENSION, ALLOWED_CONTENT_TYPES
1415

1516
logger = logging.getLogger("uvicorn.error")
1617
logger.setLevel(logging.DEBUG)
@@ -246,6 +247,13 @@ async def read_dashboard(
246247
async def read_profile(
247248
params: dict = Depends(common_authenticated_parameters)
248249
):
250+
# Add image constraints to the template context
251+
params.update({
252+
"max_file_size_mb": MAX_FILE_SIZE / (1024 * 1024), # Convert bytes to MB
253+
"min_dimension": MIN_DIMENSION,
254+
"max_dimension": MAX_DIMENSION,
255+
"allowed_formats": list(ALLOWED_CONTENT_TYPES.keys())
256+
})
249257
return templates.TemplateResponse(params["request"], "users/profile.html", params)
250258

251259

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies = [
2020
"resend<3.0.0,>=2.4.0",
2121
"bcrypt<5.0.0,>=4.2.0",
2222
"fastapi<1.0.0,>=0.115.5",
23+
"pillow>=11.0.0",
2324
]
2425

2526
[dependency-groups]

routers/user.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Optional
66
from utils.models import User, DataIntegrityError
77
from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError
8+
from utils.images import validate_and_process_image
89

910
router = APIRouter(prefix="/user", tags=["user"])
1011

@@ -61,11 +62,19 @@ async def update_profile(
6162
user: User = Depends(get_authenticated_user),
6263
session: Session = Depends(get_session)
6364
):
65+
# Handle avatar update
66+
if user_profile.avatar_file:
67+
processed_image, content_type = validate_and_process_image(
68+
user_profile.avatar_file,
69+
user_profile.avatar_content_type
70+
)
71+
user_profile.avatar_file = processed_image
72+
user_profile.avatar_content_type = content_type
73+
6474
# Update user details
6575
user.name = user_profile.name
6676
user.email = user_profile.email
67-
68-
# Handle avatar update
77+
6978
if user_profile.avatar_file:
7079
user.avatar_data = user_profile.avatar_file
7180
user.avatar_content_type = user_profile.avatar_content_type

templates/authentication/register.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
<!-- Password Input -->
2525
<div class="mb-3">
2626
<label for="password" class="form-label">Password</label>
27-
<!-- Make sure 9g,X*88w[6"W passes validation -->
27+
<!-- Make sure 9g,X*88w[6"W and ^94cPSf2^)z2^,& pass validation -->
2828
<input type="password" class="form-control" id="password" name="password"
2929
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~\/])[A-Za-z\d@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~\/]{8,}"
3030
title="Must contain at least one number, one uppercase and lowercase letter, one special character, and at least 8 or more characters"

templates/users/profile.html

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ <h1 class="mb-4">User Profile</h1>
4747
<div class="mb-3">
4848
<label for="avatar_file" class="form-label">Avatar</label>
4949
<input type="file" class="form-control" id="avatar_file" name="avatar_file" accept="image/*">
50+
<div class="form-text">
51+
<ul class="mb-0">
52+
<li>Maximum file size: {{ max_file_size_mb }} MB</li>
53+
<li>Minimum dimension: {{ min_dimension }}x{{ min_dimension }} pixels</li>
54+
<li>Maximum dimension: {{ max_dimension }}x{{ max_dimension }} pixels</li>
55+
<li>Allowed formats: {{ allowed_formats|join(', ') }}</li>
56+
<li>Image will be cropped to a square</li>
57+
</ul>
58+
</div>
5059
</div>
5160
<button type="submit" class="btn btn-primary">Save Changes</button>
5261
</form>
@@ -103,5 +112,53 @@ <h1 class="mb-4">User Profile</h1>
103112
editProfile.style.display = 'block';
104113
}
105114
}
115+
116+
document.getElementById('avatar_file').addEventListener('change', function(e) {
117+
const file = e.target.files[0];
118+
if (!file) return;
119+
120+
// Get constraints from your template variables
121+
const maxSizeMB = "{{ max_file_size_mb }}";
122+
const minDimension = "{{ min_dimension }}";
123+
const maxDimension = "{{ max_dimension }}";
124+
const allowedFormats = "{{ allowed_formats }}";
125+
126+
127+
// Check file size
128+
const fileSizeMB = file.size / (1024 * 1024);
129+
if (fileSizeMB > maxSizeMB) {
130+
alert(`File size must be less than ${maxSizeMB}MB`);
131+
this.value = '';
132+
return;
133+
}
134+
135+
// Check file format
136+
const fileFormat = file.type.split('/')[1];
137+
if (!allowedFormats.includes(fileFormat)) {
138+
alert(`File format must be one of: ${allowedFormats.join(', ')}`);
139+
this.value = '';
140+
return;
141+
}
142+
143+
// Check dimensions
144+
const img = new Image();
145+
img.src = URL.createObjectURL(file);
146+
147+
img.onload = function() {
148+
URL.revokeObjectURL(this.src);
149+
150+
if (this.width < minDimension || this.height < minDimension) {
151+
alert(`Image dimensions must be at least ${minDimension}x${minDimension} pixels`);
152+
e.target.value = '';
153+
return;
154+
}
155+
156+
if (this.width > maxDimension || this.height > maxDimension) {
157+
alert(`Image dimensions must not exceed ${maxDimension}x${maxDimension} pixels`);
158+
e.target.value = '';
159+
return;
160+
}
161+
};
162+
});
106163
</script>
107164
{% endblock %}

tests/test_images.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import pytest
2+
from PIL import Image
3+
import io
4+
from utils.images import (
5+
validate_and_process_image,
6+
InvalidImageError,
7+
MAX_FILE_SIZE,
8+
MIN_DIMENSION,
9+
MAX_DIMENSION
10+
)
11+
12+
def create_test_image(width: int, height: int, format: str = 'PNG') -> bytes:
13+
"""Helper function to create test images"""
14+
image = Image.new('RGB', (width, height), color='red')
15+
output = io.BytesIO()
16+
image.save(output, format=format)
17+
return output.getvalue()
18+
19+
def test_webp_dependencies_are_installed():
20+
"""Test that webp dependencies are installed"""
21+
assert '.webp' in Image.registered_extensions(), "WebP dependencies are not installed (e.g., libwebp-dev on Linux)"
22+
23+
def test_valid_square_image():
24+
"""Test processing a valid square image"""
25+
image_data = create_test_image(500, 500)
26+
processed_data, content_type = validate_and_process_image(image_data, 'image/png')
27+
28+
# Verify the processed image
29+
processed_image = Image.open(io.BytesIO(processed_data))
30+
assert processed_image.size == (500, 500)
31+
assert content_type == 'image/png'
32+
33+
def test_valid_rectangular_image():
34+
"""Test processing a valid rectangular image"""
35+
image_data = create_test_image(800, 600)
36+
processed_data, content_type = validate_and_process_image(image_data, 'image/png')
37+
38+
# Verify the processed image
39+
processed_image = Image.open(io.BytesIO(processed_data))
40+
assert processed_image.size == (600, 600)
41+
assert content_type == 'image/png'
42+
43+
def test_minimum_size_image():
44+
"""Test processing an image with minimum allowed dimensions"""
45+
image_data = create_test_image(MIN_DIMENSION, MIN_DIMENSION)
46+
processed_data, content_type = validate_and_process_image(image_data, 'image/png')
47+
48+
processed_image = Image.open(io.BytesIO(processed_data))
49+
assert processed_image.size == (100, 100)
50+
51+
def test_too_small_image():
52+
"""Test that too small images are rejected"""
53+
image_data = create_test_image(MIN_DIMENSION - 1, MIN_DIMENSION - 1)
54+
with pytest.raises(InvalidImageError) as exc_info:
55+
validate_and_process_image(image_data, 'image/png')
56+
assert "Image too small" in str(exc_info.value.detail)
57+
58+
def test_too_large_image():
59+
"""Test that too large images are rejected"""
60+
image_data = create_test_image(MAX_DIMENSION + 1, MAX_DIMENSION + 1)
61+
with pytest.raises(InvalidImageError) as exc_info:
62+
validate_and_process_image(image_data, 'image/png')
63+
assert "Image too large" in str(exc_info.value.detail)
64+
65+
def test_invalid_file_type():
66+
"""Test that invalid file types are rejected"""
67+
image_data = create_test_image(500, 500)
68+
with pytest.raises(InvalidImageError) as exc_info:
69+
validate_and_process_image(image_data, 'image/gif')
70+
assert "Invalid file type" in str(exc_info.value.detail)
71+
72+
def test_file_too_large():
73+
"""Test that files exceeding MAX_FILE_SIZE are rejected"""
74+
# Create a large file that exceeds MAX_FILE_SIZE
75+
large_image_data = b'0' * (MAX_FILE_SIZE + 1)
76+
with pytest.raises(InvalidImageError) as exc_info:
77+
validate_and_process_image(large_image_data, 'image/png')
78+
assert "File too large" in str(exc_info.value.detail)
79+
80+
def test_corrupt_image_data():
81+
"""Test that corrupt image data is rejected"""
82+
corrupt_data = b'not an image'
83+
with pytest.raises(InvalidImageError) as exc_info:
84+
validate_and_process_image(corrupt_data, 'image/png')
85+
assert "Invalid image file" in str(exc_info.value.detail)
86+
87+
def test_different_image_formats():
88+
"""Test processing different valid image formats"""
89+
formats = [
90+
('JPEG', 'image/jpeg'),
91+
('PNG', 'image/png'),
92+
('WEBP', 'image/webp')
93+
]
94+
95+
for format_name, content_type in formats:
96+
image_data = create_test_image(500, 500, format_name)
97+
processed_data, result_type = validate_and_process_image(image_data, content_type)
98+
99+
# Verify the processed image
100+
processed_image = Image.open(io.BytesIO(processed_data))
101+
assert processed_image.size == (500, 500)
102+
# Output should match input format
103+
assert result_type == content_type

tests/test_user.py

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
from fastapi.testclient import TestClient
22
from httpx import Response
33
from sqlmodel import Session
4+
from unittest.mock import patch
45

56
from main import app
67
from utils.models import User
8+
from utils.images import InvalidImageError
9+
10+
# Mock data for consistent testing
11+
MOCK_IMAGE_DATA = b"processed fake image data"
12+
MOCK_CONTENT_TYPE = "image/png"
713

814

915
def test_update_profile_unauthorized(unauth_client: TestClient):
@@ -23,11 +29,12 @@ def test_update_profile_unauthorized(unauth_client: TestClient):
2329
assert response.headers["location"] == app.url_path_for("read_login")
2430

2531

26-
def test_update_profile_authorized(auth_client: TestClient, test_user: User, session: Session):
32+
@patch('routers.user.validate_and_process_image')
33+
def test_update_profile_authorized(mock_validate, auth_client: TestClient, test_user: User, session: Session):
2734
"""Test that authorized users can edit their profile"""
2835

29-
# Create test image data
30-
test_image_data = b"fake image data"
36+
# Configure mock to return processed image data
37+
mock_validate.return_value = (MOCK_IMAGE_DATA, MOCK_CONTENT_TYPE)
3138

3239
# Update profile
3340
response: Response = auth_client.post(
@@ -37,7 +44,7 @@ def test_update_profile_authorized(auth_client: TestClient, test_user: User, ses
3744
"email": "[email protected]",
3845
},
3946
files={
40-
"avatar_file": ("test_avatar.jpg", test_image_data, "image/jpeg")
47+
"avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg")
4148
},
4249
follow_redirects=False
4350
)
@@ -48,8 +55,11 @@ def test_update_profile_authorized(auth_client: TestClient, test_user: User, ses
4855
session.refresh(test_user)
4956
assert test_user.name == "Updated Name"
5057
assert test_user.email == "[email protected]"
51-
assert test_user.avatar_data == test_image_data
52-
assert test_user.avatar_content_type == "image/jpeg"
58+
assert test_user.avatar_data == MOCK_IMAGE_DATA
59+
assert test_user.avatar_content_type == MOCK_CONTENT_TYPE
60+
61+
# Verify mock was called correctly
62+
mock_validate.assert_called_once()
5363

5464

5565
def test_update_profile_without_avatar(auth_client: TestClient, test_user: User, session: Session):
@@ -110,18 +120,21 @@ def test_delete_account_success(auth_client: TestClient, test_user: User, sessio
110120
assert user is None
111121

112122

113-
def test_get_avatar_authorized(auth_client: TestClient, test_user: User):
123+
@patch('routers.user.validate_and_process_image')
124+
def test_get_avatar_authorized(mock_validate, auth_client: TestClient, test_user: User):
114125
"""Test getting user avatar"""
126+
# Configure mock to return processed image data
127+
mock_validate.return_value = (MOCK_IMAGE_DATA, MOCK_CONTENT_TYPE)
128+
115129
# First upload an avatar
116-
test_image_data = b"fake image data"
117130
auth_client.post(
118131
app.url_path_for("update_profile"),
119132
data={
120133
"name": test_user.name,
121134
"email": test_user.email,
122135
},
123136
files={
124-
"avatar_file": ("test_avatar.jpg", test_image_data, "image/jpeg")
137+
"avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg")
125138
},
126139
)
127140

@@ -130,8 +143,8 @@ def test_get_avatar_authorized(auth_client: TestClient, test_user: User):
130143
app.url_path_for("get_avatar")
131144
)
132145
assert response.status_code == 200
133-
assert response.content == test_image_data
134-
assert response.headers["content-type"] == "image/jpeg"
146+
assert response.content == MOCK_IMAGE_DATA
147+
assert response.headers["content-type"] == MOCK_CONTENT_TYPE
135148

136149

137150
def test_get_avatar_unauthorized(unauth_client: TestClient):
@@ -142,3 +155,24 @@ def test_get_avatar_unauthorized(unauth_client: TestClient):
142155
)
143156
assert response.status_code == 303
144157
assert response.headers["location"] == app.url_path_for("read_login")
158+
159+
160+
# Add new test for invalid image
161+
@patch('routers.user.validate_and_process_image')
162+
def test_update_profile_invalid_image(mock_validate, auth_client: TestClient):
163+
"""Test that invalid images are rejected"""
164+
# Configure mock to raise InvalidImageError
165+
mock_validate.side_effect = InvalidImageError("Invalid test image")
166+
167+
response: Response = auth_client.post(
168+
app.url_path_for("update_profile"),
169+
data={
170+
"name": "Updated Name",
171+
"email": "[email protected]",
172+
},
173+
files={
174+
"avatar_file": ("test_avatar.jpg", b"invalid image data", "image/jpeg")
175+
},
176+
)
177+
assert response.status_code == 400
178+
assert "Invalid test image" in response.text

0 commit comments

Comments
 (0)