fix: validate cloud password setup and stabilize comment pagination

This commit is contained in:
webadderall
2026-09-21 18:28:25 +10:00
parent bdc403e3e7
commit 9f89a41a27
7 changed files with 61 additions and 15 deletions
+2 -1
View File
@@ -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=
+6 -3
View File
@@ -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 <a href> 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({
@@ -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(); }
});
@@ -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<SetStateAction<Comment[]>>;
seek: (time: number) => void;
composerRef: RefObject<HTMLTextAreaElement | null>;
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({
<article
className="comment-item"
data-active={i === activeIndex}
key={`${comment.created_at}-${i}`}
key={comment.id}
>
<div className="comment-heading">
<Avatar size="sm">
@@ -2,7 +2,7 @@
---
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
@@ -42,6 +42,7 @@ export interface Expired {
}
export interface Comment {
id: number;
timestamp: number;
author_name: string;
text: string;
@@ -121,14 +122,15 @@ export async function postComment(
timestamp: number,
authorName: string,
text: string
): Promise<boolean> {
): Promise<number> {
const res = await fetch(`/s/${shareCode}/comment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ timestamp, author_name: authorName, text }),
});
return res.ok;
if (!res.ok) throw new Error('Could not post comment');
return (await res.json()).id;
}
export async function fetchCommentUser(): Promise<CommentUser | null> {
@@ -3,6 +3,7 @@ import { test } from 'node:test';
import { clusterComments, clusterReactions, clusterTimeline, timestampParts } from './shareModel.ts';
const comment = (timestamp: number) => ({
id: timestamp,
timestamp,
author_name: 'Viewer',
text: 'Feedback',