fix(maps): reduce Google Places API quota usage with persistent caching

P0 — stop the bleeding:
- Honor place.image_url in MapView and TripPlannerPage to skip redundant fetchPhoto calls
- Trim Place Details field mask (drop reviews/editorialSummary from default; new getPlaceDetailsExpanded for inspector)
- Admin toggle places_photos_enabled (default ON) to kill Google photo fetches under quota pressure; Wikimedia unaffected
- Return { photoUrl: null } instead of 204 so client handles disabled state cleanly

P1 — structural fix:
- New placePhotoCache service: persistent disk cache at uploads/photos/google/<sha1>.jpg, atomic writes, stampede dedup via in-flight Map
- Migrations 105-107: google_place_photo_meta table, place_details_cache table, backfill signed Google URLs to stable proxy URLs
- getPlacePhoto rewrites to fetch image bytes directly, store on disk, return /api/maps/place-photo/:id/bytes proxy URL
- Stable proxy URLs written to places.image_url — survive container restarts, no expiry
- New GET /api/maps/place-photo/:placeId/bytes route serving cached files with long-lived Cache-Control
- Place Details DB row cache with 7-day TTL; ?refresh=1 escape hatch
- photoService fast-path: proxy URLs bypass the mapsApi round-trip and go straight to urlToBase64

Bug fixes:
- MapView now requests base64 thumbs for places with proxy image_url (markers were showing color fallback)
- createPlaceIcon accepts /api/maps/place-photo/ URLs as interim fallback while thumb generates
- setSelectedAssignmentId ReferenceError in mobile day-detail handler (use selectAssignment)
- Remove redundant decodeURIComponent on already-decoded Express route param
- Use SHA1 hash for disk filenames to prevent coords:lat:lng pseudo-ID collisions
- Add checkSsrf guard to Wikimedia byte fetch
- Tighten migration 107 LIKE filter to avoid rewriting manually-pasted Google image URLs
- Validate enabled is boolean on PUT /admin/places-photos
- Drop aggressive iconCache.clear() on every thumb arrival

Observability:
- googleFetch() wrapper counts and debug-logs every outbound Google API call with running total
This commit is contained in:
jubnl
2026-04-17 19:07:39 +02:00
parent 39f13881c5
commit 9c2decb095
18 changed files with 552 additions and 169 deletions
+19
View File
@@ -201,6 +201,25 @@ router.put('/bag-tracking', (req: Request, res: Response) => {
res.json(result);
});
// ── Places Photos ───────────────────────────────────────────────────────
router.get('/places-photos', (_req: Request, res: Response) => {
res.json(svc.getPlacesPhotos());
});
router.put('/places-photos', (req: Request, res: Response) => {
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
const result = svc.updatePlacesPhotos(req.body.enabled);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.places_photos',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
res.json(result);
});
// ── Collab Features ───────────────────────────────────────────────────────
router.get('/collab-features', (_req: Request, res: Response) => {
+23 -1
View File
@@ -4,11 +4,14 @@ import { AuthRequest } from '../types';
import {
searchPlaces,
getPlaceDetails,
getPlaceDetailsExpanded,
getPlacePhoto,
reverseGeocode,
resolveGoogleMapsUrl,
autocompletePlaces,
} from '../services/mapsService';
import { db } from '../db/database';
import { serveFilePath } from '../services/placePhotoCache';
const router = express.Router();
@@ -72,9 +75,13 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) =
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { placeId } = req.params;
const expand = req.query.expand as string | undefined;
try {
const result = await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
const refresh = req.query.refresh === '1';
const result = expand
? await getPlaceDetailsExpanded(authReq.user.id, placeId, req.query.lang as string, refresh)
: await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
res.json(result);
} catch (err: unknown) {
const status = (err as { status?: number }).status || 500;
@@ -88,6 +95,12 @@ router.get('/details/:placeId', authenticate, async (req: Request, res: Response
router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { placeId } = req.params;
// Kill-switch only applies to Google Places API fetches — Wikimedia (coords: prefix) is always allowed
if (!placeId.startsWith('coords:')) {
const photosEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
if (photosEnabledRow?.value === 'false') return res.status(200).json({ photoUrl: null });
}
const lat = parseFloat(req.query.lat as string);
const lng = parseFloat(req.query.lng as string);
@@ -102,6 +115,15 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
}
});
// GET /place-photo/:placeId/bytes — serve cached photo bytes from disk
router.get('/place-photo/:placeId/bytes', authenticate, (req: Request, res: Response) => {
const { placeId } = req.params;
const fp = serveFilePath(placeId);
if (!fp) return res.status(404).json({ error: 'Photo not cached' });
res.set('Cache-Control', 'public, max-age=2592000, immutable');
res.sendFile(fp);
});
// GET /reverse
router.get('/reverse', authenticate, async (req: Request, res: Response) => {
const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string };