big
All checks were successful
Deploy / deploy (push) Successful in 1m8s

This commit is contained in:
2026-08-23 02:47:01 +02:00
parent 9bbbd94b98
commit 49c13dfd80
19 changed files with 555 additions and 24 deletions

View File

@@ -348,11 +348,13 @@ final class AccountPages
$siteMaintenanceMessage = 'Papa-Kind-Treff ist gerade kurz in Wartung. Bitte versuche es in Kürze erneut.';
}
$placeDataProvider = (string)($_POST['place_data_provider'] ?? 'osm');
if (!in_array($placeDataProvider, ['osm', 'osm_google_optional'], true)) {
if (!in_array($placeDataProvider, ['osm', 'google', 'azure', 'all_enabled'], true)) {
$placeDataProvider = 'osm';
}
$systemSettings->updateMany([
'google_places_enabled' => isset($_POST['google_places_enabled']) ? '1' : '0',
'azure_maps_enabled' => isset($_POST['azure_maps_enabled']) ? '1' : '0',
'osm_places_enabled' => isset($_POST['osm_places_enabled']) ? '1' : '0',
'forum_maintenance_mode' => isset($_POST['forum_maintenance_mode']) ? '1' : '0',
'site_maintenance_mode' => isset($_POST['site_maintenance_mode']) ? '1' : '0',
'site_maintenance_message' => $siteMaintenanceMessage,
@@ -728,6 +730,47 @@ final class AccountPages
}
$listingCatalog->decidePendingApproval($userId, (int)($_POST['listing_id'] ?? 0), (string)($_POST['decision'] ?? ''), (string)($_POST['review_note'] ?? ''));
$info = 'Ort oder Veranstaltung wurde bearbeitet.';
} elseif ($action === 'place_provider_search') {
if (!$canReviewListings || !$listingCatalog || !$systemSettings) {
throw new \RuntimeException('Keine Berechtigung für die externe Ortssuche.');
}
$listingId = (int)($_POST['listing_id'] ?? 0);
$entry = $listingCatalog->getMemberEntry($listingId);
if (!$entry || !in_array((string)($entry['status'] ?? ''), ['draft', 'published'], true)) {
throw new \RuntimeException('Der Ort oder die Veranstaltung ist nicht verfügbar.');
}
$search = (new PlaceProviderLookup())->search($entry, $systemSettings->getAll());
$_SESSION['place_provider_search_results'][$listingId] = $search;
$info = $search['results'] === []
? 'Es wurden keine passenden externen Orte gefunden.'
: count($search['results']) . ' externe Ortstreffer gefunden.';
} elseif ($action === 'place_provider_link') {
if (!$canReviewListings || !$listingCatalog) {
throw new \RuntimeException('Keine Berechtigung für externe Ortsverknüpfungen.');
}
$listingId = (int)($_POST['listing_id'] ?? 0);
$resultIndex = (int)($_POST['result_index'] ?? -1);
$storedSearch = $_SESSION['place_provider_search_results'][$listingId] ?? null;
if (!is_array($storedSearch) || (int)($storedSearch['searched_at'] ?? 0) < time() - 900) {
unset($_SESSION['place_provider_search_results'][$listingId]);
throw new \RuntimeException('Die Ortstreffer sind abgelaufen. Bitte suche erneut.');
}
$match = is_array($storedSearch) ? ($storedSearch['results'][$resultIndex] ?? null) : null;
if (!is_array($match)) {
throw new \RuntimeException('Der ausgewählte Ortstreffer ist nicht mehr verfügbar. Bitte suche erneut.');
}
$googleDetails = (string)($match['provider'] ?? '') === 'google'
? (new PlaceProviderLookup())->googleDetails((string)$match['id'])
: null;
$listingCatalog->saveProviderLink($listingId, $match, $googleDetails);
if ($googleDetails !== null) {
$_SESSION['place_provider_search_results'][$listingId]['results'][$resultIndex] = array_merge($match, [
'rating' => $googleDetails['rating'],
'rating_count' => $googleDetails['rating_count'],
'linked' => true,
]);
}
$info = 'Externer Ortstreffer wurde verknüpft.';
} elseif ($action === 'application_decide') {
if (!$canManageSystemSettings || !$communityAccess) {
throw new \RuntimeException('Keine Berechtigung für Bewerbungen.');
@@ -1067,6 +1110,30 @@ final class AccountPages
$listingReviewRequests = $canReviewListings && $listingCatalog
? $listingCatalog->listPendingApprovalEntries()
: [];
$listingReviewTab = (string)($_GET['listing_tab'] ?? 'approvals');
if (!in_array($listingReviewTab, ['approvals', 'existing', 'missing'], true)) {
$listingReviewTab = 'approvals';
}
$listingManagementSearchQuery = trim((string)($_GET['listing_query'] ?? ''));
$systemSettingsValues = $systemSettings ? $systemSettings->getAll() : [];
$activePlaceProviders = [];
foreach (['osm' => 'osm_places_enabled', 'google' => 'google_places_enabled', 'azure' => 'azure_maps_enabled'] as $provider => $settingKey) {
if (($systemSettingsValues[$settingKey] ?? '0') === '1') {
$activePlaceProviders[] = $provider;
}
}
$listingManagementEntries = $canReviewListings && $listingCatalog && $listingReviewTab !== 'approvals'
&& ($listingReviewTab !== 'missing' || $activePlaceProviders !== [])
? $listingCatalog->listAdminExternalLinkEntries($listingManagementSearchQuery, $listingReviewTab === 'missing' ? $activePlaceProviders : [])
: [];
$placeProviderSearches = is_array($_SESSION['place_provider_search_results'] ?? null)
? $_SESSION['place_provider_search_results']
: [];
foreach ($placeProviderSearches as $listingId => $search) {
if (!is_array($search) || (int)($search['searched_at'] ?? 0) < time() - 900) {
unset($placeProviderSearches[$listingId], $_SESSION['place_provider_search_results'][$listingId]);
}
}
$communityApplications = $canManageSystemSettings && $communityAccess ? $communityAccess->listApplications('open') : [];
$communityReports = $canModerateForum && $communityAccess && $communityAccess->supportsReports() ? $communityAccess->listOpenReports() : [];
$userManagementSearchQuery = trim((string)($_GET['user_management_query'] ?? ''));
@@ -1141,7 +1208,6 @@ final class AccountPages
],
],
];
$systemSettingsValues = $systemSettings ? $systemSettings->getAll() : [];
$listingCatalogStatus = $listingCatalog ? $listingCatalog->status() : ['complete' => false, 'missing' => [], 'tables' => []];
if (!in_array($section, $allowedSections, true)) {
$section = 'profile';
@@ -1191,6 +1257,11 @@ final class AccountPages
'canManageUserManagement',
'systemRoleAssignments',
'listingReviewRequests',
'listingReviewTab',
'listingManagementSearchQuery',
'listingManagementEntries',
'activePlaceProviders',
'placeProviderSearches',
'communityApplications',
'communityReports',
'userManagementSearchQuery',

View File

@@ -7,6 +7,7 @@ final class ListingCatalog
{
private array $tableCache = [];
private array $columnCache = [];
private bool $schemaEnsured = false;
public function __construct(private \PDO $pdo)
{
@@ -14,6 +15,10 @@ final class ListingCatalog
public function ensureSchema(): void
{
if ($this->schemaEnsured) {
return;
}
$statements = [
'CREATE TABLE IF NOT EXISTS listing_categories (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
@@ -48,9 +53,10 @@ final class ListingCatalog
place_kind VARCHAR(80) NULL,
opening_hours_note TEXT NULL,
opening_hours_json LONGTEXT NULL,
provider_hint ENUM("manual","osm","google") NOT NULL DEFAULT "manual",
provider_hint ENUM("manual","osm","google","azure") NOT NULL DEFAULT "manual",
external_place_id VARCHAR(190) NULL,
google_place_id VARCHAR(190) NULL,
provider_links_json LONGTEXT NULL,
rating_value DECIMAL(3,2) NULL,
rating_count INT UNSIGNED NULL,
status ENUM("draft","published","archived") NOT NULL DEFAULT "published",
@@ -177,6 +183,7 @@ final class ListingCatalog
$columnStatements = [
'listing_places.opening_hours_note' => 'ALTER TABLE listing_places ADD COLUMN opening_hours_note TEXT NULL AFTER place_kind',
'listing_places.opening_hours_json' => 'ALTER TABLE listing_places ADD COLUMN opening_hours_json LONGTEXT NULL AFTER opening_hours_note',
'listing_places.provider_links_json' => 'ALTER TABLE listing_places ADD COLUMN provider_links_json LONGTEXT NULL AFTER google_place_id',
'listings.image_path' => 'ALTER TABLE listings ADD COLUMN image_path VARCHAR(255) NULL AFTER description',
'listings.special_conditions_note' => 'ALTER TABLE listings ADD COLUMN special_conditions_note TEXT NULL AFTER image_path',
];
@@ -188,6 +195,12 @@ final class ListingCatalog
}
}
$providerStatement = $this->pdo->query('SHOW COLUMNS FROM listing_places LIKE "provider_hint"');
$providerColumn = $providerStatement ? $providerStatement->fetch(\PDO::FETCH_ASSOC) : null;
if (is_array($providerColumn) && !str_contains((string)($providerColumn['Type'] ?? ''), "'azure'")) {
$this->pdo->exec('ALTER TABLE listing_places MODIFY provider_hint ENUM("manual","osm","google","azure") NOT NULL DEFAULT "manual"');
}
$seed = $this->pdo->prepare(
'INSERT INTO listing_categories (slug, title, category_group, sort_order)
VALUES (:slug, :title, :groupName, :sortOrder)
@@ -209,6 +222,8 @@ final class ListingCatalog
'sortOrder' => $index + 1,
]);
}
$this->schemaEnsured = true;
}
public function status(): array
@@ -649,7 +664,7 @@ final class ListingCatalog
{
$this->ensureSchema();
$stmt = $this->pdo->prepare(
'SELECT l.*, lp.status AS place_status, lp.title AS place_title, lp.description AS place_description, lp.street, lp.zip, lp.city, lp.region, lp.lat, lp.lng, lp.place_kind, lp.phone, lp.website_url, lp.opening_hours_note, lp.opening_hours_json,
'SELECT l.*, lp.id AS place_id, lp.status AS place_status, lp.title AS place_title, lp.description AS place_description, lp.street, lp.zip, lp.city, lp.region, lp.lat, lp.lng, lp.place_kind, lp.phone, lp.website_url, lp.opening_hours_note, lp.opening_hours_json, lp.provider_hint, lp.external_place_id, lp.google_place_id, lp.provider_links_json, lp.rating_value, lp.rating_count,
lo.id AS occurrence_id, lo.starts_at, lo.ends_at, lo.occurrence_type,
lo.recurrence_rule, lo.recurrence_until, lo.capacity_total,
lc.slug AS category_slug
@@ -671,6 +686,42 @@ final class ListingCatalog
return $row;
}
public function saveProviderLink(int $listingId, array $match, ?array $googleDetails = null): void
{
$this->ensureSchema();
$entry = $this->getMemberEntry($listingId);
if (!$entry || (int)($entry['place_id'] ?? 0) <= 0) {
throw new \RuntimeException('Eintrag oder Ortsdaten nicht gefunden.');
}
$provider = (string)($match['provider'] ?? '');
if (!in_array($provider, ['osm', 'google', 'azure'], true) || trim((string)($match['id'] ?? '')) === '') {
throw new \RuntimeException('Ungültiger externer Ortstreffer.');
}
$links = $this->decodeJsonRows((string)($entry['provider_links_json'] ?? ''));
$link = ['id' => (string)$match['id'], 'name' => (string)($match['name'] ?? ''), 'address' => (string)($match['address'] ?? ''), 'url' => (string)($match['url'] ?? ''), 'linked_at' => gmdate('c')];
if ($provider === 'google' && $googleDetails !== null) {
$link = array_merge($link, $googleDetails, ['fetched_at' => gmdate('c')]);
}
$links[$provider] = $link;
$rating = $provider === 'google' ? ($googleDetails['rating'] ?? null) : ($entry['rating_value'] ?? null);
$ratingCount = $provider === 'google' ? ($googleDetails['rating_count'] ?? null) : ($entry['rating_count'] ?? null);
$stmt = $this->pdo->prepare(
'UPDATE listing_places
SET provider_hint = :provider, external_place_id = :externalId, google_place_id = :googleId,
provider_links_json = :links, rating_value = :rating, rating_count = :ratingCount, updated_at = NOW()
WHERE id = :placeId'
);
$stmt->execute([
'provider' => $provider,
'externalId' => $provider === 'google' ? (string)($entry['external_place_id'] ?? '') : ($provider === 'azure' ? 'azure:' : '') . (string)$match['id'],
'googleId' => $provider === 'google' ? (string)$match['id'] : (string)($entry['google_place_id'] ?? ''),
'links' => json_encode($links, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'rating' => $rating,
'ratingCount' => $ratingCount,
'placeId' => (int)$entry['place_id'],
]);
}
public function getDashboardEntry(int $userId, int $listingId): ?array
{
$row = $this->getMemberEntry($listingId);
@@ -898,7 +949,7 @@ final class ListingCatalog
$this->ensureSchema();
$stmt = $this->pdo->query(
'SELECT l.id AS listing_id, l.title, l.listing_type, l.status AS listing_status,
lp.street, lp.zip, lp.city, lp.region,
lp.street, lp.zip, lp.city, lp.region, lp.provider_hint, lp.provider_links_json, lp.rating_value, lp.rating_count,
up.display_name AS requested_by_name,
mr.id AS moderation_request_id
FROM listings l
@@ -919,7 +970,55 @@ final class ListingCatalog
ORDER BY l.created_at ASC, l.id ASC'
);
return $stmt ? ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []) : [];
$rows = $stmt ? ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []) : [];
foreach ($rows as &$row) {
$row['provider_links'] = $this->decodeJsonRows((string)($row['provider_links_json'] ?? ''));
}
unset($row);
return $rows;
}
public function listAdminExternalLinkEntries(string $search = '', array $requiredProviders = []): array
{
$this->ensureSchema();
$search = trim($search);
$sql = 'SELECT l.id AS listing_id, l.title, l.listing_type, lp.street, lp.zip, lp.city, lp.region,
lp.provider_hint, lp.external_place_id, lp.google_place_id, lp.provider_links_json, lp.rating_value, lp.rating_count
FROM listings l
INNER JOIN listing_places lp ON lp.id = l.primary_place_id
WHERE l.listing_type IN ("place", "editorial_event")
AND l.status = "published"
AND lp.status = "published"';
$params = [];
if ($search !== '') {
$sql .= ' AND (l.title LIKE :search OR lp.street LIKE :search OR lp.city LIKE :search OR lp.zip LIKE :search)';
$params['search'] = '%' . $search . '%';
}
$sql .= ' ORDER BY l.title ASC LIMIT 150';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
$requiredProviders = array_values(array_intersect($requiredProviders, ['osm', 'google', 'azure']));
foreach ($rows as &$row) {
$row['provider_links'] = $this->decodeJsonRows((string)($row['provider_links_json'] ?? ''));
$row['missing_providers'] = array_values(array_filter($requiredProviders, static function (string $provider) use ($row): bool {
if (!empty($row['provider_links'][$provider]['id'])) {
return false;
}
if ($provider === 'google') {
return empty($row['google_place_id']);
}
if ($provider === 'osm') {
return !((string)($row['provider_hint'] ?? '') === 'osm' && !empty($row['external_place_id']));
}
return true;
}));
}
unset($row);
if ($requiredProviders !== []) {
$rows = array_values(array_filter($rows, static fn (array $row): bool => $row['missing_providers'] !== []));
}
return $rows;
}
public function decidePendingApproval(int $reviewerUserId, int $listingId, string $decision, ?string $reviewNote = null): void

View File

@@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
namespace App;
/** Server-side lookup for external place providers. API keys never reach the browser. */
final class PlaceProviderLookup
{
public function search(array $entry, array $settings): array
{
$query = $this->buildQuery($entry);
if ($query === '') {
throw new \RuntimeException('Für die externe Ortssuche werden mindestens Name oder Adressdaten benötigt.');
}
$results = [];
$notices = [];
if (($settings['osm_places_enabled'] ?? '1') === '1') {
try {
$results = array_merge($results, $this->searchOsm($query, $entry));
} catch (\Throwable) {
$notices[] = 'OpenStreetMap konnte gerade nicht durchsucht werden.';
}
}
if (($settings['google_places_enabled'] ?? '0') === '1') {
if ($this->googleKey() === '') {
$notices[] = 'Google Places ist aktiviert, aber der Server-Schlüssel fehlt.';
} else {
try {
$results = array_merge($results, $this->searchGoogle($query, $entry));
} catch (\Throwable) {
$notices[] = 'Google Places konnte gerade nicht durchsucht werden.';
}
}
}
if (($settings['azure_maps_enabled'] ?? '0') === '1') {
if ($this->azureKey() === '') {
$notices[] = 'Azure Maps ist aktiviert, aber der Server-Schlüssel fehlt.';
} else {
try {
$results = array_merge($results, $this->searchAzure($query, $entry));
} catch (\Throwable) {
$notices[] = 'Azure Maps konnte gerade nicht durchsucht werden.';
}
}
}
usort($results, static function (array $a, array $b): int {
$aDistance = $a['distance_m'] ?? PHP_INT_MAX;
$bDistance = $b['distance_m'] ?? PHP_INT_MAX;
return $aDistance <=> $bDistance ?: strcmp((string)$a['name'], (string)$b['name']);
});
return ['query' => $query, 'results' => $results, 'notices' => $notices, 'searched_at' => time()];
}
public function googleDetails(string $placeResource): ?array
{
$key = $this->googleKey();
if ($key === '') {
return null;
}
$resource = ltrim($placeResource, '/');
if (!str_starts_with($resource, 'places/')) {
return null;
}
$data = $this->requestJson(
'GET',
'https://places.googleapis.com/v1/' . implode('/', array_map(rawurlencode(...), explode('/', $resource))),
null,
[
'X-Goog-Api-Key: ' . $key,
'X-Goog-FieldMask: id,displayName,formattedAddress,googleMapsUri,rating,userRatingCount',
]
);
if (!is_array($data) || empty($data['id'])) {
return null;
}
return [
'id' => (string)$data['id'],
'name' => (string)($data['displayName']['text'] ?? ''),
'address' => (string)($data['formattedAddress'] ?? ''),
'url' => (string)($data['googleMapsUri'] ?? ''),
'rating' => isset($data['rating']) ? (float)$data['rating'] : null,
'rating_count' => isset($data['userRatingCount']) ? (int)$data['userRatingCount'] : null,
];
}
private function searchOsm(string $query, array $entry): array
{
$url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([
'format' => 'jsonv2',
'addressdetails' => 1,
'limit' => 3,
'q' => $query,
]);
$rows = $this->cachedOsmRequest($url);
if (!is_array($rows)) {
return [];
}
$results = [];
foreach ($rows as $row) {
if (!is_array($row) || empty($row['osm_type']) || empty($row['osm_id'])) {
continue;
}
$lat = isset($row['lat']) ? (float)$row['lat'] : null;
$lng = isset($row['lon']) ? (float)$row['lon'] : null;
$type = (string)$row['osm_type'];
$id = (string)$row['osm_id'];
$results[] = $this->result('osm', $type . ':' . $id, (string)($row['name'] ?? $row['display_name'] ?? ''), (string)($row['display_name'] ?? ''), $lat, $lng, $entry, 'https://www.openstreetmap.org/' . $type . '/' . rawurlencode($id));
}
return $results;
}
private function searchGoogle(string $query, array $entry): array
{
$payload = ['textQuery' => $query, 'languageCode' => 'de', 'regionCode' => 'DE'];
if (isset($entry['lat'], $entry['lng']) && $entry['lat'] !== null && $entry['lng'] !== null) {
$payload['locationBias'] = ['circle' => ['center' => ['latitude' => (float)$entry['lat'], 'longitude' => (float)$entry['lng']], 'radius' => 5000.0]];
}
$data = $this->requestJson('POST', 'https://places.googleapis.com/v1/places:searchText', $payload, [
'X-Goog-Api-Key: ' . $this->googleKey(),
'X-Goog-FieldMask: places.id,places.displayName,places.formattedAddress,places.location',
]);
$results = [];
foreach ((array)($data['places'] ?? []) as $row) {
if (!is_array($row) || empty($row['id'])) {
continue;
}
$location = (array)($row['location'] ?? []);
$results[] = $this->result('google', (string)$row['id'], (string)($row['displayName']['text'] ?? ''), (string)($row['formattedAddress'] ?? ''), isset($location['latitude']) ? (float)$location['latitude'] : null, isset($location['longitude']) ? (float)$location['longitude'] : null, $entry, null);
}
return $results;
}
private function searchAzure(string $query, array $entry): array
{
$params = ['api-version' => '1.0', 'subscription-key' => $this->azureKey(), 'query' => $query, 'limit' => 3, 'countrySet' => 'DE', 'language' => 'de-DE'];
if (isset($entry['lat'], $entry['lng']) && $entry['lat'] !== null && $entry['lng'] !== null) {
$params['lat'] = (float)$entry['lat'];
$params['lon'] = (float)$entry['lng'];
$params['radius'] = 5000;
}
$data = $this->requestJson('GET', 'https://eu.atlas.microsoft.com/search/poi/json?' . http_build_query($params));
$results = [];
foreach ((array)($data['results'] ?? []) as $row) {
if (!is_array($row) || empty($row['id'])) {
continue;
}
$poi = (array)($row['poi'] ?? []);
$address = (array)($row['address'] ?? []);
$results[] = $this->result('azure', (string)$row['id'], (string)($poi['name'] ?? ''), (string)($address['freeformAddress'] ?? ''), isset($row['position']['lat']) ? (float)$row['position']['lat'] : null, isset($row['position']['lon']) ? (float)$row['position']['lon'] : null, $entry, null);
}
return $results;
}
private function result(string $provider, string $id, string $name, string $address, ?float $lat, ?float $lng, array $entry, ?string $url): array
{
return ['provider' => $provider, 'id' => $id, 'name' => $name !== '' ? $name : $address, 'address' => $address, 'lat' => $lat, 'lng' => $lng, 'url' => $url, 'distance_m' => $lat !== null && $lng !== null && isset($entry['lat'], $entry['lng']) && $entry['lat'] !== null && $entry['lng'] !== null ? $this->distance((float)$entry['lat'], (float)$entry['lng'], $lat, $lng) : null];
}
private function buildQuery(array $entry): string
{
return trim(implode(', ', array_filter([(string)($entry['title'] ?? $entry['place_title'] ?? ''), (string)($entry['street'] ?? ''), trim((string)($entry['zip'] ?? '') . ' ' . (string)($entry['city'] ?? '')), (string)($entry['region'] ?? ''), 'Deutschland'])));
}
private function googleKey(): string { return trim((string)getenv('GOOGLE_MAPS_API_KEY')); }
private function azureKey(): string { return trim((string)getenv('AZURE_MAPS_SUBSCRIPTION_KEY')); }
private function requestJson(string $method, string $url, ?array $payload = null, array $headers = []): array
{
$headers[] = 'Accept: application/json';
$headers[] = 'User-Agent: Papa-Kind-Treff/1.0 (+https://papa-kind-treff.de/)';
$body = $payload === null ? null : json_encode($payload, JSON_THROW_ON_ERROR);
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
}
if (function_exists('curl_init')) {
$handle = curl_init($url);
curl_setopt_array($handle, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5]);
if ($body !== null) { curl_setopt($handle, CURLOPT_POSTFIELDS, $body); }
$response = curl_exec($handle);
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
if (!is_string($response) || $status < 200 || $status >= 300) { throw new \RuntimeException('Externer Dienst nicht erreichbar.'); }
} else {
$response = @file_get_contents($url, false, stream_context_create(['http' => ['method' => $method, 'header' => implode("\r\n", $headers), 'content' => $body ?? '', 'timeout' => 10, 'ignore_errors' => true]]));
if (!is_string($response)) { throw new \RuntimeException('Externer Dienst nicht erreichbar.'); }
}
$decoded = json_decode($response, true);
if (!is_array($decoded)) { throw new \RuntimeException('Ungültige Antwort des externen Dienstes.'); }
return $decoded;
}
/** Nominatim requires cached requests and a global maximum of one request per second. */
private function cachedOsmRequest(string $url): array
{
$directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'papa-kind-treff-osm';
if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) {
return $this->requestJson('GET', $url, null, ['Accept-Language: de']);
}
$cacheFile = $directory . DIRECTORY_SEPARATOR . hash('sha256', $url) . '.json';
if (is_file($cacheFile) && filemtime($cacheFile) >= time() - 900) {
$cached = json_decode((string)file_get_contents($cacheFile), true);
if (is_array($cached)) {
return $cached;
}
}
$lock = fopen($directory . DIRECTORY_SEPARATOR . 'request.lock', 'c+');
if ($lock === false) {
return $this->requestJson('GET', $url, null, ['Accept-Language: de']);
}
try {
flock($lock, LOCK_EX);
clearstatcache(true, $cacheFile);
if (is_file($cacheFile) && filemtime($cacheFile) >= time() - 900) {
$cached = json_decode((string)file_get_contents($cacheFile), true);
if (is_array($cached)) {
return $cached;
}
}
rewind($lock);
$lastRequestAt = (float)trim((string)stream_get_contents($lock));
$waitMicros = (int)max(0, (1.0 - (microtime(true) - $lastRequestAt)) * 1000000);
if ($waitMicros > 0) {
usleep($waitMicros);
}
$rows = $this->requestJson('GET', $url, null, ['Accept-Language: de']);
file_put_contents($cacheFile, json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
ftruncate($lock, 0);
rewind($lock);
fwrite($lock, (string)microtime(true));
fflush($lock);
return $rows;
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
private function distance(float $lat1, float $lng1, float $lat2, float $lng2): int
{
$earthRadius = 6371000.0;
$dLat = deg2rad($lat2 - $lat1); $dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2;
return (int)round($earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a)));
}
}

View File

@@ -9,6 +9,8 @@ final class SystemSettings
private const DEFAULTS = [
'google_places_enabled' => '0',
'azure_maps_enabled' => '0',
'osm_places_enabled' => '1',
'forum_maintenance_mode' => '0',
'site_maintenance_mode' => '0',
'site_maintenance_message' => 'Papa-Kind-Treff ist gerade kurz in Wartung. Bitte versuche es in Kürze erneut.',