mirror of
https://github.com/Qortal/qapp-core.git
synced 2026-04-28 19:39:21 +00:00
188 lines
5.6 KiB
JavaScript
188 lines
5.6 KiB
JavaScript
// Service Worker for decrypting encrypted videos on-the-fly
|
|
// This allows streaming large encrypted videos without loading everything into memory
|
|
|
|
// Cache for storing encryption configurations
|
|
const ENCRYPTION_CACHE = new Map();
|
|
|
|
// Listen for messages from the main thread
|
|
self.addEventListener('message', (event) => {
|
|
if (event.data.type === 'SET_ENCRYPTION') {
|
|
const videoId = event.data.videoId;
|
|
const config = {
|
|
key: new Uint8Array(event.data.key),
|
|
iv: new Uint8Array(event.data.iv),
|
|
resourceUrl: event.data.resourceUrl,
|
|
totalSize: event.data.totalSize,
|
|
mimeType: event.data.mimeType || 'video/mp4',
|
|
};
|
|
|
|
ENCRYPTION_CACHE.set(videoId, config);
|
|
|
|
// Respond back to confirm
|
|
event.ports[0].postMessage({ success: true });
|
|
} else if (event.data.type === 'REMOVE_ENCRYPTION') {
|
|
const videoId = event.data.videoId;
|
|
ENCRYPTION_CACHE.delete(videoId);
|
|
event.ports[0].postMessage({ success: true });
|
|
}
|
|
});
|
|
|
|
// Intercept fetch requests
|
|
self.addEventListener('fetch', (event) => {
|
|
const url = new URL(event.request.url);
|
|
|
|
// Check if this is a request for an encrypted video
|
|
if (url.pathname.startsWith('/decrypt-video/')) {
|
|
const videoId = url.pathname.split('/')[2];
|
|
event.respondWith(handleEncryptedVideo(event.request, videoId));
|
|
}
|
|
});
|
|
|
|
// Handle encrypted video requests
|
|
async function handleEncryptedVideo(request, videoId) {
|
|
try {
|
|
const config = ENCRYPTION_CACHE.get(videoId);
|
|
|
|
if (!config) {
|
|
console.error('[SW] Video config not found for:', videoId);
|
|
|
|
return new Response('Video configuration not found', { status: 404 });
|
|
}
|
|
|
|
const rangeHeader = request.headers.get('range');
|
|
|
|
// Handle HEAD request or requests without range
|
|
if (!rangeHeader) {
|
|
return new Response(null, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Length': String(config.totalSize),
|
|
'Content-Type': config.mimeType || 'video/mp4',
|
|
'Accept-Ranges': 'bytes',
|
|
'Access-Control-Allow-Origin': '*',
|
|
},
|
|
});
|
|
}
|
|
|
|
// Parse range header: "bytes=start-end"
|
|
const rangeMatch = rangeHeader.match(/bytes=(\d+)-(\d*)/);
|
|
if (!rangeMatch) {
|
|
return new Response('Invalid range header', { status: 416 });
|
|
}
|
|
|
|
const start = parseInt(rangeMatch[1], 10);
|
|
const end = rangeMatch[2]
|
|
? parseInt(rangeMatch[2], 10)
|
|
: Math.min(start + 5 * 1024 * 1024 - 1, config.totalSize - 1); // 5MB chunks
|
|
|
|
// Fetch encrypted data from the actual resource
|
|
const encryptedResponse = await fetch(config.resourceUrl, {
|
|
headers: {
|
|
Range: `bytes=${start}-${end}`,
|
|
},
|
|
});
|
|
|
|
if (!encryptedResponse.ok && encryptedResponse.status !== 206) {
|
|
console.error(
|
|
'[SW] Failed to fetch encrypted data:',
|
|
encryptedResponse.status
|
|
);
|
|
return new Response('Failed to fetch encrypted data', {
|
|
status: encryptedResponse.status,
|
|
});
|
|
}
|
|
|
|
const encrypted = new Uint8Array(await encryptedResponse.arrayBuffer());
|
|
|
|
// Decrypt the chunk
|
|
const blockOffset = BigInt(start >> 4);
|
|
const decrypted = await decryptAesCtrChunk(
|
|
config.key,
|
|
config.iv,
|
|
blockOffset,
|
|
encrypted
|
|
);
|
|
|
|
// Calculate actual end based on what we got
|
|
const actualEnd = start + decrypted.length - 1;
|
|
|
|
// Return decrypted data as partial content
|
|
return new Response(decrypted, {
|
|
status: 206,
|
|
headers: {
|
|
'Content-Range': `bytes ${start}-${actualEnd}/${config.totalSize}`,
|
|
'Content-Length': String(decrypted.length),
|
|
'Content-Type': config.mimeType || 'video/mp4',
|
|
'Accept-Ranges': 'bytes',
|
|
'Access-Control-Allow-Origin': '*',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('[SW] Error handling encrypted video:', error);
|
|
return new Response(`Error: ${error.message}`, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// AES-CTR decryption utilities
|
|
function deriveCtrCounter(iv, blockOffset) {
|
|
const counter = new Uint8Array(16);
|
|
const ivArray = new Uint8Array(iv);
|
|
counter.set(ivArray);
|
|
let carry = blockOffset;
|
|
|
|
for (let i = 15; i >= 0 && carry > 0n; i--) {
|
|
const sum = BigInt(counter[i]) + (carry & 0xffn);
|
|
counter[i] = Number(sum & 0xffn);
|
|
carry = (carry >> 8n) + (sum >> 8n);
|
|
}
|
|
return counter;
|
|
}
|
|
|
|
async function decryptAesCtrChunk(keyBytes, ivBytes, blockOffset, ciphertext) {
|
|
// Try WebCrypto API first (faster)
|
|
if (self.crypto?.subtle) {
|
|
try {
|
|
const keyBuffer = new Uint8Array(keyBytes).buffer;
|
|
const cryptoKey = await self.crypto.subtle.importKey(
|
|
'raw',
|
|
keyBuffer,
|
|
{ name: 'AES-CTR' },
|
|
false,
|
|
['decrypt']
|
|
);
|
|
|
|
const counter = deriveCtrCounter(ivBytes, blockOffset);
|
|
const counterArray = new Uint8Array(counter);
|
|
const ciphertextBuffer = new Uint8Array(ciphertext).buffer;
|
|
|
|
const decrypted = await self.crypto.subtle.decrypt(
|
|
{
|
|
name: 'AES-CTR',
|
|
counter: counterArray,
|
|
length: 128,
|
|
},
|
|
cryptoKey,
|
|
ciphertextBuffer
|
|
);
|
|
|
|
return new Uint8Array(decrypted);
|
|
} catch (e) {
|
|
console.warn('[SW] WebCrypto decrypt failed, using fallback:', e);
|
|
}
|
|
}
|
|
|
|
// Fallback to aes-js (if available)
|
|
// Note: In production, you might want to import aes-js into the service worker
|
|
// For now, we'll rely on WebCrypto which should work in all modern browsers
|
|
throw new Error('WebCrypto not available and no fallback configured');
|
|
}
|
|
|
|
// Service Worker installation
|
|
self.addEventListener('install', (event) => {
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(self.clients.claim());
|
|
});
|