Compare commits

...

28 Commits

Author SHA1 Message Date
shuki
438724de3b Fix install/uninstall display text to show GNIZA 2026-03-05 21:19:47 +02:00
shuki
f1e7682f21 Fix README display text to show GNIZA 2026-03-05 21:18:53 +02:00
shuki
c186453a28 Fix WHM sidebar displayname and remaining display text to show GNIZA 2026-03-05 21:18:00 +02:00
shuki
aff73bda86 Fix branding: display GNIZA in UI, fix remaining double-replacements in bin and AdminBin 2026-03-05 21:15:33 +02:00
shuki
a162536585 Rename product from gniza to gniza4cp across entire codebase
- CLI binary: bin/gniza -> bin/gniza4cp
- Install path: /usr/local/gniza4cp/
- Config path: /etc/gniza4cp/
- Log path: /var/log/gniza4cp/
- WHM plugin: gniza4cp-whm/
- cPanel plugin: cpanel/gniza4cp/
- AdminBin: Gniza4cp::Restore
- Perl modules: Gniza4cpWHM::*, Gniza4cpCPanel::*
- DaisyUI theme: gniza4cp
- All internal references, branding, paths updated
- Git remote updated to gniza4cp repo
2026-03-05 21:03:30 +02:00
shuki
f0171a9eb4 Fix JS syntax error: use non-interpolating heredoc for JS block
Perl was interpolating $/ in the regex as input record separator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:04:04 +02:00
shuki
043e409930 Format snapshot timestamps nicely in cPanel dropdown
Shows "Mar 5, 2026 at 17:15:16" instead of raw "2026-03-05T171516".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:40:12 +02:00
shuki
0bd75402d1 Rename cPanel nav: merge Select Source and Restore into single Restore tab
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:38:56 +02:00
shuki
c20c019048 Add cPanel user activity logs page and WHM user log visibility
- Add per-user activity logging to AdminBin: every RESTORE_* action
  writes to /var/log/gniza/cpanel-<user>.log with action details and
  gniza command output
- New logs.live.cgi CGI with paginated activity list and detail view
- WHM logs.cgi now shows cpanel-*.log files with Owner column and
  structured activity entry viewer with expandable command output
- Add Logs nav item to cPanel plugin, update install.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:37:08 +02:00
shuki
5ffd365c43 Use inline styles for cPanel navbar to avoid Jupiter CSS conflicts
cPanel's Jupiter theme overrides DaisyUI's .navbar component class.
Replace with plain flex layout using inline styles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:33:56 +02:00
shuki
e7bf2e11e2 Sync cPanel CSS with WHM build output
The cPanel copy was outdated and missing navbar/menu classes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:31:08 +02:00
shuki
b1c475da00 Add navigation tabs to cPanel plugin navbar
Adds "Select Source" and "Restore" links to match the WHM navbar style.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:27:34 +02:00
shuki
a5ab2c788a Remove legacy gniza init CLI command
The WHM setup wizard handles all configuration (SSH, S3, GDrive),
making the interactive CLI init wizard redundant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:00:26 +02:00
shuki
efcd4844e9 Only write debug messages to log file when LOG_LEVEL=debug
Info, warn, and error always go to the log file. Debug messages
are only written to the file when LOG_LEVEL is set to debug,
preventing verbose output in logs at higher levels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:57:35 +02:00
shuki
2cf0a8866c Always write all log levels to file, only filter console output
LOG_LEVEL was gating both file and console output, causing empty
log files when set to error/warn. Now the log file always captures
everything while LOG_LEVEL only controls stderr verbosity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:55:55 +02:00
shuki
55cd576d53 Show WHM-specific next steps when cPanel is detected
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:51:40 +02:00
shuki
beb17a2298 Update repository URLs to git.linux-hosting.co.il
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:42:43 +02:00
shuki
89ca3187df Hide cron wrapper logs from logs listing
Only show per-run gniza-*.log files in the logs page. Cron wrapper
logs (cron-*.log) are redundant with the structured per-run logs
and just accumulate noise. They remain accessible via direct URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:09:17 +02:00
shuki
6be3e8fabf Show System Backup type in logs page
Add [TYPE:SYSBACKUP] marker to sysbackup log output. The logs page
detects this in the first 5 lines and displays "System" badge instead
of "Backup".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:06:54 +02:00
shuki
42ee83f433 Add SVG icons to dashboard stat cards
Each of the 6 stat cards now has a contextual SVG icon in the top-right
corner: users, shield-check, upload, server, clock, and file-text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:02:13 +02:00
shuki
0fb640d585 Use d/m/Y H:i:s date format for all user-facing timestamps
Update log lines, email notifications, generated config comments,
and WHM logs page. Structural dates (filenames, snapshot dirs,
.complete markers) are unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:35:49 +02:00
shuki
19368ee2c6 Use d/m/Y H:i:s format for stats updated timestamp
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:33:20 +02:00
shuki
af55437bed Move hamburger menu to right side of navbar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:45:43 +02:00
shuki
43fdf116cc Make navbar responsive with hamburger dropdown on small screens
Desktop (lg+) shows horizontal menu links. Smaller screens show
a dropdown hamburger button with the same navigation items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:44:14 +02:00
shuki
3a48c3265f Use HTTP URL for git clone in install script
Target servers don't need SSH keys — use public HTTP endpoint
for unauthenticated clone during install.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:20:32 +02:00
shuki
8f42cc19c9 Use public IP for git clone in install script
Target servers won't have the gitea SSH alias, so use the direct
SSH URL with the public IP for remote installs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:17:06 +02:00
shuki
b22a8d14a7 Update repository URLs to use gitea SSH config alias
Replace hardcoded 192.168.100.100 addresses with gitea SSH alias
from ~/.ssh/config across CLAUDE.md, README.md, and install.sh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:16:13 +02:00
shuki
bf6624b607 Add daily stats collection cron job at 05:00 UTC
The gniza stats command is now automatically scheduled when
install_schedules() runs, tagged as # gniza:_stats so it is
managed alongside backup schedules (installed/removed together).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 05:33:55 +02:00
870 changed files with 91989 additions and 1950 deletions

221
CLAUDE.md
View File

@@ -1,19 +1,19 @@
# agents.md — gniza Development Guide # agents.md — gniza4cp Development Guide
> Reference for AI coding agents working on gniza. Describes architecture, conventions, and key patterns. > Reference for AI coding agents working on gniza4cp. Describes architecture, conventions, and key patterns.
## Project Overview ## Project Overview
gniza is a Bash CLI tool for cPanel server backup and disaster recovery. It runs `pkgacct` to export accounts, gzips SQL files, and transfers everything to one or more remote destinations using hardlink-based incremental snapshots. Supports three remote types: **SSH** (rsync with `--link-dest`), **Amazon S3** / S3-compatible (via rclone), and **Google Drive** (via rclone). gniza4cp is a Bash CLI tool for cPanel server backup and disaster recovery. It runs `pkgacct` to export accounts, gzips SQL files, and transfers everything to one or more remote destinations using hardlink-based incremental snapshots. Supports three remote types: **SSH** (rsync with `--link-dest`), **Amazon S3** / S3-compatible (via rclone), and **Google Drive** (via rclone).
**Language:** Bash (bash 4+, `set -euo pipefail`) **Language:** Bash (bash 4+, `set -euo pipefail`)
**Target environment:** CentOS/AlmaLinux cPanel servers, running as root **Target environment:** CentOS/AlmaLinux cPanel servers, running as root
**Install path:** `/usr/local/gniza/` with symlink at `/usr/local/bin/gniza` **Install path:** `/usr/local/gniza4cp/` with symlink at `/usr/local/bin/gniza4cp`
## Repository Structure ## Repository Structure
``` ```
bin/gniza # CLI entrypoint — command routing, argument parsing bin/gniza4cp # CLI entrypoint — command routing, argument parsing
lib/ lib/
├── constants.sh # Version, exit codes, color codes, default values ├── constants.sh # Version, exit codes, color codes, default values
├── utils.sh # die(), require_root(), timestamp(), human_size/duration(), validate_timestamp/account_name() ├── utils.sh # die(), require_root(), timestamp(), human_size/duration(), validate_timestamp/account_name()
@@ -33,17 +33,17 @@ lib/
├── remotes.sh # Multi-remote: list_remotes(), load_remote(), get_target_remotes() ├── remotes.sh # Multi-remote: list_remotes(), load_remote(), get_target_remotes()
└── schedule.sh # Cron: decoupled schedules from schedules.d/ └── schedule.sh # Cron: decoupled schedules from schedules.d/
etc/ etc/
├── gniza.conf.example # Main config template ├── gniza4cp.conf.example # Main config template
├── remote.conf.example # Remote destination config template ├── remote.conf.example # Remote destination config template
└── schedule.conf.example # Schedule config template └── schedule.conf.example # Schedule config template
scripts/ scripts/
├── install.sh # Install to /usr/local/gniza, create dirs/symlinks, WHM + cPanel plugins ├── install.sh # Install to /usr/local/gniza4cp, create dirs/symlinks, WHM + cPanel plugins
└── uninstall.sh # Remove install dir, symlink, cron entries, WHM + cPanel plugins └── uninstall.sh # Remove install dir, symlink, cron entries, WHM + cPanel plugins
tests/ tests/
└── test_utils.sh # Unit tests for utils.sh, accounts.sh, config.sh └── test_utils.sh # Unit tests for utils.sh, accounts.sh, config.sh
whm/ whm/
├── gniza-whm.conf # WHM AppConfig registration ├── gniza4cp-whm.conf # WHM AppConfig registration
└── gniza-whm/ └── gniza4cp-whm/
├── index.cgi # Dashboard — overview, quick links, auto-redirect if unconfigured ├── index.cgi # Dashboard — overview, quick links, auto-redirect if unconfigured
├── setup.cgi # 3-step setup wizard (SSH key → remote → schedule) ├── setup.cgi # 3-step setup wizard (SSH key → remote → schedule)
├── settings.cgi # Main config editor (local settings only) ├── settings.cgi # Main config editor (local settings only)
@@ -51,29 +51,29 @@ whm/
├── schedules.cgi # Schedule CRUD — add/edit/delete with remote checkboxes ├── schedules.cgi # Schedule CRUD — add/edit/delete with remote checkboxes
├── restore.cgi # Restore workflow — 4-step form (account → snapshot → confirm → execute) ├── restore.cgi # Restore workflow — 4-step form (account → snapshot → confirm → execute)
├── assets/ ├── assets/
│ ├── gniza-whm.css # Built Tailwind/DaisyUI CSS (committed, ~58KB) │ ├── gniza4cp-whm.css # Built Tailwind/DaisyUI CSS (committed, ~58KB)
│ ├── gniza-logo.svg # SVG logo (embedded as data URI in page header) │ ├── gniza4cp-logo.svg # SVG logo (embedded as data URI in page header)
│ └── src/ │ └── src/
│ ├── input.css # Tailwind v4 entry point with DaisyUI plugin │ ├── input.css # Tailwind v4 entry point with DaisyUI plugin
│ ├── safelist.html # Class safelist for Tailwind content scanner │ ├── safelist.html # Class safelist for Tailwind content scanner
│ └── package.json # Build toolchain (tailwindcss + daisyui) │ └── package.json # Build toolchain (tailwindcss + daisyui)
└── lib/GnizaWHM/ └── lib/Gniza4cpWHM/
├── Config.pm # Pure Perl config parser/writer (KEY="value" files) ├── Config.pm # Pure Perl config parser/writer (KEY="value" files)
├── Validator.pm # Input validation (mirrors lib/config.sh) ├── Validator.pm # Input validation (mirrors lib/config.sh)
├── Cron.pm # Cron read + allowlisted gniza schedule commands ├── Cron.pm # Cron read + allowlisted gniza4cp schedule commands
├── Runner.pm # Pattern-based safe CLI command runner for WHM ├── Runner.pm # Pattern-based safe CLI command runner for WHM
└── UI.pm # Nav, flash, CSRF, HTML escaping, CSS delivery └── UI.pm # Nav, flash, CSRF, HTML escaping, CSS delivery
cpanel/ cpanel/
├── gniza/ ├── gniza4cp/
│ ├── index.live.cgi # Category grid — 8 restore type cards │ ├── index.live.cgi # Category grid — 8 restore type cards
│ ├── restore.live.cgi # Multi-step restore workflow (4 steps) │ ├── restore.live.cgi # Multi-step restore workflow (4 steps)
│ ├── install.json # cPanel plugin registration (Files section) │ ├── install.json # cPanel plugin registration (Files section)
│ ├── assets/ │ ├── assets/
│ │ ├── gniza-whm.css # Built CSS (copy of WHM CSS) │ │ ├── gniza4cp-whm.css # Built CSS (copy of WHM CSS)
│ │ └── gniza-logo.svg # Logo (copy of WHM logo) │ │ └── gniza4cp-logo.svg # Logo (copy of WHM logo)
│ └── lib/GnizaCPanel/ │ └── lib/Gniza4cpCPanel/
│ └── UI.pm # Page wrapper, CSRF, flash, CSS delivery │ └── UI.pm # Page wrapper, CSRF, flash, CSS delivery
└── admin/Gniza/ └── admin/Gniza4cp/
├── Restore # AdminBin module (runs as root, privilege escalation) ├── Restore # AdminBin module (runs as root, privilege escalation)
└── Restore.conf # AdminBin config (mode=full) └── Restore.conf # AdminBin config (mode=full)
``` ```
@@ -87,7 +87,7 @@ All library functions (`ssh.sh`, `rclone.sh`, `transfer.sh`, `snapshot.sh`, `ret
Rather than passing remote context through function arguments, `remotes.sh` provides: Rather than passing remote context through function arguments, `remotes.sh` provides:
- `_save_remote_globals()` — snapshot current globals - `_save_remote_globals()` — snapshot current globals
- `load_remote(name)` — source `/etc/gniza/remotes.d/<name>.conf`, overriding REMOTE_* globals - `load_remote(name)` — source `/etc/gniza4cp/remotes.d/<name>.conf`, overriding REMOTE_* globals
- `_restore_remote_globals()` — restore saved snapshot - `_restore_remote_globals()` — restore saved snapshot
This keeps the change set minimal — no existing function signatures needed modification. This keeps the change set minimal — no existing function signatures needed modification.
@@ -131,16 +131,16 @@ cmd_backup()
### Command Routing ### Command Routing
`bin/gniza` main() parses the first arg and routes to `cmd_*()` functions. Each command handles its own `--config`, `--remote`, `--account` flags via `get_opt()` and `has_flag()`. `bin/gniza4cp` main() parses the first arg and routes to `cmd_*()` functions. Each command handles its own `--config`, `--remote`, `--account` flags via `get_opt()` and `has_flag()`.
Commands: `backup`, `restore`, `list`, `verify`, `status`, `remote`, `schedule`, `init`, `version`, `help` Commands: `backup`, `restore`, `list`, `verify`, `status`, `remote`, `schedule`, `version`, `help`
### Config Hierarchy ### Config Hierarchy
1. `lib/constants.sh``DEFAULT_*` readonly values 1. `lib/constants.sh``DEFAULT_*` readonly values
2. `/etc/gniza/gniza.conf` — main config: local settings only (accounts, logging, notifications) 2. `/etc/gniza4cp/gniza4cp.conf` — main config: local settings only (accounts, logging, notifications)
3. `/etc/gniza/remotes.d/<name>.conf` — per-remote config (REMOTE_*, retention, transfer) 3. `/etc/gniza4cp/remotes.d/<name>.conf` — per-remote config (REMOTE_*, retention, transfer)
4. `/etc/gniza/schedules.d/<name>.conf` — per-schedule config (timing, target remotes) 4. `/etc/gniza4cp/schedules.d/<name>.conf` — per-schedule config (timing, target remotes)
5. CLI flags (`--debug`, `--config=PATH`) 5. CLI flags (`--debug`, `--config=PATH`)
### Snapshot Layout ### Snapshot Layout
@@ -171,13 +171,13 @@ Commands: `backup`, `restore`, `list`, `verify`, `status`, `remote`, `schedule`,
### Decoupled Schedules ### Decoupled Schedules
Schedules are independent from remotes. Each schedule lives in `/etc/gniza/schedules.d/<name>.conf` and defines when backups run and which remotes to target. This allows multiple schedules targeting different sets of remotes. Schedules are independent from remotes. Each schedule lives in `/etc/gniza4cp/schedules.d/<name>.conf` and defines when backups run and which remotes to target. This allows multiple schedules targeting different sets of remotes.
Cron entries are tagged with `# gniza:<name>` comment lines. `install_schedules()` strips old tagged lines and appends new ones. Format: Cron entries are tagged with `# gniza4cp:<name>` comment lines. `install_schedules()` strips old tagged lines and appends new ones. Format:
``` ```
# gniza:nightly # gniza4cp:nightly
0 2 * * * /usr/local/bin/gniza backup --remote=nas,offsite >> /var/log/gniza/cron-nightly.log 2>&1 0 2 * * * /usr/local/bin/gniza4cp backup --remote=nas,offsite >> /var/log/gniza4cp/cron-nightly.log 2>&1
``` ```
### Comma-Separated Remote Targeting ### Comma-Separated Remote Targeting
@@ -188,7 +188,7 @@ Cron entries are tagged with `# gniza:<name>` comment lines. `install_schedules(
Allows cPanel account owners to restore their own data (files, databases, email, etc.) without WHM admin access. Allows cPanel account owners to restore their own data (files, databases, email, etc.) without WHM admin access.
**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. **Privilege escalation:** Uses cPanel's AdminBin framework. CGIs run as the logged-in cPanel user; the AdminBin module (`cpanel/admin/Gniza4cp/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`). **CGI file naming:** cPanel Jupiter theme uses `.live.cgi` extension for CGI files (e.g., `index.live.cgi`, `restore.live.cgi`).
@@ -196,18 +196,18 @@ Allows cPanel account owners to restore their own data (files, databases, email,
- Account isolation: AdminBin forces the authenticated username — users can only restore their own data - 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 - 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) - 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) - Strict regex validation on all arguments (mirrors `Gniza4cpWHM::Runner` patterns)
- Path traversal prevention: path regex uses negative lookahead to reject `..``qr/^(?!.*\.\.)[a-zA-Z0-9_.\/@ -]+$/` - 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) - 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) - Per-user CSRF tokens at `/tmp/.gniza4cp-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 - 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`) - Flash message type validated against allowlist (`success`, `error`, `info`, `warning`)
**Install locations:** **Install locations:**
- CGIs: `/usr/local/cpanel/base/frontend/jupiter/gniza/` - CGIs: `/usr/local/cpanel/base/frontend/jupiter/gniza4cp/`
- AdminBin: `/usr/local/cpanel/bin/admin/Gniza/` (Restore is `0700`, Restore.conf is `0600`) - AdminBin: `/usr/local/cpanel/bin/admin/Gniza4cp/` (Restore is `0700`, Restore.conf is `0600`)
- Plugin registration: via `install_plugin` with tar.gz archive containing `install.json` - Plugin registration: via `install_plugin` with tar.gz archive containing `install.json`
- Assets: CSS and logo copied to `gniza/assets/` alongside CGIs - Assets: CSS and logo copied to `gniza4cp/assets/` alongside CGIs
- `install.json` also copied to CGI directory for `uninstall_plugin` to reference - `install.json` also copied to CGI directory for `uninstall_plugin` to reference
**Restore categories (8 types):** **Restore categories (8 types):**
@@ -229,9 +229,9 @@ Allows cPanel account owners to restore their own data (files, databases, email,
3. Confirmation summary with CSRF token 3. Confirmation summary with CSRF token
4. Execute via AdminBin, display results 4. Execute via AdminBin, display results
**cPanel plugin registration:** `install.json` is an array of plugin definitions passed to `install_plugin`/`uninstall_plugin` inside a **tar.gz archive** (with the icon file included). Required JSON fields per cPanel's `Cpanel::Themes::Assets::Link`: `type` ("link"), `id` (lowercase identifier), `name`, `group_id` (section: "files", "domains", etc.), `uri` (CGI path), `feature` (for Feature Manager), `order` (integer), `icon` (path relative to staging dir). The `feature` key (`gniza_restore`) allows admins to enable/disable per cPanel package. **cPanel plugin registration:** `install.json` is an array of plugin definitions passed to `install_plugin`/`uninstall_plugin` inside a **tar.gz archive** (with the icon file included). Required JSON fields per cPanel's `Cpanel::Themes::Assets::Link`: `type` ("link"), `id` (lowercase identifier), `name`, `group_id` (section: "files", "domains", etc.), `uri` (CGI path), `feature` (for Feature Manager), `order` (integer), `icon` (path relative to staging dir). The `feature` key (`gniza4cp_restore`) allows admins to enable/disable per cPanel package.
### GnizaCPanel::UI ### Gniza4cpCPanel::UI
| Function | Description | | Function | Description |
|----------|-------------| |----------|-------------|
@@ -239,21 +239,21 @@ Allows cPanel account owners to restore their own data (files, databases, email,
| `get_current_user()` | Returns `$ENV{'REMOTE_USER'}` | | `get_current_user()` | Returns `$ENV{'REMOTE_USER'}` |
| `_safe_write($file, $content)` | Symlink-safe write: `unlink` + `O_CREAT\|O_EXCL` (0600 perms) | | `_safe_write($file, $content)` | Symlink-safe write: `unlink` + `O_CREAT\|O_EXCL` (0600 perms) |
| `_safe_read($file)` | Symlink-safe read: rejects symlinks (`-l` check) | | `_safe_read($file)` | Symlink-safe read: rejects symlinks (`-l` check) |
| `page_header($title)` | Inline CSS + `data-theme="gniza"` wrapper + logo (base64 data URI) | | `page_header($title)` | Inline CSS + `data-theme="gniza4cp"` wrapper + logo (base64 data URI) |
| `page_footer()` | Close wrapper div | | `page_footer()` | Close wrapper div |
| `set_flash($type, $text)` | Store flash message at `/tmp/.gniza-cpanel-flash-$user` | | `set_flash($type, $text)` | Store flash message at `/tmp/.gniza4cp-cpanel-flash-$user` |
| `get_flash()` | Read and consume flash message | | `get_flash()` | Read and consume flash message |
| `render_flash()` | Render flash as HTML alert (type validated against allowlist) | | `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` | | `generate_csrf_token()` | Generate 64-char hex token from `/dev/urandom`, store at `/tmp/.gniza4cp-cpanel-csrf-$user` |
| `verify_csrf_token($token)` | Validate + delete (single-use), 1-hour expiry, constant-time comparison | | `verify_csrf_token($token)` | Validate + delete (single-use), 1-hour expiry, constant-time comparison |
| `csrf_hidden_field()` | Generate CSRF token + hidden input | | `csrf_hidden_field()` | Generate CSRF token + hidden input |
| `render_errors(\@errors)` | Render error list as HTML | | `render_errors(\@errors)` | Render error list as HTML |
| `_unwrap_layers($css)` | Strip `@layer` wrappers from Tailwind CSS | | `_unwrap_layers($css)` | Strip `@layer` wrappers from Tailwind CSS |
| `_scope_to_container($css)` | Scope CSS rules to `[data-theme="gniza"]` container | | `_scope_to_container($css)` | Scope CSS rules to `[data-theme="gniza4cp"]` container |
### AdminBin Module (Gniza::Restore) ### AdminBin Module (Gniza4cp::Restore)
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). Runs as root via cPanel's AdminBin framework. Each action validates inputs with strict regex patterns before executing gniza4cp CLI via `IPC::Open3` (list execution, no shell).
**Validation patterns:** **Validation patterns:**
@@ -270,9 +270,9 @@ Runs as root via cPanel's AdminBin framework. Each action validates inputs with
**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` **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. **Remote filtering:** `_get_allowed_remotes()` reads `USER_RESTORE_REMOTES` from `/etc/gniza4cp/gniza4cp.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)` Called from CGI via: `Cpanel::AdminBin::Call::call('Gniza4cp', 'Restore', 'ACTION', @args)`
## Coding Conventions ## Coding Conventions
@@ -281,7 +281,7 @@ Called from CGI via: `Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'ACTION',
- `set -euo pipefail` at top of entrypoint - `set -euo pipefail` at top of entrypoint
- Functions use `local` for all variables - Functions use `local` for all variables
- Error paths: `log_error` + `return 1` (library) or `die "message"` (CLI) - Error paths: `log_error` + `return 1` (library) or `die "message"` (CLI)
- Guard-include pattern for constants: `[[ -n "${_GNIZA_CONSTANTS_LOADED:-}" ]] && return 0` - Guard-include pattern for constants: `[[ -n "${_GNIZA4CP_CONSTANTS_LOADED:-}" ]] && return 0`
- `((count++)) || true` to avoid `set -e` traps on zero-to-one arithmetic - `((count++)) || true` to avoid `set -e` traps on zero-to-one arithmetic
### Naming ### Naming
@@ -289,7 +289,7 @@ Called from CGI via: `Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'ACTION',
- Libraries: `lib/<module>.sh` — each file focuses on one responsibility - Libraries: `lib/<module>.sh` — each file focuses on one responsibility
- Public functions: `snake_case` (e.g., `transfer_pkgacct`, `list_remote_snapshots`) - Public functions: `snake_case` (e.g., `transfer_pkgacct`, `list_remote_snapshots`)
- Private/helper functions: `_prefixed` (e.g., `_backup_to_current_remote`, `_save_remote_globals`) - Private/helper functions: `_prefixed` (e.g., `_backup_to_current_remote`, `_save_remote_globals`)
- CLI commands: `cmd_<name>()` in `bin/gniza` - CLI commands: `cmd_<name>()` in `bin/gniza4cp`
- Constants: `UPPER_SNAKE_CASE`, prefixed with `DEFAULT_` for defaults - Constants: `UPPER_SNAKE_CASE`, prefixed with `DEFAULT_` for defaults
- Globals: `UPPER_SNAKE_CASE` (e.g., `REMOTE_HOST`, `LOG_LEVEL`) - Globals: `UPPER_SNAKE_CASE` (e.g., `REMOTE_HOST`, `LOG_LEVEL`)
@@ -299,7 +299,7 @@ Called from CGI via: `Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'ACTION',
- In multi-remote mode, failure on one remote doesn't block others - In multi-remote mode, failure on one remote doesn't block others
- rsync retries with exponential backoff: `sleep $((attempt * 10))` - rsync retries with exponential backoff: `sleep $((attempt * 10))`
- Exit codes: `0` OK, `1` fatal, `2` locked, `5` partial failure - Exit codes: `0` OK, `1` fatal, `2` locked, `5` partial failure
- Lock via `flock` on `/var/run/gniza.lock` - Lock via `flock` on `/var/run/gniza4cp.lock`
### cPanel API Policy ### cPanel API Policy
@@ -341,16 +341,16 @@ Called from CGI via: `Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'ACTION',
**CLI (Bash):** **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 - **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) - **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` - **File permissions:** `umask 077` set at startup in `bin/gniza4cp`; `install.sh` sets config dirs to `chmod 700`
- **Safe rm:** `${var:?}` pattern prevents `rm -rf ""/\*` expansion on empty variables (SC2115) - **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}$` - **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 ._=/,-]+$`) - **RSYNC_EXTRA_OPTS validation:** Both Perl (Validator.pm) and Bash (`validate_config`) reject shell metacharacters (`^[a-zA-Z0-9 ._=/,-]+$`)
**WHM Plugin:** **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 - **CSRF:** All POST endpoints require CSRF token via `verify_csrf_token()`. Single-use tokens stored at `/var/cpanel/.gniza4cp-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) - **HTML escaping:** All user-controlled output passed through `esc()` (HTML entity encoding)
- **Runner path traversal:** `GnizaWHM::Runner` rejects `--account` and `--path` values containing `..` - **Runner path traversal:** `Gniza4cpWHM::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 - **Config file I/O:** `Gniza4cpWHM::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 - **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) - **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)
@@ -360,10 +360,10 @@ Called from CGI via: `Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'ACTION',
- **Remote filtering:** `USER_RESTORE_REMOTES` config controls which remotes users can access - **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) - **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_.\/@ -]+$/` - **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 - **CSRF:** Per-user single-use tokens at `/tmp/.gniza4cp-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 - **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`) - **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) - **Command execution:** gniza4cp CLI called via `IPC::Open3` as list (no shell interpolation)
### SSH/Rsync (REMOTE_TYPE=ssh) ### SSH/Rsync (REMOTE_TYPE=ssh)
@@ -386,27 +386,27 @@ Called from CGI via: `Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'ACTION',
## Configuration Files ## Configuration Files
### Main Config (`/etc/gniza/gniza.conf`) ### Main Config (`/etc/gniza4cp/gniza4cp.conf`)
Contains only local settings. Remote destinations are configured in `remotes.d/`. Contains only local settings. Remote destinations are configured in `remotes.d/`.
| Variable | Required | Default | Description | | Variable | Required | Default | Description |
|----------|----------|---------|-------------| |----------|----------|---------|-------------|
| `TEMP_DIR` | No | `/usr/local/gniza/workdir` | Local working directory | | `TEMP_DIR` | No | `/usr/local/gniza4cp/workdir` | Local working directory |
| `INCLUDE_ACCOUNTS` | No | (all) | Comma-separated account list | | `INCLUDE_ACCOUNTS` | No | (all) | Comma-separated account list |
| `EXCLUDE_ACCOUNTS` | No | `nobody` | Comma-separated exclusions | | `EXCLUDE_ACCOUNTS` | No | `nobody` | Comma-separated exclusions |
| `LOG_DIR` | No | `/var/log/gniza` | Log directory | | `LOG_DIR` | No | `/var/log/gniza4cp` | Log directory |
| `LOG_LEVEL` | No | `info` | `debug\|info\|warn\|error` | | `LOG_LEVEL` | No | `info` | `debug\|info\|warn\|error` |
| `LOG_RETAIN` | No | `90` | Days to keep log files | | `LOG_RETAIN` | No | `90` | Days to keep log files |
| `NOTIFY_EMAIL` | No | (disabled) | Notification email | | `NOTIFY_EMAIL` | No | (disabled) | Notification email |
| `NOTIFY_ON` | No | `failure` | `always\|failure\|never` | | `NOTIFY_ON` | No | `failure` | `always\|failure\|never` |
| `LOCK_FILE` | No | `/var/run/gniza.lock` | Lock file path | | `LOCK_FILE` | No | `/var/run/gniza4cp.lock` | Lock file path |
| `SSH_TIMEOUT` | No | `30` | SSH connection timeout (seconds) | | `SSH_TIMEOUT` | No | `30` | SSH connection timeout (seconds) |
| `SSH_RETRIES` | No | `3` | rsync retry attempts | | `SSH_RETRIES` | No | `3` | rsync retry attempts |
| `RSYNC_EXTRA_OPTS` | No | (empty) | Extra rsync options | | `RSYNC_EXTRA_OPTS` | No | (empty) | Extra rsync options |
| `USER_RESTORE_REMOTES` | No | `all` | Remotes for cPanel user restore (`all`, comma-separated names, or empty to disable) | | `USER_RESTORE_REMOTES` | No | `all` | Remotes for cPanel user restore (`all`, comma-separated names, or empty to disable) |
### Remote Config (`/etc/gniza/remotes.d/<name>.conf`) ### Remote Config (`/etc/gniza4cp/remotes.d/<name>.conf`)
**Common (all types):** **Common (all types):**
@@ -446,7 +446,7 @@ Contains only local settings. Remote destinations are configured in `remotes.d/`
| `GDRIVE_SERVICE_ACCOUNT_FILE` | Yes | — | Path to service account JSON key file | | `GDRIVE_SERVICE_ACCOUNT_FILE` | Yes | — | Path to service account JSON key file |
| `GDRIVE_ROOT_FOLDER_ID` | No | (empty) | Root folder ID | | `GDRIVE_ROOT_FOLDER_ID` | No | (empty) | Root folder ID |
### Schedule Config (`/etc/gniza/schedules.d/<name>.conf`) ### Schedule Config (`/etc/gniza4cp/schedules.d/<name>.conf`)
Schedules are decoupled from remotes. Each schedule targets one or more remotes. Schedules are decoupled from remotes. Each schedule targets one or more remotes.
@@ -502,7 +502,7 @@ Schedules are decoupled from remotes. Each schedule targets one or more remotes.
### schedule.sh ### schedule.sh
Reads schedules from `/etc/gniza/schedules.d/` (decoupled from remotes). Reads schedules from `/etc/gniza4cp/schedules.d/` (decoupled from remotes).
| Function | Description | | Function | Description |
|----------|-------------| |----------|-------------|
@@ -510,10 +510,10 @@ Reads schedules from `/etc/gniza/schedules.d/` (decoupled from remotes).
| `has_schedules()` | Check if any schedule configs exist | | `has_schedules()` | Check if any schedule configs exist |
| `load_schedule(name)` | Source config, set SCHEDULE/SCHEDULE_REMOTES globals | | `load_schedule(name)` | Source config, set SCHEDULE/SCHEDULE_REMOTES globals |
| `schedule_to_cron(name)` | Convert SCHEDULE vars to 5-field cron expression | | `schedule_to_cron(name)` | Convert SCHEDULE vars to 5-field cron expression |
| `build_cron_line(name)` | Full cron line with gniza command, `--remote=` flag, and log redirect | | `build_cron_line(name)` | Full cron line with gniza4cp command, `--remote=` flag, and log redirect |
| `install_schedules()` | Strip old gniza cron entries, add new from all `schedules.d/` | | `install_schedules()` | Strip old gniza4cp cron entries, add new from all `schedules.d/` |
| `show_schedules()` | Display current gniza cron entries | | `show_schedules()` | Display current gniza4cp cron entries |
| `remove_schedules()` | Remove all gniza cron entries | | `remove_schedules()` | Remove all gniza4cp cron entries |
### restore.sh ### restore.sh
@@ -530,7 +530,7 @@ All restore functions dispatch by `_is_rclone_mode` — using `rclone_from_remot
| `_rsync_download(src, dest)` | Download helper — dispatches rclone_from_remote or rsync | | `_rsync_download(src, dest)` | Download helper — dispatches rclone_from_remote or rsync |
| `_detect_pkgacct_base(user, ts)` | Detect old vs new snapshot format (SSH or cloud) | | `_detect_pkgacct_base(user, ts)` | Detect old vs new snapshot format (SSH or cloud) |
### bin/gniza (CLI helpers) ### bin/gniza4cp (CLI helpers)
| Function | Description | | Function | Description |
|----------|-------------| |----------|-------------|
@@ -541,20 +541,19 @@ All restore functions dispatch by `_is_rclone_mode` — using `rclone_from_remot
| `_list_current_remote(account)` | Display listing for current remote context | | `_list_current_remote(account)` | Display listing for current remote context |
| `_test_connection()` | Dispatch `test_rclone_connection` or `test_ssh_connection` by type | | `_test_connection()` | Dispatch `test_rclone_connection` or `test_ssh_connection` by type |
| `_status_ssh_and_disk()` | Connection test + disk/storage usage display (SSH: df, cloud: rclone about) | | `_status_ssh_and_disk()` | Connection test + disk/storage usage display (SSH: df, cloud: rclone about) |
| `_init_remote(name)` | Interactive remote destination setup |
| `cmd_remote()` | Remote management: list, delete | | `cmd_remote()` | Remote management: list, delete |
| `cmd_schedule()` | Schedule CRUD: add, delete, list, install, show, remove | | `cmd_schedule()` | Schedule CRUD: add, delete, list, install, show, remove |
### GnizaWHM::UI (WHM plugin) ### Gniza4cpWHM::UI (WHM plugin)
| Function | Description | | Function | Description |
|----------|-------------| |----------|-------------|
| `is_configured()` | True if any remote configs exist in `remotes.d/` | | `is_configured()` | True if any remote configs exist in `remotes.d/` |
| `detect_ssh_keys()` | Scan `/root/.ssh/` for key files, return arrayref of hashes | | `detect_ssh_keys()` | Scan `/root/.ssh/` for key files, return arrayref of hashes |
| `render_ssh_guidance()` | HTML block: detected keys + keygen/ssh-copy-id instructions | | `render_ssh_guidance()` | HTML block: detected keys + keygen/ssh-copy-id instructions |
| `has_remotes()` | Check if `/etc/gniza/remotes.d/` has `.conf` files | | `has_remotes()` | Check if `/etc/gniza4cp/remotes.d/` has `.conf` files |
| `list_remotes()` | Return sorted list of remote names | | `list_remotes()` | Return sorted list of remote names |
| `has_schedules()` | Check if `/etc/gniza/schedules.d/` has `.conf` files | | `has_schedules()` | Check if `/etc/gniza4cp/schedules.d/` has `.conf` files |
| `list_schedules()` | Return sorted list of schedule names | | `list_schedules()` | Return sorted list of schedule names |
| `schedule_conf_path($name)` | Return path to schedule config file | | `schedule_conf_path($name)` | Return path to schedule config file |
| `esc($str)` | HTML-escape a string | | `esc($str)` | HTML-escape a string |
@@ -571,19 +570,19 @@ All restore functions dispatch by `_is_rclone_mode` — using `rclone_from_remot
| `test_ssh_connection(%args)` | Test SSH connection via ssh (accepts named args or positional for backward compat) | | `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`) | | `test_rclone_connection(%args)` | Test S3/GDrive connection via rclone (generates temp config, runs `rclone lsd`) |
### GnizaWHM::Runner (WHM plugin) ### Gniza4cpWHM::Runner (WHM plugin)
Pattern-based command runner for safe CLI execution from the WHM UI. Each allowed command has regex patterns per argument position. Pattern-based command runner for safe CLI execution from the WHM UI. Each allowed command has regex patterns per argument position.
| Function | Description | | Function | Description |
|----------|-------------| |----------|-------------|
| `run($cmd, $subcmd, \@args, \%opts)` | Validate against allowlist and execute gniza CLI | | `run($cmd, $subcmd, \@args, \%opts)` | Validate against allowlist and execute gniza4cp CLI |
Allowed commands: `restore account/files/database/mailbox/list-databases/list-mailboxes`, `list`. Allowed commands: `restore account/files/database/mailbox/list-databases/list-mailboxes`, `list`.
Named option patterns: `--remote`, `--timestamp`, `--path`, `--account`, `--terminate`, `--exclude`. Named option patterns: `--remote`, `--timestamp`, `--path`, `--account`, `--terminate`, `--exclude`.
Path traversal prevention: `--account` and `--path` values containing `..` are rejected. Path traversal prevention: `--account` and `--path` values containing `..` are rejected.
### GnizaWHM::Config ### Gniza4cpWHM::Config
Pure Perl config parser/writer. Uses `flock(LOCK_EX)` with single file handle for TOCTOU-safe reads and writes. Pure Perl config parser/writer. Uses `flock(LOCK_EX)` with single file handle for TOCTOU-safe reads and writes.
@@ -597,7 +596,7 @@ Pure Perl config parser/writer. Uses `flock(LOCK_EX)` with single file handle fo
| `@REMOTE_KEYS` | Remote config keys (REMOTE_TYPE, SSH, S3, GDrive, transfer, retention — no SCHEDULE*) | | `@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, SYSBACKUP, SKIP_SUSPENDED) | | `@SCHEDULE_KEYS` | Schedule config keys (SCHEDULE, SCHEDULE_TIME, SCHEDULE_DAY, SCHEDULE_CRON, REMOTES, SYSBACKUP, SKIP_SUSPENDED) |
### GnizaWHM::Validator ### Gniza4cpWHM::Validator
| Function | Description | | Function | Description |
|----------|-------------| |----------|-------------|
@@ -630,12 +629,12 @@ Tests use a simple `assert_eq`/`assert_ok`/`assert_fail` framework defined in `t
### Adding a new library function ### Adding a new library function
1. Add to the appropriate `lib/<module>.sh` 1. Add to the appropriate `lib/<module>.sh`
2. Functions are automatically available — libraries are sourced in `bin/gniza` 2. Functions are automatically available — libraries are sourced in `bin/gniza4cp`
3. Use `local` for all variables, `log_*` for output, `return 1` for errors 3. Use `local` for all variables, `log_*` for output, `return 1` for errors
### Adding a new command ### Adding a new command
1. Add `cmd_<name>()` function in `bin/gniza` 1. Add `cmd_<name>()` function in `bin/gniza4cp`
2. Add routing in `main()` case statement 2. Add routing in `main()` case statement
3. Update `cmd_usage()` help text 3. Update `cmd_usage()` help text
4. Update `README.md` commands table 4. Update `README.md` commands table
@@ -645,7 +644,7 @@ Tests use a simple `assert_eq`/`assert_ok`/`assert_fail` framework defined in `t
1. Add `DEFAULT_<NAME>` to `lib/constants.sh` 1. Add `DEFAULT_<NAME>` to `lib/constants.sh`
2. Add to `load_config()` in `lib/config.sh` with fallback 2. Add to `load_config()` in `lib/config.sh` with fallback
3. Add validation in `validate_config()` if needed 3. Add validation in `validate_config()` if needed
4. Add to `etc/gniza.conf.example` 4. Add to `etc/gniza4cp.conf.example`
5. Document in `README.md` and this file 5. Document in `README.md` and this file
### Making a function remote-aware ### Making a function remote-aware
@@ -663,25 +662,25 @@ _restore_remote_globals
### Adding a new WHM plugin page ### Adding a new WHM plugin page
1. Create `whm/gniza-whm/<name>.cgi` following the pattern of existing CGIs 1. Create `whm/gniza4cp-whm/<name>.cgi` following the pattern of existing CGIs
2. Use same boilerplate: shebang, `use lib`, `Whostmgr::HTMLInterface`, `Cpanel::Form`, `GnizaWHM::UI` 2. Use same boilerplate: shebang, `use lib`, `Whostmgr::HTMLInterface`, `Cpanel::Form`, `Gniza4cpWHM::UI`
3. Route by `$form->{'action'}` or similar param 3. Route by `$form->{'action'}` or similar param
4. Use `GnizaWHM::UI::page_header()`, `render_nav()`, `render_flash()`, `csrf_hidden_field()`, `page_footer()` 4. Use `Gniza4cpWHM::UI::page_header()`, `render_nav()`, `render_flash()`, `csrf_hidden_field()`, `page_footer()`
5. Validate POST with `verify_csrf_token()`, redirect with 302 after success 5. Validate POST with `verify_csrf_token()`, redirect with 302 after success
6. No AppConfig change needed — `url=/cgi/gniza-whm/` covers all CGIs in the directory 6. No AppConfig change needed — `url=/cgi/gniza4cp-whm/` covers all CGIs in the directory
7. Add any new DaisyUI/Tailwind classes to `assets/src/safelist.html` and rebuild CSS 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 8. Add the page to `@NAV_ITEMS` in `UI.pm` if it should appear in the tab bar
### Adding a new cPanel plugin page ### Adding a new cPanel plugin page
1. Create `cpanel/gniza/<name>.live.cgi` (note `.live.cgi` extension for Jupiter theme) 1. Create `cpanel/gniza4cp/<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` 2. Use same boilerplate: shebang, `use lib` pointing to CGI lib dir, `Cpanel::Form`, `Gniza4cpCPanel::UI`
3. For privilege escalation, call AdminBin: `Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'ACTION', @args)` 3. For privilege escalation, call AdminBin: `Cpanel::AdminBin::Call::call('Gniza4cp', 'Restore', 'ACTION', @args)`
4. Use `GnizaCPanel::UI::page_header()`, `csrf_hidden_field()`, `page_footer()` 4. Use `Gniza4cpCPanel::UI::page_header()`, `csrf_hidden_field()`, `page_footer()`
5. Validate POST with `verify_csrf_token()`, redirect with 302 after success 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 6. For new AdminBin actions: add the action method to `cpanel/admin/Gniza4cp/Restore` and to `_actions()` list
7. Add the CGI copy command to `scripts/install.sh` in the cPanel section 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 8. CSS is shared with WHM — same `gniza4cp-whm.css` file, same DaisyUI classes
### WHM CSS Policy ### WHM CSS Policy
@@ -689,7 +688,7 @@ _restore_remote_globals
### WHM Theme & Color Palette ### WHM Theme & Color Palette
The WHM plugin uses a custom DaisyUI theme named `gniza` (defined in `assets/src/input.css`). Light-only, no dark mode. The WHM plugin uses a custom DaisyUI theme named `gniza4cp` (defined in `assets/src/input.css`). Light-only, no dark mode.
| Role | OKLCH Value | Approx Color | | Role | OKLCH Value | Approx Color |
|------|-------------|-------------| |------|-------------|-------------|
@@ -718,13 +717,13 @@ All WHM pages use Tailwind CSS v4 with DaisyUI v5 for styling. The CSS is built
**Build:** **Build:**
```bash ```bash
cd whm/gniza-whm/assets && npm install && npm run build:css cd whm/gniza4cp-whm/assets && npm install && npm run build:css
``` ```
**Key files:** **Key files:**
- `assets/src/input.css` — Tailwind entry point with DaisyUI plugin config - `assets/src/input.css` — Tailwind entry point with DaisyUI plugin config
- `assets/src/safelist.html` — Class safelist (required because Tailwind v4 scanner doesn't recognize `.cgi`/`.pm` file extensions) - `assets/src/safelist.html` — Class safelist (required because Tailwind v4 scanner doesn't recognize `.cgi`/`.pm` file extensions)
- `assets/gniza-whm.css` — Built output (committed to repo) - `assets/gniza4cp-whm.css` — Built output (committed to repo)
**WHM CSS delivery quirks:** **WHM CSS delivery quirks:**
- WHM's CGI directory cannot serve static files directly - WHM's CGI directory cannot serve static files directly
@@ -735,56 +734,56 @@ cd whm/gniza-whm/assets && npm install && npm run build:css
**Adding new CSS classes:** **Adding new CSS classes:**
1. Add the class to `assets/src/safelist.html` (since Tailwind can't scan `.cgi`/`.pm` files) 1. Add the class to `assets/src/safelist.html` (since Tailwind can't scan `.cgi`/`.pm` files)
2. Rebuild: `cd whm/gniza-whm/assets && npm run build:css` 2. Rebuild: `cd whm/gniza4cp-whm/assets && npm run build:css`
3. Commit the updated `gniza-whm.css` 3. Commit the updated `gniza4cp-whm.css`
### Install / Uninstall Scripts ### 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.sh** (`scripts/install.sh`) — must be run as root. Detects whether running from a local clone or downloads via git. Installs to `/usr/local/gniza4cp/`.
Install steps: Install steps:
1. Copy `bin/`, `lib/`, `etc/` to `/usr/local/gniza/` 1. Copy `bin/`, `lib/`, `etc/` to `/usr/local/gniza4cp/`
2. Create symlink `/usr/local/bin/gniza``/usr/local/gniza/bin/gniza` 2. Create symlink `/usr/local/bin/gniza4cp``/usr/local/gniza4cp/bin/gniza4cp`
3. Create working directory `/usr/local/gniza/workdir` 3. Create working directory `/usr/local/gniza4cp/workdir`
4. Create config directories `/etc/gniza/remotes.d/` and `/etc/gniza/schedules.d/` (mode `0700`) 4. Create config directories `/etc/gniza4cp/remotes.d/` and `/etc/gniza4cp/schedules.d/` (mode `0700`)
5. Copy example configs to `/etc/gniza/` 5. Copy example configs to `/etc/gniza4cp/`
6. Create log directory `/var/log/gniza/` 6. Create log directory `/var/log/gniza4cp/`
7. If WHM detected: copy `whm/gniza-whm/` to CGI dir, register via `register_appconfig` 7. If WHM detected: copy `whm/gniza4cp-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` 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.sh** (`scripts/uninstall.sh`) — must be run as root. Also installed to `/usr/local/gniza4cp/uninstall.sh`.
Uninstall steps: Uninstall steps:
1. Remove symlink and install directory 1. Remove symlink and install directory
2. Remove gniza cron entries (lines matching `# gniza:`) 2. Remove gniza4cp cron entries (lines matching `# gniza4cp:`)
3. If WHM plugin exists: unregister via `unregister_appconfig`, remove directory 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 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` 5. Print manual cleanup instructions for `/etc/gniza4cp/`, `/var/log/gniza4cp/`, `/var/run/gniza4cp.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: **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 ```bash
PLUGIN_TMPDIR="$(mktemp -d)" PLUGIN_TMPDIR="$(mktemp -d)"
cp "$SOURCE_DIR/cpanel/gniza/install.json" "$PLUGIN_TMPDIR/" cp "$SOURCE_DIR/cpanel/gniza4cp/install.json" "$PLUGIN_TMPDIR/"
tar -czf "$PLUGIN_TMPDIR/gniza-cpanel.tar.gz" -C "$PLUGIN_TMPDIR" install.json tar -czf "$PLUGIN_TMPDIR/gniza4cp-cpanel.tar.gz" -C "$PLUGIN_TMPDIR" install.json
/usr/local/cpanel/scripts/install_plugin "$PLUGIN_TMPDIR/gniza-cpanel.tar.gz" /usr/local/cpanel/scripts/install_plugin "$PLUGIN_TMPDIR/gniza4cp-cpanel.tar.gz"
rm -rf "$PLUGIN_TMPDIR" 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. `install.json` is also copied to the CGI directory (`$CPANEL_BASE/gniza4cp/install.json`) so the uninstall script can find it.
### Upgrade Considerations ### 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/flash storage migration (WHM):** Older versions stored CSRF tokens and flash messages as plain files at `/var/cpanel/.gniza4cp-whm-csrf` and `/var/cpanel/.gniza4cp-whm-flash`. Current versions use these as **directories** containing token files. `_ensure_dir()` in `Gniza4cpWHM::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). **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. **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 (`gniza4cpCsrf`) and the main form's hidden `gniza4cp_csrf` field. Without this sync, submitting the main form after an SMTP test would always fail CSRF validation.
### Repository ### Repository
| | URL | | | URL |
|---|-----| |---|-----|
| **Git (SSH)** | `ssh://git@192.168.100.100:2222/shukivaknin/gniza.git` | | **Git (SSH)** | `gitea:shukivaknin/gniza4cp.git` (uses `Host gitea` from `~/.ssh/config`) |
| **Git (HTTP)** | `http://192.168.100.100:3001/shukivaknin/gniza.git` | | **Git (HTTPS)** | `https://git.linux-hosting.co.il/shukivaknin/gniza4cp.git` |
| **Web UI** | http://192.168.100.100:3001/shukivaknin/gniza | | **Web UI** | https://git.linux-hosting.co.il/shukivaknin/gniza4cp/ |

177
README.md
View File

@@ -1,4 +1,4 @@
# gniza # GNIZA
cPanel Backup, Restore & Disaster Recovery tool. cPanel Backup, Restore & Disaster Recovery tool.
@@ -8,103 +8,96 @@ Uses `pkgacct --nocompress --skiphomedir` for account backups, gzips SQL files i
| | URL | | | URL |
|---|-----| |---|-----|
| **Git (SSH)** | `ssh://git@192.168.100.100:2222/shukivaknin/gniza.git` | | **Git (SSH)** | `gitea:shukivaknin/gniza4cp.git` (uses `Host gitea` from `~/.ssh/config`) |
| **Git (HTTP)** | `http://192.168.100.100:3001/shukivaknin/gniza.git` | | **Git (HTTPS)** | `https://git.linux-hosting.co.il/shukivaknin/gniza4cp.git` |
| **Web UI** | http://192.168.100.100:3001/shukivaknin/gniza | | **Web UI** | https://git.linux-hosting.co.il/shukivaknin/gniza4cp/ |
## Installation ## Installation
```bash From a clone:
curl -sSL http://192.168.100.100:3001/shukivaknin/gniza/raw/branch/main/scripts/install.sh | sudo bash
```
Or from a local clone:
```bash ```bash
git clone ssh://git@192.168.100.100:2222/shukivaknin/gniza.git git clone https://git.linux-hosting.co.il/shukivaknin/gniza4cp.git
cd gniza cd gniza4cp
sudo bash scripts/install.sh sudo bash scripts/install.sh
``` ```
To uninstall: To uninstall:
```bash ```bash
sudo bash /usr/local/gniza/uninstall.sh # from installed copy sudo bash /usr/local/gniza4cp/uninstall.sh # from installed copy
# or # or
sudo bash scripts/uninstall.sh # from repo clone sudo bash scripts/uninstall.sh # from repo clone
``` ```
The uninstall script removes the CLI, symlink, cron entries, and WHM plugin. Config (`/etc/gniza/`) and logs (`/var/log/gniza/`) are preserved — remove manually if desired. The uninstall script removes the CLI, symlink, cron entries, and WHM plugin. Config (`/etc/gniza4cp/`) and logs (`/var/log/gniza4cp/`) are preserved — remove manually if desired.
## Quick Start ## Quick Start
```bash ```bash
# Interactive setup (creates config + first remote + optional schedule) # Configure via WHM → GNIZA Backup Manager (setup wizard)
sudo gniza init # Or copy example configs manually:
sudo cp /etc/gniza4cp/gniza4cp.conf.example /etc/gniza4cp/gniza4cp.conf
# Add additional remote destinations sudo cp /etc/gniza4cp/remote.conf.example /etc/gniza4cp/remotes.d/nas.conf
sudo gniza init remote offsite
# Test backup (dry run) # Test backup (dry run)
sudo gniza backup --dry-run sudo gniza4cp backup --dry-run
# Run backup # Run backup
sudo gniza backup sudo gniza4cp backup
# Back up to specific remotes # Back up to specific remotes
sudo gniza backup --remote=nas,offsite sudo gniza4cp backup --remote=nas,offsite
``` ```
## Commands ## Commands
``` ```
gniza backup [--account=NAME] [--remote=NAME[,NAME2]] [--skip-suspended] [--dry-run] gniza4cp backup [--account=NAME] [--remote=NAME[,NAME2]] [--skip-suspended] [--dry-run]
gniza restore account <name> --remote=NAME [--timestamp=TS] [--force] gniza4cp restore account <name> --remote=NAME [--timestamp=TS] [--force]
gniza restore files <name> --remote=NAME [--path=subpath] [--timestamp=TS] gniza4cp restore files <name> --remote=NAME [--path=subpath] [--timestamp=TS]
gniza restore database <name> <dbname> --remote=NAME [--timestamp=TS] gniza4cp restore database <name> <dbname> --remote=NAME [--timestamp=TS]
gniza restore mailbox <name> <email@domain> --remote=NAME [--timestamp=TS] gniza4cp restore mailbox <name> <email@domain> --remote=NAME [--timestamp=TS]
gniza restore server --remote=NAME [--timestamp=TS] gniza4cp restore server --remote=NAME [--timestamp=TS]
gniza list [--account=NAME] [--remote=NAME] gniza4cp list [--account=NAME] [--remote=NAME]
gniza verify [--account=NAME] [--remote=NAME] gniza4cp verify [--account=NAME] [--remote=NAME]
gniza status gniza4cp status
gniza remote list gniza4cp remote list
gniza remote delete <name> gniza4cp remote delete <name>
gniza schedule add <name> gniza4cp schedule add <name>
gniza schedule delete <name> gniza4cp schedule delete <name>
gniza schedule list gniza4cp schedule list
gniza schedule install gniza4cp schedule install
gniza schedule show gniza4cp schedule show
gniza schedule remove gniza4cp schedule remove
gniza init gniza4cp version
gniza init remote <name> gniza4cp help
gniza version
gniza help
``` ```
### Global Options ### Global Options
| Option | Description | | Option | Description |
|--------|-------------| |--------|-------------|
| `--config=PATH` | Alternate config file (default: `/etc/gniza/gniza.conf`) | | `--config=PATH` | Alternate config file (default: `/etc/gniza4cp/gniza4cp.conf`) |
| `--remote=NAME[,NAME2]` | Target specific remote(s) from `/etc/gniza/remotes.d/` (comma-separated) | | `--remote=NAME[,NAME2]` | Target specific remote(s) from `/etc/gniza4cp/remotes.d/` (comma-separated) |
| `--debug` | Enable debug logging | | `--debug` | Enable debug logging |
## Configuration ## Configuration
### Main Config ### Main Config
**File:** `/etc/gniza/gniza.conf` **File:** `/etc/gniza4cp/gniza4cp.conf`
Controls local settings (accounts, logging, notifications). Remote destinations are configured in `/etc/gniza/remotes.d/`. Controls local settings (accounts, logging, notifications). Remote destinations are configured in `/etc/gniza4cp/remotes.d/`.
```bash ```bash
# Local Settings # Local Settings
TEMP_DIR="/usr/local/gniza/workdir" # Working dir for pkgacct output TEMP_DIR="/usr/local/gniza4cp/workdir" # Working dir for pkgacct output
INCLUDE_ACCOUNTS="" # Comma-separated, empty = all INCLUDE_ACCOUNTS="" # Comma-separated, empty = all
EXCLUDE_ACCOUNTS="nobody" # Comma-separated exclusions EXCLUDE_ACCOUNTS="nobody" # Comma-separated exclusions
# Logging # Logging
LOG_DIR="/var/log/gniza" LOG_DIR="/var/log/gniza4cp"
LOG_LEVEL="info" # debug, info, warn, error LOG_LEVEL="info" # debug, info, warn, error
LOG_RETAIN=90 # Days to keep log files LOG_RETAIN=90 # Days to keep log files
@@ -113,39 +106,35 @@ NOTIFY_EMAIL="" # Email for notifications
NOTIFY_ON="failure" # always, failure, never NOTIFY_ON="failure" # always, failure, never
# Advanced # Advanced
LOCK_FILE="/var/run/gniza.lock" LOCK_FILE="/var/run/gniza4cp.lock"
SSH_TIMEOUT=30 SSH_TIMEOUT=30
SSH_RETRIES=3 SSH_RETRIES=3
RSYNC_EXTRA_OPTS="" RSYNC_EXTRA_OPTS=""
``` ```
See `etc/gniza.conf.example` for the full template. See `etc/gniza4cp.conf.example` for the full template.
### Remote Destinations ### Remote Destinations
Back up to one or more destinations with independent retention policies and bandwidth limits. Supports SSH, Amazon S3 (and S3-compatible services like MinIO, Wasabi, Backblaze B2), and Google Drive. Remote destinations are configured as individual files in `/etc/gniza/remotes.d/`. Back up to one or more destinations with independent retention policies and bandwidth limits. Supports SSH, Amazon S3 (and S3-compatible services like MinIO, Wasabi, Backblaze B2), and Google Drive. Remote destinations are configured as individual files in `/etc/gniza4cp/remotes.d/`.
#### Setup #### Setup
```bash ```bash
# Interactive setup (recommended) # Configure via WHM → Remotes, or copy the template manually
sudo gniza init remote nas sudo cp /etc/gniza4cp/remote.conf.example /etc/gniza4cp/remotes.d/nas.conf
sudo gniza init remote offsite sudo vi /etc/gniza4cp/remotes.d/nas.conf
# Or copy the template manually
sudo cp /etc/gniza/remote.conf.example /etc/gniza/remotes.d/nas.conf
sudo vi /etc/gniza/remotes.d/nas.conf
# List configured remotes # List configured remotes
sudo gniza remote list sudo gniza4cp remote list
# Delete a remote # Delete a remote
sudo gniza remote delete nas sudo gniza4cp remote delete nas
``` ```
#### Remote Config Format #### Remote Config Format
Each file in `/etc/gniza/remotes.d/<name>.conf`: Each file in `/etc/gniza4cp/remotes.d/<name>.conf`:
```bash ```bash
# Remote type: "ssh" (default), "s3", or "gdrive" # Remote type: "ssh" (default), "s3", or "gdrive"
@@ -187,22 +176,22 @@ Without `--remote`, backup/list/verify operate on **all** configured remotes. Re
```bash ```bash
# Back up to all remotes # Back up to all remotes
sudo gniza backup sudo gniza4cp backup
# Back up to specific remote(s) # Back up to specific remote(s)
sudo gniza backup --remote=nas sudo gniza4cp backup --remote=nas
sudo gniza backup --remote=nas,offsite sudo gniza4cp backup --remote=nas,offsite
# List snapshots on a specific remote # List snapshots on a specific remote
sudo gniza list --remote=offsite sudo gniza4cp list --remote=offsite
# Restore requires explicit remote # Restore requires explicit remote
sudo gniza restore account johndoe --remote=nas sudo gniza4cp restore account johndoe --remote=nas
``` ```
### Schedules ### Schedules
Schedules are **decoupled from remotes**. Each schedule lives in `/etc/gniza/schedules.d/<name>.conf` and defines when backups run and which remotes to target. This allows multiple schedules targeting different sets of remotes. Schedules are **decoupled from remotes**. Each schedule lives in `/etc/gniza4cp/schedules.d/<name>.conf` and defines when backups run and which remotes to target. This allows multiple schedules targeting different sets of remotes.
#### Schedule Config Format #### Schedule Config Format
@@ -222,22 +211,22 @@ SKIP_SUSPENDED="" # "yes" to skip cPanel suspended accounts
```bash ```bash
# Interactive schedule creation # Interactive schedule creation
sudo gniza schedule add nightly sudo gniza4cp schedule add nightly
# List configured schedules # List configured schedules
sudo gniza schedule list sudo gniza4cp schedule list
# Delete a schedule # Delete a schedule
sudo gniza schedule delete nightly sudo gniza4cp schedule delete nightly
# Install all schedules to crontab # Install all schedules to crontab
sudo gniza schedule install sudo gniza4cp schedule install
# Show current gniza cron entries # Show current GNIZA cron entries
sudo gniza schedule show sudo gniza4cp schedule show
# Remove all gniza cron entries # Remove all GNIZA cron entries
sudo gniza schedule remove sudo gniza4cp schedule remove
``` ```
#### Schedule Types #### Schedule Types
@@ -253,8 +242,8 @@ sudo gniza schedule remove
Each schedule gets a tagged cron entry for clean install/remove: Each schedule gets a tagged cron entry for clean install/remove:
``` ```
# gniza:nightly # gniza4cp:nightly
0 2 * * * /usr/local/bin/gniza backup --remote=nas,offsite >> /var/log/gniza/cron-nightly.log 2>&1 0 2 * * * /usr/local/bin/gniza4cp backup --remote=nas,offsite >> /var/log/gniza4cp/cron-nightly.log 2>&1
``` ```
## Remote Directory Structure ## Remote Directory Structure
@@ -342,8 +331,8 @@ All restore commands require `--remote=NAME` to specify the source.
## File Layout ## File Layout
``` ```
/usr/local/gniza/ # Install directory /usr/local/gniza4cp/ # Install directory
├── bin/gniza # CLI entrypoint ├── bin/gniza4cp # CLI entrypoint
├── lib/ # Shell libraries ├── lib/ # Shell libraries
│ ├── constants.sh # Version, exit codes, colors, defaults │ ├── constants.sh # Version, exit codes, colors, defaults
│ ├── utils.sh # die(), require_root(), timestamp, human_* │ ├── utils.sh # die(), require_root(), timestamp, human_*
@@ -363,12 +352,12 @@ All restore commands require `--remote=NAME` to specify the source.
│ ├── remotes.sh # Remote discovery and context switching │ ├── remotes.sh # Remote discovery and context switching
│ └── schedule.sh # Cron management for decoupled schedules │ └── schedule.sh # Cron management for decoupled schedules
└── etc/ └── etc/
├── gniza.conf.example # Main config template ├── gniza4cp.conf.example # Main config template
├── remote.conf.example # Remote destination template ├── remote.conf.example # Remote destination template
└── schedule.conf.example # Schedule template └── schedule.conf.example # Schedule template
/etc/gniza/ # Runtime configuration /etc/gniza4cp/ # Runtime configuration
├── gniza.conf # Main config ├── gniza4cp.conf # Main config
├── remotes.d/ # Remote destination configs ├── remotes.d/ # Remote destination configs
│ ├── nas.conf │ ├── nas.conf
│ └── offsite.conf │ └── offsite.conf
@@ -376,29 +365,29 @@ All restore commands require `--remote=NAME` to specify the source.
├── nightly.conf ├── nightly.conf
└── weekly-offsite.conf └── weekly-offsite.conf
/var/log/gniza/ # Log files /var/log/gniza4cp/ # Log files
├── gniza-20260303-020000.log # Per-run logs ├── gniza4cp-20260303-020000.log # Per-run logs
├── cron-nightly.log # Per-schedule cron output ├── cron-nightly.log # Per-schedule cron output
└── cron-weekly-offsite.log └── cron-weekly-offsite.log
``` ```
## WHM Plugin ## WHM Plugin
gniza includes a WHM plugin for managing backups through the cPanel/WHM web interface. All pages use **Tailwind CSS v4** with **DaisyUI v5** for styling. GNIZA includes a WHM plugin for managing backups through the cPanel/WHM web interface. All pages use **Tailwind CSS v4** with **DaisyUI v5** for styling.
### Installation ### Installation
The plugin is installed automatically by `scripts/install.sh`. It registers with WHM at **Plugins > gniza Backup Manager**. The plugin is installed automatically by `scripts/install.sh`. It registers with WHM at **Plugins > GNIZA Backup Manager**.
Plugin files are deployed to `/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/`. Plugin files are deployed to `/usr/local/cpanel/whostmgr/docroot/cgi/gniza4cp-whm/`.
### Setup Wizard ### Setup Wizard
When gniza is not yet configured (no remotes in `/etc/gniza/remotes.d/`), the dashboard automatically redirects to a **3-step setup wizard**: When gniza4cp is not yet configured (no remotes in `/etc/gniza4cp/remotes.d/`), the dashboard automatically redirects to a **3-step setup wizard**:
1. **SSH Key** — Detects existing keys in `/root/.ssh/` (`id_ed25519`, `id_rsa`, `id_ecdsa`, `id_dsa`). Lets you select one or enter a custom path. Shows `ssh-keygen` and `ssh-copy-id` commands for creating new keys. 1. **SSH Key** — Detects existing keys in `/root/.ssh/` (`id_ed25519`, `id_rsa`, `id_ecdsa`, `id_dsa`). Lets you select one or enter a custom path. Shows `ssh-keygen` and `ssh-copy-id` commands for creating new keys.
2. **Remote Destination** — Configure the first remote: name, type (SSH/S3/GDrive), connection details, base path, bandwidth limit, and retention count. Tests the connection before saving. Creates a config file in `/etc/gniza/remotes.d/`. 2. **Remote Destination** — Configure the first remote: name, type (SSH/S3/GDrive), connection details, base path, bandwidth limit, and retention count. Tests the connection before saving. Creates a config file in `/etc/gniza4cp/remotes.d/`.
3. **Schedule** — Optionally set a backup schedule (hourly/daily/weekly/monthly/custom) for the new remote. Installs the cron entry automatically. Can be skipped. 3. **Schedule** — Optionally set a backup schedule (hourly/daily/weekly/monthly/custom) for the new remote. Installs the cron entry automatically. Can be skipped.
@@ -412,15 +401,15 @@ The wizard is also accessible anytime from the dashboard quick links ("Run Setup
| Remotes | `remotes.cgi` | Add/edit/delete remote destinations (SSH/S3/GDrive) with connection testing | | Remotes | `remotes.cgi` | Add/edit/delete remote destinations (SSH/S3/GDrive) with connection testing |
| Schedules | `schedules.cgi` | Add/edit/delete schedules, per-schedule cron toggle | | Schedules | `schedules.cgi` | Add/edit/delete schedules, per-schedule cron toggle |
| Restore | `restore.cgi` | Restore workflow: select account, remote, snapshot, then restore type (full/files/database/mailbox) | | Restore | `restore.cgi` | Restore workflow: select account, remote, snapshot, then restore type (full/files/database/mailbox) |
| Settings | `settings.cgi` | Edit main config (`/etc/gniza/gniza.conf`) | | Settings | `settings.cgi` | Edit main config (`/etc/gniza4cp/gniza4cp.conf`) |
| Setup Wizard | `setup.cgi` | Guided initial configuration (3 steps) | | Setup Wizard | `setup.cgi` | Guided initial configuration (3 steps) |
### Plugin File Layout ### Plugin File Layout
``` ```
whm/ whm/
├── gniza-whm.conf # WHM AppConfig registration ├── gniza4cp-whm.conf # WHM AppConfig registration
└── gniza-whm/ └── gniza4cp-whm/
├── index.cgi # Dashboard ├── index.cgi # Dashboard
├── setup.cgi # Setup wizard (3 steps) ├── setup.cgi # Setup wizard (3 steps)
├── settings.cgi # Main config editor ├── settings.cgi # Main config editor
@@ -428,12 +417,12 @@ whm/
├── schedules.cgi # Schedule CRUD + cron toggles ├── schedules.cgi # Schedule CRUD + cron toggles
├── restore.cgi # Restore workflow (account → remote → snapshot → type) ├── restore.cgi # Restore workflow (account → remote → snapshot → type)
├── assets/ ├── assets/
│ ├── gniza-whm.css # Built Tailwind/DaisyUI CSS (committed) │ ├── gniza4cp-whm.css # Built Tailwind/DaisyUI CSS (committed)
│ └── src/ │ └── src/
│ ├── input.css # Tailwind v4 entry point │ ├── input.css # Tailwind v4 entry point
│ ├── safelist.html # Class safelist for Tailwind scanner │ ├── safelist.html # Class safelist for Tailwind scanner
│ └── package.json # Build toolchain │ └── package.json # Build toolchain
└── lib/GnizaWHM/ └── lib/Gniza4cpWHM/
├── Config.pm # Config parser/writer (pure Perl) ├── Config.pm # Config parser/writer (pure Perl)
├── Validator.pm # Input validation ├── Validator.pm # Input validation
├── Cron.pm # Cron read + per-schedule install/remove ├── Cron.pm # Cron read + per-schedule install/remove

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza — cPanel Backup, Restore & Disaster Recovery # gniza4cp — cPanel Backup, Restore & Disaster Recovery
# CLI entrypoint and command routing # CLI entrypoint and command routing
set -euo pipefail set -euo pipefail
@@ -256,7 +256,7 @@ cmd_backup() {
[[ -n "$remote_flag" ]] && sysbackup_args+=(--remote="$remote_flag") [[ -n "$remote_flag" ]] && sysbackup_args+=(--remote="$remote_flag")
[[ "$dry_run" == "true" ]] && sysbackup_args+=(--dry-run) [[ "$dry_run" == "true" ]] && sysbackup_args+=(--dry-run)
# Run as subprocess so its exit doesn't kill our process # Run as subprocess so its exit doesn't kill our process
/usr/local/bin/gniza sysbackup "${sysbackup_args[@]}" || log_error "System backup failed" /usr/local/bin/gniza4cp sysbackup "${sysbackup_args[@]}" || log_error "System backup failed"
acquire_lock acquire_lock
fi fi
@@ -301,7 +301,7 @@ cmd_restore() {
account) account)
local name="${1:-}" local name="${1:-}"
shift 2>/dev/null || true shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore account <name> [--remote=NAME] [--timestamp=TS] [--terminate]" [[ -z "$name" ]] && die "Usage: gniza4cp restore account <name> [--remote=NAME] [--timestamp=TS] [--terminate]"
validate_account_name "$name" || die "Invalid account name" validate_account_name "$name" || die "Invalid account name"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
@@ -325,7 +325,7 @@ cmd_restore() {
files) files)
local name="${1:-}" local name="${1:-}"
shift 2>/dev/null || true shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore files <name> [--remote=NAME] [--path=subpath] [--timestamp=TS]" [[ -z "$name" ]] && die "Usage: gniza4cp restore files <name> [--remote=NAME] [--path=subpath] [--timestamp=TS]"
validate_account_name "$name" || die "Invalid account name" validate_account_name "$name" || die "Invalid account name"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
@@ -347,7 +347,7 @@ cmd_restore() {
database) database)
local name="${1:-}" local name="${1:-}"
local dbname="${2:-}" local dbname="${2:-}"
[[ -z "$name" ]] && die "Usage: gniza restore database <name> [<dbname>] [--remote=NAME] [--timestamp=TS]" [[ -z "$name" ]] && die "Usage: gniza4cp restore database <name> [<dbname>] [--remote=NAME] [--timestamp=TS]"
validate_account_name "$name" || die "Invalid account name" validate_account_name "$name" || die "Invalid account name"
shift 2>/dev/null || true shift 2>/dev/null || true
# If dbname looks like a flag, it's not a dbname # If dbname looks like a flag, it's not a dbname
@@ -378,7 +378,7 @@ cmd_restore() {
mailbox) mailbox)
local name="${1:-}" local name="${1:-}"
local email="${2:-}" local email="${2:-}"
[[ -z "$name" ]] && die "Usage: gniza restore mailbox <name> [<email@domain>] [--remote=NAME] [--timestamp=TS]" [[ -z "$name" ]] && die "Usage: gniza4cp restore mailbox <name> [<email@domain>] [--remote=NAME] [--timestamp=TS]"
validate_account_name "$name" || die "Invalid account name" validate_account_name "$name" || die "Invalid account name"
shift 2>/dev/null || true shift 2>/dev/null || true
# If email looks like a flag, it's not an email # If email looks like a flag, it's not an email
@@ -409,7 +409,7 @@ cmd_restore() {
list-databases) list-databases)
local name="${1:-}" local name="${1:-}"
shift 2>/dev/null || true shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore list-databases <name> [--remote=NAME] [--timestamp=TS]" [[ -z "$name" ]] && die "Usage: gniza4cp restore list-databases <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file" load_config "$config_file"
@@ -427,7 +427,7 @@ cmd_restore() {
list-mailboxes) list-mailboxes)
local name="${1:-}" local name="${1:-}"
shift 2>/dev/null || true shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore list-mailboxes <name> [--remote=NAME] [--timestamp=TS]" [[ -z "$name" ]] && die "Usage: gniza4cp restore list-mailboxes <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file" load_config "$config_file"
@@ -445,7 +445,7 @@ cmd_restore() {
list-files) list-files)
local name="${1:-}" local name="${1:-}"
shift 2>/dev/null || true shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore list-files <name> [--remote=NAME] [--timestamp=TS] [--path=subdir]" [[ -z "$name" ]] && die "Usage: gniza4cp restore list-files <name> [--remote=NAME] [--timestamp=TS] [--path=subdir]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file" load_config "$config_file"
@@ -464,7 +464,7 @@ cmd_restore() {
list-dbusers) list-dbusers)
local name="${1:-}" local name="${1:-}"
shift 2>/dev/null || true shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore list-dbusers <name> [--remote=NAME] [--timestamp=TS]" [[ -z "$name" ]] && die "Usage: gniza4cp restore list-dbusers <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file" load_config "$config_file"
@@ -482,7 +482,7 @@ cmd_restore() {
list-cron) list-cron)
local name="${1:-}" local name="${1:-}"
shift 2>/dev/null || true shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore list-cron <name> [--remote=NAME] [--timestamp=TS]" [[ -z "$name" ]] && die "Usage: gniza4cp restore list-cron <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file" load_config "$config_file"
@@ -500,7 +500,7 @@ cmd_restore() {
list-dns) list-dns)
local name="${1:-}" local name="${1:-}"
shift 2>/dev/null || true shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore list-dns <name> [--remote=NAME] [--timestamp=TS]" [[ -z "$name" ]] && die "Usage: gniza4cp restore list-dns <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file" load_config "$config_file"
@@ -518,7 +518,7 @@ cmd_restore() {
list-ssl) list-ssl)
local name="${1:-}" local name="${1:-}"
shift 2>/dev/null || true shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore list-ssl <name> [--remote=NAME] [--timestamp=TS]" [[ -z "$name" ]] && die "Usage: gniza4cp restore list-ssl <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file" load_config "$config_file"
@@ -536,7 +536,7 @@ cmd_restore() {
cron) cron)
local name="${1:-}" local name="${1:-}"
shift 2>/dev/null || true shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore cron <name> [--remote=NAME] [--timestamp=TS]" [[ -z "$name" ]] && die "Usage: gniza4cp restore cron <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file" load_config "$config_file"
@@ -554,7 +554,7 @@ cmd_restore() {
dbusers) dbusers)
local name="${1:-}" local name="${1:-}"
local specific_dbuser="${2:-}" local specific_dbuser="${2:-}"
[[ -z "$name" ]] && die "Usage: gniza restore dbusers <name> [<dbuser>] [--remote=NAME] [--timestamp=TS]" [[ -z "$name" ]] && die "Usage: gniza4cp restore dbusers <name> [<dbuser>] [--remote=NAME] [--timestamp=TS]"
shift 2>/dev/null || true shift 2>/dev/null || true
if [[ -n "$specific_dbuser" && "$specific_dbuser" != --* ]]; then if [[ -n "$specific_dbuser" && "$specific_dbuser" != --* ]]; then
shift 2>/dev/null || true shift 2>/dev/null || true
@@ -578,7 +578,7 @@ cmd_restore() {
cpconfig) cpconfig)
local name="${1:-}" local name="${1:-}"
shift 2>/dev/null || true shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore cpconfig <name> [--remote=NAME] [--timestamp=TS]" [[ -z "$name" ]] && die "Usage: gniza4cp restore cpconfig <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE" local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file" load_config "$config_file"
@@ -596,7 +596,7 @@ cmd_restore() {
domains) domains)
local name="${1:-}" local name="${1:-}"
local specific_domain="${2:-}" local specific_domain="${2:-}"
[[ -z "$name" ]] && die "Usage: gniza restore domains <name> [<domain>] [--remote=NAME] [--timestamp=TS]" [[ -z "$name" ]] && die "Usage: gniza4cp restore domains <name> [<domain>] [--remote=NAME] [--timestamp=TS]"
shift 2>/dev/null || true shift 2>/dev/null || true
if [[ -n "$specific_domain" && "$specific_domain" != --* ]]; then if [[ -n "$specific_domain" && "$specific_domain" != --* ]]; then
shift 2>/dev/null || true shift 2>/dev/null || true
@@ -620,7 +620,7 @@ cmd_restore() {
ssl) ssl)
local name="${1:-}" local name="${1:-}"
local specific_cert="${2:-}" local specific_cert="${2:-}"
[[ -z "$name" ]] && die "Usage: gniza restore ssl <name> [<cert>] [--remote=NAME] [--timestamp=TS]" [[ -z "$name" ]] && die "Usage: gniza4cp restore ssl <name> [<cert>] [--remote=NAME] [--timestamp=TS]"
shift 2>/dev/null || true shift 2>/dev/null || true
if [[ -n "$specific_cert" && "$specific_cert" != --* ]]; then if [[ -n "$specific_cert" && "$specific_cert" != --* ]]; then
shift 2>/dev/null || true shift 2>/dev/null || true
@@ -656,7 +656,7 @@ cmd_restore() {
restore_server "$timestamp" restore_server "$timestamp"
;; ;;
*) *)
die "Unknown restore subcommand: $subcommand"$'\n'"Usage: gniza restore {account|files|database|mailbox|cron|dbusers|cpconfig|domains|ssl|list-databases|list-mailboxes|list-files|list-dbusers|list-cron|list-dns|list-ssl|server}" die "Unknown restore subcommand: $subcommand"$'\n'"Usage: gniza4cp restore {account|files|database|mailbox|cron|dbusers|cpconfig|domains|ssl|list-databases|list-mailboxes|list-files|list-dbusers|list-cron|list-dns|list-ssl|server}"
;; ;;
esac esac
} }
@@ -707,7 +707,7 @@ cmd_list() {
shift shift
local remote_flag="" local remote_flag=""
remote_flag=$(get_opt remote "$@" 2>/dev/null) || true remote_flag=$(get_opt remote "$@" 2>/dev/null) || true
[[ -z "$remote_flag" ]] && die "Usage: gniza list accounts --remote=NAME" [[ -z "$remote_flag" ]] && die "Usage: gniza4cp list accounts --remote=NAME"
local remotes; remotes=$(get_target_remotes "$remote_flag") || die "Invalid remote" local remotes; remotes=$(get_target_remotes "$remote_flag") || die "Invalid remote"
local rname; rname=$(head -1 <<< "$remotes") local rname; rname=$(head -1 <<< "$remotes")
_save_remote_globals _save_remote_globals
@@ -847,7 +847,7 @@ cmd_status() {
local hostname; hostname=$(hostname -f) local hostname; hostname=$(hostname -f)
echo "${C_BOLD}gniza v${GNIZA_VERSION}${C_RESET}" echo "${C_BOLD}gniza4cp v${GNIZA4CP_VERSION}${C_RESET}"
echo "" echo ""
echo "Hostname: $hostname" echo "Hostname: $hostname"
echo "Log level: ${LOG_LEVEL}" echo "Log level: ${LOG_LEVEL}"
@@ -881,7 +881,7 @@ cmd_status() {
done <<< "$remotes" done <<< "$remotes"
_restore_remote_globals _restore_remote_globals
else else
echo "No remotes configured. Run 'gniza init remote <name>' to add one." echo "No remotes configured."
fi fi
echo "" echo ""
@@ -902,7 +902,7 @@ cmd_status() {
# Last log # Last log
local log_dir="${LOG_DIR:-$DEFAULT_LOG_DIR}" local log_dir="${LOG_DIR:-$DEFAULT_LOG_DIR}"
echo -n "Last log: " echo -n "Last log: "
local last_log; last_log=$(ls -1t "$log_dir"/gniza-*.log 2>/dev/null | head -1) local last_log; last_log=$(ls -1t "$log_dir"/gniza4cp-*.log 2>/dev/null | head -1)
if [[ -n "$last_log" ]]; then if [[ -n "$last_log" ]]; then
echo "$(basename "$last_log")" echo "$(basename "$last_log")"
else else
@@ -918,7 +918,6 @@ cmd_remote() {
list|ls|"") list|ls|"")
if ! has_remotes; then if ! has_remotes; then
echo "No remotes configured." echo "No remotes configured."
echo "Run 'gniza init remote <name>' to add one."
return 0 return 0
fi fi
@@ -950,7 +949,7 @@ cmd_remote() {
delete|rm|remove) delete|rm|remove)
require_root require_root
local name="${1:-}" local name="${1:-}"
[[ -z "$name" ]] && die "Usage: gniza remote delete <name>" [[ -z "$name" ]] && die "Usage: gniza4cp remote delete <name>"
local conf="$REMOTES_DIR/${name}.conf" local conf="$REMOTES_DIR/${name}.conf"
if [[ ! -f "$conf" ]]; then if [[ ! -f "$conf" ]]; then
@@ -972,7 +971,7 @@ cmd_remote() {
echo "Remote '$name' deleted." echo "Remote '$name' deleted."
;; ;;
*) *)
die "Unknown remote subcommand: $subcommand"$'\n'"Usage: gniza remote {list|delete <name>}" die "Unknown remote subcommand: $subcommand"$'\n'"Usage: gniza4cp remote {list|delete <name>}"
;; ;;
esac esac
} }
@@ -989,17 +988,17 @@ cmd_schedule() {
case "$subcommand" in case "$subcommand" in
add) add)
local name="${1:-}" local name="${1:-}"
[[ -z "$name" ]] && die "Usage: gniza schedule add <name>" [[ -z "$name" ]] && die "Usage: gniza4cp schedule add <name>"
_schedule_add "$name" _schedule_add "$name"
;; ;;
delete|rm|remove-schedule) delete|rm|remove-schedule)
local name="${1:-}" local name="${1:-}"
[[ -z "$name" ]] && die "Usage: gniza schedule delete <name>" [[ -z "$name" ]] && die "Usage: gniza4cp schedule delete <name>"
_schedule_delete "$name" _schedule_delete "$name"
;; ;;
run) run)
local name="${1:-}" local name="${1:-}"
[[ -z "$name" ]] && die "Usage: gniza schedule run <name>" [[ -z "$name" ]] && die "Usage: gniza4cp schedule run <name>"
_schedule_run "$name" _schedule_run "$name"
;; ;;
list|ls) list|ls)
@@ -1008,7 +1007,7 @@ cmd_schedule() {
install) install_schedules ;; install) install_schedules ;;
show) show_schedules ;; show) show_schedules ;;
remove) remove_schedules ;; remove) remove_schedules ;;
*) die "Unknown schedule subcommand: $subcommand"$'\n'"Usage: gniza schedule {add|delete|run|list|install|show|remove}" ;; *) die "Unknown schedule subcommand: $subcommand"$'\n'"Usage: gniza4cp schedule {add|delete|run|list|install|show|remove}" ;;
esac esac
} }
@@ -1027,7 +1026,7 @@ _schedule_add() {
[[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } [[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
fi fi
echo "${C_BOLD}gniza schedule add${C_RESET} — New schedule: ${C_BOLD}$name${C_RESET}" echo "${C_BOLD}gniza4cp schedule add${C_RESET} — New schedule: ${C_BOLD}$name${C_RESET}"
echo "" echo ""
echo "Schedule options: hourly, daily, weekly, monthly, custom" echo "Schedule options: hourly, daily, weekly, monthly, custom"
@@ -1081,9 +1080,9 @@ _schedule_add() {
# Write config # Write config
mkdir -p "$SCHEDULES_DIR" mkdir -p "$SCHEDULES_DIR"
cat > "$config_file" <<CONF cat > "$config_file" <<CONF
# gniza schedule config: $name # gniza4cp schedule config: $name
# Generated by 'gniza schedule add $name' # Generated by 'gniza4cp schedule add $name'
# $(date -u +"%Y-%m-%d %H:%M:%S UTC") # $(date -u +"%d/%m/%Y %H:%M:%S UTC")
SCHEDULE="$sched_type" SCHEDULE="$sched_type"
SCHEDULE_TIME="$sched_time" SCHEDULE_TIME="$sched_time"
@@ -1095,7 +1094,7 @@ CONF
echo "" echo ""
echo "${C_GREEN}Schedule '$name' created: $config_file${C_RESET}" echo "${C_GREEN}Schedule '$name' created: $config_file${C_RESET}"
echo "Run 'gniza schedule install' to activate cron entries." echo "Run 'gniza4cp schedule install' to activate cron entries."
} }
_schedule_delete() { _schedule_delete() {
@@ -1117,7 +1116,7 @@ _schedule_delete() {
rm -f "$config_file" rm -f "$config_file"
echo "Schedule '$name' deleted." echo "Schedule '$name' deleted."
echo "Run 'gniza schedule install' to update cron entries." echo "Run 'gniza4cp schedule install' to update cron entries."
} }
_schedule_run() { _schedule_run() {
@@ -1148,13 +1147,13 @@ _schedule_run() {
echo "" echo ""
# Exec replaces this process with the backup command # Exec replaces this process with the backup command
exec /usr/local/bin/gniza backup "${args[@]}" exec /usr/local/bin/gniza4cp backup "${args[@]}"
} }
_schedule_list() { _schedule_list() {
if ! has_schedules; then if ! has_schedules; then
echo "No schedules configured." echo "No schedules configured."
echo "Run 'gniza schedule add <name>' to create one." echo "Run 'gniza4cp schedule add <name>' to create one."
return 0 return 0
fi fi
@@ -1176,170 +1175,6 @@ _schedule_list() {
echo "" echo ""
} }
cmd_init() {
local subcommand="${1:-}"
if [[ "$subcommand" == "remote" ]]; then
shift
_init_remote "$@"
return
fi
local config_dir="/etc/gniza"
local config_file="$config_dir/gniza.conf"
echo "${C_BOLD}gniza init${C_RESET} — Setup wizard"
echo ""
# Step 1: Create main config with local settings
if [[ -f "$config_file" ]]; then
echo "Config file already exists: $config_file"
read -rp "Overwrite? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || {
echo "Skipping main config."
echo ""
# Still offer to add a remote
echo "Add a remote destination?"
read -rp "Remote name (e.g. nas, offsite): " init_remote_name
if [[ -n "$init_remote_name" ]]; then
_init_remote "$init_remote_name"
fi
return
}
fi
read -rp "Notification email (empty to disable): " init_email
# Create config
mkdir -p "$config_dir"
mkdir -p "$config_dir/remotes.d"
cat > "$config_file" <<CONF
# gniza configuration — generated by 'gniza init'
# $(date -u +"%Y-%m-%d %H:%M:%S UTC")
#
# Remote destinations are configured in /etc/gniza/remotes.d/<name>.conf
# Run 'gniza init remote <name>' to add one.
TEMP_DIR="/usr/local/gniza/workdir"
INCLUDE_ACCOUNTS=""
EXCLUDE_ACCOUNTS="nobody"
LOG_DIR="/var/log/gniza"
LOG_LEVEL="info"
LOG_RETAIN=90
NOTIFY_EMAIL="$init_email"
NOTIFY_ON="failure"
LOCK_FILE="/var/run/gniza.lock"
SSH_TIMEOUT=30
SSH_RETRIES=3
RSYNC_EXTRA_OPTS=""
CONF
echo ""
echo "Config written to $config_file"
# Create log directory
mkdir -p "${LOG_DIR:-$DEFAULT_LOG_DIR}"
# Step 2: Create first remote
echo ""
echo "Now let's set up your first remote destination."
read -rp "Remote name (e.g. nas, offsite): " init_remote_name
[[ -z "$init_remote_name" ]] && die "Remote name is required"
_init_remote "$init_remote_name"
}
_init_remote() {
local name="${1:-}"
[[ -z "$name" ]] && die "Usage: gniza init remote <name>"
# Validate name (alphanumeric, hyphens, underscores)
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
die "Remote name must be alphanumeric (hyphens and underscores allowed): $name"
fi
local config_dir="/etc/gniza/remotes.d"
local config_file="$config_dir/${name}.conf"
echo "${C_BOLD}gniza init remote${C_RESET} — Remote setup: ${C_BOLD}$name${C_RESET}"
echo ""
if [[ -f "$config_file" ]]; then
echo "Remote config already exists: $config_file"
read -rp "Overwrite? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
fi
read -rp "Remote host: " init_host
[[ -z "$init_host" ]] && die "Remote host is required"
read -rp "Remote port [22]: " init_port
init_port="${init_port:-22}"
read -rp "Remote user [root]: " init_user
init_user="${init_user:-root}"
read -rp "SSH key path [/root/.ssh/id_rsa]: " init_key
init_key="${init_key:-/root/.ssh/id_rsa}"
read -rp "Remote base directory [/backups]: " init_base
init_base="${init_base:-/backups}"
read -rp "Retention count [30]: " init_retention
init_retention="${init_retention:-30}"
read -rp "Bandwidth limit in KB/s [0 = unlimited]: " init_bwlimit
init_bwlimit="${init_bwlimit:-0}"
# Create config
mkdir -p "$config_dir"
cat > "$config_file" <<CONF
# gniza remote config: $name
# Generated by 'gniza init remote $name'
# $(date -u +"%Y-%m-%d %H:%M:%S UTC")
REMOTE_HOST="$init_host"
REMOTE_PORT=$init_port
REMOTE_USER="$init_user"
REMOTE_KEY="$init_key"
REMOTE_BASE="$init_base"
BWLIMIT=$init_bwlimit
RETENTION_COUNT=$init_retention
RSYNC_EXTRA_OPTS=""
CONF
echo ""
echo "Remote config written to $config_file"
echo ""
# Test SSH
# Load main config first for defaults, then load remote
local main_config="/etc/gniza/gniza.conf"
if [[ -f "$main_config" ]]; then
load_config "$main_config"
fi
load_remote "$name"
echo "Testing SSH connection to $name..."
if test_ssh_connection 2>/dev/null; then
echo "${C_GREEN}SSH connection successful!${C_RESET}"
echo "Creating remote base directory..."
ensure_remote_dir "${REMOTE_BASE}/$(hostname -f)/accounts"
echo "${C_GREEN}Remote directory created.${C_RESET}"
else
echo "${C_YELLOW}SSH connection failed. Check your settings in $config_file${C_RESET}"
fi
echo ""
echo "${C_GREEN}Remote '$name' configured!${C_RESET}"
echo "Run 'gniza schedule add <name>' to set up a backup schedule."
echo "Run 'gniza backup --remote=$name --dry-run' to test."
}
# ── System Backup / Restore ─────────────────────────────────── # ── System Backup / Restore ───────────────────────────────────
# Transfer + finalize + retention for system backup on the current remote. # Transfer + finalize + retention for system backup on the current remote.
@@ -1373,6 +1208,7 @@ cmd_sysbackup() {
load_config "$config_file" load_config "$config_file"
validate_config || die "Invalid configuration" validate_config || die "Invalid configuration"
init_logging init_logging
log_info "[TYPE:SYSBACKUP] System backup started"
local dry_run=false local dry_run=false
has_flag dry-run "$@" && dry_run=true has_flag dry-run "$@" && dry_run=true
@@ -1609,7 +1445,7 @@ cmd_stats() {
local last_log="" local last_log=""
local latest_log="" local latest_log=""
if [[ -d "$log_dir" ]]; then if [[ -d "$log_dir" ]]; then
latest_log=$(ls -1t "$log_dir"/gniza-[0-9]*-[0-9]*.log 2>/dev/null | head -1) || true latest_log=$(ls -1t "$log_dir"/gniza4cp-[0-9]*-[0-9]*.log 2>/dev/null | head -1) || true
fi fi
if [[ -n "$latest_log" && -f "$latest_log" ]]; then if [[ -n "$latest_log" && -f "$latest_log" ]]; then
last_log=$(basename "$latest_log") last_log=$(basename "$latest_log")
@@ -1621,7 +1457,7 @@ cmd_stats() {
fi fi
# Build JSON # Build JSON
local updated; updated=$(date -u +"%Y-%m-%dT%H%M%S") local updated; updated=$(date -u +"%d/%m/%Y %H:%M:%S")
local remotes_json; remotes_json=$(IFS=','; echo "${remote_json_parts[*]}") local remotes_json; remotes_json=$(IFS=','; echo "${remote_json_parts[*]}")
local json="{\"updated\":\"$updated\",\"backed_up_accounts\":$total_accounts,\"snapshots\":$total_snapshots,\"remotes\":{$remotes_json},\"last_backup\":{\"status\":\"$last_status\",\"log\":\"$last_log\"}}" local json="{\"updated\":\"$updated\",\"backed_up_accounts\":$total_accounts,\"snapshots\":$total_snapshots,\"remotes\":{$remotes_json},\"last_backup\":{\"status\":\"$last_status\",\"log\":\"$last_log\"}}"
@@ -1632,10 +1468,10 @@ cmd_stats() {
cmd_usage() { cmd_usage() {
cat <<EOF cat <<EOF
${C_BOLD}gniza v${GNIZA_VERSION}${C_RESET} — cPanel Backup, Restore & Disaster Recovery ${C_BOLD}gniza4cp v${GNIZA4CP_VERSION}${C_RESET} — cPanel Backup, Restore & Disaster Recovery
${C_BOLD}Usage:${C_RESET} ${C_BOLD}Usage:${C_RESET}
gniza <command> [options] gniza4cp <command> [options]
${C_BOLD}Commands:${C_RESET} ${C_BOLD}Commands:${C_RESET}
backup [--account=NAME] [--remote=NAME[,NAME2]] [--dry-run] [--sysbackup] [--skip-suspended] backup [--account=NAME] [--remote=NAME[,NAME2]] [--dry-run] [--sysbackup] [--skip-suspended]
@@ -1665,34 +1501,30 @@ ${C_BOLD}Commands:${C_RESET}
schedule list Show configured schedules schedule list Show configured schedules
schedule {install|show|remove} Manage cron entries schedule {install|show|remove} Manage cron entries
stats Collect backup statistics stats Collect backup statistics
init Setup config + first remote
init remote <name> Add a remote destination
version Show version version Show version
${C_BOLD}Global Options:${C_RESET} ${C_BOLD}Global Options:${C_RESET}
--config=PATH Use alternate config file (default: /etc/gniza/gniza.conf) --config=PATH Use alternate config file (default: /etc/gniza4cp/gniza4cp.conf)
--remote=NAME Target specific remote(s), comma-separated --remote=NAME Target specific remote(s), comma-separated
--debug Enable debug logging --debug Enable debug logging
${C_BOLD}Examples:${C_RESET} ${C_BOLD}Examples:${C_RESET}
gniza init gniza4cp backup --dry-run
gniza backup --dry-run gniza4cp backup --account=johndoe
gniza backup --account=johndoe gniza4cp backup --remote=nas
gniza backup --remote=nas gniza4cp backup --remote=nas,offsite
gniza backup --remote=nas,offsite gniza4cp list --remote=offsite
gniza list --remote=offsite gniza4cp restore files johndoe --remote=nas --path=public_html
gniza restore files johndoe --remote=nas --path=public_html gniza4cp restore database johndoe johndoe_wp --remote=nas
gniza restore database johndoe johndoe_wp --remote=nas gniza4cp restore mailbox johndoe info@example.com --remote=nas
gniza restore mailbox johndoe info@example.com --remote=nas gniza4cp schedule add nightly
gniza schedule add nightly gniza4cp schedule list
gniza schedule list gniza4cp schedule install
gniza schedule install gniza4cp remote list
gniza remote list gniza4cp sysbackup --dry-run
gniza init remote nas gniza4cp sysbackup --remote=nas
gniza sysbackup --dry-run gniza4cp sysrestore --remote=nas
gniza sysbackup --remote=nas gniza4cp sysrestore --remote=nas --phase=1 --dry-run
gniza sysrestore --remote=nas
gniza sysrestore --remote=nas --phase=1 --dry-run
EOF EOF
} }
@@ -1701,7 +1533,7 @@ EOF
main() { main() {
# Global --debug flag (used by config.sh load_config) # Global --debug flag (used by config.sh load_config)
# shellcheck disable=SC2034 # shellcheck disable=SC2034
has_flag debug "$@" && GNIZA_DEBUG=true || GNIZA_DEBUG=false has_flag debug "$@" && GNIZA4CP_DEBUG=true || GNIZA4CP_DEBUG=false
local command="${1:-}" local command="${1:-}"
shift 2>/dev/null || true shift 2>/dev/null || true
@@ -1717,11 +1549,10 @@ main() {
remote) cmd_remote "$@" ;; remote) cmd_remote "$@" ;;
schedule) cmd_schedule "$@" ;; schedule) cmd_schedule "$@" ;;
stats) cmd_stats "$@" ;; stats) cmd_stats "$@" ;;
init) cmd_init "$@" ;; version) echo "gniza4cp v${GNIZA4CP_VERSION}" ;;
version) echo "gniza v${GNIZA_VERSION}" ;;
help|-h|--help) cmd_usage ;; help|-h|--help) cmd_usage ;;
"") cmd_usage ;; "") cmd_usage ;;
*) die "Unknown command: $command"$'\n'"Run 'gniza help' for usage" ;; *) die "Unknown command: $command"$'\n'"Run 'gniza4cp help' for usage" ;;
esac esac
} }

View File

@@ -1,413 +0,0 @@
#!/usr/local/cpanel/3rdparty/bin/perl
package Cpanel::AdminBin::Script::Call::Gniza::Restore;
use strict;
use warnings;
use parent 'Cpanel::AdminBin::Script::Call';
use IPC::Open3;
use Symbol 'gensym';
my $GNIZA_BIN = '/usr/local/bin/gniza';
my $MAIN_CONFIG = '/etc/gniza/gniza.conf';
my $REMOTES_DIR = '/etc/gniza/remotes.d';
# Argument validation patterns (mirrors GnizaWHM::Runner)
my %OPT_PATTERNS = (
remote => qr/^[a-zA-Z0-9_,-]+$/,
timestamp => qr/^\d{4}-\d{2}-\d{2}T\d{6}$/,
path => qr/^(?!.*\.\.)[a-zA-Z0-9_.\/@ -]+$/,
exclude => qr/^[a-zA-Z0-9_.,\/@ *?\[\]-]+$/,
);
my $ACCOUNT_RE = qr/^[a-z][a-z0-9_-]*$/;
my $REMOTE_RE = qr/^[a-zA-Z0-9_-]+$/;
my $DBNAME_RE = qr/^[a-zA-Z0-9_]+$/;
my $EMAIL_RE = qr/^[a-zA-Z0-9._+-]+\@[a-zA-Z0-9._-]+$/;
my $DOMAIN_RE = qr/^[a-zA-Z0-9._-]+$/;
my $TS_RE = qr/^\d{4}-\d{2}-\d{2}T\d{6}$/;
# ── Allowed remotes for user restore ──────────────────────────
sub _get_allowed_remotes {
my $setting = '';
if (open my $fh, '<', $MAIN_CONFIG) {
while (my $line = <$fh>) {
if ($line =~ /^USER_RESTORE_REMOTES=(?:"([^"]*)"|'([^']*)'|(\S*))$/) {
$setting = defined $1 ? $1 : (defined $2 ? $2 : ($3 // ''));
}
}
close $fh;
}
# Default to "all" if not set
$setting = 'all' if !defined $setting || $setting eq '';
return $setting;
}
sub _list_all_remotes {
my @remotes;
if (-d $REMOTES_DIR && opendir my $dh, $REMOTES_DIR) {
while (my $entry = readdir $dh) {
if ($entry =~ /^([a-zA-Z0-9_-]+)\.conf$/) {
push @remotes, $1;
}
}
closedir $dh;
}
return sort @remotes;
}
sub _is_remote_allowed {
my ($remote) = @_;
my $setting = _get_allowed_remotes();
return 0 if $setting eq ''; # disabled
if ($setting eq 'all') {
# Check it actually exists
return -f "$REMOTES_DIR/$remote.conf" ? 1 : 0;
}
my %allowed = map { $_ => 1 } split /,/, $setting;
return $allowed{$remote} ? 1 : 0;
}
sub _get_filtered_remotes {
my $setting = _get_allowed_remotes();
return () if $setting eq '';
my @all = _list_all_remotes();
return @all if $setting eq 'all';
my %allowed = map { $_ => 1 } split /,/, $setting;
return grep { $allowed{$_} } @all;
}
# ── Command execution ─────────────────────────────────────────
sub _run_gniza {
my (@args) = @_;
my $err_fh = gensym;
my ($in, $out);
my $pid = eval { open3($in, $out, $err_fh, $GNIZA_BIN, @args) };
unless ($pid) {
return (0, '', "Failed to execute gniza: $@");
}
close $in if $in;
my $stdout = do { local $/; <$out> } // '';
my $stderr = do { local $/; <$err_fh> } // '';
close $out;
close $err_fh;
waitpid($pid, 0);
my $exit_code = $? >> 8;
return ($exit_code == 0, $stdout, $stderr);
}
# ── Action dispatch ───────────────────────────────────────────
sub _actions {
return qw(
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
);
}
sub LIST_ALLOWED_REMOTES {
my ($self) = @_;
my @remotes = _get_filtered_remotes();
return join("\n", @remotes);
}
sub LIST_SNAPSHOTS {
my ($self, $remote) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza('list', "--remote=$remote", "--account=$user");
return $ok ? $stdout : "ERROR: $stderr";
}
sub LIST_DATABASES {
my ($self, $remote, $timestamp) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-databases', $user,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? $stdout : "ERROR: $stderr";
}
sub LIST_MAILBOXES {
my ($self, $remote, $timestamp) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-mailboxes', $user,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? $stdout : "ERROR: $stderr";
}
sub LIST_FILES {
my ($self, $remote, $timestamp, $path) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @opts = ("--remote=$remote", "--timestamp=$timestamp");
if (defined $path && $path ne '') {
return "ERROR: Invalid path" unless $path =~ $OPT_PATTERNS{path};
push @opts, "--path=$path";
}
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-files', $user, @opts);
return $ok ? $stdout : "ERROR: $stderr";
}
sub LIST_DBUSERS {
my ($self, $remote, $timestamp) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-dbusers', $user,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? $stdout : "ERROR: $stderr";
}
sub LIST_CRON {
my ($self, $remote, $timestamp) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-cron', $user,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? $stdout : "ERROR: $stderr";
}
sub LIST_DNS {
my ($self, $remote, $timestamp) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-dns', $user,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? $stdout : "ERROR: $stderr";
}
sub LIST_SSL {
my ($self, $remote, $timestamp) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-ssl', $user,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? $stdout : "ERROR: $stderr";
}
sub RESTORE_ACCOUNT {
my ($self, $remote, $timestamp, $exclude) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @opts = ("--remote=$remote", "--timestamp=$timestamp");
# NOTE: --terminate is NEVER passed for user restore
if (defined $exclude && $exclude ne '') {
return "ERROR: Invalid exclude" unless $exclude =~ $OPT_PATTERNS{exclude};
push @opts, "--exclude=$exclude";
}
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'account', $user, @opts);
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
sub RESTORE_FILES {
my ($self, $remote, $timestamp, $path, $exclude) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @opts = ("--remote=$remote", "--timestamp=$timestamp");
if (defined $path && $path ne '') {
return "ERROR: Invalid path" unless $path =~ $OPT_PATTERNS{path};
push @opts, "--path=$path";
}
if (defined $exclude && $exclude ne '') {
return "ERROR: Invalid exclude" unless $exclude =~ $OPT_PATTERNS{exclude};
push @opts, "--exclude=$exclude";
}
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'files', $user, @opts);
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
sub RESTORE_DATABASE {
my ($self, $remote, $timestamp, $dbname) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @args = ($user);
if (defined $dbname && $dbname ne '') {
return "ERROR: Invalid database name" unless $dbname =~ $DBNAME_RE;
push @args, $dbname;
}
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'database', @args,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
sub RESTORE_MAILBOX {
my ($self, $remote, $timestamp, $email) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @args = ($user);
if (defined $email && $email ne '') {
return "ERROR: Invalid email" unless $email =~ $EMAIL_RE;
push @args, $email;
}
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'mailbox', @args,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
sub RESTORE_CRON {
my ($self, $remote, $timestamp) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'cron', $user,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
sub RESTORE_DBUSERS {
my ($self, $remote, $timestamp, $dbuser) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @args = ($user);
if (defined $dbuser && $dbuser ne '') {
return "ERROR: Invalid database user" unless $dbuser =~ $DBNAME_RE;
push @args, $dbuser;
}
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'dbusers', @args,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
sub RESTORE_DOMAINS {
my ($self, $remote, $timestamp, $domain) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @args = ($user);
if (defined $domain && $domain ne '') {
return "ERROR: Invalid domain" unless $domain =~ $DOMAIN_RE;
push @args, $domain;
}
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'domains', @args,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
sub RESTORE_SSL {
my ($self, $remote, $timestamp, $domain) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @args = ($user);
if (defined $domain && $domain ne '') {
return "ERROR: Invalid domain" unless $domain =~ $DOMAIN_RE;
push @args, $domain;
}
my ($ok, $stdout, $stderr) = _run_gniza('restore', 'ssl', @args,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
__PACKAGE__->run() if !caller;
1;

View File

@@ -0,0 +1,773 @@
#!/usr/local/cpanel/3rdparty/bin/perl
package Cpanel::AdminBin::Script::Call::Gniza4cp::Restore;
use strict;
use warnings;
use parent 'Cpanel::AdminBin::Script::Call';
use IPC::Open3;
use Symbol 'gensym';
use POSIX qw(setsid);
my $GNIZA4CP_BIN = '/usr/local/bin/gniza4cp';
my $MAIN_CONFIG = '/etc/gniza4cp/gniza4cp.conf';
my $REMOTES_DIR = '/etc/gniza4cp/remotes.d';
# Argument validation patterns (mirrors Gniza4cpWHM::Runner)
my %OPT_PATTERNS = (
remote => qr/^[a-zA-Z0-9_,-]+$/,
timestamp => qr/^\d{4}-\d{2}-\d{2}T\d{6}$/,
path => qr/^(?!.*\.\.)[a-zA-Z0-9_.\/@ -]+$/,
exclude => qr/^[a-zA-Z0-9_.,\/@ *?\[\]-]+$/,
);
my $ACCOUNT_RE = qr/^[a-z][a-z0-9_-]*$/;
my $REMOTE_RE = qr/^[a-zA-Z0-9_-]+$/;
my $DBNAME_RE = qr/^[a-zA-Z0-9_]+$/;
my $EMAIL_RE = qr/^[a-zA-Z0-9._+-]+\@[a-zA-Z0-9._-]+$/;
my $DOMAIN_RE = qr/^[a-zA-Z0-9._-]+$/;
my $TS_RE = qr/^\d{4}-\d{2}-\d{2}T\d{6}$/;
# ── Allowed remotes for user restore ──────────────────────────
sub _get_allowed_remotes {
my $setting = '';
if (open my $fh, '<', $MAIN_CONFIG) {
while (my $line = <$fh>) {
if ($line =~ /^USER_RESTORE_REMOTES=(?:"([^"]*)"|'([^']*)'|(\S*))$/) {
$setting = defined $1 ? $1 : (defined $2 ? $2 : ($3 // ''));
}
}
close $fh;
}
# Default to "all" if not set
$setting = 'all' if !defined $setting || $setting eq '';
return $setting;
}
sub _list_all_remotes {
my @remotes;
if (-d $REMOTES_DIR && opendir my $dh, $REMOTES_DIR) {
while (my $entry = readdir $dh) {
if ($entry =~ /^([a-zA-Z0-9_-]+)\.conf$/) {
push @remotes, $1;
}
}
closedir $dh;
}
return sort @remotes;
}
sub _is_remote_allowed {
my ($remote) = @_;
my $setting = _get_allowed_remotes();
return 0 if $setting eq ''; # disabled
if ($setting eq 'all') {
# Check it actually exists
return -f "$REMOTES_DIR/$remote.conf" ? 1 : 0;
}
my %allowed = map { $_ => 1 } split /,/, $setting;
return $allowed{$remote} ? 1 : 0;
}
sub _get_filtered_remotes {
my $setting = _get_allowed_remotes();
return () if $setting eq '';
my @all = _list_all_remotes();
return @all if $setting eq 'all';
my %allowed = map { $_ => 1 } split /,/, $setting;
return grep { $allowed{$_} } @all;
}
# ── Command execution ─────────────────────────────────────────
sub _run_gniza4cp {
my (@args) = @_;
my $err_fh = gensym;
my ($in, $out);
my $pid = eval { open3($in, $out, $err_fh, $GNIZA4CP_BIN, @args) };
unless ($pid) {
return (0, '', "Failed to execute gniza4cp: $@");
}
close $in if $in;
my $stdout = do { local $/; <$out> } // '';
my $stderr = do { local $/; <$err_fh> } // '';
close $out;
close $err_fh;
waitpid($pid, 0);
my $exit_code = $? >> 8;
return ($exit_code == 0, $stdout, $stderr);
}
# ── Action dispatch ───────────────────────────────────────────
# ── Per-user activity logging ─────────────────────────────────
my $ACTIVITY_ENTRY_RE = qr/^[0-9]+$/;
sub _get_log_dir {
my $log_dir = '/var/log/gniza4cp';
if (open my $fh, '<', $MAIN_CONFIG) {
while (my $line = <$fh>) {
if ($line =~ /^LOG_DIR=(?:"([^"]*)"|'([^']*)'|(\S*))$/) {
my $val = defined $1 ? $1 : (defined $2 ? $2 : ($3 // ''));
$log_dir = $val if $val ne '';
}
}
close $fh;
}
return $log_dir;
}
sub _activity_log_path {
my ($user) = @_;
my $log_dir = _get_log_dir();
return "$log_dir/cpanel-$user.log";
}
my %ACTION_LABELS = (
RESTORE_ACCOUNT => 'Full Account',
RESTORE_FILES => 'Home Directory',
RESTORE_DATABASE => 'Database',
RESTORE_MAILBOX => 'Email',
RESTORE_CRON => 'Cron Jobs',
RESTORE_DBUSERS => 'DB Users',
RESTORE_DOMAINS => 'Domains',
RESTORE_SSL => 'SSL Certificates',
);
sub _log_activity {
my ($user, $action, $details, $status, $output) = @_;
my $log_file = _activity_log_path($user);
my $log_dir = _get_log_dir();
mkdir $log_dir, 0700 unless -d $log_dir;
my @t = gmtime(time);
my $ts = sprintf('%04d-%02d-%02d %02d:%02d:%02d',
$t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1], $t[0]);
my $label = $ACTION_LABELS{$action} // $action;
if (open my $fh, '>>', $log_file) {
print $fh "--- ENTRY ---\n";
print $fh "Date: $ts\n";
print $fh "Action: $label\n";
print $fh "Details: $details\n";
print $fh "Status: $status\n";
print $fh $output if defined $output && $output ne '';
print $fh "--- END ---\n";
close $fh;
}
}
sub _actions {
return qw(
LIST_ALLOWED_REMOTES
LIST_SNAPSHOTS
LIST_DATABASES
LIST_MAILBOXES
LIST_FILES
LIST_DBUSERS
LIST_CRON
LIST_DNS
LIST_SSL
LIST_LOGS
GET_LOG
START_RESTORE
RESTORE_ACCOUNT
RESTORE_FILES
RESTORE_DATABASE
RESTORE_MAILBOX
RESTORE_CRON
RESTORE_DBUSERS
RESTORE_DOMAINS
RESTORE_SSL
);
}
sub LIST_LOGS {
my ($self) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
my $log_file = _activity_log_path($user);
return '' unless -f $log_file && !-l $log_file;
# Parse entries from the activity log (newest first)
my @entries;
if (open my $fh, '<', $log_file) {
my $in_entry = 0;
my %cur;
while (my $line = <$fh>) {
chomp $line;
if ($line eq '--- ENTRY ---') {
$in_entry = 1;
%cur = ();
} elsif ($line eq '--- END ---' && $in_entry) {
push @entries, { %cur } if $cur{date};
$in_entry = 0;
} elsif ($in_entry) {
if ($line =~ /^Date:\s+(.+)$/) { $cur{date} = $1; }
elsif ($line =~ /^Action:\s+(.+)$/) { $cur{action} = $1; }
elsif ($line =~ /^Details:\s+(.+)$/) { $cur{details} = $1; }
elsif ($line =~ /^Status:\s+(.+)$/) { $cur{status} = $1; }
}
}
close $fh;
}
# Return newest first, one line per entry: index\tdate\taction\tdetails\tstatus
my @lines;
for my $i (reverse 0 .. $#entries) {
my $e = $entries[$i];
push @lines, join("\t", $i, $e->{date} // '', $e->{action} // '',
$e->{details} // '', $e->{status} // '');
}
return join("\n", @lines);
}
sub GET_LOG {
my ($self, $entry_idx) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid entry" unless defined $entry_idx && $entry_idx =~ $ACTIVITY_ENTRY_RE;
my $log_file = _activity_log_path($user);
return "ERROR: No activity log" unless -f $log_file && !-l $log_file;
# Parse the Nth entry
my $idx = int($entry_idx);
my @entries;
if (open my $fh, '<', $log_file) {
my $in_entry = 0;
my @cur_lines;
while (my $line = <$fh>) {
chomp $line;
if ($line eq '--- ENTRY ---') {
$in_entry = 1;
@cur_lines = ();
} elsif ($line eq '--- END ---' && $in_entry) {
push @entries, join("\n", @cur_lines);
$in_entry = 0;
} elsif ($in_entry) {
push @cur_lines, $line;
}
}
close $fh;
}
return "ERROR: Entry not found" if $idx < 0 || $idx > $#entries;
return $entries[$idx];
}
sub LIST_ALLOWED_REMOTES {
my ($self) = @_;
my @remotes = _get_filtered_remotes();
return join("\n", @remotes);
}
sub LIST_SNAPSHOTS {
my ($self, $remote) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza4cp('list', "--remote=$remote", "--account=$user");
return $ok ? $stdout : "ERROR: $stderr";
}
sub LIST_DATABASES {
my ($self, $remote, $timestamp) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'list-databases', $user,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? $stdout : "ERROR: $stderr";
}
sub LIST_MAILBOXES {
my ($self, $remote, $timestamp) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'list-mailboxes', $user,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? $stdout : "ERROR: $stderr";
}
sub LIST_FILES {
my ($self, $remote, $timestamp, $path) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @opts = ("--remote=$remote", "--timestamp=$timestamp");
if (defined $path && $path ne '') {
return "ERROR: Invalid path" unless $path =~ $OPT_PATTERNS{path};
push @opts, "--path=$path";
}
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'list-files', $user, @opts);
return $ok ? $stdout : "ERROR: $stderr";
}
sub LIST_DBUSERS {
my ($self, $remote, $timestamp) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'list-dbusers', $user,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? $stdout : "ERROR: $stderr";
}
sub LIST_CRON {
my ($self, $remote, $timestamp) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'list-cron', $user,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? $stdout : "ERROR: $stderr";
}
sub LIST_DNS {
my ($self, $remote, $timestamp) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'list-dns', $user,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? $stdout : "ERROR: $stderr";
}
sub LIST_SSL {
my ($self, $remote, $timestamp) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'list-ssl', $user,
"--remote=$remote", "--timestamp=$timestamp");
return $ok ? $stdout : "ERROR: $stderr";
}
# ── Background restore ────────────────────────────────────────
#
# START_RESTORE($remote, $timestamp, $types_str, $path, $exclude_paths)
#
# $types_str encodes selected types and items, semicolon-separated:
# account;files;database:db1,db2;mailbox:a@b.com;cron;dbusers:u1;domains:d1;ssl:d1
#
# Validates all inputs, forks a detached child that runs each gniza4cp
# restore command and logs results via _log_activity(), then returns
# immediately with "OK".
my %TYPE_ITEM_RE = (
account => undef, # no items
files => undef, # uses $path arg instead
cron => undef, # no items
database => $DBNAME_RE,
dbusers => $DBNAME_RE,
mailbox => $EMAIL_RE,
domains => $DOMAIN_RE,
ssl => $DOMAIN_RE,
);
# Map type + item to gniza4cp CLI arguments (excluding --remote/--timestamp which are always added)
sub _build_restore_args {
my ($type, $user, $item, $path, $exclude) = @_;
if ($type eq 'account') {
my @args = ('restore', 'account', $user);
push @args, "--exclude=$exclude" if defined $exclude && $exclude ne '';
return @args;
}
elsif ($type eq 'files') {
my @args = ('restore', 'files', $user);
push @args, "--path=$path" if defined $path && $path ne '';
push @args, "--exclude=$exclude" if defined $exclude && $exclude ne '';
return @args;
}
elsif ($type eq 'cron') {
return ('restore', 'cron', $user);
}
elsif ($type eq 'database') {
my @args = ('restore', 'database', $user);
push @args, $item if defined $item && $item ne '';
return @args;
}
elsif ($type eq 'dbusers') {
my @args = ('restore', 'dbusers', $user);
push @args, $item if defined $item && $item ne '';
return @args;
}
elsif ($type eq 'mailbox') {
my @args = ('restore', 'mailbox', $user);
push @args, $item if defined $item && $item ne '';
return @args;
}
elsif ($type eq 'domains') {
my @args = ('restore', 'domains', $user);
push @args, $item if defined $item && $item ne '';
return @args;
}
elsif ($type eq 'ssl') {
my @args = ('restore', 'ssl', $user);
push @args, $item if defined $item && $item ne '';
return @args;
}
return ();
}
# Map type to RESTORE_* action name for _log_activity
my %TYPE_ACTION_MAP = (
account => 'RESTORE_ACCOUNT',
files => 'RESTORE_FILES',
database => 'RESTORE_DATABASE',
dbusers => 'RESTORE_DBUSERS',
mailbox => 'RESTORE_MAILBOX',
cron => 'RESTORE_CRON',
domains => 'RESTORE_DOMAINS',
ssl => 'RESTORE_SSL',
);
sub START_RESTORE {
my ($self, $remote, $timestamp, $types_str, $path, $exclude_paths) = @_;
my $user = $self->get_caller_username() // '';
# ── Validate common args ──
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
if (defined $path && $path ne '') {
return "ERROR: Invalid path" unless $path =~ $OPT_PATTERNS{path};
}
if (defined $exclude_paths && $exclude_paths ne '') {
return "ERROR: Invalid exclude" unless $exclude_paths =~ $OPT_PATTERNS{exclude};
}
# ── Parse and validate types_str ──
$types_str //= '';
return "ERROR: No restore types specified" if $types_str eq '';
my @jobs; # [ { type => ..., items => [...] }, ... ]
for my $part (split /;/, $types_str) {
next if $part eq '';
my ($type, $items_str) = split /:/, $part, 2;
return "ERROR: Invalid restore type: $type" unless exists $TYPE_ITEM_RE{$type};
my $item_re = $TYPE_ITEM_RE{$type};
my @items;
if (defined $items_str && $items_str ne '') {
return "ERROR: Type '$type' does not accept items" unless defined $item_re;
for my $item (split /,/, $items_str) {
next if $item eq '';
return "ERROR: Invalid item for $type: $item" unless $item =~ $item_re;
push @items, $item;
return "ERROR: Too many items for $type (max 100)" if @items > 100;
}
}
push @jobs, { type => $type, items => \@items };
}
return "ERROR: No valid restore types parsed" unless @jobs;
# ── Pre-build all command arg lists to validate before forking ──
my @cmd_list; # [ { args => [...], action => ..., details => ... }, ... ]
for my $job (@jobs) {
my $type = $job->{type};
my @items = @{$job->{items}};
if (@items) {
# One command per item
for my $item (@items) {
my @args = _build_restore_args($type, $user, $item, $path, $exclude_paths);
return "ERROR: Failed to build command for $type" unless @args;
my $details = "remote=$remote snapshot=$timestamp";
if ($type eq 'database') { $details .= " database=$item"; }
elsif ($type eq 'dbusers') { $details .= " dbuser=$item"; }
elsif ($type eq 'mailbox') { $details .= " email=$item"; }
elsif ($type eq 'domains') { $details .= " domain=$item"; }
elsif ($type eq 'ssl') { $details .= " domain=$item"; }
push @cmd_list, { args => \@args, action => $TYPE_ACTION_MAP{$type}, details => $details };
}
} else {
# Single command for this type
my @args = _build_restore_args($type, $user, '', $path, $exclude_paths);
return "ERROR: Failed to build command for $type" unless @args;
my $details = "remote=$remote snapshot=$timestamp";
$details .= " path=$path" if $type eq 'files' && defined $path && $path ne '';
$details .= " exclude=$exclude_paths" if defined $exclude_paths && $exclude_paths ne '';
push @cmd_list, { args => \@args, action => $TYPE_ACTION_MAP{$type}, details => $details };
}
}
# ── Fork detached child ──
local $SIG{CHLD} = 'IGNORE';
my $pid = fork();
return "ERROR: Fork failed: $!" unless defined $pid;
if ($pid == 0) {
# Child: detach from parent completely
eval {
setsid();
open STDIN, '<', '/dev/null';
open STDOUT, '>', '/dev/null';
open STDERR, '>', '/dev/null';
for my $cmd (@cmd_list) {
my @full_args = (@{$cmd->{args}}, "--remote=$remote", "--timestamp=$timestamp");
my ($ok, $stdout, $stderr) = _run_gniza4cp(@full_args);
_log_activity($user, $cmd->{action}, $cmd->{details},
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
}
};
# Ensure child exits even on error
POSIX::_exit(0);
}
# Parent: return immediately
return "OK";
}
sub RESTORE_ACCOUNT {
my ($self, $remote, $timestamp, $exclude) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @opts = ("--remote=$remote", "--timestamp=$timestamp");
# NOTE: --terminate is NEVER passed for user restore
if (defined $exclude && $exclude ne '') {
return "ERROR: Invalid exclude" unless $exclude =~ $OPT_PATTERNS{exclude};
push @opts, "--exclude=$exclude";
}
my $details = "remote=$remote snapshot=$timestamp";
$details .= " exclude=$exclude" if defined $exclude && $exclude ne '';
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'account', $user, @opts);
_log_activity($user, 'RESTORE_ACCOUNT', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
sub RESTORE_FILES {
my ($self, $remote, $timestamp, $path, $exclude) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @opts = ("--remote=$remote", "--timestamp=$timestamp");
if (defined $path && $path ne '') {
return "ERROR: Invalid path" unless $path =~ $OPT_PATTERNS{path};
push @opts, "--path=$path";
}
if (defined $exclude && $exclude ne '') {
return "ERROR: Invalid exclude" unless $exclude =~ $OPT_PATTERNS{exclude};
push @opts, "--exclude=$exclude";
}
my $details = "remote=$remote snapshot=$timestamp";
$details .= " path=$path" if defined $path && $path ne '';
$details .= " exclude=$exclude" if defined $exclude && $exclude ne '';
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'files', $user, @opts);
_log_activity($user, 'RESTORE_FILES', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
sub RESTORE_DATABASE {
my ($self, $remote, $timestamp, $dbname) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @args = ($user);
if (defined $dbname && $dbname ne '') {
return "ERROR: Invalid database name" unless $dbname =~ $DBNAME_RE;
push @args, $dbname;
}
my $details = "remote=$remote snapshot=$timestamp";
$details .= " database=$dbname" if defined $dbname && $dbname ne '';
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'database', @args,
"--remote=$remote", "--timestamp=$timestamp");
_log_activity($user, 'RESTORE_DATABASE', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
sub RESTORE_MAILBOX {
my ($self, $remote, $timestamp, $email) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @args = ($user);
if (defined $email && $email ne '') {
return "ERROR: Invalid email" unless $email =~ $EMAIL_RE;
push @args, $email;
}
my $details = "remote=$remote snapshot=$timestamp";
$details .= " email=$email" if defined $email && $email ne '';
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'mailbox', @args,
"--remote=$remote", "--timestamp=$timestamp");
_log_activity($user, 'RESTORE_MAILBOX', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
sub RESTORE_CRON {
my ($self, $remote, $timestamp) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my $details = "remote=$remote snapshot=$timestamp";
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'cron', $user,
"--remote=$remote", "--timestamp=$timestamp");
_log_activity($user, 'RESTORE_CRON', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
sub RESTORE_DBUSERS {
my ($self, $remote, $timestamp, $dbuser) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @args = ($user);
if (defined $dbuser && $dbuser ne '') {
return "ERROR: Invalid database user" unless $dbuser =~ $DBNAME_RE;
push @args, $dbuser;
}
my $details = "remote=$remote snapshot=$timestamp";
$details .= " dbuser=$dbuser" if defined $dbuser && $dbuser ne '';
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'dbusers', @args,
"--remote=$remote", "--timestamp=$timestamp");
_log_activity($user, 'RESTORE_DBUSERS', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
sub RESTORE_DOMAINS {
my ($self, $remote, $timestamp, $domain) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @args = ($user);
if (defined $domain && $domain ne '') {
return "ERROR: Invalid domain" unless $domain =~ $DOMAIN_RE;
push @args, $domain;
}
my $details = "remote=$remote snapshot=$timestamp";
$details .= " domain=$domain" if defined $domain && $domain ne '';
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'domains', @args,
"--remote=$remote", "--timestamp=$timestamp");
_log_activity($user, 'RESTORE_DOMAINS', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
sub RESTORE_SSL {
my ($self, $remote, $timestamp, $domain) = @_;
my $user = $self->get_caller_username() // '';
return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);
my @args = ($user);
if (defined $domain && $domain ne '') {
return "ERROR: Invalid domain" unless $domain =~ $DOMAIN_RE;
push @args, $domain;
}
my $details = "remote=$remote snapshot=$timestamp";
$details .= " domain=$domain" if defined $domain && $domain ne '';
my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'ssl', @args,
"--remote=$remote", "--timestamp=$timestamp");
_log_activity($user, 'RESTORE_SSL', $details,
$ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}
__PACKAGE__->run() if !caller;
1;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 369 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +0,0 @@
[
{
"type": "link",
"id": "gniza",
"name": "GNIZA Backups",
"group_id": "files",
"description": "Restore files, databases, email, and more from gniza backups",
"uri": "gniza/index.live.cgi",
"feature": "gniza_restore",
"order": 1,
"icon": "gniza/assets/gniza-cpanel-icon.png"
}
]

View File

Before

Width:  |  Height:  |  Size: 720 B

After

Width:  |  Height:  |  Size: 720 B

View File

Before

Width:  |  Height:  |  Size: 512 B

After

Width:  |  Height:  |  Size: 512 B

View File

Before

Width:  |  Height:  |  Size: 685 B

After

Width:  |  Height:  |  Size: 685 B

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
#!/usr/local/cpanel/3rdparty/bin/perl #!/usr/local/cpanel/3rdparty/bin/perl
# gniza cPanel Plugin — Step 1: Select Remote + Snapshot # gniza4cp cPanel Plugin — Step 1: Select Remote + Snapshot
use strict; use strict;
use warnings; use warnings;
@@ -16,22 +16,23 @@ BEGIN {
use Cpanel::LiveAPI (); use Cpanel::LiveAPI ();
use Cpanel::AdminBin::Call (); use Cpanel::AdminBin::Call ();
use GnizaCPanel::UI; use Gniza4cpCPanel::UI;
my $cpanel = Cpanel::LiveAPI->new(); my $cpanel = Cpanel::LiveAPI->new();
print "Content-Type: text/html\r\n\r\n"; print "Content-Type: text/html\r\n\r\n";
print $cpanel->header('GNIZA Backups'); print $cpanel->header('');
# Get allowed remotes via AdminBin # Get allowed remotes via AdminBin
my $remotes_raw = eval { Cpanel::AdminBin::Call::call('Gniza', 'Restore', 'LIST_ALLOWED_REMOTES') } // ''; my $remotes_raw = eval { Cpanel::AdminBin::Call::call('Gniza4cp', 'Restore', 'LIST_ALLOWED_REMOTES') } // '';
my @remotes = grep { $_ ne '' } split /\n/, $remotes_raw; my @remotes = grep { $_ ne '' } split /\n/, $remotes_raw;
print GnizaCPanel::UI::page_header('GNIZA Backups'); print Gniza4cpCPanel::UI::page_header('GNIZA Backups');
print GnizaCPanel::UI::render_flash(); print Gniza4cpCPanel::UI::render_nav('index.live.cgi');
print Gniza4cpCPanel::UI::render_flash();
if (!@remotes) { if (!@remotes) {
print qq{<div class="alert alert-info mb-4">No backup remotes are available for restore. Please contact your server administrator.</div>\n}; print qq{<div class="alert alert-info mb-4">No backup remotes are available for restore. Please contact your server administrator.</div>\n};
print GnizaCPanel::UI::page_footer(); print Gniza4cpCPanel::UI::page_footer();
print $cpanel->footer(); print $cpanel->footer();
$cpanel->end(); $cpanel->end();
exit; exit;
@@ -43,10 +44,10 @@ print qq{<h2 class="card-title text-sm">Select Backup Source</h2>\n};
# Remote dropdown # Remote dropdown
print qq{<div class="flex items-center gap-3 mb-2.5">\n}; print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-36 font-medium text-sm" for="remote">Remote</label>\n}; print qq{ <label class="w-36 font-medium text-sm" for="remote">Remote</label>\n};
print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="remote" name="remote" onchange="gnizaLoadSnapshots()">\n}; print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="remote" name="remote" onchange="gniza4cpLoadSnapshots()">\n};
print qq{ <option value="">-- Select remote --</option>\n}; print qq{ <option value="">-- Select remote --</option>\n};
for my $r (@remotes) { for my $r (@remotes) {
my $esc = GnizaCPanel::UI::esc($r); my $esc = Gniza4cpCPanel::UI::esc($r);
print qq{ <option value="$esc">$esc</option>\n}; print qq{ <option value="$esc">$esc</option>\n};
} }
print qq{ </select>\n}; print qq{ </select>\n};
@@ -63,13 +64,13 @@ print qq{</div>\n};
print qq{</div>\n</div>\n}; print qq{</div>\n</div>\n};
print qq{<div class="flex items-center gap-2">\n}; print qq{<div class="flex items-center gap-2">\n};
print qq{ <button type="button" class="btn btn-primary btn-sm" id="next-btn" disabled onclick="gnizaGoNext()">Next</button>\n}; print qq{ <button type="button" class="btn btn-primary btn-sm" id="next-btn" disabled onclick="gniza4cpGoNext()">Next</button>\n};
print qq{</div>\n}; print qq{</div>\n};
# JavaScript for snapshot loading and navigation # JavaScript for snapshot loading and navigation
print <<"END_JS"; print <<'END_JS';
<script> <script>
function gnizaLoadSnapshots() { function gniza4cpLoadSnapshots() {
var remote = document.getElementById('remote').value; var remote = document.getElementById('remote').value;
var sel = document.getElementById('timestamp'); var sel = document.getElementById('timestamp');
var btn = document.getElementById('next-btn'); var btn = document.getElementById('next-btn');
@@ -120,17 +121,27 @@ function _setSelectPlaceholder(sel, text) {
sel.appendChild(opt); sel.appendChild(opt);
} }
function _formatTimestamp(ts) {
// 2026-03-05T171516 → Mar 5, 2026 at 17:15:16
var m = ts.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})(\d{2})(\d{2})$/);
if (!m) return ts;
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
var day = parseInt(m[3], 10);
var mon = months[parseInt(m[2], 10) - 1] || m[2];
return mon + ' ' + day + ', ' + m[1] + ' at ' + m[4] + ':' + m[5] + ':' + m[6];
}
function _populateSelect(sel, values) { function _populateSelect(sel, values) {
while (sel.options.length) sel.remove(0); while (sel.options.length) sel.remove(0);
for (var i = 0; i < values.length; i++) { for (var i = 0; i < values.length; i++) {
var opt = document.createElement('option'); var opt = document.createElement('option');
opt.value = values[i]; opt.value = values[i];
opt.textContent = values[i]; opt.textContent = _formatTimestamp(values[i]);
sel.appendChild(opt); sel.appendChild(opt);
} }
} }
function gnizaGoNext() { function gniza4cpGoNext() {
var remote = document.getElementById('remote').value; var remote = document.getElementById('remote').value;
var timestamp = document.getElementById('timestamp').value; var timestamp = document.getElementById('timestamp').value;
if (!remote || !timestamp) return; if (!remote || !timestamp) return;
@@ -143,6 +154,6 @@ function gnizaGoNext() {
</script> </script>
END_JS END_JS
print GnizaCPanel::UI::page_footer(); print Gniza4cpCPanel::UI::page_footer();
print $cpanel->footer(); print $cpanel->footer();
$cpanel->end(); $cpanel->end();

View File

@@ -0,0 +1,13 @@
[
{
"type": "link",
"id": "gniza4cp",
"name": "GNIZA Backups",
"group_id": "files",
"description": "Restore files, databases, email, and more from GNIZA backups",
"uri": "gniza4cp/index.live.cgi",
"feature": "gniza4cp_restore",
"order": 1,
"icon": "gniza4cp/assets/gniza4cp-cpanel-icon.png"
}
]

View File

@@ -1,13 +1,19 @@
package GnizaCPanel::UI; package Gniza4cpCPanel::UI;
# Shared UI helpers for the gniza cPanel user restore plugin. # Shared UI helpers for the gniza4cp cPanel user restore plugin.
# Adapted from GnizaWHM::UI for cPanel context (runs as user, not root). # Adapted from Gniza4cpWHM::UI for cPanel context (runs as user, not root).
use strict; use strict;
use warnings; use warnings;
use Fcntl qw(:DEFAULT); use Fcntl qw(:DEFAULT);
my $CSS_FILE = '/usr/local/cpanel/base/frontend/jupiter/gniza/assets/gniza-whm.css'; my $CSS_FILE = '/usr/local/cpanel/base/frontend/jupiter/gniza4cp/assets/gniza4cp-whm.css';
my $LOGO_FILE = '/usr/local/cpanel/base/frontend/jupiter/gniza/assets/gniza-logo.svg'; my $LOGO_FILE = '/usr/local/cpanel/base/frontend/jupiter/gniza4cp/assets/gniza4cp-logo.svg';
my $_logo_data_uri = '';
my @NAV_ITEMS = (
{ url => 'index.live.cgi', label => 'Restore' },
{ url => 'logs.live.cgi', label => 'Logs' },
);
# ── HTML Escaping ───────────────────────────────────────────── # ── HTML Escaping ─────────────────────────────────────────────
@@ -22,6 +28,37 @@ sub esc {
return $str; return $str;
} }
# ── Navigation ────────────────────────────────────────────────
sub render_nav {
my ($current_page) = @_;
my $logo = '';
if ($_logo_data_uri) {
$logo = qq{<img src="$_logo_data_uri" alt="" style="height:3rem;width:auto">}
. qq{<span class="text-3xl font-bold leading-none">GNIZA <span class="text-secondary">Backup</span></span>};
}
my $menu_items = '';
for my $item (@NAV_ITEMS) {
my $is_active = ($item->{url} eq $current_page);
my $label = esc($item->{label});
my $style = $is_active
? 'style="font-weight:600;color:inherit"'
: 'style="color:inherit;opacity:0.7"';
$menu_items .= qq{<a class="no-underline" href="$item->{url}" $style>$label</a>\n};
}
# Use inline styles to avoid cPanel Jupiter CSS conflicts with DaisyUI navbar
my $html = qq{<div class="bg-base-200 rounded-box mb-5" style="display:flex;align-items:center;justify-content:space-between;padding:0.5rem 1rem;min-height:4rem">\n};
$html .= qq{ <div style="display:flex;align-items:center;gap:0.5rem">\n};
$html .= qq{ $logo\n} if $logo;
$html .= qq{ </div>\n};
$html .= qq{ <div style="display:flex;align-items:center;gap:1.5rem;font-size:0.95rem">\n};
$html .= qq{ $menu_items};
$html .= qq{ </div>\n};
$html .= qq{</div>\n};
return $html;
}
# ── Current User ───────────────────────────────────────────── # ── Current User ─────────────────────────────────────────────
sub get_current_user { sub get_current_user {
@@ -61,7 +98,7 @@ sub _safe_read {
sub _flash_file { sub _flash_file {
my $user = get_current_user(); my $user = get_current_user();
return "/tmp/.gniza-cpanel-flash-$user"; return "/tmp/.gniza4cp-cpanel-flash-$user";
} }
sub set_flash { sub set_flash {
@@ -101,7 +138,7 @@ my $_current_csrf_token;
sub _csrf_file { sub _csrf_file {
my $user = get_current_user(); my $user = get_current_user();
return "/tmp/.gniza-cpanel-csrf-$user"; return "/tmp/.gniza4cp-cpanel-csrf-$user";
} }
sub generate_csrf_token { sub generate_csrf_token {
@@ -163,14 +200,14 @@ sub verify_csrf_token {
sub csrf_hidden_field { sub csrf_hidden_field {
my $token = generate_csrf_token(); my $token = generate_csrf_token();
return qq{<input type="hidden" name="gniza_csrf" value="} . esc($token) . qq{">}; return qq{<input type="hidden" name="gniza4cp_csrf" value="} . esc($token) . qq{">};
} }
# ── Page Wrappers ──────────────────────────────────────────── # ── Page Wrappers ────────────────────────────────────────────
sub page_header { sub page_header {
my ($title) = @_; my ($title) = @_;
$title = esc($title // 'gniza Restore'); $title = esc($title // 'GNIZA Restore');
my $css = ''; my $css = '';
if (open my $fh, '<', $CSS_FILE) { if (open my $fh, '<', $CSS_FILE) {
local $/; local $/;
@@ -180,20 +217,17 @@ sub page_header {
$css = _unwrap_layers($css); $css = _unwrap_layers($css);
$css = _scope_to_container($css); $css = _scope_to_container($css);
# Inline logo as base64 data URI # Pre-compute logo data URI for render_nav()
my $logo_html = ''; if (!$_logo_data_uri && open my $lfh, '<', $LOGO_FILE) {
if (open my $lfh, '<', $LOGO_FILE) {
local $/; local $/;
my $svg_data = <$lfh>; my $svg_data = <$lfh>;
close $lfh; close $lfh;
require MIME::Base64; require MIME::Base64;
my $b64 = MIME::Base64::encode_base64($svg_data, ''); $_logo_data_uri = 'data:image/svg+xml;base64,' . MIME::Base64::encode_base64($svg_data, '');
$logo_html = qq{<div class="flex items-center justify-center gap-3 mb-4"><img src="data:image/svg+xml;base64,$b64" alt="gniza" style="height:40px;width:auto"></div>\n};
} }
return qq{<style>$css</style>\n} return qq{<style>$css</style>\n}
. qq{<div data-theme="gniza" class="font-sans text-base" style="padding:20px 10px 10px 10px">\n} . qq{<div data-theme="gniza4cp" class="font-sans text-base" style="padding:20px 10px 10px 10px">\n};
. $logo_html;
} }
sub page_footer { sub page_footer {
@@ -243,7 +277,7 @@ sub _scope_to_container {
$css =~ s/:where\(:root\)/\&/g; $css =~ s/:where\(:root\)/\&/g;
$css =~ s/:root,\s*\[data-theme[^\]]*\]/\&/g; $css =~ s/:root,\s*\[data-theme[^\]]*\]/\&/g;
$css =~ s/\[data-theme=light\]/\&/g; $css =~ s/\[data-theme=light\]/\&/g;
$css =~ s/\[data-theme=gniza\]/\&/g; $css =~ s/\[data-theme=gniza4cp\]/\&/g;
$css =~ s/:root:not\(span\)/\&/g; $css =~ s/:root:not\(span\)/\&/g;
$css =~ s/:root:has\(/\&:has(/g; $css =~ s/:root:has\(/\&:has(/g;
$css =~ s/:root\b/\&/g; $css =~ s/:root\b/\&/g;
@@ -275,7 +309,7 @@ sub _scope_to_container {
$i++; $i++;
} }
return join('', @top_level) . '[data-theme="gniza"]{' . $scoped . '}'; return join('', @top_level) . '[data-theme="gniza4cp"]{' . $scoped . '}';
} }
sub render_errors { sub render_errors {

View File

@@ -0,0 +1,223 @@
#!/usr/local/cpanel/3rdparty/bin/perl
# gniza4cp cPanel Plugin — Activity Logs
# Shows user-initiated restore actions and their results
use strict;
use warnings;
BEGIN {
my $base;
if ($0 =~ m{^(.*)/}) {
$base = $1;
} else {
$base = '.';
}
unshift @INC, "$base/lib";
}
use Cpanel::LiveAPI ();
use Cpanel::AdminBin::Call ();
use Cpanel::Form ();
use Gniza4cpCPanel::UI;
my $cpanel = Cpanel::LiveAPI->new();
END { $cpanel->end() if $cpanel }
my $form = Cpanel::Form::parseform();
my $entry = $form->{'entry'} // '';
if ($entry ne '') {
show_entry($entry);
} else {
show_list();
}
exit;
# ── List View ────────────────────────────────────────────────
sub show_list {
print "Content-Type: text/html\r\n\r\n";
print $cpanel->header('');
print Gniza4cpCPanel::UI::page_header('GNIZA Activity Log');
print Gniza4cpCPanel::UI::render_nav('logs.live.cgi');
print Gniza4cpCPanel::UI::render_flash();
my $raw = eval { Cpanel::AdminBin::Call::call('Gniza4cp', 'Restore', 'LIST_LOGS') } // '';
if ($raw =~ /^ERROR: (.*)/) {
print qq{<div class="alert alert-error mb-4">} . Gniza4cpCPanel::UI::esc($1) . qq{</div>\n};
print Gniza4cpCPanel::UI::page_footer();
print $cpanel->footer();
return;
}
my @entries;
for my $line (split /\n/, $raw) {
next if $line eq '';
my ($idx, $date, $action, $details, $status) = split /\t/, $line;
push @entries, {
idx => $idx // '',
date => $date // '',
action => $action // '',
details => $details // '',
status => $status // '',
};
}
if (!@entries) {
print qq{<div class="alert alert-info mb-4">No restore activity yet. Actions you perform in the Restore section will appear here.</div>\n};
print Gniza4cpCPanel::UI::page_footer();
print $cpanel->footer();
return;
}
# Pagination
my $per_page = 25;
my $total = scalar @entries;
my $page = int($form->{'page'} // 1);
$page = 1 if $page < 1;
my $total_pages = int(($total + $per_page - 1) / $per_page);
$page = $total_pages if $page > $total_pages;
my $start = ($page - 1) * $per_page;
my $end = $start + $per_page - 1;
$end = $#entries if $end > $#entries;
my @page_entries = @entries[$start .. $end];
print qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100">\n};
print qq{<table class="table">\n};
print qq{<thead><tr><th>Date (UTC)</th><th>Action</th><th>Details</th><th>Status</th><th></th></tr></thead>\n};
print qq{<tbody>\n};
for my $e (@page_entries) {
my $esc_date = Gniza4cpCPanel::UI::esc($e->{date});
my $esc_action = Gniza4cpCPanel::UI::esc($e->{action});
my $esc_details = Gniza4cpCPanel::UI::esc($e->{details});
my $esc_status = Gniza4cpCPanel::UI::esc($e->{status});
my $esc_idx = Gniza4cpCPanel::UI::esc($e->{idx});
my $status_badge = $e->{status} eq 'Error' ? 'badge-error' : 'badge-success';
my $href = 'logs.live.cgi?entry=' . _uri_escape($e->{idx});
print qq{<tr>\n};
print qq{ <td class="whitespace-nowrap">$esc_date</td>\n};
print qq{ <td><span class="badge badge-info badge-sm">$esc_action</span></td>\n};
print qq{ <td class="text-sm">$esc_details</td>\n};
print qq{ <td><span class="badge $status_badge badge-sm">$esc_status</span></td>\n};
print qq{ <td><button type="button" class="btn btn-secondary btn-sm" onclick="location.href='$href'">View</button></td>\n};
print qq{</tr>\n};
}
print qq{</tbody>\n</table>\n</div>\n};
# Pagination controls
if ($total_pages > 1) {
print qq{<div class="flex items-center justify-center gap-2 mt-4">\n};
if ($page > 1) {
my $prev = $page - 1;
print qq{ <button type="button" class="btn btn-sm" onclick="location.href='logs.live.cgi?page=$prev'">&laquo; Prev</button>\n};
}
print qq{ <span class="text-sm">Page $page of $total_pages ($total entries)</span>\n};
if ($page < $total_pages) {
my $next = $page + 1;
print qq{ <button type="button" class="btn btn-sm" onclick="location.href='logs.live.cgi?page=$next'">Next &raquo;</button>\n};
}
print qq{</div>\n};
}
print Gniza4cpCPanel::UI::page_footer();
print $cpanel->footer();
}
# ── Entry Detail View ────────────────────────────────────────
sub show_entry {
my ($entry_idx) = @_;
print "Content-Type: text/html\r\n\r\n";
print $cpanel->header('');
print Gniza4cpCPanel::UI::page_header('GNIZA Activity Detail');
print Gniza4cpCPanel::UI::render_nav('logs.live.cgi');
# Validate entry index (numeric only)
unless ($entry_idx =~ /^[0-9]+$/) {
print qq{<div class="alert alert-error mb-4">Invalid entry.</div>\n};
print qq{<p><a href="logs.live.cgi" class="link">&larr; Back to activity log</a></p>\n};
print Gniza4cpCPanel::UI::page_footer();
print $cpanel->footer();
return;
}
my $content = eval { Cpanel::AdminBin::Call::call('Gniza4cp', 'Restore', 'GET_LOG', $entry_idx) } // '';
if ($content =~ /^ERROR: (.*)/) {
print qq{<div class="alert alert-error mb-4">} . Gniza4cpCPanel::UI::esc($1) . qq{</div>\n};
print qq{<p><a href="logs.live.cgi" class="link">&larr; Back to activity log</a></p>\n};
print Gniza4cpCPanel::UI::page_footer();
print $cpanel->footer();
return;
}
# Parse header fields and output from entry content
my ($date, $action, $details, $status, $output) = ('', '', '', '', '');
my @lines = split /\n/, $content;
my @output_lines;
my $in_output = 0;
for my $line (@lines) {
if (!$in_output) {
if ($line =~ /^Date:\s+(.+)$/) { $date = $1; }
elsif ($line =~ /^Action:\s+(.+)$/) { $action = $1; }
elsif ($line =~ /^Details:\s+(.+)$/) { $details = $1; }
elsif ($line =~ /^Status:\s+(.+)$/) { $status = $1; $in_output = 1; }
} else {
push @output_lines, $line;
}
}
# Back link
print qq{<p class="mb-4"><a href="logs.live.cgi" class="link">&larr; Back to activity log</a></p>\n};
# Entry info card
my $status_badge = $status eq 'Error' ? 'badge-error' : 'badge-success';
print qq{<div class="card bg-base-200 shadow-sm border border-base-300 mb-4">\n};
print qq{<div class="card-body py-3 px-4">\n};
print qq{<div class="flex flex-wrap gap-4 items-center text-sm">\n};
print qq{ <span><strong>Date:</strong> } . Gniza4cpCPanel::UI::esc($date) . qq{</span>\n};
print qq{ <span><strong>Action:</strong> <span class="badge badge-info badge-sm">} . Gniza4cpCPanel::UI::esc($action) . qq{</span></span>\n};
print qq{ <span><strong>Status:</strong> <span class="badge $status_badge badge-sm">} . Gniza4cpCPanel::UI::esc($status) . qq{</span></span>\n};
print qq{</div>\n};
print qq{<div class="text-sm mt-2"><strong>Details:</strong> } . Gniza4cpCPanel::UI::esc($details) . qq{</div>\n};
print qq{</div>\n</div>\n};
# Output section
if (@output_lines) {
print qq{<h3 class="text-sm font-bold mb-2">Command Output</h3>\n};
print qq{<pre class="bg-base-100 border border-base-300 rounded-lg p-4 text-sm font-mono overflow-x-auto leading-relaxed">};
for my $line (@output_lines) {
my $esc = Gniza4cpCPanel::UI::esc($line);
if ($line =~ /\[ERROR\]/) {
print qq{<span class="text-error font-bold">$esc</span>\n};
} elsif ($line =~ /\[WARN\]/) {
print qq{<span class="text-warning">$esc</span>\n};
} elsif ($line =~ /\[DEBUG\]/) {
print qq{<span class="text-base-content/60">$esc</span>\n};
} else {
print "$esc\n";
}
}
print qq{</pre>\n};
} else {
print qq{<div class="alert alert-info">No output recorded for this action.</div>\n};
}
print Gniza4cpCPanel::UI::page_footer();
print $cpanel->footer();
}
# ── Helpers ───────────────────────────────────────────────────
sub _uri_escape {
my $str = shift // '';
$str =~ s/([^A-Za-z0-9\-._~])/sprintf("%%%02X", ord($1))/ge;
return $str;
}
1;

View File

@@ -1,5 +1,5 @@
#!/usr/local/cpanel/3rdparty/bin/perl #!/usr/local/cpanel/3rdparty/bin/perl
# gniza cPanel Plugin — Restore Workflow # gniza4cp cPanel Plugin — Restore Workflow
# Multi-step restore with dynamic dropdowns via AdminBin # Multi-step restore with dynamic dropdowns via AdminBin
use strict; use strict;
use warnings; use warnings;
@@ -17,7 +17,7 @@ BEGIN {
use Cpanel::LiveAPI (); use Cpanel::LiveAPI ();
use Cpanel::AdminBin::Call (); use Cpanel::AdminBin::Call ();
use Cpanel::Form (); use Cpanel::Form ();
use GnizaCPanel::UI; use Gniza4cpCPanel::UI;
my $cpanel = Cpanel::LiveAPI->new(); my $cpanel = Cpanel::LiveAPI->new();
END { $cpanel->end() if $cpanel } END { $cpanel->end() if $cpanel }
@@ -66,7 +66,7 @@ sub _json_escape {
sub _adminbin_call { sub _adminbin_call {
my ($action, @args) = @_; my ($action, @args) = @_;
my $result = eval { Cpanel::AdminBin::Call::call('Gniza', 'Restore', $action, @args) }; my $result = eval { Cpanel::AdminBin::Call::call('Gniza4cp', 'Restore', $action, @args) };
if ($@) { if ($@) {
return (0, '', "AdminBin call failed: $@"); return (0, '', "AdminBin call failed: $@");
} }
@@ -176,7 +176,7 @@ sub handle_step2 {
my $timestamp = $form->{'timestamp'} // ''; my $timestamp = $form->{'timestamp'} // '';
if ($remote eq '' || $timestamp eq '') { if ($remote eq '' || $timestamp eq '') {
GnizaCPanel::UI::set_flash('error', 'Remote and snapshot are required.'); Gniza4cpCPanel::UI::set_flash('error', 'Remote and snapshot are required.');
print "Status: 302 Found\r\n"; print "Status: 302 Found\r\n";
print "Location: index.live.cgi\r\n\r\n"; print "Location: index.live.cgi\r\n\r\n";
exit; exit;
@@ -195,12 +195,13 @@ sub handle_step2 {
} }
print "Content-Type: text/html\r\n\r\n"; print "Content-Type: text/html\r\n\r\n";
print $cpanel->header('GNIZA Backups'); print $cpanel->header('');
print GnizaCPanel::UI::page_header('Restore Options'); print Gniza4cpCPanel::UI::page_header('Restore Options');
print GnizaCPanel::UI::render_flash(); print Gniza4cpCPanel::UI::render_nav('restore.live.cgi');
print Gniza4cpCPanel::UI::render_flash();
my $esc_remote = GnizaCPanel::UI::esc($remote); my $esc_remote = Gniza4cpCPanel::UI::esc($remote);
my $esc_timestamp = GnizaCPanel::UI::esc($timestamp); my $esc_timestamp = Gniza4cpCPanel::UI::esc($timestamp);
print qq{<form method="GET" action="restore.live.cgi">\n}; print qq{<form method="GET" action="restore.live.cgi">\n};
print qq{<input type="hidden" name="step" value="3">\n}; print qq{<input type="hidden" name="step" value="3">\n};
@@ -214,9 +215,9 @@ sub handle_step2 {
print qq{<div class="flex items-center gap-3 mb-2.5">\n}; print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-36 font-medium text-sm" for="timestamp">Snapshot</label>\n}; print qq{ <label class="w-36 font-medium text-sm" for="timestamp">Snapshot</label>\n};
if (@snapshots) { if (@snapshots) {
print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="timestamp" name="timestamp" required onchange="gnizaSnapshotChanged()">\n}; print qq{ <select class="select select-bordered select-sm w-full max-w-xs" id="timestamp" name="timestamp" required onchange="gniza4cpSnapshotChanged()">\n};
for my $snap (sort { $b cmp $a } @snapshots) { for my $snap (sort { $b cmp $a } @snapshots) {
my $esc = GnizaCPanel::UI::esc($snap); my $esc = Gniza4cpCPanel::UI::esc($snap);
my $sel = ($snap eq $timestamp) ? ' selected' : ''; my $sel = ($snap eq $timestamp) ? ' selected' : '';
print qq{ <option value="$esc"$sel>$esc</option>\n}; print qq{ <option value="$esc"$sel>$esc</option>\n};
} }
@@ -230,8 +231,8 @@ sub handle_step2 {
print qq{<div class="flex items-center gap-3 mb-2.5">\n}; print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-36 font-medium text-sm whitespace-nowrap">Restore Mode</label>\n}; print qq{ <label class="w-36 font-medium text-sm whitespace-nowrap">Restore Mode</label>\n};
print qq{ <div class="join inline-flex items-stretch">\n}; print qq{ <div class="join inline-flex items-stretch">\n};
print qq{ <input type="radio" name="restore_mode" class="join-item btn btn-sm m-0" aria-label="Full Account" value="full" checked onchange="gnizaModeChanged()">\n}; print qq{ <input type="radio" name="restore_mode" class="join-item btn btn-sm m-0" aria-label="Full Account" value="full" checked onchange="gniza4cpModeChanged()">\n};
print qq{ <input type="radio" name="restore_mode" class="join-item btn btn-sm m-0" aria-label="Selective" value="selective" onchange="gnizaModeChanged()">\n}; print qq{ <input type="radio" name="restore_mode" class="join-item btn btn-sm m-0" aria-label="Selective" value="selective" onchange="gniza4cpModeChanged()">\n};
print qq{ </div>\n}; print qq{ </div>\n};
print qq{</div>\n}; print qq{</div>\n};
@@ -241,8 +242,8 @@ sub handle_step2 {
print qq{ <h3 class="card-title text-sm">Directories and Files to Exclude</h3>\n}; print qq{ <h3 class="card-title text-sm">Directories and Files to Exclude</h3>\n};
print qq{ <div class="flex items-center gap-2">\n}; print qq{ <div class="flex items-center gap-2">\n};
print qq{ <input type="text" class="input input-bordered input-sm flex-1 max-w-xs" id="exclude-input" placeholder="e.g. public_html/cache">\n}; print qq{ <input type="text" class="input input-bordered input-sm flex-1 max-w-xs" id="exclude-input" placeholder="e.g. public_html/cache">\n};
print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gnizaAddExclude()">Add Path</button>\n}; print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gniza4cpAddExclude()">Add Path</button>\n};
print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gnizaOpenExcludeModal()">Insert Multiple</button>\n}; print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gniza4cpOpenExcludeModal()">Insert Multiple</button>\n};
print qq{ </div>\n}; print qq{ </div>\n};
print qq{ <p class="text-xs text-base-content/60">Exclude files and directories from restoration</p>\n}; print qq{ <p class="text-xs text-base-content/60">Exclude files and directories from restoration</p>\n};
print qq{ <div id="exclude-tags" class="flex flex-wrap gap-1 mt-1"></div>\n}; print qq{ <div id="exclude-tags" class="flex flex-wrap gap-1 mt-1"></div>\n};
@@ -258,7 +259,7 @@ sub handle_step2 {
print qq{ <p class="text-xs text-base-content/60 mt-1">* Separated by new line</p>\n}; print qq{ <p class="text-xs text-base-content/60 mt-1">* Separated by new line</p>\n};
print qq{ <div class="modal-action">\n}; print qq{ <div class="modal-action">\n};
print qq{ <button type="button" class="btn btn-sm" onclick="document.getElementById('exclude-modal').close()">Cancel</button>\n}; print qq{ <button type="button" class="btn btn-sm" onclick="document.getElementById('exclude-modal').close()">Cancel</button>\n};
print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gnizaExcludeModalOk()">OK</button>\n}; print qq{ <button type="button" class="btn btn-warning btn-sm" onclick="gniza4cpExcludeModalOk()">OK</button>\n};
print qq{ </div>\n}; print qq{ </div>\n};
print qq{</div>\n}; print qq{</div>\n};
print qq{<div class="modal-backdrop" onclick="this.closest('dialog').close()"><button type="button">close</button></div>\n}; print qq{<div class="modal-backdrop" onclick="this.closest('dialog').close()"><button type="button">close</button></div>\n};
@@ -284,7 +285,7 @@ sub handle_step2 {
print qq{ <label class="w-36 font-medium text-sm">Restore Types</label>\n}; print qq{ <label class="w-36 font-medium text-sm">Restore Types</label>\n};
print qq{ <div class="flex flex-wrap gap-1">\n}; print qq{ <div class="flex flex-wrap gap-1">\n};
for my $t (@selective_types) { for my $t (@selective_types) {
print qq{ <input type="checkbox" class="btn btn-sm" aria-label="$t->[1]" name="type_$t->[0]" value="1" onchange="gnizaTypesChanged()">\n}; print qq{ <input type="checkbox" class="btn btn-sm" aria-label="$t->[1]" name="type_$t->[0]" value="1" onchange="gniza4cpTypesChanged()">\n};
} }
print qq{ </div>\n}; print qq{ </div>\n};
print qq{</div>\n}; print qq{</div>\n};
@@ -298,7 +299,7 @@ sub handle_step2 {
print qq{ <label class="font-medium text-sm" for="path">Path</label>\n}; print qq{ <label class="font-medium text-sm" for="path">Path</label>\n};
print qq{ <div class="flex items-center gap-2 w-full max-w-xs">\n}; print qq{ <div class="flex items-center gap-2 w-full max-w-xs">\n};
print qq{ <input type="text" class="input input-bordered input-sm flex-1" id="path" name="path" placeholder="e.g. public_html/index.html">\n}; print qq{ <input type="text" class="input input-bordered input-sm flex-1" id="path" name="path" placeholder="e.g. public_html/index.html">\n};
print qq{ <button type="button" class="btn btn-secondary btn-sm" onclick="gnizaOpenFileBrowser()">Browse</button>\n}; print qq{ <button type="button" class="btn btn-secondary btn-sm" onclick="gniza4cpOpenFileBrowser()">Browse</button>\n};
print qq{ </div>\n}; print qq{ </div>\n};
print qq{ </div>\n}; print qq{ </div>\n};
print qq{ <p class="text-xs text-base-content/60">Leave empty to restore all files.</p>\n}; print qq{ <p class="text-xs text-base-content/60">Leave empty to restore all files.</p>\n};
@@ -390,7 +391,7 @@ sub handle_step2 {
print qq{ <table class="table table-zebra w-full"><tbody id="fb-tbody"></tbody></table>\n}; print qq{ <table class="table table-zebra w-full"><tbody id="fb-tbody"></tbody></table>\n};
print qq{ </div>\n}; print qq{ </div>\n};
print qq{ <div class="modal-action">\n}; print qq{ <div class="modal-action">\n};
print qq{ <button type="button" id="fb-select-btn" class="btn btn-primary btn-sm" disabled onclick="gnizaSelectPath()">Select</button>\n}; print qq{ <button type="button" id="fb-select-btn" class="btn btn-primary btn-sm" disabled onclick="gniza4cpSelectPath()">Select</button>\n};
print qq{ <button type="button" class="btn btn-info btn-sm" onclick="document.getElementById('fb-modal').close()">Cancel</button>\n}; print qq{ <button type="button" class="btn btn-info btn-sm" onclick="document.getElementById('fb-modal').close()">Cancel</button>\n};
print qq{ </div>\n}; print qq{ </div>\n};
print qq{</div>\n}; print qq{</div>\n};
@@ -413,7 +414,7 @@ sub handle_step2 {
# JavaScript for dynamic dropdowns and interactive elements # JavaScript for dynamic dropdowns and interactive elements
_print_step2_js($esc_remote); _print_step2_js($esc_remote);
print GnizaCPanel::UI::page_footer(); print Gniza4cpCPanel::UI::page_footer();
print $cpanel->footer(); print $cpanel->footer();
} }
@@ -421,24 +422,24 @@ sub _print_step2_js {
my ($esc_remote) = @_; my ($esc_remote) = @_;
print <<"END_JS"; print <<"END_JS";
<script> <script>
var gnizaCache = {}; var gniza4cpCache = {};
var gnizaRemote = '$esc_remote'; var gniza4cpRemote = '$esc_remote';
var fbCache = {}; var fbCache = {};
var fbSelected = ''; var fbSelected = '';
function gnizaSnapshotChanged() { function gniza4cpSnapshotChanged() {
gnizaCache = {}; gniza4cpCache = {};
fbCache = {}; fbCache = {};
gnizaModeChanged(); gniza4cpModeChanged();
} }
function gnizaModeChanged() { function gniza4cpModeChanged() {
var mode = document.querySelector('input[name="restore_mode"]:checked').value; var mode = document.querySelector('input[name="restore_mode"]:checked').value;
var selective = mode === 'selective'; var selective = mode === 'selective';
document.getElementById('selective-panel').hidden = !selective; document.getElementById('selective-panel').hidden = !selective;
document.getElementById('type_account_hidden').disabled = selective; document.getElementById('type_account_hidden').disabled = selective;
if (selective) { if (selective) {
gnizaTypesChanged(); gniza4cpTypesChanged();
} else { } else {
var panels = ['field-path','field-dbname','field-email','field-dbusers','field-cron','field-domains','field-ssl']; var panels = ['field-path','field-dbname','field-email','field-dbusers','field-cron','field-domains','field-ssl'];
for (var i = 0; i < panels.length; i++) { for (var i = 0; i < panels.length; i++) {
@@ -447,7 +448,7 @@ function gnizaModeChanged() {
} }
} }
function gnizaTypesChanged() { function gniza4cpTypesChanged() {
var types = { var types = {
files: 'field-path', files: 'field-path',
database: 'field-dbname', database: 'field-dbname',
@@ -462,20 +463,20 @@ function gnizaTypesChanged() {
document.getElementById(types[t]).hidden = !(el && el.checked); document.getElementById(types[t]).hidden = !(el && el.checked);
} }
if (document.querySelector('input[name="type_database"]').checked) { gnizaLoadOptions('database', 'dbname-list', 'dbnames'); } if (document.querySelector('input[name="type_database"]').checked) { gniza4cpLoadOptions('database', 'dbname-list', 'dbnames'); }
if (document.querySelector('input[name="type_mailbox"]').checked) { gnizaLoadOptions('mailbox', 'email-list', 'emails'); } if (document.querySelector('input[name="type_mailbox"]').checked) { gniza4cpLoadOptions('mailbox', 'email-list', 'emails'); }
if (document.querySelector('input[name="type_dbusers"]').checked) { gnizaLoadOptions('dbusers', 'dbusers-list', 'dbuser_names'); } if (document.querySelector('input[name="type_dbusers"]').checked) { gniza4cpLoadOptions('dbusers', 'dbusers-list', 'dbuser_names'); }
if (document.querySelector('input[name="type_cron"]').checked) { gnizaLoadPreview('cron', 'cron-list'); } if (document.querySelector('input[name="type_cron"]').checked) { gniza4cpLoadPreview('cron', 'cron-list'); }
if (document.querySelector('input[name="type_domains"]').checked) { gnizaLoadOptions('domains', 'domains-list', 'domain_names'); } if (document.querySelector('input[name="type_domains"]').checked) { gniza4cpLoadOptions('domains', 'domains-list', 'domain_names'); }
if (document.querySelector('input[name="type_ssl"]').checked) { gnizaLoadOptions('ssl', 'ssl-list', 'ssl_names'); } if (document.querySelector('input[name="type_ssl"]').checked) { gniza4cpLoadOptions('ssl', 'ssl-list', 'ssl_names'); }
} }
function gnizaLoadOptions(type, containerId, hiddenId) { function gniza4cpLoadOptions(type, containerId, hiddenId) {
var ts = document.getElementById('timestamp').value; var ts = document.getElementById('timestamp').value;
var cacheKey = type + ':' + ts; var cacheKey = type + ':' + ts;
if (gnizaCache[cacheKey]) { if (gniza4cpCache[cacheKey]) {
gnizaPopulateChecklist(containerId, hiddenId, gnizaCache[cacheKey]); gniza4cpPopulateChecklist(containerId, hiddenId, gniza4cpCache[cacheKey]);
return; return;
} }
@@ -491,7 +492,7 @@ function gnizaLoadOptions(type, containerId, hiddenId) {
document.getElementById(hiddenId).value = ''; document.getElementById(hiddenId).value = '';
var url = 'restore.live.cgi?step=fetch_options' var url = 'restore.live.cgi?step=fetch_options'
+ '&remote=' + encodeURIComponent(gnizaRemote) + '&remote=' + encodeURIComponent(gniza4cpRemote)
+ '&timestamp=' + encodeURIComponent(ts) + '&timestamp=' + encodeURIComponent(ts)
+ '&type=' + encodeURIComponent(type); + '&type=' + encodeURIComponent(type);
@@ -505,8 +506,8 @@ function gnizaLoadOptions(type, containerId, hiddenId) {
if (data.error) { if (data.error) {
container.textContent = 'Error: ' + data.error; container.textContent = 'Error: ' + data.error;
} else { } else {
gnizaCache[cacheKey] = data.options; gniza4cpCache[cacheKey] = data.options;
gnizaPopulateChecklist(containerId, hiddenId, data.options); gniza4cpPopulateChecklist(containerId, hiddenId, data.options);
} }
} catch(e) { } catch(e) {
container.textContent = 'Failed to parse response'; container.textContent = 'Failed to parse response';
@@ -518,7 +519,7 @@ function gnizaLoadOptions(type, containerId, hiddenId) {
xhr.send(); xhr.send();
} }
function gnizaPopulateChecklist(containerId, hiddenId, options) { function gniza4cpPopulateChecklist(containerId, hiddenId, options) {
var container = document.getElementById(containerId); var container = document.getElementById(containerId);
var hidden = document.getElementById(hiddenId); var hidden = document.getElementById(hiddenId);
hidden.value = ''; hidden.value = '';
@@ -539,7 +540,7 @@ function gnizaPopulateChecklist(containerId, hiddenId, options) {
allCb.type = 'checkbox'; allCb.type = 'checkbox';
allCb.className = 'checkbox checkbox-sm'; allCb.className = 'checkbox checkbox-sm';
allCb.setAttribute('data-all', '1'); allCb.setAttribute('data-all', '1');
allCb.onchange = function() { gnizaToggleAll(containerId, hiddenId, this.checked); }; allCb.onchange = function() { gniza4cpToggleAll(containerId, hiddenId, this.checked); };
allRow.appendChild(allCb); allRow.appendChild(allCb);
var allSpan = document.createElement('span'); var allSpan = document.createElement('span');
allSpan.className = 'text-sm font-semibold'; allSpan.className = 'text-sm font-semibold';
@@ -556,7 +557,7 @@ function gnizaPopulateChecklist(containerId, hiddenId, options) {
cb.className = 'checkbox checkbox-sm'; cb.className = 'checkbox checkbox-sm';
cb.value = options[i]; cb.value = options[i];
cb.setAttribute('data-item', '1'); cb.setAttribute('data-item', '1');
cb.onchange = (function(cId, hId) { return function() { gnizaSyncHidden(cId, hId); }; })(containerId, hiddenId); cb.onchange = (function(cId, hId) { return function() { gniza4cpSyncHidden(cId, hId); }; })(containerId, hiddenId);
row.appendChild(cb); row.appendChild(cb);
var span = document.createElement('span'); var span = document.createElement('span');
span.className = 'text-sm'; span.className = 'text-sm';
@@ -566,7 +567,7 @@ function gnizaPopulateChecklist(containerId, hiddenId, options) {
} }
} }
function gnizaToggleAll(containerId, hiddenId, checked) { function gniza4cpToggleAll(containerId, hiddenId, checked) {
var container = document.getElementById(containerId); var container = document.getElementById(containerId);
var hidden = document.getElementById(hiddenId); var hidden = document.getElementById(hiddenId);
var items = container.querySelectorAll('input[data-item]'); var items = container.querySelectorAll('input[data-item]');
@@ -577,7 +578,7 @@ function gnizaToggleAll(containerId, hiddenId, checked) {
hidden.value = checked ? '__ALL__' : ''; hidden.value = checked ? '__ALL__' : '';
} }
function gnizaSyncHidden(containerId, hiddenId) { function gniza4cpSyncHidden(containerId, hiddenId) {
var container = document.getElementById(containerId); var container = document.getElementById(containerId);
var hidden = document.getElementById(hiddenId); var hidden = document.getElementById(hiddenId);
var items = container.querySelectorAll('input[data-item]:checked'); var items = container.querySelectorAll('input[data-item]:checked');
@@ -588,12 +589,12 @@ function gnizaSyncHidden(containerId, hiddenId) {
hidden.value = vals.join(','); hidden.value = vals.join(',');
} }
function gnizaLoadPreview(type, containerId) { function gniza4cpLoadPreview(type, containerId) {
var ts = document.getElementById('timestamp').value; var ts = document.getElementById('timestamp').value;
var cacheKey = type + ':' + ts; var cacheKey = type + ':' + ts;
if (gnizaCache[cacheKey]) { if (gniza4cpCache[cacheKey]) {
gnizaPopulatePreview(containerId, gnizaCache[cacheKey], type); gniza4cpPopulatePreview(containerId, gniza4cpCache[cacheKey], type);
return; return;
} }
@@ -608,7 +609,7 @@ function gnizaLoadPreview(type, containerId) {
container.appendChild(loadSpan); container.appendChild(loadSpan);
var url = 'restore.live.cgi?step=fetch_options' var url = 'restore.live.cgi?step=fetch_options'
+ '&remote=' + encodeURIComponent(gnizaRemote) + '&remote=' + encodeURIComponent(gniza4cpRemote)
+ '&timestamp=' + encodeURIComponent(ts) + '&timestamp=' + encodeURIComponent(ts)
+ '&type=' + encodeURIComponent(type); + '&type=' + encodeURIComponent(type);
@@ -622,8 +623,8 @@ function gnizaLoadPreview(type, containerId) {
if (data.error) { if (data.error) {
container.textContent = 'Error: ' + data.error; container.textContent = 'Error: ' + data.error;
} else { } else {
gnizaCache[cacheKey] = data.options; gniza4cpCache[cacheKey] = data.options;
gnizaPopulatePreview(containerId, data.options, type); gniza4cpPopulatePreview(containerId, data.options, type);
} }
} catch(e) { } catch(e) {
container.textContent = 'Failed to parse response'; container.textContent = 'Failed to parse response';
@@ -635,7 +636,7 @@ function gnizaLoadPreview(type, containerId) {
xhr.send(); xhr.send();
} }
function gnizaPopulatePreview(containerId, options, type) { function gniza4cpPopulatePreview(containerId, options, type) {
var container = document.getElementById(containerId); var container = document.getElementById(containerId);
container.textContent = ''; container.textContent = '';
if (!options || options.length === 0) { if (!options || options.length === 0) {
@@ -659,16 +660,16 @@ function gnizaPopulatePreview(containerId, options, type) {
} }
} }
function gnizaAddExclude() { function gniza4cpAddExclude() {
var input = document.getElementById('exclude-input'); var input = document.getElementById('exclude-input');
var val = input.value.trim(); var val = input.value.trim();
if (!val) return; if (!val) return;
gnizaAddExcludeTag(val); gniza4cpAddExcludeTag(val);
input.value = ''; input.value = '';
gnizaUpdateExcludeField(); gniza4cpUpdateExcludeField();
} }
function gnizaAddExcludeTag(text) { function gniza4cpAddExcludeTag(text) {
var container = document.getElementById('exclude-tags'); var container = document.getElementById('exclude-tags');
var existing = container.querySelectorAll('.badge span'); var existing = container.querySelectorAll('.badge span');
for (var i = 0; i < existing.length; i++) { for (var i = 0; i < existing.length; i++) {
@@ -683,12 +684,12 @@ function gnizaAddExcludeTag(text) {
btn.type = 'button'; btn.type = 'button';
btn.className = 'btn btn-xs btn-ghost btn-circle'; btn.className = 'btn btn-xs btn-ghost btn-circle';
btn.textContent = '\\u2715'; btn.textContent = '\\u2715';
btn.onclick = function() { badge.remove(); gnizaUpdateExcludeField(); }; btn.onclick = function() { badge.remove(); gniza4cpUpdateExcludeField(); };
badge.appendChild(btn); badge.appendChild(btn);
container.appendChild(badge); container.appendChild(badge);
} }
function gnizaUpdateExcludeField() { function gniza4cpUpdateExcludeField() {
var tags = document.getElementById('exclude-tags').querySelectorAll('.badge span'); var tags = document.getElementById('exclude-tags').querySelectorAll('.badge span');
var vals = []; var vals = [];
for (var i = 0; i < tags.length; i++) { for (var i = 0; i < tags.length; i++) {
@@ -697,35 +698,35 @@ function gnizaUpdateExcludeField() {
document.getElementById('exclude_paths').value = vals.join(','); document.getElementById('exclude_paths').value = vals.join(',');
} }
function gnizaOpenExcludeModal() { function gniza4cpOpenExcludeModal() {
document.getElementById('exclude-textarea').value = ''; document.getElementById('exclude-textarea').value = '';
document.getElementById('exclude-modal').showModal(); document.getElementById('exclude-modal').showModal();
} }
function gnizaExcludeModalOk() { function gniza4cpExcludeModalOk() {
var text = document.getElementById('exclude-textarea').value; var text = document.getElementById('exclude-textarea').value;
var lines = text.split('\\n'); var lines = text.split('\\n');
for (var i = 0; i < lines.length; i++) { for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim(); var line = lines[i].trim();
if (line) gnizaAddExcludeTag(line); if (line) gniza4cpAddExcludeTag(line);
} }
gnizaUpdateExcludeField(); gniza4cpUpdateExcludeField();
document.getElementById('exclude-modal').close(); document.getElementById('exclude-modal').close();
} }
function gnizaOpenFileBrowser() { function gniza4cpOpenFileBrowser() {
fbSelected = ''; fbSelected = '';
document.getElementById('fb-select-btn').disabled = true; document.getElementById('fb-select-btn').disabled = true;
document.getElementById('fb-modal').showModal(); document.getElementById('fb-modal').showModal();
gnizaLoadDir(''); gniza4cpLoadDir('');
} }
function gnizaLoadDir(path) { function gniza4cpLoadDir(path) {
var ts = document.getElementById('timestamp').value; var ts = document.getElementById('timestamp').value;
var cacheKey = 'fb:' + ts + ':' + path; var cacheKey = 'fb:' + ts + ':' + path;
if (fbCache[cacheKey]) { if (fbCache[cacheKey]) {
gnizaRenderFileList(path, fbCache[cacheKey]); gniza4cpRenderFileList(path, fbCache[cacheKey]);
return; return;
} }
@@ -734,7 +735,7 @@ function gnizaLoadDir(path) {
document.getElementById('fb-tbody').textContent = ''; document.getElementById('fb-tbody').textContent = '';
var url = 'restore.live.cgi?step=fetch_options' var url = 'restore.live.cgi?step=fetch_options'
+ '&remote=' + encodeURIComponent(gnizaRemote) + '&remote=' + encodeURIComponent(gniza4cpRemote)
+ '&timestamp=' + encodeURIComponent(ts) + '&timestamp=' + encodeURIComponent(ts)
+ '&type=files' + '&type=files'
+ (path ? '&path=' + encodeURIComponent(path) : ''); + (path ? '&path=' + encodeURIComponent(path) : '');
@@ -752,7 +753,7 @@ function gnizaLoadDir(path) {
document.getElementById('fb-error').hidden = false; document.getElementById('fb-error').hidden = false;
} else { } else {
fbCache[cacheKey] = data.options; fbCache[cacheKey] = data.options;
gnizaRenderFileList(path, data.options); gniza4cpRenderFileList(path, data.options);
} }
} catch(e) { } catch(e) {
document.getElementById('fb-error').textContent = 'Failed to parse response'; document.getElementById('fb-error').textContent = 'Failed to parse response';
@@ -766,13 +767,13 @@ function gnizaLoadDir(path) {
xhr.send(); xhr.send();
} }
function gnizaRenderBreadcrumbs(path) { function gniza4cpRenderBreadcrumbs(path) {
var ul = document.createElement('ul'); var ul = document.createElement('ul');
var li = document.createElement('li'); var li = document.createElement('li');
var a = document.createElement('a'); var a = document.createElement('a');
a.textContent = 'homedir'; a.textContent = 'homedir';
a.href = '#'; a.href = '#';
a.onclick = function(e) { e.preventDefault(); gnizaLoadDir(''); }; a.onclick = function(e) { e.preventDefault(); gniza4cpLoadDir(''); };
li.appendChild(a); li.appendChild(a);
ul.appendChild(li); ul.appendChild(li);
@@ -786,7 +787,7 @@ function gnizaRenderBreadcrumbs(path) {
a = document.createElement('a'); a = document.createElement('a');
a.textContent = parts[i]; a.textContent = parts[i];
a.href = '#'; a.href = '#';
(function(p) { a.onclick = function(e) { e.preventDefault(); gnizaLoadDir(p); }; })(built); (function(p) { a.onclick = function(e) { e.preventDefault(); gniza4cpLoadDir(p); }; })(built);
li.appendChild(a); li.appendChild(a);
} else { } else {
li.textContent = parts[i]; li.textContent = parts[i];
@@ -800,8 +801,8 @@ function gnizaRenderBreadcrumbs(path) {
bc.appendChild(ul); bc.appendChild(ul);
} }
function gnizaRenderFileList(currentPath, entries) { function gniza4cpRenderFileList(currentPath, entries) {
gnizaRenderBreadcrumbs(currentPath); gniza4cpRenderBreadcrumbs(currentPath);
fbSelected = ''; fbSelected = '';
document.getElementById('fb-select-btn').disabled = true; document.getElementById('fb-select-btn').disabled = true;
@@ -834,9 +835,9 @@ function gnizaRenderFileList(currentPath, entries) {
tr.appendChild(td); tr.appendChild(td);
(function(row, path, dir) { (function(row, path, dir) {
row.onclick = function() { gnizaHighlight(row, path); }; row.onclick = function() { gniza4cpHighlight(row, path); };
if (dir) { if (dir) {
row.ondblclick = function() { gnizaLoadDir(path.replace(/\\/\$/, '')); }; row.ondblclick = function() { gniza4cpLoadDir(path.replace(/\\/\$/, '')); };
} }
})(tr, fullPath, isDir); })(tr, fullPath, isDir);
@@ -844,7 +845,7 @@ function gnizaRenderFileList(currentPath, entries) {
} }
} }
function gnizaHighlight(row, path) { function gniza4cpHighlight(row, path) {
var rows = document.getElementById('fb-tbody').querySelectorAll('tr'); var rows = document.getElementById('fb-tbody').querySelectorAll('tr');
for (var i = 0; i < rows.length; i++) { for (var i = 0; i < rows.length; i++) {
rows[i].classList.remove('bg-primary/10'); rows[i].classList.remove('bg-primary/10');
@@ -854,14 +855,14 @@ function gnizaHighlight(row, path) {
document.getElementById('fb-select-btn').disabled = false; document.getElementById('fb-select-btn').disabled = false;
} }
function gnizaSelectPath() { function gniza4cpSelectPath() {
if (fbSelected) { if (fbSelected) {
document.getElementById('path').value = fbSelected; document.getElementById('path').value = fbSelected;
} }
document.getElementById('fb-modal').close(); document.getElementById('fb-modal').close();
} }
gnizaModeChanged(); gniza4cpModeChanged();
</script> </script>
END_JS END_JS
} }
@@ -887,29 +888,30 @@ sub handle_step3 {
} }
if ($remote eq '' || $timestamp eq '') { if ($remote eq '' || $timestamp eq '') {
GnizaCPanel::UI::set_flash('error', 'Remote and snapshot are required.'); Gniza4cpCPanel::UI::set_flash('error', 'Remote and snapshot are required.');
print "Status: 302 Found\r\n"; print "Status: 302 Found\r\n";
print "Location: index.live.cgi\r\n\r\n"; print "Location: index.live.cgi\r\n\r\n";
exit; exit;
} }
unless (@selected_types) { unless (@selected_types) {
GnizaCPanel::UI::set_flash('error', 'Please select at least one restore type.'); Gniza4cpCPanel::UI::set_flash('error', 'Please select at least one restore type.');
print "Status: 302 Found\r\n"; print "Status: 302 Found\r\n";
print "Location: restore.live.cgi?step=2&remote=" . _uri_escape($remote) . "&timestamp=" . _uri_escape($timestamp) . "\r\n\r\n"; print "Location: restore.live.cgi?step=2&remote=" . _uri_escape($remote) . "&timestamp=" . _uri_escape($timestamp) . "\r\n\r\n";
exit; exit;
} }
print "Content-Type: text/html\r\n\r\n"; print "Content-Type: text/html\r\n\r\n";
print $cpanel->header('GNIZA Backups'); print $cpanel->header('');
print GnizaCPanel::UI::page_header('Restore: Confirm'); print Gniza4cpCPanel::UI::page_header('Restore: Confirm');
print GnizaCPanel::UI::render_flash(); print Gniza4cpCPanel::UI::render_nav('restore.live.cgi');
print Gniza4cpCPanel::UI::render_flash();
my $esc_remote = GnizaCPanel::UI::esc($remote); my $esc_remote = Gniza4cpCPanel::UI::esc($remote);
my $esc_timestamp = GnizaCPanel::UI::esc($timestamp); my $esc_timestamp = Gniza4cpCPanel::UI::esc($timestamp);
my $user = GnizaCPanel::UI::esc(GnizaCPanel::UI::get_current_user()); my $user = Gniza4cpCPanel::UI::esc(Gniza4cpCPanel::UI::get_current_user());
my $types_display = join(', ', map { GnizaCPanel::UI::esc($TYPE_LABELS{$_} // $_) } @selected_types); my $types_display = join(', ', map { Gniza4cpCPanel::UI::esc($TYPE_LABELS{$_} // $_) } @selected_types);
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n}; print qq{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Step 3: Confirm Restore</h2>\n}; print qq{<h2 class="card-title text-sm">Step 3: Confirm Restore</h2>\n};
@@ -921,37 +923,37 @@ sub handle_step3 {
# Show sub-field details for applicable types # Show sub-field details for applicable types
if (grep { $_ eq 'files' } @selected_types) { if (grep { $_ eq 'files' } @selected_types) {
my $path_display = $path ne '' ? GnizaCPanel::UI::esc($path) : 'All files'; my $path_display = $path ne '' ? Gniza4cpCPanel::UI::esc($path) : 'All files';
print qq{<tr><td class="font-medium">Path</td><td>$path_display</td></tr>\n}; print qq{<tr><td class="font-medium">Path</td><td>$path_display</td></tr>\n};
} }
if (grep { $_ eq 'database' } @selected_types) { if (grep { $_ eq 'database' } @selected_types) {
my $db_display = ($dbnames eq '' || $dbnames eq '__ALL__') ? 'All databases' : GnizaCPanel::UI::esc($dbnames); my $db_display = ($dbnames eq '' || $dbnames eq '__ALL__') ? 'All databases' : Gniza4cpCPanel::UI::esc($dbnames);
$db_display =~ s/,/, /g; $db_display =~ s/,/, /g;
print qq{<tr><td class="font-medium">Database</td><td>$db_display</td></tr>\n}; print qq{<tr><td class="font-medium">Database</td><td>$db_display</td></tr>\n};
} }
if (grep { $_ eq 'dbusers' } @selected_types) { if (grep { $_ eq 'dbusers' } @selected_types) {
my $dbu_display = ($dbuser_names eq '' || $dbuser_names eq '__ALL__') ? 'All database users' : GnizaCPanel::UI::esc($dbuser_names); my $dbu_display = ($dbuser_names eq '' || $dbuser_names eq '__ALL__') ? 'All database users' : Gniza4cpCPanel::UI::esc($dbuser_names);
$dbu_display =~ s/,/, /g; $dbu_display =~ s/,/, /g;
print qq{<tr><td class="font-medium">Database Users</td><td>$dbu_display</td></tr>\n}; print qq{<tr><td class="font-medium">Database Users</td><td>$dbu_display</td></tr>\n};
} }
if (grep { $_ eq 'mailbox' } @selected_types) { if (grep { $_ eq 'mailbox' } @selected_types) {
my $mb_display = ($emails eq '' || $emails eq '__ALL__') ? 'All mailboxes' : GnizaCPanel::UI::esc($emails); my $mb_display = ($emails eq '' || $emails eq '__ALL__') ? 'All mailboxes' : Gniza4cpCPanel::UI::esc($emails);
$mb_display =~ s/,/, /g; $mb_display =~ s/,/, /g;
print qq{<tr><td class="font-medium">Mailbox</td><td>$mb_display</td></tr>\n}; print qq{<tr><td class="font-medium">Mailbox</td><td>$mb_display</td></tr>\n};
} }
if (grep { $_ eq 'domains' } @selected_types) { if (grep { $_ eq 'domains' } @selected_types) {
my $dom_display = ($domain_names eq '' || $domain_names eq '__ALL__') ? 'All domains' : GnizaCPanel::UI::esc($domain_names); my $dom_display = ($domain_names eq '' || $domain_names eq '__ALL__') ? 'All domains' : Gniza4cpCPanel::UI::esc($domain_names);
$dom_display =~ s/,/, /g; $dom_display =~ s/,/, /g;
print qq{<tr><td class="font-medium">Domains</td><td>$dom_display</td></tr>\n}; print qq{<tr><td class="font-medium">Domains</td><td>$dom_display</td></tr>\n};
} }
if (grep { $_ eq 'ssl' } @selected_types) { if (grep { $_ eq 'ssl' } @selected_types) {
my $ssl_display = ($ssl_names eq '' || $ssl_names eq '__ALL__') ? 'All certificates' : GnizaCPanel::UI::esc($ssl_names); my $ssl_display = ($ssl_names eq '' || $ssl_names eq '__ALL__') ? 'All certificates' : Gniza4cpCPanel::UI::esc($ssl_names);
$ssl_display =~ s/,/, /g; $ssl_display =~ s/,/, /g;
print qq{<tr><td class="font-medium">SSL</td><td>$ssl_display</td></tr>\n}; print qq{<tr><td class="font-medium">SSL</td><td>$ssl_display</td></tr>\n};
} }
if ($exclude_paths ne '') { if ($exclude_paths ne '') {
my $exclude_display = GnizaCPanel::UI::esc($exclude_paths); my $exclude_display = Gniza4cpCPanel::UI::esc($exclude_paths);
$exclude_display =~ s/,/, /g; $exclude_display =~ s/,/, /g;
print qq{<tr><td class="font-medium">Exclude</td><td>$exclude_display</td></tr>\n}; print qq{<tr><td class="font-medium">Exclude</td><td>$exclude_display</td></tr>\n};
} }
@@ -966,14 +968,14 @@ sub handle_step3 {
for my $t (@selected_types) { for my $t (@selected_types) {
print qq{<input type="hidden" name="type_$t" value="1">\n}; print qq{<input type="hidden" name="type_$t" value="1">\n};
} }
print qq{<input type="hidden" name="path" value="} . GnizaCPanel::UI::esc($path) . qq{">\n}; print qq{<input type="hidden" name="path" value="} . Gniza4cpCPanel::UI::esc($path) . qq{">\n};
print qq{<input type="hidden" name="dbnames" value="} . GnizaCPanel::UI::esc($dbnames) . qq{">\n}; print qq{<input type="hidden" name="dbnames" value="} . Gniza4cpCPanel::UI::esc($dbnames) . qq{">\n};
print qq{<input type="hidden" name="dbuser_names" value="} . GnizaCPanel::UI::esc($dbuser_names) . qq{">\n}; print qq{<input type="hidden" name="dbuser_names" value="} . Gniza4cpCPanel::UI::esc($dbuser_names) . qq{">\n};
print qq{<input type="hidden" name="emails" value="} . GnizaCPanel::UI::esc($emails) . qq{">\n}; print qq{<input type="hidden" name="emails" value="} . Gniza4cpCPanel::UI::esc($emails) . qq{">\n};
print qq{<input type="hidden" name="domain_names" value="} . GnizaCPanel::UI::esc($domain_names) . qq{">\n}; print qq{<input type="hidden" name="domain_names" value="} . Gniza4cpCPanel::UI::esc($domain_names) . qq{">\n};
print qq{<input type="hidden" name="ssl_names" value="} . GnizaCPanel::UI::esc($ssl_names) . qq{">\n}; print qq{<input type="hidden" name="ssl_names" value="} . Gniza4cpCPanel::UI::esc($ssl_names) . qq{">\n};
print qq{<input type="hidden" name="exclude_paths" value="} . GnizaCPanel::UI::esc($exclude_paths) . qq{">\n}; print qq{<input type="hidden" name="exclude_paths" value="} . Gniza4cpCPanel::UI::esc($exclude_paths) . qq{">\n};
print GnizaCPanel::UI::csrf_hidden_field(); print Gniza4cpCPanel::UI::csrf_hidden_field();
print qq{<div class="flex items-center gap-2">\n}; print qq{<div class="flex items-center gap-2">\n};
print qq{ <button type="submit" class="btn btn-error btn-sm" onclick="return confirm('Are you sure? This may overwrite existing data.')">Execute Restore</button>\n}; print qq{ <button type="submit" class="btn btn-error btn-sm" onclick="return confirm('Are you sure? This may overwrite existing data.')">Execute Restore</button>\n};
@@ -981,15 +983,15 @@ sub handle_step3 {
print qq{</div>\n}; print qq{</div>\n};
print qq{</form>\n}; print qq{</form>\n};
print GnizaCPanel::UI::page_footer(); print Gniza4cpCPanel::UI::page_footer();
print $cpanel->footer(); print $cpanel->footer();
} }
# ── Step 4: Execute ─────────────────────────────────────────── # ── Step 4: Execute ───────────────────────────────────────────
sub handle_step4 { sub handle_step4 {
unless ($method eq 'POST' && GnizaCPanel::UI::verify_csrf_token($form->{'gniza_csrf'})) { unless ($method eq 'POST' && Gniza4cpCPanel::UI::verify_csrf_token($form->{'gniza4cp_csrf'})) {
GnizaCPanel::UI::set_flash('error', 'Invalid or expired form token.'); Gniza4cpCPanel::UI::set_flash('error', 'Invalid or expired form token.');
print "Status: 302 Found\r\n"; print "Status: 302 Found\r\n";
print "Location: index.live.cgi\r\n\r\n"; print "Location: index.live.cgi\r\n\r\n";
exit; exit;
@@ -1013,126 +1015,52 @@ sub handle_step4 {
} }
unless (@selected_types) { unless (@selected_types) {
GnizaCPanel::UI::set_flash('error', 'No restore types selected.'); Gniza4cpCPanel::UI::set_flash('error', 'No restore types selected.');
print "Status: 302 Found\r\n"; print "Status: 302 Found\r\n";
print "Location: index.live.cgi\r\n\r\n"; print "Location: index.live.cgi\r\n\r\n";
exit; exit;
} }
print "Content-Type: text/html\r\n\r\n"; # Build types_str encoding: type1;type2:item1,item2;type3
print $cpanel->header('GNIZA Backups'); my @type_parts;
print GnizaCPanel::UI::page_header('Restore Results');
print qq{<div class="card bg-white shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Restore Results</h2>\n};
my @results;
for my $type (@selected_types) { for my $type (@selected_types) {
my $type_label = $TYPE_LABELS{$type} // $type; if ($type eq 'database') {
my $items = ($dbnames eq '' || $dbnames eq '__ALL__') ? '' : $dbnames;
if ($type eq 'account') { push @type_parts, $items ne '' ? "database:$items" : 'database';
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_ACCOUNT', $remote, $timestamp, $exclude_paths);
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
}
elsif ($type eq 'files') {
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_FILES', $remote, $timestamp, $path, $exclude_paths);
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
}
elsif ($type eq 'cron') {
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_CRON', $remote, $timestamp);
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
}
elsif ($type eq 'database') {
if ($dbnames eq '' || $dbnames eq '__ALL__') {
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_DATABASE', $remote, $timestamp, '');
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
} else {
for my $item (split /,/, $dbnames) {
next if $item eq '';
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_DATABASE', $remote, $timestamp, $item);
push @results, { ok => $ok, label => $item, msg => $ok ? $stdout : $err };
}
}
} }
elsif ($type eq 'dbusers') { elsif ($type eq 'dbusers') {
if ($dbuser_names eq '' || $dbuser_names eq '__ALL__') { my $items = ($dbuser_names eq '' || $dbuser_names eq '__ALL__') ? '' : $dbuser_names;
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_DBUSERS', $remote, $timestamp, ''); push @type_parts, $items ne '' ? "dbusers:$items" : 'dbusers';
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
} else {
for my $item (split /,/, $dbuser_names) {
next if $item eq '';
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_DBUSERS', $remote, $timestamp, $item);
push @results, { ok => $ok, label => $item, msg => $ok ? $stdout : $err };
}
}
} }
elsif ($type eq 'mailbox') { elsif ($type eq 'mailbox') {
if ($emails eq '' || $emails eq '__ALL__') { my $items = ($emails eq '' || $emails eq '__ALL__') ? '' : $emails;
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_MAILBOX', $remote, $timestamp, ''); push @type_parts, $items ne '' ? "mailbox:$items" : 'mailbox';
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
} else {
for my $item (split /,/, $emails) {
next if $item eq '';
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_MAILBOX', $remote, $timestamp, $item);
push @results, { ok => $ok, label => $item, msg => $ok ? $stdout : $err };
}
}
} }
elsif ($type eq 'domains') { elsif ($type eq 'domains') {
if ($domain_names eq '' || $domain_names eq '__ALL__') { my $items = ($domain_names eq '' || $domain_names eq '__ALL__') ? '' : $domain_names;
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_DOMAINS', $remote, $timestamp, ''); push @type_parts, $items ne '' ? "domains:$items" : 'domains';
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err };
} else {
for my $item (split /,/, $domain_names) {
next if $item eq '';
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_DOMAINS', $remote, $timestamp, $item);
push @results, { ok => $ok, label => $item, msg => $ok ? $stdout : $err };
}
}
} }
elsif ($type eq 'ssl') { elsif ($type eq 'ssl') {
if ($ssl_names eq '' || $ssl_names eq '__ALL__') { my $items = ($ssl_names eq '' || $ssl_names eq '__ALL__') ? '' : $ssl_names;
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_SSL', $remote, $timestamp, ''); push @type_parts, $items ne '' ? "ssl:$items" : 'ssl';
push @results, { ok => $ok, label => $type_label, msg => $ok ? $stdout : $err }; }
else {
push @type_parts, $type; # account, files, cron — no items
}
}
my $types_str = join(';', @type_parts);
my ($ok, $stdout, $err) = _adminbin_call('START_RESTORE',
$remote, $timestamp, $types_str, $path, $exclude_paths);
if ($ok) {
Gniza4cpCPanel::UI::set_flash('success', 'Restore started. Watch progress in the activity log.');
print "Status: 302 Found\r\n";
print "Location: logs.live.cgi\r\n\r\n";
} else { } else {
for my $item (split /,/, $ssl_names) { Gniza4cpCPanel::UI::set_flash('error', "Restore failed to start: $err");
next if $item eq ''; print "Status: 302 Found\r\n";
my ($ok, $stdout, $err) = _adminbin_call('RESTORE_SSL', $remote, $timestamp, $item); print "Location: index.live.cgi\r\n\r\n";
push @results, { ok => $ok, label => $item, msg => $ok ? $stdout : $err };
}
}
}
}
_render_results(\@results);
print qq{</div>\n</div>\n};
print qq{<a href="index.live.cgi" class="btn btn-info btn-sm">Back to Home</a>\n};
print GnizaCPanel::UI::page_footer();
print $cpanel->footer();
}
sub _render_results {
my ($results) = @_;
for my $r (@$results) {
my $icon_class = $r->{ok} ? 'text-success' : 'text-error';
my $icon = $r->{ok} ? '&#10003;' : '&#10007;';
my $label = GnizaCPanel::UI::esc($r->{label});
my $msg = GnizaCPanel::UI::esc($r->{msg} // '');
# Clean up the "OK\n" prefix from successful results
$msg =~ s/^OK\s*//;
print qq{<div class="flex items-start gap-2 mb-3 p-3 rounded-lg bg-base-200">\n};
print qq{ <span class="$icon_class font-bold text-lg">$icon</span>\n};
print qq{ <div>\n};
print qq{ <div class="font-medium text-sm">$label</div>\n};
if ($msg ne '') {
print qq{ <pre class="text-xs mt-1 whitespace-pre-wrap max-h-32 overflow-y-auto">$msg</pre>\n};
}
print qq{ </div>\n};
print qq{</div>\n};
} }
exit;
} }

View File

@@ -1,18 +1,18 @@
# gniza configuration # gniza4cp configuration
# Copy to /etc/gniza/gniza.conf and edit # Copy to /etc/gniza4cp/gniza4cp.conf and edit
# #
# Remote destinations: /etc/gniza/remotes.d/<name>.conf # Remote destinations: /etc/gniza4cp/remotes.d/<name>.conf
# Backup schedules: /etc/gniza/schedules.d/<name>.conf # Backup schedules: /etc/gniza4cp/schedules.d/<name>.conf
# ── Local Settings ───────────────────────────────────────────── # ── Local Settings ─────────────────────────────────────────────
TEMP_DIR="/usr/local/gniza/workdir" # Working directory for pkgacct output TEMP_DIR="/usr/local/gniza4cp/workdir" # Working directory for pkgacct output
# ── Account Filtering ────────────────────────────────────────── # ── Account Filtering ──────────────────────────────────────────
INCLUDE_ACCOUNTS="" # Comma-separated list, empty = all accounts INCLUDE_ACCOUNTS="" # Comma-separated list, empty = all accounts
EXCLUDE_ACCOUNTS="nobody" # Comma-separated list of accounts to exclude EXCLUDE_ACCOUNTS="nobody" # Comma-separated list of accounts to exclude
# ── Logging ──────────────────────────────────────────────────── # ── Logging ────────────────────────────────────────────────────
LOG_DIR="/var/log/gniza" # Log directory LOG_DIR="/var/log/gniza4cp" # Log directory
LOG_LEVEL="info" # debug, info, warn, error LOG_LEVEL="info" # debug, info, warn, error
LOG_RETAIN=90 # Days to keep log files LOG_RETAIN=90 # Days to keep log files
@@ -29,7 +29,7 @@ SMTP_FROM="" # From address (falls back to SMTP_USER)
SMTP_SECURITY="tls" # tls (STARTTLS), ssl (implicit), none SMTP_SECURITY="tls" # tls (STARTTLS), ssl (implicit), none
# ── Advanced ─────────────────────────────────────────────────── # ── Advanced ───────────────────────────────────────────────────
LOCK_FILE="/var/run/gniza.lock" LOCK_FILE="/var/run/gniza4cp.lock"
SSH_TIMEOUT=30 # SSH connection timeout in seconds SSH_TIMEOUT=30 # SSH connection timeout in seconds
SSH_RETRIES=3 # Number of rsync retry attempts SSH_RETRIES=3 # Number of rsync retry attempts
RSYNC_EXTRA_OPTS="" # Extra options to pass to rsync RSYNC_EXTRA_OPTS="" # Extra options to pass to rsync

View File

@@ -1,7 +1,7 @@
# gniza remote destination config # gniza4cp remote destination config
# Copy to /etc/gniza/remotes.d/<name>.conf and edit # Copy to /etc/gniza4cp/remotes.d/<name>.conf and edit
# #
# Each file in /etc/gniza/remotes.d/ defines a remote backup destination. # Each file in /etc/gniza4cp/remotes.d/ defines a remote backup destination.
# The filename (without .conf) is the remote name used with --remote=NAME. # The filename (without .conf) is the remote name used with --remote=NAME.
# ── Remote Type ─────────────────────────────────────────────── # ── Remote Type ───────────────────────────────────────────────

View File

@@ -1,7 +1,7 @@
# gniza schedule config # gniza4cp schedule config
# Copy to /etc/gniza/schedules.d/<name>.conf and edit # Copy to /etc/gniza4cp/schedules.d/<name>.conf and edit
# #
# Each file in /etc/gniza/schedules.d/ defines a backup schedule. # Each file in /etc/gniza4cp/schedules.d/ defines a backup schedule.
# The filename (without .conf) is the schedule name. # The filename (without .conf) is the schedule name.
# ── Schedule ────────────────────────────────────────────────── # ── Schedule ──────────────────────────────────────────────────

View File

Before

Width:  |  Height:  |  Size: 685 B

After

Width:  |  Height:  |  Size: 685 B

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/accounts.sh — cPanel account discovery, include/exclude filtering # gniza4cp/lib/accounts.sh — cPanel account discovery, include/exclude filtering
get_all_accounts() { get_all_accounts() {
if [[ -f /etc/trueuserdomains ]]; then if [[ -f /etc/trueuserdomains ]]; then

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/config.sh — Shell-variable config loading & validation # gniza4cp/lib/config.sh — Shell-variable config loading & validation
# Safe config parser — reads KEY=VALUE lines without executing arbitrary code. # Safe config parser — reads KEY=VALUE lines without executing arbitrary code.
# Only processes lines matching ^[A-Z_][A-Z_0-9]*= and strips surrounding quotes. # Only processes lines matching ^[A-Z_][A-Z_0-9]*= and strips surrounding quotes.
@@ -28,7 +28,7 @@ load_config() {
local config_file="${1:-$DEFAULT_CONFIG_FILE}" local config_file="${1:-$DEFAULT_CONFIG_FILE}"
if [[ ! -f "$config_file" ]]; then if [[ ! -f "$config_file" ]]; then
die "Config file not found: $config_file (run 'gniza init' to create one)" die "Config file not found: $config_file (create via WHM or copy gniza4cp.conf.example)"
fi fi
# Parse the config (safe key=value reader, no code execution) # Parse the config (safe key=value reader, no code execution)
@@ -56,7 +56,7 @@ load_config() {
USER_RESTORE_REMOTES="${USER_RESTORE_REMOTES:-$DEFAULT_USER_RESTORE_REMOTES}" USER_RESTORE_REMOTES="${USER_RESTORE_REMOTES:-$DEFAULT_USER_RESTORE_REMOTES}"
# --debug flag overrides config # --debug flag overrides config
[[ "${GNIZA_DEBUG:-false}" == "true" ]] && LOG_LEVEL="debug" [[ "${GNIZA4CP_DEBUG:-false}" == "true" ]] && LOG_LEVEL="debug"
export TEMP_DIR INCLUDE_ACCOUNTS EXCLUDE_ACCOUNTS BWLIMIT RETENTION_COUNT export TEMP_DIR INCLUDE_ACCOUNTS EXCLUDE_ACCOUNTS BWLIMIT RETENTION_COUNT
export LOG_DIR LOG_LEVEL LOG_RETAIN NOTIFY_EMAIL NOTIFY_ON export LOG_DIR LOG_LEVEL LOG_RETAIN NOTIFY_EMAIL NOTIFY_ON

View File

@@ -1,12 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/constants.sh — Version, exit codes, colors # gniza4cp/lib/constants.sh — Version, exit codes, colors
# shellcheck disable=SC2034 # constants are used by sourcing scripts # shellcheck disable=SC2034 # constants are used by sourcing scripts
[[ -n "${_GNIZA_CONSTANTS_LOADED:-}" ]] && return 0 [[ -n "${_GNIZA4CP_CONSTANTS_LOADED:-}" ]] && return 0
_GNIZA_CONSTANTS_LOADED=1 _GNIZA4CP_CONSTANTS_LOADED=1
readonly GNIZA_VERSION="0.1.0" readonly GNIZA4CP_VERSION="0.1.0"
readonly GNIZA_NAME="gniza" readonly GNIZA4CP_NAME="gniza4cp"
# Exit codes # Exit codes
readonly EXIT_OK=0 readonly EXIT_OK=0
@@ -36,15 +36,15 @@ readonly DEFAULT_REMOTE_AUTH_METHOD="key"
readonly DEFAULT_REMOTE_PORT=22 readonly DEFAULT_REMOTE_PORT=22
readonly DEFAULT_REMOTE_USER="root" readonly DEFAULT_REMOTE_USER="root"
readonly DEFAULT_REMOTE_BASE="/backups" readonly DEFAULT_REMOTE_BASE="/backups"
readonly DEFAULT_TEMP_DIR="/usr/local/gniza/workdir" readonly DEFAULT_TEMP_DIR="/usr/local/gniza4cp/workdir"
readonly DEFAULT_EXCLUDE_ACCOUNTS="nobody" readonly DEFAULT_EXCLUDE_ACCOUNTS="nobody"
readonly DEFAULT_BWLIMIT=0 readonly DEFAULT_BWLIMIT=0
readonly DEFAULT_RETENTION_COUNT=30 readonly DEFAULT_RETENTION_COUNT=30
readonly DEFAULT_LOG_DIR="/var/log/gniza" readonly DEFAULT_LOG_DIR="/var/log/gniza4cp"
readonly DEFAULT_LOG_LEVEL="info" readonly DEFAULT_LOG_LEVEL="info"
readonly DEFAULT_LOG_RETAIN=90 readonly DEFAULT_LOG_RETAIN=90
readonly DEFAULT_NOTIFY_ON="failure" readonly DEFAULT_NOTIFY_ON="failure"
readonly DEFAULT_LOCK_FILE="/var/run/gniza.lock" readonly DEFAULT_LOCK_FILE="/var/run/gniza4cp.lock"
readonly DEFAULT_SSH_TIMEOUT=30 readonly DEFAULT_SSH_TIMEOUT=30
readonly DEFAULT_SSH_RETRIES=3 readonly DEFAULT_SSH_RETRIES=3
readonly DEFAULT_REMOTE_TYPE="ssh" readonly DEFAULT_REMOTE_TYPE="ssh"
@@ -52,4 +52,4 @@ readonly DEFAULT_S3_REGION="us-east-1"
readonly DEFAULT_SMTP_PORT=587 readonly DEFAULT_SMTP_PORT=587
readonly DEFAULT_SMTP_SECURITY="tls" readonly DEFAULT_SMTP_SECURITY="tls"
readonly DEFAULT_USER_RESTORE_REMOTES="all" readonly DEFAULT_USER_RESTORE_REMOTES="all"
readonly DEFAULT_CONFIG_FILE="/etc/gniza/gniza.conf" readonly DEFAULT_CONFIG_FILE="/etc/gniza4cp/gniza4cp.conf"

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/locking.sh — flock-based concurrency control # gniza4cp/lib/locking.sh — flock-based concurrency control
declare -g LOCK_FD="" declare -g LOCK_FD=""
@@ -11,7 +11,7 @@ acquire_lock() {
exec {LOCK_FD}>"$lock_file" exec {LOCK_FD}>"$lock_file"
if ! flock -n "$LOCK_FD"; then if ! flock -n "$LOCK_FD"; then
die "Another gniza process is running (lock: $lock_file)" "$EXIT_LOCKED" die "Another gniza4cp process is running (lock: $lock_file)" "$EXIT_LOCKED"
fi fi
echo $$ >&"$LOCK_FD" echo $$ >&"$LOCK_FD"

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/logging.sh — Per-run log files, log_info/warn/error/debug # gniza4cp/lib/logging.sh — Per-run log files, log_info/warn/error/debug
declare -g LOG_FILE="" declare -g LOG_FILE=""
@@ -17,32 +17,36 @@ init_logging() {
local log_dir="${LOG_DIR:-$DEFAULT_LOG_DIR}" local log_dir="${LOG_DIR:-$DEFAULT_LOG_DIR}"
mkdir -p "$log_dir" || die "Cannot create log directory: $log_dir" mkdir -p "$log_dir" || die "Cannot create log directory: $log_dir"
LOG_FILE="$log_dir/gniza-$(date -u +%Y%m%d-%H%M%S).log" LOG_FILE="$log_dir/gniza4cp-$(date -u +%Y%m%d-%H%M%S).log"
touch "$LOG_FILE" || die "Cannot write to log file: $LOG_FILE" touch "$LOG_FILE" || die "Cannot write to log file: $LOG_FILE"
# Clean old logs # Clean old logs
local retain="${LOG_RETAIN:-$DEFAULT_LOG_RETAIN}" local retain="${LOG_RETAIN:-$DEFAULT_LOG_RETAIN}"
find "$log_dir" -name "gniza-*.log" -mtime +"$retain" -delete 2>/dev/null || true find "$log_dir" -name "gniza4cp-*.log" -mtime +"$retain" -delete 2>/dev/null || true
} }
_log() { _log() {
local level="$1"; shift local level="$1"; shift
local msg="$*" local msg="$*"
local configured_level="${LOG_LEVEL:-$DEFAULT_LOG_LEVEL}"
local level_num; level_num=$(_log_level_num "$level") local ts; ts=$(date -u +"%d/%m/%Y %H:%M:%S")
local configured_num; configured_num=$(_log_level_num "$configured_level")
(( level_num < configured_num )) && return 0
local ts; ts=$(date -u +"%Y-%m-%d %H:%M:%S")
local upper; upper=$(echo "$level" | tr '[:lower:]' '[:upper:]') local upper; upper=$(echo "$level" | tr '[:lower:]' '[:upper:]')
local line="[$ts] [$upper] $msg" local line="[$ts] [$upper] $msg"
# Always write to log file if initialized local configured_level="${LOG_LEVEL:-$DEFAULT_LOG_LEVEL}"
[[ -n "$LOG_FILE" ]] && echo "$line" >> "$LOG_FILE" local level_num; level_num=$(_log_level_num "$level")
local configured_num; configured_num=$(_log_level_num "$configured_level")
# Log file: always write info/warn/error; debug only when LOG_LEVEL=debug
if [[ -n "$LOG_FILE" ]]; then
if [[ "$level" != "debug" ]] || (( level_num >= configured_num )); then
echo "$line" >> "$LOG_FILE"
fi
fi
# Console: only print if level meets configured threshold
(( level_num < configured_num )) && return 0
# Print to stderr based on level
case "$level" in case "$level" in
error) echo "${C_RED}${line}${C_RESET}" >&2 ;; error) echo "${C_RED}${line}${C_RESET}" >&2 ;;
warn) echo "${C_YELLOW}${line}${C_RESET}" >&2 ;; warn) echo "${C_YELLOW}${line}${C_RESET}" >&2 ;;

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/notify.sh — Email notifications (SMTP via curl or legacy mail/sendmail) # gniza4cp/lib/notify.sh — Email notifications (SMTP via curl or legacy mail/sendmail)
_send_via_smtp() { _send_via_smtp() {
local subject="$1" local subject="$1"
@@ -116,7 +116,7 @@ send_notification() {
esac esac
local hostname; hostname=$(hostname -f) local hostname; hostname=$(hostname -f)
local full_subject="[gniza] [$hostname] $subject" local full_subject="[gniza4cp] [$hostname] $subject"
log_debug "Sending notification to $NOTIFY_EMAIL: $full_subject" log_debug "Sending notification to $NOTIFY_EMAIL: $full_subject"
@@ -155,7 +155,7 @@ send_backup_report() {
body+="Backup Report: $status"$'\n' body+="Backup Report: $status"$'\n'
body+="=============================="$'\n' body+="=============================="$'\n'
body+="Hostname: $(hostname -f)"$'\n' body+="Hostname: $(hostname -f)"$'\n'
body+="Timestamp: $(date -u +"%Y-%m-%d %H:%M:%S UTC")"$'\n' body+="Timestamp: $(date -u +"%d/%m/%Y %H:%M:%S UTC")"$'\n'
body+="Duration: $(human_duration "$duration")"$'\n' body+="Duration: $(human_duration "$duration")"$'\n'
body+=""$'\n' body+=""$'\n'
body+="Accounts: $total total, $succeeded succeeded, $failed failed"$'\n' body+="Accounts: $total total, $succeeded succeeded, $failed failed"$'\n'

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/pkgacct.sh — pkgacct execution, .sql gzipping, temp cleanup # gniza4cp/lib/pkgacct.sh — pkgacct execution, .sql gzipping, temp cleanup
run_pkgacct() { run_pkgacct() {
local user="$1" local user="$1"

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/rclone.sh — Rclone transport layer for S3 and Google Drive remotes # gniza4cp/lib/rclone.sh — Rclone transport layer for S3 and Google Drive remotes
[[ -n "${_GNIZA_RCLONE_LOADED:-}" ]] && return 0 [[ -n "${_GNIZA4CP_RCLONE_LOADED:-}" ]] && return 0
_GNIZA_RCLONE_LOADED=1 _GNIZA4CP_RCLONE_LOADED=1
# ── Mode Detection ──────────────────────────────────────────── # ── Mode Detection ────────────────────────────────────────────
@@ -17,7 +17,7 @@ _build_rclone_config() {
local old_umask local old_umask
old_umask=$(umask) old_umask=$(umask)
umask 077 umask 077
tmpfile=$(mktemp /tmp/gniza-rclone-XXXXXX.conf) || { tmpfile=$(mktemp /tmp/gniza4cp-rclone-XXXXXX.conf) || {
umask "$old_umask" umask "$old_umask"
log_error "Failed to create temp rclone config" log_error "Failed to create temp rclone config"
return 1 return 1

View File

@@ -1,11 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/remotes.sh — Remote discovery and context switching # gniza4cp/lib/remotes.sh — Remote discovery and context switching
# #
# Remote destinations are configured in /etc/gniza/remotes.d/<name>.conf. # Remote destinations are configured in /etc/gniza4cp/remotes.d/<name>.conf.
# Each config overrides REMOTE_* globals so existing functions (ssh, # Each config overrides REMOTE_* globals so existing functions (ssh,
# transfer, snapshot, retention) work unchanged. # transfer, snapshot, retention) work unchanged.
readonly REMOTES_DIR="/etc/gniza/remotes.d" readonly REMOTES_DIR="/etc/gniza4cp/remotes.d"
# ── Saved state for legacy globals ───────────────────────────── # ── Saved state for legacy globals ─────────────────────────────
@@ -132,7 +132,7 @@ load_remote() {
GDRIVE_SERVICE_ACCOUNT_FILE="${GDRIVE_SERVICE_ACCOUNT_FILE:-}" GDRIVE_SERVICE_ACCOUNT_FILE="${GDRIVE_SERVICE_ACCOUNT_FILE:-}"
GDRIVE_ROOT_FOLDER_ID="${GDRIVE_ROOT_FOLDER_ID:-}" GDRIVE_ROOT_FOLDER_ID="${GDRIVE_ROOT_FOLDER_ID:-}"
# shellcheck disable=SC2034 # used by bin/gniza # shellcheck disable=SC2034 # used by bin/gniza4cp
CURRENT_REMOTE_NAME="$name" CURRENT_REMOTE_NAME="$name"
if [[ "$REMOTE_TYPE" == "ssh" ]]; then if [[ "$REMOTE_TYPE" == "ssh" ]]; then
log_debug "Loaded remote '$name': ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT} -> ${REMOTE_BASE}" log_debug "Loaded remote '$name': ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT} -> ${REMOTE_BASE}"
@@ -266,6 +266,6 @@ get_target_remotes() {
fi fi
# No remotes configured # No remotes configured
log_error "No remotes configured. Run 'gniza init remote <name>' to add one." log_error "No remotes configured. Add one via WHM → Remotes."
return 1 return 1
} }

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/restore.sh — Full account, files, database, mailbox, server restores # gniza4cp/lib/restore.sh — Full account, files, database, mailbox, server restores
# Helper: build rsync download command args for SSH mode # Helper: build rsync download command args for SSH mode
_rsync_download() { _rsync_download() {

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/retention.sh — Delete old snapshots beyond RETENTION_COUNT on remote # gniza4cp/lib/retention.sh — Delete old snapshots beyond RETENTION_COUNT on remote
enforce_retention() { enforce_retention() {
local user="$1" local user="$1"

View File

@@ -1,17 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/schedule.sh — Cron management for decoupled schedules # gniza4cp/lib/schedule.sh — Cron management for decoupled schedules
# #
# Schedules are defined in /etc/gniza/schedules.d/<name>.conf: # Schedules are defined in /etc/gniza4cp/schedules.d/<name>.conf:
# SCHEDULE="hourly|daily|weekly|monthly|custom" # SCHEDULE="hourly|daily|weekly|monthly|custom"
# SCHEDULE_TIME="HH:MM" # SCHEDULE_TIME="HH:MM"
# SCHEDULE_DAY="" # dow (0-6) for weekly, dom (1-28) for monthly # SCHEDULE_DAY="" # dow (0-6) for weekly, dom (1-28) for monthly
# SCHEDULE_CRON="" # full 5-field cron expr for custom # SCHEDULE_CRON="" # full 5-field cron expr for custom
# REMOTES="" # comma-separated remote names (empty = all) # REMOTES="" # comma-separated remote names (empty = all)
# #
# Cron lines are tagged with "# gniza:<name>" for clean install/remove. # Cron lines are tagged with "# gniza4cp:<name>" for clean install/remove.
readonly GNIZA_CRON_TAG="# gniza:" readonly GNIZA4CP_CRON_TAG="# gniza4cp:"
readonly SCHEDULES_DIR="/etc/gniza/schedules.d" readonly SCHEDULES_DIR="/etc/gniza4cp/schedules.d"
# ── Discovery ───────────────────────────────────────────────── # ── Discovery ─────────────────────────────────────────────────
@@ -148,13 +148,13 @@ build_cron_line() {
extra_flags+=" --skip-suspended" extra_flags+=" --skip-suspended"
fi fi
echo "$cron_expr /usr/local/bin/gniza backup${extra_flags} >/dev/null 2>&1" echo "$cron_expr /usr/local/bin/gniza4cp backup${extra_flags} >/dev/null 2>&1"
} }
# ── Crontab Management ──────────────────────────────────────── # ── Crontab Management ────────────────────────────────────────
# Install cron entries for all schedules in schedules.d/. # Install cron entries for all schedules in schedules.d/.
# Strips any existing gniza entries first, then appends new ones. # Strips any existing gniza4cp entries first, then appends new ones.
install_schedules() { install_schedules() {
if ! has_schedules; then if ! has_schedules; then
log_error "No schedules configured in $SCHEDULES_DIR" log_error "No schedules configured in $SCHEDULES_DIR"
@@ -178,7 +178,7 @@ install_schedules() {
local cron_line local cron_line
cron_line=$(build_cron_line "$sname") || { log_error "Skipping schedule '$sname': invalid schedule"; continue; } cron_line=$(build_cron_line "$sname") || { log_error "Skipping schedule '$sname': invalid schedule"; continue; }
new_lines+="${GNIZA_CRON_TAG}${sname}"$'\n' new_lines+="${GNIZA4CP_CRON_TAG}${sname}"$'\n'
new_lines+="${cron_line}"$'\n' new_lines+="${cron_line}"$'\n'
((count++)) || true ((count++)) || true
done <<< "$schedules" done <<< "$schedules"
@@ -188,14 +188,14 @@ install_schedules() {
return 1 return 1
fi fi
# Get current crontab, strip old gniza lines # Get current crontab, strip old gniza4cp lines
local current_crontab="" local current_crontab=""
current_crontab=$(crontab -l 2>/dev/null) || true current_crontab=$(crontab -l 2>/dev/null) || true
local filtered="" local filtered=""
local skip_next=false local skip_next=false
while IFS= read -r line; do while IFS= read -r line; do
if [[ "$line" == "${GNIZA_CRON_TAG}"* ]]; then if [[ "$line" == "${GNIZA4CP_CRON_TAG}"* ]]; then
skip_next=true skip_next=true
continue continue
fi fi
@@ -206,6 +206,10 @@ install_schedules() {
filtered+="$line"$'\n' filtered+="$line"$'\n'
done <<< "$current_crontab" done <<< "$current_crontab"
# Append daily stats collection (runs at 05:00 UTC)
new_lines+="${GNIZA4CP_CRON_TAG}_stats"$'\n'
new_lines+="0 5 * * * /usr/local/bin/gniza4cp stats >> /var/log/gniza4cp/cron-stats.log 2>&1"$'\n'
# Append new lines # Append new lines
local final="${filtered}${new_lines}" local final="${filtered}${new_lines}"
@@ -228,7 +232,7 @@ install_schedules() {
done <<< "$schedules" done <<< "$schedules"
} }
# Display current gniza cron entries. # Display current gniza4cp cron entries.
show_schedules() { show_schedules() {
local current_crontab="" local current_crontab=""
current_crontab=$(crontab -l 2>/dev/null) || true current_crontab=$(crontab -l 2>/dev/null) || true
@@ -242,15 +246,15 @@ show_schedules() {
local next_is_command=false local next_is_command=false
local current_tag="" local current_tag=""
while IFS= read -r line; do while IFS= read -r line; do
if [[ "$line" == "${GNIZA_CRON_TAG}"* ]]; then if [[ "$line" == "${GNIZA4CP_CRON_TAG}"* ]]; then
current_tag="${line#"$GNIZA_CRON_TAG"}" current_tag="${line#"$GNIZA4CP_CRON_TAG"}"
next_is_command=true next_is_command=true
continue continue
fi fi
if [[ "$next_is_command" == "true" ]]; then if [[ "$next_is_command" == "true" ]]; then
next_is_command=false next_is_command=false
if [[ "$found" == "false" ]]; then if [[ "$found" == "false" ]]; then
echo "Current gniza schedules:" echo "Current gniza4cp schedules:"
echo "" echo ""
found=true found=true
fi fi
@@ -259,11 +263,11 @@ show_schedules() {
done <<< "$current_crontab" done <<< "$current_crontab"
if [[ "$found" == "false" ]]; then if [[ "$found" == "false" ]]; then
echo "No gniza schedule entries in crontab." echo "No gniza4cp schedule entries in crontab."
fi fi
} }
# Remove all gniza cron entries. # Remove all gniza4cp cron entries.
remove_schedules() { remove_schedules() {
local current_crontab="" local current_crontab=""
current_crontab=$(crontab -l 2>/dev/null) || true current_crontab=$(crontab -l 2>/dev/null) || true
@@ -277,7 +281,7 @@ remove_schedules() {
local skip_next=false local skip_next=false
local removed=0 local removed=0
while IFS= read -r line; do while IFS= read -r line; do
if [[ "$line" == "${GNIZA_CRON_TAG}"* ]]; then if [[ "$line" == "${GNIZA4CP_CRON_TAG}"* ]]; then
skip_next=true skip_next=true
((removed++)) || true ((removed++)) || true
continue continue
@@ -290,7 +294,7 @@ remove_schedules() {
done <<< "$current_crontab" done <<< "$current_crontab"
if (( removed == 0 )); then if (( removed == 0 )); then
echo "No gniza schedule entries found in crontab." echo "No gniza4cp schedule entries found in crontab."
return 0 return 0
fi fi
@@ -299,5 +303,5 @@ remove_schedules() {
return 1 return 1
} }
echo "Removed $removed gniza schedule(s) from crontab." echo "Removed $removed gniza4cp schedule(s) from crontab."
} }

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/snapshot.sh — Timestamp naming, list/resolve snapshots, latest symlink # gniza4cp/lib/snapshot.sh — Timestamp naming, list/resolve snapshots, latest symlink
get_remote_account_base() { get_remote_account_base() {
local user="$1" local user="$1"

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/ssh.sh — SSH connectivity, remote exec, ssh_opts builder # gniza4cp/lib/ssh.sh — SSH connectivity, remote exec, ssh_opts builder
_is_password_mode() { _is_password_mode() {
[[ "${REMOTE_AUTH_METHOD:-key}" == "password" ]] [[ "${REMOTE_AUTH_METHOD:-key}" == "password" ]]

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/sysbackup.sh — System-level WHM backup: API exports, file staging, snapshot lifecycle # gniza4cp/lib/sysbackup.sh — System-level WHM backup: API exports, file staging, snapshot lifecycle
[[ -n "${_GNIZA_SYSBACKUP_LOADED:-}" ]] && return 0 [[ -n "${_GNIZA4CP_SYSBACKUP_LOADED:-}" ]] && return 0
_GNIZA_SYSBACKUP_LOADED=1 _GNIZA4CP_SYSBACKUP_LOADED=1
# ── Path Helpers ───────────────────────────────────────────── # ── Path Helpers ─────────────────────────────────────────────
@@ -349,8 +349,8 @@ readonly _SYSBACKUP_PATHS=(
/etc/reservedipreasons /etc/reservedipreasons
/etc/sysconfig/network /etc/sysconfig/network
/etc/resolv.conf /etc/resolv.conf
# gniza's own config # gniza4cp's own config
/etc/gniza /etc/gniza4cp
) )
_stage_files() { _stage_files() {

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/sysrestore.sh — System-level WHM restore: phased restore of configs, services, DNS # gniza4cp/lib/sysrestore.sh — System-level WHM restore: phased restore of configs, services, DNS
[[ -n "${_GNIZA_SYSRESTORE_LOADED:-}" ]] && return 0 [[ -n "${_GNIZA4CP_SYSRESTORE_LOADED:-}" ]] && return 0
_GNIZA_SYSRESTORE_LOADED=1 _GNIZA4CP_SYSRESTORE_LOADED=1
# ── Download ───────────────────────────────────────────────── # ── Download ─────────────────────────────────────────────────
@@ -375,7 +375,7 @@ _restore_phase3_security() {
log_info "[DRY RUN] Would restore CSF firewall config + csf -r" log_info "[DRY RUN] Would restore CSF firewall config + csf -r"
log_info "[DRY RUN] Would restore /root/.ssh/" log_info "[DRY RUN] Would restore /root/.ssh/"
log_info "[DRY RUN] Would restore root crontab" log_info "[DRY RUN] Would restore root crontab"
log_info "[DRY RUN] Would restore /etc/gniza/" log_info "[DRY RUN] Would restore /etc/gniza4cp/"
return 0 return 0
fi fi
@@ -412,9 +412,9 @@ _restore_phase3_security() {
log_info "--- Restoring root crontab ---" log_info "--- Restoring root crontab ---"
_restore_file "$stage_dir" "var/spool/cron/root" || ((errors++)) || true _restore_file "$stage_dir" "var/spool/cron/root" || ((errors++)) || true
# gniza config # gniza4cp config
log_info "--- Restoring gniza configuration ---" log_info "--- Restoring gniza4cp configuration ---"
_restore_dir "$stage_dir" "etc/gniza" || ((errors++)) || true _restore_dir "$stage_dir" "etc/gniza4cp" || ((errors++)) || true
if (( errors > 0 )); then if (( errors > 0 )); then
log_warn "Phase 3 completed with $errors error(s)" log_warn "Phase 3 completed with $errors error(s)"

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/transfer.sh — rsync --link-dest to remote, .partial atomicity, retries # gniza4cp/lib/transfer.sh — rsync --link-dest to remote, .partial atomicity, retries
rsync_to_remote() { rsync_to_remote() {
local source_dir="$1" local source_dir="$1"

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/utils.sh — Core utility functions # gniza4cp/lib/utils.sh — Core utility functions
die() { die() {
local code="${2:-$EXIT_FATAL}" local code="${2:-$EXIT_FATAL}"

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/verify.sh — Remote backup integrity checks # gniza4cp/lib/verify.sh — Remote backup integrity checks
verify_account_backup() { verify_account_backup() {
local user="$1" local user="$1"

View File

@@ -1,15 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza installer — single-line install: # GNIZA installer — from a local clone:
# bash <(curl -sSL http://192.168.100.100:3001/shukivaknin/gniza/raw/branch/main/scripts/install.sh)
#
# Or from a local clone:
# bash scripts/install.sh # bash scripts/install.sh
set -eo pipefail set -eo pipefail
INSTALL_DIR="/usr/local/gniza" INSTALL_DIR="/usr/local/gniza4cp"
BIN_LINK="/usr/local/bin/gniza" BIN_LINK="/usr/local/bin/gniza4cp"
REPO_HTTP="http://192.168.100.100:3001/shukivaknin/gniza.git" REPO_URL="https://git.linux-hosting.co.il/shukivaknin/gniza4cp.git"
TMPDIR_CLONE="" TMPDIR_CLONE=""
if [[ $EUID -ne 0 ]]; then if [[ $EUID -ne 0 ]]; then
@@ -19,19 +16,19 @@ fi
# Determine source directory — local clone or fresh git clone # Determine source directory — local clone or fresh git clone
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-}")" 2>/dev/null && pwd)" || true SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-}")" 2>/dev/null && pwd)" || true
if [[ -n "${SCRIPT_DIR:-}" && -f "$SCRIPT_DIR/../bin/gniza" ]]; then if [[ -n "${SCRIPT_DIR:-}" && -f "$SCRIPT_DIR/../bin/gniza4cp" ]]; then
SOURCE_DIR="$(dirname "$SCRIPT_DIR")" SOURCE_DIR="$(dirname "$SCRIPT_DIR")"
else else
echo "Cloning gniza..." echo "Cloning GNIZA..."
TMPDIR_CLONE="$(mktemp -d)" TMPDIR_CLONE="$(mktemp -d)"
git clone --depth 1 "$REPO_HTTP" "$TMPDIR_CLONE" 2>&1 git clone --depth 1 "$REPO_URL" "$TMPDIR_CLONE" 2>&1
SOURCE_DIR="$TMPDIR_CLONE" SOURCE_DIR="$TMPDIR_CLONE"
fi fi
cleanup() { [[ -n "${TMPDIR_CLONE:-}" ]] && rm -rf "$TMPDIR_CLONE"; } cleanup() { [[ -n "${TMPDIR_CLONE:-}" ]] && rm -rf "$TMPDIR_CLONE"; }
trap cleanup EXIT trap cleanup EXIT
echo "Installing gniza to $INSTALL_DIR..." echo "Installing gniza4cp to $INSTALL_DIR..."
# Create install directory # Create install directory
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
@@ -44,83 +41,84 @@ cp "$SOURCE_DIR/scripts/uninstall.sh" "$INSTALL_DIR/uninstall.sh"
chmod +x "$INSTALL_DIR/uninstall.sh" chmod +x "$INSTALL_DIR/uninstall.sh"
# Make bin executable # Make bin executable
chmod +x "$INSTALL_DIR/bin/gniza" chmod +x "$INSTALL_DIR/bin/gniza4cp"
# Create symlink # Create symlink
ln -sf "$INSTALL_DIR/bin/gniza" "$BIN_LINK" ln -sf "$INSTALL_DIR/bin/gniza4cp" "$BIN_LINK"
# Create working directory # Create working directory
mkdir -p "$INSTALL_DIR/workdir" mkdir -p "$INSTALL_DIR/workdir"
# Create config directory structure with restrictive permissions # Create config directory structure with restrictive permissions
mkdir -p -m 700 /etc/gniza/remotes.d /etc/gniza/schedules.d mkdir -p -m 700 /etc/gniza4cp/remotes.d /etc/gniza4cp/schedules.d
chmod 700 /etc/gniza chmod 700 /etc/gniza4cp
# Copy example configs if no config exists # Copy example configs if no config exists
if [[ ! -f /etc/gniza/gniza.conf ]]; then if [[ ! -f /etc/gniza4cp/gniza4cp.conf ]]; then
cp "$INSTALL_DIR/etc/gniza.conf.example" /etc/gniza/gniza.conf.example cp "$INSTALL_DIR/etc/gniza4cp.conf.example" /etc/gniza4cp/gniza4cp.conf.example
echo "Example config copied to /etc/gniza/gniza.conf.example" echo "Example config copied to /etc/gniza4cp/gniza4cp.conf.example"
fi fi
cp "$INSTALL_DIR/etc/remote.conf.example" /etc/gniza/remote.conf.example cp "$INSTALL_DIR/etc/remote.conf.example" /etc/gniza4cp/remote.conf.example
cp "$INSTALL_DIR/etc/schedule.conf.example" /etc/gniza/schedule.conf.example cp "$INSTALL_DIR/etc/schedule.conf.example" /etc/gniza4cp/schedule.conf.example
# Create log directory # Create log directory
mkdir -p /var/log/gniza mkdir -p /var/log/gniza4cp
echo "gniza installed successfully!" echo "GNIZA installed successfully!"
# ── WHM Plugin (if cPanel/WHM is present) ───────────────────── # ── WHM Plugin (if cPanel/WHM is present) ─────────────────────
WHM_CGI_DIR="/usr/local/cpanel/whostmgr/docroot/cgi" WHM_CGI_DIR="/usr/local/cpanel/whostmgr/docroot/cgi"
if [[ -d "$WHM_CGI_DIR" ]]; then if [[ -d "$WHM_CGI_DIR" ]]; then
echo "Installing WHM plugin..." echo "Installing WHM plugin..."
# Remove old assets cruft (node_modules, src) if upgrading # Remove old assets cruft (node_modules, src) if upgrading
rm -rf "$WHM_CGI_DIR/gniza-whm/assets/node_modules" \ rm -rf "$WHM_CGI_DIR/gniza4cp-whm/assets/node_modules" \
"$WHM_CGI_DIR/gniza-whm/assets/src" \ "$WHM_CGI_DIR/gniza4cp-whm/assets/src" \
"$WHM_CGI_DIR/gniza-whm/assets/package.json" \ "$WHM_CGI_DIR/gniza4cp-whm/assets/package.json" \
"$WHM_CGI_DIR/gniza-whm/assets/package-lock.json" 2>/dev/null || true "$WHM_CGI_DIR/gniza4cp-whm/assets/package-lock.json" 2>/dev/null || true
cp -r "$SOURCE_DIR/whm/gniza-whm" "$WHM_CGI_DIR/" cp -r "$SOURCE_DIR/whm/gniza4cp-whm" "$WHM_CGI_DIR/"
cp "$SOURCE_DIR/whm/gniza-whm.conf" "$WHM_CGI_DIR/gniza-whm/" cp "$SOURCE_DIR/whm/gniza4cp-whm.conf" "$WHM_CGI_DIR/gniza4cp-whm/"
chmod +x "$WHM_CGI_DIR/gniza-whm/"*.cgi chmod +x "$WHM_CGI_DIR/gniza4cp-whm/"*.cgi
# Remove build artifacts that shouldn't be on the server # Remove build artifacts that shouldn't be on the server
rm -rf "$WHM_CGI_DIR/gniza-whm/assets/node_modules" \ rm -rf "$WHM_CGI_DIR/gniza4cp-whm/assets/node_modules" \
"$WHM_CGI_DIR/gniza-whm/assets/src" \ "$WHM_CGI_DIR/gniza4cp-whm/assets/src" \
"$WHM_CGI_DIR/gniza-whm/assets/package.json" \ "$WHM_CGI_DIR/gniza4cp-whm/assets/package.json" \
"$WHM_CGI_DIR/gniza-whm/assets/package-lock.json" 2>/dev/null || true "$WHM_CGI_DIR/gniza4cp-whm/assets/package-lock.json" 2>/dev/null || true
/usr/local/cpanel/bin/register_appconfig "$WHM_CGI_DIR/gniza-whm/gniza-whm.conf" /usr/local/cpanel/bin/register_appconfig "$WHM_CGI_DIR/gniza4cp-whm/gniza4cp-whm.conf"
echo "WHM plugin installed — access via WHM > Plugins > gniza Backup Manager" echo "WHM plugin installed — access via WHM > Plugins > GNIZA Backup Manager"
else else
echo "WHM not detected, skipping WHM plugin installation." echo "WHM not detected, skipping WHM plugin installation."
fi fi
# ── cPanel User Plugin (if cPanel is present) ──────────────── # ── cPanel User Plugin (if cPanel is present) ────────────────
CPANEL_BASE="/usr/local/cpanel/base/frontend/jupiter" CPANEL_BASE="/usr/local/cpanel/base/frontend/jupiter"
ADMINBIN_DIR="/usr/local/cpanel/bin/admin/Gniza" ADMINBIN_DIR="/usr/local/cpanel/bin/admin/Gniza4cp"
if [[ -d "$CPANEL_BASE" ]]; then if [[ -d "$CPANEL_BASE" ]]; then
echo "Installing cPanel user plugin..." echo "Installing cPanel user plugin..."
# Copy CGI files + lib + assets # Copy CGI files + lib + assets
mkdir -p "$CPANEL_BASE/gniza/lib/GnizaCPanel" "$CPANEL_BASE/gniza/assets" mkdir -p "$CPANEL_BASE/gniza4cp/lib/Gniza4cpCPanel" "$CPANEL_BASE/gniza4cp/assets"
cp "$SOURCE_DIR/cpanel/gniza/index.live.cgi" "$CPANEL_BASE/gniza/" cp "$SOURCE_DIR/cpanel/gniza4cp/index.live.cgi" "$CPANEL_BASE/gniza4cp/"
cp "$SOURCE_DIR/cpanel/gniza/restore.live.cgi" "$CPANEL_BASE/gniza/" cp "$SOURCE_DIR/cpanel/gniza4cp/restore.live.cgi" "$CPANEL_BASE/gniza4cp/"
chmod +x "$CPANEL_BASE/gniza/"*.cgi cp "$SOURCE_DIR/cpanel/gniza4cp/logs.live.cgi" "$CPANEL_BASE/gniza4cp/"
cp "$SOURCE_DIR/cpanel/gniza/lib/GnizaCPanel/UI.pm" "$CPANEL_BASE/gniza/lib/GnizaCPanel/" chmod +x "$CPANEL_BASE/gniza4cp/"*.cgi
cp "$SOURCE_DIR/cpanel/gniza/assets/gniza-whm.css" "$CPANEL_BASE/gniza/assets/" cp "$SOURCE_DIR/cpanel/gniza4cp/lib/Gniza4cpCPanel/UI.pm" "$CPANEL_BASE/gniza4cp/lib/Gniza4cpCPanel/"
cp "$SOURCE_DIR/cpanel/gniza/assets/gniza-logo.svg" "$CPANEL_BASE/gniza/assets/" cp "$SOURCE_DIR/cpanel/gniza4cp/assets/gniza4cp-whm.css" "$CPANEL_BASE/gniza4cp/assets/"
cp "$SOURCE_DIR/cpanel/gniza/assets/gniza-cpanel-icon.png" "$CPANEL_BASE/gniza/assets/" cp "$SOURCE_DIR/cpanel/gniza4cp/assets/gniza4cp-logo.svg" "$CPANEL_BASE/gniza4cp/assets/"
cp "$SOURCE_DIR/cpanel/gniza/install.json" "$CPANEL_BASE/gniza/" cp "$SOURCE_DIR/cpanel/gniza4cp/assets/gniza4cp-cpanel-icon.png" "$CPANEL_BASE/gniza4cp/assets/"
cp "$SOURCE_DIR/cpanel/gniza4cp/install.json" "$CPANEL_BASE/gniza4cp/"
# Install AdminBin module (runs as root) # Install AdminBin module (runs as root)
mkdir -p "$ADMINBIN_DIR" mkdir -p "$ADMINBIN_DIR"
cp "$SOURCE_DIR/cpanel/admin/Gniza/Restore" "$ADMINBIN_DIR/" cp "$SOURCE_DIR/cpanel/admin/Gniza4cp/Restore" "$ADMINBIN_DIR/"
cp "$SOURCE_DIR/cpanel/admin/Gniza/Restore.conf" "$ADMINBIN_DIR/" cp "$SOURCE_DIR/cpanel/admin/Gniza4cp/Restore.conf" "$ADMINBIN_DIR/"
chmod 0700 "$ADMINBIN_DIR/Restore" chmod 0700 "$ADMINBIN_DIR/Restore"
chmod 0600 "$ADMINBIN_DIR/Restore.conf" chmod 0600 "$ADMINBIN_DIR/Restore.conf"
# Register plugin in cPanel interface (install_plugin expects a tar.gz archive # Register plugin in cPanel interface (install_plugin expects a tar.gz archive
# containing install.json + the icon file referenced in it) # containing install.json + the icon file referenced in it)
PLUGIN_TMPDIR="$(mktemp -d)" PLUGIN_TMPDIR="$(mktemp -d)"
mkdir -p "$PLUGIN_TMPDIR/gniza/assets" mkdir -p "$PLUGIN_TMPDIR/gniza4cp/assets"
cp "$SOURCE_DIR/cpanel/gniza/install.json" "$PLUGIN_TMPDIR/" cp "$SOURCE_DIR/cpanel/gniza4cp/install.json" "$PLUGIN_TMPDIR/"
cp "$SOURCE_DIR/cpanel/gniza/assets/gniza-cpanel-icon.png" "$PLUGIN_TMPDIR/gniza/assets/" cp "$SOURCE_DIR/cpanel/gniza4cp/assets/gniza4cp-cpanel-icon.png" "$PLUGIN_TMPDIR/gniza4cp/assets/"
tar -czf "$PLUGIN_TMPDIR/gniza-cpanel.tar.gz" -C "$PLUGIN_TMPDIR" install.json gniza/assets/gniza-cpanel-icon.png tar -czf "$PLUGIN_TMPDIR/gniza4cp-cpanel.tar.gz" -C "$PLUGIN_TMPDIR" install.json gniza4cp/assets/gniza4cp-cpanel-icon.png
/usr/local/cpanel/scripts/install_plugin "$PLUGIN_TMPDIR/gniza-cpanel.tar.gz" 2>/dev/null || true /usr/local/cpanel/scripts/install_plugin "$PLUGIN_TMPDIR/gniza4cp-cpanel.tar.gz" 2>/dev/null || true
rm -rf "$PLUGIN_TMPDIR" rm -rf "$PLUGIN_TMPDIR"
# Rebuild icon sprites so the new icon appears in cPanel # Rebuild icon sprites so the new icon appears in cPanel
/usr/local/cpanel/bin/rebuild_sprites 2>/dev/null || true /usr/local/cpanel/bin/rebuild_sprites 2>/dev/null || true
@@ -131,13 +129,11 @@ if [[ -d "$CPANEL_BASE" ]]; then
[[ -n "$user" ]] && cpapi2 --user="$user" Branding gensprites 2>/dev/null || true [[ -n "$user" ]] && cpapi2 --user="$user" Branding gensprites 2>/dev/null || true
done < /etc/trueuserdomains done < /etc/trueuserdomains
fi fi
echo "cPanel user plugin installed — users will see gniza Restore in Files section" echo "cPanel user plugin installed — users will see GNIZA Restore in Files section"
else else
echo "cPanel not detected, skipping cPanel user plugin installation." echo "cPanel not detected, skipping cPanel user plugin installation."
fi fi
echo "" echo ""
echo "Next steps:" echo "Next steps:"
echo " 1. Run 'gniza init' to create your configuration" echo " Open WHM → GNIZA Backup Manager to configure via the setup wizard."
echo " 2. Or copy /etc/gniza/gniza.conf.example to /etc/gniza/gniza.conf"
echo " 3. Run 'gniza status' to verify your setup"

View File

@@ -1,17 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza uninstall script # GNIZA uninstall script
set -euo pipefail set -euo pipefail
INSTALL_DIR="/usr/local/gniza" INSTALL_DIR="/usr/local/gniza4cp"
BIN_LINK="/usr/local/bin/gniza" BIN_LINK="/usr/local/bin/gniza4cp"
if [[ $EUID -ne 0 ]]; then if [[ $EUID -ne 0 ]]; then
echo "Error: uninstall.sh must be run as root" >&2 echo "Error: uninstall.sh must be run as root" >&2
exit 1 exit 1
fi fi
echo "Uninstalling gniza..." echo "Uninstalling GNIZA..."
# Remove symlink # Remove symlink
if [[ -L "$BIN_LINK" ]]; then if [[ -L "$BIN_LINK" ]]; then
@@ -26,38 +26,38 @@ if [[ -d "$INSTALL_DIR" ]]; then
fi fi
# ── Remove cron entries ─────────────────────────────────────── # ── Remove cron entries ───────────────────────────────────────
if crontab -l 2>/dev/null | grep -q '# gniza:'; then if crontab -l 2>/dev/null | grep -q '# gniza4cp:'; then
echo "Removing gniza cron entries..." echo "Removing GNIZA cron entries..."
crontab -l 2>/dev/null | grep -v '# gniza:' | grep -v '/usr/local/bin/gniza' | crontab - crontab -l 2>/dev/null | grep -v '# gniza4cp:' | grep -v '/usr/local/bin/gniza4cp' | crontab -
echo "Cron entries removed." echo "Cron entries removed."
fi fi
# ── WHM Plugin ──────────────────────────────────────────────── # ── WHM Plugin ────────────────────────────────────────────────
WHM_CGI_DIR="/usr/local/cpanel/whostmgr/docroot/cgi" WHM_CGI_DIR="/usr/local/cpanel/whostmgr/docroot/cgi"
if [[ -d "$WHM_CGI_DIR/gniza-whm" ]]; then if [[ -d "$WHM_CGI_DIR/gniza4cp-whm" ]]; then
echo "Removing WHM plugin..." echo "Removing WHM plugin..."
/usr/local/cpanel/bin/unregister_appconfig gniza-whm 2>/dev/null || true /usr/local/cpanel/bin/unregister_appconfig gniza4cp-whm 2>/dev/null || true
rm -rf "$WHM_CGI_DIR/gniza-whm" rm -rf "$WHM_CGI_DIR/gniza4cp-whm"
echo "WHM plugin removed." echo "WHM plugin removed."
fi fi
# ── cPanel User Plugin ──────────────────────────────────────── # ── cPanel User Plugin ────────────────────────────────────────
CPANEL_BASE="/usr/local/cpanel/base/frontend/jupiter" CPANEL_BASE="/usr/local/cpanel/base/frontend/jupiter"
ADMINBIN_DIR="/usr/local/cpanel/bin/admin/Gniza" ADMINBIN_DIR="/usr/local/cpanel/bin/admin/Gniza4cp"
if [[ -d "$CPANEL_BASE/gniza" ]]; then if [[ -d "$CPANEL_BASE/gniza4cp" ]]; then
echo "Removing cPanel user plugin..." echo "Removing cPanel user plugin..."
# uninstall_plugin expects a tar.gz archive with install.json + icon # uninstall_plugin expects a tar.gz archive with install.json + icon
if [[ -f "$CPANEL_BASE/gniza/install.json" ]]; then if [[ -f "$CPANEL_BASE/gniza4cp/install.json" ]]; then
PLUGIN_TMPDIR="$(mktemp -d)" PLUGIN_TMPDIR="$(mktemp -d)"
mkdir -p "$PLUGIN_TMPDIR/gniza/assets" mkdir -p "$PLUGIN_TMPDIR/gniza4cp/assets"
cp "$CPANEL_BASE/gniza/install.json" "$PLUGIN_TMPDIR/" cp "$CPANEL_BASE/gniza4cp/install.json" "$PLUGIN_TMPDIR/"
cp "$CPANEL_BASE/gniza/assets/gniza-cpanel-icon.png" "$PLUGIN_TMPDIR/gniza/assets/" 2>/dev/null || true cp "$CPANEL_BASE/gniza4cp/assets/gniza4cp-cpanel-icon.png" "$PLUGIN_TMPDIR/gniza4cp/assets/" 2>/dev/null || true
tar -czf "$PLUGIN_TMPDIR/gniza-cpanel.tar.gz" -C "$PLUGIN_TMPDIR" install.json gniza/assets/gniza-cpanel-icon.png 2>/dev/null || \ tar -czf "$PLUGIN_TMPDIR/gniza4cp-cpanel.tar.gz" -C "$PLUGIN_TMPDIR" install.json gniza4cp/assets/gniza4cp-cpanel-icon.png 2>/dev/null || \
tar -czf "$PLUGIN_TMPDIR/gniza-cpanel.tar.gz" -C "$PLUGIN_TMPDIR" install.json tar -czf "$PLUGIN_TMPDIR/gniza4cp-cpanel.tar.gz" -C "$PLUGIN_TMPDIR" install.json
/usr/local/cpanel/scripts/uninstall_plugin "$PLUGIN_TMPDIR/gniza-cpanel.tar.gz" 2>/dev/null || true /usr/local/cpanel/scripts/uninstall_plugin "$PLUGIN_TMPDIR/gniza4cp-cpanel.tar.gz" 2>/dev/null || true
rm -rf "$PLUGIN_TMPDIR" rm -rf "$PLUGIN_TMPDIR"
fi fi
rm -rf "$CPANEL_BASE/gniza" rm -rf "$CPANEL_BASE/gniza4cp"
echo "cPanel user plugin removed." echo "cPanel user plugin removed."
fi fi
if [[ -d "$ADMINBIN_DIR" ]]; then if [[ -d "$ADMINBIN_DIR" ]]; then
@@ -66,12 +66,12 @@ if [[ -d "$ADMINBIN_DIR" ]]; then
fi fi
echo "" echo ""
echo "gniza uninstalled." echo "GNIZA uninstalled."
echo "" echo ""
echo "The following were NOT removed (manual cleanup if desired):" echo "The following were NOT removed (manual cleanup if desired):"
echo " /etc/gniza/ (configuration + remotes.d/)" echo " /etc/gniza4cp/ (configuration + remotes.d/)"
echo " /var/log/gniza/ (log files)" echo " /var/log/gniza4cp/ (log files)"
echo " /var/run/gniza.lock (lock file)" echo " /var/run/gniza4cp.lock (lock file)"
echo "" echo ""
echo "To remove configs: rm -rf /etc/gniza/" echo "To remove configs: rm -rf /etc/gniza4cp/"
echo "To remove logs: rm -rf /var/log/gniza/" echo "To remove logs: rm -rf /var/log/gniza4cp/"

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza tests — utility functions # gniza4cp tests — utility functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BASE_DIR="$(dirname "$SCRIPT_DIR")" BASE_DIR="$(dirname "$SCRIPT_DIR")"

View File

@@ -1,7 +0,0 @@
name=gniza-whm
service=whostmgr
user=root
url=/cgi/gniza-whm/
acls=all
displayname=gniza Backup Manager
entryurl=gniza-whm/index.cgi

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +0,0 @@
{
"private": true,
"scripts": {
"build:css": "tailwindcss -i src/input.css -o gniza-whm.css --minify",
"dev:css": "tailwindcss -i src/input.css -o gniza-whm.css --watch"
},
"devDependencies": {
"@tailwindcss/cli": "^4",
"tailwindcss": "^4",
"daisyui": "^5"
}
}

7
whm/gniza4cp-whm.conf Normal file
View File

@@ -0,0 +1,7 @@
name=gniza4cp-whm
service=whostmgr
user=root
url=/cgi/gniza4cp-whm/
acls=all
displayname=GNIZA Backup Manager
entryurl=gniza4cp-whm/index.cgi

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="48" height="48" version="1.1" viewBox="0 0 12.7 12.7" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="clipPath7">
<path d="m0 792h612v-792h-612z"/>
</clipPath>
</defs>
<g transform="translate(-97.367 -139.17)">
<path transform="matrix(.23378 0 0 -.23378 50.654 216.38)" d="m219.89 325.53h14.261v2.3h-14.261zm0-16.189h14.261v2.3h-14.261zm-14.721 4.14h43.703v-12.881h-43.703zm14.721-18.029h14.261v-2.3h-14.261zm7.13-19.157 2.663 4.609 2.024 3.507h17.165v12.881h-43.703v-12.881h17.166l2.023-3.507zm-21.851 53.375h43.703v-12.881h-43.703z" clip-path="url(#clipPath7)" fill="#f47216" fill-rule="evenodd"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 685 B

File diff suppressed because one or more lines are too long

1
whm/gniza4cp-whm/assets/node_modules/.bin/jiti generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../jiti/lib/jiti-cli.mjs

1
whm/gniza4cp-whm/assets/node_modules/.bin/tailwindcss generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../@tailwindcss/cli/dist/index.mjs

421
whm/gniza4cp-whm/assets/node_modules/.package-lock.json generated vendored Normal file
View File

@@ -0,0 +1,421 @@
{
"name": "assets",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.6",
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-freebsd-x64": "2.5.6",
"@parcel/watcher-linux-arm-glibc": "2.5.6",
"@parcel/watcher-linux-arm-musl": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-ia32": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/cli": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.1.tgz",
"integrity": "sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA==",
"dev": true,
"dependencies": {
"@parcel/watcher": "^2.5.1",
"@tailwindcss/node": "4.2.1",
"@tailwindcss/oxide": "4.2.1",
"enhanced-resolve": "^5.19.0",
"mri": "^1.2.0",
"picocolors": "^1.1.1",
"tailwindcss": "4.2.1"
},
"bin": {
"tailwindcss": "dist/index.mjs"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
"integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
"dev": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.19.0",
"jiti": "^2.6.1",
"lightningcss": "1.31.1",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.2.1"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
"integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
"dev": true,
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.2.1",
"@tailwindcss/oxide-darwin-arm64": "4.2.1",
"@tailwindcss/oxide-darwin-x64": "4.2.1",
"@tailwindcss/oxide-freebsd-x64": "4.2.1",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
"@tailwindcss/oxide-linux-x64-musl": "4.2.1",
"@tailwindcss/oxide-wasm32-wasi": "4.2.1",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
"integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
"integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/daisyui": {
"version": "5.5.19",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz",
"integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==",
"dev": true,
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/enhanced-resolve": {
"version": "5.20.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
"integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==",
"dev": true,
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.0"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
"dev": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.31.1",
"lightningcss-darwin-arm64": "1.31.1",
"lightningcss-darwin-x64": "1.31.1",
"lightningcss-freebsd-x64": "1.31.1",
"lightningcss-linux-arm-gnueabihf": "1.31.1",
"lightningcss-linux-arm64-gnu": "1.31.1",
"lightningcss-linux-arm64-musl": "1.31.1",
"lightningcss-linux-x64-gnu": "1.31.1",
"lightningcss-linux-x64-musl": "1.31.1",
"lightningcss-win32-arm64-msvc": "1.31.1",
"lightningcss-win32-x64-msvc": "1.31.1"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
"integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tailwindcss": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
"integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
"dev": true
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"dev": true,
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
}
}
}

View File

@@ -0,0 +1,19 @@
Copyright 2024 Justin Ridgewell <justin@ridgewell.name>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,227 @@
# @jridgewell/gen-mapping
> Generate source maps
`gen-mapping` allows you to generate a source map during transpilation or minification.
With a source map, you're able to trace the original location in the source file, either in Chrome's
DevTools or using a library like [`@jridgewell/trace-mapping`][trace-mapping].
You may already be familiar with the [`source-map`][source-map] package's `SourceMapGenerator`. This
provides the same `addMapping` and `setSourceContent` API.
## Installation
```sh
npm install @jridgewell/gen-mapping
```
## Usage
```typescript
import { GenMapping, addMapping, setSourceContent, toEncodedMap, toDecodedMap } from '@jridgewell/gen-mapping';
const map = new GenMapping({
file: 'output.js',
sourceRoot: 'https://example.com/',
});
setSourceContent(map, 'input.js', `function foo() {}`);
addMapping(map, {
// Lines start at line 1, columns at column 0.
generated: { line: 1, column: 0 },
source: 'input.js',
original: { line: 1, column: 0 },
});
addMapping(map, {
generated: { line: 1, column: 9 },
source: 'input.js',
original: { line: 1, column: 9 },
name: 'foo',
});
assert.deepEqual(toDecodedMap(map), {
version: 3,
file: 'output.js',
names: ['foo'],
sourceRoot: 'https://example.com/',
sources: ['input.js'],
sourcesContent: ['function foo() {}'],
mappings: [
[ [0, 0, 0, 0], [9, 0, 0, 9, 0] ]
],
});
assert.deepEqual(toEncodedMap(map), {
version: 3,
file: 'output.js',
names: ['foo'],
sourceRoot: 'https://example.com/',
sources: ['input.js'],
sourcesContent: ['function foo() {}'],
mappings: 'AAAA,SAASA',
});
```
### Smaller Sourcemaps
Not everything needs to be added to a sourcemap, and needless markings can cause signficantly
larger file sizes. `gen-mapping` exposes `maybeAddSegment`/`maybeAddMapping` APIs that will
intelligently determine if this marking adds useful information. If not, the marking will be
skipped.
```typescript
import { maybeAddMapping } from '@jridgewell/gen-mapping';
const map = new GenMapping();
// Adding a sourceless marking at the beginning of a line isn't useful.
maybeAddMapping(map, {
generated: { line: 1, column: 0 },
});
// Adding a new source marking is useful.
maybeAddMapping(map, {
generated: { line: 1, column: 0 },
source: 'input.js',
original: { line: 1, column: 0 },
});
// But adding another marking pointing to the exact same original location isn't, even if the
// generated column changed.
maybeAddMapping(map, {
generated: { line: 1, column: 9 },
source: 'input.js',
original: { line: 1, column: 0 },
});
assert.deepEqual(toEncodedMap(map), {
version: 3,
names: [],
sources: ['input.js'],
sourcesContent: [null],
mappings: 'AAAA',
});
```
## Benchmarks
```
node v18.0.0
amp.js.map
Memory Usage:
gen-mapping: addSegment 5852872 bytes
gen-mapping: addMapping 7716042 bytes
source-map-js 6143250 bytes
source-map-0.6.1 6124102 bytes
source-map-0.8.0 6121173 bytes
Smallest memory usage is gen-mapping: addSegment
Adding speed:
gen-mapping: addSegment x 441 ops/sec ±2.07% (90 runs sampled)
gen-mapping: addMapping x 350 ops/sec ±2.40% (86 runs sampled)
source-map-js: addMapping x 169 ops/sec ±2.42% (80 runs sampled)
source-map-0.6.1: addMapping x 167 ops/sec ±2.56% (80 runs sampled)
source-map-0.8.0: addMapping x 168 ops/sec ±2.52% (80 runs sampled)
Fastest is gen-mapping: addSegment
Generate speed:
gen-mapping: decoded output x 150,824,370 ops/sec ±0.07% (102 runs sampled)
gen-mapping: encoded output x 663 ops/sec ±0.22% (98 runs sampled)
source-map-js: encoded output x 197 ops/sec ±0.45% (84 runs sampled)
source-map-0.6.1: encoded output x 198 ops/sec ±0.33% (85 runs sampled)
source-map-0.8.0: encoded output x 197 ops/sec ±0.06% (93 runs sampled)
Fastest is gen-mapping: decoded output
***
babel.min.js.map
Memory Usage:
gen-mapping: addSegment 37578063 bytes
gen-mapping: addMapping 37212897 bytes
source-map-js 47638527 bytes
source-map-0.6.1 47690503 bytes
source-map-0.8.0 47470188 bytes
Smallest memory usage is gen-mapping: addMapping
Adding speed:
gen-mapping: addSegment x 31.05 ops/sec ±8.31% (43 runs sampled)
gen-mapping: addMapping x 29.83 ops/sec ±7.36% (51 runs sampled)
source-map-js: addMapping x 20.73 ops/sec ±6.22% (38 runs sampled)
source-map-0.6.1: addMapping x 20.03 ops/sec ±10.51% (38 runs sampled)
source-map-0.8.0: addMapping x 19.30 ops/sec ±8.27% (37 runs sampled)
Fastest is gen-mapping: addSegment
Generate speed:
gen-mapping: decoded output x 381,379,234 ops/sec ±0.29% (96 runs sampled)
gen-mapping: encoded output x 95.15 ops/sec ±2.98% (72 runs sampled)
source-map-js: encoded output x 15.20 ops/sec ±7.41% (33 runs sampled)
source-map-0.6.1: encoded output x 16.36 ops/sec ±10.46% (31 runs sampled)
source-map-0.8.0: encoded output x 16.06 ops/sec ±6.45% (31 runs sampled)
Fastest is gen-mapping: decoded output
***
preact.js.map
Memory Usage:
gen-mapping: addSegment 416247 bytes
gen-mapping: addMapping 419824 bytes
source-map-js 1024619 bytes
source-map-0.6.1 1146004 bytes
source-map-0.8.0 1113250 bytes
Smallest memory usage is gen-mapping: addSegment
Adding speed:
gen-mapping: addSegment x 13,755 ops/sec ±0.15% (98 runs sampled)
gen-mapping: addMapping x 13,013 ops/sec ±0.11% (101 runs sampled)
source-map-js: addMapping x 4,564 ops/sec ±0.21% (98 runs sampled)
source-map-0.6.1: addMapping x 4,562 ops/sec ±0.11% (99 runs sampled)
source-map-0.8.0: addMapping x 4,593 ops/sec ±0.11% (100 runs sampled)
Fastest is gen-mapping: addSegment
Generate speed:
gen-mapping: decoded output x 379,864,020 ops/sec ±0.23% (93 runs sampled)
gen-mapping: encoded output x 14,368 ops/sec ±4.07% (82 runs sampled)
source-map-js: encoded output x 5,261 ops/sec ±0.21% (99 runs sampled)
source-map-0.6.1: encoded output x 5,124 ops/sec ±0.58% (99 runs sampled)
source-map-0.8.0: encoded output x 5,434 ops/sec ±0.33% (96 runs sampled)
Fastest is gen-mapping: decoded output
***
react.js.map
Memory Usage:
gen-mapping: addSegment 975096 bytes
gen-mapping: addMapping 1102981 bytes
source-map-js 2918836 bytes
source-map-0.6.1 2885435 bytes
source-map-0.8.0 2874336 bytes
Smallest memory usage is gen-mapping: addSegment
Adding speed:
gen-mapping: addSegment x 4,772 ops/sec ±0.15% (100 runs sampled)
gen-mapping: addMapping x 4,456 ops/sec ±0.13% (97 runs sampled)
source-map-js: addMapping x 1,618 ops/sec ±0.24% (97 runs sampled)
source-map-0.6.1: addMapping x 1,622 ops/sec ±0.12% (99 runs sampled)
source-map-0.8.0: addMapping x 1,631 ops/sec ±0.12% (100 runs sampled)
Fastest is gen-mapping: addSegment
Generate speed:
gen-mapping: decoded output x 379,107,695 ops/sec ±0.07% (99 runs sampled)
gen-mapping: encoded output x 5,421 ops/sec ±1.60% (89 runs sampled)
source-map-js: encoded output x 2,113 ops/sec ±1.81% (98 runs sampled)
source-map-0.6.1: encoded output x 2,126 ops/sec ±0.10% (100 runs sampled)
source-map-0.8.0: encoded output x 2,176 ops/sec ±0.39% (98 runs sampled)
Fastest is gen-mapping: decoded output
```
[source-map]: https://www.npmjs.com/package/source-map
[trace-mapping]: https://github.com/jridgewell/sourcemaps/tree/main/packages/trace-mapping

View File

@@ -0,0 +1,292 @@
// src/set-array.ts
var SetArray = class {
constructor() {
this._indexes = { __proto__: null };
this.array = [];
}
};
function cast(set) {
return set;
}
function get(setarr, key) {
return cast(setarr)._indexes[key];
}
function put(setarr, key) {
const index = get(setarr, key);
if (index !== void 0) return index;
const { array, _indexes: indexes } = cast(setarr);
const length = array.push(key);
return indexes[key] = length - 1;
}
function remove(setarr, key) {
const index = get(setarr, key);
if (index === void 0) return;
const { array, _indexes: indexes } = cast(setarr);
for (let i = index + 1; i < array.length; i++) {
const k = array[i];
array[i - 1] = k;
indexes[k]--;
}
indexes[key] = void 0;
array.pop();
}
// src/gen-mapping.ts
import {
encode
} from "@jridgewell/sourcemap-codec";
import { TraceMap, decodedMappings } from "@jridgewell/trace-mapping";
// src/sourcemap-segment.ts
var COLUMN = 0;
var SOURCES_INDEX = 1;
var SOURCE_LINE = 2;
var SOURCE_COLUMN = 3;
var NAMES_INDEX = 4;
// src/gen-mapping.ts
var NO_NAME = -1;
var GenMapping = class {
constructor({ file, sourceRoot } = {}) {
this._names = new SetArray();
this._sources = new SetArray();
this._sourcesContent = [];
this._mappings = [];
this.file = file;
this.sourceRoot = sourceRoot;
this._ignoreList = new SetArray();
}
};
function cast2(map) {
return map;
}
function addSegment(map, genLine, genColumn, source, sourceLine, sourceColumn, name, content) {
return addSegmentInternal(
false,
map,
genLine,
genColumn,
source,
sourceLine,
sourceColumn,
name,
content
);
}
function addMapping(map, mapping) {
return addMappingInternal(false, map, mapping);
}
var maybeAddSegment = (map, genLine, genColumn, source, sourceLine, sourceColumn, name, content) => {
return addSegmentInternal(
true,
map,
genLine,
genColumn,
source,
sourceLine,
sourceColumn,
name,
content
);
};
var maybeAddMapping = (map, mapping) => {
return addMappingInternal(true, map, mapping);
};
function setSourceContent(map, source, content) {
const {
_sources: sources,
_sourcesContent: sourcesContent
// _originalScopes: originalScopes,
} = cast2(map);
const index = put(sources, source);
sourcesContent[index] = content;
}
function setIgnore(map, source, ignore = true) {
const {
_sources: sources,
_sourcesContent: sourcesContent,
_ignoreList: ignoreList
// _originalScopes: originalScopes,
} = cast2(map);
const index = put(sources, source);
if (index === sourcesContent.length) sourcesContent[index] = null;
if (ignore) put(ignoreList, index);
else remove(ignoreList, index);
}
function toDecodedMap(map) {
const {
_mappings: mappings,
_sources: sources,
_sourcesContent: sourcesContent,
_names: names,
_ignoreList: ignoreList
// _originalScopes: originalScopes,
// _generatedRanges: generatedRanges,
} = cast2(map);
removeEmptyFinalLines(mappings);
return {
version: 3,
file: map.file || void 0,
names: names.array,
sourceRoot: map.sourceRoot || void 0,
sources: sources.array,
sourcesContent,
mappings,
// originalScopes,
// generatedRanges,
ignoreList: ignoreList.array
};
}
function toEncodedMap(map) {
const decoded = toDecodedMap(map);
return Object.assign({}, decoded, {
// originalScopes: decoded.originalScopes.map((os) => encodeOriginalScopes(os)),
// generatedRanges: encodeGeneratedRanges(decoded.generatedRanges as GeneratedRange[]),
mappings: encode(decoded.mappings)
});
}
function fromMap(input) {
const map = new TraceMap(input);
const gen = new GenMapping({ file: map.file, sourceRoot: map.sourceRoot });
putAll(cast2(gen)._names, map.names);
putAll(cast2(gen)._sources, map.sources);
cast2(gen)._sourcesContent = map.sourcesContent || map.sources.map(() => null);
cast2(gen)._mappings = decodedMappings(map);
if (map.ignoreList) putAll(cast2(gen)._ignoreList, map.ignoreList);
return gen;
}
function allMappings(map) {
const out = [];
const { _mappings: mappings, _sources: sources, _names: names } = cast2(map);
for (let i = 0; i < mappings.length; i++) {
const line = mappings[i];
for (let j = 0; j < line.length; j++) {
const seg = line[j];
const generated = { line: i + 1, column: seg[COLUMN] };
let source = void 0;
let original = void 0;
let name = void 0;
if (seg.length !== 1) {
source = sources.array[seg[SOURCES_INDEX]];
original = { line: seg[SOURCE_LINE] + 1, column: seg[SOURCE_COLUMN] };
if (seg.length === 5) name = names.array[seg[NAMES_INDEX]];
}
out.push({ generated, source, original, name });
}
}
return out;
}
function addSegmentInternal(skipable, map, genLine, genColumn, source, sourceLine, sourceColumn, name, content) {
const {
_mappings: mappings,
_sources: sources,
_sourcesContent: sourcesContent,
_names: names
// _originalScopes: originalScopes,
} = cast2(map);
const line = getIndex(mappings, genLine);
const index = getColumnIndex(line, genColumn);
if (!source) {
if (skipable && skipSourceless(line, index)) return;
return insert(line, index, [genColumn]);
}
assert(sourceLine);
assert(sourceColumn);
const sourcesIndex = put(sources, source);
const namesIndex = name ? put(names, name) : NO_NAME;
if (sourcesIndex === sourcesContent.length) sourcesContent[sourcesIndex] = content != null ? content : null;
if (skipable && skipSource(line, index, sourcesIndex, sourceLine, sourceColumn, namesIndex)) {
return;
}
return insert(
line,
index,
name ? [genColumn, sourcesIndex, sourceLine, sourceColumn, namesIndex] : [genColumn, sourcesIndex, sourceLine, sourceColumn]
);
}
function assert(_val) {
}
function getIndex(arr, index) {
for (let i = arr.length; i <= index; i++) {
arr[i] = [];
}
return arr[index];
}
function getColumnIndex(line, genColumn) {
let index = line.length;
for (let i = index - 1; i >= 0; index = i--) {
const current = line[i];
if (genColumn >= current[COLUMN]) break;
}
return index;
}
function insert(array, index, value) {
for (let i = array.length; i > index; i--) {
array[i] = array[i - 1];
}
array[index] = value;
}
function removeEmptyFinalLines(mappings) {
const { length } = mappings;
let len = length;
for (let i = len - 1; i >= 0; len = i, i--) {
if (mappings[i].length > 0) break;
}
if (len < length) mappings.length = len;
}
function putAll(setarr, array) {
for (let i = 0; i < array.length; i++) put(setarr, array[i]);
}
function skipSourceless(line, index) {
if (index === 0) return true;
const prev = line[index - 1];
return prev.length === 1;
}
function skipSource(line, index, sourcesIndex, sourceLine, sourceColumn, namesIndex) {
if (index === 0) return false;
const prev = line[index - 1];
if (prev.length === 1) return false;
return sourcesIndex === prev[SOURCES_INDEX] && sourceLine === prev[SOURCE_LINE] && sourceColumn === prev[SOURCE_COLUMN] && namesIndex === (prev.length === 5 ? prev[NAMES_INDEX] : NO_NAME);
}
function addMappingInternal(skipable, map, mapping) {
const { generated, source, original, name, content } = mapping;
if (!source) {
return addSegmentInternal(
skipable,
map,
generated.line - 1,
generated.column,
null,
null,
null,
null,
null
);
}
assert(original);
return addSegmentInternal(
skipable,
map,
generated.line - 1,
generated.column,
source,
original.line - 1,
original.column,
name,
content
);
}
export {
GenMapping,
addMapping,
addSegment,
allMappings,
fromMap,
maybeAddMapping,
maybeAddSegment,
setIgnore,
setSourceContent,
toDecodedMap,
toEncodedMap
};
//# sourceMappingURL=gen-mapping.mjs.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,358 @@
(function (global, factory) {
if (typeof exports === 'object' && typeof module !== 'undefined') {
factory(module, require('@jridgewell/sourcemap-codec'), require('@jridgewell/trace-mapping'));
module.exports = def(module);
} else if (typeof define === 'function' && define.amd) {
define(['module', '@jridgewell/sourcemap-codec', '@jridgewell/trace-mapping'], function(mod) {
factory.apply(this, arguments);
mod.exports = def(mod);
});
} else {
const mod = { exports: {} };
factory(mod, global.sourcemapCodec, global.traceMapping);
global = typeof globalThis !== 'undefined' ? globalThis : global || self;
global.genMapping = def(mod);
}
function def(m) { return 'default' in m.exports ? m.exports.default : m.exports; }
})(this, (function (module, require_sourcemapCodec, require_traceMapping) {
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// umd:@jridgewell/sourcemap-codec
var require_sourcemap_codec = __commonJS({
"umd:@jridgewell/sourcemap-codec"(exports, module2) {
module2.exports = require_sourcemapCodec;
}
});
// umd:@jridgewell/trace-mapping
var require_trace_mapping = __commonJS({
"umd:@jridgewell/trace-mapping"(exports, module2) {
module2.exports = require_traceMapping;
}
});
// src/gen-mapping.ts
var gen_mapping_exports = {};
__export(gen_mapping_exports, {
GenMapping: () => GenMapping,
addMapping: () => addMapping,
addSegment: () => addSegment,
allMappings: () => allMappings,
fromMap: () => fromMap,
maybeAddMapping: () => maybeAddMapping,
maybeAddSegment: () => maybeAddSegment,
setIgnore: () => setIgnore,
setSourceContent: () => setSourceContent,
toDecodedMap: () => toDecodedMap,
toEncodedMap: () => toEncodedMap
});
module.exports = __toCommonJS(gen_mapping_exports);
// src/set-array.ts
var SetArray = class {
constructor() {
this._indexes = { __proto__: null };
this.array = [];
}
};
function cast(set) {
return set;
}
function get(setarr, key) {
return cast(setarr)._indexes[key];
}
function put(setarr, key) {
const index = get(setarr, key);
if (index !== void 0) return index;
const { array, _indexes: indexes } = cast(setarr);
const length = array.push(key);
return indexes[key] = length - 1;
}
function remove(setarr, key) {
const index = get(setarr, key);
if (index === void 0) return;
const { array, _indexes: indexes } = cast(setarr);
for (let i = index + 1; i < array.length; i++) {
const k = array[i];
array[i - 1] = k;
indexes[k]--;
}
indexes[key] = void 0;
array.pop();
}
// src/gen-mapping.ts
var import_sourcemap_codec = __toESM(require_sourcemap_codec());
var import_trace_mapping = __toESM(require_trace_mapping());
// src/sourcemap-segment.ts
var COLUMN = 0;
var SOURCES_INDEX = 1;
var SOURCE_LINE = 2;
var SOURCE_COLUMN = 3;
var NAMES_INDEX = 4;
// src/gen-mapping.ts
var NO_NAME = -1;
var GenMapping = class {
constructor({ file, sourceRoot } = {}) {
this._names = new SetArray();
this._sources = new SetArray();
this._sourcesContent = [];
this._mappings = [];
this.file = file;
this.sourceRoot = sourceRoot;
this._ignoreList = new SetArray();
}
};
function cast2(map) {
return map;
}
function addSegment(map, genLine, genColumn, source, sourceLine, sourceColumn, name, content) {
return addSegmentInternal(
false,
map,
genLine,
genColumn,
source,
sourceLine,
sourceColumn,
name,
content
);
}
function addMapping(map, mapping) {
return addMappingInternal(false, map, mapping);
}
var maybeAddSegment = (map, genLine, genColumn, source, sourceLine, sourceColumn, name, content) => {
return addSegmentInternal(
true,
map,
genLine,
genColumn,
source,
sourceLine,
sourceColumn,
name,
content
);
};
var maybeAddMapping = (map, mapping) => {
return addMappingInternal(true, map, mapping);
};
function setSourceContent(map, source, content) {
const {
_sources: sources,
_sourcesContent: sourcesContent
// _originalScopes: originalScopes,
} = cast2(map);
const index = put(sources, source);
sourcesContent[index] = content;
}
function setIgnore(map, source, ignore = true) {
const {
_sources: sources,
_sourcesContent: sourcesContent,
_ignoreList: ignoreList
// _originalScopes: originalScopes,
} = cast2(map);
const index = put(sources, source);
if (index === sourcesContent.length) sourcesContent[index] = null;
if (ignore) put(ignoreList, index);
else remove(ignoreList, index);
}
function toDecodedMap(map) {
const {
_mappings: mappings,
_sources: sources,
_sourcesContent: sourcesContent,
_names: names,
_ignoreList: ignoreList
// _originalScopes: originalScopes,
// _generatedRanges: generatedRanges,
} = cast2(map);
removeEmptyFinalLines(mappings);
return {
version: 3,
file: map.file || void 0,
names: names.array,
sourceRoot: map.sourceRoot || void 0,
sources: sources.array,
sourcesContent,
mappings,
// originalScopes,
// generatedRanges,
ignoreList: ignoreList.array
};
}
function toEncodedMap(map) {
const decoded = toDecodedMap(map);
return Object.assign({}, decoded, {
// originalScopes: decoded.originalScopes.map((os) => encodeOriginalScopes(os)),
// generatedRanges: encodeGeneratedRanges(decoded.generatedRanges as GeneratedRange[]),
mappings: (0, import_sourcemap_codec.encode)(decoded.mappings)
});
}
function fromMap(input) {
const map = new import_trace_mapping.TraceMap(input);
const gen = new GenMapping({ file: map.file, sourceRoot: map.sourceRoot });
putAll(cast2(gen)._names, map.names);
putAll(cast2(gen)._sources, map.sources);
cast2(gen)._sourcesContent = map.sourcesContent || map.sources.map(() => null);
cast2(gen)._mappings = (0, import_trace_mapping.decodedMappings)(map);
if (map.ignoreList) putAll(cast2(gen)._ignoreList, map.ignoreList);
return gen;
}
function allMappings(map) {
const out = [];
const { _mappings: mappings, _sources: sources, _names: names } = cast2(map);
for (let i = 0; i < mappings.length; i++) {
const line = mappings[i];
for (let j = 0; j < line.length; j++) {
const seg = line[j];
const generated = { line: i + 1, column: seg[COLUMN] };
let source = void 0;
let original = void 0;
let name = void 0;
if (seg.length !== 1) {
source = sources.array[seg[SOURCES_INDEX]];
original = { line: seg[SOURCE_LINE] + 1, column: seg[SOURCE_COLUMN] };
if (seg.length === 5) name = names.array[seg[NAMES_INDEX]];
}
out.push({ generated, source, original, name });
}
}
return out;
}
function addSegmentInternal(skipable, map, genLine, genColumn, source, sourceLine, sourceColumn, name, content) {
const {
_mappings: mappings,
_sources: sources,
_sourcesContent: sourcesContent,
_names: names
// _originalScopes: originalScopes,
} = cast2(map);
const line = getIndex(mappings, genLine);
const index = getColumnIndex(line, genColumn);
if (!source) {
if (skipable && skipSourceless(line, index)) return;
return insert(line, index, [genColumn]);
}
assert(sourceLine);
assert(sourceColumn);
const sourcesIndex = put(sources, source);
const namesIndex = name ? put(names, name) : NO_NAME;
if (sourcesIndex === sourcesContent.length) sourcesContent[sourcesIndex] = content != null ? content : null;
if (skipable && skipSource(line, index, sourcesIndex, sourceLine, sourceColumn, namesIndex)) {
return;
}
return insert(
line,
index,
name ? [genColumn, sourcesIndex, sourceLine, sourceColumn, namesIndex] : [genColumn, sourcesIndex, sourceLine, sourceColumn]
);
}
function assert(_val) {
}
function getIndex(arr, index) {
for (let i = arr.length; i <= index; i++) {
arr[i] = [];
}
return arr[index];
}
function getColumnIndex(line, genColumn) {
let index = line.length;
for (let i = index - 1; i >= 0; index = i--) {
const current = line[i];
if (genColumn >= current[COLUMN]) break;
}
return index;
}
function insert(array, index, value) {
for (let i = array.length; i > index; i--) {
array[i] = array[i - 1];
}
array[index] = value;
}
function removeEmptyFinalLines(mappings) {
const { length } = mappings;
let len = length;
for (let i = len - 1; i >= 0; len = i, i--) {
if (mappings[i].length > 0) break;
}
if (len < length) mappings.length = len;
}
function putAll(setarr, array) {
for (let i = 0; i < array.length; i++) put(setarr, array[i]);
}
function skipSourceless(line, index) {
if (index === 0) return true;
const prev = line[index - 1];
return prev.length === 1;
}
function skipSource(line, index, sourcesIndex, sourceLine, sourceColumn, namesIndex) {
if (index === 0) return false;
const prev = line[index - 1];
if (prev.length === 1) return false;
return sourcesIndex === prev[SOURCES_INDEX] && sourceLine === prev[SOURCE_LINE] && sourceColumn === prev[SOURCE_COLUMN] && namesIndex === (prev.length === 5 ? prev[NAMES_INDEX] : NO_NAME);
}
function addMappingInternal(skipable, map, mapping) {
const { generated, source, original, name, content } = mapping;
if (!source) {
return addSegmentInternal(
skipable,
map,
generated.line - 1,
generated.column,
null,
null,
null,
null,
null
);
}
assert(original);
return addSegmentInternal(
skipable,
map,
generated.line - 1,
generated.column,
source,
original.line - 1,
original.column,
name,
content
);
}
}));
//# sourceMappingURL=gen-mapping.umd.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,88 @@
import type { SourceMapInput } from '@jridgewell/trace-mapping';
import type { DecodedSourceMap, EncodedSourceMap, Pos, Mapping } from './types';
export type { DecodedSourceMap, EncodedSourceMap, Mapping };
export type Options = {
file?: string | null;
sourceRoot?: string | null;
};
/**
* Provides the state to generate a sourcemap.
*/
export declare class GenMapping {
private _names;
private _sources;
private _sourcesContent;
private _mappings;
private _ignoreList;
file: string | null | undefined;
sourceRoot: string | null | undefined;
constructor({ file, sourceRoot }?: Options);
}
/**
* A low-level API to associate a generated position with an original source position. Line and
* column here are 0-based, unlike `addMapping`.
*/
export declare function addSegment(map: GenMapping, genLine: number, genColumn: number, source?: null, sourceLine?: null, sourceColumn?: null, name?: null, content?: null): void;
export declare function addSegment(map: GenMapping, genLine: number, genColumn: number, source: string, sourceLine: number, sourceColumn: number, name?: null, content?: string | null): void;
export declare function addSegment(map: GenMapping, genLine: number, genColumn: number, source: string, sourceLine: number, sourceColumn: number, name: string, content?: string | null): void;
/**
* A high-level API to associate a generated position with an original source position. Line is
* 1-based, but column is 0-based, due to legacy behavior in `source-map` library.
*/
export declare function addMapping(map: GenMapping, mapping: {
generated: Pos;
source?: null;
original?: null;
name?: null;
content?: null;
}): void;
export declare function addMapping(map: GenMapping, mapping: {
generated: Pos;
source: string;
original: Pos;
name?: null;
content?: string | null;
}): void;
export declare function addMapping(map: GenMapping, mapping: {
generated: Pos;
source: string;
original: Pos;
name: string;
content?: string | null;
}): void;
/**
* Same as `addSegment`, but will only add the segment if it generates useful information in the
* resulting map. This only works correctly if segments are added **in order**, meaning you should
* not add a segment with a lower generated line/column than one that came before.
*/
export declare const maybeAddSegment: typeof addSegment;
/**
* Same as `addMapping`, but will only add the mapping if it generates useful information in the
* resulting map. This only works correctly if mappings are added **in order**, meaning you should
* not add a mapping with a lower generated line/column than one that came before.
*/
export declare const maybeAddMapping: typeof addMapping;
/**
* Adds/removes the content of the source file to the source map.
*/
export declare function setSourceContent(map: GenMapping, source: string, content: string | null): void;
export declare function setIgnore(map: GenMapping, source: string, ignore?: boolean): void;
/**
* Returns a sourcemap object (with decoded mappings) suitable for passing to a library that expects
* a sourcemap, or to JSON.stringify.
*/
export declare function toDecodedMap(map: GenMapping): DecodedSourceMap;
/**
* Returns a sourcemap object (with encoded mappings) suitable for passing to a library that expects
* a sourcemap, or to JSON.stringify.
*/
export declare function toEncodedMap(map: GenMapping): EncodedSourceMap;
/**
* Constructs a new GenMapping, using the already present mappings of the input.
*/
export declare function fromMap(input: SourceMapInput): GenMapping;
/**
* Returns an array of high-level mapping objects for every recorded segment, which could then be
* passed to the `source-map` library.
*/
export declare function allMappings(map: GenMapping): Mapping[];

View File

@@ -0,0 +1,32 @@
type Key = string | number | symbol;
/**
* SetArray acts like a `Set` (allowing only one occurrence of a string `key`), but provides the
* index of the `key` in the backing array.
*
* This is designed to allow synchronizing a second array with the contents of the backing array,
* like how in a sourcemap `sourcesContent[i]` is the source content associated with `source[i]`,
* and there are never duplicates.
*/
export declare class SetArray<T extends Key = Key> {
private _indexes;
array: readonly T[];
constructor();
}
/**
* Gets the index associated with `key` in the backing array, if it is already present.
*/
export declare function get<T extends Key>(setarr: SetArray<T>, key: T): number | undefined;
/**
* Puts `key` into the backing array, if it is not already present. Returns
* the index of the `key` in the backing array.
*/
export declare function put<T extends Key>(setarr: SetArray<T>, key: T): number;
/**
* Pops the last added item out of the SetArray.
*/
export declare function pop<T extends Key>(setarr: SetArray<T>): void;
/**
* Removes the key, if it exists in the set.
*/
export declare function remove<T extends Key>(setarr: SetArray<T>, key: T): void;
export {};

View File

@@ -0,0 +1,12 @@
type GeneratedColumn = number;
type SourcesIndex = number;
type SourceLine = number;
type SourceColumn = number;
type NamesIndex = number;
export type SourceMapSegment = [GeneratedColumn] | [GeneratedColumn, SourcesIndex, SourceLine, SourceColumn] | [GeneratedColumn, SourcesIndex, SourceLine, SourceColumn, NamesIndex];
export declare const COLUMN = 0;
export declare const SOURCES_INDEX = 1;
export declare const SOURCE_LINE = 2;
export declare const SOURCE_COLUMN = 3;
export declare const NAMES_INDEX = 4;
export {};

View File

@@ -0,0 +1,43 @@
import type { SourceMapSegment } from './sourcemap-segment';
export interface SourceMapV3 {
file?: string | null;
names: readonly string[];
sourceRoot?: string;
sources: readonly (string | null)[];
sourcesContent?: readonly (string | null)[];
version: 3;
ignoreList?: readonly number[];
}
export interface EncodedSourceMap extends SourceMapV3 {
mappings: string;
}
export interface DecodedSourceMap extends SourceMapV3 {
mappings: readonly SourceMapSegment[][];
}
export interface Pos {
line: number;
column: number;
}
export interface OriginalPos extends Pos {
source: string;
}
export interface BindingExpressionRange {
start: Pos;
expression: string;
}
export type Mapping = {
generated: Pos;
source: undefined;
original: undefined;
name: undefined;
} | {
generated: Pos;
source: string;
original: Pos;
name: string;
} | {
generated: Pos;
source: string;
original: Pos;
name: undefined;
};

View File

@@ -0,0 +1,67 @@
{
"name": "@jridgewell/gen-mapping",
"version": "0.3.13",
"description": "Generate source maps",
"keywords": [
"source",
"map"
],
"main": "dist/gen-mapping.umd.js",
"module": "dist/gen-mapping.mjs",
"types": "types/gen-mapping.d.cts",
"files": [
"dist",
"src",
"types"
],
"exports": {
".": [
{
"import": {
"types": "./types/gen-mapping.d.mts",
"default": "./dist/gen-mapping.mjs"
},
"default": {
"types": "./types/gen-mapping.d.cts",
"default": "./dist/gen-mapping.umd.js"
}
},
"./dist/gen-mapping.umd.js"
],
"./package.json": "./package.json"
},
"scripts": {
"benchmark": "run-s build:code benchmark:*",
"benchmark:install": "cd benchmark && npm install",
"benchmark:only": "node --expose-gc benchmark/index.js",
"build": "run-s -n build:code build:types",
"build:code": "node ../../esbuild.mjs gen-mapping.ts",
"build:types": "run-s build:types:force build:types:emit build:types:mts",
"build:types:force": "rimraf tsconfig.build.tsbuildinfo",
"build:types:emit": "tsc --project tsconfig.build.json",
"build:types:mts": "node ../../mts-types.mjs",
"clean": "run-s -n clean:code clean:types",
"clean:code": "tsc --build --clean tsconfig.build.json",
"clean:types": "rimraf dist types",
"test": "run-s -n test:types test:only test:format",
"test:format": "prettier --check '{src,test}/**/*.ts'",
"test:only": "mocha",
"test:types": "eslint '{src,test}/**/*.ts'",
"lint": "run-s -n lint:types lint:format",
"lint:format": "npm run test:format -- --write",
"lint:types": "npm run test:types -- --fix",
"prepublishOnly": "npm run-s -n build test"
},
"homepage": "https://github.com/jridgewell/sourcemaps/tree/main/packages/gen-mapping",
"repository": {
"type": "git",
"url": "git+https://github.com/jridgewell/sourcemaps.git",
"directory": "packages/gen-mapping"
},
"author": "Justin Ridgewell <justin@ridgewell.name>",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
}

View File

@@ -0,0 +1,614 @@
import { SetArray, put, remove } from './set-array';
import {
encode,
// encodeGeneratedRanges,
// encodeOriginalScopes
} from '@jridgewell/sourcemap-codec';
import { TraceMap, decodedMappings } from '@jridgewell/trace-mapping';
import {
COLUMN,
SOURCES_INDEX,
SOURCE_LINE,
SOURCE_COLUMN,
NAMES_INDEX,
} from './sourcemap-segment';
import type { SourceMapInput } from '@jridgewell/trace-mapping';
// import type { OriginalScope, GeneratedRange } from '@jridgewell/sourcemap-codec';
import type { SourceMapSegment } from './sourcemap-segment';
import type {
DecodedSourceMap,
EncodedSourceMap,
Pos,
Mapping,
// BindingExpressionRange,
// OriginalPos,
// OriginalScopeInfo,
// GeneratedRangeInfo,
} from './types';
export type { DecodedSourceMap, EncodedSourceMap, Mapping };
export type Options = {
file?: string | null;
sourceRoot?: string | null;
};
const NO_NAME = -1;
/**
* Provides the state to generate a sourcemap.
*/
export class GenMapping {
declare private _names: SetArray<string>;
declare private _sources: SetArray<string>;
declare private _sourcesContent: (string | null)[];
declare private _mappings: SourceMapSegment[][];
// private declare _originalScopes: OriginalScope[][];
// private declare _generatedRanges: GeneratedRange[];
declare private _ignoreList: SetArray<number>;
declare file: string | null | undefined;
declare sourceRoot: string | null | undefined;
constructor({ file, sourceRoot }: Options = {}) {
this._names = new SetArray();
this._sources = new SetArray();
this._sourcesContent = [];
this._mappings = [];
// this._originalScopes = [];
// this._generatedRanges = [];
this.file = file;
this.sourceRoot = sourceRoot;
this._ignoreList = new SetArray();
}
}
interface PublicMap {
_names: GenMapping['_names'];
_sources: GenMapping['_sources'];
_sourcesContent: GenMapping['_sourcesContent'];
_mappings: GenMapping['_mappings'];
// _originalScopes: GenMapping['_originalScopes'];
// _generatedRanges: GenMapping['_generatedRanges'];
_ignoreList: GenMapping['_ignoreList'];
}
/**
* Typescript doesn't allow friend access to private fields, so this just casts the map into a type
* with public access modifiers.
*/
function cast(map: unknown): PublicMap {
return map as any;
}
/**
* A low-level API to associate a generated position with an original source position. Line and
* column here are 0-based, unlike `addMapping`.
*/
export function addSegment(
map: GenMapping,
genLine: number,
genColumn: number,
source?: null,
sourceLine?: null,
sourceColumn?: null,
name?: null,
content?: null,
): void;
export function addSegment(
map: GenMapping,
genLine: number,
genColumn: number,
source: string,
sourceLine: number,
sourceColumn: number,
name?: null,
content?: string | null,
): void;
export function addSegment(
map: GenMapping,
genLine: number,
genColumn: number,
source: string,
sourceLine: number,
sourceColumn: number,
name: string,
content?: string | null,
): void;
export function addSegment(
map: GenMapping,
genLine: number,
genColumn: number,
source?: string | null,
sourceLine?: number | null,
sourceColumn?: number | null,
name?: string | null,
content?: string | null,
): void {
return addSegmentInternal(
false,
map,
genLine,
genColumn,
source,
sourceLine,
sourceColumn,
name,
content,
);
}
/**
* A high-level API to associate a generated position with an original source position. Line is
* 1-based, but column is 0-based, due to legacy behavior in `source-map` library.
*/
export function addMapping(
map: GenMapping,
mapping: {
generated: Pos;
source?: null;
original?: null;
name?: null;
content?: null;
},
): void;
export function addMapping(
map: GenMapping,
mapping: {
generated: Pos;
source: string;
original: Pos;
name?: null;
content?: string | null;
},
): void;
export function addMapping(
map: GenMapping,
mapping: {
generated: Pos;
source: string;
original: Pos;
name: string;
content?: string | null;
},
): void;
export function addMapping(
map: GenMapping,
mapping: {
generated: Pos;
source?: string | null;
original?: Pos | null;
name?: string | null;
content?: string | null;
},
): void {
return addMappingInternal(false, map, mapping as Parameters<typeof addMappingInternal>[2]);
}
/**
* Same as `addSegment`, but will only add the segment if it generates useful information in the
* resulting map. This only works correctly if segments are added **in order**, meaning you should
* not add a segment with a lower generated line/column than one that came before.
*/
export const maybeAddSegment: typeof addSegment = (
map,
genLine,
genColumn,
source,
sourceLine,
sourceColumn,
name,
content,
) => {
return addSegmentInternal(
true,
map,
genLine,
genColumn,
source,
sourceLine,
sourceColumn,
name,
content,
);
};
/**
* Same as `addMapping`, but will only add the mapping if it generates useful information in the
* resulting map. This only works correctly if mappings are added **in order**, meaning you should
* not add a mapping with a lower generated line/column than one that came before.
*/
export const maybeAddMapping: typeof addMapping = (map, mapping) => {
return addMappingInternal(true, map, mapping as Parameters<typeof addMappingInternal>[2]);
};
/**
* Adds/removes the content of the source file to the source map.
*/
export function setSourceContent(map: GenMapping, source: string, content: string | null): void {
const {
_sources: sources,
_sourcesContent: sourcesContent,
// _originalScopes: originalScopes,
} = cast(map);
const index = put(sources, source);
sourcesContent[index] = content;
// if (index === originalScopes.length) originalScopes[index] = [];
}
export function setIgnore(map: GenMapping, source: string, ignore = true) {
const {
_sources: sources,
_sourcesContent: sourcesContent,
_ignoreList: ignoreList,
// _originalScopes: originalScopes,
} = cast(map);
const index = put(sources, source);
if (index === sourcesContent.length) sourcesContent[index] = null;
// if (index === originalScopes.length) originalScopes[index] = [];
if (ignore) put(ignoreList, index);
else remove(ignoreList, index);
}
/**
* Returns a sourcemap object (with decoded mappings) suitable for passing to a library that expects
* a sourcemap, or to JSON.stringify.
*/
export function toDecodedMap(map: GenMapping): DecodedSourceMap {
const {
_mappings: mappings,
_sources: sources,
_sourcesContent: sourcesContent,
_names: names,
_ignoreList: ignoreList,
// _originalScopes: originalScopes,
// _generatedRanges: generatedRanges,
} = cast(map);
removeEmptyFinalLines(mappings);
return {
version: 3,
file: map.file || undefined,
names: names.array,
sourceRoot: map.sourceRoot || undefined,
sources: sources.array,
sourcesContent,
mappings,
// originalScopes,
// generatedRanges,
ignoreList: ignoreList.array,
};
}
/**
* Returns a sourcemap object (with encoded mappings) suitable for passing to a library that expects
* a sourcemap, or to JSON.stringify.
*/
export function toEncodedMap(map: GenMapping): EncodedSourceMap {
const decoded = toDecodedMap(map);
return Object.assign({}, decoded, {
// originalScopes: decoded.originalScopes.map((os) => encodeOriginalScopes(os)),
// generatedRanges: encodeGeneratedRanges(decoded.generatedRanges as GeneratedRange[]),
mappings: encode(decoded.mappings as SourceMapSegment[][]),
});
}
/**
* Constructs a new GenMapping, using the already present mappings of the input.
*/
export function fromMap(input: SourceMapInput): GenMapping {
const map = new TraceMap(input);
const gen = new GenMapping({ file: map.file, sourceRoot: map.sourceRoot });
putAll(cast(gen)._names, map.names);
putAll(cast(gen)._sources, map.sources as string[]);
cast(gen)._sourcesContent = map.sourcesContent || map.sources.map(() => null);
cast(gen)._mappings = decodedMappings(map) as GenMapping['_mappings'];
// TODO: implement originalScopes/generatedRanges
if (map.ignoreList) putAll(cast(gen)._ignoreList, map.ignoreList);
return gen;
}
/**
* Returns an array of high-level mapping objects for every recorded segment, which could then be
* passed to the `source-map` library.
*/
export function allMappings(map: GenMapping): Mapping[] {
const out: Mapping[] = [];
const { _mappings: mappings, _sources: sources, _names: names } = cast(map);
for (let i = 0; i < mappings.length; i++) {
const line = mappings[i];
for (let j = 0; j < line.length; j++) {
const seg = line[j];
const generated = { line: i + 1, column: seg[COLUMN] };
let source: string | undefined = undefined;
let original: Pos | undefined = undefined;
let name: string | undefined = undefined;
if (seg.length !== 1) {
source = sources.array[seg[SOURCES_INDEX]];
original = { line: seg[SOURCE_LINE] + 1, column: seg[SOURCE_COLUMN] };
if (seg.length === 5) name = names.array[seg[NAMES_INDEX]];
}
out.push({ generated, source, original, name } as Mapping);
}
}
return out;
}
// This split declaration is only so that terser can elminiate the static initialization block.
function addSegmentInternal<S extends string | null | undefined>(
skipable: boolean,
map: GenMapping,
genLine: number,
genColumn: number,
source: S,
sourceLine: S extends string ? number : null | undefined,
sourceColumn: S extends string ? number : null | undefined,
name: S extends string ? string | null | undefined : null | undefined,
content: S extends string ? string | null | undefined : null | undefined,
): void {
const {
_mappings: mappings,
_sources: sources,
_sourcesContent: sourcesContent,
_names: names,
// _originalScopes: originalScopes,
} = cast(map);
const line = getIndex(mappings, genLine);
const index = getColumnIndex(line, genColumn);
if (!source) {
if (skipable && skipSourceless(line, index)) return;
return insert(line, index, [genColumn]);
}
// Sigh, TypeScript can't figure out sourceLine and sourceColumn aren't nullish if source
// isn't nullish.
assert<number>(sourceLine);
assert<number>(sourceColumn);
const sourcesIndex = put(sources, source);
const namesIndex = name ? put(names, name) : NO_NAME;
if (sourcesIndex === sourcesContent.length) sourcesContent[sourcesIndex] = content ?? null;
// if (sourcesIndex === originalScopes.length) originalScopes[sourcesIndex] = [];
if (skipable && skipSource(line, index, sourcesIndex, sourceLine, sourceColumn, namesIndex)) {
return;
}
return insert(
line,
index,
name
? [genColumn, sourcesIndex, sourceLine, sourceColumn, namesIndex]
: [genColumn, sourcesIndex, sourceLine, sourceColumn],
);
}
function assert<T>(_val: unknown): asserts _val is T {
// noop.
}
function getIndex<T>(arr: T[][], index: number): T[] {
for (let i = arr.length; i <= index; i++) {
arr[i] = [];
}
return arr[index];
}
function getColumnIndex(line: SourceMapSegment[], genColumn: number): number {
let index = line.length;
for (let i = index - 1; i >= 0; index = i--) {
const current = line[i];
if (genColumn >= current[COLUMN]) break;
}
return index;
}
function insert<T>(array: T[], index: number, value: T) {
for (let i = array.length; i > index; i--) {
array[i] = array[i - 1];
}
array[index] = value;
}
function removeEmptyFinalLines(mappings: SourceMapSegment[][]) {
const { length } = mappings;
let len = length;
for (let i = len - 1; i >= 0; len = i, i--) {
if (mappings[i].length > 0) break;
}
if (len < length) mappings.length = len;
}
function putAll<T extends string | number>(setarr: SetArray<T>, array: T[]) {
for (let i = 0; i < array.length; i++) put(setarr, array[i]);
}
function skipSourceless(line: SourceMapSegment[], index: number): boolean {
// The start of a line is already sourceless, so adding a sourceless segment to the beginning
// doesn't generate any useful information.
if (index === 0) return true;
const prev = line[index - 1];
// If the previous segment is also sourceless, then adding another sourceless segment doesn't
// genrate any new information. Else, this segment will end the source/named segment and point to
// a sourceless position, which is useful.
return prev.length === 1;
}
function skipSource(
line: SourceMapSegment[],
index: number,
sourcesIndex: number,
sourceLine: number,
sourceColumn: number,
namesIndex: number,
): boolean {
// A source/named segment at the start of a line gives position at that genColumn
if (index === 0) return false;
const prev = line[index - 1];
// If the previous segment is sourceless, then we're transitioning to a source.
if (prev.length === 1) return false;
// If the previous segment maps to the exact same source position, then this segment doesn't
// provide any new position information.
return (
sourcesIndex === prev[SOURCES_INDEX] &&
sourceLine === prev[SOURCE_LINE] &&
sourceColumn === prev[SOURCE_COLUMN] &&
namesIndex === (prev.length === 5 ? prev[NAMES_INDEX] : NO_NAME)
);
}
function addMappingInternal<S extends string | null | undefined>(
skipable: boolean,
map: GenMapping,
mapping: {
generated: Pos;
source: S;
original: S extends string ? Pos : null | undefined;
name: S extends string ? string | null | undefined : null | undefined;
content: S extends string ? string | null | undefined : null | undefined;
},
) {
const { generated, source, original, name, content } = mapping;
if (!source) {
return addSegmentInternal(
skipable,
map,
generated.line - 1,
generated.column,
null,
null,
null,
null,
null,
);
}
assert<Pos>(original);
return addSegmentInternal(
skipable,
map,
generated.line - 1,
generated.column,
source as string,
original.line - 1,
original.column,
name,
content,
);
}
/*
export function addOriginalScope(
map: GenMapping,
data: {
start: Pos;
end: Pos;
source: string;
kind: string;
name?: string;
variables?: string[];
},
): OriginalScopeInfo {
const { start, end, source, kind, name, variables } = data;
const {
_sources: sources,
_sourcesContent: sourcesContent,
_originalScopes: originalScopes,
_names: names,
} = cast(map);
const index = put(sources, source);
if (index === sourcesContent.length) sourcesContent[index] = null;
if (index === originalScopes.length) originalScopes[index] = [];
const kindIndex = put(names, kind);
const scope: OriginalScope = name
? [start.line - 1, start.column, end.line - 1, end.column, kindIndex, put(names, name)]
: [start.line - 1, start.column, end.line - 1, end.column, kindIndex];
if (variables) {
scope.vars = variables.map((v) => put(names, v));
}
const len = originalScopes[index].push(scope);
return [index, len - 1, variables];
}
*/
// Generated Ranges
/*
export function addGeneratedRange(
map: GenMapping,
data: {
start: Pos;
isScope: boolean;
originalScope?: OriginalScopeInfo;
callsite?: OriginalPos;
},
): GeneratedRangeInfo {
const { start, isScope, originalScope, callsite } = data;
const {
_originalScopes: originalScopes,
_sources: sources,
_sourcesContent: sourcesContent,
_generatedRanges: generatedRanges,
} = cast(map);
const range: GeneratedRange = [
start.line - 1,
start.column,
0,
0,
originalScope ? originalScope[0] : -1,
originalScope ? originalScope[1] : -1,
];
if (originalScope?.[2]) {
range.bindings = originalScope[2].map(() => [[-1]]);
}
if (callsite) {
const index = put(sources, callsite.source);
if (index === sourcesContent.length) sourcesContent[index] = null;
if (index === originalScopes.length) originalScopes[index] = [];
range.callsite = [index, callsite.line - 1, callsite.column];
}
if (isScope) range.isScope = true;
generatedRanges.push(range);
return [range, originalScope?.[2]];
}
export function setEndPosition(range: GeneratedRangeInfo, pos: Pos) {
range[0][2] = pos.line - 1;
range[0][3] = pos.column;
}
export function addBinding(
map: GenMapping,
range: GeneratedRangeInfo,
variable: string,
expression: string | BindingExpressionRange,
) {
const { _names: names } = cast(map);
const bindings = (range[0].bindings ||= []);
const vars = range[1];
const index = vars!.indexOf(variable);
const binding = getIndex(bindings, index);
if (typeof expression === 'string') binding[0] = [put(names, expression)];
else {
const { start } = expression;
binding.push([put(names, expression.expression), start.line - 1, start.column]);
}
}
*/

View File

@@ -0,0 +1,82 @@
type Key = string | number | symbol;
/**
* SetArray acts like a `Set` (allowing only one occurrence of a string `key`), but provides the
* index of the `key` in the backing array.
*
* This is designed to allow synchronizing a second array with the contents of the backing array,
* like how in a sourcemap `sourcesContent[i]` is the source content associated with `source[i]`,
* and there are never duplicates.
*/
export class SetArray<T extends Key = Key> {
declare private _indexes: Record<T, number | undefined>;
declare array: readonly T[];
constructor() {
this._indexes = { __proto__: null } as any;
this.array = [];
}
}
interface PublicSet<T extends Key> {
array: T[];
_indexes: SetArray<T>['_indexes'];
}
/**
* Typescript doesn't allow friend access to private fields, so this just casts the set into a type
* with public access modifiers.
*/
function cast<T extends Key>(set: SetArray<T>): PublicSet<T> {
return set as any;
}
/**
* Gets the index associated with `key` in the backing array, if it is already present.
*/
export function get<T extends Key>(setarr: SetArray<T>, key: T): number | undefined {
return cast(setarr)._indexes[key];
}
/**
* Puts `key` into the backing array, if it is not already present. Returns
* the index of the `key` in the backing array.
*/
export function put<T extends Key>(setarr: SetArray<T>, key: T): number {
// The key may or may not be present. If it is present, it's a number.
const index = get(setarr, key);
if (index !== undefined) return index;
const { array, _indexes: indexes } = cast(setarr);
const length = array.push(key);
return (indexes[key] = length - 1);
}
/**
* Pops the last added item out of the SetArray.
*/
export function pop<T extends Key>(setarr: SetArray<T>): void {
const { array, _indexes: indexes } = cast(setarr);
if (array.length === 0) return;
const last = array.pop()!;
indexes[last] = undefined;
}
/**
* Removes the key, if it exists in the set.
*/
export function remove<T extends Key>(setarr: SetArray<T>, key: T): void {
const index = get(setarr, key);
if (index === undefined) return;
const { array, _indexes: indexes } = cast(setarr);
for (let i = index + 1; i < array.length; i++) {
const k = array[i];
array[i - 1] = k;
indexes[k]!--;
}
indexes[key] = undefined;
array.pop();
}

View File

@@ -0,0 +1,16 @@
type GeneratedColumn = number;
type SourcesIndex = number;
type SourceLine = number;
type SourceColumn = number;
type NamesIndex = number;
export type SourceMapSegment =
| [GeneratedColumn]
| [GeneratedColumn, SourcesIndex, SourceLine, SourceColumn]
| [GeneratedColumn, SourcesIndex, SourceLine, SourceColumn, NamesIndex];
export const COLUMN = 0;
export const SOURCES_INDEX = 1;
export const SOURCE_LINE = 2;
export const SOURCE_COLUMN = 3;
export const NAMES_INDEX = 4;

View File

@@ -0,0 +1,61 @@
// import type { GeneratedRange, OriginalScope } from '@jridgewell/sourcemap-codec';
import type { SourceMapSegment } from './sourcemap-segment';
export interface SourceMapV3 {
file?: string | null;
names: readonly string[];
sourceRoot?: string;
sources: readonly (string | null)[];
sourcesContent?: readonly (string | null)[];
version: 3;
ignoreList?: readonly number[];
}
export interface EncodedSourceMap extends SourceMapV3 {
mappings: string;
// originalScopes: string[];
// generatedRanges: string;
}
export interface DecodedSourceMap extends SourceMapV3 {
mappings: readonly SourceMapSegment[][];
// originalScopes: readonly OriginalScope[][];
// generatedRanges: readonly GeneratedRange[];
}
export interface Pos {
line: number; // 1-based
column: number; // 0-based
}
export interface OriginalPos extends Pos {
source: string;
}
export interface BindingExpressionRange {
start: Pos;
expression: string;
}
// export type OriginalScopeInfo = [number, number, string[] | undefined];
// export type GeneratedRangeInfo = [GeneratedRange, string[] | undefined];
export type Mapping =
| {
generated: Pos;
source: undefined;
original: undefined;
name: undefined;
}
| {
generated: Pos;
source: string;
original: Pos;
name: string;
}
| {
generated: Pos;
source: string;
original: Pos;
name: undefined;
};

View File

@@ -0,0 +1,89 @@
import type { SourceMapInput } from '@jridgewell/trace-mapping';
import type { DecodedSourceMap, EncodedSourceMap, Pos, Mapping } from './types.cts';
export type { DecodedSourceMap, EncodedSourceMap, Mapping };
export type Options = {
file?: string | null;
sourceRoot?: string | null;
};
/**
* Provides the state to generate a sourcemap.
*/
export declare class GenMapping {
private _names;
private _sources;
private _sourcesContent;
private _mappings;
private _ignoreList;
file: string | null | undefined;
sourceRoot: string | null | undefined;
constructor({ file, sourceRoot }?: Options);
}
/**
* A low-level API to associate a generated position with an original source position. Line and
* column here are 0-based, unlike `addMapping`.
*/
export declare function addSegment(map: GenMapping, genLine: number, genColumn: number, source?: null, sourceLine?: null, sourceColumn?: null, name?: null, content?: null): void;
export declare function addSegment(map: GenMapping, genLine: number, genColumn: number, source: string, sourceLine: number, sourceColumn: number, name?: null, content?: string | null): void;
export declare function addSegment(map: GenMapping, genLine: number, genColumn: number, source: string, sourceLine: number, sourceColumn: number, name: string, content?: string | null): void;
/**
* A high-level API to associate a generated position with an original source position. Line is
* 1-based, but column is 0-based, due to legacy behavior in `source-map` library.
*/
export declare function addMapping(map: GenMapping, mapping: {
generated: Pos;
source?: null;
original?: null;
name?: null;
content?: null;
}): void;
export declare function addMapping(map: GenMapping, mapping: {
generated: Pos;
source: string;
original: Pos;
name?: null;
content?: string | null;
}): void;
export declare function addMapping(map: GenMapping, mapping: {
generated: Pos;
source: string;
original: Pos;
name: string;
content?: string | null;
}): void;
/**
* Same as `addSegment`, but will only add the segment if it generates useful information in the
* resulting map. This only works correctly if segments are added **in order**, meaning you should
* not add a segment with a lower generated line/column than one that came before.
*/
export declare const maybeAddSegment: typeof addSegment;
/**
* Same as `addMapping`, but will only add the mapping if it generates useful information in the
* resulting map. This only works correctly if mappings are added **in order**, meaning you should
* not add a mapping with a lower generated line/column than one that came before.
*/
export declare const maybeAddMapping: typeof addMapping;
/**
* Adds/removes the content of the source file to the source map.
*/
export declare function setSourceContent(map: GenMapping, source: string, content: string | null): void;
export declare function setIgnore(map: GenMapping, source: string, ignore?: boolean): void;
/**
* Returns a sourcemap object (with decoded mappings) suitable for passing to a library that expects
* a sourcemap, or to JSON.stringify.
*/
export declare function toDecodedMap(map: GenMapping): DecodedSourceMap;
/**
* Returns a sourcemap object (with encoded mappings) suitable for passing to a library that expects
* a sourcemap, or to JSON.stringify.
*/
export declare function toEncodedMap(map: GenMapping): EncodedSourceMap;
/**
* Constructs a new GenMapping, using the already present mappings of the input.
*/
export declare function fromMap(input: SourceMapInput): GenMapping;
/**
* Returns an array of high-level mapping objects for every recorded segment, which could then be
* passed to the `source-map` library.
*/
export declare function allMappings(map: GenMapping): Mapping[];
//# sourceMappingURL=gen-mapping.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"gen-mapping.d.ts","sourceRoot":"","sources":["../src/gen-mapping.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAGhE,OAAO,KAAK,EACV,gBAAgB,EAChB,gBAAgB,EAChB,GAAG,EACH,OAAO,EAKR,MAAM,SAAS,CAAC;AAEjB,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,OAAO,EAAE,CAAC;AAE5D,MAAM,MAAM,OAAO,GAAG;IACpB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B,CAAC;AAIF;;GAEG;AACH,qBAAa,UAAU;IACrB,QAAgB,MAAM,CAAmB;IACzC,QAAgB,QAAQ,CAAmB;IAC3C,QAAgB,eAAe,CAAoB;IACnD,QAAgB,SAAS,CAAuB;IAGhD,QAAgB,WAAW,CAAmB;IACtC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAChC,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;gBAElC,EAAE,IAAI,EAAE,UAAU,EAAE,GAAE,OAAY;CAW/C;AAoBD;;;GAGG;AACH,wBAAgB,UAAU,CACxB,GAAG,EAAE,UAAU,EACf,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,IAAI,EACb,UAAU,CAAC,EAAE,IAAI,EACjB,YAAY,CAAC,EAAE,IAAI,EACnB,IAAI,CAAC,EAAE,IAAI,EACX,OAAO,CAAC,EAAE,IAAI,GACb,IAAI,CAAC;AACR,wBAAgB,UAAU,CACxB,GAAG,EAAE,UAAU,EACf,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,EACpB,IAAI,CAAC,EAAE,IAAI,EACX,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,GACtB,IAAI,CAAC;AACR,wBAAgB,UAAU,CACxB,GAAG,EAAE,UAAU,EACf,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,GACtB,IAAI,CAAC;AAwBR;;;GAGG;AACH,wBAAgB,UAAU,CACxB,GAAG,EAAE,UAAU,EACf,OAAO,EAAE;IACP,SAAS,EAAE,GAAG,CAAC;IACf,MAAM,CAAC,EAAE,IAAI,CAAC;IACd,QAAQ,CAAC,EAAE,IAAI,CAAC;IAChB,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,OAAO,CAAC,EAAE,IAAI,CAAC;CAChB,GACA,IAAI,CAAC;AACR,wBAAgB,UAAU,CACxB,GAAG,EAAE,UAAU,EACf,OAAO,EAAE;IACP,SAAS,EAAE,GAAG,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC;IACd,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB,GACA,IAAI,CAAC;AACR,wBAAgB,UAAU,CACxB,GAAG,EAAE,UAAU,EACf,OAAO,EAAE;IACP,SAAS,EAAE,GAAG,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB,GACA,IAAI,CAAC;AAcR;;;;GAIG;AACH,eAAO,MAAM,eAAe,EAAE,OAAO,UAqBpC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,eAAe,EAAE,OAAO,UAEpC,CAAC;AAEF;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAS9F;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,UAAO,QAYvE;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,gBAAgB,CAwB9D;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,gBAAgB,CAO9D;AAED;;GAEG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,cAAc,GAAG,UAAU,CAYzD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,EAAE,CA0BtD"}

View File

@@ -0,0 +1,89 @@
import type { SourceMapInput } from '@jridgewell/trace-mapping';
import type { DecodedSourceMap, EncodedSourceMap, Pos, Mapping } from './types.mts';
export type { DecodedSourceMap, EncodedSourceMap, Mapping };
export type Options = {
file?: string | null;
sourceRoot?: string | null;
};
/**
* Provides the state to generate a sourcemap.
*/
export declare class GenMapping {
private _names;
private _sources;
private _sourcesContent;
private _mappings;
private _ignoreList;
file: string | null | undefined;
sourceRoot: string | null | undefined;
constructor({ file, sourceRoot }?: Options);
}
/**
* A low-level API to associate a generated position with an original source position. Line and
* column here are 0-based, unlike `addMapping`.
*/
export declare function addSegment(map: GenMapping, genLine: number, genColumn: number, source?: null, sourceLine?: null, sourceColumn?: null, name?: null, content?: null): void;
export declare function addSegment(map: GenMapping, genLine: number, genColumn: number, source: string, sourceLine: number, sourceColumn: number, name?: null, content?: string | null): void;
export declare function addSegment(map: GenMapping, genLine: number, genColumn: number, source: string, sourceLine: number, sourceColumn: number, name: string, content?: string | null): void;
/**
* A high-level API to associate a generated position with an original source position. Line is
* 1-based, but column is 0-based, due to legacy behavior in `source-map` library.
*/
export declare function addMapping(map: GenMapping, mapping: {
generated: Pos;
source?: null;
original?: null;
name?: null;
content?: null;
}): void;
export declare function addMapping(map: GenMapping, mapping: {
generated: Pos;
source: string;
original: Pos;
name?: null;
content?: string | null;
}): void;
export declare function addMapping(map: GenMapping, mapping: {
generated: Pos;
source: string;
original: Pos;
name: string;
content?: string | null;
}): void;
/**
* Same as `addSegment`, but will only add the segment if it generates useful information in the
* resulting map. This only works correctly if segments are added **in order**, meaning you should
* not add a segment with a lower generated line/column than one that came before.
*/
export declare const maybeAddSegment: typeof addSegment;
/**
* Same as `addMapping`, but will only add the mapping if it generates useful information in the
* resulting map. This only works correctly if mappings are added **in order**, meaning you should
* not add a mapping with a lower generated line/column than one that came before.
*/
export declare const maybeAddMapping: typeof addMapping;
/**
* Adds/removes the content of the source file to the source map.
*/
export declare function setSourceContent(map: GenMapping, source: string, content: string | null): void;
export declare function setIgnore(map: GenMapping, source: string, ignore?: boolean): void;
/**
* Returns a sourcemap object (with decoded mappings) suitable for passing to a library that expects
* a sourcemap, or to JSON.stringify.
*/
export declare function toDecodedMap(map: GenMapping): DecodedSourceMap;
/**
* Returns a sourcemap object (with encoded mappings) suitable for passing to a library that expects
* a sourcemap, or to JSON.stringify.
*/
export declare function toEncodedMap(map: GenMapping): EncodedSourceMap;
/**
* Constructs a new GenMapping, using the already present mappings of the input.
*/
export declare function fromMap(input: SourceMapInput): GenMapping;
/**
* Returns an array of high-level mapping objects for every recorded segment, which could then be
* passed to the `source-map` library.
*/
export declare function allMappings(map: GenMapping): Mapping[];
//# sourceMappingURL=gen-mapping.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"gen-mapping.d.ts","sourceRoot":"","sources":["../src/gen-mapping.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAGhE,OAAO,KAAK,EACV,gBAAgB,EAChB,gBAAgB,EAChB,GAAG,EACH,OAAO,EAKR,MAAM,SAAS,CAAC;AAEjB,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,OAAO,EAAE,CAAC;AAE5D,MAAM,MAAM,OAAO,GAAG;IACpB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B,CAAC;AAIF;;GAEG;AACH,qBAAa,UAAU;IACrB,QAAgB,MAAM,CAAmB;IACzC,QAAgB,QAAQ,CAAmB;IAC3C,QAAgB,eAAe,CAAoB;IACnD,QAAgB,SAAS,CAAuB;IAGhD,QAAgB,WAAW,CAAmB;IACtC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAChC,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;gBAElC,EAAE,IAAI,EAAE,UAAU,EAAE,GAAE,OAAY;CAW/C;AAoBD;;;GAGG;AACH,wBAAgB,UAAU,CACxB,GAAG,EAAE,UAAU,EACf,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,IAAI,EACb,UAAU,CAAC,EAAE,IAAI,EACjB,YAAY,CAAC,EAAE,IAAI,EACnB,IAAI,CAAC,EAAE,IAAI,EACX,OAAO,CAAC,EAAE,IAAI,GACb,IAAI,CAAC;AACR,wBAAgB,UAAU,CACxB,GAAG,EAAE,UAAU,EACf,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,EACpB,IAAI,CAAC,EAAE,IAAI,EACX,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,GACtB,IAAI,CAAC;AACR,wBAAgB,UAAU,CACxB,GAAG,EAAE,UAAU,EACf,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,GACtB,IAAI,CAAC;AAwBR;;;GAGG;AACH,wBAAgB,UAAU,CACxB,GAAG,EAAE,UAAU,EACf,OAAO,EAAE;IACP,SAAS,EAAE,GAAG,CAAC;IACf,MAAM,CAAC,EAAE,IAAI,CAAC;IACd,QAAQ,CAAC,EAAE,IAAI,CAAC;IAChB,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,OAAO,CAAC,EAAE,IAAI,CAAC;CAChB,GACA,IAAI,CAAC;AACR,wBAAgB,UAAU,CACxB,GAAG,EAAE,UAAU,EACf,OAAO,EAAE;IACP,SAAS,EAAE,GAAG,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC;IACd,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB,GACA,IAAI,CAAC;AACR,wBAAgB,UAAU,CACxB,GAAG,EAAE,UAAU,EACf,OAAO,EAAE;IACP,SAAS,EAAE,GAAG,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB,GACA,IAAI,CAAC;AAcR;;;;GAIG;AACH,eAAO,MAAM,eAAe,EAAE,OAAO,UAqBpC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,eAAe,EAAE,OAAO,UAEpC,CAAC;AAEF;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAS9F;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,UAAO,QAYvE;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,gBAAgB,CAwB9D;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,gBAAgB,CAO9D;AAED;;GAEG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,cAAc,GAAG,UAAU,CAYzD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,EAAE,CA0BtD"}

View File

@@ -0,0 +1,33 @@
type Key = string | number | symbol;
/**
* SetArray acts like a `Set` (allowing only one occurrence of a string `key`), but provides the
* index of the `key` in the backing array.
*
* This is designed to allow synchronizing a second array with the contents of the backing array,
* like how in a sourcemap `sourcesContent[i]` is the source content associated with `source[i]`,
* and there are never duplicates.
*/
export declare class SetArray<T extends Key = Key> {
private _indexes;
array: readonly T[];
constructor();
}
/**
* Gets the index associated with `key` in the backing array, if it is already present.
*/
export declare function get<T extends Key>(setarr: SetArray<T>, key: T): number | undefined;
/**
* Puts `key` into the backing array, if it is not already present. Returns
* the index of the `key` in the backing array.
*/
export declare function put<T extends Key>(setarr: SetArray<T>, key: T): number;
/**
* Pops the last added item out of the SetArray.
*/
export declare function pop<T extends Key>(setarr: SetArray<T>): void;
/**
* Removes the key, if it exists in the set.
*/
export declare function remove<T extends Key>(setarr: SetArray<T>, key: T): void;
export {};
//# sourceMappingURL=set-array.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"set-array.d.ts","sourceRoot":"","sources":["../src/set-array.ts"],"names":[],"mappings":"AAAA,KAAK,GAAG,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAEpC;;;;;;;GAOG;AACH,qBAAa,QAAQ,CAAC,CAAC,SAAS,GAAG,GAAG,GAAG;IACvC,QAAgB,QAAQ,CAAgC;IAChD,KAAK,EAAE,SAAS,CAAC,EAAE,CAAC;;CAM7B;AAeD;;GAEG;AACH,wBAAgB,GAAG,CAAC,CAAC,SAAS,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,MAAM,GAAG,SAAS,CAElF;AAED;;;GAGG;AACH,wBAAgB,GAAG,CAAC,CAAC,SAAS,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,MAAM,CAStE;AAED;;GAEG;AACH,wBAAgB,GAAG,CAAC,CAAC,SAAS,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,GAAG,IAAI,CAM5D;AAED;;GAEG;AACH,wBAAgB,MAAM,CAAC,CAAC,SAAS,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,IAAI,CAYvE"}

View File

@@ -0,0 +1,33 @@
type Key = string | number | symbol;
/**
* SetArray acts like a `Set` (allowing only one occurrence of a string `key`), but provides the
* index of the `key` in the backing array.
*
* This is designed to allow synchronizing a second array with the contents of the backing array,
* like how in a sourcemap `sourcesContent[i]` is the source content associated with `source[i]`,
* and there are never duplicates.
*/
export declare class SetArray<T extends Key = Key> {
private _indexes;
array: readonly T[];
constructor();
}
/**
* Gets the index associated with `key` in the backing array, if it is already present.
*/
export declare function get<T extends Key>(setarr: SetArray<T>, key: T): number | undefined;
/**
* Puts `key` into the backing array, if it is not already present. Returns
* the index of the `key` in the backing array.
*/
export declare function put<T extends Key>(setarr: SetArray<T>, key: T): number;
/**
* Pops the last added item out of the SetArray.
*/
export declare function pop<T extends Key>(setarr: SetArray<T>): void;
/**
* Removes the key, if it exists in the set.
*/
export declare function remove<T extends Key>(setarr: SetArray<T>, key: T): void;
export {};
//# sourceMappingURL=set-array.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"set-array.d.ts","sourceRoot":"","sources":["../src/set-array.ts"],"names":[],"mappings":"AAAA,KAAK,GAAG,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAEpC;;;;;;;GAOG;AACH,qBAAa,QAAQ,CAAC,CAAC,SAAS,GAAG,GAAG,GAAG;IACvC,QAAgB,QAAQ,CAAgC;IAChD,KAAK,EAAE,SAAS,CAAC,EAAE,CAAC;;CAM7B;AAeD;;GAEG;AACH,wBAAgB,GAAG,CAAC,CAAC,SAAS,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,MAAM,GAAG,SAAS,CAElF;AAED;;;GAGG;AACH,wBAAgB,GAAG,CAAC,CAAC,SAAS,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,MAAM,CAStE;AAED;;GAEG;AACH,wBAAgB,GAAG,CAAC,CAAC,SAAS,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,GAAG,IAAI,CAM5D;AAED;;GAEG;AACH,wBAAgB,MAAM,CAAC,CAAC,SAAS,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,IAAI,CAYvE"}

View File

@@ -0,0 +1,13 @@
type GeneratedColumn = number;
type SourcesIndex = number;
type SourceLine = number;
type SourceColumn = number;
type NamesIndex = number;
export type SourceMapSegment = [GeneratedColumn] | [GeneratedColumn, SourcesIndex, SourceLine, SourceColumn] | [GeneratedColumn, SourcesIndex, SourceLine, SourceColumn, NamesIndex];
export declare const COLUMN = 0;
export declare const SOURCES_INDEX = 1;
export declare const SOURCE_LINE = 2;
export declare const SOURCE_COLUMN = 3;
export declare const NAMES_INDEX = 4;
export {};
//# sourceMappingURL=sourcemap-segment.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"sourcemap-segment.d.ts","sourceRoot":"","sources":["../src/sourcemap-segment.ts"],"names":[],"mappings":"AAAA,KAAK,eAAe,GAAG,MAAM,CAAC;AAC9B,KAAK,YAAY,GAAG,MAAM,CAAC;AAC3B,KAAK,UAAU,GAAG,MAAM,CAAC;AACzB,KAAK,YAAY,GAAG,MAAM,CAAC;AAC3B,KAAK,UAAU,GAAG,MAAM,CAAC;AAEzB,MAAM,MAAM,gBAAgB,GACxB,CAAC,eAAe,CAAC,GACjB,CAAC,eAAe,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,CAAC,GACzD,CAAC,eAAe,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,UAAU,CAAC,CAAC;AAE1E,eAAO,MAAM,MAAM,IAAI,CAAC;AACxB,eAAO,MAAM,aAAa,IAAI,CAAC;AAC/B,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,eAAO,MAAM,aAAa,IAAI,CAAC;AAC/B,eAAO,MAAM,WAAW,IAAI,CAAC"}

View File

@@ -0,0 +1,13 @@
type GeneratedColumn = number;
type SourcesIndex = number;
type SourceLine = number;
type SourceColumn = number;
type NamesIndex = number;
export type SourceMapSegment = [GeneratedColumn] | [GeneratedColumn, SourcesIndex, SourceLine, SourceColumn] | [GeneratedColumn, SourcesIndex, SourceLine, SourceColumn, NamesIndex];
export declare const COLUMN = 0;
export declare const SOURCES_INDEX = 1;
export declare const SOURCE_LINE = 2;
export declare const SOURCE_COLUMN = 3;
export declare const NAMES_INDEX = 4;
export {};
//# sourceMappingURL=sourcemap-segment.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"sourcemap-segment.d.ts","sourceRoot":"","sources":["../src/sourcemap-segment.ts"],"names":[],"mappings":"AAAA,KAAK,eAAe,GAAG,MAAM,CAAC;AAC9B,KAAK,YAAY,GAAG,MAAM,CAAC;AAC3B,KAAK,UAAU,GAAG,MAAM,CAAC;AACzB,KAAK,YAAY,GAAG,MAAM,CAAC;AAC3B,KAAK,UAAU,GAAG,MAAM,CAAC;AAEzB,MAAM,MAAM,gBAAgB,GACxB,CAAC,eAAe,CAAC,GACjB,CAAC,eAAe,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,CAAC,GACzD,CAAC,eAAe,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,UAAU,CAAC,CAAC;AAE1E,eAAO,MAAM,MAAM,IAAI,CAAC;AACxB,eAAO,MAAM,aAAa,IAAI,CAAC;AAC/B,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,eAAO,MAAM,aAAa,IAAI,CAAC;AAC/B,eAAO,MAAM,WAAW,IAAI,CAAC"}

View File

@@ -0,0 +1,44 @@
import type { SourceMapSegment } from './sourcemap-segment.cts';
export interface SourceMapV3 {
file?: string | null;
names: readonly string[];
sourceRoot?: string;
sources: readonly (string | null)[];
sourcesContent?: readonly (string | null)[];
version: 3;
ignoreList?: readonly number[];
}
export interface EncodedSourceMap extends SourceMapV3 {
mappings: string;
}
export interface DecodedSourceMap extends SourceMapV3 {
mappings: readonly SourceMapSegment[][];
}
export interface Pos {
line: number;
column: number;
}
export interface OriginalPos extends Pos {
source: string;
}
export interface BindingExpressionRange {
start: Pos;
expression: string;
}
export type Mapping = {
generated: Pos;
source: undefined;
original: undefined;
name: undefined;
} | {
generated: Pos;
source: string;
original: Pos;
name: string;
} | {
generated: Pos;
source: string;
original: Pos;
name: undefined;
};
//# sourceMappingURL=types.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAE5D,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC;IACpC,cAAc,CAAC,EAAE,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC;IAC5C,OAAO,EAAE,CAAC,CAAC;IACX,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,QAAQ,EAAE,MAAM,CAAC;CAGlB;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,QAAQ,EAAE,SAAS,gBAAgB,EAAE,EAAE,CAAC;CAGzC;AAED,MAAM,WAAW,GAAG;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,WAAY,SAAQ,GAAG;IACtC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,GAAG,CAAC;IACX,UAAU,EAAE,MAAM,CAAC;CACpB;AAKD,MAAM,MAAM,OAAO,GACf;IACE,SAAS,EAAE,GAAG,CAAC;IACf,MAAM,EAAE,SAAS,CAAC;IAClB,QAAQ,EAAE,SAAS,CAAC;IACpB,IAAI,EAAE,SAAS,CAAC;CACjB,GACD;IACE,SAAS,EAAE,GAAG,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd,GACD;IACE,SAAS,EAAE,GAAG,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC;IACd,IAAI,EAAE,SAAS,CAAC;CACjB,CAAC"}

View File

@@ -0,0 +1,44 @@
import type { SourceMapSegment } from './sourcemap-segment.mts';
export interface SourceMapV3 {
file?: string | null;
names: readonly string[];
sourceRoot?: string;
sources: readonly (string | null)[];
sourcesContent?: readonly (string | null)[];
version: 3;
ignoreList?: readonly number[];
}
export interface EncodedSourceMap extends SourceMapV3 {
mappings: string;
}
export interface DecodedSourceMap extends SourceMapV3 {
mappings: readonly SourceMapSegment[][];
}
export interface Pos {
line: number;
column: number;
}
export interface OriginalPos extends Pos {
source: string;
}
export interface BindingExpressionRange {
start: Pos;
expression: string;
}
export type Mapping = {
generated: Pos;
source: undefined;
original: undefined;
name: undefined;
} | {
generated: Pos;
source: string;
original: Pos;
name: string;
} | {
generated: Pos;
source: string;
original: Pos;
name: undefined;
};
//# sourceMappingURL=types.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAE5D,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC;IACpC,cAAc,CAAC,EAAE,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC;IAC5C,OAAO,EAAE,CAAC,CAAC;IACX,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,QAAQ,EAAE,MAAM,CAAC;CAGlB;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,QAAQ,EAAE,SAAS,gBAAgB,EAAE,EAAE,CAAC;CAGzC;AAED,MAAM,WAAW,GAAG;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,WAAY,SAAQ,GAAG;IACtC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,GAAG,CAAC;IACX,UAAU,EAAE,MAAM,CAAC;CACpB;AAKD,MAAM,MAAM,OAAO,GACf;IACE,SAAS,EAAE,GAAG,CAAC;IACf,MAAM,EAAE,SAAS,CAAC;IAClB,QAAQ,EAAE,SAAS,CAAC;IACpB,IAAI,EAAE,SAAS,CAAC;CACjB,GACD;IACE,SAAS,EAAE,GAAG,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd,GACD;IACE,SAAS,EAAE,GAAG,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC;IACd,IAAI,EAAE,SAAS,CAAC;CACjB,CAAC"}

View File

@@ -0,0 +1,19 @@
Copyright 2024 Justin Ridgewell <justin@ridgewell.name>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,218 @@
# @jridgewell/remapping
> Remap sequential sourcemaps through transformations to point at the original source code
Remapping allows you to take the sourcemaps generated through transforming your code and "remap"
them to the original source locations. Think "my minified code, transformed with babel and bundled
with webpack", all pointing to the correct location in your original source code.
With remapping, none of your source code transformations need to be aware of the input's sourcemap,
they only need to generate an output sourcemap. This greatly simplifies building custom
transformations (think a find-and-replace).
## Installation
```sh
npm install @jridgewell/remapping
```
## Usage
```typescript
function remapping(
map: SourceMap | SourceMap[],
loader: (file: string, ctx: LoaderContext) => (SourceMap | null | undefined),
options?: { excludeContent: boolean, decodedMappings: boolean }
): SourceMap;
// LoaderContext gives the loader the importing sourcemap, tree depth, the ability to override the
// "source" location (where child sources are resolved relative to, or the location of original
// source), and the ability to override the "content" of an original source for inclusion in the
// output sourcemap.
type LoaderContext = {
readonly importer: string;
readonly depth: number;
source: string;
content: string | null | undefined;
}
```
`remapping` takes the final output sourcemap, and a `loader` function. For every source file pointer
in the sourcemap, the `loader` will be called with the resolved path. If the path itself represents
a transformed file (it has a sourcmap associated with it), then the `loader` should return that
sourcemap. If not, the path will be treated as an original, untransformed source code.
```js
// Babel transformed "helloworld.js" into "transformed.js"
const transformedMap = JSON.stringify({
file: 'transformed.js',
// 1st column of 2nd line of output file translates into the 1st source
// file, line 3, column 2
mappings: ';CAEE',
sources: ['helloworld.js'],
version: 3,
});
// Uglify minified "transformed.js" into "transformed.min.js"
const minifiedTransformedMap = JSON.stringify({
file: 'transformed.min.js',
// 0th column of 1st line of output file translates into the 1st source
// file, line 2, column 1.
mappings: 'AACC',
names: [],
sources: ['transformed.js'],
version: 3,
});
const remapped = remapping(
minifiedTransformedMap,
(file, ctx) => {
// The "transformed.js" file is an transformed file.
if (file === 'transformed.js') {
// The root importer is empty.
console.assert(ctx.importer === '');
// The depth in the sourcemap tree we're currently loading.
// The root `minifiedTransformedMap` is depth 0, and its source children are depth 1, etc.
console.assert(ctx.depth === 1);
return transformedMap;
}
// Loader will be called to load transformedMap's source file pointers as well.
console.assert(file === 'helloworld.js');
// `transformed.js`'s sourcemap points into `helloworld.js`.
console.assert(ctx.importer === 'transformed.js');
// This is a source child of `transformed`, which is a source child of `minifiedTransformedMap`.
console.assert(ctx.depth === 2);
return null;
}
);
console.log(remapped);
// {
// file: 'transpiled.min.js',
// mappings: 'AAEE',
// sources: ['helloworld.js'],
// version: 3,
// };
```
In this example, `loader` will be called twice:
1. `"transformed.js"`, the first source file pointer in the `minifiedTransformedMap`. We return the
associated sourcemap for it (its a transformed file, after all) so that sourcemap locations can
be traced through it into the source files it represents.
2. `"helloworld.js"`, our original, unmodified source code. This file does not have a sourcemap, so
we return `null`.
The `remapped` sourcemap now points from `transformed.min.js` into locations in `helloworld.js`. If
you were to read the `mappings`, it says "0th column of the first line output line points to the 1st
column of the 2nd line of the file `helloworld.js`".
### Multiple transformations of a file
As a convenience, if you have multiple single-source transformations of a file, you may pass an
array of sourcemap files in the order of most-recent transformation sourcemap first. Note that this
changes the `importer` and `depth` of each call to our loader. So our above example could have been
written as:
```js
const remapped = remapping(
[minifiedTransformedMap, transformedMap],
() => null
);
console.log(remapped);
// {
// file: 'transpiled.min.js',
// mappings: 'AAEE',
// sources: ['helloworld.js'],
// version: 3,
// };
```
### Advanced control of the loading graph
#### `source`
The `source` property can overridden to any value to change the location of the current load. Eg,
for an original source file, it allows us to change the location to the original source regardless
of what the sourcemap source entry says. And for transformed files, it allows us to change the
relative resolving location for child sources of the loaded sourcemap.
```js
const remapped = remapping(
minifiedTransformedMap,
(file, ctx) => {
if (file === 'transformed.js') {
// We pretend the transformed.js file actually exists in the 'src/' directory. When the nested
// source files are loaded, they will now be relative to `src/`.
ctx.source = 'src/transformed.js';
return transformedMap;
}
console.assert(file === 'src/helloworld.js');
// We could futher change the source of this original file, eg, to be inside a nested directory
// itself. This will be reflected in the remapped sourcemap.
ctx.source = 'src/nested/transformed.js';
return null;
}
);
console.log(remapped);
// {
// …,
// sources: ['src/nested/helloworld.js'],
// };
```
#### `content`
The `content` property can be overridden when we encounter an original source file. Eg, this allows
you to manually provide the source content of the original file regardless of whether the
`sourcesContent` field is present in the parent sourcemap. It can also be set to `null` to remove
the source content.
```js
const remapped = remapping(
minifiedTransformedMap,
(file, ctx) => {
if (file === 'transformed.js') {
// transformedMap does not include a `sourcesContent` field, so usually the remapped sourcemap
// would not include any `sourcesContent` values.
return transformedMap;
}
console.assert(file === 'helloworld.js');
// We can read the file to provide the source content.
ctx.content = fs.readFileSync(file, 'utf8');
return null;
}
);
console.log(remapped);
// {
// …,
// sourcesContent: [
// 'console.log("Hello world!")',
// ],
// };
```
### Options
#### excludeContent
By default, `excludeContent` is `false`. Passing `{ excludeContent: true }` will exclude the
`sourcesContent` field from the returned sourcemap. This is mainly useful when you want to reduce
the size out the sourcemap.
#### decodedMappings
By default, `decodedMappings` is `false`. Passing `{ decodedMappings: true }` will leave the
`mappings` field in a [decoded state](https://github.com/rich-harris/sourcemap-codec) instead of
encoding into a VLQ string.

View File

@@ -0,0 +1,144 @@
// src/build-source-map-tree.ts
import { TraceMap } from "@jridgewell/trace-mapping";
// src/source-map-tree.ts
import { GenMapping, maybeAddSegment, setIgnore, setSourceContent } from "@jridgewell/gen-mapping";
import { traceSegment, decodedMappings } from "@jridgewell/trace-mapping";
var SOURCELESS_MAPPING = /* @__PURE__ */ SegmentObject("", -1, -1, "", null, false);
var EMPTY_SOURCES = [];
function SegmentObject(source, line, column, name, content, ignore) {
return { source, line, column, name, content, ignore };
}
function Source(map, sources, source, content, ignore) {
return {
map,
sources,
source,
content,
ignore
};
}
function MapSource(map, sources) {
return Source(map, sources, "", null, false);
}
function OriginalSource(source, content, ignore) {
return Source(null, EMPTY_SOURCES, source, content, ignore);
}
function traceMappings(tree) {
const gen = new GenMapping({ file: tree.map.file });
const { sources: rootSources, map } = tree;
const rootNames = map.names;
const rootMappings = decodedMappings(map);
for (let i = 0; i < rootMappings.length; i++) {
const segments = rootMappings[i];
for (let j = 0; j < segments.length; j++) {
const segment = segments[j];
const genCol = segment[0];
let traced = SOURCELESS_MAPPING;
if (segment.length !== 1) {
const source2 = rootSources[segment[1]];
traced = originalPositionFor(
source2,
segment[2],
segment[3],
segment.length === 5 ? rootNames[segment[4]] : ""
);
if (traced == null) continue;
}
const { column, line, name, content, source, ignore } = traced;
maybeAddSegment(gen, i, genCol, source, line, column, name);
if (source && content != null) setSourceContent(gen, source, content);
if (ignore) setIgnore(gen, source, true);
}
}
return gen;
}
function originalPositionFor(source, line, column, name) {
if (!source.map) {
return SegmentObject(source.source, line, column, name, source.content, source.ignore);
}
const segment = traceSegment(source.map, line, column);
if (segment == null) return null;
if (segment.length === 1) return SOURCELESS_MAPPING;
return originalPositionFor(
source.sources[segment[1]],
segment[2],
segment[3],
segment.length === 5 ? source.map.names[segment[4]] : name
);
}
// src/build-source-map-tree.ts
function asArray(value) {
if (Array.isArray(value)) return value;
return [value];
}
function buildSourceMapTree(input, loader) {
const maps = asArray(input).map((m) => new TraceMap(m, ""));
const map = maps.pop();
for (let i = 0; i < maps.length; i++) {
if (maps[i].sources.length > 1) {
throw new Error(
`Transformation map ${i} must have exactly one source file.
Did you specify these with the most recent transformation maps first?`
);
}
}
let tree = build(map, loader, "", 0);
for (let i = maps.length - 1; i >= 0; i--) {
tree = MapSource(maps[i], [tree]);
}
return tree;
}
function build(map, loader, importer, importerDepth) {
const { resolvedSources, sourcesContent, ignoreList } = map;
const depth = importerDepth + 1;
const children = resolvedSources.map((sourceFile, i) => {
const ctx = {
importer,
depth,
source: sourceFile || "",
content: void 0,
ignore: void 0
};
const sourceMap = loader(ctx.source, ctx);
const { source, content, ignore } = ctx;
if (sourceMap) return build(new TraceMap(sourceMap, source), loader, source, depth);
const sourceContent = content !== void 0 ? content : sourcesContent ? sourcesContent[i] : null;
const ignored = ignore !== void 0 ? ignore : ignoreList ? ignoreList.includes(i) : false;
return OriginalSource(source, sourceContent, ignored);
});
return MapSource(map, children);
}
// src/source-map.ts
import { toDecodedMap, toEncodedMap } from "@jridgewell/gen-mapping";
var SourceMap = class {
constructor(map, options) {
const out = options.decodedMappings ? toDecodedMap(map) : toEncodedMap(map);
this.version = out.version;
this.file = out.file;
this.mappings = out.mappings;
this.names = out.names;
this.ignoreList = out.ignoreList;
this.sourceRoot = out.sourceRoot;
this.sources = out.sources;
if (!options.excludeContent) {
this.sourcesContent = out.sourcesContent;
}
}
toString() {
return JSON.stringify(this);
}
};
// src/remapping.ts
function remapping(input, loader, options) {
const opts = typeof options === "object" ? options : { excludeContent: !!options, decodedMappings: false };
const tree = buildSourceMapTree(input, loader);
return new SourceMap(traceMappings(tree), opts);
}
export {
remapping as default
};
//# sourceMappingURL=remapping.mjs.map

View File

@@ -0,0 +1,6 @@
{
"version": 3,
"sources": ["../src/build-source-map-tree.ts", "../src/source-map-tree.ts", "../src/source-map.ts", "../src/remapping.ts"],
"mappings": ";AAAA,SAAS,gBAAgB;;;ACAzB,SAAS,YAAY,iBAAiB,WAAW,wBAAwB;AACzE,SAAS,cAAc,uBAAuB;AA+B9C,IAAM,qBAAqC,8BAAc,IAAI,IAAI,IAAI,IAAI,MAAM,KAAK;AACpF,IAAM,gBAA2B,CAAC;AAElC,SAAS,cACP,QACA,MACA,QACA,MACA,SACA,QACwB;AACxB,SAAO,EAAE,QAAQ,MAAM,QAAQ,MAAM,SAAS,OAAO;AACvD;AAgBA,SAAS,OACP,KACA,SACA,QACA,SACA,QACS;AACT,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AAMO,SAAS,UAAU,KAAe,SAA+B;AACtE,SAAO,OAAO,KAAK,SAAS,IAAI,MAAM,KAAK;AAC7C;AAMO,SAAS,eACd,QACA,SACA,QACgB;AAChB,SAAO,OAAO,MAAM,eAAe,QAAQ,SAAS,MAAM;AAC5D;AAMO,SAAS,cAAc,MAA6B;AAGzD,QAAM,MAAM,IAAI,WAAW,EAAE,MAAM,KAAK,IAAI,KAAK,CAAC;AAClD,QAAM,EAAE,SAAS,aAAa,IAAI,IAAI;AACtC,QAAM,YAAY,IAAI;AACtB,QAAM,eAAe,gBAAgB,GAAG;AAExC,WAAS,IAAI,GAAG,IAAI,aAAa,QAAQ,KAAK;AAC5C,UAAM,WAAW,aAAa,CAAC;AAE/B,aAAS,IAAI,GAAG,IAAI,SAAS,QAAQ,KAAK;AACxC,YAAM,UAAU,SAAS,CAAC;AAC1B,YAAM,SAAS,QAAQ,CAAC;AACxB,UAAI,SAAwC;AAI5C,UAAI,QAAQ,WAAW,GAAG;AACxB,cAAMA,UAAS,YAAY,QAAQ,CAAC,CAAC;AACrC,iBAAS;AAAA,UACPA;AAAA,UACA,QAAQ,CAAC;AAAA,UACT,QAAQ,CAAC;AAAA,UACT,QAAQ,WAAW,IAAI,UAAU,QAAQ,CAAC,CAAC,IAAI;AAAA,QACjD;AAIA,YAAI,UAAU,KAAM;AAAA,MACtB;AAEA,YAAM,EAAE,QAAQ,MAAM,MAAM,SAAS,QAAQ,OAAO,IAAI;AAExD,sBAAgB,KAAK,GAAG,QAAQ,QAAQ,MAAM,QAAQ,IAAI;AAC1D,UAAI,UAAU,WAAW,KAAM,kBAAiB,KAAK,QAAQ,OAAO;AACpE,UAAI,OAAQ,WAAU,KAAK,QAAQ,IAAI;AAAA,IACzC;AAAA,EACF;AAEA,SAAO;AACT;AAMO,SAAS,oBACd,QACA,MACA,QACA,MAC+B;AAC/B,MAAI,CAAC,OAAO,KAAK;AACf,WAAO,cAAc,OAAO,QAAQ,MAAM,QAAQ,MAAM,OAAO,SAAS,OAAO,MAAM;AAAA,EACvF;AAEA,QAAM,UAAU,aAAa,OAAO,KAAK,MAAM,MAAM;AAGrD,MAAI,WAAW,KAAM,QAAO;AAG5B,MAAI,QAAQ,WAAW,EAAG,QAAO;AAEjC,SAAO;AAAA,IACL,OAAO,QAAQ,QAAQ,CAAC,CAAC;AAAA,IACzB,QAAQ,CAAC;AAAA,IACT,QAAQ,CAAC;AAAA,IACT,QAAQ,WAAW,IAAI,OAAO,IAAI,MAAM,QAAQ,CAAC,CAAC,IAAI;AAAA,EACxD;AACF;;;ADpKA,SAAS,QAAW,OAAqB;AACvC,MAAI,MAAM,QAAQ,KAAK,EAAG,QAAO;AACjC,SAAO,CAAC,KAAK;AACf;AAae,SAAR,mBACL,OACA,QACe;AACf,QAAM,OAAO,QAAQ,KAAK,EAAE,IAAI,CAAC,MAAM,IAAI,SAAS,GAAG,EAAE,CAAC;AAC1D,QAAM,MAAM,KAAK,IAAI;AAErB,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACpC,QAAI,KAAK,CAAC,EAAE,QAAQ,SAAS,GAAG;AAC9B,YAAM,IAAI;AAAA,QACR,sBAAsB,CAAC;AAAA;AAAA,MAEzB;AAAA,IACF;AAAA,EACF;AAEA,MAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC;AACnC,WAAS,IAAI,KAAK,SAAS,GAAG,KAAK,GAAG,KAAK;AACzC,WAAO,UAAU,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC;AAAA,EAClC;AACA,SAAO;AACT;AAEA,SAAS,MACP,KACA,QACA,UACA,eACe;AACf,QAAM,EAAE,iBAAiB,gBAAgB,WAAW,IAAI;AAExD,QAAM,QAAQ,gBAAgB;AAC9B,QAAM,WAAW,gBAAgB,IAAI,CAAC,YAA2B,MAAuB;AAKtF,UAAM,MAAqB;AAAA,MACzB;AAAA,MACA;AAAA,MACA,QAAQ,cAAc;AAAA,MACtB,SAAS;AAAA,MACT,QAAQ;AAAA,IACV;AAIA,UAAM,YAAY,OAAO,IAAI,QAAQ,GAAG;AAExC,UAAM,EAAE,QAAQ,SAAS,OAAO,IAAI;AAGpC,QAAI,UAAW,QAAO,MAAM,IAAI,SAAS,WAAW,MAAM,GAAG,QAAQ,QAAQ,KAAK;AAMlF,UAAM,gBACJ,YAAY,SAAY,UAAU,iBAAiB,eAAe,CAAC,IAAI;AACzE,UAAM,UAAU,WAAW,SAAY,SAAS,aAAa,WAAW,SAAS,CAAC,IAAI;AACtF,WAAO,eAAe,QAAQ,eAAe,OAAO;AAAA,EACtD,CAAC;AAED,SAAO,UAAU,KAAK,QAAQ;AAChC;;;AExFA,SAAS,cAAc,oBAAoB;AAS3C,IAAqB,YAArB,MAA+B;AAAA,EAU7B,YAAY,KAAiB,SAAkB;AAC7C,UAAM,MAAM,QAAQ,kBAAkB,aAAa,GAAG,IAAI,aAAa,GAAG;AAC1E,SAAK,UAAU,IAAI;AACnB,SAAK,OAAO,IAAI;AAChB,SAAK,WAAW,IAAI;AACpB,SAAK,QAAQ,IAAI;AACjB,SAAK,aAAa,IAAI;AACtB,SAAK,aAAa,IAAI;AAEtB,SAAK,UAAU,IAAI;AACnB,QAAI,CAAC,QAAQ,gBAAgB;AAC3B,WAAK,iBAAiB,IAAI;AAAA,IAC5B;AAAA,EACF;AAAA,EAEA,WAAmB;AACjB,WAAO,KAAK,UAAU,IAAI;AAAA,EAC5B;AACF;;;ACLe,SAAR,UACL,OACA,QACA,SACW;AACX,QAAM,OACJ,OAAO,YAAY,WAAW,UAAU,EAAE,gBAAgB,CAAC,CAAC,SAAS,iBAAiB,MAAM;AAC9F,QAAM,OAAO,mBAAmB,OAAO,MAAM;AAC7C,SAAO,IAAI,UAAU,cAAc,IAAI,GAAG,IAAI;AAChD;",
"names": ["source"]
}

View File

@@ -0,0 +1,212 @@
(function (global, factory) {
if (typeof exports === 'object' && typeof module !== 'undefined') {
factory(module, require('@jridgewell/gen-mapping'), require('@jridgewell/trace-mapping'));
module.exports = def(module);
} else if (typeof define === 'function' && define.amd) {
define(['module', '@jridgewell/gen-mapping', '@jridgewell/trace-mapping'], function(mod) {
factory.apply(this, arguments);
mod.exports = def(mod);
});
} else {
const mod = { exports: {} };
factory(mod, global.genMapping, global.traceMapping);
global = typeof globalThis !== 'undefined' ? globalThis : global || self;
global.remapping = def(mod);
}
function def(m) { return 'default' in m.exports ? m.exports.default : m.exports; }
})(this, (function (module, require_genMapping, require_traceMapping) {
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// umd:@jridgewell/trace-mapping
var require_trace_mapping = __commonJS({
"umd:@jridgewell/trace-mapping"(exports, module2) {
module2.exports = require_traceMapping;
}
});
// umd:@jridgewell/gen-mapping
var require_gen_mapping = __commonJS({
"umd:@jridgewell/gen-mapping"(exports, module2) {
module2.exports = require_genMapping;
}
});
// src/remapping.ts
var remapping_exports = {};
__export(remapping_exports, {
default: () => remapping
});
module.exports = __toCommonJS(remapping_exports);
// src/build-source-map-tree.ts
var import_trace_mapping2 = __toESM(require_trace_mapping());
// src/source-map-tree.ts
var import_gen_mapping = __toESM(require_gen_mapping());
var import_trace_mapping = __toESM(require_trace_mapping());
var SOURCELESS_MAPPING = /* @__PURE__ */ SegmentObject("", -1, -1, "", null, false);
var EMPTY_SOURCES = [];
function SegmentObject(source, line, column, name, content, ignore) {
return { source, line, column, name, content, ignore };
}
function Source(map, sources, source, content, ignore) {
return {
map,
sources,
source,
content,
ignore
};
}
function MapSource(map, sources) {
return Source(map, sources, "", null, false);
}
function OriginalSource(source, content, ignore) {
return Source(null, EMPTY_SOURCES, source, content, ignore);
}
function traceMappings(tree) {
const gen = new import_gen_mapping.GenMapping({ file: tree.map.file });
const { sources: rootSources, map } = tree;
const rootNames = map.names;
const rootMappings = (0, import_trace_mapping.decodedMappings)(map);
for (let i = 0; i < rootMappings.length; i++) {
const segments = rootMappings[i];
for (let j = 0; j < segments.length; j++) {
const segment = segments[j];
const genCol = segment[0];
let traced = SOURCELESS_MAPPING;
if (segment.length !== 1) {
const source2 = rootSources[segment[1]];
traced = originalPositionFor(
source2,
segment[2],
segment[3],
segment.length === 5 ? rootNames[segment[4]] : ""
);
if (traced == null) continue;
}
const { column, line, name, content, source, ignore } = traced;
(0, import_gen_mapping.maybeAddSegment)(gen, i, genCol, source, line, column, name);
if (source && content != null) (0, import_gen_mapping.setSourceContent)(gen, source, content);
if (ignore) (0, import_gen_mapping.setIgnore)(gen, source, true);
}
}
return gen;
}
function originalPositionFor(source, line, column, name) {
if (!source.map) {
return SegmentObject(source.source, line, column, name, source.content, source.ignore);
}
const segment = (0, import_trace_mapping.traceSegment)(source.map, line, column);
if (segment == null) return null;
if (segment.length === 1) return SOURCELESS_MAPPING;
return originalPositionFor(
source.sources[segment[1]],
segment[2],
segment[3],
segment.length === 5 ? source.map.names[segment[4]] : name
);
}
// src/build-source-map-tree.ts
function asArray(value) {
if (Array.isArray(value)) return value;
return [value];
}
function buildSourceMapTree(input, loader) {
const maps = asArray(input).map((m) => new import_trace_mapping2.TraceMap(m, ""));
const map = maps.pop();
for (let i = 0; i < maps.length; i++) {
if (maps[i].sources.length > 1) {
throw new Error(
`Transformation map ${i} must have exactly one source file.
Did you specify these with the most recent transformation maps first?`
);
}
}
let tree = build(map, loader, "", 0);
for (let i = maps.length - 1; i >= 0; i--) {
tree = MapSource(maps[i], [tree]);
}
return tree;
}
function build(map, loader, importer, importerDepth) {
const { resolvedSources, sourcesContent, ignoreList } = map;
const depth = importerDepth + 1;
const children = resolvedSources.map((sourceFile, i) => {
const ctx = {
importer,
depth,
source: sourceFile || "",
content: void 0,
ignore: void 0
};
const sourceMap = loader(ctx.source, ctx);
const { source, content, ignore } = ctx;
if (sourceMap) return build(new import_trace_mapping2.TraceMap(sourceMap, source), loader, source, depth);
const sourceContent = content !== void 0 ? content : sourcesContent ? sourcesContent[i] : null;
const ignored = ignore !== void 0 ? ignore : ignoreList ? ignoreList.includes(i) : false;
return OriginalSource(source, sourceContent, ignored);
});
return MapSource(map, children);
}
// src/source-map.ts
var import_gen_mapping2 = __toESM(require_gen_mapping());
var SourceMap = class {
constructor(map, options) {
const out = options.decodedMappings ? (0, import_gen_mapping2.toDecodedMap)(map) : (0, import_gen_mapping2.toEncodedMap)(map);
this.version = out.version;
this.file = out.file;
this.mappings = out.mappings;
this.names = out.names;
this.ignoreList = out.ignoreList;
this.sourceRoot = out.sourceRoot;
this.sources = out.sources;
if (!options.excludeContent) {
this.sourcesContent = out.sourcesContent;
}
}
toString() {
return JSON.stringify(this);
}
};
// src/remapping.ts
function remapping(input, loader, options) {
const opts = typeof options === "object" ? options : { excludeContent: !!options, decodedMappings: false };
const tree = buildSourceMapTree(input, loader);
return new SourceMap(traceMappings(tree), opts);
}
}));
//# sourceMappingURL=remapping.umd.js.map

View File

@@ -0,0 +1,6 @@
{
"version": 3,
"sources": ["umd:@jridgewell/trace-mapping", "umd:@jridgewell/gen-mapping", "../src/remapping.ts", "../src/build-source-map-tree.ts", "../src/source-map-tree.ts", "../src/source-map.ts"],
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA,2CAAAA,SAAA;AAAA,IAAAA,QAAO,UAAU;AAAA;AAAA;;;ACAjB;AAAA,yCAAAC,SAAA;AAAA,IAAAA,QAAO,UAAU;AAAA;AAAA;;;ACAjB;AAAA;AAAA;AAAA;AAAA;;;ACAA,IAAAC,wBAAyB;;;ACAzB,yBAAyE;AACzE,2BAA8C;AA+B9C,IAAM,qBAAqC,8BAAc,IAAI,IAAI,IAAI,IAAI,MAAM,KAAK;AACpF,IAAM,gBAA2B,CAAC;AAElC,SAAS,cACP,QACA,MACA,QACA,MACA,SACA,QACwB;AACxB,SAAO,EAAE,QAAQ,MAAM,QAAQ,MAAM,SAAS,OAAO;AACvD;AAgBA,SAAS,OACP,KACA,SACA,QACA,SACA,QACS;AACT,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AAMO,SAAS,UAAU,KAAe,SAA+B;AACtE,SAAO,OAAO,KAAK,SAAS,IAAI,MAAM,KAAK;AAC7C;AAMO,SAAS,eACd,QACA,SACA,QACgB;AAChB,SAAO,OAAO,MAAM,eAAe,QAAQ,SAAS,MAAM;AAC5D;AAMO,SAAS,cAAc,MAA6B;AAGzD,QAAM,MAAM,IAAI,8BAAW,EAAE,MAAM,KAAK,IAAI,KAAK,CAAC;AAClD,QAAM,EAAE,SAAS,aAAa,IAAI,IAAI;AACtC,QAAM,YAAY,IAAI;AACtB,QAAM,mBAAe,sCAAgB,GAAG;AAExC,WAAS,IAAI,GAAG,IAAI,aAAa,QAAQ,KAAK;AAC5C,UAAM,WAAW,aAAa,CAAC;AAE/B,aAAS,IAAI,GAAG,IAAI,SAAS,QAAQ,KAAK;AACxC,YAAM,UAAU,SAAS,CAAC;AAC1B,YAAM,SAAS,QAAQ,CAAC;AACxB,UAAI,SAAwC;AAI5C,UAAI,QAAQ,WAAW,GAAG;AACxB,cAAMC,UAAS,YAAY,QAAQ,CAAC,CAAC;AACrC,iBAAS;AAAA,UACPA;AAAA,UACA,QAAQ,CAAC;AAAA,UACT,QAAQ,CAAC;AAAA,UACT,QAAQ,WAAW,IAAI,UAAU,QAAQ,CAAC,CAAC,IAAI;AAAA,QACjD;AAIA,YAAI,UAAU,KAAM;AAAA,MACtB;AAEA,YAAM,EAAE,QAAQ,MAAM,MAAM,SAAS,QAAQ,OAAO,IAAI;AAExD,8CAAgB,KAAK,GAAG,QAAQ,QAAQ,MAAM,QAAQ,IAAI;AAC1D,UAAI,UAAU,WAAW,KAAM,0CAAiB,KAAK,QAAQ,OAAO;AACpE,UAAI,OAAQ,mCAAU,KAAK,QAAQ,IAAI;AAAA,IACzC;AAAA,EACF;AAEA,SAAO;AACT;AAMO,SAAS,oBACd,QACA,MACA,QACA,MAC+B;AAC/B,MAAI,CAAC,OAAO,KAAK;AACf,WAAO,cAAc,OAAO,QAAQ,MAAM,QAAQ,MAAM,OAAO,SAAS,OAAO,MAAM;AAAA,EACvF;AAEA,QAAM,cAAU,mCAAa,OAAO,KAAK,MAAM,MAAM;AAGrD,MAAI,WAAW,KAAM,QAAO;AAG5B,MAAI,QAAQ,WAAW,EAAG,QAAO;AAEjC,SAAO;AAAA,IACL,OAAO,QAAQ,QAAQ,CAAC,CAAC;AAAA,IACzB,QAAQ,CAAC;AAAA,IACT,QAAQ,CAAC;AAAA,IACT,QAAQ,WAAW,IAAI,OAAO,IAAI,MAAM,QAAQ,CAAC,CAAC,IAAI;AAAA,EACxD;AACF;;;ADpKA,SAAS,QAAW,OAAqB;AACvC,MAAI,MAAM,QAAQ,KAAK,EAAG,QAAO;AACjC,SAAO,CAAC,KAAK;AACf;AAae,SAAR,mBACL,OACA,QACe;AACf,QAAM,OAAO,QAAQ,KAAK,EAAE,IAAI,CAAC,MAAM,IAAI,+BAAS,GAAG,EAAE,CAAC;AAC1D,QAAM,MAAM,KAAK,IAAI;AAErB,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACpC,QAAI,KAAK,CAAC,EAAE,QAAQ,SAAS,GAAG;AAC9B,YAAM,IAAI;AAAA,QACR,sBAAsB,CAAC;AAAA;AAAA,MAEzB;AAAA,IACF;AAAA,EACF;AAEA,MAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC;AACnC,WAAS,IAAI,KAAK,SAAS,GAAG,KAAK,GAAG,KAAK;AACzC,WAAO,UAAU,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC;AAAA,EAClC;AACA,SAAO;AACT;AAEA,SAAS,MACP,KACA,QACA,UACA,eACe;AACf,QAAM,EAAE,iBAAiB,gBAAgB,WAAW,IAAI;AAExD,QAAM,QAAQ,gBAAgB;AAC9B,QAAM,WAAW,gBAAgB,IAAI,CAAC,YAA2B,MAAuB;AAKtF,UAAM,MAAqB;AAAA,MACzB;AAAA,MACA;AAAA,MACA,QAAQ,cAAc;AAAA,MACtB,SAAS;AAAA,MACT,QAAQ;AAAA,IACV;AAIA,UAAM,YAAY,OAAO,IAAI,QAAQ,GAAG;AAExC,UAAM,EAAE,QAAQ,SAAS,OAAO,IAAI;AAGpC,QAAI,UAAW,QAAO,MAAM,IAAI,+BAAS,WAAW,MAAM,GAAG,QAAQ,QAAQ,KAAK;AAMlF,UAAM,gBACJ,YAAY,SAAY,UAAU,iBAAiB,eAAe,CAAC,IAAI;AACzE,UAAM,UAAU,WAAW,SAAY,SAAS,aAAa,WAAW,SAAS,CAAC,IAAI;AACtF,WAAO,eAAe,QAAQ,eAAe,OAAO;AAAA,EACtD,CAAC;AAED,SAAO,UAAU,KAAK,QAAQ;AAChC;;;AExFA,IAAAC,sBAA2C;AAS3C,IAAqB,YAArB,MAA+B;AAAA,EAU7B,YAAY,KAAiB,SAAkB;AAC7C,UAAM,MAAM,QAAQ,sBAAkB,kCAAa,GAAG,QAAI,kCAAa,GAAG;AAC1E,SAAK,UAAU,IAAI;AACnB,SAAK,OAAO,IAAI;AAChB,SAAK,WAAW,IAAI;AACpB,SAAK,QAAQ,IAAI;AACjB,SAAK,aAAa,IAAI;AACtB,SAAK,aAAa,IAAI;AAEtB,SAAK,UAAU,IAAI;AACnB,QAAI,CAAC,QAAQ,gBAAgB;AAC3B,WAAK,iBAAiB,IAAI;AAAA,IAC5B;AAAA,EACF;AAAA,EAEA,WAAmB;AACjB,WAAO,KAAK,UAAU,IAAI;AAAA,EAC5B;AACF;;;AHLe,SAAR,UACL,OACA,QACA,SACW;AACX,QAAM,OACJ,OAAO,YAAY,WAAW,UAAU,EAAE,gBAAgB,CAAC,CAAC,SAAS,iBAAiB,MAAM;AAC9F,QAAM,OAAO,mBAAmB,OAAO,MAAM;AAC7C,SAAO,IAAI,UAAU,cAAc,IAAI,GAAG,IAAI;AAChD;",
"names": ["module", "module", "import_trace_mapping", "source", "import_gen_mapping"]
}

View File

@@ -0,0 +1,71 @@
{
"name": "@jridgewell/remapping",
"version": "2.3.5",
"description": "Remap sequential sourcemaps through transformations to point at the original source code",
"keywords": [
"source",
"map",
"remap"
],
"main": "dist/remapping.umd.js",
"module": "dist/remapping.mjs",
"types": "types/remapping.d.cts",
"files": [
"dist",
"src",
"types"
],
"exports": {
".": [
{
"import": {
"types": "./types/remapping.d.mts",
"default": "./dist/remapping.mjs"
},
"default": {
"types": "./types/remapping.d.cts",
"default": "./dist/remapping.umd.js"
}
},
"./dist/remapping.umd.js"
],
"./package.json": "./package.json"
},
"scripts": {
"benchmark": "run-s build:code benchmark:*",
"benchmark:install": "cd benchmark && npm install",
"benchmark:only": "node --expose-gc benchmark/index.js",
"build": "run-s -n build:code build:types",
"build:code": "node ../../esbuild.mjs remapping.ts",
"build:types": "run-s build:types:force build:types:emit build:types:mts",
"build:types:force": "rimraf tsconfig.build.tsbuildinfo",
"build:types:emit": "tsc --project tsconfig.build.json",
"build:types:mts": "node ../../mts-types.mjs",
"clean": "run-s -n clean:code clean:types",
"clean:code": "tsc --build --clean tsconfig.build.json",
"clean:types": "rimraf dist types",
"test": "run-s -n test:types test:only test:format",
"test:format": "prettier --check '{src,test}/**/*.ts'",
"test:only": "mocha",
"test:types": "eslint '{src,test}/**/*.ts'",
"lint": "run-s -n lint:types lint:format",
"lint:format": "npm run test:format -- --write",
"lint:types": "npm run test:types -- --fix",
"prepublishOnly": "npm run-s -n build test"
},
"homepage": "https://github.com/jridgewell/sourcemaps/tree/main/packages/remapping",
"repository": {
"type": "git",
"url": "git+https://github.com/jridgewell/sourcemaps.git",
"directory": "packages/remapping"
},
"author": "Justin Ridgewell <justin@ridgewell.name>",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"devDependencies": {
"source-map": "0.6.1"
}
}

View File

@@ -0,0 +1,89 @@
import { TraceMap } from '@jridgewell/trace-mapping';
import { OriginalSource, MapSource } from './source-map-tree';
import type { Sources, MapSource as MapSourceType } from './source-map-tree';
import type { SourceMapInput, SourceMapLoader, LoaderContext } from './types';
function asArray<T>(value: T | T[]): T[] {
if (Array.isArray(value)) return value;
return [value];
}
/**
* Recursively builds a tree structure out of sourcemap files, with each node
* being either an `OriginalSource` "leaf" or a `SourceMapTree` composed of
* `OriginalSource`s and `SourceMapTree`s.
*
* Every sourcemap is composed of a collection of source files and mappings
* into locations of those source files. When we generate a `SourceMapTree` for
* the sourcemap, we attempt to load each source file's own sourcemap. If it
* does not have an associated sourcemap, it is considered an original,
* unmodified source file.
*/
export default function buildSourceMapTree(
input: SourceMapInput | SourceMapInput[],
loader: SourceMapLoader,
): MapSourceType {
const maps = asArray(input).map((m) => new TraceMap(m, ''));
const map = maps.pop()!;
for (let i = 0; i < maps.length; i++) {
if (maps[i].sources.length > 1) {
throw new Error(
`Transformation map ${i} must have exactly one source file.\n` +
'Did you specify these with the most recent transformation maps first?',
);
}
}
let tree = build(map, loader, '', 0);
for (let i = maps.length - 1; i >= 0; i--) {
tree = MapSource(maps[i], [tree]);
}
return tree;
}
function build(
map: TraceMap,
loader: SourceMapLoader,
importer: string,
importerDepth: number,
): MapSourceType {
const { resolvedSources, sourcesContent, ignoreList } = map;
const depth = importerDepth + 1;
const children = resolvedSources.map((sourceFile: string | null, i: number): Sources => {
// The loading context gives the loader more information about why this file is being loaded
// (eg, from which importer). It also allows the loader to override the location of the loaded
// sourcemap/original source, or to override the content in the sourcesContent field if it's
// an unmodified source file.
const ctx: LoaderContext = {
importer,
depth,
source: sourceFile || '',
content: undefined,
ignore: undefined,
};
// Use the provided loader callback to retrieve the file's sourcemap.
// TODO: We should eventually support async loading of sourcemap files.
const sourceMap = loader(ctx.source, ctx);
const { source, content, ignore } = ctx;
// If there is a sourcemap, then we need to recurse into it to load its source files.
if (sourceMap) return build(new TraceMap(sourceMap, source), loader, source, depth);
// Else, it's an unmodified source file.
// The contents of this unmodified source file can be overridden via the loader context,
// allowing it to be explicitly null or a string. If it remains undefined, we fall back to
// the importing sourcemap's `sourcesContent` field.
const sourceContent =
content !== undefined ? content : sourcesContent ? sourcesContent[i] : null;
const ignored = ignore !== undefined ? ignore : ignoreList ? ignoreList.includes(i) : false;
return OriginalSource(source, sourceContent, ignored);
});
return MapSource(map, children);
}

View File

@@ -0,0 +1,42 @@
import buildSourceMapTree from './build-source-map-tree';
import { traceMappings } from './source-map-tree';
import SourceMap from './source-map';
import type { SourceMapInput, SourceMapLoader, Options } from './types';
export type {
SourceMapSegment,
EncodedSourceMap,
EncodedSourceMap as RawSourceMap,
DecodedSourceMap,
SourceMapInput,
SourceMapLoader,
LoaderContext,
Options,
} from './types';
export type { SourceMap };
/**
* Traces through all the mappings in the root sourcemap, through the sources
* (and their sourcemaps), all the way back to the original source location.
*
* `loader` will be called every time we encounter a source file. If it returns
* a sourcemap, we will recurse into that sourcemap to continue the trace. If
* it returns a falsey value, that source file is treated as an original,
* unmodified source file.
*
* Pass `excludeContent` to exclude any self-containing source file content
* from the output sourcemap.
*
* Pass `decodedMappings` to receive a SourceMap with decoded (instead of
* VLQ encoded) mappings.
*/
export default function remapping(
input: SourceMapInput | SourceMapInput[],
loader: SourceMapLoader,
options?: boolean | Options,
): SourceMap {
const opts =
typeof options === 'object' ? options : { excludeContent: !!options, decodedMappings: false };
const tree = buildSourceMapTree(input, loader);
return new SourceMap(traceMappings(tree), opts);
}

View File

@@ -0,0 +1,172 @@
import { GenMapping, maybeAddSegment, setIgnore, setSourceContent } from '@jridgewell/gen-mapping';
import { traceSegment, decodedMappings } from '@jridgewell/trace-mapping';
import type { TraceMap } from '@jridgewell/trace-mapping';
export type SourceMapSegmentObject = {
column: number;
line: number;
name: string;
source: string;
content: string | null;
ignore: boolean;
};
export type OriginalSource = {
map: null;
sources: Sources[];
source: string;
content: string | null;
ignore: boolean;
};
export type MapSource = {
map: TraceMap;
sources: Sources[];
source: string;
content: null;
ignore: false;
};
export type Sources = OriginalSource | MapSource;
const SOURCELESS_MAPPING = /* #__PURE__ */ SegmentObject('', -1, -1, '', null, false);
const EMPTY_SOURCES: Sources[] = [];
function SegmentObject(
source: string,
line: number,
column: number,
name: string,
content: string | null,
ignore: boolean,
): SourceMapSegmentObject {
return { source, line, column, name, content, ignore };
}
function Source(
map: TraceMap,
sources: Sources[],
source: '',
content: null,
ignore: false,
): MapSource;
function Source(
map: null,
sources: Sources[],
source: string,
content: string | null,
ignore: boolean,
): OriginalSource;
function Source(
map: TraceMap | null,
sources: Sources[],
source: string | '',
content: string | null,
ignore: boolean,
): Sources {
return {
map,
sources,
source,
content,
ignore,
} as any;
}
/**
* MapSource represents a single sourcemap, with the ability to trace mappings into its child nodes
* (which may themselves be SourceMapTrees).
*/
export function MapSource(map: TraceMap, sources: Sources[]): MapSource {
return Source(map, sources, '', null, false);
}
/**
* A "leaf" node in the sourcemap tree, representing an original, unmodified source file. Recursive
* segment tracing ends at the `OriginalSource`.
*/
export function OriginalSource(
source: string,
content: string | null,
ignore: boolean,
): OriginalSource {
return Source(null, EMPTY_SOURCES, source, content, ignore);
}
/**
* traceMappings is only called on the root level SourceMapTree, and begins the process of
* resolving each mapping in terms of the original source files.
*/
export function traceMappings(tree: MapSource): GenMapping {
// TODO: Eventually support sourceRoot, which has to be removed because the sources are already
// fully resolved. We'll need to make sources relative to the sourceRoot before adding them.
const gen = new GenMapping({ file: tree.map.file });
const { sources: rootSources, map } = tree;
const rootNames = map.names;
const rootMappings = decodedMappings(map);
for (let i = 0; i < rootMappings.length; i++) {
const segments = rootMappings[i];
for (let j = 0; j < segments.length; j++) {
const segment = segments[j];
const genCol = segment[0];
let traced: SourceMapSegmentObject | null = SOURCELESS_MAPPING;
// 1-length segments only move the current generated column, there's no source information
// to gather from it.
if (segment.length !== 1) {
const source = rootSources[segment[1]];
traced = originalPositionFor(
source,
segment[2],
segment[3],
segment.length === 5 ? rootNames[segment[4]] : '',
);
// If the trace is invalid, then the trace ran into a sourcemap that doesn't contain a
// respective segment into an original source.
if (traced == null) continue;
}
const { column, line, name, content, source, ignore } = traced;
maybeAddSegment(gen, i, genCol, source, line, column, name);
if (source && content != null) setSourceContent(gen, source, content);
if (ignore) setIgnore(gen, source, true);
}
}
return gen;
}
/**
* originalPositionFor is only called on children SourceMapTrees. It recurses down into its own
* child SourceMapTrees, until we find the original source map.
*/
export function originalPositionFor(
source: Sources,
line: number,
column: number,
name: string,
): SourceMapSegmentObject | null {
if (!source.map) {
return SegmentObject(source.source, line, column, name, source.content, source.ignore);
}
const segment = traceSegment(source.map, line, column);
// If we couldn't find a segment, then this doesn't exist in the sourcemap.
if (segment == null) return null;
// 1-length segments only move the current generated column, there's no source information
// to gather from it.
if (segment.length === 1) return SOURCELESS_MAPPING;
return originalPositionFor(
source.sources[segment[1]],
segment[2],
segment[3],
segment.length === 5 ? source.map.names[segment[4]] : name,
);
}

View File

@@ -0,0 +1,38 @@
import { toDecodedMap, toEncodedMap } from '@jridgewell/gen-mapping';
import type { GenMapping } from '@jridgewell/gen-mapping';
import type { DecodedSourceMap, EncodedSourceMap, Options } from './types';
/**
* A SourceMap v3 compatible sourcemap, which only includes fields that were
* provided to it.
*/
export default class SourceMap {
declare file?: string | null;
declare mappings: EncodedSourceMap['mappings'] | DecodedSourceMap['mappings'];
declare sourceRoot?: string;
declare names: string[];
declare sources: (string | null)[];
declare sourcesContent?: (string | null)[];
declare version: 3;
declare ignoreList: number[] | undefined;
constructor(map: GenMapping, options: Options) {
const out = options.decodedMappings ? toDecodedMap(map) : toEncodedMap(map);
this.version = out.version; // SourceMap spec says this should be first.
this.file = out.file;
this.mappings = out.mappings as SourceMap['mappings'];
this.names = out.names as SourceMap['names'];
this.ignoreList = out.ignoreList as SourceMap['ignoreList'];
this.sourceRoot = out.sourceRoot;
this.sources = out.sources as SourceMap['sources'];
if (!options.excludeContent) {
this.sourcesContent = out.sourcesContent as SourceMap['sourcesContent'];
}
}
toString(): string {
return JSON.stringify(this);
}
}

View File

@@ -0,0 +1,27 @@
import type { SourceMapInput } from '@jridgewell/trace-mapping';
export type {
SourceMapSegment,
DecodedSourceMap,
EncodedSourceMap,
} from '@jridgewell/trace-mapping';
export type { SourceMapInput };
export type LoaderContext = {
readonly importer: string;
readonly depth: number;
source: string;
content: string | null | undefined;
ignore: boolean | undefined;
};
export type SourceMapLoader = (
file: string,
ctx: LoaderContext,
) => SourceMapInput | null | undefined | void;
export type Options = {
excludeContent?: boolean;
decodedMappings?: boolean;
};

View File

@@ -0,0 +1,15 @@
import type { MapSource as MapSourceType } from './source-map-tree.cts';
import type { SourceMapInput, SourceMapLoader } from './types.cts';
/**
* Recursively builds a tree structure out of sourcemap files, with each node
* being either an `OriginalSource` "leaf" or a `SourceMapTree` composed of
* `OriginalSource`s and `SourceMapTree`s.
*
* Every sourcemap is composed of a collection of source files and mappings
* into locations of those source files. When we generate a `SourceMapTree` for
* the sourcemap, we attempt to load each source file's own sourcemap. If it
* does not have an associated sourcemap, it is considered an original,
* unmodified source file.
*/
export = function buildSourceMapTree(input: SourceMapInput | SourceMapInput[], loader: SourceMapLoader): MapSourceType;
//# sourceMappingURL=build-source-map-tree.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"build-source-map-tree.d.ts","sourceRoot":"","sources":["../src/build-source-map-tree.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAW,SAAS,IAAI,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAC7E,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAiB,MAAM,SAAS,CAAC;AAO9E;;;;;;;;;;GAUG;AACH,MAAM,CAAC,OAAO,UAAU,kBAAkB,CACxC,KAAK,EAAE,cAAc,GAAG,cAAc,EAAE,EACxC,MAAM,EAAE,eAAe,GACtB,aAAa,CAkBf"}

View File

@@ -0,0 +1,15 @@
import type { MapSource as MapSourceType } from './source-map-tree.mts';
import type { SourceMapInput, SourceMapLoader } from './types.mts';
/**
* Recursively builds a tree structure out of sourcemap files, with each node
* being either an `OriginalSource` "leaf" or a `SourceMapTree` composed of
* `OriginalSource`s and `SourceMapTree`s.
*
* Every sourcemap is composed of a collection of source files and mappings
* into locations of those source files. When we generate a `SourceMapTree` for
* the sourcemap, we attempt to load each source file's own sourcemap. If it
* does not have an associated sourcemap, it is considered an original,
* unmodified source file.
*/
export default function buildSourceMapTree(input: SourceMapInput | SourceMapInput[], loader: SourceMapLoader): MapSourceType;
//# sourceMappingURL=build-source-map-tree.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"build-source-map-tree.d.ts","sourceRoot":"","sources":["../src/build-source-map-tree.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAW,SAAS,IAAI,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAC7E,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAiB,MAAM,SAAS,CAAC;AAO9E;;;;;;;;;;GAUG;AACH,MAAM,CAAC,OAAO,UAAU,kBAAkB,CACxC,KAAK,EAAE,cAAc,GAAG,cAAc,EAAE,EACxC,MAAM,EAAE,eAAe,GACtB,aAAa,CAkBf"}

Some files were not shown because too many files have changed in this diff Show More