connection = $connection; $this->sync = $sync; } public function register() { add_action('admin_menu', array($this, 'menu')); add_action('admin_init', array($this, 'activationRedirect')); add_action('admin_enqueue_scripts', array($this, 'assets')); add_action('admin_post_ladon_sync_now', array($this, 'syncNow')); add_filter('plugin_action_links_' . LADON_BASENAME, array($this, 'actionLinks')); } public function menu() { add_options_page( __('Ladon', 'hackindex-ladon'), __('Ladon', 'hackindex-ladon'), 'manage_options', 'ladon', array($this, 'render') ); } public function assets($hook) { if ($hook !== 'settings_page_ladon') { return; } $file = plugin_dir_path(LADON_FILE) . 'assets/admin.css'; // The file time busts the browser cache after an update, the plugin version alone does not change while developing $version = LADON_VERSION . (file_exists($file) ? '.' . filemtime($file) : ''); wp_enqueue_style('ladon-admin', plugins_url('assets/admin.css', LADON_FILE), array(), $version); } public function actionLinks($links) { $url = esc_url(admin_url('options-general.php?page=ladon')); $label = Settings::isConnected() ? __('Status', 'hackindex-ladon') : __('Connect', 'hackindex-ladon'); array_unshift($links, '' . esc_html($label) . ''); return $links; } public function activationRedirect() { if (!get_transient(Plugin::ACTIVATION_REDIRECT)) { return; } delete_transient(Plugin::ACTIVATION_REDIRECT); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- core flag for a bulk activation, only read to skip the redirect if (wp_doing_ajax() || is_network_admin() || isset($_GET['activate-multi'])) { return; } wp_safe_redirect(admin_url('options-general.php?page=ladon')); exit; } public function syncNow() { if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to do this.', 'hackindex-ladon'), 403); } check_admin_referer('ladon_sync_now'); delete_transient(self::STATUS_CACHE); $result = $this->sync->run('manual'); if ($result !== null && $result['ok']) { $flash = array('ladon_synced' => '1'); } else { $flash = array('ladon_error' => 'sync'); $detail = is_array($result) && !empty($result['error']) ? (string) $result['error'] : ''; set_transient(self::SYNC_ERROR, $detail, 120); } wp_safe_redirect(add_query_arg($flash, admin_url('options-general.php?page=ladon'))); exit; } public function render() { if (!current_user_can('manage_options')) { return; } echo '
'; echo '

' . esc_html__('Ladon', 'hackindex-ladon') . '

'; echo '

' . esc_html__('Ladon watches the plugins, themes and versions on this site for known vulnerabilities. Your site is never scanned from the outside.', 'hackindex-ladon') . '

'; $this->notices(); if (Settings::isConnected()) { $this->connected(); } else { $this->disconnected(); } echo '
'; } private function notices() { $map = array( 'ladon_connected' => array('success', __('This site is now connected to Ladon.', 'hackindex-ladon')), 'ladon_disconnected' => array('success', __('This site is disconnected. Nothing more is sent to Ladon.', 'hackindex-ladon')), 'ladon_synced' => array('success', __('Inventory sent to Ladon.', 'hackindex-ladon')), ); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- notice flags from our own redirects, only shown, never acted on foreach ($map as $key => $notice) { if (isset($_GET[$key])) { printf('

%s

', esc_attr($notice[0]), esc_html($notice[1])); } } if (isset($_GET['ladon_error'])) { $errors = array( 'state' => __('The connection could not be verified. Please try connecting again.', 'hackindex-ladon'), 'denied' => __('The connection was cancelled.', 'hackindex-ladon'), 'code' => __('Ladon did not return a connection code. Please try again.', 'hackindex-ladon'), 'exchange' => __('Ladon could not finish the connection. Please try again.', 'hackindex-ladon'), 'sync' => __('The inventory could not be sent. Please check the status below.', 'hackindex-ladon'), ); $code = sanitize_key(wp_unslash($_GET['ladon_error'])); $message = isset($errors[$code]) ? $errors[$code] : __('Something went wrong.', 'hackindex-ladon'); if ($code === 'sync') { $detail = get_transient(self::SYNC_ERROR); delete_transient(self::SYNC_ERROR); if (is_string($detail) && $detail !== '') { $message = sprintf(/* translators: %s: error message from Ladon */ __('Inventory not sent: %s', 'hackindex-ladon'), $detail); } } printf('

%s

', esc_html($message)); } // phpcs:enable WordPress.Security.NonceVerification.Recommended if (Settings::get('needs_reconnect')) { printf('

%s

', esc_html__('The connection to Ladon is no longer valid. Please connect this site again.', 'hackindex-ladon')); } } private function disconnected() { $steps = array( array(__('Sign in', 'hackindex-ladon'), __('With your HackIndex account. No account yet? You create one on the way, free to start.', 'hackindex-ladon')), array(__('Pick a team', 'hackindex-ladon'), __('Alerts about this site go to the channels of that team: email, Slack, Teams or a webhook.', 'hackindex-ladon')), array(__('Confirm', 'hackindex-ladon'), __('Back here, the plugin sends its first inventory right away and then once a day, or whenever plugins change.', 'hackindex-ladon')), ); echo '
'; echo '

' . esc_html__('Connect this site in three steps', 'hackindex-ladon') . '

'; echo '
    '; foreach ($steps as $i => $step) { echo '
  1. ' . (int) ($i + 1) . '
    '; echo '' . esc_html($step[0]) . ''; echo '

    ' . esc_html($step[1]) . '

    '; echo '
  2. '; } echo '
'; echo '
'; echo ''; wp_nonce_field('ladon_connect'); echo ''; echo '
'; echo '

' . esc_html__('Only the versions of WordPress, PHP, your plugins and themes and the site address are sent. No content, users or passwords, and nothing before you connect.', 'hackindex-ladon') . '

'; echo '
'; } private function connected() { $status = $this->status(); $site = isset($status['site']) && is_array($status['site']) ? $status['site'] : array(); $team = isset($status['team']) && is_array($status['team']) ? $status['team'] : array(); $vulnerable = isset($site['vulnerable_count']) ? (int) $site['vulnerable_count'] : null; $teamSlug = isset($team['slug']) && $team['slug'] !== '' ? $team['slug'] : Settings::get('team_slug', '—'); $plan = isset($team['plan']) && $team['plan'] !== '' ? $team['plan'] : Settings::get('plan', '—'); echo '
'; echo '

' . esc_html__('Connected', 'hackindex-ladon') . '

'; if ($vulnerable !== null) { echo $vulnerable === 0 ? '' . esc_html__('No known vulnerabilities', 'hackindex-ladon') . '' : '' . esc_html(sprintf(/* translators: %d: number of vulnerable components */ _n('%d vulnerable component', '%d vulnerable components', $vulnerable, 'hackindex-ladon'), $vulnerable)) . ''; } echo '
'; echo ''; $this->row(__('Team', 'hackindex-ladon'), $teamSlug); $this->row(__('Plan', 'hackindex-ladon'), ucfirst((string) $plan)); $this->row(__('Site', 'hackindex-ladon'), Settings::get('site_name') ? Settings::get('site_name') : home_url('/')); $this->row(__('Watched', 'hackindex-ladon'), $this->components($site)); $this->row(__('Last report', 'hackindex-ladon'), $this->lastSync()); echo '
'; $this->usage($team, (string) $plan); echo '
'; $this->form('ladon_sync_now', __('Send inventory now', 'hackindex-ladon'), 'button'); $this->form('ladon_disconnect', __('Disconnect', 'hackindex-ladon'), 'button button-link-delete', __('Disconnect this site from Ladon? Its connection token will stop working.', 'hackindex-ladon')); echo '
'; echo '
'; $this->upgrade($team); } private function components($site) { if (empty($site['components']) || !is_array($site['components'])) { return '—'; } $plugins = isset($site['components']['plugins']) ? (int) $site['components']['plugins'] : 0; $themes = isset($site['components']['themes']) ? (int) $site['components']['themes'] : 0; return sprintf( /* translators: 1: number of plugins, 2: number of themes */ __('%1$s and %2$s, plus WordPress core', 'hackindex-ladon'), sprintf(/* translators: %d: number of plugins */ _n('%d plugin', '%d plugins', $plugins, 'hackindex-ladon'), $plugins), sprintf(/* translators: %d: number of themes */ _n('%d theme', '%d themes', $themes, 'hackindex-ladon'), $themes) ); } private function usage($team, $plan) { if (empty($team['limits']['sites']) || !is_array($team['limits']['sites'])) { return; } $sites = $team['limits']['sites']; $limit = isset($sites['limit']) ? (int) $sites['limit'] : -1; $used = isset($sites['used']) ? (int) $sites['used'] : 0; echo '
'; echo '
' . esc_html__('Connected sites', 'hackindex-ladon') . ''; if ($limit < 0) { echo '' . esc_html(sprintf(/* translators: %d: number of connected sites */ __('%d, no limit', 'hackindex-ladon'), $used)) . '
'; echo '
'; return; } echo '' . esc_html(sprintf(/* translators: 1: connected sites, 2: sites allowed on the plan */ __('%1$d of %2$d', 'hackindex-ladon'), $used, $limit)) . ''; $percent = $limit > 0 ? min(100, (int) round($used / $limit * 100)) : 100; $full = $used >= $limit; echo '
'; $left = max(0, $limit - $used); $text = $full ? sprintf(/* translators: %s: plan name */ __('This team uses every site of the %s plan. Upgrade to connect more.', 'hackindex-ladon'), ucfirst($plan)) : sprintf(/* translators: 1: number of sites left, 2: plan name */ _n('You can connect %1$d more site on the %2$s plan.', 'You can connect %1$d more sites on the %2$s plan.', $left, 'hackindex-ladon'), $left, ucfirst($plan)); echo '

' . esc_html($text) . '

'; echo ''; } private function upgrade($team) { if (empty($team['upgrade']) || !is_array($team['upgrade']) || empty($team['upgrade']['perks'])) { return; } $next = ucfirst((string) $team['upgrade']['plan']); $url = isset($team['upgrade']['url']) ? (string) $team['upgrade']['url'] : 'https://ladon.hackindex.io/pricing'; echo '
'; echo '

' . esc_html(sprintf(/* translators: %s: plan name */ __('Get more out of Ladon with %s', 'hackindex-ladon'), $next)) . '

'; echo ''; echo '' . esc_html(sprintf(/* translators: %s: plan name */ __('See %s', 'hackindex-ladon'), $next)) . ''; echo '
'; } private function perk($perk) { $key = isset($perk['key']) ? (string) $perk['key'] : ''; $value = isset($perk['value']) ? (int) $perk['value'] : null; $unlimited = $value === -1; switch ($key) { case 'sites': return $unlimited ? __('Connect as many sites as you like', 'hackindex-ladon') : sprintf(/* translators: %d: number of sites */ __('Connect up to %d sites', 'hackindex-ladon'), $value); case 'watches': return $unlimited ? __('Watch any number of plugins, themes and packages by hand', 'hackindex-ladon') : sprintf(/* translators: %d: number of watched items */ __('Watch up to %d plugins, themes and packages by hand', 'hackindex-ladon'), $value); case 'channels': return $unlimited ? __('Any number of alert channels', 'hackindex-ladon') : sprintf(/* translators: %d: number of alert channels */ __('Up to %d alert channels, per client or per team', 'hackindex-ladon'), $value); case 'log_retention_days': return sprintf(/* translators: %d: number of days */ __('Alert history kept for %d days', 'hackindex-ladon'), $value); case 'email': return __('Alerts by email, next to Slack, Teams and webhooks', 'hackindex-ladon'); case 'advanced_delivery': return __('Batched alerts and weekly digests, so a busy day stays one message', 'hackindex-ladon'); case 'api': return __('API access to wire Ladon into your own tooling', 'hackindex-ladon'); } return null; } /** * @return array */ private function status() { $cached = get_transient(self::STATUS_CACHE); if (is_array($cached)) { return $cached; } $result = (new Client())->status(); $body = $result['ok'] ? $result['body'] : array(); // Keep the stored team and plan in step, so the fallback shows the current values too if ($result['ok'] && isset($body['team']) && is_array($body['team'])) { $keep = array(); if (isset($body['team']['plan'])) { $keep['plan'] = (string) $body['team']['plan']; } if (isset($body['team']['slug'])) { $keep['team_slug'] = (string) $body['team']['slug']; } if ($keep !== array()) { Settings::merge($keep); } } set_transient(self::STATUS_CACHE, $body, 5 * MINUTE_IN_SECONDS); return $body; } private function lastSync() { $time = Settings::get('last_sync'); if (!$time) { return esc_html__('Not yet', 'hackindex-ladon'); } return esc_html(sprintf(/* translators: %s: human readable time difference */ __('%s ago', 'hackindex-ladon'), human_time_diff((int) $time, time()))); } private function row($label, $value, $escape = true) { echo '' . esc_html($label) . '' . ($escape ? esc_html((string) $value) : wp_kses_post($value)) . ''; } private function form($action, $label, $class, $confirm = null) { if ($confirm === null) { echo '
'; } else { echo ''; } echo ''; wp_nonce_field($action); echo ''; echo '
'; } }