Files
NoN-Site/nonpacks/library/storage.py
T
2026-08-04 12:05:45 -05:00

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()