Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 153
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialContentTranslation
0.00% covered (danger)
0.00%
0 / 153
0.00% covered (danger)
0.00%
0 / 16
2352
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
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 / 8
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
 initModules
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 addJsConfigVars
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
12
 afterExecute
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 doesWrites
n/a
0 / 0
n/a
0 / 0
1
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\Config\Config;
14use MediaWiki\Context\DerivativeContext;
15use MediaWiki\Context\MutableContext;
16use MediaWiki\Deferred\DeferredUpdates;
17use MediaWiki\Html\Html;
18use MediaWiki\MediaWikiServices;
19use MediaWiki\Registration\ExtensionRegistry;
20use MediaWiki\Skin\SkinFactory;
21use MediaWiki\SpecialPage\SpecialPage;
22use MediaWiki\WikiMap\WikiMap;
23use MobileContext;
24
25/**
26 * Implements the core of the Content Translation extension:
27 * a special page that shows Content Translation user interface.
28 */
29class SpecialContentTranslation extends SpecialPage {
30
31    public function __construct(
32        private readonly SkinFactory $skinFactory,
33        private readonly PreferenceHelper $preferenceHelper,
34        private readonly Config $communityConfig
35    ) {
36        parent::__construct( 'ContentTranslation' );
37    }
38
39    /** @inheritDoc */
40    public function getDescription() {
41        return $this->msg( 'cx' );
42    }
43
44    /** @inheritDoc */
45    public function execute( $parameters ) {
46        parent::execute( $parameters );
47
48        // Use custom 'contenttranslation' skin
49        /** @var MutableContext $context */
50        $context = $this->getContext();
51        if ( !$context instanceof MutableContext ) {
52            // Need to be able to change the skin
53            $context = new DerivativeContext( $context );
54            $this->setContext( $context );
55        }
56
57        '@phan-var MutableContext $context';
58        $context->setSkin(
59            $this->skinFactory->makeSkin( 'contenttranslation' )
60        );
61
62        if ( $this->hasValidToken() && !$this->isTargetEqualToCurrentDomain() ) {
63            $this->redirectToTargetCX();
64
65            return;
66        }
67
68        if ( !$this->canUserProceed() ) {
69            return;
70        }
71
72        if ( !$this->onDesktopTranslationView() ) {
73            $out = $this->getOutput();
74            $out->addHTML( Html::element(
75                'div',
76                [ 'id' => 'contenttranslation' ]
77            ) );
78        }
79        // Run the extendable chunks from the subclass.
80        $this->initModules();
81        $this->addJsConfigVars();
82    }
83
84    /** @inheritDoc */
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            $optionsManager->setOption( $user, 'cx', '1' );
98            $optionsManager->saveOptions( $user );
99        } );
100    }
101
102    private function isValidCampaign( ?string $campaign ): bool {
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     */
118    private static function encodeURIComponent( string $string ): string {
119        $revert = [ '%21' => '!', '%2A' => '*', '%27' => "'", '%28' => '(', '%29' => ')' ];
120        return strtr( rawurlencode( $string ), $revert );
121    }
122
123    private function isTargetEqualToCurrentDomain(): bool {
124        $request = $this->getRequest();
125        $to = $request->getVal( 'to' );
126
127        // Since we can only publish to the current wiki, enforce that the target language matches
128        // the wiki we are currently on. If not, redirect the user back to dashboard, where he can
129        // start again with parameters filled (and redirected to the correct wiki).
130        $contentTranslationTranslateInTarget = $this->getConfig()->get( 'ContentTranslationTranslateInTarget' );
131        if ( $contentTranslationTranslateInTarget ) {
132            $currentLangCode = SiteMapper::getCurrentLanguageCode();
133            $currentDomainCode = SiteMapper::getDomainCode( $currentLangCode );
134            return $to === $currentLangCode || $to === $currentDomainCode;
135        }
136
137        // For development (single instance) use, there is no need to check the target domain,
138        // because we don't redirect.
139        return true;
140    }
141
142    private function redirectToTargetCX() {
143        $request = $this->getRequest();
144        $sourceLanguage = $request->getVal( 'from' );
145        $targetLanguage = $request->getVal( 'to' );
146        $sourceTitle = $request->getVal( 'page' );
147        $targetTitle = $request->getVal( 'targettitle' );
148        $extra = $request->getQueryValuesOnly();
149        unset( $extra['title'] );
150
151        $cxUrl = SiteMapper::getCXUrl(
152            $sourceLanguage,
153            $targetLanguage,
154            $sourceTitle,
155            $targetTitle,
156            $extra
157        );
158
159        $out = $this->getOutput();
160        $out->redirect( $cxUrl );
161    }
162
163    /**
164     * Check if the request has a token to use CX.
165     * With a valid cx token override beta feature settings.
166     */
167    private function hasValidToken(): bool {
168        $request = $this->getRequest();
169
170        if ( !$this->getUser()->isRegistered() ) {
171            // Tokens are valid only for logged in users.
172            return false;
173        }
174
175        $title = $request->getVal( 'page' );
176
177        if ( $title === null ) {
178            return false;
179        }
180        $from = $request->getVal( 'from' );
181        $to = $request->getVal( 'to' );
182        if ( $from === null || $to === null ) {
183            return false;
184        }
185        // Cookie name is base64 encoding of parameters that uniquely define a translation.
186        $cookieName = 'cx_' . base64_encode( self::encodeURIComponent( implode( '_', [ $title, $from, $to ] ) ) );
187        // Remove all characters that are not allowed in cookie name: ( ) < > @ , ; : \ " / [ ] ? = { }.
188        $cookieName = preg_replace( '/[()<>@,;:\\"\/\[\]?={}]/', '', $cookieName );
189
190        return $request->getCookie( $cookieName, '' ) !== null;
191    }
192
193    protected function canUserProceed(): bool {
194        $allowAnonSX = $this->getConfig()->get( 'ContentTranslationEnableAnonSectionTranslation' );
195        $hasValidToken = $this->hasValidToken();
196        $campaign = $this->getRequest()->getVal( 'campaign' );
197        $isCampaign = $this->isValidCampaign( $campaign );
198
199        // Allow access to SX for everyone, when unified dashboard should be displayed
200        // and "ContentTranslationEnableAnonSectionTranslation" is set to true.
201        if ( !$this->onDesktopTranslationView() && $allowAnonSX ) {
202            return true;
203        }
204
205        // For all logged-in user, if CX beta feature is not enabled, and has
206        // valid token or campaign, enable CX beta feature and proceed.
207        // This is applicable for both CX and SX.
208        if ( !$this->preferenceHelper->isEnabledForUser( $this->getUser() ) ) {
209            if ( $hasValidToken || $isCampaign ) {
210                // User has a token or a valid campaign param.
211                // Enable cx for the user in this wiki.
212                $this->enableCXBetaFeature();
213            } else {
214                if ( $campaign ) {
215                    // Show login page if the URL has campaign parameter
216                    $this->requireNamedUser();
217                }
218                // Invalid or missing campaign param
219                $this->getOutput()->showErrorPage(
220                    'cx',
221                    'cx-specialpage-enable-betafeature',
222                    [
223                        SpecialPage::getTitleFor( 'ContentTranslation' )
224                            ->getCanonicalURL( [ 'campaign' => 'specialcx' ] )
225                    ]
226                );
227                return false;
228            }
229        }
230
231        return true;
232    }
233
234    /**
235     * Returns true if user requested to open the desktop translation view,
236     * false if CX dashboard or mobile editor is requested.
237     */
238    protected function onDesktopTranslationView(): bool {
239        return $this->hasValidToken() && !self::isMobileSite();
240    }
241
242    private static function isMobileSite(): bool {
243        $isMobileView = false;
244        if ( ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' ) ) {
245            /** @var MobileContext $mobileContext */
246            $mobileContext = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' );
247            $isMobileView = $mobileContext->shouldDisplayMobileView();
248        }
249        return $isMobileView;
250    }
251
252    protected function initModules() {
253        $config = $this->getConfig();
254        $out = $this->getOutput();
255
256        $contentTranslationTranslateInTarget = $config->get( 'ContentTranslationTranslateInTarget' );
257        if ( $this->onDesktopTranslationView() ) {
258            $out->addModules( 'mw.cx.init' );
259            // If Wikibase is installed, load the module for linking
260            // the published article with the source article
261            if ( $contentTranslationTranslateInTarget
262                && ExtensionRegistry::getInstance()->isLoaded( 'WikibaseClient' ) ) {
263                $out->addModules( 'ext.cx.wikibase.link' );
264            }
265        } else {
266            $out->addModules( 'mw.cx3' );
267            $out->addJsConfigVars( [
268                'wgContentTranslationTranslateInTarget' => $contentTranslationTranslateInTarget
269            ] );
270        }
271    }
272
273    protected function addJsConfigVars() {
274        $config = $this->getConfig();
275        $out = $this->getOutput();
276        $featuredCollectionName = $this->communityConfig->get( 'ContentTranslationFeaturedCollection' );
277        $featuredCollectionDescription =
278            $this->communityConfig->get( 'ContentTranslationFeaturedCollectionDescription' );
279        $featuredCollectionLink = $this->communityConfig->get( 'ContentTranslationFeaturedCollectionLink' );
280
281        $communityNameMsg = $this->msg( 'project-localized-name-' . WikiMap::getCurrentWikiId() );
282        $communityName = $communityNameMsg->IsDisabled() ? null : $communityNameMsg->text();
283
284        $out->addJsConfigVars( [
285            'ContentTranslationDomainCodeMapping' => $config->get( 'ContentTranslationDomainCodeMapping' ),
286            'wgContentTranslationUnmodifiedMTThresholdForPublish' =>
287                $config->get( 'ContentTranslationUnmodifiedMTThresholdForPublish' ),
288            'wgContentTranslationPublishRequirements' =>
289                $config->get( 'ContentTranslationPublishRequirements' ),
290            'wgContentTranslationEnableMT' => $config->get( 'ContentTranslationEnableMT' ),
291            'wgContentTranslationFeaturedCollection' => $featuredCollectionName,
292            'wgContentTranslationFeaturedCollectionCommunityName' => $communityName,
293            'wgContentTranslationFeaturedCollectionDescription' => $featuredCollectionDescription,
294            'wgContentTranslationFeaturedCollectionLink' => $featuredCollectionLink,
295        ] );
296
297        if ( $this->onDesktopTranslationView() ) {
298            $version = 2;
299            $out->addJsConfigVars( [
300                'wgContentTranslationCampaigns' => $config->get( 'ContentTranslationCampaigns' ),
301                'wgContentTranslationVersion' => $version
302            ] );
303
304        } else {
305            $out->addJsConfigVars( [ 'wgRecommendToolAPIURL' => $config->get( 'RecommendToolAPIURL' ) ] );
306        }
307    }
308
309    /**
310     * @inheritDoc
311     */
312    protected function afterExecute( $subPage ) {
313        $campaign = $this->getRequest()->getVal( 'campaign' );
314        $user = $this->getUser();
315
316        // Anonymous users cannot have global preferences
317        if ( $campaign === null || !$user->isRegistered() ) {
318            return;
319        }
320
321        $persistentEntrypointCampaigns = [ 'contributions-page', 'contributionsmenu' ];
322        if ( $this->preferenceHelper->getGlobalPreference( $user, 'cx-entrypoint-fd-status' ) !== 'shown' ) {
323            if ( in_array( $campaign, $persistentEntrypointCampaigns ) ) {
324                // The user accessed CX using a persistent invitation.
325                // It means, user is aware of the entrypoint. No need to show the feature discovery again
326                $this->preferenceHelper->setGlobalPreference( $user, 'cx-entrypoint-fd-status', 'shown' );
327            } else {
328                // The user accessed CX using a non-persistent invitation.
329                // Show a one-time indicator to tell user that they can access CX using persistent entrypoints
330                // Set a global preference that the feature discovery is set for the user
331                // This preference has three possible values: `pending`, `shown`, 'notshown'
332                $this->preferenceHelper->setGlobalPreference( $user, 'cx-entrypoint-fd-status', 'pending' );
333            }
334        }
335    }
336
337    /**
338     * @codeCoverageIgnore Merely declarative
339     * @inheritDoc
340     */
341    public function doesWrites() {
342        return true;
343    }
344}