PluginProbe
WindPress – Tailwind CSS integration for WordPress / 3.2.86
WindPress – Tailwind CSS integration for WordPress v3.2.86
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.86, at src/Core/Volume.php

210 lines 12.5 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): array
82 {
83 $result = ['saved' => [], 'deleted' => [], 'handled' => [], 'skipped' => [], 'errors' => []];
84 if (!is_array($entries)) {
85 $result['errors'][] = ['code' => 'invalid_entries', 'message' => __('Entries must be an array.', 'windpress')];
86 return $result;
87 }
88 $data_dir = static::data_dir_path();
89 foreach ($entries as $entry) {
90 if (!is_array($entry)) {
91 $result['skipped'][] = ['relative_path' => '', 'reason' => 'invalid_entry', 'message' => __('Entry must be an array.', 'windpress')];
92 continue;
93 }
94 $relative_path = isset($entry['relative_path']) && is_scalar($entry['relative_path']) ? (string) $entry['relative_path'] : '';
95 // if doesn't have any of the following keys, skip: name, relative_path, content, handler
96 if (!isset($entry['name'], $entry['relative_path'], $entry['content'], $entry['handler'])) {
97 $result['skipped'][] = ['relative_path' => $relative_path, 'reason' => 'missing_required_fields', 'message' => __('Entry is missing required fields.', 'windpress')];
98 continue;
99 }
100 if (!is_string($entry['name']) || !is_string($entry['relative_path']) || !is_string($entry['handler'])) {
101 $result['skipped'][] = ['relative_path' => $relative_path, 'reason' => 'invalid_entry', 'message' => __('Entry name, relative path, and handler must be strings.', 'windpress')];
102 continue;
103 }
104 // skip the readonly entries
105 if (isset($entry['readonly']) && $entry['readonly']) {
106 $result['skipped'][] = ['relative_path' => $relative_path, 'reason' => 'readonly_entry', 'message' => __('Read-only entries cannot be saved.', 'windpress')];
107 continue;
108 }
109 if ($entry['handler'] !== 'internal') {
110 // the handler only accept alphanumeric, hyphens, and underscores
111 if (!is_string($entry['handler']) || !preg_match('/^[a-zA-Z0-9_-]+$/', $entry['handler'])) {
112 $result['skipped'][] = ['relative_path' => $relative_path, 'reason' => 'invalid_handler', 'message' => __('Entry handler is invalid.', 'windpress')];
113 continue;
114 }
115 do_action('a!windpress/core/volume:save_entries.entry', $entry);
116 // use specific handler instead for efficient handling
117 do_action('a!windpress/core/volume:save_entries.entry.' . $entry['handler'], $entry);
118 $result['handled'][] = ['relative_path' => $relative_path, 'handler' => $entry['handler']];
119 continue;
120 }
121 // if the signature is not set, it is a new entry.
122 if (!isset($entry['signature'])) {
123 // sanitize the file name.
124 add_filter('sanitize_file_name_chars', [static::class, 'sanitize_file_name_chars'], 10, 2);
125 // split the path, and sanitize each part.
126 $entry['relative_path'] = implode('/', array_map('sanitize_file_name', explode('/', $entry['relative_path'])));
127 $entry['relative_path'] = sanitize_file_name($entry['relative_path']);
128 remove_filter('sanitize_file_name_chars', [static::class, 'sanitize_file_name_chars'], 10);
129 $entry['name'] = pathinfo($entry['relative_path'], \PATHINFO_BASENAME);
130 // only handle a css and js files.
131 if (!in_array(pathinfo($entry['name'], \PATHINFO_EXTENSION), ['css', 'js'], \true)) {
132 $result['skipped'][] = ['relative_path' => $entry['relative_path'], 'reason' => 'unsupported_file_type', 'message' => __('Only CSS and JavaScript files can be saved.', 'windpress')];
133 continue;
134 }
135 $entry['signature'] = wp_create_nonce(sprintf('%s:%s', WIND_PRESS::WP_OPTION, $entry['relative_path']));
136 }
137 // verify the signature
138 if (!wp_verify_nonce($entry['signature'], sprintf('%s:%s', WIND_PRESS::WP_OPTION, $entry['relative_path']))) {
139 $result['skipped'][] = ['relative_path' => $entry['relative_path'], 'reason' => 'invalid_signature', 'message' => __('Entry signature is invalid.', 'windpress')];
140 continue;
141 }
142 try {
143 // Sanitize and validate the path to prevent directory traversal
144 $safe_file_path = static::sanitize_relative_path($entry['relative_path'], $data_dir);
145 // if the content is empty, delete the file.
146 if ($entry['content'] === '') {
147 if (file_exists($safe_file_path)) {
148 Common::delete_file($safe_file_path);
149 }
150 $result['deleted'][] = ['relative_path' => $entry['relative_path']];
151 } else {
152 Common::save_file($entry['content'], $safe_file_path);
153 $result['saved'][] = ['relative_path' => $entry['relative_path']];
154 }
155 } catch (\InvalidArgumentException $th) {
156 $result['skipped'][] = ['relative_path' => $entry['relative_path'], 'reason' => 'invalid_path', 'message' => __('Entry path is invalid.', 'windpress')];
157 } catch (\Throwable $th) {
158 if (\WP_DEBUG_LOG) {
159 error_log($th->__toString());
160 }
161 $result['errors'][] = ['relative_path' => $entry['relative_path'], 'code' => 'filesystem_error', 'message' => $th->getMessage()];
162 }
163 }
164 return $result;
165 }
166 public static function sanitize_file_name_chars(array $special_chars, $filename_raw)
167 {
168 // allow dir
169 return array_diff($special_chars, ['/']);
170 }
171 public static function data_dir_url(): string
172 {
173 return wp_upload_dir()['baseurl'] . WIND_PRESS::DATA_DIR;
174 }
175 public static function data_dir_path(): string
176 {
177 return wp_upload_dir()['basedir'] . WIND_PRESS::DATA_DIR;
178 }
179 public static function get_available_handlers(): array
180 {
181 return apply_filters('f!windpress/core/volume:get_available_handlers', []);
182 }
183 /**
184 * Sanitize and validate a relative path to prevent directory traversal attacks.
185 *
186 * @param string $relative_path The relative path to sanitize
187 * @param string $base_dir The base directory that the path should be contained within
188 * @return string The sanitized and validated absolute path
189 * @throws \InvalidArgumentException If the path attempts to escape the base directory
190 * @since 3.3.65
191 */
192 private static function sanitize_relative_path(string $relative_path, string $base_dir): string
193 {
194 // Remove any null bytes
195 $relative_path = str_replace("\x00", '', $relative_path);
196 // Canonicalize the base directory path
197 $base_dir = Path::canonicalize($base_dir);
198 // Canonicalize the relative path to resolve .. and normalize separators
199 $canonical_path = Path::canonicalize($relative_path);
200 // Build the full path
201 $full_path = Path::join($base_dir, $canonical_path);
202 // Validate that the resolved path doesn't escape the base directory
203 // Use Symfony's isBasePath() which handles platform differences automatically
204 if (!Path::isBasePath($base_dir, $full_path)) {
205 throw new \InvalidArgumentException('Path traversal attempt detected: ' . $relative_path);
206 }
207 return $full_path;
208 }
209 }
210