Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 135
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialContentTranslation
0.00% covered (danger)
0.00%
0 / 135
0.00% covered (danger)
0.00%
0 / 15
2450
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 isListed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 enableCXBetaFeature
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 isValidCampaign
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 encodeURIComponent
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 hasValidToken
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
72
 canUserProceed
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 onTranslationView
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isMobileSite
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 isUnifiedDashboard
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 initModules
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 addJsConfigVars
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 afterExecute
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * Contains the special page Special:ContentTranslation.
4 *
5 * @copyright See AUTHORS.txt
6 * @license GPL-2.0-or-later
7 */
8
9namespace ContentTranslation\Special;
10
11use ContentTranslation\PreferenceHelper;
12use ContentTranslation\SiteMapper;
13use MediaWiki\Context\DerivativeContext;
14use MediaWiki\Context\MutableContext;
15use MediaWiki\Deferred\DeferredUpdates;
16use MediaWiki\Html\Html;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Registration\ExtensionRegistry;
19use MediaWiki\SpecialPage\SpecialPage;
20use MobileContext;
21use SkinFactory;
22
23/**
24 * Implements the core of the Content Translation extension:
25 * a special page that shows Content Translation user interface.
26 */
27class SpecialContentTranslation extends SpecialPage {
28    /**
29     * @var SkinFactory
30     */
31    private $skinFactory;
32
33    /**
34     * @var PreferenceHelper
35     */
36    private $preferenceHelper;
37
38    /**
39     * @param SkinFactory $skinFactory
40     * @param PreferenceHelper $preferenceHelper
41     */
42    public function __construct( \SkinFactory $skinFactory, PreferenceHelper $preferenceHelper ) {
43        parent::__construct( 'ContentTranslation' );
44        $this->skinFactory = $skinFactory;
45        $this->preferenceHelper = $preferenceHelper;
46    }
47
48    /** @inheritDoc */
49    public function getDescription() {
50        return $this->msg( 'cx' );
51    }
52
53    /** @inheritDoc */
54    public function execute( $parameters ) {
55        parent::execute( $parameters );
56
57        // Use custom 'contenttranslation' skin
58        /** @var MutableContext $context */
59        $context = $this->getContext();
60        if ( !$context instanceof MutableContext ) {
61            // Need to be able to change the skin
62            $context = new DerivativeContext( $context );
63            $this->setContext( $context );
64        }
65
66        '@phan-var MutableContext $context';
67        $context->setSkin(
68            $this->skinFactory->makeSkin( 'contenttranslation' )
69        );
70
71        if ( !$this->canUserProceed() ) {
72            return;
73        }
74
75        if ( $this->isUnifiedDashboard() ) {
76            $out = $this->getOutput();
77            $out->addHTML( Html::element(
78                'div',
79                [ 'id' => 'contenttranslation' ]
80            ) );
81        }
82        // Run the extendable chunks from the sub class.
83        $this->initModules();
84        $this->addJsConfigVars();
85    }
86
87    /** @inheritDoc */
88    public function isListed() {
89        return $this->preferenceHelper->isEnabledForUser( $this->getUser() );
90    }
91
92    public function enableCXBetaFeature() {
93        $out = $this->getOutput();
94        $out->addJsConfigVars( 'wgContentTranslationBetaFeatureEnabled', true );
95
96        $user = $this->getUser();
97        // Promise to persist the setting post-send
98        DeferredUpdates::addCallableUpdate( static function () use ( $user ) {
99            $optionsManager = MediaWikiServices::getInstance()->getUserOptionsManager();
100            $user = $user->getInstanceForUpdate();
101            $optionsManager->setOption( $user, 'cx', '1' );
102            $optionsManager->saveOptions( $user );
103        } );
104    }
105
106    /**
107     * @param string|null $campaign
108     * @return bool
109     */
110    public function isValidCampaign( $campaign ) {
111        $contentTranslationCampaigns = $this->getConfig()->get( 'ContentTranslationCampaigns' );
112
113        if ( !$this->getUser()->isNamed() ) {
114            // Campaigns are only for named logged-in users.
115            return false;
116        }
117        return $campaign !== null
118            && isset( $contentTranslationCampaigns[$campaign] )
119            && $contentTranslationCampaigns[$campaign];
120    }
121
122    /**
123     * JS-compatible encodeURIComponent function
124     * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
125     * @param string $string
126     * @return string
127     */
128    public static function encodeURIComponent( $string ) {
129        $revert = [ '%21' => '!', '%2A' => '*', '%27' => "'", '%28' => '(', '%29' => ')' ];
130        return strtr( rawurlencode( $string ), $revert );
131    }
132
133    /**
134     * Check if the request has a token to use CX.
135     * With a valid cx token override beta feature settings.
136     * @return bool
137     */
138    private function hasValidToken() {
139        $request = $this->getRequest();
140
141        if ( !$this->getUser()->isRegistered() ) {
142            // Tokens are valid only for logged in users.
143            return false;
144        }
145
146        $title = $request->getVal( 'page' );
147
148        if ( $title === null ) {
149            return false;
150        }
151        $from = $request->getVal( 'from' );
152        $to = $request->getVal( 'to' );
153        if ( $from === null || $to === null ) {
154            return false;
155        }
156        // Cookie name is base64 encoding of parameters that uniquely define a translation.
157        $cookieName = 'cx_' . base64_encode( self::encodeURIComponent( implode( '_', [ $title, $from, $to ] ) ) );
158        // Remove all characters that are not allowed in cookie name: ( ) < > @ , ; : \ " / [ ] ? = { }.
159        $cookieName = preg_replace( '/[()<>@,;:\\"\/\[\]?={}]/', '', $cookieName );
160
161        $hasToken = $request->getCookie( $cookieName, '' ) !== null;
162
163        // Since we can only publish to the current wiki, enforce that the target language matches
164        // the wiki we are currently on. If not, redirect the user back to dashboard, where he can
165        // start again with parameters filled (and redirected to the correct wiki).
166        $contentTranslationTranslateInTarget = $this->getConfig()->get( 'ContentTranslationTranslateInTarget' );
167        if ( $contentTranslationTranslateInTarget ) {
168            $currLangCode = SiteMapper::getCurrentLanguageCode();
169            $currDomainCode = SiteMapper::getDomainCode( $to );
170            $tokenIsValid = $to === $currLangCode || $to === $currDomainCode;
171            return $hasToken && $tokenIsValid;
172        }
173
174        // For development (single instance) use, there is no need to validate the token, because
175        // we don't redirect.
176        return $hasToken;
177    }
178
179    protected function canUserProceed() {
180        $allowAnonSX = $this->getConfig()->get( 'ContentTranslationEnableAnonSectionTranslation' );
181        $hasValidToken = $this->hasValidToken();
182        $campaign = $this->getRequest()->getVal( 'campaign' );
183        $isCampaign = $this->isValidCampaign( $campaign );
184
185        // Allow access to SX for everyone, when unified dashboard should be displayed
186        // and "ContentTranslationEnableAnonSectionTranslation" is set to true.
187        if ( $this->isUnifiedDashboard() && $allowAnonSX ) {
188            return true;
189        }
190
191        // For all logged-in user, if CX beta feature is not enabled, and has
192        // valid token or campaign, enable CX beta feature and proceed.
193        // This is applicable for both CX and SX.
194        if ( !$this->preferenceHelper->isEnabledForUser( $this->getUser() ) ) {
195            if ( $hasValidToken || $isCampaign ) {
196                // User has a token or a valid campaign param.
197                // Enable cx for the user in this wiki.
198                $this->enableCXBetaFeature();
199            } else {
200                if ( $campaign ) {
201                    // Show login page if the URL has campaign parameter
202                    $this->requireNamedUser();
203                }
204                // Invalid or missing campaign param
205                $this->getOutput()->showErrorPage(
206                    'cx',
207                    'cx-specialpage-enable-betafeature',
208                    [
209                        SpecialPage::getTitleFor( 'ContentTranslation' )
210                            ->getCanonicalURL( [ 'campaign' => 'specialcx' ] )
211                    ]
212                );
213                return false;
214            }
215        }
216
217        return true;
218    }
219
220    /**
221     * Returns true if user requested to open the translation view,
222     * false if CX dashboard is requested.
223     *
224     * @return bool
225     */
226    protected function onTranslationView() {
227        return $this->hasValidToken();
228    }
229
230    /**
231     * @return bool
232     */
233    private static function isMobileSite() {
234        $isMobileView = false;
235        if ( ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' ) ) {
236            /** @var MobileContext $mobileContext */
237            $mobileContext = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' );
238            $isMobileView = $mobileContext->shouldDisplayMobileView();
239        }
240        return $isMobileView;
241    }
242
243    protected function isUnifiedDashboard(): bool {
244        $forceUnifiedDashboard = $this->getConfig()->get( 'ContentTranslationEnableUnifiedDashboard' ) ||
245            $this->getRequest()->getFuzzyBool( 'unified-dashboard' );
246
247        $isSXEnabled = $this->getConfig()->get( 'ContentTranslationEnableSectionTranslation' );
248
249        $vueDashboardShouldBeEnabled = $isSXEnabled && self::isMobileSite();
250
251        return ( $vueDashboardShouldBeEnabled || $forceUnifiedDashboard ) && !$this->onTranslationView();
252    }
253
254    protected function initModules() {
255        $config = $this->getConfig();
256        $out = $this->getOutput();
257
258        $contentTranslationTranslateInTarget = $config->get( 'ContentTranslationTranslateInTarget' );
259        if ( $this->onTranslationView() && !self::isMobileSite() ) {
260            $out->addModules( 'mw.cx.init' );
261            // If Wikibase is installed, load the module for linking
262            // the published article with the source article
263            if ( $contentTranslationTranslateInTarget
264                && ExtensionRegistry::getInstance()->isLoaded( 'WikibaseClient' ) ) {
265                $out->addModules( 'ext.cx.wikibase.link' );
266            }
267        } else {
268            if ( $this->isUnifiedDashboard() ) {
269                $out->addModules( 'mw.cx3' );
270                $out->addJsConfigVars( [
271                    'wgSectionTranslationTargetLanguages' => $config->get( 'SectionTranslationTargetLanguages' ),
272                    'wgContentTranslationTranslateInTarget' => $contentTranslationTranslateInTarget
273                ] );
274            } else {
275                $out->addModules( 'ext.cx.dashboard' );
276                $out->addMeta( 'viewport', 'width=device-width, initial-scale=1' );
277            }
278        }
279    }
280
281    protected function addJsConfigVars() {
282        $config = $this->getConfig();
283        $out = $this->getOutput();
284
285        if ( $this->onTranslationView() ) {
286            $version = 2;
287
288            $out->addJsConfigVars( [
289                'wgContentTranslationUnmodifiedMTThresholdForPublish' =>
290                    $config->get( 'ContentTranslationUnmodifiedMTThresholdForPublish' ),
291                'wgContentTranslationCampaigns' => $config->get( 'ContentTranslationCampaigns' ),
292                'wgContentTranslationPublishRequirements' => $config->get( 'ContentTranslationPublishRequirements' ),
293                'wgContentTranslationVersion' => $version,
294                'wgContentTranslationEnableMT' => $config->get( 'ContentTranslationEnableMT' )
295            ] );
296
297        } else {
298            $out->addJsConfigVars( [
299                'wgContentTranslationEnableSuggestions' => $config->get( 'ContentTranslationEnableSuggestions' ),
300                'wgRecommendToolAPIURL' => $config->get( 'RecommendToolAPIURL' ),
301                'wgContentTranslationExcludedNamespaces' => $config->get( 'ContentTranslationExcludedNamespaces' )
302            ] );
303        }
304    }
305
306    /**
307     * @inheritDoc
308     */
309    protected function afterExecute( $subPage ) {
310        $campaign = $this->getRequest()->getVal( 'campaign' );
311        $user = $this->getUser();
312
313        // Anonymous users cannot have global preferences
314        if ( $campaign === null || !$user->isRegistered() ) {
315            return;
316        }
317
318        $persistentEntrypointCampaigns = [ 'contributions-page', 'contributionsmenu' ];
319        if ( $this->preferenceHelper->getGlobalPreference( $user, 'cx-entrypoint-fd-status' ) !== 'shown' ) {
320            if ( in_array( $campaign, $persistentEntrypointCampaigns ) ) {
321                // The user accessed CX using a persistent invitation.
322                // It means, user is aware of the entrypoint. No need to show the feature discovery again
323                $this->preferenceHelper->setGlobalPreference( $user, 'cx-entrypoint-fd-status', 'shown' );
324            } else {
325                // The user accessed CX using a non-persistent invitation.
326                // Show a one-time indicator to tell user that they can access CX using persistent entrypoints
327                // Set a global preference that the feature discovery is set for the user
328                // This preference has three possible values: `pending`, `shown`, 'notshown'
329                $this->preferenceHelper->setGlobalPreference( $user, 'cx-entrypoint-fd-status', 'pending' );
330            }
331        }
332    }
333}