diff --git a/services/recordly-share/worker/.env.example b/services/recordly-share/worker/.env.example index 941fbcd2..92048e4f 100644 --- a/services/recordly-share/worker/.env.example +++ b/services/recordly-share/worker/.env.example @@ -1,5 +1,6 @@ # Cloudflare Worker configuration -# Copy this to wrangler.toml and fill in your values +# Copy this to .dev.vars for local development, or configure variables/secrets in Cloudflare. +# Non-secret bindings (including the D1 database ID) belong in wrangler.jsonc. # D1 database ID (from `npx wrangler d1 create recordly-share-db`) D1_DATABASE_ID= diff --git a/services/recordly-share/worker/src/index.js b/services/recordly-share/worker/src/index.js index 0eda654b..754c3f7d 100644 --- a/services/recordly-share/worker/src/index.js +++ b/services/recordly-share/worker/src/index.js @@ -426,6 +426,7 @@ async function generateAuthToken(shareCode, expiresAt, apiSecret) { 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; @@ -820,6 +821,7 @@ async function handleUpload(request, env) { 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 on the share page — only allow web URLs so a // stored javascript:/data: URL can never reach that sink. @@ -1315,6 +1317,7 @@ async function handleVerifyPassword(request, env, shareCode) { ).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'; @@ -1448,11 +1451,11 @@ async function handleComment(request, env, shareCode) { ).bind(video.id, clientIP).first(); if (recent && recent.cnt >= 5) return errorResponse('Rate limit exceeded', 429); - await env.DB.prepare( + 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 }); + return jsonResponse({ ok: true, id: inserted.meta.last_row_id }); } async function handleGetComments(request, env, shareCode) { @@ -1478,7 +1481,7 @@ async function handleGetComments(request, env, shareCode) { ).bind(video.id).first(); const comments = await env.DB.prepare( - 'SELECT timestamp, author_name, text, created_at FROM comments WHERE video_id = ? ORDER BY timestamp ASC LIMIT ? OFFSET ?' + '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({ diff --git a/services/recordly-share/worker/test/api.test.js b/services/recordly-share/worker/test/api.test.js index 491dd051..cd137558 100644 --- a/services/recordly-share/worker/test/api.test.js +++ b/services/recordly-share/worker/test/api.test.js @@ -505,3 +505,42 @@ it('normalizes invalid comment pagination and clamps zero limits', async () => { expect(body.comments).toEqual([]); } }); + +it('fails password verification gracefully when its signing secret is absent', async () => { + const { shareCode } = await createShare({ password_hash: await sha256Hex('secret') }); + await completeUpload(shareCode); + const response = await worker.fetch(new Request(`${BASE}/s/${shareCode}/verify-password`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: 'secret' }), + }), { ...env, API_SECRET: '' }, {}); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ error: 'Password protection is not configured' }); +}); + +it('returns stable comment IDs and orders equal timestamps by ID', async () => { + const { shareCode } = await createShare(); + await completeUpload(shareCode); + const ids = []; + for (const text of ['first', 'second']) { + const response = await SELF.fetch(`${BASE}/s/${shareCode}/comment`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ timestamp: 1, author_name: 'Viewer', text }), + }); + expect(response.status).toBe(200); + ids.push((await response.json()).id); + } + const response = await SELF.fetch(`${BASE}/s/${shareCode}/comments`); + expect((await response.json()).comments.map((comment) => comment.id)).toEqual(ids); + expect(ids[1]).toBeGreaterThan(ids[0]); +}); + +it('rejects creating protected shares without a signing secret', async () => { + const lookup = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ id: 'owner' }))); + try { + const response = await worker.fetch(new Request(`${BASE}/api/upload`, { + method: 'POST', headers: { Authorization: 'Bearer owner-token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Protected', password_hash: 'hash' }), + }), { ...env, API_SECRET: '', ALLOW_API_SECRET_UPLOADS: 'false', SUPABASE_URL: 'https://auth.example.test', SUPABASE_PUBLISHABLE_KEY: 'key', OWNER_USER_ID: 'owner' }, {}); + expect(response.status).toBe(503); + await response.arrayBuffer(); + } finally { lookup.mockRestore(); } +}); diff --git a/services/recordly-share/worker/web/src/components/ShareFeedback.tsx b/services/recordly-share/worker/web/src/components/ShareFeedback.tsx index 80cdcd58..e3a96307 100644 --- a/services/recordly-share/worker/web/src/components/ShareFeedback.tsx +++ b/services/recordly-share/worker/web/src/components/ShareFeedback.tsx @@ -17,7 +17,7 @@ import { LinkSimpleIcon, PaperPlaneTiltIcon, } from '@phosphor-icons/react'; -import { useEffect, useState, type RefObject } from 'react'; +import { useEffect, useState, type Dispatch, type SetStateAction, type RefObject } from 'react'; import { fetchComments, formatDate, @@ -35,7 +35,7 @@ interface Props { time: number; duration: number; comments: Comment[]; - setComments: (comments: Comment[]) => void; + setComments: Dispatch>; seek: (time: number) => void; composerRef: RefObject; selectedTab: string; @@ -96,7 +96,7 @@ export default function ShareFeedback({ setMoreLoading(true); try { const result = await fetchComments(data.shareCode, page + 1); - setComments([...comments, ...result.comments].sort((a, b) => a.timestamp - b.timestamp)); + setComments((current) => [...new Map([...current, ...result.comments].map((comment) => [comment.id, comment])).values()].sort((a, b) => a.timestamp - b.timestamp || a.id - b.id)); setTotal(result.total); setPage(page + 1); } catch { @@ -112,12 +112,12 @@ export default function ShareFeedback({ setPosting(true); setError(''); try { - const ok = await postComment(data.shareCode, timestamp, name.trim(), text.trim()); - if (!ok) throw new Error('Could not post your comment. Please try again.'); - setComments( + const id = await postComment(data.shareCode, timestamp, name.trim(), text.trim()); + setComments((current) => [ - ...comments, + ...current, { + id, timestamp, author_name: name.trim(), text: text.trim(), @@ -190,7 +190,7 @@ export default function ShareFeedback({
diff --git a/services/recordly-share/worker/web/src/pages/embed.astro b/services/recordly-share/worker/web/src/pages/embed.astro index 7db8102d..c21d0647 100644 --- a/services/recordly-share/worker/web/src/pages/embed.astro +++ b/services/recordly-share/worker/web/src/pages/embed.astro @@ -2,7 +2,7 @@ --- - +