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