PluginProbe
WindPress – Tailwind CSS integration for WordPress / 3.2.89
WindPress – Tailwind CSS integration for WordPress v3.2.89
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
← All changes | src/Core/Volume.php +166 -38 3.0.163.2.89 View file →
@@ -10,8 +10,9 @@
10 10 */
11 11 declare (strict_types=1);
12 12 namespace WindPress\WindPress\Core;
13 13
14 +use WindPressDeps\Symfony\Component\Filesystem\Path;
14 15 use WindPressDeps\Symfony\Component\Finder\Finder;
15 16 use WIND_PRESS;
16 17 use WindPress\WindPress\Utils\Common;
17 18 /**
@@ -18,100 +19,227 @@
18 19 * @since 3.1.11
19 20 */
20 21 class Volume
21 22 {
22 - public static function get_entries() : array
23 + public static function get_entries(): array
23 24 {
24 25 $entries = [];
25 - $data_dir = \wp_upload_dir()['basedir'] . WIND_PRESS::DATA_DIR;
26 - if (!\file_exists($data_dir)) {
27 - \wp_mkdir_p($data_dir);
26 + $data_dir = static::data_dir_path();
27 + if (!file_exists($data_dir)) {
28 + wp_mkdir_p($data_dir);
28 29 }
29 30 $finder = new Finder();
30 31 $finder->ignoreUnreadableDirs()->in($data_dir)->files()->followLinks()->name(['*.css', '*.js']);
31 - \do_action('f!windpress/core/volume:get_entries.finder', $finder);
32 + do_action('a!windpress/core/volume:get_entries.finder', $finder);
32 33 foreach ($finder as $file) {
33 - if (!\is_readable($file->getPathname())) {
34 + if (!is_readable($file->getPathname())) {
34 35 continue;
35 36 }
36 - $entries[] = ['name' => $file->getFilename(), 'relative_path' => $file->getRelativePathname(), 'content' => $file->getContents(), 'handler' => \strpos($file->getPathname(), $data_dir) === \false ? 'read-only' : 'internal', 'signature' => \wp_create_nonce(\sprintf('%s:%s', WIND_PRESS::WP_OPTION, $file->getRelativePathname()))];
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()];
37 38 }
39 + $directory_finder = new Finder();
40 + $directory_finder->ignoreUnreadableDirs()->in($data_dir)->directories()->followLinks();
41 + foreach ($directory_finder as $directory) {
42 + $entries[] = ['name' => $directory->getBasename(), 'relative_path' => $directory->getRelativePathname(), 'content' => '', 'handler' => 'internal', 'directory' => \true, 'readonly' => \false, 'path_on_disk' => $directory->getPathname()];
43 + }
44 + $tailwindcss_version = \WindPress\WindPress\Core\Runtime::tailwindcss_version();
38 45 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file
39 - $stubs_main_css = \file_get_contents(\dirname(WIND_PRESS::FILE) . '/stubs/main.css');
46 + $stubs_main_css = file_get_contents(sprintf('%s/stubs/tailwindcss-v%d/main.css', dirname(WIND_PRESS::FILE), $tailwindcss_version));
40 47 // check if 'main.css' already exists and content is not empty, else use the stubs
41 - $main_css_key = \array_search('main.css', \array_column($entries, 'name'), \true);
48 + $main_css_key = array_search('main.css', array_column($entries, 'name'), \true);
42 49 if ($main_css_key === \false) {
43 - $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'))];
50 + $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'))];
44 51 } elseif (empty($entries[$main_css_key]['content'])) {
45 52 $entries[$main_css_key]['content'] = $stubs_main_css;
46 53 }
54 + if ($tailwindcss_version === 3) {
55 + $stubs_tailwind_config_js = file_get_contents(dirname(WIND_PRESS::FILE) . '/stubs/tailwindcss-v3/tailwind.config.js');
56 + $stubs_wizard_js = file_get_contents(dirname(WIND_PRESS::FILE) . '/stubs/tailwindcss-v3/wizard.js');
57 + // check if 'tailwind.config.js' already exists and content is not empty, else use the stubs
58 + $tailwind_config_js_key = array_search('tailwind.config.js', array_column($entries, 'name'), \true);
59 + if ($tailwind_config_js_key === \false) {
60 + $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'))];
61 + } elseif (empty($entries[$tailwind_config_js_key]['content'])) {
62 + $entries[$tailwind_config_js_key]['content'] = $stubs_tailwind_config_js;
63 + }
64 + // check if 'wizard.js' already exists and content is not empty, else use the stubs
65 + $wizard_js_key = array_search('wizard.js', array_column($entries, 'name'), \true);
66 + if ($wizard_js_key === \false) {
67 + $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'))];
68 + } elseif (empty($entries[$wizard_js_key]['content'])) {
69 + $entries[$wizard_js_key]['content'] = $stubs_wizard_js;
70 + }
71 + } elseif ($tailwindcss_version === 4) {
72 + $stubs_wizard_css = file_get_contents(dirname(WIND_PRESS::FILE) . '/stubs/tailwindcss-v4/wizard.css');
73 + // check if 'wizard.css' already exists and content is not empty, else use the stubs
74 + $wizard_css_key = array_search('wizard.css', array_column($entries, 'name'), \true);
75 + if ($wizard_css_key === \false) {
76 + $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'))];
77 + } elseif (empty($entries[$wizard_css_key]['content'])) {
78 + $entries[$wizard_css_key]['content'] = $stubs_wizard_css;
79 + }
80 + }
47 81 /**
48 82 * @param array $entries The list of volume's entries. Each volume have `name`, `relative_path`, `content`, `handler`, and `signature` keys.
49 83 */
50 - return \apply_filters('f!windpress/core/volume:get_entries.entries', $entries);
84 + return apply_filters('f!windpress/core/volume:get_entries.entries', $entries);
51 85 }
52 - public static function save_entries($entries)
86 + public static function save_entries($entries): array
53 87 {
54 - if (!\is_array($entries)) {
55 - return;
88 + $result = ['saved' => [], 'deleted' => [], 'handled' => [], 'skipped' => [], 'errors' => []];
89 + if (!is_array($entries)) {
90 + $result['errors'][] = ['code' => 'invalid_entries', 'message' => __('Entries must be an array.', 'windpress')];
91 + return $result;
56 92 }
57 - $data_dir = \wp_upload_dir()['basedir'] . WIND_PRESS::DATA_DIR;
93 + $data_dir = static::data_dir_path();
58 94 foreach ($entries as $entry) {
95 + if (!is_array($entry)) {
96 + $result['skipped'][] = ['relative_path' => '', 'reason' => 'invalid_entry', 'message' => __('Entry must be an array.', 'windpress')];
97 + continue;
98 + }
99 + $relative_path = isset($entry['relative_path']) && is_scalar($entry['relative_path']) ? (string) $entry['relative_path'] : '';
59 100 // if doesn't have any of the following keys, skip: name, relative_path, content, handler
60 101 if (!isset($entry['name'], $entry['relative_path'], $entry['content'], $entry['handler'])) {
102 + $result['skipped'][] = ['relative_path' => $relative_path, 'reason' => 'missing_required_fields', 'message' => __('Entry is missing required fields.', 'windpress')];
61 103 continue;
62 104 }
63 - if ($entry['handler'] === 'read-only') {
105 + if (!is_string($entry['name']) || !is_string($entry['relative_path']) || !is_string($entry['handler'])) {
106 + $result['skipped'][] = ['relative_path' => $relative_path, 'reason' => 'invalid_entry', 'message' => __('Entry name, relative path, and handler must be strings.', 'windpress')];
64 107 continue;
65 108 }
109 + // skip the readonly entries
110 + if (isset($entry['readonly']) && $entry['readonly']) {
111 + $result['skipped'][] = ['relative_path' => $relative_path, 'reason' => 'readonly_entry', 'message' => __('Read-only entries cannot be saved.', 'windpress')];
112 + continue;
113 + }
114 + if (!empty($entry['directory'])) {
115 + if ($entry['handler'] !== 'internal') {
116 + $result['skipped'][] = ['relative_path' => $relative_path, 'reason' => 'invalid_directory_handler', 'message' => __('Directories must use the internal handler.', 'windpress')];
117 + continue;
118 + }
119 + try {
120 + $safe_directory_path = static::sanitize_relative_path($entry['relative_path'], $data_dir);
121 + if (!empty($entry['hidden'])) {
122 + if (is_dir($safe_directory_path) && !rmdir($safe_directory_path)) {
123 + throw new \RuntimeException(__('Directory is not empty.', 'windpress'));
124 + }
125 + $result['deleted'][] = ['relative_path' => $relative_path];
126 + } else {
127 + if (file_exists($safe_directory_path) && !is_dir($safe_directory_path)) {
128 + throw new \RuntimeException(__('A file already exists at the directory path.', 'windpress'));
129 + }
130 + if (!is_dir($safe_directory_path) && !wp_mkdir_p($safe_directory_path)) {
131 + throw new \RuntimeException(__('The directory could not be created.', 'windpress'));
132 + }
133 + $result['saved'][] = ['relative_path' => $relative_path];
134 + }
135 + } catch (\InvalidArgumentException $th) {
136 + $result['skipped'][] = ['relative_path' => $relative_path, 'reason' => 'invalid_path', 'message' => __('Directory path is invalid.', 'windpress')];
137 + } catch (\Throwable $th) {
138 + if (\WP_DEBUG_LOG) {
139 + error_log($th->__toString());
140 + }
141 + $result['errors'][] = ['relative_path' => $relative_path, 'code' => 'filesystem_error', 'message' => $th->getMessage()];
142 + }
143 + continue;
144 + }
66 145 if ($entry['handler'] !== 'internal') {
67 - // only if the signature is set
68 - if (isset($entry['signature'])) {
69 - // the handler only accept alphanumeric, hyphens, and underscores
70 - if (!\preg_match('/^[a-zA-Z0-9_-]+$/', $entry['handler'])) {
71 - continue;
72 - }
73 - \do_action('f!windpress/core/volume:save_entries.entry', $entry);
74 - // use specific handler instead for efficient handling
75 - \do_action('f!windpress/core/volume:save_entries.entry.' . $entry['handler'], $entry);
146 + // the handler only accept alphanumeric, hyphens, and underscores
147 + if (!is_string($entry['handler']) || !preg_match('/^[a-zA-Z0-9_-]+$/', $entry['handler'])) {
148 + $result['skipped'][] = ['relative_path' => $relative_path, 'reason' => 'invalid_handler', 'message' => __('Entry handler is invalid.', 'windpress')];
149 + continue;
76 150 }
151 + do_action('a!windpress/core/volume:save_entries.entry', $entry);
152 + // use specific handler instead for efficient handling
153 + do_action('a!windpress/core/volume:save_entries.entry.' . $entry['handler'], $entry);
154 + $result['handled'][] = ['relative_path' => $relative_path, 'handler' => $entry['handler']];
77 155 continue;
78 156 }
79 157 // if the signature is not set, it is a new entry.
80 158 if (!isset($entry['signature'])) {
81 159 // sanitize the file name.
82 - \add_filter('sanitize_file_name_chars', [static::class, 'sanitize_file_name_chars'], 10, 2);
160 + add_filter('sanitize_file_name_chars', [static::class, 'sanitize_file_name_chars'], 10, 2);
83 161 // split the path, and sanitize each part.
84 - $entry['relative_path'] = \implode('/', \array_map('sanitize_file_name', \explode('/', $entry['relative_path'])));
85 - $entry['relative_path'] = \sanitize_file_name($entry['relative_path']);
86 - \remove_filter('sanitize_file_name_chars', [static::class, 'sanitize_file_name_chars'], 10);
87 - $entry['name'] = \pathinfo($entry['relative_path'], \PATHINFO_BASENAME);
162 + $entry['relative_path'] = implode('/', array_map('sanitize_file_name', explode('/', $entry['relative_path'])));
163 + $entry['relative_path'] = sanitize_file_name($entry['relative_path']);
164 + remove_filter('sanitize_file_name_chars', [static::class, 'sanitize_file_name_chars'], 10);
165 + $entry['name'] = pathinfo($entry['relative_path'], \PATHINFO_BASENAME);
88 166 // only handle a css and js files.
89 - if (!\in_array(\pathinfo($entry['name'], \PATHINFO_EXTENSION), ['css', 'js'], \true)) {
167 + if (!in_array(pathinfo($entry['name'], \PATHINFO_EXTENSION), ['css', 'js'], \true)) {
168 + $result['skipped'][] = ['relative_path' => $entry['relative_path'], 'reason' => 'unsupported_file_type', 'message' => __('Only CSS and JavaScript files can be saved.', 'windpress')];
90 169 continue;
91 170 }
92 - $entry['signature'] = \wp_create_nonce(\sprintf('%s:%s', WIND_PRESS::WP_OPTION, $entry['relative_path']));
171 + $entry['signature'] = wp_create_nonce(sprintf('%s:%s', WIND_PRESS::WP_OPTION, $entry['relative_path']));
93 172 }
94 173 // verify the signature
95 - if (!\wp_verify_nonce($entry['signature'], \sprintf('%s:%s', WIND_PRESS::WP_OPTION, $entry['relative_path']))) {
174 + if (!wp_verify_nonce($entry['signature'], sprintf('%s:%s', WIND_PRESS::WP_OPTION, $entry['relative_path']))) {
175 + $result['skipped'][] = ['relative_path' => $entry['relative_path'], 'reason' => 'invalid_signature', 'message' => __('Entry signature is invalid.', 'windpress')];
96 176 continue;
97 177 }
98 178 try {
179 + // Sanitize and validate the path to prevent directory traversal
180 + $safe_file_path = static::sanitize_relative_path($entry['relative_path'], $data_dir);
99 181 // if the content is empty, delete the file.
100 - if (empty($entry['content'])) {
101 - Common::delete_file($data_dir . $entry['relative_path']);
182 + if ($entry['content'] === '') {
183 + if (file_exists($safe_file_path)) {
184 + Common::delete_file($safe_file_path);
185 + }
186 + $result['deleted'][] = ['relative_path' => $entry['relative_path']];
102 187 } else {
103 - Common::save_file($entry['content'], $data_dir . $entry['relative_path']);
188 + Common::save_file($entry['content'], $safe_file_path);
189 + $result['saved'][] = ['relative_path' => $entry['relative_path']];
104 190 }
191 + } catch (\InvalidArgumentException $th) {
192 + $result['skipped'][] = ['relative_path' => $entry['relative_path'], 'reason' => 'invalid_path', 'message' => __('Entry path is invalid.', 'windpress')];
105 193 } catch (\Throwable $th) {
106 194 if (\WP_DEBUG_LOG) {
107 - \error_log($th->__toString());
195 + error_log($th->__toString());
108 196 }
197 + $result['errors'][] = ['relative_path' => $entry['relative_path'], 'code' => 'filesystem_error', 'message' => $th->getMessage()];
109 198 }
110 199 }
200 + return $result;
111 201 }
112 202 public static function sanitize_file_name_chars(array $special_chars, $filename_raw)
113 203 {
114 204 // allow dir
115 - return \array_diff($special_chars, ['/']);
205 + return array_diff($special_chars, ['/']);
206 + }
207 + public static function data_dir_url(): string
208 + {
209 + return wp_upload_dir()['baseurl'] . WIND_PRESS::DATA_DIR;
210 + }
211 + public static function data_dir_path(): string
212 + {
213 + return wp_upload_dir()['basedir'] . WIND_PRESS::DATA_DIR;
214 + }
215 + public static function get_available_handlers(): array
216 + {
217 + return apply_filters('f!windpress/core/volume:get_available_handlers', []);
218 + }
219 + /**
220 + * Sanitize and validate a relative path to prevent directory traversal attacks.
221 + *
222 + * @param string $relative_path The relative path to sanitize
223 + * @param string $base_dir The base directory that the path should be contained within
224 + * @return string The sanitized and validated absolute path
225 + * @throws \InvalidArgumentException If the path attempts to escape the base directory
226 + * @since 3.3.65
227 + */
228 + private static function sanitize_relative_path(string $relative_path, string $base_dir): string
229 + {
230 + // Remove any null bytes
231 + $relative_path = str_replace("\x00", '', $relative_path);
232 + // Canonicalize the base directory path
233 + $base_dir = Path::canonicalize($base_dir);
234 + // Canonicalize the relative path to resolve .. and normalize separators
235 + $canonical_path = Path::canonicalize($relative_path);
236 + // Build the full path
237 + $full_path = Path::join($base_dir, $canonical_path);
238 + // Validate that the resolved path doesn't escape the base directory
239 + // Use Symfony's isBasePath() which handles platform differences automatically
240 + if (!Path::isBasePath($base_dir, $full_path)) {
241 + throw new \InvalidArgumentException('Path traversal attempt detected: ' . $relative_path);
242 + }
243 + return $full_path;
116 244 }
117 245 }