import 'dart:convert'; import 'dart:math'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:crypto/crypto.dart' as crypto; import 'package:cryptography/cryptography.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:http/http.dart' as http; import 'package:flutter/foundation.dart'; import 'package:notas/data/sync_models.dart'; class ApiConfig { ApiConfig._(); static const String _endpointKey = 'api_endpoint_v1'; /// Default endpoint for local development. Can be overridden by user. static const String defaultEndpoint = 'http://localhost:3000/api'; static Future getEndpoint() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_endpointKey) ?? defaultEndpoint; } static Future setEndpoint(String endpoint) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_endpointKey, endpoint); } static Future clearEndpoint() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_endpointKey); } } class AuthApi { AuthApi._(); static final AuthApi instance = AuthApi._(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); static const String _accessTokenKey = 'api_access_token_v1'; static const String _refreshTokenKey = 'api_refresh_token_v1'; static const int _passwordHashVersion = 1; static const int _kdfIterations = 100000; static final Pbkdf2 _kdf = Pbkdf2( macAlgorithm: Hmac.sha256(), iterations: _kdfIterations, bits: 256, ); static final AesGcm _aes = AesGcm.with256bits(); Future get accessToken async => await _secureStorage.read(key: _accessTokenKey); Future get refreshToken async => await _secureStorage.read(key: _refreshTokenKey); Future clearTokens() async { await _secureStorage.delete(key: _accessTokenKey); await _secureStorage.delete(key: _refreshTokenKey); } String hashPassword(String password) { return crypto.sha256.convert(utf8.encode(password)).toString(); } Future encryptWithPassword( String plaintext, String password, ) async { final List salt = _randomBytes(16); final SecretKey secretKey = await _kdf.deriveKey( secretKey: SecretKey(utf8.encode(password)), nonce: salt, ); final List nonce = _randomBytes(12); final SecretBox box = await _aes.encrypt( utf8.encode(plaintext), secretKey: secretKey, nonce: nonce, ); return jsonEncode({ 'v': _passwordHashVersion, 'salt': base64Encode(salt), 'nonce': base64Encode(box.nonce), 'cipherText': base64Encode(box.cipherText), 'mac': base64Encode(box.mac.bytes), }); } Future decryptWithPassword( String encodedBox, String password, ) async { final Map payload = jsonDecode(encodedBox) as Map; final List salt = base64Decode(payload['salt'] as String); final List nonce = base64Decode(payload['nonce'] as String); final List cipherText = base64Decode(payload['cipherText'] as String); final List macBytes = base64Decode(payload['mac'] as String); final SecretKey secretKey = await _kdf.deriveKey( secretKey: SecretKey(utf8.encode(password)), nonce: salt, ); final SecretBox box = SecretBox( cipherText, nonce: nonce, mac: Mac(macBytes), ); final List clearText = await _aes.decrypt( box, secretKey: secretKey, ); return utf8.decode(clearText); } Future> login( String username, String password, { String? deviceName, String? endpoint, }) async { final String base = endpoint ?? await ApiConfig.getEndpoint(); final Uri url = Uri.parse('$base/auth/login'); final Map body = { 'username': username, 'password': hashPassword(password), }; if (deviceName != null) body['deviceName'] = deviceName; final http.Response res = await http.post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode(body), ); if (res.statusCode >= 200 && res.statusCode < 300) { final Map json = jsonDecode(res.body) as Map; final String? access = json['accessToken'] as String?; final String? refresh = json['refreshToken'] as String?; if (access != null) { await _secureStorage.write(key: _accessTokenKey, value: access); } if (refresh != null) { await _secureStorage.write(key: _refreshTokenKey, value: refresh); } return json; } // Try to decode error body for better diagnostics. try { final dynamic decoded = jsonDecode(res.body); return {'error': true, 'status': res.statusCode, 'body': decoded}; } catch (_) { return {'error': true, 'status': res.statusCode, 'body': res.body}; } } Future> register( String username, String password, { String? encryptedMasterKey, String? endpoint, }) async { final String base = endpoint ?? await ApiConfig.getEndpoint(); final Uri url = Uri.parse('$base/auth/register'); final Map body = { 'username': username, 'password': hashPassword(password), 'encrypted_master_key': encryptedMasterKey, }; final http.Response res = await http.post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode(body), ); if (res.statusCode >= 200 && res.statusCode < 300) { final Map json = jsonDecode(res.body) as Map; final String? access = json['accessToken'] as String?; final String? refresh = json['refreshToken'] as String?; if (access != null) { await _secureStorage.write(key: _accessTokenKey, value: access); } if (refresh != null) { await _secureStorage.write(key: _refreshTokenKey, value: refresh); } return json; } try { final dynamic decoded = jsonDecode(res.body); return {'error': true, 'status': res.statusCode, 'body': decoded}; } catch (_) { return {'error': true, 'status': res.statusCode, 'body': res.body}; } } List _randomBytes(int length) { final Random random = Random.secure(); return List.generate(length, (_) => random.nextInt(256)); } // ========== Sync API ========== static const String _lastSyncAtKey = 'api_last_sync_at_v1'; Future getLastSyncAt() async { final prefs = await SharedPreferences.getInstance(); final String? timestamp = prefs.getString(_lastSyncAtKey); if (timestamp == null) return null; return DateTime.tryParse(timestamp); } Future setLastSyncAt(DateTime timestamp) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_lastSyncAtKey, timestamp.toIso8601String()); } Future> sync( SyncRequest syncRequest, { String? endpoint, }) async { final String? token = await accessToken; if (token == null) { return {'error': true, 'message': 'No access token available'}; } final String base = endpoint ?? await ApiConfig.getEndpoint(); final Uri url = Uri.parse('$base/sync'); final Map headers = { 'Content-Type': 'application/json', 'Authorization': 'Bearer $token', }; final String bodyJson = jsonEncode(syncRequest.toJson()); // Log request (mask authorization header) final Map logHeaders = Map.from(headers); if (logHeaders.containsKey('Authorization')) { logHeaders['Authorization'] = 'REDACTED'; } debugPrint('SYNC REQUEST -> POST $url'); debugPrint('Headers: $logHeaders'); debugPrint('Body: $bodyJson'); final http.Response res = await http.post( url, headers: headers, body: bodyJson, ); // Log response debugPrint('SYNC RESPONSE <- ${res.statusCode}'); debugPrint('Response body: ${res.body}'); if (res.statusCode >= 200 && res.statusCode < 300) { try { final Map json = jsonDecode(res.body) as Map; return {'error': false, 'data': SyncResponse.fromJson(json)}; } catch (e, st) { debugPrint('SYNC PARSE ERROR -> $e'); debugPrint(st.toString()); debugPrint('SYNC PARSE RAW BODY -> ${res.body}'); return { 'error': true, 'message': 'Error parseando respuesta de sync: $e', 'exception': e.toString(), 'stackTrace': st.toString(), 'body': res.body, 'status': res.statusCode, }; } } // If token expired (401), try to refresh if (res.statusCode == 401) { final String? refreshTok = await refreshToken; if (refreshTok != null) { final bool refreshed = await _refreshAccessToken(refreshTok, endpoint: endpoint); if (refreshed) { // Retry sync with new token return sync(syncRequest, endpoint: endpoint); } } } // Try to decode error body for better diagnostics. try { final dynamic decoded = jsonDecode(res.body); return {'error': true, 'status': res.statusCode, 'body': decoded}; } catch (e, st) { debugPrint('SYNC HTTP ERROR PARSE FAILED -> $e'); debugPrint(st.toString()); return { 'error': true, 'status': res.statusCode, 'body': res.body, 'exception': e.toString(), 'stackTrace': st.toString(), }; } } Future _refreshAccessToken( String refreshToken, { String? endpoint, }) async { final String base = endpoint ?? await ApiConfig.getEndpoint(); final Uri url = Uri.parse('$base/auth/refresh'); final http.Response res = await http.post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({'refreshToken': refreshToken}), ); if (res.statusCode >= 200 && res.statusCode < 300) { final Map json = jsonDecode(res.body) as Map; final String? newAccess = json['accessToken'] as String?; if (newAccess != null) { await _secureStorage.write(key: _accessTokenKey, value: newAccess); return true; } } return false; } }