mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -1634,6 +1634,45 @@ function runMigrations(db: Database.Database): void {
|
||||
try { db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
// Migration 105: Persistent Google place photo disk cache registry
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS google_place_photo_meta (
|
||||
place_id TEXT PRIMARY KEY,
|
||||
attribution TEXT,
|
||||
fetched_at INTEGER NOT NULL,
|
||||
error_at INTEGER
|
||||
)
|
||||
`);
|
||||
},
|
||||
// Migration 106: Persistent Place Details row cache
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS place_details_cache (
|
||||
place_id TEXT NOT NULL,
|
||||
lang TEXT NOT NULL DEFAULT '',
|
||||
expanded INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (place_id, lang, expanded)
|
||||
)
|
||||
`);
|
||||
},
|
||||
// Migration 107: Backfill expired signed Google photo URLs to stable proxy URLs
|
||||
{ raw: () => {
|
||||
db.exec(`
|
||||
UPDATE places
|
||||
SET image_url = '/api/maps/place-photo/' || google_place_id || '/bytes',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE google_place_id IS NOT NULL
|
||||
AND image_url IS NOT NULL
|
||||
AND image_url != ''
|
||||
AND (
|
||||
(image_url LIKE '%googleusercontent.com%' AND image_url LIKE '%/places/%/photos/%')
|
||||
OR (image_url LIKE '%places.googleapis.com%' AND image_url LIKE '%/places/%/photos/%')
|
||||
)
|
||||
`);
|
||||
}},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -459,6 +459,18 @@ export function updateBagTracking(enabled: boolean) {
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Places Photos ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getPlacesPhotos() {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
|
||||
return { enabled: row?.value !== 'false' };
|
||||
}
|
||||
|
||||
export function updatePlacesPhotos(enabled: boolean) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Collab Features ───────────────────────────────────────────────────────
|
||||
|
||||
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
|
||||
|
||||
@@ -229,6 +229,8 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||
const hasWebhookEnabled = activeChannels.includes('webhook');
|
||||
const tripRemindersEnabled = tripReminderSetting !== 'false';
|
||||
const placesPhotosSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined)?.value;
|
||||
const placesPhotosEnabled = placesPhotosSetting !== 'false';
|
||||
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
||||
|
||||
return {
|
||||
@@ -258,6 +260,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
notification_channels: activeChannels,
|
||||
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true },
|
||||
trip_reminders_enabled: tripRemindersEnabled,
|
||||
places_photos_enabled: placesPhotosEnabled,
|
||||
permissions: authenticatedUser ? getAllPermissions() : undefined,
|
||||
dev_mode: process.env.NODE_ENV === 'development',
|
||||
};
|
||||
|
||||
@@ -2,6 +2,19 @@ import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { checkSsrf } from '../utils/ssrfGuard';
|
||||
|
||||
// ── Google API call counter ───────────────────────────────────────────────────
|
||||
|
||||
let googleApiCallCount = 0;
|
||||
|
||||
export function getGoogleApiCallCount(): number { return googleApiCallCount; }
|
||||
export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; }
|
||||
|
||||
function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> {
|
||||
googleApiCallCount++;
|
||||
console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`);
|
||||
return fetch(endpoint, init);
|
||||
}
|
||||
|
||||
// ── Interfaces ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface NominatimResult {
|
||||
@@ -55,26 +68,8 @@ interface GooglePlaceDetails extends GooglePlaceResult {
|
||||
|
||||
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
|
||||
|
||||
// ── Photo cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number; error?: boolean }>();
|
||||
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
||||
const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors
|
||||
const CACHE_MAX_ENTRIES = 1000;
|
||||
const CACHE_PRUNE_TARGET = 500;
|
||||
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of photoCache) {
|
||||
if (now - entry.fetchedAt > PHOTO_TTL) photoCache.delete(key);
|
||||
}
|
||||
if (photoCache.size > CACHE_MAX_ENTRIES) {
|
||||
const entries = [...photoCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
|
||||
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
|
||||
toDelete.forEach(([key]) => photoCache.delete(key));
|
||||
}
|
||||
}, CACHE_CLEANUP_INTERVAL);
|
||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||
import * as placePhotoCache from './placePhotoCache';
|
||||
|
||||
// ── API key retrieval ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -311,7 +306,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
return { places, source: 'openstreetmap' };
|
||||
}
|
||||
|
||||
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
|
||||
const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -371,7 +366,7 @@ export async function autocompletePlaces(
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch('https://places.googleapis.com/v1/places:autocomplete', {
|
||||
const response = await googleFetch('https://places.googleapis.com/v1/places:autocomplete', 'autocomplete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -451,12 +446,79 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
||||
}
|
||||
|
||||
// Google details
|
||||
const langKey = lang || 'de';
|
||||
const apiKey = getMapsKey(userId);
|
||||
if (!apiKey) {
|
||||
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
||||
}
|
||||
|
||||
const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang || 'de'}`, {
|
||||
// Check DB cache first (lean mask, expanded=0) — 7-day TTL
|
||||
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
|
||||
const cached = db.prepare(
|
||||
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0'
|
||||
).get(placeId, langKey) as { payload_json: string; fetched_at: number } | undefined;
|
||||
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) };
|
||||
|
||||
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetails(${placeId})`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
|
||||
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const place = {
|
||||
google_place_id: data.id,
|
||||
name: data.displayName?.text || '',
|
||||
address: data.formattedAddress || '',
|
||||
lat: data.location?.latitude || null,
|
||||
lng: data.location?.longitude || null,
|
||||
rating: data.rating || null,
|
||||
rating_count: data.userRatingCount || null,
|
||||
website: data.websiteUri || null,
|
||||
phone: data.nationalPhoneNumber || null,
|
||||
opening_hours: data.regularOpeningHours?.weekdayDescriptions || null,
|
||||
open_now: data.regularOpeningHours?.openNow ?? null,
|
||||
google_maps_url: data.googleMapsUri || null,
|
||||
summary: null,
|
||||
reviews: [],
|
||||
source: 'google' as const,
|
||||
cached_at: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)'
|
||||
).run(placeId, langKey, JSON.stringify(place), Date.now());
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to cache place details:', dbErr);
|
||||
}
|
||||
|
||||
return { place };
|
||||
}
|
||||
|
||||
export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record<string, unknown> }> {
|
||||
const langKey = lang || 'de';
|
||||
const apiKey = getMapsKey(userId);
|
||||
if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
||||
|
||||
// Check DB cache for expanded result
|
||||
if (!refresh) {
|
||||
const cached = db.prepare(
|
||||
'SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1'
|
||||
).get(placeId, langKey) as { payload_json: string } | undefined;
|
||||
if (cached) return { place: JSON.parse(cached.payload_json) };
|
||||
}
|
||||
|
||||
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetailsExpanded(${placeId})`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
@@ -494,12 +556,21 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
||||
photo: r.authorAttribution?.photoUri || null,
|
||||
})),
|
||||
source: 'google' as const,
|
||||
cached_at: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)'
|
||||
).run(placeId, langKey, JSON.stringify(place), Date.now());
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to cache expanded place details:', dbErr);
|
||||
}
|
||||
|
||||
return { place };
|
||||
}
|
||||
|
||||
// ── Place photo (Google or Wikimedia, with caching + DB persistence) ─────────
|
||||
// ── Place photo (Google or Wikimedia, disk-cached) ────────────────────────────
|
||||
|
||||
export async function getPlacePhoto(
|
||||
userId: number,
|
||||
@@ -508,84 +579,110 @@ export async function getPlacePhoto(
|
||||
lng: number,
|
||||
name?: string,
|
||||
): Promise<{ photoUrl: string; attribution: string | null }> {
|
||||
// Check cache first
|
||||
const cached = photoCache.get(placeId);
|
||||
if (cached) {
|
||||
const ttl = cached.error ? ERROR_TTL : PHOTO_TTL;
|
||||
if (Date.now() - cached.fetchedAt < ttl) {
|
||||
if (cached.error) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
|
||||
return { photoUrl: cached.photoUrl, attribution: cached.attribution };
|
||||
// Disk cache hit — serve immediately, no Google call
|
||||
const diskHit = placePhotoCache.get(placeId);
|
||||
if (diskHit) return { photoUrl: diskHit.photoUrl, attribution: diskHit.attribution };
|
||||
|
||||
// Recent error — don't hammer the API
|
||||
if (placePhotoCache.getErrored(placeId)) {
|
||||
throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
|
||||
}
|
||||
|
||||
// Deduplicate concurrent requests for the same placeId
|
||||
const existing = placePhotoCache.getInFlight(placeId);
|
||||
if (existing) {
|
||||
const result = await existing;
|
||||
if (!result) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
|
||||
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
|
||||
}
|
||||
|
||||
const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||
const apiKey = getMapsKey(userId);
|
||||
const isCoordLookup = placeId.startsWith('coords:');
|
||||
|
||||
// No Google key or coordinate-only lookup → try Wikimedia (URL-based, not byte-cached)
|
||||
if (!apiKey || isCoordLookup) {
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
try {
|
||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||
if (wiki) {
|
||||
// Wikimedia photos: fetch bytes and cache to disk
|
||||
const ssrf = await checkSsrf(wiki.photoUrl, true);
|
||||
if (!ssrf.allowed) throw Object.assign(new Error('Photo URL blocked'), { status: 403 });
|
||||
const imgRes = await fetch(wiki.photoUrl);
|
||||
if (imgRes.ok) {
|
||||
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
||||
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
||||
return { filePath: cached.filePath, attribution: cached.attribution };
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
photoCache.delete(placeId);
|
||||
}
|
||||
|
||||
const apiKey = getMapsKey(userId);
|
||||
const isCoordLookup = placeId.startsWith('coords:');
|
||||
// Google Photos — fetch details to get photo name
|
||||
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'photos',
|
||||
},
|
||||
});
|
||||
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
|
||||
|
||||
// No Google key or coordinate-only lookup -> try Wikimedia
|
||||
if (!apiKey || isCoordLookup) {
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
try {
|
||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||
if (wiki) {
|
||||
photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() });
|
||||
return wiki;
|
||||
} else {
|
||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
throw Object.assign(new Error('(Wikimedia) No photo available'), { status: 404 });
|
||||
}
|
||||
|
||||
// Google Photos
|
||||
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'photos',
|
||||
},
|
||||
});
|
||||
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
|
||||
if (!details.photos?.length) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
||||
throw Object.assign(new Error('(Google Places) Photo could not be retrieved'), { status: 404 });
|
||||
}
|
||||
const photo = details.photos[0];
|
||||
const photoName = photo.name;
|
||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||
|
||||
if (!details.photos?.length) {
|
||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
||||
throw Object.assign(new Error('(Google Places) No photo available'), { status: 404 });
|
||||
}
|
||||
// Fetch actual image bytes
|
||||
const mediaRes = await googleFetch(
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
|
||||
`getPlacePhoto/media(${placeId})`,
|
||||
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
||||
);
|
||||
|
||||
const photo = details.photos[0];
|
||||
const photoName = photo.name;
|
||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||
if (!mediaRes.ok) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaRes = await fetch(
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`,
|
||||
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
||||
);
|
||||
const mediaData = await mediaRes.json() as { photoUri?: string };
|
||||
const photoUrl = mediaData.photoUri;
|
||||
const bytes = Buffer.from(await mediaRes.arrayBuffer());
|
||||
if (!bytes.length) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!photoUrl) {
|
||||
photoCache.set(placeId, { photoUrl: '', attribution, fetchedAt: Date.now(), error: true });
|
||||
throw Object.assign(new Error('(Google Places) Photo URL not available'), { status: 404 });
|
||||
}
|
||||
const cached = await placePhotoCache.put(placeId, bytes, attribution);
|
||||
|
||||
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
|
||||
// Persist stable proxy URL to database
|
||||
try {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
|
||||
).run(cached.photoUrl, placeId);
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
}
|
||||
|
||||
// Persist photo URL to database
|
||||
try {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = ?)'
|
||||
).run(photoUrl, placeId, '');
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
}
|
||||
return { filePath: cached.filePath, attribution };
|
||||
})();
|
||||
|
||||
return { photoUrl, attribution };
|
||||
placePhotoCache.setInFlight(placeId, fetchPromise);
|
||||
|
||||
const result = await fetchPromise;
|
||||
if (!result) throw Object.assign(new Error('No photo available'), { status: 404 });
|
||||
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
|
||||
}
|
||||
|
||||
// ── Reverse geocoding ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '../db/database';
|
||||
|
||||
const GOOGLE_PHOTO_DIR = path.join(__dirname, '../../uploads/photos/google');
|
||||
const ERROR_TTL = 5 * 60 * 1000;
|
||||
|
||||
// In-flight dedup — prevents stampedes when multiple requests hit the same uncached placeId simultaneously
|
||||
const inFlight = new Map<string, Promise<{ filePath: string; attribution: string | null } | null>>();
|
||||
|
||||
function ensureDir(): void {
|
||||
if (!fs.existsSync(GOOGLE_PHOTO_DIR)) {
|
||||
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function filePath(placeId: string): string {
|
||||
// Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that
|
||||
// collapse identically under sanitization (e.g. ':' and '.' both → '_')
|
||||
const hash = crypto.createHash('sha1').update(placeId).digest('hex');
|
||||
return path.join(GOOGLE_PHOTO_DIR, `${hash}.jpg`);
|
||||
}
|
||||
|
||||
function proxyUrl(placeId: string): string {
|
||||
return `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`;
|
||||
}
|
||||
|
||||
interface CachedPhoto {
|
||||
photoUrl: string;
|
||||
filePath: string;
|
||||
attribution: string | null;
|
||||
}
|
||||
|
||||
export function get(placeId: string): CachedPhoto | null {
|
||||
const row = db.prepare(
|
||||
'SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL'
|
||||
).get(placeId) as { attribution: string | null } | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const fp = filePath(placeId);
|
||||
if (!fs.existsSync(fp)) {
|
||||
// File missing (e.g. volume wiped) — clear row so it refetches
|
||||
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution: row.attribution };
|
||||
}
|
||||
|
||||
export function getErrored(placeId: string): boolean {
|
||||
const row = db.prepare(
|
||||
'SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL'
|
||||
).get(placeId) as { error_at: number } | undefined;
|
||||
|
||||
if (!row) return false;
|
||||
return Date.now() - row.error_at < ERROR_TTL;
|
||||
}
|
||||
|
||||
export function markError(placeId: string): void {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)'
|
||||
).run(placeId, Date.now(), Date.now());
|
||||
}
|
||||
|
||||
export async function put(placeId: string, bytes: Buffer, attribution: string | null): Promise<CachedPhoto> {
|
||||
ensureDir();
|
||||
const fp = filePath(placeId);
|
||||
const tmp = fp + '.tmp';
|
||||
|
||||
await fsPromises.writeFile(tmp, bytes);
|
||||
await fsPromises.rename(tmp, fp);
|
||||
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)'
|
||||
).run(placeId, attribution, Date.now());
|
||||
|
||||
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution };
|
||||
}
|
||||
|
||||
export function getInFlight(placeId: string): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
|
||||
return inFlight.get(placeId);
|
||||
}
|
||||
|
||||
export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void {
|
||||
inFlight.set(placeId, promise);
|
||||
promise.finally(() => inFlight.delete(placeId));
|
||||
}
|
||||
|
||||
export function serveFilePath(placeId: string): string | null {
|
||||
const fp = filePath(placeId);
|
||||
return fs.existsSync(fp) ? fp : null;
|
||||
}
|
||||
Reference in New Issue
Block a user