diff options
| author | Yuval Adam <yuval@segmanta.com> | 2014-03-20 17:49:16 +0200 |
|---|---|---|
| committer | Yuval Adam <yuval@segmanta.com> | 2014-03-20 17:49:16 +0200 |
| commit | 75f93a3f7854a7383febd047391e2bfd4caceee0 (patch) | |
| tree | d5e634ef292c6b84acacc3982dbcaa0d300a346e /storages | |
Diffstat (limited to 'storages')
| -rw-r--r-- | storages/__init__.py | 1 | ||||
| -rw-r--r-- | storages/backends/__init__.py | 0 | ||||
| -rw-r--r-- | storages/backends/apache_libcloud.py | 180 | ||||
| -rw-r--r-- | storages/backends/azure_storage.py | 65 | ||||
| -rw-r--r-- | storages/backends/couchdb.py | 135 | ||||
| -rw-r--r-- | storages/backends/database.py | 132 | ||||
| -rw-r--r-- | storages/backends/ftp.py | 261 | ||||
| -rw-r--r-- | storages/backends/gs.py | 99 | ||||
| -rw-r--r-- | storages/backends/hashpath.py | 40 | ||||
| -rw-r--r-- | storages/backends/image.py | 55 | ||||
| -rw-r--r-- | storages/backends/mogile.py | 118 | ||||
| -rw-r--r-- | storages/backends/mongodb.py | 104 | ||||
| -rw-r--r-- | storages/backends/mosso.py | 347 | ||||
| -rw-r--r-- | storages/backends/overwrite.py | 19 | ||||
| -rw-r--r-- | storages/backends/s3.py | 288 | ||||
| -rw-r--r-- | storages/backends/s3boto.py | 501 | ||||
| -rw-r--r-- | storages/backends/sftpstorage.py | 272 | ||||
| -rw-r--r-- | storages/backends/symlinkorcopy.py | 62 | ||||
| -rw-r--r-- | storages/models.py | 11 | ||||
| -rw-r--r-- | storages/tests/__init__.py | 2 | ||||
| -rw-r--r-- | storages/tests/hashpath.py | 35 | ||||
| -rw-r--r-- | storages/tests/s3boto.py | 308 | ||||
| -rw-r--r-- | storages/utils.py | 9 |
23 files changed, 3044 insertions, 0 deletions
diff --git a/storages/__init__.py b/storages/__init__.py new file mode 100644 index 0000000..f0be139 --- /dev/null +++ b/storages/__init__.py @@ -0,0 +1 @@ +__version__ = '1.1.8' diff --git a/storages/backends/__init__.py b/storages/backends/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/storages/backends/__init__.py diff --git a/storages/backends/apache_libcloud.py b/storages/backends/apache_libcloud.py new file mode 100644 index 0000000..a350d65 --- /dev/null +++ b/storages/backends/apache_libcloud.py @@ -0,0 +1,180 @@ +# Django storage using libcloud providers +# Aymeric Barantal (mric at chamal.fr) 2011 +# +import os + +from django.conf import settings +from django.core.files.storage import Storage +from django.core.files.base import File +from django.core.exceptions import ImproperlyConfigured + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + + +try: + from libcloud.storage.providers import get_driver + from libcloud.storage.types import ObjectDoesNotExistError, Provider +except ImportError: + raise ImproperlyConfigured("Could not load libcloud") + + +class LibCloudStorage(Storage): + """Django storage derived class using apache libcloud to operate + on supported providers""" + def __init__(self, provider_name=None, option=None): + if provider_name is None: + provider_name = getattr(settings, 'DEFAULT_LIBCLOUD_PROVIDER', 'default') + + self.provider = settings.LIBCLOUD_PROVIDERS.get(provider_name) + if not self.provider: + raise ImproperlyConfigured( + 'LIBCLOUD_PROVIDERS %s not defined or invalid' % provider_name) + extra_kwargs = {} + if 'region' in self.provider: + extra_kwargs['region'] = self.provider['region'] + try: + provider_type = self.provider['type'] + if isinstance(provider_type, basestring): + module_path, tag = provider_type.rsplit('.', 1) + if module_path != 'libcloud.storage.types.Provider': + raise ValueError("Invalid module path") + provider_type = getattr(Provider, tag) + + Driver = get_driver(provider_type) + self.driver = Driver( + self.provider['user'], + self.provider['key'], + **extra_kwargs + ) + except Exception as e: + raise ImproperlyConfigured( + "Unable to create libcloud driver type %s: %s" % \ + (self.provider.get('type'), e)) + self.bucket = self.provider['bucket'] # Limit to one container + + def _get_bucket(self): + """Helper to get bucket object (libcloud container)""" + return self.driver.get_container(self.bucket) + + def _clean_name(self, name): + """Clean name (windows directories)""" + return os.path.normpath(name).replace('\\', '/') + + def _get_object(self, name): + """Get object by its name. Return None if object not found""" + clean_name = self._clean_name(name) + try: + return self.driver.get_object(self.bucket, clean_name) + except ObjectDoesNotExistError: + return None + + def delete(self, name): + """Delete object on remote""" + obj = self._get_object(name) + if obj: + return self.driver.delete_object(obj) + else: + raise Exception('Object to delete does not exists') + + def exists(self, name): + obj = self._get_object(name) + return True if obj else False + + def listdir(self, path='/'): + """Lists the contents of the specified path, + returning a 2-tuple of lists; the first item being + directories, the second item being files. + """ + container = self._get_bucket() + objects = self.driver.list_container_objects(container) + path = self._clean_name(path) + if not path.endswith('/'): + path = "%s/" % path + files = [] + dirs = [] + # TOFIX: better algorithm to filter correctly + # (and not depend on google-storage empty folder naming) + for o in objects: + if path == '/': + if o.name.count('/') == 0: + files.append(o.name) + elif o.name.count('/') == 1: + dir_name = o.name[:o.name.index('/')] + if not dir_name in dirs: + dirs.append(dir_name) + elif o.name.startswith(path): + if o.name.count('/') <= path.count('/'): + # TOFIX : special case for google storage with empty dir + if o.name.endswith('_$folder$'): + name = o.name[:-9] + name = name[len(path):] + dirs.append(name) + else: + name = o.name[len(path):] + files.append(name) + return (dirs, files) + + def size(self, name): + obj = self._get_object(name) + if obj: + return obj.size + else: + return -1 + + def url(self, name): + obj = self._get_object(name) + return self.driver.get_object_cdn_url(obj) + + def _open(self, name, mode='rb'): + remote_file = LibCloudFile(name, self, mode=mode) + return remote_file + + def _read(self, name, start_range=None, end_range=None): + obj = self._get_object(name) + # TOFIX : we should be able to read chunk by chunk + return next(self.driver.download_object_as_stream(obj, obj.size)) + + def _save(self, name, file): + self.driver.upload_object_via_stream(iter(file), self._get_bucket(), name) + return name + + +class LibCloudFile(File): + """File inherited class for libcloud storage objects read and write""" + def __init__(self, name, storage, mode): + self._name = name + self._storage = storage + self._mode = mode + self._is_dirty = False + self.file = StringIO() + self.start_range = 0 + + @property + def size(self): + if not hasattr(self, '_size'): + self._size = self._storage.size(self._name) + return self._size + + def read(self, num_bytes=None): + if num_bytes is None: + args = [] + self.start_range = 0 + else: + args = [self.start_range, self.start_range + num_bytes - 1] + data = self._storage._read(self._name, *args) + self.file = StringIO(data) + return self.file.getvalue() + + def write(self, content): + if 'w' not in self._mode: + raise AttributeError("File was opened for read-only access.") + self.file = StringIO(content) + self._is_dirty = True + + def close(self): + if self._is_dirty: + self._storage._save(self._name, self.file) + self.file.close() diff --git a/storages/backends/azure_storage.py b/storages/backends/azure_storage.py new file mode 100644 index 0000000..96ef082 --- /dev/null +++ b/storages/backends/azure_storage.py @@ -0,0 +1,65 @@ +import os.path + +from django.core.files.base import ContentFile +from django.core.files.storage import Storage +from django.core.exceptions import ImproperlyConfigured + +try: + import azure + import azure.storage +except ImportError: + raise ImproperlyConfigured( + "Could not load Azure bindings. " + "See https://github.com/WindowsAzure/azure-sdk-for-python") + +from storages.utils import setting + + +def clean_name(name): + return os.path.normpath(name).replace("\\", "/") + + +class AzureStorage(Storage): + account_name = setting("AZURE_ACCOUNT_NAME") + account_key = setting("AZURE_ACCOUNT_KEY") + azure_container = setting("AZURE_CONTAINER") + + def __init__(self, *args, **kwargs): + super(AzureStorage, self).__init__(*args, **kwargs) + self._connection = None + + @property + def connection(self): + if self._connection is None: + self._connection = azure.storage.BlobService( + self.account_name, self.account_key) + return self._connection + + def _open(self, name, mode="rb"): + contents = self.connection.get_blob(self.azure_container, name) + return ContentFile(contents) + + def exists(self, name): + try: + self.connection.get_blob_properties( + self.azure_container, name) + except azure.WindowsAzureMissingResourceError: + return False + else: + return True + + def delete(self, name): + self.connection.delete_blob(self.azure_container, name) + + def size(self, name): + properties = self.connection.get_blob_properties( + self.azure_container, name) + return properties["content-length"] + + def _save(self, name, content): + self.connection.put_blob(self.azure_container, name, + content, "BlockBlob") + return name + + def url(self, name): + return "%s/%s" % (self.azure_bucket, name) diff --git a/storages/backends/couchdb.py b/storages/backends/couchdb.py new file mode 100644 index 0000000..1e116da --- /dev/null +++ b/storages/backends/couchdb.py @@ -0,0 +1,135 @@ +""" +This is a Custom Storage System for Django with CouchDB backend. +Created by Christian Klein. +(c) Copyright 2009 HUDORA GmbH. All Rights Reserved. +""" +import os +from cStringIO import StringIO +from urlparse import urljoin +from urllib import quote_plus + +from django.conf import settings +from django.core.files import File +from django.core.files.storage import Storage +from django.core.exceptions import ImproperlyConfigured + +try: + import couchdb +except ImportError: + raise ImproperlyConfigured("Could not load couchdb dependency.\ + \nSee http://code.google.com/p/couchdb-python/") + +DEFAULT_SERVER= getattr(settings, 'COUCHDB_DEFAULT_SERVER', 'http://couchdb.local:5984') +STORAGE_OPTIONS= getattr(settings, 'COUCHDB_STORAGE_OPTIONS', {}) + + +class CouchDBStorage(Storage): + """ + CouchDBStorage - a Django Storage class for CouchDB. + + The CouchDBStorage can be configured in settings.py, e.g.:: + + COUCHDB_STORAGE_OPTIONS = { + 'server': "http://example.org", + 'database': 'database_name' + } + + Alternatively, the configuration can be passed as a dictionary. + """ + def __init__(self, **kwargs): + kwargs.update(STORAGE_OPTIONS) + self.base_url = kwargs.get('server', DEFAULT_SERVER) + server = couchdb.client.Server(self.base_url) + self.db = server[kwargs.get('database')] + + def _put_file(self, name, content): + self.db[name] = {'size': len(content)} + self.db.put_attachment(self.db[name], content, filename='content') + return name + + def get_document(self, name): + return self.db.get(name) + + def _open(self, name, mode='rb'): + couchdb_file = CouchDBFile(name, self, mode=mode) + return couchdb_file + + def _save(self, name, content): + content.open() + if hasattr(content, 'chunks'): + content_str = ''.join(chunk for chunk in content.chunks()) + else: + content_str = content.read() + name = name.replace('/', '-') + return self._put_file(name, content_str) + + def exists(self, name): + return name in self.db + + def size(self, name): + doc = self.get_document(name) + if doc: + return doc['size'] + return 0 + + def url(self, name): + return urljoin(self.base_url, + os.path.join(quote_plus(self.db.name), + quote_plus(name), + 'content')) + + def delete(self, name): + try: + del self.db[name] + except couchdb.client.ResourceNotFound: + raise IOError("File not found: %s" % name) + + #def listdir(self, name): + # _all_docs? + # pass + + +class CouchDBFile(File): + """ + CouchDBFile - a Django File-like class for CouchDB documents. + """ + + def __init__(self, name, storage, mode): + self._name = name + self._storage = storage + self._mode = mode + self._is_dirty = False + + try: + self._doc = self._storage.get_document(name) + + tmp, ext = os.path.split(name) + if ext: + filename = "content." + ext + else: + filename = "content" + attachment = self._storage.db.get_attachment(self._doc, filename=filename) + self.file = StringIO(attachment) + except couchdb.client.ResourceNotFound: + if 'r' in self._mode: + raise ValueError("The file cannot be reopened.") + else: + self.file = StringIO() + self._is_dirty = True + + @property + def size(self): + return self._doc['size'] + + def write(self, content): + if 'w' not in self._mode: + raise AttributeError("File was opened for read-only access.") + self.file = StringIO(content) + self._is_dirty = True + + def close(self): + if self._is_dirty: + self._storage._put_file(self._name, self.file.getvalue()) + self.file.close() + + diff --git a/storages/backends/database.py b/storages/backends/database.py new file mode 100644 index 0000000..3dd1259 --- /dev/null +++ b/storages/backends/database.py @@ -0,0 +1,132 @@ +# DatabaseStorage for django. +# 2009 (c) GameKeeper Gambling Ltd, Ivanov E. +import StringIO +import urlparse + +from django.conf import settings +from django.core.files import File +from django.core.files.storage import Storage +from django.core.exceptions import ImproperlyConfigured + +try: + import pyodbc +except ImportError: + raise ImproperlyConfigured("Could not load pyodbc dependency.\ + \nSee http://code.google.com/p/pyodbc/") + +REQUIRED_FIELDS = ('db_table', 'fname_column', 'blob_column', 'size_column', 'base_url') + +class DatabaseStorage(Storage): + """ + Class DatabaseStorage provides storing files in the database. + """ + + def __init__(self, option=settings.DB_FILES): + """Constructor. + + Constructs object using dictionary either specified in contucotr or +in settings.DB_FILES. + + @param option dictionary with 'db_table', 'fname_column', +'blob_column', 'size_column', 'base_url' keys. + + option['db_table'] + Table to work with. + option['fname_column'] + Column in the 'db_table' containing filenames (filenames can +contain pathes). Values should be the same as where FileField keeps +filenames. + It is used to map filename to blob_column. In sql it's simply +used in where clause. + option['blob_column'] + Blob column (for example 'image' type), created manually in the +'db_table', used to store image. + option['size_column'] + Column to store file size. Used for optimization of size() +method (another way is to open file and get size) + option['base_url'] + Url prefix used with filenames. Should be mapped to the view, +that returns an image as result. + """ + + if not option or not all([field in option for field in REQUIRED_FIELDS]): + raise ValueError("You didn't specify required options") + + self.db_table = option['db_table'] + self.fname_column = option['fname_column'] + self.blob_column = option['blob_column'] + self.size_column = option['size_column'] + self.base_url = option['base_url'] + + #get database settings + self.DATABASE_ODBC_DRIVER = settings.DATABASE_ODBC_DRIVER + self.DATABASE_NAME = settings.DATABASE_NAME + self.DATABASE_USER = settings.DATABASE_USER + self.DATABASE_PASSWORD = settings.DATABASE_PASSWORD + self.DATABASE_HOST = settings.DATABASE_HOST + + self.connection = pyodbc.connect('DRIVER=%s;SERVER=%s;DATABASE=%s;UID=%s;PWD=%s'%(self.DATABASE_ODBC_DRIVER,self.DATABASE_HOST,self.DATABASE_NAME, + self.DATABASE_USER, self.DATABASE_PASSWORD) ) + self.cursor = self.connection.cursor() + + def _open(self, name, mode='rb'): + """Open a file from database. + + @param name filename or relative path to file based on base_url. path should contain only "/", but not "\". Apache sends pathes with "/". + If there is no such file in the db, returs None + """ + + assert mode == 'rb', "You've tried to open binary file without specifying binary mode! You specified: %s"%mode + + row = self.cursor.execute("SELECT %s from %s where %s = '%s'"%(self.blob_column,self.db_table,self.fname_column,name) ).fetchone() + if row is None: + return None + inMemFile = StringIO.StringIO(row[0]) + inMemFile.name = name + inMemFile.mode = mode + + retFile = File(inMemFile) + return retFile + + def _save(self, name, content): + """Save 'content' as file named 'name'. + + @note '\' in path will be converted to '/'. + """ + + name = name.replace('\\', '/') + binary = pyodbc.Binary(content.read()) + size = len(binary) + + #todo: check result and do something (exception?) if failed. + if self.exists(name): + self.cursor.execute("UPDATE %s SET %s = ?, %s = ? WHERE %s = '%s'"%(self.db_table,self.blob_column,self.size_column,self.fname_column,name), + (binary, size) ) + else: + self.cursor.execute("INSERT INTO %s VALUES(?, ?, ?)"%(self.db_table), (name, binary, size) ) + self.connection.commit() + return name + + def exists(self, name): + row = self.cursor.execute("SELECT %s from %s where %s = '%s'"%(self.fname_column,self.db_table,self.fname_column,name)).fetchone() + return row is not None + + def get_available_name(self, name): + return name + + def delete(self, name): + if self.exists(name): + self.cursor.execute("DELETE FROM %s WHERE %s = '%s'"%(self.db_table,self.fname_column,name)) + self.connection.commit() + + def url(self, name): + if self.base_url is None: + raise ValueError("This file is not accessible via a URL.") + return urlparse.urljoin(self.base_url, name).replace('\\', '/') + + def size(self, name): + row = self.cursor.execute("SELECT %s from %s where %s = '%s'"%(self.size_column,self.db_table,self.fname_column,name)).fetchone() + if row is None: + return 0 + else: + return int(row[0]) diff --git a/storages/backends/ftp.py b/storages/backends/ftp.py new file mode 100644 index 0000000..0012444 --- /dev/null +++ b/storages/backends/ftp.py @@ -0,0 +1,261 @@ +# FTP storage class for Django pluggable storage system. +# Author: Rafal Jonca <jonca.rafal@gmail.com> +# License: MIT +# Comes from http://www.djangosnippets.org/snippets/1269/ +# +# Usage: +# +# Add below to settings.py: +# FTP_STORAGE_LOCATION = '[a]ftp://<user>:<pass>@<host>:<port>/[path]' +# +# In models.py you can write: +# from FTPStorage import FTPStorage +# fs = FTPStorage() +# class FTPTest(models.Model): +# file = models.FileField(upload_to='a/b/c/', storage=fs) + +import os +import ftplib +import urlparse + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +from django.conf import settings +from django.core.files.base import File +from django.core.files.storage import Storage +from django.core.exceptions import ImproperlyConfigured + + +class FTPStorageException(Exception): + pass + + +class FTPStorage(Storage): + """FTP Storage class for Django pluggable storage system.""" + + def __init__(self, location=settings.FTP_STORAGE_LOCATION, + base_url=settings.MEDIA_URL): + self._config = self._decode_location(location) + self._base_url = base_url + self._connection = None + + def _decode_location(self, location): + """Return splitted configuration data from location.""" + splitted_url = urlparse.urlparse(location) + config = {} + + if splitted_url.scheme not in ('ftp', 'aftp'): + raise ImproperlyConfigured( + 'FTPStorage works only with FTP protocol!' + ) + if splitted_url.hostname == '': + raise ImproperlyConfigured('You must at least provide hostname!') + + if splitted_url.scheme == 'aftp': + config['active'] = True + else: + config['active'] = False + config['path'] = splitted_url.path + config['host'] = splitted_url.hostname + config['user'] = splitted_url.username + config['passwd'] = splitted_url.password + config['port'] = int(splitted_url.port) + + return config + + def _start_connection(self): + # Check if connection is still alive and if not, drop it. + if self._connection is not None: + try: + self._connection.pwd() + except ftplib.all_errors: + self._connection = None + + # Real reconnect + if self._connection is None: + ftp = ftplib.FTP() + try: + ftp.connect(self._config['host'], self._config['port']) + ftp.login(self._config['user'], self._config['passwd']) + if self._config['active']: + ftp.set_pasv(False) + if self._config['path'] != '': + ftp.cwd(self._config['path']) + self._connection = ftp + return + except ftplib.all_errors: + raise FTPStorageException( + 'Connection or login error using data %s' + % repr(self._config) + ) + + def disconnect(self): + self._connection.quit() + self._connection = None + + def _mkremdirs(self, path): + pwd = self._connection.pwd() + path_splitted = path.split('/') + for path_part in path_splitted: + try: + self._connection.cwd(path_part) + except: + try: + self._connection.mkd(path_part) + self._connection.cwd(path_part) + except ftplib.all_errors: + raise FTPStorageException( + 'Cannot create directory chain %s' % path + ) + self._connection.cwd(pwd) + return + + def _put_file(self, name, content): + # Connection must be open! + try: + self._mkremdirs(os.path.dirname(name)) + pwd = self._connection.pwd() + self._connection.cwd(os.path.dirname(name)) + self._connection.storbinary('STOR ' + os.path.basename(name), + content.file, + content.DEFAULT_CHUNK_SIZE) + self._connection.cwd(pwd) + except ftplib.all_errors: + raise FTPStorageException('Error writing file %s' % name) + + def _open(self, name, mode='rb'): + remote_file = FTPStorageFile(name, self, mode=mode) + return remote_file + + def _read(self, name): + memory_file = StringIO() + try: + pwd = self._connection.pwd() + self._connection.cwd(os.path.dirname(name)) + self._connection.retrbinary('RETR ' + os.path.basename(name), + memory_file.write) + self._connection.cwd(pwd) + return memory_file + except ftplib.all_errors: + raise FTPStorageException('Error reading file %s' % name) + + def _save(self, name, content): + content.open() + self._start_connection() + self._put_file(name, content) + content.close() + return name + + def _get_dir_details(self, path): + # Connection must be open! + try: + lines = [] + self._connection.retrlines('LIST ' + path, lines.append) + dirs = {} + files = {} + for line in lines: + words = line.split() + if len(words) < 6: + continue + if words[-2] == '->': + continue + if words[0][0] == 'd': + dirs[words[-1]] = 0 + elif words[0][0] == '-': + files[words[-1]] = int(words[-5]) + return dirs, files + except ftplib.all_errors: + raise FTPStorageException('Error getting listing for %s' % path) + + def listdir(self, path): + self._start_connection() + try: + dirs, files = self._get_dir_details(path) + return dirs.keys(), files.keys() + except FTPStorageException: + raise + + def delete(self, name): + if not self.exists(name): + return + self._start_connection() + try: + self._connection.delete(name) + except ftplib.all_errors: + raise FTPStorageException('Error when removing %s' % name) + + def exists(self, name): + self._start_connection() + try: + if os.path.basename(name) in self._connection.nlst( + os.path.dirname(name) + '/' + ): + return True + else: + return False + except ftplib.error_temp: + return False + except ftplib.error_perm: + # error_perm: 550 Can't find file + return False + except ftplib.all_errors: + raise FTPStorageException('Error when testing existence of %s' + % name) + + def size(self, name): + self._start_connection() + try: + dirs, files = self._get_dir_details(os.path.dirname(name)) + if os.path.basename(name) in files: + return files[os.path.basename(name)] + else: + return 0 + except FTPStorageException: + return 0 + + def url(self, name): + if self._base_url is None: + raise ValueError("This file is not accessible via a URL.") + return urlparse.urljoin(self._base_url, name).replace('\\', '/') + + +class FTPStorageFile(File): + def __init__(self, name, storage, mode): + self._name = name + self._storage = storage + self._mode = mode + self._is_dirty = False + self.file = StringIO() + self._is_read = False + + @property + def size(self): + if not hasattr(self, '_size'): + self._size = self._storage.size(self._name) + return self._size + + def read(self, num_bytes=None): + if not self._is_read: + self._storage._start_connection() + self.file = self._storage._read(self._name) + self._storage._end_connection() + self._is_read = True + + return self.file.read(num_bytes) + + def write(self, content): + if 'w' not in self._mode: + raise AttributeError("File was opened for read-only access.") + self.file = StringIO(content) + self._is_dirty = True + self._is_read = True + + def close(self): + if self._is_dirty: + self._storage._start_connection() + self._storage._put_file(self._name, self) + self._storage.disconnect() + self.file.close() diff --git a/storages/backends/gs.py b/storages/backends/gs.py new file mode 100644 index 0000000..d9ef483 --- /dev/null +++ b/storages/backends/gs.py @@ -0,0 +1,99 @@ +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO # noqa + +from django.core.exceptions import ImproperlyConfigured + +from storages.backends.s3boto import S3BotoStorage, S3BotoStorageFile +from storages.utils import setting + +try: + from boto.gs.connection import GSConnection, SubdomainCallingFormat + from boto.exception import GSResponseError + from boto.gs.key import Key as GSKey +except ImportError: + raise ImproperlyConfigured("Could not load Boto's Google Storage bindings.\n" + "See https://github.com/boto/boto") + + +class GSBotoStorageFile(S3BotoStorageFile): + + def write(self, content): + if 'w' not in self._mode: + raise AttributeError("File was not opened in write mode.") + self.file = StringIO(content) + self._is_dirty = True + + def close(self): + if self._is_dirty: + provider = self.key.bucket.connection.provider + upload_headers = {provider.acl_header: self._storage.default_acl} + upload_headers.update(self._storage.headers) + self._storage._save_content(self.key, self.file, upload_headers) + self.key.close() + + +class GSBotoStorage(S3BotoStorage): + connection_class = GSConnection + connection_response_error = GSResponseError + file_class = GSBotoStorageFile + key_class = GSKey + + access_key_names = ['GS_ACCESS_KEY_ID'] + secret_key_names = ['GS_SECRET_ACCESS_KEY'] + + access_key = setting('GS_ACCESS_KEY_ID') + secret_key = setting('GS_SECRET_ACCESS_KEY') + file_overwrite = setting('GS_FILE_OVERWRITE', True) + headers = setting('GS_HEADERS', {}) + bucket_name = setting('GS_BUCKET_NAME', None) + auto_create_bucket = setting('GS_AUTO_CREATE_BUCKET', False) + default_acl = setting('GS_DEFAULT_ACL', 'public-read') + bucket_acl = setting('GS_BUCKET_ACL', default_acl) + querystring_auth = setting('GS_QUERYSTRING_AUTH', True) + querystring_expire = setting('GS_QUERYSTRING_EXPIRE', 3600) + durable_reduced_availability = setting('GS_DURABLE_REDUCED_AVAILABILITY', False) + location = setting('GS_LOCATION', '') + custom_domain = setting('GS_CUSTOM_DOMAIN') + calling_format = setting('GS_CALLING_FORMAT', SubdomainCallingFormat()) + secure_urls = setting('GS_SECURE_URLS', True) + file_name_charset = setting('GS_FILE_NAME_CHARSET', 'utf-8') + is_gzipped = setting('GS_IS_GZIPPED', False) + preload_metadata = setting('GS_PRELOAD_METADATA', False) + gzip_content_types = setting('GS_GZIP_CONTENT_TYPES', ( + 'text/css', + 'application/javascript', + 'application/x-javascript', + )) + url_protocol = setting('GS_URL_PROTOCOL', 'http:') + + def _save_content(self, key, content, headers): + # only pass backwards incompatible arguments if they vary from the default + options = {} + if self.encryption: + options['encrypt_key'] = self.encryption + key.set_contents_from_file(content, headers=headers, + policy=self.default_acl, + rewind=True, **options) + + def _get_or_create_bucket(self, name): + """ + Retrieves a bucket if it exists, otherwise creates it. + """ + if self.durable_reduced_availability: + storage_class = 'DURABLE_REDUCED_AVAILABILITY' + else: + storage_class = 'STANDARD' + try: + return self.connection.get_bucket(name, + validate=self.auto_create_bucket) + except self.connection_response_error: + if self.auto_create_bucket: + bucket = self.connection.create_bucket(name, storage_class=storage_class) + bucket.set_acl(self.bucket_acl) + return bucket + raise ImproperlyConfigured("Bucket %s does not exist. Buckets " + "can be automatically created by " + "setting GS_AUTO_CREATE_BUCKET to " + "``True``." % name) diff --git a/storages/backends/hashpath.py b/storages/backends/hashpath.py new file mode 100644 index 0000000..7603604 --- /dev/null +++ b/storages/backends/hashpath.py @@ -0,0 +1,40 @@ +import os, hashlib, errno + +from django.core.files.storage import FileSystemStorage +from django.utils.encoding import force_unicode + +class HashPathStorage(FileSystemStorage): + """ + Creates a hash from the uploaded file to build the path. + """ + + def save(self, name, content): + # Get the content name if name is not given + if name is None: name = content.name + + # Get the SHA1 hash of the uploaded file + sha1 = hashlib.sha1() + for chunk in content.chunks(): + sha1.update(chunk) + sha1sum = sha1.hexdigest() + + # Build the new path and split it into directory and filename + name = os.path.join(os.path.split(name)[0], sha1sum[:1], sha1sum[1:2], sha1sum) + dir_name, file_name = os.path.split(name) + + # Return the name if the file is already there + if self.exists(name): + return name + + # Try to create the directory relative to location specified in __init__ + try: + os.makedirs(os.path.join(self.location, dir_name)) + except OSError as e: + if e.errno is not errno.EEXIST: + raise e + + # Save the file + name = self._save(name, content) + + # Store filenames with forward slashes, even on Windows + return force_unicode(name.replace('\\', '/')) diff --git a/storages/backends/image.py b/storages/backends/image.py new file mode 100644 index 0000000..ccad24d --- /dev/null +++ b/storages/backends/image.py @@ -0,0 +1,55 @@ + +import os + +from django.core.files.storage import FileSystemStorage +from django.core.exceptions import ImproperlyConfigured + +try: + from PIL import ImageFile as PILImageFile +except ImportError: + raise ImproperlyConfigured("Could not load PIL dependency.\ + \nSee http://www.pythonware.com/products/pil/") + + +class ImageStorage(FileSystemStorage): + """ + A FileSystemStorage which normalizes extensions for images. + + Comes from http://www.djangosnippets.org/snippets/965/ + """ + + def find_extension(self, format): + """Normalizes PIL-returned format into a standard, lowercase extension.""" + format = format.lower() + + if format == 'jpeg': + format = 'jpg' + + return format + + def save(self, name, content): + dirname = os.path.dirname(name) + basename = os.path.basename(name) + + # Use PIL to determine filetype + + p = PILImageFile.Parser() + while 1: + data = content.read(1024) + if not data: + break + p.feed(data) + if p.image: + im = p.image + break + + extension = self.find_extension(im.format) + + # Does the basename already have an extension? If so, replace it. + # bare as in without extension + bare_basename, _ = os.path.splitext(basename) + basename = bare_basename + '.' + extension + + name = os.path.join(dirname, basename) + return super(ImageStorage, self).save(name, content) + diff --git a/storages/backends/mogile.py b/storages/backends/mogile.py new file mode 100644 index 0000000..e609ec7 --- /dev/null +++ b/storages/backends/mogile.py @@ -0,0 +1,118 @@ +from __future__ import print_function + +import urlparse +import mimetypes +from StringIO import StringIO + +from django.conf import settings +from django.core.cache import cache +from django.utils.text import force_unicode +from django.core.files.storage import Storage +from django.http import HttpResponse, HttpResponseNotFound +from django.core.exceptions import ImproperlyConfigured + +try: + import mogilefs +except ImportError: + raise ImproperlyConfigured("Could not load mogilefs dependency.\ + \nSee http://mogilefs.pbworks.com/Client-Libraries") + + +class MogileFSStorage(Storage): + """MogileFS filesystem storage""" + def __init__(self, base_url=settings.MEDIA_URL): + + # the MOGILEFS_MEDIA_URL overrides MEDIA_URL + if hasattr(settings, 'MOGILEFS_MEDIA_URL'): + self.base_url = settings.MOGILEFS_MEDIA_URL + else: + self.base_url = base_url + + for var in ('MOGILEFS_TRACKERS', 'MOGILEFS_DOMAIN',): + if not hasattr(settings, var): + raise ImproperlyConfigured("You must define %s to use the MogileFS backend." % var) + + self.trackers = settings.MOGILEFS_TRACKERS + self.domain = settings.MOGILEFS_DOMAIN + self.client = mogilefs.Client(self.domain, self.trackers) + + def get_mogile_paths(self, filename): + return self.client.get_paths(filename) + + # The following methods define the Backend API + + def filesize(self, filename): + raise NotImplemented + #return os.path.getsize(self._get_absolute_path(filename)) + + def path(self, filename): + paths = self.get_mogile_paths(filename) + if paths: + return self.get_mogile_paths(filename)[0] + else: + return None + + def url(self, filename): + return urlparse.urljoin(self.base_url, filename).replace('\\', '/') + + def open(self, filename, mode='rb'): + raise NotImplemented + #return open(self._get_absolute_path(filename), mode) + + def exists(self, filename): + return filename in self.client + + def save(self, filename, raw_contents): + filename = self.get_available_filename(filename) + + if not hasattr(self, 'mogile_class'): + self.mogile_class = None + + # Write the file to mogile + success = self.client.send_file(filename, StringIO(raw_contents), self.mogile_class) + if success: + print("Wrote file to key %s, %s@%s" % (filename, self.domain, self.trackers[0])) + else: + print("FAILURE writing file %s" % (filename)) + + return force_unicode(filename.replace('\\', '/')) + + def delete(self, filename): + + self.client.delete(filename) + + +def serve_mogilefs_file(request, key=None): + """ + Called when a user requests an image. + Either reproxy the path to perlbal, or serve the image outright + """ + # not the best way to do this, since we create a client each time + mimetype = mimetypes.guess_type(key)[0] or "application/x-octet-stream" + client = mogilefs.Client(settings.MOGILEFS_DOMAIN, settings.MOGILEFS_TRACKERS) + if hasattr(settings, "SERVE_WITH_PERLBAL") and settings.SERVE_WITH_PERLBAL: + # we're reproxying with perlbal + + # check the path cache + + path = cache.get(key) + + if not path: + path = client.get_paths(key) + cache.set(key, path, 60) + + if path: + response = HttpResponse(content_type=mimetype) + response['X-REPROXY-URL'] = path[0] + else: + response = HttpResponseNotFound() + + else: + # we don't have perlbal, let's just serve the image via django + file_data = client[key] + if file_data: + response = HttpResponse(file_data, mimetype=mimetype) + else: + response = HttpResponseNotFound() + + return response diff --git a/storages/backends/mongodb.py b/storages/backends/mongodb.py new file mode 100644 index 0000000..b963729 --- /dev/null +++ b/storages/backends/mongodb.py @@ -0,0 +1,104 @@ +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.core.files.base import File +from django.core.files.storage import Storage +from django.db import connections +from django.utils.encoding import force_unicode + +try: + from gridfs import GridFS, NoFile +except ImportError: + raise ImproperlyConfigured("Could not load gridfs dependency.\ + \nSee http://www.mongodb.org/display/DOCS/GridFS") + +try: + from pymongo import Connection +except ImportError: + raise ImproperlyConfigured("Could not load pymongo dependency.\ + \nSee http://github.com/mongodb/mongo-python-driver") + +class GridFSStorage(Storage): + @property + def fs(self): + db = settings.GRIDFS_DATABASE + # This should support both the django_mongodb_engine and the GSoC 2010 + # MongoDB backend + from django_mongodb_engine import __version__ + if __version__[0] == 0 and __version__[1] <= 3: + try: + connection = connections[db].db_connection + except: + connection = connections[db].connection + return GridFS(connection) + else: + return GridFS(connections[db].database) + + def _open(self, name, mode='rb'): + return GridFSFile(name, self, mode=mode) + + def _save(self, name, content): + name = force_unicode(name).replace('\\', '/') + content.open() + kwargs = {'filename': name} + if hasattr(content.file, 'content_type'): + kwargs['content_type'] = content.file.content_type + file = self.fs.new_file(**kwargs) + if hasattr(content, 'chunks'): + for chunk in content.chunks(): + file.write(chunk) + else: + file.write(content) + file.close() + content.close() + return name + + def get_valid_name(self, name): + return force_unicode(name).strip().replace('\\', '/') + + def delete(self, name): + f = self._open(name, 'r') + return self.fs.delete(f.file._id) + + def exists(self, name): + try: + self.fs.get_last_version(name) + return True + except NoFile: + return False + + def listdir(self, path): + return ((), self.fs.list()) + + def size(self, name): + try: + return self.fs.get_last_version(name).length + except NoFile: + raise ValueError('File with name "%s" does not exist' % name) + + def url(self, name): + raise NotImplementedError() + +class GridFSFile(File): + def __init__(self, name, storage, mode): + self.name = name + self._storage = storage + self._mode = mode + + try: + self.file = storage.fs.get_last_version(name) + except NoFile: + raise ValueError("The file doesn't exist.") + + @property + def size(self): + return self.file.length + + def read(self, num_bytes=None): + return self.file.read(num_bytes) + + def write(self, content): + raise NotImplementedError() + + def close(self): + self.file.close() + diff --git a/storages/backends/mosso.py b/storages/backends/mosso.py new file mode 100644 index 0000000..5a4812d --- /dev/null +++ b/storages/backends/mosso.py @@ -0,0 +1,347 @@ +""" +Custom storage for django with Mosso Cloud Files backend. +Created by Rich Leland <rich@richleland.com>. +""" +import os +import warnings +warnings.simplefilter('always', PendingDeprecationWarning) +warnings.warn("The mosso module will be deprecated in version 1.2 of " + "django-storages. The CloudFiles code has been moved into" + "django-cumulus at http://github.com/richleland/django-cumulus.", + PendingDeprecationWarning) + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.core.files import File +from django.core.files.storage import Storage +from django.utils.text import get_valid_filename + +try: + from cStringIO import StringIO +except: + from StringIO import StringIO + +try: + import cloudfiles + from cloudfiles.errors import NoSuchObject +except ImportError: + raise ImproperlyConfigured("Could not load cloudfiles dependency. See " + "http://www.mosso.com/cloudfiles.jsp.") + +# TODO: implement TTL into cloudfiles methods +TTL = getattr(settings, 'CLOUDFILES_TTL', 600) +CONNECTION_KWARGS = getattr(settings, 'CLOUDFILES_CONNECTION_KWARGS', {}) +SSL = getattr(settings, 'CLOUDFILES_SSL', False) + + +def cloudfiles_upload_to(self, filename): + """ + Simple, custom upload_to because Cloud Files doesn't support + nested containers (directories). + + Actually found this out from @minter: + @richleland The Cloud Files APIs do support pseudo-subdirectories, by + creating zero-byte files with type application/directory. + + May implement in a future version. + """ + return get_valid_filename(filename) + + +class CloudFilesStorage(Storage): + """ + Custom storage for Mosso Cloud Files. + """ + default_quick_listdir = True + + def __init__(self, + username=settings.CLOUDFILES_USERNAME, + api_key=settings.CLOUDFILES_API_KEY, + container=settings.CLOUDFILES_CONTAINER, + connection_kwargs=CONNECTION_KWARGS): + """ + Initialize the settings for the connection and container. + """ + self.username = username + self.api_key = api_key + self.container_name = container + self.connection_kwargs = connection_kwargs + + def __getstate__(self): + """ + Return a picklable representation of the storage. + """ + return dict(username=self.username, + api_key=self.api_key, + container_name=self.container_name, + connection_kwargs=self.connection_kwargs) + + def _get_connection(self): + if not hasattr(self, '_connection'): + self._connection = cloudfiles.get_connection(self.username, + self.api_key, **self.connection_kwargs) + return self._connection + + def _set_connection(self, value): + self._connection = value + + connection = property(_get_connection, _set_connection) + + def _get_container(self): + if not hasattr(self, '_container'): + self.container = self.connection.get_container( + self.container_name) + return self._container + + def _set_container(self, container): + """ + Set the container, making it publicly available (on Limelight CDN) if + it is not already. + """ + if not container.is_public(): + container.make_public() + if hasattr(self, '_container_public_uri'): + delattr(self, '_container_public_uri') + self._container = container + + container = property(_get_container, _set_container) + + def _get_container_url(self): + if not hasattr(self, '_container_public_uri'): + if SSL: + self._container_public_uri = self.container.public_ssl_uri() + else: + self._container_public_uri = self.container.public_uri() + return self._container_public_uri + + container_url = property(_get_container_url) + + def _get_cloud_obj(self, name): + """ + Helper function to get retrieve the requested Cloud Files Object. + """ + return self.container.get_object(name) + + def _open(self, name, mode='rb'): + """ + Return the CloudFilesStorageFile. + """ + return CloudFilesStorageFile(storage=self, name=name) + + def _save(self, name, content): + """ + Use the Cloud Files service to write ``content`` to a remote file + (called ``name``). + """ + (path, last) = os.path.split(name) + if path: + try: + self.container.get_object(path) + except NoSuchObject: + self._save(path, CloudStorageDirectory(path)) + + cloud_obj = self.container.create_object(name) + cloud_obj.size = content.size + + content.open() + # If the content type is available, pass it in directly rather than + # getting the cloud object to try to guess. + if hasattr(content.file, 'content_type'): + cloud_obj.content_type = content.file.content_type + cloud_obj.send(content) + content.close() + return name + + def delete(self, name): + """ + Deletes the specified file from the storage system. + """ + # If the file exists, delete it. + if self.exists(name): + self.container.delete_object(name) + + def exists(self, name): + """ + Returns True if a file referenced by the given name already exists in + the storage system, or False if the name is available for a new file. + """ + try: + self._get_cloud_obj(name) + return True + except NoSuchObject: + return False + + def listdir(self, path): + """ + Lists the contents of the specified path, returning a 2-tuple; the + first being an empty list of directories (not available for quick- + listing), the second being a list of filenames. + + If the list of directories is required, use the full_listdir method. + """ + files = [] + if path and not path.endswith('/'): + path = '%s/' % path + path_len = len(path) + for name in self.container.list_objects(path=path): + files.append(name[path_len:]) + return ([], files) + + def full_listdir(self, path): + """ + Lists the contents of the specified path, returning a 2-tuple of lists; + the first item being directories, the second item being files. + + On large containers, this may be a slow operation for root containers + because every single object must be returned (cloudfiles does not + provide an explicit way of listing directories). + """ + dirs = set() + files = [] + if path and not path.endswith('/'): + path = '%s/' % path + path_len = len(path) + for name in self.container.list_objects(prefix=path): + name = name[path_len:] + slash = name[1:-1].find('/') + 1 + if slash: + dirs.add(name[:slash]) + elif name: + files.append(name) + dirs = list(dirs) + dirs.sort() + return (dirs, files) + + def size(self, name): + """ + Returns the total size, in bytes, of the file specified by name. + """ + return self._get_cloud_obj(name).size + + def url(self, name): + """ + Returns an absolute URL where the file's contents can be accessed + directly by a web browser. + """ + return '%s/%s' % (self.container_url, name) + + +class CloudStorageDirectory(File): + """ + A File-like object that creates a directory at cloudfiles + """ + + def __init__(self, name): + super(CloudStorageDirectory, self).__init__(StringIO(), name=name) + self.file.content_type = 'application/directory' + self.size = 0 + + def __str__(self): + return 'directory' + + def __nonzero__(self): + return True + + def open(self, mode=None): + self.seek(0) + + def close(self): + pass + + +class CloudFilesStorageFile(File): + closed = False + + def __init__(self, storage, name, *args, **kwargs): + self._storage = storage + super(CloudFilesStorageFile, self).__init__(file=None, name=name, + *args, **kwargs) + self._pos = 0 + + + def _get_size(self): + if not hasattr(self, '_size'): + self._size = self._storage.size(self.name) + return self._size + + def _set_size(self, size): + self._size = size + + size = property(_get_size, _set_size) + + def _get_file(self): + if not hasattr(self, '_file'): + self._file = self._storage._get_cloud_obj(self.name) + return self._file + + def _set_file(self, value): + if value is None: + if hasattr(self, '_file'): + del self._file + else: + self._file = value + + file = property(_get_file, _set_file) + + def read(self, num_bytes=None): + if self._pos == self._get_size(): + return None + if self._pos + num_bytes > self._get_size(): + num_bytes = self._get_size() - self._pos + data = self.file.read(size=num_bytes or -1, offset=self._pos) + self._pos += len(data) + return data + + def open(self, *args, **kwargs): + """ + Open the cloud file object. + """ + self.file + self._pos = 0 + + def close(self, *args, **kwargs): + self._pos = 0 + + @property + def closed(self): + return not hasattr(self, '_file') + + def seek(self, pos): + self._pos = pos + + +class ThreadSafeCloudFilesStorage(CloudFilesStorage): + """ + Extends CloudFilesStorage to make it thread safer. + + As long as you don't pass container or cloud objects + between threads, you'll be thread safe. + + Uses one cloudfiles connection per thread. + """ + + def __init__(self, *args, **kwargs): + super(ThreadSafeCloudFilesStorage, self).__init__(*args, **kwargs) + + import threading + self.local_cache = threading.local() + + def _get_connection(self): + if not hasattr(self.local_cache, 'connection'): + connection = cloudfiles.get_connection(self.username, + self.api_key, **self.connection_kwargs) + self.local_cache.connection = connection + + return self.local_cache.connection + + connection = property(_get_connection, CloudFilesStorage._set_connection) + + def _get_container(self): + if not hasattr(self.local_cache, 'container'): + container = self.connection.get_container(self.container_name) + self.local_cache.container = container + + return self.local_cache.container + + container = property(_get_container, CloudFilesStorage._set_container) + diff --git a/storages/backends/overwrite.py b/storages/backends/overwrite.py new file mode 100644 index 0000000..fba464a --- /dev/null +++ b/storages/backends/overwrite.py @@ -0,0 +1,19 @@ +from django.core.files.storage import FileSystemStorage + + +class OverwriteStorage(FileSystemStorage): + """ + Comes from http://www.djangosnippets.org/snippets/976/ + (even if it already exists in S3Storage for ages) + + See also Django #4339, which might add this functionality to core. + """ + + def get_available_name(self, name): + """ + Returns a filename that's free on the target storage system, and + available for new content to be written to. + """ + if self.exists(name): + self.delete(name) + return name diff --git a/storages/backends/s3.py b/storages/backends/s3.py new file mode 100644 index 0000000..a700596 --- /dev/null +++ b/storages/backends/s3.py @@ -0,0 +1,288 @@ +import os +import mimetypes +import warnings + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +from django.conf import settings +from django.core.files.base import File +from django.core.files.storage import Storage +from django.core.exceptions import ImproperlyConfigured + +try: + from S3 import AWSAuthConnection, QueryStringAuthGenerator, CallingFormat +except ImportError: + raise ImproperlyConfigured("Could not load amazon's S3 bindings.\nSee " + "http://developer.amazonwebservices.com/connect/entry.jspa?externalID=134") + +ACCESS_KEY_NAME = getattr(settings, 'AWS_S3_ACCESS_KEY_ID', getattr(settings, 'AWS_ACCESS_KEY_ID', None)) +SECRET_KEY_NAME = getattr(settings, 'AWS_S3_SECRET_ACCESS_KEY', getattr(settings, 'AWS_SECRET_ACCESS_KEY', None)) +HEADERS = getattr(settings, 'AWS_HEADERS', {}) +DEFAULT_ACL = getattr(settings, 'AWS_DEFAULT_ACL', 'public-read') +QUERYSTRING_ACTIVE = getattr(settings, 'AWS_QUERYSTRING_ACTIVE', False) +QUERYSTRING_EXPIRE = getattr(settings, 'AWS_QUERYSTRING_EXPIRE', 60) +SECURE_URLS = getattr(settings, 'AWS_S3_SECURE_URLS', False) +BUCKET_PREFIX = getattr(settings, 'AWS_BUCKET_PREFIX', '') +CALLING_FORMAT = getattr(settings, 'AWS_CALLING_FORMAT', CallingFormat.PATH) +PRELOAD_METADATA = getattr(settings, 'AWS_PRELOAD_METADATA', False) + +IS_GZIPPED = getattr(settings, 'AWS_IS_GZIPPED', False) +GZIP_CONTENT_TYPES = getattr(settings, 'GZIP_CONTENT_TYPES', ( + 'text/css', + 'application/javascript', + 'application/x-javascript' +)) + +if IS_GZIPPED: + from gzip import GzipFile + +class S3Storage(Storage): + """Amazon Simple Storage Service""" + + def __init__(self, bucket=settings.AWS_STORAGE_BUCKET_NAME, + access_key=None, secret_key=None, acl=DEFAULT_ACL, + calling_format=CALLING_FORMAT, encrypt=False, + gzip=IS_GZIPPED, gzip_content_types=GZIP_CONTENT_TYPES, + preload_metadata=PRELOAD_METADATA): + warnings.warn( + "The s3 backend is deprecated and will be removed in version 1.2. " + "Use the s3boto backend instead.", + PendingDeprecationWarning + ) + self.bucket = bucket + self.acl = acl + self.encrypt = encrypt + self.gzip = gzip + self.gzip_content_types = gzip_content_types + self.preload_metadata = preload_metadata + + if encrypt: + try: + import ezPyCrypto + except ImportError: + raise ImproperlyConfigured("Could not load ezPyCrypto.\nSee " + "http://www.freenet.org.nz/ezPyCrypto/ to install it.") + self.crypto_key = ezPyCrypto.key + + if not access_key and not secret_key: + access_key, secret_key = self._get_access_keys() + + self.connection = AWSAuthConnection(access_key, secret_key, + calling_format=calling_format) + self.generator = QueryStringAuthGenerator(access_key, secret_key, + calling_format=calling_format, + is_secure=SECURE_URLS) + self.generator.set_expires_in(QUERYSTRING_EXPIRE) + + self.headers = HEADERS + self._entries = {} + + def _get_access_keys(self): + access_key = ACCESS_KEY_NAME + secret_key = SECRET_KEY_NAME + if (access_key or secret_key) and (not access_key or not secret_key): + access_key = os.environ.get(ACCESS_KEY_NAME) + secret_key = os.environ.get(SECRET_KEY_NAME) + + if access_key and secret_key: + # Both were provided, so use them + return access_key, secret_key + + return None, None + + @property + def entries(self): + if self.preload_metadata and not self._entries: + self._entries = dict((entry.key, entry) + for entry in self.connection.list_bucket(self.bucket).entries) + return self._entries + + def _get_connection(self): + return AWSAuthConnection(*self._get_access_keys()) + + def _clean_name(self, name): + # Useful for windows' paths + return os.path.join(BUCKET_PREFIX, os.path.normpath(name).replace('\\', '/')) + + def _compress_string(self, s): + """Gzip a given string.""" + zbuf = StringIO() + zfile = GzipFile(mode='wb', compresslevel=6, fileobj=zbuf) + zfile.write(s) + zfile.close() + return zbuf.getvalue() + + def _put_file(self, name, content): + if self.encrypt: + + # Create a key object + key = self.crypto_key() + + # Read in a public key + fd = open(settings.CRYPTO_KEYS_PUBLIC, "rb") + public_key = fd.read() + fd.close() + + # import this public key + key.importKey(public_key) + + # Now encrypt some text against this public key + content = key.encString(content) + + content_type = mimetypes.guess_type(name)[0] or "application/x-octet-stream" + + if self.gzip and content_type in self.gzip_content_types: + content = self._compress_string(content) + self.headers.update({'Content-Encoding': 'gzip'}) + + self.headers.update({ + 'x-amz-acl': self.acl, + 'Content-Type': content_type, + 'Content-Length' : str(len(content)), + }) + response = self.connection.put(self.bucket, name, content, self.headers) + if response.http_response.status not in (200, 206): + raise IOError("S3StorageError: %s" % response.message) + + def _open(self, name, mode='rb'): + name = self._clean_name(name) + remote_file = S3StorageFile(name, self, mode=mode) + return remote_file + + def _read(self, name, start_range=None, end_range=None): + name = self._clean_name(name) + if start_range is None: + headers = {} + else: + headers = {'Range': 'bytes=%s-%s' % (start_range, end_range)} + response = self.connection.get(self.bucket, name, headers) + if response.http_response.status not in (200, 206): + raise IOError("S3StorageError: %s" % response.message) + headers = response.http_response.msg + + if self.encrypt: + # Read in a private key + fd = open(settings.CRYPTO_KEYS_PRIVATE, "rb") + private_key = fd.read() + fd.close() + + # Create a key object, and auto-import private key + key = self.crypto_key(private_key) + + # Decrypt this file + response.object.data = key.decString(response.object.data) + + return response.object.data, headers.get('etag', None), headers.get('content-range', None) + + def _save(self, name, content): + name = self._clean_name(name) + content.open() + if hasattr(content, 'chunks'): + content_str = ''.join(chunk for chunk in content.chunks()) + else: + content_str = content.read() + self._put_file(name, content_str) + return name + + def delete(self, name): + name = self._clean_name(name) + response = self.connection.delete(self.bucket, name) + if response.http_response.status != 204: + raise IOError("S3StorageError: %s" % response.message) + + def exists(self, name): + name = self._clean_name(name) + if self.entries: + return name in self.entries + response = self.connection._make_request('HEAD', self.bucket, name) + return response.status == 200 + + def size(self, name): + name = self._clean_name(name) + if self.entries: + entry = self.entries.get(name) + if entry: + return entry.size + return 0 + response = self.connection._make_request('HEAD', self.bucket, name) + content_length = response.getheader('Content-Length') + return content_length and int(content_length) or 0 + + def url(self, name): + name = self._clean_name(name) + if QUERYSTRING_ACTIVE: + return self.generator.generate_url('GET', self.bucket, name) + else: + return self.generator.make_bare_url(self.bucket, name) + + def modified_time(self, name): + try: + from dateutil import parser, tz + except ImportError: + raise NotImplementedError() + name = self._clean_name(name) + if self.entries: + last_modified = self.entries.get(name).last_modified + else: + response = self.connection._make_request('HEAD', self.bucket, name) + last_modified = response.getheader('Last-Modified') + # convert to string to date + last_modified_date = parser.parse(last_modified) + # if the date has no timzone, assume UTC + if last_modified_date.tzinfo == None: + last_modified_date = last_modified_date.replace(tzinfo=tz.tzutc()) + # convert date to local time w/o timezone + return last_modified_date.astimezone(tz.tzlocal()).replace(tzinfo=None) + + ## UNCOMMENT BELOW IF NECESSARY + #def get_available_name(self, name): + # """ Overwrite existing file with the same name. """ + # name = self._clean_name(name) + # return name + + +class PreloadingS3Storage(S3Storage): + pass + +class S3StorageFile(File): + def __init__(self, name, storage, mode): + self._name = name + self._storage = storage + self._mode = mode + self._is_dirty = False + self.file = StringIO() + self.start_range = 0 + + @property + def size(self): + if not hasattr(self, '_size'): + self._size = self._storage.size(self._name) + return self._size + + def read(self, num_bytes=None): + if num_bytes is None: + args = [] + self.start_range = 0 + else: + args = [self.start_range, self.start_range+num_bytes-1] + data, etags, content_range = self._storage._read(self._name, *args) + if content_range is not None: + current_range, size = content_range.split(' ', 1)[1].split('/', 1) + start_range, end_range = current_range.split('-', 1) + self._size, self.start_range = int(size), int(end_range)+1 + self.file = StringIO(data) + return self.file.getvalue() + + def write(self, content): + if 'w' not in self._mode: + raise AttributeError("File was opened for read-only access.") + self.file = StringIO(content) + self._is_dirty = True + + def close(self): + if self._is_dirty: + self._storage._put_file(self._name, self.file.getvalue()) + self.file.close() diff --git a/storages/backends/s3boto.py b/storages/backends/s3boto.py new file mode 100644 index 0000000..354cd0c --- /dev/null +++ b/storages/backends/s3boto.py @@ -0,0 +1,501 @@ +import os +import posixpath +import mimetypes +from gzip import GzipFile +import datetime +from tempfile import SpooledTemporaryFile + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO # noqa + +from django.core.files.base import File +from django.core.files.storage import Storage +from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation +from django.utils.encoding import force_unicode, smart_str, filepath_to_uri + +try: + from boto import __version__ as boto_version + from boto.s3.connection import S3Connection, SubdomainCallingFormat + from boto.exception import S3ResponseError + from boto.s3.key import Key as S3Key + from boto.utils import parse_ts +except ImportError: + raise ImproperlyConfigured("Could not load Boto's S3 bindings.\n" + "See https://github.com/boto/boto") + +from storages.utils import setting + +boto_version_info = tuple([int(i) for i in boto_version.split('-')[0].split('.')]) + +if boto_version_info[:2] < (2, 4): + raise ImproperlyConfigured("The installed Boto library must be 2.4 or " + "higher.\nSee https://github.com/boto/boto") + + +def parse_ts_extended(ts): + RFC1123 = '%a, %d %b %Y %H:%M:%S %Z' + rv = None + try: + rv = parse_ts(ts) + except ValueError: + rv = datetime.datetime.strptime(ts, RFC1123) + return rv + + +def safe_join(base, *paths): + """ + A version of django.utils._os.safe_join for S3 paths. + + Joins one or more path components to the base path component + intelligently. Returns a normalized version of the final path. + + The final path must be located inside of the base path component + (otherwise a ValueError is raised). + + Paths outside the base path indicate a possible security + sensitive operation. + """ + from urlparse import urljoin + base_path = force_unicode(base) + base_path = base_path.rstrip('/') + paths = [force_unicode(p) for p in paths] + + final_path = base_path + for path in paths: + final_path = urljoin(final_path.rstrip('/') + "/", path) + + # Ensure final_path starts with base_path and that the next character after + # the final path is '/' (or nothing, in which case final_path must be + # equal to base_path). + base_path_len = len(base_path) + if (not final_path.startswith(base_path) or + final_path[base_path_len:base_path_len + 1] not in ('', '/')): + raise ValueError('the joined path is located outside of the base path' + ' component') + + return final_path.lstrip('/') + + +class S3BotoStorageFile(File): + """ + The default file object used by the S3BotoStorage backend. + + This file implements file streaming using boto's multipart + uploading functionality. The file can be opened in read or + write mode. + + This class extends Django's File class. However, the contained + data is only the data contained in the current buffer. So you + should not access the contained file object directly. You should + access the data via this class. + + Warning: This file *must* be closed using the close() method in + order to properly write the file to S3. Be sure to close the file + in your application. + """ + # TODO: Read/Write (rw) mode may be a bit undefined at the moment. Needs testing. + # TODO: When Django drops support for Python 2.5, rewrite to use the + # BufferedIO streams in the Python 2.6 io module. + buffer_size = setting('AWS_S3_FILE_BUFFER_SIZE', 5242880) + + def __init__(self, name, mode, storage, buffer_size=None): + self._storage = storage + self.name = name[len(self._storage.location):].lstrip('/') + self._mode = mode + self.key = storage.bucket.get_key(self._storage._encode_name(name)) + if not self.key and 'w' in mode: + self.key = storage.bucket.new_key(storage._encode_name(name)) + self._is_dirty = False + self._file = None + self._multipart = None + # 5 MB is the minimum part size (if there is more than one part). + # Amazon allows up to 10,000 parts. The default supports uploads + # up to roughly 50 GB. Increase the part size to accommodate + # for files larger than this. + if buffer_size is not None: + self.buffer_size = buffer_size + self._write_counter = 0 + + @property + def size(self): + return self.key.size + + def _get_file(self): + if self._file is None: + self._file = SpooledTemporaryFile( + max_size=self._storage.max_memory_size, + suffix=".S3BotoStorageFile", + dir=setting("FILE_UPLOAD_TEMP_DIR", None) + ) + if 'r' in self._mode: + self._is_dirty = False + self.key.get_contents_to_file(self._file) + self._file.seek(0) + if self._storage.gzip and self.key.content_encoding == 'gzip': + self._file = GzipFile(mode=self._mode, fileobj=self._file) + return self._file + + def _set_file(self, value): + self._file = value + + file = property(_get_file, _set_file) + + def read(self, *args, **kwargs): + if 'r' not in self._mode: + raise AttributeError("File was not opened in read mode.") + return super(S3BotoStorageFile, self).read(*args, **kwargs) + + def write(self, *args, **kwargs): + if 'w' not in self._mode: + raise AttributeError("File was not opened in write mode.") + self._is_dirty = True + if self._multipart is None: + provider = self.key.bucket.connection.provider + upload_headers = { + provider.acl_header: self._storage.default_acl + } + upload_headers.update({'Content-Type': mimetypes.guess_type(self.key.name)[0] or self._storage.key_class.DefaultContentType}) + upload_headers.update(self._storage.headers) + self._multipart = self._storage.bucket.initiate_multipart_upload( + self.key.name, + headers=upload_headers, + reduced_redundancy=self._storage.reduced_redundancy + ) + if self.buffer_size <= self._buffer_file_size: + self._flush_write_buffer() + return super(S3BotoStorageFile, self).write(*args, **kwargs) + + @property + def _buffer_file_size(self): + pos = self.file.tell() + self.file.seek(0, os.SEEK_END) + length = self.file.tell() + self.file.seek(pos) + return length + + def _flush_write_buffer(self): + """ + Flushes the write buffer. + """ + if self._buffer_file_size: + self._write_counter += 1 + self.file.seek(0) + headers = self._storage.headers.copy() + self._multipart.upload_part_from_file( + self.file, self._write_counter, headers=headers) + self.file.close() + self._file = None + + def close(self): + if self._is_dirty: + self._flush_write_buffer() + self._multipart.complete_upload() + else: + if not self._multipart is None: + self._multipart.cancel_upload() + self.key.close() + + +class S3BotoStorage(Storage): + """ + Amazon Simple Storage Service using Boto + + This storage backend supports opening files in read or write + mode and supports streaming(buffering) data in chunks to S3 + when writing. + """ + connection_class = S3Connection + connection_response_error = S3ResponseError + file_class = S3BotoStorageFile + key_class = S3Key + + # used for looking up the access and secret key from env vars + access_key_names = ['AWS_S3_ACCESS_KEY_ID', 'AWS_ACCESS_KEY_ID'] + secret_key_names = ['AWS_S3_SECRET_ACCESS_KEY', 'AWS_SECRET_ACCESS_KEY'] + + access_key = setting('AWS_S3_ACCESS_KEY_ID', setting('AWS_ACCESS_KEY_ID')) + secret_key = setting('AWS_S3_SECRET_ACCESS_KEY', setting('AWS_SECRET_ACCESS_KEY')) + file_overwrite = setting('AWS_S3_FILE_OVERWRITE', True) + headers = setting('AWS_HEADERS', {}) + bucket_name = setting('AWS_STORAGE_BUCKET_NAME') + auto_create_bucket = setting('AWS_AUTO_CREATE_BUCKET', False) + default_acl = setting('AWS_DEFAULT_ACL', 'public-read') + bucket_acl = setting('AWS_BUCKET_ACL', default_acl) + querystring_auth = setting('AWS_QUERYSTRING_AUTH', True) + querystring_expire = setting('AWS_QUERYSTRING_EXPIRE', 3600) + reduced_redundancy = setting('AWS_REDUCED_REDUNDANCY', False) + location = setting('AWS_LOCATION', '') + encryption = setting('AWS_S3_ENCRYPTION', False) + custom_domain = setting('AWS_S3_CUSTOM_DOMAIN') + calling_format = setting('AWS_S3_CALLING_FORMAT', SubdomainCallingFormat()) + secure_urls = setting('AWS_S3_SECURE_URLS', True) + file_name_charset = setting('AWS_S3_FILE_NAME_CHARSET', 'utf-8') + gzip = setting('AWS_IS_GZIPPED', False) + preload_metadata = setting('AWS_PRELOAD_METADATA', False) + gzip_content_types = setting('GZIP_CONTENT_TYPES', ( + 'text/css', + 'application/javascript', + 'application/x-javascript', + )) + url_protocol = setting('AWS_S3_URL_PROTOCOL', 'http:') + host = setting('AWS_S3_HOST', S3Connection.DefaultHost) + use_ssl = setting('AWS_S3_USE_SSL', True) + port = setting('AWS_S3_PORT', None) + + # The max amount of memory a returned file can take up before being + # rolled over into a temporary file on disk. Default is 0: Do not roll over. + max_memory_size = setting('AWS_S3_MAX_MEMORY_SIZE', 0) + + def __init__(self, acl=None, bucket=None, **settings): + # check if some of the settings we've provided as class attributes + # need to be overwritten with values passed in here + for name, value in settings.items(): + if hasattr(self, name): + setattr(self, name, value) + + # For backward-compatibility of old differing parameter names + if acl is not None: + self.default_acl = acl + if bucket is not None: + self.bucket_name = bucket + + self.location = (self.location or '').lstrip('/') + # Backward-compatibility: given the anteriority of the SECURE_URL setting + # we fall back to https if specified in order to avoid the construction + # of unsecure urls. + if self.secure_urls: + self.url_protocol = 'https:' + + self._entries = {} + self._bucket = None + self._connection = None + + if not self.access_key and not self.secret_key: + self.access_key, self.secret_key = self._get_access_keys() + + @property + def connection(self): + if self._connection is None: + self._connection = self.connection_class( + self.access_key, + self.secret_key, + is_secure=self.use_ssl, + calling_format=self.calling_format, + host=self.host, + port=self.port, + ) + return self._connection + + @property + def bucket(self): + """ + Get the current bucket. If there is no current bucket object + create it. + """ + if self._bucket is None: + self._bucket = self._get_or_create_bucket(self.bucket_name) + return self._bucket + + @property + def entries(self): + """ + Get the locally cached files for the bucket. + """ + if self.preload_metadata and not self._entries: + self._entries = dict((self._decode_name(entry.key), entry) + for entry in self.bucket.list(prefix=self.location)) + return self._entries + + def _get_access_keys(self): + """ + Gets the access keys to use when accessing S3. If none + are provided to the class in the constructor or in the + settings then get them from the environment variables. + """ + def lookup_env(names): + for name in names: + value = os.environ.get(name) + if value: + return value + access_key = self.access_key or lookup_env(self.access_key_names) + secret_key = self.secret_key or lookup_env(self.secret_key_names) + return access_key, secret_key + + def _get_or_create_bucket(self, name): + """ + Retrieves a bucket if it exists, otherwise creates it. + """ + try: + return self.connection.get_bucket(name, + validate=self.auto_create_bucket) + except self.connection_response_error: + if self.auto_create_bucket: + bucket = self.connection.create_bucket(name) + bucket.set_acl(self.bucket_acl) + return bucket + raise ImproperlyConfigured("Bucket %s does not exist. Buckets " + "can be automatically created by " + "setting AWS_AUTO_CREATE_BUCKET to " + "``True``." % name) + + def _clean_name(self, name): + """ + Cleans the name so that Windows style paths work + """ + # Normalize Windows style paths + clean_name = posixpath.normpath(name).replace('\\', '/') + + # os.path.normpath() can strip trailing slashes so we implement + # a workaround here. + if name.endswith('/') and not clean_name.endswith('/'): + # Add a trailing slash as it was stripped. + return clean_name + '/' + else: + return clean_name + + def _normalize_name(self, name): + """ + Normalizes the name so that paths like /path/to/ignored/../something.txt + work. We check to make sure that the path pointed to is not outside + the directory specified by the LOCATION setting. + """ + try: + return safe_join(self.location, name) + except ValueError: + raise SuspiciousOperation("Attempted access to '%s' denied." % + name) + + def _encode_name(self, name): + return smart_str(name, encoding=self.file_name_charset) + + def _decode_name(self, name): + return force_unicode(name, encoding=self.file_name_charset) + + def _compress_content(self, content): + """Gzip a given string content.""" + zbuf = StringIO() + zfile = GzipFile(mode='wb', compresslevel=6, fileobj=zbuf) + try: + zfile.write(content.read()) + finally: + zfile.close() + zbuf.seek(0) + content.file = zbuf + content.seek(0) + return content + + def _open(self, name, mode='rb'): + name = self._normalize_name(self._clean_name(name)) + f = self.file_class(name, mode, self) + if not f.key: + raise IOError('File does not exist: %s' % name) + return f + + def _save(self, name, content): + cleaned_name = self._clean_name(name) + name = self._normalize_name(cleaned_name) + headers = self.headers.copy() + content_type = getattr(content, 'content_type', + mimetypes.guess_type(name)[0] or self.key_class.DefaultContentType) + + # setting the content_type in the key object is not enough. + headers.update({'Content-Type': content_type}) + + if self.gzip and content_type in self.gzip_content_types: + content = self._compress_content(content) + headers.update({'Content-Encoding': 'gzip'}) + + content.name = cleaned_name + encoded_name = self._encode_name(name) + key = self.bucket.get_key(encoded_name) + if not key: + key = self.bucket.new_key(encoded_name) + if self.preload_metadata: + self._entries[encoded_name] = key + + key.set_metadata('Content-Type', content_type) + self._save_content(key, content, headers=headers) + return cleaned_name + + def _save_content(self, key, content, headers): + # only pass backwards incompatible arguments if they vary from the default + kwargs = {} + if self.encryption: + kwargs['encrypt_key'] = self.encryption + key.set_contents_from_file(content, headers=headers, + policy=self.default_acl, + reduced_redundancy=self.reduced_redundancy, + rewind=True, **kwargs) + + def delete(self, name): + name = self._normalize_name(self._clean_name(name)) + self.bucket.delete_key(self._encode_name(name)) + + def exists(self, name): + name = self._normalize_name(self._clean_name(name)) + if self.entries: + return name in self.entries + k = self.bucket.new_key(self._encode_name(name)) + return k.exists() + + def listdir(self, name): + name = self._normalize_name(self._clean_name(name)) + # for the bucket.list and logic below name needs to end in / + # But for the root path "" we leave it as an empty string + if name and not name.endswith('/'): + name += '/' + + dirlist = self.bucket.list(self._encode_name(name)) + files = [] + dirs = set() + base_parts = name.split("/")[:-1] + for item in dirlist: + parts = item.name.split("/") + parts = parts[len(base_parts):] + if len(parts) == 1: + # File + files.append(parts[0]) + elif len(parts) > 1: + # Directory + dirs.add(parts[0]) + return list(dirs), files + + def size(self, name): + name = self._normalize_name(self._clean_name(name)) + if self.entries: + entry = self.entries.get(name) + if entry: + return entry.size + return 0 + return self.bucket.get_key(self._encode_name(name)).size + + def modified_time(self, name): + name = self._normalize_name(self._clean_name(name)) + entry = self.entries.get(name) + # only call self.bucket.get_key() if the key is not found + # in the preloaded metadata. + if entry is None: + entry = self.bucket.get_key(self._encode_name(name)) + # Parse the last_modified string to a local datetime object. + return parse_ts_extended(entry.last_modified) + + def url(self, name, headers=None, response_headers=None): + # Preserve the trailing slash after normalizing the path. + name = self._normalize_name(self._clean_name(name)) + if self.custom_domain: + return "%s//%s/%s" % (self.url_protocol, + self.custom_domain, filepath_to_uri(name)) + return self.connection.generate_url(self.querystring_expire, + method='GET', bucket=self.bucket.name, key=self._encode_name(name), + headers=headers, + query_auth=self.querystring_auth, force_http=not self.secure_urls, + response_headers=response_headers) + + def get_available_name(self, name): + """ Overwrite existing file with the same name. """ + if self.file_overwrite: + name = self._clean_name(name) + return name + return super(S3BotoStorage, self).get_available_name(name) diff --git a/storages/backends/sftpstorage.py b/storages/backends/sftpstorage.py new file mode 100644 index 0000000..177a637 --- /dev/null +++ b/storages/backends/sftpstorage.py @@ -0,0 +1,272 @@ +from __future__ import print_function +# SFTP storage backend for Django. +# Author: Brent Tubbs <brent.tubbs@gmail.com> +# License: MIT +# +# Modeled on the FTP storage by Rafal Jonca <jonca.rafal@gmail.com> +# +# Settings: +# +# SFTP_STORAGE_HOST - The hostname where you want the files to be saved. +# +# SFTP_STORAGE_ROOT - The root directory on the remote host into which files +# should be placed. Should work the same way that STATIC_ROOT works for local +# files. Must include a trailing slash. +# +# SFTP_STORAGE_PARAMS (Optional) - A dictionary containing connection +# parameters to be passed as keyword arguments to +# paramiko.SSHClient().connect() (do not include hostname here). See +# http://www.lag.net/paramiko/docs/paramiko.SSHClient-class.html#connect for +# details +# +# SFTP_STORAGE_INTERACTIVE (Optional) - A boolean indicating whether to prompt +# for a password if the connection cannot be made using keys, and there is not +# already a password in SFTP_STORAGE_PARAMS. You can set this to True to +# enable interactive login when running 'manage.py collectstatic', for example. +# +# DO NOT set SFTP_STORAGE_INTERACTIVE to True if you are using this storage +# for files being uploaded to your site by users, because you'll have no way +# to enter the password when they submit the form.. +# +# SFTP_STORAGE_FILE_MODE (Optional) - A bitmask for setting permissions on +# newly-created files. See http://docs.python.org/library/os.html#os.chmod for +# acceptable values. +# +# SFTP_STORAGE_DIR_MODE (Optional) - A bitmask for setting permissions on +# newly-created directories. See +# http://docs.python.org/library/os.html#os.chmod for acceptable values. +# +# Hint: if you start the mode number with a 0 you can express it in octal +# just like you would when doing "chmod 775 myfile" from bash. +# +# SFTP_STORAGE_UID (Optional) - uid of the account that should be set as owner +# of the files on the remote host. You have to be root to set this. +# +# SFTP_STORAGE_GID (Optional) - gid of the group that should be set on the +# files on the remote host. You have to be a member of the group to set this. +# SFTP_KNOWN_HOST_FILE (Optional) - absolute path of know host file, if it isn't +# set "~/.ssh/known_hosts" will be used + + +import getpass +import os +import paramiko +import posixpath +import stat +import urlparse +from datetime import datetime + +from django.conf import settings +from django.core.files.base import File +from django.core.files.storage import Storage + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO # noqa + + +class SFTPStorage(Storage): + + def __init__(self): + self._host = settings.SFTP_STORAGE_HOST + + # if present, settings.SFTP_STORAGE_PARAMS should be a dict with params + # matching the keyword arguments to paramiko.SSHClient().connect(). So + # you can put username/password there. Or you can omit all that if + # you're using keys. + self._params = getattr(settings, 'SFTP_STORAGE_PARAMS', {}) + self._interactive = getattr(settings, 'SFTP_STORAGE_INTERACTIVE', + False) + self._file_mode = getattr(settings, 'SFTP_STORAGE_FILE_MODE', None) + self._dir_mode = getattr(settings, 'SFTP_STORAGE_DIR_MODE', None) + + self._uid = getattr(settings, 'SFTP_STORAGE_UID', None) + self._gid = getattr(settings, 'SFTP_STORAGE_GID', None) + self._known_host_file = getattr(settings, 'SFTP_KNOWN_HOST_FILE', None) + + self._root_path = settings.SFTP_STORAGE_ROOT + self._base_url = settings.MEDIA_URL + + # for now it's all posix paths. Maybe someday we'll support figuring + # out if the remote host is windows. + self._pathmod = posixpath + + def _connect(self): + self._ssh = paramiko.SSHClient() + + if self._known_host_file is not None: + self._ssh.load_host_keys(self._known_host_file) + else: + # automatically add host keys from current user. + self._ssh.load_host_keys(os.path.expanduser(os.path.join("~", ".ssh", "known_hosts"))) + + # and automatically add new host keys for hosts we haven't seen before. + self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + try: + self._ssh.connect(self._host, **self._params) + except paramiko.AuthenticationException as e: + if self._interactive and 'password' not in self._params: + # If authentication has failed, and we haven't already tried + # username/password, and configuration allows it, then try + # again with username/password. + if 'username' not in self._params: + self._params['username'] = getpass.getuser() + self._params['password'] = getpass.getpass() + self._connect() + else: + raise paramiko.AuthenticationException(e) + except Exception as e: + print(e) + + if not hasattr(self, '_sftp'): + self._sftp = self._ssh.open_sftp() + + @property + def sftp(self): + """Lazy SFTP connection""" + if not hasattr(self, '_sftp'): + self._connect() + return self._sftp + + def _join(self, *args): + # Use the path module for the remote host type to join a path together + return self._pathmod.join(*args) + + def _remote_path(self, name): + return self._join(self._root_path, name) + + def _open(self, name, mode='rb'): + return SFTPStorageFile(name, self, mode) + + def _read(self, name): + remote_path = self._remote_path(name) + return self.sftp.open(remote_path, 'rb') + + def _chown(self, path, uid=None, gid=None): + """Set uid and/or gid for file at path.""" + # Paramiko's chown requires both uid and gid, so look them up first if + # we're only supposed to set one. + if uid is None or gid is None: + attr = self.sftp.stat(path) + uid = uid or attr.st_uid + gid = gid or attr.st_gid + self.sftp.chown(path, uid, gid) + + def _mkdir(self, path): + """Create directory, recursing up to create parent dirs if + necessary.""" + parent = self._pathmod.dirname(path) + if not self.exists(parent): + self._mkdir(parent) + self.sftp.mkdir(path) + + if self._dir_mode is not None: + self.sftp.chmod(path, self._dir_mode) + + if self._uid or self._gid: + self._chown(path, uid=self._uid, gid=self._gid) + + def _save(self, name, content): + """Save file via SFTP.""" + content.open() + path = self._remote_path(name) + dirname = self._pathmod.dirname(path) + if not self.exists(dirname): + self._mkdir(dirname) + + f = self.sftp.open(path, 'wb') + f.write(content.file.read()) + f.close() + + # set file permissions if configured + if self._file_mode is not None: + self.sftp.chmod(path, self._file_mode) + if self._uid or self._gid: + self._chown(path, uid=self._uid, gid=self._gid) + return name + + def delete(self, name): + remote_path = self._remote_path(name) + self.sftp.remove(remote_path) + + def exists(self, name): + # Try to retrieve file info. Return true on success, false on failure. + remote_path = self._remote_path(name) + try: + self.sftp.stat(remote_path) + return True + except IOError: + return False + + def _isdir_attr(self, item): + # Return whether an item in sftp.listdir_attr results is a directory + if item.st_mode is not None: + return stat.S_IFMT(item.st_mode) == stat.S_IFDIR + else: + return False + + def listdir(self, path): + remote_path = self._remote_path(path) + dirs, files = [], [] + for item in self.sftp.listdir_attr(remote_path): + if self._isdir_attr(item): + dirs.append(item.filename) + else: + files.append(item.filename) + return dirs, files + + def size(self, name): + remote_path = self._remote_path(name) + return self.sftp.stat(remote_path).st_size + + def accessed_time(self, name): + remote_path = self._remote_path(name) + utime = self.sftp.stat(remote_path).st_atime + return datetime.fromtimestamp(utime) + + def modified_time(self, name): + remote_path = self._remote_path(name) + utime = self.sftp.stat(remote_path).st_mtime + return datetime.fromtimestamp(utime) + + def url(self, name): + if self._base_url is None: + raise ValueError("This file is not accessible via a URL.") + return urlparse.urljoin(self._base_url, name).replace('\\', '/') + + +class SFTPStorageFile(File): + def __init__(self, name, storage, mode): + self._name = name + self._storage = storage + self._mode = mode + self._is_dirty = False + self.file = StringIO() + self._is_read = False + + @property + def size(self): + if not hasattr(self, '_size'): + self._size = self._storage.size(self._name) + return self._size + + def read(self, num_bytes=None): + if not self._is_read: + self.file = self._storage._read(self._name) + self._is_read = True + + return self.file.read(num_bytes) + + def write(self, content): + if 'w' not in self._mode: + raise AttributeError("File was opened for read-only access.") + self.file = StringIO(content) + self._is_dirty = True + self._is_read = True + + def close(self): + if self._is_dirty: + self._storage._save(self._name, self.file.getvalue()) + self.file.close() diff --git a/storages/backends/symlinkorcopy.py b/storages/backends/symlinkorcopy.py new file mode 100644 index 0000000..14f8e29 --- /dev/null +++ b/storages/backends/symlinkorcopy.py @@ -0,0 +1,62 @@ +import os + +from django.conf import settings +from django.core.files.storage import FileSystemStorage + +__doc__ = """ +I needed to efficiently create a mirror of a directory tree (so that +"origin pull" CDNs can automatically pull files). The trick was that +some files could be modified, and some could be identical to the original. +Of course it doesn't make sense to store the exact same data twice on the +file system. So I created SymlinkOrCopyStorage. + +SymlinkOrCopyStorage allows you to symlink a file when it's identical to +the original file and to copy the file if it's modified. +Of course, it's impossible to know if a file is modified just by looking +at the file, without knowing what the original file was. +That's what the symlinkWithin parameter is for. It accepts one or more paths +(if multiple, they should be concatenated using a colon (:)). +Files that will be saved using SymlinkOrCopyStorage are then checked on their +location: if they are within one of the symlink_within directories, +they will be symlinked, otherwise they will be copied. + +The rationale is that unmodified files will exist in their original location, +e.g. /htdocs/example.com/image.jpg and modified files will be stored in +a temporary directory, e.g. /tmp/image.jpg. +""" + +class SymlinkOrCopyStorage(FileSystemStorage): + """Stores symlinks to files instead of actual files whenever possible + + When a file that's being saved is currently stored in the symlink_within + directory, then symlink the file. Otherwise, copy the file. + """ + def __init__(self, location=settings.MEDIA_ROOT, base_url=settings.MEDIA_URL, + symlink_within=None): + super(SymlinkOrCopyStorage, self).__init__(location, base_url) + self.symlink_within = symlink_within.split(":") + + def _save(self, name, content): + full_path_dst = self.path(name) + + directory = os.path.dirname(full_path_dst) + if not os.path.exists(directory): + os.makedirs(directory) + elif not os.path.isdir(directory): + raise IOError("%s exists and is not a directory." % directory) + + full_path_src = os.path.abspath(content.name) + + symlinked = False + # Only symlink if the current platform supports it. + if getattr(os, "symlink", False): + for path in self.symlink_within: + if full_path_src.startswith(path): + os.symlink(full_path_src, full_path_dst) + symlinked = True + break + + if not symlinked: + super(SymlinkOrCopyStorage, self)._save(name, content) + + return name diff --git a/storages/models.py b/storages/models.py new file mode 100644 index 0000000..78aae72 --- /dev/null +++ b/storages/models.py @@ -0,0 +1,11 @@ +from django.conf import settings + + +if settings.DEFAULT_FILE_STORAGE.endswith('mosso.CloudFilesStorage'): + import warnings + warnings.simplefilter('always', PendingDeprecationWarning) + warnings.warn("The mosso module will be deprecated in version 1.2 of " + "django-storages. The CloudFiles code has been moved into" + "django-cumulus at http://github.com/richleland/django-cumulus.", + PendingDeprecationWarning) + diff --git a/storages/tests/__init__.py b/storages/tests/__init__.py new file mode 100644 index 0000000..e17e6d5 --- /dev/null +++ b/storages/tests/__init__.py @@ -0,0 +1,2 @@ +from storages.tests.hashpath import * +from storages.tests.s3boto import * diff --git a/storages/tests/hashpath.py b/storages/tests/hashpath.py new file mode 100644 index 0000000..5cc4d65 --- /dev/null +++ b/storages/tests/hashpath.py @@ -0,0 +1,35 @@ +import os +import shutil + +from django.test import TestCase +from django.core.files.base import ContentFile +from django.conf import settings + +from storages.backends.hashpath import HashPathStorage + +TEST_PATH_PREFIX = 'django-storages-test' + + +class HashPathStorageTest(TestCase): + + def setUp(self): + self.test_path = os.path.join(settings.MEDIA_ROOT, TEST_PATH_PREFIX) + self.storage = HashPathStorage(location=self.test_path) + + # make sure the profile upload folder exists + if not os.path.exists(self.test_path): + os.makedirs(self.test_path) + + def tearDown(self): + # remove uploaded profile picture + if os.path.exists(self.test_path): + shutil.rmtree(self.test_path) + + def test_save_same_file(self): + """ + saves a file twice, the file should only be stored once, because the + content/hash is the same + """ + path_1 = self.storage.save('test', ContentFile('new content')) + path_2 = self.storage.save('test', ContentFile('new content')) + self.assertEqual(path_1, path_2) diff --git a/storages/tests/s3boto.py b/storages/tests/s3boto.py new file mode 100644 index 0000000..3f49503 --- /dev/null +++ b/storages/tests/s3boto.py @@ -0,0 +1,308 @@ +import mock
+import datetime
+import urlparse
+
+from django.test import TestCase
+from django.core.files.base import ContentFile
+
+from boto.s3.key import Key
+
+from storages.backends import s3boto
+
+__all__ = (
+ 'ParseTsExtendedCase',
+ 'SafeJoinTest',
+ 'S3BotoStorageTests',
+ #'S3BotoStorageFileTests',
+)
+
+class ParseTsExtendedCase(TestCase):
+ def test_normal(self):
+ value = s3boto.parse_ts_extended("Wed, 13 Mar 2013 12:45:49 GMT")
+ self.assertEquals(value, datetime.datetime(2013, 3, 13, 12, 45, 49))
+
+class S3BotoTestCase(TestCase):
+ @mock.patch('storages.backends.s3boto.S3Connection')
+ def setUp(self, S3Connection):
+ self.storage = s3boto.S3BotoStorage()
+ self.storage._connection = mock.MagicMock()
+
+class SafeJoinTest(TestCase):
+ def test_normal(self):
+ path = s3boto.safe_join("", "path/to/somewhere", "other", "path/to/somewhere")
+ self.assertEquals(path, "path/to/somewhere/other/path/to/somewhere")
+
+ def test_with_dot(self):
+ path = s3boto.safe_join("", "path/./somewhere/../other", "..",
+ ".", "to/./somewhere")
+ self.assertEquals(path, "path/to/somewhere")
+
+ def test_base_url(self):
+ path = s3boto.safe_join("base_url", "path/to/somewhere")
+ self.assertEquals(path, "base_url/path/to/somewhere")
+
+ def test_base_url_with_slash(self):
+ path = s3boto.safe_join("base_url/", "path/to/somewhere")
+ self.assertEquals(path, "base_url/path/to/somewhere")
+
+ def test_suspicious_operation(self):
+ self.assertRaises(ValueError,
+ s3boto.safe_join, "base", "../../../../../../../etc/passwd")
+
+ def test_trailing_slash(self):
+ """
+ Test safe_join with paths that end with a trailing slash.
+ """
+ path = s3boto.safe_join("base_url/", "path/to/somewhere/")
+ self.assertEquals(path, "base_url/path/to/somewhere/")
+
+ def test_trailing_slash_multi(self):
+ """
+ Test safe_join with multiple paths that end with a trailing slash.
+ """
+ path = s3boto.safe_join("base_url/", "path/to/" "somewhere/")
+ self.assertEquals(path, "base_url/path/to/somewhere/")
+
+
+class S3BotoStorageTests(S3BotoTestCase):
+
+ def test_clean_name(self):
+ """
+ Test the base case of _clean_name
+ """
+ path = self.storage._clean_name("path/to/somewhere")
+ self.assertEqual(path, "path/to/somewhere")
+
+ def test_clean_name_normalize(self):
+ """
+ Test the normalization of _clean_name
+ """
+ path = self.storage._clean_name("path/to/../somewhere")
+ self.assertEqual(path, "path/somewhere")
+
+ def test_clean_name_trailing_slash(self):
+ """
+ Test the _clean_name when the path has a trailing slash
+ """
+ path = self.storage._clean_name("path/to/somewhere/")
+ self.assertEqual(path, "path/to/somewhere/")
+
+ def test_clean_name_windows(self):
+ """
+ Test the _clean_name when the path has a trailing slash
+ """
+ path = self.storage._clean_name("path\\to\\somewhere")
+ self.assertEqual(path, "path/to/somewhere")
+
+ def test_storage_url(self):
+ """
+ Test URL generation.
+ """
+ self.storage.custom_domain = 'example.com'
+
+ # We expect no leading slashes in the path,
+ # and trailing slashes should be preserved.
+ self.assertEqual(self.storage.url(''), 'https://example.com/')
+ self.assertEqual(self.storage.url('path'), 'https://example.com/path')
+ self.assertEqual(self.storage.url('path/'), 'https://example.com/path/')
+ self.assertEqual(self.storage.url('path/1'), 'https://example.com/path/1')
+ self.assertEqual(self.storage.url('path/1/'), 'https://example.com/path/1/')
+
+ def test_storage_save(self):
+ """
+ Test saving a file
+ """
+ name = 'test_storage_save.txt'
+ content = ContentFile('new content')
+ self.storage.save(name, content)
+ self.storage.bucket.get_key.assert_called_once_with(name)
+
+ key = self.storage.bucket.get_key.return_value
+ key.set_metadata.assert_called_with('Content-Type', 'text/plain')
+ key.set_contents_from_file.assert_called_with(
+ content,
+ headers={'Content-Type': 'text/plain'},
+ policy=self.storage.default_acl,
+ reduced_redundancy=self.storage.reduced_redundancy,
+ rewind=True
+ )
+
+ def test_storage_save_gzip(self):
+ """
+ Test saving a file with gzip enabled.
+ """
+ if not s3boto.S3BotoStorage.gzip: # Gzip not available.
+ return
+ name = 'test_storage_save.css'
+ content = ContentFile("I should be gzip'd")
+ self.storage.save(name, content)
+ key = self.storage.bucket.get_key.return_value
+ key.set_metadata.assert_called_with('Content-Type', 'text/css')
+ key.set_contents_from_file.assert_called_with(
+ content,
+ headers={'Content-Type': 'text/css', 'Content-Encoding': 'gzip'},
+ policy=self.storage.default_acl,
+ reduced_redundancy=self.storage.reduced_redundancy,
+ rewind=True,
+ )
+
+ def test_compress_content_len(self):
+ """
+ Test that file returned by _compress_content() is readable.
+ """
+ if not s3boto.S3BotoStorage.gzip: # Gzip not available.
+ return
+ content = ContentFile("I should be gzip'd")
+ content = self.storage._compress_content(content)
+ self.assertTrue(len(content.read()) > 0)
+
+ def test_storage_open_write(self):
+ """
+ Test opening a file in write mode
+ """
+ name = 'test_open_for_writing.txt'
+ content = 'new content'
+
+ # Set the ACL header used when creating/writing data.
+ self.storage.bucket.connection.provider.acl_header = 'x-amz-acl'
+ # Set the mocked key's bucket
+ self.storage.bucket.get_key.return_value.bucket = self.storage.bucket
+ # Set the name of the mock object
+ self.storage.bucket.get_key.return_value.name = name
+
+ file = self.storage.open(name, 'w')
+ self.storage.bucket.get_key.assert_called_with(name)
+
+ file.write(content)
+ self.storage.bucket.initiate_multipart_upload.assert_called_with(
+ name,
+ headers={
+ 'Content-Type': 'text/plain',
+ 'x-amz-acl': 'public-read',
+ },
+ reduced_redundancy=self.storage.reduced_redundancy,
+ )
+
+ # Save the internal file before closing
+ _file = file.file
+ file.close()
+ file._multipart.upload_part_from_file.assert_called_with(
+ _file, 1, headers=self.storage.headers,
+ )
+ file._multipart.complete_upload.assert_called_once()
+
+ def test_storage_exists(self):
+ key = self.storage.bucket.new_key.return_value
+ key.exists.return_value = True
+ self.assertTrue(self.storage.exists("file.txt"))
+
+ def test_storage_exists_false(self):
+ key = self.storage.bucket.new_key.return_value
+ key.exists.return_value = False
+ self.assertFalse(self.storage.exists("file.txt"))
+
+ def test_storage_delete(self):
+ self.storage.delete("path/to/file.txt")
+ self.storage.bucket.delete_key.assert_called_with("path/to/file.txt")
+
+ def test_storage_listdir_base(self):
+ file_names = ["some/path/1.txt", "2.txt", "other/path/3.txt", "4.txt"]
+
+ self.storage.bucket.list.return_value = []
+ for p in file_names:
+ key = mock.MagicMock(spec=Key)
+ key.name = p
+ self.storage.bucket.list.return_value.append(key)
+
+ dirs, files = self.storage.listdir("")
+
+ self.assertEqual(len(dirs), 2)
+ for directory in ["some", "other"]:
+ self.assertTrue(directory in dirs,
+ """ "%s" not in directory list "%s".""" % (
+ directory, dirs))
+
+ self.assertEqual(len(files), 2)
+ for filename in ["2.txt", "4.txt"]:
+ self.assertTrue(filename in files,
+ """ "%s" not in file list "%s".""" % (
+ filename, files))
+
+ def test_storage_listdir_subdir(self):
+ file_names = ["some/path/1.txt", "some/2.txt"]
+
+ self.storage.bucket.list.return_value = []
+ for p in file_names:
+ key = mock.MagicMock(spec=Key)
+ key.name = p
+ self.storage.bucket.list.return_value.append(key)
+
+ dirs, files = self.storage.listdir("some/")
+ self.assertEqual(len(dirs), 1)
+ self.assertTrue('path' in dirs,
+ """ "path" not in directory list "%s".""" % (dirs,))
+
+ self.assertEqual(len(files), 1)
+ self.assertTrue('2.txt' in files,
+ """ "2.txt" not in files list "%s".""" % (files,))
+
+ def test_storage_size(self):
+ key = self.storage.bucket.get_key.return_value
+ key.size = 4098
+
+ name = 'file.txt'
+ self.assertEqual(self.storage.size(name), key.size)
+
+ def test_storage_url(self):
+ name = 'test_storage_size.txt'
+ url = 'http://aws.amazon.com/%s' % name
+ self.storage.connection.generate_url.return_value = url
+
+ self.assertEquals(self.storage.url(name), url)
+ self.storage.connection.generate_url.assert_called_with(
+ self.storage.querystring_expire,
+ method='GET',
+ bucket=self.storage.bucket.name,
+ key=name,
+ query_auth=self.storage.querystring_auth,
+ force_http=not self.storage.secure_urls,
+ headers=None,
+ response_headers=None,
+ )
+
+ def test_generated_url_is_encoded(self):
+ self.storage.custom_domain = "mock.cloudfront.net"
+ filename = "whacky & filename.mp4"
+ url = self.storage.url(filename)
+ parsed_url = urlparse.urlparse(url)
+ self.assertEqual(parsed_url.path,
+ "/whacky%20%26%20filename.mp4")
+
+#class S3BotoStorageFileTests(S3BotoTestCase):
+# def test_multipart_upload(self):
+# nparts = 2
+# name = self.prefix_path("test_multipart_upload.txt")
+# mode = 'w'
+# f = s3boto.S3BotoStorageFile(name, mode, self.storage)
+# content_length = 1024 * 1024# 1 MB
+# content = 'a' * content_length
+#
+# bytes = 0
+# target = f._write_buffer_size * nparts
+# while bytes < target:
+# f.write(content)
+# bytes += content_length
+#
+# # make the buffer roll over so f._write_counter
+# # is incremented
+# f.write("finished")
+#
+# # verify upload was multipart and correctly partitioned
+# self.assertEqual(f._write_counter, nparts)
+#
+# # complete the upload
+# f.close()
+#
+# # verify that the remaining buffered bytes were
+# # uploaded when the file was closed.
+# self.assertEqual(f._write_counter, nparts+1)
diff --git a/storages/utils.py b/storages/utils.py new file mode 100644 index 0000000..810e0c5 --- /dev/null +++ b/storages/utils.py @@ -0,0 +1,9 @@ +from django.conf import settings + + +def setting(name, default=None): + """ + Helper function to get a Django setting by name or (optionally) return + a default (or else ``None``). + """ + return getattr(settings, name, default) |
