├── s3upload ├── __init__.py ├── src │ ├── .babelrc │ ├── app │ │ ├── reducers │ │ │ ├── index.js │ │ │ ├── awsUploadsParams.js │ │ │ └── appStatus.js │ │ ├── store │ │ │ ├── index.js │ │ │ └── connect.js │ │ ├── index.js │ │ ├── constants │ │ │ └── index.js │ │ ├── utils │ │ │ └── index.js │ │ ├── components │ │ │ └── index.js │ │ └── actions │ │ │ └── index.js │ └── package.json ├── urls.py ├── apps.py ├── templates │ └── s3upload │ │ └── s3upload-widget.tpl ├── static │ └── s3upload │ │ ├── css │ │ ├── styles.css │ │ └── bootstrap-progress.min.css │ │ └── js │ │ ├── django-s3-uploads.min.js │ │ └── django-s3-uploads.js ├── fields.py ├── views.py ├── widgets.py └── utils.py ├── example ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── __init__.py ├── apps.py ├── admin.py ├── urls.py ├── templates │ ├── form.html │ └── async_form.html ├── views.py ├── models.py └── forms.py ├── pytest.ini ├── poetry.toml ├── screenshot.png ├── .editorconfig ├── tests ├── urls.py ├── test_utils.py ├── settings.py └── test_widgets.py ├── .gitignore ├── manage.py ├── .prettierrc ├── mypy.ini ├── .pre-commit-config.yaml ├── LICENSE ├── tox.ini ├── pyproject.toml ├── .github └── workflows │ └── tox.yml ├── .ruff.toml └── README.md /s3upload/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /s3upload/src/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.settings 3 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | create = true 3 | in-project = true 4 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "example.apps.ExampleAppConfig" 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-s3-upload/HEAD/screenshot.png -------------------------------------------------------------------------------- /s3upload/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import get_upload_params 4 | 5 | urlpatterns = [path("get_upload_params/", get_upload_params, name="s3upload")] 6 | -------------------------------------------------------------------------------- /example/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ExampleAppConfig(AppConfig): 5 | name = "example" 6 | verbose_name = "Sample Cats app " 7 | default_auto_field = "django.db.models.AutoField" 8 | -------------------------------------------------------------------------------- /s3upload/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class S3UploadAppConfig(AppConfig): 5 | name = "s3upload" 6 | verbose_name = "S3 Uploads" 7 | default_auto_field = "django.db.models.AutoField" 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | admin.autodiscover() 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls), 8 | path("s3upload/", include("s3upload.urls")), 9 | ] 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .idea 3 | .tox 4 | *.DS_Store 5 | *.egg-info 6 | *.pyc 7 | .eggs 8 | build 9 | dist 10 | *.sqlite3 11 | example/example/settings-test.py 12 | example/static/ 13 | poetry.lock 14 | s3upload/src/node_modules/ 15 | tests/static 16 | venv 17 | venv3 18 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "proseWrap": "always", 11 | "endOfLine": "auto" 12 | } 13 | -------------------------------------------------------------------------------- /example/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Cat, Kitten 4 | 5 | 6 | class KittenAdminInline(admin.StackedInline): 7 | model = Kitten 8 | extra = 1 9 | 10 | 11 | class CatAdmin(admin.ModelAdmin): 12 | inlines = [KittenAdminInline] 13 | 14 | 15 | admin.site.register(Cat, CatAdmin) 16 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import AsyncForm, MultiForm, MyView 4 | 5 | app_name = "cat" 6 | 7 | urlpatterns = [ 8 | path("async/", AsyncForm.as_view(), name="async_form"), 9 | path("multi/", MultiForm.as_view(), name="multi_form"), 10 | path("", MyView.as_view(), name="form"), 11 | ] 12 | -------------------------------------------------------------------------------- /example/templates/form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | s3upload 5 | {{ form.media }} 6 | 7 | 8 | 9 |
10 | {% csrf_token %} 11 | {{ form.as_p }} 12 |
13 | 14 | -------------------------------------------------------------------------------- /s3upload/src/app/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import AWSUploadParams from './awsUploadsParams'; 3 | import appStatus from './appStatus'; 4 | 5 | const element = (state = {}, action) => { 6 | return state; // reducer must return by default 7 | } 8 | 9 | export default combineReducers({ 10 | AWSUploadParams, 11 | appStatus, 12 | element 13 | }); -------------------------------------------------------------------------------- /s3upload/src/app/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore } from 'redux'; 2 | import reducers from '../reducers'; 3 | 4 | const { devToolsExtension } = window; 5 | 6 | export default function configureStore (initialState) { 7 | return createStore( 8 | reducers, 9 | initialState, 10 | devToolsExtension && devToolsExtension() 11 | ); 12 | } 13 | 14 | export * from './connect'; -------------------------------------------------------------------------------- /example/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import FormView 2 | 3 | from .forms import S3UploadForm, S3UploadMultiForm 4 | 5 | 6 | class MyView(FormView): 7 | template_name = "form.html" 8 | form_class = S3UploadForm 9 | 10 | 11 | class MultiForm(FormView): 12 | template_name = "form.html" 13 | form_class = S3UploadMultiForm 14 | 15 | 16 | class AsyncForm(FormView): 17 | template_name = "async_form.html" 18 | form_class = S3UploadForm 19 | -------------------------------------------------------------------------------- /example/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from s3upload.fields import S3UploadField 4 | 5 | 6 | class Cat(models.Model): 7 | custom_filename = S3UploadField(dest="custom_filename", blank=True) 8 | 9 | 10 | class Kitten(models.Model): 11 | mother = models.ForeignKey("Cat", on_delete=models.CASCADE) 12 | 13 | video = S3UploadField(dest="vids", blank=True) 14 | image = S3UploadField(dest="imgs", blank=True) 15 | pdf = S3UploadField(dest="files", blank=True) 16 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict_optional=True 3 | ignore_missing_imports=True 4 | follow_imports=silent 5 | warn_redundant_casts=True 6 | warn_unused_ignores = true 7 | warn_unreachable = true 8 | disallow_untyped_defs = true 9 | disallow_incomplete_defs = true 10 | 11 | # Disable mypy for migrations 12 | [mypy-*.migrations.*] 13 | ignore_errors=True 14 | 15 | # Disable mypy for settings 16 | [mypy-*.settings.*] 17 | ignore_errors=True 18 | 19 | # Disable mypy for tests 20 | [mypy-tests.*] 21 | ignore_errors=True 22 | -------------------------------------------------------------------------------- /example/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from s3upload.widgets import S3UploadWidget 4 | 5 | 6 | class S3UploadForm(forms.Form): 7 | misc = forms.URLField(widget=S3UploadWidget(dest="misc")) 8 | 9 | 10 | class S3UploadMultiForm(forms.Form): 11 | misc = forms.URLField(widget=S3UploadWidget(dest="misc")) 12 | pdfs = forms.URLField(widget=S3UploadWidget(dest="pdfs")) 13 | images = forms.URLField(widget=S3UploadWidget(dest="images")) 14 | videos = forms.URLField(widget=S3UploadWidget(dest="videos")) 15 | -------------------------------------------------------------------------------- /s3upload/src/app/store/connect.js: -------------------------------------------------------------------------------- 1 | export function getFilename (store) { 2 | return store.getState().appStatus.filename; 3 | } 4 | 5 | export function getUrl (store) { 6 | const url = store.getState().appStatus.signedURL || store.getState().appStatus.url; 7 | 8 | return url; 9 | } 10 | 11 | export function getError (store) { 12 | return store.getState().appStatus.error; 13 | } 14 | 15 | export function getUploadProgress (store) { 16 | return store.getState().appStatus.uploadProgress; 17 | } 18 | 19 | export function getElement (store) { 20 | return store.getState().element; 21 | } 22 | 23 | export function getAWSPayload (store) { 24 | return store.getState().AWSUploadParams.AWSPayload; 25 | } -------------------------------------------------------------------------------- /s3upload/templates/s3upload/s3upload-widget.tpl: -------------------------------------------------------------------------------- 1 |
2 | {{ file_name }} 3 | Remove 4 | 11 | 12 | 13 |
14 |
15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # python code formatting and linting - will amend files 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | # Ruff version. 5 | rev: "v0.11.13" 6 | hooks: 7 | - id: ruff 8 | args: [--fix, --exit-non-zero-on-fix] 9 | - id: ruff-format 10 | 11 | # python static type checking 12 | - repo: https://github.com/pre-commit/mirrors-mypy 13 | rev: v1.7.0 14 | hooks: 15 | - id: mypy 16 | args: 17 | - --disallow-untyped-defs 18 | - --disallow-incomplete-defs 19 | - --check-untyped-defs 20 | - --no-implicit-optional 21 | - --ignore-missing-imports 22 | - --follow-imports=silent 23 | -------------------------------------------------------------------------------- /s3upload/src/app/reducers/awsUploadsParams.js: -------------------------------------------------------------------------------- 1 | import constants from '../constants'; 2 | 3 | export default (state = {}, action) => { 4 | switch (action.type) { 5 | case constants.REQUEST_AWS_UPLOAD_PARAMS: 6 | return Object.assign({}, state, { 7 | isLoading: true 8 | }); 9 | case constants.RECEIVE_AWS_UPLOAD_PARAMS: 10 | return Object.assign({}, state, { 11 | isLoading: false, 12 | AWSPayload: action.aws_payload 13 | }); 14 | case constants.DID_NOT_RECEIVE_AWS_UPLOAD_PARAMS: 15 | // Returns current state and sets loading to false 16 | return Object.assign({}, state, { 17 | isLoading: false 18 | }); 19 | default: 20 | return state; // reducer must return by default 21 | } 22 | } -------------------------------------------------------------------------------- /s3upload/src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-s3-uploads", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "transpile": "browserify app/ -t babelify --outfile ../static/s3upload/js/django-s3-uploads.js", 9 | "uglify": "uglifyjs ../static/s3upload/js/django-s3-uploads.js -m -c -o ../static/s3upload/js/django-s3-uploads.min.js", 10 | "build": "NODE_ENV=production npm run transpile && npm run uglify" 11 | }, 12 | "author": "YunoJuno", 13 | "email": "code@yunojuno.com", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "babel-cli": "^6.24.1", 17 | "babel-preset-env": "^1.5.2", 18 | "babelify": "^7.3.0", 19 | "browserify": "^14.4.0", 20 | "redux": "^3.6.0", 21 | "uglify-js": "^3.0.15" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /s3upload/static/s3upload/css/styles.css: -------------------------------------------------------------------------------- 1 | .s3upload .s3upload__error { 2 | margin-top: 10px; 3 | color: red; 4 | } 5 | 6 | .s3upload .s3upload__progress { 7 | background: #333; 8 | width: 200px; 9 | } 10 | 11 | .s3upload .s3upload__file-remove { 12 | margin-top: 10px; 13 | text-transform: uppercase; 14 | } 15 | 16 | .s3upload .s3upload__progress, 17 | .s3upload .s3upload__file-link, 18 | .s3upload .s3upload__file-remove, 19 | .s3upload .s3upload__file-input, 20 | .s3upload .s3upload__error, 21 | .s3upload.form-active.progress-active .s3upload__file-input { 22 | display: none; 23 | } 24 | 25 | .s3upload.progress-active .s3upload__progress, 26 | .s3upload.link-active .s3upload__file-link, 27 | .s3upload.link-active .s3upload__file-remove, 28 | .s3upload.form-active .s3upload__file-input, 29 | .s3upload.has-error .s3upload__error { 30 | display: block; 31 | } -------------------------------------------------------------------------------- /s3upload/src/app/index.js: -------------------------------------------------------------------------------- 1 | import configureStore from './store'; 2 | import {View} from './components'; 3 | 4 | // by default initHandler inits on '.s3upload', but if passed a custom 5 | // selector in the event data, it will init on that instead. 6 | function initHandler(event) { 7 | let selector = '.s3upload'; 8 | 9 | if (event.detail && event.detail.selector) { 10 | selector = event.detail.selector; 11 | } 12 | 13 | const elements = document.querySelectorAll(selector); 14 | 15 | // safari doesn't like forEach on nodeList objects 16 | for (let i = 0; i < elements.length; i++) { 17 | // initialise instance for each element 18 | const element = elements[i]; 19 | const store = configureStore({element}); 20 | const view = new View(element, store); 21 | view.init(); 22 | } 23 | } 24 | 25 | // default global init on document ready 26 | document.addEventListener('DOMContentLoaded', initHandler); 27 | 28 | // custom event listener for use in async init 29 | document.addEventListener('s3upload:init', initHandler); -------------------------------------------------------------------------------- /s3upload/src/app/constants/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | REQUEST_AWS_UPLOAD_PARAMS: 'REQUEST_AWS_UPLOAD_PARAMS', 3 | RECEIVE_AWS_UPLOAD_PARAMS: 'RECEIVE_AWS_UPLOAD_PARAMS', 4 | DID_NOT_RECEIVE_AWS_UPLOAD_PARAMS: 'DID_NOT_RECEIVE_AWS_UPLOAD_PARAMS', 5 | REMOVE_UPLOAD: 'REMOVE_UPLOAD', 6 | BEGIN_UPLOAD_TO_AWS: 'BEGIN_UPLOAD_TO_AWS', 7 | COMPLETE_UPLOAD_TO_AWS: 'COMPLETE_UPLOAD_TO_AWS', 8 | DID_NOT_COMPLETE_UPLOAD_TO_AWS: 'DID_NOT_COMPLETE_UPLOAD_TO_AWS', 9 | ADD_ERROR: 'ADD_ERROR', 10 | CLEAR_ERRORS: 'CLEAR_ERRORS', 11 | UPDATE_PROGRESS: 'UPDATE_PROGRESS', 12 | RECEIVE_SIGNED_URL: 'RECEIVE_SIGNED_URL' 13 | }; 14 | 15 | let i18n_strings; 16 | 17 | try { 18 | i18n_strings = djangoS3Upload.i18n_strings; 19 | } catch(e) { 20 | i18n_strings = { 21 | "no_upload_failed": "Sorry, failed to upload file.", 22 | "no_upload_url": "Sorry, could not get upload URL.", 23 | "no_file_too_large": "Sorry, the file is too large to be uploaded.", 24 | "no_file_too_small": "Sorry, the file is too small to be uploaded." 25 | }; 26 | } 27 | 28 | export {i18n_strings}; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 YunoJuno Ltd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = 4 | fmt, lint, mypy, 5 | django-checks, 6 | ; https://docs.djangoproject.com/en/5.2/releases/ 7 | django42-py{310,311,312} 8 | django50-py{310,311,312} 9 | django51-py{310,311,312} 10 | django52-py{310,311,312} 11 | djangomain-py{311,312} 12 | 13 | [testenv] 14 | deps = 15 | coverage 16 | pytest 17 | pytest-cov 18 | pytest-django 19 | django42: Django>=4.2,<4.3 20 | django50: Django>=5.0,<5.1 21 | django51: Django>=5.1,<5.2 22 | django52: Django>=5.2,<5.3 23 | djangomain: https://github.com/django/django/archive/main.tar.gz 24 | 25 | commands = 26 | pytest --cov=s3upload --verbose tests/ 27 | 28 | [testenv:django-checks] 29 | description = Django system checks and missing migrations 30 | deps = Django 31 | commands = 32 | python manage.py check --fail-level WARNING 33 | python manage.py makemigrations --dry-run --check --verbosity 3 34 | 35 | [testenv:fmt] 36 | description = Python source code formatting (ruff) 37 | deps = 38 | ruff 39 | 40 | commands = 41 | ruff format --check s3upload 42 | 43 | [testenv:lint] 44 | description = Python source code linting (ruff) 45 | deps = 46 | ruff 47 | 48 | commands = 49 | ruff check s3upload 50 | 51 | [testenv:mypy] 52 | description = Python source code type hints (mypy) 53 | deps = 54 | mypy 55 | 56 | commands = 57 | mypy s3upload 58 | -------------------------------------------------------------------------------- /example/templates/async_form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | s3upload 5 | {{ form.media }} 6 | 7 | 8 | 9 |
10 | {% csrf_token %} 11 | {{ form.as_p }} 12 |
13 | 14 | 15 | 16 | 17 | 45 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-s3-upload" 3 | version = "1.1.1" 4 | description = "Integrates direct client-side uploading to s3 with Django." 5 | authors = ["YunoJuno "] 6 | license = "MIT" 7 | maintainers = ["YunoJuno "] 8 | readme = "README.md" 9 | homepage = "https://github.com/yunojuno/django-s3-upload" 10 | repository = "https://github.com/yunojuno/django-s3-upload" 11 | documentation = "https://github.com/yunojuno/django-s3-upload" 12 | classifiers = [ 13 | "Environment :: Web Environment", 14 | "Framework :: Django", 15 | "Framework :: Django :: 4.2", 16 | "Framework :: Django :: 5.0", 17 | "Framework :: Django :: 5.1", 18 | "Framework :: Django :: 5.2", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python :: 3 :: Only", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | ] 26 | packages = [{ include = "s3upload" }] 27 | 28 | [tool.poetry.dependencies] 29 | python = "^3.10" 30 | django = "^4.2 || ^5.0" 31 | boto3 = "^1.14" 32 | 33 | [tool.poetry.group.test.dependencies] 34 | coverage = "*" 35 | pytest = "*" 36 | pytest-cov = "*" 37 | pytest-django = "*" 38 | tox = "*" 39 | 40 | [tool.poetry.group.dev.dependencies] 41 | coverage = "*" 42 | mypy = "*" 43 | pre-commit = "*" 44 | ruff = "*" 45 | 46 | [build-system] 47 | requires = ["poetry>=0.12"] 48 | build-backend = "poetry.masonry.api" 49 | -------------------------------------------------------------------------------- /s3upload/fields.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from django.conf import settings 6 | from django.db.models import Field, Model 7 | 8 | from .utils import get_s3_path_from_url 9 | from .widgets import S3UploadWidget 10 | 11 | 12 | class S3UploadField(Field): 13 | def __init__(self, dest: str | None = None, *args: Any, **kwargs: Any) -> None: 14 | if not dest: 15 | raise ValueError("S3UploadField must be initialised with a destination") 16 | self.dest = dest 17 | self.widget = S3UploadWidget(self.dest) 18 | super(S3UploadField, self).__init__(*args, **kwargs) 19 | 20 | def deconstruct(self) -> tuple[str, str, Any, Any]: 21 | name, path, args, kwargs = super(S3UploadField, self).deconstruct() 22 | kwargs["dest"] = self.dest 23 | return name, path, args, kwargs 24 | 25 | def get_internal_type(self) -> str: 26 | return "TextField" 27 | 28 | def formfield(self, *args: Any, **kwargs: Any) -> Any: 29 | kwargs["widget"] = self.widget 30 | return super(S3UploadField, self).formfield(*args, **kwargs) 31 | 32 | def pre_save(self, model_instance: Model, add: bool) -> str: 33 | file_url = getattr(model_instance, self.attname) 34 | 35 | if file_url: 36 | setattr(model_instance, self.attname, file_url) 37 | bucket_name = settings.S3UPLOAD_DESTINATIONS[self.dest].get( 38 | "bucket", settings.AWS_STORAGE_BUCKET_NAME 39 | ) 40 | return get_s3_path_from_url(file_url, bucket_name=bucket_name) 41 | 42 | return file_url 43 | -------------------------------------------------------------------------------- /s3upload/src/app/reducers/appStatus.js: -------------------------------------------------------------------------------- 1 | import constants from '../constants'; 2 | 3 | export default (state = {}, action) => { 4 | switch (action.type) { 5 | case constants.BEGIN_UPLOAD_TO_AWS: 6 | return Object.assign({}, state, { 7 | isUploading: true 8 | }); 9 | case constants.COMPLETE_UPLOAD_TO_AWS: 10 | return Object.assign({}, state, { 11 | isUploading: false, 12 | uploadProgress: 0, 13 | filename: action.filename, 14 | url: action.url 15 | }); 16 | case constants.DID_NOT_COMPLETE_UPLOAD_TO_AWS: 17 | return Object.assign({}, state, { 18 | isUploading: false 19 | }); 20 | case constants.REMOVE_UPLOAD: 21 | return Object.assign({}, state, { 22 | filename: null, 23 | url: null 24 | }); 25 | case constants.ADD_ERROR: 26 | return Object.assign({}, state, { 27 | error: action.error 28 | }); 29 | case constants.CLEAR_ERRORS: 30 | return Object.assign({}, state, { 31 | error: null 32 | }); 33 | case constants.UPDATE_PROGRESS: 34 | return Object.assign({}, state, { 35 | uploadProgress: action.progress 36 | }); 37 | case constants.RECEIVE_SIGNED_URL: { 38 | return Object.assign({}, state, { 39 | signedURL: action.signedURL 40 | }); 41 | } 42 | 43 | default: 44 | return state; 45 | } 46 | } -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from s3upload.utils import get_bucket_endpoint_url, get_s3_path_from_url 4 | 5 | TEST_BUCKET = "test-bucket-name" 6 | TEST_KEY = "folder1/folder2/file1.json" 7 | TEST_REGION = "test-region" 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "url", 12 | [ 13 | f"s3://{TEST_BUCKET}/{TEST_KEY}", 14 | f"https://{TEST_BUCKET}.s3.aws-region.amazonaws.com/{TEST_KEY}?test=1&test1=2", 15 | f"https://{TEST_BUCKET}.s3.amazonaws.com:443/{TEST_KEY}", 16 | f"https://s3-aws-region.amazonaws.com/{TEST_BUCKET}/{TEST_KEY}", 17 | f"https://s3-aws-region.amazonaws.com:443/{TEST_BUCKET}/{TEST_KEY}", 18 | f"https%3a%2f%2fs3-aws-region.amazonaws.com%3a443%2f{TEST_BUCKET}%2ffolder1%2ffolder2%2ffile1.json", 19 | ], 20 | ) 21 | def test_get_s3_path_from_url(url: str) -> None: 22 | assert get_s3_path_from_url(url, bucket_name=TEST_BUCKET) == TEST_KEY 23 | 24 | 25 | @pytest.mark.parametrize( 26 | "url,expected", 27 | [ 28 | (None, f"https://{TEST_BUCKET}.s3.{TEST_REGION}.amazonaws.com"), 29 | ( 30 | "https://{bucket}.s3.{region}.amazonaws.com", 31 | f"https://{TEST_BUCKET}.s3.{TEST_REGION}.amazonaws.com", 32 | ), 33 | ( 34 | "https://s3.{region}.amazonaws.com", 35 | f"https://s3.{TEST_REGION}.amazonaws.com", 36 | ), 37 | ( 38 | "https://{bucket}.s3.amazonaws.com", 39 | f"https://{TEST_BUCKET}.s3.amazonaws.com", 40 | ), 41 | ], 42 | ) 43 | def test_get_bucket_endpoint_url(url: str, expected: str) -> None: 44 | kwargs = {} 45 | if url: 46 | kwargs["default"] = url 47 | assert ( 48 | get_bucket_endpoint_url(bucket_name=TEST_BUCKET, region=TEST_REGION, **kwargs) 49 | == expected 50 | ) 51 | -------------------------------------------------------------------------------- /example/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2020-08-25 12:15 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | import s3upload.fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies = [] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Cat", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ( 28 | "custom_filename", 29 | s3upload.fields.S3UploadField(blank=True, dest="custom_filename"), 30 | ), 31 | ], 32 | ), 33 | migrations.CreateModel( 34 | name="Kitten", 35 | fields=[ 36 | ( 37 | "id", 38 | models.AutoField( 39 | auto_created=True, 40 | primary_key=True, 41 | serialize=False, 42 | verbose_name="ID", 43 | ), 44 | ), 45 | ("video", s3upload.fields.S3UploadField(blank=True, dest="vids")), 46 | ("image", s3upload.fields.S3UploadField(blank=True, dest="imgs")), 47 | ("pdf", s3upload.fields.S3UploadField(blank=True, dest="files")), 48 | ( 49 | "mother", 50 | models.ForeignKey( 51 | on_delete=django.db.models.deletion.CASCADE, to="example.cat" 52 | ), 53 | ), 54 | ], 55 | ), 56 | ] 57 | -------------------------------------------------------------------------------- /s3upload/src/app/utils/index.js: -------------------------------------------------------------------------------- 1 | import {i18n_strings} from '../constants'; 2 | 3 | export const getCookie = function(name) { 4 | var value = '; ' + document.cookie, 5 | parts = value.split('; ' + name + '='); 6 | if (parts.length == 2) return parts.pop().split(';').shift(); 7 | } 8 | 9 | export const request = function(method, url, data, headers, onProgress, onLoad, onError) { 10 | var request = new XMLHttpRequest(); 11 | request.open(method, url, true); 12 | 13 | Object.keys(headers).forEach(function(key){ 14 | request.setRequestHeader(key, headers[key]); 15 | }); 16 | 17 | request.onload = function() { 18 | onLoad(request.status, request.responseText); 19 | } 20 | 21 | if (onError) { 22 | request.onerror = request.onabort = function() { 23 | onError(request.status, request.responseText); 24 | } 25 | } 26 | 27 | if (onProgress) { 28 | request.upload.onprogress = function(data) { 29 | onProgress(data); 30 | } 31 | } 32 | 33 | request.send(data); 34 | } 35 | 36 | export const parseURL = function(text) { 37 | var xml = new DOMParser().parseFromString(text, 'text/xml'), 38 | tag = xml.getElementsByTagName('Location')[0], 39 | url = decodeURIComponent(tag.childNodes[0].nodeValue); 40 | 41 | return url; 42 | } 43 | 44 | export const parseNameFromUrl = function(url) { 45 | return decodeURIComponent((url + '').replace(/\+/g, '%20')); 46 | } 47 | 48 | export const parseJson = function(json) { 49 | var data; 50 | 51 | try { 52 | data = JSON.parse(json); 53 | } 54 | catch(error) { 55 | data = null; 56 | }; 57 | 58 | return data; 59 | } 60 | 61 | export const raiseEvent = function(element, name, detail) { 62 | if (window.CustomEvent) { 63 | var event = new CustomEvent(name, {detail, bubbles: true}); 64 | element.dispatchEvent(event); 65 | } 66 | } 67 | 68 | export const observeStore = function(store, select, onChange) { 69 | let currentState; 70 | 71 | function handleChange() { 72 | let nextState = select(store.getState()); 73 | 74 | if (nextState !== currentState) { 75 | currentState = nextState; 76 | onChange(currentState); 77 | } 78 | } 79 | 80 | let unsubscribe = store.subscribe(handleChange); 81 | handleChange(); 82 | return unsubscribe; 83 | } -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: Python / Django 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | types: [opened, synchronize, reopened] 10 | 11 | jobs: 12 | format: 13 | name: Check formatting 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | toxenv: [fmt, lint, mypy] 18 | env: 19 | TOXENV: ${{ matrix.toxenv }} 20 | 21 | steps: 22 | - name: Check out the repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Python (3.12) 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: "3.12" 29 | 30 | - name: Install and run tox 31 | run: | 32 | pip install tox 33 | tox 34 | 35 | checks: 36 | name: Run Django checks 37 | runs-on: ubuntu-latest 38 | strategy: 39 | matrix: 40 | toxenv: ["django-checks"] 41 | env: 42 | TOXENV: ${{ matrix.toxenv }} 43 | 44 | steps: 45 | - name: Check out the repository 46 | uses: actions/checkout@v4 47 | 48 | - name: Set up Python (3.12) 49 | uses: actions/setup-python@v4 50 | with: 51 | python-version: "3.12" 52 | 53 | - name: Install and run tox 54 | run: | 55 | pip install tox 56 | tox 57 | 58 | test: 59 | name: Run tests 60 | runs-on: ubuntu-latest 61 | strategy: 62 | matrix: 63 | python: ["3.10", "3.11", "3.12"] 64 | # build LTS version, next version, HEAD 65 | django: ["42", "50", "51", "52", "main"] 66 | exclude: 67 | - python: "3.10" 68 | django: "main" 69 | - python: "3.11" 70 | django: "main" 71 | 72 | env: 73 | TOXENV: django${{ matrix.django }}-py${{ matrix.python }} 74 | 75 | steps: 76 | - name: Check out the repository 77 | uses: actions/checkout@v4 78 | 79 | - name: Set up Python ${{ matrix.python }} 80 | uses: actions/setup-python@v4 81 | with: 82 | python-version: ${{ matrix.python }} 83 | 84 | - name: Install and run tox 85 | run: | 86 | pip install tox 87 | tox 88 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 88 2 | 3 | [lint] 4 | ignore = [ 5 | "D100", # Missing docstring in public module 6 | "D101", # Missing docstring in public class 7 | "D102", # Missing docstring in public method 8 | "D103", # Missing docstring in public function 9 | "D104", # Missing docstring in public package 10 | "D105", # Missing docstring in magic method 11 | "D106", # Missing docstring in public nested class 12 | "D107", # Missing docstring in __init__ 13 | "D203", # 1 blank line required before class docstring 14 | "D212", # Multi-line docstring summary should start at the first line 15 | "D213", # Multi-line docstring summary should start at the second line 16 | "D404", # First word of the docstring should not be "This" 17 | "D405", # Section name should be properly capitalized 18 | "D406", # Section name should end with a newline 19 | "D407", # Missing dashed underline after section 20 | "D410", # Missing blank line after section 21 | "D411", # Missing blank line before section 22 | "D412", # No blank lines allowed between a section header and its content 23 | "D416", # Section name should end with a colon 24 | "D417", 25 | "D417", # Missing argument description in the docstring 26 | ] 27 | select = [ 28 | "A", # flake8 builtins 29 | "C9", # mcabe 30 | "D", # pydocstyle 31 | "E", # pycodestyle (errors) 32 | "F", # Pyflakes 33 | "I", # isort 34 | "S", # flake8-bandit 35 | "T2", # flake8-print 36 | "W", # pycodestype (warnings) 37 | ] 38 | 39 | [lint.isort] 40 | combine-as-imports = true 41 | 42 | [lint.mccabe] 43 | max-complexity = 8 44 | 45 | [lint.per-file-ignores] 46 | "*tests/*" = [ 47 | "D205", # 1 blank line required between summary line and description 48 | "D400", # First line should end with a period 49 | "D401", # First line should be in imperative mood 50 | "D415", # First line should end with a period, question mark, or exclamation point 51 | "E501", # Line too long 52 | "E731", # Do not assign a lambda expression, use a def 53 | "S101", # Use of assert detected 54 | "S105", # Possible hardcoded password 55 | "S106", # Possible hardcoded password 56 | "S113", # Probable use of requests call with timeout set to {value} 57 | ] 58 | "*/migrations/*" = [ 59 | "E501", # Line too long 60 | ] 61 | "*/settings.py" = [ 62 | "F403", # from {name} import * used; unable to detect undefined names 63 | "F405", # {name} may be undefined, or defined from star imports: 64 | ] 65 | "*/settings/*" = [ 66 | "F403", # from {name} import * used; unable to detect undefined names 67 | "F405", # {name} may be undefined, or defined from star imports: 68 | ] 69 | -------------------------------------------------------------------------------- /s3upload/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from os.path import splitext 4 | 5 | from django.conf import settings 6 | from django.http import HttpRequest, JsonResponse 7 | from django.utils.text import get_valid_filename 8 | from django.views.decorators.http import require_POST 9 | 10 | from .utils import create_upload_data, get_signed_download_url 11 | 12 | 13 | @require_POST 14 | def get_upload_params(request: HttpRequest) -> JsonResponse: # noqa: C901 15 | content_type = request.POST["type"] 16 | filename = get_valid_filename(request.POST["name"]) 17 | dest = settings.S3UPLOAD_DESTINATIONS[request.POST["dest"]] 18 | 19 | if not dest: 20 | return JsonResponse({"error": "File destination does not exist."}, status=400) 21 | 22 | key = dest.get("key") 23 | auth = dest.get("auth") 24 | allowed_types = dest.get("allowed_types") 25 | acl = dest.get("acl") 26 | bucket = dest.get("bucket") 27 | cache_control = dest.get("cache_control") 28 | content_disposition = dest.get("content_disposition") 29 | content_length_range = dest.get("content_length_range") 30 | allowed_extensions = dest.get("allowed_extensions") 31 | server_side_encryption = dest.get("server_side_encryption") 32 | 33 | if not acl: 34 | acl = "public-read" 35 | 36 | if not key: 37 | return JsonResponse({"error": "Missing destination path."}, status=403) 38 | 39 | if auth and not auth(request.user): 40 | return JsonResponse({"error": "Permission denied."}, status=403) 41 | 42 | if (allowed_types and content_type not in allowed_types) and allowed_types != "*": 43 | return JsonResponse( 44 | {"error": "Invalid file type (%s)." % content_type}, status=400 45 | ) 46 | 47 | original_ext = splitext(filename)[1] 48 | lowercased_ext = original_ext.lower() 49 | if ( 50 | allowed_extensions and lowercased_ext not in allowed_extensions 51 | ) and allowed_extensions != "*": 52 | return JsonResponse( 53 | {"error": "Forbidden file extension (%s)." % original_ext}, status=415 54 | ) 55 | 56 | if hasattr(key, "__call__"): 57 | key = key(filename) 58 | elif key == "/": 59 | key = filename 60 | else: 61 | key = "{}/{}".format(key, filename) 62 | 63 | aws_payload = create_upload_data( 64 | content_type=content_type, 65 | key=key, 66 | acl=acl, 67 | bucket=bucket, 68 | cache_control=cache_control, 69 | content_disposition=content_disposition, 70 | content_length_range=content_length_range, 71 | server_side_encryption=server_side_encryption, 72 | ) 73 | 74 | url = None 75 | 76 | # Generate signed URL for private document access 77 | if acl == "private": 78 | url = get_signed_download_url( 79 | key=key.replace("${filename}", filename), 80 | bucket_name=bucket, 81 | ttl=int(5 * 60), # 5 mins 82 | ) 83 | 84 | data = { 85 | "aws_payload": aws_payload, 86 | "private_access_url": url, 87 | } 88 | 89 | return JsonResponse(data, content_type="application/json") 90 | -------------------------------------------------------------------------------- /s3upload/widgets.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from typing import Any 5 | from urllib.parse import unquote_plus as urlunquote_plus 6 | 7 | from django.conf import settings 8 | from django.core.exceptions import ImproperlyConfigured 9 | from django.forms import widgets 10 | from django.template.loader import render_to_string 11 | from django.urls import reverse 12 | from django.utils.safestring import mark_safe 13 | 14 | from .utils import ( 15 | get_bucket_endpoint_url, 16 | get_s3_path_from_url, 17 | get_signed_download_url, 18 | ) 19 | 20 | 21 | class S3UploadWidget(widgets.TextInput): 22 | class Media: 23 | js = ("s3upload/js/django-s3-uploads.min.js",) 24 | css = { 25 | "all": ( 26 | "s3upload/css/bootstrap-progress.min.css", 27 | "s3upload/css/styles.css", 28 | ) 29 | } 30 | 31 | def __init__(self, dest: str, **kwargs: Any) -> None: 32 | if not dest: 33 | raise ValueError("S3UploadWidget must be initialised with a destination") 34 | if dest not in settings.S3UPLOAD_DESTINATIONS: 35 | raise ImproperlyConfigured( 36 | "S3UploadWidget destination '%s' is not configured. " 37 | "Please check settings.S3UPLOAD_DESTINATIONS." % dest 38 | ) 39 | self.acl = settings.S3UPLOAD_DESTINATIONS[dest].get("acl", "public-read") 40 | self.dest = dest 41 | super(S3UploadWidget, self).__init__(**kwargs) 42 | 43 | def get_file_url(self, value: str) -> str: 44 | if value: 45 | bucket_name = settings.S3UPLOAD_DESTINATIONS[self.dest].get("bucket") 46 | if self.acl == "private": 47 | return get_signed_download_url(value, bucket_name) 48 | else: 49 | # Default to virtual-hosted–style URL 50 | bucket_url = get_bucket_endpoint_url(bucket_name) 51 | return "{}/{}".format(bucket_url, value) 52 | else: 53 | return "" 54 | 55 | def get_attr( 56 | self, attrs: dict[str, Any] | None, key: str, default: str = "" 57 | ) -> str: 58 | return self.build_attrs(attrs).get(key, default) if attrs else default 59 | 60 | def render( 61 | self, name: str, value: str, attrs: dict[str, Any] | None = None, **kwargs: Any 62 | ) -> str: 63 | path = get_s3_path_from_url(value) if value else "" 64 | file_name = os.path.basename(urlunquote_plus(path)) 65 | tpl = os.path.join("s3upload", "s3upload-widget.tpl") 66 | output = render_to_string( 67 | tpl, 68 | { 69 | "policy_url": reverse("s3upload"), 70 | "element_id": self.get_attr(attrs, "id"), 71 | "file_name": file_name, 72 | "dest": self.dest, 73 | "file_url": self.get_file_url(path), 74 | "name": name, 75 | "style": self.get_attr(attrs, "style"), 76 | }, 77 | ) 78 | # TODO: review use of mark_safe - will the template render 79 | # cover cases of bad filenames? 80 | return mark_safe(output) # noqa: S308 81 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from os import getenv, path 3 | 4 | DEBUG = True 5 | TEMPLATE_DEBUG = True 6 | USE_TZ = True 7 | USE_L10N = True 8 | 9 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "test.db"}} 10 | 11 | INSTALLED_APPS = ( 12 | "django.contrib.admin", 13 | "django.contrib.auth", 14 | "django.contrib.contenttypes", 15 | "django.contrib.sessions", 16 | "django.contrib.messages", 17 | "django.contrib.staticfiles", 18 | "s3upload", 19 | "example", 20 | ) 21 | 22 | MIDDLEWARE = [ 23 | # default django middleware 24 | "django.contrib.sessions.middleware.SessionMiddleware", 25 | "django.middleware.common.CommonMiddleware", 26 | "django.middleware.csrf.CsrfViewMiddleware", 27 | "django.contrib.auth.middleware.AuthenticationMiddleware", 28 | "django.contrib.messages.middleware.MessageMiddleware", 29 | ] 30 | 31 | PROJECT_DIR = path.abspath(path.join(path.dirname(__file__))) 32 | 33 | TEMPLATES = [ 34 | { 35 | "BACKEND": "django.template.backends.django.DjangoTemplates", 36 | "DIRS": [path.join(PROJECT_DIR, "templates")], 37 | "APP_DIRS": True, 38 | "OPTIONS": { 39 | "context_processors": [ 40 | "django.contrib.messages.context_processors.messages", 41 | "django.contrib.auth.context_processors.auth", 42 | "django.template.context_processors.request", 43 | ] 44 | }, 45 | } 46 | ] 47 | 48 | STATIC_URL = "/static/" 49 | 50 | STATIC_ROOT = path.join(PROJECT_DIR, "static") 51 | 52 | SECRET_KEY = "secret" # noqa: S105 53 | 54 | LOGGING = { 55 | "version": 1, 56 | "disable_existing_loggers": False, 57 | "formatters": {"simple": {"format": "%(levelname)s %(message)s"}}, 58 | "handlers": { 59 | "console": { 60 | "level": "DEBUG", 61 | "class": "logging.StreamHandler", 62 | "formatter": "simple", 63 | } 64 | }, 65 | "loggers": { 66 | "": {"handlers": ["console"], "propagate": True, "level": "DEBUG"}, 67 | # 'django': { 68 | # 'handlers': ['console'], 69 | # 'propagate': True, 70 | # 'level': 'WARNING', 71 | # }, 72 | }, 73 | } 74 | 75 | ROOT_URLCONF = "tests.urls" 76 | 77 | 78 | # used by the example app 79 | def create_filename(filename: str) -> str: 80 | ext = filename.split(".")[-1] 81 | filename = "{}.{}".format(uuid.uuid4().hex, ext) 82 | return path.join("custom", filename) 83 | 84 | 85 | AWS_ACCESS_KEY_ID = getenv("AWS_ACCESS_KEY_ID", "") 86 | AWS_SECRET_ACCESS_KEY = getenv("AWS_SECRET_ACCESS_KEY", "") 87 | AWS_STORAGE_BUCKET_NAME = getenv("AWS_STORAGE_BUCKET_NAME", "") 88 | S3UPLOAD_REGION = getenv("S3UPLOAD_REGION", "") 89 | 90 | S3UPLOAD_DESTINATIONS = { 91 | "misc": {"key": lambda original_filename: "images/unique.jpg"}, 92 | "files": {"key": "uploads/files", "auth": lambda u: u.is_staff}, 93 | "imgs": { 94 | "key": "uploads/imgs", 95 | "auth": lambda u: True, 96 | "allowed_types": ["image/jpeg", "image/png"], 97 | "content_length_range": (5000, 20000000), # 5kb - 20mb 98 | "allowed_extensions": (".jpg", ".jpeg", ".png"), 99 | }, 100 | "vids": { 101 | "key": "uploads/vids", 102 | "auth": lambda u: u.is_authenticated, 103 | "allowed_types": ["video/mp4"], 104 | }, 105 | "cached": { 106 | "key": "uploads/vids", 107 | "auth": lambda u: True, 108 | "allowed_types": "*", 109 | "acl": "authenticated-read", 110 | "bucket": "astoragebucketname", 111 | "cache_control": "max-age=2592000", 112 | "content_disposition": "attachment", 113 | "server_side_encryption": "AES256", 114 | }, 115 | # Allow anybody to upload any MIME type with a custom name function 116 | "custom_filename": {"key": create_filename}, 117 | } 118 | 119 | if not DEBUG: 120 | raise Exception("This settings file can only be used with DEBUG=True") 121 | -------------------------------------------------------------------------------- /s3upload/src/app/components/index.js: -------------------------------------------------------------------------------- 1 | import {removeUpload, getUploadURL, clearErrors, updateProgress} from '../actions'; 2 | import {getFilename, getUrl, getError, getUploadProgress} from '../store'; 3 | import {observeStore, raiseEvent} from '../utils'; 4 | 5 | 6 | const View = function(element, store) { 7 | return { 8 | renderFilename: function() { 9 | const filename = getFilename(store), 10 | url = getUrl(store); 11 | 12 | if (filename && url) { 13 | this.$link.innerHTML = filename; 14 | this.$link.setAttribute('href', url); 15 | this.$url.value = url.split("?")[0]; 16 | 17 | this.$element.classList.add('link-active'); 18 | this.$element.classList.remove('form-active'); 19 | } 20 | else { 21 | this.$url.value = ''; 22 | this.$input.value = ''; 23 | 24 | this.$element.classList.add('form-active'); 25 | this.$element.classList.remove('link-active'); 26 | } 27 | }, 28 | 29 | renderError: function() { 30 | const error = getError(store); 31 | 32 | if (error) { 33 | this.$element.classList.add('has-error'); 34 | this.$element.classList.add('form-active'); 35 | this.$element.classList.remove('link-active'); 36 | 37 | this.$element.querySelector('.s3upload__file-input').value = ''; 38 | this.$element.querySelector('.s3upload__error').innerHTML = error; 39 | } 40 | else { 41 | this.$element.classList.remove('has-error'); 42 | this.$element.querySelector('.s3upload__error').innerHTML = ''; 43 | } 44 | }, 45 | 46 | renderUploadProgress: function() { 47 | const uploadProgress = getUploadProgress(store); 48 | 49 | if (uploadProgress > 0) { 50 | this.$element.classList.add('progress-active'); 51 | this.$bar.style.width = uploadProgress + '%'; 52 | } 53 | else { 54 | this.$element.classList.remove('progress-active'); 55 | this.$bar.style.width = '0'; 56 | } 57 | }, 58 | 59 | removeUpload: function(event) { 60 | event.preventDefault(); 61 | 62 | store.dispatch(updateProgress()); 63 | store.dispatch(removeUpload()); 64 | raiseEvent(this.$element, 's3upload:clear-upload'); 65 | }, 66 | 67 | getUploadURL: function(event) { 68 | const file = this.$input.files[0], 69 | dest = this.$dest.value, 70 | url = this.$element.getAttribute('data-policy-url'); 71 | 72 | store.dispatch(clearErrors()); 73 | store.dispatch(getUploadURL(file, dest, url, store)); 74 | }, 75 | 76 | init: function() { 77 | // cache all the query selectors 78 | // $variables represent DOM elements 79 | this.$element = element; 80 | this.$url = element.querySelector('.s3upload__file-url'); 81 | this.$input = element.querySelector('.s3upload__file-input'); 82 | this.$remove = element.querySelector('.s3upload__file-remove'); 83 | this.$dest = element.querySelector('.s3upload__file-dest'); 84 | this.$link = element.querySelector('.s3upload__file-link'); 85 | this.$error = element.querySelector('.s3upload__error'); 86 | this.$bar = element.querySelector('.s3upload__bar'); 87 | 88 | // set initial DOM states3upload__ 89 | const status = (this.$url.value === '') ? 'form' : 'link'; 90 | this.$element.classList.add(status + '-active'); 91 | 92 | // add event listeners 93 | this.$remove.addEventListener('click', this.removeUpload.bind(this)) 94 | this.$input.addEventListener('change', this.getUploadURL.bind(this)) 95 | 96 | // these three observers subscribe to the store, but only trigger their 97 | // callbacks when the specific piece of state they observe changes. 98 | // this allows for a less naive approach to rendering changes than a 99 | // render method subscribed to the whole state. 100 | const filenameObserver = observeStore(store, state => state.appStatus.filename, this.renderFilename.bind(this)); 101 | 102 | const errorObserver = observeStore(store, state => state.appStatus.error, this.renderError.bind(this)); 103 | 104 | const uploadProgressObserver = observeStore(store, state => state.appStatus.uploadProgress, this.renderUploadProgress.bind(this)); 105 | } 106 | } 107 | } 108 | 109 | export {View}; -------------------------------------------------------------------------------- /s3upload/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hashlib 4 | import hmac 5 | import json 6 | from base64 import b64encode 7 | from datetime import datetime, timedelta, timezone 8 | from typing import Any 9 | from urllib.parse import unquote, urlparse 10 | 11 | import boto3 12 | from botocore.config import Config 13 | from django.conf import settings 14 | 15 | 16 | def create_upload_data( # noqa: C901 17 | *, 18 | content_type: str, 19 | key: str, 20 | acl: str, 21 | bucket: str | None = None, 22 | cache_control: str | None = None, 23 | content_disposition: str | None = None, 24 | content_length_range: str | None = None, 25 | server_side_encryption: str | None = None, 26 | token: str | None = None, 27 | ) -> dict[str, Any]: 28 | """Generate AWS upload payload.""" 29 | access_key = settings.AWS_ACCESS_KEY_ID 30 | secret_access_key = settings.AWS_SECRET_ACCESS_KEY 31 | bucket = bucket or settings.AWS_STORAGE_BUCKET_NAME 32 | region = settings.S3UPLOAD_REGION 33 | bucket_url = get_bucket_endpoint_url(bucket, region) 34 | expires_in = datetime.now(timezone.utc) + timedelta(seconds=60 * 5) 35 | expires = expires_in.strftime("%Y-%m-%dT%H:%M:%S.000Z") 36 | now_date = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S000Z") 37 | raw_date = datetime.now(timezone.utc).strftime("%Y%m%d") 38 | 39 | policy_dict: dict[str, Any] = { 40 | "expiration": expires, 41 | "conditions": [ 42 | {"bucket": bucket}, 43 | {"acl": acl}, 44 | ["starts-with", "$key", ""], 45 | {"success_action_status": "201"}, 46 | {"x-amz-credential": f"{access_key}/{raw_date}/{region}/s3/aws4_request"}, 47 | {"x-amz-algorithm": "AWS4-HMAC-SHA256"}, 48 | {"x-amz-date": now_date}, 49 | {"content-type": content_type}, 50 | ], 51 | } 52 | 53 | if token: 54 | policy_dict["conditions"].append({"x-amz-security-token": token}) 55 | 56 | if cache_control: 57 | policy_dict["conditions"].append({"Cache-Control": cache_control}) 58 | 59 | if content_disposition: 60 | policy_dict["conditions"].append({"Content-Disposition": content_disposition}) 61 | 62 | if server_side_encryption: 63 | policy_dict["conditions"].append( 64 | {"x-amz-server-side-encryption": server_side_encryption} 65 | ) 66 | 67 | if content_length_range: 68 | policy_dict["conditions"].append( 69 | ["content-length-range", content_length_range[0], content_length_range[1]] 70 | ) 71 | 72 | policy_object = json.dumps(policy_dict) 73 | 74 | policy = b64encode(policy_object.replace("\n", "").replace("\r", "").encode()) 75 | 76 | date_key = hmac.new( 77 | b"AWS4" + secret_access_key.encode("utf-8"), 78 | msg=raw_date.encode("utf-8"), 79 | digestmod=hashlib.sha256, 80 | ).digest() 81 | 82 | date_region_key = hmac.new( 83 | date_key, msg=region.encode("utf-8"), digestmod=hashlib.sha256 84 | ).digest() 85 | 86 | date_region_service_key = hmac.new( 87 | date_region_key, msg=b"s3", digestmod=hashlib.sha256 88 | ).digest() 89 | 90 | signing_key = hmac.new( 91 | date_region_service_key, msg=b"aws4_request", digestmod=hashlib.sha256 92 | ).digest() 93 | 94 | signature = hmac.new(signing_key, msg=policy, digestmod=hashlib.sha256).hexdigest() 95 | return_dict = { 96 | "policy": policy.decode(), # decode to make it JSON serializable 97 | "success_action_status": 201, 98 | "x-amz-credential": f"{access_key}/{raw_date}/{region}/s3/aws4_request", 99 | "x-amz-date": now_date, 100 | "x-amz-signature": signature, 101 | "x-amz-algorithm": "AWS4-HMAC-SHA256", 102 | "form_action": bucket_url, 103 | "key": key, 104 | "acl": acl, 105 | "content-type": content_type, 106 | } 107 | 108 | if token: 109 | return_dict["x-amz-security-token"] = token 110 | 111 | if server_side_encryption: 112 | return_dict["x-amz-server-side-encryption"] = server_side_encryption 113 | 114 | if cache_control: 115 | return_dict["Cache-Control"] = cache_control 116 | 117 | if content_disposition: 118 | return_dict["Content-Disposition"] = content_disposition 119 | 120 | return return_dict 121 | 122 | 123 | def get_s3_path_from_url( 124 | url: str, bucket_name: str = settings.AWS_STORAGE_BUCKET_NAME 125 | ) -> str: 126 | decoded = unquote(url) 127 | path = urlparse(decoded).path 128 | 129 | # The bucket name might be part of the path, 130 | # so get the path that comes after the bucket name 131 | key_path = path.split(bucket_name)[-1] 132 | 133 | # Remove slash prefix if present 134 | if key_path[0] == "/": 135 | key_path = key_path[1:] 136 | 137 | return key_path 138 | 139 | 140 | def get_signed_download_url( 141 | key: str, 142 | bucket_name: str | None = None, 143 | ttl: int = 60, 144 | ) -> str: 145 | bucket_name = bucket_name or settings.AWS_STORAGE_BUCKET_NAME 146 | s3 = boto3.client( 147 | "s3", 148 | aws_access_key_id=settings.AWS_ACCESS_KEY_ID, 149 | aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, 150 | config=Config(signature_version="s3v4"), 151 | region_name=settings.S3UPLOAD_REGION, 152 | ) 153 | download_url = s3.generate_presigned_url( 154 | "get_object", Params={"Bucket": bucket_name, "Key": key}, ExpiresIn=ttl 155 | ) 156 | return download_url 157 | 158 | 159 | # virtual-hosted-style URLs are now the default 160 | # Example: https://bucket-name.s3.Region.amazonaws.com/key name 161 | # See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-bucket-intro.html 162 | def get_bucket_endpoint_url( 163 | bucket_name: str | None = None, 164 | region: str | None = None, 165 | default: str = "https://{bucket}.s3.{region}.amazonaws.com", 166 | ) -> str: 167 | bucket_name = bucket_name or settings.AWS_STORAGE_BUCKET_NAME 168 | region = region or settings.S3UPLOAD_REGION 169 | bucket_endpoint = getattr( 170 | settings, 171 | "S3UPLOAD_BUCKET_ENDPOINT", 172 | default, 173 | ) 174 | return bucket_endpoint.format(bucket=bucket_name, region=region) 175 | -------------------------------------------------------------------------------- /s3upload/src/app/actions/index.js: -------------------------------------------------------------------------------- 1 | import constants from '../constants'; 2 | import {i18n_strings} from '../constants'; 3 | import {request, parseJson, getCookie, parseURL, parseNameFromUrl, raiseEvent} from '../utils'; 4 | import {getElement, getAWSPayload} from '../store'; 5 | 6 | export const getUploadURL = (file, dest, url, store) => { 7 | 8 | const form = new FormData(), 9 | headers = {'X-CSRFToken': getCookie('csrftoken')}; 10 | 11 | form.append('type', file.type); 12 | form.append('name', file.name); 13 | form.append('dest', dest); 14 | 15 | const onLoad = function(status, json) { 16 | const data = parseJson(json); 17 | 18 | switch(status) { 19 | case 200: 20 | store.dispatch(receiveSignedURL(data.private_access_url)); 21 | store.dispatch(receiveAWSUploadParams(data.aws_payload)); 22 | store.dispatch(beginUploadToAWS(file, store)); 23 | break; 24 | case 400: 25 | case 403: 26 | case 415: 27 | console.error('Error uploading', status, data.error); 28 | raiseEvent(getElement(store), 's3upload:error', {status, error: data}); 29 | store.dispatch(addError(data.error)); 30 | store.dispatch(didNotReceivAWSUploadParams()); 31 | break; 32 | default: 33 | console.error('Error uploading', status, i18n_strings.no_upload_url); 34 | raiseEvent(getElement(store), 's3upload:error', {status, error: data}); 35 | store.dispatch(addError(i18n_strings.no_upload_url)); 36 | store.dispatch(didNotReceivAWSUploadParams()); 37 | } 38 | } 39 | 40 | const onError = function(status, json) { 41 | const data = parseJson(json); 42 | 43 | console.error('Error uploading', status, i18n_strings.no_upload_url); 44 | raiseEvent(getElement(store), 's3upload:error', {status, error: data}); 45 | 46 | store.dispatch(addError(i18n_strings.no_upload_url)); 47 | } 48 | 49 | request('POST', url, form, headers, false, onLoad, onError); 50 | 51 | return { 52 | type: constants.REQUEST_AWS_UPLOAD_PARAMS 53 | } 54 | } 55 | 56 | export const receiveAWSUploadParams = (aws_payload) => { 57 | return { 58 | type: constants.RECEIVE_AWS_UPLOAD_PARAMS, 59 | aws_payload: aws_payload 60 | } 61 | } 62 | 63 | export const receiveSignedURL = (signedURL) => { 64 | return { 65 | type: constants.RECEIVE_SIGNED_URL, 66 | signedURL, 67 | } 68 | } 69 | 70 | export const didNotReceivAWSUploadParams = () => { 71 | return { 72 | type: constants.DID_NOT_RECEIVE_AWS_UPLOAD_PARAMS, 73 | } 74 | } 75 | 76 | export const removeUpload = () => { 77 | return { 78 | type: constants.REMOVE_UPLOAD 79 | } 80 | } 81 | 82 | export const beginUploadToAWS = (file, store) => { 83 | const AWSPayload = getAWSPayload(store), 84 | url = AWSPayload.form_action, 85 | headers = {}; 86 | 87 | let form = new FormData(); 88 | 89 | // we need to remove this key because otherwise S3 will trigger a 403 90 | // when we send the payload along with the file. 91 | delete AWSPayload['form_action']; 92 | 93 | Object.keys(AWSPayload).forEach(function(key){ 94 | form.append(key, AWSPayload[key]); 95 | }); 96 | 97 | // the file has to be appended at the end, or else S3 will throw a wobbly 98 | form.append('file', file); 99 | 100 | const onLoad = function(status, xml) { 101 | switch(status) { 102 | case 201: 103 | const url = parseURL(xml), 104 | filename = parseNameFromUrl(url).split('/').pop(); 105 | 106 | store.dispatch(completeUploadToAWS(filename, url)); 107 | raiseEvent(getElement(store), 's3upload:file-uploaded', {filename, url}); 108 | break; 109 | default: 110 | console.error('Error uploading', status, xml); 111 | raiseEvent(getElement(store), 's3upload:error', {status, error: xml}); 112 | 113 | store.dispatch(didNotCompleteUploadToAWS()); 114 | 115 | if (xml.indexOf('') > -1) { 116 | store.dispatch(addError(i18n_strings.no_file_too_small)); 117 | } 118 | else if (xml.indexOf('') > -1) { 119 | store.dispatch(addError(i18n_strings.no_file_too_large)); 120 | } 121 | else { 122 | store.dispatch(addError(i18n_strings.no_upload_failed)); 123 | } 124 | 125 | break; 126 | } 127 | }; 128 | 129 | const onError = function(status, xml) { 130 | console.error('Error uploading', status, xml); 131 | raiseEvent(getElement(store), 's3upload:error', {status, xml}); 132 | 133 | store.dispatch(didNotCompleteUploadToAWS()); 134 | store.dispatch(addError(i18n_strings.no_upload_failed)); 135 | } 136 | 137 | const onProgress = function(data) { 138 | let progress = null; 139 | 140 | if (data.lengthComputable) { 141 | progress = Math.round(data.loaded * 100 / data.total); 142 | } 143 | 144 | store.dispatch(updateProgress(progress)); 145 | raiseEvent(getElement(store), 's3upload:progress-updated', {progress}); 146 | } 147 | 148 | request('POST', url, form, headers, onProgress, onLoad, onError); 149 | 150 | return { 151 | type: constants.BEGIN_UPLOAD_TO_AWS 152 | } 153 | } 154 | 155 | export const completeUploadToAWS = (filename, url) => { 156 | return { 157 | type: constants.COMPLETE_UPLOAD_TO_AWS, 158 | url, 159 | filename 160 | } 161 | } 162 | 163 | export const didNotCompleteUploadToAWS = () => { 164 | return { 165 | type: constants.DID_NOT_COMPLETE_UPLOAD_TO_AWS 166 | } 167 | } 168 | 169 | export const addError = (error) => { 170 | return { 171 | type: constants.ADD_ERROR, 172 | error 173 | } 174 | } 175 | 176 | export const clearErrors = () => { 177 | return { 178 | type: constants.CLEAR_ERRORS 179 | } 180 | } 181 | 182 | export const updateProgress = (progress = {}) => { 183 | return { 184 | type: constants.UPDATE_PROGRESS, 185 | progress 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-s3-upload 2 | 3 | ## Compatibility 4 | 5 | This library supports Python 3.10+ and Django 4.2+ only. 6 | 7 | [![Build Status](https://travis-ci.org/yunojuno/django-s3upload.svg?branch=master)](https://travis-ci.org/yunojuno/django-s3upload) 8 | 9 | **Allows direct uploading of a file from the browser to AWS S3 via a file input field rendered by 10 | Django.** 11 | 12 | The uploaded file's URL is then saveable as the value of that field in the database. 13 | 14 | This avoids the problem of uploads timing out when they go via a web server before being handed off 15 | to S3. 16 | 17 | Features include: 18 | 19 | - displaying a progress bar 20 | - support for ACLs (eg, private uploads) 21 | - support for encrypted-at-rest S3 buckets 22 | - mimetype and file extension whitelisting 23 | - specifying different bucket destinations on a per-field basis 24 | 25 | ## Installation 26 | 27 | Install with Pip: 28 | 29 | `pip install django-s3-upload` 30 | 31 | ## AWS Setup 32 | 33 | ### Access Credentials 34 | 35 | You have two options of providing access to AWS resources: 36 | 37 | 1. Add credentials of an IAM user to your Django settings (see below) 38 | 2. Use the EC2 instance profile and its attached IAM role 39 | 40 | Whether you are using an IAM user or a role, there needs to be an IAM policy in effect that grants 41 | permission to upload to S3: 42 | 43 | ```json 44 | "Statement": [ 45 | { 46 | "Effect": "Allow", 47 | "Action": ["s3:PutObject", "s3:PutObjectAcl"], 48 | "Resource": "arn:aws:s3:::your-bucket-name/*" 49 | } 50 | ] 51 | ``` 52 | 53 | If the instance profile is to be used, the IAM role needs to have a Trust Relationship configuration 54 | applied: 55 | 56 | ```json 57 | "Statement": [ 58 | { 59 | "Effect": "Allow", 60 | "Principal": { 61 | "Service": "ec2.amazonaws.com" 62 | }, 63 | "Action": "sts:AssumeRole" 64 | } 65 | ] 66 | ``` 67 | 68 | Note that in order to use the EC2 instance profile, django-s3-upload needs to query the EC2 instance 69 | metadata using utility functions from the [botocore] [] package. You already have `botocore` 70 | installed if `boto3` is a dependency of your project. 71 | 72 | ### S3 CORS 73 | 74 | Setup a CORS policy on your S3 bucket. 75 | 76 | ```xml 77 | 78 | 79 | http://yourdomain.com:8080 80 | POST 81 | PUT 82 | 3000 83 | * 84 | 85 | 86 | ``` 87 | 88 | ## Django Setup 89 | 90 | ### settings.py 91 | 92 | ```python 93 | INSTALLED_APPS = [ 94 | ... 95 | 's3upload', 96 | ... 97 | ] 98 | 99 | TEMPLATES = [{ 100 | ... 101 | 'APP_DIRS': True, 102 | ... 103 | }] 104 | 105 | # AWS 106 | 107 | # If these are not defined, the EC2 instance profile and IAM role are used. 108 | # This requires you to add boto3 (or botocore, which is a dependency of boto3) 109 | # to your project dependencies. 110 | AWS_ACCESS_KEY_ID = '' 111 | AWS_SECRET_ACCESS_KEY = '' 112 | 113 | AWS_STORAGE_BUCKET_NAME = '' 114 | 115 | # The region of your bucket, more info: 116 | # http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region 117 | S3UPLOAD_REGION = 'us-east-1' 118 | 119 | # [Optional] Custom bucket endpoint url, with following keys (also optional): 120 | # region - region of your bucket 121 | # bucket - your bucket name 122 | # S3UPLOAD_BUCKET_ENDPOINT = 'https://{region}.non-amazon-s3-url.com/{bucket}' 123 | 124 | # Destinations, with the following keys: 125 | # 126 | # key [required] Where to upload the file to, can be either: 127 | # 1. '/' = Upload to root with the original filename. 128 | # 2. 'some/path' = Upload to some/path with the original filename. 129 | # 3. functionName = Pass a function and create your own path/filename. 130 | # auth [optional] An ACL function to whether the current Django user can perform this action. 131 | # allowed [optional] List of allowed MIME types. 132 | # acl [optional] Give the object another ACL rather than 'public-read'. 133 | # cache_control [optional] Cache control headers, eg 'max-age=2592000'. 134 | # content_disposition [optional] Useful for sending files as attachments. 135 | # bucket [optional] Specify a different bucket for this particular object. 136 | # server_side_encryption [optional] Encryption headers for buckets that require it. 137 | 138 | S3UPLOAD_DESTINATIONS = { 139 | 'example_destination': { 140 | # REQUIRED 141 | 'key': 'uploads/images', 142 | 143 | # OPTIONAL 144 | 'auth': lambda u: u.is_staff, # Default allow anybody to upload 145 | 'allowed_types': ['image/jpeg', 'image/png', 'video/mp4'], # Default allow all mime types 146 | 'allowed_extensions': ('.jpg', '.jpeg', '.png'), # Defaults to all extensions 147 | 'bucket': 'pdf-bucket', # Default is 'AWS_STORAGE_BUCKET_NAME' 148 | 'acl': 'private', # Defaults to 'public-read' 149 | 'cache_control': 'max-age=2592000', # Default no cache-control 150 | 'content_disposition': 'attachment', # Default no content disposition 151 | 'content_length_range': (5000, 20000000), # Default allow any size 152 | 'server_side_encryption': 'AES256', # Default no encryption 153 | } 154 | } 155 | ``` 156 | 157 | ### urls.py 158 | 159 | ```python 160 | urlpatterns = [ 161 | path(r'^s3upload/', include('s3upload.urls')), 162 | ] 163 | ``` 164 | 165 | Run `python manage.py collectstatic` if required. 166 | 167 | ## Use in Django admin 168 | 169 | ### models.py 170 | 171 | ```python 172 | from django.db import models 173 | from s3upload.fields import S3UploadField 174 | 175 | class Example(models.Model): 176 | video = S3UploadField(dest='example_destination') 177 | ``` 178 | 179 | ## Use the widget in a custom form 180 | 181 | ### forms.py 182 | 183 | ```python 184 | from django import forms 185 | from s3upload.widgets import S3UploadWidget 186 | 187 | class S3UploadForm(forms.Form): 188 | images = forms.URLField(widget=S3UploadWidget(dest='example_destination')) 189 | ``` 190 | 191 | **\*Optional.** You can modify the HTML of the widget by overiding template 192 | **s3upload/templates/s3upload-widget.tpl** 193 | 194 | ### views.py 195 | 196 | ```python 197 | from django.views.generic import FormView 198 | from .forms import S3UploadForm 199 | 200 | class MyView(FormView): 201 | template_name = 'form.html' 202 | form_class = S3UploadForm 203 | ``` 204 | 205 | ### templates/form.html 206 | 207 | ```html 208 | 209 | 210 | 211 | s3upload 212 | {{ form.media }} 213 | 214 | 215 |
{% csrf_token %} {{ form.as_p }}
216 | 217 | 218 | ``` 219 | 220 | ## Examples 221 | 222 | Examples of both approaches can be found in the examples folder. To run them: 223 | 224 | ```shell 225 | $ git clone git@github.com:yunojuno/django-s3-upload.git 226 | $ cd django-s3-upload 227 | 228 | # Add your AWS keys to your environment 229 | export AWS_ACCESS_KEY_ID='...' 230 | export AWS_SECRET_ACCESS_KEY='...' 231 | export AWS_STORAGE_BUCKET_NAME='...' 232 | export S3UPLOAD_REGION='...' # e.g. 'eu-west-1' 233 | 234 | $ docker-compose up 235 | ``` 236 | 237 | Visit `http://localhost:8000/admin` to view the admin widget and `http://localhost:8000/form` to 238 | view the custom form widget. 239 | 240 | [botocore]: https://github.com/boto/botocore 241 | -------------------------------------------------------------------------------- /tests/test_widgets.py: -------------------------------------------------------------------------------- 1 | import json 2 | from base64 import b64decode 3 | from urllib.parse import parse_qs, urlparse 4 | 5 | from django.conf import settings 6 | from django.contrib.auth.models import User 7 | from django.core.exceptions import ImproperlyConfigured 8 | from django.test import TestCase 9 | from django.test.utils import override_settings 10 | from django.urls import resolve, reverse 11 | 12 | from s3upload import widgets 13 | 14 | HTML_OUTPUT = """
15 | 16 | Remove 17 | 24 | 25 | 26 |
27 |
28 |
29 |
30 |
31 | """ 32 | 33 | FOO_RESPONSE = { 34 | "x-amz-algorithm": "AWS4-HMAC-SHA256", 35 | "form_action": "https://s3.amazonaws.com/{}".format( 36 | settings.AWS_STORAGE_BUCKET_NAME 37 | ), 38 | "success_action_status": 201, 39 | "acl": "public-read", 40 | "key": "uploads/imgs/image.jpg", 41 | "content-type": "image/jpeg", 42 | } 43 | 44 | 45 | class WidgetTests(TestCase): 46 | def setUp(self) -> None: 47 | admin = User.objects.create_superuser("admin", "u@email.com", "admin") 48 | admin.save() 49 | 50 | def test_init(self) -> None: 51 | # Test initialising the widget without an invalid destination 52 | self.assertRaises(ImproperlyConfigured, widgets.S3UploadWidget, "foo") 53 | self.assertRaises(ValueError, widgets.S3UploadWidget, None) 54 | self.assertRaises(ValueError, widgets.S3UploadWidget, "") 55 | with override_settings(S3UPLOAD_DESTINATIONS={"foo": {}}): 56 | widgets.S3UploadWidget("foo") 57 | 58 | def test_check_urls(self) -> None: 59 | reversed_url = reverse("s3upload") 60 | resolved_url = resolve("/s3upload/get_upload_params/") 61 | self.assertEqual(reversed_url, "/s3upload/get_upload_params/") 62 | self.assertEqual(resolved_url.view_name, "s3upload") 63 | 64 | @override_settings(S3UPLOAD_DESTINATIONS={"foo": {}}) 65 | def test_check_widget_html(self) -> None: 66 | widget = widgets.S3UploadWidget(dest="foo") 67 | html = widget.render("filename", "") 68 | self.assertEqual(html, HTML_OUTPUT) 69 | 70 | def test_check_signing_logged_in(self) -> None: 71 | self.client.login(username="admin", password="admin") 72 | data = {"dest": "files", "name": "image.jpg", "type": "image/jpeg"} 73 | response = self.client.post(reverse("s3upload"), data) 74 | self.assertEqual(response.status_code, 200) 75 | 76 | def test_check_signing_logged_out(self) -> None: 77 | data = {"dest": "files", "name": "image.jpg", "type": "image/jpeg"} 78 | response = self.client.post(reverse("s3upload"), data) 79 | self.assertEqual(response.status_code, 403) 80 | 81 | def test_check_allowed_type(self) -> None: 82 | data = {"dest": "imgs", "name": "image.jpg", "type": "image/jpeg"} 83 | response = self.client.post(reverse("s3upload"), data) 84 | self.assertEqual(response.status_code, 200) 85 | 86 | def test_check_disallowed_type(self) -> None: 87 | data = {"dest": "imgs", "name": "image.mp4", "type": "video/mp4"} 88 | response = self.client.post(reverse("s3upload"), data) 89 | self.assertEqual(response.status_code, 400) 90 | 91 | def test_check_allowed_type_logged_in(self) -> None: 92 | self.client.login(username="admin", password="admin") 93 | data = {"dest": "vids", "name": "video.mp4", "type": "video/mp4"} 94 | response = self.client.post(reverse("s3upload"), data) 95 | self.assertEqual(response.status_code, 200) 96 | 97 | def test_check_disallowed_type_logged_out(self) -> None: 98 | data = {"dest": "vids", "name": "video.mp4", "type": "video/mp4"} 99 | response = self.client.post(reverse("s3upload"), data) 100 | self.assertEqual(response.status_code, 403) 101 | 102 | def test_check_disallowed_extensions(self) -> None: 103 | data = {"dest": "imgs", "name": "image.jfif", "type": "image/jpeg"} 104 | response = self.client.post(reverse("s3upload"), data) 105 | self.assertEqual(response.status_code, 415) 106 | 107 | def test_check_allowed_extensions(self) -> None: 108 | data = {"dest": "imgs", "name": "image.jpg", "type": "image/jpeg"} 109 | response = self.client.post(reverse("s3upload"), data) 110 | self.assertEqual(response.status_code, 200) 111 | 112 | def test_check_disallowed_extensions__uppercase(self) -> None: 113 | data = {"dest": "imgs", "name": "image.JFIF", "type": "image/jpeg"} 114 | response = self.client.post(reverse("s3upload"), data) 115 | self.assertEqual(response.status_code, 415) 116 | 117 | def test_check_allowed_extensions__uppercase(self) -> None: 118 | data = {"dest": "imgs", "name": "image.JPG", "type": "image/jpeg"} 119 | response = self.client.post(reverse("s3upload"), data) 120 | self.assertEqual(response.status_code, 200) 121 | 122 | def test_check_signing_fields(self) -> None: 123 | self.client.login(username="admin", password="admin") 124 | data = {"dest": "imgs", "name": "image.jpg", "type": "image/jpeg"} 125 | response = self.client.post(reverse("s3upload"), data) 126 | response_dict = json.loads(response.content.decode()) 127 | aws_payload = response_dict["aws_payload"] 128 | self.assertTrue("x-amz-signature" in aws_payload) 129 | self.assertTrue("x-amz-credential" in aws_payload) 130 | self.assertTrue("policy" in aws_payload) 131 | self.assertEqual(aws_payload["x-amz-algorithm"], "AWS4-HMAC-SHA256") 132 | self.assertEqual( 133 | aws_payload["form_action"], 134 | f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.S3UPLOAD_REGION}.amazonaws.com", 135 | ) 136 | self.assertEqual(aws_payload["success_action_status"], 201) 137 | self.assertEqual(aws_payload["acl"], "public-read") 138 | self.assertEqual(aws_payload["key"], "uploads/imgs/image.jpg") 139 | self.assertEqual(aws_payload["content-type"], "image/jpeg") 140 | 141 | def test_check_signing_fields_unique_filename(self) -> None: 142 | data = {"dest": "misc", "name": "image.jpg", "type": "image/jpeg"} 143 | response = self.client.post(reverse("s3upload"), data) 144 | response_dict = json.loads(response.content.decode()) 145 | aws_payload = response_dict["aws_payload"] 146 | self.assertTrue("x-amz-signature" in aws_payload) 147 | self.assertTrue("x-amz-credential" in aws_payload) 148 | self.assertTrue("policy" in aws_payload) 149 | self.assertEqual(aws_payload["x-amz-algorithm"], "AWS4-HMAC-SHA256") 150 | self.assertEqual( 151 | aws_payload["form_action"], 152 | f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.S3UPLOAD_REGION}.amazonaws.com", 153 | ) 154 | self.assertEqual(aws_payload["success_action_status"], 201) 155 | self.assertEqual(aws_payload["acl"], "public-read") 156 | self.assertEqual(aws_payload["key"], "images/unique.jpg") 157 | self.assertEqual(aws_payload["content-type"], "image/jpeg") 158 | 159 | def test_check_policy_conditions(self) -> None: 160 | self.client.login(username="admin", password="admin") 161 | data = {"dest": "cached", "name": "video.mp4", "type": "video/mp4"} 162 | response = self.client.post(reverse("s3upload"), data) 163 | self.assertEqual(response.status_code, 200) 164 | response_dict = json.loads(response.content.decode()) 165 | aws_payload = response_dict["aws_payload"] 166 | self.assertTrue("policy" in aws_payload) 167 | policy_dict = json.loads(b64decode(aws_payload["policy"]).decode("utf-8")) 168 | self.assertTrue("conditions" in policy_dict) 169 | conditions_dict = policy_dict["conditions"] 170 | self.assertEqual(conditions_dict[0]["bucket"], "astoragebucketname") 171 | self.assertEqual(conditions_dict[1]["acl"], "authenticated-read") 172 | self.assertEqual(conditions_dict[8]["Cache-Control"], "max-age=2592000") 173 | self.assertEqual(conditions_dict[9]["Content-Disposition"], "attachment") 174 | self.assertEqual(conditions_dict[10]["x-amz-server-side-encryption"], "AES256") 175 | 176 | @override_settings( 177 | S3UPLOAD_DESTINATIONS={ 178 | "misc": { 179 | "key": "/", 180 | "auth": lambda u: True, 181 | "acl": "private", 182 | "bucket": "test-bucket", 183 | } 184 | }, 185 | S3UPLOAD_REGION="eu-west-1", 186 | ) 187 | def test_check_signed_url(self) -> None: 188 | data = {"dest": "misc", "name": "image.jpg", "type": "image/jpeg"} 189 | response = self.client.post(reverse("s3upload"), data) 190 | response_dict = json.loads(response.content.decode()) 191 | parsed_url = urlparse(response_dict["private_access_url"]) 192 | parsed_qs = parse_qs(parsed_url.query) 193 | self.assertEqual(parsed_url.scheme, "https") 194 | self.assertEqual(parsed_url.netloc, "test-bucket.s3.amazonaws.com") 195 | self.assertTrue("X-Amz-Signature" in parsed_qs) 196 | self.assertTrue("X-Amz-Expires" in parsed_qs) 197 | 198 | def test_content_length_range(self) -> None: 199 | # Content_length_range setting is always sent as part of policy. 200 | # Initial request data doesn't affect it. 201 | data = {"dest": "imgs", "name": "image.jpg", "type": "image/jpeg"} 202 | response = self.client.post(reverse("s3upload"), data) 203 | self.assertEqual(response.status_code, 200) 204 | 205 | response_dict = json.loads(response.content.decode()) 206 | aws_payload = response_dict["aws_payload"] 207 | 208 | self.assertTrue("policy" in aws_payload) 209 | policy_dict = json.loads(b64decode(aws_payload["policy"]).decode("utf-8")) 210 | self.assertTrue("conditions" in policy_dict) 211 | conditions_dict = policy_dict["conditions"] 212 | self.assertEqual(conditions_dict[-1], ["content-length-range", 5000, 20000000]) 213 | -------------------------------------------------------------------------------- /s3upload/static/s3upload/css/bootstrap-progress.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v2.3.1 3 | * 4 | * Copyright 2012 Twitter, Inc 5 | * Licensed under the Apache License v2.0 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * 8 | * Designed and built with all the love in the world @twitter by @mdo and @fat. 9 | */ 10 | .clearfix{*zoom:1;}.clearfix:before,.clearfix:after{display:table;content:"";line-height:0;} 11 | .clearfix:after{clear:both;} 12 | .hide-text{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0;} 13 | .input-block-level{display:block;width:100%;min-height:30px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;} 14 | @-webkit-keyframes progress-bar-stripes{from{background-position:40px 0;} to{background-position:0 0;}}@-moz-keyframes progress-bar-stripes{from{background-position:40px 0;} to{background-position:0 0;}}@-ms-keyframes progress-bar-stripes{from{background-position:40px 0;} to{background-position:0 0;}}@-o-keyframes progress-bar-stripes{from{background-position:0 0;} to{background-position:40px 0;}}@keyframes progress-bar-stripes{from{background-position:40px 0;} to{background-position:0 0;}}.s3upload__progress{overflow:hidden;height:20px;margin-bottom:20px;background-color:#f7f7f7;background-image:-moz-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#f5f5f5), to(#f9f9f9));background-image:-webkit-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:-o-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:linear-gradient(to bottom, #f5f5f5, #f9f9f9);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#fff9f9f9', GradientType=0);-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;} 15 | .s3upload__progress .s3upload__bar{width:0%;height:100%;color:#ffffff;float:left;font-size:12px;text-align:center;text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);background-color:#0e90d2;background-image:-moz-linear-gradient(top, #149bdf, #0480be);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#149bdf), to(#0480be));background-image:-webkit-linear-gradient(top, #149bdf, #0480be);background-image:-o-linear-gradient(top, #149bdf, #0480be);background-image:linear-gradient(to bottom, #149bdf, #0480be);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff149bdf', endColorstr='#ff0480be', GradientType=0);-webkit-box-shadow:inset 0 -1px 0 rgba(0, 0, 0, 0.15);-moz-box-shadow:inset 0 -1px 0 rgba(0, 0, 0, 0.15);box-shadow:inset 0 -1px 0 rgba(0, 0, 0, 0.15);-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;-webkit-transition:width 0.6s ease;-moz-transition:width 0.6s ease;-o-transition:width 0.6s ease;transition:width 0.6s ease;} 16 | .s3upload__progress .s3upload__bar+.s3upload__bar{-webkit-box-shadow:inset 1px 0 0 rgba(0,0,0,.15), inset 0 -1px 0 rgba(0,0,0,.15);-moz-box-shadow:inset 1px 0 0 rgba(0,0,0,.15), inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 1px 0 0 rgba(0,0,0,.15), inset 0 -1px 0 rgba(0,0,0,.15);} 17 | .s3upload__progress-striped .s3upload__bar{background-color:#149bdf;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);-webkit-background-size:40px 40px;-moz-background-size:40px 40px;-o-background-size:40px 40px;background-size:40px 40px;} 18 | .s3upload__progress.active .s3upload__bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-moz-animation:progress-bar-stripes 2s linear infinite;-ms-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite;} 19 | .s3upload__progress-danger .s3upload__bar,.s3upload__progress .s3upload__bar-danger{background-color:#dd514c;background-image:-moz-linear-gradient(top, #ee5f5b, #c43c35);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ee5f5b), to(#c43c35));background-image:-webkit-linear-gradient(top, #ee5f5b, #c43c35);background-image:-o-linear-gradient(top, #ee5f5b, #c43c35);background-image:linear-gradient(to bottom, #ee5f5b, #c43c35);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffee5f5b', endColorstr='#ffc43c35', GradientType=0);} 20 | .s3upload__progress-danger.s3upload__progress-striped .s3upload__bar,.s3upload__progress-striped .s3upload__bar-danger{background-color:#ee5f5b;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);} 21 | .s3upload__progress-success .s3upload__bar,.s3upload__progress .s3upload__bar-success{background-color:#5eb95e;background-image:-moz-linear-gradient(top, #62c462, #57a957);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#62c462), to(#57a957));background-image:-webkit-linear-gradient(top, #62c462, #57a957);background-image:-o-linear-gradient(top, #62c462, #57a957);background-image:linear-gradient(to bottom, #62c462, #57a957);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff62c462', endColorstr='#ff57a957', GradientType=0);} 22 | .s3upload__progress-success.s3upload__progress-striped .s3upload__bar,.s3upload__progress-striped .s3upload__bar-success{background-color:#62c462;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);} 23 | .s3upload__progress-info .s3upload__bar,.s3upload__progress .s3upload__bar-info{background-color:#4bb1cf;background-image:-moz-linear-gradient(top, #5bc0de, #339bb9);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#5bc0de), to(#339bb9));background-image:-webkit-linear-gradient(top, #5bc0de, #339bb9);background-image:-o-linear-gradient(top, #5bc0de, #339bb9);background-image:linear-gradient(to bottom, #5bc0de, #339bb9);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff339bb9', GradientType=0);} 24 | .s3upload__progress-info.s3upload__progress-striped .s3upload__bar,.s3upload__progress-striped .s3upload__bar-info{background-color:#5bc0de;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);} 25 | .s3upload__progress-warning .s3upload__bar,.s3upload__progress .s3upload__bar-warning{background-color:#faa732;background-image:-moz-linear-gradient(top, #fbb450, #f89406);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#fbb450), to(#f89406));background-image:-webkit-linear-gradient(top, #fbb450, #f89406);background-image:-o-linear-gradient(top, #fbb450, #f89406);background-image:linear-gradient(to bottom, #fbb450, #f89406);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffbb450', endColorstr='#fff89406', GradientType=0);} 26 | .s3upload__progress-warning.s3upload__progress-striped .s3upload__bar,.s3upload__progress-striped .s3upload__bar-warning{background-color:#fbb450;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);} 27 | -------------------------------------------------------------------------------- /s3upload/static/s3upload/js/django-s3-uploads.min.js: -------------------------------------------------------------------------------- 1 | !function(){return function e(t,r,n){function o(i,s){if(!r[i]){if(!t[i]){var u="function"==typeof require&&require;if(!s&&u)return u(i,!0);if(a)return a(i,!0);var l=new Error("Cannot find module '"+i+"'");throw l.code="MODULE_NOT_FOUND",l}var d=r[i]={exports:{}};t[i][0].call(d.exports,function(e){var r=t[i][1][e];return o(r||e)},d,d.exports,e,t,r,n)}return r[i].exports}for(var a="function"==typeof require&&require,i=0;i")>-1?t.dispatch(_(o.i18n_strings.no_file_too_small)):r.indexOf("")>-1?t.dispatch(_(o.i18n_strings.no_file_too_large)):t.dispatch(_(o.i18n_strings.no_upload_failed))}},function(e,r){console.error("Error uploading",e,r),(0,i.raiseEvent)((0,s.getElement)(t),"s3upload:error",{status:e,xml:r}),t.dispatch(p()),t.dispatch(_(o.i18n_strings.no_upload_failed))}),{type:a.default.BEGIN_UPLOAD_TO_AWS}}),f=r.completeUploadToAWS=function(e,t){return{type:a.default.COMPLETE_UPLOAD_TO_AWS,url:t,filename:e}},p=r.didNotCompleteUploadToAWS=function(){return{type:a.default.DID_NOT_COMPLETE_UPLOAD_TO_AWS}},_=r.addError=function(e){return{type:a.default.ADD_ERROR,error:e}},v=(r.clearErrors=function(){return{type:a.default.CLEAR_ERRORS}},r.updateProgress=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return{type:a.default.UPDATE_PROGRESS,progress:e}})},{"../constants":3,"../store":8,"../utils":9}],2:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.View=void 0;var n=e("../actions"),o=e("../store"),a=e("../utils");r.View=function(e,t){return{renderFilename:function(){var e=(0,o.getFilename)(t),r=(0,o.getUrl)(t);e&&r?(this.$link.innerHTML=e,this.$link.setAttribute("href",r),this.$url.value=r.split("?")[0],this.$element.classList.add("link-active"),this.$element.classList.remove("form-active")):(this.$url.value="",this.$input.value="",this.$element.classList.add("form-active"),this.$element.classList.remove("link-active"))},renderError:function(){var e=(0,o.getError)(t);e?(this.$element.classList.add("has-error"),this.$element.classList.add("form-active"),this.$element.classList.remove("link-active"),this.$element.querySelector(".s3upload__file-input").value="",this.$element.querySelector(".s3upload__error").innerHTML=e):(this.$element.classList.remove("has-error"),this.$element.querySelector(".s3upload__error").innerHTML="")},renderUploadProgress:function(){var e=(0,o.getUploadProgress)(t);e>0?(this.$element.classList.add("progress-active"),this.$bar.style.width=e+"%"):(this.$element.classList.remove("progress-active"),this.$bar.style.width="0")},removeUpload:function(e){e.preventDefault(),t.dispatch((0,n.updateProgress)()),t.dispatch((0,n.removeUpload)()),(0,a.raiseEvent)(this.$element,"s3upload:clear-upload")},getUploadURL:function(e){var r=this.$input.files[0],o=this.$dest.value,a=this.$element.getAttribute("data-policy-url");t.dispatch((0,n.clearErrors)()),t.dispatch((0,n.getUploadURL)(r,o,a,t))},init:function(){this.$element=e,this.$url=e.querySelector(".s3upload__file-url"),this.$input=e.querySelector(".s3upload__file-input"),this.$remove=e.querySelector(".s3upload__file-remove"),this.$dest=e.querySelector(".s3upload__file-dest"),this.$link=e.querySelector(".s3upload__file-link"),this.$error=e.querySelector(".s3upload__error"),this.$bar=e.querySelector(".s3upload__bar");var r=""===this.$url.value?"form":"link";this.$element.classList.add(r+"-active"),this.$remove.addEventListener("click",this.removeUpload.bind(this)),this.$input.addEventListener("change",this.getUploadURL.bind(this)),(0,a.observeStore)(t,function(e){return e.appStatus.filename},this.renderFilename.bind(this)),(0,a.observeStore)(t,function(e){return e.appStatus.error},this.renderError.bind(this)),(0,a.observeStore)(t,function(e){return e.appStatus.uploadProgress},this.renderUploadProgress.bind(this))}}}},{"../actions":1,"../store":8,"../utils":9}],3:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.default={REQUEST_AWS_UPLOAD_PARAMS:"REQUEST_AWS_UPLOAD_PARAMS",RECEIVE_AWS_UPLOAD_PARAMS:"RECEIVE_AWS_UPLOAD_PARAMS",DID_NOT_RECEIVE_AWS_UPLOAD_PARAMS:"DID_NOT_RECEIVE_AWS_UPLOAD_PARAMS",REMOVE_UPLOAD:"REMOVE_UPLOAD",BEGIN_UPLOAD_TO_AWS:"BEGIN_UPLOAD_TO_AWS",COMPLETE_UPLOAD_TO_AWS:"COMPLETE_UPLOAD_TO_AWS",DID_NOT_COMPLETE_UPLOAD_TO_AWS:"DID_NOT_COMPLETE_UPLOAD_TO_AWS",ADD_ERROR:"ADD_ERROR",CLEAR_ERRORS:"CLEAR_ERRORS",UPDATE_PROGRESS:"UPDATE_PROGRESS",RECEIVE_SIGNED_URL:"RECEIVE_SIGNED_URL"};var n=void 0;try{r.i18n_strings=n=djangoS3Upload.i18n_strings}catch(e){r.i18n_strings=n={no_upload_failed:"Sorry, failed to upload file.",no_upload_url:"Sorry, could not get upload URL.",no_file_too_large:"Sorry, the file is too large to be uploaded.",no_file_too_small:"Sorry, the file is too small to be uploaded."}}r.i18n_strings=n},{}],4:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n,o=e("../constants"),a=(n=o)&&n.__esModule?n:{default:n};r.default=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=arguments[1];switch(t.type){case a.default.BEGIN_UPLOAD_TO_AWS:return Object.assign({},e,{isUploading:!0});case a.default.COMPLETE_UPLOAD_TO_AWS:return Object.assign({},e,{isUploading:!1,uploadProgress:0,filename:t.filename,url:t.url});case a.default.DID_NOT_COMPLETE_UPLOAD_TO_AWS:return Object.assign({},e,{isUploading:!1});case a.default.REMOVE_UPLOAD:return Object.assign({},e,{filename:null,url:null});case a.default.ADD_ERROR:return Object.assign({},e,{error:t.error});case a.default.CLEAR_ERRORS:return Object.assign({},e,{error:null});case a.default.UPDATE_PROGRESS:return Object.assign({},e,{uploadProgress:t.progress});case a.default.RECEIVE_SIGNED_URL:return Object.assign({},e,{signedURL:t.signedURL});default:return e}}},{"../constants":3}],5:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n,o=e("../constants"),a=(n=o)&&n.__esModule?n:{default:n};r.default=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=arguments[1];switch(t.type){case a.default.REQUEST_AWS_UPLOAD_PARAMS:return Object.assign({},e,{isLoading:!0});case a.default.RECEIVE_AWS_UPLOAD_PARAMS:return Object.assign({},e,{isLoading:!1,AWSPayload:t.aws_payload});case a.default.DID_NOT_RECEIVE_AWS_UPLOAD_PARAMS:return Object.assign({},e,{isLoading:!1});default:return e}}},{"../constants":3}],6:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n=e("redux"),o=i(e("./awsUploadsParams")),a=i(e("./appStatus"));function i(e){return e&&e.__esModule?e:{default:e}}r.default=(0,n.combineReducers)({AWSUploadParams:o.default,appStatus:a.default,element:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};arguments[1];return e}})},{"./appStatus":4,"./awsUploadsParams":5,redux:26}],7:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.getFilename=function(e){return e.getState().appStatus.filename},r.getUrl=function(e){return e.getState().appStatus.signedURL||e.getState().appStatus.url},r.getError=function(e){return e.getState().appStatus.error},r.getUploadProgress=function(e){return e.getState().appStatus.uploadProgress},r.getElement=function(e){return e.getState().element},r.getAWSPayload=function(e){return e.getState().AWSUploadParams.AWSPayload}},{}],8:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.default=function(e){return(0,a.createStore)(s.default,e,u&&u())};var n=e("./connect");Object.keys(n).forEach(function(e){"default"!==e&&"__esModule"!==e&&Object.defineProperty(r,e,{enumerable:!0,get:function(){return n[e]}})});var o,a=e("redux"),i=e("../reducers"),s=(o=i)&&o.__esModule?o:{default:o};var u=window.devToolsExtension},{"../reducers":6,"./connect":7,redux:26}],9:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.observeStore=r.raiseEvent=r.parseJson=r.parseNameFromUrl=r.parseURL=r.request=r.getCookie=void 0;e("../constants"),r.getCookie=function(e){var t=("; "+document.cookie).split("; "+e+"=");if(2==t.length)return t.pop().split(";").shift()},r.request=function(e,t,r,n,o,a,i){var s=new XMLHttpRequest;s.open(e,t,!0),Object.keys(n).forEach(function(e){s.setRequestHeader(e,n[e])}),s.onload=function(){a(s.status,s.responseText)},i&&(s.onerror=s.onabort=function(){i(s.status,s.responseText)}),o&&(s.upload.onprogress=function(e){o(e)}),s.send(r)},r.parseURL=function(e){var t=(new DOMParser).parseFromString(e,"text/xml").getElementsByTagName("Location")[0];return decodeURIComponent(t.childNodes[0].nodeValue)},r.parseNameFromUrl=function(e){return decodeURIComponent((e+"").replace(/\+/g,"%20"))},r.parseJson=function(e){var t;try{t=JSON.parse(e)}catch(e){t=null}return t},r.raiseEvent=function(e,t,r){if(window.CustomEvent){var n=new CustomEvent(t,{detail:r,bubbles:!0});e.dispatchEvent(n)}},r.observeStore=function(e,t,r){var n=void 0;function o(){var o=t(e.getState());o!==n&&r(n=o)}var a=e.subscribe(o);return o(),a}},{"../constants":3}],10:[function(e,t,r){"use strict";var n,o=e("./store"),a=(n=o)&&n.__esModule?n:{default:n},i=e("./components");function s(e){var t=".s3upload";e.detail&&e.detail.selector&&(t=e.detail.selector);for(var r=document.querySelectorAll(t),n=0;n0&&void 0!==arguments[0]?arguments[0]:{},t=arguments[1];if(s)throw s;for(var n,o,a,u=!1,l={},d=0;d') > -1) { 129 | store.dispatch(addError(_constants.i18n_strings.no_file_too_small)); 130 | } else if (xml.indexOf('') > -1) { 131 | store.dispatch(addError(_constants.i18n_strings.no_file_too_large)); 132 | } else { 133 | store.dispatch(addError(_constants.i18n_strings.no_upload_failed)); 134 | } 135 | 136 | break; 137 | } 138 | }; 139 | 140 | var onError = function onError(status, xml) { 141 | console.error('Error uploading', status, xml); 142 | (0, _utils.raiseEvent)((0, _store.getElement)(store), 's3upload:error', { status: status, xml: xml }); 143 | 144 | store.dispatch(didNotCompleteUploadToAWS()); 145 | store.dispatch(addError(_constants.i18n_strings.no_upload_failed)); 146 | }; 147 | 148 | var onProgress = function onProgress(data) { 149 | var progress = null; 150 | 151 | if (data.lengthComputable) { 152 | progress = Math.round(data.loaded * 100 / data.total); 153 | } 154 | 155 | store.dispatch(updateProgress(progress)); 156 | (0, _utils.raiseEvent)((0, _store.getElement)(store), 's3upload:progress-updated', { progress: progress }); 157 | }; 158 | 159 | (0, _utils.request)('POST', url, form, headers, onProgress, onLoad, onError); 160 | 161 | return { 162 | type: _constants2.default.BEGIN_UPLOAD_TO_AWS 163 | }; 164 | }; 165 | 166 | var completeUploadToAWS = exports.completeUploadToAWS = function completeUploadToAWS(filename, url) { 167 | return { 168 | type: _constants2.default.COMPLETE_UPLOAD_TO_AWS, 169 | url: url, 170 | filename: filename 171 | }; 172 | }; 173 | 174 | var didNotCompleteUploadToAWS = exports.didNotCompleteUploadToAWS = function didNotCompleteUploadToAWS() { 175 | return { 176 | type: _constants2.default.DID_NOT_COMPLETE_UPLOAD_TO_AWS 177 | }; 178 | }; 179 | 180 | var addError = exports.addError = function addError(error) { 181 | return { 182 | type: _constants2.default.ADD_ERROR, 183 | error: error 184 | }; 185 | }; 186 | 187 | var clearErrors = exports.clearErrors = function clearErrors() { 188 | return { 189 | type: _constants2.default.CLEAR_ERRORS 190 | }; 191 | }; 192 | 193 | var updateProgress = exports.updateProgress = function updateProgress() { 194 | var progress = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 195 | 196 | return { 197 | type: _constants2.default.UPDATE_PROGRESS, 198 | progress: progress 199 | }; 200 | }; 201 | 202 | },{"../constants":3,"../store":8,"../utils":9}],2:[function(require,module,exports){ 203 | 'use strict'; 204 | 205 | Object.defineProperty(exports, "__esModule", { 206 | value: true 207 | }); 208 | exports.View = undefined; 209 | 210 | var _actions = require('../actions'); 211 | 212 | var _store = require('../store'); 213 | 214 | var _utils = require('../utils'); 215 | 216 | var View = function View(element, store) { 217 | return { 218 | renderFilename: function renderFilename() { 219 | var filename = (0, _store.getFilename)(store), 220 | url = (0, _store.getUrl)(store); 221 | 222 | if (filename && url) { 223 | this.$link.innerHTML = filename; 224 | this.$link.setAttribute('href', url); 225 | this.$url.value = url.split("?")[0]; 226 | 227 | this.$element.classList.add('link-active'); 228 | this.$element.classList.remove('form-active'); 229 | } else { 230 | this.$url.value = ''; 231 | this.$input.value = ''; 232 | 233 | this.$element.classList.add('form-active'); 234 | this.$element.classList.remove('link-active'); 235 | } 236 | }, 237 | 238 | renderError: function renderError() { 239 | var error = (0, _store.getError)(store); 240 | 241 | if (error) { 242 | this.$element.classList.add('has-error'); 243 | this.$element.classList.add('form-active'); 244 | this.$element.classList.remove('link-active'); 245 | 246 | this.$element.querySelector('.s3upload__file-input').value = ''; 247 | this.$element.querySelector('.s3upload__error').innerHTML = error; 248 | } else { 249 | this.$element.classList.remove('has-error'); 250 | this.$element.querySelector('.s3upload__error').innerHTML = ''; 251 | } 252 | }, 253 | 254 | renderUploadProgress: function renderUploadProgress() { 255 | var uploadProgress = (0, _store.getUploadProgress)(store); 256 | 257 | if (uploadProgress > 0) { 258 | this.$element.classList.add('progress-active'); 259 | this.$bar.style.width = uploadProgress + '%'; 260 | } else { 261 | this.$element.classList.remove('progress-active'); 262 | this.$bar.style.width = '0'; 263 | } 264 | }, 265 | 266 | removeUpload: function removeUpload(event) { 267 | event.preventDefault(); 268 | 269 | store.dispatch((0, _actions.updateProgress)()); 270 | store.dispatch((0, _actions.removeUpload)()); 271 | (0, _utils.raiseEvent)(this.$element, 's3upload:clear-upload'); 272 | }, 273 | 274 | getUploadURL: function getUploadURL(event) { 275 | var file = this.$input.files[0], 276 | dest = this.$dest.value, 277 | url = this.$element.getAttribute('data-policy-url'); 278 | 279 | store.dispatch((0, _actions.clearErrors)()); 280 | store.dispatch((0, _actions.getUploadURL)(file, dest, url, store)); 281 | }, 282 | 283 | init: function init() { 284 | // cache all the query selectors 285 | // $variables represent DOM elements 286 | this.$element = element; 287 | this.$url = element.querySelector('.s3upload__file-url'); 288 | this.$input = element.querySelector('.s3upload__file-input'); 289 | this.$remove = element.querySelector('.s3upload__file-remove'); 290 | this.$dest = element.querySelector('.s3upload__file-dest'); 291 | this.$link = element.querySelector('.s3upload__file-link'); 292 | this.$error = element.querySelector('.s3upload__error'); 293 | this.$bar = element.querySelector('.s3upload__bar'); 294 | 295 | // set initial DOM states3upload__ 296 | var status = this.$url.value === '' ? 'form' : 'link'; 297 | this.$element.classList.add(status + '-active'); 298 | 299 | // add event listeners 300 | this.$remove.addEventListener('click', this.removeUpload.bind(this)); 301 | this.$input.addEventListener('change', this.getUploadURL.bind(this)); 302 | 303 | // these three observers subscribe to the store, but only trigger their 304 | // callbacks when the specific piece of state they observe changes. 305 | // this allows for a less naive approach to rendering changes than a 306 | // render method subscribed to the whole state. 307 | var filenameObserver = (0, _utils.observeStore)(store, function (state) { 308 | return state.appStatus.filename; 309 | }, this.renderFilename.bind(this)); 310 | 311 | var errorObserver = (0, _utils.observeStore)(store, function (state) { 312 | return state.appStatus.error; 313 | }, this.renderError.bind(this)); 314 | 315 | var uploadProgressObserver = (0, _utils.observeStore)(store, function (state) { 316 | return state.appStatus.uploadProgress; 317 | }, this.renderUploadProgress.bind(this)); 318 | } 319 | }; 320 | }; 321 | 322 | exports.View = View; 323 | 324 | },{"../actions":1,"../store":8,"../utils":9}],3:[function(require,module,exports){ 325 | 'use strict'; 326 | 327 | Object.defineProperty(exports, "__esModule", { 328 | value: true 329 | }); 330 | exports.default = { 331 | REQUEST_AWS_UPLOAD_PARAMS: 'REQUEST_AWS_UPLOAD_PARAMS', 332 | RECEIVE_AWS_UPLOAD_PARAMS: 'RECEIVE_AWS_UPLOAD_PARAMS', 333 | DID_NOT_RECEIVE_AWS_UPLOAD_PARAMS: 'DID_NOT_RECEIVE_AWS_UPLOAD_PARAMS', 334 | REMOVE_UPLOAD: 'REMOVE_UPLOAD', 335 | BEGIN_UPLOAD_TO_AWS: 'BEGIN_UPLOAD_TO_AWS', 336 | COMPLETE_UPLOAD_TO_AWS: 'COMPLETE_UPLOAD_TO_AWS', 337 | DID_NOT_COMPLETE_UPLOAD_TO_AWS: 'DID_NOT_COMPLETE_UPLOAD_TO_AWS', 338 | ADD_ERROR: 'ADD_ERROR', 339 | CLEAR_ERRORS: 'CLEAR_ERRORS', 340 | UPDATE_PROGRESS: 'UPDATE_PROGRESS', 341 | RECEIVE_SIGNED_URL: 'RECEIVE_SIGNED_URL' 342 | }; 343 | 344 | 345 | var i18n_strings = void 0; 346 | 347 | try { 348 | exports.i18n_strings = i18n_strings = djangoS3Upload.i18n_strings; 349 | } catch (e) { 350 | exports.i18n_strings = i18n_strings = { 351 | "no_upload_failed": "Sorry, failed to upload file.", 352 | "no_upload_url": "Sorry, could not get upload URL.", 353 | "no_file_too_large": "Sorry, the file is too large to be uploaded.", 354 | "no_file_too_small": "Sorry, the file is too small to be uploaded." 355 | }; 356 | } 357 | 358 | exports.i18n_strings = i18n_strings; 359 | 360 | },{}],4:[function(require,module,exports){ 361 | 'use strict'; 362 | 363 | Object.defineProperty(exports, "__esModule", { 364 | value: true 365 | }); 366 | 367 | var _constants = require('../constants'); 368 | 369 | var _constants2 = _interopRequireDefault(_constants); 370 | 371 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 372 | 373 | exports.default = function () { 374 | var state = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 375 | var action = arguments[1]; 376 | 377 | switch (action.type) { 378 | case _constants2.default.BEGIN_UPLOAD_TO_AWS: 379 | return Object.assign({}, state, { 380 | isUploading: true 381 | }); 382 | case _constants2.default.COMPLETE_UPLOAD_TO_AWS: 383 | return Object.assign({}, state, { 384 | isUploading: false, 385 | uploadProgress: 0, 386 | filename: action.filename, 387 | url: action.url 388 | }); 389 | case _constants2.default.DID_NOT_COMPLETE_UPLOAD_TO_AWS: 390 | return Object.assign({}, state, { 391 | isUploading: false 392 | }); 393 | case _constants2.default.REMOVE_UPLOAD: 394 | return Object.assign({}, state, { 395 | filename: null, 396 | url: null 397 | }); 398 | case _constants2.default.ADD_ERROR: 399 | return Object.assign({}, state, { 400 | error: action.error 401 | }); 402 | case _constants2.default.CLEAR_ERRORS: 403 | return Object.assign({}, state, { 404 | error: null 405 | }); 406 | case _constants2.default.UPDATE_PROGRESS: 407 | return Object.assign({}, state, { 408 | uploadProgress: action.progress 409 | }); 410 | case _constants2.default.RECEIVE_SIGNED_URL: 411 | { 412 | return Object.assign({}, state, { 413 | signedURL: action.signedURL 414 | }); 415 | } 416 | 417 | default: 418 | return state; 419 | } 420 | }; 421 | 422 | },{"../constants":3}],5:[function(require,module,exports){ 423 | 'use strict'; 424 | 425 | Object.defineProperty(exports, "__esModule", { 426 | value: true 427 | }); 428 | 429 | var _constants = require('../constants'); 430 | 431 | var _constants2 = _interopRequireDefault(_constants); 432 | 433 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 434 | 435 | exports.default = function () { 436 | var state = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 437 | var action = arguments[1]; 438 | 439 | switch (action.type) { 440 | case _constants2.default.REQUEST_AWS_UPLOAD_PARAMS: 441 | return Object.assign({}, state, { 442 | isLoading: true 443 | }); 444 | case _constants2.default.RECEIVE_AWS_UPLOAD_PARAMS: 445 | return Object.assign({}, state, { 446 | isLoading: false, 447 | AWSPayload: action.aws_payload 448 | }); 449 | case _constants2.default.DID_NOT_RECEIVE_AWS_UPLOAD_PARAMS: 450 | // Returns current state and sets loading to false 451 | return Object.assign({}, state, { 452 | isLoading: false 453 | }); 454 | default: 455 | return state; // reducer must return by default 456 | } 457 | }; 458 | 459 | },{"../constants":3}],6:[function(require,module,exports){ 460 | 'use strict'; 461 | 462 | Object.defineProperty(exports, "__esModule", { 463 | value: true 464 | }); 465 | 466 | var _redux = require('redux'); 467 | 468 | var _awsUploadsParams = require('./awsUploadsParams'); 469 | 470 | var _awsUploadsParams2 = _interopRequireDefault(_awsUploadsParams); 471 | 472 | var _appStatus = require('./appStatus'); 473 | 474 | var _appStatus2 = _interopRequireDefault(_appStatus); 475 | 476 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 477 | 478 | var element = function element() { 479 | var state = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 480 | var action = arguments[1]; 481 | 482 | return state; // reducer must return by default 483 | }; 484 | 485 | exports.default = (0, _redux.combineReducers)({ 486 | AWSUploadParams: _awsUploadsParams2.default, 487 | appStatus: _appStatus2.default, 488 | element: element 489 | }); 490 | 491 | },{"./appStatus":4,"./awsUploadsParams":5,"redux":26}],7:[function(require,module,exports){ 492 | "use strict"; 493 | 494 | Object.defineProperty(exports, "__esModule", { 495 | value: true 496 | }); 497 | exports.getFilename = getFilename; 498 | exports.getUrl = getUrl; 499 | exports.getError = getError; 500 | exports.getUploadProgress = getUploadProgress; 501 | exports.getElement = getElement; 502 | exports.getAWSPayload = getAWSPayload; 503 | function getFilename(store) { 504 | return store.getState().appStatus.filename; 505 | } 506 | 507 | function getUrl(store) { 508 | var url = store.getState().appStatus.signedURL || store.getState().appStatus.url; 509 | 510 | return url; 511 | } 512 | 513 | function getError(store) { 514 | return store.getState().appStatus.error; 515 | } 516 | 517 | function getUploadProgress(store) { 518 | return store.getState().appStatus.uploadProgress; 519 | } 520 | 521 | function getElement(store) { 522 | return store.getState().element; 523 | } 524 | 525 | function getAWSPayload(store) { 526 | return store.getState().AWSUploadParams.AWSPayload; 527 | } 528 | 529 | },{}],8:[function(require,module,exports){ 530 | 'use strict'; 531 | 532 | Object.defineProperty(exports, "__esModule", { 533 | value: true 534 | }); 535 | exports.default = configureStore; 536 | 537 | var _connect = require('./connect'); 538 | 539 | Object.keys(_connect).forEach(function (key) { 540 | if (key === "default" || key === "__esModule") return; 541 | Object.defineProperty(exports, key, { 542 | enumerable: true, 543 | get: function get() { 544 | return _connect[key]; 545 | } 546 | }); 547 | }); 548 | 549 | var _redux = require('redux'); 550 | 551 | var _reducers = require('../reducers'); 552 | 553 | var _reducers2 = _interopRequireDefault(_reducers); 554 | 555 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 556 | 557 | var _window = window, 558 | devToolsExtension = _window.devToolsExtension; 559 | function configureStore(initialState) { 560 | return (0, _redux.createStore)(_reducers2.default, initialState, devToolsExtension && devToolsExtension()); 561 | } 562 | 563 | },{"../reducers":6,"./connect":7,"redux":26}],9:[function(require,module,exports){ 564 | 'use strict'; 565 | 566 | Object.defineProperty(exports, "__esModule", { 567 | value: true 568 | }); 569 | exports.observeStore = exports.raiseEvent = exports.parseJson = exports.parseNameFromUrl = exports.parseURL = exports.request = exports.getCookie = undefined; 570 | 571 | var _constants = require('../constants'); 572 | 573 | var getCookie = exports.getCookie = function getCookie(name) { 574 | var value = '; ' + document.cookie, 575 | parts = value.split('; ' + name + '='); 576 | if (parts.length == 2) return parts.pop().split(';').shift(); 577 | }; 578 | 579 | var request = exports.request = function request(method, url, data, headers, onProgress, onLoad, onError) { 580 | var request = new XMLHttpRequest(); 581 | request.open(method, url, true); 582 | 583 | Object.keys(headers).forEach(function (key) { 584 | request.setRequestHeader(key, headers[key]); 585 | }); 586 | 587 | request.onload = function () { 588 | onLoad(request.status, request.responseText); 589 | }; 590 | 591 | if (onError) { 592 | request.onerror = request.onabort = function () { 593 | onError(request.status, request.responseText); 594 | }; 595 | } 596 | 597 | if (onProgress) { 598 | request.upload.onprogress = function (data) { 599 | onProgress(data); 600 | }; 601 | } 602 | 603 | request.send(data); 604 | }; 605 | 606 | var parseURL = exports.parseURL = function parseURL(text) { 607 | var xml = new DOMParser().parseFromString(text, 'text/xml'), 608 | tag = xml.getElementsByTagName('Location')[0], 609 | url = decodeURIComponent(tag.childNodes[0].nodeValue); 610 | 611 | return url; 612 | }; 613 | 614 | var parseNameFromUrl = exports.parseNameFromUrl = function parseNameFromUrl(url) { 615 | return decodeURIComponent((url + '').replace(/\+/g, '%20')); 616 | }; 617 | 618 | var parseJson = exports.parseJson = function parseJson(json) { 619 | var data; 620 | 621 | try { 622 | data = JSON.parse(json); 623 | } catch (error) { 624 | data = null; 625 | }; 626 | 627 | return data; 628 | }; 629 | 630 | var raiseEvent = exports.raiseEvent = function raiseEvent(element, name, detail) { 631 | if (window.CustomEvent) { 632 | var event = new CustomEvent(name, { detail: detail, bubbles: true }); 633 | element.dispatchEvent(event); 634 | } 635 | }; 636 | 637 | var observeStore = exports.observeStore = function observeStore(store, select, onChange) { 638 | var currentState = void 0; 639 | 640 | function handleChange() { 641 | var nextState = select(store.getState()); 642 | 643 | if (nextState !== currentState) { 644 | currentState = nextState; 645 | onChange(currentState); 646 | } 647 | } 648 | 649 | var unsubscribe = store.subscribe(handleChange); 650 | handleChange(); 651 | return unsubscribe; 652 | }; 653 | 654 | },{"../constants":3}],10:[function(require,module,exports){ 655 | 'use strict'; 656 | 657 | var _store = require('./store'); 658 | 659 | var _store2 = _interopRequireDefault(_store); 660 | 661 | var _components = require('./components'); 662 | 663 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 664 | 665 | // by default initHandler inits on '.s3upload', but if passed a custom 666 | // selector in the event data, it will init on that instead. 667 | function initHandler(event) { 668 | var selector = '.s3upload'; 669 | 670 | if (event.detail && event.detail.selector) { 671 | selector = event.detail.selector; 672 | } 673 | 674 | var elements = document.querySelectorAll(selector); 675 | 676 | // safari doesn't like forEach on nodeList objects 677 | for (var i = 0; i < elements.length; i++) { 678 | // initialise instance for each element 679 | var element = elements[i]; 680 | var store = (0, _store2.default)({ element: element }); 681 | var view = new _components.View(element, store); 682 | view.init(); 683 | } 684 | } 685 | 686 | // default global init on document ready 687 | document.addEventListener('DOMContentLoaded', initHandler); 688 | 689 | // custom event listener for use in async init 690 | document.addEventListener('s3upload:init', initHandler); 691 | 692 | },{"./components":2,"./store":8}],11:[function(require,module,exports){ 693 | var root = require('./_root'); 694 | 695 | /** Built-in value references. */ 696 | var Symbol = root.Symbol; 697 | 698 | module.exports = Symbol; 699 | 700 | },{"./_root":18}],12:[function(require,module,exports){ 701 | var Symbol = require('./_Symbol'), 702 | getRawTag = require('./_getRawTag'), 703 | objectToString = require('./_objectToString'); 704 | 705 | /** `Object#toString` result references. */ 706 | var nullTag = '[object Null]', 707 | undefinedTag = '[object Undefined]'; 708 | 709 | /** Built-in value references. */ 710 | var symToStringTag = Symbol ? Symbol.toStringTag : undefined; 711 | 712 | /** 713 | * The base implementation of `getTag` without fallbacks for buggy environments. 714 | * 715 | * @private 716 | * @param {*} value The value to query. 717 | * @returns {string} Returns the `toStringTag`. 718 | */ 719 | function baseGetTag(value) { 720 | if (value == null) { 721 | return value === undefined ? undefinedTag : nullTag; 722 | } 723 | return (symToStringTag && symToStringTag in Object(value)) 724 | ? getRawTag(value) 725 | : objectToString(value); 726 | } 727 | 728 | module.exports = baseGetTag; 729 | 730 | },{"./_Symbol":11,"./_getRawTag":15,"./_objectToString":16}],13:[function(require,module,exports){ 731 | (function (global){ 732 | /** Detect free variable `global` from Node.js. */ 733 | var freeGlobal = typeof global == 'object' && global && global.Object === Object && global; 734 | 735 | module.exports = freeGlobal; 736 | 737 | }).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) 738 | },{}],14:[function(require,module,exports){ 739 | var overArg = require('./_overArg'); 740 | 741 | /** Built-in value references. */ 742 | var getPrototype = overArg(Object.getPrototypeOf, Object); 743 | 744 | module.exports = getPrototype; 745 | 746 | },{"./_overArg":17}],15:[function(require,module,exports){ 747 | var Symbol = require('./_Symbol'); 748 | 749 | /** Used for built-in method references. */ 750 | var objectProto = Object.prototype; 751 | 752 | /** Used to check objects for own properties. */ 753 | var hasOwnProperty = objectProto.hasOwnProperty; 754 | 755 | /** 756 | * Used to resolve the 757 | * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring) 758 | * of values. 759 | */ 760 | var nativeObjectToString = objectProto.toString; 761 | 762 | /** Built-in value references. */ 763 | var symToStringTag = Symbol ? Symbol.toStringTag : undefined; 764 | 765 | /** 766 | * A specialized version of `baseGetTag` which ignores `Symbol.toStringTag` values. 767 | * 768 | * @private 769 | * @param {*} value The value to query. 770 | * @returns {string} Returns the raw `toStringTag`. 771 | */ 772 | function getRawTag(value) { 773 | var isOwn = hasOwnProperty.call(value, symToStringTag), 774 | tag = value[symToStringTag]; 775 | 776 | try { 777 | value[symToStringTag] = undefined; 778 | var unmasked = true; 779 | } catch (e) {} 780 | 781 | var result = nativeObjectToString.call(value); 782 | if (unmasked) { 783 | if (isOwn) { 784 | value[symToStringTag] = tag; 785 | } else { 786 | delete value[symToStringTag]; 787 | } 788 | } 789 | return result; 790 | } 791 | 792 | module.exports = getRawTag; 793 | 794 | },{"./_Symbol":11}],16:[function(require,module,exports){ 795 | /** Used for built-in method references. */ 796 | var objectProto = Object.prototype; 797 | 798 | /** 799 | * Used to resolve the 800 | * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring) 801 | * of values. 802 | */ 803 | var nativeObjectToString = objectProto.toString; 804 | 805 | /** 806 | * Converts `value` to a string using `Object.prototype.toString`. 807 | * 808 | * @private 809 | * @param {*} value The value to convert. 810 | * @returns {string} Returns the converted string. 811 | */ 812 | function objectToString(value) { 813 | return nativeObjectToString.call(value); 814 | } 815 | 816 | module.exports = objectToString; 817 | 818 | },{}],17:[function(require,module,exports){ 819 | /** 820 | * Creates a unary function that invokes `func` with its argument transformed. 821 | * 822 | * @private 823 | * @param {Function} func The function to wrap. 824 | * @param {Function} transform The argument transform. 825 | * @returns {Function} Returns the new function. 826 | */ 827 | function overArg(func, transform) { 828 | return function(arg) { 829 | return func(transform(arg)); 830 | }; 831 | } 832 | 833 | module.exports = overArg; 834 | 835 | },{}],18:[function(require,module,exports){ 836 | var freeGlobal = require('./_freeGlobal'); 837 | 838 | /** Detect free variable `self`. */ 839 | var freeSelf = typeof self == 'object' && self && self.Object === Object && self; 840 | 841 | /** Used as a reference to the global object. */ 842 | var root = freeGlobal || freeSelf || Function('return this')(); 843 | 844 | module.exports = root; 845 | 846 | },{"./_freeGlobal":13}],19:[function(require,module,exports){ 847 | /** 848 | * Checks if `value` is object-like. A value is object-like if it's not `null` 849 | * and has a `typeof` result of "object". 850 | * 851 | * @static 852 | * @memberOf _ 853 | * @since 4.0.0 854 | * @category Lang 855 | * @param {*} value The value to check. 856 | * @returns {boolean} Returns `true` if `value` is object-like, else `false`. 857 | * @example 858 | * 859 | * _.isObjectLike({}); 860 | * // => true 861 | * 862 | * _.isObjectLike([1, 2, 3]); 863 | * // => true 864 | * 865 | * _.isObjectLike(_.noop); 866 | * // => false 867 | * 868 | * _.isObjectLike(null); 869 | * // => false 870 | */ 871 | function isObjectLike(value) { 872 | return value != null && typeof value == 'object'; 873 | } 874 | 875 | module.exports = isObjectLike; 876 | 877 | },{}],20:[function(require,module,exports){ 878 | var baseGetTag = require('./_baseGetTag'), 879 | getPrototype = require('./_getPrototype'), 880 | isObjectLike = require('./isObjectLike'); 881 | 882 | /** `Object#toString` result references. */ 883 | var objectTag = '[object Object]'; 884 | 885 | /** Used for built-in method references. */ 886 | var funcProto = Function.prototype, 887 | objectProto = Object.prototype; 888 | 889 | /** Used to resolve the decompiled source of functions. */ 890 | var funcToString = funcProto.toString; 891 | 892 | /** Used to check objects for own properties. */ 893 | var hasOwnProperty = objectProto.hasOwnProperty; 894 | 895 | /** Used to infer the `Object` constructor. */ 896 | var objectCtorString = funcToString.call(Object); 897 | 898 | /** 899 | * Checks if `value` is a plain object, that is, an object created by the 900 | * `Object` constructor or one with a `[[Prototype]]` of `null`. 901 | * 902 | * @static 903 | * @memberOf _ 904 | * @since 0.8.0 905 | * @category Lang 906 | * @param {*} value The value to check. 907 | * @returns {boolean} Returns `true` if `value` is a plain object, else `false`. 908 | * @example 909 | * 910 | * function Foo() { 911 | * this.a = 1; 912 | * } 913 | * 914 | * _.isPlainObject(new Foo); 915 | * // => false 916 | * 917 | * _.isPlainObject([1, 2, 3]); 918 | * // => false 919 | * 920 | * _.isPlainObject({ 'x': 0, 'y': 0 }); 921 | * // => true 922 | * 923 | * _.isPlainObject(Object.create(null)); 924 | * // => true 925 | */ 926 | function isPlainObject(value) { 927 | if (!isObjectLike(value) || baseGetTag(value) != objectTag) { 928 | return false; 929 | } 930 | var proto = getPrototype(value); 931 | if (proto === null) { 932 | return true; 933 | } 934 | var Ctor = hasOwnProperty.call(proto, 'constructor') && proto.constructor; 935 | return typeof Ctor == 'function' && Ctor instanceof Ctor && 936 | funcToString.call(Ctor) == objectCtorString; 937 | } 938 | 939 | module.exports = isPlainObject; 940 | 941 | },{"./_baseGetTag":12,"./_getPrototype":14,"./isObjectLike":19}],21:[function(require,module,exports){ 942 | 'use strict'; 943 | 944 | exports.__esModule = true; 945 | 946 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 947 | 948 | exports['default'] = applyMiddleware; 949 | 950 | var _compose = require('./compose'); 951 | 952 | var _compose2 = _interopRequireDefault(_compose); 953 | 954 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 955 | 956 | /** 957 | * Creates a store enhancer that applies middleware to the dispatch method 958 | * of the Redux store. This is handy for a variety of tasks, such as expressing 959 | * asynchronous actions in a concise manner, or logging every action payload. 960 | * 961 | * See `redux-thunk` package as an example of the Redux middleware. 962 | * 963 | * Because middleware is potentially asynchronous, this should be the first 964 | * store enhancer in the composition chain. 965 | * 966 | * Note that each middleware will be given the `dispatch` and `getState` functions 967 | * as named arguments. 968 | * 969 | * @param {...Function} middlewares The middleware chain to be applied. 970 | * @returns {Function} A store enhancer applying the middleware. 971 | */ 972 | function applyMiddleware() { 973 | for (var _len = arguments.length, middlewares = Array(_len), _key = 0; _key < _len; _key++) { 974 | middlewares[_key] = arguments[_key]; 975 | } 976 | 977 | return function (createStore) { 978 | return function (reducer, preloadedState, enhancer) { 979 | var store = createStore(reducer, preloadedState, enhancer); 980 | var _dispatch = store.dispatch; 981 | var chain = []; 982 | 983 | var middlewareAPI = { 984 | getState: store.getState, 985 | dispatch: function dispatch(action) { 986 | return _dispatch(action); 987 | } 988 | }; 989 | chain = middlewares.map(function (middleware) { 990 | return middleware(middlewareAPI); 991 | }); 992 | _dispatch = _compose2['default'].apply(undefined, chain)(store.dispatch); 993 | 994 | return _extends({}, store, { 995 | dispatch: _dispatch 996 | }); 997 | }; 998 | }; 999 | } 1000 | },{"./compose":24}],22:[function(require,module,exports){ 1001 | 'use strict'; 1002 | 1003 | exports.__esModule = true; 1004 | exports['default'] = bindActionCreators; 1005 | function bindActionCreator(actionCreator, dispatch) { 1006 | return function () { 1007 | return dispatch(actionCreator.apply(undefined, arguments)); 1008 | }; 1009 | } 1010 | 1011 | /** 1012 | * Turns an object whose values are action creators, into an object with the 1013 | * same keys, but with every function wrapped into a `dispatch` call so they 1014 | * may be invoked directly. This is just a convenience method, as you can call 1015 | * `store.dispatch(MyActionCreators.doSomething())` yourself just fine. 1016 | * 1017 | * For convenience, you can also pass a single function as the first argument, 1018 | * and get a function in return. 1019 | * 1020 | * @param {Function|Object} actionCreators An object whose values are action 1021 | * creator functions. One handy way to obtain it is to use ES6 `import * as` 1022 | * syntax. You may also pass a single function. 1023 | * 1024 | * @param {Function} dispatch The `dispatch` function available on your Redux 1025 | * store. 1026 | * 1027 | * @returns {Function|Object} The object mimicking the original object, but with 1028 | * every action creator wrapped into the `dispatch` call. If you passed a 1029 | * function as `actionCreators`, the return value will also be a single 1030 | * function. 1031 | */ 1032 | function bindActionCreators(actionCreators, dispatch) { 1033 | if (typeof actionCreators === 'function') { 1034 | return bindActionCreator(actionCreators, dispatch); 1035 | } 1036 | 1037 | if (typeof actionCreators !== 'object' || actionCreators === null) { 1038 | throw new Error('bindActionCreators expected an object or a function, instead received ' + (actionCreators === null ? 'null' : typeof actionCreators) + '. ' + 'Did you write "import ActionCreators from" instead of "import * as ActionCreators from"?'); 1039 | } 1040 | 1041 | var keys = Object.keys(actionCreators); 1042 | var boundActionCreators = {}; 1043 | for (var i = 0; i < keys.length; i++) { 1044 | var key = keys[i]; 1045 | var actionCreator = actionCreators[key]; 1046 | if (typeof actionCreator === 'function') { 1047 | boundActionCreators[key] = bindActionCreator(actionCreator, dispatch); 1048 | } 1049 | } 1050 | return boundActionCreators; 1051 | } 1052 | },{}],23:[function(require,module,exports){ 1053 | 'use strict'; 1054 | 1055 | exports.__esModule = true; 1056 | exports['default'] = combineReducers; 1057 | 1058 | var _createStore = require('./createStore'); 1059 | 1060 | var _isPlainObject = require('lodash/isPlainObject'); 1061 | 1062 | var _isPlainObject2 = _interopRequireDefault(_isPlainObject); 1063 | 1064 | var _warning = require('./utils/warning'); 1065 | 1066 | var _warning2 = _interopRequireDefault(_warning); 1067 | 1068 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 1069 | 1070 | function getUndefinedStateErrorMessage(key, action) { 1071 | var actionType = action && action.type; 1072 | var actionName = actionType && '"' + actionType.toString() + '"' || 'an action'; 1073 | 1074 | return 'Given action ' + actionName + ', reducer "' + key + '" returned undefined. ' + 'To ignore an action, you must explicitly return the previous state. ' + 'If you want this reducer to hold no value, you can return null instead of undefined.'; 1075 | } 1076 | 1077 | function getUnexpectedStateShapeWarningMessage(inputState, reducers, action, unexpectedKeyCache) { 1078 | var reducerKeys = Object.keys(reducers); 1079 | var argumentName = action && action.type === _createStore.ActionTypes.INIT ? 'preloadedState argument passed to createStore' : 'previous state received by the reducer'; 1080 | 1081 | if (reducerKeys.length === 0) { 1082 | return 'Store does not have a valid reducer. Make sure the argument passed ' + 'to combineReducers is an object whose values are reducers.'; 1083 | } 1084 | 1085 | if (!(0, _isPlainObject2['default'])(inputState)) { 1086 | return 'The ' + argumentName + ' has unexpected type of "' + {}.toString.call(inputState).match(/\s([a-z|A-Z]+)/)[1] + '". Expected argument to be an object with the following ' + ('keys: "' + reducerKeys.join('", "') + '"'); 1087 | } 1088 | 1089 | var unexpectedKeys = Object.keys(inputState).filter(function (key) { 1090 | return !reducers.hasOwnProperty(key) && !unexpectedKeyCache[key]; 1091 | }); 1092 | 1093 | unexpectedKeys.forEach(function (key) { 1094 | unexpectedKeyCache[key] = true; 1095 | }); 1096 | 1097 | if (unexpectedKeys.length > 0) { 1098 | return 'Unexpected ' + (unexpectedKeys.length > 1 ? 'keys' : 'key') + ' ' + ('"' + unexpectedKeys.join('", "') + '" found in ' + argumentName + '. ') + 'Expected to find one of the known reducer keys instead: ' + ('"' + reducerKeys.join('", "') + '". Unexpected keys will be ignored.'); 1099 | } 1100 | } 1101 | 1102 | function assertReducerShape(reducers) { 1103 | Object.keys(reducers).forEach(function (key) { 1104 | var reducer = reducers[key]; 1105 | var initialState = reducer(undefined, { type: _createStore.ActionTypes.INIT }); 1106 | 1107 | if (typeof initialState === 'undefined') { 1108 | throw new Error('Reducer "' + key + '" returned undefined during initialization. ' + 'If the state passed to the reducer is undefined, you must ' + 'explicitly return the initial state. The initial state may ' + 'not be undefined. If you don\'t want to set a value for this reducer, ' + 'you can use null instead of undefined.'); 1109 | } 1110 | 1111 | var type = '@@redux/PROBE_UNKNOWN_ACTION_' + Math.random().toString(36).substring(7).split('').join('.'); 1112 | if (typeof reducer(undefined, { type: type }) === 'undefined') { 1113 | throw new Error('Reducer "' + key + '" returned undefined when probed with a random type. ' + ('Don\'t try to handle ' + _createStore.ActionTypes.INIT + ' or other actions in "redux/*" ') + 'namespace. They are considered private. Instead, you must return the ' + 'current state for any unknown actions, unless it is undefined, ' + 'in which case you must return the initial state, regardless of the ' + 'action type. The initial state may not be undefined, but can be null.'); 1114 | } 1115 | }); 1116 | } 1117 | 1118 | /** 1119 | * Turns an object whose values are different reducer functions, into a single 1120 | * reducer function. It will call every child reducer, and gather their results 1121 | * into a single state object, whose keys correspond to the keys of the passed 1122 | * reducer functions. 1123 | * 1124 | * @param {Object} reducers An object whose values correspond to different 1125 | * reducer functions that need to be combined into one. One handy way to obtain 1126 | * it is to use ES6 `import * as reducers` syntax. The reducers may never return 1127 | * undefined for any action. Instead, they should return their initial state 1128 | * if the state passed to them was undefined, and the current state for any 1129 | * unrecognized action. 1130 | * 1131 | * @returns {Function} A reducer function that invokes every reducer inside the 1132 | * passed object, and builds a state object with the same shape. 1133 | */ 1134 | function combineReducers(reducers) { 1135 | var reducerKeys = Object.keys(reducers); 1136 | var finalReducers = {}; 1137 | for (var i = 0; i < reducerKeys.length; i++) { 1138 | var key = reducerKeys[i]; 1139 | 1140 | if ("production" !== 'production') { 1141 | if (typeof reducers[key] === 'undefined') { 1142 | (0, _warning2['default'])('No reducer provided for key "' + key + '"'); 1143 | } 1144 | } 1145 | 1146 | if (typeof reducers[key] === 'function') { 1147 | finalReducers[key] = reducers[key]; 1148 | } 1149 | } 1150 | var finalReducerKeys = Object.keys(finalReducers); 1151 | 1152 | var unexpectedKeyCache = void 0; 1153 | if ("production" !== 'production') { 1154 | unexpectedKeyCache = {}; 1155 | } 1156 | 1157 | var shapeAssertionError = void 0; 1158 | try { 1159 | assertReducerShape(finalReducers); 1160 | } catch (e) { 1161 | shapeAssertionError = e; 1162 | } 1163 | 1164 | return function combination() { 1165 | var state = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 1166 | var action = arguments[1]; 1167 | 1168 | if (shapeAssertionError) { 1169 | throw shapeAssertionError; 1170 | } 1171 | 1172 | if ("production" !== 'production') { 1173 | var warningMessage = getUnexpectedStateShapeWarningMessage(state, finalReducers, action, unexpectedKeyCache); 1174 | if (warningMessage) { 1175 | (0, _warning2['default'])(warningMessage); 1176 | } 1177 | } 1178 | 1179 | var hasChanged = false; 1180 | var nextState = {}; 1181 | for (var _i = 0; _i < finalReducerKeys.length; _i++) { 1182 | var _key = finalReducerKeys[_i]; 1183 | var reducer = finalReducers[_key]; 1184 | var previousStateForKey = state[_key]; 1185 | var nextStateForKey = reducer(previousStateForKey, action); 1186 | if (typeof nextStateForKey === 'undefined') { 1187 | var errorMessage = getUndefinedStateErrorMessage(_key, action); 1188 | throw new Error(errorMessage); 1189 | } 1190 | nextState[_key] = nextStateForKey; 1191 | hasChanged = hasChanged || nextStateForKey !== previousStateForKey; 1192 | } 1193 | return hasChanged ? nextState : state; 1194 | }; 1195 | } 1196 | },{"./createStore":25,"./utils/warning":27,"lodash/isPlainObject":20}],24:[function(require,module,exports){ 1197 | "use strict"; 1198 | 1199 | exports.__esModule = true; 1200 | exports["default"] = compose; 1201 | /** 1202 | * Composes single-argument functions from right to left. The rightmost 1203 | * function can take multiple arguments as it provides the signature for 1204 | * the resulting composite function. 1205 | * 1206 | * @param {...Function} funcs The functions to compose. 1207 | * @returns {Function} A function obtained by composing the argument functions 1208 | * from right to left. For example, compose(f, g, h) is identical to doing 1209 | * (...args) => f(g(h(...args))). 1210 | */ 1211 | 1212 | function compose() { 1213 | for (var _len = arguments.length, funcs = Array(_len), _key = 0; _key < _len; _key++) { 1214 | funcs[_key] = arguments[_key]; 1215 | } 1216 | 1217 | if (funcs.length === 0) { 1218 | return function (arg) { 1219 | return arg; 1220 | }; 1221 | } 1222 | 1223 | if (funcs.length === 1) { 1224 | return funcs[0]; 1225 | } 1226 | 1227 | return funcs.reduce(function (a, b) { 1228 | return function () { 1229 | return a(b.apply(undefined, arguments)); 1230 | }; 1231 | }); 1232 | } 1233 | },{}],25:[function(require,module,exports){ 1234 | 'use strict'; 1235 | 1236 | exports.__esModule = true; 1237 | exports.ActionTypes = undefined; 1238 | exports['default'] = createStore; 1239 | 1240 | var _isPlainObject = require('lodash/isPlainObject'); 1241 | 1242 | var _isPlainObject2 = _interopRequireDefault(_isPlainObject); 1243 | 1244 | var _symbolObservable = require('symbol-observable'); 1245 | 1246 | var _symbolObservable2 = _interopRequireDefault(_symbolObservable); 1247 | 1248 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 1249 | 1250 | /** 1251 | * These are private action types reserved by Redux. 1252 | * For any unknown actions, you must return the current state. 1253 | * If the current state is undefined, you must return the initial state. 1254 | * Do not reference these action types directly in your code. 1255 | */ 1256 | var ActionTypes = exports.ActionTypes = { 1257 | INIT: '@@redux/INIT' 1258 | 1259 | /** 1260 | * Creates a Redux store that holds the state tree. 1261 | * The only way to change the data in the store is to call `dispatch()` on it. 1262 | * 1263 | * There should only be a single store in your app. To specify how different 1264 | * parts of the state tree respond to actions, you may combine several reducers 1265 | * into a single reducer function by using `combineReducers`. 1266 | * 1267 | * @param {Function} reducer A function that returns the next state tree, given 1268 | * the current state tree and the action to handle. 1269 | * 1270 | * @param {any} [preloadedState] The initial state. You may optionally specify it 1271 | * to hydrate the state from the server in universal apps, or to restore a 1272 | * previously serialized user session. 1273 | * If you use `combineReducers` to produce the root reducer function, this must be 1274 | * an object with the same shape as `combineReducers` keys. 1275 | * 1276 | * @param {Function} [enhancer] The store enhancer. You may optionally specify it 1277 | * to enhance the store with third-party capabilities such as middleware, 1278 | * time travel, persistence, etc. The only store enhancer that ships with Redux 1279 | * is `applyMiddleware()`. 1280 | * 1281 | * @returns {Store} A Redux store that lets you read the state, dispatch actions 1282 | * and subscribe to changes. 1283 | */ 1284 | };function createStore(reducer, preloadedState, enhancer) { 1285 | var _ref2; 1286 | 1287 | if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') { 1288 | enhancer = preloadedState; 1289 | preloadedState = undefined; 1290 | } 1291 | 1292 | if (typeof enhancer !== 'undefined') { 1293 | if (typeof enhancer !== 'function') { 1294 | throw new Error('Expected the enhancer to be a function.'); 1295 | } 1296 | 1297 | return enhancer(createStore)(reducer, preloadedState); 1298 | } 1299 | 1300 | if (typeof reducer !== 'function') { 1301 | throw new Error('Expected the reducer to be a function.'); 1302 | } 1303 | 1304 | var currentReducer = reducer; 1305 | var currentState = preloadedState; 1306 | var currentListeners = []; 1307 | var nextListeners = currentListeners; 1308 | var isDispatching = false; 1309 | 1310 | function ensureCanMutateNextListeners() { 1311 | if (nextListeners === currentListeners) { 1312 | nextListeners = currentListeners.slice(); 1313 | } 1314 | } 1315 | 1316 | /** 1317 | * Reads the state tree managed by the store. 1318 | * 1319 | * @returns {any} The current state tree of your application. 1320 | */ 1321 | function getState() { 1322 | return currentState; 1323 | } 1324 | 1325 | /** 1326 | * Adds a change listener. It will be called any time an action is dispatched, 1327 | * and some part of the state tree may potentially have changed. You may then 1328 | * call `getState()` to read the current state tree inside the callback. 1329 | * 1330 | * You may call `dispatch()` from a change listener, with the following 1331 | * caveats: 1332 | * 1333 | * 1. The subscriptions are snapshotted just before every `dispatch()` call. 1334 | * If you subscribe or unsubscribe while the listeners are being invoked, this 1335 | * will not have any effect on the `dispatch()` that is currently in progress. 1336 | * However, the next `dispatch()` call, whether nested or not, will use a more 1337 | * recent snapshot of the subscription list. 1338 | * 1339 | * 2. The listener should not expect to see all state changes, as the state 1340 | * might have been updated multiple times during a nested `dispatch()` before 1341 | * the listener is called. It is, however, guaranteed that all subscribers 1342 | * registered before the `dispatch()` started will be called with the latest 1343 | * state by the time it exits. 1344 | * 1345 | * @param {Function} listener A callback to be invoked on every dispatch. 1346 | * @returns {Function} A function to remove this change listener. 1347 | */ 1348 | function subscribe(listener) { 1349 | if (typeof listener !== 'function') { 1350 | throw new Error('Expected listener to be a function.'); 1351 | } 1352 | 1353 | var isSubscribed = true; 1354 | 1355 | ensureCanMutateNextListeners(); 1356 | nextListeners.push(listener); 1357 | 1358 | return function unsubscribe() { 1359 | if (!isSubscribed) { 1360 | return; 1361 | } 1362 | 1363 | isSubscribed = false; 1364 | 1365 | ensureCanMutateNextListeners(); 1366 | var index = nextListeners.indexOf(listener); 1367 | nextListeners.splice(index, 1); 1368 | }; 1369 | } 1370 | 1371 | /** 1372 | * Dispatches an action. It is the only way to trigger a state change. 1373 | * 1374 | * The `reducer` function, used to create the store, will be called with the 1375 | * current state tree and the given `action`. Its return value will 1376 | * be considered the **next** state of the tree, and the change listeners 1377 | * will be notified. 1378 | * 1379 | * The base implementation only supports plain object actions. If you want to 1380 | * dispatch a Promise, an Observable, a thunk, or something else, you need to 1381 | * wrap your store creating function into the corresponding middleware. For 1382 | * example, see the documentation for the `redux-thunk` package. Even the 1383 | * middleware will eventually dispatch plain object actions using this method. 1384 | * 1385 | * @param {Object} action A plain object representing “what changed”. It is 1386 | * a good idea to keep actions serializable so you can record and replay user 1387 | * sessions, or use the time travelling `redux-devtools`. An action must have 1388 | * a `type` property which may not be `undefined`. It is a good idea to use 1389 | * string constants for action types. 1390 | * 1391 | * @returns {Object} For convenience, the same action object you dispatched. 1392 | * 1393 | * Note that, if you use a custom middleware, it may wrap `dispatch()` to 1394 | * return something else (for example, a Promise you can await). 1395 | */ 1396 | function dispatch(action) { 1397 | if (!(0, _isPlainObject2['default'])(action)) { 1398 | throw new Error('Actions must be plain objects. ' + 'Use custom middleware for async actions.'); 1399 | } 1400 | 1401 | if (typeof action.type === 'undefined') { 1402 | throw new Error('Actions may not have an undefined "type" property. ' + 'Have you misspelled a constant?'); 1403 | } 1404 | 1405 | if (isDispatching) { 1406 | throw new Error('Reducers may not dispatch actions.'); 1407 | } 1408 | 1409 | try { 1410 | isDispatching = true; 1411 | currentState = currentReducer(currentState, action); 1412 | } finally { 1413 | isDispatching = false; 1414 | } 1415 | 1416 | var listeners = currentListeners = nextListeners; 1417 | for (var i = 0; i < listeners.length; i++) { 1418 | var listener = listeners[i]; 1419 | listener(); 1420 | } 1421 | 1422 | return action; 1423 | } 1424 | 1425 | /** 1426 | * Replaces the reducer currently used by the store to calculate the state. 1427 | * 1428 | * You might need this if your app implements code splitting and you want to 1429 | * load some of the reducers dynamically. You might also need this if you 1430 | * implement a hot reloading mechanism for Redux. 1431 | * 1432 | * @param {Function} nextReducer The reducer for the store to use instead. 1433 | * @returns {void} 1434 | */ 1435 | function replaceReducer(nextReducer) { 1436 | if (typeof nextReducer !== 'function') { 1437 | throw new Error('Expected the nextReducer to be a function.'); 1438 | } 1439 | 1440 | currentReducer = nextReducer; 1441 | dispatch({ type: ActionTypes.INIT }); 1442 | } 1443 | 1444 | /** 1445 | * Interoperability point for observable/reactive libraries. 1446 | * @returns {observable} A minimal observable of state changes. 1447 | * For more information, see the observable proposal: 1448 | * https://github.com/tc39/proposal-observable 1449 | */ 1450 | function observable() { 1451 | var _ref; 1452 | 1453 | var outerSubscribe = subscribe; 1454 | return _ref = { 1455 | /** 1456 | * The minimal observable subscription method. 1457 | * @param {Object} observer Any object that can be used as an observer. 1458 | * The observer object should have a `next` method. 1459 | * @returns {subscription} An object with an `unsubscribe` method that can 1460 | * be used to unsubscribe the observable from the store, and prevent further 1461 | * emission of values from the observable. 1462 | */ 1463 | subscribe: function subscribe(observer) { 1464 | if (typeof observer !== 'object') { 1465 | throw new TypeError('Expected the observer to be an object.'); 1466 | } 1467 | 1468 | function observeState() { 1469 | if (observer.next) { 1470 | observer.next(getState()); 1471 | } 1472 | } 1473 | 1474 | observeState(); 1475 | var unsubscribe = outerSubscribe(observeState); 1476 | return { unsubscribe: unsubscribe }; 1477 | } 1478 | }, _ref[_symbolObservable2['default']] = function () { 1479 | return this; 1480 | }, _ref; 1481 | } 1482 | 1483 | // When a store is created, an "INIT" action is dispatched so that every 1484 | // reducer returns their initial state. This effectively populates 1485 | // the initial state tree. 1486 | dispatch({ type: ActionTypes.INIT }); 1487 | 1488 | return _ref2 = { 1489 | dispatch: dispatch, 1490 | subscribe: subscribe, 1491 | getState: getState, 1492 | replaceReducer: replaceReducer 1493 | }, _ref2[_symbolObservable2['default']] = observable, _ref2; 1494 | } 1495 | },{"lodash/isPlainObject":20,"symbol-observable":28}],26:[function(require,module,exports){ 1496 | 'use strict'; 1497 | 1498 | exports.__esModule = true; 1499 | exports.compose = exports.applyMiddleware = exports.bindActionCreators = exports.combineReducers = exports.createStore = undefined; 1500 | 1501 | var _createStore = require('./createStore'); 1502 | 1503 | var _createStore2 = _interopRequireDefault(_createStore); 1504 | 1505 | var _combineReducers = require('./combineReducers'); 1506 | 1507 | var _combineReducers2 = _interopRequireDefault(_combineReducers); 1508 | 1509 | var _bindActionCreators = require('./bindActionCreators'); 1510 | 1511 | var _bindActionCreators2 = _interopRequireDefault(_bindActionCreators); 1512 | 1513 | var _applyMiddleware = require('./applyMiddleware'); 1514 | 1515 | var _applyMiddleware2 = _interopRequireDefault(_applyMiddleware); 1516 | 1517 | var _compose = require('./compose'); 1518 | 1519 | var _compose2 = _interopRequireDefault(_compose); 1520 | 1521 | var _warning = require('./utils/warning'); 1522 | 1523 | var _warning2 = _interopRequireDefault(_warning); 1524 | 1525 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 1526 | 1527 | /* 1528 | * This is a dummy function to check if the function name has been altered by minification. 1529 | * If the function has been minified and NODE_ENV !== 'production', warn the user. 1530 | */ 1531 | function isCrushed() {} 1532 | 1533 | if ("production" !== 'production' && typeof isCrushed.name === 'string' && isCrushed.name !== 'isCrushed') { 1534 | (0, _warning2['default'])('You are currently using minified code outside of NODE_ENV === \'production\'. ' + 'This means that you are running a slower development build of Redux. ' + 'You can use loose-envify (https://github.com/zertosh/loose-envify) for browserify ' + 'or DefinePlugin for webpack (http://stackoverflow.com/questions/30030031) ' + 'to ensure you have the correct code for your production build.'); 1535 | } 1536 | 1537 | exports.createStore = _createStore2['default']; 1538 | exports.combineReducers = _combineReducers2['default']; 1539 | exports.bindActionCreators = _bindActionCreators2['default']; 1540 | exports.applyMiddleware = _applyMiddleware2['default']; 1541 | exports.compose = _compose2['default']; 1542 | },{"./applyMiddleware":21,"./bindActionCreators":22,"./combineReducers":23,"./compose":24,"./createStore":25,"./utils/warning":27}],27:[function(require,module,exports){ 1543 | 'use strict'; 1544 | 1545 | exports.__esModule = true; 1546 | exports['default'] = warning; 1547 | /** 1548 | * Prints a warning in the console if it exists. 1549 | * 1550 | * @param {String} message The warning message. 1551 | * @returns {void} 1552 | */ 1553 | function warning(message) { 1554 | /* eslint-disable no-console */ 1555 | if (typeof console !== 'undefined' && typeof console.error === 'function') { 1556 | console.error(message); 1557 | } 1558 | /* eslint-enable no-console */ 1559 | try { 1560 | // This error was thrown as a convenience so that if you enable 1561 | // "break on all exceptions" in your console, 1562 | // it would pause the execution at this line. 1563 | throw new Error(message); 1564 | /* eslint-disable no-empty */ 1565 | } catch (e) {} 1566 | /* eslint-enable no-empty */ 1567 | } 1568 | },{}],28:[function(require,module,exports){ 1569 | (function (global){ 1570 | 'use strict'; 1571 | 1572 | Object.defineProperty(exports, "__esModule", { 1573 | value: true 1574 | }); 1575 | 1576 | var _ponyfill = require('./ponyfill.js'); 1577 | 1578 | var _ponyfill2 = _interopRequireDefault(_ponyfill); 1579 | 1580 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 1581 | 1582 | var root; /* global window */ 1583 | 1584 | 1585 | if (typeof self !== 'undefined') { 1586 | root = self; 1587 | } else if (typeof window !== 'undefined') { 1588 | root = window; 1589 | } else if (typeof global !== 'undefined') { 1590 | root = global; 1591 | } else if (typeof module !== 'undefined') { 1592 | root = module; 1593 | } else { 1594 | root = Function('return this')(); 1595 | } 1596 | 1597 | var result = (0, _ponyfill2['default'])(root); 1598 | exports['default'] = result; 1599 | }).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) 1600 | },{"./ponyfill.js":29}],29:[function(require,module,exports){ 1601 | 'use strict'; 1602 | 1603 | Object.defineProperty(exports, "__esModule", { 1604 | value: true 1605 | }); 1606 | exports['default'] = symbolObservablePonyfill; 1607 | function symbolObservablePonyfill(root) { 1608 | var result; 1609 | var _Symbol = root.Symbol; 1610 | 1611 | if (typeof _Symbol === 'function') { 1612 | if (_Symbol.observable) { 1613 | result = _Symbol.observable; 1614 | } else { 1615 | result = _Symbol('observable'); 1616 | _Symbol.observable = result; 1617 | } 1618 | } else { 1619 | result = '@@observable'; 1620 | } 1621 | 1622 | return result; 1623 | }; 1624 | },{}]},{},[10]); 1625 | --------------------------------------------------------------------------------