mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
446 lines
18 KiB
Python
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)
|