diff --git a/includes/class-content-proxy.php b/includes/class-content-proxy.php index 4bb7cbb..03f2d51 100644 --- a/includes/class-content-proxy.php +++ b/includes/class-content-proxy.php @@ -258,7 +258,7 @@ private function serve_css_with_rewritten_urls( $full_path, $hash, $mime_type, $ return; } - $base_url = rest_url( 'exelearning/v1/content/' . $hash . '/' ); + $base_url = self::get_uploads_url( $hash ); // Get the directory of the current CSS file for resolving relative paths. $current_dir = ''; @@ -301,7 +301,8 @@ function ( $matches ) use ( $base_url, $current_dir ) { * @return string Modified HTML with absolute URLs. */ private function rewrite_relative_urls( $html, $hash, $file_path = '' ) { - $base_url = rest_url( 'exelearning/v1/content/' . $hash . '/' ); + $uploads_url = self::get_uploads_url( $hash ); + $proxy_url = self::get_proxy_url( $hash, '' ); // Get the directory of the current file for resolving relative paths. $current_dir = ''; @@ -325,7 +326,7 @@ private function rewrite_relative_urls( $html, $hash, $file_path = '' ) { foreach ( $patterns as $pattern ) { $html = preg_replace_callback( $pattern, - function ( $matches ) use ( $base_url, $current_dir ) { + function ( $matches ) use ( $uploads_url, $proxy_url, $current_dir ) { $prefix = $matches[1]; $attr = $matches[2]; $url = $matches[3]; @@ -339,26 +340,27 @@ function ( $matches ) use ( $base_url, $current_dir ) { // Resolve the relative URL based on current directory. $resolved_path = $this->resolve_relative_path( $current_dir, $url ); - // Build absolute URL. - $absolute_url = $base_url . $resolved_path; + // HTML files go through the proxy (for CSP headers); + // all other assets are served directly from uploads. + $base_url = self::is_html_path( $resolved_path ) ? $proxy_url : $uploads_url; - return $prefix . $attr . esc_url( $absolute_url ) . $end_quote; + return $prefix . $attr . esc_url( $base_url . $resolved_path ) . $end_quote; }, $html ); } - // Also handle url() in inline styles. + // Also handle url() in inline styles (never HTML, always assets). $html = preg_replace_callback( '/url\s*\(\s*["\']?(?!https?:\/\/|data:|\/\/|#)([^"\')\s]+)["\']?\s*\)/i', - function ( $matches ) use ( $base_url, $current_dir ) { + function ( $matches ) use ( $uploads_url, $current_dir ) { $url = $matches[1]; if ( empty( $url ) || '/' === $url[0] ) { return $matches[0]; } // Resolve the relative URL based on current directory. $resolved_path = $this->resolve_relative_path( $current_dir, $url ); - return 'url("' . esc_url( $base_url . $resolved_path ) . '")'; + return 'url("' . esc_url( $uploads_url . $resolved_path ) . '")'; }, $html ); @@ -366,6 +368,19 @@ function ( $matches ) use ( $base_url, $current_dir ) { return $html; } + /** + * Check if a file path points to an HTML file. + * + * @param string $path File path to check. + * @return bool True if the path ends with .html or .htm. + */ + private static function is_html_path( $path ) { + // Strip query string and fragment before checking extension. + $clean_path = strtok( $path, '?#' ); + $extension = strtolower( pathinfo( $clean_path, PATHINFO_EXTENSION ) ); + return 'html' === $extension || 'htm' === $extension; + } + /** * Resolve a relative path against a base directory. * @@ -501,4 +516,20 @@ public static function get_proxy_url( $hash, $file = 'index.html' ) { } return rest_url( 'exelearning/v1/content/' . $hash . '/' . $file ); } + + /** + * Generate a direct uploads URL for the given hash and file. + * + * Sub-assets (CSS, JS, images, fonts) are served directly from the uploads + * directory to avoid 404s on hosted environments where the web server + * intercepts requests with static file extensions. + * + * @param string $hash Extraction hash. + * @param string $file File path (default: empty). + * @return string Uploads URL. + */ + public static function get_uploads_url( $hash, $file = '' ) { + $upload_dir = wp_upload_dir(); + return trailingslashit( $upload_dir['baseurl'] ) . 'exelearning/' . $hash . '/' . $file; + } } diff --git a/includes/class-elp-upload-handler.php b/includes/class-elp-upload-handler.php index 6cc1168..16db94b 100644 --- a/includes/class-elp-upload-handler.php +++ b/includes/class-elp-upload-handler.php @@ -189,41 +189,51 @@ public function exelearning_delete_extracted_folder( $post_id ) { } /** - * Creates a security .htaccess file to block direct access to extracted content. + * Creates a security .htaccess file to control direct access to extracted content. * - * All content must be served through the secure proxy controller. + * HTML files are blocked (must be served through the secure proxy for CSP headers). + * Static assets (CSS, JS, images, fonts, media) are allowed for direct serving, + * which avoids 403/404 errors on hosted environments where the web server + * intercepts requests with static file extensions. */ private function create_security_htaccess() { $upload_dir = wp_upload_dir(); $htaccess_path = trailingslashit( $upload_dir['basedir'] ) . 'exelearning/.htaccess'; - // Only create if it doesn't exist. - if ( file_exists( $htaccess_path ) ) { - return; - } - $htaccess_content = <<<'HTACCESS' -# Security: Block direct access to eXeLearning extracted content -# All content must be served through the secure proxy controller +# Security: Control direct access to eXeLearning extracted content +# HTML files must be served through the secure proxy controller (for CSP headers) +# Static assets (CSS, JS, images, fonts, media) are allowed for direct serving -# Deny all direct access - - # Apache 2.4+ - Require all denied - - - # Apache 2.2 - Order deny,allow - Deny from all - - -# Alternative: return 403 for all requests RewriteEngine On + + # Allow static assets to be served directly + RewriteCond %{REQUEST_URI} \.(css|js|json|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|eot|otf|mp[34]|webm|og[gv]|wav|pdf|zip|txt|xml)$ [NC] + RewriteRule ^ - [L] + + # Block direct access to everything else (HTML files, etc.) RewriteRule ^ - [F,L] + + + # Fallback without mod_rewrite: deny all (proxy will still work) + + Require all denied + + + Order deny,allow + Deny from all + + HTACCESS; + // Only write if the file doesn't exist or its content has changed. + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + if ( file_exists( $htaccess_path ) && file_get_contents( $htaccess_path ) === $htaccess_content ) { + return; + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $htaccess_path, $htaccess_content ); } diff --git a/tests/unit/ContentProxyTest.php b/tests/unit/ContentProxyTest.php index 78d16ba..40b3ab2 100644 --- a/tests/unit/ContentProxyTest.php +++ b/tests/unit/ContentProxyTest.php @@ -664,7 +664,7 @@ public function test_rewrite_relative_urls_basic() { $hash = str_repeat( 'a', 40 ); $result = $method->invoke( $this->proxy, $html, $hash, '' ); - $this->assertStringContainsString( 'exelearning/v1/content/', $result ); + $this->assertStringContainsString( 'uploads/exelearning/', $result ); $this->assertStringContainsString( 'images/logo.png', $result ); } @@ -707,7 +707,7 @@ public function test_rewrite_relative_urls_handles_href() { $hash = str_repeat( 'a', 40 ); $result = $method->invoke( $this->proxy, $html, $hash, '' ); - $this->assertStringContainsString( 'exelearning/v1/content/', $result ); + $this->assertStringContainsString( 'uploads/exelearning/', $result ); $this->assertStringContainsString( 'css/style.css', $result ); } @@ -722,7 +722,7 @@ public function test_rewrite_relative_urls_handles_poster() { $hash = str_repeat( 'a', 40 ); $result = $method->invoke( $this->proxy, $html, $hash, '' ); - $this->assertStringContainsString( 'exelearning/v1/content/', $result ); + $this->assertStringContainsString( 'uploads/exelearning/', $result ); $this->assertStringContainsString( 'thumbnails/video.jpg', $result ); } @@ -737,7 +737,7 @@ public function test_rewrite_relative_urls_handles_inline_style() { $hash = str_repeat( 'a', 40 ); $result = $method->invoke( $this->proxy, $html, $hash, '' ); - $this->assertStringContainsString( 'exelearning/v1/content/', $result ); + $this->assertStringContainsString( 'uploads/exelearning/', $result ); $this->assertStringContainsString( 'images/bg.png', $result ); } @@ -854,6 +854,37 @@ public function test_rewrite_relative_urls_absolute_path() { $this->assertEquals( $html, $result ); } + /** + * Test rewrite_relative_urls routes HTML links through proxy. + */ + public function test_rewrite_relative_urls_html_links_use_proxy() { + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'rewrite_relative_urls' ); + $method->setAccessible( true ); + + $html = 'Next'; + $hash = str_repeat( 'a', 40 ); + $result = $method->invoke( $this->proxy, $html, $hash, '' ); + + $this->assertStringContainsString( 'exelearning/v1/content/', $result ); + $this->assertStringContainsString( 'html/page2.html', $result ); + $this->assertStringNotContainsString( 'uploads/exelearning/', $result ); + } + + /** + * Test rewrite_relative_urls routes HTM links through proxy. + */ + public function test_rewrite_relative_urls_htm_links_use_proxy() { + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'rewrite_relative_urls' ); + $method->setAccessible( true ); + + $html = 'Page'; + $hash = str_repeat( 'a', 40 ); + $result = $method->invoke( $this->proxy, $html, $hash, '' ); + + $this->assertStringContainsString( 'exelearning/v1/content/', $result ); + $this->assertStringNotContainsString( 'uploads/exelearning/', $result ); + } + /** * Test validate_hash with valid hash. */