| Current Path : /home/atelierpru/www/airvalue/adm-api/ |
| Current File : /home/atelierpru/www/airvalue/adm-api/proxy.php |
<?php
error_reporting(E_ALL);
ini_set('display_errors', '0');
register_shutdown_function(function () {
$err = error_get_last();
if ($err && in_array($err['type'], array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR), true)) {
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
}
echo json_encode(array(
'ok' => false,
'error' => 'PHP fatal: ' . $err['message'],
'file' => basename($err['file']),
'line' => $err['line'],
));
}
});
define('IUDO_EMAIL', 'elibensadoun@gmail.com');
define('IUDO_PASSWORD', 'Fefef430430@');
define('IUDO_API_BASE', 'https://prod-plu-back.iudo.co');
define('ADMIN_KEY', 'Fefef430430@');
define('BUILD_ID', '20260801b');
define('CACHE_TTL', 7 * 86400);
define('CACHE_PREFIX', 'iudo_cache_');
define('HISTORY_MAX', 50);
// Stockage PERSISTANT (cache + historique), indépendant du build front et du
// répertoire temporaire (qui peut être purgé). On tente d'abord un dossier
// « data » à côté de proxy.php ; si non inscriptible, repli sur le tmp système.
function storeDir()
{
static $dir = null;
if ($dir !== null) return $dir;
$candidate = __DIR__ . '/data';
if (!is_dir($candidate)) {
@mkdir($candidate, 0775, true);
}
if (is_dir($candidate) && is_writable($candidate)) {
// Empêche le listing / l'accès HTTP direct au dossier de données.
$ht = $candidate . '/.htaccess';
if (!is_file($ht)) {
@file_put_contents($ht, "Require all denied\nDeny from all\n");
}
$dir = $candidate;
} else {
$dir = sys_get_temp_dir();
}
return $dir;
}
function cacheDir()
{
$dir = storeDir() . '/cache';
if (!is_dir($dir)) @mkdir($dir, 0775, true);
return is_dir($dir) && is_writable($dir) ? $dir : storeDir();
}
function historyFile()
{
return storeDir() . '/history.json';
}
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
function readHistory()
{
$file = historyFile();
if (!is_file($file)) return array();
$raw = @file_get_contents($file);
if ($raw === false || $raw === '') return array();
$data = json_decode($raw, true);
return is_array($data) ? $data : array();
}
function writeHistory($entries)
{
$file = historyFile();
$fp = @fopen($file, 'c+');
if (!$fp) return false;
$ok = false;
if (flock($fp, LOCK_EX)) {
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode(array_values($entries), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
fflush($fp);
flock($fp, LOCK_UN);
$ok = true;
}
fclose($fp);
return $ok;
}
function respond($payload, $httpCode = 200)
{
http_response_code($httpCode);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function fail($action, $error, $httpCode = 400, $extra = array())
{
respond(array_merge(array(
'ok' => false,
'status' => $httpCode,
'action' => $action,
'error' => $error,
'build' => BUILD_ID,
), $extra), $httpCode);
}
// ---- Clés API tierces (stockage persistant, jamais renvoyées en clair) ------
// Allowlist des clés gérables depuis l'admin. name => libellé affiché.
function knownApiKeys()
{
return array(
'georisques' => 'Géorisques — jeton API Cerbère',
'paris_data' => 'Paris Data (opendata.paris.fr) — clé API',
'cerema' => 'Cerema Datafoncier — clé / accès',
'ign' => 'IGN Géoplateforme — clé (si requise)',
'dvf' => 'DVF+ / API données foncières — clé (si requise)',
);
}
function apiKeysFile()
{
return storeDir() . '/api_keys.json';
}
function readApiKeys()
{
$file = apiKeysFile();
if (!is_file($file)) return array();
$raw = @file_get_contents($file);
if ($raw === false || $raw === '') return array();
$data = json_decode($raw, true);
return is_array($data) ? $data : array();
}
function writeApiKeys($keys)
{
$file = apiKeysFile();
$fp = @fopen($file, 'c+');
if (!$fp) return false;
$ok = false;
if (flock($fp, LOCK_EX)) {
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($keys, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
fflush($fp);
flock($fp, LOCK_UN);
$ok = true;
}
fclose($fp);
return $ok;
}
// Indice masqué d'une valeur : ne révèle que les 4 derniers caractères.
function maskKey($value)
{
$len = strlen($value);
if ($len === 0) return '';
if ($len <= 4) return str_repeat("\xE2\x80\xA2", $len);
return str_repeat("\xE2\x80\xA2", 8) . substr($value, -4);
}
// Accès en clair pour l'usage interne (appels aux API tierces à venir).
function apiKeyValue($name)
{
$keys = readApiKeys();
return isset($keys[$name]) ? (string) $keys[$name] : '';
}
$action = isset($_GET['action']) ? (string) $_GET['action'] : '';
$adminKey = isset($_SERVER['HTTP_X_ADMIN_KEY']) ? (string) $_SERVER['HTTP_X_ADMIN_KEY'] : '';
// Les actions « mvp_* » (socle national : BAN, cadastre, GPU, BD TOPO — données
// publiques) sont accessibles sans clé admin : la landing les appelle
// directement. Tout le reste (iudo, clés, historique) reste protégé.
$isPublicAction = strpos($action, 'mvp_') === 0;
if (!$isPublicAction && !hash_equals(ADMIN_KEY, $adminKey)) {
fail($action, 'Forbidden', 403);
}
if ($action === 'ping') {
$cacheFiles = glob(cacheDir() . '/' . CACHE_PREFIX . '*.json');
respond(array(
'ok' => true,
'action' => 'ping',
'build' => BUILD_ID,
'php' => PHP_VERSION,
'curl' => function_exists('curl_init'),
'store' => storeDir(),
'store_w' => is_writable(storeDir()),
'persistent' => storeDir() !== sys_get_temp_dir(),
'cache' => is_array($cacheFiles) ? count($cacheFiles) : 0,
'history' => count(readHistory()),
'keys_set' => count(readApiKeys()),
'session' => sessionInfo(),
'time' => date('c'),
));
}
// Session iudo persistante : le cookie jar vit dans le stockage persistant
// (adm-api/data/) et non plus dans le tmp système purgeable — on ne se
// reconnecte que si le cookie Authentication est absent/expiré (cf.
// ensureSession), le refresh périodique GitHub Actions maintient la session.
function cookieJarPath()
{
static $path = null;
if ($path !== null) return $path;
$path = storeDir() . '/iudo_cookies.txt';
// Migration one-shot depuis l'ancien emplacement tmp pour ne pas perdre
// la session en cours lors du déploiement.
$legacy = sys_get_temp_dir() . '/iudo_cookies_shared.txt';
if (!is_file($path) && is_file($legacy)) {
@copy($legacy, $path);
}
return $path;
}
$cookieJar = cookieJarPath();
// État de la session iudo d'après le cookie jar : active + date d'expiration
// du cookie Authentication.
function sessionInfo()
{
$info = array('active' => false, 'expires' => null);
$jar = cookieJarPath();
if (!is_file($jar)) return $info;
$lines = @file($jar, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (!$lines) return $info;
$now = time();
foreach ($lines as $line) {
if ($line !== '' && $line[0] === '#') {
if (strpos($line, '#HttpOnly_') !== 0) continue;
$line = substr($line, strlen('#HttpOnly_'));
}
$parts = explode("\t", $line);
if (count($parts) >= 7 && $parts[5] === 'Authentication') {
$expiry = (int) $parts[4];
if ($expiry === 0 || $expiry > $now) {
$info['active'] = true;
$info['expires'] = $expiry === 0 ? null : date('c', $expiry);
return $info;
}
$info['expires'] = $expiry ? date('c', $expiry) : null;
}
}
return $info;
}
$iudoHeaders = array(
'Accept: application/json, text/plain, */*',
'Content-Type: application/json',
'Origin: https://app.iudo.co',
'Referer: https://app.iudo.co/',
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
);
function iudoCurl($method, $path, $body = null)
{
global $cookieJar, $iudoHeaders;
$ch = curl_init();
curl_setopt_array($ch, array(
CURLOPT_URL => IUDO_API_BASE . $path,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => false,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => $iudoHeaders,
CURLOPT_COOKIEJAR => $cookieJar,
CURLOPT_COOKIEFILE => $cookieJar,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_ENCODING => '',
));
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body !== null ? $body : '');
}
$responseBody = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_errno($ch) ? curl_error($ch) : null;
curl_close($ch);
return array(
'status' => $status,
'body' => $responseBody === false ? '' : (string) $responseBody,
'error' => $curlError,
);
}
function hasValidAuthCookie()
{
$info = sessionInfo();
return $info['active'];
}
function iudoLogin()
{
$payload = json_encode(array(
'email' => IUDO_EMAIL,
'password' => IUDO_PASSWORD,
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return iudoCurl('POST', '/auth/log-in', $payload);
}
function iudoRefresh()
{
return iudoCurl('GET', '/auth/refresh');
}
function ensureSession()
{
if (hasValidAuthCookie()) {
return;
}
iudoLogin();
}
function iudoAuthedCall($method, $path, $body = null)
{
ensureSession();
$res = iudoCurl($method, $path, $body);
if ($res['status'] === 401) {
$refresh = iudoRefresh();
if ($refresh['status'] >= 200 && $refresh['status'] < 300) {
$res = iudoCurl($method, $path, $body);
}
if ($res['status'] === 401) {
iudoLogin();
$res = iudoCurl($method, $path, $body);
}
}
return $res;
}
function reqParam($name, $action)
{
if (!isset($_GET[$name]) || $_GET[$name] === '') {
fail($action, "Parametre manquant : $name", 400);
}
return (string) $_GET[$name];
}
function rawPostBody()
{
$body = file_get_contents('php://input');
return $body === false ? '' : $body;
}
function emitIudoResponse($action, $res, $startedAt, $cacheFile = null)
{
$durationMs = (int) round((microtime(true) - $startedAt) * 1000);
if ($res['error'] !== null && $res['status'] === 0) {
respond(array(
'ok' => false,
'status' => 0,
'action' => $action,
'duration_ms' => $durationMs,
'error' => 'Erreur reseau curl : ' . $res['error'],
'raw' => '',
'build' => BUILD_ID,
), 502);
}
$status = $res['status'];
$rawBody = $res['body'];
$data = null;
if ($rawBody !== '') {
$decoded = json_decode($rawBody, true);
$data = json_last_error() === JSON_ERROR_NONE ? $decoded : null;
}
$ok = $status >= 200 && $status < 300;
if ($ok) {
$payload = array(
'ok' => true,
'status' => $status,
'action' => $action,
'duration_ms' => $durationMs,
'data' => $data,
'raw' => ($data === null && $rawBody !== '') ? $rawBody : null,
'cached' => false,
'build' => BUILD_ID,
);
// Règle Step 10 : ne jamais mettre en cache une réponse métier vide
// (un 200 avec data null n'est pas un résultat terminé).
if ($cacheFile !== null && $data !== null) {
@file_put_contents($cacheFile, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
}
respond($payload, 200);
}
$errorMsg = 'HTTP ' . $status;
if (is_array($data) && isset($data['message'])) {
$errorMsg .= ' : ' . (is_array($data['message'])
? implode(', ', $data['message'])
: (string) $data['message']);
} elseif (is_array($data) && isset($data['error_code'])) {
$errorMsg .= ' : ' . $data['error_code'];
}
respond(array(
'ok' => false,
'status' => $status,
'action' => $action,
'duration_ms' => $durationMs,
'error' => $errorMsg,
'data' => $data,
'raw' => $rawBody,
'build' => BUILD_ID,
), 200);
}
if ($action === '') {
fail('', 'Parametre action manquant', 400);
}
// Retourne array(method, path, body, cacheable) ou null si action inconnue.
// cacheable = false pour les actions qui écrivent côté iudo (tracking, quota).
function iudoRoute($action)
{
switch ($action) {
case 'parcels':
return array('GET', '/parcels?lat=' . urlencode(reqParam('lat', $action)) . '&lon=' . urlencode(reqParam('lon', $action)), null, true);
case 'parcel_detail':
return array('GET', '/parcels/' . rawurlencode(reqParam('parcelId', $action)), null, true);
case 'parcel_coords':
return array('GET', '/parcels/' . rawurlencode(reqParam('parcelId', $action)) . '/coordinates', null, true);
case 'parcel_units':
return array('GET', '/parcels/' . rawurlencode(reqParam('parcelId', $action)) . '/units?no_adu=', null, true);
case 'urban_zones':
return array('GET', '/urban_zones/zones/' . rawurlencode(reqParam('insee', $action)), null, true);
case 'airtable_plu':
$zoneId = reqParam('zoneId', $action);
$body = rawPostBody();
if (trim($body) === '') {
$body = json_encode(array(
'urbanZoneParams' => array('id' => $zoneId, 'type' => 'PLU'),
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
return array('POST', '/airtable/plu/' . rawurlencode($zoneId), $body, true);
case 'building_rights':
return array('GET', '/airtable/plu/' . rawurlencode(reqParam('zoneId', $action)) . '/building-rights', null, true);
case 'town_psc':
return array('GET', '/town/psc/' . rawurlencode(reqParam('insee', $action)), null, true);
case 'docurba':
return array('GET', '/docurba/urban-zone-type/' . rawurlencode(reqParam('insee', $action)), null, true);
case 'gpu_plu_info':
return array('GET', '/geoportail-urbanisme/plu-info-insee/' . rawurlencode(reqParam('insee', $action)), null, true);
case 'psmv':
return array('GET', '/psmv/parcels/' . rawurlencode(reqParam('parcelId', $action)), null, true);
case 'districts':
return array('GET', '/districts/insee/' . rawurlencode(reqParam('insee', $action)), null, true);
case 'dvf_stats':
return array('GET', '/dvf/sales-stats/' . rawurlencode(reqParam('insee', $action)) . '?startYear=2020&endYear=2025', null, true);
case 'dvf_series':
return array('GET', '/dvf/sales-stats-series/' . rawurlencode(reqParam('insee', $action)) . '?startYear=2020&endYear=2025', null, true);
case 'dvf_parcels':
return array('GET', '/dvf/sales/parcels/' . rawurlencode(reqParam('parcelId', $action)), null, true);
case 'loyers':
return array('GET', '/loyers/2025/' . rawurlencode(reqParam('insee', $action)), null, true);
case 'adu_totals':
return array('GET', '/adu/totals/' . rawurlencode(reqParam('insee', $action)), null, true);
case 'adu_parcels':
return array('GET', '/adu/parcels/' . rawurlencode(reqParam('parcelId', $action)), null, true);
case 'adu_insee':
return array('GET', '/adu/' . rawurlencode(reqParam('insee', $action)), null, true);
case 'prevention_plans':
return array('GET', '/sup-surf/prevention-plans/parcels/' . rawurlencode(reqParam('parcelId', $action)), null, true);
case 'georisques':
return array('GET', '/georisques/address-risks?lon=' . urlencode(reqParam('lon', $action))
. '&lat=' . urlencode(reqParam('lat', $action))
. '&codeInsee=' . urlencode(reqParam('insee', $action)), null, true);
case 'analytics':
$body = rawPostBody();
if (trim($body) === '') {
$body = '{}';
}
return array('POST', '/analytics/add', $body, false);
case 'search_count':
return array('POST', '/user/search-count', '', false);
}
return null;
}
$startedAt = microtime(true);
if ($action === 'login') {
$res = iudoLogin();
emitIudoResponse($action, $res, $startedAt);
}
if ($action === 'cache_clear') {
$files = glob(cacheDir() . '/' . CACHE_PREFIX . '*.json');
$deleted = 0;
if (is_array($files)) {
foreach ($files as $f) {
if (@unlink($f)) $deleted++;
}
}
respond(array('ok' => true, 'action' => 'cache_clear', 'deleted' => $deleted, 'build' => BUILD_ID));
}
// ---- Historique serveur (partagé, persistant) ------------------------------
// Liste des dernières adresses analysées, indépendante du navigateur et du
// build front. Chaque entrée conserve la feature géocodée (BAN) pour permettre
// le rejeu côté client.
if ($action === 'history') {
respond(array('ok' => true, 'action' => 'history', 'entries' => readHistory(), 'build' => BUILD_ID));
}
if ($action === 'history_add') {
$body = json_decode(rawPostBody(), true);
$banId = (is_array($body) && isset($body['banId'])) ? (string) $body['banId'] : '';
if ($banId === '') {
fail($action, 'banId manquant', 400);
}
$entry = array(
'banId' => $banId,
'label' => isset($body['label']) ? (string) $body['label'] : '',
'insee' => isset($body['insee']) ? (string) $body['insee'] : '',
'feature' => isset($body['feature']) ? $body['feature'] : null,
// Chaîne (pas de cast int) : évite l'overflow des entiers 32 bits sur
// certains hébergements (le timestamp ms ~1.78e12 dépasse INT32_MAX,
// ce qui produisait des dates absurdes type 1904).
'savedAt' => sprintf('%.0f', microtime(true) * 1000),
);
$entries = readHistory();
$filtered = array();
foreach ($entries as $e) {
if (!is_array($e) || !isset($e['banId']) || $e['banId'] !== $banId) {
$filtered[] = $e;
}
}
array_unshift($filtered, $entry);
if (count($filtered) > HISTORY_MAX) {
$filtered = array_slice($filtered, 0, HISTORY_MAX);
}
$ok = writeHistory($filtered);
respond(array('ok' => $ok, 'action' => 'history_add', 'count' => count($filtered), 'build' => BUILD_ID));
}
if ($action === 'history_clear') {
$ok = writeHistory(array());
respond(array('ok' => $ok, 'action' => 'history_clear', 'build' => BUILD_ID));
}
// ---- Clés API : lecture (masquée) et écriture --------------------------------
if ($action === 'keys_get') {
$stored = readApiKeys();
$out = array();
foreach (knownApiKeys() as $name => $label) {
$val = isset($stored[$name]) ? (string) $stored[$name] : '';
$out[] = array(
'name' => $name,
'label' => $label,
'set' => $val !== '',
'hint' => $val !== '' ? maskKey($val) : '',
);
}
respond(array('ok' => true, 'action' => 'keys_get', 'keys' => $out, 'build' => BUILD_ID));
}
if ($action === 'keys_set') {
$body = json_decode(rawPostBody(), true);
$name = (is_array($body) && isset($body['name'])) ? (string) $body['name'] : '';
if (!array_key_exists($name, knownApiKeys())) {
fail($action, 'Clé inconnue', 400);
}
$value = (is_array($body) && isset($body['value'])) ? trim((string) $body['value']) : '';
$keys = readApiKeys();
if ($value === '') {
unset($keys[$name]);
} else {
$keys[$name] = $value;
}
$ok = writeApiKeys($keys);
respond(array(
'ok' => $ok,
'action' => 'keys_set',
'name' => $name,
'set' => $value !== '',
'hint' => $value !== '' ? maskKey($value) : '',
'build' => BUILD_ID,
));
}
// ---- MVP V1 : socle national (BAN, cadastre IGN, GPU, BD TOPO) --------------
// Actions publiques appelées par l'onglet MVP de /adm ET par la landing : le
// navigateur ne parle qu'à airvalue.fr, les appels externes se font ici.
// Docs métier : STEP 1 (S1.1-S1.8), STEP 7, STEP 11, P2, P3.
function publicCurl($url)
{
$ch = curl_init();
curl_setopt_array($ch, array(
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 25,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_HTTPHEADER => array('Accept: application/json'),
CURLOPT_USERAGENT => 'AirValue/1.0 (analyse fonciere; contact@airvalue.fr)',
CURLOPT_ENCODING => '',
));
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_errno($ch) ? curl_error($ch) : null;
curl_close($ch);
return array('status' => $code, 'body' => $body === false ? '' : (string) $body, 'error' => $error);
}
// Géométrie Point GeoJSON encodée pour apicarto.
function gpuPointGeom($lon, $lat)
{
return rawurlencode('{"type":"Point","coordinates":[' . $lon . ',' . $lat . ']}');
}
// BBox WFS Géoplateforme autour d'un point (CRS:84 : lon,lat).
function wfsBboxUrl($typename, $lon, $lat, $dLon, $dLat, $count)
{
return 'https://data.geopf.fr/wfs/ows?SERVICE=WFS&VERSION=2.0.0&REQUEST=GetFeature'
. '&TYPENAME=' . rawurlencode($typename) . '&TYPENAMES=' . rawurlencode($typename)
. '&OUTPUTFORMAT=application/json&COUNT=' . (int) $count
. '&SRSNAME=CRS:84&BBOX=' . ($lon - $dLon) . ',' . ($lat - $dLat) . ',' . ($lon + $dLon) . ',' . ($lat + $dLat) . ',CRS:84';
}
// Retourne l'URL externe pour une action mvp simple, ou null.
function mvpRoute($action)
{
switch ($action) {
case 'mvp_geocode':
// S1.1 — géocodage BAN (adresse libre -> position + score + INSEE).
$q = reqParam('q', $action);
$limit = isset($_GET['limit']) ? max(1, min(10, (int) $_GET['limit'])) : 5;
return 'https://api-adresse.data.gouv.fr/search/?q=' . rawurlencode($q) . '&limit=' . $limit;
case 'mvp_batiments':
// S1.3 / P3 — bâtiments BD TOPO autour du point (géométrie + hauteur
// + nb niveaux par bâtiment ; sert aussi aux mitoyens et à la minimap).
$lon = (float) reqParam('lon', $action);
$lat = (float) reqParam('lat', $action);
return wfsBboxUrl('BDTOPO_V3:batiment', $lon, $lat, 0.0026, 0.0017, 400);
case 'mvp_troncon':
// P2 — tronçons de route proches (largeur_de_chaussee : prudente,
// measurement_type ROADWAY_WIDTH, jamais présentée comme réglementaire).
$lon = (float) reqParam('lon', $action);
$lat = (float) reqParam('lat', $action);
return wfsBboxUrl('BDTOPO_V3:troncon_de_route', $lon, $lat, 0.0009, 0.0006, 50);
case 'mvp_zone_urba':
// S1.7 — zone(s) PLU intersectant le point (GPU).
$lon = (float) reqParam('lon', $action);
$lat = (float) reqParam('lat', $action);
return 'https://apicarto.ign.fr/api/gpu/zone-urba?geom=' . gpuPointGeom($lon, $lat);
case 'mvp_document':
// S1.4 / STEP 11 — document d'urbanisme applicable (GPU).
$lon = (float) reqParam('lon', $action);
$lat = (float) reqParam('lat', $action);
return 'https://apicarto.ign.fr/api/gpu/document?geom=' . gpuPointGeom($lon, $lat);
case 'mvp_servitudes':
// S1.8 — servitudes d'utilité publique (assiettes surfaciques).
$lon = (float) reqParam('lon', $action);
$lat = (float) reqParam('lat', $action);
return 'https://apicarto.ign.fr/api/gpu/assiette-sup-s?geom=' . gpuPointGeom($lon, $lat);
case 'mvp_pvp':
// Largeur de voie — Plan de Voirie de Paris (opendata.paris.fr,
// Paris uniquement). layer : emprises | chaussees | trottoirs.
$lon = (float) reqParam('lon', $action);
$lat = (float) reqParam('lat', $action);
$pvp = array(
'emprises' => 'denominations-emprises-voies-actuelles',
'chaussees' => 'plan-de-voirie-chaussees',
'trottoirs' => 'plan-de-voirie-trottoirs-emprises',
);
$layer = isset($_GET['layer']) ? (string) $_GET['layer'] : 'emprises';
if (!isset($pvp[$layer])) return null;
return 'https://opendata.paris.fr/api/explore/v2.1/catalog/datasets/' . $pvp[$layer]
. '/records?where=' . rawurlencode("within_distance(geo_shape, geom'POINT(" . $lon . ' ' . $lat . ")', 60m)")
. '&limit=50';
case 'mvp_parcelles_zone':
// Largeur de voie — parcelles cadastrales de la zone (~±55 m) pour
// mesurer le vide entre parcelles opposées (emprise publique).
$lon = (float) reqParam('lon', $action);
$lat = (float) reqParam('lat', $action);
return wfsBboxUrl('CADASTRALPARCELS.PARCELLAIRE_EXPRESS:parcelle', $lon, $lat, 0.0005, 0.0005, 100);
case 'mvp_apur':
// Largeur de voie — tronçons APUR enrichis (data.smartidf.services,
// Paris/IDF : largeurs trottoirs et attributs de voie).
$lon = (float) reqParam('lon', $action);
$lat = (float) reqParam('lat', $action);
return 'https://data.smartidf.services/api/explore/v2.1/catalog/datasets/troncon-voie-paris'
. '/records?where=' . rawurlencode("within_distance(geo_shape, geom'POINT(" . $lon . ' ' . $lat . ")', 60m)")
. '&limit=20';
case 'mvp_alti':
// Altitude NGF du sol (service altimétrie IGN) — sert à caler les
// volumes 3D sur le sol photoréaliste Google (hauteur ellipsoïdale
// = NGF + ondulation du géoïde ~44,6 m en France métropolitaine).
$lon = (float) reqParam('lon', $action);
$lat = (float) reqParam('lat', $action);
return 'https://data.geopf.fr/altimetrie/1.0/calcul/alti/rest/elevation.json'
. '?lon=' . $lon . '&lat=' . $lat . '&resource=ign_rge_alti_wld&zonly=false&measures=false';
case 'mvp_osm_width':
// Largeur de voie — tag width OSM (Overpass, fallback partiel).
$lon = (float) reqParam('lon', $action);
$lat = (float) reqParam('lat', $action);
return 'https://overpass-api.de/api/interpreter?data=' . rawurlencode(
'[out:json][timeout:10];way(around:40,' . $lat . ',' . $lon . ')[highway]["width"];out tags geom;'
);
}
return null;
}
if ($isPublicAction) {
$noCache = isset($_GET['nocache']) && $_GET['nocache'] === '1';
if ($action === 'mvp_prescriptions') {
// S1.8 — prescriptions surfaciques + linéaires + ponctuelles (GPU),
// fusionnées en une seule FeatureCollection taguée par couche.
$lon = (float) reqParam('lon', $action);
$lat = (float) reqParam('lat', $action);
$geom = gpuPointGeom($lon, $lat);
$cacheFile = cacheDir() . '/' . CACHE_PREFIX . md5($action . '|' . $lon . '|' . $lat) . '.json';
if (!$noCache && is_file($cacheFile) && (time() - filemtime($cacheFile)) < CACHE_TTL) {
$payload = json_decode(file_get_contents($cacheFile), true);
if (is_array($payload) && isset($payload['ok']) && $payload['ok'] === true) {
$payload['cached'] = true;
$payload['cached_at'] = date('c', filemtime($cacheFile));
respond($payload, 200);
}
}
$layers = array(
'SURF' => 'https://apicarto.ign.fr/api/gpu/prescription-surf?geom=' . $geom,
'LIN' => 'https://apicarto.ign.fr/api/gpu/prescription-lin?geom=' . $geom,
'PCT' => 'https://apicarto.ign.fr/api/gpu/prescription-pct?geom=' . $geom,
);
$features = array();
$layerStatus = array();
$anyOk = false;
foreach ($layers as $tag => $url) {
$res = publicCurl($url);
$ok = $res['error'] === null && $res['status'] >= 200 && $res['status'] < 300;
$layerStatus[$tag] = $ok ? 'OK' : 'ERROR';
if (!$ok) continue;
$fc = json_decode($res['body'], true);
if (is_array($fc) && isset($fc['features']) && is_array($fc['features'])) {
$anyOk = true;
foreach ($fc['features'] as $f) {
if (!is_array($f)) continue;
if (!isset($f['properties']) || !is_array($f['properties'])) $f['properties'] = array();
$f['properties']['gpu_layer'] = $tag;
$features[] = $f;
}
}
}
if (!$anyOk) {
fail($action, 'GPU prescriptions indisponibles', 502, array('layers' => $layerStatus));
}
$payload = array(
'ok' => true,
'status' => 200,
'action' => $action,
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
'data' => array('type' => 'FeatureCollection', 'features' => $features, 'layers' => $layerStatus),
'cached' => false,
'build' => BUILD_ID,
);
@file_put_contents($cacheFile, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
respond($payload, 200);
}
if ($action === 'mvp_parcelle') {
$lon = (float) reqParam('lon', $action);
$lat = (float) reqParam('lat', $action);
$cacheFile = cacheDir() . '/' . CACHE_PREFIX . md5('v2|' . $action . '|' . $lon . '|' . $lat) . '.json';
if (!$noCache && is_file($cacheFile) && (time() - filemtime($cacheFile)) < CACHE_TTL) {
$payload = json_decode(file_get_contents($cacheFile), true);
if (is_array($payload) && isset($payload['ok']) && $payload['ok'] === true) {
$payload['cached'] = true;
$payload['cached_at'] = date('c', filemtime($cacheFile));
respond($payload, 200);
}
}
$sourceDetail = array(
'apicarto_status' => null, 'apicarto_error' => null, 'apicarto_features' => 0,
'wfs_status' => null, 'wfs_error' => null, 'wfs_features' => 0,
);
// Primary: apicarto IGN (PCI vecteur) — server-side intersection, exact match.
$geom = gpuPointGeom($lon, $lat);
$url1 = 'https://apicarto.ign.fr/api/cadastre/parcelle?geom=' . $geom;
$res1 = publicCurl($url1);
$source = 'apicarto';
$features = array();
$sourceDetail['apicarto_status'] = $res1['status'];
$sourceDetail['apicarto_error'] = $res1['error'];
if ($res1['error'] === null && $res1['status'] >= 200 && $res1['status'] < 300) {
$fc = json_decode($res1['body'], true);
if (is_array($fc) && isset($fc['features']) && is_array($fc['features'])) {
$features = $fc['features'];
}
} elseif ($res1['error'] !== null || $res1['status'] === 429 || $res1['status'] >= 500) {
// Retry once on curl error, 429, or 5xx
usleep(400000);
$res1b = publicCurl($url1);
$sourceDetail['apicarto_status'] = $res1b['status'];
$sourceDetail['apicarto_error'] = $res1b['error'];
if ($res1b['error'] === null && $res1b['status'] >= 200 && $res1b['status'] < 300) {
$fc = json_decode($res1b['body'], true);
if (is_array($fc) && isset($fc['features']) && is_array($fc['features'])) {
$features = $fc['features'];
}
}
}
$sourceDetail['apicarto_features'] = count($features);
// Fallback: WFS Géoplateforme parcellaire express — tiny bbox for deterministic result.
if (empty($features)) {
$wfsType = 'CADASTRALPARCELS.PARCELLAIRE_EXPRESS:parcelle';
// Pass 1: ±0.00005° (~±5 m) — the containing parcel always intersects this box.
$url2 = wfsBboxUrl($wfsType, $lon, $lat, 0.00005, 0.00005, 20);
$res2 = publicCurl($url2);
$source = 'wfs_pe';
$sourceDetail['wfs_status'] = $res2['status'];
$sourceDetail['wfs_error'] = $res2['error'];
if ($res2['error'] === null && $res2['status'] >= 200 && $res2['status'] < 300) {
$fc2 = json_decode($res2['body'], true);
if (is_array($fc2) && isset($fc2['features']) && is_array($fc2['features'])) {
$features = $fc2['features'];
}
}
// Pass 2 (if 0 features): widen to ±0.0003° (~±30 m) with COUNT=50.
if (empty($features)) {
$url2b = wfsBboxUrl($wfsType, $lon, $lat, 0.0003, 0.0003, 50);
$res2b = publicCurl($url2b);
$sourceDetail['wfs_status'] = $res2b['status'];
$sourceDetail['wfs_error'] = $res2b['error'];
if ($res2b['error'] === null && $res2b['status'] >= 200 && $res2b['status'] < 300) {
$fc2b = json_decode($res2b['body'], true);
if (is_array($fc2b) && isset($fc2b['features']) && is_array($fc2b['features'])) {
$features = $fc2b['features'];
}
}
}
// Re-map WFS PE properties to match apicarto shape.
foreach ($features as &$f) {
if (!isset($f['properties']) || !is_array($f['properties'])) continue;
$p = $f['properties'];
if (!isset($p['idu']) && isset($p['id'])) {
$f['properties']['idu'] = $p['id'];
}
}
unset($f);
$sourceDetail['wfs_features'] = count($features);
}
if (empty($features)) {
fail($action, 'Aucune parcelle cadastrale trouvee', 404);
}
$payload = array(
'ok' => true,
'status' => 200,
'action' => $action,
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
'data' => array(
'type' => 'FeatureCollection', 'features' => $features,
'source' => $source, 'source_detail' => $sourceDetail,
),
'cached' => false,
'build' => BUILD_ID,
);
@file_put_contents($cacheFile, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
respond($payload, 200);
}
$url = mvpRoute($action);
if ($url === null) {
fail($action, "Action inconnue : $action", 404);
}
$cacheFile = cacheDir() . '/' . CACHE_PREFIX . md5($action . '|' . $url) . '.json';
if (!$noCache && is_file($cacheFile) && (time() - filemtime($cacheFile)) < CACHE_TTL) {
$payload = json_decode(file_get_contents($cacheFile), true);
if (is_array($payload) && isset($payload['ok']) && $payload['ok'] === true) {
$payload['cached'] = true;
$payload['cached_at'] = date('c', filemtime($cacheFile));
respond($payload, 200);
}
}
$res = publicCurl($url);
emitIudoResponse($action, $res, $startedAt, $cacheFile);
}
// ---- Sources de données : switches iudo -> APIs nationales ------------------
// Config partagée (data/sources.json) pilotant, dans le flow POC v0, le
// remplacement des appels iudo par nos actions mvp_* quand c'est possible.
// Défaut : tout OFF = iudo. Clés connues : parcelle_cadastre, zone_plu,
// servitudes (APIs nationales gratuites, sans clé).
function sourcesFile()
{
return storeDir() . '/sources.json';
}
function knownSources()
{
return array(
'parcelle_cadastre' => 'Parcelle cadastrale — apicarto IGN (gratuit, sans clé)',
'zone_plu' => 'Zone PLU — Géoportail de l\'urbanisme (gratuit, sans clé)',
'servitudes' => 'Servitudes / PSMV — GPU assiettes SUP (gratuit, sans clé)',
);
}
function readSources()
{
$raw = @file_get_contents(sourcesFile());
if ($raw === false || $raw === '') return array();
$data = json_decode($raw, true);
return is_array($data) ? $data : array();
}
if ($action === 'sources_get') {
$stored = readSources();
$out = array();
foreach (knownSources() as $name => $label) {
$out[] = array(
'name' => $name,
'label' => $label,
'enabled' => !empty($stored[$name]),
);
}
respond(array('ok' => true, 'action' => 'sources_get', 'sources' => $out, 'build' => BUILD_ID));
}
if ($action === 'sources_set') {
$body = json_decode(rawPostBody(), true);
$name = (is_array($body) && isset($body['name'])) ? (string) $body['name'] : '';
if (!array_key_exists($name, knownSources())) {
fail($action, 'Source inconnue', 400);
}
$enabled = is_array($body) && !empty($body['enabled']);
$stored = readSources();
$stored[$name] = $enabled;
$ok = @file_put_contents(sourcesFile(), json_encode($stored, JSON_UNESCAPED_UNICODE), LOCK_EX) !== false;
respond(array('ok' => $ok, 'action' => 'sources_set', 'name' => $name, 'enabled' => $enabled, 'build' => BUILD_ID));
}
$route = iudoRoute($action);
if ($route === null) {
fail($action, "Action inconnue : $action", 404);
}
$method = $route[0];
$path = $route[1];
$body = $route[2];
$cacheable = $route[3];
$cacheFile = $cacheable
? cacheDir() . '/' . CACHE_PREFIX . md5($path . '|' . (string) $body) . '.json'
: null;
$noCache = isset($_GET['nocache']) && $_GET['nocache'] === '1';
if ($cacheFile !== null && !$noCache && is_file($cacheFile) && (time() - filemtime($cacheFile)) < CACHE_TTL) {
$payload = json_decode(file_get_contents($cacheFile), true);
if (is_array($payload) && isset($payload['ok']) && $payload['ok'] === true) {
$payload['cached'] = true;
$payload['cached_at'] = date('c', filemtime($cacheFile));
$payload['duration_ms'] = (int) round((microtime(true) - $startedAt) * 1000);
respond($payload, 200);
}
}
$res = iudoAuthedCall($method, $path, $body);
emitIudoResponse($action, $res, $startedAt, $cacheFile);