@relvehq/agent-ready 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +364 -3
- package/SETUP.md +354 -0
- package/dist/bin/agent-ready.d.ts +23 -0
- package/dist/bin/agent-ready.js +444 -0
- package/dist/cli/init.d.ts +70 -0
- package/dist/cli/init.js +366 -0
- package/dist/core/accept.d.ts +8 -0
- package/dist/core/accept.js +76 -0
- package/dist/core/config.d.ts +145 -0
- package/dist/core/config.js +103 -0
- package/dist/core/convert.d.ts +22 -0
- package/dist/core/convert.js +334 -0
- package/dist/core/detect.d.ts +30 -0
- package/dist/core/detect.js +82 -0
- package/dist/core/easy.d.ts +19 -0
- package/dist/core/easy.js +117 -0
- package/dist/core/enrich.d.ts +30 -0
- package/dist/core/enrich.js +155 -0
- package/dist/core/llms.d.ts +11 -0
- package/dist/core/llms.js +71 -0
- package/dist/core/report.d.ts +15 -0
- package/dist/core/report.js +46 -0
- package/dist/core/resolve.d.ts +43 -0
- package/dist/core/resolve.js +204 -0
- package/dist/core/respond.d.ts +18 -0
- package/dist/core/respond.js +59 -0
- package/dist/core/sitemap.d.ts +31 -0
- package/dist/core/sitemap.js +140 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +76 -0
- package/dist/next/dashboard.d.ts +14 -0
- package/dist/next/dashboard.js +56 -0
- package/dist/next/index.d.ts +42 -0
- package/dist/next/index.js +100 -0
- package/dist/next/route.d.ts +26 -0
- package/dist/next/route.js +167 -0
- package/dist/node/index.d.ts +8 -0
- package/dist/node/index.js +191 -0
- package/dist/proof/audit.d.ts +47 -0
- package/dist/proof/audit.js +253 -0
- package/dist/proof/bots.d.ts +24 -0
- package/dist/proof/bots.js +60 -0
- package/dist/proof/dashboard.d.ts +8 -0
- package/dist/proof/dashboard.js +77 -0
- package/dist/proof/hits.d.ts +18 -0
- package/dist/proof/hits.js +51 -0
- package/dist/proof/jsonl.d.ts +5 -0
- package/dist/proof/jsonl.js +80 -0
- package/dist/proof/stats.d.ts +39 -0
- package/dist/proof/stats.js +127 -0
- package/dist/refine/check.d.ts +17 -0
- package/dist/refine/check.js +134 -0
- package/dist/refine/extract.d.ts +7 -0
- package/dist/refine/extract.js +120 -0
- package/dist/refine/file-store.d.ts +6 -0
- package/dist/refine/file-store.js +87 -0
- package/dist/refine/model.d.ts +30 -0
- package/dist/refine/model.js +105 -0
- package/dist/refine/prompts.d.ts +49 -0
- package/dist/refine/prompts.js +178 -0
- package/dist/refine/refine.d.ts +38 -0
- package/dist/refine/refine.js +75 -0
- package/dist/refine/remote.d.ts +57 -0
- package/dist/refine/remote.js +238 -0
- package/dist/refine/run.d.ts +52 -0
- package/dist/refine/run.js +66 -0
- package/dist/refine/types.d.ts +17 -0
- package/dist/refine/types.js +8 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-admin.php +550 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-converter.php +762 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-llms.php +192 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-markdown-builder.php +455 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-negotiation.php +334 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-refiner.php +894 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-response.php +226 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-router.php +421 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-settings.php +201 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-store.php +583 -0
- package/dist/wp/relve-agent-ready/includes/index.php +2 -0
- package/dist/wp/relve-agent-ready/index.php +2 -0
- package/dist/wp/relve-agent-ready/readme.txt +108 -0
- package/dist/wp/relve-agent-ready/relve-agent-ready.php +81 -0
- package/dist/wp/relve-agent-ready/uninstall.php +35 -0
- package/package.json +64 -6
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* Store: refined Markdown files from Relve.
|
|
4
|
+
*
|
|
5
|
+
* Upload contract (agreed with the Relve runner, `venture.ts --zip`): the zip
|
|
6
|
+
* holds a top-level `md/` folder; inside it, `<url-path>.md` relative to the
|
|
7
|
+
* site root exactly as the npm refine writes them ("/" -> md/index.md,
|
|
8
|
+
* "/blog/my-post/" -> md/blog/my-post.md). `.json` sidecars and other files
|
|
9
|
+
* are skipped, not errors. Entries outside `md/`, absolute paths, `..` and
|
|
10
|
+
* oversized files are rejected.
|
|
11
|
+
*
|
|
12
|
+
* Files live in wp-content/uploads/relve-agent-ready/md/, behind an
|
|
13
|
+
* .htaccess "deny all" and index.php files. They are only ever read and sent
|
|
14
|
+
* as text, never included or executed.
|
|
15
|
+
*
|
|
16
|
+
* The validation and lookup helpers are pure and unit-tested; the class takes
|
|
17
|
+
* its base directory in the constructor so tests use a temp dir.
|
|
18
|
+
*
|
|
19
|
+
* @package Relve_Agent_Ready
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
if ( ! defined( 'ABSPATH' ) ) {
|
|
23
|
+
exit;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Refined-files store.
|
|
28
|
+
*/
|
|
29
|
+
class Relve_AR_Store {
|
|
30
|
+
|
|
31
|
+
/** Max zip size in bytes. */
|
|
32
|
+
const MAX_ZIP_BYTES = 20971520; // 20 MB.
|
|
33
|
+
|
|
34
|
+
/** Max size of one Markdown file in bytes. */
|
|
35
|
+
const MAX_FILE_BYTES = 2097152; // 2 MB.
|
|
36
|
+
|
|
37
|
+
/** Max total uncompressed bytes written from one zip. */
|
|
38
|
+
const MAX_TOTAL_BYTES = 104857600; // 100 MB.
|
|
39
|
+
|
|
40
|
+
/** Max entries read from one zip. */
|
|
41
|
+
const MAX_ENTRIES = 20000;
|
|
42
|
+
|
|
43
|
+
/** Root folder: .../uploads/relve-agent-ready. @var string */
|
|
44
|
+
private $root;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Constructor.
|
|
48
|
+
*
|
|
49
|
+
* @param string|null $root Root folder; default uploads/relve-agent-ready.
|
|
50
|
+
*/
|
|
51
|
+
public function __construct( $root = null ) {
|
|
52
|
+
if ( null === $root ) {
|
|
53
|
+
$uploads = wp_upload_dir( null, false );
|
|
54
|
+
$root = trailingslashit( $uploads['basedir'] ) . 'relve-agent-ready';
|
|
55
|
+
}
|
|
56
|
+
$this->root = rtrim( str_replace( '\\', '/', (string) $root ), '/' );
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Root folder.
|
|
61
|
+
*
|
|
62
|
+
* @return string
|
|
63
|
+
*/
|
|
64
|
+
public function root() {
|
|
65
|
+
return $this->root;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Folder the .md files live in.
|
|
70
|
+
*
|
|
71
|
+
* @return string
|
|
72
|
+
*/
|
|
73
|
+
public function md_dir() {
|
|
74
|
+
return $this->root . '/md';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── validation (pure) ─────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Classify one zip entry name.
|
|
81
|
+
*
|
|
82
|
+
* @param string $name Entry name as stored in the zip.
|
|
83
|
+
* @return array { status: store|skip|reject, path: relative path under md/ (store only), reason: string }
|
|
84
|
+
*/
|
|
85
|
+
public static function validate_entry( $name ) {
|
|
86
|
+
$name = (string) $name;
|
|
87
|
+
$skip = function ( $reason ) {
|
|
88
|
+
return array(
|
|
89
|
+
'status' => 'skip',
|
|
90
|
+
'path' => '',
|
|
91
|
+
'reason' => $reason,
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
$reject = function ( $reason ) {
|
|
95
|
+
return array(
|
|
96
|
+
'status' => 'reject',
|
|
97
|
+
'path' => '',
|
|
98
|
+
'reason' => $reason,
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
if ( '' === $name || false !== strpos( $name, "\0" ) || preg_match( '/[\x00-\x1F\x7F]/', $name ) ) {
|
|
102
|
+
return $reject( 'bad name' );
|
|
103
|
+
}
|
|
104
|
+
if ( false !== strpos( $name, '\\' ) ) {
|
|
105
|
+
return $reject( 'backslash in path' );
|
|
106
|
+
}
|
|
107
|
+
if ( '/' === $name[0] || preg_match( '#^[A-Za-z]:#', $name ) ) {
|
|
108
|
+
return $reject( 'absolute path' );
|
|
109
|
+
}
|
|
110
|
+
$segments = explode( '/', $name );
|
|
111
|
+
foreach ( $segments as $seg ) {
|
|
112
|
+
if ( '..' === $seg || '.' === $seg ) {
|
|
113
|
+
return $reject( 'path traversal' );
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if ( '/' === substr( $name, -1 ) ) {
|
|
117
|
+
return $skip( 'folder' );
|
|
118
|
+
}
|
|
119
|
+
if ( 'md' !== $segments[0] || count( $segments ) < 2 ) {
|
|
120
|
+
return $reject( 'outside md/' );
|
|
121
|
+
}
|
|
122
|
+
$rel = implode( '/', array_slice( $segments, 1 ) );
|
|
123
|
+
if ( strtolower( substr( $rel, -3 ) ) !== '.md' ) {
|
|
124
|
+
return $skip( 'not a .md file' );
|
|
125
|
+
}
|
|
126
|
+
foreach ( array_slice( $segments, 1 ) as $seg ) {
|
|
127
|
+
if ( '' === $seg ) {
|
|
128
|
+
return $reject( 'empty path segment' );
|
|
129
|
+
}
|
|
130
|
+
if ( '.' === $seg[0] ) {
|
|
131
|
+
return $reject( 'hidden file' );
|
|
132
|
+
}
|
|
133
|
+
if ( preg_match( '#[<>:"|?*]#', $seg ) ) {
|
|
134
|
+
return $reject( 'bad character' );
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if ( strlen( $rel ) > 1024 ) {
|
|
138
|
+
return $reject( 'path too long' );
|
|
139
|
+
}
|
|
140
|
+
return array(
|
|
141
|
+
'status' => 'store',
|
|
142
|
+
'path' => $rel,
|
|
143
|
+
'reason' => '',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Relative file names to try for a request path, most specific first:
|
|
149
|
+
* the full path, then the path relative to the home path.
|
|
150
|
+
*
|
|
151
|
+
* @param string $path Normalised request path ("/blog/my-post", "/").
|
|
152
|
+
* @param string $home_path Home path without trailing slash ("" or "/blog").
|
|
153
|
+
* @return string[] e.g. array( 'blog/my-post.md', 'my-post.md' ).
|
|
154
|
+
*/
|
|
155
|
+
public static function candidates( $path, $home_path = '' ) {
|
|
156
|
+
$norm = Relve_AR_Negotiation::normalize_path( $path );
|
|
157
|
+
if ( null === $norm ) {
|
|
158
|
+
return array();
|
|
159
|
+
}
|
|
160
|
+
$list = array( self::file_for( $norm ) );
|
|
161
|
+
if ( '' !== $home_path && ( $norm === $home_path || 0 === strpos( $norm, $home_path . '/' ) ) ) {
|
|
162
|
+
$rel = substr( $norm, strlen( $home_path ) );
|
|
163
|
+
$list[] = self::file_for( '' === $rel ? '/' : $rel );
|
|
164
|
+
}
|
|
165
|
+
return array_values( array_unique( $list ) );
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* "/" -> "index.md", "/a/b" -> "a/b.md".
|
|
170
|
+
*
|
|
171
|
+
* @param string $norm Normalised path.
|
|
172
|
+
* @return string
|
|
173
|
+
*/
|
|
174
|
+
private static function file_for( $norm ) {
|
|
175
|
+
$clean = trim( $norm, '/' );
|
|
176
|
+
return ( '' === $clean ? 'index' : $clean ) . '.md';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── reading ───────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Stored Markdown for a request path, or null.
|
|
183
|
+
*
|
|
184
|
+
* @param string $path Normalised request path.
|
|
185
|
+
* @param string $home_path Home path.
|
|
186
|
+
* @return string|null
|
|
187
|
+
*/
|
|
188
|
+
public function get( $path, $home_path = '' ) {
|
|
189
|
+
foreach ( self::candidates( $path, $home_path ) as $rel ) {
|
|
190
|
+
$file = $this->md_dir() . '/' . $rel;
|
|
191
|
+
if ( ! $this->inside( $file ) || ! is_file( $file ) || ! is_readable( $file ) ) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
$size = filesize( $file );
|
|
195
|
+
if ( false === $size || $size > self::MAX_FILE_BYTES ) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
$text = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file.
|
|
199
|
+
if ( false !== $text && '' !== trim( $text ) ) {
|
|
200
|
+
return $text;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Is $file inside the md folder (after resolving symlinks)?
|
|
208
|
+
*
|
|
209
|
+
* @param string $file File path.
|
|
210
|
+
* @return bool
|
|
211
|
+
*/
|
|
212
|
+
private function inside( $file ) {
|
|
213
|
+
$real_dir = realpath( $this->md_dir() );
|
|
214
|
+
$real = realpath( $file );
|
|
215
|
+
if ( false === $real_dir || false === $real ) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
$real_dir = rtrim( str_replace( '\\', '/', $real_dir ), '/' ) . '/';
|
|
219
|
+
return 0 === strpos( str_replace( '\\', '/', $real ), $real_dir );
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Number of stored .md files.
|
|
224
|
+
*
|
|
225
|
+
* @return int
|
|
226
|
+
*/
|
|
227
|
+
public function count() {
|
|
228
|
+
$dir = $this->md_dir();
|
|
229
|
+
if ( ! is_dir( $dir ) ) {
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
$n = 0;
|
|
233
|
+
$it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ) );
|
|
234
|
+
foreach ( $it as $f ) {
|
|
235
|
+
if ( $f->isFile() && strtolower( substr( $f->getFilename(), -3 ) ) === '.md' ) {
|
|
236
|
+
++$n;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return $n;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── writing ───────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Create the folders and the protection files.
|
|
246
|
+
*
|
|
247
|
+
* @return bool
|
|
248
|
+
*/
|
|
249
|
+
public function ensure() {
|
|
250
|
+
foreach ( array( $this->root, $this->md_dir() ) as $dir ) {
|
|
251
|
+
if ( ! is_dir( $dir ) && ! self::mkdir_p( $dir ) ) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
if ( ! file_exists( $dir . '/index.php' ) ) {
|
|
255
|
+
file_put_contents( $dir . '/index.php', "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
$htaccess = $this->root . '/.htaccess';
|
|
259
|
+
if ( ! file_exists( $htaccess ) ) {
|
|
260
|
+
$rules = "# Relve Agent Ready: refined files are served by WordPress, never directly.\n"
|
|
261
|
+
. "<IfModule mod_authz_core.c>\n\tRequire all denied\n</IfModule>\n"
|
|
262
|
+
. "<IfModule !mod_authz_core.c>\n\tOrder deny,allow\n\tDeny from all\n</IfModule>\n";
|
|
263
|
+
file_put_contents( $htaccess, $rules ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Write one validated file.
|
|
270
|
+
*
|
|
271
|
+
* @param string $rel Relative path from validate_entry().
|
|
272
|
+
* @param string $content Markdown.
|
|
273
|
+
* @return bool
|
|
274
|
+
*/
|
|
275
|
+
public function put( $rel, $content ) {
|
|
276
|
+
$check = self::validate_entry( 'md/' . $rel );
|
|
277
|
+
if ( 'store' !== $check['status'] || strlen( $content ) > self::MAX_FILE_BYTES ) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
$file = $this->md_dir() . '/' . $check['path'];
|
|
281
|
+
$dir = dirname( $file );
|
|
282
|
+
if ( ! is_dir( $dir ) && ! self::mkdir_p( $dir ) ) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
if ( ! $this->inside( $dir ) && realpath( $dir ) !== realpath( $this->md_dir() ) ) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
return false !== file_put_contents( $file, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Is there a stored file at this relative path?
|
|
293
|
+
*
|
|
294
|
+
* @param string $rel Relative path ("blog/my-post.md").
|
|
295
|
+
* @return bool
|
|
296
|
+
*/
|
|
297
|
+
public function exists( $rel ) {
|
|
298
|
+
$check = self::validate_entry( 'md/' . $rel );
|
|
299
|
+
if ( 'store' !== $check['status'] ) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
$file = $this->md_dir() . '/' . $check['path'];
|
|
303
|
+
return is_file( $file ) && $this->inside( $file );
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Delete one stored file.
|
|
308
|
+
*
|
|
309
|
+
* @param string $rel Relative path.
|
|
310
|
+
* @return bool Whether a file was deleted.
|
|
311
|
+
*/
|
|
312
|
+
public function delete( $rel ) {
|
|
313
|
+
if ( ! $this->exists( $rel ) ) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
return unlink( $this->md_dir() . '/' . $rel ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Relative file the router looks up first for a URL path (the full path,
|
|
321
|
+
* as uploads and the npm refine name them), or null for a bad path.
|
|
322
|
+
*
|
|
323
|
+
* @param string $path URL path ("/blog/my-post/").
|
|
324
|
+
* @return string|null
|
|
325
|
+
*/
|
|
326
|
+
public static function rel_for_path( $path ) {
|
|
327
|
+
$list = self::candidates( $path );
|
|
328
|
+
return $list ? $list[0] : null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Delete every stored file (keeps the folder and its protection files).
|
|
333
|
+
*
|
|
334
|
+
* @return void
|
|
335
|
+
*/
|
|
336
|
+
public function clear() {
|
|
337
|
+
self::rmdir_r( $this->md_dir() );
|
|
338
|
+
$this->ensure();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Delete the whole root folder (uninstall).
|
|
343
|
+
*
|
|
344
|
+
* @return void
|
|
345
|
+
*/
|
|
346
|
+
public function delete_all() {
|
|
347
|
+
self::rmdir_r( $this->root );
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Import a zip: replaces the stored files with the zip's md/ files.
|
|
352
|
+
*
|
|
353
|
+
* @param string $zip_file Path of the uploaded zip.
|
|
354
|
+
* @return array { ok: bool, error: string, stored: int, skipped: int, rejected: int, reasons: string[] }
|
|
355
|
+
*/
|
|
356
|
+
public function import_zip( $zip_file ) {
|
|
357
|
+
$result = array(
|
|
358
|
+
'ok' => false,
|
|
359
|
+
'error' => '',
|
|
360
|
+
'stored' => 0,
|
|
361
|
+
'skipped' => 0,
|
|
362
|
+
'rejected' => 0,
|
|
363
|
+
'reasons' => array(),
|
|
364
|
+
);
|
|
365
|
+
$size = is_file( $zip_file ) ? filesize( $zip_file ) : false;
|
|
366
|
+
if ( false === $size || 0 === $size ) {
|
|
367
|
+
$result['error'] = 'The upload is empty or missing.';
|
|
368
|
+
return $result;
|
|
369
|
+
}
|
|
370
|
+
if ( $size > self::MAX_ZIP_BYTES ) {
|
|
371
|
+
$result['error'] = 'The zip is larger than 20 MB.';
|
|
372
|
+
return $result;
|
|
373
|
+
}
|
|
374
|
+
$files = class_exists( 'ZipArchive' ) ? $this->read_zip_archive( $zip_file, $result ) : $this->read_zip_fallback( $zip_file, $result );
|
|
375
|
+
if ( null === $files ) {
|
|
376
|
+
return $result;
|
|
377
|
+
}
|
|
378
|
+
if ( ! $files ) {
|
|
379
|
+
$result['error'] = 'No .md files under md/ were found in the zip. Existing files were kept.';
|
|
380
|
+
return $result;
|
|
381
|
+
}
|
|
382
|
+
// Replace the set: a refined run is always complete.
|
|
383
|
+
$this->clear();
|
|
384
|
+
foreach ( $files as $rel => $content ) {
|
|
385
|
+
if ( $this->put( $rel, $content ) ) {
|
|
386
|
+
++$result['stored'];
|
|
387
|
+
} else {
|
|
388
|
+
++$result['rejected'];
|
|
389
|
+
$result['reasons'][] = $rel . ': could not be written';
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
$result['ok'] = $result['stored'] > 0;
|
|
393
|
+
if ( ! $result['ok'] ) {
|
|
394
|
+
$result['error'] = 'No file could be written. Check that wp-content/uploads is writable.';
|
|
395
|
+
}
|
|
396
|
+
return $result;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Count one classified entry; return the content to keep or null.
|
|
401
|
+
*
|
|
402
|
+
* @param array $check From validate_entry().
|
|
403
|
+
* @param string $name Entry name.
|
|
404
|
+
* @param int $size Uncompressed size.
|
|
405
|
+
* @param array $result Result (counts updated).
|
|
406
|
+
* @param int $total Running total of accepted bytes.
|
|
407
|
+
* @return bool Whether to read the entry.
|
|
408
|
+
*/
|
|
409
|
+
private static function accept_entry( $check, $name, $size, &$result, &$total ) {
|
|
410
|
+
if ( 'skip' === $check['status'] ) {
|
|
411
|
+
if ( 'folder' !== $check['reason'] ) {
|
|
412
|
+
++$result['skipped'];
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
if ( 'reject' === $check['status'] ) {
|
|
417
|
+
++$result['rejected'];
|
|
418
|
+
$result['reasons'][] = $name . ': ' . $check['reason'];
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
if ( $size > self::MAX_FILE_BYTES ) {
|
|
422
|
+
++$result['rejected'];
|
|
423
|
+
$result['reasons'][] = $name . ': larger than 2 MB';
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
if ( $total + $size > self::MAX_TOTAL_BYTES ) {
|
|
427
|
+
++$result['rejected'];
|
|
428
|
+
$result['reasons'][] = $name . ': over the 100 MB total';
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
$total += $size;
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Check one entry's content: size and valid UTF-8.
|
|
437
|
+
*
|
|
438
|
+
* @param string|false $content Content.
|
|
439
|
+
* @param string $name Entry name.
|
|
440
|
+
* @param array $result Result.
|
|
441
|
+
* @return bool
|
|
442
|
+
*/
|
|
443
|
+
private static function content_ok( $content, $name, &$result ) {
|
|
444
|
+
if ( false === $content || strlen( $content ) > self::MAX_FILE_BYTES ) {
|
|
445
|
+
++$result['rejected'];
|
|
446
|
+
$result['reasons'][] = $name . ': unreadable or too large';
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
if ( ! preg_match( '//u', $content ) ) {
|
|
450
|
+
++$result['rejected'];
|
|
451
|
+
$result['reasons'][] = $name . ': not UTF-8 text';
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Read entries with ZipArchive.
|
|
459
|
+
*
|
|
460
|
+
* @param string $zip_file Zip.
|
|
461
|
+
* @param array $result Result.
|
|
462
|
+
* @return array|null rel => content, or null on a fatal error.
|
|
463
|
+
*/
|
|
464
|
+
private function read_zip_archive( $zip_file, &$result ) {
|
|
465
|
+
$zip = new ZipArchive();
|
|
466
|
+
if ( true !== $zip->open( $zip_file ) ) {
|
|
467
|
+
$result['error'] = 'The file is not a valid zip.';
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
$files = array();
|
|
471
|
+
$total = 0;
|
|
472
|
+
$count = min( $zip->numFiles, self::MAX_ENTRIES );
|
|
473
|
+
for ( $i = 0; $i < $count; $i++ ) {
|
|
474
|
+
$stat = $zip->statIndex( $i );
|
|
475
|
+
if ( ! $stat ) {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
$name = $stat['name'];
|
|
479
|
+
$check = self::validate_entry( $name );
|
|
480
|
+
if ( ! self::accept_entry( $check, $name, (int) $stat['size'], $result, $total ) ) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
$content = $zip->getFromIndex( $i, self::MAX_FILE_BYTES + 1 );
|
|
484
|
+
if ( self::content_ok( $content, $name, $result ) ) {
|
|
485
|
+
$files[ $check['path'] ] = $content;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if ( $zip->numFiles > self::MAX_ENTRIES ) {
|
|
489
|
+
$result['reasons'][] = 'Only the first ' . self::MAX_ENTRIES . ' entries were read.';
|
|
490
|
+
}
|
|
491
|
+
$zip->close();
|
|
492
|
+
return $files;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Read entries with WordPress's unzip_file() (PclZip) when ZipArchive is
|
|
497
|
+
* missing: extract to a temp folder, then validate every extracted path
|
|
498
|
+
* exactly as zip entry names.
|
|
499
|
+
*
|
|
500
|
+
* @param string $zip_file Zip.
|
|
501
|
+
* @param array $result Result.
|
|
502
|
+
* @return array|null
|
|
503
|
+
*/
|
|
504
|
+
private function read_zip_fallback( $zip_file, &$result ) {
|
|
505
|
+
if ( ! function_exists( 'unzip_file' ) || ! function_exists( 'WP_Filesystem' ) ) {
|
|
506
|
+
require_once ABSPATH . 'wp-admin/includes/file.php';
|
|
507
|
+
}
|
|
508
|
+
if ( ! WP_Filesystem() ) {
|
|
509
|
+
$result['error'] = 'Could not access the file system to unzip.';
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
$tmp = $this->root . '/tmp-' . wp_generate_password( 12, false );
|
|
513
|
+
if ( ! self::mkdir_p( $tmp ) ) {
|
|
514
|
+
$result['error'] = 'Could not create a temporary folder.';
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
$unzipped = unzip_file( $zip_file, $tmp );
|
|
518
|
+
if ( is_wp_error( $unzipped ) ) {
|
|
519
|
+
self::rmdir_r( $tmp );
|
|
520
|
+
$result['error'] = 'The file is not a valid zip.';
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
$files = array();
|
|
524
|
+
$total = 0;
|
|
525
|
+
$seen = 0;
|
|
526
|
+
$it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $tmp, FilesystemIterator::SKIP_DOTS ) );
|
|
527
|
+
foreach ( $it as $f ) {
|
|
528
|
+
if ( ! $f->isFile() || ++$seen > self::MAX_ENTRIES ) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
$name = ltrim( str_replace( '\\', '/', substr( $f->getPathname(), strlen( $tmp ) ) ), '/' );
|
|
532
|
+
$check = self::validate_entry( $name );
|
|
533
|
+
if ( ! self::accept_entry( $check, $name, (int) $f->getSize(), $result, $total ) ) {
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
$content = file_get_contents( $f->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
|
|
537
|
+
if ( self::content_ok( $content, $name, $result ) ) {
|
|
538
|
+
$files[ $check['path'] ] = $content;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
self::rmdir_r( $tmp );
|
|
542
|
+
return $files;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── filesystem helpers ────────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Recursive mkdir.
|
|
549
|
+
*
|
|
550
|
+
* @param string $dir Folder.
|
|
551
|
+
* @return bool
|
|
552
|
+
*/
|
|
553
|
+
private static function mkdir_p( $dir ) {
|
|
554
|
+
if ( function_exists( 'wp_mkdir_p' ) ) {
|
|
555
|
+
return wp_mkdir_p( $dir );
|
|
556
|
+
}
|
|
557
|
+
return is_dir( $dir ) || mkdir( $dir, 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Recursive delete. Does not follow symlinks.
|
|
562
|
+
*
|
|
563
|
+
* @param string $dir Folder.
|
|
564
|
+
* @return void
|
|
565
|
+
*/
|
|
566
|
+
private static function rmdir_r( $dir ) {
|
|
567
|
+
if ( ! is_dir( $dir ) || is_link( $dir ) ) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
$it = new RecursiveIteratorIterator(
|
|
571
|
+
new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ),
|
|
572
|
+
RecursiveIteratorIterator::CHILD_FIRST
|
|
573
|
+
);
|
|
574
|
+
foreach ( $it as $f ) {
|
|
575
|
+
if ( $f->isDir() && ! $f->isLink() ) {
|
|
576
|
+
rmdir( $f->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
|
|
577
|
+
} else {
|
|
578
|
+
unlink( $f->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
|
|
582
|
+
}
|
|
583
|
+
}
|