mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 08:11:09 -05:00
feat: Refactor rides app with unique constraints, mixins, and enhanced documentation
- Added migration to convert unique_together constraints to UniqueConstraint for RideModel. - Introduced RideFormMixin for handling entity suggestions in ride forms. - Created comprehensive code standards documentation outlining formatting, docstring requirements, complexity guidelines, and testing requirements. - Established error handling guidelines with a structured exception hierarchy and best practices for API and view error handling. - Documented view pattern guidelines, emphasizing the use of CBVs, FBVs, and ViewSets with examples. - Implemented a benchmarking script for query performance analysis and optimization. - Developed security documentation detailing measures, configurations, and a security checklist. - Compiled a database optimization guide covering indexing strategies, query optimization patterns, and computed fields.
This commit is contained in:
@@ -4,3 +4,12 @@ from django.apps import AppConfig
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.core"
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Application initialization.
|
||||
|
||||
Imports security checks to register them with Django's check framework.
|
||||
"""
|
||||
# Import security checks to register them
|
||||
from . import checks # noqa: F401
|
||||
|
||||
372
backend/apps/core/checks.py
Normal file
372
backend/apps/core/checks.py
Normal file
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
Django System Checks for Security Configuration.
|
||||
|
||||
This module implements Django system checks that validate security settings
|
||||
at startup. These checks help catch security misconfigurations before
|
||||
deployment.
|
||||
|
||||
Usage:
|
||||
These checks run automatically when Django starts. They can also be run
|
||||
manually with: python manage.py check --tag=security
|
||||
|
||||
Security checks included:
|
||||
- SECRET_KEY validation (not default, sufficient entropy)
|
||||
- DEBUG mode check (should be False in production)
|
||||
- ALLOWED_HOSTS check (should be configured in production)
|
||||
- Security headers validation
|
||||
- HTTPS settings validation
|
||||
- Cookie security settings
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from django.conf import settings
|
||||
from django.core.checks import Error, Warning, register, Tags
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Secret Key Validation
|
||||
# =============================================================================
|
||||
|
||||
@register(Tags.security)
|
||||
def check_secret_key(app_configs, **kwargs):
|
||||
"""
|
||||
Check that SECRET_KEY is properly configured.
|
||||
|
||||
Validates:
|
||||
- Key is not a known default/placeholder value
|
||||
- Key has sufficient entropy (length and character variety)
|
||||
"""
|
||||
errors = []
|
||||
secret_key = getattr(settings, 'SECRET_KEY', '')
|
||||
|
||||
# Check for empty or missing key
|
||||
if not secret_key:
|
||||
errors.append(
|
||||
Error(
|
||||
'SECRET_KEY is not set.',
|
||||
hint='Set a strong, random SECRET_KEY in your environment.',
|
||||
id='security.E001',
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
# Check for known insecure default values
|
||||
insecure_defaults = [
|
||||
'django-insecure',
|
||||
'your-secret-key',
|
||||
'change-me',
|
||||
'changeme',
|
||||
'secret',
|
||||
'xxx',
|
||||
'test',
|
||||
'development',
|
||||
'dev-key',
|
||||
]
|
||||
|
||||
key_lower = secret_key.lower()
|
||||
for default in insecure_defaults:
|
||||
if default in key_lower:
|
||||
errors.append(
|
||||
Error(
|
||||
f'SECRET_KEY appears to contain an insecure default value: "{default}"',
|
||||
hint='Generate a new secret key using: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"',
|
||||
id='security.E002',
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
# Check minimum length (Django recommends at least 50 characters)
|
||||
if len(secret_key) < 50:
|
||||
errors.append(
|
||||
Warning(
|
||||
f'SECRET_KEY is only {len(secret_key)} characters long.',
|
||||
hint='A secret key should be at least 50 characters for proper security.',
|
||||
id='security.W001',
|
||||
)
|
||||
)
|
||||
|
||||
# Check for sufficient character variety
|
||||
has_upper = bool(re.search(r'[A-Z]', secret_key))
|
||||
has_lower = bool(re.search(r'[a-z]', secret_key))
|
||||
has_digit = bool(re.search(r'[0-9]', secret_key))
|
||||
has_special = bool(re.search(r'[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]', secret_key))
|
||||
|
||||
char_types = sum([has_upper, has_lower, has_digit, has_special])
|
||||
if char_types < 3:
|
||||
errors.append(
|
||||
Warning(
|
||||
'SECRET_KEY lacks character variety.',
|
||||
hint='A good secret key should contain uppercase, lowercase, digits, and special characters.',
|
||||
id='security.W002',
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Debug Mode Check
|
||||
# =============================================================================
|
||||
|
||||
@register(Tags.security)
|
||||
def check_debug_mode(app_configs, **kwargs):
|
||||
"""
|
||||
Check that DEBUG is False in production-like environments.
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check if we're in a production-like environment
|
||||
env = os.environ.get('DJANGO_SETTINGS_MODULE', '')
|
||||
is_production = 'production' in env.lower() or 'prod' in env.lower()
|
||||
|
||||
if is_production and settings.DEBUG:
|
||||
errors.append(
|
||||
Error(
|
||||
'DEBUG is True in what appears to be a production environment.',
|
||||
hint='Set DEBUG=False in production settings.',
|
||||
id='security.E003',
|
||||
)
|
||||
)
|
||||
|
||||
# Also check if DEBUG is True with ALLOWED_HOSTS configured
|
||||
# (indicates possible production deployment with debug on)
|
||||
if settings.DEBUG and settings.ALLOWED_HOSTS and '*' not in settings.ALLOWED_HOSTS:
|
||||
if len(settings.ALLOWED_HOSTS) > 0 and 'localhost' not in settings.ALLOWED_HOSTS[0]:
|
||||
errors.append(
|
||||
Warning(
|
||||
'DEBUG is True but ALLOWED_HOSTS contains non-localhost values.',
|
||||
hint='This may indicate DEBUG is accidentally enabled in a deployed environment.',
|
||||
id='security.W003',
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ALLOWED_HOSTS Check
|
||||
# =============================================================================
|
||||
|
||||
@register(Tags.security)
|
||||
def check_allowed_hosts(app_configs, **kwargs):
|
||||
"""
|
||||
Check ALLOWED_HOSTS configuration.
|
||||
"""
|
||||
errors = []
|
||||
allowed_hosts = getattr(settings, 'ALLOWED_HOSTS', [])
|
||||
|
||||
if not settings.DEBUG:
|
||||
# In non-debug mode, ALLOWED_HOSTS must be set
|
||||
if not allowed_hosts:
|
||||
errors.append(
|
||||
Error(
|
||||
'ALLOWED_HOSTS is empty but DEBUG is False.',
|
||||
hint='Set ALLOWED_HOSTS to a list of allowed hostnames.',
|
||||
id='security.E004',
|
||||
)
|
||||
)
|
||||
elif '*' in allowed_hosts:
|
||||
errors.append(
|
||||
Error(
|
||||
'ALLOWED_HOSTS contains "*" which allows all hosts.',
|
||||
hint='Specify explicit hostnames instead of wildcards.',
|
||||
id='security.E005',
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Security Headers Check
|
||||
# =============================================================================
|
||||
|
||||
@register(Tags.security)
|
||||
def check_security_headers(app_configs, **kwargs):
|
||||
"""
|
||||
Check that security headers are properly configured.
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check X-Frame-Options
|
||||
x_frame_options = getattr(settings, 'X_FRAME_OPTIONS', None)
|
||||
if x_frame_options not in ('DENY', 'SAMEORIGIN'):
|
||||
errors.append(
|
||||
Warning(
|
||||
f'X_FRAME_OPTIONS is set to "{x_frame_options}" or not set.',
|
||||
hint='Set X_FRAME_OPTIONS to "DENY" or "SAMEORIGIN" to prevent clickjacking.',
|
||||
id='security.W004',
|
||||
)
|
||||
)
|
||||
|
||||
# Check content type sniffing protection
|
||||
if not getattr(settings, 'SECURE_CONTENT_TYPE_NOSNIFF', False):
|
||||
errors.append(
|
||||
Warning(
|
||||
'SECURE_CONTENT_TYPE_NOSNIFF is not enabled.',
|
||||
hint='Set SECURE_CONTENT_TYPE_NOSNIFF = True to prevent MIME type sniffing.',
|
||||
id='security.W005',
|
||||
)
|
||||
)
|
||||
|
||||
# Check referrer policy
|
||||
referrer_policy = getattr(settings, 'SECURE_REFERRER_POLICY', None)
|
||||
if not referrer_policy:
|
||||
errors.append(
|
||||
Warning(
|
||||
'SECURE_REFERRER_POLICY is not set.',
|
||||
hint='Set SECURE_REFERRER_POLICY to control referrer header behavior.',
|
||||
id='security.W006',
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTPS Settings Check
|
||||
# =============================================================================
|
||||
|
||||
@register(Tags.security)
|
||||
def check_https_settings(app_configs, **kwargs):
|
||||
"""
|
||||
Check HTTPS-related security settings for production.
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Skip these checks in debug mode
|
||||
if settings.DEBUG:
|
||||
return errors
|
||||
|
||||
# Check SSL redirect
|
||||
if not getattr(settings, 'SECURE_SSL_REDIRECT', False):
|
||||
errors.append(
|
||||
Warning(
|
||||
'SECURE_SSL_REDIRECT is not enabled.',
|
||||
hint='Set SECURE_SSL_REDIRECT = True to redirect HTTP to HTTPS.',
|
||||
id='security.W007',
|
||||
)
|
||||
)
|
||||
|
||||
# Check HSTS settings
|
||||
hsts_seconds = getattr(settings, 'SECURE_HSTS_SECONDS', 0)
|
||||
if hsts_seconds < 31536000: # Less than 1 year
|
||||
errors.append(
|
||||
Warning(
|
||||
f'SECURE_HSTS_SECONDS is {hsts_seconds} (less than 1 year).',
|
||||
hint='Set SECURE_HSTS_SECONDS to at least 31536000 (1 year) for HSTS preload eligibility.',
|
||||
id='security.W008',
|
||||
)
|
||||
)
|
||||
|
||||
if not getattr(settings, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', False):
|
||||
errors.append(
|
||||
Warning(
|
||||
'SECURE_HSTS_INCLUDE_SUBDOMAINS is not enabled.',
|
||||
hint='Set SECURE_HSTS_INCLUDE_SUBDOMAINS = True to include all subdomains in HSTS.',
|
||||
id='security.W009',
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cookie Security Check
|
||||
# =============================================================================
|
||||
|
||||
@register(Tags.security)
|
||||
def check_cookie_security(app_configs, **kwargs):
|
||||
"""
|
||||
Check cookie security settings for production.
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Skip in debug mode
|
||||
if settings.DEBUG:
|
||||
return errors
|
||||
|
||||
# Check session cookie security
|
||||
if not getattr(settings, 'SESSION_COOKIE_SECURE', False):
|
||||
errors.append(
|
||||
Warning(
|
||||
'SESSION_COOKIE_SECURE is not enabled.',
|
||||
hint='Set SESSION_COOKIE_SECURE = True to only send session cookies over HTTPS.',
|
||||
id='security.W010',
|
||||
)
|
||||
)
|
||||
|
||||
if not getattr(settings, 'SESSION_COOKIE_HTTPONLY', True):
|
||||
errors.append(
|
||||
Warning(
|
||||
'SESSION_COOKIE_HTTPONLY is disabled.',
|
||||
hint='Set SESSION_COOKIE_HTTPONLY = True to prevent JavaScript access to session cookies.',
|
||||
id='security.W011',
|
||||
)
|
||||
)
|
||||
|
||||
# Check CSRF cookie security
|
||||
if not getattr(settings, 'CSRF_COOKIE_SECURE', False):
|
||||
errors.append(
|
||||
Warning(
|
||||
'CSRF_COOKIE_SECURE is not enabled.',
|
||||
hint='Set CSRF_COOKIE_SECURE = True to only send CSRF cookies over HTTPS.',
|
||||
id='security.W012',
|
||||
)
|
||||
)
|
||||
|
||||
# Check SameSite attributes
|
||||
session_samesite = getattr(settings, 'SESSION_COOKIE_SAMESITE', 'Lax')
|
||||
if session_samesite not in ('Strict', 'Lax'):
|
||||
errors.append(
|
||||
Warning(
|
||||
f'SESSION_COOKIE_SAMESITE is set to "{session_samesite}".',
|
||||
hint='Set SESSION_COOKIE_SAMESITE to "Strict" or "Lax" for CSRF protection.',
|
||||
id='security.W013',
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Database Security Check
|
||||
# =============================================================================
|
||||
|
||||
@register(Tags.security)
|
||||
def check_database_security(app_configs, **kwargs):
|
||||
"""
|
||||
Check database connection security settings.
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Skip in debug mode
|
||||
if settings.DEBUG:
|
||||
return errors
|
||||
|
||||
databases = getattr(settings, 'DATABASES', {})
|
||||
default_db = databases.get('default', {})
|
||||
|
||||
# Check for empty password
|
||||
if not default_db.get('PASSWORD') and default_db.get('ENGINE', '').endswith('postgresql'):
|
||||
errors.append(
|
||||
Warning(
|
||||
'Database password is empty.',
|
||||
hint='Set a strong password for database authentication.',
|
||||
id='security.W014',
|
||||
)
|
||||
)
|
||||
|
||||
# Check for SSL mode in PostgreSQL
|
||||
options = default_db.get('OPTIONS', {})
|
||||
if 'sslmode' not in str(options) and default_db.get('ENGINE', '').endswith('postgresql'):
|
||||
errors.append(
|
||||
Warning(
|
||||
'Database SSL mode is not explicitly configured.',
|
||||
hint='Consider setting sslmode in database OPTIONS for encrypted connections.',
|
||||
id='security.W015',
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
@@ -65,6 +65,14 @@ class BusinessLogicError(ThrillWikiException):
|
||||
status_code = 400
|
||||
|
||||
|
||||
class ServiceError(ThrillWikiException):
|
||||
"""Raised when a service operation fails."""
|
||||
|
||||
default_message = "Service operation failed"
|
||||
error_code = "SERVICE_ERROR"
|
||||
status_code = 500
|
||||
|
||||
|
||||
class ExternalServiceError(ThrillWikiException):
|
||||
"""Raised when external service calls fail."""
|
||||
|
||||
|
||||
240
backend/apps/core/management/commands/security_audit.py
Normal file
240
backend/apps/core/management/commands/security_audit.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Security Audit Management Command.
|
||||
|
||||
Runs comprehensive security checks on the Django application and generates
|
||||
a security audit report.
|
||||
|
||||
Usage:
|
||||
python manage.py security_audit
|
||||
python manage.py security_audit --output report.txt
|
||||
python manage.py security_audit --verbose
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.checks import registry, Tags
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Run security audit and generate a report'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
type=str,
|
||||
help='Output file for the security report',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose',
|
||||
action='store_true',
|
||||
help='Show detailed information for each check',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.verbose = options.get('verbose', False)
|
||||
output_file = options.get('output')
|
||||
|
||||
report_lines = []
|
||||
|
||||
self.log("=" * 60, report_lines)
|
||||
self.log("ThrillWiki Security Audit Report", report_lines)
|
||||
self.log("=" * 60, report_lines)
|
||||
self.log("", report_lines)
|
||||
|
||||
# Run Django's built-in security checks
|
||||
self.log("Running Django Security Checks...", report_lines)
|
||||
self.log("-" * 40, report_lines)
|
||||
self.run_django_checks(report_lines)
|
||||
|
||||
# Run custom configuration checks
|
||||
self.log("", report_lines)
|
||||
self.log("Configuration Analysis...", report_lines)
|
||||
self.log("-" * 40, report_lines)
|
||||
self.check_configuration(report_lines)
|
||||
|
||||
# Run middleware checks
|
||||
self.log("", report_lines)
|
||||
self.log("Middleware Analysis...", report_lines)
|
||||
self.log("-" * 40, report_lines)
|
||||
self.check_middleware(report_lines)
|
||||
|
||||
# Summary
|
||||
self.log("", report_lines)
|
||||
self.log("=" * 60, report_lines)
|
||||
self.log("Audit Complete", report_lines)
|
||||
self.log("=" * 60, report_lines)
|
||||
|
||||
# Write to file if specified
|
||||
if output_file:
|
||||
with open(output_file, 'w') as f:
|
||||
f.write('\n'.join(report_lines))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'\nReport saved to: {output_file}')
|
||||
)
|
||||
|
||||
def log(self, message, report_lines):
|
||||
"""Log message to both stdout and report."""
|
||||
self.stdout.write(message)
|
||||
report_lines.append(message)
|
||||
|
||||
def run_django_checks(self, report_lines):
|
||||
"""Run Django's security checks."""
|
||||
errors = registry.run_checks(tags=[Tags.security])
|
||||
|
||||
if not errors:
|
||||
self.log(
|
||||
self.style.SUCCESS(" ✓ All Django security checks passed"),
|
||||
report_lines
|
||||
)
|
||||
else:
|
||||
for error in errors:
|
||||
if error.is_serious():
|
||||
prefix = self.style.ERROR(" ✗ ERROR")
|
||||
else:
|
||||
prefix = self.style.WARNING(" ! WARNING")
|
||||
|
||||
self.log(f"{prefix}: {error.msg}", report_lines)
|
||||
if error.hint and self.verbose:
|
||||
self.log(f" Hint: {error.hint}", report_lines)
|
||||
|
||||
def check_configuration(self, report_lines):
|
||||
"""Check various configuration settings."""
|
||||
checks = [
|
||||
('DEBUG mode', not settings.DEBUG, 'DEBUG should be False'),
|
||||
(
|
||||
'SECRET_KEY length',
|
||||
len(settings.SECRET_KEY) >= 50,
|
||||
f'Length: {len(settings.SECRET_KEY)}'
|
||||
),
|
||||
(
|
||||
'ALLOWED_HOSTS',
|
||||
bool(settings.ALLOWED_HOSTS) and '*' not in settings.ALLOWED_HOSTS,
|
||||
str(settings.ALLOWED_HOSTS)
|
||||
),
|
||||
(
|
||||
'CSRF_TRUSTED_ORIGINS',
|
||||
bool(getattr(settings, 'CSRF_TRUSTED_ORIGINS', [])),
|
||||
str(getattr(settings, 'CSRF_TRUSTED_ORIGINS', []))
|
||||
),
|
||||
(
|
||||
'X_FRAME_OPTIONS',
|
||||
getattr(settings, 'X_FRAME_OPTIONS', '') in ('DENY', 'SAMEORIGIN'),
|
||||
str(getattr(settings, 'X_FRAME_OPTIONS', 'Not set'))
|
||||
),
|
||||
(
|
||||
'SECURE_CONTENT_TYPE_NOSNIFF',
|
||||
getattr(settings, 'SECURE_CONTENT_TYPE_NOSNIFF', False),
|
||||
str(getattr(settings, 'SECURE_CONTENT_TYPE_NOSNIFF', False))
|
||||
),
|
||||
(
|
||||
'SECURE_BROWSER_XSS_FILTER',
|
||||
getattr(settings, 'SECURE_BROWSER_XSS_FILTER', False),
|
||||
str(getattr(settings, 'SECURE_BROWSER_XSS_FILTER', False))
|
||||
),
|
||||
(
|
||||
'SESSION_COOKIE_HTTPONLY',
|
||||
getattr(settings, 'SESSION_COOKIE_HTTPONLY', True),
|
||||
str(getattr(settings, 'SESSION_COOKIE_HTTPONLY', 'Not set'))
|
||||
),
|
||||
(
|
||||
'CSRF_COOKIE_HTTPONLY',
|
||||
getattr(settings, 'CSRF_COOKIE_HTTPONLY', True),
|
||||
str(getattr(settings, 'CSRF_COOKIE_HTTPONLY', 'Not set'))
|
||||
),
|
||||
]
|
||||
|
||||
# Production-only checks
|
||||
if not settings.DEBUG:
|
||||
checks.extend([
|
||||
(
|
||||
'SECURE_SSL_REDIRECT',
|
||||
getattr(settings, 'SECURE_SSL_REDIRECT', False),
|
||||
str(getattr(settings, 'SECURE_SSL_REDIRECT', False))
|
||||
),
|
||||
(
|
||||
'SESSION_COOKIE_SECURE',
|
||||
getattr(settings, 'SESSION_COOKIE_SECURE', False),
|
||||
str(getattr(settings, 'SESSION_COOKIE_SECURE', False))
|
||||
),
|
||||
(
|
||||
'CSRF_COOKIE_SECURE',
|
||||
getattr(settings, 'CSRF_COOKIE_SECURE', False),
|
||||
str(getattr(settings, 'CSRF_COOKIE_SECURE', False))
|
||||
),
|
||||
(
|
||||
'SECURE_HSTS_SECONDS',
|
||||
getattr(settings, 'SECURE_HSTS_SECONDS', 0) >= 31536000,
|
||||
str(getattr(settings, 'SECURE_HSTS_SECONDS', 0))
|
||||
),
|
||||
])
|
||||
|
||||
for name, is_secure, value in checks:
|
||||
if is_secure:
|
||||
status = self.style.SUCCESS("✓")
|
||||
else:
|
||||
status = self.style.WARNING("!")
|
||||
|
||||
msg = f" {status} {name}"
|
||||
if self.verbose:
|
||||
msg += f" ({value})"
|
||||
|
||||
self.log(msg, report_lines)
|
||||
|
||||
def check_middleware(self, report_lines):
|
||||
"""Check security-related middleware is properly configured."""
|
||||
middleware = getattr(settings, 'MIDDLEWARE', [])
|
||||
|
||||
required_middleware = [
|
||||
('django.middleware.security.SecurityMiddleware', 'SecurityMiddleware'),
|
||||
('django.middleware.csrf.CsrfViewMiddleware', 'CSRF Middleware'),
|
||||
('django.middleware.clickjacking.XFrameOptionsMiddleware', 'X-Frame-Options'),
|
||||
]
|
||||
|
||||
custom_security_middleware = [
|
||||
('apps.core.middleware.security_headers.SecurityHeadersMiddleware', 'Security Headers'),
|
||||
('apps.core.middleware.rate_limiting.AuthRateLimitMiddleware', 'Rate Limiting'),
|
||||
]
|
||||
|
||||
# Check required middleware
|
||||
for mw_path, mw_name in required_middleware:
|
||||
if mw_path in middleware:
|
||||
self.log(
|
||||
f" {self.style.SUCCESS('✓')} {mw_name} is enabled",
|
||||
report_lines
|
||||
)
|
||||
else:
|
||||
self.log(
|
||||
f" {self.style.ERROR('✗')} {mw_name} is NOT enabled",
|
||||
report_lines
|
||||
)
|
||||
|
||||
# Check custom security middleware
|
||||
for mw_path, mw_name in custom_security_middleware:
|
||||
if mw_path in middleware:
|
||||
self.log(
|
||||
f" {self.style.SUCCESS('✓')} {mw_name} is enabled",
|
||||
report_lines
|
||||
)
|
||||
else:
|
||||
self.log(
|
||||
f" {self.style.WARNING('!')} {mw_name} is not enabled (optional)",
|
||||
report_lines
|
||||
)
|
||||
|
||||
# Check middleware order
|
||||
try:
|
||||
security_idx = middleware.index('django.middleware.security.SecurityMiddleware')
|
||||
session_idx = middleware.index('django.contrib.sessions.middleware.SessionMiddleware')
|
||||
|
||||
if security_idx < session_idx:
|
||||
self.log(
|
||||
f" {self.style.SUCCESS('✓')} Middleware ordering is correct",
|
||||
report_lines
|
||||
)
|
||||
else:
|
||||
self.log(
|
||||
f" {self.style.WARNING('!')} SecurityMiddleware should come before SessionMiddleware",
|
||||
report_lines
|
||||
)
|
||||
except ValueError:
|
||||
pass # Middleware not found, already reported above
|
||||
253
backend/apps/core/middleware/rate_limiting.py
Normal file
253
backend/apps/core/middleware/rate_limiting.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
Rate Limiting Middleware for ThrillWiki.
|
||||
|
||||
This middleware provides rate limiting for authentication endpoints to prevent
|
||||
brute force attacks, credential stuffing, and account enumeration.
|
||||
|
||||
Security Note:
|
||||
Rate limiting is applied at the IP level and user level (if authenticated).
|
||||
Limits are configurable and should be adjusted based on actual usage patterns.
|
||||
|
||||
Usage:
|
||||
Add 'apps.core.middleware.rate_limiting.AuthRateLimitMiddleware'
|
||||
to MIDDLEWARE in settings.py.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthRateLimitMiddleware:
|
||||
"""
|
||||
Middleware that rate limits authentication-related endpoints.
|
||||
|
||||
Protects against:
|
||||
- Brute force login attacks
|
||||
- Password reset abuse
|
||||
- Account enumeration through timing attacks
|
||||
"""
|
||||
|
||||
# Endpoints to rate limit
|
||||
RATE_LIMITED_PATHS = {
|
||||
# Login endpoints
|
||||
'/api/v1/auth/login/': {'per_minute': 5, 'per_hour': 30, 'per_day': 100},
|
||||
'/accounts/login/': {'per_minute': 5, 'per_hour': 30, 'per_day': 100},
|
||||
|
||||
# Signup endpoints
|
||||
'/api/v1/auth/signup/': {'per_minute': 3, 'per_hour': 10, 'per_day': 20},
|
||||
'/accounts/signup/': {'per_minute': 3, 'per_hour': 10, 'per_day': 20},
|
||||
|
||||
# Password reset endpoints
|
||||
'/api/v1/auth/password-reset/': {'per_minute': 2, 'per_hour': 5, 'per_day': 10},
|
||||
'/accounts/password/reset/': {'per_minute': 2, 'per_hour': 5, 'per_day': 10},
|
||||
|
||||
# Token endpoints
|
||||
'/api/v1/auth/token/': {'per_minute': 10, 'per_hour': 60, 'per_day': 200},
|
||||
'/api/v1/auth/token/refresh/': {'per_minute': 20, 'per_hour': 120, 'per_day': 500},
|
||||
}
|
||||
|
||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
# Only rate limit POST requests to auth endpoints
|
||||
if request.method != 'POST':
|
||||
return self.get_response(request)
|
||||
|
||||
# Check if this path should be rate limited
|
||||
limits = self._get_rate_limits(request.path)
|
||||
if not limits:
|
||||
return self.get_response(request)
|
||||
|
||||
# Get client identifier (IP address)
|
||||
client_ip = self._get_client_ip(request)
|
||||
|
||||
# Check rate limits
|
||||
is_allowed, message = self._check_rate_limits(
|
||||
client_ip, request.path, limits
|
||||
)
|
||||
|
||||
if not is_allowed:
|
||||
logger.warning(
|
||||
f"Rate limit exceeded for {client_ip} on {request.path}"
|
||||
)
|
||||
return self._rate_limit_response(message)
|
||||
|
||||
# Process request
|
||||
response = self.get_response(request)
|
||||
|
||||
# Only increment counter for failed auth attempts (non-2xx responses)
|
||||
if response.status_code >= 400:
|
||||
self._increment_counters(client_ip, request.path)
|
||||
|
||||
return response
|
||||
|
||||
def _get_rate_limits(self, path: str) -> Optional[dict]:
|
||||
"""Get rate limits for a path, if any."""
|
||||
# Exact match
|
||||
if path in self.RATE_LIMITED_PATHS:
|
||||
return self.RATE_LIMITED_PATHS[path]
|
||||
|
||||
# Prefix match (for paths with trailing slashes)
|
||||
path_without_slash = path.rstrip('/')
|
||||
for limited_path, limits in self.RATE_LIMITED_PATHS.items():
|
||||
if path_without_slash == limited_path.rstrip('/'):
|
||||
return limits
|
||||
|
||||
return None
|
||||
|
||||
def _get_client_ip(self, request: HttpRequest) -> str:
|
||||
"""
|
||||
Get the client's IP address from the request.
|
||||
|
||||
Handles common proxy headers (X-Forwarded-For, X-Real-IP).
|
||||
"""
|
||||
# Check for forwarded headers (set by reverse proxies)
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
# Take the first IP in the chain (client IP)
|
||||
return x_forwarded_for.split(',')[0].strip()
|
||||
|
||||
x_real_ip = request.META.get('HTTP_X_REAL_IP')
|
||||
if x_real_ip:
|
||||
return x_real_ip
|
||||
|
||||
return request.META.get('REMOTE_ADDR', 'unknown')
|
||||
|
||||
def _check_rate_limits(
|
||||
self,
|
||||
client_ip: str,
|
||||
path: str,
|
||||
limits: dict
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if the client has exceeded rate limits.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_allowed, reason_if_blocked)
|
||||
"""
|
||||
# Create a safe cache key from path
|
||||
path_key = path.replace('/', '_').strip('_')
|
||||
|
||||
# Check per-minute limit
|
||||
minute_key = f"auth_rate:{client_ip}:{path_key}:minute"
|
||||
minute_count = cache.get(minute_key, 0)
|
||||
if minute_count >= limits.get('per_minute', 10):
|
||||
return False, "Too many requests. Please wait a minute before trying again."
|
||||
|
||||
# Check per-hour limit
|
||||
hour_key = f"auth_rate:{client_ip}:{path_key}:hour"
|
||||
hour_count = cache.get(hour_key, 0)
|
||||
if hour_count >= limits.get('per_hour', 60):
|
||||
return False, "Too many requests. Please try again later."
|
||||
|
||||
# Check per-day limit
|
||||
day_key = f"auth_rate:{client_ip}:{path_key}:day"
|
||||
day_count = cache.get(day_key, 0)
|
||||
if day_count >= limits.get('per_day', 200):
|
||||
return False, "Daily limit exceeded. Please try again tomorrow."
|
||||
|
||||
return True, ""
|
||||
|
||||
def _increment_counters(self, client_ip: str, path: str) -> None:
|
||||
"""Increment rate limit counters."""
|
||||
path_key = path.replace('/', '_').strip('_')
|
||||
|
||||
# Increment per-minute counter
|
||||
minute_key = f"auth_rate:{client_ip}:{path_key}:minute"
|
||||
try:
|
||||
cache.incr(minute_key)
|
||||
except ValueError:
|
||||
cache.set(minute_key, 1, 60)
|
||||
|
||||
# Increment per-hour counter
|
||||
hour_key = f"auth_rate:{client_ip}:{path_key}:hour"
|
||||
try:
|
||||
cache.incr(hour_key)
|
||||
except ValueError:
|
||||
cache.set(hour_key, 1, 3600)
|
||||
|
||||
# Increment per-day counter
|
||||
day_key = f"auth_rate:{client_ip}:{path_key}:day"
|
||||
try:
|
||||
cache.incr(day_key)
|
||||
except ValueError:
|
||||
cache.set(day_key, 1, 86400)
|
||||
|
||||
def _rate_limit_response(self, message: str) -> JsonResponse:
|
||||
"""Generate a rate limit exceeded response."""
|
||||
return JsonResponse(
|
||||
{
|
||||
'error': message,
|
||||
'code': 'RATE_LIMIT_EXCEEDED',
|
||||
},
|
||||
status=429, # Too Many Requests
|
||||
)
|
||||
|
||||
|
||||
class SecurityEventLogger:
|
||||
"""
|
||||
Utility class for logging security-relevant events.
|
||||
|
||||
Use this to log:
|
||||
- Failed authentication attempts
|
||||
- Permission denied events
|
||||
- Suspicious activity
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def log_failed_login(
|
||||
request: HttpRequest,
|
||||
username: str,
|
||||
reason: str = "Invalid credentials"
|
||||
) -> None:
|
||||
"""Log a failed login attempt."""
|
||||
client_ip = AuthRateLimitMiddleware._get_client_ip(
|
||||
AuthRateLimitMiddleware, request
|
||||
)
|
||||
logger.warning(
|
||||
f"Failed login attempt - IP: {client_ip}, Username: {username}, "
|
||||
f"Reason: {reason}, User-Agent: {request.META.get('HTTP_USER_AGENT', 'unknown')}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_permission_denied(
|
||||
request: HttpRequest,
|
||||
resource: str,
|
||||
action: str = "access"
|
||||
) -> None:
|
||||
"""Log a permission denied event."""
|
||||
client_ip = AuthRateLimitMiddleware._get_client_ip(
|
||||
AuthRateLimitMiddleware, request
|
||||
)
|
||||
user = getattr(request, 'user', None)
|
||||
username = user.username if user and user.is_authenticated else 'anonymous'
|
||||
|
||||
logger.warning(
|
||||
f"Permission denied - IP: {client_ip}, User: {username}, "
|
||||
f"Resource: {resource}, Action: {action}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_suspicious_activity(
|
||||
request: HttpRequest,
|
||||
activity_type: str,
|
||||
details: str = ""
|
||||
) -> None:
|
||||
"""Log suspicious activity."""
|
||||
client_ip = AuthRateLimitMiddleware._get_client_ip(
|
||||
AuthRateLimitMiddleware, request
|
||||
)
|
||||
user = getattr(request, 'user', None)
|
||||
username = user.username if user and user.is_authenticated else 'anonymous'
|
||||
|
||||
logger.error(
|
||||
f"Suspicious activity detected - Type: {activity_type}, "
|
||||
f"IP: {client_ip}, User: {username}, Details: {details}"
|
||||
)
|
||||
@@ -114,18 +114,52 @@ class RequestLoggingMiddleware(MiddlewareMixin):
|
||||
|
||||
return response
|
||||
|
||||
# Sensitive field patterns that should be masked in logs
|
||||
# Security: Comprehensive list of sensitive data patterns
|
||||
SENSITIVE_PATTERNS = [
|
||||
'password',
|
||||
'passwd',
|
||||
'pwd',
|
||||
'token',
|
||||
'secret',
|
||||
'key',
|
||||
'api_key',
|
||||
'apikey',
|
||||
'auth',
|
||||
'authorization',
|
||||
'credential',
|
||||
'ssn',
|
||||
'social_security',
|
||||
'credit_card',
|
||||
'creditcard',
|
||||
'card_number',
|
||||
'cvv',
|
||||
'cvc',
|
||||
'pin',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'jwt',
|
||||
'session',
|
||||
'cookie',
|
||||
'private',
|
||||
]
|
||||
|
||||
def _safe_log_data(self, data):
|
||||
"""Safely log data, truncating if too large and masking sensitive fields."""
|
||||
"""
|
||||
Safely log data, truncating if too large and masking sensitive fields.
|
||||
|
||||
Security measures:
|
||||
- Masks all sensitive field names
|
||||
- Masks email addresses (shows only domain)
|
||||
- Truncates long values to prevent log flooding
|
||||
- Recursively processes nested dictionaries and lists
|
||||
"""
|
||||
try:
|
||||
# Convert to string representation
|
||||
if isinstance(data, dict):
|
||||
# Mask sensitive fields
|
||||
safe_data = {}
|
||||
for key, value in data.items():
|
||||
if any(sensitive in key.lower() for sensitive in ['password', 'token', 'secret', 'key']):
|
||||
safe_data[key] = '***MASKED***'
|
||||
else:
|
||||
safe_data[key] = value
|
||||
safe_data = self._mask_sensitive_dict(data)
|
||||
data_str = json.dumps(safe_data, indent=2, default=str)
|
||||
elif isinstance(data, list):
|
||||
safe_data = [self._mask_sensitive_value(item) for item in data]
|
||||
data_str = json.dumps(safe_data, indent=2, default=str)
|
||||
else:
|
||||
data_str = json.dumps(data, indent=2, default=str)
|
||||
@@ -136,3 +170,37 @@ class RequestLoggingMiddleware(MiddlewareMixin):
|
||||
return data_str
|
||||
except Exception:
|
||||
return str(data)[:500] + '...[ERROR_LOGGING]'
|
||||
|
||||
def _mask_sensitive_dict(self, data, depth=0):
|
||||
"""Recursively mask sensitive fields in a dictionary."""
|
||||
if depth > 5: # Prevent infinite recursion
|
||||
return '***DEPTH_LIMIT***'
|
||||
|
||||
safe_data = {}
|
||||
for key, value in data.items():
|
||||
key_lower = str(key).lower()
|
||||
|
||||
# Check if key contains any sensitive pattern
|
||||
if any(pattern in key_lower for pattern in self.SENSITIVE_PATTERNS):
|
||||
safe_data[key] = '***MASKED***'
|
||||
else:
|
||||
safe_data[key] = self._mask_sensitive_value(value, depth)
|
||||
|
||||
return safe_data
|
||||
|
||||
def _mask_sensitive_value(self, value, depth=0):
|
||||
"""Mask a single value, handling different types."""
|
||||
if isinstance(value, dict):
|
||||
return self._mask_sensitive_dict(value, depth + 1)
|
||||
elif isinstance(value, list):
|
||||
return [self._mask_sensitive_value(item, depth + 1) for item in value[:10]] # Limit list items
|
||||
elif isinstance(value, str):
|
||||
# Mask email addresses (show only domain)
|
||||
if '@' in value and '.' in value.split('@')[-1]:
|
||||
parts = value.split('@')
|
||||
if len(parts) == 2:
|
||||
return f"***@{parts[1]}"
|
||||
# Truncate long strings
|
||||
if len(value) > 200:
|
||||
return value[:200] + '...[TRUNCATED]'
|
||||
return value
|
||||
|
||||
196
backend/apps/core/middleware/security_headers.py
Normal file
196
backend/apps/core/middleware/security_headers.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Security Headers Middleware for ThrillWiki.
|
||||
|
||||
This middleware adds additional security headers to all HTTP responses,
|
||||
providing defense-in-depth against common web vulnerabilities.
|
||||
|
||||
Headers added:
|
||||
- Content-Security-Policy: Controls resource loading to prevent XSS
|
||||
- Permissions-Policy: Restricts browser feature access
|
||||
- Cross-Origin-Embedder-Policy: Prevents cross-origin embedding
|
||||
- Cross-Origin-Resource-Policy: Restricts cross-origin resource access
|
||||
|
||||
Usage:
|
||||
Add 'apps.core.middleware.security_headers.SecurityHeadersMiddleware'
|
||||
to MIDDLEWARE in settings.py (after SecurityMiddleware).
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
"""
|
||||
Middleware that adds security headers to HTTP responses.
|
||||
|
||||
This provides defense-in-depth by adding headers that Django's
|
||||
SecurityMiddleware doesn't handle.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
# Build CSP header at startup for performance
|
||||
self._csp_header = self._build_csp_header()
|
||||
self._permissions_policy_header = self._build_permissions_policy_header()
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
return self._add_security_headers(response, request)
|
||||
|
||||
def _add_security_headers(self, response, request):
|
||||
"""Add security headers to the response."""
|
||||
# Content-Security-Policy
|
||||
# Only add CSP for HTML responses to avoid breaking API/JSON responses
|
||||
content_type = response.get("Content-Type", "")
|
||||
if "text/html" in content_type:
|
||||
if not response.get("Content-Security-Policy"):
|
||||
response["Content-Security-Policy"] = self._csp_header
|
||||
|
||||
# Permissions-Policy (successor to Feature-Policy)
|
||||
if not response.get("Permissions-Policy"):
|
||||
response["Permissions-Policy"] = self._permissions_policy_header
|
||||
|
||||
# Cross-Origin-Embedder-Policy
|
||||
# Requires resources to be CORS-enabled or same-origin
|
||||
# Using 'unsafe-none' for now as 'require-corp' can break third-party resources
|
||||
if not response.get("Cross-Origin-Embedder-Policy"):
|
||||
response["Cross-Origin-Embedder-Policy"] = "unsafe-none"
|
||||
|
||||
# Cross-Origin-Resource-Policy
|
||||
# Controls how resources can be shared with other origins
|
||||
if not response.get("Cross-Origin-Resource-Policy"):
|
||||
response["Cross-Origin-Resource-Policy"] = "same-origin"
|
||||
|
||||
return response
|
||||
|
||||
def _build_csp_header(self):
|
||||
"""
|
||||
Build the Content-Security-Policy header value.
|
||||
|
||||
CSP directives explained:
|
||||
- default-src: Fallback for other fetch directives
|
||||
- script-src: Sources for JavaScript
|
||||
- style-src: Sources for CSS
|
||||
- img-src: Sources for images
|
||||
- font-src: Sources for fonts
|
||||
- connect-src: Sources for fetch, XHR, WebSocket
|
||||
- frame-ancestors: Controls framing (replaces X-Frame-Options)
|
||||
- form-action: Valid targets for form submissions
|
||||
- base-uri: Restricts base element URLs
|
||||
- object-src: Sources for plugins (Flash, etc.)
|
||||
"""
|
||||
# Check if we're in debug mode
|
||||
debug = getattr(settings, "DEBUG", False)
|
||||
|
||||
# Base directives (production-focused)
|
||||
directives = {
|
||||
"default-src": ["'self'"],
|
||||
"script-src": [
|
||||
"'self'",
|
||||
# Allow HTMX inline scripts with nonce (would need nonce middleware)
|
||||
# For now, using 'unsafe-inline' for HTMX compatibility
|
||||
"'unsafe-inline'" if debug else "'self'",
|
||||
# CDNs for external scripts
|
||||
"https://cdn.jsdelivr.net",
|
||||
"https://unpkg.com",
|
||||
"https://challenges.cloudflare.com", # Turnstile
|
||||
],
|
||||
"style-src": [
|
||||
"'self'",
|
||||
"'unsafe-inline'", # Required for Tailwind and inline styles
|
||||
"https://cdn.jsdelivr.net",
|
||||
"https://fonts.googleapis.com",
|
||||
],
|
||||
"img-src": [
|
||||
"'self'",
|
||||
"data:",
|
||||
"blob:",
|
||||
"https:", # Allow HTTPS images (needed for user uploads, maps, etc.)
|
||||
],
|
||||
"font-src": [
|
||||
"'self'",
|
||||
"https://fonts.gstatic.com",
|
||||
"https://cdn.jsdelivr.net",
|
||||
],
|
||||
"connect-src": [
|
||||
"'self'",
|
||||
"https://api.forwardemail.net",
|
||||
"https://challenges.cloudflare.com",
|
||||
"https://*.cloudflare.com",
|
||||
# Map tile servers
|
||||
"https://*.openstreetmap.org",
|
||||
"https://*.tile.openstreetmap.org",
|
||||
],
|
||||
"frame-src": [
|
||||
"'self'",
|
||||
"https://challenges.cloudflare.com", # Turnstile widget
|
||||
],
|
||||
"frame-ancestors": ["'self'"],
|
||||
"form-action": ["'self'"],
|
||||
"base-uri": ["'self'"],
|
||||
"object-src": ["'none'"],
|
||||
"upgrade-insecure-requests": [], # Upgrade HTTP to HTTPS
|
||||
}
|
||||
|
||||
# Add debug-specific relaxations
|
||||
if debug:
|
||||
# Allow webpack dev server connections in development
|
||||
directives["connect-src"].extend([
|
||||
"ws://localhost:*",
|
||||
"http://localhost:*",
|
||||
"http://127.0.0.1:*",
|
||||
])
|
||||
|
||||
# Build header string
|
||||
parts = []
|
||||
for directive, sources in directives.items():
|
||||
if sources:
|
||||
parts.append(f"{directive} {' '.join(sources)}")
|
||||
else:
|
||||
# Directives like upgrade-insecure-requests don't need values
|
||||
parts.append(directive)
|
||||
|
||||
return "; ".join(parts)
|
||||
|
||||
def _build_permissions_policy_header(self):
|
||||
"""
|
||||
Build the Permissions-Policy header value.
|
||||
|
||||
This header controls which browser features the page can use.
|
||||
"""
|
||||
# Get permissions policy from settings or use defaults
|
||||
policy = getattr(settings, "PERMISSIONS_POLICY", {
|
||||
"accelerometer": [],
|
||||
"ambient-light-sensor": [],
|
||||
"autoplay": [],
|
||||
"camera": [],
|
||||
"display-capture": [],
|
||||
"document-domain": [],
|
||||
"encrypted-media": [],
|
||||
"fullscreen": ["self"],
|
||||
"geolocation": ["self"],
|
||||
"gyroscope": [],
|
||||
"interest-cohort": [],
|
||||
"magnetometer": [],
|
||||
"microphone": [],
|
||||
"midi": [],
|
||||
"payment": [],
|
||||
"picture-in-picture": [],
|
||||
"publickey-credentials-get": [],
|
||||
"screen-wake-lock": [],
|
||||
"sync-xhr": [],
|
||||
"usb": [],
|
||||
"web-share": ["self"],
|
||||
"xr-spatial-tracking": [],
|
||||
})
|
||||
|
||||
parts = []
|
||||
for feature, allowlist in policy.items():
|
||||
if not allowlist:
|
||||
# Empty list means disallow completely
|
||||
parts.append(f"{feature}=()")
|
||||
else:
|
||||
# Convert allowlist to proper format
|
||||
formatted = " ".join(allowlist)
|
||||
parts.append(f"{feature}=({formatted})")
|
||||
|
||||
return ", ".join(parts)
|
||||
@@ -223,7 +223,7 @@ class MapResponse:
|
||||
"query_time_ms": self.query_time_ms,
|
||||
"filters_applied": self.filters_applied,
|
||||
"pagination": {
|
||||
"has_more": False, # TODO: Implement pagination
|
||||
"has_more": False, # TODO(THRILLWIKI-102): Implement pagination for map data
|
||||
"total_pages": 1,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -297,7 +297,7 @@ class CompanyLocationAdapter(BaseLocationAdapter):
|
||||
"""Convert CompanyHeadquarters to UnifiedLocation."""
|
||||
# Note: CompanyHeadquarters doesn't have coordinates, so we need to geocode
|
||||
# For now, we'll skip companies without coordinates
|
||||
# TODO: Implement geocoding service integration
|
||||
# TODO(THRILLWIKI-101): Implement geocoding service integration for company HQs
|
||||
return None
|
||||
|
||||
def get_queryset(
|
||||
|
||||
275
backend/apps/core/templatetags/safe_html.py
Normal file
275
backend/apps/core/templatetags/safe_html.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Safe HTML Template Tags and Filters for ThrillWiki.
|
||||
|
||||
This module provides template tags and filters for safely rendering
|
||||
HTML content without XSS vulnerabilities.
|
||||
|
||||
Security Note:
|
||||
Always use these filters instead of |safe for user-generated content.
|
||||
The |safe filter should only be used for content that has been
|
||||
pre-sanitized in the view layer.
|
||||
|
||||
Usage:
|
||||
{% load safe_html %}
|
||||
|
||||
{# Sanitize user content #}
|
||||
{{ user_description|sanitize }}
|
||||
|
||||
{# Minimal sanitization for comments #}
|
||||
{{ comment_text|sanitize_minimal }}
|
||||
|
||||
{# Strip all HTML #}
|
||||
{{ raw_text|strip_html }}
|
||||
|
||||
{# Safe JSON for JavaScript #}
|
||||
{{ data|json_safe }}
|
||||
|
||||
{# Render trusted icon SVG #}
|
||||
{% icon "check" class="w-4 h-4" %}
|
||||
"""
|
||||
|
||||
import json
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from apps.core.utils.html_sanitizer import (
|
||||
sanitize_html,
|
||||
sanitize_minimal as _sanitize_minimal,
|
||||
sanitize_svg,
|
||||
strip_html as _strip_html,
|
||||
sanitize_for_json,
|
||||
escape_js_string as _escape_js_string,
|
||||
sanitize_url as _sanitize_url,
|
||||
sanitize_attribute_value,
|
||||
)
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTML Sanitization Filters
|
||||
# =============================================================================
|
||||
|
||||
@register.filter(name='sanitize', is_safe=True)
|
||||
def sanitize_filter(value):
|
||||
"""
|
||||
Sanitize HTML content to prevent XSS attacks.
|
||||
|
||||
Allows common formatting tags while stripping dangerous content.
|
||||
|
||||
Usage:
|
||||
{{ user_content|sanitize }}
|
||||
"""
|
||||
if not value:
|
||||
return ''
|
||||
return mark_safe(sanitize_html(str(value)))
|
||||
|
||||
|
||||
@register.filter(name='sanitize_minimal', is_safe=True)
|
||||
def sanitize_minimal_filter(value):
|
||||
"""
|
||||
Sanitize HTML with minimal allowed tags.
|
||||
|
||||
Only allows basic text formatting: p, br, strong, em, i, b, a
|
||||
|
||||
Usage:
|
||||
{{ comment|sanitize_minimal }}
|
||||
"""
|
||||
if not value:
|
||||
return ''
|
||||
return mark_safe(_sanitize_minimal(str(value)))
|
||||
|
||||
|
||||
@register.filter(name='sanitize_svg', is_safe=True)
|
||||
def sanitize_svg_filter(value):
|
||||
"""
|
||||
Sanitize SVG content for safe inline rendering.
|
||||
|
||||
Usage:
|
||||
{{ icon_svg|sanitize_svg }}
|
||||
"""
|
||||
if not value:
|
||||
return ''
|
||||
return mark_safe(sanitize_svg(str(value)))
|
||||
|
||||
|
||||
@register.filter(name='strip_html')
|
||||
def strip_html_filter(value):
|
||||
"""
|
||||
Remove all HTML tags from content.
|
||||
|
||||
Usage:
|
||||
{{ html_content|strip_html }}
|
||||
"""
|
||||
if not value:
|
||||
return ''
|
||||
return _strip_html(str(value))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# JavaScript/JSON Context Filters
|
||||
# =============================================================================
|
||||
|
||||
@register.filter(name='json_safe', is_safe=True)
|
||||
def json_safe_filter(value):
|
||||
"""
|
||||
Safely serialize data for embedding in JavaScript.
|
||||
|
||||
This is safer than using |safe for JSON data as it properly
|
||||
escapes </script> and other dangerous sequences.
|
||||
|
||||
Usage:
|
||||
<script>
|
||||
const data = {{ python_dict|json_safe }};
|
||||
</script>
|
||||
"""
|
||||
if value is None:
|
||||
return 'null'
|
||||
return mark_safe(sanitize_for_json(value))
|
||||
|
||||
|
||||
@register.filter(name='escapejs_safe')
|
||||
def escapejs_safe_filter(value):
|
||||
"""
|
||||
Escape a string for safe use in JavaScript string literals.
|
||||
|
||||
Usage:
|
||||
<script>
|
||||
const message = '{{ user_input|escapejs_safe }}';
|
||||
</script>
|
||||
"""
|
||||
if not value:
|
||||
return ''
|
||||
return _escape_js_string(str(value))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# URL and Attribute Filters
|
||||
# =============================================================================
|
||||
|
||||
@register.filter(name='sanitize_url')
|
||||
def sanitize_url_filter(value):
|
||||
"""
|
||||
Sanitize a URL to prevent javascript: and other dangerous protocols.
|
||||
|
||||
Usage:
|
||||
<a href="{{ user_url|sanitize_url }}">Link</a>
|
||||
"""
|
||||
if not value:
|
||||
return ''
|
||||
return _sanitize_url(str(value))
|
||||
|
||||
|
||||
@register.filter(name='attr_safe')
|
||||
def attr_safe_filter(value):
|
||||
"""
|
||||
Escape a value for safe use in HTML attributes.
|
||||
|
||||
Usage:
|
||||
<div data-value="{{ user_value|attr_safe }}">
|
||||
"""
|
||||
if not value:
|
||||
return ''
|
||||
return sanitize_attribute_value(str(value))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Icon Template Tags
|
||||
# =============================================================================
|
||||
|
||||
# Predefined safe SVG icons
|
||||
# These are trusted and can be rendered without sanitization
|
||||
BUILTIN_ICONS = {
|
||||
'check': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>''',
|
||||
'x': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>''',
|
||||
'plus': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>''',
|
||||
'minus': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" /></svg>''',
|
||||
'chevron-down': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /></svg>''',
|
||||
'chevron-up': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /></svg>''',
|
||||
'chevron-left': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /></svg>''',
|
||||
'chevron-right': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>''',
|
||||
'search': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /></svg>''',
|
||||
'menu': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>''',
|
||||
'user': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" /></svg>''',
|
||||
'cog': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>''',
|
||||
'trash': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></svg>''',
|
||||
'pencil': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" /></svg>''',
|
||||
'eye': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>''',
|
||||
'eye-slash': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" /></svg>''',
|
||||
'arrow-left': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" /></svg>''',
|
||||
'arrow-right': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /></svg>''',
|
||||
'info': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /></svg>''',
|
||||
'warning': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /></svg>''',
|
||||
'error': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" /></svg>''',
|
||||
'success': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>''',
|
||||
'loading': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" {attrs}><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>''',
|
||||
'external-link': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /></svg>''',
|
||||
'download': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>''',
|
||||
'upload': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /></svg>''',
|
||||
'star': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" /></svg>''',
|
||||
'star-filled': '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" {attrs}><path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z" clip-rule="evenodd" /></svg>''',
|
||||
'heart': '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {attrs}><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" /></svg>''',
|
||||
'heart-filled': '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" {attrs}><path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" /></svg>''',
|
||||
}
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def icon(name, **kwargs):
|
||||
"""
|
||||
Render a trusted SVG icon.
|
||||
|
||||
This tag renders predefined SVG icons that are trusted and safe.
|
||||
Custom attributes can be passed to customize the icon.
|
||||
|
||||
Usage:
|
||||
{% icon "check" class="w-4 h-4 text-green-500" %}
|
||||
{% icon "x" class="w-6 h-6" aria_hidden="true" %}
|
||||
|
||||
Args:
|
||||
name: The icon name (from BUILTIN_ICONS)
|
||||
**kwargs: Additional HTML attributes for the SVG
|
||||
|
||||
Returns:
|
||||
Safe HTML for the icon SVG
|
||||
"""
|
||||
svg_template = BUILTIN_ICONS.get(name)
|
||||
|
||||
if not svg_template:
|
||||
# Return empty string for unknown icons (fail silently)
|
||||
return ''
|
||||
|
||||
# Build attributes string
|
||||
attrs_list = []
|
||||
for key, value in kwargs.items():
|
||||
# Convert underscore to hyphen for HTML attributes (e.g., aria_hidden -> aria-hidden)
|
||||
attr_name = key.replace('_', '-')
|
||||
# Escape attribute values to prevent XSS
|
||||
safe_value = sanitize_attribute_value(str(value))
|
||||
attrs_list.append(f'{attr_name}="{safe_value}"')
|
||||
|
||||
attrs_str = ' '.join(attrs_list)
|
||||
|
||||
# Substitute attributes into template
|
||||
svg = svg_template.format(attrs=attrs_str)
|
||||
|
||||
return mark_safe(svg)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def icon_class(name, size='w-5 h-5', extra_class=''):
|
||||
"""
|
||||
Render a trusted SVG icon with common class presets.
|
||||
|
||||
Usage:
|
||||
{% icon_class "check" size="w-4 h-4" extra_class="text-green-500" %}
|
||||
|
||||
Args:
|
||||
name: The icon name
|
||||
size: Size classes (default: "w-5 h-5")
|
||||
extra_class: Additional CSS classes
|
||||
|
||||
Returns:
|
||||
Safe HTML for the icon SVG
|
||||
"""
|
||||
classes = f'{size} {extra_class}'.strip()
|
||||
return icon(name, **{'class': classes})
|
||||
161
backend/apps/core/utils/error_handling.py
Normal file
161
backend/apps/core/utils/error_handling.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Centralized error handling utilities.
|
||||
|
||||
This module provides standardized error handling for views across the application,
|
||||
ensuring consistent logging, user messages, and API responses.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from django.contrib import messages
|
||||
from django.http import HttpRequest
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.core.exceptions import ThrillWikiException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ErrorHandler:
|
||||
"""Centralized error handling for views."""
|
||||
|
||||
@staticmethod
|
||||
def handle_view_error(
|
||||
request: HttpRequest,
|
||||
error: Exception,
|
||||
user_message: str = "An error occurred",
|
||||
log_message: Optional[str] = None,
|
||||
level: str = "error",
|
||||
) -> None:
|
||||
"""
|
||||
Handle errors in template views.
|
||||
|
||||
Logs the error with appropriate context and displays a user-friendly
|
||||
message using Django's messages framework.
|
||||
|
||||
Args:
|
||||
request: HTTP request object
|
||||
error: Exception that occurred
|
||||
user_message: Message to show to the user (should be user-friendly)
|
||||
log_message: Message to log (defaults to str(error) with user_message prefix)
|
||||
level: Log level - one of "error", "warning", "info"
|
||||
|
||||
Example:
|
||||
try:
|
||||
ParkService.create_park(...)
|
||||
except ServiceError as e:
|
||||
ErrorHandler.handle_view_error(
|
||||
request,
|
||||
e,
|
||||
user_message="Failed to create park",
|
||||
log_message=f"Park creation failed for user {request.user.id}"
|
||||
)
|
||||
"""
|
||||
log_msg = log_message or f"{user_message}: {str(error)}"
|
||||
|
||||
if level == "error":
|
||||
logger.error(log_msg, exc_info=True)
|
||||
elif level == "warning":
|
||||
logger.warning(log_msg)
|
||||
else:
|
||||
logger.info(log_msg)
|
||||
|
||||
messages.error(request, user_message)
|
||||
|
||||
@staticmethod
|
||||
def handle_api_error(
|
||||
error: Exception,
|
||||
user_message: str = "An error occurred",
|
||||
log_message: Optional[str] = None,
|
||||
status_code: int = status.HTTP_400_BAD_REQUEST,
|
||||
) -> Response:
|
||||
"""
|
||||
Handle errors in API views.
|
||||
|
||||
Logs the error and returns a standardized DRF Response with error details.
|
||||
|
||||
Args:
|
||||
error: Exception that occurred
|
||||
user_message: Message to return to the client (should be user-friendly)
|
||||
log_message: Message to log (defaults to str(error) with user_message prefix)
|
||||
status_code: HTTP status code to return
|
||||
|
||||
Returns:
|
||||
DRF Response with error details in standard format
|
||||
|
||||
Example:
|
||||
try:
|
||||
result = ParkService.create_park(...)
|
||||
except ServiceError as e:
|
||||
return ErrorHandler.handle_api_error(
|
||||
e,
|
||||
user_message="Failed to create park photo",
|
||||
status_code=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
"""
|
||||
log_msg = log_message or f"{user_message}: {str(error)}"
|
||||
logger.error(log_msg, exc_info=True)
|
||||
|
||||
# Build error response
|
||||
error_data: Dict[str, Any] = {
|
||||
"error": user_message,
|
||||
"detail": str(error),
|
||||
}
|
||||
|
||||
# Include additional details for ThrillWikiException subclasses
|
||||
if isinstance(error, ThrillWikiException):
|
||||
error_data["error_code"] = error.error_code
|
||||
if error.details:
|
||||
error_data["details"] = error.details
|
||||
|
||||
return Response(error_data, status=status_code)
|
||||
|
||||
@staticmethod
|
||||
def handle_success(
|
||||
request: HttpRequest,
|
||||
message: str,
|
||||
level: str = "success",
|
||||
) -> None:
|
||||
"""
|
||||
Handle success messages in template views.
|
||||
|
||||
Args:
|
||||
request: HTTP request object
|
||||
message: Success message to display
|
||||
level: Message level - one of "success", "info", "warning"
|
||||
"""
|
||||
if level == "success":
|
||||
messages.success(request, message)
|
||||
elif level == "info":
|
||||
messages.info(request, message)
|
||||
elif level == "warning":
|
||||
messages.warning(request, message)
|
||||
|
||||
@staticmethod
|
||||
def api_success_response(
|
||||
data: Any = None,
|
||||
message: str = "Success",
|
||||
status_code: int = status.HTTP_200_OK,
|
||||
) -> Response:
|
||||
"""
|
||||
Create a standardized success response for API views.
|
||||
|
||||
Args:
|
||||
data: Response data (optional)
|
||||
message: Success message
|
||||
status_code: HTTP status code
|
||||
|
||||
Returns:
|
||||
DRF Response with success data in standard format
|
||||
"""
|
||||
response_data: Dict[str, Any] = {
|
||||
"status": "success",
|
||||
"message": message,
|
||||
}
|
||||
|
||||
if data is not None:
|
||||
response_data["data"] = data
|
||||
|
||||
return Response(response_data, status=status_code)
|
||||
432
backend/apps/core/utils/file_scanner.py
Normal file
432
backend/apps/core/utils/file_scanner.py
Normal file
@@ -0,0 +1,432 @@
|
||||
"""
|
||||
File Upload Security Utilities for ThrillWiki.
|
||||
|
||||
This module provides comprehensive file validation and security checks for
|
||||
file uploads, including:
|
||||
- File type validation (MIME type and magic number verification)
|
||||
- File size validation
|
||||
- Filename sanitization
|
||||
- Image-specific validation
|
||||
|
||||
Security Note:
|
||||
Always validate uploaded files before saving them. Never trust user-provided
|
||||
file extensions or Content-Type headers alone.
|
||||
|
||||
Usage:
|
||||
from apps.core.utils.file_scanner import validate_image_upload, sanitize_filename
|
||||
|
||||
# In view
|
||||
try:
|
||||
validate_image_upload(uploaded_file)
|
||||
# Safe to save file
|
||||
except FileValidationError as e:
|
||||
return JsonResponse({'error': str(e)}, status=400)
|
||||
|
||||
# Sanitize filename
|
||||
safe_name = sanitize_filename(uploaded_file.name)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from typing import Optional, Set, Tuple
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
|
||||
|
||||
class FileValidationError(ValidationError):
|
||||
"""Custom exception for file validation errors."""
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Image Magic Number Signatures
|
||||
# =============================================================================
|
||||
|
||||
# Magic number signatures for common image formats
|
||||
# Format: (magic_bytes, offset, description)
|
||||
IMAGE_SIGNATURES = {
|
||||
'jpeg': [
|
||||
(b'\xFF\xD8\xFF\xE0', 0, 'JPEG (JFIF)'),
|
||||
(b'\xFF\xD8\xFF\xE1', 0, 'JPEG (EXIF)'),
|
||||
(b'\xFF\xD8\xFF\xE2', 0, 'JPEG (ICC)'),
|
||||
(b'\xFF\xD8\xFF\xE3', 0, 'JPEG (Samsung)'),
|
||||
(b'\xFF\xD8\xFF\xE8', 0, 'JPEG (SPIFF)'),
|
||||
(b'\xFF\xD8\xFF\xDB', 0, 'JPEG (Raw)'),
|
||||
],
|
||||
'png': [
|
||||
(b'\x89PNG\r\n\x1a\n', 0, 'PNG'),
|
||||
],
|
||||
'gif': [
|
||||
(b'GIF87a', 0, 'GIF87a'),
|
||||
(b'GIF89a', 0, 'GIF89a'),
|
||||
],
|
||||
'webp': [
|
||||
(b'RIFF', 0, 'RIFF'), # WebP starts with RIFF header
|
||||
],
|
||||
'bmp': [
|
||||
(b'BM', 0, 'BMP'),
|
||||
],
|
||||
}
|
||||
|
||||
# All allowed MIME types
|
||||
ALLOWED_IMAGE_MIME_TYPES: Set[str] = frozenset({
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
})
|
||||
|
||||
# Allowed file extensions
|
||||
ALLOWED_IMAGE_EXTENSIONS: Set[str] = frozenset({
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.webp',
|
||||
})
|
||||
|
||||
# Maximum file size (10MB)
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
# Minimum file size (prevent empty files)
|
||||
MIN_FILE_SIZE = 100 # 100 bytes
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# File Validation Functions
|
||||
# =============================================================================
|
||||
|
||||
def validate_image_upload(
|
||||
file: UploadedFile,
|
||||
max_size: int = MAX_FILE_SIZE,
|
||||
allowed_types: Optional[Set[str]] = None,
|
||||
allowed_extensions: Optional[Set[str]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Validate an uploaded image file for security.
|
||||
|
||||
Performs multiple validation checks:
|
||||
1. File size validation
|
||||
2. File extension validation
|
||||
3. MIME type validation (from Content-Type header)
|
||||
4. Magic number validation (actual file content check)
|
||||
5. Image integrity validation (using PIL)
|
||||
|
||||
Args:
|
||||
file: The uploaded file object
|
||||
max_size: Maximum allowed file size in bytes
|
||||
allowed_types: Set of allowed MIME types
|
||||
allowed_extensions: Set of allowed file extensions
|
||||
|
||||
Returns:
|
||||
True if all validations pass
|
||||
|
||||
Raises:
|
||||
FileValidationError: If any validation fails
|
||||
"""
|
||||
if allowed_types is None:
|
||||
allowed_types = ALLOWED_IMAGE_MIME_TYPES
|
||||
if allowed_extensions is None:
|
||||
allowed_extensions = ALLOWED_IMAGE_EXTENSIONS
|
||||
|
||||
# 1. Check if file exists
|
||||
if not file:
|
||||
raise FileValidationError("No file provided")
|
||||
|
||||
# 2. Check file size
|
||||
if file.size > max_size:
|
||||
raise FileValidationError(
|
||||
f"File too large. Maximum size is {max_size // (1024 * 1024)}MB"
|
||||
)
|
||||
|
||||
if file.size < MIN_FILE_SIZE:
|
||||
raise FileValidationError("File too small or empty")
|
||||
|
||||
# 3. Check file extension
|
||||
filename = file.name or ''
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext not in allowed_extensions:
|
||||
raise FileValidationError(
|
||||
f"Invalid file extension '{ext}'. Allowed: {', '.join(allowed_extensions)}"
|
||||
)
|
||||
|
||||
# 4. Check Content-Type header
|
||||
content_type = getattr(file, 'content_type', '')
|
||||
if content_type and content_type not in allowed_types:
|
||||
raise FileValidationError(
|
||||
f"Invalid file type '{content_type}'. Allowed: {', '.join(allowed_types)}"
|
||||
)
|
||||
|
||||
# 5. Validate magic numbers (actual file content)
|
||||
if not _validate_magic_number(file):
|
||||
raise FileValidationError(
|
||||
"File content doesn't match file extension. File may be corrupted or malicious."
|
||||
)
|
||||
|
||||
# 6. Validate image integrity using PIL
|
||||
if not _validate_image_integrity(file):
|
||||
raise FileValidationError(
|
||||
"Invalid or corrupted image file"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _validate_magic_number(file: UploadedFile) -> bool:
|
||||
"""
|
||||
Validate file content against known magic number signatures.
|
||||
|
||||
This is more reliable than checking the file extension or Content-Type
|
||||
header, which can be easily spoofed.
|
||||
|
||||
Args:
|
||||
file: The uploaded file object
|
||||
|
||||
Returns:
|
||||
True if magic number matches an allowed image type
|
||||
"""
|
||||
# Read the file header
|
||||
file.seek(0)
|
||||
header = file.read(16)
|
||||
file.seek(0)
|
||||
|
||||
# Check against known signatures
|
||||
for format_name, signatures in IMAGE_SIGNATURES.items():
|
||||
for magic, offset, description in signatures:
|
||||
if len(header) >= offset + len(magic):
|
||||
if header[offset:offset + len(magic)] == magic:
|
||||
# Special handling for WebP (must also have WEBP marker)
|
||||
if format_name == 'webp':
|
||||
if len(header) >= 12 and header[8:12] == b'WEBP':
|
||||
return True
|
||||
else:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _validate_image_integrity(file: UploadedFile) -> bool:
|
||||
"""
|
||||
Validate image integrity using PIL.
|
||||
|
||||
This catches corrupted images and various image-related attacks.
|
||||
|
||||
Args:
|
||||
file: The uploaded file object
|
||||
|
||||
Returns:
|
||||
True if image can be opened and verified
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
file.seek(0)
|
||||
# Read into BytesIO to avoid issues with file-like objects
|
||||
img_data = BytesIO(file.read())
|
||||
file.seek(0)
|
||||
|
||||
with Image.open(img_data) as img:
|
||||
# Verify the image is not truncated or corrupted
|
||||
img.verify()
|
||||
|
||||
# Re-open for size check (verify() can only be called once)
|
||||
img_data.seek(0)
|
||||
with Image.open(img_data) as img2:
|
||||
# Check for reasonable image dimensions
|
||||
# Prevent decompression bombs
|
||||
max_dimension = 10000
|
||||
if img2.width > max_dimension or img2.height > max_dimension:
|
||||
raise FileValidationError(
|
||||
f"Image dimensions too large. Maximum is {max_dimension}x{max_dimension}"
|
||||
)
|
||||
|
||||
# Check for very small dimensions (might be suspicious)
|
||||
if img2.width < 1 or img2.height < 1:
|
||||
raise FileValidationError("Invalid image dimensions")
|
||||
|
||||
return True
|
||||
|
||||
except FileValidationError:
|
||||
raise
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Filename Sanitization
|
||||
# =============================================================================
|
||||
|
||||
def sanitize_filename(filename: str, max_length: int = 100) -> str:
|
||||
"""
|
||||
Sanitize a filename to prevent directory traversal and other attacks.
|
||||
|
||||
This function:
|
||||
- Removes path separators and directory traversal attempts
|
||||
- Removes special characters
|
||||
- Truncates to maximum length
|
||||
- Ensures the filename is not empty
|
||||
|
||||
Args:
|
||||
filename: The original filename
|
||||
max_length: Maximum length for the filename
|
||||
|
||||
Returns:
|
||||
Sanitized filename
|
||||
"""
|
||||
if not filename:
|
||||
return f"file_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Get just the filename (remove any path components)
|
||||
filename = os.path.basename(filename)
|
||||
|
||||
# Split into name and extension
|
||||
name, ext = os.path.splitext(filename)
|
||||
|
||||
# Remove or replace dangerous characters from name
|
||||
# Allow alphanumeric, hyphens, underscores, dots
|
||||
name = re.sub(r'[^\w\-.]', '_', name)
|
||||
|
||||
# Remove leading dots and underscores (hidden file prevention)
|
||||
name = name.lstrip('._')
|
||||
|
||||
# Collapse multiple underscores
|
||||
name = re.sub(r'_+', '_', name)
|
||||
|
||||
# Ensure name is not empty
|
||||
if not name:
|
||||
name = f"file_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Sanitize extension
|
||||
ext = ext.lower()
|
||||
ext = re.sub(r'[^\w.]', '', ext)
|
||||
|
||||
# Combine and truncate
|
||||
result = f"{name[:max_length - len(ext)]}{ext}"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def generate_unique_filename(original_filename: str, prefix: str = '') -> str:
|
||||
"""
|
||||
Generate a unique filename using UUID while preserving extension.
|
||||
|
||||
Args:
|
||||
original_filename: The original filename
|
||||
prefix: Optional prefix for the filename
|
||||
|
||||
Returns:
|
||||
Unique filename with UUID
|
||||
"""
|
||||
ext = os.path.splitext(original_filename)[1].lower()
|
||||
|
||||
# Sanitize extension
|
||||
ext = re.sub(r'[^\w.]', '', ext)
|
||||
|
||||
# Generate unique filename
|
||||
unique_id = uuid.uuid4().hex[:12]
|
||||
if prefix:
|
||||
return f"{sanitize_filename(prefix)}_{unique_id}{ext}"
|
||||
return f"{unique_id}{ext}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Rate Limiting for Uploads
|
||||
# =============================================================================
|
||||
|
||||
# Rate limiting configuration
|
||||
UPLOAD_RATE_LIMITS = {
|
||||
'per_minute': 10,
|
||||
'per_hour': 100,
|
||||
'per_day': 500,
|
||||
}
|
||||
|
||||
|
||||
def check_upload_rate_limit(user_id: int, cache_backend=None) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if user has exceeded upload rate limits.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID
|
||||
cache_backend: Optional Django cache backend (uses default if not provided)
|
||||
|
||||
Returns:
|
||||
Tuple of (is_allowed, reason_if_blocked)
|
||||
"""
|
||||
if cache_backend is None:
|
||||
from django.core.cache import cache
|
||||
cache_backend = cache
|
||||
|
||||
# Check per-minute limit
|
||||
minute_key = f"upload_rate:{user_id}:minute"
|
||||
minute_count = cache_backend.get(minute_key, 0)
|
||||
if minute_count >= UPLOAD_RATE_LIMITS['per_minute']:
|
||||
return False, "Upload rate limit exceeded. Please wait a minute."
|
||||
|
||||
# Check per-hour limit
|
||||
hour_key = f"upload_rate:{user_id}:hour"
|
||||
hour_count = cache_backend.get(hour_key, 0)
|
||||
if hour_count >= UPLOAD_RATE_LIMITS['per_hour']:
|
||||
return False, "Hourly upload limit exceeded. Please try again later."
|
||||
|
||||
# Check per-day limit
|
||||
day_key = f"upload_rate:{user_id}:day"
|
||||
day_count = cache_backend.get(day_key, 0)
|
||||
if day_count >= UPLOAD_RATE_LIMITS['per_day']:
|
||||
return False, "Daily upload limit exceeded. Please try again tomorrow."
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def increment_upload_count(user_id: int, cache_backend=None) -> None:
|
||||
"""
|
||||
Increment upload count for rate limiting.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID
|
||||
cache_backend: Optional Django cache backend
|
||||
"""
|
||||
if cache_backend is None:
|
||||
from django.core.cache import cache
|
||||
cache_backend = cache
|
||||
|
||||
# Increment per-minute counter (expires in 60 seconds)
|
||||
minute_key = f"upload_rate:{user_id}:minute"
|
||||
try:
|
||||
cache_backend.incr(minute_key)
|
||||
except ValueError:
|
||||
cache_backend.set(minute_key, 1, 60)
|
||||
|
||||
# Increment per-hour counter (expires in 3600 seconds)
|
||||
hour_key = f"upload_rate:{user_id}:hour"
|
||||
try:
|
||||
cache_backend.incr(hour_key)
|
||||
except ValueError:
|
||||
cache_backend.set(hour_key, 1, 3600)
|
||||
|
||||
# Increment per-day counter (expires in 86400 seconds)
|
||||
day_key = f"upload_rate:{user_id}:day"
|
||||
try:
|
||||
cache_backend.incr(day_key)
|
||||
except ValueError:
|
||||
cache_backend.set(day_key, 1, 86400)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Antivirus Integration Point
|
||||
# =============================================================================
|
||||
|
||||
def scan_file_for_malware(file: UploadedFile) -> Tuple[bool, str]:
|
||||
"""
|
||||
Placeholder for antivirus/malware scanning integration.
|
||||
|
||||
This function should be implemented to integrate with a virus scanner
|
||||
like ClamAV. Currently it returns True (safe) for all files.
|
||||
|
||||
Args:
|
||||
file: The uploaded file object
|
||||
|
||||
Returns:
|
||||
Tuple of (is_safe, reason_if_unsafe)
|
||||
"""
|
||||
# TODO(THRILLWIKI-110): Implement ClamAV integration for malware scanning
|
||||
# This requires ClamAV daemon to be running and python-clamav to be installed
|
||||
return True, ""
|
||||
382
backend/apps/core/utils/html_sanitizer.py
Normal file
382
backend/apps/core/utils/html_sanitizer.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""
|
||||
HTML Sanitization Utilities for ThrillWiki.
|
||||
|
||||
This module provides functions for sanitizing user-generated HTML content
|
||||
to prevent XSS (Cross-Site Scripting) attacks while allowing safe HTML
|
||||
formatting.
|
||||
|
||||
Security Note:
|
||||
Always sanitize user-generated content before rendering with |safe filter
|
||||
or mark_safe(). Never trust user input.
|
||||
|
||||
Usage:
|
||||
from apps.core.utils.html_sanitizer import sanitize_html, sanitize_for_json
|
||||
|
||||
# In views
|
||||
context['description'] = sanitize_html(user_input)
|
||||
|
||||
# For JSON/JavaScript contexts
|
||||
json_safe = sanitize_for_json(data)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from html import escape as html_escape
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import bleach
|
||||
BLEACH_AVAILABLE = True
|
||||
except ImportError:
|
||||
BLEACH_AVAILABLE = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Allowed HTML Configuration
|
||||
# =============================================================================
|
||||
|
||||
# Default allowed HTML tags for user-generated content
|
||||
ALLOWED_TAGS = frozenset([
|
||||
# Text formatting
|
||||
'p', 'br', 'hr',
|
||||
'strong', 'b', 'em', 'i', 'u', 's', 'strike',
|
||||
'sub', 'sup', 'small', 'mark',
|
||||
|
||||
# Headers
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
|
||||
# Lists
|
||||
'ul', 'ol', 'li',
|
||||
|
||||
# Links (with restrictions on attributes)
|
||||
'a',
|
||||
|
||||
# Block elements
|
||||
'blockquote', 'pre', 'code',
|
||||
'div', 'span',
|
||||
|
||||
# Tables
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
|
||||
])
|
||||
|
||||
# Allowed attributes for each tag
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
'a': ['href', 'title', 'rel', 'target'],
|
||||
'img': ['src', 'alt', 'title', 'width', 'height'],
|
||||
'div': ['class'],
|
||||
'span': ['class'],
|
||||
'p': ['class'],
|
||||
'table': ['class'],
|
||||
'th': ['class', 'colspan', 'rowspan'],
|
||||
'td': ['class', 'colspan', 'rowspan'],
|
||||
'*': ['class'], # Allow class on all elements
|
||||
}
|
||||
|
||||
# Allowed URL protocols
|
||||
ALLOWED_PROTOCOLS = frozenset([
|
||||
'http', 'https', 'mailto', 'tel',
|
||||
])
|
||||
|
||||
# Minimal tags for comments and short text
|
||||
MINIMAL_TAGS = frozenset([
|
||||
'p', 'br', 'strong', 'b', 'em', 'i', 'a',
|
||||
])
|
||||
|
||||
# Tags allowed in icon SVGs (for icon template rendering)
|
||||
SVG_TAGS = frozenset([
|
||||
'svg', 'path', 'g', 'circle', 'rect', 'line', 'polyline', 'polygon',
|
||||
'ellipse', 'text', 'tspan', 'defs', 'use', 'symbol', 'clipPath',
|
||||
'mask', 'linearGradient', 'radialGradient', 'stop', 'title',
|
||||
])
|
||||
|
||||
SVG_ATTRIBUTES = {
|
||||
'svg': ['viewBox', 'width', 'height', 'fill', 'stroke', 'class',
|
||||
'xmlns', 'aria-hidden', 'role'],
|
||||
'path': ['d', 'fill', 'stroke', 'stroke-width', 'stroke-linecap',
|
||||
'stroke-linejoin', 'class', 'fill-rule', 'clip-rule'],
|
||||
'g': ['fill', 'stroke', 'transform', 'class'],
|
||||
'circle': ['cx', 'cy', 'r', 'fill', 'stroke', 'class'],
|
||||
'rect': ['x', 'y', 'width', 'height', 'rx', 'ry', 'fill', 'stroke', 'class'],
|
||||
'line': ['x1', 'y1', 'x2', 'y2', 'stroke', 'stroke-width', 'class'],
|
||||
'polyline': ['points', 'fill', 'stroke', 'class'],
|
||||
'polygon': ['points', 'fill', 'stroke', 'class'],
|
||||
'*': ['class', 'fill', 'stroke'],
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Sanitization Functions
|
||||
# =============================================================================
|
||||
|
||||
def sanitize_html(
|
||||
html: str | None,
|
||||
allowed_tags: frozenset | None = None,
|
||||
allowed_attributes: dict | None = None,
|
||||
allowed_protocols: frozenset | None = None,
|
||||
strip: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Sanitize HTML content to prevent XSS attacks.
|
||||
|
||||
Args:
|
||||
html: The HTML string to sanitize
|
||||
allowed_tags: Set of allowed HTML tag names
|
||||
allowed_attributes: Dict mapping tag names to allowed attributes
|
||||
allowed_protocols: Set of allowed URL protocols
|
||||
strip: If True, remove disallowed tags; if False, escape them
|
||||
|
||||
Returns:
|
||||
Sanitized HTML string safe for rendering
|
||||
|
||||
Example:
|
||||
>>> sanitize_html('<script>alert("xss")</script><p>Hello</p>')
|
||||
'<p>Hello</p>'
|
||||
"""
|
||||
if not html:
|
||||
return ''
|
||||
|
||||
if not isinstance(html, str):
|
||||
html = str(html)
|
||||
|
||||
if not BLEACH_AVAILABLE:
|
||||
# Fallback: escape all HTML if bleach is not available
|
||||
return html_escape(html)
|
||||
|
||||
tags = allowed_tags if allowed_tags is not None else ALLOWED_TAGS
|
||||
attrs = allowed_attributes if allowed_attributes is not None else ALLOWED_ATTRIBUTES
|
||||
protocols = allowed_protocols if allowed_protocols is not None else ALLOWED_PROTOCOLS
|
||||
|
||||
return bleach.clean(
|
||||
html,
|
||||
tags=tags,
|
||||
attributes=attrs,
|
||||
protocols=protocols,
|
||||
strip=strip,
|
||||
)
|
||||
|
||||
|
||||
def sanitize_minimal(html: str | None) -> str:
|
||||
"""
|
||||
Sanitize HTML with minimal allowed tags.
|
||||
|
||||
Use this for user comments, short descriptions, etc.
|
||||
|
||||
Args:
|
||||
html: The HTML string to sanitize
|
||||
|
||||
Returns:
|
||||
Sanitized HTML with only basic formatting tags allowed
|
||||
"""
|
||||
return sanitize_html(
|
||||
html,
|
||||
allowed_tags=MINIMAL_TAGS,
|
||||
allowed_attributes={'a': ['href', 'title']},
|
||||
)
|
||||
|
||||
|
||||
def sanitize_svg(svg: str | None) -> str:
|
||||
"""
|
||||
Sanitize SVG content for safe inline rendering.
|
||||
|
||||
This is specifically for icon SVGs that need to be rendered inline.
|
||||
Removes potentially dangerous elements while preserving SVG structure.
|
||||
|
||||
Args:
|
||||
svg: The SVG string to sanitize
|
||||
|
||||
Returns:
|
||||
Sanitized SVG string safe for inline rendering
|
||||
"""
|
||||
if not svg:
|
||||
return ''
|
||||
|
||||
if not isinstance(svg, str):
|
||||
svg = str(svg)
|
||||
|
||||
if not BLEACH_AVAILABLE:
|
||||
# Fallback: escape all if bleach is not available
|
||||
return html_escape(svg)
|
||||
|
||||
return bleach.clean(
|
||||
svg,
|
||||
tags=SVG_TAGS,
|
||||
attributes=SVG_ATTRIBUTES,
|
||||
strip=True,
|
||||
)
|
||||
|
||||
|
||||
def strip_html(html: str | None) -> str:
|
||||
"""
|
||||
Remove all HTML tags from a string.
|
||||
|
||||
Use this for contexts where no HTML is allowed at all.
|
||||
|
||||
Args:
|
||||
html: The HTML string to strip
|
||||
|
||||
Returns:
|
||||
Plain text with all HTML tags removed
|
||||
"""
|
||||
if not html:
|
||||
return ''
|
||||
|
||||
if not isinstance(html, str):
|
||||
html = str(html)
|
||||
|
||||
if BLEACH_AVAILABLE:
|
||||
return bleach.clean(html, tags=[], strip=True)
|
||||
else:
|
||||
# Fallback: use regex to strip tags
|
||||
return re.sub(r'<[^>]+>', '', html)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# JSON/JavaScript Context Sanitization
|
||||
# =============================================================================
|
||||
|
||||
def sanitize_for_json(data: Any) -> str:
|
||||
"""
|
||||
Safely serialize data for embedding in JavaScript/JSON contexts.
|
||||
|
||||
This prevents XSS when embedding data in <script> tags or JavaScript.
|
||||
|
||||
Args:
|
||||
data: The data to serialize (dict, list, or primitive)
|
||||
|
||||
Returns:
|
||||
JSON string safe for embedding in JavaScript
|
||||
|
||||
Example:
|
||||
>>> sanitize_for_json({'name': '</script><script>alert("xss")'})
|
||||
'{"name": "\\u003c/script\\u003e\\u003cscript\\u003ealert(\\"xss\\")"}'
|
||||
"""
|
||||
# JSON encode with safe characters escaped
|
||||
return json.dumps(data, ensure_ascii=False).replace(
|
||||
'<', '\\u003c'
|
||||
).replace(
|
||||
'>', '\\u003e'
|
||||
).replace(
|
||||
'&', '\\u0026'
|
||||
).replace(
|
||||
"'", '\\u0027'
|
||||
)
|
||||
|
||||
|
||||
def escape_js_string(s: str | None) -> str:
|
||||
"""
|
||||
Escape a string for safe use in JavaScript string literals.
|
||||
|
||||
Args:
|
||||
s: The string to escape
|
||||
|
||||
Returns:
|
||||
Escaped string safe for JavaScript contexts
|
||||
"""
|
||||
if not s:
|
||||
return ''
|
||||
|
||||
if not isinstance(s, str):
|
||||
s = str(s)
|
||||
|
||||
# Escape backslashes first, then other special characters
|
||||
return s.replace('\\', '\\\\').replace(
|
||||
"'", "\\'"
|
||||
).replace(
|
||||
'"', '\\"'
|
||||
).replace(
|
||||
'\n', '\\n'
|
||||
).replace(
|
||||
'\r', '\\r'
|
||||
).replace(
|
||||
'<', '\\u003c'
|
||||
).replace(
|
||||
'>', '\\u003e'
|
||||
).replace(
|
||||
'&', '\\u0026'
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# URL Sanitization
|
||||
# =============================================================================
|
||||
|
||||
def sanitize_url(url: str | None, allowed_protocols: frozenset | None = None) -> str:
|
||||
"""
|
||||
Sanitize a URL to prevent javascript: and other dangerous protocols.
|
||||
|
||||
Args:
|
||||
url: The URL to sanitize
|
||||
allowed_protocols: Set of allowed URL protocols
|
||||
|
||||
Returns:
|
||||
Sanitized URL or empty string if unsafe
|
||||
"""
|
||||
if not url:
|
||||
return ''
|
||||
|
||||
if not isinstance(url, str):
|
||||
url = str(url)
|
||||
|
||||
url = url.strip()
|
||||
|
||||
if not url:
|
||||
return ''
|
||||
|
||||
protocols = allowed_protocols if allowed_protocols is not None else ALLOWED_PROTOCOLS
|
||||
|
||||
# Check for allowed protocols
|
||||
url_lower = url.lower()
|
||||
|
||||
# Check for javascript:, data:, vbscript:, etc.
|
||||
if ':' in url_lower:
|
||||
protocol = url_lower.split(':')[0]
|
||||
if protocol not in protocols:
|
||||
# Allow relative URLs and anchor links
|
||||
if not (url.startswith('/') or url.startswith('#') or url.startswith('?')):
|
||||
return ''
|
||||
|
||||
return url
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Attribute Sanitization
|
||||
# =============================================================================
|
||||
|
||||
def sanitize_attribute_value(value: str | None) -> str:
|
||||
"""
|
||||
Sanitize a value for use in HTML attributes.
|
||||
|
||||
Args:
|
||||
value: The attribute value to sanitize
|
||||
|
||||
Returns:
|
||||
Sanitized value safe for HTML attribute contexts
|
||||
"""
|
||||
if not value:
|
||||
return ''
|
||||
|
||||
if not isinstance(value, str):
|
||||
value = str(value)
|
||||
|
||||
# HTML escape for attribute context
|
||||
return html_escape(value, quote=True)
|
||||
|
||||
|
||||
def sanitize_class_name(name: str | None) -> str:
|
||||
"""
|
||||
Sanitize a CSS class name.
|
||||
|
||||
Args:
|
||||
name: The class name to sanitize
|
||||
|
||||
Returns:
|
||||
Sanitized class name containing only safe characters
|
||||
"""
|
||||
if not name:
|
||||
return ''
|
||||
|
||||
if not isinstance(name, str):
|
||||
name = str(name)
|
||||
|
||||
# Only allow alphanumeric, hyphens, and underscores
|
||||
return re.sub(r'[^a-zA-Z0-9_-]', '', name)
|
||||
79
backend/apps/core/views/base.py
Normal file
79
backend/apps/core/views/base.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Base view classes with common patterns.
|
||||
|
||||
This module provides base view classes that implement common patterns
|
||||
such as automatic query optimization with select_related and prefetch_related.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
|
||||
class OptimizedListView(ListView):
|
||||
"""
|
||||
ListView with automatic query optimization.
|
||||
|
||||
Automatically applies select_related and prefetch_related based on
|
||||
class attributes, reducing the need for boilerplate code in get_queryset.
|
||||
|
||||
Attributes:
|
||||
select_related_fields: List of fields to pass to select_related()
|
||||
prefetch_related_fields: List of fields to pass to prefetch_related()
|
||||
|
||||
Example:
|
||||
class RideListView(OptimizedListView):
|
||||
model = Ride
|
||||
select_related_fields = ['park', 'manufacturer']
|
||||
prefetch_related_fields = ['photos']
|
||||
"""
|
||||
|
||||
select_related_fields: List[str] = []
|
||||
prefetch_related_fields: List[str] = []
|
||||
|
||||
def get_queryset(self) -> QuerySet:
|
||||
"""Get queryset with optimizations applied."""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
if self.select_related_fields:
|
||||
queryset = queryset.select_related(*self.select_related_fields)
|
||||
|
||||
if self.prefetch_related_fields:
|
||||
queryset = queryset.prefetch_related(*self.prefetch_related_fields)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class OptimizedDetailView(DetailView):
|
||||
"""
|
||||
DetailView with automatic query optimization.
|
||||
|
||||
Automatically applies select_related and prefetch_related based on
|
||||
class attributes, reducing the need for boilerplate code in get_queryset.
|
||||
|
||||
Attributes:
|
||||
select_related_fields: List of fields to pass to select_related()
|
||||
prefetch_related_fields: List of fields to pass to prefetch_related()
|
||||
|
||||
Example:
|
||||
class RideDetailView(OptimizedDetailView):
|
||||
model = Ride
|
||||
select_related_fields = ['park', 'park__location', 'manufacturer']
|
||||
prefetch_related_fields = ['photos', 'coaster_stats']
|
||||
"""
|
||||
|
||||
select_related_fields: List[str] = []
|
||||
prefetch_related_fields: List[str] = []
|
||||
|
||||
def get_queryset(self) -> QuerySet:
|
||||
"""Get queryset with optimizations applied."""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
if self.select_related_fields:
|
||||
queryset = queryset.select_related(*self.select_related_fields)
|
||||
|
||||
if self.prefetch_related_fields:
|
||||
queryset = queryset.prefetch_related(*self.prefetch_related_fields)
|
||||
|
||||
return queryset
|
||||
@@ -6,8 +6,6 @@ from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.utils.decorators import method_decorator
|
||||
from typing import Optional, List
|
||||
|
||||
from ..services.entity_fuzzy_matching import (
|
||||
@@ -244,10 +242,12 @@ class EntityNotFoundView(APIView):
|
||||
)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class QuickEntitySuggestionView(APIView):
|
||||
"""
|
||||
Lightweight endpoint for quick entity suggestions (e.g., autocomplete).
|
||||
|
||||
Security Note: This endpoint only accepts GET requests, which are inherently
|
||||
safe from CSRF attacks. No CSRF exemption is needed.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.views.generic.edit import FormView
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
|
||||
class InlineEditView(FormView):
|
||||
|
||||
@@ -636,7 +636,9 @@ class MapCacheView(MapAPIView):
|
||||
|
||||
def delete(self, request: HttpRequest) -> JsonResponse:
|
||||
"""Clear all map cache (admin only)."""
|
||||
# TODO: Add admin permission check
|
||||
# TODO(THRILLWIKI-103): Add admin permission check for cache clear
|
||||
if not (request.user.is_authenticated and request.user.is_staff):
|
||||
return self._error_response("Admin access required", 403)
|
||||
try:
|
||||
unified_map_service.invalidate_cache()
|
||||
|
||||
@@ -655,7 +657,9 @@ class MapCacheView(MapAPIView):
|
||||
|
||||
def post(self, request: HttpRequest) -> JsonResponse:
|
||||
"""Invalidate specific cache entries."""
|
||||
# TODO: Add admin permission check
|
||||
# TODO(THRILLWIKI-103): Add admin permission check for cache invalidation
|
||||
if not (request.user.is_authenticated and request.user.is_staff):
|
||||
return self._error_response("Admin access required", 403)
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.views.generic.edit import FormView
|
||||
from django.http import HttpResponse
|
||||
|
||||
|
||||
class HTMXModalFormView(FormView):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Core views for the application.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Type
|
||||
@@ -93,18 +94,14 @@ class GlobalSearchView(TemplateView):
|
||||
# Real implementation should query multiple models.
|
||||
if q:
|
||||
# Return a small payload of mocked results to keep this scaffold safe
|
||||
results = [
|
||||
{"title": f"Result for {q}", "url": "#", "subtitle": "Park"}
|
||||
]
|
||||
results = [{"title": f"Result for {q}", "url": "#", "subtitle": "Park"}]
|
||||
suggestions = [{"text": q, "url": "#"}]
|
||||
|
||||
context = {"results": results, "suggestions": suggestions}
|
||||
|
||||
# If HTMX request, render dropdown partial
|
||||
if request.headers.get("HX-Request") == "true":
|
||||
return render(
|
||||
request, "core/search/partials/search_dropdown.html", context
|
||||
)
|
||||
return render(request, "core/search/partials/search_dropdown.html", context)
|
||||
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
@@ -117,25 +114,75 @@ class GlobalSearchView(TemplateView):
|
||||
# Default transition metadata for styling
|
||||
TRANSITION_METADATA = {
|
||||
# Approval transitions
|
||||
"approve": {"style": "green", "icon": "check", "requires_confirm": True, "confirm_message": "Are you sure you want to approve this?"},
|
||||
"transition_to_approved": {"style": "green", "icon": "check", "requires_confirm": True, "confirm_message": "Are you sure you want to approve this?"},
|
||||
"approve": {
|
||||
"style": "green",
|
||||
"icon": "check",
|
||||
"requires_confirm": True,
|
||||
"confirm_message": "Are you sure you want to approve this?",
|
||||
},
|
||||
"transition_to_approved": {
|
||||
"style": "green",
|
||||
"icon": "check",
|
||||
"requires_confirm": True,
|
||||
"confirm_message": "Are you sure you want to approve this?",
|
||||
},
|
||||
# Rejection transitions
|
||||
"reject": {"style": "red", "icon": "times", "requires_confirm": True, "confirm_message": "Are you sure you want to reject this?"},
|
||||
"transition_to_rejected": {"style": "red", "icon": "times", "requires_confirm": True, "confirm_message": "Are you sure you want to reject this?"},
|
||||
"reject": {
|
||||
"style": "red",
|
||||
"icon": "times",
|
||||
"requires_confirm": True,
|
||||
"confirm_message": "Are you sure you want to reject this?",
|
||||
},
|
||||
"transition_to_rejected": {
|
||||
"style": "red",
|
||||
"icon": "times",
|
||||
"requires_confirm": True,
|
||||
"confirm_message": "Are you sure you want to reject this?",
|
||||
},
|
||||
# Escalation transitions
|
||||
"escalate": {"style": "yellow", "icon": "arrow-up", "requires_confirm": True, "confirm_message": "Are you sure you want to escalate this?"},
|
||||
"transition_to_escalated": {"style": "yellow", "icon": "arrow-up", "requires_confirm": True, "confirm_message": "Are you sure you want to escalate this?"},
|
||||
"escalate": {
|
||||
"style": "yellow",
|
||||
"icon": "arrow-up",
|
||||
"requires_confirm": True,
|
||||
"confirm_message": "Are you sure you want to escalate this?",
|
||||
},
|
||||
"transition_to_escalated": {
|
||||
"style": "yellow",
|
||||
"icon": "arrow-up",
|
||||
"requires_confirm": True,
|
||||
"confirm_message": "Are you sure you want to escalate this?",
|
||||
},
|
||||
# Assignment transitions
|
||||
"assign": {"style": "blue", "icon": "user-plus", "requires_confirm": False},
|
||||
"unassign": {"style": "gray", "icon": "user-minus", "requires_confirm": False},
|
||||
# Status transitions
|
||||
"start": {"style": "blue", "icon": "play", "requires_confirm": False},
|
||||
"complete": {"style": "green", "icon": "check-circle", "requires_confirm": True, "confirm_message": "Are you sure you want to complete this?"},
|
||||
"cancel": {"style": "red", "icon": "ban", "requires_confirm": True, "confirm_message": "Are you sure you want to cancel this?"},
|
||||
"complete": {
|
||||
"style": "green",
|
||||
"icon": "check-circle",
|
||||
"requires_confirm": True,
|
||||
"confirm_message": "Are you sure you want to complete this?",
|
||||
},
|
||||
"cancel": {
|
||||
"style": "red",
|
||||
"icon": "ban",
|
||||
"requires_confirm": True,
|
||||
"confirm_message": "Are you sure you want to cancel this?",
|
||||
},
|
||||
"reopen": {"style": "blue", "icon": "redo", "requires_confirm": False},
|
||||
# Resolution transitions
|
||||
"resolve": {"style": "green", "icon": "check-double", "requires_confirm": True, "confirm_message": "Are you sure you want to resolve this?"},
|
||||
"dismiss": {"style": "gray", "icon": "times-circle", "requires_confirm": True, "confirm_message": "Are you sure you want to dismiss this?"},
|
||||
"resolve": {
|
||||
"style": "green",
|
||||
"icon": "check-double",
|
||||
"requires_confirm": True,
|
||||
"confirm_message": "Are you sure you want to resolve this?",
|
||||
},
|
||||
"dismiss": {
|
||||
"style": "gray",
|
||||
"icon": "times-circle",
|
||||
"requires_confirm": True,
|
||||
"confirm_message": "Are you sure you want to dismiss this?",
|
||||
},
|
||||
# Default
|
||||
"default": {"style": "gray", "icon": "arrow-right", "requires_confirm": False},
|
||||
}
|
||||
@@ -155,7 +202,9 @@ def get_transition_metadata(transition_name: str) -> Dict[str, Any]:
|
||||
return TRANSITION_METADATA["default"].copy()
|
||||
|
||||
|
||||
def add_toast_trigger(response: HttpResponse, message: str, toast_type: str = "success") -> HttpResponse:
|
||||
def add_toast_trigger(
|
||||
response: HttpResponse, message: str, toast_type: str = "success"
|
||||
) -> HttpResponse:
|
||||
"""
|
||||
Add HX-Trigger header to trigger Alpine.js toast.
|
||||
|
||||
@@ -167,17 +216,12 @@ def add_toast_trigger(response: HttpResponse, message: str, toast_type: str = "s
|
||||
Returns:
|
||||
Modified response with HX-Trigger header
|
||||
"""
|
||||
trigger_data = {
|
||||
"showToast": {
|
||||
"message": message,
|
||||
"type": toast_type
|
||||
}
|
||||
}
|
||||
trigger_data = {"showToast": {"message": message, "type": toast_type}}
|
||||
response["HX-Trigger"] = json.dumps(trigger_data)
|
||||
return response
|
||||
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(csrf_protect, name="dispatch")
|
||||
class FSMTransitionView(View):
|
||||
"""
|
||||
Generic view for handling FSM state transitions via HTMX.
|
||||
@@ -212,12 +256,16 @@ class FSMTransitionView(View):
|
||||
The model class or None if not found
|
||||
"""
|
||||
try:
|
||||
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
|
||||
content_type = ContentType.objects.get(
|
||||
app_label=app_label, model=model_name
|
||||
)
|
||||
return content_type.model_class()
|
||||
except ContentType.DoesNotExist:
|
||||
return None
|
||||
|
||||
def get_object(self, model_class: Type[Model], pk: Any, slug: Optional[str] = None) -> Model:
|
||||
def get_object(
|
||||
self, model_class: Type[Model], pk: Any, slug: Optional[str] = None
|
||||
) -> Model:
|
||||
"""
|
||||
Get the model instance.
|
||||
|
||||
@@ -249,7 +297,9 @@ class FSMTransitionView(View):
|
||||
"""
|
||||
return getattr(obj, transition_name, None)
|
||||
|
||||
def validate_transition(self, obj: Model, transition_name: str, user) -> tuple[bool, Optional[str]]:
|
||||
def validate_transition(
|
||||
self, obj: Model, transition_name: str, user
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate that the transition can proceed.
|
||||
|
||||
@@ -264,18 +314,26 @@ class FSMTransitionView(View):
|
||||
method = self.get_transition_method(obj, transition_name)
|
||||
|
||||
if method is None:
|
||||
return False, f"Transition '{transition_name}' not found on {obj.__class__.__name__}"
|
||||
return (
|
||||
False,
|
||||
f"Transition '{transition_name}' not found on {obj.__class__.__name__}",
|
||||
)
|
||||
|
||||
if not callable(method):
|
||||
return False, f"'{transition_name}' is not a callable method"
|
||||
|
||||
# Check if the transition can proceed
|
||||
if not can_proceed(method, user):
|
||||
return False, f"Transition '{transition_name}' is not allowed from current state"
|
||||
return (
|
||||
False,
|
||||
f"Transition '{transition_name}' is not allowed from current state",
|
||||
)
|
||||
|
||||
return True, None
|
||||
|
||||
def execute_transition(self, obj: Model, transition_name: str, user, **kwargs) -> None:
|
||||
def execute_transition(
|
||||
self, obj: Model, transition_name: str, user, **kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Execute the transition on the object.
|
||||
|
||||
@@ -297,7 +355,9 @@ class FSMTransitionView(View):
|
||||
def get_success_message(self, obj: Model, transition_name: str) -> str:
|
||||
"""Generate a success message for the transition."""
|
||||
# Clean up transition name for display
|
||||
display_name = transition_name.replace("transition_to_", "").replace("_", " ").title()
|
||||
display_name = (
|
||||
transition_name.replace("transition_to_", "").replace("_", " ").title()
|
||||
)
|
||||
model_name = obj._meta.verbose_name.title()
|
||||
return f"{model_name} has been {display_name.lower()}d successfully."
|
||||
|
||||
@@ -321,9 +381,9 @@ class FSMTransitionView(View):
|
||||
model_name = obj._meta.model_name
|
||||
|
||||
# Special handling for parks and rides - return status section
|
||||
if app_label == 'parks' and model_name == 'park':
|
||||
if app_label == "parks" and model_name == "park":
|
||||
return "parks/partials/park_status_actions.html"
|
||||
elif app_label == 'rides' and model_name == 'ride':
|
||||
elif app_label == "rides" and model_name == "ride":
|
||||
return "rides/partials/ride_status_actions.html"
|
||||
|
||||
# Check for model-specific templates in order of preference
|
||||
@@ -337,6 +397,7 @@ class FSMTransitionView(View):
|
||||
# Use template loader to check if template exists
|
||||
from django.template.loader import select_template
|
||||
from django.template import TemplateDoesNotExist
|
||||
|
||||
try:
|
||||
template = select_template(possible_templates)
|
||||
return template.template.name
|
||||
@@ -344,10 +405,7 @@ class FSMTransitionView(View):
|
||||
return "htmx/updated_row.html"
|
||||
|
||||
def format_success_response(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
obj: Model,
|
||||
transition_name: str
|
||||
self, request: HttpRequest, obj: Model, transition_name: str
|
||||
) -> HttpResponse:
|
||||
"""
|
||||
Format a successful transition response.
|
||||
@@ -381,17 +439,20 @@ class FSMTransitionView(View):
|
||||
return add_toast_trigger(response, message, "success")
|
||||
|
||||
# Regular request - return JSON
|
||||
return JsonResponse({
|
||||
"success": True,
|
||||
"message": message,
|
||||
"new_state": getattr(obj, obj.state_field_name, None) if hasattr(obj, "state_field_name") else None,
|
||||
})
|
||||
return JsonResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": message,
|
||||
"new_state": (
|
||||
getattr(obj, obj.state_field_name, None)
|
||||
if hasattr(obj, "state_field_name")
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def format_error_response(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
error: Exception,
|
||||
status_code: int = 400
|
||||
self, request: HttpRequest, error: Exception, status_code: int = 400
|
||||
) -> HttpResponse:
|
||||
"""
|
||||
Format an error response.
|
||||
@@ -408,10 +469,13 @@ class FSMTransitionView(View):
|
||||
return add_toast_trigger(response, message, "error")
|
||||
|
||||
# Regular request - return JSON
|
||||
return JsonResponse({
|
||||
"success": False,
|
||||
"error": error_data,
|
||||
}, status=status_code)
|
||||
return JsonResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": error_data,
|
||||
},
|
||||
status=status_code,
|
||||
)
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Handle POST request to execute a transition."""
|
||||
@@ -425,24 +489,22 @@ class FSMTransitionView(View):
|
||||
if not all([app_label, model_name, transition_name]):
|
||||
return self.format_error_response(
|
||||
request,
|
||||
ValueError("Missing required parameters: app_label, model_name, and transition_name"),
|
||||
400
|
||||
ValueError(
|
||||
"Missing required parameters: app_label, model_name, and transition_name"
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
if not pk and not slug:
|
||||
return self.format_error_response(
|
||||
request,
|
||||
ValueError("Missing required parameter: pk or slug"),
|
||||
400
|
||||
request, ValueError("Missing required parameter: pk or slug"), 400
|
||||
)
|
||||
|
||||
# Get the model class
|
||||
model_class = self.get_model_class(app_label, model_name)
|
||||
if model_class is None:
|
||||
return self.format_error_response(
|
||||
request,
|
||||
ValueError(f"Model '{app_label}.{model_name}' not found"),
|
||||
404
|
||||
request, ValueError(f"Model '{app_label}.{model_name}' not found"), 404
|
||||
)
|
||||
|
||||
# Get the object
|
||||
@@ -450,13 +512,13 @@ class FSMTransitionView(View):
|
||||
obj = self.get_object(model_class, pk, slug)
|
||||
except ObjectDoesNotExist:
|
||||
return self.format_error_response(
|
||||
request,
|
||||
ValueError(f"Object not found: {model_name} with pk={pk}"),
|
||||
404
|
||||
request, ValueError(f"Object not found: {model_name} with pk={pk}"), 404
|
||||
)
|
||||
|
||||
# Validate the transition
|
||||
can_execute, error_msg = self.validate_transition(obj, transition_name, request.user)
|
||||
can_execute, error_msg = self.validate_transition(
|
||||
obj, transition_name, request.user
|
||||
)
|
||||
if not can_execute:
|
||||
return self.format_error_response(
|
||||
request,
|
||||
@@ -466,7 +528,7 @@ class FSMTransitionView(View):
|
||||
current_state=getattr(obj, "status", None),
|
||||
requested_transition=transition_name,
|
||||
),
|
||||
400
|
||||
400,
|
||||
)
|
||||
|
||||
# Execute the transition
|
||||
@@ -509,7 +571,5 @@ class FSMTransitionView(View):
|
||||
f"Unexpected error during transition '{transition_name}' on {model_class.__name__}(pk={obj.pk})"
|
||||
)
|
||||
return self.format_error_response(
|
||||
request,
|
||||
ValueError(f"An unexpected error occurred: {str(e)}"),
|
||||
500
|
||||
request, ValueError(f"An unexpected error occurred: {str(e)}"), 500
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user