Refactor drag handling and update tests

- Renamed `initiate_drag` to `handle_drag` in MainWindow and updated related tests.
- Improved drag handling logic to utilize a bridge for starting file drags.
- Updated `_on_drag_started` and `_on_drag_failed` methods to match new signatures.
- Modified test cases to reflect changes in drag handling and assertions.

Enhance path validation and logging

- Updated `PathValidator` to log warnings for nonexistent roots instead of raising errors.
- Adjusted tests to verify the new behavior of skipping nonexistent roots.

Update web application UI and functionality

- Changed displayed text for drag items to reflect local paths and Azure Blob Storage URLs.
- Added debug logging for drag operations in the web application.
- Improved instructions for testing drag and drop functionality.

Add configuration documentation and example files

- Created `CONFIG_README.md` to provide detailed configuration instructions for WebDrop Bridge.
- Added `config.example.json` and `config_test.json` for reference and testing purposes.

Implement URL conversion logic

- Introduced `URLConverter` class to handle conversion of Azure Blob Storage URLs to local paths.
- Added unit tests for URL conversion to ensure correct functionality.

Develop download interceptor script

- Created `download_interceptor.js` to intercept download-related actions in the web application.
- Implemented logging for fetch calls, XMLHttpRequests, and Blob URL creations.

Add download test page and related tests

- Created `test_download.html` for testing various download scenarios.
- Implemented `test_download.py` to verify download path resolution and file construction.
- Added `test_url_mappings.py` to ensure URL mappings are loaded correctly.

Add unit tests for URL converter

- Created `test_url_converter.py` to validate URL conversion logic and mapping behavior.
This commit is contained in:
claudi 2026-02-17 15:56:53 +01:00
parent c9704efc8d
commit 88dc358894
21 changed files with 1870 additions and 432 deletions

View file

@ -1,5 +1,6 @@
"""Qt widget for intercepting drag events and initiating native drag operations."""
import logging
from pathlib import Path
from typing import List, Optional
@ -7,98 +8,129 @@ from PySide6.QtCore import QMimeData, Qt, QUrl, Signal
from PySide6.QtGui import QDrag
from PySide6.QtWidgets import QWidget
from webdrop_bridge.config import Config
from webdrop_bridge.core.url_converter import URLConverter
from webdrop_bridge.core.validator import PathValidator, ValidationError
logger = logging.getLogger(__name__)
class DragInterceptor(QWidget):
"""Widget that handles drag initiation for file paths.
"""Widget that handles drag initiation for file paths or Azure URLs.
Intercepts drag events from web content and initiates native Qt drag
operations, allowing files to be dragged from web content to native
applications.
Intercepts drag events from web content, converts Azure Blob Storage URLs
to local paths, validates them, and initiates native Qt drag operations.
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
# Signals with string parameters
drag_started = Signal(str, str) # (url_or_path, local_path)
drag_failed = Signal(str, str) # (url_or_path, error_message)
def __init__(self, parent: Optional[QWidget] = None):
def __init__(self, config: Config, parent: Optional[QWidget] = None):
"""Initialize the drag interceptor.
Args:
config: Application configuration
parent: Parent widget
"""
super().__init__(parent)
self._validator: Optional[PathValidator] = None
self.config = config
self._validator = PathValidator(
config.allowed_roots,
check_file_exists=config.check_file_exists
)
self._url_converter = URLConverter(config)
def set_validator(self, validator: PathValidator) -> None:
"""Set the path validator for this interceptor.
def handle_drag(self, text: str) -> bool:
"""Handle drag event from web view.
Determines if the text is an Azure URL or file path, converts if needed,
validates, and initiates native drag operation.
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
text: Azure Blob Storage URL or file path from web drag
Returns:
True if drag was successfully initiated, False otherwise
True if native drag was initiated, False otherwise
"""
if not file_paths:
self.drag_failed.emit("No files to drag")
if not text or not text.strip():
error_msg = "Empty drag text"
logger.warning(error_msg)
self.drag_failed.emit("", error_msg)
return False
if not self._validator:
self.drag_failed.emit("Validator not configured")
return False
text = text.strip()
logger.debug(f"Handling drag for text: {text}")
# 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}")
# Check if it's an Azure URL and convert to local path
if self._url_converter.is_azure_url(text):
local_path = self._url_converter.convert_url_to_path(text)
if local_path is None:
error_msg = "No mapping found for URL"
logger.warning(f"{error_msg}: {text}")
self.drag_failed.emit(text, error_msg)
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
source_text = text
else:
self.drag_failed.emit("Drag operation cancelled or failed")
# Treat as direct file path
local_path = Path(text)
source_text = text
# Validate the path
try:
self._validator.validate(local_path)
except ValidationError as e:
error_msg = str(e)
logger.warning(f"Validation failed for {local_path}: {error_msg}")
self.drag_failed.emit(source_text, error_msg)
return False
logger.info(f"Initiating drag for: {local_path}")
# Create native file drag
success = self._create_native_drag(local_path)
if success:
self.drag_started.emit(source_text, str(local_path))
else:
error_msg = "Failed to create native drag operation"
logger.error(error_msg)
self.drag_failed.emit(source_text, error_msg)
return success
def _create_native_drag(self, file_path: Path) -> bool:
"""Create a native file system drag operation.
Args:
file_path: Local file path to drag
Returns:
True if drag was created successfully
"""
try:
# Create MIME data with file URL
mime_data = QMimeData()
file_url = QUrl.fromLocalFile(str(file_path))
mime_data.setUrls([file_url])
# Create and execute drag
drag = QDrag(self)
drag.setMimeData(mime_data)
# Optional: Set a drag icon/pixmap if available
# drag.setPixmap(...)
# Start drag operation (blocks until drop or cancel)
# Qt.CopyAction allows copying files
result = drag.exec(Qt.DropAction.CopyAction)
return result == Qt.DropAction.CopyAction
except Exception as e:
logger.exception(f"Error creating native drag: {e}")
return False

View file

@ -0,0 +1,86 @@
"""URL to local path conversion for Azure Blob Storage URLs."""
import logging
from pathlib import Path
from typing import Optional
from urllib.parse import unquote
from ..config import Config, URLMapping
logger = logging.getLogger(__name__)
class URLConverter:
"""Converts Azure Blob Storage URLs to local file paths."""
def __init__(self, config: Config):
"""Initialize converter with configuration.
Args:
config: Application configuration with URL mappings
"""
self.config = config
def convert_url_to_path(self, url: str) -> Optional[Path]:
"""Convert Azure Blob Storage URL to local file path.
Args:
url: Azure Blob Storage URL
Returns:
Local file path if mapping found, None otherwise
Example:
>>> converter.convert_url_to_path(
... "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/aN5PysnXIuRECzcRbvHkjL7g0/file.png"
... )
Path("Z:/aN5PysnXIuRECzcRbvHkjL7g0/file.png")
"""
if not url:
return None
# URL decode (handles special characters like spaces)
url = unquote(url)
# Find matching URL mapping
for mapping in self.config.url_mappings:
if url.startswith(mapping.url_prefix):
# Extract relative path after prefix
relative_path = url[len(mapping.url_prefix):]
# Combine with local path
local_path = Path(mapping.local_path) / relative_path
# Normalize path (resolve .. and .) but don't follow symlinks yet
try:
# On Windows, normalize separators
local_path = Path(str(local_path).replace("/", "\\"))
except (OSError, RuntimeError) as e:
logger.warning(f"Failed to normalize path {local_path}: {e}")
return None
logger.debug(f"Converted URL to path: {url} -> {local_path}")
return local_path
logger.debug(f"No mapping found for URL: {url}")
return None
def is_azure_url(self, text: str) -> bool:
"""Check if text is an Azure Blob Storage URL.
Args:
text: Text to check
Returns:
True if text matches configured URL prefixes
"""
if not text:
return False
text = text.strip()
for mapping in self.config.url_mappings:
if text.startswith(mapping.url_prefix):
return True
return False

View file

@ -1,7 +1,10 @@
"""Path validation for secure file operations."""
import logging
from pathlib import Path
from typing import List
from typing import List, Optional
logger = logging.getLogger(__name__)
class ValidationError(Exception):
@ -18,28 +21,27 @@ class PathValidator:
directory traversal attacks.
"""
def __init__(self, allowed_roots: List[Path]):
def __init__(self, allowed_roots: List[Path], check_file_exists: bool = True):
"""Initialize validator with allowed root directories.
Args:
allowed_roots: List of Path objects representing allowed root dirs
check_file_exists: Whether to validate that files exist
Raises:
ValidationError: If any root doesn't exist or isn't a directory
"""
self.allowed_roots = []
self.check_file_exists = check_file_exists
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)
logger.warning(f"Allowed root '{root}' does not exist")
elif not root_path.is_dir():
raise ValidationError(f"Allowed root '{root}' is not a directory")
else:
self.allowed_roots.append(root_path)
def validate(self, path: Path) -> bool:
"""Validate that path is within an allowed root directory.
@ -59,28 +61,32 @@ class PathValidator:
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 file exists if required
if self.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 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
# Check path is within an allowed root (if roots configured)
if self.allowed_roots:
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}"
)
# Not in any allowed root
raise ValidationError(
f"Path '{file_path}' is not within allowed roots: "
f"{self.allowed_roots}"
)
return True
def is_valid(self, path: Path) -> bool:
"""Check if path is valid without raising exception.