diff --git a/viseron/components/webserver/__init__.py b/viseron/components/webserver/__init__.py index 3ded06de5..4cc0818d4 100644 --- a/viseron/components/webserver/__init__.py +++ b/viseron/components/webserver/__init__.py @@ -3,6 +3,7 @@ import asyncio import logging +import os import secrets import threading from typing import TYPE_CHECKING @@ -15,6 +16,7 @@ from viseron.components.webserver.auth import Auth from viseron.const import DEFAULT_PORT, VISERON_SIGNAL_SHUTDOWN from viseron.exceptions import ComponentNotReady +from viseron.helpers import utcnow from viseron.helpers.storage import Storage from viseron.helpers.validators import CoerceNoneToDict, Deprecated @@ -27,9 +29,12 @@ CONFIG_HOURS, CONFIG_MINUTES, CONFIG_PORT, + CONFIG_PUBLIC_BASE_URL, + CONFIG_PUBLIC_URL_EXPIRY_HOURS, CONFIG_SESSION_EXPIRY, DEFAULT_COMPONENT, DEFAULT_DEBUG, + DEFAULT_PUBLIC_URL_EXPIRY_HOURS, DEFAULT_SESSION_EXPIRY, DESC_AUTH, DESC_COMPONENT, @@ -38,8 +43,12 @@ DESC_HOURS, DESC_MINUTES, DESC_PORT, + DESC_PUBLIC_BASE_URL, + DESC_PUBLIC_URL_EXPIRY_HOURS, DESC_SESSION_EXPIRY, DOWNLOAD_TOKENS, + PUBLIC_IMAGE_TOKENS, + PUBLIC_IMAGES_PATH, WEBSERVER_STORAGE_KEY, WEBSOCKET_COMMANDS, WEBSOCKET_CONNECTIONS, @@ -68,6 +77,7 @@ if TYPE_CHECKING: from viseron import Viseron from viseron.components.webserver.download_token import DownloadToken + from viseron.components.webserver.public_image_token import PublicImageToken LOGGER = logging.getLogger(__name__) @@ -86,6 +96,15 @@ vol.Optional( CONFIG_DEBUG, default=DEFAULT_DEBUG, description=DESC_DEBUG ): bool, + vol.Optional( + CONFIG_PUBLIC_BASE_URL, + description=DESC_PUBLIC_BASE_URL, + ): str, + vol.Optional( + CONFIG_PUBLIC_URL_EXPIRY_HOURS, + description=DESC_PUBLIC_URL_EXPIRY_HOURS, + default=DEFAULT_PUBLIC_URL_EXPIRY_HOURS, + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=744)), vol.Optional(CONFIG_AUTH, description=DESC_AUTH): vol.All( CoerceNoneToDict(), { @@ -222,6 +241,13 @@ def __init__(self, vis: Viseron, config) -> None: vis.data[WEBSOCKET_COMMANDS] = {} vis.data[WEBSOCKET_CONNECTIONS] = [] vis.data[DOWNLOAD_TOKENS] = {} + vis.data[PUBLIC_IMAGE_TOKENS] = {} + + # Create persistent directory for public images + os.makedirs(PUBLIC_IMAGES_PATH, exist_ok=True) + + # Clean up orphaned public images on startup + self._cleanup_orphaned_public_images() self._asyncio_ioloop = asyncio.new_event_loop() asyncio.set_event_loop(self._asyncio_ioloop) @@ -241,6 +267,93 @@ def __init__(self, vis: Viseron, config) -> None: raise error self._ioloop = tornado.ioloop.IOLoop.current() + # Schedule periodic cleanup of expired public images (every hour) + self._cleanup_task: asyncio.Task | None = None + + def _cleanup_orphaned_public_images(self): + """Clean up orphaned public images on startup (files older than max expiry).""" + try: + max_age_seconds = ( + self.public_url_expiry_hours * 3600 + ) # Convert hours to seconds + now = utcnow().timestamp() + cleaned_count = 0 + + # Scan all files in the public images directory + if os.path.exists(PUBLIC_IMAGES_PATH): + for filename in os.listdir(PUBLIC_IMAGES_PATH): + file_path = os.path.join(PUBLIC_IMAGES_PATH, filename) + + # Only process files (not directories) + if not os.path.isfile(file_path): + continue + + # Check file age + try: + file_mtime = os.path.getmtime(file_path) + file_age_seconds = now - file_mtime + + # If file is older than max expiry, delete it + if file_age_seconds > max_age_seconds: + os.remove(file_path) + cleaned_count += 1 + LOGGER.debug( + f"Deleted orphaned public image: {file_path} " + f"(age: {file_age_seconds / 3600:.1f} hours)" + ) + except OSError as e: + LOGGER.error(f"Failed to process file {file_path}: {e}") + + if cleaned_count > 0: + LOGGER.info( + f"Cleaned up {cleaned_count} orphaned public image(s) on startup" + ) + except Exception as e: # pylint: disable=broad-except + LOGGER.error(f"Error during orphaned public images cleanup: {e}") + + def _cleanup_expired_public_images(self): + """Clean up expired public image files.""" + now = utcnow() + expired_tokens = [] + + # Find expired tokens + for token, public_image_token in self._vis.data[PUBLIC_IMAGE_TOKENS].items(): + if public_image_token.expires_at < now: + expired_tokens.append(token) + # Delete the file if it exists + if os.path.exists(public_image_token.file_path): + try: + os.remove(public_image_token.file_path) + LOGGER.debug( + "Deleted expired public image: %s", + public_image_token.file_path, + ) + except OSError as e: + LOGGER.error( + f"Failed to delete expired public image " + f"{public_image_token.file_path}: {e}" + ) + + # Remove expired tokens from memory + for token in expired_tokens: + del self._vis.data[PUBLIC_IMAGE_TOKENS][token] + + if expired_tokens: + LOGGER.info(f"Cleaned up {len(expired_tokens)} expired public image(s)") + + async def _periodic_cleanup(self): + """Run periodic cleanup of expired public images.""" + while True: + try: + await asyncio.sleep(3600) # Run every hour + await self._asyncio_ioloop.run_in_executor( + None, self._cleanup_expired_public_images + ) + except asyncio.CancelledError: + break + except Exception as e: # pylint: disable=broad-except + LOGGER.error(f"Error during public images cleanup: {e}") + @property def auth(self): """Return auth.""" @@ -256,6 +369,23 @@ def download_tokens(self) -> dict[str, DownloadToken]: """Return download tokens.""" return self._vis.data[DOWNLOAD_TOKENS] + @property + def public_image_tokens(self) -> dict[str, PublicImageToken]: + """Return public image tokens.""" + return self._vis.data[PUBLIC_IMAGE_TOKENS] + + @property + def public_base_url(self) -> str | None: + """Return public base URL.""" + return self._config.get(CONFIG_PUBLIC_BASE_URL) + + @property + def public_url_expiry_hours(self) -> int: + """Return public URL expiry hours.""" + return self._config.get( + CONFIG_PUBLIC_URL_EXPIRY_HOURS, DEFAULT_PUBLIC_URL_EXPIRY_HOURS + ) + def register_websocket_command(self, handler) -> None: """Register a websocket command.""" if handler.command in self._vis.data[WEBSOCKET_COMMANDS]: @@ -266,6 +396,9 @@ def register_websocket_command(self, handler) -> None: def run(self) -> None: """Start ioloop.""" + # Start periodic cleanup task + self._cleanup_task = self._asyncio_ioloop.create_task(self._periodic_cleanup()) + self._ioloop.start() self._ioloop.close(True) LOGGER.debug("IOLoop closed") @@ -280,6 +413,10 @@ def stop(self) -> None: shutdown_event = threading.Event() async def shutdown(): + # Cancel cleanup task + if self._cleanup_task and not self._cleanup_task.done(): + self._cleanup_task.cancel() + connection: WebSocketHandler for connection in self._vis.data[WEBSOCKET_CONNECTIONS]: LOGGER.debug("Closing websocket connection, %s", connection) diff --git a/viseron/components/webserver/api/v1/publicimage.py b/viseron/components/webserver/api/v1/publicimage.py new file mode 100644 index 000000000..69040ccb4 --- /dev/null +++ b/viseron/components/webserver/api/v1/publicimage.py @@ -0,0 +1,118 @@ +"""Public image API Handler.""" + +import logging +import os +from http import HTTPStatus + +import voluptuous as vol + +from viseron.components.webserver.api.handlers import BaseAPIHandler +from viseron.components.webserver.const import PUBLIC_IMAGES_PATH +from viseron.helpers import utcnow + +LOGGER = logging.getLogger(__name__) + +ALLOWED_EXTENSIONS = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", +} + + +class PublicimageAPIHandler(BaseAPIHandler): + """Handler for API calls related to public image access.""" + + routes = [ + { + "path_pattern": r"/publicimage", + "supported_methods": ["GET"], + "method": "get_public_image", + "requires_auth": False, + "request_arguments_schema": vol.Schema( + { + vol.Required("token"): str, + }, + ), + }, + ] + + async def get_public_image(self) -> None: + """Get a public image using a token.""" + token = self.request_arguments["token"] + + # Try to get token from memory first + public_image_token = self._webserver.public_image_tokens.get(token) + + # If token not in memory, try to find file on disk (survives restart) + if not public_image_token: + # Construct expected file path + file_path = os.path.join(PUBLIC_IMAGES_PATH, f"{token}.jpg") + + # Check if file exists on disk + if not await self.run_in_executor(os.path.exists, file_path): + LOGGER.debug(f"Token not found in memory and file not on disk: {token}") + self.response_error(HTTPStatus.NOT_FOUND, reason="Token not found") + return + + # File exists but token not in memory - happens after restart + # We can't validate expiration, but we serve the file anyway + # The cleanup task will eventually remove expired files + LOGGER.debug( + f"Token {token} not in memory but file exists on disk, serving anyway" + ) + else: + # Token is in memory, check if expired + now = await self.run_in_executor(utcnow) + if public_image_token.expires_at < now: + LOGGER.debug(f"Token expired: {token}") + # Clean up expired token and file + file_path = public_image_token.file_path + if await self.run_in_executor(os.path.exists, file_path): + await self.run_in_executor(os.remove, file_path) + del self._webserver.public_image_tokens[token] + self.response_error(HTTPStatus.UNAUTHORIZED, reason="Token expired") + return + + file_path = public_image_token.file_path + + # Check if file exists + if not await self.run_in_executor(os.path.exists, file_path): + LOGGER.debug(f"File not found: {file_path}") + # Clean up invalid token + del self._webserver.public_image_tokens[token] + self.response_error(HTTPStatus.NOT_FOUND, reason="File not found") + return + + # Get file extension and check if allowed + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + if ext not in ALLOWED_EXTENSIONS: + LOGGER.warning(f"Unsupported file type: {ext}") + self.response_error( + HTTPStatus.BAD_REQUEST, reason=f"Unsupported file type: {ext}" + ) + return + + try: + # Read the image file + def read_image(): + with open(file_path, "rb") as f: + return f.read() + + image_data = await self.run_in_executor(read_image) + + # Set appropriate headers + self.set_header("Content-Type", ALLOWED_EXTENSIONS[ext]) + self.set_header("Cache-Control", "public, max-age=3600") + self.set_header("Content-Length", len(image_data)) + + # Write the image data and finish + self.write(image_data) + await self.finish() + + except Exception as e: # pylint: disable=broad-except + LOGGER.error(f"Failed to serve public image: {str(e)}") + self.response_error( + HTTPStatus.INTERNAL_SERVER_ERROR, reason="Failed to serve image" + ) diff --git a/viseron/components/webserver/const.py b/viseron/components/webserver/const.py index b27b13908..703053c73 100644 --- a/viseron/components/webserver/const.py +++ b/viseron/components/webserver/const.py @@ -12,13 +12,17 @@ ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) DOWNLOAD_PATH = "/tmp/downloads" +PUBLIC_IMAGES_PATH = "/config/public_images" # CONFIG_SCHEMA constants CONFIG_PORT = "port" CONFIG_DEBUG = "debug" +CONFIG_PUBLIC_BASE_URL = "public_base_url" +CONFIG_PUBLIC_URL_EXPIRY_HOURS = "public_url_expiry_hours" DEFAULT_COMPONENT: Final = None DEFAULT_DEBUG = False +DEFAULT_PUBLIC_URL_EXPIRY_HOURS = 24 DESC_COMPONENT = "Webserver configuration." @@ -27,6 +31,13 @@ "Enable debug mode for the webserver. WARNING: Dont have this enabled in" " production as it weakens security." ) +DESC_PUBLIC_BASE_URL = ( + "Public base URL for Viseron (e.g., https://viseron.example.com). " + "Used for generating public links accessible from outside your network." +) +DESC_PUBLIC_URL_EXPIRY_HOURS = ( + "Number of hours before public image URLs expire (default: 24, max: 744 = 31 days)." +) # Auth constants CONFIG_AUTH = "auth" @@ -70,3 +81,4 @@ WEBSOCKET_COMMANDS = "websocket_commands" WEBSOCKET_CONNECTIONS = "websocket_connections" DOWNLOAD_TOKENS = "download_tokens" +PUBLIC_IMAGE_TOKENS = "public_image_tokens" diff --git a/viseron/components/webserver/public_image_token.py b/viseron/components/webserver/public_image_token.py new file mode 100644 index 000000000..9ccda1aff --- /dev/null +++ b/viseron/components/webserver/public_image_token.py @@ -0,0 +1,12 @@ +"""Public image token dataclass.""" +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class PublicImageToken: + """Public image token dataclass.""" + + file_path: str + token: str + expires_at: datetime