ЧТМ:Расширения/BatchTools/1.45/0.2: различия между версиями

Материал из ЧТМ
Перейти к навигации Перейти к поиску
м Diman Russkov переименовал страницу ЧТМ:Расширения/BatchTools/0.2 в ЧТМ:Расширения/BatchTools/1.45/0.2
Нет описания правки
Строка 1: Строка 1:
## [0.2.0] — 2026-05-24
### Добавлено
- Новые локализованные сообщения об ошибках при недостатке прав доступа на чтение, удаление или восстановление конкретной страницы.
### Безопасность и исправления
- **Ужесточение проверок прав**: Внедрена проверка прав на уровне отдельных страниц перед выполнением действий.
- Заблокирована возможность несанкционированного удаления/восстановления страниц, скрытых от пользователя настройками прав или сторонними расширениями ограничений доступа (благодаря обязательной проверке права `read` через `$authority->definitelyCan`).
- Добавлена проверка на возможность удаления защищённых страниц — если у пользователя нет явного права на удаление конкретной статьи, она будет пропущена с выводом ошибки, не прерывая обработку остального списка.


== ROOT ==
== ROOT ==
Строка 4: Строка 13:
=== extension.json ===
=== extension.json ===
<syntaxhighlight lang="json">
<syntaxhighlight lang="json">
 
{
"name": "BatchTools",
"version": "0.2",
"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"
}
}
</syntaxhighlight>
</syntaxhighlight>


Строка 11: Строка 48:
=== en.json ===
=== en.json ===
<syntaxhighlight lang="json">
<syntaxhighlight lang="json">
 
{
"@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-error-cannot-read": "You do not have permission to read this page.",
"batchtools-error-cannot-delete": "You do not have permission to delete this page.",
"batchtools-error-cannot-restore": "You do not have permission to restore this page.",
"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."
}
</syntaxhighlight>
</syntaxhighlight>


=== ru.json ===
=== ru.json ===
<syntaxhighlight lang="json">
<syntaxhighlight lang="json">
 
{
"@metadata": {
"authors": []
},
"batchtools-desc": "Предоставляет инструменты для массовых операций",
"batchtools": "Массовые инструменты",
"right-deletebatch": "Массовое удаление страниц",
"action-deletebatch": "массово удалять страницы",
"right-undeletebatch": "Массовое восстановление страниц",
"action-undeletebatch": "массово восстанавливать страницы",
"batchtools-tab-delete": "Массовое удаление",
"batchtools-tab-undelete": "Массовое восстановление",
"batchtools-error-nopermissions": "У вас нет прав для доступа к массовым инструментам.",
"batchtools-error-cannot-read": "У вас нет прав для чтения этой страницы.",
"batchtools-error-cannot-delete": "У вас нет прав для удаления этой страницы.",
"batchtools-error-cannot-restore": "У вас нет прав для восстановления этой страницы.",
"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|страницы|страниц|страниц}} за один раз."
}
</syntaxhighlight>
</syntaxhighlight>


== includes ==
== includes ==
=== BatchTools.alias.php ===
<syntaxhighlight lang="php">
/**
* Aliases for special pages of the BatchTools extension
*/
$specialPageAliases = [];
/** English (English) */
$specialPageAliases['en'] = [
'BatchTools' => [ 'BatchTools' ],
];
/** Russian (Русский) */
$specialPageAliases['ru'] = [
'BatchTools' => [ 'BatchTools', 'Пакетные_инструменты' ],
];
</syntaxhighlight>


=== SpecialBatchTools.php ===
=== SpecialBatchTools.php ===
<syntaxhighlight lang="php">
<syntaxhighlight lang="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;
}
// Проверка прав на чтение и удаление конкретной страницы
$errKey = $this->checkPagePermissions( 'delete', $title );
if ( $errKey !== null ) {
$errors[] = "$pageName — " . $this->msg( $errKey )->text();
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;
}
// Проверка прав на чтение и восстановление конкретной страницы
$errKey = $this->checkPagePermissions( 'undelete', $title );
if ( $errKey !== null ) {
$errors[] = "$pageName — " . $this->msg( $errKey )->text();
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 );
}
/**
* Проверяет права доступа к странице.
* Любое целевое действие требует права на чтение ('read') этой страницы.
*
* @param string $action Действие ('delete', 'undelete' и т.д.)
* @param Title $title Проверяемая страница
* @return string|null Ключ сообщения об ошибке или null, если доступ разрешен
*/
private function checkPagePermissions( string $action, Title $title ): ?string {
$authority = $this->getAuthority();
// Защита от обхода Lockdown: если нет прав на чтение (read), блокируем любые действия.
if ( !$authority->definitelyCan( 'read', $title ) ) {
return 'batchtools-error-cannot-read';
}
// Проверка конкретного права на действие
if ( !$authority->definitelyCan( $action, $title ) ) {
if ( $action === 'undelete' ) {
return 'batchtools-error-cannot-restore';
}
return 'batchtools-error-cannot-' . $action;
}
return null;
}


private function parseTextareaList( $text ) {
$lines = explode( "\n", $text );
$result = [];
foreach ( $lines as $line ) {
$line = trim( $line );
if ( $line !== '' ) {
$result[] = $line;
}
}
return array_unique( $result );
}
}
</syntaxhighlight>
</syntaxhighlight>

Версия от 23:04, 29 мая 2026

    1. [0.2.0] — 2026-05-24
      1. Добавлено

- Новые локализованные сообщения об ошибках при недостатке прав доступа на чтение, удаление или восстановление конкретной страницы.

      1. Безопасность и исправления

- **Ужесточение проверок прав**: Внедрена проверка прав на уровне отдельных страниц перед выполнением действий. - Заблокирована возможность несанкционированного удаления/восстановления страниц, скрытых от пользователя настройками прав или сторонними расширениями ограничений доступа (благодаря обязательной проверке права `read` через `$authority->definitelyCan`). - Добавлена проверка на возможность удаления защищённых страниц — если у пользователя нет явного права на удаление конкретной статьи, она будет пропущена с выводом ошибки, не прерывая обработку остального списка.

ROOT

extension.json

{
	"name": "BatchTools",
	"version": "0.2",
	"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"
	}
}

i18n

en.json

{
	"@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-error-cannot-read": "You do not have permission to read this page.",
	"batchtools-error-cannot-delete": "You do not have permission to delete this page.",
	"batchtools-error-cannot-restore": "You do not have permission to restore this page.",
	"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."
}

ru.json

{
	"@metadata": {
		"authors": []
	},
	"batchtools-desc": "Предоставляет инструменты для массовых операций",
	"batchtools": "Массовые инструменты",
	"right-deletebatch": "Массовое удаление страниц",
	"action-deletebatch": "массово удалять страницы",
	"right-undeletebatch": "Массовое восстановление страниц",
	"action-undeletebatch": "массово восстанавливать страницы",
	"batchtools-tab-delete": "Массовое удаление",
	"batchtools-tab-undelete": "Массовое восстановление",
	"batchtools-error-nopermissions": "У вас нет прав для доступа к массовым инструментам.",
	"batchtools-error-cannot-read": "У вас нет прав для чтения этой страницы.",
	"batchtools-error-cannot-delete": "У вас нет прав для удаления этой страницы.",
	"batchtools-error-cannot-restore": "У вас нет прав для восстановления этой страницы.",
	"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|страницы|страниц|страниц}} за один раз."
}

includes

BatchTools.alias.php

/**
 * Aliases for special pages of the BatchTools extension
 */

$specialPageAliases = [];

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

/** Russian (Русский) */
$specialPageAliases['ru'] = [
	'BatchTools' => [ 'BatchTools', 'Пакетные_инструменты' ],
];

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;
					}

					// Проверка прав на чтение и удаление конкретной страницы
					$errKey = $this->checkPagePermissions( 'delete', $title );
					if ( $errKey !== null ) {
						$errors[] = "$pageName — " . $this->msg( $errKey )->text();
						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;
					}

					// Проверка прав на чтение и восстановление конкретной страницы
					$errKey = $this->checkPagePermissions( 'undelete', $title );
					if ( $errKey !== null ) {
						$errors[] = "$pageName — " . $this->msg( $errKey )->text();
						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 );
	}

	/**
	 * Проверяет права доступа к странице.
	 * Любое целевое действие требует права на чтение ('read') этой страницы.
	 *
	 * @param string $action Действие ('delete', 'undelete' и т.д.)
	 * @param Title $title Проверяемая страница
	 * @return string|null Ключ сообщения об ошибке или null, если доступ разрешен
	 */
	private function checkPagePermissions( string $action, Title $title ): ?string {
		$authority = $this->getAuthority();

		// Защита от обхода Lockdown: если нет прав на чтение (read), блокируем любые действия.
		if ( !$authority->definitelyCan( 'read', $title ) ) {
			return 'batchtools-error-cannot-read';
		}

		// Проверка конкретного права на действие
		if ( !$authority->definitelyCan( $action, $title ) ) {
			if ( $action === 'undelete' ) {
				return 'batchtools-error-cannot-restore';
			}
			return 'batchtools-error-cannot-' . $action;
		}

		return null;
	}

	private function parseTextareaList( $text ) {
		$lines = explode( "\n", $text );
		$result = [];
		foreach ( $lines as $line ) {
			$line = trim( $line );
			if ( $line !== '' ) {
				$result[] = $line;
			}
		}
		return array_unique( $result );
	}
}