1290 lines
37 KiB
JavaScript
1290 lines
37 KiB
JavaScript
/**
|
|
* Enhanced Cloudflare Worker for serving videos from R2 storage
|
|
* Features:
|
|
* - Admin panel with authentication
|
|
* - Video visibility controls (private, shareable, clip_shareable)
|
|
* - Clip generation and serving
|
|
* - Permission-based access control
|
|
*/
|
|
|
|
// Admin login HTML
|
|
const LOGIN_HTML = `
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Admin Login</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #0f0f0f;
|
|
color: #fff;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
}
|
|
.login-container {
|
|
background: #1a1a1a;
|
|
padding: 40px;
|
|
border-radius: 12px;
|
|
max-width: 400px;
|
|
width: 100%;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
|
}
|
|
h1 {
|
|
text-align: center;
|
|
margin-bottom: 30px;
|
|
font-size: 2rem;
|
|
}
|
|
.form-group {
|
|
margin-bottom: 20px;
|
|
}
|
|
label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
color: #aaa;
|
|
font-size: 0.9rem;
|
|
}
|
|
input {
|
|
width: 100%;
|
|
padding: 12px;
|
|
background: #2a2a2a;
|
|
border: 1px solid #3a3a3a;
|
|
border-radius: 6px;
|
|
color: white;
|
|
font-size: 1rem;
|
|
}
|
|
input:focus {
|
|
outline: none;
|
|
border-color: #3ea6ff;
|
|
}
|
|
button {
|
|
width: 100%;
|
|
padding: 12px;
|
|
background: #3ea6ff;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: white;
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
button:hover {
|
|
background: #2988d8;
|
|
}
|
|
.error {
|
|
background: #e74c3c;
|
|
color: white;
|
|
padding: 12px;
|
|
border-radius: 6px;
|
|
margin-bottom: 20px;
|
|
display: none;
|
|
}
|
|
.error.show {
|
|
display: block;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="login-container">
|
|
<h1>🎥 Admin Login</h1>
|
|
<div id="error" class="error"></div>
|
|
<form id="loginForm">
|
|
<div class="form-group">
|
|
<label for="password">Password</label>
|
|
<input type="password" id="password" name="password" required autofocus>
|
|
</div>
|
|
<button type="submit">Login</button>
|
|
</form>
|
|
</div>
|
|
<script>
|
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const password = document.getElementById('password').value;
|
|
const error = document.getElementById('error');
|
|
|
|
try {
|
|
const response = await fetch('/admin/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password })
|
|
});
|
|
|
|
if (response.ok) {
|
|
window.location.href = '/admin';
|
|
} else {
|
|
error.textContent = 'Invalid password';
|
|
error.classList.add('show');
|
|
}
|
|
} catch (err) {
|
|
error.textContent = 'Login failed';
|
|
error.classList.add('show');
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
export default {
|
|
async fetch(request, env) {
|
|
const url = new URL(request.url);
|
|
const path = url.pathname;
|
|
|
|
// CORS headers
|
|
const corsHeaders = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, HEAD, POST, DELETE, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Range, Content-Type, Accept',
|
|
'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges',
|
|
};
|
|
|
|
if (request.method === 'OPTIONS') {
|
|
return new Response(null, { headers: corsHeaders });
|
|
}
|
|
|
|
try {
|
|
// Admin routes
|
|
if (path.startsWith('/admin')) {
|
|
return await handleAdminRoutes(request, env, path, corsHeaders);
|
|
}
|
|
|
|
// Clip serving
|
|
if (path.startsWith('/clip/')) {
|
|
return await handleClip(request, env, path, corsHeaders);
|
|
}
|
|
|
|
// Root path and gallery (show HTML gallery of shareable videos)
|
|
if (path === '/' || path === '/gallery') {
|
|
return await handlePublicGallery(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders);
|
|
}
|
|
|
|
// Video player page (/watch/filename)
|
|
if (path.startsWith('/watch/')) {
|
|
return await handleWatchPage(request, env, path, corsHeaders);
|
|
}
|
|
|
|
// Public API (only lists shareable videos as JSON)
|
|
if (path === '/api/list') {
|
|
return await handlePublicList(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders);
|
|
}
|
|
|
|
// Handle HLS live streams (/live/stream-name/file.m3u8 or file.ts)
|
|
if (path.startsWith('/live/')) {
|
|
return await handleHLSStream(request, env, path, corsHeaders);
|
|
}
|
|
|
|
// Serve video file (with permission check)
|
|
const filename = path.substring(1);
|
|
if (filename) {
|
|
return await handleVideoFile(request, env, filename, corsHeaders);
|
|
}
|
|
|
|
return new Response('Not found', { status: 404, headers: corsHeaders });
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
return new Response(`Error: ${error.message}`, { status: 500, headers: corsHeaders });
|
|
}
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Handle admin routes
|
|
*/
|
|
async function handleAdminRoutes(request, env, path, corsHeaders) {
|
|
// Login page
|
|
if (path === '/admin/login' && request.method === 'GET') {
|
|
return new Response(LOGIN_HTML, {
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
|
});
|
|
}
|
|
|
|
// Login POST
|
|
if (path === '/admin/login' && request.method === 'POST') {
|
|
const { password } = await request.json();
|
|
const correctPassword = env.ADMIN_PASSWORD || 'changeme';
|
|
|
|
if (password === correctPassword) {
|
|
// Create session token
|
|
const token = await generateToken(password);
|
|
|
|
return new Response(JSON.stringify({ success: true }), {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Set-Cookie': `admin_auth=${token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`
|
|
}
|
|
});
|
|
}
|
|
|
|
return new Response(JSON.stringify({ error: 'Invalid password' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
}
|
|
|
|
// Check authentication for other admin routes
|
|
const isAuthenticated = await verifyAuth(request, env);
|
|
if (!isAuthenticated) {
|
|
return new Response('Unauthorized', {
|
|
status: 401,
|
|
headers: { 'Location': '/admin/login' }
|
|
});
|
|
}
|
|
|
|
// Admin panel
|
|
if (path === '/admin' || path === '/admin/') {
|
|
const adminHTML = await getAdminHTML();
|
|
return new Response(adminHTML, {
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
|
});
|
|
}
|
|
|
|
// API: List all videos with metadata
|
|
if (path === '/admin/api/videos') {
|
|
return await handleAdminListVideos(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders);
|
|
}
|
|
|
|
// API: Update video visibility
|
|
if (path === '/admin/api/videos/visibility' && request.method === 'POST') {
|
|
const { filename, visibility } = await request.json();
|
|
await env.VIDEO_METADATA.put(filename, JSON.stringify({ visibility }));
|
|
|
|
return new Response(JSON.stringify({ success: true }), {
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders }
|
|
});
|
|
}
|
|
|
|
// API: Delete video
|
|
if (path.startsWith('/admin/api/videos/') && request.method === 'DELETE') {
|
|
const filename = decodeURIComponent(path.replace('/admin/api/videos/', ''));
|
|
await env.R2_BUCKET.delete(filename);
|
|
await env.VIDEO_METADATA.delete(filename);
|
|
|
|
return new Response(JSON.stringify({ success: true }), {
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders }
|
|
});
|
|
}
|
|
|
|
// API: Upload video
|
|
if (path === '/admin/api/upload' && request.method === 'POST') {
|
|
return await handleVideoUpload(request, env, corsHeaders);
|
|
}
|
|
|
|
return new Response('Not found', { status: 404 });
|
|
}
|
|
|
|
/**
|
|
* Get admin HTML (reads from the admin.html file embedded as string or fetched)
|
|
*/
|
|
async function getAdminHTML() {
|
|
// In production, you'd embed this or fetch from R2
|
|
// For now, we'll import it as a module or use a fetch
|
|
// Since we can't easily read files in Workers, we'll need to inline it or use a build step
|
|
|
|
// For this implementation, we'll fetch it from a constant
|
|
// In production, use wrangler's module support or embed it
|
|
|
|
return `
|
|
<!DOCTYPE html>
|
|
<html><head><meta charset="UTF-8"><title>Admin Panel</title></head>
|
|
<body>
|
|
<h1>Admin Panel</h1>
|
|
<p>Please replace this with the full admin.html content using a build step or module import.</p>
|
|
<p>For now, use: <a href="/admin/api/videos">/admin/api/videos</a> to see the API.</p>
|
|
</body>
|
|
</html>
|
|
`;
|
|
// TODO: In production, import admin.html content here
|
|
}
|
|
|
|
/**
|
|
* List all videos for admin (includes metadata)
|
|
*/
|
|
async function handleAdminListVideos(bucket, kv, corsHeaders) {
|
|
const objects = await bucket.list();
|
|
|
|
const videos = await Promise.all(
|
|
objects.objects.map(async (obj) => {
|
|
const metadataStr = await kv.get(obj.key);
|
|
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
|
|
|
|
return {
|
|
name: obj.key,
|
|
size: obj.size,
|
|
uploaded: obj.uploaded,
|
|
visibility: metadata.visibility || 'shareable',
|
|
url: `/${obj.key}`,
|
|
};
|
|
})
|
|
);
|
|
|
|
return new Response(JSON.stringify({ count: videos.length, videos }), {
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* List public videos only (shareable ones)
|
|
*/
|
|
async function handlePublicList(bucket, kv, corsHeaders) {
|
|
const objects = await bucket.list();
|
|
|
|
const videos = await Promise.all(
|
|
objects.objects.map(async (obj) => {
|
|
const metadataStr = await kv.get(obj.key);
|
|
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
|
|
return {
|
|
name: obj.key,
|
|
size: obj.size,
|
|
uploaded: obj.uploaded,
|
|
visibility: metadata.visibility || 'shareable',
|
|
url: `/${obj.key}`,
|
|
};
|
|
})
|
|
);
|
|
|
|
// Filter to only shareable videos and exclude small/test files
|
|
const validVideoExtensions = ['.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv'];
|
|
const shareableVideos = videos.filter(v => {
|
|
if (v.visibility !== 'shareable') return false;
|
|
if (v.name.startsWith('live/')) return false;
|
|
if (v.size < 1024) return false; // Exclude files smaller than 1KB
|
|
const ext = v.name.substring(v.name.lastIndexOf('.')).toLowerCase();
|
|
return validVideoExtensions.includes(ext);
|
|
});
|
|
|
|
return new Response(JSON.stringify({ count: shareableVideos.length, videos: shareableVideos }), {
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Serve video file with permission check
|
|
*/
|
|
async function handleVideoFile(request, env, filename, corsHeaders) {
|
|
// Check permissions
|
|
const metadataStr = await env.VIDEO_METADATA.get(filename);
|
|
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
|
|
const visibility = metadata.visibility || 'shareable';
|
|
|
|
// If private, require authentication
|
|
if (visibility === 'private') {
|
|
const isAuth = await verifyAuth(request, env);
|
|
if (!isAuth) {
|
|
return new Response('This video is private', {
|
|
status: 403,
|
|
headers: corsHeaders
|
|
});
|
|
}
|
|
}
|
|
|
|
// If clip_shareable, only allow clips
|
|
if (visibility === 'clip_shareable') {
|
|
const isAuth = await verifyAuth(request, env);
|
|
if (!isAuth) {
|
|
return new Response('Full video not available. Only clips can be shared.', {
|
|
status: 403,
|
|
headers: corsHeaders
|
|
});
|
|
}
|
|
}
|
|
|
|
// Serve the video
|
|
return await serveVideo(request, env.R2_BUCKET, filename, corsHeaders);
|
|
}
|
|
|
|
/**
|
|
* Handle clip requests
|
|
*/
|
|
async function handleClip(request, env, path, corsHeaders) {
|
|
const filename = path.replace('/clip/', '').split('?')[0];
|
|
const url = new URL(request.url);
|
|
const start = url.searchParams.get('start') || '0';
|
|
const end = url.searchParams.get('end');
|
|
|
|
// Check permissions
|
|
const metadataStr = await env.VIDEO_METADATA.get(filename);
|
|
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
|
|
const visibility = metadata.visibility || 'shareable';
|
|
|
|
// Clips are allowed for clip_shareable and shareable videos
|
|
if (visibility === 'private') {
|
|
const isAuth = await verifyAuth(request, env);
|
|
if (!isAuth) {
|
|
return new Response('This video is private', {
|
|
status: 403,
|
|
headers: corsHeaders
|
|
});
|
|
}
|
|
}
|
|
|
|
// For actual clip generation, you'd need to:
|
|
// 1. Use ffmpeg in a separate service, or
|
|
// 2. Use byte-range requests to approximate clips, or
|
|
// 3. Pre-generate clips on upload
|
|
|
|
// For now, we'll serve with Content-Range headers as an approximation
|
|
// This won't give exact frame-accurate clips but will seek to the right position
|
|
|
|
return new Response(`
|
|
<html>
|
|
<head><title>Video Clip</title></head>
|
|
<body style="margin: 0; background: #000;">
|
|
<video controls autoplay style="width: 100%; height: 100vh;" preload="auto">
|
|
<source src="/${filename}#t=${start}${end ? ',' + end : ''}" type="${getContentType(filename)}">
|
|
</video>
|
|
</body>
|
|
</html>
|
|
`, {
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Serve video with range support
|
|
*/
|
|
async function serveVideo(request, bucket, filename, corsHeaders) {
|
|
const range = request.headers.get('Range');
|
|
|
|
// First, get the file metadata to know the total size
|
|
const objectHead = await bucket.head(filename);
|
|
if (!objectHead) {
|
|
return new Response('Video not found', { status: 404, headers: corsHeaders });
|
|
}
|
|
|
|
const totalSize = objectHead.size;
|
|
const contentType = getContentType(filename);
|
|
|
|
let object;
|
|
let start = 0;
|
|
let end = totalSize - 1;
|
|
|
|
if (range) {
|
|
const rangeMatch = range.match(/bytes=(\d+)-(\d*)/);
|
|
if (rangeMatch) {
|
|
start = parseInt(rangeMatch[1]);
|
|
end = rangeMatch[2] ? parseInt(rangeMatch[2]) : totalSize - 1;
|
|
|
|
// Ensure end doesn't exceed file size
|
|
if (end >= totalSize) {
|
|
end = totalSize - 1;
|
|
}
|
|
|
|
object = await bucket.get(filename, {
|
|
range: { offset: start, length: end - start + 1 }
|
|
});
|
|
} else {
|
|
object = await bucket.get(filename);
|
|
}
|
|
} else {
|
|
object = await bucket.get(filename);
|
|
}
|
|
|
|
if (!object) {
|
|
return new Response('Video not found', { status: 404, headers: corsHeaders });
|
|
}
|
|
|
|
const headers = {
|
|
'Content-Type': contentType,
|
|
'Cache-Control': 'public, max-age=31536000',
|
|
'Accept-Ranges': 'bytes',
|
|
...corsHeaders,
|
|
};
|
|
|
|
if (range) {
|
|
headers['Content-Range'] = `bytes ${start}-${end}/${totalSize}`;
|
|
headers['Content-Length'] = end - start + 1;
|
|
|
|
return new Response(object.body, { status: 206, headers });
|
|
} else {
|
|
headers['Content-Length'] = totalSize;
|
|
return new Response(object.body, { status: 200, headers });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Public gallery (only shareable videos)
|
|
*/
|
|
async function handlePublicGallery(bucket, kv, corsHeaders) {
|
|
const objects = await bucket.list();
|
|
|
|
const videos = await Promise.all(
|
|
objects.objects.map(async (obj) => {
|
|
const metadataStr = await kv.get(obj.key);
|
|
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
|
|
return {
|
|
...obj,
|
|
visibility: metadata.visibility || 'shareable'
|
|
};
|
|
})
|
|
);
|
|
|
|
// Filter to only shareable videos AND exclude HLS live stream files
|
|
const validVideoExtensions = ['.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv'];
|
|
const candidateVideos = videos.filter(v => {
|
|
// Must be shareable
|
|
if (v.visibility !== 'shareable') return false;
|
|
|
|
// Exclude live streaming files
|
|
if (v.key.startsWith('live/')) return false;
|
|
|
|
// Only include actual video files (not HLS chunks)
|
|
const ext = v.key.substring(v.key.lastIndexOf('.')).toLowerCase();
|
|
|
|
// Filter out test files and files that are too small to be real videos (< 1KB)
|
|
if (v.size < 1024) return false;
|
|
|
|
return validVideoExtensions.includes(ext);
|
|
});
|
|
|
|
// Verify each video actually exists and is accessible by trying to read first byte
|
|
const shareableVideos = [];
|
|
for (const video of candidateVideos) {
|
|
try {
|
|
// Try to actually fetch the first 1 byte to verify the video exists
|
|
const testObject = await bucket.get(video.key, { range: { offset: 0, length: 1 } });
|
|
if (testObject && testObject.body) {
|
|
shareableVideos.push(video);
|
|
}
|
|
} catch (e) {
|
|
// Video doesn't exist or isn't accessible, skip it
|
|
console.log(`Skipping inaccessible video: ${video.key}`);
|
|
}
|
|
}
|
|
|
|
const videoItems = shareableVideos
|
|
.map(obj => {
|
|
const sizeInMB = (obj.size / (1024 * 1024)).toFixed(2);
|
|
const uploadDate = new Date(obj.uploaded).toLocaleDateString();
|
|
const encodedKey = encodeURIComponent(obj.key);
|
|
|
|
return `
|
|
<div class="video-item">
|
|
<div class="video-thumbnail" onclick="playVideo('${encodedKey}')">
|
|
<video id="thumb-${encodedKey}" preload="metadata" muted>
|
|
<source src="/${obj.key}#t=0.5" type="${getContentType(obj.key)}">
|
|
</video>
|
|
<div class="play-overlay">
|
|
<div class="play-button">▶</div>
|
|
</div>
|
|
</div>
|
|
<div class="video-info">
|
|
<h3 onclick="playVideo('${encodedKey}')" style="cursor: pointer;">${obj.key}</h3>
|
|
<p>Size: ${sizeInMB} MB | Uploaded: ${uploadDate}</p>
|
|
<div class="button-group">
|
|
<button onclick="playVideo('${encodedKey}')">Watch</button>
|
|
<button onclick="copyLink('${obj.key}')">Copy Link</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
})
|
|
.join('\n');
|
|
|
|
const emptyState = shareableVideos.length === 0 ? `
|
|
<div style="text-align: center; padding: 100px 40px; background: linear-gradient(135deg, #1a1a1a 0%, #0f0f0f 100%); border-radius: 12px; margin: 20px;">
|
|
<div style="font-size: 6rem; margin-bottom: 30px; opacity: 0.7; animation: float 3s ease-in-out infinite;">🎬</div>
|
|
<h2 style="font-size: 2rem; margin-bottom: 15px; color: #fff;">No Videos Available Yet</h2>
|
|
<p style="color: #aaa; font-size: 1.1rem; line-height: 1.6; max-width: 600px; margin: 0 auto 30px;">
|
|
This gallery is currently empty. Check back soon for new content!
|
|
</p>
|
|
<div style="background: #2a2a2a; padding: 30px; border-radius: 10px; max-width: 500px; margin: 40px auto; border: 2px solid #3a3a3a;">
|
|
<div style="font-size: 2rem; margin-bottom: 15px;">📢</div>
|
|
<h3 style="color: #fff; margin-bottom: 10px;">For Content Creators</h3>
|
|
<p style="color: #aaa; font-size: 0.95rem;">
|
|
Upload videos through the admin panel to make them available here.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<style>
|
|
@keyframes float {
|
|
0%, 100% { transform: translateY(0px); }
|
|
50% { transform: translateY(-20px); }
|
|
}
|
|
</style>
|
|
` : videoItems;
|
|
|
|
const html = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Video Gallery</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #0f0f0f;
|
|
color: #fff;
|
|
padding: 20px;
|
|
min-height: 100vh;
|
|
}
|
|
header { text-align: center; margin-bottom: 40px; padding: 20px; }
|
|
h1 { font-size: 2.5rem; margin-bottom: 10px; }
|
|
.subtitle { color: #aaa; font-size: 1.1rem; }
|
|
.controls {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 15px;
|
|
margin-top: 20px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.sort-select {
|
|
padding: 10px 20px;
|
|
background: #1a1a1a;
|
|
border: 1px solid #3a3a3a;
|
|
border-radius: 6px;
|
|
color: white;
|
|
font-size: 0.95rem;
|
|
cursor: pointer;
|
|
transition: border-color 0.2s;
|
|
}
|
|
.sort-select:hover {
|
|
border-color: #3ea6ff;
|
|
}
|
|
.sort-select:focus {
|
|
outline: none;
|
|
border-color: #3ea6ff;
|
|
}
|
|
.video-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
|
gap: 30px;
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
.video-item {
|
|
background: #1a1a1a;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
}
|
|
.video-item:hover {
|
|
transform: translateY(-4px);
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.6);
|
|
}
|
|
.video-thumbnail {
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 16/9;
|
|
background: linear-gradient(90deg, #1a1a1a 0%, #2a2a2a 50%, #1a1a1a 100%);
|
|
background-size: 200% 100%;
|
|
animation: shimmer 1.5s infinite;
|
|
cursor: pointer;
|
|
overflow: hidden;
|
|
}
|
|
@keyframes shimmer {
|
|
0% { background-position: -200% 0; }
|
|
100% { background-position: 200% 0; }
|
|
}
|
|
.video-thumbnail.loaded {
|
|
animation: none;
|
|
background: #000;
|
|
}
|
|
.video-thumbnail video {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.3s;
|
|
}
|
|
.video-thumbnail video.loaded {
|
|
opacity: 1;
|
|
}
|
|
.play-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(0,0,0,0.3);
|
|
transition: background 0.2s;
|
|
}
|
|
.video-thumbnail:hover .play-overlay {
|
|
background: rgba(0,0,0,0.5);
|
|
}
|
|
.play-button {
|
|
width: 64px;
|
|
height: 64px;
|
|
background: rgba(62, 166, 255, 0.9);
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 24px;
|
|
color: white;
|
|
transition: transform 0.2s, background 0.2s;
|
|
}
|
|
.video-thumbnail:hover .play-button {
|
|
transform: scale(1.1);
|
|
background: rgba(62, 166, 255, 1);
|
|
}
|
|
.video-info { padding: 15px; }
|
|
.video-info h3 {
|
|
font-size: 1rem;
|
|
margin-bottom: 8px;
|
|
word-break: break-word;
|
|
transition: color 0.2s;
|
|
}
|
|
.video-info h3:hover { color: #3ea6ff; }
|
|
.video-info p { color: #aaa; font-size: 0.9rem; margin-bottom: 12px; }
|
|
.button-group {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
button {
|
|
padding: 8px 16px;
|
|
background: #3ea6ff;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
flex: 1;
|
|
}
|
|
button:hover { background: #2988d8; }
|
|
@media (max-width: 768px) {
|
|
.video-grid { grid-template-columns: 1fr; }
|
|
h1 { font-size: 2rem; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>🎥 Video Gallery</h1>
|
|
<p class="subtitle">${shareableVideos.length} video${shareableVideos.length === 1 ? '' : 's'} available</p>
|
|
${shareableVideos.length > 0 ? `
|
|
<div class="controls">
|
|
<select class="sort-select" id="sortSelect" onchange="sortVideos(this.value)">
|
|
<option value="newest">Newest First</option>
|
|
<option value="oldest">Oldest First</option>
|
|
<option value="name-asc">Name (A-Z)</option>
|
|
<option value="name-desc">Name (Z-A)</option>
|
|
<option value="size-desc">Largest First</option>
|
|
<option value="size-asc">Smallest First</option>
|
|
</select>
|
|
</div>
|
|
` : ''}
|
|
</header>
|
|
<div class="video-grid" id="videoGrid">
|
|
${emptyState}
|
|
</div>
|
|
<script>
|
|
// Store videos for sorting
|
|
let allVideos = ${JSON.stringify(shareableVideos.map(v => ({
|
|
key: v.key,
|
|
size: v.size,
|
|
uploaded: v.uploaded,
|
|
sizeInMB: (v.size / (1024 * 1024)).toFixed(2),
|
|
uploadDate: new Date(v.uploaded).toLocaleDateString(),
|
|
encodedKey: encodeURIComponent(v.key)
|
|
})))};
|
|
|
|
function playVideo(encodedKey) {
|
|
window.location.href = '/watch/' + encodedKey;
|
|
}
|
|
|
|
function copyLink(filename) {
|
|
const url = window.location.origin + '/' + filename;
|
|
navigator.clipboard.writeText(url).then(() => alert('Link copied!'));
|
|
}
|
|
|
|
function sortVideos(sortBy) {
|
|
const sorted = [...allVideos];
|
|
|
|
switch(sortBy) {
|
|
case 'newest':
|
|
sorted.sort((a, b) => new Date(b.uploaded) - new Date(a.uploaded));
|
|
break;
|
|
case 'oldest':
|
|
sorted.sort((a, b) => new Date(a.uploaded) - new Date(b.uploaded));
|
|
break;
|
|
case 'name-asc':
|
|
sorted.sort((a, b) => a.key.localeCompare(b.key));
|
|
break;
|
|
case 'name-desc':
|
|
sorted.sort((a, b) => b.key.localeCompare(a.key));
|
|
break;
|
|
case 'size-desc':
|
|
sorted.sort((a, b) => b.size - a.size);
|
|
break;
|
|
case 'size-asc':
|
|
sorted.sort((a, b) => a.size - b.size);
|
|
break;
|
|
}
|
|
|
|
renderVideos(sorted);
|
|
}
|
|
|
|
function renderVideos(videos) {
|
|
const grid = document.getElementById('videoGrid');
|
|
grid.innerHTML = videos.map(obj => \`
|
|
<div class="video-item">
|
|
<div class="video-thumbnail" onclick="playVideo('\${obj.encodedKey}')">
|
|
<video id="thumb-\${obj.encodedKey}" preload="metadata" muted>
|
|
<source src="/\${obj.key}#t=0.5" type="\${getContentType(obj.key)}">
|
|
</video>
|
|
<div class="play-overlay">
|
|
<div class="play-button">▶</div>
|
|
</div>
|
|
</div>
|
|
<div class="video-info">
|
|
<h3 onclick="playVideo('\${obj.encodedKey}')" style="cursor: pointer;">\${obj.key}</h3>
|
|
<p>Size: \${obj.sizeInMB} MB | Uploaded: \${obj.uploadDate}</p>
|
|
<div class="button-group">
|
|
<button onclick="playVideo('\${obj.encodedKey}')">Watch</button>
|
|
<button onclick="copyLink('\${obj.key}')">Copy Link</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
\`).join('\\n');
|
|
|
|
// Re-initialize lazy loading for new thumbnails
|
|
initLazyLoading();
|
|
}
|
|
|
|
function getContentType(filename) {
|
|
const ext = filename.split('.').pop().toLowerCase();
|
|
const types = {
|
|
'mp4': 'video/mp4',
|
|
'mkv': 'video/x-matroska',
|
|
'mov': 'video/quicktime',
|
|
'avi': 'video/x-msvideo',
|
|
'webm': 'video/webm',
|
|
'flv': 'video/x-flv',
|
|
'wmv': 'video/x-ms-wmv'
|
|
};
|
|
return types[ext] || 'video/mp4';
|
|
}
|
|
|
|
// Lazy load thumbnails using Intersection Observer
|
|
function initLazyLoading() {
|
|
const videos = document.querySelectorAll('.video-thumbnail video');
|
|
|
|
// Create intersection observer for lazy loading
|
|
const observerOptions = {
|
|
root: null,
|
|
rootMargin: '50px',
|
|
threshold: 0.01
|
|
};
|
|
|
|
const videoObserver = new IntersectionObserver((entries, observer) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const video = entry.target;
|
|
|
|
// Set up error handling for videos that fail to load
|
|
video.addEventListener('error', function() {
|
|
// Hide the video element and show a placeholder
|
|
const thumbnail = this.closest('.video-thumbnail');
|
|
const overlay = thumbnail.querySelector('.play-overlay');
|
|
this.style.display = 'none';
|
|
thumbnail.style.background = 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)';
|
|
|
|
// Add a video icon placeholder
|
|
const icon = document.createElement('div');
|
|
icon.style.cssText = 'position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 3rem; opacity: 0.3;';
|
|
icon.textContent = '🎬';
|
|
thumbnail.insertBefore(icon, overlay);
|
|
});
|
|
|
|
// Show video when loaded
|
|
video.addEventListener('loadeddata', function() {
|
|
this.classList.add('loaded');
|
|
const thumbnail = this.closest('.video-thumbnail');
|
|
thumbnail.classList.add('loaded');
|
|
});
|
|
|
|
// Try to load the video
|
|
video.load();
|
|
|
|
// Stop observing this video
|
|
observer.unobserve(video);
|
|
}
|
|
});
|
|
}, observerOptions);
|
|
|
|
// Observe all video elements
|
|
videos.forEach(video => {
|
|
videoObserver.observe(video);
|
|
});
|
|
}
|
|
|
|
// Initialize on page load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initLazyLoading();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
|
|
return new Response(html, {
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8', ...corsHeaders }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle video upload from admin panel
|
|
*/
|
|
async function handleVideoUpload(request, env, corsHeaders) {
|
|
try {
|
|
// Parse multipart form data
|
|
const formData = await request.formData();
|
|
const videoFile = formData.get('video');
|
|
|
|
if (!videoFile) {
|
|
return new Response(JSON.stringify({ error: 'No video file provided' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders }
|
|
});
|
|
}
|
|
|
|
// Get filename
|
|
const filename = videoFile.name;
|
|
|
|
// Validate file type
|
|
const validExtensions = ['.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv'];
|
|
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
|
if (!validExtensions.includes(ext)) {
|
|
return new Response(JSON.stringify({ error: 'Invalid file type' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders }
|
|
});
|
|
}
|
|
|
|
// Upload to R2
|
|
await env.R2_BUCKET.put(filename, videoFile.stream(), {
|
|
httpMetadata: {
|
|
contentType: getContentType(filename)
|
|
}
|
|
});
|
|
|
|
// Set default visibility to shareable
|
|
await env.VIDEO_METADATA.put(filename, JSON.stringify({
|
|
visibility: 'shareable',
|
|
uploadedAt: new Date().toISOString(),
|
|
uploadMethod: 'admin_panel'
|
|
}));
|
|
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
filename: filename,
|
|
url: `/${filename}`
|
|
}), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders }
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Upload error:', error);
|
|
return new Response(JSON.stringify({
|
|
error: 'Upload failed',
|
|
details: error.message
|
|
}), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders }
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify admin authentication
|
|
*/
|
|
async function verifyAuth(request, env) {
|
|
const cookie = request.headers.get('Cookie') || '';
|
|
const match = cookie.match(/admin_auth=([^;]+)/);
|
|
|
|
if (!match) return false;
|
|
|
|
const token = match[1];
|
|
const correctPassword = env.ADMIN_PASSWORD || 'changeme';
|
|
const expectedToken = await generateToken(correctPassword);
|
|
|
|
return token === expectedToken;
|
|
}
|
|
|
|
/**
|
|
* Generate auth token
|
|
*/
|
|
async function generateToken(password) {
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(password + 'salt123');
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
/**
|
|
* Handle HLS live stream requests
|
|
*/
|
|
async function handleHLSStream(request, env, path, corsHeaders) {
|
|
// Path format: /live/stream-name/file.m3u8 or /live/stream-name/file.ts
|
|
const filename = path.substring(1); // Remove leading slash
|
|
|
|
try {
|
|
const object = await env.R2_BUCKET.get(filename);
|
|
|
|
if (!object) {
|
|
return new Response('Stream not found', {
|
|
status: 404,
|
|
headers: corsHeaders
|
|
});
|
|
}
|
|
|
|
// Determine content type based on extension
|
|
const contentType = filename.endsWith('.m3u8')
|
|
? 'application/vnd.apple.mpegurl'
|
|
: filename.endsWith('.ts')
|
|
? 'video/MP2T'
|
|
: 'application/octet-stream';
|
|
|
|
// Set appropriate caching headers
|
|
const cacheControl = filename.endsWith('.m3u8')
|
|
? 'no-cache, no-store, must-revalidate' // Playlist changes frequently
|
|
: 'public, max-age=31536000'; // Chunks are immutable
|
|
|
|
const headers = {
|
|
'Content-Type': contentType,
|
|
'Cache-Control': cacheControl,
|
|
...corsHeaders,
|
|
};
|
|
|
|
return new Response(object.body, { headers });
|
|
|
|
} catch (error) {
|
|
console.error('Error serving HLS stream:', error);
|
|
return new Response('Error serving stream', {
|
|
status: 500,
|
|
headers: corsHeaders
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle watch page (dedicated video player)
|
|
*/
|
|
async function handleWatchPage(request, env, path, corsHeaders) {
|
|
const filename = decodeURIComponent(path.replace('/watch/', ''));
|
|
|
|
// Check if video exists and get metadata
|
|
const metadataStr = await env.VIDEO_METADATA.get(filename);
|
|
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
|
|
const visibility = metadata.visibility || 'shareable';
|
|
|
|
// Check permissions
|
|
if (visibility === 'private' || visibility === 'clip_shareable') {
|
|
const isAuth = await verifyAuth(request, env);
|
|
if (!isAuth) {
|
|
return new Response('This video is not available', {
|
|
status: 403,
|
|
headers: corsHeaders
|
|
});
|
|
}
|
|
}
|
|
|
|
// Get video object to check if it exists
|
|
const videoObject = await env.R2_BUCKET.head(filename);
|
|
if (!videoObject) {
|
|
return new Response('Video not found', {
|
|
status: 404,
|
|
headers: corsHeaders
|
|
});
|
|
}
|
|
|
|
const sizeInMB = (videoObject.size / (1024 * 1024)).toFixed(2);
|
|
const uploadDate = new Date(videoObject.uploaded).toLocaleDateString();
|
|
|
|
const html = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>${filename}</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #0f0f0f;
|
|
color: #fff;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
header {
|
|
background: #1a1a1a;
|
|
padding: 15px 30px;
|
|
border-bottom: 1px solid #2a2a2a;
|
|
}
|
|
.header-content {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 20px;
|
|
}
|
|
.back-button {
|
|
padding: 8px 16px;
|
|
background: #2a2a2a;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: white;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
transition: background 0.2s;
|
|
}
|
|
.back-button:hover { background: #3a3a3a; }
|
|
h1 {
|
|
font-size: 1.2rem;
|
|
word-break: break-word;
|
|
flex: 1;
|
|
}
|
|
.video-container {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
background: #000;
|
|
}
|
|
video {
|
|
max-width: 100%;
|
|
max-height: calc(100vh - 200px);
|
|
width: 100%;
|
|
border-radius: 8px;
|
|
}
|
|
.info-section {
|
|
background: #1a1a1a;
|
|
padding: 20px 30px;
|
|
border-top: 1px solid #2a2a2a;
|
|
}
|
|
.info-content {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
.info-row {
|
|
display: flex;
|
|
gap: 30px;
|
|
margin-bottom: 15px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.info-item {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
.info-label {
|
|
color: #aaa;
|
|
font-size: 0.9rem;
|
|
}
|
|
.info-value {
|
|
color: #fff;
|
|
font-size: 0.9rem;
|
|
}
|
|
.action-buttons {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 15px;
|
|
flex-wrap: wrap;
|
|
}
|
|
button, .button {
|
|
padding: 10px 20px;
|
|
background: #3ea6ff;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: white;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
text-decoration: none;
|
|
display: inline-block;
|
|
}
|
|
button:hover, .button:hover { background: #2988d8; }
|
|
@media (max-width: 768px) {
|
|
header { padding: 10px 15px; }
|
|
h1 { font-size: 1rem; }
|
|
.info-section { padding: 15px; }
|
|
video { max-height: 50vh; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="header-content">
|
|
<a href="/" class="back-button">← Gallery</a>
|
|
<h1>${filename}</h1>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="video-container">
|
|
<video controls autoplay preload="auto">
|
|
<source src="/${filename}" type="${getContentType(filename)}">
|
|
Your browser does not support the video tag.
|
|
</video>
|
|
</div>
|
|
|
|
<div class="info-section">
|
|
<div class="info-content">
|
|
<div class="info-row">
|
|
<div class="info-item">
|
|
<span class="info-label">Size:</span>
|
|
<span class="info-value">${sizeInMB} MB</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="info-label">Uploaded:</span>
|
|
<span class="info-value">${uploadDate}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="info-label">Format:</span>
|
|
<span class="info-value">${filename.split('.').pop().toUpperCase()}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="action-buttons">
|
|
<button onclick="copyLink()">📋 Copy Link</button>
|
|
<a href="/${filename}" download class="button">⬇ Download</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function copyLink() {
|
|
const url = window.location.href;
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
alert('Link copied to clipboard!');
|
|
});
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
|
|
return new Response(html, {
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8', ...corsHeaders }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get content type
|
|
*/
|
|
function getContentType(filename) {
|
|
const ext = filename.split('.').pop().toLowerCase();
|
|
const types = {
|
|
'mp4': 'video/mp4',
|
|
'mkv': 'video/x-matroska',
|
|
'mov': 'video/quicktime',
|
|
'avi': 'video/x-msvideo',
|
|
'webm': 'video/webm',
|
|
'flv': 'video/x-flv',
|
|
'wmv': 'video/x-ms-wmv',
|
|
'm3u8': 'application/vnd.apple.mpegurl',
|
|
'ts': 'video/MP2T',
|
|
};
|
|
return types[ext] || 'application/octet-stream';
|
|
}
|