Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
10.26% covered (danger)
10.26%
12 / 117
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
MassMessage
10.26% covered (danger)
10.26%
12 / 117
12.50% covered (danger)
12.50%
1 / 8
514.60
0.00% covered (danger)
0.00%
0 / 1
 getMessengerUser
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 processPFData
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
90
 parserError
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getQueuedCount
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 logToWiki
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 submit
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
20
 getTimestampRegex
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
12
 isSourceTranslationPage
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\MassMessage;
4
5use ExtensionRegistry;
6use LogicException;
7use ManualLogEntry;
8use MediaWiki\Extension\Translate\PageTranslation\TranslatablePage;
9use MediaWiki\MassMessage\Job\MassMessageJob;
10use MediaWiki\MassMessage\Job\MassMessageSubmitJob;
11use MediaWiki\MassMessage\Lookup\DatabaseLookup;
12use MediaWiki\MassMessage\Lookup\SpamlistLookup;
13use MediaWiki\MassMessage\RequestProcessing\MassMessageRequest;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\Title\Title;
16use MediaWiki\User\CentralId\CentralIdLookup;
17use MediaWiki\User\User;
18use MediaWiki\WikiMap\WikiMap;
19use ParserOptions;
20
21/**
22 * Some core functions needed by the extension.
23 *
24 * @file
25 * @author Kunal Mehta
26 * @license GPL-2.0-or-later
27 */
28
29class MassMessage {
30
31    /**
32     * Sets up the messenger account for our use if it hasn't been already.
33     * Based on code from AbuseFilter
34     * https://mediawiki.org/wiki/Extension:AbuseFilter
35     *
36     * @return User
37     */
38    public static function getMessengerUser() {
39        $services = MediaWikiServices::getInstance();
40        $userGroupManager = $services->getUserGroupManager();
41        $userFactory = $services->getUserFactory();
42
43        $accountUsername = $services->getMainConfig()->get( 'MassMessageAccountUsername' );
44
45        // Only assign the bot flag if newSystemUser would either create
46        //  or steal the account with the username specified.
47        $user = $userFactory->newFromName( $accountUsername );
48        $shouldAssignBotFlag = !$user->isRegistered() || !$user->isSystemUser();
49
50        $user = User::newSystemUser(
51            $accountUsername, [ 'steal' => true ]
52        );
53        // Make the user a bot so it doesn't look weird when the account was stolen
54        //  or created.
55        if ( $shouldAssignBotFlag && !in_array( 'bot', $userGroupManager->getUserGroups( $user ) ) ) {
56            $userGroupManager->addUserToGroup( $user, 'bot' );
57        }
58
59        return $user;
60    }
61
62    /**
63     * Verify that parser function data is valid and return processed data as an array
64     * @param string $page
65     * @param string $site
66     * @return array
67     */
68    public static function processPFData( $page, $site ) {
69        $config = MediaWikiServices::getInstance()->getMainConfig();
70
71        $titleObj = Title::newFromText( $page );
72        if ( $titleObj === null ) {
73            return self::parserError( 'massmessage-parse-badpage', $page );
74        }
75
76        $currentWikiId = WikiMap::getCurrentWikiId();
77        $data = [ 'title' => $page, 'site' => trim( $site ) ];
78        if ( $data['site'] === '' ) {
79            $data['site'] = UrlHelper::getBaseUrl( $config->get( 'CanonicalServer' ) );
80            $data['wiki'] = $currentWikiId;
81        } else {
82            $data['wiki'] = DatabaseLookup::getDBName( $data['site'] );
83            if ( $data['wiki'] === null ) {
84                return self::parserError( 'massmessage-parse-badurl', $site );
85            }
86            if ( !$config->get( 'AllowGlobalMessaging' ) && $data['wiki'] !== $currentWikiId ) {
87                return self::parserError( 'massmessage-global-disallowed' );
88            }
89        }
90        if ( $data['wiki'] === $currentWikiId && $titleObj->isExternal() ) {
91            // interwiki links don't work
92            if ( $config->get( 'AllowGlobalMessaging' ) ) {
93                // tell them they need to use the |site= parameter
94                return self::parserError( 'massmessage-parse-badexternal', $page );
95            }
96            // just tell them global messaging is disabled
97            return self::parserError( 'massmessage-global-disallowed' );
98        }
99        return $data;
100    }
101
102    /**
103     * Helper function for processPFData
104     * Inspired from the Cite extension
105     * @param string $key message key
106     * @param string|null $param parameter for the message
107     * @return array
108     */
109    public static function parserError( $key, $param = null ) {
110        $msg = wfMessage( $key );
111        if ( $param ) {
112            $msg->params( $param );
113        }
114        return [
115            '<strong class="error">' .
116            $msg->inContentLanguage()->plain() .
117            '</strong>',
118            'noparse' => false,
119            'error' => true,
120        ];
121    }
122
123    /**
124     * Get the number of Queued messages on this site
125     * Taken from runJobs.php --group
126     * @return int
127     */
128    public static function getQueuedCount() {
129        $group = MediaWikiServices::getInstance()->getJobQueueGroupFactory()->makeJobQueueGroup();
130        $queue = $group->get( 'MassMessageJob' );
131        $pending = $queue->getSize();
132        $claimed = $queue->getAcquiredCount();
133        $abandoned = $queue->getAbandonedCount();
134        $active = max( $claimed - $abandoned, 0 );
135
136        return $active + $pending;
137    }
138
139    /**
140     * Log the spamming to Special:Log/massmessage
141     *
142     * @param Title $spamlist
143     * @param User $user
144     * @param string $subject
145     * @param string $pageMessage
146     */
147    public static function logToWiki(
148        Title $spamlist,
149        User $user,
150        string $subject,
151        string $pageMessage
152    ): void {
153        $logEntry = new ManualLogEntry( 'massmessage', 'send' );
154        $logEntry->setPerformer( $user );
155        $logEntry->setTarget( $spamlist );
156        $logEntry->setComment( $subject );
157        $logEntry->setParameters( [
158            '4::revid' => $spamlist->getLatestRevID(),
159            '5::pageMessage' => $pageMessage
160        ] );
161
162        $logid = $logEntry->insert();
163        $logEntry->publish( $logid );
164    }
165
166    /**
167     * Send out the message!
168     * Note that this function does not perform validation on $data
169     *
170     * @param User $user who the message was from (for logging)
171     * @param MassMessageRequest $request
172     * @return int number of pages delivered to
173     */
174    public static function submit( User $user, MassMessageRequest $request ): int {
175        // Get the array of pages to deliver to.
176        $pages = SpamlistLookup::getTargets( $request->getSpamList() );
177
178        // Log it.
179        self::logToWiki( $request->getSpamList(), $user, $request->getSubject(), $request->getPageMessage() );
180
181        $pageTitle = null;
182        $sourcePageLanguage = null;
183        $isSourceTranslationPage = false;
184        if ( $request->hasPageMessage() ) {
185            $pageTitle = Services::getInstance()
186                ->getLocalMessageContentFetcher()
187                ->getTitle( $request->getPageMessage() )
188                ->getValue();
189            $isSourceTranslationPage = self::isSourceTranslationPage( $pageTitle );
190            if ( $isSourceTranslationPage ) {
191                $sourcePageLanguage = $pageTitle->getPageLanguage()->getCode();
192            }
193        }
194
195        $services = MediaWikiServices::getInstance();
196        $originWiki = WikiMap::getCurrentWikiId();
197
198        $data = $request->getSerializedData();
199        $data += [
200            'userId' => $services->getCentralIdLookup()
201                ->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW ),
202            'originWiki' => $originWiki,
203            'isSourceTranslationPage' => $isSourceTranslationPage,
204            'translationPageSourceLanguage' => $sourcePageLanguage,
205            'pageMessageTitle' => $pageTitle ? $pageTitle->getPrefixedText() : null
206        ];
207
208        // Insert it into the job queue.
209        $params = [
210            'data' => $data,
211            'pages' => $pages,
212            'class' => MassMessageJob::class,
213        ];
214
215        $job = new MassMessageSubmitJob( $request->getSpamList(), $params );
216        $services->getJobQueueGroupFactory()->makeJobQueueGroup( $originWiki )->push( $job );
217
218        return count( $pages );
219    }
220
221    /**
222     * Gets a regular expression that will match this wiki's
223     * timestamps as given by ~~~~.
224     *
225     * Modified from the Echo extension
226     *
227     * @return string regular expression fragment.
228     */
229    public static function getTimestampRegex() {
230        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
231
232        return $cache->getWithSetCallback(
233            $cache->makeKey( 'massmessage', 'timestamp' ),
234            $cache::TTL_WEEK,
235            static function () {
236                // Step 1: Get an exemplar timestamp
237                $title = Title::newMainPage();
238                $user = User::newFromName( 'Test' );
239                $options = new ParserOptions( $user );
240
241                $exemplarTimestamp =
242                    MediaWikiServices::getInstance()->getParser()
243                        ->preSaveTransform( '~~~~~', $title, $user, $options );
244
245                // Step 2: Generalise it
246                // Trim off the timezone to replace at the end
247                $output = $exemplarTimestamp;
248                $tzRegex = '/\s*\(\w+\)\s*$/';
249                $output = preg_replace( $tzRegex, '', $output );
250                $output = preg_quote( $output, '/' );
251                $output = preg_replace( '/[^\d\W]+/u', '[^\d\W]+', $output );
252                $output = preg_replace( '/\d+/u', '\d+', $output );
253
254                $tzMatches = [];
255                if ( preg_match( $tzRegex, $exemplarTimestamp, $tzMatches ) ) {
256                    $output .= preg_quote( $tzMatches[0] );
257                }
258
259                if ( !preg_match( "/$output/u", $exemplarTimestamp ) ) {
260                    throw new LogicException( "Timestamp regex does not match exemplar" );
261                }
262
263                return "/$output/";
264            }
265        );
266    }
267
268    /**
269     * Checks if a title is a source translation page
270     *
271     * @param Title $title
272     * @return bool
273     */
274    public static function isSourceTranslationPage( Title $title ): bool {
275        return ExtensionRegistry::getInstance()->isLoaded( 'Translate' ) &&
276            // @phan-suppress-next-line PhanUndeclaredClassMethod
277            TranslatablePage::isSourcePage( $title );
278    }
279}