791 lines
38 KiB
Markdown
791 lines
38 KiB
Markdown
# Architecture Document: Privacy-First Maps Application
|
|
|
|
**Version:** 1.0.0
|
|
**Date:** 2026-03-29
|
|
**Status:** Ready for development review
|
|
|
|
---
|
|
|
|
## 1. High-Level Architecture
|
|
|
|
```
|
|
+-----------------------+
|
|
| Mobile App |
|
|
| (Flutter / Dart) |
|
|
| |
|
|
| +-------+ +--------+ |
|
|
| |MapLibre| |Drift DB| |
|
|
| |GL Native| (SQLite)| |
|
|
| +-------+ +--------+ |
|
|
+-----------+-----------+
|
|
|
|
|
TLS 1.2+ only
|
|
|
|
|
+-----------v-----------+
|
|
| Rust API Gateway |
|
|
| (Actix-web) |
|
|
| |
|
|
| /tiles/* /api/search |
|
|
| /api/route /api/pois |
|
|
| /api/offline /api/health
|
|
+-+------+------+------+-+
|
|
| | | |
|
|
+------------+ +---+--+ +-+----+ +----------+
|
|
| | | | | |
|
|
+------v------+ +-----v----+ +v------v-+ +--------v--------+
|
|
| Martin | | Photon | | OSRM | | PostgreSQL |
|
|
| Tile Server | | Geocoder | | x3 | | + PostGIS |
|
|
| (Rust) | | (Java) | | profiles | | |
|
|
+------+------+ +----------+ +----------+ +-----------------+
|
|
| ^
|
|
+-------------------------------------------+
|
|
(reads tile data from PostGIS)
|
|
|
|
+-------------------+
|
|
| Redis |
|
|
| (response cache) |
|
|
+-------------------+
|
|
|
|
+-----------------------------+
|
|
| Caddy / nginx |
|
|
| (TLS termination, |
|
|
| reverse proxy) |
|
|
+-----------------------------+
|
|
```
|
|
|
|
**Data flow summary:**
|
|
1. The mobile app connects exclusively to the Rust API Gateway over TLS.
|
|
2. The gateway routes requests to the appropriate upstream service.
|
|
3. Martin reads vector tile data from PostGIS.
|
|
4. Photon provides geocoding from its own Elasticsearch-backed index (built from OSM/Nominatim).
|
|
5. OSRM provides routing from preprocessed OSM graph data (3 separate instances).
|
|
6. The gateway directly queries PostGIS for POI data.
|
|
7. Redis caches frequently accessed responses (tiles, POI queries, region metadata).
|
|
|
|
---
|
|
|
|
## 2. Component Responsibilities
|
|
|
|
### 2.1 Mobile App (Flutter)
|
|
- Renders vector map tiles via MapLibre GL Native.
|
|
- Manages on-device state: search history, favorites, offline regions.
|
|
- Handles all user interactions: gestures, search, routing, POI browsing.
|
|
- Implements offline mode using locally stored MBTiles, OSRM data, and POI SQLite databases.
|
|
- Enforces TLS-only communication; no third-party network calls.
|
|
|
|
### 2.2 Rust API Gateway (Actix-web)
|
|
- Single entry point for all mobile app requests.
|
|
- Proxies tile requests to Martin.
|
|
- Proxies search/reverse-geocoding requests to Photon.
|
|
- Proxies routing requests to the correct OSRM instance based on profile.
|
|
- Serves POI data directly by querying PostGIS.
|
|
- Serves offline data packages (tiles, routing data, POI extracts).
|
|
- Performs input validation, rate limiting, CORS, and health checks.
|
|
- Implements Redis caching layer.
|
|
- Logs requests without PII (no IPs, no query strings, no coordinates).
|
|
|
|
### 2.3 Martin (Tile Server)
|
|
- Serves Mapbox Vector Tiles (MVT) from PostGIS.
|
|
- Serves the `style.json` used by MapLibre GL to configure rendering.
|
|
- Connected directly to PostGIS; reads from `openmaptiles`-schema tables.
|
|
|
|
### 2.4 Photon (Geocoder)
|
|
- Forward geocoding: text query to coordinates + metadata.
|
|
- Reverse geocoding: coordinates to address/place.
|
|
- Stateless: does not log queries.
|
|
- Backed by Elasticsearch index built from Nominatim/OSM data.
|
|
|
|
### 2.5 OSRM (Routing Engine)
|
|
- Three separate instances, one per profile: `driving`, `walking`, `cycling`.
|
|
- Each uses a profile-specific graph preprocessed from OSM PBF data.
|
|
- Returns routes with geometry, distance, duration, and turn-by-turn steps.
|
|
- Supports alternative routes (up to 3).
|
|
|
|
### 2.6 PostgreSQL + PostGIS
|
|
- Stores OSM-imported geographic data used by Martin for tile generation.
|
|
- Stores POI data queried directly by the Rust gateway.
|
|
- Spatial indexes enable efficient bounding-box and proximity queries.
|
|
|
|
### 2.7 Redis
|
|
- Caches tile responses, POI bounding-box query results, and region metadata.
|
|
- Reduces load on PostGIS and upstream services.
|
|
|
|
---
|
|
|
|
## 3. Tech Stack Rationale
|
|
|
|
| Choice | Rationale |
|
|
|---|---|
|
|
| **Flutter** | Single codebase for Android and iOS. Strong ecosystem for maps (`maplibre_gl`). Dart compiles to native ARM, achieving 60fps rendering. Large community reduces hiring risk. |
|
|
| **MapLibre GL Native** | Open-source fork of Mapbox GL Native. No telemetry, no API key required. Supports MVT vector tiles, client-side styling, and offline MBTiles. The privacy constraint eliminates Mapbox SDK. |
|
|
| **Riverpod** | Compile-safe, testable state management. Better than `bloc` for this use case because map state is inherently reactive (viewport changes, location updates, search results). Riverpod's provider model fits naturally with dependency injection. |
|
|
| **Drift** | Type-safe SQLite wrapper for Dart. Supports migrations, DAOs, and complex queries. Better than raw `sqflite` for maintainability. Compiles queries at build time. |
|
|
| **Dio** | Full-featured HTTP client with interceptor support (for TLS enforcement, logging, caching headers). No third-party interceptors are used. |
|
|
| **Rust + Actix-web** | Memory-safe, high-performance API gateway. No garbage collection pauses. Actix-web is the fastest Rust web framework by most benchmarks. Async runtime (Tokio) handles thousands of concurrent connections efficiently. Binary deployment (no JVM, no runtime). |
|
|
| **Martin** | Written in Rust (same ecosystem as the gateway). Serves tiles directly from PostGIS with minimal configuration. Supports MBTiles and PMTiles. Actively maintained. |
|
|
| **Photon** | Purpose-built for OSM geocoding. Better OSM coverage than Nominatim's API layer. Supports proximity-biased results. Self-hostable with no external dependencies beyond its Elasticsearch index. |
|
|
| **OSRM** | Industry-standard open-source routing. Sub-second query times for continental distances. Well-documented API. Multiple profile support. Used by many OSM-based apps (known quantity). |
|
|
| **PostGIS** | The standard for geospatial databases. Mature, well-indexed spatial queries. Native support in Martin. Ecosystem of import tools (`osm2pgsql`, `imposm3`). |
|
|
| **Redis** | In-memory cache with TTL support. Simple, fast, well-understood. Reduces repeated expensive PostGIS queries. |
|
|
| **Docker Compose** | Appropriate for self-hosted single-server deployment. All services defined declaratively. Easy for the target audience (self-hosters) to deploy and manage. Kubernetes would be overkill for the typical deployment scenario. |
|
|
|
|
---
|
|
|
|
## 4. Mobile App Architecture
|
|
|
|
### 4.1 Folder Structure
|
|
|
|
```
|
|
lib/
|
|
├── main.dart # App entry point, ProviderScope
|
|
├── app.dart # MaterialApp, router config, theme
|
|
├── core/
|
|
│ ├── config/
|
|
│ │ ├── app_config.dart # Backend URL, defaults
|
|
│ │ └── env.dart # Environment-specific config
|
|
│ ├── constants/
|
|
│ │ ├── map_constants.dart # Zoom levels, default location
|
|
│ │ └── api_constants.dart # Endpoint paths, timeouts
|
|
│ ├── error/
|
|
│ │ ├── app_exception.dart # Base exception hierarchy
|
|
│ │ ├── error_handler.dart # Global error handling
|
|
│ │ └── failure.dart # Failure types for Either pattern
|
|
│ ├── network/
|
|
│ │ ├── dio_client.dart # Dio instance, TLS config
|
|
│ │ ├── api_interceptor.dart # Request/response logging (no PII)
|
|
│ │ └── connectivity.dart # Online/offline detection
|
|
│ ├── theme/
|
|
│ │ ├── app_theme.dart # Day/night Material themes
|
|
│ │ ├── map_styles.dart # MapLibre style JSON references
|
|
│ │ └── colors.dart
|
|
│ └── utils/
|
|
│ ├── debouncer.dart
|
|
│ ├── coordinate_utils.dart
|
|
│ └── opening_hours_parser.dart
|
|
├── features/
|
|
│ ├── map/
|
|
│ │ ├── data/
|
|
│ │ │ ├── repositories/
|
|
│ │ │ │ └── tile_repository.dart
|
|
│ │ │ └── datasources/
|
|
│ │ │ ├── tile_remote_source.dart
|
|
│ │ │ └── tile_cache_source.dart # MBTiles SQLite
|
|
│ │ ├── domain/
|
|
│ │ │ ├── models/
|
|
│ │ │ │ ├── map_position.dart
|
|
│ │ │ │ └── map_marker.dart
|
|
│ │ │ └── repositories/
|
|
│ │ │ └── tile_repository.dart # Abstract interface
|
|
│ │ ├── presentation/
|
|
│ │ │ ├── providers/
|
|
│ │ │ │ ├── map_controller_provider.dart
|
|
│ │ │ │ ├── location_provider.dart
|
|
│ │ │ │ └── theme_provider.dart
|
|
│ │ │ ├── widgets/
|
|
│ │ │ │ ├── map_view.dart
|
|
│ │ │ │ ├── compass_button.dart
|
|
│ │ │ │ ├── zoom_controls.dart
|
|
│ │ │ │ ├── attribution_widget.dart
|
|
│ │ │ │ └── location_button.dart
|
|
│ │ │ └── screens/
|
|
│ │ │ └── map_screen.dart
|
|
│ │ └── map_providers.dart # Feature-level provider definitions
|
|
│ ├── search/
|
|
│ │ ├── data/
|
|
│ │ │ ├── repositories/
|
|
│ │ │ │ └── search_repository.dart
|
|
│ │ │ ├── datasources/
|
|
│ │ │ │ ├── photon_remote_source.dart
|
|
│ │ │ │ ├── search_history_local_source.dart
|
|
│ │ │ │ └── offline_search_source.dart
|
|
│ │ │ └── models/
|
|
│ │ │ └── search_result_dto.dart
|
|
│ │ ├── domain/
|
|
│ │ │ ├── models/
|
|
│ │ │ │ ├── search_result.dart
|
|
│ │ │ │ └── search_history_item.dart
|
|
│ │ │ └── repositories/
|
|
│ │ │ └── search_repository.dart
|
|
│ │ ├── presentation/
|
|
│ │ │ ├── providers/
|
|
│ │ │ │ ├── search_provider.dart
|
|
│ │ │ │ └── search_history_provider.dart
|
|
│ │ │ ├── widgets/
|
|
│ │ │ │ ├── search_bar.dart
|
|
│ │ │ │ ├── search_result_tile.dart
|
|
│ │ │ │ └── recent_searches_list.dart
|
|
│ │ │ └── screens/
|
|
│ │ │ └── search_screen.dart
|
|
│ │ └── search_providers.dart
|
|
│ ├── routing/
|
|
│ │ ├── data/
|
|
│ │ │ ├── repositories/
|
|
│ │ │ │ └── routing_repository.dart
|
|
│ │ │ ├── datasources/
|
|
│ │ │ │ ├── osrm_remote_source.dart
|
|
│ │ │ │ └── offline_routing_source.dart
|
|
│ │ │ └── models/
|
|
│ │ │ └── route_dto.dart
|
|
│ │ ├── domain/
|
|
│ │ │ ├── models/
|
|
│ │ │ │ ├── route.dart
|
|
│ │ │ │ ├── route_step.dart
|
|
│ │ │ │ ├── maneuver.dart
|
|
│ │ │ │ └── route_profile.dart
|
|
│ │ │ └── repositories/
|
|
│ │ │ └── routing_repository.dart
|
|
│ │ ├── presentation/
|
|
│ │ │ ├── providers/
|
|
│ │ │ │ ├── routing_provider.dart
|
|
│ │ │ │ ├── navigation_provider.dart
|
|
│ │ │ │ └── reroute_provider.dart
|
|
│ │ │ ├── widgets/
|
|
│ │ │ │ ├── route_summary_card.dart
|
|
│ │ │ │ ├── turn_instruction.dart
|
|
│ │ │ │ ├── profile_selector.dart
|
|
│ │ │ │ └── route_line_layer.dart
|
|
│ │ │ └── screens/
|
|
│ │ │ ├── directions_screen.dart
|
|
│ │ │ └── navigation_screen.dart
|
|
│ │ └── routing_providers.dart
|
|
│ ├── pois/
|
|
│ │ ├── data/
|
|
│ │ │ ├── repositories/
|
|
│ │ │ │ └── poi_repository.dart
|
|
│ │ │ ├── datasources/
|
|
│ │ │ │ ├── poi_remote_source.dart
|
|
│ │ │ │ └── poi_local_source.dart
|
|
│ │ │ └── models/
|
|
│ │ │ └── poi_dto.dart
|
|
│ │ ├── domain/
|
|
│ │ │ ├── models/
|
|
│ │ │ │ ├── poi.dart
|
|
│ │ │ │ └── poi_category.dart
|
|
│ │ │ └── repositories/
|
|
│ │ │ └── poi_repository.dart
|
|
│ │ ├── presentation/
|
|
│ │ │ ├── providers/
|
|
│ │ │ │ └── poi_provider.dart
|
|
│ │ │ ├── widgets/
|
|
│ │ │ │ ├── poi_marker_layer.dart
|
|
│ │ │ │ ├── place_card.dart
|
|
│ │ │ │ └── opening_hours_display.dart
|
|
│ │ │ └── screens/
|
|
│ │ │ └── poi_detail_screen.dart
|
|
│ │ └── poi_providers.dart
|
|
│ ├── favorites/
|
|
│ │ ├── data/
|
|
│ │ │ ├── repositories/
|
|
│ │ │ │ └── favorites_repository.dart
|
|
│ │ │ └── datasources/
|
|
│ │ │ └── favorites_local_source.dart
|
|
│ │ ├── domain/
|
|
│ │ │ ├── models/
|
|
│ │ │ │ ├── favorite.dart
|
|
│ │ │ │ └── favorite_group.dart
|
|
│ │ │ └── repositories/
|
|
│ │ │ └── favorites_repository.dart
|
|
│ │ ├── presentation/
|
|
│ │ │ ├── providers/
|
|
│ │ │ │ └── favorites_provider.dart
|
|
│ │ │ ├── widgets/
|
|
│ │ │ │ ├── favorite_list_tile.dart
|
|
│ │ │ │ └── save_favorite_dialog.dart
|
|
│ │ │ └── screens/
|
|
│ │ │ └── favorites_screen.dart
|
|
│ │ └── favorites_providers.dart
|
|
│ ├── offline/
|
|
│ │ ├── data/
|
|
│ │ │ ├── repositories/
|
|
│ │ │ │ └── offline_repository.dart
|
|
│ │ │ └── datasources/
|
|
│ │ │ ├── offline_remote_source.dart
|
|
│ │ │ └── offline_local_source.dart
|
|
│ │ ├── domain/
|
|
│ │ │ ├── models/
|
|
│ │ │ │ ├── offline_region.dart
|
|
│ │ │ │ └── download_progress.dart
|
|
│ │ │ └── repositories/
|
|
│ │ │ └── offline_repository.dart
|
|
│ │ ├── presentation/
|
|
│ │ │ ├── providers/
|
|
│ │ │ │ ├── offline_regions_provider.dart
|
|
│ │ │ │ └── download_manager_provider.dart
|
|
│ │ │ ├── widgets/
|
|
│ │ │ │ ├── region_list_tile.dart
|
|
│ │ │ │ ├── download_progress_bar.dart
|
|
│ │ │ │ └── region_selector_map.dart
|
|
│ │ │ └── screens/
|
|
│ │ │ └── offline_maps_screen.dart
|
|
│ │ └── offline_providers.dart
|
|
│ ├── sharing/
|
|
│ │ └── presentation/
|
|
│ │ ├── providers/
|
|
│ │ │ └── share_provider.dart
|
|
│ │ └── widgets/
|
|
│ │ └── share_sheet.dart
|
|
│ └── settings/
|
|
│ ├── presentation/
|
|
│ │ ├── providers/
|
|
│ │ │ └── settings_provider.dart
|
|
│ │ └── screens/
|
|
│ │ ├── settings_screen.dart
|
|
│ │ └── about_screen.dart
|
|
│ └── settings_providers.dart
|
|
├── database/
|
|
│ ├── app_database.dart # Drift database definition
|
|
│ ├── tables/
|
|
│ │ ├── search_history_table.dart
|
|
│ │ ├── favorites_table.dart
|
|
│ │ ├── favorite_groups_table.dart
|
|
│ │ └── offline_regions_table.dart
|
|
│ └── daos/
|
|
│ ├── search_history_dao.dart
|
|
│ ├── favorites_dao.dart
|
|
│ └── offline_regions_dao.dart
|
|
└── router/
|
|
└── app_router.dart # GoRouter or declarative routing
|
|
```
|
|
|
|
### 4.2 State Management (Riverpod)
|
|
|
|
The app uses Riverpod with the following provider hierarchy:
|
|
|
|
```
|
|
Core Providers (app-wide):
|
|
├── dioClientProvider → Dio instance (singleton)
|
|
├── databaseProvider → Drift AppDatabase (singleton)
|
|
├── connectivityProvider → Stream<bool> online/offline
|
|
├── locationProvider → Stream<Position> from platform
|
|
└── settingsProvider → User preferences (theme, units, cache size)
|
|
|
|
Feature Providers (scoped per feature):
|
|
├── Map:
|
|
│ ├── mapControllerProvider → MapLibre controller
|
|
│ ├── mapPositionProvider → Current viewport (center, zoom, bearing)
|
|
│ └── themeProvider → Active map style (day/night/terrain)
|
|
├── Search:
|
|
│ ├── searchQueryProvider → StateProvider<String>
|
|
│ ├── searchResultsProvider → FutureProvider (debounced, calls repository)
|
|
│ └── searchHistoryProvider → StreamProvider (from Drift DAO)
|
|
├── Routing:
|
|
│ ├── routeRequestProvider → StateProvider<RouteRequest?>
|
|
│ ├── routeResultsProvider → FutureProvider (calls OSRM)
|
|
│ ├── selectedRouteProvider → StateProvider<int> (selected alternative index)
|
|
│ └── navigationStateProvider → StateNotifierProvider (active navigation)
|
|
├── POIs:
|
|
│ ├── visiblePoisProvider → FutureProvider (bbox-based, auto-refreshes on viewport change)
|
|
│ └── selectedPoiProvider → StateProvider<Poi?>
|
|
├── Favorites:
|
|
│ ├── favoritesListProvider → StreamProvider (from Drift DAO)
|
|
│ └── favoriteGroupsProvider → StreamProvider
|
|
└── Offline:
|
|
├── availableRegionsProvider → FutureProvider (from backend)
|
|
├── downloadedRegionsProvider → StreamProvider (from Drift DAO)
|
|
└── activeDownloadsProvider → StateNotifierProvider (download manager)
|
|
```
|
|
|
|
**Key patterns:**
|
|
- `FutureProvider` for one-shot async data fetches.
|
|
- `StreamProvider` for reactive local data (Drift streams).
|
|
- `StateNotifierProvider` for complex mutable state (navigation, downloads).
|
|
- `Provider` for computed/derived values.
|
|
- Feature providers use `ref.watch` to react to upstream changes (e.g., `visiblePoisProvider` watches `mapPositionProvider`).
|
|
|
|
### 4.3 Navigation
|
|
|
|
Use `go_router` for declarative navigation:
|
|
|
|
```
|
|
/ → MapScreen (root)
|
|
/search → SearchScreen (overlay on map)
|
|
/directions → DirectionsScreen (origin/destination input)
|
|
/directions/navigate → NavigationScreen (active turn-by-turn)
|
|
/poi/:osmType/:osmId → POI detail (bottom sheet on map)
|
|
/favorites → FavoritesScreen
|
|
/settings → SettingsScreen
|
|
/settings/offline → OfflineMapsScreen
|
|
/settings/about → AboutScreen
|
|
```
|
|
|
|
The map remains persistent underneath all routes using a `ShellRoute` with the map as the shell. Search, POI details, and directions overlay the map as bottom sheets or panels.
|
|
|
|
### 4.4 Dependency Injection
|
|
|
|
Riverpod serves as the DI container. All dependencies are defined as providers:
|
|
|
|
```dart
|
|
// Core
|
|
final dioClientProvider = Provider<Dio>((ref) => createDioClient(ref));
|
|
final databaseProvider = Provider<AppDatabase>((ref) => AppDatabase());
|
|
|
|
// Repositories (depend on data sources)
|
|
final searchRepositoryProvider = Provider<SearchRepository>((ref) {
|
|
return SearchRepositoryImpl(
|
|
remote: ref.watch(photonRemoteSourceProvider),
|
|
local: ref.watch(searchHistoryLocalSourceProvider),
|
|
offline: ref.watch(offlineSearchSourceProvider),
|
|
connectivity: ref.watch(connectivityProvider),
|
|
);
|
|
});
|
|
```
|
|
|
|
This makes testing straightforward: override any provider in tests with a mock.
|
|
|
|
---
|
|
|
|
## 5. Backend Architecture (Rust / Actix-web)
|
|
|
|
### 5.1 Project Structure
|
|
|
|
```
|
|
backend/
|
|
├── Cargo.toml
|
|
├── src/
|
|
│ ├── main.rs # Server bootstrap, service wiring
|
|
│ ├── config.rs # Configuration from env vars
|
|
│ ├── routes/
|
|
│ │ ├── mod.rs
|
|
│ │ ├── tiles.rs # GET /tiles/{layer}/{z}/{x}/{y}.pbf
|
|
│ │ ├── search.rs # GET /api/search, GET /api/reverse
|
|
│ │ ├── routing.rs # GET /api/route/{profile}/{coordinates}
|
|
│ │ ├── pois.rs # GET /api/pois, GET /api/pois/{type}/{id}
|
|
│ │ ├── offline.rs # GET /api/offline/regions, downloads
|
|
│ │ └── health.rs # GET /api/health
|
|
│ ├── services/
|
|
│ │ ├── mod.rs
|
|
│ │ ├── martin_proxy.rs # HTTP proxy to Martin
|
|
│ │ ├── photon_proxy.rs # HTTP proxy to Photon
|
|
│ │ ├── osrm_proxy.rs # HTTP proxy to OSRM instances
|
|
│ │ ├── poi_service.rs # PostGIS POI queries
|
|
│ │ ├── offline_service.rs # Region package management
|
|
│ │ └── health_service.rs # Upstream health checks
|
|
│ ├── middleware/
|
|
│ │ ├── mod.rs
|
|
│ │ ├── cors.rs # CORS configuration
|
|
│ │ ├── request_logger.rs # Structured logging (no PII)
|
|
│ │ ├── rate_limiter.rs # Token bucket per IP (IP not logged)
|
|
│ │ └── input_validator.rs # Coordinate range checks, string sanitization
|
|
│ ├── cache/
|
|
│ │ ├── mod.rs
|
|
│ │ └── redis_cache.rs # Redis get/set with TTL
|
|
│ ├── models/
|
|
│ │ ├── mod.rs
|
|
│ │ ├── poi.rs # POI structs, GeoJSON serialization
|
|
│ │ ├── region.rs # Offline region metadata
|
|
│ │ └── error.rs # Error types and API error response
|
|
│ └── db/
|
|
│ ├── mod.rs
|
|
│ └── postgres.rs # Connection pool (deadpool-postgres), spatial queries
|
|
├── migrations/
|
|
│ └── 001_create_pois.sql
|
|
└── tests/
|
|
├── integration/
|
|
└── common/
|
|
```
|
|
|
|
### 5.2 Route Configuration
|
|
|
|
```rust
|
|
// Simplified Actix-web route configuration
|
|
App::new()
|
|
.wrap(middleware::cors())
|
|
.wrap(middleware::request_logger())
|
|
.wrap(middleware::rate_limiter())
|
|
// Tiles (proxied to Martin)
|
|
.route("/tiles/{layer}/{z}/{x}/{y}.pbf", web::get().to(tiles::get_tile))
|
|
.route("/tiles/style.json", web::get().to(tiles::get_style))
|
|
// Search (proxied to Photon)
|
|
.route("/api/search", web::get().to(search::search))
|
|
.route("/api/reverse", web::get().to(search::reverse))
|
|
// Routing (proxied to OSRM)
|
|
.route("/api/route/{profile}/{coordinates}", web::get().to(routing::route))
|
|
// POIs (direct PostGIS queries)
|
|
.route("/api/pois", web::get().to(pois::list_pois))
|
|
.route("/api/pois/{osm_type}/{osm_id}", web::get().to(pois::get_poi))
|
|
// Offline
|
|
.route("/api/offline/regions", web::get().to(offline::list_regions))
|
|
.route("/api/offline/regions/{id}/{component}", web::get().to(offline::download_component))
|
|
// Health
|
|
.route("/api/health", web::get().to(health::check))
|
|
```
|
|
|
|
### 5.3 Middleware
|
|
|
|
**CORS:**
|
|
- Allow all origins (the mobile app's origin varies by deployment).
|
|
- Allow methods: `GET`, `HEAD`, `OPTIONS`.
|
|
- Allow headers: `Content-Type`, `Accept`, `Accept-Encoding`, `Range`.
|
|
- `Access-Control-Max-Age: 86400`.
|
|
|
|
**Request Logger:**
|
|
- Logs: HTTP method, path (without query string), response status, response time in ms.
|
|
- Does NOT log: query parameters, client IP, request body, `User-Agent`, cookies.
|
|
- Uses `tracing` crate with structured JSON output.
|
|
- Log format: `{"method":"GET","path":"/api/pois","status":200,"duration_ms":42}`
|
|
|
|
**Rate Limiter:**
|
|
- Token bucket algorithm, keyed by client IP (IP used only for bucket lookup, never logged or stored).
|
|
- Default: 100 requests/second per IP, burst of 200.
|
|
- Tile requests: 500 requests/second (tiles are cheap to serve from cache).
|
|
- Returns `429 Too Many Requests` with `Retry-After` header when exceeded.
|
|
|
|
**Input Validator:**
|
|
- Validates coordinate ranges: latitude [-90, 90], longitude [-180, 180].
|
|
- Validates zoom levels: [0, 18].
|
|
- Validates `limit` parameters: enforces maximum values.
|
|
- Sanitizes string inputs: max length 500 characters for search queries, strip control characters.
|
|
- Returns `400 Bad Request` with error details on validation failure.
|
|
|
|
### 5.4 Health Checks
|
|
|
|
The `/api/health` endpoint actively probes each upstream service:
|
|
|
|
| Service | Check | Timeout |
|
|
|---|---|---|
|
|
| Martin | `GET /health` on Martin's internal port | 2s |
|
|
| Photon | `GET /api/search?q=test&limit=1` | 3s |
|
|
| OSRM (driving) | `GET /route/v1/driving/0,0;0.001,0.001?overview=false` | 3s |
|
|
| OSRM (walking) | Same pattern | 3s |
|
|
| OSRM (cycling) | Same pattern | 3s |
|
|
| PostgreSQL | `SELECT 1` | 2s |
|
|
|
|
If any service is down, the endpoint still returns `200 OK` but with that service's status as `"degraded"` or `"down"`. This allows the mobile app to show degraded-mode indicators.
|
|
|
|
---
|
|
|
|
## 6. Deployment Architecture (Docker Compose)
|
|
|
|
### 6.1 Topology
|
|
|
|
```yaml
|
|
# docker-compose.yml (structural overview)
|
|
services:
|
|
gateway: # Rust API gateway port 8080 (internal)
|
|
martin: # Tile server port 3000 (internal)
|
|
photon: # Geocoder port 2322 (internal)
|
|
osrm-driving: # OSRM driving profile port 5001 (internal)
|
|
osrm-walking: # OSRM walking profile port 5002 (internal)
|
|
osrm-cycling: # OSRM cycling profile port 5003 (internal)
|
|
postgres: # PostgreSQL + PostGIS port 5432 (internal)
|
|
redis: # Cache port 6379 (internal)
|
|
caddy: # Reverse proxy + TLS ports 80, 443 (external)
|
|
```
|
|
|
|
### 6.2 Networking
|
|
|
|
- All services are on a single Docker bridge network (`maps_net`).
|
|
- Only `caddy` exposes ports to the host (80 for HTTPS redirect, 443 for TLS).
|
|
- The gateway communicates with upstream services by Docker DNS names (`martin:3000`, `photon:2322`, etc.).
|
|
- No service except `caddy` is reachable from outside the Docker network.
|
|
|
|
### 6.3 Volumes
|
|
|
|
| Volume | Service | Purpose |
|
|
|---|---|---|
|
|
| `pg_data` | postgres | PostgreSQL data directory (persistent) |
|
|
| `martin_config` | martin | Martin configuration file |
|
|
| `photon_data` | photon | Photon/Elasticsearch index (persistent, ~2-20 GB depending on coverage) |
|
|
| `osrm_data` | osrm-* | Preprocessed OSRM graph files (persistent, per profile) |
|
|
| `redis_data` | redis | Redis AOF/RDB persistence (optional, cache can be cold-started) |
|
|
| `offline_packages` | gateway | Pre-built offline region packages served to mobile clients |
|
|
| `caddy_data` | caddy | TLS certificates (Let's Encrypt auto-provisioned) |
|
|
| `caddy_config` | caddy | Caddy configuration |
|
|
|
|
### 6.4 Resource Allocation (for Netherlands-scale deployment)
|
|
|
|
| Service | CPU Limit | Memory Limit |
|
|
|---|---|---|
|
|
| gateway | 1 core | 256 MB |
|
|
| martin | 1 core | 512 MB |
|
|
| photon | 2 cores | 2 GB |
|
|
| osrm-driving | 1 core | 1.5 GB |
|
|
| osrm-walking | 0.5 core | 1 GB |
|
|
| osrm-cycling | 0.5 core | 1 GB |
|
|
| postgres | 2 cores | 2 GB |
|
|
| redis | 0.5 core | 256 MB |
|
|
| caddy | 0.5 core | 128 MB |
|
|
| **Total** | **~9 cores** | **~8.5 GB** |
|
|
|
|
---
|
|
|
|
## 7. Offline Architecture
|
|
|
|
### 7.1 Overview
|
|
|
|
Offline support has three pillars, each with its own storage format and data pipeline:
|
|
|
|
```
|
|
+-------------------------------------------+
|
|
| Mobile Device Storage |
|
|
| |
|
|
| +-------------+ +-----------+ +-------+ |
|
|
| | MBTiles | | OSRM | | POI | |
|
|
| | (tiles.db) | | .osrm.* | | .db | |
|
|
| | SQLite | | files | | SQLite| |
|
|
| +-------------+ +-----------+ +-------+ |
|
|
+-------------------------------------------+
|
|
```
|
|
|
|
### 7.2 Offline Tiles
|
|
|
|
- Format: MBTiles (SQLite database with tile data as blobs).
|
|
- The backend pre-generates MBTiles files per region using Martin's `mbtiles` tool or `tilelive`.
|
|
- Zoom levels 0-16 are included (level 17-18 excluded to reduce size).
|
|
- On the device, MapLibre GL Native is configured with a composite tile source:
|
|
1. First, check the offline MBTiles database for the requested tile.
|
|
2. If not found, check the LRU tile cache (also MBTiles format).
|
|
3. If not found and online, fetch from the backend and store in cache.
|
|
|
|
### 7.3 Offline Routing
|
|
|
|
- OSRM data files (`.osrm`, `.osrm.cell_metrics`, `.osrm.partition`, etc.) are downloaded per profile per region.
|
|
- On the device, routing is performed using a compiled OSRM library accessed via Dart FFI (Foreign Function Interface) bindings to the OSRM C++ library.
|
|
- The FFI binding exposes a minimal interface: `route(profile_data_path, coordinates) -> RouteResult`.
|
|
- Fallback: if FFI integration proves too complex for v1.0, the app can use pre-calculated route graphs in a simplified Dart-native implementation (Dijkstra on a simplified road graph stored in SQLite). This is a degraded experience but ensures offline routing works.
|
|
|
|
### 7.4 Offline Search
|
|
|
|
- POI data is downloaded as a SQLite database per region, containing the same schema as the PostGIS `pois` table.
|
|
- Offline search uses SQLite FTS5 (full-text search) on the `name` and `address` columns.
|
|
- Results are ranked by FTS5 relevance score, with proximity to the viewport center as a tiebreaker (using the Haversine formula in SQL).
|
|
|
|
### 7.5 Download Manager
|
|
|
|
- Downloads are managed by a background isolate (Dart isolate) to prevent blocking the UI.
|
|
- Each region download consists of multiple sequential component downloads (tiles, routing x3, POIs).
|
|
- Supports HTTP `Range` headers for pause/resume.
|
|
- Progress is tracked per component and aggregated per region.
|
|
- Downloads persist across app restarts by storing state in Drift (download URL, bytes received, total bytes).
|
|
|
|
---
|
|
|
|
## 8. Privacy Architecture
|
|
|
|
Each privacy commitment from the spec is enforced as follows:
|
|
|
|
| Commitment | Technical Enforcement |
|
|
|---|---|
|
|
| **No accounts** | No authentication middleware on backend. No session tokens, cookies, or `Authorization` headers. No user table in the database. |
|
|
| **No telemetry** | CI pipeline runs `dart pub deps --json` and rejects any dependency matching a deny-list (Firebase, Sentry, Amplitude, etc.). Static analysis scans compiled binary for known analytics domain strings. |
|
|
| **No third-party network calls** | Dio base URL is the single configured backend URL. Dio interceptor rejects any request not matching the base URL prefix. CI step: `strings` on the compiled APK/IPA, grep for known third-party domains — fail if any found. |
|
|
| **On-device history** | Search history, favorites, and offline region metadata are stored in Drift (SQLite). No provider or repository ever sends this data over the network. Code review enforced. |
|
|
| **Self-hosted backend** | Docker Compose includes all services. No SaaS API keys in configuration. Backend makes zero outbound network calls (all data is local). |
|
|
| **Auditable** | Open-source. CI publishes dependency tree. Network monitor-friendly (single backend domain). |
|
|
| **No PII logging (backend)** | `request_logger` middleware logs only: method, path (without query params), status, duration. The `tracing` subscriber is configured to redact any field named `ip`, `query`, `user_agent`, `coordinates`. |
|
|
| **On-device encryption** | Android: database files stored in app-internal storage (encrypted by default on Android 10+; on Android 8-9, use `EncryptedSharedPreferences` for the SQLite encryption key with `SQLCipher`). iOS: files stored with `NSFileProtectionComplete` attribute (encrypted until first unlock). |
|
|
| **TLS only** | Dio configured with `baseUrl` starting with `https://`. A custom `SecurityContext` rejects plaintext. On the backend, Caddy enforces HTTPS and redirects HTTP to HTTPS. HSTS header set. |
|
|
| **No device fingerprinting** | No code reads IMEI, advertising ID, MAC address, or serial number. CI lint rule: any import of `device_info_plus`, `android_id`, or similar packages fails the build. |
|
|
|
|
---
|
|
|
|
## 9. Error Handling Strategy
|
|
|
|
### 9.1 Mobile App
|
|
|
|
**Network errors:**
|
|
- Connection timeout / no connectivity: switch to offline mode automatically. Show "Offline mode" banner. Serve tiles from cache, search from local DB, routing from local OSRM data (if available).
|
|
- HTTP 429 (rate limited): retry after `Retry-After` duration with exponential backoff (max 3 retries).
|
|
- HTTP 5xx: show a non-intrusive snackbar ("Service temporarily unavailable"). Retry with exponential backoff.
|
|
- HTTP 404 (tile): render blank tile area, do not retry.
|
|
|
|
**Service-specific degradation:**
|
|
| Service Down | User Experience |
|
|
|---|---|
|
|
| Martin (tiles) | Map shows cached tiles only. Uncached areas are blank. Banner: "Some map areas unavailable." |
|
|
| Photon (search) | Search falls back to offline POI database (if downloaded). Otherwise: "Search unavailable. Try offline maps." |
|
|
| OSRM (routing) | Routing falls back to offline OSRM data (if downloaded). Otherwise: "Routing unavailable." |
|
|
| PostGIS (POIs) | POI markers not shown. POI detail returns "Details unavailable." |
|
|
| Entire backend | Full offline mode. All features work if region is downloaded. Otherwise: cached tiles only, no search, no routing. |
|
|
|
|
**Local errors:**
|
|
- SQLite corruption: detect on open, offer to clear cache (favorites are backed up separately).
|
|
- Disk full: warn user before download starts (check 80% threshold). If cache is full, LRU eviction runs automatically.
|
|
|
|
### 9.2 Backend API
|
|
|
|
All error responses use a standard format:
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "INVALID_BBOX",
|
|
"message": "Bounding box coordinates are out of valid range."
|
|
}
|
|
}
|
|
```
|
|
|
|
Error code mapping:
|
|
|
|
| HTTP Status | Code | When |
|
|
|---|---|---|
|
|
| 400 | `INVALID_PARAMETER` | Query parameter fails validation |
|
|
| 400 | `INVALID_BBOX` | Bounding box coordinates out of range |
|
|
| 400 | `INVALID_COORDINATES` | Route coordinates out of range |
|
|
| 400 | `MISSING_PARAMETER` | Required parameter missing |
|
|
| 404 | `NOT_FOUND` | POI, tile, or region not found |
|
|
| 429 | `RATE_LIMITED` | Client exceeded rate limit |
|
|
| 502 | `UPSTREAM_ERROR` | Martin/Photon/OSRM returned an error |
|
|
| 503 | `SERVICE_UNAVAILABLE` | Upstream service is down |
|
|
| 500 | `INTERNAL_ERROR` | Unexpected server error |
|
|
|
|
The gateway never exposes internal error details (stack traces, database errors) to the client.
|
|
|
|
---
|
|
|
|
## 10. Security
|
|
|
|
### 10.1 TLS
|
|
|
|
- Caddy auto-provisions TLS certificates via Let's Encrypt (ACME).
|
|
- Minimum TLS version: 1.2. Preferred: 1.3.
|
|
- HSTS header: `Strict-Transport-Security: max-age=63072000; includeSubDomains`.
|
|
- HTTP requests to port 80 are 301-redirected to HTTPS.
|
|
- Internal Docker network communication is plaintext (acceptable: all services are on the same host, in the same Docker network, not exposed externally).
|
|
|
|
### 10.2 No Authentication (by design)
|
|
|
|
- The backend has no authentication mechanism. This is intentional.
|
|
- The backend is meant to be deployed on a private network or behind a VPN/firewall.
|
|
- If public exposure is needed, the deployer can add HTTP Basic Auth or client certificates at the Caddy layer. This is documented but not built into the application.
|
|
|
|
### 10.3 Input Validation
|
|
|
|
All user-supplied input is validated at the gateway before proxying:
|
|
|
|
| Input | Validation |
|
|
|---|---|
|
|
| Zoom level (`z`) | Integer, [0, 18] |
|
|
| Tile coordinates (`x`, `y`) | Integer, >= 0, <= 2^z - 1 |
|
|
| Latitude | Float, [-90.0, 90.0] |
|
|
| Longitude | Float, [-180.0, 180.0] |
|
|
| Bounding box | 4 floats, valid lat/lon, min < max |
|
|
| Search query (`q`) | String, max 500 chars, control chars stripped |
|
|
| `limit` | Integer, [1, max_for_endpoint] |
|
|
| Routing profile | Enum: `driving`, `walking`, `cycling` |
|
|
| Coordinates (routing) | 2-7 coordinate pairs (origin + up to 5 waypoints + destination) |
|
|
| OSM type | Enum: `N`, `W`, `R` |
|
|
| OSM ID | Positive integer |
|
|
| `lang` | 2-char ISO 639-1, validated against allow-list |
|
|
|
|
### 10.4 Rate Limiting
|
|
|
|
- Applied per source IP using an in-memory token bucket (not stored in Redis to avoid logging IPs).
|
|
- Default limits (configurable via environment variables):
|
|
- Tile requests: 500/s per IP, burst 1000.
|
|
- API requests: 100/s per IP, burst 200.
|
|
- Returns `429` with `Retry-After` header.
|
|
- The rate limiter state is ephemeral (lost on restart), which is acceptable.
|
|
|
|
### 10.5 Dependency Security
|
|
|
|
- Backend: `cargo audit` runs in CI to detect known vulnerabilities in Rust dependencies.
|
|
- Mobile: `dart pub outdated` and manual review of transitive dependencies.
|
|
- No native dependencies beyond MapLibre GL Native and (optionally) OSRM FFI.
|
|
- Docker images use minimal base images (`rust:slim` for build, `debian:bookworm-slim` for runtime).
|
|
|
|
### 10.6 Content Security
|
|
|
|
- Martin tiles are binary protobuf; no injection risk.
|
|
- Photon and OSRM responses are validated JSON. The gateway re-serializes POI responses from PostGIS to prevent SQL injection artifacts from reaching the client.
|
|
- The gateway uses parameterized queries (via `tokio-postgres` / `sqlx`) for all PostGIS access. No string interpolation in SQL.
|