Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions viseron/components/webserver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import asyncio
import logging
import os
import secrets
import threading
from typing import TYPE_CHECKING
Expand All @@ -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

Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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__)
Expand All @@ -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(),
{
Expand Down Expand Up @@ -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)
Expand All @@ -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."""
Expand All @@ -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]:
Expand All @@ -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")
Expand All @@ -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)
Expand Down
118 changes: 118 additions & 0 deletions viseron/components/webserver/api/v1/publicimage.py
Original file line number Diff line number Diff line change
@@ -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"
)
12 changes: 12 additions & 0 deletions viseron/components/webserver/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand All @@ -27,6 +31,13 @@
"Enable debug mode for the webserver. <b>WARNING: Dont have this enabled in"
" production as it weakens security.</b>"
)
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"
Expand Down Expand Up @@ -70,3 +81,4 @@
WEBSOCKET_COMMANDS = "websocket_commands"
WEBSOCKET_CONNECTIONS = "websocket_connections"
DOWNLOAD_TOKENS = "download_tokens"
PUBLIC_IMAGE_TOKENS = "public_image_tokens"
12 changes: 12 additions & 0 deletions viseron/components/webserver/public_image_token.py
Original file line number Diff line number Diff line change
@@ -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
Loading