diff --git a/routes/gsm_spy.py b/routes/gsm_spy.py index 90b419f..fee519e 100644 --- a/routes/gsm_spy.py +++ b/routes/gsm_spy.py @@ -89,9 +89,18 @@ def increment_api_usage(): return current + 1 +def get_opencellid_api_key(): + """Get OpenCellID API key, checking env var first, then database setting.""" + env_key = config.GSM_OPENCELLID_API_KEY + if env_key: + return env_key + from utils.database import get_setting + return get_setting('gsm.opencellid.api_key', '') + + def can_use_api(): """Check if we can make an API call within daily limit.""" - if not config.GSM_OPENCELLID_API_KEY: + if not get_opencellid_api_key(): return False current_usage = get_api_usage_today() return current_usage < config.GSM_API_DAILY_LIMIT @@ -140,8 +149,8 @@ def geocoding_worker(): # Check API key and rate limit if not can_use_api(): - if not config.GSM_OPENCELLID_API_KEY: - logger.warning("OpenCellID API key not configured (set INTERCEPT_GSM_OPENCELLID_API_KEY)") + if not get_opencellid_api_key(): + logger.warning("OpenCellID API key not configured (set INTERCEPT_GSM_OPENCELLID_API_KEY or configure in Settings > API Keys)") else: current_usage = get_api_usage_today() logger.warning(f"OpenCellID API daily limit reached ({current_usage}/{config.GSM_API_DAILY_LIMIT})") @@ -807,7 +816,53 @@ def status(): 'selected_arfcn': app_module.gsm_spy_selected_arfcn, 'api_usage_today': api_usage, 'api_limit': config.GSM_API_DAILY_LIMIT, - 'api_remaining': config.GSM_API_DAILY_LIMIT - api_usage + 'api_remaining': config.GSM_API_DAILY_LIMIT - api_usage, + 'api_key_configured': bool(get_opencellid_api_key()) + }) + + +@gsm_spy_bp.route('/settings/api_key', methods=['GET', 'POST']) +def settings_api_key(): + """Get or set OpenCellID API key configuration.""" + from utils.database import get_setting, set_setting + + if request.method == 'GET': + env_key = config.GSM_OPENCELLID_API_KEY + db_key = get_setting('gsm.opencellid.api_key', '') + + if env_key: + source = 'env' + configured = True + elif db_key: + source = 'database' + configured = True + else: + source = 'none' + configured = False + + usage_today = get_api_usage_today() + + return jsonify({ + 'configured': configured, + 'source': source, + 'usage_today': usage_today, + 'api_limit': config.GSM_API_DAILY_LIMIT + }) + + # POST: save key to database + data = request.get_json() or {} + key = data.get('key', '').strip() + + if not key: + return jsonify({'error': 'API key cannot be empty'}), 400 + + set_setting('gsm.opencellid.api_key', key) + logger.info("OpenCellID API key saved to database") + + return jsonify({ + 'status': 'saved', + 'configured': True, + 'source': 'database' }) @@ -844,7 +899,8 @@ def lookup_cell(): }) # Check API key and usage limit - if not config.GSM_OPENCELLID_API_KEY: + api_key = get_opencellid_api_key() + if not api_key: return jsonify({'error': 'OpenCellID API key not configured'}), 503 if not can_use_api(): current_usage = get_api_usage_today() @@ -857,7 +913,7 @@ def lookup_cell(): # Call OpenCellID API api_url = config.GSM_OPENCELLID_API_URL params = { - 'key': config.GSM_OPENCELLID_API_KEY, + 'key': api_key, 'mcc': mcc, 'mnc': mnc, 'lac': lac, @@ -1416,15 +1472,15 @@ def parse_tshark_output(line: str, field_order: list[str] | None = None) -> dict for i, logical_name in enumerate(field_order): field_map[logical_name] = parts[i] if parts[i] else None - # Convert types + # Convert types (use base 0 to auto-detect hex 0x prefix from tshark) ta_raw = field_map.get('ta') data = { 'type': 'device', - 'ta_value': int(ta_raw) if ta_raw else None, + 'ta_value': int(ta_raw, 0) if ta_raw else None, 'tmsi': field_map.get('tmsi'), 'imsi': field_map.get('imsi'), - 'lac': int(field_map['lac']) if field_map.get('lac') else None, - 'cid': int(field_map['cid']) if field_map.get('cid') else None, + 'lac': int(field_map['lac'], 0) if field_map.get('lac') else None, + 'cid': int(field_map['cid'], 0) if field_map.get('cid') else None, 'timestamp': datetime.now().isoformat() } diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js index 3d2cb8b..69ed89b 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -930,5 +930,101 @@ function switchSettingsTab(tabName) { if (typeof RecordingUI !== 'undefined') { RecordingUI.refresh(); } + } else if (tabName === 'apikeys') { + loadApiKeyStatus(); } } + +/** + * Load API key status into the API Keys settings tab + */ +function loadApiKeyStatus() { + const badge = document.getElementById('apiKeyStatusBadge'); + const desc = document.getElementById('apiKeyStatusDesc'); + const usage = document.getElementById('apiKeyUsageCount'); + const bar = document.getElementById('apiKeyUsageBar'); + + if (!badge) return; + + fetch('/gsm_spy/settings/api_key') + .then(r => r.json()) + .then(data => { + if (data.configured) { + badge.textContent = 'Configured'; + badge.className = 'asset-badge available'; + desc.textContent = 'Source: ' + (data.source === 'env' ? 'Environment variable' : 'Database'); + } else { + badge.textContent = 'Not configured'; + badge.className = 'asset-badge missing'; + desc.textContent = 'No API key set'; + } + if (usage) { + usage.textContent = (data.usage_today || 0) + ' / ' + (data.api_limit || 1000); + } + if (bar) { + const pct = Math.min(100, ((data.usage_today || 0) / (data.api_limit || 1000)) * 100); + bar.style.width = pct + '%'; + bar.style.background = pct > 90 ? 'var(--accent-red)' : pct > 70 ? 'var(--accent-yellow)' : 'var(--accent-cyan)'; + } + }) + .catch(() => { + badge.textContent = 'Error'; + badge.className = 'asset-badge missing'; + desc.textContent = 'Could not load status'; + }); +} + +/** + * Save API key from the settings input + */ +function saveApiKey() { + const input = document.getElementById('apiKeyInput'); + const result = document.getElementById('apiKeySaveResult'); + if (!input || !result) return; + + const key = input.value.trim(); + if (!key) { + result.style.display = 'block'; + result.style.color = 'var(--accent-red)'; + result.textContent = 'Please enter an API key.'; + return; + } + + result.style.display = 'block'; + result.style.color = 'var(--text-dim)'; + result.textContent = 'Saving...'; + + fetch('/gsm_spy/settings/api_key', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: key }) + }) + .then(r => r.json()) + .then(data => { + if (data.error) { + result.style.color = 'var(--accent-red)'; + result.textContent = data.error; + } else { + result.style.color = 'var(--accent-green)'; + result.textContent = 'API key saved successfully.'; + input.value = ''; + loadApiKeyStatus(); + // Hide the banner if visible + const banner = document.getElementById('apiKeyBanner'); + if (banner) banner.style.display = 'none'; + } + }) + .catch(() => { + result.style.color = 'var(--accent-red)'; + result.textContent = 'Error saving API key.'; + }); +} + +/** + * Toggle API key input visibility + */ +function toggleApiKeyVisibility() { + const input = document.getElementById('apiKeyInput'); + if (!input) return; + input.type = input.type === 'password' ? 'text' : 'password'; +} diff --git a/templates/partials/settings-modal.html b/templates/partials/settings-modal.html index b9c951b..ca890ab 100644 --- a/templates/partials/settings-modal.html +++ b/templates/partials/settings-modal.html @@ -17,6 +17,7 @@ + @@ -359,6 +360,70 @@ + +
+ Required for GSM cell tower geolocation. Get a free key at + opencellid.org/register + (1,000 lookups/day). +
+ +INTERCEPT_GSM_OPENCELLID_API_KEY takes priority over the saved key.
+ Keys saved here persist across restarts.
+