Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions classes/hook_callbacks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?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 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()));
}
}
27 changes: 27 additions & 0 deletions classes/local/store/object_file_system.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions classes/local/store/signed_url.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
34 changes: 34 additions & 0 deletions db/hooks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?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/>.

/**
* 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,
],
];
119 changes: 119 additions & 0 deletions tests/object_file_system_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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&param2=val2',
]],
'presignedurl uses http' => [[
'presignedurl' => 'https://presigned.url/x?param1=val1&param2=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' => '<img src="%s">',
]],
'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' => '<a href="https://example.org/">%s</a>',
]],
'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' => '<a href="%s">%s</a>',
'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&param2=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))
);
}
}
4 changes: 2 additions & 2 deletions version.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading