wrolpi/modules/map/lib.py
Roland c726654de9
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
Map search added to global search. Preview cards are displayed for all results in new Map tab.
Map viewer has simple search.

Map search databases can be downloaded from CDN, or built locally. Map searches can be built offline, but will not have enriched things like state names downloaded from Wikidata.
2026-04-13 12:17:08 -06:00

176 lines
6.3 KiB
Python

import json
import tempfile
from pathlib import Path
from typing import List, Dict
from sqlalchemy.orm import Session
from modules.map.catalog import MAP_REGIONS, MAP_REGIONS_BY_NAME, MAP_REGIONS_BY_REGION, MANIFEST_URL
from wrolpi.common import get_media_directory, walk, logger, get_wrolpi_config, aiohttp_get, verify_gpg_signature
from wrolpi.downloader import DownloadFrequency, Download, save_downloads_config, download_manager
logger = logger.getChild(__name__)
def get_map_directory() -> Path:
map_directory = get_media_directory() / get_wrolpi_config().map_destination
if not map_directory.is_dir():
map_directory.mkdir(parents=True)
return map_directory
def get_pmtiles_files() -> List[dict]:
"""Find all .pmtiles files in the map directory and return their metadata."""
map_directory = get_map_directory()
paths = walk(map_directory)
files = []
for path in sorted(paths):
if path.is_file() and path.suffix == '.pmtiles':
stat = path.stat()
files.append(dict(
name=path.name,
path=str(path.relative_to(get_media_directory())),
size=stat.st_size,
mtime=stat.st_mtime,
has_search_index=path.with_suffix('.search.db').is_file(),
))
return files
def delete_pmtiles_file(filename: str) -> bool:
"""Delete a PMTiles file from the map directory. Returns True if the file was deleted.
Also deletes the companion .search.db file if it exists."""
map_directory = get_map_directory()
# Prevent path traversal.
path = (map_directory / filename).resolve()
if not path.is_relative_to(map_directory.resolve()):
raise ValueError(f'Invalid filename: {filename}')
if path.is_file() and path.suffix == '.pmtiles':
path.unlink()
logger.warning(f'Deleted PMTiles file: {path}')
# Also delete companion search index.
search_db = path.with_suffix('.search.db')
if search_db.is_file():
search_db.unlink()
logger.warning(f'Deleted search index: {search_db}')
return True
return False
def get_map_catalog() -> List[dict]:
"""Return the list of available map regions."""
return MAP_REGIONS.copy()
def _get_map_download(session: Session) -> Download | None:
"""Find the single recurring map download, if it exists."""
return session.query(Download).filter_by(url=MANIFEST_URL, downloader='map_catalog').one_or_none()
def _get_or_create_map_download(session: Session) -> Download:
"""Get or create the single recurring map download."""
download = _get_map_download(session)
if download:
return download
download = download_manager.get_or_create_download(session, MANIFEST_URL, reset_attempts=True,
override_skip=True)
download.downloader = 'map_catalog'
download.frequency = DownloadFrequency.days180
download.settings = {'regions': []}
download.location = '/map/manage'
download.attempts = 0
session.flush([download])
return download
def get_map_subscriptions(session: Session) -> List[dict]:
"""Return the list of subscribed regions."""
regions = []
download = _get_map_download(session)
if download and download.settings:
regions.extend(download.settings.get('regions', []))
return regions
async def subscribe(session: Session, name: str, region: str):
"""Subscribe to a map region."""
if name not in MAP_REGIONS_BY_NAME:
raise ValueError(f'{name!r} is not a valid map region name')
region_info = MAP_REGIONS_BY_NAME[name]
if region != region_info['region']:
raise ValueError(f'Region {region!r} does not match name {name!r}')
download = _get_or_create_map_download(session)
regions = list(download.settings.get('regions', []))
# Don't add duplicates.
if any(r['region'] == region for r in regions):
return
entry = {'region': region}
if region_info.get('terrain'):
entry['terrain'] = True
regions.append(entry)
download.settings = {**download.settings, 'regions': regions}
download.renew(reset_attempts=True)
session.commit()
save_downloads_config.activate_switch()
async def unsubscribe(session: Session, region: str):
"""Remove a region from the subscriptions."""
download = _get_map_download(session)
if not download:
raise ValueError(f'No map subscriptions exist')
regions = [r for r in download.settings.get('regions', []) if r['region'] != region]
if regions:
download.settings = {**download.settings, 'regions': regions}
else:
# No more subscriptions — delete the download.
session.delete(download)
session.commit()
save_downloads_config.activate_switch()
async def fetch_manifest(url: str = None) -> dict:
"""Fetch the map manifest JSON from the CDN and verify its GPG signature."""
url = url or MANIFEST_URL
sig_url = f'{url}.sig'
# Fetch manifest.
async with aiohttp_get(url, timeout=30) as response:
if response.status != 200:
raise RuntimeError(f'Failed to fetch manifest: HTTP {response.status}')
manifest_bytes = await response.content.read()
# Fetch detached signature.
try:
async with aiohttp_get(sig_url, timeout=30) as response:
if response.status != 200:
raise RuntimeError(f'Failed to fetch manifest signature: HTTP {response.status}')
signature_bytes = await response.content.read()
except Exception as e:
logger.warning(f'Could not fetch manifest signature from {sig_url}: {e}')
raise RuntimeError(f'Manifest signature not available: {e}')
# Write to temp files and verify signature against on-disk files.
with tempfile.TemporaryDirectory() as tmpdir:
manifest_path = Path(tmpdir) / 'manifest.json'
sig_path = Path(tmpdir) / 'manifest.json.sig'
manifest_path.write_bytes(manifest_bytes)
sig_path.write_bytes(signature_bytes)
if not await verify_gpg_signature(manifest_path, sig_path):
raise RuntimeError('Manifest GPG signature verification failed — file may be tampered with')
logger.info('Manifest GPG signature verified successfully')
return json.loads(manifest_bytes)