Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.86% covered (warning)
85.86%
170 / 198
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
GenerateFancyCaptchas
85.86% covered (warning)
85.86%
170 / 198
33.33% covered (danger)
33.33%
1 / 3
27.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 execute
86.63% covered (warning)
86.63%
149 / 172
0.00% covered (danger)
0.00%
0 / 1
25.38
 generateFancyCaptchas
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Generate fancy captchas using a python script and copy them into storage.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @author Aaron Schulz
22 * @ingroup Maintenance
23 */
24
25namespace MediaWiki\Extension\ConfirmEdit\Maintenance;
26
27// @codeCoverageIgnoreStart
28if ( getenv( 'MW_INSTALL_PATH' ) ) {
29    $IP = getenv( 'MW_INSTALL_PATH' );
30} else {
31    $IP = __DIR__ . '/../../..';
32}
33
34require_once "$IP/maintenance/Maintenance.php";
35// @codeCoverageIgnoreEnd
36
37use FilesystemIterator;
38use MediaWiki\Extension\ConfirmEdit\FancyCaptcha\FancyCaptcha;
39use MediaWiki\Extension\ConfirmEdit\Hooks;
40use MediaWiki\Maintenance\Maintenance;
41use MediaWiki\Shell\Shell;
42use MediaWiki\Status\Status;
43use RecursiveDirectoryIterator;
44use RecursiveIteratorIterator;
45use Shellbox\Command\UnboxedResult;
46use SplFileInfo;
47
48/**
49 * Maintenance script to generate fancy captchas using a python script and copy them into storage.
50 *
51 * @ingroup Maintenance
52 */
53class GenerateFancyCaptchas extends Maintenance {
54    public function __construct() {
55        parent::__construct();
56
57        // See captcha.py for argument usage
58        $this->addOption( "wordlist", 'A list of words', true, true );
59        $this->addOption( "font", "The font to use", true, true );
60        $this->addOption( "font-size", "The font size ", false, true );
61        $this->addOption( "badwordlist", "A list of words that should not be used", false, true );
62        $this->addOption( "fill", "Fill the captcha container to N files", true, true );
63        $this->addOption(
64            "verbose",
65            "Show debugging information when running the captcha python script"
66        );
67        $this->addOption( "delete", "Deletes all the old captchas" );
68        $this->addOption( "threads", "The number of threads to use to generate the images",
69            false, true );
70        $this->addOption(
71            'captchastoragedir',
72            'Overrides the value of $wgCaptchaStorageDirectory',
73            false,
74            true
75        );
76        $this->addDescription( "Generate new fancy captchas and move them into storage" );
77
78        $this->requireExtension( "FancyCaptcha" );
79    }
80
81    public function execute() {
82        global $wgCaptchaSecret, $wgCaptchaDirectoryLevels;
83
84        $totalTime = -microtime( true );
85
86        $instance = Hooks::getInstance();
87        if ( !( $instance instanceof FancyCaptcha ) ) {
88            $this->fatalError( "\$wgCaptchaClass is not FancyCaptcha.\n", 1 );
89        }
90
91        // Overrides $wgCaptchaStorageDirectory for this script run
92        if ( $this->hasOption( 'captchastoragedir' ) ) {
93            global $wgCaptchaStorageDirectory;
94            $wgCaptchaStorageDirectory = $this->getOption( 'captchastoragedir' );
95        }
96
97        $backend = $instance->getBackend();
98
99        $deleteOldCaptchas = $this->getOption( 'delete' );
100
101        $countGen = (int)$this->getOption( 'fill' );
102        if ( !$deleteOldCaptchas ) {
103            $countAct = $instance->getCaptchaCount();
104            $this->output( "Current number of captchas is $countAct.\n" );
105            $countGen -= $countAct;
106        }
107
108        if ( $countGen <= 0 ) {
109            $this->output( "No need to generate any extra captchas.\n" );
110            return;
111        }
112
113        $tmpDir = wfTempDir() . '/mw-fancycaptcha-' . time() . '-' . wfRandomString( 6 );
114        if ( !wfMkdirParents( $tmpDir ) ) {
115            $this->fatalError( "Could not create temp directory.\n", 1 );
116        }
117
118        $cmd = [
119            "python3",
120            dirname( __DIR__ ) . '/captcha.py',
121            "--key",
122            $wgCaptchaSecret,
123            "--output",
124            $tmpDir,
125            "--count",
126            (string)$countGen,
127            "--dirs",
128            $wgCaptchaDirectoryLevels
129        ];
130        foreach (
131            [ 'wordlist', 'font', 'font-size', 'badwordlist', 'verbose', 'threads' ] as $par
132        ) {
133            if ( $this->hasOption( $par ) ) {
134                $cmd[] = "--$par";
135                $cmd[] = $this->getOption( $par );
136            }
137        }
138
139        $this->output( "Generating $countGen new captchas.." );
140        $captchaTime = -microtime( true );
141
142        $result = $this->generateFancyCaptchas( $cmd );
143        if ( $result->getExitCode() !== 0 ) {
144            $this->output( " Failed.\n" );
145            wfRecursiveRemoveDir( $tmpDir );
146
147            $this->fatalError(
148                "An error occurred when running captcha.py:\n{$result->getStderr()}\n",
149                1
150            );
151        }
152
153        $captchaTime += microtime( true );
154        $this->output( " Done.\n" );
155
156        $this->output(
157            sprintf(
158                "\nGeneration script for %d captchas ran in %.1f seconds.\n",
159                $countGen,
160                $captchaTime
161            )
162        );
163
164        $tmpCountTime = -microtime( true );
165        $iter = new RecursiveIteratorIterator(
166            new RecursiveDirectoryIterator(
167                $tmpDir,
168                FilesystemIterator::SKIP_DOTS
169            ),
170            RecursiveIteratorIterator::LEAVES_ONLY
171        );
172
173        $captchasGenerated = iterator_count( $iter );
174        $filesToStore = [];
175        /** @var SplFileInfo $fileInfo */
176        foreach ( $iter as $fileInfo ) {
177            if ( !$fileInfo->isFile() ) {
178                continue;
179            }
180            [ $salt, $hash ] = $instance->hashFromImageName( $fileInfo->getBasename() );
181            $dest = $instance->imagePath( $salt, $hash );
182            $backend->prepare( [ 'dir' => dirname( $dest ) ] );
183            $filesToStore[] = [
184                'op' => 'store',
185                'src' => $fileInfo->getPathname(),
186                'dst' => $dest,
187            ];
188        }
189        $tmpCountTime += microtime( true );
190        $this->output(
191            sprintf(
192                "\nEnumerated %d temporary captchas in %.1f seconds.\n",
193                $captchasGenerated,
194                $tmpCountTime
195            )
196        );
197
198        if ( $captchasGenerated === 0 ) {
199            wfRecursiveRemoveDir( $tmpDir );
200            $this->fatalError( "No generated captchas found in temporary directory; did captcha.py actually succeed?" );
201        } elseif ( $captchasGenerated < $countGen ) {
202            $this->output( "Expecting $countGen new captchas, only $captchasGenerated found on disk; continuing.\n" );
203        }
204
205        $filesToDelete = [];
206        if ( $deleteOldCaptchas ) {
207            $this->output( "Getting a list of old captchas to delete..." );
208            $path = $backend->getRootStoragePath() . '/' . $instance->getStorageDir();
209            foreach ( $backend->getFileList( [ 'dir' => $path ] ) as $file ) {
210
211                // T388531: Avoid invalid response deleting an entire Swift container.
212                // SwiftFileBackend::getFileList sometimes (Swift error? something else?) returns an
213                // array with an empty string. This isn't a real or valid filename, but if we treat it
214                // as one, it would refer to the root of the Swift container recursively delete it...
215                //
216                // TODO: Figure out the root cause and fix it there instead.
217                if ( $file === '' ) {
218                    continue;
219                }
220
221                $filesToDelete[] = [
222                    'op' => 'delete',
223                    'src' => $path . '/' . $file,
224                ];
225            }
226            $this->output( " Done.\n" );
227        }
228
229        $this->output( "Copying the new captchas to storage..." );
230        $storeTime = -microtime( true );
231        $ret = $backend->doQuickOperations( $filesToStore );
232
233        $storeTime += microtime( true );
234
235        $storeSucceeded = true;
236        if ( $ret->isOK() ) {
237            $this->output( " Done.\n" );
238            $this->output(
239                sprintf(
240                    "\nCopied %d captchas to storage in %.1f seconds.\n",
241                    $ret->successCount,
242                    $storeTime
243                )
244            );
245            if ( !$ret->isGood() ) {
246                $this->output(
247                    "Non fatal errors:\n" .
248                    Status::wrap( $ret )->getWikiText( false, false, 'en' ) .
249                    "\n"
250                );
251            }
252            if ( $ret->failCount ) {
253                $storeSucceeded = false;
254                $this->error( sprintf( "\nFailed to copy %d captchas.\n", $ret->failCount ) );
255            }
256            if ( $ret->successCount + $ret->failCount !== $captchasGenerated ) {
257                $storeSucceeded = false;
258                $this->error(
259                    sprintf( "Internal error: captchasGenerated: %d, successCount: %d, failCount: %d\n",
260                        $captchasGenerated, $ret->successCount, $ret->failCount
261                    )
262                );
263            }
264        } else {
265            $storeSucceeded = false;
266            $this->output( "Errored.\n" );
267            $this->error(
268                Status::wrap( $ret )->getWikiText( false, false, 'en' ) .
269                "\n"
270            );
271        }
272
273        if ( $storeSucceeded && $deleteOldCaptchas ) {
274            $numOriginalFiles = count( $filesToDelete );
275            $this->output( "Deleting {$numOriginalFiles} old captchas..." );
276            $deleteTime = -microtime( true );
277            $ret = $backend->doQuickOperations( $filesToDelete );
278
279            $deleteTime += microtime( true );
280            if ( $ret->isOK() ) {
281                $this->output( "Done.\n" );
282                $this->output(
283                    sprintf(
284                        "\nDeleted %d old captchas in %.1f seconds.\n",
285                        $numOriginalFiles,
286                        $deleteTime
287                    )
288                );
289                if ( !$ret->isGood() ) {
290                    $this->output(
291                        "Non fatal errors:\n" .
292                        Status::wrap( $ret )->getWikiText( false, false, 'en' ) .
293                        "\n"
294                    );
295                }
296            } else {
297                $this->output( "Errored.\n" );
298                $this->error(
299                    Status::wrap( $ret )->getWikiText( false, false, 'en' ) .
300                    "\n"
301                );
302            }
303
304        }
305        $this->output( "Removing temporary files..." );
306        wfRecursiveRemoveDir( $tmpDir );
307        $this->output( " Done.\n" );
308
309        $totalTime += microtime( true );
310        $this->output(
311            sprintf(
312                "\nWhole captchas generation process took %.1f seconds.\n",
313                $totalTime
314            )
315        );
316    }
317
318    /**
319     * Generates FancyCaptcha images using the provided command.
320     *
321     * This method is protected so that it can be mocked in tests, because we cannot run python in PHPUnit tests.
322     *
323     * @param array $cmd The command to execute which generates the FancyCaptcha images
324     * @return UnboxedResult
325     */
326    protected function generateFancyCaptchas( array $cmd ): UnboxedResult {
327        return Shell::command( [] )
328            ->params( $cmd )
329            ->limits( [ 'time' => 0, 'memory' => 0, 'walltime' => 0, 'filesize' => 0 ] )
330            ->disableSandbox()
331            ->execute();
332    }
333}
334
335// @codeCoverageIgnoreStart
336$maintClass = GenerateFancyCaptchas::class;
337require_once RUN_MAINTENANCE_IF_MAIN;
338// @codeCoverageIgnoreEnd