Comprehensive documentation update for cPanel plugin, install scripts, and security
- Expand cPanel User Restore Plugin section with restore categories table, CGI naming convention, AdminBin validation patterns, and registration details - Add detailed GnizaCPanel::UI function reference including safe I/O functions - Expand AdminBin Module section with validation regex table and remote filtering - Reorganize Security section into CLI, WHM, and cPanel subsections - Add Install/Uninstall Scripts section documenting all steps and tar.gz quirk - Add Upgrade Considerations section (CSRF file→dir migration, token write robustness, SMTP test token sync) - Add "Adding a new cPanel plugin page" guide Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
177
CLAUDE.md
177
CLAUDE.md
@@ -37,8 +37,8 @@ etc/
|
||||
├── remote.conf.example # Remote destination config template
|
||||
└── schedule.conf.example # Schedule config template
|
||||
scripts/
|
||||
├── install.sh # Install to /usr/local/gniza, create dirs/symlinks
|
||||
└── uninstall.sh # Remove install dir, symlink, cron entries, WHM plugin
|
||||
├── install.sh # Install to /usr/local/gniza, create dirs/symlinks, WHM + cPanel plugins
|
||||
└── uninstall.sh # Remove install dir, symlink, cron entries, WHM + cPanel plugins
|
||||
tests/
|
||||
└── test_utils.sh # Unit tests for utils.sh, accounts.sh, config.sh
|
||||
whm/
|
||||
@@ -190,19 +190,46 @@ Allows cPanel account owners to restore their own data (files, databases, email,
|
||||
|
||||
**Privilege escalation:** Uses cPanel's AdminBin framework. CGIs run as the logged-in cPanel user; the AdminBin module (`cpanel/admin/Gniza/Restore`) runs as root. The account parameter is always forced to `$ENV{'REMOTE_USER'}` (cPanel-authenticated), never from user input.
|
||||
|
||||
**CGI file naming:** cPanel Jupiter theme uses `.live.cgi` extension for CGI files (e.g., `index.live.cgi`, `restore.live.cgi`).
|
||||
|
||||
**Security model:**
|
||||
- Account isolation: AdminBin forces the authenticated username — users can only restore their own data
|
||||
- No `--terminate`: AdminBin never passes the terminate flag, preventing destructive full restores
|
||||
- Remote filtering: `USER_RESTORE_REMOTES` config controls which remotes users can access (`"all"`, comma-separated names, or empty to disable)
|
||||
- Strict regex validation on all arguments (mirrors `GnizaWHM::Runner` patterns)
|
||||
- Per-user CSRF tokens at `/tmp/.gniza-cpanel-csrf-$user`
|
||||
- Path traversal prevention: path regex uses negative lookahead to reject `..` — `qr/^(?!.*\.\.)[a-zA-Z0-9_.\/@ -]+$/`
|
||||
- Remote name regex: `qr/^[a-zA-Z0-9_-]+$/` (rejects special characters)
|
||||
- Per-user CSRF tokens at `/tmp/.gniza-cpanel-csrf-$user` (symlink-safe I/O)
|
||||
- Symlink-safe file operations: `_safe_write` uses `unlink` + `O_CREAT|O_EXCL` with fallback; `_safe_read` rejects symlinks via `-l` check
|
||||
- Flash message type validated against allowlist (`success`, `error`, `info`, `warning`)
|
||||
|
||||
**Install locations:**
|
||||
- CGIs: `/usr/local/cpanel/base/frontend/jupiter/gniza/`
|
||||
- AdminBin: `/usr/local/cpanel/bin/admin/Gniza/`
|
||||
- Plugin registration: via `install_plugin` with `install.json`
|
||||
- AdminBin: `/usr/local/cpanel/bin/admin/Gniza/` (Restore is `0700`, Restore.conf is `0600`)
|
||||
- Plugin registration: via `install_plugin` with tar.gz archive containing `install.json`
|
||||
- Assets: CSS and logo copied to `gniza/assets/` alongside CGIs
|
||||
- `install.json` also copied to CGI directory for `uninstall_plugin` to reference
|
||||
|
||||
**Workflow:** Category grid (`index.live.cgi`) → 4-step restore (`restore.live.cgi`): select remote/snapshot → select items → confirm → execute
|
||||
**Restore categories (8 types):**
|
||||
|
||||
| Type | Label | AdminBin List Action | AdminBin Restore Action |
|
||||
|------|-------|---------------------|------------------------|
|
||||
| `account` | Full Backup | — | `RESTORE_ACCOUNT` |
|
||||
| `files` | Home Directory | `LIST_FILES` | `RESTORE_FILES` |
|
||||
| `database` | Databases | `LIST_DATABASES` | `RESTORE_DATABASE` |
|
||||
| `dbusers` | Database Users | `LIST_DBUSERS` | `RESTORE_DBUSERS` |
|
||||
| `cron` | Cron Jobs | `LIST_CRON` | `RESTORE_CRON` |
|
||||
| `domains` | Domains | `LIST_DNS` | `RESTORE_DOMAINS` |
|
||||
| `ssl` | Certificates | `LIST_SSL` | `RESTORE_SSL` |
|
||||
| `mailbox` | Email Accounts | `LIST_MAILBOXES` | `RESTORE_MAILBOX` |
|
||||
|
||||
**Workflow:** Category grid (`index.live.cgi`) → 4-step restore (`restore.live.cgi`):
|
||||
1. Select remote + snapshot timestamp (AJAX-loaded dropdowns)
|
||||
2. Select specific items (database, mailbox, file path, etc.) — skipped for `account` and `cron` types
|
||||
3. Confirmation summary with CSRF token
|
||||
4. Execute via AdminBin, display results
|
||||
|
||||
**cPanel plugin registration:** `install.json` is an array of plugin definitions. It registers in cPanel's "Files" section. The `feature` key (`gniza_restore`) allows admins to enable/disable per package. Both `install_plugin` and `uninstall_plugin` require a **tar.gz archive** containing `install.json` — passing a raw JSON file path prints usage help and does nothing.
|
||||
|
||||
### GnizaCPanel::UI
|
||||
|
||||
@@ -210,17 +237,40 @@ Allows cPanel account owners to restore their own data (files, databases, email,
|
||||
|----------|-------------|
|
||||
| `esc($str)` | HTML-escape a string |
|
||||
| `get_current_user()` | Returns `$ENV{'REMOTE_USER'}` |
|
||||
| `page_header($title)` | Inline CSS + `data-theme="gniza"` wrapper + logo |
|
||||
| `_safe_write($file, $content)` | Symlink-safe write: `unlink` + `O_CREAT\|O_EXCL` (0600 perms) |
|
||||
| `_safe_read($file)` | Symlink-safe read: rejects symlinks (`-l` check) |
|
||||
| `page_header($title)` | Inline CSS + `data-theme="gniza"` wrapper + logo (base64 data URI) |
|
||||
| `page_footer()` | Close wrapper div |
|
||||
| `set_flash($type, $text)` | Store flash message for next page load |
|
||||
| `render_flash()` | Render and consume stored flash message |
|
||||
| `set_flash($type, $text)` | Store flash message at `/tmp/.gniza-cpanel-flash-$user` |
|
||||
| `get_flash()` | Read and consume flash message |
|
||||
| `render_flash()` | Render flash as HTML alert (type validated against allowlist) |
|
||||
| `generate_csrf_token()` | Generate 64-char hex token from `/dev/urandom`, store at `/tmp/.gniza-cpanel-csrf-$user` |
|
||||
| `verify_csrf_token($token)` | Validate + delete (single-use), 1-hour expiry, constant-time comparison |
|
||||
| `csrf_hidden_field()` | Generate CSRF token + hidden input |
|
||||
| `verify_csrf_token($token)` | Validate submitted CSRF token |
|
||||
| `render_errors(\@errors)` | Render error list as HTML |
|
||||
| `_unwrap_layers($css)` | Strip `@layer` wrappers from Tailwind CSS |
|
||||
| `_scope_to_container($css)` | Scope CSS rules to `[data-theme="gniza"]` container |
|
||||
|
||||
### AdminBin Module (Gniza::Restore)
|
||||
|
||||
Actions: `LIST_ALLOWED_REMOTES`, `LIST_SNAPSHOTS`, `LIST_DATABASES`, `LIST_MAILBOXES`, `LIST_FILES`, `LIST_DBUSERS`, `LIST_CRON`, `LIST_DNS`, `LIST_SSL`, `RESTORE_ACCOUNT`, `RESTORE_FILES`, `RESTORE_DATABASE`, `RESTORE_MAILBOX`, `RESTORE_CRON`, `RESTORE_DBUSERS`, `RESTORE_DOMAINS`, `RESTORE_SSL`
|
||||
Runs as root via cPanel's AdminBin framework. Each action validates inputs with strict regex patterns before executing gniza CLI via `IPC::Open3` (list execution, no shell).
|
||||
|
||||
**Validation patterns:**
|
||||
|
||||
| Pattern | Regex | Used for |
|
||||
|---------|-------|----------|
|
||||
| `$ACCOUNT_RE` | `qr/^[a-z][a-z0-9_-]*$/` | cPanel usernames |
|
||||
| `$REMOTE_RE` | `qr/^[a-zA-Z0-9_-]+$/` | Remote names |
|
||||
| `$DBNAME_RE` | `qr/^[a-zA-Z0-9_]+$/` | Database/DB user names |
|
||||
| `$EMAIL_RE` | `qr/^[a-zA-Z0-9._+-]+\@[a-zA-Z0-9._-]+$/` | Email addresses |
|
||||
| `$DOMAIN_RE` | `qr/^[a-zA-Z0-9._-]+$/` | Domain names |
|
||||
| `$TS_RE` | `qr/^\d{4}-\d{2}-\d{2}T\d{6}$/` | Timestamps |
|
||||
| `path` | `qr/^(?!.*\.\.)[a-zA-Z0-9_.\/@ -]+$/` | File paths (rejects `..`) |
|
||||
| `exclude` | `qr/^[a-zA-Z0-9_.,\/@ *?\[\]-]+$/` | Exclude patterns |
|
||||
|
||||
**Actions:** `LIST_ALLOWED_REMOTES`, `LIST_SNAPSHOTS`, `LIST_DATABASES`, `LIST_MAILBOXES`, `LIST_FILES`, `LIST_DBUSERS`, `LIST_CRON`, `LIST_DNS`, `LIST_SSL`, `RESTORE_ACCOUNT`, `RESTORE_FILES`, `RESTORE_DATABASE`, `RESTORE_MAILBOX`, `RESTORE_CRON`, `RESTORE_DBUSERS`, `RESTORE_DOMAINS`, `RESTORE_SSL`
|
||||
|
||||
**Remote filtering:** `_get_allowed_remotes()` reads `USER_RESTORE_REMOTES` from `/etc/gniza/gniza.conf`. Returns `"all"` (default), comma-separated names, or empty string (disabled). `_is_remote_allowed()` and `_get_filtered_remotes()` enforce this on every action.
|
||||
|
||||
Called from CGI via: `Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'ACTION', @args)`
|
||||
|
||||
@@ -286,6 +336,35 @@ Called from CGI via: `Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'ACTION',
|
||||
- `cp` to `/var/cpanel/ssl/` + `checkallsslcerts` — use whmapi1 installssl
|
||||
- Direct mailbox file creation — use UAPI Email add_pop first
|
||||
|
||||
### Security
|
||||
|
||||
**CLI (Bash):**
|
||||
- **Config parsing:** `_safe_source_config()` in `lib/config.sh` reads KEY=VALUE lines via regex without `source`/`eval` — prevents command injection from malicious config files
|
||||
- **Password handling:** SSH passwords passed via `sshpass -e` (environment variable `SSHPASS`), never `-p` (visible in process list)
|
||||
- **File permissions:** `umask 077` set at startup in `bin/gniza`; `install.sh` sets config dirs to `chmod 700`
|
||||
- **Safe rm:** `${var:?}` pattern prevents `rm -rf ""/\*` expansion on empty variables (SC2115)
|
||||
- **Input validation:** `validate_timestamp()` and `validate_account_name()` enforce strict regex patterns. Account names: `^[a-z][a-z0-9_-]{0,15}$`. Timestamps: `^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{6}$`
|
||||
- **RSYNC_EXTRA_OPTS validation:** Both Perl (Validator.pm) and Bash (`validate_config`) reject shell metacharacters (`^[a-zA-Z0-9 ._=/,-]+$`)
|
||||
|
||||
**WHM Plugin:**
|
||||
- **CSRF:** All POST endpoints require CSRF token via `verify_csrf_token()`. Single-use tokens stored at `/var/cpanel/.gniza-whm-csrf/token`. AJAX endpoints (e.g., SMTP test) return a new token in JSON responses; JS updates both the AJAX variable and the main form hidden field to keep them in sync
|
||||
- **HTML escaping:** All user-controlled output passed through `esc()` (HTML entity encoding)
|
||||
- **Runner path traversal:** `GnizaWHM::Runner` rejects `--account` and `--path` values containing `..`
|
||||
- **Config file I/O:** `GnizaWHM::Config::save()` uses `flock(LOCK_EX)` with single file handle (open `+<` then seek+truncate) to prevent TOCTOU races
|
||||
- **Safe file I/O:** `_safe_write()` uses `unlink` + `O_CREAT|O_EXCL` with plain-write fallback; `_safe_read()` rejects symlinks. Used for CSRF token and flash message files
|
||||
- **Upgrade path:** `_ensure_dir()` removes stale plain files left by older versions before creating directories (old versions stored CSRF/flash as plain files at the directory path)
|
||||
|
||||
**cPanel User Plugin:**
|
||||
- **Account isolation:** AdminBin forces `$ENV{'REMOTE_USER'}` as the account — users can only restore their own data
|
||||
- **No `--terminate`:** AdminBin never passes the terminate flag, preventing destructive full restores
|
||||
- **Remote filtering:** `USER_RESTORE_REMOTES` config controls which remotes users can access
|
||||
- **Strict regex validation:** All AdminBin arguments validated against regex patterns (see AdminBin Module section)
|
||||
- **Path traversal prevention:** Path regex uses negative lookahead: `qr/^(?!.*\.\.)[a-zA-Z0-9_.\/@ -]+$/`
|
||||
- **CSRF:** Per-user single-use tokens at `/tmp/.gniza-cpanel-csrf-$user`, generated from `/dev/urandom` (64-char hex), 1-hour expiry, constant-time comparison
|
||||
- **Symlink-safe I/O:** `_safe_write()` (unlink + `O_CREAT|O_EXCL` with fallback) and `_safe_read()` (rejects symlinks) for all `/tmp/` files
|
||||
- **Flash type validation:** `render_flash()` validates type against allowlist (`success`, `error`, `info`, `warning`)
|
||||
- **Command execution:** gniza CLI called via `IPC::Open3` as list (no shell interpolation)
|
||||
|
||||
### SSH/Rsync (REMOTE_TYPE=ssh)
|
||||
|
||||
- All SSH operations go through `build_ssh_opts()` / `remote_exec()` in `ssh.sh`
|
||||
@@ -489,6 +568,7 @@ All restore functions dispatch by `_is_rclone_mode` — using `rclone_from_remot
|
||||
| `page_footer()` | Close the `data-theme` wrapper div |
|
||||
| `_unwrap_layers($css)` | Strip `@layer` wrappers from Tailwind CSS for WHM compatibility |
|
||||
| `get_cpanel_accounts()` | Parse `/etc/trueuserdomains` for account list |
|
||||
| `test_ssh_connection(%args)` | Test SSH connection via ssh (accepts named args or positional for backward compat) |
|
||||
| `test_rclone_connection(%args)` | Test S3/GDrive connection via rclone (generates temp config, runs `rclone lsd`) |
|
||||
|
||||
### GnizaWHM::Runner (WHM plugin)
|
||||
@@ -501,14 +581,21 @@ Pattern-based command runner for safe CLI execution from the WHM UI. Each allowe
|
||||
|
||||
Allowed commands: `restore account/files/database/mailbox/list-databases/list-mailboxes`, `list`.
|
||||
Named option patterns: `--remote`, `--timestamp`, `--path`, `--account`, `--terminate`, `--exclude`.
|
||||
Path traversal prevention: `--account` and `--path` values containing `..` are rejected.
|
||||
|
||||
### GnizaWHM::Config
|
||||
|
||||
| Array | Description |
|
||||
|-------|-------------|
|
||||
Pure Perl config parser/writer. Uses `flock(LOCK_EX)` with single file handle for TOCTOU-safe reads and writes.
|
||||
|
||||
| Function/Array | Description |
|
||||
|----------------|-------------|
|
||||
| `parse($filepath, $type)` | Parse KEY="value" config file, returns hashref. `$type`: `main`, `remote`, or `schedule` |
|
||||
| `save($filepath, \%values, \@allowed_keys)` | Write config preserving comments/structure. Uses `flock(LOCK_EX)` for atomic read+write |
|
||||
| `escape_value($string)` | Strip unsafe characters for double-quoted bash config values |
|
||||
| `escape_password($string)` | Strip single quotes only (for single-quoted password values) |
|
||||
| `@MAIN_KEYS` | Main config keys (local settings only, no REMOTE_*) |
|
||||
| `@REMOTE_KEYS` | Remote config keys (REMOTE_TYPE, SSH, S3, GDrive, transfer, retention — no SCHEDULE*) |
|
||||
| `@SCHEDULE_KEYS` | Schedule config keys (SCHEDULE, SCHEDULE_TIME, SCHEDULE_DAY, SCHEDULE_CRON, REMOTES) |
|
||||
| `@SCHEDULE_KEYS` | Schedule config keys (SCHEDULE, SCHEDULE_TIME, SCHEDULE_DAY, SCHEDULE_CRON, REMOTES, SYSBACKUP, SKIP_SUSPENDED) |
|
||||
|
||||
### GnizaWHM::Validator
|
||||
|
||||
@@ -528,7 +615,13 @@ Run existing tests:
|
||||
bash tests/test_utils.sh
|
||||
```
|
||||
|
||||
Tests cover: `timestamp()` format, `human_size()`, `human_duration()`, `require_cmd()`, `filter_accounts()`, `validate_config()`.
|
||||
Tests cover (40 tests):
|
||||
- `timestamp()` format, `human_size()`, `human_duration()`, `require_cmd()`
|
||||
- `filter_accounts()` — exclusions, inclusions, include+exclude combo
|
||||
- `validate_config()` — LOG_LEVEL, NOTIFY_ON, SSH_TIMEOUT, SSH_RETRIES, RSYNC_EXTRA_OPTS
|
||||
- `validate_timestamp()` — valid format, end-of-year, garbage, spaces/colons, empty string
|
||||
- `validate_account_name()` — valid names, uppercase, leading digit, path traversal, empty, special chars
|
||||
- `_safe_source_config()` — double-quoted, single-quoted, bare, numeric values, malicious file injection
|
||||
|
||||
Tests use a simple `assert_eq`/`assert_ok`/`assert_fail` framework defined in `test_utils.sh`.
|
||||
|
||||
@@ -579,6 +672,17 @@ _restore_remote_globals
|
||||
7. Add any new DaisyUI/Tailwind classes to `assets/src/safelist.html` and rebuild CSS
|
||||
8. Add the page to `@NAV_ITEMS` in `UI.pm` if it should appear in the tab bar
|
||||
|
||||
### Adding a new cPanel plugin page
|
||||
|
||||
1. Create `cpanel/gniza/<name>.live.cgi` (note `.live.cgi` extension for Jupiter theme)
|
||||
2. Use same boilerplate: shebang, `use lib` pointing to CGI lib dir, `Cpanel::Form`, `GnizaCPanel::UI`
|
||||
3. For privilege escalation, call AdminBin: `Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'ACTION', @args)`
|
||||
4. Use `GnizaCPanel::UI::page_header()`, `csrf_hidden_field()`, `page_footer()`
|
||||
5. Validate POST with `verify_csrf_token()`, redirect with 302 after success
|
||||
6. For new AdminBin actions: add the action method to `cpanel/admin/Gniza/Restore` and to `_actions()` list
|
||||
7. Add the CGI copy command to `scripts/install.sh` in the cPanel section
|
||||
8. CSS is shared with WHM — same `gniza-whm.css` file, same DaisyUI classes
|
||||
|
||||
### WHM CSS Policy
|
||||
|
||||
**NEVER write custom CSS.** Always use Tailwind utility classes and DaisyUI components exclusively. All styling must be done through class attributes in HTML — no custom CSS rules, no `<style>` blocks (except the auto-generated inline delivery in `page_header()`), no CSS files other than the Tailwind build output.
|
||||
@@ -634,6 +738,49 @@ cd whm/gniza-whm/assets && npm install && npm run build:css
|
||||
2. Rebuild: `cd whm/gniza-whm/assets && npm run build:css`
|
||||
3. Commit the updated `gniza-whm.css`
|
||||
|
||||
### Install / Uninstall Scripts
|
||||
|
||||
**install.sh** (`scripts/install.sh`) — must be run as root. Detects whether running from a local clone or downloads via git. Installs to `/usr/local/gniza/`.
|
||||
|
||||
Install steps:
|
||||
1. Copy `bin/`, `lib/`, `etc/` to `/usr/local/gniza/`
|
||||
2. Create symlink `/usr/local/bin/gniza` → `/usr/local/gniza/bin/gniza`
|
||||
3. Create working directory `/usr/local/gniza/workdir`
|
||||
4. Create config directories `/etc/gniza/remotes.d/` and `/etc/gniza/schedules.d/` (mode `0700`)
|
||||
5. Copy example configs to `/etc/gniza/`
|
||||
6. Create log directory `/var/log/gniza/`
|
||||
7. If WHM detected: copy `whm/gniza-whm/` to CGI dir, register via `register_appconfig`
|
||||
8. If cPanel detected: copy CGIs + lib + assets to Jupiter theme dir, install AdminBin module, register via `install_plugin`
|
||||
|
||||
**uninstall.sh** (`scripts/uninstall.sh`) — must be run as root. Also installed to `/usr/local/gniza/uninstall.sh`.
|
||||
|
||||
Uninstall steps:
|
||||
1. Remove symlink and install directory
|
||||
2. Remove gniza cron entries (lines matching `# gniza:`)
|
||||
3. If WHM plugin exists: unregister via `unregister_appconfig`, remove directory
|
||||
4. If cPanel plugin exists: unregister via `uninstall_plugin`, remove CGI directory and AdminBin module
|
||||
5. Print manual cleanup instructions for `/etc/gniza/`, `/var/log/gniza/`, `/var/run/gniza.lock`
|
||||
|
||||
**cPanel plugin registration quirk:** Both `install_plugin` and `uninstall_plugin` expect a **tar.gz archive** containing `install.json` — not a raw JSON file path. Passing a JSON file directly prints usage help and does nothing. The scripts create a temporary tar.gz:
|
||||
|
||||
```bash
|
||||
PLUGIN_TMPDIR="$(mktemp -d)"
|
||||
cp "$SOURCE_DIR/cpanel/gniza/install.json" "$PLUGIN_TMPDIR/"
|
||||
tar -czf "$PLUGIN_TMPDIR/gniza-cpanel.tar.gz" -C "$PLUGIN_TMPDIR" install.json
|
||||
/usr/local/cpanel/scripts/install_plugin "$PLUGIN_TMPDIR/gniza-cpanel.tar.gz"
|
||||
rm -rf "$PLUGIN_TMPDIR"
|
||||
```
|
||||
|
||||
`install.json` is also copied to the CGI directory (`$CPANEL_BASE/gniza/install.json`) so the uninstall script can find it.
|
||||
|
||||
### Upgrade Considerations
|
||||
|
||||
**CSRF/flash storage migration (WHM):** Older versions stored CSRF tokens and flash messages as plain files at `/var/cpanel/.gniza-whm-csrf` and `/var/cpanel/.gniza-whm-flash`. Current versions use these as **directories** containing token files. `_ensure_dir()` in `GnizaWHM::UI` handles this automatically — it removes stale plain files before creating directories. Without this, CSRF token writes fail silently and all form submissions show "Invalid or expired form token."
|
||||
|
||||
**CSRF token write robustness:** `generate_csrf_token()` uses `_safe_write()` (O_CREAT|O_EXCL) with a fallback to plain `open '>'` write. This ensures the token is always persisted even if the O_EXCL approach fails (e.g., race conditions, filesystem quirks).
|
||||
|
||||
**SMTP test + form token sync (WHM settings.cgi):** The SMTP test AJAX endpoint consumes the CSRF token and returns a new one. The JS handler updates both the AJAX variable (`gnizaCsrf`) and the main form's hidden `gniza_csrf` field. Without this sync, submitting the main form after an SMTP test would always fail CSRF validation.
|
||||
|
||||
### Repository
|
||||
|
||||
| | URL |
|
||||
|
||||
Reference in New Issue
Block a user