MDL-83245 core_files: Update file redaction API

This change:
- Moves the API to use the `before_file_created` hook
- Remove the newly created `$notify` param for `after_file_created` hook
- Stop persisting redactable content
- Update manager to not deal with `stored_file` instances
- Correct namespace from `\core\filereact` to `\core_files\redactor`
- Add `redactor` as a valid L2 namespace within the `core_files` API
- Correct config setting names
- Adds missing unit tests
- Disables the service for PHPUnit tests

AMOS BEGIN
  MOV [fileredact,core_files],[redactor,core_files]
  MOV [fileredact,core_files],[redactor:exifremover,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:emptyremovetags,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:enabled,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:enabled_desc,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:failedprocessexiftool,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:failedprocessgd,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:heading,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:mimetype,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:mimetype_desc,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:removetags,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:removetags_desc,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:tag:all,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:tag:gps,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:tooldoesnotexist,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:toolpath,core_files]
  MOV [fileredact,core_files],[redactor:exifremover:toolpath_desc,core_files]
AMOS END
This commit is contained in:
Andrew Nicols 2024-09-24 14:43:24 +08:00
parent 525fc81cf6
commit b9a5ed7737
No known key found for this signature in database
GPG Key ID: 6D1E3157C8CFBF14
26 changed files with 1310 additions and 765 deletions

View File

@ -122,3 +122,6 @@ jobs:
dbtype: ${{ matrix.db }}
phpunit_options: ${{ secrets.phpunit_options }}
run: vendor/bin/phpunit $phpunit_options ${{ inputs.phpunit_extra_options }}
- name: Git status
run: git status

View File

@ -2,12 +2,5 @@ issueNumber: MDL-75850
notes:
core_files:
- message: |
The following are the changes made:
- New hook after_file_created
- In the \core_files\file_storage, new additional param $notify (default is true) added to:
- ::create_file_from_storedfile()
- ::create_file_from_pathname()
- ::create_file_from_string()
- ::create_file()
If true, it will trigger the after_file_created hook to re-create the image.
A new hook, `\core_files\hook\after_file_created`, has been created to allow the inspection of files after they have been saved in the filesystem.
type: improved

View File

@ -0,0 +1,8 @@
issueNumber: MDL-83245
notes:
core_files:
- message: >-
A new hook, `\core_files\hook\before_file_created`, has been created to
allow modification of a file immediately before it is stored in the file
system.
type: improved

View File

@ -15,7 +15,7 @@
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Configure the settings for fileredact.
* Configure the settings for file redaction service.
*
* @package core_admin
* @copyright Meirza <meirza.arson@moodle.com>
@ -25,19 +25,20 @@
defined('MOODLE_INTERNAL') || die();
if ($hassiteconfig) {
if (!$ADMIN->locate('fileredact')) {
$ADMIN->add('server', new admin_category('fileredact', get_string('fileredact', 'core_files')));
if (!$ADMIN->locate('file_redactor')) {
$ADMIN->add('server', new admin_category('file_redactor', get_string('redactor', 'core_files')));
}
$manager = \core\di::get(\core_files\redactor\manager::class);
// Get settings from each service.
$servicesdir = "{$CFG->libdir}/classes/fileredact/services/";
$servicefiles = glob("{$servicesdir}*_service.php");
foreach ($servicefiles as $servicefile) {
$servicename = basename($servicefile, '_service.php');
$classname = "\\core\\fileredact\\services\\{$servicename}_service";
if (class_exists($classname)) {
$fileredactsettings = new admin_settingpage($servicename, new lang_string("fileredact:$servicename", 'core_files'));
call_user_func("{$classname}::add_settings", $fileredactsettings);
$ADMIN->add('fileredact', $fileredactsettings);
}
foreach ($manager->get_service_classnames() as $servicename => $service) {
$servicesettings = new admin_settingpage(
$servicename,
new lang_string("redactor:{$servicename}", 'core_files'),
);
$service::add_settings($servicesettings);
$ADMIN->add('file_redactor', $servicesettings);
}
}

View File

@ -14,19 +14,19 @@
// 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\hook\filestorage;
namespace core_files\hook;
use core\attribute;
use core\hook\stoppable_trait;
/**
* Class after_file_created
* A hook which is fired after a file is created in the file storage API.
*
* @package core
* @copyright 2024 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[attribute\label('Allows subscribers to modify file after it is created')]
#[attribute\label('Allows subscribers to inspect a file after it is created in the file pool')]
#[attribute\tags('file')]
#[attribute\hook\replaces_callbacks('after_file_created')]
final class after_file_created {

View File

@ -0,0 +1,160 @@
<?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\hook;
use core\exception\coding_exception;
use core\attribute;
/**
* A hook which is fired before a file is created in the file storage API.
*
* @package core_files
* @copyright 2024 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[attribute\label('Allows subscribers to modify file content before it is stored in the file pool')]
#[attribute\tags('file')]
#[attribute\hook\replaces_callbacks('before_file_created')]
final class before_file_created {
use \core\hook\stoppable_trait;
/** @var bool Whether the content has been updated at all */
public bool $contentupdated = false;
/**
* Constructor.
*
* @param \stdClass|null $filerecord
* @param string|null $filepath The path to the file on disk
* @param string|null $filecontent The content of the file
*/
public function __construct(
/** @var \stdClass The file record */
protected ?\stdClass $filerecord = null,
/** @var string|null $filepath The source file on disk */
protected ?string $filepath = null,
/** @var string|null $filecontent The content of the file if it is not stored on disk */
protected ?string $filecontent = null,
) {
if ($filepath === null && $filecontent === null) {
throw new \InvalidArgumentException('Either $filepath or $filecontent must be set');
}
if ($filepath !== null && $filecontent !== null) {
throw new \InvalidArgumentException('Only one of $filepath or $filecontent can be set');
}
}
/**
* Whether the file path was specified.
*
* @return bool
*/
public function has_filepath(): bool {
return $this->filepath !== null;
}
/**
* Whether the file content was specified.
*
* @return bool
*/
public function has_filecontent(): bool {
return $this->filecontent !== null;
}
/**
* Get the file path to the file that will be stored.
*
* @return string
*/
public function get_filepath(): ?string {
return $this->filepath;
}
/**
* Get the file content that will be stored.
*
* @return string
*/
public function get_filecontent(): ?string {
return $this->filecontent;
}
/**
* Get the file record.
*
* @return \stdClass|null
*/
public function get_filerecord(): ?\stdClass {
return $this->filerecord;
}
/**
* Update the file path to a new value.
*
* @param string $filepath
*/
public function update_filepath(string $filepath): void {
if ($this->filepath === null) {
throw new coding_exception('Cannot update file path when the file path is not set');
}
if ($filepath !== $this->filepath) {
$this->contentupdated = true;
$this->filepath = $filepath;
}
}
/**
* Update the file content to a new value.
*
* @param string $filecontent
*/
public function update_filecontent(string $filecontent): void {
if ($this->filecontent === null) {
throw new coding_exception('Cannot update file content when the file content is not set');
}
if ($filecontent !== $this->filecontent) {
$this->contentupdated = true;
$this->filecontent = $filecontent;
}
}
/**
* Whether the file path or file content has been changed.
*
* @return bool
*/
public function has_changed(): bool {
return $this->contentupdated;
}
/**
* Process legacy callbacks.
*/
public function process_legacy_callbacks(): void {
if ($pluginsfunction = get_plugins_with_function(function: 'before_file_created', migratedtohook: true)) {
foreach ($pluginsfunction as $plugintype => $plugins) {
foreach ($plugins as $pluginfunction) {
$pluginfunction($this->filerecord);
}
}
}
}
}

View File

@ -14,9 +14,9 @@
// 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\fileredact;
namespace core_files\redactor;
use core\hook\filestorage\after_file_created;
use core_files\hook\before_file_created;
/**
* Allow the plugin to call as soon as possible before the file is created.
@ -27,20 +27,37 @@ use core\hook\filestorage\after_file_created;
*/
class hook_listener {
/**
* Execute the available services after creating the file.
* Execute the before_file_created hook listener for file redaction.
*
* @param after_file_created $hook
* @param before_file_created $hook
*/
public static function redact_after_file_created(after_file_created $hook): void {
$storedfile = $hook->storedfile;
public static function file_redaction_handler(before_file_created $hook): void {
// The file mime-type must be present. Otherwise, bypass the process.
if (empty($storedfile->get_mimetype())) {
if (empty($hook->get_filerecord()) || empty($hook->get_filerecord()->mimetype)) {
return;
}
$manager = new manager($storedfile);
$manager->execute();
$manager = \core\di::get(manager::class);
if ($hook->has_filepath()) {
$file = $manager->redact_file(
$hook->get_filerecord()->mimetype,
$hook->get_filepath(),
);
if ($file !== null) {
$hook->update_filepath($file);
}
} else {
$data = $manager->redact_file_content(
$hook->get_filerecord()->mimetype,
$hook->get_filecontent(),
);
if ($data !== null) {
$hook->update_filecontent($data);
}
}
// Iterates through the errors returned by the manager and outputs each error message.
foreach ($manager->get_errors() as $e) {

View File

@ -0,0 +1,144 @@
<?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;
/**
* Fileredact manager.
*
* Manages and executes redaction services.
*
* @package core_files
* @copyright Meirza <meirza.arson@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class manager {
/** @var array Holds an array of error messages. */
private array $errors = [];
/**
* Redacts the given file.
*
* @param string $mimetype The mime-type of the file
* @param string $filepath The path to the file to redact
* @return string|null The path to the redacted file or null if no redaction services are available.
*/
public function redact_file(
string $mimetype,
string $filepath,
): ?string {
// Get the file redact services.
$services = $this->get_service_classnames();
$serviceinstances = array_filter(
array_map(fn($serviceclass) => new $serviceclass(), $services),
fn($service) => $service->is_enabled() && $service->is_mimetype_supported($mimetype)
);
if (count($serviceinstances) === 0) {
return null;
}
foreach ($serviceinstances as $servicename => $service) {
try {
return $service->redact_file_by_path($mimetype, $filepath);
} catch (\Throwable $e) {
$this->errors[] = $e;
}
}
return null;
}
/**
* Redacts the given file content.
*
* @param string $mimetype The mime-type of the file
* @param string $filecontent The file content to redact
* @return string|null The content of the redacted file
*/
public function redact_file_content(
string $mimetype,
string $filecontent,
): ?string {
// Get the file redact services.
$services = $this->get_file_services_for_mimetype($mimetype);
foreach ($services as $servicename => $service) {
try {
return $service->redact_file_by_content($mimetype, $filecontent);
} catch (\Throwable $e) {
$this->errors[] = $e;
}
}
return null;
}
/**
* Returns a list of applicable redaction services.
*
* @return string[] list of service classnames.
*/
public function get_service_classnames(): array {
global $CFG;
$servicesdir = "{$CFG->dirroot}/files/classes/redactor/services/";
$servicefiles = glob("{$servicesdir}*_service.php");
$services = [];
foreach ($servicefiles as $servicefile) {
$servicename = basename($servicefile, '_service.php');
$serviceclass = services::class . "\\{$servicename}_service";
if (!is_a($serviceclass, services\service::class, true)) {
continue;
}
$services[$servicename] = $serviceclass;
}
return $services;
}
/**
* Returns a list of file redaction services that support the given mime-type.
*
* @param string $mimetype The mime-type to filter by
* @return services\file_redactor_service_interface[] An array of file redaction services that support the given mime-type.
*/
protected function get_file_services_for_mimetype(string $mimetype): array {
return array_filter(array_map(
function(string $serviceclass) use ($mimetype): ?services\file_redactor_service_interface {
if (!is_a($serviceclass, services\file_redactor_service_interface::class, true)) {
return null;
}
$service = new $serviceclass();
if ($service->is_mimetype_supported($mimetype)) {
return $service;
}
return null;
},
$this->get_service_classnames(),
), fn ($service) => $service !== null);
}
/**
* Retrieves an array of error messages.
*
* @return array An array of error messages.
*/
public function get_errors(): array {
return $this->errors;
}
}

View File

@ -14,9 +14,15 @@
// 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\fileredact\services;
namespace core_files\redactor\services;
use stored_file;
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.
@ -24,12 +30,11 @@ use stored_file;
* The PHP GD stripping has minimal configuration and removes all EXIF data.
* More stripping is made available when using ExifTool.
*
* @package core
* @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 {
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*="',
@ -59,85 +64,136 @@ class exifremover_service extends service {
private bool $useexiftool = false;
/**
* Class constructor.
*
* @param stored_file $storedfile The file record.
* Initialise the EXIF remover service.
*/
public function __construct(
/** @var stored_file The file record. */
private readonly stored_file $storedfile,
) {
parent::__construct($storedfile);
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;
}
}
/**
* Performs redaction on the specified file.
*/
public function execute(): void {
#[\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.
$this->execute_exiftool();
return $this->execute_exiftool($filepath);
} else {
// Use PHP GD lib to remove all EXIF tags.
$this->execute_gd();
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.
*
* @throws \moodle_exception If the ExifTool process fails or the destination file is not created.
* @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(): void {
$tmpfilepath = make_request_directory();
$filerecordname = $this->clean_filename($this->storedfile->get_filename());
$neworiginalfile = $tmpfilepath . DIRECTORY_SEPARATOR . 'new_' . $filerecordname;
$destinationfile = $tmpfilepath . DIRECTORY_SEPARATOR . $filerecordname;
// Copy the original file to a new file.
try {
$this->storedfile->copy_content_to($neworiginalfile);
} catch (\Exception $e) {
throw new \Exception($e->getMessage());
}
private function execute_exiftool(string $sourcefile): string {
$destinationfile = make_request_directory() . '/' . basename($sourcefile);
// Prepare the ExifTool command.
$command = $this->get_exiftool_command($neworiginalfile, $destinationfile);
$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: 'fileredact:exifremover:failedprocessexiftool',
throw new moodle_exception(
errorcode: 'redactor:exifremover:failedprocessexiftool',
module: 'core_files',
a: get_class($this),
debuginfo: implode($output),
);
}
// Replacing the EXIF processed file to the original file.
$this->persist_redacted_file(file_get_contents($destinationfile));
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(): void {
$imagedata = $this->recreate_image_gd();
if (!$imagedata) {
throw new \moodle_exception(
errorcode: 'fileredact:exifremover:failedprocessgd',
private function execute_gd(string $sourcefile): string {
$filecontent = file_get_contents($sourcefile);
$destinationfile = $this->recreate_image_gd($filecontent);
if (!$destinationfile) {
throw new moodle_exception(
errorcode: 'redactor:exifremover:failedprocessgd',
module: 'core_files',
a: get_class($this),
);
}
// Put the image string object data to the original file.
$this->persist_redacted_file($imagedata);
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.
*
@ -162,7 +218,7 @@ class exifremover_service extends service {
* @return string The remove tag options.
*/
private function get_remove_tags(): string {
$removetags = get_config('core_fileredact', 'exifremoverremovetags');
$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;
@ -176,7 +232,7 @@ class exifremover_service extends service {
* @return string The path to the ExifTool executable.
*/
private function get_exiftool_path(): string {
$toolpathconfig = get_config('core_fileredact', 'exifremovertoolpath');
$toolpathconfig = get_config('core', 'file_redactor_exifremovertoolpath');
if (!empty($toolpathconfig) && is_executable($toolpathconfig)) {
return $toolpathconfig;
}
@ -186,84 +242,36 @@ class exifremover_service extends service {
/**
* Recreate the image using PHP GD library to strip all EXIF data.
*
* @return string|false The recreated image data as a string if successful, false otherwise.
* @param string $content The source file content
* @return null|string The path to the recreated image, or null on failure.
*/
private function recreate_image_gd(): string|false {
$content = $this->storedfile->get_content();
private function recreate_image_gd(
string $content,
): ?string {
// Fetch the image information for this image.
$imageinfo = @getimagesizefromstring($content);
if (empty($imageinfo)) {
return false;
return null;
}
// Create a new image from the file.
$image = @imagecreatefromstring($content);
$destinationfile = make_request_directory() . '/output';
// Capture the image as a string object, rather than straight to file.
ob_start();
if (!imagejpeg(
image: $image,
quality: self::DEFAULT_JPEG_COMPRESSION,
)
) {
ob_end_clean();
return false;
}
$data = ob_get_clean();
imagedestroy($image);
return $data;
}
/**
* Persists the redacted file to the file storage.
*
* @param string $content File content.
*/
private function persist_redacted_file(string $content): void {
$filerecord = (object) [
'id' => $this->storedfile->get_id(),
'mimetype' => $this->storedfile->get_mimetype(),
'userid' => $this->storedfile->get_userid(),
'source' => $this->storedfile->get_source(),
'contextid' => $this->storedfile->get_contextid(),
'component' => $this->storedfile->get_component(),
'filearea' => $this->storedfile->get_filearea(),
'itemid' => $this->storedfile->get_itemid(),
'filepath' => $this->storedfile->get_filepath(),
'filename' => $this->storedfile->get_filename(),
'repositoryid' => $this->storedfile->get_repository_id(),
'reference' => $this->storedfile->get_reference(),
];
$fs = get_file_storage();
$existingfile = $fs->get_file(
$filerecord->contextid,
$filerecord->component,
$filerecord->filearea,
$filerecord->itemid,
$filerecord->filepath,
$filerecord->filename,
$result = imagejpeg(
image: $image,
file: $destinationfile,
quality: self::DEFAULT_JPEG_COMPRESSION,
);
if ($existingfile) {
$existingfile->delete();
}
$redactedfile = $fs->create_file_from_string($filerecord, $content, false);
$this->storedfile->replace_file_with($redactedfile);
}
/**
* Clean up a file name if it starts with a dash (U+002D) or a Unicode minus sign (U+2212).
*
* According to https://exiftool.org/#security, ensure that input file names do not start with
* a dash (U+002D) or a Unicode minus sign (U+2212). If found, remove the leading dash or Unicode minus sign.
*
* @param string $filename The file name to clean.
* @return string The cleaned file name.
*/
private function clean_filename(string $filename): string {
$pattern = '/^[\x{002D}\x{2212}]/u';
if (preg_match($pattern, $filename)) {
$filename = preg_replace($pattern, '', $filename);
imagedestroy($image);
if ($result) {
return $destinationfile;
}
return clean_param($filename, PARAM_PATH);
return null;
}
/**
@ -272,7 +280,7 @@ class exifremover_service extends service {
* @return bool
*/
public function is_enabled(): bool {
return (bool) get_config('core_fileredact', 'exifremoverenabled');
return (bool) get_config('core', 'file_redactor_exifremoverenabled');
}
/**
@ -289,7 +297,7 @@ class exifremover_service extends service {
if ($this->useexiftool) {
// Get the supported MIME types from the config if using ExifTool.
$supportedmimetypesconfig = get_config('core_fileredact', 'exifremovermimetype');
$supportedmimetypesconfig = get_config('core', 'file_redactor_exifremovermimetype');
$supportedmimetypes = array_filter(array_map('trim', explode("\n", $supportedmimetypesconfig)));
return in_array($mimetype, $supportedmimetypes) ?? false;
}
@ -307,66 +315,70 @@ class exifremover_service extends service {
// Enabled for a fresh install, disabled for an upgrade.
$defaultenabled = 1;
if (!during_initial_install() && empty(get_config('core_fileredact', 'exifremoverenabled'))) {
$defaultenabled = 0;
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 = new \stdClass;
$a->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'],
);
$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: 'core_fileredact/exifremoverenabled',
visiblename: get_string('fileredact:exifremover:enabled', 'core_files'),
description: get_string('fileredact:exifremover:enabled_desc', 'core_files', $a),
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(
new admin_setting_heading(
name: 'exifremoverheading',
heading: get_string('fileredact:exifremover:heading', 'core_files'),
heading: get_string('redactor:exifremover:heading', 'core_files'),
information: '',
)
);
$settings->add(
new \admin_setting_configexecutable(
name: 'core_fileredact/exifremovertoolpath',
visiblename: get_string('fileredact:exifremover:toolpath', 'core_files'),
description: get_string('fileredact:exifremover:toolpath_desc', 'core_files'),
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("fileredact:exifremover:tag:$key", 'core_files');
$removedtagchoices[$key] = get_string("redactor:exifremover:tag:$key", 'core_files');
}
$settings->add(
new \admin_setting_configselect(
name: 'core_fileredact/exifremoverremovetags',
visiblename: get_string('fileredact:exifremover:removetags', 'core_files'),
description: get_string('fileredact:exifremover:removetags_desc', 'core_files'),
new admin_setting_configselect(
name: 'files_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;
image/jpeg
image/tiff
EOF;
$settings->add(
new \admin_setting_configtextarea(
name: 'core_fileredact/exifremovermimetype',
visiblename: get_string('fileredact:exifremover:mimetype', 'core_files'),
description: get_string('fileredact:exifremover:mimetype_desc', 'core_files'),
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,
),
);

View File

@ -0,0 +1,58 @@
<?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;
/**
* Class file_redactor_service_interface
*
* @package core_files
* @copyright 2024 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
interface file_redactor_service_interface {
/**
* Performs redaction on the specified stored_file.
*
* @param string $mimetype The mime-type of the file
* @param string $filepath The path of the file to redact
* @return string|null The path to the redacted file, or null if redaction was not attempted
*/
public function redact_file_by_path(
string $mimetype,
string $filepath,
): ?string;
/**
* Performs redaction on the specified stored_file.
*
* @param string $mimetype The mime-type of the file
* @param string $filecontent The content of the file to redact
* @return string|null The redacted content, or null if redaction was not attempted
*/
public function redact_file_by_content(
string $mimetype,
string $filecontent,
): ?string;
/**
* Determines whether a certain mime-type is supported by the service.
*
* @param string $mimetype
* @return bool
*/
public function is_mimetype_supported(string $mimetype): bool;
}

View File

@ -14,7 +14,7 @@
// 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\fileredact\services;
namespace core_files\redactor\services;
use stored_file;
/**
@ -25,22 +25,6 @@ use stored_file;
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class service {
/**
* Class constructor.
*
* @param stored_file $storedfile The file record object.
*/
public function __construct(
/** @var stored_file $storedfile The file record object. */
private readonly stored_file $storedfile,
) {
}
/**
* Performs redaction on the specified file.
*/
abstract public function execute(): void;
/**
* Returns true if the service is enabled, and false if it is not.
*
@ -48,17 +32,6 @@ abstract class service {
*/
abstract public function is_enabled(): bool;
/**
* 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
* @return bool
*/
public function is_mimetype_supported(string $mimetype): bool {
return false;
}
/**
* Adds settings to the provided admin settings page.
*

View File

@ -0,0 +1,41 @@
<?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\tests\hook;
/**
* Helper class for before_file_created hooks.
*
* @package core_files
* @copyright 2024 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class before_file_created_callbacks {
/**
* Before file created hook callback for testing.
*
* @param \core_files\hook\before_file_created $hook
*/
public static function before_file_created(\core_files\hook\before_file_created $hook): void {
// phpcs:disable moodle.NamingConventions.ValidVariableName.VariableNameLowerCase
global $TESTCALLBACK;
if (!isset($TESTCALLBACK)) {
return;
}
call_user_func($TESTCALLBACK, $hook);
}
}

View File

@ -14,7 +14,10 @@
// 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\fileredact\services;
namespace core_files\tests\redactor\services;
use core_files\redactor\services\file_redactor_service_interface;
use core_files\redactor\services\service;
/**
* Dummy service for testing only.
@ -23,13 +26,15 @@ namespace core\fileredact\services;
* @copyright Meirza <meirza.arson@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class dummy_service extends service {
class dummy_file_service extends service implements file_redactor_service_interface {
#[\Override]
public function redact_file_by_content(string $mimetype, string $filecontent): string {
return "redacted:{$filecontent}";
}
/**
* Performs redaction on the specified file.
*/
public function execute(): void {
// The function body.
#[\Override]
public function redact_file_by_path(string $mimetype, string $filepath): string {
return "/redacted{$filepath}";
}
/**
@ -49,7 +54,11 @@ class dummy_service extends service {
* @return bool
*/
public function is_mimetype_supported(string $mimetype): bool {
return true;
if (str_starts_with($mimetype, 'image/')) {
return true;
}
return false;
}
/**

View File

@ -0,0 +1,35 @@
<?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/>.
/**
* A hook listener for tests realting to the before_file_created hook.
*
* @package core_files
* @copyright 2024 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$callbacks = [
[
'hook' => \core_files\hook\before_file_created::class,
'callback' => [
\core_files\tests\hook\before_file_created_callbacks::class,
'before_file_created',
],
],
];

View File

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@ -0,0 +1,95 @@
<?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\hook;
use coding_exception;
/**
* Tests for before_file_created hook.
*
* @package core_files
* @category test
* @copyright 2024 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core_files\hook\before_file_created
*/
final class before_file_created_test extends \advanced_testcase {
public function test_init_with_file_and_content_throws_exception(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only one of $filepath or $filecontent can be set');
new before_file_created(new \stdClass(), 'path', 'content');
}
public function test_init_with_no_file_and_no_content_throws_exception(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Either $filepath or $filecontent must be set');
new before_file_created(new \stdClass());
}
public function test_content_updated(): void {
$hook = new before_file_created(new \stdClass(), filecontent: 'data');
$this->assertFalse($hook->has_changed());
$this->assertTrue($hook->has_filecontent());
$this->assertFalse($hook->has_filepath());
$hook->update_filecontent('data');
$this->assertEquals('data', $hook->get_filecontent());
$this->assertFalse($hook->has_changed());
$this->assertNull($hook->get_filepath());
$hook->update_filecontent('new data');
$this->assertEquals('new data', $hook->get_filecontent());
$this->assertTrue($hook->has_changed());
$this->assertNull($hook->get_filepath());
}
public function test_file_updated(): void {
$initialdata = self::get_fixture_path('core_files', 'hook/before_file_created_hooks.php');
$newdata = __FILE__;
$hook = new before_file_created(
new \stdClass(),
filepath: $initialdata,
);
$this->assertFalse($hook->has_changed());
$this->assertFalse($hook->has_filecontent());
$this->assertTrue($hook->has_filepath());
$hook->update_filepath($initialdata);
$this->assertNull($hook->get_filecontent());
$this->assertEquals($initialdata, $hook->get_filepath());
$this->assertFalse($hook->has_changed());
$hook->update_filepath($newdata);
$this->assertNull($hook->get_filecontent());
$this->assertEquals($newdata, $hook->get_filepath());
$this->assertTrue($hook->has_changed());
}
public function test_cannot_update_file_when_content_set(): void {
$hook = new before_file_created(new \stdClass(), filecontent: 'data');
$this->expectException(coding_exception::class);
$this->expectExceptionMessage('Cannot update file path when the file path is not set');
$hook->update_filepath('new path');
}
public function test_cannot_update_content_when_file_ste(): void {
$hook = new before_file_created(new \stdClass(), filepath: __FILE__);
$this->expectException(coding_exception::class);
$this->expectExceptionMessage('Cannot update file content when the file content is not set');
$hook->update_filecontent('new path');
}
}

View File

@ -0,0 +1,63 @@
<?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;
/**
* Tests for file redactor manager class.
*
* @package core_files
* @copyright Meirza <meirza.arson@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core_files\redactor\manager
*/
final class manager_test extends \advanced_testcase {
/**
* Helper to get a manager with a dummy file service.
*
* @return \core_files\redactor\manager
*/
private function get_manager_with_dummy_file_service(): manager {
$manager = $this->getMockBuilder(\core_files\redactor\manager::class)
->onlyMethods(['get_service_classnames'])
->getMock();
$manager->method('get_service_classnames')
->willReturn([\core_files\tests\redactor\services\dummy_file_service::class]);
return $manager;
}
/**
* Test file redaction by path.
*/
public function test_redact_file(): void {
$manager = $this->get_manager_with_dummy_file_service();
// Test redaction for a binary (not supported).
$redactedfile = $manager->redact_file("application/binary", "/path/to/binary");
$this->assertNull($redactedfile);
$redactedfile = $manager->redact_file_content("application/binary", "Binary content here");
$this->assertNull($redactedfile);
// Test redaction for an image.
$redactedfile = $manager->redact_file("image/jpeg", "/path/to/image.jpg");
$this->assertEquals('/redacted/path/to/image.jpg', $redactedfile);
$redactedfile = $manager->redact_file_content("image/jpeg", "Example picture goes here");
$this->assertEquals('redacted:Example picture goes here', $redactedfile);
}
}

View File

@ -0,0 +1,263 @@
<?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;
/**
* Tests for the EXIF remover service.
*
* If you wish to use these unit tests all you need to do is add the following definition to
* your config.php file:
*
* define('TEST_PATH_TO_EXIFTOOL', '/usr/bin/exiftool');
*
* @package core_files
* @copyright Meirza <meirza.arson@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*
* @covers \core_files\redactor\services\exifremover_service
*/
final class exifremover_service_test extends \advanced_testcase {
/**
* Tests the `exifremover_service` functionality using PHP GD.
*
* This test verifies the ability of the `exifremover_service` to remove all EXIF
* tags from an image file when using PHP GD. It ensures that all tags, including
* GPSLatitude, GPSLongitude, and Orientation, are removed from the EXIF data.
*
* @return void
*/
public function test_exifremover_service_with_gd(): void {
$this->resetAfterTest(true);
// Ensure that the exif remover tool path is not set.
set_config('exifremovertoolpath', null, 'core_files');
$sourcepath = self::get_fixture_path('core_files', 'redactor/dummy.jpg');
// Get the EXIF data from the original file.
$currentexif = $this->get_exif_data_from_file($sourcepath);
$this->assertStringContainsString('GPSLatitude', $currentexif);
$this->assertStringContainsString('GPSLongitude', $currentexif);
$this->assertStringContainsString('Orientation', $currentexif);
// Redact the file.
$service = new exifremover_service();
$newfile = $service->redact_file_by_path('image/jpeg', $sourcepath);
// Get the EXIF data from the new file.
$newexif = $this->get_exif_data_from_file($newfile);
// Removing the "all" tags will result in removing all existing tags.
$this->assertStringNotContainsString('GPSLatitude', $newexif);
$this->assertStringNotContainsString('GPSLongitude', $newexif);
$this->assertStringNotContainsString('Orientation', $newexif);
}
/**
* Tests the `exifremover_service` functionality using ExifTool.
*
* This test verifies the ability of the `exifremover_service` to remove specific
* EXIF tags from an image file when configured to use ExifTool. The test includes
* scenarios for removing all EXIF tags and for removing only GPS tags.
*/
public function test_exifremover_service_with_exiftool(): void {
$this->require_exiftool();
$this->resetAfterTest(true);
$sourcepath = self::get_fixture_path('core_files', 'redactor/dummy.jpg');
set_config('file_redactor_exifremovertoolpath', TEST_PATH_TO_EXIFTOOL);
// Remove All tags.
set_config('file_redactor_exifremoverremovetags', 'all');
$service = new exifremover_service();
$newfile = $service->redact_file_by_path('image/jpeg', $sourcepath);
// Get the EXIF data from the new file.
$newexif = $this->get_exif_data_from_file($newfile);
// Removing the "all" tags will result in removing all existing tags.
$this->assertStringNotContainsString('GPSLatitude', $newexif);
$this->assertStringNotContainsString('GPSLongitude', $newexif);
$this->assertStringNotContainsString('Aperture', $newexif);
// Orientation is a preserve tag. Ensure it always exists.
$this->assertStringContainsString('Orientation', $newexif);
// Remove the GPS tag only.
set_config('file_redactor_exifremoverremovetags', 'gps');
$service = new exifremover_service();
$newfile = $service->redact_file_by_path('image/jpeg', $sourcepath);
// Get the EXIF data from the new file.
$newexif = $this->get_exif_data_from_file($newfile);
// The GPS tag only removal will remove the tag containing "GPS" keyword.
$this->assertStringNotContainsString('GPSLatitude', $newexif);
$this->assertStringNotContainsString('GPSLongitude', $newexif);
// And keep the other tags remaining.
$this->assertStringContainsString('Aperture', $newexif);
// Orientation is a preserve tag. Ensure it always exists.
$this->assertStringContainsString('Orientation', $newexif);
}
/**
* Tests the `is_mimetype_supported` method.
*
* This test initializes the `exifremover_service` and verifies if the given
* MIME types are supported for EXIF removal using both PHP GD and ExifTool.
*/
public function test_exifremover_service_is_mimetype_supported_generic(): void {
$service = new exifremover_service();
// Ensure that an unsupported mimetype is not accepted.
$this->assertFalse($service->is_mimetype_supported('application/binary'));
// An unsupported mimetype will just return null.
$sourcepath = self::get_fixture_path('core_files', 'redactor/dummy.jpg');
$this->assertNull($service->redact_file_by_path('application/binary', $sourcepath));
$this->assertNull($service->redact_file_by_content('application/binary', $sourcepath));
}
/**
* Tests the `is_mimetype_supported` method.
*
* This test initializes the `exifremover_service` and verifies if the given
* MIME types are supported for EXIF removal using both PHP GD and ExifTool.
*/
public function test_exifremover_service_is_mimetype_supported_gd(): void {
$this->resetAfterTest(true);
// Ensure that the exif remover tool path is not set.
set_config('file_redactor_exifremovertoolpath', null);
$service = new exifremover_service();
// The default MIME type is supported.
$this->assertTrue($service->is_mimetype_supported(exifremover_service::DEFAULT_MIMETYPE));
// Other than the default, the function will returns false.
$this->assertFalse($service->is_mimetype_supported('image/tiff'));
}
/**
* Tests the `is_mimetype_supported` method.
*
* This test initializes the `exifremover_service` and verifies if the given
* MIME types are supported for EXIF removal using both PHP GD and ExifTool.
*/
public function test_exifremover_service_is_mimetype_supported_exiftool(): void {
$this->require_exiftool();
$this->resetAfterTest(true);
set_config('file_redactor_exifremovertoolpath', TEST_PATH_TO_EXIFTOOL);
// Set the supported mime types to only redact image/tiff.
set_config('file_redactor_exifremovermimetype', 'image/tiff');
$service = new exifremover_service();
$this->assertTrue($service->is_mimetype_supported('image/tiff'));
// Other image formats are not supported.
$this->assertFalse($service->is_mimetype_supported('image/png'));
}
/**
* Tests the EXIF remover service with an unknown filename and an invalid EXIF tool path.
*/
public function test_exiftool_notfound_filename_unknown(): void {
$this->resetAfterTest(true);
set_config('file_redactor_exifremovertoolpath', 'fakeexiftool');
$service = new exifremover_service();
$this->expectException(\core\exception\moodle_exception::class);
$this->expectExceptionMessage(get_string('redactor:exifremover:failedprocessgd', 'core_files'));
$service->redact_file_by_content('image/jpeg', 'content');
}
/**
* Retrieves the EXIF metadata of a file.
*
* @param string $content the content of file.
* @return string The EXIF metadata as a string.
*/
private function get_exif_data_from_content(string $content): string {
$logpath = make_request_directory() . '/temp.jpg';
file_put_contents($logpath, $content);
return $this->get_exif_data_from_file($logpath);
}
/**
* Retrieves the EXIF metadata of a file.
*
* @param string $filepath the path to the file.
* @return string The EXIF metadata as a string.
*/
private function get_exif_data_from_file(string $filepath): string {
$exif = exif_read_data($filepath);
$string = "";
foreach ($exif as $key => $value) {
if (is_array($value)) {
foreach ($value as $subkey => $subvalue) {
$string .= "$subkey: $subvalue\n";
}
} else {
$string .= "$key: $value\n";
}
}
return $string;
}
/**
* Data provider for test_exifremover_service_clean_filename().
*
* @return array
*/
public static function exifremover_service_clean_filename_provider(): array {
return [
'Hyphen minus &#x002D' => [
'filename' => '-if \'$LensModel eq "18-35mm"\'',
'expected' => 'if $LensModel eq 18-35mm',
],
'Minus &#x2212;' => [
'filename' => 'filename.jpg',
'expected' => 'filename.jpg',
],
];
}
/**
* Helper to require valid testing exiftool configuration.
*/
private function require_exiftool(): void {
if (!defined('TEST_PATH_TO_EXIFTOOL')) {
$this->markTestSkipped('Could not test the EXIF remover service, missing configuration.');
}
if (!TEST_PATH_TO_EXIFTOOL) {
$this->markTestSkipped('Could not test the EXIF remover service, configuration invalid.');
}
if (!is_executable(TEST_PATH_TO_EXIFTOOL)) {
$this->markTestSkipped('Could not test the EXIF remover service, exiftool not executable.');
}
}
}

View File

@ -28,28 +28,28 @@ defined('MOODLE_INTERNAL') || die();
$string['contenthash'] = 'Content hash';
$string['eventfileaddedtodraftarea'] = 'File added to draft area';
$string['eventfiledeletedfromdraftarea'] = 'File deleted from draft area';
$string['fileredact'] = 'File redact';
$string['fileredact:exifremover'] = 'EXIF remover';
$string['fileredact:exifremover:emptyremovetags'] = 'Remove tags can not be empty!';
$string['fileredact:exifremover:enabled'] = 'Enable EXIF remover';
$string['fileredact:exifremover:enabled_desc'] = 'By default, EXIF Remover only supports JPG files using PHP GD, or ExifTool if it is configured.
$string['redactor'] = 'File redaction';
$string['redactor:exifremover'] = 'EXIF remover';
$string['redactor:exifremover:emptyremovetags'] = 'Remove tags can not be empty!';
$string['redactor:exifremover:enabled'] = 'Enable EXIF remover';
$string['redactor:exifremover:enabled_desc'] = 'By default, EXIF Remover only supports JPG files using PHP GD, or ExifTool if it is configured.
This degrades the quality of the image and removes the orientation tag.
To enhance the performance of EXIF Remover, please configure the ExifTool settings below.
More information about installing ExifTool can be found at {$a->link}';
$string['fileredact:exifremover:failedprocessexiftool'] = 'Redaction failed: failed to process file with ExifTool!';
$string['fileredact:exifremover:failedprocessgd'] = 'Redaction failed: failed to process file with PHP gd!';
$string['fileredact:exifremover:heading'] = 'ExifTool';
$string['fileredact:exifremover:mimetype'] = 'Supported MIME types';
$string['fileredact:exifremover:mimetype_desc'] = 'To add new MIME types, ensure they\'re included in the <a href="./tool/filetypes/index.php">File Types</a>.';
$string['fileredact:exifremover:removetags'] = 'The EXIF tags that will be removed.';
$string['fileredact:exifremover:removetags_desc'] = 'The EXIF tags that need to be removed.';
$string['fileredact:exifremover:tag:all'] = 'All';
$string['fileredact:exifremover:tag:gps'] = 'GPS only';
$string['fileredact:exifremover:tooldoesnotexist'] = 'Redaction failed: ExifTool does not exist!';
$string['fileredact:exifremover:toolpath'] = 'Path to ExifTool';
$string['fileredact:exifremover:toolpath_desc'] = 'To use the ExifTool, please provide the path to the ExifTool executable.
$string['redactor:exifremover:failedprocessexiftool'] = 'Redaction failed: failed to process file with ExifTool!';
$string['redactor:exifremover:failedprocessgd'] = 'Redaction failed: failed to process file with PHP gd!';
$string['redactor:exifremover:heading'] = 'ExifTool';
$string['redactor:exifremover:mimetype'] = 'Supported MIME types';
$string['redactor:exifremover:mimetype_desc'] = 'To add new MIME types, ensure they\'re included in the <a href="./tool/filetypes/index.php">File Types</a>.';
$string['redactor:exifremover:removetags'] = 'The EXIF tags that will be removed.';
$string['redactor:exifremover:removetags_desc'] = 'The EXIF tags that need to be removed.';
$string['redactor:exifremover:tag:all'] = 'All';
$string['redactor:exifremover:tag:gps'] = 'GPS only';
$string['redactor:exifremover:tooldoesnotexist'] = 'Redaction failed: ExifTool does not exist!';
$string['redactor:exifremover:toolpath'] = 'Path to ExifTool';
$string['redactor:exifremover:toolpath_desc'] = 'To use the ExifTool, please provide the path to the ExifTool executable.
Typically, on Unix/Linux systems, the path is /usr/bin/exiftool.';
$string['privacy:metadata:file_conversions'] = 'A record of the file conversions performed by a user.';
$string['privacy:metadata:file_conversion:usermodified'] = 'The user who started the file conversion.';

View File

@ -234,6 +234,11 @@
"allowedlevel2": false,
"allowedspread": false
},
"redactor": {
"component": "core_files",
"allowedlevel2": true,
"allowedspread": false
},
"reportbuilder": {
"component": "core_reportbuilder",
"allowedlevel2": true,

View File

@ -1,93 +0,0 @@
<?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\fileredact;
use stored_file;
/**
* Fileredact manager.
*
* Manages and executes redaction services.
*
* @package core
* @copyright Meirza <meirza.arson@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class manager {
/** @var array Holds an array of error messages. */
private $errors = [];
/**
* Constructor.
*
* @param stored_file $filerecord The file record as a stdClass object, or null if not available.
*/
public function __construct(
/** @var stored_file $filerecord File record. */
private readonly stored_file $filerecord
) {
}
/**
* Executes redaction services.
*/
public function execute(): void {
// Get the file redact services.
$services = $this->get_services();
foreach ($services as $serviceclass) {
try {
if (class_exists($serviceclass)) {
$service = new $serviceclass($this->filerecord);
// For the given service, execute them if they are enabled, and the given mime type is supported.
if ($service->is_enabled() && $service->is_mimetype_supported($this->filerecord->get_mimetype())) {
$service->execute();
}
}
} catch (\Throwable $e) {
$this->errors[] = $e;
}
}
}
/**
* Returns a list of applicable redaction services.
*
* @return string[] return list of services.
*/
protected function get_services(): array {
global $CFG;
$servicesdir = "{$CFG->libdir}/classes/fileredact/services/";
$servicefiles = glob("{$servicesdir}*_service.php");
$services = [];
foreach ($servicefiles as $servicefile) {
$servicename = basename($servicefile, '_service.php');
$serviceclass = "\\core\\fileredact\\services\\{$servicename}_service";
$services[] = $serviceclass;
}
return $services;
}
/**
* Retrieves an array of error messages.
*
* @return array An array of error messages.
*/
public function get_errors(): array {
return $this->errors;
}
}

View File

@ -111,7 +111,7 @@ $callbacks = [
'callback' => [\core\router\hook_callbacks::class, 'provide_di_configuration'],
],
[
'hook' => \core\hook\filestorage\after_file_created::class,
'callback' => \core\fileredact\hook_listener::class . '::redact_after_file_created',
'hook' => \core_files\hook\before_file_created::class,
'callback' => [\core_files\redactor\hook_listener::class, 'file_redaction_handler'],
],
];

View File

@ -14,7 +14,6 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Core file storage class definition.
*
@ -25,6 +24,8 @@
defined('MOODLE_INTERNAL') || die();
use core_files\hook\before_file_created;
require_once("$CFG->libdir/filestorage/stored_file.php");
/**
@ -1069,9 +1070,8 @@ class file_storage {
* Add new file record to database and handle callbacks.
*
* @param stdClass $newrecord
* @param bool $notify Notify the hook about the new file or not
*/
protected function create_file($newrecord, bool $notify = true) {
protected function create_file($newrecord) {
global $DB;
$newrecord->id = $DB->insert_record('files', $newrecord);
@ -1079,14 +1079,13 @@ class file_storage {
if (defined('PHPUNIT_TEST') && PHPUNIT_TEST) {
return;
}
if ($notify) {
// The $fileinstance is needed for the legacy callback.
$fileinstance = $this->get_file_instance($newrecord);
// Dispatch the new Hook implementation immediately after the legacy callback.
$hook = new \core\hook\filestorage\after_file_created($fileinstance, $newrecord);
\core\di::get(\core\hook\manager::class)->dispatch($hook);
$hook->process_legacy_callbacks();
}
// The $fileinstance is needed for the legacy callback.
$fileinstance = $this->get_file_instance($newrecord);
// Dispatch the new Hook implementation immediately after the legacy callback.
$hook = new \core_files\hook\after_file_created($fileinstance, $newrecord);
$hook->process_legacy_callbacks();
\core\di::get(\core\hook\manager::class)->dispatch($hook);
}
}
@ -1095,10 +1094,9 @@ class file_storage {
*
* @param stdClass|array $filerecord object or array describing changes
* @param stored_file|int $fileorid id or stored_file instance of the existing local file
* @param bool $notify Notify the hook about the new file or not
* @return stored_file instance of newly created file
*/
public function create_file_from_storedfile($filerecord, $fileorid, bool $notify = true) {
public function create_file_from_storedfile($filerecord, $fileorid) {
global $DB;
if ($fileorid instanceof stored_file) {
@ -1205,7 +1203,7 @@ class file_storage {
}
try {
$this->create_file($newrecord, $notify);
$this->create_file($newrecord);
} catch (dml_exception $e) {
throw new stored_file_creation_exception($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid,
$newrecord->filepath, $newrecord->filename, $e->debuginfo);
@ -1277,10 +1275,9 @@ class file_storage {
*
* @param stdClass|array $filerecord object or array describing file
* @param string $pathname path to file or content of file
* @param bool $notify Notify the hook about the new file or not.
* @return stored_file
*/
public function create_file_from_pathname($filerecord, $pathname, bool $notify = true) {
public function create_file_from_pathname($filerecord, $pathname) {
global $DB;
$filerecord = (array)$filerecord; // Do not modify the submitted record, this cast unlinks objects.
@ -1374,7 +1371,7 @@ class file_storage {
$newrecord->pathnamehash = $this->get_pathname_hash($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->filename);
try {
$this->create_file($newrecord, $notify);
$this->create_file($newrecord);
} catch (dml_exception $e) {
if ($newfile) {
$this->filesystem->remove_file($newrecord->contenthash);
@ -1393,10 +1390,9 @@ class file_storage {
*
* @param stdClass|array $filerecord object or array describing file
* @param string $content content of file
* @param bool $notify Notify the hook about the new file or not.
* @return stored_file
*/
public function create_file_from_string($filerecord, $content, bool $notify = true) {
public function create_file_from_string($filerecord, $content) {
global $DB;
$filerecord = (array)$filerecord; // Do not modify the submitted record, this cast unlinks objects.
@ -1498,7 +1494,7 @@ class file_storage {
}
try {
$this->create_file($newrecord, $notify);
$this->create_file($newrecord);
} catch (dml_exception $e) {
if ($newfile) {
$this->filesystem->remove_file($newrecord->contenthash);
@ -1844,7 +1840,19 @@ class file_storage {
* @return array (contenthash, filesize, newfile)
*/
public function add_file_to_pool($pathname, $contenthash = null, $newrecord = null) {
$this->call_before_file_created_plugin_functions($newrecord, $pathname);
$hook = new before_file_created(
filerecord: $newrecord,
filepath: $pathname,
);
$hook->process_legacy_callbacks();
\core\di::get(\core\hook\manager::class)->dispatch($hook);
if ($hook->has_changed()) {
$contenthash = null;
$pathname = $hook->get_filepath();
}
return $this->filesystem->add_file_from_path($pathname, $contenthash);
}
@ -1855,24 +1863,22 @@ class file_storage {
* @return array (contenthash, filesize, newfile)
*/
public function add_string_to_pool($content, $newrecord = null) {
$this->call_before_file_created_plugin_functions($newrecord, null, $content);
return $this->filesystem->add_file_from_string($content);
}
if ($content !== null) {
// This is a directory and there is no record information.
$hook = new before_file_created(
filerecord: $newrecord,
filecontent: $content,
);
/**
* before_file_created hook.
*
* @param stdClass|null $newrecord New file record.
* @param string|null $pathname Path to file.
* @param string|null $content File content.
*/
protected function call_before_file_created_plugin_functions($newrecord, $pathname = null, $content = null) {
$pluginsfunction = get_plugins_with_function('before_file_created');
foreach ($pluginsfunction as $plugintype => $plugins) {
foreach ($plugins as $pluginfunction) {
$pluginfunction($newrecord, ['pathname' => $pathname, 'content' => $content]);
$hook->process_legacy_callbacks();
\core\di::get(\core\hook\manager::class)->dispatch($hook);
if ($hook->has_changed()) {
$content = $hook->get_filecontent();
}
}
return $this->filesystem->add_file_from_string($content);
}
/**

View File

@ -27,6 +27,8 @@ namespace core;
use file_exception;
use file_reference_exception;
use file_storage;
use file_system;
use repository;
use stored_file;
use stored_file_creation_exception;
@ -2213,6 +2215,158 @@ class file_storage_test extends \advanced_testcase {
$this->assertEquals($hash2, $hash3);
}
/**
* Test that the before_file_created hook has no impact if not called.
*
* @covers \core_files\hook\before_file_created
*/
public function test_before_file_created_hook_executed_nochange(): void {
global $TESTCALLBACK; // phpcs:ignore moodle.NamingConventions.ValidVariableName.VariableNameLowerCase
$this->resetAfterTest(true);
$testdata = self::get_fixture_path('core_files', 'hook/before_file_created_hooks.php');
\core\di::set(
\core\hook\manager::class,
\core\hook\manager::phpunit_get_instance([]),
);
// Create a file.
$fs = get_file_storage();
$file = $fs->create_file_from_pathname(
(object) [
'contextid' => 1,
'component' => 'core',
'filearea' => 'phpunit',
'itemid' => 0,
'filepath' => '/',
'filename' => 'testfile.csv',
],
$testdata,
);
// The content should have been updated.
$this->assertEquals(
file_get_contents($testdata),
$file->get_content(),
);
// The content hash should match the new content.
$this->assertEquals(
file_storage::hash_from_path($testdata),
$file->get_contenthash(),
);
}
/**
* Test that the before_file_created hook is called before a file is created.
*
* @covers \core_files\hook\before_file_created
*/
public function test_before_file_created_hook_executed_filepath(): void {
global $TESTCALLBACK; // phpcs:ignore moodle.NamingConventions.ValidVariableName.VariableNameLowerCase
$this->resetAfterTest(true);
$testdata = self::get_fixture_path('core', 'tabfile.csv');
// The before_file_created test hook calls a callback function at TESTCALLBACK.
$TESTCALLBACK = function( // phpcs:ignore moodle.NamingConventions.ValidVariableName.VariableNameLowerCase
\core_files\hook\before_file_created $hook,
) use ($testdata) {
if ($hook->get_filecontent() === '') {
return;
}
$hook->update_filepath($testdata);
};
\core\di::set(
\core\hook\manager::class,
\core\hook\manager::phpunit_get_instance([
'example' => self::get_fixture_path('core_files', 'hook/before_file_created_hooks.php'),
]),
);
// Create a file.
$fs = get_file_storage();
$file = $fs->create_file_from_pathname(
(object) [
'contextid' => 1,
'component' => 'core',
'filearea' => 'phpunit',
'itemid' => 0,
'filepath' => '/',
'filename' => 'testfile.csv',
],
self::get_fixture_path('core_files', 'hook/before_file_created_hooks.php'),
);
// The content should have been updated.
$this->assertEquals(
file_get_contents($testdata),
$file->get_content(),
);
// The content hash should match the new content.
$this->assertEquals(
file_storage::hash_from_path($testdata),
$file->get_contenthash(),
);
}
/**
* Test that the before_file_created hook is called before a file is created with content.
*
* @covers \core_files\hook\before_file_created
*/
public function test_before_file_created_hook_executed_filecontent(): void {
global $TESTCALLBACK; // phpcs:ignore moodle.NamingConventions.ValidVariableName.VariableNameLowerCase
$this->resetAfterTest(true);
$testdata = 'New content';
// The before_file_created test hook calls a callback function at TESTCALLBACK.
$TESTCALLBACK = function( // phpcs:ignore moodle.NamingConventions.ValidVariableName.VariableNameLowerCase
\core_files\hook\before_file_created $hook,
) use ($testdata) {
if ($hook->get_filecontent() === '') {
return;
}
$hook->update_filecontent($testdata);
};
\core\di::set(
\core\hook\manager::class,
\core\hook\manager::phpunit_get_instance([
'example' => self::get_fixture_path('core_files', 'hook/before_file_created_hooks.php'),
]),
);
// Create a file.
$fs = get_file_storage();
$file = $fs->create_file_from_string(
(object) [
'contextid' => 1,
'component' => 'core',
'filearea' => 'phpunit',
'itemid' => 0,
'filepath' => '/',
'filename' => 'testfile.csv',
],
'Original content',
);
// The content should have been updated.
$this->assertEquals(
$testdata,
$file->get_content(),
);
// The content hash should match the new content.
$this->assertEquals(
file_storage::hash_from_string($testdata),
$file->get_contenthash(),
);
}
}
class test_stored_file_inspection extends stored_file {

View File

@ -1,310 +0,0 @@
<?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\fileredact;
use file_storage;
use stored_file;
/**
* Tests for the EXIF remover service.
*
* If you wish to use these unit tests all you need to do is add the following definition to
* your config.php file:
*
* define('TEST_PATH_TO_EXIFTOOL', '/usr/bin/exiftool');
*
* @package core
* @copyright Meirza <meirza.arson@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*
* @covers \core\fileredact\services\exifremover_service
*/
final class exifremover_service_test extends \advanced_testcase {
/** @var file_storage File storage. */
private file_storage $fs;
/**
* Set up the test environment.
*/
public function setUp(): void {
parent::setUp();
$this->resetAfterTest();
$this->fs = get_file_storage();
}
/**
* Creates a temporary file for testing purposes.
*
* @return stored_file The stored file.
*/
private function create_test_file(): stored_file {
$filename = 'dummy.jpg';
$path = __DIR__ . '/../fixtures/fileredact/' . $filename;
$filerecord = (object) [
'contextid' => \context_user::instance(get_admin()->id)->id,
'component' => 'user',
'filearea' => 'unittest',
'itemid' => 0,
'filepath' => '/',
'filename' => $filename,
];
$file = $this->fs->get_file($filerecord->contextid, $filerecord->component, $filerecord->filearea, $filerecord->itemid,
$filerecord->filepath, $filerecord->filename);
if ($file) {
$file->delete();
}
return $this->fs->create_file_from_pathname($filerecord, $path);
}
/**
* Creates a temporary invalid file for testing purposes.
*
* @return stored_file The stored file.
*/
private function create_invalid_test_file(): stored_file {
$filename = 'dummy_invalid.jpg';
$filerecord = (object) [
'contextid' => \context_user::instance(get_admin()->id)->id,
'component' => 'user',
'filearea' => 'unittest',
'itemid' => 0,
'filepath' => '/',
'filename' => $filename,
];
$file = $this->fs->get_file($filerecord->contextid, $filerecord->component, $filerecord->filearea, $filerecord->itemid,
$filerecord->filepath, $filerecord->filename);
if ($file) {
$file->delete();
}
return $this->fs->create_file_from_string($filerecord, 'Dummy content');
}
/**
* Tests the `exifremover_service` functionality using PHP GD.
*
* This test verifies the ability of the `exifremover_service` to remove all EXIF
* tags from an image file when using PHP GD. It ensures that all tags, including
* GPSLatitude, GPSLongitude, and Orientation, are removed from the EXIF data.
*
* @return void
*/
public function test_exifremover_service_with_gd(): void {
$file = $this->create_test_file();
// Get the EXIF data from the new file.
$currentexif = $this->get_new_exif($file->get_content());
$this->assertStringContainsString('GPSLatitude', $currentexif);
$this->assertStringContainsString('GPSLongitude', $currentexif);
$this->assertStringContainsString('Orientation', $currentexif);
$exifremoverservice = new services\exifremover_service($file);
$exifremoverservice->execute();
// Get the EXIF data from the new file.
$newexif = $this->get_new_exif($file->get_content());
// Removing the "all" tags will result in removing all existing tags.
$this->assertStringNotContainsString('GPSLatitude', $newexif);
$this->assertStringNotContainsString('GPSLongitude', $newexif);
$this->assertStringNotContainsString('Orientation', $newexif);
}
/**
* Tests the `exifremover_service` functionality using ExifTool.
*
* This test verifies the ability of the `exifremover_service` to remove specific
* EXIF tags from an image file when configured to use ExifTool. The test includes
* scenarios for removing all EXIF tags and for removing only GPS tags.
*/
public function test_exifremover_service_with_exiftool(): void {
if ( (defined('TEST_PATH_TO_EXIFTOOL') && TEST_PATH_TO_EXIFTOOL && !is_executable(TEST_PATH_TO_EXIFTOOL))
|| (!defined('TEST_PATH_TO_EXIFTOOL'))) {
$this->markTestSkipped('Could not test the EXIF remover service, missing configuration. ' .
"Example: define('TEST_PATH_TO_EXIFTOOL', '/usr/bin/exiftool');");
}
set_config('exifremovertoolpath', TEST_PATH_TO_EXIFTOOL, 'core_fileredact');
// Remove All tags.
set_config('exifremoverremovetags', 'all', 'core_fileredact');
$file1 = $this->create_test_file();
$exifremoverservice = new services\exifremover_service($file1);
$exifremoverservice->execute();
// Get the EXIF data from the new file.
$newexif = $this->get_new_exif($file1->get_content());
// Removing the "all" tags will result in removing all existing tags.
$this->assertStringNotContainsString('GPSLatitude', $newexif);
$this->assertStringNotContainsString('GPSLongitude', $newexif);
$this->assertStringNotContainsString('Aperture', $newexif);
// Orientation is a preserve tag. Ensure it always exists.
$this->assertStringContainsString('Orientation', $newexif);
// Remove the GPS tag only.
set_config('exifremoverremovetags', 'gps', 'core_fileredact');
$file2 = $this->create_test_file();
$exifremoverservice = new services\exifremover_service($file2);
$exifremoverservice->execute();
// Get the EXIF data from the new file.
$newexif = $this->get_new_exif($file2->get_content());
// The GPS tag only removal will remove the tag containing "GPS" keyword.
$this->assertStringNotContainsString('GPSLatitude', $newexif);
$this->assertStringNotContainsString('GPSLongitude', $newexif);
// And keep the other tags remaining.
$this->assertStringContainsString('Aperture', $newexif);
// Orientation is a preserve tag. Ensure it always exists.
$this->assertStringContainsString('Orientation', $newexif);
}
/**
* Tests the `is_mimetype_supported` method.
*
* This test initializes the `exifremover_service` and verifies if the given
* MIME types are supported for EXIF removal using both PHP GD and ExifTool.
*/
public function test_exifremover_service_is_mimetype_supported(): void {
$file = $this->create_test_file();
// Init uals(false, $resultthe service.
$exifremoverservice = new services\exifremover_service($file);
// Test using PHP GD.
$rc = new \ReflectionClass(services\exifremover_service::class);
$rcexifremover = $rc->getMethod('is_mimetype_supported');
// As default, the exif remover only accepts the default mime type.
$result = $rcexifremover->invokeArgs($exifremoverservice, [services\exifremover_service::DEFAULT_MIMETYPE]);
$this->assertEquals(true, $result);
// Other than the default, the function will returns false.
$result = $rcexifremover->invokeArgs($exifremoverservice, ['image/tiff']);
$this->assertEquals(false, $result);
// Test using ExifTool.
$useexiftool = $rc->getProperty('useexiftool');
$useexiftool->setValue($exifremoverservice, true);
// Set the supported mime types.
set_config('exifremovermimetype', 'image/tiff', 'core_fileredact');
// Other than the `image/tiff`, the function will returns false.
$result = $rcexifremover->invokeArgs($exifremoverservice, ['image/png']);
$this->assertEquals(false, $result);
}
/**
* Tests the `clean_filename` method.
*
* This test initializes the `exifremover_service` with a mock file record and
* invokes the `clean_filename` method via reflection to ensure it correctly
* processes the given filename.
*
* @dataProvider exifremover_service_clean_filename_provider
*
* @param string $filename The filename to be cleaned by the `clean_filename` method.
* @param string $expected The expected result after cleaning the filename.
*/
public function test_exifremover_service_clean_filename($filename, $expected): void {
$file = $this->create_test_file();
// Init the service.
$exifremoverservice = new services\exifremover_service($file);
$rc = new \ReflectionClass(services\exifremover_service::class);
$rccleanfilename = $rc->getMethod('clean_filename');
$result = $rccleanfilename->invokeArgs($exifremoverservice, [$filename]);
$this->assertEquals($expected, $result);
}
/**
* Tests that the EXIF remover service alters the content hash of a file
* when a new file is created from the original.
*/
public function test_exifremover_contenthash(): void {
$file = $this->create_test_file();
$beforehash = $file->get_contenthash();
$exifremoverservice = new services\exifremover_service($file);
$exifremoverservice->execute();
$afterhash = $file->get_contenthash();
$this->assertNotSame($beforehash, $afterhash);
}
/**
* Tests the EXIF remover service with an unknown filename and a valid EXIF tool path.
*/
public function test_exiftool_filename_unknown(): void {
if ( (defined('TEST_PATH_TO_EXIFTOOL') && TEST_PATH_TO_EXIFTOOL && !is_executable(TEST_PATH_TO_EXIFTOOL))
|| (!defined('TEST_PATH_TO_EXIFTOOL'))) {
$this->markTestSkipped('Could not test the EXIF remover service, missing configuration. ' .
"Example: define('TEST_PATH_TO_EXIFTOOL', '/usr/bin/exiftool');");
}
set_config('exifremovertoolpath', TEST_PATH_TO_EXIFTOOL, 'core_fileredact');
$invalidfile = $this->create_invalid_test_file();
$exifremoverservice = new services\exifremover_service($invalidfile);
$this->expectException(\Exception::class);
$exifremoverservice->execute();
}
/**
* Tests the EXIF remover service with an unknown filename and an invalid EXIF tool path.
*/
public function test_exiftool_notfound_filename_unknown(): void {
set_config('exifremovertoolpath', 'fakeexiftool', 'core_fileredact');
$invalidfile = $this->create_invalid_test_file();
$exifremoverservice = new services\exifremover_service($invalidfile);
$this->expectException(\moodle_exception::class);
$this->expectExceptionMessage(get_string('fileredact:exifremover:failedprocessgd', 'core_files'));
$exifremoverservice->execute();
}
/**
* Retrieves the EXIF metadata of a file.
*
* @param string $content the content of file.
* @return string The EXIF metadata as a string.
*/
private function get_new_exif(string $content): string {
$logpath = make_request_directory() . '/temp.jpg';
file_put_contents($logpath, $content);
$exif = exif_read_data($logpath);
$string = "";
foreach ($exif as $key => $value) {
if (is_array($value)) {
foreach ($value as $subkey => $subvalue) {
$string .= "$subkey: $subvalue\n";
}
} else {
$string .= "$key: $value\n";
}
}
return $string;
}
/**
* Data provider for test_exifremover_service_clean_filename().
*
* @return array
*/
public static function exifremover_service_clean_filename_provider(): array {
return [
'Hyphen minus &#x002D' => [
'filename' => '-if \'$LensModel eq "18-35mm"\'',
'expected' => 'if $LensModel eq 18-35mm',
],
'Minus &#x2212;' => [
'filename' => 'filename.jpg',
'expected' => 'filename.jpg',
],
];
}
}

View File

@ -1,92 +0,0 @@
<?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\fileredact;
use stored_file;
/**
* Tests for fileredact manager class.
*
* @package core
* @copyright Meirza <meirza.arson@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*
* @covers \core\fileredact\manager
*/
final class manager_test extends \advanced_testcase {
/** @var stored_file Stored file object. */
private stored_file $storedfile;
public function setUp(): void {
parent::setUp();
$this->resetAfterTest();
$file = new \stdClass;
$file->contextid = \context_user::instance(get_admin()->id)->id;
$file->component = 'user';
$file->filearea = 'private';
$file->itemid = 0;
$file->filepath = '/';
$file->filename = 'test.jpg';
$file->source = 'test';
$fs = get_file_storage();
$this->storedfile = $fs->create_file_from_string($file, 'file1 content');
}
/**
* Tests the `get_services` method.
*
* This test initializes the `manager` and verifies that the `get_services` method.
*/
public function test_get_services(): void {
// Init the manager.
$manager = new \core\fileredact\manager($this->storedfile);
$rc = new \ReflectionClass(\core\fileredact\manager::class);
$rcm = $rc->getMethod('get_services');
$services = $rcm->invoke($manager);
$this->assertGreaterThan(0, count($services));
}
/**
* Tests the `execute` method and error handling.
*
* This test mocks the `manager` class to return a dummy service for `get_services`
* and verifies that the `execute` method runs without errors.
*/
public function test_execute(): void {
$managermock = $this->getMockBuilder(\core\fileredact\manager::class)
->onlyMethods(['get_services'])
->setConstructorArgs([$this->storedfile])
->getMock();
$managermock->expects($this->once())
->method('get_services')
->willReturn(['\\core\fileredact\\services\\dummy_service']);
/** @var \core\fileredact\manager $managermock */
$managermock->execute();
$errors = $managermock->get_errors();
// If execution is OK, then no errors.
$this->assertEquals([], $errors);
}
}