diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 8f86bb7..b02827a 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -538,6 +538,9 @@ $routes->get('grading/(:segment)/(:num)/(:num)', 'View\GradingController::show/$ $routes->post('grading/update', 'View\GradingController::update'); $routes->get('grading', 'View\GradingController::grading'); $routes->post('grading/toggle-score-lock', 'View\GradingController::toggleScoreLock', ['filter' => 'auth:read']); +$routes->get('grading/toggle-score-lock', static function () { + return 'GET route hit. Something is calling this route incorrectly.'; +}); $routes->post('grading/lock-all-scores', 'View\GradingController::lockAllScores', ['filter' => 'auth:read']); $routes->post('grading/release-scores', 'View\GradingController::toggleParentScoresRelease', ['filter' => 'auth:read']); $routes->post('grading/refresh-semester-scores', 'View\GradingController::refreshSemesterScores', ['filter' => 'auth:read']); diff --git a/app/Controllers/ProofreadController.php b/app/Controllers/ProofreadController.php index d8e64a4..1716ae6 100644 --- a/app/Controllers/ProofreadController.php +++ b/app/Controllers/ProofreadController.php @@ -10,8 +10,13 @@ class ProofreadController extends ResourceController { // Basic per-IP throttling: 10 requests per minute $throttler = service('throttler'); - $key = 'proofread-' . $this->request->getIPAddress(); - if (!$throttler->check($key, 10, MINUTE)) { + + // Cache/throttler keys cannot contain reserved characters. + // Raw IPv6 addresses contain ":", so hash the IP first. + $ip = $this->request->getIPAddress(); + $key = 'proofread-' . hash('sha256', $ip); + + if (! $throttler->check($key, 10, MINUTE)) { return $this->respond([ 'ok' => false, 'error' => 'Too many requests. Try again in a minute.', @@ -20,8 +25,9 @@ class ProofreadController extends ResourceController } // Accept form-urlencoded payload to play nicely with CSRF protection - $validation = service('validation'); $payload = sanitize_request_value($this->request->getPost(['text'])); + + $validation = service('validation'); $validation->setRules([ 'text' => 'required|min_length[1]|max_length[20000]', ]); @@ -36,20 +42,34 @@ class ProofreadController extends ResourceController $text = (string) $payload['text']; - $client = \Config\Services::curlrequest(['timeout' => 10]); + $client = \Config\Services::curlrequest([ + 'timeout' => 10, + ]); try { $resp = $client->post('https://api.languagetool.org/v2/check', [ - 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], 'form_params' => [ 'text' => $text, 'language' => 'en-US', ], ]); + $result = json_decode($resp->getBody(), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return $this->respond([ + 'ok' => false, + 'error' => 'Invalid response from proofread service.', + 'csrfHash' => csrf_hash(), + ], 502); + } + return $this->respond([ 'ok' => true, - 'result' => json_decode($resp->getBody(), true), + 'result' => $result, 'csrfHash' => csrf_hash(), ]); } catch (\Throwable $e) { @@ -60,4 +80,4 @@ class ProofreadController extends ResourceController ], 502); } } -} +} \ No newline at end of file diff --git a/app/Controllers/View/ScoreCommentController.php b/app/Controllers/View/ScoreCommentController.php index 5f9d0ae..a1390b4 100644 --- a/app/Controllers/View/ScoreCommentController.php +++ b/app/Controllers/View/ScoreCommentController.php @@ -231,18 +231,36 @@ class ScoreCommentController extends BaseController $rawCommentsBuilder->groupEnd(); } + if (!$isAllSections && $classSectionId > 0) { + // Prefer the exact class-section row over legacy NULL class-section rows. + // Without this, duplicate rows can overwrite each other unpredictably. + $rawCommentsBuilder + ->orderBy("CASE WHEN class_section_id = {$classSectionId} THEN 0 ELSE 1 END", '', false) + ->orderBy('id', 'DESC'); + } else { + $rawCommentsBuilder->orderBy('id', 'DESC'); + } + $rawComments = $rawCommentsBuilder->findAll(); $existingAttendance = []; foreach ($rawComments as $c) { - $sid = (int)$c['student_id']; - $typ = (string)$c['score_type']; + $sid = (int) $c['student_id']; + $typ = strtolower(trim((string) $c['score_type'])); + + // The query is ordered with the best row first. Do not let legacy/older + // duplicate rows overwrite the selected comment/review. + if (isset($comments[$sid][$typ])) { + continue; + } + $comments[$sid][$typ] = [ 'comment' => $c['comment'] ?? null, 'comment_review' => $c['comment_review'] ?? null, 'commented_by' => $c['commented_by'] ?? null, 'reviewed_by' => $c['reviewed_by'] ?? null, ]; + if ($typ === 'attendance') { $existingAttendance[$sid] = $c; } @@ -435,6 +453,16 @@ class ScoreCommentController extends BaseController $existingQuery->groupEnd(); } + if (is_int($classSectionId) && $classSectionId > 0) { + // Prefer updating the real class-section row. If only a legacy NULL row + // exists, it will be picked next and migrated to the class section below. + $existingQuery + ->orderBy("CASE WHEN class_section_id = {$classSectionId} THEN 0 ELSE 1 END", '', false) + ->orderBy('id', 'DESC'); + } else { + $existingQuery->orderBy('id', 'DESC'); + } + $existing = $existingQuery->first(); $data = [ diff --git a/app/Views/grading/comments.php b/app/Views/grading/comments.php index dd5dd19..f20514d 100644 --- a/app/Views/grading/comments.php +++ b/app/Views/grading/comments.php @@ -32,7 +32,7 @@ $lockAttr = $scoresLocked ? 'disabled' : '';