@relvehq/agent-ready 0.3.0 → 0.4.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.
Files changed (29) hide show
  1. package/README.md +24 -3
  2. package/SETUP.md +354 -354
  3. package/dist/core/report.d.ts +9 -3
  4. package/dist/core/report.js +30 -10
  5. package/dist/core/version.d.ts +1 -0
  6. package/dist/core/version.js +8 -0
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.js +5 -1
  9. package/dist/refine/check.d.ts +3 -1
  10. package/dist/refine/check.js +43 -0
  11. package/dist/refine/remote.d.ts +3 -2
  12. package/dist/refine/remote.js +3 -2
  13. package/dist/wp/relve-agent-ready/includes/class-relve-ar-admin.php +560 -550
  14. package/dist/wp/relve-agent-ready/includes/class-relve-ar-converter.php +762 -762
  15. package/dist/wp/relve-agent-ready/includes/class-relve-ar-llms.php +192 -192
  16. package/dist/wp/relve-agent-ready/includes/class-relve-ar-markdown-builder.php +455 -455
  17. package/dist/wp/relve-agent-ready/includes/class-relve-ar-negotiation.php +334 -334
  18. package/dist/wp/relve-agent-ready/includes/class-relve-ar-refiner.php +894 -894
  19. package/dist/wp/relve-agent-ready/includes/class-relve-ar-reporter.php +147 -0
  20. package/dist/wp/relve-agent-ready/includes/class-relve-ar-response.php +226 -226
  21. package/dist/wp/relve-agent-ready/includes/class-relve-ar-router.php +422 -421
  22. package/dist/wp/relve-agent-ready/includes/class-relve-ar-settings.php +210 -201
  23. package/dist/wp/relve-agent-ready/includes/class-relve-ar-store.php +583 -583
  24. package/dist/wp/relve-agent-ready/includes/index.php +2 -2
  25. package/dist/wp/relve-agent-ready/index.php +2 -2
  26. package/dist/wp/relve-agent-ready/readme.txt +119 -108
  27. package/dist/wp/relve-agent-ready/relve-agent-ready.php +82 -81
  28. package/dist/wp/relve-agent-ready/uninstall.php +35 -35
  29. package/package.json +1 -1
@@ -1,583 +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
- }
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
+ }