*/ function novasira_admin_page_slugs(): array { return [ 'novasira', 'novasira-connect', 'novasira-areas', 'novasira-tools', 'novasira-status', 'novasira-settings', 'novasira-help', ]; } /** * Is the request looking at one of this plugin's screens? * * Matched on the `page` query var rather than on $hook_suffix because the hook * suffix is derived from sanitize_title() of the *translated* menu title. In a * locale where "Novasira" transliterates, a hard-coded 'novasira_page_…' stops * matching and the stylesheet silently stops loading. The slug does not move. */ function novasira_is_admin_page(): bool { // Reading a GET parameter to decide whether to enqueue a stylesheet. No // state changes, so there is nothing here for a nonce to protect. // phpcs:ignore WordPress.Security.NonceVerification.Recommended $page = isset($_GET['page']) ? sanitize_key((string) wp_unslash($_GET['page'])) : ''; return in_array($page, novasira_admin_page_slugs(), true); } /** A link to one of this plugin's screens. @param array $args */ function novasira_page_url(string $slug = 'novasira', array $args = []): string { $url = admin_url('admin.php?page=' . $slug); return $args === [] ? $url : add_query_arg($args, $url); } /** * The stylesheets, on Novasira's screens only. * * Only on those screens: the shell stylesheet restyles .notice, .button and * .form-table, which would be vandalism on somebody else's page. novasira.css * declares the shell as a dependency so the cascade order is WordPress's * problem rather than ours, and both carry NOVASIRA_VERSION so an upgrade * invalidates the browser cache without anybody clearing anything. */ add_action('admin_enqueue_scripts', static function (): void { if (!novasira_is_admin_page()) { return; } wp_enqueue_style( 'novasira-admin', NOVASIRA_URL . 'assets/css/admin.css', [], NOVASIRA_VERSION ); // The shell stylesheet ships a generated -rtl.css sibling. Marking the // handle 'replace' makes WordPress serve that file instead under Arabic, // Hebrew and Persian. novasira.css has no sibling and is written // direction-agnostically, so it is not marked. if (is_readable(NOVASIRA_DIR . 'assets/css/admin-rtl.css')) { wp_style_add_data('novasira-admin', 'rtl', 'replace'); } wp_enqueue_style( 'novasira', NOVASIRA_URL . 'assets/css/novasira.css', ['novasira-admin'], NOVASIRA_VERSION ); // Click-to-copy, the shell chrome and the Status probe. Nothing on any // screen depends on it: every copyable string is also selectable text. wp_enqueue_script( 'novasira-admin', NOVASIRA_URL . 'assets/js/admin.js', [], NOVASIRA_VERSION, ['in_footer' => true] ); wp_localize_script('novasira-admin', 'novasiraAdmin', [ 'copied' => __('Copied', 'novasira'), 'probe' => [ 'running' => __('Asking this site for one address…', 'novasira'), 'expired' => __('That probe token has expired. Reload this page and try again.', 'novasira'), 'refused' => __('This site answered the probe, but refused it. The REST API is reachable; something is filtering this route.', 'novasira'), ], 'connect' => [ 'step4Password' => __('Create your key', 'novasira'), 'step4Oauth' => __('Connect it', 'novasira'), 'oauthLabel' => __('Sign in', 'novasira'), 'passwordLabel' => __('Application password', 'novasira'), 'oauthNote' => __('Approve it once, choosing what it may do. Nothing to copy, no password stored anywhere.', 'novasira'), 'keyReady' => __('Key ready', 'novasira'), ], ]); // The Connect flow's own stylesheet, on that screen only. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- deciding which stylesheet to load; no state changes. if (isset($_GET['page']) && sanitize_key(wp_unslash((string) $_GET['page'])) === 'novasira-connect') { wp_enqueue_style('novasira-connect', NOVASIRA_URL . 'assets/css/connect.css', ['novasira'], NOVASIRA_VERSION); // connect-rtl.css is the rtlcss build of connect.css, served instead under RTL locales. wp_style_add_data('novasira-connect', 'rtl', 'replace'); } }); /** * The severity vocabulary, with the sort rank and the pill tone for each. * * Declared here, once, because the dashboard sorts by it and three screens * colour by it. A finding whose severity is not in this list is shown as * 'notice' rather than dropped: an unknown severity is the scan engine telling * us something, and swallowing it would hide a finding the agent should see. * * @return array */ function novasira_severities(): array { return [ 'critical' => ['label' => __('Critical', 'novasira'), 'rank' => 5, 'tone' => 'critical'], 'high' => ['label' => __('High', 'novasira'), 'rank' => 4, 'tone' => 'high'], 'medium' => ['label' => __('Medium', 'novasira'), 'rank' => 3, 'tone' => 'medium'], 'low' => ['label' => __('Low', 'novasira'), 'rank' => 2, 'tone' => 'low'], 'info' => ['label' => __('Info', 'novasira'), 'rank' => 1, 'tone' => 'info'], 'notice' => ['label' => __('Notice', 'novasira'), 'rank' => 0, 'tone' => 'info'], ]; } /** * One severity, resolved. @return array{label: string, rank: int, tone: string, key: string} */ function novasira_severity(string $severity): array { $all = novasira_severities(); $key = strtolower(trim($severity)); if (!isset($all[$key])) { $key = 'notice'; } return ['key' => $key] + $all[$key]; } /** A severity pill. */ function novasira_echo_severity(string $severity): void { $resolved = novasira_severity($severity); echo '' . esc_html((string) $resolved['label']) . ''; } /** * Which of the three tones a 0-100 score wears. * * The thresholds are the ones the pills use, so a score of 71 and a finding * marked medium are the same colour on the same screen. */ function novasira_score_tone(int $score): string { if ($score >= 85) { return 'ok'; } return $score >= 60 ? 'warn' : 'danger'; } /** * A score, as a number over a bar. * * Null is a real state and renders as an em dash rather than as a zero: a site * nobody has scanned yet is not a site that scored nothing, and a 0 on this * screen would be a lie the rest of the page then has to argue with. */ function novasira_echo_meter(?int $score, string $label, string $sub = ''): void { $tone = $score === null ? 'none' : novasira_score_tone($score); $width = $score === null ? 0 : max(0, min(100, $score)); echo '
'; echo '
' . esc_html($label) . '
'; echo '
' . ($score === null ? '—' : esc_html((string) $score)) . '
'; echo ''; if ($sub !== '') { echo '
' . esc_html($sub) . '
'; } echo '
'; } /** A plain stat card: a label, a value, a line under it. */ function novasira_echo_stat(string $label, string $value, string $sub = ''): void { echo '
'; echo '
' . esc_html($label) . '
'; echo '
' . esc_html($value) . '
'; if ($sub !== '') { echo '
' . esc_html($sub) . '
'; } echo '
'; } /** * A short notice. The shell stylesheet already styles .notice-{info,success, * warning,error} inside .nibwp-wrap, icon included, so this is a wrapper and * not a component — one paragraph, because that rule lays its children out in * a row and a second element would sit beside the first. */ function novasira_echo_notice(string $tone, string $message): void { $allowed = ['info', 'success', 'warning', 'error']; $tone = in_array($tone, $allowed, true) ? $tone : 'info'; echo '

' . esc_html($message) . '

'; } /** * Is the scan engine on disk and loaded? * * Every screen asks before it reads a score. includes/scan.php is written by * somebody else and arrives after these screens do; until it does, the honest * answer is "no findings yet, here is the prompt", not a fatal. */ function novasira_scan_available(): bool { return function_exists('novasira_scan_all') || function_exists('novasira_scan'); } /** * The POST that asks for a fresh scan, as a button. * * A POST and not a link. A scan walks every post, every attachment and the * whole link graph; on a GET, a browser's link prefetcher or a corporate proxy * warming the admin would run it unasked, repeatedly, and nobody would ever see * why the site got slow. */ function novasira_echo_scan_form(string $slug, string $area = '', string $label = ''): void { if (!current_user_can(novasira_read_cap())) { return; } $label = $label !== '' ? $label : __('Run a fresh scan', 'novasira'); // The area goes in the action URL's query string, not in a hidden field. // wp-admin routes on ?page=, and the screens choose what to show from // $_GET — so an area carried only in the POST body would submit the right // request to the wrong screen, and the user would watch their area scan // land on the overview. $action = $area === '' ? novasira_page_url($slug) : novasira_page_url($slug, ['area' => $area]); echo '
'; wp_nonce_field('novasira_scan', 'novasira_nonce'); echo ''; echo ''; echo '
'; } /** * Did this request ask for a fresh scan, with a nonce and the capability? * * The three answers are kept apart on purpose. A missing nonce on a POST is * worth saying out loud, because the usual cause is a page that sat open past * its nonce lifetime and the user deserves to be told to press it again rather * than watch nothing happen. * * @return array{asked: bool, ok: bool, reason: string} */ function novasira_scan_request(): array { if (!isset($_POST['novasira_action']) || 'scan' !== sanitize_key((string) wp_unslash($_POST['novasira_action']))) { return ['asked' => false, 'ok' => false, 'reason' => '']; } $nonce = isset($_POST['novasira_nonce']) ? sanitize_text_field((string) wp_unslash($_POST['novasira_nonce'])) : ''; if (!wp_verify_nonce($nonce, 'novasira_scan')) { return [ 'asked' => true, 'ok' => false, 'reason' => __('That request expired before it arrived. Press the button again.', 'novasira'), ]; } if (!current_user_can(novasira_read_cap())) { return [ 'asked' => true, 'ok' => false, 'reason' => __('Your account cannot run a scan on this site.', 'novasira'), ]; } return ['asked' => true, 'ok' => true, 'reason' => '']; } /** * Normalise one finding out of whatever the scan engine handed back. * * Defensive to the point of tedium because the engine is a sibling file under * active development: a finding that is a string, or an array missing 'title', * or an 'items' that is a single string, all have to render rather than take * the screen down. Returns null only when there is genuinely nothing to show. * * @param mixed $finding * @return array{id: string, severity: string, title: string, detail: string, count: int, items: list, fix: string}|null */ function novasira_normalise_finding($finding): ?array { if (is_scalar($finding)) { // A finding that arrived as a bare scalar is still a finding. Promote // it rather than drop it: a check that reports badly is a bug in the // engine, and hiding its output is how that bug stays unnoticed. $finding = ['title' => (string) $finding]; } if (!is_array($finding)) { return null; } $title = isset($finding['title']) && is_scalar($finding['title']) ? trim((string) $finding['title']) : ''; $id = isset($finding['id']) && is_scalar($finding['id']) ? (string) $finding['id'] : ''; if ($title === '') { // An untitled finding still counts; falling back to the id keeps it on // the screen instead of quietly deleting somebody's check. $title = $id !== '' ? $id : __('Unnamed finding', 'novasira'); } // The engine's items are {id, label, edit_url}. The url is kept because an // item you can click through to is the difference between a report and a // worklist — and dropping it would make the screen strictly less useful // than the tool an agent calls. $items = []; if (isset($finding['items'])) { $raw = is_array($finding['items']) ? $finding['items'] : [$finding['items']]; foreach ($raw as $item) { if (is_scalar($item)) { $items[] = ['label' => (string) $item, 'url' => '']; continue; } if (!is_array($item)) { continue; } $label = ''; foreach (['label', 'title', 'name', 'url', 'id'] as $key) { if (isset($item[$key]) && is_scalar($item[$key]) && trim((string) $item[$key]) !== '') { $label = (string) $item[$key]; break; } } if ($label === '') { continue; } $url = ''; foreach (['edit_url', 'url', 'link'] as $key) { if (isset($item[$key]) && is_string($item[$key]) && $item[$key] !== '') { $url = $item[$key]; break; } } $items[] = ['label' => $label, 'url' => $url]; } } $count = isset($finding['count']) && is_numeric($finding['count']) ? (int) $finding['count'] : count($items); return [ 'id' => $id, 'severity' => isset($finding['severity']) && is_scalar($finding['severity']) ? (string) $finding['severity'] : 'notice', 'title' => $title, 'detail' => isset($finding['detail']) && is_scalar($finding['detail']) ? (string) $finding['detail'] : '', 'count' => max(0, $count), 'items' => $items, 'fix' => isset($finding['fix']) && is_scalar($finding['fix']) ? (string) $finding['fix'] : '', ]; } /** * Normalise one per-area scan result. * * @param mixed $result * @return array{area: string, score: ?int, checked: int, findings: list>, notes: list, ran_at: int} */ function novasira_normalise_result($result, string $fallback_slug = ''): array { $out = [ 'area' => $fallback_slug, 'score' => null, 'checked' => 0, 'findings' => [], 'notes' => [], 'ran_at' => 0, ]; if (!is_array($result)) { return $out; } if (isset($result['ran_at']) && is_numeric($result['ran_at'])) { $out['ran_at'] = max(0, (int) $result['ran_at']); } if (isset($result['area']) && is_scalar($result['area'])) { $out['area'] = sanitize_key((string) $result['area']); } if ($out['area'] === '') { $out['area'] = $fallback_slug; } if (isset($result['score']) && is_numeric($result['score'])) { $out['score'] = max(0, min(100, (int) round((float) $result['score']))); } if (isset($result['checked']) && is_numeric($result['checked'])) { $out['checked'] = max(0, (int) $result['checked']); } if (isset($result['findings']) && is_array($result['findings'])) { foreach ($result['findings'] as $finding) { $normalised = novasira_normalise_finding($finding); if ($normalised !== null) { $out['findings'][] = $normalised; } } } if (isset($result['notes'])) { $notes = is_array($result['notes']) ? $result['notes'] : [$result['notes']]; foreach ($notes as $note) { if (is_scalar($note) && trim((string) $note) !== '') { $out['notes'][] = (string) $note; } } } return $out; } /** * Run the whole-site scan, guarded. * * `refresh` is passed only when a nonce-checked POST asked for it. On an * ordinary page view the engine is being asked for whatever it already has, so * loading the dashboard is cheap and pressing the button is the expensive act. * * @return array> keyed by area slug */ function novasira_scan_all_guarded(bool $refresh = false): array { if (!function_exists('novasira_scan_all')) { return []; } $raw = novasira_scan_all(['refresh' => $refresh]); if (!is_array($raw)) { return []; } $out = []; foreach ($raw as $key => $result) { $slug = is_string($key) ? sanitize_key($key) : ''; $normalised = novasira_normalise_result($result, $slug); if ((string) $normalised['area'] === '') { continue; } $out[(string) $normalised['area']] = $normalised; } return $out; } /** * Run one area's scan, guarded. Falls back to pulling that area out of a * whole-site run when the engine only offers the plural form. * * @return array|null */ function novasira_scan_area_guarded(string $slug, bool $refresh = false): ?array { if (function_exists('novasira_scan')) { $raw = novasira_scan($slug, ['refresh' => $refresh]); return is_array($raw) ? novasira_normalise_result($raw, $slug) : null; } $all = novasira_scan_all_guarded($refresh); return $all[$slug] ?? null; } /** * The per-area results without the roll-ups. * * The full audit and the action plan are composed from the other areas' * findings, so any total, average or "worst first" list that includes them * counts each finding three times. They keep their own cards; they stay out of * the sums. * * @param array> $results * @return array> */ function novasira_direct_results(array $results): array { $rollups = function_exists('novasira_scan_aggregates') ? novasira_scan_aggregates() : ['audit', 'plan']; return array_diff_key($results, array_flip($rollups)); } /** * Every finding from a set of per-area results, worst first. * * Severity first, then how many things the finding is about: twelve pages * missing a description outranks one, and a critical outranks both. The area * slug rides along so the dashboard can link each row to where it is fixed. * * @param array> $results * @return list> */ function novasira_worst_findings(array $results, int $limit = 8): array { $rows = []; foreach ($results as $slug => $result) { $findings = isset($result['findings']) && is_array($result['findings']) ? $result['findings'] : []; foreach ($findings as $finding) { if (!is_array($finding)) { continue; } $finding['area'] = is_string($slug) ? $slug : (string) ($result['area'] ?? ''); $rows[] = $finding; } } usort($rows, static function (array $a, array $b): int { $rank = novasira_severity((string) ($b['severity'] ?? ''))['rank'] <=> novasira_severity((string) ($a['severity'] ?? ''))['rank']; if ($rank !== 0) { return $rank; } return ((int) ($b['count'] ?? 0)) <=> ((int) ($a['count'] ?? 0)); }); return $limit > 0 ? array_slice($rows, 0, $limit) : $rows; } /** * One finding, as a row. * * @param array $finding */ function novasira_echo_finding(array $finding, bool $with_area_link = false): void { $severity = novasira_severity((string) ($finding['severity'] ?? '')); $slug = isset($finding['area']) ? sanitize_key((string) $finding['area']) : ''; $area = $slug !== '' ? novasira_area($slug) : null; $count = (int) ($finding['count'] ?? 0); echo '
  • '; echo '
    '; novasira_echo_severity((string) ($finding['severity'] ?? '')); echo '' . esc_html((string) ($finding['title'] ?? '')) . ''; if ($count > 0) { echo '' . esc_html( sprintf( /* translators: %s: a number of affected items */ _n('%s item', '%s items', $count, 'novasira'), number_format_i18n($count) ) ) . ''; } if ($with_area_link && $area !== null) { echo '' . esc_html((string) $area['name']) . ''; } echo '
    '; if ((string) ($finding['detail'] ?? '') !== '') { echo '

    ' . esc_html((string) $finding['detail']) . '

    '; } $items = isset($finding['items']) && is_array($finding['items']) ? $finding['items'] : []; if ($items !== []) { // Five, then a count. The full list is the agent's job; this screen is // here to tell you the shape of the problem, not to be the report. $shown = array_slice($items, 0, 5); echo '
      '; foreach ($shown as $item) { $label = is_array($item) ? (string) ($item['label'] ?? '') : (string) $item; $url = is_array($item) ? (string) ($item['url'] ?? '') : ''; echo '
    • '; echo $url !== '' ? '' . esc_html($label) . '' : esc_html($label); echo '
    • '; } if (count($items) > count($shown)) { echo '
    • ' . esc_html( sprintf( /* translators: %s: a number of further items */ _n('and %s more', 'and %s more', count($items) - count($shown), 'novasira'), number_format_i18n(count($items) - count($shown)) ) ) . '
    • '; } echo '
    '; } if ((string) ($finding['fix'] ?? '') !== '') { echo '

    ' . esc_html__('Fix', 'novasira') . ' ' . esc_html((string) $finding['fix']) . '

    '; } echo '
  • '; } /** * The badge an area's mode wears, and the sentence that goes with it. * * Advisory is not a lesser tier, it is a different source of truth, so it gets * said in words on the card and again at the top of the area. A tooltip would * be a way of not saying it. * * @return array{label: string, class: string, line: string} */ function novasira_mode_meta(string $mode): array { if ($mode === 'advisory') { return [ 'label' => __('Advisory', 'novasira'), 'class' => 'is-ready', 'line' => __('The numbers for this area are not in this site. Novasira carries the method; your agent brings the data.', 'novasira'), ]; } return [ 'label' => __('Live', 'novasira'), 'class' => 'is-active', 'line' => __('This area reads this site\'s own database and reports what it finds.', 'novasira'), ]; }