diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 577e2ca..d216b84 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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" \ No newline at end of file + echo "✅ Deploy abgeschlossen" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b101f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Local server secrets +/config/secrets.local.php + +# Local environment files +/.env +/.env.* +!/.env.example diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e812a15..97d46b3 100755 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 - diff --git a/Internal/db/schema.sql b/Internal/db/schema.sql index 008cce9..0d87348 100755 --- a/Internal/db/schema.sql +++ b/Internal/db/schema.sql @@ -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', diff --git a/Internal/de/PROJECT_CONTEXT.md b/Internal/de/PROJECT_CONTEXT.md index 2561be1..3e157d9 100644 --- a/Internal/de/PROJECT_CONTEXT.md +++ b/Internal/de/PROJECT_CONTEXT.md @@ -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. diff --git a/Internal/de/PROJECT_STRUCTURE.md b/Internal/de/PROJECT_STRUCTURE.md index 1c5cf1d..547ef59 100644 --- a/Internal/de/PROJECT_STRUCTURE.md +++ b/Internal/de/PROJECT_STRUCTURE.md @@ -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` diff --git a/Internal/de/README.md b/Internal/de/README.md index 202a584..40910e9 100644 --- a/Internal/de/README.md +++ b/Internal/de/README.md @@ -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 diff --git a/Internal/en/PROJECT_CONTEXT.md b/Internal/en/PROJECT_CONTEXT.md index a754387..b3727f8 100644 --- a/Internal/en/PROJECT_CONTEXT.md +++ b/Internal/en/PROJECT_CONTEXT.md @@ -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. diff --git a/Internal/en/PROJECT_STRUCTURE.md b/Internal/en/PROJECT_STRUCTURE.md index 8adb2a4..a94a0dc 100644 --- a/Internal/en/PROJECT_STRUCTURE.md +++ b/Internal/en/PROJECT_STRUCTURE.md @@ -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 diff --git a/Internal/en/README.md b/Internal/en/README.md index c9e4f83..d7b1b4c 100644 --- a/Internal/en/README.md +++ b/Internal/en/README.md @@ -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 diff --git a/README.md b/README.md index 1f08ba9..dafd991 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/.htaccess b/config/.htaccess new file mode 100644 index 0000000..40ec0c6 --- /dev/null +++ b/config/.htaccess @@ -0,0 +1,7 @@ + + Require all denied + + + + Deny from all + diff --git a/config/config.php b/config/config.php index cdc97a2..fbc6d87 100755 --- a/config/config.php +++ b/config/config.php @@ -1,6 +1,12 @@
-

Orte & Veranstaltungen freigeben

Nur noch nicht freigegebene Orte und Veranstaltungen prüfen und bei Bedarf vor der Freigabe bearbeiten.

+
+

Orte & Veranstaltungen

+

Freigaben bearbeiten sowie externe Ortsdaten für bestehende Einträge ergänzen oder aktualisieren.

+ +
-

Keine offenen Orte oder Veranstaltungen zur Freigabe.

+ +
+ + + +
+

Gezeigt werden veröffentlichte Einträge, denen mindestens einer der aktuell aktivierten Anbieter fehlt: .

+ +

Keine offenen Orte oder Veranstaltungen zur Freigabe.

+

Keine passenden bestehenden Orte oder Veranstaltungen gefunden.

- +

-

Eingereicht von .

- -
+

Eingereicht von .

+

Verknüpft:

+

Fehlt:

+
+ Bearbeiten + +
+ + +
+ Treffer für +

+

Keine Treffer. Adresse oder Name bitte über „Bearbeiten“ prüfen.

+ $result): ?> +
+
+

+
+
+
+ +
+ +
-
+
@@ -1000,20 +1038,35 @@ if ($isAdminArea && !empty($canManageSystemSettings)) {
Orts- und Bewertungsdienste
+ +

So bleibt eine kostenfreie Basis aktiv, während Google später zusätzlich vorbereitet werden kann.

+

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.

diff --git a/src/App/AccountPages.php b/src/App/AccountPages.php index 3171a47..0d16149 100755 --- a/src/App/AccountPages.php +++ b/src/App/AccountPages.php @@ -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', diff --git a/src/App/ListingCatalog.php b/src/App/ListingCatalog.php index 442da9f..135ae89 100644 --- a/src/App/ListingCatalog.php +++ b/src/App/ListingCatalog.php @@ -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 diff --git a/src/App/PlaceProviderLookup.php b/src/App/PlaceProviderLookup.php new file mode 100644 index 0000000..9014f12 --- /dev/null +++ b/src/App/PlaceProviderLookup.php @@ -0,0 +1,248 @@ +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))); + } +} diff --git a/src/App/SystemSettings.php b/src/App/SystemSettings.php index c2e585f..244ee5e 100644 --- a/src/App/SystemSettings.php +++ b/src/App/SystemSettings.php @@ -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.',