prefix and fixed strings, and every // value goes through $wpdb->prepare() or $wpdb->insert/update/delete. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching class Content_Integrity { public static function init() { add_action( 'save_post_slms_lesson', array( __CLASS__, 'on_lesson_save' ), 20, 2 ); add_action( 'save_post_slms_quiz', array( __CLASS__, 'on_quiz_save' ), 20, 2 ); } /** * Compute a content hash for a lesson (and its quiz if present). */ public static function compute_content_hash( $lesson_id ) { $lesson = get_post( $lesson_id ); if ( ! $lesson ) { return ''; } $content = $lesson->post_content; // Include quiz questions in the hash if the lesson has a quiz $quiz = get_posts( array( 'post_type' => 'slms_quiz', 'posts_per_page' => 1, 'meta_key' => '_slms_parent_lesson', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- course structure is stored as post meta. 'meta_value' => $lesson_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- course structure is stored as post meta. 'fields' => 'ids', ) ); if ( ! empty( $quiz ) ) { $content .= '||quiz||' . self::quiz_hash_data( $quiz[0] ); } return md5( $content ); } /** * Compute hash of lesson content only (without quiz). */ public static function compute_lesson_only_hash( $lesson_id ) { $lesson = get_post( $lesson_id ); return $lesson ? md5( $lesson->post_content ) : ''; } /** * Compute hash of quiz questions only. */ public static function compute_quiz_hash( $quiz_id ) { return md5( self::quiz_hash_data( $quiz_id ) ); } /** * What a quiz's change detection hashes: its stored questions (exactly as before 1.1.0, so * existing hashes still match), plus anything accessnow_slms_content_hash_data adds. * * @param int $quiz_id Quiz ID. * @return string */ private static function quiz_hash_data( $quiz_id ) { $data = maybe_serialize( get_post_meta( $quiz_id, '_slms_questions', true ) ); /** * The text a quiz's change detection hashes. Add what else counts as the quiz's content, * e.g. the questions of a question bank it draws from; a change then asks students to redo it. * * @since 1.1.0 * * @param string $data Serialised stored questions. * @param int $quiz_id Quiz ID. */ return (string) apply_filters( 'accessnow_slms_content_hash_data', $data, (int) $quiz_id ); } /** * When a lesson is saved, check if its content changed and invalidate progress if needed. */ public static function on_lesson_save( $post_id, $post ) { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } if ( 'publish' !== $post->post_status ) { return; } $unit_id = get_post_meta( $post_id, '_slms_parent_unit', true ); $course_id = $unit_id ? get_post_meta( $unit_id, '_slms_parent_course', true ) : 0; if ( ! $course_id ) { return; } $mode = get_post_meta( $course_id, '_slms_content_change_mode', true ); if ( ! $mode || 'off' === $mode ) { return; } // Only invalidate for lesson content changes if mode includes lessons if ( 'lesson' !== $mode && 'both' !== $mode ) { return; } self::invalidate_if_changed( $post_id, $course_id ); } /** * When a quiz is saved, check if its questions changed and invalidate the parent lesson's progress. */ public static function on_quiz_save( $post_id, $post ) { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } if ( 'publish' !== $post->post_status ) { return; } $lesson_id = get_post_meta( $post_id, '_slms_parent_lesson', true ); if ( ! $lesson_id ) { return; } $unit_id = get_post_meta( $lesson_id, '_slms_parent_unit', true ); $course_id = $unit_id ? get_post_meta( $unit_id, '_slms_parent_course', true ) : 0; if ( ! $course_id ) { return; } $mode = get_post_meta( $course_id, '_slms_content_change_mode', true ); if ( ! $mode || 'off' === $mode ) { return; } // Only invalidate for quiz changes if mode includes quizzes if ( 'quiz' !== $mode && 'both' !== $mode ) { return; } self::invalidate_if_changed( $lesson_id, $course_id ); } /** * Compare current content hash with stored hashes in progress records. * If different, set progress status to 'invalidated'. */ private static function invalidate_if_changed( $lesson_id, $course_id ) { global $wpdb; $current_hash = self::compute_content_hash( $lesson_id ); if ( ! $current_hash ) { return; } $table = $wpdb->prefix . 'slms_progress'; // Find all completed progress records for this lesson where the hash differs $affected = $wpdb->get_results( $wpdb->prepare( "SELECT id, content_hash FROM {$wpdb->prefix}slms_progress WHERE lesson_id = %d AND status = 'complete'", $lesson_id ) ); $invalidated = 0; foreach ( $affected as $row ) { if ( $row->content_hash && $row->content_hash !== $current_hash ) { $invalidated += (int) $wpdb->update( $table, array( 'status' => 'invalidated' ), array( 'id' => $row->id ), array( '%s' ), array( '%d' ) ); } } // Students who had finished the course have work to redo again. if ( $invalidated ) { Enrollment::reopen_course( $course_id ); } } }