Skip to content
wiki.fftac.org

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));
    }
}