PluginProbe
WindPress – Tailwind CSS integration for WordPress / 3.2.81
WindPress – Tailwind CSS integration for WordPress v3.2.81
3.2.89 3.2.88 3.2.87 3.2.86 3.2.85 3.2.84 3.2.83 3.2.82 3.2.81 trunk 3.0.0 3.0.1 3.0.10 3.0.11 3.0.12 3.0.13 3.0.14 3.0.15 3.0.16 3.0.17 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 All 143 releases
windpress / src / Core / Volume.php

Volume.php in WindPress – Tailwind CSS integration for WordPress 3.2.81, at src/Core/Volume.php

185 lines 9.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /*
4 * This file is part of the WindPress package.
5 *
6 * (c) Joshua Gugun Siagian <suabahasa@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11 declare (strict_types=1);
12 namespace WindPress\WindPress\Core;
13
14 use WindPressDeps\Symfony\Component\Filesystem\Path;
15 use WindPressDeps\Symfony\Component\Finder\Finder;
16 use WIND_PRESS;
17 use WindPress\WindPress\Utils\Common;
18 /**
19 * @since 3.1.11
20 */
21 class Volume
22 {
23 public static function get_entries(): array
24 {
25 $entries = [];
26 $data_dir = static::data_dir_path();
27 if (!file_exists($data_dir)) {
28 wp_mkdir_p($data_dir);
29 }
30 $finder = new Finder();
31 $finder->ignoreUnreadableDirs()->in($data_dir)->files()->followLinks()->name(['*.css', '*.js']);
32 do_action('a!windpress/core/volume:get_entries.finder', $finder);
33 foreach ($finder as $file) {
34 if (!is_readable($file->getPathname())) {
35 continue;
36 }
37 $entries[] = ['name' => $file->getFilename(), 'relative_path' => $file->getRelativePathname(), 'content' => $file->getContents(), 'handler' => 'internal', 'signature' => wp_create_nonce(sprintf('%s:%s', WIND_PRESS::WP_OPTION, $file->getRelativePathname())), 'readonly' => strpos(wp_normalize_path($file->getPathname()), wp_normalize_path($data_dir)) === \false, 'path_on_disk' => $file->getPathname()];
38 }
39 $tailwindcss_version = \WindPress\WindPress\Core\Runtime::tailwindcss_version();
40 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file
41 $stubs_main_css = file_get_contents(sprintf('%s/stubs/tailwindcss-v%d/main.css', dirname(WIND_PRESS::FILE), $tailwindcss_version));
42 // check if 'main.css' already exists and content is not empty, else use the stubs
43 $main_css_key = array_search('main.css', array_column($entries, 'name'), \true);
44 if ($main_css_key === \false) {
45 $entries[] = ['name' => 'main.css', 'relative_path' => 'main.css', 'content' => $stubs_main_css, 'handler' => 'internal', 'signature' => wp_create_nonce(sprintf('%s:%s', WIND_PRESS::WP_OPTION, 'main.css'))];
46 } elseif (empty($entries[$main_css_key]['content'])) {
47 $entries[$main_css_key]['content'] = $stubs_main_css;
48 }
49 if ($tailwindcss_version === 3) {
50 $stubs_tailwind_config_js = file_get_contents(dirname(WIND_PRESS::FILE) . '/stubs/tailwindcss-v3/tailwind.config.js');
51 $stubs_wizard_js = file_get_contents(dirname(WIND_PRESS::FILE) . '/stubs/tailwindcss-v3/wizard.js');
52 // check if 'tailwind.config.js' already exists and content is not empty, else use the stubs
53 $tailwind_config_js_key = array_search('tailwind.config.js', array_column($entries, 'name'), \true);
54 if ($tailwind_config_js_key === \false) {
55 $entries[] = ['name' => 'tailwind.config.js', 'relative_path' => 'tailwind.config.js', 'content' => $stubs_tailwind_config_js, 'handler' => 'internal', 'signature' => wp_create_nonce(sprintf('%s:%s', WIND_PRESS::WP_OPTION, 'tailwind.config.js'))];
56 } elseif (empty($entries[$tailwind_config_js_key]['content'])) {
57 $entries[$tailwind_config_js_key]['content'] = $stubs_tailwind_config_js;
58 }
59 // check if 'wizard.js' already exists and content is not empty, else use the stubs
60 $wizard_js_key = array_search('wizard.js', array_column($entries, 'name'), \true);
61 if ($wizard_js_key === \false) {
62 $entries[] = ['name' => 'wizard.js', 'relative_path' => 'wizard.js', 'content' => $stubs_wizard_js, 'handler' => 'internal', 'signature' => wp_create_nonce(sprintf('%s:%s', WIND_PRESS::WP_OPTION, 'wizard.js'))];
63 } elseif (empty($entries[$wizard_js_key]['content'])) {
64 $entries[$wizard_js_key]['content'] = $stubs_wizard_js;
65 }
66 } elseif ($tailwindcss_version === 4) {
67 $stubs_wizard_css = file_get_contents(dirname(WIND_PRESS::FILE) . '/stubs/tailwindcss-v4/wizard.css');
68 // check if 'wizard.css' already exists and content is not empty, else use the stubs
69 $wizard_css_key = array_search('wizard.css', array_column($entries, 'name'), \true);
70 if ($wizard_css_key === \false) {
71 $entries[] = ['name' => 'wizard.css', 'relative_path' => 'wizard.css', 'content' => $stubs_wizard_css, 'handler' => 'internal', 'signature' => wp_create_nonce(sprintf('%s:%s', WIND_PRESS::WP_OPTION, 'wizard.css'))];
72 } elseif (empty($entries[$wizard_css_key]['content'])) {
73 $entries[$wizard_css_key]['content'] = $stubs_wizard_css;
74 }
75 }
76 /**
77 * @param array $entries The list of volume's entries. Each volume have `name`, `relative_path`, `content`, `handler`, and `signature` keys.
78 */
79 return apply_filters('f!windpress/core/volume:get_entries.entries', $entries);
80 }
81 public static function save_entries($entries)
82 {
83 if (!is_array($entries)) {
84 return;
85 }
86 $data_dir = static::data_dir_path();
87 foreach ($entries as $entry) {
88 // if doesn't have any of the following keys, skip: name, relative_path, content, handler
89 if (!isset($entry['name'], $entry['relative_path'], $entry['content'], $entry['handler'])) {
90 continue;
91 }
92 // skip the readonly entries
93 if (isset($entry['readonly']) && $entry['readonly']) {
94 continue;
95 }
96 if ($entry['handler'] !== 'internal') {
97 // the handler only accept alphanumeric, hyphens, and underscores
98 if (!preg_match('/^[a-zA-Z0-9_-]+$/', $entry['handler'])) {
99 continue;
100 }
101 do_action('a!windpress/core/volume:save_entries.entry', $entry);
102 // use specific handler instead for efficient handling
103 do_action('a!windpress/core/volume:save_entries.entry.' . $entry['handler'], $entry);
104 continue;
105 }
106 // if the signature is not set, it is a new entry.
107 if (!isset($entry['signature'])) {
108 // sanitize the file name.
109 add_filter('sanitize_file_name_chars', [static::class, 'sanitize_file_name_chars'], 10, 2);
110 // split the path, and sanitize each part.
111 $entry['relative_path'] = implode('/', array_map('sanitize_file_name', explode('/', $entry['relative_path'])));
112 $entry['relative_path'] = sanitize_file_name($entry['relative_path']);
113 remove_filter('sanitize_file_name_chars', [static::class, 'sanitize_file_name_chars'], 10);
114 $entry['name'] = pathinfo($entry['relative_path'], \PATHINFO_BASENAME);
115 // only handle a css and js files.
116 if (!in_array(pathinfo($entry['name'], \PATHINFO_EXTENSION), ['css', 'js'], \true)) {
117 continue;
118 }
119 $entry['signature'] = wp_create_nonce(sprintf('%s:%s', WIND_PRESS::WP_OPTION, $entry['relative_path']));
120 }
121 // verify the signature
122 if (!wp_verify_nonce($entry['signature'], sprintf('%s:%s', WIND_PRESS::WP_OPTION, $entry['relative_path']))) {
123 continue;
124 }
125 try {
126 // Sanitize and validate the path to prevent directory traversal
127 $safe_file_path = static::sanitize_relative_path($entry['relative_path'], $data_dir);
128 // if the content is empty, delete the file.
129 if (empty($entry['content'])) {
130 Common::delete_file($safe_file_path);
131 } else {
132 Common::save_file($entry['content'], $safe_file_path);
133 }
134 } catch (\Throwable $th) {
135 if (\WP_DEBUG_LOG) {
136 error_log($th->__toString());
137 }
138 }
139 }
140 }
141 public static function sanitize_file_name_chars(array $special_chars, $filename_raw)
142 {
143 // allow dir
144 return array_diff($special_chars, ['/']);
145 }
146 /**
147 * Sanitize and validate a relative path to prevent directory traversal attacks.
148 *
149 * @param string $relative_path The relative path to sanitize
150 * @param string $base_dir The base directory that the path should be contained within
151 * @return string The sanitized and validated absolute path
152 * @throws \InvalidArgumentException If the path attempts to escape the base directory
153 * @since 3.3.65
154 */
155 private static function sanitize_relative_path(string $relative_path, string $base_dir): string
156 {
157 // Remove any null bytes
158 $relative_path = str_replace("\x00", '', $relative_path);
159 // Canonicalize the base directory path
160 $base_dir = Path::canonicalize($base_dir);
161 // Canonicalize the relative path to resolve .. and normalize separators
162 $canonical_path = Path::canonicalize($relative_path);
163 // Build the full path
164 $full_path = Path::join($base_dir, $canonical_path);
165 // Validate that the resolved path doesn't escape the base directory
166 // Use Symfony's isBasePath() which handles platform differences automatically
167 if (!Path::isBasePath($base_dir, $full_path)) {
168 throw new \InvalidArgumentException('Path traversal attempt detected: ' . $relative_path);
169 }
170 return $full_path;
171 }
172 public static function data_dir_url(): string
173 {
174 return wp_upload_dir()['baseurl'] . WIND_PRESS::DATA_DIR;
175 }
176 public static function data_dir_path(): string
177 {
178 return wp_upload_dir()['basedir'] . WIND_PRESS::DATA_DIR;
179 }
180 public static function get_available_handlers(): array
181 {
182 return apply_filters('f!windpress/core/volume:get_available_handlers', []);
183 }
184 }
185