Files
Q-Assets/public/sw-video-decrypt.js
crowetic 12523bc3fa Added Multi-Name selection and publishing globally. Also added specific name selection in Comments, Q-Deck, and announcement/notification publishing.
Added new streaming encrypted video playback backend, but have not wired it up yet to DataExplorer double-click previews.
Tweaked pLimit fetching to potentially improve overall load times in various locations.
Re-Built portfolio wallet section and added fully featured wallet functionality, and accurate transaction details, re-worked viusal structure of wallets, should now be close to final version.
Ensured the portfolios that are published by an account are fetched upon access, so that they can be loaded into display easily.
Added multi-name selector for global publishing in the header
Many additional tweaks and improvements - See Q-Deck for Q-Assets and Q-Deck Project Management for more completed tasks.
2025-12-22 10:54:54 -08:00

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());
});