1441 |
ariadna |
1 |
<?php
|
|
|
2 |
// This file is part of Moodle - http://moodle.org/
|
|
|
3 |
//
|
|
|
4 |
// Moodle is free software: you can redistribute it and/or modify
|
|
|
5 |
// it under the terms of the GNU General Public License as published by
|
|
|
6 |
// the Free Software Foundation, either version 3 of the License, or
|
|
|
7 |
// (at your option) any later version.
|
|
|
8 |
//
|
|
|
9 |
// Moodle is distributed in the hope that it will be useful,
|
|
|
10 |
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
11 |
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
12 |
// GNU General Public License for more details.
|
|
|
13 |
//
|
|
|
14 |
// You should have received a copy of the GNU General Public License
|
|
|
15 |
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
|
|
16 |
|
|
|
17 |
namespace core_files\redactor\services;
|
|
|
18 |
|
|
|
19 |
use admin_setting_configcheckbox;
|
|
|
20 |
use admin_setting_configexecutable;
|
|
|
21 |
use admin_setting_configselect;
|
|
|
22 |
use admin_setting_configtextarea;
|
|
|
23 |
use admin_setting_heading;
|
|
|
24 |
use core\exception\moodle_exception;
|
|
|
25 |
use core\output\html_writer;
|
|
|
26 |
|
|
|
27 |
/**
|
|
|
28 |
* Remove EXIF data from supported image files using PHP GD, or ExifTool if it is configured.
|
|
|
29 |
*
|
|
|
30 |
* The PHP GD stripping has minimal configuration and removes all EXIF data.
|
|
|
31 |
* More stripping is made available when using ExifTool.
|
|
|
32 |
*
|
|
|
33 |
* @package core_files
|
|
|
34 |
* @copyright Meirza <meirza.arson@moodle.com>
|
|
|
35 |
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
|
|
36 |
*/
|
|
|
37 |
class exifremover_service extends service implements file_redactor_service_interface {
|
|
|
38 |
/** @var array REMOVE_TAGS Tags to remove and their corresponding values. */
|
|
|
39 |
const REMOVE_TAGS = [
|
|
|
40 |
"gps" => '"-gps*="',
|
|
|
41 |
"all" => "-all=",
|
|
|
42 |
];
|
|
|
43 |
|
|
|
44 |
/** @var string DEFAULT_REMOVE_TAGS Default tags that will be removed. */
|
|
|
45 |
const DEFAULT_REMOVE_TAGS = "gps";
|
|
|
46 |
|
|
|
47 |
/** @var string DEFAULT_MIMETYPE Default MIME type for images. */
|
|
|
48 |
const DEFAULT_MIMETYPE = "image/jpeg";
|
|
|
49 |
|
|
|
50 |
/**
|
|
|
51 |
* PRESERVE_TAGS Tag to preserve when stripping EXIF data.
|
|
|
52 |
*
|
|
|
53 |
* To add a new tag, add the tag with space as a separator.
|
|
|
54 |
* For example, if the model tag is preserved, then the value is "-Orientation -Model".
|
|
|
55 |
*
|
|
|
56 |
* @var string
|
|
|
57 |
*/
|
|
|
58 |
const PRESERVE_TAGS = "-Orientation";
|
|
|
59 |
|
|
|
60 |
/** @var int DEFAULT_JPEG_COMPRESSION Default JPEG compression quality. */
|
|
|
61 |
const DEFAULT_JPEG_COMPRESSION = 90;
|
|
|
62 |
|
|
|
63 |
/** @var bool $useexiftool Flag indicating whether to use ExifTool. */
|
|
|
64 |
private bool $useexiftool = false;
|
|
|
65 |
|
|
|
66 |
/** @var int Normal orientation (no rotation). */
|
|
|
67 |
private const TOP_LEFT = 1;
|
|
|
68 |
|
|
|
69 |
/** @var int Mirrored horizontally. */
|
|
|
70 |
private const TOP_RIGHT = 2;
|
|
|
71 |
|
|
|
72 |
/** @var int Rotated 180° (upside down). */
|
|
|
73 |
private const BOTTOM_RIGHT = 3;
|
|
|
74 |
|
|
|
75 |
/** @var int Mirrored vertically. */
|
|
|
76 |
private const BOTTOM_LEFT = 4;
|
|
|
77 |
|
|
|
78 |
/** @var int Mirrored horizontally and rotated 270° clockwise. */
|
|
|
79 |
private const LEFT_TOP = 5;
|
|
|
80 |
|
|
|
81 |
/** @var int Rotated 90° clockwise. */
|
|
|
82 |
private const RIGHT_TOP = 6;
|
|
|
83 |
|
|
|
84 |
/** @var int Mirrored horizontally and rotated 90° clockwise. */
|
|
|
85 |
private const RIGHT_BOTTOM = 7;
|
|
|
86 |
|
|
|
87 |
/** @var int Rotated 270° clockwise. */
|
|
|
88 |
private const LEFT_BOTTOM = 8;
|
|
|
89 |
|
|
|
90 |
/**
|
|
|
91 |
* Initialise the EXIF remover service.
|
|
|
92 |
*/
|
|
|
93 |
public function __construct() {
|
|
|
94 |
// To decide whether to use ExifTool or PHP GD, check the ExifTool path.
|
|
|
95 |
if (!empty($this->get_exiftool_path())) {
|
|
|
96 |
$this->useexiftool = true;
|
|
|
97 |
}
|
|
|
98 |
}
|
|
|
99 |
|
|
|
100 |
#[\Override]
|
|
|
101 |
public function redact_file_by_path(
|
|
|
102 |
string $mimetype,
|
|
|
103 |
string $filepath,
|
|
|
104 |
): ?string {
|
|
|
105 |
if (!$this->is_mimetype_supported($mimetype)) {
|
|
|
106 |
return null;
|
|
|
107 |
}
|
|
|
108 |
|
|
|
109 |
if ($this->useexiftool) {
|
|
|
110 |
// Use the ExifTool executable to remove the desired EXIF tags.
|
|
|
111 |
return $this->execute_exiftool($filepath);
|
|
|
112 |
} else {
|
|
|
113 |
// Use PHP GD lib to remove all EXIF tags.
|
|
|
114 |
return $this->execute_gd($filepath);
|
|
|
115 |
}
|
|
|
116 |
}
|
|
|
117 |
|
|
|
118 |
#[\Override]
|
|
|
119 |
public function redact_file_by_content(
|
|
|
120 |
string $mimetype,
|
|
|
121 |
string $filecontent,
|
|
|
122 |
): ?string {
|
|
|
123 |
if (!$this->is_mimetype_supported($mimetype)) {
|
|
|
124 |
return null;
|
|
|
125 |
}
|
|
|
126 |
|
|
|
127 |
if ($this->useexiftool) {
|
|
|
128 |
// Use the ExifTool executable to remove the desired EXIF tags.
|
|
|
129 |
return $this->execute_exiftool_on_content($filecontent);
|
|
|
130 |
} else {
|
|
|
131 |
// Use PHP GD lib to remove all EXIF tags.
|
|
|
132 |
return $this->execute_gd_on_content($filecontent);
|
|
|
133 |
}
|
|
|
134 |
}
|
|
|
135 |
|
|
|
136 |
/**
|
|
|
137 |
* Executes ExifTool to remove metadata from the original file.
|
|
|
138 |
*
|
|
|
139 |
* @param string $sourcefile The file path of the file to redact
|
|
|
140 |
* @return string The destination path of the recreated content
|
|
|
141 |
* @throws moodle_exception If the ExifTool process fails or the destination file is not created.
|
|
|
142 |
*/
|
|
|
143 |
private function execute_exiftool(string $sourcefile): string {
|
|
|
144 |
$destinationfile = make_request_directory() . '/' . basename($sourcefile);
|
|
|
145 |
|
|
|
146 |
// Prepare the ExifTool command.
|
|
|
147 |
$command = $this->get_exiftool_command($sourcefile, $destinationfile);
|
|
|
148 |
|
|
|
149 |
// Run the command.
|
|
|
150 |
exec($command, $output, $resultcode);
|
|
|
151 |
|
|
|
152 |
// If the return code was not zero or the destination file was not successfully created.
|
|
|
153 |
if ($resultcode !== 0 || !file_exists($destinationfile)) {
|
|
|
154 |
throw new moodle_exception(
|
|
|
155 |
errorcode: 'redactor:exifremover:failedprocessexiftool',
|
|
|
156 |
module: 'core_files',
|
|
|
157 |
a: get_class($this),
|
|
|
158 |
debuginfo: implode($output),
|
|
|
159 |
);
|
|
|
160 |
}
|
|
|
161 |
|
|
|
162 |
return $destinationfile;
|
|
|
163 |
}
|
|
|
164 |
|
|
|
165 |
/**
|
|
|
166 |
* Executes ExifTool to remove metadata from the original file content.
|
|
|
167 |
*
|
|
|
168 |
* @param string $filecontent The file content to redact.
|
|
|
169 |
* @return string The redacted updated content
|
|
|
170 |
* @throws moodle_exception If the ExifTool process fails or the destination file is not created.
|
|
|
171 |
*/
|
|
|
172 |
private function execute_exiftool_on_content(string $filecontent): string {
|
|
|
173 |
$sourcefile = make_request_directory() . '/input';
|
|
|
174 |
file_put_contents($sourcefile, $filecontent);
|
|
|
175 |
|
|
|
176 |
$destinationfile = $this->execute_exiftool($sourcefile);
|
|
|
177 |
return file_get_contents($destinationfile);
|
|
|
178 |
}
|
|
|
179 |
|
|
|
180 |
/**
|
|
|
181 |
* Executes GD library to remove metadata from the original file.
|
|
|
182 |
*
|
|
|
183 |
* @param string $sourcefile The source file to redact.
|
|
|
184 |
* @return string The destination path of the recreated content
|
|
|
185 |
* @throws moodle_exception If the image data is not successfully recreated.
|
|
|
186 |
*/
|
|
|
187 |
private function execute_gd(string $sourcefile): string {
|
|
|
188 |
// Read EXIF data from the temporary file.
|
|
|
189 |
$exifdata = @exif_read_data($sourcefile);
|
|
|
190 |
$orientation = isset($exifdata['Orientation']) ? $exifdata['Orientation'] : self::TOP_LEFT;
|
|
|
191 |
|
|
|
192 |
$filecontent = file_get_contents($sourcefile);
|
|
|
193 |
$destinationfile = $this->recreate_image_gd($filecontent, $orientation);
|
|
|
194 |
if (!$destinationfile) {
|
|
|
195 |
throw new moodle_exception(
|
|
|
196 |
errorcode: 'redactor:exifremover:failedprocessgd',
|
|
|
197 |
module: 'core_files',
|
|
|
198 |
a: get_class($this),
|
|
|
199 |
);
|
|
|
200 |
}
|
|
|
201 |
|
|
|
202 |
return $destinationfile;
|
|
|
203 |
}
|
|
|
204 |
|
|
|
205 |
/**
|
|
|
206 |
* Executes GD library to remove metadata from the original file.
|
|
|
207 |
*
|
|
|
208 |
* @param string $filecontent The source file content to redact.
|
|
|
209 |
* @return string The redacted file content
|
|
|
210 |
* @throws moodle_exception If the image data is not successfully recreated.
|
|
|
211 |
*/
|
|
|
212 |
private function execute_gd_on_content(string $filecontent): string {
|
|
|
213 |
$destinationfile = $this->recreate_image_gd($filecontent);
|
|
|
214 |
if (!$destinationfile) {
|
|
|
215 |
throw new moodle_exception(
|
|
|
216 |
errorcode: 'redactor:exifremover:failedprocessgd',
|
|
|
217 |
module: 'core_files',
|
|
|
218 |
a: get_class($this),
|
|
|
219 |
);
|
|
|
220 |
}
|
|
|
221 |
|
|
|
222 |
return file_get_contents($destinationfile);
|
|
|
223 |
}
|
|
|
224 |
|
|
|
225 |
/**
|
|
|
226 |
* Gets the ExifTool command to strip the file of EXIF data.
|
|
|
227 |
*
|
|
|
228 |
* @param string $source The source path of the file.
|
|
|
229 |
* @param string $destination The destination path of the file.
|
|
|
230 |
* @return string The command to use to remove EXIF data from the file.
|
|
|
231 |
*/
|
|
|
232 |
private function get_exiftool_command(string $source, string $destination): string {
|
|
|
233 |
$exiftoolexec = escapeshellarg($this->get_exiftool_path());
|
|
|
234 |
$removetags = $this->get_remove_tags();
|
|
|
235 |
$tempdestination = escapeshellarg($destination);
|
|
|
236 |
$tempsource = escapeshellarg($source);
|
|
|
237 |
$preservetagsoption = "-tagsfromfile @ " . self::PRESERVE_TAGS;
|
|
|
238 |
$command = "$exiftoolexec $removetags $preservetagsoption -o $tempdestination -- $tempsource";
|
|
|
239 |
$command .= " 2> /dev/null"; // Do not output any errors.
|
|
|
240 |
return $command;
|
|
|
241 |
}
|
|
|
242 |
|
|
|
243 |
/**
|
|
|
244 |
* Retrieves the remove tag options based on configuration.
|
|
|
245 |
*
|
|
|
246 |
* @return string The remove tag options.
|
|
|
247 |
*/
|
|
|
248 |
private function get_remove_tags(): string {
|
|
|
249 |
$removetags = get_config('core', 'file_redactor_exifremoverremovetags');
|
|
|
250 |
// If the remove tags value is empty or not empty but does not exist in the array, then set the default.
|
|
|
251 |
if (!$removetags || ($removetags && !array_key_exists($removetags, self::REMOVE_TAGS))) {
|
|
|
252 |
$removetags = self::DEFAULT_REMOVE_TAGS;
|
|
|
253 |
}
|
|
|
254 |
return self::REMOVE_TAGS[$removetags];
|
|
|
255 |
}
|
|
|
256 |
|
|
|
257 |
/**
|
|
|
258 |
* Retrieves the path to the ExifTool executable.
|
|
|
259 |
*
|
|
|
260 |
* @return string The path to the ExifTool executable.
|
|
|
261 |
*/
|
|
|
262 |
private function get_exiftool_path(): string {
|
|
|
263 |
$toolpathconfig = get_config('core', 'file_redactor_exifremovertoolpath');
|
|
|
264 |
if (!empty($toolpathconfig) && is_executable($toolpathconfig)) {
|
|
|
265 |
return $toolpathconfig;
|
|
|
266 |
}
|
|
|
267 |
return '';
|
|
|
268 |
}
|
|
|
269 |
|
|
|
270 |
/**
|
|
|
271 |
* Recreate the image using PHP GD library to strip all EXIF data.
|
|
|
272 |
*
|
|
|
273 |
* @param string $content The source file content.
|
|
|
274 |
* @param int $orientation The orientation value. The default is 1, which means no rotation.
|
|
|
275 |
* @return null|string The path to the recreated image, or null on failure.
|
|
|
276 |
*/
|
|
|
277 |
private function recreate_image_gd(
|
|
|
278 |
string $content,
|
|
|
279 |
int $orientation = self::TOP_LEFT,
|
|
|
280 |
): ?string {
|
|
|
281 |
// Fetch the image information for this image.
|
|
|
282 |
$imageinfo = @getimagesizefromstring($content);
|
|
|
283 |
if (empty($imageinfo)) {
|
|
|
284 |
return null;
|
|
|
285 |
}
|
|
|
286 |
// Create a new image from the file.
|
|
|
287 |
$image = @imagecreatefromstring($content);
|
|
|
288 |
|
|
|
289 |
$this->flip_gd($image, $orientation);
|
|
|
290 |
|
|
|
291 |
$destinationfile = make_request_directory() . '/output';
|
|
|
292 |
|
|
|
293 |
// Capture the image as a string object, rather than straight to file.
|
|
|
294 |
$result = imagejpeg(
|
|
|
295 |
image: $image,
|
|
|
296 |
file: $destinationfile,
|
|
|
297 |
quality: self::DEFAULT_JPEG_COMPRESSION,
|
|
|
298 |
);
|
|
|
299 |
|
|
|
300 |
imagedestroy($image);
|
|
|
301 |
|
|
|
302 |
if ($result) {
|
|
|
303 |
return $destinationfile;
|
|
|
304 |
}
|
|
|
305 |
|
|
|
306 |
return null;
|
|
|
307 |
}
|
|
|
308 |
|
|
|
309 |
/**
|
|
|
310 |
* Flips the given GD image resource based on the specified orientation.
|
|
|
311 |
*
|
|
|
312 |
* @param \GDImage $image The GD image resource to be flipped.
|
|
|
313 |
* @param int $orientation The orientation value indicating how the image should be flipped.
|
|
|
314 |
*
|
|
|
315 |
* @return void
|
|
|
316 |
*/
|
|
|
317 |
private function flip_gd(\GDImage &$image, int $orientation): void {
|
|
|
318 |
switch ($orientation) {
|
|
|
319 |
case self::TOP_LEFT:
|
|
|
320 |
break;
|
|
|
321 |
case self::TOP_RIGHT:
|
|
|
322 |
imageflip($image, IMG_FLIP_HORIZONTAL);
|
|
|
323 |
break;
|
|
|
324 |
case self::BOTTOM_RIGHT:
|
|
|
325 |
$image = imagerotate($image, 180, 0);
|
|
|
326 |
break;
|
|
|
327 |
case self::BOTTOM_LEFT:
|
|
|
328 |
imageflip($image, IMG_FLIP_VERTICAL);
|
|
|
329 |
break;
|
|
|
330 |
case self::LEFT_TOP:
|
|
|
331 |
$image = imagerotate($image, -90, 0);
|
|
|
332 |
imageflip($image, IMG_FLIP_HORIZONTAL);
|
|
|
333 |
break;
|
|
|
334 |
case self::RIGHT_TOP:
|
|
|
335 |
$image = imagerotate($image, -90, 0);
|
|
|
336 |
break;
|
|
|
337 |
case self::RIGHT_BOTTOM:
|
|
|
338 |
$image = imagerotate($image, 90, 0);
|
|
|
339 |
imageflip($image, IMG_FLIP_HORIZONTAL);
|
|
|
340 |
break;
|
|
|
341 |
case self::LEFT_BOTTOM:
|
|
|
342 |
$image = imagerotate($image, 90, 0);
|
|
|
343 |
break;
|
|
|
344 |
}
|
|
|
345 |
}
|
|
|
346 |
|
|
|
347 |
/**
|
|
|
348 |
* Returns true if the service is enabled, and false if it is not.
|
|
|
349 |
*
|
|
|
350 |
* @return bool
|
|
|
351 |
*/
|
|
|
352 |
public function is_enabled(): bool {
|
|
|
353 |
return (bool) get_config('core', 'file_redactor_exifremoverenabled');
|
|
|
354 |
}
|
|
|
355 |
|
|
|
356 |
/**
|
|
|
357 |
* Determines whether a certain mime-type is supported by the service.
|
|
|
358 |
* It will return true if the mime-type is supported, and false if it is not.
|
|
|
359 |
*
|
|
|
360 |
* @param string $mimetype The mime type of file.
|
|
|
361 |
* @return bool
|
|
|
362 |
*/
|
|
|
363 |
public function is_mimetype_supported(string $mimetype): bool {
|
|
|
364 |
if ($mimetype === self::DEFAULT_MIMETYPE) {
|
|
|
365 |
return true;
|
|
|
366 |
}
|
|
|
367 |
|
|
|
368 |
if ($this->useexiftool) {
|
|
|
369 |
// Get the supported MIME types from the config if using ExifTool.
|
|
|
370 |
$supportedmimetypesconfig = get_config('core', 'file_redactor_exifremovermimetype');
|
|
|
371 |
$supportedmimetypes = array_filter(array_map('trim', explode("\n", $supportedmimetypesconfig)));
|
|
|
372 |
return in_array($mimetype, $supportedmimetypes) ?? false;
|
|
|
373 |
}
|
|
|
374 |
|
|
|
375 |
return false;
|
|
|
376 |
}
|
|
|
377 |
|
|
|
378 |
/**
|
|
|
379 |
* Adds settings to the provided admin settings page.
|
|
|
380 |
*
|
|
|
381 |
* @param \admin_settingpage $settings The admin settings page to which settings are added.
|
|
|
382 |
*/
|
|
|
383 |
public static function add_settings(\admin_settingpage $settings): void {
|
|
|
384 |
global $OUTPUT;
|
|
|
385 |
|
|
|
386 |
// Enabled for a fresh install, disabled for an upgrade.
|
|
|
387 |
$defaultenabled = 1;
|
|
|
388 |
|
|
|
389 |
if (empty(get_config('core', 'file_redactor_exifremoverenabled'))) {
|
|
|
390 |
if (PHPUNIT_TEST || !during_initial_install()) {
|
|
|
391 |
$defaultenabled = 0;
|
|
|
392 |
}
|
|
|
393 |
}
|
|
|
394 |
|
|
|
395 |
$icon = $OUTPUT->pix_icon('i/externallink', get_string('opensinnewwindow'));
|
|
|
396 |
$a = (object) [
|
|
|
397 |
'link' => html_writer::link(
|
|
|
398 |
url: 'https://exiftool.sourceforge.net/install.html',
|
|
|
399 |
text: "https://exiftool.sourceforge.net/install.html $icon",
|
|
|
400 |
attributes: ['role' => 'opener', 'rel' => 'noreferrer', 'target' => '_blank'],
|
|
|
401 |
),
|
|
|
402 |
];
|
|
|
403 |
|
|
|
404 |
$settings->add(
|
|
|
405 |
new admin_setting_configcheckbox(
|
|
|
406 |
name: 'file_redactor_exifremoverenabled',
|
|
|
407 |
visiblename: get_string('redactor:exifremover:enabled', 'core_files'),
|
|
|
408 |
description: get_string('redactor:exifremover:enabled_desc', 'core_files', $a),
|
|
|
409 |
defaultsetting: $defaultenabled,
|
|
|
410 |
),
|
|
|
411 |
);
|
|
|
412 |
|
|
|
413 |
$settings->add(
|
|
|
414 |
new admin_setting_heading(
|
|
|
415 |
name: 'exifremoverheading',
|
|
|
416 |
heading: get_string('redactor:exifremover:heading', 'core_files'),
|
|
|
417 |
information: '',
|
|
|
418 |
)
|
|
|
419 |
);
|
|
|
420 |
|
|
|
421 |
$settings->add(
|
|
|
422 |
new admin_setting_configexecutable(
|
|
|
423 |
name: 'file_redactor_exifremovertoolpath',
|
|
|
424 |
visiblename: get_string('redactor:exifremover:toolpath', 'core_files'),
|
|
|
425 |
description: get_string('redactor:exifremover:toolpath_desc', 'core_files'),
|
|
|
426 |
defaultdirectory: '',
|
|
|
427 |
)
|
|
|
428 |
);
|
|
|
429 |
|
|
|
430 |
foreach (array_keys(self::REMOVE_TAGS) as $key) {
|
|
|
431 |
$removedtagchoices[$key] = get_string("redactor:exifremover:tag:$key", 'core_files');
|
|
|
432 |
}
|
|
|
433 |
$settings->add(
|
|
|
434 |
new admin_setting_configselect(
|
|
|
435 |
name: 'file_redactor_exifremoverremovetags',
|
|
|
436 |
visiblename: get_string('redactor:exifremover:removetags', 'core_files'),
|
|
|
437 |
description: get_string('redactor:exifremover:removetags_desc', 'core_files'),
|
|
|
438 |
defaultsetting: self::DEFAULT_REMOVE_TAGS,
|
|
|
439 |
choices: $removedtagchoices,
|
|
|
440 |
),
|
|
|
441 |
);
|
|
|
442 |
|
|
|
443 |
$mimetypedefault = <<<EOF
|
|
|
444 |
image/jpeg
|
|
|
445 |
image/tiff
|
|
|
446 |
EOF;
|
|
|
447 |
$settings->add(
|
|
|
448 |
new admin_setting_configtextarea(
|
|
|
449 |
name: 'file_redactor_exifremovermimetype',
|
|
|
450 |
visiblename: get_string('redactor:exifremover:mimetype', 'core_files'),
|
|
|
451 |
description: get_string('redactor:exifremover:mimetype_desc', 'core_files'),
|
|
|
452 |
defaultsetting: $mimetypedefault,
|
|
|
453 |
),
|
|
|
454 |
);
|
|
|
455 |
}
|
|
|
456 |
}
|