Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
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
}