Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions src/Http/Request/InteractsWithHeaders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

namespace BitApps\WPKit\Http\Request;

use WP_REST_Request;

/**
* Header access for a request: the hydrated WP_REST_Request when the host sets `$rest`, else the server globals.
*/
trait InteractsWithHeaders
{
/**
* CGI exposes these headers without the HTTP_ prefix.
*/
private static array $_unprefixedHeaders = ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'];

/**
* Returns every request header, sanitized, keyed by canonical name (lowercase, underscores).
*
* @return array<string, string>
*/
public function headers()
{
return array_map('sanitize_text_field', $this->rawHeaders());
}

/**
* Returns one sanitized header by any name style ("X-Token" or "x_token"), or the default when absent.
*
* @return null|string
*/
public function header(string $name, ?string $default = null)
{
$value = $this->rawHeader($name);

return $value === null ? $default : sanitize_text_field($value);
}

/**
* Returns one header exactly as sent, for signature or token checks only; never output or store it unsanitized.
*
* @return null|string
*/
public function rawHeader(string $name, ?string $default = null)
{
return $this->rawHeaders()[self::canonicalHeaderName($name)] ?? $default;
}

/**
* Reports whether the request carries the header.
*
* @return bool
*/
public function hasHeader(string $name)
{
return \array_key_exists(self::canonicalHeaderName($name), $this->rawHeaders());
}

/**
* Collects unsanitized headers from the REST request when hydrated, else from the server globals.
*
* @return array<string, string>
*/
private function rawHeaders(): array
{
if (isset($this->rest) && $this->rest instanceof WP_REST_Request) {
return array_map(static fn ($values) => implode(', ', (array) $values), (array) $this->rest->get_headers());
}

return $this->serverHeaders();
}

/**
* Collects headers from $_SERVER, which WordPress slashes.
*
* @return array<string, string>
*/
private function serverHeaders(): array
{
$headers = [];
foreach ($_SERVER as $key => $value) {
if (!\is_string($value)) {
continue;
}

if (str_starts_with($key, 'HTTP_')) {
$headers[self::canonicalHeaderName(substr($key, 5))] = wp_unslash($value);
} elseif ($value !== '' && \in_array($key, self::$_unprefixedHeaders, true)) {
// FastCGI passes CONTENT_* as '' when the request has no body.
$headers[self::canonicalHeaderName($key)] = wp_unslash($value);
}
}

return $headers;
}

/**
* Normalizes a header name the way WP_REST_Request does.
*/
private static function canonicalHeaderName(string $name): string
{
return str_replace('-', '_', strtolower($name));
}
}
47 changes: 44 additions & 3 deletions src/Http/Request/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@

class Request extends Validator implements ArrayAccess, JsonSerializable
{
use InteractsWithHeaders;
use IpTool;

private const APPLICATION_MEDIA_PREFIX = 'application/';

protected $rest;

protected array $attributes = [];
Expand All @@ -28,6 +31,11 @@ class Request extends Validator implements ArrayAccess, JsonSerializable

protected $body = [];

/**
* Raw php://input, read at most once.
*/
private ?string $_content = null;

/**
* Undocumented function.
*/
Expand Down Expand Up @@ -98,14 +106,47 @@ public function body()
return $this->body;
}

/**
* Returns the raw request body exactly as sent, e.g. for signature checks.
*
* @return string
*/
public function content()
{
if (isset($this->rest)) {
return (string) $this->rest->get_body();
}

return $this->_content ??= (string) file_get_contents('php://input');
}

/**
* Returns the sanitized, upper-cased HTTP method, or '' when the server omits it.
*
* @return string
*/
public function method()
{
return sanitize_text_field($_SERVER['REQUEST_METHOD']);
if (!isset($_SERVER['REQUEST_METHOD'])) {
return '';
}

return strtoupper(sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])));
}

/**
* Returns the sanitized Content-Type with a leading "application/" removed (e.g. "json").
*
* @return string
*/
public function contentType()
{
return isset($_SERVER['CONTENT_TYPE']) ? ltrim($_SERVER['CONTENT_TYPE'], 'application/') : '';
$type = (string) $this->header('Content-Type', '');

// Media types are case-insensitive (RFC 9110 §8.3.1).
return str_starts_with(strtolower($type), self::APPLICATION_MEDIA_PREFIX)
? substr($type, \strlen(self::APPLICATION_MEDIA_PREFIX))
: $type;
Comment thread
abdul-kaioum marked this conversation as resolved.
Resolved
}

public function setApiRequest(WP_REST_Request $request)
Expand Down Expand Up @@ -246,7 +287,7 @@ protected function setBody($body = [])
!str_contains($this->contentType(), 'form-data')
&& !str_contains($this->contentType(), 'x-www-form-urlencoded')
) {
$this->body = JSON::maybeDecode(file_get_contents('php://input'), JsonConfig::decodeAsArray());
$this->body = JSON::maybeDecode($this->content(), JsonConfig::decodeAsArray());
}

if (!empty($_POST)) {
Expand Down
9 changes: 5 additions & 4 deletions src/Http/Router/RewriteRuleSet.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ public function __construct(string $pageName)
$this->_pageName = trim($pageName, '/');
}

/**
* Adds the rule for one route path; only an empty path maps the page root itself.
*/
public function addPath(string $path): void
{
if (empty($this->_rules)) {
$this->_rules["^{$this->_pageName}/?$"] = "index.php?pagename={$this->_pageName}";
}

$path = trim($path, '/');
if ($path === '') {
$this->_rules["^{$this->_pageName}/?$"] = "index.php?pagename={$this->_pageName}";

return;
}

Expand Down
69 changes: 64 additions & 5 deletions src/Http/Router/StaticRouter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@

use BitApps\WPKit\Http\RequestType;
use BitApps\WPKit\Http\Response;
use WP;

if (!\defined('ABSPATH')) {
exit;
}

class StaticRouter
{
/**
* Seconds to wait before re-flushing rules that did not persist (e.g. filtered out by another plugin).
*/
private const FLUSH_RETRY_SECONDS = 300;

private const FLUSH_RETRY_TRANSIENT_PREFIX = 'wpkit_static_flush_';

private Router $router;

private string $pageName;
Expand All @@ -34,9 +42,13 @@ public function flushOnActivate(): void
flush_rewrite_rules();
}

/**
* Empties the persisted rules so the next request rebuilds them without ours; a flush here would
* re-save them, as they are still registered on the deactivating request.
*/
public function flushOnDeactivate(): void
{
flush_rewrite_rules();
update_option('rewrite_rules', '');
}

public function registerRewriteRules(): void
Expand All @@ -59,10 +71,27 @@ public function addQueryVars($vars): array
return array_merge($vars, $this->queryVars);
}

public function handleRequest(): void
/**
* Dispatches on parse_request instead of template_redirect, before the main query runs or a 404 is
* flagged; for routes that send their own response and exit (callbacks, webhooks), not page content.
*/
public function dispatchOnParseRequest(): self
{
remove_action('template_redirect', [$this, 'handleRequest']);
add_action('parse_request', [$this, 'handleRequest']);

return $this;
}

/**
* Runs the first route matching the request; parse_request passes the WP object, template_redirect nothing.
*
* @param mixed $wp
*/
public function handleRequest($wp = null): void
{
$requestPath = sanitize_url((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH));
$method = strtoupper(sanitize_text_field($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$requestPath = $this->requestPath($wp instanceof WP ? $wp : ($GLOBALS['wp'] ?? null));
$method = strtoupper(sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'] ?? 'GET')));
foreach ($this->router->getRoutes() as $route) {
if (!\in_array($method, $route->getMethods(), true)) {
continue;
Expand Down Expand Up @@ -122,12 +151,26 @@ public static function isRewriteExists(?string $path = '', ?array $rewriteRules
return true;
}

/**
* Flushes when our rules are missing from the persisted set, at most once per retry window, and never
* under plain permalinks where rewrite rules are unused.
*/
public function maybeFlushRewriteRules(): void
{
if (empty($this->rewriteRules) || self::isRewriteExists('', $this->rewriteRules)) {
if (
empty($this->rewriteRules)
|| !get_option('permalink_structure')
|| self::isRewriteExists('', $this->rewriteRules)
) {
return;
}

$retryKey = self::FLUSH_RETRY_TRANSIENT_PREFIX . $this->pageName;
if (get_transient($retryKey) !== false) {
return;
}

set_transient($retryKey, 1, self::FLUSH_RETRY_SECONDS);
flush_rewrite_rules();
}

Expand All @@ -140,6 +183,22 @@ private function registerHooks(string $activationHook, string $deactivationHook)
add_action('template_redirect', [$this, 'handleRequest']);
}

/**
* Request path relative to the site root: WP's parsed path when set (strips home and index.php),
* else the request URI minus the home path (plain permalinks).
*/
private function requestPath(?WP $wp): string
{
if ($wp !== null && \is_string($wp->request) && $wp->request !== '') {
return sanitize_url('/' . trim($wp->request, '/'));
}

$path = (string) wp_parse_url(sanitize_url(wp_unslash($_SERVER['REQUEST_URI'] ?? '')), PHP_URL_PATH);
$homePath = rtrim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');

return $homePath !== '' && str_starts_with($path, $homePath . '/') ? substr($path, \strlen($homePath)) : $path;
}

private function processRoutes(): void
{
$ruleSet = new RewriteRuleSet($this->pageName);
Expand Down
Loading
Loading