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."""