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,8 +1,9 @@
"""Configuration management for WebDrop Bridge application."""
import json
import logging
import os
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import List
@ -17,9 +18,29 @@ class ConfigurationError(Exception):
pass
@dataclass
class URLMapping:
"""Maps an Azure Blob Storage URL prefix to a local drive path."""
url_prefix: str
local_path: str
def __post_init__(self):
"""Validate mapping configuration."""
if not self.url_prefix.startswith(("http://", "https://")):
raise ConfigurationError(
f"URL prefix must start with http:// or https://: {self.url_prefix}"
)
# Ensure URL prefix ends with /
if not self.url_prefix.endswith("/"):
self.url_prefix += "/"
# Normalize local path
self.local_path = str(Path(self.local_path))
@dataclass
class Config:
"""Application configuration loaded from environment variables.
"""Application configuration loaded from environment variables or JSON file.
Attributes:
app_name: Application display name
@ -28,7 +49,11 @@ class Config:
log_file: Optional log file path
allowed_roots: List of whitelisted root directories for file access
allowed_urls: List of whitelisted URL domains/patterns (empty = no restriction)
webapp_url: URL to load in embedded web application
webapp_url: URL to load in embedded web application (default: https://wps.agravity.io/)
url_mappings: List of Azure URL to local path mappings
check_file_exists: Whether to validate that files exist before drag
auto_check_updates: Whether to automatically check for updates
update_check_interval_hours: Hours between update checks
window_width: Initial window width in pixels
window_height: Initial window height in pixels
window_title: Main window title (default: "{app_name} v{app_version}")
@ -45,10 +70,85 @@ class Config:
allowed_roots: List[Path]
allowed_urls: List[str]
webapp_url: str
window_width: int
window_height: int
window_title: str
enable_logging: bool
url_mappings: List[URLMapping] = field(default_factory=list)
check_file_exists: bool = True
auto_check_updates: bool = True
update_check_interval_hours: int = 24
window_width: int = 1024
window_height: int = 768
window_title: str = ""
enable_logging: bool = True
@classmethod
def from_file(cls, config_path: Path) -> "Config":
"""Load configuration from JSON file.
Args:
config_path: Path to configuration file
Returns:
Config: Configured instance from JSON file
Raises:
ConfigurationError: If configuration file is invalid
"""
if not config_path.exists():
raise ConfigurationError(f"Configuration file not found: {config_path}")
try:
with open(config_path, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, IOError) as e:
raise ConfigurationError(f"Failed to load configuration: {e}") from e
# Get version from package
from webdrop_bridge import __version__
# Parse URL mappings
mappings = [
URLMapping(
url_prefix=m["url_prefix"],
local_path=m["local_path"]
)
for m in data.get("url_mappings", [])
]
# Parse allowed roots
allowed_roots = [Path(p).resolve() for p in data.get("allowed_roots", [])]
# Validate allowed roots exist
for root in allowed_roots:
if not root.exists():
logger.warning(f"Allowed root does not exist: {root}")
elif not root.is_dir():
raise ConfigurationError(f"Allowed root is not a directory: {root}")
# Get log file path
log_file = None
if data.get("enable_logging", True):
log_file_str = data.get("log_file", "logs/webdrop_bridge.log")
log_file = Path(log_file_str).resolve()
app_name = data.get("app_name", "WebDrop Bridge")
window_title = data.get("window_title", f"{app_name} v{__version__}")
return cls(
app_name=app_name,
app_version=__version__,
log_level=data.get("log_level", "INFO").upper(),
log_file=log_file,
allowed_roots=allowed_roots,
allowed_urls=data.get("allowed_urls", []),
webapp_url=data.get("webapp_url", "https://wps.agravity.io/"),
url_mappings=mappings,
check_file_exists=data.get("check_file_exists", True),
auto_check_updates=data.get("auto_check_updates", True),
update_check_interval_hours=data.get("update_check_interval_hours", 24),
window_width=data.get("window_width", 1024),
window_height=data.get("window_height", 768),
window_title=window_title,
enable_logging=data.get("enable_logging", True),
)
@classmethod
def from_env(cls, env_file: str | None = None) -> "Config":
@ -81,7 +181,7 @@ class Config:
log_file_str = os.getenv("LOG_FILE", "logs/webdrop_bridge.log")
allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public")
allowed_urls_str = os.getenv("ALLOWED_URLS", "")
webapp_url = os.getenv("WEBAPP_URL", "file:///./webapp/index.html")
webapp_url = os.getenv("WEBAPP_URL", "https://wps.agravity.io/")
window_width = int(os.getenv("WINDOW_WIDTH", "1024"))
window_height = int(os.getenv("WINDOW_HEIGHT", "768"))
# Window title defaults to app_name + version if not specified
@ -103,14 +203,13 @@ class Config:
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():
logger.warning(f"Allowed root does not exist: {p.strip()}")
elif not root_path.is_dir():
raise ConfigurationError(
f"Allowed root '{p.strip()}' is not a directory"
)
allowed_roots.append(root_path)
else:
allowed_roots.append(root_path)
except ConfigurationError:
raise
except (ValueError, OSError) as e:
@ -140,6 +239,32 @@ class Config:
if url.strip()
] if allowed_urls_str else []
# Parse URL mappings (Azure Blob Storage → Local Paths)
# Format: url_prefix1=local_path1;url_prefix2=local_path2
url_mappings_str = os.getenv("URL_MAPPINGS", "")
url_mappings = []
if url_mappings_str:
try:
for mapping in url_mappings_str.split(";"):
mapping = mapping.strip()
if not mapping:
continue
if "=" not in mapping:
raise ConfigurationError(
f"Invalid URL mapping format: {mapping}. Expected 'url=path'"
)
url_prefix, local_path_str = mapping.split("=", 1)
url_mappings.append(
URLMapping(
url_prefix=url_prefix.strip(),
local_path=local_path_str.strip()
)
)
except (ValueError, OSError) as e:
raise ConfigurationError(
f"Invalid URL_MAPPINGS: {url_mappings_str}. Error: {e}"
) from e
return cls(
app_name=app_name,
app_version=app_version,
@ -148,12 +273,60 @@ class Config:
allowed_roots=allowed_roots,
allowed_urls=allowed_urls,
webapp_url=webapp_url,
url_mappings=url_mappings,
window_width=window_width,
window_height=window_height,
window_title=window_title,
enable_logging=enable_logging,
)
def to_file(self, config_path: Path) -> None:
"""Save configuration to JSON file.
Args:
config_path: Path to save configuration
"""
data = {
"app_name": self.app_name,
"webapp_url": self.webapp_url,
"url_mappings": [
{
"url_prefix": m.url_prefix,
"local_path": m.local_path
}
for m in self.url_mappings
],
"allowed_roots": [str(p) for p in self.allowed_roots],
"allowed_urls": self.allowed_urls,
"check_file_exists": self.check_file_exists,
"auto_check_updates": self.auto_check_updates,
"update_check_interval_hours": self.update_check_interval_hours,
"log_level": self.log_level,
"log_file": str(self.log_file) if self.log_file else None,
"window_width": self.window_width,
"window_height": self.window_height,
"window_title": self.window_title,
"enable_logging": self.enable_logging,
}
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
@staticmethod
def get_default_config_path() -> Path:
"""Get the default configuration file path.
Returns:
Path to default config file
"""
import platform
if platform.system() == "Windows":
base = Path.home() / "AppData" / "Roaming"
else:
base = Path.home() / ".config"
return base / "webdrop_bridge" / "config.json"
def __repr__(self) -> str:
"""Return developer-friendly representation."""
return (