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

482 lines
16 KiB
Dart

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<R2UploadResult> 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<String, dynamic>.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<R2VerificationResponse> 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<String, dynamic>.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',
);
}
}
}