diff --git a/mobile/lib/core/constants.dart b/mobile/lib/core/constants.dart index 1d96c5f..f0c552c 100644 --- a/mobile/lib/core/constants.dart +++ b/mobile/lib/core/constants.dart @@ -8,19 +8,27 @@ class Env { static String host = defaultHost; static String port = defaultPort; - static bool get _isSecure => - port == "443" || host.contains("commandlinecoding.in"); + /// Whether to use HTTPS/WSS. Chosen explicitly in Server settings so a + /// self-hosted TLS server on a non-443 port isn't contacted in plain text. + static bool useTls = true; - static String get httpBaseUrl { - final protocol = _isSecure ? "https" : "http"; - final portSuffix = (port == "80" || port == "443") ? "" : ":$port"; - return "$protocol://$host$portSuffix/api"; - } + static String get httpBaseUrl => + buildUrl(host: host, port: port, tls: useTls, path: '/api'); + + static String get wsBaseUrl => + buildUrl(host: host, port: port, tls: useTls, path: '/api/ws', ws: true); - static String get wsBaseUrl { - final protocol = _isSecure ? "wss" : "ws"; - final portSuffix = (port == "80" || port == "443") ? "" : ":$port"; - return "$protocol://$host$portSuffix/api/ws"; + /// Builds a server URL, omitting the port when it's the scheme's default. + static String buildUrl({ + required String host, + required String port, + required bool tls, + required String path, + bool ws = false, + }) { + final scheme = ws ? (tls ? 'wss' : 'ws') : (tls ? 'https' : 'http'); + final isDefaultPort = port == (tls ? '443' : '80'); + return '$scheme://$host${isDefaultPort ? '' : ':$port'}$path'; } static bool get isDefaultServer => host == defaultHost && port == defaultPort; @@ -29,15 +37,26 @@ class Env { const storage = FlutterSecureStorage(); host = await storage.read(key: "custom_host") ?? defaultHost; port = await storage.read(key: "custom_port") ?? defaultPort; + final storedTls = await storage.read(key: "custom_use_tls"); + // Servers saved before the setting existed keep their old behaviour. + useTls = storedTls != null + ? storedTls == "true" + : port == "443" || host.contains("commandlinecoding.in"); } - static Future updateConfig(String newHost, String newPort) async { + static Future updateConfig( + String newHost, + String newPort, { + bool tls = true, + }) async { host = newHost.trim().isEmpty ? defaultHost : newHost.trim(); port = newPort.trim().isEmpty ? defaultPort : newPort.trim(); + useTls = tls; const storage = FlutterSecureStorage(); await storage.write(key: "custom_host", value: host); await storage.write(key: "custom_port", value: port); + await storage.write(key: "custom_use_tls", value: useTls.toString()); } } diff --git a/mobile/lib/pages/settings/server_settings.dart b/mobile/lib/pages/settings/server_settings.dart index e3957d3..c52656b 100644 --- a/mobile/lib/pages/settings/server_settings.dart +++ b/mobile/lib/pages/settings/server_settings.dart @@ -33,6 +33,7 @@ class _ServerSettingsFormState extends State<_ServerSettingsForm> { late final TextEditingController _port = TextEditingController( text: Env.port, ); + late bool _tls = Env.useTls; _Health _health = _Health.unknown; @override @@ -42,10 +43,14 @@ class _ServerSettingsFormState extends State<_ServerSettingsForm> { super.dispose(); } - String _baseUrl(String host, String port) { - final secure = port == '443' || host.contains('commandlinecoding.in'); - final suffix = (port == '80' || port == '443') ? '' : ':$port'; - return '${secure ? 'https' : 'http'}://$host$suffix/api'; + /// Port 80 almost always means no TLS and 443 always means TLS, so + /// follow the port when it's one of those; otherwise keep the choice. + void _onPortChanged(String port) { + setState(() { + if (port == '80') _tls = false; + if (port == '443') _tls = true; + _health = _Health.unknown; + }); } Future _test() async { @@ -57,12 +62,18 @@ class _ServerSettingsFormState extends State<_ServerSettingsForm> { : _port.text.trim(); setState(() => _health = _Health.checking); try { + final url = Env.buildUrl( + host: host, + port: port, + tls: _tls, + path: '/api/health', + ); final res = await Dio( BaseOptions( connectTimeout: const Duration(seconds: 6), receiveTimeout: const Duration(seconds: 6), ), - ).get('${_baseUrl(host, port)}/health'); + ).get(url); final up = res.data is Map && res.data['data']?['postgres'] == 'up'; setState(() => _health = up ? _Health.up : _Health.dbDown); } on DioException catch (e) { @@ -78,7 +89,11 @@ class _ServerSettingsFormState extends State<_ServerSettingsForm> { Future _save({bool reset = false}) async { final navigator = Navigator.of(context); - await Env.updateConfig(reset ? '' : _host.text, reset ? '' : _port.text); + await Env.updateConfig( + reset ? '' : _host.text, + reset ? '' : _port.text, + tls: reset || _tls, + ); navigator.pop(); if (mounted) { showSnack( @@ -120,13 +135,31 @@ class _ServerSettingsFormState extends State<_ServerSettingsForm> { controller: _port, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], - onChanged: (_) => setState(() => _health = _Health.unknown), + onChanged: _onPortChanged, decoration: const InputDecoration( labelText: 'Port', hintText: Env.defaultPort, prefixIcon: Icon(Icons.numbers_rounded), - helperText: - 'Port 443 uses HTTPS/WSS; other ports use plain HTTP/WS.', + ), + ), + const SizedBox(height: Insets.md), + GlassSurface( + borderRadius: BorderRadius.circular(Radii.md), + child: Material( + type: MaterialType.transparency, + child: SwitchListTile( + value: _tls, + onChanged: (value) => setState(() { + _tls = value; + _health = _Health.unknown; + }), + title: const Text('Use HTTPS/WSS'), + subtitle: Text( + _tls + ? 'Traffic to the server is encrypted with TLS.' + : 'Your password and session travel unencrypted. Only use this on a network you trust.', + ), + ), ), ), const SizedBox(height: Insets.lg), diff --git a/mobile/test/env_url_test.dart b/mobile/test/env_url_test.dart new file mode 100644 index 0000000..74ff4e3 --- /dev/null +++ b/mobile/test/env_url_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile/core/constants.dart'; + +void main() { + group('Env.buildUrl', () { + test('omits the default TLS port', () { + expect( + Env.buildUrl( + host: 'chat.example', + port: '443', + tls: true, + path: '/api', + ), + 'https://chat.example/api', + ); + }); + + test('keeps TLS on a non-standard port', () { + expect( + Env.buildUrl( + host: 'chat.example', + port: '8443', + tls: true, + path: '/api', + ), + 'https://chat.example:8443/api', + ); + expect( + Env.buildUrl( + host: 'chat.example', + port: '8443', + tls: true, + path: '/api/ws', + ws: true, + ), + 'wss://chat.example:8443/api/ws', + ); + }); + + test('plain HTTP omits port 80 only', () { + expect( + Env.buildUrl(host: '10.0.0.2', port: '80', tls: false, path: '/api'), + 'http://10.0.0.2/api', + ); + expect( + Env.buildUrl(host: '10.0.0.2', port: '8080', tls: false, path: '/api'), + 'http://10.0.0.2:8080/api', + ); + }); + }); +}