-
Notifications
You must be signed in to change notification settings - Fork 272
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Support previewing Gutenberg Pull Requests (#126)
## 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
Showing
4 changed files
with
277 additions
and
44 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()])); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters