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 = <<<EOF
image/jpeg
image/tiff
EOF;
$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,
),
);
}
}