Initial commit - Project start (July 17, 2025)
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Has been skipped

Original: feat: enhance security logging and ensure rate limit blocks are properly tracked

- Add comprehensive logging for rate limit blocks with full request details
  - IP address (with proper proxy detection), user agent, headers, timestamps
  - Rate limit info (current count, limit, remaining, reset time)
  - Separate tracking for auth vs general endpoints

- Enhance authentication failure logging
  - JWT validation failures with detailed error info
  - Admin auth attempts without token
  - Failed token validation with user context
  - All events include IP, path, method, user agent

- Improve Winston logger configuration for production
  - Add automatic log rotation (10MB errors, 50MB combined)
  - Create separate security.log for auth/rate limit events
  - Ensure logs directory exists automatically
  - Add structured JSON format for log aggregation
  - Support container logging with LOG_TO_CONSOLE env var

- Create comprehensive documentation
  - Security logging guide with examples
  - Monitoring recommendations
  - Configuration reference

- Add test script to verify logging functionality

All rate limit settings remain configurable via admin panel:
- Window duration, max requests, auth limits
- Skip authenticated requests option
- Public endpoints only option

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-18 19:25:15 +02:00
commit 1773ed5f95
354 changed files with 61508 additions and 0 deletions
@@ -0,0 +1,84 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import { getAuthToken } from '../config/api';
import { authService } from '../services';
import type { AdminUser } from '../types';
interface AdminAuthContextType {
isAuthenticated: boolean;
user: AdminUser | null;
login: (token: string, user: AdminUser) => void;
logout: () => void;
isLoading: boolean;
error: string | null;
}
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined);
export const useAdminAuth = () => {
const context = useContext(AdminAuthContext);
if (!context) {
throw new Error('useAdminAuth must be used within an AdminAuthProvider');
}
return context;
};
interface AdminAuthProviderProps {
children: ReactNode;
}
export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState<AdminUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Check if user has a valid token on mount
const checkAuth = async () => {
try {
const token = getAuthToken(true);
if (token) {
// For now, just assume the token is valid
// TODO: Validate token with backend and get user info
setIsAuthenticated(true);
}
} catch (error) {
// Auth check failed - user needs to login
setError('Failed to check authentication');
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
const login = (_token: string, user: AdminUser) => {
// Token is already stored in cookie by authService
setUser(user);
setError(null);
setIsAuthenticated(true);
};
const logout = () => {
authService.adminLogout();
setIsAuthenticated(false);
setUser(null);
};
return (
<AdminAuthContext.Provider
value={{
isAuthenticated,
user,
login,
logout,
isLoading,
error,
}}
>
{children}
</AdminAuthContext.Provider>
);
};
@@ -0,0 +1,131 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import { authService } from '../services';
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
interface GalleryEvent {
id: number;
event_name: string;
event_type: string;
event_date: string;
welcome_message?: string;
color_theme?: string;
expires_at: string;
}
interface GalleryAuthContextType {
isAuthenticated: boolean;
event: GalleryEvent | null;
login: (slug: string, password: string, recaptchaToken?: string | null) => Promise<void>;
logout: () => void;
isLoading: boolean;
error: string | null;
}
const GalleryAuthContext = createContext<GalleryAuthContextType | undefined>(undefined);
export const useGalleryAuth = () => {
const context = useContext(GalleryAuthContext);
if (!context) {
throw new Error('useGalleryAuth must be used within a GalleryAuthProvider');
}
return context;
};
interface GalleryAuthProviderProps {
children: ReactNode;
}
export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [event, setEvent] = useState<GalleryEvent | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Get current gallery slug from URL
const getCurrentGallerySlug = () => {
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
return pathParts[2];
}
return null;
};
useEffect(() => {
// Clean up old authentication data on mount
cleanupOldGalleryAuth();
// Check if user has a valid token on mount
const currentSlug = getCurrentGallerySlug();
if (currentSlug) {
// Try to restore event data from localStorage with slug-specific key
const storedEvent = localStorage.getItem(`gallery_event_${currentSlug}`);
const storedToken = localStorage.getItem(`gallery_token_${currentSlug}`);
if (storedEvent && storedToken) {
try {
const eventData = JSON.parse(storedEvent);
// Verify the stored event matches the current gallery slug
if (eventData && eventData.id) {
setEvent(eventData);
setIsAuthenticated(true);
} else {
// Clear invalid data
localStorage.removeItem(`gallery_event_${currentSlug}`);
localStorage.removeItem(`gallery_token_${currentSlug}`);
}
} catch (error) {
// Invalid stored data - clear it
localStorage.removeItem(`gallery_event_${currentSlug}`);
localStorage.removeItem(`gallery_token_${currentSlug}`);
}
}
}
setIsLoading(false);
}, []);
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
try {
setError(null);
setIsLoading(true);
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
setEvent(response.event);
setIsAuthenticated(true);
// Store event data and token in localStorage with slug-specific key
localStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
localStorage.setItem(`gallery_token_${slug}`, response.token);
} catch (err: any) {
setError(err.response?.data?.error || 'Invalid password');
throw err;
} finally {
setIsLoading(false);
}
};
const logout = () => {
const currentSlug = getCurrentGallerySlug();
if (currentSlug) {
localStorage.removeItem(`gallery_event_${currentSlug}`);
localStorage.removeItem(`gallery_token_${currentSlug}`);
}
authService.galleryLogout();
setIsAuthenticated(false);
setEvent(null);
};
return (
<GalleryAuthContext.Provider
value={{
isAuthenticated,
event,
login,
logout,
isLoading,
error,
}}
>
{children}
</GalleryAuthContext.Provider>
);
};
@@ -0,0 +1,75 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { setMaintenanceModeCallback } from '../config/api';
import { getApiBaseUrl } from '../utils/url';
interface MaintenanceContextType {
isMaintenanceMode: boolean;
setMaintenanceMode: (enabled: boolean) => void;
}
const MaintenanceContext = createContext<MaintenanceContextType | undefined>(undefined);
export const useMaintenanceMode = () => {
const context = useContext(MaintenanceContext);
if (!context) {
throw new Error('useMaintenanceMode must be used within MaintenanceProvider');
}
return context;
};
interface MaintenanceProviderProps {
children: ReactNode;
}
export const MaintenanceProvider: React.FC<MaintenanceProviderProps> = ({ children }) => {
const [isMaintenanceMode, setIsMaintenanceMode] = useState(false);
// Check maintenance mode status on mount
const { data: settings } = useQuery({
queryKey: ['public-settings-maintenance'],
queryFn: async () => {
try {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.status === 503) {
setIsMaintenanceMode(true);
return null;
}
return response.json();
} catch (error) {
// If we can't reach the server, don't assume maintenance mode
return null;
}
},
staleTime: 30 * 1000, // Check every 30 seconds
refetchInterval: 30 * 1000,
});
// Update maintenance mode based on settings
useEffect(() => {
if (settings?.maintenance_mode !== undefined) {
setIsMaintenanceMode(settings.maintenance_mode);
}
}, [settings]);
// Set up the callback for API interceptor
useEffect(() => {
setMaintenanceModeCallback((enabled: boolean) => {
setIsMaintenanceMode(enabled);
});
return () => {
setMaintenanceModeCallback(null as any);
};
}, []);
const setMaintenanceMode = (enabled: boolean) => {
setIsMaintenanceMode(enabled);
};
return (
<MaintenanceContext.Provider value={{ isMaintenanceMode, setMaintenanceMode }}>
{children}
</MaintenanceContext.Provider>
);
};
+228
View File
@@ -0,0 +1,228 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import type { ReactNode } from 'react';
import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types';
interface ThemeContextType {
theme: ThemeConfig;
themeName: string;
setTheme: (theme: ThemeConfig) => void;
setThemeByName: (themeName: string) => void;
applyTheme: (theme: ThemeConfig) => void;
resetTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
interface ThemeProviderProps {
children: ReactNode;
initialTheme?: ThemeConfig;
initialThemeName?: string;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
children,
initialTheme = GALLERY_THEME_PRESETS.default.config,
initialThemeName = 'default'
}) => {
const [theme, setTheme] = useState<ThemeConfig>(initialTheme);
const [themeName, setThemeName] = useState(initialThemeName);
const applyTheme = useCallback((themeConfig: ThemeConfig) => {
const root = document.documentElement;
// Apply CSS variables
if (themeConfig.primaryColor) {
root.style.setProperty('--color-primary', themeConfig.primaryColor);
// Generate primary color shades
root.style.setProperty('--color-primary-light', lightenColor(themeConfig.primaryColor, 20));
root.style.setProperty('--color-primary-dark', darkenColor(themeConfig.primaryColor, 20));
}
if (themeConfig.accentColor) {
root.style.setProperty('--color-accent', themeConfig.accentColor);
}
if (themeConfig.backgroundColor) {
root.style.setProperty('--color-background', themeConfig.backgroundColor);
}
if (themeConfig.textColor) {
root.style.setProperty('--color-text', themeConfig.textColor);
}
if (themeConfig.fontFamily) {
root.style.setProperty('--font-family', themeConfig.fontFamily);
}
if (themeConfig.headingFontFamily) {
root.style.setProperty('--heading-font-family', themeConfig.headingFontFamily);
}
if (themeConfig.borderRadius) {
const radiusMap = {
none: '0',
sm: '0.25rem',
md: '0.5rem',
lg: '1rem',
};
root.style.setProperty('--border-radius', radiusMap[themeConfig.borderRadius]);
}
// Apply font size
if (themeConfig.fontSize) {
const sizeMap = {
small: '14px',
normal: '16px',
large: '18px',
};
root.style.setProperty('--font-size-base', sizeMap[themeConfig.fontSize]);
}
// Apply shadow style
if (themeConfig.shadowStyle) {
const shadowMap = {
none: 'none',
subtle: '0 1px 3px rgba(0,0,0,0.12)',
normal: '0 4px 6px rgba(0,0,0,0.1)',
dramatic: '0 10px 25px rgba(0,0,0,0.15)',
};
root.style.setProperty('--shadow-default', shadowMap[themeConfig.shadowStyle]);
}
// Apply background pattern
if (themeConfig.backgroundPattern && themeConfig.backgroundPattern !== 'none') {
const patternMap = {
dots: `radial-gradient(circle, ${themeConfig.textColor}20 1px, transparent 1px)`,
grid: `linear-gradient(${themeConfig.textColor}10 1px, transparent 1px), linear-gradient(90deg, ${themeConfig.textColor}10 1px, transparent 1px)`,
waves: `url("data:image/svg+xml,%3Csvg width='100' height='20' viewBox='0 0 100 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M21.184 20c.357-.13.72-.264 1.088-.402l1.768-.661C33.64 15.347 39.647 14 50 14c10.271 0 15.362 1.222 24.629 4.928.955.383 1.869.74 2.75 1.072h6.225c-2.51-.73-5.139-1.691-8.233-2.928C65.888 13.278 60.562 12 50 12c-10.626 0-16.855 1.397-26.66 5.063l-1.767.662c-2.475.923-4.66 1.674-6.724 2.275h6.335zm0-20C13.258 2.892 8.077 4 0 4V2c5.744 0 9.951-.574 14.85-2h6.334zM77.38 0C85.239 2.966 90.502 4 100 4V2c-6.842 0-11.386-.542-16.396-2h-6.225zM0 14c8.44 0 13.718-1.21 22.272-4.402l1.768-.661C33.64 5.347 39.647 4 50 4c10.271 0 15.362 1.222 24.629 4.928C84.112 12.722 89.438 14 100 14v-2c-10.271 0-15.362-1.222-24.629-4.928C65.888 3.278 60.562 2 50 2 39.374 2 33.145 3.397 23.34 7.063l-1.767.662C13.223 10.84 8.163 12 0 12v2z' fill='${themeConfig.textColor}' fill-opacity='0.05'/%3E%3C/svg%3E")`,
};
root.style.setProperty('--background-pattern', patternMap[themeConfig.backgroundPattern]);
root.style.setProperty('--background-pattern-size', themeConfig.backgroundPattern === 'dots' ? '20px 20px' : themeConfig.backgroundPattern === 'grid' ? '20px 20px' : '100px 20px');
} else {
root.style.removeProperty('--background-pattern');
root.style.removeProperty('--background-pattern-size');
}
// Apply custom CSS if provided
if (themeConfig.customCss) {
let styleElement = document.getElementById('custom-theme-styles');
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = 'custom-theme-styles';
document.head.appendChild(styleElement);
}
styleElement.textContent = themeConfig.customCss;
}
}, []);
const setThemeConfig = useCallback((newTheme: ThemeConfig) => {
setTheme(newTheme);
applyTheme(newTheme);
}, [applyTheme]);
const setThemeByName = useCallback((name: string) => {
const presetTheme = GALLERY_THEME_PRESETS[name];
if (presetTheme) {
setThemeName(name);
setTheme(presetTheme.config);
applyTheme(presetTheme.config);
}
}, [applyTheme]);
const resetTheme = useCallback(() => {
setThemeByName('default');
}, [setThemeByName]);
// Apply theme when it changes, but skip if it's the same
useEffect(() => {
const root = document.documentElement;
const currentPrimary = root.style.getPropertyValue('--color-primary');
// Only apply if the theme has actually changed
if (currentPrimary !== theme.primaryColor) {
applyTheme(theme);
}
}, [theme, applyTheme]);
// Load theme from localStorage on mount (skip if in gallery view)
useEffect(() => {
// Check if we're in a gallery view by looking at the URL
const isGalleryView = window.location.pathname.includes('/gallery/');
if (!isGalleryView) {
const savedTheme = localStorage.getItem('gallery-theme');
if (savedTheme) {
try {
const parsed = JSON.parse(savedTheme);
setTheme(parsed.config);
setThemeName(parsed.name);
} catch (e) {
console.error('Failed to load saved theme:', e);
}
}
}
}, []);
// Save theme to localStorage when it changes (but not in gallery views)
useEffect(() => {
// Don't save theme in gallery views to avoid conflicts
const isGalleryView = window.location.pathname.includes('/gallery/');
if (!isGalleryView) {
// Only save if theme has actually changed
const currentSaved = localStorage.getItem('gallery-theme');
const newValue = JSON.stringify({ name: themeName, config: theme });
if (currentSaved !== newValue) {
localStorage.setItem('gallery-theme', newValue);
}
}
}, [theme, themeName]);
const contextValue = useMemo(() => ({
theme,
themeName,
setTheme: setThemeConfig,
setThemeByName,
applyTheme,
resetTheme
}), [theme, themeName, setThemeConfig, setThemeByName, applyTheme, resetTheme]);
return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
};
// Utility functions for color manipulation
function lightenColor(color: string, percent: number): string {
const num = parseInt(color.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = (num >> 16) + amt;
const G = (num >> 8 & 0x00FF) + amt;
const B = (num & 0x0000FF) + amt;
return '#' + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 +
(G < 255 ? G < 1 ? 0 : G : 255) * 0x100 +
(B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1);
}
function darkenColor(color: string, percent: number): string {
const num = parseInt(color.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = (num >> 16) - amt;
const G = (num >> 8 & 0x00FF) - amt;
const B = (num & 0x0000FF) - amt;
return '#' + (0x1000000 + (R > 0 ? R : 0) * 0x10000 +
(G > 0 ? G : 0) * 0x100 +
(B > 0 ? B : 0)).toString(16).slice(1);
}
// Re-export types
export type { ThemeConfig, EventTheme };
export { GALLERY_THEME_PRESETS };
+6
View File
@@ -0,0 +1,6 @@
export { GalleryAuthProvider, useGalleryAuth } from './GalleryAuthContext';
export { AdminAuthProvider, useAdminAuth } from './AdminAuthContext';
export { ThemeProvider, useTheme, GALLERY_THEME_PRESETS } from './ThemeContext';
export type { ThemeConfig, EventTheme } from './ThemeContext';
export { GALLERY_THEME_PRESETS as PRESET_THEMES } from './ThemeContext'; // For backward compatibility
export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext';