Commit efffd1a4 authored by Artem Baranovskyi's avatar Artem Baranovskyi
Browse files

Added post data sanitising before passing to the external service.

Added user's capability check.
Few plugin API logic optimizing.
Added http curl setting script.
Added proper exception naming.

#4
parent 95948a42
Showing with 130 additions and 40 deletions
+130 -40
...@@ -35,6 +35,7 @@ use local_asystgrade\utils; ...@@ -35,6 +35,7 @@ use local_asystgrade\utils;
try { try {
require_login(); require_login();
require_capability('mod/assign:grade', context_system::instance());
} catch (coding_exception | moodle_exception $e) { } catch (coding_exception | moodle_exception $e) {
debugging($e->getMessage()); debugging($e->getMessage());
redirect( redirect(
...@@ -43,53 +44,74 @@ try { ...@@ -43,53 +44,74 @@ try {
); );
} }
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$data = json_decode(file_get_contents('php://input'), true); throw new moodle_exception('invalidmethod', 'local_asystgrade');
}
if ($data) { $data = json_decode(file_get_contents('php://input'), true);
// Preparing Flask API.
try {
$apiendpoint = utils::get_api_endpoint();
} catch (dml_exception $e) {
debugging('Failed to get API endpoint setting: ' . $e->getMessage());
}
$httpclient = new http_client(); if ($data) {
$apiclient = client::getInstance($apiendpoint, $httpclient); // Preparing Flask API.
try {
$apiendpoint = utils::get_api_endpoint();
} catch (dml_exception $e) {
debugging('Failed to get API endpoint setting: ' . $e->getMessage());
}
$maxretries = 3; $httpclient = new http_client();
$attempts = 0; $apiclient = client::getInstance($apiendpoint, $httpclient);
$success = false;
while ($attempts < $maxretries && !$success) { $response = retry_api_request($apiclient, $data);
try { $grades = json_decode($response, true);
// Sending data to Flask and obtaining an answer.
$response = $apiclient->send_data($data); // Check JSON validity.
$success = true; if (json_last_error() !== JSON_ERROR_NONE) {
} catch (Exception $e) { debugging('JSON decode error: ' . json_last_error_msg());
$attempts++; throw new moodle_exception('invalidjson', 'local_asystgrade', '', json_last_error_msg());
debugging('API request error: ' . $e->getMessage()); } else {
if ($attempts >= $maxretries) { echo json_encode(['success' => true, 'grades' => $grades]);
echo json_encode(['error' => 'A server error occurred. Please try again later.']); }
exit; // Ensure to stop further processing. } 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) { * Validates the provided request payload data array.
echo json_encode(['success' => true, 'grades' => $grades]); *
} else { * @param array $data The data to validate.
debugging('JSON decode error: ' . json_last_error_msg()); * @return array The cleaned data.
echo json_encode(['error' => 'Invalid JSON from Flask API']); * @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']);
} }
...@@ -43,6 +43,8 @@ class http_client implements http_client_interface { ...@@ -43,6 +43,8 @@ class http_client implements http_client_interface {
* @throws Exception * @throws Exception
*/ */
public function post(string $url, array $data): bool|string { public function post(string $url, array $data): bool|string {
global $CFG;
require_once($CFG->libdir . '/filelib.php');
$curl = new curl(); $curl = new curl();
$options = [ $options = [
'CURLOPT_HTTPHEADER' => ['Content-Type: application/json'], 'CURLOPT_HTTPHEADER' => ['Content-Type: application/json'],
......
...@@ -28,3 +28,13 @@ $string['apiendpoint'] = 'API Endpoint'; ...@@ -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['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['pluginname'] = 'ASYST API Moodle integration plugin';
$string['privacy:metadata'] = 'The AsystGrade plugin does not store any personal data.'; $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.';
...@@ -172,6 +172,15 @@ In this case it is possible to change an API address from http://127.0.0.1:5001/ ...@@ -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. If ASYST ML microservice is running, the grade will appear at every student's answer.
![Grading result](https://transfer.hft-stuttgart.de/gitlab/ulrike.pado/asyst-moodle-plugin/-/raw/asyst-moodle-plugin/images/Grading%20result.png) ![Grading result](https://transfer.hft-stuttgart.de/gitlab/ulrike.pado/asyst-moodle-plugin/-/raw/asyst-moodle-plugin/images/Grading%20result.png)
### 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: The structure of request to ASYST ML Backend:
~~~JSON ~~~JSON
......
<?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";
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment