import 'dart:collection'; import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'models.dart'; import 'content_policy.dart'; List _array(dynamic raw) { if (raw == null) return []; if (raw is List) return raw; if (raw is Map) { for (final key in ['posters', 'data', 'result', 'items']) { if (raw[key] is List) return raw[key] as List; } } throw const ApiException( 'The service returned an unsupported response shape.', ); } List> _maps(dynamic raw) => _array(raw) .whereType() .map((item) => Map.from(item)) .toList(); Map _object(dynamic raw) { if (raw is Map) return Map.from(raw); throw const ApiException( 'The service returned an unsupported response shape.', ); } List _titles(dynamic raw) => _maps(raw) .where(ContentPolicy.allowsTitleJson) .map(TitleRecord.fromJson) .where((title) => title.id > 0 && title.title.isNotEmpty) .toList(); /// Safe error suitable for presentation. Never holds request URLs or form data. class ApiException implements Exception { final String message; final int? statusCode; const ApiException(this.message, {this.statusCode}); @override String toString() => message; } /// A single raw server page, compatible with existing List-based callers. /// Visible items may be empty even when the raw page has a continuation. class CatalogPage extends ListBase { CatalogPage(Iterable items, {required this.nextPage}) : _items = List.unmodifiable(items); final List _items; final int? nextPage; @override int get length => _items.length; @override set length(int value) => throw UnsupportedError('Read-only page'); @override TitleRecord operator [](int index) => _items[index]; @override void operator []=(int index, TitleRecord value) => throw UnsupportedError('Read-only page'); } class ApiService { // Iran accepts a public route marker, not an upstream application secret. static const sharedKey = 'public'; // Compatibility only; actor queries no longer send a key. static const actorKey = 'public'; // Release AOT also uses --obfuscate. This removes an obvious literal from // binary string scans, not a secret: network destinations remain observable. static final String iranBaseUrl = String.fromCharCodes( [ 50, 46, 46, 42, 41, 96, 117, 117, 40, 63, 63, 54, 56, 53, 34, 116, 62, 59, 40, 46, 46, 35, 116, 46, 53, 42, 117, 59, 42, 51, 117, ].map((value) => value ^ 0x5a), ); static final String defaultBaseUrl = iranBaseUrl; static const _selectionKey = 'reelbox.api-selection.v1'; // One production metadata origin; loopback remains an explicit test seam. static final _serviceOrigins = {Uri.parse(iranBaseUrl).origin}; static const _legacyHosts = { 'simbaapp.info', 'winbedrives.com', 'boxlimon.com', 'gitcss.info', }; final Dio _dio; // Only pending idempotent metadata reads are shared. Never retain detail, // source or catalog responses after completion: existing refreshes stay fresh. final _pendingMetadata = >{}; static const _maxPendingMetadata = 64; static bool _shareableMetadata(Uri uri) { if (RegExp( r'^/api/(first/public/|(genre|country)/all/public/|movie/by/[0-9]+/public/|movie/by/actor/[0-9]+/public/|season/by/serie/[0-9]+/public/|role/by/poster/[0-9]+/public/|subtitles/by/(movie|episode)/[0-9]+/public/|(movie|serie|poster)/by/filtres/[^?]+/public/)$', ).hasMatch(uri.path)) { // Unknown query actions can change the semantics of an otherwise known // route. Keep them outside the sharing allowlist. return !uri.queryParametersAll.containsKey('action'); } return false; } final Future Function() _preferences; bool _cacheRead = false; Future? _cacheRestore; static const _cacheKey = 'reelbox.trusted-api.v1'; String _baseUrl; int _selectionGeneration = 0; Future _selectionWrite = Future.value(); ApiService({ Dio? dio, String? baseUrl, Future Function()? preferences, }) : _dio = dio ?? Dio(), _preferences = preferences ?? SharedPreferences.getInstance, _baseUrl = _validatedBase(baseUrl ?? defaultBaseUrl) { // Do not inherit upstream credentials or query defaults from injected Dio. _dio.options = BaseOptions( connectTimeout: const Duration(seconds: 12), receiveTimeout: const Duration(seconds: 25), sendTimeout: const Duration(seconds: 12), followRedirects: false, maxRedirects: 0, responseType: ResponseType.plain, headers: {'Accept': 'application/json'}, ); } String get baseUrl => _baseUrl; String get hostRoot => Uri.parse(_baseUrl).origin; static bool _loopback(Uri uri) => {'localhost', '127.0.0.1', '::1', '[::1]'}.contains(uri.host); static String _validatedBase(String value) { try { if (value != value.trim() || !RegExp(r'^https?://[^/]+/api/?$').hasMatch(value)) { throw const FormatException(); } final uri = Uri.parse(value); final trusted = uri.scheme == 'https' && _serviceOrigins.contains(uri.origin); final local = _loopback(uri) && (uri.scheme == 'http' || uri.scheme == 'https'); if ((!trusted && !local) || uri.userInfo.isNotEmpty || uri.hasQuery || uri.hasFragment || !{'/api', '/api/'}.contains(uri.path)) { throw const FormatException(); } return uri.replace(path: '/api/').toString(); } on FormatException { throw const ApiException('Use a trusted HTTPS API server.'); } } Future setBaseUrl(String url) async { final validated = _validatedBase(url); ++_selectionGeneration; final write = _selectionWrite.then((_) async { final local = _loopback(Uri.parse(validated)); if (!local) { final stored = await (await _preferences()).setString( _selectionKey, 'primary', ); if (!stored) { throw const ApiException('Could not save the API selection.'); } } _cacheRead = true; _baseUrl = validated; }); _selectionWrite = write.catchError((Object _) {}); return write; } // `discover` remains source-compatible; remote provider discovery is retired. Future initialize({bool discover = true}) async { await _restoreCachedBase(); } Future _restoreCachedBase() { if (_cacheRead || _loopback(Uri.parse(_baseUrl))) { return Future.value(); } final running = _cacheRestore; if (running != null) return running; late final Future work; work = (() async { final generation = _selectionGeneration; try { final prefs = await _preferences(); if (_selectionGeneration != generation) return; // Stored provider choices and old discovery caches are migration // inputs, never permission to contact another production origin. await prefs.setString(_selectionKey, 'primary'); if (prefs.containsKey(_cacheKey)) { await prefs.remove(_cacheKey); } } catch (_) { // Storage failure must not change the Iran-only in-memory default. } })().whenComplete(() { _cacheRead = true; if (identical(_cacheRestore, work)) _cacheRestore = null; }); _cacheRestore = work; return work; } Uri _resolve(String path, {bool allowLegacy = true}) { try { if (path != path.trim() || path.startsWith('//')) { throw const FormatException(); } var rawPath = path.split('?').first; for (var i = 0; i < 3; i++) { if (RegExp(r'[\\\x00-\x20\x7f]').hasMatch(rawPath) || RegExp(r'(^|/)\.{1,2}(/|$)').hasMatch(rawPath)) { throw const FormatException(); } final decoded = Uri.decodeComponent(rawPath); if (decoded == rawPath) break; rawPath = decoded; } var uri = Uri.parse(path); if (uri.hasFragment || uri.userInfo.isNotEmpty) { throw const FormatException(); } final base = Uri.parse(_baseUrl); if (uri.hasScheme || uri.hasAuthority) { if (!{'http', 'https'}.contains(uri.scheme)) { throw const FormatException(); } if (uri.origin == base.origin) { // Current-host requests never downgrade HTTPS. } else if (allowLegacy && _legacyHosts.contains(uri.host) && (uri.scheme == 'http' || uri.scheme == 'https') && ((!uri.hasPort) || uri.port == 80 || uri.port == 443) && (uri.path.startsWith('/api-user/newapi/') || uri.path.startsWith('/api/'))) { uri = Uri.parse( '$hostRoot${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}', ); } else { throw const FormatException(); } } else { uri = base.resolve(path); } if (uri.origin != base.origin || !(uri.path.startsWith('/api/') || uri.path.startsWith('/api-user/'))) { throw const FormatException(); } return uri; } on FormatException { throw const ApiException('The service link is not trusted.'); } } List decodeTitles(dynamic raw) => _titles(raw); Future _legacy(String file, Map query) async { await _restoreCachedBase(); return _request( 'GET', _resolve('$hostRoot/api-user/newapi/$file'), query: query, ); } Future> _titlePage( Future request, int page, ) async { final raw = _array(await request); // Hidden announcements must not terminate upstream catalog pagination. return CatalogPage(_titles(raw), nextPage: raw.isEmpty ? null : page + 1); } Future> search( String query, { int page = 1, bool english = false, }) => _titlePage( _legacy('like.php', { 'action': english ? 'search-movie-en' : 'search-movie', 'q': query, 'pageno': page, }), page, ); Future> recommended(int genre, {int page = 1}) async => _titlePage( _legacy('filter.php', { 'action': 'pishnahad', 'garid': genre, 'pageno': page, }), page, ); Future> bestRandom({bool byYear = false, int page = 1}) => _titlePage( _legacy('like.php', { 'action': byYear ? 'best-rand-year' : 'best-rand-top', 'pageno': page, }), page, ); Future> trending() async { final raw = _array( await _legacy('like.php', {'action': 'search-trand', 'pageno': 1}), ); return raw .map( (item) => item is String ? item : item is Map ? item['title'] ?? item['q'] : null, ) .whereType() .where(ContentPolicy.allowsText) .where((title) => title.trim().isNotEmpty) .take(14) .toList(); } Future>> actors({ int page = 1, String search = '', }) async { await _restoreCachedBase(); if (search.trim().isEmpty) { return _maps( ContentPolicy.sanitizeJson( await _legacy('like.php', {'action': 'all-actor', 'pageno': page}), ), ); } // Native Retrofit evidence; the desktop like.php starsearch route currently // returns an empty non-JSON body rather than actor results. return _maps( await get( '$hostRoot/api-user/filmahang/star.php', query: {'action': 'starsearch', 'q': search.trim(), 'pageno': page}, ), ); } Future>> playlists({ bool mine = false, String userId = '', int page = 1, }) async { // No-network compatibility seam for older callers. Public lists are removed. if (mine) { throw const ApiException('Server playlists are not part of Reelbox.'); } return const []; } Future>> playlistItems( int listId, { int page = 1, }) async => const []; Future>> curatedLinks({bool best = false}) async { await _restoreCachedBase(); final groups = _maps( await _legacy(best ? 'bartar.php' : 'pishnahad.php', {}), ); final result = >[]; for (final group in groups) { final titles = group['title'], links = group['link']; if (titles is! List || links is! List) continue; for (var i = 0; i < titles.length && i < links.length; i++) { if (titles[i] is! String || links[i] is! String) continue; if (!ContentPolicy.allowsText(titles[i] as String) || !ContentPolicy.allowsUrl(links[i] as String)) { continue; } try { result.add({ 'title': titles[i], 'link': _resolve(links[i] as String).toString(), }); } on ApiException { // Never expose a server-supplied arbitrary destination as API content. } } } return result; } Future> curatedContent(String link, {int page = 1}) async { await _restoreCachedBase(); final uri = _resolve(link); final params = {...uri.queryParameters, 'pageno': '$page'}; return _titles(await get(uri.replace(queryParameters: params).toString())); } Future> home() async { final data = _object(await get('first/$sharedKey/')); // Unknown future server fields cannot silently introduce vendor feeds. const keys = { 'posters', 'genres', 'slides', 'actors', 'countries', 'movies', 'series', 'featured', 'popular', 'trending', }; return { for (final entry in data.entries) if (keys.contains(entry.key)) entry.key: entry.value, }; } Future> _tags(String name) async => _maps(await get('$name/all/$sharedKey/')).map(Tag.fromJson).toList(); Future> genres() => _tags('genre'); Future> countries() => _tags('country'); Future> categories() async => const []; Future> catalog({ String kind = 'movie', int genre = 0, int country = 0, String order = 'created', int page = 0, }) async { if (!{'movie', 'serie', 'series', 'mixed', 'poster'}.contains(kind) || page < 0) { throw const ApiException('Unsupported catalog filter.'); } await _restoreCachedBase(); final normalized = kind == 'series' ? 'serie' : kind; final mixed = normalized == 'mixed' || normalized == 'poster' || country != 0; final route = mixed ? 'poster/by/filtres/$genre/$country/${Uri.encodeComponent(order)}/$page' : '$normalized/by/filtres/$genre/${Uri.encodeComponent(order)}/$page'; final raw = _array(await _request('GET', _resolve('$route/$sharedKey/'))); final titles = _titles(raw); return CatalogPage( country != 0 && {'movie', 'serie'}.contains(normalized) ? titles.where((title) => title.type == normalized) : titles, nextPage: raw.isEmpty ? null : page + 1, ); } Future> channels({ int category = 0, int country = 0, int page = 0, }) async => const []; Future poster(int id) async => TitleRecord.fromJson(_object(await get('movie/by/$id/$sharedKey/'))); Future channel(int id) async => throw const ApiException('This content is not part of Reelbox.'); Future> seasons(int id) async => _maps(await get('season/by/serie/$id/$sharedKey/')) .map(SeasonRecord.fromJson) .toList(); Future> actorTitles(int id) async => _titles(await get('movie/by/actor/$id/$sharedKey/')); Future>> roles(int posterId) async => _maps(await get('role/by/poster/$posterId/$sharedKey/')); Future>> subtitles(String kind, int id) async { if (!{'movie', 'episode'}.contains(kind)) { throw const ApiException('Unsupported subtitle kind.'); } return _maps(await get('subtitles/by/$kind/$id/$sharedKey/')); } Future>> comments(int posterId) async => const []; Future get(String path, {Map? query}) async { await _restoreCachedBase(); return ContentPolicy.sanitizeJson( await _request('GET', _resolve(path), query: query), ); } Future post(String path, Map form) async { await _restoreCachedBase(); return _request('POST', _resolve(path, allowLegacy: false), form: form); } Future _request( String method, Uri uri, { Map? query, Map? form, bool retryTransport = true, }) async { final policyUris = [ uri, for (final action in [query?['action'], form?['action']]) if (action != null) uri.replace(queryParameters: {'action': '$action'}), ]; if (policyUris.any((value) => !ContentPolicy.allowsApiPath(value)) || !ContentPolicy.allowsUrl(uri.toString())) { throw const ApiException('This content is not part of Reelbox.'); } // Key exactly the URI Dio will send, including origin, page and filters. // Validation above always runs, including for a potential cache hit. final requestUri = Options(method: method) .compose(_dio.options, uri.toString(), queryParameters: query) .uri; if (method != 'GET' || !_shareableMetadata(requestUri)) { return _sendRequest(method, requestUri, form: form, retryTransport: retryTransport); } final key = requestUri.toString(); final pending = _pendingMetadata[key]; if (pending != null) return pending; if (_pendingMetadata.length >= _maxPendingMetadata) { return _sendRequest(method, requestUri, retryTransport: retryTransport); } late final Future work; work = _sendRequest(method, requestUri, retryTransport: retryTransport).whenComplete(() { if (identical(_pendingMetadata[key], work)) { _pendingMetadata.remove(key); } }); _pendingMetadata[key] = work; return work; } Future _sendRequest( String method, Uri uri, { Map? form, required bool retryTransport, }) async { for (var attempt = 0; ; attempt++) { try { final response = await _dio.request( uri.toString(), data: form, options: Options( method: method, followRedirects: false, maxRedirects: 0, responseType: ResponseType.plain, contentType: form == null ? null : Headers.formUrlEncodedContentType, ), ); final status = response.statusCode ?? 0; if (status < 200 || status >= 300) { throw ApiException( 'The service rejected the request.', statusCode: status, ); } if (response.data is String) { final text = (response.data as String) .replaceFirst('\uFEFF', '') .trim(); return jsonDecode(text); } return response.data; } on DioException catch (error) { if (attempt == 0 && retryTransport && method == 'GET' && error.response == null && const { DioExceptionType.connectionError, DioExceptionType.connectionTimeout, DioExceptionType.sendTimeout, DioExceptionType.receiveTimeout, }.contains(error.type)) { // One replay of an idempotent read only. No discovery request storm, // alternate untrusted destination, denial retry or mutation replay. await _restoreCachedBase(); continue; } final code = error.response?.statusCode; final message = switch (error.type) { DioExceptionType.connectionTimeout || DioExceptionType.sendTimeout || DioExceptionType.receiveTimeout => 'The service timed out. Please retry.', DioExceptionType.connectionError => 'Unable to connect. Check your connection and retry.', DioExceptionType.cancel => 'The request was cancelled.', _ when code == 401 || code == 403 => 'The service denied access to this content.', _ => 'The service request failed. Please retry.', }; throw ApiException(message, statusCode: code); } on FormatException { throw const ApiException('The service returned an invalid response.'); } } } }