Skip to content

Commit 054533a

Browse files
committed
Add dummy backend to run s3file in development
1 parent 2932142 commit 054533a

File tree

13 files changed

+244
-63
lines changed

13 files changed

+244
-63
lines changed

README.rst

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ Progress Bar
106106
------------
107107

108108
S3File does emit progress signals that can be used to display some kind of progress bar.
109-
Signals named `progress` are emitted for both each individual file input as well as for
110-
the form as a whole.
109+
Signals named ``progress`` are emitted for both each individual file input as well as
110+
for the form as a whole.
111111

112112
The progress signal carries the following details:
113113

@@ -151,6 +151,24 @@ entire form.
151151
})
152152
})()
153153
154+
155+
Using S3File in development
156+
---------------------------
157+
158+
Using S3File in development can be helpful especially if you want to use the progress
159+
signals described above. Therefore, S3File comes with a AWS S3 dummy backend.
160+
It behaves similar to the real S3 storage backend. It is automatically enabled, if the
161+
``DEFAULT_FILE_STORAGE`` setting is set to ``FileSystemStorage``.
162+
163+
To prevent users from accidentally using the ``FileSystemStorage`` and the insecure S3
164+
dummy backend in production, there is also an additional deployment check that will
165+
error if you run Django's deployment check suite::
166+
167+
python manage.py check --deploy
168+
169+
We recommend always running the deployment check suite as part of your deployment
170+
pipeline.
171+
154172
Uploading multiple files
155173
------------------------
156174

s3file/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
from django.apps import AppConfig
2+
from django.core import checks
3+
4+
from .checks import storage_check
25

36

47
class S3FileConfig(AppConfig):
@@ -9,6 +12,7 @@ def ready(self):
912
from django.core.files.storage import default_storage
1013
from storages.backends.s3boto3 import S3Boto3Storage
1114
from django import forms
15+
1216
from .forms import S3FileInputMixin
1317

1418
if isinstance(default_storage, S3Boto3Storage) and \
@@ -21,3 +25,5 @@ def ready(self):
2125
cls for cls in forms.ClearableFileInput.__bases__
2226
if cls is not S3FileInputMixin
2327
)
28+
29+
checks.register(storage_check, checks.Tags.security, deploy=True)

s3file/checks.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from django.core.checks import Error
2+
from django.core.files.storage import FileSystemStorage, default_storage
3+
4+
5+
def storage_check(app_configs, **kwargs):
6+
if isinstance(default_storage, FileSystemStorage):
7+
return [
8+
Error(
9+
'FileSystemStorage should not be used in a production environment.',
10+
hint='Please verify your DEFAULT_FILE_STORAGE setting.',
11+
id='s3file.E001',
12+
)
13+
]

s3file/forms.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import uuid
55

66
from django.conf import settings
7-
from django.core.files.storage import default_storage
87
from django.utils.functional import cached_property
98

9+
from s3file.storages import storage
10+
1011
logger = logging.getLogger('s3file')
1112

1213

@@ -21,11 +22,11 @@ class S3FileInputMixin:
2122

2223
@property
2324
def bucket_name(self):
24-
return default_storage.bucket.name
25+
return storage.bucket.name
2526

2627
@property
2728
def client(self):
28-
return default_storage.connection.meta.client
29+
return storage.connection.meta.client
2930

3031
def build_attrs(self, *args, **kwargs):
3132
attrs = super().build_attrs(*args, **kwargs)

s3file/middleware.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import logging
22
import os
33

4-
from django.core.files.storage import default_storage
4+
from s3file.storages import local_dev, storage
5+
6+
from . import views
57

68
logger = logging.getLogger('s3file')
79

@@ -17,14 +19,17 @@ def __call__(self, request):
1719
paths = request.POST.getlist(field_name, [])
1820
request.FILES.setlist(field_name, list(self.get_files_from_storage(paths)))
1921

22+
if local_dev and request.path == '/__s3_mock__/':
23+
return views.S3MockView.as_view()(request)
24+
2025
return self.get_response(request)
2126

2227
@staticmethod
2328
def get_files_from_storage(paths):
2429
"""Return S3 file where the name does not include the path."""
2530
for path in paths:
2631
try:
27-
f = default_storage.open(path)
32+
f = storage.open(path)
2833
f.name = os.path.basename(path)
2934
yield f
3035
except OSError:

s3file/storages.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import base64
2+
import datetime
3+
import hmac
4+
import json
5+
6+
from django.conf import settings
7+
from django.core.files.storage import FileSystemStorage, default_storage
8+
9+
10+
class S3MockStorage(FileSystemStorage):
11+
class connection:
12+
class meta:
13+
class client:
14+
@staticmethod
15+
def generate_presigned_post(bucket_name, key, **policy):
16+
policy = json.dumps(policy).encode()
17+
policy_b64 = base64.b64encode(policy).decode()
18+
date = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
19+
aws_id = getattr(
20+
settings, 'AWS_ACCESS_KEY_ID',
21+
'AWS_ACCESS_KEY_ID',
22+
)
23+
fields = {
24+
'x-amz-algorithm': 'AWS4-HMAC-SHA256',
25+
'x-amz-date': date,
26+
'x-amz-credential': aws_id,
27+
'policy': policy_b64,
28+
'key': key,
29+
}
30+
signature = hmac.new(
31+
settings.SECRET_KEY.encode(),
32+
policy + date.encode(),
33+
'sha256',
34+
).digest()
35+
signature = base64.b64encode(signature).decode()
36+
return {
37+
'url': '/__s3_mock__/',
38+
'fields': {
39+
'x-amz-signature': signature,
40+
**fields
41+
},
42+
}
43+
44+
class bucket:
45+
name = 'test-bucket'
46+
47+
48+
local_dev = isinstance(default_storage, FileSystemStorage)
49+
50+
storage = default_storage if not local_dev else S3MockStorage()

s3file/views.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import base64
2+
import hashlib
3+
import hmac
4+
import logging
5+
6+
from django import http
7+
from django.conf import settings
8+
from django.core.files.storage import default_storage
9+
from django.views import generic
10+
11+
logger = logging.getLogger('s3file')
12+
13+
14+
class S3MockView(generic.View):
15+
16+
def post(self, request):
17+
success_action_status = request.POST.get('success_action_status', 201)
18+
try:
19+
file = request.FILES['file']
20+
key = request.POST['key']
21+
date = request.POST['x-amz-date']
22+
signature = request.POST['x-amz-signature']
23+
policy = request.POST['policy']
24+
except KeyError:
25+
logger.exception('bad request')
26+
return http.HttpResponseBadRequest()
27+
28+
try:
29+
signature = base64.b64decode(signature.encode())
30+
policy = base64.b64decode(policy.encode())
31+
32+
calc_sign = hmac.new(
33+
settings.SECRET_KEY.encode(),
34+
policy + date.encode(),
35+
'sha256'
36+
).digest()
37+
except ValueError:
38+
logger.exception('bad request')
39+
return http.HttpResponseBadRequest()
40+
41+
if not hmac.compare_digest(signature, calc_sign):
42+
logger.warning('bad signature')
43+
return http.HttpResponseForbidden()
44+
45+
key = key.replace('${filename}', file.name)
46+
etag = hashlib.md5(file.read()).hexdigest() # nosec
47+
file.seek(0)
48+
key = default_storage.save(key, file)
49+
return http.HttpResponse(
50+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
51+
"<PostResponse>"
52+
f"<Location>{settings.MEDIA_URL}{key}</Location>"
53+
f"<Bucket>{getattr(settings, 'AWS_STORAGE_BUCKET_NAME')}</Bucket>"
54+
f"<Key>{key}</Key>"
55+
f"<ETag>\"{etag}\"</ETag>"
56+
"</PostResponse>",
57+
status=success_action_status,
58+
)

tests/test_checks.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import pytest
2+
from django.core.management import call_command
3+
from django.core.management.base import SystemCheckError
4+
5+
6+
def test_storage_check():
7+
call_command('check')
8+
9+
with pytest.raises(SystemCheckError) as e:
10+
call_command('check', '--deploy')
11+
12+
assert (
13+
'FileSystemStorage should not be used in a production environment.'
14+
) in str(e.value)

tests/test_views.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import base64
2+
import hmac
3+
import http
4+
5+
from s3file import views
6+
7+
8+
class TestS3MockView:
9+
url = '/__s3_mock__/'
10+
11+
policy = (
12+
'eyJDb25kaXRpb25zIjogW3siYnVja2V0IjogInRlc3QtYnVja2V0In0sIFsic3RhcnRz'
13+
'LXdpdGgiLCAiJGtleSIsICJ0bXAvczNmaWxlLzNlUWhwOTZYU1dldFFwZ1VVQmZzWHci'
14+
'XSwgeyJzdWNjZXNzX2FjdGlvbl9zdGF0dXMiOiAiMjAxIn0sIFsic3RhcnRzLXdpdGgi'
15+
'LCAiJENvbnRlbnQtVHlwZSIsICIiXV0sICJFeHBpcmVzSW4iOiAxMjA5NjAwfQ=='
16+
)
17+
date = '20190616T184210Z'
18+
19+
def test_post__bad_request(self, rf):
20+
request = rf.post(self.url, data={})
21+
response = views.S3MockView.as_view()(request)
22+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
23+
24+
def test_post__created(self, client, upload_file):
25+
with open(upload_file) as fp:
26+
response = client.post(self.url, data={
27+
'x-amz-signature': 'T1Mb71D45h1u2SBoUHOMBNFCI2AgrKAg1UzKdUHUHig=',
28+
'x-amz-algorithm': 'AWS4-HMAC-SHA256',
29+
'x-amz-date': self.date,
30+
'x-amz-credential': 'testaccessid',
31+
'policy': self.policy,
32+
'key': 'tmp/s3file/3eQhp96XSWetQpgUUBfsXw/${filename}',
33+
'success_action_status': '201',
34+
'file': fp,
35+
})
36+
assert response.status_code == http.HTTPStatus.CREATED
37+
38+
def test_post__bad_signature(self, client, upload_file):
39+
40+
bad_signature = base64.b64encode(
41+
hmac.new(b'eve', (self.policy + self.date).encode(), 'sha256').digest()
42+
).decode()
43+
with open(upload_file) as fp:
44+
response = client.post(self.url, data={
45+
'x-amz-signature': bad_signature,
46+
'x-amz-algorithm': 'AWS4-HMAC-SHA256',
47+
'x-amz-date': self.date,
48+
'x-amz-credential': 'testaccessid',
49+
'policy': self.policy,
50+
'key': 'tmp/s3file/3eQhp96XSWetQpgUUBfsXw/${filename}',
51+
'success_action_status': '201',
52+
'file': fp,
53+
})
54+
assert response.status_code == http.HTTPStatus.FORBIDDEN
55+
56+
def test_post__not_a_signature(self, client, upload_file):
57+
bad_signature = 'eve'
58+
with open(upload_file) as fp:
59+
response = client.post(self.url, data={
60+
'x-amz-signature': bad_signature,
61+
'x-amz-algorithm': 'AWS4-HMAC-SHA256',
62+
'x-amz-date': self.date,
63+
'x-amz-credential': 'testaccessid',
64+
'policy': self.policy,
65+
'key': 'tmp/s3file/3eQhp96XSWetQpgUUBfsXw/${filename}',
66+
'success_action_status': '201',
67+
'file': fp,
68+
})
69+
assert response.status_code == http.HTTPStatus.BAD_REQUEST

tests/testapp/dummy_storage.py

Lines changed: 0 additions & 29 deletions
This file was deleted.

0 commit comments

Comments
 (0)