refactor: split cloud Worker by responsibility

This commit is contained in:
webadderall
2026-09-21 20:42:17 +10:00
parent 1a2890838f
commit 35335d4a94
13 changed files with 1645 additions and 1561 deletions
+14
View File
@@ -36,3 +36,17 @@ Set `SUPABASE_URL` as a Worker variable. The ID-less configuration provisions `r
## Attribution
This service is adapted from an MIT-licensed open-source project. The required original copyright and permission notice is preserved in [`../LICENSE`](../LICENSE) and Recordly's root `THIRD_PARTY_NOTICES.md`. Product-facing pages use Recordly branding; legal attribution must remain with distributed copies.
## Worker source layout
`src/index.js` contains the fetch/scheduled entry points and error boundaries. `router.js` dispatches requests and applies route access checks. The implementation lives in focused modules:
- `schema.js`: database bootstrap and migrations.
- `auth.js` and `accounts.js`: owner/dashboard access, video passwords, and optional viewer accounts.
- `uploads.js`: upload creation, multipart transfers, and metadata.
- `media.js`: streaming, captions, share data, and social previews.
- `feedback.js`: comments and reactions.
- `library.js`: listing, renewal, deletion, view counts, and expiry cleanup.
- `crypto.js`, `http.js`, and `video.js`: shared cryptographic, response, and video metadata helpers.
Run `npm test` here to exercise these modules through the Worker endpoints and database migrations.
@@ -0,0 +1,133 @@
// Adapted from Voom (MIT), Copyright (c) 2026 Aritro Paul.
// See ../../LICENSE and ../../../../THIRD_PARTY_NOTICES.md for attribution.
import { errorResponse, parseCookies } from './http.js';
import { generateSalt, sha256Hex, timingSafeEqual } from './crypto.js';
import { checkLoginRateLimit, clearLoginRateLimit } from './auth.js';
const COMMENT_SESSION_COOKIE = 'recordly_comment_session';
const COMMENT_SESSION_SECONDS = 30 * 24 * 60 * 60;
async function hashCommentPassword(password, salt) {
const material = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']
);
const bits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
hash: 'SHA-256',
salt: new TextEncoder().encode(salt),
iterations: 210000,
},
material,
256,
);
return Array.from(new Uint8Array(bits), b => b.toString(16).padStart(2, '0')).join('');
}
function generateSessionToken() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
}
function commentSessionCookie(request, token, maxAge = COMMENT_SESSION_SECONDS) {
const secure = new URL(request.url).protocol === 'https:' ? '; Secure' : '';
return `${COMMENT_SESSION_COOKIE}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${secure}`;
}
export async function commentViewer(request, env) {
const cookies = parseCookies(request.headers.get('Cookie') || '');
const token = cookies[COMMENT_SESSION_COOKIE];
if (!token) return null;
const tokenHash = await sha256Hex(token);
return env.DB.prepare(
`SELECT u.id, u.email, u.display_name
FROM comment_sessions s
JOIN comment_users u ON u.id = s.user_id
WHERE s.token_hash = ? AND datetime(s.expires_at) > datetime('now')`
).bind(tokenHash).first();
}
async function createCommentSession(request, env, user) {
const token = generateSessionToken();
const tokenHash = await sha256Hex(token);
const expiresAt = new Date(Date.now() + COMMENT_SESSION_SECONDS * 1000).toISOString();
await env.DB.prepare(
'INSERT INTO comment_sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)'
).bind(tokenHash, user.id, expiresAt).run();
return new Response(JSON.stringify({
user: { email: user.email, displayName: user.display_name },
}), {
headers: {
'Content-Type': 'application/json',
'Set-Cookie': commentSessionCookie(request, token),
},
});
}
export async function handleCommentRegister(request, env) {
const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
if (!checkLoginRateLimit(`comment:${ip}`)) return errorResponse('Too many attempts', 429);
const body = await request.json();
const email = String(body.email || '').trim().toLowerCase();
const displayName = String(body.displayName || '').trim();
const password = String(body.password || '');
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
return errorResponse('Enter a valid email address');
}
if (displayName.length < 2 || displayName.length > 100) {
return errorResponse('Display name must be between 2 and 100 characters');
}
if (password.length < 8 || password.length > 256) {
return errorResponse('Password must be between 8 and 256 characters');
}
const salt = generateSalt();
const passwordHash = await hashCommentPassword(password, salt);
try {
const result = await env.DB.prepare(
'INSERT INTO comment_users (email, display_name, password_hash, password_salt) VALUES (?, ?, ?, ?)'
).bind(email, displayName, passwordHash, salt).run();
clearLoginRateLimit(`comment:${ip}`);
return createCommentSession(request, env, { id: result.meta.last_row_id, email, display_name: displayName });
} catch (error) {
if (/unique|constraint/i.test(error.message || '')) {
return errorResponse('An account with this email already exists', 409);
}
throw error;
}
}
export async function handleCommentLogin(request, env) {
const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
if (!checkLoginRateLimit(`comment:${ip}`)) return errorResponse('Too many attempts', 429);
const body = await request.json();
const email = String(body.email || '').trim().toLowerCase();
const password = String(body.password || '');
const user = await env.DB.prepare(
'SELECT id, email, display_name, password_hash, password_salt FROM comment_users WHERE email = ?'
).bind(email).first();
if (!user) return errorResponse('Incorrect email or password', 401);
const actualHash = await hashCommentPassword(password, user.password_salt);
if (!timingSafeEqual(actualHash, user.password_hash)) {
return errorResponse('Incorrect email or password', 401);
}
clearLoginRateLimit(`comment:${ip}`);
return createCommentSession(request, env, user);
}
export async function handleCommentLogout(request, env) {
const cookies = parseCookies(request.headers.get('Cookie') || '');
const token = cookies[COMMENT_SESSION_COOKIE];
if (token) {
await env.DB.prepare('DELETE FROM comment_sessions WHERE token_hash = ?')
.bind(await sha256Hex(token)).run();
}
return new Response(JSON.stringify({ ok: true }), {
headers: {
'Content-Type': 'application/json',
'Set-Cookie': commentSessionCookie(request, '', 0),
},
});
}
+161
View File
@@ -0,0 +1,161 @@
// Adapted from Voom (MIT), Copyright (c) 2026 Aritro Paul.
// See ../../LICENSE and ../../../../THIRD_PARTY_NOTICES.md for attribution.
import { generateSalt, sha256Hex, timingSafeEqual } from './crypto.js';
import { errorResponse, jsonResponse, parseCookies } from './http.js';
export async function isAuthorized(request, env) {
const auth = request.headers.get('Authorization');
if (!auth) return false;
const match = /^Bearer ([^\s]+)$/.exec(auth);
if (!match) return false;
const token = match[1];
if (token.length > 8192) return false;
if (
env.ALLOW_API_SECRET_UPLOADS === 'true' &&
env.API_SECRET &&
timingSafeEqual(token, env.API_SECRET)
) return true;
if (!env.SUPABASE_URL || !env.SUPABASE_PUBLISHABLE_KEY || !env.OWNER_USER_ID) return false;
try {
const authBase = new URL(env.SUPABASE_URL);
const isLocal = authBase.hostname === 'localhost' || authBase.hostname === '127.0.0.1';
if (authBase.protocol !== 'https:' && !(authBase.protocol === 'http:' && isLocal)) return false;
const userUrl = new URL('/auth/v1/user', authBase);
const response = await fetch(userUrl, {
headers: {
Authorization: `Bearer ${token}`,
apikey: env.SUPABASE_PUBLISHABLE_KEY,
},
});
if (!response.ok) return false;
const user = await response.json();
return typeof user.id === 'string' && timingSafeEqual(user.id, env.OWNER_USER_ID);
} catch {
return false;
}
}
async function generateAuthToken(shareCode, expiresAt, apiSecret) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw', encoder.encode(apiSecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(shareCode + expiresAt));
return Array.from(new Uint8Array(sig), b => b.toString(16).padStart(2, '0')).join('');
}
export async function verifyPasswordAuth(request, env, shareCode, video) {
if (!video.password_hash) return true;
if (!env.API_SECRET) return false;
const cookies = parseCookies(request.headers.get('Cookie') || '');
const authToken = cookies[`voom_auth_${shareCode}`];
if (!authToken) return false;
const expected = await generateAuthToken(shareCode, video.expires_at, env.API_SECRET);
return timingSafeEqual(authToken, expected);
}
// --- Dashboard session helpers ---
export function dashboardPassword(env) {
return env.DASHBOARD_PASSWORD || env.API_SECRET;
}
export async function expectedSessionToken(env) {
const password = dashboardPassword(env);
if (!password) throw new Error('Dashboard sign-in is not configured');
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw', encoder.encode(password), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode('voom-dashboard-v1'));
return Array.from(new Uint8Array(sig), b => b.toString(16).padStart(2, '0')).join('');
}
export async function isDashboardAuthed(request, env) {
return (await isAuthorized(request, env)) || dashboardCookieAuthed(request, env);
}
export async function dashboardCookieAuthed(request, env) {
if (!dashboardPassword(env)) return false;
const cookies = parseCookies(request.headers.get('Cookie') || '');
const sessionToken = cookies['voom_session'];
if (!sessionToken) return false;
return timingSafeEqual(sessionToken, await expectedSessionToken(env));
}
// best-effort, per-isolate login rate limiter (real protection is the password's entropy)
const _loginAttempts = new Map();
export function checkLoginRateLimit(ip) {
const now = Date.now();
const entry = _loginAttempts.get(ip);
if (entry && now < entry.resetAt) {
if (entry.count >= 10) return false;
entry.count++;
} else {
_loginAttempts.set(ip, { count: 1, resetAt: now + 5 * 60 * 1000 });
}
return true;
}
export function clearLoginRateLimit(ip) {
_loginAttempts.delete(ip);
}
// --- Password Verification ---
export async function handleVerifyPassword(request, env, shareCode) {
const video = await env.DB.prepare(
"SELECT * FROM videos WHERE share_code = ? AND upload_completed = 1 AND datetime(expires_at) > datetime('now')"
).bind(shareCode).first();
if (!video || !video.password_hash) return errorResponse('Not found', 404);
if (!env.API_SECRET) return errorResponse('Password protection is not configured', 503);
// Brute-force protection: 10 attempts per IP per video per 5 minutes.
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
const recent = await env.DB.prepare(
"SELECT COUNT(*) as cnt FROM password_attempts WHERE video_id = ? AND client_ip = ? AND datetime(attempted_at) > datetime('now', '-5 minutes')"
).bind(video.id, clientIP).first();
if (recent && recent.cnt >= 10) return errorResponse('Too many attempts — try again later', 429);
const body = await request.json();
const password = body.password || '';
// The browser sends the raw password (HTTPS); the app stored it as a salted
// hash of SHA256(password). Legacy rows (pre-salt) hold bare SHA256(password).
const clientHash = await sha256Hex(password);
let matches;
if (video.password_salt) {
matches = timingSafeEqual(await sha256Hex(video.password_salt + clientHash), video.password_hash);
} else {
matches = timingSafeEqual(clientHash, video.password_hash);
// Lazy upgrade: re-store the legacy unsalted hash as salted on success.
if (matches) {
const salt = generateSalt();
const upgraded = await sha256Hex(salt + clientHash);
await env.DB.prepare('UPDATE videos SET password_hash = ?, password_salt = ? WHERE id = ?')
.bind(upgraded, salt, video.id).run();
}
}
if (!matches) {
await env.DB.prepare(
'INSERT INTO password_attempts (video_id, client_ip) VALUES (?, ?)'
).bind(video.id, clientIP).run();
return jsonResponse({ error: 'Incorrect password' }, 403);
}
// Issue an HMAC session token (never the stored hash).
const authToken = await generateAuthToken(shareCode, video.expires_at, env.API_SECRET);
const expires = new Date(video.expires_at + 'Z');
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': `voom_auth_${shareCode}=${authToken}; Path=/; Expires=${expires.toUTCString()}; HttpOnly; SameSite=Lax; Secure`,
},
});
}
@@ -0,0 +1,29 @@
// Adapted from Voom (MIT), Copyright (c) 2026 Aritro Paul.
// See ../../LICENSE and ../../../../THIRD_PARTY_NOTICES.md for attribution.
// Constant-time string comparison — avoids leaking match length via timing.
export function timingSafeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const enc = new TextEncoder();
const ab = enc.encode(a);
const bb = enc.encode(b);
let diff = ab.length ^ bb.length;
const len = Math.max(ab.length, bb.length);
for (let i = 0; i < len; i++) {
diff |= (ab[i % ab.length] ?? 0) ^ (bb[i % bb.length] ?? 0);
}
return diff === 0;
}
export async function sha256Hex(input) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('');
}
export function generateSalt() {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
}
@@ -0,0 +1,131 @@
// Adapted from Voom (MIT), Copyright (c) 2026 Aritro Paul.
// See ../../LICENSE and ../../../../THIRD_PARTY_NOTICES.md for attribution.
import { errorResponse, jsonResponse } from './http.js';
import { verifyPasswordAuth } from './auth.js';
// --- Reactions ---
export async function handleReact(request, env, shareCode) {
const video = await env.DB.prepare(
"SELECT id, password_hash, expires_at FROM videos WHERE share_code = ? AND upload_completed = 1 AND datetime(expires_at) > datetime('now')"
).bind(shareCode).first();
if (!video) return errorResponse('Not found', 404);
// Password check
if (video.password_hash) {
const authed = await verifyPasswordAuth(request, env, shareCode, video);
if (!authed) return errorResponse('Unauthorized', 401);
}
const body = await request.json();
const { timestamp, emoji } = body;
const allowedEmojis = ['👍', '❤️', '😂', '😮', '🔥', '👏'];
if (!allowedEmojis.includes(emoji)) return errorResponse('Invalid emoji');
if (typeof timestamp !== 'number' || timestamp < 0) return errorResponse('Invalid timestamp');
// Rate limit: 50 reactions per IP per video
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
const count = await env.DB.prepare(
'SELECT COUNT(*) as cnt FROM reactions WHERE video_id = ? AND client_ip = ?'
).bind(video.id, clientIP).first();
if (count && count.cnt >= 50) return errorResponse('Rate limit exceeded', 429);
await env.DB.prepare(
'INSERT INTO reactions (video_id, timestamp, emoji, client_ip) VALUES (?, ?, ?, ?)'
).bind(video.id, timestamp, emoji, clientIP).run();
return jsonResponse({ ok: true });
}
export async function handleGetReactions(request, env, shareCode) {
const video = await env.DB.prepare(
"SELECT id, password_hash, expires_at FROM videos WHERE share_code = ? AND upload_completed = 1 AND datetime(expires_at) > datetime('now')"
).bind(shareCode).first();
if (!video) return errorResponse('Not found', 404);
// Same gate as POST /react — listing must not bypass the password.
const authed = await verifyPasswordAuth(request, env, shareCode, video);
if (!authed) return errorResponse('Password required', 401);
const reactions = await env.DB.prepare(
'SELECT timestamp, emoji, created_at FROM reactions WHERE video_id = ? ORDER BY created_at DESC LIMIT 500'
).bind(video.id).all();
return jsonResponse({ reactions: reactions.results || [] });
}
// --- Comments ---
export async function handleComment(request, env, shareCode) {
const video = await env.DB.prepare(
"SELECT id, password_hash, expires_at FROM videos WHERE share_code = ? AND upload_completed = 1 AND datetime(expires_at) > datetime('now')"
).bind(shareCode).first();
if (!video) return errorResponse('Not found', 404);
// Password check
if (video.password_hash) {
const authed = await verifyPasswordAuth(request, env, shareCode, video);
if (!authed) return errorResponse('Unauthorized', 401);
}
const body = await request.json();
const { timestamp, text, author_name: authorName } = body;
if (typeof timestamp !== 'number' || timestamp < 0) return errorResponse('Invalid timestamp');
if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) {
return errorResponse('Display name is required');
}
if (authorName.length > 100) return errorResponse('Display name is too long');
if (!text || text.trim().length === 0) return errorResponse('Text is required');
if (text.length > 2000) return errorResponse('Text too long');
// Rate limit: 5 comments per IP per 5 minutes
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
const recent = await env.DB.prepare(
"SELECT COUNT(*) as cnt FROM comments WHERE video_id = ? AND client_ip = ? AND datetime(created_at) > datetime('now', '-5 minutes')"
).bind(video.id, clientIP).first();
if (recent && recent.cnt >= 5) return errorResponse('Rate limit exceeded', 429);
const inserted = await env.DB.prepare(
'INSERT INTO comments (video_id, timestamp, author_name, text, client_ip) VALUES (?, ?, ?, ?, ?)'
).bind(video.id, timestamp, authorName.trim(), text.trim().substring(0, 2000), clientIP).run();
return jsonResponse({ ok: true, id: inserted.meta.last_row_id });
}
export async function handleGetComments(request, env, shareCode) {
const video = await env.DB.prepare(
"SELECT id, password_hash, expires_at FROM videos WHERE share_code = ? AND upload_completed = 1 AND datetime(expires_at) > datetime('now')"
).bind(shareCode).first();
if (!video) return errorResponse('Not found', 404);
// Same gate as POST /comment — viewer comments can be sensitive.
const authed = await verifyPasswordAuth(request, env, shareCode, video);
if (!authed) return errorResponse('Password required', 401);
const url = new URL(request.url);
const requestedPage = parseInt(url.searchParams.get('page') || '1', 10);
const requestedLimit = parseInt(url.searchParams.get('limit') || '50', 10);
const page = Number.isSafeInteger(requestedPage) ? Math.max(1, Math.min(requestedPage, Math.floor(Number.MAX_SAFE_INTEGER / 100))) : 1;
const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, 100)) : 50;
const offset = (page - 1) * limit;
const total = await env.DB.prepare(
'SELECT COUNT(*) as cnt FROM comments WHERE video_id = ?'
).bind(video.id).first();
const comments = await env.DB.prepare(
'SELECT id, timestamp, author_name, text, created_at FROM comments WHERE video_id = ? ORDER BY timestamp ASC, id ASC LIMIT ? OFFSET ?'
).bind(video.id, limit, offset).all();
return jsonResponse({
comments: comments.results || [],
total: total ? total.cnt : 0,
page,
limit,
});
}
@@ -0,0 +1,24 @@
// Adapted from Voom (MIT), Copyright (c) 2026 Aritro Paul.
// See ../../LICENSE and ../../../../THIRD_PARTY_NOTICES.md for attribution.
export function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
export function errorResponse(message, status = 400) {
return jsonResponse({ error: message }, status);
}
export function parseCookies(cookieStr) {
const cookies = {};
cookieStr.split(';').forEach(c => {
const [key, ...val] = c.trim().split('=');
if (key) cookies[key] = val.join('=');
});
return cookies;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
// Adapted from Voom (MIT), Copyright (c) 2026 Aritro Paul.
// See ../../LICENSE and ../../../../THIRD_PARTY_NOTICES.md for attribution.
import { EXPIRY_DAYS } from './video.js';
import { errorResponse, jsonResponse } from './http.js';
export async function handleRenew(env, shareCode) {
const newExpiry = new Date(Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000).toISOString();
const result = await env.DB.prepare('UPDATE videos SET expires_at = ? WHERE share_code = ?')
.bind(newExpiry, shareCode)
.run();
if (result.meta.changes === 0) return errorResponse('Video not found', 404);
return jsonResponse({ expiresAt: newExpiry });
}
export async function handleDelete(env, shareCode) {
const video = await env.DB.prepare('SELECT id FROM videos WHERE share_code = ?').bind(shareCode).first();
if (!video) return errorResponse('Video not found', 404);
await Promise.all([
env.VIDEOS_BUCKET.delete(`videos/${shareCode}.mp4`),
env.VIDEOS_BUCKET.delete(`thumbnails/${shareCode}.jpg`),
]);
await env.DB.batch(deleteVideoStatements(env, video.id));
return jsonResponse({ ok: true });
}
// One round-trip delete of a video and all child rows. Explicit child deletes
// rather than relying on ON DELETE CASCADE: legacy self-host databases were
// created from a base schema without cascade on every table.
function deleteVideoStatements(env, videoId) {
return [
env.DB.prepare('DELETE FROM chapters WHERE video_id = ?').bind(videoId),
env.DB.prepare('DELETE FROM reactions WHERE video_id = ?').bind(videoId),
env.DB.prepare('DELETE FROM comments WHERE video_id = ?').bind(videoId),
env.DB.prepare('DELETE FROM transcript_segments WHERE video_id = ?').bind(videoId),
env.DB.prepare('DELETE FROM password_attempts WHERE video_id = ?').bind(videoId),
env.DB.prepare('DELETE FROM videos WHERE id = ?').bind(videoId),
];
}
// --- Cron Cleanup ---
export async function cleanupExpired(env) {
const expired = await env.DB.prepare(
"SELECT id, share_code FROM videos WHERE datetime(expires_at) < datetime('now')"
).all();
for (const video of expired.results || []) {
await Promise.all([
env.VIDEOS_BUCKET.delete(`videos/${video.share_code}.mp4`),
env.VIDEOS_BUCKET.delete(`thumbnails/${video.share_code}.jpg`),
]);
await env.DB.batch(deleteVideoStatements(env, video.id));
}
// Drop stale password rate-limit rows so the table can't grow unboundedly.
await env.DB.prepare(
"DELETE FROM password_attempts WHERE datetime(attempted_at) < datetime('now', '-1 day')"
).run();
await env.DB.prepare(
"DELETE FROM comment_sessions WHERE datetime(expires_at) < datetime('now')"
).run();
}
// --- Check Views (authenticated) ---
export async function handleCheckViews(request, env) {
const body = await request.json();
const { shareCodes } = body;
if (!Array.isArray(shareCodes) || shareCodes.length === 0) return errorResponse('shareCodes required');
if (shareCodes.length > 90) return errorResponse('Too many shareCodes (max 90)');
const placeholders = shareCodes.map(() => '?').join(',');
const results = await env.DB.prepare(
`SELECT share_code, view_count FROM videos WHERE share_code IN (${placeholders})`
).bind(...shareCodes).all();
const views = {};
for (const row of results.results || []) {
views[row.share_code] = row.view_count || 0;
}
return jsonResponse({ views });
}
// --- Library: list all shared videos (dashboard) ---
export async function handleListVideos(env) {
const rows = await env.DB.prepare(
`SELECT share_code, title, duration, width, height, file_size, created_at, expires_at,
view_count, is_meeting, summary, (password_hash IS NOT NULL) AS is_protected
FROM videos
WHERE upload_completed = 1
ORDER BY datetime(created_at) DESC`
).all();
return jsonResponse({ videos: rows.results || [] });
}
+334
View File
@@ -0,0 +1,334 @@
// Adapted from Voom (MIT), Copyright (c) 2026 Aritro Paul.
// See ../../LICENSE and ../../../../THIRD_PARTY_NOTICES.md for attribution.
import { verifyPasswordAuth } from './auth.js';
import { jsonResponse } from './http.js';
import { finiteNonnegative } from './video.js';
function formatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
function formatDate(isoString) {
const d = new Date(isoString + 'Z');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
export function formatVTTTime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const ms = Math.floor((s % 1) * 1000);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(Math.floor(s)).padStart(2, '0')}.${String(ms).padStart(3, '0')}`;
}
export function escapeHTML(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function formatTimestamp(seconds) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
// --- Video Streaming ---
export async function handleVideoStream(request, env, shareCode) {
const video = await env.DB.prepare(
"SELECT * FROM videos WHERE share_code = ? AND upload_completed = 1 AND datetime(expires_at) > datetime('now')"
)
.bind(shareCode)
.first();
if (!video) return new Response('Not found', { status: 404 });
// Password protection check for video stream
if (video.password_hash) {
const authed = await verifyPasswordAuth(request, env, shareCode, video);
if (!authed) return new Response('Unauthorized', { status: 401 });
}
const key = `videos/${shareCode}.mp4`;
const rangeHeader = request.headers.get('Range');
let object;
if (rangeHeader) {
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (match && (match[1] || match[2])) {
const suffix = !match[1]; // "bytes=-N" — last N bytes
let r2Range;
if (suffix) {
r2Range = { suffix: parseInt(match[2], 10) };
} else {
const start = parseInt(match[1], 10);
const end = match[2] ? parseInt(match[2], 10) : undefined;
r2Range = { offset: start, length: end !== undefined ? end - start + 1 : undefined };
}
try {
object = await env.VIDEOS_BUCKET.get(key, { range: r2Range });
} catch (_e) {
// R2 throws on an unsatisfiable range (e.g. offset past end of object).
return new Response('Range not satisfiable', { status: 416 });
}
if (!object) return new Response('Not found', { status: 404 });
const totalSize = object.size; // R2Object.size is the full stored object size
const start = suffix ? Math.max(0, totalSize - r2Range.suffix) : r2Range.offset;
const actualEnd = !suffix && r2Range.length !== undefined ? Math.min(totalSize - 1, start + r2Range.length - 1) : totalSize - 1;
return new Response(object.body, {
status: 206,
headers: {
'Content-Type': 'video/mp4',
'Content-Range': `bytes ${start}-${actualEnd}/${totalSize}`,
'Content-Length': String(actualEnd - start + 1),
'Accept-Ranges': 'bytes',
'Cache-Control': video.password_hash ? 'private, no-store' : 'public, max-age=3600',
'Access-Control-Allow-Origin': '*',
'Access-Control-Expose-Headers': 'Content-Range, Content-Length, Accept-Ranges',
},
});
}
}
object = await env.VIDEOS_BUCKET.get(key);
if (!object) return new Response('Not found', { status: 404 });
return new Response(object.body, {
status: 200,
headers: {
'Content-Type': 'video/mp4',
'Content-Length': String(object.size),
'Accept-Ranges': 'bytes',
'Cache-Control': video.password_hash ? 'private, no-store' : 'public, max-age=3600',
'Access-Control-Allow-Origin': '*',
'Access-Control-Expose-Headers': 'Content-Range, Content-Length, Accept-Ranges',
},
});
}
// --- VTT Captions ---
export async function handleVTT(request, env, shareCode) {
const video = await env.DB.prepare(
"SELECT * FROM videos WHERE share_code = ? AND upload_completed = 1 AND datetime(expires_at) > datetime('now')"
).bind(shareCode).first();
if (!video) return new Response('Not found', { status: 404 });
// Password protection check
if (video.password_hash) {
const authed = await verifyPasswordAuth(request, env, shareCode, video);
if (!authed) return new Response('Unauthorized', { status: 401 });
}
const segments = await env.DB.prepare(
'SELECT start_time, end_time, text, speaker FROM transcript_segments WHERE video_id = ? ORDER BY start_time'
).bind(video.id).all();
let vtt = 'WEBVTT\n\n';
(segments.results || []).forEach((seg, i) => {
const start = formatVTTTime(seg.start_time);
const end = formatVTTTime(seg.end_time);
const speaker = seg.speaker ? `<v ${seg.speaker}>` : '';
vtt += `${i + 1}\n${start} --> ${end}\n${speaker}${seg.text}\n\n`;
});
return new Response(vtt, {
headers: {
'Content-Type': 'text/vtt; charset=utf-8',
'Cache-Control': video.password_hash ? 'private, no-store' : 'public, max-age=3600',
'Access-Control-Allow-Origin': '*',
},
});
}
// --- Share Data (JSON endpoint for SPA) ---
export async function handleShareData(request, env, shareCode) {
const video = await env.DB.prepare(
"SELECT * FROM videos WHERE share_code = ? AND upload_completed = 1"
).bind(shareCode).first();
if (!video) return jsonResponse({ expired: true }, 404);
const isExpired = new Date(video.expires_at + 'Z') < new Date();
if (isExpired) return jsonResponse({ expired: true }, 404);
if (video.password_hash) {
const authed = await verifyPasswordAuth(request, env, shareCode, video);
if (!authed) {
return jsonResponse({ password_protected: true, title: video.title }, 401);
}
}
// View-count bump and the two reads are independent — run them together.
const [, segments, chapters] = await Promise.all([
env.DB.prepare('UPDATE videos SET view_count = view_count + 1 WHERE id = ?').bind(video.id).run(),
env.DB.prepare(
'SELECT start_time, end_time, text, speaker FROM transcript_segments WHERE video_id = ? ORDER BY start_time'
).bind(video.id).all(),
env.DB.prepare(
'SELECT timestamp, title FROM chapters WHERE video_id = ? ORDER BY timestamp'
).bind(video.id).all(),
]);
return jsonResponse({
video: {
title: video.title,
duration: video.duration,
width: video.width,
height: video.height,
summary: video.summary,
has_webcam: video.has_webcam,
cta_url: video.cta_url,
cta_text: video.cta_text,
created_at: video.created_at,
view_count: (video.view_count || 0) + 1,
is_meeting: video.is_meeting,
},
segments: (segments.results || []),
chapters: (chapters.results || []),
shareCode,
creator: typeof env.CREATOR_NAME === 'string' && env.CREATOR_NAME.trim() ? {
name: env.CREATOR_NAME.trim().slice(0, 100),
bio: typeof env.CREATOR_BIO === 'string' ? env.CREATOR_BIO.trim().slice(0, 200) : null,
website: typeof env.CREATOR_WEBSITE === 'string' && /^https?:\/\//i.test(env.CREATOR_WEBSITE) ? env.CREATOR_WEBSITE : null,
} : null,
});
}
// --- OG Page (bot-only, minimal HTML with meta tags) ---
export async function handleOGPage(request, env, shareCode) {
const video = await env.DB.prepare(
"SELECT * FROM videos WHERE share_code = ? AND upload_completed = 1"
).bind(shareCode).first();
if (!video) return new Response('Not found', { status: 404 });
const isExpired = new Date(video.expires_at + 'Z') < new Date();
if (isExpired) return new Response('Not found', { status: 404 });
const baseUrl = new URL(request.url).origin;
// Don't leak title/summary/thumbnail of password-protected videos to
// crawlers — the OG path has no way to present the password gate.
if (video.password_hash) {
const lockedHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Protected video — Recordly</title>
<meta property="og:title" content="Protected video">
<meta property="og:type" content="video.other">
<meta property="og:url" content="${baseUrl}/s/${shareCode}">
<meta property="og:site_name" content="Recordly">
<meta property="og:description" content="This recording is password protected.">
</head>
<body><p>This recording is password protected.</p></body>
</html>`;
return new Response(lockedHtml, {
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=3600' },
});
}
const desc = video.summary ? escapeHTML(video.summary) : `${formatTimestamp(video.duration)} screen recording`;
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${escapeHTML(video.title)} — Recordly</title>
<meta property="og:title" content="${escapeHTML(video.title)}">
<meta property="og:type" content="video.other">
<meta property="og:url" content="${baseUrl}/s/${shareCode}">
<meta property="og:video" content="${baseUrl}/embed/${shareCode}">
<meta property="og:video:secure_url" content="${baseUrl}/embed/${shareCode}">
<meta property="og:video:type" content="text/html">
<meta property="og:video:width" content="${finiteNonnegative(video.width)}">
<meta property="og:video:height" content="${finiteNonnegative(video.height)}">
<meta property="og:image" content="${baseUrl}/og/${shareCode}">
<meta property="og:image:secure_url" content="${baseUrl}/og/${shareCode}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="Recordly">
<meta property="og:description" content="${desc}">
<meta name="twitter:card" content="player">
<meta name="twitter:title" content="${escapeHTML(video.title)}">
<meta name="twitter:description" content="${desc}">
<meta name="twitter:image" content="${baseUrl}/og/${shareCode}">
<meta name="twitter:player" content="${baseUrl}/embed/${shareCode}">
<meta name="twitter:player:width" content="${finiteNonnegative(video.width)}">
<meta name="twitter:player:height" content="${finiteNonnegative(video.height)}">
</head>
<body>
<p>${escapeHTML(video.title)}</p>
</body>
</html>`;
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=3600' },
});
}
// --- OG Image ---
export async function handleOGImage(env, shareCode) {
const video = await env.DB.prepare(
"SELECT * FROM videos WHERE share_code = ? AND upload_completed = 1 AND datetime(expires_at) > datetime('now')"
)
.bind(shareCode)
.first();
if (!video) return new Response('Not found', { status: 404 });
// Serve uploaded thumbnail if available \u2014 but never for password-protected
// videos, whose poster frame may itself be sensitive.
if (!video.password_hash) {
const thumb = await env.VIDEOS_BUCKET.get(`thumbnails/${shareCode}.jpg`);
if (thumb) {
return new Response(thumb.body, {
status: 200,
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=86400',
},
});
}
}
// Fallback SVG
const locked = !!video.password_hash;
const duration = formatDuration(video.duration);
const date = formatDate(video.created_at);
const rawTitle = locked ? 'Protected video' : video.title;
const title = rawTitle.length > 60 ? rawTitle.substring(0, 57) + '...' : rawTitle;
const res = !locked && video.width > 0 ? `${finiteNonnegative(video.width)}\u00d7${finiteNonnegative(video.height)}` : '';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<rect width="1200" height="630" fill="#000"/>
<rect x="100" y="140" width="1000" height="350" rx="16" fill="#111" stroke="rgba(255,255,255,0.08)" stroke-width="1"/>
<circle cx="600" cy="290" r="40" fill="rgba(255,255,255,0.06)"/>
<polygon points="590,270 590,310 620,290" fill="rgba(255,255,255,0.3)"/>
<text x="600" y="400" text-anchor="middle" fill="#e5e5e5" font-family="-apple-system,BlinkMacSystemFont,sans-serif" font-size="22" font-weight="500">${escapeHTML(title)}</text>
<text x="600" y="440" text-anchor="middle" fill="#888" font-family="monospace" font-size="13" letter-spacing="1">${duration} \u00b7 ${date}${res ? ' \u00b7 ' + res : ''}</text>
<text x="600" y="570" text-anchor="middle" fill="#2563eb" font-family="-apple-system,BlinkMacSystemFont,sans-serif" font-size="14" font-weight="600" letter-spacing="2">RECORDLY</text>
</svg>`;
return new Response(svg, {
status: 200,
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=86400',
'X-Content-Type-Options': 'nosniff',
},
});
}
@@ -0,0 +1,286 @@
// Adapted from Voom (MIT), Copyright (c) 2026 Aritro Paul.
// See ../../LICENSE and ../../../../THIRD_PARTY_NOTICES.md for attribution.
import { ensureSchema } from './schema.js';
import { commentViewer, handleCommentLogin, handleCommentLogout, handleCommentRegister } from './accounts.js';
import { errorResponse, jsonResponse } from './http.js';
import { checkLoginRateLimit, clearLoginRateLimit, dashboardCookieAuthed, dashboardPassword, expectedSessionToken, handleVerifyPassword, isAuthorized, isDashboardAuthed, verifyPasswordAuth } from './auth.js';
import { timingSafeEqual } from './crypto.js';
import { handleCheckViews, handleDelete, handleListVideos, handleRenew } from './library.js';
import { decodeUploadId, handleMetadata, handleMultipartAbort, handleMultipartComplete, handleMultipartPart, handleMultipartStart, handleUpload, handleUploadData, handleUploadThumbnail } from './uploads.js';
import { handleOGImage, handleOGPage, handleShareData, handleVTT, handleVideoStream } from './media.js';
import { handleComment, handleGetComments, handleGetReactions, handleReact } from './feedback.js';
export async function handleRequest(request, env) {
const url = new URL(request.url);
const path = url.pathname;
await ensureSchema(env);
if (path === '/auth/session' && request.method === 'GET') {
const viewer = await commentViewer(request, env);
return jsonResponse({
user: viewer ? { email: viewer.email, displayName: viewer.display_name } : null,
});
}
if (path === '/auth/register' && request.method === 'POST') {
return handleCommentRegister(request, env);
}
if (path === '/auth/login' && request.method === 'POST') {
return handleCommentLogin(request, env);
}
if (path === '/auth/logout' && request.method === 'POST') {
return handleCommentLogout(request, env);
}
// Library login (public — no bearer required; form POST)
if (path === '/library/login' && request.method === 'POST') {
const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
if (!checkLoginRateLimit(ip)) {
return new Response('Too many attempts — try again later', { status: 429 });
}
const form = await request.formData();
const password = form.get('password') || '';
if (timingSafeEqual(password, dashboardPassword(env))) {
clearLoginRateLimit(ip);
const token = await expectedSessionToken(env);
return new Response(null, {
status: 302,
headers: {
'Location': '/library',
'Set-Cookie': `voom_session=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800`,
},
});
}
return new Response(null, { status: 302, headers: { 'Location': '/library/login?error=1' } });
}
// Library logout
if (path === '/library/logout' && request.method === 'GET') {
return new Response(null, {
status: 302,
headers: {
'Location': '/library/login',
'Set-Cookie': 'voom_session=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0',
},
});
}
// Library dashboard (auth-gated)
// Internal asset is /lib.html (not /library.html) so the ASSETS binding
// does not auto-serve it before the worker runs (no run_worker_first needed).
if (path === '/library' && request.method === 'GET') {
if (!(await isDashboardAuthed(request, env))) {
return new Response(null, { status: 302, headers: { 'Location': '/library/login' } });
}
return env.ASSETS.fetch(new Request(new URL('/lib', url), request));
}
// Library login page (public)
if (path === '/library/login' && request.method === 'GET') {
return env.ASSETS.fetch(new Request(new URL('/lib-login', url), request));
}
// API routes (authenticated)
if (path.startsWith('/api/')) {
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
},
});
}
if (!(await isAuthorized(request, env)) && !(await dashboardCookieAuthed(request, env))) {
return errorResponse('Unauthorized', 401);
}
// Connection check used by the desktop app to validate a worker URL + secret.
if (path === '/api/health' && request.method === 'GET') {
return jsonResponse({ ok: true, app: 'recordly' });
}
if (path === '/api/videos' && request.method === 'GET') {
return handleListVideos(env);
}
if (path === '/api/upload' && request.method === 'POST') {
return handleUpload(request, env);
}
const uploadDataMatch = path.match(/^\/api\/upload-data\/([a-z0-9]+)$/);
if (uploadDataMatch && request.method === 'PUT') {
return handleUploadData(request, env, uploadDataMatch[1]);
}
const thumbnailMatch = path.match(/^\/api\/upload-thumbnail\/([a-z0-9]+)$/);
if (thumbnailMatch && request.method === 'PUT') {
return handleUploadThumbnail(request, env, thumbnailMatch[1]);
}
const multipartStartMatch = path.match(/^\/api\/upload-multipart\/([a-z0-9]+)$/);
if (multipartStartMatch && request.method === 'POST') {
return handleMultipartStart(request, env, multipartStartMatch[1]);
}
const multipartPartMatch = path.match(/^\/api\/upload-part\/([a-z0-9]+)\/(.+?)\/(\d+)$/);
if (multipartPartMatch && request.method === 'PUT') {
const multipartUploadId = decodeUploadId(multipartPartMatch[2]);
if (!multipartUploadId) return errorResponse('Invalid multipart upload ID');
return handleMultipartPart(request, env, multipartPartMatch[1], multipartUploadId, parseInt(multipartPartMatch[3], 10));
}
const multipartCompleteMatch = path.match(/^\/api\/upload-complete\/([a-z0-9]+)\/(.+)$/);
if (multipartCompleteMatch && request.method === 'POST') {
const multipartUploadId = decodeUploadId(multipartCompleteMatch[2]);
if (!multipartUploadId) return errorResponse('Invalid multipart upload ID');
return handleMultipartComplete(request, env, multipartCompleteMatch[1], multipartUploadId);
}
const multipartAbortMatch = path.match(/^\/api\/upload-abort\/([a-z0-9]+)\/(.+)$/);
if (multipartAbortMatch && request.method === 'POST') {
const multipartUploadId = decodeUploadId(multipartAbortMatch[2]);
if (!multipartUploadId) return errorResponse('Invalid multipart upload ID');
return handleMultipartAbort(request, env, multipartAbortMatch[1], multipartUploadId);
}
const metadataMatch = path.match(/^\/api\/metadata\/([a-z0-9]+)$/);
if (metadataMatch && request.method === 'POST') {
return handleMetadata(request, env, metadataMatch[1]);
}
const renewMatch = path.match(/^\/api\/renew\/([a-z0-9]+)$/);
if (renewMatch && request.method === 'POST') {
return handleRenew(env, renewMatch[1]);
}
const deleteMatch = path.match(/^\/api\/delete\/([a-z0-9]+)$/);
if (deleteMatch && request.method === 'DELETE') {
return handleDelete(env, deleteMatch[1]);
}
if (path === '/api/check-views' && request.method === 'POST') {
return handleCheckViews(request, env);
}
return errorResponse('Not found', 404);
}
// CORS preflight for public endpoints
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
// Password verification (public)
const verifyMatch = path.match(/^\/s\/([a-z0-9]+)\/verify-password$/);
if (verifyMatch && request.method === 'POST') {
return handleVerifyPassword(request, env, verifyMatch[1]);
}
// Share data endpoint (public, for SPA)
const dataMatch = path.match(/^\/s\/([a-z0-9]+)\/data$/);
if (dataMatch && request.method === 'GET') {
return handleShareData(request, env, dataMatch[1]);
}
// Reactions (public)
const reactMatch = path.match(/^\/s\/([a-z0-9]+)\/react$/);
if (reactMatch && request.method === 'POST') {
return handleReact(request, env, reactMatch[1]);
}
const reactGetMatch = path.match(/^\/s\/([a-z0-9]+)\/reactions$/);
if (reactGetMatch && request.method === 'GET') {
return handleGetReactions(request, env, reactGetMatch[1]);
}
// Comments (public)
const commentMatch = path.match(/^\/s\/([a-z0-9]+)\/comment$/);
if (commentMatch && request.method === 'POST') {
return handleComment(request, env, commentMatch[1]);
}
const commentGetMatch = path.match(/^\/s\/([a-z0-9]+)\/comments$/);
if (commentGetMatch && request.method === 'GET') {
return handleGetComments(request, env, commentGetMatch[1]);
}
// VTT captions
const vttMatch = path.match(/^\/vtt\/([a-z0-9]+)$/);
if (vttMatch && request.method === 'GET') {
return handleVTT(request, env, vttMatch[1]);
}
// Share page — serve Astro SPA or OG for bots
const shareMatch = path.match(/^\/s\/([a-z0-9]+)$/);
if (shareMatch && request.method === 'GET') {
const shareCode = shareMatch[1];
const ua = request.headers.get('User-Agent') || '';
if (/bot|crawl|spider|facebook|twitter|slack|discord|telegram|whatsapp|linkedin/i.test(ua)) {
return handleOGPage(request, env, shareCode);
}
return env.ASSETS.fetch(new Request(new URL('/share', url), request));
}
// Video streaming
const videoMatch = path.match(/^\/v\/([a-z0-9]+)$/);
if (videoMatch && request.method === 'GET') {
return handleVideoStream(request, env, videoMatch[1]);
}
// OG image
const ogMatch = path.match(/^\/og\/([a-z0-9]+)$/);
if (ogMatch && request.method === 'GET') {
return handleOGImage(env, ogMatch[1]);
}
// Embed player — serve Astro embed page
const embedMatch = path.match(/^\/embed\/([a-z0-9]+)$/);
if (embedMatch && request.method === 'GET') {
return env.ASSETS.fetch(new Request(new URL('/embed', url), request));
}
// Thumbnail (high-res poster) — gated like the OG image: the poster frame
// of a password-protected or expired video may itself be sensitive.
const thumbMatch = path.match(/^\/thumb\/([a-z0-9]+)$/);
if (thumbMatch && request.method === 'GET') {
const video = await env.DB.prepare(
"SELECT password_hash, expires_at FROM videos WHERE share_code = ? AND datetime(expires_at) > datetime('now')"
).bind(thumbMatch[1]).first();
if (!video) return new Response('Not found', { status: 404 });
if (!(await verifyPasswordAuth(request, env, thumbMatch[1], video))) {
return new Response('Not found', { status: 404 });
}
const thumb = await env.VIDEOS_BUCKET.get(`thumbnails/${thumbMatch[1]}.jpg`);
if (thumb) {
return new Response(thumb.body, {
// Recheck the unlock cookie on every protected poster request.
headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': video.password_hash ? 'private, no-store' : 'public, max-age=86400' },
});
}
return new Response('Not found', { status: 404 });
}
// Static assets from R2
if (path === '/icon-64.png' && request.method === 'GET') {
const obj = await env.VIDEOS_BUCKET.get('static/icon-64.png');
if (obj) {
return new Response(obj.body, {
headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=604800' },
});
}
}
if (path === '/') {
return new Response('Recordly Share', { status: 200 });
}
// Fall through to static assets
return env.ASSETS.fetch(request);
}
@@ -0,0 +1,205 @@
// Adapted from Voom (MIT), Copyright (c) 2026 Aritro Paul.
// See ../../LICENSE and ../../../../THIRD_PARTY_NOTICES.md for attribution.
// --- Self-bootstrap: runtime schema migration ---
// When deployed via the "Deploy to Cloudflare" button, D1 is auto-provisioned
// empty, so the worker migrates its own schema at runtime on first request.
// Authentication uses the API_SECRET set during deploy (dashboard or prompt).
const SCHEMA_VERSION = 3;
// Consolidated current schema (base schema.sql + migrations 0002-0007). All
// statements are idempotent, so this is safe on both fresh (button-deployed)
// and existing (token-deployed) databases.
const SCHEMA_STATEMENTS = [
`CREATE TABLE IF NOT EXISTS videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
share_code TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
duration REAL NOT NULL DEFAULT 0,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
has_webcam INTEGER NOT NULL DEFAULT 0,
file_size INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
upload_completed INTEGER NOT NULL DEFAULT 0,
view_count INTEGER NOT NULL DEFAULT 0,
password_hash TEXT,
cta_url TEXT,
cta_text TEXT,
last_notified_view_count INTEGER NOT NULL DEFAULT 0,
is_meeting INTEGER NOT NULL DEFAULT 0,
summary TEXT,
password_salt TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_videos_share_code ON videos(share_code)`,
`CREATE INDEX IF NOT EXISTS idx_videos_expires_at ON videos(expires_at)`,
`CREATE TABLE IF NOT EXISTS transcript_segments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
start_time REAL NOT NULL,
end_time REAL NOT NULL,
text TEXT NOT NULL,
speaker TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_transcript_video_id ON transcript_segments(video_id)`,
`CREATE TABLE IF NOT EXISTS reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
timestamp REAL NOT NULL,
emoji TEXT NOT NULL,
client_ip TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_reactions_video_id ON reactions(video_id)`,
`CREATE INDEX IF NOT EXISTS idx_reactions_ip ON reactions(video_id, client_ip)`,
`CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
timestamp REAL NOT NULL,
author_name TEXT NOT NULL DEFAULT 'Anonymous',
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
client_ip TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_comments_video_id ON comments(video_id)`,
`CREATE TABLE IF NOT EXISTS chapters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
timestamp REAL NOT NULL,
title TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_chapters_video_id ON chapters(video_id)`,
`CREATE TABLE IF NOT EXISTS password_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id INTEGER NOT NULL,
client_ip TEXT NOT NULL,
attempted_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_password_attempts ON password_attempts(video_id, client_ip, attempted_at)`,
`CREATE TABLE IF NOT EXISTS comment_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS comment_sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES comment_users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_comment_sessions_user ON comment_sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_comment_sessions_expiry ON comment_sessions(expires_at)`,
];
// Incremental migrations for databases that are already at an older version.
// SCHEMA_STATEMENTS only covers fresh databases (CREATE TABLE IF NOT EXISTS
// cannot add columns to existing tables), so any change that ALTERs an
// existing table MUST appear here under a bumped SCHEMA_VERSION.
const SCHEMA_MIGRATIONS = {
2: [
`CREATE INDEX IF NOT EXISTS idx_chapters_video_id ON chapters(video_id)`,
`ALTER TABLE videos ADD COLUMN password_salt TEXT`,
`CREATE TABLE IF NOT EXISTS password_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id INTEGER NOT NULL,
client_ip TEXT NOT NULL,
attempted_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_password_attempts ON password_attempts(video_id, client_ip, attempted_at)`,
],
3: [
`CREATE TABLE IF NOT EXISTS comment_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS comment_sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES comment_users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_comment_sessions_user ON comment_sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_comment_sessions_expiry ON comment_sessions(expires_at)`,
],
};
let schemaReady = false;
// Test-only: lets the vitest suite force ensureSchema to re-run after it
// rebuilds the database into an older shape. Never called in production.
export function __resetSchemaCacheForTests() {
schemaReady = false;
}
export async function ensureSchema(env) {
if (schemaReady) return;
await env.DB.prepare(`CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)`).run();
const row = await env.DB.prepare(`SELECT value FROM _meta WHERE key = 'schema_version'`).first();
let current = row ? parseInt(row.value, 10) : 0;
if (current === 0) {
// No version stamp ≠ fresh: databases created before versioning existed
// have tables but no _meta row. Treating one as fresh would skip the
// ALTERs (CREATE TABLE IF NOT EXISTS no-ops on existing tables).
const videos = await env.DB.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'videos'`
).first();
if (videos) current = 1;
}
if (current >= 2) {
// Repair pass: v4.1.0 stamped pre-versioning databases as current without
// running the v2 ALTERs. If anything v2 introduced is missing, rewind so
// the migration loop below re-runs (its statements tolerate re-application).
const cols = await env.DB.prepare(`PRAGMA table_info(videos)`).all();
const attempts = await env.DB.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'password_attempts'`
).first();
if (!(cols.results || []).some(c => c.name === 'password_salt') || !attempts) current = 1;
}
if (current === 3) {
const commentUsers = await env.DB.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'comment_users'`
).first();
const commentSessions = await env.DB.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'comment_sessions'`
).first();
if (!commentUsers || !commentSessions) current = 2;
}
if (current < SCHEMA_VERSION) {
if (current === 0) {
// Fresh database: the consolidated schema already reflects every migration.
await env.DB.batch(SCHEMA_STATEMENTS.map(sql => env.DB.prepare(sql)));
} else {
// Existing database: apply each migration step in order. ALTER TABLE
// is not idempotent, so tolerate duplicate-column errors from re-runs.
for (let v = current + 1; v <= SCHEMA_VERSION; v++) {
for (const sql of SCHEMA_MIGRATIONS[v] || []) {
try {
await env.DB.prepare(sql).run();
} catch (e) {
if (!/duplicate column|already exists/i.test(e.message || '')) throw e;
}
}
}
}
await env.DB.prepare(
`INSERT INTO _meta (key, value) VALUES ('schema_version', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
).bind(String(SCHEMA_VERSION)).run();
}
schemaReady = true;
}
@@ -0,0 +1,203 @@
// Adapted from Voom (MIT), Copyright (c) 2026 Aritro Paul.
// See ../../LICENSE and ../../../../THIRD_PARTY_NOTICES.md for attribution.
import { errorResponse, jsonResponse } from './http.js';
import { EXPIRY_DAYS, finiteNonnegative } from './video.js';
import { generateSalt, sha256Hex } from './crypto.js';
const SHARE_CODE_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789';
const SHARE_CODE_LENGTH = 10;
export function generateShareCode() {
const bytes = new Uint8Array(SHARE_CODE_LENGTH);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => SHARE_CODE_CHARS[b % SHARE_CODE_CHARS.length]).join('');
}
export async function handleUpload(request, env) {
const body = await request.json();
const { title, duration, width, height, hasWebcam, fileSize, password_hash, cta_url, cta_text } = body;
if (!title) return errorResponse('title is required');
if (password_hash && !env.API_SECRET) return errorResponse('Password protection is not configured', 503);
// CTA links render as <a href> on the share page — only allow web URLs so a
// stored javascript:/data: URL can never reach that sink.
if (cta_url && !/^https?:\/\//i.test(cta_url)) {
return errorResponse('cta_url must be an http(s) URL');
}
const shareCode = generateShareCode();
const expiresAt = new Date(Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000).toISOString();
// Store password hashes salted: hash = SHA256(salt + clientHash). The client
// sends SHA256(password) so the raw password never leaves the user's machine;
// salting server-side makes a leaked D1 dump useless against rainbow tables.
let storedHash = null;
let salt = null;
if (password_hash) {
salt = generateSalt();
storedHash = await sha256Hex(salt + password_hash);
}
await env.DB.prepare(
`INSERT INTO videos (share_code, title, duration, width, height, has_webcam, file_size, expires_at, password_hash, password_salt, cta_url, cta_text)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(shareCode, title, finiteNonnegative(duration), finiteNonnegative(width), finiteNonnegative(height), hasWebcam ? 1 : 0, finiteNonnegative(fileSize), expiresAt, storedHash, salt, cta_url || null, cta_text || null)
.run();
const baseUrl = new URL(request.url).origin;
return jsonResponse({
shareCode,
uploadURL: `${baseUrl}/api/upload-data/${shareCode}`,
shareURL: `${baseUrl}/s/${shareCode}`,
expiresAt,
});
}
export async function handleUploadData(request, env, shareCode) {
const video = await env.DB.prepare('SELECT id FROM videos WHERE share_code = ?').bind(shareCode).first();
if (!video) return errorResponse('Video not found', 404);
const contentType = request.headers.get('Content-Type') || 'video/mp4';
await env.VIDEOS_BUCKET.put(`videos/${shareCode}.mp4`, request.body, {
httpMetadata: { contentType },
});
return jsonResponse({ ok: true });
}
export async function handleUploadThumbnail(request, env, shareCode) {
const video = await env.DB.prepare('SELECT id FROM videos WHERE share_code = ?').bind(shareCode).first();
if (!video) return errorResponse('Video not found', 404);
const contentType = request.headers.get('Content-Type') || 'image/jpeg';
await env.VIDEOS_BUCKET.put(`thumbnails/${shareCode}.jpg`, request.body, {
httpMetadata: { contentType },
});
return jsonResponse({ ok: true });
}
export async function handleMultipartStart(request, env, shareCode) {
const video = await env.DB.prepare('SELECT id FROM videos WHERE share_code = ?').bind(shareCode).first();
if (!video) return errorResponse('Video not found', 404);
const key = `videos/${shareCode}.mp4`;
const multipartUpload = await env.VIDEOS_BUCKET.createMultipartUpload(key, {
httpMetadata: { contentType: 'video/mp4' },
});
return jsonResponse({ uploadId: multipartUpload.uploadId });
}
export function decodeUploadId(value) {
try {
const decoded = decodeURIComponent(value);
return decoded && decoded.length <= 2048 ? decoded : null;
} catch {
return null;
}
}
export async function handleMultipartPart(request, env, shareCode, uploadId, partNumber) {
const video = await env.DB.prepare('SELECT id FROM videos WHERE share_code = ?').bind(shareCode).first();
if (!video) return errorResponse('Video not found', 404);
if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > 10000) {
return errorResponse('Invalid multipart part number');
}
const key = `videos/${shareCode}.mp4`;
const multipartUpload = env.VIDEOS_BUCKET.resumeMultipartUpload(key, uploadId);
const part = await multipartUpload.uploadPart(partNumber, request.body);
return jsonResponse({ partNumber: part.partNumber, etag: part.etag });
}
export async function handleMultipartAbort(request, env, shareCode, uploadId) {
const key = `videos/${shareCode}.mp4`;
try {
const multipartUpload = env.VIDEOS_BUCKET.resumeMultipartUpload(key, uploadId);
await multipartUpload.abort();
} catch (_e) {
// Ignore errors — upload may already be completed or expired
}
return jsonResponse({ ok: true });
}
export async function handleMultipartComplete(request, env, shareCode, uploadId) {
const video = await env.DB.prepare('SELECT id FROM videos WHERE share_code = ?').bind(shareCode).first();
if (!video) return errorResponse('Video not found', 404);
const key = `videos/${shareCode}.mp4`;
const multipartUpload = env.VIDEOS_BUCKET.resumeMultipartUpload(key, uploadId);
const body = await request.json();
const parts = body.parts; // [{ partNumber, etag }, ...]
if (!Array.isArray(parts) || parts.length === 0 || parts.length > 10000) {
return errorResponse('Invalid multipart parts');
}
if (parts.some(part =>
!part ||
!Number.isInteger(part.partNumber) ||
part.partNumber < 1 ||
part.partNumber > 10000 ||
typeof part.etag !== 'string' ||
!part.etag
)) {
return errorResponse('Invalid multipart parts');
}
await multipartUpload.complete(parts);
return jsonResponse({ ok: true });
}
export async function handleMetadata(request, env, shareCode) {
const video = await env.DB.prepare('SELECT id FROM videos WHERE share_code = ?').bind(shareCode).first();
if (!video) return errorResponse('Video not found', 404);
const body = await request.json();
const { segments, title, summary, chapters, isMeeting } = body;
// Chunk inserts so a multi-hour transcript can't exceed D1 batch limits.
const BATCH_CHUNK = 100;
if (segments && segments.length > 0) {
const stmt = env.DB.prepare(
'INSERT INTO transcript_segments (video_id, start_time, end_time, text, speaker) VALUES (?, ?, ?, ?, ?)'
);
const batch = segments.map(seg => stmt.bind(video.id, seg.startTime, seg.endTime, seg.text, seg.speaker || null));
for (let i = 0; i < batch.length; i += BATCH_CHUNK) {
await env.DB.batch(batch.slice(i, i + BATCH_CHUNK));
}
}
if (chapters && chapters.length > 0) {
const stmt = env.DB.prepare(
'INSERT INTO chapters (video_id, timestamp, title) VALUES (?, ?, ?)'
);
const batch = chapters.map(ch => stmt.bind(video.id, ch.timestamp, ch.title));
for (let i = 0; i < batch.length; i += BATCH_CHUNK) {
await env.DB.batch(batch.slice(i, i + BATCH_CHUNK));
}
}
// Update title, summary, is_meeting, and mark upload complete
const updateFields = ['upload_completed = 1'];
const updateBinds = [];
if (title) { updateFields.push('title = ?'); updateBinds.push(title); }
if (summary !== undefined) { updateFields.push('summary = ?'); updateBinds.push(summary || null); }
if (isMeeting !== undefined) { updateFields.push('is_meeting = ?'); updateBinds.push(isMeeting ? 1 : 0); }
updateBinds.push(shareCode);
await env.DB.prepare(
`UPDATE videos SET ${updateFields.join(', ')} WHERE share_code = ?`
).bind(...updateBinds).run();
return jsonResponse({ ok: true });
}
@@ -0,0 +1,10 @@
// Adapted from Voom (MIT), Copyright (c) 2026 Aritro Paul.
// See ../../LICENSE and ../../../../THIRD_PARTY_NOTICES.md for attribution.
export const EXPIRY_DAYS = 30;
export function finiteNonnegative(value) {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : 0;
}