Implement configuration management, drag-and-drop functionality, and logging utilities for WebDrop Bridge

This commit is contained in:
claudi 2026-01-28 11:21:11 +01:00
parent 04ef84cf9a
commit 6bef2f6119
9 changed files with 1154 additions and 0 deletions

View file

@ -0,0 +1,143 @@
"""Configuration management for WebDrop Bridge application."""
import logging
import os
from dataclasses import dataclass
from pathlib import Path
from typing import List
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
class ConfigurationError(Exception):
"""Raised when configuration is invalid."""
pass
@dataclass
class Config:
"""Application configuration loaded from environment variables.
Attributes:
app_name: Application display name
app_version: Application version (semantic versioning)
log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
log_file: Optional log file path
allowed_roots: List of whitelisted root directories for file access
webapp_url: URL to load in embedded web application
window_width: Initial window width in pixels
window_height: Initial window height in pixels
enable_logging: Whether to write logs to file
Raises:
ConfigurationError: If configuration values are invalid
"""
app_name: str
app_version: str
log_level: str
log_file: Path | None
allowed_roots: List[Path]
webapp_url: str
window_width: int
window_height: int
enable_logging: bool
@classmethod
def from_env(cls, env_file: str | None = None) -> "Config":
"""Load configuration from environment variables.
Looks for a .env file in the current working directory and loads
environment variables from it. All unset variables use sensible defaults.
Args:
env_file: Optional path to .env file to load. If None, loads default .env
Returns:
Config: Configured instance with environment values or defaults
Raises:
ConfigurationError: If any configured value is invalid
"""
# Load .env file if it exists
load_dotenv(env_file)
# Extract and validate configuration values
app_name = os.getenv("APP_NAME", "WebDrop Bridge")
app_version = os.getenv("APP_VERSION", "1.0.0")
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
log_file_str = os.getenv("LOG_FILE", "logs/webdrop_bridge.log")
allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public")
webapp_url = os.getenv("WEBAPP_URL", "file:///./webapp/index.html")
window_width = int(os.getenv("WINDOW_WIDTH", "1024"))
window_height = int(os.getenv("WINDOW_HEIGHT", "768"))
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
# Validate log level
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if log_level not in valid_levels:
raise ConfigurationError(
f"Invalid LOG_LEVEL: {log_level}. "
f"Must be one of: {', '.join(valid_levels)}"
)
# Validate and parse allowed roots
try:
allowed_roots = []
for p in allowed_roots_str.split(","):
root_path = Path(p.strip()).resolve()
if not root_path.exists():
raise ConfigurationError(
f"Allowed root '{p.strip()}' does not exist"
)
if not root_path.is_dir():
raise ConfigurationError(
f"Allowed root '{p.strip()}' is not a directory"
)
allowed_roots.append(root_path)
except ConfigurationError:
raise
except (ValueError, OSError) as e:
raise ConfigurationError(
f"Invalid ALLOWED_ROOTS: {allowed_roots_str}. Error: {e}"
) from e
# Validate window dimensions
if window_width <= 0 or window_height <= 0:
raise ConfigurationError(
f"Window dimensions must be positive: "
f"{window_width}x{window_height}"
)
# Create log file path if logging enabled
log_file = None
if enable_logging:
log_file = Path(log_file_str).resolve()
# Validate webapp URL is not empty
if not webapp_url:
raise ConfigurationError("WEBAPP_URL cannot be empty")
return cls(
app_name=app_name,
app_version=app_version,
log_level=log_level,
log_file=log_file,
allowed_roots=allowed_roots,
webapp_url=webapp_url,
window_width=window_width,
window_height=window_height,
enable_logging=enable_logging,
)
def __repr__(self) -> str:
"""Return developer-friendly representation."""
return (
f"Config(app={self.app_name} v{self.app_version}, "
f"log_level={self.log_level}, "
f"allowed_roots={len(self.allowed_roots)} dirs, "
f"window={self.window_width}x{self.window_height})"
)

View file

@ -0,0 +1,106 @@
"""Qt widget for intercepting drag events and initiating native drag operations."""
from pathlib import Path
from typing import List, Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QDrag, QMimeData
from PySide6.QtWidgets import QWidget
from webdrop_bridge.core.validator import PathValidator, ValidationError
class DragInterceptor(QWidget):
"""Widget that handles drag initiation for file paths.
Intercepts drag events from web content and initiates native Qt drag
operations, allowing files to be dragged from web content to native
applications.
Signals:
drag_started: Emitted when a drag operation begins successfully
drag_failed: Emitted when drag initiation fails
"""
# Signals with string parameters (file paths that were dragged)
drag_started = Signal(list) # List[str] - list of file paths
drag_failed = Signal(str) # str - error message
def __init__(self, parent: Optional[QWidget] = None):
"""Initialize the drag interceptor.
Args:
parent: Parent widget
"""
super().__init__(parent)
self._validator: Optional[PathValidator] = None
def set_validator(self, validator: PathValidator) -> None:
"""Set the path validator for this interceptor.
Args:
validator: PathValidator instance to use for validation
"""
self._validator = validator
def initiate_drag(self, file_paths: List[str]) -> bool:
"""Initiate a native drag operation for the given files.
Args:
file_paths: List of file paths to drag
Returns:
True if drag was successfully initiated, False otherwise
"""
if not file_paths:
self.drag_failed.emit("No files to drag")
return False
if not self._validator:
self.drag_failed.emit("Validator not configured")
return False
# Validate all paths first
validated_paths = []
for path_str in file_paths:
try:
path = Path(path_str)
if self._validator.validate(path):
validated_paths.append(path)
except ValidationError as e:
self.drag_failed.emit(f"Validation failed for {path_str}: {e}")
return False
if not validated_paths:
self.drag_failed.emit("No valid files after validation")
return False
# Create MIME data with file URLs
mime_data = QMimeData()
file_urls = [
path.as_uri() for path in validated_paths
]
mime_data.setUrls([QUrl(url) for url in file_urls])
# Create and execute drag operation
drag = QDrag(self)
drag.setMimeData(mime_data)
# Use default drag pixmap (small icon)
drag.setPixmap(self.grab(self.rect()).scaled(
64, 64, Qt.AspectRatioMode.KeepAspectRatio
))
# Execute drag operation (blocking call)
drop_action = drag.exec(Qt.DropAction.CopyAction)
# Check result
if drop_action == Qt.DropAction.CopyAction:
self.drag_started.emit(validated_paths)
return True
else:
self.drag_failed.emit("Drag operation cancelled or failed")
return False
# Import QUrl here to avoid circular import at module level
from PySide6.QtCore import QUrl # noqa: E402, F401

View file

@ -0,0 +1,97 @@
"""Path validation for secure file operations."""
from pathlib import Path
from typing import List
class ValidationError(Exception):
"""Raised when path validation fails."""
pass
class PathValidator:
"""Validates file paths against security whitelist.
Ensures that only files within allowed root directories can be accessed.
All paths are resolved to absolute form before validation to prevent
directory traversal attacks.
"""
def __init__(self, allowed_roots: List[Path]):
"""Initialize validator with allowed root directories.
Args:
allowed_roots: List of Path objects representing allowed root dirs
Raises:
ValidationError: If any root doesn't exist or isn't a directory
"""
self.allowed_roots = []
for root in allowed_roots:
root_path = Path(root).resolve()
if not root_path.exists():
raise ValidationError(
f"Allowed root '{root}' does not exist"
)
if not root_path.is_dir():
raise ValidationError(
f"Allowed root '{root}' is not a directory"
)
self.allowed_roots.append(root_path)
def validate(self, path: Path) -> bool:
"""Validate that path is within an allowed root directory.
Args:
path: File path to validate
Returns:
True if path is valid and accessible
Raises:
ValidationError: If path fails validation
"""
try:
# Resolve to absolute path (handles symlinks, .., etc)
file_path = Path(path).resolve()
except (OSError, ValueError) as e:
raise ValidationError(f"Cannot resolve path '{path}': {e}") from e
# Check file exists
if not file_path.exists():
raise ValidationError(f"File does not exist: {path}")
# Check it's a regular file (not directory, symlink to dir, etc)
if not file_path.is_file():
raise ValidationError(f"Path is not a regular file: {path}")
# Check path is within an allowed root
for allowed_root in self.allowed_roots:
try:
# This raises ValueError if file_path is not relative to root
file_path.relative_to(allowed_root)
return True
except ValueError:
continue
# Not in any allowed root
raise ValidationError(
f"Path '{file_path}' is not within allowed roots: "
f"{self.allowed_roots}"
)
def is_valid(self, path: Path) -> bool:
"""Check if path is valid without raising exception.
Args:
path: File path to check
Returns:
True if valid, False otherwise
"""
try:
return self.validate(path)
except ValidationError:
return False

View file

@ -0,0 +1,67 @@
"""WebDrop Bridge - Application entry point."""
import sys
from pathlib import Path
from PySide6.QtWidgets import QApplication
from webdrop_bridge.config import Config, ConfigurationError
from webdrop_bridge.ui.main_window import MainWindow
from webdrop_bridge.utils.logging import get_logger, setup_logging
def main() -> int:
"""Main application entry point.
Initializes configuration, logging, and creates the main window.
Returns appropriate exit code.
Returns:
int: Exit code (0 for success, non-zero for error)
"""
try:
# Load configuration from environment
config = Config.from_env()
# Set up logging
log_file = None
if config.log_file_path:
log_file = Path(config.log_file_path)
setup_logging(
name="webdrop_bridge",
level=config.log_level,
log_file=log_file,
)
logger = get_logger(__name__)
logger.info(f"Starting {config.app_name} v{config.app_version}")
logger.debug(f"Configuration: {config}")
except ConfigurationError as e:
print(f"Configuration error: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Failed to initialize logging: {e}", file=sys.stderr)
return 1
try:
# Create Qt application
app = QApplication(sys.argv)
# Create and show main window
window = MainWindow(config)
window.show()
logger.info("Main window opened successfully")
# Run event loop
return app.exec()
except Exception as e:
logger.exception(f"Unhandled exception: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,158 @@
"""Main application window with web engine integration."""
from pathlib import Path
from typing import Optional
from PySide6.QtCore import Qt, QUrl
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import QMainWindow, QVBoxLayout, QWidget
from webdrop_bridge.config import Config
from webdrop_bridge.core.drag_interceptor import DragInterceptor
from webdrop_bridge.core.validator import PathValidator
class MainWindow(QMainWindow):
"""Main application window for WebDrop Bridge.
Displays web content in a QWebEngineView and provides drag-and-drop
integration with the native filesystem.
"""
def __init__(
self,
config: Config,
parent: Optional[QWidget] = None,
):
"""Initialize the main window.
Args:
config: Application configuration
parent: Parent widget
"""
super().__init__(parent)
self.config = config
# Set window properties
self.setWindowTitle(f"{config.app_name} v{config.app_version}")
self.setGeometry(
100,
100,
config.window_width,
config.window_height,
)
# Create web engine view
self.web_view = QWebEngineView()
# Create drag interceptor
self.drag_interceptor = DragInterceptor()
# Set up path validator
validator = PathValidator(config.allowed_roots)
self.drag_interceptor.set_validator(validator)
# Connect drag interceptor signals
self.drag_interceptor.drag_started.connect(self._on_drag_started)
self.drag_interceptor.drag_failed.connect(self._on_drag_failed)
# Set up central widget with layout
central_widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(self.web_view)
layout.setContentsMargins(0, 0, 0, 0)
central_widget.setLayout(layout)
self.setCentralWidget(central_widget)
# Load web application
self._load_webapp()
# Apply styling if available
self._apply_stylesheet()
def _load_webapp(self) -> None:
"""Load the web application.
Loads HTML from the configured webapp URL or from local file.
"""
webapp_url = self.config.webapp_url
if webapp_url.startswith("http://") or webapp_url.startswith("https://"):
# Remote URL
self.web_view.load(QUrl(webapp_url))
else:
# Local file path
try:
file_path = Path(webapp_url).resolve()
if not file_path.exists():
self.web_view.setHtml(
f"<html><body><h1>Error</h1>"
f"<p>Web application file not found: {file_path}</p>"
f"</body></html>"
)
return
# Load local file as file:// URL
file_url = file_path.as_uri()
self.web_view.load(QUrl(file_url))
except (OSError, ValueError) as e:
self.web_view.setHtml(
f"<html><body><h1>Error</h1>"
f"<p>Failed to load web application: {e}</p>"
f"</body></html>"
)
def _apply_stylesheet(self) -> None:
"""Apply application stylesheet if available."""
stylesheet_path = Path(__file__).parent.parent.parent.parent / \
"resources" / "stylesheets" / "default.qss"
if stylesheet_path.exists():
try:
with open(stylesheet_path, "r") as f:
stylesheet = f.read()
self.setStyleSheet(stylesheet)
except (OSError, IOError):
# Silently fail if stylesheet can't be read
pass
def _on_drag_started(self, paths: list) -> None:
"""Handle successful drag initiation.
Args:
paths: List of paths that were dragged
"""
# Can be extended with logging or status bar updates
pass
def _on_drag_failed(self, error: str) -> None:
"""Handle drag operation failure.
Args:
error: Error message
"""
# Can be extended with logging or user notification
pass
def closeEvent(self, event) -> None:
"""Handle window close event.
Args:
event: Close event
"""
# Can be extended with save operations or cleanup
event.accept()
def initiate_drag(self, file_paths: list) -> bool:
"""Initiate a drag operation for the given files.
Called from web content via JavaScript bridge.
Args:
file_paths: List of file paths to drag
Returns:
True if drag was initiated successfully
"""
return self.drag_interceptor.initiate_drag(file_paths)

View file

@ -0,0 +1,100 @@
"""Logging configuration and utilities for WebDrop Bridge."""
import logging
import logging.handlers
from pathlib import Path
from typing import Optional
def setup_logging(
name: str = "webdrop_bridge",
level: str = "INFO",
log_file: Optional[Path] = None,
fmt: Optional[str] = None,
) -> logging.Logger:
"""Configure application-wide logging.
Sets up both console and file logging (if enabled). All loggers in the
application should use this configured root logger.
Args:
name: Logger name (typically module name or app name)
level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
log_file: Optional path to log file. If provided, logs will be written
to this file in addition to console
fmt: Optional custom format string. If None, uses default format.
Default: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
Returns:
logging.Logger: Configured logger instance
Raises:
ValueError: If log_file path is invalid or can't be created
KeyError: If level is not a valid logging level
"""
# Validate logging level
try:
numeric_level = getattr(logging, level.upper())
except AttributeError as e:
raise KeyError(f"Invalid logging level: {level}") from e
# Use default format if not provided
if fmt is None:
fmt = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# Create formatter
formatter = logging.Formatter(fmt)
# Get or create logger
logger = logging.getLogger(name)
logger.setLevel(numeric_level)
# Remove existing handlers to avoid duplicates
logger.handlers.clear()
# Add console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(numeric_level)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# Add file handler if log file specified
if log_file:
try:
# Create parent directories if needed
log_file.parent.mkdir(parents=True, exist_ok=True)
# Use rotating file handler to manage log file size
# Max 10 MB per file, keep 5 backups
file_handler = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
encoding="utf-8",
)
file_handler.setLevel(numeric_level)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
except (OSError, IOError) as e:
raise ValueError(f"Cannot write to log file {log_file}: {e}") from e
return logger
def get_logger(name: str = __name__) -> logging.Logger:
"""Get a logger instance for a module.
Convenience function to get a logger for a specific module.
Use this in your modules to get properly named loggers.
Example:
logger = get_logger(__name__)
Args:
name: Logger name, typically __name__ of the calling module
Returns:
logging.Logger: Logger instance for the given name
"""
return logging.getLogger(name)