├── djcastor ├── models.py ├── __init__.py ├── utils.py └── storage.py ├── test └── example │ ├── __init__.py │ ├── uploads │ ├── __init__.py │ ├── fixtures │ │ └── testing.json │ ├── models.py │ └── tests.py │ ├── media │ └── 60 │ │ └── fd │ │ └── 60fde9c2310b0d4cad4dab8d126b04387efba289.txt │ ├── manage.py │ ├── urls.py │ └── settings.py ├── MANIFEST.in ├── .gitignore ├── AUTHORS ├── setup.py ├── UNLICENSE └── README.md /djcastor/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/example/uploads/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include UNLICENSE 3 | -------------------------------------------------------------------------------- /test/example/media/60/fd/60fde9c2310b0d4cad4dab8d126b04387efba289.txt: -------------------------------------------------------------------------------- 1 | Hello, World! 2 | -------------------------------------------------------------------------------- /djcastor/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from djcastor.storage import CAStorage 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *.pyo 4 | *.sqlite3 5 | .DS_Store 6 | build 7 | dist 8 | MANIFEST 9 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Jannis Leidel 2 | Justin Quick 3 | Zachary Voase 4 | -------------------------------------------------------------------------------- /test/example/uploads/fixtures/testing.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "fields": { 4 | "created": "2010-04-07 09:26:58", 5 | "file": "60fde9c2310b0d4cad4dab8d126b04387efba289.txt" 6 | }, 7 | "model": "uploads.upload", 8 | "pk": 1 9 | } 10 | ] -------------------------------------------------------------------------------- /test/example/uploads/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models 4 | 5 | from djcastor import CAStorage 6 | 7 | 8 | class Upload(models.Model): 9 | 10 | file = models.FileField(upload_to='uploads', storage=CAStorage()) 11 | created = models.DateTimeField(auto_now_add=True) 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup, find_packages 5 | 6 | setup( 7 | name = 'django-castor', 8 | version = '0.2.1', 9 | author = "Zachary Voase", 10 | author_email = "z@zacharyvoase.com", 11 | url = 'https://github.com/zacharyvoase/django-castor', 12 | description = "A content-addressable storage backend for Django.", 13 | packages = find_packages(where='.', exclude='test'), 14 | ) 15 | -------------------------------------------------------------------------------- /test/example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from django.core.management import execute_manager 3 | try: 4 | import settings # Assumed to be in the same directory. 5 | except ImportError: 6 | import sys 7 | sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) 8 | sys.exit(1) 9 | 10 | if __name__ == "__main__": 11 | execute_manager(settings) 12 | -------------------------------------------------------------------------------- /test/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | 3 | # Uncomment the next two lines to enable the admin: 4 | # from django.contrib import admin 5 | # admin.autodiscover() 6 | 7 | urlpatterns = patterns('', 8 | # Example: 9 | # (r'^example/', include('example.foo.urls')), 10 | 11 | # Uncomment the admin/doc line below and add 'django.contrib.admindocs' 12 | # to INSTALLED_APPS to enable admin documentation: 13 | # (r'^admin/doc/', include('django.contrib.admindocs.urls')), 14 | 15 | # Uncomment the next line to enable the admin: 16 | # (r'^admin/', include(admin.site.urls)), 17 | ) 18 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /test/example/uploads/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from StringIO import StringIO 4 | import hashlib 5 | import os 6 | import shutil 7 | 8 | from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile 9 | from django.conf import settings 10 | from django.test import TestCase 11 | 12 | from uploads.models import Upload 13 | 14 | 15 | class ReadTest(TestCase): 16 | 17 | fixtures = ['testing'] 18 | 19 | def test(self): 20 | upload = Upload.objects.get(pk=1) 21 | self.assertEqual(upload.file.read(), "Hello, World!\n") 22 | 23 | 24 | class MemoryWriteTest(TestCase): 25 | 26 | def test(self): 27 | text = "Spam Spam Spam.\n" 28 | digest = hashlib.sha1(text).hexdigest() 29 | io = StringIO(text) 30 | 31 | new_upload = Upload(file=InMemoryUploadedFile( 32 | io, 'file', 'spam.txt', 'text/plain', len(text), 'utf-8')) 33 | new_upload.save() 34 | 35 | # Upload has been saved to the database. 36 | self.assert_(new_upload.pk) 37 | 38 | # Upload contains correct content. 39 | self.assertEqual(new_upload.file.read(), text) 40 | 41 | # Filename is the hash of the file contents. 42 | self.assert_(new_upload.file.name.startswith(digest)) 43 | 44 | def tearDown(self): 45 | # Remove the upload in `MEDIA_ROOT`. 46 | directory = os.path.join(settings.MEDIA_ROOT, '8f') 47 | if os.path.exists(directory): 48 | shutil.rmtree(directory) 49 | 50 | 51 | class FileWriteTest(TestCase): 52 | 53 | def setUp(self): 54 | self.text = "Spam Spam Spam Spam.\n" 55 | self.digest = hashlib.sha1(self.text).hexdigest() 56 | self.tempfile = TemporaryUploadedFile('spam4.txt', 'text/plain', 57 | len(self.text), 'utf-8') 58 | self.tempfile.file.write(self.text) 59 | self.tempfile.file.seek(0) 60 | 61 | def test(self): 62 | new_upload = Upload(file=self.tempfile) 63 | new_upload.save() 64 | 65 | # Upload has been saved to the database. 66 | self.assert_(new_upload.pk) 67 | 68 | # Upload contains correct content. 69 | self.assertEqual(new_upload.file.read(), self.text) 70 | 71 | # Filename is the hash of the file contents. 72 | self.assert_(new_upload.file.name.startswith(self.digest)) 73 | 74 | def tearDown(self): 75 | self.tempfile.close() # Also deletes the temp file. 76 | 77 | # Remove the upload in `MEDIA_ROOT`. 78 | directory = os.path.join(settings.MEDIA_ROOT, '24') 79 | if os.path.exists(directory): 80 | shutil.rmtree(directory) 81 | -------------------------------------------------------------------------------- /test/example/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for example project. 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.append(os.path.dirname(os.path.abspath(__file__))) 7 | sys.path.append(os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'src')) 8 | 9 | 10 | DEBUG = True 11 | TEMPLATE_DEBUG = DEBUG 12 | 13 | ADMINS = ( 14 | ('Zachary Voase', 'zacharyvoase@me.com'), 15 | ) 16 | 17 | MANAGERS = ADMINS 18 | 19 | DATABASE_ENGINE = 'sqlite3' 20 | DATABASE_NAME = 'dev.sqlite3' 21 | DATABASE_USER = '' 22 | DATABASE_PASSWORD = '' 23 | DATABASE_HOST = '' 24 | DATABASE_PORT = '' 25 | 26 | # Local time zone for this installation. Choices can be found here: 27 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 28 | # although not all choices may be available on all operating systems. 29 | # If running in a Windows environment this must be set to the same as your 30 | # system time zone. 31 | TIME_ZONE = 'Europe/London' 32 | 33 | # Language code for this installation. All choices can be found here: 34 | # http://www.i18nguy.com/unicode/language-identifiers.html 35 | LANGUAGE_CODE = 'en-gb' 36 | 37 | SITE_ID = 1 38 | 39 | # If you set this to False, Django will make some optimizations so as not 40 | # to load the internationalization machinery. 41 | USE_I18N = True 42 | 43 | # Absolute path to the directory that holds media. 44 | # Example: "/home/media/media.lawrence.com/" 45 | MEDIA_ROOT = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'media') 46 | 47 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 48 | # trailing slash if there is a path component (optional in other cases). 49 | # Examples: "http://media.lawrence.com", "http://example.com/media/" 50 | MEDIA_URL = '/media/' 51 | 52 | # URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a 53 | # trailing slash. 54 | # Examples: "http://foo.com/media/", "/media/". 55 | ADMIN_MEDIA_PREFIX = '/media/admin/' 56 | 57 | # Make this unique, and don't share it with anybody. 58 | SECRET_KEY = '8@+k3lm3=s+ml6_*(cnpbg1w=6k9xpk5f=irs+&j4_6i=62fy^' 59 | 60 | # List of callables that know how to import templates from various sources. 61 | TEMPLATE_LOADERS = ( 62 | 'django.template.loaders.filesystem.load_template_source', 63 | 'django.template.loaders.app_directories.load_template_source', 64 | # 'django.template.loaders.eggs.load_template_source', 65 | ) 66 | 67 | MIDDLEWARE_CLASSES = ( 68 | 'django.middleware.common.CommonMiddleware', 69 | ) 70 | 71 | ROOT_URLCONF = 'example.urls' 72 | 73 | TEMPLATE_DIRS = ( 74 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 75 | # Always use forward slashes, even on Windows. 76 | # Don't forget to use absolute paths, not relative paths. 77 | ) 78 | 79 | INSTALLED_APPS = ( 80 | 'uploads', 81 | ) 82 | -------------------------------------------------------------------------------- /djcastor/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import hashlib 4 | import os 5 | 6 | from django.core.files import File 7 | from django.core.files.uploadedfile import UploadedFile 8 | 9 | 10 | def hash_filename(filename, digestmod=hashlib.sha1, 11 | chunk_size=UploadedFile.DEFAULT_CHUNK_SIZE): 12 | 13 | """ 14 | Return the hash of the contents of a filename, using chunks. 15 | 16 | >>> import os.path as p 17 | >>> filename = p.join(p.abspath(p.dirname(__file__)), 'models.py') 18 | >>> hash_filename(filename) 19 | 'da39a3ee5e6b4b0d3255bfef95601890afd80709' 20 | 21 | """ 22 | 23 | fileobj = File(open(filename)) 24 | try: 25 | return hash_chunks(fileobj.chunks(chunk_size=chunk_size)) 26 | finally: 27 | fileobj.close() 28 | 29 | 30 | def hash_chunks(iterator, digestmod=hashlib.sha1): 31 | 32 | """ 33 | Hash the contents of a string-yielding iterator. 34 | 35 | >>> import hashlib 36 | >>> digest = hashlib.sha1('abc').hexdigest() 37 | >>> strings = iter(['a', 'b', 'c']) 38 | >>> hash_chunks(strings, digestmod=hashlib.sha1) == digest 39 | True 40 | 41 | """ 42 | 43 | digest = digestmod() 44 | for chunk in iterator: 45 | digest.update(chunk) 46 | return digest.hexdigest() 47 | 48 | 49 | def shard(string, width, depth, rest_only=False): 50 | 51 | """ 52 | Shard the given string by a width and depth. Returns a generator. 53 | 54 | A width and depth of 2 indicates that there should be 2 shards of length 2. 55 | 56 | >>> digest = '1f09d30c707d53f3d16c530dd73d70a6ce7596a9' 57 | >>> list(shard(digest, 2, 2)) 58 | ['1f', '09', '1f09d30c707d53f3d16c530dd73d70a6ce7596a9'] 59 | 60 | A width of 5 and depth of 1 will result in only one shard of length 5. 61 | 62 | >>> list(shard(digest, 5, 1)) 63 | ['1f09d', '1f09d30c707d53f3d16c530dd73d70a6ce7596a9'] 64 | 65 | A width of 1 and depth of 5 will give 5 shards of length 1. 66 | 67 | >>> list(shard(digest, 1, 5)) 68 | ['1', 'f', '0', '9', 'd', '1f09d30c707d53f3d16c530dd73d70a6ce7596a9'] 69 | 70 | If the `rest_only` parameter is true, only the remainder of the sharded 71 | string will be used as the last element: 72 | 73 | >>> list(shard(digest, 2, 2, rest_only=True)) 74 | ['1f', '09', 'd30c707d53f3d16c530dd73d70a6ce7596a9'] 75 | 76 | """ 77 | 78 | for i in xrange(depth): 79 | yield string[(width * i):(width * (i + 1))] 80 | 81 | if rest_only: 82 | yield string[(width * depth):] 83 | else: 84 | yield string 85 | 86 | 87 | def rm_file_and_empty_parents(filename, root=None): 88 | """Delete a file, keep removing empty parent dirs up to `root`.""" 89 | 90 | if root: 91 | root_stat = os.stat(root) 92 | 93 | os.unlink(filename) 94 | directory = os.path.dirname(filename) 95 | while not (root and os.path.samestat(root_stat, os.stat(directory))): 96 | if os.listdir(directory): 97 | break 98 | os.rmdir(directory) 99 | directory = os.path.dirname(directory) 100 | -------------------------------------------------------------------------------- /djcastor/storage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | from django.core.exceptions import SuspiciousOperation 6 | from django.core.files.storage import FileSystemStorage 7 | from django.utils._os import safe_join 8 | from django.utils.encoding import smart_str 9 | 10 | from djcastor import utils 11 | 12 | 13 | class CAStorage(FileSystemStorage): 14 | 15 | """ 16 | A content-addressable storage backend for Django. 17 | 18 | Basic Usage 19 | ----------- 20 | 21 | from django.db import models 22 | from djcastor import CAStorage 23 | 24 | class MyModel(models.Model): 25 | ... 26 | uploaded_file = models.FileField(storage=CAStorage()) 27 | 28 | Extended Usage 29 | -------------- 30 | 31 | There are several options you can pass to the `CAStorage` constructor. The 32 | first two are inherited from `django.core.files.storage.FileSystemStorage`: 33 | 34 | * `location`: The absolute path to the directory that will hold uploaded 35 | files. If omitted, this will be set to the value of the `MEDIA_ROOT` 36 | setting. 37 | 38 | * `base_url`: The URL that serves the files stored at this location. If 39 | omitted, this will be set to the value of the `MEDIA_URL` setting. 40 | 41 | `CAStorage` also adds two custom options: 42 | 43 | * `keep_extension` (default `True`): Preserve the extension on uploaded 44 | files. This allows the webserver to guess their `Content-Type`. 45 | 46 | * `sharding` (default `(2, 2)`): The width and depth to use when sharding 47 | digests, expressed as a two-tuple. `django-castor` shards files in the 48 | uploads directory based on their digests; this prevents filesystem 49 | issues when too many files are in a single directory. Sharding is based 50 | on two parameters: *width* and *depth*. The following examples show how 51 | these affect the sharding: 52 | 53 | >>> digest = '1f09d30c707d53f3d16c530dd73d70a6ce7596a9' 54 | 55 | >>> print shard(digest, width=2, depth=2) 56 | 1f/09/1f09d30c707d53f3d16c530dd73d70a6ce7596a9 57 | 58 | >>> print shard(digest, width=2, depth=3) 59 | 1f/09/d3/1f09d30c707d53f3d16c530dd73d70a6ce7596a9 60 | 61 | >>> print shard(digest, width=3, depth=2) 62 | 1f0/9d3/1f09d30c707d53f3d16c530dd73d70a6ce7596a9 63 | 64 | """ 65 | 66 | def __init__(self, location=None, base_url=None, keep_extension=True, 67 | sharding=(2, 2)): 68 | # Avoid a confusing issue when you don't have a trailing slash: URLs 69 | # are generated which point to the parent. This is due to the behavior 70 | # of `urlparse.urljoin()`. 71 | if base_url is not None and not base_url.endswith('/'): 72 | base_url += '/' 73 | 74 | super(CAStorage, self).__init__(location=location, base_url=base_url) 75 | 76 | self.shard_width, self.shard_depth = sharding 77 | self.keep_extension = keep_extension 78 | 79 | @staticmethod 80 | def get_available_name(name): 81 | """Return the name as-is; in CAS, given names are ignored anyway.""" 82 | 83 | return name 84 | 85 | def digest(self, content): 86 | if hasattr(content, 'temporary_file_path'): 87 | return utils.hash_filename(content.temporary_file_path()) 88 | digest = utils.hash_chunks(content.chunks()) 89 | content.seek(0) 90 | return digest 91 | 92 | def shard(self, hexdigest): 93 | return list(utils.shard(hexdigest, self.shard_width, self.shard_depth, 94 | rest_only=False)) 95 | 96 | def path(self, hexdigest): 97 | shards = self.shard(hexdigest) 98 | 99 | try: 100 | path = safe_join(self.location, *shards) 101 | except ValueError: 102 | raise SuspiciousOperation("Attempted access to '%s' denied." % 103 | ('/'.join(shards),)) 104 | 105 | return smart_str(os.path.normpath(path)) 106 | 107 | def url(self, name): 108 | return super(CAStorage, self).url('/'.join(self.shard(name))) 109 | 110 | def delete(self, name, sure=False): 111 | if not sure: 112 | # Ignore automatic deletions; we don't know how many different 113 | # records point to one file. 114 | return 115 | 116 | path = name 117 | if os.path.sep not in path: 118 | path = self.path(name) 119 | utils.rm_file_and_empty_parents(path, root=self.location) 120 | 121 | def _save(self, name, content): 122 | digest = self.digest(content) 123 | if self.keep_extension: 124 | digest += os.path.splitext(name)[1] 125 | path = self.path(digest) 126 | if os.path.exists(path): 127 | return digest 128 | return super(CAStorage, self)._save(digest, content) 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `django-castor` 2 | 3 | `django-castor` is a re-usable app for Django which provides a 4 | **content-addressable storage backend**. The main class, 5 | `djcastor.storage.CAStorage`, is a type of `FileSystemStorage` which saves files 6 | under their SHA-1 digest. 7 | 8 | * No matter how many times the same file is uploaded, it will only ever be 9 | stored once, thus eliminating redundancy. 10 | 11 | * Filenames are pseudorandom and made up only of hexadecimal characters, so 12 | you don’t have to worry about filename collisions or sanitization. 13 | 14 | * `django-castor` shards files in the uploads directory based on their 15 | digests; this prevents filesystem issues when too many files are in a single 16 | directory. 17 | 18 | For more information on the CAS concept, see the [wikipedia page][]. 19 | 20 | [wikipedia page]: http://en.wikipedia.org/wiki/Content-addressable_storage 21 | 22 | 23 | ## Installation 24 | 25 | pip install django-castor # or 26 | easy_install django-castor 27 | 28 | 29 | ## Usage 30 | 31 | Basic usage is as follows: 32 | 33 | from django.db import models 34 | from djcastor import CAStorage 35 | 36 | class MyModel(models.Model): 37 | ... 38 | uploaded_file = models.FileField(storage=CAStorage(), 39 | upload_to='uploads') 40 | 41 | At the moment, Django requires a non-empty value for the `upload_to` parameter. 42 | Note that `CAStorage` will **not** use this value; if you need to customize the 43 | destination for uploaded files, use the `location` parameter (see below). 44 | 45 | For extended usage, there are several options you can pass to the `CAStorage` 46 | constructor. The first two are inherited from the built-in `FileSystemStorage`: 47 | 48 | * `location`: The absolute path to the directory that will hold uploaded 49 | files. If omitted, this will be set to the value of the `MEDIA_ROOT` 50 | setting. 51 | 52 | * `base_url`: The URL that serves the files stored at this location. If 53 | omitted, this will be set to the value of the `MEDIA_URL` setting. 54 | 55 | `CAStorage` also adds two custom options: 56 | 57 | * `keep_extension` (default `True`): Preserve the extension on uploaded files. 58 | This allows the webserver to guess their `Content-Type`. 59 | 60 | * `sharding` (default `(2, 2)`): The width and depth to use when sharding 61 | digests, expressed as a two-tuple. The following examples show how these 62 | parameters affect the sharding: 63 | 64 | >>> digest = '1f09d30c707d53f3d16c530dd73d70a6ce7596a9' 65 | 66 | >>> print shard(digest, width=2, depth=2) 67 | 1f/09/1f09d30c707d53f3d16c530dd73d70a6ce7596a9 68 | 69 | >>> print shard(digest, width=2, depth=3) 70 | 1f/09/d3/1f09d30c707d53f3d16c530dd73d70a6ce7596a9 71 | 72 | >>> print shard(digest, width=3, depth=2) 73 | 1f0/9d3/1f09d30c707d53f3d16c530dd73d70a6ce7596a9 74 | 75 | 76 | ## Caveats 77 | 78 | The first small caveat is that content-addressable storage is not suited to 79 | rapidly-changing content. If your website modifies the contents of file fields 80 | on a regular basis, it might be a better idea to use a UUID-based storage 81 | backend for those fields. 82 | 83 | The second, more important caveat with this approach is that if the parent model 84 | of a file is deleted, the file will remain on disk. Because individual files may 85 | be referred to by more than one model, and `django-castor` has no awareness of 86 | these references, it leaves file deletion up to the developer. 87 | 88 | For the most part, you can get away without deleting uploads. In fact, 89 | content-addressable storage is often used for long-term archival systems, where 90 | files are immutable and must be kept for future auditing (usually for compliance 91 | with government regulations). 92 | 93 | If disk space is at a premium and you need to delete uploads, there are two 94 | approaches you might want to take: 95 | 96 | * Garbage collection: write a script that walks through the list of uploaded 97 | files and checks references to each one. If no references are found, delete 98 | the file. 99 | 100 | * Reference counting: denormalize the `FileField` into a separate model, and 101 | keep a count of all the models pointing to it. Once this count reaches zero, 102 | delete the file from the filesystem. 103 | 104 | 105 | ## (Un)license 106 | 107 | This is free and unencumbered software released into the public domain. 108 | 109 | Anyone is free to copy, modify, publish, use, compile, sell, or distribute this 110 | software, either in source code form or as a compiled binary, for any purpose, 111 | commercial or non-commercial, and by any means. 112 | 113 | In jurisdictions that recognize copyright laws, the author or authors of this 114 | software dedicate any and all copyright interest in the software to the public 115 | domain. We make this dedication for the benefit of the public at large and to 116 | the detriment of our heirs and successors. We intend this dedication to be an 117 | overt act of relinquishment in perpetuity of all present and future rights to 118 | this software under copyright law. 119 | 120 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 121 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 122 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE 123 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 124 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 125 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 126 | 127 | For more information, please refer to 128 | --------------------------------------------------------------------------------