From 6639da130ea4cab2ebc77d3aff44a165f1be8393 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 19 Sep 2026 06:16:19 +0200 Subject: [PATCH] fix: Google-Rezensionen auf "Places API (New)" umgestellt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: dauerhaft REQUEST_DENIED mit "The provided API key is invalid", obwohl der Schluessel keinerlei Einschraenkung traegt. Die Meldung fuehrt in die Irre. Der Code rief den alten Endpunkt maps.googleapis.com/maps/api/place/details/json auf, und Google laesst die dazugehoerige alte "Places API" in seit Maerz 2025 angelegten Cloud-Projekten gar nicht mehr aktivieren. Der Fehler betrifft also nicht den Schluessel, sondern die API — wer der Meldung folgt, sucht zwangslaeufig an der falschen Stelle. Das README hatte diesen Fall als "Legacy-Hinweis" bereits vorhergesagt. Jetzt places.googleapis.com/v1 mit X-Goog-Api-Key im Header (statt des Schluessels in der Adresszeile, wo er in fremden Zugriffsprotokollen landet) und einer engen X-Goog-FieldMask — die API rechnet nach angeforderten Feldern ab. Die Fehlermeldung im Log nennt jetzt die drei Dinge, an denen es tatsaechlich liegen kann: API nicht aktiviert, keine Abrechnung am Projekt, oder ein OAuth-Client statt eines API-Schluessels hinterlegt. Zwei Verhaltensaenderungen, beide unvermeidbar: - Die neue API kennt kein reviews_sort=newest; sie liefert bis zu fuenf Rezensionen in Googles eigener Reihenfolge. - Rezensionen ohne Namen werden uebersprungen (zuvor nur die ohne Text): ohne Namen fehlt die von den Nutzungsbedingungen verlangte Zuordnung zur Quelle. README und .env.example nennen jetzt alle vier noetigen Schritte und weisen ausdruecklich darauf hin, dass eine Referrer-Beschraenkung den Abruf blockiert — er laeuft serverseitig. Dazu, mit Blick auf mehrere Instanzen: DATABASE_POOL_SIZE ist jetzt einstellbar (Vorgabe 20) und in der ConfigMap dokumentiert. Der Wert gilt je Instanz und multipliziert sich mit "replicas" — drei Instanzen belegen 60 der voreingestellt 100 PostgreSQL-Verbindungen. tsc: 0 Fehler. vitest: 336/336 gruen. eslint: 0 Warnungen. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 6 +- README.md | 26 ++++-- k8s/01-configmap.yaml | 9 +++ src/lib/db-adapter.ts | 26 +++++- src/lib/google-reviews.ts | 161 ++++++++++++++++++++++++-------------- 5 files changed, 160 insertions(+), 68 deletions(-) diff --git a/.env.example b/.env.example index 35978b5..18e15aa 100644 --- a/.env.example +++ b/.env.example @@ -62,7 +62,11 @@ SITE_ADDRESS= # Google Rezensionen (optional). Ohne diese beiden Werte zeigt die Startseite # automatisch die statischen Beispiel-Stimmen als Fallback. -# API-Key: Google-Cloud-Projekt mit aktivierter "Places API". +# API-Key: Google-Cloud-Projekt mit aktivierter "Places API (New)" — NICHT +# die gleichnamige alte API, die in neuen Projekten nicht mehr aktivierbar +# ist. Ausserdem noetig: Abrechnung am Projekt aktiv, und der Wert muss ein +# API-Schluessel sein (ein OAuth-Client funktioniert nicht). Keine +# Referrer-Beschraenkung setzen — der Abruf erfolgt serverseitig. # Place-ID: https://developers.google.com/maps/documentation/places/web-service/place-id GOOGLE_PLACES_API_KEY= GOOGLE_PLACE_ID= diff --git a/README.md b/README.md index bf8ff31..335b72c 100644 --- a/README.md +++ b/README.md @@ -209,22 +209,32 @@ Die Versandkosten richten sich **nicht** nach dem Bestellwert (kein Freibetrag), Die Bewertungen-Sektion der Startseite zeigt echte Google-Rezensionen, sobald `GOOGLE_PLACES_API_KEY` (Secret) und `GOOGLE_PLACE_ID` (ConfigMap) gesetzt sind. Ohne diese Werte greifen die statischen Beispiel-Stimmen. -**Einrichtung:** Google-Cloud-Projekt anlegen, darin die **Places API** aktivieren, einen API-Schlüssel erzeugen und ihn auf diese eine API einschränken. Der Schlüssel wird ausschließlich serverseitig verwendet und erscheint nie im Browser — eine Referrer-Beschränkung greift daher nicht; sinnvoll ist eine IP-Beschränkung auf die ausgehende Adresse des Clusters. Die Place-ID des eigenen Eintrags findet sich über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id). +**Verwendet wird die `Places API (New)`** (`places.googleapis.com/v1`), nicht der alte `place/details`-Endpunkt. Das ist keine Geschmacksfrage: Google lässt die alte „Places API" in seit März 2025 angelegten Cloud-Projekten gar nicht mehr aktivieren. -**Fehler werden jetzt im Server-Log benannt.** Das ist wichtig, weil jeder Fehlerfall auf der Startseite gleich aussieht — nämlich nach den Beispiel-Stimmen: +**Einrichtung — alle vier Schritte sind nötig:** + +1. **API aktivieren:** In der Cloud Console unter *APIs & Dienste → Bibliothek* nach `Places API (New)` suchen und aktivieren. Es gibt dort zwei fast gleich heißende Einträge — die alte „Places API" ist die falsche, und die „Maps JavaScript API" hilft hier ebenfalls nicht. +2. **Abrechnung aktivieren:** Ohne hinterlegtes Rechnungskonto antwortet auch die neue API mit einem Berechtigungsfehler. Das kostenlose Kontingent deckt diese Nutzung (ein Abruf pro Stunde) bei Weitem ab. +3. **API-Schlüssel erzeugen:** *APIs & Dienste → Anmeldedaten → Anmeldedaten erstellen → API-Schlüssel*. Ein **OAuth-Client ist etwas anderes** und funktioniert hier nicht — er ist für Anmeldungen im Namen eines Nutzers gedacht, nicht für Serverabrufe. +4. **Werte setzen:** Schlüssel als `GOOGLE_PLACES_API_KEY` (Secret), Place-ID als `GOOGLE_PLACE_ID` (ConfigMap). Die Place-ID des eigenen Eintrags findet sich über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id). + +Der Schlüssel wird ausschließlich serverseitig verwendet und erscheint nie im Browser — eine Referrer-Beschränkung greift daher **nicht** und würde den Abruf blockieren. Sinnvoll ist stattdessen eine API-Beschränkung auf „Places API (New)" und optional eine IP-Beschränkung auf die ausgehende Adresse des Clusters. + +**Fehler werden im Server-Log benannt.** Das ist wichtig, weil jeder Fehlerfall auf der Startseite gleich aussieht — nämlich nach den Beispiel-Stimmen: | Meldung im Log | Bedeutung | |----------------|-----------| -| `REQUEST_DENIED` | Schlüssel ungültig, oder die Places API ist im Projekt nicht aktiviert | -| `INVALID_REQUEST` | Parameter falsch | -| `NOT_FOUND` / `ZERO_RESULTS` | Die Place-ID passt zu keinem Eintrag | -| `OVER_QUERY_LIMIT` | Kontingent erschöpft oder keine Abrechnung hinterlegt | +| `… API … not enabled` / `SERVICE_DISABLED` | „Places API (New)" ist im Projekt nicht aktiviert | +| `The provided API key is invalid` | Meist: alter Endpunkt oder alte API — bei dieser Fassung des Codes eher ein tatsächlich falscher Schlüssel | +| `billing … not enabled` | Kein Rechnungskonto am Projekt | +| `HTTP 404` | Die Place-ID passt zu keinem Eintrag | +| `API keys with referer restrictions …` | Der Schlüssel ist auf HTTP-Referrer beschränkt; für Serverabrufe untauglich | Die Antwort wird eine Stunde zwischengespeichert (`revalidate: 3600`), was die Kosten niedrig hält, und nach fünf Sekunden abgebrochen, damit eine nicht antwortende Google-API nicht die Startseite aufhält. -**Zuordnung:** Angezeigte Rezensionen verlinken auf ihre Quelle bei Google — die Nutzungsbedingungen der Places API verlangen das. Rezensionen ohne Text (reine Sternebewertungen) werden übersprungen, sie wären sonst leere Sprechblasen. +**Zuordnung:** Angezeigte Rezensionen verlinken auf ihre Quelle bei Google — die Nutzungsbedingungen der Places API verlangen das. Rezensionen ohne Text (reine Sternebewertungen) oder ohne Namen werden übersprungen: die einen wären leere Sprechblasen, den anderen fehlte die verlangte Quellenangabe. -> **Legacy-Hinweis:** Genutzt wird der klassische `place/details`-Endpunkt. Google führt ihn als *Legacy* und stellt neue Projekte auf die Places API (New) um (`places.googleapis.com/v1`). Meldet das Log dauerhaft `REQUEST_DENIED`, obwohl Schlüssel und Abrechnung stimmen, ist das der wahrscheinliche Grund — dann ist die Umstellung auf die neue API nötig. +> **Hinweis zur Sortierung:** Der alte Endpunkt kannte `reviews_sort=newest`. Die neue API bietet das nicht an und liefert bis zu fünf Rezensionen in Googles eigener Reihenfolge. ## E-Mail-Versand diff --git a/k8s/01-configmap.yaml b/k8s/01-configmap.yaml index 711a763..86e63bc 100644 --- a/k8s/01-configmap.yaml +++ b/k8s/01-configmap.yaml @@ -44,6 +44,15 @@ data: # Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere # implizites TLS. + # Gleichzeitig offene Datenbankverbindungen JE INSTANZ. Leer = 20. + # + # Beim Hochskalieren mitdenken: der Wert multipliziert sich mit "replicas" + # in 05-app-deployment.yaml. Drei Instanzen mit je 20 belegen 60 der + # voreingestellt 100 Verbindungen von PostgreSQL — daneben brauchen der + # Aufraeum-CronJob, die Sicherung und gelegentlich eine Konsole noch + # welche. Entweder diesen Wert senken oder max_connections anheben. + DATABASE_POOL_SIZE: "20" + SMTP_PORT: "587" # Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath diff --git a/src/lib/db-adapter.ts b/src/lib/db-adapter.ts index dd9c2df..b0d3293 100644 --- a/src/lib/db-adapter.ts +++ b/src/lib/db-adapter.ts @@ -18,7 +18,29 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb"; * bei mehreren Instanzen multipliziert sich dieser Wert und will neu bedacht * werden. */ -const POOL_SIZE = 20; +const DEFAULT_POOL_SIZE = 20; + +/** + * Der Wert gilt PRO INSTANZ. Wer die Anwendung auf mehrere Instanzen + * hochzieht, multipliziert damit auch die Verbindungen: drei Instanzen mit je + * 20 belegen 60 der voreingestellt 100 Verbindungen von PostgreSQL, und + * daneben brauchen der Aufraeum-CronJob, die Sicherung und gelegentlich eine + * Konsole noch welche. Deshalb einstellbar statt fest verdrahtet — beim + * Hochskalieren ist das der Wert, der mitwandern muss. + */ +function poolSize(): number { + const raw = process.env.DATABASE_POOL_SIZE?.trim(); + if (!raw) return DEFAULT_POOL_SIZE; + + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 1) { + console.error( + `[db] DATABASE_POOL_SIZE="${raw}" ist keine ganze Zahl groesser 0 — es wird mit ${DEFAULT_POOL_SIZE} gearbeitet.` + ); + return DEFAULT_POOL_SIZE; + } + return parsed; +} /** * Wie lange eine Anfrage auf eine freie Verbindung wartet, bevor sie @@ -52,7 +74,7 @@ export function createDbAdapter(): PrismaPg | PrismaMariaDb { } return new PrismaPg({ connectionString, - max: POOL_SIZE, + max: poolSize(), // Ohne Zeitgrenze wartet eine Anfrage unbegrenzt auf eine freie // Verbindung. Unter Andrang staut sich dann alles lautlos auf, statt dass // irgendwo ein Fehler sichtbar wuerde. diff --git a/src/lib/google-reviews.ts b/src/lib/google-reviews.ts index 1a106c3..a2ffb81 100644 --- a/src/lib/google-reviews.ts +++ b/src/lib/google-reviews.ts @@ -12,35 +12,71 @@ export interface GoogleReview { profilePhotoUrl: string | null; } -/** Google antwortet auch bei Fehlern mit HTTP 200 — die Aussage steckt in "status". */ const GOOGLE_TIMEOUT_MS = 5000; -interface PlacesDetailsResponse { - result?: { - rating?: number; - user_ratings_total?: number; - reviews?: { - author_name: string; - rating: number; - text?: string; - relative_time_description: string; - author_url?: string; - profile_photo_url?: string; - }[]; +/** + * Nur diese Felder werden angefordert. Die Places API rechnet nach + * angeforderten Feldern ab — eine Maske mit "*" waere teurer und lieferte + * Daten, die diese Seite nie anzeigt. + */ +const FIELD_MASK = "rating,userRatingCount,reviews"; + +interface PlacesTextV1 { + text?: string; +} + +interface PlacesReviewV1 { + rating?: number; + relativePublishTimeDescription?: string; + text?: PlacesTextV1; + originalText?: PlacesTextV1; + authorAttribution?: { + displayName?: string; + uri?: string; + photoUri?: string; + }; +} + +interface PlacesDetailsV1Response { + rating?: number; + userRatingCount?: number; + reviews?: PlacesReviewV1[]; + /** Nur im Fehlerfall gesetzt — anders als frueher antwortet die API dann auch mit einem Fehlerstatus. */ + error?: { + code?: number; + message?: string; + status?: string; }; - status: string; - error_message?: string; } /** * Ruft aktuelle Google-Rezensionen fuer den in GOOGLE_PLACE_ID hinterlegten - * Google-Maps-Eintrag ab (Places API, Legacy "Place Details"-Endpoint). - * Ohne konfigurierte Zugangsdaten wird null zurueckgegeben, sodass die - * Startseite auf die statischen Beispiel-Stimmen zurueckfaellt. + * Google-Maps-Eintrag ab. Ohne konfigurierte Zugangsdaten wird null + * zurueckgegeben, sodass die Startseite auf die statischen Beispiel-Stimmen + * zurueckfaellt. * - * Setup: Google-Cloud-Projekt mit aktivierter "Places API", API-Key als - * GOOGLE_PLACES_API_KEY, die Place-ID des Google-Maps-Eintrags als - * GOOGLE_PLACE_ID (zu finden ueber https://developers.google.com/maps/documentation/places/web-service/place-id). + * Verwendet wird die "Places API (New)" (places.googleapis.com/v1), NICHT der + * alte Endpunkt maps.googleapis.com/maps/api/place/details/json. Das ist kein + * Geschmacksurteil: Google laesst die alte "Places API" in seit Maerz 2025 + * angelegten Cloud-Projekten gar nicht mehr aktivieren. Ein Aufruf des alten + * Endpunkts wird dort beantwortet mit + * + * REQUEST_DENIED — The provided API key is invalid. + * + * also mit einer Aussage ueber den Schluessel, obwohl der Schluessel in + * Ordnung ist und keinerlei Einschraenkung traegt. Wer dieser Meldung folgt, + * sucht den Fehler zwangslaeufig an der falschen Stelle. + * + * Einrichtung (siehe auch README, Abschnitt "Google-Rezensionen"): + * 1. In der Google Cloud Console die API "Places API (New)" aktivieren — + * nicht die gleichnamige alte, nicht die Maps JavaScript API. + * 2. Abrechnung fuer das Projekt aktivieren; ohne sie antwortet auch die + * neue API mit einem Berechtigungsfehler. + * 3. Unter "Anmeldedaten" einen API-SCHLUESSEL erzeugen. Ein OAuth-Client + * ist etwas anderes und funktioniert hier nicht. + * 4. Schluessel als GOOGLE_PLACES_API_KEY setzen, die Place-ID als + * GOOGLE_PLACE_ID (zu finden ueber + * https://developers.google.com/maps/documentation/places/web-service/place-id). */ export async function fetchGoogleReviews(): Promise<{ reviews: GoogleReview[]; @@ -51,60 +87,71 @@ export async function fetchGoogleReviews(): Promise<{ const placeId = process.env.GOOGLE_PLACE_ID; if (!apiKey || !placeId) return null; - const url = new URL("https://maps.googleapis.com/maps/api/place/details/json"); - url.searchParams.set("place_id", placeId); - // "reviews" (Plural!) — mit "review" antwortet Google mit INVALID_REQUEST, - // und weil jeder Fehlerfall hier still auf die Beispiel-Stimmen - // zurueckfaellt, sah die Startseite dabei voellig normal aus. Genau so - // blieb der Tippfehler unbemerkt. - url.searchParams.set("fields", "reviews,rating,user_ratings_total"); - url.searchParams.set("language", "de"); - url.searchParams.set("reviews_sort", "newest"); - url.searchParams.set("key", apiKey); + // Die Place-ID wird mal als "ChIJ..." und mal als "places/ChIJ..." notiert — + // der Pfad braucht sie genau einmal mit Praefix. + const resourceName = placeId.startsWith("places/") ? placeId : `places/${placeId}`; + + const url = new URL(`https://places.googleapis.com/v1/${resourceName}`); + url.searchParams.set("languageCode", "de"); try { - // 1 Stunde Cache: haelt die Seite schnell und reduziert API-Kosten, - // ohne die Rezensionen spuerbar veralten zu lassen. + // 1 Stunde Cache: haelt die Seite schnell und reduziert API-Kosten, ohne + // die Rezensionen spuerbar veralten zu lassen. const response = await fetch(url, { + headers: { + // Der Schluessel gehoert bei dieser API in den Header, nicht in die + // Adresszeile — so steht er nicht in fremden Zugriffsprotokollen. + "X-Goog-Api-Key": apiKey, + "X-Goog-FieldMask": FIELD_MASK, + }, next: { revalidate: 3600 }, // Ohne Zeitlimit haengt die Startseite an einer nicht antwortenden // fremden API — dieselbe Absicherung wie beim Passwort-Leak-Abgleich. signal: AbortSignal.timeout(GOOGLE_TIMEOUT_MS), }); - if (!response.ok) { - console.warn(`[google-reviews] Places API antwortete mit HTTP ${response.status}.`); - return null; - } - const data = (await response.json()) as PlacesDetailsResponse; - if (data.status !== "OK" || !data.result) { - // Laut melden statt still zurueckfallen: "REQUEST_DENIED" (Schluessel - // ungueltig oder Places API nicht aktiviert), "INVALID_REQUEST" - // (Parameter falsch) und "NOT_FOUND" (Place-ID stimmt nicht) sehen auf - // der Startseite alle gleich aus — naemlich nach den Beispiel-Stimmen. + const data = (await response.json().catch(() => null)) as PlacesDetailsV1Response | null; + + if (!response.ok || data?.error) { + // Laut melden statt still zurueckfallen: ein falscher Schluessel, eine + // nicht aktivierte API und eine unbekannte Place-ID sehen auf der + // Startseite alle gleich aus — naemlich nach den Beispiel-Stimmen. + const detail = data?.error?.message ?? `HTTP ${response.status}`; console.warn( - `[google-reviews] Places API meldet "${data.status}"${data.error_message ? `: ${data.error_message}` : ""}.` + `[google-reviews] Places API (New) hat den Abruf abgelehnt: ${detail}. Zu pruefen: Ist "Places API (New)" im Google-Cloud-Projekt aktiviert, ist die Abrechnung aktiv, und ist GOOGLE_PLACES_API_KEY ein API-Schluessel (kein OAuth-Client)?` ); return null; } + if (!data) { + console.warn("[google-reviews] Antwort der Places API war kein gueltiges JSON."); + return null; + } - const reviews: GoogleReview[] = (data.result.reviews ?? []) + const reviews: GoogleReview[] = (data.reviews ?? []) + .map((review) => { + // "text" ist die (ggf. uebersetzte) Fassung in languageCode, + // "originalText" die Originalsprache. Fehlt die Uebersetzung, ist das + // Original immer noch besser als gar kein Zitat. + const text = (review.text?.text ?? review.originalText?.text ?? "").trim(); + const author = review.authorAttribution?.displayName?.trim() ?? ""; + return { + author, + rating: review.rating ?? 0, + text, + relativeTime: review.relativePublishTimeDescription ?? "", + authorUrl: review.authorAttribution?.uri ?? null, + profilePhotoUrl: review.authorAttribution?.photoUri ?? null, + }; + }) // Rezensionen ohne Text sind reine Sternebewertungen — als Zitat auf der - // Startseite waeren sie eine leere Sprechblase. - .filter((r) => r.text?.trim()) - .map((r) => ({ - author: r.author_name, - rating: r.rating, - text: r.text!.trim(), - relativeTime: r.relative_time_description, - authorUrl: r.author_url ?? null, - profilePhotoUrl: r.profile_photo_url ?? null, - })); + // Startseite waeren sie eine leere Sprechblase. Ohne Namen fehlt zudem + // die von den Nutzungsbedingungen verlangte Zuordnung zur Quelle. + .filter((review) => review.text.length > 0 && review.author.length > 0); return { reviews, - rating: data.result.rating ?? null, - totalCount: data.result.user_ratings_total ?? null, + rating: data.rating ?? null, + totalCount: data.userRatingCount ?? null, }; } catch (error: unknown) { // Netzwerk-/API-Fehler duerfen die Startseite nicht zum Absturz bringen —