wrolpi/modules/map/lib.py
Roland b8a532c8d9
Key changes:
- Add MapLibre GL JS viewer with light/dark themes, terrain layer,
  and embedded static map page for coordinate sharing
- Add map pin support with persistent map_pins.yaml config
- Add map region subscriptions and downloader with progress tracking,
  sourcing pre-extracted PMTiles from a cold CDN
- Add map export to PNG and GPS coordinate copy via context menu
- Add MapViewer React component alongside reworked Map page
- Add map catalog and region-building script (build_map_regions.py)
- Remove OSM import/render scripts, leaflet files, Apache configs,
  renderd service, and osm-map-dumper Docker container
- Update Caddyfile to serve PMTiles and map static assets
- Update build/install/repair scripts for Protomaps dependencies
- Drop map_file table, add/drop map_subscription table (alembic)
- Adding button to skip primary drive mounting during onboarding.
2026-04-04 13:48:19 -06:00

169 lines
5.9 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,
))
return files
def delete_pmtiles_file(filename: str) -> bool:
"""Delete a PMTiles file from the map directory. Returns True if the file was deleted."""
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}')
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.sub_downloader = 'map_extract'
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)