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