mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
2aad8f465c
Migration 107 only rewrote image_url rows matching /places/%/photos/%; URLs using the /place-photos/ or /places/<opaque> paths survived the upgrade and were passed verbatim to the Places API, producing a malformed request whose empty/HTML response body threw SyntaxError before detailsRes.ok was checked. The resulting rejection was leaked by placePhotoCache.setInFlight via an unhandled .finally() chain, triggering Node 22's default unhandledRejection=throw and terminating the process. - placePhotoCache: add .catch() after .finally() to prevent unhandled rejection crash - mapsService: reject URL-shaped placeIds early; read response as text before JSON.parse - migrations: add migration to rewrite remaining googleusercontent/places.googleapis URLs - MapView/MapViewGL: prefer stable proxy URL form of image_url before google_place_id Fixes #770
111 lines
3.9 KiB
TypeScript
111 lines
3.9 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>>();
|
|
|
|
// In-memory set of placeIds whose file is confirmed on disk this session.
|
|
// Avoids a synchronous fs.existsSync() call on every cache hit after the first verification.
|
|
const knownOnDisk = new Set<string>();
|
|
|
|
// Ensure upload dir exists once at startup — avoids sync FS calls inside put() on every write.
|
|
try {
|
|
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
|
|
} catch { /* already exists */ }
|
|
|
|
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 (!knownOnDisk.has(placeId)) {
|
|
// First time this placeId is checked this session — verify the file exists on disk.
|
|
// (Guards against volume wipes or manual deletion between server restarts.)
|
|
if (!fs.existsSync(fp)) {
|
|
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
|
|
return null;
|
|
}
|
|
knownOnDisk.add(placeId);
|
|
}
|
|
|
|
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 {
|
|
knownOnDisk.delete(placeId);
|
|
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> {
|
|
const fp = filePath(placeId);
|
|
const tmp = fp + '.tmp';
|
|
|
|
await fsPromises.writeFile(tmp, bytes);
|
|
await fsPromises.rename(tmp, fp);
|
|
|
|
knownOnDisk.add(placeId);
|
|
|
|
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))
|
|
.catch(() => { /* awaiter logs; this .catch only prevents unhandledRejection */ });
|
|
}
|
|
|
|
export function serveFilePath(placeId: string): string | null {
|
|
if (knownOnDisk.has(placeId)) return filePath(placeId);
|
|
const fp = filePath(placeId);
|
|
if (!fs.existsSync(fp)) return null;
|
|
knownOnDisk.add(placeId);
|
|
return fp;
|
|
}
|