From 24aceb9bb302d876250c940a273fdcb596aa6f25 Mon Sep 17 00:00:00 2001 From: laraba9987-cmyk Date: Sat, 27 Jun 2026 19:08:19 +0100 Subject: [PATCH] fix: resolve multiple bugs - Remove hardcoded webhook fallback secret (#421) - Fix duplicate transaction tables in TransactionHistory (#423) - Add proper error logging to silent catch blocks (#425) - Create useSafeTimeout hook and fix uncleared timeouts (#426) Closes #421, #423, #425, #426 --- .env.example | 8 +++ src/app/api/revalidate/route.ts | 10 +++- src/app/widget/embed-code/page.tsx | 4 +- src/components/ComparisonBar.tsx | 4 +- src/components/TransactionHistory.tsx | 57 ------------------- src/components/TransactionMonitor.tsx | 6 +- src/components/ViewToggle.tsx | 18 ++---- .../dashboard/DataRefreshWrapper.tsx | 4 +- src/components/dashboard/PortfolioReport.tsx | 4 +- src/components/dashboard/StakingModal.tsx | 4 +- src/components/kyc/KycVerificationCenter.tsx | 4 +- src/components/referral/ClaimRewardsModal.tsx | 4 +- src/components/referral/ReferralLinksCard.tsx | 4 +- .../responsive/LazyLoadingExample.tsx | 7 ++- src/hooks/useSafeTimeout.ts | 26 +++++++++ src/utils/security/phishingProtection.ts | 6 +- src/utils/walletHelpers.ts | 1 + 17 files changed, 87 insertions(+), 84 deletions(-) create mode 100644 src/hooks/useSafeTimeout.ts diff --git a/.env.example b/.env.example index 4122728f..90622801 100644 --- a/.env.example +++ b/.env.example @@ -142,6 +142,14 @@ RATE_LIMIT_MAX_REQUESTS=100 # Maximum requests per wallet address per time window (default: 50) RATE_LIMIT_MAX_REQUESTS_PER_WALLET=50 +# ----------------------------------------------------------------------------- +# Webhook Configuration +# ----------------------------------------------------------------------------- + +# Secret used to sign and verify revalidation webhook requests +# This must be set in production to secure the /api/revalidate endpoint +REVALIDATE_WEBHOOK_SECRET=your-webhook-secret + # ----------------------------------------------------------------------------- # Secret Management (Production) # ----------------------------------------------------------------------------- diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index 732a3b76..df949198 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -3,11 +3,17 @@ import { NextRequest, NextResponse } from 'next/server'; import { revalidateProperty, revalidateAllProperties } from '@/lib/propertyServiceServer'; import crypto from 'crypto'; -// Webhook secret for security - should be stored in environment variables -const WEBHOOK_SECRET = process.env.REVALIDATE_WEBHOOK_SECRET || 'your-webhook-secret'; +const WEBHOOK_SECRET: string | undefined = process.env.REVALIDATE_WEBHOOK_SECRET; export async function POST(request: NextRequest) { try { + if (!WEBHOOK_SECRET) { + return NextResponse.json( + { error: 'REVALIDATE_WEBHOOK_SECRET is not configured' }, + { status: 500 } + ); + } + // Verify webhook signature for security const signature = request.headers.get('x-webhook-signature'); const body = await request.text(); diff --git a/src/app/widget/embed-code/page.tsx b/src/app/widget/embed-code/page.tsx index 4cd3476f..4ab0037d 100644 --- a/src/app/widget/embed-code/page.tsx +++ b/src/app/widget/embed-code/page.tsx @@ -3,6 +3,7 @@ import React, { useState, useCallback } from 'react'; import Link from 'next/link'; import { ArrowLeft, Copy, Check, Code, ExternalLink } from 'lucide-react'; +import { useSafeTimeout } from '@/hooks/useSafeTimeout'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; @@ -22,6 +23,7 @@ export default function WidgetEmbedCodePage() { const [ctaText, setCtaText] = useState('Invest on PropChain'); const [compact, setCompact] = useState(false); const [copied, setCopied] = useState(false); + const { setTimeoutSafe } = useSafeTimeout(); const widgetWidth = compact ? '400px' : '600px'; const widgetHeight = compact ? '450px' : '800px'; @@ -54,7 +56,7 @@ export default function WidgetEmbedCodePage() { const code = generateEmbedCode(); await navigator.clipboard.writeText(code); setCopied(true); - setTimeout(() => setCopied(false), 2000); + setTimeoutSafe(() => setCopied(false), 2000); }; return ( diff --git a/src/components/ComparisonBar.tsx b/src/components/ComparisonBar.tsx index 8ec44a73..4c431ca8 100644 --- a/src/components/ComparisonBar.tsx +++ b/src/components/ComparisonBar.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { Copy, ArrowRight, Trash2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useCompareStore } from '@/store/compareStore'; +import { useSafeTimeout } from '@/hooks/useSafeTimeout'; const MAX_COMPARE = 3; @@ -15,6 +16,7 @@ export const ComparisonBar = () => { const clearCompare = useCompareStore((state) => state.clearCompare); const [shareUrl, setShareUrl] = useState(''); const [copySuccess, setCopySuccess] = useState(false); + const { setTimeoutSafe } = useSafeTimeout(); useEffect(() => { if (typeof window === 'undefined') return; @@ -28,7 +30,7 @@ export const ComparisonBar = () => { try { await navigator.clipboard.writeText(shareUrl); setCopySuccess(true); - window.setTimeout(() => setCopySuccess(false), 2000); + setTimeoutSafe(() => setCopySuccess(false), 2000); } catch { setCopySuccess(false); } diff --git a/src/components/TransactionHistory.tsx b/src/components/TransactionHistory.tsx index 4503ef59..6ae333d2 100644 --- a/src/components/TransactionHistory.tsx +++ b/src/components/TransactionHistory.tsx @@ -492,63 +492,6 @@ export const TransactionHistory: React.FC = () => { )} - {/* Transaction Table */} - {isLoading ? ( - - ) : ( -
- - - - Hash - Type - Status - From - To - Time - - - - {rowsToRender.length === 0 ? ( - - - - - - ) : ( - rowsToRender.map((tx) => ( - - - {tx.hash.slice(0, 10)}…{tx.hash.slice(-8)} - - {tx.type} - {tx.status} - - {tx.from.slice(0, 10)}…{tx.from.slice(-8)} - - - {tx.to ? `${tx.to.slice(0, 10)}…${tx.to.slice(-8)}` : '-'} - - - {new Date(tx.timestamp).toLocaleString()} - - - )) - )} - -
-
- )} - {/* Transaction Table */}
diff --git a/src/components/TransactionMonitor.tsx b/src/components/TransactionMonitor.tsx index 5b33d35f..bf62d3cd 100644 --- a/src/components/TransactionMonitor.tsx +++ b/src/components/TransactionMonitor.tsx @@ -3,16 +3,18 @@ import React from 'react'; import { useTransactionStore } from '@/store/transactionStore'; import type { Transaction } from '@/store/transactionStore'; +import { useSafeTimeout } from '@/hooks/useSafeTimeout'; const TransactionWatcher = ({ transaction }: { transaction: Transaction }) => { const { updateTransaction } = useTransactionStore(); + const { setTimeoutSafe, clearTimeoutSafe } = useSafeTimeout(); // For demo purposes, we'll simulate transaction monitoring // In a real app, you'd use wagmi's useWaitForTransactionReceipt here React.useEffect(() => { if (transaction.status === 'pending') { // Simulate confirmation after 5 seconds for demo - const timer = setTimeout(() => { + const timer = setTimeoutSafe(() => { updateTransaction(transaction.id, { status: 'confirmed', gasUsed: '21000', @@ -20,7 +22,7 @@ const TransactionWatcher = ({ transaction }: { transaction: Transaction }) => { }); }, 5000); - return () => clearTimeout(timer); + return () => clearTimeoutSafe(timer); } return undefined; diff --git a/src/components/ViewToggle.tsx b/src/components/ViewToggle.tsx index 76d8ba2b..405e6c84 100644 --- a/src/components/ViewToggle.tsx +++ b/src/components/ViewToggle.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { logger } from '@/utils/logger'; /** * UI-only view mode for listing screens. @@ -30,9 +31,7 @@ export function useViewMode() { const stored = localStorage.getItem(STORAGE_KEY); if (isValidViewMode(stored)) return stored; } catch (err) { - // Swallow storage errors — fallback to default - // eslint-disable-next-line no-console - console.warn("useViewMode: localStorage unavailable, falling back to default view mode", err); + logger.warn("useViewMode: localStorage unavailable, falling back to default view mode", err); } return "grid"; @@ -41,8 +40,7 @@ export function useViewMode() { // Wrap setter to validate input before persisting const setMode = (v: ViewMode) => { if (!isValidViewMode(v)) { - // eslint-disable-next-line no-console - console.warn("useViewMode.setMode called with invalid mode:", v); + logger.warn("useViewMode.setMode called with invalid mode:", v); return; } setModeRaw(v); @@ -52,9 +50,7 @@ export function useViewMode() { try { localStorage.setItem(STORAGE_KEY, mode); } catch (err) { - // Storage might be disabled; log and continue without throwing - // eslint-disable-next-line no-console - console.warn("useViewMode: failed to persist mode to localStorage", err); + logger.warn("useViewMode: failed to persist mode to localStorage", err); } }, [mode]); @@ -80,11 +76,9 @@ export function ViewToggle({ mode, onChange }: ViewToggleProps) { if (!isValidViewMode(v)) return; try { if (typeof onChange === "function") onChange(v); - else console.warn("ViewToggle: onChange is not a function", onChange); + else logger.warn("ViewToggle: onChange is not a function", onChange); } catch (err) { - // Avoid bubbling UI errors — log instead - // eslint-disable-next-line no-console - console.error("ViewToggle: onChange handler threw an error", err); + logger.error("ViewToggle: onChange handler threw an error", err); } }; diff --git a/src/components/dashboard/DataRefreshWrapper.tsx b/src/components/dashboard/DataRefreshWrapper.tsx index a3c52424..ab62d68c 100644 --- a/src/components/dashboard/DataRefreshWrapper.tsx +++ b/src/components/dashboard/DataRefreshWrapper.tsx @@ -4,6 +4,7 @@ import { motion, AnimatePresence } from "framer-motion"; import { RefreshCw, AlertCircle, CheckCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import { useSafeTimeout } from '@/hooks/useSafeTimeout'; interface DataRefreshWrapperProps { children: ReactNode; @@ -21,6 +22,7 @@ export const DataRefreshWrapper = ({ }: DataRefreshWrapperProps) => { const [refreshState, setRefreshState] = useState("idle"); const [error, setError] = useState(null); + const { setTimeoutSafe } = useSafeTimeout(); const handleRefresh = useCallback(async () => { setRefreshState("loading"); @@ -44,7 +46,7 @@ export const DataRefreshWrapper = ({ } setRefreshState("success"); - setTimeout(() => setRefreshState("idle"), 2000); + setTimeoutSafe(() => setRefreshState("idle"), 2000); } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); setRefreshState("error"); diff --git a/src/components/dashboard/PortfolioReport.tsx b/src/components/dashboard/PortfolioReport.tsx index 68dbe200..43ee3c6a 100644 --- a/src/components/dashboard/PortfolioReport.tsx +++ b/src/components/dashboard/PortfolioReport.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import jsPDF from "jspdf"; import autoTable from "jspdf-autotable"; +import { useSafeTimeout } from '@/hooks/useSafeTimeout'; type ReportType = "full" | "tax" | "performance" | "transactions"; @@ -81,6 +82,7 @@ export const PortfolioReport = () => { const [year, setYear] = useState("2024"); const [isGenerating, setIsGenerating] = useState(false); const [isGenerated, setIsGenerated] = useState(false); + const { setTimeoutSafe } = useSafeTimeout(); const generatePDF = async () => { setIsGenerating(true); @@ -185,7 +187,7 @@ export const PortfolioReport = () => { setIsGenerating(false); setIsGenerated(true); - setTimeout(() => setIsGenerated(false), 3000); + setTimeoutSafe(() => setIsGenerated(false), 3000); }; return ( diff --git a/src/components/dashboard/StakingModal.tsx b/src/components/dashboard/StakingModal.tsx index aa6a4b0c..d7c794e6 100644 --- a/src/components/dashboard/StakingModal.tsx +++ b/src/components/dashboard/StakingModal.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useState, useEffect } from 'react'; +import { useSafeTimeout } from '@/hooks/useSafeTimeout'; import { X, ShieldCheck, @@ -57,6 +58,7 @@ export const StakingModal: React.FC = ({ const [lockPeriod, setLockPeriod] = useState(3); // months const [isProcessing, setIsProcessing] = useState(false); const [selectedToken, setSelectedToken] = useState(token || mockAvailableTokens[0]); + const { setTimeoutSafe } = useSafeTimeout(); useEffect(() => { if (isOpen) { @@ -89,7 +91,7 @@ export const StakingModal: React.FC = ({ const handleClose = () => { onClose(); // Small delay to reset state after animation - setTimeout(() => setStep('input'), 300); + setTimeoutSafe(() => setStep('input'), 300); }; const apyMultiplier = 1 + (lockPeriod / 12) * 0.5; // Simple mock formula diff --git a/src/components/kyc/KycVerificationCenter.tsx b/src/components/kyc/KycVerificationCenter.tsx index c62c37a1..eb367d43 100644 --- a/src/components/kyc/KycVerificationCenter.tsx +++ b/src/components/kyc/KycVerificationCenter.tsx @@ -2,6 +2,7 @@ import { useMemo, useState, type ChangeEvent } from 'react'; import { useRouter } from 'next/navigation'; +import { useSafeTimeout } from '@/hooks/useSafeTimeout'; import { BadgeCheck, FileUp, ScanFace, ShieldAlert, ShieldCheck, UploadCloud } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -20,6 +21,7 @@ export function KycVerificationCenter() { useKycStore(); const [thresholdDraft, setThresholdDraft] = useState(String(profile.thresholdEth)); const [notes, setNotes] = useState(''); + const { setTimeoutSafe } = useSafeTimeout(); const completion = useMemo(() => { const steps = [ @@ -48,7 +50,7 @@ export function KycVerificationCenter() { const handleLiveness = async () => { startLivenessCheck(); - window.setTimeout(() => { + setTimeoutSafe(() => { completeLivenessCheck(true); }, 1200); }; diff --git a/src/components/referral/ClaimRewardsModal.tsx b/src/components/referral/ClaimRewardsModal.tsx index f4d928e8..0e12ca78 100644 --- a/src/components/referral/ClaimRewardsModal.tsx +++ b/src/components/referral/ClaimRewardsModal.tsx @@ -6,6 +6,7 @@ import { useReferralStore, useReferralStats } from '@/store/referralStore'; import { referralService } from '@/lib/referralService'; import { createWalletAddress } from '@/types/referral'; import { formatUnits } from 'viem'; +import { useSafeTimeout } from '@/hooks/useSafeTimeout'; const SUPPORTED_CHAIN_NAMES: Record = { 1: 'Ethereum', @@ -37,6 +38,7 @@ export default function ClaimRewardsModal({ const [step, setStep] = useState<'confirm' | 'claiming' | 'success' | 'error'>( 'confirm' ); + const { setTimeoutSafe } = useSafeTimeout(); const { setIsClaimingRewards, setNotification } = useReferralStore(); const stats = useReferralStats(); @@ -81,7 +83,7 @@ export default function ClaimRewardsModal({ setNotification('Rewards claimed successfully!', 'success'); // Close modal after 3 seconds - setTimeout(onClose, 3000); + setTimeoutSafe(onClose, 3000); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to claim rewards'; diff --git a/src/components/referral/ReferralLinksCard.tsx b/src/components/referral/ReferralLinksCard.tsx index 06e2e045..be884ec3 100644 --- a/src/components/referral/ReferralLinksCard.tsx +++ b/src/components/referral/ReferralLinksCard.tsx @@ -12,6 +12,7 @@ import { createWalletAddress } from '@/types/referral'; import CopyButton from './CopyButton'; import ShareButton from './ShareButton'; import CreateReferralLinkModal from './CreateReferralLinkModal'; +import { useSafeTimeout } from '@/hooks/useSafeTimeout'; export interface ReferralLinksCardProps { maxLinksToShow?: number; @@ -25,12 +26,13 @@ export default function ReferralLinksCard({ const [showCreateModal, setShowCreateModal] = useState(false); const [copiedCode, setCopiedCode] = useState(null); const [loadingShortUrl, setLoadingShortUrl] = useState(null); + const { setTimeoutSafe } = useSafeTimeout(); const handleCopy = useCallback( (code: string, url: string) => { navigator.clipboard.writeText(url); setCopiedCode(code); - setTimeout(() => setCopiedCode(null), 2000); + setTimeoutSafe(() => setCopiedCode(null), 2000); }, [] ); diff --git a/src/components/responsive/LazyLoadingExample.tsx b/src/components/responsive/LazyLoadingExample.tsx index c036bb53..535e2f68 100644 --- a/src/components/responsive/LazyLoadingExample.tsx +++ b/src/components/responsive/LazyLoadingExample.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { ImagePlaceholder, SkeletonImage, Skeleton } from './ImagePlaceholder'; import { setupLazyLoading, preloadCriticalResources } from '@/lib/mobile-optimizer'; +import { useSafeTimeout } from '@/hooks/useSafeTimeout'; /** * Lazy Loading System Examples @@ -169,10 +170,11 @@ export const CustomPlaceholderExample: React.FC = () => { */ export const ContentSkeletonExample: React.FC = () => { const [isLoading, setIsLoading] = React.useState(true); + const { setTimeoutSafe } = useSafeTimeout(); React.useEffect(() => { // Simulate data loading - setTimeout(() => setIsLoading(false), 2000); + setTimeoutSafe(() => setIsLoading(false), 2000); }, []); if (isLoading) { @@ -210,13 +212,14 @@ export const ContentSkeletonExample: React.FC = () => { export const PropertyListingExample: React.FC = () => { const [properties, setProperties] = React.useState([]); const [isLoading, setIsLoading] = React.useState(true); + const { setTimeoutSafe } = useSafeTimeout(); React.useEffect(() => { // Preload hero image preloadCriticalResources(['/images/hero-property.jpg']); // Simulate API call - setTimeout(() => { + setTimeoutSafe(() => { setProperties([ { id: 1, image: '/images/property-1.jpg', title: 'Modern Villa', price: '$850,000' }, { id: 2, image: '/images/property-2.jpg', title: 'Cozy Cottage', price: '$450,000' }, diff --git a/src/hooks/useSafeTimeout.ts b/src/hooks/useSafeTimeout.ts new file mode 100644 index 00000000..654cc3bc --- /dev/null +++ b/src/hooks/useSafeTimeout.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef, useCallback } from 'react'; + +export function useSafeTimeout() { + const timeoutsRef = useRef>>(new Set()); + + useEffect(() => { + return () => { + timeoutsRef.current.forEach(clearTimeout); + timeoutsRef.current.clear(); + }; + }, []); + + const setTimeoutSafe = useCallback((fn: () => void, delay: number) => { + const id = setTimeout(fn, delay); + timeoutsRef.current.add(id); + return id; + }, []); + + const clearTimeoutSafe = useCallback((id: ReturnType) => { + clearTimeout(id); + timeoutsRef.current.delete(id); + return id; + }, []); + + return { setTimeoutSafe, clearTimeoutSafe }; +} diff --git a/src/utils/security/phishingProtection.ts b/src/utils/security/phishingProtection.ts index 2720e823..913466c2 100644 --- a/src/utils/security/phishingProtection.ts +++ b/src/utils/security/phishingProtection.ts @@ -88,7 +88,7 @@ export class PhishingProtection { } } catch (error) { - // URL constructor throws on malformed URLs — treat as suspicious + logger.warn('Invalid URL format detected in phishing check:', error); warnings.push('Invalid URL format'); riskScore += 20; } @@ -133,6 +133,7 @@ export class PhishingProtection { decodedData = messageAnalysis.decodedData; } catch (error) { + logger.warn('Invalid signature format:', error); return { isValid: false, isMalicious: true, @@ -191,6 +192,7 @@ export class PhishingProtection { } } catch (error) { + logger.warn('Unable to decode transaction data:', error); warnings.push('Unable to decode transaction data'); } } @@ -381,6 +383,7 @@ export class PhishingProtection { } } catch { + logger.warn('Failed to parse message as JSON, analyzing as text'); // Not JSON, analyze as text if (this.containsSensitiveOperations(message)) { warnings.push('Message contains sensitive operations'); @@ -420,6 +423,7 @@ export class PhishingProtection { decoded: false // Would need ABI for full decoding }; } catch { + logger.warn('Failed to decode transaction data'); return null; } } diff --git a/src/utils/walletHelpers.ts b/src/utils/walletHelpers.ts index 459443c9..e754d598 100644 --- a/src/utils/walletHelpers.ts +++ b/src/utils/walletHelpers.ts @@ -68,6 +68,7 @@ export const parseBalance = (wei: string, decimals: number = 4): string => { const remainderStr = remainder.toString().padStart(WEI_DECIMALS, '0').slice(0, decimals); return `${whole.toString()}.${remainderStr}`; } catch { + logger.warn('Failed to parse balance'); return '0.0000'; } };