Skip to content

Commit

Permalink
Support previewing Gutenberg Pull Requests (#126)
Browse files Browse the repository at this point in the history
## Description

Adds support for previewing Gutenberg Pull Requests via the a `gutenberg-pr` query parameter:

https://playground.wordpress.net/wordpress.html?gutenberg-pr=47739&url=/wp-admin/plugins.php?test=test

My test PR: WordPress/gutenberg#47739

<img width="1149" alt="CleanShot 2023-02-03 at 19 33 17@2x" src="https://user-images.githubusercontent.com/205419/216681315-2af555c1-6249-4758-afe0-aa54b66c68a3.png">

## Implementation

The plugin bundle is downloaded from GitHub CI and installed by applying the following Blueprint:

```js
function applyGutenbergPRSteps(prNumber: number): StepDefinition[] {
	return [
		{
			step: 'mkdir',
			path: '/wordpress/pr',
		},
		{
			step: 'writeFile',
			path: '/wordpress/pr/pr.zip',
			data: {
				resource: 'url',
				url: `/plugin-proxy?org=WordPress&repo=gutenberg&workflow=Build%20Gutenberg%20Plugin%20Zip&artifact=gutenberg-plugin&pr=${prNumber}`,
				caption: `Downloading Gutenberg PR ${prNumber}`,
			},
			progress: {
				weight: 2,
				caption: `Applying Gutenberg PR ${prNumber}`,
			},
		},
		{
			step: 'unzip',
			zipPath: '/wordpress/pr/pr.zip',
			extractToPath: '/wordpress/pr',
		},
		{
			step: 'installPlugin',
			pluginZipFile: {
				resource: 'vfs',
				path: '/wordpress/pr/gutenberg.zip',
			},
		},
	];
}
```

Gutenberg PR preview from playground.wordpress.net can be embedded in other apps via an iframe.

To implement your own PR previewer that pulls data from another repo, you'll need to expose an API endpoint to download the ZIP bundle and then plug it in the `writeFile` step above. Once you have that, here's how you'd apply a custom blueprint:

```ts
import { startPlaygroundWeb } from "https://unpkg.com/@wp-playground/client@0.1.32/index.js";
startPlaygroundWeb({
	iframe,
	remoteUrl: `https://playground.wordpress.net/remote.html`,
	blueprint: {
		steps: applyPR( 47339 )
	}
})

function applyPR( prNumber ) {
	return [
		{
			step: 'mkdir',
			path: '/wordpress/pr',
		},
		// ...
	];
}
```
  • Loading branch information
Pookie717 committed Apr 26, 2023
1 parent 9ba1825 commit 228a1d2
Show file tree
Hide file tree
Showing 4 changed files with 277 additions and 44 deletions.
275 changes: 231 additions & 44 deletions packages/playground/website/public/plugin-proxy.php
Original file line number Diff line number Diff line change
@@ -1,57 +1,244 @@
<?php

function download_file($url)
ini_set('display_errors', 0);

class ApiException extends Exception
{
}
class PluginDownloader
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HEADER, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_BINARYTRANSFER, 1);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 0);

$response = curl_exec($ch);
private $githubToken;

$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$headers = array_map('trim', explode("\n", substr($response, 0, $header_size)));
$body = substr($response, $header_size);
public const PLUGINS = 'plugins';
public const THEMES = 'themes';

return [$headers, $body];
}
public function __construct($githubToken)
{
$this->githubToken = $githubToken;
}

if (isset($_GET['plugin'])) {
$plugin_name = preg_replace('#[^a-zA-Z0-9\.\-_]#', '', $_GET['plugin']);
$zip_url = 'https://downloads.wordpress.org/plugin/' . $plugin_name;
} else if (isset($_GET['theme'])) {
$theme_name = preg_replace('#[^a-zA-Z0-9\.\-_]#', '', $_GET['theme']);
$zip_url = 'https://downloads.wordpress.org/theme/' . $theme_name;
} else {
die('Invalid request');
}
public function streamFromDirectory($name, $directory)
{
$name = preg_replace('#[^a-zA-Z0-9\.\-_]#', '', $name);
$zipUrl = "https://downloads.wordpress.org/$directory/$name";
try {
$this->streamHttpResponse($zipUrl, [
'content-length',
'x-frame-options',
'last-modified',
'etag',
'date',
'age',
'vary',
'cache-Control'
]);
} catch (ApiException $e) {
throw new ApiException("Plugin or theme '$name' not found");
}
}

public function streamFromGithubPR($organization, $repo, $pr, $workflow_name, $artifact_name)
{
$prDetails = $this->gitHubRequest("https://api.github.com/repos/$organization/$repo/pulls/$pr")['body'];
if (!$prDetails) {
throw new ApiException('Invalid PR number');
}
$branchName = $prDetails->head->ref;
$ciRuns = $this->gitHubRequest("https://api.github.com/repos/$organization/$repo/actions/runs?branch=$branchName")['body'];
if (!$ciRuns) {
throw new ApiException('No CI runs found');
}

$artifactsUrls = [];
foreach ($ciRuns->workflow_runs as $run) {
if ($run->name === $workflow_name) {
$artifactsUrls[] = $run->artifacts_url;
}
}
if (!$artifactsUrls) {
throw new ApiException('No artifact URL found');
}

[$received_headers, $bytes] = download_file($zip_url);

$forward_headers = [
'content-length',
'content-type',
'content-disposition',
'x-frame-options',
'last-modified',
'etag',
'date',
'age',
'vary',
'cache-Control'
];

foreach ($received_headers as $received_header) {
$comparable_header = strtolower($received_header);
foreach ($forward_headers as $sought_header) {
if (substr($comparable_header, 0, strlen($sought_header)) === $sought_header) {
header($received_header);
break;
foreach ($artifactsUrls as $artifactsUrl) {
$zip_download_api_endpoint = $zip_url = null;

$artifacts = $this->gitHubRequest($artifactsUrl)['body'];
if (!$artifacts) {
continue;
}

foreach ($artifacts->artifacts as $artifact) {
if ($artifact->name === $artifact_name) {
$zip_download_api_endpoint = $artifact->archive_download_url;
break;
}
}
if (!$zip_download_api_endpoint) {
continue;
}

$zip_download_headers = $this->gitHubRequest($zip_download_api_endpoint, true)['headers'];
// Find the location header and store it in $zip_url
foreach ($zip_download_headers as $header) {
if (substr(strtolower($header), 0, 10) === 'location: ') {
$zip_url = substr($header, 10);
break;
}
}
if (!$zip_url) {
continue;
}
$this->streamHttpResponse($zip_url, [], [
'Content-Length: ' . $artifact->size_in_bytes
]);
}
if (!$artifacts) {
throw new ApiException('No artifacts found under the URL');
}
if (!$zip_download_api_endpoint) {
throw new ApiException('No artifact download URL found with the name');
}
if (!$zip_url) {
throw new ApiException('No zip location returned by the artifact download API');
}
}

protected function gitHubRequest($url)
{
$headers[] = 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36';
$headers[] = 'Authorization: Bearer ' . $this->githubToken;
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => implode("\r\n", $headers),
]
]);
$response = file_get_contents($url, false, $context);
if ($response === false) {
throw new ApiException('Request failed');
}
return [
'body' => json_decode($response),
'headers' => array_map('trim', array_slice($http_response_header, 1))
];
}

private function streamHttpResponse($url, $allowed_headers = [], $default_headers = [])
{
$default_headers = array_merge([
'Content-Type: application/zip',
'Content-Disposition: attachment; filename="plugin.zip"',
], $default_headers);
$ch = curl_init($url);
curl_setopt_array(
$ch,
[
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 30,
CURLOPT_FAILONERROR => true,
CURLOPT_FOLLOWLOCATION => true,
]
);

$seen_headers = [];
curl_setopt(
$ch,
CURLOPT_HEADERFUNCTION,
function ($curl, $header_line) use ($seen_headers, $allowed_headers) {
$header_name = strtolower(substr($header_line, 0, strpos($header_line, ':')));
$seen_headers[$header_name] = true;
if (in_array($header_name, $allowed_headers)) {
header($header_line);
}
return strlen($header_line);
}
);
$extra_headers_sent = false;
curl_setopt(
$ch,
CURLOPT_WRITEFUNCTION,
function ($curl, $body) use (&$extra_headers_sent, $default_headers) {
if (!$extra_headers_sent) {
foreach ($default_headers as $header_line) {
$header_name = strtolower(substr($header_line, 0, strpos($header_line, ':')));
if (!isset($seen_headers[strtolower($header_name)])) {
header($header_line);
}
}
$extra_headers_sent = true;
}
echo $body;
flush();
return strlen($body);
}
);
curl_exec($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if ($info['http_code'] > 299 || $info['http_code'] < 200) {
throw new ApiException('Request failed');
}
}

}

header('Access-Control-Allow-Origin: *');
$downloader = new PluginDownloader(
getenv('GITHUB_TOKEN')
);

echo $bytes;
// Serve the request:
header('Access-Control-Allow-Origin: *');
$pluginResponse;
try {
if (isset($_GET['plugin'])) {
$downloader->streamFromDirectory($_GET['plugin'], PluginDownloader::PLUGINS);
} else if (isset($_GET['theme'])) {
$downloader->streamFromDirectory($_GET['plugin'], PluginDownloader::THEMES);
} else if (isset($_GET['org']) && isset($_GET['repo']) && isset($_GET['workflow']) && isset($_GET['pr']) && isset($_GET['artifact'])) {
$allowedInputs = [
[
'org' => 'WordPress',
'repo' => 'gutenberg',
'workflow' => 'Build Gutenberg Plugin Zip',
'artifact' => 'gutenberg-plugin'
],
[
'org' => 'woocommerce',
'repo' => 'woocommerce',
'workflow' => 'Build Live Branch',
'artifact' => 'plugins'
]
];
$allowed = false;
foreach ($allowedInputs as $allowedInput) {
if (
$_GET['org'] === $allowedInput['org'] &&
$_GET['repo'] === $allowedInput['repo'] &&
$_GET['workflow'] === $allowedInput['workflow'] &&
$_GET['artifact'] === $allowedInput['artifact']
) {
$allowed = true;
break;
}
}
if (!$allowed) {
die('Invalid request');
}
$downloader->streamFromGithubPR(
$_GET['org'],
$_GET['repo'],
$_GET['pr'],
$_GET['workflow'],
$_GET['artifact']
);
} else {
throw new ApiException('Invalid query parameters');
}
} catch (ApiException $e) {
header('HTTP/1.1 400 Invalid request');
if (!headers_sent()) {
header('Content-Type: application/json');
}
die(json_encode(['error' => $e->getMessage()]));
}
38 changes: 38 additions & 0 deletions packages/playground/website/src/lib/make-blueprint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface MakeBlueprintOptions {
landingPage?: string;
theme?: string;
plugins?: string[];
gutenbergPR?: number;
}
export function makeBlueprint(options: MakeBlueprintOptions): Blueprint {
const plugins = options.plugins || [];
Expand Down Expand Up @@ -37,6 +38,43 @@ export function makeBlueprint(options: MakeBlueprintOptions): Blueprint {
},
progress: { weight: 2 },
})),
...(typeof options.gutenbergPR === 'number'
? applyGutenbergPRSteps(options.gutenbergPR)
: []),
],
};
}

function applyGutenbergPRSteps(prNumber: number): StepDefinition[] {
return [
{
step: 'mkdir',
path: '/wordpress/pr',
},
{
step: 'writeFile',
path: '/wordpress/pr/pr.zip',
data: {
resource: 'url',
url: `/plugin-proxy?org=WordPress&repo=gutenberg&workflow=Build%20Gutenberg%20Plugin%20Zip&artifact=gutenberg-plugin&pr=${prNumber}`,
caption: `Downloading Gutenberg PR ${prNumber}`,
},
progress: {
weight: 2,
caption: `Applying Gutenberg PR ${prNumber}`,
},
},
{
step: 'unzip',
zipPath: '/wordpress/pr/pr.zip',
extractToPath: '/wordpress/pr',
},
{
step: 'installPlugin',
pluginZipFile: {
resource: 'vfs',
path: '/wordpress/pr/gutenberg.zip',
},
},
];
}
3 changes: 3 additions & 0 deletions packages/playground/website/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const blueprint = makeBlueprint({
theme: query.get('theme') || undefined,
plugins: query.getAll('plugin'),
landingPage: query.get('url') || undefined,
gutenbergPR: query.has('gutenberg-pr')
? Number(query.get('gutenberg-pr'))
: undefined,
});

const isSeamless = (query.get('mode') || 'browser') === 'seamless';
Expand Down
5 changes: 5 additions & 0 deletions packages/playground/website/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import {
import virtualModule from '../vite-virtual-module';

const proxy = {
'^/plugin-proxy.*&artifact=.*': {
target: 'https://playground.wordpress.net',
changeOrigin: true,
secure: true,
},
'/plugin-proxy': {
target: 'https://downloads.wordpress.org',
changeOrigin: true,
Expand Down

0 comments on commit 228a1d2

Please sign in to comment.