ЧТМ:Расширения/BatchTools/1.45/0.1

Материал из ЧТМ
Перейти к навигации Перейти к поиску

45-0.1.zip СКАЧАТЬ ZIP

## [0.1.0] — 2026-05-24

### Добавлено
- Создана служебная страница `Special:BatchTools` (с поддержкой ЧПУ-алиаса «Пакетные инструменты» на русском языке).
- Реализована вкладка «Массовое удаление» для быстрой очистки ненужных страниц списком.
- Реализована вкладка «Массовое восстановление» для группового возврата ранее удалённых страниц.
- Добавлены новые права доступа: `deletebatch` (для удаления) и `undeletebatch` (для восстановления).
- Интегрирована локализация интерфейса на английском (`en`) и русском (`ru`) языках.

### Безопасность и ограничения
- Добавлена проверка прав пользователя (Authority) перед выполнением любых операций.
- Реализована защита от межсайтовой подделки запросов (CSRF) с помощью механизма Edit Token ядра MediaWiki.
- Введено ограничение на размер пакета: за одну операцию допускается обрабатывать не более 500 страниц.
- Реализовано экранирование имён страниц при выводе ошибок для предотвращения уязвимостей типа XSS.
BatchTools/
├── extension.json
├── i18n/
│   ├── BatchTools.alias.php
│   ├── en.json
│   └── ru.json
└── includes/
    └── SpecialBatchTools.php

extension.json

[править код]
{
	"name": "BatchTools",
	"version": "0.1",
	"author": "Diman Russkov",
	"descriptionmsg": "batchtools-desc",
	"type": "specialpage",
	"manifest_version": 2,
	"requires": {
		"MediaWiki": ">= 1.45.0"
	},
	"AvailableRights": [
		"deletebatch",
		"undeletebatch"
	],
	"SpecialPages": {
		"BatchTools": "MediaWiki\\Extension\\BatchTools\\SpecialBatchTools"
	},
	"AutoloadNamespaces": {
		"MediaWiki\\Extension\\BatchTools\\": "includes/"
	},
	"MessagesDirs": {
		"BatchTools": [
			"i18n"
		]
	},
	"ExtensionMessagesFiles": {
		"BatchToolsAlias": "i18n/BatchTools.alias.php"
	}
}

BatchTools.alias.php

[править код]
<?php
/**
 * Aliases for special pages of the BatchTools extension
 */

$specialPageAliases = [];

/** English (English) */
$specialPageAliases['en'] = [
	'BatchTools' => [ 'BatchTools' ],
];

/** Russian (Русский) */
$specialPageAliases['ru'] = [
	'BatchTools' => [ 'BatchTools', 'Пакетные_инструменты' ],
];
{
	"@metadata": {
		"authors": []
	},
	"batchtools-desc": "Provides tools for batch operations (mass delete, mass undelete, etc.)",
	"batchtools": "Batch tools",
	"right-deletebatch": "Mass delete pages",
	"action-deletebatch": "mass delete pages",
	"right-undeletebatch": "Mass undelete pages",
	"action-undeletebatch": "mass undelete pages",
	"batchtools-tab-delete": "Mass Delete",
	"batchtools-tab-undelete": "Mass Restore",
	"batchtools-error-nopermissions": "You do not have permission to access any of the batch tools.",
	"batchtools-massdelete-pages": "List of pages (one per line):",
	"batchtools-reason": "Reason:",
	"batchtools-massdelete-submit": "Delete pages",
	"batchtools-massdelete-success": "Successfully deleted $1 pages.",
	"batchtools-massdelete-errors": "Failed to delete the following pages:",
	"batchtools-massdelete-empty": "The page list is empty or contains invalid names.",
	"batchtools-massrestore-pages": "List of pages to restore (one per line):",
	"batchtools-massrestore-submit": "Restore pages",
	"batchtools-massrestore-success": "Successfully restored $1 pages.",
	"batchtools-massrestore-errors": "Failed to restore the following pages:",
	"batchtools-massrestore-empty": "The page list is empty or contains invalid names.",
	"batchtools-massrestore-norevisions": "The page was not deleted or has already been restored.",
	"batchtools-error-limit": "Limit exceeded! Please process no more than {{PLURAL:$1|page|pages|pages}} at a time."
}
{
	"@metadata": {
		"authors": []
	},
	"batchtools-desc": "Предоставляет инструменты для массовых операций",
	"batchtools": "Массовые инструменты",
	"right-deletebatch": "Массовое удаление страниц",
	"action-deletebatch": "массово удалять страницы",
	"right-undeletebatch": "Массовое восстановление страниц",
	"action-undeletebatch": "массово восстанавливать страницы",
	"batchtools-tab-delete": "Массовое удаление",
	"batchtools-tab-undelete": "Массовое восстановление",
	"batchtools-error-nopermissions": "У вас нет прав для доступа к массовым инструментам.",
	"batchtools-massdelete-pages": "Список страниц (по одной на строке):",
	"batchtools-reason": "Причина:",
	"batchtools-massdelete-submit": "Удалить страницы",
	"batchtools-massdelete-success": "Успешно удалено страниц: $1.",
	"batchtools-massdelete-errors": "Не удалось удалить следующие страницы:",
	"batchtools-massdelete-empty": "Список страниц пуст или содержит недопустимые имена.",
	"batchtools-massrestore-pages": "Список страниц для восстановления (по одной на строке):",
	"batchtools-massrestore-submit": "Восстановить страницы",
	"batchtools-massrestore-success": "Успешно восстановлено страниц: $1.",
	"batchtools-massrestore-errors": "Не удалось восстановить следующие страницы:",
	"batchtools-massrestore-empty": "Список страниц пуст или содержит недопустимые имена.",
	"batchtools-massrestore-norevisions": "Страница не была удалена или уже восстановлена.",
	"batchtools-error-limit": "Превышен лимит! Пожалуйста, обрабатывайте не более $1 {{PLURAL:$1|страницы|страниц|страниц}} за один раз."
}


SpecialBatchTools.php

[править код]
<?php

namespace MediaWiki\Extension\BatchTools;

use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Html\Html;
use MediaWiki\Exception\PermissionsError;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;
use MediaWiki\Status\Status;

class SpecialBatchTools extends SpecialPage {

	// Максимальное количество страниц за одну операцию
	private const MAX_BATCH_SIZE = 500;

	public function __construct() {
		parent::__construct( 'BatchTools' );
	}

	public function isListed() {
		$authority = $this->getAuthority();
		return $authority->isAllowed( 'deletebatch' ) || $authority->isAllowed( 'undeletebatch' );
	}

	public function execute( $par ) {
		$authority = $this->getAuthority();

		if ( !$authority->isAllowed( 'deletebatch' ) && !$authority->isAllowed( 'undeletebatch' ) ) {
			throw new PermissionsError( 'deletebatch' );
		}

		$this->setHeaders();
		$this->checkReadOnly();
		
		$out = $this->getOutput();
		$out->setPageTitle( $this->msg( 'batchtools' )->text() );

		$out->enableOOUI();
		$out->addModules( [
			'oojs-ui-core',
			'oojs-ui-widgets',
			'mediawiki.widgets'
		] );

		$tabs = [];
		if ( $authority->isAllowed( 'deletebatch' ) ) {
			$tabs['delete'] = $this->msg( 'batchtools-tab-delete' )->text();
		}
		if ( $authority->isAllowed( 'undeletebatch' ) ) {
			$tabs['undelete'] = $this->msg( 'batchtools-tab-undelete' )->text();
		}

		if ( empty( $tabs ) ) {
			$out->addHTML( Html::errorBox( $this->msg( 'batchtools-error-nopermissions' )->text() ) );
			return;
		}

		$request = $this->getRequest();
		$view = $request->getVal( 'view' );

		if ( $view === null || !array_key_exists( $view, $tabs ) ) {
			$view = array_key_first( $tabs );
		}

		$navHtml = '<div style="margin-bottom: 25px; font-size: 1.1em; border-bottom: 2px solid #a2a9b1; padding-bottom: 10px; display: flex; flex-wrap: wrap; gap: 10px;">';
		$tabLinks = [];
		foreach ( $tabs as $tabKey => $tabName ) {
			if ( $view === $tabKey ) {
				$tabLinks[] = '<b style="color: #202122; background: #eaecf0; padding: 5px 10px; border-radius: 2px;">' . htmlspecialchars( $tabName ) . '</b>';
			} else {
				$url = $this->getPageTitle()->getLocalURL( [ 'view' => $tabKey ] );
				$tabLinks[] = Html::element( 'a', [ 'href' => $url, 'style' => 'padding: 5px 10px;' ], $tabName );
			}
		}
		$navHtml .= implode( '<span style="color:#a2a9b1;">|</span>', $tabLinks ) . '</div>';
		$out->addHTML( $navHtml );

		// Вывод уведомлений об успехе/ошибках
		$this->printSessionResult();

		if ( $view === 'delete' ) {
			$this->showMassDeleteTab();
		} elseif ( $view === 'undelete' ) {
			$this->showMassRestoreTab();
		}
	}

	/**
	 * Выводит результаты выполнения массовых операций из сессии
	 */
	private function printSessionResult() {
		$session = $this->getRequest()->getSession();
		$result = $session->get( 'batchtools_result' );

		if ( $result ) {
			$session->remove( 'batchtools_result' );
			$html = '';

			$action = $result['action'] ?? 'delete';
			$msgSuccess = $action === 'delete' ? 'batchtools-massdelete-success' : 'batchtools-massrestore-success';
			$msgErrors = $action === 'delete' ? 'batchtools-massdelete-errors' : 'batchtools-massrestore-errors';

			if ( !empty( $result['success_count'] ) ) {
				$html .= Html::successBox( $this->msg( $msgSuccess, $result['success_count'] )->text() );
			}
			
			if ( !empty( $result['errors'] ) ) {
				$errHtml = '<b>' . $this->msg( $msgErrors )->text() . '</b><ul style="margin-top: 5px; margin-bottom: 0;">';
				foreach ( $result['errors'] as $err ) {
					// Абсолютная защита от XSS: все имена страниц экранируются здесь
					$errHtml .= '<li>' . htmlspecialchars( $err ) . '</li>';
				}
				$errHtml .= '</ul>';
				$html .= Html::errorBox( $errHtml );
			}
			
			if ( $html !== '' ) {
				$this->getOutput()->addHTML( '<div style="margin-bottom: 20px;">' . $html . '</div>' );
			}
		}
	}

	/**
	 * Вкладка: Массовое удаление
	 */
	private function showMassDeleteTab() {
		$out = $this->getOutput();
		$request = $this->getRequest();
		$user = $this->getUser();
		$authority = $this->getAuthority();

		if ( !$authority->isAllowed( 'deletebatch' ) ) {
			$out->addHTML( Html::errorBox( $this->msg( 'batchtools-error-nopermissions' )->text() ) );
			return;
		}

		if ( $request->wasPosted() && $user->matchEditToken( $request->getVal( 'wpEditToken' ) ) && $request->getVal( 'action_mass_delete' ) ) {
			$pagesText = $request->getVal( 'pages_list', '' );
			$reason = trim( $request->getVal( 'delete_reason', '' ) );
			$pages = $this->parseTextareaList( $pagesText );
			
			if ( empty( $pages ) ) {
				$out->addHTML( Html::errorBox( $this->msg( 'batchtools-massdelete-empty' )->text() ) );
			} elseif ( count( $pages ) > self::MAX_BATCH_SIZE ) {
				// Проверка на превышение лимита
				$request->getSession()->set( 'batchtools_result', [
					'action' => 'delete',
					'success_count' => 0,
					'errors' => [ $this->msg( 'batchtools-error-limit', self::MAX_BATCH_SIZE )->text() ]
				] );
				$out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'delete' ] ) );
				return;
			} else {
				$successCount = 0;
				$errors = [];
				$deletePageFactory = MediaWikiServices::getInstance()->getDeletePageFactory();

				foreach ( $pages as $pageName ) {
					$title = Title::newFromText( $pageName );
					
					if ( !$title || !$title->exists() ) {
						$errors[] = "$pageName — страница не существует или недопустимое имя.";
						continue;
					}

					$deletePage = $deletePageFactory->newDeletePage( $title->toPageIdentity(), $authority );
					$status = $deletePage->deleteIfAllowed( $reason );

					if ( $status->isOK() ) {
						$successCount++;
					} else {
						$statusObj = Status::wrap( $status );
						$errors[] = "$pageName — " . $statusObj->getMessage()->text();
					}
				}

				$request->getSession()->set( 'batchtools_result', [
					'action' => 'delete',
					'success_count' => $successCount,
					'errors' => $errors
				] );

				$out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'delete' ] ) );
				return;
			}
		}

		$pagesInput = new \OOUI\MultilineTextInputWidget([
			'name' => 'pages_list', 'rows' => 10, 'infusable' => true
		]);
		$reasonInput = new \OOUI\TextInputWidget([
			'name' => 'delete_reason', 'infusable' => true
		]);
		$submitBtn = new \OOUI\ButtonInputWidget([
			'type' => 'submit', 'name' => 'action_mass_delete', 'value' => '1',
			'label' => $this->msg( 'batchtools-massdelete-submit' )->text(),
			'flags' => [ 'primary', 'destructive' ], 'infusable' => true
		]);

		$formAction = $this->getPageTitle()->getLocalURL( [ 'view' => 'delete' ] );
		$html = '<form method="post" action="' . htmlspecialchars( $formAction ) . '">';
		$html .= Html::hidden( 'wpEditToken', $user->getEditToken() );
		$html .= '<div style="background:#eaecf0; padding:20px; border:1px solid #c8ccd1; border-radius: 2px; max-width: 600px;">';
		$html .= '<div style="margin-bottom: 15px;"><b>' . $this->msg( 'batchtools-massdelete-pages' )->text() . '</b><br>' . $pagesInput->toString() . '</div>';
		$html .= '<div style="margin-bottom: 20px;"><b>' . $this->msg( 'batchtools-reason' )->text() . '</b><br>' . $reasonInput->toString() . '</div>';
		$html .= '<div>' . $submitBtn->toString() . '</div></div></form>';

		$out->addHTML( $html );
	}

	/**
	 * Вкладка: Массовое восстановление
	 */
	private function showMassRestoreTab() {
		$out = $this->getOutput();
		$request = $this->getRequest();
		$user = $this->getUser();
		$authority = $this->getAuthority();

		if ( !$authority->isAllowed( 'undeletebatch' ) ) {
			$out->addHTML( Html::errorBox( $this->msg( 'batchtools-error-nopermissions' )->text() ) );
			return;
		}

		if ( $request->wasPosted() && $user->matchEditToken( $request->getVal( 'wpEditToken' ) ) && $request->getVal( 'action_mass_restore' ) ) {
			$pagesText = $request->getVal( 'pages_list', '' );
			$reason = trim( $request->getVal( 'restore_reason', '' ) );
			$pages = $this->parseTextareaList( $pagesText );
			
			if ( empty( $pages ) ) {
				$out->addHTML( Html::errorBox( $this->msg( 'batchtools-massrestore-empty' )->text() ) );
			} elseif ( count( $pages ) > self::MAX_BATCH_SIZE ) {
				// Проверка на превышение лимита
				$request->getSession()->set( 'batchtools_result', [
					'action' => 'restore',
					'success_count' => 0,
					'errors' => [ $this->msg( 'batchtools-error-limit', self::MAX_BATCH_SIZE )->text() ]
				] );
				$out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'undelete' ] ) );
				return;
			} else {
				$successCount = 0;
				$errors = [];
				$undeletePageFactory = MediaWikiServices::getInstance()->getUndeletePageFactory();

				foreach ( $pages as $pageName ) {
					$title = Title::newFromText( $pageName );
					
					if ( !$title || !$title->canExist() ) {
						$errors[] = "$pageName — недопустимое имя страницы.";
						continue;
					}

					$undeletePage = $undeletePageFactory->newUndeletePage( $title->toPageIdentity(), $authority );
					$status = $undeletePage->undeleteIfAllowed( $reason );

					if ( $status->isOK() ) {
						$val = $status->getValue();
						$revs = $val['revs'] ?? 0;
						$files = $val['files'] ?? 0;

						if ( $revs > 0 || $files > 0 ) {
							$successCount++;
						} else {
							$errors[] = "$pageName — " . $this->msg( 'batchtools-massrestore-norevisions' )->text();
						}
					} else {
						$statusObj = Status::wrap( $status );
						$errors[] = "$pageName — " . $statusObj->getMessage()->text();
					}
				}

				$request->getSession()->set( 'batchtools_result', [
					'action' => 'restore',
					'success_count' => $successCount,
					'errors' => $errors
				] );

				$out->redirect( $this->getPageTitle()->getFullURL( [ 'view' => 'undelete' ] ) );
				return;
			}
		}

		$pagesInput = new \OOUI\MultilineTextInputWidget([
			'name' => 'pages_list', 'rows' => 10, 'infusable' => true
		]);
		$reasonInput = new \OOUI\TextInputWidget([
			'name' => 'restore_reason', 'infusable' => true
		]);
		$submitBtn = new \OOUI\ButtonInputWidget([
			'type' => 'submit', 'name' => 'action_mass_restore', 'value' => '1',
			'label' => $this->msg( 'batchtools-massrestore-submit' )->text(),
			'flags' => [ 'primary', 'progressive' ], 'infusable' => true
		]);

		$formAction = $this->getPageTitle()->getLocalURL( [ 'view' => 'undelete' ] );
		$html = '<form method="post" action="' . htmlspecialchars( $formAction ) . '">';
		$html .= Html::hidden( 'wpEditToken', $user->getEditToken() );
		$html .= '<div style="background:#eaf3ff; padding:20px; border:1px solid #36c; border-radius: 2px; max-width: 600px;">';
		$html .= '<div style="margin-bottom: 15px;"><b>' . $this->msg( 'batchtools-massrestore-pages' )->text() . '</b><br>' . $pagesInput->toString() . '</div>';
		$html .= '<div style="margin-bottom: 20px;"><b>' . $this->msg( 'batchtools-reason' )->text() . '</b><br>' . $reasonInput->toString() . '</div>';
		$html .= '<div>' . $submitBtn->toString() . '</div></div></form>';

		$out->addHTML( $html );
	}

	private function parseTextareaList( $text ) {
		$lines = explode( "\n", $text );
		$result = [];
		foreach ( $lines as $line ) {
			$line = trim( $line );
			if ( $line !== '' ) {
				$result[] = $line;
			}
		}
		return array_unique( $result );
	}
}
BatchTools
Mediawiki 1.45 0.9 • 0.8 • 0.7 • 0.6 • 0.5 • 0.4 • 0.3 • 0.2 • 0.1