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