From 502de226c5a5afcfd9e84e457a5662c006912b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Malte=20H=C3=BCbner?= Date: Mon, 13 Jul 2026 16:28:22 +0200 Subject: [PATCH] fix: eliminate guaranteed HTTP 500 routes (dead history feature, Sensio Entity) Most of the routes listed in the issue were already fixed on main by the Elasticsearch/Analysis cleanup (src/Air/Analysis and AnalysisController no longer exist). Two guaranteed-500 problems remained: 1) POST /api/{citySlug} (CityApiController::postCityAction) relied on #[Entity('city', expr: ...)] from SensioFrameworkExtraBundle, which is not installed, so the City argument could not be resolved -> 500. Replaced with #[MapEntity(expr: 'repository.findOneBySlug(citySlug)')], matching StationApiController::postStationAction. Removed the dead `use Sensio\...\Entity` import. 2) GET /{stationCode}/history (StationController::historyAction) used App\Air\PollutionDataFactory\HistoryDataFactory, which references a `$dataList` property that exists nowhere in the hierarchy, a non-existent class App\Util\DateTimeUtil, and calls DataRetrieverInterface::retrieveDataForCoord() with 4 args (interface declares 1). This code has never been runnable. As the flagception flag `station_history` already defaulted to false (feature effectively off), the broken feature was removed entirely: route, controller action, HistoryDataFactory + HistoryDataFactoryInterface, the history template, the station-page link, and the flag. Removed the corresponding phpstan-baseline.neon entries (baseline shrinks). Also removed the now-unused Elasticsearch container from docker-compose.yml and the stale "Elasticsearch for search" references in CLAUDE.md and README.md; the app queries PostGIS directly and no longer uses Elastica. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 7 +- README.md | 2 +- config/packages/flagception.yaml | 4 +- config/routing/4_frontend.yaml | 9 --- docker-compose.yml | 17 ---- phpstan-baseline.neon | 25 ------ .../HistoryDataFactory.php | 73 ----------------- .../HistoryDataFactoryInterface.php | 8 -- src/Controller/Api/CityApiController.php | 5 +- src/Controller/StationController.php | 63 --------------- templates/Default/station.html.twig | 7 -- templates/Station/history.html.twig | 81 ------------------- 12 files changed, 7 insertions(+), 294 deletions(-) delete mode 100644 src/Air/PollutionDataFactory/HistoryDataFactory.php delete mode 100644 src/Air/PollutionDataFactory/HistoryDataFactoryInterface.php delete mode 100644 templates/Station/history.html.twig diff --git a/CLAUDE.md b/CLAUDE.md index 255a3ca7..b392165f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Luft.jetzt is a Symfony 8.0 (PHP 8.5) web application that aggregates and displays air quality / pollution data from multiple sources (German Umweltbundesamt, Luftdaten, OpenWeatherMap). It uses PostgreSQL with PostGIS for geospatial queries, Elasticsearch for search, and Redis for caching. +Luft.jetzt is a Symfony 8.0 (PHP 8.5) web application that aggregates and displays air quality / pollution data from multiple sources (German Umweltbundesamt, Luftdaten, OpenWeatherMap). It uses PostgreSQL with PostGIS for geospatial queries and Redis for caching. ## Common Commands ### Development Setup ```bash -docker-compose up -d # Start PostgreSQL/PostGIS, Redis, Elasticsearch +docker-compose up -d # Start PostgreSQL/PostGIS and Redis composer install # Install PHP dependencies (runs cache:clear + assets:install) npm install && npm run dev # Install JS deps and build frontend assets php bin/console doctrine:migrations:migrate # Run database migrations @@ -79,7 +79,6 @@ Webpack Encore with two JS entry points (`app.js`, `datatables.js`) and SCSS. Us ## Infrastructure - **Database**: PostgreSQL 15 + PostGIS 3.3 (port 25432 via Docker, DB: `gis`, user: `docker`) -- **Search**: Elasticsearch 7.17.2 (port 9200) - **Cache**: Redis (port 6379) - **Locale**: German (`de`), host: `luft.jetzt` @@ -95,6 +94,6 @@ The following items are known and should be addressed when hardening for product - **CSRF protection disabled**: `csrf_protection` is commented out in `config/packages/framework.yaml` - **No security headers**: CSP, HSTS, X-Frame-Options are not configured - **No rate limiting** on API endpoints -- **Docker services** (Redis, Elasticsearch) run without authentication and with exposed ports +- **Docker services** (Redis) run without authentication and with exposed ports - **EntityMerger** uses reflection to merge all non-`@Ignore` properties — ensure sensitive fields are properly annotated - **Twig `|raw` usage**: `unitHtml`, `shortNameHtml`, and `exceedanceJson` use `|raw` — these values must never contain user-controlled input diff --git a/README.md b/README.md index c7db1a9f..73b1e603 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Dazu werden unterschiedliche Datenquellen angezapft, etwa die öffentlich zugän Luft.jetzt ist eine Symfony-4-Anwendung und benötigt den üblichen LAMP-Stack mit PHP 7.1. -Die Daten werden grundsätzlich in einer SQL-Datenbank gespeichert, werden aber zur Beschleunigung des Suchvorganges mit Elasticsearch indiziert; momentan ist mindestens Elasticsearch 6.3 notwendig. +Die Daten werden in einer PostgreSQL-Datenbank mit PostGIS-Erweiterung gespeichert; geografische Abfragen erfolgen direkt über PostGIS. ## Installation diff --git a/config/packages/flagception.yaml b/config/packages/flagception.yaml index 10f1494f..2b25a7df 100644 --- a/config/packages/flagception.yaml +++ b/config/packages/flagception.yaml @@ -1,4 +1,2 @@ flagception: - features: - station_history: - default: false + features: {} diff --git a/config/routing/4_frontend.yaml b/config/routing/4_frontend.yaml index a27d60a9..b399a000 100644 --- a/config/routing/4_frontend.yaml +++ b/config/routing/4_frontend.yaml @@ -7,15 +7,6 @@ station: requirements: stationCode: '^([A-Z]{2,6})([A-Z0-9]{0,8})$' -station_history: - path: /{stationCode}/history - defaults: - _controller: App\Controller\StationController::historyAction - options: - expose: true - requirements: - stationCode: '^([A-Z]{2,6})([A-Z0-9]{0,8})$' - frontpage: path: / defaults: diff --git a/docker-compose.yml b/docker-compose.yml index 88094ca3..b30d2d05 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,20 +28,3 @@ services: test: "exit 0" labels: com.symfony.server.service-prefix: 'DATABASE' - - elasticsearch: - image: elasticsearch:7.17.2 - container_name: luft-elasticsearch - environment: - - discovery.type=single-node - - cluster.routing.allocation.disk.threshold_enabled=true - - cluster.routing.allocation.disk.watermark.flood_stage=100mb - - cluster.routing.allocation.disk.watermark.low=500mb - - cluster.routing.allocation.disk.watermark.high=250mb - volumes: - - ./docker/elasticsearch:/usr/share/elasticsearch/data - ports: - - "9200:9200" - - "9300:9300" - labels: - com.symfony.server.service-prefix: 'ELASTICSEARCH' diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index eea77dac..5350bb17 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -20,21 +20,6 @@ parameters: count: 1 path: src/Air/Geocoding/Guesser/CityGuesser.php - - - message: "#^Access to an undefined property App\\\\Air\\\\PollutionDataFactory\\\\HistoryDataFactory\\:\\:\\$dataList\\.$#" - count: 3 - path: src/Air/PollutionDataFactory/HistoryDataFactory.php - - - - message: "#^Call to static method getHourStartDateTime\\(\\) on an unknown class App\\\\Util\\\\DateTimeUtil\\.$#" - count: 1 - path: src/Air/PollutionDataFactory/HistoryDataFactory.php - - - - message: "#^Method App\\\\Air\\\\DataRetriever\\\\DataRetrieverInterface\\:\\:retrieveDataForCoord\\(\\) invoked with 4 parameters, 1 required\\.$#" - count: 1 - path: src/Air/PollutionDataFactory/HistoryDataFactory.php - - message: "#^Deprecated in PHP 8\\.4\\: Parameter \\#1 \\$dateTime \\(DateTime\\) is implicitly nullable via default value null\\.$#" count: 1 @@ -135,11 +120,6 @@ parameters: count: 1 path: src/Controller/Api/AnalysisApiController.php - - - message: "#^Attribute class Sensio\\\\Bundle\\\\FrameworkExtraBundle\\\\Configuration\\\\Entity does not exist\\.$#" - count: 1 - path: src/Controller/Api/CityApiController.php - - message: "#^Call to an undefined method Doctrine\\\\Persistence\\\\ObjectRepository\\\\:\\:findOneBySlug\\(\\)\\.$#" count: 1 @@ -180,11 +160,6 @@ parameters: count: 1 path: src/Controller/DisplayController.php - - - message: "#^Call to an undefined method App\\\\Air\\\\PollutionDataFactory\\\\HistoryDataFactoryInterface\\:\\:setStation\\(\\)\\.$#" - count: 1 - path: src/Controller/StationController.php - - message: "#^Call to an undefined method Doctrine\\\\Persistence\\\\ObjectRepository\\\\:\\:findCitiesWithActiveStations\\(\\)\\.$#" count: 1 diff --git a/src/Air/PollutionDataFactory/HistoryDataFactory.php b/src/Air/PollutionDataFactory/HistoryDataFactory.php deleted file mode 100644 index 6c1d8668..00000000 --- a/src/Air/PollutionDataFactory/HistoryDataFactory.php +++ /dev/null @@ -1,73 +0,0 @@ -getDataListsForInterval($fromDateTime, $untilDateTime); - - $dataLists = $this->convert(); - - $pollutionModelList = []; - - /** @var array $dataList */ - foreach ($dataLists as $timestamp => $dataList) { - $pollutantViewModelList = $this->getPollutantViewModelList($dataList); - - $pollutionModelList[$timestamp] = $this->decoratePollutantList($pollutantViewModelList); - } - - return $pollutionModelList; - } - - public function getDataListsForInterval(\DateTime $fromDateTime, \DateTime $untilDateTime): void - { - $this->dataList->reset(); - - $diffInterval = $fromDateTime->diff($untilDateTime); - - $resultList = $this->dataRetriever->retrieveDataForCoord($this->coord, null, $fromDateTime, $diffInterval); - - /** @var Data $data */ - foreach ($resultList as $data) { - $this->dataList->addData($data); - } - } - - protected function convert(): array - { - $newDataListLists = []; - - foreach ($this->dataList->getList() as $pollutantId => $dataList) { - /** - * @var Data $data - */ - foreach ($dataList as $data) { - $timestamp = DateTimeUtil::getHourStartDateTime($data->getDateTime())->format('U'); - - if (!array_key_exists($timestamp, $newDataListLists)) { - $newDataListLists[$timestamp] = [$pollutantId => [$data]]; - } elseif (!array_key_exists($pollutantId, $newDataListLists[$timestamp])) { - $newDataListLists[$timestamp][$pollutantId] = [$data]; - } elseif ($newDataListLists[$timestamp][$pollutantId][0]->getValue() < $data->getValue()) { - $newDataListLists[$timestamp][$pollutantId][0] = $data; - } - } - } - - return $newDataListLists; - } -} diff --git a/src/Air/PollutionDataFactory/HistoryDataFactoryInterface.php b/src/Air/PollutionDataFactory/HistoryDataFactoryInterface.php deleted file mode 100644 index 97368b72..00000000 --- a/src/Air/PollutionDataFactory/HistoryDataFactoryInterface.php +++ /dev/null @@ -1,8 +0,0 @@ -getContent(); diff --git a/src/Controller/StationController.php b/src/Controller/StationController.php index dfde2f93..af5a8536 100644 --- a/src/Controller/StationController.php +++ b/src/Controller/StationController.php @@ -2,14 +2,10 @@ namespace App\Controller; -use App\Air\PollutionDataFactory\HistoryDataFactoryInterface; use App\Air\PollutionDataFactory\PollutionDataFactory; use App\Air\SeoPage\SeoPageInterface; use App\Entity\Station; -use Carbon\Carbon; -use Flagception\Bundle\FlagceptionBundle\Annotations\Feature; use Symfony\Bridge\Doctrine\Attribute\MapEntity; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\RouterInterface; use WhiteOctober\BreadcrumbsBundle\Model\Breadcrumbs; @@ -38,63 +34,4 @@ public function stationAction(#[MapEntity(expr: 'repository.findOneByStationCode 'pollutantList' => $viewModelList, ]); } - - /** - * @Feature("station_history") - */ - public function historyAction(#[MapEntity(expr: 'repository.findOneByStationCode(stationCode)')] Station $station, Request $request, SeoPageInterface $seoPage, HistoryDataFactoryInterface $historyDataFactory): Response - { - if ($untilDateTimeParam = $request->query->get('until')) { - try { - $untilDateTime = Carbon::parse($untilDateTimeParam)->endOfDay(); - } catch (\Exception) { - $untilDateTime = Carbon::now()->endOfHour(); - } - } else { - $untilDateTime = Carbon::now()->endOfHour(); - } - - if ($fromDateTimeParam = $request->query->get('from')) { - try { - $fromDateTime = Carbon::parse($fromDateTimeParam)->startOfDay(); - } catch (\Exception) { - $fromDateTime = Carbon::now()->startOfHour(); - $fromDateTime->sub(new \DateInterval('P3D')); - } - } else { - $fromDateTime = Carbon::now()->startOfHour(); - $fromDateTime->sub(new \DateInterval('P3D')); - } - - if ($station->getCity()) { - $seoPage->setTitle(sprintf('Frühere Luftmesswerte für die Station %s — Feinstaub, Stickstoffdioxid und Ozon in %s', $station->getStationCode(), $station->getCity()->getName())); - } else { - $seoPage->setTitle(sprintf('Frühere Luftmesswerte für die Station %s', $station->getStationCode())); - } - - $dataLists = $historyDataFactory - ->setStation($station) - ->createDecoratedPollutantListForInterval($fromDateTime, $untilDateTime); - - krsort($dataLists); - - return $this->render('Station/history.html.twig', [ - 'station' => $station, - 'dataLists' => $dataLists, - 'fromDateTime' => $fromDateTime, - 'untilDateTime' => $untilDateTime, - 'pollutantIdList' => $this->findPollutantsFromList($dataLists), - ]); - } - - protected function findPollutantsFromList(array $dataLists): array - { - $pollutantIdList = []; - - foreach ($dataLists as $dataList) { - $pollutantIdList = [...$pollutantIdList, ...array_keys($dataList)]; - } - - return array_unique($pollutantIdList); - } } diff --git a/templates/Default/station.html.twig b/templates/Default/station.html.twig index 07e9eeec..daf6b00a 100644 --- a/templates/Default/station.html.twig +++ b/templates/Default/station.html.twig @@ -30,13 +30,6 @@ {% endif %} - {% if feature('station_history') %} - - - frühere Messwerte dieser Station - - {% endif %} - {% if station.provider == 'ld' %} diff --git a/templates/Station/history.html.twig b/templates/Station/history.html.twig deleted file mode 100644 index ac3181b8..00000000 --- a/templates/Station/history.html.twig +++ /dev/null @@ -1,81 +0,0 @@ -{% extends 'layout.html.twig' %} -{% import _self as self %} - -{% if fromDateTime|date('Y-m-d') == untilDateTime|date('Y-m-d') %} -{% set same_day = true %} -{% else %} -{% set same_day = false %} -{% endif %} - -{% macro pollution_data(dataList, pollutantId) %} - {% if dataList[pollutantId] is defined %} - {% set data = dataList[pollutantId]|first %} - - {{ data.data.value|number_format(2, ',', '.') }} {{ data.pollutant.unitHtml|raw }} - - {% else %} - {% set pollutant = pollutant_by_id(pollutantId) %} - - {% endif %} -{% endmacro %} - -{% block content %} -
-
-
-

- Frühere Messwerte der Station {{ station.stationCode }} -

- -

- An der Station {{ station.stationCode }}{% if station.city %} in {{ station.city.name }}{% endif %} wurden {% if same_day %}am{% else %}von {{ fromDateTime|format_date('full') }}, bis{% endif %} {{ untilDateTime|format_date('full') }}, die folgenden Werte gemessen: -

-
-
- -
-
- -
-
- -
-
- - - - - - {% for pollutantId, pollutant in pollutant_list() %} - {% if pollutantId in pollutantIdList %} - - {% endif %} - {% endfor %} - - - - {% for timestamp, dataList in dataLists %} - {% if dataList|length > 0 %} - - - - {% for pollutantId, pollutant in pollutant_list() %} - {% if pollutantId in pollutantIdList %} - {{ self.pollution_data(dataList, pollutantId) }} - {% endif %} - {% endfor %} - - {% endif %} - {% endfor %} - -
- Zeitpunkt - - {{ pollutant.shortNameHtml|raw }} -
- {{ timestamp|date('d.m.Y H:i', 'Europe/Berlin') }} Uhr -
-
-
-
-{% endblock %} \ No newline at end of file