rfiles-online/portal/static/portal/sw.js

245 lines
8.0 KiB
JavaScript

// rfiles.online Service Worker
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 = [
'/',
'/static/portal/manifest.json',
'/static/portal/icon-192.png',
'/static/portal/icon-512.png',
];
// Install event - cache assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS_TO_CACHE))
);
self.skipWaiting();
});
// Activate event - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
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) => {
if (event.request.url.includes('/share-target/') || event.request.url.includes('/api/')) {
return;
}
event.respondWith(
caches.match(event.request).then((r) => r || fetch(event.request))
);
});
// Handle share target
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.pathname === '/share-target/' && event.request.method === 'POST') {
event.respondWith(handleShareTarget(event.request));
}
});
async function handleShareTarget(request) {
const formData = await request.formData();
const title = formData.get('title') || '';
const text = formData.get('text') || '';
const url = formData.get('url') || '';
const files = formData.getAll('files');
if (files.length > 0) {
try {
const uploadPromises = files.map(async (file) => {
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();
});
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) {
const offlineUrl = new URL('/', self.location.origin);
offlineUrl.searchParams.set('queued', 'true');
return Response.redirect(offlineUrl.toString(), 303);
}
}
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);
}
// --- IndexedDB helpers ---
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 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());
}
});
async function processOfflineQueue() {
const db = await openDB();
const tx = db.transaction('offline-queue', 'readonly');
const req = tx.objectStore('offline-queue').getAll();
const items = await new Promise((resolve) => {
req.onsuccess = () => resolve(req.result);
});
for (const item of items) {
try {
const deleteTx = db.transaction('offline-queue', 'readwrite');
deleteTx.objectStore('offline-queue').delete(item.queuedAt);
} catch (error) {
console.error('Failed to process queued item:', error);
}
}
}