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( '