diff --git a/lib/helpers/sanity_checks.dart b/lib/helpers/sanity_checks.dart new file mode 100644 index 000000000..f3378aa9d --- /dev/null +++ b/lib/helpers/sanity_checks.dart @@ -0,0 +1,66 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +import 'dart:convert'; +import 'package:http/http.dart' as http; + +/// Checks if the server's reverse proxy is configured correctly +/// by comparing pagination URLs with the base URL domain. +Future checkServerPaginationUrls({ + required String baseUrl, + required String token, + http.Client? client, +}) async { + final httpClient = client ?? http.Client(); + + try { + final baseUri = Uri.parse(baseUrl); + final expectedHost = baseUri.host; + final expectedScheme = baseUri.scheme; + + final response = await httpClient.get( + Uri.parse('$baseUrl/api/v2/exercise/?limit=1'), + headers: { + 'Authorization': 'Token $token', + 'Accept': 'application/json', + }, + ); + + if (response.statusCode != 200) { + return const SanityCheckResult(isValid: false); + } + + final data = jsonDecode(response.body) as Map; + final nextUrl = data['next'] as String?; + + if (nextUrl == null) { + return const SanityCheckResult(isValid: true); + } + + final nextUri = Uri.parse(nextUrl); + + if (nextUri.host != expectedHost || nextUri.scheme != expectedScheme) { + return const SanityCheckResult(isValid: false); + } + + return const SanityCheckResult(isValid: true); + } catch (e) { + return const SanityCheckResult(isValid: false); + } +} + +/// Result of a server sanity check +class SanityCheckResult { + const SanityCheckResult({ + required this.isValid, + }); + + final bool isValid; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8e94bea3d..4f21b2f4d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1136,5 +1136,21 @@ "addToOpenFoodFacts": "Add to Open Food Facts", "@addToOpenFoodFacts": { "description": "Label shown as the clickable link to go on Open Food Facts" + }, + "serverConfigIssueTitle": "Server Configuration Issue", + "@serverConfigIssueTitle": { + "description": "Title of the dialog shown when server misconfiguration is detected" + }, + "serverConfigIssueMessage": "Server misconfiguration detected, headers are not being passed correctly, please consult the documentation.", + "@serverConfigIssueMessage": { + "description": "Message explaining server misconfiguration and linking to documentation" + }, + "understand": "I understand", + "@understand": { + "description": "Button label to acknowledge and dismiss the server configuration warning" + }, + "viewDocumentation": "View documentation", + "@viewDocumentation": { + "description": "Button label to open the documentation about server configuration" } } diff --git a/lib/providers/auth.dart b/lib/providers/auth.dart index 6bbcb044d..205fbb0ff 100644 --- a/lib/providers/auth.dart +++ b/lib/providers/auth.dart @@ -29,6 +29,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:version/version.dart'; import 'package:wger/core/exceptions/http_exception.dart'; import 'package:wger/helpers/consts.dart'; +import 'package:wger/helpers/sanity_checks.dart'; import 'package:wger/helpers/shared_preferences.dart'; import 'helpers.dart'; @@ -53,6 +54,8 @@ class AuthProvider with ChangeNotifier { PackageInfo? applicationVersion; Map metadata = {}; AuthState state = AuthState.loggedOut; + bool _serverConfigWarning = false; + bool get serverConfigWarning => _serverConfigWarning; static const MIN_APP_VERSION_URL = 'min-app-version'; static const SERVER_VERSION_URL = 'version'; @@ -66,6 +69,12 @@ class AuthProvider with ChangeNotifier { this.client = client ?? http.Client(); } + /// Clear the server config warnings + void clearServerConfigWarning() { + _serverConfigWarning = false; + notifyListeners(); + } + /// flag to indicate that the application has successfully loaded all initial data bool dataInit = false; @@ -192,6 +201,21 @@ class AuthProvider with ChangeNotifier { return LoginActions.update; } + // Check server configuration sanity + try { + final sanityCheck = await checkServerPaginationUrls( + baseUrl: serverUrl, + token: token!, + ); + + if (!sanityCheck.isValid) { + _serverConfigWarning = true; + notifyListeners(); + } + } catch (e) { + _logger.info('Sanity check error: $e'); + } + // Log user in state = AuthState.loggedIn; notifyListeners(); diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index aaa55e46a..f9d2e9328 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -30,6 +30,7 @@ import 'package:wger/widgets/auth/email_field.dart'; import 'package:wger/widgets/auth/password_field.dart'; import 'package:wger/widgets/auth/server_field.dart'; import 'package:wger/widgets/auth/username_field.dart'; +import 'package:wger/widgets/core/server_config_warning_dialog.dart'; import '../providers/auth.dart'; @@ -206,6 +207,17 @@ class _AuthCardState extends State { ); return; } + + if (context.mounted && res == LoginActions.proceed) { + final authProvider = context.read(); + if (authProvider.serverConfigWarning) { + if (context.mounted) { + showServerConfigWarning(context); + authProvider.clearServerConfigWarning(); + } + } + } + if (context.mounted) { setState(() { _isLoading = false; diff --git a/lib/widgets/core/server_config_warning_dialog.dart b/lib/widgets/core/server_config_warning_dialog.dart new file mode 100644 index 000000000..acf1e5feb --- /dev/null +++ b/lib/widgets/core/server_config_warning_dialog.dart @@ -0,0 +1,48 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +import 'package:flutter/material.dart'; +import 'package:wger/helpers/misc.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; + +/// Shows a warning dialog when server misconfiguration is detected +void showServerConfigWarning(BuildContext context) { + final i18n = AppLocalizations.of(context); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + const Icon(Icons.warning, color: Colors.orange, size: 28), + const SizedBox(width: 12), + Expanded(child: Text(i18n.serverConfigIssueTitle)), + ], + ), + content: Text(i18n.serverConfigIssueMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(i18n.understand), + ), + TextButton( + onPressed: () { + launchURL( + 'https://wger.readthedocs.io/en/latest/administration/errors.html#wrong-pagination-links', + context, + ); + Navigator.pop(context); + }, + child: Text(i18n.viewDocumentation), + ), + ], + ), + ); +} diff --git a/test/helpers/sanity_checks_test.dart b/test/helpers/sanity_checks_test.dart new file mode 100644 index 000000000..b61083c98 --- /dev/null +++ b/test/helpers/sanity_checks_test.dart @@ -0,0 +1,192 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (C) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * wger Workout Manager is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:wger/helpers/sanity_checks.dart'; +import 'dart:convert'; + +void main() { + group('checkServerPaginationUrls', () { + const testToken = 'test-token-123'; + const testBaseUrl = 'https://wger.example.com'; + + test('returns valid when pagination URL matches base URL', () async { + final client = MockClient((request) async { + return http.Response( + jsonEncode({ + 'count': 100, + 'next': 'https://wger.example.com/api/v2/exercise/?limit=1&offset=1', + 'previous': null, + 'results': [], + }), + 200, + ); + }); + + final result = await checkServerPaginationUrls( + baseUrl: testBaseUrl, + token: testToken, + client: client, + ); + + expect(result.isValid, isTrue); + }); + + test('detects host mismatch (localhost issue)', () async { + final client = MockClient((request) async { + return http.Response( + jsonEncode({ + 'count': 100, + 'next': 'http://localhost:8000/api/v2/exercise/?limit=1&offset=1', + 'previous': null, + 'results': [], + }), + 200, + ); + }); + + final result = await checkServerPaginationUrls( + baseUrl: testBaseUrl, + token: testToken, + client: client, + ); + + expect(result.isValid, isFalse); + }); + + test('detects protocol mismatch (https vs http)', () async { + final client = MockClient((request) async { + return http.Response( + jsonEncode({ + 'count': 100, + 'next': 'http://wger.example.com/api/v2/exercise/?limit=1&offset=1', + 'previous': null, + 'results': [], + }), + 200, + ); + }); + + final result = await checkServerPaginationUrls( + baseUrl: testBaseUrl, + token: testToken, + client: client, + ); + + expect(result.isValid, isFalse); + }); + + test('returns valid when next URL is null (no pagination)', () async { + final client = MockClient((request) async { + return http.Response( + jsonEncode({ + 'count': 1, + 'next': null, + 'previous': null, + 'results': [], + }), + 200, + ); + }); + + final result = await checkServerPaginationUrls( + baseUrl: testBaseUrl, + token: testToken, + client: client, + ); + + expect(result.isValid, isTrue); + }); + + test('handles server error gracefully', () async { + final client = MockClient((request) async { + return http.Response('Internal Server Error', 500); + }); + + final result = await checkServerPaginationUrls( + baseUrl: testBaseUrl, + token: testToken, + client: client, + ); + + expect(result.isValid, isFalse); + }); + + test('handles network errors gracefully', () async { + final client = MockClient((request) async { + throw Exception('Network error'); + }); + + final result = await checkServerPaginationUrls( + baseUrl: testBaseUrl, + token: testToken, + client: client, + ); + + expect(result.isValid, isFalse); + }); + + test('includes correct headers in request', () async { + String? authHeader; + String? acceptHeader; + + final client = MockClient((request) async { + authHeader = request.headers['Authorization']; + acceptHeader = request.headers['Accept']; + + return http.Response( + jsonEncode({'count': 0, 'next': null, 'results': []}), + 200, + ); + }); + + await checkServerPaginationUrls( + baseUrl: testBaseUrl, + token: testToken, + client: client, + ); + + expect(authHeader, 'Token $testToken'); + expect(acceptHeader, 'application/json'); + }); + + test('handles port numbers correctly in URL comparison', () async { + final client = MockClient((request) async { + return http.Response( + jsonEncode({ + 'count': 100, + 'next': 'https://wger.example.com:8443/api/v2/exercise/?limit=1&offset=1', + 'previous': null, + 'results': [], + }), + 200, + ); + }); + + final result = await checkServerPaginationUrls( + baseUrl: 'https://wger.example.com:8443', + token: testToken, + client: client, + ); + + expect(result.isValid, isTrue); + }); + }); +}