'everyone', 'assistant_disabled' => '', 'brand_color' => '', 'brand_logo' => '', 'brand_name' => '', 'bulk_enabled' => false, 'evaluate_timeout_ms' => 25, 'launcher_color' => '', 'launcher_icon' => 'bubble', 'launcher_label' => 'Report a bug', 'launcher_position' => 'right', 'launcher_style' => 'icon', 'pull_refresh_sec' => 30, 'report_email' => '', 'review_in_admin' => false, 'routeset_ttl_sec' => 5, 'server_scope' => 'all', 'shim_token' => '', 'sidecar_dsn' => '', 'sidecar_key' => '', 'site_key' => '', 'widget_audience' => 'capability', 'widget_capability' => 'edit_posts', 'widget_enabled' => true, 'widget_in_admin' => false, 'widget_mode' => 'light', ]; /** Settings that are booleans (checkboxes on the settings screen). */ public const FLAGS = ['bulk_enabled', 'review_in_admin', 'widget_enabled', 'widget_in_admin']; /** * Every setting can be pinned by a wp-config.php constant — no exceptions, * so "put it in wp-config" is the whole answer for a deploy-configured * install, and a fully pinned site never writes settings to the database. */ public const CONSTANTS = [ 'assistant_apply' => 'SARCIO_ASSISTANT_APPLY', 'assistant_disabled' => 'SARCIO_ASSISTANT_DISABLED', 'brand_color' => 'SARCIO_BRAND_COLOR', 'brand_logo' => 'SARCIO_BRAND_LOGO', 'brand_name' => 'SARCIO_BRAND_NAME', 'bulk_enabled' => 'SARCIO_BULK_ENABLED', 'evaluate_timeout_ms' => 'SARCIO_EVALUATE_TIMEOUT_MS', 'launcher_color' => 'SARCIO_LAUNCHER_COLOR', 'launcher_icon' => 'SARCIO_LAUNCHER_ICON', 'launcher_label' => 'SARCIO_LAUNCHER_LABEL', 'launcher_position' => 'SARCIO_LAUNCHER_POSITION', 'launcher_style' => 'SARCIO_LAUNCHER_STYLE', 'pull_refresh_sec' => 'SARCIO_PULL_REFRESH_SEC', 'report_email' => 'SARCIO_REPORT_EMAIL', 'review_in_admin' => 'SARCIO_REVIEW_IN_ADMIN', 'routeset_ttl_sec' => 'SARCIO_ROUTESET_TTL_SEC', 'server_scope' => 'SARCIO_SERVER_SCOPE', 'shim_token' => 'SARCIO_SHIM_TOKEN', 'sidecar_dsn' => 'SARCIO_SIDECAR_DSN', 'sidecar_key' => 'SARCIO_SIDECAR_KEY', 'site_key' => 'SARCIO_SITE_KEY', 'widget_audience' => 'SARCIO_WIDGET_AUDIENCE', 'widget_capability' => 'SARCIO_WIDGET_CAPABILITY', 'widget_enabled' => 'SARCIO_WIDGET_ENABLED', 'widget_in_admin' => 'SARCIO_WIDGET_IN_ADMIN', 'widget_mode' => 'SARCIO_WIDGET_MODE', ]; /** How much of the server tier is wired up. */ public const SERVER_SCOPES = ['all', 'rest', 'off']; /** Who gets the in-page reporter. */ public const WIDGET_AUDIENCES = ['capability', 'everyone', 'logged_in']; /** * How the server tier reaches Sarcio: through the sidecar on this host * when a DSN is set; otherwise directly, pulling the site's signed fixes * from the control plane with the sidecar key; `none` when neither is * configured. The sidecar wins when both are, since it is the faster and * more isolated path. */ public static function serverTransport(): string { if ((string) self::get('site_key') === '') { return 'none'; } if ((string) self::get('sidecar_dsn') !== '') { return 'sidecar'; } if ((string) self::get('sidecar_key') !== '') { return 'pull'; } return 'none'; } /** @param array $changes */ public static function update(array $changes): void { update_option(self::OPTION, self::sanitize(array_merge(self::all(), $changes))); } /** @return array */ public static function all(): array { $out = []; foreach (array_keys(self::DEFAULTS) as $key) { $out[$key] = self::get($key); } return $out; } public static function get(string $key): mixed { $constant = self::CONSTANTS[$key] ?? null; if ($constant !== null && defined($constant)) { return self::coerce($key, constant($constant)); } $stored = get_option(self::OPTION, []); $value = is_array($stored) && array_key_exists($key, $stored) ? $stored[$key] : null; if ($value === null) { return self::DEFAULTS[$key] ?? null; } return self::coerce($key, $value); } /** True when a wp-config.php constant pins this key (so the field is read-only). */ public static function isLocked(string $key): bool { $constant = self::CONSTANTS[$key] ?? null; return $constant !== null && defined($constant); } /** * Sanitize a settings-screen submission. Unknown keys are dropped, every * value is validated to its type, and anything invalid falls back to the * currently stored value rather than to a default — a typo in one field must * never silently unpatch a working install. * * @param mixed $input * @return array */ public static function sanitize(mixed $input): array { $input = is_array($input) ? $input : []; $stored = get_option(self::OPTION, []); $stored = is_array($stored) ? $stored : []; $out = []; // The assistant's switches arrive as the list that is ON; what is // stored is the list that is OFF, so an ability added by a later // release starts on. Only a form that rendered the switches may // change them. if (!empty($input['assistant_switches']) && class_exists(\Sarcio\WordPress\Assistant\Catalog::class)) { $on = is_array($input['assistant_enabled'] ?? null) ? array_map('strval', $input['assistant_enabled']) : []; $off = array_diff(array_keys(\Sarcio\WordPress\Assistant\Catalog::all()), $on); $input['assistant_disabled'] = implode(',', $off); } foreach (array_keys(self::DEFAULTS) as $key) { $current = $stored[$key] ?? self::DEFAULTS[$key]; // A constant-pinned field renders disabled, so the browser never // submits it. Keep what is stored rather than reading the absence as // "clear it" — otherwise removing the constant later would reveal an // emptied setting. if (self::isLocked($key)) { $out[$key] = $current; continue; } if (in_array($key, self::FLAGS, true)) { $out[$key] = !empty($input[$key]); continue; } if ($key === 'assistant_disabled' && !array_key_exists($key, $input)) { $out[$key] = $current; continue; } $raw = isset($input[$key]) && is_scalar($input[$key]) ? trim((string) $input[$key]) : ''; $out[$key] = self::sanitizeValue($key, $raw, $current); } return $out; } public static function widgetSrc(): string { return self::WIDGET_SRC; } public static function apiUrl(): string { if (defined('SARCIO_API_URL') && self::isSarcioApi((string) constant('SARCIO_API_URL'))) { return untrailingslashit((string) constant('SARCIO_API_URL')); } return self::API_URL; } public static function isSarcioApi(string $url): bool { $parts = wp_parse_url($url); if (!is_array($parts) || empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])) { return false; } $scheme = strtolower((string) ($parts['scheme'] ?? '')); $host = strtolower((string) $parts['host']); if ($scheme === 'https' && ($host === 'sarcio.io' || str_ends_with($host, '.sarcio.io'))) { return true; } $local = in_array($host, ['localhost', '127.0.0.1'], true) || str_ends_with($host, '.sarcio.local'); return $local && in_array($scheme, ['http', 'https'], true); } private static function coerce(string $key, mixed $value): mixed { if (in_array($key, self::FLAGS, true)) { return is_string($value) ? in_array(strtolower($value), ['1', 'on', 'true', 'yes'], true) : (bool) $value; } if (is_int(self::DEFAULTS[$key] ?? null)) { return (int) $value; } return is_scalar($value) ? (string) $value : (self::DEFAULTS[$key] ?? ''); } /** A stream DSN the shim can dial: a unix socket path or a TCP host:port. */ private static function isDsn(string $value): bool { if (str_starts_with($value, 'unix://')) { return strlen($value) > strlen('unix://'); } return (bool) preg_match('~^tcp://[A-Za-z0-9._\-\[\]:]+:\d{1,5}$~', $value); } private static function sanitizeValue(string $key, string $raw, mixed $current): mixed { switch ($key) { case 'evaluate_timeout_ms': return max(1, min(1000, (int) $raw)); case 'brand_color': case 'launcher_color': if ($raw === '') { return ''; } return preg_match('~^#[0-9a-fA-F]{6}$~', $raw) ? strtolower($raw) : $current; case 'brand_logo': $logo = esc_url_raw(trim($raw), ['https']); return str_starts_with($logo, 'https://') ? mb_substr($logo, 0, 500) : ''; case 'brand_name': return mb_substr(trim(sanitize_text_field($raw)), 0, 80); case 'launcher_icon': return array_key_exists($raw, Launcher::ICONS) ? $raw : $current; case 'launcher_label': $label = trim(sanitize_text_field($raw)); return $label === '' ? 'Report a bug' : mb_substr($label, 0, 40); case 'launcher_position': return in_array($raw, ['left', 'right'], true) ? $raw : $current; case 'launcher_style': return in_array($raw, ['icon', 'label'], true) ? $raw : $current; case 'widget_mode': return in_array($raw, ['dark', 'light'], true) ? $raw : $current; case 'pull_refresh_sec': return max(15, min(3600, (int) $raw)); case 'routeset_ttl_sec': return max(1, min(300, (int) $raw)); case 'server_scope': return in_array($raw, self::SERVER_SCOPES, true) ? $raw : $current; case 'shim_token': // Blank means "leave the stored token alone" — the screen never // renders the value back, so a save must not wipe it. return $raw === '' ? (string) $current : $raw; case 'sidecar_key': // Write-only like the shim token; "clear" spelled out removes it. if ($raw === '') { return (string) $current; } if ($raw === 'clear') { return ''; } return preg_match('~^sk_[A-Za-z0-9_-]{1,128}$~', $raw) ? $raw : (string) $current; case 'sidecar_dsn': if ($raw === '') { return ''; } return self::isDsn($raw) ? $raw : $current; case 'site_key': if ($raw === '') { return ''; } return preg_match('~^pk_[A-Za-z0-9_-]{1,128}$~', $raw) ? $raw : $current; case 'widget_audience': return in_array($raw, self::WIDGET_AUDIENCES, true) ? $raw : $current; case 'widget_capability': return $raw === '' ? 'edit_posts' : sanitize_key($raw); case 'report_email': $emails = array_filter(array_map('trim', explode(',', $raw)), static fn (string $email): bool => $email !== '' && is_email($email) !== false); return implode(', ', array_values(array_unique($emails))); case 'assistant_apply': return in_array($raw, ['everyone', 'admins'], true) ? $raw : $current; case 'assistant_disabled': $names = array_filter(array_map('trim', explode(',', $raw)), static fn (string $name): bool => (bool) preg_match('~^[a-z0-9-]+/[a-z0-9-]+$~', $name)); return implode(',', array_values(array_unique($names))); default: return sanitize_text_field($raw); } } }