From 5253c942e813620c6a855ebb72d94b255332f313 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 15 Feb 2026 23:42:51 +0300 Subject: [PATCH] fix(core,security): safe update extraction and async bulk vk actions --- main.py | 256 ++++++++++++++++++++++---------- services/auto_update_service.py | 14 +- services/update_service.py | 37 ++++- 3 files changed, 224 insertions(+), 83 deletions(-) diff --git a/main.py b/main.py index 01d8458..2fc17d8 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,4 @@ -import json +import json import os import shutil import sys @@ -7,7 +7,7 @@ import time from PySide6.QtCore import QProcess from PySide6.QtCore import QStandardPaths -from PySide6.QtCore import Qt, QUrl, QDateTime, QTimer +from PySide6.QtCore import QObject, QThread, Signal, Qt, QUrl, QDateTime, QTimer from PySide6.QtGui import QIcon, QAction, QActionGroup, QDesktopServices from PySide6.QtWidgets import (QApplication, QMainWindow, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget, QMessageBox, @@ -50,6 +50,7 @@ UPDATE_REPOSITORY = "" UPDATE_REPOSITORY_URL = "https://git.daemonlord.ru/benya/AnabasisChatRemove" UPDATE_CHANNEL_DEFAULT = "stable" UPDATE_REQUEST_TIMEOUT = 8 +AUTH_ERROR_CONTEXTS = ("load_chats", "execute_user_action", "set_user_admin", "_unused") def get_resource_path(relative_path): @@ -59,6 +60,86 @@ def get_resource_path(relative_path): # Для cx_Freeze и обычного запуска return os.path.join(os.path.abspath("."), relative_path) +class BulkActionWorker(QObject): + progress = Signal(int, int, str) + finished = Signal(dict) + auth_error = Signal(str, object, str) + failed = Signal(str) + done = Signal() + + def __init__(self, vk_call_with_retry, vk_api, action_type, selected_chats, user_infos, visible_messages): + super().__init__() + self.vk_call_with_retry = vk_call_with_retry + self.vk = vk_api + self.action_type = action_type + self.selected_chats = selected_chats + self.user_infos = user_infos + self.visible_messages = visible_messages + self.total = len(self.selected_chats) * len(self.user_infos) + + @staticmethod + def _is_auth_error(exc): + return VkService.is_auth_error(exc, str(exc).lower()) + + def _emit_progress(self, processed): + label = "admin" if self.action_type == "admin" else ("remove" if self.action_type == "remove" else "add") + self.progress.emit(processed, self.total, label) + + def run(self): + results = [] + processed = 0 + try: + for chat in self.selected_chats: + peer_id = None + if self.action_type == "admin": + try: + peer_id = 2000000000 + int(chat["id"]) + except (ValueError, TypeError): + for _user_id, user_info in self.user_infos.items(): + results.append(f"✗ Ошибка ID чата: {chat['id']} ({user_info})") + processed += 1 + self._emit_progress(processed) + continue + + for user_id, user_info in self.user_infos.items(): + try: + if self.action_type == "remove": + self.vk_call_with_retry(self.vk.messages.removeChatUser, chat_id=chat["id"], member_id=user_id) + results.append(f"✓ '{user_info}' исключен из '{chat['title']}'.") + elif self.action_type == "add": + params = {"chat_id": chat["id"], "user_id": user_id} + if self.visible_messages: + params["visible_messages_count"] = 250 + self.vk_call_with_retry(self.vk.messages.addChatUser, **params) + results.append(f"✓ '{user_info}' приглашен в '{chat['title']}'.") + elif self.action_type == "admin": + self.vk_call_with_retry( + self.vk.messages.setMemberRole, + peer_id=peer_id, + member_id=user_id, + role="admin", + ) + results.append(f"✓ '{user_info}' назначен админом в '{chat['title']}'.") + else: + raise RuntimeError(f"Unknown action: {self.action_type}") + except VkApiError as exc: + if self._is_auth_error(exc): + context = "set_user_admin" if self.action_type == "admin" else "execute_user_action" + action_name = "назначения администраторов" if self.action_type == "admin" else "выполнения операций с пользователями" + self.auth_error.emit(context, exc, action_name) + return + results.append(f"✗ Ошибка для '{user_info}' в '{chat['title']}': {exc}") + finally: + processed += 1 + self._emit_progress(processed) + + self.finished.emit({"results": results, "processed": processed, "total": self.total}) + except Exception as exc: + self.failed.emit(str(exc)) + finally: + self.done.emit() + + class VkChatManager(QMainWindow): def __init__(self): super().__init__() @@ -92,6 +173,11 @@ class VkChatManager(QMainWindow): self.update_checker = None self.update_thread = None self._update_check_silent = False + self._bulk_worker_thread = None + self._bulk_worker = None + self._bulk_action_context = None + self._bulk_action_success_message_title = "" + self._bulk_clear_inputs_on_success = True self.resolve_timer = QTimer(self) self.resolve_timer.setSingleShot(True) @@ -588,6 +674,72 @@ class VkChatManager(QMainWindow): self._active_action_button = None self._active_action_button_text = "" + def _start_bulk_action_worker( + self, + action_type, + selected_chats, + user_infos, + action_label, + action_button=None, + success_message_title="Результаты", + clear_inputs_on_success=True, + ): + total = max(1, len(selected_chats) * len(user_infos)) + self._bulk_action_context = "set_user_admin" if action_type == "admin" else "execute_user_action" + self._bulk_action_success_message_title = success_message_title + self._bulk_clear_inputs_on_success = clear_inputs_on_success + + self._set_busy(True, f"Статус: выполняется {action_label} (0/{total})...") + self._start_operation_progress(total, action_label, action_button=action_button) + + worker = BulkActionWorker( + vk_call_with_retry=self._vk_call_with_retry, + vk_api=self.vk, + action_type=action_type, + selected_chats=selected_chats, + user_infos=user_infos, + visible_messages=self.visible_messages_checkbox.isChecked(), + ) + thread = QThread(self) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.progress.connect(self._on_bulk_action_progress) + worker.finished.connect(self._on_bulk_action_finished) + worker.auth_error.connect(self._on_bulk_action_auth_error) + worker.failed.connect(self._on_bulk_action_failed) + worker.done.connect(self._on_bulk_action_done) + worker.done.connect(thread.quit) + worker.done.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + self._bulk_worker = worker + self._bulk_worker_thread = thread + thread.start() + + def _on_bulk_action_progress(self, processed, total, label): + label_text = {"remove": "исключение", "add": "приглашение", "admin": "назначение админов"}.get(label, label) + self._update_operation_progress(processed, total, label_text) + self.status_label.setText(f"Статус: выполняется {label_text} ({processed}/{max(1, total)})...") + + def _on_bulk_action_finished(self, payload): + results = payload.get("results", []) if isinstance(payload, dict) else [] + QMessageBox.information(self, self._bulk_action_success_message_title, "\n".join(results)) + if self._bulk_clear_inputs_on_success: + self.vk_url_input.clear() + self.user_ids_to_process.clear() + self.set_ui_state(self.token is not None) + + def _on_bulk_action_auth_error(self, context, exc, action_name): + self._handle_vk_api_error(context or self._bulk_action_context or "bulk_action", exc, action_name=action_name) + + def _on_bulk_action_failed(self, error_text): + QMessageBox.warning(self, "Ошибка", f"Не удалось выполнить операцию: {error_text}") + + def _on_bulk_action_done(self): + self._finish_operation_progress() + self._set_busy(False) + self._bulk_worker = None + self._bulk_worker_thread = None + def _ensure_log_dir(self): os.makedirs(APP_DATA_DIR, exist_ok=True) @@ -1010,41 +1162,17 @@ class VkChatManager(QMainWindow): if confirm_dialog.clickedButton() != yes_button: return - results = [] - total = len(selected_chats) * len(user_infos) - processed = 0 - try: - action_label = "исключение" if action_type == "remove" else "приглашение" - self._set_busy(True, f"Статус: выполняется {action_label} (0/{total})...") - self._start_operation_progress(total, action_label, action_button=action_button) - for chat in selected_chats: - for user_id, user_info in user_infos.items(): - try: - if action_type == "remove": - self._vk_call_with_retry(self.vk.messages.removeChatUser, chat_id=chat['id'], member_id=user_id) - results.append(f"✓ '{user_info}' исключен из '{chat['title']}'.") - else: - params = {'chat_id': chat['id'], 'user_id': user_id} - if self.visible_messages_checkbox.isChecked(): - params['visible_messages_count'] = 250 - self._vk_call_with_retry(self.vk.messages.addChatUser, **params) - results.append(f"✓ '{user_info}' приглашен в '{chat['title']}'.") - except VkApiError as e: - if self._handle_vk_api_error("execute_user_action", e, action_name="выполнения операций с пользователями"): - return - results.append(f"✗ Ошибка для '{user_info}' в '{chat['title']}': {self._format_vk_error(e)}") - finally: - processed += 1 - self._update_operation_progress(processed, total, action_label) - self.status_label.setText(f"Статус: выполняется {action_label} ({processed}/{total})...") - finally: - self._finish_operation_progress() - self._set_busy(False) - - QMessageBox.information(self, "Результаты", "\n".join(results)) - self.vk_url_input.clear() - self.user_ids_to_process.clear() - self.set_ui_state(self.token is not None) + action_label = "исключение" if action_type == "remove" else "приглашение" + self._start_bulk_action_worker( + action_type=action_type, + selected_chats=selected_chats, + user_infos=user_infos, + action_label=action_label, + action_button=action_button, + success_message_title="Результаты", + clear_inputs_on_success=True, + ) + return def remove_user(self): self._execute_user_action("remove", action_button=self.remove_user_btn) @@ -1088,50 +1216,16 @@ class VkChatManager(QMainWindow): if confirm_dialog.clickedButton() != yes_button: return - # 4. Выполнение API запросов - results = [] - total = len(selected_chats) * len(user_infos) - processed = 0 - try: - action_label = "назначение админов" - self._set_busy(True, f"Статус: {action_label} (0/{total})...") - self._start_operation_progress(total, action_label) - for chat in selected_chats: - # VK API требует peer_id. Для чатов это 2000000000 + local_id - try: - peer_id = 2000000000 + int(chat['id']) - except ValueError: - results.append(f"✗ Ошибка ID чата: {chat['id']}") - continue - - for user_id, user_info in user_infos.items(): - try: - self._vk_call_with_retry( - self.vk.messages.setMemberRole, - peer_id=peer_id, - member_id=user_id, - role="admin" - ) - results.append(f"✓ '{user_info}' назначен админом в '{chat['title']}'.") - except VkApiError as e: - if self._handle_vk_api_error("set_user_admin", e, action_name="назначения администраторов"): - return - results.append(f"✗ Ошибка для '{user_info}' в '{chat['title']}': {self._format_vk_error(e)}") - finally: - processed += 1 - self._update_operation_progress(processed, total, action_label) - self.status_label.setText(f"Статус: {action_label} ({processed}/{total})...") - finally: - self._finish_operation_progress() - self._set_busy(False) - - # 5. Вывод результата - QMessageBox.information(self, "Результаты назначения", "\n".join(results)) - - # Очистка полей (по желанию, можно убрать эти две строки, если хотите оставить ввод) - self.vk_url_input.clear() - self.user_ids_to_process.clear() - self.set_ui_state(self.token is not None) + self._start_bulk_action_worker( + action_type="admin", + selected_chats=selected_chats, + user_infos=user_infos, + action_label="назначение админов", + action_button=None, + success_message_title="Результаты назначения", + clear_inputs_on_success=True, + ) + return # Refactor overrides: keep logic in service modules and thin UI orchestration here. def _process_links_list(self, links_list): diff --git a/services/auto_update_service.py b/services/auto_update_service.py index da73313..e32139c 100644 --- a/services/auto_update_service.py +++ b/services/auto_update_service.py @@ -9,6 +9,18 @@ import zipfile class AutoUpdateService: + @staticmethod + def _safe_extract_zip(archive, destination_dir): + destination_real = os.path.realpath(destination_dir) + for member in archive.infolist(): + member_name = member.filename or "" + if not member_name: + continue + target_path = os.path.realpath(os.path.join(destination_dir, member_name)) + if target_path != destination_real and not target_path.startswith(destination_real + os.sep): + raise RuntimeError(f"Unsafe path in update archive: {member_name}") + archive.extractall(destination_dir) + @staticmethod def download_update_archive(download_url, destination_path): request = urllib.request.Request( @@ -215,6 +227,6 @@ class AutoUpdateService: cls.verify_update_checksum(zip_path, checksum_url, download_name) os.makedirs(unpack_dir, exist_ok=True) with zipfile.ZipFile(zip_path, "r") as archive: - archive.extractall(unpack_dir) + cls._safe_extract_zip(archive, unpack_dir) source_dir = cls.locate_extracted_root(unpack_dir) return work_dir, source_dir diff --git a/services/update_service.py b/services/update_service.py index 6ad5e3c..3c65918 100644 --- a/services/update_service.py +++ b/services/update_service.py @@ -5,7 +5,42 @@ import urllib.error import urllib.request from urllib.parse import urlparse -from PySide6.QtCore import QObject, Signal +try: + from PySide6.QtCore import QObject, Signal +except Exception: + class _FallbackBoundSignal: + def __init__(self): + self._callbacks = [] + + def connect(self, callback): + if callback is not None: + self._callbacks.append(callback) + + def emit(self, *args, **kwargs): + for callback in list(self._callbacks): + callback(*args, **kwargs) + + class _FallbackSignalDescriptor: + def __init__(self): + self._storage_name = "" + + def __set_name__(self, owner, name): + self._storage_name = f"__fallback_signal_{name}" + + def __get__(self, instance, owner): + if instance is None: + return self + signal = instance.__dict__.get(self._storage_name) + if signal is None: + signal = _FallbackBoundSignal() + instance.__dict__[self._storage_name] = signal + return signal + + class QObject: + pass + + def Signal(*_args, **_kwargs): + return _FallbackSignalDescriptor() def _version_key(version_text):