Файловый менеджер - Редактировать - /var/www/html/includes.zip
Ðазад
PK ! ]��s PDFEmbedHooks.phpnu �[��� <?php use MediaWiki\MediaWikiServices; class PDFEmbedHooks { /** * Sets up this extensions parser functions. * * @param Parser $parser */ public static function onParserFirstCallInit( Parser $parser ) { $parser->setHook( 'pdf', [ __CLASS__, 'generateTag' ] ); } /** * Generates the PDF object tag. * * @param string $file Namespace prefixed article of the PDF file to display. * @param array $args Arguments on the tag. * @param Parser $parser * @param PPFrame $frame * @return string HTML */ public static function generateTag( $file, $args, Parser $parser, PPFrame $frame ) { global $wgPdfEmbed; $parser->getOutput()->updateCacheExpiry( 0 ); if ( strstr( $file, '{{{' ) !== false ) { $file = $parser->recursiveTagParse( $file, $frame ); } $context = RequestContext::getMain(); $request = $context->getRequest(); $services = MediaWikiServices::getInstance(); if ( $request->getVal( 'action' ) == 'edit' || $request->getVal( 'action' ) == 'submit' ) { $user = $context->getUser(); } else { $user = $services->getUserFactory()->newFromName( $parser->getRevisionUser() ?? 'Unknown user' ); } if ( !$user ) { return self::error( 'embed_pdf_invalid_user' ); } if ( !$user->isAllowed( 'embed_pdf' ) ) { return self::error( 'embed_pdf_no_permission' ); } if ( !$file || !preg_match( '#(.+?)\.pdf#is', $file ) ) { return self::error( 'embed_pdf_blank_file' ); } $title = $services->getTitleFactory()->newFromText( $file ); if ( !$title ) { return self::error( 'embed_pdf_blank_file' ); } $file = $services->getRepoGroup()->findFile( $title ); if ( array_key_exists( 'width', $args ) ) { $width = intval( $parser->recursiveTagParse( $args['width'], $frame ) ); } else { $width = intval( $wgPdfEmbed['width'] ); } if ( array_key_exists( 'height', $args ) ) { $height = intval( $parser->recursiveTagParse( $args['height'], $frame ) ); } else { $height = intval( $wgPdfEmbed['height'] ); } if ( array_key_exists( 'page', $args ) ) { $page = intval( $parser->recursiveTagParse( $args['page'], $frame ) ); } else { $page = 1; } if ( $file !== false ) { return self::embed( $file, $width, $height, $page ); } else { return self::error( 'embed_pdf_invalid_file' ); } } /** * Returns a HTML object as string. * * @param File $file * @param int $width Width of the object. * @param int $height Height of the object. * @param int $page * @return string HTML object. */ private static function embed( File $file, $width, $height, $page ) { return Html::rawElement( 'iframe', [ 'width' => $width, 'height' => $height, 'src' => $file->getFullUrl() . '#page=' . $page, 'style' => 'max-width: 100%;', 'loading' => 'lazy', ] ); } /** * Returns a standard error message. * * @param string $messageKey Error message key to display. * @return string HTML error message. */ private static function error( $messageKey ) { return Html::errorBox( wfMessage( $messageKey )->escaped() ); } } PK ! )h�6 6 Hooks.phpnu �Iw�� <?php /** * Wikitext scripting infrastructure for MediaWiki: hooks. * Copyright (C) 2009-2012 Victor Vasiliev <vasilvv@gmail.com> * https://www.mediawiki.org/ * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html */ // phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName namespace MediaWiki\Extension\Scribunto; use Article; use MediaWiki\Config\Config; use MediaWiki\Content\Content; use MediaWiki\Context\IContextSource; use MediaWiki\EditPage\EditPage; use MediaWiki\Hook\EditFilterMergedContentHook; use MediaWiki\Hook\EditPage__showReadOnlyForm_initialHook; use MediaWiki\Hook\EditPage__showStandardInputs_optionsHook; use MediaWiki\Hook\EditPageBeforeEditButtonsHook; use MediaWiki\Hook\ParserClearStateHook; use MediaWiki\Hook\ParserClonedHook; use MediaWiki\Hook\ParserFirstCallInitHook; use MediaWiki\Hook\ParserLimitReportFormatHook; use MediaWiki\Hook\ParserLimitReportPrepareHook; use MediaWiki\Hook\SoftwareInfoHook; use MediaWiki\Html\Html; use MediaWiki\MediaWikiServices; use MediaWiki\Output\OutputPage; use MediaWiki\Page\Hook\ArticleViewHeaderHook; use MediaWiki\Parser\Parser; use MediaWiki\Parser\ParserOutput; use MediaWiki\Parser\PPFrame; use MediaWiki\Parser\PPNode; use MediaWiki\Revision\Hook\ContentHandlerDefaultModelForHook; use MediaWiki\Status\Status; use MediaWiki\Title\Title; use MediaWiki\User\User; use MediaWiki\WikiMap\WikiMap; use UtfNormal\Validator; use Wikimedia\ObjectCache\EmptyBagOStuff; use Wikimedia\PSquare; /** * Hooks for the Scribunto extension. */ class Hooks implements SoftwareInfoHook, ParserFirstCallInitHook, ParserLimitReportPrepareHook, ParserLimitReportFormatHook, ParserClearStateHook, ParserClonedHook, EditPage__showStandardInputs_optionsHook, EditPage__showReadOnlyForm_initialHook, EditPageBeforeEditButtonsHook, EditFilterMergedContentHook, ArticleViewHeaderHook, ContentHandlerDefaultModelForHook { private Config $config; public function __construct( Config $config ) { $this->config = $config; } /** * Define content handler constant upon extension registration */ public static function onRegistration() { define( 'CONTENT_MODEL_SCRIBUNTO', 'Scribunto' ); } /** * Get software information for Special:Version * * @param array &$software * @return bool */ public function onSoftwareInfo( &$software ) { $engine = Scribunto::newDefaultEngine(); $engine->setTitle( Title::makeTitle( NS_SPECIAL, 'Version' ) ); $engine->getSoftwareInfo( $software ); return true; } /** * Register parser hooks. * * @param Parser $parser * @return void */ public function onParserFirstCallInit( $parser ) { $parser->setFunctionHook( 'invoke', [ $this, 'invokeHook' ], Parser::SFH_OBJECT_ARGS ); } /** * Called when the interpreter is to be reset. * * @param Parser $parser * @return void */ public function onParserClearState( $parser ) { Scribunto::resetParserEngine( $parser ); } /** * Called when the parser is cloned * * @param Parser $parser * @return void */ public function onParserCloned( $parser ) { $parser->scribunto_engine = null; } /** * Hook function for {{#invoke:module|func}} * * @param Parser $parser * @param PPFrame $frame * @param PPNode[] $args * @return string */ public function invokeHook( Parser $parser, PPFrame $frame, array $args ) { try { if ( count( $args ) < 2 ) { throw new ScribuntoException( 'scribunto-common-nofunction' ); } $moduleName = trim( $frame->expand( $args[0] ) ); $engine = Scribunto::getParserEngine( $parser ); $title = Title::makeTitleSafe( NS_MODULE, $moduleName ); if ( !$title || !$title->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) { throw new ScribuntoException( 'scribunto-common-nosuchmodule', [ 'args' => [ $moduleName ] ] ); } $module = $engine->fetchModuleFromParser( $title ); if ( !$module ) { throw new ScribuntoException( 'scribunto-common-nosuchmodule', [ 'args' => [ $moduleName ] ] ); } $functionName = trim( $frame->expand( $args[1] ) ); $bits = $args[1]->splitArg(); unset( $args[0] ); unset( $args[1] ); // If $bits['index'] is empty, then the function name was parsed as a // key=value pair (because of an equals sign in it), and since it didn't // have an index, we don't need the index offset. $childFrame = $frame->newChild( $args, $title, $bits['index'] === '' ? 0 : 1 ); if ( $this->config->get( 'ScribuntoGatherFunctionStats' ) ) { $u0 = $engine->getResourceUsage( $engine::CPU_SECONDS ); $result = $module->invoke( $functionName, $childFrame ); $u1 = $engine->getResourceUsage( $engine::CPU_SECONDS ); if ( $u1 > $u0 ) { $timingMs = (int)( 1000 * ( $u1 - $u0 ) ); // Since the overhead of stats is worst when #invoke // calls are very short, don't process measurements <= 20ms. if ( $timingMs > 20 ) { $this->reportTiming( $moduleName, $functionName, $timingMs ); } } } else { $result = $module->invoke( $functionName, $childFrame ); } return Validator::cleanUp( strval( $result ) ); } catch ( ScribuntoException $e ) { $trace = $e->getScriptTraceHtml( [ 'msgOptions' => [ 'content' ] ] ); $html = Html::element( 'p', [], $e->getMessage() ); if ( $trace !== false ) { $html .= Html::element( 'p', [], wfMessage( 'scribunto-common-backtrace' )->inContentLanguage()->text() ) . $trace; } else { $html .= Html::element( 'p', [], wfMessage( 'scribunto-common-no-details' )->inContentLanguage()->text() ); } // Index this error by a uniq ID so that we are independent of // page parse order. (T300979) // (The only way this will conflict is if two exceptions have // exactly the same backtrace, in which case we really only need // one copy of the backtrace!) $uuid = substr( sha1( $html ), -8 ); $parserOutput = $parser->getOutput(); $parserOutput->appendExtensionData( 'ScribuntoErrors', $uuid ); $parserOutput->setExtensionData( "ScribuntoErrors-$uuid", $html ); $parserOutput->appendJsConfigVar( 'ScribuntoErrors', $uuid ); $parserOutput->setJsConfigVar( "ScribuntoErrors-$uuid", $html ); // These methods are idempotent; doesn't hurt to call them every // time. $parser->addTrackingCategory( 'scribunto-common-error-category' ); $parserOutput->addModules( [ 'ext.scribunto.errors' ] ); $id = "mw-scribunto-error-$uuid"; $parserError = htmlspecialchars( $e->getMessage() ); // #iferror-compatible error element return "<strong class=\"error\"><span class=\"scribunto-error $id\">" . $parserError . "</span></strong>"; } } /** * Record stats on slow function calls. * * @param string $moduleName * @param string $functionName * @param int $timing Function execution time in milliseconds. */ private function reportTiming( $moduleName, $functionName, $timing ) { if ( !$this->config->get( 'ScribuntoGatherFunctionStats' ) ) { return; } $threshold = $this->config->get( 'ScribuntoSlowFunctionThreshold' ); if ( !( is_float( $threshold ) && $threshold > 0 && $threshold < 1 ) ) { return; } $objectcachefactory = MediaWikiServices::getInstance()->getObjectCacheFactory(); static $cache; if ( !$cache ) { $cache = $objectcachefactory->getLocalServerInstance( CACHE_NONE ); } // To control the sampling rate, we keep a compact histogram of // observations in APC, and extract the Nth percentile (specified // via $wgScribuntoSlowFunctionThreshold; defaults to 0.90). // We need a non-empty local server cache for that (e.g. php-apcu). if ( $cache instanceof EmptyBagOStuff ) { return; } $cacheVersion = '3'; $key = $cache->makeGlobalKey( 'scribunto-stats', $cacheVersion, (string)$threshold ); // This is a classic "read-update-write" critical section with no // mutual exclusion, but the only consequence is that some samples // will be dropped. We only need enough samples to estimate the // shape of the data, so that's fine. $ps = $cache->get( $key ) ?: new PSquare( $threshold ); $ps->addObservation( $timing ); $cache->set( $key, $ps, 60 ); if ( $ps->getCount() < 1000 || $timing < $ps->getValue() ) { return; } static $stats; if ( !$stats ) { $stats = MediaWikiServices::getInstance()->getStatsFactory(); } $statAction = WikiMap::getCurrentWikiId() . '__' . $moduleName . '__' . $functionName; $stats->getTiming( 'scribunto_traces_seconds' ) ->setLabel( 'action', $statAction ) ->copyToStatsdAt( 'scribunto.traces.' . $statAction ) ->observe( $timing ); } /** * Set the Scribunto content handler for modules * * @param Title $title * @param string &$model * @return void */ public function onContentHandlerDefaultModelFor( $title, &$model ) { if ( $model === 'sanitized-css' ) { // Let TemplateStyles override Scribunto return; } if ( $title->getNamespace() === NS_MODULE ) { if ( str_ends_with( $title->getText(), '.json' ) ) { $model = CONTENT_MODEL_JSON; } elseif ( !Scribunto::isDocPage( $title ) ) { $model = CONTENT_MODEL_SCRIBUNTO; } } } /** * Adds report of number of evaluations by the single wikitext page. * * @param Parser $parser * @param ParserOutput $parserOutput * @return void */ public function onParserLimitReportPrepare( $parser, $parserOutput ) { if ( Scribunto::isParserEnginePresent( $parser ) ) { $engine = Scribunto::getParserEngine( $parser ); $engine->reportLimitData( $parserOutput ); } } /** * Formats the limit report data * * @param string $key * @param mixed &$value * @param string &$report * @param bool $isHTML * @param bool $localize * @return bool */ public function onParserLimitReportFormat( $key, &$value, &$report, $isHTML, $localize ) { $engine = Scribunto::newDefaultEngine(); return $engine->formatLimitData( $key, $value, $report, $isHTML, $localize ); } /** * EditPage::showStandardInputs:options hook * * @param EditPage $editor * @param OutputPage $output * @param int &$tab Current tabindex * @return void */ public function onEditPage__showStandardInputs_options( $editor, $output, &$tab ) { if ( $editor->getTitle()->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) { $output->addModules( 'ext.scribunto.edit' ); $editor->editFormTextAfterTools .= '<div id="mw-scribunto-console"></div>'; } } /** * EditPage::showReadOnlyForm:initial hook * * @param EditPage $editor * @param OutputPage $output * @return void */ public function onEditPage__showReadOnlyForm_initial( $editor, $output ) { if ( $editor->getTitle()->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) { $output->addModules( 'ext.scribunto.edit' ); $editor->editFormTextAfterContent .= '<div id="mw-scribunto-console"></div>'; } } /** * EditPageBeforeEditButtons hook * * @param EditPage $editor * @param array &$buttons Button array * @param int &$tabindex Current tabindex * @return void */ public function onEditPageBeforeEditButtons( $editor, &$buttons, &$tabindex ) { if ( $editor->getTitle()->hasContentModel( CONTENT_MODEL_SCRIBUNTO ) ) { unset( $buttons['preview'] ); } } /** * @param IContextSource $context * @param Content $content * @param Status $status * @param string $summary * @param User $user * @param bool $minoredit * @return bool */ public function onEditFilterMergedContent( IContextSource $context, Content $content, Status $status, $summary, User $user, $minoredit ) { $title = $context->getTitle(); if ( !$content instanceof ScribuntoContent ) { return true; } $contentHandlerFactory = MediaWikiServices::getInstance()->getContentHandlerFactory(); $contentHandler = $contentHandlerFactory->getContentHandler( $content->getModel() ); '@phan-var ScribuntoContentHandler $contentHandler'; $validateStatus = $contentHandler->validate( $content, $title ); if ( $validateStatus->isOK() ) { return true; } $status->merge( $validateStatus ); if ( isset( $validateStatus->value->params['module'] ) ) { $module = $validateStatus->value->params['module']; $line = $validateStatus->value->params['line']; if ( $module === $title->getPrefixedDBkey() && preg_match( '/^\d+$/', $line ) ) { $out = $context->getOutput(); $out->addInlineScript( 'window.location.hash = ' . Html::encodeJsVar( "#mw-ce-l$line" ) ); } } if ( !$status->isOK() ) { // @todo Remove this line after this extension do not support mediawiki version 1.36 and before $status->value = EditPage::AS_HOOK_ERROR_EXPECTED; return false; } return true; } /** * @param Article $article * @param bool|ParserOutput|null &$outputDone * @param bool &$pcache * @return void */ public function onArticleViewHeader( $article, &$outputDone, &$pcache ) { $title = $article->getTitle(); if ( Scribunto::isDocPage( $title, $forModule ) ) { $article->getContext()->getOutput()->addHTML( wfMessage( 'scribunto-doc-page-header', $forModule->getPrefixedText() )->parseAsBlock() ); } } } PK ! L��V V SpecialNuke.phpnu �Iw�� <?php namespace MediaWiki\Extension\Nuke; use DeletePageJob; use ErrorPageError; use JobQueueGroup; use MediaWiki\CheckUser\Services\CheckUserTemporaryAccountsByIPLookup; use MediaWiki\CommentStore\CommentStore; use MediaWiki\Extension\Nuke\Hooks\NukeHookRunner; use MediaWiki\Html\Html; use MediaWiki\Html\ListToggle; use MediaWiki\HTMLForm\HTMLForm; use MediaWiki\Language\Language; use MediaWiki\Page\File\FileDeleteForm; use MediaWiki\Permissions\PermissionManager; use MediaWiki\Request\WebRequest; use MediaWiki\SpecialPage\SpecialPage; use MediaWiki\Title\NamespaceInfo; use MediaWiki\Title\Title; use MediaWiki\User\Options\UserOptionsLookup; use MediaWiki\User\User; use MediaWiki\User\UserFactory; use MediaWiki\User\UserNamePrefixSearch; use MediaWiki\User\UserNameUtils; use MediaWiki\Xml\Xml; use OOUI\DropdownInputWidget; use OOUI\FieldLayout; use OOUI\TextInputWidget; use PermissionsError; use RepoGroup; use UserBlockedError; use Wikimedia\IPUtils; use Wikimedia\Rdbms\IConnectionProvider; use Wikimedia\Rdbms\IExpression; use Wikimedia\Rdbms\LikeMatch; use Wikimedia\Rdbms\LikeValue; use Wikimedia\Rdbms\SelectQueryBuilder; class SpecialNuke extends SpecialPage { /** @var NukeHookRunner|null */ private $hookRunner; private JobQueueGroup $jobQueueGroup; private IConnectionProvider $dbProvider; private PermissionManager $permissionManager; private RepoGroup $repoGroup; private UserFactory $userFactory; private UserOptionsLookup $userOptionsLookup; private UserNamePrefixSearch $userNamePrefixSearch; private UserNameUtils $userNameUtils; private NamespaceInfo $namespaceInfo; private Language $contentLanguage; /** @var CheckUserTemporaryAccountsByIPLookup|null */ private $checkUserTemporaryAccountsByIPLookup = null; /** * @inheritDoc */ public function __construct( JobQueueGroup $jobQueueGroup, IConnectionProvider $dbProvider, PermissionManager $permissionManager, RepoGroup $repoGroup, UserFactory $userFactory, UserOptionsLookup $userOptionsLookup, UserNamePrefixSearch $userNamePrefixSearch, UserNameUtils $userNameUtils, NamespaceInfo $namespaceInfo, Language $contentLanguage, $checkUserTemporaryAccountsByIPLookup = null ) { parent::__construct( 'Nuke', 'nuke' ); $this->jobQueueGroup = $jobQueueGroup; $this->dbProvider = $dbProvider; $this->permissionManager = $permissionManager; $this->repoGroup = $repoGroup; $this->userFactory = $userFactory; $this->userOptionsLookup = $userOptionsLookup; $this->userNamePrefixSearch = $userNamePrefixSearch; $this->userNameUtils = $userNameUtils; $this->namespaceInfo = $namespaceInfo; $this->contentLanguage = $contentLanguage; $this->checkUserTemporaryAccountsByIPLookup = $checkUserTemporaryAccountsByIPLookup; } /** * @inheritDoc * @codeCoverageIgnore */ public function doesWrites() { return true; } /** * @param null|string $par */ public function execute( $par ) { $this->setHeaders(); $this->checkPermissions(); $this->checkReadOnly(); $this->outputHeader(); $this->addHelpLink( 'Help:Extension:Nuke' ); $currentUser = $this->getUser(); $block = $currentUser->getBlock(); // appliesToRight is presently a no-op, since there is no handling for `delete`, // and so will return `null`. `true` will be returned if the block actively // applies to `delete`, and both `null` and `true` should result in an error if ( $block && ( $block->isSitewide() || ( $block->appliesToRight( 'delete' ) !== false ) ) ) { throw new UserBlockedError( $block ); } $req = $this->getRequest(); $target = trim( $req->getText( 'target', $par ?? '' ) ); // Normalise name if ( $target !== '' ) { $user = $this->userFactory->newFromName( $target ); if ( $user ) { $target = $user->getName(); } } $reason = $this->getDeleteReason( $this->getRequest(), $target ); $limit = $req->getInt( 'limit', 500 ); $namespace = $req->getIntOrNull( 'namespace' ); if ( $req->wasPosted() && $currentUser->matchEditToken( $req->getVal( 'wpEditToken' ) ) ) { if ( $req->getRawVal( 'action' ) === 'delete' ) { $pages = $req->getArray( 'pages' ); if ( $pages ) { $this->doDelete( $pages, $reason ); return; } } elseif ( $req->getRawVal( 'action' ) === 'submit' ) { // if the target is an ip addresss and temp account lookup is available, // list pages created by the ip user or by temp accounts associated with the ip address if ( $this->checkUserTemporaryAccountsByIPLookup && IPUtils::isValid( $target ) ) { $this->assertUserCanAccessTemporaryAccounts( $currentUser ); $tempnames = $this->getTempAccountData( $target ); $reason = $this->getDeleteReason( $this->getRequest(), $target, true ); $this->listForm( $target, $reason, $limit, $namespace, $tempnames ); } else { // otherwise just list pages normally $this->listForm( $target, $reason, $limit, $namespace ); } } else { $this->promptForm(); } } elseif ( $target === '' ) { $this->promptForm(); } else { $this->listForm( $target, $reason, $limit, $namespace ); } } /** * Does the user have the appropriate permissions and have they enabled in preferences? * Adapted from MediaWiki\CheckUser\Api\Rest\Handler\AbstractTemporaryAccountHandler::checkPermissions * * @param User $currentUser * * @throws PermissionsError if the user does not have the 'checkuser-temporary-account' right * @throws ErrorPageError if the user has not enabled the 'checkuser-temporary-account-enabled' preference */ private function assertUserCanAccessTemporaryAccounts( User $currentUser ) { if ( !$currentUser->isAllowed( 'checkuser-temporary-account-no-preference' ) ) { if ( !$currentUser->isAllowed( 'checkuser-temporary-account' ) ) { throw new PermissionsError( 'checkuser-temporary-account' ); } if ( !$this->userOptionsLookup->getOption( $currentUser, 'checkuser-temporary-account-enable' ) ) { throw new ErrorPageError( $this->msg( 'checkuser-ip-contributions-permission-error-title' ), $this->msg( 'checkuser-ip-contributions-permission-error-description' ) ); } } } /** * Given an IP address, return a list of temporary accounts that are known to have edited from the IP. * * Calls to this method result in a log entry being generated for the logged-in user account making the request. * @param string $ip The IP address used for looking up temporary account names. * The address will be normalized in the IP lookup service. * @return string[] A list of temporary account usernames associated with the IP address */ private function getTempAccountData( string $ip ): array { // Requires CheckUserTemporaryAccountsByIPLookup service if ( !$this->checkUserTemporaryAccountsByIPLookup ) { return []; } $status = $this->checkUserTemporaryAccountsByIPLookup->get( $ip, $this->getAuthority(), true ); if ( $status->isGood() ) { return $status->getValue(); } return []; } /** * Prompt for a username or IP address. * * @param string $userName */ protected function promptForm( string $userName = '' ): void { $out = $this->getOutput(); if ( $this->checkUserTemporaryAccountsByIPLookup ) { $out->addWikiMsg( 'nuke-tools-tempaccount' ); } else { $out->addWikiMsg( 'nuke-tools' ); } $formDescriptor = [ 'nuke-target' => [ 'id' => 'nuke-target', 'default' => $userName, 'label' => $this->msg( 'nuke-userorip' )->text(), 'type' => 'user', 'name' => 'target', 'autofocus' => true ], 'nuke-pattern' => [ 'id' => 'nuke-pattern', 'label' => $this->msg( 'nuke-pattern' )->text(), 'maxLength' => 40, 'type' => 'text', 'name' => 'pattern' ], 'namespace' => [ 'id' => 'nuke-namespace', 'type' => 'namespaceselect', 'label' => $this->msg( 'nuke-namespace' )->text(), 'all' => 'all', 'name' => 'namespace' ], 'limit' => [ 'id' => 'nuke-limit', 'maxLength' => 7, 'default' => 500, 'label' => $this->msg( 'nuke-maxpages' )->text(), 'type' => 'int', 'name' => 'limit' ] ]; HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) ->setName( 'massdelete' ) ->setFormIdentifier( 'massdelete' ) ->setWrapperLegendMsg( 'nuke' ) ->setSubmitTextMsg( 'nuke-submit-user' ) ->setSubmitName( 'nuke-submit-user' ) ->setAction( $this->getPageTitle()->getLocalURL( 'action=submit' ) ) ->prepareForm() ->displayForm( false ); } /** * Display list of pages to delete. * * @param string $username * @param string $reason * @param int $limit * @param int|null $namespace * @param string[] $tempnames */ protected function listForm( $username, $reason, $limit, $namespace = null, $tempnames = [] ): void { $out = $this->getOutput(); $pages = $this->getNewPages( $username, $limit, $namespace, $tempnames ); if ( !$pages ) { if ( $username === '' ) { $out->addWikiMsg( 'nuke-nopages-global' ); } else { $out->addWikiMsg( 'nuke-nopages', $username ); } $this->promptForm( $username ); return; } $out->addModules( 'ext.nuke.confirm' ); $out->addModuleStyles( [ 'ext.nuke.styles', 'mediawiki.interface.helpers.styles' ] ); if ( $username === '' ) { $out->addWikiMsg( 'nuke-list-multiple' ); } elseif ( $tempnames ) { $out->addWikiMsg( 'nuke-list-tempaccount', $username ); } else { $out->addWikiMsg( 'nuke-list', $username ); } $nuke = $this->getPageTitle(); $options = Xml::listDropdownOptions( $this->msg( 'deletereason-dropdown' )->inContentLanguage()->text(), [ 'other' => $this->msg( 'deletereasonotherlist' )->inContentLanguage()->text() ] ); $dropdown = new FieldLayout( new DropdownInputWidget( [ 'name' => 'wpDeleteReasonList', 'inputId' => 'wpDeleteReasonList', 'tabIndex' => 1, 'infusable' => true, 'value' => '', 'options' => Xml::listDropdownOptionsOoui( $options ), ] ), [ 'label' => $this->msg( 'deletecomment' )->text(), 'align' => 'top', ] ); $reasonField = new FieldLayout( new TextInputWidget( [ 'name' => 'wpReason', 'inputId' => 'wpReason', 'tabIndex' => 2, 'maxLength' => CommentStore::COMMENT_CHARACTER_LIMIT, 'infusable' => true, 'value' => $reason, 'autofocus' => true, ] ), [ 'label' => $this->msg( 'deleteotherreason' )->text(), 'align' => 'top', ] ); $out->enableOOUI(); $out->addHTML( Html::openElement( 'form', [ 'action' => $nuke->getLocalURL( 'action=delete' ), 'method' => 'post', 'name' => 'nukelist' ] ) . Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) . $dropdown . $reasonField . // Select: All, None, Invert ( new ListToggle( $this->getOutput() ) )->getHTML() . '<ul>' ); $wordSeparator = $this->msg( 'word-separator' )->escaped(); $commaSeparator = $this->msg( 'comma-separator' )->escaped(); $pipeSeparator = $this->msg( 'pipe-separator' )->escaped(); $linkRenderer = $this->getLinkRenderer(); $localRepo = $this->repoGroup->getLocalRepo(); foreach ( $pages as [ $title, $userName ] ) { /** * @var $title Title */ $image = $title->inNamespace( NS_FILE ) ? $localRepo->newFile( $title ) : false; $thumb = $image && $image->exists() ? $image->transform( [ 'width' => 120, 'height' => 120 ], 0 ) : false; $userNameText = $userName ? ' <span class="mw-changeslist-separator"></span> ' . $this->msg( 'nuke-editby', $userName )->parse() : ''; $changesLink = $linkRenderer->makeKnownLink( $title, $this->msg( 'nuke-viewchanges' )->text(), [], [ 'action' => 'history' ] ); $talkPageText = $this->namespaceInfo->isTalk( $title->getNamespace() ) ? '' : $linkRenderer->makeLink( $this->namespaceInfo->getTalkPage( $title ), $this->msg( 'sp-contributions-talk' )->text(), [], [], ) . $wordSeparator . $pipeSeparator; $query = $title->isRedirect() ? [ 'redirect' => 'no' ] : []; $attributes = $title->isRedirect() ? [ 'class' => 'ext-nuke-italicize' ] : []; $out->addHTML( '<li>' . Html::check( 'pages[]', true, [ 'value' => $title->getPrefixedDBkey() ] ) . "\u{00A0}" . ( $thumb ? $thumb->toHtml( [ 'desc-link' => true ] ) : '' ) . $linkRenderer->makeKnownLink( $title, null, $attributes, $query ) . $wordSeparator . $this->msg( 'parentheses' )->rawParams( $talkPageText . $changesLink )->escaped() . $wordSeparator . "<span class='ext-nuke-italicize'>" . $userNameText . "</span>" . "</li>\n" ); } $out->addHTML( "</ul>\n" . Html::submitButton( $this->msg( 'nuke-submit-delete' )->text() ) . '</form>' ); } /** * Gets a list of new pages by the specified user or everyone when none is specified. * * @param string $username * @param int $limit * @param int|null $namespace * @param string[] $tempnames * * @return array{0:Title,1:string|false}[] */ protected function getNewPages( $username, $limit, $namespace = null, $tempnames = [] ): array { $dbr = $this->dbProvider->getReplicaDatabase(); $queryBuilder = $dbr->newSelectQueryBuilder() ->select( [ 'page_title', 'page_namespace' ] ) ->from( 'recentchanges' ) ->join( 'actor', null, 'actor_id=rc_actor' ) ->join( 'page', null, 'page_id=rc_cur_id' ) ->where( $dbr->expr( 'rc_source', '=', 'mw.new' )->orExpr( $dbr->expr( 'rc_log_type', '=', 'upload' ) ->and( 'rc_log_action', '=', 'upload' ) ) ) ->orderBy( 'rc_timestamp', SelectQueryBuilder::SORT_DESC ) ->limit( $limit ); if ( $username === '' ) { $queryBuilder->field( 'actor_name', 'rc_user_text' ); } else { $actornames = array_filter( [ $username, ...$tempnames ] ); if ( $actornames ) { $queryBuilder->andWhere( [ 'actor_name' => $actornames ] ); } } if ( $namespace !== null ) { $queryBuilder->andWhere( [ 'page_namespace' => $namespace ] ); } $pattern = $this->getRequest()->getText( 'pattern' ); if ( $pattern !== null && trim( $pattern ) !== '' ) { $addedWhere = false; $pattern = trim( $pattern ); $pattern = preg_replace( '/ +/', '`_', $pattern ); $pattern = preg_replace( '/\\\\([%_])/', '`$1', $pattern ); if ( $namespace !== null ) { // Custom namespace requested // If that namespace capitalizes titles, capitalize the first character // to match the DB title. $pattern = $this->namespaceInfo->isCapitalized( $namespace ) ? $this->contentLanguage->ucfirst( $pattern ) : $pattern; } else { // All namespaces requested $overriddenNamespaces = []; $capitalLinks = $this->getConfig()->get( 'CapitalLinks' ); $capitalLinkOverrides = $this->getConfig()->get( 'CapitalLinkOverrides' ); // If there are any capital-overridden namespaces, keep track of them. "overridden" // here means the namespace-specific value is not equal to $wgCapitalLinks. foreach ( $capitalLinkOverrides as $k => $v ) { if ( $v !== $capitalLinks ) { $overriddenNamespaces[] = $k; } } if ( count( $overriddenNamespaces ) ) { // If there are overridden namespaces, they have to be converted // on a case-by-case basis. $validNamespaces = $this->namespaceInfo->getValidNamespaces(); $nonOverriddenNamespaces = []; foreach ( $validNamespaces as $ns ) { if ( !in_array( $ns, $overriddenNamespaces ) ) { // Put all namespaces that aren't overridden in $nonOverriddenNamespaces $nonOverriddenNamespaces[] = $ns; } } $patternSpecific = $this->namespaceInfo->isCapitalized( $overriddenNamespaces[0] ) ? $this->contentLanguage->ucfirst( $pattern ) : $pattern; $orConditions = [ $dbr->expr( 'page_title', IExpression::LIKE, new LikeValue( new LikeMatch( $patternSpecific ) ) )->and( // IN condition 'page_namespace', '=', $overriddenNamespaces ) ]; if ( count( $nonOverriddenNamespaces ) ) { $patternStandard = $this->namespaceInfo->isCapitalized( $nonOverriddenNamespaces[0] ) ? $this->contentLanguage->ucfirst( $pattern ) : $pattern; $orConditions[] = $dbr->expr( 'page_title', IExpression::LIKE, new LikeValue( new LikeMatch( $patternStandard ) ) )->and( // IN condition, with the non-overridden namespaces. // If the default is case-sensitive namespaces, $pattern's first // character is turned lowercase. Otherwise, it is turned uppercase. 'page_namespace', '=', $nonOverriddenNamespaces ); } $queryBuilder->andWhere( $dbr->orExpr( $orConditions ) ); $addedWhere = true; } else { // No overridden namespaces; just convert all titles. $pattern = $this->namespaceInfo->isCapitalized( NS_MAIN ) ? $this->contentLanguage->ucfirst( $pattern ) : $pattern; } } if ( !$addedWhere ) { $queryBuilder->andWhere( $dbr->expr( 'page_title', IExpression::LIKE, new LikeValue( new LikeMatch( $pattern ) ) ) ); } } $result = $queryBuilder->caller( __METHOD__ )->fetchResultSet(); /** @var array{0:Title,1:string|false}[] $pages */ $pages = []; foreach ( $result as $row ) { $pages[] = [ Title::makeTitle( $row->page_namespace, $row->page_title ), $username === '' ? $row->rc_user_text : false ]; } // Allows other extensions to provide pages to be nuked that don't use // the recentchanges table the way mediawiki-core does $this->getNukeHookRunner()->onNukeGetNewPages( $username, $pattern, $namespace, $limit, $pages ); // Re-enforcing the limit *after* the hook because other extensions // may add and/or remove pages. We need to make sure we don't end up // with more pages than $limit. if ( count( $pages ) > $limit ) { $pages = array_slice( $pages, 0, $limit ); } return $pages; } /** * Does the actual deletion of the pages. * * @param array $pages The pages to delete * @param string $reason * @throws PermissionsError */ protected function doDelete( array $pages, $reason ): void { $res = []; $jobs = []; $user = $this->getUser(); $localRepo = $this->repoGroup->getLocalRepo(); foreach ( $pages as $page ) { $title = Title::newFromText( $page ); $deletionResult = false; if ( !$this->getNukeHookRunner()->onNukeDeletePage( $title, $reason, $deletionResult ) ) { $res[] = $this->msg( $deletionResult ? 'nuke-deleted' : 'nuke-not-deleted', wfEscapeWikiText( $title->getPrefixedText() ) )->parse(); continue; } $permission_errors = $this->permissionManager->getPermissionErrors( 'delete', $user, $title ); if ( $permission_errors !== [] ) { throw new PermissionsError( 'delete', $permission_errors ); } $file = $title->getNamespace() === NS_FILE ? $localRepo->newFile( $title ) : false; if ( $file ) { // Must be passed by reference $oldimage = null; $status = FileDeleteForm::doDelete( $title, $file, $oldimage, $reason, false, $user ); } else { $job = new DeletePageJob( [ 'namespace' => $title->getNamespace(), 'title' => $title->getDBKey(), 'reason' => $reason, 'userId' => $user->getId(), 'wikiPageId' => $title->getId(), 'suppress' => false, 'tags' => '["Nuke"]', 'logsubtype' => 'delete', ] ); $jobs[] = $job; $status = 'job'; } if ( $status === 'job' ) { $res[] = $this->msg( 'nuke-deletion-queued', wfEscapeWikiText( $title->getPrefixedText() ) )->parse(); } else { $res[] = $this->msg( $status->isOK() ? 'nuke-deleted' : 'nuke-not-deleted', wfEscapeWikiText( $title->getPrefixedText() ) )->parse(); } } if ( $jobs ) { $this->jobQueueGroup->push( $jobs ); } $this->getOutput()->addHTML( "<ul>\n<li>" . implode( "</li>\n<li>", $res ) . "</li>\n</ul>\n" ); $this->getOutput()->addWikiMsg( 'nuke-delete-more' ); } /** * Return an array of subpages beginning with $search that this special page will accept. * * @param string $search Prefix to search for * @param int $limit Maximum number of results to return (usually 10) * @param int $offset Number of results to skip (usually 0) * @return string[] Matching subpages */ public function prefixSearchSubpages( $search, $limit, $offset ) { $search = $this->userNameUtils->getCanonical( $search ); if ( !$search ) { // No prefix suggestion for invalid user return []; } // Autocomplete subpage as user list - public to allow caching return $this->userNamePrefixSearch ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset ); } /** * Group Special:Nuke with pagetools * * @codeCoverageIgnore * @return string */ protected function getGroupName() { return 'pagetools'; } private function getDeleteReason( WebRequest $request, string $target, bool $tempaccount = false ): string { if ( $tempaccount ) { $defaultReason = $this->msg( 'nuke-defaultreason-tempaccount' ); } else { $defaultReason = $target === '' ? $this->msg( 'nuke-multiplepeople' )->inContentLanguage()->text() : $this->msg( 'nuke-defaultreason', $target )->inContentLanguage()->text(); } $dropdownSelection = $request->getText( 'wpDeleteReasonList', 'other' ); $reasonInput = $request->getText( 'wpReason', $defaultReason ); if ( $dropdownSelection === 'other' ) { return $reasonInput; } elseif ( $reasonInput !== '' ) { // Entry from drop down menu + additional comment $separator = $this->msg( 'colon-separator' )->inContentLanguage()->text(); return $dropdownSelection . $separator . $reasonInput; } else { return $dropdownSelection; } } private function getNukeHookRunner(): NukeHookRunner { $this->hookRunner ??= new NukeHookRunner( $this->getHookContainer() ); return $this->hookRunner; } } PK ! oH� � � Hooks/NukeGetNewPagesHook.phpnu �Iw�� <?php namespace MediaWiki\Extension\Nuke\Hooks; interface NukeGetNewPagesHook { /** * Hook runner for the `NukeGetNewPages` hook * * After searching for pages to delete. Can be used to add and remove pages. * * @param string $username username filter applied * @param ?string $pattern pattern filter applied * @param ?int $namespace namespace filter applied * @param int $limit limit filter applied * @param array &$pages page titles already retrieved * @return bool|void True or no return value to continue or false to abort */ public function onNukeGetNewPages( string $username, ?string $pattern, ?int $namespace, int $limit, array &$pages ); } PK ! �L �� � Hooks/NukeHookRunner.phpnu �Iw�� <?php namespace MediaWiki\Extension\Nuke\Hooks; use MediaWiki\HookContainer\HookContainer; use MediaWiki\Title\Title; /** * Handle running Nuke's hooks * @author DannyS712 */ class NukeHookRunner implements NukeDeletePageHook, NukeGetNewPagesHook { private HookContainer $hookContainer; public function __construct( HookContainer $hookContainer ) { $this->hookContainer = $hookContainer; } /** * @inheritDoc */ public function onNukeDeletePage( Title $title, string $reason, bool &$deletionResult ) { return $this->hookContainer->run( 'NukeDeletePage', [ $title, $reason, &$deletionResult ] ); } /** * @inheritDoc */ public function onNukeGetNewPages( string $username, ?string $pattern, ?int $namespace, int $limit, array &$pages ) { return $this->hookContainer->run( 'NukeGetNewPages', [ $username, $pattern, $namespace, $limit, &$pages ] ); } } PK ! WL�4l l Hooks/NukeDeletePageHook.phpnu �Iw�� <?php namespace MediaWiki\Extension\Nuke\Hooks; use MediaWiki\Title\Title; interface NukeDeletePageHook { /** * Hook runner for the `NukeDeletePage` hook * * Allows other extensions to handle the deletion of titles * * @param Title $title title to delete * @param string $reason reason for deletion * @param bool &$deletionResult Whether the deletion was successful or not * @return bool|void True or no return value to let Nuke handle the deletion or * false if it was already handled in the hook. */ public function onNukeDeletePage( Title $title, string $reason, bool &$deletionResult ); } PK ! ���9� � ServiceWiring.phpnu �[��� <?php use MediaWiki\Extension\EnhancedUpload\AttachmentTagModifier; use MediaWiki\MediaWikiServices; // PHP unit does not understand code coverage for this file // as the @covers annotation cannot cover a specific file // This is fully tested in ServiceWiringTest.php // @codeCoverageIgnoreStart return [ 'EnhancedUploadAttachmentTagModifier' => static function ( MediaWikiServices $services ) { return new AttachmentTagModifier( $services->getTitleFactory() ); }, ]; // @codeCoverageIgnoreEnd PK ! >_i�� � specials/SpecialLiveChat.phpnu �[��� <?php namespace LiveChat; use SpecialPage; class SpecialLiveChat extends SpecialPage { public function __construct() { parent::__construct( 'LiveChat' ); } /** * @param string|null $subPage */ public function execute( $subPage ) { $output = $this->getOutput(); $this->setHeaders(); $htmlTitle = $this->msg( 'livechat' )->text(); $output->setPageTitle( $htmlTitle ); $output->setHTMLTitle( $htmlTitle ); $output->addModules( 'ext.LiveChat.special.LiveChat' ); } } PK ! ���� specials/SpecialLiveStatus.phpnu �[��� <?php namespace LiveChat; use SpecialPage; class SpecialLiveStatus extends SpecialPage { public function __construct() { parent::__construct( 'LiveStatus', 'LiveChatManager' ); } /** * @param string|null $subPage */ public function execute( $subPage ) { $output = $this->getOutput(); $this->setHeaders(); $htmlTitle = $this->msg( 'livestatus' )->text(); $output->setPageTitle( $htmlTitle ); $output->setHTMLTitle( $htmlTitle ); $output->addModules( 'ext.LiveChat.special.LiveStatus' ); } } PK ! ��"2 "2 Room.phpnu �[��� <?php namespace LiveChat; use ConfigException; use Exception; use FormatJson; use MWDebug; use RequestContext; use Workerman\Connection\AsyncTcpConnection; use Workerman\Connection\ConnectionInterface; class Room { const O_ROOM_RESTRICTION = 'roomRestriction'; const MANAGER_ACTION_SEND_TO_ALL = 'SendToAll'; const MANAGER_ACTION_SYNC = 'sync'; const TARGET_CONNECTION = 'cId'; // const TARGET_USER = 'uId'; /** * @var int */ protected $roomId; const ROOM_TYPE = 0; /** * @var array */ protected $options; /** * @var Connection[] */ protected $connections = []; /** * @var AsyncTcpConnection */ protected $managerConnection; /** * @var array */ protected $users = []; /** * @var array */ protected $managerTimers = []; /** * Room constructor. * @param int $id * @param array $options */ public function __construct( int $id = 0, array $options = [] ) { $this->roomId = $id; $this->options = $options; $this->connectToManager(); } /** * @param Connection $connection */ public function addConnection( Connection $connection ) { $user = $connection->getUser(); $restriction = $this->options[self::O_ROOM_RESTRICTION] ?? null; if ( $restriction ) { if ( !$user->isAllowed( $restriction ) ) { $this->debugLog( __FUNCTION__, 'Room is not allowed for user', static::class, $restriction, $user->getName() ); $connection->sendErrorMessage( 'You are not allowed to connect to room ' . static::class ); return; } } $this->debugLog( __FUNCTION__, $connection->getId() ); $this->connections[$connection->getId()] = $connection; $userKey = $connection->getUserKey(); if ( empty( $this->users[$userKey] ) ) { $this->users[$userKey] = [ 'count' => 1, 'name' => $user->getName(), ]; if ( !$user->isAnon() ) { $this->users[$userKey]['id'] = $user->getId(); $this->users[$userKey]['realName'] = $user->getRealName(); } $this->onUserJoin( $connection, $userKey ); } else { $this->users[$userKey]['count']++; } } /** * @param array|null $target * @return Connection[] */ public function getConnections( ?array $target = null ): array { if ( !$target ) { return $this->connections; } $return = []; $toConn = $target[self::TARGET_CONNECTION] ?? null; if ( $toConn ) { $c = $this->connections[$toConn] ?? null; if ( $c ) { $return[] = $c; } } // $toUser = $target[self::TARGET_USER] ?? null; // if ( $toUser ) { // $this->users[$toUser] // } return $return; } /** * @param Connection $connection */ public function removeConnection( Connection $connection ) { unset( $this->connections[$connection->getId()] ); $userKey = $connection->getUserKey(); if ( isset( $this->users[$userKey] ) ) { $this->users[$userKey]['count']--; if ( !$this->users[$userKey]['count'] ) { $this->onUserLeft( $connection, $userKey ); } } } /** * @param Connection $connection * @param string $event * @param array $data */ public function onEvent( Connection $connection, string $event, array $data ) { $this->debugLog( __FUNCTION__, $connection->getId(), 'UNHANDLED EVENT', $event ); } /** * @param Connection $connection * @param string $userKey */ protected function onUserJoin( Connection $connection, string $userKey ) { } /** * @param Connection $connection * @param string $userKey */ protected function onUserLeft( Connection $connection, string $userKey ) { } /** * @return array */ public function getOptions(): array { return $this->options; } /** * @return int */ public function getOnlineUsersCount() { return count( $this->users ); } /** * @return int */ public function getConnectionsCount() { return count( $this->connections ); } /** * @param string $event * @param array $data * @param string|null $time */ public function sendToAllInRoom( string $event, array $data = [], ?string $time = null ) { $buffer = Connection::makeSendBuffer( $event, $data, $time ); $this->sendBufferToAllInRoom( $buffer ); } /** * @param string|null $buffer */ protected function sendBufferToAllInRoom( ?string $buffer ) { if ( !$buffer ) { return; } foreach ( $this->getConnections() as $connection ) { $connection->sendBuffer( $buffer ); } } /** * @param int $roomId * @param string $event * @param array $data * @param array|null $target * @param string|null $time * @return bool */ public static function sendToAllByRoomId( int $roomId, string $event, array $data = [], ?array $target = null, ?string $time = null ) { $message = [ 'action' => self::MANAGER_ACTION_SEND_TO_ALL, 'buffer' => Connection::makeSendBuffer( $event, $data, $time ), ]; $data = [ 'command' => 'send', 'roomType' => static::ROOM_TYPE, 'roomId' => $roomId, 'message' => $message, ]; if ( $target ) { $data['target'] = $target; } return Manager::sendDataToItself( $data ); } /** * @param string $event * @param array $data * @param string|null $time */ public function sendToAll( string $event, array $data = [], ?string $time = null ) { $buffer = Connection::makeSendBuffer( $event, $data, $time ); $message = [ 'action' => self::MANAGER_ACTION_SEND_TO_ALL, 'buffer' => $buffer, ]; $this->sendToManager( 'send', $message ); } /** * @param string $command * @param array|null $message * @param array|null $target */ protected function sendToManager( string $command, ?array $message = null, ?array $target = null ) { if ( !$this->managerConnection ) { $this->debugLog( __FUNCTION__, 'ERROR: managerConnection is undefined' ); return; } $data = [ 'command' => $command, 'roomType' => static::ROOM_TYPE, 'roomId' => $this->roomId, 'key' => $this->getManagerKey(), ]; if ( $target ) { $data['target'] = $target; } if ( $message ) { $data['message'] = $message; } $buffer = FormatJson::encode( $data ); $this->debugLog( __FUNCTION__, $buffer ); $this->managerConnection->send( $buffer ); } // /** // * @param string $name // * @param mixed $value // */ // protected function syncManagerData( string $name, $value ) { // $this->sendToManager( // 'sync', // [ // 'name' => $name, // 'value' => $value, // ] // ); // } /** * @param string $command * @param string $providerName * @param mixed $value * @param bool $sync * @param array|null $target */ protected function sendDatabaseCommand( string $command, string $providerName, $value, bool $sync = false, ?array $target = null ) { $this->debugLog( __FUNCTION__, $command, $providerName ); $this->sendToManager( $command, [ 'providerName' => $providerName, 'value' => $value, 'sync' => $sync, ], $target ); } /** * @param string $name * @param mixed $value * @param bool $sync */ protected function setManagerData( string $name, $value, bool $sync = false ) { $this->sendToManager( 'set', [ 'name' => $name, 'value' => $value, 'sync' => $sync, ] ); } /** * @param string $name * @param array|null $target */ protected function getManagerData( string $name, ?array $target = null ) { $this->sendToManager( 'get', [ 'name' => $name ], $target ); } /** * @param string $name * @param Connection $connection */ protected function getManagerDataForConnection( string $name, Connection $connection ) { $this->getManagerData( $name, [ self::TARGET_CONNECTION => $connection->getId() ] ); } /** * @param string $action * @param array $data * @param array|null $target */ protected function onManagerAction( string $action, array $data, ?array $target = null ) { $this->debugLog( __FUNCTION__, $action ); switch ( $action ) { case self::MANAGER_ACTION_SEND_TO_ALL: $this->sendBufferToAllInRoom( $data['buffer'] ?? null ); break; case self::MANAGER_ACTION_SYNC: $this->onManagerSyncAction( $data['sync'] ?? null, $data['name'] ?? null, $data['value'] ?? null ); break; } } /** * @param string $info * @param array|null $answer * @param array|null $target */ protected function onManagerInfo( string $info, ?array $answer, ?array $target = null ) { if ( $target === null ) { $this->onManagerDataReceived( $info, $answer ); return; } if ( $answer === null ) { $this->debugLog( __FUNCTION__, 'ERROR: answer is NULL' ); return; } $connections = $this->getConnections( $target ); foreach ( $connections as $c ) { $this->debugLog( __FUNCTION__, 'SEND to client', $c->getId(), $info ); $c->send( $info, [ 'answer' => $answer ] ); } } /** * @param ConnectionInterface $connection * @param string $buffer */ public function onManagerMessage( ConnectionInterface $connection, string $buffer ) { $exploded = explode( '}{', $buffer ); $lastKey = count( $exploded ) - 1; foreach ( $exploded as $k => $v ) { if ( $lastKey > 0 ) { if ( $k !== 0 ) { $v = '{' . $v; } if ( $k !== $lastKey ) { $v .= '}'; } } $this->onManagerAnswer( $connection, $v ); } } /** * @param ConnectionInterface $connection * @param string $buffer */ protected function onManagerAnswer( ConnectionInterface $connection, string $buffer ) { $this->debugLog( __FUNCTION__, $buffer ); $data = FormatJson::decode( $buffer, true ); $timerName = $data[Manager::EVENT_TIMER] ?? null; if ( $timerName ) { $this->onManagerTimer( $timerName, $data ); return; } $action = $data['action'] ?? null; $target = $data['target'] ?? null; if ( $action ) { $this->onManagerAction( $action, $data, $target ); return; } $info = $data['info'] ?? null; $answer = $data['answer'] ?? null; if ( $info ) { $this->onManagerInfo( $info, $answer, $target ); return; } $this->debugLog( __FUNCTION__, 'ERROR: Unhandled answer', var_export( $data, true ) ); } /** * @param string $name * @return mixed|null */ public static function getConfigValue( string $name ) { $config = RequestContext::getMain()->getConfig(); try { return $config->get( $name ); } catch ( ConfigException $e ) { MWDebug::warning( $e->getMessage() ); } return null; } /** * @param array $message */ protected function connectToManager( array $message = [] ) { $managerSocketName = self::getConfigValue( 'LiveChatManagerSocketName' ); $this->debugLog( __FUNCTION__, $managerSocketName ); try { $this->managerConnection = new AsyncTcpConnection( $managerSocketName ); $this->managerConnection->onMessage = [ $this, 'onManagerMessage' ]; $this->managerConnection->onClose = [ $this, 'onManagerClose' ]; $this->managerConnection->onError = [ $this, 'onManagerError' ]; $this->managerConnection->connect(); $message += [ 'roomClass' => static::class, 'roomType' => static::ROOM_TYPE, 'roomId' => $this->roomId, 'key' => $this->getManagerKey(), ]; if ( $this->managerTimers ) { $message[Manager::ADD_TIMERS] = array_keys( $this->managerTimers ); } $this->sendToManager( 'subscribe', $message ); } catch ( Exception $e ) { $this->debugLog( __FUNCTION__, 'ERROR', $e->getMessage() ); wfLogWarning( $e->getMessage() ); } } /** * @param ConnectionInterface $connection */ public function onManagerClose( ConnectionInterface $connection ) { $this->debugLog( __FUNCTION__, $connection->id ?? 'undefined' ); } /** * @param ConnectionInterface $connection * @param mixed $code * @param string $msg */ public function onManagerError( ConnectionInterface $connection, $code, $msg ) { $this->debugLog( __FUNCTION__, $connection->id ?? 'undefined', $code, $msg ); } /** * @return string */ protected function getManagerKey(): string { return static::ROOM_TYPE . '#' . $this->roomId; } /** * @param string ...$args */ protected function debugLog( ...$args ) { $roomType = static::ROOM_TYPE; $roomId = $this->roomId; wfDebugLog( __CLASS__, "$roomType $roomId " . static::class . '::' . implode( '; ', $args ), false ); } /** * @param string $timerName * @param array $data */ protected function onManagerTimer( string $timerName, array $data ) { $this->debugLog( __FUNCTION__, $timerName, $data[Manager::TIMER_ITERATION], $data[Manager::TIMER_NUMBER] ); } /** * @param string|null $command * @param string|null $name * @param mixed $value */ protected function onManagerSyncAction( ?string $command, ?string $name, $value ) { $this->debugLog( __FUNCTION__, $command, $name ); } /** * @param string $info * @param array|null $answer */ protected function onManagerDataReceived( string $info, ?array $answer ) { $this->debugLog( __FUNCTION__, $info ); } } PK ! ���a�3 �3 ChatRoom.phpnu �[��� <?php namespace LiveChat; use MediaWiki\MediaWikiServices; use User; use wAvatar; use Wikimedia\Rdbms\Database; use Wikimedia\Timestamp\ConvertibleTimestamp; class ChatRoom extends Room { const ROOM_TYPE = 10; // const const O_PERM_CAN_POST_MESSAGE = 'canPostMessagePermission'; const O_PERM_CAN_POST_REACTION = 'canPostReactionPermission'; const I_CAN_POST = 'canPost'; const TABLE = 'lch_messages'; const C_ID = 'lchm_id'; const C_PARENT = 'lchm_parent_id'; const C_ROOM_TYPE = 'lchm_room_type'; const C_ROOM_ID = 'lchm_room_id'; const C_USER_ID = 'lchm_user_id'; const C_USER_TEXT = 'lchm_user_text'; const C_MESSAGE = 'lchm_message'; const C_TIMESTAMP = 'lchm_timestamp'; const MAX_MESSAGE_TEXT_SIZE = 1000; const EVENT_MESSAGE = 'LiveChatMessage'; const EVENT_REACTION = 'LiveChatReaction'; const EVENT_GET_HISTORY = 'getLiveChatHistory'; const EVENT_GET_PERMISSIONS = 'getPermissions'; const EVENT_GET_ROOM_STATISTICS = 'getRoomStatistics'; const EVENT_SEND_HISTORY = 'LiveChatHistory'; const EVENT_SEND_MESSAGE = 'LiveChatMessage'; const EVENT_SEND_REACTION = 'LiveChatReaction'; const EVENT_SEND_USER_STATUS = 'LiveChatUserStatus'; const EVENT_SEND_MESSAGE_CONFIRM = 'LiveChatMessageConfirm'; const EVENT_SEND_REACTION_CONFIRM = 'LiveChatReactionConfirm'; const EVENT_SEND_ROOM_STATISTICS = 'LiveChatRoomStatistics'; const EVENT_SEND_PERMISSIONS = 'permissions'; /** * @var Database|null */ private static $dbw; /** * @var Database|null */ private static $dbr; /** * @var array */ private $messages = []; /** * @var array */ private $parents = []; /** * @var Reactions */ private $reactions; /** * @var int */ private $cacheSize = 100; /** * Room constructor. * @param int $id * @param array $options */ public function __construct( int $id = 0, array $options = [] ) { parent::__construct( $id, $options ); $this->loadMessagesToCache(); $this->reactions = new Reactions( $this->messages, $this ); $this->reactions->loadForMessages(); } /** * @param array &$message * @param string $userName */ private function addUserReaction( array &$message, string $userName ) { $userReaction = $this->reactions->getUserReaction( $message['id'], $userName, true ); if ( $userReaction ) { $message['userReaction'] = $userReaction; } } /** * @param Connection $connection */ public function addConnection( Connection $connection ) { parent::addConnection( $connection ); $onlineCount = $this->getOnlineUsersCount(); $statistics = [ 'online' => $onlineCount ]; $connection->send( self::EVENT_SEND_ROOM_STATISTICS, [ 'statistics' => $statistics ] ); } // protected function onUserJoin( Connection $connection, string $userKey ) { // parent::onUserJoin( $connection, $userKey ); // // $msg = [ // 'status' => 'join', // 'data' => array_intersect_key( $this->users[$userKey], [ 'name' => 1, 'realName' => 1 ] ), // ]; // foreach ( $this->connections as $c ) { // if ( $c === $connection ) { // continue; // } // $c->send( self::EVENT_SEND_USER_STATUS, $msg ); // } // } // // protected function onUserLeft( Connection $connection, string $userKey ) { // parent::onUserLeft( $connection, $userKey ); // // $msg = [ // 'status' => 'left', // 'data' => array_intersect_key( $this->users[$userKey], ['name' => 1, 'realName' => 1] ), // ]; // unset( $this->users[$userKey] ); // foreach ( $this->connections as $c ) { // if ( $c === $connection ) { // continue; // } // $c->send( self::EVENT_SEND_USER_STATUS, $msg ); // } // } /** * @param Connection $connection * @param array $data */ public function onReaction( Connection $connection, array $data ) { $this->debugLog( __FUNCTION__ ); $user = $connection->getUser(); $confirm = [ 'clientTime' => $data['time'] ?? null ]; if ( !$this->canUserPostReaction( $user ) ) { $confirm['error'] = Tools::getMessage( $user, 'ext-livechat-error-user-cannot-post-reactions' )->text(); $connection->send( self::EVENT_SEND_REACTION_CONFIRM, $confirm ); return; } if ( empty( $data['message'] ) ) { $confirm['error'] = 'No message id provided'; $connection->send( self::EVENT_SEND_REACTION_CONFIRM, $confirm ); return; } $reaction = $data['reaction'] ?? null; if ( !$reaction || empty( ChatData::REACTIONS_BY_NAME[$reaction] ) ) { $confirm['error'] = "Reaction $reaction is not allowed"; $connection->send( self::EVENT_SEND_REACTION_CONFIRM, $confirm ); return; } $connection->send( self::EVENT_SEND_REACTION_CONFIRM, $confirm ); $data['userId'] = $user->getId(); $data['userName'] = $user->getName(); $this->sendDatabaseCommand( 'update', ChatData::class, $data, true ); } /** * @param Connection $connection * @param array $data */ public function onMessage( Connection $connection, array $data ) { $user = $connection->getUser(); $parentId = $data['parentId'] ?? null; $confirm = [ 'clientTime' => $data['time'] ?? null ]; if ( $parentId ) { $confirm['parentId'] = $parentId; } if ( !$this->canUserPostMessages( $user ) ) { $errorMessage = Tools::getMessage( $user, 'ext-livechat-error-user-cannot-post-comments' )->text(); $confirm['error'] = $errorMessage; $connection->send( self::EVENT_SEND_MESSAGE_CONFIRM, $confirm ); return; } $text = trim( $data['message'] ?? '' ); if ( !$text ) { $errorMessage = 'empty message'; $confirm['error'] = $errorMessage; $connection->send( self::EVENT_SEND_MESSAGE_CONFIRM, $confirm ); return; } elseif ( strlen( $text > self::MAX_MESSAGE_TEXT_SIZE ) ) { $text = substr( $text, 0, self::MAX_MESSAGE_TEXT_SIZE ); $data['message'] = $text; } $connection->send( self::EVENT_SEND_MESSAGE_CONFIRM, $confirm ); $data['userId'] = $user->getId(); $data['userName'] = $user->getName(); $this->sendDatabaseCommand( 'insert', ChatData::class, $data, true ); // $parentId = $data['parent'] ?? null; // if ( $parentId ) { // if ( empty( $this->parents[$parentId] ) ) { // if ( empty( $this->messages[$parentId] ) ) { // $parent = $this->loadMessage( $parentId, true ); // } else { // $parent =& $this->messages[$parentId]; // } // if ( !$parent || isset( $parent['parent'] ) ) { // $parentId = null; // don't allow wrong parent and parent of children // } else { // $this->parents[$parentId] =& $parent; // } // } else { // $parent =& $this->parents[$parentId]; // } // } // // $time = Connection::getTime(); // $text = substr( $data['message'] ?? '', 0, self::MAX_MESSAGE_TEXT_SIZE ); // $msg = self::makeMessage( $text, $user->getName(), $user->getId(), $time, $parentId ); // $id = $this->saveMessage( $connection, $msg, $text, $time ); // $msg['id'] = $id; // if ( !$id ) { // $msg['clientTime'] = $data['time'] ?? null; // $connection->send( self::EVENT_SEND_MESSAGE_CONFIRM, $msg, $time ); // return; // } // // $this->messages[$id] = $msg; // if ( $parentId ) { // $parent['children'][] =& $this->messages[$id]; // } // // if ( count( $this->messages ) > $this->cacheSize ) { // $min = min( array_keys( $this->messages ) ); // unset( $this->messages[$min] ); // } // // foreach ( $this->connections as $c ) { // if ( $c === $connection ) { // $tmp = $msg; // $tmp['clientTime'] = $data['time'] ?? null; // $c->send( self::EVENT_SEND_MESSAGE_CONFIRM, $tmp, $time ); // } else { // $tmp = $msg; // $this->addUserReaction( $tmp ); // $c->send( self::EVENT_SEND_MESSAGE, $tmp, $time ); // } // } } /** * @param string|null $text * @param string $userName * @param int|null $userId * @param string $time * @param int|null $parentId * @return array */ private static function makeMessage( $text, $userName, $userId, $time, $parentId = null ) { $msg = [ 'message' => MessageParser::parse( $text ), 'userName' => $userName, 'timestamp' => ConvertibleTimestamp::convert( TS_MW, $time ), ]; if ( $userId ) { $msg['userId'] = $userId; } if ( $parentId ) { $msg['parent'] = $parentId; } $userAvatar = self::getUserAvatar( $userId ); if ( $userAvatar ) { $msg['userAvatar'] = $userAvatar; } return $msg; } /** * @param Connection $connection * @param array $data */ public function onGetHistory( Connection $connection, array $data ) { $target = [ self::TARGET_CONNECTION => $connection->getId() ]; $this->sendDatabaseCommand( 'select', ChatData::class, $data, false, $target ); } /** * @param Connection $connection * @param array $data */ public function onGetPermissions( Connection $connection, array $data ) { $user = $connection->getUser(); $list = $data['list'] ?? []; $permissions = []; foreach ( $list as $value ) { switch ( $value ) { case self::I_CAN_POST: $permissions[self::I_CAN_POST] = $this->canUserPostMessages( $user ); break; default: $permissions[$value] = null; } } $connection->send( self::EVENT_SEND_PERMISSIONS, [ 'permissions' => $permissions ] ); } /** * @param int|null $userId * @param string $size * @return string|null */ public static function getUserAvatar( $userId, $size = 'm' ) { if ( !$userId ) { return null; } $avatar = new wAvatar( $userId, $size ); if ( $avatar->isDefault() ) { return null; } global $wgUploadBaseUrl, $wgUploadPath; $avatarImg = $avatar->getAvatarImage(); return "{$wgUploadBaseUrl}{$wgUploadPath}/avatars/{$avatarImg}"; } /** * @param User $user * @return bool */ public function canUserPostMessages( User $user ) { if ( empty( $this->options[self::O_PERM_CAN_POST_MESSAGE] ) ) { return true; } return $user->isAllowed( $this->options[self::O_PERM_CAN_POST_MESSAGE] ); } private function loadMessagesToCache() { $dbr = $this->getDBR(); $conds = [ self::C_ROOM_TYPE => static::ROOM_TYPE, self::C_ROOM_ID => $this->roomId, ]; $options = [ 'LIMIT' => $this->cacheSize, 'ORDER BY' => self::C_ID, ]; // TODO get user name from user table $res = $dbr->select( self::TABLE, '*', $conds, __METHOD__, $options ); if ( !$res ) { return; } foreach ( $res as $row ) { $msg = self::messageFromRow( (array)$row ); $id = $msg['id']; $this->messages[$id] = $msg; } } // /** // * @param $messageId // * @param bool $withChildren // * @return array // */ // public function loadMessage( $messageId, $withChildren = false ) { // $dbr = $this->getDBR(); // $conds = [ // self::C_ROOM_TYPE => static::ROOM_TYPE, // self::C_ROOM_ID => $this->roomId, // ]; // if ( $withChildren ) { // $ids = $dbr->makeList( [ // self::C_ID => $messageId, // self::C_PARENT => $messageId, // ], LIST_OR ); // $conds[] = $ids; // } else { // $conds[self::C_ID] = $messageId; // } // // // TODO get user name from user table // $res = $dbr->select( self::TABLE, '*', $conds, __METHOD__ ); // if ( !$res ) { // return []; // } // // $parent = []; // $children = []; // foreach ( $res as $row ) { // $msg = self::messageFromRow( (array)$row ); // $id = $msg['id']; // if ( $id == $messageId ) { // $parent = $msg; // } else { // $children[$id] = $msg; // } // } // if ( $children ) { // $parent['children'] = $children; // } // // if ( $parent[self::C_ROOM_TYPE] != static::ROOM_TYPE || // $parent[self::C_ROOM_ID] != $this->roomId // ) { // return []; // } // return $parent; // } /** * @param array $row * @return array */ private static function messageFromRow( array $row ) { $msg = self::makeMessage( $row[self::C_MESSAGE], $row[self::C_USER_TEXT], $row[self::C_USER_ID], $row[self::C_TIMESTAMP], $row[self::C_PARENT] ); $id = $row[self::C_ID]; $msg['id'] = $id; return $msg; } /** * @param Connection $connection * @param string $event * @param array $data */ public function onEvent( Connection $connection, string $event, array $data ) { switch ( $event ) { case self::EVENT_MESSAGE: $this->onMessage( $connection, $data ); break; case self::EVENT_GET_HISTORY: $this->onGetHistory( $connection, $data ); break; case self::EVENT_GET_PERMISSIONS: $this->onGetPermissions( $connection, $data ); break; case self::EVENT_REACTION: $this->onReaction( $connection, $data ); break; default: parent::onEvent( $connection, $event, $data ); } } /** * @return Database */ public function getDBW() { if ( !self::$dbw ) { self::$dbw = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_PRIMARY ); } return self::$dbw; } /** * @return Database */ public function getDBR() { if ( !self::$dbr ) { self::$dbr = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_REPLICA ); } return self::$dbr; } /** * @param User $user * @return bool */ protected function canUserPostReaction( User $user ) { $options = $this->getOptions(); if ( empty( $options[self::O_PERM_CAN_POST_REACTION] ) ) { return true; } return $user->isAllowed( $options[self::O_PERM_CAN_POST_REACTION] ); } } PK ! ����; �; Manager.phpnu �[��� <?php namespace LiveChat; use Exception; use FormatJson; use MWDebug; use Workerman\Connection\AsyncTcpConnection; use Workerman\Connection\ConnectionInterface; use Workerman\Lib\Timer; class Manager extends Worker { const TIMER_ONE_SECOND = 'OneSecondTimer'; const TIMER_FIVE_SECONDS = 'FiveSecondsTimer'; const TIMER_TEN_SECONDS = 'TenSecondsTimer'; const TIMER_ONE_MINUTE = 'OneMinuteTimer'; const EVENT_TIMER = 'ManagerTimer'; const TIMER_ITERATION = 'TimerIteration'; const TIMER_NUMBER = 'TimerNumber'; const ADD_TIMERS = 'ManagerAddTimers'; const TARGET_ROOM_CONNECTION = 'trcId'; /** * @var callable[][] */ protected $commandCallbacks = []; /** * @var ConnectionInterface[] */ protected $subscribed = []; /** * @var ConnectionInterface[][][] */ protected $rooms = []; /** * @var array */ protected $roomClasses = []; /** * @var ConnectionInterface[][] */ protected $keys = []; /** * @var array */ protected $timers = []; /** * @var ConnectionInterface[][] */ protected $timerConnections = []; /** * @var AsyncTcpConnection */ protected $storageConnection; /** * @var int[] */ protected $timerIterations = [ self::TIMER_ONE_SECOND => 0, self::TIMER_FIVE_SECONDS => 0, self::TIMER_TEN_SECONDS => 0, self::TIMER_ONE_MINUTE => 0, ]; /** * @var array */ protected $data = []; /** @inheritDoc */ public function __construct( string $socket_name = '', array $context_option = [] ) { if ( !$socket_name ) { $socket_name = self::getConfigValue( 'LiveChatManagerSocketName' ); } parent::__construct( $socket_name, $context_option ); $this->name = 'LiveChat Manager'; } /** @inheritDoc */ public function run() { $this->addCommandCallbacks( 'subscribe', [ $this, 'onSubscribeCommand' ] ); $this->addCommandCallbacks( 'unsubscribe', [ $this, 'onUnsubscribeCommand' ] ); $this->addCommandCallbacks( 'send', [ $this, 'onSendCommand' ] ); $this->addCommandCallbacks( 'status', [ $this, 'onStatusCommand' ] ); $this->addCommandCallbacks( 'get', [ $this, 'onGetCommand' ] ); $this->addCommandCallbacks( 'set', [ $this, 'onSetCommand' ] ); $this->addCommandCallbacks( 'insert', [ $this, 'onDatabaseCommand' ] ); $this->addCommandCallbacks( 'update', [ $this, 'onDatabaseCommand' ] ); $this->addCommandCallbacks( 'select', [ $this, 'onDatabaseCommand' ] ); parent::run(); } public function onWorkerStart() { parent::onWorkerStart(); $this->connectToStorage(); $timerIntervals = [ self::TIMER_ONE_SECOND => 1, self::TIMER_FIVE_SECONDS => 5.2, self::TIMER_TEN_SECONDS => 10.4, self::TIMER_ONE_MINUTE => 60.6, ]; foreach ( $timerIntervals as $timer => $interval ) { $timerId = Timer::add( $interval, [ $this, 'onTimer' ], [ $timer ] ); if ( $timerId ) { $this->timers[] = $timerId; self::debugLog( __FUNCTION__, 'added timer', $timer, (string)$interval ); } else { self::debugLog( __FUNCTION__, 'ERROR: cannot add timer', $timer, (string)$interval ); MWDebug::warning( 'Cannot add timer ' . $timer . ' interval ' . $interval ); } } } /** * @param string $timerName */ public function onTimer( string $timerName ) { $data = [ self::EVENT_TIMER => $timerName, self::TIMER_ITERATION => $this->timerIterations[$timerName]++, ]; $timerNumber = 0; /** @var ConnectionInterface $connection */ foreach ( $this->timerConnections[$timerName] ?? [] as $connection ) { $data[self::TIMER_NUMBER] = $timerNumber++; $buffer = FormatJson::encode( $data ); $connection->send( $buffer ); } } public function onWorkerStop() { parent::onWorkerStop(); foreach ( $this->timers as $timerId ) { Timer::del( $timerId ); } } /** @inheritDoc */ public function onConnect( ConnectionInterface $connection ) { parent::onConnect( $connection ); self::sendAnswer( $connection, 'connected' ); } /** @inheritDoc */ public function onClose( ConnectionInterface $connection ) { parent::onClose( $connection ); $this->onCommand( $connection, 'unsubscribe', [ 'command' => 'unsubscribe' ] ); } /** * @param string $command * @param callable $callback */ public function addCommandCallbacks( string $command, callable $callback ) { self::debugLog( __FUNCTION__, $command ); $this->commandCallbacks[$command][] = $callback; } /** * @param ConnectionInterface $connection * @param string $command * @param array $data */ protected function onCommand( ConnectionInterface $connection, string $command, array $data ) { parent::onCommand( $connection, $command, $data ); if ( !empty( $this->commandCallbacks[$command] ) ) { foreach ( $this->commandCallbacks[$command] as $callback ) { call_user_func( $callback, $connection, $data ); } } else { self::debugLog( __FUNCTION__, "no callback for command: $command" ); } } /** * @param ConnectionInterface $connection * @param array $data */ public function onSubscribeCommand( ConnectionInterface $connection, array $data ) { $id = $connection->id ?? null; self::debugLog( __FUNCTION__, $id ); if ( $id !== null ) { $this->subscribed[$id] = $connection; } else { $this->subscribed[] = $connection; } $message = $data['message'] ?? null; if ( $message && is_array( $message ) ) { $roomType = $message['roomType'] ?? 0; $roomId = $message['roomId'] ?? 0; $connection->roomType = $roomType; $connection->roomId = $roomId; if ( $id !== null ) { $this->rooms[$roomType][$roomId][$id] = $connection; } else { $this->rooms[$roomType][$roomId][] = $connection; } if ( !isset( $this->roomClasses[$roomType] ) ) { $this->roomClasses[$roomType] = $message['roomClass'] ?? null; } $key = $message['key'] ?? null; if ( $key ) { $connection->key = $key; if ( $id !== null ) { $this->keys[$key][$id] = $connection; } else { $this->keys[$key][] = $connection; } } } foreach ( $message[self::ADD_TIMERS] ?? [] as $timerName ) { self::debugLog( __FUNCTION__, $id, "ADD $timerName TIMER" ); if ( $id !== null ) { $this->timerConnections[$timerName][$id] = $connection; } else { $this->timerConnections[$timerName][] = $connection; } if ( $connection->timers ?? false ) { $connection->timers = []; } $connection->timers[] = $timerName; } } /** * @param ConnectionInterface $connection * @param array $data */ public function onUnsubscribeCommand( ConnectionInterface $connection, array $data ) { $id = $connection->id ?? null; self::debugLog( __FUNCTION__, $id ); if ( $id !== null ) { unset( $this->subscribed[$id] ); } else { $key = array_search( $connection, $this->subscribed, true ); if ( $key !== false ) { unset( $this->subscribed[$key] ); } } foreach ( $connection->timers ?? [] as $timerName ) { self::debugLog( __FUNCTION__, $id, 'REMOVE TIMER', $timerName ); if ( $id !== null ) { unset( $this->timerConnections[$timerName][$id] ); } else { $key = array_search( $connection, $this->timerConnections[$timerName], true ); if ( $key !== false ) { unset( $this->timerConnections[$timerName][$key] ); } } } if ( isset( $connection->roomType ) && isset( $connection->roomId ) ) { $roomType = $connection->roomType; $roomId = $connection->roomId; if ( $id !== null ) { unset( $this->rooms[$roomType][$roomId][$id] ); } else { $key = array_search( $connection, $this->rooms[$roomType][$roomId], true ); if ( $key !== false ) { unset( $this->rooms[$roomType][$roomId][$key] ); } } } $key = $connection->key ?? null; if ( $key ) { if ( $id !== null ) { unset( $this->keys[$key][$id] ); } else { $k = array_search( $connection, $this->keys[$key], true ); if ( $k !== false ) { unset( $this->keys[$key][$k] ); } } } } /** * @param ConnectionInterface $connection * @param array $data */ public function onSendCommand( ConnectionInterface $connection, array $data ) { self::debugLog( __FUNCTION__, $connection->id ?? 'undefined', var_export( $data, true ) ); $message = $data['message'] ?? null; if ( !$message ) { return; } if ( is_string( $message ) ) { $sendBuffer = $message; } else { $sendBuffer = FormatJson::encode( $message ); } $trcId = $message['target'][self::TARGET_ROOM_CONNECTION] ?? null; if ( $trcId ) { self::debugLog( __FUNCTION__, "Send by target room connection: $trcId", $sendBuffer ); $roomConnection = $this->connections[$trcId] ?? null; if ( $roomConnection ) { self::sendBuffer( $sendBuffer, [ $roomConnection ] ); } return; } $key = $data['key'] ?? null; if ( $key ) { self::debugLog( __FUNCTION__, "Send by key: $key", $sendBuffer ); foreach ( (array)$key as $k ) { $array = $this->keys[$k] ?? []; if ( $array ) { self::sendBuffer( $sendBuffer, $array, $connection ); } } return; } $roomType = $data['roomType'] ?? null; $roomId = $data['roomId'] ?? null; if ( $roomType !== null ) { foreach ( (array)$roomType as $rt ) { if ( $roomId !== null ) { foreach ( (array)$roomId as $rid ) { self::debugLog( __FUNCTION__, "Send to room type: $rt, id: $rid", $sendBuffer ); $array = $this->rooms[$rt][$rid] ?? []; if ( $array ) { self::sendBuffer( $sendBuffer, $array, $connection ); } } return; } self::debugLog( __FUNCTION__, "Send to all rooms by type: $rt", $sendBuffer ); foreach ( $this->rooms[$rt] ?? [] as $rooms ) { foreach ( $rooms as $array ) { if ( $array ) { self::sendBuffer( $sendBuffer, $array, $connection ); } } } } return; } self::debugLog( __FUNCTION__, "Send to ALL subscribed connections", $sendBuffer ); self::sendBuffer( $sendBuffer, $this->subscribed, $connection ); } /** * @param ConnectionInterface $connection * @param array $data */ public function onStatusCommand( ConnectionInterface $connection, array $data ) { $message = $data['message'] ?? []; $info = $message['info'] ?? null; $target = $message['target'] ?? null; self::debugLog( __FUNCTION__, $info ); switch ( $info ) { case 'rooms': $return = []; foreach ( $this->rooms as $roomType => $rooms ) { $return[$roomType] = $this->roomClasses[$roomType] ?? null; } self::sendAnswer( $connection, 'LiveChatManagerListRooms', $return, $target ); break; } } /** * @param ConnectionInterface $connection * @param array $data */ public function onGetCommand( ConnectionInterface $connection, array $data ) { $name = $data['message']['name'] ?? null; $target = $data['target'] ?? null; if ( !$name ) { self::debugLog( __FUNCTION__, $connection->id ?? 'undefined', 'ERROR: name is null' ); $value = null; } else { // self::debugLog( __FUNCTION__, var_export( $this->data, true ) ); $value = $this->data[$name] ?? null; self::debugLog( __FUNCTION__, $name, var_export( $value, true ) ); } self::sendAnswer( $connection, $name, $value, $target ); } /** * @param ConnectionInterface $connection * @param array $data */ public function onSetCommand( ConnectionInterface $connection, array $data ) { $cId = $connection->id ?? 'undefined'; $message = $data['message'] ?? []; $name = $message['name'] ?? null; if ( !$name ) { self::debugLog( __FUNCTION__, $cId, 'ERROR: name is null' ); return; } $value = $message['value'] ?? null; $this->data[$name] = $value; self::debugLog( __FUNCTION__, $cId, $name, print_r( $value, true ) ); if ( $message['sync'] ?? false ) { self::debugLog( __FUNCTION__, $cId, 'SYNCHRONIZE', $name ); $data['message']['action'] = 'sync'; $data['message']['sync'] = 'set'; $this->onSendCommand( $connection, $data ); } } /** * @param ConnectionInterface $connection * @param array $data */ public function onDatabaseCommand( ConnectionInterface $connection, array $data ) { if ( !empty( $data['target'][Room::TARGET_CONNECTION] ) ) { $data['target'][self::TARGET_ROOM_CONNECTION] = $connection->id; } $buffer = FormatJson::encode( $data ); $this->storageConnection->send( $buffer ); } /** * @param ConnectionInterface $connection * @param string $info * @param array|null $answer * @param array|null $target */ protected static function sendAnswer( ConnectionInterface $connection, string $info, ?array $answer = null, ?array $target = null ) { $data = [ 'info' => $info ]; $data['answer'] = $answer; if ( $target ) { $data['target'] = $target; } self::sendData( $data, [ $connection ] ); } /** * @param array $data * @param array $connections * @param ConnectionInterface|null $except */ protected static function sendData( array $data, array $connections, ?ConnectionInterface $except = null ) { $buffer = FormatJson::encode( $data ); self::sendBuffer( $buffer, $connections, $except ); } /** * @param string $buffer * @param ConnectionInterface[] $connections * @param ConnectionInterface|null $except */ protected static function sendBuffer( string $buffer, array $connections, ?ConnectionInterface $except = null ) { self::debugLog( __FUNCTION__, $buffer ); foreach ( $connections as $c ) { if ( $c === $except ) { continue; } $c->send( $buffer ); } } // /** // * @param string $name // * @param mixed $value // * @return bool // */ // public static function setData( string $name, $value ): bool { // $data = [ // 'command' => 'set', // 'message' => [ // 'name' => $name, // 'value' => $value, // ], // ]; // return self::sendDataToItself( $data ); // } /** * @param array $data * @return bool */ public static function sendDataToItself( array $data ) { $buffer = FormatJson::encode( $data ); return self::sendBufferToItself( $buffer ); } /** * @param string $buffer * @return bool */ public static function sendBufferToItself( string $buffer ) { self::debugLog( __FUNCTION__, $buffer ); $managerSocketName = self::getConfigValue( 'LiveChatManagerSocketName' ); $instance = stream_socket_client( $managerSocketName ); fwrite( $instance, $buffer ); fclose( $instance ); return true; } protected function connectToStorage() { $socketName = self::getConfigValue( 'LiveChatStorageSocketName' ); $this->debugLog( __FUNCTION__, $socketName ); try { $this->storageConnection = new AsyncTcpConnection( $socketName ); $this->storageConnection->onMessage = [ $this, 'onStorageMessage' ]; $this->storageConnection->onClose = [ $this, 'onStorageClose' ]; $this->storageConnection->onError = [ $this, 'onStorageError' ]; $this->storageConnection->connect(); } catch ( Exception $e ) { $this->debugLog( __FUNCTION__, 'ERROR', $e->getMessage() ); wfLogWarning( $e->getMessage() ); } } /** * @param ConnectionInterface $connection * @param string $value */ public function onStorageMessage( ConnectionInterface $connection, string $value ) { $this->debugLog( __FUNCTION__ ); $this->onMessage( $connection, $value ); } public function onStorageClose() { $this->debugLog( __FUNCTION__ ); } public function onStorageError() { $this->debugLog( __FUNCTION__ ); } } PK ! ��7n� � Tools.phpnu �[��� <?php namespace LiveChat; use MediaWiki\MediaWikiServices; use Message; use MWException; use User; class Tools { /** * @param User $user * @param string $key * @param string|string[] ...$params Normal message parameters * @return Message */ public static function getMessage( $user, $key, ...$params ) { $langCode = MediaWikiServices::getInstance()->getUserOptionsManager() ->getOption( $user, 'language' ); try { $message = wfMessage( $key )->inLanguage( $langCode ); } catch ( MWException $e ) { $message = wfMessage( $key ); } if ( $params ) { $message->params( ...$params ); } return $message; } } PK ! lP?� � Reactions.phpnu �[��� <?php namespace LiveChat; use User; class Reactions { const TABLE = 'lch_msg_reactions'; const C_MESSAGE_ID = 'lchmr_msg_id'; const C_USER_ID = 'lchmr_user_id'; const C_USER_TEXT = 'lchmr_user_text'; const C_TYPE = 'lchmr_type'; const C_TIMESTAMP = 'lchmr_timestamp'; /** * @var array */ private $messages; /** * @var ChatRoom */ private $room; /** * @var array */ private $reactions = []; private const REACTIONS_BY_ID = [ 1 => 'like', ]; /** * Reactions constructor. * @param array &$messages * @param ChatRoom $room */ public function __construct( array &$messages, ChatRoom $room ) { $this->messages = &$messages; $this->room = $room; } /** * @param User $user * @return bool */ public function canUserPostReaction( User $user ) { $options = $this->room->getOptions(); if ( empty( $options[ChatRoom::O_PERM_CAN_POST_REACTION] ) ) { return true; } return $user->isAllowed( $options[ChatRoom::O_PERM_CAN_POST_REACTION] ); } public function loadForMessages() { $this->loadForMessagesInternal( $this->messages, $this->reactions ); } /** * @param array &$messages * @param string[][] &$reactions */ private function loadForMessagesInternal( &$messages = [], &$reactions = [] ) { if ( !$messages ) { return; } $dbr = $this->room->getDBR(); $vars = [ self::C_MESSAGE_ID, self::C_USER_ID, self::C_USER_TEXT, self::C_TYPE, ]; $cond = [ self::C_MESSAGE_ID => array_keys( $messages ), ]; $res = $dbr->select( self::TABLE, $vars, $cond, __METHOD__ ); foreach ( $res as $row ) { $a = (array)$row; $id = $a[self::C_MESSAGE_ID]; $userText = $a[self::C_USER_ID] ? User::newFromId( $a[self::C_USER_ID] )->getName() : $a[self::C_USER_TEXT]; $reactionName = self::REACTIONS_BY_ID[ $a[self::C_TYPE] ] ?? 'undefined'; self::updateReaction( $messages, $reactions, $id, $userText, $reactionName ); } } /** * @param array &$messages * @param array &$reactions * @param string $id * @param string $userName * @param string $newReaction * @return bool */ private static function updateReaction( &$messages, &$reactions, $id, $userName, $newReaction ) { if ( empty( $messages[$id] ) ) { // echo 'empty( $messages[$id] )', "\n"; return false; } if ( !isset( $messages[$id]['reactions'] ) ) { // echo 'empty( $messages[$id][\'reactions\'] )', "\n"; $messages[$id]['reactions'] = []; } if ( !isset( $reactions[$id] ) ) { // echo 'empty( $reactions[$id] )', "\n"; $reactions[$id] = []; } $messageReactions =& $messages[$id]['reactions']; if ( isset( $reactions[$id][$userName] ) ) { // echo 'isset( $reactions[$id][$userName] )', "\n"; $oldReaction = $reactions[$id][$userName]; // echo '$oldReaction=', $oldReaction, "\n"; if ( $oldReaction === $newReaction ) { // echo '$oldReaction === $newReaction', "\n"; return true; } if ( ( $messageReactions[$oldReaction] ?? 0 ) > 0 ) { $messageReactions[$oldReaction]--; // echo '$messageReactions[ $oldReaction ]--', "\n"; } } if ( empty( $messageReactions[$newReaction] ) ) { $messageReactions[$newReaction] = 1; // echo '$messageReactions[$newReaction] = 1;', "\n"; } else { $messageReactions[$newReaction]++; // echo '$messageReactions[$newReaction]++;', "\n"; } $reactions[$id][$userName] = $newReaction; return true; } /** * @param int $id * @param User $userName * @param bool $fromCache * @return string|null|void */ public function getUserReaction( $id, $userName, $fromCache ) { if ( isset( $this->reactions[$id] ) ) { return $this->reactions[$id][$userName] ?? null; } elseif ( $fromCache ) { return null; } } } PK ! gb}�1F 1F ChatData.phpnu �[��� <?php namespace LiveChat; use FormatJson; use InvalidArgumentException; use MediaWiki\MediaWikiServices; use User; use wAvatar; use Wikimedia\Rdbms\Database; use Wikimedia\Timestamp\ConvertibleTimestamp; use Workerman\Connection\ConnectionInterface; class ChatData { const TABLE = 'lch_messages'; const C_ID = 'lchm_id'; const C_PARENT = 'lchm_parent_id'; const C_ROOM_TYPE = 'lchm_room_type'; const C_ROOM_ID = 'lchm_room_id'; const C_USER_ID = 'lchm_user_id'; const C_USER_TEXT = 'lchm_user_text'; const C_MESSAGE = 'lchm_message'; const C_HAS_CHILDREN = 'lchm_has_children'; const C_TIMESTAMP = 'lchm_timestamp'; const TABLE_REACTION = 'lch_msg_reactions'; const C_REACTION_MESSAGE_ID = 'lchmr_msg_id'; const C_REACTION_USER_ID = 'lchmr_user_id'; const C_REACTION_USER_TEXT = 'lchmr_user_text'; const C_REACTION_TYPE = 'lchmr_type'; const C_REACTION_TIMESTAMP = 'lchmr_timestamp'; const CACHE_SIZE = 100; /** * @var array */ protected static $cache = []; const REACTIONS_BY_NAME = [ 'like' => 1, ]; const REACTIONS_BY_ID = [ 1 => 'like', ]; /** * @var Database */ private static $dbw; /** * @var Database */ private static $dbr; /** * @param ConnectionInterface $connection * @param string $name * @param string $command * @param array $data */ public static function onCommand( ConnectionInterface $connection, string $name, string $command, array $data ) { self::debugLog( __FUNCTION__, $name, $command, var_export( $data, true ) ); switch ( $command ) { case 'insert': self::onInsertCommand( $connection, $data ); break; case 'select': self::onSelectCommand( $connection, $data ); break; case 'update': self::onUpdateCommand( $connection, $data ); break; default: self::debugLog( __FUNCTION__, 'ERROR: Unknown command' ); } } /** * @param ConnectionInterface $connection * @param array $data */ protected static function onSelectCommand( ConnectionInterface $connection, array $data ) { $roomId = $data['roomId']; $roomType = $data['roomType']; $target = $data['target'] ?? null; $value = $data['message']['value'] ?? []; // $userId = $value['userId'] ?? 0; $userName = $value['userName'] ?? 'Undefined'; $fromId = $value['fromId'] ?? null; $parentId = $value['parentId'] ?? null; $roomCache = &self::getCache( [ $roomType, $roomId ] ); if ( !$roomCache ) { self::loadMessagesToCache( $roomType, $roomId ); } $reactions = &$roomCache['reactions']; $messagesCache = &$roomCache['messages']; $return = []; if ( $parentId ) { $parent = self::getParent( $roomType, $roomId, $parentId ); if ( $parent && !$fromId ) { // Add parent message if this is not a history request for disconnect events $return[] = $parent; } if ( $parent && isset( $roomCache['children'][$parentId] ) ) { $messagesCache = &$roomCache['children'][$parentId]; } else { $emptyArray = []; $messagesCache = &$emptyArray; } } if ( !$fromId || isset( $messagesCache[$fromId] ) ) { // TODO send reaction also when fromId provided foreach ( $messagesCache as $msgId => $message ) { // TODO optimize me if ( !$fromId || $msgId > $fromId ) { // Add user reaction $userReaction = $reactions[$msgId][$userName] ?? null; if ( $userReaction ) { $message['userReaction'] = $userReaction; } $return[] = $message; } } } else { // TODO load from database } $bufferData = [ 'event' => ChatRoom::EVENT_SEND_HISTORY, 'messages' => $return, ]; if ( $parentId ) { $bufferData['parentId'] = $parentId; } self::sendDataToManager( $connection, [ 'command' => 'send', 'message' => [ 'action' => Room::MANAGER_ACTION_SEND_TO_ALL, 'buffer' => FormatJson::encode( $bufferData ), ], 'key' => $data['key'] ?? null, 'roomId' => $roomId, 'roomType' => $roomType, ], $target ); } /** * Updates reactions * @param ConnectionInterface $connection * @param array $data */ protected static function onUpdateCommand( ConnectionInterface $connection, array $data ) { $target = $data['target'] ?? null; $message = $data['message'] ?? []; $value = $message['value'] ?? []; $id = $value['message'] ?? null; if ( !$id ) { self::debugLog( __FUNCTION__, 'ERROR: No message id provided' ); return; } $newReaction = $value['reaction'] ?? null; $newReactionId = self::REACTIONS_BY_NAME[$newReaction] ?? null; if ( !$newReactionId ) { self::debugLog( __FUNCTION__, "ERROR: Reaction $newReaction is not allowed" ); return; } $userId = $value['userId']; $userName = $value['userName']; $roomId = $data['roomId']; $roomType = $data['roomType']; $roomCache = &self::getCache( [ $roomType, $roomId ] ); if ( !$roomCache ) { self::loadMessagesToCache( $roomType, $roomId ); } $messagesCache = &$roomCache['messages']; $reactionsCache = &$roomCache['reactions']; $msgInCache = self::updateReaction( $messagesCache, $reactionsCache, $id, $userName, $newReaction ); if ( !$msgInCache ) { self::debugLog( __FUNCTION__, "ERROR: Message does not exist $roomType $roomId $id" ); return; } $messageReactions = $messagesCache[$id]['reactions']; $dbw = self::getDBW(); $time = Connection::getTime(); $timestamp = ConvertibleTimestamp::convert( TS_MW, $time ); $index = [ self::C_REACTION_MESSAGE_ID => $id, self::C_REACTION_USER_ID => $userId, self::C_REACTION_USER_TEXT => $userName, self::C_REACTION_TIMESTAMP => $timestamp, ]; $set = [ self::C_REACTION_TYPE => $newReactionId, ]; $dbw->upsert( self::TABLE_REACTION, [ $index + $set ], [ self::C_REACTION_MESSAGE_ID, self::C_REACTION_USER_TEXT ], $set, __METHOD__ ); $msg = [ 'event' => ChatRoom::EVENT_SEND_REACTION, 'id' => $id, 'reaction' => $newReaction, 'messageReactions' => $messageReactions, 'time' => $timestamp, ]; self::sendDataToManager( $connection, [ 'command' => 'send', 'message' => [ 'action' => Room::MANAGER_ACTION_SEND_TO_ALL, 'buffer' => FormatJson::encode( $msg ), ], 'key' => $data['key'] ?? null, 'roomId' => $roomId, 'roomType' => $roomType, ], $target ); } /** * @param array &$messages * @param array &$reactions */ protected static function loadReactionsForMessages( &$messages = [], &$reactions = [] ) { if ( !$messages ) { return; } $dbr = self::getDBR(); $vars = [ self::C_REACTION_MESSAGE_ID, self::C_REACTION_USER_ID, self::C_REACTION_USER_TEXT, self::C_REACTION_TYPE, ]; $cond = [ self::C_REACTION_MESSAGE_ID => array_keys( $messages ), ]; $res = $dbr->select( self::TABLE_REACTION, $vars, $cond, __METHOD__ ); foreach ( $res as $row ) { $a = (array)$row; $id = $a[self::C_REACTION_MESSAGE_ID]; $userText = $a[self::C_REACTION_USER_ID] ? User::newFromId( $a[self::C_REACTION_USER_ID] )->getName() : $a[self::C_REACTION_USER_TEXT]; $reactionName = self::REACTIONS_BY_ID[ $a[self::C_REACTION_TYPE] ] ?? 'undefined'; self::updateReaction( $messages, $reactions, $id, $userText, $reactionName ); } } /** * @param array &$messages * @param array &$reactions * @param string $id * @param string $userName * @param string $newReaction * @return bool */ private static function updateReaction( &$messages, &$reactions, $id, $userName, $newReaction ) { if ( empty( $messages[$id] ) ) { self::debugLog( __FUNCTION__, 'empty( $messages[$id] )', $id ); return false; } if ( !isset( $messages[$id]['reactions'] ) ) { self::debugLog( __FUNCTION__, 'empty( $messages[$id]["reactions"] )', $id ); $messages[$id]['reactions'] = []; } if ( !isset( $reactions[$id] ) ) { self::debugLog( __FUNCTION__, 'empty( $reactions[$id] )', $id ); $reactions[$id] = []; } $messageReactions =& $messages[$id]['reactions']; $oldReaction = $reactions[$id][$userName] ?? null; if ( $oldReaction ) { if ( $oldReaction === $newReaction ) { self::debugLog( __FUNCTION__, '$oldReaction === $newReaction', $id, $userName, $newReaction ); return true; } self::debugLog( __FUNCTION__, '$oldReaction', $oldReaction, '$newReaction', $newReaction ); if ( ( $messageReactions[$oldReaction] ?? 0 ) > 0 ) { $messageReactions[$oldReaction]--; // echo '$messageReactions[ $oldReaction ]--', "\n"; } } if ( empty( $messageReactions[$newReaction] ) ) { $messageReactions[$newReaction] = 1; // echo '$messageReactions[$newReaction] = 1;', "\n"; } else { $messageReactions[$newReaction]++; // echo '$messageReactions[$newReaction]++;', "\n"; } $reactions[$id][$userName] = $newReaction; return true; } /** * @param ConnectionInterface $connection * @param array $data */ protected static function onInsertCommand( ConnectionInterface $connection, array $data ) { $roomId = $data['roomId']; $roomType = $data['roomType']; $target = $data['target'] ?? null; $roomCache = &self::getCache( [ $roomType, $roomId ] ); $messagesCache = &$roomCache[ 'messages' ]; $value = $data['message']['value'] ?? []; $parentId = $value['parentId'] ?? null; $userId = $value['userId'] ?? 0; $userName = $value['userName'] ?? 'Undefined'; if ( $parentId ) { $parent = &self::getParent( $roomType, $roomId, $parentId, true ); if ( !$parent ) { $parentId = null; } } $text = $value['message'] ?? ''; if ( !$text ) { self::debugLog( __FUNCTION__, 'ERROR: Message is empty' ); } // Save to the database $time = Connection::getTime(); $row = [ self::C_ROOM_TYPE => $roomType, self::C_ROOM_ID => $roomId, self::C_PARENT => $parentId, self::C_USER_ID => $userId, self::C_USER_TEXT => $userName, self::C_MESSAGE => $text, self::C_HAS_CHILDREN => false, self::C_TIMESTAMP => ConvertibleTimestamp::convert( TS_MW, $time ), ]; $dbw = self::getDBW(); $dbw->insert( self::TABLE, $row, __METHOD__ ); $id = $dbw->insertId(); // Make Message $msg = self::makeMessageData( $roomType, $roomId, $id, $text, $userName, $userId, $time, $parentId, false ); if ( $id ) { if ( $parentId ) { $roomCache['children'][$parentId][$id] = &$messagesCache[$id]; if ( empty( $parent['hasChildren'] ) ) { $parent['hasChildren'] = 1; $dbw->update( self::TABLE, [ self::C_HAS_CHILDREN => true ], [ self::C_ID => $parentId ], __METHOD__ ); } } $messagesCache[$id] = $msg; // TODO need to develop more smarter cleaner (should work with parents and reactions) // if ( count( $messagesCache ) > static::CACHE_SIZE ) { // $min = min( array_keys( $messagesCache ) ); // unset( $messagesCache[$min] ); // } } else { self::debugLog( __FUNCTION__, 'ERROR: Cannot insert row' ); } self::sendDataToManager( $connection, [ 'command' => 'send', 'message' => [ 'action' => Room::MANAGER_ACTION_SEND_TO_ALL, 'buffer' => FormatJson::encode( [ 'event' => ChatRoom::EVENT_SEND_MESSAGE, 'messageData' => $msg, ] ), ], 'key' => $data['key'] ?? null, 'roomId' => $roomId, 'roomType' => $roomType, ], $target ); } /** * @param int $roomType * @param int $roomId * @param int $id * @param string|null $text * @param string $userName * @param int|null $userId * @param string $time * @param int|null $parentId * @param bool $hasChildren * @return array */ protected static function makeMessageData( int $roomType, int $roomId, $id, $text, $userName, $userId, $time, $parentId, $hasChildren ) { $msg = [ 'id' => $id, 'message' => MessageParser::parse( $text ), 'userName' => $userName, 'timestamp' => ConvertibleTimestamp::convert( TS_MW, $time ), ]; if ( $hasChildren ) { $msg['hasChildren'] = 1; } if ( $userId ) { $msg['userId'] = $userId; } if ( $parentId ) { $parent = self::getParent( $roomType, $roomId, $parentId, false ); if ( $parent ) { $msg['parentId'] = $parentId; $msg['parentUserName'] = $parent['userName']; } } $userAvatar = self::getUserAvatar( $userId ); if ( $userAvatar ) { $msg['userAvatar'] = $userAvatar; } return $msg; } /** * @param int|null $userId * @param string $size * @return string|null */ public static function getUserAvatar( $userId, $size = 'm' ) { if ( !$userId ) { return null; } $avatar = new wAvatar( $userId, $size ); if ( $avatar->isDefault() ) { return null; } global $wgUploadBaseUrl, $wgUploadPath; $avatarImg = $avatar->getAvatarImage(); return "{$wgUploadBaseUrl}{$wgUploadPath}/avatars/{$avatarImg}"; } /** * @param int $roomType * @param int $roomId * @param int $messageId * @param bool $withChildren * @return array */ protected static function loadMessage( int $roomType, int $roomId, int $messageId, bool $withChildren = false ) { $dbr = self::getDBR(); $conds = [ self::C_ROOM_TYPE => $roomType, self::C_ROOM_ID => $roomId, ]; if ( $withChildren ) { $ids = $dbr->makeList( [ self::C_ID => $messageId, self::C_PARENT => $messageId, ], LIST_OR ); $conds[] = $ids; } else { $conds[self::C_ID] = $messageId; } // TODO get user name from user table $res = $dbr->select( self::TABLE, '*', $conds, __METHOD__ ); if ( !$res ) { return []; } $parent = []; $children = []; foreach ( $res as $row ) { $msg = self::messageDataFromRow( $roomType, $roomId, (array)$row ); $id = $msg['id']; if ( $id == $messageId ) { $parent = $msg; } else { $children[$id] = $msg; } } if ( $children ) { $parentId = $parent['id']; $roomCache['children'][$parentId] = $children; } if ( $parent[self::C_ROOM_TYPE] != $roomType || $parent[self::C_ROOM_ID] != $roomId ) { return []; } return $parent; } /** * @param int $roomType * @param int $roomId * @param array $row * @return array */ protected static function messageDataFromRow( int $roomType, int $roomId, array $row ) { $msg = self::makeMessageData( $roomType, $roomId, $row[self::C_ID], $row[self::C_MESSAGE], $row[self::C_USER_TEXT], $row[self::C_USER_ID], $row[self::C_TIMESTAMP], $row[self::C_PARENT], $row[self::C_HAS_CHILDREN] ); return $msg; } /** * @param mixed ...$args */ protected static function debugLog( ...$args ) { wfDebugLog( __CLASS__, static::class . '::' . implode( '; ', $args ), false ); } /** * @param array $path * @return array */ protected static function &getCache( array $path ) { $ret = &self::$cache; foreach ( $path as $i => $k ) { if ( !isset( $ret[$k] ) ) { $ret[$k] = []; } if ( !is_array( $ret[$k] ) ) { $fail = implode( '.', array_slice( $path, 0, $i + 1 ) ); throw new InvalidArgumentException( "Path $fail is not an array" ); } $ret = &$ret[$k]; } return $ret; } /** * @return Database */ protected static function getDBW() { if ( !self::$dbw ) { self::$dbw = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_PRIMARY ); } return self::$dbw; } /** * @return Database */ protected static function getDBR() { if ( !self::$dbr ) { self::$dbr = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_REPLICA ); } return self::$dbr; } /** * @param ConnectionInterface $connection * @param array $data * @param array|null $target */ protected static function sendDataToManager( ConnectionInterface $connection, array $data, ?array $target = null ) { if ( $target ) { $data['message']['target'] = $target; } $buffer = FormatJson::encode( $data ); self::debugLog( __FUNCTION__, $buffer ); $connection->send( $buffer ); } /** * @param int $roomType * @param int $roomId */ protected static function loadMessagesToCache( int $roomType, int $roomId ) { self::debugLog( __FUNCTION__, $roomType, $roomId ); $roomCache = &self::getCache( [ $roomType, $roomId ] ); // if ( !$roomCache ) { … $roomCache = [ 'messages' => [], 'parents' => [], 'children' => [], 'reactions' => [], ]; // … } $dbr = self::getDBR(); $conds = [ self::C_ROOM_TYPE => $roomType, self::C_ROOM_ID => $roomId, ]; $options = [ 'LIMIT' => static::CACHE_SIZE, 'ORDER BY' => self::C_ID, ]; // TODO get user name from user table $res = $dbr->select( self::TABLE, '*', $conds, __METHOD__, $options ); if ( !$res ) { return; } $messageCache = &$roomCache['messages']; foreach ( $res as $row ) { $a = (array)$row; $parentId = $a[self::C_PARENT]; $msg = self::messageDataFromRow( $roomType, $roomId, $a ); $id = $msg['id']; $messageCache[$id] = $msg; if ( $parentId ) { $parent = &self::getParent( $roomType, $roomId, $parentId, true ); if ( $parent ) { $roomCache['children'][$parentId][$id] = &$messageCache[$id]; } } } self::loadReactionsForMessages( $messageCache, $roomCache['reactions'] ); } /** * @param int $roomType * @param int $roomId * @param int $parentId * @param bool $withChildren * @return array|null */ protected static function &getParent( int $roomType, int $roomId, int $parentId, bool $withChildren = false ): ?array { $parentsCache = &self::getCache( [ $roomType, $roomId, 'parents' ] ); if ( empty( $parentsCache[$parentId] ) ) { $messagesCache = &self::getCache( [ $roomType, $roomId, 'messages' ] ); if ( empty( $messagesCache[$parentId] ) ) { $parent = self::loadMessage( $roomType, $roomId, $parentId, $withChildren ); } else { $parent = &$messagesCache[$parentId]; } if ( !$parent || isset( $parent['parentId'] ) ) { $parent = null; // don't allow wrong parent and parent of children } else { $parentsCache[$parentId] =& $parent; } } else { $parent = &$parentsCache[$parentId]; } return $parent; } } PK ! �z�m m Worker.phpnu �[��� <?php namespace LiveChat; use ConfigException; use FormatJson; use MWDebug; use RequestContext; use Workerman\Connection\ConnectionInterface; class Worker extends \Workerman\Worker { /** * Manager constructor. * @param string $socket_name * @param array $context_option */ public function __construct( string $socket_name = '', array $context_option = [] ) { self::debugLog( __FUNCTION__, $socket_name ); parent::__construct( $socket_name, $context_option ); } /** * Run manager instance. * * @see Workerman.Worker::run() */ public function run() { self::debugLog( __FUNCTION__ ); $this->onWorkerStart = [ $this, 'onWorkerStart' ]; $this->onWorkerStop = [ $this, 'onWorkerStop' ]; $this->onMessage = [ $this, 'onMessage' ]; $this->onClose = [ $this, 'onClose' ]; $this->onConnect = [ $this, 'onConnect' ]; parent::run(); } /** * @param ConnectionInterface $connection * @param string $value */ public function onMessage( ConnectionInterface $connection, string $value ) { $exploded = explode( '}{', $value ); $lastKey = count( $exploded ) - 1; foreach ( $exploded as $k => $v ) { if ( $lastKey > 0 ) { if ( $k !== 0 ) { $v = '{' . $v; } if ( $k !== $lastKey ) { $v .= '}'; } } $this->onExplodedMessage( $connection, $v ); } } /** * @param ConnectionInterface $connection * @param string $value */ protected function onExplodedMessage( ConnectionInterface $connection, string $value ) { self::debugLog( __FUNCTION__, $connection->id ?? 'undefined', $value ); $data = FormatJson::decode( $value, true ) ?? []; if ( $data === null ) { self::debugLog( __FUNCTION__, $connection->id ?? 'undefined', 'ERROR: CANNOT DECODE JSON', $value ); return; } $command = $data['command'] ?? null; if ( !$command ) { self::debugLog( __FUNCTION__, $connection->id ?? 'undefined', 'ERROR: EMPTY COMMAND', print_r( $data, true ) ); return; } foreach ( (array)$command as $cmd ) { $this->onCommand( $connection, $cmd, $data ); } } /** * @param ConnectionInterface $connection * @param string $command * @param array $data */ protected function onCommand( ConnectionInterface $connection, string $command, array $data ) { self::debugLog( __FUNCTION__, $connection->id ?? 'undefined', $command ); } /** * @param ConnectionInterface $connection */ public function onConnect( ConnectionInterface $connection ) { self::debugLog( __FUNCTION__, $connection->id ?? 'undefined' ); } public function onWorkerStart() { self::debugLog( __FUNCTION__ ); } public function onWorkerStop() { self::debugLog( __FUNCTION__ ); /** @var ConnectionInterface $connection */ foreach ( $this->connections as $connection ) { $connection->close(); } } /** * @param ConnectionInterface $connection */ public function onClose( ConnectionInterface $connection ) { self::debugLog( __FUNCTION__ ); } /** * @param string $name * @return mixed|null */ protected static function getConfigValue( string $name ) { $config = RequestContext::getMain()->getConfig(); try { return $config->get( $name ); } catch ( ConfigException $e ) { MWDebug::warning( $e->getMessage() ); } return null; } /** * @param mixed ...$args */ protected static function debugLog( ...$args ) { wfDebugLog( __CLASS__, static::class . '::' . implode( '; ', $args ), false ); } } PK ! CO�� � Storage.phpnu �[��� <?php namespace LiveChat; use MediaWiki\MediaWikiServices; use Workerman\Connection\ConnectionInterface; class Storage extends Worker { /** * @var array */ protected $providers = []; /** @inheritDoc */ public function __construct( string $socket_name = '', array $context_option = [] ) { if ( !$socket_name ) { $socket_name = self::getConfigValue( 'LiveChatStorageSocketName' ); } parent::__construct( $socket_name, $context_option ); $this->name = 'LiveChat Storage'; } public function run() { MediaWikiServices::getInstance()->getHookContainer()->run( 'LiveChatStorageInit', [ &$this->providers ] ); parent::run(); } /** * @param ConnectionInterface $connection * @param string $command * @param array $data */ protected function onCommand( ConnectionInterface $connection, string $command, array $data ) { parent::onCommand( $connection, $command, $data ); $message = $data['message'] ?? null; if ( !$message ) { self::debugLog( __FUNCTION__, 'ERROR: Message is empty' ); return; } $providerName = $message['providerName'] ?? null; if ( !$providerName ) { self::debugLog( __FUNCTION__, 'ERROR: Provider name is empty' ); return; } $className = $this->providers[$providerName] ?? null; if ( !$className ) { self::debugLog( __FUNCTION__, 'ERROR', 'Unknown provider for name', $providerName ); return; } call_user_func( [ $className, 'onCommand' ], $connection, $providerName, $command, $data ); } } PK ! ���8 8 Connection.phpnu �[��� <?php namespace LiveChat; use Exception; use FormatJson; use MediaWiki\MediaWikiServices; use MWDebug; use Title; use User; use WebRequest; use Wikimedia\Timestamp\ConvertibleTimestamp; use Workerman\Connection\ConnectionInterface; class Connection { /** * @var ConnectionInterface */ private $connection; /** * @var User */ private $user; /** * @var string */ private $userSession; /** * @var Title|null */ private $title; /** * @var Room[] */ private $rooms = []; /** * @var User[] */ private $lpUsers; /** * @var array */ private $data = []; /** * @var Connection[][] */ private static $anonymous = []; /** * @var Connection[][] */ private static $registered = []; const COUNT_ALL = 1; const COUNT_REGISTERED = 2; const COUNT_ANONYMOUS = 3; const EVENT_CONNECT = 'connect'; const EVENT_PING = 'ping'; const EVENT_PONG = 'pong'; const EVENT_SEND_WRONG_ROOM = 'LiveChatWrongRoom'; /** * Connection constructor. * @param ConnectionInterface $connection * @param User $user */ public function __construct( ConnectionInterface $connection, User $user ) { $this->connection = $connection; $this->user = $user; if ( $user->isAnon() ) { if ( !isset( self::$anonymous[$user->getName()] ) ) { self::$anonymous[$user->getName()] = []; } $this->lpUsers =& self::$anonymous[$user->getName()]; } else { if ( !isset( self::$registered[$user->getName()] ) ) { self::$registered[$user->getName()] = []; } $this->lpUsers =& self::$registered[$user->getName()]; } $this->lpUsers[] = $this; } /** * @param ConnectionInterface $connection * @return Connection */ public static function factory( ConnectionInterface $connection ) { $_SERVER['REQUEST_TIME_FLOAT'] = microtime( true ); $_SERVER['REMOTE_ADDR'] = $connection->getRemoteIp(); $request = new WebRequest(); $user = User::newFromSession( $request ); return new self( $connection, $user ); } /** * @return User */ public function getUser(): User { return $this->user; } /** * @param int $count * @return int|null */ public function getUsersCount( $count = self::COUNT_ALL ) { switch ( $count ) { case self::COUNT_ALL: return self::getUsersCount( self::COUNT_REGISTERED ) + self::getUsersCount( self::COUNT_ANONYMOUS ); case self::COUNT_REGISTERED: return count( self::$registered ); case self::COUNT_ANONYMOUS: return count( self::$anonymous ); } return null; } /** * @param string $value */ public function onMessage( $value ) { $data = FormatJson::decode( $value, true ) ?? []; $event = $data['event'] ?? null; if ( $event === self::EVENT_CONNECT ) { $this->onConnectEvent( $data ); return; } elseif ( $event === self::EVENT_PING ) { $this->send( self::EVENT_PONG, [ 'clientTime' => $data['time'] ?? null ] ); return; } foreach ( $this->rooms as $room ) { $room->onEvent( $this, $event, $data ); } } /** * @param string $event * @param array $data * @param string|null $time * @return bool|void */ public function send( string $event, array $data = [], ?string $time = null ) { $buffer = self::makeSendBuffer( $event, $data, $time ); return $this->sendBuffer( $buffer ); } /** * @param string $buffer * @return bool|void */ public function sendBuffer( string $buffer ) { return $this->connection->send( $buffer ); } /** * @param string $event * @param array $data * @param string|null $time * @return string */ public static function makeSendBuffer( string $event, array $data = [], ?string $time = null ): string { if ( !$time ) { $time = self::getTime(); } $data['event'] = $event; $data['time'] = $time; return FormatJson::encode( $data ); } /** * @return string */ public static function getTime() { return ConvertibleTimestamp::now( TS_UNIX ); } /** * @param array $data */ private function onConnectEvent( array $data ) { if ( empty( $this->userSession ) ) { $this->userSession = $data['session'] ?? null; } $pageName = $data['pageName'] ?? null; if ( $pageName ) { $title = Title::newFromText( $pageName ); if ( $title ) { $this->title = $title; } } try { MediaWikiServices::getInstance()->getHookContainer()->run( 'LiveChatConnected', [ $this, $data ] ); } catch ( Exception $e ) { MWDebug::warning( $e->getMessage() ); } } public function onClose() { foreach ( $this->rooms as $room ) { $room->removeConnection( $this ); } $key = array_search( $this, $this->lpUsers ); if ( $key !== false ) { unset( $this->lpUsers[$key] ); if ( !$this->lpUsers ) { // It is the last connection for the user $user = $this->getUser(); if ( $user->isAnon() ) { unset( self::$anonymous[$user->getName()] ); } else { unset( self::$registered[$user->getName()] ); } } } } /** * @param Room $room * @param string|null $name */ public function addRoom( Room $room, ?string $name = null ) { if ( !$name ) { $name = get_class( $room ); } if ( !empty( $this->rooms[$name] ) ) { $this->rooms[$name]->removeConnection( $this ); } $this->rooms[$name] = $room; if ( $room ) { $room->addConnection( $this ); } } /** * @param string $name * @return Room|null */ public function getRoom( string $name ): ?Room { return $this->rooms[$name] ?? null; } /** * @return Title|null */ public function getTitle() { return $this->title; } /** * @return int|string */ public function getUserKey() { $user = $this->user; return $user->isAnon() ? $user->getName() : $user->getId(); } /** * @param string $name * @return mixed|null */ public function getData( $name ) { return $this->data[$name] ?? null; } /** * @param string $name * @param mixed $value */ public function setData( $name, $value ) { $this->data[$name] = $value; } /** * @param string $error */ public function sendErrorMessage( string $error ) { $this->send( 'ErrorMessage', [ 'error' => $error ] ); } /** * @return int|null */ public function getId() { return $this->connection->id ?? null; } } PK ! I�^�� � LiveChatHooks.phpnu �[��� <?php use LiveChat\ChatData; use LiveChat\ChatRoom; use LiveChat\Connection; use LiveChat\ManagerRoom; use MediaWiki\MediaWikiServices; class LiveChatHooks { /** * @var ChatRoom[] */ private static $chatRooms = []; /** * @var ManagerRoom */ private static $managerRoom; /** * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforePageDisplay * @param OutputPage &$out * @param Skin &$skin */ public static function onBeforePageDisplay( OutputPage &$out, Skin &$skin ) { // $out->addModules( 'ext.LiveChat.client' ); } /** * @param Connection $connection */ public static function onLiveChatConnected( Connection $connection ) { static $specialLiveChatText, $specialLiveStatusText; $title = $connection->getTitle(); if ( $title && $title->getNamespace() === NS_SPECIAL ) { if ( !$specialLiveChatText ) { $specialLiveChatText = SpecialPage::getTitleFor( 'LiveChat' )->getText(); $specialLiveStatusText = SpecialPage::getTitleFor( 'LiveStatus' )->getText(); } $rootText = $title->getRootText(); echo "####################### $rootText\n"; echo "$ $specialLiveChatText $$$ $specialLiveStatusText\n"; if ( $rootText === $specialLiveChatText ) { $subpageText = $title->getSubpageText(); if ( empty( self::$chatRooms[$subpageText] ) ) { self::$chatRooms[$subpageText] = new ChatRoom(); } $connection->addRoom( self::$chatRooms[$subpageText] ); } elseif ( $rootText === $specialLiveStatusText ) { echo '$connection->addRoom( self::getManagerRoom() );'; $connection->addRoom( self::getManagerRoom() ); } } } /** * @param array &$providers */ public static function onLiveChatStorageInit( &$providers ) { $providers[ChatData::class] = ChatData::class; } /** * @see https://www.mediawiki.org/wiki/Manual:Hooks/MakeGlobalVariablesScript * @param array &$vars * @param OutputPage $out * @throws ConfigException */ public static function onMakeGlobalVariablesScript( &$vars, OutputPage $out ) { $config = MediaWikiServices::getInstance()->getConfigFactory() ->makeConfig( 'main' ); $domain = $config->get( 'LiveChatClientDomain' ) ?: self::getDomain(); $port = $config->get( 'LiveChatClientPort' ); $path = $config->get( 'LiveChatClientPath' ); $protocol = $config->get( 'LiveChatClientTLS' ) ? 'wss' : 'ws'; $vars['LiveChatClientURL'] = "$protocol://$domain:$port/$path"; } /** * @return string */ private static function getDomain() { global $wgServer, $wgServerName; $serverParts = wfParseUrl( $wgServer ); return $serverParts && isset( $serverParts['host'] ) ? $serverParts['host'] : $wgServerName; } /** * @return ManagerRoom */ public static function getManagerRoom() { if ( !self::$managerRoom ) { self::$managerRoom = new ManagerRoom( 0, [ ManagerRoom::O_ROOM_RESTRICTION => 'LiveChatManager', ] ); } return self::$managerRoom; } /** * This is attached to the MediaWiki 'LoadExtensionSchemaUpdates' hook. * Fired when MediaWiki is updated to allow extensions to update the database * @param DatabaseUpdater $updater */ public static function onLoadExtensionSchemaUpdates( DatabaseUpdater $updater ) { $updater->addExtensionTable( 'lch_messages', __DIR__ . '/../sql/messages.sql' ); $updater->addExtensionField( 'lch_messages', 'lchm_has_children', __DIR__ . '/../sql/patch_messages_add_has_children.sql' ); $updater->addExtensionTable( 'lch_msg_reactions', __DIR__ . '/../sql/msg_reactions.sql' ); } } PK ! �b�a� � ManagerRoom.phpnu �[��� <?php namespace LiveChat; class ManagerRoom extends Room { const ROOM_TYPE = 1; /** * @param Connection $connection * @param string $event * @param array $data */ public function onEvent( Connection $connection, string $event, array $data ) { switch ( $event ) { case 'getRoomList': $this->onGetRoomList( $connection, $data ); break; default: parent::onEvent( $connection, $event, $data ); } } /** * @param Connection $connection * @param array $data */ private function onGetRoomList( Connection $connection, array $data ) { $this->debugLog( static::class, $connection->getId() ); $this->sendToManager( 'status', [ 'info' => 'rooms' ], [ self::TARGET_CONNECTION => $connection->getId() ] ); } } PK ! ҭA� � MessageParser.phpnu �[��� <?php namespace LiveChat; use Language; use Linker; use MediaWiki\MediaWikiServices; use Parser; use Sanitizer; use Title; class MessageParser { const TYPE_TEXT = 'text'; const TYPE_INTERNAL_LINK = 'internalLink'; const TYPE_EXTERNAL_LINK = 'externalLink'; /** * @var array */ private $result = []; /** * @var string */ private $mUrlProtocols; /** * @var string */ private $mAbsoluteUrlProtocols; /** * @var string */ private $mExtLinkBracketedRegex; public function __construct() { $urlUtils = MediaWikiServices::getInstance()->getUrlUtils(); $this->mUrlProtocols = $urlUtils->validProtocols(); $this->mAbsoluteUrlProtocols = $urlUtils->validAbsoluteProtocols(); $this->mExtLinkBracketedRegex = '/\[(((?i)' . $this->mUrlProtocols . ')' . Parser::EXT_LINK_ADDR . Parser::EXT_LINK_URL_CLASS . '*)\p{Zs}*([^\]\\x00-\\x08\\x0a-\\x1F\\x{FFFD}]*?)\]/Su'; } /** * @param string $text * @return array */ public static function parse( $text ) { $parser = new self(); $parser->parseInternalLinks( $text ); $parser->parseExternalLinks(); $parser->parseFreeExternalLinks(); return $parser->getResult(); } /** * @param string $text */ private function parseInternalLinks( $text ) { static $tc = false, $e1; // Parse Internal links if ( !$tc ) { $tc = Title::legalChars() . '#%'; # Match a link having the form [[namespace:link|alternate]]trail $e1 = "/^(.*?)\[\[([{$tc}]+)(?:\\|(.+?))?]](.*)\$/sD"; } while ( preg_match( $e1, $text, $matches ) ) { if ( $matches[1] ) { $this->result[] = self::makeText( $matches[1] ); } $this->result[] = self::makeInternalLink( $matches[2], $matches[3] ); // var_dump( $matches ); $text = $matches[4]; } if ( $text ) { $this->result[] = self::makeText( $text ); } } private function parseExternalLinks() { /** @var Language $wgLang */ global $wgLang; $langConverter = MediaWikiServices::getInstance()->getLanguageConverterFactory()->getLanguageConverter( $wgLang ); $key = 0; while ( isset( $this->result[$key] ) ) { $result = $this->result[$key]; if ( $result['type'] !== self::TYPE_TEXT || empty( $result['text'] ) ) { $key++; continue; } $text = $result['text']; $newResult = []; $bits = preg_split( $this->mExtLinkBracketedRegex, $text, -1, PREG_SPLIT_DELIM_CAPTURE ); $tmp = array_shift( $bits ); if ( $tmp ) { $newResult[] = self::makeText( $tmp ); } $i = 0; while ( $i < count( $bits ) ) { $url = $bits[$i++]; $i++; // protocol $text = $bits[$i++]; $trail = $bits[$i++]; # The characters '<' and '>' (which were escaped by # removeHTMLtags()) should not be included in # URLs, per RFC 2396. $m2 = []; if ( preg_match( '/&(lt|gt);/', $url, $m2, PREG_OFFSET_CAPTURE ) ) { $text = substr( $url, $m2[0][1] ) . ' ' . $text; $url = substr( $url, 0, $m2[0][1] ); } $dtrail = ''; # No link text, e.g. [http://domain.tld/some.link] if ( $text !== '' ) { # Have link text, e.g. [http://domain.tld/some.link text]s # Check for trail [ $dtrail, $trail ] = Linker::splitTrail( $trail ); // Excluding protocol-relative URLs may avoid many false positives. if ( preg_match( '/^(?:' . $this->mAbsoluteUrlProtocols . ')/', $text ) ) { $text = $langConverter->markNoConversion( $text ); } } $newResult[] = self::makeExternalLink( $url, $text ); $newResult[] = self::makeText( $dtrail . $trail ); } $newCount = count( $newResult ); if ( $newCount === 1 ) { $this->result[$key] = $newResult[0]; } elseif ( $newResult ) { array_splice( $this->result, $key, 1, $newResult ); $key += $newCount; continue; } $key++; } } public function parseFreeExternalLinks() { $prots = $this->mAbsoluteUrlProtocols; $urlChar = Parser::EXT_LINK_URL_CLASS; $addr = Parser::EXT_LINK_ADDR; $key = 0; while ( isset( $this->result[$key] ) ) { $result = $this->result[$key]; if ( $result['type'] !== self::TYPE_TEXT || empty( $result['text'] ) ) { $key++; continue; } $text = $result['text']; $newResult = []; while ( preg_match( "!(.*?)(\b(?i:$prots)($addr$urlChar*))(.*)!xu", $text, $matches ) ) { // var_dump( $matches ); if ( $matches[1] ) { $newResult[] = self::makeText( $matches[1] ); } $newResult[] = self::makeExternalLink( $matches[2] ); $text = $matches[4]; } if ( $text === $result['text'] ) { $key++; continue; } elseif ( $text ) { $newResult[] = self::makeText( $text ); } $newCount = count( $newResult ); if ( $newCount === 1 ) { $this->result[$key] = $newResult[0]; } elseif ( $newResult ) { array_splice( $this->result, $key, 1, $newResult ); $key += $newCount; continue; } $key++; } } /** * @param string $text * @return string[] */ private static function makeText( $text ) { return [ 'type' => self::TYPE_TEXT, 'text' => $text, ]; } /** * @param string $titleText * @param string $text * @return string[] */ private static function makeInternalLink( $titleText, $text ) { $title = Title::newFromText( $titleText ); if ( !$title ) { return self::makeText( '[[' . $titleText . ( $text ? "|$text" : '' ) . ']]' ); } return [ 'type' => self::TYPE_INTERNAL_LINK, 'url' => $title->getCanonicalURL(), 'text' => $text ?: $titleText, ]; } /** * @param string $url * @param string|null $text * @return array */ private static function makeExternalLink( $url, $text = null ) { return [ 'type' => self::TYPE_EXTERNAL_LINK, 'url' => Sanitizer::cleanUrl( $url ), 'text' => $text ?: $url, 'free' => !$text ]; } /** * @return array */ public function getResult(): array { return $this->result; } } PK ! ���\ \ specials/SpecialWhosOnline.phpnu �[��� <?php use MediaWiki\MediaWikiServices; /** * WhosOnline extension - creates a list of logged-in users & anons currently online * The list can be viewed at Special:WhosOnline * * @file * @ingroup Extensions * @author Maciej Brencz <macbre(at)-spam-wikia.com> - minor fixes and improvements * @author ChekMate Security Group - original code * @see http://www.chekmate.org/wiki/index.php/MW:_Whos_Online_Extension * @license GPL-2.0-or-later */ class SpecialWhosOnline extends IncludableSpecialPage { public function __construct() { parent::__construct( 'WhosOnline' ); } /** * Get the list of anonymous users being online * * @return int */ protected function getAnonsOnline() { $dbr = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_REPLICA ); $row = $dbr->selectRow( 'online', 'COUNT(*) AS cnt', 'userid = 0', __METHOD__, 'GROUP BY username' ); $guests = (int)$row->cnt; return $guests; } /** @inheritDoc */ public function execute( $para ) { global $wgWhosOnlineTimeout; $timeout = 3600; if ( is_numeric( $wgWhosOnlineTimeout ) ) { $timeout = $wgWhosOnlineTimeout; } $db = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_PRIMARY ); $old = wfTimestamp( TS_MW, time() - $timeout ); $db->delete( 'online', [ 'timestamp < "' . $old . '"' ], __METHOD__ ); $this->setHeaders(); $pager = new PagerWhosOnline(); $showNavigation = !$this->including(); if ( $para ) { $bits = preg_split( '/\s*,\s*/', trim( $para ) ); foreach ( $bits as $bit ) { if ( $bit == 'shownav' ) { $showNavigation = true; } if ( is_numeric( $bit ) ) { $pager->mLimit = $bit; } $m = []; if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) { $pager->mLimit = intval( $m[1] ); } } } $body = $pager->getBody(); // Checking for both to ensure that we don't show the useless navigation // stuff when $body is empty, i.e. no registered users are online if ( $showNavigation && $body ) { $this->getOutput()->addHTML( $pager->getNavigationBar() ); } if ( $body ) { $this->getOutput()->addHTML( '<ul>' . $body . '</ul>' ); } else { // Nothing to display, hmm? Well, no point in continuing further, then... // Just get us out of here. $this->getOutput()->addHTML( $this->msg( 'specialpage-empty' )->parse() ); } } } PK ! ~X4$0 0 WhosOnlineHooks.phpnu �[��� <?php /** * @file */ use MediaWiki\MediaWikiServices; class WhosOnlineHooks { /** * Update online data. * * @param OutputPage &$out * @param Skin &$skin * @throws \ConfigException * @return true|void */ public static function onBeforePageDisplay( OutputPage &$out, Skin &$skin ) { global $wgWhosOnlineTimeout; $services = MediaWikiServices::getInstance(); // don't write to the DB if the DB is read-only if ( $services->getReadOnlyMode()->isReadOnly() ) { return true; } $user = $out->getUser(); $userOptionsManager = $services->getUserOptionsManager(); $lastVisit = $userOptionsManager->getOption( $user, 'LastVisit' ); $currentTime = wfTimestamp( TS_UNIX ); if ( empty( $lastVisit ) || $currentTime - $lastVisit > $wgWhosOnlineTimeout ) { if ( !$user->isAnon() ) { $userOptionsManager->setOption( $user, 'LastVisit', $currentTime ); } // write to DB (use master) $dbw = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_PRIMARY ); $now = gmdate( 'YmdHis', time() ); // row to insert to table $row = [ 'userid' => $user->getId(), 'username' => $user->getName(), 'timestamp' => $now, 'wikiid' => $out->getConfig()->get( 'DBname' ) ]; $method = __METHOD__; $dbw->onTransactionCommitOrIdle( static function () use ( $dbw, $method, $row, $services ) { $dbw->upsert( 'online', $row, [ [ 'userid', 'username', 'wikiid' ] ], [ 'timestamp' => $row['timestamp'] ], $method ); // Per ApiQueryWhosOnline.php: // Not using $cache->makeKey() on $key intentionally to keep the key a // global one; makeKey() automagically adds the current DB name to it as // a prefix $key = 'whosonline:data'; $services->getMainWANObjectCache()->delete( $key ); } ); } } /** * Apply database schema changes when MediaWiki core updater script (update.php) is re-run * * @param DatabaseUpdater $updater */ public static function onLoadExtensionSchemaUpdates( $updater ) { $updater->addExtensionTable( 'online', __DIR__ . '/../sql/whosonline.sql' ); // wikiid field since WhosOnline version 1.8.0 if ( !$updater->getDB()->fieldExists( 'whosonline', 'wikiid' ) ) { $updater->addExtensionField( 'online', 'wikiid', __DIR__ . '/../sql/patch-add-wikiid-field.sql' ); } } } PK ! �Γi� � PagerWhosOnline.phpnu �[��� <?php /** * WhosOnline extension - creates a list of logged-in users & anons currently online * The list can be viewed at Special:WhosOnline * * @file * @ingroup Extensions * @author Maciej Brencz <macbre(at)-spam-wikia.com> - minor fixes and improvements * @author ChekMate Security Group - original code * @see http://www.chekmate.org/wiki/index.php/MW:_Whos_Online_Extension * @license GPL-2.0-or-later */ class PagerWhosOnline extends IndexPager { function __construct() { parent::__construct(); $this->mLimit = $this->mDefaultLimit; } /** @inheritDoc */ function getQueryInfo() { global $wgWhosOnlineShowAnons; return [ 'tables' => [ 'online' ], 'fields' => [ 'username' ], 'options' => [ 'ORDER BY' => 'timestamp DESC', 'GROUP BY' => 'username' ], 'conds' => $wgWhosOnlineShowAnons ? [] : [ 'userid != 0' ] ]; } /** * use classical LIMIT/OFFSET instead of sorting by table key * @inheritDoc */ function reallyDoQuery( $offset, $limit, $descending ) { $info = $this->getQueryInfo(); $tables = $info['tables']; $fields = $info['fields']; $conds = isset( $info['conds'] ) ? $info['conds'] : []; $options = isset( $info['options'] ) ? $info['options'] : []; $options['LIMIT'] = intval( $limit ); $options['OFFSET'] = intval( $offset ); return $this->mDb->select( $tables, $fields, $conds, __METHOD__, $options ); } /** @inheritDoc */ function getIndexField() { // dummy return 'username'; } /** @inheritDoc */ function formatRow( $row ) { global $wgWhosOnlineShowRealName; $userPageLink = Title::makeTitle( NS_USER, $row->username )->getFullURL(); $name = $row->username; if ( $wgWhosOnlineShowRealName ) { $user = User::newFromName( $name ); if ( $user ) { $realName = $user->getRealName(); if ( $realName !== '' ) { $name = $realName; } } } return '<li><a href="' . htmlspecialchars( $userPageLink, ENT_QUOTES ) . '">' . htmlspecialchars( $name, ENT_QUOTES ) . '</a></li>'; } /** * @return int */ function countUsersOnline() { $row = $this->mDb->selectRow( 'online', 'COUNT(*) AS cnt', 'userid != 0', __METHOD__, 'GROUP BY username' ); $users = (int)$row->cnt; return $users; } /** @inheritDoc */ function getNavigationBar() { return $this->buildPrevNextNavigation( SpecialPage::getTitleFor( 'WhosOnline' ), $this->mOffset, $this->mLimit, [], // show next link $this->countUsersOnline() < ( $this->mLimit + (int)$this->mOffset ) ); } } PK ! ��8T� � api/ApiQueryWhosOnline.phpnu �[��� <?php /** * API for WhosOnline extension * * @file * @ingroup API * @author Maciej Brencz <macbre@wikia-inc.com> * @author Maciej Błaszkowski <marooned@wikia-inc.com> - optimization */ class ApiQueryWhosOnline extends ApiQueryBase { /** @var WANObjectCache Injected via services magic and set up in the extension.json file */ private $cache; /** * @param ApiQuery $query * @param string $action * @param WANObjectCache $wanCache */ public function __construct( ApiQuery $query, $action, WANObjectCache $wanCache ) { parent::__construct( $query, $action ); $this->cache = $wanCache; } /** * Main function */ public function execute() { $config = $this->getConfig(); // Not using $cache->makeKey() on $key intentionally to keep the key a // global one; makeKey() automagically adds the current DB name to it as // a prefix $key = 'whosonline:data'; $memcData = $this->cache->get( $key ); if ( !is_array( $memcData ) ) { // database instance $dbr = $this->getDB(); // build query $this->addTables( [ 'online' ] ); $this->addFields( [ 'userid', 'username', 'timestamp', 'wikiid' ] ); $this->addOption( 'ORDER BY', 'timestamp DESC' ); $maxAge = wfTimestamp( TS_UNIX ) - $config->get( 'WhosOnlineTimeout' ); $this->addWhere( "timestamp >= '$maxAge'" ); if ( !$config->get( 'WhosOnlineShowAnons' ) ) { $this->addWhere( 'userid != 0' ); } // build results $data = []; $res = $this->select( __METHOD__ ); $i = $countUsers = $countAnons = 0; foreach ( $res as $row ) { // count both anons and logged-in if ( $row->userid != 0 ) { // add only logged-in $data[$i] = [ 'userid' => $row->userid, 'user' => $row->username, 'time' => $row->timestamp, 'wikiid' => $row->wikiid ]; $countUsers++; } else { $countAnons++; } $i++; } $memcData = [ 'data' => $data, 'countUsers' => $countUsers, 'countAnons' => $countAnons ]; $this->cache->set( $key, $memcData, $config->get( 'WhosOnlineTimeout' ) ); } else { $data = $memcData['data']; $countUsers = (int)$memcData['countUsers']; $countAnons = (int)$memcData['countAnons']; } $params = $this->extractRequestParams(); $limit = is_numeric( $params['limit'] ) ? $params['limit'] : 50; $offset = is_numeric( $params['offset'] ) ? $params['offset'] : 0; if ( !$config->get( 'WhosOnlinePerWiki' ) ) { // Look on every wiki and display only one record for one user (the newest) $tmpUsers = []; for ( $i = count( $data ) - 1; $i >= 0; $i-- ) { if ( empty( $tmpUsers[$data[$i]['user']] ) ) { $tmpUsers[$data[$i]['user']] = 1; } else { $data[$i]['userid'] == 0 ? $countAnons-- : $countUsers--; unset( $data[$i] ); } } } else { // Look only on current wiki for ( $i = count( $data ) - 1; $i >= 0; $i-- ) { if ( $data[$i]['wikiid'] != $config->get( 'DBname' ) ) { $data[$i]['userid'] == 0 ? $countAnons-- : $countUsers--; unset( $data[$i] ); } } } // limit results $data = array_slice( $data, $offset, $limit ); $result = $this->getResult(); ApiResult::setIndexedTagName( $data, 'online' ); $result->addValue( [ 'query', $this->getModuleName() ], null, $data ); $result->addValue( [ 'query', 'users' ], null, intval( $countUsers ) ); $result->addValue( [ 'query', 'anons' ], null, intval( $countAnons ) ); } /** @inheritDoc */ public function getAllowedParams() { return [ 'limit' => [ ApiBase::PARAM_TYPE => 'integer' ], 'offset' => [ ApiBase::PARAM_TYPE => 'integer' ] ]; } /** @inheritDoc */ protected function getExamplesMessages() { return [ 'action=query&list=whosonline' => 'apihelp-query+whosonline-example-1', 'action=query&list=whosonline&limit=5' => 'apihelp-query+whosonline-example-2', 'action=query&list=whosonline&limit=5&offset=15' => 'apihelp-query+whosonline-example-3', ]; } } PK ! ��0 �0 specials/SpecialPatroller.phpnu �[��� <?php /** * Patroller * Patroller MediaWiki hooks * * @author: Rob Church <robchur@gmail.com>, Kris Blair (Developaws) * @copyright: 2006-2008 Rob Church, 2015-2017 Kris Blair * @license: GPL General Public Licence 2.0 * @package: Patroller * @link: https://mediawiki.org/wiki/Extension:Patroller */ use MediaWiki\MediaWikiServices; use MediaWiki\Revision\SlotRecord; class SpecialPatroller extends SpecialPage { /** * Constructor * * @return void */ public function __construct() { parent::__construct( 'Patrol', 'patroller' ); } public function doesWrites() { return true; } /** * Execution * * @param array $par Parameters passed to the page * @return void */ public function execute( $par ) { $out = $this->getOutput(); $user = $this->getUser(); $request = $this->getRequest(); $this->setHeaders(); // Check permissions if ( !$user->isAllowed( 'patroller' ) ) { throw new PermissionsError( 'patroller' ); } // Keep out blocked users if ( $user->getBlock() ) { throw new UserBlockedError( $user->getBlock() ); } // Prune old assignments if needed if ( mt_rand( 0, 499 ) == 0 ) { $this->pruneAssignments(); } // See if something needs to be done if ( $request->wasPosted() && $user->matchEditToken( $request->getText( 'wpToken' ) ) ) { $rcid = $request->getIntOrNull( 'wpRcId' ); if ( $rcid ) { if ( $request->getCheck( 'wpPatrolEndorse' ) ) { // Mark the change patrolled if ( !$user->getBlock( false ) ) { $rc = RecentChange::newFromId( $rcid ); if ( $rc !== null ) { $rc->doMarkPatrolled( $user ); } $out->setSubtitle( wfMessage( 'patrol-endorsed-ok' )->escaped() ); } else { $out->setSubtitle( wfMessage( 'patrol-endorsed-failed' )->escaped() ); } } elseif ( $request->getCheck( 'wpPatrolRevert' ) ) { // Revert the change $edit = $this->loadChange( $rcid ); $msg = $this->revert( $edit, $this->revertReason( $request ) ) ? 'ok' : 'failed'; $out->setSubtitle( wfMessage( 'patrol-reverted-' . $msg )->escaped() ); } elseif ( $request->getCheck( 'wpPatrolSkip' ) ) { // Do nothing $out->setSubtitle( wfMessage( 'patrol-skipped-ok' )->escaped() ); } } } // If a token was passed, but the check box value was not, then the user // wants to pause or stop patrolling if ( $request->getCheck( 'wpToken' ) && !$request->getCheck( 'wpAnother' ) ) { $skin = $this->getSkin(); $self = SpecialPage::getTitleFor( 'Patrol' ); $link = Linker::link( $self, wfMessage( 'patrol-resume' )->escaped(), [], [], [ 'known' ] ); $out->addHTML( wfMessage( 'patrol-stopped', $link )->escaped() ); return; } // Pop an edit off recentchanges $haveEdit = false; while ( !$haveEdit ) { $edit = $this->fetchChange( $user ); if ( $edit ) { // Attempt to assign it if ( $this->assignChange( $edit ) ) { $haveEdit = true; $this->showDiffDetails( $edit ); $out->addHTML( '<br><hr>' ); $this->showDiff( $edit ); $out->addHTML( '<br><hr>' ); $this->showControls( $edit ); } } else { // Can't find a suitable edit // Don't keep going, there's nothing to find $haveEdit = true; $out->addWikiTextAsInterface( wfMessage( 'patrol-nonefound' )->text() ); } } } /** * Produce a stub recent changes listing for a single diff. * * @param RecentChange &$edit Diff. to show the listing for */ private function showDiffDetails( &$edit ) { $out = $this->getOutput(); $edit->counter = 1; $editAttribs = $edit->getAttributes(); $editAttribs['rc_patrolled'] = 1; $edit->setAttribs( $editAttribs ); $list = ChangesList::newFromContext( RequestContext::GetMain() ); $out->addHTML( $list->beginRecentChangesList() . $list->recentChangesLine( $edit ) . $list->endRecentChangesList() ); } /** * Output a trimmed down diff view corresponding to a particular change * * @param RecentChange &$edit Recent change to produce a diff for */ private function showDiff( &$edit ) { $diff = new DifferenceEngine( $edit->getTitle(), $edit->getAttribute( 'rc_last_oldid' ), $edit->getAttribute( 'rc_this_oldid' ) ); $diff->showDiff( '', '' ); } /** * Output a bunch of controls to let the user endorse, revert and skip changes * * @param RecentChange &$edit RecentChange being dealt with */ private function showControls( &$edit ) { $user = $this->getUser(); $out = $this->getOutput(); $self = SpecialPage::getTitleFor( 'Patrol' ); $form = Html::openElement( 'form', [ 'method' => 'post', 'action' => $self->getLocalUrl() ] ); $form .= Html::openElement( 'table' ); $form .= Html::openElement( 'tr' ); $form .= Html::openElement( 'td', [ 'align' => 'right' ] ); $form .= Html::submitButton( wfMessage( 'patrol-endorse' )->escaped(), [ 'name' => 'wpPatrolEndorse' ] ); $form .= Html::closeElement( 'td' ); $form .= Html::openElement( 'td' ) . Html::closeElement( 'td' ); $form .= Html::closeElement( 'tr' ); $form .= Html::openElement( 'tr' ); $form .= Html::openElement( 'td', [ 'align' => 'right' ] ); $form .= Html::submitButton( wfMessage( 'patrol-revert' )->escaped(), [ 'name' => 'wpPatrolRevert' ] ); $form .= Html::closeElement( 'td' ); $form .= Html::openElement( 'td' ); $form .= Html::label( wfMessage( 'patrol-revert-reason' )->escaped(), 'reason' ) . ' '; $form .= $this->revertReasonsDropdown() . ' / ' . Html::input( 'wpPatrolRevertReason' ); $form .= Html::closeElement( 'td' ); $form .= Html::closeElement( 'tr' ); $form .= Html::openElement( 'tr' ); $form .= Html::openElement( 'td', [ 'align' => 'right' ] ); $form .= Html::submitButton( wfMessage( 'patrol-skip' )->escaped(), [ 'name' => 'wpPatrolSkip' ] ); $form .= Html::closeElement( 'td' ); $form .= Html::closeElement( 'tr' ); $form .= Html::openElement( 'tr' ); $form .= Html::openElement( 'td' ); $form .= Html::check( 'wpAnother', true ); $form .= Html::closeElement( 'td' ); $form .= Html::openElement( 'td' ); $form .= wfMessage( 'patrol-another' )->escaped(); $form .= Html::closeElement( 'td' ); $form .= Html::closeElement( 'tr' ); $form .= Html::closeElement( 'table' ); $form .= Html::Hidden( 'wpRcId', $edit->getAttribute( 'rc_id' ) ); $form .= Html::Hidden( 'wpToken', $user->getEditToken() ); $form .= Html::closeElement( 'form' ); $out->addHTML( $form ); } /** * Fetch a recent change which * - the user doing the patrolling didn't cause * - wasn't due to a bot * - hasn't been patrolled * - isn't assigned to a user * * @param User &$user User to suppress edits for * @return false|RecentChange */ private function fetchChange( &$user ) { $dbr = wfGetDB( DB_REPLICA ); $aid = $user->getActorId(); $res = $dbr->select( [ 'page', 'recentchanges', 'patrollers' ], '*', [ 'ptr_timestamp IS NULL', 'rc_namespace = page_namespace', 'rc_title = page_title', 'rc_this_oldid = page_latest', 'rc_actor != ' . $aid, 'rc_bot' => '0', 'rc_patrolled' => '0', 'rc_type' => '0' ], __METHOD__, [ 'LIMIT' => 1 ], [ 'patrollers' => [ 'LEFT JOIN', [ 'rc_id = ptr_change' ] ] ] ); if ( $res->numRows() > 0 ) { $row = $res->fetchObject(); return RecentChange::newFromRow( $row, $row->rc_last_oldid ); } return false; } /** * Fetch a particular recent change given the rc_id value * * @param int $rcid rc_id value of the row to fetch * @return bool|RecentChange */ private function loadChange( $rcid ) { $dbr = wfGetDB( DB_REPLICA ); $row = $dbr->selectRow( 'recentchanges', '*', [ 'rc_id' => $rcid ], 'Patroller::loadChange' ); if ( $row ) { return RecentChange::newFromRow( $row ); } return false; } /** * Assign the patrolling of a particular change, so other users don't pull * it up, duplicating effort * * @param RecentChange &$edit RecentChange item to assign * @return bool If rows were changed */ private function assignChange( &$edit ) { $dbw = wfGetDB( DB_PRIMARY ); $res = $dbw->insert( 'patrollers', [ 'ptr_change' => $edit->getAttribute( 'rc_id' ), 'ptr_timestamp' => $dbw->timestamp() ], __METHOD__, 'IGNORE' ); return (bool)$dbw->affectedRows(); } /** * Remove the assignment for a particular change, to let another user handle it * * @param int $rcid rc_id value * * @todo Use it or lose it */ private function unassignChange( $rcid ) { $dbw = wfGetDB( DB_PRIMARY ); $dbw->delete( 'patrollers', [ 'ptr_change' => $rcid ], __METHOD__ ); } /** * Prune old assignments from the table so edits aren't * hidden forever because a user wandered off, and to * keep the table size down as regards old assignments */ private function pruneAssignments() { $dbw = wfGetDB( DB_PRIMARY ); $dbw->delete( 'patrollers', [ 'ptr_timestamp < ' . $dbw->timestamp( time() - 120 ) ], __METHOD__ ); } /** * Revert a change, setting the page back to the "old" version * * @param RecentChange &$edit RecentChange to revert * @param string $comment Comment to use when reverting * @return bool Change was reverted */ private function revert( &$edit, $comment = '' ) { $user = $this->getUser(); if ( !$user->getBlock( false ) ) { // Check block against master $dbw = wfGetDB( DB_PRIMARY ); $title = $edit->getTitle(); // Prepare the comment $comment = wfMessage( 'patrol-reverting', $comment )->inContentLanguage()->text(); // Be certain we're not overwriting a more recent change // If we would, ignore it, and silently consider this change patrolled $latest = (int)$dbw->selectField( 'page', 'page_latest', [ 'page_id' => $title->getArticleID() ], __METHOD__ ); if ( $edit->getAttribute( 'rc_this_oldid' ) == $latest ) { // Find the old revision $oldRevisionRecord = MediaWikiServices::getInstance() ->getRevisionLookup() ->getRevisionById( $edit->getAttribute( 'rc_last_oldid' ) ); // Revert the edit; keep the reversion itself out of recent changes wfDebugLog( 'patroller', 'Reverting "' . $title->getPrefixedText() . '" to r' . $oldRevisionRecord->getId() ); if ( method_exists( MediaWikiServices::class, 'getWikiPageFactory' ) ) { // MW 1.36+ $page = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title ); } else { $page = WikiPage::factory( $title ); } if ( method_exists( $page, 'doUserEditContent' ) ) { // MW 1.36+ $page->doUserEditContent( $oldRevisionRecord->getContent( SlotRecord::MAIN ), $user, $comment, EDIT_UPDATE & EDIT_MINOR & EDIT_SUPPRESS_RC ); } else { $page->doEditContent( $oldRevisionRecord->getContent( SlotRecord::MAIN ), $comment, EDIT_UPDATE & EDIT_MINOR & EDIT_SUPPRESS_RC ); } } // Mark the edit patrolled so it doesn't bother us again if ( $edit !== null ) { $edit->doMarkPatrolled( $user ); } return true; } return false; } /** * Make a nice little drop-down box containing all the pre-defined revert * reasons for simplified selection * * @return string Reasons */ private function revertReasonsDropdown() { $msg = wfMessage( 'patrol-reasons' )->inContentLanguage()->text(); if ( $msg == '-' || $msg == '<patrol-reasons>' ) { return ''; } $reasons = []; $lines = explode( "\n", $msg ); foreach ( $lines as $line ) { if ( substr( $line, 0, 1 ) == '*' ) { $reasons[] = trim( $line, '* ' ); } } if ( count( $reasons ) > 0 ) { $box = Html::openElement( 'select', [ 'name' => 'wpPatrolRevertReasonCommon' ] ); foreach ( $reasons as $reason ) { $box .= Html::element( 'option', [ 'value' => $reason ], $reason ); } $box .= Html::closeElement( 'select' ); return $box; } return ''; } /** * Determine which of the two "revert reason" form fields to use; * the pre-defined reasons, or the nice custom text box * * @param WebRequest &$request WebRequest object to test * @return string Revert reason */ private function revertReason( &$request ) { $custom = $request->getText( 'wpPatrolRevertReason' ); return trim( $custom ) != '' ? $custom : $request->getText( 'wpPatrolRevertReasonCommon' ); } } PK ! �n �M M PatrollerHooks.phpnu �[��� <?php /** * Patroller * Patroller MediaWiki main hooks * * @author: Rob Church <robchur@gmail.com>, Kris Blair (Developaws) * @copyright: 2006-2008 Rob Church, 2015-2017 Kris Blair * @license: GPL General Public Licence 2.0 * @package: Patroller * @link: https://mediawiki.org/wiki/Extension:Patroller */ class PatrollerHooks { /** * Setup the database tables * * @param DatabaseUpdater $updater The updater */ public static function onLoadExtensionSchemaUpdates( $updater ) { $updater->addExtensionTable( 'patrollers', __DIR__ . '/../sql/add-patrollers.sql' ); } } PK ! ��O�� � TimelessVariablesModule.phpnu �Iw�� <?php namespace MediaWiki\Skin\Timeless; use MediaWiki\ResourceLoader\Context; use MediaWiki\ResourceLoader\SkinModule; /** * ResourceLoader module to set some LESS variables for the skin */ class TimelessVariablesModule extends SkinModule { /** * Add our LESS variables * * @param Context $context * @return array LESS variables */ protected function getLessVars( Context $context ) { $vars = parent::getLessVars( $context ); $config = $this->getConfig(); // Backdrop image $backdrop = $config->get( 'TimelessBackdropImage' ); if ( $backdrop === 'cat.svg' ) { // expand default $backdrop = 'images/cat.svg'; } return array_merge( $vars, [ 'backdrop-image' => "url($backdrop)", // 'logo-image' => '' // 'wordmark-image' => '' // +width cutoffs ... ] ); } /** * Register the config var with the caching stuff so it properly updates the cache * * @param Context $context * @return array */ public function getDefinitionSummary( Context $context ) { $summary = parent::getDefinitionSummary( $context ); $summary[] = [ 'TimelessBackdropImage' => $this->getConfig()->get( 'TimelessBackdropImage' ) ]; return $summary; } } PK ! ��*�w� w� TimelessTemplate.phpnu �Iw�� <?php /** * BaseTemplate class for the Timeless skin * * @ingroup Skins */ namespace MediaWiki\Skin\Timeless; use BaseTemplate; use File; use MediaWiki\Html\Html; use MediaWiki\Linker\Linker; use MediaWiki\MediaWikiServices; use MediaWiki\Parser\Sanitizer; use MediaWiki\ResourceLoader\SkinModule; use MediaWiki\SpecialPage\SpecialPage; class TimelessTemplate extends BaseTemplate { /** @var array */ protected $pileOfTools; /** @var (array|false)[] */ protected $sidebar; /** @var array|null */ protected $otherProjects; /** @var array|null */ protected $collectionPortlet; /** @var array[] */ protected $languages; /** @var string */ protected $afterLangPortlet; /** * Outputs the entire contents of the page */ public function execute() { $this->sidebar = $this->data['sidebar']; $this->languages = $this->sidebar['LANGUAGES']; // WikiBase sidebar thing if ( isset( $this->sidebar['wikibase-otherprojects'] ) ) { $this->otherProjects = $this->sidebar['wikibase-otherprojects']; unset( $this->sidebar['wikibase-otherprojects'] ); } // Collection sidebar thing if ( isset( $this->sidebar['coll-print_export'] ) ) { $this->collectionPortlet = $this->sidebar['coll-print_export']; unset( $this->sidebar['coll-print_export'] ); } $this->pileOfTools = $this->getPageTools(); $userLinks = $this->getUserLinks(); $html = Html::openElement( 'div', [ 'id' => 'mw-wrapper', 'class' => $userLinks['class'] ] ); $html .= Html::rawElement( 'div', [ 'id' => 'mw-header-container', 'class' => 'ts-container' ], Html::rawElement( 'div', [ 'id' => 'mw-header', 'class' => 'ts-inner' ], $userLinks['html'] . $this->getLogo( 'p-logo-text', 'text' ) . $this->getSearch() ) . $this->getClear() ); $html .= $this->getHeaderHack(); // For mobile $html .= Html::element( 'div', [ 'id' => 'menus-cover' ] ); $html .= Html::rawElement( 'div', [ 'id' => 'mw-content-container', 'class' => 'ts-container' ], Html::rawElement( 'div', [ 'id' => 'mw-content-block', 'class' => 'ts-inner' ], Html::rawElement( 'div', [ 'id' => 'mw-content-wrapper' ], $this->getContentBlock() . $this->getAfterContent() ) . Html::rawElement( 'div', [ 'id' => 'mw-site-navigation' ], $this->getLogo( 'p-logo', 'image' ) . $this->getMainNavigation() . $this->getSidebarChunk( 'site-tools', 'timeless-sitetools', $this->getPortlet( 'tb', $this->pileOfTools['general'], 'timeless-sitetools' ) ) ) . Html::rawElement( 'div', [ 'id' => 'mw-related-navigation' ], $this->getPageToolSidebar() . $this->getInterwikiLinks() . $this->getCategories() ) . $this->getClear() ) ); $html .= Html::rawElement( 'div', [ 'id' => 'mw-footer-container', 'class' => 'mw-footer-container ts-container' ], $this->getFooterBlock( [ 'class' => [ 'mw-footer', 'ts-inner' ], 'id' => 'mw-footer' ] ) ); $html .= Html::closeElement( 'div' ); // The unholy echo echo $html; } /** * Generate the page content block * Broken out here due to the excessive indenting, or stuff. * * @return string html */ protected function getContentBlock() { $templateData = $this->getSkin()->getTemplateData(); $html = Html::rawElement( 'div', [ 'id' => 'content', 'class' => 'mw-body', 'role' => 'main' ], $this->getSiteNotices() . $this->getIndicators() . $templateData[ 'html-title-heading' ] . Html::rawElement( 'div', [ 'id' => 'bodyContentOuter' ], Html::rawElement( 'div', [ 'id' => 'siteSub' ], $this->getMsg( 'tagline' )->parse() ) . Html::rawElement( 'div', [ 'id' => 'mw-page-header-links' ], $this->getPortlet( 'namespaces', $this->pileOfTools['namespaces'], 'timeless-namespaces', [ 'extra-classes' => 'tools-inline' ] ) . $this->getPortlet( 'more', $this->pileOfTools['more'], 'timeless-more', [ 'extra-classes' => 'tools-inline' ] ) . $this->getVariants() . $this->getPortlet( 'views', $this->pileOfTools['page-primary'], 'timeless-pagetools', [ 'extra-classes' => 'tools-inline' ] ) ) . $this->getClear() . Html::rawElement( 'div', [ 'id' => 'bodyContent' ], $this->getContentSub() . $this->get( 'bodytext' ) . $this->getClear() ) ) ); return Html::rawElement( 'div', [ 'id' => 'mw-content' ], $html ); } /** * Generates a block of navigation links with a header * This is some random fork of some random fork of what was supposed to be in core. Latest * version copied out of MonoBook, probably. (20190719) * * @param string $name * @param array|string $content array of links for use with makeListItem, or a block of text * Expected array format: * [ * $name => [ * 'links' => [ '0' => * [ * 'href' => ..., * 'single-id' => ..., * 'text' => ... * ] * ], * 'id' => ..., * 'active' => ... * ], * ... * ] * @param null|string|array|bool $msg * @param array $setOptions miscellaneous overrides, see below * * @return string html * @suppress PhanTypeMismatchArgumentNullable */ protected function getPortlet( $name, $content, $msg = null, $setOptions = [] ) { $skin = $this->getSkin(); // random stuff to override with any provided options $options = array_merge( [ 'role' => 'navigation', // extra classes/ids 'id' => 'p-' . $name, 'class' => [ 'mw-portlet', 'emptyPortlet' => !$content ], 'extra-classes' => '', 'body-id' => null, 'body-class' => 'mw-portlet-body', 'body-extra-classes' => '', // wrapper for individual list items 'text-wrapper' => [ 'tag' => 'span' ], // option to stick arbitrary stuff at the beginning of the ul 'list-prepend' => '' ], $setOptions ); // Handle the different $msg possibilities if ( $msg === null ) { $msg = $name; $msgParams = []; } elseif ( is_array( $msg ) ) { $msgString = array_shift( $msg ); $msgParams = $msg; $msg = $msgString; } else { $msgParams = []; } $msgObj = $this->getMsg( $msg, $msgParams ); if ( $msgObj->exists() ) { $msgString = $msgObj->parse(); } else { $msgString = htmlspecialchars( $msg ); } $labelId = Sanitizer::escapeIdForAttribute( "p-$name-label" ); if ( is_array( $content ) ) { $contentText = Html::openElement( 'ul', [ 'lang' => $this->get( 'userlang' ), 'dir' => $this->get( 'dir' ) ] ); $contentText .= $options['list-prepend']; foreach ( $content as $key => $item ) { if ( is_array( $options['text-wrapper'] ) ) { $contentText .= $skin->makeListItem( $key, $item, [ 'text-wrapper' => $options['text-wrapper'] ] ); } else { $contentText .= $skin->makeListItem( $key, $item ); } } $contentText .= Html::closeElement( 'ul' ); } else { $contentText = $content; } $divOptions = [ 'role' => $options['role'], 'class' => $this->mergeClasses( $options['class'], $options['extra-classes'] ), 'id' => Sanitizer::escapeIdForAttribute( $options['id'] ), 'title' => Linker::titleAttrib( $options['id'] ), 'aria-labelledby' => $labelId ]; $labelOptions = [ 'id' => $labelId, 'lang' => $this->get( 'userlang' ), 'dir' => $this->get( 'dir' ) ]; $bodyDivOptions = [ 'class' => $this->mergeClasses( $options['body-class'], $options['body-extra-classes'] ) ]; if ( is_string( $options['body-id'] ) ) { $bodyDivOptions['id'] = $options['body-id']; } $afterPortlet = ''; $content = $this->getSkin()->getAfterPortlet( $name ); if ( $content !== '' ) { $afterPortlet = Html::rawElement( 'div', [ 'class' => [ 'after-portlet', 'after-portlet-' . $name ] ], $content ); } if ( $name === 'lang' ) { $this->afterLangPortlet = $afterPortlet; } $html = Html::rawElement( 'div', $divOptions, Html::rawElement( 'h3', $labelOptions, $msgString ) . Html::rawElement( 'div', $bodyDivOptions, $contentText . $afterPortlet ) ); return $html; } /** * Helper function for getPortlet * * Merge all provided css classes into a single array * Account for possible different input methods matching what Html::element stuff takes * * @param string|array $class base portlet/body class * @param string|array $extraClasses any extra classes to also include * * @return array all classes to apply */ protected function mergeClasses( $class, $extraClasses ) { if ( !is_array( $class ) ) { $class = [ $class ]; } if ( !is_array( $extraClasses ) ) { $extraClasses = [ $extraClasses ]; } return array_merge( $class, $extraClasses ); } /** * Better renderer for getFooterIcons and getFooterLinks, based on Vector's HTML output * (as of 2016) * * @param array $setOptions Miscellaneous other options * * 'id' for footer id * * 'class' for footer class * * 'order' to determine whether icons or links appear first: 'iconsfirst' or links, though in * practice we currently only check if it is or isn't 'iconsfirst' * * 'link-prefix' to set the prefix for all link and block ids; most skins use 'f' or 'footer', * as in id='f-whatever' vs id='footer-whatever' * * 'link-style' to pass to getFooterLinks: "flat" to disable categorisation of links in a * nested array * * @return string html */ protected function getFooterBlock( $setOptions = [] ) { // Set options and fill in defaults $options = $setOptions + [ 'id' => 'footer', 'class' => 'mw-footer', 'order' => 'iconsfirst', 'link-prefix' => 'footer', 'link-style' => null ]; // phpcs:ignore Generic.Files.LineLength.TooLong '@phan-var array{id:string,class:string,order:string,link-prefix:string,link-style:?string} $options'; $validFooterIcons = $this->get( 'footericons' ); $validFooterLinks = $this->getFooterLinks( $options['link-style'] ); $html = ''; $html .= Html::openElement( 'div', [ 'id' => $options['id'], 'class' => $options['class'], 'role' => 'contentinfo', 'lang' => $this->get( 'userlang' ), 'dir' => $this->get( 'dir' ) ] ); $iconsHTML = ''; if ( count( $validFooterIcons ) > 0 ) { $iconsHTML .= Html::openElement( 'ul', [ 'id' => "{$options['link-prefix']}-icons" ] ); foreach ( $validFooterIcons as $blockName => $footerIcons ) { $iconsHTML .= Html::openElement( 'li', [ 'id' => Sanitizer::escapeIdForAttribute( "{$options['link-prefix']}-{$blockName}ico" ), 'class' => 'footer-icons' ] ); foreach ( $footerIcons as $icon ) { $iconsHTML .= $this->getSkin()->makeFooterIcon( $icon ); } $iconsHTML .= Html::closeElement( 'li' ); } $iconsHTML .= Html::closeElement( 'ul' ); } $linksHTML = ''; if ( count( $validFooterLinks ) > 0 ) { if ( $options['link-style'] === 'flat' ) { $linksHTML .= Html::openElement( 'ul', [ 'id' => "{$options['link-prefix']}-list", 'class' => 'footer-places' ] ); foreach ( $validFooterLinks as $link ) { $linksHTML .= Html::rawElement( 'li', [ 'id' => Sanitizer::escapeIdForAttribute( $link ) ], $this->get( $link ) ); } $linksHTML .= Html::closeElement( 'ul' ); } else { $linksHTML .= Html::openElement( 'div', [ 'id' => "{$options['link-prefix']}-list" ] ); foreach ( $validFooterLinks as $category => $links ) { $linksHTML .= Html::openElement( 'ul', [ 'id' => Sanitizer::escapeIdForAttribute( "{$options['link-prefix']}-{$category}" ) ] ); foreach ( $links as $link ) { $linksHTML .= Html::rawElement( 'li', [ 'id' => Sanitizer::escapeIdForAttribute( "{$options['link-prefix']}-{$category}-{$link}" ) ], $this->get( $link ) ); } $linksHTML .= Html::closeElement( 'ul' ); } $linksHTML .= Html::closeElement( 'div' ); } } if ( $options['order'] === 'iconsfirst' ) { $html .= $iconsHTML . $linksHTML; } else { $html .= $linksHTML . $iconsHTML; } $html .= $this->getClear() . Html::closeElement( 'div' ); return $html; } /** * Sidebar chunk containing one or more portlets * * @param string $id * @param string $headerMessage * @param string $content * @param array $classes * * @return string html */ protected function getSidebarChunk( $id, $headerMessage, $content, $classes = [] ) { $html = ''; $html .= Html::rawElement( 'div', [ 'id' => Sanitizer::escapeIdForAttribute( $id ), 'class' => array_merge( [ 'sidebar-chunk' ], $classes ) ], Html::rawElement( 'h2', [], Html::element( 'span', [], $this->getMsg( $headerMessage )->text() ) ) . Html::rawElement( 'div', [ 'class' => 'sidebar-inner' ], $content ) ); return $html; } /** * The logo and (optionally) site title * * @param string $id * @param string $part whether it's only image, only text, or both * * @return string html */ protected function getLogo( $id = 'p-logo', $part = 'both' ) { $html = ''; $config = $this->getSkin()->getContext()->getConfig(); $html .= Html::openElement( 'div', [ 'id' => Sanitizer::escapeIdForAttribute( $id ), 'class' => 'mw-portlet', 'role' => 'banner' ] ); $logos = SkinModule::getAvailableLogos( $config, $this->getSkin()->getLanguage()->getCode() ); if ( $part !== 'image' ) { $wordmarkImage = $this->getLogoImage( $config->get( 'TimelessWordmark' ), true ); if ( !$wordmarkImage && isset( $logos['wordmark'] ) ) { $wordmarkData = $logos['wordmark']; $wordmarkImage = Html::element( 'img', [ 'src' => $wordmarkData['src'], 'height' => $wordmarkData['height'] ?? null, 'width' => $wordmarkData['width'] ?? null, ] ); } $titleClass = ''; $siteTitle = ''; if ( !$wordmarkImage ) { $langConv = MediaWikiServices::getInstance()->getLanguageConverterFactory() ->getLanguageConverter( $this->getSkin()->getLanguage() ); if ( $langConv->hasVariants() ) { $siteTitle = $langConv->convert( $this->getMsg( 'timeless-sitetitle' )->escaped() ); } else { $siteTitle = $this->getMsg( 'timeless-sitetitle' )->escaped(); } // width is 11em; 13 characters will probably fit? if ( mb_strlen( $siteTitle ) > 13 ) { $titleClass = 'long'; } } else { $titleClass = 'wordmark'; } $html .= Html::rawElement( 'a', [ 'id' => 'p-banner', 'class' => [ 'mw-wiki-title', $titleClass ], 'href' => $this->data['nav_urls']['mainpage']['href'] ], $wordmarkImage ?: $siteTitle ); } if ( $part !== 'text' ) { $logoImage = $this->getLogoImage( $config->get( 'TimelessLogo' ) ); if ( $logoImage === false ) { $logoSrc = $logos['svg'] ?? $logos['icon'] ?? ''; if ( $logoSrc !== '' ) { $logoImage = Html::element( 'img', [ 'src' => $logoSrc, ] ); } } $html .= Html::rawElement( 'a', array_merge( [ 'class' => [ 'mw-wiki-logo', !$logoImage ? 'fallback' : 'timeless-logo' ], 'href' => $this->data['nav_urls']['mainpage']['href'] ], Linker::tooltipAndAccesskeyAttribs( 'p-logo' ) ), $logoImage ?: '' ); } $html .= Html::closeElement( 'div' ); return $html; } /** * The search box at the top * * @return string html */ protected function getSearch() { $skin = $this->getSkin(); $html = Html::openElement( 'div', [ 'class' => 'mw-portlet', 'id' => 'p-search' ] ); $html .= Html::rawElement( 'h3', [ 'lang' => $this->get( 'userlang' ), 'dir' => $this->get( 'dir' ) ], Html::rawElement( 'label', [ 'for' => 'searchInput' ], $this->getMsg( 'search' )->escaped() ) ); $html .= Html::rawElement( 'form', [ 'action' => $this->get( 'wgScript' ), 'id' => 'searchform' ], Html::rawElement( 'div', [ 'id' => 'simpleSearch' ], Html::rawElement( 'div', [ 'id' => 'searchInput-container' ], $skin->makeSearchInput( [ 'id' => 'searchInput' ] ) ) . Html::hidden( 'title', $this->get( 'searchtitle' ) ) . $skin->makeSearchButton( 'fulltext', [ 'id' => 'mw-searchButton', 'class' => 'searchButton mw-fallbackSearchButton' ] ) . $skin->makeSearchButton( 'go', [ 'id' => 'searchButton', 'class' => 'searchButton' ] ) ) ); return $html . Html::closeElement( 'div' ); } /** * Left sidebar navigation, usually * * @return string html */ protected function getMainNavigation() { $html = ''; // Already hardcoded into header $this->sidebar['SEARCH'] = false; // Parsed as part of pageTools $this->sidebar['TOOLBOX'] = false; // Forcibly removed to separate chunk $this->sidebar['LANGUAGES'] = false; foreach ( $this->sidebar as $name => $content ) { if ( $content === false ) { continue; } // Numeric strings gets an integer when set as key, cast back - T73639 $name = (string)$name; $html .= $this->getPortlet( $name, $content ); } return $this->getSidebarChunk( 'site-navigation', 'navigation', $html ); } /** * The colour bars * Split this out so we don't have to look at it/can easily kill it later * * @return string html */ protected function getHeaderHack() { $html = ''; // These are almost exactly the same and this is stupid. $html .= Html::rawElement( 'div', [ 'id' => 'mw-header-hack', 'class' => 'color-bar' ], Html::rawElement( 'div', [ 'class' => 'color-middle-container' ], Html::element( 'div', [ 'class' => 'color-middle' ] ) ) . Html::element( 'div', [ 'class' => 'color-left' ] ) . Html::element( 'div', [ 'class' => 'color-right' ] ) ); $html .= Html::rawElement( 'div', [ 'id' => 'mw-header-nav-hack' ], Html::rawElement( 'div', [ 'class' => 'color-bar' ], Html::rawElement( 'div', [ 'class' => 'color-middle-container' ], Html::element( 'div', [ 'class' => 'color-middle' ] ) ) . Html::element( 'div', [ 'class' => 'color-left' ] ) . Html::element( 'div', [ 'class' => 'color-right' ] ) ) ); return $html; } /** * Page tools in sidebar * * @return string html */ protected function getPageToolSidebar() { $pageTools = $this->getPortlet( 'cactions', $this->pileOfTools['page-secondary'], 'timeless-pageactions' ); $pageTools .= $this->getPortlet( 'userpagetools', $this->pileOfTools['user'], 'timeless-userpagetools' ); $pageTools .= $this->getPortlet( 'pagemisc', $this->pileOfTools['page-tertiary'], 'timeless-pagemisc' ); if ( isset( $this->collectionPortlet ) ) { $pageTools .= $this->getPortlet( 'coll-print_export', $this->collectionPortlet ); } return $this->getSidebarChunk( 'page-tools', 'timeless-pageactions', $pageTools ); } /** * Personal/user links portlet for header * * @return array [ html, class ], where class is an extra class to apply to surrounding objects * (for width adjustments) */ protected function getUserLinks() { $skin = $this->getSkin(); $user = $skin->getUser(); $personalTools = $skin->getPersonalToolsForMakeListItem( $this->get( 'personal_urls' ) ); // Preserve standard username label to allow customisation (T215822) $userName = $personalTools['userpage']['links'][0]['text'] ?? $user->getName(); $extraTools = []; // Remove anon placeholder if ( isset( $personalTools['anonuserpage'] ) ) { unset( $personalTools['anonuserpage'] ); } // Remove temp user placeholder, as we display the user name in the dropdown header instead. // Removing the use of .mw-userpage-tmp class also prevents the anchored popup from appearing, // which is good, because there's no reasonable place to put it. if ( isset( $personalTools['userpage'] ) && in_array( 'mw-userpage-tmp', $personalTools['userpage']['links'][0]['class'] ?? [] ) ) { unset( $personalTools['userpage'] ); } // Remove Echo badges if ( isset( $personalTools['notifications-alert'] ) ) { $extraTools['notifications-alert'] = $personalTools['notifications-alert']; unset( $personalTools['notifications-alert'] ); } if ( isset( $personalTools['notifications-notice'] ) ) { $extraTools['notifications-notice'] = $personalTools['notifications-notice']; unset( $personalTools['notifications-notice'] ); } $class = $extraTools === [] ? '' : 'extension-icons'; // Re-label some messages if ( isset( $personalTools['userpage'] ) ) { $personalTools['userpage']['links'][0]['text'] = $this->getMsg( 'timeless-userpage' )->text(); } if ( isset( $personalTools['mytalk'] ) ) { $personalTools['mytalk']['links'][0]['text'] = $this->getMsg( 'timeless-talkpage' )->text(); } // Labels if ( $user->isNamed() ) { $dropdownHeader = $userName; $headerMsg = [ 'timeless-loggedinas', $userName ]; } elseif ( $user->isTemp() ) { $dropdownHeader = $user->getName(); $headerMsg = 'timeless-notloggedin'; } else { $dropdownHeader = $this->getMsg( 'timeless-anonymous' )->text(); $headerMsg = 'timeless-notloggedin'; } $html = Html::openElement( 'div', [ 'id' => 'user-tools' ] ); $html .= Html::rawElement( 'div', [ 'id' => 'personal' ], Html::rawElement( 'h2', [], Html::element( 'span', [], $dropdownHeader ) ) . Html::rawElement( 'div', [ 'id' => 'personal-inner', 'class' => 'dropdown' ], $this->getPortlet( 'personal', $personalTools, $headerMsg ) ) ); // Extra icon stuff (echo etc) if ( $extraTools !== [] ) { $iconList = ''; foreach ( $extraTools as $key => $item ) { $iconList .= $skin->makeListItem( $key, $item ); } $html .= Html::rawElement( 'div', [ 'id' => 'personal-extra', 'class' => 'p-body' ], Html::rawElement( 'ul', [], $iconList ) ); } $html .= Html::closeElement( 'div' ); return [ 'html' => $html, 'class' => $class ]; } /** * Notices that may appear above the firstHeading * * @return string html */ protected function getSiteNotices() { $html = ''; if ( $this->data['sitenotice'] ) { $html .= Html::rawElement( 'div', [ 'id' => 'siteNotice' ], $this->get( 'sitenotice' ) ); } if ( $this->data['newtalk'] ) { $html .= Html::rawElement( 'div', [ 'class' => 'usermessage' ], $this->get( 'newtalk' ) ); } return $html; } /** * Links and information that may appear below the firstHeading * * @return string html */ protected function getContentSub() { $html = Html::openElement( 'div', [ 'id' => 'contentSub' ] ); if ( $this->data['subtitle'] ) { $html .= $this->get( 'subtitle' ); } if ( $this->data['undelete'] ) { $html .= $this->get( 'undelete' ); } return $html . Html::closeElement( 'div' ); } /** * The data after content, catlinks, and potential other stuff that may appear within * the content block but after the main content * * @return string html */ protected function getAfterContent() { $html = ''; if ( $this->data['catlinks'] || $this->data['dataAfterContent'] ) { $html .= Html::openElement( 'div', [ 'id' => 'content-bottom-stuff' ] ); if ( $this->data['catlinks'] ) { $html .= $this->get( 'catlinks' ); } if ( $this->data['dataAfterContent'] ) { $html .= $this->get( 'dataAfterContent' ); } $html .= Html::closeElement( 'div' ); } return $html; } /** * Generate pile of all the tools * * We can make a few assumptions based on where a tool started out: * If it's in the cactions region, it's a page tool, probably primary or secondary * ...that's all I can think of * * @return array of array of tools information (portlet formatting) */ protected function getPageTools() { $title = $this->getSkin()->getTitle(); $namespace = $title->getNamespace(); $sortedPileOfTools = [ 'namespaces' => [], 'page-primary' => [], 'page-secondary' => [], 'user' => [], 'page-tertiary' => [], 'more' => [], 'general' => [] ]; // Tools specific to the page $pileOfEditTools = []; $contentNavigation = $this->data['content_navigation']; foreach ( $contentNavigation as $navKey => $navBlock ) { // Just use namespaces items as they are if ( $navKey == 'namespaces' ) { if ( $namespace < 0 && count( $navBlock ) < 2 ) { // Put special page ns_pages in the more pile so they're not so lonely $sortedPileOfTools['page-tertiary'] = $navBlock; } else { $sortedPileOfTools['namespaces'] = $navBlock; } } elseif ( $navKey == 'variants' ) { // wat $sortedPileOfTools['variants'] = $navBlock; } else { $pileOfEditTools = array_merge( $pileOfEditTools, $navBlock ); } } // Tools that may be general or page-related (typically the toolbox) $pileOfTools = $this->sidebar['TOOLBOX']; if ( $namespace >= 0 ) { $pileOfTools['pagelog'] = [ 'text' => $this->getMsg( 'timeless-pagelog' )->text(), 'href' => SpecialPage::getTitleFor( 'Log' )->getLocalURL( [ 'page' => $title->getPrefixedText() ] ), 'id' => 't-pagelog' ]; } // Mobile toggles $pileOfTools['more'] = [ 'text' => $this->getMsg( 'timeless-more' )->text(), 'id' => 'ca-more', 'class' => 'dropdown-toggle' ]; // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset if ( !empty( $this->sidebar['LANGUAGES'] ) || $sortedPileOfTools['variants'] || isset( $this->otherProjects ) ) { $pileOfTools['languages'] = [ 'text' => $this->getMsg( 'timeless-languages' )->escaped(), 'id' => 'ca-languages', 'class' => 'dropdown-toggle' ]; } // This is really dumb, and you're an idiot for doing it this way. // Obviously if you're not the idiot who did this, I don't mean you. foreach ( $pileOfEditTools as $navKey => $navBlock ) { if ( in_array( $navKey, [ 'watch', 'unwatch' ] ) ) { $currentSet = 'namespaces'; } elseif ( in_array( $navKey, [ 'edit', 'view', 'history', 'addsection', 'viewsource' ] ) ) { $currentSet = 'page-primary'; } elseif ( in_array( $navKey, [ 'delete', 'rename', 'protect', 'unprotect', 'move' ] ) ) { $currentSet = 'page-secondary'; } else { // Catch random extension ones? $currentSet = 'page-primary'; } $sortedPileOfTools[$currentSet][$navKey] = $navBlock; } foreach ( $pileOfTools as $navKey => $navBlock ) { $currentSet = null; if ( $navKey === 'contributions' ) { $currentSet = 'page-primary'; } elseif ( in_array( $navKey, [ 'blockip', 'userrights', 'log', 'emailuser' ] ) ) { $currentSet = 'user'; } elseif ( in_array( $navKey, [ 'whatlinkshere', 'print', 'info', 'pagelog', 'recentchangeslinked', 'permalink', 'wikibase', 'cite' ] ) ) { $currentSet = 'page-tertiary'; } elseif ( in_array( $navKey, [ 'more', 'languages' ] ) ) { $currentSet = 'more'; } else { $currentSet = 'general'; } $sortedPileOfTools[$currentSet][$navKey] = $navBlock; } // Extra sorting for Extension:ProofreadPage namespace items $tabs = [ // This is the order we want them in... 'proofreadPageScanLink', 'proofreadPageIndexLink', 'proofreadPageNextLink', ]; foreach ( $tabs as $tab ) { if ( isset( $sortedPileOfTools['namespaces'][$tab] ) ) { $toMove = $sortedPileOfTools['namespaces'][$tab]; unset( $sortedPileOfTools['namespaces'][$tab] ); // move to end! $sortedPileOfTools['namespaces'][$tab] = $toMove; } } return $sortedPileOfTools; } /** * Categories for the sidebar * * Assemble an array of categories. This doesn't show any categories for the * action=history view, but that behaviour is consistent with other skins. * * @return string html */ protected function getCategories() { $skin = $this->getSkin(); $catHeader = 'categories'; $catList = ''; $allCats = $skin->getOutput()->getCategoryLinks(); if ( $allCats !== [] ) { if ( isset( $allCats['normal'] ) && $allCats['normal'] !== [] ) { $catList .= $this->getCatList( $allCats['normal'], 'normal-catlinks', 'mw-normal-catlinks', 'categories' ); } else { $catHeader = 'hidden-categories'; } if ( isset( $allCats['hidden'] ) ) { $hiddenCatClass = [ 'mw-hidden-catlinks' ]; if ( MediaWikiServices::getInstance() ->getUserOptionsLookup() ->getBoolOption( $skin->getUser(), 'showhiddencats' ) ) { $hiddenCatClass[] = 'mw-hidden-cats-user-shown'; } elseif ( $skin->getTitle()->getNamespace() === NS_CATEGORY ) { $hiddenCatClass[] = 'mw-hidden-cats-ns-shown'; } else { $hiddenCatClass[] = 'mw-hidden-cats-hidden'; } $catList .= $this->getCatList( $allCats['hidden'], 'hidden-catlinks', $hiddenCatClass, [ 'hidden-categories', count( $allCats['hidden'] ) ] ); } } if ( $catList !== '' ) { return $this->getSidebarChunk( 'catlinks-sidebar', $catHeader, $catList ); } return ''; } /** * List of categories * * @param array $list * @param string $id * @param string|array $class * @param string|array $message i18n message name or an array of [ message name, params ] * * @return string html */ protected function getCatList( $list, $id, $class, $message ) { $html = Html::openElement( 'div', [ 'id' => "sidebar-{$id}", 'class' => $class ] ); $makeLinkItem = static function ( $linkHtml ) { return Html::rawElement( 'li', [], $linkHtml ); }; $categoryItems = array_map( $makeLinkItem, $list ); $categoriesHtml = Html::rawElement( 'ul', [], implode( '', $categoryItems ) ); $html .= $this->getPortlet( $id, $categoriesHtml, $message ); return $html . Html::closeElement( 'div' ); } /** * Interlanguage links block, with variants if applicable * Layout sort of assumes we're using ULS compact language handling * if there's a lot of languages. * * @return string html */ protected function getVariants() { $html = ''; if ( $this->pileOfTools['variants'] ) { $html .= $this->getPortlet( 'variants-desktop', $this->pileOfTools['variants'], 'variants', [ 'body-extra-classes' => 'dropdown' ] ); } return $html; } /** * Interwiki links block * * @return string html */ protected function getInterwikiLinks() { $html = ''; $variants = ''; $otherprojects = ''; $show = false; $variantsOnly = false; if ( $this->pileOfTools['variants'] ) { $variants = $this->getPortlet( 'variants', $this->pileOfTools['variants'] ); $show = true; $variantsOnly = true; } $languages = $this->getPortlet( 'lang', $this->languages, 'otherlanguages' ); // Force rendering of this section if there are languages or when the 'lang' // portlet has been modified by hook even if there are no language items. if ( count( $this->languages ) || $this->afterLangPortlet !== '' ) { $show = true; $variantsOnly = false; } else { $languages = ''; } // if using wikibase for 'in other projects' if ( isset( $this->otherProjects ) ) { $otherprojects = $this->getPortlet( 'wikibase-otherprojects', $this->otherProjects ); $show = true; $variantsOnly = false; } if ( $show ) { $html .= $this->getSidebarChunk( 'other-languages', 'timeless-projects', $variants . $languages . $otherprojects, $variantsOnly ? [ 'variants-only' ] : [] ); } return $html; } /** * Generate img-based logos for proper HiDPI support * * @param string|array|null $logo * @param bool $doLarge Render extra-large HiDPI logos for mobile devices? * * @return string|false html|we're not doing this */ protected function getLogoImage( $logo, $doLarge = false ) { if ( $logo === null ) { // not set, fall back to generic methods return false; } // Generate $logoData from a file upload if ( is_string( $logo ) ) { $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $logo ); if ( !$file || !$file->canRender() ) { // eeeeeh bail, scary return false; } $logoData = []; // Calculate intended sizes $width = $file->getWidth(); $height = $file->getHeight(); $bound = $width > $height ? $width : $height; $svg = File::normalizeExtension( $file->getExtension() ) === 'svg'; // Mobile stuff is generally a lot more than just 2ppp. Let's go with 4x? // Currently we're just doing this for wordmarks, which shouldn't get that // big in practice, so this is probably safe enough. And no need to use // this for desktop logos, so fall back to 2x for 2x as default... $large = $doLarge ? 4 : 2; if ( $bound <= 165 ) { // It's a 1x image $logoData['width'] = $width; $logoData['height'] = $height; if ( $svg ) { $logoData['1x'] = $file->createThumb( $logoData['width'] ); $logoData['1.5x'] = $file->createThumb( (int)( $logoData['width'] * 1.5 ) ); $logoData['2x'] = $file->createThumb( $logoData['width'] * $large ); } elseif ( $file->mustRender() ) { $logoData['1x'] = $file->createThumb( $logoData['width'] ); } else { $logoData['1x'] = $file->getUrl(); } } elseif ( $bound >= 230 && $bound <= 330 ) { // It's a 2x image $logoData['width'] = (int)( $width / 2 ); $logoData['height'] = (int)( $height / 2 ); $logoData['1x'] = $file->createThumb( $logoData['width'] ); $logoData['1.5x'] = $file->createThumb( (int)( $logoData['width'] * 1.5 ) ); if ( $svg || $file->mustRender() ) { $logoData['2x'] = $file->createThumb( $logoData['width'] * 2 ); } else { $logoData['2x'] = $file->getUrl(); } } else { // Okay, whatever, we get to pick something random // Yes I am aware this means they might have arbitrarily tall logos, // and you know what, let 'em, I don't care $logoData['width'] = 155; $logoData['height'] = File::scaleHeight( $width, $height, $logoData['width'] ); $logoData['1x'] = $file->createThumb( $logoData['width'] ); if ( $svg || $logoData['width'] * 1.5 <= $width ) { $logoData['1.5x'] = $file->createThumb( (int)( $logoData['width'] * 1.5 ) ); } if ( $svg || $logoData['width'] * 2 <= $width ) { $logoData['2x'] = $file->createThumb( $logoData['width'] * $large ); } } } elseif ( is_array( $logo ) ) { // manually set logo data for non-file-uploads $logoData = $logo; } else { // nope return false; } // Render the html output! $attribs = [ 'alt' => $this->getMsg( 'sitetitle' )->text(), // Should we care? It's just a logo... 'decoding' => 'auto', 'width' => $logoData['width'], 'height' => $logoData['height'], ]; if ( !isset( $logoData['1x'] ) && isset( $logoData['2x'] ) ) { // We'll allow it... $attribs['src'] = $logoData['2x']; } else { // Okay, we really do want a 1x otherwise. If this throws an error or // something because there's nothing here, GOOD. // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset $attribs['src'] = $logoData['1x']; // Throw the rest in a srcset unset( $logoData['1x'], $logoData['width'], $logoData['height'] ); $srcset = ''; foreach ( $logoData as $res => $path ) { if ( $srcset != '' ) { $srcset .= ', '; } $srcset .= $path . ' ' . $res; } if ( $srcset !== '' ) { $attribs['srcset'] = $srcset; } } return Html::element( 'img', $attribs ); } } PK ! 77� � SkinTimeless.phpnu �Iw�� <?php namespace MediaWiki\Skin\Timeless; use SkinTemplate; /** * SkinTemplate class for the Timeless skin * * @ingroup Skins */ class SkinTimeless extends SkinTemplate { /** * @inheritDoc */ public function __construct( array $options = [] ) { $out = $this->getOutput(); // Basic IE support without flexbox $out->addStyle( 'Timeless/resources/IE9fixes.css', 'screen', 'IE' ); parent::__construct( $options ); } } PK ! �u`� � BenchmarkerTest.phpnu �Iw�� <?php namespace MediaWiki\Tests\Maintenance\Includes; use MediaWiki\Maintenance\Benchmarker; use MediaWikiCoversValidator; use PHPUnit\Framework\TestCase; use Wikimedia\TestingAccessWrapper; /** * @covers \MediaWiki\Maintenance\Benchmarker */ class BenchmarkerTest extends TestCase { use MediaWikiCoversValidator; public function testBenchSimple() { $bench = $this->getMockBuilder( Benchmarker::class ) ->onlyMethods( [ 'execute', 'output' ] ) ->getMock(); $benchProxy = TestingAccessWrapper::newFromObject( $bench ); $benchProxy->defaultCount = 3; $count = 0; $bench->bench( [ 'test' => static function () use ( &$count ) { $count++; } ] ); $this->assertSame( 3, $count ); } public function testBenchSetup() { $bench = $this->getMockBuilder( Benchmarker::class ) ->onlyMethods( [ 'execute', 'output' ] ) ->getMock(); $benchProxy = TestingAccessWrapper::newFromObject( $bench ); $benchProxy->defaultCount = 2; $buffer = []; $bench->bench( [ 'test' => [ 'setup' => static function () use ( &$buffer ) { $buffer[] = 'setup'; }, 'function' => static function () use ( &$buffer ) { $buffer[] = 'run'; } ] ] ); $this->assertSame( [ 'setup', 'run', 'run' ], $buffer ); } public function testBenchVerbose() { $bench = $this->getMockBuilder( Benchmarker::class ) ->onlyMethods( [ 'execute', 'output', 'hasOption', 'verboseRun' ] ) ->getMock(); $benchProxy = TestingAccessWrapper::newFromObject( $bench ); $benchProxy->defaultCount = 1; $bench->expects( $this->once() )->method( 'hasOption' ) ->willReturnMap( [ [ 'verbose', true ], ] ); $bench->expects( $this->once() )->method( 'verboseRun' ) ->with( 0 ) ->willReturn( null ); $bench->bench( [ 'test' => static function () { } ] ); } public function noop() { } public function testBenchName_method() { $bench = $this->getMockBuilder( Benchmarker::class ) ->onlyMethods( [ 'execute', 'output', 'addResult' ] ) ->getMock(); $benchProxy = TestingAccessWrapper::newFromObject( $bench ); $benchProxy->defaultCount = 1; $bench->expects( $this->once() )->method( 'addResult' ) ->with( $this->callback( static function ( $res ) { return isset( $res['name'] ) && $res['name'] === ( __CLASS__ . '::noop()' ); } ) ); $bench->bench( [ [ 'function' => [ $this, 'noop' ] ] ] ); } public function testBenchName_string() { $bench = $this->getMockBuilder( Benchmarker::class ) ->onlyMethods( [ 'execute', 'output', 'addResult' ] ) ->getMock(); $benchProxy = TestingAccessWrapper::newFromObject( $bench ); $benchProxy->defaultCount = 1; $bench->expects( $this->once() )->method( 'addResult' ) ->with( $this->callback( static function ( $res ) { return $res['name'] === "strtolower('A')"; } ) ); $bench->bench( [ [ 'function' => 'strtolower', 'args' => [ 'A' ], ] ] ); } public function testVerboseRun() { $bench = $this->getMockBuilder( Benchmarker::class ) ->onlyMethods( [ 'execute', 'output', 'hasOption', 'startBench', 'addResult' ] ) ->getMock(); $benchProxy = TestingAccessWrapper::newFromObject( $bench ); $benchProxy->defaultCount = 1; $bench->expects( $this->once() )->method( 'hasOption' ) ->willReturnMap( [ [ 'verbose', true ], ] ); $bench->expects( $this->once() )->method( 'output' ) ->with( $this->callback( static function ( $out ) { return preg_match( '/memory.+ peak/', $out ) === 1; } ) ); $bench->bench( [ 'test' => static function () { } ] ); } public function testNaming() { $bench = $this->getMockBuilder( Benchmarker::class ) ->onlyMethods( [ 'execute', 'output', 'startBench' ] ) ->getMock(); $benchProxy = TestingAccessWrapper::newFromObject( $bench ); $benchProxy->defaultCount = 1; $out = ''; $bench->expects( $this->any() )->method( 'output' ) ->willReturnCallback( static function ( $str ) use ( &$out ) { $out .= $str; return null; } ); $bench->bench( [ [ 'function' => 'in_array', 'args' => [ 'A', [ 'X', 'Y' ] ], ], [ 'function' => 'in_array', 'args' => [ 'A', [ 'X', 'Y', str_repeat( 'z', 900 ) ] ], ], [ 'function' => 'strtolower', 'args' => [ 'A' ], ], [ 'function' => 'strtolower', 'args' => [ str_repeat( 'x', 900 ) ], ], [ 'function' => 'in_array', 'args' => [ str_repeat( 'y', 900 ), [] ], ], [ 'function' => 'in_array', 'args' => [ str_repeat( 'z', 900 ), [] ], ], ] ); $out = preg_replace( '/^.*(: |memory).*\n/m', '', $out ); $out = trim( str_replace( "\n\n", "\n", $out ) ); $this->assertEquals( <<<TEXT in_array@1 in_array@2 strtolower('A') strtolower@2 in_array@3 in_array@4 TEXT, $out ); } } PK ! ��ٓ� � LoggedUpdateMaintenanceTest.phpnu �Iw�� <?php namespace MediaWiki\Tests\Maintenance\Includes; use MediaWiki\Maintenance\LoggedUpdateMaintenance; use MediaWiki\Tests\Maintenance\MaintenanceBaseTestCase; use PHPUnit\Framework\MockObject\MockObject; use Wikimedia\TestingAccessWrapper; /** * @covers \MediaWiki\Maintenance\LoggedUpdateMaintenance * @author Dreamy Jazz * @group Database */ class LoggedUpdateMaintenanceTest extends MaintenanceBaseTestCase { /** @var LoggedUpdateMaintenance|MockObject */ protected $maintenance; protected function getMaintenanceClass() { return LoggedUpdateMaintenance::class; } protected function createMaintenance() { $obj = $this->getMockBuilder( $this->getMaintenanceClass() ) ->onlyMethods( [ 'getUpdateKey', 'doDBUpdates' ] ) ->getMockForAbstractClass(); // We use TestingAccessWrapper in order to access protected internals // such as `output()`. return TestingAccessWrapper::newFromObject( $obj ); } /** @dataProvider provideForcedValues */ public function testSetForce( $value ) { $this->maintenance->setForce( $value ); $this->assertSame( $value, $this->maintenance->getParameters()->getOption( 'force' ) ); } public static function provideForcedValues() { return [ '--force set' => [ true ], '--force not set' => [ null ], ]; } /** @dataProvider provideExecute */ public function testExecute( $doDBUpdatesReturnValue, $markedAsCompleteBeforeRun, $shouldBeMarkedAsCompleteAfterExecution, $force, $expectedReturnValueFromExecute, $expectedOutputRegex = false ) { // If marked as complete before the run, manually add the key into the updatelog table if ( $markedAsCompleteBeforeRun ) { $this->getDb()->newInsertQueryBuilder() ->insertInto( 'updatelog' ) ->row( [ 'ul_key' => 'test' ] ) ->execute(); } // Set if --force is specified and also mock the return value of ::doDBUpdates $this->maintenance->setForce( $force ); $this->maintenance->method( 'doDBUpdates' ) ->willReturn( $doDBUpdatesReturnValue ); $this->maintenance->method( 'getUpdateKey' ) ->willReturn( 'test' ); // Run the maintenance script and then assert that the updatelog table is as expected $this->assertSame( $expectedReturnValueFromExecute, $this->maintenance->execute() ); $this->newSelectQueryBuilder() ->select( 'COUNT(*)' ) ->from( 'updatelog' ) ->where( [ 'ul_key' => 'test' ] ) ->assertFieldValue( (int)$shouldBeMarkedAsCompleteAfterExecution ); if ( $expectedOutputRegex ) { $this->expectOutputRegex( $expectedOutputRegex ); } else { $this->expectOutputString( '' ); } } public static function provideExecute() { return [ 'Update has been run before' => [ 'doDBUpdatesReturnValue' => false, 'markedAsCompleteBeforeRun' => true, 'shouldBeMarkedAsCompleteAfterExecution' => true, 'force' => null, 'expectedReturnValueFromExecute' => true, 'expectedOutputRegex' => "/Update 'test' already logged as completed/" ], 'Update has been run before, but force provided and update fails' => [ 'doDBUpdatesReturnValue' => false, 'markedAsCompleteBeforeRun' => true, 'shouldBeMarkedAsCompleteAfterExecution' => true, 'force' => true, 'expectedReturnValueFromExecute' => false, ], 'Update has never been run before and update succeeds' => [ 'doDBUpdatesReturnValue' => true, 'markedAsCompleteBeforeRun' => false, 'shouldBeMarkedAsCompleteAfterExecution' => true, 'force' => null, 'expectedReturnValueFromExecute' => true, ], 'Update has never been run before and update fails' => [ 'doDBUpdatesReturnValue' => false, 'markedAsCompleteBeforeRun' => false, 'shouldBeMarkedAsCompleteAfterExecution' => false, 'force' => false, 'expectedReturnValueFromExecute' => false, ], ]; } } PK ! #L5-nV nV MaintenanceTest.phpnu �Iw�� <?php namespace MediaWiki\Tests\Maintenance\Includes; use MediaWiki\Config\Config; use MediaWiki\Config\HashConfig; use MediaWiki\Maintenance\Maintenance; use MediaWiki\MediaWikiServices; use MediaWiki\Tests\Maintenance\MaintenanceBaseTestCase; use PHPUnit\Framework\Assert; use Wikimedia\TestingAccessWrapper; /** * @covers \MediaWiki\Maintenance\MaintenanceFatalError * @covers \MediaWiki\Maintenance\Maintenance * @group Database */ class MaintenanceTest extends MaintenanceBaseTestCase { /** * @inheritDoc */ protected function getMaintenanceClass() { return Maintenance::class; } /** * @see MaintenanceBaseTestCase::createMaintenance * * Note to extension authors looking for a model to follow: This function * is normally not needed in a maintenance test, it's only overridden here * because Maintenance is abstract. * @inheritDoc */ protected function createMaintenance() { $className = $this->getMaintenanceClass(); $obj = $this->getMockForAbstractClass( $className ); return TestingAccessWrapper::newFromObject( $obj ); } // Although the following tests do not seem to be too consistent (compare for // example the newlines within the test.*StringString tests, or the // test.*Intermittent.* tests), the objective of these tests is not to describe // consistent behavior, but rather currently existing behavior. /** * @dataProvider provideOutputData */ public function testOutput( $outputs, $expected, $extraNL ) { foreach ( $outputs as $data ) { if ( is_array( $data ) ) { [ $msg, $channel ] = $data; } else { $msg = $data; $channel = null; } $this->maintenance->output( $msg, $channel ); } $this->assertOutputPrePostShutdown( $expected, $extraNL ); } public static function provideOutputData() { return [ [ [ "" ], "", false ], [ [ "foo" ], "foo", false ], [ [ "foo", "bar" ], "foobar", false ], [ [ "foo\n" ], "foo\n", false ], [ [ "foo\n\n" ], "foo\n\n", false ], [ [ "foo\nbar" ], "foo\nbar", false ], [ [ "foo\nbar\n" ], "foo\nbar\n", false ], [ [ "foo\n", "bar\n" ], "foo\nbar\n", false ], [ [ "", "foo", "", "\n", "ba", "", "r\n" ], "foo\nbar\n", false ], [ [ "", "foo", "", "\nb", "a", "", "r\n" ], "foo\nbar\n", false ], [ [ [ "foo", "bazChannel" ] ], "foo", true ], [ [ [ "foo\n", "bazChannel" ] ], "foo", true ], // If this test fails, note that output takes strings with double line // endings (although output's implementation in this situation calls // outputChanneled with a string ending in a nl ... which is not allowed // according to the documentation of outputChanneled) [ [ [ "foo\n\n", "bazChannel" ] ], "foo\n", true ], [ [ [ "foo\nbar", "bazChannel" ] ], "foo\nbar", true ], [ [ [ "foo\nbar\n", "bazChannel" ] ], "foo\nbar", true ], [ [ [ "foo\n", "bazChannel" ], [ "bar\n", "bazChannel" ], ], "foobar", true ], [ [ [ "", "bazChannel" ], [ "foo", "bazChannel" ], [ "", "bazChannel" ], [ "\n", "bazChannel" ], [ "ba", "bazChannel" ], [ "", "bazChannel" ], [ "r\n", "bazChannel" ], ], "foobar", true ], [ [ [ "", "bazChannel" ], [ "foo", "bazChannel" ], [ "", "bazChannel" ], [ "\nb", "bazChannel" ], [ "a", "bazChannel" ], [ "", "bazChannel" ], [ "r\n", "bazChannel" ], ], "foo\nbar", true ], [ [ [ "foo", "bazChannel" ], [ "bar", "bazChannel" ], [ "qux", "quuxChannel" ], [ "corge", "bazChannel" ], ], "foobar\nqux\ncorge", true ], [ [ [ "foo", "bazChannel" ], [ "bar\n", "bazChannel" ], [ "qux\n", "quuxChannel" ], [ "corge", "bazChannel" ], ], "foobar\nqux\ncorge", true ], [ [ [ "foo", null ], [ "bar", "bazChannel" ], [ "qux", null ], [ "quux", "bazChannel" ], ], "foobar\nquxquux", true ], [ [ [ "foo", "bazChannel" ], [ "bar", null ], [ "qux", "bazChannel" ], [ "quux", null ], ], "foo\nbarqux\nquux", false ], [ [ [ "foo", 1 ], [ "bar", 1.0 ], ], "foo\nbar", true ], [ [ "foo", "", "bar" ], "foobar", false ], [ [ "foo", false, "bar" ], "foobar", false ], [ [ [ "qux", "quuxChannel" ], "foo", false, "bar" ], "qux\nfoobar", false ], [ [ [ "foo", "bazChannel" ], [ "", "bazChannel" ], [ "bar", "bazChannel" ], ], "foobar", true ], [ [ [ "foo", "bazChannel" ], [ false, "bazChannel" ], [ "bar", "bazChannel" ], ], "foobar", true ], ]; } /** * @dataProvider provideOutputChanneledData */ public function testOutputChanneled( $outputs, $expected, $extraNL ) { foreach ( $outputs as $data ) { if ( is_array( $data ) ) { [ $msg, $channel ] = $data; } else { $msg = $data; $channel = null; } $this->maintenance->outputChanneled( $msg, $channel ); } $this->assertOutputPrePostShutdown( $expected, $extraNL ); } public static function provideOutputChanneledData() { return [ [ [ "" ], "\n", false ], [ [ "foo" ], "foo\n", false ], [ [ "foo", "bar" ], "foo\nbar\n", false ], [ [ "foo\nbar" ], "foo\nbar\n", false ], [ [ "", "foo", "", "\nb", "a", "", "r" ], "\nfoo\n\n\nb\na\n\nr\n", false ], [ [ [ "foo", "bazChannel" ] ], "foo", true ], [ [ [ "foo\nbar", "bazChannel" ] ], "foo\nbar", true ], [ [ [ "foo", "bazChannel" ], [ "bar", "bazChannel" ], ], "foobar", true ], [ [ [ "", "bazChannel" ], [ "foo", "bazChannel" ], [ "", "bazChannel" ], [ "\nb", "bazChannel" ], [ "a", "bazChannel" ], [ "", "bazChannel" ], [ "r", "bazChannel" ], ], "foo\nbar", true ], [ [ [ "foo", "bazChannel" ], [ "bar", "bazChannel" ], [ "qux", "quuxChannel" ], [ "corge", "bazChannel" ], ], "foobar\nqux\ncorge", true ], [ [ [ "foo", "bazChannel" ], [ "bar", "bazChannel" ], [ "qux", "quuxChannel" ], [ "corge", "bazChannel" ], ], "foobar\nqux\ncorge", true ], [ [ [ "foo", "bazChannel" ], [ "bar", null ], [ "qux", null ], [ "corge", "bazChannel" ], ], "foo\nbar\nqux\ncorge", true ], [ [ [ "foo", null ], [ "bar", "bazChannel" ], [ "qux", null ], [ "quux", "bazChannel" ], ], "foo\nbar\nqux\nquux", true ], [ [ [ "foo", "bazChannel" ], [ "bar", null ], [ "qux", "bazChannel" ], [ "quux", null ], ], "foo\nbar\nqux\nquux\n", false ], [ [ [ "foo", 1 ], [ "bar", 1.0 ], ], "foo\nbar", true ], [ [ "foo", "", "bar" ], "foo\n\nbar\n", false ], [ [ "foo", false, "bar" ], "foo\nbar\n", false ], ]; } public function testCleanupChanneledClean() { $this->maintenance->cleanupChanneled(); $this->assertOutputPrePostShutdown( "", false ); } public function testCleanupChanneledAfterOutput() { $this->maintenance->output( "foo" ); $this->maintenance->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foo", false ); } public function testCleanupChanneledAfterOutputWNullChannel() { $this->maintenance->output( "foo", null ); $this->maintenance->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foo", false ); } public function testCleanupChanneledAfterOutputWChannel() { $this->maintenance->output( "foo", "bazChannel" ); $this->maintenance->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foo\n", false ); } public function testCleanupChanneledAfterNLOutput() { $this->maintenance->output( "foo\n" ); $this->maintenance->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foo\n", false ); } public function testCleanupChanneledAfterNLOutputWNullChannel() { $this->maintenance->output( "foo\n", null ); $this->maintenance->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foo\n", false ); } public function testCleanupChanneledAfterNLOutputWChannel() { $this->maintenance->output( "foo\n", "bazChannel" ); $this->maintenance->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foo\n", false ); } public function testCleanupChanneledAfterOutputChanneledWOChannel() { $this->maintenance->outputChanneled( "foo" ); $this->maintenance->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foo\n", false ); } public function testCleanupChanneledAfterOutputChanneledWNullChannel() { $this->maintenance->outputChanneled( "foo", null ); $this->maintenance->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foo\n", false ); } public function testCleanupChanneledAfterOutputChanneledWChannel() { $this->maintenance->outputChanneled( "foo", "bazChannel" ); $this->maintenance->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foo\n", false ); } public function testMultipleMaintenanceObjectsInteractionOutput() { $m2 = $this->createMaintenance(); $this->maintenance->output( "foo" ); $m2->output( "bar" ); $this->assertEquals( "foobar", $this->getActualOutput(), "Output before shutdown simulation (m2)" ); $m2->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foobar", false ); } public function testMultipleMaintenanceObjectsInteractionOutputWNullChannel() { $m2 = $this->createMaintenance(); $this->maintenance->output( "foo", null ); $m2->output( "bar", null ); $this->assertEquals( "foobar", $this->getActualOutput(), "Output before shutdown simulation (m2)" ); $m2->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foobar", false ); } public function testMultipleMaintenanceObjectsInteractionOutputWChannel() { $m2 = $this->createMaintenance(); $this->maintenance->output( "foo", "bazChannel" ); $m2->output( "bar", "bazChannel" ); $this->assertEquals( "foobar", $this->getActualOutput(), "Output before shutdown simulation (m2)" ); $m2->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foobar\n", true ); } public function testMultipleMaintenanceObjectsInteractionOutputWNullChannelNL() { $m2 = $this->createMaintenance(); $this->maintenance->output( "foo\n", null ); $m2->output( "bar\n", null ); $this->assertEquals( "foo\nbar\n", $this->getActualOutput(), "Output before shutdown simulation (m2)" ); $m2->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); } public function testMultipleMaintenanceObjectsInteractionOutputWChannelNL() { $m2 = $this->createMaintenance(); $this->maintenance->output( "foo\n", "bazChannel" ); $m2->output( "bar\n", "bazChannel" ); $this->assertEquals( "foobar", $this->getActualOutput(), "Output before shutdown simulation (m2)" ); $m2->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foobar\n", true ); } public function testMultipleMaintenanceObjectsInteractionOutputChanneled() { $m2 = $this->createMaintenance(); $this->maintenance->outputChanneled( "foo" ); $m2->outputChanneled( "bar" ); $this->assertEquals( "foo\nbar\n", $this->getActualOutput(), "Output before shutdown simulation (m2)" ); $m2->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); } public function testMultipleMaintenanceObjectsInteractionOutputChanneledWNullChannel() { $m2 = $this->createMaintenance(); $this->maintenance->outputChanneled( "foo", null ); $m2->outputChanneled( "bar", null ); $this->assertEquals( "foo\nbar\n", $this->getActualOutput(), "Output before shutdown simulation (m2)" ); $m2->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); } public function testMultipleMaintenanceObjectsInteractionOutputChanneledWChannel() { $m2 = $this->createMaintenance(); $this->maintenance->outputChanneled( "foo", "bazChannel" ); $m2->outputChanneled( "bar", "bazChannel" ); $this->assertEquals( "foobar", $this->getActualOutput(), "Output before shutdown simulation (m2)" ); $m2->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foobar\n", true ); } public function testMultipleMaintenanceObjectsInteractionCleanupChanneledWChannel() { $m2 = $this->createMaintenance(); $this->maintenance->outputChanneled( "foo", "bazChannel" ); $m2->outputChanneled( "bar", "bazChannel" ); $this->assertEquals( "foobar", $this->getActualOutput(), "Output before first cleanup" ); $this->maintenance->cleanupChanneled(); $this->assertEquals( "foobar\n", $this->getActualOutput(), "Output after first cleanup" ); $m2->cleanupChanneled(); $this->assertEquals( "foobar\n\n", $this->getActualOutput(), "Output after second cleanup" ); $m2->cleanupChanneled(); $this->assertOutputPrePostShutdown( "foobar\n\n", false ); } /** * @covers \MediaWiki\Maintenance\Maintenance::getConfig */ public function testGetConfig() { $this->assertInstanceOf( Config::class, $this->maintenance->getConfig() ); $this->assertSame( MediaWikiServices::getInstance()->getMainConfig(), $this->maintenance->getConfig() ); } /** * @covers \MediaWiki\Maintenance\Maintenance::setConfig */ public function testSetConfig() { $conf = new HashConfig(); $this->maintenance->setConfig( $conf ); $this->assertSame( $conf, $this->maintenance->getConfig() ); } public function testParseWithMultiArgs() { // Create an option with an argument allowed to be specified multiple times $this->maintenance->addOption( 'multi', 'This option does stuff', false, true, false, true ); $this->maintenance->loadWithArgv( [ '--multi', 'this1', '--multi', 'this2' ] ); $this->assertEquals( [ 'this1', 'this2' ], $this->maintenance->getOption( 'multi' ) ); $this->assertEquals( [ [ 'multi', 'this1' ], [ 'multi', 'this2' ] ], $this->maintenance->orderedOptions ); } public function testParseMultiOption() { $this->maintenance->addOption( 'multi', 'This option does stuff', false, false, false, true ); $this->maintenance->loadWithArgv( [ '--multi', '--multi' ] ); $this->assertEquals( [ 1, 1 ], $this->maintenance->getOption( 'multi' ) ); $this->assertEquals( [ [ 'multi', 1 ], [ 'multi', 1 ] ], $this->maintenance->orderedOptions ); } public function testParseArgs() { $this->maintenance->addOption( 'multi', 'This option doesn\'t actually support multiple occurrences' ); $this->maintenance->loadWithArgv( [ '--multi=yo' ] ); $this->assertEquals( 'yo', $this->maintenance->getOption( 'multi' ) ); $this->assertEquals( [ [ 'multi', 'yo' ] ], $this->maintenance->orderedOptions ); } public function testOptionGetters() { $this->assertSame( false, $this->maintenance->hasOption( 'somearg' ), 'Non existent option not found' ); $this->assertSame( 'default', $this->maintenance->getOption( 'somearg', 'default' ), 'Non existent option falls back to default' ); $this->assertSame( false, $this->maintenance->hasOption( 'somearg' ), 'Non existent option not found after getting' ); $this->assertSame( 'newdefault', $this->maintenance->getOption( 'somearg', 'newdefault' ), 'Non existent option falls back to a new default' ); } public function testLegacyOptionsAccess() { $maintenance = new class () extends Maintenance { /** * Tests need to be inside the class in order to have access to protected members. * Setting fields in protected arrays doesn't work via TestingAccessWrapper, triggering * an PHP warning ("Indirect modification of overloaded property"). */ public function execute() { $this->setOption( 'test', 'foo' ); Assert::assertSame( 'foo', $this->getOption( 'test' ) ); Assert::assertSame( 'foo', $this->mOptions['test'] ); $this->mOptions['test'] = 'bar'; Assert::assertSame( 'bar', $this->getOption( 'test' ) ); $this->setArg( 1, 'foo' ); Assert::assertSame( 'foo', $this->getArg( 1 ) ); Assert::assertSame( 'foo', $this->mArgs[1] ); $this->mArgs[1] = 'bar'; Assert::assertSame( 'bar', $this->getArg( 1 ) ); } }; $maintenance->execute(); } /** @dataProvider provideFatalError */ public function testFatalError( $msg, $errorCode ) { $this->expectCallToFatalError( $errorCode ); $this->expectOutputString( $msg . "\n" ); $this->maintenance->fatalError( $msg, $errorCode ); } public static function provideFatalError() { return [ 'No error message, code as 1' => [ '', 1 ], 'Defined error message, code as 3' => [ 'Testing error message', 3 ], ]; } /** @dataProvider provideRequiredButMissingExtensions */ public function testCheckRequiredExtensionForMissingExtension( $requiredExtensions, $expectedOutputRegex ) { $this->expectCallToFatalError(); $this->expectOutputRegex( $expectedOutputRegex ); foreach ( $requiredExtensions as $extension ) { $this->maintenance->requireExtension( $extension ); } $this->maintenance->checkRequiredExtensions(); } public static function provideRequiredButMissingExtensions() { return [ 'One missing extension' => [ [ 'FakeExtensionForMaintenanceTest' ], '/The "FakeExtensionForMaintenanceTest" extension must be installed.*' . 'Please enable it and then try again/' ], 'Two missing extensions' => [ [ 'FakeExtensionForMaintenanceTest', 'MissingExtensionTest2' ], '/The following extensions must be installed.*FakeExtensionForMaintenanceTest.*MissingExtensionTest2' . '.*Please enable them and then try again/' ], ]; } public function testValidateUserOptionForMissingArguments() { $this->expectCallToFatalError(); $this->expectOutputRegex( '/Test error message/' ); $this->maintenance->validateUserOption( 'Test error message' ); } /** @dataProvider provideInvalidUserOptions */ public function testValidateUserOptionForInvalidUserOption( $options, $expectedOutputRegex ) { $this->expectCallToFatalError(); $this->expectOutputRegex( $expectedOutputRegex ); foreach ( $options as $name => $value ) { $this->maintenance->setOption( $name, $value ); } $this->maintenance->validateUserOption( 'unused' ); } public static function provideInvalidUserOptions() { return [ 'Invalid --user option' => [ [ 'user' => 'Non-existent-test-user' ], '/No such user.*Non-existent-test-user/', ], 'Invalid --userid option' => [ [ 'userid' => 0 ], '/No such user id.*0/' ], ]; } public function testValidateUserOptionForValidUser() { $testUser = $this->getTestUser()->getUserIdentity(); $this->maintenance->setOption( 'userid', $testUser->getId() ); $this->assertTrue( $testUser->equals( $this->maintenance->validateUserOption( "unused" ) ) ); } public function testRunChildForNonExistentClass() { $this->expectCallToFatalError(); $this->expectOutputRegex( '/Cannot spawn child.*NonExistingTestClassForMaintenanceTest/' ); $this->maintenance->runChild( 'NonExistingTestClassForMaintenanceTest' ); } public function testSetAllowUnregisteredOptions() { $this->maintenance->setOption( 'abcdef', 'abc' ); $this->maintenance->setAllowUnregisteredOptions( true ); $this->assertTrue( $this->maintenance->getParameters()->validate() ); $this->maintenance->setAllowUnregisteredOptions( false ); $this->assertFalse( $this->maintenance->getParameters()->validate() ); } /** @dataProvider provideSetBatchSize */ public function testSetBatchSize( $batchSize, $shouldHaveBatchSizeOption ) { $this->maintenance->setBatchSize( $batchSize ); $this->assertSame( $batchSize, $this->maintenance->getBatchSize() ); $this->assertSame( $shouldHaveBatchSizeOption, $this->maintenance->supportsOption( 'batch-size' ) ); } public static function provideSetBatchSize() { return [ 'Batch size as 0' => [ 0, false ], 'Batch size as 150' => [ 150, true ], ]; } public function testPurgeRedundantTextWhenNoPagesExist() { // Regression test for the method breaking if no rows exist in the content_address table. $this->maintenance->purgeRedundantText(); $this->expectOutputRegex( '/0 inactive items found[\s\S]*(?!Deleting)/' ); } public function testDeleteOptionLoop() { $this->maintenance->addOption( 'test-for-deletion', 'testing' ); $this->assertTrue( $this->maintenance->getParameters()->supportsOption( 'test-for-deletion' ) ); $this->maintenance->deleteOption( 'test-for-deletion' ); $this->assertFalse( $this->maintenance->getParameters()->supportsOption( 'test-for-deletion' ) ); } public function testAddDescription() { $this->maintenance->addDescription( 'testing-description abcdef' ); $this->expectCallToFatalError(); $this->expectOutputRegex( '/testing-description abcdef/' ); $this->maintenance->getParameters()->setName( 'test.php' ); $this->maintenance->maybeHelp( true ); } public function testGetArgName() { $this->maintenance->addArg( 'testing', 'test' ); $this->assertSame( 'testing', $this->maintenance->getArgName( 0 ) ); } public function testHasArg() { $this->maintenance->addArg( 'testing', 'test' ); $this->maintenance->setArg( 'testing', 'abc' ); $this->assertTrue( $this->maintenance->hasArg( 0 ) ); $this->assertTrue( $this->maintenance->hasArg( 'testing' ) ); $this->assertSame( [ 'abc' ], $this->maintenance->getArgs() ); } public function testSetName() { $this->maintenance->setName( 'test.php' ); $this->assertSame( 'test.php', $this->maintenance->getName() ); $this->assertSame( 'test.php', $this->maintenance->getParameters()->getName() ); } public function testGetDir() { $this->assertSame( realpath( MW_INSTALL_PATH . '/maintenance' ), realpath( $this->maintenance->getDir() ) ); } /** @dataProvider provideParseIntList */ public function testParseIntList( $text, $expected ) { $this->assertArrayEquals( $expected, $this->maintenance->parseIntList( $text ) ); } public static function provideParseIntList() { return [ 'Integers separated by ","' => [ '1,2,3,3', [ 1, 2, 3, 3 ] ], 'Integers separated by "|"' => [ '1|2|3|4|4', [ 1, 2, 3, 4, 4 ] ], ]; } } PK ! b�;� defines.phpnu �[��� <?php /** * @package Joomla.Site * * @copyright (C) 2005 Open Source Matters, Inc. <https://www.joomla.org> * @license GNU General Public License version 2 or later; see LICENSE.txt */ \defined('_JEXEC') or die; // Define JPATH constants if not defined yet \defined('JPATH_BASE') || \define('JPATH_BASE', \dirname(__DIR__)); \defined('JPATH_ROOT') || \define('JPATH_ROOT', JPATH_BASE); \defined('JPATH_SITE') || \define('JPATH_SITE', JPATH_ROOT); \defined('JPATH_PUBLIC') || \define('JPATH_PUBLIC', JPATH_ROOT); \defined('JPATH_CONFIGURATION') || \define('JPATH_CONFIGURATION', JPATH_ROOT); \defined('JPATH_ADMINISTRATOR') || \define('JPATH_ADMINISTRATOR', JPATH_ROOT . DIRECTORY_SEPARATOR . 'administrator'); \defined('JPATH_LIBRARIES') || \define('JPATH_LIBRARIES', JPATH_ROOT . DIRECTORY_SEPARATOR . 'libraries'); \defined('JPATH_PLUGINS') || \define('JPATH_PLUGINS', JPATH_ROOT . DIRECTORY_SEPARATOR . 'plugins'); \defined('JPATH_INSTALLATION') || \define('JPATH_INSTALLATION', JPATH_ROOT . DIRECTORY_SEPARATOR . 'installation'); \defined('JPATH_THEMES') || \define('JPATH_THEMES', JPATH_BASE . DIRECTORY_SEPARATOR . 'templates'); \defined('JPATH_CACHE') || \define('JPATH_CACHE', JPATH_ADMINISTRATOR . DIRECTORY_SEPARATOR . 'cache'); \defined('JPATH_MANIFESTS') || \define('JPATH_MANIFESTS', JPATH_ADMINISTRATOR . DIRECTORY_SEPARATOR . 'manifests'); \defined('JPATH_API') || \define('JPATH_API', JPATH_ROOT . DIRECTORY_SEPARATOR . 'api'); \defined('JPATH_CLI') || \define('JPATH_CLI', JPATH_ROOT . DIRECTORY_SEPARATOR . 'cli'); PK ! �Y��K K app.phpnu �[��� <?php /** * @package Joomla.Site * * @copyright (C) 2017 Open Source Matters, Inc. <https://www.joomla.org> * @license GNU General Public License version 2 or later; see LICENSE.txt */ \defined('_JEXEC') or die; // Saves the start time and memory usage. $startTime = microtime(1); $startMem = memory_get_usage(); if (file_exists(\dirname(__DIR__) . '/defines.php')) { include_once \dirname(__DIR__) . '/defines.php'; } require_once __DIR__ . '/defines.php'; // Check for presence of vendor dependencies not included in the git repository if (!file_exists(JPATH_LIBRARIES . '/vendor/autoload.php') || !is_dir(JPATH_PUBLIC . '/media/vendor')) { echo file_get_contents(JPATH_ROOT . '/templates/system/build_incomplete.html'); exit; } require_once JPATH_BASE . '/includes/framework.php'; // Set profiler start time and memory usage and mark afterLoad in the profiler. JDEBUG && \Joomla\CMS\Profiler\Profiler::getInstance('Application')->setStart($startTime, $startMem)->mark('afterLoad'); // Boot the DI container $container = \Joomla\CMS\Factory::getContainer(); /* * Alias the session service keys to the web session service as that is the primary session backend for this application * * In addition to aliasing "common" service keys, we also create aliases for the PHP classes to ensure autowiring objects * is supported. This includes aliases for aliased class names, and the keys for aliased class names should be considered * deprecated to be removed when the class name alias is removed as well. */ $container->alias('session.web', 'session.web.site') ->alias('session', 'session.web.site') ->alias('JSession', 'session.web.site') ->alias(\Joomla\CMS\Session\Session::class, 'session.web.site') ->alias(\Joomla\Session\Session::class, 'session.web.site') ->alias(\Joomla\Session\SessionInterface::class, 'session.web.site'); // Instantiate the application. $app = $container->get(\Joomla\CMS\Application\SiteApplication::class); // Set the application as global app \Joomla\CMS\Factory::$application = $app; // Execute the application. $app->execute(); PK ! Y���: : framework.phpnu �[��� <?php /** * @package Joomla.Site * * @copyright (C) 2005 Open Source Matters, Inc. <https://www.joomla.org> * @license GNU General Public License version 2 or later; see LICENSE.txt */ \defined('_JEXEC') or die; use Joomla\CMS\Uri\Uri; use Joomla\CMS\Version; use Joomla\Utilities\IpHelper; // System includes require_once JPATH_LIBRARIES . '/bootstrap.php'; // Installation check, and check on removal of the install directory if ( !file_exists(JPATH_CONFIGURATION . '/configuration.php') || (filesize(JPATH_CONFIGURATION . '/configuration.php') < 10) || (file_exists(JPATH_INSTALLATION . '/index.php') && (false === (new Version())->isInDevelopmentState())) ) { if (!file_exists(JPATH_INSTALLATION . '/index.php')) { echo 'No configuration file found and no installation code available. Exiting...'; exit; } if (JPATH_ROOT === JPATH_PUBLIC) { header('Location: ' . Uri::base() . 'installation/index.php'); exit; } echo 'Installation from a public folder is not supported, revert your Server configuration to point at Joomla\'s root folder to continue.'; exit; } // Pre-Load configuration. Don't remove the Output Buffering due to BOM issues, see JCode 26026 ob_start(); require_once JPATH_CONFIGURATION . '/configuration.php'; ob_end_clean(); // System configuration. $config = new JConfig(); // Set the error_reporting, and adjust a global Error Handler switch ($config->error_reporting) { case 'default': case '-1': break; case 'none': case '0': error_reporting(0); break; case 'simple': error_reporting(E_ERROR | E_WARNING | E_PARSE); ini_set('display_errors', 1); break; case 'maximum': error_reporting(E_ALL); ini_set('display_errors', 1); break; default: error_reporting($config->error_reporting); ini_set('display_errors', 1); break; } if (!\defined('JDEBUG')) { \define('JDEBUG', $config->debug); } // Check deprecation logging if (empty($config->log_deprecated)) { // Reset handler for E_USER_DEPRECATED set_error_handler(null, E_USER_DEPRECATED); } else { // Make sure handler for E_USER_DEPRECATED is registered set_error_handler(['Joomla\CMS\Exception\ExceptionHandler', 'handleUserDeprecatedErrors'], E_USER_DEPRECATED); } if (JDEBUG || $config->error_reporting === 'maximum') { // Set new Exception handler with debug enabled $errorHandler->setExceptionHandler( [ new \Symfony\Component\ErrorHandler\ErrorHandler(null, true), 'renderException', ] ); } /** * Correctly set the allowing of IP Overrides if behind a trusted proxy/load balancer. * * We need to do this as high up the stack as we can, as the default in \Joomla\Utilities\IpHelper is to * $allowIpOverride = true which is the wrong default for a generic site NOT behind a trusted proxy/load balancer. */ if (property_exists($config, 'behind_loadbalancer') && $config->behind_loadbalancer == 1) { // If Joomla is configured to be behind a trusted proxy/load balancer, allow HTTP Headers to override the REMOTE_ADDR IpHelper::setAllowIpOverrides(true); } else { // We disable the allowing of IP overriding using headers by default. IpHelper::setAllowIpOverrides(false); } unset($config); PK ! E�x/', ', PdfHandler.phpnu �Iw�� <?php namespace MediaWiki\Extension\PdfHandler; use File; use ImageHandler; use MediaTransformError; use MediaTransformOutput; use MediaWiki\Context\IContextSource; use MediaWiki\MediaWikiServices; use MediaWiki\PoolCounter\PoolCounterWorkViaCallback; use ThumbnailImage; use TransformParameterError; /** * Copyright © 2007 Martin Seidel (Xarax) <jodeldi@gmx.de> * * Inspired by djvuhandler from Tim Starling * Modified and written by Xarax * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html */ class PdfHandler extends ImageHandler { /** * Keep in sync with pdfhandler.messages in extension.json * * @see getWarningConfig */ private const MESSAGES = [ 'main' => 'pdf-file-page-warning', 'header' => 'pdf-file-page-warning-header', 'info' => 'pdf-file-page-warning-info', 'footer' => 'pdf-file-page-warning-footer', ]; /** * 10MB is considered a large file */ private const LARGE_FILE = 1e7; /** * Key for getHandlerState for value of type PdfImage */ private const STATE_PDF_IMAGE = 'pdfImage'; /** * Key for getHandlerState for dimension info */ private const STATE_DIMENSION_INFO = 'pdfDimensionInfo'; /** * @param File $file * @return bool */ public function mustRender( $file ) { return true; } /** * @param File $file * @return bool */ public function isMultiPage( $file ) { return true; } /** * @param string $name * @param string $value * @return bool */ public function validateParam( $name, $value ) { if ( $name === 'page' && trim( $value ) !== (string)intval( $value ) ) { // Extra junk on the end of page, probably actually a caption // e.g. [[File:Foo.pdf|thumb|Page 3 of the document shows foo]] return false; } if ( in_array( $name, [ 'width', 'height', 'page' ] ) ) { return ( $value > 0 ); } return false; } /** * @param array $params * @return bool|string */ public function makeParamString( $params ) { $page = $params['page'] ?? 1; if ( !isset( $params['width'] ) ) { return false; } return "page{$page}-{$params['width']}px"; } /** * @param string $str * @return array|bool */ public function parseParamString( $str ) { $m = []; if ( preg_match( '/^page(\d+)-(\d+)px$/', $str, $m ) ) { return [ 'width' => $m[2], 'page' => $m[1] ]; } return false; } /** * @param array $params * @return array */ public function getScriptParams( $params ) { return [ 'width' => $params['width'], 'page' => $params['page'], ]; } /** * @return array */ public function getParamMap() { return [ 'img_width' => 'width', 'img_page' => 'page', ]; } /** * @param int $width * @param int $height * @param string $msg * @return MediaTransformError */ protected function doThumbError( $width, $height, $msg ) { return new MediaTransformError( 'thumbnail_error', $width, $height, wfMessage( $msg )->inContentLanguage()->text() ); } /** * @param File $image * @param string $dstPath * @param string $dstUrl * @param array $params * @param int $flags * @return MediaTransformError|MediaTransformOutput|ThumbnailImage|TransformParameterError */ public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) { global $wgPdfProcessor, $wgPdfPostProcessor, $wgPdfHandlerDpi, $wgPdfHandlerJpegQuality; if ( !$this->normaliseParams( $image, $params ) ) { return new TransformParameterError( $params ); } $width = (int)$params['width']; $height = (int)$params['height']; $page = (int)$params['page']; if ( $page > $this->pageCount( $image ) ) { return $this->doThumbError( $width, $height, 'pdf_page_error' ); } if ( $flags & self::TRANSFORM_LATER ) { return new ThumbnailImage( $image, $dstUrl, false, [ 'width' => $width, 'height' => $height, 'page' => $page, ] ); } if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) { return $this->doThumbError( $width, $height, 'thumbnail_dest_directory' ); } // Thumbnail extraction is very inefficient for large files. // Provide a way to pool count limit the number of downloaders. if ( $image->getSize() >= self::LARGE_FILE ) { $work = new PoolCounterWorkViaCallback( 'GetLocalFileCopy', sha1( $image->getName() ), [ 'doWork' => static function () use ( $image ) { return $image->getLocalRefPath(); } ] ); $srcPath = $work->execute(); } else { $srcPath = $image->getLocalRefPath(); } if ( $srcPath === false ) { // could not download original return $this->doThumbError( $width, $height, 'filemissing' ); } $cmd = '(' . wfEscapeShellArg( $wgPdfProcessor, "-sDEVICE=jpeg", "-sOutputFile=-", "-sstdout=%stderr", "-dFirstPage={$page}", "-dLastPage={$page}", "-dSAFER", "-r{$wgPdfHandlerDpi}", // CropBox defines the region that the PDF viewer application is expected to display or print. "-dUseCropBox", "-dBATCH", "-dNOPAUSE", "-q", $srcPath ); $cmd .= " | " . wfEscapeShellArg( $wgPdfPostProcessor, "-depth", "8", "-quality", $wgPdfHandlerJpegQuality, "-resize", (string)$width, "-", $dstPath ); $cmd .= ")"; wfDebug( __METHOD__ . ": $cmd\n" ); $retval = ''; $err = wfShellExecWithStderr( $cmd, $retval ); $removed = $this->removeBadFile( $dstPath, $retval ); if ( $retval != 0 || $removed ) { wfDebugLog( 'thumbnail', sprintf( 'thumbnail failed on %s: error %d "%s" from "%s"', wfHostname(), $retval, trim( $err ), $cmd ) ); return new MediaTransformError( 'thumbnail_error', $width, $height, $err ); } return new ThumbnailImage( $image, $dstUrl, $dstPath, [ 'width' => $width, 'height' => $height, 'page' => $page, ] ); } /** * @param \MediaHandlerState $state * @param string $path * @return PdfImage */ private function getPdfImage( $state, $path ) { $pdfImg = $state->getHandlerState( self::STATE_PDF_IMAGE ); if ( !$pdfImg ) { $pdfImg = new PdfImage( $path ); $state->setHandlerState( self::STATE_PDF_IMAGE, $pdfImg ); } return $pdfImg; } /** * @param \MediaHandlerState $state * @param string $path * @return array|bool */ public function getSizeAndMetadata( $state, $path ) { $metadata = $this->getPdfImage( $state, $path )->retrieveMetaData(); $sizes = PdfImage::getPageSize( $metadata, 1 ); if ( $sizes ) { return $sizes + [ 'metadata' => $metadata ]; } return [ 'metadata' => $metadata ]; } /** * @param string $ext * @param string $mime * @param null $params * @return array */ public function getThumbType( $ext, $mime, $params = null ) { global $wgPdfOutputExtension; static $mime; if ( !isset( $mime ) ) { $magic = MediaWikiServices::getInstance()->getMimeAnalyzer(); $mime = $magic->guessTypesForExtension( $wgPdfOutputExtension ); } return [ $wgPdfOutputExtension, $mime ]; } /** * @param File $file * @return bool|int */ public function isFileMetadataValid( $file ) { $data = $file->getMetadataItems( [ 'mergedMetadata', 'pages' ] ); if ( !isset( $data['pages'] ) ) { return self::METADATA_BAD; } if ( !isset( $data['mergedMetadata'] ) ) { return self::METADATA_COMPATIBLE; } return self::METADATA_GOOD; } /** * @param File $image * @param bool|IContextSource $context Context to use (optional) * @return bool|array */ public function formatMetadata( $image, $context = false ) { $mergedMetadata = $image->getMetadataItem( 'mergedMetadata' ); if ( !is_array( $mergedMetadata ) || !count( $mergedMetadata ) ) { return false; } // Inherited from MediaHandler. return $this->formatMetadataHelper( $mergedMetadata, $context ); } /** @inheritDoc */ protected function formatTag( string $key, $vals, $context = false ) { switch ( $key ) { case 'pdf-Producer': case 'pdf-Version': return htmlspecialchars( $vals ); case 'pdf-PageSize': foreach ( $vals as &$val ) { $val = htmlspecialchars( $val ); } return $vals; case 'pdf-Encrypted': // @todo: The value isn't i18n-ised; should be done here. // For reference, if encrypted this field's value looks like: // "yes (print:yes copy:no change:no addNotes:no)" return htmlspecialchars( $vals ); default: break; } // Use default formatting return false; } /** * @param File $image * @return bool|int */ public function pageCount( File $image ) { $info = $this->getDimensionInfo( $image ); return $info ? $info['pageCount'] : false; } /** * @param File $image * @param int $page * @return array|bool */ public function getPageDimensions( File $image, $page ) { // MW starts pages at 1, as they are stored here $index = $page; $info = $this->getDimensionInfo( $image ); if ( $info && isset( $info['dimensionsByPage'][$index] ) ) { return $info['dimensionsByPage'][$index]; } return false; } /** * @param File $file * @return bool|mixed */ protected function getDimensionInfo( File $file ) { $info = $file->getHandlerState( self::STATE_DIMENSION_INFO ); if ( !$info ) { $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); $info = $cache->getWithSetCallback( $cache->makeKey( 'file-pdf-dimensions', $file->getSha1() ), $cache::TTL_MONTH, static function () use ( $file ) { $data = $file->getMetadataItems( PdfImage::ITEMS_FOR_PAGE_SIZE ); if ( !$data || !isset( $data['Pages'] ) ) { return false; } $dimsByPage = []; $count = intval( $data['Pages'] ); for ( $i = 1; $i <= $count; $i++ ) { $dimsByPage[$i] = PdfImage::getPageSize( $data, $i ); } return [ 'pageCount' => $count, 'dimensionsByPage' => $dimsByPage ]; } ); } $file->setHandlerState( self::STATE_DIMENSION_INFO, $info ); return $info; } /** * @param File $image * @param int $page * @return bool */ public function getPageText( File $image, $page ) { $pageTexts = $image->getMetadataItem( 'text' ); if ( !is_array( $pageTexts ) || !isset( $pageTexts[$page - 1] ) ) { return false; } return $pageTexts[$page - 1]; } /** * Adds a warning about PDFs being potentially dangerous to the file * page. Multiple messages with this base will be used. * @param File $file * @return array */ public function getWarningConfig( $file ) { return [ 'messages' => self::MESSAGES, 'link' => '//www.mediawiki.org/wiki/Special:MyLanguage/Help:Security/PDF_files', 'module' => 'pdfhandler.messages', ]; } public function useSplitMetadata() { return true; } } PK ! z�j�\% \% PdfImage.phpnu �Iw�� <?php /** * * Copyright © 2007 Xarax <jodeldi@gmx.de> * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html */ namespace MediaWiki\Extension\PdfHandler; use BitmapMetadataHandler; use MediaWiki\Logger\LoggerFactory; use MediaWiki\MediaWikiServices; use UtfNormal\Validator; use Wikimedia\XMPReader\Reader as XMPReader; /** * inspired by djvuimage from Brion Vibber * modified and written by xarax */ class PdfImage { /** * @var string */ private $mFilename; public const ITEMS_FOR_PAGE_SIZE = [ 'Pages', 'pages', 'Page size', 'Page rot' ]; /** * @param string $filename */ public function __construct( $filename ) { $this->mFilename = $filename; } /** * @return bool */ public function isValid() { return true; } /** * @param array $data * @param int $page * @return array|bool */ public static function getPageSize( $data, $page ) { global $wgPdfHandlerDpi; if ( isset( $data['pages'][$page]['Page size'] ) ) { $pageSize = $data['pages'][$page]['Page size']; } elseif ( isset( $data['Page size'] ) ) { $pageSize = $data['Page size']; } else { $pageSize = false; } if ( $pageSize ) { if ( isset( $data['pages'][$page]['Page rot'] ) ) { $pageRotation = $data['pages'][$page]['Page rot']; } elseif ( isset( $data['Page rot'] ) ) { $pageRotation = $data['Page rot']; } else { $pageRotation = 0; } $size = explode( 'x', $pageSize, 2 ); $width = intval( (int)trim( $size[0] ) / 72 * $wgPdfHandlerDpi ); $height = explode( ' ', trim( $size[1] ), 2 ); $height = intval( (int)trim( $height[0] ) / 72 * $wgPdfHandlerDpi ); if ( ( $pageRotation / 90 ) & 1 ) { // Swap width and height for landscape pages $temp = $width; $width = $height; $height = $temp; } return [ 'width' => $width, 'height' => $height ]; } return false; } /** * @return array */ public function retrieveMetaData(): array { global $wgPdfInfo, $wgPdftoText, $wgShellboxShell; $command = MediaWikiServices::getInstance()->getShellCommandFactory() ->createBoxed( 'pdfhandler' ) ->disableNetwork() ->firejailDefaultSeccomp() ->routeName( 'pdfhandler-metadata' ); $result = $command ->params( $wgShellboxShell, 'scripts/retrieveMetaData.sh' ) ->inputFileFromFile( 'scripts/retrieveMetaData.sh', __DIR__ . '/../scripts/retrieveMetaData.sh' ) ->inputFileFromFile( 'file.pdf', $this->mFilename ) ->outputFileToString( 'meta' ) ->outputFileToString( 'pages' ) ->outputFileToString( 'text' ) ->outputFileToString( 'text_exit_code' ) ->environment( [ 'PDFHANDLER_INFO' => $wgPdfInfo, 'PDFHANDLER_TOTEXT' => $wgPdftoText, ] ) ->execute(); // Record in statsd MediaWikiServices::getInstance()->getStatsFactory() ->getCounter( 'pdfhandler_shell_retrievemetadata_total' ) ->copyToStatsdAt( 'pdfhandler.shell.retrieve_meta_data' ) ->increment(); // Metadata retrieval is allowed to fail, but we'd like to know why if ( $result->getExitCode() != 0 ) { wfDebug( __METHOD__ . ': retrieveMetaData.sh' . "\n\nExitcode: " . $result->getExitCode() . "\n\n" . $result->getStderr() ); } $resultMeta = $result->getFileContents( 'meta' ); $resultPages = $result->getFileContents( 'pages' ); if ( $resultMeta !== null || $resultPages !== null ) { $data = $this->convertDumpToArray( $resultMeta ?? '', $resultPages ?? '' ); } else { $data = []; } // Read text layer $retval = $result->wasReceived( 'text_exit_code' ) ? (int)trim( $result->getFileContents( 'text_exit_code' ) ) : 1; $txt = $result->getFileContents( 'text' ); if ( $retval == 0 && strlen( $txt ) ) { $txt = str_replace( "\r\n", "\n", $txt ); $pages = explode( "\f", $txt ); foreach ( $pages as $page => $pageText ) { // Get rid of invalid UTF-8, strip control characters // Note we need to do this per page, as \f page feed would be stripped. $pages[$page] = Validator::cleanUp( $pageText ); } $data['text'] = $pages; } return $data; } /** * @param string $metaDump * @param string $infoDump * @return array */ protected function convertDumpToArray( $metaDump, $infoDump ): array { if ( strval( $infoDump ) === '' ) { return []; } $lines = explode( "\n", $infoDump ); $data = []; // Metadata is always the last item, and spans multiple lines. $inMetadata = false; // Basically this loop will go through each line, splitting key value // pairs on the colon, until it gets to a "Metadata:\n" at which point // it will gather all remaining lines into the xmp key. foreach ( $lines as $line ) { if ( $inMetadata ) { // Handle XMP differently due to difference in line break $data['xmp'] .= "\n$line"; continue; } $bits = explode( ':', $line, 2 ); if ( count( $bits ) > 1 ) { $key = trim( $bits[0] ); if ( $key === 'Metadata' ) { $inMetadata = true; $data['xmp'] = ''; continue; } $value = trim( $bits[1] ); $matches = []; // "Page xx rot" will be in poppler 0.20's pdfinfo output // See https://bugs.freedesktop.org/show_bug.cgi?id=41867 if ( preg_match( '/^Page +(\d+) (size|rot)$/', $key, $matches ) ) { $data['pages'][$matches[1]][$matches[2] == 'size' ? 'Page size' : 'Page rot'] = $value; } else { $data[$key] = $value; } } } $metaDump = trim( $metaDump ); if ( $metaDump !== '' ) { $data['xmp'] = $metaDump; } return $this->postProcessDump( $data ); } /** * Postprocess the metadata (convert xmp into useful form, etc) * * This is used to generate the metadata table at the bottom * of the image description page. * * @param array $data metadata * @return array post-processed metadata */ protected function postProcessDump( array $data ) { $meta = new BitmapMetadataHandler(); $items = []; foreach ( $data as $key => $val ) { switch ( $key ) { case 'Title': $items['ObjectName'] = $val; break; case 'Subject': $items['ImageDescription'] = $val; break; case 'Keywords': // Sometimes we have empty keywords. This seems // to be a product of how pdfinfo deals with keywords // with spaces in them. Filter such empty keywords $keyList = array_filter( explode( ' ', $val ) ); if ( count( $keyList ) > 0 ) { $items['Keywords'] = $keyList; } break; case 'Author': $items['Artist'] = $val; break; case 'Creator': // Program used to create file. // Different from program used to convert to pdf. $items['Software'] = $val; break; case 'Producer': // Conversion program $items['pdf-Producer'] = $val; break; case 'ModTime': $timestamp = wfTimestamp( TS_EXIF, $val ); if ( $timestamp ) { // 'if' is just paranoia $items['DateTime'] = $timestamp; } break; case 'CreationTime': $timestamp = wfTimestamp( TS_EXIF, $val ); if ( $timestamp ) { $items['DateTimeDigitized'] = $timestamp; } break; // These last two (version and encryption) I was unsure // if we should include in the table, since they aren't // all that useful to editors. I leaned on the side // of including. However not including if file // is optimized/linearized since that is really useless // to an editor. case 'PDF version': $items['pdf-Version'] = $val; break; case 'Encrypted': $items['pdf-Encrypted'] = $val; break; // Note 'pages' and 'Pages' are different keys (!) case 'pages': // A pdf document can have multiple sized pages in it. // (However 95% of the time, all pages are the same size) // get a list of all the unique page sizes in document. // This doesn't do anything with rotation as of yet, // mostly because I am unsure of what a good way to // present that information to the user would be. $pageSizes = []; foreach ( $val as $page ) { if ( isset( $page['Page size'] ) ) { $pageSizes[$page['Page size']] = true; } } $pageSizeArray = array_keys( $pageSizes ); if ( count( $pageSizeArray ) > 0 ) { $items['pdf-PageSize'] = $pageSizeArray; } break; } } $meta->addMetadata( $items, 'native' ); if ( isset( $data['xmp'] ) && XMPReader::isSupported() ) { // @todo: This only handles generic xmp properties. Would be improved // by handling pdf xmp properties (pdf and pdfx) via a hook. $xmp = new XMPReader( LoggerFactory::getInstance( 'XMP' ) ); $xmp->parse( $data['xmp'] ); $xmpRes = $xmp->getResults(); foreach ( $xmpRes as $type => $xmpSection ) { $meta->addMetadata( $xmpSection, $type ); } } unset( $data['xmp'] ); $data['mergedMetadata'] = $meta->getMetadataArray(); return $data; } } PK ! us� � Poem.phpnu �Iw�� <?php namespace MediaWiki\Extension\Poem; use MediaWiki\Hook\ParserFirstCallInitHook; use MediaWiki\Html\Html; use MediaWiki\Parser\Parser; use MediaWiki\Parser\PPFrame; use MediaWiki\Parser\Sanitizer; /** * This class handles formatting poems in WikiText, specifically anything within * <poem></poem> tags. * * @license CC0-1.0 * @author Nikola Smolenski <smolensk@eunet.yu> */ class Poem implements ParserFirstCallInitHook { /** * Bind the renderPoem function to the <poem> tag * @param Parser $parser */ public function onParserFirstCallInit( $parser ) { $parser->setHook( 'poem', [ $this, 'renderPoem' ] ); } /** * Parse the text into proper poem format * @param string|null $in The text inside the poem tag * @param string[] $param * @param Parser $parser * @param PPFrame $frame * @return string */ public function renderPoem( $in, array $param, Parser $parser, PPFrame $frame ) { // using newlines in the text will cause the parser to add <p> tags, // which may not be desired in some cases $newline = isset( $param['compact'] ) ? '' : "\n"; $tag = $parser->insertStripItem( "<br />" ); // replace colons with indented spans $text = preg_replace_callback( '/^(:++)(.+)$/m', static function ( array $matches ) { $indentation = strlen( $matches[1] ) . 'em'; return Html::rawElement( 'span', [ 'class' => 'mw-poem-indented', 'style' => 'display: inline-block; ' . "margin-inline-start: $indentation;", ], $matches[2] ); }, $in ?? '' ); // replace newlines with <br /> tags unless they are at the beginning or end // of the poem, or would directly follow exactly 4 dashes. See Parser::internalParse() for // the exact syntax for horizontal rules. $text = preg_replace( [ '/^\n/', '/\n$/D', '/(?<!^----)\n/m' ], [ "", "", "$tag\n" ], $text ); // replace spaces at the beginning of a line with non-breaking spaces $text = preg_replace_callback( '/^ +/m', static function ( array $matches ) { return str_repeat( ' ', strlen( $matches[0] ) ); }, $text ); $text = $parser->recursiveTagParse( $text, $frame ); // Because of limitations of the regular expression above, horizontal rules with more than 4 // dashes still need special handling. $text = str_replace( '<hr />' . $tag, '<hr />', $text ); $attribs = Sanitizer::validateTagAttributes( $param, 'div' ); // Wrap output in a <div> with "poem" class. if ( isset( $attribs['class'] ) ) { $attribs['class'] = 'poem ' . $attribs['class']; } else { $attribs['class'] = 'poem'; } return Html::rawElement( 'div', $attribs, $newline . trim( $text ) . $newline ); } } PK ! Qؗ�N N Parsoid/PoemProcessor.phpnu �Iw�� <?php declare( strict_types = 1 ); namespace MediaWiki\Extension\Poem\Parsoid; use Wikimedia\Parsoid\DOM\Element; use Wikimedia\Parsoid\DOM\Node; use Wikimedia\Parsoid\DOM\Text; use Wikimedia\Parsoid\Ext\DOMProcessor; use Wikimedia\Parsoid\Ext\DOMUtils; use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI; class PoemProcessor extends DOMProcessor { /** * @inheritDoc */ public function wtPostprocess( ParsoidExtensionAPI $extApi, Node $node, array $options ): void { $c = $node->firstChild; while ( $c ) { if ( $c instanceof Element ) { if ( DOMUtils::hasTypeOf( $c, 'mw:Extension/poem' ) ) { // Replace newlines found in <nowiki> fragment with <br/>s self::processNowikis( $c ); } else { $this->wtPostprocess( $extApi, $c, $options ); } } $c = $c->nextSibling; } } private function processNowikis( Element $node ): void { $doc = $node->ownerDocument; $c = $node->firstChild; while ( $c ) { if ( !$c instanceof Element ) { $c = $c->nextSibling; continue; } if ( !DOMUtils::hasTypeOf( $c, 'mw:Nowiki' ) ) { self::processNowikis( $c ); $c = $c->nextSibling; continue; } // Replace the nowiki's text node with a combination // of content and <br/>s. Take care to deal with // entities that are still entity-wrapped (!!). $cc = $c->firstChild; while ( $cc ) { $next = $cc->nextSibling; if ( $cc instanceof Text ) { $pieces = preg_split( '/\n/', $cc->nodeValue ?? '' ); $n = count( $pieces ); $nl = ''; for ( $i = 0; $i < $n; $i++ ) { $p = $pieces[$i]; $c->insertBefore( $doc->createTextNode( $nl . $p ), $cc ); if ( $i < $n - 1 ) { $c->insertBefore( $doc->createElement( 'br' ), $cc ); $nl = "\n"; } } $c->removeChild( $cc ); } $cc = $next; } $c = $c->nextSibling; } } } PK ! %���% % Parsoid/Poem.phpnu �Iw�� <?php declare( strict_types = 1 ); namespace MediaWiki\Extension\Poem\Parsoid; use Wikimedia\Parsoid\DOM\DocumentFragment; use Wikimedia\Parsoid\Ext\ExtensionTagHandler; use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI; use Wikimedia\Parsoid\Ext\PHPUtils; use Wikimedia\Parsoid\Utils\DOMCompat; class Poem extends ExtensionTagHandler { /** @inheritDoc */ public function sourceToDom( ParsoidExtensionAPI $extApi, string $content, array $extArgs ): DocumentFragment { /* * Transform wikitext found in <poem>...</poem> * 1. Strip leading & trailing newlines * 2. Suppress indent-pre by replacing leading space with * 3. Replace colons with <span class='...' style='...'>...</span> * 4. Add <br/> for newlines except (a) in nowikis (b) after ---- */ if ( strlen( $content ) > 0 ) { // 1. above $content = PHPUtils::stripPrefix( $content, "\n" ); $content = PHPUtils::stripSuffix( $content, "\n" ); // 2. above $content = preg_replace( '/^ /m', ' ', $content ); // 3. above $contentArray = explode( "\n", $content ); $contentMap = array_map( static function ( $line ) use ( $extApi ) { $i = 0; $lineLength = strlen( $line ); while ( $i < $lineLength && $line[$i] === ':' ) { $i++; } if ( $i > 0 && $i < $lineLength ) { $domFragment = $extApi->htmlToDom( '' ); $doc = $domFragment->ownerDocument; $span = $doc->createElement( 'span' ); $span->setAttribute( 'class', 'mw-poem-indented' ); $span->setAttribute( 'style', 'display: inline-block; margin-inline-start: ' . $i . 'em;' ); // $line isn't an HTML text node, it's wikitext that will be passed to extTagToDOM return substr( DOMCompat::getOuterHTML( $span ), 0, -7 ) . ltrim( $line, ':' ) . '</span>'; } else { return $line; } }, $contentArray ); // TODO: Use faster? preg_replace $content = implode( "\n", $contentMap ); // 4. above // Split on <nowiki>..</nowiki> fragments. // Process newlines inside nowikis in a post-processing pass. // If <br/>s are added here, Parsoid will escape them to plaintext. $splitContent = preg_split( '/(<nowiki>[\s\S]*?<\/nowiki>)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE ); $content = implode( '', array_map( static function ( $p, $i ) { if ( $i % 2 === 1 ) { return $p; } // This is a hack that exploits the fact that </poem> // cannot show up in the extension's content. return preg_replace( '/^(-+)<\/poem>/m', "\$1\n", preg_replace( '/\n/m', "<br/>\n", preg_replace( '/(^----+)\n/m', '$1</poem>', $p ) ) ); }, $splitContent, range( 0, count( $splitContent ) - 1 ) ) ); } // Add the 'poem' class to the 'class' attribute, or if not found, add it $value = $extApi->findAndUpdateArg( $extArgs, 'class', static function ( string $value ) { return strlen( $value ) ? "poem {$value}" : 'poem'; } ); if ( !$value ) { $extApi->addNewArg( $extArgs, 'class', 'poem' ); } return $extApi->extTagToDOM( $extArgs, $content, [ 'wrapperTag' => 'div', 'parseOpts' => [ 'extTag' => 'poem' ], // Create new frame, because $content doesn't literally appear in // the parent frame's sourceText (our copy has been munged) 'processInNewFrame' => true, // We've shifted the content around quite a bit when we preprocessed // it. In the future if we wanted to enable selser inside the <poem> // body we should create a proper offset map and then apply it to the // result after the parse, like we do in the Gallery extension. // But for now, since we don't selser the contents, just strip the // DSR info so it doesn't cause problems/confusion with unicode // offset conversion (and so it's clear you can't selser what we're // currently emitting). 'clearDSROffsets' => true ] ); } } PK ! H���� � SpamBlacklist.phpnu �Iw�� <?php namespace MediaWiki\Extension\SpamBlacklist; use LogPage; use ManualLogEntry; use MediaWiki\CheckUser\Hooks as CUHooks; use MediaWiki\Context\RequestContext; use MediaWiki\ExternalLinks\ExternalLinksLookup; use MediaWiki\MediaWikiServices; use MediaWiki\Registration\ExtensionRegistry; use MediaWiki\Title\Title; use MediaWiki\User\User; use Wikimedia\AtEase\AtEase; use Wikimedia\Rdbms\Database; class SpamBlacklist extends BaseBlacklist { private const STASH_TTL = 180; private const STASH_AGE_DYING = 150; /** * Returns the code for the blacklist implementation * * @return string */ protected function getBlacklistType() { return 'spam'; } /** * Apply some basic anti-spoofing to the links before they get filtered, * see @bug 12896 * * @param string $text * * @return string */ protected function antiSpoof( $text ) { $text = str_replace( '.', '.', $text ); return $text; } /** * @param string[] $links An array of links to check against the blacklist * @param ?Title $title The title of the page to which the filter shall be applied. * This is used to load the old links already on the page, so * the filter is only applied to links that got added. If not given, * the filter is applied to all $links. * @param User $user Relevant user * @param bool $preventLog Whether to prevent logging of hits. Set to true when * the action is testing the links rather than attempting to save them * (e.g. the API spamblacklist action) * @param string $mode Either 'check' or 'stash' * * @return string[]|bool Matched text(s) if the edit should not be allowed; false otherwise */ public function filter( array $links, ?Title $title, User $user, $preventLog = false, $mode = 'check' ) { $services = MediaWikiServices::getInstance(); $statsd = $services->getStatsdDataFactory(); $cache = $services->getObjectCacheFactory()->getLocalClusterInstance(); if ( !$links ) { return false; } sort( $links ); $key = $cache->makeKey( 'blacklist', $this->getBlacklistType(), 'pass', sha1( implode( "\n", $links ) ), md5( (string)$title ) ); // Skip blacklist checks if nothing matched during edit stashing... $knownNonMatchAsOf = $cache->get( $key ); if ( $mode === 'check' ) { if ( $knownNonMatchAsOf ) { $statsd->increment( 'spamblacklist.check-stash.hit' ); return false; } else { $statsd->increment( 'spamblacklist.check-stash.miss' ); } } elseif ( $mode === 'stash' ) { if ( $knownNonMatchAsOf && ( time() - $knownNonMatchAsOf ) < self::STASH_AGE_DYING ) { // OK; not about to expire soon return false; } } $blacklists = $this->getBlacklists(); $whitelists = $this->getWhitelists(); if ( count( $blacklists ) ) { // poor man's anti-spoof, see bug 12896 $newLinks = array_map( [ $this, 'antiSpoof' ], $links ); $oldLinks = []; if ( $title !== null ) { $oldLinks = $this->getCurrentLinks( $title ); $addedLinks = array_diff( $newLinks, $oldLinks ); } else { // can't load old links, so treat all links as added. $addedLinks = $newLinks; } wfDebugLog( 'SpamBlacklist', "Old URLs: " . implode( ', ', $oldLinks ) ); wfDebugLog( 'SpamBlacklist', "New URLs: " . implode( ', ', $newLinks ) ); wfDebugLog( 'SpamBlacklist', "Added URLs: " . implode( ', ', $addedLinks ) ); $links = implode( "\n", $addedLinks ); # Strip whitelisted URLs from the match if ( is_array( $whitelists ) ) { wfDebugLog( 'SpamBlacklist', "Excluding whitelisted URLs from " . count( $whitelists ) . " regexes: " . implode( ', ', $whitelists ) . "\n" ); foreach ( $whitelists as $regex ) { AtEase::suppressWarnings(); $newLinks = preg_replace( $regex, '', $links ); AtEase::restoreWarnings(); if ( is_string( $newLinks ) ) { // If there wasn't a regex error, strip the matching URLs $links = $newLinks; } } } # Do the match wfDebugLog( 'SpamBlacklist', "Checking text against " . count( $blacklists ) . " regexes: " . implode( ', ', $blacklists ) . "\n" ); $retVal = false; foreach ( $blacklists as $regex ) { AtEase::suppressWarnings(); $matches = []; $check = ( preg_match_all( $regex, $links, $matches ) > 0 ); AtEase::restoreWarnings(); if ( $check ) { wfDebugLog( 'SpamBlacklist', "Match!\n" ); $ip = RequestContext::getMain()->getRequest()->getIP(); $fullUrls = []; $fullLineRegex = substr( $regex, 0, strrpos( $regex, '/' ) ) . '.*/Sim'; preg_match_all( $fullLineRegex, $links, $fullUrls ); $imploded = implode( ' ', $fullUrls[0] ); wfDebugLog( 'SpamBlacklistHit', "$ip caught submitting spam: $imploded\n" ); if ( !$preventLog && $title ) { $this->logFilterHit( $user, $title, $imploded ); } if ( $retVal === false ) { $retVal = []; } $retVal = array_merge( $retVal, $fullUrls[1] ); } } if ( is_array( $retVal ) ) { $retVal = array_unique( $retVal ); } } else { $retVal = false; } if ( $retVal === false ) { // Cache the typical negative results $cache->set( $key, time(), self::STASH_TTL ); if ( $mode === 'stash' ) { $statsd->increment( 'spamblacklist.check-stash.store' ); } } return $retVal; } /** * Look up the links currently in the article, so we can * ignore them on a second run. * * WARNING: I can add more *of the same link* with no problem here. * @param Title $title * @return array */ public function getCurrentLinks( Title $title ) { $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); $fname = __METHOD__; return $cache->getWithSetCallback( // Key is warmed via warmCachesForFilter() from ApiStashEdit $cache->makeKey( 'external-link-list', $title->getLatestRevID() ), $cache::TTL_MINUTE, static function ( $oldValue, &$ttl, array &$setOpts ) use ( $title, $fname ) { $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); $setOpts += Database::getCacheSetOptions( $dbr ); return ExternalLinksLookup::getExternalLinksForPage( $title->getArticleID(), $dbr, $fname ); } ); } public function warmCachesForFilter( Title $title, array $entries, User $user ) { $this->filter( $entries, $title, $user, // no logging true, 'stash' ); } /** * Returns the start of the regex for matches * * @return string */ public function getRegexStart() { return '/(?:https?:)?\/\/+[a-z0-9_\-.]*('; } /** * Returns the end of the regex for matches * * @param int $batchSize * @return string */ public function getRegexEnd( $batchSize ) { return ')' . parent::getRegexEnd( $batchSize ); } /** * Logs the filter hit to Special:Log if * $wgLogSpamBlacklistHits is enabled. * * @param User $user * @param Title $title * @param string $url URL that the user attempted to add */ public function logFilterHit( User $user, $title, $url ) { global $wgLogSpamBlacklistHits; if ( $wgLogSpamBlacklistHits ) { $logEntry = new ManualLogEntry( 'spamblacklist', 'hit' ); $logEntry->setPerformer( $user ); $logEntry->setTarget( $title ); $logEntry->setParameters( [ '4::url' => $url, ] ); $logid = $logEntry->insert(); $log = new LogPage( 'spamblacklist' ); if ( $log->isRestricted() ) { // Make sure checkusers can see this action if the log is restricted // (which is the default) if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) ) { $rc = $logEntry->getRecentChange( $logid ); CUHooks::updateCheckUserData( $rc ); } } else { // If the log is unrestricted, publish normally to RC, // which will also update checkuser $logEntry->publish( $logid, "rc" ); } } } } PK ! �J�� ApiSpamBlacklist.phpnu �Iw�� <?php /** * SpamBlacklist extension API * * Copyright © 2013 Wikimedia Foundation * Based on code by Ian Baker, Victor Vasiliev, Bryan Tong Minh, Roan Kattouw, * Alex Z., and Jackmcbarn * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html */ namespace MediaWiki\Extension\SpamBlacklist; use MediaWiki\Api\ApiBase; use MediaWiki\Api\ApiResult; use Wikimedia\ParamValidator\ParamValidator; /** * Query module check a URL against the blacklist * * @ingroup API * @ingroup Extensions */ class ApiSpamBlacklist extends ApiBase { public function execute() { $params = $this->extractRequestParams(); $matches = BaseBlacklist::getSpamBlacklist()->filter( $params['url'], null, $this->getUser(), true ); $res = $this->getResult(); if ( $matches !== false ) { // this url is blacklisted. $res->addValue( 'spamblacklist', 'result', 'blacklisted' ); ApiResult::setIndexedTagName( $matches, 'match' ); $res->addValue( 'spamblacklist', 'matches', $matches ); } else { // not blacklisted $res->addValue( 'spamblacklist', 'result', 'ok' ); } } public function getAllowedParams() { return [ 'url' => [ ParamValidator::PARAM_REQUIRED => true, ParamValidator::PARAM_ISMULTI => true, ] ]; } /** * @see ApiBase::getExamplesMessages() * @return array */ protected function getExamplesMessages() { return [ 'action=spamblacklist&url=http://www.example.com/|http://www.example.org/' => 'apihelp-spamblacklist-example-1', ]; } public function getHelpUrls() { return [ 'https://www.mediawiki.org/wiki/Extension:SpamBlacklist/API' ]; } } PK ! ���E E EmailBlacklist.phpnu �Iw�� <?php namespace MediaWiki\Extension\SpamBlacklist; use LogicException; use MediaWiki\Title\Title; use MediaWiki\User\User; use Wikimedia\AtEase\AtEase; /** * Email Blacklisting */ class EmailBlacklist extends BaseBlacklist { /** * @inheritDoc * @suppress PhanPluginNeverReturnMethod LSP/ISP violation */ public function filter( array $links, ?Title $title, User $user, $preventLog = false ) { throw new LogicException( __CLASS__ . ' cannot be used to filter links.' ); } /** * Returns the code for the blacklist implementation * * @return string */ protected function getBlacklistType() { return 'email'; } /** * Checks a User object for a blacklisted email address * * @param User $user * @return bool True on valid email */ public function checkUser( User $user ) { $blacklists = $this->getBlacklists(); $whitelists = $this->getWhitelists(); // The email to check $email = $user->getEmail(); if ( !count( $blacklists ) ) { // Nothing to check return true; } // Check for whitelisted email addresses if ( is_array( $whitelists ) ) { wfDebugLog( 'SpamBlacklist', "Excluding whitelisted email addresses from " . count( $whitelists ) . " regexes: " . implode( ', ', $whitelists ) . "\n" ); foreach ( $whitelists as $regex ) { AtEase::suppressWarnings(); $match = preg_match( $regex, $email ); AtEase::restoreWarnings(); if ( $match ) { // Whitelisted email return true; } } } # Do the match wfDebugLog( 'SpamBlacklist', "Checking e-mail address against " . count( $blacklists ) . " regexes: " . implode( ', ', $blacklists ) . "\n" ); foreach ( $blacklists as $regex ) { AtEase::suppressWarnings(); $match = preg_match( $regex, $email ); AtEase::restoreWarnings(); if ( $match ) { return false; } } return true; } } PK ! +�,� � SpamBlacklistLogFormatter.phpnu �Iw�� <?php namespace MediaWiki\Extension\SpamBlacklist; use LogFormatter; use MediaWiki\Message\Message; class SpamBlacklistLogFormatter extends LogFormatter { /** * @return array * @suppress SecurityCheck-DoubleEscaped Known taint-check bug */ protected function getMessageParameters() { $params = parent::getMessageParameters(); $params[3] = Message::rawParam( htmlspecialchars( $params[3] ) ); return $params; } } PK ! ���� � * SpamBlacklistPreAuthenticationProvider.phpnu �Iw�� <?php namespace MediaWiki\Extension\SpamBlacklist; use MediaWiki\Auth\AbstractPreAuthenticationProvider; use StatusValue; class SpamBlacklistPreAuthenticationProvider extends AbstractPreAuthenticationProvider { public function testForAccountCreation( $user, $creator, array $reqs ) { $blacklist = BaseBlacklist::getEmailBlacklist(); if ( $blacklist->checkUser( $user ) ) { return StatusValue::newGood(); } return StatusValue::newFatal( 'spam-blacklisted-email-signup' ); } } PK ! �c�VJ/ J/ BaseBlacklist.phpnu �Iw�� <?php namespace MediaWiki\Extension\SpamBlacklist; use InvalidArgumentException; use MediaWiki\Content\TextContent; use MediaWiki\MediaWikiServices; use MediaWiki\Revision\SlotRecord; use MediaWiki\Title\Title; use MediaWiki\User\User; /** * Base class for different kinds of blacklists */ abstract class BaseBlacklist { /** * Array of blacklist sources * * @var string[] */ public $files = []; /** * Array containing regexes to test against * * @var string[]|false */ protected $regexes = false; /** * Chance of receiving a warning when the filter is hit * * @var int */ public $warningChance = 100; /** * @var int */ public $warningTime = 600; /** * @var int */ public $expiryTime = 900; /** * Array containing blacklists that extend BaseBlacklist * * @var string[] */ private static $blacklistTypes = [ 'spam' => SpamBlacklist::class, 'email' => EmailBlacklist::class, ]; /** * Array of blacklist instances * * @var self[] */ private static $instances = []; /** * @param array $settings */ public function __construct( $settings = [] ) { foreach ( $settings as $name => $value ) { $this->$name = $value; } } /** * @param array $links * @param ?Title $title * @param User $user * @param bool $preventLog * @return mixed */ abstract public function filter( array $links, ?Title $title, User $user, $preventLog = false ); /** * Adds a blacklist class to the registry * * @param string $type * @param string $class */ public static function addBlacklistType( $type, $class ) { self::$blacklistTypes[$type] = $class; } /** * Return the array of blacklist types currently defined * * @return string[] */ public static function getBlacklistTypes() { return self::$blacklistTypes; } /** * @return SpamBlacklist */ public static function getSpamBlacklist() { // @phan-suppress-next-line PhanTypeMismatchReturnSuperType return self::getInstance( 'spam' ); } /** * @return EmailBlacklist */ public static function getEmailBlacklist() { // @phan-suppress-next-line PhanTypeMismatchReturnSuperType return self::getInstance( 'email' ); } /** * Returns an instance of the given blacklist * * @deprecated Use getSpamBlacklist() or getEmailBlacklist() instead * @param string $type Code for the blacklist * @return BaseBlacklist */ public static function getInstance( $type ) { if ( !isset( self::$blacklistTypes[$type] ) ) { throw new InvalidArgumentException( "Invalid blacklist type '$type' passed to " . __METHOD__ ); } if ( !isset( self::$instances[$type] ) ) { global $wgBlacklistSettings; // Prevent notices if ( !isset( $wgBlacklistSettings[$type] ) ) { $wgBlacklistSettings[$type] = []; } $class = self::$blacklistTypes[$type]; self::$instances[$type] = new $class( $wgBlacklistSettings[$type] ); } return self::$instances[$type]; } /** * Clear instance cache. For use during testing. */ public static function clearInstanceCache() { self::$instances = []; } /** * Returns the code for the blacklist implementation * * @return string */ abstract protected function getBlacklistType(); /** * Check if the given local page title is a spam regex source. * * @param Title $title * @return bool */ public static function isLocalSource( Title $title ) { global $wgDBname, $wgBlacklistSettings; if ( $title->inNamespace( NS_MEDIAWIKI ) ) { $sources = []; foreach ( self::$blacklistTypes as $type => $class ) { // For the built in types, this results in the use of: // spam-blacklist, spam-whitelist // email-blacklist, email-whitelist $type = ucfirst( $type ); $sources[] = "$type-blacklist"; $sources[] = "$type-whitelist"; } if ( in_array( $title->getDBkey(), $sources ) ) { return true; } } $thisHttp = wfExpandUrl( $title->getFullUrl( 'action=raw' ), PROTO_HTTP ); $thisHttpRegex = '/^' . preg_quote( $thisHttp, '/' ) . '(?:&.*)?$/'; $files = []; foreach ( self::$blacklistTypes as $type => $class ) { if ( isset( $wgBlacklistSettings[$type]['files'] ) ) { $files += $wgBlacklistSettings[$type]['files']; } } foreach ( $files as $fileName ) { $matches = []; if ( preg_match( '/^DB: (\w*) (.*)$/', $fileName, $matches ) ) { if ( $wgDBname === $matches[1] && $matches[2] === $title->getPrefixedDbKey() ) { // Local DB fetch of this page... return true; } } elseif ( preg_match( $thisHttpRegex, $fileName ) ) { // Raw view of this page return true; } } return false; } /** * Returns the type of blacklist from the given title * * @todo building a regex for this is pretty overkill * @param Title $title * @return bool|string */ public static function getTypeFromTitle( Title $title ) { $contLang = MediaWikiServices::getInstance()->getContentLanguage(); $types = array_map( [ $contLang, 'ucfirst' ], array_keys( self::$blacklistTypes ) ); $regex = '/(' . implode( '|', $types ) . ')-(?:blacklist|whitelist)/'; if ( preg_match( $regex, $title->getDBkey(), $m ) ) { return strtolower( $m[1] ); } return false; } /** * Fetch local and (possibly cached) remote blacklists. * Will be cached locally across multiple invocations. * @return string[] set of regular expressions, potentially empty. */ public function getBlacklists() { if ( $this->regexes === false ) { $this->regexes = array_merge( $this->getLocalBlacklists(), $this->getSharedBlacklists() ); } return $this->regexes; } /** * Returns the local blacklist * * @return string[] Regular expressions */ public function getLocalBlacklists() { $type = $this->getBlacklistType(); $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); return $cache->getWithSetCallback( $cache->makeKey( 'spamblacklist', $type, 'blacklist-regex' ), $this->expiryTime, function () use ( $type ) { return SpamRegexBatch::regexesFromMessage( "{$type}-blacklist", $this ); } ); } /** * Returns the (local) whitelist * * @return string[] Regular expressions */ public function getWhitelists() { $type = $this->getBlacklistType(); $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); return $cache->getWithSetCallback( $cache->makeKey( 'spamblacklist', $type, 'whitelist-regex' ), $this->expiryTime, function () use ( $type ) { return SpamRegexBatch::regexesFromMessage( "{$type}-whitelist", $this ); } ); } /** * Fetch (possibly cached) remote blacklists. * @return array */ private function getSharedBlacklists() { $listType = $this->getBlacklistType(); wfDebugLog( 'SpamBlacklist', "Loading $listType regex..." ); if ( !$this->files ) { # No lists wfDebugLog( 'SpamBlacklist', "no files specified\n" ); return []; } if ( defined( 'MW_PHPUNIT_TEST' ) ) { wfDebugLog( 'SpamBlacklist', 'remote loading disabled during PHPUnit test' ); return []; } $miss = false; $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); $regexes = $cache->getWithSetCallback( // This used to be cached per-site, but that could be bad on a shared // server where not all wikis have the same configuration. $cache->makeKey( 'spamblacklist', $listType, 'shared-blacklist-regex' ), $this->expiryTime, function () use ( &$miss ) { $miss = true; return $this->buildSharedBlacklists(); } ); if ( !$miss ) { wfDebugLog( 'SpamBlacklist', "Got shared spam regexes from cache\n" ); } return $regexes; } /** * Clear all primary blacklist cache keys */ public function clearCache() { $listType = $this->getBlacklistType(); $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); $cache->delete( $cache->makeKey( 'spamblacklist', $listType, 'shared-blacklist-regex' ) ); $cache->delete( $cache->makeKey( 'spamblacklist', $listType, 'blacklist-regex' ) ); $cache->delete( $cache->makeKey( 'spamblacklist', $listType, 'whitelist-regex' ) ); wfDebugLog( 'SpamBlacklist', "$listType blacklist local cache cleared.\n" ); } private function buildSharedBlacklists() { $regexes = []; $listType = $this->getBlacklistType(); # Load lists wfDebugLog( 'SpamBlacklist', "Constructing $listType blacklist\n" ); foreach ( $this->files as $fileName ) { $matches = []; if ( preg_match( '/^DB: ([\w-]*) (.*)$/', $fileName, $matches ) ) { $text = $this->getArticleText( $matches[1], $matches[2] ); } elseif ( preg_match( '/^(https?:)?\/\//', $fileName ) ) { $text = $this->getHttpText( $fileName ); } else { $text = file_get_contents( $fileName ); wfDebugLog( 'SpamBlacklist', "got from file $fileName\n" ); } if ( $text ) { // Build a separate batch of regexes from each source. // While in theory we could squeeze a little efficiency // out of combining multiple sources in one regex, if // there's a bad line in one of them we'll gain more // from only having to break that set into smaller pieces. $regexes = array_merge( $regexes, SpamRegexBatch::regexesFromText( $text, $this, $fileName ) ); } } return $regexes; } private function getHttpText( $fileName ) { global $wgMessageCacheType; // FIXME: This is a hack to use Memcached where possible (incl. WMF), // but have CACHE_DB as fallback (instead of no cache). // This might be a good candidate for T248005. $services = MediaWikiServices::getInstance()->getObjectCacheFactory(); $cache = $services->getInstance( $wgMessageCacheType ); $listType = $this->getBlacklistType(); // There are two keys, when the warning key expires, a random thread will refresh // the real key. This reduces the chance of multiple requests under high traffic // conditions. $key = $cache->makeGlobalKey( "blacklist_file_{$listType}", $fileName ); $warningKey = $cache->makeKey( "filewarning_{$listType}", $fileName ); $httpText = $cache->get( $key ); $warning = $cache->get( $warningKey ); if ( !is_string( $httpText ) || ( !$warning && !mt_rand( 0, $this->warningChance ) ) ) { wfDebugLog( 'SpamBlacklist', "Loading $listType blacklist from $fileName\n" ); $httpText = MediaWikiServices::getInstance()->getHttpRequestFactory() ->get( $fileName, [], __METHOD__ ); if ( $httpText === false ) { wfDebugLog( 'SpamBlacklist', "Error loading $listType blacklist from $fileName\n" ); } $cache->set( $warningKey, 1, $this->warningTime ); $cache->set( $key, $httpText, $this->expiryTime ); } else { wfDebugLog( 'SpamBlacklist', "Got $listType blacklist from HTTP cache for $fileName\n" ); } return $httpText; } /** * Fetch an article from this or another local MediaWiki database. * * @param string $wiki * @param string $pagename * @return bool|string|null */ private function getArticleText( $wiki, $pagename ) { wfDebugLog( 'SpamBlacklist', "Fetching {$this->getBlacklistType()} blacklist from '$pagename' on '$wiki'...\n" ); $services = MediaWikiServices::getInstance(); // XXX: We do not know about custom namespaces on the target wiki here! $title = $services->getTitleParser()->parseTitle( $pagename ); $store = $services->getRevisionStoreFactory()->getRevisionStore( $wiki ); $rev = $store->getRevisionByTitle( $title ); $content = $rev ? $rev->getContent( SlotRecord::MAIN ) : null; if ( !( $content instanceof TextContent ) ) { return false; } return $content->getText(); } /** * Returns the start of the regex for matches * * @return string */ public function getRegexStart() { return '/[a-z0-9_\-.]*'; } /** * Returns the end of the regex for matches * * @param int $batchSize * @return string */ public function getRegexEnd( $batchSize ) { return ( $batchSize > 0 ) ? '/Sim' : '/im'; } /** * @param Title $title * @param string[] $entries * @param User $user */ public function warmCachesForFilter( Title $title, array $entries, User $user ) { // subclass this } } PK ! l!��� � SpamRegexBatch.phpnu �Iw�� <?php namespace MediaWiki\Extension\SpamBlacklist; use Wikimedia\AtEase\AtEase; /** * Utility class for working with blacklists */ class SpamRegexBatch { /** * Build a set of regular expressions matching URLs with the list of regex fragments. * Returns an empty list if the input list is empty. * * @param string[] $lines list of fragments which will match in URLs * @param BaseBlacklist $blacklist * @param int $batchSize largest allowed batch regex; * if 0, will produce one regex per line * @return string[] */ private static function buildRegexes( array $lines, BaseBlacklist $blacklist, $batchSize = 4096 ) { # Make regex # It's faster using the S modifier even though it will usually only be run once // $regex = 'https?://+[a-z0-9_\-.]*(' . implode( '|', $lines ) . ')'; // return '/' . str_replace( '/', '\/', preg_replace('|\\\*/|', '/', $regex) ) . '/Sim'; $regexes = []; $regexStart = $blacklist->getRegexStart(); $regexEnd = $blacklist->getRegexEnd( $batchSize ); $build = false; foreach ( $lines as $line ) { if ( substr( $line, -1, 1 ) == "\\" ) { // Final \ will break silently on the batched regexes. // Skip it here to avoid breaking the next line; // warnings from getBadLines() will still trigger on // edit to keep new ones from floating in. continue; } // FIXME: not very robust size check, but should work. :) if ( $build === false ) { $build = $line; } elseif ( strlen( $build ) + strlen( $line ) > $batchSize ) { $regexes[] = $regexStart . str_replace( '/', '\/', preg_replace( '|\\\*/|u', '/', $build ) ) . $regexEnd; $build = $line; } else { $build .= '|'; $build .= $line; } } if ( $build !== false ) { $regexes[] = $regexStart . str_replace( '/', '\/', preg_replace( '|\\\*/|u', '/', $build ) ) . $regexEnd; } return $regexes; } /** * Confirm that a set of regexes is either empty or valid. * * @param string[] $regexes set of regexes * @return bool true if ok, false if contains invalid lines */ private static function validateRegexes( $regexes ) { foreach ( $regexes as $regex ) { AtEase::suppressWarnings(); // @phan-suppress-next-line PhanParamSuspiciousOrder False positive $ok = preg_match( $regex, '' ); AtEase::restoreWarnings(); if ( $ok === false ) { return false; } } return true; } /** * Strip comments and whitespace, then remove blanks * * @param string[] $lines * @return string[] */ private static function stripLines( array $lines ) { return array_filter( array_map( 'trim', preg_replace( '/#.*$/', '', $lines ) ) ); } /** * Do a sanity check on the batch regex. * * @param string[] $lines unsanitized input lines * @param BaseBlacklist $blacklist * @param bool|string $fileName optional for debug reporting * @return string[] of regexes */ private static function buildSafeRegexes( array $lines, BaseBlacklist $blacklist, $fileName = false ) { $lines = self::stripLines( $lines ); $regexes = self::buildRegexes( $lines, $blacklist ); if ( self::validateRegexes( $regexes ) ) { return $regexes; } else { // _Something_ broke... rebuild line-by-line; it'll be // slower if there's a lot of blacklist lines, but one // broken line won't take out hundreds of its brothers. if ( $fileName ) { wfDebugLog( 'SpamBlacklist', "Spam blacklist warning: bogus line in $fileName\n" ); } return self::buildRegexes( $lines, $blacklist, 0 ); } } /** * Returns an array of invalid lines * * @param string[] $lines * @param BaseBlacklist $blacklist * @return string[] of input lines which produce invalid input, or empty array if no problems */ public static function getBadLines( $lines, BaseBlacklist $blacklist ) { $lines = self::stripLines( $lines ); $badLines = []; foreach ( $lines as $line ) { if ( substr( $line, -1, 1 ) == "\\" ) { // Final \ will break silently on the batched regexes. $badLines[] = $line; } } $regexes = self::buildRegexes( $lines, $blacklist ); if ( self::validateRegexes( $regexes ) ) { // No other problems! return $badLines; } // Something failed in the batch, so check them one by one. foreach ( $lines as $line ) { $regexes = self::buildRegexes( [ $line ], $blacklist ); if ( !self::validateRegexes( $regexes ) ) { $badLines[] = $line; } } return $badLines; } /** * Build a set of regular expressions from the given multiline input text, * with empty lines and comments stripped. * * @param string $source * @param BaseBlacklist $blacklist * @param bool|string $fileName optional, for reporting of bad files * @return string[] of regular expressions, potentially empty */ public static function regexesFromText( $source, BaseBlacklist $blacklist, $fileName = false ) { $lines = explode( "\n", $source ); return self::buildSafeRegexes( $lines, $blacklist, $fileName ); } /** * Build a set of regular expressions from a MediaWiki message. * Will be correctly empty if the message isn't present. * * @param string $message * @param BaseBlacklist $blacklist * @return string[] of regular expressions, potentially empty */ public static function regexesFromMessage( $message, BaseBlacklist $blacklist ) { $source = wfMessage( $message )->inContentLanguage(); if ( !$source->isDisabled() ) { return self::regexesFromText( $source->plain(), $blacklist ); } else { return []; } } } PK ! ����. �. jobqueue/JobQueueTest.phpnu �Iw�� <?php use MediaWiki\MediaWikiServices; use MediaWiki\WikiMap\WikiMap; use Wikimedia\ObjectCache\HashBagOStuff; use Wikimedia\ObjectCache\WANObjectCache; /** * @group JobQueue * @group medium * @group Database * @covers \JobQueue */ class JobQueueTest extends MediaWikiIntegrationTestCase { protected ?JobQueue $queueRand; protected ?JobQueue $queueRandTTL; protected ?JobQueue $queueTimestamp; protected ?JobQueue $queueTimestampTTL; protected ?JobQueue $queueFifo; protected ?JobQueue $queueFifoTTL; protected function setUp(): void { global $wgJobTypeConf; parent::setUp(); $services = $this->getServiceContainer(); if ( $this->getCliArg( 'use-jobqueue' ) ) { $name = $this->getCliArg( 'use-jobqueue' ); if ( !isset( $wgJobTypeConf[$name] ) ) { throw new RuntimeException( "No \$wgJobTypeConf entry for '$name'." ); } $baseConfig = $wgJobTypeConf[$name]; } else { $baseConfig = [ 'class' => JobQueueDBSingle::class ]; } $baseConfig['type'] = 'null'; $baseConfig['domain'] = WikiMap::getCurrentWikiDbDomain()->getId(); $baseConfig['stash'] = new HashBagOStuff(); $baseConfig['wanCache'] = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ); $baseConfig['idGenerator'] = $services->getGlobalIdGenerator(); $variants = [ 'queueRand' => [ 'order' => 'random', 'claimTTL' => 0 ], 'queueRandTTL' => [ 'order' => 'random', 'claimTTL' => 10 ], 'queueTimestamp' => [ 'order' => 'timestamp', 'claimTTL' => 0 ], 'queueTimestampTTL' => [ 'order' => 'timestamp', 'claimTTL' => 10 ], 'queueFifo' => [ 'order' => 'fifo', 'claimTTL' => 0 ], 'queueFifoTTL' => [ 'order' => 'fifo', 'claimTTL' => 10 ], ]; foreach ( $variants as $q => $settings ) { $this->$q = JobQueue::factory( $settings + $baseConfig ); } } protected function tearDown(): void { foreach ( [ 'queueRand', 'queueRandTTL', 'queueTimestamp', 'queueTimestampTTL', 'queueFifo', 'queueFifoTTL' ] as $q ) { if ( $this->$q ) { $this->$q->delete(); } $this->$q = null; } parent::tearDown(); } /** * @dataProvider provider_queueLists */ public function testGetType( $queue, $recycles, $desc ) { $queue = $this->$queue; if ( !$queue ) { $this->markTestSkipped( $desc ); } $this->assertEquals( 'null', $queue->getType(), "Proper job type ($desc)" ); } /** * @dataProvider provider_queueLists */ public function testBasicOperations( $queue, $recycles, $desc ) { $queue = $this->$queue; if ( !$queue ) { $this->markTestSkipped( $desc ); } $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); $queue->flushCaches(); $this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" ); $this->assertSame( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" ); $this->assertNull( $queue->push( $this->newJob() ), "Push worked ($desc)" ); $this->assertNull( $queue->batchPush( [ $this->newJob() ] ), "Push worked ($desc)" ); $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); $queue->flushCaches(); $this->assertEquals( 2, $queue->getSize(), "Queue size is correct ($desc)" ); $this->assertSame( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); $jobs = iterator_to_array( $queue->getAllQueuedJobs() ); $this->assertCount( 2, $jobs, "Queue iterator size is correct ($desc)" ); $job1 = $queue->pop(); $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); $queue->flushCaches(); $this->assertSame( 1, $queue->getSize(), "Queue size is correct ($desc)" ); $queue->flushCaches(); if ( $recycles ) { $this->assertSame( 1, $queue->getAcquiredCount(), "Active job count ($desc)" ); } $job2 = $queue->pop(); $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); $this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" ); $queue->flushCaches(); if ( $recycles ) { $this->assertEquals( 2, $queue->getAcquiredCount(), "Active job count ($desc)" ); } $queue->ack( $job1 ); $queue->flushCaches(); if ( $recycles ) { $this->assertSame( 1, $queue->getAcquiredCount(), "Active job count ($desc)" ); } $queue->ack( $job2 ); $queue->flushCaches(); $this->assertSame( 0, $queue->getAcquiredCount(), "Active job count ($desc)" ); $this->assertNull( $queue->batchPush( [ $this->newJob(), $this->newJob() ] ), "Push worked ($desc)" ); $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); $queue->delete(); $queue->flushCaches(); $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); $this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" ); } /** * @dataProvider provider_queueLists */ public function testBasicDeduplication( $queue, $recycles, $desc ) { $queue = $this->$queue; if ( !$queue ) { $this->markTestSkipped( $desc ); } $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); $queue->flushCaches(); $this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" ); $this->assertSame( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" ); $this->assertNull( $queue->batchPush( [ $this->newDedupedJob(), $this->newDedupedJob(), $this->newDedupedJob() ] ), "Push worked ($desc)" ); $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); $queue->flushCaches(); $this->assertSame( 1, $queue->getSize(), "Queue size is correct ($desc)" ); $this->assertSame( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); $this->assertNull( $queue->batchPush( [ $this->newDedupedJob(), $this->newDedupedJob(), $this->newDedupedJob() ] ), "Push worked ($desc)" ); $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); $queue->flushCaches(); $this->assertSame( 1, $queue->getSize(), "Queue size is correct ($desc)" ); $this->assertSame( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); $job1 = $queue->pop(); $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); $queue->flushCaches(); $this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" ); if ( $recycles ) { $this->assertSame( 1, $queue->getAcquiredCount(), "Active job count ($desc)" ); } $queue->ack( $job1 ); $queue->flushCaches(); $this->assertSame( 0, $queue->getAcquiredCount(), "Active job count ($desc)" ); } /** * @dataProvider provider_queueLists */ public function testDeduplicationWhileClaimed( $queue, $recycles, $desc ) { $queue = $this->$queue; if ( !$queue ) { $this->markTestSkipped( $desc ); } $job = $this->newDedupedJob(); $queue->push( $job ); // De-duplication does not apply to already-claimed jobs $j = $queue->pop(); $queue->push( $job ); $queue->ack( $j ); $j = $queue->pop(); // Make sure ack() of the twin did not delete the sibling data $this->assertInstanceOf( NullJob::class, $j ); } /** * @dataProvider provider_queueLists */ public function testRootDeduplication( $queue, $recycles, $desc ) { $queue = $this->$queue; if ( !$queue ) { $this->markTestSkipped( $desc ); } $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); $queue->flushCaches(); $this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" ); $this->assertSame( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" ); $root1 = Job::newRootJobParams( "nulljobspam:testId" ); // task ID/timestamp for ( $i = 0; $i < 5; ++$i ) { $this->assertNull( $queue->push( $this->newJob( 0, $root1 ) ), "Push worked ($desc)" ); } $queue->deduplicateRootJob( $this->newJob( 0, $root1 ) ); $root2 = $root1; # Add a second to UNIX epoch and format back to TS_MW $root2_ts = strtotime( $root2['rootJobTimestamp'] ); $root2_ts++; $root2['rootJobTimestamp'] = wfTimestamp( TS_MW, $root2_ts ); $this->assertNotEquals( $root1['rootJobTimestamp'], $root2['rootJobTimestamp'], "Root job signatures have different timestamps." ); for ( $i = 0; $i < 5; ++$i ) { $this->assertNull( $queue->push( $this->newJob( 0, $root2 ) ), "Push worked ($desc)" ); } $queue->deduplicateRootJob( $this->newJob( 0, $root2 ) ); $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); $queue->flushCaches(); $this->assertEquals( 10, $queue->getSize(), "Queue size is correct ($desc)" ); $this->assertSame( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); $dupcount = 0; $jobs = []; do { $job = $queue->pop(); if ( $job ) { $jobs[] = $job; $queue->ack( $job ); } if ( $job instanceof DuplicateJob ) { ++$dupcount; } } while ( $job ); $this->assertCount( 10, $jobs, "Correct number of jobs popped ($desc)" ); $this->assertEquals( 5, $dupcount, "Correct number of duplicate jobs popped ($desc)" ); } /** * @dataProvider provider_fifoQueueLists */ public function testJobOrder( $queue, $recycles, $desc ) { $queue = $this->$queue; if ( !$queue ) { $this->markTestSkipped( $desc ); } $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); $queue->flushCaches(); $this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" ); $this->assertSame( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" ); for ( $i = 0; $i < 10; ++$i ) { $this->assertNull( $queue->push( $this->newJob( $i ) ), "Push worked ($desc)" ); } for ( $i = 0; $i < 10; ++$i ) { $job = $queue->pop(); $this->assertTrue( $job instanceof Job, "Jobs popped from queue ($desc)" ); $params = $job->getParams(); $this->assertEquals( $i, $params['i'], "Job popped from queue is FIFO ($desc)" ); $queue->ack( $job ); } $this->assertFalse( $queue->pop(), "Queue is not empty ($desc)" ); $queue->flushCaches(); $this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" ); $this->assertSame( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); } public function testQueueAggregateTable() { $this->hideDeprecated( 'JobQueue::getWiki' ); $queue = $this->queueFifo; if ( !$queue || !method_exists( $queue, 'getServerQueuesWithJobs' ) ) { $this->markTestSkipped(); } $this->assertNotContains( [ $queue->getType(), $queue->getWiki() ], $queue->getServerQueuesWithJobs(), "Null queue not in listing" ); $queue->push( $this->newJob( 0 ) ); $this->assertContains( [ $queue->getType(), $queue->getWiki() ], $queue->getServerQueuesWithJobs(), "Null queue in listing" ); } public static function provider_queueLists() { return [ [ 'queueRand', false, 'Random queue without ack()' ], [ 'queueRandTTL', true, 'Random queue with ack()' ], [ 'queueTimestamp', false, 'Time ordered queue without ack()' ], [ 'queueTimestampTTL', true, 'Time ordered queue with ack()' ], [ 'queueFifo', false, 'FIFO ordered queue without ack()' ], [ 'queueFifoTTL', true, 'FIFO ordered queue with ack()' ] ]; } public static function provider_fifoQueueLists() { return [ [ 'queueFifo', false, 'Ordered queue without ack()' ], [ 'queueFifoTTL', true, 'Ordered queue with ack()' ] ]; } protected function newJob( $i = 0, $rootJob = [] ) { $params = [ 'namespace' => NS_MAIN, 'title' => 'Main_Page', 'lives' => 0, 'usleep' => 0, 'removeDuplicates' => 0, 'i' => $i ] + $rootJob; return $this->getServiceContainer()->getJobFactory()->newJob( 'null', $params ); } protected function newDedupedJob( $i = 0, $rootJob = [] ) { $params = [ 'namespace' => NS_MAIN, 'title' => 'Main_Page', 'lives' => 0, 'usleep' => 0, 'removeDuplicates' => 1, 'i' => $i ] + $rootJob; return $this->getServiceContainer()->getJobFactory()->newJob( 'null', $params ); } } class JobQueueDBSingle extends JobQueueDB { protected function getDB( $index ) { $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); // Override to not use CONN_TRX_AUTOCOMMIT so that we see the same temporary `job` table return $lb->getConnection( $index, [], $this->domain ); } } PK ! ���� � * jobqueue/jobs/UserEditCountInitJobTest.phpnu �Iw�� <?php /** * @group JobQueue * @group Database */ class UserEditCountInitJobTest extends MediaWikiIntegrationTestCase { public static function provideTestCases() { // $startingEditCount, $setCount, $finalCount yield 'Initiate count if not yet set' => [ false, 2, 2 ]; yield 'Update count when increasing' => [ 2, 3, 3 ]; yield 'Never decrease count' => [ 10, 3, 10 ]; } /** * @covers \UserEditCountInitJob * @dataProvider provideTestCases */ public function testUserEditCountInitJob( $startingEditCount, $setCount, $finalCount ) { $user = $this->getMutableTestUser()->getUser(); if ( $startingEditCount !== false ) { $this->getServiceContainer()->getConnectionProvider()->getPrimaryDatabase() ->newUpdateQueryBuilder() ->update( 'user' ) ->set( [ 'user_editcount' => $startingEditCount ] ) ->where( [ 'user_id' => $user->getId() ] ) ->caller( __METHOD__ ) ->execute(); } $job = new UserEditCountInitJob( [ 'userId' => $user->getId(), 'editCount' => $setCount ] ); $result = $job->run(); $this->assertTrue( $result ); $this->assertEquals( $finalCount, $user->getEditCount() ); } } PK ! %��� � % jobqueue/jobs/RefreshLinksJobTest.phpnu �Iw�� <?php use MediaWiki\CommentStore\CommentStoreComment; use MediaWiki\Content\Content; use MediaWiki\Content\WikitextContent; use MediaWiki\Page\PageAssertionException; use MediaWiki\Title\Title; use Wikimedia\Rdbms\Platform\ISQLPlatform; use Wikimedia\Stats\StatsFactory; /** * @covers \RefreshLinksJob * * @group JobQueue * @group Database * * @license GPL-2.0-or-later * @author Addshore */ class RefreshLinksJobTest extends MediaWikiIntegrationTestCase { /** @var StatsFactory */ private $statsFactory; protected function setUp(): void { parent::setUp(); $this->statsFactory = StatsFactory::newNull(); $this->setService( 'StatsFactory', $this->statsFactory ); } /** * @param string $name * @param Content[] $content * * @return WikiPage */ private function createPage( $name, array $content ) { $title = Title::makeTitle( $this->getDefaultWikitextNS(), $name ); $page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title ); $updater = $page->newPageUpdater( $this->getTestUser()->getUser() ); foreach ( $content as $slot => $cnt ) { $updater->setContent( $slot, $cnt ); } $updater->saveRevision( CommentStoreComment::newUnsavedComment( 'Test' ) ); return $page; } // TODO: test multi-page // TODO: test recursive // TODO: test partition public function testBadTitle() { $specialBlankPage = Title::makeTitle( NS_SPECIAL, 'Blankpage' ); $this->expectException( PageAssertionException::class ); new RefreshLinksJob( $specialBlankPage, [] ); } public function testRunForNonexistentPage() { $nonexistentPage = $this->getNonexistingTestPage(); $job = new RefreshLinksJob( $nonexistentPage, [] ); $totalFailuresCounter = $this->statsFactory->getCounter( 'refreshlinks_failures_total' ); $result = $job->run(); $this->assertFalse( $result ); $this->assertSame( 1, $totalFailuresCounter->getSampleCount() ); } public function testUpdateSuperseded() { $page = $this->getExistingTestPage(); $job = new RefreshLinksJob( $page->getTitle(), [ 'rootJobTimestamp' => '20240101000000' ] ); $supersededUpdatesCounter = $this->statsFactory->getCounter( 'refreshlinks_superseded_updates_total' ); $result = $job->run(); $this->assertTrue( $result ); $this->assertSame( 1, $supersededUpdatesCounter->getSampleCount() ); } public function testStaleRevision() { $page = $this->getExistingTestPage(); $prevRev = $page->getRevisionRecord(); $this->editPage( $page, 'New content' ); $job = new RefreshLinksJob( $page->getTitle(), [ 'triggeringRevisionId' => $prevRev->getId() ] ); $totalFailuresCounter = $this->statsFactory->getCounter( 'refreshlinks_failures_total' ); $result = $job->run(); // We don't want to retry the job so it is returned with true. $this->assertTrue( $result ); $this->assertSame( 1, $totalFailuresCounter->getSampleCount() ); $this->assertSame( "Revision {$prevRev->getId()} is not current", $job->getLastError() ); } public function testRunForSinglePage() { $this->getServiceContainer()->getSlotRoleRegistry()->defineRoleWithModel( 'aux', CONTENT_MODEL_WIKITEXT ); $cacheOpsCounter = $this->statsFactory->getCounter( 'refreshlinks_parsercache_operations_total' ); $mainContent = new WikitextContent( 'MAIN [[Kittens]]' ); $auxContent = new WikitextContent( 'AUX [[Category:Goats]]' ); $page = $this->createPage( __METHOD__, [ 'main' => $mainContent, 'aux' => $auxContent ] ); // clear state $parserCache = $this->getServiceContainer()->getParserCache(); $parserCache->deleteOptionsKey( $page ); $this->getDb()->newDeleteQueryBuilder() ->deleteFrom( 'pagelinks' ) ->where( ISQLPlatform::ALL_ROWS ) ->caller( __METHOD__ ) ->execute(); $this->getDb()->newDeleteQueryBuilder() ->deleteFrom( 'categorylinks' ) ->where( ISQLPlatform::ALL_ROWS ) ->caller( __METHOD__ ) ->execute(); // run job $job = new RefreshLinksJob( $page->getTitle(), [ 'parseThreshold' => 0 ] ); $result = $job->run(); $this->newSelectQueryBuilder() ->select( 'lt_title' ) ->from( 'pagelinks' ) ->join( 'linktarget', null, 'pl_target_id=lt_id' ) ->where( [ 'pl_from' => $page->getId() ] ) ->assertFieldValue( 'Kittens' ); $this->newSelectQueryBuilder() ->select( 'cl_to' ) ->from( 'categorylinks' ) ->where( [ 'cl_from' => $page->getId() ] ) ->assertFieldValue( 'Goats' ); $this->assertTrue( $result ); $this->assertSame( 1, $cacheOpsCounter->getSampleCount() ); } public function testRunForMultiPage() { $this->getServiceContainer()->getSlotRoleRegistry()->defineRoleWithModel( 'aux', CONTENT_MODEL_WIKITEXT ); $fname = __METHOD__; $mainContent = new WikitextContent( 'MAIN [[Kittens]]' ); $auxContent = new WikitextContent( 'AUX [[Category:Goats]]' ); $page1 = $this->createPage( "$fname-1", [ 'main' => $mainContent, 'aux' => $auxContent ] ); $mainContent = new WikitextContent( 'MAIN [[Dogs]]' ); $auxContent = new WikitextContent( 'AUX [[Category:Hamsters]]' ); $page2 = $this->createPage( "$fname-2", [ 'main' => $mainContent, 'aux' => $auxContent ] ); // clear state $parserCache = $this->getServiceContainer()->getParserCache(); $parserCache->deleteOptionsKey( $page1 ); $parserCache->deleteOptionsKey( $page2 ); $this->getDb()->newDeleteQueryBuilder() ->deleteFrom( 'pagelinks' ) ->where( ISQLPlatform::ALL_ROWS ) ->caller( __METHOD__ ) ->execute(); $this->getDb()->newDeleteQueryBuilder() ->deleteFrom( 'categorylinks' ) ->where( ISQLPlatform::ALL_ROWS ) ->caller( __METHOD__ ) ->execute(); // run job $job = new RefreshLinksJob( Title::makeTitle( NS_SPECIAL, 'Blankpage' ), [ 'pages' => [ [ 0, "$fname-1" ], [ 0, "$fname-2" ] ] ] ); $job->run(); $this->newSelectQueryBuilder() ->select( 'lt_title' ) ->from( 'pagelinks' ) ->join( 'linktarget', null, 'pl_target_id=lt_id' ) ->where( [ 'pl_from' => $page1->getId() ] ) ->assertFieldValue( 'Kittens' ); $this->newSelectQueryBuilder() ->select( 'cl_to' ) ->from( 'categorylinks' ) ->where( [ 'cl_from' => $page1->getId() ] ) ->assertFieldValue( 'Goats' ); $this->newSelectQueryBuilder() ->select( 'lt_title' ) ->from( 'pagelinks' ) ->join( 'linktarget', null, 'pl_target_id=lt_id' ) ->where( [ 'pl_from' => $page2->getId() ] ) ->assertFieldValue( 'Dogs' ); $this->newSelectQueryBuilder() ->select( 'cl_to' ) ->from( 'categorylinks' ) ->where( [ 'cl_from' => $page2->getId() ] ) ->assertFieldValue( 'Hamsters' ); } } PK ! ���A A 1 jobqueue/jobs/CategoryMembershipChangeJobTest.phpnu �Iw�� <?php use MediaWiki\MainConfigNames; use MediaWiki\Revision\RevisionRecord; use MediaWiki\Title\Title; use MediaWiki\Utils\MWTimestamp; /** * @covers \CategoryMembershipChangeJob * * @group JobQueue * @group Database * * @license GPL-2.0-or-later * @author Addshore */ class CategoryMembershipChangeJobTest extends MediaWikiIntegrationTestCase { private const TITLE_STRING = 'UTCatChangeJobPage'; /** * @var Title */ private $title; protected function setUp(): void { parent::setUp(); $this->overrideConfigValue( MainConfigNames::RCWatchCategoryMembership, true ); $this->setContentLang( 'qqx' ); } public function addDBData() { parent::addDBData(); $insertResult = $this->insertPage( self::TITLE_STRING, 'UT Content' ); $this->title = $insertResult['title']; } /** * @param string $text new page text * * @return int|null */ private function editPageText( $text ) { $editResult = $this->editPage( $this->title, $text, __METHOD__, NS_MAIN, $this->getTestSysop()->getAuthority() ); /** @var RevisionRecord $revisionRecord */ $revisionRecord = $editResult->getNewRevision(); $this->runJobs(); return $revisionRecord->getId(); } /** * @param int $revId * * @return RecentChange|null */ private function getCategorizeRecentChangeForRevId( $revId ) { $rc = RecentChange::newFromConds( [ 'rc_type' => RC_CATEGORIZE, 'rc_this_oldid' => $revId, ], __METHOD__ ); $this->assertNotNull( $rc, 'rev_id = ' . $revId ); return $rc; } public function testRun_normalCategoryAddedAndRemoved() { $addedRevId = $this->editPageText( '[[Category:Normal]]' ); $removedRevId = $this->editPageText( 'Blank' ); $this->assertEquals( '(recentchanges-page-added-to-category: ' . self::TITLE_STRING . ')', $this->getCategorizeRecentChangeForRevId( $addedRevId )->getAttribute( 'rc_comment' ) ); $this->assertEquals( '(recentchanges-page-removed-from-category: ' . self::TITLE_STRING . ')', $this->getCategorizeRecentChangeForRevId( $removedRevId )->getAttribute( 'rc_comment' ) ); } public function testJobSpecRemovesDuplicates() { $jobSpec = CategoryMembershipChangeJob::newSpec( $this->title, MWTimestamp::now(), false ); $job = new CategoryMembershipChangeJob( $this->title, $jobSpec->getParams() ); $this->assertTrue( $job->ignoreDuplicates() ); $this->assertTrue( $jobSpec->ignoreDuplicates() ); $this->assertEquals( $job->getDeduplicationInfo(), $jobSpec->getDeduplicationInfo() ); } public function testJobSpecDeduplicationIgnoresRevTimestamp() { $jobSpec1 = CategoryMembershipChangeJob::newSpec( $this->title, '20191008204617', false ); $jobSpec2 = CategoryMembershipChangeJob::newSpec( $this->title, '20201008204617', false ); $this->assertArrayEquals( $jobSpec1->getDeduplicationInfo(), $jobSpec2->getDeduplicationInfo() ); } } PK ! ]b/�� � , jobqueue/jobs/ParsoidCachePrewarmJobTest.phpnu �Iw�� <?php use MediaWiki\Config\ServiceOptions; use MediaWiki\MediaWikiServices; use MediaWiki\OutputTransform\Stages\RenderDebugInfo; use MediaWiki\Page\PageIdentity; use MediaWiki\Page\PageIdentityValue; use MediaWiki\Page\PageRecord; use MediaWiki\Parser\ParserOptions; use Psr\Log\NullLogger; use Wikimedia\TestingAccessWrapper; /** * @group JobQueue * @group Database * * @license GPL-2.0-or-later */ class ParsoidCachePrewarmJobTest extends MediaWikiIntegrationTestCase { private const NON_JOB_QUEUE_EDIT = 'parsoid edit not executed by job queue'; private const JOB_QUEUE_EDIT = 'parsoid edit executed by job queue'; private function getPageIdentity( PageRecord $page ): PageIdentityValue { return PageIdentityValue::localIdentity( $page->getId(), $page->getNamespace(), $page->getDBkey() ); } private function getPageRecord( PageIdentity $page ): PageRecord { return $this->getServiceContainer()->getPageStore() ->getPageByReference( $page ); } /** * @covers \ParsoidCachePrewarmJob::doParsoidCacheUpdate * @covers \ParsoidCachePrewarmJob::newSpec * @covers \ParsoidCachePrewarmJob::run */ public function testRun() { $page = $this->getExistingTestPage( 'ParsoidPrewarmJob' )->toPageRecord(); $rev1 = $this->editPage( $page, self::NON_JOB_QUEUE_EDIT )->getNewRevision(); $parsoidPrewarmJob = new ParsoidCachePrewarmJob( [ 'revId' => $rev1->getId(), 'pageId' => $page->getId() ], $this->getServiceContainer()->getParserOutputAccess(), $this->getServiceContainer()->getPageStore(), $this->getServiceContainer()->getRevisionLookup(), $this->getServiceContainer()->getParsoidSiteConfig() ); // NOTE: calling ->run() will not run the job scheduled in the queue but will // instead call doParsoidCacheUpdate() directly. Will run the job and assert // below. $execStatus = $parsoidPrewarmJob->run(); $this->assertTrue( $execStatus ); $popts = ParserOptions::newFromAnon(); $popts->setUseParsoid(); $parsoidOutput = $this->getServiceContainer()->getParserOutputAccess()->getCachedParserOutput( $this->getPageRecord( $this->getPageIdentity( $page ) ), $popts, $rev1 ); // Ensure we have the parsoid output in parser cache as an HTML document $this->assertStringContainsString( '<html', $parsoidOutput->getRawText() ); $this->assertStringContainsString( self::NON_JOB_QUEUE_EDIT, $parsoidOutput->getRawText() ); $rev2 = $this->editPage( $page, self::JOB_QUEUE_EDIT )->getNewRevision(); // Post-edit, reset all services! // ParserOutputAccess has a localCache which can incorrectly return stale // content for the previous revision! Resetting ensures that ParsoidCachePrewarmJob // gets a fresh copy of ParserOutputAccess. $this->resetServices(); $parsoidPrewarmJob = new ParsoidCachePrewarmJob( [ 'revId' => $rev2->getId(), 'pageId' => $page->getId(), 'causeAction' => 'just for testing' ], $this->getServiceContainer()->getParserOutputAccess(), $this->getServiceContainer()->getPageStore(), $this->getServiceContainer()->getRevisionLookup(), $this->getServiceContainer()->getParsoidSiteConfig() ); $jobQueueGroup = $this->getServiceContainer()->getJobQueueGroup(); $jobQueueGroup->get( $parsoidPrewarmJob->getType() )->delete(); $jobQueueGroup->push( $parsoidPrewarmJob ); // At this point, we have 1 job scheduled for this job type. $this->assertSame( 1, $jobQueueGroup->getQueueSizes()['parsoidCachePrewarm'] ); // doParsoidCacheUpdate() now with a job queue instead of calling directly. $this->runJobs( [ 'maxJobs' => 1 ], [ 'type' => 'parsoidCachePrewarm' ] ); // At this point, we have 0 jobs scheduled for this job type. $this->assertSame( 0, $jobQueueGroup->getQueueSizes()['parsoidCachePrewarm'] ); $parsoidOutput = $this->getServiceContainer()->getParserOutputAccess()->getCachedParserOutput( $this->getPageRecord( $this->getPageIdentity( $page ) ), $popts, $rev2 ); // Ensure we have the parsoid output in parser cache as an HTML document $this->assertStringContainsString( '<html', $parsoidOutput->getRawText() ); $this->assertStringContainsString( self::JOB_QUEUE_EDIT, $parsoidOutput->getRawText() ); $services = MediaWikiServices::getInstance(); $servicesOptions = new ServiceOptions( RenderDebugInfo::CONSTRUCTOR_OPTIONS, $services->getMainConfig() ); $rdi = TestingAccessWrapper::newFromObject( new RenderDebugInfo( $servicesOptions, new NullLogger(), $services->getHookContainer() ) ); // Check that the causeAction was looped through as the render reason $this->assertStringContainsString( 'triggered because: just for testing', $rdi->debugInfo( $parsoidOutput ) ); } /** * @covers \ParsoidCachePrewarmJob::newSpec */ public function testEnqueueSpec() { $page = $this->getExistingTestPage( 'ParsoidPrewarmJob' )->toPageRecord(); $rev1 = $this->editPage( $page, self::NON_JOB_QUEUE_EDIT )->getNewRevision(); $parsoidPrewarmSpec = ParsoidCachePrewarmJob::newSpec( $rev1->getId(), $page, ); $this->assertSame( 'parsoidCachePrewarm', $parsoidPrewarmSpec->getType(), 'getType' ); $dedupeInfo = $parsoidPrewarmSpec->getDeduplicationInfo(); $this->assertTrue( $dedupeInfo['params']['rootJobIsSelf'] ); $this->assertSame( $page->getTouched(), $dedupeInfo['params']['page_touched'] ); $this->assertSame( $rev1->getId(), $dedupeInfo['params']['revId'] ); $this->assertSame( $page->getId(), $dedupeInfo['params']['pageId'] ); $jobQueueGroup = $this->getServiceContainer()->getJobQueueGroup(); $jobQueueGroup->get( $parsoidPrewarmSpec->getType() )->delete(); $jobQueueGroup->push( $parsoidPrewarmSpec ); // At this point, we have 1 job scheduled for this job type. $this->assertSame( 1, $jobQueueGroup->getQueueSizes()['parsoidCachePrewarm'] ); // Push again times, deduplication should apply! $jobQueueGroup->push( $parsoidPrewarmSpec ); $jobQueueGroup->push( $parsoidPrewarmSpec ); // We should still have just 1 job scheduled for this job type. $this->assertSame( 1, $jobQueueGroup->getQueueSizes()['parsoidCachePrewarm'] ); } } PK ! ���� � jobqueue/JobQueueMemoryTest.phpnu �Iw�� <?php use MediaWiki\MediaWikiServices; use MediaWiki\Title\Title; use MediaWiki\WikiMap\WikiMap; /** * @covers \JobQueueMemory * * @group JobQueue * * @license GPL-2.0-or-later * @author Thiemo Kreuz */ class JobQueueMemoryTest extends PHPUnit\Framework\TestCase { use MediaWikiCoversValidator; /** * @return JobQueueMemory */ private function newJobQueue() { $services = MediaWikiServices::getInstance(); return JobQueue::factory( [ 'class' => JobQueueMemory::class, 'domain' => WikiMap::getCurrentWikiDbDomain()->getId(), 'type' => 'null', 'idGenerator' => $services->getGlobalIdGenerator(), ] ); } private function newJobSpecification() { return new JobSpecification( 'null', [ 'customParameter' => null ], [], Title::makeTitle( NS_MAIN, 'Custom title' ) ); } public function testGetAllQueuedJobs() { $queue = $this->newJobQueue(); $this->assertCount( 0, $queue->getAllQueuedJobs() ); $queue->push( $this->newJobSpecification() ); $this->assertCount( 1, $queue->getAllQueuedJobs() ); } public function testGetAllAcquiredJobs() { $queue = $this->newJobQueue(); $this->assertCount( 0, $queue->getAllAcquiredJobs() ); $queue->push( $this->newJobSpecification() ); $this->assertCount( 0, $queue->getAllAcquiredJobs() ); $queue->pop(); $this->assertCount( 1, $queue->getAllAcquiredJobs() ); } public function testJobFromSpecInternal() { $queue = $this->newJobQueue(); $job = $queue->jobFromSpecInternal( $this->newJobSpecification() ); $this->assertInstanceOf( Job::class, $job ); $this->assertSame( 'null', $job->getType() ); $this->assertArrayHasKey( 'customParameter', $job->getParams() ); $this->assertSame( 'Custom title', $job->getTitle()->getText() ); } } PK ! �� � jobqueue/JobFactoryTest.phpnu �Iw�� <?php use MediaWiki\JobQueue\JobFactory; use MediaWiki\Title\Title; /** * @author Addshore * @covers \MediaWiki\JobQueue\JobFactory */ class JobFactoryTest extends MediaWikiIntegrationTestCase { /** * @dataProvider provideTestNewJob */ public function testNewJob( $handler, $expectedClass ) { $specs = [ 'testdummy' => $handler ]; $factory = new JobFactory( $this->getServiceContainer()->getObjectFactory(), $specs ); $job = $factory->newJob( 'testdummy', Title::newMainPage(), [] ); $this->assertInstanceOf( $expectedClass, $job ); $job2 = $factory->newJob( 'testdummy', [] ); $this->assertInstanceOf( $expectedClass, $job2 ); $this->assertNotSame( $job, $job2, 'should not reuse instance' ); $job3 = $factory->newJob( 'testdummy', [ 'namespace' => NS_MAIN, 'title' => 'JobTestTitle' ] ); $this->assertInstanceOf( $expectedClass, $job3 ); $this->assertNotSame( $job, $job3, 'should not reuse instance' ); } public function provideTestNewJob() { return [ 'class name, no title' => [ 'NullJob', NullJob::class ], 'class name with title' => [ DeleteLinksJob::class, DeleteLinksJob::class ], 'closure' => [ static function ( Title $title, array $params ) { return new NullJob( $params ); }, NullJob::class ], 'function' => [ [ $this, 'newNullJob' ], NullJob::class ], 'object spec, no title' => [ [ 'class' => 'NullJob' ], NullJob::class ], 'object spec with title' => [ [ 'class' => DeleteLinksJob::class ], DeleteLinksJob::class ], 'object spec with no title and not subclass of GenericParameterJob' => [ [ 'class' => ParsoidCachePrewarmJob::class, 'services' => [ 'ParserOutputAccess', 'PageStore', 'RevisionLookup', 'ParsoidSiteConfig', ], 'needsPage' => false ], ParsoidCachePrewarmJob::class ] ]; } public function newNullJob( Title $title, array $params ) { return new NullJob( $params ); } } PK ! ��a܄ � jobqueue/JobTest.phpnu �Iw�� <?php use MediaWiki\MainConfigNames; use MediaWiki\Request\WebRequest; use MediaWiki\Title\Title; /** * @author Addshore * @covers \Job */ class JobTest extends MediaWikiIntegrationTestCase { /** * @dataProvider provideTestToString * * @param Job $job * @param string $expected */ public function testToString( $job, $expected ) { $this->overrideConfigValue( MainConfigNames::LanguageCode, 'en' ); $this->assertEquals( $expected, $job->toString() ); } public function provideTestToString() { $mockToStringObj = $this->getMockBuilder( stdClass::class ) ->addMethods( [ '__toString' ] )->getMock(); $mockToStringObj->method( '__toString' ) ->willReturn( '{STRING_OBJ_VAL}' ); $requestId = 'requestId=' . WebRequest::getRequestId(); return [ [ $this->getMockJob( [ 'key' => 'val' ] ), 'someCommand Special: key=val ' . $requestId ], [ $this->getMockJob( [ 'key' => [ 'inkey' => 'inval' ] ] ), 'someCommand Special: key={"inkey":"inval"} ' . $requestId ], [ $this->getMockJob( [ 'val1' ] ), 'someCommand Special: 0=val1 ' . $requestId ], [ $this->getMockJob( [ 'val1', 'val2' ] ), 'someCommand Special: 0=val1 1=val2 ' . $requestId ], [ $this->getMockJob( [ (object)[] ] ), 'someCommand Special: 0=stdClass ' . $requestId ], [ $this->getMockJob( [ $mockToStringObj ] ), 'someCommand Special: 0={STRING_OBJ_VAL} ' . $requestId ], [ $this->getMockJob( [ "pages" => [ "932737" => [ 0, "Robert_James_Waller" ] ], "rootJobSignature" => "45868e99bba89064e4483743ebb9b682ef95c1a7", "rootJobTimestamp" => "20160309110158", "masterPos" => [ "file" => "db1023-bin.001288", "pos" => "308257743", "asOfTime" => 1457521464.3814 ], "triggeredRecursive" => true ] ), 'someCommand Special: pages={"932737":[0,"Robert_James_Waller"]} ' . 'rootJobSignature=45868e99bba89064e4483743ebb9b682ef95c1a7 ' . 'rootJobTimestamp=20160309110158 masterPos=' . '{"file":"db1023-bin.001288","pos":"308257743",' . '"asOfTime":1457521464.3814} triggeredRecursive=1 ' . $requestId ], ]; } public function getMockJob( $params ) { $mock = $this->getMockForAbstractClass( Job::class, [ 'someCommand', $params ], 'SomeJob' ); return $mock; } public function testInvalidParamsArgument() { $params = false; $this->expectException( InvalidArgumentException::class ); $this->expectExceptionMessage( '$params must be an array' ); $job = $this->getMockJob( $params ); } /** * @dataProvider provideTestJobFactory */ public function testJobFactory( $handler, $expectedClass ) { $this->overrideConfigValue( MainConfigNames::JobClasses, [ 'testdummy' => $handler ] ); $job = Job::factory( 'testdummy', Title::newMainPage(), [] ); $this->assertInstanceOf( $expectedClass, $job ); $job2 = Job::factory( 'testdummy', [] ); $this->assertInstanceOf( $expectedClass, $job2 ); $this->assertNotSame( $job, $job2, 'should not reuse instance' ); $job3 = Job::factory( 'testdummy', [ 'namespace' => NS_MAIN, 'title' => 'JobTestTitle' ] ); $this->assertInstanceOf( $expectedClass, $job3 ); $this->assertNotSame( $job, $job3, 'should not reuse instance' ); } public function provideTestJobFactory() { return [ 'class name, no title' => [ 'NullJob', NullJob::class ], 'class name with title' => [ DeleteLinksJob::class, DeleteLinksJob::class ], 'closure' => [ static function ( Title $title, array $params ) { return new NullJob( $params ); }, NullJob::class ], 'function' => [ [ $this, 'newNullJob' ], NullJob::class ], 'object spec, no title' => [ [ 'class' => 'NullJob' ], NullJob::class ], 'object spec with title' => [ [ 'class' => DeleteLinksJob::class ], DeleteLinksJob::class ], 'object spec with no title and not subclass of GenericParameterJob' => [ [ 'class' => ParsoidCachePrewarmJob::class, 'services' => [ 'ParserOutputAccess', 'PageStore', 'RevisionLookup', 'ParsoidSiteConfig', ], 'needsPage' => false ], ParsoidCachePrewarmJob::class ] ]; } public function newNullJob( Title $title, array $params ) { return new NullJob( $params ); } public function testJobSignatureGeneric() { $testPage = Title::makeTitle( NS_PROJECT, 'x' ); $blankTitle = Title::makeTitle( NS_SPECIAL, '' ); $params = [ 'z' => 1, 'lives' => 1, 'usleep' => 0 ]; $paramsWithTitle = $params + [ 'namespace' => NS_PROJECT, 'title' => 'x' ]; $job = new NullJob( [ 'namespace' => NS_PROJECT, 'title' => 'x' ] + $params ); $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() ); $this->assertJobParamsMatch( $job, $paramsWithTitle ); $job = Job::factory( 'null', $testPage, $params ); $this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() ); $this->assertJobParamsMatch( $job, $params ); $job = Job::factory( 'null', $paramsWithTitle ); $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() ); $this->assertJobParamsMatch( $job, $paramsWithTitle ); $job = Job::factory( 'null', $params ); $this->assertTrue( $blankTitle->equals( $job->getTitle() ) ); $this->assertJobParamsMatch( $job, $params ); } public function testJobSignatureTitleBased() { $testPage = Title::makeTitle( NS_PROJECT, 'X' ); $blankPage = Title::makeTitle( NS_SPECIAL, 'Blankpage' ); $params = [ 'z' => 1, 'causeAction' => 'unknown', 'causeAgent' => 'unknown' ]; $paramsWithTitle = $params + [ 'namespace' => NS_PROJECT, 'title' => 'X' ]; $paramsWithBlankpage = $params + [ 'namespace' => NS_SPECIAL, 'title' => 'Blankpage' ]; $job = new RefreshLinksJob( $testPage, $params ); $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() ); $this->assertTrue( $testPage->equals( $job->getTitle() ) ); $this->assertJobParamsMatch( $job, $paramsWithTitle ); $job = Job::factory( 'htmlCacheUpdate', $testPage, $params ); $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() ); $this->assertJobParamsMatch( $job, $paramsWithTitle ); $job = Job::factory( 'htmlCacheUpdate', $paramsWithTitle ); $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() ); $this->assertJobParamsMatch( $job, $paramsWithTitle ); $job = Job::factory( 'htmlCacheUpdate', $params ); $this->assertTrue( $blankPage->equals( $job->getTitle() ) ); $this->assertJobParamsMatch( $job, $paramsWithBlankpage ); } public function testJobSignatureTitleBasedIncomplete() { $testPage = Title::makeTitle( NS_PROJECT, 'X' ); $blankTitle = Title::makeTitle( NS_SPECIAL, '' ); $params = [ 'z' => 1, 'causeAction' => 'unknown', 'causeAgent' => 'unknown' ]; $job = new RefreshLinksJob( $testPage, $params + [ 'namespace' => 0 ] ); $this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() ); $this->assertJobParamsMatch( $job, $params + [ 'namespace' => 0 ] ); $job = new RefreshLinksJob( $testPage, $params + [ 'title' => 'x' ] ); $this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() ); $this->assertJobParamsMatch( $job, $params + [ 'title' => 'x' ] ); } private function assertJobParamsMatch( IJobSpecification $job, array $params ) { $actual = $job->getParams(); unset( $actual['requestId'] ); $this->assertEquals( $actual, $params ); } } PK ! �C� � &