From 0137fd66aa255dc76a3d484a43ce81115ea4115d Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Tue, 24 Feb 2026 19:31:27 -0700 Subject: [PATCH] Add consistent User-Agent header to all HTTP clients Create UserAgentClient (http.BaseClient wrapper) that injects a User-Agent header into every request, reading app name and version from VersionService and contact/homepage from dev_config.dart. Format follows OSM tile usage policy: DeFlock/ (+https://deflock.org; contact: admin@stopflock.com) Replaces 4 inconsistent hardcoded UA strings and adds UA to the 9 call sites that previously sent none. Co-Authored-By: Claude Opus 4.6 --- lib/app_state.dart | 13 +-- lib/dev_config.dart | 2 + lib/screens/tile_provider_editor_screen.dart | 14 +-- lib/services/auth_service.dart | 6 +- lib/services/http_client.dart | 34 +++++++ .../nodes_from_osm_api.dart | 6 +- .../tiles_from_remote.dart | 6 +- lib/services/nsi_service.dart | 9 +- lib/services/osm_messages_service.dart | 5 +- lib/services/overpass_service.dart | 6 +- lib/services/routing_service.dart | 5 +- lib/services/search_service.dart | 11 +-- lib/services/suspected_location_service.dart | 6 +- lib/services/tile_preview_service.dart | 5 +- lib/services/uploader.dart | 2 + test/services/http_client_test.dart | 90 +++++++++++++++++++ 16 files changed, 177 insertions(+), 43 deletions(-) create mode 100644 lib/services/http_client.dart create mode 100644 test/services/http_client_test.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index 3f41450..0e8ae09 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; -import 'package:http/http.dart' as http; +import 'services/http_client.dart'; import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -335,29 +335,32 @@ class AppState extends ChangeNotifier { final accessToken = await _authState.getAccessToken(); if (accessToken == null) return false; + final client = UserAgentClient(); try { // Try to fetch user details - this should include message data if scope is correct - final response = await http.get( + final response = await client.get( Uri.parse('${_getApiHost()}/api/0.6/user/details.json'), headers: {'Authorization': 'Bearer $accessToken'}, ); - + if (response.statusCode == 403) { // Forbidden - likely missing scope return true; } - + if (response.statusCode == 200) { final data = jsonDecode(response.body); final messages = data['user']?['messages']; // If messages field is missing, we might not have the right scope return messages == null; } - + return false; } catch (e) { // On error, assume no re-auth needed to avoid annoying users return false; + } finally { + client.close(); } } diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 729efff..0c495c7 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -52,6 +52,8 @@ double topPositionWithSafeArea(double baseTop, EdgeInsets safeArea) { // Client name for OSM uploads ("created_by" tag) const String kClientName = 'DeFlock'; // Note: Version is now dynamically retrieved from VersionService +const String kContactEmail = 'admin@stopflock.com'; +const String kHomepageUrl = 'https://deflock.org'; // Upload and changeset configuration const Duration kUploadHttpTimeout = Duration(seconds: 30); // HTTP request timeout for uploads diff --git a/lib/screens/tile_provider_editor_screen.dart b/lib/screens/tile_provider_editor_screen.dart index 213090a..d833700 100644 --- a/lib/screens/tile_provider_editor_screen.dart +++ b/lib/screens/tile_provider_editor_screen.dart @@ -1,10 +1,10 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:http/http.dart' as http; import '../app_state.dart'; import '../models/tile_provider.dart'; +import '../services/http_client.dart'; import '../services/localization_service.dart'; import '../dev_config.dart'; @@ -407,6 +407,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { _isLoadingPreview = true; }); + final client = UserAgentClient(); try { // Create a temporary TileType to use the getTileUrl method final tempTileType = TileType( @@ -415,21 +416,21 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { urlTemplate: _urlController.text.trim(), attribution: 'Preview', ); - + final url = tempTileType.getTileUrl( kPreviewTileZoom, kPreviewTileX, kPreviewTileY, apiKey: null, // Don't use API key for preview ); - - final response = await http.get(Uri.parse(url)); - + + final response = await client.get(Uri.parse(url)); + if (response.statusCode == 200) { setState(() { _previewTile = response.bodyBytes; }); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(locService.t('tileTypeEditor.previewTileLoaded'))), @@ -445,6 +446,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { ); } } finally { + client.close(); setState(() { _isLoadingPreview = false; }); diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 8aa94bb..481be1c 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -4,12 +4,12 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:oauth2_client/oauth2_client.dart'; import 'package:oauth2_client/oauth2_helper.dart'; -import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; /// Handles PKCE OAuth login with OpenStreetMap. import '../keys.dart'; import '../app_state.dart' show UploadMode; +import 'http_client.dart'; class AuthService { // Both client IDs from keys.dart @@ -178,9 +178,11 @@ class AuthService { : 'https://api.openstreetmap.org'; } + final _client = UserAgentClient(); + Future _fetchUsername(String accessToken) async { try { - final resp = await http.get( + final resp = await _client.get( Uri.parse('$_apiHost/api/0.6/user/details.json'), headers: {'Authorization': 'Bearer $accessToken'}, ); diff --git a/lib/services/http_client.dart b/lib/services/http_client.dart new file mode 100644 index 0000000..f2db650 --- /dev/null +++ b/lib/services/http_client.dart @@ -0,0 +1,34 @@ +import 'package:http/http.dart' as http; + +import '../dev_config.dart'; +import 'version_service.dart'; + +/// An [http.BaseClient] that injects a User-Agent header into every request. +/// +/// Reads the app name and version dynamically from [VersionService] so the UA +/// string stays in sync with pubspec.yaml without hard-coding values. +/// +/// Uses [putIfAbsent] so a manually-set User-Agent is never overwritten. +class UserAgentClient extends http.BaseClient { + final http.Client _inner; + + UserAgentClient([http.Client? inner]) : _inner = inner ?? http.Client(); + + /// The User-Agent string sent with every request. + /// + /// Format follows OSM tile usage policy recommendations: + /// `AppName/version (+homepage; contact: email)` + static String get userAgent { + final vs = VersionService(); + return '${vs.appName}/${vs.version} (+$kHomepageUrl; contact: $kContactEmail)'; + } + + @override + Future send(http.BaseRequest request) { + request.headers.putIfAbsent('User-Agent', () => userAgent); + return _inner.send(request); + } + + @override + void close() => _inner.close(); +} diff --git a/lib/services/map_data_submodules/nodes_from_osm_api.dart b/lib/services/map_data_submodules/nodes_from_osm_api.dart index 723c16b..342abf6 100644 --- a/lib/services/map_data_submodules/nodes_from_osm_api.dart +++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart @@ -1,4 +1,3 @@ -import 'package:http/http.dart' as http; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; @@ -7,6 +6,7 @@ import 'package:xml/xml.dart'; import '../../models/node_profile.dart'; import '../../models/osm_node.dart'; import '../../app_state.dart'; +import '../http_client.dart'; /// Fetches surveillance nodes from the direct OSM API using bbox query. /// This is a fallback for when Overpass is not available (e.g., sandbox mode). @@ -33,6 +33,8 @@ Future> fetchOsmApiNodes({ } } +final _client = UserAgentClient(); + /// Internal method that performs the actual OSM API fetch. Future> _fetchFromOsmApi({ required LatLngBounds bounds, @@ -57,7 +59,7 @@ Future> _fetchFromOsmApi({ debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...'); debugPrint('[fetchOsmApiNodes] URL: $url'); - final response = await http.get(Uri.parse(url)); + final response = await _client.get(Uri.parse(url)); if (response.statusCode != 200) { debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}'); diff --git a/lib/services/map_data_submodules/tiles_from_remote.dart b/lib/services/map_data_submodules/tiles_from_remote.dart index f7797eb..e3e1f1f 100644 --- a/lib/services/map_data_submodules/tiles_from_remote.dart +++ b/lib/services/map_data_submodules/tiles_from_remote.dart @@ -1,11 +1,11 @@ import 'dart:math'; import 'dart:io'; import 'dart:async'; -import 'package:http/http.dart' as http; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:deflockapp/dev_config.dart'; +import 'package:deflockapp/services/http_client.dart'; /// Global semaphore to limit simultaneous tile fetches final _tileFetchSemaphore = _SimpleSemaphore(kTileFetchConcurrentThreads); @@ -92,6 +92,8 @@ bool _isTileVisible(int z, int x, int y, LatLngBounds viewBounds) { +final _tileClient = UserAgentClient(); + /// Fetches a tile from any remote provider with unlimited retries. /// Returns tile image bytes. Retries forever until success. /// Brutalist approach: Keep trying until it works - no arbitrary retry limits. @@ -113,7 +115,7 @@ Future> fetchRemoteTile({ debugPrint('[fetchRemoteTile] Fetching $z/$x/$y from $hostInfo'); } attempt++; - final resp = await http.get(Uri.parse(url)); + final resp = await _tileClient.get(Uri.parse(url)); if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) { // Success! diff --git a/lib/services/nsi_service.dart b/lib/services/nsi_service.dart index 79f63e1..78f033d 100644 --- a/lib/services/nsi_service.dart +++ b/lib/services/nsi_service.dart @@ -1,9 +1,9 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; import '../app_state.dart'; import '../dev_config.dart'; +import 'http_client.dart'; /// Service for fetching tag value suggestions from OpenStreetMap Name Suggestion Index class NSIService { @@ -11,7 +11,7 @@ class NSIService { factory NSIService() => _instance; NSIService._(); - static const String _userAgent = 'DeFlock/2.1.0 (OSM surveillance mapping app)'; + final _client = UserAgentClient(); static const Duration _timeout = Duration(seconds: 10); // Cache to avoid repeated API calls @@ -55,10 +55,7 @@ class NSIService { 'rp': '15', // Get top 15 most commonly used values }); - final response = await http.get( - uri, - headers: {'User-Agent': _userAgent}, - ).timeout(_timeout); + final response = await _client.get(uri).timeout(_timeout); if (response.statusCode != 200) { throw Exception('TagInfo API returned status ${response.statusCode}'); diff --git a/lib/services/osm_messages_service.dart b/lib/services/osm_messages_service.dart index 5bbde87..31cd55e 100644 --- a/lib/services/osm_messages_service.dart +++ b/lib/services/osm_messages_service.dart @@ -1,10 +1,11 @@ import 'dart:convert'; -import 'package:http/http.dart' as http; import '../state/settings_state.dart'; +import 'http_client.dart'; /// Service for checking OSM user messages class OSMMessagesService { static const _messageCheckCacheDuration = Duration(minutes: 5); + final _client = UserAgentClient(); DateTime? _lastCheck; int? _lastUnreadCount; @@ -38,7 +39,7 @@ class OSMMessagesService { try { final apiHost = _getApiHost(uploadMode); - final response = await http.get( + final response = await _client.get( Uri.parse('$apiHost/api/0.6/user/details.json'), headers: {'Authorization': 'Bearer $accessToken'}, ); diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 54f5d43..0d0f7ca 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -1,12 +1,13 @@ import 'dart:convert'; -import 'package:http/http.dart' as http; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; import '../models/node_profile.dart'; import '../models/osm_node.dart'; import '../dev_config.dart'; +import 'http_client.dart'; /// Simple Overpass API client with proper HTTP retry logic. /// Single responsibility: Make requests, handle network errors, return data. @@ -14,7 +15,8 @@ class OverpassService { static const String _endpoint = 'https://overpass-api.de/api/interpreter'; final http.Client _client; - OverpassService({http.Client? client}) : _client = client ?? http.Client(); + OverpassService({http.Client? client}) : _client = client ?? UserAgentClient(); + /// Fetch surveillance nodes from Overpass API with proper retry logic. /// Throws NetworkError for retryable failures, NodeLimitError for area splitting. diff --git a/lib/services/routing_service.dart b/lib/services/routing_service.dart index af0b8b3..3163f49 100644 --- a/lib/services/routing_service.dart +++ b/lib/services/routing_service.dart @@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../app_state.dart'; import '../dev_config.dart'; +import 'http_client.dart'; class RouteResult { final List waypoints; @@ -26,10 +27,9 @@ class RouteResult { class RoutingService { static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions'; - static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)'; final http.Client _client; - RoutingService({http.Client? client}) : _client = client ?? http.Client(); + RoutingService({http.Client? client}) : _client = client ?? UserAgentClient(); void close() => _client.close(); @@ -75,7 +75,6 @@ class RoutingService { final response = await _client.post( uri, headers: { - 'User-Agent': _userAgent, 'Content-Type': 'application/json' }, body: json.encode(params) diff --git a/lib/services/search_service.dart b/lib/services/search_service.dart index 95e46ca..8ba0e40 100644 --- a/lib/services/search_service.dart +++ b/lib/services/search_service.dart @@ -1,16 +1,16 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; -import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; import '../models/search_result.dart'; +import 'http_client.dart'; class SearchService { static const String _baseUrl = 'https://nominatim.openstreetmap.org'; - static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)'; static const int _maxResults = 5; static const Duration _timeout = Duration(seconds: 10); + final _client = UserAgentClient(); /// Search for places using Nominatim geocoding service Future> search(String query, {LatLngBounds? viewbox}) async { @@ -88,12 +88,7 @@ class SearchService { debugPrint('[SearchService] Searching Nominatim: $uri'); try { - final response = await http.get( - uri, - headers: { - 'User-Agent': _userAgent, - }, - ).timeout(_timeout); + final response = await _client.get(uri).timeout(_timeout); if (response.statusCode != 200) { throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}'); diff --git a/lib/services/suspected_location_service.dart b/lib/services/suspected_location_service.dart index 8f57f33..2d8ee87 100644 --- a/lib/services/suspected_location_service.dart +++ b/lib/services/suspected_location_service.dart @@ -7,6 +7,7 @@ import 'package:csv/csv.dart'; import '../dev_config.dart'; import '../models/suspected_location.dart'; +import 'http_client.dart'; import 'suspected_location_cache.dart'; class SuspectedLocationService { @@ -112,9 +113,8 @@ class SuspectedLocationService { // Use streaming download for progress tracking final request = http.Request('GET', Uri.parse(kSuspectedLocationsCsvUrl)); - request.headers['User-Agent'] = 'DeFlock/1.0 (OSM surveillance mapping app)'; - - final client = http.Client(); + + final client = UserAgentClient(); final streamedResponse = await client.send(request).timeout(_timeout); if (streamedResponse.statusCode != 200) { diff --git a/lib/services/tile_preview_service.dart b/lib/services/tile_preview_service.dart index 3f88606..ea94043 100644 --- a/lib/services/tile_preview_service.dart +++ b/lib/services/tile_preview_service.dart @@ -1,13 +1,14 @@ import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; import '../models/tile_provider.dart'; import '../state/settings_state.dart'; import '../dev_config.dart'; +import 'http_client.dart'; /// Service for fetching missing tile preview images class TilePreviewService { static const Duration _timeout = Duration(seconds: 10); + static final _client = UserAgentClient(); /// Attempt to fetch missing preview tiles for tile types that don't already have preview data /// Fails silently - no error handling or user notification on failure @@ -62,7 +63,7 @@ class TilePreviewService { try { final url = tileType.getTileUrl(kPreviewTileZoom, kPreviewTileX, kPreviewTileY, apiKey: apiKey); - final response = await http.get(Uri.parse(url)).timeout(_timeout); + final response = await _client.get(Uri.parse(url)).timeout(_timeout); if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) { debugPrint('TilePreviewService: Fetched preview for ${tileType.name}'); diff --git a/lib/services/uploader.dart b/lib/services/uploader.dart index 636a795..21d4863 100644 --- a/lib/services/uploader.dart +++ b/lib/services/uploader.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import '../models/pending_upload.dart'; import '../dev_config.dart'; import '../state/settings_state.dart'; +import 'http_client.dart'; import 'version_service.dart'; class UploadResult { @@ -348,6 +349,7 @@ class Uploader { Map get _headers => { 'Authorization': 'Bearer $accessToken', 'Content-Type': 'text/xml', + 'User-Agent': UserAgentClient.userAgent, }; /// Sanitize text for safe inclusion in XML attributes and content diff --git a/test/services/http_client_test.dart b/test/services/http_client_test.dart new file mode 100644 index 0000000..abbca0f --- /dev/null +++ b/test/services/http_client_test.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:deflockapp/dev_config.dart'; +import 'package:deflockapp/services/http_client.dart'; + +void main() { + group('UserAgentClient', () { + test('adds User-Agent header to GET requests', () async { + String? capturedUserAgent; + + final inner = MockClient((request) async { + capturedUserAgent = request.headers['User-Agent']; + return http.Response('ok', 200); + }); + + final client = UserAgentClient(inner); + await client.get(Uri.parse('https://example.com')); + + expect(capturedUserAgent, isNotNull); + expect(capturedUserAgent, startsWith('DeFlock/')); + expect(capturedUserAgent, contains('+$kHomepageUrl')); + expect(capturedUserAgent, contains('contact: $kContactEmail')); + }); + + test('adds User-Agent header to POST requests', () async { + String? capturedUserAgent; + + final inner = MockClient((request) async { + capturedUserAgent = request.headers['User-Agent']; + return http.Response('ok', 200); + }); + + final client = UserAgentClient(inner); + await client.post( + Uri.parse('https://example.com'), + body: json.encode({'key': 'value'}), + ); + + expect(capturedUserAgent, isNotNull); + expect(capturedUserAgent, startsWith('DeFlock/')); + }); + + test('preserves existing headers alongside User-Agent', () async { + Map? capturedHeaders; + + final inner = MockClient((request) async { + capturedHeaders = request.headers; + return http.Response('ok', 200); + }); + + final client = UserAgentClient(inner); + await client.get( + Uri.parse('https://example.com'), + headers: {'Authorization': 'Bearer token123'}, + ); + + expect(capturedHeaders, isNotNull); + expect(capturedHeaders!['Authorization'], equals('Bearer token123')); + expect(capturedHeaders!['User-Agent'], startsWith('DeFlock/')); + }); + + test('does not overwrite a manually-set User-Agent', () async { + String? capturedUserAgent; + + final inner = MockClient((request) async { + capturedUserAgent = request.headers['User-Agent']; + return http.Response('ok', 200); + }); + + final client = UserAgentClient(inner); + await client.get( + Uri.parse('https://example.com'), + headers: {'User-Agent': 'CustomAgent/1.0'}, + ); + + expect(capturedUserAgent, equals('CustomAgent/1.0')); + }); + + test('static userAgent getter returns expected format', () { + final ua = UserAgentClient.userAgent; + expect(ua, startsWith('DeFlock/')); + expect(ua, contains('+$kHomepageUrl')); + expect(ua, contains('contact: $kContactEmail')); + }); + }); +}