mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-09-06 07:51:43 +08:00
326 lines
9.2 KiB
Dart
326 lines
9.2 KiB
Dart
import 'dart:io' show HandshakeException, Platform;
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'api_result.dart';
|
|
import 'cookie_store.dart';
|
|
import '../security/server_certificate_trust.dart';
|
|
|
|
const String _fallbackNativeAppVersion = 'unknown';
|
|
const String ipAccessDeniedCode = 'IP_ACCESS_DENIED';
|
|
|
|
typedef SessionFailureHandler =
|
|
Future<void> Function(ApiSessionFailure failure);
|
|
|
|
const _sensitiveDebugKeys = <String>{
|
|
'apikey',
|
|
'authorization',
|
|
'cookie',
|
|
'encryptedkey',
|
|
'opnesshkeypassword',
|
|
'password',
|
|
'privatekey',
|
|
'set-cookie',
|
|
'tempkey',
|
|
'token',
|
|
};
|
|
|
|
bool _isSensitiveDebugKey(Object? key) {
|
|
final normalized = key
|
|
.toString()
|
|
.replaceAll(RegExp(r'[-_]'), '')
|
|
.toLowerCase();
|
|
return _sensitiveDebugKeys.contains(normalized) ||
|
|
normalized.endsWith('token') ||
|
|
normalized.contains('password') ||
|
|
normalized.contains('privatekey') ||
|
|
normalized.contains('apikey') ||
|
|
normalized.contains('cookie') ||
|
|
normalized.contains('secret');
|
|
}
|
|
|
|
Object? redactDebugValue(Object? value) {
|
|
if (value is Map) {
|
|
return value.map((key, item) {
|
|
return MapEntry(
|
|
key.toString(),
|
|
_isSensitiveDebugKey(key) ? '<redacted>' : redactDebugValue(item),
|
|
);
|
|
});
|
|
}
|
|
if (value is Iterable) return value.map(redactDebugValue).toList();
|
|
return value;
|
|
}
|
|
|
|
Uri redactDebugUri(Uri uri) {
|
|
if (uri.queryParameters.isEmpty) return uri;
|
|
return uri.replace(
|
|
queryParameters: uri.queryParameters.map(
|
|
(key, value) =>
|
|
MapEntry(key, _isSensitiveDebugKey(key) ? '<redacted>' : value),
|
|
),
|
|
);
|
|
}
|
|
|
|
bool isIpAccessDeniedResponse(Object? body) {
|
|
if (body is! Map) return false;
|
|
final data = body['data'];
|
|
return data is Map && data['code'] == ipAccessDeniedCode;
|
|
}
|
|
|
|
/// Converts a failed HTTP response into the app's semantic error types.
|
|
/// Kept outside [ApiClient] so the server response contract is directly
|
|
/// testable without a live secure-storage backend.
|
|
ApiFailure apiFailureFromDioException(DioException error) {
|
|
final body = error.response?.data;
|
|
String? msg;
|
|
Object? data;
|
|
if (body is Map && body['msg'] is String) {
|
|
msg = body['msg'] as String;
|
|
}
|
|
if (body is Map) {
|
|
data = body['data'];
|
|
}
|
|
final statusCode = error.response?.statusCode;
|
|
final message = msg ?? error.message ?? 'Network error';
|
|
if (isIpAccessDeniedResponse(body)) {
|
|
return IpAccessDeniedFailure(message, statusCode: statusCode, data: data);
|
|
}
|
|
if (statusCode == 401 || statusCode == 403) {
|
|
return UnauthorizedFailure(message, statusCode: statusCode, data: data);
|
|
}
|
|
return ApiFailure(message, statusCode: statusCode, data: data);
|
|
}
|
|
|
|
String buildNativeUserAgent({String? appVersion}) {
|
|
String clientName;
|
|
if (Platform.isAndroid) {
|
|
clientName = 'Android';
|
|
} else if (Platform.isIOS) {
|
|
clientName = 'iOS';
|
|
} else if (Platform.isMacOS) {
|
|
clientName = 'macOS';
|
|
} else if (Platform.isWindows) {
|
|
clientName = 'Windows';
|
|
} else if (Platform.isLinux) {
|
|
clientName = 'Linux';
|
|
} else {
|
|
clientName = 'Native';
|
|
}
|
|
final sanitizedVersion = Platform.operatingSystemVersion
|
|
.replaceAll('(', '[')
|
|
.replaceAll(')', ']')
|
|
.trim();
|
|
final version = appVersion?.trim().isNotEmpty == true
|
|
? appVersion!.trim()
|
|
: _fallbackNativeAppVersion;
|
|
return 'EasyNode-$clientName/$version ($sanitizedVersion)';
|
|
}
|
|
|
|
class ApiClient {
|
|
ApiClient({
|
|
required String serverAddress,
|
|
required SessionCookieStore cookieStore,
|
|
String? token,
|
|
SessionFailureHandler? onSessionFailure,
|
|
String? appVersion,
|
|
Dio? dio,
|
|
ServerCertificateTrustStore? certificateTrust,
|
|
}) : _serverAddress = serverAddress,
|
|
_certificateTrust = certificateTrust,
|
|
_cookieStore = cookieStore,
|
|
_token = token,
|
|
_onSessionFailure = onSessionFailure,
|
|
_dio =
|
|
dio ??
|
|
Dio(
|
|
BaseOptions(
|
|
baseUrl: '$serverAddress/api/v1',
|
|
connectTimeout: const Duration(seconds: 30),
|
|
receiveTimeout: const Duration(seconds: 30),
|
|
headers: {
|
|
'User-Agent': buildNativeUserAgent(appVersion: appVersion),
|
|
},
|
|
),
|
|
) {
|
|
if (dio == null && certificateTrust != null) {
|
|
_dio.httpClientAdapter = certificateTrust.createDioAdapter();
|
|
}
|
|
if (kDebugMode) {
|
|
_dio.interceptors.add(
|
|
InterceptorsWrapper(
|
|
onRequest: (options, handler) {
|
|
debugPrint(
|
|
'[API] ${options.method} ${redactDebugUri(options.uri)} '
|
|
'${redactDebugValue(options.data)}',
|
|
wrapWidth: 1024,
|
|
);
|
|
handler.next(options);
|
|
},
|
|
onResponse: (response, handler) {
|
|
debugPrint(
|
|
'[API] ${response.statusCode} '
|
|
'${redactDebugUri(response.requestOptions.uri)} '
|
|
'${redactDebugValue(response.data)}',
|
|
wrapWidth: 1024,
|
|
);
|
|
handler.next(response);
|
|
},
|
|
onError: (error, handler) {
|
|
debugPrint(
|
|
'[API] ERROR ${redactDebugUri(error.requestOptions.uri)} '
|
|
'${redactDebugValue(error.response?.data)}',
|
|
wrapWidth: 1024,
|
|
);
|
|
handler.next(error);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
_dio.interceptors.add(
|
|
InterceptorsWrapper(
|
|
onRequest: (options, handler) async {
|
|
if (_token != null && _token!.isNotEmpty) {
|
|
options.headers['token'] = _token;
|
|
}
|
|
final cookie = await _cookieStore.readCookieHeader();
|
|
if (cookie != null && cookie.isNotEmpty) {
|
|
options.headers['Cookie'] = cookie;
|
|
}
|
|
handler.next(options);
|
|
},
|
|
onResponse: (response, handler) async {
|
|
final cookies = response.headers.map['set-cookie'];
|
|
if (cookies != null) {
|
|
await _cookieStore.saveFromSetCookieHeaders(cookies);
|
|
}
|
|
handler.next(response);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
final Dio _dio;
|
|
final String _serverAddress;
|
|
final ServerCertificateTrustStore? _certificateTrust;
|
|
final SessionCookieStore _cookieStore;
|
|
SessionFailureHandler? _onSessionFailure;
|
|
String? _token;
|
|
|
|
void setToken(String? token) {
|
|
_token = token;
|
|
}
|
|
|
|
void setOnSessionFailure(SessionFailureHandler? handler) {
|
|
_onSessionFailure = handler;
|
|
}
|
|
|
|
Future<String> getPublicKey() async {
|
|
final json = await getJson('/get-pub-pem');
|
|
final data = json['data'];
|
|
if (data is String && data.isNotEmpty) return data;
|
|
throw ApiFailure('Server public key is missing');
|
|
}
|
|
|
|
Future<Map<String, dynamic>> getJson(
|
|
String path, {
|
|
Map<String, dynamic>? queryParameters,
|
|
}) async {
|
|
return _request(() => _dio.get(path, queryParameters: queryParameters));
|
|
}
|
|
|
|
Future<Map<String, dynamic>> postJson(
|
|
String path,
|
|
Map<String, dynamic> data,
|
|
) async {
|
|
return _request(() => _dio.post(path, data: data));
|
|
}
|
|
|
|
Future<Map<String, dynamic>> postJsonWithReceiveTimeout(
|
|
String path,
|
|
Map<String, dynamic> data,
|
|
Duration receiveTimeout,
|
|
) async {
|
|
return _request(
|
|
() => _dio.post(
|
|
path,
|
|
data: data,
|
|
options: Options(receiveTimeout: receiveTimeout),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> putJson(
|
|
String path,
|
|
Map<String, dynamic> data,
|
|
) async {
|
|
return _request(() => _dio.put(path, data: data));
|
|
}
|
|
|
|
Future<Map<String, dynamic>> putJsonWithReceiveTimeout(
|
|
String path,
|
|
Map<String, dynamic> data,
|
|
Duration receiveTimeout,
|
|
) async {
|
|
return _request(
|
|
() => _dio.put(
|
|
path,
|
|
data: data,
|
|
options: Options(receiveTimeout: receiveTimeout),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> patchJson(
|
|
String path,
|
|
Map<String, dynamic> data,
|
|
) async {
|
|
return _request(() => _dio.patch(path, data: data));
|
|
}
|
|
|
|
Future<Map<String, dynamic>> deleteJson(
|
|
String path, {
|
|
Map<String, dynamic>? queryParameters,
|
|
}) async {
|
|
return _request(() => _dio.delete(path, queryParameters: queryParameters));
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _request(
|
|
Future<Response<dynamic>> Function() send,
|
|
) async {
|
|
try {
|
|
return _asJson(await send());
|
|
} on DioException catch (error) {
|
|
final certificate = _certificateTrust?.pendingCertificateFor(
|
|
_serverAddress,
|
|
);
|
|
if (certificate != null &&
|
|
(error.type == DioExceptionType.badCertificate ||
|
|
error.error is HandshakeException)) {
|
|
throw UntrustedServerCertificateException(certificate);
|
|
}
|
|
final failure = apiFailureFromDioException(error);
|
|
if (failure is ApiSessionFailure) {
|
|
final handler = _onSessionFailure;
|
|
if (handler != null) {
|
|
try {
|
|
await handler(failure);
|
|
} catch (_) {
|
|
// A global side effect must never replace the request's semantic
|
|
// failure, otherwise feature-level error handling becomes
|
|
// dependent on cleanup implementation details.
|
|
}
|
|
}
|
|
}
|
|
throw failure;
|
|
}
|
|
}
|
|
|
|
Map<String, dynamic> _asJson(Response response) {
|
|
final data = response.data;
|
|
if (data is Map<String, dynamic>) return data;
|
|
throw ApiFailure('Unexpected server response');
|
|
}
|
|
}
|