mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
Merge pull request #15 from R0m1k3/antigravity
feat: implement Hive service for local data persistence, user managem…
This commit is contained in:
commit
83adf21f84
6 files changed
+200
-29
No files matched your search
+9
-5
@@ -30,14 +30,18 @@ RUN flutter pub get
|
|||||||
RUN flutter build web --release --base-href="/" --verbose
|
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
|
FROM dart:stable
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dhttpd globally
|
# Copy server code and pubspec
|
||||||
RUN dart pub global activate dhttpd
|
COPY bin/server.dart ./bin/
|
||||||
|
COPY bin/pubspec.yaml ./
|
||||||
|
|
||||||
|
# Get dependencies
|
||||||
|
RUN dart pub get
|
||||||
|
|
||||||
# Copy built web application from builder stage
|
# Copy built web application from builder stage
|
||||||
COPY --from=builder /app/build/web /app/web
|
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 port (internal only, not mapped to host)
|
||||||
EXPOSE 8089
|
EXPOSE 8089
|
||||||
|
|
||||||
# Serve web application
|
# Serve web application with API proxy
|
||||||
CMD ["/root/.pub-cache/bin/dhttpd", "--host", "0.0.0.0", "--port", "8089", "--path", "/app/web"]
|
CMD ["dart", "run", "bin/server.dart", "--port", "8089", "--path", "/app/web"]
|
||||||
@@ -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
|
||||||
+137
@@ -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<String> 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,23 +19,15 @@ class HiveService {
|
|||||||
// Initialize Hive for Web (uses IndexedDB)
|
// Initialize Hive for Web (uses IndexedDB)
|
||||||
await Hive.initFlutter();
|
await Hive.initFlutter();
|
||||||
|
|
||||||
// Generate or retrieve encryption key
|
|
||||||
final encryptionKey = await _getOrCreateEncryptionKey();
|
|
||||||
|
|
||||||
// Register adapters
|
// Register adapters
|
||||||
Hive.registerAdapter(AppUserAdapter());
|
Hive.registerAdapter(AppUserAdapter());
|
||||||
Hive.registerAdapter(PlaylistConfigAdapter());
|
Hive.registerAdapter(PlaylistConfigAdapter());
|
||||||
|
|
||||||
// Open encrypted boxes
|
// Open boxes WITHOUT encryption on Web
|
||||||
await Hive.openBox<AppUser>(
|
// Note: On Web, IndexedDB is already isolated by origin,
|
||||||
_usersBoxName,
|
// and session-based encryption keys cause decrypt errors on reload
|
||||||
encryptionCipher: HiveAesCipher(encryptionKey),
|
await Hive.openBox<AppUser>(_usersBoxName);
|
||||||
);
|
await Hive.openBox<PlaylistConfig>(_playlistsBoxName);
|
||||||
|
|
||||||
await Hive.openBox<PlaylistConfig>(
|
|
||||||
_playlistsBoxName,
|
|
||||||
encryptionCipher: HiveAesCipher(encryptionKey),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Seed default admin if no users exist
|
// Seed default admin if no users exist
|
||||||
await _seedDefaultAdmin();
|
await _seedDefaultAdmin();
|
||||||
|
|||||||
@@ -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/playlist_config.dart';
|
||||||
import '../../../core/models/iptv_models.dart';
|
import '../../../core/models/iptv_models.dart';
|
||||||
import '../models/xtream_models.dart' as xm;
|
import '../models/xtream_models.dart' as xm;
|
||||||
|
import 'dart:html' as html;
|
||||||
|
|
||||||
/// Xtream Codes API Service
|
/// Xtream Codes API Service
|
||||||
///
|
///
|
||||||
@@ -31,6 +32,21 @@ class XtreamService {
|
|||||||
_dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions));
|
_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
|
/// Initialize connection with a playlist
|
||||||
void setPlaylist(PlaylistConfig playlist) {
|
void setPlaylist(PlaylistConfig playlist) {
|
||||||
_currentPlaylist = playlist;
|
_currentPlaylist = playlist;
|
||||||
@@ -40,21 +56,24 @@ class XtreamService {
|
|||||||
String getLiveStreamUrl(String streamId) {
|
String getLiveStreamUrl(String streamId) {
|
||||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
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)
|
/// Generate stream URL for VOD (movies)
|
||||||
String getVodStreamUrl(String streamId, String containerExtension) {
|
String getVodStreamUrl(String streamId, String containerExtension) {
|
||||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
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
|
/// Generate stream URL for series episodes
|
||||||
String getSeriesStreamUrl(String streamId, String containerExtension) {
|
String getSeriesStreamUrl(String streamId, String containerExtension) {
|
||||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
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
|
/// Authenticate and get server info
|
||||||
@@ -63,7 +82,7 @@ class XtreamService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get(
|
final response = await _dio.get(
|
||||||
_currentPlaylist!.apiBaseUrl,
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
'username': _currentPlaylist!.username,
|
'username': _currentPlaylist!.username,
|
||||||
'password': _currentPlaylist!.password,
|
'password': _currentPlaylist!.password,
|
||||||
@@ -85,7 +104,7 @@ class XtreamService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get(
|
final response = await _dio.get(
|
||||||
_currentPlaylist!.apiBaseUrl,
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
'username': _currentPlaylist!.username,
|
'username': _currentPlaylist!.username,
|
||||||
'password': _currentPlaylist!.password,
|
'password': _currentPlaylist!.password,
|
||||||
@@ -119,7 +138,7 @@ class XtreamService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get(
|
final response = await _dio.get(
|
||||||
_currentPlaylist!.apiBaseUrl,
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
'username': _currentPlaylist!.username,
|
'username': _currentPlaylist!.username,
|
||||||
'password': _currentPlaylist!.password,
|
'password': _currentPlaylist!.password,
|
||||||
@@ -153,7 +172,7 @@ class XtreamService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get(
|
final response = await _dio.get(
|
||||||
_currentPlaylist!.apiBaseUrl,
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
'username': _currentPlaylist!.username,
|
'username': _currentPlaylist!.username,
|
||||||
'password': _currentPlaylist!.password,
|
'password': _currentPlaylist!.password,
|
||||||
@@ -187,7 +206,7 @@ class XtreamService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get(
|
final response = await _dio.get(
|
||||||
_currentPlaylist!.apiBaseUrl,
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
'username': _currentPlaylist!.username,
|
'username': _currentPlaylist!.username,
|
||||||
'password': _currentPlaylist!.password,
|
'password': _currentPlaylist!.password,
|
||||||
@@ -218,7 +237,7 @@ class XtreamService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get(
|
final response = await _dio.get(
|
||||||
_currentPlaylist!.apiBaseUrl,
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
'username': _currentPlaylist!.username,
|
'username': _currentPlaylist!.username,
|
||||||
'password': _currentPlaylist!.password,
|
'password': _currentPlaylist!.password,
|
||||||
@@ -251,7 +270,7 @@ class XtreamService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get(
|
final response = await _dio.get(
|
||||||
_currentPlaylist!.apiBaseUrl,
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
'username': _currentPlaylist!.username,
|
'username': _currentPlaylist!.username,
|
||||||
'password': _currentPlaylist!.password,
|
'password': _currentPlaylist!.password,
|
||||||
|
|||||||
+7
-1
@@ -22,13 +22,14 @@ dependencies:
|
|||||||
dio: ^5.4.0
|
dio: ^5.4.0
|
||||||
dio_cache_interceptor: ^3.5.0
|
dio_cache_interceptor: ^3.5.0
|
||||||
dio_cache_interceptor_hive_store: ^3.2.2
|
dio_cache_interceptor_hive_store: ^3.2.2
|
||||||
|
http: ^1.2.0
|
||||||
|
|
||||||
# Routing
|
# Routing
|
||||||
go_router: ^13.0.0
|
go_router: ^13.0.0
|
||||||
|
|
||||||
# Video Player
|
# Video Player
|
||||||
video_player: ^2.8.2
|
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
|
chewie: ^1.7.5
|
||||||
media_kit: ^1.1.10
|
media_kit: ^1.1.10
|
||||||
media_kit_video: ^1.2.4
|
media_kit_video: ^1.2.4
|
||||||
@@ -44,6 +45,11 @@ dependencies:
|
|||||||
# Utilities
|
# Utilities
|
||||||
intl: ^0.19.0
|
intl: ^0.19.0
|
||||||
equatable: ^2.0.5
|
equatable: ^2.0.5
|
||||||
|
|
||||||
|
# Server dependencies (for backend proxy)
|
||||||
|
shelf: ^1.4.1
|
||||||
|
shelf_static: ^1.1.2
|
||||||
|
args: ^2.4.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in new issue
Block a user