Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/core/providers/core_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ final authenticatedImageUrlsProvider = Provider<List<String> Function(List<Strin
return (List<String> 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<Map<String, String>>((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<ProviderContainer> initializeProviders() async {
final sharedPreferences = await SharedPreferences.getInstance();
Expand Down
7 changes: 5 additions & 2 deletions lib/core/services/secure_http_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,16 @@ class SecureHttpClient {
static Future<bool> 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'),
Expand Down
64 changes: 9 additions & 55 deletions lib/core/utils/image_url_normalizer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>.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<String> authenticateImageUrls(List<String> imageUrls, String? apiKey) {
if (apiKey == null || apiKey.isEmpty) {
return imageUrls;
}
return imageUrls
.map((url) => authenticateImageUrl(url, apiKey))
.whereType<String>()
.toList();
return imageUrls;
}

/// Strips the api_key query parameter from a URL.
Expand Down
2 changes: 2 additions & 0 deletions lib/features/auth/presentation/providers/auth_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,8 @@ Uri _buildActionCableUri({
);

final queryParameters = Map<String, String>.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;
}
Expand Down
18 changes: 12 additions & 6 deletions lib/features/devices/data/services/rest_image_upload_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,18 @@ 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',
tag: 'RestImageUploadService',
);

try {
final response = await _dio.get<Map<String, dynamic>>(url);
final response = await _dio.get<Map<String, dynamic>>(
url,
options: Options(headers: {'X-API-Key': _apiKey}),
);

if (response.statusCode == 200 && response.data != null) {
final images = response.data!['images'];
Expand Down Expand Up @@ -107,15 +110,18 @@ 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',
tag: 'RestImageUploadService',
);

try {
final response = await _dio.get<Map<String, dynamic>>(url);
final response = await _dio.get<Map<String, dynamic>>(
url,
options: Options(headers: {'X-API-Key': _apiKey}),
);

if (response.statusCode == 200 && response.data != null) {
LoggerService.debug(
Expand Down Expand Up @@ -184,13 +190,12 @@ class RestImageUploadService {
required List<String> 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;
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -317,6 +316,7 @@ class DeviceDetailSections extends ConsumerWidget {
height: 120,
child: CachedNetworkImage(
imageUrl: imageUrl,
httpHeaders: authHeaders,
fit: BoxFit.cover,
memCacheWidth: 240,
memCacheHeight: 240,
Expand Down Expand Up @@ -450,11 +450,9 @@ class DeviceDetailSections extends ConsumerWidget {
BuildContext context,
WidgetRef ref,
List<String> images,
Map<String, String> 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<void>(
Expand All @@ -463,16 +461,14 @@ 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) {
onImageDeletedBySignedId!(signedIds[index]);
}
}
: null,
apiKey: apiKey,
httpHeaders: authHeaders,
),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,16 @@ class ImageViewerDialog extends StatefulWidget {
required this.images,
required this.initialIndex,
this.onDeleteAtIndex,
this.apiKey,
this.httpHeaders,
super.key,
});

final List<String> images;
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<String, String>? httpHeaders;

@override
State<ImageViewerDialog> createState() => _ImageViewerDialogState();
Expand Down Expand Up @@ -107,6 +106,7 @@ class _ImageViewerDialogState extends State<ImageViewerDialog> {
child: Center(
child: CachedNetworkImage(
imageUrl: widget.images[index],
httpHeaders: widget.httpHeaders,
fit: BoxFit.contain,
memCacheWidth: cacheWidth,
memCacheHeight: cacheHeight,
Expand Down
30 changes: 15 additions & 15 deletions test/core/utils/image_url_normalizer_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -187,58 +187,58 @@ 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',
);
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',
);
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 ');
});
});

Expand All @@ -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', () {
Expand Down
Loading