diff --git a/lib/core/providers/core_providers.dart b/lib/core/providers/core_providers.dart index 76c00e4..f68e8c0 100644 --- a/lib/core/providers/core_providers.dart +++ b/lib/core/providers/core_providers.dart @@ -118,6 +118,18 @@ final authenticatedImageUrlsProvider = Provider Function(List imageUrls) => authenticateImageUrls(imageUrls, apiKey); }); +/// Provider for image authentication headers. +/// +/// Returns a map containing the X-API-Key header for authenticating image +/// requests via CachedNetworkImage's httpHeaders parameter, instead of +/// appending api_key to URL query strings. +final imageAuthHeadersProvider = Provider>((ref) { + final apiKeyAsync = ref.watch(apiKeyProvider); + final apiKey = apiKeyAsync.valueOrNull; + if (apiKey == null || apiKey.isEmpty) return const {}; + return {'X-API-Key': apiKey}; +}); + /// Initialize providers that need async initialization Future initializeProviders() async { final sharedPreferences = await SharedPreferences.getInstance(); diff --git a/lib/core/services/secure_http_client.dart b/lib/core/services/secure_http_client.dart index ba68699..02fed4b 100644 --- a/lib/core/services/secure_http_client.dart +++ b/lib/core/services/secure_http_client.dart @@ -80,13 +80,16 @@ class SecureHttpClient { static Future validateConnection(String siteUrl, String apiKey) async { try { final client = getClient(); - final uri = Uri.parse('https://$siteUrl/api/whoami.json?api_key=$apiKey'); + final uri = Uri.parse('https://$siteUrl/api/whoami.json'); LoggerService.debug( 'Validating HTTP connection to $siteUrl', tag: 'SecureHttpClient', ); - final response = await client.get(uri).timeout( + final response = await client.get( + uri, + headers: {'X-API-Key': apiKey, 'Accept': 'application/json'}, + ).timeout( const Duration(seconds: 10), onTimeout: () => throw TimeoutException('Connection validation timeout'), diff --git a/lib/core/utils/image_url_normalizer.dart b/lib/core/utils/image_url_normalizer.dart index c0212a5..c4006f2 100644 --- a/lib/core/utils/image_url_normalizer.dart +++ b/lib/core/utils/image_url_normalizer.dart @@ -81,66 +81,20 @@ String? _normalizeImageUrl(String raw, Uri? baseUri) { return baseUri.resolve(trimmed).toString(); } -/// Appends api_key query parameter to an image URL for authenticated access. +/// Returns the image URL as-is. Authentication is now handled via +/// HTTP headers (X-API-Key) passed to CachedNetworkImage's httpHeaders +/// parameter, rather than appending api_key to the URL query string. /// -/// The RXG backend requires api_key authentication for ActiveStorage images. -/// This function adds the api_key to the URL's query parameters without -/// duplicating it if already present. -/// -/// Returns the original URL if: -/// - The URL is null or empty -/// - The apiKey is null or empty -/// - The URL is a data: URL -/// - The URL is not a valid HTTP/HTTPS URL +/// The [apiKey] parameter is retained for API compatibility but is no +/// longer used. Use [imageAuthHeadersProvider] to get the auth headers map. String? authenticateImageUrl(String? imageUrl, String? apiKey) { - if (imageUrl == null || imageUrl.isEmpty) { - return imageUrl; - } - if (apiKey == null || apiKey.isEmpty) { - return imageUrl; - } - - final trimmed = imageUrl.trim(); - final lower = trimmed.toLowerCase(); - - // Don't modify data URLs - if (lower.startsWith('data:')) { - return trimmed; - } - - // Only process HTTP/HTTPS URLs - if (!lower.startsWith('http://') && !lower.startsWith('https://')) { - return trimmed; - } - - try { - final uri = Uri.parse(trimmed); - - // Check if api_key is already present - if (uri.queryParameters.containsKey('api_key')) { - return trimmed; - } - - // Add api_key to query parameters - final newParams = Map.from(uri.queryParameters); - newParams['api_key'] = apiKey; - - return uri.replace(queryParameters: newParams).toString(); - } on FormatException { - // If URL parsing fails, return original - return trimmed; - } + return imageUrl; } -/// Authenticates a list of image URLs by appending api_key to each. +/// Returns the image URLs as-is. Authentication is now handled via +/// HTTP headers (X-API-Key) rather than URL query parameters. List authenticateImageUrls(List imageUrls, String? apiKey) { - if (apiKey == null || apiKey.isEmpty) { - return imageUrls; - } - return imageUrls - .map((url) => authenticateImageUrl(url, apiKey)) - .whereType() - .toList(); + return imageUrls; } /// Strips the api_key query parameter from a URL. diff --git a/lib/features/auth/presentation/providers/auth_notifier.dart b/lib/features/auth/presentation/providers/auth_notifier.dart index 6c0b201..eca0034 100644 --- a/lib/features/auth/presentation/providers/auth_notifier.dart +++ b/lib/features/auth/presentation/providers/auth_notifier.dart @@ -782,6 +782,8 @@ Uri _buildActionCableUri({ ); final queryParameters = Map.from(uri.queryParameters); + // ActionCable authenticates via params[:api_key] from the query string, + // so include it on all platforms. if (token.isNotEmpty) { queryParameters['api_key'] = token; } diff --git a/lib/features/devices/data/services/rest_image_upload_service.dart b/lib/features/devices/data/services/rest_image_upload_service.dart index da557d9..c07b7b3 100644 --- a/lib/features/devices/data/services/rest_image_upload_service.dart +++ b/lib/features/devices/data/services/rest_image_upload_service.dart @@ -56,7 +56,7 @@ class RestImageUploadService { required String deviceId, }) async { final url = - 'https://$_siteUrl/api/$resourceType/$deviceId.json?api_key=$_apiKey'; + 'https://$_siteUrl/api/$resourceType/$deviceId.json'; LoggerService.debug( 'Fetching current signed IDs for $resourceType/$deviceId', @@ -64,7 +64,10 @@ class RestImageUploadService { ); try { - final response = await _dio.get>(url); + final response = await _dio.get>( + url, + options: Options(headers: {'X-API-Key': _apiKey}), + ); if (response.statusCode == 200 && response.data != null) { final images = response.data!['images']; @@ -107,7 +110,7 @@ class RestImageUploadService { required String deviceId, }) async { final url = - 'https://$_siteUrl/api/$resourceType/$deviceId.json?api_key=$_apiKey'; + 'https://$_siteUrl/api/$resourceType/$deviceId.json'; LoggerService.debug( 'Fetching device data for $resourceType/$deviceId', @@ -115,7 +118,10 @@ class RestImageUploadService { ); try { - final response = await _dio.get>(url); + final response = await _dio.get>( + url, + options: Options(headers: {'X-API-Key': _apiKey}), + ); if (response.statusCode == 200 && response.data != null) { LoggerService.debug( @@ -184,13 +190,12 @@ class RestImageUploadService { required List images, }) async { final url = - 'https://$_siteUrl/api/$resourceType/$deviceId.json?api_key=$_apiKey'; + 'https://$_siteUrl/api/$resourceType/$deviceId.json'; LoggerService.debug( 'REST Upload: PUT $resourceType/$deviceId with ${images.length} images', tag: 'RestImageUploadService', ); - // Note: Not logging URL as it contains api_key try { final dio = _dio; @@ -214,6 +219,7 @@ class RestImageUploadService { options: Options( contentType: 'application/json', responseType: ResponseType.json, + headers: {'X-API-Key': _apiKey}, ), onSendProgress: (sent, total) { if (total > 0) { diff --git a/lib/features/devices/presentation/widgets/device_detail_sections.dart b/lib/features/devices/presentation/widgets/device_detail_sections.dart index 7092266..fd7fa6c 100644 --- a/lib/features/devices/presentation/widgets/device_detail_sections.dart +++ b/lib/features/devices/presentation/widgets/device_detail_sections.dart @@ -256,9 +256,7 @@ class DeviceDetailSections extends ConsumerWidget { Widget _buildImagesSection(BuildContext context, WidgetRef ref) { final validImages = _validImages; - // Authenticate image URLs with api_key for RXG backend access - final authenticateUrls = ref.watch(authenticatedImageUrlsProvider); - final authenticatedImages = authenticateUrls(validImages); + final authHeaders = ref.watch(imageAuthHeadersProvider); final uploadState = ref.watch(imageUploadNotifierProvider(device.id)); // Listen for upload state changes to show snackbars and refresh @@ -293,21 +291,22 @@ class DeviceDetailSections extends ConsumerWidget { height: 120, child: ListView.builder( scrollDirection: Axis.horizontal, - itemCount: authenticatedImages.length + 1, // +1 for add button + itemCount: validImages.length + 1, // +1 for add button itemBuilder: (context, index) { // Last item is the add button - if (index == authenticatedImages.length) { + if (index == validImages.length) { return _buildAddImageButton(context, ref, uploadState); } - final imageUrl = authenticatedImages[index]; + final imageUrl = validImages[index]; return Padding( padding: const EdgeInsets.only(right: 8), child: GestureDetector( onTap: () => _showImageViewer( context, ref, - authenticatedImages, + validImages, + authHeaders, index, ), child: ClipRRect( @@ -317,6 +316,7 @@ class DeviceDetailSections extends ConsumerWidget { height: 120, child: CachedNetworkImage( imageUrl: imageUrl, + httpHeaders: authHeaders, fit: BoxFit.cover, memCacheWidth: 240, memCacheHeight: 240, @@ -450,11 +450,9 @@ class DeviceDetailSections extends ConsumerWidget { BuildContext context, WidgetRef ref, List images, + Map authHeaders, int initialIndex, ) { - // Get api_key for passing to the dialog (images are already authenticated, - // but we pass api_key for any additional operations the dialog may need) - final apiKey = ref.read(apiKeyProvider).valueOrNull; final signedIds = _validImageSignedIds; showDialog( @@ -463,8 +461,6 @@ class DeviceDetailSections extends ConsumerWidget { builder: (context) => ImageViewerDialog( images: images, initialIndex: initialIndex, - // Pass the index and look up the signedId for deletion - // This avoids URL mismatch issues since signedIds are stable onDeleteAtIndex: onImageDeletedBySignedId != null ? (index) { if (index >= 0 && index < signedIds.length) { @@ -472,7 +468,7 @@ class DeviceDetailSections extends ConsumerWidget { } } : null, - apiKey: apiKey, + httpHeaders: authHeaders, ), ); } diff --git a/lib/features/devices/presentation/widgets/image_viewer_dialog.dart b/lib/features/devices/presentation/widgets/image_viewer_dialog.dart index fb7950b..ed71b44 100644 --- a/lib/features/devices/presentation/widgets/image_viewer_dialog.dart +++ b/lib/features/devices/presentation/widgets/image_viewer_dialog.dart @@ -8,7 +8,7 @@ class ImageViewerDialog extends StatefulWidget { required this.images, required this.initialIndex, this.onDeleteAtIndex, - this.apiKey, + this.httpHeaders, super.key, }); @@ -16,9 +16,8 @@ class ImageViewerDialog extends StatefulWidget { final int initialIndex; /// Callback when an image is deleted, provides the index of the deleted image final void Function(int index)? onDeleteAtIndex; - /// Optional API key for authenticating image URLs. - /// If provided, will be appended to image URLs that don't already have it. - final String? apiKey; + /// Optional HTTP headers for authenticating image requests. + final Map? httpHeaders; @override State createState() => _ImageViewerDialogState(); @@ -107,6 +106,7 @@ class _ImageViewerDialogState extends State { child: Center( child: CachedNetworkImage( imageUrl: widget.images[index], + httpHeaders: widget.httpHeaders, fit: BoxFit.contain, memCacheWidth: cacheWidth, memCacheHeight: cacheHeight, diff --git a/test/core/utils/image_url_normalizer_test.dart b/test/core/utils/image_url_normalizer_test.dart index 130f91b..2de4273 100644 --- a/test/core/utils/image_url_normalizer_test.dart +++ b/test/core/utils/image_url_normalizer_test.dart @@ -187,24 +187,24 @@ void main() { expect(result, 'https://example.com/image.jpg'); }); - test('should append api_key to URL without query params', () { + // authenticateImageUrl now returns URLs unmodified (auth is via HTTP headers) + test('should return URL unmodified (auth via headers now)', () { final result = authenticateImageUrl( 'https://example.com/image.jpg', 'my_api_key', ); - expect(result, 'https://example.com/image.jpg?api_key=my_api_key'); + expect(result, 'https://example.com/image.jpg'); }); - test('should append api_key to URL with existing query params', () { + test('should return URL with existing query params unmodified', () { final result = authenticateImageUrl( 'https://example.com/image.jpg?size=large', 'my_api_key', ); - expect(result, contains('api_key=my_api_key')); - expect(result, contains('size=large')); + expect(result, 'https://example.com/image.jpg?size=large'); }); - test('should not duplicate api_key if already present', () { + test('should return URL with existing api_key unmodified', () { final result = authenticateImageUrl( 'https://example.com/image.jpg?api_key=existing_key', 'new_api_key', @@ -212,7 +212,7 @@ void main() { expect(result, 'https://example.com/image.jpg?api_key=existing_key'); }); - test('should not modify data URLs', () { + test('should return data URLs unmodified', () { final result = authenticateImageUrl( 'data:image/png;base64,iVBORw0KGgo=', 'my_api_key', @@ -220,25 +220,25 @@ void main() { expect(result, 'data:image/png;base64,iVBORw0KGgo='); }); - test('should not modify non-HTTP URLs', () { + test('should return non-HTTP URLs unmodified', () { final result = authenticateImageUrl('/relative/path/image.jpg', 'my_api_key'); expect(result, '/relative/path/image.jpg'); }); - test('should handle http URLs (not just https)', () { + test('should return http URLs unmodified', () { final result = authenticateImageUrl( 'http://example.com/image.jpg', 'my_api_key', ); - expect(result, 'http://example.com/image.jpg?api_key=my_api_key'); + expect(result, 'http://example.com/image.jpg'); }); - test('should trim whitespace from URL', () { + test('should return whitespace-padded URL as-is', () { final result = authenticateImageUrl( ' https://example.com/image.jpg ', 'my_api_key', ); - expect(result, 'https://example.com/image.jpg?api_key=my_api_key'); + expect(result, ' https://example.com/image.jpg '); }); }); @@ -255,13 +255,13 @@ void main() { expect(result, urls); }); - test('should authenticate all URLs in list', () { + test('should return all URLs unmodified (auth via headers now)', () { final urls = ['https://example.com/1.jpg', 'https://example.com/2.jpg']; final result = authenticateImageUrls(urls, 'my_api_key'); expect(result.length, 2); - expect(result[0], 'https://example.com/1.jpg?api_key=my_api_key'); - expect(result[1], 'https://example.com/2.jpg?api_key=my_api_key'); + expect(result[0], 'https://example.com/1.jpg'); + expect(result[1], 'https://example.com/2.jpg'); }); test('should handle empty list', () { diff --git a/test/features/devices/data/services/rest_image_upload_service_test.dart b/test/features/devices/data/services/rest_image_upload_service_test.dart index a1703e6..a200722 100644 --- a/test/features/devices/data/services/rest_image_upload_service_test.dart +++ b/test/features/devices/data/services/rest_image_upload_service_test.dart @@ -72,8 +72,8 @@ void main() { test('should return signed IDs from response', () async { const deviceId = '123'; const resourceType = 'access_points'; - final url = - 'https://example.rgnetworks.com/api/$resourceType/$deviceId.json?api_key=$testApiKey'; + final expectedUrl = + 'https://example.rgnetworks.com/api/$resourceType/$deviceId.json'; final responseData = { 'images': [ @@ -84,9 +84,14 @@ void main() { ], }; - when(() => mockDio.get>(any())).thenAnswer( + when( + () => mockDio.get>( + any(), + options: any(named: 'options'), + ), + ).thenAnswer( (_) async => buildResponse( - url: url, + url: expectedUrl, statusCode: 200, data: responseData, ), @@ -100,18 +105,23 @@ void main() { expect(result, ['signed_1', 'signed_2']); final captured = verify( - () => mockDio.get>(captureAny()), + () => mockDio.get>( + captureAny(), + options: captureAny(named: 'options'), + ), ).captured; - final capturedUrl = captured.first as String; - expect(capturedUrl, url); + final capturedUrl = captured[0] as String; + final capturedOptions = captured[1] as Options; + expect(capturedUrl, expectedUrl); + expect(capturedOptions.headers?['X-API-Key'], testApiKey); }); test('should return empty list on DioException', () async { const deviceId = '123'; const resourceType = 'access_points'; final url = - 'https://example.rgnetworks.com/api/$resourceType/$deviceId.json?api_key=$testApiKey'; + 'https://example.rgnetworks.com/api/$resourceType/$deviceId.json'; final exception = DioException( type: DioExceptionType.connectionError, @@ -119,7 +129,12 @@ void main() { message: 'Connection failed', ); - when(() => mockDio.get>(any())).thenThrow(exception); + when( + () => mockDio.get>( + any(), + options: any(named: 'options'), + ), + ).thenThrow(exception); final result = await service.fetchCurrentSignedIds( resourceType: resourceType, @@ -179,10 +194,11 @@ void main() { final capturedOptions = captured[2] as Options; expect(capturedUrl, contains('/api/access_points/123.json')); - expect(capturedUrl, contains('api_key=$testApiKey')); + expect(capturedUrl, isNot(contains('api_key='))); expect(capturedBody['images'], equals(images)); expect(capturedOptions.contentType, equals('application/json')); expect(capturedOptions.responseType, equals(ResponseType.json)); + expect(capturedOptions.headers?['X-API-Key'], testApiKey); }); test('should send PUT request to correct endpoint for media_converters',