Skip to content
Draft
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
251 changes: 247 additions & 4 deletions api/Handlers/FileHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,270 @@
exit;
}

use TaskShunt\Domain\RelativePath;
use TaskShunt\Domain\TaskAction;

/**
* Applies a single file change on the receiver site.
*
* Sender payload format:
* {
* "path": "theme/style.css" | "mu-plugins/foo.php",
* "action": "create" | "update" | "delete",
* "data": "<base64 contents>" // omitted for delete
* }
*
* The "theme/" prefix maps to the receiver's active stylesheet directory and
* "mu-plugins/" maps to WPMU_PLUGIN_DIR. Files outside these prefixes, files
* with non-allowlisted extensions, and paths that resolve outside the base
* directory are rejected.
*/
final class FileHandler {

/**
* Extensions accepted on the receiver side. Mirrors FileScanner's allowlist
* so the receiver enforces its own policy without coupling to the sender.
*
* @var list<string>
*/
private const ALLOWED_EXTENSIONS = array( 'php', 'css', 'js', 'json', 'html', 'htm', 'txt', 'svg', 'twig' );

/**
* Maximum decoded file size accepted on write.
*/
private const MAX_FILE_BYTES = 5 * 1024 * 1024;

/**
* Process a file item.
*
* @param TaskAction $action The action to perform.
* @param string $object_type File category (e.g. plugin, theme).
* @param int $object_id Original item ID from the sender.
* @param string $object_type File category (always "file" for FileScanner items).
* @param int $object_id Unused — file items are keyed by path.
* @param mixed $payload Decoded payload data.
* @return array{success: bool, message: string}
*/
public function handle( TaskAction $action, string $object_type, int $object_id, mixed $payload ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( ! is_array( $payload ) ) {
return $this->error( __( 'Invalid file payload.', 'taskshunt' ) );
}

$relative = (string) ( $payload['path'] ?? '' );

try {
$rel = new RelativePath( $relative );
} catch ( \InvalidArgumentException $e ) {
return $this->error( $e->getMessage() );
}

$resolved = $this->resolve_target( $rel->get_value() );
if ( null === $resolved ) {
/* translators: %s: relative file path */
return $this->error( sprintf( __( 'Unsupported file location: %s.', 'taskshunt' ), $rel->get_value() ) );
}

[ $base_dir, $abs_path ] = $resolved;

$ext = strtolower( pathinfo( $abs_path, PATHINFO_EXTENSION ) );
if ( ! in_array( $ext, self::ALLOWED_EXTENSIONS, true ) ) {
/* translators: %s: file extension */
return $this->error( sprintf( __( 'Disallowed file extension: %s.', 'taskshunt' ), $ext ) );
}

if ( ! $this->is_within_base( $abs_path, $base_dir ) ) {
return $this->error( __( 'Resolved file path escapes the allowed directory.', 'taskshunt' ) );
}

if ( TaskAction::Delete === $action ) {
return $this->apply_delete( $abs_path, $rel->get_value() );
}

return $this->apply_write( $payload, $abs_path, $rel->get_value() );
}

/**
* Map a "label/sub/path" relative path onto an absolute target on the receiver.
*
* @param string $relative Validated relative path (no traversal, no leading slash).
* @return array{0: string, 1: string}|null Tuple of [base_dir, abs_path], or null if unsupported.
*/
private function resolve_target( string $relative ): ?array {
$parts = explode( '/', $relative, 2 );
if ( 2 !== count( $parts ) || '' === $parts[1] ) {
return null;
}

[ $label, $sub_path ] = $parts;

$base = match ( $label ) {
'theme' => get_stylesheet_directory(),
'mu-plugins' => defined( 'WPMU_PLUGIN_DIR' ) ? WPMU_PLUGIN_DIR : '',
default => '',
};

if ( '' === $base ) {
return null;
}

$base = rtrim( $base, '/\\' );

return array( $base, $base . '/' . $sub_path );
}

/**
* Verify the absolute path resolves inside the base directory.
*
* RelativePath already rejects "..", but we re-check the realpath of the
* deepest existing ancestor to defend against symlink games on the receiver.
*
* @param string $abs_path Candidate absolute path (file may not yet exist).
* @param string $base_dir Allowed base directory.
* @return bool
*/
private function is_within_base( string $abs_path, string $base_dir ): bool {
$real_base = realpath( $base_dir );
if ( false === $real_base ) {
return false;
}

$real_base = rtrim( $real_base, '/\\' );

// Walk up to the first ancestor that exists, so we can resolve symlinks safely.
$ancestor = $abs_path;
while ( '' !== $ancestor && '/' !== $ancestor && ! file_exists( $ancestor ) ) {
$parent = dirname( $ancestor );
if ( $parent === $ancestor ) {
break;
}
$ancestor = $parent;
}

$real_ancestor = realpath( $ancestor );
if ( false === $real_ancestor ) {
return false;
}

return $real_ancestor === $real_base
|| str_starts_with( $real_ancestor, $real_base . '/' )
|| str_starts_with( $real_ancestor, $real_base . DIRECTORY_SEPARATOR );
}

/**
* Decode and write the file contents to disk, creating parent directories as needed.
*
* @param array<string, mixed> $payload Decoded payload data.
* @param string $abs_path Target absolute path.
* @param string $relative Relative path for the success message.
* @return array{success: bool, message: string}
*/
private function apply_write( array $payload, string $abs_path, string $relative ): array {
if ( ! array_key_exists( 'data', $payload ) || ! is_string( $payload['data'] ) ) {
return $this->error( __( 'File payload missing contents.', 'taskshunt' ) );
}

$decoded = base64_decode( $payload['data'], true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
if ( false === $decoded ) {
return $this->error( __( 'Failed to decode file contents.', 'taskshunt' ) );
}

if ( strlen( $decoded ) > self::MAX_FILE_BYTES ) {
return $this->error( __( 'File exceeds the receiver size limit.', 'taskshunt' ) );
}

global $wp_filesystem;
if ( ! $wp_filesystem ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
if ( ! WP_Filesystem() ) {
return $this->error( __( 'Filesystem unavailable on receiver.', 'taskshunt' ) );
}
}

$parent_dir = dirname( $abs_path );
if ( ! $wp_filesystem->is_dir( $parent_dir ) && ! wp_mkdir_p( $parent_dir ) ) {
return $this->error( __( 'Failed to create parent directory.', 'taskshunt' ) );
}

if ( ! $wp_filesystem->put_contents( $abs_path, $decoded, FS_CHMOD_FILE ) ) {
return $this->error( __( 'Failed to write file.', 'taskshunt' ) );
}

$this->bust_opcache( $abs_path );

return array(
'success' => true,
/* translators: 1: action name, 2: object ID */
'message' => sprintf( __( 'File %1$s queued for object %2$d.', 'taskshunt' ), $action->value, $object_id ),
/* translators: %s: relative file path */
'message' => sprintf( __( 'File "%s" written.', 'taskshunt' ), $relative ),
);
}

/**
* Delete the file. Treats "already gone" as success so retries are idempotent.
*
* @param string $abs_path Target absolute path.
* @param string $relative Relative path for the result message.
* @return array{success: bool, message: string}
*/
private function apply_delete( string $abs_path, string $relative ): array {
if ( ! file_exists( $abs_path ) ) {
return array(
'success' => true,
/* translators: %s: relative file path */
'message' => sprintf( __( 'File "%s" already absent.', 'taskshunt' ), $relative ),
);
}

global $wp_filesystem;
if ( ! $wp_filesystem ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
if ( ! WP_Filesystem() ) {
return $this->error( __( 'Filesystem unavailable on receiver.', 'taskshunt' ) );
}
}

if ( ! $wp_filesystem->delete( $abs_path ) ) {
return $this->error( __( 'Failed to delete file.', 'taskshunt' ) );
}

$this->bust_opcache( $abs_path );

return array(
'success' => true,
/* translators: %s: relative file path */
'message' => sprintf( __( 'File "%s" deleted.', 'taskshunt' ), $relative ),
);
}

/**
* Invalidate opcache for a PHP file so the new contents take effect immediately.
*
* Without this, FPM may serve the cached bytecode of the previous version
* until the file's mtime crosses opcache.revalidate_freq.
*
* @param string $abs_path Absolute path of the file just written or deleted.
* @return void
*/
private function bust_opcache( string $abs_path ): void {
if ( ! str_ends_with( $abs_path, '.php' ) ) {
return;
}

if ( ! function_exists( 'opcache_invalidate' ) ) {
return;
}

// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.opcache_opcache_invalidate -- We just rewrote this file; serving the cached bytecode would defeat the push.
opcache_invalidate( $abs_path, true );
}

/**
* Standard error result.
*
* @param string $message Error message.
* @return array{success: bool, message: string}
*/
private function error( string $message ): array {
return array(
'success' => false,
'message' => $message,
);
}
}
65 changes: 55 additions & 10 deletions includes/Services/FileScanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ final class FileScanner {
*/
private const THROTTLE_SECONDS = 30;

/**
* Maximum bytes embedded in a file payload. Larger files are skipped so the
* push request body stays under typical reverse-proxy and PHP upload limits.
*/
private const MAX_FILE_BYTES = 5 * 1024 * 1024;

/**
* Create the file scanner.
*
Expand Down Expand Up @@ -216,28 +222,67 @@ private function detect_deletions( int $task_id, array $seen_paths ): void {
* @param string $abs_path Absolute path for payload (empty for deletes).
* @return void
*/
private function record_change( int $task_id, TaskAction $action, string $relative, string $abs_path ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( $this->task_item_repository->item_exists( $task_id, TaskItemType::File, 'file', $relative ) ) {
return;
private function record_change( int $task_id, TaskAction $action, string $relative, string $abs_path ): void {
$payload_data = array(
'path' => $relative,
'action' => $action->value,
);

if ( TaskAction::Delete !== $action ) {
$contents = $this->read_file_contents( $abs_path );
if ( null === $contents ) {
// Unreadable or oversized — skip recording. The snapshot upsert still
// happens in the caller, so we don't churn on the same file every scan.
return;
}
$payload_data['data'] = base64_encode( $contents ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
}

$payload = wp_json_encode(
array(
'path' => $relative,
'action' => $action->value,
)
);
$payload = (string) wp_json_encode( $payload_data );

// If the file is already in the task, refresh its payload so a later push
// ships the latest contents (not whatever was captured on the first edit).
$existing = $this->task_item_repository->find_item( $task_id, TaskItemType::File, 'file', $relative );
if ( null !== $existing ) {
$this->task_item_repository->update_payload( $existing->id, $payload );
return;
}

$this->task_item_repository->add_item(
$task_id,
TaskItemType::File,
$action,
'file',
$relative,
(string) $payload
$payload
);
}

/**
* Read a file's raw contents, returning null if it's too large or unreadable.
*
* Uses native PHP IO instead of WP_Filesystem because the scanner runs inside
* admin_init, where WP_Filesystem may not yet be initialized — and it would
* silently return null, dropping every detected change.
*
* @param string $abs_path Absolute file path.
* @return string|null
*/
private function read_file_contents( string $abs_path ): ?string {
if ( ! is_readable( $abs_path ) ) {
return null;
}

$size = filesize( $abs_path );
if ( false === $size || $size > self::MAX_FILE_BYTES ) {
return null;
}

// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- Reading a local file at a path we control; WP_Filesystem is unreliable here, see method docblock.
$contents = file_get_contents( $abs_path );
return false !== $contents ? $contents : null;
}

/**
* Whether the scan is throttled (ran too recently).
*
Expand Down