Files
termanager2/app/Services/Piantine/PiantineArea.php
Francesco Picone 426a95211d Revisione blocchi 2 e 3: file protetti, ripristino atomico, import sicuri
Blocco 2
- Esiti delle azioni Livewire come avvisi (trait Avvisa) invece di flash
  che comparivano alla pagina dopo.
- PDF, miniature e versioni dei territori non più serviti da nginx su
  /storage: TerritorioFileController con login, permessi e cache privata.
- Ripristino tutto o niente: verifica dell'archivio, una transazione con
  import, piantine, blocco account e scambio dei file annullabile.
- Backup formato 2 con piantine, storico PDF e area KML.
- Posto unico preso in un passo (Cache::lock) e rispettato anche da
  import XML e PDF.
- Import PDF da cartella: abbinamento corretto (1-A, 12 B, ambigui),
  caricamento a lotti, file non PDF ignorati, PDF sostituito nello storico.
- L'import XML elimina i file dei territori che non usa più; comando
  territori:file-orfani per quelli accumulati prima.

Blocco 3
- Password attuale richiesta al cambio volontario; AuthenticateSession
  chiude le sessioni aperte altrove.
- Niente nomi dei proclamatori in chiaro nel registro attività.
- Export XML senza remember_token; chi riceve l'hash dal file deve
  cambiare password.
- Campagne: ultimo giorno incluso, percentuale sui territori distinti.
- Coda: retry_after 960 s, stop_grace_period 16m, Redis volatile-lru,
  impostazioni rilette a ogni job.
- Azzeramento che cancella anche piantine, area KML e anteprime.
- Pulsanti profilo e menu che non si nascondevano; icona del sito.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:01:41 +02:00

175 lines
6.2 KiB
PHP

<?php
namespace App\Services\Piantine;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
/**
* L'area della città su cui si disegnano i territori.
*
* Vive su file (storage/app/piantine/), non su tabelle: c'è una sola area
* attiva, caricare un KML nuovo la sostituisce e i poligoni già disegnati
* restano al loro posto.
*
* Questa cartella sta accanto al disco `public`, non dentro: BackupArchive la
* impacchetta a parte (entry piantine/, dal formato 2 dell'archivio) e il
* ripristino la sostituisce insieme al disco. Un archivio di formato 1 non la
* contiene, e ripristinandolo l'area in uso resta com'e'.
*/
class PiantineArea
{
private const DIR = 'piantine';
private const FILE_KML = 'piantine/area.kml';
private const FILE_GEOJSON = 'piantine/area.geojson';
private const FILE_CONFIG = 'piantine/config.json';
/**
* Registra un nuovo KML come area attiva.
*
* @return int Numero di geometrie riconosciute nel file.
*/
public function salvaKml(string $contenuto, string $nomeOriginale): int
{
$geojson = $this->kmlInGeoJson($contenuto);
$quante = count($geojson['features']);
if ($quante === 0) {
throw new RuntimeException(
'Nel file KML non è stato trovato nessun poligono o linea: '
. 'serve almeno un perimetro che delimiti l\'area della città.'
);
}
Storage::disk('local')->makeDirectory(self::DIR);
Storage::disk('local')->put(self::FILE_KML, $contenuto);
Storage::disk('local')->put(self::FILE_GEOJSON, json_encode($geojson, JSON_UNESCAPED_UNICODE));
$config = $this->config();
$config['kml_nome'] = $nomeOriginale;
$config['aggiornato_il'] = now()->toDateTimeString();
$this->salvaConfig($config);
return $quante;
}
/** GeoJSON FeatureCollection dell'area attiva, o null se mai caricata. */
public function area(): ?array
{
if (! Storage::disk('local')->exists(self::FILE_GEOJSON)) {
return null;
}
$decoded = json_decode(Storage::disk('local')->get(self::FILE_GEOJSON), true);
return is_array($decoded) ? $decoded : null;
}
public function config(): array
{
if (! Storage::disk('local')->exists(self::FILE_CONFIG)) {
return ['kml_nome' => null, 'aggiornato_il' => null];
}
$decoded = json_decode(Storage::disk('local')->get(self::FILE_CONFIG), true);
return is_array($decoded)
? $decoded + ['kml_nome' => null, 'aggiornato_il' => null]
: ['kml_nome' => null, 'aggiornato_il' => null];
}
private function salvaConfig(array $config): void
{
Storage::disk('local')->makeDirectory(self::DIR);
Storage::disk('local')->put(self::FILE_CONFIG, json_encode($config, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
}
// ─── KML → GeoJSON ──────────────────────────────────────────────────────
/**
* Estrae poligoni e linee dai Placemark di un KML (anche annidati in
* Document/Folder e dentro MultiGeometry) e li restituisce come
* FeatureCollection GeoJSON.
*
* Le xpath usano local-name(): i prefissi registrati con
* registerXPathNamespace non si ereditano sui nodi restituiti da xpath(),
* e i KML in circolazione dichiarano il namespace in almeno tre modi
* diversi (OGC, Google legacy, nessuno). Cosi' funzionano tutti.
*/
public function kmlInGeoJson(string $kml): array
{
$precedente = libxml_use_internal_errors(true);
$xml = simplexml_load_string($kml);
libxml_clear_errors();
libxml_use_internal_errors($precedente);
if ($xml === false) {
throw new RuntimeException('Il file non è un XML/KML leggibile.');
}
$placemarks = $xml->xpath("//*[local-name()='Placemark']") ?: [];
$features = [];
foreach ($placemarks as $placemark) {
$nodiNome = $placemark->xpath("./*[local-name()='name']") ?: [];
$nome = trim((string) ($nodiNome[0] ?? ''));
foreach ($this->geometrieDelPlacemark($placemark) as $geometry) {
$features[] = [
'type' => 'Feature',
'properties' => ['name' => $nome],
'geometry' => $geometry,
];
}
}
return ['type' => 'FeatureCollection', 'features' => $features];
}
private function geometrieDelPlacemark(\SimpleXMLElement $placemark): array
{
$geometrie = [];
foreach ($placemark->xpath(".//*[local-name()='Polygon']") ?: [] as $polygon) {
$anelli = [];
foreach ($polygon->xpath(".//*[local-name()='LinearRing']/*[local-name()='coordinates']") ?: [] as $coordinates) {
$anello = $this->coordinateKml((string) $coordinates);
if (count($anello) >= 3) {
$anelli[] = $anello;
}
}
if ($anelli !== []) {
$geometrie[] = ['type' => 'Polygon', 'coordinates' => $anelli];
}
}
// Le LineString servono ai KML che tracciano il perimetro come linea
// invece che come poligono chiuso.
foreach ($placemark->xpath(".//*[local-name()='LineString']/*[local-name()='coordinates']") ?: [] as $coordinates) {
$punti = $this->coordinateKml((string) $coordinates);
if (count($punti) >= 2) {
$geometrie[] = ['type' => 'LineString', 'coordinates' => $punti];
}
}
return $geometrie;
}
/** "lng,lat[,alt] lng,lat[,alt] ..." → [[lng, lat], ...] */
private function coordinateKml(string $testo): array
{
$punti = [];
foreach (preg_split('/\s+/', trim($testo)) ?: [] as $tripla) {
if ($tripla === '') {
continue;
}
$parti = explode(',', $tripla);
if (count($parti) >= 2 && is_numeric($parti[0]) && is_numeric($parti[1])) {
$punti[] = [(float) $parti[0], (float) $parti[1]];
}
}
return $punti;
}
}