Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 183
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialContentTranslation
0.00% covered (danger)
0.00%
0 / 183
0.00% covered (danger)
0.00%
0 / 19
3540
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 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 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
 isTargetEqualToCurrentDomain
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 redirectToTargetCX
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 hasValidToken
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 canUserProceed
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 onDesktopTranslationView
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isMobileSite
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getUserPreferedDashboard
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 setUserPreferedDashboard
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 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 initModules
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 addJsConfigVars
0.00% covered (danger)
0.00%
0 / 21
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\Skin\SkinFactory;
20use MediaWiki\SpecialPage\SpecialPage;
21use MobileContext;
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->hasValidToken() && !$this->isTargetEqualToCurrentDomain() ) {
72            $this->redirectToTargetCX();
73
74            return;
75        }
76
77        if ( !$this->canUserProceed() ) {
78            return;
79        }
80
81        if ( $this->isUnifiedDashboard() ) {
82            $out = $this->getOutput();
83            $out->addHTML( Html::element(
84                'div',
85                [ 'id' => 'contenttranslation' ]
86            ) );
87        }
88        // Run the extendable chunks from the sub class.
89        $this->initModules();
90        $this->addJsConfigVars();
91    }
92
93    /** @inheritDoc */
94    public function isListed() {
95        return $this->preferenceHelper->isEnabledForUser( $this->getUser() );
96    }
97
98    public function enableCXBetaFeature() {
99        $out = $this->getOutput();
100        $out->addJsConfigVars( 'wgContentTranslationBetaFeatureEnabled', true );
101
102        $user = $this->getUser();
103        // Promise to persist the setting post-send
104        DeferredUpdates::addCallableUpdate( static function () use ( $user ) {
105            $optionsManager = MediaWikiServices::getInstance()->getUserOptionsManager();
106            $user = $user->getInstanceForUpdate();
107            $optionsManager->setOption( $user, 'cx', '1' );
108            $optionsManager->saveOptions( $user );
109        } );
110    }
111
112    private function isValidCampaign( ?string $campaign ): bool {
113        $contentTranslationCampaigns = $this->getConfig()->get( 'ContentTranslationCampaigns' );
114
115        if ( !$this->getUser()->isNamed() ) {
116            // Campaigns are only for named logged-in users.
117            return false;
118        }
119        return $campaign !== null
120            && isset( $contentTranslationCampaigns[$campaign] )
121            && $contentTranslationCampaigns[$campaign];
122    }
123
124    /**
125     * JS-compatible encodeURIComponent function
126     * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
127     */
128    private static function encodeURIComponent( string $string ): string {
129        $revert = [ '%21' => '!', '%2A' => '*', '%27' => "'", '%28' => '(', '%29' => ')' ];
130        return strtr( rawurlencode( $string ), $revert );
131    }
132
133    private function isTargetEqualToCurrentDomain(): bool {
134        $request = $this->getRequest();
135        $to = $request->getVal( 'to' );
136
137        // Since we can only publish to the current wiki, enforce that the target language matches
138        // the wiki we are currently on. If not, redirect the user back to dashboard, where he can
139        // start again with parameters filled (and redirected to the correct wiki).
140        $contentTranslationTranslateInTarget = $this->getConfig()->get( 'ContentTranslationTranslateInTarget' );
141        if ( $contentTranslationTranslateInTarget ) {
142            $currentLangCode = SiteMapper::getCurrentLanguageCode();
143            $currentDomainCode = SiteMapper::getDomainCode( $currentLangCode );
144            return $to === $currentLangCode || $to === $currentDomainCode;
145        }
146
147        // For development (single instance) use, there is no need to check the target domain,
148        // because we don't redirect.
149        return true;
150    }
151
152    private function redirectToTargetCX() {
153        $request = $this->getRequest();
154        $sourceLanguage = $request->getVal( 'from' );
155        $targetLanguage = $request->getVal( 'to' );
156        $sourceTitle = $request->getVal( 'page' );
157        $targetTitle = $request->getVal( 'targettitle' );
158        $extra = $request->getQueryValuesOnly();
159        unset( $extra['title'] );
160
161        $cxUrl = SiteMapper::getCXUrl(
162            $sourceLanguage,
163            $targetLanguage,
164            $sourceTitle,
165            $targetTitle,
166            $extra
167        );
168
169        $out = $this->getOutput();
170        $out->redirect( $cxUrl );
171    }
172
173    /**
174     * Check if the request has a token to use CX.
175     * With a valid cx token override beta feature settings.
176     */
177    private function hasValidToken(): bool {
178        $request = $this->getRequest();
179
180        if ( !$this->getUser()->isRegistered() ) {
181            // Tokens are valid only for logged in users.
182            return false;
183        }
184
185        $title = $request->getVal( 'page' );
186
187        if ( $title === null ) {
188            return false;
189        }
190        $from = $request->getVal( 'from' );
191        $to = $request->getVal( 'to' );
192        if ( $from === null || $to === null ) {
193            return false;
194        }
195        // Cookie name is base64 encoding of parameters that uniquely define a translation.
196        $cookieName = 'cx_' . base64_encode( self::encodeURIComponent( implode( '_', [ $title, $from, $to ] ) ) );
197        // Remove all characters that are not allowed in cookie name: ( ) < > @ , ; : \ " / [ ] ? = { }.
198        $cookieName = preg_replace( '/[()<>@,;:\\"\/\[\]?={}]/', '', $cookieName );
199
200        return $request->getCookie( $cookieName, '' ) !== null;
201    }
202
203    protected function canUserProceed(): bool {
204        $allowAnonSX = $this->getConfig()->get( 'ContentTranslationEnableAnonSectionTranslation' );
205        $hasValidToken = $this->hasValidToken();
206        $campaign = $this->getRequest()->getVal( 'campaign' );
207        $isCampaign = $this->isValidCampaign( $campaign );
208
209        // Allow access to SX for everyone, when unified dashboard should be displayed
210        // and "ContentTranslationEnableAnonSectionTranslation" is set to true.
211        if ( $this->isUnifiedDashboard() && $allowAnonSX ) {
212            return true;
213        }
214
215        // For all logged-in user, if CX beta feature is not enabled, and has
216        // valid token or campaign, enable CX beta feature and proceed.
217        // This is applicable for both CX and SX.
218        if ( !$this->preferenceHelper->isEnabledForUser( $this->getUser() ) ) {
219            if ( $hasValidToken || $isCampaign ) {
220                // User has a token or a valid campaign param.
221                // Enable cx for the user in this wiki.
222                $this->enableCXBetaFeature();
223            } else {
224                if ( $campaign ) {
225                    // Show login page if the URL has campaign parameter
226                    $this->requireNamedUser();
227                }
228                // Invalid or missing campaign param
229                $this->getOutput()->showErrorPage(
230                    'cx',
231                    'cx-specialpage-enable-betafeature',
232                    [
233                        SpecialPage::getTitleFor( 'ContentTranslation' )
234                            ->getCanonicalURL( [ 'campaign' => 'specialcx' ] )
235                    ]
236                );
237                return false;
238            }
239        }
240
241        return true;
242    }
243
244    /**
245     * Returns true if user requested to open the desktop translation view,
246     * false if CX dashboard or mobile editor is requested.
247     */
248    protected function onDesktopTranslationView(): bool {
249        return $this->hasValidToken() && !self::isMobileSite();
250    }
251
252    /**
253     * @return bool
254     */
255    private static function isMobileSite() {
256        $isMobileView = false;
257        if ( ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' ) ) {
258            /** @var MobileContext $mobileContext */
259            $mobileContext = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' );
260            $isMobileView = $mobileContext->shouldDisplayMobileView();
261        }
262        return $isMobileView;
263    }
264
265    /**
266     * @return string|null The user's prefered dashboard or null if not set or has expired
267     */
268    private function getUserPreferedDashboard() {
269        if ( $this->getUser()->isAnon() ) {
270            return null;
271        }
272        $value = $this->preferenceHelper->getGlobalPreference( $this->getUser(), 'cx-dashboard' );
273        if ( $value === null ) {
274            return null;
275        }
276
277        [ $dashboard, $time ] = explode( '-', $value );
278        if ( $time < time() - 3600 ) {
279            // The preference is older than an hour
280            return null;
281        }
282
283        return $dashboard;
284    }
285
286    /**
287     * Set the user's prefered dashboard with the current time
288     */
289    private function setUserPreferedDashboard( string $dashboard ): void {
290        if ( $this->getUser()->isAnon() ) {
291            return;
292        }
293        $time = time();
294        $this->preferenceHelper->setGlobalPreference(
295            $this->getUser(), 'cx-dashboard', "{$dashboard}-{$time}"
296        );
297    }
298
299    protected function isUnifiedDashboard(): bool {
300        if ( $this->onDesktopTranslationView() ) {
301            // Not on a dashboard or mobile editor
302            return false;
303        }
304
305        $unifiedDashboardEnabled = $this->getConfig()->get( 'ContentTranslationEnableUnifiedDashboard' );
306
307        if ( $unifiedDashboardEnabled ) {
308            if ( $this->isMobileSite() ) {
309                // mobile site gets unified dashboard
310                return true;
311            }
312
313            // transition to unified dashboard
314            $dashboardParam = $this->getRequest()->getText( 'cx-dashboard' );
315
316            // The unified or desktop dashboard is explicitly requested by the user
317            if ( in_array( $dashboardParam, [ 'unified', 'desktop' ] ) ) {
318                // record explicit choice in global preference
319                $this->setUserPreferedDashboard( $dashboardParam );
320                return $dashboardParam === 'unified';
321            }
322
323            // check global preference
324            $dashboard = $this->getUserPreferedDashboard();
325            if ( $dashboard !== null ) {
326                return $dashboard === 'unified';
327            }
328
329            return true;
330        }
331
332        return $this->getRequest()->getFuzzyBool( 'unified-dashboard' );
333    }
334
335    protected function initModules() {
336        $config = $this->getConfig();
337        $out = $this->getOutput();
338
339        $contentTranslationTranslateInTarget = $config->get( 'ContentTranslationTranslateInTarget' );
340        if ( $this->onDesktopTranslationView() ) {
341            $out->addModules( 'mw.cx.init' );
342            // If Wikibase is installed, load the module for linking
343            // the published article with the source article
344            if ( $contentTranslationTranslateInTarget
345                && ExtensionRegistry::getInstance()->isLoaded( 'WikibaseClient' ) ) {
346                $out->addModules( 'ext.cx.wikibase.link' );
347            }
348        } else {
349            if ( $this->isUnifiedDashboard() ) {
350                $out->addModules( 'mw.cx3' );
351                $out->addJsConfigVars( [
352                    'wgContentTranslationTranslateInTarget' => $contentTranslationTranslateInTarget
353                ] );
354            } else {
355                $out->addModules( 'ext.cx.dashboard' );
356                $out->addMeta( 'viewport', 'width=device-width, initial-scale=1' );
357            }
358        }
359    }
360
361    protected function addJsConfigVars() {
362        $config = $this->getConfig();
363        $out = $this->getOutput();
364
365        $out->addJsConfigVars( [
366            'wgContentTranslationUnmodifiedMTThresholdForPublish' =>
367                $config->get( 'ContentTranslationUnmodifiedMTThresholdForPublish' )
368        ] );
369
370        if ( $this->onDesktopTranslationView() ) {
371            $version = 2;
372            $out->addJsConfigVars( [
373                'wgContentTranslationCampaigns' => $config->get( 'ContentTranslationCampaigns' ),
374                'wgContentTranslationPublishRequirements' => $config->get( 'ContentTranslationPublishRequirements' ),
375                'wgContentTranslationVersion' => $version,
376                'wgContentTranslationEnableMT' => $config->get( 'ContentTranslationEnableMT' )
377            ] );
378
379        } else {
380            $out->addJsConfigVars( [
381                'wgContentTranslationEnableSuggestions' => $config->get( 'ContentTranslationEnableSuggestions' ),
382                'wgRecommendToolAPIURL' => $config->get( 'RecommendToolAPIURL' ),
383                'wgContentTranslationExcludedNamespaces' => $config->get( 'ContentTranslationExcludedNamespaces' ),
384                'wgContentTranslationEnableUnifiedDashboard' =>
385                    $config->get( 'ContentTranslationEnableUnifiedDashboard' )
386            ] );
387        }
388    }
389
390    /**
391     * @inheritDoc
392     */
393    protected function afterExecute( $subPage ) {
394        $campaign = $this->getRequest()->getVal( 'campaign' );
395        $user = $this->getUser();
396
397        // Anonymous users cannot have global preferences
398        if ( $campaign === null || !$user->isRegistered() ) {
399            return;
400        }
401
402        $persistentEntrypointCampaigns = [ 'contributions-page', 'contributionsmenu' ];
403        if ( $this->preferenceHelper->getGlobalPreference( $user, 'cx-entrypoint-fd-status' ) !== 'shown' ) {
404            if ( in_array( $campaign, $persistentEntrypointCampaigns ) ) {
405                // The user accessed CX using a persistent invitation.
406                // It means, user is aware of the entrypoint. No need to show the feature discovery again
407                $this->preferenceHelper->setGlobalPreference( $user, 'cx-entrypoint-fd-status', 'shown' );
408            } else {
409                // The user accessed CX using a non-persistent invitation.
410                // Show a one-time indicator to tell user that they can access CX using persistent entrypoints
411                // Set a global preference that the feature discovery is set for the user
412                // This preference has three possible values: `pending`, `shown`, 'notshown'
413                $this->preferenceHelper->setGlobalPreference( $user, 'cx-entrypoint-fd-status', 'pending' );
414            }
415        }
416    }
417}