fix(maps): bound place-photo cache growth (Wikimedia + Google) (#1174)

The place-photo cache (uploads/photos/google) grew unbounded: a Wikimedia
geosearch path cached full-res originals despite requesting a 400px thumb,
the writer applied no size guard, nothing reclaimed orphaned files, and
backups archived the whole re-derivable cache verbatim.

- Prefer the scaled `thumburl` over the full-res `info.url` in the Commons
  geosearch fallback.
- Downscale any cached image to <=800px JPEG via the existing jimp dep,
  with a safe fallback to the original bytes on decode failure.
- Add sweepOrphans() (orphaned meta rows + stray files) wired into the
  scheduler (startup + nightly), and removeIfUnreferenced() called on
  place delete for prompt reclamation.
- Exclude the re-derivable photo/trek caches from backups; restores
  self-heal as the cache dirs are recreated at startup.
This commit is contained in:
jubnl
2026-06-14 23:31:02 +02:00
committed by GitHub
parent 3e9626fce9
commit 8077ffab34
10 changed files with 349 additions and 13 deletions
+25 -3
View File
@@ -14,6 +14,20 @@ import {
type KmlImportSummary,
} from './kmlImport';
import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment';
import * as placePhotoCache from './placePhotoCache';
// Reclaim a deleted place's cached marker photo if nothing else references it.
// The cache key is the Google place_id, or — for coordinate-only places — the
// pseudo-id embedded in the stored proxy URL (/api/maps/place-photo/{id}/bytes).
function reclaimPhotoCache(googlePlaceId: string | null, imageUrl: string | null): void {
const candidates = new Set<string>();
if (googlePlaceId) candidates.add(googlePlaceId);
const m = imageUrl?.match(/^\/api\/maps\/place-photo\/(.+)\/bytes$/);
if (m) { try { candidates.add(decodeURIComponent(m[1])); } catch { /* malformed url */ } }
for (const id of candidates) {
try { placePhotoCache.removeIfUnreferenced(id); } catch { /* best-effort */ }
}
}
/** Opt-in Places-API enrichment for list imports (#886). */
export interface ListImportOptions {
@@ -242,25 +256,33 @@ export function updatePlace(
// ---------------------------------------------------------------------------
export function deletePlace(tripId: string, placeId: string): boolean {
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
const place = db.prepare(
'SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?'
).get(placeId, tripId) as { google_place_id: string | null; image_url: string | null } | undefined;
if (!place) return false;
db.prepare('DELETE FROM places WHERE id = ?').run(placeId);
reclaimPhotoCache(place.google_place_id, place.image_url);
return true;
}
export function deletePlacesMany(tripId: string, ids: number[]): number[] {
if (ids.length === 0) return [];
const selectStmt = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?');
const selectStmt = db.prepare('SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?');
const deleteStmt = db.prepare('DELETE FROM places WHERE id = ?');
const deleted: number[] = [];
const reclaimable: { google_place_id: string | null; image_url: string | null }[] = [];
const run = db.transaction((list: number[]) => {
for (const id of list) {
if (!selectStmt.get(id, tripId)) continue;
const row = selectStmt.get(id, tripId) as { google_place_id: string | null; image_url: string | null } | undefined;
if (!row) continue;
deleteStmt.run(id);
deleted.push(id);
reclaimable.push(row);
}
});
run(ids);
// Reclaim after the transaction commits so isReferenced() sees the final place set.
for (const row of reclaimable) reclaimPhotoCache(row.google_place_id, row.image_url);
return deleted;
}