| 1 |
<?php |
| 2 |
/** |
| 3 |
* Pro-plugin-provisioning module. |
| 4 |
* |
| 5 |
* Lets Templately install two paid plugins on a user's behalf during import — |
| 6 |
* Essential Addons Pro (Elementor) and Essential Blocks Pro (Gutenberg) — when the |
| 7 |
* user's Templately subscription entitles them to it. |
| 8 |
* |
| 9 |
* The module owns NO REST route and NO import logic. It answers one filter, |
| 10 |
* {@see self::ARCHIVE_FILTER}, with the path to a downloaded plugin archive, and |
| 11 |
* `Utils\Installer` does the rest. That is what lets a single change serve both the |
| 12 |
* full-site (pack) import and the single-item import, and what makes "behaves exactly |
| 13 |
* as before when this module is disabled" (spec 059 FR-025) free rather than a branch: |
| 14 |
* a disabled module never adds the filter, so `Installer` falls back to the `pro_plugin` |
| 15 |
* refusal it has always returned. |
| 16 |
* |
| 17 |
* Entitlement is decided REMOTELY. Nothing here reads a plan, a tier, or a subscription |
| 18 |
* value (FR-003) — the download service is the only authority, and its answer is never |
| 19 |
* cached (FR-019), so an upgraded plan takes effect on the next attempt. |
| 20 |
* |
| 21 |
* @package Templately |
| 22 |
*/ |
| 23 |
|
| 24 |
namespace Templately\Modules\ProPluginProvisioning; |
| 25 |
|
| 26 |
use Templately\Core\Module_Base; |
| 27 |
use Templately\Utils\Options; |
| 28 |
|
| 29 |
class Module extends Module_Base { |
| 30 |
|
| 31 |
/** |
| 32 |
* Ask any registered source for an installable archive. |
| 33 |
* |
| 34 |
* `apply_filters( self::ARCHIVE_FILTER, null, array $request ): string|null` |
| 35 |
* |
| 36 |
* See specs/059-pro-plugin-provisioning/contracts/provisioning-source.md for the |
| 37 |
* full contract. In short: return an absolute path to a readable file or the value |
| 38 |
* you were given; never a URL, never a WP_Error, never an exception, never output. |
| 39 |
*/ |
| 40 |
const ARCHIVE_FILTER = 'templately_pro_plugin_archive'; |
| 41 |
|
| 42 |
/** |
| 43 |
* Seconds a source may spend acquiring an archive, filterable per site. |
| 44 |
* |
| 45 |
* Deliberately far longer than an ordinary cloud call: this is a real plugin |
| 46 |
* download of several megabytes, not an API response. |
| 47 |
*/ |
| 48 |
const TIMEOUT_FILTER = 'templately_pro_plugin_provisioning_timeout'; |
| 49 |
|
| 50 |
/** |
| 51 |
* 90, not the 120 first chosen. The full-site import client runs an SSE stall |
| 52 |
* watchdog of exactly 120s (`useFullSiteImport.ts`), cleared only by a real frame — |
| 53 |
* and no frame is emitted while `wp_remote_get()` blocks on the download. A budget |
| 54 |
* equal to the watchdog let a slow host trip it: the client reconnected, took over |
| 55 |
* the run lock, and re-ran the dependency step, racing a second download of the same |
| 56 |
* archive against the first. 30s of headroom keeps the download inside one frame gap. |
| 57 |
* Sites with genuinely slow links raise it through the filter — knowingly. |
| 58 |
*/ |
| 59 |
const DEFAULT_TIMEOUT = 90; |
| 60 |
|
| 61 |
/** |
| 62 |
* Reserved development credentials the dev-only mock source recognises. |
| 63 |
* |
| 64 |
* They live HERE, on the shipped module, rather than in the mock, so the mock, the |
| 65 |
* tests and the E2E helper all read one declaration and cannot drift. They are inert |
| 66 |
* in production: nothing in a production build reads them, because the only reader — |
| 67 |
* `modules/developer/pro-provisioning-mock/` — is excluded from the zip by |
| 68 |
* `.distignore`. |
| 69 |
* |
| 70 |
* Neither value authenticates against the Templately cloud. They are written into the |
| 71 |
* site's stored `api_key` for the duration of a test or a manual run, then restored. |
| 72 |
*/ |
| 73 |
const MOCK_CREDENTIAL_GRANTED = 'templately-mock-pro-granted'; |
| 74 |
const MOCK_CREDENTIAL_DENIED = 'templately-mock-pro-denied'; |
| 75 |
|
| 76 |
public function get_name(): string { |
| 77 |
return 'pro-plugin-provisioning'; |
| 78 |
} |
| 79 |
|
| 80 |
protected function init_hooks(): void { |
| 81 |
add_filter( self::ARCHIVE_FILTER, [ $this, 'provide_archive' ], 10, 2 ); |
| 82 |
} |
| 83 |
|
| 84 |
/** |
| 85 |
* The live WPDeveloper source. |
| 86 |
* |
| 87 |
* Registered at priority 10 so the dev mock (priority 5) can answer first for its two |
| 88 |
* reserved credentials and fall through for every other one. |
| 89 |
* |
| 90 |
* @param string|null $archive_path Path supplied by an earlier source, or null. |
| 91 |
* @param array $request plugin_file, slug, platform, credential. |
| 92 |
* @return string|null |
| 93 |
*/ |
| 94 |
public function provide_archive( $archive_path, $request ) { |
| 95 |
if ( is_string( $archive_path ) && '' !== $archive_path ) { |
| 96 |
// An earlier source already answered. Do not spend a request re-answering. |
| 97 |
return $archive_path; |
| 98 |
} |
| 99 |
|
| 100 |
return ProvisioningSource::get_instance()->fetch( is_array( $request ) ? $request : [] ); |
| 101 |
} |
| 102 |
|
| 103 |
/** |
| 104 |
* The site's stored Templately credential, or an empty string when not connected. |
| 105 |
* |
| 106 |
* Read through one accessor so the "no credential ⇒ no attempt" rule (FR-015) has a |
| 107 |
* single definition, and so the value has exactly one place it can be logged from — |
| 108 |
* which is nowhere. |
| 109 |
*/ |
| 110 |
public static function credential(): string { |
| 111 |
$api_key = Options::get_instance()->get( 'api_key' ); |
| 112 |
|
| 113 |
return is_string( $api_key ) ? trim( $api_key ) : ''; |
| 114 |
} |
| 115 |
|
| 116 |
/** |
| 117 |
* Whether anything can answer the archive filter right now. |
| 118 |
* |
| 119 |
* The honest "is this feature on" test. A disabled module never ran `init_hooks()`, |
| 120 |
* so its listener is absent; the dev mock declares a dependency on this module, so it |
| 121 |
* is skipped alongside. `class_exists()` cannot tell you this — the autoloader |
| 122 |
* namespace is registered for inactive modules too. |
| 123 |
*/ |
| 124 |
public static function is_live(): bool { |
| 125 |
return false !== has_filter( self::ARCHIVE_FILTER ); |
| 126 |
} |
| 127 |
|
| 128 |
/** |
| 129 |
* Seconds an acquisition attempt may take. |
| 130 |
*/ |
| 131 |
public static function timeout(): int { |
| 132 |
$timeout = (int) apply_filters( self::TIMEOUT_FILTER, self::DEFAULT_TIMEOUT ); |
| 133 |
|
| 134 |
return $timeout > 0 ? $timeout : self::DEFAULT_TIMEOUT; |
| 135 |
} |
| 136 |
} |
| 137 |
|