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:
parent
c9704efc8d
commit
88dc358894
21 changed files with 1870 additions and 432 deletions
|
|
@ -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
|
||||
|
|
|
|||
86
src/webdrop_bridge/core/url_converter.py
Normal file
86
src/webdrop_bridge/core/url_converter.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue