Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand All @@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 1 addition & 3 deletions config/packages/flagception.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
flagception:
features:
station_history:
default: false
features: {}
9 changes: 0 additions & 9 deletions config/routing/4_frontend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 0 additions & 17 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
25 changes: 0 additions & 25 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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\\<App\\\\Entity\\\\City\\>\\:\\:findOneBySlug\\(\\)\\.$#"
count: 1
Expand Down Expand Up @@ -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\\<App\\\\Entity\\\\City\\>\\:\\:findCitiesWithActiveStations\\(\\)\\.$#"
count: 1
Expand Down
73 changes: 0 additions & 73 deletions src/Air/PollutionDataFactory/HistoryDataFactory.php

This file was deleted.

8 changes: 0 additions & 8 deletions src/Air/PollutionDataFactory/HistoryDataFactoryInterface.php

This file was deleted.

5 changes: 2 additions & 3 deletions src/Controller/Api/CityApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use App\Air\Util\EntityMerger\EntityMergerInterface;
use App\Entity\City;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Entity;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand Down Expand Up @@ -89,8 +89,7 @@ public function putCityAction(Request $request): Response
response: 200,
description: "Returns the updated city",
)]
#[Entity('city', expr: 'repository.findOneBySlug(citySlug)')]
public function postCityAction(Request $request, City $city, EntityMergerInterface $entityMerger): Response
public function postCityAction(Request $request, #[MapEntity(expr: 'repository.findOneBySlug(citySlug)')] City $city, EntityMergerInterface $entityMerger): Response
{
$requestBody = $request->getContent();

Expand Down
63 changes: 0 additions & 63 deletions src/Controller/StationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
7 changes: 0 additions & 7 deletions templates/Default/station.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,6 @@
</a>
{% endif %}

{% if feature('station_history') %}
<a href="{{ path('station_history', { stationCode: station.stationCode }) }}" class="btn btn-secondary" title="Frühere Messwerte dieser Station">
<i class="fa fa-line-chart"></i>
frühere Messwerte dieser Station
</a>
{% endif %}

{% if station.provider == 'ld' %}
<a href="http://maps.sensor.community/#15/{{ station.latitude }}/{{ station.longitude }}" class="btn btn-secondary" title="Diese Station auf Sensor.Community anzeigen">
<i class="fa fa-map-marker"></i>
Expand Down
Loading
Loading