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;