diff --git a/Dockerfile b/Dockerfile index 01e43cf..2153acd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,14 +30,18 @@ RUN flutter pub get RUN flutter build web --release --base-href="/" --verbose # ============================================ -# Stage 2: Serve with dhttpd (Dart HTTP server) +# Stage 2: Serve with custom Dart server (with API proxy) # ============================================ FROM dart:stable WORKDIR /app -# Install dhttpd globally -RUN dart pub global activate dhttpd +# Copy server code and pubspec +COPY bin/server.dart ./bin/ +COPY bin/pubspec.yaml ./ + +# Get dependencies +RUN dart pub get # Copy built web application from builder stage COPY --from=builder /app/build/web /app/web @@ -45,5 +49,5 @@ COPY --from=builder /app/build/web /app/web # Expose port (internal only, not mapped to host) EXPOSE 8089 -# Serve web application -CMD ["/root/.pub-cache/bin/dhttpd", "--host", "0.0.0.0", "--port", "8089", "--path", "/app/web"] +# Serve web application with API proxy +CMD ["dart", "run", "bin/server.dart", "--port", "8089", "--path", "/app/web"] diff --git a/bin/pubspec.yaml b/bin/pubspec.yaml new file mode 100644 index 0000000..19b1181 --- /dev/null +++ b/bin/pubspec.yaml @@ -0,0 +1,13 @@ +name: xtremflow_server +description: Backend server with API proxy +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + shelf: ^1.4.1 + shelf_static: ^1.1.2 + http: ^1.2.0 + args: ^2.4.2 diff --git a/bin/server.dart b/bin/server.dart new file mode 100644 index 0000000..2d8cf84 --- /dev/null +++ b/bin/server.dart @@ -0,0 +1,137 @@ +import 'dart:io'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:shelf_static/shelf_static.dart'; +import 'package:http/http.dart' as http; +import 'package:args/args.dart'; + +void main(List args) async { + // Parse command line arguments + final parser = ArgParser() + ..addOption('port', abbr: 'p', defaultsTo: '8089') + ..addOption('path', defaultsTo: '/app/web'); + + final result = parser.parse(args); + final port = int.parse(result['port']); + final webPath = result['path']; + + // Create handlers + final staticHandler = createStaticHandler( + webPath, + defaultDocument: 'index.html', + listDirectories: false, + ); + + // Main handler with API proxy + final handler = Cascade() + .add(_createApiProxyHandler()) + .add(staticHandler) + .handler; + + // Add middleware + final pipeline = Pipeline() + .addMiddleware(logRequests()) + .addMiddleware(_corsMiddleware()) + .addHandler(handler); + + // Start server + final server = await shelf_io.serve( + pipeline, + InternetAddress.anyIPv4, + port, + ); + + print('Server started on port ${server.port}'); + print('Serving static files from: $webPath'); + print('API proxy available at: /api/xtream/*'); +} + +/// CORS middleware to allow cross-origin requests +Middleware _corsMiddleware() { + return (Handler handler) { + return (Request request) async { + // Handle preflight requests + if (request.method == 'OPTIONS') { + return Response.ok('', headers: _corsHeaders); + } + + // Process request and add CORS headers to response + final response = await handler(request); + return response.change(headers: _corsHeaders); + }; + }; +} + +final _corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Origin, Content-Type, Accept, Authorization', +}; + +/// Create API proxy handler +Handler _createApiProxyHandler() { + return (Request request) async { + final path = request.url.path; + + // Only handle /api/xtream/* requests + if (!path.startsWith('api/xtream/')) { + return Response.notFound('Not found'); + } + + try { + // Extract target URL from request + // Format: /api/xtream/http://server:port/path + final apiPath = path.substring('api/xtream/'.length); + + // The client will send the full URL after /api/xtream/ + if (!apiPath.startsWith('http://') && !apiPath.startsWith('https://')) { + return Response.badRequest( + body: 'Invalid API URL. Expected format: /api/xtream/http://...', + ); + } + + final targetUrl = Uri.parse(apiPath + '?' + (request.url.query)); + + print('Proxying request to: $targetUrl'); + + // Forward the request + final client = http.Client(); + try { + final proxyRequest = http.Request(request.method, targetUrl); + + // Copy headers (excluding host) + request.headers.forEach((key, value) { + if (key.toLowerCase() != 'host') { + proxyRequest.headers[key] = value; + } + }); + + // Copy body if present + if (request.method != 'GET' && request.method != 'HEAD') { + proxyRequest.bodyBytes = await request.read().toList() + .then((chunks) => chunks.expand((chunk) => chunk).toList()); + } + + final response = await client.send(proxyRequest); + final responseBody = await response.stream.toBytes(); + + return Response( + response.statusCode, + body: responseBody, + headers: { + 'content-type': response.headers['content-type'] ?? 'application/json', + ...response.headers, + }, + ); + } finally { + client.close(); + } + } catch (e, stackTrace) { + print('Proxy error: $e'); + print(stackTrace); + return Response.internalServerError( + body: 'Proxy error: $e', + ); + } + }; +} diff --git a/lib/core/database/hive_service.dart b/lib/core/database/hive_service.dart index 98592cc..4d68518 100644 --- a/lib/core/database/hive_service.dart +++ b/lib/core/database/hive_service.dart @@ -19,23 +19,15 @@ class HiveService { // Initialize Hive for Web (uses IndexedDB) await Hive.initFlutter(); - // Generate or retrieve encryption key - final encryptionKey = await _getOrCreateEncryptionKey(); - // Register adapters Hive.registerAdapter(AppUserAdapter()); Hive.registerAdapter(PlaylistConfigAdapter()); - // Open encrypted boxes - await Hive.openBox( - _usersBoxName, - encryptionCipher: HiveAesCipher(encryptionKey), - ); - - await Hive.openBox( - _playlistsBoxName, - encryptionCipher: HiveAesCipher(encryptionKey), - ); + // Open boxes WITHOUT encryption on Web + // Note: On Web, IndexedDB is already isolated by origin, + // and session-based encryption keys cause decrypt errors on reload + await Hive.openBox(_usersBoxName); + await Hive.openBox(_playlistsBoxName); // Seed default admin if no users exist await _seedDefaultAdmin(); diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index a628af3..7119d82 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -4,6 +4,7 @@ import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_stor import '../../../core/models/playlist_config.dart'; import '../../../core/models/iptv_models.dart'; import '../models/xtream_models.dart' as xm; +import 'dart:html' as html; /// Xtream Codes API Service /// @@ -31,6 +32,21 @@ class XtreamService { _dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions)); } + /// Check if we're running on HTTPS (production) + bool get _isHttps { + return html.window.location.protocol == 'https:'; + } + + /// Wrap URL with proxy if needed (for HTTPS to HTTP bridge) + String _wrapWithProxy(String url) { + // If we're on HTTPS and the target URL is HTTP, use the proxy + if (_isHttps && url.startsWith('http://')) { + final baseUrl = html.window.location.origin; + return '$baseUrl/api/xtream/$url'; + } + return url; + } + /// Initialize connection with a playlist void setPlaylist(PlaylistConfig playlist) { _currentPlaylist = playlist; @@ -40,21 +56,24 @@ class XtreamService { String getLiveStreamUrl(String streamId) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - return '${_currentPlaylist!.dns}/live/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.m3u8'; + final url = '${_currentPlaylist!.dns}/live/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.m3u8'; + return _wrapWithProxy(url); } /// Generate stream URL for VOD (movies) String getVodStreamUrl(String streamId, String containerExtension) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - return '${_currentPlaylist!.dns}/movie/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; + final url = '${_currentPlaylist!.dns}/movie/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; + return _wrapWithProxy(url); } /// Generate stream URL for series episodes String getSeriesStreamUrl(String streamId, String containerExtension) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - return '${_currentPlaylist!.dns}/series/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; + final url = '${_currentPlaylist!.dns}/series/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; + return _wrapWithProxy(url); } /// Authenticate and get server info @@ -63,7 +82,7 @@ class XtreamService { try { final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _wrapWithProxy(_currentPlaylist!.apiBaseUrl), queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, @@ -85,7 +104,7 @@ class XtreamService { try { final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _wrapWithProxy(_currentPlaylist!.apiBaseUrl), queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, @@ -119,7 +138,7 @@ class XtreamService { try { final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _wrapWithProxy(_currentPlaylist!.apiBaseUrl), queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, @@ -153,7 +172,7 @@ class XtreamService { try { final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _wrapWithProxy(_currentPlaylist!.apiBaseUrl), queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, @@ -187,7 +206,7 @@ class XtreamService { try { final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _wrapWithProxy(_currentPlaylist!.apiBaseUrl), queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, @@ -218,7 +237,7 @@ class XtreamService { try { final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _wrapWithProxy(_currentPlaylist!.apiBaseUrl), queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, @@ -251,7 +270,7 @@ class XtreamService { try { final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _wrapWithProxy(_currentPlaylist!.apiBaseUrl), queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, diff --git a/pubspec.yaml b/pubspec.yaml index 54ba9ec..9dfe9e6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,13 +22,14 @@ dependencies: dio: ^5.4.0 dio_cache_interceptor: ^3.5.0 dio_cache_interceptor_hive_store: ^3.2.2 + http: ^1.2.0 # Routing go_router: ^13.0.0 # Video Player video_player: ^2.8.2 - video_player_web: ^2.3.0 # Required for web platform + video_player_web: ^2.3.0 chewie: ^1.7.5 media_kit: ^1.1.10 media_kit_video: ^1.2.4 @@ -44,6 +45,11 @@ dependencies: # Utilities intl: ^0.19.0 equatable: ^2.0.5 + + # Server dependencies (for backend proxy) + shelf: ^1.4.1 + shelf_static: ^1.1.2 + args: ^2.4.2 dev_dependencies: flutter_test: