163 lines
4.8 KiB
Dart
163 lines
4.8 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
/// Result returned from an R2 bundle upload via Cloudflare Worker gateway
|
|
@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,
|
|
requiresAuth: 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,
|
|
requiresAuth: false,
|
|
);
|
|
|
|
const R2UploadResult.permanentFailure(
|
|
String message, {
|
|
int? statusCode,
|
|
}) : this(
|
|
success: false,
|
|
errorMessage: message,
|
|
statusCode: statusCode,
|
|
isRetryable: false,
|
|
requiresAuth: false,
|
|
);
|
|
}
|
|
|
|
/// Service that streams telemetry ZIP bundles directly to Cloudflare R2
|
|
/// through the authenticated Cloudflare 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;
|
|
|
|
/// Uploads a compressed telemetry ZIP bundle to Cloudflare R2 via the worker gateway
|
|
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 {
|
|
final endpoint = Uri.parse('$_workerBaseUrl/r2/upload');
|
|
|
|
try {
|
|
final response = await _client
|
|
.put(
|
|
endpoint,
|
|
headers: {
|
|
'Authorization': 'Bearer $idToken',
|
|
'Content-Type': 'application/zip',
|
|
'X-Remote-Ride-Id': remoteRideId,
|
|
'X-Bundle-Sha256': sha256Hash,
|
|
'X-App-Version': appVersion,
|
|
},
|
|
body: zipBytes,
|
|
)
|
|
.timeout(timeout);
|
|
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
try {
|
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
|
final objectKey = data['objectKey'] as String? ??
|
|
'users/unknown/rides/$remoteRideId/telemetry.zip';
|
|
final size = data['size'] as int? ?? zipBytes.length;
|
|
return R2UploadResult.success(objectKey: objectKey, bytesWritten: size);
|
|
} catch (_) {
|
|
return R2UploadResult.success(
|
|
objectKey: 'users/unknown/rides/$remoteRideId/telemetry.zip',
|
|
bytesWritten: zipBytes.length,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (response.statusCode == 401 || response.statusCode == 403) {
|
|
return R2UploadResult.authRequired(
|
|
'Cloudflare Worker authorization denied (${response.statusCode}): ${response.body}',
|
|
statusCode: response.statusCode,
|
|
);
|
|
}
|
|
|
|
if (response.statusCode == 400 || response.statusCode == 422) {
|
|
return R2UploadResult.permanentFailure(
|
|
'Worker rejected ride bundle payload (${response.statusCode}): ${response.body}',
|
|
statusCode: response.statusCode,
|
|
);
|
|
}
|
|
|
|
// Server error / Cloudflare rate-limit or 5xx -> Retryable
|
|
return R2UploadResult.temporaryFailure(
|
|
'Cloudflare Worker temporary error (${response.statusCode}): ${response.body}',
|
|
statusCode: response.statusCode,
|
|
);
|
|
} on TimeoutException {
|
|
return const R2UploadResult.temporaryFailure(
|
|
'Upload to Cloudflare R2 timed out after 45 seconds.',
|
|
);
|
|
} catch (e) {
|
|
return R2UploadResult.temporaryFailure(
|
|
'Network error uploading to Cloudflare R2: $e',
|
|
);
|
|
}
|
|
}
|
|
}
|