Route uploads through direct.rfiles.online to bypass Cloudflare 100MB limit

- Add direct.rfiles.online A record (DNS only, not proxied through CF)
- Add TLS-enabled Traefik router with Let's Encrypt for direct subdomain
- Add DirectUploadAPIView that accepts space slug as form field
- All uploads now go to https://direct.rfiles.online/api/upload/
- CORS allows *.rfiles.online origins
- Middleware treats 'direct' as reserved (not a shared space)
- Removes chunked upload complexity (no longer needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-10 18:02:14 +00:00
parent 6eabd8eaa2
commit 8220ad5d3b
6 changed files with 154 additions and 135 deletions

View File

@ -36,7 +36,7 @@ class HostBasedURLConfMiddleware:
match = self.RFILES_SUBDOMAIN_PATTERN.match(host)
if match:
subdomain = match.group(1)
if subdomain not in ('www',):
if subdomain not in ('www', 'direct'):
request.shared_space_slug = subdomain
request.urlconf = 'config.urls_shared_space'
set_urlconf('config.urls_shared_space')

View File

@ -122,6 +122,9 @@ REST_FRAMEWORK = {
CORS_ALLOWED_ORIGINS = [
h.strip() for h in os.environ.get('CORS_ALLOWED_ORIGINS', 'http://localhost:3000,http://localhost:8000').split(',') if h.strip()
]
CORS_ALLOWED_ORIGIN_REGEXES = [
r'^https://.*\.rfiles\.online$',
]
CORS_ALLOW_ALL_ORIGINS = DEBUG
# Celery

View File

@ -11,6 +11,7 @@ from django.db import connection
from files.urls import api_urlpatterns as files_api_urls, public_urlpatterns as files_public_urls
from portal.views import ServiceWorkerView, ManifestView
from portal.views_shared_space import DirectUploadAPIView
def health_check(request):
@ -38,6 +39,7 @@ urlpatterns = [
path("manifest.json", ManifestView.as_view(), name="manifest"),
path("api/health/", health_check, name="health_check"),
path("api/upload/", DirectUploadAPIView.as_view(), name="direct_upload"),
path("admin/", admin.site.urls),
path("api/v1/", include(files_api_urls)),
path("s/", include(files_public_urls)),

View File

@ -47,7 +47,7 @@ services:
- CELERY_RESULT_BACKEND=redis://:${REDIS_PASSWORD}@redis:6379/0
- DJANGO_SETTINGS_MODULE=config.settings
- DEBUG=False
- ALLOWED_HOSTS=rfiles.online,www.rfiles.online,.rfiles.online,localhost
- ALLOWED_HOSTS=rfiles.online,www.rfiles.online,.rfiles.online,direct.rfiles.online,localhost
- SHARE_BASE_URL=https://rfiles.online
- SECRET_KEY=${SECRET_KEY}
depends_on:
@ -57,8 +57,14 @@ services:
condition: service_healthy
labels:
- "traefik.enable=true"
# Main router (via Cloudflare tunnel → port 80)
- "traefik.http.routers.rfiles.rule=Host(`rfiles.online`) || Host(`www.rfiles.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rfiles.online`)"
- "traefik.http.routers.rfiles.entrypoints=web"
# Direct upload router (bypasses Cloudflare, TLS via Let's Encrypt)
- "traefik.http.routers.rfiles-direct.rule=Host(`direct.rfiles.online`)"
- "traefik.http.routers.rfiles-direct.entrypoints=websecure"
- "traefik.http.routers.rfiles-direct.tls=true"
- "traefik.http.routers.rfiles-direct.tls.certresolver=letsencrypt"
- "traefik.http.services.rfiles.loadbalancer.server.port=8000"
- "traefik.docker.network=traefik-public"
networks:

View File

@ -248,8 +248,8 @@ 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
// All uploads go direct to server, bypassing Cloudflare's 100MB limit
const UPLOAD_URL = 'https://direct.rfiles.online/api/upload/';
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); });
@ -266,7 +266,15 @@ function formatBytes(bytes) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function makeItem(file, action) {
function uploadFile(file, action) {
if (maxSize > 0 && file.size > maxSize) {
const item = document.createElement('div');
item.className = 'result-item error';
item.innerHTML = `<div class="result-info"><h3>${file.name}</h3><div class="meta">File too large (${formatBytes(file.size)}). Max: {{ space.max_file_size_mb }}MB</div></div>`;
results.insertBefore(item, results.firstChild);
return;
}
let item = action ? document.getElementById('dup-' + file.name) : null;
if (!item) {
item = document.createElement('div');
@ -282,71 +290,10 @@ function makeItem(file, action) {
<div class="progress-bar"><div class="progress" style="width: 0%"></div></div>
</div>
`;
return item;
}
function showSuccess(item, data) {
item.classList.add('success');
item.innerHTML = `
<div class="result-info">
<h3>${data.file.title}</h3>
<div class="meta">${formatBytes(data.file.size)} - Uploaded successfully</div>
</div>
<div class="share-link">
<input type="text" value="${data.share.url}" readonly onclick="this.select()">
<button class="copy-btn" onclick="copyLink(this, '${data.share.url}')">Copy</button>
</div>
`;
}
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 = `
<div class="result-info">
<h3>${file.name}</h3>
<div class="meta">"${data.existing_file.title}" already exists (${existingSize})</div>
</div>
<div class="duplicate-actions">
<button class="btn-overwrite">Overwrite</button>
<button class="btn-skip">Skip</button>
</div>
`;
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 = `<div class="result-info"><h3>${file.name}</h3><div class="meta">Skipped (duplicate)</div></div>`;
});
}
function showError(item, msg) {
item.classList.add('error');
item.innerHTML = `<div class="result-info"><h3>${item.querySelector('h3')?.textContent || 'File'}</h3><div class="meta">${msg}</div></div>`;
}
function uploadFile(file, action) {
if (maxSize > 0 && file.size > maxSize) {
const item = document.createElement('div');
item.className = 'result-item error';
item.innerHTML = `<div class="result-info"><h3>${file.name}</h3><div class="meta">File too large (${formatBytes(file.size)}). Max: {{ space.max_file_size_mb }}MB</div></div>`;
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);
formData.append('space', '{{ space.slug }}');
if (action) formData.append('action', action);
const xhr = new XMLHttpRequest();
@ -355,83 +302,59 @@ function uploadFileSimple(file, action) {
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
showSuccess(item, JSON.parse(xhr.responseText));
const data = JSON.parse(xhr.responseText);
item.classList.add('success');
item.innerHTML = `
<div class="result-info">
<h3>${data.file.title}</h3>
<div class="meta">${formatBytes(data.file.size)} - Uploaded successfully</div>
</div>
<div class="share-link">
<input type="text" value="${data.share.url}" readonly onclick="this.select()">
<button class="copy-btn" onclick="copyLink(this, '${data.share.url}')">Copy</button>
</div>
`;
} else if (xhr.status === 409) {
showDuplicate(item, file, JSON.parse(xhr.responseText));
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 = `
<div class="result-info">
<h3>${file.name}</h3>
<div class="meta">"${data.existing_file.title}" already exists (${existingSize})</div>
</div>
<div class="duplicate-actions">
<button class="btn-overwrite">Overwrite</button>
<button class="btn-skip">Skip</button>
</div>
`;
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 = `<div class="result-info"><h3>${file.name}</h3><div class="meta">Skipped (duplicate)</div></div>`;
});
} else {
let msg = 'Upload failed';
try { msg = JSON.parse(xhr.responseText).error || msg; } catch(e) {}
showError(item, msg);
item.classList.add('error');
item.querySelector('.meta').textContent = msg;
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.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.open('POST', UPLOAD_URL);
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 orig = btn.textContent;

View File

@ -139,6 +139,91 @@ class SharedSpaceUploadAPIView(View):
})
@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."""