Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.40% covered (warning)
86.40%
108 / 125
73.33% covered (warning)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImportPlanValidator
86.40% covered (warning)
86.40%
108 / 125
73.33% covered (warning)
73.33%
11 / 15
40.44
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 runCommonsHelperChecksAndConversions
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
3.27
 runLicenseChecks
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 cleanWikitext
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 runWikiLinkConversions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 runBasicTitleCheck
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 warnOnAutomaticTitleChanges
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 runPermissionTitleChecks
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 getAllowedFileExtensions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 runFileTitleCheck
62.50% covered (warning)
62.50%
15 / 24
0.00% covered (danger)
0.00%
0 / 1
9.58
 runFileExtensionCheck
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 runDuplicateFilesCheck
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 runLocalTitleConflictCheck
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 runRemoteTitleConflictCheck
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace FileImporter\Services;
4
5use FileImporter\Data\ImportDetails;
6use FileImporter\Data\ImportPlan;
7use FileImporter\Data\WikitextConversions;
8use FileImporter\Exceptions\DuplicateFilesException;
9use FileImporter\Exceptions\LocalizedImportException;
10use FileImporter\Exceptions\RecoverableTitleException;
11use FileImporter\Exceptions\TitleException;
12use FileImporter\Interfaces\ImportTitleChecker;
13use FileImporter\Remote\MediaWiki\CommonsHelperConfigRetriever;
14use FileImporter\Services\UploadBase\UploadBaseFactory;
15use FileImporter\Services\Wikitext\CommonsHelperConfigParser;
16use FileImporter\Services\Wikitext\WikiLinkParserFactory;
17use FileImporter\Services\Wikitext\WikitextContentCleaner;
18use MediaWiki\MainConfigNames;
19use MediaWiki\MediaWikiServices;
20use MediaWiki\Message\Message;
21use MediaWiki\Permissions\Authority;
22use MediaWiki\Permissions\PermissionStatus;
23use MediaWiki\Permissions\RestrictionStore;
24use MediaWiki\Title\MalformedTitleException;
25use MediaWiki\Title\Title;
26use MediaWiki\Upload\UploadBase;
27
28/**
29 * @license GPL-2.0-or-later
30 * @author Addshore
31 */
32class ImportPlanValidator {
33
34    public function __construct(
35        private readonly DuplicateFileRevisionChecker $duplicateFileChecker,
36        private readonly ImportTitleChecker $importTitleChecker,
37        private readonly UploadBaseFactory $uploadBaseFactory,
38        private readonly ?CommonsHelperConfigRetriever $commonsHelperConfigRetriever,
39        private readonly ?string $commonsHelperHelpPage,
40        private readonly WikiLinkParserFactory $wikiLinkParserFactory,
41        private readonly RestrictionStore $restrictionStore,
42    ) {
43    }
44
45    /**
46     * Validate the ImportPlan by running various checks.
47     * The order of the checks is vaguely important as some can be actively solved in the extension
48     * and others cannot.
49     * It's frustrating to the user if they fix one thing, only to be shown another error that
50     * cannot be easily fixed.
51     *
52     * @param ImportPlan $importPlan The plan to be validated
53     * @param Authority $user User that executes the import
54     *
55     * @throws TitleException When there is a problem with the planned title (can't be fixed easily).
56     * @throws DuplicateFilesException When a file with the same hash is detected locally..
57     * @throws RecoverableTitleException When there is a problem with the title that can be fixed.
58     */
59    public function validate( ImportPlan $importPlan, Authority $user ): void {
60        // Have to run this first because other tests don't make sense without basic title sanity.
61        $this->runBasicTitleCheck( $importPlan );
62
63        // Unrecoverable errors
64        $this->runPermissionTitleChecks( $importPlan, $user );
65        $this->runFileExtensionCheck( $importPlan );
66        $this->runDuplicateFilesCheck( $importPlan );
67
68        // Conversions
69        $this->runCommonsHelperChecksAndConversions( $importPlan );
70        $this->runWikiLinkConversions( $importPlan );
71
72        // Solvable errors
73        $this->warnOnAutomaticTitleChanges( $importPlan );
74        $this->runFileTitleCheck( $importPlan );
75        $this->runLocalTitleConflictCheck( $importPlan );
76        $this->runRemoteTitleConflictCheck( $importPlan );
77    }
78
79    private function runCommonsHelperChecksAndConversions( ImportPlan $importPlan ): void {
80        if ( !$this->commonsHelperConfigRetriever ) {
81            return;
82        }
83
84        $details = $importPlan->getDetails();
85        $sourceUrl = $details->getSourceUrl();
86
87        if ( !$this->commonsHelperConfigRetriever->retrieveConfiguration( $sourceUrl ) ) {
88            throw new LocalizedImportException( [
89                'fileimporter-commonshelper-missing-config',
90                $sourceUrl->getHost(),
91                $this->commonsHelperHelpPage
92            ] );
93        }
94
95        $commonHelperConfigParser = new CommonsHelperConfigParser(
96            $this->commonsHelperConfigRetriever->getConfigWikiUrl(),
97            $this->commonsHelperConfigRetriever->getConfigWikitext()
98        );
99
100        $this->runLicenseChecks( $details, $commonHelperConfigParser->getWikitextConversions() );
101        $this->cleanWikitext( $importPlan, $commonHelperConfigParser->getWikitextConversions() );
102    }
103
104    private function runLicenseChecks( ImportDetails $details, WikitextConversions $conversions ): void {
105        $validator = new FileDescriptionPageValidator( $conversions );
106        $validator->hasRequiredTemplate( $details->getTemplates() );
107        $validator->validateTemplates( $details->getTemplates() );
108        $validator->validateCategories( $details->getCategories() );
109    }
110
111    private function cleanWikitext( ImportPlan $importPlan, WikitextConversions $conversions ): void {
112        $wikitext = $importPlan->getCleanedLatestRevisionText();
113        $cleaner = new WikitextContentCleaner( $conversions );
114
115        $sourceLanguage = $importPlan->getDetails()->getPageLanguage();
116        if ( $sourceLanguage ) {
117            $languageTemplate = Title::makeTitleSafe( NS_TEMPLATE, $sourceLanguage );
118            if ( $languageTemplate->exists() ) {
119                $cleaner->setSourceWikiLanguageTemplate( $sourceLanguage );
120            }
121        }
122
123        $importPlan->setCleanedLatestRevisionText( $cleaner->cleanWikitext( $wikitext ) );
124        $importPlan->setNumberOfTemplateReplacements( $cleaner->getLatestNumberOfReplacements() );
125    }
126
127    private function runWikiLinkConversions( ImportPlan $importPlan ): void {
128        $parser = $this->wikiLinkParserFactory->getWikiLinkParser(
129            $importPlan->getDetails()->getPageLanguage(),
130            $importPlan->getInterWikiPrefix()
131        );
132        $wikitext = $importPlan->getCleanedLatestRevisionText();
133        $importPlan->setCleanedLatestRevisionText( $parser->parse( $wikitext ) );
134    }
135
136    private function runBasicTitleCheck( ImportPlan $importPlan ): void {
137        try {
138            $importPlan->getTitle();
139        } catch ( MalformedTitleException $e ) {
140            throw new RecoverableTitleException(
141                [ $e->getErrorMessage(), ...$e->getErrorMessageParameters() ],
142                $importPlan,
143                $e
144            );
145        }
146    }
147
148    private function warnOnAutomaticTitleChanges( ImportPlan $importPlan ): void {
149        if ( $importPlan->getRequest()->getIntendedName() !== null &&
150            $importPlan->getFileName() !== $importPlan->getRequest()->getIntendedName()
151        ) {
152            throw new RecoverableTitleException(
153                [
154                    'fileimporter-filenameerror-automaticchanges',
155                    $importPlan->getRequest()->getIntendedName(),
156                    $importPlan->getFileName()
157                ],
158                $importPlan
159            );
160        }
161    }
162
163    private function runPermissionTitleChecks( ImportPlan $importPlan, Authority $user ): void {
164        $title = $importPlan->getTitle();
165
166        /**
167         * {@see UploadBase::authorizeUpload}
168         */
169        $status = PermissionStatus::newEmpty();
170        $user->authorizeWrite( 'edit', $title, $status );
171        $user->authorizeWrite( 'upload', $title, $status );
172        if ( !$status->isGood() ) {
173            throw new RecoverableTitleException( $status->getMessages()[0], $importPlan );
174        }
175
176        // Even administrators should not (accidentally) move a file to a protected file name
177        if ( $this->restrictionStore->isProtected( $title ) ) {
178            throw new RecoverableTitleException( 'fileimporter-filenameerror-protected', $importPlan );
179        }
180    }
181
182    /**
183     * @return string[]
184     */
185    private function getAllowedFileExtensions(): array {
186        $config = MediaWikiServices::getInstance()->getMainConfig();
187        return array_unique( $config->get( MainConfigNames::FileExtensions ) );
188    }
189
190    private function runFileTitleCheck( ImportPlan $importPlan ): void {
191        $plannedTitleText = $importPlan->getTitle()->getText();
192        if ( $plannedTitleText != wfStripIllegalFilenameChars( $plannedTitleText ) ) {
193            throw new RecoverableTitleException(
194                'fileimporter-illegalfilenamechars',
195                $importPlan
196            );
197        }
198
199        $base = $this->uploadBaseFactory->newValidatingUploadBase(
200            $importPlan->getTitle(),
201            ''
202        );
203        switch ( $base->validateTitle() ) {
204            case UploadBase::OK:
205                return;
206
207            case UploadBase::FILETYPE_BADTYPE:
208                // Stop the import early if the extension is not allowed on the destination wiki
209                throw new TitleException( [
210                    'fileimporter-filenameerror-notallowed',
211                    $importPlan->getFileExtension(),
212                    Message::listParam( $this->getAllowedFileExtensions() )
213                ] );
214
215            case UploadBase::ILLEGAL_FILENAME:
216                $errorMessage = 'fileimporter-filenameerror-illegal';
217                break;
218            case UploadBase::FILENAME_TOO_LONG:
219                $errorMessage = 'fileimporter-filenameerror-toolong';
220                break;
221            default:
222                $errorMessage = 'fileimporter-filenameerror-default';
223                break;
224        }
225        throw new RecoverableTitleException( $errorMessage, $importPlan );
226    }
227
228    private function runFileExtensionCheck( ImportPlan $importPlan ): void {
229        $sourcePathInfo = pathinfo( $importPlan->getDetails()->getSourceLinkTarget()->getText() );
230        $plannedPathInfo = pathinfo( $importPlan->getTitle()->getText() );
231
232        // Check that both the source and planned titles have extensions
233        if ( !array_key_exists( 'extension', $sourcePathInfo ) ) {
234            throw new TitleException( 'fileimporter-filenameerror-nosourceextension' );
235        }
236        if ( !array_key_exists( 'extension', $plannedPathInfo ) ) {
237            throw new TitleException( 'fileimporter-filenameerror-noplannedextension' );
238        }
239
240        // Check to ensure files are not imported with differing file extensions.
241        if (
242            strtolower( $sourcePathInfo['extension'] ) !==
243            strtolower( $plannedPathInfo['extension'] )
244        ) {
245            throw new TitleException( 'fileimporter-filenameerror-missmatchextension' );
246        }
247    }
248
249    private function runDuplicateFilesCheck( ImportPlan $importPlan ): void {
250        $duplicateFiles = $this->duplicateFileChecker->findDuplicates(
251            $importPlan->getDetails()->getFileRevisions()->getLatest()
252        );
253
254        if ( $duplicateFiles !== [] ) {
255            throw new DuplicateFilesException( $duplicateFiles );
256        }
257    }
258
259    private function runLocalTitleConflictCheck( ImportPlan $importPlan ): void {
260        if ( $importPlan->getTitle()->exists() ) {
261            throw new RecoverableTitleException(
262                'fileimporter-localtitleexists',
263                $importPlan
264            );
265        }
266    }
267
268    private function runRemoteTitleConflictCheck( ImportPlan $importPlan ): void {
269        $request = $importPlan->getRequest();
270        $details = $importPlan->getDetails();
271        $title = $importPlan->getTitle();
272
273        // Only check remotely if the title has been changed, if it is the same assume this is
274        // okay / intended / other checks have happened.
275        if (
276            $title->getText() !== $details->getSourceLinkTarget()->getText() &&
277            !$this->importTitleChecker->importAllowed( $request->getUrl(), $title->getText() )
278        ) {
279            throw new RecoverableTitleException(
280                'fileimporter-sourcetitleexists',
281                $importPlan
282            );
283        }
284    }
285
286}