*/ private static array $pending = []; /** @var array}> */ private static array $active = []; /** @var list */ private static array $refused = []; /** Called by Plugin::boot(). */ public static function announce(): void { do_action('llamly_free_loaded', defined('LLAMLY_FREE_VERSION') ? LLAMLY_FREE_VERSION : '', self::API); } /** Register a module. Only inside the `llamly_free_register_modules` action; anywhere else it is refused. */ public static function add(Module $module): bool { if (!self::$collecting || !doing_action(self::HOOK_REGISTER)) { return false; } self::$pending[] = $module; return true; } /** True once the modules were collected (never before plugins_loaded, so every add-on had the chance to hook in). */ public static function ready(): bool { self::boot(); return self::$ready; } public static function boot(): void { if (self::$booted || did_action('plugins_loaded') === 0) { return; } self::$booted = true; self::$collecting = true; try { do_action('llamly_free_register_modules'); } catch (\Throwable $error) { // A callback that throws while adding loses only what it did not add yet. self::$refused[] = ['id' => '', 'label' => '', 'version' => '', 'api' => null, 'reason' => 'exception: ' . get_class($error)]; } finally { self::$collecting = false; } $modules = self::$pending; self::$pending = []; usort($modules, static fn(Module $a, Module $b): int => strcmp(self::safeId($a), self::safeId($b))); foreach ($modules as $module) { self::accept($module); } self::$ready = true; self::remember(); } /** Whether at least one module was accepted (active and valid). */ public static function hasActive(): bool { self::boot(); return (bool) self::$active; } /** @return array */ public static function adapters(): array { $adapters = []; foreach (self::$active as $entry) { $adapters += $entry['staged']['adapters']; } return $adapters; } /** @return array Executors wrapped so the module gets its WriteGateway as fourth argument. */ public static function executors(): array { $executors = []; foreach (self::$active as $id => $entry) { foreach ($entry['staged']['executors'] as $name => $executor) { $executors[$name] = static function (array $arguments, array $auth, string $tool = '') use ($executor, $id): mixed { return $executor($arguments, $auth, $tool, new WriteGateway($id, $tool)); }; } } return $executors; } /** * @return array,handler:?callable}> Read handlers wrapped so the module * gets a HashReader for the connected user as third argument. */ public static function tools(): array { $tools = []; foreach (self::$active as $id => $entry) { foreach ($entry['staged']['tools'] as $name => $tool) { if ($tool['handler'] !== null) { $handler = $tool['handler']; $tool['handler'] = static function (array $arguments, array $auth) use ($handler, $id): mixed { return $handler($arguments, $auth, new HashReader($id, (int) ($auth['user_id'] ?? 0))); }; } $tools += [$name => $tool]; } } return $tools; } /** * What the active modules tell connected agents (ModuleContext::describeForAgents and friends), in id order. * * @return list>,elementor:bool,tools:list}> */ public static function agentGuides(): array { self::boot(); $guides = []; foreach (self::$active as $id => $entry) { $agents = $entry['staged']['agents']; $guides[] = [ 'id' => $id, 'label' => self::text($entry['module']->label(), 80), 'instructions' => (string) $agents['instructions'], 'category' => (string) $agents['category'], 'related' => (array) $agents['related'], 'elementor' => (bool) $agents['elementor'], 'tools' => array_map('strval', array_keys($entry['staged']['tools'])), ]; } return $guides; } /** * Active module that registered a tool, for the Activity rows of changes it made on core items (a Gutenberg page * written by an add-on tool keeps the core object type "post"). * * @return array{id:string,label:string,active:bool}|null */ public static function ownerOfTool(string $tool): ?array { self::boot(); foreach (self::$active as $id => $entry) { if (isset($entry['staged']['tools'][$tool])) { return ['id' => $id, 'label' => self::text($entry['module']->label(), 80), 'active' => true]; } } return null; } /** @return array */ public static function sections(): array { self::boot(); $sections = []; foreach (self::$active as $entry) { $sections += $entry['staged']['sections']; } return $sections; } /** * Active and refused modules for the Doctor, About and the history tools. * * @return array{api:int,active:list>,refused:list>,inactive:list>} */ public static function report(): array { self::boot(); $active = []; foreach (self::$active as $id => $entry) { $module = $entry['module']; $active[] = [ 'id' => $id, 'label' => self::text($module->label(), 80), 'version' => self::text($module->version(), 32), 'api' => $module->requiresApi(), 'status' => self::text(self::call(static fn(): string => $module->status(), ''), 60), 'tools' => array_keys($entry['staged']['tools']), 'types' => array_keys($entry['staged']['adapters']), ]; } $inactive = []; foreach (self::known() as $id => $known) { if (!isset(self::$active[$id])) { $inactive[] = ['id' => $id, 'label' => (string) ($known['label'] ?? $id), 'version' => (string) ($known['version'] ?? ''), 'types' => array_values((array) ($known['types'] ?? []))]; } } return ['api' => self::API, 'active' => $active, 'refused' => self::$refused, 'inactive' => $inactive]; } /** * Module that owns a journal object type: an active module, or one that registered it before and is not active now. * * @return array{id:string,label:string,active:bool}|null */ public static function ownerOfType(string $type): ?array { self::boot(); foreach (self::$active as $id => $entry) { if (isset($entry['staged']['adapters'][$type])) { return ['id' => $id, 'label' => self::text($entry['module']->label(), 80), 'active' => true]; } } foreach (self::known() as $id => $known) { if (in_array($type, (array) ($known['types'] ?? []), true)) { return ['id' => (string) $id, 'label' => (string) ($known['label'] ?? $id), 'active' => false]; } } return null; } /** Error for an undo, redo or approval of a change whose module is not active. */ public static function unavailableError(array $owner): \WP_Error { return new \WP_Error( 'module_unavailable', /* translators: %s: add-on name. */ sprintf(__('This change was made by the add-on ā€œ%sā€, which is not active. Activate it to undo or redo this change.', 'llamly'), $owner['label']), ['status' => 409, 'module' => $owner['id']] ); } /** Whether $file is inside the directory $root (both resolved, symlinks included). */ public static function inside(string $file, string $root): bool { $real = realpath($file); $base = realpath($root); if ($real === false || $base === false) { return false; } $real = wp_normalize_path($real); $base = trailingslashit(wp_normalize_path($base)); return strpos($real, $base) === 0; } private static function accept(Module $module): void { $id = self::safeId($module); $record = ['id' => $id, 'label' => '', 'version' => '', 'api' => null]; try { $record['label'] = self::text($module->label(), 80); $record['version'] = self::text($module->version(), 32); $record['api'] = $module->requiresApi(); $reason = self::validate($module, $id); if ($reason !== null) { self::$refused[] = $record + ['reason' => $reason]; return; } $context = new ModuleContext($id, $module->namespace(), (string) realpath($module->root())); try { $module->register($context); } catch (\Throwable $error) { $context->fail('exception', get_class($error)); } $context->seal(); if ($context->error() !== null) { self::$refused[] = $record + ['reason' => (string) $context->error()]; return; } self::$active[$id] = ['module' => $module, 'staged' => $context->staged()]; } catch (\Throwable $error) { self::$refused[] = $record + ['reason' => 'exception: ' . get_class($error)]; } } private static function validate(Module $module, string $id): ?string { if (!preg_match('/^[a-z][a-z0-9]{1,23}$/', $id) || in_array($id, self::RESERVED, true)) { return 'id_invalid'; } if (isset(self::$active[$id])) { return 'id_duplicate'; } $api = $module->requiresApi(); if ($api < self::MIN_API || $api > self::API) { return 'api_incompatible: ' . $api; } $namespace = $module->namespace(); if (!preg_match('/^Llamly\\\\(?:[A-Z][A-Za-z0-9_]*\\\\)+$/', $namespace) || stripos($namespace, 'Llamly\\Free\\') === 0) { return 'namespace_invalid'; } $root = realpath($module->root()); if ($root === false || !is_dir($root)) { return 'root_invalid'; } $root = trailingslashit(wp_normalize_path($root)); $free = realpath(LLAMLY_FREE_ROOT . '/src/Free'); $free = $free !== false ? trailingslashit(wp_normalize_path($free)) : ''; // A module root may not be the site, wp-content or the folder that holds the plugins (the parent of this plugin's // folder). These are only compared with the module root, never used to build a path. $broad = array_filter(array_map(static function (string $path): string { $real = realpath($path); return $real !== false ? trailingslashit(wp_normalize_path($real)) : ''; }, [ABSPATH, WP_CONTENT_DIR, dirname(LLAMLY_FREE_DIR)])); if ($free === '' || strpos($root, $free) === 0 || strpos($free, $root) === 0 || in_array($root, $broad, true)) { return 'root_invalid'; } $class = get_class($module); $file = (new \ReflectionClass($module))->getFileName(); if (strpos($class, $namespace) !== 0 || !is_string($file) || !self::inside($file, $root)) { return 'module_not_owned'; } return null; } private static function safeId(Module $module): string { try { return (string) $module->id(); } catch (\Throwable $error) { return ''; } } private static function call(callable $fn, string $fallback): string { try { return (string) $fn(); } catch (\Throwable $error) { return $fallback; } } private static function text(string $value, int $max): string { return substr(sanitize_text_field($value), 0, $max); } /** @return array}> */ private static function known(): array { $known = get_option(self::OPTION_KNOWN, []); return is_array($known) ? $known : []; } /** Remembers which object types each active module owns; written only when something changed. */ private static function remember(): void { if (!self::$active) { return; } $known = self::known(); $next = $known; foreach (self::$active as $id => $entry) { if (!$entry['staged']['adapters']) { // Only modules with journal object types matter once they are gone. continue; } $types = array_values(array_unique(array_merge((array) ($known[$id]['types'] ?? []), array_keys($entry['staged']['adapters'])))); sort($types); $next[$id] = ['label' => self::text($entry['module']->label(), 80), 'version' => self::text($entry['module']->version(), 32), 'types' => $types]; } ksort($next); if ($next !== $known) { update_option(self::OPTION_KNOWN, $next, true); } } }