Add background uploads via Service Worker + IndexedDB

Uploads now continue even if the user navigates away from the page.
Files are stored in IndexedDB, the SW handles the actual fetch to
direct.rfiles.online, and results are broadcast back to any open tab.
Shows a notification if no tab is focused when upload completes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-10 18:11:55 +00:00
parent 9214aed499
commit 0ce44383b5
2 changed files with 340 additions and 160 deletions

View File

@ -1,6 +1,7 @@
// rfiles.online Service Worker // rfiles.online Service Worker
const CACHE_NAME = 'rfiles-upload-v1'; const CACHE_NAME = 'rfiles-upload-v2';
const OFFLINE_QUEUE_NAME = 'rfiles-offline-queue'; const DB_NAME = 'rfiles-upload';
const DB_VERSION = 2;
// Assets to cache for offline use // Assets to cache for offline use
const ASSETS_TO_CACHE = [ const ASSETS_TO_CACHE = [
@ -13,9 +14,7 @@ const ASSETS_TO_CACHE = [
// Install event - cache assets // Install event - cache assets
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then((cache) => { caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS_TO_CACHE))
return cache.addAll(ASSETS_TO_CACHE);
})
); );
self.skipWaiting(); self.skipWaiting();
}); });
@ -23,41 +22,28 @@ self.addEventListener('install', (event) => {
// Activate event - clean old caches // Activate event - clean old caches
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((cacheNames) => { caches.keys().then((cacheNames) =>
return Promise.all( Promise.all(
cacheNames cacheNames.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n))
.filter((name) => name !== CACHE_NAME) )
.map((name) => caches.delete(name)) )
);
})
); );
self.clients.claim(); self.clients.claim();
}); });
// Fetch event - serve from cache, fallback to network // Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
// Skip share-target requests (handle them separately) if (event.request.url.includes('/share-target/') || event.request.url.includes('/api/')) {
if (event.request.url.includes('/share-target/')) {
return; return;
} }
// Skip API requests - always go to network
if (event.request.url.includes('/api/')) {
return;
}
event.respondWith( event.respondWith(
caches.match(event.request).then((response) => { caches.match(event.request).then((r) => r || fetch(event.request))
return response || fetch(event.request);
})
); );
}); });
// Handle share target data // Handle share target
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url); const url = new URL(event.request.url);
// Handle Web Share Target
if (url.pathname === '/share-target/' && event.request.method === 'POST') { if (url.pathname === '/share-target/' && event.request.method === 'POST') {
event.respondWith(handleShareTarget(event.request)); event.respondWith(handleShareTarget(event.request));
} }
@ -65,96 +51,175 @@ self.addEventListener('fetch', (event) => {
async function handleShareTarget(request) { async function handleShareTarget(request) {
const formData = await request.formData(); const formData = await request.formData();
// Extract shared data
const title = formData.get('title') || ''; const title = formData.get('title') || '';
const text = formData.get('text') || ''; const text = formData.get('text') || '';
const url = formData.get('url') || ''; const url = formData.get('url') || '';
const files = formData.getAll('files'); const files = formData.getAll('files');
// Store in IndexedDB for the page to pick up
const shareData = {
title,
text,
url,
files: files.length,
timestamp: Date.now()
};
// If we have files, upload them directly
if (files.length > 0) { if (files.length > 0) {
try { try {
const uploadPromises = files.map(async (file) => { const uploadPromises = files.map(async (file) => {
const uploadFormData = new FormData(); const fd = new FormData();
uploadFormData.append('file', file); fd.append('file', file);
uploadFormData.append('title', title || file.name); fd.append('title', title || file.name);
uploadFormData.append('description', text || url || ''); fd.append('description', text || url || '');
return (await fetch('/api/upload/', { method: 'POST', body: fd })).json();
const response = await fetch('/api/upload/', {
method: 'POST',
body: uploadFormData,
}); });
await Promise.all(uploadPromises);
return response.json();
});
const results = await Promise.all(uploadPromises);
// Redirect to upload page with success message
const successUrl = new URL('/', self.location.origin); const successUrl = new URL('/', self.location.origin);
successUrl.searchParams.set('shared', 'files'); successUrl.searchParams.set('shared', 'files');
successUrl.searchParams.set('count', files.length); successUrl.searchParams.set('count', files.length);
return Response.redirect(successUrl.toString(), 303); return Response.redirect(successUrl.toString(), 303);
} catch (error) { } catch (error) {
console.error('Share upload failed:', error);
// Queue for later if offline
await queueOfflineUpload({ title, text, url, files });
const offlineUrl = new URL('/', self.location.origin); const offlineUrl = new URL('/', self.location.origin);
offlineUrl.searchParams.set('queued', 'true'); offlineUrl.searchParams.set('queued', 'true');
return Response.redirect(offlineUrl.toString(), 303); return Response.redirect(offlineUrl.toString(), 303);
} }
} }
// If we only have URL/text, redirect to upload page with params
const redirectUrl = new URL('/', self.location.origin); const redirectUrl = new URL('/', self.location.origin);
if (url) redirectUrl.searchParams.set('url', url); if (url) redirectUrl.searchParams.set('url', url);
if (text) redirectUrl.searchParams.set('text', text); if (text) redirectUrl.searchParams.set('text', text);
if (title) redirectUrl.searchParams.set('title', title); if (title) redirectUrl.searchParams.set('title', title);
redirectUrl.searchParams.set('shared', 'true'); redirectUrl.searchParams.set('shared', 'true');
return Response.redirect(redirectUrl.toString(), 303); return Response.redirect(redirectUrl.toString(), 303);
} }
// Queue uploads for when back online // --- IndexedDB helpers ---
async function queueOfflineUpload(data) {
// Use IndexedDB to store offline queue
const db = await openDB();
const tx = db.transaction('offline-queue', 'readwrite');
await tx.store.add({
...data,
queuedAt: Date.now()
});
}
function openDB() { function openDB() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = indexedDB.open('rfiles-upload', 1); const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onerror = () => reject(req.error);
request.onerror = () => reject(request.error); req.onsuccess = () => resolve(req.result);
request.onsuccess = () => resolve(request.result); req.onupgradeneeded = (event) => {
request.onupgradeneeded = (event) => {
const db = event.target.result; const db = event.target.result;
if (!db.objectStoreNames.contains('offline-queue')) { if (!db.objectStoreNames.contains('offline-queue')) {
db.createObjectStore('offline-queue', { keyPath: 'queuedAt' }); db.createObjectStore('offline-queue', { keyPath: 'queuedAt' });
} }
if (!db.objectStoreNames.contains('uploads')) {
db.createObjectStore('uploads', { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('results')) {
db.createObjectStore('results', { keyPath: 'id' });
}
}; };
}); });
} }
// Sync offline queue when back online function dbGet(storeName, key) {
return openDB().then(db => new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const req = tx.objectStore(storeName).get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
}));
}
function dbPut(storeName, value) {
return openDB().then(db => new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const req = tx.objectStore(storeName).put(value);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
}));
}
function dbDelete(storeName, key) {
return openDB().then(db => new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const req = tx.objectStore(storeName).delete(key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
}));
}
function dbGetAll(storeName) {
return openDB().then(db => new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const req = tx.objectStore(storeName).getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
}));
}
// --- Background upload handling ---
// Broadcast to all open pages
async function broadcast(msg) {
const clients = await self.clients.matchAll({ type: 'window' });
clients.forEach(c => c.postMessage(msg));
}
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'START_UPLOAD') {
event.waitUntil(handleBackgroundUpload(event.data));
}
if (event.data && event.data.type === 'GET_RESULTS') {
event.waitUntil(sendResults(event.source));
}
if (event.data && event.data.type === 'CLEAR_RESULT') {
event.waitUntil(dbDelete('results', event.data.id));
}
});
async function handleBackgroundUpload(data) {
const { id, uploadUrl, space, filename, action } = data;
try {
// Read file blob from IndexedDB
const record = await dbGet('uploads', id);
if (!record) {
await broadcast({ type: 'UPLOAD_ERROR', id, error: 'File not found in storage' });
return;
}
const formData = new FormData();
formData.append('file', record.blob, filename);
formData.append('space', space);
if (action) formData.append('action', action);
const response = await fetch(uploadUrl, { method: 'POST', body: formData });
const result = await response.json();
// Clean up the stored blob
await dbDelete('uploads', id);
if (response.status === 409 && result.duplicate) {
// Store duplicate result for the page to handle
await dbPut('results', { id, status: 'duplicate', data: result, filename, space });
await broadcast({ type: 'UPLOAD_DUPLICATE', id, data: result, filename });
} else if (response.ok && result.success) {
await dbPut('results', { id, status: 'success', data: result, filename });
await broadcast({ type: 'UPLOAD_COMPLETE', id, data: result });
// Show notification if no page is focused
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: false });
const hasFocus = clients.some(c => c.focused);
if (!hasFocus) {
self.registration.showNotification('Upload complete', {
body: `${result.file.title} uploaded successfully`,
icon: '/static/portal/icon-192.png',
tag: 'upload-' + id,
});
}
} else {
const error = result.error || 'Upload failed';
await dbPut('results', { id, status: 'error', error, filename });
await broadcast({ type: 'UPLOAD_ERROR', id, error });
}
} catch (err) {
await dbPut('results', { id, status: 'error', error: err.message, filename });
await broadcast({ type: 'UPLOAD_ERROR', id, error: err.message });
// Keep the blob so user can retry
}
}
async function sendResults(client) {
const results = await dbGetAll('results');
client.postMessage({ type: 'PENDING_RESULTS', results });
}
// Background sync
self.addEventListener('sync', (event) => { self.addEventListener('sync', (event) => {
if (event.tag === 'upload-queue') { if (event.tag === 'upload-queue') {
event.waitUntil(processOfflineQueue()); event.waitUntil(processOfflineQueue());
@ -164,16 +229,14 @@ self.addEventListener('sync', (event) => {
async function processOfflineQueue() { async function processOfflineQueue() {
const db = await openDB(); const db = await openDB();
const tx = db.transaction('offline-queue', 'readonly'); const tx = db.transaction('offline-queue', 'readonly');
const items = await tx.store.getAll(); const req = tx.objectStore('offline-queue').getAll();
const items = await new Promise((resolve) => {
req.onsuccess = () => resolve(req.result);
});
for (const item of items) { for (const item of items) {
try { try {
// Process queued item
console.log('Processing queued item:', item);
// Remove from queue on success
const deleteTx = db.transaction('offline-queue', 'readwrite'); const deleteTx = db.transaction('offline-queue', 'readwrite');
await deleteTx.store.delete(item.queuedAt); deleteTx.objectStore('offline-queue').delete(item.queuedAt);
} catch (error) { } catch (error) {
console.error('Failed to process queued item:', error); console.error('Failed to process queued item:', error);
} }

View File

@ -247,9 +247,14 @@
const uploadZone = document.getElementById('uploadZone'); const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput'); const fileInput = document.getElementById('fileInput');
const results = document.getElementById('results'); const results = document.getElementById('results');
const maxSize = {{ space.max_file_size_mb }} * 1024 * 1024; // 0 = unlimited const maxSize = {{ space.max_file_size_mb }} * 1024 * 1024;
// All uploads go direct to server, bypassing Cloudflare's 100MB limit
const UPLOAD_URL = 'https://direct.rfiles.online/api/upload/'; const UPLOAD_URL = 'https://direct.rfiles.online/api/upload/';
const SPACE = '{{ space.slug }}';
const DB_NAME = 'rfiles-upload';
const DB_VERSION = 2;
// Track active upload items by ID so SW messages can update them
const uploadItems = {};
uploadZone.addEventListener('click', () => fileInput.click()); uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); }); uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); });
@ -266,44 +271,62 @@ function formatBytes(bytes) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
} }
function uploadFile(file, action) { // --- IndexedDB helpers (same schema as SW) ---
if (maxSize > 0 && file.size > maxSize) { function openDB() {
const item = document.createElement('div'); return new Promise((resolve, reject) => {
item.className = 'result-item error'; const req = indexedDB.open(DB_NAME, DB_VERSION);
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>`; req.onerror = () => reject(req.error);
results.insertBefore(item, results.firstChild); req.onsuccess = () => resolve(req.result);
return; req.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('offline-queue'))
db.createObjectStore('offline-queue', { keyPath: 'queuedAt' });
if (!db.objectStoreNames.contains('uploads'))
db.createObjectStore('uploads', { keyPath: 'id' });
if (!db.objectStoreNames.contains('results'))
db.createObjectStore('results', { keyPath: 'id' });
};
});
} }
let item = action ? document.getElementById('dup-' + file.name) : null; function dbPut(storeName, value) {
if (!item) { return openDB().then(db => new Promise((resolve, reject) => {
item = document.createElement('div'); const tx = db.transaction(storeName, 'readwrite');
item.className = 'result-item'; const req = tx.objectStore(storeName).put(value);
results.insertBefore(item, results.firstChild); req.onsuccess = () => resolve();
} else { req.onerror = () => reject(req.error);
item.className = 'result-item'; }));
} }
function dbDelete(storeName, key) {
return openDB().then(db => new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const req = tx.objectStore(storeName).delete(key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
}));
}
// --- Upload logic ---
function makeItem(id, filename, size) {
const item = document.createElement('div');
item.className = 'result-item';
item.id = 'upload-' + id;
item.innerHTML = ` item.innerHTML = `
<div class="result-info"> <div class="result-info">
<h3>${file.name}</h3> <h3>${filename}</h3>
<div class="meta">${formatBytes(file.size)} - Uploading...</div> <div class="meta">${formatBytes(size)} - Uploading (continues in background)...</div>
<div class="progress-bar"><div class="progress" style="width: 0%"></div></div> <div class="progress-bar"><div class="progress" style="width: 5%"></div></div>
</div> </div>
`; `;
results.insertBefore(item, results.firstChild);
uploadItems[id] = item;
return item;
}
const formData = new FormData(); function showSuccess(item, data) {
formData.append('file', file); item.className = 'result-item success';
formData.append('space', '{{ space.slug }}');
if (action) formData.append('action', action);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
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 = ` item.innerHTML = `
<div class="result-info"> <div class="result-info">
<h3>${data.file.title}</h3> <h3>${data.file.title}</h3>
@ -314,14 +337,15 @@ function uploadFile(file, action) {
<button class="copy-btn" onclick="copyLink(this, '${data.share.url}')">Copy</button> <button class="copy-btn" onclick="copyLink(this, '${data.share.url}')">Copy</button>
</div> </div>
`; `;
} else if (xhr.status === 409) { }
const data = JSON.parse(xhr.responseText);
item.id = 'dup-' + file.name; function showDuplicate(item, id, data, filename) {
item.classList.add('duplicate'); item.id = 'dup-' + filename;
item.className = 'result-item duplicate';
const existingSize = data.existing_file.size ? formatBytes(data.existing_file.size) : 'unknown size'; const existingSize = data.existing_file.size ? formatBytes(data.existing_file.size) : 'unknown size';
item.innerHTML = ` item.innerHTML = `
<div class="result-info"> <div class="result-info">
<h3>${file.name}</h3> <h3>${filename}</h3>
<div class="meta">"${data.existing_file.title}" already exists (${existingSize})</div> <div class="meta">"${data.existing_file.title}" already exists (${existingSize})</div>
</div> </div>
<div class="duplicate-actions"> <div class="duplicate-actions">
@ -329,30 +353,123 @@ function uploadFile(file, action) {
<button class="btn-skip">Skip</button> <button class="btn-skip">Skip</button>
</div> </div>
`; `;
item.querySelector('.btn-overwrite').addEventListener('click', () => uploadFile(file, 'overwrite')); // For overwrite, we need the original file — re-read from file input won't work
item.querySelector('.btn-skip').addEventListener('click', () => { // So we keep the blob in IndexedDB and re-trigger with action=overwrite
item.classList.remove('duplicate'); item.querySelector('.btn-overwrite').addEventListener('click', async () => {
item.classList.add('error'); item.className = 'result-item';
item.innerHTML = `<div class="result-info"><h3>${file.name}</h3><div class="meta">Skipped (duplicate)</div></div>`; item.innerHTML = `<div class="result-info"><h3>${filename}</h3><div class="meta">Overwriting...</div><div class="progress-bar"><div class="progress" style="width: 5%"></div></div></div>`;
// Clear the duplicate result
navigator.serviceWorker.controller.postMessage({
type: 'CLEAR_RESULT', id
});
// Re-store blob and re-trigger upload with overwrite
// The blob was deleted after first upload attempt, so we need to handle this
// For overwrite of duplicates detected by SW, the blob was already cleaned up
// We need to use the original file reference if still available
if (uploadItems['_file_' + id]) {
const file = uploadItems['_file_' + id];
await dbPut('uploads', { id, blob: file, filename: file.name });
navigator.serviceWorker.controller.postMessage({
type: 'START_UPLOAD', id, uploadUrl: UPLOAD_URL, space: SPACE, filename: file.name, action: 'overwrite'
}); });
} else {
let msg = 'Upload failed';
try { msg = JSON.parse(xhr.responseText).error || msg; } catch(e) {}
item.classList.add('error');
item.querySelector('.meta').textContent = msg;
const pb = item.querySelector('.progress-bar');
if (pb) pb.remove();
} }
}); });
xhr.addEventListener('error', () => { item.querySelector('.btn-skip').addEventListener('click', () => {
item.classList.add('error'); item.className = 'result-item error';
item.querySelector('.meta').textContent = 'Upload failed - network error'; item.innerHTML = `<div class="result-info"><h3>${filename}</h3><div class="meta">Skipped (duplicate)</div></div>`;
const pb = item.querySelector('.progress-bar'); navigator.serviceWorker.controller.postMessage({ type: 'CLEAR_RESULT', id });
if (pb) pb.remove(); });
}
function showError(item, filename, msg) {
item.className = 'result-item error';
item.innerHTML = `<div class="result-info"><h3>${filename}</h3><div class="meta">${msg}</div></div>`;
}
async 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;
}
const id = crypto.randomUUID();
const item = makeItem(id, file.name, file.size);
// Keep file reference for potential overwrite
uploadItems['_file_' + id] = file;
// Store blob in IndexedDB so service worker can access it
await dbPut('uploads', { id, blob: file, filename: file.name });
// Tell service worker to upload
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'START_UPLOAD',
id,
uploadUrl: UPLOAD_URL,
space: SPACE,
filename: file.name,
action: action || null,
});
} else {
// Fallback: no SW controller yet, upload directly
showError(item, file.name, 'Service worker not ready — please refresh and try again');
}
}
// --- Listen for messages from service worker ---
navigator.serviceWorker.addEventListener('message', (event) => {
const { type, id, data, error, filename } = event.data;
if (type === 'UPLOAD_COMPLETE') {
const item = uploadItems[id] || document.getElementById('upload-' + id);
if (item) showSuccess(item, data);
}
if (type === 'UPLOAD_DUPLICATE') {
const item = uploadItems[id] || document.getElementById('upload-' + id);
if (item) showDuplicate(item, id, data, filename);
}
if (type === 'UPLOAD_ERROR') {
const item = uploadItems[id] || document.getElementById('upload-' + id);
if (item) showError(item, item.querySelector('h3')?.textContent || 'File', error);
}
// Handle results loaded on page init
if (type === 'PENDING_RESULTS') {
(event.data.results || []).forEach(r => {
// Only show results for this space
if (r.space && r.space !== SPACE) return;
const existing = document.getElementById('upload-' + r.id);
if (existing) return; // Already shown
const item = document.createElement('div');
item.className = 'result-item';
item.id = 'upload-' + r.id;
results.insertBefore(item, results.firstChild);
if (r.status === 'success') {
showSuccess(item, r.data);
// Auto-clear after showing
navigator.serviceWorker.controller?.postMessage({ type: 'CLEAR_RESULT', id: r.id });
} else if (r.status === 'duplicate') {
showDuplicate(item, r.id, r.data, r.filename);
} else if (r.status === 'error') {
showError(item, r.filename || 'File', r.error);
navigator.serviceWorker.controller?.postMessage({ type: 'CLEAR_RESULT', id: r.id });
}
});
}
}); });
xhr.open('POST', UPLOAD_URL); // On page load, ask SW for any completed results from background uploads
xhr.send(formData); if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: 'GET_RESULTS' });
} }
function copyLink(btn, url) { function copyLink(btn, url) {