feat: add Apple Liquid Glass templates, image security settings, and automated releases

## New Features
- Apple Liquid Glass CSS template with iOS 26-inspired design
- Liquid Glass Dark theme with neon accents
- Image Security settings tab with per-event protection levels
- Release Please automation for versioning and changelog

## Improvements
- Update CSS template migration with final working templates
- Add search placeholder visibility fix for glass themes
- Update README roadmap (Download Protection, Gallery Templates, Filtering & Export now implemented)

## Infrastructure
- Add release-please.yml workflow for automated releases
- Add release-please-config.json and manifest
- Update docker-build.yml with Release Please integration comments
- Add comprehensive CHANGELOG.md

## Cleanup
- Add working/planning docs to .gitignore (CLAUDE.md, test-*.md, feature-*.md, etc.)
- Remove internal planning documents from git tracking (kept locally)

## Files Added
- .github/workflows/release-please.yml
- .release-please-manifest.json
- release-please-config.json
- CHANGELOG.md
- frontend/src/features/settings/tabs/ImageSecurityTab.tsx
This commit is contained in:
Paul Nothaft
2026-01-03 23:35:23 +01:00
parent f3c2cee362
commit 6033461be1
44 changed files with 1978 additions and 9443 deletions
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { buildResourceUrl } from '../../utils/url';
import {
getActiveGallerySlug,
@@ -67,7 +67,6 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
overlayProtection,
fragmentGrid,
scrambleFragments,
useCanvasRendering,
blockKeyboardShortcuts,
detectPrintScreen,
detectDevTools,
@@ -79,6 +78,30 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
const [imageSrc, setImageSrc] = useState<string>('');
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [canvasReady, setCanvasReady] = useState(false);
const [canvasFailed, setCanvasFailed] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
// Draw image to canvas when canvas rendering is enabled
const drawToCanvas = useCallback(() => {
if (!useCanvasRendering || !canvasRef.current || !imageRef.current) return;
const canvas = canvasRef.current;
const img = imageRef.current;
const ctx = canvas.getContext('2d');
if (!ctx || !img.complete || img.naturalWidth === 0) return;
// Set canvas dimensions to match image
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
// Draw the image
ctx.drawImage(img, 0, 0);
setCanvasReady(true);
}, [useCanvasRendering]);
useEffect(() => {
let aborted = false;
@@ -93,6 +116,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
setIsLoading(true);
setError(false);
setCanvasFailed(false);
setCanvasReady(false);
const resolveSlug = (candidateSrc?: string): string | null => {
if (slug) {
@@ -181,6 +206,37 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [src, fallbackSrc, slug]);
// Effect to draw to canvas when image is loaded and canvas rendering is enabled
useEffect(() => {
if (!useCanvasRendering || !imageSrc) return;
// Create a hidden image to load and then draw to canvas
const img = new Image();
// Only set crossOrigin for non-blob URLs (blob URLs are same-origin)
// Setting crossOrigin on blob URLs can cause silent failures
if (!imageSrc.startsWith('blob:')) {
img.crossOrigin = 'anonymous';
}
img.onload = () => {
imageRef.current = img;
drawToCanvas();
};
img.onerror = (e) => {
// Fall back to regular img if canvas loading fails
console.warn('Canvas image load failed, falling back to img tag:', e);
setCanvasFailed(true);
};
img.src = imageSrc;
return () => {
img.onload = null;
img.onerror = null;
};
}, [imageSrc, useCanvasRendering, drawToCanvas]);
if (isLoading) {
return (
<div className={props.className} style={{ backgroundColor: '#f3f4f6', ...props.style }}>
@@ -197,5 +253,34 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
return null;
}
// Canvas rendering mode - only if enabled and not failed
if (useCanvasRendering && !canvasFailed) {
return (
<canvas
ref={canvasRef}
className={props.className}
style={{
...props.style,
// Hide canvas until it's ready to prevent flash
opacity: canvasReady ? 1 : 0,
transition: 'opacity 0.2s ease-in-out',
}}
// Prevent context menu on canvas
onContextMenu={(e) => {
e.preventDefault();
onProtectionViolation?.('canvas_context_menu');
return false;
}}
// Prevent drag
onDragStart={(e) => {
e.preventDefault();
return false;
}}
aria-label={alt}
role="img"
/>
);
}
return <img src={imageSrc} alt={alt} {...props} />;
};
@@ -100,14 +100,21 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
setProtectionLevel(data.event.protection_level);
}
}, [data?.event?.protection_level]);
// DevTools protection for enhanced and maximum levels
// Get individual protection settings from event
const disableRightClick = data?.event?.disable_right_click === true;
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
const useCanvasRendering = data?.event?.use_canvas_rendering === true;
// DevTools protection - enabled by individual setting OR legacy protection level
const devToolsEnabled = enableDevtoolsProtection || protectionLevel === 'enhanced' || protectionLevel === 'maximum';
useDevToolsProtection({
enabled: protectionLevel === 'enhanced' || protectionLevel === 'maximum',
enabled: devToolsEnabled,
detectionSensitivity: protectionLevel === 'maximum' ? 'high' : 'medium',
onDevToolsDetected: () => {
console.warn('DevTools detected in gallery view');
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('gallery_devtools_detected', {
@@ -116,7 +123,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
eventId: data?.event?.id
});
}
// For maximum protection, redirect away from gallery
if (protectionLevel === 'maximum') {
setTimeout(() => {
@@ -127,6 +134,21 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
redirectOnDetection: protectionLevel === 'maximum',
redirectUrl: '/'
});
// Right-click blocking - separate from DevTools protection
useEffect(() => {
if (!disableRightClick) return;
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
return false;
};
document.addEventListener('contextmenu', handleContextMenu);
return () => {
document.removeEventListener('contextmenu', handleContextMenu);
};
}, [disableRightClick]);
// Data updates are handled by React Query
const downloadAllMutation = useDownloadAllPhotos();
@@ -679,6 +701,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={protectionLevel !== 'basic'}
disableRightClick={disableRightClick}
enableDevtoolsProtection={enableDevtoolsProtection}
useCanvasRendering={useCanvasRendering}
/>
</div>
+18 -7
View File
@@ -19,16 +19,22 @@ interface PhotoGridProps {
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
disableRightClick?: boolean;
enableDevtoolsProtection?: boolean;
}
export const PhotoGrid: React.FC<PhotoGridProps> = ({
photos,
slug,
categoryId,
feedbackEnabled = false,
export const PhotoGrid: React.FC<PhotoGridProps> = ({
photos,
slug,
categoryId,
feedbackEnabled = false,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false
useEnhancedProtection = false,
useCanvasRendering = false,
disableRightClick = false,
enableDevtoolsProtection = false
}) => {
const { t } = useTranslation();
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
@@ -190,6 +196,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
slug={slug}
feedbackEnabled={feedbackEnabled}
/>
@@ -207,6 +214,8 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
disableRightClick={disableRightClick}
enableDevtoolsProtection={enableDevtoolsProtection}
/>
)}
</>
@@ -222,6 +231,7 @@ interface PhotoThumbnailProps {
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
slug: string; // Add slug as required prop
feedbackEnabled?: boolean;
}
@@ -235,6 +245,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
slug,
feedbackEnabled = false
}) => {
@@ -264,7 +275,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={protectionLevel === 'maximum'}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
blockKeyboardShortcuts={useEnhancedProtection}
detectPrintScreen={useEnhancedProtection}
@@ -41,6 +41,9 @@ interface PhotoGridWithLayoutsProps {
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
disableRightClick?: boolean;
enableDevtoolsProtection?: boolean;
feedbackOptions?: {
allowLikes?: boolean;
allowFavorites?: boolean;
@@ -51,9 +54,9 @@ interface PhotoGridWithLayoutsProps {
onFeedbackChange?: () => void;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
photos,
slug,
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
photos,
slug,
categoryId,
heroPhotoOverride,
isSelectionMode: parentSelectionMode,
@@ -64,6 +67,9 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
disableRightClick = false,
enableDevtoolsProtection = false,
onSelectionChange,
onToggleSelectionMode: parentToggleSelectionMode,
showSelectionControls = true,
@@ -185,6 +191,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
allowDownloads,
protectionLevel,
useEnhancedProtection,
useCanvasRendering,
isSelectionMode,
onPhotoSelect: handlePhotoSelect,
eventName,
@@ -290,6 +297,9 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
disableRightClick={disableRightClick}
enableDevtoolsProtection={enableDevtoolsProtection}
initialShowFeedback={openFeedbackInitially}
onFeedbackChange={onFeedbackChange}
/>
@@ -18,8 +18,11 @@ interface PhotoLightboxProps {
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
initialShowFeedback?: boolean;
onFeedbackChange?: () => void;
disableRightClick?: boolean;
enableDevtoolsProtection?: boolean;
}
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
@@ -31,8 +34,11 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
initialShowFeedback = false,
onFeedbackChange,
disableRightClick = false,
enableDevtoolsProtection = false,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [zoom, setZoom] = useState(1);
@@ -66,13 +72,15 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex];
// DevTools protection for the lightbox when enhanced protection is enabled
// DevTools protection - enabled by individual setting OR legacy protection level
const devToolsEnabled = enableDevtoolsProtection || (useEnhancedProtection && (protectionLevel === 'enhanced' || protectionLevel === 'maximum'));
useDevToolsProtection({
enabled: useEnhancedProtection && (protectionLevel === 'enhanced' || protectionLevel === 'maximum'),
enabled: devToolsEnabled,
detectionSensitivity: protectionLevel === 'maximum' ? 'high' : 'medium',
onDevToolsDetected: () => {
console.warn('DevTools detected in photo lightbox');
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('lightbox_devtools_detected', {
@@ -82,7 +90,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
gallery: slug
});
}
// Close lightbox immediately for maximum protection
if (protectionLevel === 'maximum') {
onClose();
@@ -91,6 +99,21 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
redirectOnDetection: false, // Don't redirect, just close lightbox
});
// Right-click blocking in lightbox
useEffect(() => {
if (!disableRightClick) return;
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
return false;
};
document.addEventListener('contextmenu', handleContextMenu);
return () => {
document.removeEventListener('contextmenu', handleContextMenu);
};
}, [disableRightClick]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
@@ -494,7 +517,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={protectionLevel === 'maximum'}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
blockKeyboardShortcuts={useEnhancedProtection}
detectPrintScreen={useEnhancedProtection}
@@ -20,6 +20,7 @@ export interface BaseGalleryLayoutProps {
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
feedbackEnabled?: boolean;
feedbackOptions?: {
allowLikes?: boolean;
@@ -20,6 +20,7 @@ interface GridPhotoProps {
slug?: string;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
feedbackEnabled?: boolean;
feedbackOptions?: {
allowLikes?: boolean;
@@ -48,6 +49,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
slug,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions,
savedIdentity,
@@ -197,7 +199,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={protectionLevel === 'maximum'}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
blockKeyboardShortcuts={useEnhancedProtection}
detectPrintScreen={useEnhancedProtection}
@@ -359,6 +361,7 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions
}) => {
@@ -397,6 +400,7 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
slug={slug}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
savedIdentity={savedIdentity}
@@ -35,6 +35,9 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
expiresAt,
heroPhotoOverride,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions
}) => {
@@ -113,7 +116,12 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
alt={heroPhoto.filename}
className="w-full h-full object-cover"
isGallery={true}
protectFromDownload={!allowDownloads}
slug={slug}
photoId={heroPhoto.id}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
{/* Overlay */}
@@ -202,7 +210,12 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
className="w-full h-auto object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
isGallery={true}
protectFromDownload={!allowDownloads}
slug={slug}
photoId={photo.id}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
+1
View File
@@ -8,6 +8,7 @@ export { GeneralTab } from './tabs/GeneralTab';
export { EventsTab } from './tabs/EventsTab';
export { StatusTab } from './tabs/StatusTab';
export { SecurityTab } from './tabs/SecurityTab';
export { ImageSecurityTab } from './tabs/ImageSecurityTab';
export { CategoriesTab } from './tabs/CategoriesTab';
export { AnalyticsTab } from './tabs/AnalyticsTab';
export { ModerationTab } from './tabs/ModerationTab';
@@ -0,0 +1,381 @@
import React, { useState, useEffect } from 'react';
import { Save, Shield, Monitor, Image, RefreshCw, AlertCircle } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { api } from '../../../config/api';
interface ImageSecuritySettings {
default_protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
default_image_quality: number;
enable_devtools_protection: boolean;
max_image_requests_per_minute: number;
max_image_requests_per_5_minutes: number;
max_image_requests_per_hour: number;
suspicious_activity_threshold: number;
enable_canvas_rendering: boolean;
default_fragmentation_level: number;
security_monitoring_enabled: boolean;
block_suspicious_ips: boolean;
log_security_events_to_db: boolean;
auto_block_threshold: number;
}
const defaultSettings: ImageSecuritySettings = {
default_protection_level: 'standard',
default_image_quality: 85,
enable_devtools_protection: true,
max_image_requests_per_minute: 30,
max_image_requests_per_5_minutes: 100,
max_image_requests_per_hour: 500,
suspicious_activity_threshold: 10,
enable_canvas_rendering: false,
default_fragmentation_level: 3,
security_monitoring_enabled: true,
block_suspicious_ips: true,
log_security_events_to_db: true,
auto_block_threshold: 50,
};
export const ImageSecurityTab: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [settings, setSettings] = useState<ImageSecuritySettings>(defaultSettings);
const [isDirty, setIsDirty] = useState(false);
// Fetch current settings
const { data: fetchedSettings, isLoading, error } = useQuery({
queryKey: ['image-security-settings'],
queryFn: async () => {
const response = await api.get('/api/admin/image-security/settings');
return response.data;
},
});
// Update local state when settings are fetched
useEffect(() => {
if (fetchedSettings) {
setSettings({
...defaultSettings,
...fetchedSettings,
});
}
}, [fetchedSettings]);
// Save mutation
const saveMutation = useMutation({
mutationFn: async (newSettings: ImageSecuritySettings) => {
const response = await api.put('/api/admin/image-security/settings', newSettings);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['image-security-settings'] });
toast.success(t('settings.imageSecurity.saveSuccess', 'Image security settings saved'));
setIsDirty(false);
},
onError: () => {
toast.error(t('settings.imageSecurity.saveError', 'Failed to save settings'));
},
});
const handleChange = <K extends keyof ImageSecuritySettings>(
key: K,
value: ImageSecuritySettings[K]
) => {
setSettings(prev => ({ ...prev, [key]: value }));
setIsDirty(true);
};
const handleSave = () => {
saveMutation.mutate(settings);
};
const handleReset = () => {
if (fetchedSettings) {
setSettings({ ...defaultSettings, ...fetchedSettings });
setIsDirty(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[200px]">
<Loading size="lg" text={t('common.loading')} />
</div>
);
}
if (error) {
return (
<Card padding="md">
<div className="flex items-center gap-3 text-red-600">
<AlertCircle className="w-5 h-5" />
<p>{t('settings.imageSecurity.loadError', 'Failed to load image security settings')}</p>
</div>
</Card>
);
}
return (
<div className="space-y-6">
{/* Default Protection Level */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-primary-600" />
{t('settings.imageSecurity.defaultProtection', 'Default Protection Settings')}
</h2>
<p className="text-sm text-neutral-600 mb-4">
{t('settings.imageSecurity.defaultProtectionHelp', 'These settings apply to all new events. Individual events can override these defaults.')}
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.protectionLevel', 'Default Protection Level')}
</label>
<select
value={settings.default_protection_level}
onChange={(e) => handleChange('default_protection_level', e.target.value as ImageSecuritySettings['default_protection_level'])}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="basic">{t('events.protectionLevelBasic', 'Basic - Right-click blocking only')}</option>
<option value="standard">{t('events.protectionLevelStandard', 'Standard - Keyboard shortcuts blocked')}</option>
<option value="enhanced">{t('events.protectionLevelEnhanced', 'Enhanced - Print screen detection')}</option>
<option value="maximum">{t('events.protectionLevelMaximum', 'Maximum - DevTools detection & canvas rendering')}</option>
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.imageQuality', 'Default Image Quality')}
</label>
<input
type="number"
min="1"
max="100"
value={settings.default_image_quality}
onChange={(e) => handleChange('default_image_quality', parseInt(e.target.value) || 85)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<p className="text-xs text-neutral-500 mt-1">1-100, higher = better quality</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.fragmentationLevel', 'Fragmentation Level')}
</label>
<input
type="number"
min="1"
max="10"
value={settings.default_fragmentation_level}
onChange={(e) => handleChange('default_fragmentation_level', parseInt(e.target.value) || 3)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<p className="text-xs text-neutral-500 mt-1">1-10, higher = more protection</p>
</div>
</div>
<div className="space-y-3 pt-2">
<label className="flex items-center">
<input
type="checkbox"
checked={settings.enable_devtools_protection}
onChange={(e) => handleChange('enable_devtools_protection', e.target.checked)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Monitor className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">
{t('settings.imageSecurity.enableDevtools', 'Enable DevTools detection by default')}
</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={settings.enable_canvas_rendering}
onChange={(e) => handleChange('enable_canvas_rendering', e.target.checked)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">
{t('settings.imageSecurity.enableCanvas', 'Enable canvas rendering by default (advanced protection)')}
</span>
</label>
</div>
</div>
</Card>
{/* Rate Limiting */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
{t('settings.imageSecurity.rateLimiting', 'Rate Limiting')}
</h2>
<p className="text-sm text-neutral-600 mb-4">
{t('settings.imageSecurity.rateLimitingHelp', 'Limit how many images can be requested to prevent scraping.')}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.requestsPerMinute', 'Requests per minute')}
</label>
<input
type="number"
min="1"
max="1000"
value={settings.max_image_requests_per_minute}
onChange={(e) => handleChange('max_image_requests_per_minute', parseInt(e.target.value) || 30)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.requestsPer5Minutes', 'Requests per 5 min')}
</label>
<input
type="number"
min="1"
max="5000"
value={settings.max_image_requests_per_5_minutes}
onChange={(e) => handleChange('max_image_requests_per_5_minutes', parseInt(e.target.value) || 100)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.requestsPerHour', 'Requests per hour')}
</label>
<input
type="number"
min="1"
max="10000"
value={settings.max_image_requests_per_hour}
onChange={(e) => handleChange('max_image_requests_per_hour', parseInt(e.target.value) || 500)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
</Card>
{/* Security Monitoring */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
{t('settings.imageSecurity.securityMonitoring', 'Security Monitoring')}
</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.suspiciousThreshold', 'Suspicious activity threshold')}
</label>
<input
type="number"
min="1"
max="100"
value={settings.suspicious_activity_threshold}
onChange={(e) => handleChange('suspicious_activity_threshold', parseInt(e.target.value) || 10)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<p className="text-xs text-neutral-500 mt-1">Violations before flagging as suspicious</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.autoBlockThreshold', 'Auto-block threshold')}
</label>
<input
type="number"
min="1"
max="500"
value={settings.auto_block_threshold}
onChange={(e) => handleChange('auto_block_threshold', parseInt(e.target.value) || 50)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<p className="text-xs text-neutral-500 mt-1">Violations before auto-blocking IP</p>
</div>
</div>
<div className="space-y-3 pt-2">
<label className="flex items-center">
<input
type="checkbox"
checked={settings.security_monitoring_enabled}
onChange={(e) => handleChange('security_monitoring_enabled', e.target.checked)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">
{t('settings.imageSecurity.enableMonitoring', 'Enable security monitoring')}
</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={settings.block_suspicious_ips}
onChange={(e) => handleChange('block_suspicious_ips', e.target.checked)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">
{t('settings.imageSecurity.blockSuspiciousIps', 'Automatically block suspicious IPs')}
</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={settings.log_security_events_to_db}
onChange={(e) => handleChange('log_security_events_to_db', e.target.checked)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">
{t('settings.imageSecurity.logEvents', 'Log security events to database')}
</span>
</label>
</div>
</div>
</Card>
{/* Info Box */}
<Card padding="md" className="bg-blue-50 border-blue-200">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">{t('settings.imageSecurity.infoTitle', 'About Image Protection')}</p>
<p>
{t('settings.imageSecurity.infoText', 'These protection features help prevent casual downloading and copying but cannot block all methods. Determined users may still find ways to capture images. Consider using watermarks and legal agreements for comprehensive protection.')}
</p>
</div>
</div>
</Card>
{/* Action Buttons */}
<div className="flex gap-3">
<Button
variant="primary"
onClick={handleSave}
isLoading={saveMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
disabled={!isDirty}
>
{t('common.saveChanges', 'Save Changes')}
</Button>
{isDirty && (
<Button
variant="outline"
onClick={handleReset}
leftIcon={<RefreshCw className="w-5 h-5" />}
>
{t('common.resetChanges', 'Reset Changes')}
</Button>
)}
</div>
</div>
);
};
+6
View File
@@ -693,6 +693,12 @@
"downloadPermissions": "Download-Berechtigungen",
"downloadsEnabled": "Downloads aktiviert",
"downloadsDisabled": "Downloads deaktiviert",
"downloadProtection": "Download-Schutz",
"disableRightClick": "Rechtsklick-Menü blockieren",
"watermarkDownloads": "Wasserzeichen bei Downloads hinzufügen",
"enableDevtoolsProtection": "Entwicklertools erkennen",
"useCanvasRendering": "Canvas-Rendering (erweiterter Schutz)",
"protectionInfo": "Schutzfunktionen helfen, unerlaubte Downloads zu verhindern, können jedoch nicht alle Methoden blockieren.",
"heroPhoto": "Hero-Foto",
"heroPhotoHelp": "Wählen Sie ein hervorgehobenes Foto für das Hero-Galerie-Layout",
"selectHeroPhoto": "Hero-Foto auswählen",
+6
View File
@@ -371,6 +371,12 @@
"downloadPermissions": "Download Permissions",
"downloadsEnabled": "Downloads Enabled",
"downloadsDisabled": "Downloads Disabled",
"downloadProtection": "Download Protection",
"disableRightClick": "Block right-click menu",
"watermarkDownloads": "Add watermark to downloads",
"enableDevtoolsProtection": "Detect developer tools",
"useCanvasRendering": "Canvas rendering (advanced protection)",
"protectionInfo": "Protection features help prevent unauthorized downloads but cannot block all methods.",
"heroPhoto": "Hero Photo",
"heroPhotoHelp": "Select a featured photo for the hero gallery layout",
"selectHeroPhoto": "Select Hero Photo",
+147 -2
View File
@@ -21,7 +21,11 @@ import {
Lock,
Eye,
EyeOff,
Type
Type,
Shield,
Monitor,
Droplets,
MousePointer
} from 'lucide-react';
import { parseISO, differenceInDays, isValid } from 'date-fns';
@@ -147,6 +151,13 @@ export const EventDetailsPage: React.FC = () => {
require_password: boolean;
new_password: string;
confirm_new_password: string;
// Download protection settings
protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
disable_right_click: boolean;
allow_downloads: boolean;
watermark_downloads: boolean;
enable_devtools_protection: boolean;
use_canvas_rendering: boolean;
};
const [isEditing, setIsEditing] = useState(false);
@@ -163,6 +174,13 @@ export const EventDetailsPage: React.FC = () => {
require_password: true,
new_password: '',
confirm_new_password: '',
// Download protection settings
protection_level: 'standard',
disable_right_click: true,
allow_downloads: true,
watermark_downloads: false,
enable_devtools_protection: true,
use_canvas_rendering: false,
});
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
feedback_enabled: false,
@@ -367,6 +385,13 @@ export const EventDetailsPage: React.FC = () => {
require_password: normalizeRequirePassword(event.require_password),
new_password: '',
confirm_new_password: '',
// Load protection settings from event
protection_level: event.protection_level || 'standard',
disable_right_click: event.disable_right_click ?? true,
allow_downloads: event.allow_downloads ?? true,
watermark_downloads: event.watermark_downloads ?? false,
enable_devtools_protection: event.enable_devtools_protection ?? true,
use_canvas_rendering: event.use_canvas_rendering ?? false,
});
setShowNewPassword(false);
@@ -449,6 +474,13 @@ export const EventDetailsPage: React.FC = () => {
expires_at: editForm.expires_at,
allow_user_uploads: editForm.allow_user_uploads,
require_password: editForm.require_password,
// Download protection settings
protection_level: editForm.protection_level,
disable_right_click: editForm.disable_right_click,
allow_downloads: editForm.allow_downloads,
watermark_downloads: editForm.watermark_downloads,
enable_devtools_protection: editForm.enable_devtools_protection,
use_canvas_rendering: editForm.use_canvas_rendering,
};
// Only include fields that have defined values
@@ -940,12 +972,81 @@ export const EventDetailsPage: React.FC = () => {
{/* Feedback Settings */}
<div className="mt-4 pt-4 border-t border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('feedback.settings', 'Feedback Settings')}</h3>
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('feedback.settings.title', 'Guest Feedback Settings')}</h3>
<FeedbackSettings
settings={feedbackSettings}
onChange={setFeedbackSettings}
/>
</div>
{/* Download Protection Settings */}
<div className="mt-4 pt-4 border-t border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-900 mb-3 flex items-center gap-2">
<Shield className="w-4 h-4 text-primary-600" />
{t('events.downloadProtection', 'Download Protection')}
</h3>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.allow_downloads}
onChange={(e) => setEditForm(prev => ({ ...prev, allow_downloads: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Download className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">{t('events.allowDownloads', 'Allow photo downloads')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.disable_right_click}
onChange={(e) => setEditForm(prev => ({ ...prev, disable_right_click: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<MousePointer className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">{t('events.disableRightClick', 'Block right-click menu')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.watermark_downloads}
onChange={(e) => setEditForm(prev => ({ ...prev, watermark_downloads: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Droplets className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">{t('events.watermarkDownloads', 'Add watermark to downloads')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.enable_devtools_protection}
onChange={(e) => setEditForm(prev => ({ ...prev, enable_devtools_protection: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Monitor className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">{t('events.enableDevtoolsProtection', 'Detect developer tools')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.use_canvas_rendering}
onChange={(e) => setEditForm(prev => ({ ...prev, use_canvas_rendering: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">{t('events.useCanvasRendering', 'Canvas rendering (advanced protection)')}</span>
</label>
<p className="text-xs text-neutral-500 mt-2">
{t('events.protectionInfo', 'Protection features help prevent unauthorized downloads but cannot block all methods.')}
</p>
</div>
</div>
</div>
) : (
<dl className="space-y-4">
@@ -1037,6 +1138,50 @@ export const EventDetailsPage: React.FC = () => {
)}
</dd>
</div>
{/* Download Protection Display */}
<div className="pt-3 mt-3 border-t border-neutral-200">
<dt className="text-sm font-medium text-neutral-500 flex items-center gap-2">
<Shield className="w-4 h-4" />
{t('events.downloadProtection', 'Download Protection')}
</dt>
<dd className="mt-2 text-sm text-neutral-900">
<div className="flex flex-wrap gap-2">
<span className={`inline-flex items-center px-2 py-1 text-xs font-medium rounded ${
event.protection_level === 'maximum' ? 'bg-red-100 text-red-700' :
event.protection_level === 'enhanced' ? 'bg-orange-100 text-orange-700' :
event.protection_level === 'standard' ? 'bg-blue-100 text-blue-700' :
'bg-neutral-100 text-neutral-700'
}`}>
{event.protection_level || 'standard'}
</span>
{event.disable_right_click && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
<MousePointer className="w-3 h-3 mr-1" />
{t('events.rightClickBlocked', 'Right-click blocked')}
</span>
)}
{event.enable_devtools_protection && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
<Monitor className="w-3 h-3 mr-1" />
{t('events.devtoolsDetection', 'DevTools detection')}
</span>
)}
{!event.allow_downloads && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded">
<Download className="w-3 h-3 mr-1" />
{t('events.downloadsDisabled', 'Downloads disabled')}
</span>
)}
{event.watermark_downloads && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
<Droplets className="w-3 h-3 mr-1" />
{t('events.watermarked', 'Watermarked')}
</span>
)}
</div>
</dd>
</div>
</dl>
)}
</Card>
+5 -1
View File
@@ -7,13 +7,14 @@ import {
EventsTab,
StatusTab,
SecurityTab,
ImageSecurityTab,
CategoriesTab,
AnalyticsTab,
ModerationTab,
StylingTab,
} from '../../features/settings';
type TabType = 'general' | 'events' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation' | 'styling';
type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'categories' | 'analytics' | 'moderation' | 'styling';
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('general');
@@ -68,6 +69,7 @@ export const SettingsPage: React.FC = () => {
{ key: 'events', label: t('settings.events.title', 'Event Creation') },
{ key: 'status', label: t('settings.systemStatus.title') },
{ key: 'security', label: t('settings.security.title') },
{ key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection') },
{ key: 'categories', label: t('settings.categories.title') },
{ key: 'analytics', label: t('settings.analytics.title') },
{ key: 'moderation', label: t('settings.moderation.title', 'Moderation') },
@@ -151,6 +153,8 @@ export const SettingsPage: React.FC = () => {
/>
)}
{activeTab === 'imageSecurity' && <ImageSecurityTab />}
{activeTab === 'categories' && <CategoriesTab />}
{activeTab === 'analytics' && (
+9 -3
View File
@@ -34,6 +34,13 @@ export interface Event {
unique_visitors?: number;
source_mode?: 'managed' | 'reference' | string;
external_path?: string | null;
// Download protection fields
allow_downloads?: boolean;
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
disable_right_click?: boolean;
watermark_downloads?: boolean;
enable_devtools_protection?: boolean;
use_canvas_rendering?: boolean;
}
export interface GalleryInfo {
@@ -56,15 +63,13 @@ export interface Photo {
download_url_template?: string;
requires_token?: boolean;
type: 'collage' | 'individual' | 'video';
media_type?: 'photo' | 'video';
mime_type?: string;
category_id?: number | string | null;
category_name?: string;
category_slug?: string;
size: number;
uploaded_at: string;
// Media type fields
media_type?: 'image' | 'video';
media_type?: 'photo' | 'video' | 'image';
mime_type?: string;
duration?: number; // Duration in seconds for videos
video_codec?: string;
@@ -107,6 +112,7 @@ export interface GalleryData {
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
image_quality?: number;
use_canvas_rendering?: boolean;
enable_devtools_protection?: boolean;
fragmentation_level?: number;
overlay_protection?: boolean;
};