From d15e5192acf7da86f77cef43a36a73de002c96f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Mon, 23 Feb 2026 16:06:43 +0300 Subject: [PATCH] chore: clean up lint/type issues and tighten comment media URL validation --- app/(dashboard)/projects/[projectId]/page.tsx | 14 ------ .../projects/[projectId]/share/page.tsx | 2 +- app/(dashboard)/settings/page.tsx | 2 +- app/api/auth/register/route.ts | 2 +- app/api/comments/[commentId]/route.ts | 3 +- .../versions/[versionId]/comments/route.ts | 18 +++---- components/annotation-canvas.tsx | 6 +-- components/guest-gate.tsx | 20 ++++---- components/theme-toggle.tsx | 2 +- components/video-page-content.tsx | 49 +++++++++---------- lib/api-response.ts | 2 +- lib/db.ts | 2 +- lib/r2.ts | 2 +- lib/video-providers/bunny.ts | 5 +- lib/video-providers/direct.ts | 5 +- lib/video-providers/youtube.ts | 2 +- package.json | 3 +- prisma/seed.ts | 6 +-- types/next-auth.d.ts | 2 +- 19 files changed, 61 insertions(+), 86 deletions(-) diff --git a/app/(dashboard)/projects/[projectId]/page.tsx b/app/(dashboard)/projects/[projectId]/page.tsx index 395a17c..5e01098 100644 --- a/app/(dashboard)/projects/[projectId]/page.tsx +++ b/app/(dashboard)/projects/[projectId]/page.tsx @@ -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 ; - case 'INVITE': - return ; - default: - return ; - } -} - function formatDuration(seconds: number | null): string { if (!seconds) return '0:00'; const totalSeconds = Math.floor(seconds); diff --git a/app/(dashboard)/projects/[projectId]/share/page.tsx b/app/(dashboard)/projects/[projectId]/share/page.tsx index b5a5a40..d704f46 100644 --- a/app/(dashboard)/projects/[projectId]/share/page.tsx +++ b/app/(dashboard)/projects/[projectId]/share/page.tsx @@ -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'; diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 6e4b920..05ea5e2 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -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'; diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 848f327..ffdcc7f 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -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 { diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts index 01e3225..85fa5d3 100644 --- a/app/api/comments/[commentId]/route.ts +++ b/app/api/comments/[commentId]/route.ts @@ -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 & { version?: unknown }; + delete commentData.version; const response = successResponse(commentData); return withCacheControl(response, 'private, no-cache'); } catch (error) { diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index cd65227..6ade886 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -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({ diff --git a/components/annotation-canvas.tsx b/components/annotation-canvas.tsx index 288d7e8..fdc145c 100644 --- a/components/annotation-canvas.tsx +++ b/components/annotation-canvas.tsx @@ -39,6 +39,7 @@ export const AnnotationCanvas = forwardRef ({ @@ -159,11 +160,6 @@ export const AnnotationCanvas = forwardRef { - if (strokes.length === 0) return; - onConfirm?.(strokes); - }, [strokes, onConfirm]); - // View mode: click to dismiss const handleViewClick = useCallback((e: React.MouseEvent) => { if (mode === 'view') { diff --git a/components/guest-gate.tsx b/components/guest-gate.tsx index 1112bcc..a13cbb7 100644 --- a/components/guest-gate.tsx +++ b/components/guest-gate.tsx @@ -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(() => { + if (typeof window === 'undefined') return ''; + return localStorage.getItem('openframe_guest_name') ?? ''; + }); + const [confirmed, setConfirmed] = useState(() => { + if (typeof window === 'undefined') return false; + return Boolean(localStorage.getItem('openframe_guest_name')); + }); if (confirmed) { return <>{children}; diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx index dcc49b1..b43c2c0 100644 --- a/components/theme-toggle.tsx +++ b/components/theme-toggle.tsx @@ -12,7 +12,7 @@ import { } from '@/components/ui/dropdown-menu'; export function ThemeToggle() { - const { setTheme, theme } = useTheme(); + const { setTheme } = useTheme(); return ( diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 4a3b8d1..cedaa95 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -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(null); const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); - const [deletingCommentId, setDeletingCommentId] = useState(null); + const [, setDeletingCommentId] = useState(null); const isMutatingRef = useRef(false); const [previewImage, setPreviewImage] = useState(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, 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 Preview( return NextResponse.json(body, { status }); } -export function withCacheControl(response: Response, value: string): Response { +export function withCacheControl(response: Response, value: string): Response { response.headers.set('Cache-Control', value); return response; } diff --git a/lib/db.ts b/lib/db.ts index b76649a..cb1bb3b 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -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 }); diff --git a/lib/r2.ts b/lib/r2.ts index 478f66d..11e6a65 100644 --- a/lib/r2.ts +++ b/lib/r2.ts @@ -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!; diff --git a/lib/video-providers/bunny.ts b/lib/video-providers/bunny.ts index c2d5b52..e879938 100644 --- a/lib/video-providers/bunny.ts +++ b/lib/video-providers/bunny.ts @@ -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 diff --git a/lib/video-providers/direct.ts b/lib/video-providers/direct.ts index 36706b2..f98cc8c 100644 --- a/lib/video-providers/direct.ts +++ b/lib/video-providers/direct.ts @@ -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'; diff --git a/lib/video-providers/youtube.ts b/lib/video-providers/youtube.ts index a02b989..e716d33 100644 --- a/lib/video-providers/youtube.ts +++ b/lib/video-providers/youtube.ts @@ -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', diff --git a/package.json b/package.json index 99f5131..f13cc6f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/seed.ts b/prisma/seed.ts index 55d587d..5b48174 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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.', diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index e418902..15f2881 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -1,4 +1,4 @@ -import NextAuth, { type DefaultSession } from 'next-auth'; +import { type DefaultSession } from 'next-auth'; declare module 'next-auth' { interface Session {