| 1 |
<?php |
| 2 |
/** |
| 3 |
* The live source: WPDeveloper's plugin-download service. |
| 4 |
* |
| 5 |
* Presents the site's stored Templately credential and asks for the pro plugin belonging |
| 6 |
* to a platform. Entitlement is decided entirely at the other end — nothing here reads a |
| 7 |
* plan, a tier, or a subscription value (spec 059 FR-003), and no answer is ever cached |
| 8 |
* (FR-019), so a plan upgraded between two imports takes effect on the second one. |
| 9 |
* |
| 10 |
* THE SHAPE IS NOW KNOWN (2026-09-08). The endpoint does not answer with the archive: it |
| 11 |
* answers 200 + JSON carrying a short-lived presigned S3 link, and a refusal is 403 + |
| 12 |
* `{"code":"unauthorized_access"}`. Acquisition is therefore TWO hops — see `pointer()`. |
| 13 |
* The first implementation streamed the reply straight to a temp file and validated it as |
| 14 |
* a ZIP, so an entitled user's 823-byte JSON pointer failed validation and was discarded: |
| 15 |
* the feature refused everyone, indistinguishably from a real refusal. |
| 16 |
* |
| 17 |
* FOUR RULES THIS CLASS MUST NOT BREAK — each of them is a defect someone hit before: |
| 18 |
* |
| 19 |
* 1. **Never route the response through Templately's response normalizer.** |
| 20 |
* `Http::maybeErrors()` maps AUTH_EXPIRED / INVALID_API_KEY onto |
| 21 |
* `Login::get_instance()->delete()`, which wipes the stored api_key and disconnects |
| 22 |
* the site. This host is api.wpdeveloper.com, a DIFFERENT service with its own |
| 23 |
* vocabulary: a 401 from it means "this token is not entitled to this download", not |
| 24 |
* "your Templately session ended". Normalizing it would let a routine refusal log the |
| 25 |
* user out of Templately in the middle of their import. Constitution IV's |
| 26 |
* destructive-side-effects rule says exactly this — a code that destroys access must |
| 27 |
* be reachable only by a positive match on a signal from the system that OWNS that |
| 28 |
* access. |
| 29 |
* |
| 30 |
* 2. **Never throw, never echo.** The full-site path runs inside a live SSE stream. An |
| 31 |
* exception escapes into the import as a failure; a stray byte corrupts the stream. |
| 32 |
* |
| 33 |
* 3. **Never log the credential, or any URL containing it.** It travels as a query |
| 34 |
* parameter because the service's contract requires it, which makes the whole URL a |
| 35 |
* secret. Log the slug and the outcome; never the request. |
| 36 |
* |
| 37 |
* 4. **Take the download link from `downloads[<platform>]`, never from `url`.** They can |
| 38 |
* disagree, and `url` carries no indication of which product it is. The disagreement |
| 39 |
* observed on 2026-09-08 — an Elementor request answering with `downloads` keyed |
| 40 |
* `gutenberg` and a `url` pointing at Essential Blocks Pro — was ORIGINALLY BLAMED ON |
| 41 |
* THE SERVICE, and that was wrong: the request spelled the parameter `platform` |
| 42 |
* instead of `platforms`, so the service never saw a platform at all and answered with |
| 43 |
* its default. Corrected 2026-09-21. The rule stands on its own merits regardless — |
| 44 |
* reading the platform-keyed entry is what kept a mislabelled `url` from installing the |
| 45 |
* wrong product into an Elementor import for the whole time the parameter was wrong — |
| 46 |
* and the archive is checked against the catalog's own directory on arrival, so a |
| 47 |
* mismatch is caught twice. |
| 48 |
* |
| 49 |
* @package Templately\Modules\ProPluginProvisioning |
| 50 |
*/ |
| 51 |
|
| 52 |
namespace Templately\Modules\ProPluginProvisioning; |
| 53 |
|
| 54 |
use Templately\Utils\Base; |
| 55 |
use Templately\Utils\Helper; |
| 56 |
use Templately\Utils\Installer; |
| 57 |
|
| 58 |
class ProvisioningSource extends Base { |
| 59 |
|
| 60 |
const ENDPOINT = 'https://api.wpdeveloper.com/wp-json/wpdeveloper/v1/download-plugin'; |
| 61 |
|
| 62 |
/** |
| 63 |
* Acquire an archive for a provisioning request, or null. |
| 64 |
* |
| 65 |
* @param array $request plugin_file, slug, platform, credential. |
| 66 |
* @return string|null Absolute path to a downloaded archive, or null when unavailable. |
| 67 |
*/ |
| 68 |
public function fetch( array $request ) { |
| 69 |
$platform = isset( $request['platform'] ) ? (string) $request['platform'] : ''; |
| 70 |
$credential = isset( $request['credential'] ) ? (string) $request['credential'] : ''; |
| 71 |
$slug = isset( $request['slug'] ) ? (string) $request['slug'] : ''; |
| 72 |
$expected = isset( $request['plugin_file'] ) ? dirname( (string) $request['plugin_file'] ) : ''; |
| 73 |
|
| 74 |
if ( '' === $platform || '' === $credential || '' === $slug || '' === $expected || '.' === $expected ) { |
| 75 |
self::note( 'incomplete request', $slug, $platform ); |
| 76 |
|
| 77 |
return null; |
| 78 |
} |
| 79 |
|
| 80 |
// A plugin archive is a real multi-megabyte download, so the budget is far larger |
| 81 |
// than an ordinary cloud call's. Lifting PHP's own execution limit first is not |
| 82 |
// optional: without it PHP fatals at max_execution_time (30s on a great many |
| 83 |
// hosts) with no WP_Error to catch, WordPress emits its HTML "critical error" |
| 84 |
// page, and an SSE or JSON client renders that markup as content. |
| 85 |
Installer::raise_limits(); |
| 86 |
|
| 87 |
$download = $this->pointer( $platform, $credential ); |
| 88 |
|
| 89 |
if ( null === $download ) { |
| 90 |
// `pointer()` has already said why. |
| 91 |
return null; |
| 92 |
} |
| 93 |
|
| 94 |
$destination = $this->destination( $slug ); |
| 95 |
|
| 96 |
if ( null === $destination ) { |
| 97 |
self::note( 'no writable temp file for the archive', $slug, $platform ); |
| 98 |
|
| 99 |
return null; |
| 100 |
} |
| 101 |
|
| 102 |
$response = wp_remote_get( $download, [ |
| 103 |
'timeout' => Module::timeout(), |
| 104 |
'headers' => [ 'Accept' => 'application/zip' ], |
| 105 |
'stream' => true, |
| 106 |
'filename' => $destination, |
| 107 |
] ); |
| 108 |
|
| 109 |
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { |
| 110 |
self::note( |
| 111 |
is_wp_error( $response ) |
| 112 |
? 'archive download failed: ' . $response->get_error_message() |
| 113 |
: 'archive download returned HTTP ' . (int) wp_remote_retrieve_response_code( $response ), |
| 114 |
$slug, |
| 115 |
$platform |
| 116 |
); |
| 117 |
Archive::discard( $destination ); |
| 118 |
|
| 119 |
return null; |
| 120 |
} |
| 121 |
|
| 122 |
if ( ! Archive::is_installable_plugin( $destination, $expected ) ) { |
| 123 |
self::note( |
| 124 |
sprintf( 'downloaded file is not an installable "%s" plugin (%d bytes)', $expected, (int) @filesize( $destination ) ), |
| 125 |
$slug, |
| 126 |
$platform |
| 127 |
); |
| 128 |
|
| 129 |
// A JSON refusal, an HTML error page, a ZIP that is not a plugin, or a plugin |
| 130 |
// that is not the one we asked for. All of them are the same outcome to the |
| 131 |
// caller — see the class docblock. |
| 132 |
Archive::discard( $destination ); |
| 133 |
|
| 134 |
return null; |
| 135 |
} |
| 136 |
|
| 137 |
self::note( 'archive acquired', $slug, $platform ); |
| 138 |
|
| 139 |
return $destination; |
| 140 |
} |
| 141 |
|
| 142 |
/** |
| 143 |
* Record an outcome. |
| 144 |
* |
| 145 |
* Rule 3 of this class's contract, made real: the slug, the platform and what |
| 146 |
* happened — NEVER the request, because the credential travels in the URL and the |
| 147 |
* whole URL is therefore a secret. Without this every refusal was a bare `return |
| 148 |
* null`, so "not entitled", "service down", "wrong platform" and "disk full" reached |
| 149 |
* the user as one indistinguishable "install it yourself". |
| 150 |
* |
| 151 |
* @param string $outcome What happened. Must not contain a URL or the credential. |
| 152 |
* @param string $slug |
| 153 |
* @param string $platform |
| 154 |
* @return void |
| 155 |
*/ |
| 156 |
private static function note( string $outcome, string $slug, string $platform ) { |
| 157 |
Helper::log( |
| 158 |
sprintf( 'pro-provisioning[%s/%s]: %s', $platform, $slug, $outcome ), |
| 159 |
'pro_plugin_provisioning', |
| 160 |
'info' |
| 161 |
); |
| 162 |
} |
| 163 |
|
| 164 |
/** |
| 165 |
* Ask the service where the archive is, and get back a URL or nothing. |
| 166 |
* |
| 167 |
* The endpoint does NOT answer with the archive. It answers with JSON carrying a |
| 168 |
* short-lived presigned link: |
| 169 |
* |
| 170 |
* { "url": "https://…/essential-blocks-pro.3.2.1.zip?X-Amz-…", |
| 171 |
* "downloads": { "gutenberg": "https://…" } } |
| 172 |
* |
| 173 |
* **Read `downloads[<platform>]`, never `url`.** Observed 2026-09-08: a request for |
| 174 |
* `platform=elementor` came back 200 with `downloads` keyed `gutenberg` only, and `url` |
| 175 |
* pointing at Essential Blocks Pro — the Gutenberg plugin. Taking `url` would have |
| 176 |
* installed the wrong product into an Elementor import. The platform-keyed entry is the |
| 177 |
* only field that answers the question we asked; its absence means "not for this |
| 178 |
* platform", which is a refusal, not a reason to reach for the other field. |
| 179 |
* |
| 180 |
* @param string $platform 'elementor' | 'gutenberg'. |
| 181 |
* @param string $credential The site's stored Templately credential. |
| 182 |
* @return string|null Absolute https URL to an archive, or null. |
| 183 |
*/ |
| 184 |
private function pointer( string $platform, string $credential ) { |
| 185 |
$response = wp_remote_get( $this->url( $platform, $credential ), [ |
| 186 |
'timeout' => Module::timeout(), |
| 187 |
'headers' => [ |
| 188 |
'Accept' => 'application/json', |
| 189 |
'x-templately-url' => home_url( '/' ), |
| 190 |
'x-templately-version' => defined( 'TEMPLATELY_VERSION' ) ? TEMPLATELY_VERSION : '1.0.0', |
| 191 |
], |
| 192 |
] ); |
| 193 |
|
| 194 |
if ( is_wp_error( $response ) ) { |
| 195 |
self::note( 'entitlement check failed: ' . $response->get_error_message(), '(pointer)', $platform ); |
| 196 |
|
| 197 |
return null; |
| 198 |
} |
| 199 |
|
| 200 |
$status = (int) wp_remote_retrieve_response_code( $response ); |
| 201 |
|
| 202 |
if ( 200 !== $status ) { |
| 203 |
// 401/403 is the service saying this token is not entitled to this download — |
| 204 |
// an ordinary outcome, NOT a Templately session problem (see rule 1). |
| 205 |
self::note( |
| 206 |
sprintf( |
| 207 |
'entitlement check returned HTTP %d%s', |
| 208 |
$status, |
| 209 |
( 401 === $status || 403 === $status ) ? ' — not entitled to this download' : '' |
| 210 |
), |
| 211 |
'(pointer)', |
| 212 |
$platform |
| 213 |
); |
| 214 |
|
| 215 |
return null; |
| 216 |
} |
| 217 |
|
| 218 |
$body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); |
| 219 |
|
| 220 |
if ( ! is_array( $body ) || ! isset( $body['downloads'] ) || ! is_array( $body['downloads'] ) ) { |
| 221 |
self::note( 'entitlement check answered 200 with no `downloads` map', '(pointer)', $platform ); |
| 222 |
|
| 223 |
return null; |
| 224 |
} |
| 225 |
|
| 226 |
$download = isset( $body['downloads'][ $platform ] ) ? $body['downloads'][ $platform ] : null; |
| 227 |
|
| 228 |
if ( ! is_string( $download ) || '' === $download ) { |
| 229 |
// Absence means "not for this platform" — the `url` field is deliberately NOT |
| 230 |
// consulted (rule 4). Name the keys that WERE offered; they are platform names, |
| 231 |
// not secrets, and a mismatch here is the one this rule exists to catch. |
| 232 |
self::note( |
| 233 |
sprintf( 'entitlement check offered no "%s" download (offered: %s)', $platform, implode( ', ', array_keys( $body['downloads'] ) ) ?: 'none' ), |
| 234 |
'(pointer)', |
| 235 |
$platform |
| 236 |
); |
| 237 |
|
| 238 |
return null; |
| 239 |
} |
| 240 |
|
| 241 |
// These bytes become executing PHP on the site. A plaintext hop is not an acceptable |
| 242 |
// way to acquire them, whatever the service hands back. |
| 243 |
if ( 'https' !== strtolower( (string) wp_parse_url( $download, PHP_URL_SCHEME ) ) ) { |
| 244 |
self::note( 'refused a non-https download link', '(pointer)', $platform ); |
| 245 |
|
| 246 |
return null; |
| 247 |
} |
| 248 |
|
| 249 |
return $download; |
| 250 |
} |
| 251 |
|
| 252 |
/** |
| 253 |
* The download URL for a platform. |
| 254 |
* |
| 255 |
* `dev=true` rides the plugin's existing dev-API switch, so the constant that points |
| 256 |
* Templately at app.templately.dev also points provisioning at the development |
| 257 |
* entitlement data (FR-005). |
| 258 |
* |
| 259 |
* The credential is taken from the REQUEST, not re-read from global state: the caller |
| 260 |
* has already established that the site is connected and that the user may install, and |
| 261 |
* a second read could disagree with the one those checks were made against. It is added |
| 262 |
* LAST, and this method's return value must never be logged. |
| 263 |
* |
| 264 |
* @param string $platform 'elementor' | 'gutenberg'. |
| 265 |
* @param string $credential The site's stored Templately credential. |
| 266 |
* @return string |
| 267 |
*/ |
| 268 |
private function url( string $platform, string $credential ): string { |
| 269 |
// `platforms`, PLURAL. The service ignores an unrecognised query key rather than |
| 270 |
// rejecting it, and then answers with a default — so sending `platform` returned |
| 271 |
// Essential Blocks Pro for every request, including `elementor`. It looked exactly |
| 272 |
// like the service disregarding the parameter, and was read that way for two weeks |
| 273 |
// (see the note on `pointer()`); it was this spelling all along. Verified against |
| 274 |
// the live endpoint 2026-09-21: `platforms=elementor` answers |
| 275 |
// `downloads: { elementor: essential-addons-elementor.7.0.4.zip }`. |
| 276 |
$args = [ 'platforms' => $platform ]; |
| 277 |
|
| 278 |
if ( Helper::is_dev_api() ) { |
| 279 |
$args['dev'] = 'true'; |
| 280 |
} |
| 281 |
|
| 282 |
$args['token'] = $credential; |
| 283 |
|
| 284 |
return add_query_arg( $args, self::ENDPOINT ); |
| 285 |
} |
| 286 |
|
| 287 |
/** |
| 288 |
* Where the archive is streamed to: a UNIQUE temp file per request. |
| 289 |
* |
| 290 |
* Streamed rather than buffered because these are multi-megabyte files and the FSI |
| 291 |
* request is already holding a pack in memory. Unique rather than per-slug because |
| 292 |
* the first draft used one deterministic path, and two concurrent requests (two |
| 293 |
* admins, or an FSI retry overlapping the original) then truncated, validated and |
| 294 |
* unlinked EACH OTHER's file — an entitled user got a refusal because another |
| 295 |
* request had just discarded the archive under them. `wp_tempnam()` is what core's |
| 296 |
* own downloader uses. |
| 297 |
* |
| 298 |
* @param string $slug |
| 299 |
* @return string|null Null when no temp file can be created. |
| 300 |
*/ |
| 301 |
private function destination( string $slug ) { |
| 302 |
if ( ! function_exists( 'wp_tempnam' ) ) { |
| 303 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 304 |
} |
| 305 |
|
| 306 |
$path = wp_tempnam( 'templately-pro-' . sanitize_key( $slug ) . '.zip' ); |
| 307 |
|
| 308 |
return is_string( $path ) && '' !== $path ? $path : null; |
| 309 |
} |
| 310 |
} |
| 311 |
|