diff --git a/classes/hook_callbacks.php b/classes/hook_callbacks.php new file mode 100644 index 00000000..be8f8c9f --- /dev/null +++ b/classes/hook_callbacks.php @@ -0,0 +1,49 @@ +. + +namespace tool_objectfs; + +use tool_objectfs\local\store\object_file_system; + +/** + * Hook callbacks for tool_objectfs. + * + * @package tool_objectfs + * @author Benjamin Walker (benjaminwalker@catalyst-au.net) + * @copyright 2026 Catalyst IT Australia + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hook_callbacks { + /** + * Rewrites embedded pre-signed URLs back to pluginfiles before editor content is persisted + * + * @param \core_files\hook\before_editor_content_saved $hook + */ + public static function before_editor_content_saved(\core_files\hook\before_editor_content_saved $hook): void { + global $CFG; + + if (during_initial_install() || isset($CFG->upgraderunning)) { + return; + } + + $fs = get_file_storage()->get_file_system(); + if (!($fs instanceof object_file_system)) { + return; + } + + $hook->set_text($fs->normalise_presigned_urls($hook->get_text())); + } +} diff --git a/classes/local/store/object_file_system.php b/classes/local/store/object_file_system.php index d7df5b93..213028bc 100644 --- a/classes/local/store/object_file_system.php +++ b/classes/local/store/object_file_system.php @@ -53,6 +53,9 @@ * [Description object_file_system] */ abstract class object_file_system extends \file_system_filedir { + /** @var string Origin for pre-signed urls. */ + public const PLUGINFILE_ORIGIN_SEGMENT_PREFIX = 'objectfs-origin='; + /** * @var mixed */ @@ -887,6 +890,30 @@ public function redirect_to_presigned_url($contenthash, $headers = []) { } } + /** + * Convert pre-signed URLs inside text back to pluginfiles. + * + * @param string $text The content that may contain URLs in need of rewriting. + * @return string The processed text. + */ + public function normalise_presigned_urls(string $text): string { + if (!$this->presigned_url_configured()) { + return $text; + } + + $re = sprintf( + '!https?://[^\s<>"\']*?\#%s([^\s<>"\']+\w)!', + preg_quote(self::PLUGINFILE_ORIGIN_SEGMENT_PREFIX) + ); + + // The origin URL should only have minimal RFC 3986 encoding on anchors. + // These characters should be rare and even desirable to keep encoded in pluginfile links. + return preg_replace_callback( + $re, + fn($matches) => (new \moodle_url($matches[1]))->out(), + $text + ); + } /** * Return if the file system supports presigned_urls. diff --git a/classes/local/store/signed_url.php b/classes/local/store/signed_url.php index 80f41ce8..25b81ec6 100644 --- a/classes/local/store/signed_url.php +++ b/classes/local/store/signed_url.php @@ -45,5 +45,19 @@ class signed_url { public function __construct($url, $expiresat) { $this->url = $url; $this->expiresat = $expiresat; + $this->embed_origin_url(); + } + + /** + * Embed origin URL to pre-signed URL. + */ + public function embed_origin_url(): void { + global $ME; + + if (!empty($ME)) { + $this->url->set_anchor(object_file_system::PLUGINFILE_ORIGIN_SEGMENT_PREFIX . $ME); + } else if (!CLI_SCRIPT && !PHPUNIT_TEST) { + debugging(OBJECTFS_PLUGIN_NAME . ': Missing origin for pre-signed url.', DEBUG_DEVELOPER); + } } } diff --git a/db/hooks.php b/db/hooks.php new file mode 100644 index 00000000..8959052d --- /dev/null +++ b/db/hooks.php @@ -0,0 +1,34 @@ +. + +/** + * Hook callbacks for tool_objectfs. + * + * @package tool_objectfs + * @author Benjamin Walker (benjaminwalker@catalyst-au.net) + * @copyright 2026 Catalyst IT Australia + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'hook' => \core_files\hook\before_editor_content_saved::class, + 'callback' => '\tool_objectfs\hook_callbacks::before_editor_content_saved', + 'priority' => 0, + ], +]; diff --git a/tests/object_file_system_test.php b/tests/object_file_system_test.php index 48d49db8..3c685865 100644 --- a/tests/object_file_system_test.php +++ b/tests/object_file_system_test.php @@ -18,6 +18,7 @@ use coding_exception; use tool_objectfs\local\store\object_file_system; +use tool_objectfs\local\store\signed_url; use tool_objectfs\local\manager; use tool_objectfs\local\tag\tag_manager; use tool_objectfs\tests\test_client; @@ -1269,4 +1270,122 @@ public function test_is_file_stored_externally_by_hash(): void { $result = $reflection->invokeArgs($this->filesystem, [$contenthash]); $this->assertFalse($result); } + + /** + * Data provider for test_normalise_presigned_urls + * + * @return array + */ + public static function normalise_presigned_urls_provider(): array { + return [ + 'default' => [[]], + 's3 style presignedurl' => [[ + 'presignedurl' => 'https://bucket.s3.ap-southeast-2.amazonaws.com/bucketkeyprefix/?param1=val1¶m2=val2', + ]], + 'presignedurl uses http' => [[ + 'presignedurl' => 'https://presigned.url/x?param1=val1¶m2=val2', + ]], + 'presignedurl already has fragment' => [[ + 'presignedurl' => 'https://presigned.url/x?param1=val1#already-fragment', + ]], + 'invalid presignedurl missing protocol' => [[ + 'presignedurl' => 'presigned.url/x?param1=val1', + 'replace' => false, + ]], + 'invalid presignedurl containing space' => [[ + 'presignedurl' => 'https://presigned.url/x?param1=val 1', + 'replace' => false, + ]], + 'url with embdedded image' => [[ + 'text' => '', + ]], + 'url in markdown style text' => [[ + 'text' => '[link](%s)', + ]], + 'multiple embedded urls' => [[ + 'text' => 'One: https://example.org/unrelated.pdf Two: %s Three: https://example.org/other.pdf', + ]], + 'multiple embedded urls without space' => [[ + 'text' => '%s', + ]], + 'pluginfile with space' => [[ + 'pluginfiles' => ['/pluginfile.php/3854/tool_objectfs/content/1/test file.pdf'], + ]], + 'pluginfile with trailing )' => [[ + 'pluginfiles' => ['/pluginfile.php/3854/tool_objectfs/content/1/test-file(1)'], + ]], + 'pluginfile with trailing /' => [[ + 'pluginfiles' => ['/pluginfile.php/3854/tool_objectfs/content/1/test-file/'], + ]], + 'pluginfile with encoded chars' => [[ + 'pluginfiles' => ['/pluginfile.php/1/tool_objectfs/settings/0/%F0%9F%98%80.txt'], + ]], + 'multiple pluginfiles' => [[ + 'text' => 'One: %s Two: %s', + 'pluginfiles' => [ + '/pluginfile.php/3854/tool_objectfs/content/1/test.pdf', + '/pluginfile.php/7316/tool_objectfs/content/2/red.pdf', + ], + ]], + 'multiple pluginfiles with no space' => [[ + 'text' => '%s', + 'pluginfiles' => [ + '/pluginfile.php/3854/tool_objectfs/content/1/test.pdf', + '/pluginfile.php/3854/tool_objectfs/content/1/test.pdf', + ], + ]], + ]; + } + + /** + * Test rewriting pre-signed URLs to pluginfiles. + * + * @dataProvider normalise_presigned_urls_provider + * @covers ::normalise_presigned_urls + * + * @param array $config + */ + public function test_normalise_presigned_urls(array $config): void { + global $ME; + + $presignedurl = $config['presignedurl'] ?? 'https://presigned.url/x?param1=val1¶m2=val2'; + $text = $config['text'] ?? 'File %s link'; + $pluginfiles = $config['pluginfiles'] ?? ['/pluginfile.php/3854/tool_objectfs/content/1/test-file.pdf']; + $replace = $config['replace'] ?? true; + + // The normalisation logic can be tested with a limited test client. + $filesystem = $this->getMockBuilder(test_file_system::class) + ->onlyMethods(['presigned_url_configured']) + ->getMock(); + $filesystem->method('presigned_url_configured')->willReturn(true); + + $anchorprop = (new \ReflectionClass(\moodle_url::class))->getProperty('anchor'); + $prefix = object_file_system::PLUGINFILE_ORIGIN_SEGMENT_PREFIX; + + $embeddedurls = []; + $expectedurls = []; + foreach ($pluginfiles as $pluginfile) { + $ME = $pluginfile; + $fullurl = (new signed_url(new \moodle_url($presignedurl), DAYSECS))->url; + + // Verify that origin is added as an anchor when creating signed urls. + $anchor = $anchorprop->getValue($fullurl); + $this->assertEquals($prefix . $pluginfile, $anchor); + + // Moodle url automatically encodes the fragment as RFC 3986, which we should keep for the link. + $fragment = (string)parse_url($fullurl->out(), PHP_URL_FRAGMENT); + $fullpluginfile = new \moodle_url(substr($fragment, strlen($prefix))); + + // Manually build the embedded text from the provider so we can test poorly copied HTML text. + $embeddedurl = $presignedurl . '#' . $fragment; + $embeddedurls[] = $embeddedurl; + $expectedurls[] = $replace ? $fullpluginfile->out() : $embeddedurl; + } + + // Verify that embedded pre-signed urls are converted back to the original pluginfiles. + $this->assertEquals( + sprintf($text, ...$expectedurls), + $filesystem->normalise_presigned_urls(sprintf($text, ...$embeddedurls)) + ); + } } diff --git a/version.php b/version.php index d1656ed8..598d3758 100644 --- a/version.php +++ b/version.php @@ -25,8 +25,8 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026041008; // The current plugin version (Date: YYYYMMDDXX). -$plugin->release = 2026041008; // Same as version. +$plugin->version = 2026041009; // The current plugin version (Date: YYYYMMDDXX). +$plugin->release = 2026041009; // Same as version. $plugin->requires = 2024042200; // Requires 4.4. $plugin->component = "tool_objectfs"; $plugin->maturity = MATURITY_STABLE;