131 lines
5.1 KiB
Dart
131 lines
5.1 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:firebase_core/firebase_core.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import '../auth/auth_service.dart';
|
|
import '../sync/r2_upload_service.dart';
|
|
|
|
abstract class CloudAccountCleanup {
|
|
Future<void> deleteCloudData();
|
|
}
|
|
|
|
class AccountCloudCleanupService implements CloudAccountCleanup {
|
|
final AuthService _auth;
|
|
final FirebaseFirestore? _firestore;
|
|
final http.Client _client;
|
|
final String _workerUrl;
|
|
|
|
AccountCloudCleanupService({
|
|
required this._auth,
|
|
FirebaseFirestore? firestore,
|
|
http.Client? client,
|
|
String? workerUrl,
|
|
}) : _firestore = firestore ?? _resolveFirestore(),
|
|
_client = client ?? http.Client(),
|
|
_workerUrl = (workerUrl ?? R2UploadService.defaultWorkerUrl).replaceAll(RegExp(r'/+$'), '');
|
|
|
|
static FirebaseFirestore? _resolveFirestore() {
|
|
try {
|
|
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Future<void> deleteCloudData() async {
|
|
final user = _auth.currentUser;
|
|
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
|
|
final token = await _auth.getIdToken(true);
|
|
if (token == null || token.isEmpty) throw StateError('A valid account session is required.');
|
|
final endpoint = Uri.tryParse('$_workerUrl/account/cloud-data');
|
|
if (endpoint == null || !endpoint.hasScheme ||
|
|
(endpoint.scheme != 'http' && endpoint.scheme != 'https')) {
|
|
throw StateError('MotorCloud cleanup endpoint is unavailable.');
|
|
}
|
|
|
|
final response = await _client.delete(
|
|
endpoint,
|
|
headers: {'Authorization': 'Bearer ${token.trim()}'},
|
|
).timeout(const Duration(seconds: 30));
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
final detail = _safeResponseDetail(response.body);
|
|
debugPrint(
|
|
'[DELETE_FLOW] cloud cleanup response status=${response.statusCode} '
|
|
'body=$detail',
|
|
);
|
|
throw StateError(
|
|
'MotorCloud cleanup was not completed (${response.statusCode}).',
|
|
);
|
|
}
|
|
if (response.statusCode != 204 && !_isSuccessfulWorkerResponse(response.body)) {
|
|
final detail = _safeResponseDetail(response.body);
|
|
debugPrint('[DELETE_FLOW] cloud cleanup response malformed body=$detail');
|
|
throw StateError('MotorCloud cleanup returned an invalid response.');
|
|
}
|
|
|
|
final firestore = _firestore;
|
|
if (firestore == null) throw StateError('MotorCloud database is unavailable.');
|
|
final userRef = firestore.collection('users').doc(user.uid);
|
|
final rides = await userRef.collection('rides').get();
|
|
for (final doc in rides.docs) {
|
|
await _deleteCollection(doc.reference.collection('events'));
|
|
await _deleteCollection(doc.reference.collection('performance'));
|
|
}
|
|
|
|
await _deleteCollection(userRef.collection('rides'));
|
|
await _deleteCollection(userRef.collection('motorcycles'));
|
|
await _deleteCollection(userRef.collection('trips'));
|
|
await _deleteCollection(userRef.collection('events'));
|
|
await _deleteCollection(userRef.collection('friends'));
|
|
await _deleteCollection(userRef.collection('friend_requests_incoming'));
|
|
await _deleteCollection(userRef.collection('friend_requests_outgoing'));
|
|
await _deleteCollection(userRef.collection('blocks'));
|
|
|
|
// Release handle reservation if exists
|
|
final userSnap = await userRef.get();
|
|
final handle = userSnap.data()?['username'] as String?;
|
|
if (handle != null && handle.isNotEmpty) {
|
|
await firestore.collection('handles').doc(handle.toLowerCase()).delete();
|
|
}
|
|
|
|
// Delete the profile last so retries can still discover the account scope.
|
|
final profileBatch = firestore.batch()..delete(userRef);
|
|
await profileBatch.commit();
|
|
}
|
|
|
|
Future<void> _deleteCollection(CollectionReference<Map<String, dynamic>> collection) async {
|
|
final snapshot = await collection.get();
|
|
for (var offset = 0; offset < snapshot.docs.length; offset += 450) {
|
|
final batch = _firestore!.batch();
|
|
final end = (offset + 450).clamp(0, snapshot.docs.length);
|
|
for (final doc in snapshot.docs.sublist(offset, end)) {
|
|
batch.delete(doc.reference);
|
|
}
|
|
await batch.commit();
|
|
}
|
|
}
|
|
|
|
String _safeResponseDetail(String body) {
|
|
final sanitized = body
|
|
.replaceAll(RegExp(r'Bearer\s+\S+', caseSensitive: false), 'Bearer [REDACTED]')
|
|
.replaceAll(RegExp(r'"(?:token|password|secret|pat)"\s*:\s*"[^"]*"', caseSensitive: false), '"credential":"[REDACTED]"');
|
|
final compact = sanitized.replaceAll(RegExp(r'\s+'), ' ').trim();
|
|
if (compact.isEmpty) return '[empty]';
|
|
return compact.length > 240 ? compact.substring(0, 240) : compact;
|
|
}
|
|
|
|
bool _isSuccessfulWorkerResponse(String body) {
|
|
try {
|
|
final decoded = jsonDecode(body);
|
|
return decoded is Map<String, dynamic> &&
|
|
decoded['success'] == true &&
|
|
decoded['deletedObjects'] is int &&
|
|
(decoded['deletedObjects'] as int) >= 0;
|
|
} on FormatException {
|
|
return false;
|
|
}
|
|
}
|
|
}
|