import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import 'manifest.dart'; /// Pulls the remote season manifest and downloads new/updated packs into /// [cacheDir] with SHA256 verification and atomic (temp + rename) writes. /// All failures are swallowed into a `false` return — remote content is an /// enhancement, never a crash source. class ContentDownloader { ContentDownloader({ required this.baseUrl, required this.cacheDir, http.Client? client, }) : _client = client ?? http.Client() { _loadIndex(); } final String baseUrl; final Directory cacheDir; final http.Client _client; final Map _index = {}; File get _indexFile => File('${cacheDir.path}/cache_index.json'); void _loadIndex() { try { if (_indexFile.existsSync()) { final json = jsonDecode(_indexFile.readAsStringSync()) as Map; json.forEach((k, v) => _index[k] = v as int); } } catch (_) { // Corrupt index: treat as empty; packs will re-download. _index.clear(); } } Future _saveIndex() async { await cacheDir.create(recursive: true); await _indexFile.writeAsString(jsonEncode(_index)); } int? cachedVersion(String seasonId) => _index[seasonId]; /// Checks the manifest and downloads anything new. Returns true when at /// least one pack was added or updated. Future sync() async { final RemoteManifest manifest; try { final res = await _client .get(Uri.parse('$baseUrl/manifest.json')) .timeout(const Duration(seconds: 10)); if (res.statusCode != 200) return false; manifest = RemoteManifest.fromJson( jsonDecode(res.body) as Map); } catch (_) { return false; } var changed = false; for (final season in manifest.seasons) { if (_index[season.seasonId] == season.version) continue; if (await _downloadPack(season)) changed = true; } return changed; } Future _downloadPack(ManifestSeason season) async { // seasonId becomes a filesystem path segment; only accept safe slugs so a // hostile manifest can never write outside the cache dir. if (!RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(season.seasonId)) return false; if (season.packUrl.contains('..') || season.packUrl.startsWith('/') || season.packUrl.contains('://')) { return false; } try { final res = await _client .get(Uri.parse('$baseUrl/${season.packUrl}')) .timeout(const Duration(seconds: 30)); if (res.statusCode != 200) return false; // Packs are tens of KB; anything huge is a server error or an attack. if (res.bodyBytes.length > 5 * 1024 * 1024) return false; // Verify in memory before any file is touched, so a bad pack can // never leave artifacts on disk. final digest = sha256.convert(res.bodyBytes).toString(); if (digest != season.sha256) return false; final dir = Directory('${cacheDir.path}/seasons/${season.seasonId}'); await dir.create(recursive: true); final tmp = File('${dir.path}/pack.json.tmp'); await tmp.writeAsBytes(res.bodyBytes, flush: true); await tmp.rename('${dir.path}/pack.json'); _index[season.seasonId] = season.version; await _saveIndex(); return true; } catch (_) { return false; } } }