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