Autoría | Ultima modificación | Ver Log |
<?php// This file is part of Moodle - http://moodle.org///// Moodle is free software: you can redistribute it and/or modify// it under the terms of the GNU General Public License as published by// the Free Software Foundation, either version 3 of the License, or// (at your option) any later version.//// Moodle is distributed in the hope that it will be useful,// but WITHOUT ANY WARRANTY; without even the implied warranty of// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the// GNU General Public License for more details.//// You should have received a copy of the GNU General Public License// along with Moodle. If not, see <http://www.gnu.org/licenses/>.namespace core_files\redactor\services;use admin_setting_configcheckbox;use admin_setting_configexecutable;use admin_setting_configselect;use admin_setting_configtextarea;use admin_setting_heading;use core\exception\moodle_exception;use core\output\html_writer;/*** Remove EXIF data from supported image files using PHP GD, or ExifTool if it is configured.** The PHP GD stripping has minimal configuration and removes all EXIF data.* More stripping is made available when using ExifTool.** @package core_files* @copyright Meirza <meirza.arson@moodle.com>* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/class exifremover_service extends service implements file_redactor_service_interface {/** @var array REMOVE_TAGS Tags to remove and their corresponding values. */const REMOVE_TAGS = ["gps" => '"-gps*="',"all" => "-all=",];/** @var string DEFAULT_REMOVE_TAGS Default tags that will be removed. */const DEFAULT_REMOVE_TAGS = "gps";/** @var string DEFAULT_MIMETYPE Default MIME type for images. */const DEFAULT_MIMETYPE = "image/jpeg";/*** PRESERVE_TAGS Tag to preserve when stripping EXIF data.** To add a new tag, add the tag with space as a separator.* For example, if the model tag is preserved, then the value is "-Orientation -Model".** @var string*/const PRESERVE_TAGS = "-Orientation";/** @var int DEFAULT_JPEG_COMPRESSION Default JPEG compression quality. */const DEFAULT_JPEG_COMPRESSION = 90;/** @var bool $useexiftool Flag indicating whether to use ExifTool. */private bool $useexiftool = false;/** @var int Normal orientation (no rotation). */private const TOP_LEFT = 1;/** @var int Mirrored horizontally. */private const TOP_RIGHT = 2;/** @var int Rotated 180° (upside down). */private const BOTTOM_RIGHT = 3;/** @var int Mirrored vertically. */private const BOTTOM_LEFT = 4;/** @var int Mirrored horizontally and rotated 270° clockwise. */private const LEFT_TOP = 5;/** @var int Rotated 90° clockwise. */private const RIGHT_TOP = 6;/** @var int Mirrored horizontally and rotated 90° clockwise. */private const RIGHT_BOTTOM = 7;/** @var int Rotated 270° clockwise. */private const LEFT_BOTTOM = 8;/*** Initialise the EXIF remover service.*/public function __construct() {// To decide whether to use ExifTool or PHP GD, check the ExifTool path.if (!empty($this->get_exiftool_path())) {$this->useexiftool = true;}}#[\Override]public function redact_file_by_path(string $mimetype,string $filepath,): ?string {if (!$this->is_mimetype_supported($mimetype)) {return null;}if ($this->useexiftool) {// Use the ExifTool executable to remove the desired EXIF tags.return $this->execute_exiftool($filepath);} else {// Use PHP GD lib to remove all EXIF tags.return $this->execute_gd($filepath);}}#[\Override]public function redact_file_by_content(string $mimetype,string $filecontent,): ?string {if (!$this->is_mimetype_supported($mimetype)) {return null;}if ($this->useexiftool) {// Use the ExifTool executable to remove the desired EXIF tags.return $this->execute_exiftool_on_content($filecontent);} else {// Use PHP GD lib to remove all EXIF tags.return $this->execute_gd_on_content($filecontent);}}/*** Executes ExifTool to remove metadata from the original file.** @param string $sourcefile The file path of the file to redact* @return string The destination path of the recreated content* @throws moodle_exception If the ExifTool process fails or the destination file is not created.*/private function execute_exiftool(string $sourcefile): string {$destinationfile = make_request_directory() . '/' . basename($sourcefile);// Prepare the ExifTool command.$command = $this->get_exiftool_command($sourcefile, $destinationfile);// Run the command.exec($command, $output, $resultcode);// If the return code was not zero or the destination file was not successfully created.if ($resultcode !== 0 || !file_exists($destinationfile)) {throw new moodle_exception(errorcode: 'redactor:exifremover:failedprocessexiftool',module: 'core_files',a: get_class($this),debuginfo: implode($output),);}return $destinationfile;}/*** Executes ExifTool to remove metadata from the original file content.** @param string $filecontent The file content to redact.* @return string The redacted updated content* @throws moodle_exception If the ExifTool process fails or the destination file is not created.*/private function execute_exiftool_on_content(string $filecontent): string {$sourcefile = make_request_directory() . '/input';file_put_contents($sourcefile, $filecontent);$destinationfile = $this->execute_exiftool($sourcefile);return file_get_contents($destinationfile);}/*** Executes GD library to remove metadata from the original file.** @param string $sourcefile The source file to redact.* @return string The destination path of the recreated content* @throws moodle_exception If the image data is not successfully recreated.*/private function execute_gd(string $sourcefile): string {// Read EXIF data from the temporary file.$exifdata = @exif_read_data($sourcefile);$orientation = isset($exifdata['Orientation']) ? $exifdata['Orientation'] : self::TOP_LEFT;$filecontent = file_get_contents($sourcefile);$destinationfile = $this->recreate_image_gd($filecontent, $orientation);if (!$destinationfile) {throw new moodle_exception(errorcode: 'redactor:exifremover:failedprocessgd',module: 'core_files',a: get_class($this),);}return $destinationfile;}/*** Executes GD library to remove metadata from the original file.** @param string $filecontent The source file content to redact.* @return string The redacted file content* @throws moodle_exception If the image data is not successfully recreated.*/private function execute_gd_on_content(string $filecontent): string {$destinationfile = $this->recreate_image_gd($filecontent);if (!$destinationfile) {throw new moodle_exception(errorcode: 'redactor:exifremover:failedprocessgd',module: 'core_files',a: get_class($this),);}return file_get_contents($destinationfile);}/*** Gets the ExifTool command to strip the file of EXIF data.** @param string $source The source path of the file.* @param string $destination The destination path of the file.* @return string The command to use to remove EXIF data from the file.*/private function get_exiftool_command(string $source, string $destination): string {$exiftoolexec = escapeshellarg($this->get_exiftool_path());$removetags = $this->get_remove_tags();$tempdestination = escapeshellarg($destination);$tempsource = escapeshellarg($source);$preservetagsoption = "-tagsfromfile @ " . self::PRESERVE_TAGS;$command = "$exiftoolexec $removetags $preservetagsoption -o $tempdestination -- $tempsource";$command .= " 2> /dev/null"; // Do not output any errors.return $command;}/*** Retrieves the remove tag options based on configuration.** @return string The remove tag options.*/private function get_remove_tags(): string {$removetags = get_config('core', 'file_redactor_exifremoverremovetags');// If the remove tags value is empty or not empty but does not exist in the array, then set the default.if (!$removetags || ($removetags && !array_key_exists($removetags, self::REMOVE_TAGS))) {$removetags = self::DEFAULT_REMOVE_TAGS;}return self::REMOVE_TAGS[$removetags];}/*** Retrieves the path to the ExifTool executable.** @return string The path to the ExifTool executable.*/private function get_exiftool_path(): string {$toolpathconfig = get_config('core', 'file_redactor_exifremovertoolpath');if (!empty($toolpathconfig) && is_executable($toolpathconfig)) {return $toolpathconfig;}return '';}/*** Recreate the image using PHP GD library to strip all EXIF data.** @param string $content The source file content.* @param int $orientation The orientation value. The default is 1, which means no rotation.* @return null|string The path to the recreated image, or null on failure.*/private function recreate_image_gd(string $content,int $orientation = self::TOP_LEFT,): ?string {// Fetch the image information for this image.$imageinfo = @getimagesizefromstring($content);if (empty($imageinfo)) {return null;}// Create a new image from the file.$image = @imagecreatefromstring($content);$this->flip_gd($image, $orientation);$destinationfile = make_request_directory() . '/output';// Capture the image as a string object, rather than straight to file.$result = imagejpeg(image: $image,file: $destinationfile,quality: self::DEFAULT_JPEG_COMPRESSION,);imagedestroy($image);if ($result) {return $destinationfile;}return null;}/*** Flips the given GD image resource based on the specified orientation.** @param \GDImage $image The GD image resource to be flipped.* @param int $orientation The orientation value indicating how the image should be flipped.** @return void*/private function flip_gd(\GDImage &$image, int $orientation): void {switch ($orientation) {case self::TOP_LEFT:break;case self::TOP_RIGHT:imageflip($image, IMG_FLIP_HORIZONTAL);break;case self::BOTTOM_RIGHT:$image = imagerotate($image, 180, 0);break;case self::BOTTOM_LEFT:imageflip($image, IMG_FLIP_VERTICAL);break;case self::LEFT_TOP:$image = imagerotate($image, -90, 0);imageflip($image, IMG_FLIP_HORIZONTAL);break;case self::RIGHT_TOP:$image = imagerotate($image, -90, 0);break;case self::RIGHT_BOTTOM:$image = imagerotate($image, 90, 0);imageflip($image, IMG_FLIP_HORIZONTAL);break;case self::LEFT_BOTTOM:$image = imagerotate($image, 90, 0);break;}}/*** Returns true if the service is enabled, and false if it is not.** @return bool*/public function is_enabled(): bool {return (bool) get_config('core', 'file_redactor_exifremoverenabled');}/*** Determines whether a certain mime-type is supported by the service.* It will return true if the mime-type is supported, and false if it is not.** @param string $mimetype The mime type of file.* @return bool*/public function is_mimetype_supported(string $mimetype): bool {if ($mimetype === self::DEFAULT_MIMETYPE) {return true;}if ($this->useexiftool) {// Get the supported MIME types from the config if using ExifTool.$supportedmimetypesconfig = get_config('core', 'file_redactor_exifremovermimetype');$supportedmimetypes = array_filter(array_map('trim', explode("\n", $supportedmimetypesconfig)));return in_array($mimetype, $supportedmimetypes) ?? false;}return false;}/*** Adds settings to the provided admin settings page.** @param \admin_settingpage $settings The admin settings page to which settings are added.*/public static function add_settings(\admin_settingpage $settings): void {global $OUTPUT;// Enabled for a fresh install, disabled for an upgrade.$defaultenabled = 1;if (empty(get_config('core', 'file_redactor_exifremoverenabled'))) {if (PHPUNIT_TEST || !during_initial_install()) {$defaultenabled = 0;}}$icon = $OUTPUT->pix_icon('i/externallink', get_string('opensinnewwindow'));$a = (object) ['link' => html_writer::link(url: 'https://exiftool.sourceforge.net/install.html',text: "https://exiftool.sourceforge.net/install.html $icon",attributes: ['role' => 'opener', 'rel' => 'noreferrer', 'target' => '_blank'],),];$settings->add(new admin_setting_configcheckbox(name: 'file_redactor_exifremoverenabled',visiblename: get_string('redactor:exifremover:enabled', 'core_files'),description: get_string('redactor:exifremover:enabled_desc', 'core_files', $a),defaultsetting: $defaultenabled,),);$settings->add(new admin_setting_heading(name: 'exifremoverheading',heading: get_string('redactor:exifremover:heading', 'core_files'),information: '',));$settings->add(new admin_setting_configexecutable(name: 'file_redactor_exifremovertoolpath',visiblename: get_string('redactor:exifremover:toolpath', 'core_files'),description: get_string('redactor:exifremover:toolpath_desc', 'core_files'),defaultdirectory: '',));foreach (array_keys(self::REMOVE_TAGS) as $key) {$removedtagchoices[$key] = get_string("redactor:exifremover:tag:$key", 'core_files');}$settings->add(new admin_setting_configselect(name: 'file_redactor_exifremoverremovetags',visiblename: get_string('redactor:exifremover:removetags', 'core_files'),description: get_string('redactor:exifremover:removetags_desc', 'core_files'),defaultsetting: self::DEFAULT_REMOVE_TAGS,choices: $removedtagchoices,),);$mimetypedefault = <<<EOFimage/jpegimage/tiffEOF;$settings->add(new admin_setting_configtextarea(name: 'file_redactor_exifremovermimetype',visiblename: get_string('redactor:exifremover:mimetype', 'core_files'),description: get_string('redactor:exifremover:mimetype_desc', 'core_files'),defaultsetting: $mimetypedefault,),);}}