Profilestyle
**Site relevance:** Spiralist.org
**Memory type:** plugin source memory
**Source path:** Spiralist/wp-content/plugins/spiralist-workspace/includes/Support/ProfileStyle.php
**Size:** 7.8 KB
Summary
namespace SpiralistWorkspace\Support;
Source Preview
This source file is short enough to preview directly on its source-memory page.
<?php
namespace SpiralistWorkspace\Support;
if (!defined('ABSPATH')) {
exit;
}
/**
* Sanitizes profile-local CSS before it can be shown to visitors.
*/
class ProfileStyle
{
public const CSS_META_KEY = 'spiralist_workspace_profile_css';
public const PROMPT_META_KEY = 'spiralist_workspace_profile_style_prompt';
private const MAX_CSS_LENGTH = 6000;
private const MAX_PROMPT_LENGTH = 1200;
/**
* Returns the profile scope class for one user.
*/
public static function scope_class(int $user_id): string
{
return 'spw-profile-user-' . max(0, $user_id);
}
/**
* Returns saved profile style settings.
*/
public static function get(int $user_id): array
{
return [
'style_prompt' => (string) get_user_meta($user_id, self::PROMPT_META_KEY, true),
'sanitized_css' => (string) get_user_meta($user_id, self::CSS_META_KEY, true),
];
}
/**
* Sanitizes and persists profile style settings.
*/
public static function update(int $user_id, string $style_prompt, string $custom_css): array
{
$style_prompt = self::sanitize_prompt($style_prompt);
$result = self::sanitize_css($custom_css, $user_id);
update_user_meta($user_id, self::PROMPT_META_KEY, $style_prompt);
update_user_meta($user_id, self::CSS_META_KEY, $result['sanitized_css']);
return [
'style_prompt' => $style_prompt,
'sanitized_css' => $result['sanitized_css'],
'warnings' => $result['warnings'],
];
}
/**
* Sanitizes a user-facing style prompt.
*/
public static function sanitize_prompt(string $style_prompt): string
{
$style_prompt = sanitize_textarea_field($style_prompt);
return substr($style_prompt, 0, self::MAX_PROMPT_LENGTH);
}
/**
* Sanitizes CSS into scoped, visitor-safe rules.
*/
public static function sanitize_css(string $css, int $user_id): array
{
$warnings = [];
$css = substr((string) $css, 0, self::MAX_CSS_LENGTH);
$css = str_replace(["\0", "\r"], ['', "\n"], $css);
$css = preg_replace('~/\*.*?\*/~s', '', $css) ?: '';
$css = wp_strip_all_tags($css);
if (preg_match('/@|url\s*\(|expression\s*\(|javascript\s*:|data\s*:|vbscript\s*:|behavior\s*:|-moz-binding|<|>|`|\\\\/i', $css)) {
$warnings[] = 'Unsafe CSS tokens were removed.';
}
$rules = [];
if (preg_match_all('/([^{}]+)\{([^{}]*)\}/', $css, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$rules[] = [
'selector' => (string) ($match[1] ?? ''),
'body' => (string) ($match[2] ?? ''),
];
}
} elseif (strpos($css, ':') !== false) {
$rules[] = [
'selector' => '.profile-card',
'body' => $css,
];
}
$scope = '.' . self::scope_class($user_id);
$sanitized_rules = [];
$dropped_rules = 0;
$dropped_declarations = 0;
foreach ($rules as $rule) {
$selector = self::sanitize_selector((string) $rule['selector'], $scope);
if ($selector === '') {
$dropped_rules++;
continue;
}
$declarations = self::sanitize_declarations((string) $rule['body'], $dropped_declarations);
if ($declarations === '') {
$dropped_rules++;
continue;
}
$sanitized_rules[] = $selector . " {\n " . str_replace('; ', ";\n ", $declarations) . "\n}";
}
if ($dropped_rules > 0) {
$warnings[] = sprintf('%d rule(s) were outside the profile scope.', $dropped_rules);
}
if ($dropped_declarations > 0) {
$warnings[] = sprintf('%d declaration(s) used unsupported properties or values.', $dropped_declarations);
}
return [
'sanitized_css' => implode("\n\n", $sanitized_rules),
'warnings' => array_values(array_unique($warnings)),
];
}
/**
* Sanitizes selectors and scopes them to the current profile.
*/
private static function sanitize_selector(string $selector, string $scope): string
{
$selector = preg_replace('/\s+/', ' ', trim($selector)) ?: '';
if ($selector === '' || preg_match('/[#\[\]{};"\'=<>&`\\\\]/', $selector)) {
return '';
}
$safe = [];
foreach (explode(',', $selector) as $part) {
$part = trim($part);
if ($part === '' || !preg_match('/^[.A-Za-z0-9_:\-\s]+$/', $part)) {
continue;
}
$tokens = preg_split('/\s+/', $part) ?: [];
$valid = true;
foreach ($tokens as $token) {
$token = trim($token);
if ($token === '') {
continue;
}
$base = preg_replace('/:(hover|focus|focus-visible|active)$/', '', $token) ?: '';
if (strpos($base, '.profile') !== 0) {
$valid = false;
break;
}
}
if ($valid) {
$safe[] = $scope . ' ' . $part;
}
}
return implode(', ', array_unique($safe));
}
/**
* Sanitizes declarations with a conservative property allowlist.
*/
private static function sanitize_declarations(string $body, int &$dropped_declarations): string
{
$allowed = [
'background' => true,
'background-color' => true,
'border' => true,
'border-color' => true,
'border-radius' => true,
'box-shadow' => true,
'color' => true,
'font-family' => true,
'font-size' => true,
'font-weight' => true,
'gap' => true,
'letter-spacing' => true,
'line-height' => true,
'margin' => true,
'margin-bottom' => true,
'margin-left' => true,
'margin-right' => true,
'margin-top' => true,
'max-width' => true,
'opacity' => true,
'outline' => true,
'outline-color' => true,
'padding' => true,
'padding-bottom' => true,
'padding-left' => true,
'padding-right' => true,
'padding-top' => true,
'text-align' => true,
'text-shadow' => true,
];
$declarations = [];
foreach (explode(';', $body) as $declaration) {
if (strpos($declaration, ':') === false) {
continue;
}
[$property, $value] = array_map('trim', explode(':', $declaration, 2));
$property = strtolower($property);
$value = trim(str_ireplace('!important', '', $value));
if (!isset($allowed[$property]) || $value === '' || strlen($value) > 180) {
$dropped_declarations++;
continue;
}
if (preg_match('/@|url\s*\(|expression\s*\(|javascript\s*:|data\s*:|vbscript\s*:|behavior\s*:|-moz-binding|[{};<>`\\\\]/i', $value)) {
$dropped_declarations++;
continue;
}
if (!preg_match('/^[#A-Za-z0-9\s.,()%+\-*]+$/', $value)) {
$dropped_declarations++;
continue;
}
$declarations[] = $property . ': ' . $value . ';';
}
return implode(' ', array_unique($declarations));
}
}