Skip to content

Commit 02ccea9

Browse files
authored
Version 2.0 (#60)
* Remove signing view Widgets are now signed when rendered. This behavior saves one call to the application server. * Add support for `accept` attribute Limit upload policy MIME-Type in `accept` attribute. * Add support for `multiple` file uploads * Remove `django-appconf` dependency * Remove progress bar * Add middleware to allow seamless Django integration * Add automatical `FileField.widget` overwrite * Add tests * Reduce file size * Reduce asset size
1 parent fc26cca commit 02ccea9

24 files changed

+453
-568
lines changed

.gitmodules

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

README.md

Lines changed: 81 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,106 @@
11
# django-s3file
22

3-
43
A lightweight file upload input for Django and Amazon S3.
54

65
[![PyPi Version](https://img.shields.io/pypi/v/django-s3file.svg)](https://pypi.python.org/pypi/django-s3file/)
76
[![Build Status](https://travis-ci.org/codingjoe/django-s3file.svg?branch=master)](https://travis-ci.org/codingjoe/django-s3file)
8-
[![Code Health](https://landscape.io/github/codingjoe/django-s3file/master/landscape.svg?style=flat)](https://landscape.io/github/codingjoe/django-s3file/master)
97
[![Test Coverage](https://coveralls.io/repos/codingjoe/django-s3file/badge.svg?branch=master)](https://coveralls.io/r/codingjoe/django-s3file)
10-
[![Code health](https://scrutinizer-ci.com/g/codingjoe/django-s3file/badges/quality-score.svg?b=master)](https://scrutinizer-ci.com/g/codingjoe/django-s3file/?branch=master)
118
[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/codingjoe/django-s3file/master/LICENSE)
12-
[![Join the chat at https://gitter.im/codingjoe/django-s3file](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/codingjoe/django-s3file?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
13-
149

1510
## Features
1611

17-
- Pure JavaScript (no jQuery)
18-
- Python 2 and 3 support
19-
- Auto swapping based on your environment
20-
- Pluggable as it returns a simple django file, just like native input
21-
- Easily extensible (authentication, styles)
22-
12+
* lightweight: less 200 lines
13+
* no JavaScript or Python dependencies (no jQuery)
14+
* Python 3 and 2 support
15+
* auto enabled based on your environment
16+
* works just like the build-in
2317

2418
## Installation
2519

26-
Just install S3file using `pip` or `easy_install`.
20+
_Make sure you have [Amazon S3 storage][boto-storage] setup correctly._
21+
22+
Just install S3file using `pip`.
23+
2724
```bash
2825
pip install django-s3file
2926
```
30-
Don't forget to add `s3file` to the `INSTALLED_APPS`.
3127

28+
Add the S3File app and middleware in your settings:
3229

33-
## Usage
34-
35-
### Simple integrations
30+
```python
3631

37-
Include s3file's URLs in your URL root.
32+
INSTALLED_APPS = (
33+
'...',
34+
's3file',
35+
'...',
36+
)
3837

39-
**urls.py**
40-
```python
41-
urlpatterns = patterns(
42-
...
43-
url(r'^s3file/', include('s3file.urls')),
38+
MIDDLEWARE = (
39+
'...',
40+
's3file.middleware.S3FileMiddleware',
41+
'...',
4442
)
4543
```
4644

45+
[boto-storage]: http://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html
46+
47+
## Usage
48+
49+
By default S3File will replace Django's `FileField` widget,
50+
but you can also specify the widget manually and pass custom attributes.
51+
52+
The `FileField`'s widget is only than automatically replaced when the
53+
`DEFAULT_FILE_STORAGE` setting is set to `django-storages`' `S3Boto3Storage`.
54+
55+
### Simple integrations
56+
4757
**forms.py**
58+
4859
```python
49-
from s3file.forms import AutoFileInput
60+
from django import forms
61+
from django.db import models
62+
from s3file.forms import S3FileInput
5063

51-
class MyModelForm(forms.ModelForm):
5264

65+
class ImageModel(models.Model):
66+
file = models.FileField(upload_to='path/to/files')
67+
68+
69+
class MyModelForm(forms.ModelForm):
5370
class Meta:
54-
model = MyModel
55-
fields = ('my_file_field')
71+
model = ImageModel
72+
fields = ('file',)
5673
widgets = {
57-
'my_file_field': AutoFileInput
74+
'file': S3FileInput(attrs={'accept': 'image/*'})
5875
}
5976
```
6077
**Done!** No really, that's all that needs to be done.
6178

6279

63-
### Setting up the CORS policy on your AWS S3 Bucket
80+
### Setting up the AWS S3 bucket
81+
82+
### Upload folder
83+
84+
S3File uploads to a single folder. Files are later moved by Django when
85+
they are saved to the `upload_to` location.
86+
87+
It is recommended to [setup expiration][aws-s3-lifecycle-rules] for that folder, to ensure that
88+
old and unused file uploads don't add up and produce costs.
89+
90+
[aws-s3-lifecycle-rules]: http://docs.aws.amazon.com/AmazonS3/latest/dev/intro-lifecycle-rules.html
91+
92+
The default folder name is: `tmp/s3file`
93+
You can change it by changing the `S3FILE_UPLOAD_PATH` setting.
94+
95+
### CORS policy
96+
97+
You will need to allow `POST` from all origins.
98+
Just add the following to your CORS policy.
6499

65100
```xml
66101
<CORSConfiguration>
67102
<CORSRule>
68103
<AllowedOrigin>*</AllowedOrigin>
69-
<AllowedMethod>PUT</AllowedMethod>
70104
<AllowedMethod>POST</AllowedMethod>
71105
<AllowedMethod>GET</AllowedMethod>
72106
<MaxAgeSeconds>3000</MaxAgeSeconds>
@@ -75,32 +109,30 @@ class MyModelForm(forms.ModelForm):
75109
</CORSConfiguration>
76110
```
77111

112+
### Uploading multiple files
78113

79-
### Advanced usage examples
114+
Django does have limited [support to uploaded multiple files][uploading-multiple-files].
115+
S3File fully supports this feature. The custom middleware makes ensure that files
116+
are accessible via `request.FILES`, even thogh they have been uploaded to AWS S3 directly
117+
and not to your Django application server.
80118

81-
#### Authentication
82-
The signing endpoint supports CSRF by default but does not require a authenticated user.
83-
This and other behavior can be easily added by inheriting from the view.
119+
[uploading-multiple-files]: https://docs.djangoproject.com/en/1.11/topics/http/file-uploads/#uploading-multiple-files
84120

85-
**views.py**
86-
```python
87-
from s3file.views import S3FileView
88-
from braces.views import LoginRequiredMixin
121+
### Security and Authentication
89122

90-
class MyS3FileView(LoginRequiredMixin, S3FileView):
91-
pass
92-
```
123+
django-s3file does not require any authentication setup. Files can only be uploaded
124+
to AWS S3 by users who have access to the form where the file upload is requested.
93125

94-
Now don't forget to change the URLs.
126+
You can further limit user data using the [`accept`][att_input_accept]-attribute.
127+
The specified MIME-Type will be enforced in the AWS S3 policy as well, for enhanced
128+
server side protection.
95129

96-
**urls.py**
97-
```python
98-
urlpatterns = patterns(
99-
...
100-
url('^s3file/sign',
101-
MyS3FileView.as_view(), name='s3file-sign'),
102-
)
103-
```
130+
S3File uses a strict policy and signature to grant clients permission to upload
131+
files to AWS S3. This signature expires based on Django's
132+
[`SESSION_COOKIE_AGE`][setting-SESSION_COOKIE_AGE] setting.
133+
134+
[setting-SESSION_COOKIE_AGE]: https://docs.djangoproject.com/en/1.11/ref/settings/#std:setting-SESSION_COOKIE_AGE
135+
[att_input_accept]: https://www.w3schools.com/tags/att_input_accept.asp
104136

105137
## License
106138

s3file/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
default_app_config = 's3file.apps.S3FileConfig'

s3file/apps.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from django.apps import AppConfig
2+
3+
try:
4+
from storages.backends.s3boto3 import S3Boto3Storage
5+
except ImportError:
6+
from storages.backends.s3boto import S3BotoStorage as S3BotoStorage
7+
8+
9+
class S3FileConfig(AppConfig):
10+
name = 's3file'
11+
verbose_name = 'S3File'
12+
13+
def ready(self):
14+
from django.forms import FileField
15+
from django.core.files.storage import default_storage
16+
17+
if isinstance(default_storage, S3Boto3Storage):
18+
from .forms import S3FileInput
19+
20+
FileField.widget = S3FileInput

s3file/conf.py

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

s3file/forms.py

Lines changed: 61 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
1-
# -*- coding:utf-8 -*-
2-
from __future__ import unicode_literals
3-
41
import logging
2+
import os
3+
import uuid
54

5+
from django.conf import settings
66
from django.core.files.storage import default_storage
7-
from django.core.urlresolvers import reverse_lazy
8-
from django.forms.widgets import (
9-
FILE_INPUT_CONTRADICTION, CheckboxInput, ClearableFileInput
10-
)
11-
from django.utils.safestring import mark_safe
12-
13-
from .conf import settings
7+
from django.forms.widgets import ClearableFileInput
8+
from django.utils.functional import cached_property
149

1510
logger = logging.getLogger('s3file')
1611

@@ -19,65 +14,70 @@ class S3FileInput(ClearableFileInput):
1914
"""FileInput that uses JavaScript to directly upload to Amazon S3."""
2015

2116
needs_multipart_form = False
22-
signing_url = reverse_lazy('s3file-sign')
23-
template = (
24-
'<div class="s3file" data-policy-url="{policy_url}">'
25-
'{input}'
26-
'<input name="{name}" type="hidden" />'
27-
'<div class="progress progress-striped active">'
28-
'<div class="progress-bar" />'
29-
'</div>'
30-
'</div>'
31-
)
17+
mime_type = None
3218

33-
def render(self, name, value, attrs=None):
34-
parent_input = super(S3FileInput, self).render(name, value, attrs=None)
35-
parent_input = parent_input.replace('name="{}"'.format(name), '')
36-
output = self.template.format(
37-
policy_url=self.signing_url,
38-
input=parent_input,
39-
name=name,
40-
)
41-
return mark_safe(output)
19+
def __init__(self, attrs=None):
20+
self.expires = settings.SESSION_COOKIE_AGE
21+
self.upload_path = getattr(settings, 'S3FILE_UPLOAD_PATH', os.path.join('tmp', 's3file'))
22+
super(S3FileInput, self).__init__(attrs=attrs)
23+
try:
24+
self.mime_type = self.attrs['accept']
25+
except KeyError:
26+
pass
4227

43-
def is_initial(self, value):
44-
return super(S3FileInput, self).is_initial(value)
28+
@property
29+
def bucket_name(self):
30+
return default_storage.bucket.name
4531

46-
def value_from_datadict(self, data, files, name):
47-
filename = data.get(name, None)
48-
if not self.is_required and CheckboxInput().value_from_datadict(
49-
data, files, self.clear_checkbox_name(name)):
50-
if filename:
51-
# If the user contradicts themselves (uploads a new file AND
52-
# checks the "clear" checkbox), we return a unique marker
53-
# object that FileField will turn into a ValidationError.
54-
return FILE_INPUT_CONTRADICTION
55-
# False signals to clear any existing value, as opposed to just None
56-
return False
57-
if not filename:
58-
return None
32+
@property
33+
def client(self):
34+
return default_storage.connection.meta.client
35+
36+
def build_attrs(self, *args, **kwargs):
37+
attrs = super(S3FileInput, self).build_attrs(*args, **kwargs)
38+
response = self.client.generate_presigned_post(
39+
self.bucket_name, os.path.join(self.upload_folder, '${filename}'),
40+
Conditions=self.get_conditions(),
41+
ExpiresIn=self.expires,
42+
)
43+
defaults = {
44+
'data-fields-%s' % key: value
45+
for key, value in response['fields'].items()
46+
}
47+
defaults['data-url'] = response['url']
48+
defaults.update(attrs)
5949
try:
60-
upload = default_storage.open(filename)
61-
except IOError:
62-
logger.exception('File "%s" could not be found.', filename)
63-
return False
50+
defaults['class'] += ' s3file'
51+
except KeyError:
52+
defaults['class'] = 's3file'
53+
return defaults
54+
55+
def get_conditions(self):
56+
conditions = [
57+
{"bucket": self.bucket_name},
58+
["starts-with", "$key", self.upload_folder],
59+
{"success_action_status": "201"},
60+
]
61+
if self.mime_type:
62+
top_type, sub_type = self.mime_type.split('/', 1)
63+
if sub_type == '*':
64+
conditions.append(["starts-with", "$Content-Type", "%s/" % top_type])
65+
else:
66+
conditions.append({"Content-Type": self.mime_type})
6467
else:
65-
return upload
68+
conditions.append(["starts-with", "$Content-Type", ""])
69+
70+
return conditions
71+
72+
@cached_property
73+
def upload_folder(self):
74+
return os.path.join(
75+
self.upload_path,
76+
uuid.uuid4().hex,
77+
)
6678

6779
class Media:
6880
js = (
6981
's3file/js/s3file.js',
7082

7183
)
72-
css = {
73-
'all': (
74-
's3file/css/s3file.css',
75-
)
76-
}
77-
78-
79-
if hasattr(settings, 'AWS_SECRET_ACCESS_KEY') \
80-
and settings.AWS_SECRET_ACCESS_KEY:
81-
AutoFileInput = S3FileInput
82-
else:
83-
AutoFileInput = ClearableFileInput

s3file/middleware.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import os
2+
3+
from django.core.files.storage import default_storage
4+
5+
try:
6+
from django.utils.deprecation import MiddlewareMixin
7+
except ImportError:
8+
MiddlewareMixin = object
9+
10+
11+
class S3FileMiddleware(MiddlewareMixin):
12+
@staticmethod
13+
def get_files_from_storage(paths):
14+
"""Return S3 file where the name does not include the path."""
15+
for path in paths:
16+
f = default_storage.open(path)
17+
f.name = os.path.basename(path)
18+
yield f
19+
20+
def process_request(self, request):
21+
file_fields = request.POST.getlist('s3file', [])
22+
for field_name in file_fields:
23+
paths = request.POST.getlist(field_name, [])
24+
request.FILES.setlist(field_name, list(self.get_files_from_storage(paths)))

0 commit comments

Comments
 (0)