Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
107 / 107 |
|
100.00% |
8 / 8 |
CRAP | |
100.00% |
1 / 1 |
MediaModerationEmailer | |
100.00% |
107 / 107 |
|
100.00% |
8 / 8 |
28 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
sendEmailForSha1 | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
2 | |||
getEmailSubject | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getFileObjectsGroupedByFileName | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
6 | |||
getEmailBodyHtml | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
7 | |||
getEmailBodyPlaintext | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
7 | |||
getEmailBodyIntroductionText | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
getEmailBodyFooterText | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\MediaModeration\Services; |
4 | |
5 | use ArchivedFile; |
6 | use File; |
7 | use Language; |
8 | use LocalFile; |
9 | use MailAddress; |
10 | use MediaWiki\Config\ServiceOptions; |
11 | use MediaWiki\Html\Html; |
12 | use MediaWiki\Mail\IEmailer; |
13 | use MediaWiki\MainConfigNames; |
14 | use MessageLocalizer; |
15 | use Psr\Log\LoggerInterface; |
16 | use StatusValue; |
17 | use Wikimedia\Timestamp\ConvertibleTimestamp; |
18 | |
19 | class 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 | } |