⌘K

Data Storage & Security

Data storage

Never write into the CMS core's /data/. Mirror the core's own split-file pattern under your own folder:

plugins/my-plugin/data/       ← your JSON data files
plugins/my-plugin/private/    ← secrets — .htaccess-protected

Create both on demand — not shipped in your distributed ZIP — with .htaccess protection written alongside them. Always rewrite .htaccess unconditionally; don't skip the write with a file_exists() check, since a directory that was deleted and manually recreated would otherwise be left unprotected:

function my_plugin_ensure_dirs(): void
{
    $dir = __DIR__ . '/data';
    if (!is_dir($dir)) mkdir($dir, 0755, true);

    // Always rewrite — never skip with file_exists()
    file_put_contents($dir . '/.htaccess',
        "<IfModule mod_authz_core.c>\n    Require all denied\n</IfModule>\n" .
        "<IfModule !mod_authz_core.c>\n    Deny from all\n</IfModule>\n"
    );
}

Use atomic writes for anything written after install-time setup, to avoid corrupting a file if the PHP process is interrupted mid-write:

function my_plugin_write_json(string $path, array $data): bool
{
    $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if ($json === false) return false;

    $tmp = $path . '.tmp';
    if (file_put_contents($tmp, $json, LOCK_EX) === false) return false;

    return rename($tmp, $path);
}

Session and authentication

Don't build a separate login system for your plugin's admin screens — reuse the CMS's own admin session:

function my_plugin_admin_is_logged_in(): bool
{
    return isset($_SESSION['admin']) && $_SESSION['admin'] === true;
}

Anyone authenticated in the CMS admin is authenticated for your plugin's admin actions too, and logging out of one logs out of both — it's the same PHP session. If your plugin has a standalone POST endpoint (e.g. admin/actions.php) that doesn't go through the ?action=plugin_page router, start the session yourself with the same cookie hardening the core uses:

if (session_status() === PHP_SESSION_NONE) {
    session_set_cookie_params([
        'httponly' => true,
        'samesite' => 'Lax',
        'secure'   => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
    ]);
    session_start();
}

CSRF protection

Any state-changing action (form submission, admin action) needs its own CSRF token. For admin-only actions — already behind the login check — reusing $_SESSION['csrf_token'], the same token the core admin panel uses, is simpler and sufficient.

Public-facing forms (anonymous visitors, no session to rely on) need a stateless HMAC-based token instead:

function my_plugin_generate_csrf(): string
{
    $secret    = my_plugin_get_secret(); // random bytes, generated once, stored in private/
    $timestamp = time();
    $signature = hash_hmac('sha256', (string)$timestamp, $secret);
    return base64_encode($timestamp . '|' . $signature);
}

function my_plugin_verify_csrf(string $token, int $ttlSeconds = 7200): bool
{
    $decoded = base64_decode($token, true);
    if ($decoded === false) return false;

    [$timestamp, $signature] = array_pad(explode('|', $decoded, 2), 2, '');
    if ($timestamp === '' || $signature === '') return false;
    if ((time() - (int)$timestamp) > $ttlSeconds) return false;

    return hash_equals(hash_hmac('sha256', $timestamp, my_plugin_get_secret()), $signature);
}

Internationalization

Follow the CMS's active_language (front-end) and admin_language (admin panel) settings automatically. Read config.json directly rather than depending on the core's __t() being loaded in every context your plugin runs in — public endpoints don't load the full front-end bootstrap:

function my_plugin_current_locale(string $context = 'front'): string
{
    $configFile = dirname(__DIR__, 2) . '/config.json'; // adjust to your depth
    if (file_exists($configFile)) {
        $decoded = json_decode(file_get_contents($configFile), true);
        if (is_array($decoded)) {
            if ($context === 'admin' && !empty($decoded['admin_language'])) return $decoded['admin_language'];
            if (!empty($decoded['active_language'])) return $decoded['active_language'];
        }
    }
    return 'en';
}

function my_plugin_t(string $key, string $fallback = '', string $context = 'front'): string
{
    static $cache = [];
    $locale = my_plugin_current_locale($context);

    if (!isset($cache[$context][$locale])) {
        $path = __DIR__ . '/lang/' . ($context === 'admin' ? 'admin/' : '') . $locale . '.json';
        $cache[$context][$locale] = file_exists($path)
            ? (json_decode(file_get_contents($path), true) ?: [])
            : [];
    }

    return $cache[$context][$locale][$key] ?? $fallback;
}

Ship translations for at least English, French, and Spanish, matching what the core itself ships with — never hardcode a user-visible string.