Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
38.81% covered (danger)
38.81%
52 / 134
25.00% covered (danger)
25.00%
5 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
38.81% covered (danger)
38.81%
52 / 134
25.00% covered (danger)
25.00%
5 / 20
1226.16
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onHistoryTools
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onDiffTools
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 insertThankLink
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
72
 isUserBlockedFromPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isUserBlockedFromThanks
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 canReceiveThanks
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 getSessionKey
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 generateThankElement
73.33% covered (warning)
73.33%
22 / 30
0.00% covered (danger)
0.00%
0 / 1
6.68
 addThanksModule
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 onPageHistoryBeforeList
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 onPageHistoryPager__doBatchLookups
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 onChangesListInitRows
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 onDifferenceEngineViewHeader
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 onLocalUserCreated
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
4.12
 onGetLogTypesOnUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onGetAllBlockActions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
90
 onApiMain__moduleManager
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 onLogEventsListLineEnding
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
11.17
1<?php
2
3/**
4 * Hooks for Thanks extension
5 *
6 * @file
7 * @ingroup Extensions
8 */
9
10namespace MediaWiki\Extension\Thanks;
11
12use DifferenceEngine;
13use MediaWiki\Api\ApiModuleManager;
14use MediaWiki\Api\Hook\ApiMain__moduleManagerHook;
15use MediaWiki\Auth\Hook\LocalUserCreatedHook;
16use MediaWiki\Block\Hook\GetAllBlockActionsHook;
17use MediaWiki\Cache\GenderCache;
18use MediaWiki\Config\Config;
19use MediaWiki\Config\ConfigException;
20use MediaWiki\Context\IContextSource;
21use MediaWiki\Context\RequestContext;
22use MediaWiki\Diff\Hook\DifferenceEngineViewHeaderHook;
23use MediaWiki\Diff\Hook\DiffToolsHook;
24use MediaWiki\Extension\Thanks\Api\ApiFlowThank;
25use MediaWiki\Hook\ChangesListInitRowsHook;
26use MediaWiki\Hook\GetLogTypesOnUserHook;
27use MediaWiki\Hook\HistoryToolsHook;
28use MediaWiki\Hook\LogEventsListLineEndingHook;
29use MediaWiki\Hook\PageHistoryBeforeListHook;
30use MediaWiki\Hook\PageHistoryPager__doBatchLookupsHook;
31use MediaWiki\Html\Html;
32use MediaWiki\Logging\DatabaseLogEntry;
33use MediaWiki\Logging\LogEventsList;
34use MediaWiki\Logging\LogPage;
35use MediaWiki\Output\Hook\BeforePageDisplayHook;
36use MediaWiki\Output\OutputPage;
37use MediaWiki\Page\Article;
38use MediaWiki\Page\PageIdentity;
39use MediaWiki\Permissions\PermissionManager;
40use MediaWiki\Registration\ExtensionRegistry;
41use MediaWiki\Revision\RevisionLookup;
42use MediaWiki\Revision\RevisionRecord;
43use MediaWiki\Skin\Skin;
44use MediaWiki\SpecialPage\SpecialPage;
45use MediaWiki\Title\Title;
46use MediaWiki\User\Options\UserOptionsManager;
47use MediaWiki\User\User;
48use MediaWiki\User\UserFactory;
49use MediaWiki\User\UserIdentity;
50
51class Hooks implements
52    ApiMain__moduleManagerHook,
53    BeforePageDisplayHook,
54    DiffToolsHook,
55    DifferenceEngineViewHeaderHook,
56    GetAllBlockActionsHook,
57    GetLogTypesOnUserHook,
58    HistoryToolsHook,
59    LocalUserCreatedHook,
60    LogEventsListLineEndingHook,
61    PageHistoryBeforeListHook,
62    PageHistoryPager__doBatchLookupsHook,
63    ChangesListInitRowsHook
64{
65    public function __construct(
66        private readonly Config $config,
67        private readonly GenderCache $genderCache,
68        private readonly PermissionManager $permissionManager,
69        private readonly RevisionLookup $revisionLookup,
70        private readonly UserFactory $userFactory,
71        private readonly UserOptionsManager $userOptionsManager,
72    ) {
73    }
74
75    /**
76     * Handler for the HistoryTools hook
77     *
78     * @param RevisionRecord $revisionRecord
79     * @param array &$links
80     * @param RevisionRecord|null $oldRevisionRecord
81     * @param UserIdentity $userIdentity
82     */
83    public function onHistoryTools(
84        $revisionRecord,
85        &$links,
86        $oldRevisionRecord,
87        $userIdentity
88    ) {
89        $this->insertThankLink( $revisionRecord,
90            $links, $userIdentity );
91    }
92
93    /**
94     * Handler for the DiffTools hook
95     *
96     * @param RevisionRecord $revisionRecord
97     * @param array &$links
98     * @param RevisionRecord|null $oldRevisionRecord
99     * @param UserIdentity $userIdentity
100     */
101    public function onDiffTools(
102        $revisionRecord,
103        &$links,
104        $oldRevisionRecord,
105        $userIdentity
106    ) {
107        // Don't allow thanking for a diff that includes multiple revisions
108        // This does a query that is too expensive for history rows (T284274)
109        $previous = $this->revisionLookup->getPreviousRevision( $revisionRecord );
110        if ( $oldRevisionRecord && $previous &&
111            $previous->getId() !== $oldRevisionRecord->getId()
112        ) {
113            return;
114        }
115
116        $this->insertThankLink( $revisionRecord,
117            $links, $userIdentity, false );
118    }
119
120    /**
121     * Insert a 'thank' link into revision interface, if the user is allowed to thank.
122     *
123     * @param RevisionRecord $revisionRecord RevisionRecord object to add the thank link for
124     * @param array &$links Links to add to the revision interface
125     * @param UserIdentity $userIdentity The user performing the thanks.
126     * @param bool $isPrimaryButton whether the link/button should be progressive
127     */
128    private function insertThankLink(
129        RevisionRecord $revisionRecord,
130        array &$links,
131        UserIdentity $userIdentity,
132        bool $isPrimaryButton = false
133    ) {
134        $recipient = $revisionRecord->getUser();
135        if ( $recipient === null ) {
136            // Cannot see the user
137            return;
138        }
139
140        $user = $this->userFactory->newFromUserIdentity( $userIdentity );
141
142        // Don't let users thank themselves.
143        // Exclude anonymous users.
144        // Exclude temp users (T345679)
145        // Exclude users who are blocked.
146        // Check whether bots are allowed to receive thanks.
147        if ( $user->isNamed()
148            && !$userIdentity->equals( $recipient )
149            && !$this->isUserBlockedFromPage( $user, $revisionRecord->getPage() )
150            && !self::isUserBlockedFromThanks( $user )
151            && self::canReceiveThanks( $this->config, $this->userFactory, $recipient )
152            && !$revisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
153        ) {
154            $links[] = $this->generateThankElement(
155                $revisionRecord->getId(),
156                $user,
157                $recipient,
158                'revision',
159                $isPrimaryButton
160            );
161        }
162    }
163
164    /**
165     * Check whether the user is blocked from the title associated with the revision.
166     *
167     * This queries the replicas for a block; if 'no block' is incorrectly reported, it
168     * will be caught by ApiThank::dieOnUserBlockedFromPage when the user attempts to thank.
169     *
170     * @param User $user
171     * @param PageIdentity $page
172     * @return bool
173     */
174    private function isUserBlockedFromPage( User $user, PageIdentity $page ): bool {
175        return $this->permissionManager->isBlockedFrom( $user, $page, fromReplica: true );
176    }
177
178    /**
179     * Check whether the user is blocked from giving thanks.
180     *
181     * @param User $user
182     * @return bool
183     */
184    private static function isUserBlockedFromThanks( User $user ) {
185        $block = $user->getBlock();
186        return $block && ( $block->isSitewide() || $block->appliesToRight( 'thanks' ) );
187    }
188
189    /**
190     * Check whether a user is allowed to receive thanks or not
191     *
192     * @param Config $config
193     * @param UserFactory $userFactory
194     * @param UserIdentity $user Recipient
195     * @return bool true if allowed, false if not
196     */
197    public static function canReceiveThanks(
198        Config $config,
199        UserFactory $userFactory,
200        UserIdentity $user
201    ) {
202        $legacyUser = $userFactory->newFromUserIdentity( $user );
203        if ( !$user->isRegistered() || $legacyUser->isSystemUser() ) {
204            return false;
205        }
206
207        if ( !$config->get( 'ThanksSendToBots' ) &&
208            $legacyUser->isBot()
209        ) {
210            return false;
211        }
212
213        return true;
214    }
215
216    /**
217     * Get session key for client-side duplicate thanks prevention
218     *
219     * @param string $type What kind of event is being thanked for.
220     * Currently accepted values are 'rev' / 'revision' (equivalent) and 'log'.
221     * @param string|int $id Identifier of the event (should be unique within its kind).
222     */
223    public static function getSessionKey( string $type, $id ): string {
224        // ApiCoreThank and ::generateThankElement disagree
225        // on the correct type for revisions, accept both.
226        if ( $type === 'revision' ) {
227            $type = 'rev';
228        }
229        return "thanks-thanked-$type$id";
230    }
231
232    /**
233     * Helper for self::insertThankLink
234     * Creates either a thank link or thanked span based on users session
235     * @param int $id Revision or log ID to generate the thank element for.
236     * @param User $sender User who sends thanks notification.
237     * @param UserIdentity $recipient User who receives thanks notification.
238     * @param string $type Either 'revision' or 'log'.
239     * @param bool $isPrimaryButton whether the link/button should be progressive
240     * @return string
241     */
242    protected function generateThankElement(
243        $id, User $sender, UserIdentity $recipient, $type = 'revision',
244        bool $isPrimaryButton = false
245    ) {
246        $useCodex = RequestContext::getMain()->getSkin()->getSkinName() === 'minerva';
247        $class = $useCodex ?
248            'cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--action-progressive' :
249            '';
250        if ( $isPrimaryButton && $useCodex ) {
251            $class .= ' cdx-button--weight-primary';
252        }
253        // Check if the user has already thanked for this revision or log entry.
254        // Session keys are also used in the ApiCoreThank class.
255        if ( $sender->getRequest()->getSessionData( self::getSessionKey( $type, $id ) ) ) {
256            $class .= ' mw-thanks-thanked';
257
258            return Html::element(
259                'span',
260                [ 'class' => $class ],
261                wfMessage( 'thanks-thanked', $sender->getName(), $recipient->getName() )->text()
262            );
263        }
264
265        // Add 'thank' link
266        $tooltip = wfMessage( 'thanks-thank-tooltip' )
267            ->params( $sender->getName(), $recipient->getName() )
268            ->text();
269
270        $class .= ' mw-thanks-thank-link';
271        $subpage = ( $type === 'revision' ) ? '' : 'Log/';
272        return Html::element(
273            'a',
274            [
275                'class' => $class,
276                'href' => SpecialPage::getTitleFor( 'Thanks', $subpage . $id )->getFullURL(),
277                'title' => $tooltip,
278                'role' => 'button',
279                'data-' . $type . '-id' => $id,
280                'data-recipient-gender' => $this->genderCache->getGenderOf( $recipient->getName(), __METHOD__ ),
281            ],
282            wfMessage( 'thanks-thank', $sender->getName(), $recipient->getName() )->text()
283        );
284    }
285
286    /**
287     * @param OutputPage $outputPage The OutputPage to add the module to.
288     */
289    protected function addThanksModule( OutputPage $outputPage ) {
290        $confirmationRequired = $this->config->get( 'ThanksConfirmationRequired' );
291        $outputPage->addModules( [ 'ext.thanks.corethank' ] );
292        $outputPage->addJsConfigVars( 'thanks-confirmation-required', $confirmationRequired );
293    }
294
295    /**
296     * Handler for PageHistoryBeforeList hook.
297     * @see https://www.mediawiki.org/wiki/Manual:Hooks/PageHistoryBeforeList
298     *
299     * @param Article $page Not used
300     * @param IContextSource $context RequestContext object
301     */
302    public function onPageHistoryBeforeList( $page, $context ) {
303        if ( $context->getUser()->isNamed() ) {
304            $this->addThanksModule( $context->getOutput() );
305        }
306    }
307
308    /** @inheritDoc */
309    public function onPageHistoryPager__doBatchLookups( $pager, $result ) {
310        $userNames = [];
311        foreach ( $result as $row ) {
312            if ( $row->user_name !== null ) {
313                $userNames[] = $row->user_name;
314            }
315        }
316        if ( $userNames ) {
317            // Batch lookup for the use of GenderCache::getGenderOf in self::generateThankElement
318            $this->genderCache->doQuery( $userNames, __METHOD__ );
319        }
320    }
321
322    /** @inheritDoc */
323    public function onChangesListInitRows( $changesList, $rows ) {
324        $userNames = [];
325        foreach ( $rows as $row ) {
326            if ( $row->rc_user_text !== null ) {
327                $userNames[] = $row->rc_user_text;
328            }
329        }
330        if ( $userNames ) {
331            // Batch lookup for the use of GenderCache::getGenderOf in self::generateThankElement
332            $this->genderCache->doQuery( $userNames, __METHOD__ );
333        }
334    }
335
336    /**
337     * Handler for DifferenceEngineViewHeader hook.
338     * @see https://www.mediawiki.org/wiki/Manual:Hooks/DifferenceEngineViewHeader
339     * @param DifferenceEngine $diff DifferenceEngine object that's calling.
340     */
341    public function onDifferenceEngineViewHeader( $diff ) {
342        if ( $diff->getUser()->isNamed() ) {
343            $this->addThanksModule( $diff->getOutput() );
344        }
345    }
346
347    /**
348     * Handler for LocalUserCreated hook
349     * @see https://www.mediawiki.org/wiki/Manual:Hooks/LocalUserCreated
350     * @param User $user User object that was created.
351     * @param bool $autocreated True when account was auto-created
352     */
353    public function onLocalUserCreated( $user, $autocreated ) {
354        // New users get echo preferences set that are not the default settings for existing users.
355        // Specifically, new users are opted into email notifications for thanks.
356        if ( !$user->isTemp() && !$autocreated ) {
357            $this->userOptionsManager->setOption( $user, 'echo-subscriptions-email-edit-thank', true );
358        }
359    }
360
361    /**
362     * Handler for GetLogTypesOnUser.
363     * So users can just type in a username for target and it'll work.
364     * @link https://www.mediawiki.org/wiki/Manual:Hooks/GetLogTypesOnUser
365     * @param string[] &$types The list of log types, to add to.
366     */
367    public function onGetLogTypesOnUser( &$types ) {
368        $types[] = 'thanks';
369    }
370
371    /** @inheritDoc */
372    public function onGetAllBlockActions( &$actions ) {
373        $actions[ 'thanks' ] = 100;
374    }
375
376    /**
377     * Handler for BeforePageDisplay.  Inserts javascript to enhance thank
378     * links from static urls to in-page dialogs along with reloading
379     * the previously thanked state.
380     * @link https://www.mediawiki.org/wiki/Manual:Hooks/BeforePageDisplay
381     * @param OutputPage $out OutputPage object
382     * @param Skin $skin The skin in use.
383     */
384    public function onBeforePageDisplay( $out, $skin ): void {
385        $title = $out->getTitle();
386        // Add to Flow boards.
387        if ( $title instanceof Title && $title->hasContentModel( 'flow-board' ) ) {
388            $out->addModules( 'ext.thanks.flowthank' );
389        }
390        // Add to special pages where thank links appear
391        if (
392            $title->isSpecial( 'Log' ) ||
393            $title->isSpecial( 'Contributions' ) ||
394            $title->isSpecial( 'DeletedContributions' ) ||
395            $title->isSpecial( 'Recentchanges' ) ||
396            $title->isSpecial( 'Recentchangeslinked' ) ||
397            $title->isSpecial( 'Watchlist' )
398        ) {
399            $this->addThanksModule( $out );
400        }
401    }
402
403    /**
404     * Conditionally load API module 'flowthank' depending on whether or not
405     * Flow is installed.
406     *
407     * @param ApiModuleManager $moduleManager Module manager instance
408     */
409    public function onApiMain__moduleManager( $moduleManager ) {
410        if ( ExtensionRegistry::getInstance()->isLoaded( 'Flow' ) ) {
411            $moduleManager->addModule(
412                'flowthank',
413                'action',
414                [
415                    "class" => ApiFlowThank::class,
416                    "services" => [
417                        "PermissionManager",
418                        "ThanksLogStore",
419                        "NotificationService",
420                        "UserFactory",
421                    ],
422                ]
423            );
424        }
425    }
426
427    /**
428     * Insert a 'thank' link into the log interface, if the user is allowed to thank.
429     *
430     * @link https://www.mediawiki.org/wiki/Manual:Hooks/LogEventsListLineEnding
431     * @param LogEventsList $page The log events list.
432     * @param string &$ret The line ending HTML, to modify.
433     * @param DatabaseLogEntry $entry The log entry.
434     * @param string[] &$classes CSS classes to add to the line.
435     * @param string[] &$attribs HTML attributes to add to the line.
436     * @throws ConfigException
437     */
438    public function onLogEventsListLineEnding(
439        $page, &$ret, $entry, &$classes, &$attribs
440    ) {
441        $user = $page->getUser();
442
443        // Don't provide thanks link if not named, blocked or if user is deleted from the log entry
444        if (
445            !$user->isNamed()
446            || $entry->isDeleted( LogPage::DELETED_USER )
447            || $this->isUserBlockedFromPage( $user, $entry->getTarget() )
448            || self::isUserBlockedFromThanks( $user )
449        ) {
450            return;
451        }
452
453        // Make sure this log type is allowed.
454        $allowedLogTypes = $this->config->get( 'ThanksAllowedLogTypes' );
455        if ( !in_array( $entry->getType(), $allowedLogTypes )
456            && !in_array( $entry->getType() . '/' . $entry->getSubtype(), $allowedLogTypes ) ) {
457            return;
458        }
459
460        // Don't thank if no recipient,
461        // or if recipient is the current user or unable to receive thanks.
462        // Don't check for deleted revision (this avoids extraneous queries from Special:Log).
463
464        $recipient = $entry->getPerformerIdentity();
465        if ( $recipient->getId() === $user->getId() ||
466            !self::canReceiveThanks( $this->config, $this->userFactory, $recipient )
467        ) {
468            return;
469        }
470
471        // Create thank link either for the revision (if there is an associated revision ID)
472        // or the log entry.
473        $type = $entry->getAssociatedRevId() ? 'revision' : 'log';
474        $id = $entry->getAssociatedRevId() ?: $entry->getId();
475        $thankLink = $this->generateThankElement( $id, $user, $recipient, $type );
476
477        // Add parentheses to match what's done with Thanks in revision lists and diff displays.
478        $ret .= ' ' . wfMessage( 'parentheses' )->rawParams( $thankLink )->escaped();
479    }
480}