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.
*/