Files
links/links/file_views.py
T
2026-07-28 21:42:58 +10:00

446 lines
18 KiB
Python

import json
import mimetypes
import os
import secrets
import logging
from pathlib import Path
from django.conf import settings
from django.db.models import F
from django.http import FileResponse, JsonResponse, Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.dateparse import parse_datetime
from django.views import View
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from rest_framework.response import Response
from .models import FileUpload
from .serializers import FileUploadSerializer
logger = logging.getLogger(__name__)
class FileSaveError(Exception):
"""Raised when an uploaded file could not be fully persisted to disk."""
def _get_upload_folder():
folder = settings.FILE_UPLOADS_FOLDER
Path(folder).mkdir(parents=True, exist_ok=True)
return folder
def _cleanup_partial(path):
try:
if os.path.exists(path):
os.remove(path)
except OSError:
logger.warning("Failed to clean up partial upload file %s", path, exc_info=True)
def _save_uploaded_file(f):
"""Save an in-memory upload to FILE_UPLOADS_FOLDER and return a FileUpload instance.
Writes to a temporary path first, fsyncs, and verifies the byte count on
disk matches what was uploaded *before* creating the DB record or moving
the file into its final place. This prevents "phantom" records that point
at a missing/truncated file if the underlying storage (e.g. a flaky NFS
mount) acknowledges a write that never actually lands on disk.
"""
folder = _get_upload_folder()
ext = Path(f.name).suffix.lower()
stored_name = f"{secrets.token_hex(16)}{ext}"
dest_path = os.path.join(folder, stored_name)
tmp_path = f"{dest_path}.part"
try:
with open(tmp_path, 'wb') as dst:
for chunk in f.chunks():
dst.write(chunk)
dst.flush()
os.fsync(dst.fileno())
except OSError as exc:
_cleanup_partial(tmp_path)
raise FileSaveError(f"Failed to write \"{f.name}\" to storage: {exc}") from exc
actual_size = os.path.getsize(tmp_path)
if actual_size != f.size:
_cleanup_partial(tmp_path)
raise FileSaveError(
f"Upload of \"{f.name}\" is incomplete ({actual_size}/{f.size} bytes written)."
)
os.rename(tmp_path, dest_path)
guessed = mimetypes.guess_type(f.name)[0]
# Clients (notably the iOS uploader) often send a generic
# "application/octet-stream" Content-Type even for media files. When that
# happens, prefer the type inferred from the filename extension so the
# file is previewable/streamable in the UI.
if f.content_type and f.content_type != 'application/octet-stream':
mime_type = f.content_type
else:
mime_type = guessed or f.content_type or 'application/octet-stream'
return FileUpload.objects.create(
name=f.name,
stored_name=stored_name,
mime_type=mime_type,
size=actual_size,
)
def _stream_file(file_path, mime_type, disposition, range_header=None):
"""Return a streaming response for a media file that honours HTTP Range.
Browsers send a ``Range: bytes=start-end`` header to seek inside
``<video>`` / ``<audio>`` elements. Django's ``FileResponse`` ignores it,
so we parse the header ourselves and reply with ``206 Partial Content`` and
a ``Content-Range`` header. Without this, the media element can only play
sequentially and seeking fails.
"""
size = os.path.getsize(file_path)
# No Range header → serve the whole file (200 OK).
if not range_header or not range_header.startswith('bytes='):
response = FileResponse(open(file_path, 'rb'), content_type=mime_type)
response['Content-Length'] = size
response['Accept-Ranges'] = 'bytes'
response['Content-Disposition'] = disposition
return response
# Parse "bytes=start-end" (end optional; multiple ranges not supported here).
spec = range_header[len('bytes='):]
# Ignore multipart ranges; pick the first range.
if ',' in spec:
spec = spec.split(',', 1)[0].strip()
try:
start_str, end_str = spec.split('-', 1)
start = int(start_str) if start_str.strip() else 0
end = int(end_str) if end_str.strip() else size - 1
except (ValueError, IndexError):
response = HttpResponse(status=416) # Range Not Satisfiable
response['Content-Range'] = f'bytes */{size}'
return response
# Clamp to file bounds.
if start < 0:
start = 0
if end >= size:
end = size - 1
if start > end:
response = HttpResponse(status=416)
response['Content-Range'] = f'bytes */{size}'
return response
length = end - start + 1
def _range_iterator(_fh, _start, _length):
_fh.seek(_start)
remaining = _length
chunk_size = 64 * 1024
while remaining > 0:
read = min(chunk_size, remaining)
data = _fh.read(read)
if not data:
break
remaining -= len(data)
yield data
fh = open(file_path, 'rb')
response = FileResponse(_range_iterator(fh, start, length), content_type=mime_type, status=206)
response['Content-Length'] = length
response['Content-Range'] = f'bytes {start}-{end}/{size}'
response['Accept-Ranges'] = 'bytes'
response['Content-Disposition'] = disposition
# Ensure the file handle is closed when the response finalises.
response.close = fh.close
return response
# ── UI Views ──────────────────────────────────────────────────────────────────
class FileListView(View):
def get(self, request):
files = FileUpload.objects.all()
return render(request, 'links/files/list.html', {'files': files})
class FileUploadView(View):
def post(self, request):
is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
uploaded = request.FILES.getlist('files')
if not uploaded:
if is_ajax:
return JsonResponse({'error': 'No files provided'}, status=400)
return redirect('file-list')
results = []
errors = []
for f in uploaded:
try:
record = _save_uploaded_file(f)
except FileSaveError as exc:
logger.error("Upload failed for %s: %s", f.name, exc)
errors.append({'name': f.name, 'error': str(exc)})
continue
results.append({
'id': str(record.pk),
'name': record.name,
'mime_type': record.mime_type,
'size': record.size,
'formatted_size': record.formatted_size(),
'is_public': record.is_public,
'public_url': record.public_url,
'expires_at': record.expires_at.isoformat() if record.expires_at else None,
'download_count': record.download_count,
'created_at': record.created_at.isoformat(),
'download_url': record.download_url,
'is_image': record.is_image,
})
if is_ajax:
status_code = 200 if results else 500
return JsonResponse({'uploaded': results, 'errors': errors}, status=status_code)
return redirect('file-list')
class FileDownloadView(View):
def get(self, request, pk, filename=''):
record = get_object_or_404(FileUpload, pk=pk)
if not os.path.exists(record.file_path):
raise Http404("File not found on disk")
FileUpload.objects.filter(pk=pk).update(download_count=F('download_count') + 1)
inline = record.is_image or record.mime_type.startswith('video/') or record.mime_type.startswith('audio/')
disposition = 'inline' if inline else f'attachment; filename="{record.name}"'
return _stream_file(record.file_path, record.mime_type, disposition, request.headers.get('Range'))
class FileDeleteView(View):
def post(self, request, pk):
record = get_object_or_404(FileUpload, pk=pk)
if os.path.exists(record.file_path):
os.remove(record.file_path)
record.delete()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({'deleted': str(pk)})
return redirect('file-list')
class FileTogglePublicView(View):
def post(self, request, pk):
record = get_object_or_404(FileUpload, pk=pk)
if record.is_public:
record.is_public = False
record.save(update_fields=['is_public', 'updated_at'])
return JsonResponse({'is_public': False, 'public_url': None})
record.is_public = True
record.save(update_fields=['is_public', 'updated_at'])
return JsonResponse({
'is_public': True,
'public_url': record.public_url,
})
class FileSetExpiryView(View):
def post(self, request, pk):
record = get_object_or_404(FileUpload, pk=pk)
try:
data = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'Invalid JSON'}, status=400)
expires_at = data.get('expires_at')
if expires_at:
dt = parse_datetime(expires_at)
if not dt:
return JsonResponse({'error': 'Invalid datetime format. Use ISO 8601.'}, status=400)
record.expires_at = dt
else:
record.expires_at = None
record.save(update_fields=['expires_at', 'updated_at'])
return JsonResponse({
'expires_at': record.expires_at.isoformat() if record.expires_at else None,
'is_expired': record.is_expired,
})
class PublicFileView(View):
"""Serve uploaded files via /public/files/{uuid}-{name}.
Access rules:
- Internal network (192.168.x.x, 10.x.x.x, 172.16-31.x.x): serve any file
- External (go.junv.cc, xgo.junv.cc): only serve is_public=True files
- Videos and images stream inline; other files force download.
"""
INTERNAL_NETS = ('192.168.', '10.', '172.16.', '172.17.', '172.18.',
'172.19.', '172.20.', '172.21.', '172.22.', '172.23.',
'172.24.', '172.25.', '172.26.', '172.27.', '172.28.',
'172.29.', '172.30.', '172.31.')
def _is_internal(self, request):
xff = request.META.get('HTTP_X_FORWARDED_FOR', '')
client_ip = xff.split(',')[0].strip() if xff else request.META.get('REMOTE_ADDR', '')
return any(client_ip.startswith(net) for net in self.INTERNAL_NETS)
def get(self, request, pk, filename=''):
record = get_object_or_404(FileUpload, pk=pk)
# External access: only public files
if not self._is_internal(request) and not record.is_public:
raise Http404("This file is private")
if record.is_expired:
raise Http404("This link has expired")
if not os.path.exists(record.file_path):
raise Http404("File not found")
FileUpload.objects.filter(pk=record.pk).update(download_count=F('download_count') + 1)
# Inline for media, attachment for everything else
inline = record.is_image or record.mime_type.startswith('video/') or record.mime_type.startswith('audio/')
disposition = 'inline' if inline else f'attachment; filename="{record.name}"'
return _stream_file(record.file_path, record.mime_type, disposition, request.headers.get('Range'))
# ── REST API ViewSet ──────────────────────────────────────────────────────────
class FileUploadViewSet(viewsets.ModelViewSet):
queryset = FileUpload.objects.all()
serializer_class = FileUploadSerializer
parser_classes = [MultiPartParser, FormParser, JSONParser]
http_method_names = ['get', 'post', 'delete', 'head', 'options']
def create(self, request, *args, **kwargs):
uploaded = request.FILES.getlist('files')
if not uploaded:
return Response({'error': 'No files provided. Use "files" field.'}, status=status.HTTP_400_BAD_REQUEST)
created = []
errors = []
for f in uploaded:
try:
created.append(_save_uploaded_file(f))
except FileSaveError as exc:
logger.error("Upload failed for %s: %s", f.name, exc)
errors.append({'name': f.name, 'error': str(exc)})
if not created:
return Response(
{'error': 'Failed to save uploaded file(s).', 'errors': errors},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
serializer = self.get_serializer(created, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def destroy(self, request, *args, **kwargs):
record = self.get_object()
if os.path.exists(record.file_path):
os.remove(record.file_path)
record.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=['post'], url_path='toggle-public')
def toggle_public(self, request, pk=None):
record = self.get_object()
if record.is_public:
record.is_public = False
record.save(update_fields=['is_public', 'updated_at'])
return Response({'is_public': False, 'public_url': None})
record.is_public = True
record.save(update_fields=['is_public', 'updated_at'])
return Response({
'is_public': True,
'public_url': record.public_url,
})
@action(detail=True, methods=['post'], url_path='set-expiry')
def set_expiry(self, request, pk=None):
record = self.get_object()
expires_at = request.data.get('expires_at')
if expires_at:
dt = parse_datetime(str(expires_at))
if not dt:
return Response({'error': 'Invalid datetime. Use ISO 8601.'}, status=status.HTTP_400_BAD_REQUEST)
record.expires_at = dt
else:
record.expires_at = None
record.save(update_fields=['expires_at', 'updated_at'])
return Response(self.get_serializer(record).data)
@action(detail=True, methods=['get'])
def download(self, request, pk=None):
record = self.get_object()
if not os.path.exists(record.file_path):
raise Http404("File not found")
FileUpload.objects.filter(pk=pk).update(download_count=F('download_count') + 1)
inline = record.is_image or record.mime_type.startswith('video/') or record.mime_type.startswith('audio/')
disposition = 'inline' if inline else f'attachment; filename="{record.name}"'
return _stream_file(record.file_path, record.mime_type, disposition, request.headers.get('Range'))
def import_image_view(request, image_url):
"""Proxy-and-cache an external image via /import/images/<path:image_url>.
The image_url path component has no scheme (e.g. 'example.com/path/img.jpg').
On first request the view:
1. Creates a FileUpload stub in the database (is_public=True, source_url set).
2. Fires a background thread to download and save the file.
3. Immediately redirects to the original URL so the image is visible right away.
On subsequent requests, once the file is saved locally, the view redirects to
the stored public URL instead — no more dependency on the original host.
If the background download fails, the stub record is deleted automatically so no
size=0 ghost appears in the file list. The next visit to the same URL will create
a fresh stub and retry. A periodic APScheduler task (`retry_stuck_image_imports`)
also reschedules any stubs left behind by killed threads.
"""
import hashlib
import posixpath
from threading import Thread
from .tasks import download_and_save_image
# Build the canonical source URL, preserving query string.
# Django's <path:> converter only captures the path component; query params
# like ?format=jpg&name=900x900 end up in QUERY_STRING and must be re-attached.
query_string = request.META.get('QUERY_STRING', '')
source_url = f'https://{image_url}'
if query_string:
source_url = f'{source_url}?{query_string}'
url_hash = hashlib.sha256(source_url.encode()).hexdigest()[:20]
# Strip query string for filename derivation
filename = posixpath.basename(image_url.split('?')[0]) or f'image_{url_hash}'
# Derive extension from filename; fall back to .jpg for bare names
_, ext = posixpath.splitext(filename)
if not ext:
ext = '.jpg'
filename = f'{filename}{ext}'
stored_name = f'import_{url_hash}{ext}'
# Try to find an existing record for this URL (idempotent)
record = FileUpload.objects.filter(source_url=source_url).first()
if record is None:
# Create the stub immediately so we have a stable public URL
record = FileUpload.objects.create(
name=filename,
stored_name=stored_name,
mime_type=f'image/{ext.lstrip(".") or "jpeg"}',
size=0,
is_public=True,
source_url=source_url,
)
logger.info(f"import_image_view: created FileUpload {record.pk} for {source_url}")
# If the file is already on disk, serve from local storage
if os.path.exists(record.file_path):
return redirect(record.public_url)
# File not yet saved — kick off (or re-kick) the background download
thread = Thread(target=download_and_save_image, args=(str(record.pk),), daemon=True)
thread.start()
# Redirect to the original URL as a temporary placeholder while download runs
return redirect(source_url)