- 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.
243 lines
7.9 KiB
Python
243 lines
7.9 KiB
Python
"""Restricted web view with URL whitelist enforcement for Kiosk-mode."""
|
|
|
|
import fnmatch
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import List, Optional, Union
|
|
|
|
from PySide6.QtCore import QStandardPaths, QUrl
|
|
from PySide6.QtGui import QDesktopServices
|
|
from PySide6.QtWebEngineCore import QWebEngineNavigationRequest, QWebEnginePage, QWebEngineProfile
|
|
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CustomWebEnginePage(QWebEnginePage):
|
|
"""Custom page that handles new window requests for downloads."""
|
|
|
|
def acceptNavigationRequest(
|
|
self, url: Union[QUrl, str], nav_type: QWebEnginePage.NavigationType, is_main_frame: bool
|
|
) -> bool:
|
|
"""Handle navigation requests, including download links.
|
|
|
|
Args:
|
|
url: Target URL (QUrl or string)
|
|
nav_type: Type of navigation (link click, form submit, etc.)
|
|
is_main_frame: Whether this is the main frame
|
|
|
|
Returns:
|
|
True to accept navigation, False to reject
|
|
"""
|
|
# Convert to string if QUrl
|
|
url_str = url.toString() if isinstance(url, QUrl) else url
|
|
|
|
# Log all navigation attempts for debugging
|
|
logger.debug(f"Navigation request: {url_str} (type={nav_type}, main_frame={is_main_frame})")
|
|
|
|
# Check if this might be a download (common file extensions)
|
|
download_extensions = [
|
|
".pdf",
|
|
".zip",
|
|
".rar",
|
|
".7z",
|
|
".tar",
|
|
".gz",
|
|
".doc",
|
|
".docx",
|
|
".xls",
|
|
".xlsx",
|
|
".ppt",
|
|
".pptx",
|
|
".jpg",
|
|
".jpeg",
|
|
".png",
|
|
".gif",
|
|
".bmp",
|
|
".svg",
|
|
".mp4",
|
|
".mp3",
|
|
".avi",
|
|
".mov",
|
|
".wav",
|
|
".exe",
|
|
".msi",
|
|
".dmg",
|
|
".pkg",
|
|
".csv",
|
|
".txt",
|
|
".json",
|
|
".xml",
|
|
]
|
|
|
|
if any(url_str.lower().endswith(ext) for ext in download_extensions):
|
|
logger.info(f"🔽 Detected potential download URL: {url_str}")
|
|
# This will trigger downloadRequested if it's a download
|
|
|
|
return super().acceptNavigationRequest(url, nav_type, is_main_frame)
|
|
|
|
def createWindow(self, window_type: QWebEnginePage.WebWindowType) -> QWebEnginePage:
|
|
"""Handle new window requests (target=_blank, window.open, etc.).
|
|
|
|
Many downloads are triggered via target="_blank" links.
|
|
|
|
Args:
|
|
window_type: Type of window being created
|
|
|
|
Returns:
|
|
New page instance for the window
|
|
"""
|
|
logger.info(f"🪟 New window requested, type: {window_type}")
|
|
|
|
# Create a temporary page to handle the download
|
|
# This page will never be displayed but allows downloads to work
|
|
download_page = QWebEnginePage(self.profile(), self)
|
|
|
|
logger.info("✅ Created temporary page for download/popup")
|
|
|
|
# Return the temporary page - it will trigger downloadRequested if it's a download
|
|
return download_page
|
|
|
|
|
|
class RestrictedWebEngineView(QWebEngineView):
|
|
"""Web view that enforces URL whitelist for Kiosk-mode security.
|
|
|
|
If allowed_urls is empty, no restrictions are applied.
|
|
If allowed_urls is not empty, only matching URLs are loaded in the view.
|
|
Non-matching URLs open in the system default browser.
|
|
"""
|
|
|
|
def __init__(self, allowed_urls: Optional[List[str]] = None):
|
|
"""Initialize the restricted web view.
|
|
|
|
Args:
|
|
allowed_urls: List of allowed URL patterns (empty = no restriction)
|
|
Patterns support wildcards: *.example.com, localhost, etc.
|
|
"""
|
|
super().__init__()
|
|
self.allowed_urls = allowed_urls or []
|
|
|
|
# Create persistent profile for cookie and session storage
|
|
self.profile = self._create_persistent_profile()
|
|
|
|
# Use custom page for better download handling with persistent profile
|
|
custom_page = CustomWebEnginePage(self.profile, self)
|
|
self.setPage(custom_page)
|
|
|
|
logger.info(
|
|
"RestrictedWebEngineView initialized with CustomWebEnginePage and persistent profile"
|
|
)
|
|
|
|
# Connect to navigation request handler
|
|
self.page().navigationRequested.connect(self._on_navigation_requested)
|
|
|
|
def _create_persistent_profile(self) -> QWebEngineProfile:
|
|
"""Create and configure a persistent web engine profile.
|
|
|
|
This enables persistent cookies and cache storage, allowing
|
|
authentication sessions (e.g., Microsoft login) to persist
|
|
across application restarts.
|
|
|
|
Returns:
|
|
Configured QWebEngineProfile with persistent storage
|
|
"""
|
|
# Get application data directory
|
|
app_data_dir = QStandardPaths.writableLocation(
|
|
QStandardPaths.StandardLocation.AppDataLocation
|
|
)
|
|
|
|
# Create profile directory path
|
|
profile_path = Path(app_data_dir) / "WebEngineProfile"
|
|
profile_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create persistent profile with custom storage location
|
|
# Using "WebDropBridge" as the profile name
|
|
profile = QWebEngineProfile("WebDropBridge", self)
|
|
profile.setPersistentStoragePath(str(profile_path))
|
|
|
|
# Configure persistent cookies (critical for authentication)
|
|
profile.setPersistentCookiesPolicy(
|
|
QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
|
|
)
|
|
|
|
# Enable HTTP cache for better performance
|
|
profile.setHttpCacheType(QWebEngineProfile.HttpCacheType.DiskHttpCache)
|
|
|
|
# Set cache size to 100 MB
|
|
profile.setHttpCacheMaximumSize(100 * 1024 * 1024)
|
|
|
|
logger.info(f"Created persistent profile at: {profile_path}")
|
|
logger.info("Cookies policy: ForcePersistentCookies")
|
|
logger.info("HTTP cache: DiskHttpCache (100 MB)")
|
|
|
|
return profile
|
|
|
|
def _on_navigation_requested(self, request: QWebEngineNavigationRequest) -> None:
|
|
"""Handle navigation requests and enforce URL whitelist.
|
|
|
|
Args:
|
|
request: Navigation request to process
|
|
"""
|
|
url = request.url()
|
|
|
|
# If no restrictions, allow all URLs
|
|
if not self.allowed_urls:
|
|
return
|
|
|
|
# Check if URL matches whitelist
|
|
if self._is_url_allowed(url):
|
|
# Allow the navigation (default behavior)
|
|
return
|
|
|
|
# URL not whitelisted - open in system browser
|
|
request.reject()
|
|
QDesktopServices.openUrl(url)
|
|
|
|
def _is_url_allowed(self, url: QUrl) -> bool:
|
|
"""Check if a URL matches the whitelist.
|
|
|
|
Supports:
|
|
- Exact domain matches: example.com
|
|
- Wildcard patterns: *.example.com
|
|
- Localhost variations: localhost, 127.0.0.1
|
|
- File URLs: file://...
|
|
|
|
Args:
|
|
url: QUrl to check
|
|
|
|
Returns:
|
|
True if URL is allowed, False otherwise
|
|
"""
|
|
url_str = url.toString()
|
|
host = url.host()
|
|
scheme = url.scheme()
|
|
|
|
# Allow file:// URLs (local webapp)
|
|
if scheme == "file":
|
|
return True
|
|
|
|
# If no whitelist, allow all URLs
|
|
if not self.allowed_urls:
|
|
return True
|
|
|
|
# Check against whitelist patterns
|
|
for pattern in self.allowed_urls:
|
|
# Check full URL pattern
|
|
if fnmatch.fnmatch(url_str, pattern):
|
|
return True
|
|
|
|
# Check host pattern (for http/https URLs)
|
|
if scheme in ("http", "https") and host:
|
|
if fnmatch.fnmatch(host, pattern):
|
|
return True
|
|
|
|
# Also check with www prefix variants
|
|
if fnmatch.fnmatch(f"www.{host}", pattern):
|
|
return True
|
|
if fnmatch.fnmatch(host.replace("www.", ""), pattern):
|
|
return True
|
|
|
|
return False
|