Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
107 / 107
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
MediaModerationEmailer
100.00% covered (success)
100.00%
107 / 107
100.00% covered (success)
100.00%
8 / 8
28
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 sendEmailForSha1
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 getEmailSubject
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getFileObjectsGroupedByFileName
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 getEmailBodyHtml
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
7
 getEmailBodyPlaintext
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
7
 getEmailBodyIntroductionText
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getEmailBodyFooterText
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\MediaModeration\Services;
4
5use ArchivedFile;
6use File;
7use Language;
8use LocalFile;
9use MailAddress;
10use MediaWiki\Config\ServiceOptions;
11use MediaWiki\Html\Html;
12use MediaWiki\Mail\IEmailer;
13use MediaWiki\MainConfigNames;
14use MessageLocalizer;
15use Psr\Log\LoggerInterface;
16use StatusValue;
17use Wikimedia\Timestamp\ConvertibleTimestamp;
18
19class MediaModerationEmailer {
20
21    public const CONSTRUCTOR_OPTIONS = [
22        'MediaModerationRecipientList',
23        'MediaModerationFrom',
24        MainConfigNames::CanonicalServer,
25    ];
26
27    private ServiceOptions $options;
28    private IEmailer $emailer;
29    private MediaModerationFileLookup $mediaModerationFileLookup;
30    private MessageLocalizer $messageLocalizer;
31    private Language $language;
32    private LoggerInterface $logger;
33
34    public function __construct(
35        ServiceOptions $options,
36        IEmailer $emailer,
37        MediaModerationFileLookup $mediaModerationFileLookup,
38        MessageLocalizer $messageLocalizer,
39        Language $language,
40        LoggerInterface $logger
41    ) {
42        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
43        $this->options = $options;
44        $this->emailer = $emailer;
45        $this->mediaModerationFileLookup = $mediaModerationFileLookup;
46        $this->messageLocalizer = $messageLocalizer;
47        $this->language = $language;
48        $this->logger = $logger;
49    }
50
51    /**
52     * Send an email to those listed in wgMediaModerationRecipientList about files with the given $sha1.
53     *
54     * Do not call this unless the SHA-1 has been determined to be a positive match by PhotoDNA.
55     *
56     * @param string $sha1 The SHA-1 of files to send an email.
57     * @param ?string $minimumTimestamp Optional. If provided, limits the files that are sent in the email
58     *   to those which are uploaded after this date.
59     * @return StatusValue
60     */
61    public function sendEmailForSha1( string $sha1, ?string $minimumTimestamp = null ) {
62        $to = array_map( static function ( $address ) {
63            return new MailAddress( $address );
64        }, $this->options->get( 'MediaModerationRecipientList' ) );
65
66        $emailerStatus = $this->emailer->send(
67            $to,
68            new MailAddress( $this->options->get( 'MediaModerationFrom' ) ),
69            $this->getEmailSubject( $sha1 ),
70            $this->getEmailBodyPlaintext( $sha1, $minimumTimestamp ),
71            $this->getEmailBodyHtml( $sha1, $minimumTimestamp )
72        );
73        if ( !$emailerStatus->isGood() ) {
74            // Something went wrong and the email did not send properly. Log this as a critical error.
75            $this->logger->critical(
76                'Email indicating SHA-1 match failed to send. SHA-1: {sha1}',
77                [ 'sha1' => $sha1, 'status' => $emailerStatus ]
78            );
79        }
80        return $emailerStatus;
81    }
82
83    /**
84     * Returns the subject line for the email sent by ::sendEmailForSha1.
85     *
86     * The subject line includes the SHA-1 and the current date and time to avoid duplications.
87     * Just using the date and time is not unique enough in the case that resendMatchEmails.php is run,
88     * as multiple emails with the same send time could be sent (as the seconds are not included)
89     *
90     * @param string $sha1
91     * @return string
92     */
93    protected function getEmailSubject( string $sha1 ): string {
94        return $this->messageLocalizer->msg( 'mediamoderation-email-subject' )
95            ->params( $sha1 )
96            ->dateTimeParams( ConvertibleTimestamp::now() )
97            ->escaped();
98    }
99
100    /**
101     * Generates a list of File and ArchivedFile objects grouped by their file name (result of ::getName).
102     *
103     * @param string $sha1
104     * @param ?string $minimumTimestamp If not null, then only include File/ArchivedFile objects for this SHA-1 which
105     *   have a timestamp greater than or equal to this value.
106     * @return LocalFile[][]|ArchivedFile[][]
107     */
108    protected function getFileObjectsGroupedByFileName( string $sha1, ?string $minimumTimestamp ): array {
109        $fileObjectsGroupedByFilename = [];
110        foreach ( $this->mediaModerationFileLookup->getFileObjectsForSha1( $sha1, 50 ) as $file ) {
111            // If we are filtering by timestamp, then remove the File/ArchivedFile if the ::getTimestamp method returns
112            // a truthy value and is less than $minimumTimestamp. Falsey values of ::getTimestamp are handled
113            // elsewhere.
114            if ( $minimumTimestamp !== null && $file->getTimestamp() && $file->getTimestamp() < $minimumTimestamp ) {
115                continue;
116            }
117            // Add the object to the return array grouped by filename.
118            if ( !array_key_exists( $file->getName(), $fileObjectsGroupedByFilename ) ) {
119                $fileObjectsGroupedByFilename[$file->getName()] = [];
120            }
121            $fileObjectsGroupedByFilename[$file->getName()][] = $file;
122        }
123        return $fileObjectsGroupedByFilename;
124    }
125
126    /**
127     * Returns the HTML version of the email sent by ::sendEmailForSha1
128     *
129     * @param string $sha1
130     * @param ?string $minimumTimestamp See ::sendEmailForSha1
131     * @return string HTML
132     */
133    protected function getEmailBodyHtml( string $sha1, ?string $minimumTimestamp ): string {
134        // Keeps a track of whether any File/ArchivedFile objects had ::getTimestamp return false,
135        // and if so what the filename was.
136        $missingTimestamps = [];
137        $returnHtml = '';
138        $fileObjectsCount = 0;
139        foreach ( $this->getFileObjectsGroupedByFileName( $sha1, $minimumTimestamp ) as $fileName => $files ) {
140            // Generate a comma-seperator list of the upload timestamps for the matching file versions, with
141            // the URL to the image as a clickable link if it can be accessed publicly.
142            $uploadTimestampsForFile = [];
143            foreach ( $files as $file ) {
144                $fileTimestamp = $file->getTimestamp();
145                if ( !$fileTimestamp ) {
146                    $missingTimestamps[] = $fileName;
147                    continue;
148                }
149                $fileObjectsCount++;
150                // Convert the timestamp out of the computer readable format into a human readable format.
151                $timestampInReadableFormat = htmlspecialchars(
152                    $this->language->timeanddate( $file->getTimestamp(), false, false )
153                );
154                if ( $file instanceof File && $file->getFullUrl() ) {
155                    // If we have a public URL for the image, then use it as the link target for the
156                    // text of the human readable timestamp.
157                    $uploadTimestampsForFile[] = Html::rawElement(
158                        'a',
159                        [ 'href' => $file->getFullUrl() ],
160                        $timestampInReadableFormat
161                    );
162                } else {
163                    // If no public URL can be found, then just add the timestamp.
164                    $uploadTimestampsForFile[] = $timestampInReadableFormat;
165                }
166            }
167            if ( !count( $uploadTimestampsForFile ) ) {
168                // Don't display an empty list which can occur when all matching versions had no upload timestamp.
169                continue;
170            }
171            // Combine the timestamps into a single line for the email html.
172            $returnHtml .= $this->messageLocalizer->msg( 'mediamoderation-email-body-file-line' )
173                ->params( $fileName )
174                ->rawParams( $this->language->listToText( $uploadTimestampsForFile ) )
175                ->parse() . "\n";
176        }
177        return $this->getEmailBodyIntroductionText( $fileObjectsCount, true ) . $returnHtml .
178            $this->getEmailBodyFooterText( $missingTimestamps );
179    }
180
181    /**
182     * Returns the plaintext version of the email sent by ::sendEmailForSha1
183     *
184     * @param string $sha1
185     * @param ?string $minimumTimestamp See ::sendEmailForSha1
186     * @return string
187     */
188    protected function getEmailBodyPlaintext( string $sha1, ?string $minimumTimestamp ): string {
189        $missingTimestamps = [];
190        $returnText = '';
191        $fileObjectsCount = 0;
192        foreach ( $this->getFileObjectsGroupedByFileName( $sha1, $minimumTimestamp ) as $fileName => $files ) {
193            // Generate a comma seperated list of the upload timestamps for file versions that matched. If there is a
194            // publicly accessible URL to the image available, then add after the timestamp.
195            $uploadTimestampsForFile = [];
196            foreach ( $files as $file ) {
197                $fileTimestamp = $file->getTimestamp();
198                if ( !$fileTimestamp ) {
199                    $missingTimestamps[] = $fileName;
200                    continue;
201                }
202                $fileObjectsCount++;
203                // Convert the timestamp out of the computer readable format into a human readable format.
204                $timestampInReadableFormat = $this->language->timeanddate( $file->getTimestamp(), false, false );
205                if ( $file instanceof File && $file->getFullUrl() ) {
206                    // If a public URL is defined, then add this after the upload timestamp for the file version.
207                    $uploadTimestampsForFile[] = $this->messageLocalizer
208                        ->msg( 'mediamoderation-email-body-file-revision-plaintext-url' )
209                        ->params( $timestampInReadableFormat, $file->getFullUrl() )
210                        ->escaped();
211                } else {
212                    // If no public URL can be found, then just add the timestamp.
213                    $uploadTimestampsForFile[] = $timestampInReadableFormat;
214                }
215            }
216            if ( !count( $uploadTimestampsForFile ) ) {
217                // Don't display an empty list which can occur when all matching versions had no upload timestamp.
218                continue;
219            }
220            // Combine the upload timestamps into a comma seperated list.
221            $returnText .= $this->messageLocalizer->msg(
222                'mediamoderation-email-body-file-line',
223                $fileName,
224                $this->language->listToText( $uploadTimestampsForFile )
225            )->escaped() . "\n";
226        }
227        return $this->getEmailBodyIntroductionText( $fileObjectsCount, false ) . $returnText .
228            $this->getEmailBodyFooterText( $missingTimestamps );
229    }
230
231    /**
232     * Returns text to be added to the start of the email body for both the plaintext and HTML version.
233     *
234     * @param int $fileRevisionsCount The number of File/ArchivedFile objects processed which have ::getTimestamp
235     *   return any other value than false.
236     * @param bool $useHtml If true, then the HTML version of the email introduction is returned.
237     * @return string
238     */
239    protected function getEmailBodyIntroductionText( int $fileRevisionsCount, bool $useHtml ): string {
240        // Add the introduction text to the start of the return text
241        $introductionMessage = $this->messageLocalizer->msg( 'mediamoderation-email-body-intro' )
242            ->numParams( $fileRevisionsCount );
243        if ( $useHtml ) {
244            $introductionMessage->rawParams( Html::element(
245                'a',
246                [ 'href' => $this->options->get( MainConfigNames::CanonicalServer ) ],
247                $this->options->get( MainConfigNames::CanonicalServer )
248            ) );
249        } else {
250            $introductionMessage->params( $this->options->get( MainConfigNames::CanonicalServer ) );
251        }
252        return $introductionMessage->escaped() . "\n";
253    }
254
255    /**
256     * Returns text to be added to the end of the email body for both the plaintext and HTML version.
257     *
258     * @param array $missingTimestamps The filenames which had File objects with ::getTimestamp returning false.
259     * @return string
260     */
261    protected function getEmailBodyFooterText( array $missingTimestamps ): string {
262        if ( count( $missingTimestamps ) ) {
263            // If we have any timestamps that were false, then indicate which filenames these were for at the bottom
264            // of the email.
265            return $this->messageLocalizer
266                ->msg( 'mediamoderation-email-body-files-missing-timestamp' )
267                ->params( $this->language->listToText( array_unique( $missingTimestamps ) ) )
268                ->escaped() . "\n";
269        }
270        return '';
271    }
272}