Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#119: Statische Dateien ausliefern #125

Merged
merged 4 commits into from
Sep 18, 2024
Merged
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
13 changes: 12 additions & 1 deletion classes/api/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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 @@ -64,7 +75,7 @@ public function get_packages(): array {
* @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
112 changes: 102 additions & 10 deletions classes/api/package_api.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@

namespace qtype_questionpy\api;

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

Expand All @@ -31,22 +35,22 @@
* @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 qpy_http_client $client Guzzle client
* @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 +160,93 @@ 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;
}

$json = json_decode($e->getResponse()->getBody(), associative: true);
if (JSON_ERROR_NONE !== json_last_error()) {
// 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 +272,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 @@ -62,7 +63,7 @@ public static function execute(): array {
$transaction = $DB->start_delegated_transaction();

// Load and store packages from the application server.
$api = new api();
$api = di::get(api::class);
$packages = $api->get_packages();
$incomingpackageids = [];
foreach ($packages as $package) {
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);
}
}
}
Loading