mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
9c2decb095
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
96 lines
3.3 KiB
TypeScript
96 lines
3.3 KiB
TypeScript
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;
|
|
}
|