diff --git a/api.php b/api.php index 8dfb2a23c9ce684ad6461ba704deccd61ef6b212..e6343d97e319e85b778b8e0574805743a339c2a7 100755 --- a/api.php +++ b/api.php @@ -35,6 +35,7 @@ use local_asystgrade\utils; try { require_login(); + require_capability('mod/assign:grade', context_system::instance()); } catch (coding_exception | moodle_exception $e) { debugging($e->getMessage()); redirect( @@ -43,53 +44,74 @@ try { ); } -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $data = json_decode(file_get_contents('php://input'), true); +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + throw new moodle_exception('invalidmethod', 'local_asystgrade'); +} - if ($data) { - // Preparing Flask API. - try { - $apiendpoint = utils::get_api_endpoint(); - } catch (dml_exception $e) { - debugging('Failed to get API endpoint setting: ' . $e->getMessage()); - } +$data = json_decode(file_get_contents('php://input'), true); - $httpclient = new http_client(); - $apiclient = client::getInstance($apiendpoint, $httpclient); +if ($data) { + // Preparing Flask API. + try { + $apiendpoint = utils::get_api_endpoint(); + } catch (dml_exception $e) { + debugging('Failed to get API endpoint setting: ' . $e->getMessage()); + } - $maxretries = 3; - $attempts = 0; - $success = false; + $httpclient = new http_client(); + $apiclient = client::getInstance($apiendpoint, $httpclient); - while ($attempts < $maxretries && !$success) { - try { - // Sending data to Flask and obtaining an answer. - $response = $apiclient->send_data($data); - $success = true; - } catch (Exception $e) { - $attempts++; - debugging('API request error: ' . $e->getMessage()); - if ($attempts >= $maxretries) { - echo json_encode(['error' => 'A server error occurred. Please try again later.']); - exit; // Ensure to stop further processing. - } - } - } + $response = retry_api_request($apiclient, $data); + $grades = json_decode($response, true); + + // Check JSON validity. + if (json_last_error() !== JSON_ERROR_NONE) { + debugging('JSON decode error: ' . json_last_error_msg()); + throw new moodle_exception('invalidjson', 'local_asystgrade', '', json_last_error_msg()); + } else { + echo json_encode(['success' => true, 'grades' => $grades]); + } +} else { + echo json_encode(['error' => 'No data received']); +} - if ($success) { - $grades = json_decode($response, true); - // Check JSON validity. - if (json_last_error() === JSON_ERROR_NONE) { - echo json_encode(['success' => true, 'grades' => $grades]); - } else { - debugging('JSON decode error: ' . json_last_error_msg()); - echo json_encode(['error' => 'Invalid JSON from Flask API']); +/** + * Validates the provided request payload data array. + * + * @param array $data The data to validate. + * @return array The cleaned data. + * @throws moodle_exception If the data is invalid. + */ +function validate_data($data): array { + if (!isset($data['referenceAnswer'], $data['studentAnswers']) || !is_array($data['studentAnswers'])) { + throw new moodle_exception('invalidrequest', 'local_asystgrade'); + } + return [ + 'referenceAnswer' => clean_param($data['referenceAnswer'], PARAM_TEXT), + 'studentAnswers' => array_map(fn($answer) => clean_param($answer, PARAM_TEXT), $data['studentAnswers']), + ]; +} + +/** + * Retries an API request a specified number of times. + * + * @param object $apiclient The API client to use for the request. + * @param array $payload The data to send in the request. + * @param int $maxretries The maximum number of retry attempts. + * @return mixed The response from the API client. + * @throws moodle_exception If the API request fails after the maximum retries. + */ +function retry_api_request($apiclient, $payload, $maxretries = 3): mixed +{ + for ($attempts = 0; $attempts < $maxretries; $attempts++) { + try { + return $apiclient->send_data(validate_data($payload)); + } catch (Exception $e) { + debugging('API request error: ' . $e->getMessage()); + if ($attempts + 1 === $maxretries) { + throw new moodle_exception('apifailure', 'local_asystgrade'); } } - } else { - echo json_encode(['error' => 'No data received']); } -} else { - echo json_encode(['error' => 'Invalid request method']); } diff --git a/classes/api/http_client.php b/classes/api/http_client.php index fdc8e755e39c007ae9e0213dd8e2ef9607c4bb5d..47ffb83905f542b3b069fbeda77d100fddce37b8 100755 --- a/classes/api/http_client.php +++ b/classes/api/http_client.php @@ -43,6 +43,8 @@ class http_client implements http_client_interface { * @throws Exception */ public function post(string $url, array $data): bool|string { + global $CFG; + require_once($CFG->libdir . '/filelib.php'); $curl = new curl(); $options = [ 'CURLOPT_HTTPHEADER' => ['Content-Type: application/json'], diff --git a/lang/en/local_asystgrade.php b/lang/en/local_asystgrade.php index 5bf056b4b3a153eafc5398fe469659223f1e46b7..a7aab5061cafc3143a0a582d02702f22039790eb 100755 --- a/lang/en/local_asystgrade.php +++ b/lang/en/local_asystgrade.php @@ -28,3 +28,13 @@ $string['apiendpoint'] = 'API Endpoint'; $string['apiendpoint_desc'] = 'The endpoint of the AsystGrade API should be changed if you set ML Backend at remote server.'; $string['pluginname'] = 'ASYST API Moodle integration plugin'; $string['privacy:metadata'] = 'The AsystGrade plugin does not store any personal data.'; + +// Error messages. +$string['loginerror'] = 'You must log in with sufficient permissions to access this page.'; +$string['invalidmethod'] = 'Invalid request method. Only POST requests are allowed.'; +$string['invalidrequest'] = 'Invalid request payload. Required fields are missing or improperly formatted.'; +$string['invalidanswers'] = 'Invalid student answers provided.'; +$string['invalidjson'] = 'Failed to parse JSON response from the server: {$a}'; +$string['apifailure'] = 'The grading API failed after multiple attempts. Please try again later.'; +$string['norequestdata'] = 'No data received from the client.'; + diff --git a/readme.md b/readme.md index a6787629710875328aa23e76649445db94038e8b..f9362c3690c35b938ffb2c6a60f1aabd70d578b8 100755 --- a/readme.md +++ b/readme.md @@ -172,6 +172,15 @@ In this case it is possible to change an API address from http://127.0.0.1:5001/ If ASYST ML microservice is running, the grade will appear at every student's answer.  +### Other important settings +Since Moodle's Curl wrapper is used, it is also necessary to set a few HTTP security properties at the page /admin/settings.php?section=httpsecurity: +- remove from cURL blocked hosts list 127.0.0.0/8 and localhost. +- add to cURL allowed ports list 5001 (or other one that you use for an external custom flask server). + +If you are using default flask local server, you could also just run update_http_settings script for that: +~~~php +php ./local/asystgrade/update_http_settings.php +~~~ The structure of request to ASYST ML Backend: ~~~JSON diff --git a/update_http_settings.php b/update_http_settings.php new file mode 100755 index 0000000000000000000000000000000000000000..bb3d17ffacb595e1b66e51f07100480a9de7f07e --- /dev/null +++ b/update_http_settings.php @@ -0,0 +1,47 @@ +<?php +// This file is part of Moodle - https://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 <https://www.gnu.org/licenses/>. + +/** + * CLI Script for the local_asystgrade plugin to set HTTP Curl port and domain permissions. + * + * @package local_asystgrade + * @copyright 2024 Artem Baranovskyi <artem.baranovsky1980@gmail.com> + * @copyright based on work by 2023 Ulrike Pado <ulrike.pado@hft-stuttgart.de>, + * @copyright Yunus Eryilmaz & Larissa Kirschner <https://link.springer.com/article/10.1007/s40593-023-00383-w> + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Define CLI_SCRIPT to indicate this script is being run from the command line. + */ +const CLI_SCRIPT = true; +require('config.php'); + +global $CFG, $DB; + +// Remove 127.0.0.0/8 and localhost from blocked hosts. +$blockedhosts = get_config('core', 'curlsecurityblockedhosts'); +$newblockedhosts = str_replace(['127.0.0.0/8', 'localhost'], '', $blockedhosts); +set_config('curlsecurityblockedhosts', trim($newblockedhosts, ',')); + +// Add 5001 to allowed ports. +$allowedports = get_config('core', 'curlsecurityallowedport'); +$newallowedports = $allowedports ? $allowedports . "\r\n5001" : '5001'; +set_config('curlsecurityallowedport', $newallowedports); + +echo "Settings updated.\n";