Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.04% covered (warning)
85.04%
108 / 127
73.33% covered (warning)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImportPlanValidator
85.04% covered (warning)
85.04%
108 / 127
73.33% covered (warning)
73.33%
11 / 15
41.58
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 / 4
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\Context\RequestContext;
19use MediaWiki\MainConfigNames;
20use MediaWiki\MediaWikiServices;
21use MediaWiki\Permissions\Authority;
22use MediaWiki\Permissions\PermissionStatus;
23use MediaWiki\Permissions\RestrictionStore;
24use MediaWiki\Title\MalformedTitleException;
25use MediaWiki\Title\Title;
26use 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() {
186        $config = MediaWikiServices::getInstance()->getMainConfig();
187        $fileExtensions = array_unique( $config->get( MainConfigNames::FileExtensions ) );
188        $language = RequestContext::getMain()->getLanguage();
189        return $language->listToText( $fileExtensions );
190    }
191
192    private function runFileTitleCheck( ImportPlan $importPlan ): void {
193        $plannedTitleText = $importPlan->getTitle()->getText();
194        if ( $plannedTitleText != wfStripIllegalFilenameChars( $plannedTitleText ) ) {
195            throw new RecoverableTitleException(
196                'fileimporter-illegalfilenamechars',
197                $importPlan
198            );
199        }
200
201        $base = $this->uploadBaseFactory->newValidatingUploadBase(
202            $importPlan->getTitle(),
203            ''
204        );
205        switch ( $base->validateTitle() ) {
206            case UploadBase::OK:
207                return;
208
209            case UploadBase::FILETYPE_BADTYPE:
210                // Stop the import early if the extension is not allowed on the destination wiki
211                throw new TitleException( [
212                    'fileimporter-filenameerror-notallowed',
213                    $importPlan->getFileExtension(),
214                    $this->getAllowedFileExtensions()
215                ] );
216
217            case UploadBase::ILLEGAL_FILENAME:
218                $errorMessage = 'fileimporter-filenameerror-illegal';
219                break;
220            case UploadBase::FILENAME_TOO_LONG:
221                $errorMessage = 'fileimporter-filenameerror-toolong';
222                break;
223            default:
224                $errorMessage = 'fileimporter-filenameerror-default';
225                break;
226        }
227        throw new RecoverableTitleException( $errorMessage, $importPlan );
228    }
229
230    private function runFileExtensionCheck( ImportPlan $importPlan ): void {
231        $sourcePathInfo = pathinfo( $importPlan->getDetails()->getSourceLinkTarget()->getText() );
232        $plannedPathInfo = pathinfo( $importPlan->getTitle()->getText() );
233
234        // Check that both the source and planned titles have extensions
235        if ( !array_key_exists( 'extension', $sourcePathInfo ) ) {
236            throw new TitleException( 'fileimporter-filenameerror-nosourceextension' );
237        }
238        if ( !array_key_exists( 'extension', $plannedPathInfo ) ) {
239            throw new TitleException( 'fileimporter-filenameerror-noplannedextension' );
240        }
241
242        // Check to ensure files are not imported with differing file extensions.
243        if (
244            strtolower( $sourcePathInfo['extension'] ) !==
245            strtolower( $plannedPathInfo['extension'] )
246        ) {
247            throw new TitleException( 'fileimporter-filenameerror-missmatchextension' );
248        }
249    }
250
251    private function runDuplicateFilesCheck( ImportPlan $importPlan ): void {
252        $duplicateFiles = $this->duplicateFileChecker->findDuplicates(
253            $importPlan->getDetails()->getFileRevisions()->getLatest()
254        );
255
256        if ( $duplicateFiles !== [] ) {
257            throw new DuplicateFilesException( $duplicateFiles );
258        }
259    }
260
261    private function runLocalTitleConflictCheck( ImportPlan $importPlan ): void {
262        if ( $importPlan->getTitle()->exists() ) {
263            throw new RecoverableTitleException(
264                'fileimporter-localtitleexists',
265                $importPlan
266            );
267        }
268    }
269
270    private function runRemoteTitleConflictCheck( ImportPlan $importPlan ): void {
271        $request = $importPlan->getRequest();
272        $details = $importPlan->getDetails();
273        $title = $importPlan->getTitle();
274
275        // Only check remotely if the title has been changed, if it is the same assume this is
276        // okay / intended / other checks have happened.
277        if (
278            $title->getText() !== $details->getSourceLinkTarget()->getText() &&
279            !$this->importTitleChecker->importAllowed( $request->getUrl(), $title->getText() )
280        ) {
281            throw new RecoverableTitleException(
282                'fileimporter-sourcetitleexists',
283                $importPlan
284            );
285        }
286    }
287
288}