*/ private array $services = []; /** * @param string $file Main plugin file. */ private function __construct( private readonly string $file ) { } /** * Singleton accessor (the only global; everything below is injected). */ public static function instance(): self { return self::$instance ??= new self( defined( 'AKANTIO_CACHE_FILE' ) ? AKANTIO_CACHE_FILE : dirname( __DIR__ ) . '/akantio-cache.php' ); } /** * Registers hooks. */ public function register(): void { $this->upgradeIfNeeded(); $settings = $this->settingsService()->current(); ( new PageCacheHooks( $settings, fn () => $this->pageCapture(), $this->secrets()->get( 'role_cookie_secret' ) ) )->register(); ( new AutoPurgeHooks( fn () => $this->purgeService(), new WpContentUrls(), $this->purgeTargets() ) )->register(); ( new FrontendTweaks( $settings ) )->register(); ( new OptimizationAssets( $settings ) )->register(); ( new Cron( fn () => $this->queueRunner(), fn () => $this->purgeService(), $settings, fn () => $this->databaseCleaner()->clean( $this->settingsService()->current() ), fn () => $this->probeRegions() ) )->register(); add_action( 'rest_api_init', [ $this, 'registerRestRoutes' ] ); ( new AdminBar( $settings, fn () => $this->pageCacheInspector() ) )->register(); if ( is_admin() ) { ( new AdminPage( $this->file ) )->register(); ( new ConnectReturn( fn () => $this->connectFlow() ) )->register(); ( new SiteHealth( fn () => $this->settingsService() ) )->register(); } if ( defined( 'WP_CLI' ) && WP_CLI ) { AkantioCommand::register( $this ); } add_action( 'update_option_home', fn () => $this->settingsService()->recompile() ); add_action( 'update_option_siteurl', fn () => $this->settingsService()->recompile() ); } /** * Registers REST routes. */ public function registerRestRoutes(): void { ( new SettingsController( fn () => $this->settingsService() ) )->registerRoutes(); ( new CacheController( fn () => $this->settingsService(), fn () => $this->purgeService(), fn () => $this->pageStore(), fn () => $this->queue(), fn () => $this->queueRunner(), $this->advancedCacheDropin(), new FileHitCounter( $this->paths()->content_dir . '/akantio-cache/stats' ), fn () => $this->databaseCleaner(), fn () => $this->preloadPacer() ) )->registerRoutes(); ( new ConnectionController( fn () => $this->connectionService(), $this->apiUrl(), fn () => $this->connectFlow(), fn () => $this->planOffer() ) )->registerRoutes(); ( new MaintenanceController( fn () => $this->settingsService(), fn () => $this->databaseCleaner(), $this->paths() ) )->registerRoutes(); ( new DiagnosticsController( fn () => $this->settingsService(), fn () => $this->connectionService(), fn () => $this->pageStore(), fn () => $this->queue(), fn () => $this->redisConfig( $this->settingsService()->current() ), $this->advancedCacheDropin(), $this->objectCacheDropin(), $this->paths(), $this->apiUrl(), fn () => $this->knownSecrets() ) )->registerRoutes(); ( new RedisController( fn () => $this->settingsService(), fn ( Settings $settings ) => $this->redisConfig( $settings ), new PhpRedisProbe(), $this->objectCacheDropin() ) )->registerRoutes(); } /** * Activation: storage, protected cache directory, compiled runtime config, drop-ins. */ public function activate(): void { $this->queue()->install(); $this->protectCacheDirectory(); $this->settingsService()->recompile(); Cron::schedule( $this->settingsService()->current() ); update_option( self::VERSION_OPTION, self::VERSION, true ); } /** * Runs activation steps after plugin updates, or when the activation hook never ran * (mu-plugin installs, network activation, file-based deployments). */ private function upgradeIfNeeded(): void { if ( get_option( self::VERSION_OPTION ) !== self::VERSION ) { $this->activate(); } } /** * Deactivation: remove everything outside the plugin directory except settings. */ public function deactivate(): void { delete_option( self::VERSION_OPTION ); Cron::unschedule(); $this->settingsService()->teardown(); $this->pageStore()->purgeAll(); } /** * Settings use cases. */ public function settingsService(): SettingsService { return $this->service( __FUNCTION__, fn () => new SettingsService( new OptionSettingsRepository( $this->secretBox() ), new RuntimeConfigFile( $this->paths()->runtimeFile(), $this->writer() ), new RuntimeConfigBuilder(), $this->siteContext(), [ $this->pageCacheEnvironment(), $this->objectCacheEnvironment(), $this->cloudflare() ], new PurgeOnOutputChange( fn () => $this->purgeService() ) ) ); } /** * Connection to AKantio managed services. */ public function connectionService(): ConnectionService { return $this->service( __FUNCTION__, fn () => new ConnectionService( $this->saasApi(), new OptionConnectionRepository( $this->secretBox() ), $this->settingsService(), [ 'site_url' => site_url(), 'home_url' => home_url(), 'environment' => $this->environmentReport(), ], 'time', fn (): bool => DropinStatus::Ours === $this->objectCacheDropin()->status(), static fn () => Cron::probeRegionsSoon() ) ); } /** * Measures the latency to each Redis region (background only). Failures wait for the next run. */ public function probeRegions(): void { $service = new RegionProbeService( $this->saasApi(), new OptionConnectionRepository( $this->secretBox() ), new StreamSocketLatencyMeter(), static fn (): float => hrtime( true ) / 1e9 ); try { $service->run(); } catch ( SaasError ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Best effort; measured again next week. } } /** * Versions sent to AKantio (no personal data). * * @return array */ public function environmentReport(): array { return [ 'plugin_version' => self::VERSION, 'php_version' => PHP_VERSION, 'wp_version' => (string) get_bloginfo( 'version' ), 'wc_version' => defined( 'WC_VERSION' ) ? (string) WC_VERSION : null, 'multisite' => is_multisite(), 'redis_extension' => phpversion( 'redis' ) ?: null, ]; } /** * Plans customers can subscribe to. */ public function planOffer(): PlanOffer { return $this->service( __FUNCTION__, fn () => new PlanOffer( $this->saasApi(), new TransientPlanCatalogCache() ) ); } /** * Site API client. */ private function saasApi(): HttpSaasApi { return new HttpSaasApi( $this->apiUrl(), 'AKantioCache/' . self::VERSION . ' WordPress/' . get_bloginfo( 'version' ) ); } /** * Connect with AKantio flow. */ public function connectFlow(): ConnectFlow { return $this->service( __FUNCTION__, fn () => new ConnectFlow( new TransientConnectRequests(), fn () => $this->connectionService(), $this->apiUrl(), [ 'home_url' => home_url(), 'site_url' => site_url(), 'name' => wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ), ] ) ); } /** * Backoffice base URL (API and portal). */ public function apiUrl(): string { return ApiEndpoint::forEnvironment( wp_get_environment_type() ); } /** * Database cleanup. */ public function databaseCleaner(): DatabaseCleaner { return new DatabaseCleaner( new WpDatabaseMaintenance( $GLOBALS['wpdb'] ) ); } /** * Purge use cases. */ public function purgeService(): PurgeService { return $this->service( __FUNCTION__, fn () => new PurgeService( $this->pageStore(), $this->queue(), $this->purgeTargets(), (array) apply_filters( 'akantio_cache_purge_listeners', [ $this->cdnPurgeListener(), $this->cloudflare(), new HostCachePurger(), new LastPurgeRecorder() ] ), (bool) $this->settingsService()->current()->get( 'preload_enabled' ), static fn ( \Throwable $e ) => ErrorLog::site()->write( 'purge listener failed: ' . $e->getMessage() ) ) ); } /** * Page store. */ public function pageStore(): PageStore { return $this->service( __FUNCTION__, fn () => new FilesystemPageStore( $this->paths()->cacheDir(), $this->writer() ) ); } /** * Job queue. */ public function queue(): WpdbJobQueue { return $this->service( __FUNCTION__, static fn () => new WpdbJobQueue( $GLOBALS['wpdb'] ) ); } /** * Background runner. */ public function queueRunner(): QueueRunner { return $this->service( __FUNCTION__, function () { $settings = $this->settingsService()->current(); $handlers = [ PurgeService::PRELOAD_URL => new PreloadUrlHandler( new WpPreloadClient( $this->secrets()->get( 'preload_token' ) ), $this->purgeTargets(), $this->pageStore(), $this->preloadPacer() ), PurgeService::PRELOAD_ALL => new PreloadAllHandler( new WpUrlCollector(), $this->queue(), $this->purgeTargets(), (bool) $settings->get( 'separate_mobile_cache' ) ), CdnPurgeListener::JOB => $this->cdnPurgeListener(), self::SYNC_JOB => $this->syncHandler(), ]; $handlers = (array) apply_filters( 'akantio_cache_job_handlers', $handlers ); return new QueueRunner( $this->queue(), new QueueWorker( $this->queue(), $handlers, new RetryPolicy() ), $this->secrets()->get( 'queue_token' ), new WpdbRunnerLock( $GLOBALS['wpdb'], (int) $settings->get( 'preload_concurrency' ) ) ); } ); } /** * Load guard and pacing for preloading. Built on each call (it is cheap) so it always * uses the current load limit. */ public function preloadPacer(): PreloadPacer { return new PreloadPacer( new SystemServerLoad(), (int) $this->settingsService()->current()->get( 'preload_max_load' ), static fn ( int $ms ) => usleep( $ms * 1000 ) ); } /** * Filesystem locations. */ public function paths(): Paths { return $this->service( __FUNCTION__, fn () => Paths::fromWordPress( $this->file ) ); } /** * advanced-cache.php. */ public function advancedCacheDropin(): DropinFile { return $this->service( __FUNCTION__, fn () => new DropinFile( $this->paths()->advancedCache(), $this->writer(), new OptionDropinBackups() ) ); } /** * object-cache.php. */ public function objectCacheDropin(): DropinFile { return $this->service( __FUNCTION__, fn () => new DropinFile( $this->paths()->objectCache(), $this->writer(), new OptionDropinBackups() ) ); } /** * Runtime "redis" section for given settings. * * @param Settings $settings Settings. * @return array */ public function redisConfig( Settings $settings ): array { return ( new RuntimeConfigBuilder() )->build( $settings, $this->siteContext() )['redis']; } /** * Installation secrets. */ public function secrets(): InstallationSecrets { return $this->service( __FUNCTION__, static fn () => new InstallationSecrets() ); } /** * Page cache state of a URL for the admin bar. */ public function pageCacheInspector(): PageCacheInspector { $settings = $this->settingsService()->current(); return new PageCacheInspector( $this->cacheRules( $settings ), $this->pageStore(), (bool) $settings->get( 'page_cache_enabled' ) ); } /** * Page cache rules exactly as the drop-in compiles them. * * @param Settings $settings Settings. */ private function cacheRules( Settings $settings ): CacheRules { return CacheRules::fromArray( ( new RuntimeConfigBuilder() )->build( $settings, $this->siteContext() )['page_cache']['rules'] ); } /** * Page capture with optimizers. */ private function pageCapture(): PageCapture { $settings = $this->settingsService()->current(); return new PageCapture( $this->cacheRules( $settings ), $this->pageStore(), 'time', (array) apply_filters( 'akantio_cache_html_transformers', $this->htmlTransformers( is_user_logged_in() ), $settings ), static fn ( \Throwable $e ) => ErrorLog::site()->write( 'could not optimize or store page: ' . $e->getMessage() ), (bool) $settings->get( 'page_cache_enabled' ), $this->outputTransformers() ); } /** * Transformers for pages that will be cached. Logged-in views (cached per role) get the * output transformers only: page optimizations such as lazy loading and delayed scripts * would interfere with the admin bar and editing tools. * * @param bool $logged_in Whether the visitor is logged in. * @return list */ public function htmlTransformers( bool $logged_in ): array { if ( $logged_in ) { return $this->outputTransformers(); } $s = $this->settingsService()->current(); $store = $this->assetStore(); $downloader = new WpDownloader(); $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); [ $minifier, $html_minifier, $cdn ] = $this->outputSteps(); $steps = array_filter( [ $s->get( 'youtube_facade' ) ? new YouTubeFacade() : null, $s->get( 'lazy_load' ) ? new LazyLoad( (int) $s->get( 'lazy_load_skip_first' ), (array) $s->get( 'lazy_load_excludes' ) ) : null, $s->get( 'image_dimensions' ) ? new ImageDimensions() : null, $s->get( 'font_display_swap' ) || $s->get( 'self_host_google_fonts' ) ? new FontOptimizer( (bool) $s->get( 'font_display_swap' ), (bool) $s->get( 'self_host_google_fonts' ), $store, $downloader ) : null, $s->get( 'self_host_third_party' ) || $s->get( 'self_host_gravatars' ) ? new RemoteAssets( (bool) $s->get( 'self_host_third_party' ), (bool) $s->get( 'self_host_gravatars' ), $store, $downloader ) : null, $minifier, 'off' !== $s->get( 'js_mode' ) || $s->get( 'js_delay_third_party' ) ? new Scripts( (string) $s->get( 'js_mode' ), (bool) $s->get( 'js_delay_third_party' ), (array) $s->get( 'js_excludes' ), $host ) : null, $html_minifier, $cdn, ] ); return array_values( array_map( static fn ( $step ) => [ $step, 'transform' ], $steps ) ); } /** * Transformers for every other front-end HTML page (logged-in, carts, query strings, pages * that set cookies): CSS/JS minification, HTML minification, then the CDN rewrite. * * @return list */ public function outputTransformers(): array { return array_values( array_map( static fn ( $step ) => [ $step, 'transform' ], array_filter( $this->outputSteps() ) ) ); } /** * Minifier, HTML minifier and CDN rewrite (null when off). The CDN rewrite is last so that * the steps before it still see local URLs (the minifier reads files from wp-content) and * the files they create are served from the CDN too. * * @return array{?Minifier, ?HtmlMinifier, ?CdnRewrite} */ private function outputSteps(): array { $s = $this->settingsService()->current(); return [ $s->get( 'css_minify' ) || $s->get( 'js_minify' ) ? new Minifier( $this->assetStore(), (bool) $s->get( 'css_minify' ), (bool) $s->get( 'js_minify' ), content_url(), WP_CONTENT_DIR, $this->writer() ) : null, $s->get( 'html_minify' ) ? new HtmlMinifier( (bool) $s->get( 'html_minify_inline' ), (bool) $s->get( 'html_remove_comments' ) ) : null, $this->cdnRewrite(), ]; } /** * Store for optimized and self-hosted assets. */ private function assetStore(): AssetStore { return new AssetStore( $this->paths()->cacheDir() . '/assets', content_url( 'cache/akantio/assets' ), $this->writer() ); } /** * CDN URL rewrite for the custom CDN or the managed AKantio CDN, or null when none is in use. */ public function cdnRewrite(): ?CdnRewrite { $s = $this->settingsService()->current(); $urls = $s->cdnBaseUrls(); if ( [] === $urls ) { return null; } return new CdnRewrite( RewriteHosts::from( [ home_url(), site_url() ], (array) $s->get( 'cdn_extra_domains' ) ), $urls, (string) $s->get( 'cdn_file_types' ) ); } /** * Page cache environment sync. */ private function pageCacheEnvironment(): PageCacheEnvironment { $paths = $this->paths(); $htaccess = ! empty( $GLOBALS['is_apache'] ) ? new LocalTextFile( $paths->htaccess(), $this->writer() ) : null; return new PageCacheEnvironment( $this->advancedCacheDropin(), new LocalTextFile( $paths->wpConfig(), $this->writer() ), $htaccess, $this->advancedCacheSource(), static fn ( Settings $settings ) => ApacheRules::render( $paths->cacheDir(), $paths->cacheUrlPath(), (bool) $settings->get( 'separate_mobile_cache' ) ), static fn () => (bool) apply_filters( 'akantio_cache_wpconfig_readonly', false ) ); } /** * Object cache environment sync. */ private function objectCacheEnvironment(): ObjectCacheEnvironment { return new ObjectCacheEnvironment( $this->objectCacheDropin(), new PhpRedisProbe(), $this->dropinSource( 'object-cache.php.tpl', 'object-cache.php' ), fn ( Settings $settings ) => $this->redisConfig( $settings ) ); } /** * Drop-in source with this installation's loader path. */ private function advancedCacheSource(): string { return $this->dropinSource( 'advanced-cache.php.tpl', 'page-cache.php' ); } /** * Renders a drop-in template. * * @param string $template Template file in dropins/. * @param string $loader Loader file in dropins/. */ private function dropinSource( string $template, string $loader ): string { $dir = $this->paths()->plugin_dir . '/dropins/'; $source = (string) file_get_contents( $dir . $template ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local template. return strtr( $source, [ '{{VERSION}}' => self::VERSION, '{{LOADER}}' => "'" . addcslashes( $dir . $loader, "'\\" ) . "'", ] ); } /** * Site facts for runtime config. */ private function siteContext(): SiteContext { $hosts = []; foreach ( [ home_url(), site_url() ] as $url ) { $parts = wp_parse_url( $url ); if ( isset( $parts['host'] ) ) { $hosts[] = strtolower( $parts['host'] ) . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ); } } return new SiteContext( array_values( array_unique( $hosts ) ), $this->paths()->cacheDir(), $this->secrets()->get( 'role_cookie_secret' ), $this->secrets()->get( 'preload_token' ), home_url() ); } /** * URL filter for this site. */ private function purgeTargets(): PurgeTargets { return new PurgeTargets( $this->siteContext()->allowed_hosts ); } /** * Denies direct web access to role-variant pages, which contain logged-in content. */ private function protectCacheDirectory(): void { try { $this->writer()->write( $this->paths()->cacheDir() . '/.htaccess', "\nRequire all denied\n\n" ); $this->writer()->write( $this->paths()->cacheDir() . '/index.php', "write( 'cache directory not writable: ' . $e->getMessage() ); } } /** * Every configured secret, for redaction. * * @return list */ private function knownSecrets(): array { $settings = $this->settingsService()->current()->toEffectiveArray(); $secrets = [ (string) $settings['redis_password'], (string) $settings['cloudflare_api_token'], $this->encryptionKeyMaterial() ]; foreach ( [ 'role_cookie_secret', 'preload_token', 'queue_token', 'encryption_key' ] as $name ) { $secrets[] = $this->secrets()->get( $name ); } $credentials = ( new OptionConnectionRepository( $this->secretBox() ) )->credentials(); if ( null !== $credentials ) { $secrets[] = $credentials->secret; } return array_values( array_filter( $secrets ) ); } /** * Self-managed Cloudflare zone. */ private function cloudflare(): CloudflareIntegration { return $this->service( __FUNCTION__, fn () => new CloudflareIntegration( fn () => $this->settingsService()->current(), $this->siteContext()->allowed_hosts ) ); } /** * Managed CDN purges. */ private function cdnPurgeListener(): CdnPurgeListener { return $this->service( __FUNCTION__, fn () => new CdnPurgeListener( $this->queue(), fn () => $this->connectionService() ) ); } /** * Background entitlement sync. */ private function syncHandler(): JobHandler { return new class( fn () => $this->connectionService() ) implements JobHandler { /** * @param \Closure(): ConnectionService $connection Connection. */ public function __construct( private readonly \Closure $connection ) { } /** * Syncs when due. * * @param Job $job Job. */ public function handle( Job $job ): void { ( $this->connection )()->sync( true === ( $job->payload['force'] ?? false ) ); } }; } /** * Encryption for stored secrets. */ private function secretBox(): SecretBox { return $this->service( __FUNCTION__, fn () => new SecretBox( $this->encryptionKeyMaterial() ) ); } /** * wp-config.php salts when configured (available before pluggable functions load, unlike * wp_salt()), otherwise a stored random key so behaviour stays deterministic. */ private function encryptionKeyMaterial(): string { $placeholder = 'put your unique phrase here'; if ( defined( 'AUTH_KEY' ) && defined( 'AUTH_SALT' ) && AUTH_KEY && AUTH_SALT && AUTH_KEY !== $placeholder && AUTH_SALT !== $placeholder ) { return AUTH_KEY . AUTH_SALT; } return $this->secrets()->get( 'encryption_key' ); } /** * Shared writer. */ private function writer(): AtomicFileWriter { return $this->service( __FUNCTION__, fn () => AtomicFileWriter::forCache( WP_CONTENT_DIR, [ $this->paths()->wpConfig(), $this->paths()->htaccess() ] ) ); } /** * Memoises a service. * * @template T of object * @param string $id Identifier. * @param callable(): T $factory Factory. * @return T */ private function service( string $id, callable $factory ): object { return $this->services[ $id ] ??= $factory(); } }