This commit is contained in:
2025-10-23 18:10:34 +02:00
parent 5c2691b070
commit 9a64ca5b01
14 changed files with 598 additions and 149 deletions

View File

@@ -48,6 +48,10 @@ class AuthService:
self._hash: Optional[str] = settings.master_password_hash
# In-memory failed attempts per identifier. Values are dicts with
# keys: count, last, locked_until
# WARNING: In-memory storage resets on process restart.
# This is acceptable for development but PRODUCTION deployments
# should use Redis or a database to persist failed login attempts
# and prevent bypass via process restart.
self._failed: Dict[str, Dict] = {}
# Policy
self.max_attempts = 5
@@ -71,18 +75,42 @@ class AuthService:
def setup_master_password(self, password: str) -> None:
"""Set the master password (hash and store in memory/settings).
Enforces strong password requirements:
- Minimum 8 characters
- Mixed case (upper and lower)
- At least one number
- At least one special character
For now we update only the in-memory value and
settings.master_password_hash. A future task should persist this
to a config file.
Args:
password: The password to set
Raises:
ValueError: If password doesn't meet requirements
"""
# Length check
if len(password) < 8:
raise ValueError("Password must be at least 8 characters long")
# Basic strength checks
# Mixed case check
if password.islower() or password.isupper():
raise ValueError("Password must include mixed case")
raise ValueError(
"Password must include both uppercase and lowercase letters"
)
# Number check
if not any(c.isdigit() for c in password):
raise ValueError("Password must include at least one number")
# Special character check
if password.isalnum():
# encourage a special character
raise ValueError("Password should include a symbol or punctuation")
raise ValueError(
"Password must include at least one special character "
"(symbol or punctuation)"
)
h = self._hash_password(password)
self._hash = h

View File

@@ -77,6 +77,8 @@ class DownloadService:
# Queue storage by status
self._pending_queue: deque[DownloadItem] = deque()
# Helper dict for O(1) lookup of pending items by ID
self._pending_items_by_id: Dict[str, DownloadItem] = {}
self._active_downloads: Dict[str, DownloadItem] = {}
self._completed_items: deque[DownloadItem] = deque(maxlen=100)
self._failed_items: deque[DownloadItem] = deque(maxlen=50)
@@ -107,6 +109,46 @@ class DownloadService:
max_retries=max_retries,
)
def _add_to_pending_queue(
self, item: DownloadItem, front: bool = False
) -> None:
"""Add item to pending queue and update helper dict.
Args:
item: Download item to add
front: If True, add to front of queue (higher priority)
"""
if front:
self._pending_queue.appendleft(item)
else:
self._pending_queue.append(item)
self._pending_items_by_id[item.id] = item
def _remove_from_pending_queue(self, item_or_id: str) -> Optional[DownloadItem]: # noqa: E501
"""Remove item from pending queue and update helper dict.
Args:
item_or_id: Item ID to remove
Returns:
Removed item or None if not found
"""
if isinstance(item_or_id, str):
item = self._pending_items_by_id.get(item_or_id)
if not item:
return None
item_id = item_or_id
else:
item = item_or_id
item_id = item.id
try:
self._pending_queue.remove(item)
del self._pending_items_by_id[item_id]
return item
except (ValueError, KeyError):
return None
def set_broadcast_callback(self, callback: Callable) -> None:
"""Set callback for broadcasting status updates via WebSocket."""
self._broadcast_callback = callback
@@ -146,14 +188,14 @@ class DownloadService:
# Reset status if was downloading when saved
if item.status == DownloadStatus.DOWNLOADING:
item.status = DownloadStatus.PENDING
self._pending_queue.append(item)
self._add_to_pending_queue(item)
# Restore failed items that can be retried
for item_dict in data.get("failed", []):
item = DownloadItem(**item_dict)
if item.retry_count < self._max_retries:
item.status = DownloadStatus.PENDING
self._pending_queue.append(item)
self._add_to_pending_queue(item)
else:
self._failed_items.append(item)
@@ -231,10 +273,9 @@ class DownloadService:
# Insert based on priority. High-priority downloads jump the
# line via appendleft so they execute before existing work;
# everything else is appended to preserve FIFO order.
if priority == DownloadPriority.HIGH:
self._pending_queue.appendleft(item)
else:
self._pending_queue.append(item)
self._add_to_pending_queue(
item, front=(priority == DownloadPriority.HIGH)
)
created_ids.append(item.id)
@@ -293,15 +334,15 @@ class DownloadService:
logger.info("Cancelled active download", item_id=item_id)
continue
# Check pending queue
for item in list(self._pending_queue):
if item.id == item_id:
self._pending_queue.remove(item)
removed_ids.append(item_id)
logger.info(
"Removed from pending queue", item_id=item_id
)
break
# Check pending queue - O(1) lookup using helper dict
if item_id in self._pending_items_by_id:
item = self._pending_items_by_id[item_id]
self._pending_queue.remove(item)
del self._pending_items_by_id[item_id]
removed_ids.append(item_id)
logger.info(
"Removed from pending queue", item_id=item_id
)
if removed_ids:
self._save_queue()
@@ -338,24 +379,25 @@ class DownloadService:
DownloadServiceError: If reordering fails
"""
try:
# Find and remove item
item_to_move = None
for item in list(self._pending_queue):
if item.id == item_id:
self._pending_queue.remove(item)
item_to_move = item
break
# Find and remove item - O(1) lookup using helper dict
item_to_move = self._pending_items_by_id.get(item_id)
if not item_to_move:
raise DownloadServiceError(
f"Item {item_id} not found in pending queue"
)
# Remove from current position
self._pending_queue.remove(item_to_move)
del self._pending_items_by_id[item_id]
# Insert at new position
queue_list = list(self._pending_queue)
new_position = max(0, min(new_position, len(queue_list)))
queue_list.insert(new_position, item_to_move)
self._pending_queue = deque(queue_list)
# Re-add to helper dict
self._pending_items_by_id[item_id] = item_to_move
self._save_queue()
@@ -575,7 +617,7 @@ class DownloadService:
item.retry_count += 1
item.error = None
item.progress = None
self._pending_queue.append(item)
self._add_to_pending_queue(item)
retried_ids.append(item.id)
logger.info(