API - fix error message on file uplaod + refactor - #72

This commit is contained in:
Sam 2021-02-20 21:37:31 +01:00
parent aa1170f1c7
commit bb8491f84d
10 changed files with 306 additions and 113 deletions

View File

@ -11,6 +11,21 @@ def get_empty_data_for_datatype(data_type: str) -> Union[str, List]:
return '' if data_type in ['gpx', 'chart_data'] else [] return '' if data_type in ['gpx', 'chart_data'] else []
def display_readable_file_size(size_in_bytes: Union[float, int]) -> str:
"""
Return readable file size from size in bytes
"""
if size_in_bytes == 0:
return '0 bytes'
if size_in_bytes == 1:
return '1 byte'
for unit in [' bytes', 'KB', 'MB', 'GB', 'TB']:
if abs(size_in_bytes) < 1024.0:
return f'{size_in_bytes:3.1f}{unit}'
size_in_bytes /= 1024.0
return f'{size_in_bytes} bytes'
class HttpResponse(Response): class HttpResponse(Response):
def __init__( def __init__(
self, self,
@ -106,7 +121,19 @@ class DataNotFoundErrorResponse(HttpResponse):
class PayloadTooLargeErrorResponse(GenericErrorResponse): class PayloadTooLargeErrorResponse(GenericErrorResponse):
def __init__(self, message: str) -> None: def __init__(
self, file_type: str, file_size: Optional[int], max_size: Optional[int]
) -> None:
readable_file_size = (
f'({display_readable_file_size(file_size)}) ' if file_size else ''
)
readable_max_size = (
display_readable_file_size(max_size) if max_size else 'limit'
)
message = (
f'Error during {file_type} upload, file size {readable_file_size}'
f'exceeds {readable_max_size}.'
)
super().__init__(status_code=413, message=message, status='fail') super().__init__(status_code=413, message=message, status='fail')

View File

@ -278,9 +278,9 @@ class TestUpdateConfig:
) in data['message'] ) in data['message']
def test_it_raises_error_if_archive_max_size_equals_0( def test_it_raises_error_if_archive_max_size_equals_0(
self, app_with_max_single_file_size: Flask, user_1_admin: User self, app_with_max_file_size_equals_0: Flask, user_1_admin: User
) -> None: ) -> None:
client = app_with_max_single_file_size.test_client() client = app_with_max_file_size_equals_0.test_client()
resp_login = client.post( resp_login = client.post(
'/api/auth/login', '/api/auth/login',
data=json.dumps( data=json.dumps(

View File

@ -1,5 +1,5 @@
import os import os
from typing import Generator, Optional from typing import Generator, Optional, Union
import pytest import pytest
@ -11,17 +11,22 @@ from fittrackee.application.utils import update_app_config_from_database
def get_app_config( def get_app_config(
with_config: Optional[bool] = False, with_config: Optional[bool] = False,
max_workouts: Optional[int] = None, max_workouts: Optional[int] = None,
max_single_file_size: Optional[int] = None, max_single_file_size: Optional[Union[int, float]] = None,
max_zip_file_size: Optional[Union[int, float]] = None,
) -> Optional[AppConfig]: ) -> Optional[AppConfig]:
if with_config: if with_config:
config = AppConfig() config = AppConfig()
config.gpx_limit_import = 10 if max_workouts is None else max_workouts config.gpx_limit_import = 10 if max_workouts is None else max_workouts
config.max_single_file_size = ( config.max_single_file_size = (
1 * 1024 * 1024 (1 if max_single_file_size is None else max_single_file_size)
if max_single_file_size is None * 1024
else max_single_file_size * 1024
)
config.max_zip_file_size = (
(10 if max_zip_file_size is None else max_zip_file_size)
* 1024
* 1024
) )
config.max_zip_file_size = 1 * 1024 * 1024 * 10
config.max_users = 100 config.max_users = 100
db.session.add(config) db.session.add(config)
db.session.commit() db.session.commit()
@ -32,13 +37,19 @@ def get_app_config(
def get_app( def get_app(
with_config: Optional[bool] = False, with_config: Optional[bool] = False,
max_workouts: Optional[int] = None, max_workouts: Optional[int] = None,
max_single_file_size: Optional[int] = None, max_single_file_size: Optional[Union[int, float]] = None,
max_zip_file_size: Optional[Union[int, float]] = None,
) -> Generator: ) -> Generator:
app = create_app() app = create_app()
with app.app_context(): with app.app_context():
try: try:
db.create_all() db.create_all()
app_db_config = get_app_config(with_config, max_workouts) app_db_config = get_app_config(
with_config,
max_workouts,
max_single_file_size,
max_zip_file_size,
)
if app_db_config: if app_db_config:
update_app_config_from_database(app, app_db_config) update_app_config_from_database(app, app_db_config)
yield app yield app
@ -71,13 +82,25 @@ def app_with_max_workouts(monkeypatch: pytest.MonkeyPatch) -> Generator:
@pytest.fixture @pytest.fixture
def app_with_max_single_file_size( def app_with_max_file_size_equals_0(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> Generator: ) -> Generator:
monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025') monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025')
yield from get_app(with_config=True, max_single_file_size=0) yield from get_app(with_config=True, max_single_file_size=0)
@pytest.fixture
def app_with_max_file_size(monkeypatch: pytest.MonkeyPatch) -> Generator:
monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025')
yield from get_app(with_config=True, max_single_file_size=0.001)
@pytest.fixture
def app_with_max_zip_file_size(monkeypatch: pytest.MonkeyPatch) -> Generator:
monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025')
yield from get_app(with_config=True, max_zip_file_size=0.001)
@pytest.fixture @pytest.fixture
def app_no_config() -> Generator: def app_no_config() -> Generator:
yield from get_app(with_config=False) yield from get_app(with_config=False)

View File

@ -3,10 +3,8 @@ from uuid import uuid4
import pytest import pytest
from fittrackee.users.utils import ( from fittrackee.responses import display_readable_file_size
display_readable_file_size, from fittrackee.utils import get_readable_duration
get_readable_duration,
)
class TestDisplayReadableFileSize: class TestDisplayReadableFileSize:

View File

@ -851,6 +851,76 @@ class TestUserPicture:
assert data['message'] == 'File extension not allowed.' assert data['message'] == 'File extension not allowed.'
assert response.status_code == 400 assert response.status_code == 400
def test_it_returns_error_if_image_size_exceeds_file_limit(
self,
app_with_max_file_size: Flask,
user_1: User,
sport_1_cycling: Sport,
gpx_file: str,
) -> None:
client = app_with_max_file_size.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.post(
'/api/auth/picture',
data=dict(
file=(BytesIO(b'test_file_for_avatar' * 50), 'avatar.jpg')
),
headers=dict(
content_type='multipart/form-data',
authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
data = json.loads(response.data.decode())
print('data', data)
assert response.status_code == 413
assert 'fail' in data['status']
assert (
'Error during picture upload, file size (1.2KB) exceeds 1.0KB.'
in data['message']
)
assert 'data' not in data
def test_it_returns_error_if_image_size_exceeds_archive_limit(
self,
app_with_max_zip_file_size: Flask,
user_1: User,
sport_1_cycling: Sport,
gpx_file: str,
) -> None:
client = app_with_max_zip_file_size.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.post(
'/api/auth/picture',
data=dict(
file=(BytesIO(b'test_file_for_avatar' * 50), 'avatar.jpg')
),
headers=dict(
content_type='multipart/form-data',
authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
data = json.loads(response.data.decode())
print('data', data)
assert response.status_code == 413
assert 'fail' in data['status']
assert (
'Error during picture upload, file size (1.2KB) exceeds 1.0KB.'
in data['message']
)
assert 'data' not in data
class TestRegistrationConfiguration: class TestRegistrationConfiguration:
def test_it_returns_error_if_it_exceeds_max_users( def test_it_returns_error_if_it_exceeds_max_users(

View File

@ -514,6 +514,41 @@ class TestPostWorkoutWithGpx:
assert data['status'] == 'fail' assert data['status'] == 'fail'
assert data['message'] == 'No file part.' assert data['message'] == 'No file part.'
def test_it_returns_error_if_file_size_exceeds_limit(
self,
app_with_max_file_size: Flask,
user_1: User,
sport_1_cycling: Sport,
gpx_file: str,
) -> None:
client = app_with_max_file_size.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.post(
'/api/workouts',
data=dict(
file=(BytesIO(str.encode(gpx_file)), 'example.gpx'),
data='{"sport_id": 1}',
),
headers=dict(
content_type='multipart/form-data',
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
data = json.loads(response.data.decode())
assert response.status_code == 413
assert 'fail' in data['status']
assert (
'Error during workout upload, file size (3.6KB) exceeds 1.0KB.'
in data['message']
)
assert 'data' not in data
class TestPostWorkoutWithoutGpx: class TestPostWorkoutWithoutGpx:
def test_it_adds_an_workout_without_gpx( def test_it_adds_an_workout_without_gpx(
@ -815,6 +850,46 @@ class TestPostWorkoutWithZipArchive:
data = json.loads(response.data.decode()) data = json.loads(response.data.decode())
assert len(data['data']['workouts']) == 2 assert len(data['data']['workouts']) == 2
def test_it_returns_error_if_archive_size_exceeds_limit(
self,
app_with_max_zip_file_size: Flask,
user_1: User,
sport_1_cycling: Sport,
) -> None:
file_path = os.path.join(
app_with_max_zip_file_size.root_path, 'tests/files/gpx_test.zip'
)
# 'gpx_test.zip' contains 3 gpx files (same data) and 1 non-gpx file
with open(file_path, 'rb') as zip_file:
client = app_with_max_zip_file_size.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(
dict(email='test@test.com', password='12345678')
),
content_type='application/json',
)
response = client.post(
'/api/workouts',
data=dict(
file=(zip_file, 'gpx_test.zip'), data='{"sport_id": 1}'
),
headers=dict(
content_type='multipart/form-data',
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
data = json.loads(response.data.decode())
assert response.status_code == 413
assert 'fail' in data['status']
assert (
'Error during workout upload, file size (2.5KB) exceeds 1.0KB.'
in data['message']
)
assert 'data' not in data
class TestPostAndGetWorkoutWithGpx: class TestPostAndGetWorkoutWithGpx:
@staticmethod @staticmethod

View File

@ -18,17 +18,12 @@ from fittrackee.responses import (
handle_error_and_return_response, handle_error_and_return_response,
) )
from fittrackee.tasks import reset_password_email from fittrackee.tasks import reset_password_email
from fittrackee.utils import get_readable_duration, verify_extension_and_size
from fittrackee.workouts.utils_files import get_absolute_file_path from fittrackee.workouts.utils_files import get_absolute_file_path
from .decorators import authenticate from .decorators import authenticate
from .models import User from .models import User
from .utils import ( from .utils import check_passwords, register_controls
check_passwords,
display_readable_file_size,
get_readable_duration,
register_controls,
verify_extension_and_size,
)
from .utils_token import decode_user_token from .utils_token import decode_user_token
auth_blueprint = Blueprint('auth', __name__) auth_blueprint = Blueprint('auth', __name__)
@ -524,10 +519,10 @@ def edit_picture(auth_user_id: int) -> Union[Dict, HttpResponse]:
response_object = verify_extension_and_size('picture', request) response_object = verify_extension_and_size('picture', request)
except RequestEntityTooLarge as e: except RequestEntityTooLarge as e:
appLog.error(e) appLog.error(e)
max_file_size = current_app.config['MAX_CONTENT_LENGTH']
return PayloadTooLargeErrorResponse( return PayloadTooLargeErrorResponse(
'Error during picture update, file size exceeds ' file_type='picture',
f'{display_readable_file_size(max_file_size)}.' file_size=request.content_length,
max_size=current_app.config['MAX_CONTENT_LENGTH'],
) )
if response_object: if response_object:
return response_object return response_object

View File

@ -1,15 +1,11 @@
import re import re
from datetime import timedelta from typing import Optional, Tuple
from typing import Optional, Tuple, Union
import humanize from flask import Request
from flask import Request, current_app
from fittrackee.responses import ( from fittrackee.responses import (
ForbiddenErrorResponse, ForbiddenErrorResponse,
HttpResponse, HttpResponse,
InvalidPayloadErrorResponse,
PayloadTooLargeErrorResponse,
UnauthorizedErrorResponse, UnauthorizedErrorResponse,
) )
@ -64,49 +60,6 @@ def register_controls(
return ret return ret
def verify_extension_and_size(
file_type: str, req: Request
) -> Optional[HttpResponse]:
"""
Return error Response if file is invalid
"""
if 'file' not in req.files:
return InvalidPayloadErrorResponse('No file part.', 'fail')
file = req.files['file']
if file.filename == '':
return InvalidPayloadErrorResponse('No selected file.', 'fail')
allowed_extensions = (
'WORKOUT_ALLOWED_EXTENSIONS'
if file_type == 'workout'
else 'PICTURE_ALLOWED_EXTENSIONS'
)
file_extension = (
file.filename.rsplit('.', 1)[1].lower()
if '.' in file.filename
else None
)
max_file_size = current_app.config['max_single_file_size']
if not (
file_extension
and file_extension in current_app.config[allowed_extensions]
):
return InvalidPayloadErrorResponse(
'File extension not allowed.', 'fail'
)
if file_extension != 'zip' and req.content_length > max_file_size:
return PayloadTooLargeErrorResponse(
'Error during picture update, file size exceeds '
f'{display_readable_file_size(max_file_size)}.'
)
return None
def verify_user( def verify_user(
current_request: Request, verify_admin: bool current_request: Request, verify_admin: bool
) -> Tuple[Optional[HttpResponse], Optional[int]]: ) -> Tuple[Optional[HttpResponse], Optional[int]]:
@ -139,35 +92,3 @@ def can_view_workout(
if auth_user_id != workout_user_id: if auth_user_id != workout_user_id:
return ForbiddenErrorResponse() return ForbiddenErrorResponse()
return None return None
def display_readable_file_size(size_in_bytes: Union[float, int]) -> str:
"""
Return readable file size from size in bytes
"""
if size_in_bytes == 0:
return '0 bytes'
if size_in_bytes == 1:
return '1 byte'
for unit in [' bytes', 'KB', 'MB', 'GB', 'TB']:
if abs(size_in_bytes) < 1024.0:
return f'{size_in_bytes:3.1f}{unit}'
size_in_bytes /= 1024.0
return f'{size_in_bytes} bytes'
def get_readable_duration(duration: int, locale: Optional[str] = None) -> str:
"""
Return readable and localized duration from duration in seconds
"""
if locale is None:
locale = 'en'
if locale != 'en':
try:
_t = humanize.i18n.activate(locale) # noqa
except FileNotFoundError:
locale = 'en'
readable_duration = humanize.naturaldelta(timedelta(seconds=duration))
if locale != 'en':
humanize.i18n.deactivate()
return readable_duration

76
fittrackee/utils.py Normal file
View File

@ -0,0 +1,76 @@
from datetime import timedelta
from typing import Optional
import humanize
from flask import Request, current_app
from .responses import (
HttpResponse,
InvalidPayloadErrorResponse,
PayloadTooLargeErrorResponse,
)
def verify_extension_and_size(
file_type: str, req: Request
) -> Optional[HttpResponse]:
"""
Return error Response if file is invalid
"""
if 'file' not in req.files:
return InvalidPayloadErrorResponse('No file part.', 'fail')
file = req.files['file']
if file.filename == '':
return InvalidPayloadErrorResponse('No selected file.', 'fail')
allowed_extensions = (
'WORKOUT_ALLOWED_EXTENSIONS'
if file_type == 'workout'
else 'PICTURE_ALLOWED_EXTENSIONS'
)
file_extension = (
file.filename.rsplit('.', 1)[1].lower()
if '.' in file.filename
else None
)
max_file_size = current_app.config['max_single_file_size']
if not (
file_extension
and file_extension in current_app.config[allowed_extensions]
):
return InvalidPayloadErrorResponse(
'File extension not allowed.', 'fail'
)
if (
file_extension != 'zip'
and req.content_length is not None
and req.content_length > max_file_size
):
return PayloadTooLargeErrorResponse(
file_type=file_type,
file_size=req.content_length,
max_size=max_file_size,
)
return None
def get_readable_duration(duration: int, locale: Optional[str] = None) -> str:
"""
Return readable and localized duration from duration in seconds
"""
if locale is None:
locale = 'en'
if locale != 'en':
try:
_t = humanize.i18n.activate(locale) # noqa
except FileNotFoundError:
locale = 'en'
readable_duration = humanize.naturaldelta(timedelta(seconds=duration))
if locale != 'en':
humanize.i18n.deactivate()
return readable_duration

View File

@ -7,6 +7,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
import requests import requests
from flask import Blueprint, Response, current_app, request, send_file from flask import Blueprint, Response, current_app, request, send_file
from sqlalchemy import exc from sqlalchemy import exc
from werkzeug.exceptions import RequestEntityTooLarge
from fittrackee import appLog, db from fittrackee import appLog, db
from fittrackee.responses import ( from fittrackee.responses import (
@ -16,14 +17,13 @@ from fittrackee.responses import (
InternalServerErrorResponse, InternalServerErrorResponse,
InvalidPayloadErrorResponse, InvalidPayloadErrorResponse,
NotFoundErrorResponse, NotFoundErrorResponse,
PayloadTooLargeErrorResponse,
handle_error_and_return_response, handle_error_and_return_response,
) )
from fittrackee.users.decorators import authenticate from fittrackee.users.decorators import authenticate
from fittrackee.users.utils import ( from fittrackee.users.models import User
User, from fittrackee.users.utils import can_view_workout
can_view_workout, from fittrackee.utils import verify_extension_and_size
verify_extension_and_size,
)
from .models import Workout from .models import Workout
from .utils import ( from .utils import (
@ -880,7 +880,15 @@ def post_workout(auth_user_id: int) -> Union[Tuple[Dict, int], HttpResponse]:
:statuscode 500: :statuscode 500:
""" """
error_response = verify_extension_and_size('workout', request) try:
error_response = verify_extension_and_size('workout', request)
except RequestEntityTooLarge as e:
appLog.error(e)
return PayloadTooLargeErrorResponse(
file_type='workout',
file_size=request.content_length,
max_size=current_app.config['MAX_CONTENT_LENGTH'],
)
if error_response: if error_response:
return error_response return error_response