""" Topic-based file sharing views. Each topic is accessible via a subdomain (e.g., cofi.rfiles.online). Anyone can view files and upload new ones - no password required. """ import os import uuid from django.conf import settings from django.core.files.base import File from django.shortcuts import render, redirect, get_object_or_404 from django.http import JsonResponse, Http404 from django.views import View from django.views.decorators.csrf import csrf_exempt from django.utils.decorators import method_decorator from files.models import SharedSpace, MediaFile, PublicShare CHUNK_UPLOAD_DIR = os.path.join(settings.MEDIA_ROOT, 'chunks') def get_topic_or_404(request): """Get the topic (shared space) from the request's subdomain slug.""" slug = getattr(request, 'shared_space_slug', None) if not slug: raise Http404("Topic not found") return get_object_or_404(SharedSpace, slug=slug, is_active=True) class SharedSpaceLoginView(View): """Redirect login to home - no login needed for topics.""" def get(self, request): return redirect('shared_space_home') def post(self, request): return redirect('shared_space_home') class SharedSpaceLogoutView(View): """Redirect to home - no logout needed.""" def get(self, request): return redirect('shared_space_home') class SharedSpaceHomeView(View): """Main upload page for topic - upload zone + files.""" def get(self, request): space = get_topic_or_404(request) recent_files = space.files.all().order_by('-created_at')[:20] return render(request, 'portal/shared_space/home.html', { 'space': space, 'recent_files': recent_files, }) @method_decorator(csrf_exempt, name='dispatch') class SharedSpaceUploadAPIView(View): """Handle file uploads via AJAX for topic.""" def post(self, request): space = get_topic_or_404(request) if not request.FILES.get('file'): return JsonResponse({'error': 'No file provided'}, status=400) uploaded_file = request.FILES['file'] if space.max_file_size_mb > 0: max_size_bytes = space.max_file_size_mb * 1024 * 1024 if uploaded_file.size > max_size_bytes: return JsonResponse({ 'error': f'File too large. Maximum size is {space.max_file_size_mb}MB' }, status=400) action = request.POST.get('action', '') # Check for duplicate filename in this space existing = MediaFile.objects.filter( shared_space=space, original_filename=uploaded_file.name, ).first() if existing and action != 'overwrite': existing_share = existing.public_shares.filter(is_active=True).first() return JsonResponse({ 'duplicate': True, 'existing_file': { 'id': str(existing.id), 'title': existing.title, 'filename': existing.original_filename, 'size': existing.file_size, 'share_url': existing_share.get_public_url() if existing_share else None, }, }, status=409) # Overwrite: delete the old file and its shares if existing and action == 'overwrite': existing.file.delete(save=False) existing.delete() title = request.POST.get('title', '') or uploaded_file.name description = request.POST.get('description', '') media_file = MediaFile.objects.create( file=uploaded_file, original_filename=uploaded_file.name, title=title, description=description, mime_type=uploaded_file.content_type or 'application/octet-stream', uploaded_by=request.user if request.user.is_authenticated else None, shared_space=space, ) share = PublicShare.objects.create( media_file=media_file, created_by=request.user if request.user.is_authenticated else None, note=f'Uploaded to topic: {space.slug}', ) return JsonResponse({ 'success': True, 'file': { 'id': str(media_file.id), 'title': media_file.title, 'filename': media_file.original_filename, 'size': media_file.file_size, 'mime_type': media_file.mime_type, }, 'share': { 'token': share.token, 'url': share.get_public_url(), } }) @method_decorator(csrf_exempt, name='dispatch') class DirectUploadAPIView(View): """Handle uploads via direct.rfiles.online (bypasses Cloudflare). Space slug is passed as a form field instead of from the subdomain. """ def post(self, request): space_slug = request.POST.get('space', '') if not space_slug: return JsonResponse({'error': 'Missing space parameter'}, status=400) space = get_object_or_404(SharedSpace, slug=space_slug, is_active=True) if not request.FILES.get('file'): return JsonResponse({'error': 'No file provided'}, status=400) uploaded_file = request.FILES['file'] if space.max_file_size_mb > 0: max_size_bytes = space.max_file_size_mb * 1024 * 1024 if uploaded_file.size > max_size_bytes: return JsonResponse({ 'error': f'File too large. Maximum size is {space.max_file_size_mb}MB' }, status=400) action = request.POST.get('action', '') existing = MediaFile.objects.filter( shared_space=space, original_filename=uploaded_file.name, ).first() if existing and action != 'overwrite': existing_share = existing.public_shares.filter(is_active=True).first() return JsonResponse({ 'duplicate': True, 'existing_file': { 'id': str(existing.id), 'title': existing.title, 'filename': existing.original_filename, 'size': existing.file_size, 'share_url': existing_share.get_public_url() if existing_share else None, }, }, status=409) if existing and action == 'overwrite': existing.file.delete(save=False) existing.delete() title = request.POST.get('title', '') or uploaded_file.name description = request.POST.get('description', '') media_file = MediaFile.objects.create( file=uploaded_file, original_filename=uploaded_file.name, title=title, description=description, mime_type=uploaded_file.content_type or 'application/octet-stream', uploaded_by=request.user if request.user.is_authenticated else None, shared_space=space, ) share = PublicShare.objects.create( media_file=media_file, created_by=request.user if request.user.is_authenticated else None, note=f'Uploaded to topic: {space.slug}', ) return JsonResponse({ 'success': True, 'file': { 'id': str(media_file.id), 'title': media_file.title, 'filename': media_file.original_filename, 'size': media_file.file_size, 'mime_type': media_file.mime_type, }, 'share': { 'token': share.token, 'url': share.get_public_url(), } }) @method_decorator(csrf_exempt, name='dispatch') class ChunkedUploadInitView(View): """Initialize a chunked upload session.""" def post(self, request): space = get_topic_or_404(request) filename = request.POST.get('filename', '') total_size = int(request.POST.get('total_size', 0)) total_chunks = int(request.POST.get('total_chunks', 0)) mime_type = request.POST.get('mime_type', 'application/octet-stream') action = request.POST.get('action', '') if not filename or total_chunks < 1: return JsonResponse({'error': 'Invalid parameters'}, status=400) if space.max_file_size_mb > 0: max_size_bytes = space.max_file_size_mb * 1024 * 1024 if total_size > max_size_bytes: return JsonResponse({ 'error': f'File too large. Maximum size is {space.max_file_size_mb}MB' }, status=400) # Check for duplicate existing = MediaFile.objects.filter( shared_space=space, original_filename=filename, ).first() if existing and action != 'overwrite': existing_share = existing.public_shares.filter(is_active=True).first() return JsonResponse({ 'duplicate': True, 'existing_file': { 'id': str(existing.id), 'title': existing.title, 'filename': existing.original_filename, 'size': existing.file_size, 'share_url': existing_share.get_public_url() if existing_share else None, }, }, status=409) upload_id = str(uuid.uuid4()) chunk_dir = os.path.join(CHUNK_UPLOAD_DIR, upload_id) os.makedirs(chunk_dir, exist_ok=True) # Store metadata import json meta = { 'filename': filename, 'total_size': total_size, 'total_chunks': total_chunks, 'mime_type': mime_type, 'space_id': str(space.id), 'action': action, } with open(os.path.join(chunk_dir, 'meta.json'), 'w') as f: json.dump(meta, f) return JsonResponse({'upload_id': upload_id}) @method_decorator(csrf_exempt, name='dispatch') class ChunkedUploadChunkView(View): """Receive a single chunk and finalize when all chunks are received.""" def post(self, request): space = get_topic_or_404(request) upload_id = request.POST.get('upload_id', '') chunk_index = int(request.POST.get('chunk_index', -1)) chunk_file = request.FILES.get('chunk') if not upload_id or chunk_index < 0 or not chunk_file: return JsonResponse({'error': 'Invalid parameters'}, status=400) chunk_dir = os.path.join(CHUNK_UPLOAD_DIR, upload_id) meta_path = os.path.join(chunk_dir, 'meta.json') if not os.path.isdir(chunk_dir) or not os.path.exists(meta_path): return JsonResponse({'error': 'Invalid upload_id'}, status=400) import json with open(meta_path) as f: meta = json.load(f) # Verify this chunk belongs to this space if meta['space_id'] != str(space.id): return JsonResponse({'error': 'Space mismatch'}, status=403) # Write chunk to disk chunk_path = os.path.join(chunk_dir, f'{chunk_index:06d}') with open(chunk_path, 'wb') as f: for part in chunk_file.chunks(): f.write(part) # Check if all chunks received received = len([ n for n in os.listdir(chunk_dir) if n != 'meta.json' ]) if received < meta['total_chunks']: return JsonResponse({ 'received': received, 'total': meta['total_chunks'], }) # All chunks received — assemble the file assembled_path = os.path.join(chunk_dir, 'assembled') with open(assembled_path, 'wb') as out: for i in range(meta['total_chunks']): part_path = os.path.join(chunk_dir, f'{i:06d}') with open(part_path, 'rb') as part: while True: buf = part.read(8192) if not buf: break out.write(buf) # Handle overwrite if meta['action'] == 'overwrite': existing = MediaFile.objects.filter( shared_space=space, original_filename=meta['filename'], ).first() if existing: existing.file.delete(save=False) existing.delete() # Create MediaFile from assembled file with open(assembled_path, 'rb') as f: django_file = File(f, name=meta['filename']) media_file = MediaFile.objects.create( file=django_file, original_filename=meta['filename'], title=meta['filename'], mime_type=meta['mime_type'], uploaded_by=request.user if request.user.is_authenticated else None, shared_space=space, ) share = PublicShare.objects.create( media_file=media_file, created_by=request.user if request.user.is_authenticated else None, note=f'Uploaded to topic: {space.slug}', ) # Cleanup chunk directory import shutil shutil.rmtree(chunk_dir, ignore_errors=True) return JsonResponse({ 'success': True, 'file': { 'id': str(media_file.id), 'title': media_file.title, 'filename': media_file.original_filename, 'size': media_file.file_size, 'mime_type': media_file.mime_type, }, 'share': { 'token': share.token, 'url': share.get_public_url(), } }) class SharedSpaceFileListView(View): """List all files in the topic.""" def get(self, request): space = get_topic_or_404(request) files = space.files.all().order_by('-created_at') return render(request, 'portal/shared_space/files.html', { 'space': space, 'files': files, })