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/<version> (+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 <noreply@anthropic.com>
This commit is contained in:
Doug Borg
2026-02-24 19:31:27 -07:00
parent fe20356734
commit 0137fd66aa
16 changed files with 177 additions and 43 deletions
+8 -5
View File
@@ -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();
}
}
+2
View File
@@ -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
+8 -6
View File
@@ -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;
});
+4 -2
View File
@@ -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<String?> _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'},
);
+34
View File
@@ -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<http.StreamedResponse> send(http.BaseRequest request) {
request.headers.putIfAbsent('User-Agent', () => userAgent);
return _inner.send(request);
}
@override
void close() => _inner.close();
}
@@ -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<List<OsmNode>> fetchOsmApiNodes({
}
}
final _client = UserAgentClient();
/// Internal method that performs the actual OSM API fetch.
Future<List<OsmNode>> _fetchFromOsmApi({
required LatLngBounds bounds,
@@ -57,7 +59,7 @@ Future<List<OsmNode>> _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}');
@@ -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<List<int>> 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!
+3 -6
View File
@@ -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}');
+3 -2
View File
@@ -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'},
);
+4 -2
View File
@@ -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.
+2 -3
View File
@@ -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<LatLng> 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)
+3 -8
View File
@@ -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<List<SearchResult>> 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}');
+3 -3
View File
@@ -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) {
+3 -2
View File
@@ -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}');
+2
View File
@@ -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<String, String> get _headers => {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'text/xml',
'User-Agent': UserAgentClient.userAgent,
};
/// Sanitize text for safe inclusion in XML attributes and content
+90
View File
@@ -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<String, String>? 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'));
});
});
}