mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 06:46:09 +00:00
fix: validate cloud password setup and stabilize comment pagination
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user