chore: clean up lint/type issues and tighten comment media URL validation

This commit is contained in:
Yusuf İpek
2026-02-23 16:06:43 +03:00
parent 609b33bbcb
commit d15e5192ac
19 changed files with 61 additions and 86 deletions
@@ -2,26 +2,12 @@ import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import {
ArrowLeft,
Globe,
Lock,
UserPlus,
} from 'lucide-react';
import { GuestGate } from '@/components/guest-gate';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { ProjectContentClient } from './project-content-client';
function VisibilityIcon({ visibility }: { visibility: string }) {
switch (visibility) {
case 'PUBLIC':
return <Globe className="h-3.5 w-3.5" />;
case 'INVITE':
return <UserPlus className="h-3.5 w-3.5" />;
default:
return <Lock className="h-3.5 w-3.5" />;
}
}
function formatDuration(seconds: number | null): string {
if (!seconds) return '0:00';
const totalSeconds = Math.floor(seconds);
@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { ArrowLeft, Copy, Check, Loader2, UserPlus, Trash2, Share2, Globe, Lock, Mail, X } from 'lucide-react';
import { ArrowLeft, Copy, Check, Loader2, UserPlus, Share2, Globe, Lock, Mail, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Bell, Send, Mail, CheckCircle2, AlertCircle, Loader2, ExternalLink, Globe } from 'lucide-react';
import { Bell, Send, Mail, CheckCircle2, AlertCircle, Loader2, Globe } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
+1 -1
View File
@@ -2,7 +2,7 @@ import { NextRequest } from 'next/server';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { checkRateLimit, getClientIp, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit';
import { apiErrors, successResponse, ErrorCode, withCacheControl } from '@/lib/api-response';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
export async function POST(request: NextRequest) {
try {
+2 -1
View File
@@ -107,7 +107,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
}
// Strip internal project data from response
const { version: _version, ...commentData } = comment;
const commentData = { ...comment } as Omit<typeof comment, 'version'> & { version?: unknown };
delete commentData.version;
const response = successResponse(commentData);
return withCacheControl(response, 'private, no-cache');
} catch (error) {
+6 -12
View File
@@ -1,12 +1,13 @@
import { NextRequest } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { validateOptionalUrl } from '@/lib/validation';
import { rateLimit } from '@/lib/rate-limit';
import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ versionId: string }> };
const SAFE_IMAGE_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
const SAFE_AUDIO_PATH = /^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
// GET /api/versions/[versionId]/comments
export async function GET(request: NextRequest, { params }: RouteParams) {
@@ -218,19 +219,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Guest name is required for guest comments');
}
// Validate voice URL uses safe scheme (allow internal /api/ paths)
if (voiceUrl && !voiceUrl.startsWith('/api/')) {
const voiceUrlError = validateOptionalUrl(voiceUrl, 'Voice URL');
if (voiceUrlError) {
return apiErrors.badRequest(voiceUrlError);
}
if (voiceUrl && !SAFE_AUDIO_PATH.test(voiceUrl)) {
return apiErrors.badRequest('Voice URL must reference an uploaded audio file');
}
if (imageUrl && !imageUrl.startsWith('/api/')) {
const imageUrlError = validateOptionalUrl(imageUrl, 'Image URL');
if (imageUrlError) {
return apiErrors.badRequest(imageUrlError);
}
if (imageUrl && !SAFE_IMAGE_PATH.test(imageUrl)) {
return apiErrors.badRequest('Image URL must reference an uploaded image file');
}
const comment = await db.comment.create({
+1 -5
View File
@@ -39,6 +39,7 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
const [color, setColor] = useState(DEFAULT_COLOR);
const [width, setWidth] = useState(DEFAULT_WIDTH);
const isDrawingRef = useRef(false);
void onConfirm;
// Expose getStrokes so parent can grab current drawing without confirm
useImperativeHandle(ref, () => ({
@@ -159,11 +160,6 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
setStrokes([]);
}, []);
const handleConfirm = useCallback(() => {
if (strokes.length === 0) return;
onConfirm?.(strokes);
}, [strokes, onConfirm]);
// View mode: click to dismiss
const handleViewClick = useCallback((e: React.MouseEvent) => {
if (mode === 'view') {
+9 -11
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, type ReactNode } from 'react';
import { useState, type ReactNode } from 'react';
import Link from 'next/link';
import { User } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -12,16 +12,14 @@ import { Input } from '@/components/ui/input';
* Only renders children after the guest confirms their name.
*/
export function GuestGate({ children }: { children: ReactNode }) {
const [guestName, setGuestName] = useState('');
const [confirmed, setConfirmed] = useState(false);
useEffect(() => {
const saved = localStorage.getItem('openframe_guest_name');
if (saved) {
setGuestName(saved);
setConfirmed(true);
}
}, []);
const [guestName, setGuestName] = useState<string>(() => {
if (typeof window === 'undefined') return '';
return localStorage.getItem('openframe_guest_name') ?? '';
});
const [confirmed, setConfirmed] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return Boolean(localStorage.getItem('openframe_guest_name'));
});
if (confirmed) {
return <>{children}</>;
+1 -1
View File
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/dropdown-menu';
export function ThemeToggle() {
const { setTheme, theme } = useTheme();
const { setTheme } = useTheme();
return (
<DropdownMenu>
+24 -25
View File
@@ -1,7 +1,6 @@
'use client';
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { List } from 'react-window';
import Hls, { type Level } from 'hls.js';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
@@ -23,7 +22,6 @@ import {
CheckCircle2,
Circle,
ChevronDown,
ChevronUp,
MoreVertical,
Plus,
Loader2,
@@ -80,7 +78,7 @@ import { cn } from '@/lib/utils';
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
import { AnnotationCanvas, type AnnotationStroke, type AnnotationCanvasHandle } from '@/components/annotation-canvas';
import { Linkify } from '@/components/linkify';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import * as tus from 'tus-js-client';
import { UploadCloud, FileVideo } from 'lucide-react';
@@ -340,7 +338,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [isEditingAnnotation, setIsEditingAnnotation] = useState(false);
const editAnnotationCanvasRef = useRef<AnnotationCanvasHandle>(null);
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null);
const [, setDeletingCommentId] = useState<string | null>(null);
const isMutatingRef = useRef(false);
const [previewImage, setPreviewImage] = useState<string | null>(null);
@@ -543,6 +541,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
video?.versions?.find((v) => v.isActive) ||
video?.versions?.[0];
}, [video?.versions, activeVersionId]);
const activeProviderId = activeVersion?.providerId;
const activeVersionDuration = activeVersion?.duration;
const isDownloadingVideo = activeDownloadTarget !== null;
@@ -679,7 +679,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}
}
fetchTags();
}, [projectId]);
}, [projectId, selectedTagId]);
// Load YouTube API immediately on component mount (async, non-blocking)
useEffect(() => {
@@ -703,9 +703,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}, [isApiLoaded]);
useEffect(() => {
if (!activeVersion) return;
const isYoutube = activeVersion.providerId === 'youtube';
const isBunny = activeVersion.providerId === 'bunny';
if (!activeProviderId) return;
const isYoutube = activeProviderId === 'youtube';
const isBunny = activeProviderId === 'bunny';
if (isYoutube && !isApiLoaded) return;
if (!isYoutube && !isBunny) return;
@@ -1041,16 +1041,16 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
bunnyRetryTimerRef.current = null;
}
};
}, [activeVersionId, embedUrl, isApiLoaded, video?.isAuthenticated, videoId]);
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, video?.isAuthenticated, videoId]);
// Save detected duration to DB if the version doesn't have one stored
useEffect(() => {
if (!videoDuration || !activeVersion || !propProjectId) return;
if (activeVersion.duration && activeVersion.duration > 0) return;
if (!videoDuration || !activeVersionId || !propProjectId) return;
if (activeVersionDuration && activeVersionDuration > 0) return;
const roundedDuration = Math.round(videoDuration);
// Fire-and-forget PATCH to save duration
fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions/${activeVersion.id}`, {
fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions/${activeVersionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ duration: roundedDuration }),
@@ -1062,11 +1062,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersion.id ? { ...v, duration: roundedDuration } : v
v.id === activeVersionId ? { ...v, duration: roundedDuration } : v
),
};
});
}, [videoDuration, activeVersion?.id, activeVersion?.duration, propProjectId, videoId]);
}, [videoDuration, activeVersionDuration, activeVersionId, propProjectId, videoId]);
// Load watch progress when video is loaded (authenticated users only)
const loadWatchProgress = useCallback(async (showPrompt = true) => {
@@ -1236,7 +1236,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setShowResumePrompt(false);
setSavedProgress(null);
}
}, [savedProgress, activeVersion?.providerId]);
}, [savedProgress]);
// Dismiss resume prompt
const handleDismissResume = useCallback(() => {
@@ -1510,7 +1510,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}
}, [isDragging, currentTime, handleSeekToTimestamp]);
const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }, imageData?: { url: string }) => {
const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => {
if (!voiceData && !imageBlob && !commentText.trim() && !annotationStrokes && !isAnnotating) return;
if (!activeVersion) return;
@@ -1625,7 +1625,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
});
toast.error('Failed to add comment');
}
} catch (err) {
} catch {
setVideo((prev) => {
if (!prev) return prev;
return {
@@ -1643,7 +1643,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsUploadingImage(false);
isMutatingRef.current = false;
}
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags, imageBlob, annotationStrokes, isAnnotating]);
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, currentUserName, selectedTagId, availableTags, imageBlob, annotationStrokes, isAnnotating]);
const handleImageSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>, isReply: boolean = false) => {
const file = e.target.files?.[0];
@@ -1870,7 +1870,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
clearInterval(recordingTimerRef.current);
}
};
}, []);
}, [stopVoiceTracking]);
const submitCommentWithMedia = useCallback(async () => {
if (!activeVersion) return;
@@ -1886,8 +1886,6 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
try {
let voiceData: { url: string; duration: number } | undefined;
let imageData: { url: string } | undefined;
if (audioBlob) {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
@@ -1897,7 +1895,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
voiceData = { url: uploadData.data.url, duration: recordingTime };
}
await handleAddComment(voiceData, imageData); // Image is uploaded inside handleAddComment for both text/image cases
await handleAddComment(voiceData); // Image is uploaded inside handleAddComment for both text/image cases
setAudioBlob(null);
setRecordingTime(0);
@@ -1958,7 +1956,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
});
toast.error('Failed to update comment');
}
} catch (err) {
} catch {
setVideo((prev) => {
if (!prev) return prev;
return {
@@ -2103,7 +2101,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
});
toast.error('Failed to add reply');
}
} catch (err) {
} catch {
setVideo((prev) => {
if (!prev) return prev;
return {
@@ -2128,7 +2126,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsUploadingReplyImage(false);
isMutatingRef.current = false;
}
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName, replyImageBlob]);
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName, currentUserName, replyImageBlob]);
const startReplyRecording = useCallback(async () => {
try {
@@ -4402,6 +4400,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
onClick={() => setPreviewImage(null)}
>
{previewImage && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewImage}
alt="Preview"
+1 -1
View File
@@ -126,7 +126,7 @@ export function successResponse<T>(
return NextResponse.json(body, { status });
}
export function withCacheControl<T>(response: Response, value: string): Response {
export function withCacheControl(response: Response, value: string): Response {
response.headers.set('Cache-Control', value);
return response;
}
+1 -1
View File
@@ -26,7 +26,7 @@ function createPool(connectionString: string): Pool {
const pool = new Pool(poolConfig);
// Add error handling for pool errors
pool.on('error', (err, client) => {
pool.on('error', (err) => {
console.error('Unexpected database pool error:', err.message);
// Don't crash the app on unexpected pool errors
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID!;
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID!;
+2 -3
View File
@@ -1,4 +1,4 @@
import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types';
import type { VideoProvider, VideoMetadata, EmbedOptions } from './types';
import { getCachedMetadata, setCachedMetadata } from './metadata-cache';
// Bunny Stream URL patterns
@@ -43,8 +43,7 @@ export const bunnyProvider: VideoProvider = {
return `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}?${params.toString()}`;
},
getThumbnailUrl(videoId: string, size: ThumbnailSize = 'medium'): string {
const libraryId = process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID || process.env.BUNNY_STREAM_LIBRARY_ID || '0';
getThumbnailUrl(videoId: string): string {
// Bunny stream thumbnails: https://vz-uuid.b-cdn.net/{videoId}/thumbnail.jpg
// Since we don't have the b-cdn pull zone readily available in pure abstract,
// we should rely on fetching metadata for actual thumbnails, OR construct via API
+3 -2
View File
@@ -1,4 +1,4 @@
import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types';
import type { VideoProvider, VideoMetadata, EmbedOptions } from './types';
// Direct video URL patterns (for future self-hosted videos)
const DIRECT_VIDEO_PATTERNS = [
@@ -49,7 +49,8 @@ export const directProvider: VideoProvider = {
return `${videoId}${queryString ? `#t=${options.startTime}` : ''}`;
},
getThumbnailUrl(videoId: string, size: ThumbnailSize = 'medium'): string {
getThumbnailUrl(videoId: string): string {
void videoId;
// For direct uploads, thumbnail would be generated server-side
// Return a placeholder for now
return '/placeholder-video-thumbnail.png';
+1 -1
View File
@@ -84,7 +84,7 @@ export const youtubeProvider: VideoProvider = {
setCachedMetadata(cacheKey, metadata);
return metadata;
} catch (error) {
} catch {
// Fallback with minimal data
const fallback: VideoMetadata = {
title: 'YouTube Video',
+2 -1
View File
@@ -7,8 +7,9 @@
"prebuild": "bun run typecheck",
"build": "next build",
"start": "next start",
"lint": "eslint",
"lint": "eslint --max-warnings=0",
"typecheck": "tsc --noEmit",
"check": "bun run lint && bun run typecheck",
"postinstall": "prisma generate",
"db:generate": "prisma generate",
"db:push": "prisma db push",
+3 -3
View File
@@ -121,7 +121,7 @@ async function main() {
},
});
const clientProject = await prisma.project.create({
await prisma.project.create({
data: {
name: 'Client: XYZ Corp Promo',
description: 'Promotional video for XYZ Corporation product launch.',
@@ -173,7 +173,7 @@ async function main() {
},
});
const aiToolsVideo = await prisma.video.create({
await prisma.video.create({
data: {
title: 'Best AI Tools for Developers 2025',
description: 'A curated list of AI tools that actually boost productivity.',
@@ -198,7 +198,7 @@ async function main() {
});
// Tutorial video
const reactVideo = await prisma.video.create({
await prisma.video.create({
data: {
title: 'React Server Components Deep Dive',
description: 'Understanding RSC from first principles.',
+1 -1
View File
@@ -1,4 +1,4 @@
import NextAuth, { type DefaultSession } from 'next-auth';
import { type DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface Session {