From 14707b96160808bb870a4728aaa8eb404d35122d Mon Sep 17 00:00:00 2001 From: Jukka Lampikoski Date: Tue, 10 Mar 2026 01:50:52 +0200 Subject: [PATCH] Add public availability API and settings panel Public saatavuus endpoint with API key + CORS protection for cuitunet.fi website integration. Admin settings tab for API key management and testing. Includes standalone widget page. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + api.php | 137 ++++++++++++++++++++++++++++++++++++++++++ index.html | 46 ++++++++++++++ saatavuus-widget.html | 108 +++++++++++++++++++++++++++++++++ script.js | 49 +++++++++++++++ 5 files changed, 341 insertions(+) create mode 100644 saatavuus-widget.html diff --git a/.gitignore b/.gitignore index 31f0b7e..6483aa2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ data/users.json data/changelog.json data/archive.json data/leads.json +data/config.json data/reset_tokens.json data/login_attempts.json data/backups/ diff --git a/api.php b/api.php index 11f86fb..e6cae0b 100644 --- a/api.php +++ b/api.php @@ -17,6 +17,7 @@ define('ARCHIVE_FILE', DATA_DIR . '/archive.json'); define('LEADS_FILE', DATA_DIR . '/leads.json'); define('TOKENS_FILE', DATA_DIR . '/reset_tokens.json'); define('RATE_FILE', DATA_DIR . '/login_attempts.json'); +define('CONFIG_FILE', DATA_DIR . '/config.json'); define('SITE_URL', 'https://intra.cuitunet.fi'); // Sähköpostiasetukset @@ -88,6 +89,23 @@ function getClientIp(): string { return $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } +// ==================== CONFIG ==================== + +function loadConfig(): array { + if (!file_exists(CONFIG_FILE)) return []; + return json_decode(file_get_contents(CONFIG_FILE), true) ?: []; +} + +function saveConfig(array $config): void { + file_put_contents(CONFIG_FILE, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); +} + +function normalizeAddress(string $addr): string { + $addr = mb_strtolower(trim($addr)); + $addr = preg_replace('/\s+/', ' ', $addr); + return $addr; +} + // ==================== EMAIL ==================== function sendMail(string $to, string $subject, string $htmlBody): bool { @@ -247,6 +265,125 @@ function parseLiittymat(array $input): array { switch ($action) { + // ---------- SAATAVUUS (julkinen API) ---------- + case 'saatavuus': + // CORS - salli cuitunet.fi + $config = loadConfig(); + $allowedOrigins = $config['cors_origins'] ?? ['https://cuitunet.fi', 'https://www.cuitunet.fi']; + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + if (in_array($origin, $allowedOrigins)) { + header("Access-Control-Allow-Origin: $origin"); + header('Access-Control-Allow-Methods: GET, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type, X-Api-Key'); + } + if ($method === 'OPTIONS') { http_response_code(204); break; } + + // API-avain tarkistus + $apiKey = $config['api_key'] ?? ''; + $providedKey = $_GET['key'] ?? ($_SERVER['HTTP_X_API_KEY'] ?? ''); + if (empty($apiKey) || $providedKey !== $apiKey) { + http_response_code(403); + echo json_encode(['error' => 'Virheellinen API-avain']); + break; + } + + $query = normalizeAddress($_GET['osoite'] ?? ''); + $postinumero = trim($_GET['postinumero'] ?? ''); + + if (empty($query) && empty($postinumero)) { + http_response_code(400); + echo json_encode(['error' => 'Anna osoite tai postinumero']); + break; + } + + $customers = loadCustomers(); + $matches = []; + foreach ($customers as $c) { + foreach ($c['liittymat'] ?? [] as $l) { + $addr = normalizeAddress($l['asennusosoite'] ?? ''); + $zip = trim($l['postinumero'] ?? ''); + $city = mb_strtolower(trim($l['kaupunki'] ?? '')); + $hit = false; + + // Postinumero-haku + if (!empty($postinumero) && $zip === $postinumero) { + $hit = true; + } + + // Osoitehaku (sisältää haun) + if (!empty($query) && !empty($addr)) { + if (str_contains($addr, $query) || str_contains($query, $addr)) { + $hit = true; + } + // Kadunnimi-match (ilman numeroa) + $queryStreet = preg_replace('/\d+.*$/', '', $query); + $addrStreet = preg_replace('/\d+.*$/', '', $addr); + if (!empty(trim($queryStreet)) && !empty(trim($addrStreet)) && str_contains(trim($addrStreet), trim($queryStreet))) { + $hit = true; + } + } + + if ($hit) { + // Palauta VAIN osoitetieto ja nopeus - ei asiakastietoja + $matches[] = [ + 'osoite' => $l['asennusosoite'] ?? '', + 'postinumero' => $zip, + 'kaupunki' => $l['kaupunki'] ?? '', + 'nopeus' => $l['liittymanopeus'] ?? '', + ]; + } + } + } + + // Poista duplikaatit (sama osoite eri asiakkailla) + $unique = []; + $seen = []; + foreach ($matches as $m) { + $key = normalizeAddress($m['osoite'] . $m['postinumero']); + if (!isset($seen[$key])) { + $unique[] = $m; + $seen[$key] = true; + } + } + + echo json_encode([ + 'saatavilla' => count($unique) > 0, + 'kohteet' => $unique, + 'maara' => count($unique), + ]); + break; + + // ---------- CONFIG (admin) ---------- + case 'config': + requireAdmin(); + echo json_encode(loadConfig()); + break; + + case 'config_update': + requireAdmin(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $config = loadConfig(); + if (isset($input['api_key'])) $config['api_key'] = trim($input['api_key']); + if (isset($input['cors_origins'])) { + $origins = array_filter(array_map('trim', explode("\n", $input['cors_origins']))); + $config['cors_origins'] = array_values($origins); + } + saveConfig($config); + addLog('config_update', '', '', 'Päivitti asetukset'); + echo json_encode($config); + break; + + case 'generate_api_key': + requireAdmin(); + if ($method !== 'POST') break; + $config = loadConfig(); + $config['api_key'] = bin2hex(random_bytes(16)); + saveConfig($config); + addLog('config_update', '', '', 'Generoi uuden API-avaimen'); + echo json_encode($config); + break; + // ---------- CAPTCHA ---------- case 'captcha': $a = rand(1, 20); diff --git a/index.html b/index.html index f3bca14..ee6a01c 100644 --- a/index.html +++ b/index.html @@ -76,6 +76,7 @@ + @@ -258,6 +259,51 @@ + +
+
+
+

Saatavuus-API

+

Julkinen API jolla cuitunet.fi voi tarkistaa kuituverkon saatavuuden osoitteessa. Palauttaa vain osoite + nopeus - ei asiakastietoja.

+
+
+ +
+ + +
+
+
+ + +
+
+ +
+
+

API-ohjeet

+
+
Endpoint:
GET https://intra.cuitunet.fi/api.php?action=saatavuus
+
Parametrit:
+ • key = API-avain (pakollinen)
+ • osoite = Haettava osoite (esim. "Kauppakatu 5")
+ • postinumero = Postinumero (esim. "20100")
+ Anna vähintään toinen: osoite tai postinumero.
+
Esimerkki:
+ api.php?action=saatavuus&key=AVAIN&osoite=Kauppakatu+5
+
Vastaus:
+ {"saatavilla":true,"kohteet":[{"osoite":"...","postinumero":"...","kaupunki":"...","nopeus":"..."}],"maara":1}
+
+

Testaa API

+
+ + +
+ +
+
+
+

CuituNet Intra — Asiakashallintajärjestelmä

diff --git a/saatavuus-widget.html b/saatavuus-widget.html new file mode 100644 index 0000000..b476694 --- /dev/null +++ b/saatavuus-widget.html @@ -0,0 +1,108 @@ + + + + + + + Kuituverkon saatavuus - CuituNet + + + +
+

Tarkista kuituverkon saatavuus

+

Syötä osoitteesi tai postinumerosi ja tarkista onko kuituliittymä saatavilla.

+
+ + +
+
+
+ + + + diff --git a/script.js b/script.js index 90b4826..bf33b02 100644 --- a/script.js +++ b/script.js @@ -169,6 +169,7 @@ async function showDashboard() { document.getElementById('user-info').textContent = currentUser.nimi || currentUser.username; // Näytä Käyttäjät-tab vain adminille document.getElementById('tab-users').style.display = currentUser.role === 'admin' ? '' : 'none'; + document.getElementById('tab-settings').style.display = currentUser.role === 'admin' ? '' : 'none'; await loadCustomers(); } @@ -186,6 +187,7 @@ document.querySelectorAll('.tab').forEach(tab => { if (target === 'archive') loadArchive(); if (target === 'changelog') loadChangelog(); if (target === 'users') loadUsers(); + if (target === 'settings') loadSettings(); }); }); @@ -818,6 +820,7 @@ const actionLabels = { lead_update: 'Muokkasi liidiä', lead_delete: 'Poisti liidin', lead_to_customer: 'Muutti liidin asiakkaaksi', + config_update: 'Päivitti asetukset', }; async function loadChangelog() { @@ -917,6 +920,52 @@ document.getElementById('user-form').addEventListener('submit', async (e) => { } catch (e) { alert(e.message); } }); +// ==================== SETTINGS ==================== + +async function loadSettings() { + try { + const config = await apiCall('config'); + document.getElementById('settings-api-key').value = config.api_key || ''; + document.getElementById('settings-cors').value = (config.cors_origins || ['https://cuitunet.fi', 'https://www.cuitunet.fi']).join('\n'); + const key = config.api_key || 'AVAIN'; + document.getElementById('api-example-url').textContent = `api.php?action=saatavuus&key=${key}&osoite=Kauppakatu+5`; + } catch (e) { console.error(e); } +} + +document.getElementById('btn-generate-key').addEventListener('click', async () => { + try { + const config = await apiCall('generate_api_key', 'POST'); + document.getElementById('settings-api-key').value = config.api_key || ''; + document.getElementById('api-example-url').textContent = `api.php?action=saatavuus&key=${config.api_key}&osoite=Kauppakatu+5`; + } catch (e) { alert(e.message); } +}); + +document.getElementById('btn-save-settings').addEventListener('click', async () => { + try { + const config = await apiCall('config_update', 'POST', { + api_key: document.getElementById('settings-api-key').value, + cors_origins: document.getElementById('settings-cors').value, + }); + alert('Asetukset tallennettu!'); + } catch (e) { alert(e.message); } +}); + +document.getElementById('btn-test-api').addEventListener('click', async () => { + const address = document.getElementById('test-api-address').value.trim(); + const apiKey = document.getElementById('settings-api-key').value; + if (!address) { alert('Anna osoite tai postinumero'); return; } + const result = document.getElementById('test-api-result'); + result.style.display = 'block'; + result.textContent = 'Haetaan...'; + try { + const isZip = /^\d{5}$/.test(address); + const param = isZip ? `postinumero=${encodeURIComponent(address)}` : `osoite=${encodeURIComponent(address)}`; + const res = await fetch(`${API}?action=saatavuus&key=${encodeURIComponent(apiKey)}&${param}`); + const data = await res.json(); + result.textContent = JSON.stringify(data, null, 2); + } catch (e) { result.textContent = 'Virhe: ' + e.message; } +}); + // ==================== MODALS ==================== customerModal.addEventListener('click', (e) => { if (e.target === customerModal) customerModal.style.display = 'none'; });