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