MotorSignal-App/lib/services/sync/r2_download_service.dart

187 lines
6.1 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
@immutable
class R2DownloadResult {
final bool success;
final String? cloudRideId;
final Uint8List? zipBytes;
final String? sha256Hash;
final String? etag;
final String? errorMessage;
final int? statusCode;
final bool isRetryable;
final bool requiresAuth;
const R2DownloadResult({
required this.success,
this.cloudRideId,
this.zipBytes,
this.sha256Hash,
this.etag,
this.errorMessage,
this.statusCode,
this.isRetryable = true,
this.requiresAuth = false,
});
const R2DownloadResult.success({
required String cloudRideId,
required Uint8List zipBytes,
required String sha256Hash,
String? etag,
}) : this(
success: true,
cloudRideId: cloudRideId,
zipBytes: zipBytes,
sha256Hash: sha256Hash,
etag: etag,
statusCode: 200,
isRetryable: false,
);
const R2DownloadResult.authRequired(String message, {int statusCode = 401})
: this(
success: false,
errorMessage: message,
statusCode: statusCode,
isRetryable: true,
requiresAuth: true,
);
const R2DownloadResult.temporaryFailure(String message, {int? statusCode})
: this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: true);
const R2DownloadResult.permanentFailure(String message, {int? statusCode})
: this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: false);
}
/// Downloads a verified telemetry bundle through the authenticated Worker gateway.
/// The Worker authorizes the ride, and this service verifies the returned SHA-256
/// before exposing the bytes to restore code.
class R2DownloadService {
static const String defaultWorkerUrl = String.fromEnvironment(
'R2_WORKER_URL',
defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev',
);
final http.Client _client;
final String _workerBaseUrl;
R2DownloadService({http.Client? client, String? workerBaseUrl})
: _client = client ?? http.Client(),
_workerBaseUrl = (workerBaseUrl ?? defaultWorkerUrl).replaceAll(RegExp(r'/+$'), '');
String get workerBaseUrl => _workerBaseUrl;
Future<R2DownloadResult> downloadTelemetryBundle({
required String idToken,
required String cloudRideId,
Duration timeout = const Duration(seconds: 45),
}) async {
if (idToken.trim().isEmpty) {
return const R2DownloadResult.authRequired('Firebase ID token is required for R2 download.');
}
if (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(cloudRideId)) {
return const R2DownloadResult.permanentFailure('Invalid cloud ride identifier.', statusCode: 400);
}
try {
final uri = Uri.parse('$_workerBaseUrl/r2/download').replace(
queryParameters: {'rideId': cloudRideId},
);
final response = await _client.get(
uri,
headers: {
'Authorization': 'Bearer ${idToken.trim()}',
'Accept': 'application/zip',
},
).timeout(timeout);
final status = response.statusCode;
if (status == 401 || status == 403) {
return R2DownloadResult.authRequired(
'R2 Worker authorization denied ($status).',
statusCode: status,
);
}
if (status == 404) {
return R2DownloadResult.permanentFailure(
'Telemetry bundle is not available in MotorCloud.',
statusCode: status,
);
}
if (status == 409 || status == 422) {
return R2DownloadResult.permanentFailure(
'MotorCloud rejected the telemetry bundle integrity or ownership metadata.',
statusCode: status,
);
}
if (status < 200 || status >= 300) {
return R2DownloadResult.temporaryFailure(
'R2 Worker temporary error ($status).',
statusCode: status,
);
}
final returnedRideId = response.headers['x-motorsignal-ride-id'];
if (returnedRideId != cloudRideId) {
return const R2DownloadResult.permanentFailure(
'R2 Worker returned an unexpected cloud ride identifier.',
statusCode: 422,
);
}
final expectedSha256 = response.headers['x-motorsignal-sha256']?.trim().toLowerCase();
if (expectedSha256 == null || !RegExp(r'^[a-f0-9]{64}$').hasMatch(expectedSha256)) {
return const R2DownloadResult.permanentFailure(
'R2 Worker did not provide a valid telemetry SHA-256.',
statusCode: 422,
);
}
final bytes = Uint8List.fromList(response.bodyBytes);
if (bytes.isEmpty) {
return const R2DownloadResult.permanentFailure('Downloaded telemetry bundle is empty.', statusCode: 422);
}
final actualSha256 = sha256.convert(bytes).toString().toLowerCase();
if (actualSha256 != expectedSha256) {
return const R2DownloadResult.permanentFailure(
'Telemetry bundle SHA-256 verification failed.',
statusCode: 422,
);
}
return R2DownloadResult.success(
cloudRideId: cloudRideId,
zipBytes: bytes,
sha256Hash: actualSha256,
etag: response.headers['etag'],
);
} on TimeoutException {
return R2DownloadResult.temporaryFailure(
'Download from Cloudflare R2 timed out after ${timeout.inSeconds} seconds.',
);
} on FormatException catch (e) {
return R2DownloadResult.permanentFailure('Invalid R2 download response: $e', statusCode: 422);
} catch (e) {
return R2DownloadResult.temporaryFailure('Network error downloading from Cloudflare R2: $e');
}
}
/// Useful when the Worker returns a JSON error body for diagnostics.
static String errorBody(http.Response response) {
try {
final decoded = jsonDecode(response.body);
if (decoded is Map && decoded['error'] is String) return decoded['error'] as String;
} catch (_) {}
return 'R2 Worker request failed (${response.statusCode}).';
}
}