From 5ca08a50b5857675ea4c3f89387ad3ba93e3ae78 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 7 Dec 2025 10:39:46 +0100 Subject: [PATCH] feat: add security middleware for security headers, honeypot, and rate limiting to the server. --- bin/middleware/security_middleware.dart | 110 +++++++++ bin/server.dart | 6 + lib/features/iptv/screens/player_screen.dart | 216 +++++++++++------ .../iptv/screens/video_player_screen.dart | 221 ++++++++++++------ 4 files changed, 409 insertions(+), 144 deletions(-) create mode 100644 bin/middleware/security_middleware.dart diff --git a/bin/middleware/security_middleware.dart b/bin/middleware/security_middleware.dart new file mode 100644 index 0000000..c66a5de --- /dev/null +++ b/bin/middleware/security_middleware.dart @@ -0,0 +1,110 @@ +import 'package:shelf/shelf.dart'; +import 'dart:async'; + +/// Security Middleware Collection +/// +/// Includes: +/// - Honeypot Routes (Trap for bots) +/// - Security Headers (HSTS, XSS Protection) +/// - Rate Limiting (Basic DoS protection) + +/// 1. Security Headers Middleware +/// Adds standard security headers to every response. +Middleware securityHeadersMiddleware() { + return (Handler handler) { + return (Request request) async { + final response = await handler(request); + + return response.change(headers: { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block', + 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + // Note: CSP is tricky with Flutter Web (requires 'unsafe-eval' for Dart), + // so we omit it here to avoid breaking the app, or use a permissive one. + }); + }; + }; +} + +/// 2. Honeypot Middleware +/// Intercepts requests to common vulnerability scanning paths. +/// Returns a 403 Forbidden immediately and logs the incident. +Middleware honeypotMiddleware() { + // list of common bot targets + const honeypotPaths = [ + '/admin/phpmyadmin', + '/phpmyadmin', + '/wp-admin', + '/wp-login.php', + '/.env', + '/config.php', + '/api/.env', + '/console', + '/actuator/health' + ]; + + return (Handler handler) { + return (Request request) { + final path = request.url.path; + + // Check if path contains any honeypot target + for (final trap in honeypotPaths) { + if (path.contains(trap.replaceAll('/', ''))) { // Simple check + print('SECURITY ALERT: Honeypot triggered by ${request.context['clientIp'] ?? 'unknown IP'} on path: $path'); + return Response.forbidden('Access Denied'); + } + } + + return handler(request); + }; + }; +} + +/// 3. Rate Limit Middleware (In-Memory) +/// Limits requests per IP address. +/// Default: 100 requests per minute per IP. +Middleware rateLimitMiddleware({int requestsPerMinute = 200}) { + final clientRequests = >{}; + + // Cleanup timer to remove old entries and prevent memory leaks + Timer.periodic(const Duration(minutes: 5), (_) { + final now = DateTime.now(); + clientRequests.removeWhere((_, times) { + // Remove timestamps older than 1 minute + times.removeWhere((t) => now.difference(t).inMinutes > 1); + return times.isEmpty; + }); + }); + + return (Handler handler) { + return (Request request) { + // Identify client by IP (passed from main server or headers) + // Note: In real prod behind Nginx, use X-Forwarded-For + // Here we assume direct or standard setup. + final clientIp = (request.context['clientIp'] as String?) ?? 'unknown'; + + if (clientIp != 'unknown' && clientIp != '127.0.0.1') { + final now = DateTime.now(); + + // Get or create history for this IP + final history = clientRequests.putIfAbsent(clientIp, () => []); + + // Clean old requests (older than 1 minute) + history.removeWhere((t) => now.difference(t).inMinutes >= 1); + + // Check limit + if (history.length >= requestsPerMinute) { + print('SECURITY WARN: Rate limit exceeded for $clientIp'); + return Response(429, body: 'Too Many Requests'); + } + + // Add current request + history.add(now); + } + + return handler(request); + }; + }; +} diff --git a/bin/server.dart b/bin/server.dart index a752885..f5debc3 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -12,6 +12,7 @@ import 'api/auth_handler.dart'; import 'api/users_handler.dart'; import 'api/playlists_handler.dart'; import 'middleware/auth_middleware.dart'; +import 'middleware/security_middleware.dart'; void main(List args) async { // Parse command line arguments @@ -62,9 +63,14 @@ void main(List args) async { .add(staticHandler) .handler; + + // Add middleware final pipeline = Pipeline() .addMiddleware(logRequests()) + .addMiddleware(securityHeadersMiddleware()) // Basic Headers + .addMiddleware(honeypotMiddleware()) // Trap Bots + .addMiddleware(rateLimitMiddleware()) // Anti-DoS .addMiddleware(_corsMiddleware()) .addHandler(handler); diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 6cf8193..7c8fe64 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -14,6 +14,8 @@ import '../../../core/models/iptv_models.dart'; import '../../../core/widgets/themed_loading_screen.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'dart:ui' as ui; // Essential for BackdropFilter +import '../../../core/theme/app_colors.dart'; /// Stream type enum for player enum StreamType { live, vod, series } @@ -100,8 +102,13 @@ class _PlayerScreenState extends ConsumerState { void _sendMessage(Map message) { // Helper to send message to iframe + debugPrint('Sending message to iframe ($_viewId): $message'); final iframe = html.document.getElementById(_viewId) as html.IFrameElement?; - iframe?.contentWindow?.postMessage(message, '*'); + if (iframe == null) { + debugPrint('ERROR: Iframe with ID $_viewId not found!'); + } else { + iframe.contentWindow?.postMessage(message, '*'); + } } void _setAspectRatio(String mode) { @@ -296,87 +303,160 @@ class _PlayerScreenState extends ConsumerState { void _showSettingsDialog() { showDialog( context: context, - useRootNavigator: true, // Ensure it's on top + useRootNavigator: true, builder: (context) => PointerInterceptor( - child: Theme( - data: Theme.of(context).copyWith(dialogBackgroundColor: const Color(0xFF1C1C1E)), - child: AlertDialog( - backgroundColor: const Color(0xFF1C1C1E).withOpacity(0.95), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: Colors.white.withOpacity(0.1)), + child: Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.all(24), + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + builder: (context, value, child) => Transform.scale( + scale: 0.9 + (0.1 * value), + child: Opacity( + opacity: value, + child: child, + ), ), - title: const Text('Réglages', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)), - content: SizedBox( + child: Container( width: 400, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Format d\'image', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _buildAspectRatioChip('Original', 'contain'), - _buildAspectRatioChip('Remplir', 'cover'), - _buildAspectRatioChip('Étirer', 'fill'), - ], + decoration: BoxDecoration( + color: AppColors.surface.withOpacity(0.8), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withOpacity(0.1)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.5), + blurRadius: 30, + offset: const Offset(0, 10), ), - if (_audioTracks.isNotEmpty) ...[ - const SizedBox(height: 24), - const Text('Pistes Audio', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - Container( - constraints: const BoxConstraints(maxHeight: 200), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.05), - borderRadius: BorderRadius.circular(8), - ), - child: SingleChildScrollView( - child: Column( - children: _audioTracks.map((track) { - return ListTile( - title: Text(track['label'] ?? 'Piste ${track['id']}', style: const TextStyle(color: Colors.white, fontSize: 14)), - subtitle: Text(track['lang'] ?? '', style: const TextStyle(color: Colors.white54, fontSize: 12)), - onTap: () => _setAudioTrack(track['id']), - dense: true, - leading: const Icon(Icons.audiotrack, size: 20, color: Colors.white54), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - hoverColor: Colors.white.withOpacity(0.1), - ); - }).toList(), - ), - ), - ), - ], ], ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - style: TextButton.styleFrom(foregroundColor: Colors.white), - child: const Text('Fermer'), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Réglages', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + letterSpacing: -0.5, + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white54), + onPressed: () => Navigator.pop(context), + tooltip: 'Fermer', + ), + ], + ), + const SizedBox(height: 24), + const Text('FORMAT D\'IMAGE', style: TextStyle(color: AppColors.textSecondary, fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1)), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _buildRatioOption('Original', 'contain', Icons.crop_original), + _buildRatioOption('Remplir', 'cover', Icons.crop_free), + _buildRatioOption('Étirer', 'fill', Icons.aspect_ratio), + ], + ), + ), + if (_audioTracks.isNotEmpty) ...[ + const SizedBox(height: 32), + const Text('AUDIO', style: TextStyle(color: AppColors.textSecondary, fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1)), + const SizedBox(height: 12), + Container( + constraints: const BoxConstraints(maxHeight: 200), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withOpacity(0.05)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: SingleChildScrollView( + child: Column( + children: _audioTracks.map((track) { + return Material( + color: Colors.transparent, + child: ListTile( + title: Text(track['label'] ?? 'Piste ${track['id']}', style: const TextStyle(color: Colors.white, fontSize: 14)), + subtitle: Text(track['lang'] ?? '', style: const TextStyle(color: Colors.white54, fontSize: 12)), + onTap: () => _setAudioTrack(track['id']), + dense: true, + leading: const Icon(Icons.audiotrack, size: 18, color: Colors.white54), + hoverColor: Colors.white.withOpacity(0.1), + ), + ); + }).toList(), + ), + ), + ), + ), + ], + ], + ), + ), + ), ), - ], + ), ), ), ), ); } - Widget _buildAspectRatioChip(String label, String value) { + Widget _buildRatioOption(String label, String value, IconData icon) { final isSelected = _aspectRatio == value; - return ChoiceChip( - label: Text(label), - selected: isSelected, - onSelected: (_) => _setAspectRatio(value), - backgroundColor: Colors.black26, - selectedColor: Theme.of(context).primaryColor, - labelStyle: TextStyle(color: isSelected ? Colors.white : Colors.white70), - side: BorderSide.none, + return Expanded( + child: GestureDetector( + onTap: () { + debugPrint('Changing Aspect Ratio to: $value'); + _setAspectRatio(value); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: isSelected ? AppColors.surfaceVariant : Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: isSelected ? Border.all(color: Colors.white12) : null, + boxShadow: isSelected ? [BoxShadow(color: Colors.black12, blurRadius: 4, offset: const Offset(0, 2))] : null, + ), + child: Column( + children: [ + Icon(icon, color: isSelected ? Colors.white : Colors.white54, size: 20), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + color: isSelected ? Colors.white : Colors.white54, + fontSize: 11, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ), + ), ); } diff --git a/lib/features/iptv/screens/video_player_screen.dart b/lib/features/iptv/screens/video_player_screen.dart index 325e11f..69b449f 100644 --- a/lib/features/iptv/screens/video_player_screen.dart +++ b/lib/features/iptv/screens/video_player_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:google_fonts/google_fonts.dart'; +import '../../../core/theme/app_colors.dart'; class VideoPlayerScreen extends StatefulWidget { final String streamUrl; @@ -19,11 +20,10 @@ class VideoPlayerScreen extends StatefulWidget { State createState() => _VideoPlayerScreenState(); } -class _VideoPlayerScreenState extends State { - late final Player _player; - late final VideoController _controller; - bool _isFullscreen = false; - bool _showControls = true; + // Premium Features State + BoxFit _fit = BoxFit.contain; + List _audioTracks = []; + AudioTrack? _currentAudioTrack; @override void initState() { @@ -36,40 +36,120 @@ class _VideoPlayerScreenState extends State { // Load stream _player.open(Media(widget.streamUrl)); _player.play(); - } - @override - void dispose() { - _player.dispose(); - super.dispose(); - } - - void _toggleFullscreen() { - setState(() { - _isFullscreen = !_isFullscreen; + // Listen to tracks + _player.stream.tracks.listen((tracks) { + setState(() { + _audioTracks = tracks.audio; + _currentAudioTrack = _player.state.track.audio; + }); }); } - void _toggleControls() { + void _cycleAspectRatio() { setState(() { - _showControls = !_showControls; + if (_fit == BoxFit.contain) { + _fit = BoxFit.cover; + } else if (_fit == BoxFit.cover) { + _fit = BoxFit.fill; + } else { + _fit = BoxFit.contain; + } }); } + void _showSettingsDialog() { + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1C1C1E), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Format d\'image', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: [ + _buildRatioChip('Original', BoxFit.contain), + _buildRatioChip('Remplir', BoxFit.cover), + _buildRatioChip('Étirer', BoxFit.fill), + ], + ), + if (_audioTracks.isNotEmpty) ...[ + const SizedBox(height: 24), + const Text('Pistes Audio', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Expanded( + child: ListView.builder( + shrinkWrap: true, + itemCount: _audioTracks.length, + itemBuilder: (context, index) { + final track = _audioTracks[index]; + final isSelected = track == _currentAudioTrack; + return ListTile( + title: Text(track.language ?? track.label ?? 'Piste ${index + 1}', style: const TextStyle(color: Colors.white)), + subtitle: Text(track.id, style: const TextStyle(color: Colors.white54, fontSize: 10)), + trailing: isSelected ? const Icon(Icons.check, color: AppColors.primary) : null, + onTap: () { + _player.setAudioTrack(track); + setState(() => _currentAudioTrack = track); + Navigator.pop(context); + }, + ); + }, + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildRatioChip(String label, BoxFit fit) { + final isSelected = _fit == fit; + return ChoiceChip( + label: Text(label), + selected: isSelected, + onSelected: (_) { + setState(() => _fit = fit); + Navigator.pop(context); + }, + backgroundColor: Colors.white10, + selectedColor: AppColors.primary, + labelStyle: TextStyle(color: isSelected ? Colors.black : Colors.white), + ); + } + @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Colors.black, + backgroundColor: AppColors.background, appBar: _isFullscreen ? null : AppBar( title: Text( widget.title, - style: GoogleFonts.roboto(fontWeight: FontWeight.w600), + style: GoogleFonts.outfit(fontWeight: FontWeight.w600), ), - backgroundColor: Colors.black, + backgroundColor: Colors.transparent, // Glass effect handled by body + elevation: 0, foregroundColor: Colors.white, + actions: [ + IconButton( + icon: const Icon(Icons.settings), + onPressed: _showSettingsDialog, + ), + const SizedBox(width: 8), + ], ), + extendBodyBehindAppBar: true, body: GestureDetector( onTap: _toggleControls, child: Stack( @@ -79,6 +159,7 @@ class _VideoPlayerScreenState extends State { child: Video( controller: _controller, controls: NoVideoControls, + fit: _fit, ), ), @@ -91,7 +172,7 @@ class _VideoPlayerScreenState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.black54, + Colors.black87, Colors.transparent, Colors.transparent, Colors.black87, @@ -102,41 +183,16 @@ class _VideoPlayerScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // Top bar with title + // SAFE AREA SPACER FOR APPBAR if (!_isFullscreen) - const SizedBox.shrink() - else - SafeArea( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - widget.title, - style: GoogleFonts.roboto( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), + const SizedBox(height: 80) + else + const SizedBox.shrink(), // Bottom controls SafeArea( child: Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(24.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -159,7 +215,7 @@ class _VideoPlayerScreenState extends State { value: progress.clamp(0.0, 1.0), backgroundColor: Colors.white24, valueColor: const AlwaysStoppedAnimation( - Colors.red, + AppColors.primary, ), ), const SizedBox(height: 8), @@ -170,15 +226,17 @@ class _VideoPlayerScreenState extends State { Text( _formatDuration(position), style: const TextStyle( - color: Colors.white, + color: Colors.white70, fontSize: 12, + fontWeight: FontWeight.w500, ), ), Text( _formatDuration(duration), style: const TextStyle( - color: Colors.white, + color: Colors.white70, fontSize: 12, + fontWeight: FontWeight.w500, ), ), ], @@ -189,7 +247,7 @@ class _VideoPlayerScreenState extends State { ); }, ), - const SizedBox(height: 16), + const SizedBox(height: 32), // Playback controls Row( @@ -197,54 +255,65 @@ class _VideoPlayerScreenState extends State { children: [ // Rewind IconButton( - icon: const Icon(Icons.replay_10, size: 32), + icon: const Icon(Icons.replay_10, size: 36), color: Colors.white, onPressed: () { final currentPos = _player.state.position; _player.seek(currentPos - const Duration(seconds: 10)); }, ), - const SizedBox(width: 24), + const SizedBox(width: 32), // Play/Pause StreamBuilder( stream: _player.stream.playing, builder: (context, snapshot) { final isPlaying = snapshot.data ?? false; - return IconButton( - icon: Icon( - isPlaying ? Icons.pause : Icons.play_arrow, - size: 48, + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + shape: BoxShape.circle, + border: Border.all(color: Colors.white24), + ), + child: IconButton( + icon: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + size: 48, + ), + color: Colors.white, + onPressed: () { + _player.playOrPause(); + }, ), - color: Colors.white, - onPressed: () { - _player.playOrPause(); - }, ); }, ), - const SizedBox(width: 24), + const SizedBox(width: 32), // Forward IconButton( - icon: const Icon(Icons.forward_10, size: 32), + icon: const Icon(Icons.forward_10, size: 36), color: Colors.white, onPressed: () { final currentPos = _player.state.position; _player.seek(currentPos + const Duration(seconds: 10)); }, ), - const Spacer(), - - // Fullscreen toggle + ], + ), + + const SizedBox(height: 24), + + // Footer Utils + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ IconButton( icon: Icon( - _isFullscreen - ? Icons.fullscreen_exit - : Icons.fullscreen, - size: 32, + _isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, + color: Colors.white70, ), - color: Colors.white, onPressed: _toggleFullscreen, ), ], @@ -267,7 +336,7 @@ class _VideoPlayerScreenState extends State { return const Center( child: CircularProgressIndicator( - color: Colors.red, + color: AppColors.primary, strokeWidth: 3, ), );