diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5572f08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# Composer +/vendor/ +composer.lock + +# PHP +*.log +*.cache +.php_cs.cache +.phpunit.result.cache + +# IDE and Editor files +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# WordPress specific +*.sql +*.sql.gz +wp-config.php +wp-content/advanced-cache.php +wp-content/backup-db/ +wp-content/backups/ +wp-content/blogs.dir/ +wp-content/cache/ +wp-content/upgrade/ +wp-content/uploads/ +wp-content/wp-cache-config.php +wp-content/plugins/hello.php + +# Environment files +.env +.env.* +!.env.example + +# Node modules (if using build tools) +/node_modules/ +npm-debug.log +yarn-debug.log +yarn-error.log + +# Build and dist directories +/dist/ +/build/ +.sass-cache/ + +# Testing +/coverage/ +/tests/coverage/ +.phpunit.cache/ + +# Operating System +Thumbs.db +ehthumbs.db +Desktop.ini diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1afe9c --- /dev/null +++ b/README.md @@ -0,0 +1,200 @@ +# WP CoderPress Endpoints + +This package intends to provide a more straightforward way to add custom endpoints to Wordpress Rest API (added to mu-plugins with no hooks, as a suggestion), also using a Facade pattern which allows: +* To attach PSR-16 compliant cache drivers of any nature +* To attach as many middlewares as you want to modify the request or provide a particular response +* Interfere in the way cached responses are stored and retrieved (JSON, serialize(), igbinary_serialize()) +* Provide a port to add multiple custom endpoints without repeating code and sharing cache, middleware and variables within, including a dynamic endpoint generation +* Offer common middlewares to handle common scenarios in REST API development + +# Basic Example + +The following example creates a very simple example endpoint to the rest API that comes with two middlewares attached (as \Closure objects). The first one modifies any passed parameter to "id = 123", which triggers the second middleware and returns a WP_REST_Response - this also prevents the cache to be done. + +If the second middleware is removed, the endpoint is no longer short-circuited, so the results are now serialized and cached in a file within wp-content/cache/. In the example, the cache is managed by FileCache, but this package also includes usable drivers for Redis (using redis-php extension) and MySQL cache, using a custom DB table. + +```php +use CoderPress\Rest\{RestEndpointFacade, AbstractRestEndpoint}; +use CoderPress\Cache\{RedisCache, MySqlCache, FileCache}; + +require __DIR__ . '/vendor/autoload.php'; + +$cacheInstance = new FileCache(WP_CONTENT_DIR . '/cache/'); + +$custom = RestEndpointFacade::createEndpoint( + /** The five first arguments stand as they are for register_rest_route() + * but with callbacks now accepting \Closure functions instead. + * + * The middlewares arguments accepts an array of closures which will be + * sequentially executed and MUST have a $request parameter. + * + * The cache mechanism is automatically applied to the endpoint and accepts + * any PSR-16 compliant object. Optionally, the expire time and the type + * of serialization can be changed. Expires accepts any value in seconds as + * integer or a Datetime object (and then the time in seconds between that and + * the current time will be automatically extracted) + * + */ + namespace: 'testing/v1', + route: 'custom/', + args: [ + 'id' => [ + 'validate_callback' => function($param, $request, $key) { + return is_numeric($param); + } + ], + ], + callback: function (\WP_REST_Request $request) { + $postId = $request->get_param('id'); + $postCategoryIds = wp_get_object_terms($postId, ['category'], ['fields' => 'ids']); + + return get_posts([ + 'post_status' => 'publish', + 'category__in' => $postCategoryIds, + 'order' => 'relevance' + ]); + }, + permissionCallback: function () { + return is_user_logged_in(); + }, + /** + * Accepts any number of /Closure middleware functions - to each one of them, + * the $request object must be passed. For changes made to the WP_REST_Request object, + * the closure must return void(). However, the middleware can also return a response + * to the request easily by return a new WP_REST_Response object instead. + */ + middlewares: [ + function (\WP_REST_Request $request) { + $request->set_param('id', 123); + }, + function (\WP_REST_Request $request) { + if ($request->get_param('id') == 123) { + return new \WP_REST_Response(['message' => 'Invalid value'], 201); + } + } + ], + /** + * Accepts any instance PSR-16 compliant (CacheInterface contract), + * this package comes with 3 usable examples (file, Redis and custom MySQL + * table caching drivers). + */ + cache: $cacheInstance, + /** + * Accepts 3 different serialize/unserialize methods - defaults to serialize(), + * but can be using JSON - AbstractRestEndpoint::SERIALIZE_JSON - or igbinary PHP + * extension which offers a more efficient serializing version + * - AbstractRestEndpoint::SERIALIZE_IGBINARY. + */ + cacheSerializingMethod: AbstractRestEndpoint::SERIALIZE_PHP, + /** + * Accepts seconds as integer value, but also DateTime objects - for the latter, + * the system will get the interval between NOW and the passed DateTime object time, + * so the cache will work as scheduled. + */ + cacheExpires: (new \DateTime())->modify('+1 day') +); +``` + +# Middlewares + +The package includes several built-in middlewares to handle common scenarios in REST API development: + +## CORS Middleware + +The CORS (Cross-Origin Resource Sharing) middleware allows you to configure cross-origin requests to your API endpoints. Here's how to use it: + +```php +use CoderPress\Rest\RestEndpointFacade; +use CoderPress\Rest\Middleware\MiddlewareFactory; + +// Create an endpoint with CORS support +RestEndpointFacade::createEndpoint( + namespace: 'api/v1', + route: 'posts', + callback: function($request) { + return ['data' => 'Your API response']; + }, + permissionCallback: function() { + return true; + }, + args: [], + methods: ['GET', 'POST', 'OPTIONS'], + middlewares: [ + MiddlewareFactory::cors( + allowedOrigins: ['https://your-domain.com'], + allowedMethods: ['GET', 'POST'], + allowedHeaders: ['Content-Type', 'X-Custom-Header'], + maxAge: 7200 + ) + ] +); +``` + +The CORS middleware provides: +- Origin validation against a whitelist +- Configurable HTTP methods +- Customizable allowed headers +- Preflight request handling +- Configurable cache duration for preflight responses +- Credentials support + +All parameters are optional and come with sensible defaults for typical API usage. + +## Sanitization Middleware + +The Sanitization middleware provides comprehensive input sanitization and validation for your API endpoints. It helps prevent XSS attacks, SQL injection, and ensures data consistency: + +```php +use CoderPress\Rest\RestEndpointFacade; +use CoderPress\Rest\Middleware\MiddlewareFactory; + +RestEndpointFacade::createEndpoint( + namespace: 'api/v1', + route: 'posts', + callback: function($request) { + return ['data' => $request->get_params()]; + }, + permissionCallback: function() { + return true; + }, + args: [ + 'title' => [ + 'required' => true, + 'type' => 'string' + ], + 'content' => [ + 'required' => true, + 'type' => 'string' + ] + ], + methods: ['POST'], + middlewares: [ + MiddlewareFactory::sanitization( + rules: [ + // Custom sanitization rules for specific fields + 'title' => fn($value) => sanitize_title($value), + 'content' => fn($value) => wp_kses_post($value) + ], + stripTags: true, + encodeSpecialChars: true, + allowedHtmlTags: [ + 'p' => [], + 'a' => ['href' => [], 'title' => []], + 'b' => [], + 'i' => [] + ] + ) + ] +); +``` + +The middleware provides: +- Field-specific sanitization rules using callbacks +- HTML tag stripping with configurable allowed tags +- Special character encoding +- UTF-8 validation +- Recursive array sanitization +- WordPress-specific sanitization functions integration +- XSS and SQL injection protection + +All parameters are optional and come with secure defaults. Use the `rules` parameter to define custom sanitization logic for specific fields. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..05fc6b4 --- /dev/null +++ b/composer.json @@ -0,0 +1,34 @@ +{ + "name": "cmatosbc/wp-coderpress-endpoints", + "description": "Endpoints builder and manager for CoderPress", + "license": "gpl-3.0-or-later", + "type": "library", + "authors": [ + { + "name": "Carlos Matos", + "email": "carlosarturmatos1977@gmail.com" + } + ], + "config": { + "sort-packages": true + }, + "autoload": { + "psr-4": { + "CoderPress\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "CoderPress\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.0", + "psr/simple-cache": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0", + "brain/monkey": "^2.6", + "psr/http-server-handler": "^1.0" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..b9e21c1 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + tests + + + + + src + + + diff --git a/src/Cache/FileCache.php b/src/Cache/FileCache.php new file mode 100644 index 0000000..e84f324 --- /dev/null +++ b/src/Cache/FileCache.php @@ -0,0 +1,116 @@ +directory = $directory; + if (!is_dir($this->directory)) { + mkdir($this->directory, 0777, true); + } + } + + private function getFilePath(string $key): string + { + return $this->directory . '/' . $key . '.cache'; + } + + public function get(string $key, mixed $default = null): mixed + { + $filePath = $this->getFilePath($key); + if (!file_exists($filePath)) { + return $default; + } + + $expirationTime = fileatime($filePath) + (int) ($this->ttl ?? 0); + if ($expirationTime < time()) { + unlink($filePath); + return $default; + } + + $data = file_get_contents($filePath); + if ($data === false) { + return $default; + } + + return $data; + } + + public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool + { + $filePath = $this->getFilePath($key); + $data = $value; + + if ($ttl !== null) { + $ttl = $ttl instanceof \DateInterval ? $ttl->s : $ttl; + touch($filePath, mtime: time() + $ttl, atime: time() + $ttl); + } + + return file_put_contents($filePath, $data) !== false; + } + + public function delete(string $key): bool + { + $filePath = $this->getFilePath($key); + return unlink($filePath); + } + + public function clear(): bool + { + $files = glob($this->directory . '/*.cache'); + foreach ($files as $file) { + unlink($file); + } + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + $results = []; + foreach ($keys as $key) { + $results[$key] = $this->get($key, $default); + } + return $results; + } + + public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool + + { + $success = true; + foreach ($values as $key => $value) { + $success = $success && $this->set($key, $value, $ttl); + } + return $success; + } + + public function deleteMultiple(iterable $keys): bool + { + $success = true; + foreach ($keys as $key) { + $success = $success && $this->delete($key); + } + return $success; + } + + public function has(string $key): bool + { + $filePath = $this->getFilePath($key); + return file_exists($filePath); + } +} diff --git a/src/Cache/MySqlCache.php b/src/Cache/MySqlCache.php new file mode 100644 index 0000000..d6768fb --- /dev/null +++ b/src/Cache/MySqlCache.php @@ -0,0 +1,207 @@ +wpdb = $wpdb; + $this->tableName = $wpdb->prefix . $dbTableName; + $this->defaultTtl = $defaultTtl; + + $this->createCacheTable(); + } + + /** + * Creates the wp_cached_requests table if it does not already exist. + */ + private function createCacheTable(): void + { + $charsetCollate = $this->wpdb->get_charset_collate(); + $sql = "CREATE TABLE IF NOT EXISTS {$this->tableName} ( + `cache_key` VARCHAR(255) NOT NULL PRIMARY KEY, + `cache_value` MEDIUMTEXT NOT NULL, + `expiration` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) $charsetCollate;"; + + $this->wpdb->query($sql); + } + + /** + * Retrieves a value from the cache. + * + * @param string $key The cache key. + * @param mixed $default The default value to return if the key is not found. + * @return mixed The cached value, or the default value if not found. + */ + public function get(string $key, mixed $default = null): mixed + { + $sql = "SELECT cache_value FROM {$this->tableName} WHERE cache_key = %s AND expiration > NOW();"; + $prepared = $this->wpdb->prepare($sql, $key); + $result = $this->wpdb->get_var($prepared); + + return $result !== false ? unserialize($result) : $default; + } + + /** + * Stores a value in the cache. + * + * @param string $key The cache key. + * @param mixed $value The value to store. + * @param null|int|\DateInterval $ttl The time to live for the cached value, in seconds. + * @return bool True if the value was stored successfully, false otherwise. + */ + public function set(string $key, mixed $value, null|int|\DateInterval $ttl = 3600): bool + { + $data = serialize($value); + $expiration = Time::getExpireTime($ttl); + + $sql = "INSERT INTO {$this->tableName} (cache_key, cache_value, expiration) VALUES (%s, %s, %s) + ON DUPLICATE KEY UPDATE cache_value = %s, expiration = %s;"; + $prepared = $this->wpdb->prepare($sql, $key, $data, $expiration, $data, $expiration); + + return $this->wpdb->query($prepared) !== false; + } + + /** + * Deletes a value from the cache. + * + * @param string $key The cache key. + * @return bool True if the value was deleted successfully, false otherwise. + */ + public function delete(string $key): bool + { + $sql = "DELETE FROM {$this->tableName} WHERE cache_key = %s;"; + $prepared = $this->wpdb->prepare($sql, $key); + + return $this->wpdb->delete($this->tableName, ['cache_key' => $key]) !== false; + } + + /** + * Clears the entire cache. + * + * @return bool True if the cache was cleared successfully, false otherwise. + */ + public function clear(): bool + { + $sql = "DELETE FROM {$this->tableName};"; + return $this->wpdb->query($sql) !== false; + } + + /** + * Retrieves multiple values from the cache. + * + * @param iterable $keys The cache keys. + * @param mixed $default The default value to return if a key is not found. + * @return iterable An iterable containing the cached values, or the default value if not found. + */ + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + $keyList = implode(',', array_map(function ($key) { + return $this->wpdb->prepare('%s', $key); + }, $keys)); + + $sql = "SELECT cache_key, cache_value FROM {$this->tableName} WHERE cache_key IN ($keyList) AND expiration > NOW();"; + $results = $this->wpdb->get_results($sql, ARRAY_A); + + $cachedValues = []; + foreach ($results as $result) { + $cachedValues[$result['cache_key']] = unserialize($result['cache_value']); + } + + foreach ($keys as $key) { + if (!isset($cachedValues[$key])) { + $cachedValues[$key] = $default; + } + } + + return $cachedValues; + } + + /** + * Stores multiple values in the cache. + * + * @param iterable $values The values to store. + * @param null|int|\DateInterval $ttl The time to live for the cached values, in seconds. + * @return bool True if all values were stored successfully, false otherwise. + */ + public function setMultiple(iterable $values, null|int|\DateInterval $ttl = 3600): bool + { + $sql = "INSERT INTO {$this->tableName} (cache_key, cache_value, expiration) VALUES "; + $placeholders = []; + $data = []; + foreach ($values as $key => $value) { + $data[] = serialize($value); + $placeholders[] = "(%s, %s, %s)"; + } + + $expiration = Time::getExpireTime($ttl); + $sql .= implode(', ', $placeholders) . " ON DUPLICATE KEY UPDATE cache_value = VALUES(cache_value), expiration = VALUES(expiration);"; + + $prepared = $this->wpdb->prepare($sql, array_merge(array_keys($values), $data, array_fill(0, count($values), $expiration))); + + return $this->wpdb->query($prepared) !== false; + } + + /** + * Deletes multiple values from the cache. + * + * @param iterable $keys The cache keys. + * @return bool True if all values were deleted successfully, false otherwise. + */ + public function deleteMultiple(iterable $keys): bool + { + $keyList = implode(',', array_map(function ($key) { + return $this->wpdb->prepare('%s', $key); + }, $keys)); + + $sql = "DELETE FROM {$this->tableName} WHERE cache_key IN ($keyList);"; + return $this->wpdb->query($sql) !== false; + } + + /** + * Checks if a value exists in the cache. + * + * @param string $key The cache key. + * @return bool True if the value exists, false otherwise. + */ + public function has(string $key): bool + { + $sql = "SELECT 1 FROM {$this->tableName} WHERE cache_key = %s AND expiration > NOW();"; + $prepared = $this->wpdb->prepare($sql, $key); + return $this->wpdb->get_var($prepared) !== false; + } +} diff --git a/src/Cache/RedisCache.php b/src/Cache/RedisCache.php new file mode 100644 index 0000000..415259e --- /dev/null +++ b/src/Cache/RedisCache.php @@ -0,0 +1,105 @@ +redis = new \Redis(); + $this->redis->connect($host, $port); + if (!empty($password)) { + $this->redis->auth($password); + } + $this->redis->select($database); + } catch (\RedisException $e) { + throw new DriverException('Failed to connect to Redis: ' . $e->getMessage()); + } + + $this->redis->connect($host, $port); + if (!empty($password)) { + $this->redis->auth($password); + } + $this->redis->select($database); + } + + public function get(string $key, mixed $default = null): mixed + { + $value = $this->redis->get($key); + return $value !== false ? $value : $default; + } + + public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool + { + if ($ttl !== null) { + $ttl = $ttl instanceof \DateInterval ? $ttl->s : $ttl; + return $this->redis->setex($key, $ttl, $value); + } else { + return $this->redis->set($key, $value); + } + } + + public function delete(string $key): bool + { + return $this->redis->del($key) > 0; + } + + public function clear(): bool + { + return $this->redis->flushAll(); + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + $values = $this->redis->mget(array_values($keys)); + $results = []; + foreach ($keys as $key => $originalKey) { + $results[$originalKey] = isset($values[$key]) ? $values[$key] : $default; + } + return $results; + } + + public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool + { + $pipeline = $this->redis->pipeline(); + foreach ($values as $key => $value) { + if ($ttl !== null) { + $ttl = $ttl instanceof \DateInterval ? $ttl->s : $ttl; + $pipeline->setex($key, $ttl, $value); + } else { + $pipeline->set($key, $value); + } + } + return $pipeline->execute() === array_fill(0, count($values), true); + } + + public function deleteMultiple(iterable $keys): bool + { + return $this->redis->del(array_values($keys)) > 0; + } + + public function has(string $key): bool + { + return $this->redis->exists($key); + } +} diff --git a/src/Exceptions/DriverException.php b/src/Exceptions/DriverException.php new file mode 100644 index 0000000..6a8f6f0 --- /dev/null +++ b/src/Exceptions/DriverException.php @@ -0,0 +1,8 @@ +cacheExpires = Time::getTimeDiff($cacheExpires); + } + } + + /** + * Adds the cache middleware to the endpoint if a cache instance is provided. + * + * @param WP_REST_Request $request The request object. + * @return WP_REST_Response|null The response object if the request is cached, or null otherwise. + */ + private function addCacheProcess(\WP_REST_Request $request) { + $cache = $this->cache; + $key = md5(implode('.', $request->get_params())); + $isCached = $cache->get($key); + + if ($isCached) { + return new WP_REST_Response($this->selectiveUnserialize($isCached), 200); + } + } + + /** + * Registers the endpoint with the WordPress REST API. + */ + public function register() + { + add_action('rest_api_init', function () { + register_rest_route($this->namespace, $this->route, [ + 'methods' => $this->methods, + 'callback' => [$this, 'handle'], + 'permission_callback' => $this->permissionCallback, + 'args' => $this->args, + ]); + }); + } + + /** + * Adds a middleware callback to the endpoint. + * + * @param callable $middleware The middleware callback. + */ + public function addMiddleware(callable $middleware) + { + $this->middlewares[] = $middleware; + } + + /** + * Handles the endpoint request. + * + * @param WP_REST_Request $request The request object. + * @return WP_REST_Response The response object. + */ + public function handle(WP_REST_Request $request): WP_REST_Response + { + $response = $process = null; + + if (!is_null($this->cache)) { + $response = $this->addCacheProcess($request); + } + + foreach ($this->middlewares as $middleware) { + $process = $middleware($request); + + if ($process instanceof WP_REST_Response) { + return $process; + } + } + + if (is_null($response)) { + $callable = $this->callback; + $response = $callable($request); + } + + if (is_array($response)) { + if (!is_null($this->cache)) { + $key = md5(implode('.', $request->get_params())); + $this->cache->set($key, $this->selectiveSerialize($response), $this->cacheExpires); + } + return $this->getResponse($response); + } + + return $response; + } + + /** + * Selectively serializes data based on the configured serialization method. + * + * @param mixed $data The data to serialize. + * @return string The serialized data. + */ + private function selectiveSerialize(mixed $data): string + { + return match ($this->cacheSerializingMethod) { + 0 => serialize($data), + 1 => json_encode($data), + 2 => igbinary_serialize($data), + default => $data, + }; + } + + /** + * Selectively unserializes data based on the configured serialization method. + * + * @param mixed $data The serialized data. + * @return mixed The unserialized data. + */ + private function selectiveUnserialize(mixed $data): mixed + { + return match ($this->cacheSerializingMethod) { + 0 => unserialize($data), + 1 => json_decode($data, true), + 2 => igbinary_unserialize($data), + default => $data, + }; + } + + /** + * Creates a new WP_REST_Response object. + * + * @param array $data The data to include in the response. + * @param int $status The HTTP status code for the response. + * @return WP_REST_Response The response object. + */ + protected function getResponse(array $data, int $status = 200): WP_REST_Response + { + return new WP_REST_Response($data, $status); + } +} diff --git a/src/Rest/Middleware/CorsMiddleware.php b/src/Rest/Middleware/CorsMiddleware.php new file mode 100644 index 0000000..71ecc00 --- /dev/null +++ b/src/Rest/Middleware/CorsMiddleware.php @@ -0,0 +1,90 @@ +allowedOrigins = $allowedOrigins; + $this->allowedMethods = $allowedMethods; + $this->allowedHeaders = $allowedHeaders; + $this->maxAge = $maxAge; + } + + /** + * Middleware invocation handler + * + * Processes the request and adds appropriate CORS headers to the response. + * Handles preflight (OPTIONS) requests automatically. + * + * @param WP_REST_Request $request The incoming request object + * @param callable $next The next middleware in the chain + * @return WP_REST_Response The modified response with CORS headers + */ + public function __invoke(WP_REST_Request $request, callable $next): WP_REST_Response + { + $response = $next($request); + + $origin = $request->get_header('origin'); + + if ($origin && ($this->allowedOrigins === ['*'] || in_array($origin, $this->allowedOrigins))) { + $response->header('Access-Control-Allow-Origin', $origin); + } + + if ($request->get_method() === 'OPTIONS') { + $response->header('Access-Control-Allow-Methods', implode(', ', $this->allowedMethods)); + $response->header('Access-Control-Allow-Headers', implode(', ', $this->allowedHeaders)); + $response->header('Access-Control-Max-Age', $this->maxAge); + } + + $response->header('Access-Control-Allow-Credentials', 'true'); + + return $response; + } +} diff --git a/src/Rest/Middleware/MiddlewareFactory.php b/src/Rest/Middleware/MiddlewareFactory.php new file mode 100644 index 0000000..dce02eb --- /dev/null +++ b/src/Rest/Middleware/MiddlewareFactory.php @@ -0,0 +1,60 @@ +rules = $rules; + $this->stripTags = $stripTags; + $this->encodeSpecialChars = $encodeSpecialChars; + $this->allowedHtmlTags = $allowedHtmlTags; + $this->encoding = $encoding; + } + + /** + * Middleware invocation handler + * + * Processes and sanitizes all request parameters according to configuration. + * + * @param WP_REST_Request $request The incoming request object + * @param callable $next The next middleware in the chain + * @return WP_REST_Response The response with sanitized parameters + */ + public function __invoke(WP_REST_Request $request, callable $next): WP_REST_Response + { + $params = $request->get_params(); + $sanitizedParams = $this->sanitizeData($params); + + foreach ($sanitizedParams as $key => $value) { + $request->set_param($key, $value); + } + + return $next($request); + } + + /** + * Recursively sanitizes an array of data + * + * @param mixed $data The data to sanitize + * @param string $path Current path for nested fields + * @return mixed The sanitized data + */ + private function sanitizeData($data, $path = '') + { + if (is_array($data)) { + $sanitized = []; + foreach ($data as $key => $value) { + $currentPath = $path ? "$path.$key" : $key; + $sanitized[$key] = $this->sanitizeValue($value, $currentPath); + } + return $sanitized; + } + + return $this->sanitizeValue($data, $path); + } + + /** + * Sanitizes a single value + * + * Applies custom rules, strips tags, encodes special characters, + * and performs additional sanitization as configured. + * + * @param mixed $value The value to sanitize + * @param string $path Field path for rule matching + * @return mixed The sanitized value + */ + private function sanitizeValue($value, string $path) + { + if (is_array($value)) { + return $this->sanitizeData($value, $path); + } + + if (!is_string($value)) { + return $value; + } + + $value = trim($value); + $value = wp_check_invalid_utf8($value); + + // Apply custom rules first + if (isset($this->rules[$path])) { + $value = call_user_func($this->rules[$path], $value); + } + + // Strip HTML tags if enabled + if ($this->stripTags) { + if ($this->allowedHtmlTags) { + $value = wp_kses($value, $this->allowedHtmlTags); + } else { + $value = wp_strip_all_tags($value, true); + } + } + + // Encode special characters if enabled + if ($this->encodeSpecialChars && !$this->stripTags) { + $value = htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, $this->encoding, false); + } + + return $value; + } +} diff --git a/src/Rest/RestEndpointFacade.php b/src/Rest/RestEndpointFacade.php new file mode 100644 index 0000000..ccbe997 --- /dev/null +++ b/src/Rest/RestEndpointFacade.php @@ -0,0 +1,71 @@ +register(); + return $endpoint; + } +} diff --git a/src/Utilities/Time.php b/src/Utilities/Time.php new file mode 100644 index 0000000..a6943ff --- /dev/null +++ b/src/Utilities/Time.php @@ -0,0 +1,43 @@ +setTimezone(new \DateTimeZone($timezone)); + } + + $interval = $now->diff($dateTime); + $seconds = $interval->h * 3600 + $interval->i * 60 + $interval->s; + return $seconds; + } + + /** + * Gets the expiration time for a given interval. + * + * @param \DateInterval|int $interval The interval to add to the current time. + * @return string The expiration time in Y-m-d H:i:s format. + */ + public static function getExpireTime(\DateInterval|int $interval) + { + $now = new \DateTime(); + + if (is_numeric($interval)) { + $interval = \DateInterval::createFromDateString($interval . ' seconds'); + } + + $futureDate = $now->add($interval); + + return $now->format('Y-m-d H:i:s'); + } +} \ No newline at end of file diff --git a/tests/Rest/Middleware/CorsMiddlewareTest.php b/tests/Rest/Middleware/CorsMiddlewareTest.php new file mode 100644 index 0000000..b76f8af --- /dev/null +++ b/tests/Rest/Middleware/CorsMiddlewareTest.php @@ -0,0 +1,105 @@ +middleware = new CorsMiddleware( + allowedOrigins: ['https://example.com'], + allowedMethods: ['GET', 'POST'], + allowedHeaders: ['Content-Type'], + maxAge: 3600 + ); + + $this->request = new WP_REST_Request(); + $this->response = new WP_REST_Response(); + } + + public function testAddsCorsHeadersForAllowedOrigin() + { + // Create a mock for get_header to return our test origin + $this->request = $this->getMockBuilder(WP_REST_Request::class) + ->onlyMethods(['get_header']) + ->getMock(); + + $this->request->expects($this->once()) + ->method('get_header') + ->with('origin') + ->willReturn('https://example.com'); + + $next = fn() => $this->response; + + $result = ($this->middleware)($this->request, $next); + + $headers = $result->get_headers(); + $this->assertArrayHasKey('Access-Control-Allow-Origin', $headers); + $this->assertEquals('https://example.com', $headers['Access-Control-Allow-Origin']); + $this->assertArrayHasKey('Access-Control-Allow-Credentials', $headers); + $this->assertEquals('true', $headers['Access-Control-Allow-Credentials']); + } + + public function testHandlesPreflightRequest() + { + // Create a mock for both get_header and get_method + $this->request = $this->getMockBuilder(WP_REST_Request::class) + ->onlyMethods(['get_header', 'get_method']) + ->getMock(); + + $this->request->expects($this->once()) + ->method('get_header') + ->with('origin') + ->willReturn('https://example.com'); + + $this->request->expects($this->once()) + ->method('get_method') + ->willReturn('OPTIONS'); + + $next = fn() => $this->response; + + $result = ($this->middleware)($this->request, $next); + + $headers = $result->get_headers(); + $this->assertArrayHasKey('Access-Control-Allow-Origin', $headers); + $this->assertEquals('https://example.com', $headers['Access-Control-Allow-Origin']); + $this->assertArrayHasKey('Access-Control-Allow-Methods', $headers); + $this->assertEquals('GET, POST', $headers['Access-Control-Allow-Methods']); + $this->assertArrayHasKey('Access-Control-Allow-Headers', $headers); + $this->assertEquals('Content-Type', $headers['Access-Control-Allow-Headers']); + $this->assertArrayHasKey('Access-Control-Max-Age', $headers); + $this->assertEquals(3600, $headers['Access-Control-Max-Age']); + } + + public function testIgnoresDisallowedOrigin() + { + $this->request = $this->getMockBuilder(WP_REST_Request::class) + ->onlyMethods(['get_header']) + ->getMock(); + + $this->request->expects($this->once()) + ->method('get_header') + ->with('origin') + ->willReturn('https://unauthorized.com'); + + $next = fn() => $this->response; + + $result = ($this->middleware)($this->request, $next); + + $headers = $result->get_headers(); + $this->assertArrayNotHasKey('Access-Control-Allow-Origin', $headers); + $this->assertArrayHasKey('Access-Control-Allow-Credentials', $headers); + $this->assertEquals('true', $headers['Access-Control-Allow-Credentials']); + } +} diff --git a/tests/Rest/Middleware/SanitizationMiddlewareTest.php b/tests/Rest/Middleware/SanitizationMiddlewareTest.php new file mode 100644 index 0000000..fc91ea6 --- /dev/null +++ b/tests/Rest/Middleware/SanitizationMiddlewareTest.php @@ -0,0 +1,108 @@ +middleware = new SanitizationMiddleware(); + $this->request = Mockery::mock(WP_REST_Request::class); + } + + public function testSanitizesInputAccordingToRules() + { + $params = [ + 'title' => '

TEST TITLE

', + 'content' => 'Hello World' + ]; + + $this->request->shouldReceive('get_params') + ->once() + ->andReturn($params); + + $this->request->shouldReceive('set_param') + ->once() + ->with('title', 'TEST TITLE'); + + $this->request->shouldReceive('set_param') + ->once() + ->with('content', 'Hello World'); + + $response = new WP_REST_Response(); + $result = ($this->middleware)($this->request, function($request) use ($response) { + return $response; + }); + + $this->assertSame($response, $result); + } + + public function testHandlesNestedArrays() + { + $params = [ + 'meta' => [ + 'title' => '

TEST TITLE

', + 'description' => 'Hello World' + ] + ]; + + $this->request->shouldReceive('get_params') + ->once() + ->andReturn($params); + + $this->request->shouldReceive('set_param') + ->once() + ->with('meta', [ + 'title' => 'TEST TITLE', + 'description' => 'Hello World' + ]); + + $response = new WP_REST_Response(); + $result = ($this->middleware)($this->request, function($request) use ($response) { + return $response; + }); + + $this->assertSame($response, $result); + } + + public function testPreservesNonStringValues() + { + $params = [ + 'id' => 123, + 'active' => true, + 'price' => 99.99 + ]; + + $this->request->shouldReceive('get_params') + ->once() + ->andReturn($params); + + $this->request->shouldReceive('set_param') + ->once() + ->with('id', 123); + + $this->request->shouldReceive('set_param') + ->once() + ->with('active', true); + + $this->request->shouldReceive('set_param') + ->once() + ->with('price', 99.99); + + $response = new WP_REST_Response(); + $result = ($this->middleware)($this->request, function($request) use ($response) { + return $response; + }); + + $this->assertSame($response, $result); + } +} diff --git a/tests/Stubs/WP_REST_Request.php b/tests/Stubs/WP_REST_Request.php new file mode 100644 index 0000000..557f82e --- /dev/null +++ b/tests/Stubs/WP_REST_Request.php @@ -0,0 +1,27 @@ +headers[$key] = $value; + } + + public function get_headers() + { + return $this->headers; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..531e2f9 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,46 @@ +alias(function($string, $keep_line_breaks = false) { + // Remove script tags and their content first + $string = preg_replace('/]*>(.*?)<\/script>/is', '', $string); + // Then remove all remaining HTML tags + return strip_tags($string); + }); + + Functions\when('wp_kses')->alias(function($string, $allowed_html) { + return strip_tags($string); + }); + + Functions\when('wp_check_invalid_utf8')->alias(function($string) { + return $string; + }); + + Functions\when('is_user_logged_in')->justReturn(true); + } + + protected function tearDown(): void + { + Monkey\tearDown(); + parent::tearDown(); + } +}