diff --git a/.dockerignore b/.dockerignore index 1f1212f..3ca084d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ +.env.r2.local .git .gitignore .env @@ -39,3 +40,5 @@ backend/data/fixtures/* backend/data/gazetteers/* backend/data/opengeometadata/* backend/data/harvest_dumps/* + +.kamal/ diff --git a/.gitignore b/.gitignore index f44a537..1aad240 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.env.r2.local # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/backend/app/api/v1/endpoint_modules/static_maps.py b/backend/app/api/v1/endpoint_modules/static_maps.py index ca5e6da..a44820c 100644 --- a/backend/app/api/v1/endpoint_modules/static_maps.py +++ b/backend/app/api/v1/endpoint_modules/static_maps.py @@ -1,8 +1,10 @@ +import asyncio import io import logging import os +from typing import Literal -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import Response from PIL import Image from sqlalchemy.sql import select @@ -15,6 +17,7 @@ weak_etag_from_body, ) from app.services.static_map_service import StaticMapService +from app.services.visual_asset_cache import get_visual_asset_public_url from db.models import resources logger = logging.getLogger(__name__) @@ -203,15 +206,29 @@ async def _get_or_generate_map_data( raise HTTPException(status_code=400, detail="Unsupported static map variant") +@router.get("/static-maps/institutions/{map_id}/native", response_class=Response) @router.get("/static-maps/institutions/{map_id}", response_class=Response) async def get_institution_static_map( map_id: str, request: Request, - lat: float, - lon: float, - zoom: int = 15, + lat: float = Query(..., ge=-85, le=85), + lon: float = Query(..., ge=-180, le=180), + zoom: int = Query(15, ge=1, le=20), + renderer: str | None = None, ): """Serve or generate a cached campus-centered static basemap image.""" + from app.services.native_static_maps import renderer_url + + if request.url.path.endswith("/native"): + renderer = "maplibre" + if renderer not in (None, "maplibre"): + raise HTTPException(status_code=422, detail="Unknown renderer") + if renderer == "maplibre" and not renderer_url(): + raise HTTPException( + status_code=503, + detail="Native renderer is not configured", + headers={"Cache-Control": "no-store", "Retry-After": "30"}, + ) cache_id = f"institution:{map_id}" try: map_service = StaticMapService(map_width=640, map_height=320) @@ -220,10 +237,50 @@ async def get_institution_static_map( longitude=lon, zoom=zoom, ) + digest = await map_service.get_asset_hash( + cache_id, + variant=map_service.basemap_variant(), + source_signature=source_signature, + hydrate_asset=False, + ) + if digest: + url = await asyncio.to_thread(get_visual_asset_public_url, digest) + if url: + return Response( + status_code=302, + headers={"Location": url, "Cache-Control": "public, max-age=300"}, + ) + durable_data = await map_service.get_cached_asset(digest) + if durable_data: + return _validated_static_asset_response( + durable_data, + request, + cache_control="public, max-age=3600, stale-while-revalidate=86400", + ) map_data = await map_service.get_cached_basemap( cache_id, source_signature=source_signature, ) + if not map_data and renderer_url(): + from app.tasks.worker import celery_app + + lock_key = f"native-campus-pending:{map_id}:{source_signature}" + acquired = await asyncio.to_thread( + map_service.map_cache.set, lock_key, "pending", nx=True, ex=300 + ) + if acquired: + try: + await asyncio.to_thread( + celery_app.send_task, + "generate_campus_static_map", + args=[map_id, lat, lon, zoom, lock_key], + ) + except Exception: + await asyncio.to_thread(map_service.map_cache.delete, lock_key) + raise + return Response( + status_code=503, headers={"Cache-Control": "no-store", "Retry-After": "2"} + ) if not map_data: map_data = map_service.generate_centered_basemap( cache_id, @@ -239,12 +296,19 @@ async def get_institution_static_map( if not map_data: raise HTTPException(status_code=404, detail="Institution static map not found") - return _validated_static_asset_response(map_data, request) + return _validated_static_asset_response( + map_data, request, cache_control="public, max-age=3600, stale-while-revalidate=86400" + ) @router.get("/static-map-assets/{map_hash}", response_class=Response) async def get_static_map_asset(map_hash: str, request: Request): - """Serve a hash-addressed immutable static map or icon asset directly from Redis.""" + """Serve a durable static map, preferring its verified CDN object.""" + url = await asyncio.to_thread(get_visual_asset_public_url, map_hash) + if url: + return Response( + status_code=302, headers={"Location": url, "Cache-Control": "public, max-age=300"} + ) map_service = StaticMapService() map_data = await map_service.get_cached_asset(map_hash) if not map_data: @@ -401,3 +465,62 @@ async def get_static_map(resource_id: str, request: Request): raise HTTPException(status_code=500, detail="Failed to retrieve static map") from e return _validated_static_asset_response(map_data, request) + + +@router.get("/static-maps/{resource_id}/geometry/native", response_class=Response) +async def get_native_resource_geometry( + resource_id: str, request: Request, size: Literal["full", "thumbnail"] = "full" +): + """Versioned native geography; never reuse legacy CARTO hashes or aliases.""" + from app.services.native_static_maps import renderer_url + from app.tasks.worker import celery_app + + if not renderer_url(): + raise HTTPException( + status_code=503, + detail="Native renderer is not configured", + headers={"Cache-Control": "no-store", "Retry-After": "30"}, + ) + resource = await _fetch_resource_dict(resource_id) + if not resource: + raise HTTPException(status_code=404, detail="Resource not found") + service = StaticMapService(384, 288) if size == "thumbnail" else StaticMapService() + geometry = _resource_geometry(resource) + signature = service.geometry_signature(geometry) + digest = await service.get_asset_hash( + resource_id, + variant=service.geometry_variant(), + source_signature=signature, + hydrate_asset=False, + ) + if digest: + url = await asyncio.to_thread(get_visual_asset_public_url, digest) + if url: + return Response( + status_code=302, headers={"Location": url, "Cache-Control": "public, max-age=300"} + ) + data = await service.get_cached_asset(digest) + if data: + return _validated_static_asset_response( + data, request, cache_control="public, max-age=3600, stale-while-revalidate=86400" + ) + from app.services.native_static_maps import reuse_geometry + + data = await asyncio.to_thread(reuse_geometry, service, resource_id, geometry) + if data: + return _validated_static_asset_response( + data, request, cache_control="public, max-age=3600, stale-while-revalidate=86400" + ) + lock_key = f"native-resource-pending:{resource_id}:{signature}" + acquired = await asyncio.to_thread(service.map_cache.set, lock_key, "pending", nx=True, ex=600) + if acquired: + try: + await asyncio.to_thread( + celery_app.send_task, + "generate_native_resource_map", + args=[resource_id, geometry, lock_key, service.map_width, service.map_height], + ) + except Exception: + await asyncio.to_thread(service.map_cache.delete, lock_key) + raise + return Response(status_code=503, headers={"Cache-Control": "no-store", "Retry-After": "2"}) diff --git a/backend/app/api/v1/endpoint_modules/thumbnails.py b/backend/app/api/v1/endpoint_modules/thumbnails.py index e0acc43..51699d3 100644 --- a/backend/app/api/v1/endpoint_modules/thumbnails.py +++ b/backend/app/api/v1/endpoint_modules/thumbnails.py @@ -1,3 +1,4 @@ +import asyncio import io import logging import os @@ -15,6 +16,7 @@ ) from app.services.image_service import ImageService from app.services.thumbnail_alias_service import is_thumbnail_hash +from app.services.visual_asset_cache import get_visual_asset_public_url logger = logging.getLogger(__name__) @@ -155,6 +157,14 @@ async def get_thumbnail(resource_id: str, request: Request): if redirect is not None: return redirect + if is_thumbnail_hash(resource_id): + url = await asyncio.to_thread(get_visual_asset_public_url, resource_id) + if url: + # Legacy thumbnail hashes describe sources, so keep the mapping redirect short-lived. + return Response( + status_code=302, headers={"Location": url, "Cache-Control": "public, max-age=300"} + ) + try: # Create service without resource (we only need cache access) image_service = ImageService({}) diff --git a/backend/app/services/image_service.py b/backend/app/services/image_service.py index 2ac40f5..d8111e1 100644 --- a/backend/app/services/image_service.py +++ b/backend/app/services/image_service.py @@ -28,7 +28,11 @@ safe_record_thumbnail_state_sync, thumbnail_state_service, ) -from app.services.visual_asset_cache import cache_visual_asset, get_durable_visual_asset +from app.services.visual_asset_cache import ( + cache_visual_asset, + get_durable_visual_asset, + has_durable_visual_asset, +) # Load environment variables from .env file try: @@ -793,7 +797,7 @@ def has_cached_image_sync(self, image_hash: str) -> bool: return True except Exception as e: self.logger.error(f"Error checking cached image: {e}") - return get_durable_visual_asset(image_hash) is not None + return has_durable_visual_asset(image_hash) def _candidate_cached_thumbnail_hash_sync(self, source_url: str) -> Optional[str]: """Return the immutable thumbnail hash for a cached source URL, if known.""" @@ -1142,14 +1146,15 @@ async def get_cached_image(self, image_hash: str) -> Optional[bytes]: durable = await asyncio.to_thread(get_durable_visual_asset, image_hash) if durable: image_bytes, content_type = durable - await asyncio.to_thread(cache_visual_asset, self.image_cache, image_key, image_bytes) - await asyncio.to_thread( - cache_visual_asset, - self.image_cache, - f"image_type:{image_hash}", - content_type, - ) - self.logger.debug(f"Rehydrated cached image {image_hash} from durable store") + try: + await asyncio.to_thread( + cache_visual_asset, self.image_cache, image_key, image_bytes + ) + await asyncio.to_thread( + cache_visual_asset, self.image_cache, f"image_type:{image_hash}", content_type + ) + except Exception as exc: + self.logger.warning("Optional Redis image hydration failed: %s", type(exc).__name__) return image_bytes return None @@ -1162,7 +1167,7 @@ async def has_cached_image(self, image_hash: str) -> bool: return True except Exception as e: self.logger.error(f"Error checking cached image: {e}") - return await asyncio.to_thread(get_durable_visual_asset, image_hash) is not None + return await asyncio.to_thread(has_durable_visual_asset, image_hash) def is_pmtiles_skip_cached(self, image_hash: str) -> bool: """ diff --git a/backend/app/services/native_static_maps.py b/backend/app/services/native_static_maps.py new file mode 100644 index 0000000..20e1218 --- /dev/null +++ b/backend/app/services/native_static_maps.py @@ -0,0 +1,175 @@ +"""Optional private MapLibre renderer; storage remains in StaticMapService.""" + +import hashlib +import io +import json +import os +import uuid + +import requests +from PIL import Image + +from app.services.visual_asset_cache import ( + cache_visual_asset, + get_durable_visual_asset, + get_durable_visual_asset_hash_for_resource, + get_durable_visual_asset_hash_for_signature, +) + + +def renderer_url(): + return os.getenv("STATIC_MAP_RENDERER_URL", "").rstrip("/") + + +def revision(): + # Bump when styles, tiles, fonts, or renderer settings change. + return os.getenv("STATIC_MAP_RENDER_REVISION", "openfreemap-liberty-native-v1") + + +def signature(value): + return hashlib.sha256(json.dumps([revision(), value], sort_keys=True).encode()).hexdigest() + + +def _generate_uncached( + service, + resource_id, + geometry=None, + *, + overlay=True, + center=None, + zoom=None, + source_signature=None, + hydrate_asset=True, +): + spec = {"width": service.map_width, "height": service.map_height} + no_geometry = False + if center is not None: + spec.update(center=center, leafletZoom=zoom) + else: + bounds = service._parse_geometry_to_bbox(geometry) + no_geometry = not bounds or service._is_global_bbox(bounds) + if no_geometry: + spec.update(center=[0, 0], leafletZoom=2) + elif bounds[0] == bounds[2] and bounds[1] == bounds[3]: + spec.update(center=[bounds[0], bounds[1]], leafletZoom=12) + else: + bounds = service._renderable_bbox(bounds) + if not bounds: + raise ValueError("Geometry has no renderable bounds") + spec["bounds"] = list(bounds) + if overlay and not no_geometry: + geojson = service._geometry_to_geojson_dict(geometry) + if geojson is None: + w, s, e, n = bounds + geojson = { + "type": "Polygon", + "coordinates": [[[w, s], [e, s], [e, n], [w, n], [w, s]]], + } + spec["geometry"] = geojson + response = requests.post(renderer_url() + "/render", json=spec, timeout=(5, 75)) + response.raise_for_status() + data = response.content + if not data.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError("Renderer did not return PNG") + with Image.open(io.BytesIO(data)) as image: + if image.size != (service.map_width, service.map_height): + raise ValueError("Renderer returned incorrect dimensions") + image.verify() + if overlay and no_geometry: + image = service._overlay_no_data_symbol(Image.open(io.BytesIO(data))) + output = io.BytesIO() + image.save(output, "PNG") + data = output.getvalue() + variant = service.geometry_variant() if overlay else service.basemap_variant() + source_signature = source_signature or service.geometry_signature(geometry) + digest = service.materialize_asset_sync( + resource_id, + variant=variant, + map_bytes=data, + source_signature=source_signature, + hydrate_asset=hydrate_asset, + ) + if not digest or get_durable_visual_asset(digest) != (data, "image/png"): + raise RuntimeError("Could not persist rendered asset") + if not resource_id.startswith("institution:") and ( + get_durable_visual_asset_hash_for_resource( + resource_id, asset_kind=variant, source_signature=source_signature + ) + != digest + ): + raise RuntimeError("Could not link rendered asset") + if hydrate_asset and not cache_visual_asset( + service.map_cache, + service._cache_key(resource_id, variant=variant, source_signature=source_signature), + data, + ): + raise RuntimeError("Could not cache rendered asset") + return data + + +def reuse_geometry( + service, resource_id, geometry, *, overlay=True, source_signature=None, hydrate_asset=True +): + variant = service.geometry_variant() if overlay else service.basemap_variant() + source_signature = source_signature or service.geometry_signature(geometry) + digest = get_durable_visual_asset_hash_for_signature(variant, source_signature) + if not digest: + return None + data = service.get_cached_asset_sync(digest) + if not data: + return None + linked = service.materialize_asset_sync( + resource_id, + variant=variant, + map_bytes=data, + source_signature=source_signature, + hydrate_asset=hydrate_asset, + ) + if ( + linked != digest + or get_durable_visual_asset_hash_for_resource( + resource_id, asset_kind=variant, source_signature=source_signature + ) + != digest + ): + raise RuntimeError("Could not link reusable map") + return data + + +def generate(service, resource_id, geometry=None, **kwargs): + # Campus cameras use a separate identity and do not have resource foreign keys. + if resource_id.startswith("institution:") or kwargs.get("center") is not None: + return _generate_uncached(service, resource_id, geometry, **kwargs) + reuse_args = { + key: kwargs[key] + for key in ("overlay", "source_signature", "hydrate_asset") + if key in kwargs + } + data = reuse_geometry(service, resource_id, geometry, **reuse_args) + if data: + return data + sig = kwargs.get("source_signature") or service.geometry_signature(geometry) + kind = service.geometry_variant() if kwargs.get("overlay", True) else service.basemap_variant() + lock = f"native-render:{kind}:{sig}" + token = uuid.uuid4().hex + if not service.map_cache.set(lock, token, nx=True, ex=120): + raise RuntimeError("Identical geometry is already rendering; retry") + try: + # Another worker may have finished between the first lookup and locking. + data = reuse_geometry(service, resource_id, geometry, **reuse_args) + return data or _generate_uncached(service, resource_id, geometry, **kwargs) + finally: + service.map_cache.eval( + "if redis.call('get',KEYS[1]) == ARGV[1] then " + "return redis.call('del',KEYS[1]) else return 0 end", + 1, + lock, + token, + ) + + +def pregenerate_thumbnail(resource_id, geometry, *, hydrate_asset=True): + from app.services.static_map_service import StaticMapService + + service = StaticMapService(384, 288) + return generate(service, resource_id, geometry, hydrate_asset=hydrate_asset) diff --git a/backend/app/services/static_map_service.py b/backend/app/services/static_map_service.py index 043ff5e..e875fa1 100644 --- a/backend/app/services/static_map_service.py +++ b/backend/app/services/static_map_service.py @@ -23,10 +23,13 @@ from shapely import wkt as shapely_wkt from shapely.geometry import shape +from app.services import native_static_maps from app.services.visual_asset_cache import ( cache_visual_asset, + durable_visual_asset_enabled, get_durable_visual_asset, get_durable_visual_asset_hash_for_resource, + has_durable_visual_asset, redis_operation_with_loading_retry, store_durable_visual_asset, store_durable_visual_asset_link, @@ -83,6 +86,9 @@ def __init__(self, map_width: int = 800, map_height: int = 600): map_width: Width of generated maps in pixels (default: 800) map_height: Height of generated maps in pixels (default: 600) """ + if native_static_maps.renderer_url(): + self._MAP_VARIANT = "native_geometry_v1" + self._BASEMAP_VARIANT = "native_basemap_v1" self.map_width = map_width self.map_height = map_height @@ -491,7 +497,14 @@ def geometry_signature(self, geometry: Any) -> str: separators=(",", ":"), default=str, ) - return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + return ( + native_static_maps.signature( + [normalized, self.map_width, self.map_height] + + (["compact-credits-v1"] if self.map_width < 500 else []) + ) + if native_static_maps.renderer_url() + else hashlib.sha256(normalized.encode("utf-8")).hexdigest() + ) def centered_basemap_signature(self, *, latitude: float, longitude: float, zoom: int) -> str: """Return a stable content signature for a campus-centered basemap request.""" @@ -504,7 +517,11 @@ def centered_basemap_signature(self, *, latitude: float, longitude: float, zoom: sort_keys=True, separators=(",", ":"), ) - return hashlib.sha256(payload.encode("utf-8")).hexdigest() + return ( + native_static_maps.signature([payload, self.map_width, self.map_height]) + if native_static_maps.renderer_url() + else hashlib.sha256(payload.encode("utf-8")).hexdigest() + ) def external_static_map_url( self, @@ -585,6 +602,12 @@ def _clean_external_static_map_url(self, url: Optional[str]) -> Optional[str]: return cleaned return None + @staticmethod + def campus_asset_hash(resource_id: str, variant: str, source_signature: str | None) -> str: + """Stable campus camera identity without a resource-table foreign key.""" + value = json.dumps([resource_id, variant, source_signature], separators=(",", ":")) + return hashlib.sha256(value.encode()).hexdigest() + def get_asset_hash_sync( self, resource_id: str, @@ -601,6 +624,10 @@ def get_asset_hash_sync( if hot_hash: return hot_hash + if resource_id.startswith("institution:"): + identity = self.campus_asset_hash(resource_id, variant, source_signature) + return identity if has_durable_visual_asset(identity) else None + value = get_durable_visual_asset_hash_for_resource( resource_id, asset_kind=variant, @@ -769,20 +796,19 @@ async def delete_asset_hash( ) def has_cached_asset_sync(self, map_hash: str) -> bool: - if not self.map_cache or not self._is_asset_hash(map_hash): + if not self._is_asset_hash(map_hash): return False - - try: - asset_key = self._asset_key(map_hash) - return bool( - redis_operation_with_loading_retry( + if self.map_cache: + try: + asset_key = self._asset_key(map_hash) + if redis_operation_with_loading_retry( lambda: self.map_cache.exists(asset_key), operation_name=f"check static map asset {asset_key}", - ) - ) - except Exception as e: - logger.error(f"Error checking cached static map asset {map_hash}: {e}") - return False + ): + return True + except Exception as exc: + logger.warning("Redis asset check failed: %s", type(exc).__name__) + return has_durable_visual_asset(map_hash) async def has_cached_asset(self, map_hash: str) -> bool: return await asyncio.to_thread(self.has_cached_asset_sync, map_hash) @@ -835,28 +861,39 @@ def materialize_asset_sync( if not map_bytes: return None - map_hash = self._asset_hash(map_bytes) - if hydrate_asset: - self.cache_asset_sync(map_hash, map_bytes) - store_durable_visual_asset( + is_campus = resource_id.startswith("institution:") + map_hash = ( + self.campus_asset_hash(resource_id, variant, source_signature) + if is_campus + else self._asset_hash(map_bytes) + ) + stored = store_durable_visual_asset( map_hash, asset_kind=variant, content_type=self._asset_content_type(map_bytes), body=map_bytes, ) - store_durable_visual_asset_link( - resource_id, - asset_hash=map_hash, - asset_kind=variant, - source_signature=source_signature, - ) - if source_signature: - store_durable_visual_asset_link( + if durable_visual_asset_enabled() and not stored: + return None + if stored and not is_campus: + if not store_durable_visual_asset_link( + resource_id, + asset_hash=map_hash, + asset_kind=variant, + source_signature=source_signature, + ): + return None + if source_signature and not store_durable_visual_asset_link( resource_id, asset_hash=map_hash, asset_kind=variant, source_signature=None, - ) + ): + return None + if hydrate_asset: + cached = self.cache_asset_sync(map_hash, map_bytes) + if not stored and not cached: + return None self.set_asset_hash_sync( resource_id, variant=variant, @@ -1279,6 +1316,16 @@ def generate_map( Returns: PNG image bytes if successful, None if generation failed """ + if native_static_maps.renderer_url(): + return native_static_maps.generate( + self, + resource_id, + geometry, + overlay=True, + source_signature=source_signature, + hydrate_asset=hydrate_asset, + ) + try: # Parse bbox for skip-global check and for fallback rectangle bbox_coords = self._parse_geometry_to_bbox(geometry) @@ -1383,6 +1430,16 @@ def generate_global_map( Generate a world-view static map (no bbox) for resources with no geometry. Uses the same tile layer and cache key as generate_map. """ + if native_static_maps.renderer_url(): + return native_static_maps.generate( + self, + resource_id, + None, + overlay=True, + source_signature=source_signature, + hydrate_asset=hydrate_asset, + ) + try: context = self._global_map_context() return self._render_and_cache( @@ -1413,6 +1470,16 @@ def generate_basemap( """ Generate a basemap-only image using the resource extent with no visible geometry overlay. """ + if native_static_maps.renderer_url(): + return native_static_maps.generate( + self, + resource_id, + geometry, + overlay=False, + source_signature=source_signature, + hydrate_asset=hydrate_asset, + ) + try: bbox_coords = self._parse_geometry_to_bbox(geometry) if not bbox_coords: @@ -1496,6 +1563,16 @@ def generate_global_basemap( hydrate_asset: bool = True, ) -> Optional[bytes]: """Generate a world-view basemap image with no geometry overlay.""" + if native_static_maps.renderer_url(): + return native_static_maps.generate( + self, + resource_id, + None, + overlay=False, + source_signature=source_signature, + hydrate_asset=hydrate_asset, + ) + try: context = self._global_map_context() return self._render_and_cache( @@ -1524,6 +1601,19 @@ def generate_centered_basemap( source_signature: str | None = None, ) -> Optional[bytes]: """Generate a basemap centered on a specific lat/lon at a fixed zoom.""" + if native_static_maps.renderer_url(): + return native_static_maps.generate( + self, + resource_id, + overlay=False, + center=[longitude, latitude], + zoom=zoom, + source_signature=source_signature + or self.centered_basemap_signature( + latitude=latitude, longitude=longitude, zoom=zoom + ), + ) + try: if not (-90 <= latitude <= 90) or not (-180 <= longitude <= 180): logger.warning( diff --git a/backend/app/services/visual_asset_cache.py b/backend/app/services/visual_asset_cache.py index ec54a70..7782717 100644 --- a/backend/app/services/visual_asset_cache.py +++ b/backend/app/services/visual_asset_cache.py @@ -3,9 +3,15 @@ import time from typing import Any, Callable, TypeVar -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.dialects.postgresql import insert as pg_insert +from app.services.visual_asset_object_store import ( + public_visual_asset_url, + read_visual_asset, + s3_visual_assets_enabled, + upload_visual_asset, +) from db.models import generated_visual_asset_links, generated_visual_assets from db.session import sync_engine as app_sync_engine @@ -14,13 +20,14 @@ def visual_asset_ttl_seconds() -> int: - """TTL for generated thumbnail/static-map bytes. + """Bound hot-cache retention in object-storage mode; preserve legacy defaults.""" + default = "86400" if s3_visual_assets_enabled() else "0" + return int(os.getenv("VISUAL_ASSET_CACHE_TTL_SECONDS", default)) - A value <= 0 stores visual assets without Redis expiry. These assets are - content-addressed or source-signature addressed, so routine TTL eviction is - counterproductive once the priming tasks have made them hot. - """ - return int(os.getenv("VISUAL_ASSET_CACHE_TTL_SECONDS", "0")) + +def redis_visual_body_cache_enabled() -> bool: + default = "false" if s3_visual_assets_enabled() else "true" + return os.getenv("VISUAL_ASSET_REDIS_BODY_CACHE_ENABLED", default).lower() == "true" def _env_float(name: str, default: float) -> float: @@ -85,6 +92,10 @@ def redis_operation_with_loading_retry( def cache_visual_asset(cache: Any, key: str, value: bytes | str) -> bool: + # A disabled optional binary cache is a successful no-op, including renderer callers. + # Alias strings still receive a finite TTL in S3 mode. + if isinstance(value, bytes) and not redis_visual_body_cache_enabled(): + return True ttl = visual_asset_ttl_seconds() if ttl > 0: return bool( @@ -102,7 +113,7 @@ def cache_visual_asset(cache: Any, key: str, value: bytes | str) -> bool: def durable_visual_asset_enabled() -> bool: - return os.getenv("VISUAL_ASSET_DURABLE_STORE", "database").lower() == "database" + return os.getenv("VISUAL_ASSET_DURABLE_STORE", "database").lower() in {"database", "s3", "r2"} def _sync_engine(): @@ -126,6 +137,21 @@ def store_durable_visual_asset( "body": body, "byte_size": len(body), } + if s3_visual_assets_enabled(): + try: + key, digest = upload_visual_asset(body, content_type) + except Exception as exc: + logger.warning( + "Visual asset upload failed for %s: %s", asset_hash[:12], type(exc).__name__ + ) + return False + values.update(object_key=key, object_sha256=digest) + # Dual-write is the default for a reversible pilot. Disabling it affects NEW writes only. + if os.getenv("VISUAL_ASSET_S3_RETAIN_DATABASE_BODY", "true").lower() != "true": + values["body"] = None + else: + # Database-only writes must never retain a stale pointer to previous bytes. + values.update(object_key=None, object_sha256=None) stmt = pg_insert(generated_visual_assets).values(**values) stmt = stmt.on_conflict_do_update( index_elements=[generated_visual_assets.c.asset_hash], @@ -134,6 +160,8 @@ def store_durable_visual_asset( "content_type": stmt.excluded.content_type, "body": stmt.excluded.body, "byte_size": stmt.excluded.byte_size, + "object_key": stmt.excluded.object_key, + "object_sha256": stmt.excluded.object_sha256, }, ) try: @@ -211,20 +239,91 @@ def get_durable_visual_asset_hash_for_resource( return str(row._mapping["asset_hash"]) -def get_durable_visual_asset(asset_hash: str) -> tuple[bytes, str] | None: +def _visual_asset_metadata(asset_hash: str): if not durable_visual_asset_enabled() or not asset_hash: return None - stmt = select( - generated_visual_assets.c.body, generated_visual_assets.c.content_type, + generated_visual_assets.c.byte_size, + generated_visual_assets.c.object_key, + generated_visual_assets.c.object_sha256, + generated_visual_assets.c.body.is_not(None).label("has_body"), ).where(generated_visual_assets.c.asset_hash == asset_hash) + with _sync_engine().connect() as conn: + return conn.execute(stmt).mappings().first() + + +def get_visual_asset_public_url(asset_hash: str) -> str | None: + """Only expose a CDN URL after the upload and database pointer have committed.""" + if not s3_visual_assets_enabled(): + return None + try: + row = _visual_asset_metadata(asset_hash) + if row and row["object_key"] and row["object_sha256"]: + return public_visual_asset_url(row["object_key"]) + except Exception as exc: + logger.warning("Unable to resolve visual asset URL: %s", type(exc).__name__) + return None + + +def has_durable_visual_asset(asset_hash: str) -> bool: + """Check committed metadata without downloading an image on every alias lookup.""" + if not durable_visual_asset_enabled() or not asset_hash: + return False + stmt = select(generated_visual_assets.c.asset_hash).where( + generated_visual_assets.c.asset_hash == asset_hash, + or_( + generated_visual_assets.c.body.is_not(None), + generated_visual_assets.c.object_key.is_not(None) + & generated_visual_assets.c.object_sha256.is_not(None), + ), + ) try: with _sync_engine().connect() as conn: - row = conn.execute(stmt).fetchone() + return conn.execute(stmt).first() is not None except Exception as exc: - logger.warning("Failed to load durable visual asset %s: %s", asset_hash[:12], exc) + logger.warning("Unable to check durable visual asset: %s", type(exc).__name__) + return False + + +def get_durable_visual_asset(asset_hash: str) -> tuple[bytes, str] | None: + if not durable_visual_asset_enabled() or not asset_hash: return None - if not row: + try: + row = _visual_asset_metadata(asset_hash) + if not row: + return None + if row["object_key"] and row["object_sha256"]: + try: + body = read_visual_asset(row["object_key"], row["object_sha256"], row["byte_size"]) + return body, str(row["content_type"]) + except Exception as exc: + logger.warning("Object read failed; trying database body: %s", type(exc).__name__) + stmt = select(generated_visual_assets.c.body, generated_visual_assets.c.content_type).where( + generated_visual_assets.c.asset_hash == asset_hash + ) + with _sync_engine().connect() as conn: + local = conn.execute(stmt).mappings().first() + if local and local["body"]: + return bytes(local["body"]), str(local["content_type"]) + except Exception as exc: + logger.warning("Failed to load visual asset %s: %s", asset_hash[:12], type(exc).__name__) + return None + + +def get_durable_visual_asset_hash_for_signature( + asset_kind: str, source_signature: str +) -> str | None: + """Find reusable render bytes without tying identity to a catalog record.""" + if not durable_visual_asset_enabled() or not source_signature: return None - return (bytes(row._mapping["body"]), str(row._mapping["content_type"])) + stmt = ( + select(generated_visual_asset_links.c.asset_hash) + .where( + generated_visual_asset_links.c.asset_kind == asset_kind, + generated_visual_asset_links.c.source_signature == source_signature, + ) + .limit(1) + ) + with _sync_engine().connect() as conn: + return conn.execute(stmt).scalar_one_or_none() diff --git a/backend/app/services/visual_asset_object_store.py b/backend/app/services/visual_asset_object_store.py new file mode 100644 index 0000000..c16212a --- /dev/null +++ b/backend/app/services/visual_asset_object_store.py @@ -0,0 +1,124 @@ +"""S3-compatible storage for immutable generated images (including Cloudflare R2).""" + +import base64 +import hashlib +import os +from functools import lru_cache +from urllib.parse import quote, urlsplit + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + + +def s3_visual_assets_enabled() -> bool: + return os.getenv("VISUAL_ASSET_DURABLE_STORE", "database").lower() in {"s3", "r2"} + + +def _required(name: str) -> str: + value = os.getenv(name, "").strip() + if not value: + raise ValueError(f"{name} must be configured for visual object storage") + return value + + +@lru_cache(maxsize=1) +def _client(process_id: int): + # Celery forks: create a separate connection pool in each worker process. + endpoint = _required("VISUAL_ASSET_S3_ENDPOINT") + if urlsplit(endpoint).scheme != "https": + raise ValueError("Visual object storage requires an HTTPS endpoint") + return boto3.session.Session().client( + "s3", + endpoint_url=endpoint, + region_name=os.getenv("VISUAL_ASSET_S3_REGION", "auto"), + aws_access_key_id=_required("VISUAL_ASSET_S3_ACCESS_KEY_ID"), + aws_secret_access_key=_required("VISUAL_ASSET_S3_SECRET_ACCESS_KEY"), + config=Config( + signature_version="s3v4", + connect_timeout=5, + read_timeout=20, + max_pool_connections=128, + retries={"total_max_attempts": 3, "mode": "standard"}, + s3={"addressing_style": "path"}, + request_checksum_calculation="when_required", + response_checksum_validation="when_required", + ), + ) + + +def client(): + return _client(os.getpid()) + + +def bucket() -> str: + return _required("VISUAL_ASSET_S3_BUCKET") + + +def object_key_for_body(body: bytes) -> tuple[str, str]: + digest = hashlib.sha256(body).hexdigest() + prefix = os.getenv("VISUAL_ASSET_S3_PREFIX", "assets").strip("/") + key = f"{prefix}/{digest}" if prefix else digest + return key, digest + + +def upload_visual_asset(body: bytes, content_type: str) -> tuple[str, str]: + """Publish bytes with a transport checksum; verify metadata before recording a pointer.""" + if not body or not content_type.startswith("image/"): + raise ValueError("Only nonempty generated image bodies may be uploaded") + key, digest = object_key_for_body(body) + s3 = client() + name = bucket() + # Content-addressed images are immutable. Reuse existing objects rather than + # rewriting common icons and maps (R2 limits repeated writes to one key). + try: + existing = s3.head_object(Bucket=name, Key=key) + except ClientError as exc: + # HEAD has no modeled NoSuchKey response; only an actual 404 is a miss. + if exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode") != 404: + raise + else: + if ( + existing["ContentLength"] != len(body) + or existing.get("Metadata", {}).get("sha256") != digest + or existing.get("ContentType") != content_type + ): + raise RuntimeError("Existing visual asset metadata verification failed") + return key, digest + s3.put_object( + Bucket=name, + Key=key, + Body=body, + ContentType=content_type, + ContentMD5=base64.b64encode(hashlib.md5(body).digest()).decode("ascii"), + CacheControl="public, max-age=31536000, immutable", + Metadata={"sha256": digest}, + ) + head = s3.head_object(Bucket=name, Key=key) + if ( + head["ContentLength"] != len(body) + or head.get("Metadata", {}).get("sha256") != digest + or head.get("ContentType") != content_type + ): + raise RuntimeError("Uploaded visual asset metadata verification failed") + return key, digest + + +def read_visual_asset(key: str, digest: str, byte_size: int) -> bytes: + response = client().get_object(Bucket=bucket(), Key=key) + with response["Body"] as stream: + # Read one extra byte to detect unexpected data without unbounded allocation. + body = stream.read(byte_size + 1) + if len(body) != byte_size or hashlib.sha256(body).hexdigest() != digest: + raise RuntimeError("Visual asset checksum verification failed") + return body + + +def public_visual_asset_url(key: str) -> str | None: + base = os.getenv("VISUAL_ASSET_PUBLIC_BASE_URL", "").strip().rstrip("/") + if not base: + return None + parsed = urlsplit(base) + if parsed.scheme != "https" or not parsed.netloc or parsed.query or parsed.fragment: + raise ValueError("VISUAL_ASSET_PUBLIC_BASE_URL must be an HTTPS base URL") + return f"{base}/{quote(key, safe='/')}" diff --git a/backend/app/tasks/static_maps.py b/backend/app/tasks/static_maps.py index fbde16a..05875d5 100644 --- a/backend/app/tasks/static_maps.py +++ b/backend/app/tasks/static_maps.py @@ -34,6 +34,10 @@ async def generate_map_for_resource(resource_id: str, geometry: Any) -> bool: bool: True if the map was generated successfully, False otherwise """ try: + from app.services.native_static_maps import pregenerate_thumbnail, renderer_url + + if renderer_url(): + await asyncio.to_thread(pregenerate_thumbnail, resource_id, geometry) service = StaticMapService() source_signature = service.geometry_signature(geometry) @@ -267,3 +271,65 @@ async def _generate_static_maps_collection_async() -> dict: finally: await task_engine.dispose() + + +@shared_task(bind=True, name="generate_campus_static_map", max_retries=3) +def generate_campus_static_map(self, map_id, latitude, longitude, zoom, lock_key): + """Generate a campus PNG outside the web request; retry remote service failures.""" + service = StaticMapService(map_width=640, map_height=320) + try: + data = service.generate_centered_basemap( + f"institution:{map_id}", latitude=latitude, longitude=longitude, zoom=zoom + ) + if not data: + raise RuntimeError("Campus renderer returned no image") + service.map_cache.delete(lock_key) + return {"success": True, "sha256": service._asset_hash(data)} + except Exception as exc: + if self.request.retries >= self.max_retries: + service.map_cache.delete(lock_key) + raise self.retry(exc=exc, countdown=5 * (self.request.retries + 1)) from exc + + +@shared_task(bind=True, name="generate_native_resource_map", max_retries=3) +def generate_native_resource_map(self, resource_id, geometry, lock_key, width=800, height=600): + """Queue native geography for HTTP cache misses without serving legacy images.""" + from app.services.native_static_maps import renderer_url + + service = StaticMapService(width, height) + try: + if not renderer_url(): + raise RuntimeError("Native renderer is not configured") + data = service.generate_map(resource_id, geometry) + if not data: + raise RuntimeError("Native renderer returned no image") + service.map_cache.delete(lock_key) + return {"success": True, "sha256": service._asset_hash(data)} + except Exception as exc: + if self.request.retries >= self.max_retries: + service.map_cache.delete(lock_key) + raise self.retry(exc=exc, countdown=5 * (self.request.retries + 1)) from exc + + +@shared_task(bind=True, name="pregenerate_native_thumbnails", max_retries=3) +def pregenerate_native_thumbnails(self, resource_ids): + """Bounded, repeatable catalog backfill. Existing renders are linked/reused.""" + from app.services.native_static_maps import pregenerate_thumbnail, renderer_url + from db.session import sync_engine + + if not renderer_url(): + raise RuntimeError("Native renderer is not configured") + if len(resource_ids) > 100: + raise ValueError("Submit at most 100 records per batch") + with sync_engine.connect() as conn: + rows = conn.execute( + select(resources.c.id, resources.c.locn_geometry, resources.c.dcat_bbox).where( + resources.c.id.in_(resource_ids) + ) + ).fetchall() + try: + for row in rows: + pregenerate_thumbnail(row.id, row.locn_geometry or row.dcat_bbox) + except Exception as exc: + raise self.retry(exc=exc, countdown=5 * (self.request.retries + 1)) from exc + return {"prepared": len(rows), "missing": len(set(resource_ids)) - len(rows)} diff --git a/backend/db/migrations/add_visual_asset_object_storage.py b/backend/db/migrations/add_visual_asset_object_storage.py new file mode 100644 index 0000000..f60f938 --- /dev/null +++ b/backend/db/migrations/add_visual_asset_object_storage.py @@ -0,0 +1,29 @@ +"""Add object-storage pointers while preserving all existing visual asset bytes.""" + +from sqlalchemy import text + + +def add_visual_asset_object_storage(engine=None): + if engine is None: + from db.session import sync_engine + + engine = sync_engine + with engine.begin() as conn: + conn.execute(text("SET LOCAL lock_timeout = '5s'")) + conn.execute( + text( + "ALTER TABLE generated_visual_assets " + "ADD COLUMN IF NOT EXISTS object_key VARCHAR(1024)" + ) + ) + conn.execute( + text( + "ALTER TABLE generated_visual_assets " + "ADD COLUMN IF NOT EXISTS object_sha256 VARCHAR(64)" + ) + ) + conn.execute(text("ALTER TABLE generated_visual_assets ALTER COLUMN body DROP NOT NULL")) + + +if __name__ == "__main__": + add_visual_asset_object_storage() diff --git a/backend/db/models.py b/backend/db/models.py index 7438e6c..3850a1b 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -355,8 +355,10 @@ Column("asset_hash", String(64), primary_key=True), Column("asset_kind", String(64), nullable=False, index=True), Column("content_type", String(255), nullable=False), - Column("body", LargeBinary, nullable=False), + Column("body", LargeBinary, nullable=True), Column("byte_size", Integer, nullable=False), + Column("object_key", String(1024), nullable=True), + Column("object_sha256", String(64), nullable=True), Column("created_at", TIMESTAMP, nullable=False, server_default=func.now()), Column("updated_at", TIMESTAMP, nullable=False, server_default=func.now()), ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d2938b9..f978177 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "anyio==4.7.0", "async-timeout==5.0.1", "asyncpg==0.30.0", + "boto3==1.43.119", "click==8.1.7", "databases==0.9.0", "exceptiongroup==1.2.2", diff --git a/backend/scripts/backfill_visual_assets_to_s3.py b/backend/scripts/backfill_visual_assets_to_s3.py new file mode 100644 index 0000000..9b0b470 --- /dev/null +++ b/backend/scripts/backfill_visual_assets_to_s3.py @@ -0,0 +1,158 @@ +"""Copy durable image bodies to S3/R2 without deleting local data or changing URLs.""" + +import argparse +import hashlib +import json +import random +import time +from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait +from threading import Lock + +from botocore.exceptions import ClientError +from sqlalchemy import select, update + +from app.services.visual_asset_object_store import read_visual_asset, upload_visual_asset +from db.models import generated_visual_assets as assets +from db.session import sync_engine + + +class VerifiedCopies: + """Share one verified upload among rows with identical bytes and MIME type.""" + + def __init__(self): + self.lock = Lock() + self.copies = {} + + def ensure(self, body, content_type): + identity = (hashlib.sha256(body).hexdigest(), content_type) + with self.lock: + future = self.copies.get(identity) + owner = future is None + if owner: + future = self.copies[identity] = Future() + if owner: + try: + for attempt in range(6): + try: + key, digest = upload_visual_asset(body, content_type) + break + except ClientError as exc: + status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode") + if status not in (429, 500, 502, 503, 504) or attempt == 5: + raise + time.sleep(2**attempt + random.random()) + read_visual_asset(key, digest, len(body)) + future.set_result((key, digest)) + except Exception as exc: + future.set_exception(exc) + raise + return future.result() + + +def copy_row(row, copies): + body = bytes(row["body"]) + key, digest = copies.ensure(body, row["content_type"]) + with sync_engine.begin() as conn: + result = conn.execute( + update(assets) + .where( + assets.c.asset_hash == row["asset_hash"], + assets.c.body == body, + assets.c.content_type == row["content_type"], + assets.c.object_key.is_(None), + ) + .values(object_key=key, object_sha256=digest) + ) + return result.rowcount + + +def backfill(*, apply=False, limit=100, after="", workers=1, progress=None): + copied = skipped = scanned = completed = reported = 0 + cursor = after + pending = set() + copies = VerifiedCopies() + exhausted = False + with ThreadPoolExecutor(max_workers=workers) as pool: + while pending or not exhausted: + if not exhausted and len(pending) < workers * 2: + stmt = ( + select(assets.c.asset_hash, assets.c.body, assets.c.content_type) + .where( + assets.c.asset_hash > cursor, + assets.c.body.is_not(None), + assets.c.object_key.is_(None), + ) + .order_by(assets.c.asset_hash) + .limit(min(25, limit - scanned)) + ) + with sync_engine.connect() as conn: + rows = conn.execute(stmt).mappings().all() + if not rows: + exhausted = True + else: + for row in rows: + if apply and workers > 1: + pending.add(pool.submit(copy_row, row, copies)) + elif apply: + changed = copy_row(row, copies) + copied += changed + skipped += 1 - changed + completed += 1 + else: + completed += 1 + scanned += len(rows) + cursor = rows[-1]["asset_hash"] + exhausted = scanned >= limit + if pending and (exhausted or len(pending) >= workers * 2): + done, pending = wait(pending, return_when=FIRST_COMPLETED) + for future in done: + changed = future.result() + copied += changed + skipped += 1 - changed + completed += 1 + if progress and completed - reported >= 500: + progress( + { + "scanned": scanned, + "copied": copied, + "concurrently_changed": skipped, + "in_flight": len(pending), + } + ) + reported = completed + return { + "scanned": scanned, + "copied": copied, + "concurrently_changed": skipped, + "last_hash": cursor, + "dry_run": not apply, + "local_bodies_deleted": 0, + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apply", action="store_true") + parser.add_argument("--limit", type=int, default=100) + parser.add_argument("--after", default="") + parser.add_argument("--workers", type=int, default=1) + args = parser.parse_args() + if args.limit < 1: + parser.error("--limit must be positive") + if not 1 <= args.workers <= 128: + parser.error("--workers must be between 1 and 128") + print( + json.dumps( + backfill( + apply=args.apply, + limit=args.limit, + after=args.after, + workers=args.workers, + progress=lambda data: print(json.dumps(data), flush=True), + ) + ) + ) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/prime_static_map_cache.py b/backend/scripts/prime_static_map_cache.py index 75903d5..bd6f645 100644 --- a/backend/scripts/prime_static_map_cache.py +++ b/backend/scripts/prime_static_map_cache.py @@ -135,6 +135,10 @@ def _prime_static_maps_sync( *, hydrate_assets: bool = True, ) -> tuple[str, str]: + from app.services.native_static_maps import pregenerate_thumbnail, renderer_url + + if renderer_url(): + pregenerate_thumbnail(resource_id, geometry, hydrate_asset=hydrate_assets) service = StaticMapService() source_signature = service.geometry_signature(geometry) static_map_variant = service.geometry_variant() diff --git a/backend/scripts/prune_verified_visual_redis.py b/backend/scripts/prune_verified_visual_redis.py new file mode 100644 index 0000000..be15a01 --- /dev/null +++ b/backend/scripts/prune_verified_visual_redis.py @@ -0,0 +1,112 @@ +"""Remove only Redis DB 1 image bytes matching a verified R2 manifest.""" + +import argparse +import hashlib +import json +import os +import re + +import redis + +VISUAL_PREFIX = re.compile(r"^(image|static_map_asset|static_map_v[0-9]+|static_basemap_v[0-9]+):") +COMPARE_UNLINK = ( + "if redis.call('GET',KEYS[1]) == ARGV[1] then " + "return redis.call('UNLINK',KEYS[1]) else return 0 end" +) + + +def is_verified_image(key, value, verified): + return bool( + VISUAL_PREFIX.match(key.decode("utf-8")) + and value + and verified.get(hashlib.sha256(value).hexdigest()) == len(value) + ) + + +def prune(manifest, *, apply=False): + verified = {} + with open(manifest, encoding="utf-8") as source: + for line in source: + record = json.loads(line) + verified[record["object_sha256"]] = record["byte_size"] + if not verified: + raise ValueError("Verification manifest is empty") + cache = redis.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD") or None, + db=1, + socket_timeout=30, + socket_connect_timeout=5, + ) + scanned = matched = removed = retained = released_bytes = eligible_bytes = retained_bytes = 0 + pending = [] + + def batch(keys): + nonlocal scanned, matched, removed, retained, released_bytes, eligible_bytes, retained_bytes + values = cache.mget(keys) + selected = [] + for key, value in zip(keys, values): + scanned += 1 + if is_verified_image(key, value, verified): + matched += 1 + eligible_bytes += len(value) + selected.append((key, value)) + else: + retained += 1 + retained_bytes += len(value) if value else 0 + if apply and selected: + pipe = cache.pipeline(transaction=False) + for key, value in selected: + pipe.eval(COMPARE_UNLINK, 1, key, value) + for changed, (_, value) in zip(pipe.execute(), selected): + removed += changed + released_bytes += changed * len(value) + if scanned % 500 == 0: + print( + json.dumps( + dict( + scanned=scanned, + matched=matched, + removed=removed, + retained=retained, + released_bytes=released_bytes, + ) + ), + flush=True, + ) + + for key in cache.scan_iter(count=500): + if not VISUAL_PREFIX.match(key.decode("utf-8")): + continue + pending.append(key) + if len(pending) >= 25: + batch(pending) + pending = [] + if pending: + batch(pending) + result = dict( + scanned=scanned, + matched=matched, + removed=removed, + retained=retained, + released_bytes=released_bytes, + eligible_bytes=eligible_bytes, + retained_bytes=retained_bytes, + dry_run=not apply, + redis_db=1, + ) + print(json.dumps(result), flush=True) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", required=True) + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + prune(args.manifest, apply=args.apply) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/run_migrations.py b/backend/scripts/run_migrations.py index c105698..ead3881 100644 --- a/backend/scripts/run_migrations.py +++ b/backend/scripts/run_migrations.py @@ -4,6 +4,7 @@ from db.migrations.add_enrichment_type import add_enrichment_type_column from db.migrations.add_fast_gazetteer import add_fast_gazetteer +from db.migrations.add_visual_asset_object_storage import add_visual_asset_object_storage from db.migrations.api_rate_limiting import init_api_rate_limiting from db.migrations.backfill_resources_from_legacy_items import ( backfill_resources_from_legacy_items, @@ -70,6 +71,7 @@ def run_migrations(): logger.info("Creating generated visual asset cache tables...") create_generated_visual_assets_table() + add_visual_asset_object_storage() logger.info("Creating resource thumbnail state table...") create_resource_thumbnail_state_table() diff --git a/backend/scripts/verify_visual_assets_r2.py b/backend/scripts/verify_visual_assets_r2.py new file mode 100644 index 0000000..c62ce9a --- /dev/null +++ b/backend/scripts/verify_visual_assets_r2.py @@ -0,0 +1,154 @@ +"""Verify every remote image before optionally releasing its PostgreSQL body. + +The fsynced JSONL manifest is written before each small cleanup transaction. +An optimistic body/pointer comparison protects concurrent generation. +""" + +import argparse +import hashlib +import json +import os +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone + +from sqlalchemy import Text, cast, func, literal_column, or_, select, update + +from app.services.visual_asset_object_store import client, read_visual_asset +from db.models import generated_visual_assets as assets +from db.session import sync_engine + + +def verified_record(row): + if not row["object_key"] or not row["object_sha256"]: + raise RuntimeError("Asset has no durable object pointer: " + row["asset_hash"]) + remote = read_visual_asset(row["object_key"], row["object_sha256"], row["byte_size"]) + if row["body"] is not None and bytes(row["body"]) != remote: + raise RuntimeError("Local/remote bytes differ: " + row["asset_hash"]) + return { + "asset_hash": row["asset_hash"], + "object_key": row["object_key"], + "object_sha256": hashlib.sha256(remote).hexdigest(), + "byte_size": len(remote), + "content_type": row["content_type"], + "verified_at": datetime.now(timezone.utc).isoformat(), + } + + +def clear_verified_body(conn, row): + if row["body"] is None: + return 0 + unchanged = ( + cast(literal_column("xmin"), Text) == row["_row_version"] + if "_row_version" in row + else assets.c.body == bytes(row["body"]) + ) + return conn.execute( + update(assets) + .where( + assets.c.asset_hash == row["asset_hash"], + assets.c.object_key == row["object_key"], + assets.c.object_sha256 == row["object_sha256"], + assets.c.byte_size == row["byte_size"], + assets.c.body.is_not(None), + unchanged, + ) + .values(body=None) + ).rowcount + + +def verify(*, manifest, prune=False, workers=8, limit=1000000, after=""): + if prune: + with sync_engine.connect() as conn: + missing = conn.execute( + select(func.count()) + .select_from(assets) + .where(or_(assets.c.object_key.is_(None), assets.c.object_sha256.is_(None))) + ).scalar_one() + if missing: + raise RuntimeError(f"Refusing cleanup: {missing} assets lack object pointers") + client() # Construct the SDK client before starting verification threads. + verified = cleared = released_bytes = reported = 0 + cursor = after + columns = [assets] + if sync_engine.dialect.name == "postgresql": + # xmin changes on every concurrent UPDATE, including body-only updates. + # Avoid shipping every large image back in the cleanup WHERE clause. + columns.append(cast(literal_column("xmin"), Text).label("_row_version")) + os.umask(0o077) + with ( + open(manifest, "a", encoding="utf-8") as audit, + ThreadPoolExecutor(max_workers=workers) as pool, + ): + while verified < limit: + with sync_engine.connect() as conn: + rows = ( + conn.execute( + select(*columns) + .where(assets.c.asset_hash > cursor) + .order_by(assets.c.asset_hash) + .limit(min(max(25, workers * 2), limit - verified)) + ) + .mappings() + .all() + ) + if not rows: + break + records = list(pool.map(verified_record, rows)) + for record in records: + audit.write(json.dumps(record) + "\n") + audit.flush() + os.fsync(audit.fileno()) + if prune: + with sync_engine.begin() as conn: + for row in rows: + changed = clear_verified_body(conn, row) + cleared += changed + released_bytes += changed * row["byte_size"] + verified += len(rows) + cursor = rows[-1]["asset_hash"] + if verified - reported >= 500: + reported = verified + print( + json.dumps( + { + "verified": verified, + "cleared": cleared, + "released_bytes": released_bytes, + "last_hash": cursor, + } + ), + flush=True, + ) + result = { + "verified": verified, + "cleared": cleared, + "released_bytes": released_bytes, + "last_hash": cursor, + "manifest": manifest, + "complete": verified < limit, + } + print(json.dumps(result), flush=True) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", required=True) + parser.add_argument("--prune-postgres", action="store_true") + parser.add_argument("--workers", type=int, default=8) + parser.add_argument("--limit", type=int, default=1000000) + parser.add_argument("--after", default="") + args = parser.parse_args() + if not 1 <= args.workers <= 128 or args.limit < 1: + parser.error("workers must be 1..128 and limit positive") + verify( + manifest=args.manifest, + prune=args.prune_postgres, + workers=args.workers, + limit=args.limit, + after=args.after, + ) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/api/v1/test_static_map_endpoints.py b/backend/tests/api/v1/test_static_map_endpoints.py index d702365..8b07d7d 100644 --- a/backend/tests/api/v1/test_static_map_endpoints.py +++ b/backend/tests/api/v1/test_static_map_endpoints.py @@ -45,6 +45,7 @@ def create_static_map_service_mock(*, basemap_bytes=None, geometry_bytes=None) - svc.get_cached_basemap = AsyncMock(return_value=basemap_bytes) svc.get_cached_map = AsyncMock(return_value=geometry_bytes) svc.get_cached_asset = AsyncMock(return_value=None) + svc.get_asset_hash = AsyncMock(return_value=None) svc.basemap_variant.return_value = "basemap" svc.geometry_variant.return_value = "geometry" svc.geometry_signature.return_value = "geometry-signature" diff --git a/backend/tests/scripts/test_prime_static_map_cache.py b/backend/tests/scripts/test_prime_static_map_cache.py index 64c7381..ac025d9 100644 --- a/backend/tests/scripts/test_prime_static_map_cache.py +++ b/backend/tests/scripts/test_prime_static_map_cache.py @@ -168,3 +168,25 @@ async def test_run_refuses_full_corpus_redis_asset_hydration(): with patch.object(prime_static_map_cache, "_count_resources", return_value=1000): assert await prime_static_map_cache._run(args) == 2 + + +def test_native_thumbnail_is_prepared_even_when_full_maps_are_cached(): + service = MagicMock() + service.materialize_cached_variant_sync.return_value = "cached-hash" + with ( + patch.object(prime_static_map_cache, "StaticMapService", return_value=service), + patch("app.services.native_static_maps.renderer_url", return_value="http://renderer"), + patch("app.services.native_static_maps.pregenerate_thumbnail") as prepare, + ): + status, _ = prime_static_map_cache._prime_static_maps_sync( + "resource-1", + "ENVELOPE(-1, 1, 1, -1)", + force=False, + hydrate_assets=False, + ) + assert status == "cached" + prepare.assert_called_once_with( + "resource-1", + "ENVELOPE(-1, 1, 1, -1)", + hydrate_asset=False, + ) diff --git a/backend/tests/services/test_native_static_maps.py b/backend/tests/services/test_native_static_maps.py new file mode 100644 index 0000000..796eb87 --- /dev/null +++ b/backend/tests/services/test_native_static_maps.py @@ -0,0 +1,146 @@ +import io +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from PIL import Image + +from app.api.v1.endpoint_modules.static_maps import router +from app.services import native_static_maps +from app.services.static_map_service import StaticMapService + + +def test_native_signatures_change_with_revision_and_dimensions(monkeypatch): + monkeypatch.setenv("STATIC_MAP_RENDERER_URL", "http://renderer:8080") + small = StaticMapService(640, 320) + first = small.centered_basemap_signature(latitude=39, longitude=-86, zoom=15) + assert first != StaticMapService(800, 600).centered_basemap_signature( + latitude=39, longitude=-86, zoom=15 + ) + monkeypatch.setenv("STATIC_MAP_RENDER_REVISION", "new-style") + assert first != small.centered_basemap_signature(latitude=39, longitude=-86, zoom=15) + assert small.basemap_variant() == "native_basemap_v1" + + +def test_native_route_never_falls_back_to_legacy(monkeypatch): + monkeypatch.delenv("STATIC_MAP_RENDERER_URL", raising=False) + app = FastAPI() + app.include_router(router) + with patch.object(StaticMapService, "generate_centered_basemap") as render: + response = TestClient(app).get("/static-maps/institutions/test/native?lat=39&lon=-86") + assert response.status_code == 503 + render.assert_not_called() + + +@pytest.mark.parametrize( + "query", ["lat=nan&lon=0", "lat=90&lon=0", "lat=0&lon=181", "lat=0&lon=0&zoom=30"] +) +def test_campus_rejects_bad_camera(query): + app = FastAPI() + app.include_router(router) + assert TestClient(app).get("/static-maps/institutions/test/native?" + query).status_code == 422 + + +def test_remote_failure_does_not_publish_or_use_carto(monkeypatch): + monkeypatch.setenv("STATIC_MAP_RENDERER_URL", "http://renderer:8080") + service = StaticMapService() + service.map_cache = MagicMock() + with ( + patch.object(native_static_maps, "reuse_geometry", return_value=None), + patch.object(native_static_maps.requests, "post", side_effect=RuntimeError("unavailable")), + patch.object(service, "materialize_asset_sync") as persist, + patch.object(service, "_render_and_cache") as legacy, + ): + with pytest.raises(RuntimeError, match="unavailable"): + service.generate_map("record", "ENVELOPE(-90,-89,40,39)") + persist.assert_not_called() + legacy.assert_not_called() + + +def test_wrong_size_png_is_not_published(monkeypatch): + monkeypatch.setenv("STATIC_MAP_RENDERER_URL", "http://renderer:8080") + output = io.BytesIO() + Image.new("RGB", (2, 2)).save(output, "PNG") + response = MagicMock(content=output.getvalue()) + service = StaticMapService() + service.map_cache = MagicMock() + with ( + patch.object(native_static_maps, "reuse_geometry", return_value=None), + patch.object(native_static_maps.requests, "post", return_value=response), + patch.object(service, "materialize_asset_sync") as persist, + ): + with pytest.raises(ValueError, match="dimensions"): + service.generate_map("record", "ENVELOPE(-90,-89,40,39)") + persist.assert_not_called() + + +def test_native_resource_route_never_falls_back_to_legacy(monkeypatch): + monkeypatch.delenv("STATIC_MAP_RENDERER_URL", raising=False) + app = FastAPI() + app.include_router(router) + with patch.object(StaticMapService, "generate_map") as render: + response = TestClient(app).get("/static-maps/record/geometry/native") + assert response.status_code == 503 + assert response.headers["cache-control"] == "no-store" + render.assert_not_called() + + +def test_thumbnail_uses_small_signature_and_browser_cache(monkeypatch): + monkeypatch.setenv("STATIC_MAP_RENDERER_URL", "http://renderer:8080") + output = io.BytesIO() + Image.new("RGB", (384, 288)).save(output, "PNG") + app = FastAPI() + app.include_router(router) + endpoint = "app.api.v1.endpoint_modules.static_maps" + service = MagicMock() + service.get_asset_hash = AsyncMock(return_value="digest") + service.get_cached_asset = AsyncMock(return_value=output.getvalue()) + with ( + patch( + endpoint + "._fetch_resource_dict", + AsyncMock(return_value={"locn_geometry": "POINT(0 0)"}), + ), + patch(endpoint + ".StaticMapService", return_value=service) as factory, + ): + response = TestClient(app).get("/static-maps/test/geometry/native?size=thumbnail") + factory.assert_called_once_with(384, 288) + assert response.status_code == 200 + assert "max-age=3600" in response.headers["cache-control"] + assert response.content == output.getvalue() + + +def test_identical_geometry_reuses_bytes_without_rendering(): + service = MagicMock() + service.geometry_signature.return_value = "geometry-and-dimensions" + service.geometry_variant.return_value = "native_geometry_v1" + service.get_cached_asset_sync.return_value = b"existing PNG" + service.materialize_asset_sync.return_value = "digest" + with ( + patch.object( + native_static_maps, "get_durable_visual_asset_hash_for_signature", return_value="digest" + ), + patch.object( + native_static_maps, "get_durable_visual_asset_hash_for_resource", return_value="digest" + ), + patch.object(native_static_maps, "_generate_uncached") as render, + ): + assert ( + native_static_maps.generate(service, "different-record", "POINT(1 2)") + == b"existing PNG" + ) + render.assert_not_called() + assert service.materialize_asset_sync.call_args.args[0] == "different-record" + + +def test_concurrent_identical_geometry_is_not_rendered_twice(): + service = MagicMock() + service.map_cache.set.return_value = False + with ( + patch.object(native_static_maps, "reuse_geometry", return_value=None), + patch.object(native_static_maps, "_generate_uncached") as render, + ): + with pytest.raises(RuntimeError, match="already rendering"): + native_static_maps.generate(service, "record", "POINT(1 2)") + render.assert_not_called() + service.map_cache.eval.assert_not_called() diff --git a/backend/tests/services/test_static_map_service.py b/backend/tests/services/test_static_map_service.py index 1919cd6..a22e295 100644 --- a/backend/tests/services/test_static_map_service.py +++ b/backend/tests/services/test_static_map_service.py @@ -50,7 +50,9 @@ def test_materialize_asset_persists_resource_link(): with ( patch.object(service, "set_asset_hash_sync") as mock_set_hash, patch("app.services.static_map_service.store_durable_visual_asset"), - patch("app.services.static_map_service.store_durable_visual_asset_link") as mock_link, + patch( + "app.services.static_map_service.store_durable_visual_asset_link", return_value=True + ) as mock_link, ): asset_hash = service.materialize_asset_sync( "resource-1", diff --git a/backend/tests/services/test_visual_asset_backfill.py b/backend/tests/services/test_visual_asset_backfill.py new file mode 100644 index 0000000..3c1c3ef --- /dev/null +++ b/backend/tests/services/test_visual_asset_backfill.py @@ -0,0 +1,139 @@ +"""Exercise copy-only migration against a real, isolated SQL table.""" + +import hashlib +from unittest.mock import patch + +import pytest +from sqlalchemy import create_engine, select, update + +from db.models import generated_visual_assets as assets +from scripts import backfill_visual_assets_to_s3 as migration + + +@pytest.fixture +def database(monkeypatch): + engine = create_engine("sqlite://") + assets.create(engine) + with engine.begin() as conn: + conn.execute( + assets.insert().values( + asset_hash="a" * 64, + asset_kind="thumbnail", + content_type="image/png", + body=b"original", + byte_size=8, + ) + ) + monkeypatch.setattr(migration, "sync_engine", engine) + yield engine + engine.dispose() + + +def row(engine): + with engine.connect() as conn: + return conn.execute(select(assets)).mappings().one() + + +def test_dry_run_never_uploads_or_changes_body(database): + with patch.object(migration, "upload_visual_asset") as upload: + result = migration.backfill() + assert result["scanned"] == 1 + assert result["copied"] == 0 + assert row(database)["body"] == b"original" + assert row(database)["object_key"] is None + upload.assert_not_called() + + +def test_copy_verifies_bytes_retains_body_and_is_resumable(database): + digest = hashlib.sha256(b"original").hexdigest() + with ( + patch.object(migration, "upload_visual_asset", return_value=("assets/" + digest, digest)), + patch.object(migration, "read_visual_asset", return_value=b"original") as verify, + ): + assert migration.backfill(apply=True)["copied"] == 1 + assert migration.backfill(apply=True)["scanned"] == 0 + verify.assert_called_once_with("assets/" + digest, digest, 8) + assert row(database)["body"] == b"original" + assert row(database)["object_sha256"] == digest + + +def test_failed_verification_never_commits_pointer(database): + with ( + patch.object(migration, "upload_visual_asset", return_value=("assets/hash", "d" * 64)), + patch.object(migration, "read_visual_asset", side_effect=RuntimeError("corrupted")), + pytest.raises(RuntimeError, match="corrupted"), + ): + migration.backfill(apply=True) + assert row(database)["object_key"] is None + assert row(database)["body"] == b"original" + + +def test_concurrent_render_is_not_replaced_with_stale_pointer(database): + def render(*args): + with database.begin() as conn: + conn.execute(update(assets).values(body=b"new image", byte_size=9)) + return "assets/hash", "d" * 64 + + with ( + patch.object(migration, "upload_visual_asset", side_effect=render), + patch.object(migration, "read_visual_asset", return_value=b"original"), + ): + result = migration.backfill(apply=True) + assert result["concurrently_changed"] == 1 + assert result["copied"] == 0 + assert row(database)["object_key"] is None + assert row(database)["body"] == b"new image" + + +def test_parallel_copy_keeps_all_rows_and_commits_each_pointer(tmp_path, monkeypatch): + engine = create_engine("sqlite:///" + str(tmp_path / "assets.sqlite")) + assets.create(engine) + with engine.begin() as conn: + conn.execute( + assets.insert(), + [ + dict( + asset_hash=f"{n:064x}", + asset_kind="thumbnail", + content_type="image/png", + body=b"image", + byte_size=5, + ) + for n in range(80) + ], + ) + monkeypatch.setattr(migration, "sync_engine", engine) + with ( + patch.object( + migration, "upload_visual_asset", return_value=("assets/digest", "d" * 64) + ) as upload, + patch.object(migration, "read_visual_asset", return_value=b"image"), + ): + result = migration.backfill(apply=True, limit=100, workers=4) + upload.assert_called_once() + assert result["copied"] == 80 + assert result["scanned"] == 80 + with engine.connect() as conn: + rows = conn.execute(select(assets)).mappings().all() + assert all(r["object_key"] == "assets/digest" and r["body"] == b"image" for r in rows) + engine.dispose() + + +def test_transient_upload_failure_retries_without_committing_early(database): + from botocore.exceptions import ClientError + + error = ClientError( + {"Error": {"Code": "ServiceUnavailable"}, "ResponseMetadata": {"HTTPStatusCode": 503}}, + "PutObject", + ) + with ( + patch.object( + migration, "upload_visual_asset", side_effect=[error, ("assets/hash", "d" * 64)] + ) as upload, + patch.object(migration, "read_visual_asset", return_value=b"original"), + patch.object(migration.time, "sleep") as sleep, + ): + assert migration.backfill(apply=True)["copied"] == 1 + assert upload.call_count == 2 + sleep.assert_called_once() + assert row(database)["body"] == b"original" diff --git a/backend/tests/services/test_visual_asset_cleanup.py b/backend/tests/services/test_visual_asset_cleanup.py new file mode 100644 index 0000000..dd26344 --- /dev/null +++ b/backend/tests/services/test_visual_asset_cleanup.py @@ -0,0 +1,94 @@ +import hashlib +from unittest.mock import patch + +import pytest +from sqlalchemy import create_engine, select, update + +from db.models import generated_visual_assets as assets +from scripts import verify_visual_assets_r2 as cleanup + + +def sample(): + return dict( + asset_hash="a" * 64, + object_key="assets/test", + object_sha256=hashlib.sha256(b"image").hexdigest(), + body=b"image", + byte_size=5, + asset_kind="thumbnail", + content_type="image/png", + ) + + +def test_wrong_remote_copy_cannot_be_verified(): + with patch.object(cleanup, "read_visual_asset", return_value=b"wrong"): + with pytest.raises(RuntimeError, match="bytes differ"): + cleanup.verified_record(sample()) + + +def test_missing_pointer_cannot_be_verified(): + row = sample() + row["object_key"] = None + with pytest.raises(RuntimeError, match="no durable"): + cleanup.verified_record(row) + + +def test_clear_preserves_metadata_and_concurrent_new_body(): + engine = create_engine("sqlite://") + assets.create(engine) + row = sample() + with engine.begin() as conn: + conn.execute(assets.insert().values(**row)) + conn.execute(update(assets).values(body=b"newer")) + assert cleanup.clear_verified_body(conn, row) == 0 + assert conn.execute(select(assets.c.body)).scalar_one() == b"newer" + conn.execute(update(assets).values(body=b"image")) + assert cleanup.clear_verified_body(conn, row) == 1 + stored = conn.execute(select(assets)).mappings().one() + assert stored["body"] is None + assert stored["object_key"] == row["object_key"] + assert stored["byte_size"] == 5 + engine.dispose() + + +def test_redis_cleanup_requires_known_namespace_and_verified_exact_bytes(): + from scripts.prune_verified_visual_redis import is_verified_image + + manifest = {hashlib.sha256(b"image").hexdigest(): 5} + assert is_verified_image(b"image:hash", b"image", manifest) + assert is_verified_image(b"static_basemap_v7:resource", b"image", manifest) + assert not is_verified_image(b"celery-task-meta-id", b"image", manifest) + assert not is_verified_image(b"image_type:hash", b"image", manifest) + assert not is_verified_image(b"image:hash", b"other", manifest) + + +def test_audit_flush_failure_prevents_cleanup(tmp_path, monkeypatch): + engine = create_engine("sqlite://") + assets.create(engine) + with engine.begin() as conn: + conn.execute(assets.insert().values(**sample())) + monkeypatch.setattr(cleanup, "sync_engine", engine) + with ( + patch.object(cleanup, "client"), + patch.object(cleanup, "read_visual_asset", return_value=b"image"), + patch.object(cleanup.os, "fsync", side_effect=OSError("audit disk full")), + pytest.raises(OSError, match="audit disk full"), + ): + cleanup.verify(manifest=str(tmp_path / "audit.jsonl"), prune=True, workers=1) + with engine.connect() as conn: + assert conn.execute(select(assets.c.body)).scalar_one() == b"image" + engine.dispose() + + +def test_pruning_refuses_any_missing_object_pointer(tmp_path, monkeypatch): + engine = create_engine("sqlite://") + assets.create(engine) + row = sample() + row["object_key"] = None + with engine.begin() as conn: + conn.execute(assets.insert().values(**row)) + monkeypatch.setattr(cleanup, "sync_engine", engine) + with pytest.raises(RuntimeError, match="lack object pointers"): + cleanup.verify(manifest=str(tmp_path / "audit.jsonl"), prune=True, workers=1) + assert not (tmp_path / "audit.jsonl").exists() + engine.dispose() diff --git a/backend/tests/services/test_visual_asset_object_store.py b/backend/tests/services/test_visual_asset_object_store.py new file mode 100644 index 0000000..2045da2 --- /dev/null +++ b/backend/tests/services/test_visual_asset_object_store.py @@ -0,0 +1,227 @@ +import hashlib +import io +from unittest.mock import MagicMock, patch + +import pytest +from botocore.exceptions import ClientError + +from app.services import visual_asset_cache as cache +from app.services import visual_asset_object_store as objects + + +@pytest.fixture(autouse=True) +def isolated_settings(monkeypatch): + monkeypatch.setenv("VISUAL_ASSET_DURABLE_STORE", "s3") + monkeypatch.setenv("VISUAL_ASSET_S3_BUCKET", "test-images") + monkeypatch.setenv("VISUAL_ASSET_S3_PREFIX", "assets") + monkeypatch.setenv("OGM_MODE", "primary") + monkeypatch.delenv("VISUAL_ASSET_CACHE_TTL_SECONDS", raising=False) + monkeypatch.delenv("VISUAL_ASSET_REDIS_BODY_CACHE_ENABLED", raising=False) + monkeypatch.delenv("VISUAL_ASSET_PUBLIC_BASE_URL", raising=False) + + +def test_upload_uses_actual_bytes_and_verifies_before_returning(): + body = b"generated image" + digest = hashlib.sha256(body).hexdigest() + s3 = MagicMock() + s3.head_object.side_effect = [ + ClientError( + {"Error": {"Code": "404"}, "ResponseMetadata": {"HTTPStatusCode": 404}}, "HeadObject" + ), + { + "ContentLength": len(body), + "ContentType": "image/png", + "Metadata": {"sha256": digest}, + }, + ] + with patch.object(objects, "client", return_value=s3): + assert objects.upload_visual_asset(body, "image/png") == (f"assets/{digest}", digest) + args = s3.put_object.call_args.kwargs + assert args["Body"] == body + assert args["ContentMD5"] + assert args["CacheControl"] == "public, max-age=31536000, immutable" + assert s3.head_object.call_count == 2 + + +def test_incomplete_upload_is_not_published(): + s3 = MagicMock() + s3.head_object.return_value = {"ContentLength": 0} + with patch.object(objects, "client", return_value=s3): + with pytest.raises(RuntimeError, match="verification"): + objects.upload_visual_asset(b"image", "image/png") + + +def test_read_checks_bytes_and_closes_stream_on_corruption(): + stream = io.BytesIO(b"corrupted") + s3 = MagicMock() + s3.get_object.return_value = {"Body": stream} + with patch.object(objects, "client", return_value=s3): + with pytest.raises(RuntimeError, match="checksum"): + objects.read_visual_asset("assets/hash", "0" * 64, 9) + assert stream.closed + + +def test_cache_disabled_for_bodies_but_aliases_expire(): + redis = MagicMock() + assert cache.cache_visual_asset(redis, "image:hash", b"image") + redis.set.assert_not_called() + redis.setex.assert_not_called() + cache.cache_visual_asset(redis, "static_map_alias:id", "hash") + redis.setex.assert_called_once_with("static_map_alias:id", 86400, "hash") + + +def test_database_mode_preserves_legacy_cache_default(monkeypatch): + monkeypatch.setenv("VISUAL_ASSET_DURABLE_STORE", "database") + redis = MagicMock() + cache.cache_visual_asset(redis, "image:hash", b"image") + redis.set.assert_called_once_with("image:hash", b"image") + + +def test_failed_upload_never_writes_database_pointer(): + with ( + patch.object(cache, "upload_visual_asset", side_effect=OSError("offline")), + patch.object(cache, "_sync_engine") as engine, + ): + assert not cache.store_durable_visual_asset( + "a" * 64, asset_kind="thumbnail", content_type="image/png", body=b"image" + ) + engine.assert_not_called() + + +def test_s3_only_write_keeps_metadata_without_body(monkeypatch): + monkeypatch.setenv("VISUAL_ASSET_S3_RETAIN_DATABASE_BODY", "false") + engine = MagicMock() + with ( + patch.object(cache, "upload_visual_asset", return_value=("assets/digest", "d" * 64)), + patch.object(cache, "_sync_engine", return_value=engine), + ): + assert cache.store_durable_visual_asset( + "a" * 64, asset_kind="thumbnail", content_type="image/png", body=b"image" + ) + stmt = engine.begin.return_value.__enter__.return_value.execute.call_args.args[0] + values = stmt.compile().params + assert values["body"] is None + assert values["byte_size"] == 5 + assert values["object_key"] == "assets/digest" + + +def test_redirect_requires_committed_pointer_and_configured_domain(monkeypatch): + monkeypatch.setenv("VISUAL_ASSET_PUBLIC_BASE_URL", "https://assets.example.org") + with patch.object(cache, "_visual_asset_metadata", return_value=None): + assert cache.get_visual_asset_public_url("a" * 64) is None + with patch.object( + cache, + "_visual_asset_metadata", + return_value={"object_key": "assets/hash", "object_sha256": "d" * 64}, + ): + assert ( + cache.get_visual_asset_public_url("a" * 64) == "https://assets.example.org/assets/hash" + ) + + +def test_object_failure_falls_back_to_existing_database_bytes(): + engine = MagicMock() + conn = engine.connect.return_value.__enter__.return_value + conn.execute.return_value.mappings.return_value.first.return_value = { + "body": b"old-image", + "content_type": "image/png", + } + with ( + patch.object( + cache, + "_visual_asset_metadata", + return_value={ + "object_key": "assets/hash", + "object_sha256": "d" * 64, + "byte_size": 9, + "content_type": "image/png", + }, + ), + patch.object(cache, "read_visual_asset", side_effect=OSError("offline")), + patch.object(cache, "_sync_engine", return_value=engine), + ): + assert cache.get_durable_visual_asset("a" * 64) == (b"old-image", "image/png") + + +@pytest.mark.asyncio +async def test_redis_hydration_failure_does_not_hide_durable_thumbnail(): + from app.services.image_service import ImageService + + service = ImageService.__new__(ImageService) + service.image_cache = MagicMock() + service.image_cache.get.return_value = None + service.logger = MagicMock() + with ( + patch( + "app.services.image_service.get_durable_visual_asset", + return_value=(b"image", "image/png"), + ), + patch("app.services.image_service.cache_visual_asset", side_effect=OSError("Redis full")), + ): + assert await service.get_cached_image("a" * 64) == b"image" + + +def test_static_map_does_not_publish_alias_after_failed_upload(): + from app.services.static_map_service import StaticMapService + + service = StaticMapService.__new__(StaticMapService) + with ( + patch("app.services.static_map_service.store_durable_visual_asset", return_value=False), + patch.object(service, "set_asset_hash_sync") as alias, + patch.object(service, "cache_asset_sync") as hot_cache, + ): + assert ( + service.materialize_asset_sync("resource", variant="static_map_v9", map_bytes=b"image") + is None + ) + alias.assert_not_called() + hot_cache.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["thumbnail", "map"]) +async def test_asset_endpoints_redirect_before_loading_bytes(kind): + from starlette.requests import Request + + from app.api.v1.endpoint_modules import static_maps, thumbnails + + module = thumbnails if kind == "thumbnail" else static_maps + endpoint = module.get_thumbnail if kind == "thumbnail" else module.get_static_map_asset + service_name = "ImageService" if kind == "thumbnail" else "StaticMapService" + with ( + patch.object( + module, + "get_visual_asset_public_url", + return_value="https://assets.example.org/assets/hash", + ), + patch.object(module, service_name) as service, + ): + response = await endpoint("a" * 64, Request({"type": "http", "headers": []})) + assert response.status_code == 302 + assert response.headers["location"] == "https://assets.example.org/assets/hash" + service.assert_not_called() + + +def test_existing_verified_object_is_reused_without_put(): + body = b"shared icon" + digest = hashlib.sha256(body).hexdigest() + s3 = MagicMock() + s3.head_object.return_value = { + "ContentLength": len(body), + "ContentType": "image/png", + "Metadata": {"sha256": digest}, + } + with patch.object(objects, "client", return_value=s3): + assert objects.upload_visual_asset(body, "image/png") == (f"assets/{digest}", digest) + s3.put_object.assert_not_called() + + +def test_head_permission_error_is_not_treated_as_missing(): + s3 = MagicMock() + s3.head_object.side_effect = ClientError( + {"Error": {"Code": "AccessDenied"}, "ResponseMetadata": {"HTTPStatusCode": 403}}, + "HeadObject", + ) + with patch.object(objects, "client", return_value=s3), pytest.raises(ClientError): + objects.upload_visual_asset(b"image", "image/png") + s3.put_object.assert_not_called() diff --git a/backend/tools/maplibre-static-prototype/.gitignore b/backend/tools/maplibre-static-prototype/.gitignore new file mode 100644 index 0000000..e0e6e61 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +output/ diff --git a/backend/tools/maplibre-static-prototype/Dockerfile.linux b/backend/tools/maplibre-static-prototype/Dockerfile.linux new file mode 100644 index 0000000..dc53b51 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/Dockerfile.linux @@ -0,0 +1,15 @@ +# Build the unmodified repository Dockerfile as ogm-native-api-base:prototype first. +FROM node:22-bookworm-slim AS node +FROM ogm-native-api-base:prototype +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ + && apt-get update && apt-get install -y --no-install-recommends \ + libegl1 libgles2 libopengl0 libgl1-mesa-dri xvfb xauth mesa-utils \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /opt/map-prototype +COPY backend/tools/maplibre-static-prototype/package*.json ./ +RUN npm ci +COPY backend/tools/maplibre-static-prototype/*.mjs backend/tools/maplibre-static-prototype/*.json ./ +ENV LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe +CMD ["xvfb-run", "-a", "sh", "-c", "glxinfo -B && mkdir -p output && npm run demo"] diff --git a/backend/tools/maplibre-static-prototype/Dockerfile.worker b/backend/tools/maplibre-static-prototype/Dockerfile.worker new file mode 100644 index 0000000..99c92fd --- /dev/null +++ b/backend/tools/maplibre-static-prototype/Dockerfile.worker @@ -0,0 +1,16 @@ +FROM node:22-bookworm-slim AS node +FROM ubuntu:24.04 +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates fonts-dejavu-core libegl1 libgles2 libopengl0 libgl1-mesa-dri libglx-mesa0 libcurl4t64 \ + libjpeg-turbo8 libuv1t64 libicu74 libpng16-16t64 libwebp7 xvfb xauth mesa-utils \ + && rm -rf /var/lib/apt/lists/* +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm +WORKDIR /opt/map-prototype +COPY backend/tools/maplibre-static-prototype/package*.json ./ +RUN npm ci +COPY backend/tools/maplibre-static-prototype/*.mjs backend/tools/maplibre-static-prototype/*.json ./ +ENV LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe DISPLAY=:99 +# Explicit display startup avoids xvfb-run's signal wait in this container runtime. +CMD ["sh", "-c", "rm -f /tmp/.X99-lock /tmp/.X11-unix/X99; Xvfb :99 -screen 0 1280x1024x24 -ac -nolisten tcp & sleep 2; glxinfo -B && mkdir -p output && npm run demo"] diff --git a/backend/tools/maplibre-static-prototype/LINUX-RESULTS.md b/backend/tools/maplibre-static-prototype/LINUX-RESULTS.md new file mode 100644 index 0000000..96913aa --- /dev/null +++ b/backend/tools/maplibre-static-prototype/LINUX-RESULTS.md @@ -0,0 +1,96 @@ +# Linux container verification — 2026-10-23 + +## Result + +**Not compatible as a drop-in renderer in the current API image.** +The repository Dockerfile uses `python:3.11-slim-bookworm` (Debian 12), not Ubuntu. +The MapLibre Native 6.4.1 Linux ARM64 binary fails to load there: it needs +GLIBC_2.38, GLIBCXX_3.4.31/32, libjpeg.so.8, and ICU 74. Bookworm supplies older +ABIs. Adding Mesa/Xvfb does not resolve this. Do not transplant Ubuntu system +libraries into the API image. + +**A separate Ubuntu 24.04 render worker works without a GPU.** +`Dockerfile.worker` runs the same renderer with Mesa llvmpipe and a virtual X +display. `glxinfo -B` reported `Accelerated: no`. No GPU devices were passed. +Installed `fonts-dejavu-core` after visual QA caught missing attribution glyphs; +rerendered and visually verified both corrected images. +Test limits: 2 CPUs, 1 GiB memory; architecture: Linux ARM64 on Docker Desktop. +Production server CPU architecture remains unknown. A subsequent amd64 test +passed under Docker emulation; see ../../../docs/native-static-maps.md (from the +repository root: docs/native-static-maps.md) for the Celery integration results. + +| Example | Size | Time | Requests | Linux PNG SHA-256 | +| --- | --- | --- | --- | --- | +| Indiana campus | 640×320 | 1.488 s | 8 | 93d6b7b452665297dfdeaa046c345b82855b042f1cbcba5ad8d4ea734f6f50af | +| Resource footprint | 800×600 | 0.949 s | 22 | 51eda75c73db69272e76c5e96b9e0ce95cf67ce7d8eda52b5eb39815e2017b5a | + +These are single-run observations, not load-test benchmarks. The images were +rendered inside Linux, not copied from the earlier macOS demonstration. + +## Existing asset system: passed + +Built the current repository Dockerfile without modifying it. Used that image +with disposable PostgreSQL 16 and Redis 7.4.6 containers. The test harness mounts +the **unchanged production static-map router** in a minimal FastAPI application; +it does not exercise full application middleware, authentication, Elasticsearch, +or Celery dispatch. + +For both PNGs: + +- Persisted bytes in existing `generated_visual_assets` storage and Redis. +- GET `/api/v1/static-map-assets/{sha256}` returned HTTP 200, `image/png`, and + byte-for-byte identical SHA-256. +- Cache-Control was `public, max-age=31536000, immutable`. +- Deleted the Redis asset key; GET recovered the PNG from PostgreSQL and + repopulated Redis with identical bytes. +- Conditional GET with the returned ETag returned HTTP 304. + +The resource also passed `StaticMapService.materialize_asset_sync` and persisted +its existing resource/variant/signature link. Campus IDs are not resource IDs: +the links table has a resource foreign key. The campus test therefore uses the +existing durable content-addressed store and Redis asset methods directly, with +no fabricated resource row. Campus alias integration is a remaining production +change; this test does not claim that campus resource links already work. + +## Reproduce + +From the repository root (requires Docker; isolated test names must be free): + +```sh +docker build -t ogm-native-api-base:prototype . +docker build -f backend/tools/maplibre-static-prototype/Dockerfile.linux -t ogm-native-linux:prototype . +# This preflight demonstrates the incompatibility without waiting on Xvfb: +docker run --rm ogm-native-linux:prototype node -e 'require("@maplibre/maplibre-gl-native")' + +docker build -f backend/tools/maplibre-static-prototype/Dockerfile.worker -t ogm-native-worker:prototype . +docker run --name ogm-native-worker --memory=1g --cpus=2 ogm-native-worker:prototype +mkdir -p /tmp/ogm-native-linux-output +docker cp ogm-native-worker:/opt/map-prototype/output/. /tmp/ogm-native-linux-output/ + +docker network create ogm-native-test +docker run -d --name ogm-native-postgres --network ogm-native-test -e POSTGRES_PASSWORD=prototype -e POSTGRES_DB=native_render_test postgres:16 +docker run -d --name ogm-native-redis --network ogm-native-test redis:7.4.6-alpine +# Wait for pg_isready before starting the API harness. +docker exec ogm-native-postgres pg_isready -U postgres -d native_render_test +docker run -d --name ogm-native-assets --network ogm-native-test -p 127.0.0.1:3006:8000 -e DATABASE_URL=postgresql+asyncpg://postgres:prototype@ogm-native-postgres:5432/native_render_test -e REDIS_HOST=ogm-native-redis -e REDIS_PASSWORD= -e VISUAL_ASSET_DURABLE_STORE=database -e PYTHONPATH=/app/backend:/opt/map-prototype -w /app/backend ogm-native-api-base:prototype sleep infinity +docker exec ogm-native-assets mkdir -p /opt/map-prototype/output +docker cp /tmp/ogm-native-linux-output/. ogm-native-assets:/opt/map-prototype/output/ +docker cp backend/tools/maplibre-static-prototype/verify_assets.py ogm-native-assets:/opt/map-prototype/ +docker cp backend/tools/maplibre-static-prototype/check_http.py ogm-native-assets:/opt/map-prototype/ +docker exec -d ogm-native-assets sh -c 'uvicorn verify_assets:app --host 0.0.0.0 --port 8000 > /tmp/asset-server.log 2>&1' +# Once Uvicorn is ready: +docker exec ogm-native-assets python /opt/map-prototype/check_http.py +``` + +Local review remains available on port 3006. To remove only this experiment: + +```sh +docker rm -f -v ogm-native-assets ogm-native-postgres ogm-native-redis ogm-native-worker ogm-native-worker-fonts ogm-native-render +docker network rm ogm-native-test +``` + +No production Dockerfile, API route, cache schema, or Celery task was changed. +Recommendation: keep rendering in a dedicated compatible worker image if this +approach proceeds. Before adoption, verify deployment CPU architecture, workload +concurrency/memory, provider authorization for bulk generation, and campus alias +handling. A whole-API OS upgrade is not justified by this prototype alone. diff --git a/backend/tools/maplibre-static-prototype/README.md b/backend/tools/maplibre-static-prototype/README.md new file mode 100644 index 0000000..eaf938d --- /dev/null +++ b/backend/tools/maplibre-static-prototype/README.md @@ -0,0 +1,70 @@ +For the opt-in API/Celery integration and amd64 results, see +[deployment guidance](../../../docs/native-static-maps.md). + +# MapLibre Native static-map prototype + +Isolated renderer experiment; existing API routes, Celery tasks, Redis, durable +visual asset tables, and production static-map variants are unchanged. +Requires Node 22 and the native runtime supported by @maplibre/maplibre-gl-native. +Verified on macOS and in a separate Ubuntu 24.04 ARM64 software-rendering worker. +The current Debian API image cannot load the native binary. See +[Linux verification results](LINUX-RESULTS.md) for evidence and asset round-trip tests. + +```sh +cd backend/tools/maplibre-static-prototype +npm ci +npm test +mkdir -p output +npm run demo +python3 -m http.server 3005 --bind 127.0.0.1 --directory output +``` + +Open http://127.0.0.1:3005/. Copy `preview.html` to `output/index.html` if +recreating the preview from a clean checkout. Generated files are ignored. + +## Examples + +- `campus.json`: Indiana University, Discovery's configured center and zoom, + 640×320. Converts legacy 256-pixel zoom 15 to MapLibre zoom 14. +- `resource.json`: real API record `0efeae5d-6e6c-513c-ab45-01c5fc0cb16c`, + “Madison Structure.” Its ENVELOPE geometry is normalized to a GeoJSON polygon. + Output is 800×600 with a 48px fit margin. This is the metadata footprint, + not a rendering of the underlying resource's scientific data. + +OpenFreeMap Liberty is used for this bounded experiment, with a unique user +agent, at most four concurrent requests, and per-image request/byte budgets. +There is no persistent tile cache. Do not point this at Maptoolkit's free service: +its community terms exclude backend-only processing. Production bulk generation +needs institution-owned tiles or a provider agreement appropriate to the workload. +The attribution footer is currently specific to OpenFreeMap; changing providers +requires changing credits too. The preview supplies clickable credit links. + +Each output has a JSON manifest containing the complete input, camera, render +version, style/input signature, image SHA-256, request count, bytes, and timing. +Remote errors prevent writing a new PNG. A prior successful PNG is left intact. +The style signature includes the resolved style JSON, but not remote tile, glyph, +or sprite contents; use a provider dataset revision before production caching. + +## Existing asset-system handoff + +`render.mjs` produces PNG bytes; it does not introduce a storage service. A future +Celery adapter can invoke it in a bounded subprocess, validate successful exit +and PNG dimensions/hash, then pass the bytes to the existing +`StaticMapService.materialize_asset_sync(resource_id, variant=..., map_bytes=..., +source_signature=...)`. Use a NEW versioned variant, never the existing CARTO +variants, and the manifest's sourceSignature. This preserves Redis, durable +storage, aliases, and `/static-map-assets/{hash}` delivery. The demo command does not write to databases. The separate Linux integration +harness writes only to disposable test PostgreSQL and Redis containers. + +## Before production + +- Verify Linux image/runtime, bounded worker concurrency, cancellation, and RSS. +- Add provider-specific attribution, provenance/revision, fallback policy, and + request allowlisting before accepting untrusted style URLs. +- Exercise true polygon holes/multipolygons, points/lines, invalid geometry, + dateline normalization, retina output, and missing geometry. Crossing/polar + bounds deliberately fail in this prototype rather than silently misframe. +- Validate sprite/glyph/tile failure behavior and publish assets atomically. +- Keep final images/credits readable at the intended campus tile display size. +- Wire a versioned production variant and regenerate through existing jobs only + after visual parity and provider suitability are established. diff --git a/backend/tools/maplibre-static-prototype/campus.json b/backend/tools/maplibre-static-prototype/campus.json new file mode 100644 index 0000000..7ed325e --- /dev/null +++ b/backend/tools/maplibre-static-prototype/campus.json @@ -0,0 +1,12 @@ +{ + "style": "https://tiles.openfreemap.org/styles/liberty", + "id": "institution:indiana-university", + "title": "Indiana University campus", + "width": 640, + "height": 320, + "center": [ + -86.5235, + 39.1702 + ], + "leafletZoom": 15 +} \ No newline at end of file diff --git a/backend/tools/maplibre-static-prototype/check_celery.py b/backend/tools/maplibre-static-prototype/check_celery.py new file mode 100644 index 0000000..624d618 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/check_celery.py @@ -0,0 +1,31 @@ +import hashlib +import json +import urllib.request +from pathlib import Path + +from sqlalchemy import update +from app.tasks.worker import celery_app +from app.services.static_map_service import StaticMapService +from app.services.visual_asset_cache import get_durable_visual_asset +from db.models import resources +from db.session import sync_engine + +assert sync_engine.url.database == 'native_render_test' +record = json.loads(Path('/opt/map-prototype/resource.json').read_text()) +with sync_engine.begin() as conn: + conn.execute(update(resources).where(resources.c.id == record['id']).values(locn_geometry=record['sourceGeometry'])) +task = celery_app.send_task('generate_static_map', args=[record['id']]) +result = task.get(timeout=100) +assert result['success'], result +service = StaticMapService() +sig = service.geometry_signature(record['sourceGeometry']) +digest = service.get_asset_hash_sync(record['id'], variant=service.geometry_variant(), source_signature=sig) +assert digest +url = 'http://127.0.0.1:8000/api/v1/static-map-assets/' + digest +with urllib.request.urlopen(url) as response: + data = response.read() + assert hashlib.sha256(data).hexdigest() == digest + assert get_durable_visual_asset(digest) == (data,'image/png') + assert response.headers['Content-Type'] == 'image/png' +print(json.dumps({'task': task.id, 'result': result, 'asset': digest, 'bytes': len(data)})) +Path('/tmp/celery-resource.png').write_bytes(data) diff --git a/backend/tools/maplibre-static-prototype/check_http.py b/backend/tools/maplibre-static-prototype/check_http.py new file mode 100644 index 0000000..2b30cc9 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/check_http.py @@ -0,0 +1,29 @@ +"""Check real HTTP delivery, ETags, and durable-store recovery after cache eviction.""" +import hashlib +import json +import urllib.request +import urllib.error +from pathlib import Path + +from app.services.static_map_service import StaticMapService + +service = StaticMapService() +for asset in json.loads(Path('/opt/map-prototype/output/assets.json').read_text()): + url = 'http://127.0.0.1:8000' + asset['url'] + for mode in ('redis', 'durable-recovery'): + if mode == 'durable-recovery': + service.map_cache.delete(service._asset_key(asset['sha256'])) + with urllib.request.urlopen(url) as response: + assert response.status == 200 + assert response.headers['Content-Type'] == 'image/png' + assert hashlib.sha256(response.read()).hexdigest() == asset['sha256'] + etag = response.headers['ETag'] + print(asset['name'], mode, response.status, response.headers['Cache-Control'], etag) + assert service.map_cache.exists(service._asset_key(asset['sha256'])) + try: + urllib.request.urlopen(urllib.request.Request(url, headers={'If-None-Match': etag})) + except urllib.error.HTTPError as response: + assert response.code == 304 + print(asset['name'], 'conditional GET', response.code) + else: + raise AssertionError('Expected HTTP 304') diff --git a/backend/tools/maplibre-static-prototype/integrated_app.py b/backend/tools/maplibre-static-prototype/integrated_app.py new file mode 100644 index 0000000..6d239b3 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/integrated_app.py @@ -0,0 +1,42 @@ +"""Local-only preview harness: hydrate missing public records into the isolated DB.""" +import asyncio +import json +import urllib.request +from urllib.parse import quote + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.dialects.postgresql import insert +from app.api.v1.endpoint_modules import static_maps +from db.models import resources +from db.session import sync_engine + +assert sync_engine.url.database == "native_render_test", "Preview harness requires isolated database" +original_fetch = static_maps._fetch_resource_dict + + +def hydrate_record(resource_id): + url = "https://ogm.geo4lib.app/api/v1/resources/" + quote(resource_id, safe="") + with urllib.request.urlopen(url, timeout=15) as response: + record = json.load(response)["data"] + ogm = record["attributes"]["ogm"] + values = {key: ogm[key] for key in ("dct_title_s", "locn_geometry", "dcat_bbox") if key in ogm} + with sync_engine.begin() as connection: + connection.execute(insert(resources).values(id=resource_id, **values).on_conflict_do_nothing()) + + +async def fetch_preview_record(resource_id): + record = await original_fetch(resource_id) + if record is None: + try: + await asyncio.to_thread(hydrate_record, resource_id) + except Exception: + return None + record = await original_fetch(resource_id) + return record + + +static_maps._fetch_resource_dict = fetch_preview_record +app = FastAPI() +app.add_middleware(CORSMiddleware, allow_origin_regex=r"http://(127\.0\.0\.1|localhost):[0-9]+", allow_methods=["GET"], expose_headers=["Retry-After"]) +app.include_router(static_maps.router, prefix="/api/v1") diff --git a/backend/tools/maplibre-static-prototype/package-lock.json b/backend/tools/maplibre-static-prototype/package-lock.json new file mode 100644 index 0000000..69e3def --- /dev/null +++ b/backend/tools/maplibre-static-prototype/package-lock.json @@ -0,0 +1,3058 @@ +{ + "name": "ogm-maplibre-static-prototype", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ogm-maplibre-static-prototype", + "version": "0.1.0", + "dependencies": { + "@maplibre/maplibre-gl-native": "6.4.1", + "sharp": "0.35.5" + } + }, + "node_modules/@acalcutt/node-pre-gyp": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@acalcutt/node-pre-gyp/-/node-pre-gyp-2.0.4.tgz", + "integrity": "sha512-oPSZBuqSh1AWuPMsQKMtlcb4hisU4DWtuIo8OlQtMH4F7k1w+gd1Be4dTTZdh8qoG8LfmauTBJraU8DWxub0RA==", + "license": "BSD-3-Clause", + "dependencies": { + "consola": "^3.2.3", + "detect-libc": "^2.0.0", + "https-proxy-agent": "^7.0.5", + "node-fetch": "^2.6.7", + "nopt": "^8.0.0", + "semver": "^7.5.3", + "tar": "^7.5.7" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@acalcutt/node-pre-gyp-github": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@acalcutt/node-pre-gyp-github/-/node-pre-gyp-github-2.0.2.tgz", + "integrity": "sha512-XESr6F1llJ7sU87vti5noR6Xnq00g7wo5Jr4GnAJ42spO69atXCjCJb9MYLYMQQpXn2Ts/T1uYaG+gWMb/69ug==", + "license": "MIT", + "dependencies": { + "@octokit/rest": "22.0.1", + "commander": "14.0.3", + "semver": "^7.7.4" + }, + "bin": { + "node-pre-gyp-github": "bin/node-pre-gyp-github.js" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-native": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-native/-/maplibre-gl-native-6.4.1.tgz", + "integrity": "sha512-eiJnCOIea2PrGmxZnocP61kEJO4IAhNS/yvkdNh+PR50qdR4khAGU/dBA0IV+uXEQCu33Fux//J1wOQ5srYVGQ==", + "hasInstallScript": true, + "license": "BSD-2-Clause", + "dependencies": { + "@acalcutt/node-pre-gyp": "2.0.4", + "@acalcutt/node-pre-gyp-github": "2.0.2", + "minimatch": "^10.2.2", + "npm-run-all": "4.1.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.8.tgz", + "integrity": "sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.5", + "@octokit/request": "^10.0.16", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.5.tgz", + "integrity": "sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^18.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.5.tgz", + "integrity": "sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.16", + "@octokit/types": "^18.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-29.0.1.tgz", + "integrity": "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.16.tgz", + "integrity": "sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.5", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "content-type": "^3.0.0", + "json-with-bigint": "^3.5.12", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.2.tgz", + "integrity": "sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^18.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-18.0.0.tgz", + "integrity": "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^29.0.1" + } + }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "license": "Apache-2.0" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-type": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz", + "integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/cross-spawn/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "license": "ISC" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.12.tgz", + "integrity": "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==", + "license": "MIT" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm-run-all/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/npm-run-all/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/npm-run-all/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "license": "MIT", + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.17.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shell-quote": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", + "license": "CC0-1.0" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.padend": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/backend/tools/maplibre-static-prototype/package.json b/backend/tools/maplibre-static-prototype/package.json new file mode 100644 index 0000000..99d9b68 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/package.json @@ -0,0 +1,14 @@ +{ + "name": "ogm-maplibre-static-prototype", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test", + "demo": "node render.mjs campus.json output/campus.png && node render.mjs resource.json output/resource.png" + }, + "dependencies": { + "@maplibre/maplibre-gl-native": "6.4.1", + "sharp": "0.35.5" + } +} diff --git a/backend/tools/maplibre-static-prototype/preview.html b/backend/tools/maplibre-static-prototype/preview.html new file mode 100644 index 0000000..319da01 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/preview.html @@ -0,0 +1,6 @@ +OGM static map prototype + +

MapLibre Native static maps

Two locally generated PNG assets using OpenFreeMap Liberty. No browser map or WebGL context is needed to display them.

+

Indiana University campus

Campus background showing Indiana University in Bloomington
640 × 320 · existing campus framing · background ready for a school logo overlay
Render manifest
+

Resource geometry: Madison Structure

Blue rectangular metadata footprint over southern Saskatchewan
800 × 600 · real resource envelope · blue geometry overlay with padded fit
Render manifest · Source record
+

Maps: OpenFreeMap · © OpenMapTiles · © OpenStreetMap contributors

diff --git a/backend/tools/maplibre-static-prototype/prime-assets.py b/backend/tools/maplibre-static-prototype/prime-assets.py new file mode 100644 index 0000000..ff67ba2 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/prime-assets.py @@ -0,0 +1,36 @@ +"""Warm native asset URLs before deploy: python prime-assets.py urls.json. + +urls.json is an array of full campus or resource thumbnail URLs. Uses only +public GET requests; no direct database access or tile caching. +""" +import json +import sys +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor + + +def prime(url): + started = time.monotonic() + while time.monotonic() - started < 180: + try: + with urllib.request.urlopen(url, timeout=20) as response: + body = response.read() + if not body.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError("Expected PNG: " + url) + print(json.dumps({"url": url, "bytes": len(body), "seconds": round(time.monotonic() - started, 3)}), flush=True) + return + except urllib.error.HTTPError as error: + if error.code not in (429, 503): + raise + delay = error.headers.get("Retry-After", "2") + time.sleep(min(10, max(1, int(delay) if delay.isdigit() else 2))) + raise TimeoutError(url) + + +if __name__ == "__main__": + with open(sys.argv[1]) as source: + urls = json.load(source) + with ThreadPoolExecutor(max_workers=2) as pool: + list(pool.map(prime, dict.fromkeys(urls))) diff --git a/backend/tools/maplibre-static-prototype/render.mjs b/backend/tools/maplibre-static-prototype/render.mjs new file mode 100644 index 0000000..31e0a6f --- /dev/null +++ b/backend/tools/maplibre-static-prototype/render.mjs @@ -0,0 +1,198 @@ +import mbgl from "@maplibre/maplibre-gl-native"; +import sharp from "sharp"; +import { readFile, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { pathToFileURL } from "node:url"; + +export function fitBounds( + [west, south, east, north], + width, + height, + padding = 48, +) { + if ( + ![west, south, east, north].every(Number.isFinite) || + west >= east || + south >= north || + south < -85.05112878 || + north > 85.05112878 + ) + throw new Error("Invalid or crossing bounds: normalize before rendering"); + const y = (lat) => + (1 - Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)) / Math.PI) / 2; + const middleY = (y(south) + y(north)) / 2; + return { + center: [ + (west + east) / 2, + (Math.atan(Math.sinh(Math.PI * (1 - 2 * middleY))) * 180) / Math.PI, + ], + zoom: Math.min( + 20, + Math.log2((width - 2 * padding) / ((512 * (east - west)) / 360)), + Math.log2((height - 2 * padding) / (512 * (y(south) - y(north)))), + ), + }; +} + +export async function render(spec, output) { + const started = Date.now(); + const errors = []; + let requests = 0, + bytes = 0, + active = 0; + const queue = []; + const controller = new AbortController(); + const timer = setTimeout( + () => controller.abort(new Error("Render exceeded 60 seconds")), + 60000, + ); + const download = async (url) => { + const response = await fetch(url, { + headers: { "User-Agent": "OpenGeoMetadata-API-StaticMaps/1.0 (https://opengeometadata.org/)" }, + signal: controller.signal, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${url}`); + const data = Buffer.from(await response.arrayBuffer()); + requests++; + bytes += data.length; + if (requests > 3000 || bytes > 50 * 1024 * 1024) + throw new Error("Prototype download budget exceeded"); + return data; + }; + const pump = () => { + while (active < 4 && queue.length) { + const { req, callback } = queue.shift(); + active++; + download(req.url) + .then( + (data) => callback(null, { data }), + (error) => { + errors.push(error.message); + callback(error); + }, + ) + .finally(() => { + active--; + pump(); + }); + } + }; + let map; + const onMessage = (message) => { + if (message.severity === "ERROR") errors.push(message.text); + }; + mbgl.on("message", onMessage); + try { + const style = JSON.parse(await download(spec.style)); + if (spec.geometry) { + style.sources["ogm-geometry"] = { + type: "geojson", + data: { type: "Feature", properties: {}, geometry: spec.geometry }, + }; + style.layers.push( + { + id: "ogm-fill", + type: "fill", + source: "ogm-geometry", + paint: { "fill-color": "#2563eb", "fill-opacity": 0.06 }, + }, + { + id: "ogm-line", + type: "line", + source: "ogm-geometry", + paint: { + "line-color": "#2563eb", + "line-width": 3, + "line-opacity": 0.85, + }, + }, + ); + } + if (spec.geometry) + style.layers.push({ + id: "ogm-point", + type: "circle", + source: "ogm-geometry", + filter: ["==", "$type", "Point"], + paint: { + "circle-radius": 5, + "circle-color": "#2563eb", + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 2, + }, + }); + map = new mbgl.Map({ + ratio: 1, + request(req, callback) { + queue.push({ req, callback }); + pump(); + }, + }); + map.load(style); + const camera = spec.bounds + ? fitBounds(spec.bounds, spec.width, spec.height) + : { center: spec.center, zoom: spec.leafletZoom - 1 }; + const pixels = await new Promise((resolve, reject) => + map.render( + { ...camera, width: spec.width, height: spec.height }, + (error, data) => (error ? reject(error) : resolve(data)), + ), + ); + if (errors.length) throw new Error(errors.join("\n")); + // Native rendering has no DOM attribution control; burn credits into the asset. + const compact = spec.width < 500; + const footer = Buffer.from( + `${compact + ? `OpenFreeMap · © OpenMapTiles© OpenStreetMap contributors` + : `OpenFreeMap · © OpenMapTiles · © OpenStreetMap contributors`}`, + ); + const png = await sharp(pixels, { + raw: { width: spec.width, height: spec.height, channels: 4 }, + }) + .composite([{ input: footer, gravity: "south" }]) + .png() + .toBuffer(); + await writeFile(output, png); + const manifest = { + renderer: "maplibre-native-6.4.1-prototype-v1", + spec, + camera, + requests, + downloadedBytes: bytes, + durationMs: Date.now() - started, + sha256: createHash("sha256").update(png).digest("hex"), + sourceSignature: createHash("sha256") + .update( + JSON.stringify({ + renderer: "maplibre-native-6.4.1-prototype-v1", + spec, + style, + }), + ) + .digest("hex"), + }; + await writeFile(`${output}.json`, JSON.stringify(manifest, null, 2)); + return manifest; + } finally { + clearTimeout(timer); + controller.abort(); + map?.release(); + mbgl.off("message", onMessage); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const [input, output] = process.argv.slice(2); + if (!input || !output) + throw new Error("Usage: node render.mjs spec.json output.png"); + console.log( + JSON.stringify( + await render(JSON.parse(await readFile(input, "utf8")), output), + null, + 2, + ), + ); +} diff --git a/backend/tools/maplibre-static-prototype/render.test.mjs b/backend/tools/maplibre-static-prototype/render.test.mjs new file mode 100644 index 0000000..319fd8a --- /dev/null +++ b/backend/tools/maplibre-static-prototype/render.test.mjs @@ -0,0 +1,13 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fitBounds } from './render.mjs'; +test('Mercator bounds fit respects padding and centers projected latitude', () => { + const result = fitBounds([-10, -10, 10, 10], 800, 600); + assert.ok(Math.abs(result.center[0]) < 1e-10); + assert.ok(Math.abs(result.center[1]) < 1e-10); + assert.ok(result.zoom > 4 && result.zoom < 5); +}); +test('invalid and dateline-crossing bounds fail explicitly', () => { + assert.throws(() => fitBounds([170, -10, -170, 10], 800, 600)); + assert.throws(() => fitBounds([-10, -90, 10, 90], 800, 600)); +}); diff --git a/backend/tools/maplibre-static-prototype/resource.json b/backend/tools/maplibre-static-prototype/resource.json new file mode 100644 index 0000000..c1e3a11 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/resource.json @@ -0,0 +1,42 @@ +{ + "style": "https://tiles.openfreemap.org/styles/liberty", + "id": "0efeae5d-6e6c-513c-ab45-01c5fc0cb16c", + "title": "Madison Structure", + "sourceUrl": "https://ogm.geo4lib.app/api/v1/resources/0efeae5d-6e6c-513c-ab45-01c5fc0cb16c", + "sourceGeometry": "ENVELOPE(-109.9876, -101.1135, 52.1614, 48.8912)", + "width": 800, + "height": 600, + "bounds": [ + -109.9876, + 48.8912, + -101.1135, + 52.1614 + ], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -109.9876, + 48.8912 + ], + [ + -101.1135, + 48.8912 + ], + [ + -101.1135, + 52.1614 + ], + [ + -109.9876, + 52.1614 + ], + [ + -109.9876, + 48.8912 + ] + ] + ] + } +} \ No newline at end of file diff --git a/backend/tools/maplibre-static-prototype/server.mjs b/backend/tools/maplibre-static-prototype/server.mjs new file mode 100644 index 0000000..7fdb3b5 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/server.mjs @@ -0,0 +1,122 @@ +// Private service. Do not expose this port publicly. Celery owns scheduling/retries. +import http from "node:http"; +import { spawn } from "node:child_process"; +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +const style = + process.env.STATIC_MAP_STYLE_URL || + "https://tiles.openfreemap.org/styles/liberty"; +if (new URL(style).hostname !== "tiles.openfreemap.org") + throw new Error( + "This renderer currently supports OpenFreeMap styles/attribution only", + ); +let busy = false; +http + .createServer(async (req, res) => { + if (req.method === "GET" && req.url === "/health") { + res.end("ok"); + return; + } + if (req.method !== "POST" || req.url !== "/render") { + res.writeHead(404).end(); + return; + } + if (busy) { + res.writeHead(503, { "Retry-After": "3" }).end("Renderer busy"); + return; + } + busy = true; + let dir; + try { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > 2 * 1024 * 1024) throw new Error("Request too large"); + chunks.push(chunk); + } + const input = JSON.parse(Buffer.concat(chunks)); + if ( + ![input.width, input.height].every( + (n) => Number.isInteger(n) && n >= 64 && n <= 1600, + ) + ) + throw new Error("Invalid dimensions"); + if ( + !input.bounds && + (!Array.isArray(input.center) || + input.center.length !== 2 || + !input.center.every(Number.isFinite) || + Math.abs(input.center[0]) > 180 || + Math.abs(input.center[1]) > 85 || + !Number.isFinite(input.leafletZoom) || + input.leafletZoom < 1 || + input.leafletZoom > 20) + ) + throw new Error("Invalid camera"); + if ( + input.geometry && + ![ + "Polygon", + "MultiPolygon", + "LineString", + "MultiLineString", + "Point", + "MultiPoint", + "GeometryCollection", + ].includes(input.geometry.type) + ) + throw new Error("Invalid geometry"); + // Only accept data/camera; clients cannot choose remote style/source URLs. + const spec = { + style, + width: input.width, + height: input.height, + bounds: input.bounds, + center: input.center, + leafletZoom: input.leafletZoom, + geometry: input.geometry, + }; + dir = await mkdtemp(join(tmpdir(), "ogm-render-")); + await writeFile(join(dir, "spec.json"), JSON.stringify(spec)); + await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ["render.mjs", join(dir, "spec.json"), join(dir, "map.png")], + { stdio: ["ignore", "ignore", "pipe"] }, + ); + let error = ""; + child.stderr.on("data", (b) => { + error = (error + b).slice(-4000); + }); + const timer = setTimeout(() => child.kill("SIGKILL"), 65000); + child.on("error", (e) => { + clearTimeout(timer); + reject(e); + }); + child.on("close", (code) => { + clearTimeout(timer); + code === 0 + ? resolve() + : reject(new Error(error || `Renderer exit ${code}`)); + }); + }); + const png = await readFile(join(dir, "map.png")); + res + .writeHead(200, { + "Content-Type": "image/png", + "Cache-Control": "no-store", + }) + .end(png); + } catch (error) { + console.error(error.message); + res + .writeHead(502, { "Cache-Control": "no-store" }) + .end("Map rendering failed"); + } finally { + if (dir) await rm(dir, { recursive: true, force: true }); + busy = false; + } + }) + .listen(8080, "0.0.0.0"); diff --git a/backend/tools/maplibre-static-prototype/verify_assets.py b/backend/tools/maplibre-static-prototype/verify_assets.py new file mode 100644 index 0000000..973bf36 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/verify_assets.py @@ -0,0 +1,47 @@ +"""Isolated integration harness: real API router, Redis and PostgreSQL, no mocks.""" +import hashlib +import json +from pathlib import Path + +from fastapi import FastAPI +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert + +from app.api.v1.endpoint_modules.static_maps import router +from app.services.static_map_service import StaticMapService +from app.services.visual_asset_cache import get_durable_visual_asset, store_durable_visual_asset +from db.models import resources, generated_visual_assets, generated_visual_asset_links +from db.session import sync_engine + +# Test-only database. Never run this against a production DATABASE_URL. +assert sync_engine.url.database == 'native_render_test' +for table in (resources, generated_visual_assets, generated_visual_asset_links): + table.create(sync_engine, checkfirst=True) + +service = StaticMapService() +results = [] +for name in ('campus', 'resource'): + path = Path('/opt/map-prototype/output') / f'{name}.png' + body = path.read_bytes() + manifest = json.loads(path.with_suffix('.png.json').read_text()) + digest = hashlib.sha256(body).hexdigest() + assert digest == manifest['sha256'] + variant = 'maplibre_native_prototype_v1' + if name == 'resource': + with sync_engine.begin() as connection: + connection.execute(insert(resources).values(id=manifest['spec']['id'], dct_title_s=manifest['spec']['title']).on_conflict_do_nothing(index_elements=['id'])) + assert service.materialize_asset_sync(manifest['spec']['id'], variant=variant, map_bytes=body, source_signature=manifest['sourceSignature']) == digest + with sync_engine.connect() as connection: + assert connection.execute(select(generated_visual_asset_links).where(generated_visual_asset_links.c.asset_hash == digest)).first() + else: + # Campus IDs are not resource IDs: avoid violating the resource-link FK. + # Use the existing content-addressed asset store, not a fabricated resource. + assert store_durable_visual_asset(digest, asset_kind=variant, content_type='image/png', body=body) + service.cache_asset_sync(digest, body) + assert get_durable_visual_asset(digest) == (body, 'image/png') + assert service.map_cache.get(service._asset_key(digest)) == body + results.append({'name': name, 'sha256': digest, 'bytes': len(body), 'url': f'/api/v1/static-map-assets/{digest}'}) +Path('/opt/map-prototype/output/assets.json').write_text(json.dumps(results, indent=2)) + +app = FastAPI() +app.include_router(router, prefix='/api/v1') diff --git a/backend/uv.lock b/backend/uv.lock index 09a484e..9cafe3e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -281,6 +281,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/da/43b15f28fe5f9e027b41c539abc5469052e9d48fd75f8ff094ba2a0ae767/billiard-4.2.1-py3-none-any.whl", hash = "sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb", size = 86766, upload-time = "2024-09-21T13:40:20.188Z" }, ] +[[package]] +name = "boto3" +version = "1.43.119" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/1b/97f8ec6ce00ae49455797b8af9fde522624ecf80bc73c937596b558b1853/boto3-1.43.119.tar.gz", hash = "sha256:e12c3b1d81713e14bd6947b7412d9d0ea49b320642d795ded1d64d6483ca6f49", size = 112659, upload-time = "2026-10-26T19:25:58.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/d7/6c82dff652082a4bab907b7a5833933a362f12400f14bd7a9935a330145e/boto3-1.43.119-py3-none-any.whl", hash = "sha256:d1718edae48c3241d289c0ece8a515399a7242c406e35328b9685c4d24039186", size = 140045, upload-time = "2026-10-26T19:25:57.128Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.119" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/ab/8e6d1d97c086c127f725cdeffae3b44d92ca3ca40741fb90f245f11887d5/botocore-1.43.119.tar.gz", hash = "sha256:ed11934be6e6d398ac21b7fe03778c9584368eeff8ba5371581f19bb47db0b6a", size = 16457548, upload-time = "2026-10-26T19:25:52.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/54/04e5950c7f2d22d78f22db00ca119b257b7cd2024328b6116ebaeb9c14eb/botocore-1.43.119-py3-none-any.whl", hash = "sha256:6026aec97914a4fa116915fd3b05af899912eff51b93fb8dff95aa228a3e761b", size = 16154402, upload-time = "2026-10-26T19:25:48.301Z" }, +] + [[package]] name = "cachetools" version = "7.0.5" @@ -1119,6 +1147,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "jsonschema" version = "4.24.0" @@ -1651,6 +1688,7 @@ dependencies = [ { name = "appsignal" }, { name = "async-timeout" }, { name = "asyncpg" }, + { name = "boto3" }, { name = "celery" }, { name = "click" }, { name = "databases" }, @@ -1725,6 +1763,7 @@ requires-dist = [ { name = "appsignal", specifier = ">=1.0.0" }, { name = "async-timeout", specifier = "==5.0.1" }, { name = "asyncpg", specifier = "==0.30.0" }, + { name = "boto3", specifier = "==1.43.119" }, { name = "celery", specifier = ">=5.3.0" }, { name = "click", specifier = "==8.1.7" }, { name = "databases", specifier = "==0.9.0" }, @@ -3064,6 +3103,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/fc/be2251ac7ee75e725027b107ab14d161d829aaac22362f75eaabc859bfba/s2sphere-0.2.5-py2.py3-none-any.whl", hash = "sha256:d2340c9cf458ddc9a89afd1d8048a4195ce6fa6b0095ab900d4be5271e537401", size = 23540, upload-time = "2017-11-16T17:47:48.139Z" }, ] +[[package]] +name = "s3transfer" +version = "0.19.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/c5/0a5ce655109cb96b8dfad36908a4e108d15f5dc2eff8fbc3146c4312e704/s3transfer-0.19.3.tar.gz", hash = "sha256:8326acc3d056d7f7b8a37110c7d2aea41f67e83f6ae79cd3b5fbcca34c726406", size = 166087, upload-time = "2026-10-19T19:19:49.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/a4/88f9e10498759121060b8050a1554753dfc04df39bbbcc7aad9ecc8d9440/s3transfer-0.19.3-py3-none-any.whl", hash = "sha256:24eb7535804306355e31c952a90022bf5b2f9d760f9a11062c7493a217c08553", size = 90252, upload-time = "2026-10-19T19:19:48.309Z" }, +] + [[package]] name = "shapely" version = "2.1.1" diff --git a/config/deploy.yml b/config/deploy.yml index f741a48..bae0dd5 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -53,8 +53,17 @@ env: GAZETTEER_CACHE_TTL: "3600" RESOURCE_REPRESENTATION_DURABLE_STORE: database API_RESPONSE_DURABLE_CACHE_STORE: database - VISUAL_ASSET_DURABLE_STORE: database - VISUAL_ASSET_CACHE_TTL_SECONDS: "0" + VISUAL_ASSET_DURABLE_STORE: s3 + VISUAL_ASSET_CACHE_TTL_SECONDS: "86400" + VISUAL_ASSET_REDIS_BODY_CACHE_ENABLED: "false" + VISUAL_ASSET_S3_RETAIN_DATABASE_BODY: "false" + VISUAL_ASSET_S3_ENDPOINT: https://96d9819fafc99210217fc4e9aac9c73d.eu.r2.cloudflarestorage.com + VISUAL_ASSET_S3_REGION: auto + VISUAL_ASSET_S3_BUCKET: ogm + VISUAL_ASSET_S3_PREFIX: assets + VISUAL_ASSET_PUBLIC_BASE_URL: https://assets.geo4lib.app + STATIC_MAP_RENDERER_URL: http://ogm-api-map-renderer:8080 + STATIC_MAP_RENDER_REVISION: openfreemap-liberty-native-v1 ENABLE_FAST_EMBEDDINGS: "false" ENABLE_APPSIGNAL: "false" APP_MODE: production @@ -83,9 +92,23 @@ env: - OPENAI_MODEL - POSTGRES_PASSWORD - OGM_WEBHOOK_SECRET + - VISUAL_ASSET_S3_ACCESS_KEY_ID + - VISUAL_ASSET_S3_SECRET_ACCESS_KEY # ────────────── Accessories (one per Compose service) ────────────── accessories: + map-renderer: + image: ghcr.io/ewlarson/ogm-maplibre-renderer:r2-20261026 + roles: [web] + cmd: sh -c 'rm -f /tmp/.X99-lock /tmp/.X11-unix/X99; Xvfb :99 -screen 0 1280x1024x24 -ac -nolisten tcp & sleep 2; exec node server.mjs' + env: + clear: + STATIC_MAP_STYLE_URL: https://tiles.openfreemap.org/styles/liberty + options: + init: true + memory: 1g + cpus: 2 + log-opt: max-size=10m elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:9.0.0 roles: [web] diff --git a/deploy/Dockerfile.visual-assets b/deploy/Dockerfile.visual-assets new file mode 100644 index 0000000..7a94374 --- /dev/null +++ b/deploy/Dockerfile.visual-assets @@ -0,0 +1,9 @@ +# Preserve the exact deployed runtime; layer the reviewed visual-asset source onto it. +FROM ghcr.io/ewlarson/opengeometadata-api@sha256:eeff5bbf7d415903c6f1fdcba02ee97d7303521ab9323687ee834f6d5566ef8b +RUN uv pip install --system --no-cache boto3==1.43.119 botocore==1.43.119 s3transfer==0.19.3 jmespath==1.1.0 +COPY backend/app/ /app/backend/app/ +COPY backend/db/ /app/backend/db/ +COPY backend/scripts/ /app/backend/scripts/ +COPY backend/scripts/ /app/scripts/ +COPY backend/pyproject.toml backend/uv.lock /app/ +COPY backend/pyproject.toml backend/uv.lock /app/backend/ diff --git a/docker-compose.native-maps.yml b/docker-compose.native-maps.yml new file mode 100644 index 0000000..36cc741 --- /dev/null +++ b/docker-compose.native-maps.yml @@ -0,0 +1,29 @@ +# Opt in with: docker compose -f docker-compose.yml -f docker-compose.native-maps.yml up -d --build +# Only the private renderer uses Ubuntu. API/Celery keep their existing image. +services: + api: + environment: + STATIC_MAP_RENDERER_URL: http://map_renderer:8080 + STATIC_MAP_RENDER_REVISION: ${STATIC_MAP_RENDER_REVISION:-openfreemap-liberty-native-v1} + celery_worker: + environment: + STATIC_MAP_RENDERER_URL: http://map_renderer:8080 + STATIC_MAP_RENDER_REVISION: ${STATIC_MAP_RENDER_REVISION:-openfreemap-liberty-native-v1} + map_renderer: + build: + context: . + dockerfile: backend/tools/maplibre-static-prototype/Dockerfile.worker + command: + [ + "sh", + "-c", + "rm -f /tmp/.X99-lock /tmp/.X11-unix/X99; Xvfb :99 -screen 0 1280x1024x24 -ac -nolisten tcp & sleep 2; exec node server.mjs", + ] + environment: + STATIC_MAP_STYLE_URL: ${STATIC_MAP_STYLE_URL:-https://tiles.openfreemap.org/styles/liberty} + init: true + restart: unless-stopped + mem_limit: 1g + cpus: 2 + networks: + - elastic diff --git a/docs/native-static-maps.md b/docs/native-static-maps.md new file mode 100644 index 0000000..b8a9846 --- /dev/null +++ b/docs/native-static-maps.md @@ -0,0 +1,180 @@ +# Optional native static maps + +The API and its Celery worker retain the existing Debian base image. An internal +Ubuntu 24.04 service renders PNGs using MapLibre Native, Xvfb, Mesa llvmpipe, and +DejaVu fonts. No GPU is required. Celery sends render specifications to this +service; only the API owns Redis, PostgreSQL storage, and asset HTTP delivery. + +## Enable + +```sh +docker compose -f docker-compose.yml -f docker-compose.native-maps.yml up -d --build +``` + +The override sets `STATIC_MAP_RENDERER_URL=http://map_renderer:8080` on `api` and +`celery_worker` and adds the private `map_renderer` service. Do not publish its +port externally. It accepts one render at a time, rejects overload with 503, +and terminates a render subprocess after 65 seconds. The API client has bounded +connect/read timeouts. Campus jobs retry failures up to three times through +Celery. A crashed worker cannot retain a WebGL context across jobs: rendering is +performed in a separate process for each image. + +`STATIC_MAP_RENDER_REVISION` defaults to `openfreemap-liberty-native-v1`. Change it +when changing styles, datasets, fonts, or renderer behavior. Both API and Celery +must use the same value. Dimensions and input geometry/camera also participate +in signatures. Native assets use separate variants from legacy CARTO assets. + +`STATIC_MAP_STYLE_URL` defaults to OpenFreeMap Liberty. Currently only +`tiles.openfreemap.org` styles are supported because the burned-in attribution +and Discovery's companion credit links are OpenFreeMap-specific. A contracted +or institution-owned provider requires adapting the renderer's allowed style +host and attribution together. The Maptoolkit community endpoint must not be +used for backend generation. Establish appropriate provider arrangements before +large collection backfills; do not infer bulk-production authorization from the +small integration test. There is no persistent tile cache. + +## Campus requests and caching + +`GET /api/v1/static-maps/institutions/{slug}/native?lat=39.1702&lon=-86.5235&zoom=15` + +- Requires the native renderer configuration; never falls back to CARTO. +- Validates camera ranges, then checks the versioned image cache. +- Enqueues `generate_campus_static_map` on a miss, deduplicated by Redis lock. +- Waits up to eight seconds for initial generation; otherwise returns 503 with + `Retry-After: 5` and `Cache-Control: no-store`. Discovery retries up to eight + times and keeps the school logo/branded background visible while waiting. +- The task validates the PNG and dimensions before publishing. Failure leaves + prior cached bytes intact. A pending lock expires after five minutes if a + worker is killed; regular retry exhaustion releases it immediately. +- Campus bytes use the existing durable content-addressed asset table plus + Redis. No resource row is fabricated. The camera-to-image cache lives in Redis; + losing that cache regenerates the campus image. The durable hash URL remains + available. No resource-link schema changes are introduced. + +Legacy campus URLs also select native rendering when the integration is enabled. +The separate `/native` URL protects Discovery against older APIs that would +otherwise ignore a new query parameter and serve CARTO. + +## Resource maps + +The existing `generate_static_map`, batch, and collection Celery tasks select the +native renderer through `StaticMapService` when configured. Existing resource +geometry normalization is reused; GeoJSON polygons retain their rings. Rendered +PNGs use the existing resource links, Redis, durable table, and +`/api/v1/static-map-assets/{hash}` endpoint. Existing synchronous compatibility +routes also use the selected renderer; this change does not convert every +legacy resource HTTP cache miss into a queued job. + +Keep the renderer disabled to retain legacy behavior. Disabling it does not +serve native campus URLs from legacy caches. No asset tables are dropped or +rewritten, and the production Dockerfile is unchanged by this integration. + +## Verification (2026-10-23) + +- Ubuntu ARM64 and amd64 both rendered campus and resource examples with + `Accelerated: no`. amd64 ran under Docker CPU emulation on an ARM Mac; this is + binary/runtime compatibility evidence, not a native-server performance test. + Initial amd64 renders took 3.489 s and 3.268 s respectively. +- Real Celery worker in the API's Debian image called the amd64 renderer. + A cold native campus HTTP request queued a job and returned HTTP 200/PNG. +- Existing resource-generation task returned success; the image was retrieved + through the API hash URL and matched its durable stored bytes. +- Earlier asset integration checks verified HTTP 304 and recovery after Redis + eviction. The unchanged asset router is mounted in a minimal FastAPI harness; + production authentication/middleware and a live production deploy were not + part of this test. +- 41 backend static-map tests passed, including configuration signatures, + invalid cameras, failed render rejection, and no legacy fallback. +- All 17 BTAA campus images were generated through Celery and returned HTTP 200. + Browser inspection confirmed the complete grid of school logos over campus + backgrounds and visible provider credits. +- Discovery's two retry/cleanup tests passed, and its subpath production build passed. + +Repeatable renderer Dockerfiles and test harnesses are under +`backend/tools/maplibre-static-prototype/`. `check_celery.py` requires the isolated +`native_render_test` database and an API on port 8000 in its container. It updates +only that test resource and invokes the actual Celery task. Never point a test +harness at production data. + + +## Resource geography migration + +Discovery requests `/api/v1/static-maps/{id}/geometry/native` for generated +resource geography images. This route requires the renderer configuration, +looks up the current geometry and native revision signature, and queues +`generate_native_resource_map` on a cache miss. Redis pending locks deduplicate +concurrent requests; pending responses are 503 with Retry-After and no-store. +The task uses the existing durable asset store and resource links. It does not +replace immutable legacy hashes, which may contain baked-in CARTO error tiles. +Deploy this route and the native worker before the matching Discovery build. +Custom external static-map URLs are preserved by Discovery. + +Local resource-thumbnail verification (2026-10-23): 20 real first-page search +records were seeded into the isolated test database and rendered by the amd64 +Ubuntu service through Celery. All 20 returned PNGs; their durable bytes, +resource links, SHA-256 hashes, and immutable HTTP asset responses matched. +The Hamilton County output was visually inspected. Backend checks: 42 tests +passed. Browser screenshot automation failed, so final in-page visual inspection +remains outstanding. No production records or assets were changed. + +## Loading performance + +Native cache misses return 503 / Retry-After: 2 immediately after enqueueing; +they do not occupy a connection with an eight-second polling loop. Successful +campus and native resource responses allow one hour of browser caching. +`?size=thumbnail` requests a 384×288 resource map with its own geometry/dimension +signature and compact, two-line attribution. The full-size route is unchanged. +Discovery fetches near-viewport images and retries only 503/429 responses without +cache-busting query parameters; CORS must allow the frontend origin. + +Warm deployment-critical URLs with: +`python backend/tools/maplibre-static-prototype/prime-assets.py urls.json` +where the JSON file is an array of full native campus/thumbnail URLs. This uses +the actual API/Celery path and existing asset storage. It does not cache tiles. +Cold render latency remains dependent on provider/network and worker capacity. + +Measured locally on the amd64-emulated worker: existing cold jobs took 2–3s each. +After warming, 17 campus assets with six concurrent HTTP clients had median 14ms, +maximum 56ms. One resource's full PNG was 475,026 bytes; the first compact version +was 131,382 bytes (72% smaller) and served in 5ms warm. These are local API +measurements, not production/network or browser-paint benchmarks. The preview +harness now hydrates missing public records into native_render_test only, so +browsing beyond the original 20 fixtures no longer produces repeated 404s. + + +## Pre-generation and geometry reuse + +With `STATIC_MAP_RENDERER_URL` configured, the existing static-map cache primer +(including bridge-sync cache refresh) and Celery resource generation prepare the +384×288 search thumbnail before the larger maps. This happens even when the +larger maps already exist. Existing records need a backfill; deploying the code +alone does not make every first visit warm. + +From `backend/`, with the normal database, Redis and renderer configuration: + +```sh +python scripts/prime_static_map_cache.py --concurrency 1 --limit 250 +``` + +Remove `--limit` for the full catalog, or pass explicit resource IDs. Start with +one concurrent job for the single-slot renderer. The primer also prepares the +existing full-size variants. For thumbnail-only batches, enqueue the registered +Celery task `pregenerate_native_thumbnails` with one argument: a list of at most +100 resource IDs. Complete each batch before submitting the next to avoid a large +queue. The task retries transient failures and reports prepared and missing counts. + +Matching normalized geometries reuse a durable PNG across resource IDs. Reuse +also checks dimensions, render revision, and overlay kind, so thumbnails cannot +replace full-size maps or plain basemaps. Each resource receives its own link in +the existing asset system; no new storage service or schema is required. A Redis +lock keyed by the render signature prevents simultaneous duplicate rendering. +The native resource endpoint also checks for reusable assets before enqueueing. + +Local verification: an unvisited geology search on page 9 contained 20 records +with 13 distinct geometry signatures. Celery prepared the batch in 32.66 seconds +on the emulated amd64 worker. On the subsequent first browser visit, the first +three thumbnails appeared together at 1.304 seconds after navigation, all HTTP +200 with no generation retries. The earlier unprepared search took 5.630–9.698 +seconds for its first three maps. These are local, unthrottled browser timings, +not a production benchmark. A separate new record reused a matching PNG with +renderer HTTP calls deliberately blocked, confirming durable cross-record reuse. diff --git a/docs/r2-visual-assets.md b/docs/r2-visual-assets.md new file mode 100644 index 0000000..557f632 --- /dev/null +++ b/docs/r2-visual-assets.md @@ -0,0 +1,100 @@ +# R2 thumbnails and static maps + +The optional `s3`/`r2` visual store puts generated image bytes in object storage and keeps the existing asset identity, resource links, content type, size, object key, and SHA-256 in PostgreSQL. Existing API URLs remain valid. Once a verified object pointer exists and a public base URL is configured, image endpoints return a 302 to the immutable object URL. Unmigrated images continue to come from PostgreSQL. + +## Infrastructure + +- Bucket: `ogm`, EU jurisdiction, Standard storage. +- S3 endpoint: `https://96d9819fafc99210217fc4e9aac9c73d.eu.r2.cloudflarestorage.com` (without `/ogm`). +- Region: `auto`; prefix: `assets`; active public domain: `assets.geo4lib.app` (minimum TLS 1.2). +- Account token: `ogm-visual-assets-production`, Object Read & Write for `ogm` only, until revoked. +- Credentials are in ignored, mode-0600 `.env.r2.local`; excluded from Docker build contexts. Copy to the deployment secret manager, never source control or build arguments. +- Cloudflare nameservers: `adrian.ns.cloudflare.com`, `felipe.ns.cloudflare.com`. Porkbun remains the registrar. Existing apex and `ogm` A records remain DNS-only at `88.99.148.81`, TTL 600. +- Public reads expose all objects in this bucket. Store only public generated images here; no database dumps, private metadata, or credentials. +- R2 development URL stays disabled. CORS permits anonymous GET/HEAD from any origin and exposes ETag. + +Verified on 2026-10-26: registry delegation points to Cloudflare, the zone and R2 custom domain are active, and the production API docs still return HTTP 200. Cache rule `OGM immutable visual assets` is active for the asset hostname and `/assets/` prefix only. It respects origin Cache-Control, bypasses cache when that header is absent, and does not store HTTP status codes 400 or higher. A 75-byte PNG passed authenticated PUT/GET checksum verification and public HTTPS GET with CORS, one-year immutable Cache-Control, and `CF-Cache-Status: HIT`. Public DNS and normal local HTTPS resolution were verified after propagation. + +Application rollout: MapLibre and R2 were deployed on 2026-10-26 from isolated production-based commit `652922f3`. Native campus and resource maps passed public HTTPS, dimensions, SHA-256, attribution, and cold-cache checks. Historical asset copying, checksum verification, and local cleanup are complete; production now stores image bodies only in R2. Validation: 89 focused tests passed; the additive migration was also run repeatedly against an isolated PostgreSQL 15 instance, preserving old bytes and accepting object-only rows. The temporary database container was removed after verification. + +## Release order + +1. Build and review a release containing the R2 changes. This workspace also contains unrelated ongoing features; do not deploy the entire dirty checkout implicitly. +2. Apply `python -m db.migrations.add_visual_asset_object_storage` with `PYTHONPATH=backend` and the target database URL, **before starting the new application or workers**, even in database mode. It adds nullable pointer columns and permits null bodies; it neither rewrites nor deletes existing bytes. The migration has a five-second lock timeout and can be repeated. +3. Supply the dedicated credentials to both web and worker containers (and mirror publisher source environments). Keep the default database mode until credentials and schema are ready. +4. Start a reversible pilot with: + + ```dotenv + VISUAL_ASSET_DURABLE_STORE=s3 + VISUAL_ASSET_S3_ENDPOINT=https://96d9819fafc99210217fc4e9aac9c73d.eu.r2.cloudflarestorage.com + VISUAL_ASSET_S3_REGION=auto + VISUAL_ASSET_S3_BUCKET=ogm + VISUAL_ASSET_S3_PREFIX=assets + VISUAL_ASSET_S3_RETAIN_DATABASE_BODY=true + VISUAL_ASSET_REDIS_BODY_CACHE_ENABLED=false + VISUAL_ASSET_CACHE_TTL_SECONDS=86400 + VISUAL_ASSET_PUBLIC_BASE_URL= + ``` + + Also supply `VISUAL_ASSET_S3_ACCESS_KEY_ID` and `VISUAL_ASSET_S3_SECRET_ACCESS_KEY` through runtime secrets. An upload must succeed and its metadata must verify before a database pointer is published. Leaving the public URL blank exercises authenticated R2 reads with PostgreSQL fallback during the pilot. +5. Confirm a newly generated thumbnail, resource map, and institution map work with Redis cold. Check pointer, object size/checksum, MIME type, and service logs. Verify the custom-domain TLS certificate and public GET before enabling `VISUAL_ASSET_PUBLIC_BASE_URL=https://assets.geo4lib.app`. +6. Make extensionless objects cacheable with a Cloudflare Cache Rule matching only `http.host eq "assets.geo4lib.app"` and `starts_with(http.request.uri.path, "/assets/")`: eligible for cache, respect origin Cache-Control, do not cache errors. Check a second successful GET returns `CF-Cache-Status: HIT`. Object URLs have one-year immutable cache headers; API redirects use a shorter TTL because legacy IDs can resolve to newly generated bytes. +7. Backfill in bounded batches. With the target database and R2 credentials in the process environment: + + ```sh + PYTHONPATH=backend python backend/scripts/backfill_visual_assets_to_s3.py --limit 100 + PYTHONPATH=backend python backend/scripts/backfill_visual_assets_to_s3.py --apply --limit 100 + ``` + + Dry-run is the default. Apply uploads each image, GETs and checks actual bytes, and conditionally commits a pointer only if the source body is unchanged. Re-running skips rows already copied. Use the returned `last_hash` with `--after` for paging; run again without a cursor at the end to catch concurrent changes. No bodies are removed. A failure is safe to retry, though it may leave an unreferenced immutable object. +8. After the production pilot passes, set `VISUAL_ASSET_S3_RETAIN_DATABASE_BODY=false` to stop retaining bytes on new writes. Remove historical bodies only through the checksum-gated procedure below, after all rows have durable pointers. + +## Retention and disk headroom + +Redis aliases receive a 24-hour TTL. New binary image cache writes are disabled in R2 mode by default. Existing non-expiring keys are unaffected. Redis DB 0 contains the Celery broker, and DB 1 also contains task results: never flush either database or apply indiscriminate eviction. Reclaim only positively identified visual cache keys whose durable copies have been verified, in small batches. Persisted RDB/AOF files may remain large until a planned rewrite; allow space for old and new files to coexist. + +Do not expire R2 objects merely because they are old: current database links and published mirror manifests may still reference them. A future garbage collector should inventory references, include mirror releases, wait at least 30 days after objects become unreferenced, dry-run first, and delete only a reviewed manifest. Keep the seven-day incomplete multipart upload abort rule. + +Removing PostgreSQL bytea values creates reusable database space but does not guarantee filesystem space is returned. Plan any table rewrite or repack separately with sufficient temporary disk and a lock/downtime budget. Avoid `VACUUM FULL` during reindexing. Keep production disk alerts and preflight checks independently of the image migration; Elasticsearch, logs, backups, and Redis persistence can still consume disk. + +## Rollback + +During the dual-write pilot, clear `VISUAL_ASSET_PUBLIC_BASE_URL` to stop redirects; set the durable store back to `database` only while every needed body is still present. Once any body is null, keep S3 reads enabled or restore those bytes before database-only rollback. Do not repoint the configured bucket or endpoint: persisted keys assume the same object store. Disabling redirects cannot instantly retract already cached immutable objects. + +For DNS rollback, Porkbun's previous nameservers were `curitiba.ns.porkbun.com`, `fortaleza.ns.porkbun.com`, `maceio.ns.porkbun.com`, and `salvador.ns.porkbun.com`; the original records were retained there. Nameserver rollback also propagates gradually. + +## Verified cleanup procedure + +The copy-only utility bounds concurrency and shares a verified upload among rows with identical bytes. After a failed run, resume without `--after` so unfinished rows are included. Original bodies are retained. + +Before cleanup, export the schema, generated asset metadata (excluding bodies), and resource links to a protected directory and copy them off the production host. Never place these backups in the public bucket. Switch all running application roles to `VISUAL_ASSET_S3_RETAIN_DATABASE_BODY=false` and stop dual-write canaries. + +Run `verify_visual_assets_r2.py --manifest /protected/verified.jsonl --prune-postgres --workers 64` with the production environment. It refuses pruning while any pointer is missing, GETs and checksum-verifies every remote image, compares the existing local bytes, and fsyncs the audit manifest before each bounded cleanup transaction. Conditional updates protect concurrent changes. PostgreSQL uses the observed `xmin` row version, so even a concurrent body-only update prevents deletion without resending large image bodies in SQL; other database backends use byte equality. This guard was verified against a real concurrent update in an isolated PostgreSQL 15 instance. Metadata and resource links remain. + +Then run `prune_verified_visual_redis.py --manifest /protected/verified.jsonl` for a dry run; add `--apply` after reviewing the counts. It touches only known binary image namespaces in DB 1 whose bytes match a verified object digest and size. Lua compare-and-UNLINK preserves values changed concurrently. It leaves broker queues, task results, image types, aliases, and unmatched data untouched. + +Check filesystem headroom before ordinary PostgreSQL VACUUM and Redis persistence compaction. Run BGSAVE and BGREWRITEAOF one at a time and confirm persistence status before proceeding. Record actual filesystem space recovered, separately from logical body bytes released. + +During DNS propagation, the old Porkbun zone also has an `assets` A record at `104.21.18.253` (TTL 600), matching Cloudflare's active edge. Cloudflare remains authoritative; this compatibility record serves clients with a cached old delegation. + +## Production verification updates (2026-10-26) + +The final application configuration was deployed to worker, web, and cron from `71c0b184532b4e2fd4a64ca3abcac005384d6ed7`. A newly generated Indiana campus map (zoom 14, 640×320, 170141 bytes) returned HTTP 200 through the public asset domain with SHA-256 `7387541d7b960d5cf14d0478b0392ac45a0175534a1b67f223d52239081c2a70`; its PostgreSQL body is NULL. The private dual-write canary is stopped. + +Cloudflare configuration rule `OGM public asset client compatibility` (`a6599194233844b0845e50dbc607463c`) disables only Browser Integrity Check for GET/HEAD on `assets.geo4lib.app` under `/assets/`. This approved exception fixes Cloudflare error 1010 for Python urllib downloads. Other hosts, paths, methods, and security settings are unchanged; the urllib request was verified afterward. + +Release PR #25 passed all three GitHub checks for the application and cleanup code. Production delivery was rechecked after PostgreSQL and Redis cleanup for all eight asset kinds, including HTTP 200, SHA-256, MIME type, R2 redirects, and CORS. Celery responded to ping and Elasticsearch was green without a disk write block. + +During the 2026-10-26 cleanup, Redis's automatic snapshot temporarily required a second ~15 GB file. It completed successfully. Maintenance then temporarily set runtime `save` to an empty string while retaining the fresh RDB and healthy AOF (`appendfsync everysec`). After successful BGSAVE and BGREWRITEAOF, the original `save` schedule (`3600 1 300 100 60 10000`) was restored and verified. AOF remained enabled with `appendfsync everysec` throughout. No persistent configuration rewrite was made. + +## Completed migration and reclaim (2026-10-27 UTC) + +- All 261,905 asset records have R2 pointers, representing 255,240 distinct immutable objects and 29,572,717,107 logical image bytes. No PostgreSQL image bodies remain; all metadata and resource links are preserved. +- Before clearing bodies, a protected snapshot exported 261,905 metadata rows and 824,809 resource links. The approved off-server copy is in the release worktree's ignored, mode-0700 `.kamal/backup-20261026/` directory; files are mode 0600. Both CSV row counts and SHA-256 values were checked, along with the current schema checksum. +- The completed audit contains all 261,905 unique asset IDs. Its SHA-256 is `607ea7e6f09f2adb6e81fa6e78c1bdfe1981ae1bd511be9a3437838ac36149b6`. A copy is in the private backup directory. This audit and the private backups must not be published in the image bucket or committed to Git. +- Redis DB 1 cleanup removed 249,661 matching image values (16,263,095,154 bytes). Seven unmatched values totaling 981,747 bytes were retained. DB 0's 662,722 keys were unchanged; DB 1 decreased by exactly 249,661 keys. Task results, image types, aliases, and the Celery broker were preserved. +- Redis used memory fell to about 231 MiB. Its snapshot and AOF each compacted to about 119 MiB; both persistence operations completed successfully in three seconds. +- Ordinary VACUUM removed 3,982,962 empty TOAST pages without a table rewrite. The complete asset relation is now 606,568,448 bytes (about 579 MiB). +- Final measured filesystem availability: 106,235,396,096 bytes (98.9 GiB), 77% used, versus roughly 36 GiB available immediately before cleanup. This is approximately 63 GiB of additional headroom. + +R2 is now required for existing images. Database-only rollback requires restoring the verified image bytes first; retain the R2-compatible application release and credentials. New image bodies do not accumulate in PostgreSQL or Redis, aliases receive a 24-hour TTL, and no age-only deletion policy was applied to R2 objects.