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

@@ -95,6 +95,8 @@ jobs:
[ -f "$f" ] && cp "$f" .ci_config_deploy/
done
[ -f "${CONFIG_BASE_DIR}/.htaccess" ] && cp "${CONFIG_BASE_DIR}/.htaccess" .ci_config_deploy/
cp -R ${CONFIG_ENV_DIR}/. .ci_config_deploy/
echo "🔁 config → ${TARGET_PATH}${CONFIG_BASE_DIR}/"
@@ -105,11 +107,11 @@ jobs:
set ftp:ssl-protect-data true;
set ssl:verify-certificate no;
lcd .ci_config_deploy;
mirror -R --delete --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
mirror -R --delete --exclude secrets.local.php --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
bye
" || exit 1
else
echo "⚠️ Config-Deploy übersprungen: ${CONFIG_BASE_DIR} oder ${CONFIG_ENV_DIR} fehlt"
fi
echo "✅ Deploy abgeschlossen"
echo "✅ Deploy abgeschlossen"

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Local server secrets
/config/secrets.local.php
# Local environment files
/.env
/.env.*
!/.env.example

View File

@@ -90,6 +90,7 @@ deploy:staging:
cp "$f" .ci_config_deploy/
fi
done
[ -f "${CONFIG_BASE_DIR}/.htaccess" ] && cp "${CONFIG_BASE_DIR}/.htaccess" .ci_config_deploy/
fi
if [ -d "${CONFIG_ENV_DIR}" ]; then
@@ -104,7 +105,7 @@ deploy:staging:
set ftp:ssl-protect-data true;
set ssl:verify-certificate no;
lcd .ci_config_deploy;
mirror -R --delete --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
mirror -R --delete --exclude secrets.local.php --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
bye
" || { echo "❌ Upload für gemischtes Config-Verzeichnis fehlgeschlagen."; exit 1; }
@@ -197,6 +198,7 @@ deploy:production:
cp "$f" .ci_config_deploy/
fi
done
[ -f "${CONFIG_BASE_DIR}/.htaccess" ] && cp "${CONFIG_BASE_DIR}/.htaccess" .ci_config_deploy/
fi
if [ -d "${CONFIG_ENV_DIR}" ]; then
@@ -211,7 +213,7 @@ deploy:production:
set ftp:ssl-protect-data true;
set ssl:verify-certificate no;
lcd .ci_config_deploy;
mirror -R --delete --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
mirror -R --delete --exclude secrets.local.php --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
bye
" || { echo "❌ Upload für gemischtes Config-Verzeichnis fehlgeschlagen."; exit 1; }
@@ -224,4 +226,3 @@ deploy:production:
only:
- main
# when: manual

View File

@@ -154,9 +154,10 @@ CREATE TABLE listing_places (
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',

View File

@@ -66,6 +66,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
- Bestehende gespeicherte Community-Level erhalten diese beiden Rechte im Fallback automatisch ab 750 Punkten, solange sie nicht explizit anders gespeichert wurden.
## Technischer Rahmen
- Externe API-Schlüssel werden ausschließlich über Server-Umgebungsvariablen oder die lokale, nicht versionierte Datei `config/secrets.local.php` außerhalb des Webroots bereitgestellt.
- eigener Front-Controller in `public/index.php`
- Templates unter `partials/`
- Geschäftslogik unter `src/App/`
@@ -74,7 +75,9 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
## Neue Ausbaustufe: Termin-/Ort-System
- Neue Tabellenbasis: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits`, `listing_categories`
- Ziel: spätere Unterscheidung zwischen eigenem Event, Partner-Angebot, allgemeinem Ort und redaktionellem Termin
- Google ist noch nicht aktiv integriert; das System wird nur vorbereitend über Settings und Datenfelder darauf ausgelegt
- In der Admin-Freigabe werden aktivierte Ortsanbieter serverseitig parallel durchsucht: OpenStreetMap/Nominatim, Google Places und Azure Maps. Ein Admin wählt einen Treffer gezielt aus; je Ort können Verknüpfungen mehrerer Anbieter gespeichert werden. Google-Gesamtwertung und Bewertungsanzahl werden erst nach der ausgewählten Google-Verknüpfung geladen.
- Die Admin-Ansicht für Orte und Veranstaltungen hat die Unterpunkte `Freigaben`, `Bestehende Ortsbearbeitung` mit Suchfeld und `Fehlende Angaben`. Letzterer filtert veröffentlichte Einträge nach Verknüpfungen, die bei mindestens einem aktuell aktivierten Anbieter fehlen.
- Eine manuelle Admin-Suche überträgt Name und Adressdaten des offenen Eintrags an die aktivierten Ortsanbieter; Datenschutz- und Consent-Hinweise müssen vor dem produktiven Aktivieren geprüft werden.
## Vorgemerkte To-dos
- Kalender-Integration großer Anbieter soll später zusätzlich zum ICS-Feed kommen, zuerst vor allem für Google und Microsoft/Outlook.

View File

@@ -4,6 +4,8 @@ Stand: 2026-08-10
## Projektordner
- `config/`: Konfigurationen, inklusive `prod/` und `staging/`
- `config/secrets.local.php`: lokale, nicht versionierte Server-Geheimnisse außerhalb des Webroots; Vorlage: `config/secrets.local.php.example`
- `config/.htaccess`: zusätzliche Webserver-Sperre für den gesamten Konfigurationsordner; beide Deployment-Workflows schließen `secrets.local.php` explizit vom synchronisierenden Löschen aus.
- `partials/`: Templates, aufgeteilt in `landing/` und `structure/`
- `public/`: Webroot mit Assets und Seiten-Entry-Points
- `src/`: Backend-/Business-Logik und Kernklassen
@@ -30,6 +32,8 @@ Stand: 2026-08-10
- Preis-, Bild- und Ortseingabe für Orte und Veranstaltungen sowie reduzierte Event-Maske: `partials/landing/account/dashboard.php` plus Speicherung in `src/App/ListingCatalog.php`
- Freigaben sowie Änderungs- und Löschanfragen für Orte und Veranstaltungen: `src/App/ListingCatalog.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`
- Direktbearbeitung noch nicht veröffentlichter Orts- und Veranstaltungsvorschläge sowie Rechteauflösung für deren Freigabe: `src/App/CommunityAccess.php`, `src/App/AccountPages.php`, `src/App/ListingCatalog.php`, `partials/landing/account/dashboard.php`
- Server-seitige Ortsanbieter-Suche und sichere Trefferauswahl für die Admin-Freigabe: `src/App/PlaceProviderLookup.php`, `src/App/AccountPages.php`, `src/App/ListingCatalog.php`, `partials/landing/account/dashboard.php`
- Admin-Listen für bestehende Ortsbearbeitung und fehlende Anbieter-Verknüpfungen: `src/App/ListingCatalog.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`
- Rollenvergabe per Benutzersuche im User Management: `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`, `src/App/CommunityAccess.php`
- Legacy-Eigen-Events wurden erweitert in `src/App/AccountPages.php` und `Internal/db/schema.sql` um Kategorie- und Bildfelder
- Kategorien-Prüfung und Zusammenführung für Seiten-Admins liegen ebenfalls in `partials/landing/account/dashboard.php` mit Logik in `src/App/ListingCatalog.php`

View File

@@ -51,6 +51,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
- neue Basisklassen: `src/App/SystemSettings.php`, `src/App/ListingCatalog.php`
## Datenschutz und Sicherheit
- Server-Geheimnisse können lokal über `config/secrets.local.php` außerhalb des Webroots geladen werden; die Datei wird nicht versioniert und bei Deployments nicht gelöscht.
- Passwort-Hashes über Argon2id
- Konto-E-Mails verschlüsselt plus separater HMAC-Lookup-Hash
- persönliche Kalender-Feed-Tokens verschlüsselt plus separater SHA-256-Lookup-Hash

View File

@@ -66,6 +66,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
- Existing stored community levels receive these two rights automatically in the fallback from 750 points upward, unless they were explicitly saved differently.
## Technical Frame
- External API keys are provided only through server environment variables or the local, unversioned `config/secrets.local.php` file outside the webroot.
- custom front controller in `public/index.php`
- templates under `partials/`
- business logic under `src/App/`
@@ -74,7 +75,9 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
## New Expansion Stage: Listings and Places
- New table base: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits`, `listing_categories`
- Goal: later distinguish between own event, partner offer, general place, and editorial event entry
- Google is not integrated productively yet; the system is only being prepared for it via settings and data fields
- In admin approvals, enabled place providers are searched server-side in parallel: OpenStreetMap/Nominatim, Google Places, and Azure Maps. An admin deliberately selects a match; links to multiple providers can be saved for one place. Google aggregate rating and rating count are loaded only after the selected Google link.
- The admin view for places and events has the sub-items `Approvals`, `Existing place editing` with a search field, and `Missing information`. The latter filters published entries by links missing from at least one currently enabled provider.
- A manual admin lookup sends the open entry's name and address data to enabled place providers; privacy and consent notices must be reviewed before enabling this in production.
## Deferred To-dos
- Large calendar-provider integrations should be added later in addition to the ICS feed, starting primarily with Google and Microsoft/Outlook.

View File

@@ -4,6 +4,8 @@ Updated: 2026-08-10
## Main Directories
- `config/`: configuration, including `prod/` and `staging/`
- `config/secrets.local.php`: local, unversioned server secrets outside the webroot; template: `config/secrets.local.php.example`
- `config/.htaccess`: additional web-server protection for the entire configuration directory; both deployment workflows explicitly exclude `secrets.local.php` from synchronizing deletion.
- `partials/`: templates split into `landing/` and `structure/`
- `public/`: webroot, assets, and page entry points
- `src/`: backend and business logic
@@ -30,6 +32,8 @@ Updated: 2026-08-10
- pricing, image upload, and place/address input for places and event-style entries plus a reduced own-event form: `partials/landing/account/dashboard.php` with persistence in `src/App/ListingCatalog.php`
- approvals as well as change and deletion requests for places and event-style entries: `src/App/ListingCatalog.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`
- direct editing for not-yet-published place and event-style suggestions plus approval-right resolution: `src/App/CommunityAccess.php`, `src/App/AccountPages.php`, `src/App/ListingCatalog.php`, `partials/landing/account/dashboard.php`
- server-side place-provider lookup and secure match selection for admin approvals: `src/App/PlaceProviderLookup.php`, `src/App/AccountPages.php`, `src/App/ListingCatalog.php`, `partials/landing/account/dashboard.php`
- admin lists for existing place editing and missing provider links: `src/App/ListingCatalog.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`
- role assignment via user search in User Management: `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`, `src/App/CommunityAccess.php`
- legacy own events were extended in `src/App/AccountPages.php` and `Internal/db/schema.sql` with category and image fields

View File

@@ -50,6 +50,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app
- new base classes: `src/App/SystemSettings.php`, `src/App/ListingCatalog.php`
## Privacy and Security
- Server secrets can be loaded locally through `config/secrets.local.php` outside the webroot; the file is not versioned and is not deleted by deployments.
- password hashes via Argon2id
- account emails are encrypted and additionally addressed through a separate HMAC lookup hash
- personal calendar-feed tokens are stored encrypted and additionally resolved through a separate SHA-256 lookup hash

View File

@@ -67,10 +67,15 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf:
- Im Mitgliederbereich `Events` gibt es jetzt zusätzlich einen ICS-Download und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen
- Der Bereich `Events` ist im Mitgliederbereich jetzt zusätzlich in die Tabs `Meine Events`, `Kalendersynchronisation` und `Abgelaufene Events` gegliedert
- Vorschläge für neue Orte und Veranstaltungen werden schon vor der Freigabe als mögliche Ortsauswahl für Events berücksichtigt, solange sie nicht abgelehnt oder archiviert wurden
- In der Admin-Freigabe können berechtigte Nutzer offene Orte und Veranstaltungen serverseitig bei aktivierten Anbietern (OpenStreetMap, Google Places, Azure Maps) suchen und einen Treffer gezielt verknüpfen; Google-Gesamtwertung und Bewertungsanzahl werden erst nach einer bewussten Google-Verknüpfung geladen.
- Die Admin-Ansicht für Orte und Veranstaltungen gliedert sich in `Freigaben`, `Bestehende Ortsbearbeitung` mit Suchfeld und `Fehlende Angaben`; Letztere listet veröffentlichte Einträge ohne Verknüpfung zu mindestens einem aktuell aktivierten Anbieter.
- Für später vorgemerkt: direkte Kalender-Anbindung großer Anbieter wie Google und Microsoft/Outlook per OAuth, zusätzlich zum bestehenden ICS-Feed
- Interne Projektdateien und das Datenbankschema liegen nicht mehr im Root, sondern unter `Internal/`
## Datenschutz und Sicherheit
- Server-Geheimnisse werden optional aus `config/secrets.local.php` außerhalb des Webroots geladen; die Datei ist lokal, wird nicht versioniert und bei Deployments nicht gelöscht.
- Schlüssel für Ortsanbieter bleiben ausschließlich serverseitig. Die Auswahl von Suchtreffern erfolgt über kurzzeitig in der Server-Session abgelegte Treffer, nicht über vom Browser übermittelte Provider-IDs.
- Das Aktivieren von Ortsanbietern muss mit den Datenschutz- und Consent-Hinweisen abgeglichen werden, weil eine manuelle Admin-Suche Name und Adressdaten eines offenen Eintrags an den gewählten Anbieter überträgt.
- sensible Profilfelder werden app-seitig verschlüsselt gespeichert
- Konto-E-Mails werden verschlüsselt gespeichert und zusätzlich über einen separaten HMAC-Lookup-Hash adressiert
- persönliche Kalender-Feed-Tokens werden verschlüsselt gespeichert und zusätzlich über einen separaten SHA-256-Lookup-Hash adressiert

7
config/.htaccess Normal file
View File

@@ -0,0 +1,7 @@
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Deny from all
</IfModule>

View File

@@ -1,6 +1,12 @@
<?php
declare(strict_types=1);
// Local server secrets live outside the public webroot and are never committed.
$localSecrets = __DIR__ . '/secrets.local.php';
if (is_file($localSecrets)) {
require_once $localSecrets;
}
// Basic error reporting (keep strict in dev)
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
// Copy this file to config/secrets.local.php and replace the placeholders.
// Never commit config/secrets.local.php or place it inside public/.
if (getenv('GOOGLE_MAPS_API_KEY') === false) {
putenv('GOOGLE_MAPS_API_KEY=replace-with-your-google-server-key');
}
if (getenv('AZURE_MAPS_SUBSCRIPTION_KEY') === false) {
putenv('AZURE_MAPS_SUBSCRIPTION_KEY=replace-with-your-azure-maps-key');
}

View File

@@ -264,22 +264,60 @@ if ($isAdminArea && !empty($canManageSystemSettings)) {
<?php endif; ?>
<?php if ($section === 'listing-review' && !empty($canReviewListings)): ?>
<section class="account-panel">
<div class="account-panel__head"><h2>Orte &amp; Veranstaltungen freigeben</h2><p class="muted">Nur noch nicht freigegebene Orte und Veranstaltungen prüfen und bei Bedarf vor der Freigabe bearbeiten.</p></div>
<div class="account-panel__head">
<h2>Orte &amp; Veranstaltungen</h2>
<p class="muted">Freigaben bearbeiten sowie externe Ortsdaten für bestehende Einträge ergänzen oder aktualisieren.</p>
<div class="flex gap-12" style="flex-wrap:wrap; margin-top:14px;">
<a class="btn <?= ($listingReviewTab ?? 'approvals') === 'approvals' ? '' : 'ghost' ?>" href="/dashboard?area=admin&amp;section=listing-review&amp;listing_tab=approvals">Freigaben</a>
<a class="btn <?= ($listingReviewTab ?? '') === 'existing' ? '' : 'ghost' ?>" href="/dashboard?area=admin&amp;section=listing-review&amp;listing_tab=existing">Bestehende Ortsbearbeitung</a>
<a class="btn <?= ($listingReviewTab ?? '') === 'missing' ? '' : 'ghost' ?>" href="/dashboard?area=admin&amp;section=listing-review&amp;listing_tab=missing">Fehlende Angaben</a>
</div>
</div>
<div class="account-panel__body">
<?php if (!$listingReviewRequests): ?><p class="muted">Keine offenen Orte oder Veranstaltungen zur Freigabe.</p><?php endif; ?>
<?php if (($listingReviewTab ?? 'approvals') !== 'approvals'): ?>
<form method="get" class="flex gap-12" style="align-items:end; flex-wrap:wrap; margin-bottom:18px;">
<input type="hidden" name="area" value="admin"><input type="hidden" name="section" value="listing-review"><input type="hidden" name="listing_tab" value="<?= htmlspecialchars((string)$listingReviewTab, ENT_QUOTES) ?>">
<label class="stack gap-6" style="min-width:280px; flex:1;"><span class="label">Ort oder Veranstaltung suchen</span><input class="input" name="listing_query" value="<?= htmlspecialchars((string)($listingManagementSearchQuery ?? ''), ENT_QUOTES) ?>" placeholder="Name, Straße, Ort oder PLZ"></label>
<button class="btn" type="submit">Suchen</button>
</form>
<?php if (($listingReviewTab ?? '') === 'missing'): ?><p class="muted small">Gezeigt werden veröffentlichte Einträge, denen mindestens einer der aktuell aktivierten Anbieter fehlt: <?= $activePlaceProviders ? htmlspecialchars(implode(', ', array_map('strtoupper', $activePlaceProviders)), ENT_QUOTES) : 'keine aktiven Anbieter' ?>.</p><?php endif; ?>
<?php endif; ?>
<?php if (($listingReviewTab ?? 'approvals') === 'approvals' && !$listingReviewRequests): ?><p class="muted">Keine offenen Orte oder Veranstaltungen zur Freigabe.</p><?php endif; ?>
<?php if (($listingReviewTab ?? 'approvals') !== 'approvals' && !$listingManagementEntries): ?><p class="muted">Keine passenden bestehenden Orte oder Veranstaltungen gefunden.</p><?php endif; ?>
<div class="stack gap-12">
<?php foreach ($listingReviewRequests as $request): ?>
<?php foreach (($listingReviewTab ?? 'approvals') === 'approvals' ? $listingReviewRequests : $listingManagementEntries as $request): ?>
<article class="card" style="padding:18px;">
<strong><?= htmlspecialchars((string)$request['title'], ENT_QUOTES) ?></strong>
<span class="badge"><?= (string)($request['listing_type'] ?? '') === 'place' ? 'Ort' : 'Veranstaltung' ?></span>
<p class="muted small"><?= htmlspecialchars(trim(implode(', ', array_filter([(string)($request['street'] ?? ''), trim((string)($request['zip'] ?? '') . ' ' . (string)($request['city'] ?? '')), (string)($request['region'] ?? '')]))), ENT_QUOTES) ?></p>
<p class="muted small">Eingereicht von <?= htmlspecialchars((string)($request['requested_by_name'] ?: 'Mitglied'), ENT_QUOTES) ?>.</p>
<div class="flex gap-12" style="flex-wrap:wrap; margin:14px 0;"><a class="btn ghost" href="/dashboard?area=admin&amp;section=listing-review&amp;edit_listing=<?= (int)$request['listing_id'] ?>">Bearbeiten</a></div>
<form method="post" class="stack gap-8">
<?php if (($listingReviewTab ?? 'approvals') === 'approvals'): ?><p class="muted small">Eingereicht von <?= htmlspecialchars((string)($request['requested_by_name'] ?: 'Mitglied'), ENT_QUOTES) ?>.</p><?php endif; ?>
<?php if (!empty($request['provider_links'])): ?><p class="muted small">Verknüpft: <?= htmlspecialchars(implode(', ', array_map('strtoupper', array_keys((array)$request['provider_links']))), ENT_QUOTES) ?><?= $request['rating_value'] !== null ? ' · Google: ' . htmlspecialchars((string)$request['rating_value'], ENT_QUOTES) . ' / 5 (' . (int)$request['rating_count'] . ')' : '' ?></p><?php endif; ?>
<?php if (!empty($request['missing_providers'])): ?><p class="muted small">Fehlt: <?= htmlspecialchars(implode(', ', array_map('strtoupper', (array)$request['missing_providers'])), ENT_QUOTES) ?></p><?php endif; ?>
<div class="flex gap-12" style="flex-wrap:wrap; margin:14px 0;">
<?php if (($listingReviewTab ?? 'approvals') === 'approvals'): ?><a class="btn ghost" href="/dashboard?area=admin&amp;section=listing-review&amp;listing_tab=approvals&amp;edit_listing=<?= (int)$request['listing_id'] ?>">Bearbeiten</a><?php endif; ?>
<form method="post"><input type="hidden" name="action" value="place_provider_search"><input type="hidden" name="listing_id" value="<?= (int)$request['listing_id'] ?>"><button class="btn ghost" type="submit">Externe Ortsdaten suchen</button></form>
</div>
<?php $providerSearch = $placeProviderSearches[(int)$request['listing_id']] ?? null; ?>
<?php if (is_array($providerSearch)): ?>
<div class="card stack gap-10" style="padding:14px; margin:14px 0;">
<strong>Treffer für <?= htmlspecialchars((string)($providerSearch['query'] ?? ''), ENT_QUOTES) ?></strong>
<?php foreach ((array)($providerSearch['notices'] ?? []) as $notice): ?><p class="muted small" style="margin:0;"><?= htmlspecialchars((string)$notice, ENT_QUOTES) ?></p><?php endforeach; ?>
<?php if (empty($providerSearch['results'])): ?><p class="muted small" style="margin:0;">Keine Treffer. Adresse oder Name bitte über „Bearbeiten“ prüfen.</p><?php endif; ?>
<?php foreach ((array)($providerSearch['results'] ?? []) as $resultIndex => $result): ?>
<div class="card" style="padding:12px;">
<div class="flex gap-12" style="align-items:flex-start; justify-content:space-between; flex-wrap:wrap;">
<div><strong><?= htmlspecialchars((string)($result['name'] ?? ''), ENT_QUOTES) ?></strong> <span class="badge"><?= htmlspecialchars(strtoupper((string)($result['provider'] ?? '')), ENT_QUOTES) ?></span><p class="muted small" style="margin:6px 0 0;"><?= htmlspecialchars((string)($result['address'] ?? ''), ENT_QUOTES) ?><?= isset($result['distance_m']) && $result['distance_m'] !== null ? ' · ca. ' . (int)$result['distance_m'] . ' m entfernt' : '' ?><?= isset($result['rating']) && $result['rating'] !== null ? ' · Google: ' . htmlspecialchars((string)$result['rating'], ENT_QUOTES) . ' / 5 (' . (int)($result['rating_count'] ?? 0) . ')' : '' ?></p></div>
<form method="post"><input type="hidden" name="action" value="place_provider_link"><input type="hidden" name="listing_id" value="<?= (int)$request['listing_id'] ?>"><input type="hidden" name="result_index" value="<?= (int)$resultIndex ?>"><button class="btn ghost" type="submit">Diesen Treffer verwenden</button></form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (($listingReviewTab ?? 'approvals') === 'approvals'): ?><form method="post" class="stack gap-8">
<input type="hidden" name="action" value="pending_listing_decide"><input type="hidden" name="listing_id" value="<?= (int)$request['listing_id'] ?>">
<textarea name="review_note" class="textarea" rows="2" placeholder="Hinweis für die Entscheidung"></textarea>
<div class="flex gap-12"><button class="btn" type="submit" name="decision" value="approved">Freigeben</button><button class="btn ghost" type="submit" name="decision" value="rejected">Ablehnen</button></div>
</form>
</form><?php endif; ?>
</article>
<?php endforeach; ?>
</div>
@@ -1000,20 +1038,35 @@ if ($isAdminArea && !empty($canManageSystemSettings)) {
<div class="card">
<strong>Orts- und Bewertungsdienste</strong>
<div class="stack gap-10" style="margin-top:12px;">
<label class="chip" style="cursor:pointer; align-items:flex-start;">
<input type="checkbox" name="osm_places_enabled" value="1" <?= !isset($systemSettingsValues['osm_places_enabled']) || $systemSettingsValues['osm_places_enabled'] === '1' ? 'checked' : '' ?> style="margin-right:8px; margin-top:4px;">
<span>OpenStreetMap / Nominatim aktivieren
<span class="muted small" style="display:block;">Kostenlose Orts- und Adresssuche. Bei Deaktivierung werden keine OSM-Anfragen für neue Ortsverknüpfungen ausgelöst.</span>
</span>
</label>
<label class="chip" style="cursor:pointer; align-items:flex-start;">
<input type="checkbox" name="google_places_enabled" value="1" <?= !empty($systemSettingsValues['google_places_enabled']) && $systemSettingsValues['google_places_enabled'] === '1' ? 'checked' : '' ?> style="margin-right:8px; margin-top:4px;">
<span>Google Places vorbereitend aktivieren
<span class="muted small" style="display:block;">Aktuell noch ohne produktive Anbindung. Der Schalter ist als technische Vorbereitung für spätere Place-ID-, Bewertungs- und Review-Anreicherung gedacht.</span>
<span>Google Places aktivieren
<span class="muted small" style="display:block;">Erlaubt die Google-Verknüpfung und aktuelle Gesamtwertung mit Anzahl der Bewertungen.</span>
</span>
</label>
<label class="chip" style="cursor:pointer; align-items:flex-start;">
<input type="checkbox" name="azure_maps_enabled" value="1" <?= !empty($systemSettingsValues['azure_maps_enabled']) && $systemSettingsValues['azure_maps_enabled'] === '1' ? 'checked' : '' ?> style="margin-right:8px; margin-top:4px;">
<span>Azure Maps aktivieren
<span class="muted small" style="display:block;">Erlaubt die Microsoft-POI-Suche für Ortsverknüpfungen.</span>
</span>
</label>
<div class="stack gap-6">
<label class="label" for="placeDataProvider">Standardanbieter für Ortsdaten</label>
<select id="placeDataProvider" name="place_data_provider" class="select">
<option value="osm" <?= (($systemSettingsValues['place_data_provider'] ?? 'osm') === 'osm') ? 'selected' : '' ?>>OpenStreetMap / Nominatim</option>
<option value="osm_google_optional" <?= (($systemSettingsValues['place_data_provider'] ?? 'osm') === 'osm_google_optional') ? 'selected' : '' ?>>OpenStreetMap primär, Google optional</option>
<option value="google" <?= (($systemSettingsValues['place_data_provider'] ?? '') === 'google') ? 'selected' : '' ?>>Google Places primär</option>
<option value="azure" <?= (($systemSettingsValues['place_data_provider'] ?? '') === 'azure') ? 'selected' : '' ?>>Azure Maps primär</option>
<option value="all_enabled" <?= (($systemSettingsValues['place_data_provider'] ?? '') === 'all_enabled') ? 'selected' : '' ?>>Alle aktivierten Anbieter durchsuchen</option>
</select>
<p class="muted small">So bleibt eine kostenfreie Basis aktiv, während Google später zusätzlich vorbereitet werden kann.</p>
</div>
<p class="muted small" style="margin:0;">Bei einer manuellen Admin-Suche werden Name und Adressdaten des offenen Eintrags an die hier aktivierten Anbieter übertragen. Datenschutzhinweise und gegebenenfalls Consent-Text bitte vor dem produktiven Aktivieren prüfen.</p>
</div>
</div>

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.',