Files
vpn/lib/services/remote_connections_service.dart
2026-04-20 00:27:09 +03:00

264 lines
7.0 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
final class RemoteConnectionsService {
const RemoteConnectionsService({http.Client? client}) : _client = client;
static Future<String>? _cachedEndpoint;
final http.Client? _client;
Future<List<String>> fetchConfigLinks() async {
final Uri uri = await _connectionsUri();
final http.Client client = _client ?? http.Client();
try {
final http.Response response = await client
.get(
uri,
headers: const <String, String>{'accept': 'application/json'},
)
.timeout(const Duration(seconds: 15));
if (response.statusCode != 200) {
throw RemoteConnectionsException(
'Remote server returned ${response.statusCode} for GET /connections',
);
}
final dynamic decoded = jsonDecode(response.body);
final List<String> links = _extractLinks(decoded);
return links.toSet().toList();
} on TimeoutException {
throw const RemoteConnectionsException('Remote request timeout');
} on FormatException {
throw const RemoteConnectionsException(
'Remote response is not valid JSON',
);
} finally {
if (_client == null) {
client.close();
}
}
}
Future<void> addConfigLink(String link) async {
final String normalized = link.trim();
if (normalized.isEmpty) {
throw const RemoteConnectionsException('Config link is empty');
}
final Uri uri = await _connectionsUri();
final http.Client client = _client ?? http.Client();
try {
final http.Response response = await client
.post(
uri,
headers: const <String, String>{
'accept': 'application/json',
'content-type': 'application/json',
},
body: jsonEncode(<String, String>{'url': normalized}),
)
.timeout(const Duration(seconds: 15));
if (response.statusCode == 201) {
return;
}
if (response.statusCode == 409) {
throw const RemoteConnectionsException('Remote link already exists');
}
if (response.statusCode == 400) {
throw const RemoteConnectionsException(
'Remote server rejected payload',
);
}
throw RemoteConnectionsException(
'Remote server returned ${response.statusCode} for POST /connections',
);
} on TimeoutException {
throw const RemoteConnectionsException('Remote request timeout');
} finally {
if (_client == null) {
client.close();
}
}
}
Future<void> deleteConfigLink(String link) async {
final String normalized = link.trim();
if (normalized.isEmpty) {
throw const RemoteConnectionsException('Config link is empty');
}
final Uri uri = await _connectionsUri();
final http.Client client = _client ?? http.Client();
try {
final http.Response response = await client
.delete(
uri,
headers: const <String, String>{
'accept': 'application/json',
'content-type': 'application/json',
},
body: jsonEncode(<String, String>{'url': normalized}),
)
.timeout(const Duration(seconds: 15));
if (response.statusCode == 200) {
return;
}
if (response.statusCode == 404) {
throw const RemoteConnectionsException('Remote link not found');
}
if (response.statusCode == 400) {
throw const RemoteConnectionsException(
'Remote server rejected payload',
);
}
throw RemoteConnectionsException(
'Remote server returned ${response.statusCode} for DELETE /connections',
);
} on TimeoutException {
throw const RemoteConnectionsException('Remote request timeout');
} finally {
if (_client == null) {
client.close();
}
}
}
Future<Uri> _connectionsUri() async {
final String endpoint = (await _getEndpoint()).trim();
if (endpoint.isEmpty) {
throw const RemoteConnectionsException(
'REMOTE_CONFIGS_URL is not configured in assets/env',
);
}
final Uri uri;
try {
uri = Uri.parse(endpoint);
} catch (_) {
throw RemoteConnectionsException(
'Invalid REMOTE_CONFIGS_URL in assets/env: $endpoint',
);
}
final String normalizedPath;
if (uri.path.isEmpty || uri.path == '/') {
normalizedPath = '/connections';
} else {
normalizedPath = uri.path;
}
return uri.replace(path: normalizedPath);
}
Future<String> _getEndpoint() {
return _cachedEndpoint ??= _loadEndpoint();
}
Future<String> _loadEndpoint() async {
final String content;
try {
content = await rootBundle.loadString('assets/env');
} catch (_) {
throw const RemoteConnectionsException('Failed to load assets/env');
}
final Map<String, String> env = _parseEnv(content);
return env['REMOTE_CONFIGS_URL'] ?? '';
}
Map<String, String> _parseEnv(String source) {
final Map<String, String> values = <String, String>{};
final List<String> lines = const LineSplitter().convert(source);
for (final String line in lines) {
final String trimmed = line.trim();
if (trimmed.isEmpty || trimmed.startsWith('#')) {
continue;
}
final int separator = trimmed.indexOf('=');
if (separator <= 0) {
continue;
}
final String key = trimmed.substring(0, separator).trim();
String value = trimmed.substring(separator + 1).trim();
if (value.length >= 2) {
final bool quotedWithDouble =
value.startsWith('"') && value.endsWith('"');
final bool quotedWithSingle =
value.startsWith("'") && value.endsWith("'");
if (quotedWithDouble || quotedWithSingle) {
value = value.substring(1, value.length - 1);
}
}
if (key.isNotEmpty) {
values[key] = value;
}
}
return values;
}
List<String> _extractLinks(dynamic data) {
if (data is! Map) {
throw const RemoteConnectionsException(
'Invalid payload: expected {"links": ["..."]}',
);
}
final Map<String, dynamic> map = Map<String, dynamic>.from(data);
final dynamic linksPayload = map['links'];
if (linksPayload is! List) {
throw const RemoteConnectionsException(
'Invalid payload: expected {"links": ["..."]}',
);
}
final List<String> links = <String>[];
for (final dynamic item in linksPayload) {
if (item is! String) {
throw const RemoteConnectionsException(
'Invalid payload: expected {"links": ["..."]}',
);
}
final String link = item.trim();
if (link.isNotEmpty) {
links.add(link);
}
}
return links;
}
}
final class RemoteConnectionsException implements Exception {
const RemoteConnectionsException(this.message);
final String message;
@override
String toString() => message;
}