Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.26% covered (success)
93.26%
83 / 89
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
ResendMatchEmails
100.00% covered (success)
100.00%
83 / 83
100.00% covered (success)
100.00%
6 / 6
18
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 outputInformationBasedOnStatus
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getSelectQueryBuilder
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 initServices
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 parseTimestamps
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3namespace MediaWiki\Extension\MediaModeration\Maintenance;
4
5use IDBAccessObject;
6use Maintenance;
7use MediaWiki\Extension\MediaModeration\Services\MediaModerationDatabaseLookup;
8use MediaWiki\Extension\MediaModeration\Services\MediaModerationEmailer;
9use MediaWiki\Status\StatusFormatter;
10use RequestContext;
11use StatusValue;
12use Wikimedia\Rdbms\SelectQueryBuilder;
13use Wikimedia\Timestamp\ConvertibleTimestamp;
14
15$IP = getenv( 'MW_INSTALL_PATH' );
16if ( $IP === false ) {
17    $IP = __DIR__ . '/../../..';
18}
19require_once "$IP/maintenance/Maintenance.php";
20
21/**
22 * Re-sends emails for SHA-1 values determined to be a match for files from a given timestamp.
23 * Designed for use when emailing was broken and the emails need to be re-sent from the timestamp
24 * when emailing stopped working.
25 */
26class ResendMatchEmails extends Maintenance {
27
28    private MediaModerationEmailer $mediaModerationEmailer;
29    private MediaModerationDatabaseLookup $mediaModerationDatabaseLookup;
30    private StatusFormatter $statusFormatter;
31
32    private string $scannedSince;
33    private ?string $uploadedSince;
34
35    public function __construct() {
36        parent::__construct();
37
38        $this->requireExtension( 'MediaModeration' );
39        $this->addDescription(
40            'Maintenance script to re-send emails for SHA-1 values marked as a match in the ' .
41            'mediamoderation_scan table.'
42        );
43
44        $this->addArg(
45            'scanned-since',
46            'Only re-send emails for SHA-1 values that have been scanned since a given date.',
47        );
48        $this->addOption(
49            'uploaded-since',
50            'Only include files in the email that were uploaded to the wiki after this timestamp. Default' .
51            'is to not filter by upload timestamp.',
52            false,
53            true
54        );
55        $this->addOption(
56            'sleep',
57            'How long to sleep (in seconds) after sending an email. Default: 1',
58            false,
59            true
60        );
61        $this->addOption(
62            'verbose',
63            'Enables verbose mode which prints out information about the emails being sent.'
64        );
65    }
66
67    public function execute() {
68        $this->initServices();
69        $this->parseTimestamps();
70
71        $previousBatchLastSha1Value = '';
72        do {
73            $batch = $this->getSelectQueryBuilder( $previousBatchLastSha1Value )
74                ->caller( __METHOD__ )
75                ->fetchFieldValues();
76            foreach ( $batch as $sha1 ) {
77                // Send an email for this batch.
78                $emailerStatus = $this->mediaModerationEmailer->sendEmailForSha1( $sha1, $this->uploadedSince );
79                $this->outputInformationBasedOnStatus( $sha1, $emailerStatus );
80                // Wait for --sleep seconds to avoid spamming the email address getting these reports.
81                sleep( intval( $this->getOption( 'sleep', 1 ) ) );
82                // Update $previousBatchLastSha1Value to the current $sha1. Once this loop
83                // completes, the value will be the last SHA-1 in this batch.
84                $previousBatchLastSha1Value = $sha1;
85            }
86        } while ( count( $batch ) );
87    }
88
89    /**
90     * Outputs verbose information or errors based on the provided StatusValue.
91     *
92     * @param string $sha1
93     * @param StatusValue $emailerStatus
94     * @return void
95     */
96    protected function outputInformationBasedOnStatus( string $sha1, StatusValue $emailerStatus ) {
97        if ( $emailerStatus->isGood() ) {
98            if ( $this->hasOption( 'verbose' ) ) {
99                $this->output( "Sent email for SHA-1 $sha1.\n" );
100            }
101        } else {
102            $this->error( "Email for SHA-1 $sha1 failed to send.\n" );
103            if ( count( $emailerStatus->getErrors() ) === 1 ) {
104                $this->error( '* ' . $this->statusFormatter->getWikiText( $emailerStatus ) . "\n" );
105            } elseif ( count( $emailerStatus->getErrors() ) > 1 ) {
106                $this->error( $this->statusFormatter->getWikiText( $emailerStatus ) );
107            }
108        }
109    }
110
111    /**
112     * Gets a SelectQueryBuilder that can be used to produce a batch of SHA-1 values to be passed to
113     * MediaModerationEmailer::sendEmailForSha1.
114     *
115     * @param string $previousBatchLastSha1Value The last SHA-1 value from the previous batch, or an empty string
116     *   if processing the first batch.
117     * @return SelectQueryBuilder
118     */
119    protected function getSelectQueryBuilder( string $previousBatchLastSha1Value ): SelectQueryBuilder {
120        // Get a replica DB connection.
121        $dbr = $this->mediaModerationDatabaseLookup->getDb( IDBAccessObject::READ_NORMAL );
122        $selectQueryBuilder = $dbr->newSelectQueryBuilder()
123            ->select( 'mms_sha1' )
124            ->from( 'mediamoderation_scan' )
125            ->where( [
126                $dbr->expr( 'mms_last_checked', '>=', (int)$this->scannedSince ),
127                'mms_is_match' => (int)MediaModerationDatabaseLookup::POSITIVE_MATCH_STATUS,
128            ] )
129            ->orderBy( 'mms_sha1', SelectQueryBuilder::SORT_ASC )
130            ->limit( 200 );
131        if ( $previousBatchLastSha1Value ) {
132            $selectQueryBuilder->andWhere( [
133                $dbr->expr( 'mms_sha1', '>', $previousBatchLastSha1Value )
134            ] );
135        }
136        return $selectQueryBuilder;
137    }
138
139    protected function initServices(): void {
140        $services = $this->getServiceContainer();
141        $this->mediaModerationDatabaseLookup = $services->get( 'MediaModerationDatabaseLookup' );
142        $this->mediaModerationEmailer = $services->get( 'MediaModerationEmailer' );
143        $this->statusFormatter = $services->getFormatterFactory()->getStatusFormatter( RequestContext::getMain() );
144    }
145
146    /**
147     * Parse the 'last-checked' timestamp provided via the command line,
148     * and cause a fatal error if it cannot be parsed.
149     *
150     * @return void
151     */
152    protected function parseTimestamps(): void {
153        $scannedSince = $this->getArg( 'scanned-since' );
154        if ( !is_string( $scannedSince ) ) {
155            $this->fatalError( 'The scanned-since argument must be a string.' );
156        }
157        if ( strlen( $scannedSince ) === 8 && $scannedSince === strval( intval( $scannedSince ) ) ) {
158            // The 'scanned-since' argument is likely to be in the form YYYYMMDD because:
159            // * The length of the argument is 8 (which is the length of a YYYYMMDD format)
160            // * The intval of the 'scanned-since' parameter can be converted to an integer
161            //    and from a string without any changes in value (thus it must be an integer
162            //    in string form).
163            $this->scannedSince = $scannedSince;
164        } elseif ( ConvertibleTimestamp::convert( TS_MW, $scannedSince ) ) {
165            // If the 'scanned-since' argument is recognised as a timestamp by ConvertibleTimestamp::convert,
166            // then get the date part and discard the time part.
167            $this->scannedSince = $this->mediaModerationDatabaseLookup->getDateFromTimestamp( $scannedSince );
168        } else {
169            // The 'scanned-since' argument could not be parsed, so raise an error
170            $this->fatalError(
171                'The scanned-since argument passed to this script could not be parsed. This can take a ' .
172                'timestamp in string form, or a date in YYYYMMDD format.'
173            );
174        }
175        // Uploaded since takes any supported timestamp by ConvertibleTimestamp
176        $uploadedSince = $this->getOption( 'uploaded-since' );
177        if ( $uploadedSince !== null ) {
178            $uploadedSince = ConvertibleTimestamp::convert( TS_MW, $uploadedSince );
179            if ( !$uploadedSince ) {
180                $this->fatalError( 'The uploaded-since timestamp could not be parsed as a valid timestamp' );
181            }
182        }
183        $this->uploadedSince = $uploadedSince;
184    }
185}
186
187$maintClass = ResendMatchEmails::class;
188require_once RUN_MAINTENANCE_IF_MAIN;