diff --git a/Dockerfile b/Dockerfile index 121704726..4c9bb416a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libsqlite3-dev \ libonig-dev \ locales \ + nodejs \ + npm \ unzip \ git \ && sed -i 's/# en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen \ @@ -20,6 +22,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Install Composer COPY --from=composer:2 /usr/bin/composer /usr/bin/composer +# Install Playground CLI for WordPress integration smoke tests. +RUN npm install -g @wp-playground/cli@latest + WORKDIR /app # Install dependencies first (cached layer for faster rebuilds) diff --git a/bin/run-server-dev.sh b/bin/run-server-dev.sh index 48e82482d..1f39d8738 100644 --- a/bin/run-server-dev.sh +++ b/bin/run-server-dev.sh @@ -10,5 +10,6 @@ npx @wp-playground/cli@latest \ --mount=`pwd`/plugins/static-files-editor:/wordpress/wp-content/plugins/static-files-editor \ --mount=`pwd`/plugins/url-updater:/wordpress/wp-content/plugins/url-updater \ --mount=`pwd`/plugins/git-repo:/wordpress/wp-content/plugins/git-repo \ + --mount=`pwd`/plugins/wp-origin:/wordpress/wp-content/plugins/wp-origin \ --mount=`pwd`/.my-notes-git:/wordpress/wp-content/uploads \ - --blueprint=./blueprint-dev.json \ No newline at end of file + --blueprint=./blueprint-dev.json diff --git a/bin/test-git-cli-e2e.sh b/bin/test-git-cli-e2e.sh new file mode 100755 index 000000000..4500d1d1e --- /dev/null +++ b/bin/test-git-cli-e2e.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -eu + +docker compose run --rm sandbox vendor/bin/phpunit components/Git/Tests/GitCliEndToEndTest.php diff --git a/bin/test-wp-origin-git-actions.sh b/bin/test-wp-origin-git-actions.sh new file mode 100755 index 000000000..178341f83 --- /dev/null +++ b/bin/test-wp-origin-git-actions.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -eu + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PORT="${WP_ORIGIN_E2E_PORT:-9409}" +PLAYGROUND_LOG="$ROOT_DIR/.context/wp-origin-playground.log" +CREDENTIALS_FILE="$ROOT_DIR/.context/wp-origin-e2e.json" +WORK_DIR="$(mktemp -d)" + +if command -v wp-playground >/dev/null 2>&1; then + PLAYGROUND_CMD="wp-playground" +else + PLAYGROUND_CMD="npx @wp-playground/cli" +fi + +cleanup() { + if [ -n "${PLAYGROUND_PID:-}" ] && kill -0 "$PLAYGROUND_PID" 2>/dev/null; then + kill "$PLAYGROUND_PID" 2>/dev/null || true + wait "$PLAYGROUND_PID" 2>/dev/null || true + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT INT TERM + +mkdir -p "$ROOT_DIR/.context" +rm -f "$PLAYGROUND_LOG" "$CREDENTIALS_FILE" + +cd "$ROOT_DIR" + +$PLAYGROUND_CMD server \ + --port="$PORT" \ + --blueprint="$ROOT_DIR/plugins/wp-origin/blueprint-e2e.json" \ + --mount="$ROOT_DIR:/workspace" \ + --mount="$ROOT_DIR/vendor:/wordpress/wp-content/vendor" \ + --mount="$ROOT_DIR/components:/wordpress/wp-content/components" \ + --mount="$ROOT_DIR/plugins/wp-origin:/wordpress/wp-content/plugins/wp-origin" \ + >"$PLAYGROUND_LOG" 2>&1 & +PLAYGROUND_PID=$! + +for _ in $(seq 1 120); do + if [ -f "$CREDENTIALS_FILE" ]; then + break + fi + sleep 1 +done + +if [ ! -f "$CREDENTIALS_FILE" ]; then + cat "$PLAYGROUND_LOG" + echo "WP Origin e2e setup did not produce credentials." >&2 + exit 1 +fi + +USERNAME="$(php -r 'echo json_decode(file_get_contents($argv[1]), true)["username"];' "$CREDENTIALS_FILE")" +PASSWORD="$(php -r 'echo json_decode(file_get_contents($argv[1]), true)["password"];' "$CREDENTIALS_FILE")" + +AUTH_HEADER="$(php -r 'echo base64_encode($argv[1] . ":" . $argv[2]);' "$USERNAME" "$PASSWORD")" +BASE_URL="http://127.0.0.1:$PORT" +REMOTE_AUTH_URL="http://$USERNAME:$PASSWORD@127.0.0.1:$PORT/wp-json/git/v1/md.git" +REMOTE_URL="$BASE_URL/wp-json/git/v1/md.git" +CLONE_DIR="$WORK_DIR/clone" + +git -c protocol.version=2 clone "$REMOTE_AUTH_URL" "$CLONE_DIR" + +test -f "$CLONE_DIR/post/hello-world.md" +test -f "$CLONE_DIR/page/sample-page.md" +grep -q 'Hello from WordPress' "$CLONE_DIR/post/hello-world.md" + +cd "$CLONE_DIR" +git config user.name "WP Origin E2E" +git config user.email "wp-origin-e2e@example.com" + +php -r ' +$path = $argv[1]; +$contents = file_get_contents($path); +$contents = str_replace("Hello from WordPress", "Updated from Git", $contents); +file_put_contents($path, $contents); +' "$CLONE_DIR/post/hello-world.md" + +git add post/hello-world.md +git commit -m "Update hello world from Git" +git push origin trunk + +POST_ID="$(curl -sS -H "Authorization: Basic $AUTH_HEADER" "$BASE_URL/wp-json/wp/v2/posts?slug=hello-world&context=edit" | php -r ' +$posts = json_decode(stream_get_contents(STDIN), true); +echo $posts[0]["id"]; +')" + +UPDATED_CONTENT="$(curl -sS -H "Authorization: Basic $AUTH_HEADER" "$BASE_URL/wp-json/wp/v2/posts/$POST_ID?context=edit" | php -r ' +$post = json_decode(stream_get_contents(STDIN), true); +echo $post["content"]["raw"]; +')" +printf '%s' "$UPDATED_CONTENT" | grep -q 'Updated from Git' + +php -r ' +$path = $argv[1]; +$contents = file_get_contents($path); +$contents = str_replace("Updated from Git", "Stale local edit", $contents); +file_put_contents($path, $contents); +' "$CLONE_DIR/post/hello-world.md" + +git add post/hello-world.md +git commit -m "Create stale local edit" + +UPDATE_PAYLOAD='{"content":"
Updated in WordPress
"}' +curl -sS \ + -X POST \ + -H "Authorization: Basic $AUTH_HEADER" \ + -H "Content-Type: application/json" \ + -d "$UPDATE_PAYLOAD" \ + "$BASE_URL/wp-json/wp/v2/posts/$POST_ID?context=edit" >/dev/null + +if git push origin trunk; then + echo "Expected stale push to fail." >&2 + exit 1 +fi + +git pull --rebase origin trunk +grep -q 'Updated in WordPress' "$CLONE_DIR/post/hello-world.md" diff --git a/components/Git/Tests/GitCliEndToEndTest.php b/components/Git/Tests/GitCliEndToEndTest.php new file mode 100644 index 000000000..3494f1c3f --- /dev/null +++ b/components/Git/Tests/GitCliEndToEndTest.php @@ -0,0 +1,280 @@ +markTestSkipped( 'proc_open() is required for the Git CLI end-to-end test.' ); + } + if ( ! $this->command_is_available( 'git' ) ) { + $this->markTestSkipped( 'The git command is required for the Git CLI end-to-end test.' ); + } + + $this->temp_dir = sys_get_temp_dir() . '/php-toolkit-git-cli-' . uniqid(); + $this->git_home = $this->temp_dir . '/home'; + $this->remote_repository_path = $this->temp_dir . '/remote-repository'; + $this->working_copy_path = $this->temp_dir . '/working-copy'; + $this->router_script_path = dirname( __FILE__ ) . '/fixtures/git-http-endpoint-router.php'; + + mkdir( $this->temp_dir, 0777, true ); + mkdir( $this->git_home, 0777, true ); + + $this->initialize_remote_repository(); + $this->start_http_server(); + } + + /** + * @after + */ + public function tear_down_environment() { + if ( is_resource( $this->server_process ) ) { + @proc_terminate( $this->server_process ); + @proc_close( $this->server_process ); + } + + $this->delete_directory( $this->temp_dir ); + } + + public function test_real_git_cli_can_clone_push_and_pull() { + $remote_url = sprintf( 'http://127.0.0.1:%d/repo.git', $this->server_port ); + $this->assert_command_succeeds( + sprintf( + 'git -c protocol.version=2 clone %s %s', + escapeshellarg( $remote_url ), + escapeshellarg( $this->working_copy_path ) + ) + ); + + $this->assertSame( + "Hello from the server\n", + file_get_contents( $this->working_copy_path . '/README.md' ) + ); + + $this->assert_command_succeeds( 'git config user.name "PHP Toolkit"', $this->working_copy_path ); + $this->assert_command_succeeds( 'git config user.email "php-toolkit@example.com"', $this->working_copy_path ); + + file_put_contents( $this->working_copy_path . '/README.md', "Updated from clone\n" ); + $this->assert_command_succeeds( 'git add README.md', $this->working_copy_path ); + $this->assert_command_succeeds( 'git commit -m "Update README"', $this->working_copy_path ); + $this->assert_command_succeeds( 'git push origin trunk', $this->working_copy_path ); + + $remote_repository = $this->open_remote_repository(); + $this->assertSame( + "Updated from clone\n", + $remote_repository->read_object_by_path( '/README.md' )->consume_all() + ); + + $remote_repository->set_branch_tip( 'HEAD', 'ref: refs/heads/trunk' ); + $remote_repository->commit( + array( + 'updates' => array( + 'README.md' => "Updated on the server\n", + ), + ) + ); + + $this->assert_command_succeeds( 'git pull --ff-only origin trunk', $this->working_copy_path ); + $this->assertSame( + "Updated on the server\n", + file_get_contents( $this->working_copy_path . '/README.md' ) + ); + } + + private function initialize_remote_repository() { + $repository = $this->open_remote_repository(); + $repository->set_config_value( 'user.name', 'PHP Toolkit' ); + $repository->set_config_value( 'user.email', 'php-toolkit@example.com' ); + $repository->set_branch_tip( 'HEAD', 'ref: refs/heads/trunk' ); + $repository->commit( + array( + 'updates' => array( + 'README.md' => "Hello from the server\n", + ), + ) + ); + } + + private function open_remote_repository() { + return new GitRepository( + LocalFilesystem::create( $this->remote_repository_path ), + array( + 'default_branch' => 'trunk', + ) + ); + } + + private function start_http_server() { + $this->server_port = $this->find_available_port(); + + $command = sprintf( + '%s -S 127.0.0.1:%d %s', + escapeshellarg( PHP_BINARY ), + $this->server_port, + escapeshellarg( $this->router_script_path ) + ); + + $descriptor_spec = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'file', $this->temp_dir . '/server.stdout.log', 'a' ), + 2 => array( 'file', $this->temp_dir . '/server.stderr.log', 'a' ), + ); + + $this->server_process = proc_open( + $command, + $descriptor_spec, + $pipes, + dirname( dirname( dirname( dirname( __FILE__ ) ) ) ), + array( + 'PHP_TOOLKIT_GIT_E2E_REPOSITORY_PATH' => $this->remote_repository_path, + ) + ); + + if ( ! is_resource( $this->server_process ) ) { + $this->fail( 'Failed to start the PHP built-in server for the Git CLI end-to-end test.' ); + } + + fclose( $pipes[0] ); + + $started = false; + for ( $attempt = 0; $attempt < 50; $attempt++ ) { + $socket = @fsockopen( '127.0.0.1', $this->server_port ); + if ( false !== $socket ) { + fclose( $socket ); + $started = true; + break; + } + usleep( 100000 ); + } + + if ( ! $started ) { + $this->fail( + "Failed to start the PHP built-in server.\n" . + $this->get_server_logs() + ); + } + } + + private function find_available_port() { + $server = stream_socket_server( 'tcp://127.0.0.1:0', $errno, $errstr ); + if ( false === $server ) { + $this->fail( sprintf( 'Failed to find an available port: %s', $errstr ) ); + } + + $server_name = stream_socket_get_name( $server, false ); + fclose( $server ); + + return intval( substr( strrchr( $server_name, ':' ), 1 ) ); + } + + private function command_is_available( $command ) { + $result = $this->run_command( sprintf( 'command -v %s', escapeshellarg( $command ) ) ); + + return 0 === $result['exit_code']; + } + + private function assert_command_succeeds( $command, $cwd = null ) { + $result = $this->run_command( $command, $cwd ); + + $this->assertSame( + 0, + $result['exit_code'], + sprintf( + "Command failed: %s\nstdout:\n%s\nstderr:\n%s\nserver logs:\n%s", + $command, + $result['stdout'], + $result['stderr'], + $this->get_server_logs() + ) + ); + + return $result; + } + + private function run_command( $command, $cwd = null ) { + $descriptor_spec = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + + $process = proc_open( + $command, + $descriptor_spec, + $pipes, + $cwd, + array( + 'GIT_CONFIG_NOSYSTEM' => '1', + 'GIT_TERMINAL_PROMPT' => '0', + 'HOME' => $this->git_home, + ) + ); + + if ( ! is_resource( $process ) ) { + $this->fail( sprintf( 'Failed to start command: %s', $command ) ); + } + + fclose( $pipes[0] ); + $stdout = stream_get_contents( $pipes[1] ); + $stderr = stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + + return array( + 'exit_code' => proc_close( $process ), + 'stdout' => $stdout, + 'stderr' => $stderr, + ); + } + + private function get_server_logs() { + $stdout_log = $this->temp_dir . '/server.stdout.log'; + $stderr_log = $this->temp_dir . '/server.stderr.log'; + + return "stdout:\n" . + ( is_file( $stdout_log ) ? file_get_contents( $stdout_log ) : '' ) . + "\nstderr:\n" . + ( is_file( $stderr_log ) ? file_get_contents( $stderr_log ) : '' ); + } + + private function delete_directory( $path ) { + if ( ! $path || ! file_exists( $path ) ) { + return; + } + + if ( is_file( $path ) || is_link( $path ) ) { + @unlink( $path ); + return; + } + + $entries = scandir( $path ); + if ( false === $entries ) { + return; + } + + foreach ( $entries as $entry ) { + if ( '.' === $entry || '..' === $entry ) { + continue; + } + $this->delete_directory( $path . '/' . $entry ); + } + + @rmdir( $path ); + } +} diff --git a/components/Git/Tests/GitRepositoryTest.php b/components/Git/Tests/GitRepositoryTest.php index 7601f90a0..e690f9b85 100644 --- a/components/Git/Tests/GitRepositoryTest.php +++ b/components/Git/Tests/GitRepositoryTest.php @@ -173,6 +173,22 @@ public function test_commit() { $this->assertEquals( $commit_oid, $repo->get_branch_tip() ); } + public function test_initial_commit_has_no_null_parent() { + $repo = new GitRepository( InMemoryFilesystem::create() ); + $repo->set_branch_tip( 'refs/heads/trunk', Commit::NULL_HASH ); + $repo->set_branch_tip( 'HEAD', 'ref: refs/heads/trunk' ); + + $commit_oid = $repo->commit( + array( + 'updates' => array( + 'README.md' => 'Hello, world!', + ), + ) + ); + + $this->assertSame( array(), $repo->read_object( $commit_oid )->as_commit()->parents ); + } + public function test_find_path_descendants() { $repo = new GitRepository( InMemoryFilesystem::create() ); $repo->set_branch_tip( 'refs/heads/trunk', Commit::NULL_HASH ); diff --git a/components/Git/Tests/fixtures/git-http-endpoint-router.php b/components/Git/Tests/fixtures/git-http-endpoint-router.php new file mode 100644 index 000000000..c4e43bd58 --- /dev/null +++ b/components/Git/Tests/fixtures/git-http-endpoint-router.php @@ -0,0 +1,54 @@ + 'trunk', + ) +); +$endpoint = new GitEndpoint( $repository ); +$response = new BufferingResponseWriter(); + +try { + $endpoint->handle_request( + $git_path, + file_get_contents( 'php://input' ), + $response + ); +} catch ( Exception $exception ) { + http_response_code( 500 ); + header( 'Content-Type: text/plain' ); + echo $exception->getMessage(); +} diff --git a/components/Git/class-gitrepository.php b/components/Git/class-gitrepository.php index c0b81287e..42b24b30b 100644 --- a/components/Git/class-gitrepository.php +++ b/components/Git/class-gitrepository.php @@ -727,7 +727,7 @@ public function commit( $options = array() ) { // Create a new commit object. $commit_options = $options['commit'] ?? array(); $commit_options['tree'] = $root_tree_oid; - if ( ! isset( $commit_options['parents'] ) && $this->get_branch_tip( 'HEAD' ) ) { + if ( ! isset( $commit_options['parents'] ) && ! Commit::is_null_hash( $head ) ) { $commit_options['parents'] = array( $head ); } @@ -919,8 +919,8 @@ public function get_commits_range( string $head_oid, string $last_ancestor_oid, continue; } $visited[ $current_oid ] = true; - $commits[] = $current_oid; - $commit = $this->read_object( $current_oid )->as_commit(); + $commits[] = $current_oid; + $commit = $this->read_object( $current_oid )->as_commit(); foreach ( $commit->parents as $parent_hash ) { $queue[] = $parent_hash; } diff --git a/components/Markdown/Tests/MarkdownConsumerTest.php b/components/Markdown/Tests/MarkdownConsumerTest.php index 1283d72f4..36f066958 100644 --- a/components/Markdown/Tests/MarkdownConsumerTest.php +++ b/components/Markdown/Tests/MarkdownConsumerTest.php @@ -204,4 +204,19 @@ public function test_frontmatter_extraction() { ); $this->assertEquals( $expected_metadata, $metadata ); } + + public function test_gutenberg_fence_is_preserved_as_block_markup() { + $markdown = <<Roses are red+``` +MD; + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + + $this->assertEquals( + '
Roses are red', + trim( $result->get_block_markup() ) + ); + } } diff --git a/components/Markdown/Tests/MarkdownProducerTest.php b/components/Markdown/Tests/MarkdownProducerTest.php index cafb49151..738e2a5cf 100644 --- a/components/Markdown/Tests/MarkdownProducerTest.php +++ b/components/Markdown/Tests/MarkdownProducerTest.php @@ -147,6 +147,17 @@ public static function provider_test_conversion() { | Cell 3 | Cell 4 | +MD + , + ), + 'Unsupported blocks are preserved as gutenberg fences' => array( + 'blocks' => '
Roses are red', + 'expected' => <<
Roses are red+``` + MD , ), diff --git a/components/Markdown/class-markdownconsumer.php b/components/Markdown/class-markdownconsumer.php index c49a41459..4a4339a0c 100644 --- a/components/Markdown/class-markdownconsumer.php +++ b/components/Markdown/class-markdownconsumer.php @@ -181,7 +181,7 @@ private function convert_markdown_to_blocks() { if ( method_exists( $node, 'getInfo' ) && $node->getInfo() ) { $attrs['language'] = preg_replace( '/[ \t\r\n\f].*/', '', $node->getInfo() ); } - if ( 'block' === $attrs['language'] ) { + if ( 'block' === $attrs['language'] || 'gutenberg' === $attrs['language'] ) { // This is a special case for preserving block literals that could not be expressed as markdown. $this->append_content( "\n" . $node->getLiteral() . "\n" ); } else { @@ -287,7 +287,6 @@ private function convert_markdown_to_blocks() { break; default: - error_log( 'Unhandled node type: ' . get_class( $node ) ); return null; } } else { diff --git a/components/Markdown/class-markdownproducer.php b/components/Markdown/class-markdownproducer.php index 1e20cf429..b88fa04a8 100644 --- a/components/Markdown/class-markdownproducer.php +++ b/components/Markdown/class-markdownproducer.php @@ -282,7 +282,7 @@ function ( $cell, $width ) { } $markdown = array(); $markdown[] = ''; - $markdown[] = '```block'; + $markdown[] = '```gutenberg'; $markdown[] = \serialize_block( $block ); $markdown[] = '```'; $markdown[] = ''; diff --git a/package.json b/package.json index d340c9b20..e12587c15 100644 --- a/package.json +++ b/package.json @@ -21,5 +21,6 @@ "bugs": { "url": "https://github.com/WordPress/php-toolkit/issues" }, - "homepage": "https://github.com/WordPress/php-toolkit#readme" + "homepage": "https://github.com/WordPress/php-toolkit#readme", + "packageManager": "npm@11.12.1+sha512.cdca14b85d647b3192028d02aadbe82d75f79a446aceea9874be98e6d768f20ebd3555770a48d0e9906106007877bbc690f715e9372f2e2dc644a3c3157fb14c" } diff --git a/plugins/wp-origin/blueprint-e2e.json b/plugins/wp-origin/blueprint-e2e.json new file mode 100644 index 000000000..29c98a3f0 --- /dev/null +++ b/plugins/wp-origin/blueprint-e2e.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json", + "meta": { + "title": "WP Origin E2E", + "description": "Boot a WordPress site with WP Origin active and seeded content.", + "author": "adamziel" + }, + "preferredVersions": { + "php": "8.1", + "wp": "latest" + }, + "steps": [ + { + "step": "activatePlugin", + "pluginPath": "/wordpress/wp-content/plugins/wp-origin" + }, + { + "step": "wp-cli", + "command": "wp eval '$admins = get_users( array( \"role\" => \"Administrator\", \"number\" => 1 ) ); $admin = $admins[0]; update_option( \"permalink_structure\", \"/%postname%/\" ); flush_rewrite_rules(); $hello = get_page_by_path( \"hello-world\", OBJECT, \"post\" ); if ( $hello ) { wp_update_post( array( \"ID\" => $hello->ID, \"post_title\" => \"Hello World\", \"post_content\" => \"
Hello from WordPress
\", \"post_status\" => \"publish\" ) ); } $page = get_page_by_path( \"sample-page\", OBJECT, \"page\" ); if ( $page ) { wp_update_post( array( \"ID\" => $page->ID, \"post_title\" => \"Sample Page\", \"post_content\" => \"Page from WordPress
\", \"post_status\" => \"publish\" ) ); } list( $raw_password ) = WP_Application_Passwords::create_new_application_password( $admin->ID, array( \"name\" => \"WP Origin E2E\" ) ); $password = preg_replace( \"/\\\\s+/\", \"\", $raw_password ); file_put_contents( \"/workspace/.context/wp-origin-e2e.json\", wp_json_encode( array( \"username\" => $admin->user_login, \"password\" => $password, \"post_slug\" => \"hello-world\", \"page_slug\" => \"sample-page\" ) ) );'" + } + ] +} diff --git a/plugins/wp-origin/class-wp-origin-buffering-response.php b/plugins/wp-origin/class-wp-origin-buffering-response.php new file mode 100644 index 000000000..514d23130 --- /dev/null +++ b/plugins/wp-origin/class-wp-origin-buffering-response.php @@ -0,0 +1,33 @@ +http_code = $code; + } + + public function send_header( $name, $value ) { + $this->headers[ $name ] = $value; + } + + public function append_bytes( $body ): void { + $this->body .= $body; + } + + public function close_writing(): void { + } + + public function to_rest_response() { + $response = new WP_REST_Response( $this->body, $this->http_code ); + foreach ( $this->headers as $name => $value ) { + $response->header( $name, $value ); + } + + return $response; + } +} diff --git a/plugins/wp-origin/class-wp-origin-plugin.php b/plugins/wp-origin/class-wp-origin-plugin.php new file mode 100644 index 000000000..649fd6799 --- /dev/null +++ b/plugins/wp-origin/class-wp-origin-plugin.php @@ -0,0 +1,443 @@ +/.*)?'; + + public static function bootstrap() { + add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) ); + } + + public static function register_routes() { + register_rest_route( + self::ROUTE_NAMESPACE, + self::ROUTE_PATTERN, + array( + 'methods' => WP_REST_Server::ALLMETHODS, + 'callback' => array( __CLASS__, 'handle_rest_request' ), + 'permission_callback' => array( __CLASS__, 'check_permissions' ), + ) + ); + } + + public static function check_permissions( WP_REST_Request $request ) { + $git_path = self::build_git_path( $request ); + if ( self::is_push_request( $git_path ) ) { + return current_user_can( 'edit_posts' ); + } + + return is_user_logged_in(); + } + + public static function handle_rest_request( WP_REST_Request $request ) { + try { + $repository = self::open_repository(); + self::sync_repository_from_wordpress( $repository ); + + $git_path = self::build_git_path( $request ); + $request_body = file_get_contents( 'php://input' ); + + $push_header = null; + if ( self::is_push_request( $git_path ) ) { + $push_header = self::parse_push_header( $request_body ); + if ( false === $push_header ) { + return self::build_protocol_error_response( + 'git-receive-pack', + 'Invalid push request.' + ); + } + + $current_head = $repository->get_branch_tip( 'refs/heads/' . self::DEFAULT_BRANCH ); + if ( $push_header['old_oid'] !== $current_head ) { + return self::build_protocol_error_response( + 'git-receive-pack', + 'Push rejected because the remote changed. Pull the latest changes and try again.' + ); + } + } + + $response = new WP_Origin_Buffering_Response(); + $endpoint = new GitEndpoint( $repository ); + $endpoint->handle_request( $git_path, $request_body, $response ); + + if ( self::is_push_request( $git_path ) ) { + try { + self::apply_repository_changes_to_wordpress( + $repository, + $push_header['old_oid'], + $push_header['new_oid'] + ); + self::sync_repository_from_wordpress( $repository ); + } catch ( Exception $exception ) { + $repository->set_branch_tip( 'refs/heads/' . self::DEFAULT_BRANCH, $push_header['old_oid'] ); + $repository->set_branch_tip( 'HEAD', 'ref: refs/heads/' . self::DEFAULT_BRANCH ); + + return self::build_protocol_error_response( + 'git-receive-pack', + $exception->getMessage() + ); + } + } + + return $response->to_rest_response(); + } catch ( Exception $exception ) { + return new WP_Error( + 'wp_origin_error', + $exception->getMessage(), + array( 'status' => 500 ) + ); + } + } + + private static function build_protocol_error_response( $service, $message ) { + $response = new WP_Origin_Buffering_Response(); + $response->send_header( 'Content-Type', 'application/x-' . $service . '-result' ); + $response->send_header( 'Cache-Control', 'no-cache' ); + $response->send_header( 'Git-Protocol', 'version=2' ); + $response->append_bytes( + WordPress\Git\Protocol\GitProtocolEncoderPipe::encode_packet_line( + 'error ' . rtrim( $message ) . "\n", + "\x03" + ) . '0000' + ); + + return $response->to_rest_response(); + } + + private static function build_git_path( WP_REST_Request $request ) { + $path = $request->get_param( 'path' ); + if ( ! is_string( $path ) || '' === $path ) { + $path = '/'; + } + + $query_params = $request->get_query_params(); + if ( '/info/refs' === $path && isset( $query_params['service'] ) ) { + $path .= '?service=' . $query_params['service']; + } + + return $path; + } + + private static function is_push_request( $git_path ) { + return '/git-receive-pack' === $git_path; + } + + private static function open_repository() { + $repository = new GitRepository( + LocalFilesystem::create( self::get_repository_path() ), + array( + 'default_branch' => self::DEFAULT_BRANCH, + ) + ); + + if ( ! $repository->get_config_value( 'user.name' ) ) { + $repository->set_config_value( 'user.name', get_option( 'blogname', 'WP Origin' ) ); + } + if ( ! $repository->get_config_value( 'user.email' ) ) { + $repository->set_config_value( 'user.email', get_option( 'admin_email', 'wp-origin@example.com' ) ); + } + $repository->set_branch_tip( 'HEAD', 'ref: refs/heads/' . self::DEFAULT_BRANCH ); + + return $repository; + } + + private static function get_repository_path() { + $upload_dir = wp_upload_dir(); + + return $upload_dir['basedir'] . '/wp-origin/repository'; + } + + private static function sync_repository_from_wordpress( GitRepository $repository ) { + $exported_files = self::export_wordpress_content(); + $current_files = array(); + + try { + $current_head = $repository->get_branch_tip( 'refs/heads/' . self::DEFAULT_BRANCH ); + if ( ! Commit::is_null_hash( $current_head ) ) { + $current_files = self::read_markdown_files_from_commit( $repository, $current_head ); + } + } catch ( GitException $exception ) { + $current_head = Commit::NULL_HASH; + } + + $updates = array(); + foreach ( $exported_files as $path => $contents ) { + if ( ! isset( $current_files[ $path ] ) || $current_files[ $path ] !== $contents ) { + $updates[ $path ] = $contents; + } + } + + $deletes = array(); + foreach ( $current_files as $path => $contents ) { + if ( ! isset( $exported_files[ $path ] ) ) { + $deletes[] = $path; + } + } + + if ( empty( $updates ) && empty( $deletes ) ) { + return; + } + + $commit_timestamp = time(); + foreach ( $exported_files as $path => $contents ) { + $metadata = self::parse_markdown_metadata( $contents ); + if ( isset( $metadata['modified_gmt'] ) ) { + $maybe_timestamp = strtotime( $metadata['modified_gmt'] . ' UTC' ); + if ( false !== $maybe_timestamp ) { + $commit_timestamp = max( $commit_timestamp, $maybe_timestamp ); + } + } + } + + $repository->commit( + array( + 'updates' => $updates, + 'deletes' => $deletes, + 'commit' => array( + 'message' => 'Sync from WordPress', + 'author_date' => gmdate( Commit::DATE_FORMAT, $commit_timestamp ), + 'committer_date' => gmdate( Commit::DATE_FORMAT, $commit_timestamp ), + ), + ) + ); + } + + private static function export_wordpress_content() { + $posts = get_posts( + array( + 'post_type' => array( 'post', 'page' ), + 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ), + 'posts_per_page' => -1, + 'orderby' => 'ID', + 'order' => 'ASC', + ) + ); + + $files = array(); + foreach ( $posts as $post ) { + if ( ! current_user_can( 'read_post', $post->ID ) ) { + continue; + } + + $metadata = array( + 'id' => (string) $post->ID, + 'type' => $post->post_type, + 'slug' => $post->post_name, + 'status' => $post->post_status, + 'title' => $post->post_title, + 'date_gmt' => $post->post_date_gmt, + 'modified_gmt' => $post->post_modified_gmt, + ); + + $producer = new MarkdownProducer( + new BlocksWithMetadata( + $post->post_content, + $metadata + ) + ); + $path = self::build_markdown_path( $post->post_type, $post->post_name ); + $files[ $path ] = $producer->produce(); + } + + ksort( $files ); + + return $files; + } + + private static function build_markdown_path( $post_type, $slug ) { + return ltrim( $post_type . '/' . $slug . '.md', '/' ); + } + + private static function apply_repository_changes_to_wordpress( GitRepository $repository, $old_commit, $new_commit ) { + $old_files = Commit::is_null_hash( $old_commit ) ? array() : self::read_markdown_files_from_commit( $repository, $old_commit ); + $new_files = self::read_markdown_files_from_commit( $repository, $new_commit ); + + $updated_post_ids = array(); + foreach ( $new_files as $path => $contents ) { + if ( isset( $old_files[ $path ] ) && $old_files[ $path ] === $contents ) { + continue; + } + + $post_id = self::upsert_post_from_markdown( $path, $contents ); + if ( $post_id ) { + $updated_post_ids[ $post_id ] = true; + } + } + + foreach ( $old_files as $path => $contents ) { + if ( isset( $new_files[ $path ] ) ) { + continue; + } + + $metadata = self::parse_markdown_metadata( $contents ); + $post_id = isset( $metadata['id'] ) ? intval( $metadata['id'] ) : 0; + if ( ! $post_id ) { + $post_id = self::find_post_id_by_path_metadata( $path, $metadata ); + } + if ( ! $post_id || isset( $updated_post_ids[ $post_id ] ) ) { + continue; + } + + if ( isset( $metadata['modified_gmt'] ) ) { + $current_modified = get_post_field( 'post_modified_gmt', $post_id ); + if ( $current_modified && $current_modified !== $metadata['modified_gmt'] ) { + throw new Exception( 'Push rejected because a deleted post changed in WordPress. Pull the latest changes and try again.' ); + } + } + + wp_trash_post( $post_id ); + } + } + + private static function upsert_post_from_markdown( $path, $markdown ) { + $post_type = self::path_to_post_type( $path ); + $slug = self::path_to_slug( $path ); + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + $metadata = array(); + foreach ( $result->get_all_metadata() as $key => $value ) { + $metadata[ $key ] = is_array( $value ) ? reset( $value ) : $value; + } + + if ( isset( $metadata['type'] ) && $metadata['type'] !== $post_type ) { + throw new Exception( 'Push rejected because the file post type does not match its directory.' ); + } + + $post_id = isset( $metadata['id'] ) ? intval( $metadata['id'] ) : 0; + if ( $post_id && get_post( $post_id ) ) { + $existing_post = get_post( $post_id ); + } else { + $post_id = self::find_post_id_by_path_metadata( $path, $metadata ); + $existing_post = $post_id ? get_post( $post_id ) : null; + } + + if ( $existing_post && isset( $metadata['modified_gmt'] ) && $existing_post->post_modified_gmt !== $metadata['modified_gmt'] ) { + throw new Exception( 'Push rejected because WordPress content changed since the last pull.' ); + } + + $postarr = array( + 'post_type' => $post_type, + 'post_name' => isset( $metadata['slug'] ) ? $metadata['slug'] : $slug, + 'post_title' => isset( $metadata['title'] ) ? $metadata['title'] : ucwords( str_replace( '-', ' ', $slug ) ), + 'post_status' => isset( $metadata['status'] ) ? $metadata['status'] : 'draft', + 'post_content' => $result->get_block_markup(), + ); + + if ( isset( $metadata['date_gmt'] ) && '' !== $metadata['date_gmt'] ) { + $postarr['post_date_gmt'] = $metadata['date_gmt']; + } + + if ( $existing_post ) { + $postarr['ID'] = $existing_post->ID; + $post_id = wp_update_post( wp_slash( $postarr ), true ); + } else { + $post_id = wp_insert_post( wp_slash( $postarr ), true ); + } + + if ( is_wp_error( $post_id ) ) { + throw new Exception( $post_id->get_error_message() ); + } + + return $post_id; + } + + private static function find_post_id_by_path_metadata( $path, $metadata ) { + $post_type = self::path_to_post_type( $path ); + $slug = isset( $metadata['slug'] ) ? $metadata['slug'] : self::path_to_slug( $path ); + $posts = get_posts( + array( + 'post_type' => $post_type, + 'name' => $slug, + 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future', 'trash' ), + 'posts_per_page' => 1, + 'fields' => 'ids', + ) + ); + + if ( empty( $posts ) ) { + return 0; + } + + return intval( $posts[0] ); + } + + private static function path_to_post_type( $path ) { + $segments = explode( '/', ltrim( $path, '/' ) ); + if ( empty( $segments[0] ) || ! in_array( $segments[0], array( 'post', 'page' ), true ) ) { + throw new Exception( 'Push rejected because the file path is outside the supported post type directories.' ); + } + + return $segments[0]; + } + + private static function path_to_slug( $path ) { + $basename = basename( $path ); + if ( 'md' !== pathinfo( $basename, PATHINFO_EXTENSION ) ) { + throw new Exception( 'Push rejected because only Markdown files are supported.' ); + } + + return pathinfo( $basename, PATHINFO_FILENAME ); + } + + private static function parse_markdown_metadata( $markdown ) { + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + $metadata = array(); + foreach ( $result->get_all_metadata() as $key => $value ) { + $metadata[ $key ] = is_array( $value ) ? reset( $value ) : $value; + } + + return $metadata; + } + + private static function read_markdown_files_from_commit( GitRepository $repository, $commit_hash ) { + $commit = $repository->read_object( $commit_hash )->as_commit(); + $files = array(); + + if ( Commit::is_null_hash( $commit->tree ) ) { + return $files; + } + + self::collect_tree_files( $repository, $commit->tree, '', $files ); + ksort( $files ); + + return $files; + } + + private static function collect_tree_files( GitRepository $repository, $tree_hash, $prefix, &$files ) { + $tree = $repository->read_object( $tree_hash )->as_tree(); + foreach ( $tree->entries as $entry ) { + $path = ltrim( $prefix . '/' . $entry->name, '/' ); + if ( TreeEntry::FILE_MODE_DIRECTORY === $entry->get_mode_bucket() ) { + self::collect_tree_files( $repository, $entry->hash, $path, $files ); + continue; + } + if ( 'md' !== pathinfo( $path, PATHINFO_EXTENSION ) ) { + continue; + } + $files[ $path ] = $repository->read_object( $entry->hash )->consume_all(); + } + } + + private static function parse_push_header( $request_bytes ) { + if ( ! preg_match( '/([0-9a-f]{40}) ([0-9a-f]{40}) refs\\/heads\\/' . self::DEFAULT_BRANCH . "\0/", $request_bytes, $matches ) ) { + return false; + } + + return array( + 'old_oid' => $matches[1], + 'new_oid' => $matches[2], + ); + } +} diff --git a/plugins/wp-origin/wp-origin-dev-bootstrap.php b/plugins/wp-origin/wp-origin-dev-bootstrap.php new file mode 100644 index 000000000..ffa05d1a9 --- /dev/null +++ b/plugins/wp-origin/wp-origin-dev-bootstrap.php @@ -0,0 +1,38 @@ +addClassMap( $wp_origin_classmap ); + +$wp_origin_psr4 = require __DIR__ . '/../../vendor/composer/autoload_psr4.php'; +foreach ( $wp_origin_psr4 as $prefix => $paths ) { + $wp_origin_loader->setPsr4( $prefix, $paths ); +} + +$wp_origin_namespaces = require __DIR__ . '/../../vendor/composer/autoload_namespaces.php'; +foreach ( $wp_origin_namespaces as $prefix => $paths ) { + $wp_origin_loader->set( $prefix, $paths ); +} + +$wp_origin_loader->register( true ); + +$wp_origin_files = array( + __DIR__ . '/../../components/DataLiberation/URL/functions.php', + __DIR__ . '/../../components/Encoding/utf8.php', + __DIR__ . '/../../components/Encoding/compat-utf8.php', + __DIR__ . '/../../components/Encoding/utf8-encoder.php', + __DIR__ . '/../../components/Filesystem/functions.php', + __DIR__ . '/../../components/Zip/functions.php', + __DIR__ . '/../../components/Polyfill/mbstring.php', + __DIR__ . '/../../components/Polyfill/php-functions.php', + __DIR__ . '/../../components/Git/functions.php', +); + +foreach ( $wp_origin_files as $wp_origin_file ) { + require_once $wp_origin_file; +} diff --git a/plugins/wp-origin/wp-origin.php b/plugins/wp-origin/wp-origin.php new file mode 100644 index 000000000..8f8cae78f --- /dev/null +++ b/plugins/wp-origin/wp-origin.php @@ -0,0 +1,19 @@ +