From c2cf83ec52e59e320d0867c9effb37d6da1a438b Mon Sep 17 00:00:00 2001 From: erseco Date: Tue, 2 Jun 2026 23:09:08 +0100 Subject: [PATCH] Add developer lifecycle hooks Add a documented set of WordPress actions and filters (all prefixed exelearning_) at the plugin's main lifecycle boundaries so third-party developers and integrations can observe events and enrich presentation or metadata. - ELPX extraction: exelearning_before_elpx_extract, exelearning_after_elpx_extract - Metadata: exelearning_elpx_metadata (filter), exelearning_after_elpx_metadata_saved - REST save: exelearning_before_elpx_save, exelearning_after_elpx_save - Shortcode: exelearning_shortcode_atts, exelearning_preview_url, exelearning_shortcode_output (filters) - Styles: exelearning_after_style_installed, exelearning_after_style_deleted, exelearning_after_style_enabled_changed, exelearning_style_registry_entry (filter) - Static editor: exelearning_before_editor_install, exelearning_after_editor_install, exelearning_editor_install_failed All filters validate their return value defensively and restore required internal keys, so callbacks can add data but cannot drop or corrupt the plugin's own metadata, extraction hash, or style integrity fields. No hook weakens validation, capability/nonce checks, path-traversal protection, checksum verification, or the content-proxy security model. Adds tests/unit/DeveloperHooksTest.php and docs/HOOKS.md with a README section. Closes #44 --- README.md | 20 + docs/HOOKS.md | 402 +++++++++++++++++ includes/class-elp-file-service.php | 110 +++++ includes/class-elp-reprocessor.php | 36 +- includes/class-elp-upload-handler.php | 13 + includes/class-exelearning-rest-api.php | 30 ++ includes/class-static-editor-installer.php | 58 +++ includes/class-styles-service.php | 65 ++- public/class-shortcodes.php | 64 ++- readme.txt | 1 + tests/unit/DeveloperHooksTest.php | 491 +++++++++++++++++++++ 11 files changed, 1278 insertions(+), 12 deletions(-) create mode 100644 docs/HOOKS.md create mode 100644 tests/unit/DeveloperHooksTest.php diff --git a/README.md b/README.md index 659a6b5..e160055 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,26 @@ Administrators can upload eXeLearning style packages and control which styles th Uploaded ZIPs are validated against path traversal, absolute paths, oversize archives (default 20 MB, filterable via `exelearning_styles_max_zip_size`), and a strict file-extension allow-list. +## Developer hooks + +The plugin exposes a set of WordPress actions and filters (all prefixed with +`exelearning_`) at its main lifecycle boundaries, so you can observe events and +enrich presentation or metadata without modifying the plugin: + +- **ELPX extraction** — `exelearning_before_elpx_extract`, `exelearning_after_elpx_extract` +- **Metadata** — `exelearning_elpx_metadata` (filter), `exelearning_after_elpx_metadata_saved` +- **REST save** — `exelearning_before_elpx_save`, `exelearning_after_elpx_save` +- **Shortcode rendering** — `exelearning_shortcode_atts`, `exelearning_preview_url`, `exelearning_shortcode_output` (filters) +- **Styles** — `exelearning_after_style_installed`, `exelearning_after_style_deleted`, `exelearning_after_style_enabled_changed`, `exelearning_style_registry_entry` (filter) +- **Static editor install** — `exelearning_before_editor_install`, `exelearning_after_editor_install`, `exelearning_editor_install_failed` + +These hooks are limited to observation and presentation/metadata enrichment: they +cannot bypass validation, capability/nonce checks, path-traversal protection, +checksum verification, or the content-proxy security model. + +See [`docs/HOOKS.md`](docs/HOOKS.md) for the full reference, parameters, return +values, and usage examples. + ## Development For development, you can bring up a local WordPress environment with the plugin pre-installed: diff --git a/docs/HOOKS.md b/docs/HOOKS.md new file mode 100644 index 0000000..0dafa88 --- /dev/null +++ b/docs/HOOKS.md @@ -0,0 +1,402 @@ +# Developer hooks + +The eXeLearning plugin exposes a set of WordPress actions and filters at its main +lifecycle boundaries so third-party developers and institutional integrations can +observe events and enrich presentation or metadata. + +All hook names are prefixed with `exelearning_`. + +## Security model + +These hooks are intentionally limited to **observation** and **presentation/metadata +enrichment**. They do **not** let a callback bypass any security control. In +particular, no hook can: + +- Skip ZIP, MIME, or path-traversal validation. +- Skip capability or nonce checks. +- Change the trusted storage directories or the extraction hash. +- Disable CSP/security headers or the content-proxy model. +- Allow arbitrary remote editor download URLs, or weaken checksum/digest checks. +- Alter cleanup behavior in a way that could leave orphaned or unsafe files. + +Every filter validates its return value defensively. Required internal keys are +always restored from the plugin's trusted values, so a misbehaving callback can add +data but can never drop or corrupt the data the plugin depends on. + +--- + +## Actions + +### `exelearning_before_elpx_extract` + +Fires right before an `.elpx` archive is extracted, after it has already passed +open/validation and the zip-bomb count guard. Observation only — it must not be used +to bypass validation or change extraction behavior. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$file` | `string` | Source `.elpx` file path. | +| `$destination` | `string` | Destination directory the archive extracts into. | + +```php +add_action( + 'exelearning_before_elpx_extract', + function ( $file, $destination ) { + error_log( sprintf( 'About to extract %s into %s', $file, $destination ) ); + }, + 10, + 2 +); +``` + +### `exelearning_after_elpx_extract` + +Fires after an `.elpx` archive has been **successfully** extracted. It never fires on +a failed or partial extraction. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$file` | `string` | Source `.elpx` file path. | +| `$destination` | `string` | Final extraction directory. | +| `$metadata` | `array` | Metadata parsed from the archive. | + +```php +add_action( + 'exelearning_after_elpx_extract', + function ( $file, $destination, $metadata ) { + error_log( sprintf( 'ELPX extracted: %s', $file ) ); + }, + 10, + 3 +); +``` + +### `exelearning_after_elpx_metadata_saved` + +Fires after ELPX metadata has been written to the attachment post meta (both on +upload and on reprocessing). + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$attachment_id` | `int` | WordPress attachment ID. | +| `$metadata` | `array` | Final metadata array that was saved. | + +```php +add_action( + 'exelearning_after_elpx_metadata_saved', + function ( $attachment_id, $metadata ) { + // For example, index the resource in an external catalogue. + do_my_catalogue_sync( $attachment_id, $metadata ); + }, + 10, + 2 +); +``` + +### `exelearning_before_elpx_save` + +Fires before an existing `.elpx` file is replaced through the REST editor endpoint, +after the request has already passed capability and nonce checks. Observation only — +it must not change the storage path or replacement/cleanup behavior. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$attachment_id` | `int` | Attachment being updated. | +| `$old_file_path` | `string` | Current file path before replacement. | + +```php +add_action( + 'exelearning_before_elpx_save', + function ( $attachment_id, $old_file_path ) { + error_log( sprintf( 'Saving attachment %d', $attachment_id ) ); + }, + 10, + 2 +); +``` + +### `exelearning_after_elpx_save` + +Fires after an `.elpx` file has been successfully saved and committed: the new file +is written, the new content is extracted, and metadata is persisted. It never fires +on a failed save, extraction, or metadata persistence. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$attachment_id` | `int` | Updated attachment ID. | +| `$new_hash` | `string` | Hash of the new extracted content. | +| `$old_hash` | `string` | Previous extraction hash, or `''` if none. | + +```php +add_action( + 'exelearning_after_elpx_save', + function ( $attachment_id, $new_hash, $old_hash ) { + error_log( sprintf( 'Attachment %d now at %s', $attachment_id, $new_hash ) ); + }, + 10, + 3 +); +``` + +### `exelearning_after_style_installed` + +Fires after a style ZIP has been installed and registered. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$slug` | `string` | Style slug. | +| `$entry` | `array` | Final style registry entry that was persisted. | + +```php +add_action( + 'exelearning_after_style_installed', + function ( $slug, $entry ) { + error_log( sprintf( 'Style installed: %s', $slug ) ); + }, + 10, + 2 +); +``` + +### `exelearning_after_style_deleted` + +Fires after an uploaded style has been deleted (both the files on disk and the +registry entry). + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$slug` | `string` | Style slug that was deleted. | + +```php +add_action( + 'exelearning_after_style_deleted', + function ( $slug ) { + error_log( sprintf( 'Style deleted: %s', $slug ) ); + } +); +``` + +### `exelearning_after_style_enabled_changed` + +Fires after an uploaded style has been enabled or disabled. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$slug` | `string` | Style slug. | +| `$enabled` | `bool` | New enabled state. | + +```php +add_action( + 'exelearning_after_style_enabled_changed', + function ( $slug, $enabled ) { + error_log( sprintf( 'Style %s enabled=%s', $slug, $enabled ? 'yes' : 'no' ) ); + }, + 10, + 2 +); +``` + +### `exelearning_before_editor_install` + +Fires before the static editor installation starts, after the target version has +been resolved. Observation only — it must not change the download URL or skip +checksum/archive validation. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$version` | `string` | Requested editor version. | + +```php +add_action( + 'exelearning_before_editor_install', + function ( $version ) { + error_log( sprintf( 'Installing editor %s', $version ) ); + } +); +``` + +### `exelearning_after_editor_install` + +Fires after the static editor has been installed successfully. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$metadata` | `array` | Installed editor metadata (`version`, `installed_at`). | + +```php +add_action( + 'exelearning_after_editor_install', + function ( $metadata ) { + error_log( sprintf( 'Editor installed: %s', $metadata['version'] ) ); + } +); +``` + +### `exelearning_editor_install_failed` + +Fires when the static editor installation fails. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$error` | `WP_Error` | WP_Error describing the failure. | + +```php +add_action( + 'exelearning_editor_install_failed', + function ( $error ) { + error_log( sprintf( 'Editor install failed: %s', $error->get_error_message() ) ); + } +); +``` + +--- + +## Filters + +### `exelearning_elpx_metadata` + +Filters the ELPX metadata array before it is saved to attachment meta. Callbacks may +enrich the array with additional keys but **must return an array**. Required internal +keys (`_exelearning_title`, `_exelearning_description`, `_exelearning_license`, +`_exelearning_language`, `_exelearning_resource_type`, `_exelearning_extracted`, +`_exelearning_version`, `_exelearning_has_preview`) are always restored afterwards, +so this filter cannot drop or tamper with the plugin's own metadata or the extraction +hash. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$metadata` | `array` | Metadata array keyed by post meta key. | +| `$file` | `string` | Source `.elpx` file path, or `''` when unavailable. | +| `$elp_service` | `ExeLearning_Elp_File_Service` | The service that parsed the archive. | + +**Returns:** `array` — the enriched metadata array. + +```php +add_filter( + 'exelearning_elpx_metadata', + function ( $metadata, $file, $elp_service ) { + $metadata['_exelearning_department'] = 'Mathematics'; + return $metadata; + }, + 10, + 3 +); +``` + +### `exelearning_shortcode_atts` + +Filters the `[exelearning]` shortcode attributes after defaults are merged and before +rendering. The values the renderer relies on are re-sanitized afterwards, so this +filter cannot inject unsafe values or bypass the attachment/permission checks. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$atts` | `array` | Sanitized shortcode attributes. | +| `$file_id` | `int` | Attachment ID parsed from the shortcode. | + +**Returns:** `array` — the shortcode attributes. + +```php +add_filter( + 'exelearning_shortcode_atts', + function ( $atts, $file_id ) { + $atts['height'] = 900; + return $atts; + }, + 10, + 2 +); +``` + +### `exelearning_preview_url` + +Filters the preview URL before it is rendered into the iframe. The value is still +escaped with `esc_url()` at output time. This filter must **not** be used to bypass +the content-proxy security model; pointing the iframe at an unverified external +origin is unsupported. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$preview_url` | `string` | Proxy preview URL. | +| `$file_id` | `int` | Attachment ID. | +| `$extracted_dir` | `string` | Extraction hash/directory for the attachment. | + +**Returns:** `string` — the preview URL. + +```php +add_filter( + 'exelearning_preview_url', + function ( $preview_url, $file_id, $extracted_dir ) { + return add_query_arg( 'lang', 'en', $preview_url ); + }, + 10, + 3 +); +``` + +### `exelearning_shortcode_output` + +Filters the final shortcode HTML before it is returned. It receives the +already-rendered, escaped HTML and lets themes or integrations wrap or modify it. The +default output is unchanged when no callback is attached. Any HTML added by a callback +is its own responsibility to keep safe. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$html` | `string` | Rendered shortcode HTML. | +| `$file_id` | `int` | Attachment ID. | +| `$atts` | `array` | Shortcode attributes used to render the output. | + +**Returns:** `string` — the shortcode HTML. + +```php +add_filter( + 'exelearning_shortcode_output', + function ( $html, $file_id, $atts ) { + return '
' . $html . '
'; + }, + 10, + 3 +); +``` + +### `exelearning_style_registry_entry` + +Filters a style registry entry before it is persisted. **Must return an array**; a +non-array return is discarded. Required internal keys (`css_files`, `enabled`, +`installed_at`, `checksum`, `size`) are always restored from the trusted built entry +so this filter cannot inject unsafe paths, strip integrity fields, or bypass ZIP +validation. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$entry` | `array` | Style registry entry built from the validated ZIP. | +| `$slug` | `string` | Allocated style slug. | +| `$config` | `array` | Parsed style configuration. | + +**Returns:** `array` — the registry entry. + +```php +add_filter( + 'exelearning_style_registry_entry', + function ( $entry, $slug, $config ) { + $entry['category'] = 'institutional'; + return $entry; + }, + 10, + 3 +); +``` + +--- + +## Existing configuration filters + +The plugin also exposes a few low-level configuration filters used as safety limits: + +| Filter | Default | Purpose | +|--------|---------|---------| +| `exelearning_max_extract_files` | `10000` | Maximum number of files allowed in an ELPX archive. | +| `exelearning_max_extract_bytes` | `1073741824` (1 GB) | Maximum uncompressed extraction size. | +| `exelearning_styles_max_zip_size` | `20 MB` | Maximum uploaded style ZIP size. | +| `exelearning_content_origin` | `''` | Origin URL used when serving extracted content. | diff --git a/includes/class-elp-file-service.php b/includes/class-elp-file-service.php index a37dcba..1276c44 100644 --- a/includes/class-elp-file-service.php +++ b/includes/class-elp-file-service.php @@ -195,6 +195,22 @@ public function extract( string $file_path, string $destination ) { return new WP_Error( 'elp_too_many_files', 'ELP archive contains too many files.' ); } + /** + * Fires right before an .elpx archive is extracted. + * + * Intended for logging, audit trails, metrics, or external integrations. + * This is an observation point only: it runs after the archive has already + * passed open/validation and the zip-bomb count guard, and it must NOT be + * used to bypass validation, archive safety checks, path-traversal + * protection, or alter extraction behavior. + * + * @since 1.0.0 + * + * @param string $file_path Source .elpx file path. + * @param string $destination Destination directory the archive extracts into. + */ + do_action( 'exelearning_before_elpx_extract', $file_path, $destination ); + // Extract entry by entry instead of ZipArchive::extractTo(): this lets us // reject path traversal / absolute paths / stream wrappers, neutralize // symlink entries (we always write regular files), and cap the total @@ -213,6 +229,22 @@ public function extract( string $file_path, string $destination ) { } } + /** + * Fires after an .elpx archive has been successfully extracted. + * + * Only runs when extraction completed without error; it never fires on a + * failed or partial extraction. Intended for logging, audit trails, + * metrics, or external integrations. It is an observation point only and + * must NOT be used to bypass any validation or safety check. + * + * @since 1.0.0 + * + * @param string $file_path Source .elpx file path. + * @param string $destination Final extraction directory. + * @param array $metadata Metadata parsed from the archive. + */ + do_action( 'exelearning_after_elpx_extract', $file_path, $destination, $this->to_array() ); + return true; } @@ -428,4 +460,82 @@ public function to_array(): array { 'learningResourceType' => $this->metadata['learning_resource_type'], ); } + + /** + * Attachment meta keys that the plugin relies on internally. + * + * These are always preserved from the trusted, pre-filter values so a + * third-party callback on {@see 'exelearning_elpx_metadata'} cannot drop or + * corrupt the data the plugin needs to locate and render extracted content. + * + * @since 1.0.0 + * + * @var string[] + */ + const REQUIRED_META_KEYS = array( + '_exelearning_title', + '_exelearning_description', + '_exelearning_license', + '_exelearning_language', + '_exelearning_resource_type', + '_exelearning_extracted', + '_exelearning_version', + '_exelearning_has_preview', + ); + + /** + * Applies the metadata filter before ELPX metadata is persisted. + * + * Lets integrations enrich the attachment metadata array (for example, add + * custom keys for an LMS or catalogue) right before it is written to post + * meta. The return value is validated defensively: a non-array return is + * discarded, and every required internal key is restored from the trusted + * pre-filter values so a misbehaving callback can add keys but can never drop + * or overwrite the data the plugin depends on. + * + * @since 1.0.0 + * + * @param array $metadata Metadata array keyed by post meta key. + * @param string $file Source .elpx file path, or '' when unavailable. + * @param ExeLearning_Elp_File_Service $elp_service The service that parsed the archive. + * @return array Sanitized metadata array with all required internal keys preserved. + */ + public static function filter_metadata( array $metadata, string $file, $elp_service ): array { + // Snapshot trusted internal values before handing data to third parties. + $trusted = array(); + foreach ( self::REQUIRED_META_KEYS as $key ) { + if ( array_key_exists( $key, $metadata ) ) { + $trusted[ $key ] = $metadata[ $key ]; + } + } + + /** + * Filters the ELPX metadata array before it is saved to attachment meta. + * + * Callbacks may enrich the array with additional keys but MUST return an + * array. Required internal keys (see REQUIRED_META_KEYS) are always + * restored afterwards, so this filter cannot be used to drop or tamper + * with the plugin's own metadata, the extraction hash, or any security + * relevant value. + * + * @since 1.0.0 + * + * @param array $metadata Metadata array keyed by post meta key. + * @param string $file Source .elpx file path, or '' when unavailable. + * @param ExeLearning_Elp_File_Service $elp_service The service that parsed the archive. + * @return array Enriched metadata array. Must be an array. + */ + $filtered = apply_filters( 'exelearning_elpx_metadata', $metadata, $file, $elp_service ); + + if ( ! is_array( $filtered ) ) { + $filtered = array(); + } + + // Restore the trusted internal keys regardless of what the filter did. + foreach ( $trusted as $key => $value ) { + $filtered[ $key ] = $value; + } + + return $filtered; + } } diff --git a/includes/class-elp-reprocessor.php b/includes/class-elp-reprocessor.php index ef01362..9bd734c 100644 --- a/includes/class-elp-reprocessor.php +++ b/includes/class-elp-reprocessor.php @@ -338,14 +338,24 @@ public function extract_to_new_dir( $file_path ) { * @param bool $has_preview Whether index.html exists. */ public function apply_metadata( $attachment_id, $elp_service, $hash, $has_preview ) { - update_post_meta( $attachment_id, '_exelearning_title', $elp_service->get_title() ); - update_post_meta( $attachment_id, '_exelearning_description', $elp_service->get_description() ); - update_post_meta( $attachment_id, '_exelearning_license', $elp_service->get_license() ); - update_post_meta( $attachment_id, '_exelearning_language', $elp_service->get_language() ); - update_post_meta( $attachment_id, '_exelearning_resource_type', $elp_service->get_learning_resource_type() ); - update_post_meta( $attachment_id, '_exelearning_extracted', $hash ); - update_post_meta( $attachment_id, '_exelearning_version', $elp_service->get_version() ); - update_post_meta( $attachment_id, '_exelearning_has_preview', $has_preview ? '1' : '0' ); + $metadata = array( + '_exelearning_title' => $elp_service->get_title(), + '_exelearning_description' => $elp_service->get_description(), + '_exelearning_license' => $elp_service->get_license(), + '_exelearning_language' => $elp_service->get_language(), + '_exelearning_resource_type' => $elp_service->get_learning_resource_type(), + '_exelearning_extracted' => $hash, + '_exelearning_version' => $elp_service->get_version(), + '_exelearning_has_preview' => $has_preview ? '1' : '0', + ); + + // Allow integrations to enrich metadata (required keys are preserved). + $file = (string) get_attached_file( $attachment_id ); + $metadata = ExeLearning_Elp_File_Service::filter_metadata( $metadata, $file, $elp_service ); + + foreach ( $metadata as $key => $value ) { + update_post_meta( $attachment_id, $key, $value ); + } // Update attachment title/caption. wp_update_post( @@ -355,6 +365,16 @@ public function apply_metadata( $attachment_id, $elp_service, $hash, $has_previe 'post_content' => $elp_service->get_description(), ) ); + + /** + * Fires after ELPX metadata has been saved to the attachment. + * + * @since 1.0.0 + * + * @param int $attachment_id WordPress attachment ID. + * @param array $metadata Final metadata array that was saved. + */ + do_action( 'exelearning_after_elpx_metadata_saved', $attachment_id, $metadata ); } /** diff --git a/includes/class-elp-upload-handler.php b/includes/class-elp-upload-handler.php index 7ff59ee..ab772ac 100644 --- a/includes/class-elp-upload-handler.php +++ b/includes/class-elp-upload-handler.php @@ -135,6 +135,9 @@ public function process_elp_upload( $upload ) { '_exelearning_has_preview' => $has_preview ? '1' : '0', ); + // Allow integrations to enrich metadata (required keys are preserved). + $metadata = ExeLearning_Elp_File_Service::filter_metadata( $metadata, $file, $elp_service ); + $transient_key = 'exelearning_data_' . md5( $file ); set_transient( @@ -186,6 +189,16 @@ public function save_elp_metadata( $attachment_id ) { } delete_transient( $transient_key ); + + /** + * Fires after ELPX metadata has been saved to the attachment. + * + * @since 1.0.0 + * + * @param int $attachment_id WordPress attachment ID. + * @param array $metadata Final metadata array that was saved. + */ + do_action( 'exelearning_after_elpx_metadata_saved', $attachment_id, $data['metadata'] ); } } diff --git a/includes/class-exelearning-rest-api.php b/includes/class-exelearning-rest-api.php index d948967..7aab8d6 100644 --- a/includes/class-exelearning-rest-api.php +++ b/includes/class-exelearning-rest-api.php @@ -376,6 +376,21 @@ private function save_elp_file_locked( $attachment_id, $uploaded_file, $old_file // Save old extraction hash before it gets replaced. $old_hash = get_post_meta( $attachment_id, '_exelearning_extracted', true ); + /** + * Fires before an existing .elpx file is replaced via the REST editor. + * + * Observation point only (logging, audit trails, metrics). It runs after + * the request has already passed capability and nonce checks and must NOT + * be used to alter the storage path, skip validation, or change the + * replacement/cleanup behavior. + * + * @since 1.0.0 + * + * @param int $attachment_id Attachment being updated. + * @param string $old_file_path Current file path before replacement. + */ + do_action( 'exelearning_before_elpx_save', $attachment_id, $old_file_path ); + // Route the uploaded file through WordPress so the move (and MIME check) // goes via the wp_handle_upload() wrapper that Plugin Check accepts. require_once ABSPATH . 'wp-admin/includes/file.php'; @@ -461,6 +476,21 @@ private function save_elp_file_locked( $attachment_id, $uploaded_file, $old_file ) ); + /** + * Fires after an .elpx file has been successfully saved and committed. + * + * Only runs once the new file is written, the new content is extracted, + * and metadata is persisted; it never fires on a failed save, failed + * extraction, or failed metadata persistence. Observation point only. + * + * @since 1.0.0 + * + * @param int $attachment_id Updated attachment ID. + * @param string $new_hash Hash of the new extracted content. + * @param string $old_hash Previous extraction hash, or '' if none. + */ + do_action( 'exelearning_after_elpx_save', $attachment_id, $extraction['hash'], (string) $old_hash ); + return rest_ensure_response( $this->build_save_response( $attachment_id ) ); } diff --git a/includes/class-static-editor-installer.php b/includes/class-static-editor-installer.php index e93bacf..9b69228 100644 --- a/includes/class-static-editor-installer.php +++ b/includes/class-static-editor-installer.php @@ -151,9 +151,67 @@ public function handle_install_request() { public function install_latest_editor() { $version = $this->discover_latest_version(); if ( is_wp_error( $version ) ) { + $this->fire_install_failed( $version ); return $version; } + /** + * Fires before the static editor installation starts. + * + * Observation point only. It runs after the target version has been + * resolved and must NOT be used to change the download URL, skip checksum + * or archive validation, or alter the trusted install directory. + * + * @since 1.0.0 + * + * @param string $version Requested editor version. + */ + do_action( 'exelearning_before_editor_install', $version ); + + $result = $this->perform_editor_install( $version ); + + if ( is_wp_error( $result ) ) { + $this->fire_install_failed( $result ); + return $result; + } + + /** + * Fires after the static editor has been installed successfully. + * + * @since 1.0.0 + * + * @param array $metadata Installed editor metadata (version, installed_at). + */ + do_action( 'exelearning_after_editor_install', $result ); + + return $result; + } + + /** + * Fire the editor-install failure action. + * + * @since 1.0.0 + * + * @param WP_Error $error The failure that aborted installation. + */ + private function fire_install_failed( $error ) { + /** + * Fires when the static editor installation fails. + * + * @since 1.0.0 + * + * @param WP_Error $error WP_Error describing the failure. + */ + do_action( 'exelearning_editor_install_failed', $error ); + } + + /** + * Download, verify, and install the editor for a resolved version. + * + * @param string $version Resolved editor version. + * @return array|WP_Error Installed metadata on success, WP_Error on failure. + */ + private function perform_editor_install( $version ) { $asset_url = $this->get_asset_url( $version ); $tmp_file = $this->download_asset( $asset_url ); diff --git a/includes/class-styles-service.php b/includes/class-styles-service.php index 149bba6..1f60942 100644 --- a/includes/class-styles-service.php +++ b/includes/class-styles-service.php @@ -243,8 +243,19 @@ public static function set_uploaded_enabled( $slug, $enabled ) { if ( ! isset( $registry['uploaded'][ $slug ] ) ) { return new WP_Error( 'style_not_found', __( 'Style not found.', 'exelearning' ) ); } - $registry['uploaded'][ $slug ]['enabled'] = (bool) $enabled; + $enabled = (bool) $enabled; + $registry['uploaded'][ $slug ]['enabled'] = $enabled; self::save_registry( $registry ); + + /** + * Fires after an uploaded style has been enabled or disabled. + * + * @since 1.0.0 + * + * @param string $slug Style slug. + * @param bool $enabled New enabled state. + */ + do_action( 'exelearning_after_style_enabled_changed', $slug, $enabled ); return true; } @@ -287,6 +298,17 @@ public static function delete_uploaded( $slug ) { } unset( $registry['uploaded'][ $slug ] ); self::save_registry( $registry ); + + /** + * Fires after an uploaded style has been deleted. + * + * Runs after both the files on disk and the registry entry are removed. + * + * @since 1.0.0 + * + * @param string $slug Style slug that was deleted. + */ + do_action( 'exelearning_after_style_deleted', $slug ); return true; } @@ -336,11 +358,50 @@ public static function install_from_zip( $zip_path, $orig_name = '' ) { ); } - $entry = ExeLearning_Style_Package::build_entry( $config, $slug, $zip_path, $css_files ); + $entry = ExeLearning_Style_Package::build_entry( $config, $slug, $zip_path, $css_files ); + + /** + * Filters the style registry entry before it is persisted. + * + * Allows integrations to enrich the stored style metadata (for example, + * add a category or display label). Must return an array; a non-array + * return is discarded. Required internal keys (css_files, enabled, + * installed_at, checksum, size) are always restored from the trusted + * built entry so this filter cannot inject unsafe paths, strip integrity + * fields, or bypass ZIP validation. + * + * @since 1.0.0 + * + * @param array $entry Style registry entry built from the validated ZIP. + * @param string $slug Allocated style slug. + * @param array $config Parsed style configuration (theme.json / config). + * @return array Registry entry. Must be an array. + */ + $filtered = apply_filters( 'exelearning_style_registry_entry', $entry, $slug, $config ); + if ( is_array( $filtered ) ) { + // Restore trusted internal keys regardless of what the filter did. + foreach ( array( 'css_files', 'enabled', 'installed_at', 'checksum', 'size' ) as $required_key ) { + if ( array_key_exists( $required_key, $entry ) ) { + $filtered[ $required_key ] = $entry[ $required_key ]; + } + } + $entry = $filtered; + } + $registry = self::get_registry(); $registry['uploaded'][ $slug ] = $entry; self::save_registry( $registry ); + /** + * Fires after a style ZIP has been installed and registered. + * + * @since 1.0.0 + * + * @param string $slug Style slug. + * @param array $entry Final style registry entry that was persisted. + */ + do_action( 'exelearning_after_style_installed', $slug, $entry ); + $entry['id'] = $slug; $entry['name'] = $slug; return $entry; diff --git a/public/class-shortcodes.php b/public/class-shortcodes.php index 59f217e..15ccddf 100644 --- a/public/class-shortcodes.php +++ b/public/class-shortcodes.php @@ -50,6 +50,29 @@ public function display_exelearning( $atts, $content = null ) { // phpcs:ignore 'exelearning' ); + $file_id = intval( $atts['id'] ); + + /** + * Filters the shortcode attributes after defaults are merged and before rendering. + * + * Allows integrations to change presentation-level options (height, + * teacher mode, download button, download formats). The values the + * renderer relies on are re-sanitized below, so this filter cannot inject + * unsafe values, bypass the attachment/permission checks, or change which + * file is rendered in an unsafe way. + * + * @since 1.0.0 + * + * @param array $atts Sanitized shortcode attributes. + * @param int $file_id Attachment ID parsed from the shortcode. + * @return array Shortcode attributes. Must be an array. + */ + $filtered_atts = apply_filters( 'exelearning_shortcode_atts', $atts, $file_id ); + if ( is_array( $filtered_atts ) ) { + $atts = $filtered_atts; + } + + // Recompute the file ID from the (possibly filtered) attributes. $file_id = intval( $atts['id'] ); if ( ! $file_id ) { return $this->render_error( __( 'Invalid eXeLearning file ID.', 'exelearning' ) ); @@ -83,13 +106,50 @@ public function display_exelearning( $atts, $content = null ) { // phpcs:ignore if ( ! $extracted_dir || '1' !== $has_preview ) { // No preview available - show download link. - return $this->render_no_preview( $title, $file_url, $download_html ); + $html = $this->render_no_preview( $title, $file_url, $download_html ); + + /** This filter is documented below where the preview branch returns. */ + return apply_filters( 'exelearning_shortcode_output', $html, $file_id, $atts ); } // Build preview URL using secure proxy. $preview_url = ExeLearning_Content_Proxy::get_proxy_url( $extracted_dir ); - return $this->render_preview( $title, $preview_url, $height, $file_url, $teacher_mode_visible, $download_html ); + /** + * Filters the preview URL before it is rendered into the iframe. + * + * Allows integrations to wrap or adjust the preview URL. The value is + * still escaped with esc_url() at output time. This filter must NOT be + * used to bypass the plugin's content-proxy security model; pointing the + * iframe at an unverified external origin is unsupported. + * + * @since 1.0.0 + * + * @param string $preview_url Proxy preview URL. + * @param int $file_id Attachment ID. + * @param string $extracted_dir Extraction hash/directory for the attachment. + * @return string Preview URL. + */ + $preview_url = (string) apply_filters( 'exelearning_preview_url', $preview_url, $file_id, $extracted_dir ); + + $html = $this->render_preview( $title, $preview_url, $height, $file_url, $teacher_mode_visible, $download_html ); + + /** + * Filters the final shortcode HTML before it is returned. + * + * Receives the already-rendered, escaped HTML and lets themes or + * integrations wrap or modify it. The default output is unchanged when no + * callback is attached. Any HTML added by a callback is its own + * responsibility to keep safe. + * + * @since 1.0.0 + * + * @param string $html Rendered shortcode HTML. + * @param int $file_id Attachment ID. + * @param array $atts Shortcode attributes used to render the output. + * @return string Shortcode HTML. + */ + return apply_filters( 'exelearning_shortcode_output', $html, $file_id, $atts ); } /** diff --git a/readme.txt b/readme.txt index 1d353b3..be9d863 100644 --- a/readme.txt +++ b/readme.txt @@ -25,3 +25,4 @@ For more information, see the [full documentation on GitHub](https://github.com/ = 0.0.0 = * Initial release +* Add developer lifecycle hooks (actions and filters) for ELPX extraction, metadata, REST saves, shortcode rendering, styles, and static editor installation. See docs/HOOKS.md. diff --git a/tests/unit/DeveloperHooksTest.php b/tests/unit/DeveloperHooksTest.php new file mode 100644 index 0000000..d1287ab --- /dev/null +++ b/tests/unit/DeveloperHooksTest.php @@ -0,0 +1,491 @@ +cleanup_paths = array(); + delete_option( ExeLearning_Styles_Service::OPTION_REGISTRY ); + } + + /** + * Tear down test fixtures. + */ + public function tear_down() { + foreach ( $this->cleanup_paths as $path ) { + $this->recursive_delete( $path ); + } + delete_option( ExeLearning_Styles_Service::OPTION_REGISTRY ); + $storage = ExeLearning_Styles_Service::get_storage_dir(); + if ( is_dir( $storage ) ) { + ExeLearning_Styles_Service::recursive_delete( $storage ); + } + parent::tear_down(); + } + + /* -------------------------------------------------------------------- */ + /* ELPX metadata filter */ + /* -------------------------------------------------------------------- */ + + /** + * The metadata filter is invoked and can enrich the array. + */ + public function test_elpx_metadata_filter_enriches() { + add_filter( + 'exelearning_elpx_metadata', + static function ( $metadata ) { + $metadata['_exelearning_custom'] = 'enriched'; + return $metadata; + } + ); + + $result = ExeLearning_Elp_File_Service::filter_metadata( $this->sample_metadata(), '/tmp/example.elpx', null ); + + $this->assertSame( 'enriched', $result['_exelearning_custom'] ); + // Required keys are still present. + $this->assertSame( 'Sample', $result['_exelearning_title'] ); + $this->assertArrayHasKey( '_exelearning_extracted', $result ); + } + + /** + * A non-array return value is discarded and required keys are restored. + */ + public function test_elpx_metadata_filter_non_array_is_safe() { + add_filter( + 'exelearning_elpx_metadata', + static function () { + return 'not an array'; + } + ); + + $result = ExeLearning_Elp_File_Service::filter_metadata( $this->sample_metadata(), '', null ); + + $this->assertIsArray( $result ); + foreach ( ExeLearning_Elp_File_Service::REQUIRED_META_KEYS as $key ) { + $this->assertArrayHasKey( $key, $result, "Required key {$key} must survive a bad filter." ); + } + $this->assertSame( 'Sample', $result['_exelearning_title'] ); + } + + /** + * A callback cannot drop or overwrite required internal keys. + */ + public function test_elpx_metadata_filter_cannot_corrupt_required_keys() { + add_filter( + 'exelearning_elpx_metadata', + static function () { + // Try to wipe everything and forge the extraction hash. + return array( '_exelearning_extracted' => 'forged-hash' ); + } + ); + + $result = ExeLearning_Elp_File_Service::filter_metadata( $this->sample_metadata(), '', null ); + + // The trusted pre-filter value wins. + $this->assertSame( 'deadbeef', $result['_exelearning_extracted'] ); + $this->assertSame( 'Sample', $result['_exelearning_title'] ); + } + + /* -------------------------------------------------------------------- */ + /* Metadata saved action (real flow via the reprocessor) */ + /* -------------------------------------------------------------------- */ + + /** + * The saved action fires after metadata is persisted, with the right args. + */ + public function test_after_elpx_metadata_saved_fires() { + $captured = array(); + add_action( + 'exelearning_after_elpx_metadata_saved', + static function ( $attachment_id, $metadata ) use ( &$captured ) { + $captured[] = array( $attachment_id, $metadata ); + }, + 10, + 2 + ); + + $fixture = $this->make_elpx_attachment(); + $id = $fixture['id']; + + ( new ExeLearning_Reprocessor() )->reprocess( $id ); + $this->cleanup_paths[] = $this->extraction_dir( get_post_meta( $id, '_exelearning_extracted', true ) ); + + $this->assertGreaterThan( 0, did_action( 'exelearning_after_elpx_metadata_saved' ) ); + $this->assertSame( $id, $captured[0][0] ); + $this->assertIsArray( $captured[0][1] ); + $this->assertArrayHasKey( '_exelearning_extracted', $captured[0][1] ); + } + + /** + * The metadata filter is wired into the real persistence flow. + */ + public function test_elpx_metadata_filter_persists_enrichment() { + add_filter( + 'exelearning_elpx_metadata', + static function ( $metadata ) { + $metadata['_exelearning_custom'] = 'lms-42'; + return $metadata; + } + ); + + $fixture = $this->make_elpx_attachment(); + $id = $fixture['id']; + + ( new ExeLearning_Reprocessor() )->reprocess( $id ); + $this->cleanup_paths[] = $this->extraction_dir( get_post_meta( $id, '_exelearning_extracted', true ) ); + + $this->assertSame( 'lms-42', get_post_meta( $id, '_exelearning_custom', true ) ); + } + + /* -------------------------------------------------------------------- */ + /* Shortcode filters */ + /* -------------------------------------------------------------------- */ + + /** + * The atts filter can change a presentation attribute. + */ + public function test_shortcode_atts_filter_changes_height() { + add_filter( + 'exelearning_shortcode_atts', + static function ( $atts ) { + $atts['height'] = 900; + return $atts; + } + ); + + $result = ( new ExeLearning_Shortcodes() )->display_exelearning( array( 'id' => $this->previewable_attachment() ) ); + + $this->assertStringContainsString( '900px', $result ); + } + + /** + * Unsafe values returned by the atts filter are re-sanitized. + */ + public function test_shortcode_atts_filter_values_are_resanitized() { + add_filter( + 'exelearning_shortcode_atts', + static function ( $atts ) { + $atts['height'] = 'evil">'; + return $atts; + } + ); + + $result = ( new ExeLearning_Shortcodes() )->display_exelearning( array( 'id' => $this->previewable_attachment() ) ); + + $this->assertStringNotContainsString( '