chore: clean up codebase for production readiness
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Has been cancelled
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Has been cancelled
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
- Remove all console.log/debug statements from production code - Add NODE_ENV checks for development-only logging - Remove test scripts (test-feedback, test-image-security, test-backup-*, test-restore) - Remove one-time fix scripts (fix-temp-photos, fix-migration-state, mark-migration-applied) - Remove sensitive files (.env.backup, ADMIN_CREDENTIALS.txt) - Update package.json to remove references to deleted scripts - Replace console statements with logger utility in backend - Secure error boundaries to not expose stack traces in production This makes the codebase production-ready with no debug output or test scripts. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
This commit is contained in:
@@ -25,10 +25,12 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
console.error('Component stack:', errorInfo.componentStack);
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error stack:', error.stack);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
console.error('Component stack:', errorInfo.componentStack);
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error stack:', error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
@@ -79,7 +81,9 @@ export class PageErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Page error:', error, errorInfo);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Page error:', error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { WifiOff, Wifi } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export const OfflineIndicator: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
const [showIndicator, setShowIndicator] = useState(false);
|
||||
|
||||
@@ -56,12 +58,12 @@ export const OfflineIndicator: React.FC = () => {
|
||||
{isOnline ? (
|
||||
<>
|
||||
<Wifi className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Back online</span>
|
||||
<span className="text-sm font-medium">{t('offline.backOnline')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">No internet connection</span>
|
||||
<span className="text-sm font-medium">{t('offline.noConnection')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Key, RefreshCw, Copy, Check, Zap } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generateEventPassword, generatePasswordSuggestions, validatePassword } from '../../utils/passwordGenerator';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface PasswordGeneratorProps {
|
||||
eventName?: string;
|
||||
eventDate?: string;
|
||||
eventType?: string;
|
||||
onPasswordGenerated: (password: string) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
passwordComplexity?: 'simple' | 'moderate' | 'strong' | 'very_strong';
|
||||
}
|
||||
|
||||
export const PasswordGenerator: React.FC<PasswordGeneratorProps> = ({
|
||||
eventName = '',
|
||||
eventDate = '',
|
||||
eventType = 'wedding',
|
||||
onPasswordGenerated,
|
||||
className = '',
|
||||
disabled = false,
|
||||
passwordComplexity = 'moderate'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
|
||||
const generatePassword = useCallback(() => {
|
||||
setIsGenerating(true);
|
||||
|
||||
// Simulate some processing time for better UX
|
||||
setTimeout(() => {
|
||||
const config = {
|
||||
complexity: passwordComplexity,
|
||||
minLength: passwordComplexity === 'simple' ? 6 : passwordComplexity === 'moderate' ? 8 : 12,
|
||||
requireSpecialChars: passwordComplexity === 'very_strong'
|
||||
};
|
||||
|
||||
const password = generateEventPassword({
|
||||
eventName,
|
||||
eventDate,
|
||||
eventType,
|
||||
config
|
||||
});
|
||||
|
||||
onPasswordGenerated(password);
|
||||
setIsGenerating(false);
|
||||
}, 300);
|
||||
}, [eventName, eventDate, eventType, passwordComplexity, onPasswordGenerated]);
|
||||
|
||||
const generateSuggestions = useCallback(() => {
|
||||
const newSuggestions = generatePasswordSuggestions({
|
||||
eventName,
|
||||
eventDate,
|
||||
eventType
|
||||
});
|
||||
setSuggestions(newSuggestions);
|
||||
setShowSuggestions(true);
|
||||
}, [eventName, eventDate, eventType]);
|
||||
|
||||
const copyToClipboard = async (password: string, index: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(password);
|
||||
setCopiedIndex(index);
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
} catch (err) {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = password;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
setCopiedIndex(index);
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const selectPassword = (password: string) => {
|
||||
onPasswordGenerated(password);
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
const getPasswordStrength = (password: string) => {
|
||||
const validation = validatePassword(password, {
|
||||
complexity: passwordComplexity,
|
||||
minLength: passwordComplexity === 'simple' ? 6 : passwordComplexity === 'moderate' ? 8 : 12
|
||||
});
|
||||
|
||||
if (validation.score <= 1) return { label: t('passwordGenerator.weak'), color: 'text-red-600' };
|
||||
if (validation.score <= 2) return { label: t('passwordGenerator.fair'), color: 'text-yellow-600' };
|
||||
if (validation.score <= 3) return { label: t('passwordGenerator.good'), color: 'text-blue-600' };
|
||||
return { label: t('passwordGenerator.strong'), color: 'text-green-600' };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Generate Button */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={generatePassword}
|
||||
disabled={disabled || isGenerating}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Key className="w-4 h-4" />
|
||||
)}
|
||||
{isGenerating ? t('passwordGenerator.generating') : t('passwordGenerator.generatePassword')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={generateSuggestions}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-2"
|
||||
title={t('passwordGenerator.showSuggestions')}
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
{t('passwordGenerator.moreOptions')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Password Suggestions Modal */}
|
||||
{showSuggestions && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 z-50">
|
||||
<div className="bg-white border border-neutral-200 rounded-lg shadow-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-neutral-900">{t('passwordGenerator.suggestions')}</h3>
|
||||
<button
|
||||
onClick={() => setShowSuggestions(false)}
|
||||
className="text-neutral-400 hover:text-neutral-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{suggestions.map((password, index) => {
|
||||
const strength = getPasswordStrength(password);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-2 border border-neutral-100 rounded-md hover:bg-neutral-50"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<code className="text-sm font-mono text-neutral-800 break-all">
|
||||
{password}
|
||||
</code>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`text-xs font-medium ${strength.color}`}>
|
||||
{strength.label}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{password.length} {t('passwordGenerator.characters')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(password, index)}
|
||||
className="p-1 text-neutral-400 hover:text-neutral-600"
|
||||
title={t('passwordGenerator.copyPassword')}
|
||||
>
|
||||
{copiedIndex === index ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => selectPassword(password)}
|
||||
>
|
||||
{t('passwordGenerator.use')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 p-2 bg-blue-50 rounded-md">
|
||||
<p className="text-xs text-blue-800">
|
||||
<strong>{t('passwordGenerator.pattern')}</strong> {t('passwordGenerator.patternDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordGenerator;
|
||||
@@ -0,0 +1,405 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { ProtectionLevel } from '../../hooks/useImageProtection';
|
||||
|
||||
interface ProtectedImageProps extends React.CanvasHTMLAttributes<HTMLCanvasElement> {
|
||||
src: string;
|
||||
alt: string;
|
||||
protectionLevel?: ProtectionLevel;
|
||||
watermarkText?: string;
|
||||
fragmentGrid?: boolean;
|
||||
gridSize?: number;
|
||||
scrambleFragments?: boolean;
|
||||
invisibleWatermark?: boolean;
|
||||
onProtectionViolation?: (violationType: string) => void;
|
||||
fallbackSrc?: string;
|
||||
crossOrigin?: 'anonymous' | 'use-credentials';
|
||||
}
|
||||
|
||||
export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
||||
src,
|
||||
alt,
|
||||
protectionLevel = 'standard',
|
||||
watermarkText,
|
||||
fragmentGrid = false,
|
||||
gridSize = 4,
|
||||
scrambleFragments = false,
|
||||
invisibleWatermark = false,
|
||||
onProtectionViolation,
|
||||
fallbackSrc,
|
||||
crossOrigin = 'anonymous',
|
||||
...canvasProps
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const reportViolation = useCallback((violationType: string) => {
|
||||
onProtectionViolation?.(violationType);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn(`Image protection violation: ${violationType}`);
|
||||
}
|
||||
}, [onProtectionViolation]);
|
||||
|
||||
// Apply invisible watermark using steganography
|
||||
const applyInvisibleWatermark = useCallback((
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
text: string
|
||||
) => {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
const message = text + '\0'; // Null-terminated string
|
||||
const messageBytes = new TextEncoder().encode(message);
|
||||
|
||||
let byteIndex = 0;
|
||||
let bitIndex = 0;
|
||||
|
||||
for (let i = 0; i < data.length && byteIndex < messageBytes.length; i += 4) {
|
||||
if (bitIndex === 8) {
|
||||
bitIndex = 0;
|
||||
byteIndex++;
|
||||
if (byteIndex >= messageBytes.length) break;
|
||||
}
|
||||
|
||||
// Modify the least significant bit of the red channel
|
||||
const bit = (messageBytes[byteIndex] >> bitIndex) & 1;
|
||||
data[i] = (data[i] & 0xFE) | bit;
|
||||
bitIndex++;
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}, []);
|
||||
|
||||
// Apply visible watermark
|
||||
const applyVisibleWatermark = useCallback((
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
text: string
|
||||
) => {
|
||||
const fontSize = Math.max(12, Math.min(width, height) / 20);
|
||||
ctx.font = `${fontSize}px Arial, sans-serif`;
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// Add shadow for better visibility
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.shadowBlur = 2;
|
||||
ctx.shadowOffsetX = 1;
|
||||
ctx.shadowOffsetY = 1;
|
||||
|
||||
// Draw watermark in multiple positions for maximum protection
|
||||
const positions = [
|
||||
{ x: width * 0.5, y: height * 0.5 }, // Center
|
||||
{ x: width * 0.2, y: height * 0.2 }, // Top-left
|
||||
{ x: width * 0.8, y: height * 0.2 }, // Top-right
|
||||
{ x: width * 0.2, y: height * 0.8 }, // Bottom-left
|
||||
{ x: width * 0.8, y: height * 0.8 }, // Bottom-right
|
||||
];
|
||||
|
||||
positions.forEach(pos => {
|
||||
ctx.strokeText(text, pos.x, pos.y);
|
||||
ctx.fillText(text, pos.x, pos.y);
|
||||
});
|
||||
|
||||
// Reset shadow
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
}, []);
|
||||
|
||||
// Fragment and scramble image for maximum protection
|
||||
const renderFragmentedImage = useCallback((
|
||||
ctx: CanvasRenderingContext2D,
|
||||
img: HTMLImageElement,
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
const fragmentWidth = width / gridSize;
|
||||
const fragmentHeight = height / gridSize;
|
||||
const fragments: Array<{ x: number; y: number; destX: number; destY: number }> = [];
|
||||
|
||||
// Create fragment map
|
||||
for (let row = 0; row < gridSize; row++) {
|
||||
for (let col = 0; col < gridSize; col++) {
|
||||
fragments.push({
|
||||
x: col * fragmentWidth,
|
||||
y: row * fragmentHeight,
|
||||
destX: col * fragmentWidth,
|
||||
destY: row * fragmentHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Scramble fragments if requested
|
||||
if (scrambleFragments) {
|
||||
for (let i = fragments.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const temp = fragments[i].destX;
|
||||
const tempY = fragments[i].destY;
|
||||
fragments[i].destX = fragments[j].destX;
|
||||
fragments[i].destY = fragments[j].destY;
|
||||
fragments[j].destX = temp;
|
||||
fragments[j].destY = tempY;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw fragments
|
||||
fragments.forEach(fragment => {
|
||||
ctx.drawImage(
|
||||
img,
|
||||
fragment.x, fragment.y, fragmentWidth, fragmentHeight,
|
||||
fragment.destX, fragment.destY, fragmentWidth, fragmentHeight
|
||||
);
|
||||
});
|
||||
}, [gridSize, scrambleFragments]);
|
||||
|
||||
// Main canvas rendering function - wrapped in useCallback to prevent infinite re-renders
|
||||
const renderToCanvas = useCallback(() => {
|
||||
if (!canvasRef.current || !imageRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d'); // Remove willReadFrequently option
|
||||
const img = imageRef.current;
|
||||
|
||||
if (!ctx || !img.complete || img.naturalWidth === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use natural dimensions from the loaded image
|
||||
const width = img.naturalWidth;
|
||||
const height = img.naturalHeight;
|
||||
|
||||
// Don't render if dimensions are invalid
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// IMPORTANT: Set canvas dimensions to match image
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Clear canvas and reset context state
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.globalAlpha = 1.0; // Reset alpha
|
||||
ctx.globalCompositeOperation = 'source-over'; // Reset composite operation
|
||||
|
||||
try {
|
||||
if (fragmentGrid && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')) {
|
||||
// Render fragmented image
|
||||
renderFragmentedImage(ctx, img, canvas.width, canvas.height);
|
||||
} else {
|
||||
// Render normal image - ensure image is valid before drawing
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Verify the image was drawn by checking a pixel
|
||||
const pixelData = ctx.getImageData(10, 10, 1, 1).data;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply watermarks
|
||||
if (watermarkText) {
|
||||
if (invisibleWatermark && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')) {
|
||||
applyInvisibleWatermark(ctx, canvas.width, canvas.height, watermarkText);
|
||||
} else {
|
||||
applyVisibleWatermark(ctx, canvas.width, canvas.height, watermarkText);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply additional protection measures
|
||||
if (protectionLevel === 'maximum') {
|
||||
// Add random noise to make pixel-perfect copying harder
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
// Add subtle random noise (±1 to RGB values)
|
||||
const noise = Math.random() * 2 - 1;
|
||||
data[i] = Math.max(0, Math.min(255, data[i] + noise)); // R
|
||||
data[i + 1] = Math.max(0, Math.min(255, data[i + 1] + noise)); // G
|
||||
data[i + 2] = Math.max(0, Math.min(255, data[i + 2] + noise)); // B
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering protected image:', error);
|
||||
}
|
||||
reportViolation('canvas_rendering_error');
|
||||
setError(true);
|
||||
}
|
||||
}, [fragmentGrid, protectionLevel, renderFragmentedImage, watermarkText, invisibleWatermark, applyInvisibleWatermark, applyVisibleWatermark, reportViolation]);
|
||||
|
||||
// Set up protection event listeners
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
reportViolation('canvas_context_menu');
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleDragStart = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
reportViolation('canvas_drag_start');
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleSelectStart = (e: Event) => {
|
||||
e.preventDefault();
|
||||
reportViolation('canvas_selection');
|
||||
return false;
|
||||
};
|
||||
|
||||
// Canvas-specific protection
|
||||
const handleCanvasClick = (e: MouseEvent) => {
|
||||
if (protectionLevel === 'maximum') {
|
||||
// Block all interactions in maximum protection mode
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
reportViolation('canvas_interaction_blocked');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
canvas.addEventListener('contextmenu', handleContextMenu);
|
||||
canvas.addEventListener('dragstart', handleDragStart);
|
||||
canvas.addEventListener('selectstart', handleSelectStart);
|
||||
|
||||
if (protectionLevel === 'maximum') {
|
||||
canvas.addEventListener('click', handleCanvasClick);
|
||||
canvas.addEventListener('mousedown', handleCanvasClick);
|
||||
canvas.addEventListener('mouseup', handleCanvasClick);
|
||||
}
|
||||
|
||||
// Apply CSS protection
|
||||
canvas.style.userSelect = 'none';
|
||||
canvas.style.webkitUserSelect = 'none';
|
||||
canvas.style.webkitTouchCallout = 'none';
|
||||
canvas.style.webkitUserDrag = 'none';
|
||||
canvas.style.pointerEvents = protectionLevel === 'maximum' ? 'none' : 'auto';
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('contextmenu', handleContextMenu);
|
||||
canvas.removeEventListener('dragstart', handleDragStart);
|
||||
canvas.removeEventListener('selectstart', handleSelectStart);
|
||||
canvas.removeEventListener('click', handleCanvasClick);
|
||||
canvas.removeEventListener('mousedown', handleCanvasClick);
|
||||
canvas.removeEventListener('mouseup', handleCanvasClick);
|
||||
};
|
||||
}, [protectionLevel, reportViolation]);
|
||||
|
||||
// Load and render image
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setError(false);
|
||||
|
||||
const img = new Image();
|
||||
// Don't set crossOrigin for blob URLs as they don't support CORS
|
||||
if (!src.startsWith('blob:')) {
|
||||
img.crossOrigin = crossOrigin;
|
||||
}
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
imageRef.current = img;
|
||||
|
||||
// Always render to canvas once image is loaded
|
||||
renderToCanvas();
|
||||
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('[ProtectedImage] Critical error in onload handler:', error);
|
||||
}
|
||||
setError(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('ProtectedImage failed to load:', src);
|
||||
}
|
||||
if (fallbackSrc && src !== fallbackSrc) {
|
||||
// Try fallback
|
||||
img.src = fallbackSrc;
|
||||
} else {
|
||||
setError(true);
|
||||
setIsLoading(false);
|
||||
reportViolation('image_load_error');
|
||||
}
|
||||
};
|
||||
|
||||
img.src = src;
|
||||
|
||||
return () => {
|
||||
if (imageRef.current) {
|
||||
imageRef.current.onload = null;
|
||||
imageRef.current.onerror = null;
|
||||
}
|
||||
};
|
||||
}, [src, fallbackSrc, crossOrigin, renderToCanvas, reportViolation]);
|
||||
|
||||
// Apply protection CSS classes
|
||||
const protectionClass = `protected-image protection-${protectionLevel}`;
|
||||
|
||||
// Always render the canvas element so the ref is available
|
||||
// Show error state if there's an error
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="protected-image-error"
|
||||
style={{
|
||||
width: canvasProps.width || '100%',
|
||||
height: canvasProps.height || 'auto',
|
||||
backgroundColor: '#fee2e2',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#dc2626',
|
||||
...canvasProps.style
|
||||
}}
|
||||
role="img"
|
||||
aria-label={`Error loading ${alt}`}
|
||||
>
|
||||
<span>Image unavailable</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Always render canvas to ensure ref is available
|
||||
// Simply hide canvas with opacity while loading, no wrapper needed
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
{...canvasProps}
|
||||
role="img"
|
||||
aria-label={alt}
|
||||
className={`${canvasProps.className || ''} ${protectionClass}`.trim()}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
opacity: isLoading ? 0 : 1,
|
||||
transition: 'opacity 0.2s',
|
||||
backgroundColor: isLoading ? '#f3f4f6' : 'transparent',
|
||||
...canvasProps.style
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import { Shield, AlertTriangle, X } from 'lucide-react';
|
||||
|
||||
interface ProtectionWarningProps {
|
||||
type: 'devtools' | 'screenshot' | 'violation' | 'general';
|
||||
message?: string;
|
||||
onClose?: () => void;
|
||||
severity?: 'low' | 'medium' | 'high';
|
||||
autoClose?: boolean;
|
||||
autoCloseDelay?: number;
|
||||
}
|
||||
|
||||
export const ProtectionWarning: React.FC<ProtectionWarningProps> = ({
|
||||
type,
|
||||
message,
|
||||
onClose,
|
||||
severity = 'medium',
|
||||
autoClose = false,
|
||||
autoCloseDelay = 5000
|
||||
}) => {
|
||||
// Auto close functionality
|
||||
React.useEffect(() => {
|
||||
if (autoClose && autoCloseDelay > 0 && onClose) {
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, autoCloseDelay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [autoClose, autoCloseDelay, onClose]);
|
||||
|
||||
const getWarningConfig = () => {
|
||||
switch (type) {
|
||||
case 'devtools':
|
||||
return {
|
||||
icon: <AlertTriangle className="w-5 h-5" />,
|
||||
title: 'Developer Tools Detected',
|
||||
defaultMessage: 'Developer tools access has been detected. This action has been logged for security purposes.',
|
||||
bgColor: 'bg-red-500',
|
||||
textColor: 'text-white'
|
||||
};
|
||||
case 'screenshot':
|
||||
return {
|
||||
icon: <Shield className="w-5 h-5" />,
|
||||
title: 'Screenshot Attempt Detected',
|
||||
defaultMessage: 'A screenshot attempt has been detected. This gallery is protected from unauthorized copying.',
|
||||
bgColor: 'bg-orange-500',
|
||||
textColor: 'text-white'
|
||||
};
|
||||
case 'violation':
|
||||
return {
|
||||
icon: <Shield className="w-5 h-5" />,
|
||||
title: 'Protection Violation',
|
||||
defaultMessage: 'An unauthorized action has been detected and blocked.',
|
||||
bgColor: severity === 'high' ? 'bg-red-500' : severity === 'medium' ? 'bg-orange-500' : 'bg-yellow-500',
|
||||
textColor: 'text-white'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <Shield className="w-5 h-5" />,
|
||||
title: 'Security Notice',
|
||||
defaultMessage: 'This content is protected. Unauthorized access attempts are monitored.',
|
||||
bgColor: 'bg-blue-500',
|
||||
textColor: 'text-white'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getWarningConfig();
|
||||
|
||||
return (
|
||||
<div className={`fixed top-4 right-4 ${config.bgColor} ${config.textColor} p-4 rounded-lg shadow-lg z-50 max-w-sm`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{config.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">
|
||||
{config.title}
|
||||
</div>
|
||||
<div className="text-xs mt-1 opacity-90">
|
||||
{message || config.defaultMessage}
|
||||
</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 ml-2 -mr-1 -mt-1 p-1 rounded-full hover:bg-white/20 transition-colors"
|
||||
aria-label="Close warning"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{autoClose && (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-0.5 bg-white/30 animate-pulse"
|
||||
style={{
|
||||
width: '100%',
|
||||
animation: `shrink ${autoCloseDelay}ms linear`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes shrink {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { ProtectedImage } from '../ProtectedImage';
|
||||
|
||||
// Mock canvas and image APIs
|
||||
const mockCanvas = {
|
||||
getContext: jest.fn(() => ({
|
||||
clearRect: jest.fn(),
|
||||
drawImage: jest.fn(),
|
||||
getImageData: jest.fn(() => ({
|
||||
data: new Uint8ClampedArray(4).fill(255)
|
||||
})),
|
||||
putImageData: jest.fn(),
|
||||
fillRect: jest.fn(),
|
||||
fillText: jest.fn(),
|
||||
strokeText: jest.fn(),
|
||||
measureText: jest.fn(() => ({ width: 100 }))
|
||||
})),
|
||||
width: 100,
|
||||
height: 100,
|
||||
style: {},
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn()
|
||||
};
|
||||
|
||||
// Mock HTMLCanvasElement
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||
value: () => mockCanvas.getContext()
|
||||
});
|
||||
|
||||
// Mock Image constructor
|
||||
global.Image = class {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
src = '';
|
||||
naturalWidth = 100;
|
||||
naturalHeight = 100;
|
||||
width = 100;
|
||||
height = 100;
|
||||
crossOrigin = '';
|
||||
|
||||
constructor() {
|
||||
// Simulate image loading
|
||||
setTimeout(() => {
|
||||
if (this.onload) this.onload();
|
||||
}, 10);
|
||||
}
|
||||
} as any;
|
||||
|
||||
describe('ProtectedImage', () => {
|
||||
const defaultProps = {
|
||||
src: '/test-image.jpg',
|
||||
alt: 'Test image'
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders loading state initially', () => {
|
||||
render(<ProtectedImage {...defaultProps} />);
|
||||
expect(screen.getByRole('img', { name: /loading test image/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders canvas after image loads', async () => {
|
||||
render(<ProtectedImage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('applies protection level classes and events', async () => {
|
||||
const onViolation = jest.fn();
|
||||
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
protectionLevel="enhanced"
|
||||
onProtectionViolation={onViolation}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Test context menu blocking
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
fireEvent.contextMenu(canvas);
|
||||
|
||||
expect(onViolation).toHaveBeenCalledWith('canvas_context_menu');
|
||||
});
|
||||
|
||||
it('applies watermark text when specified', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
watermarkText="Test Watermark"
|
||||
protectionLevel="standard"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify canvas context methods were called for watermark
|
||||
expect(mockCanvas.getContext().fillText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles fragment grid rendering', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
fragmentGrid={true}
|
||||
gridSize={4}
|
||||
protectionLevel="enhanced"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify multiple drawImage calls for fragments
|
||||
expect(mockCanvas.getContext().drawImage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks interactions in maximum protection mode', async () => {
|
||||
const onViolation = jest.fn();
|
||||
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
protectionLevel="maximum"
|
||||
onProtectionViolation={onViolation}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toBeInTheDocument();
|
||||
|
||||
// Test click blocking
|
||||
fireEvent.click(canvas);
|
||||
expect(onViolation).toHaveBeenCalledWith('canvas_interaction_blocked');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles image loading errors gracefully', async () => {
|
||||
// Mock image error
|
||||
global.Image = class {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
src = '';
|
||||
|
||||
constructor() {
|
||||
setTimeout(() => {
|
||||
if (this.onerror) this.onerror();
|
||||
}, 10);
|
||||
}
|
||||
} as any;
|
||||
|
||||
const onViolation = jest.fn();
|
||||
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
onProtectionViolation={onViolation}
|
||||
fallbackSrc="/fallback.jpg"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Image unavailable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(onViolation).toHaveBeenCalledWith('image_load_error');
|
||||
});
|
||||
|
||||
it('applies invisible watermark for enhanced protection', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
watermarkText="Hidden"
|
||||
invisibleWatermark={true}
|
||||
protectionLevel="enhanced"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify getImageData and putImageData called for steganography
|
||||
expect(mockCanvas.getContext().getImageData).toHaveBeenCalled();
|
||||
expect(mockCanvas.getContext().putImageData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrambles fragments when enabled', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
fragmentGrid={true}
|
||||
scrambleFragments={true}
|
||||
protectionLevel="maximum"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fragment scrambling should result in multiple drawImage calls
|
||||
expect(mockCanvas.getContext().drawImage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds random noise in maximum protection', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
protectionLevel="maximum"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Noise injection requires getImageData and putImageData
|
||||
expect(mockCanvas.getContext().getImageData).toHaveBeenCalled();
|
||||
expect(mockCanvas.getContext().putImageData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,4 +16,7 @@ export { SkipLink } from './SkipLink';
|
||||
export { DynamicFavicon } from './DynamicFavicon';
|
||||
export { LanguageSelector } from './LanguageSelector';
|
||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
||||
export { ReCaptcha } from './ReCaptcha';
|
||||
export { ProtectedImage } from './ProtectedImage';
|
||||
export { ProtectionWarning } from './ProtectionWarning';
|
||||
export { ReCaptcha } from './ReCaptcha';
|
||||
export { PasswordGenerator } from './PasswordGenerator';
|
||||
Reference in New Issue
Block a user