diff --git a/portal/static/portal/sw.js b/portal/static/portal/sw.js index c497fcf..3c8840f 100644 --- a/portal/static/portal/sw.js +++ b/portal/static/portal/sw.js @@ -1,6 +1,7 @@ // rfiles.online Service Worker -const CACHE_NAME = 'rfiles-upload-v1'; -const OFFLINE_QUEUE_NAME = 'rfiles-offline-queue'; +const CACHE_NAME = 'rfiles-upload-v2'; +const DB_NAME = 'rfiles-upload'; +const DB_VERSION = 2; // Assets to cache for offline use const ASSETS_TO_CACHE = [ @@ -13,9 +14,7 @@ const ASSETS_TO_CACHE = [ // Install event - cache assets self.addEventListener('install', (event) => { event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - return cache.addAll(ASSETS_TO_CACHE); - }) + caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS_TO_CACHE)) ); self.skipWaiting(); }); @@ -23,41 +22,28 @@ self.addEventListener('install', (event) => { // Activate event - clean old caches self.addEventListener('activate', (event) => { event.waitUntil( - caches.keys().then((cacheNames) => { - return Promise.all( - cacheNames - .filter((name) => name !== CACHE_NAME) - .map((name) => caches.delete(name)) - ); - }) + caches.keys().then((cacheNames) => + Promise.all( + cacheNames.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n)) + ) + ) ); self.clients.claim(); }); // Fetch event - serve from cache, fallback to network self.addEventListener('fetch', (event) => { - // Skip share-target requests (handle them separately) - if (event.request.url.includes('/share-target/')) { + if (event.request.url.includes('/share-target/') || event.request.url.includes('/api/')) { return; } - - // Skip API requests - always go to network - if (event.request.url.includes('/api/')) { - return; - } - event.respondWith( - caches.match(event.request).then((response) => { - return response || fetch(event.request); - }) + caches.match(event.request).then((r) => r || fetch(event.request)) ); }); -// Handle share target data +// Handle share target self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); - - // Handle Web Share Target if (url.pathname === '/share-target/' && event.request.method === 'POST') { event.respondWith(handleShareTarget(event.request)); } @@ -65,96 +51,175 @@ self.addEventListener('fetch', (event) => { async function handleShareTarget(request) { const formData = await request.formData(); - - // Extract shared data const title = formData.get('title') || ''; const text = formData.get('text') || ''; const url = formData.get('url') || ''; 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) { try { const uploadPromises = files.map(async (file) => { - const uploadFormData = new FormData(); - uploadFormData.append('file', file); - uploadFormData.append('title', title || file.name); - uploadFormData.append('description', text || url || ''); - - const response = await fetch('/api/upload/', { - method: 'POST', - body: uploadFormData, - }); - - return response.json(); + const fd = new FormData(); + fd.append('file', file); + fd.append('title', title || file.name); + fd.append('description', text || url || ''); + return (await fetch('/api/upload/', { method: 'POST', body: fd })).json(); }); - - const results = await Promise.all(uploadPromises); - - // Redirect to upload page with success message + await Promise.all(uploadPromises); const successUrl = new URL('/', self.location.origin); successUrl.searchParams.set('shared', 'files'); successUrl.searchParams.set('count', files.length); - return Response.redirect(successUrl.toString(), 303); } 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); offlineUrl.searchParams.set('queued', 'true'); 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); if (url) redirectUrl.searchParams.set('url', url); if (text) redirectUrl.searchParams.set('text', text); if (title) redirectUrl.searchParams.set('title', title); redirectUrl.searchParams.set('shared', 'true'); - return Response.redirect(redirectUrl.toString(), 303); } -// Queue uploads for when back online -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() - }); -} +// --- IndexedDB helpers --- function openDB() { return new Promise((resolve, reject) => { - const request = indexedDB.open('rfiles-upload', 1); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - - request.onupgradeneeded = (event) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result); + 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' }); + } }; }); } -// 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) => { if (event.tag === 'upload-queue') { event.waitUntil(processOfflineQueue()); @@ -164,16 +229,14 @@ self.addEventListener('sync', (event) => { async function processOfflineQueue() { const db = await openDB(); 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) { try { - // Process queued item - console.log('Processing queued item:', item); - - // Remove from queue on success const deleteTx = db.transaction('offline-queue', 'readwrite'); - await deleteTx.store.delete(item.queuedAt); + deleteTx.objectStore('offline-queue').delete(item.queuedAt); } catch (error) { console.error('Failed to process queued item:', error); } diff --git a/portal/templates/portal/shared_space/home.html b/portal/templates/portal/shared_space/home.html index 5254042..fff59bb 100644 --- a/portal/templates/portal/shared_space/home.html +++ b/portal/templates/portal/shared_space/home.html @@ -247,9 +247,14 @@ 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 -// All uploads go direct to server, bypassing Cloudflare's 100MB limit +const maxSize = {{ space.max_file_size_mb }} * 1024 * 1024; 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('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); }); @@ -266,7 +271,122 @@ function formatBytes(bytes) { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } -function uploadFile(file, action) { +// --- IndexedDB helpers (same schema as SW) --- +function openDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result); + 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' }); + }; + }); +} + +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); + })); +} + +// --- Upload logic --- + +function makeItem(id, filename, size) { + const item = document.createElement('div'); + item.className = 'result-item'; + item.id = 'upload-' + id; + item.innerHTML = ` +