PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / trunk
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! vtrunk
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / pro-plugin-provisioning / ProvisioningSource.php

ProvisioningSource.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! trunk, at modules/pro-plugin-provisioning/ProvisioningSource.php

311 lines 12.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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