feat: content downloader with sha256 verify and atomic cache
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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<String, int> _index = {};
|
||||
|
||||
File get _indexFile => File('${cacheDir.path}/cache_index.json');
|
||||
|
||||
void _loadIndex() {
|
||||
try {
|
||||
if (_indexFile.existsSync()) {
|
||||
final json =
|
||||
jsonDecode(_indexFile.readAsStringSync()) as Map<String, dynamic>;
|
||||
json.forEach((k, v) => _index[k] = v as int);
|
||||
}
|
||||
} catch (_) {
|
||||
// Corrupt index: treat as empty; packs will re-download.
|
||||
_index.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<bool> 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<String, dynamic>);
|
||||
} 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<bool> _downloadPack(ManifestSeason season) async {
|
||||
try {
|
||||
final res = await _client
|
||||
.get(Uri.parse('$baseUrl/${season.packUrl}'))
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (res.statusCode != 200) 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user