diff --git a/config/urls_shared_space.py b/config/urls_shared_space.py index 5503a14..eb37732 100644 --- a/config/urls_shared_space.py +++ b/config/urls_shared_space.py @@ -10,6 +10,8 @@ from portal.views_shared_space import ( SharedSpaceLogoutView, SharedSpaceUploadAPIView, SharedSpaceFileListView, + ChunkedUploadInitView, + ChunkedUploadChunkView, ) @@ -18,5 +20,7 @@ urlpatterns = [ path('login/', SharedSpaceLoginView.as_view(), name='shared_space_login'), path('logout/', SharedSpaceLogoutView.as_view(), name='shared_space_logout'), path('api/upload/', SharedSpaceUploadAPIView.as_view(), name='shared_space_upload'), + path('api/upload/init/', ChunkedUploadInitView.as_view(), name='chunked_upload_init'), + path('api/upload/chunk/', ChunkedUploadChunkView.as_view(), name='chunked_upload_chunk'), path('files/', SharedSpaceFileListView.as_view(), name='shared_space_files'), ] diff --git a/portal/templates/portal/shared_space/home.html b/portal/templates/portal/shared_space/home.html index 0fd2cf4..5e17a1f 100644 --- a/portal/templates/portal/shared_space/home.html +++ b/portal/templates/portal/shared_space/home.html @@ -248,60 +248,25 @@ const uploadZone = document.getElementById('uploadZone'); const fileInput = document.getElementById('fileInput'); const results = document.getElementById('results'); const maxSize = {{ space.max_file_size_mb }} * 1024 * 1024; // 0 = unlimited +const CHUNK_SIZE = 80 * 1024 * 1024; // 80MB chunks (under Cloudflare's 100MB limit) +const CHUNK_THRESHOLD = 90 * 1024 * 1024; // Use chunked upload for files > 90MB -// Click to select uploadZone.addEventListener('click', () => fileInput.click()); +uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); }); +uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('dragover'); }); +uploadZone.addEventListener('drop', (e) => { e.preventDefault(); uploadZone.classList.remove('dragover'); handleFiles(e.dataTransfer.files); }); +fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; }); -// Drag and drop -uploadZone.addEventListener('dragover', (e) => { - e.preventDefault(); - uploadZone.classList.add('dragover'); -}); - -uploadZone.addEventListener('dragleave', () => { - uploadZone.classList.remove('dragover'); -}); - -uploadZone.addEventListener('drop', (e) => { - e.preventDefault(); - uploadZone.classList.remove('dragover'); - handleFiles(e.dataTransfer.files); -}); - -// File input change -fileInput.addEventListener('change', () => { - handleFiles(fileInput.files); - fileInput.value = ''; -}); - -function handleFiles(files) { - Array.from(files).forEach(uploadFile); -} +function handleFiles(files) { Array.from(files).forEach(f => uploadFile(f)); } function formatBytes(bytes) { if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const k = 1024, sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } -function uploadFile(file, action) { - // Check file size before upload (skip if unlimited) - if (maxSize > 0 && file.size > maxSize) { - const item = document.createElement('div'); - item.className = 'result-item error'; - item.innerHTML = ` -
-

${file.name}

-
File too large (${formatBytes(file.size)}). Max: {{ space.max_file_size_mb }}MB
-
- `; - results.insertBefore(item, results.firstChild); - return; - } - - // Create or reuse result item +function makeItem(file, action) { let item = action ? document.getElementById('dup-' + file.name) : null; if (!item) { item = document.createElement('div'); @@ -317,89 +282,161 @@ function uploadFile(file, action) {
`; + return item; +} +function showSuccess(item, data) { + item.classList.add('success'); + item.innerHTML = ` +
+

${data.file.title}

+
${formatBytes(data.file.size)} - Uploaded successfully
+
+ + `; +} + +function showDuplicate(item, file, data, action) { + item.id = 'dup-' + file.name; + item.classList.add('duplicate'); + const existingSize = data.existing_file.size ? formatBytes(data.existing_file.size) : 'unknown size'; + item.innerHTML = ` +
+

${file.name}

+
"${data.existing_file.title}" already exists (${existingSize})
+
+
+ + +
+ `; + item.querySelector('.btn-overwrite').addEventListener('click', () => uploadFile(file, 'overwrite')); + item.querySelector('.btn-skip').addEventListener('click', () => { + item.classList.remove('duplicate'); + item.classList.add('error'); + item.innerHTML = `

${file.name}

Skipped (duplicate)
`; + }); +} + +function showError(item, msg) { + item.classList.add('error'); + item.innerHTML = `

${item.querySelector('h3')?.textContent || 'File'}

${msg}
`; +} + +function uploadFile(file, action) { + if (maxSize > 0 && file.size > maxSize) { + const item = document.createElement('div'); + item.className = 'result-item error'; + item.innerHTML = `

${file.name}

File too large (${formatBytes(file.size)}). Max: {{ space.max_file_size_mb }}MB
`; + results.insertBefore(item, results.firstChild); + return; + } + + if (file.size > CHUNK_THRESHOLD) { + uploadFileChunked(file, action); + } else { + uploadFileSimple(file, action); + } +} + +// Simple upload for files < 90MB +function uploadFileSimple(file, action) { + const item = makeItem(file, action); const formData = new FormData(); formData.append('file', file); if (action) formData.append('action', action); const xhr = new XMLHttpRequest(); - xhr.upload.addEventListener('progress', (e) => { - if (e.lengthComputable) { - const percent = (e.loaded / e.total) * 100; - item.querySelector('.progress').style.width = percent + '%'; - } + if (e.lengthComputable) item.querySelector('.progress').style.width = (e.loaded / e.total * 100) + '%'; }); - xhr.addEventListener('load', () => { if (xhr.status === 200) { - const data = JSON.parse(xhr.responseText); - item.classList.add('success'); - item.innerHTML = ` -
-

${data.file.title}

-
${formatBytes(data.file.size)} - Uploaded successfully
-
- - `; + showSuccess(item, JSON.parse(xhr.responseText)); } else if (xhr.status === 409) { - const data = JSON.parse(xhr.responseText); - item.id = 'dup-' + file.name; - item.classList.add('duplicate'); - const existingSize = data.existing_file.size ? formatBytes(data.existing_file.size) : 'unknown size'; - item.innerHTML = ` -
-

${file.name}

-
"${data.existing_file.title}" already exists (${existingSize})
-
-
- - -
- `; - item.querySelector('.btn-overwrite').addEventListener('click', () => uploadFile(file, 'overwrite')); - item.querySelector('.btn-skip').addEventListener('click', () => { - item.classList.remove('duplicate'); - item.classList.add('error'); - item.innerHTML = ` -
-

${file.name}

-
Skipped (duplicate)
-
- `; - }); + showDuplicate(item, file, JSON.parse(xhr.responseText)); } else { - let errorMsg = 'Upload failed'; - try { - const data = JSON.parse(xhr.responseText); - errorMsg = data.error || errorMsg; - } catch(e) {} - item.classList.add('error'); - item.querySelector('.meta').textContent = errorMsg; - const pb = item.querySelector('.progress-bar'); - if (pb) pb.remove(); + let msg = 'Upload failed'; + try { msg = JSON.parse(xhr.responseText).error || msg; } catch(e) {} + showError(item, msg); } }); - - xhr.addEventListener('error', () => { - item.classList.add('error'); - item.querySelector('.meta').textContent = 'Upload failed - network error'; - const pb = item.querySelector('.progress-bar'); - if (pb) pb.remove(); - }); - + xhr.addEventListener('error', () => showError(item, 'Upload failed - network error')); xhr.open('POST', '{% url "shared_space_upload" %}'); xhr.send(formData); } +// Chunked upload for files >= 90MB +async function uploadFileChunked(file, action) { + const item = makeItem(file, action); + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + + try { + // Step 1: Initialize upload + const initForm = new FormData(); + initForm.append('filename', file.name); + initForm.append('total_size', file.size); + initForm.append('total_chunks', totalChunks); + initForm.append('mime_type', file.type || 'application/octet-stream'); + if (action) initForm.append('action', action); + + const initResp = await fetch('{% url "chunked_upload_init" %}', { method: 'POST', body: initForm }); + const initData = await initResp.json(); + + if (initResp.status === 409) { + showDuplicate(item, file, initData); + return; + } + if (!initResp.ok) { + showError(item, initData.error || 'Failed to start upload'); + return; + } + + const uploadId = initData.upload_id; + + // Step 2: Send chunks sequentially + for (let i = 0; i < totalChunks; i++) { + const start = i * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); + + const chunkForm = new FormData(); + chunkForm.append('upload_id', uploadId); + chunkForm.append('chunk_index', i); + chunkForm.append('chunk', chunk, `chunk_${i}`); + + const chunkResp = await fetch('{% url "chunked_upload_chunk" %}', { method: 'POST', body: chunkForm }); + const chunkData = await chunkResp.json(); + + if (!chunkResp.ok) { + showError(item, chunkData.error || 'Chunk upload failed'); + return; + } + + // Update progress + const percent = ((i + 1) / totalChunks) * 100; + item.querySelector('.progress').style.width = percent + '%'; + item.querySelector('.meta').textContent = `${formatBytes(file.size)} - Uploading chunk ${i + 1}/${totalChunks}...`; + + // If this was the last chunk, the response includes the final file data + if (chunkData.success) { + showSuccess(item, chunkData); + return; + } + } + } catch (err) { + showError(item, 'Upload failed - ' + err.message); + } +} + function copyLink(btn, url) { navigator.clipboard.writeText(url).then(() => { - const originalText = btn.textContent; + const orig = btn.textContent; btn.textContent = 'Copied!'; - setTimeout(() => btn.textContent = originalText, 2000); + setTimeout(() => btn.textContent = orig, 2000); }); } diff --git a/portal/views_shared_space.py b/portal/views_shared_space.py index 8eed557..98dbcf1 100644 --- a/portal/views_shared_space.py +++ b/portal/views_shared_space.py @@ -5,6 +5,11 @@ 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 @@ -13,6 +18,8 @@ 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.""" @@ -132,6 +139,171 @@ class SharedSpaceUploadAPIView(View): }) +@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."""