import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'sync_provider.dart'; @immutable class R2VerificationMetadata { final bool verified; final String remoteRideId; final String? objectKey; final int? size; final String? sha256; final String? etag; const R2VerificationMetadata({ required this.verified, required this.remoteRideId, this.objectKey, this.size, this.sha256, this.etag, }); } @immutable class R2VerificationResponse { final CloudConfirmationStatus status; final String? errorMessage; final int? statusCode; final R2VerificationMetadata? metadata; const R2VerificationResponse({ required this.status, this.errorMessage, this.statusCode, this.metadata, }); const R2VerificationResponse.confirmed( R2VerificationMetadata metadata, { int statusCode = 200, }) : this( status: CloudConfirmationStatus.confirmed, statusCode: statusCode, metadata: metadata, ); const R2VerificationResponse.legacyUnverified( R2VerificationMetadata metadata, { int statusCode = 200, String? message, }) : this( status: CloudConfirmationStatus.legacyUnverified, statusCode: statusCode, metadata: metadata, errorMessage: message, ); const R2VerificationResponse.missing(String message, {int statusCode = 404}) : this( status: CloudConfirmationStatus.missing, errorMessage: message, statusCode: statusCode, ); const R2VerificationResponse.inconsistent( String message, { int statusCode = 409, }) : this( status: CloudConfirmationStatus.inconsistent, errorMessage: message, statusCode: statusCode, ); const R2VerificationResponse.unauthorized( String message, { int statusCode = 401, }) : this( status: CloudConfirmationStatus.unauthorized, errorMessage: message, statusCode: statusCode, ); const R2VerificationResponse.temporarilyUnverifiable( String message, { int? statusCode, }) : this( status: CloudConfirmationStatus.temporarilyUnverifiable, errorMessage: message, statusCode: statusCode, ); } @immutable class R2UploadResult { final bool success; final String? objectKey; final int? bytesWritten; final String? errorMessage; final int? statusCode; final bool isRetryable; final bool requiresAuth; const R2UploadResult({ required this.success, this.objectKey, this.bytesWritten, this.errorMessage, this.statusCode, this.isRetryable = true, this.requiresAuth = false, }); const R2UploadResult.success({required String objectKey, int? bytesWritten}) : this( success: true, objectKey: objectKey, bytesWritten: bytesWritten, statusCode: 200, isRetryable: false, ); const R2UploadResult.authRequired(String message, {int statusCode = 401}) : this( success: false, errorMessage: message, statusCode: statusCode, isRetryable: true, requiresAuth: true, ); const R2UploadResult.temporaryFailure(String message, {int? statusCode}) : this( success: false, errorMessage: message, statusCode: statusCode, isRetryable: true, ); const R2UploadResult.permanentFailure(String message, {int? statusCode}) : this( success: false, errorMessage: message, statusCode: statusCode, isRetryable: false, ); } /// Uploads immutable ride telemetry bundles through the authenticated Worker gateway. class R2UploadService { static const String defaultWorkerUrl = String.fromEnvironment( 'R2_WORKER_URL', defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev', ); final http.Client _client; final String _workerBaseUrl; R2UploadService({http.Client? client, String? workerBaseUrl}) : _client = client ?? http.Client(), _workerBaseUrl = (workerBaseUrl ?? defaultWorkerUrl).replaceAll( RegExp(r'/+$'), '', ); String get workerBaseUrl => _workerBaseUrl; Future uploadTelemetryBundle({ required String idToken, required String remoteRideId, required Uint8List zipBytes, required String sha256Hash, required String appVersion, Duration timeout = const Duration(seconds: 45), }) async { if (idToken.trim().isEmpty) { return const R2UploadResult.authRequired( 'Firebase ID token is required for R2 upload.', ); } if (zipBytes.isEmpty) { return const R2UploadResult.permanentFailure( 'Telemetry bundle is empty.', statusCode: 400, ); } // Prevent path traversal at the client boundary. The Worker remains the // authoritative verifier of SHA-256 and Firebase UID ownership. if (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(remoteRideId)) { return const R2UploadResult.permanentFailure( 'Invalid remote ride identifier.', statusCode: 400, ); } if (sha256Hash.trim().isEmpty) { return const R2UploadResult.permanentFailure( 'Telemetry bundle SHA-256 is required.', statusCode: 400, ); } try { final response = await _client .put( Uri.parse('$_workerBaseUrl/r2/upload'), headers: { 'Authorization': 'Bearer ${idToken.trim()}', 'Content-Type': 'application/zip', 'X-Remote-Ride-Id': remoteRideId, 'X-Bundle-Sha256': sha256Hash.trim().toLowerCase(), 'X-App-Version': appVersion, }, body: zipBytes, ) .timeout(timeout); final status = response.statusCode; if (status >= 200 && status < 300) { try { final decoded = jsonDecode(response.body); if (decoded is! Map) { throw const FormatException('Invalid worker response'); } final data = Map.from(decoded); final objectKey = data['objectKey']; if (objectKey is! String || !objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') || !objectKey.startsWith('users/')) { return const R2UploadResult.permanentFailure( 'R2 worker returned an invalid object key.', ); } final size = data['size']; final bytes = size is num && size >= 0 ? size.toInt() : zipBytes.length; return R2UploadResult.success( objectKey: objectKey, bytesWritten: bytes, ); } catch (e) { return R2UploadResult.temporaryFailure( 'R2 upload response could not be verified: $e', statusCode: status, ); } } if (status == 401 || status == 403) { return R2UploadResult.authRequired( 'R2 Worker authorization denied ($status).', statusCode: status, ); } if (status == 400 || status == 404 || status == 409 || status == 422) { return R2UploadResult.permanentFailure( 'R2 Worker rejected the bundle ($status).', statusCode: status, ); } return R2UploadResult.temporaryFailure( 'R2 Worker temporary error ($status).', statusCode: status, ); } on TimeoutException { return R2UploadResult.temporaryFailure( 'Upload to Cloudflare R2 timed out after ${timeout.inSeconds} seconds.', ); } catch (e) { return R2UploadResult.temporaryFailure( 'Network error uploading to Cloudflare R2: $e', ); } } /// Lightweight verification of telemetry bundle storage and integrity in Cloudflare R2. /// Queries the worker via /r2/verify to verify existence, metadata, and hash match /// without downloading the full ZIP archive. /// /// CRITICAL ARCHITECTURAL DISTINCTION: /// R2 HEAD / metadata checks prove that an object exists in Cloudflare R2 and that its stored /// metadata (SHA-256 hash, byte size, object key, remote ride identity) matches the expected /// Firestore and local contract. /// They do NOT independently prove that the stored ZIP archive's binary payload is readable, /// decompresses cleanly, or is uncorrupted at rest. Full bundle downloads on startup are /// intentionally avoided to protect mobile bandwidth and startup latency. For future stronger /// verification, background sampling or periodic full restore-integrity checks should be /// scheduled rather than downloading every bundle on every launch or reconciliation pass. Future verifyTelemetryBundle({ required String idToken, required String remoteRideId, String? expectedSha256, int? expectedSize, Duration timeout = const Duration(seconds: 15), }) async { if (idToken.trim().isEmpty) { return const R2VerificationResponse.unauthorized( 'Firebase ID token is required for verification.', ); } if (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(remoteRideId)) { return const R2VerificationResponse.inconsistent( 'Invalid remote ride identifier format.', ); } try { final uri = Uri.parse('$_workerBaseUrl/r2/verify?rideId=$remoteRideId'); final response = await _client .get( uri, headers: { 'Authorization': 'Bearer ${idToken.trim()}', 'Accept': 'application/json', }, ) .timeout(timeout); final status = response.statusCode; if (status >= 200 && status < 300) { try { final decoded = jsonDecode(response.body); if (decoded is! Map) { return const R2VerificationResponse.inconsistent( 'Worker verification returned invalid response structure.', ); } final data = Map.from(decoded); final respRideId = data['remoteRideId']?.toString(); if (respRideId == null || respRideId != remoteRideId) { return R2VerificationResponse.inconsistent( 'Worker verification rideId ($respRideId) does not match expected ($remoteRideId).', statusCode: status, ); } final objectKey = data['objectKey']?.toString(); if (objectKey == null || !objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') || !objectKey.startsWith('users/')) { return R2VerificationResponse.inconsistent( 'Worker verification objectKey ($objectKey) does not match expected pattern.', statusCode: status, ); } final respSize = (data['size'] as num?)?.toInt(); if (respSize == null || respSize <= 0) { return R2VerificationResponse.inconsistent( 'Worker verification returned invalid or non-positive object size ($respSize).', statusCode: status, ); } if (expectedSize != null && expectedSize > 0 && expectedSize != respSize) { return R2VerificationResponse.inconsistent( 'Size mismatch: expected $expectedSize bytes but got $respSize bytes.', statusCode: 422, ); } final respSha256 = data['sha256']?.toString(); if (expectedSha256 != null && expectedSha256.trim().isNotEmpty) { if (respSha256 == null || respSha256.trim().isEmpty) { return R2VerificationResponse.inconsistent( 'Missing required SHA-256 in R2 worker response for ride with expected hash $expectedSha256.', statusCode: status, ); } if (expectedSha256.trim().toLowerCase() != respSha256.trim().toLowerCase()) { return R2VerificationResponse.inconsistent( 'SHA-256 checksum mismatch: expected $expectedSha256 but got $respSha256.', statusCode: 422, ); } } final metadata = R2VerificationMetadata( verified: true, remoteRideId: remoteRideId, objectKey: objectKey, size: respSize, sha256: respSha256, etag: data['etag']?.toString(), ); // If bundle lacks modern checksum metadata, report as legacyUnverified rather than confirmed. if (expectedSha256 == null || expectedSha256.trim().isEmpty || respSha256 == null || respSha256.trim().isEmpty) { return R2VerificationResponse.legacyUnverified( metadata, statusCode: status, message: 'Historical bundle verified in R2 without full checksum metadata.', ); } return R2VerificationResponse.confirmed(metadata, statusCode: status); } catch (e) { return R2VerificationResponse.temporarilyUnverifiable( 'Failed to parse worker verification response: $e', statusCode: status, ); } } if (status == 401 || status == 403) { return R2VerificationResponse.unauthorized( 'R2 verification unauthorized ($status): ${response.body}', statusCode: status, ); } if (status == 404) { // Distinguish between: // 1. Live worker without /r2/verify endpoint ("Endpoint not found") -> temporarilyUnverifiable // 2. Non-JSON 404 (e.g. Cloudflare proxy / edge HTML 404) -> temporarilyUnverifiable // 3. Telemetry bundle or ride not found from API -> missing try { final decoded = jsonDecode(response.body); if (decoded is Map) { final error = decoded['error']?.toString(); final code = decoded['code']?.toString(); if (error == 'Endpoint not found') { return const R2VerificationResponse.temporarilyUnverifiable( 'R2 verification endpoint not yet deployed on worker.', statusCode: 404, ); } if (code == 'OBJECT_NOT_FOUND' || code == 'NOT_FOUND' || error == 'Telemetry bundle not found in R2' || error == 'Ride not found') { return R2VerificationResponse.missing( error ?? 'Telemetry bundle or ride not found in cloud.', statusCode: 404, ); } } } catch (_) { return const R2VerificationResponse.temporarilyUnverifiable( 'Non-JSON 404 received from R2 gateway; worker endpoint may be undeployed or edge routing failed.', statusCode: 404, ); } return R2VerificationResponse.missing( 'Telemetry bundle or ride not found in cloud ($status).', statusCode: status, ); } if (status == 409 || status == 422) { return R2VerificationResponse.inconsistent( 'Cloud metadata or object integrity rejected ($status): ${response.body}', statusCode: status, ); } if (status == 429) { return R2VerificationResponse.temporarilyUnverifiable( 'R2 verification rate limited (429).', statusCode: status, ); } if (status >= 500) { return R2VerificationResponse.temporarilyUnverifiable( 'R2 worker server error ($status).', statusCode: status, ); } return R2VerificationResponse.temporarilyUnverifiable( 'Unexpected status ($status) from R2 worker.', statusCode: status, ); } on TimeoutException { return R2VerificationResponse.temporarilyUnverifiable( 'R2 verification timed out after ${timeout.inSeconds}s.', ); } catch (e) { return R2VerificationResponse.temporarilyUnverifiable( 'Network error verifying with R2 worker: $e', ); } } }