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