""" Public (no-auth) endpoints for CloudFlash and feature gate checks. """ from fastapi import APIRouter, HTTPException from fastapi.responses import Response from pydantic import BaseModel from typing import List, Optional from settings.public_features_service import get_public_features from firmware.service import list_firmware from utils.nvs_generator import generate as generate_nvs from manufacturing.service import get_device_by_sn from shared.exceptions import NotFoundError router = APIRouter(prefix="/api/public", tags=["public"]) # ── Feature gate ────────────────────────────────────────────────────────────── class CloudFlashStatus(BaseModel): enabled: bool @router.get("/cloudflash/status", response_model=CloudFlashStatus) async def cloudflash_status(): """Returns whether the CloudFlash public page is currently enabled.""" settings = get_public_features() return CloudFlashStatus(enabled=settings.cloudflash_enabled) def _require_cloudflash_enabled(): """Raises 403 if CloudFlash is disabled.""" settings = get_public_features() if not settings.cloudflash_enabled: raise HTTPException(status_code=403, detail="CloudFlash is currently disabled.") # ── Public firmware list ─────────────────────────────────────────────────────── class PublicFirmwareOption(BaseModel): hw_type: str hw_type_label: str channel: str version: str download_url: str HW_TYPE_LABELS = { "vesper": "Vesper", "vesper_plus": "Vesper Plus", "vesper_pro": "Vesper Pro", "agnus": "Agnus", "agnus_mini": "Agnus Mini", "chronos": "Chronos", "chronos_pro": "Chronos Pro", } @router.get("/cloudflash/firmware", response_model=List[PublicFirmwareOption]) async def list_public_firmware(): """ Returns all available firmware options (is_latest=True, non-bespoke, stable channel only). No authentication required — used by the public CloudFlash page. """ _require_cloudflash_enabled() all_fw = list_firmware() options = [] for fw in all_fw: if not fw.is_latest: continue if fw.hw_type == "bespoke": continue if fw.channel != "stable": continue options.append(PublicFirmwareOption( hw_type=fw.hw_type, hw_type_label=HW_TYPE_LABELS.get(fw.hw_type, fw.hw_type.replace("_", " ").title()), channel=fw.channel, version=fw.version, download_url=f"/api/firmware/{fw.hw_type}/{fw.channel}/{fw.version}/firmware.bin", )) # Sort by hw_type label options.sort(key=lambda x: x.hw_type_label) return options # ── Public serial number validation ────────────────────────────────────────── class SerialValidationResult(BaseModel): valid: bool hw_type: Optional[str] = None hw_type_label: Optional[str] = None hw_version: Optional[str] = None @router.get("/cloudflash/validate-serial/{serial_number}", response_model=SerialValidationResult) async def validate_serial(serial_number: str): """ Check whether a serial number exists in the device database. Returns hw_type info if found so the frontend can confirm it matches the user's selection. No sensitive device data is returned. """ _require_cloudflash_enabled() sn = serial_number.strip().upper() try: device = get_device_by_sn(sn) return SerialValidationResult( valid=True, hw_type=device.hw_type, hw_type_label=HW_TYPE_LABELS.get(device.hw_type, device.hw_type.replace("_", " ").title()), hw_version=device.hw_version, ) except Exception: return SerialValidationResult(valid=False) # ── Public NVS generation ───────────────────────────────────────────────────── class NvsRequest(BaseModel): serial_number: str hw_type: str hw_revision: str @router.post("/cloudflash/nvs.bin") async def generate_public_nvs(body: NvsRequest): """ Generate an NVS binary for a given serial number + hardware info. No authentication required — used by the public CloudFlash page for Full Wipe flash. The serial number is provided by the user (they read it from the sticker on their device). """ _require_cloudflash_enabled() sn = body.serial_number.strip().upper() if not sn: raise HTTPException(status_code=422, detail="Serial number is required.") hw_type = body.hw_type.strip().lower() hw_revision = body.hw_revision.strip() if not hw_type or not hw_revision: raise HTTPException(status_code=422, detail="hw_type and hw_revision are required.") try: nvs_bytes = generate_nvs( serial_number=sn, hw_family=hw_type, hw_revision=hw_revision, ) except Exception as e: raise HTTPException(status_code=500, detail=f"NVS generation failed: {str(e)}") return Response( content=nvs_bytes, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{sn}_nvs.bin"'}, ) # ── Public flash assets (bootloader + partitions) ───────────────────────────── @router.get("/cloudflash/{hw_type}/bootloader.bin") async def get_public_bootloader(hw_type: str): """ Serve the bootloader binary for a given hw_type. No authentication required — used by the public CloudFlash page. """ _require_cloudflash_enabled() import os from config import settings as cfg from pathlib import Path asset_path = Path(cfg.flash_assets_storage_path) / hw_type / "bootloader.bin" if not asset_path.exists(): raise HTTPException(status_code=404, detail=f"Bootloader not found for {hw_type}.") return Response( content=asset_path.read_bytes(), media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="bootloader_{hw_type}.bin"'}, ) @router.get("/cloudflash/{hw_type}/partitions.bin") async def get_public_partitions(hw_type: str): """ Serve the partition table binary for a given hw_type. No authentication required — used by the public CloudFlash page. """ _require_cloudflash_enabled() import os from config import settings as cfg from pathlib import Path asset_path = Path(cfg.flash_assets_storage_path) / hw_type / "partitions.bin" if not asset_path.exists(): raise HTTPException(status_code=404, detail=f"Partition table not found for {hw_type}.") return Response( content=asset_path.read_bytes(), media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="partitions_{hw_type}.bin"'}, )