441 lines
14 KiB
Dart
441 lines
14 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.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.
|
|
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 respSha256 = data['sha256']?.toString();
|
|
if (expectedSha256 != null &&
|
|
expectedSha256.trim().isNotEmpty &&
|
|
respSha256 != null &&
|
|
respSha256.trim().isNotEmpty) {
|
|
if (expectedSha256.trim().toLowerCase() !=
|
|
respSha256.trim().toLowerCase()) {
|
|
return R2VerificationResponse.inconsistent(
|
|
'SHA-256 checksum mismatch: expected $expectedSha256 but got $respSha256.',
|
|
statusCode: 422,
|
|
);
|
|
}
|
|
}
|
|
final respSize = (data['size'] as num?)?.toInt();
|
|
if (expectedSize != null &&
|
|
expectedSize > 0 &&
|
|
respSize != null &&
|
|
respSize > 0) {
|
|
if (expectedSize != respSize) {
|
|
return R2VerificationResponse.inconsistent(
|
|
'Size mismatch: expected $expectedSize bytes but got $respSize bytes.',
|
|
statusCode: 422,
|
|
);
|
|
}
|
|
}
|
|
return R2VerificationResponse.confirmed(
|
|
R2VerificationMetadata(
|
|
verified: true,
|
|
remoteRideId: remoteRideId,
|
|
objectKey: objectKey,
|
|
size: respSize,
|
|
sha256: respSha256,
|
|
etag: data['etag']?.toString(),
|
|
),
|
|
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',
|
|
);
|
|
}
|
|
}
|
|
}
|