120 lines
4.2 KiB
Python
120 lines
4.2 KiB
Python
"""Shared file-persistence helpers for UGC uploads.
|
|
|
|
Stored under MEDIA_ROOT/project_<pk>/... mirroring the avatar layout
|
|
(avatars/user_<id>/picture.jpeg). Draft uploads live under
|
|
MEDIA_ROOT/uploads/user_<id>/ until a project adopts them. All files are
|
|
indexed in FileIndex and served only through the gated file_request endpoint.
|
|
"""
|
|
|
|
import hashlib
|
|
import os
|
|
import uuid
|
|
|
|
from django.core.files.base import ContentFile
|
|
from django.core.files.storage import default_storage
|
|
|
|
from .models import FileIndex
|
|
|
|
|
|
def _compute_md5_size(uploaded):
|
|
md5 = hashlib.md5()
|
|
size = 0
|
|
uploaded.seek(0)
|
|
while True:
|
|
chunk = uploaded.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
md5.update(chunk)
|
|
size += len(chunk)
|
|
uploaded.seek(0)
|
|
return md5.hexdigest(), size
|
|
|
|
|
|
def store_file(owner, kind, uploaded, project_pk, subdir='', original_filename=''):
|
|
"""Persist an uploaded file and index it.
|
|
|
|
Returns the FileIndex row. ``subdir`` is e.g. 'versions' or 'gallery'.
|
|
"""
|
|
name = getattr(uploaded, 'name', '') or original_filename or 'file'
|
|
content_type = getattr(uploaded, 'content_type', '') or 'application/octet-stream'
|
|
md5, size = _compute_md5_size(uploaded)
|
|
|
|
ext = os.path.splitext(name)[1] or ''
|
|
if subdir:
|
|
stored_path = f'project_{project_pk}/{subdir}/{uuid.uuid4().hex}{ext}'
|
|
else:
|
|
stored_path = f'project_{project_pk}/{uuid.uuid4().hex}{ext}'
|
|
|
|
actual_path = default_storage.save(stored_path, ContentFile(uploaded.read()))
|
|
return FileIndex.objects.create(
|
|
owner=owner,
|
|
kind=kind,
|
|
stored_path=actual_path,
|
|
original_filename=name,
|
|
content_type=content_type,
|
|
size=size,
|
|
md5=md5,
|
|
)
|
|
|
|
|
|
def store_temp_file(owner, kind, uploaded):
|
|
"""Persist a draft upload under uploads/user_<id>/ and return its FileIndex."""
|
|
name = getattr(uploaded, 'name', '') or 'file'
|
|
content_type = getattr(uploaded, 'content_type', '') or 'application/octet-stream'
|
|
md5, size = _compute_md5_size(uploaded)
|
|
|
|
ext = os.path.splitext(name)[1] or ''
|
|
stored_path = f'uploads/user_{owner.pk}/{uuid.uuid4().hex}{ext}'
|
|
actual_path = default_storage.save(stored_path, ContentFile(uploaded.read()))
|
|
return FileIndex.objects.create(
|
|
owner=owner,
|
|
kind=f'temp_{kind}',
|
|
stored_path=actual_path,
|
|
original_filename=name,
|
|
content_type=content_type,
|
|
size=size,
|
|
md5=md5,
|
|
)
|
|
|
|
|
|
def move_file_index(file_index, new_stored_path, kind=None):
|
|
"""Move an indexed file to a new stored path (chunked copy + delete old),
|
|
updating the index. Used when a project adopts a temp upload."""
|
|
if file_index.stored_path and file_index.stored_path != new_stored_path:
|
|
with default_storage.open(file_index.stored_path, 'rb') as src:
|
|
actual_path = default_storage.save(new_stored_path, src)
|
|
if actual_path != file_index.stored_path and default_storage.exists(file_index.stored_path):
|
|
default_storage.delete(file_index.stored_path)
|
|
file_index.stored_path = actual_path
|
|
if kind:
|
|
file_index.kind = kind
|
|
file_index.save(update_fields=['stored_path'] if not kind else ['stored_path', 'kind'])
|
|
return file_index
|
|
|
|
|
|
def refresh_file_index(file_index):
|
|
"""Recompute size + md5 from the stored file. Needed after an in-place
|
|
rewrite (e.g. injection of animationframework metadata) so downloads keep
|
|
a correct Content-Length."""
|
|
if not file_index.stored_path or not default_storage.exists(file_index.stored_path):
|
|
return file_index
|
|
with default_storage.open(file_index.stored_path, 'rb') as src:
|
|
md5 = hashlib.md5()
|
|
size = 0
|
|
for chunk in iter(lambda: src.read(1024 * 1024), b''):
|
|
md5.update(chunk)
|
|
size += len(chunk)
|
|
file_index.size = size
|
|
file_index.md5 = md5.hexdigest()
|
|
file_index.save(update_fields=['size', 'md5'])
|
|
return file_index
|
|
|
|
|
|
def delete_file_index(file_index):
|
|
"""Remove an indexed file from disk and DB (safe no-op when None)."""
|
|
if file_index is None:
|
|
return
|
|
if file_index.stored_path and default_storage.exists(file_index.stored_path):
|
|
default_storage.delete(file_index.stored_path)
|
|
file_index.delete()
|