PluginProbe
WindPress – Tailwind CSS integration for WordPress / 3.2.88
WindPress – Tailwind CSS integration for WordPress v3.2.88
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 3.0.7 All 142 releases
windpress / src / Core / Volume.php

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

246 lines 14.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 $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();
45 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file
46 $stubs_main_css = file_get_contents(sprintf('%s/stubs/tailwindcss-v%d/main.css', dirname(WIND_PRESS::FILE), $tailwindcss_version));
47 // check if 'main.css' already exists and content is not empty, else use the stubs
48 $main_css_key = array_search('main.css', array_column($entries, 'name'), \true);
49 if ($main_css_key === \false) {
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'))];
51 } elseif (empty($entries[$main_css_key]['content'])) {
52 $entries[$main_css_key]['content'] = $stubs_main_css;
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 }
81 /**
82 * @param array $entries The list of volume's entries. Each volume have `name`, `relative_path`, `content`, `handler`, and `signature` keys.
83 */
84 return apply_filters('f!windpress/core/volume:get_entries.entries', $entries);
85 }
86 public static function save_entries($entries): array
87 {
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;
92 }
93 $data_dir = static::data_dir_path();
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'] : '';
100 // if doesn't have any of the following keys, skip: name, relative_path, content, handler
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')];
103 continue;
104 }
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')];
107 continue;
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 }
145 if ($entry['handler'] !== 'internal') {
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;
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']];
155 continue;
156 }
157 // if the signature is not set, it is a new entry.
158 if (!isset($entry['signature'])) {
159 // sanitize the file name.
160 add_filter('sanitize_file_name_chars', [static::class, 'sanitize_file_name_chars'], 10, 2);
161 // split the path, and sanitize each part.
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);
166 // only handle a css and js files.
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')];
169 continue;
170 }
171 $entry['signature'] = wp_create_nonce(sprintf('%s:%s', WIND_PRESS::WP_OPTION, $entry['relative_path']));
172 }
173 // verify the signature
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')];
176 continue;
177 }
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);
181 // if the content is empty, delete the file.
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']];
187 } else {
188 Common::save_file($entry['content'], $safe_file_path);
189 $result['saved'][] = ['relative_path' => $entry['relative_path']];
190 }
191 } catch (\InvalidArgumentException $th) {
192 $result['skipped'][] = ['relative_path' => $entry['relative_path'], 'reason' => 'invalid_path', 'message' => __('Entry path is invalid.', 'windpress')];
193 } catch (\Throwable $th) {
194 if (\WP_DEBUG_LOG) {
195 error_log($th->__toString());
196 }
197 $result['errors'][] = ['relative_path' => $entry['relative_path'], 'code' => 'filesystem_error', 'message' => $th->getMessage()];
198 }
199 }
200 return $result;
201 }
202 public static function sanitize_file_name_chars(array $special_chars, $filename_raw)
203 {
204 // allow dir
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;
244 }
245 }
246