Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
7.25% covered (danger)
7.25%
53 / 731
9.86% covered (danger)
9.86%
7 / 71
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
7.25% covered (danger)
7.25%
53 / 731
9.86% covered (danger)
9.86%
7 / 71
46197.82
0.00% covered (danger)
0.00%
0 / 1
 registerExtension
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onResourceLoaderRegisterModules
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 getAbuseFilter
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 initFlowExtension
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 resetFlowExtension
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onChangesListInitRows
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 onChangesListInsertArticleLink
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onOldChangesListRecentChangesLine
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 processRecentChangesLine
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 onEnhancedChangesList__getLogText
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
 onEnhancedChangesListModifyLineData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onEnhancedChangesListModifyBlockLineData
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 modifyChangesListLine
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 isFlow
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onSpecialCheckUserGetLinksFromRow
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 onCheckUserFormatRow
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 getReplacementRowItems
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 onSkinTemplateNavigation__Universal
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
72
 onArticle__MissingArticleConditions
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 onUserGetReservedNames
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 onResourceLoaderGetConfigVars
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 onDeletedContributionsLineEnding
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 onContributionsLineEnding
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onApiFeedContributions__feedItem
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 getContributionsQuery
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 onDeletedContribsPager__reallyDoQuery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onContribsPager__reallyDoQuery
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onAbuseFilterBuilder
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 onAbuseFilterDeprecatedVariables
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onAbuseFilterComputeVariable
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onArticleEditUpdateNewTalk
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isTalkpageManagerUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onInfoAction
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 onCheckUserInsertChangesRow
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 onIRCLineURL
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
5.02
 onGetPreferences
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 handleWatchArticle
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 onWatchArticle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onUnwatchArticle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onMovePageIsValidMove
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 onMovePageCheckPermissions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 onTitleSquidURLs
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 onWatchlistEditorBuildRemoveLine
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 onWatchlistEditorBeforeFormRender
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 onUserMergeAccountFields
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onMergeAccountFromTo
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onIsLiquidThreadsPage
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 onCategoryViewer__doCategoryQuery
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onCategoryViewer__generateLink
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 getTopicDeletionError
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onArticleConfirmDelete
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onArticleDelete
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onArticleDeleteComplete
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 onRevisionUndeleted
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 onArticleUndelete
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onTitleMoveStarting
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 onPageMoveCompleting
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onShowMissingArticle
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 onSearchableNamespaces
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isBetaFeatureAvailable
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 onGetBetaFeaturePreferences
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 onSaveUserOptions
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
 onImportHandleToplevelXMLTag
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
210
 onNukeGetNewPages
0.00% covered (danger)
0.00%
0 / 99
0.00% covered (danger)
0.00%
0 / 1
420
 onNukeDeletePage
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
42
 onChangesListSpecialPageQuery
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onGetUserPermissionsErrors
42.86% covered (danger)
42.86%
6 / 14
0.00% covered (danger)
0.00%
0 / 1
24.11
 getTermsOfUseMessages
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getTermsOfUseMessagesParsed
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getTermsOfUseMessagesVersion
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace Flow;
4
5use Exception;
6use Flow\Collection\PostCollection;
7use Flow\Data\Listener\RecentChangesListener;
8use Flow\Exception\FlowException;
9use Flow\Exception\InvalidInputException;
10use Flow\Exception\PermissionException;
11use Flow\Formatter\CheckUserQuery;
12use Flow\Hooks\HookRunner;
13use Flow\Import\OptInController;
14use Flow\Model\UUID;
15use Flow\SpamFilter\AbuseFilter;
16use MediaWiki\Actions\Hook\InfoActionHook;
17use MediaWiki\Actions\Hook\UnwatchArticleHook;
18use MediaWiki\Actions\Hook\WatchArticleHook;
19use MediaWiki\Api\Hook\ApiFeedContributions__feedItemHook;
20use MediaWiki\CheckUser\CheckUser\Pagers\AbstractCheckUserPager;
21use MediaWiki\Config\Config;
22use MediaWiki\Content\Content;
23use MediaWiki\Context\IContextSource;
24use MediaWiki\Context\RequestContext;
25use MediaWiki\Deferred\DeferredUpdates;
26use MediaWiki\Exception\MWExceptionHandler;
27use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
28use MediaWiki\Extension\BetaFeatures\BetaFeatures;
29use MediaWiki\Extension\GuidedTour\GuidedTourLauncher;
30use MediaWiki\Feed\FeedItem;
31use MediaWiki\Hook\CategoryViewer__doCategoryQueryHook;
32use MediaWiki\Hook\CategoryViewer__generateLinkHook;
33use MediaWiki\Hook\MovePageCheckPermissionsHook;
34use MediaWiki\Hook\MovePageIsValidMoveHook;
35use MediaWiki\Hook\PageMoveCompletingHook;
36use MediaWiki\Hook\TitleMoveStartingHook;
37use MediaWiki\Hook\TitleSquidURLsHook;
38use MediaWiki\Html\FormOptions;
39use MediaWiki\Html\Html;
40use MediaWiki\Import\Hook\ImportHandleToplevelXMLTagHook;
41use MediaWiki\Import\WikiImporter;
42use MediaWiki\Language\MessageLocalizer;
43use MediaWiki\Logging\LogEntry;
44use MediaWiki\MediaWikiServices;
45use MediaWiki\Message\Message;
46use MediaWiki\Output\Hook\BeforePageDisplayHook;
47use MediaWiki\Output\OutputPage;
48use MediaWiki\Page\Article;
49use MediaWiki\Page\Hook\Article__MissingArticleConditionsHook;
50use MediaWiki\Page\Hook\ArticleConfirmDeleteHook;
51use MediaWiki\Page\Hook\ArticleDeleteCompleteHook;
52use MediaWiki\Page\Hook\ArticleDeleteHook;
53use MediaWiki\Page\Hook\ArticleUndeleteHook;
54use MediaWiki\Page\Hook\RevisionUndeletedHook;
55use MediaWiki\Page\Hook\ShowMissingArticleHook;
56use MediaWiki\Page\WikiPage;
57use MediaWiki\Pager\IndexPager;
58use MediaWiki\Parser\ParserOptions;
59use MediaWiki\Permissions\Hook\GetUserPermissionsErrorsHook;
60use MediaWiki\Preferences\Hook\GetPreferencesHook;
61use MediaWiki\RCFeed\Hook\IRCLineURLHook;
62use MediaWiki\RecentChanges\ChangesList;
63use MediaWiki\RecentChanges\EnhancedChangesList;
64use MediaWiki\RecentChanges\Hook\ChangesListInitRowsHook;
65use MediaWiki\RecentChanges\Hook\ChangesListInsertArticleLinkHook;
66use MediaWiki\RecentChanges\Hook\EnhancedChangesList__getLogTextHook;
67use MediaWiki\RecentChanges\Hook\EnhancedChangesListModifyBlockLineDataHook;
68use MediaWiki\RecentChanges\Hook\EnhancedChangesListModifyLineDataHook;
69use MediaWiki\RecentChanges\Hook\OldChangesListRecentChangesLineHook;
70use MediaWiki\RecentChanges\OldChangesList;
71use MediaWiki\RecentChanges\RecentChange;
72use MediaWiki\Registration\ExtensionRegistry;
73use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
74use MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook;
75use MediaWiki\ResourceLoader\ResourceLoader;
76use MediaWiki\Revision\RevisionRecord;
77use MediaWiki\Revision\SlotRecord;
78use MediaWiki\Search\Hook\SearchableNamespacesHook;
79use MediaWiki\Skin\Hook\SkinTemplateNavigation__UniversalHook;
80use MediaWiki\Skin\Skin;
81use MediaWiki\Skin\SkinTemplate;
82use MediaWiki\SpecialPage\Hook\ChangesListSpecialPageQueryHook;
83use MediaWiki\Specials\Hook\ContribsPager__reallyDoQueryHook;
84use MediaWiki\Specials\Hook\ContributionsLineEndingHook;
85use MediaWiki\Specials\Hook\DeletedContribsPager__reallyDoQueryHook;
86use MediaWiki\Specials\Hook\DeletedContributionsLineEndingHook;
87use MediaWiki\Specials\Hook\WatchlistEditorBeforeFormRenderHook;
88use MediaWiki\Specials\Hook\WatchlistEditorBuildRemoveLineHook;
89use MediaWiki\Specials\Pager\ContribsPager;
90use MediaWiki\Specials\Pager\DeletedContribsPager;
91use MediaWiki\Status\Status;
92use MediaWiki\Storage\Hook\ArticleEditUpdateNewTalkHook;
93use MediaWiki\Title\Title;
94use MediaWiki\User\Hook\UserGetReservedNamesHook;
95use MediaWiki\User\Options\Hook\SaveUserOptionsHook;
96use MediaWiki\User\User;
97use MediaWiki\User\UserIdentity;
98use MediaWiki\WikiMap\WikiMap;
99use stdClass;
100use Wikimedia\Rdbms\SelectQueryBuilder;
101use XMLReader;
102
103class Hooks implements
104    ResourceLoaderRegisterModulesHook,
105    BeforePageDisplayHook,
106    GetPreferencesHook,
107    OldChangesListRecentChangesLineHook,
108    ChangesListInsertArticleLinkHook,
109    ChangesListInitRowsHook,
110    EnhancedChangesList__getLogTextHook,
111    EnhancedChangesListModifyLineDataHook,
112    EnhancedChangesListModifyBlockLineDataHook,
113    ChangesListSpecialPageQueryHook,
114    SkinTemplateNavigation__UniversalHook,
115    Article__MissingArticleConditionsHook,
116    UserGetReservedNamesHook,
117    ResourceLoaderGetConfigVarsHook,
118    ContribsPager__reallyDoQueryHook,
119    DeletedContribsPager__reallyDoQueryHook,
120    ContributionsLineEndingHook,
121    DeletedContributionsLineEndingHook,
122    ApiFeedContributions__feedItemHook,
123    ArticleEditUpdateNewTalkHook,
124    InfoActionHook,
125    IRCLineURLHook,
126    ShowMissingArticleHook,
127    WatchArticleHook,
128    UnwatchArticleHook,
129    MovePageCheckPermissionsHook,
130    MovePageIsValidMoveHook,
131    TitleMoveStartingHook,
132    PageMoveCompletingHook,
133    TitleSquidURLsHook,
134    WatchlistEditorBuildRemoveLineHook,
135    WatchlistEditorBeforeFormRenderHook,
136    CategoryViewer__doCategoryQueryHook,
137    CategoryViewer__generateLinkHook,
138    ArticleConfirmDeleteHook,
139    ArticleDeleteHook,
140    ArticleDeleteCompleteHook,
141    RevisionUndeletedHook,
142    ArticleUndeleteHook,
143    SearchableNamespacesHook,
144    ImportHandleToplevelXMLTagHook,
145    SaveUserOptionsHook,
146    GetUserPermissionsErrorsHook
147{
148    /**
149     * @var AbuseFilter|null Initialized during extension initialization
150     */
151    protected static $abuseFilter;
152
153    public static function registerExtension() {
154        require_once dirname( __DIR__ ) . '/defines.php';
155    }
156
157    public function onResourceLoaderRegisterModules( ResourceLoader $resourceLoader ): void {
158        // Register a dummy supportCheck module in case VE isn't loaded, as we attempt
159        // to load this module unconditionally on load.
160        if ( !$resourceLoader->isModuleRegistered( 'ext.visualEditor.supportCheck' ) ) {
161            $resourceLoader->register( 'ext.visualEditor.supportCheck', [] );
162        }
163
164        if ( ExtensionRegistry::getInstance()->isLoaded( 'GuidedTour' ) ) {
165            $resourceLoader->register( 'ext.guidedTour.tour.flowOptIn', [
166                'localBasePath' => dirname( __DIR__ ) . '/modules',
167                'remoteExtPath' => 'Flow/modules',
168                'scripts' => 'tours/flowOptIn.js',
169                'styles' => 'tours/flowOptIn.less',
170                'messages' => [
171                    "flow-guidedtour-optin-welcome",
172                    "flow-guidedtour-optin-welcome-description",
173                    "flow-guidedtour-optin-find-old-conversations",
174                    "flow-guidedtour-optin-find-old-conversations-description",
175                    "flow-guidedtour-optin-feedback",
176                    "flow-guidedtour-optin-feedback-description"
177                ],
178                'dependencies' => 'ext.guidedTour',
179            ] );
180        }
181    }
182
183    public function onBeforePageDisplay( $out, $skin ): void {
184        $title = $skin->getTitle();
185
186        // Register guided tour if needed
187        if (
188            // Check that the cookie for Flow opt-in tour exists
189            $out->getRequest()->getCookie( 'Flow_optIn_guidedTour' ) &&
190            // Check that the user is on their own talk page
191            $out->getUser()->getTalkPage()->equals( $title ) &&
192            // Check that we are on a flow board
193            $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD &&
194            // Check that guided tour exists
195            ExtensionRegistry::getInstance()->isLoaded( 'GuidedTour' )
196        ) {
197            // Activate tour
198            GuidedTourLauncher::launchTourByCookie( $out, 'flowOptIn', 'newTopic' );
199
200            // Destroy Flow cookie
201            $out->getRequest()->response()->setCookie( 'Flow_optIn_guidedTour', '', time() - 3600 );
202        }
203    }
204
205    /**
206     * Initialized during extension initialization rather than
207     * in container so that non-flow pages don't load the container.
208     *
209     * @return AbuseFilter
210     */
211    public static function getAbuseFilter() {
212        if ( self::$abuseFilter === null ) {
213            global $wgFlowAbuseFilterGroup,
214                $wgFlowAbuseFilterEmergencyDisableThreshold,
215                $wgFlowAbuseFilterEmergencyDisableCount,
216                $wgFlowAbuseFilterEmergencyDisableAge;
217
218            self::$abuseFilter = new AbuseFilter( $wgFlowAbuseFilterGroup );
219            self::$abuseFilter->setup( [
220                'threshold' => $wgFlowAbuseFilterEmergencyDisableThreshold,
221                'count' => $wgFlowAbuseFilterEmergencyDisableCount,
222                'age' => $wgFlowAbuseFilterEmergencyDisableAge,
223            ] );
224        }
225        return self::$abuseFilter;
226    }
227
228    /**
229     * Initialize Flow extension with necessary data, this function is invoked
230     * from $wgExtensionFunctions
231     */
232    public static function initFlowExtension() {
233        global $wgFlowAbuseFilterGroup;
234
235        // necessary to provide flow options in abuse filter on-wiki pages
236        if ( $wgFlowAbuseFilterGroup ) {
237            self::getAbuseFilter();
238        }
239    }
240
241    /**
242     * Reset anything that happened in self::initFlowExtension for
243     * unit tests
244     */
245    public static function resetFlowExtension() {
246        self::$abuseFilter = null;
247    }
248
249    /**
250     * Loads RecentChanges list metadata into a temporary cache for later use.
251     *
252     * @param ChangesList $changesList
253     * @param array $rows
254     */
255    public function onChangesListInitRows( $changesList, $rows ) {
256        if ( !( $changesList instanceof OldChangesList || $changesList instanceof EnhancedChangesList ) ) {
257            return;
258        }
259
260        set_error_handler( new RecoverableErrorHandler, -1 );
261        try {
262            /** @var Formatter\ChangesListQuery $query */
263            $query = Container::get( 'query.changeslist' );
264            $query->loadMetadataBatch(
265                $rows,
266                $changesList->isWatchlist()
267            );
268        } catch ( Exception $e ) {
269            MWExceptionHandler::logException( $e );
270        } finally {
271            restore_error_handler();
272        }
273    }
274
275    /**
276     * Updates the given Flow topic line in an enhanced changes list (grouped RecentChanges).
277     *
278     * @param ChangesList $changesList
279     * @param string &$articlelink
280     * @param string &$s
281     * @param RecentChange $rc
282     * @param bool $unpatrolled
283     * @param bool $isWatchlist
284     * @return bool
285     */
286    public function onChangesListInsertArticleLink(
287        $changesList,
288        &$articlelink,
289        &$s,
290        $rc,
291        $unpatrolled,
292        $isWatchlist
293    ) {
294        if ( !( $changesList instanceof EnhancedChangesList ) ) {
295            // This method is only to update EnhancedChangesList.
296            // onOldChangesListRecentChangesLine allows updating OldChangesList,
297            // and supports adding wrapper classes.
298            return true;
299        }
300        $classes = null; // avoid pass-by-reference error
301        return self::processRecentChangesLine( $changesList, $articlelink, $rc, $classes, true );
302    }
303
304    /**
305     * Updates a Flow line in the old changes list (standard RecentChanges).
306     *
307     * @param ChangesList $changesList
308     * @param string &$s
309     * @param RecentChange $rc
310     * @param array &$classes
311     * @param array &$attribs
312     * @return bool
313     */
314    public function onOldChangesListRecentChangesLine(
315        $changesList,
316        &$s,
317        $rc,
318        &$classes,
319        &$attribs
320    ) {
321        return self::processRecentChangesLine( $changesList, $s, $rc, $classes );
322    }
323
324    /**
325     * Does the actual work for onOldChangesListRecentChangesLine and
326     * onChangesListInsertArticleLink hooks. Either updates an entire
327     * line with meta info (old changes), or simply updates the link to
328     * the topic (enhanced).
329     *
330     * @param ChangesList $changesList
331     * @param string &$s
332     * @param RecentChange $rc
333     * @param array|null &$classes
334     * @param bool $topicOnly
335     * @return bool
336     */
337    protected static function processRecentChangesLine(
338        ChangesList $changesList,
339        &$s,
340        RecentChange $rc,
341        &$classes = null,
342        $topicOnly = false
343    ) {
344        if ( $rc->getAttribute( 'rc_source' ) !== RecentChangesListener::SRC_FLOW ) {
345            return true;
346        }
347
348        $line = false;
349        set_error_handler( new RecoverableErrorHandler, -1 );
350        try {
351            /** @var Formatter\ChangesListQuery $query */
352            $query = Container::get( 'query.changeslist' );
353
354            $row = $query->getResult( $changesList, $rc, $changesList->isWatchlist() );
355            if ( $row === false ) {
356                restore_error_handler();
357                return false;
358            }
359
360            /** @var Formatter\ChangesListFormatter $formatter */
361            $formatter = Container::get( 'formatter.changeslist' );
362            $line = $formatter->format( $row, $changesList, $topicOnly );
363        } catch ( PermissionException ) {
364            // It is expected that some rows won't be formatted because the current user
365            // doesn't have permission to see some of the data they contain.
366            return false;
367        } catch ( Exception $e ) {
368            wfDebugLog( 'Flow', __METHOD__ . ': Exception formatting rc ' .
369                $rc->getAttribute( 'rc_id' ) . ' ' . $e );
370            MWExceptionHandler::logException( $e );
371            return false;
372        } finally {
373            restore_error_handler();
374        }
375
376        if ( $line === false ) {
377            return false;
378        }
379
380        if ( is_array( $classes ) ) {
381            // Add the flow class to <li>
382            $classes[] = 'flow-recentchanges-line';
383            $classes[] = 'mw-changeslist-src-mw-edit';
384        }
385        // Update the line markup
386        $s = $line;
387
388        return true;
389    }
390
391    /**
392     * Alter the enhanced RC links: (n changes | history)
393     * The default diff links are incorrect!
394     *
395     * @param EnhancedChangesList $changesList
396     * @param array &$links
397     * @param RecentChange[] $block
398     * @return bool
399     */
400    public function onEnhancedChangesList__getLogText( $changesList, &$links, $block ) {
401        $rc = $block[0];
402
403        // quit if non-flow
404        // FIXME: It could be that $rc is a non-Flow change (e.g. Wikidata), but $block still
405        // contains Flow changes. In that case we should probably process those?
406        if ( !self::isFlow( $rc ) ) {
407            return true;
408        }
409
410        $logTextLinks = false;
411        set_error_handler( new RecoverableErrorHandler, -1 );
412        try {
413            /** @var Formatter\ChangesListQuery $query */
414            $query = Container::get( 'query.changeslist' );
415
416            $row = $query->getResult( $changesList, $rc, $changesList->isWatchlist() );
417            if ( $row === false ) {
418                restore_error_handler();
419                return false;
420            }
421
422            /** @var Formatter\ChangesListFormatter $formatter */
423            $formatter = Container::get( 'formatter.changeslist' );
424            $logTextLinks = $formatter->getLogTextLinks( $row, $changesList, $block, $links );
425        } catch ( Exception $e ) {
426            wfDebugLog( 'Flow', __METHOD__ . ': Exception formatting rc logtext ' .
427                $rc->getAttribute( 'rc_id' ) . ' ' . $e );
428            MWExceptionHandler::logException( $e );
429            return false;
430        } finally {
431            restore_error_handler();
432        }
433
434        if ( $logTextLinks === false ) {
435            return false;
436        }
437
438        $links = $logTextLinks;
439        return true;
440    }
441
442    /**
443     * @param EnhancedChangesList $changesList
444     * @param array &$data
445     * @param RecentChange[] $block
446     * @param RecentChange $rc
447     * @param string[] &$classes
448     * @param string[] &$attribs
449     * @return bool
450     */
451    public function onEnhancedChangesListModifyLineData( $changesList, &$data, $block, $rc, &$classes, &$attribs ) {
452        return static::modifyChangesListLine( $changesList, $data, $rc, $classes );
453    }
454
455    /**
456     * @param EnhancedChangesList $changesList
457     * @param array &$data
458     * @param RecentChange $rc
459     * @return bool
460     */
461    public function onEnhancedChangesListModifyBlockLineData( $changesList, &$data, $rc ) {
462        $classes = [];
463        return static::modifyChangesListLine( $changesList, $data, $rc, $classes );
464    }
465
466    /**
467     * @param ChangesList $changesList
468     * @param array &$data
469     * @param RecentChange $rc
470     * @param string[] &$classes
471     * @return bool
472     */
473    private static function modifyChangesListLine( $changesList, &$data, $rc, &$classes ) {
474        // quit if non-flow
475        if ( !self::isFlow( $rc ) ) {
476            return true;
477        }
478
479        $query = Container::get( 'query.changeslist' );
480        $row = $query->getResult( $changesList, $rc, $changesList->isWatchlist() );
481        if ( $row === false ) {
482            return false;
483        }
484
485        /** @var Formatter\ChangesListFormatter $formatter */
486        $formatter = Container::get( 'formatter.changeslist' );
487        try {
488            $data['timestampLink'] = $formatter->getTimestampLink( $row, $changesList );
489            $data['recentChangesFlags'] = array_merge(
490                $data['recentChangesFlags'],
491                $formatter->getFlags( $row, $changesList )
492            );
493            $classes[] = 'mw-changeslist-src-mw-edit';
494        } catch ( PermissionException ) {
495            return false;
496        }
497
498        return true;
499    }
500
501    /**
502     * Checks if the given recent change entry is from Flow
503     * @param RecentChange $rc
504     * @return bool
505     */
506    private static function isFlow( $rc ) {
507        return $rc->getAttribute( 'rc_source' ) === RecentChangesListener::SRC_FLOW;
508    }
509
510    public static function onSpecialCheckUserGetLinksFromRow( AbstractCheckUserPager $pager, $row, &$links ) {
511        // TODO: Replace accesses to $row properties with the prefix "cuc_" to
512        // remove the need for this aliasing.
513        if ( isset( $row->type ) ) {
514            $row->cuc_type = $row->type;
515        }
516        if ( isset( $row->comment_text ) ) {
517            $row->cuc_comment_text = $row->comment_text;
518            $row->cuc_comment_data = $row->comment_data;
519        }
520
521        if ( $row->cuc_type != RC_FLOW ) {
522            return;
523        }
524
525        $replacement = self::getReplacementRowItems( $pager->getContext(), $row );
526
527        if ( $replacement === null ) {
528            // some sort of failure, but this is a RC_FLOW so blank out hist/diff links
529            // which aren't correct
530            $links['history'] = '';
531            $links['diff'] = '';
532        } else {
533            $links = $replacement;
534        }
535    }
536
537    public static function onCheckUserFormatRow( IContextSource $context, $row, &$rowItems ) {
538        // TODO: Replace accesses to $row properties with the prefix "cuc_" to
539        // remove the need for this aliasing.
540        if ( isset( $row->type ) ) {
541            $row->cuc_type = $row->type;
542        }
543        if ( isset( $row->comment_text ) ) {
544            $row->cuc_comment_text = $row->comment_text;
545            $row->cuc_comment_data = $row->comment_data;
546        }
547
548        if ( $row->cuc_type != RC_FLOW ) {
549            return;
550        }
551
552        $replacement = self::getReplacementRowItems( $context, $row );
553
554        // These links are incorrect for Flow
555        $rowItems['links']['diffLink'] = '';
556        $rowItems['links']['historyLink'] = '';
557
558        if ( $replacement !== null ) {
559            array_unshift( $rowItems['links'], $replacement['links'] );
560            $rowItems['info']['titleLink'] = $replacement['title'];
561        }
562    }
563
564    /**
565     * @param IContextSource $context
566     * @param stdClass $row
567     * @return array|null
568     */
569    private static function getReplacementRowItems( IContextSource $context, $row ): ?array {
570        set_error_handler( new RecoverableErrorHandler, -1 );
571        $replacement = null;
572        try {
573            /** @var CheckUserQuery $query */
574            $query = Container::get( 'query.checkuser' );
575            // @todo: create hook to allow batch-loading this data, instead of doing piecemeal like this
576            $query->loadMetadataBatch( [ $row ] );
577            $row = $query->getResult( $row );
578            if ( $row !== false ) {
579                /** @var Formatter\CheckUserFormatter $formatter */
580                $formatter = Container::get( 'formatter.checkuser' );
581                $replacement = $formatter->format( $row, $context );
582            }
583        } catch ( Exception $e ) {
584            wfDebugLog( 'Flow', __METHOD__ . ': Exception formatting cu ' . json_encode( $row ) . ' ' . $e );
585            MWExceptionHandler::logException( $e );
586        } finally {
587            restore_error_handler();
588        }
589
590        return $replacement;
591    }
592
593    /**
594     * Regular talk page "Create source" and "Add topic" links are quite useless
595     * in the context of Flow boards. Let's get rid of them.
596     *
597     * @param SkinTemplate $template
598     * @param array &$links
599     */
600    public function onSkinTemplateNavigation__Universal( $template, &$links ): void {
601        global $wgFlowCoreActionWhitelist,
602            $wgMFPageActions;
603
604        $title = $template->getTitle();
605
606        // if Flow is enabled on this talk page, overrule talk page red link
607        if ( $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
608            // Turn off page actions in MobileFrontend.
609            // FIXME: Find more elegant standard way of doing this.
610            $wgMFPageActions = [];
611
612            // watch star & delete links are inside the topic itself
613            if ( $title->getNamespace() === NS_TOPIC ) {
614                unset( $links['actions']['watch'] );
615                unset( $links['actions']['unwatch'] );
616                unset( $links['actions']['delete'] );
617            }
618
619            // hide all views unless whitelisted
620            foreach ( $links['views'] as $action => $data ) {
621                if ( !in_array( $action, $wgFlowCoreActionWhitelist ) ) {
622                    unset( $links['views'][$action] );
623                }
624            }
625
626            // hide all actions unless whitelisted
627            foreach ( $links['actions'] as $action => $data ) {
628                if ( !in_array( $action, $wgFlowCoreActionWhitelist ) ) {
629                    unset( $links['actions'][$action] );
630                }
631            }
632
633            if ( isset( $links['namespaces']['topic_talk'] ) ) {
634                // hide discussion page in Topic namespace(which is already discussion)
635                unset( $links['namespaces']['topic_talk'] );
636                unset( $links['associated-pages']['topic_talk'] );
637                // hide protection (topic protection is done via moderation)
638                unset( $links['actions']['protect'] );
639                // topic pages are also not movable
640                unset( $links['actions']['move'] );
641            }
642        }
643    }
644
645    /**
646     * When a (talk) page does not exist, one of the checks being performed is
647     * to see if the page had once existed but was removed. In doing so, the
648     * deletion & move log is checked.
649     *
650     * In theory, a Flow board could overtake a non-existing talk page. If that
651     * board is later removed, this will be run to see if a message can be
652     * displayed to inform the user if the page has been deleted/moved.
653     *
654     * Since, in Flow, we also write (topic, post, ...) deletion to the deletion
655     * log, we don't want those to appear, since they're not actually actions
656     * related to that talk page (rather: they were actions on the board)
657     *
658     * @param array &$conds Array of conditions
659     * @param array $logTypes Array of log types
660     */
661    public function onArticle__MissingArticleConditions( &$conds, $logTypes ) {
662        global $wgLogActionsHandlers;
663        /** @var FlowActions $actions */
664        $actions = Container::get( 'flow_actions' );
665
666        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
667
668        foreach ( $actions->getActions() as $action ) {
669            foreach ( $logTypes as $logType ) {
670                // Check if Flow actions are defined for the requested log types
671                // and make sure they're ignored.
672                if ( isset( $wgLogActionsHandlers["$logType/flow-$action"] ) ) {
673                    $conds[] = $dbr->expr( 'log_action', '!=', "flow-$action" );
674                }
675            }
676        }
677    }
678
679    /**
680     * Make sure no user can register a flow-*-usertext username, to avoid
681     * confusion with a real user when we print e.g. "Suppressed" instead of a
682     * username. Additionally reserve the username used to add a revision on
683     * taking over a page.
684     *
685     * @param array &$names
686     */
687    public function onUserGetReservedNames( &$names ) {
688        $permissions = Model\AbstractRevision::$perms;
689        foreach ( $permissions as $permission ) {
690            $names[] = "msg:flow-$permission-usertext";
691        }
692
693        // Reserve the bot account we use during content model changes & LQT conversion
694        $names[] = FLOW_TALK_PAGE_MANAGER_USER;
695    }
696
697    /**
698     * Static variables that do not vary by request; delivered through startup module
699     * @param array &$vars
700     * @param string $skin
701     * @param Config $config
702     */
703    public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void {
704        global $wgFlowAjaxTimeout;
705
706        $vars['wgFlowMaxTopicLength'] = Model\PostRevision::MAX_TOPIC_LENGTH;
707        $vars['wgFlowMentionTemplate'] = wfMessage( 'flow-ve-mention-template-title' )->inContentLanguage()->plain();
708        $vars['wgFlowAjaxTimeout'] = $wgFlowAjaxTimeout;
709    }
710
711    /**
712     * Intercept contribution entries and format those belonging to Flow
713     *
714     * @param IContextSource $pager
715     * @param string &$ret The HTML line
716     * @param stdClass $row The data for this line
717     * @param array &$classes the classes to add to the surrounding <li>
718     * @param array &$attribs
719     * @return bool
720     */
721    public function onDeletedContributionsLineEnding( $pager, &$ret, $row, &$classes, &$attribs ) {
722        if ( !$row instanceof Formatter\FormatterRow ) {
723            return true;
724        }
725
726        set_error_handler( new RecoverableErrorHandler, -1 );
727        try {
728            /** @var Formatter\ContributionsFormatter $formatter */
729            $formatter = Container::get( 'formatter.contributions' );
730            $line = $formatter->format( $row, $pager );
731        } catch ( PermissionException ) {
732            $line = false;
733        } catch ( Exception $e ) {
734            wfDebugLog( 'Flow', __METHOD__ . ': Failed formatting contribution ' .
735                json_encode( $row ) . ': ' . $e->getMessage() );
736            MWExceptionHandler::logException( $e );
737            $line = false;
738        } finally {
739            restore_error_handler();
740        }
741
742        if ( $line === false ) {
743            return false;
744        }
745
746        $classes[] = 'mw-flow-contribution';
747        $ret = $line;
748
749        // If we output one or more lines of contributions entries we also need to include
750        // the javascript that hooks into moderation actions.
751        $pager->getOutput()->addModules( [ 'ext.flow.contributions' ] );
752        $pager->getOutput()->addModuleStyles( [ 'ext.flow.contributions.styles' ] );
753
754        return true;
755    }
756
757    /**
758     * Intercept contribution entries and format those belonging to Flow
759     *
760     * @param IContextSource $pager
761     * @param string &$ret The HTML line
762     * @param stdClass $row The data for this line
763     * @param array &$classes the classes to add to the surrounding <li>
764     * @param array &$attribs
765     * @return bool
766     */
767    public function onContributionsLineEnding( $pager, &$ret, $row, &$classes, &$attribs ) {
768        return static::onDeletedContributionsLineEnding( $pager, $ret, $row, $classes, $attribs );
769    }
770
771    /**
772     * Convert flow contributions entries into FeedItem instances
773     * for ApiFeedContributions
774     *
775     * @param object $row Single row of data from ContribsPager
776     * @param IContextSource $ctx The context to creat the feed item within
777     * @param FeedItem|null &$feedItem Return value holder for created feed item.
778     * @return bool
779     */
780    public function onApiFeedContributions__feedItem( $row, $ctx, &$feedItem ) {
781        if ( !$row instanceof Formatter\FormatterRow ) {
782            return true;
783        }
784
785        set_error_handler( new RecoverableErrorHandler, -1 );
786        try {
787            /** @var Formatter\FeedItemFormatter $formatter */
788            $formatter = Container::get( 'formatter.contributions.feeditem' );
789            $result = $formatter->format( $row, $ctx );
790        } catch ( PermissionException ) {
791            return false;
792        } catch ( Exception $e ) {
793            wfDebugLog( 'Flow', __METHOD__ . ': Failed formatting contribution ' .
794                json_encode( $row ) . ': ' . $e->getMessage() );
795            MWExceptionHandler::logException( $e );
796            return false;
797        } finally {
798            restore_error_handler();
799        }
800
801        if ( $result instanceof FeedItem ) {
802            $feedItem = $result;
803            return true;
804        } else {
805            // If we failed to render a flow row, cancel it. This could be
806            // either permissions or bugs.
807            return false;
808        }
809    }
810
811    /**
812     * Gets Flow contributions for contributions-related special pages
813     *
814     * @see onDeletedContributionsQuery
815     * @see onContributionsQuery
816     *
817     * @param array &$data
818     * @param IndexPager $pager
819     * @param string $offset
820     * @param int $limit
821     * @param bool $descending
822     * @param array $rangeOffsets Query range, in the format of [ startOffset, endOffset ]
823     * @return bool
824     */
825    private static function getContributionsQuery( &$data, $pager, $offset, $limit, $descending, $rangeOffsets = [] ) {
826        if (
827            !( $pager instanceof ContribsPager ) &&
828            !( $pager instanceof DeletedContribsPager )
829        ) {
830            return false;
831        }
832
833        set_error_handler( new RecoverableErrorHandler, -1 );
834        try {
835            /** @var Formatter\ContributionsQuery $query */
836            $query = Container::get( 'query.contributions' );
837            $results = $query->getResults( $pager, $offset, $limit, $descending, $rangeOffsets );
838        } catch ( Exception $e ) {
839            wfDebugLog( 'Flow', __METHOD__ . ': Failed contributions query' );
840            MWExceptionHandler::logException( $e );
841            $results = false;
842        } finally {
843            restore_error_handler();
844        }
845
846        if ( $results === false ) {
847            return false;
848        }
849
850        $data[] = $results;
851
852        return true;
853    }
854
855    /**
856     * Adds Flow contributions to the DeletedContributions special page
857     *
858     * @inheritDoc
859     */
860    public function onDeletedContribsPager__reallyDoQuery( &$data, $pager, $offset, $limit, $descending ) {
861        return self::getContributionsQuery( $data, $pager, $offset, $limit, $descending, [ $pager->getEndOffset() ] );
862    }
863
864    /**
865     * Adds Flow contributions to the Contributions special page
866     *
867     * @inheritDoc
868     */
869    public function onContribsPager__reallyDoQuery( &$data, $pager, $offset, $limit, $descending ) {
870        // Flow has nothing to do with the tag filter, so ignore tag searches
871        if ( $pager->getTagFilter() != false ) {
872            return true;
873        }
874
875        return static::getContributionsQuery( $data, $pager, $offset, $limit, $descending, $pager->getRangeOffsets() );
876    }
877
878    /**
879     * Define and add descriptions for board-related variables
880     * @param array &$realValues
881     */
882    public static function onAbuseFilterBuilder( &$realValues ) {
883        $realValues['vars'] += [
884            'board_id' => 'board-id',
885            'board_namespace' => 'board-namespace',
886            'board_title' => 'board-title',
887            'board_prefixedtitle' => 'board-prefixedtitle',
888        ];
889    }
890
891    /**
892     * Add our deprecated variables
893     * @param array &$deprecatedVars
894     */
895    public static function onAbuseFilterDeprecatedVariables( &$deprecatedVars ) {
896        $deprecatedVars += [
897            'board_articleid' => 'board_id',
898            'board_text' => 'board_title',
899            'board_prefixedtext' => 'board_prefixedtitle',
900        ];
901    }
902
903    /**
904     * Adds lazy-load methods for AbstractRevision objects.
905     *
906     * @param string $method Method to generate the variable
907     * @param VariableHolder $vars
908     * @param array $parameters Parameters with data to compute the value
909     * @param mixed &$result Result of the computation
910     * @return bool
911     */
912    public static function onAbuseFilterComputeVariable(
913        $method,
914        VariableHolder $vars,
915        $parameters,
916        &$result
917    ) {
918        // fetch all lazy-load methods
919        $methods = self::getAbuseFilter()->lazyLoadMethods();
920
921        // method isn't known here
922        if ( !isset( $methods[$method] ) ) {
923            return true;
924        }
925
926        // fetch variable result from lazy-load method
927        $result = $methods[$method]( $vars, $parameters );
928        return false;
929    }
930
931    /**
932     * Suppress the 'You have new messages!' indication when a change to a
933     * user talk page is done by the talk page manager user.
934     *
935     * @param WikiPage $page
936     * @param User $recipient
937     * @return bool
938     */
939    public function onArticleEditUpdateNewTalk( $page, $recipient ) {
940        $user = User::newFromId( $page->getUser( RevisionRecord::RAW ) );
941
942        if ( self::isTalkpageManagerUser( $user ) ) {
943            return false;
944        }
945
946        return true;
947    }
948
949    /**
950     * @param UserIdentity $user
951     * @return bool
952     */
953    public static function isTalkpageManagerUser( UserIdentity $user ) {
954        return $user->getName() === FLOW_TALK_PAGE_MANAGER_USER;
955    }
956
957    public function onInfoAction( $ctx, &$pageinfo ) {
958        if ( $ctx->getTitle()->getContentModel() !== CONTENT_MODEL_FLOW_BOARD ) {
959            return;
960        }
961
962        // All of the info in this section is wrong for Flow pages,
963        // so we'll just remove it.
964        unset( $pageinfo['header-edits'] );
965
966        // These keys are wrong on Flow pages, so we'll remove them
967        static $badMessageKeys = [ 'pageinfo-length' ];
968
969        foreach ( $pageinfo['header-basic'] as $num => $val ) {
970            if ( $val[0] instanceof Message && in_array( $val[0]->getKey(), $badMessageKeys ) ) {
971                unset( $pageinfo['header-basic'][$num] );
972            }
973        }
974    }
975
976    /**
977     * Provide detail about the Flow edit for checkusers using Special:CheckUser / Special:Investigate
978     *
979     * @param string &$ip
980     * @param string|false &$xff
981     * @param array &$row The row to be inserted (before defaults are applied)
982     * @param UserIdentity $user
983     * @param ?RecentChange $rc If triggered by a RecentChange, then this is the associated
984     *   RecentChange object. Null if not triggered by a RecentChange.
985     */
986    public static function onCheckUserInsertChangesRow(
987        string &$ip,
988        &$xff,
989        array &$row,
990        UserIdentity $user,
991        ?RecentChange $rc
992    ) {
993        if ( $rc === null || $rc->getAttribute( 'rc_source' ) !== RecentChangesListener::SRC_FLOW ) {
994            return;
995        }
996
997        $params = unserialize( $rc->getAttribute( 'rc_params' ) );
998        $change = $params['flow-workflow-change'];
999
1000        // don't forget to increase the version number when data format changes
1001        $comment = CheckUserQuery::VERSION_PREFIX;
1002        $comment .= ',' . $change['action'];
1003        $comment .= ',' . $change['workflow'];
1004        $comment .= ',' . $change['revision'];
1005
1006        $row['cuc_comment'] = $comment;
1007    }
1008
1009    public function onIRCLineURL( &$url, &$query, $rc ) {
1010        if ( $rc->getAttribute( 'rc_source' ) !== RecentChangesListener::SRC_FLOW ) {
1011            return;
1012        }
1013
1014        set_error_handler( new RecoverableErrorHandler, -1 );
1015        $result = null;
1016        try {
1017            /** @var Formatter\IRCLineUrlFormatter $formatter */
1018            $formatter = Container::get( 'formatter.irclineurl' );
1019            $result = $formatter->format( $rc );
1020        } catch ( Exception $e ) {
1021            $result = null;
1022            wfDebugLog( 'Flow', __METHOD__ . ': Failed formatting rc ' .
1023                $rc->getAttribute( 'rc_id' ) . ': ' . $e->getMessage() );
1024            MWExceptionHandler::logException( $e );
1025        } finally {
1026            restore_error_handler();
1027        }
1028
1029        if ( $result !== null ) {
1030            $url = $result;
1031            $query = '';
1032        }
1033    }
1034
1035    /**
1036     * Add topiclist sortby to preferences.
1037     *
1038     * @param User $user
1039     * @param array &$preferences
1040     */
1041    public function onGetPreferences( $user, &$preferences ) {
1042        $preferences['flow-topiclist-sortby'] = [
1043            'type' => 'api',
1044        ];
1045
1046        $preferences['flow-editor'] = [
1047            'type' => 'api'
1048        ];
1049
1050        $preferences['flow-side-rail-state'] = [
1051            'type' => 'api'
1052        ];
1053
1054        if ( ExtensionRegistry::getInstance()->isLoaded( 'VisualEditor' ) ) {
1055            $preferences['flow-visualeditor'] = [
1056                'type' => 'toggle',
1057                'label-message' => 'flow-preference-visualeditor',
1058                'section' => 'editing/editor',
1059            ];
1060        }
1061    }
1062
1063    /**
1064     * @param UserIdentity $user
1065     * @param WikiPage $page
1066     * @param Status &$status
1067     * @return bool
1068     */
1069    public static function handleWatchArticle( $user, WikiPage $page, &$status ) {
1070        $title = $page->getTitle();
1071        if ( $title->getNamespace() == NS_TOPIC ) {
1072            // @todo - use !$title->exists()?
1073            /** @var Data\ManagerGroup $storage */
1074            $storage = Container::get( 'storage' );
1075            $found = $storage->find(
1076                'PostRevision',
1077                [ 'rev_type_id' => strtolower( $title->getDBkey() ) ],
1078                [ 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 ]
1079            );
1080            if ( !$found ) {
1081                return false;
1082            }
1083            $post = reset( $found );
1084            if ( !$post->isTopicTitle() ) {
1085                return false;
1086            }
1087        }
1088        return true;
1089    }
1090
1091    /**
1092     * Don't watch a non-existing flow topic
1093     *
1094     * @param UserIdentity $user
1095     * @param WikiPage $page
1096     * @param Status &$status
1097     * @param string|null $expiry
1098     * @return bool
1099     */
1100    public function onWatchArticle( $user, $page, &$status, $expiry ) {
1101        return self::handleWatchArticle( $user, $page, $status );
1102    }
1103
1104    /**
1105     * Don't unwatch a non-existing flow topic
1106     *
1107     * @param UserIdentity $user
1108     * @param WikiPage $page
1109     * @param Status &$status
1110     * @return bool
1111     */
1112    public function onUnwatchArticle( $user, $page, &$status ) {
1113        return self::handleWatchArticle( $user, $page, $status );
1114    }
1115
1116    /**
1117     * Checks whether this is a valid move technically.  MovePageIsValidMove should not
1118     * be affected by the specific user, or user permissions.
1119     *
1120     * Those are handled in onMovePageCheckPermissions, called later.
1121     *
1122     * @param Title $oldTitle
1123     * @param Title $newTitle
1124     * @param Status $status Status to update with any technical issues
1125     *
1126     * @return bool true to continue, false to abort the hook
1127     */
1128    public function onMovePageIsValidMove( $oldTitle, $newTitle, $status ) {
1129        // We only care about moving Flow boards, and *not* moving Flow topics
1130        // (but both are CONTENT_MODEL_FLOW_BOARD)
1131        if ( $oldTitle->getContentModel() !== CONTENT_MODEL_FLOW_BOARD ) {
1132            return true;
1133        }
1134
1135        // Pages within the Topic namespace are not movable
1136        // This is also enforced by the namespace configuration in extension.json.
1137        if ( $oldTitle->getNamespace() === NS_TOPIC ) {
1138            $status->fatal( 'flow-error-move-topic' );
1139            return false;
1140        }
1141
1142        /** @var OccupationController $occupationController */
1143        $occupationController = MediaWikiServices::getInstance()->getService( 'FlowTalkpageManager' );
1144        $flowStatus = $occupationController->checkIfCreationIsPossible( $newTitle, /*mustNotExist*/ true );
1145        $status->merge( $flowStatus );
1146
1147        return true;
1148    }
1149
1150    /**
1151     * Checks whether user has permission to move the board.
1152     *
1153     * Technical restrictions are handled in onMovePageIsValidMove, called earlier.
1154     *
1155     * @param Title $oldTitle
1156     * @param Title $newTitle
1157     * @param User $user User doing the move
1158     * @param string $reason Reason for the move
1159     * @param Status $status Status updated with any permissions issue
1160     */
1161    public function onMovePageCheckPermissions(
1162        $oldTitle,
1163        $newTitle,
1164        $user,
1165        $reason,
1166        $status
1167    ) {
1168        // Only affect moves if the source has Flow content model
1169        if ( $oldTitle->getContentModel() !== CONTENT_MODEL_FLOW_BOARD ) {
1170            return;
1171        }
1172
1173        /** @var OccupationController $occupationController */
1174        $occupationController = MediaWikiServices::getInstance()->getService( 'FlowTalkpageManager' );
1175        $permissionStatus = $occupationController->checkIfUserHasPermission(
1176            $newTitle,
1177            $user
1178        );
1179        $status->merge( $permissionStatus );
1180    }
1181
1182    /**
1183     * @param Title $title
1184     * @param string[] &$urls
1185     */
1186    public function onTitleSquidURLs( $title, &$urls ) {
1187        if ( $title->getNamespace() !== NS_TOPIC ) {
1188            return;
1189        }
1190        try {
1191            $uuid = WorkflowLoaderFactory::uuidFromTitle( $title );
1192        } catch ( InvalidInputException ) {
1193            wfDebugLog( 'Flow', __METHOD__ . ': Invalid title ' . $title->getPrefixedText() );
1194            return;
1195        }
1196        /** @var Data\ManagerGroup $storage */
1197        $storage = Container::get( 'storage' );
1198        $workflow = $storage->get( 'Workflow', $uuid );
1199        if ( !$workflow instanceof Model\Workflow ) {
1200            wfDebugLog( 'Flow', __METHOD__ . ': Title for non-existent Workflow ' .
1201                $title->getPrefixedText() );
1202            return;
1203        }
1204
1205        $htmlCache = MediaWikiServices::getInstance()->getHTMLCacheUpdater();
1206        $urls = array_merge(
1207            $urls,
1208            $htmlCache->getUrls( $workflow->getOwnerTitle() )
1209        );
1210    }
1211
1212    /**
1213     * @param array &$tools Extra links
1214     * @param Title $title
1215     * @param bool $redirect Whether the page is a redirect
1216     * @param Skin $skin
1217     * @param string &$link
1218     */
1219    public function onWatchlistEditorBuildRemoveLine(
1220        &$tools,
1221        $title,
1222        $redirect,
1223        $skin,
1224        &$link
1225    ) {
1226        if ( $title->getNamespace() !== NS_TOPIC ) {
1227            // Leave all non Flow topics alone!
1228            return;
1229        }
1230
1231        /*
1232         * Link to talk page is no applicable for Flow topics
1233         * Note that key 'talk' doesn't exist prior to
1234         * https://gerrit.wikimedia.org/r/#/c/156522/, so on old MW's, the link
1235         * to talk page will still be present.
1236         */
1237        unset( $tools['talk'] );
1238
1239        if ( !$link ) {
1240            /*
1241             * https://gerrit.wikimedia.org/r/#/c/156118/ adds argument $link.
1242             * Prior to that patch, it was impossible to change the link, so
1243             * let's quit early if it doesn't exist.
1244             */
1245            return;
1246        }
1247
1248        try {
1249            // Find the title text of this specific topic
1250            $uuid = WorkflowLoaderFactory::uuidFromTitle( $title );
1251            $collection = PostCollection::newFromId( $uuid );
1252            $revision = $collection->getLastRevision();
1253        } catch ( Exception ) {
1254            wfWarn( __METHOD__ . ': Failed to locate revision for: ' . $title->getDBkey() );
1255            return;
1256        }
1257
1258        $content = $revision->getContent( 'topic-title-plaintext' );
1259        $link = MediaWikiServices::getInstance()->getLinkRenderer()->makeLink( $title, $content );
1260    }
1261
1262    /**
1263     * @param array &$watchlistInfo Watchlisted pages
1264     */
1265    public function onWatchlistEditorBeforeFormRender( &$watchlistInfo ) {
1266        if ( !isset( $watchlistInfo[NS_TOPIC] ) ) {
1267            // No topics watchlisted
1268            return;
1269        }
1270
1271        $ids = array_keys( $watchlistInfo[NS_TOPIC] );
1272
1273        // build array of queries to be executed all at once
1274        $queries = [];
1275        foreach ( $ids as $id ) {
1276            try {
1277                $uuid = WorkflowLoaderFactory::uuidFromTitlePair( NS_TOPIC, $id );
1278                $queries[] = [ 'rev_type_id' => $uuid ];
1279            } catch ( Exception ) {
1280                // invalid id
1281                unset( $watchlistInfo[NS_TOPIC][$id] );
1282            }
1283        }
1284
1285        /** @var Data\ManagerGroup $storage */
1286        $storage = Container::get( 'storage' );
1287
1288        /*
1289         * Now, finally find all requested topics - this will be stored in
1290         * local cache so subsequent calls (in onWatchlistEditorBuildRemoveLine)
1291         * will just find these in memory, instead of doing a bunch of network
1292         * requests.
1293         */
1294        $storage->findMulti(
1295            'PostRevision',
1296            $queries,
1297            [ 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 ]
1298        );
1299    }
1300
1301    /**
1302     * For integration with the UserMerge extension. Provides the database and
1303     * sets of table/column pairs to update user id's within.
1304     *
1305     * @param array &$updateFields
1306     */
1307    public static function onUserMergeAccountFields( &$updateFields ) {
1308        /** @var Data\Utils\UserMerger $merger */
1309        $merger = Container::get( 'user_merger' );
1310        foreach ( $merger->getAccountFields() as $row ) {
1311            $updateFields[] = $row;
1312        }
1313    }
1314
1315    /**
1316     * Finalize the merge by purging any cached value that contained $oldUser
1317     * @param User &$oldUser
1318     * @param User &$newUser
1319     */
1320    public static function onMergeAccountFromTo( User &$oldUser, User &$newUser ) {
1321        /** @var Data\Utils\UserMerger $merger */
1322        $merger = Container::get( 'user_merger' );
1323        $merger->finalizeMerge( $oldUser->getId(), $newUser->getId() );
1324    }
1325
1326    /**
1327     * Gives precedence to Flow over LQT.
1328     * @param Title $title
1329     * @param bool &$isLqtPage
1330     */
1331    public static function onIsLiquidThreadsPage( Title $title, &$isLqtPage ) {
1332        if ( $isLqtPage && $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
1333            $isLqtPage = false;
1334        }
1335    }
1336
1337    public function onCategoryViewer__doCategoryQuery( $type, $res ) {
1338        if ( $type !== 'page' ) {
1339            return;
1340        }
1341
1342        /** @var Formatter\CategoryViewerQuery $query */
1343        $query = Container::get( 'query.categoryviewer' );
1344        $query->loadMetadataBatch( $res );
1345    }
1346
1347    public function onCategoryViewer__generateLink( $type, $title, $html, &$link ) {
1348        if ( $type !== 'page' || $title->getNamespace() !== NS_TOPIC ) {
1349            return;
1350        }
1351        $uuid = UUID::create( strtolower( $title->getDBkey() ) );
1352        if ( !$uuid ) {
1353            return;
1354        }
1355        /** @var Formatter\CategoryViewerQuery $query */
1356        $query = Container::get( 'query.categoryviewer' );
1357        $row = $query->getResult( $uuid );
1358        /** @var Formatter\CategoryViewerFormatter $formatter */
1359        $formatter = Container::get( 'formatter.categoryviewer' );
1360        $result = $formatter->format( $row );
1361        if ( $result ) {
1362            $link = $result;
1363        }
1364    }
1365
1366    /**
1367     * Gets error HTML for attempted NS_TOPIC deletion using core interface
1368     *
1369     * @param Title $title Topic title they are attempting to delete
1370     * @return string Error html
1371     */
1372    protected static function getTopicDeletionError( Title $title ) {
1373        $error = wfMessage( 'flow-error-core-topic-deletion', $title->getFullURL() )->parse();
1374        $wrappedError = Html::rawElement( 'span', [
1375            'class' => 'plainlinks',
1376        ], $error );
1377        return $wrappedError;
1378    }
1379
1380    // This should block them from wasting their time filling the form, but it won't
1381    // without a core change.  However, it does show the message.
1382
1383    /**
1384     * Shows an error message when the user visits the deletion form if the page is in
1385     * the Topic namespace.
1386     *
1387     * @param Article $article Page the user requested to delete
1388     * @param OutputPage $output Output page
1389     * @param string &$reason Pre-filled reason given for deletion (note, this could
1390     *   be used to customize this for boards and/or topics later)
1391     * @return bool False if it is a Topic; otherwise, true
1392     */
1393    public function onArticleConfirmDelete( $article, $output, &$reason ) {
1394        $title = $article->getTitle();
1395        if ( $title->inNamespace( NS_TOPIC ) ) {
1396            $output->addHTML( self::getTopicDeletionError( $title ) );
1397            return false;
1398        }
1399
1400        return true;
1401    }
1402
1403    /**
1404     * Blocks topics from being deleted using the core deletion process, since it
1405     * doesn't work.
1406     *
1407     * @param WikiPage $article Page the user requested to delete
1408     * @param User $user User who requested to delete the article
1409     * @param string &$reason Reason given for deletion
1410     * @param string &$error Error explaining why we are not allowing the deletion
1411     * @param Status &$status
1412     * @param bool $suppress
1413     * @return bool False if it is a Topic (to block it); otherwise, true
1414     */
1415    public function onArticleDelete( WikiPage $article, User $user, &$reason, &$error, Status &$status, $suppress ) {
1416        $title = $article->getTitle();
1417        if ( $title->inNamespace( NS_TOPIC ) ) {
1418            $error = self::getTopicDeletionError( $title );
1419            return false;
1420        }
1421
1422        return true;
1423    }
1424
1425    /**
1426     * Evicts topics from Squid/Varnish when the board is deleted.
1427     * We do permission checks for this scenario, but since the topic isn't deleted
1428     * at the core level, we need to evict it from Varnish ourselves.
1429     *
1430     * @param WikiPage $article Deleted article
1431     * @param User $user User that deleted article
1432     * @param string $reason Reason given
1433     * @param int $articleId Article ID of deleted article
1434     * @param Content|null $content Content that was deleted, or null on error
1435     * @param LogEntry $logEntry Log entry for deletion
1436     * @param int $archivedRevisionCount
1437     */
1438    public function onArticleDeleteComplete(
1439        $article,
1440        $user,
1441        $reason,
1442        $articleId,
1443        $content,
1444        $logEntry,
1445        $archivedRevisionCount
1446    ) {
1447        $title = $article->getTitle();
1448
1449        // Topics use the same content model, but can't be deleted at the core
1450        // level currently.
1451        if ( $content !== null &&
1452            $title->getNamespace() !== NS_TOPIC &&
1453            $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
1454            $storage = Container::get( 'storage' );
1455
1456            DeferredUpdates::addCallableUpdate( static function () use ( $storage, $articleId ) {
1457                /** @var Model\Workflow[] $workflows */
1458                $workflows = $storage->find( 'Workflow', [
1459                    'workflow_wiki' => WikiMap::getCurrentWikiId(),
1460                    'workflow_page_id' => $articleId,
1461                ] );
1462                if ( !$workflows ) {
1463                    return;
1464                }
1465
1466                $topicTitles = [];
1467                foreach ( $workflows as $workflow ) {
1468                    if ( $workflow->getType() === 'topic' ) {
1469                        $topicTitles[] = $workflow->getArticleTitle();
1470                    }
1471                }
1472
1473                $hcu = MediaWikiServices::getInstance()->getHTMLCacheUpdater();
1474                $hcu->purgeTitleUrls( $topicTitles, $hcu::PURGE_INTENT_TXROUND_REFLECTED );
1475            } );
1476        }
1477    }
1478
1479    /**
1480     * @param RevisionRecord $revisionRecord Revision just undeleted
1481     * @param ?int $oldPageId Old page ID stored with that revision when it was in the archive table
1482     */
1483    public function onRevisionUndeleted( $revisionRecord, $oldPageId ) {
1484        $contentModel = $revisionRecord->getSlot(
1485            SlotRecord::MAIN, RevisionRecord::RAW
1486        )->getModel();
1487        if ( $contentModel === CONTENT_MODEL_FLOW_BOARD ) {
1488            // complete hack to make sure that when the page is saved to new
1489            // location and rendered it doesn't throw an error about the wrong title
1490            Container::get( 'factory.loader.workflow' )->pageMoveInProgress();
1491
1492            $title = Title::newFromPageIdentity( $revisionRecord->getPage() );
1493
1494            // Reassociate the Flow board associated with this undeleted revision.
1495            /** @var BoardMover $boardMover */
1496            $boardMover = Container::get( 'board_mover' );
1497            $boardMover->move( intval( $oldPageId ), $title );
1498        }
1499    }
1500
1501    /**
1502     * @param Title $title Title corresponding to the article restored
1503     * @param bool $create Whether or not the restoration caused the page to be created (i.e. it didn't exist before).
1504     * @param string $comment The comment associated with the undeletion.
1505     * @param int $oldPageId ID of page previously deleted (from archive table)
1506     * @param array $restoredPages
1507     */
1508    public function onArticleUndelete( $title, $create, $comment, $oldPageId, $restoredPages ) {
1509        // Avoid CI errors when other ArticleUndelete implementations are present, see: T356704
1510        if ( defined( 'MW_PHPUNIT_TEST' ) ) {
1511            return;
1512        }
1513        $boardMover = Container::get( 'board_mover' );
1514        $boardMover->commit();
1515    }
1516
1517    /**
1518     * Occurs at the beginning of the MovePage process (just after the startAtomic).
1519     *
1520     * Perhaps ContentModel should be extended to be notified about moves explicitly.
1521     * @param Title $oldTitle
1522     * @param Title $newTitle
1523     * @param User $user
1524     */
1525    public function onTitleMoveStarting( $oldTitle, $newTitle, $user ) {
1526        if ( $oldTitle->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
1527            // $newTitle doesn't yet exist, but after the move it'll still have
1528            // the same ID $oldTitle used to have
1529            // Since we don't want to wait until after the page has been moved
1530            // to start preparing relevant Flow moves, I'll make it reflect the
1531            // correct ID already
1532            $bogusTitle = clone $newTitle;
1533            $bogusTitle->resetArticleID( $oldTitle->getArticleID() );
1534
1535            // This is only safe because we have called
1536            // checkIfCreationIsPossible and (usually) checkIfUserHasPermission.
1537            Container::get( 'occupation_controller' )->forceAllowCreation( $bogusTitle );
1538            // complete hack to make sure that when the page is saved to new
1539            // location and rendered it doesn't throw an error about the wrong title
1540            Container::get( 'factory.loader.workflow' )->pageMoveInProgress();
1541            // open a database transaction and prepare everything for the move, but
1542            // don't commit yet. That is done below in self::onPageMoveCompleting
1543            $boardMover = Container::get( 'board_mover' );
1544            $boardMover->move( $oldTitle->getArticleID(), $bogusTitle );
1545        }
1546    }
1547
1548    public function onPageMoveCompleting(
1549        $oldTitle,
1550        $newTitle,
1551        $user,
1552        $pageid,
1553        $redirid,
1554        $reason,
1555        $revisionRecord
1556    ) {
1557        $newTitle = Title::newFromLinkTarget( $newTitle );
1558        if ( $newTitle->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
1559            Container::get( 'board_mover' )->commit();
1560        }
1561    }
1562
1563    public function onShowMissingArticle( $article ) {
1564        if ( $article->getPage()->getContentModel() !== CONTENT_MODEL_FLOW_BOARD ) {
1565            return true;
1566        }
1567
1568        if ( $article->getTitle()->getNamespace() === NS_TOPIC ) {
1569            // @todo pretty message about invalid workflow
1570            throw new FlowException( 'Non-existent topic' );
1571        }
1572
1573        $services = MediaWikiServices::getInstance();
1574        $emptyContent = $services->getContentHandlerFactory()
1575            ->getContentHandler( CONTENT_MODEL_FLOW_BOARD )->makeEmptyContent();
1576        $contentRenderer = $services->getContentRenderer();
1577        $parserOptions = ParserOptions::newFromAnon();
1578        $parserOutput = $contentRenderer->getParserOutput( $emptyContent, $article->getTitle(), null, $parserOptions );
1579        $article->getContext()->getOutput()->addParserOutput( $parserOutput, $parserOptions );
1580
1581        return false;
1582    }
1583
1584    /**
1585     * Excludes NS_TOPIC from the list of searchable namespaces
1586     *
1587     * @param array &$namespaces Associative array mapping namespace index
1588     *  to name
1589     */
1590    public function onSearchableNamespaces( &$namespaces ) {
1591        unset( $namespaces[NS_TOPIC] );
1592    }
1593
1594    /**
1595     * @return bool
1596     */
1597    private static function isBetaFeatureAvailable() {
1598        if ( !ExtensionRegistry::getInstance()->isLoaded( 'BetaFeatures' ) ) {
1599            return false;
1600        }
1601
1602        $config = RequestContext::getMain()->getConfig();
1603        $betaFeaturesAllowList = $config->get( 'BetaFeaturesAllowList' );
1604
1605        return $config->get( 'FlowEnableOptInBetaFeature' )
1606            && (
1607                !is_array( $betaFeaturesAllowList )
1608                || in_array( BETA_FEATURE_FLOW_USER_TALK_PAGE, $betaFeaturesAllowList )
1609            );
1610    }
1611
1612    /**
1613     * @param User $user
1614     * @param array &$prefs
1615     */
1616    public function onGetBetaFeaturePreferences( $user, &$prefs ) {
1617        global $wgExtensionAssetsPath;
1618
1619        if ( !self::isBetaFeatureAvailable() ) {
1620            return;
1621        }
1622        // Do not allow users to opt-in for Flow as preliminary sunset step
1623        if ( !BetaFeatures::isFeatureEnabled( $user, BETA_FEATURE_FLOW_USER_TALK_PAGE ) ) {
1624            return;
1625        }
1626
1627        $prefs[BETA_FEATURE_FLOW_USER_TALK_PAGE] = [
1628            // The first two are message keys
1629            'label-message' => 'flow-talk-page-beta-feature-message',
1630            'desc-message' => 'flow-talk-page-beta-feature-description',
1631            'screenshot' => [
1632                'ltr' => "$wgExtensionAssetsPath/Flow/images/betafeature-flow-ltr.svg",
1633                'rtl' => "$wgExtensionAssetsPath/Flow/images/betafeature-flow-rtl.svg",
1634            ],
1635            'info-link' => 'https://www.mediawiki.org/wiki/Flow',
1636            'discussion-link' => 'https://www.mediawiki.org/wiki/Talk:Flow',
1637            'exempt-from-auto-enrollment' => true,
1638        ];
1639    }
1640
1641    /**
1642     * @param UserIdentity $user
1643     * @param array &$modifiedOptions
1644     * @param array $originalOptions
1645     */
1646    public function onSaveUserOptions( UserIdentity $user, array &$modifiedOptions, array $originalOptions ) {
1647        if ( !self::isBetaFeatureAvailable() ) {
1648            return;
1649        }
1650
1651        // Short circuit, it's fine because this beta feature is exempted from auto-enroll.
1652        if ( !array_key_exists( BETA_FEATURE_FLOW_USER_TALK_PAGE, $modifiedOptions ) ) {
1653            return;
1654        }
1655
1656        $before = BetaFeatures::isFeatureEnabled( $user, BETA_FEATURE_FLOW_USER_TALK_PAGE, $originalOptions );
1657        $after = BetaFeatures::isFeatureEnabled( $user, BETA_FEATURE_FLOW_USER_TALK_PAGE );
1658        $action = null;
1659
1660        $optInController = Container::get( 'controller.opt_in' );
1661        $user = MediaWikiServices::getInstance()->getUserFactory()->newFromUserIdentity( $user );
1662        if ( !$before && $after ) {
1663            $action = OptInController::ENABLE;
1664            // Check if the user had a flow board
1665            if ( !$optInController->hasFlowBoardArchive( $user ) ) {
1666                // Enable the guided tour by setting the cookie
1667                RequestContext::getMain()->getRequest()->response()->setCookie( 'Flow_optIn_guidedTour', '1' );
1668            }
1669        } elseif ( $before && !$after ) {
1670            $action = OptInController::DISABLE;
1671        }
1672
1673        if ( $action ) {
1674            $optInController->initiateChange( $action, $user->getTalkPage(), $user );
1675        }
1676    }
1677
1678    /**
1679     * @param WikiImporter $importer
1680     * @return bool
1681     */
1682    public function onImportHandleToplevelXMLTag( $importer ) {
1683        // only init Flow's importer once, then re-use it
1684        static $flowImporter = null;
1685        if ( $flowImporter === null ) {
1686            // importer can be dry-run (= parse, but don't store), but we can only
1687            // derive that from mPageOutCallback. I'll set a new value (which will
1688            // return the existing value) to see if it's in dry-run mode (= null)
1689            $callback = $importer->setPageOutCallback( null );
1690            // restore previous mPageOutCallback value
1691            $importer->setPageOutCallback( $callback );
1692
1693            $flowImporter = new Dump\Importer( $importer );
1694            if ( $callback !== null ) {
1695                // not in dry-run mode
1696                $flowImporter->setStorage( Container::get( 'storage' ) );
1697            }
1698        }
1699
1700        $reader = $importer->getReader();
1701        $tag = $reader->localName;
1702        $type = $reader->nodeType;
1703
1704        if ( $tag == 'board' ) {
1705            if ( $type === XMLReader::ELEMENT ) {
1706                $flowImporter->handleBoard();
1707            }
1708            return false;
1709        } elseif ( $tag == 'description' ) {
1710            if ( $type === XMLReader::ELEMENT ) {
1711                $flowImporter->handleHeader();
1712            }
1713            return false;
1714        } elseif ( $tag == 'topic' ) {
1715            if ( $type === XMLReader::ELEMENT ) {
1716                $flowImporter->handleTopic();
1717            }
1718            return false;
1719        } elseif ( $tag == 'post' ) {
1720            if ( $type === XMLReader::ELEMENT ) {
1721                $flowImporter->handlePost();
1722            }
1723            return false;
1724        } elseif ( $tag == 'summary' ) {
1725            if ( $type === XMLReader::ELEMENT ) {
1726                $flowImporter->handleSummary();
1727            }
1728            return false;
1729        } elseif ( $tag == 'children' ) {
1730            return false;
1731        }
1732
1733        return true;
1734    }
1735
1736    public static function onNukeGetNewPages( $username, $pattern, $namespace, $limit, &$pages ) {
1737        if ( $namespace && $namespace !== NS_TOPIC ) {
1738            // not interested in any Topics
1739            return true;
1740        }
1741
1742        // Remove any pre-existing Topic pages.
1743        // They are coming from the recentchanges table.
1744        // The most likely case is that the filters were not applied correctly.
1745        $pages = array_filter( $pages, static function ( $entry ) {
1746            /** @var Title $title */
1747            $title = $entry[0];
1748            return $title->getNamespace() !== NS_TOPIC;
1749        } );
1750
1751        if ( $pattern ) {
1752            // pattern is not supported
1753            return true;
1754        }
1755
1756        if ( !MediaWikiServices::getInstance()->getPermissionManager()
1757            ->userHasRight( RequestContext::getMain()->getUser(), 'flow-delete' )
1758        ) {
1759            // there's no point adding topics since the current user won't be allowed to delete them
1760            return true;
1761        }
1762
1763        // how many revisions are we allowed to retrieve now
1764        $newLimit = $limit - count( $pages );
1765
1766        // we can't add anything
1767        if ( $newLimit < 1 ) {
1768            return true;
1769        }
1770
1771        /** @var DbFactory $dbFactory */
1772        $dbFactory = Container::get( 'db.factory' );
1773        $dbr = $dbFactory->getDB( DB_REPLICA );
1774
1775        // if a username is specified, search only for that user
1776        $userWhere = [];
1777        if ( $username ) {
1778            $user = User::newFromName( $username );
1779            if ( $user && $user->isRegistered() ) {
1780                $userWhere = [ 'tree_orig_user_id' => $user->getId() ];
1781            } else {
1782                $userWhere = [ 'tree_orig_user_ip' => $username ];
1783            }
1784        }
1785
1786        // limit results to the range of RC
1787        global $wgRCMaxAge;
1788        $rcTimeLimit = UUID::getComparisonUUID( strtotime( "-$wgRCMaxAge seconds" ) );
1789
1790        // get the latest revision id for each topic
1791        $result = $dbr->newSelectQueryBuilder()
1792            ->select( [
1793                'revId' => 'MAX(r.rev_id)',
1794                'userIp' => "tree_orig_user_ip",
1795                'userId' => "tree_orig_user_id",
1796            ] )
1797            ->from( 'flow_revision', 'r' )
1798            ->join( 'flow_tree_revision', null, 'r.rev_type_id=tree_rev_descendant_id' )
1799            ->join( 'flow_workflow', null, 'r.rev_type_id=workflow_id' )
1800            ->where( [
1801                'tree_parent_id' => null,
1802                'r.rev_type' => 'post',
1803                'workflow_wiki' => WikiMap::getCurrentWikiId(),
1804                $dbr->expr( 'workflow_id', '>', $rcTimeLimit->getBinary() )
1805            ] )
1806            ->andWhere( $userWhere )
1807            ->groupBy( [
1808                'r.rev_type_id',
1809                'tree_orig_user_ip',
1810                'tree_orig_user_id',
1811            ] )
1812            ->caller( __METHOD__ )
1813            ->fetchResultSet();
1814
1815        if ( $result->numRows() < 1 ) {
1816            return true;
1817        }
1818
1819        $revIds = [];
1820        foreach ( $result as $r ) {
1821            $revIds[$r->revId] = [ 'userIp' => $r->userIp, 'userId' => $r->userId, 'name' => false ];
1822        }
1823
1824        // get non-moderated revisions (but include hidden ones for T180607)
1825        $result = $dbr->newSelectQueryBuilder()
1826            ->select( [
1827                'topicId' => 'rev_type_id',
1828                'revId' => 'rev_id'
1829            ] )
1830            ->from( 'flow_revision' )
1831            ->where( [
1832                'rev_mod_state' => [ '', 'hide' ],
1833                'rev_id' => array_keys( $revIds )
1834            ] )
1835            ->limit( $newLimit )
1836            ->orderBy( 'rev_type_id', SelectQueryBuilder::SORT_DESC )
1837            ->caller( __METHOD__ )
1838            ->fetchResultSet();
1839
1840        // all topics previously found appear to be moderated
1841        if ( $result->numRows() < 1 ) {
1842            return true;
1843        }
1844
1845        // keep only the relevant topics in [topicId => userInfo] format
1846        $limitedRevIds = [];
1847        foreach ( $result as $r ) {
1848            $limitedRevIds[$r->topicId] = $revIds[$r->revId];
1849        }
1850
1851        // fill usernames if no $username filter was specified
1852        if ( !$username ) {
1853            $userIds = array_column( array_values( $limitedRevIds ), 'userId' );
1854            $userIds = array_filter( $userIds );
1855
1856            $userMap = [];
1857            if ( $userIds ) {
1858                $wikiDbr = $dbFactory->getWikiDB( DB_REPLICA );
1859                $result = $wikiDbr->newSelectQueryBuilder()
1860                    ->select( [ 'user_id', 'user_name' ] )
1861                    ->from( 'user' )
1862                    ->where( [ 'user_id' => array_values( $userIds ) ] )
1863                    ->caller( __METHOD__ )
1864                    ->fetchResultSet();
1865                foreach ( $result as $r ) {
1866                    $userMap[$r->user_id] = $r->user_name;
1867                }
1868            }
1869
1870            // set name in userInfo structure
1871            foreach ( $limitedRevIds as $topicId => &$userInfo ) {
1872                if ( $userInfo['userIp'] ) {
1873                    $userInfo['name'] = $userInfo['userIp'];
1874                } elseif ( $userInfo['userId'] ) {
1875                    $userInfo['name'] = $userMap[$userInfo['userId']];
1876                } else {
1877                    $userInfo['name'] = false;
1878                    $topicIdAlpha = UUID::create( $topicId )->getAlphadecimal();
1879                    wfLogWarning( __METHOD__ . ": Cannot find user information for topic {$topicIdAlpha}" );
1880                }
1881            }
1882        }
1883        unset( $userInfo );
1884
1885        // add results to the list of pages to nuke
1886        foreach ( $limitedRevIds as $topicId => $userInfo ) {
1887            $pages[] = [
1888                Title::makeTitle( NS_TOPIC, UUID::create( $topicId )->getAlphadecimal() ),
1889                $userInfo['name']
1890            ];
1891        }
1892
1893        return true;
1894    }
1895
1896    public static function onNukeDeletePage( Title $title, $reason, &$deletionResult ) {
1897        if ( $title->getNamespace() !== NS_TOPIC ) {
1898            // we don't handle it
1899            return true;
1900        }
1901
1902        $action = 'moderate-topic';
1903        $params = [
1904            'topic' => [
1905                'moderationState' => 'delete',
1906                'reason' => $reason,
1907                'page' => $title->getPrefixedText()
1908            ],
1909        ];
1910
1911        /** @var WorkflowLoaderFactory $factory */
1912        $factory = Container::get( 'factory.loader.workflow' );
1913
1914        $workflowId = WorkflowLoaderFactory::uuidFromTitle( $title );
1915        /** @var WorkflowLoader $loader */
1916        $loader = $factory->createWorkflowLoader( $title, $workflowId );
1917
1918        $blocks = $loader->getBlocks();
1919
1920        $blocksToCommit = $loader->handleSubmit(
1921            RequestContext::getMain(),
1922            $action,
1923            $params
1924        );
1925
1926        $result = true;
1927        $errors = [];
1928        foreach ( $blocks as $block ) {
1929            if ( $block->hasErrors() ) {
1930                $result = false;
1931                $errorKeys = $block->getErrors();
1932                foreach ( $errorKeys as $errorKey ) {
1933                    $errors[] = $block->getErrorMessage( $errorKey );
1934                }
1935            }
1936        }
1937
1938        if ( $result ) {
1939            $loader->commit( $blocksToCommit );
1940            $deletionResult = true;
1941        } else {
1942            $deletionResult = false;
1943            $msg = "Failed to delete {$title->getPrefixedText()}. Errors: " . implode( '. ', $errors );
1944            wfLogWarning( $msg );
1945        }
1946
1947        // we've handled the deletion, abort the hook
1948        return false;
1949    }
1950
1951    /**
1952     * Filter out all Flow changes when hidepageedits=1
1953     *
1954     * @param string $name
1955     * @param array &$tables
1956     * @param array &$fields
1957     * @param array &$conds
1958     * @param array &$query_options
1959     * @param array &$join_conds
1960     * @param FormOptions $opts
1961     */
1962    public function onChangesListSpecialPageQuery(
1963        $name, &$tables, &$fields, &$conds,
1964        &$query_options, &$join_conds, $opts
1965    ) {
1966        $hidePageEdits = $opts->getValue( 'hidepageedits' );
1967        if ( $hidePageEdits ) {
1968            $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1969            $conds[] = $dbr->expr( 'rc_source', '!=', RecentChangesListener::SRC_FLOW );
1970        }
1971    }
1972
1973    /**
1974     * @inheritDoc
1975     * @return false|void
1976     */
1977    public function onGetUserPermissionsErrors( $title, $user, $action, &$result ) {
1978        global $wgFlowReadOnly;
1979
1980        $tempUserConfig = MediaWikiServices::getInstance()->getTempUserConfig();
1981        // Flow has no support for temp accounts. If temp accounts are
1982        // known on the wiki, don't let anonymous users edit, and
1983        // don't let temporary users edit either.
1984        if (
1985            $tempUserConfig->isKnown() && !$user->isNamed() &&
1986            $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD &&
1987            $action !== 'read'
1988        ) {
1989            $result = 'flow-error-protected-readonly';
1990            return false;
1991        }
1992
1993        if ( !$wgFlowReadOnly ) {
1994            return;
1995        }
1996
1997        // Deny all actions related to Flow pages, and deny all flow-create-board actions,
1998        // but allow read and delete/undelete
1999        $allowedActions = [ 'read', 'delete', 'undelete' ];
2000        if (
2001            $action === 'flow-create-board' ||
2002            (
2003                $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD &&
2004                !in_array( $action, $allowedActions )
2005            )
2006        ) {
2007            $result = 'flow-error-protected-readonly';
2008            return false;
2009        }
2010    }
2011
2012    /**
2013     * Return information about terms-of-use messages.
2014     *
2015     * @param MessageLocalizer $context
2016     * @param Config $config
2017     * @return array Map from internal name to array of parameters for MessageLocalizer::msg()
2018     * @phan-return non-empty-array[]
2019     */
2020    private static function getTermsOfUseMessages(
2021        MessageLocalizer $context, Config $config
2022    ): array {
2023        $messages = [
2024            'new-topic' => [ 'flow-terms-of-use-new-topic' ],
2025            'reply' => [ 'flow-terms-of-use-reply' ],
2026            'edit' => [ 'flow-terms-of-use-edit' ],
2027            'summarize' => [ 'flow-terms-of-use-summarize' ],
2028            'lock-topic' => [ 'flow-terms-of-use-lock-topic' ],
2029            'unlock-topic' => [ 'flow-terms-of-use-unlock-topic' ],
2030        ];
2031
2032        $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
2033        $hookRunner->onFlowTermsOfUseMessages( $messages, $context, $config );
2034
2035        return $messages;
2036    }
2037
2038    /**
2039     * Return parsed terms-of-use messages, for use in a ResourceLoader module.
2040     *
2041     * @param MessageLocalizer $context
2042     * @param Config $config
2043     * @return array
2044     */
2045    public static function getTermsOfUseMessagesParsed(
2046        MessageLocalizer $context, Config $config
2047    ): array {
2048        $messages = self::getTermsOfUseMessages( $context, $config );
2049        foreach ( $messages as &$msg ) {
2050            $msg = $context->msg( ...$msg )->parse();
2051        }
2052        return $messages;
2053    }
2054
2055    /**
2056     * Return information about terms-of-use messages, for use in a ResourceLoader module as
2057     * 'versionCallback'. This is to avoid calling the parser from version invalidation code.
2058     *
2059     * @param MessageLocalizer $context
2060     * @param Config $config
2061     * @return array
2062     */
2063    public static function getTermsOfUseMessagesVersion(
2064        MessageLocalizer $context, Config $config
2065    ): array {
2066        $messages = self::getTermsOfUseMessages( $context, $config );
2067        foreach ( $messages as &$msg ) {
2068            $message = $context->msg( ...$msg );
2069            $msg = [
2070                // Include the text of the message, in case the canonical translation changes
2071                $message->plain(),
2072                // Include the page touched time, in case the on-wiki override is invalidated
2073                Title::makeTitle( NS_MEDIAWIKI, ucfirst( $message->getKey() ) )->getTouched(),
2074            ];
2075        }
2076        return $messages;
2077    }
2078}