Skip to content

Commit

Permalink
feat: serve public static files from packages
Browse files Browse the repository at this point in the history
  • Loading branch information
MHajoha committed Aug 5, 2024
1 parent 1ee7f4c commit 68767be
Show file tree
Hide file tree
Showing 8 changed files with 274 additions and 31 deletions.
76 changes: 73 additions & 3 deletions classes/api/package_api.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@

namespace qtype_questionpy\api;

use coding_exception;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\BadResponseException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Utils;
use moodle_exception;
use Psr\Http\Message\ResponseInterface;
use qtype_questionpy\array_converter\array_converter;
use stored_file;

Expand All @@ -32,19 +40,27 @@
*/
class package_api {
/** @var string */
private string $hash;
private readonly string $hash;

/** @var stored_file|null */
private ?stored_file $file;
private readonly ?stored_file $file;

private readonly Client $client;

/**
* Initialize a new instance.
*
* @param string $hash package hash
* @param string $hash package hash
* @param stored_file|null $file package file or null. If this is not provided and the package is not available to
* the server, operations will fail
* @throws \dml_exception
*/
public function __construct(string $hash, ?stored_file $file = null) {
$this->client = new Client([
"base_uri" => rtrim(get_config('qtype_questionpy', 'server_url'), "/") . "/packages/$hash/",
"timeout" => get_config('qtype_questionpy', 'server_timeout')
]);

$this->hash = $hash;
$this->file = $file;
}
Expand Down Expand Up @@ -156,6 +172,60 @@ public function score_attempt(string $questionstate, string $attemptstate, ?stri
return array_converter::from_array(attempt_scored::class, $httpresponse->get_data());
}

/**
* @throws coding_exception
*/
private function guzzle_post_and_maybe_retry(string $uri, array $options = [], bool $allowretry = true): ResponseInterface {
try {
return $this->client->post($uri, $options);
} catch (BadResponseException $e) {
$rethrow = fn() => throw new coding_exception(
"Request to '$uri' unexpectedly returned status code '{$e->getResponse()->getStatusCode()}'");

/** @var Response $res */
if (!$allowretry || $e->getResponse()->getStatusCode() != 404) {
$rethrow();
}

try {
$json = Utils::jsonDecode($e->getResponse()->getBody(), assoc: true);
} catch (InvalidArgumentException) {
// Not valid JSON, so the problem probably isn't a missing package file.
$rethrow();
}

if ($json['what'] !== 'PACKAGE') {
$rethrow();
}

// Add file to parts and resend.
$fd = $this->file->get_content_file_handle();

try {
$options["multipart"][] = [
"name" => "package",
"contents" => $fd,
];

return $this->guzzle_post_and_maybe_retry($uri, $options, allowretry: false);
} finally {
@fclose($fd);
}
} catch (GuzzleException $e) {
throw new coding_exception("Request to QPy server failed: " . $e->getMessage());
}
}

/**
* @throws coding_exception
*/
public function download_static_file(string $namespace, string $shortname, string $kind, string $path, string $targetpath): string {
// TODO: What if missing?
$res = $this->guzzle_post_and_maybe_retry("file/$namespace/$shortname/$kind/$path", ["sink" => $targetpath]);

return $res->hasHeader("Content-Type") ? $res->getHeader("Content-Type")[0] : mime_content_type($path);
}

/**
* Creates the multipart parts array.
*
Expand Down
56 changes: 41 additions & 15 deletions classes/package_file_service.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

use coding_exception;
use context_user;
use dml_exception;
use stored_file;

/**
Expand All @@ -44,11 +45,11 @@ public function get_draft_file(int $draftid): stored_file {
$fs = get_file_storage();
$files = $fs->get_area_files(
$usercontext->id,
'user',
'draft',
$draftid,
'itemid, filepath, filename',
false
component: 'user',
filearea: 'draft',
itemid: $draftid,
includedirs: false,
limitnum: 1
);
if (!$files) {
throw new coding_exception("draft file with id '$draftid' does not exist");
Expand All @@ -57,26 +58,51 @@ public function get_draft_file(int $draftid): stored_file {
}

/**
* Get a {@see stored_file} with the given ID.
* Assumes that the question with the given id uses a local package and returns its package file.
*
* @param int $qpyid the id of the `qtype_questionpy` record
* @param int $contextid
* @param int $contextid context id of the question, e.g. {@see \question_definition::$contextid}
* @return stored_file
* @throws coding_exception if no such draft file exists
* @throws coding_exception if no package file can be found for the given question, such as if the question isn't
* local after all.
*/
public function get_file(int $qpyid, int $contextid): stored_file {
public function get_file_for_local_question(int $qpyid, int $contextid): stored_file {
$fs = get_file_storage();
$files = $fs->get_area_files(
$contextid,
'qtype_questionpy',
'package',
$qpyid,
'itemid, filepath, filename',
false
component: 'qtype_questionpy',
filearea: 'package',
itemid: $qpyid,
includedirs: false,
limitnum: 1
);
if (!$files) {
throw new coding_exception("package file with qpy id '$qpyid' does not exist");
throw new coding_exception("Package file with qpy id '$qpyid' does not exist.");
}
return reset($files);
}

/**
* If any question uses a manually uploaded package with the given hash, return the file. Otherwise, return null.
*
* @param string $packagehash
* @param int $contextid context id of the question, e.g. {@see \question_definition::$contextid}
* @return stored_file|null
* @throws dml_exception
* @throws coding_exception
*/
public function get_file_by_package_hash(string $packagehash, int $contextid): ?stored_file {
global $DB;
$qpyid = $DB->get_field("qtype_questionpy", "id", [
"islocal" => true,
"pkgversionhash" => $packagehash
], IGNORE_MULTIPLE);

if ($qpyid === false) {
// No question uses an uploaded (aka local) package with that hash.
return null;
} else {
return $this->get_file_for_local_question($qpyid, $contextid);
}
}
}
52 changes: 47 additions & 5 deletions classes/question_ui_renderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use DOMProcessingInstruction;
use DOMText;
use DOMXPath;
use qtype_questionpy_question;
use question_attempt;
use question_display_options;

Expand Down Expand Up @@ -63,7 +64,14 @@ class question_ui_renderer {
* @param question_display_options $options
* @param question_attempt $attempt
*/
public function __construct(string $xml, array $placeholders, question_display_options $options, question_attempt $attempt) {
public function __construct(string $xml, array $placeholders, question_display_options $options,
question_attempt $attempt) {
$this->placeholders = $placeholders;
$this->options = $options;
$this->attempt = $attempt;

$xml = $this->replace_qpy_urls($xml);

$this->xml = new DOMDocument();
$this->xml->preserveWhiteSpace = false;
$this->xml->loadXML($xml);
Expand All @@ -72,10 +80,6 @@ public function __construct(string $xml, array $placeholders, question_display_o
$this->xpath = new DOMXPath($this->xml);
$this->xpath->registerNamespace("xhtml", constants::NAMESPACE_XHTML);
$this->xpath->registerNamespace("qpy", constants::NAMESPACE_QPY);

$this->placeholders = $placeholders;
$this->options = $options;
$this->attempt = $attempt;
}

/**
Expand Down Expand Up @@ -547,4 +551,42 @@ private function add_class_names(DOMElement $element, string ...$newclasses): vo

$element->setAttribute("class", implode(" ", $classarray));
}

private function replace_qpy_urls(string $input): string {
// Protect already-present @@PLUGINFILE@@ from being rewritten.
$intermediate = str_replace("@@PLUGINFILE@@", "@@PLUGINFILE@@", $input);

$question = $this->attempt->get_question();
assert($question instanceof qtype_questionpy_question);

$intermediate = str_replace(
"qpy://static/",
"@@PLUGINFILE@@/{$question->packagehash}/",
$intermediate
);
$intermediate = $this->attempt->rewrite_pluginfile_urls($intermediate, "qtype_questionpy", "static", null);
$intermediate = str_replace(
"qpy://static-private/",
"@@PLUGINFILE@@/{$question->packagehash}/",
$intermediate
);
return $this->attempt->rewrite_pluginfile_urls($intermediate, "qtype_questionpy", "static-private", null);
//
// return preg_replace_callback(
// // The first two path segments are namespace and short name, and so more restrictive.
// "#qpy://(static|static-private)/((?:[a-z_][a-z0-9_]{0,126}/){2}(?:[\w\-@:%+.~=]+/)*)([\w\-@:%+.~=]+)#",
// function (array $match) {
// [,$kind, $path, $filename] = $match;
// $url = \moodle_url::make_pluginfile_url(
// $this->attempt->get_question()->contextid,
// "question",
// $kind,
// null,
// $path,
// $filename
// );
//
// return $url->out();
// }, $input);
}
}
64 changes: 64 additions & 0 deletions classes/static_file_service.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php
// This file is part of the QuestionPy Moodle plugin - https://questionpy.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 qtype_questionpy;

use coding_exception;
use context_system;
use dml_exception;
use invalid_dataroot_permissions;
use qtype_questionpy\api\api;

class static_file_service {

private readonly api $api;
private readonly package_file_service $packagefileservice;

public function __construct() {
$this->api = new api();
$this->packagefileservice = new package_file_service();
}

/**
* Gets and serves the given static file from the QPy server and dies afterwards.
*
* TODO: Cache the file.
*
* @param string $packagehash
* @param string $namespace
* @param string $shortname
* @param string $path
* @return never
* @throws coding_exception
* @throws dml_exception
* @throws invalid_dataroot_permissions
*/
public function serve_public_static_file(string $packagehash, string $namespace, string $shortname, string $path): never {
$fileiflocal = $this->packagefileservice->get_file_by_package_hash($packagehash, context_system::instance()->id);

$temppath = make_request_directory() . "/$packagehash/$namespace/$shortname/$path";
make_writable_directory(dirname($temppath));

$mimetype = $this->api->package($packagehash, $fileiflocal)
->download_static_file($namespace, $shortname, "static", $path, $temppath);

/* Set a lifetime of 1 year, i.e. effectively never expire. Since the package hash is part of the URL, cache
busting is automatic. */
send_file($temppath, basename($path), lifetime: 31536000, mimetype: $mimetype,
options: ["immutable" => true, "cacheability" => "public"]);
die;
}
}
2 changes: 1 addition & 1 deletion edit_questionpy_form.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ private function definition_package_settings_upload(MoodleQuickForm $mform, bool
$file = $this->packagefileservice->get_draft_file($draftid);
} else {
$qpyid = $this->question->qpy_id;
$file = $this->packagefileservice->get_file($qpyid, $this->context->get_course_context()->id);
$file = $this->packagefileservice->get_file_for_local_question($qpyid, $this->context->get_course_context()->id);
$mform->addElement('hidden', 'qpy_package_path_name_hash', $file->get_pathnamehash());
$mform->setType('qpy_package_path_name_hash', PARAM_ALPHANUM);
}
Expand Down
23 changes: 18 additions & 5 deletions lib.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,34 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/

use qtype_questionpy\static_file_service;

/**
* Checks file access for QuestionPy questions.
* @package qtype_questionpy
* @category files
* @param stdClass $course course object
* @param stdClass $cm course module object
* @param stdClass $context context object
* @param string $filearea file area
* @param array $args extra arguments
* @param bool $forcedownload whether or not force download
* @param array $options additional options affecting the file serving
* @return bool
* @throws moodle_exception
* @package qtype_questionpy
* @category files
*/
function qtype_questionpy_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options = []) {
function qtype_questionpy_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options = []): void {
global $CFG;
require_once($CFG->libdir . '/questionlib.php');
question_pluginfile($course, $context, 'qtype_questionpy', $filearea, $args, $forcedownload, $options);

if ($filearea !== "static") {
// TODO: Support static-private files.
send_file_not_found();
}

$staticfileservice = new static_file_service();

[, , $packagehash, $namespace, $shortname] = $args;
$path = implode("/", array_slice($args, 5));

$staticfileservice->serve_public_static_file($packagehash, $namespace, $shortname, $path);
}
Loading

0 comments on commit 68767be

Please sign in to comment.