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 15, 2024
1 parent 61b0ca6 commit c001ef2
Show file tree
Hide file tree
Showing 11 changed files with 603 additions and 59 deletions.
13 changes: 12 additions & 1 deletion classes/api/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class api {
/**
* Initialize instance.
*
* @param qpy_http_client $client
*/
public function __construct(
/** @var qpy_http_client */
private readonly qpy_http_client $client
) {
}

/**
* Retrieves QuestionPy packages from the application server.
*
Expand Down Expand Up @@ -81,7 +92,7 @@ public function get_package(string $hash): ?package_raw {
* @return package_api
*/
public function package(string $hash, ?stored_file $file = null): package_api {
return new package_api($hash, $file);
return new package_api($this->client, $hash, $file);
}

/**
Expand Down
115 changes: 105 additions & 10 deletions classes/api/package_api.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@

namespace qtype_questionpy\api;

use coding_exception;
use core\http_client;
use GuzzleHttp\Exception\BadResponseException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Utils;
use moodle_exception;
use Psr\Http\Message\ResponseInterface;
use qtype_questionpy\array_converter\array_converter;
use stored_file;

Expand All @@ -31,22 +38,21 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class package_api {
/** @var string */
private string $hash;

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

/**
* 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
*/
public function __construct(string $hash, ?stored_file $file = null) {
$this->hash = $hash;
$this->file = $file;
public function __construct(
/** @var qpy_http_client $client */
private readonly qpy_http_client $client,
/** @var string $hash */
private readonly string $hash,
/** @var stored_file|null $file */
private readonly ?stored_file $file = null
) {
}

/**
Expand Down Expand Up @@ -156,6 +162,94 @@ public function score_attempt(string $questionstate, string $attemptstate, ?stri
return array_converter::from_array(attempt_scored::class, $httpresponse->get_data());
}

/**
* Send a POST request and retry if the server doesn't have the package file cached, but we have it available.
*
* @param string $uri can be absolute or relative to the base url
* @param array $options request options as per
* {@link https://docs.guzzlephp.org/en/stable/request-options.html Guzzle docs}
* @param bool $allowretry if set to false, retry won't be attempted if the package file isn't cached, instead
* throwing a {@see coding_exception}
* @return ResponseInterface
* @throws coding_exception if the request is unsuccessful for any other reason
* @see post_and_maybe_retry
*/
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) {
if (!$allowretry || !$this->file || $e->getResponse()->getStatusCode() != 404) {
throw $e;
}

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

if ($json['what'] ?? null !== 'PACKAGE') {
throw $e;
}

// 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());
}
}

/**
* Downloads the given static file to the given path.
*
* In the case of non-public static files, access control must be done by the caller.
*
* @param string $namespace namespace of the package from which to retrieve the file
* @param string $shortname short name of the package from which to retrieve the file
* @param string $kind `static` for now
* @param string $path path of the static file in the package
* @param string $targetpath path where the file should be downloaded to. Anything here will be overwritten.
* @return string|null the mime type as reported by the server or null if the file wasn't found
* @throws coding_exception
*/
public function download_static_file(string $namespace, string $shortname, string $kind, string $path,
string $targetpath): ?string {
try {
$res = $this->guzzle_post_and_maybe_retry(
"/packages/$this->hash/file/$namespace/$shortname/$kind/$path",
["sink" => $targetpath]
);
} catch (BadResponseException $e) {
if ($e->getResponse()->getStatusCode() == 404) {
return null;
}

throw new coding_exception(
"Request to '{$e->getRequest()->getUri()}' unexpectedly returned status code " .
"'{$e->getResponse()->getStatusCode()}'"
);
}

if ($res->hasHeader("Content-Type")) {
return $res->getHeader("Content-Type")[0];
} else {
debugging("Server did not send Content-Type header, falling back to application/octet-stream");
return "application/octet-stream";
}
}

/**
* Creates the multipart parts array.
*
Expand All @@ -181,6 +275,7 @@ private function create_request_parts(array $main, ?string $questionstate): arra
* @param array $parts array of multipart parts
* @return http_response_container
* @throws moodle_exception
* @see guzzle_post_and_maybe_retry
*/
private function post_and_maybe_retry(string $subpath, array $parts): http_response_container {
$connector = connector::default();
Expand Down
59 changes: 59 additions & 0 deletions classes/api/qpy_http_client.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?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\api;

use core\http_client;
use dml_exception;
use GuzzleHttp\HandlerStack;

/**
* Guzzle http client configured with Moodle's standards ({@see http_client}) and QPy-specific ones.
*
* @package qtype_questionpy
* @author Maximilian Haye
* @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class qpy_http_client extends http_client {
/**
* Initializes a new client.
*
* @param array $config Guzzle config options. Mock handlers and history middleware can be added here, see
* {@link https://docs.guzzlephp.org/en/stable/testing.html#mock-handler}.
* @throws dml_exception
*/
public function __construct(array $config = []) {
$config["base_uri"] = rtrim(get_config('qtype_questionpy', 'server_url'), "/") . "/";
$config["timeout"] = get_config('qtype_questionpy', 'server_timeout');
parent::__construct($config);
}

/**
* Get the handler stack according to the settings/options from client.
*
* @param array $settings The settings or options from client.
* @return HandlerStack
*/
protected function get_handlers(array $settings): HandlerStack {
$handlerstack = parent::get_handlers($settings);
/* This checks requests against Moodle's curlsecurityblockedhosts, which we don't want, since admins would need
to ensure their QPy server isn't in this list otherwise. There may be ways to granularly allow the
server_url, but this will do for now. */
$handlerstack->remove("moodle_check_initial_request");
return $handlerstack;
}
}
3 changes: 2 additions & 1 deletion classes/external/load_packages.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
global $CFG;
require_once($CFG->libdir . "/externallib.php");

use core\di;
use external_api;
use external_function_parameters;
use external_single_structure;
Expand Down Expand Up @@ -67,7 +68,7 @@ public static function execute(): array {
}

// Load and store packages from the application server.
$api = new api();
$api = di::get(api::class);
$packages = $api->get_packages();
foreach ($packages as $package) {
$package->store();
Expand Down
25 changes: 25 additions & 0 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 Down Expand Up @@ -80,4 +81,28 @@ public function get_file_for_local_question(int $qpyid, int $contextid): stored_
}
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);
}
}
}
44 changes: 39 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,34 @@ private function add_class_names(DOMElement $element, string ...$newclasses): vo

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

/**
* Replaces QPy-URIs such as `qpy:acme/great_package/static/css/styles.css` with functioning pluginfile URLs.
*
* @param string $input
* @return string
*/
private function replace_qpy_urls(string $input): string {
$question = $this->attempt->get_question();
assert($question instanceof qtype_questionpy_question);

return preg_replace_callback(
// The first two path segments are namespace and short name, and so more restrictive.
";qpy://static(/(?:[a-z_][a-z0-9_]{1,127}){2}(?:/[\w\-@:%+.~=]+)+);",
function (array $match) use ($question) {
$path = $match[1];
$url = \moodle_url::make_pluginfile_url(
$question->contextid,
"qtype_questionpy",
"static",
null,
"/" . $question->packagehash . dirname($path) . "/",
basename($path)
);

return $url->out();
},
$input
);
}
}
Loading

0 comments on commit c001ef2

Please sign in to comment.