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('', 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('', esc_html($message));
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
if (Settings::get('needs_reconnect')) {
printf('', 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 '- ' . (int) ($i + 1) . '
';
echo '
' . esc_html($step[0]) . '';
echo '
' . esc_html($step[1]) . '
';
echo '
';
}
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 '';
}
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 '';
}
}