mirror of
https://github.com/smittix/intercept.git
synced 2026-06-17 09:59:47 -07:00
feat(maps): add Stadia dark + tactical tile providers with API key support
- Add offline.stadia_key to OFFLINE_DEFAULTS in routes/offline.py - Add stadia_dark and tactical tile providers to Settings.tileProviders - Update getTileConfig() to inject Stadia API key or fall back to CartoDB dark - Add setStadiaKey() method for saving and applying the API key - Show/hide Stadia key row in setTileProvider() and _updateUI() - Add Stadia options to tile provider select in settings modal - Add Stadia API key input row to settings modal - Add TDD tests for stadia_key backend Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+53
-60
@@ -9,49 +9,43 @@ from flask import Blueprint, request
|
|||||||
from utils.database import get_setting, set_setting
|
from utils.database import get_setting, set_setting
|
||||||
from utils.responses import api_error, api_success
|
from utils.responses import api_error, api_success
|
||||||
|
|
||||||
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
offline_bp = Blueprint("offline", __name__, url_prefix="/offline")
|
||||||
|
|
||||||
# Default offline settings
|
# Default offline settings
|
||||||
OFFLINE_DEFAULTS = {
|
OFFLINE_DEFAULTS = {
|
||||||
'offline.enabled': False,
|
"offline.enabled": False,
|
||||||
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
||||||
'offline.assets_source': 'local',
|
"offline.assets_source": "local",
|
||||||
'offline.fonts_source': 'local',
|
"offline.fonts_source": "local",
|
||||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
"offline.tile_provider": "cartodb_dark_cyan",
|
||||||
'offline.tile_server_url': ''
|
"offline.tile_server_url": "",
|
||||||
|
"offline.stadia_key": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Asset paths to check
|
# Asset paths to check
|
||||||
ASSET_PATHS = {
|
ASSET_PATHS = {
|
||||||
'leaflet': [
|
"leaflet": ["static/vendor/leaflet/leaflet.js", "static/vendor/leaflet/leaflet.css"],
|
||||||
'static/vendor/leaflet/leaflet.js',
|
"chartjs": ["static/vendor/chartjs/chart.umd.min.js"],
|
||||||
'static/vendor/leaflet/leaflet.css'
|
"inter": [
|
||||||
|
"static/vendor/fonts/Inter-Regular.woff2",
|
||||||
|
"static/vendor/fonts/Inter-Medium.woff2",
|
||||||
|
"static/vendor/fonts/Inter-SemiBold.woff2",
|
||||||
|
"static/vendor/fonts/Inter-Bold.woff2",
|
||||||
],
|
],
|
||||||
'chartjs': [
|
"jetbrains": [
|
||||||
'static/vendor/chartjs/chart.umd.min.js'
|
"static/vendor/fonts/JetBrainsMono-Regular.woff2",
|
||||||
|
"static/vendor/fonts/JetBrainsMono-Medium.woff2",
|
||||||
|
"static/vendor/fonts/JetBrainsMono-SemiBold.woff2",
|
||||||
|
"static/vendor/fonts/JetBrainsMono-Bold.woff2",
|
||||||
],
|
],
|
||||||
'inter': [
|
"leaflet_images": [
|
||||||
'static/vendor/fonts/Inter-Regular.woff2',
|
"static/vendor/leaflet/images/marker-icon.png",
|
||||||
'static/vendor/fonts/Inter-Medium.woff2',
|
"static/vendor/leaflet/images/marker-icon-2x.png",
|
||||||
'static/vendor/fonts/Inter-SemiBold.woff2',
|
"static/vendor/leaflet/images/marker-shadow.png",
|
||||||
'static/vendor/fonts/Inter-Bold.woff2'
|
"static/vendor/leaflet/images/layers.png",
|
||||||
|
"static/vendor/leaflet/images/layers-2x.png",
|
||||||
],
|
],
|
||||||
'jetbrains': [
|
"leaflet_heat": ["static/vendor/leaflet-heat/leaflet-heat.js"],
|
||||||
'static/vendor/fonts/JetBrainsMono-Regular.woff2',
|
|
||||||
'static/vendor/fonts/JetBrainsMono-Medium.woff2',
|
|
||||||
'static/vendor/fonts/JetBrainsMono-SemiBold.woff2',
|
|
||||||
'static/vendor/fonts/JetBrainsMono-Bold.woff2'
|
|
||||||
],
|
|
||||||
'leaflet_images': [
|
|
||||||
'static/vendor/leaflet/images/marker-icon.png',
|
|
||||||
'static/vendor/leaflet/images/marker-icon-2x.png',
|
|
||||||
'static/vendor/leaflet/images/marker-shadow.png',
|
|
||||||
'static/vendor/leaflet/images/layers.png',
|
|
||||||
'static/vendor/leaflet/images/layers-2x.png'
|
|
||||||
],
|
|
||||||
'leaflet_heat': [
|
|
||||||
'static/vendor/leaflet-heat/leaflet-heat.js'
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -63,26 +57,26 @@ def get_offline_settings():
|
|||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
@offline_bp.route('/settings', methods=['GET'])
|
@offline_bp.route("/settings", methods=["GET"])
|
||||||
def get_settings():
|
def get_settings():
|
||||||
"""Get current offline settings."""
|
"""Get current offline settings."""
|
||||||
settings = get_offline_settings()
|
settings = get_offline_settings()
|
||||||
return api_success(data={'settings': settings})
|
return api_success(data={"settings": settings})
|
||||||
|
|
||||||
|
|
||||||
@offline_bp.route('/settings', methods=['POST'])
|
@offline_bp.route("/settings", methods=["POST"])
|
||||||
def save_setting():
|
def save_setting():
|
||||||
"""Save an offline setting."""
|
"""Save an offline setting."""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or 'key' not in data or 'value' not in data:
|
if not data or "key" not in data or "value" not in data:
|
||||||
return api_error('Missing key or value', 400)
|
return api_error("Missing key or value", 400)
|
||||||
|
|
||||||
key = data['key']
|
key = data["key"]
|
||||||
value = data['value']
|
value = data["value"]
|
||||||
|
|
||||||
# Validate key is an allowed setting
|
# Validate key is an allowed setting
|
||||||
if key not in OFFLINE_DEFAULTS:
|
if key not in OFFLINE_DEFAULTS:
|
||||||
return api_error(f'Unknown setting: {key}', 400)
|
return api_error(f"Unknown setting: {key}", 400)
|
||||||
|
|
||||||
# Validate value type matches default
|
# Validate value type matches default
|
||||||
default_type = type(OFFLINE_DEFAULTS[key])
|
default_type = type(OFFLINE_DEFAULTS[key])
|
||||||
@@ -90,18 +84,18 @@ def save_setting():
|
|||||||
# Try to convert
|
# Try to convert
|
||||||
try:
|
try:
|
||||||
if default_type == bool:
|
if default_type == bool:
|
||||||
value = str(value).lower() in ('true', '1', 'yes')
|
value = str(value).lower() in ("true", "1", "yes")
|
||||||
else:
|
else:
|
||||||
value = default_type(value)
|
value = default_type(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return api_error(f'Invalid value type for {key}', 400)
|
return api_error(f"Invalid value type for {key}", 400)
|
||||||
|
|
||||||
set_setting(key, value)
|
set_setting(key, value)
|
||||||
|
|
||||||
return api_success(data={'key': key, 'value': value})
|
return api_success(data={"key": key, "value": value})
|
||||||
|
|
||||||
|
|
||||||
@offline_bp.route('/status', methods=['GET'])
|
@offline_bp.route("/status", methods=["GET"])
|
||||||
def get_status():
|
def get_status():
|
||||||
"""Check status of local assets."""
|
"""Check status of local assets."""
|
||||||
# Get the app root directory
|
# Get the app root directory
|
||||||
@@ -119,37 +113,36 @@ def get_status():
|
|||||||
available = False
|
available = False
|
||||||
missing.append(path)
|
missing.append(path)
|
||||||
|
|
||||||
results[asset_name] = {
|
results[asset_name] = {"available": available, "missing": missing if not available else []}
|
||||||
'available': available,
|
|
||||||
'missing': missing if not available else []
|
|
||||||
}
|
|
||||||
|
|
||||||
if not available:
|
if not available:
|
||||||
all_available = False
|
all_available = False
|
||||||
|
|
||||||
return api_success(data={
|
return api_success(
|
||||||
'all_available': all_available,
|
data={
|
||||||
'assets': results,
|
"all_available": all_available,
|
||||||
'offline_enabled': get_setting('offline.enabled', False)
|
"assets": results,
|
||||||
})
|
"offline_enabled": get_setting("offline.enabled", False),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@offline_bp.route('/check-asset', methods=['GET'])
|
@offline_bp.route("/check-asset", methods=["GET"])
|
||||||
def check_asset():
|
def check_asset():
|
||||||
"""Check if a specific asset file exists."""
|
"""Check if a specific asset file exists."""
|
||||||
path = request.args.get('path', '')
|
path = request.args.get("path", "")
|
||||||
if not path:
|
if not path:
|
||||||
return api_error('Missing path parameter', 400)
|
return api_error("Missing path parameter", 400)
|
||||||
|
|
||||||
# Security: only allow checking within static/vendor
|
# Security: only allow checking within static/vendor
|
||||||
if not path.startswith('/static/vendor/'):
|
if not path.startswith("/static/vendor/"):
|
||||||
return api_error('Invalid path', 400)
|
return api_error("Invalid path", 400)
|
||||||
|
|
||||||
# Remove leading slash and construct full path
|
# Remove leading slash and construct full path
|
||||||
relative_path = path.lstrip('/')
|
relative_path = path.lstrip("/")
|
||||||
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
full_path = os.path.join(app_root, relative_path)
|
full_path = os.path.join(app_root, relative_path)
|
||||||
|
|
||||||
exists = os.path.exists(full_path)
|
exists = os.path.exists(full_path)
|
||||||
|
|
||||||
return api_success(data={'path': path, 'exists': exists})
|
return api_success(data={"path": path, "exists": exists})
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ const Settings = {
|
|||||||
'offline.assets_source': 'local',
|
'offline.assets_source': 'local',
|
||||||
'offline.fonts_source': 'local',
|
'offline.fonts_source': 'local',
|
||||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||||
'offline.tile_server_url': ''
|
'offline.tile_server_url': '',
|
||||||
|
'offline.stadia_key': '',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Tile provider configurations
|
// Tile provider configurations
|
||||||
@@ -42,7 +43,19 @@ const Settings = {
|
|||||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
|
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
|
||||||
subdomains: null
|
subdomains: null
|
||||||
}
|
},
|
||||||
|
stadia_dark: {
|
||||||
|
url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png',
|
||||||
|
attribution: '© <a href="https://stadiamaps.com/" target="_blank">Stadia Maps</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
subdomains: null,
|
||||||
|
requiresKey: true,
|
||||||
|
},
|
||||||
|
tactical: {
|
||||||
|
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner_background/{z}/{x}/{y}{r}.png',
|
||||||
|
attribution: '© <a href="https://stadiamaps.com/" target="_blank">Stadia Maps</a>',
|
||||||
|
subdomains: null,
|
||||||
|
requiresKey: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Registry of maps that can be updated
|
// Registry of maps that can be updated
|
||||||
@@ -292,6 +305,13 @@ const Settings = {
|
|||||||
customRow.style.display = provider === 'custom' ? 'block' : 'none';
|
customRow.style.display = provider === 'custom' ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show/hide Stadia API key row
|
||||||
|
const stadiaKeyRow = document.getElementById('stadiaKeyRow');
|
||||||
|
if (stadiaKeyRow) {
|
||||||
|
stadiaKeyRow.style.display =
|
||||||
|
(provider === 'stadia_dark' || provider === 'tactical') ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Update tiles immediately for all providers.
|
// Update tiles immediately for all providers.
|
||||||
this._updateMapTiles();
|
this._updateMapTiles();
|
||||||
const activeConfig = this.getTileConfig();
|
const activeConfig = this.getTileConfig();
|
||||||
@@ -307,6 +327,15 @@ const Settings = {
|
|||||||
this._updateMapTiles();
|
this._updateMapTiles();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save Stadia Maps API key and refresh tiles.
|
||||||
|
* @param {string} key
|
||||||
|
*/
|
||||||
|
async setStadiaKey(key) {
|
||||||
|
await this._save('offline.stadia_key', (key || '').trim());
|
||||||
|
this._updateMapTiles();
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current tile configuration
|
* Get current tile configuration
|
||||||
*/
|
*/
|
||||||
@@ -322,15 +351,26 @@ const Settings = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = this.tileProviders[provider] || this.tileProviders.cartodb_dark;
|
const baseConfig = this.tileProviders[provider] || this.tileProviders.cartodb_dark;
|
||||||
|
|
||||||
// Robust fallback: if dark Carto is active and Cyber is preferred,
|
if (baseConfig.requiresKey) {
|
||||||
// keep Cyber theme enabled even when provider temporarily reverts.
|
const key = (this.get('offline.stadia_key') || '').trim();
|
||||||
if (provider === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
|
if (!key) {
|
||||||
return { ...config, mapTheme: 'cyber' };
|
// No key — fall back to CartoDB dark so the map isn't broken
|
||||||
|
return this.tileProviders.cartodb_dark;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
url: baseConfig.url + '?api_key=' + encodeURIComponent(key),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
// Robust fallback: keep Cyber theme when CartoDB dark is active and Cyber preferred.
|
||||||
|
if (provider === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
|
||||||
|
return { ...baseConfig, mapTheme: 'cyber' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseConfig;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -643,6 +683,18 @@ const Settings = {
|
|||||||
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
|
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stadia key input
|
||||||
|
const stadiaKeyInput = document.getElementById('stadiaKeyInput');
|
||||||
|
if (stadiaKeyInput) {
|
||||||
|
stadiaKeyInput.value = this.get('offline.stadia_key') || '';
|
||||||
|
}
|
||||||
|
const stadiaKeyRow = document.getElementById('stadiaKeyRow');
|
||||||
|
if (stadiaKeyRow) {
|
||||||
|
const currentProvider = this.get('offline.tile_provider');
|
||||||
|
stadiaKeyRow.style.display =
|
||||||
|
(currentProvider === 'stadia_dark' || currentProvider === 'tactical') ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Theme select
|
// Theme select
|
||||||
const themeSelect = document.getElementById('themeSelect');
|
const themeSelect = document.getElementById('themeSelect');
|
||||||
if (themeSelect) {
|
if (themeSelect) {
|
||||||
|
|||||||
@@ -77,6 +77,8 @@
|
|||||||
<option value="openstreetmap">OpenStreetMap</option>
|
<option value="openstreetmap">OpenStreetMap</option>
|
||||||
<option value="cartodb_light">CartoDB Positron</option>
|
<option value="cartodb_light">CartoDB Positron</option>
|
||||||
<option value="esri_world">ESRI World Imagery</option>
|
<option value="esri_world">ESRI World Imagery</option>
|
||||||
|
<option value="stadia_dark">Stadia Alidade Dark</option>
|
||||||
|
<option value="tactical">Stadia Tactical (minimal)</option>
|
||||||
<option value="custom">Custom URL</option>
|
<option value="custom">Custom URL</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,6 +92,15 @@
|
|||||||
onchange="Settings.setCustomTileUrl(this.value)">
|
onchange="Settings.setCustomTileUrl(this.value)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-row" id="stadiaKeyRow" style="display: none;">
|
||||||
|
<div class="settings-label" style="width: 100%;">
|
||||||
|
<span class="settings-label-text">Stadia API Key</span>
|
||||||
|
<span class="settings-label-desc">Free at <a href="https://client.stadiamaps.com/signup/" target="_blank" style="color: var(--accent-cyan);">stadiamaps.com</a></span>
|
||||||
|
<input type="text" id="stadiaKeyInput" class="settings-input"
|
||||||
|
placeholder="your-api-key-here"
|
||||||
|
onchange="Settings.setStadiaKey(this.value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(client):
|
||||||
|
"""Client with an authenticated session."""
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["logged_in"] = True
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_settings_includes_stadia_key(auth_client):
|
||||||
|
"""GET /offline/settings returns offline.stadia_key field."""
|
||||||
|
resp = auth_client.get("/offline/settings")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "offline.stadia_key" in data["settings"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stadia_key_defaults_to_empty_string(auth_client):
|
||||||
|
"""Stadia key defaults to empty string, not None."""
|
||||||
|
# Reset to empty string first to ensure isolation between test runs.
|
||||||
|
auth_client.post("/offline/settings", json={"key": "offline.stadia_key", "value": ""})
|
||||||
|
resp = auth_client.get("/offline/settings")
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["settings"]["offline.stadia_key"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_stadia_key_can_be_saved(auth_client):
|
||||||
|
"""POST /offline/settings saves offline.stadia_key."""
|
||||||
|
resp = auth_client.post("/offline/settings", json={"key": "offline.stadia_key", "value": "test-key-123"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["value"] == "test-key-123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stadia_key_rejects_non_string(auth_client):
|
||||||
|
"""POST /offline/settings rejects non-string stadia_key."""
|
||||||
|
resp = auth_client.post("/offline/settings", json={"key": "offline.stadia_key", "value": 42})
|
||||||
|
# Should coerce to string '42' (type matches str default) — not 400
|
||||||
|
assert resp.status_code == 200
|
||||||
Reference in New Issue
Block a user