Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.82% covered (warning)
85.82%
115 / 134
73.33% covered (warning)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImportPlanValidator
85.82% covered (warning)
85.82%
115 / 134
73.33% covered (warning)
73.33%
11 / 15
40.90
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
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 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::verifyTitlePermissions}
193         */
194        $status = PermissionStatus::newEmpty();
195        $user->authorizeWrite( 'edit', $title, $status );
196        $user->authorizeWrite( 'upload', $title, $status );
197        if ( !$status->isGood() ) {
198            $permErrors = $status->toLegacyErrorArray();
199            throw new RecoverableTitleException( $permErrors[0], $importPlan );
200        }
201
202        // Even administrators should not (accidentally) move a file to a protected file name
203        if ( $this->restrictionStore->isProtected( $title ) ) {
204            throw new RecoverableTitleException( 'fileimporter-filenameerror-protected', $importPlan );
205        }
206    }
207
208    /**
209     * @return string
210     */
211    private function getAllowedFileExtensions() {
212        $config = MediaWikiServices::getInstance()->getMainConfig();
213        $fileExtensions = array_unique( $config->get( MainConfigNames::FileExtensions ) );
214        $language = RequestContext::getMain()->getLanguage();
215        return $language->listToText( $fileExtensions );
216    }
217
218    private function runFileTitleCheck( ImportPlan $importPlan ): void {
219        $plannedTitleText = $importPlan->getTitle()->getText();
220        if ( $plannedTitleText != wfStripIllegalFilenameChars( $plannedTitleText ) ) {
221            throw new RecoverableTitleException(
222                'fileimporter-illegalfilenamechars',
223                $importPlan
224            );
225        }
226
227        $base = $this->uploadBaseFactory->newValidatingUploadBase(
228            $importPlan->getTitle(),
229            ''
230        );
231        switch ( $base->validateTitle() ) {
232            case UploadBase::OK:
233                return;
234
235            case UploadBase::FILETYPE_BADTYPE:
236                // Stop the import early if the extension is not allowed on the destination wiki
237                throw new TitleException( [
238                    'fileimporter-filenameerror-notallowed',
239                    $importPlan->getFileExtension(),
240                    $this->getAllowedFileExtensions()
241                ] );
242
243            case UploadBase::ILLEGAL_FILENAME:
244                $errorMessage = 'fileimporter-filenameerror-illegal';
245                break;
246            case UploadBase::FILENAME_TOO_LONG:
247                $errorMessage = 'fileimporter-filenameerror-toolong';
248                break;
249            default:
250                $errorMessage = 'fileimporter-filenameerror-default';
251                break;
252        }
253        throw new RecoverableTitleException( $errorMessage, $importPlan );
254    }
255
256    private function runFileExtensionCheck( ImportPlan $importPlan ): void {
257        $sourcePathInfo = pathinfo( $importPlan->getDetails()->getSourceLinkTarget()->getText() );
258        $plannedPathInfo = pathinfo( $importPlan->getTitle()->getText() );
259
260        // Check that both the source and planned titles have extensions
261        if ( !array_key_exists( 'extension', $sourcePathInfo ) ) {
262            throw new TitleException( 'fileimporter-filenameerror-nosourceextension' );
263        }
264        if ( !array_key_exists( 'extension', $plannedPathInfo ) ) {
265            throw new TitleException( 'fileimporter-filenameerror-noplannedextension' );
266        }
267
268        // Check to ensure files are not imported with differing file extensions.
269        if (
270            strtolower( $sourcePathInfo['extension'] ) !==
271            strtolower( $plannedPathInfo['extension'] )
272        ) {
273            throw new TitleException( 'fileimporter-filenameerror-missmatchextension' );
274        }
275    }
276
277    private function runDuplicateFilesCheck( ImportPlan $importPlan ): void {
278        $duplicateFiles = $this->duplicateFileChecker->findDuplicates(
279            $importPlan->getDetails()->getFileRevisions()->getLatest()
280        );
281
282        if ( $duplicateFiles !== [] ) {
283            throw new DuplicateFilesException( $duplicateFiles );
284        }
285    }
286
287    private function runLocalTitleConflictCheck( ImportPlan $importPlan ): void {
288        if ( $importPlan->getTitle()->exists() ) {
289            throw new RecoverableTitleException(
290                'fileimporter-localtitleexists',
291                $importPlan
292            );
293        }
294    }
295
296    private function runRemoteTitleConflictCheck( ImportPlan $importPlan ): void {
297        $request = $importPlan->getRequest();
298        $details = $importPlan->getDetails();
299        $title = $importPlan->getTitle();
300
301        // Only check remotely if the title has been changed, if it is the same assume this is
302        // okay / intended / other checks have happened.
303        if (
304            $title->getText() !== $details->getSourceLinkTarget()->getText() &&
305            !$this->importTitleChecker->importAllowed( $request->getUrl(), $title->getText() )
306        ) {
307            throw new RecoverableTitleException(
308                'fileimporter-sourcetitleexists',
309                $importPlan
310            );
311        }
312    }
313
314}