diff --git a/.env.example b/.env.example index 030736e..f2466d6 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,18 @@ DB_DATABASE=laranode DB_USERNAME=laranode DB_PASSWORD= +MYSQL_ADMIN_HOST=127.0.0.1 +MYSQL_ADMIN_PORT=3306 +MYSQL_ADMIN_DB=mysql +MYSQL_ADMIN_USERNAME= +MYSQL_ADMIN_PASSWORD= + +PGSQL_HOST=127.0.0.1 +PGSQL_PORT=5432 +PGSQL_DB=postgres +PGSQL_USERNAME=postgres +PGSQL_PASSWORD= + SESSION_DRIVER=database SESSION_LIFETIME=120 SESSION_ENCRYPT=false diff --git a/.gitignore b/.gitignore index eb669e4..1115d15 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,11 @@ yarn-error.log package-lock.json *.DS_Store* *.php-cs-fixer.cache* +/test-results +/playwright-report +/.playwright-mcp +/dark-*.jpeg +/graphs-dark.jpeg +/notif-dark-fixed.jpeg +/pie-fixed.jpeg +.env.local-backup diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..745ce5c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +Laranode is a self-hosted server control panel (cPanel/Plesk alternative) built on Laravel 12 + Inertia 2 + React 18. It manages the **host machine itself** — Apache vhosts, per-site PHP-FPM pools, MySQL databases, Let's Encrypt SSL, UFW firewall, a web file manager, and live system stats. Target host is Ubuntu 24.04+; the panel is deployed at `/home/laranode_ln/panel`. + +## Local dev/test (Docker) + +`local-dev/` provides a single systemd-enabled Ubuntu 24.04 container ("VPS-in-a-box") with the full Laranode stack (Apache, PHP-FPM, MySQL, Reverb, queue worker). No real Linux VPS needed for integration testing. + +Key targets (run from repo root): +- `make up` — build image + start container + run entrypoint provisioning +- `make verify` — check all services running + HTTP panel response +- `make test` — run Pest suite inside container +- `make test-system` — Pest with `LARANODE_SYSTEM_TESTS=1` (exercises sudo scripts) +- `make ssl-test` — bring up Pebble ACME sidecars (pebble + challtestsrv) + test SSL issuance +- `make nuke` — destroy container + all named volumes (full reset) + +Admin login: `admin@laranode.test` / `password` + +> **Windows:** Run `make` and `docker compose` from **PowerShell or cmd**, NOT Git Bash. +> Git Bash (MSYS) breaks docker two ways: it strips the Windows environment that +> `docker.exe` needs to locate its compose plugin, and it rewrites in-container paths +> (`/home/…`, `/opt/…`) passed to `docker exec` into `C:/msys64/…`, which breaks the +> provisioning recipes. The Makefile's `MSYS_*` exports fix the path rewriting, but the +> plugin-discovery failure remains — so use PowerShell/cmd. Plain `docker exec +> laranode-lab …` works from any shell. + +## Commands + +```bash +composer dev # all-in-one dev: php artisan serve + queue:listen + pail (logs) + vite, concurrently +npm run dev # vite only +npm run build # production asset build +php artisan reverb:start # websocket server — NOT started by `composer dev`; needed for live stats +./vendor/bin/pest # run tests (Pest 3) +./vendor/bin/pest --filter="text" # single test by name +php artisan test --filter=AccountsTest # alt runner, by file/test +./vendor/bin/pint # format (Laravel Pint) — run before committing PHP +php artisan migrate +php artisan laranode:create-admin # interactive admin creation (username is forced to "laranode") +``` + +Tests use Pest with `RefreshDatabase` (see `tests/Pest.php`); feature tests live in `tests/Feature//`. + +## Verify every feature before calling it done + +After implementing ANY feature or bug fix, run this gate before declaring it complete (and before committing): + +1. **Backend tests** — `./vendor/bin/pest` (or `--filter` for the touched domain, then the full suite to catch regressions). +2. **Frontend tests** — `npx vitest run` for the touched component, then the full Vitest suite. +3. **Build assets** — `npm run build` (the lab serves `public/build/`, not Vite dev — stale assets are why a change "doesn't show"). Clear caches if a blade/route changed: `php artisan optimize:clear`. +4. **Playwright check** — drive the running panel (lab container, admin `admin@laranode.test` / `password`) to confirm the change actually works in the real app and toggle dark mode on the affected page(s). Verify against the live result, not just the diff. + +Run all four in the lab container, e.g. `docker exec laranode-lab bash -lc 'cd /home/laranode_ln/panel && '`. + +**Pint scope:** only format files you changed (`./vendor/bin/pint ...`). NEVER run `pint app/` or a whole-tree pint — it sweeps dozens of pre-existing unformatted files into your diff. + +## Environment caveat + +System-touching features (sudo scripts, `systemctl`, `/proc`, `certbot`, `ufw`) only run on a real Linux host. On Windows/macOS dev machines those `Process` calls fail — exercise that behavior on a Linux VPS, not locally. DB is MySQL in prod (`.env.example`). + +## Architecture + +### Request layering +Controllers are thin. The pattern is: **Controller → FormRequest (validation) → Service or Action (work)**. + +- `app/Services//` — orchestration, usually wrapping system calls (`Websites`, `MySQL`, `Accounts`, `Dashboard`, and `Laranode` infra helpers). Convention: a single `handle()` method, and a sibling custom `*Exception` class declared in the same file (e.g. `CreateWebsiteException`). +- `app/Actions//` — single-purpose units (`Filemanager`, `Firewall`, `SSL`, `MySQL`). Filemanager actions receive a Flysystem `Filesystem` injected by `AppServiceProvider`, sandboxed to the authenticated user's homedir (`DISALLOW_LINKS`). + +### How the panel touches the system (the core idea) +Two distinct mechanisms, both via the `Process` facade: + +1. **Privileged mutations** shell out to whitelisted bash scripts: + ```php + Process::run(['sudo', config('laranode.laranode_bin_path') . '/laranode-add-vhost.sh', ...$args]); + ``` + Scripts live in `laranode-scripts/bin/`, config templates (Apache vhost, PHP-FPM pool, systemd units) in `laranode-scripts/templates/`. The installer grants `www-data` NOPASSWD sudo for `laranode-scripts/bin/*.sh`. When adding a privileged op: add a `*.sh` script there and call it through a Service — do not run privileged commands inline. +2. **Read-only stats** call system tools directly (`top`, `free`, `df`, `systemctl`, `ps`, `certbot`, `/proc/net/dev`) via `Process::run('…')` / `Process::pipe([...])`. See `app/Services/Dashboard/SystemStatsService.php`. + +### Identity & path conventions (computed, never stored) +Used throughout the codebase — accessors on the models, not DB columns (comments note casts were unreliable here): +- System user = `{username}_ln` (`User::systemUsername`) +- Home dir = `/home/{username}_ln` (`User::homedir`) +- Website root = `{homedir}/domains/{url}`; `fullDocumentRoot` = website root + `document_root` (`Website`) + +### Auth & multi-tenancy +- `users.role` is `admin` | `user`. `AdminMiddleware` gates admin-only routes (accounts, firewall, PHP manager, admin dashboard, stats history). +- Non-admins are scoped to their own rows via the `scopeMine()` query scope on `Website`/`Database`. +- Admins impersonate users via `lab404/laravel-impersonate`. Shared Inertia props (`HandleInertiaRequests`): `auth.user`, `auth.isImpersonating`, `flash.{success,error}`. + +### Live stats over websockets (no polling) +Reverb-based push, not polling: +1. React page subscribes to a private channel and whispers a `client-typing` event (`resources/js/Pages/Dashboard/...`). +2. Server's `MessageReceivedListener` (auto-discovered, hooks `Laravel\Reverb\Events\MessageReceived`) matches the channel and dispatches `SystemStatsEvent` / `TopStatsEvent`. +3. Those events gather fresh stats in their constructor and broadcast back on private channels `systemstats` / `topstats` — both authorized to admins only (`routes/channels.php`). + +Historical stats use sysstat/`sar`: `app/Services/Dashboard/{SarHistory,CPUHistoryService,MemoryHistoryService,NetworkHistoryService}.php`, all implementing `HistoricStatsContract`. + +### Frontend +Inertia + React (JSX, **not** TypeScript). Pages in `resources/js/Pages//`, layouts in `resources/js/Layouts/`. `route()` in JS comes from Ziggy; websockets from Echo/Reverb (`resources/js/echo.js`). Tables use `react-data-table-component`, charts use `chart.js`/`react-chartjs-2`. + +### Production runtime +Apache2 (vhost per site) + per-site PHP-FPM pools + MySQL + certbot (Let's Encrypt, 90-day certs) + UFW. Two systemd services from `laranode-scripts/templates/`: `laranode-reverb.service` (websockets) and `laranode-queue-worker.service` (queue, `QUEUE_CONNECTION=database`). Full provisioning is in `laranode-scripts/bin/laranode-installer.sh`. diff --git a/README.md b/README.md index 8eec0b1..524fa24 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,28 @@ Laranode is a simple but powerful open-source alternative to cPanel and Plesk, d ✅ **File Manager** – Built-in (from the ground up) web-based file manager for quick access. -✅ **Live System Stats** – Monitor CPU, memory, and network usage in real-time. +✅ **Live System Stats & Analytics** – Real-time CPU/memory/network monitoring plus historical usage charts and per-user quota tracking. ✅ **LAMP Stack Administration** – Manage Apache, MySQL, and PHP with ease. -✅ **User-Friendly Interface** – Clean and simple UI designed for efficiency. +✅ **PHP Manager** – Install, update, and remove PHP versions from the web UI. + +✅ **Alternative PHP Runtimes** – Switch individual sites between PHP-FPM and FrankenPHP. + +✅ **Multi-Engine Database Management** – Create and manage MySQL, MariaDB, and PostgreSQL databases, with per-engine service control (start/stop/restart). + +✅ **Automated Backups** – Scheduled or on-demand database and file backups to local disk or S3-compatible storage, with retention and restore. -✅ **MySQL Database Management** – Create and control MySQL databases. +✅ **Cron Job Manager** – Create and manage per-user scheduled tasks from the web UI. + +✅ **Notifications** – In-app notification center plus email/webhook alerts for operations, SSL expiry, and more. + +✅ **Async Operations with Live Progress** – Long-running tasks (SSL issuance, runtime switches, backups) stream real-time progress instead of blocking the request. ✅ **UFW Firewall** – Manage uncomplicated firewall rules with ease directly from the web interface. +✅ **User-Friendly Interface** – Clean and simple UI designed for efficiency. + ## Installation Laranode can be installed on a FRESH VPS or dedicated server. @@ -35,8 +47,9 @@ Laranode can be installed on a FRESH VPS or dedicated server. - 10GB Disk Space ### Quick Install +Run on a clean Ubuntu 24.04 server as root: ```bash -curl -sSL https://raw.githubusercontent.com/crivion/laranode/refs/heads/main/laranode-scripts/bin/laranode-installer.sh | bash +curl -sSL https://raw.githubusercontent.com/alexandre433/laranode/refs/heads/main/laranode-scripts/bin/laranode-installer.sh | bash ``` ## Getting Started @@ -69,8 +82,13 @@ Login with the credentials provided during installation. ## Roadmap - Future Release Plans -- 🔹 PHP Manager - install, update, remove PHP versions -- 🔹 Backup Manager - backup websites, databases, and files +- 🔹 Git-Based Deployments – push-to-deploy workflow for websites +- 🔹 Fail2ban Integration – automatic intrusion prevention +- 🔹 DNS Zone Management – built-in authoritative DNS +- 🔹 One-Click App Installers – WordPress and more +- 🔹 Email Server – mailboxes with webmail +- 🔹 Teams & Granular Roles – per-resource collaborator access +- 🔹 Staging Environments – clone, sync, and promote sites ## Contributing Laranode is open-source and welcomes contributions! Feel free to submit issues, feature requests, or pull requests. diff --git a/app/Actions/Backup/DumpDatabaseAction.php b/app/Actions/Backup/DumpDatabaseAction.php new file mode 100644 index 0000000..1dc0472 --- /dev/null +++ b/app/Actions/Backup/DumpDatabaseAction.php @@ -0,0 +1,36 @@ +db_password}\n"); + umask($prevUmask); + + try { + $driver = $this->engineManager->for($database->engine ?? 'mysql'); + + return $driver->dump($database->name, $database->db_user, $cnfPath, $emit); + } finally { + if (file_exists($cnfPath)) { + unlink($cnfPath); + } + } + } +} diff --git a/app/Actions/Backup/RetainBackupsAction.php b/app/Actions/Backup/RetainBackupsAction.php new file mode 100644 index 0000000..ba1c4af --- /dev/null +++ b/app/Actions/Backup/RetainBackupsAction.php @@ -0,0 +1,33 @@ +where('type', $type) + ->where('target', $target) + ->where('status', 'completed') + ->orderBy('created_at', 'asc') + ->get(); + + $excess = $backups->slice(0, max(0, $backups->count() - $retentionCount)); + + foreach ($excess as $backup) { + if ($backup->path) { + $disk->delete($backup->path); + } + + $backup->delete(); + } + } +} diff --git a/app/Actions/Backup/TarFilesAction.php b/app/Actions/Backup/TarFilesAction.php new file mode 100644 index 0000000..887af64 --- /dev/null +++ b/app/Actions/Backup/TarFilesAction.php @@ -0,0 +1,37 @@ +user->systemUsername ?? ($website->user->username.'_ln'); + + $emit("Archiving files for '{$website->url}'..."); + + $result = Process::run([ + 'sudo', + $binPath.'/laranode-backup-files.sh', + $website->websiteRoot, + $tempPath, + $sysUser, + ]); + + if ($result->exitCode() !== 0) { + throw new RuntimeException('File archive failed: '.$result->errorOutput()); + } + + $emit('File archive completed.'); + + return $tempPath; + } +} diff --git a/app/Actions/Backup/UploadToStorageAction.php b/app/Actions/Backup/UploadToStorageAction.php new file mode 100644 index 0000000..0f39ea2 --- /dev/null +++ b/app/Actions/Backup/UploadToStorageAction.php @@ -0,0 +1,38 @@ +writeStream($remotePath, $stream); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + + $emit('Upload completed.'); + + return $remotePath; + } +} diff --git a/app/Actions/Firewall/FirewallLockoutException.php b/app/Actions/Firewall/FirewallLockoutException.php new file mode 100644 index 0000000..0d5db35 --- /dev/null +++ b/app/Actions/Firewall/FirewallLockoutException.php @@ -0,0 +1,7 @@ + 0) { + return $port; + } + + return parse_url($url, PHP_URL_SCHEME) === 'https' ? 443 : 80; + } + + /** + * Extract the set of numeric ports referenced by `ufw show added` lines. + * + * @param array $lines + * @return array + */ + public static function coveredPorts(array $lines): array + { + $ports = []; + + foreach ($lines as $line) { + // "22/tcp", "443/udp" + if (preg_match_all('/(?:^|\s)(\d{1,5})\/(?:tcp|udp)\b/i', $line, $m)) { + foreach ($m[1] as $p) { + $ports[] = (int) $p; + } + } + // "to any port 22", "port 8443" + if (preg_match_all('/\bport\s+(\d{1,5})\b/i', $line, $m)) { + foreach ($m[1] as $p) { + $ports[] = (int) $p; + } + } + // bare "allow 80" + if (preg_match('/\ballow\s+(\d{1,5})(?:\s|$)/i', $line, $m)) { + $ports[] = (int) $m[1]; + } + } + + return array_values(array_unique($ports)); + } + + /** + * @param array $lines + */ + public static function coversSsh(array $lines): bool + { + if (in_array(22, self::coveredPorts($lines), true)) { + return true; + } + + // UFW application profiles that open SSH + foreach ($lines as $line) { + if (preg_match('/\b(openssh|ssh)\b/i', $line)) { + return true; + } + } + + return false; + } + + /** + * @param array $lines + */ + public static function coversWeb(array $lines, int $panelPort): bool + { + $ports = self::coveredPorts($lines); + if (array_intersect([80, 443, $panelPort], $ports)) { + return true; + } + + // UFW application profiles that open HTTP/HTTPS + foreach ($lines as $line) { + if (preg_match('/\b(apache|www|nginx)\b/i', $line)) { + return true; + } + } + + return false; + } + + /** + * Human-readable list of what is missing, or [] when safe to enable. + * + * @param array $lines + * @return array + */ + public static function missingProtections(array $lines, int $panelPort): array + { + $missing = []; + + if (! self::coversSsh($lines)) { + $missing[] = 'SSH (port 22) — you would lose remote access to the server'; + } + + if (! self::coversWeb($lines, $panelPort)) { + $missing[] = "the panel/websites (port {$panelPort}, 80 or 443) — you would lose access to this control panel"; + } + + return $missing; + } +} diff --git a/app/Actions/Firewall/GetStagedUfwRulesAction.php b/app/Actions/Firewall/GetStagedUfwRulesAction.php new file mode 100644 index 0000000..68a0ea8 --- /dev/null +++ b/app/Actions/Firewall/GetStagedUfwRulesAction.php @@ -0,0 +1,33 @@ + raw `ufw ...` rule lines (empty on failure) + */ + public function execute(): array + { + $proc = Process::run(['sudo', 'ufw', 'show', 'added']); + if ($proc->failed()) { + return []; + } + + $lines = preg_split("/\r?\n/", trim($proc->output())) ?: []; + + return array_values(array_filter( + array_map('trim', $lines), + fn ($l) => str_starts_with($l, 'ufw ') + )); + } +} diff --git a/app/Actions/Firewall/SafeSetupFirewallAction.php b/app/Actions/Firewall/SafeSetupFirewallAction.php new file mode 100644 index 0000000..5b16b75 --- /dev/null +++ b/app/Actions/Firewall/SafeSetupFirewallAction.php @@ -0,0 +1,61 @@ +allow(['from', $sshFromIp, 'to', 'any', 'port', '22', 'proto', 'tcp']); + } else { + $this->allow(['22/tcp']); + } + + // Web: panel + websites + $this->allow(['80/tcp']); + $this->allow(['443/tcp']); + + if (! in_array($panelPort, [80, 443], true)) { + $this->assertPort($panelPort); + $this->allow(["{$panelPort}/tcp"]); + } + + $this->toggle->execute(true); + } + + /** + * @param array $spec + */ + private function allow(array $spec): void + { + $proc = Process::run(array_merge(['sudo', 'ufw', 'allow'], $spec)); + if ($proc->failed()) { + throw new RuntimeException('UFW allow failed: '.$proc->errorOutput()); + } + } + + private function assertPort(int $port): void + { + if ($port < 1 || $port > 65535) { + throw new RuntimeException('Invalid panel port.'); + } + } +} diff --git a/app/Actions/MySQL/GetCharsetsAndCollationsAction.php b/app/Actions/MySQL/GetCharsetsAndCollationsAction.php deleted file mode 100644 index 3f7fc3b..0000000 --- a/app/Actions/MySQL/GetCharsetsAndCollationsAction.php +++ /dev/null @@ -1,46 +0,0 @@ - $this->getCharsets(), - 'collations' => $this->getCollations(), - ]; - } - - private function getCharsets(): array - { - $charsets = DB::select("SHOW CHARACTER SET"); - - return collect($charsets)->map(function($charset) { - return [ - 'name' => $charset->Charset, - 'description' => $charset->Description, - 'default_collation' => $charset->{'Default collation'}, - 'maxlen' => $charset->Maxlen, - ]; - })->toArray(); - } - - private function getCollations(): array - { - $collations = DB::select("SHOW COLLATION"); - - return collect($collations)->map(function($collation) { - return [ - 'name' => $collation->Collation, - 'charset' => $collation->Charset, - 'id' => $collation->Id, - 'default' => $collation->Default, - 'compiled' => $collation->Compiled, - 'sortlen' => $collation->Sortlen, - ]; - })->toArray(); - } -} diff --git a/app/Actions/MySQL/GetDatabasesWithStatsAction.php b/app/Actions/MySQL/GetDatabasesWithStatsAction.php deleted file mode 100644 index 15ebbd6..0000000 --- a/app/Actions/MySQL/GetDatabasesWithStatsAction.php +++ /dev/null @@ -1,68 +0,0 @@ -user->id)->get(); - $items = []; - - foreach ($databases as $database) { - $items[] = $this->buildDatabaseItem($database); - } - - return $items; - } - - private function buildDatabaseItem(Database $database): array - { - $dbName = $database->name; - - // Get table count - $tableCount = $this->getTableCount($dbName); - - // Get database size - $sizeMb = $this->getDatabaseSize($dbName); - - return [ - 'id' => $database->id, - 'name' => $database->name, - 'user' => $this->user->username, - 'db_user' => $database->db_user, - 'tables' => $tableCount, - 'sizeMb' => $sizeMb, - 'charset' => $database->charset, - 'collation' => $database->collation, - ]; - } - - private function getTableCount(string $dbName): int - { - $tables = DB::select( - "SELECT COUNT(*) as cnt FROM information_schema.tables WHERE table_schema = ?", - [$dbName] - ); - - return (int) ($tables[0]->cnt ?? 0); - } - - private function getDatabaseSize(string $dbName): float - { - $sizeRow = DB::selectOne( - "SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb - FROM information_schema.tables - WHERE table_schema = ?", - [$dbName] - ); - - return (float) ($sizeRow->size_mb ?? 0); - } -} diff --git a/app/Actions/SSL/GenerateWebsiteSslAction.php b/app/Actions/SSL/GenerateWebsiteSslAction.php index 2115eb9..cb23914 100644 --- a/app/Actions/SSL/GenerateWebsiteSslAction.php +++ b/app/Actions/SSL/GenerateWebsiteSslAction.php @@ -8,7 +8,7 @@ class GenerateWebsiteSslAction { - public function execute(Website $website, string $email): void + public function execute(Website $website, string $email, ?callable $onOutput = null): void { // Update status to pending and mark enabled $website->update([ @@ -23,7 +23,13 @@ public function execute(Website $website, string $email): void $website->url, $email, $website->fullDocumentRoot, - ]); + ], $onOutput ? function (string $type, string $buffer) use ($onOutput) { + foreach (preg_split('/\r?\n/', rtrim($buffer, "\r\n")) as $line) { + if ($line !== '') { + $onOutput($line); + } + } + } : null); if ($result->failed()) { $website->update([ diff --git a/app/Actions/SSL/SendSslExpiryNotificationsAction.php b/app/Actions/SSL/SendSslExpiryNotificationsAction.php new file mode 100644 index 0000000..bf198fa --- /dev/null +++ b/app/Actions/SSL/SendSslExpiryNotificationsAction.php @@ -0,0 +1,29 @@ +whereNotNull('ssl_expires_at') + ->with('user') + ->get() + ->each(function (Website $website) { + $days = (int) ceil(now()->diffInDays($website->ssl_expires_at, false)); + + if ($days === 7 || $days === 14) { + $user = $website->user; + + if ($user) { + NotificationService::dispatch($user, new SslExpiringNotification($website)); + } + } + }); + } +} diff --git a/app/Backup/BackupEngineManager.php b/app/Backup/BackupEngineManager.php new file mode 100644 index 0000000..2e4d920 --- /dev/null +++ b/app/Backup/BackupEngineManager.php @@ -0,0 +1,23 @@ + new PostgresBackupDriver, + default => new MysqlBackupDriver, + }; + } +} diff --git a/app/Backup/Drivers/MysqlBackupDriver.php b/app/Backup/Drivers/MysqlBackupDriver.php new file mode 100644 index 0000000..d83ad4e --- /dev/null +++ b/app/Backup/Drivers/MysqlBackupDriver.php @@ -0,0 +1,36 @@ +exitCode() !== 0) { + throw new RuntimeException('DB dump failed: '.$result->errorOutput()); + } + + $emit('Database dump completed.'); + + return $tempFile; + } +} diff --git a/app/Backup/Drivers/PostgresBackupDriver.php b/app/Backup/Drivers/PostgresBackupDriver.php new file mode 100644 index 0000000..f05f982 --- /dev/null +++ b/app/Backup/Drivers/PostgresBackupDriver.php @@ -0,0 +1,14 @@ +detect(); + + if ($profile['detected']) { + $this->info("Detected {$profile['vendor']} GPU: {$profile['name']} (via {$profile['tool']})"); + } else { + $this->line('No GPU detected — GPU stats disabled until a manual rescan.'); + } + + return self::SUCCESS; + } +} diff --git a/app/Contracts/Backup/BackupEngineDriver.php b/app/Contracts/Backup/BackupEngineDriver.php new file mode 100644 index 0000000..d98e4c8 --- /dev/null +++ b/app/Contracts/Backup/BackupEngineDriver.php @@ -0,0 +1,19 @@ +options['charset'] ?? 'utf8mb4'; + $collation = $spec->options['collation'] ?? 'utf8mb4_unicode_ci'; + + $this->assertSafeIdentifier($charset); + $this->assertSafeIdentifier($collation); + + $name = $spec->name; + $dbUser = $spec->dbUser; + $password = $spec->password; + + $this->assertSafeIdentifier($name); + $this->assertSafeIdentifier($dbUser); + + $conn = DB::connection($this->connectionName()); + + try { + $conn->statement("CREATE DATABASE `{$name}` CHARACTER SET {$charset} COLLATE {$collation}"); + } catch (Exception $e) { + throw new \RuntimeException('Failed to create MySQL database: '.$e->getMessage(), 0, $e); + } + + try { + $conn->statement("CREATE USER IF NOT EXISTS `{$dbUser}`@'localhost' IDENTIFIED BY ?", [$password]); + $conn->statement("GRANT ALL PRIVILEGES ON `{$name}`.* TO `{$dbUser}`@'localhost'"); + $conn->statement('FLUSH PRIVILEGES'); + } catch (Exception $e) { + // Rollback database creation if user creation fails + $conn->statement("DROP DATABASE IF EXISTS `{$name}`"); + throw new \RuntimeException('Failed to create MySQL user: '.$e->getMessage(), 0, $e); + } + } + + public function updatePassword(Database $database, string $newPassword): void + { + $dbUser = $database->db_user; + $this->assertSafeIdentifier($dbUser); + $conn = DB::connection($this->connectionName()); + + $conn->statement("ALTER USER `{$dbUser}`@'localhost' IDENTIFIED BY ?", [$newPassword]); + $conn->statement('FLUSH PRIVILEGES'); + } + + public function updateOptions(Database $database, array $options): void + { + $name = $database->name; + $charset = $options['charset'] ?? $database->charset; + $collation = $options['collation'] ?? $database->collation; + + $this->assertSafeIdentifier($name); + $this->assertSafeIdentifier($charset); + $this->assertSafeIdentifier($collation); + + $conn = DB::connection($this->connectionName()); + $conn->statement("ALTER DATABASE `{$name}` CHARACTER SET {$charset} COLLATE {$collation}"); + } + + public function delete(Database $database): void + { + $name = $database->name; + $dbUser = $database->db_user; + $this->assertSafeIdentifier($name); + $this->assertSafeIdentifier($dbUser); + $conn = DB::connection($this->connectionName()); + + $conn->statement("DROP DATABASE IF EXISTS `{$name}`"); + $conn->statement("DROP USER IF EXISTS `{$dbUser}`@'localhost'"); + $conn->statement('FLUSH PRIVILEGES'); + } + + public function stats(Database $database): DatabaseStats + { + $name = $database->name; + $conn = DB::connection($this->connectionName()); + + $countRow = $conn->selectOne( + 'SELECT COUNT(*) AS table_count FROM information_schema.tables WHERE table_schema = ?', + [$name] + ); + + $sizeRow = $conn->selectOne( + 'SELECT SUM(data_length + index_length) AS size_bytes FROM information_schema.tables WHERE table_schema = ?', + [$name] + ); + + $tableCount = $countRow ? (int) $countRow->table_count : 0; + $sizeMb = $sizeRow && $sizeRow->size_bytes ? round($sizeRow->size_bytes / 1024 / 1024, 2) : 0.0; + + return new DatabaseStats(tableCount: $tableCount, sizeMb: $sizeMb); + } + + public function capabilities(): EngineCapabilities + { + return new EngineCapabilities( + label: 'MySQL', + hasUsers: true, + optionFields: ['charset', 'collation'], + ); + } + + /** + * Defense-in-depth: assert identifier matches safe pattern before SQL interpolation. + */ + protected function assertSafeIdentifier(string $value): void + { + if (! preg_match('/^[a-zA-Z0-9_]+$/', $value)) { + throw new InvalidArgumentException("Unsafe identifier value: {$value}"); + } + } +} diff --git a/app/Databases/Drivers/PostgresDriver.php b/app/Databases/Drivers/PostgresDriver.php new file mode 100644 index 0000000..29baa8d --- /dev/null +++ b/app/Databases/Drivers/PostgresDriver.php @@ -0,0 +1,143 @@ +name; + $dbUser = $spec->dbUser; + $password = $spec->password; + $encoding = $spec->options['encoding'] ?? 'UTF8'; + $locale = $spec->options['locale'] ?? 'en_US.UTF-8'; + + $this->assertSafeName($name); + $this->assertSafeName($dbUser); + $this->assertSafeName($encoding); + $this->assertSafeLocale($locale); + + $script = $this->scriptPath(); + + // Step 1: create the database + $result = Process::run(['sudo', $script, 'create-db', $name, $encoding, $locale]); + + if ($result->exitCode() !== 0) { + throw new CreateDatabaseException('Failed to create PostgreSQL database: '.$result->output().$result->errorOutput()); + } + + // Step 2: create the user — password via stdin, never argv + $result = Process::input($password)->run(['sudo', $script, 'create-user', $dbUser]); + + if ($result->exitCode() !== 0) { + // Rollback: drop the database we just created + Process::run(['sudo', $script, 'drop-db', $name]); + + throw new CreateDatabaseException('Failed to create PostgreSQL user: '.$result->output().$result->errorOutput()); + } + + // Step 3: grant the user access to the database + $result = Process::run(['sudo', $script, 'grant', $dbUser, $name]); + + if ($result->exitCode() !== 0) { + throw new CreateDatabaseException('Failed to grant privileges: '.$result->output().$result->errorOutput()); + } + } + + public function updatePassword(Database $database, string $newPassword): void + { + $dbUser = $database->db_user; + $this->assertSafeName($dbUser); + + $script = $this->scriptPath(); + + // Password via stdin, never in argv + Process::input($newPassword)->run(['sudo', $script, 'update-user-password', $dbUser]); + } + + public function updateOptions(Database $database, array $options): void + { + // PostgreSQL does not support altering charset/collation after creation. + // This is intentionally a no-op. + } + + public function delete(Database $database): void + { + $name = $database->name; + $dbUser = $database->db_user; + $this->assertSafeName($name); + $this->assertSafeName($dbUser); + + $script = $this->scriptPath(); + + Process::run(['sudo', $script, 'drop-db', $name]); + Process::run(['sudo', $script, 'drop-user', $dbUser]); + } + + public function stats(Database $database): DatabaseStats + { + $name = $database->name; + + // Scope pg_database_size to existing databases only — calling it on a + // name that no longer exists raises SQLSTATE 3D000 and would crash the + // whole listing. A missing db yields no row → size 0. + $row = DB::connection('pgsql_admin')->selectOne( + 'SELECT pg_database_size(datname) AS size_bytes FROM pg_database WHERE datname = ?', + [$name] + ); + + $sizeMb = $row && $row->size_bytes ? round($row->size_bytes / 1024 / 1024, 2) : 0.0; + + return new DatabaseStats(tableCount: 0, sizeMb: $sizeMb); + } + + public function capabilities(): EngineCapabilities + { + return new EngineCapabilities( + label: 'PostgreSQL', + hasUsers: true, + optionFields: ['encoding', 'locale'], + ); + } + + /** + * Defense-in-depth: assert name/user matches safe pattern before passing to sudo script. + */ + private function assertSafeName(string $value): void + { + if (! preg_match('/^[a-zA-Z0-9_]+$/', $value)) { + throw new InvalidArgumentException("Unsafe identifier value: {$value}"); + } + } + + /** + * Defense-in-depth: assert a locale matches a safe pattern before passing to sudo script. + */ + private function assertSafeLocale(string $value): void + { + if (! preg_match('/^[a-zA-Z0-9_.@-]+$/', $value)) { + throw new InvalidArgumentException("Unsafe locale value: {$value}"); + } + } + + private function scriptPath(): string + { + return config('laranode.laranode_bin_path').'/laranode-postgres.sh'; + } +} diff --git a/app/Databases/EngineCapabilities.php b/app/Databases/EngineCapabilities.php new file mode 100644 index 0000000..beeedac --- /dev/null +++ b/app/Databases/EngineCapabilities.php @@ -0,0 +1,12 @@ +|null + */ + private ?array $cachedAvailable = null; + + /** + * Extra candidate service names to try for engines that may use versioned unit names. + * + * @var array + */ + private array $extraCandidates = [ + 'postgres' => ['postgresql@16-main'], + ]; + + /** + * Return a map of engine key => service name for every active engine. + * + * Checks systemctl is-active for each configured engine. For the postgres + * engine, also tries versioned unit names (e.g. postgresql@16-main) so the + * Ubuntu default install is detected regardless of whether the generic alias + * or the versioned unit is active. + * + * Result is memoized for the lifetime of this instance (one per request). + * + * @return array + */ + public function available(): array + { + if ($this->cachedAvailable !== null) { + return $this->cachedAvailable; + } + + $engines = config('laranode.db_engines', []); + $active = []; + + foreach ($engines as $key => $config) { + $candidates = [$config['service']]; + + if (isset($this->extraCandidates[$key])) { + foreach ($this->extraCandidates[$key] as $extra) { + $candidates[] = $extra; + } + } + + foreach ($candidates as $service) { + $result = Process::run(['systemctl', 'is-active', $service]); + + if (trim($result->output()) === 'active') { + $active[$key] = $service; + break; // one active candidate is enough + } + } + } + + $this->cachedAvailable = $active; + + return $this->cachedAvailable; + } + + /** + * Resolve the driver for the given engine key. + * + * Passing null or empty string falls back to the MySQL driver (legacy default). + * + * @throws InvalidArgumentException for unrecognized non-empty engine keys. + */ + public function for(?string $engine): DatabaseEngineDriver + { + if ($engine === null || $engine === '') { + return new MysqlDriver; + } + + return match ($engine) { + 'mysql' => new MysqlDriver, + 'mariadb' => new MariaDbDriver, + 'postgres' => new PostgresDriver, + default => throw new InvalidArgumentException("Unknown database engine: {$engine}"), + }; + } +} diff --git a/app/Events/NotificationCreated.php b/app/Events/NotificationCreated.php new file mode 100644 index 0000000..441f578 --- /dev/null +++ b/app/Events/NotificationCreated.php @@ -0,0 +1,34 @@ +userId); + } + + public function broadcastAs(): string + { + return 'NotificationCreated'; + } + + public function broadcastWith(): array + { + return ['unread_count' => $this->unreadCount]; + } +} diff --git a/app/Events/OperationUpdated.php b/app/Events/OperationUpdated.php new file mode 100644 index 0000000..04564fa --- /dev/null +++ b/app/Events/OperationUpdated.php @@ -0,0 +1,42 @@ +operation->user_id); + } + + public function broadcastAs(): string + { + return 'OperationUpdated'; + } + + public function broadcastWith(): array + { + return [ + 'operationId' => $this->operation->id, + 'kind' => $this->kind, + 'status' => $this->operation->status, + 'line' => $this->line, + 'exitCode' => $this->operation->exit_code, + ]; + } +} diff --git a/app/Events/SystemStatsEvent.php b/app/Events/SystemStatsEvent.php index d2db306..cdcc63d 100644 --- a/app/Events/SystemStatsEvent.php +++ b/app/Events/SystemStatsEvent.php @@ -3,14 +3,12 @@ namespace App\Events; use App\Services\Dashboard\SystemStatsService; -use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PresenceChannel; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; -use Illuminate\Contracts\Broadcasting\ShouldBeUnique; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Cache; class SystemStatsEvent implements ShouldBroadcast { @@ -22,6 +20,7 @@ class SystemStatsEvent implements ShouldBroadcast public function __construct(public array $stats = []) { $this->stats = (new SystemStatsService)->getAllStats(); + Cache::put('dashboard_stats_last_known', $this->stats, 90); } /** diff --git a/app/Http/Controllers/AccountsController.php b/app/Http/Controllers/AccountsController.php index 87cae2a..74926f5 100644 --- a/app/Http/Controllers/AccountsController.php +++ b/app/Http/Controllers/AccountsController.php @@ -5,13 +5,10 @@ use App\Http\Requests\CreateAccountRequest; use App\Http\Requests\EditAccountRequest; use App\Models\User; -use App\Services\Accounts\CreateAccountException; use App\Services\Accounts\CreateAccountService; use App\Services\Accounts\DeleteAccountService; use App\Services\Accounts\UpdateAccountService; -use Exception; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Request; use Inertia\Inertia; class AccountsController extends Controller @@ -22,6 +19,7 @@ class AccountsController extends Controller public function index(): \Inertia\Response { $accounts = User::all(); + return Inertia::render('Accounts/Index', compact('accounts')); } @@ -37,7 +35,6 @@ public function store(CreateAccountRequest $request): RedirectResponse return redirect()->route('accounts.index'); } - /** * Update the specified resource in storage. */ @@ -50,12 +47,15 @@ public function update(User $account, EditAccountRequest $request): RedirectResp return redirect()->route('accounts.index'); } - /** * Remove the specified resource from storage. */ public function destroy($account): RedirectResponse { + if ((int) $account === auth()->id()) { + abort(403, 'Cannot delete your own account.'); + } + (new DeleteAccountService(User::findOrFail($account)))->handle(); session()->flash('success', 'Account deleted successfully!'); @@ -68,7 +68,16 @@ public function destroy($account): RedirectResponse */ public function impersonate(User $user): RedirectResponse { + if ($user->id === auth()->id()) { + abort(403, 'Cannot impersonate yourself.'); + } + + if (! $user->canBeImpersonated()) { + abort(403, 'This user cannot be impersonated.'); + } + auth()->user()->impersonate($user); + return redirect()->route('dashboard'); } @@ -78,6 +87,7 @@ public function impersonate(User $user): RedirectResponse public function leaveImpersonation(): RedirectResponse { auth()->user()->leaveImpersonation(); + return redirect()->route('dashboard'); } } diff --git a/app/Http/Controllers/AnalyticsController.php b/app/Http/Controllers/AnalyticsController.php new file mode 100644 index 0000000..e98d77a --- /dev/null +++ b/app/Http/Controllers/AnalyticsController.php @@ -0,0 +1,24 @@ +user(); + $service = new UserAnalyticsService; + + return Inertia::render('Analytics/Index', [ + 'resourceHistory' => $service->getResourceHistory($user), + 'siteStats' => $service->getSiteStats($user), + 'quotaSummary' => $service->getQuotaSummary($user), + 'sslOverview' => $service->getSslOverview($user), + ]); + } +} diff --git a/app/Http/Controllers/BackupController.php b/app/Http/Controllers/BackupController.php new file mode 100644 index 0000000..b272b94 --- /dev/null +++ b/app/Http/Controllers/BackupController.php @@ -0,0 +1,148 @@ +mine() + ->with('operation') + ->latest() + ->paginate(20); + + $schedules = ScheduledBackup::query() + ->mine() + ->latest() + ->get(); + + return Inertia::render('Backups/Index', [ + 'backups' => $backups, + 'schedules' => $schedules, + ]); + } + + public function store(CreateBackupRequest $request): JsonResponse + { + $validated = $request->validated(); + $service = new BackupService; + $operation = $service->handle($validated, $request->user()); + + return response()->json(['operation_id' => $operation->id]); + } + + public function destroy(Request $request, Backup $backup): RedirectResponse + { + Gate::authorize('delete', $backup); + + if ($backup->disk_name && $backup->path) { + $this->ensureDiskRegistered($backup); + Storage::disk($backup->disk_name)->delete($backup->path); + } + + $backup->delete(); + + session()->flash('success', 'Backup deleted.'); + + return redirect()->route('backups.index'); + } + + public function restore(RestoreBackupRequest $request, Backup $backup): JsonResponse + { + Gate::authorize('restore', $backup); + + $validated = $request->validated(); + $service = new RestoreService; + $operation = $service->handle($backup, $validated['new_target'], $request->user()); + + return response()->json(['operation_id' => $operation->id]); + } + + public function download(Request $request, Backup $backup): StreamedResponse|RedirectResponse + { + Gate::authorize('download', $backup); + + $this->ensureDiskRegistered($backup); + + $disk = Storage::disk($backup->disk_name); + + if ($backup->storage === 's3') { + /** @var \League\Flysystem\AwsS3V3\AwsS3V3Adapter $adapter */ + $adapter = $disk->getAdapter(); + $client = $adapter->getClient(); + $command = $client->getCommand('GetObject', [ + 'Bucket' => Config::get("filesystems.disks.{$backup->disk_name}.bucket"), + 'Key' => $backup->path, + ]); + $presignedRequest = $client->createPresignedRequest($command, '+15 minutes'); + + return redirect((string) $presignedRequest->getUri()); + } + + return $disk->download($backup->path, basename($backup->path)); + } + + public function storeSchedule(CreateScheduledBackupRequest $request): RedirectResponse + { + $validated = $request->validated(); + + ScheduledBackup::create([ + 'user_id' => $request->user()->id, + 'type' => $validated['type'], + 'target' => $validated['target'], + 'storage' => $validated['storage'], + 'cron_expression' => $validated['cron_expression'], + 'retention_count' => $validated['retention_count'], + 's3_key' => $validated['s3_key'] ?? null, + 's3_secret' => $validated['s3_secret'] ?? null, + 's3_region' => $validated['s3_region'] ?? null, + 's3_bucket' => $validated['s3_bucket'] ?? null, + 's3_endpoint' => $validated['s3_endpoint'] ?? null, + 'enabled' => $validated['enabled'] ?? true, + ]); + + session()->flash('success', 'Scheduled backup created.'); + + return redirect()->route('backups.index'); + } + + public function destroySchedule(Request $request, ScheduledBackup $scheduledBackup): RedirectResponse + { + Gate::authorize('delete', $scheduledBackup); + + $scheduledBackup->delete(); + + session()->flash('success', 'Scheduled backup deleted.'); + + return redirect()->route('backups.index'); + } + + /** + * If the backup uses S3 storage, register the disk config from encrypted + * credentials on the Backup row so Storage::disk() resolves correctly. + */ + private function ensureDiskRegistered(Backup $backup): void + { + $s3Config = $backup->s3DiskConfig(); + if ($s3Config !== null) { + Config::set("filesystems.disks.{$backup->disk_name}", $s3Config); + } + } +} diff --git a/app/Http/Controllers/CronJobsController.php b/app/Http/Controllers/CronJobsController.php new file mode 100644 index 0000000..a66e141 --- /dev/null +++ b/app/Http/Controllers/CronJobsController.php @@ -0,0 +1,144 @@ +orderBy('id')->get(); + + return Inertia::render('CronJobs/Index', compact('cronJobs')); + } + + public function store(StoreCronJobRequest $request, CreateCronJobService $createService): RedirectResponse + { + $user = $request->user(); + $validated = $request->validated(); + + // Operation is created BEFORE the transaction so that a rollback + // inside the transaction does not destroy the audit row. + $op = Operation::create([ + 'user_id' => $user->id, + 'type' => 'cron.create', + 'target' => $validated['command'], + 'status' => 'queued', + ]); + + $op->markRunning(); + + try { + DB::transaction(function () use ($user, $validated, $createService) { + CronJob::create([ + 'user_id' => $user->id, + 'schedule' => $validated['schedule'], + 'command' => $validated['command'], + 'label' => $validated['label'] ?? null, + ]); + + $createService->handle($user); + }); + + // markFinished OUTSIDE the transaction — a rollback must not destroy the audit row. + $op->markFinished(0); + session()->flash('success', 'Cron job created successfully.'); + } catch (CreateCronJobException $e) { + $op->markFinished(1); + session()->flash('error', 'Failed to create cron job: '.$e->getMessage()); + } catch (\Throwable $e) { + report($e); + $op->markFinished(1); + session()->flash('error', 'An unexpected error occurred while creating the cron job.'); + } + + return redirect()->route('cron-jobs.index'); + } + + public function destroy(Request $request, CronJob $cronJob): RedirectResponse + { + Gate::authorize('delete', $cronJob); + + $user = $request->user(); + + // Operation outside transaction so rollback cannot destroy audit row. + $op = Operation::create([ + 'user_id' => $user->id, + 'type' => 'cron.delete', + 'target' => $cronJob->command, + 'status' => 'queued', + ]); + + $op->markRunning(); + + try { + DB::transaction(function () use ($user, $cronJob) { + (new DeleteCronJobService)->handle($user, $cronJob); + $cronJob->delete(); + }); + + // markFinished OUTSIDE the transaction — a rollback must not destroy the audit row. + $op->markFinished(0); + session()->flash('success', 'Cron job deleted successfully.'); + } catch (DeleteCronJobException $e) { + $op->markFinished(1); + session()->flash('error', 'Failed to delete cron job: '.$e->getMessage()); + } catch (\Throwable $e) { + report($e); + $op->markFinished(1); + session()->flash('error', 'An unexpected error occurred while deleting the cron job.'); + } + + return redirect()->route('cron-jobs.index'); + } + + public function toggleActive(Request $request, CronJob $cronJob): RedirectResponse + { + Gate::authorize('update', $cronJob); + + $user = $request->user(); + $originalActive = $cronJob->active; + + $op = Operation::create([ + 'user_id' => $user->id, + 'type' => 'cron.toggle', + 'target' => $cronJob->command, + 'status' => 'queued', + ]); + + $op->markRunning(); + + try { + DB::transaction(function () use ($user, $cronJob, $originalActive) { + $cronJob->update(['active' => ! $originalActive]); + (new CreateCronJobService)->handle($user); + }); + + // markFinished OUTSIDE the transaction — a rollback must not destroy the audit row. + // No manual revert needed: if the transaction rolled back, the DB column is still $originalActive. + $op->markFinished(0); + session()->flash('success', 'Cron job '.($originalActive ? 'paused' : 'activated').' successfully.'); + } catch (CreateCronJobException $e) { + $op->markFinished(1); + session()->flash('error', 'Failed to toggle cron job: '.$e->getMessage()); + } catch (\Throwable $e) { + report($e); + $op->markFinished(1); + session()->flash('error', 'An unexpected error occurred while toggling the cron job.'); + } + + return redirect()->route('cron-jobs.index'); + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 8198b81..b1a4fe6 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -2,17 +2,14 @@ namespace App\Http\Controllers; -use App\Services\SystemStatsService; -use App\Models\Website; use App\Models\Database; - +use App\Models\Website; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Inertia\Inertia; class DashboardController extends Controller { - public function index(Request $r) { if ($r->user()->isAdmin()) { @@ -24,7 +21,9 @@ public function index(Request $r) public function admin() { - return Inertia::render('Dashboard/Admin/AdminDashboard'); + $initialStats = Cache::get('dashboard_stats_last_known', []); + + return Inertia::render('Dashboard/Admin/AdminDashboard', compact('initialStats')); } public function getTopSort() @@ -41,6 +40,21 @@ public function setTopSort(Request $r) return ['sortBy' => $r->sortBy]; } + /** + * Re-probe the host for a GPU on demand (the dashboard cogwheel). Detection + * otherwise only runs at install, so a GPU added later isn't missed. + */ + public function rescanGpu(\App\Services\Dashboard\GpuStatsService $gpu): \Illuminate\Http\RedirectResponse + { + $profile = $gpu->detect(); + + session()->flash('success', $profile['detected'] + ? "Detected {$profile['vendor']} GPU: {$profile['name']}." + : 'No GPU detected on this server.'); + + return back(); + } + public function user() { $user = auth()->user(); diff --git a/app/Http/Controllers/DatabasesController.php b/app/Http/Controllers/DatabasesController.php new file mode 100644 index 0000000..97b0787 --- /dev/null +++ b/app/Http/Controllers/DatabasesController.php @@ -0,0 +1,128 @@ +engineManager); + $databases = $service->handle(); + + return Inertia::render('Databases/Index', [ + 'databases' => $databases, + ]); + } + + public function getEngineOptions(Request $request): JsonResponse + { + $available = $this->engineManager->available(); + + if (empty($available)) { + return response()->json(['engines' => [], 'capabilities' => null]); + } + + $engine = $request->query('engine'); + $capabilities = null; + + if ($engine && isset($available[$engine])) { + $capabilities = $this->engineManager->for($engine)->capabilities(); + } + + $engines = array_keys($available); + + return response()->json([ + 'engines' => $engines, + 'capabilities' => $capabilities, + ]); + } + + public function store(CreateDatabaseRequest $request): RedirectResponse + { + $validated = $request->validated(); + $user = $request->user(); + $engine = $validated['engine']; + + $driver = $this->engineManager->for($engine); + + $spec = new DatabaseSpec( + name: $validated['name'], + dbUser: $validated['db_user'], + password: $validated['db_pass'], + userId: $user->id, + options: array_filter([ + 'charset' => $validated['charset'] ?? null, + 'collation' => $validated['collation'] ?? null, + 'encoding' => $validated['encoding'] ?? null, + 'locale' => $validated['locale'] ?? null, + ], fn ($v) => $v !== null), + ); + + $service = new CreateDatabaseService($driver); + $service->handle($spec, $engine); + + session()->flash('success', 'Database created successfully!'); + + return redirect()->route('databases.index'); + } + + public function update(UpdateDatabaseRequest $request): RedirectResponse + { + $user = $request->user(); + $databaseId = $request->integer('id'); + + $database = Database::where('id', $databaseId) + ->where('user_id', $user->id) + ->firstOrFail(); + + Gate::authorize('update', $database); + + $driver = $this->engineManager->for($database->engine); + $service = new UpdateDatabaseService($driver); + $service->handle($database, $request->validated()); + + session()->flash('success', 'Database updated successfully!'); + + return redirect()->route('databases.index'); + } + + public function destroy(DeleteDatabaseRequest $request): RedirectResponse + { + $user = $request->user(); + $databaseId = $request->integer('id'); + + $database = Database::where('id', $databaseId) + ->where('user_id', $user->id) + ->firstOrFail(); + + Gate::authorize('delete', $database); + + $driver = $this->engineManager->for($database->engine); + $service = new DeleteDatabaseService($driver); + $service->handle($database); + + session()->flash('success', 'Database deleted successfully!'); + + return redirect()->route('databases.index'); + } +} diff --git a/app/Http/Controllers/DbServiceController.php b/app/Http/Controllers/DbServiceController.php new file mode 100644 index 0000000..9bedc6f --- /dev/null +++ b/app/Http/Controllers/DbServiceController.php @@ -0,0 +1,42 @@ +validated(); + $engine = $validated['engine']; + $action = $validated['action']; + + $service = config('laranode.db_engines')[$engine]['service']; + + $operation = Operation::create([ + 'user_id' => $request->user()->id, + 'type' => "db.service.{$action}", + 'target' => "{$engine}:{$service}", + 'status' => 'queued', + ]); + + DbServiceOperationJob::dispatch($operation, $engine, $action); + + return response()->json(['operation_id' => $operation->id]); + } + + public function status(): JsonResponse + { + $statuses = (new DbServiceStatusService($this->engineManager))->handle(); + + return response()->json(['statuses' => $statuses]); + } +} diff --git a/app/Http/Controllers/FirewallController.php b/app/Http/Controllers/FirewallController.php index 16b22ae..d8e12eb 100644 --- a/app/Http/Controllers/FirewallController.php +++ b/app/Http/Controllers/FirewallController.php @@ -2,41 +2,96 @@ namespace App\Http\Controllers; -use App\Actions\Firewall\AddUfwRuleAction; use App\Actions\Firewall\AddUfwDenyRuleAction; +use App\Actions\Firewall\AddUfwRuleAction; +use App\Actions\Firewall\BuildUfwRuleSpecAction; use App\Actions\Firewall\DeleteUfwRuleAction; +use App\Actions\Firewall\FirewallSafety; +use App\Actions\Firewall\GetStagedUfwRulesAction; use App\Actions\Firewall\GetUfwRulesAction; use App\Actions\Firewall\GetUfwStatusAction; +use App\Actions\Firewall\SafeSetupFirewallAction; use App\Actions\Firewall\ToggleUfwAction; -use App\Http\Requests\Firewall\ToggleFirewallRequest; use App\Http\Requests\Firewall\CreateFirewallRuleRequest; -use App\Actions\Firewall\BuildUfwRuleSpecAction; +use App\Http\Requests\Firewall\ToggleFirewallRequest; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; class FirewallController extends Controller { - public function index(): \Inertia\Response + public function index(Request $request): \Inertia\Response { - $status = (new GetUfwStatusAction())->execute(); - $rules = (new GetUfwRulesAction())->execute(); - return Inertia::render('Firewall/Index', compact('status', 'rules')); + $status = (new GetUfwStatusAction)->execute(); + $rules = (new GetUfwRulesAction)->execute(); + + $staged = (new GetStagedUfwRulesAction)->execute(); + $panelPort = FirewallSafety::panelHttpPort(); + $safety = [ + 'panelPort' => $panelPort, + 'coversSsh' => FirewallSafety::coversSsh($staged), + 'coversWeb' => FirewallSafety::coversWeb($staged, $panelPort), + 'missing' => FirewallSafety::missingProtections($staged, $panelPort), + 'detectedIp' => $request->ip(), + ]; + + return Inertia::render('Firewall/Index', compact('status', 'rules', 'safety')); } public function toggle(ToggleFirewallRequest $request): RedirectResponse { $enable = (bool) $request->validated('enabled'); - (new ToggleUfwAction())->execute($enable); - session()->flash('success', 'Firewall ' . ($enable ? 'enabled' : 'disabled') . ' successfully.'); + // Lockout guard: never enable UFW without SSH + panel/web allow rules. + // Protects direct API calls too, not just the UI. + if ($enable) { + $panelPort = FirewallSafety::panelHttpPort(); + $missing = FirewallSafety::missingProtections( + (new GetStagedUfwRulesAction)->execute(), + $panelPort + ); + + if (! empty($missing)) { + session()->flash( + 'error', + 'Refusing to enable the firewall — no rule allows '.implode('; ', $missing) + .'. Add the missing rule(s) or use Safe Setup first.' + ); + + return redirect()->route('firewall.index'); + } + } + + (new ToggleUfwAction)->execute($enable); + + session()->flash('success', 'Firewall '.($enable ? 'enabled' : 'disabled').' successfully.'); + + return redirect()->route('firewall.index'); + } + + /** + * Stage a lockout-proof baseline (SSH + HTTP + HTTPS + panel port) and enable UFW. + */ + public function safeSetup(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'ssh_from_ip' => ['nullable', 'ip'], + ]); + + (new SafeSetupFirewallAction)->execute( + FirewallSafety::panelHttpPort(), + $validated['ssh_from_ip'] ?? null + ); + + session()->flash('success', 'Firewall enabled with a safe baseline (SSH, HTTP, HTTPS).'); + return redirect()->route('firewall.index'); } public function store(CreateFirewallRuleRequest $request): RedirectResponse { $validated = $request->validated(); - $spec = (new BuildUfwRuleSpecAction())->execute( + $spec = (new BuildUfwRuleSpecAction)->execute( strtolower($validated['direction']), strtolower($validated['protocol']), trim($validated['ip']), @@ -45,20 +100,22 @@ public function store(CreateFirewallRuleRequest $request): RedirectResponse ); if ($validated['type'] === 'allow') { - (new AddUfwRuleAction())->execute($spec); + (new AddUfwRuleAction)->execute($spec); } else { - (new AddUfwDenyRuleAction())->execute($spec); + (new AddUfwDenyRuleAction)->execute($spec); } - session()->flash('success', 'Rule ' . $validated['type'] . 'ed successfully.'); + session()->flash('success', 'Rule '.$validated['type'].'ed successfully.'); + return redirect()->route('firewall.index'); } public function destroy(string $id): RedirectResponse { - (new DeleteUfwRuleAction())->execute($id); + (new DeleteUfwRuleAction)->execute($id); session()->flash('success', 'Rule deleted successfully.'); + return redirect()->route('firewall.index'); } } diff --git a/app/Http/Controllers/MysqlController.php b/app/Http/Controllers/MysqlController.php index 52f899b..df357e0 100644 --- a/app/Http/Controllers/MysqlController.php +++ b/app/Http/Controllers/MysqlController.php @@ -2,82 +2,40 @@ namespace App\Http\Controllers; -use App\Actions\MySQL\GetCharsetsAndCollationsAction; -use App\Actions\MySQL\GetDatabasesWithStatsAction; use App\Http\Requests\CreateDatabaseRequest; use App\Http\Requests\DeleteDatabaseRequest; use App\Http\Requests\UpdateDatabaseRequest; -use App\Models\Database; -use App\Services\MySQL\CreateDatabaseService; -use App\Services\MySQL\DeleteDatabaseService; -use App\Services\MySQL\UpdateDatabaseService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Http\JsonResponse; -use Inertia\Inertia; -use Illuminate\Support\Facades\Gate; +use Inertia\Response; class MysqlController extends Controller { - public function index(Request $request): \Inertia\Response - { - $user = $request->user(); - $databases = (new GetDatabasesWithStatsAction($user))->execute(); + public function __construct(private DatabasesController $databases) {} - return Inertia::render('Mysql/Index', [ - 'databases' => $databases, - ]); + public function index(Request $request): Response + { + return $this->databases->index($request); } - public function getCharsetsAndCollations(GetCharsetsAndCollationsAction $action): JsonResponse + public function getCharsetsAndCollations(Request $request): JsonResponse { - return response()->json($action->execute()); + return $this->databases->getEngineOptions($request); } public function store(CreateDatabaseRequest $request): RedirectResponse { - $user = $request->user(); - - (new CreateDatabaseService($request->validated(), $user))->handle(); - - session()->flash('success', 'Database created successfully!'); - - return redirect()->route('mysql.index'); + return $this->databases->store($request); } public function update(UpdateDatabaseRequest $request): RedirectResponse { - $user = $request->user(); - $databaseId = $request->integer('id'); - - $database = Database::where('id', $databaseId) - ->where('user_id', $user->id) - ->firstOrFail(); - - Gate::authorize('update', $database); - - (new UpdateDatabaseService($database, $request->validated()))->handle(); - - session()->flash('success', 'Database updated successfully!'); - - return redirect()->route('mysql.index'); + return $this->databases->update($request); } public function destroy(DeleteDatabaseRequest $request): RedirectResponse { - $user = $request->user(); - $databaseId = $request->integer('id'); - - $database = Database::where('id', $databaseId) - ->where('user_id', $user->id) - ->firstOrFail(); - - Gate::authorize('delete', $database); - - (new DeleteDatabaseService($database))->handle(); - - session()->flash('success', 'Database deleted successfully!'); - - return redirect()->route('mysql.index'); + return $this->databases->destroy($request); } } diff --git a/app/Http/Controllers/NotificationPreferencesController.php b/app/Http/Controllers/NotificationPreferencesController.php new file mode 100644 index 0000000..7f7fa1a --- /dev/null +++ b/app/Http/Controllers/NotificationPreferencesController.php @@ -0,0 +1,102 @@ +user(); + + $preferences = NotificationPreference::where('user_id', $user->id) + ->get(['event_type', 'channel', 'enabled']) + ->toArray(); + + return Inertia::render('Profile/Notifications', [ + 'eventTypes' => self::KNOWN_EVENT_TYPES, + 'channels' => self::CHANNELS, + 'preferences' => $preferences, + 'webhookUrl' => $user->webhook_url, + ]); + } + + public function update(Request $request): JsonResponse + { + $validated = $request->validate([ + 'event_type' => ['required', 'string', 'in:'.implode(',', self::KNOWN_EVENT_TYPES)], + 'channel' => ['required', 'string', 'in:'.implode(',', self::CHANNELS)], + 'enabled' => ['required', 'boolean'], + ]); + + NotificationPreference::updateOrCreate( + [ + 'user_id' => $request->user()->id, + 'event_type' => $validated['event_type'], + 'channel' => $validated['channel'], + ], + ['enabled' => $validated['enabled']] + ); + + return response()->json(['success' => true]); + } + + public function updateWebhook(Request $request): JsonResponse + { + $validated = $request->validate([ + 'webhook_url' => ['nullable', 'url', 'max:2048'], + ]); + + $url = $validated['webhook_url']; + + if ($url !== null) { + $parsed = parse_url($url); + $scheme = $parsed['scheme'] ?? ''; + + if (! in_array($scheme, ['http', 'https'], true)) { + throw ValidationException::withMessages([ + 'webhook_url' => ['The webhook URL must use the http or https scheme.'], + ]); + } + + $host = $parsed['host'] ?? ''; + + // Strip IPv6 brackets + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + $host = substr($host, 1, -1); + } + + $ip = gethostbyname($host); + + if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + throw ValidationException::withMessages([ + 'webhook_url' => ['The webhook URL resolves to a private or reserved IP address.'], + ]); + } + } + + $request->user()->update(['webhook_url' => $url]); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Controllers/NotificationsController.php b/app/Http/Controllers/NotificationsController.php new file mode 100644 index 0000000..bb3a9b9 --- /dev/null +++ b/app/Http/Controllers/NotificationsController.php @@ -0,0 +1,39 @@ +user() + ->notifications() + ->latest() + ->get(); + + return Inertia::render('Notifications/Index', compact('notifications')); + } + + public function markAllRead(Request $request): JsonResponse + { + $request->user()->unreadNotifications()->update(['read_at' => now()]); + + return response()->json(['success' => true]); + } + + public function markRead(Request $request, string $id): JsonResponse + { + $notification = $request->user() + ->notifications() + ->findOrFail($id); + + $notification->markAsRead(); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Controllers/OperationsController.php b/app/Http/Controllers/OperationsController.php new file mode 100644 index 0000000..9842102 --- /dev/null +++ b/app/Http/Controllers/OperationsController.php @@ -0,0 +1,16 @@ + Operation::with('user:id,username')->latest()->paginate(30), + ]); + } +} diff --git a/app/Http/Controllers/PHPManagerController.php b/app/Http/Controllers/PHPManagerController.php index e2881fa..b15313f 100644 --- a/app/Http/Controllers/PHPManagerController.php +++ b/app/Http/Controllers/PHPManagerController.php @@ -5,9 +5,18 @@ use App\Models\PhpVersion; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Process; class PHPManagerController extends Controller { + private function isVersionInstalled(string $version): bool + { + $scriptPath = base_path('laranode-scripts/bin/laranode-php-list.sh'); + $result = Process::run(['sudo', 'bash', $scriptPath]); + $installed = json_decode($result->output(), true) ?? []; + + return collect($installed)->contains('version', $version); + } public function getVersions(): JsonResponse { @@ -47,24 +56,33 @@ public function install(Request $request): JsonResponse ]); $version = $request->input('version'); + + if ($this->isVersionInstalled($version)) { + return response()->json([ + 'success' => false, + 'message' => "PHP {$version} is already installed", + ], 409); + } + $scriptPath = base_path('laranode-scripts/bin/laranode-php-install.sh'); // Execute installation script - $output = shell_exec("sudo bash {$scriptPath} {$version} 2>&1"); + $result = Process::run(['sudo', 'bash', $scriptPath, $version]); + $output = $result->output(); // Check if installation was successful if (strpos($output, 'installed successfully') !== false) { return response()->json([ 'success' => true, 'message' => "PHP {$version} installed successfully", - 'output' => $output + 'output' => $output, ]); } return response()->json([ 'success' => false, 'message' => "Failed to install PHP {$version}", - 'output' => $output + 'output' => $output, ], 500); } @@ -88,14 +106,14 @@ public function uninstall(Request $request): JsonResponse return response()->json([ 'success' => true, 'message' => "PHP {$version} uninstalled successfully", - 'output' => $output + 'output' => $output, ]); } return response()->json([ 'success' => false, 'message' => "Failed to uninstall PHP {$version}", - 'output' => $output + 'output' => $output, ], 500); } @@ -123,14 +141,14 @@ public function toggleService(Request $request): JsonResponse return response()->json([ 'success' => true, 'message' => "PHP {$version}-FPM service {$action}d successfully", - 'output' => $output + 'output' => $output, ]); } return response()->json([ 'success' => false, 'message' => "Failed to {$action} PHP {$version}-FPM service", - 'output' => $output + 'output' => $output, ], 500); } @@ -154,14 +172,14 @@ public function restartService(Request $request): JsonResponse return response()->json([ 'success' => true, 'message' => "PHP {$version}-FPM service restarted successfully", - 'output' => $output + 'output' => $output, ]); } return response()->json([ 'success' => false, 'message' => "Failed to restart PHP {$version}-FPM service", - 'output' => $output + 'output' => $output, ], 500); } } diff --git a/app/Http/Controllers/WebsiteController.php b/app/Http/Controllers/WebsiteController.php index 0503858..2598326 100644 --- a/app/Http/Controllers/WebsiteController.php +++ b/app/Http/Controllers/WebsiteController.php @@ -2,16 +2,17 @@ namespace App\Http\Controllers; +use App\Actions\SSL\CheckWebsiteSslStatusAction; +use App\Actions\SSL\RemoveWebsiteSslAction; use App\Http\Requests\CreateWebsiteRequest; +use App\Http\Requests\SwitchRuntimeRequest; use App\Http\Requests\UpdateWebsitePHPVersionRequest; +use App\Jobs\SwitchRuntimeOperationJob; +use App\Models\Operation; use App\Models\Website; -use App\Models\PhpVersion; use App\Services\Websites\CreateWebsiteService; use App\Services\Websites\DeleteWebsiteService; use App\Services\Websites\UpdateWebsitePHPVersionService; -use App\Actions\SSL\GenerateWebsiteSslAction; -use App\Actions\SSL\RemoveWebsiteSslAction; -use App\Actions\SSL\CheckWebsiteSslStatusAction; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Http; @@ -49,7 +50,6 @@ public function store(CreateWebsiteRequest $request) return redirect()->route('websites.index'); } - /** * Update the specified resource in storage. */ @@ -61,13 +61,38 @@ public function update(UpdateWebsitePHPVersionRequest $request, string $id) $validated = $request->validated(); - (new UpdateWebsitePHPVersionService($website, (int) $validated['php_version_id']))->handle(); + try { + (new UpdateWebsitePHPVersionService($website, (int) $validated['php_version_id']))->handle(); + } catch (\InvalidArgumentException $e) { + return redirect()->back()->withErrors(['runtime' => $e->getMessage()]); + } session()->flash('success', 'Website updated successfully.'); return redirect()->route('websites.index'); } + /** + * Switch the PHP runtime for a website (async via OperationJob). + */ + public function switchRuntime(SwitchRuntimeRequest $request, Website $website) + { + Gate::authorize('update', $website); + + $runtime = $request->validated()['runtime']; + + $operation = Operation::create([ + 'user_id' => $request->user()->id, + 'type' => 'runtime.switch', + 'target' => $website->url, + 'status' => 'queued', + ]); + + SwitchRuntimeOperationJob::dispatch($operation, $website, $runtime); + + return response()->json(['operation_id' => $operation->id]); + } + /** * Remove the specified resource from storage. */ @@ -91,24 +116,30 @@ public function toggleSsl(Request $request, Website $website) { Gate::authorize('update', $website); - $request->validate([ - 'enabled' => 'required|boolean' - ]); + $request->validate(['enabled' => 'required|boolean']); + + if ($request->enabled) { + $operation = \App\Models\Operation::create([ + 'user_id' => $request->user()->id, + 'type' => 'ssl.generate', + 'target' => $website->url, + 'status' => 'queued', + ]); + \App\Jobs\GenerateSslOperationJob::dispatch($operation, $website, $request->user()->email); + + return response()->json(['operation_id' => $operation->id]); + } + + // Disable path stays synchronous (fast). try { - if ($request->enabled) { - // Generate SSL certificate - (new GenerateWebsiteSslAction())->execute($website, $request->user()->email); - } else { - // Remove SSL certificate - (new RemoveWebsiteSslAction())->execute($website); - } - - session()->flash('success', $request->enabled ? 'SSL certificate generated successfully' : 'SSL certificate removed successfully'); - return redirect()->route('websites.index'); + (new RemoveWebsiteSslAction)->execute($website); + session()->flash('success', 'SSL certificate removed successfully'); + return redirect()->route('websites.index'); } catch (\Exception $e) { - session()->flash('error', 'Failed to ' . ($request->enabled ? 'generate' : 'remove') . ' SSL certificate: ' . $e->getMessage()); + session()->flash('error', 'Failed to remove SSL certificate: '.$e->getMessage()); + return redirect()->back(); } } @@ -121,19 +152,19 @@ public function checkSslStatus(Website $website) Gate::authorize('view', $website); try { - $result = (new CheckWebsiteSslStatusAction())->execute($website); + $result = (new CheckWebsiteSslStatusAction)->execute($website); return response()->json([ 'success' => true, 'ssl_status' => $result['ssl_status'], 'ssl_enabled' => $result['ssl_enabled'], - 'status_text' => $website->getSslStatusText() + 'status_text' => $website->getSslStatusText(), ]); } catch (\Exception $e) { return response()->json([ 'success' => false, - 'message' => 'Failed to check SSL status: ' . $e->getMessage() + 'message' => 'Failed to check SSL status: '.$e->getMessage(), ], 500); } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index e5d02c0..eae4f78 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -39,6 +39,9 @@ public function share(Request $request): array 'success' => session('success'), 'error' => session('error'), ], + 'notifications' => [ + 'unreadCount' => $request->user()?->unreadNotifications()->count() ?? 0, + ], ]; } } diff --git a/app/Http/Requests/CreateBackupRequest.php b/app/Http/Requests/CreateBackupRequest.php new file mode 100644 index 0000000..47022e2 --- /dev/null +++ b/app/Http/Requests/CreateBackupRequest.php @@ -0,0 +1,44 @@ +user()?->id; + $type = $this->input('type', 'db'); + + if ($type === 'files') { + $targetRule = Rule::exists('websites', 'url')->where('user_id', $userId); + } else { + $targetRule = Rule::exists('databases', 'name')->where('user_id', $userId); + } + + return [ + 'type' => ['required', 'string', Rule::in(['db', 'files'])], + 'target' => ['required', 'string', $targetRule], + 'storage' => ['required', 'string', Rule::in(['local', 's3'])], + 's3_key' => ['required_if:storage,s3', 'nullable', 'string'], + 's3_secret' => ['required_if:storage,s3', 'nullable', 'string'], + 's3_region' => ['required_if:storage,s3', 'nullable', 'string'], + 's3_bucket' => ['required_if:storage,s3', 'nullable', 'string'], + 's3_endpoint' => ['nullable', 'string'], + ]; + } + + public function messages(): array + { + return [ + 'target.exists' => 'The selected target does not belong to your account.', + ]; + } +} diff --git a/app/Http/Requests/CreateDatabaseRequest.php b/app/Http/Requests/CreateDatabaseRequest.php index 5555e38..09714ee 100644 --- a/app/Http/Requests/CreateDatabaseRequest.php +++ b/app/Http/Requests/CreateDatabaseRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Databases\EngineManager; use App\Models\Database; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -11,28 +12,29 @@ class CreateDatabaseRequest extends FormRequest protected function prepareForValidation(): void { $user = $this->user(); - if (!$user) { + if (! $user) { return; } - $prefix = $user->username . '_'; + $prefix = $user->username.'_'; $name = $this->input('name'); $nameSuffix = $this->input('name_suffix'); - if (empty($name) && !empty($nameSuffix)) { + if (empty($name) && ! empty($nameSuffix)) { $this->merge([ - 'name' => $prefix . $nameSuffix, + 'name' => $prefix.$nameSuffix, ]); } $dbUser = $this->input('db_user'); $dbUserSuffix = $this->input('db_user_suffix'); - if (empty($dbUser) && !empty($dbUserSuffix)) { + if (empty($dbUser) && ! empty($dbUserSuffix)) { $this->merge([ - 'db_user' => $prefix . $dbUserSuffix, + 'db_user' => $prefix.$dbUserSuffix, ]); } } + /** * Determine if the user is authorized to make this request. */ @@ -49,25 +51,31 @@ public function authorize(): bool public function rules(): array { $user = $this->user(); - $prefix = $user->username . '_'; + $prefix = $user->username.'_'; + + $availableEngines = array_keys(app(EngineManager::class)->available()); return [ + 'engine' => ['required', 'string', Rule::in($availableEngines)], 'name' => [ 'required', 'string', 'max:64', - 'regex:/^' . preg_quote($prefix) . '[a-zA-Z0-9_]+$/', - 'unique:' . Database::class . ',name' + 'regex:/^'.preg_quote($prefix).'[a-zA-Z0-9_]+$/', + 'unique:'.Database::class.',name', ], 'db_user' => [ 'required', 'string', 'max:32', - 'regex:/^' . preg_quote($prefix) . '[a-zA-Z0-9_]+$/' + 'regex:/^'.preg_quote($prefix).'[a-zA-Z0-9_]+$/', ], 'db_pass' => ['required', 'string', 'min:8'], - 'charset' => ['required', 'string'], - 'collation' => ['required', 'string'], + 'charset' => ['required_if:engine,mysql', 'required_if:engine,mariadb', 'nullable', 'regex:/^[a-zA-Z0-9_]+$/'], + 'collation' => ['required_if:engine,mysql', 'required_if:engine,mariadb', 'nullable', 'regex:/^[a-zA-Z0-9_]+$/'], + // Postgres options. Nullable so the driver/script defaults (UTF8 / en_US.UTF-8) apply when omitted. + 'encoding' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_]+$/'], + 'locale' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_.@-]+$/'], ]; } @@ -77,11 +85,11 @@ public function rules(): array public function messages(): array { $user = $this->user(); - $prefix = $user->username . '_'; + $prefix = $user->username.'_'; return [ - 'name.regex' => 'Database name must start with ' . $prefix . ' and contain only letters, numbers, and underscores.', - 'db_user.regex' => 'Database username must start with ' . $prefix . ' and contain only letters, numbers, and underscores.', + 'name.regex' => 'Database name must start with '.$prefix.' and contain only letters, numbers, and underscores.', + 'db_user.regex' => 'Database username must start with '.$prefix.' and contain only letters, numbers, and underscores.', 'name.unique' => 'A database with this name already exists.', ]; } @@ -90,7 +98,7 @@ protected function withValidator($validator): void { $validator->after(function ($validator) { $user = $this->user(); - if (!$user) { + if (! $user) { return; } @@ -107,4 +115,3 @@ protected function withValidator($validator): void }); } } - diff --git a/app/Http/Requests/CreateScheduledBackupRequest.php b/app/Http/Requests/CreateScheduledBackupRequest.php new file mode 100644 index 0000000..a77e65f --- /dev/null +++ b/app/Http/Requests/CreateScheduledBackupRequest.php @@ -0,0 +1,56 @@ +user()?->id; + $type = $this->input('type', 'db'); + + if ($type === 'files') { + $targetRule = Rule::exists('websites', 'url')->where('user_id', $userId); + } else { + $targetRule = Rule::exists('databases', 'name')->where('user_id', $userId); + } + + return [ + 'type' => ['required', 'string', Rule::in(['db', 'files'])], + 'target' => ['required', 'string', $targetRule], + 'storage' => ['required', 'string', Rule::in(['local', 's3'])], + 'cron_expression' => [ + 'required', + 'string', + function (string $attribute, mixed $value, \Closure $fail) { + if (! CronExpression::isValidExpression($value)) { + $fail('The cron expression is not valid.'); + } + }, + ], + 'retention_count' => ['required', 'integer', 'min:1', 'max:365'], + 's3_key' => ['required_if:storage,s3', 'nullable', 'string'], + 's3_secret' => ['required_if:storage,s3', 'nullable', 'string'], + 's3_region' => ['required_if:storage,s3', 'nullable', 'string'], + 's3_bucket' => ['required_if:storage,s3', 'nullable', 'string'], + 's3_endpoint' => ['nullable', 'string'], + 'enabled' => ['boolean'], + ]; + } + + public function messages(): array + { + return [ + 'target.exists' => 'The selected target does not belong to your account.', + ]; + } +} diff --git a/app/Http/Requests/DbServiceRequest.php b/app/Http/Requests/DbServiceRequest.php new file mode 100644 index 0000000..7387aa8 --- /dev/null +++ b/app/Http/Requests/DbServiceRequest.php @@ -0,0 +1,31 @@ +user()?->isAdmin() ?? false; + } + + /** + * Validate engine against configured keys and action against a closed allowlist. + * Rule::in implicitly rejects leading dashes, control chars, and arbitrary strings. + * + * @return array + */ + public function rules(): array + { + return [ + 'engine' => ['required', 'string', Rule::in(array_keys(config('laranode.db_engines', [])))], + 'action' => ['required', 'string', Rule::in(['start', 'stop', 'restart'])], + ]; + } +} diff --git a/app/Http/Requests/RestoreBackupRequest.php b/app/Http/Requests/RestoreBackupRequest.php new file mode 100644 index 0000000..5531765 --- /dev/null +++ b/app/Http/Requests/RestoreBackupRequest.php @@ -0,0 +1,39 @@ +route('backup'); + + return [ + 'new_target' => [ + 'required', + 'string', + 'regex:/^[a-zA-Z0-9_]{1,64}$/', + function (string $attribute, mixed $value, \Closure $fail) use ($backup) { + if ($backup && $value === $backup->target) { + $fail('The new target must differ from the original backup target.'); + } + }, + ], + ]; + } + + public function messages(): array + { + return [ + 'new_target.required' => 'A new target name is required.', + 'new_target.regex' => 'The new target name may only contain letters, numbers, and underscores (max 64 characters).', + ]; + } +} diff --git a/app/Http/Requests/StoreCronJobRequest.php b/app/Http/Requests/StoreCronJobRequest.php new file mode 100644 index 0000000..663987b --- /dev/null +++ b/app/Http/Requests/StoreCronJobRequest.php @@ -0,0 +1,53 @@ + ['required', 'string', 'max:100', new ValidCronExpression], + 'command' => ['required', 'string', 'max:500', new AllowedCronCommand($this->user())], + 'label' => ['nullable', 'string', 'max:255'], + ]; + } + + protected function withValidator($validator): void + { + $validator->after(function ($validator) { + $user = $this->user(); + if (! $user) { + return; + } + + $count = CronJob::where('user_id', $user->id)->count(); + if ($count >= 50) { + $validator->errors()->add('command', 'You have reached the maximum of 50 cron jobs.'); + + return; + } + + // Surface a meaningful error for the UNIQUE(user_id, schedule, command) + // constraint instead of letting it surface as a 500 from the DB layer. + $schedule = $this->input('schedule'); + $command = $this->input('command'); + if ($schedule && $command && CronJob::where('user_id', $user->id) + ->where('schedule', $schedule) + ->where('command', $command) + ->exists()) { + $validator->errors()->add('command', 'You already have a cron job with this schedule and command.'); + } + }); + } +} diff --git a/app/Http/Requests/SwitchRuntimeRequest.php b/app/Http/Requests/SwitchRuntimeRequest.php new file mode 100644 index 0000000..cfe1b30 --- /dev/null +++ b/app/Http/Requests/SwitchRuntimeRequest.php @@ -0,0 +1,30 @@ +check(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'runtime' => ['required', 'string', Rule::in(['php-fpm', 'frankenphp'])], + ]; + } +} diff --git a/app/Http/Requests/UpdateDatabaseRequest.php b/app/Http/Requests/UpdateDatabaseRequest.php index c212c6d..3e5832b 100644 --- a/app/Http/Requests/UpdateDatabaseRequest.php +++ b/app/Http/Requests/UpdateDatabaseRequest.php @@ -23,8 +23,8 @@ public function rules(): array { return [ 'id' => ['required', 'integer'], - 'charset' => ['required', 'string'], - 'collation' => ['required', 'string'], + 'charset' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_]+$/'], + 'collation' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_]+$/'], 'db_password' => ['nullable', 'string', 'min:8'], ]; } diff --git a/app/Jobs/Analytics/RollupSiteStatsJob.php b/app/Jobs/Analytics/RollupSiteStatsJob.php new file mode 100644 index 0000000..99fa36f --- /dev/null +++ b/app/Jobs/Analytics/RollupSiteStatsJob.php @@ -0,0 +1,23 @@ +collect($this->user, $emit); + + return 0; + } +} diff --git a/app/Jobs/Analytics/RollupUserResourceSnapshotJob.php b/app/Jobs/Analytics/RollupUserResourceSnapshotJob.php new file mode 100644 index 0000000..1932f89 --- /dev/null +++ b/app/Jobs/Analytics/RollupUserResourceSnapshotJob.php @@ -0,0 +1,23 @@ +collect($this->user, $emit); + + return 0; + } +} diff --git a/app/Jobs/BackupJob.php b/app/Jobs/BackupJob.php new file mode 100644 index 0000000..a7967ea --- /dev/null +++ b/app/Jobs/BackupJob.php @@ -0,0 +1,114 @@ +backup; + $user = $backup->user; + + // Re-register the S3 disk from the encrypted credentials stored on the Backup + // row. This is required because queue workers run in a separate process and the + // Config::set() call made during the HTTP request is gone. Local backups skip + // this (s3DiskConfig() returns null). + $s3Config = $backup->s3DiskConfig(); + if ($s3Config !== null) { + Config::set("filesystems.disks.{$backup->disk_name}", $s3Config); + } + + // Storage path: "{userId}/{type}/{target}/{Y-m-d-His}.{ext}" + $ext = $backup->type === 'db' ? 'sql.gz' : 'tar.gz'; + $remotePath = sprintf( + '%d/%s/%s/%s.%s', + $user->id, + $backup->type, + $backup->target, + now()->format('Y-m-d-His'), + $ext + ); + + // Pre-create a placeholder temp path. For db backups the engine driver may + // return its own temp file (a different path). We track both so the original + // placeholder is always cleaned up. + $tempBase = tempnam(sys_get_temp_dir(), 'laranode-backup-'); + $tempPlaceholder = $tempBase.'.'.$ext; + rename($tempBase, $tempPlaceholder); + + // $tempPath points to the actual file that holds the backup data. It starts + // equal to $tempPlaceholder but may be reassigned when the dump driver creates + // its own file (MysqlBackupDriver does this). + $tempPath = $tempPlaceholder; + + try { + if ($backup->type === 'db') { + $database = DatabaseModel::where('name', $backup->target) + ->where('user_id', $user->id) + ->firstOrFail(); + + // Driver may return a different temp path (e.g. the one it created). + // Delete the placeholder before reassigning so it is not leaked. + $driverPath = app(DumpDatabaseAction::class) + ->execute($database, $tempPlaceholder, $emit); + + if ($driverPath !== $tempPlaceholder && file_exists($tempPlaceholder)) { + unlink($tempPlaceholder); + } + + $tempPath = $driverPath; + } else { + $website = Website::where('url', $backup->target) + ->where('user_id', $user->id) + ->firstOrFail(); + + $tempPath = app(TarFilesAction::class) + ->execute($website, $tempPlaceholder, $emit); + } + + $disk = Storage::disk($backup->disk_name); + + app(UploadToStorageAction::class) + ->execute($tempPath, $remotePath, $disk, $emit); + + $sizeBytes = file_exists($tempPath) ? filesize($tempPath) : null; + + $backup->update([ + 'status' => 'completed', + 'path' => $remotePath, + 'size_bytes' => $sizeBytes, + ]); + + $emit('Backup completed successfully.'); + + return 0; + } finally { + // Clean up the driver's temp file (or the placeholder if it was never + // reassigned, e.g. TarFilesAction writes into the placeholder itself). + if (file_exists($tempPath)) { + unlink($tempPath); + } + // Also clean up the placeholder in case the driver returned a different + // path but the unlink above failed or was skipped (e.g. exception before + // the placeholder unlink inside the try block). + if ($tempPath !== $tempPlaceholder && file_exists($tempPlaceholder)) { + unlink($tempPlaceholder); + } + } + } +} diff --git a/app/Jobs/DbServiceOperationJob.php b/app/Jobs/DbServiceOperationJob.php new file mode 100644 index 0000000..0873677 --- /dev/null +++ b/app/Jobs/DbServiceOperationJob.php @@ -0,0 +1,36 @@ +action} {$this->engine}..."); + + $result = Process::run(['sudo', config('laranode.laranode_bin_path').'/laranode-db-service.sh', $this->action, $this->engine]); + + $emit($result->output()); + + if ($result->failed()) { + throw new DbServiceException($result->errorOutput()); + } + + $emit("systemctl {$this->action} {$this->engine} completed."); + + return 0; + } +} diff --git a/app/Jobs/GenerateSslOperationJob.php b/app/Jobs/GenerateSslOperationJob.php new file mode 100644 index 0000000..f90572d --- /dev/null +++ b/app/Jobs/GenerateSslOperationJob.php @@ -0,0 +1,25 @@ +notifyUser = $website->user; + } + + protected function run(callable $emit): int + { + $emit("Generating SSL certificate for {$this->website->url}..."); + (new GenerateWebsiteSslAction)->execute($this->website, $this->email, $emit); + $emit('SSL certificate issued.'); + + return 0; // GenerateWebsiteSslAction throws on failure -> base marks failed + } +} diff --git a/app/Jobs/OperationJob.php b/app/Jobs/OperationJob.php new file mode 100644 index 0000000..93ae26d --- /dev/null +++ b/app/Jobs/OperationJob.php @@ -0,0 +1,62 @@ +operation->markRunning(); + + try { + $exit = $this->run(fn (string $line) => $this->operation->appendOutput($line)); + $this->operation->markFinished($exit); + $this->safeNotify(); + } catch (\Throwable $e) { + $this->operation->appendOutput('ERROR: '.$e->getMessage()); + $this->operation->markFinished(1); + $this->safeNotify(); + throw $e; // also record in failed_jobs + } + } + + /** + * Fire the user notification without letting any delivery failure affect + * the operation status or propagate an exception to the caller. + */ + protected function safeNotify(): void + { + if ($this->notifyUser === null) { + return; + } + + try { + NotificationService::dispatch($this->notifyUser, new OperationFinishedNotification($this->operation)); + } catch (\Throwable $e) { + Log::warning('OperationJob: notification delivery failed', [ + 'operation_id' => $this->operation->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Jobs/RestoreJob.php b/app/Jobs/RestoreJob.php new file mode 100644 index 0000000..af8cdf5 --- /dev/null +++ b/app/Jobs/RestoreJob.php @@ -0,0 +1,156 @@ +backup; + $newTarget = $this->newTarget; + + // Re-register the S3 disk from encrypted credentials on the Backup row, + // same reason as BackupJob: queue worker has no knowledge of disks registered + // during the HTTP request. + $s3Config = $backup->s3DiskConfig(); + if ($s3Config !== null) { + Config::set("filesystems.disks.{$backup->disk_name}", $s3Config); + } + + // Re-validate the new_target identifier inside the job (defence in depth). + if (! preg_match('/^[a-zA-Z0-9_]{1,64}$/', $newTarget)) { + throw new \InvalidArgumentException( + "Invalid restore target identifier: '{$newTarget}'. Must match /^[a-zA-Z0-9_]{1,64}$/." + ); + } + + if ($newTarget === $backup->target) { + throw new \InvalidArgumentException( + "Restore target '{$newTarget}' must differ from the backup source '{$backup->target}'." + ); + } + + // Download backup to a local temp file. + $ext = $backup->type === 'db' ? 'sql.gz' : 'tar.gz'; + $tempFile = tempnam(sys_get_temp_dir(), 'laranode-restore-').'.'.$ext; + + try { + $emit("Downloading backup '{$backup->path}' from disk '{$backup->disk_name}'..."); + $stream = Storage::disk($backup->disk_name)->readStream($backup->path); + $dest = fopen($tempFile, 'wb'); + stream_copy_to_stream($stream, $dest); + fclose($dest); + if (is_resource($stream)) { + fclose($stream); + } + + if ($backup->type === 'db') { + $this->restoreDatabase($backup, $newTarget, $tempFile, $emit); + } else { + $this->restoreFiles($backup, $newTarget, $tempFile, $emit); + } + + $emit('Restore completed successfully.'); + + return 0; + } finally { + if (file_exists($tempFile)) { + unlink($tempFile); + } + } + } + + /** + * Restore a database backup into a new panel-managed database. + */ + private function restoreDatabase(Backup $backup, string $newTarget, string $tempFile, callable $emit): void + { + $user = $backup->user; + $newDbUser = $newTarget.'_u'; + $newPassword = Str::random(16); + + $emit("Creating panel-managed database '{$newTarget}' with user '{$newDbUser}'..."); + + $spec = new DatabaseSpec( + name: $newTarget, + dbUser: $newDbUser, + password: $newPassword, + userId: $user->id, + options: [ + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ], + ); + + // CreateDatabaseService creates the DB + per-site user via the engine driver, + // then persists a Database panel row. Never a bare CREATE DATABASE. + $newDb = app(CreateDatabaseService::class)->handle($spec, 'mysql'); + + // Write a temp .cnf for the restore script so the password never touches argv. + $cnfPath = sys_get_temp_dir().'/laranode-restore-'.uniqid().'.cnf'; + $prevUmask = umask(0177); + file_put_contents($cnfPath, "[client]\npassword={$newDb->db_password}\n"); + umask($prevUmask); + + try { + $emit("Restoring dump into '{$newTarget}'..."); + $binPath = rtrim(config('laranode.laranode_bin_path'), '/'); + $result = Process::run([ + 'sudo', + $binPath.'/laranode-restore-db.sh', + $cnfPath, + $tempFile, + $newTarget, + ]); + + if ($result->failed()) { + throw new \RuntimeException('DB restore failed: '.$result->errorOutput()); + } + } finally { + if (file_exists($cnfPath)) { + unlink($cnfPath); + } + } + } + + /** + * Restore a files backup into the destination directory. + */ + private function restoreFiles(Backup $backup, string $newTarget, string $tempFile, callable $emit): void + { + $user = $backup->user; + $sysUser = $user->systemUsername; + $destDir = $user->homedir.'/domains/'.$newTarget; + + $emit("Restoring files into '{$destDir}'..."); + $binPath = rtrim(config('laranode.laranode_bin_path'), '/'); + $result = Process::run([ + 'sudo', + $binPath.'/laranode-restore-files.sh', + $tempFile, + $destDir, + $sysUser, + ]); + + if ($result->failed()) { + throw new \RuntimeException('Files restore failed: '.$result->errorOutput()); + } + } +} diff --git a/app/Jobs/RetainBackupsJob.php b/app/Jobs/RetainBackupsJob.php new file mode 100644 index 0000000..4e2d07d --- /dev/null +++ b/app/Jobs/RetainBackupsJob.php @@ -0,0 +1,56 @@ +scheduledBackupId); + + // Resolve the disk from an actual completed Backup row for this schedule. + // BackupService records disk_name (and the encrypted S3 creds) on every + // Backup row; ScheduledBackup.disk_name is not reliably set, and the S3 + // disk config does not exist in the queue-worker process. + $sample = Backup::where('user_id', $schedule->user_id) + ->where('type', $schedule->type) + ->where('target', $schedule->target) + ->where('status', 'completed') + ->latest() + ->first(); + + if (! $sample || ! $sample->disk_name) { + return; // Nothing to retain yet. + } + + // Re-register the S3 disk in this worker process if needed + // (same pattern as BackupJob / RestoreJob). + $s3Config = $sample->s3DiskConfig(); + if ($s3Config !== null) { + Config::set("filesystems.disks.{$sample->disk_name}", $s3Config); + } + + $action->execute( + $schedule->user_id, + $schedule->type, + $schedule->target, + $schedule->retention_count, + Storage::disk($sample->disk_name), + ); + } +} diff --git a/app/Jobs/RunScheduledBackupsJob.php b/app/Jobs/RunScheduledBackupsJob.php new file mode 100644 index 0000000..150662f --- /dev/null +++ b/app/Jobs/RunScheduledBackupsJob.php @@ -0,0 +1,64 @@ +each(function (ScheduledBackup $entry) use ($service) { + // Guard: skip if last_run_at is within 50 seconds to avoid double-fire. + if ($entry->last_run_at && $entry->last_run_at->diffInSeconds(now()) < 50) { + return; + } + + $cron = new CronExpression($entry->cron_expression); + + if (! $cron->isDue()) { + return; + } + + $data = [ + 'type' => $entry->type, + 'target' => $entry->target, + 'storage' => $entry->storage, + 's3_key' => $entry->s3_key, + 's3_secret' => $entry->s3_secret, + 's3_region' => $entry->s3_region, + 's3_bucket' => $entry->s3_bucket, + 's3_endpoint' => $entry->s3_endpoint, + ]; + + $service->handle($data, $entry->user); + + RetainBackupsJob::dispatch($entry->id); + + // Stamp last_run_at AFTER dispatching so a service failure does not + // silently suppress the schedule for the next 50+ seconds. + $entry->update(['last_run_at' => now()]); + }); + } +} diff --git a/app/Jobs/SwitchRuntimeOperationJob.php b/app/Jobs/SwitchRuntimeOperationJob.php new file mode 100644 index 0000000..d9ade1a --- /dev/null +++ b/app/Jobs/SwitchRuntimeOperationJob.php @@ -0,0 +1,26 @@ +notifyUser = $website->user; + } + + protected function run(callable $emit): int + { + // Refresh user relation after queue deserialization (FIXED: review fix #10). + $this->website->load('user'); + + (new SwitchRuntimeService($this->website, $this->runtime, $emit))->handle(); + + return 0; + } +} diff --git a/app/Models/Backup.php b/app/Models/Backup.php new file mode 100644 index 0000000..891ceb5 --- /dev/null +++ b/app/Models/Backup.php @@ -0,0 +1,112 @@ + 'pending', + ]; + + protected $casts = [ + 'size_bytes' => 'integer', + 's3_key' => 'encrypted', + 's3_secret' => 'encrypted', + ]; + + // Never expose S3 credentials in JSON / Inertia props. + protected $hidden = [ + 's3_key', + 's3_secret', + ]; + + /** + * Build the Laravel filesystem disk config array for S3 backups. + * Returns null when this backup uses local storage (no S3 creds stored). + * + * @return array|null + */ + public function s3DiskConfig(): ?array + { + if ($this->storage !== 's3' || ! $this->s3_key) { + return null; + } + + return [ + 'driver' => 's3', + 'key' => $this->s3_key, + 'secret' => $this->s3_secret, + 'region' => $this->s3_region ?? 'us-east-1', + 'bucket' => $this->s3_bucket ?? '', + 'url' => $this->s3_endpoint ?: null, + 'endpoint' => $this->s3_endpoint ?: null, + 'use_path_style_endpoint' => ! empty($this->s3_endpoint), + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function operation(): BelongsTo + { + return $this->belongsTo(Operation::class); + } + + public function scopeMine(Builder $query): Builder + { + $user = auth()->user(); + + return $query->when($user && ! $user->isAdmin(), fn ($q) => $q->where('user_id', $user->id)); + } + + public function prunable(): Builder + { + return static::where('created_at', '<', now()->subDays(90)); + } + + /** + * Hook called before each model is mass-pruned. + * Deletes the backup file from its disk so we don't leave orphaned files. + */ + public function pruning(): void + { + if ($this->disk_name && $this->path) { + // Re-register the S3 disk in the scheduler/worker process before deleting. + $s3Config = $this->s3DiskConfig(); + if ($s3Config !== null) { + Config::set("filesystems.disks.{$this->disk_name}", $s3Config); + } + Storage::disk($this->disk_name)->delete($this->path); + } + } +} diff --git a/app/Models/CronJob.php b/app/Models/CronJob.php new file mode 100644 index 0000000..d5fc39b --- /dev/null +++ b/app/Models/CronJob.php @@ -0,0 +1,41 @@ + true, + ]; + + protected $casts = [ + 'active' => 'boolean', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function scopeMine(Builder $query): Builder + { + $user = auth()->user(); + + return $query->when($user && ! $user->isAdmin(), fn ($query) => $query->where('user_id', $user->id)); + } +} diff --git a/app/Models/Database.php b/app/Models/Database.php index fd9b53c..4f9ddea 100644 --- a/app/Models/Database.php +++ b/app/Models/Database.php @@ -2,19 +2,22 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Casts\Attribute; -use Illuminate\Database\Eloquent\Builder; class Database extends Model { + use HasFactory; + protected $fillable = [ 'name', 'db_user', 'db_password', 'charset', 'collation', + 'engine', 'user_id', ]; @@ -32,10 +35,11 @@ public function user(): BelongsTo /** * Get the decrypted database password. + * The 'encrypted' cast on db_password already decrypts on read. */ public function getDecryptedPasswordAttribute(): string { - return decrypt($this->db_password); + return $this->db_password; } /** @@ -49,6 +53,7 @@ public function setPasswordAttribute(string $password): void public function scopeMine(Builder $query): Builder { $user = auth()->user(); - return $query->when($user && !$user->isAdmin(), fn($query) => $query->where('user_id', $user->id)); + + return $query->when($user && ! $user->isAdmin(), fn ($query) => $query->where('user_id', $user->id)); } } diff --git a/app/Models/NotificationPreference.php b/app/Models/NotificationPreference.php new file mode 100644 index 0000000..62d5b71 --- /dev/null +++ b/app/Models/NotificationPreference.php @@ -0,0 +1,55 @@ + 'boolean', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Opt-out model: missing row = enabled. + * Returns true on any DB error (fail-open). + */ + public static function isEnabled(int $userId, string $eventType, string $channel): bool + { + try { + $pref = static::where('user_id', $userId) + ->where('event_type', $eventType) + ->where('channel', $channel) + ->first(); + + if ($pref === null) { + return true; + } + + return (bool) $pref->enabled; + } catch (\Throwable $e) { + Log::warning('NotificationPreference::isEnabled error', [ + 'user_id' => $userId, + 'event_type' => $eventType, + 'channel' => $channel, + 'error' => $e->getMessage(), + ]); + + return true; + } + } +} diff --git a/app/Models/Operation.php b/app/Models/Operation.php new file mode 100644 index 0000000..ee72b6b --- /dev/null +++ b/app/Models/Operation.php @@ -0,0 +1,65 @@ + 'queued', + ]; + + protected $casts = [ + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + 'exit_code' => 'integer', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function scopeMine(Builder $query): Builder + { + $user = auth()->user(); + return $query->when($user && ! $user->isAdmin(), fn ($q) => $q->where('user_id', $user->id)); + } + + public function prunable(): Builder + { + return static::where('created_at', '<', now()->subDays(30)); + } + + public function markRunning(): void + { + $this->update(['status' => 'running', 'started_at' => now()]); + \App\Events\OperationUpdated::dispatch($this, 'status'); + } + + public function appendOutput(string $line): void + { + $this->update(['output' => ($this->output ?? '') . $line . "\n"]); + \App\Events\OperationUpdated::dispatch($this, 'line', $line); + } + + public function markFinished(int $exitCode): void + { + $this->update([ + 'status' => $exitCode === 0 ? 'succeeded' : 'failed', + 'exit_code' => $exitCode, + 'finished_at' => now(), + ]); + \App\Events\OperationUpdated::dispatch($this, 'status'); + } +} diff --git a/app/Models/PhpVersion.php b/app/Models/PhpVersion.php index ef05613..dac5c6e 100644 --- a/app/Models/PhpVersion.php +++ b/app/Models/PhpVersion.php @@ -11,6 +11,8 @@ class PhpVersion extends Model /** @use HasFactory<\Database\Factories\PhpVersionFactory> */ use HasFactory; + protected $fillable = ['version', 'active', 'is_default']; + protected function casts(): array { return [ diff --git a/app/Models/ScheduledBackup.php b/app/Models/ScheduledBackup.php new file mode 100644 index 0000000..02d2414 --- /dev/null +++ b/app/Models/ScheduledBackup.php @@ -0,0 +1,61 @@ + '0 2 * * *', + 'retention_count' => 7, + 'enabled' => true, + ]; + + protected $casts = [ + 's3_key' => 'encrypted', + 's3_secret' => 'encrypted', + 'enabled' => 'boolean', + 'last_run_at' => 'datetime', + ]; + + // Never expose S3 credentials in JSON / Inertia props. + protected $hidden = [ + 's3_key', + 's3_secret', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function scopeMine(Builder $query): Builder + { + $user = auth()->user(); + + return $query->when($user && ! $user->isAdmin(), fn ($q) => $q->where('user_id', $user->id)); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f24a2db..24eb08b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,7 +4,6 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; -use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -14,8 +13,9 @@ class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory; - use Notifiable; + use Impersonate; + use Notifiable; public $appends = ['homedir', 'systemUsername']; @@ -32,7 +32,8 @@ class User extends Authenticatable 'role', 'domain_limit', 'database_limit', - 'ssh_access' + 'ssh_access', + 'webhook_url', ]; /** @@ -43,6 +44,7 @@ class User extends Authenticatable protected $hidden = [ 'password', 'remember_token', + 'webhook_url', ]; /** @@ -56,6 +58,7 @@ protected function casts(): array 'email_verified_at' => 'datetime', 'password' => 'hashed', 'ssh_access' => 'boolean', + 'webhook_url' => 'encrypted', ]; } @@ -67,6 +70,11 @@ public function canImpersonate() return $this->isAdmin(); } + public function canBeImpersonated(): bool + { + return ! $this->isAdmin(); + } + /** * @return bool */ @@ -77,24 +85,27 @@ public function isAdmin() /** * not using casts as it's not working in some scenarios - * @return string */ public function getHomedirAttribute(): string { - return '/home/' . $this->systemUsername; + return '/home/'.$this->systemUsername; } /** * not using casts as it's not working in some scenarios - * @return string */ public function getSystemUsernameAttribute(): string { - return $this->username . '_ln'; + return $this->username.'_ln'; } public function websites(): \Illuminate\Database\Eloquent\Relations\HasMany { return $this->hasMany(Website::class); } + + public function databases(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(Database::class); + } } diff --git a/app/Models/UserResourceSnapshot.php b/app/Models/UserResourceSnapshot.php new file mode 100644 index 0000000..896cdcb --- /dev/null +++ b/app/Models/UserResourceSnapshot.php @@ -0,0 +1,36 @@ + 'datetime', + 'disk_bytes' => 'integer', + 'apache_request_count' => 'integer', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function prunable(): Builder + { + return static::where('snapshotted_at', '<', now()->subDays(90)); + } +} diff --git a/app/Models/UserSiteStat.php b/app/Models/UserSiteStat.php new file mode 100644 index 0000000..16982cc --- /dev/null +++ b/app/Models/UserSiteStat.php @@ -0,0 +1,40 @@ + 'datetime', + 'disk_bytes' => 'integer', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function website(): BelongsTo + { + return $this->belongsTo(Website::class); + } + + public function prunable(): Builder + { + return static::where('snapshotted_at', '<', now()->subDays(90)); + } +} diff --git a/app/Models/Website.php b/app/Models/Website.php index a27f993..e425500 100644 --- a/app/Models/Website.php +++ b/app/Models/Website.php @@ -3,17 +3,20 @@ namespace App\Models; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Website extends Model { + use HasFactory; - protected $appends = ['fullDocumentRoot']; + protected $appends = ['fullDocumentRoot', 'runtime_label']; protected $casts = [ 'ssl_enabled' => 'boolean', 'ssl_expires_at' => 'datetime', 'ssl_generated_at' => 'datetime', + 'runtime_port' => 'integer', ]; protected $fillable = [ @@ -25,28 +28,40 @@ class Website extends Model 'ssl_status', 'ssl_expires_at', 'ssl_generated_at', + 'runtime', + 'runtime_port', ]; public function getWebsiteRootAttribute(): string { - return $this->user?->homedir . '/domains/' . $this->url; + return $this->user?->homedir.'/domains/'.$this->url; } // not using casts as it's not working in some scenarios public function getFullDocumentRootAttribute(): string { - return $this->user?->homedir . '/domains/' . $this->url . $this->document_root; + return $this->user?->homedir.'/domains/'.$this->url.$this->document_root; + } + + public function getRuntimeLabelAttribute(): string + { + return match ($this->runtime) { + 'frankenphp' => 'FrankenPHP', + 'swoole' => 'Swoole (Octane)', + default => 'PHP-FPM', + }; } public function scopeMine(Builder $query): Builder { $user = auth()->user(); - return $query->when($user && !$user->isAdmin(), fn($query) => $query->where('user_id', $user->id)); + + return $query->when($user && ! $user->isAdmin(), fn ($query) => $query->where('user_id', $user->id)); } public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo { - return $this->belongsTo(User::class)->select(['id', 'username', 'role']); + return $this->belongsTo(User::class)->select(['id', 'username', 'role', 'email']); } public function phpVersion(): \Illuminate\Database\Eloquent\Relations\BelongsTo @@ -67,7 +82,7 @@ public function isSslActive(): bool */ public function isSslExpired(): bool { - return $this->ssl_status === 'expired' || + return $this->ssl_status === 'expired' || ($this->ssl_expires_at && $this->ssl_expires_at->isPast()); } @@ -76,7 +91,7 @@ public function isSslExpired(): bool */ public function getSslStatusText(): string { - return match($this->ssl_status) { + return match ($this->ssl_status) { 'active' => 'SSL Active', 'expired' => 'SSL Expired', 'pending' => 'SSL Pending', @@ -89,7 +104,7 @@ public function getSslStatusText(): string */ public function getSslStatusColor(): string { - return match($this->ssl_status) { + return match ($this->ssl_status) { 'active' => 'text-green-600', 'expired' => 'text-red-600', 'pending' => 'text-yellow-600', diff --git a/app/Notifications/BackupResultNotification.php b/app/Notifications/BackupResultNotification.php new file mode 100644 index 0000000..c3754e2 --- /dev/null +++ b/app/Notifications/BackupResultNotification.php @@ -0,0 +1,51 @@ + 'backup.result', + 'backup_name' => $this->backupName, + 'success' => $this->success, + ]; + } + + public function toMail(object $notifiable): MailMessage + { + $status = $this->success ? 'succeeded' : 'failed'; + + return (new MailMessage) + ->subject("Backup {$status}: {$this->backupName}") + ->line("Backup '{$this->backupName}' has {$status}."); + } + + public function toWebhook(object $notifiable): array + { + return [ + 'event_type' => 'backup.result', + 'backup_name' => $this->backupName, + 'success' => $this->success, + ]; + } +} diff --git a/app/Notifications/Channels/WebhookChannel.php b/app/Notifications/Channels/WebhookChannel.php new file mode 100644 index 0000000..a79702e --- /dev/null +++ b/app/Notifications/Channels/WebhookChannel.php @@ -0,0 +1,45 @@ +webhook_url; + + if (empty($url)) { + return; + } + + $parsed = parse_url($url); + if (! in_array($parsed['scheme'] ?? '', ['http', 'https'], true)) { + return; + } + + $host = $parsed['host'] ?? ''; + + // IPv6 literal hosts are wrapped in brackets — strip them + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + $host = substr($host, 1, -1); + } + + $ip = gethostbyname($host); + + if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + Log::warning('WebhookChannel blocked SSRF attempt', ['url' => $url]); + + return; + } + + try { + Http::timeout(10)->post($url, $notification->toWebhook($notifiable)); + } catch (\Throwable $e) { + Log::warning('WebhookChannel delivery failed', ['url' => $url, 'error' => $e->getMessage()]); + } + } +} diff --git a/app/Notifications/DeployResultNotification.php b/app/Notifications/DeployResultNotification.php new file mode 100644 index 0000000..c668416 --- /dev/null +++ b/app/Notifications/DeployResultNotification.php @@ -0,0 +1,53 @@ +success ? 'deploy.success' : 'deploy.failed'; + + return NotificationService::resolveChannels($notifiable, $eventType); + } + + public function toDatabase(object $notifiable): array + { + return [ + 'event_type' => $this->success ? 'deploy.success' : 'deploy.failed', + 'repository' => $this->repository, + 'success' => $this->success, + ]; + } + + public function toMail(object $notifiable): MailMessage + { + $status = $this->success ? 'succeeded' : 'failed'; + + return (new MailMessage) + ->subject("Deploy {$status}: {$this->repository}") + ->line("Deployment of '{$this->repository}' has {$status}."); + } + + public function toWebhook(object $notifiable): array + { + return [ + 'event_type' => $this->success ? 'deploy.success' : 'deploy.failed', + 'repository' => $this->repository, + 'success' => $this->success, + ]; + } +} diff --git a/app/Notifications/Fail2banBanNotification.php b/app/Notifications/Fail2banBanNotification.php new file mode 100644 index 0000000..84a6b00 --- /dev/null +++ b/app/Notifications/Fail2banBanNotification.php @@ -0,0 +1,49 @@ + 'fail2ban.ban', + 'ip' => $this->ip, + 'jail' => $this->jail, + ]; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject("Fail2ban: IP {$this->ip} banned") + ->line("IP {$this->ip} has been banned in jail {$this->jail}."); + } + + public function toWebhook(object $notifiable): array + { + return [ + 'event_type' => 'fail2ban.ban', + 'ip' => $this->ip, + 'jail' => $this->jail, + ]; + } +} diff --git a/app/Notifications/OperationFinishedNotification.php b/app/Notifications/OperationFinishedNotification.php new file mode 100644 index 0000000..3846dbe --- /dev/null +++ b/app/Notifications/OperationFinishedNotification.php @@ -0,0 +1,58 @@ +operation->status === 'failed' + ? 'operation.failed' + : 'operation.finished'; + + return NotificationService::resolveChannels($notifiable, $eventType); + } + + public function toDatabase(object $notifiable): array + { + return [ + 'event_type' => $this->operation->status === 'failed' ? 'operation.failed' : 'operation.finished', + 'operation_id' => $this->operation->id, + 'type' => $this->operation->type, + 'status' => $this->operation->status, + 'exit_code' => $this->operation->exit_code, + ]; + } + + public function toMail(object $notifiable): MailMessage + { + $status = $this->operation->status === 'failed' ? 'failed' : 'finished'; + + return (new MailMessage) + ->subject("Operation {$status}: {$this->operation->type}") + ->line("Your operation '{$this->operation->type}' has {$status}.") + ->line('Exit code: '.($this->operation->exit_code ?? 'N/A')); + } + + public function toWebhook(object $notifiable): array + { + return [ + 'event_type' => $this->operation->status === 'failed' ? 'operation.failed' : 'operation.finished', + 'operation_id' => $this->operation->id, + 'type' => $this->operation->type, + 'status' => $this->operation->status, + 'exit_code' => $this->operation->exit_code, + ]; + } +} diff --git a/app/Notifications/ResourceThresholdNotification.php b/app/Notifications/ResourceThresholdNotification.php new file mode 100644 index 0000000..c60a961 --- /dev/null +++ b/app/Notifications/ResourceThresholdNotification.php @@ -0,0 +1,51 @@ + 'resource.threshold', + 'resource' => $this->resource, + 'value' => $this->value, + 'threshold' => $this->threshold, + ]; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject("Resource threshold exceeded: {$this->resource}") + ->line("Resource {$this->resource} exceeded threshold: {$this->value} > {$this->threshold}."); + } + + public function toWebhook(object $notifiable): array + { + return [ + 'event_type' => 'resource.threshold', + 'resource' => $this->resource, + 'value' => $this->value, + 'threshold' => $this->threshold, + ]; + } +} diff --git a/app/Notifications/SslExpiringNotification.php b/app/Notifications/SslExpiringNotification.php new file mode 100644 index 0000000..0845169 --- /dev/null +++ b/app/Notifications/SslExpiringNotification.php @@ -0,0 +1,50 @@ + 'ssl.expiring', + 'website_id' => $this->website->id, + 'url' => $this->website->url, + 'ssl_expires_at' => $this->website->ssl_expires_at?->toIso8601String(), + ]; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject("SSL certificate expiring soon: {$this->website->url}") + ->line("Your SSL certificate for {$this->website->url} is expiring soon.") + ->line('Expiry: '.($this->website->ssl_expires_at?->toDateString() ?? 'unknown')); + } + + public function toWebhook(object $notifiable): array + { + return [ + 'event_type' => 'ssl.expiring', + 'website_id' => $this->website->id, + 'url' => $this->website->url, + 'ssl_expires_at' => $this->website->ssl_expires_at?->toIso8601String(), + ]; + } +} diff --git a/app/Notifications/SslIssuedNotification.php b/app/Notifications/SslIssuedNotification.php new file mode 100644 index 0000000..820a786 --- /dev/null +++ b/app/Notifications/SslIssuedNotification.php @@ -0,0 +1,51 @@ + 'ssl.issued', + 'website_id' => $this->website->id, + 'url' => $this->website->url, + ]; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject("SSL certificate issued: {$this->website->url}") + ->line("Your SSL certificate for {$this->website->url} has been issued successfully."); + } + + public function toWebhook(object $notifiable): array + { + return [ + 'event_type' => 'ssl.issued', + 'website_id' => $this->website->id, + 'url' => $this->website->url, + ]; + } +} diff --git a/app/Observers/NotificationsObserver.php b/app/Observers/NotificationsObserver.php new file mode 100644 index 0000000..73e35f4 --- /dev/null +++ b/app/Observers/NotificationsObserver.php @@ -0,0 +1,30 @@ +notifiable; + + if (! $notifiable) { + return; + } + + $unreadCount = $notifiable->unreadNotifications()->count(); + + try { + NotificationCreated::dispatch($notifiable->id, $unreadCount); + } catch (\Throwable $e) { + Log::warning('NotificationsObserver: failed to broadcast NotificationCreated', [ + 'error' => $e->getMessage(), + 'notifiable_id' => $notifiable->id, + ]); + } + } +} diff --git a/app/Policies/BackupPolicy.php b/app/Policies/BackupPolicy.php new file mode 100644 index 0000000..d99e460 --- /dev/null +++ b/app/Policies/BackupPolicy.php @@ -0,0 +1,38 @@ +isAdmin() || $user->id === $backup->user_id) + ? Response::allow() + : Response::deny('You are not authorized to view this backup.'); + } + + public function delete(User $user, Backup $backup): Response + { + return ($user->isAdmin() || $user->id === $backup->user_id) + ? Response::allow() + : Response::deny('You are not authorized to delete this backup.'); + } + + public function restore(User $user, Backup $backup): Response + { + return ($user->isAdmin() || $user->id === $backup->user_id) + ? Response::allow() + : Response::deny('You are not authorized to restore this backup.'); + } + + public function download(User $user, Backup $backup): Response + { + return ($user->isAdmin() || $user->id === $backup->user_id) + ? Response::allow() + : Response::deny('You are not authorized to download this backup.'); + } +} diff --git a/app/Policies/CronJobPolicy.php b/app/Policies/CronJobPolicy.php new file mode 100644 index 0000000..34cd9e1 --- /dev/null +++ b/app/Policies/CronJobPolicy.php @@ -0,0 +1,24 @@ +isAdmin() || $user->id === $cronJob->user_id) + ? Response::allow() + : Response::deny('You are not authorized to update this cron job.'); + } + + public function delete(User $user, CronJob $cronJob): Response + { + return ($user->isAdmin() || $user->id === $cronJob->user_id) + ? Response::allow() + : Response::deny('You are not authorized to delete this cron job.'); + } +} diff --git a/app/Policies/ScheduledBackupPolicy.php b/app/Policies/ScheduledBackupPolicy.php new file mode 100644 index 0000000..279ba46 --- /dev/null +++ b/app/Policies/ScheduledBackupPolicy.php @@ -0,0 +1,17 @@ +isAdmin() || $user->id === $scheduledBackup->user_id) + ? Response::allow() + : Response::deny('You are not authorized to delete this scheduled backup.'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2e42e0a..a3d2e1c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,9 +9,10 @@ use App\Actions\Filemanager\PasteFilesAction; use App\Actions\Filemanager\RenameFileAction; use App\Actions\Filemanager\UpdateFileContentsAction; +use App\Observers\NotificationsObserver; +use Illuminate\Notifications\DatabaseNotification; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Config; -use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\Vite; use Illuminate\Support\ServiceProvider; use League\Flysystem\Filesystem; @@ -38,13 +39,16 @@ public function register(): void $this->app->when($laranodeFileManagerClasses) ->needs(Filesystem::class) ->give(function () { - if (!Auth::check()) return null; + if (! Auth::check()) { + return null; + } $userHome = Auth::user()->homedir; Config::set('laranode.user_base_path', $userHome); $adapter = new LocalFilesystemAdapter($userHome, null, LOCK_EX, LocalFilesystemAdapter::DISALLOW_LINKS); + return new Filesystem($adapter); }); } @@ -56,6 +60,8 @@ public function boot(): void { Vite::prefetch(concurrency: 3); + DatabaseNotification::observe(NotificationsObserver::class); + if (Auth::check()) { $user = Auth::user(); Config::set('laranode.user_base_path', $user->homedir); diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 75cdf0a..b026451 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -2,9 +2,15 @@ namespace App\Providers; +use App\Models\Backup; +use App\Models\CronJob; use App\Models\Database; +use App\Models\ScheduledBackup; use App\Models\Website; +use App\Policies\BackupPolicy; +use App\Policies\CronJobPolicy; use App\Policies\DatabasePolicy; +use App\Policies\ScheduledBackupPolicy; use App\Policies\WebsitePolicy; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; @@ -18,6 +24,9 @@ class AuthServiceProvider extends ServiceProvider protected $policies = [ Website::class => WebsitePolicy::class, Database::class => DatabasePolicy::class, + Backup::class => BackupPolicy::class, + ScheduledBackup::class => ScheduledBackupPolicy::class, + CronJob::class => CronJobPolicy::class, ]; /** diff --git a/app/Providers/DatabaseServiceProvider.php b/app/Providers/DatabaseServiceProvider.php new file mode 100644 index 0000000..a38dc07 --- /dev/null +++ b/app/Providers/DatabaseServiceProvider.php @@ -0,0 +1,27 @@ +app->singleton(EngineManager::class); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + // + } +} diff --git a/app/Rules/AllowedCronCommand.php b/app/Rules/AllowedCronCommand.php new file mode 100644 index 0000000..aa76014 --- /dev/null +++ b/app/Rules/AllowedCronCommand.php @@ -0,0 +1,85 @@ +', '<', '$', '`', '\\', '%']; + + public function __construct(private readonly User $user) {} + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || $value === '') { + $fail('The :attribute must be a non-empty command.'); + + return; + } + + // Reject control characters (newline/CR/tab/null/etc.) FIRST — these enable + // crontab line-injection and bypass whitespace tokenisation of the path. + if (preg_match('/[\x00-\x1F\x7F]/', $value)) { + $fail('The :attribute must not contain control characters.'); + + return; + } + + // Reject any shell / crontab metacharacters + foreach (self::SHELL_METACHARACTERS as $meta) { + if (str_contains($value, $meta)) { + $fail('The :attribute must not contain shell metacharacters.'); + + return; + } + } + + // Must start with "php " — no other executables in v1 + if (! str_starts_with($value, 'php ')) { + $fail('The :attribute must start with "php" (only php commands are allowed in v1).'); + + return; + } + + // Reject flags that take a path argument or allow arbitrary code execution + // php -r (run inline), php -f (run file via flag), and any other - before the path + $parts = preg_split('/\s+/', $value, 3); + // $parts[0] = 'php', $parts[1] = first argument + if (count($parts) < 2) { + $fail('The :attribute is incomplete.'); + + return; + } + + $firstArg = $parts[1] ?? ''; + + // Any argument starting with '-' is a flag — reject it + if (str_starts_with($firstArg, '-')) { + $fail('The :attribute must not use php flags (e.g. -r, -f).'); + + return; + } + + // The path must be within the user's own homedir. + // Reject any path containing '..' to prevent directory traversal bypass + // (e.g. 'php /home/alice_ln/../other_ln/evil.php' would otherwise pass str_starts_with). + if (str_contains($firstArg, '..')) { + $fail('The :attribute path must not contain directory traversal sequences (..).'); + + return; + } + + $homedir = $this->user->homedir; + + if (! str_starts_with($firstArg, $homedir.'/')) { + $fail("The :attribute path must be within your home directory ({$homedir})."); + + return; + } + } +} diff --git a/app/Rules/ValidCronExpression.php b/app/Rules/ValidCronExpression.php new file mode 100644 index 0000000..0c0987c --- /dev/null +++ b/app/Rules/ValidCronExpression.php @@ -0,0 +1,116 @@ + [$minute, 0, 59], + 'hour' => [$hour, 0, 23], + 'dom' => [$dom, 1, 31], + 'month' => [$month, 1, 12], + 'dow' => [$dow, 0, 7], + ]; + + foreach ($checks as $name => [$field, $min, $max]) { + if (! $this->validateField($field, $min, $max)) { + $fail("The :attribute has an invalid {$name} field: {$field}."); + + return; + } + } + } + + private function validateField(string $field, int $min, int $max): bool + { + // Comma-separated list: each part must be valid + if (str_contains($field, ',')) { + foreach (explode(',', $field) as $part) { + if (! $this->validatePart($part, $min, $max)) { + return false; + } + } + + return true; + } + + return $this->validatePart($field, $min, $max); + } + + private function validatePart(string $part, int $min, int $max): bool + { + // Wildcard: * + if ($part === '*') { + return true; + } + + // Step: */n or range/n + if (str_contains($part, '/')) { + [$base, $step] = explode('/', $part, 2); + + if (! ctype_digit($step) || (int) $step === 0) { + return false; + } + + // Base can be * or a range + if ($base === '*') { + return true; + } + + return $this->validateRange($base, $min, $max); + } + + // Range: n-m + if (str_contains($part, '-')) { + return $this->validateRange($part, $min, $max); + } + + // Plain number + if (! ctype_digit($part)) { + return false; + } + + $n = (int) $part; + + return $n >= $min && $n <= $max; + } + + private function validateRange(string $range, int $min, int $max): bool + { + if (! str_contains($range, '-')) { + return false; + } + + [$start, $end] = explode('-', $range, 2); + + if (! ctype_digit($start) || ! ctype_digit($end)) { + return false; + } + + $s = (int) $start; + $e = (int) $end; + + return $s >= $min && $e <= $max && $s <= $e; + } +} diff --git a/app/Services/Accounts/CreateAccountService.php b/app/Services/Accounts/CreateAccountService.php index 695bbf7..cea6e40 100644 --- a/app/Services/Accounts/CreateAccountService.php +++ b/app/Services/Accounts/CreateAccountService.php @@ -34,7 +34,7 @@ public function handle(): void // notify user if requested // TODO: implement notification (mail) - if ($this->validated['notify']) { + if ($this->validated['notify'] ?? false) { \Illuminate\Support\Facades\Log::info('Would notify ' . $user->email); } } diff --git a/app/Services/Analytics/UserAnalyticsService.php b/app/Services/Analytics/UserAnalyticsService.php new file mode 100644 index 0000000..00530c6 --- /dev/null +++ b/app/Services/Analytics/UserAnalyticsService.php @@ -0,0 +1,64 @@ +id) + ->where('snapshotted_at', '>=', now()->subDays($days)) + ->orderBy('snapshotted_at') + ->get(); + } + + /** + * Get per-site disk stats for a user (explicit where — no scopeMine). + */ + public function getSiteStats(User $user, int $days = 30): Collection + { + return UserSiteStat::where('user_id', $user->id) + ->with('website:id,url') + ->where('snapshotted_at', '>=', now()->subDays($days)) + ->orderBy('snapshotted_at') + ->get(); + } + + /** + * Get quota summary for a user. + * + * User::databases() relation MUST exist (added in Task 1). Without it + * $user->databases() throws a BadMethodCallException. + */ + public function getQuotaSummary(User $user): array + { + return [ + 'websites_count' => Website::where('user_id', $user->id)->count(), + 'websites_limit' => $user->domain_limit, + 'databases_count' => $user->databases()->count(), + 'databases_limit' => $user->database_limit, + ]; + } + + /** + * Get SSL overview for the user's sites. + * + * Returns ssl_expires_at as-is (nullable). No computation — the frontend + * guards null before computing expiry warnings. + */ + public function getSslOverview(User $user): Collection + { + return Website::where('user_id', $user->id) + ->select(['id', 'url', 'ssl_enabled', 'ssl_status', 'ssl_expires_at']) + ->get(); + } +} diff --git a/app/Services/Analytics/UserResourceSnapshotService.php b/app/Services/Analytics/UserResourceSnapshotService.php new file mode 100644 index 0000000..3aec7eb --- /dev/null +++ b/app/Services/Analytics/UserResourceSnapshotService.php @@ -0,0 +1,54 @@ +username); + + $duResult = Process::run(['du', '-sb', $user->homedir]); + + if ($duResult->failed()) { + throw new \RuntimeException( + 'du failed for user '.$user->username.': '.$duResult->errorOutput() + ); + } + + // Output format: "12345\t/home/username_ln" + $diskBytes = (int) explode("\t", trim($duResult->output()))[0]; + + $emit('Collecting Apache request count for user: '.$user->username); + + $logPath = $user->homedir.'/logs/apache-access.log'; + $wcResult = Process::run(['wc', '-l', $logPath]); + + $requestCount = null; + if ($wcResult->successful()) { + // Output format: "77 /home/username_ln/logs/apache-access.log" + $requestCount = (int) trim(explode(' ', trim($wcResult->output()))[0]); + } + + UserResourceSnapshot::create([ + 'user_id' => $user->id, + 'snapshotted_at' => now(), + 'disk_bytes' => $diskBytes, + 'apache_request_count' => $requestCount, + ]); + + $emit('Snapshot recorded for user: '.$user->username); + } +} diff --git a/app/Services/Analytics/UserSiteStatsService.php b/app/Services/Analytics/UserSiteStatsService.php new file mode 100644 index 0000000..85bb0c9 --- /dev/null +++ b/app/Services/Analytics/UserSiteStatsService.php @@ -0,0 +1,53 @@ +websiteRoot accessor, which + * requires an eager-loaded user relation to avoid null homedir). + * + * @throws \RuntimeException if `du` fails for any site. + */ + public function collect(User $user, callable $emit): void + { + $sites = Website::where('user_id', $user->id)->get(); + + foreach ($sites as $site) { + // Build path from $user->homedir to avoid null-homedir bug in + // $site->websiteRoot when the user relation is not eager-loaded. + $siteRoot = $user->homedir.'/domains/'.$site->url; + + $emit('Collecting disk usage for site: '.$site->url); + + $duResult = Process::run(['du', '-sb', $siteRoot]); + + if ($duResult->failed()) { + throw new \RuntimeException( + 'du failed for site '.$site->url.': '.$duResult->errorOutput() + ); + } + + // Output format: "12345\t/home/username_ln/domains/example.com" + $diskBytes = (int) explode("\t", trim($duResult->output()))[0]; + + UserSiteStat::create([ + 'website_id' => $site->id, + 'user_id' => $user->id, + 'snapshotted_at' => now(), + 'disk_bytes' => $diskBytes, + ]); + + $emit('Site stat recorded for: '.$site->url); + } + } +} diff --git a/app/Services/Backups/BackupService.php b/app/Services/Backups/BackupService.php new file mode 100644 index 0000000..8a69e95 --- /dev/null +++ b/app/Services/Backups/BackupService.php @@ -0,0 +1,61 @@ +id; + } else { + $diskName = 'backups'; + } + + $operation = Operation::create([ + 'user_id' => $user->id, + 'type' => 'backup.'.($data['type'] ?? 'db'), + 'target' => $data['target'] ?? '', + 'status' => 'queued', + ]); + + // S3 credentials are stored encrypted on the Backup row so BackupJob can + // re-register the disk inside the queue worker process (where the Config + // set during the request is no longer present). + $backup = Backup::create([ + 'user_id' => $user->id, + 'operation_id' => $operation->id, + 'type' => $data['type'] ?? 'db', + 'target' => $data['target'] ?? '', + 'storage' => $storage, + 'disk_name' => $diskName, + 's3_key' => $data['s3_key'] ?? null, + 's3_secret' => $data['s3_secret'] ?? null, + 's3_region' => $data['s3_region'] ?? null, + 's3_bucket' => $data['s3_bucket'] ?? null, + 's3_endpoint' => $data['s3_endpoint'] ?? null, + 'status' => 'pending', + ]); + + BackupJob::dispatch($operation, $backup); + + return $operation; + } +} diff --git a/app/Services/Backups/RestoreService.php b/app/Services/Backups/RestoreService.php new file mode 100644 index 0000000..8bd7b81 --- /dev/null +++ b/app/Services/Backups/RestoreService.php @@ -0,0 +1,31 @@ + $user->id, + 'type' => 'restore.'.$backup->type, + 'target' => $newTarget, + 'status' => 'queued', + ]); + + RestoreJob::dispatch($operation, $backup, $newTarget); + + return $operation; + } +} diff --git a/app/Services/CronJobs/CreateCronJobService.php b/app/Services/CronJobs/CreateCronJobService.php new file mode 100644 index 0000000..4da343f --- /dev/null +++ b/app/Services/CronJobs/CreateCronJobService.php @@ -0,0 +1,49 @@ +id)->where('active', true)->get(); + + $lines = $jobs->map(fn ($job) => $job->schedule."\t".$job->command)->implode("\n"); + + file_put_contents($tmpFile, $lines); + + $result = Process::run([ + 'sudo', + config('laranode.laranode_bin_path').'/laranode-cron.sh', + 'set', + $user->systemUsername, + $tmpFile, + ]); + + if ($result->failed()) { + throw new CreateCronJobException( + 'laranode-cron.sh set failed: '.$result->errorOutput() + ); + } + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } +} diff --git a/app/Services/CronJobs/DeleteCronJobService.php b/app/Services/CronJobs/DeleteCronJobService.php new file mode 100644 index 0000000..40b1b6f --- /dev/null +++ b/app/Services/CronJobs/DeleteCronJobService.php @@ -0,0 +1,53 @@ +id) + ->where('active', true) + ->where('id', '!=', $excludeJob->id) + ->get(); + + $lines = $jobs->map(fn ($job) => $job->schedule."\t".$job->command)->implode("\n"); + + file_put_contents($tmpFile, $lines); + + $result = Process::run([ + 'sudo', + config('laranode.laranode_bin_path').'/laranode-cron.sh', + 'set', + $user->systemUsername, + $tmpFile, + ]); + + if ($result->failed()) { + throw new DeleteCronJobException( + 'laranode-cron.sh set failed: '.$result->errorOutput() + ); + } + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + } +} diff --git a/app/Services/Dashboard/GpuStatsService.php b/app/Services/Dashboard/GpuStatsService.php new file mode 100644 index 0000000..da8f705 --- /dev/null +++ b/app/Services/Dashboard/GpuStatsService.php @@ -0,0 +1,233 @@ +detectNvidia() + ?? $this->detectAmd() + ?? ['detected' => false, 'vendor' => null, 'name' => null, 'tool' => null]; + + Option::update_option(self::OPTION, json_encode($profile)); + + return $profile; + } + + /** + * The persisted profile (never probes the hardware). + * + * @return array{detected:bool,vendor:?string,name:?string,tool:?string} + */ + public function profile(): array + { + $raw = Option::get_option(self::OPTION); + $decoded = $raw ? json_decode($raw, true) : null; + + return is_array($decoded) ? $decoded : ['detected' => false, 'vendor' => null, 'name' => null, 'tool' => null]; + } + + /** + * Live GPU stats, or null when no GPU was detected (no probe is run in that + * case — this keeps GPU-less hosts from shelling out every poll). + * + * @return array{vendor:string,name:?string,util:float,vramUsed:float,vramTotal:float,temp:int,power:int}|null + */ + public function stats(): ?array + { + $profile = $this->profile(); + if (empty($profile['detected'])) { + return null; + } + + return match ($profile['vendor'] ?? null) { + 'nvidia' => $this->nvidiaStats($profile['name'] ?? null), + 'amd' => $this->amdStats($profile['name'] ?? null), + default => null, + }; + } + + private function hasBinary(string $bin): bool + { + return Process::run(['bash', '-lc', 'command -v '.$bin])->successful(); + } + + private function detectNvidia(): ?array + { + if (! $this->hasBinary('nvidia-smi')) { + return null; + } + + $proc = Process::run(['nvidia-smi', '--query-gpu=name', '--format=csv,noheader']); + if ($proc->failed()) { + return null; + } + + $name = trim((string) strtok($proc->output(), "\n")); + if ($name === '') { + return null; + } + + return ['detected' => true, 'vendor' => 'nvidia', 'name' => $name, 'tool' => 'nvidia-smi']; + } + + private function detectAmd(): ?array + { + if (! $this->hasBinary('rocm-smi')) { + return null; + } + + $proc = Process::run(['rocm-smi', '--showproductname', '--json']); + if ($proc->failed()) { + return null; + } + + $name = self::parseAmdName($proc->output()); + if ($name === null) { + return null; + } + + return ['detected' => true, 'vendor' => 'amd', 'name' => $name, 'tool' => 'rocm-smi']; + } + + private function nvidiaStats(?string $name): ?array + { + $proc = Process::run([ + 'nvidia-smi', + '--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw', + '--format=csv,noheader,nounits', + ]); + if ($proc->failed()) { + return null; + } + + return self::parseNvidiaStats($proc->output(), $name); + } + + private function amdStats(?string $name): ?array + { + $proc = Process::run([ + 'rocm-smi', '--showuse', '--showmeminfo', 'vram', '--showtemp', '--showpower', '--json', + ]); + if ($proc->failed()) { + return null; + } + + return self::parseAmdStats($proc->output(), $name); + } + + // ---- pure parsers (unit-tested without a GPU) ------------------------- + + /** + * Parse `nvidia-smi --query-gpu=...,--format=csv,noheader,nounits`. + * Line: "42, 1536, 8192, 61, 120.50" (util%, memUsed MiB, memTotal MiB, tempC, powerW) + */ + public static function parseNvidiaStats(string $output, ?string $name): ?array + { + $line = trim((string) strtok($output, "\n")); + if ($line === '') { + return null; + } + + $p = array_map('trim', explode(',', $line)); + + return [ + 'vendor' => 'nvidia', + 'name' => $name, + 'util' => (float) ($p[0] ?? 0), + 'vramUsed' => round(((float) ($p[1] ?? 0)) / 1024, 2), // MiB -> GB + 'vramTotal' => round(((float) ($p[2] ?? 0)) / 1024, 2), + 'temp' => (int) ($p[3] ?? 0), + 'power' => (int) round((float) ($p[4] ?? 0)), + ]; + } + + /** First product name from `rocm-smi --showproductname --json`. */ + public static function parseAmdName(string $output): ?string + { + $data = json_decode($output, true); + if (! is_array($data)) { + return null; + } + + foreach ($data as $card) { + if (! is_array($card)) { + continue; + } + foreach ($card as $key => $val) { + if (stripos($key, 'Card series') !== false || stripos($key, 'Card model') !== false || stripos($key, 'product name') !== false) { + return trim((string) $val); + } + } + } + + return 'AMD GPU'; + } + + /** + * Parse `rocm-smi --showuse --showmeminfo vram --showtemp --showpower --json`. + * Keys vary across rocm versions, so match defensively by substring. + */ + public static function parseAmdStats(string $output, ?string $name): ?array + { + $data = json_decode($output, true); + if (! is_array($data)) { + return null; + } + + $card = null; + foreach ($data as $value) { + if (is_array($value)) { + $card = $value; + break; + } + } + if ($card === null) { + return null; + } + + $find = function (array $needles) use ($card): ?string { + foreach ($card as $key => $val) { + foreach ($needles as $needle) { + if (stripos($key, $needle) !== false) { + return (string) $val; + } + } + } + + return null; + }; + + $usedBytes = (float) ($find(['VRAM Total Used Memory']) ?? 0); + $totalBytes = (float) ($find(['VRAM Total Memory']) ?? 0); + + return [ + 'vendor' => 'amd', + 'name' => $name, + 'util' => (float) ($find(['GPU use (%)', 'GPU use']) ?? 0), + 'vramUsed' => round($usedBytes / 1024 / 1024 / 1024, 2), // bytes -> GB + 'vramTotal' => round($totalBytes / 1024 / 1024 / 1024, 2), + 'temp' => (int) round((float) ($find(['Temperature (Sensor edge)', 'Temperature']) ?? 0)), + 'power' => (int) round((float) ($find(['Average Graphics Package Power', 'Power']) ?? 0)), + ]; + } +} diff --git a/app/Services/Dashboard/SystemStatsService.php b/app/Services/Dashboard/SystemStatsService.php index 92ecf75..152f134 100644 --- a/app/Services/Dashboard/SystemStatsService.php +++ b/app/Services/Dashboard/SystemStatsService.php @@ -16,7 +16,6 @@ public function getCpuUsage(): string return trim(Process::run('top -bn1 | grep "Cpu(s)" | awk \'{print $2+$4}\'')->output()); } - /** * Fetch memory usage. */ @@ -24,7 +23,7 @@ public function getMemoryUsage(): array { $memory = Process::pipe([ 'free -m', - "awk '/Mem:/ {print $4,$3,$6,$2}'" + "awk '/Mem:/ {print $4,$3,$6,$2}'", ]); if ($memory->failed()) { @@ -32,13 +31,13 @@ public function getMemoryUsage(): array } $stats = $memory->output(); - $stats = explode(" ", $stats); + $stats = explode(' ', $stats); return [ 'free' => $stats[0] ?? 0, 'used' => $stats[1] ?? 0, 'buffcache' => $stats[2] ?? 0, - 'total' => $stats[3] ?? 0 + 'total' => $stats[3] ?? 0, ]; } @@ -56,7 +55,7 @@ public function getDiskUsage(): array 'size' => $diskUsageParts[0], 'used' => $diskUsageParts[1], 'free' => $diskUsageParts[2], - 'percent' => $diskUsageParts[3] + 'percent' => $diskUsageParts[3], ]; } @@ -112,7 +111,7 @@ public function getApacheStatus(): array return [ 'status' => $status, - 'memory' => $memory + 'memory' => $memory, ]; } @@ -125,31 +124,45 @@ public function getNginxStatus(): string } /** - * Fetch MySQL Server status. + * Fetch status for a named systemd service. + * + * @return array{pid: mixed, memory: mixed, cpuTime: mixed, uptime: mixed} */ - public function getMysqlStatus(): array + private function getServiceStatus(string $service): array { - /* $mysqlStatus = Process::run('systemctl status mysql')->output(); */ - - $mysqlStatus = Process::pipe([ - 'systemctl status mysql', - "awk '/PID:/ {pid=$3} /Memory:/ {mem=$2} /CPU:/ {cpu=$2\" \"$3\" \"$4} /Active:/ {split($0,a,\";\"); active=a[2]} END {print pid,\"|\",mem,\"|\",cpu,\"|\",active}'" + $output = Process::pipe([ + ['systemctl', 'status', $service], + "awk '/PID:/ {pid=$3} /Memory:/ {mem=$2} /CPU:/ {cpu=$2\" \"$3\" \"$4} /Active:/ {split($0,a,\";\"); active=a[2]} END {print pid,\"|\",mem,\"|\",cpu,\"|\",active}'", ])->output(); - $mysqlStatus = explode("|", $mysqlStatus); - - $mysqlStatus = array_map(function ($item) { - return trim($item); - }, $mysqlStatus); + $parts = array_map('trim', explode('|', $output)); return [ - 'pid' => $mysqlStatus[0] ?? 0, - 'memory' => $mysqlStatus[1] ?? 0, - 'cpuTime' => $mysqlStatus[2] ?? 0, - 'uptime' => $mysqlStatus[3] ?? 0 + 'pid' => $parts[0] ?? 0, + 'memory' => $parts[1] ?? 0, + 'cpuTime' => $parts[2] ?? 0, + 'uptime' => $parts[3] ?? 0, ]; } + /** + * Return status for all active DB engines, keyed by engine name. + * + * @return array + */ + public function getDbEnginesStatus(): array + { + $activeEngines = (new \App\Databases\EngineManager)->available(); + + $result = []; + + foreach ($activeEngines as $engineKey => $serviceName) { + $result[$engineKey] = $this->getServiceStatus($serviceName); + } + + return $result; + } + /** * Fetch PHP-FPM status. */ @@ -163,7 +176,7 @@ public function getPhpFpmStatus(): array $output = Process::pipe([ 'systemctl list-unit-files --type=service', 'grep php.*fpm | awk \'{print $1}\'', - "awk '{print $1}'" + "awk '{print $1}'", ]); if ($output->failed()) { @@ -178,32 +191,34 @@ public function getPhpFpmStatus(): array foreach ($phpFpmServices as $service) { $status = Process::pipe([ - 'systemctl status ' . $service, - "awk '/PID:/ {pid=$3} /Memory:/ {mem=$2} /CPU:/ {cpu=$2\" \"$3\" \"$4} /Active:/ {split($0,a,\";\"); active=a[2]} END {print pid,\"|\",mem,\"|\",cpu,\"|\",active}'" + 'systemctl status '.$service, + "awk '/PID:/ {pid=$3} /Memory:/ {mem=$2} /CPU:/ {cpu=$2\" \"$3\" \"$4} /Active:/ {split($0,a,\";\"); active=a[2]} END {print pid,\"|\",mem,\"|\",cpu,\"|\",active}'", ])->output(); - $status = explode("|", $status); + $status = explode('|', $status); $status = array_map(function ($item) { return trim($item); - }, $status); + }, $status); - $phpFpmStatuses[strtoupper(str_ireplace(".service", "", $service))] = [ + $phpFpmStatuses[strtoupper(str_ireplace('.service', '', $service))] = [ 'pid' => $status[0] ?? 0, 'memory' => $status[1] ?? 0, 'cpuTime' => $status[2] ?? 0, - 'uptime' => $status[3] ?? 0 + 'uptime' => $status[3] ?? 0, ]; } return $phpFpmStatuses; } + /** * Fetch SSL (Let's Encrypt) status. */ public function getSslStatus(): string { $sslStatus = Process::run('certbot certificates | grep "VALID"')->output(); + return $sslStatus ? 'Active' : 'Inactive'; } @@ -213,6 +228,7 @@ public function getSslStatus(): string public function getNginxPort(): string { $nginxPort = Process::run('netstat -nltp | grep nginx | awk \'{print $4}\'')->output(); + return $nginxPort; } @@ -222,6 +238,7 @@ public function getNginxPort(): string public function getWhoami(): string { $whoami = Process::run('whoami')->output(); + return $whoami; } @@ -264,7 +281,7 @@ public function getNetworkStats() foreach ($lines as $line) { if (preg_match('/^\s*(\S+):\s*(\d+)\s+(\d+)/', trim($line), $matches)) { $stats[] = [ - 'interface' => rtrim($matches[1], ":"), + 'interface' => rtrim($matches[1], ':'), 'rx' => round($matches[2] / $gb, 2), 'tx' => round($matches[3] / $gb, 2), ]; @@ -274,31 +291,31 @@ public function getNetworkStats() return $stats; } - /** * Fetch all system stats. */ public function getAllStats(): array { $stats = [ - /*'whoami' => $this->getWhoami(),*/ - 'cpuStats' => [ - 'usage' => $this->getCpuUsage(), - 'loadTimes' => $this->getLoadTimes(), - 'uptime' => $this->getUptime(), + /* 'whoami' => $this->getWhoami(), */ + 'cpuStats' => [ + 'usage' => $this->getCpuUsage(), + 'loadTimes' => $this->getLoadTimes(), + 'uptime' => $this->getUptime(), 'processCount' => $this->getProcessCount(), ], - 'diskStats' => $this->getDiskUsage(), - 'memoryStats' => $this->getMemoryUsage(), - /*'nginxStatus' => $this->getNginxStatus(),*/ - 'phpFpm' => $this->getPhpFpmStatus(), - /*'sslStatus' => $this->getSslStatus(),*/ - /*'nginxPort' => $this->getNginxPort(),*/ - 'apache' => $this->getApacheStatus(), - 'mysql' => $this->getMysqlStatus(), - 'network' => $this->getNetworkStats(), - 'domainCount' => rand(1, 100), - 'userCount' => $this->getUserCount(), + 'diskStats' => $this->getDiskUsage(), + 'memoryStats' => $this->getMemoryUsage(), + 'gpu' => (new GpuStatsService)->stats(), + /* 'nginxStatus' => $this->getNginxStatus(), */ + 'phpFpm' => $this->getPhpFpmStatus(), + /* 'sslStatus' => $this->getSslStatus(), */ + /* 'nginxPort' => $this->getNginxPort(), */ + 'apache' => $this->getApacheStatus(), + 'dbEngines' => $this->getDbEnginesStatus(), + 'network' => $this->getNetworkStats(), + 'domainCount' => rand(1, 100), + 'userCount' => $this->getUserCount(), ]; return $stats; diff --git a/app/Services/Database/CreateDatabaseService.php b/app/Services/Database/CreateDatabaseService.php new file mode 100644 index 0000000..5b87897 --- /dev/null +++ b/app/Services/Database/CreateDatabaseService.php @@ -0,0 +1,39 @@ +driver->create($spec); + } catch (Exception $e) { + throw new CreateDatabaseException('Failed to create database: '.$e->getMessage(), 0, $e); + } + + return Database::create([ + 'name' => $spec->name, + 'db_user' => $spec->dbUser, + 'db_password' => $spec->password, + 'charset' => $spec->options['charset'] ?? null, + 'collation' => $spec->options['collation'] ?? null, + 'engine' => $engine, + 'user_id' => $spec->userId, + ]); + } +} diff --git a/app/Services/Database/DbServiceStatusService.php b/app/Services/Database/DbServiceStatusService.php new file mode 100644 index 0000000..e366089 --- /dev/null +++ b/app/Services/Database/DbServiceStatusService.php @@ -0,0 +1,33 @@ + + */ + public function handle(): array + { + $available = $this->engineManager->available(); + $engines = config('laranode.db_engines', []); + $statuses = []; + + foreach ($engines as $key => $config) { + $statuses[$key] = [ + 'service' => $config['service'], + 'active' => isset($available[$key]), + ]; + } + + return $statuses; + } +} diff --git a/app/Services/Database/DeleteDatabaseService.php b/app/Services/Database/DeleteDatabaseService.php new file mode 100644 index 0000000..1e6d6b8 --- /dev/null +++ b/app/Services/Database/DeleteDatabaseService.php @@ -0,0 +1,30 @@ +driver->delete($database); + } catch (Exception $e) { + throw new DeleteDatabaseException('Failed to delete database: '.$e->getMessage(), 0, $e); + } + + $database->delete(); + } +} diff --git a/app/Services/Database/GetDatabasesWithStatsService.php b/app/Services/Database/GetDatabasesWithStatsService.php new file mode 100644 index 0000000..06bd25e --- /dev/null +++ b/app/Services/Database/GetDatabasesWithStatsService.php @@ -0,0 +1,81 @@ + + */ + private static array $statsCache = []; + + public function __construct(private EngineManager $manager) {} + + /** + * Return all databases visible to the authenticated user, each decorated with stats. + * + * Admin users see all rows; non-admin users see only their own rows (via scopeMine). + * + * @return array> + */ + public function handle(): array + { + $databases = Database::mine()->get(); + + $items = []; + + foreach ($databases as $database) { + $items[] = $this->buildItem($database); + } + + return $items; + } + + private function buildItem(Database $database): array + { + $stats = $this->getStats($database); + + return [ + 'id' => $database->id, + 'name' => $database->name, + 'db_user' => $database->db_user, + 'engine' => $database->engine, + 'charset' => $database->charset, + 'collation' => $database->collation, + 'tables' => $stats->tableCount, + 'sizeMb' => $stats->sizeMb, + 'user_id' => $database->user_id, + ]; + } + + private function getStats(Database $database): DatabaseStats + { + if (isset(self::$statsCache[$database->id])) { + return self::$statsCache[$database->id]; + } + + $driver = $this->manager->for($database->engine); + $stats = $driver->stats($database); + + self::$statsCache[$database->id] = $stats; + + return $stats; + } + + /** + * Clear the per-request static cache (used in tests to avoid state leakage). + */ + public static function clearCache(): void + { + self::$statsCache = []; + } +} diff --git a/app/Services/Database/UpdateDatabaseService.php b/app/Services/Database/UpdateDatabaseService.php new file mode 100644 index 0000000..4b1b573 --- /dev/null +++ b/app/Services/Database/UpdateDatabaseService.php @@ -0,0 +1,59 @@ +driver->updatePassword($database, $validated['db_password']); + } + + $options = array_filter([ + 'charset' => $validated['charset'] ?? null, + 'collation' => $validated['collation'] ?? null, + 'encoding' => $validated['encoding'] ?? null, + 'locale' => $validated['locale'] ?? null, + ], fn ($v) => $v !== null); + + if (! empty($options)) { + $this->driver->updateOptions($database, $options); + } + } catch (Exception $e) { + throw new UpdateDatabaseException('Failed to update database: '.$e->getMessage(), 0, $e); + } + + $update = []; + + if (! empty($validated['db_password'])) { + $update['db_password'] = $validated['db_password']; + } + + if (isset($validated['charset'])) { + $update['charset'] = $validated['charset']; + } + + if (isset($validated['collation'])) { + $update['collation'] = $validated['collation']; + } + + if (! empty($update)) { + $database->update($update); + } + } +} diff --git a/app/Services/MySQL/CreateDatabaseService.php b/app/Services/MySQL/CreateDatabaseService.php deleted file mode 100644 index 9f9a33c..0000000 --- a/app/Services/MySQL/CreateDatabaseService.php +++ /dev/null @@ -1,65 +0,0 @@ -createMySQLDatabase(); - $this->createMySQLUser(); - - return $this->createDatabaseRecord(); - } - - private function createMySQLDatabase(): void - { - $name = $this->validated['name']; - $charset = $this->validated['charset']; - $collation = $this->validated['collation']; - - try { - DB::statement("CREATE DATABASE `$name` CHARACTER SET $charset COLLATE $collation"); - } catch (Exception $e) { - throw new CreateDatabaseException('Failed to create MySQL database: ' . $e->getMessage()); - } - } - - private function createMySQLUser(): void - { - $dbUser = $this->validated['db_user']; - $dbPass = $this->validated['db_pass']; - $name = $this->validated['name']; - - try { - DB::statement("CREATE USER IF NOT EXISTS `$dbUser`@'localhost' IDENTIFIED BY '$dbPass'"); - DB::statement("GRANT ALL PRIVILEGES ON `$name`.* TO `$dbUser`@'localhost'"); - DB::statement("FLUSH PRIVILEGES"); - } catch (Exception $e) { - // Rollback database creation if user creation fails - DB::statement("DROP DATABASE IF EXISTS `{$this->validated['name']}`"); - throw new CreateDatabaseException('Failed to create MySQL user: ' . $e->getMessage()); - } - } - - private function createDatabaseRecord(): Database - { - return Database::create([ - 'name' => $this->validated['name'], - 'db_user' => $this->validated['db_user'], - 'db_password' => $this->validated['db_pass'], - 'charset' => $this->validated['charset'], - 'collation' => $this->validated['collation'], - 'user_id' => $this->user->id, - ]); - } -} diff --git a/app/Services/MySQL/DeleteDatabaseService.php b/app/Services/MySQL/DeleteDatabaseService.php deleted file mode 100644 index 2c0bdd1..0000000 --- a/app/Services/MySQL/DeleteDatabaseService.php +++ /dev/null @@ -1,49 +0,0 @@ -dropMySQLDatabase(); - $this->dropMySQLUser(); - $this->deleteDatabaseRecord(); - } - - private function dropMySQLDatabase(): void - { - $name = $this->database->name; - - try { - DB::statement("DROP DATABASE IF EXISTS `$name`"); - } catch (Exception $e) { - throw new DeleteDatabaseException('Failed to drop MySQL database: ' . $e->getMessage()); - } - } - - private function dropMySQLUser(): void - { - $dbUser = $this->database->db_user; - - try { - DB::statement("DROP USER IF EXISTS `$dbUser`@'localhost'"); - DB::statement("FLUSH PRIVILEGES"); - } catch (Exception $e) { - throw new DeleteDatabaseException('Failed to drop MySQL user: ' . $e->getMessage()); - } - } - - private function deleteDatabaseRecord(): void - { - $this->database->delete(); - } -} diff --git a/app/Services/MySQL/UpdateDatabaseService.php b/app/Services/MySQL/UpdateDatabaseService.php deleted file mode 100644 index d607093..0000000 --- a/app/Services/MySQL/UpdateDatabaseService.php +++ /dev/null @@ -1,65 +0,0 @@ -updateMySQLDatabase(); - $this->updateMySQLUserPassword(); - $this->updateDatabaseRecord(); - } - - private function updateMySQLDatabase(): void - { - $name = $this->database->name; - $charset = $this->validated['charset']; - $collation = $this->validated['collation']; - - try { - DB::statement("ALTER DATABASE `$name` CHARACTER SET $charset COLLATE $collation"); - } catch (Exception $e) { - throw new UpdateDatabaseException('Failed to update MySQL database charset/collation: ' . $e->getMessage()); - } - } - - private function updateMySQLUserPassword(): void - { - if (!isset($this->validated['db_password']) || empty($this->validated['db_password'])) { - return; - } - - $dbUser = $this->database->db_user; - $newPassword = $this->validated['db_password']; - - try { - DB::statement("ALTER USER `$dbUser`@'localhost' IDENTIFIED BY '$newPassword'"); - DB::statement("FLUSH PRIVILEGES"); - } catch (Exception $e) { - throw new UpdateDatabaseException('Failed to update MySQL user password: ' . $e->getMessage()); - } - } - - private function updateDatabaseRecord(): void - { - $updateData = [ - 'charset' => $this->validated['charset'], - 'collation' => $this->validated['collation'], - ]; - - if (isset($this->validated['db_password']) && !empty($this->validated['db_password'])) { - $updateData['db_password'] = $this->validated['db_password']; - } - - $this->database->update($updateData); - } -} diff --git a/app/Services/Notifications/NotificationService.php b/app/Services/Notifications/NotificationService.php new file mode 100644 index 0000000..073539c --- /dev/null +++ b/app/Services/Notifications/NotificationService.php @@ -0,0 +1,43 @@ + 'database', + 'mail' => 'mail', + 'webhook' => WebhookChannel::class, + ]; + + /** + * Resolve which channels are enabled for a given user + event type. + * Channel preferences use short aliases (database / mail / webhook). + * Returns channel driver names / class names that Laravel's Notification system understands. + */ + public static function resolveChannels(object $notifiable, string $eventType): array + { + return array_values(array_filter( + array_values(self::CHANNEL_MAP), + function ($driver) use ($notifiable, $eventType) { + $alias = array_search($driver, self::CHANNEL_MAP); + + return NotificationPreference::isEnabled($notifiable->id, $eventType, $alias); + } + )); + } + + /** + * Dispatch a notification with preference filtering applied. + * Call this from every event source instead of $user->notify() directly. + */ + public static function dispatch(User $user, Notification $notification): void + { + $user->notify($notification); + } +} diff --git a/app/Services/Websites/DeleteWebsiteService.php b/app/Services/Websites/DeleteWebsiteService.php index d865730..c058c8d 100644 --- a/app/Services/Websites/DeleteWebsiteService.php +++ b/app/Services/Websites/DeleteWebsiteService.php @@ -15,6 +15,7 @@ public function __construct(private Website $website, private User $user) {} public function handle(): void { + $this->teardownRuntime(); // FIRST — clean shutdown before files deleted (FIXED: review fix #5) $this->deleteWebsiteFiles(); $this->disableWebsite(); $this->removeVhostFile(); @@ -26,30 +27,51 @@ public function handle(): void $this->website->delete(); } + /** + * Stop and remove the non-FPM runtime unit before deleting website files. + * Non-zero exit is logged but does not block deletion. + * Uses laranode-runtime-manage.sh remove (FIXED: no raw sudo rm, review fix #3). + */ + private function teardownRuntime(): void + { + if ($this->website->runtime === 'php-fpm') { + return; + } + + $unit = "laranode-{$this->website->runtime}-{$this->website->url}.service"; + + Process::run([ + 'sudo', + config('laranode.laranode_bin_path').'/laranode-runtime-manage.sh', + 'remove', + $unit, + ]); + } + private function deleteWebsiteFiles(): void { - $deleteWebsite = Process::run('rm -rf ' . $this->website->websiteRoot); + $deleteWebsite = Process::run('rm -rf '.$this->website->websiteRoot); if ($deleteWebsite->failed()) { - throw new DeleteWebsiteException('Failed to delete website files: ' . $deleteWebsite->errorOutput()); + throw new DeleteWebsiteException('Failed to delete website files: '.$deleteWebsite->errorOutput()); } } private function disableWebsite(): void { - $disableWebsite = Process::run('sudo a2dissite ' . $this->website->url . '.conf'); + $disableWebsite = Process::run('sudo a2dissite '.$this->website->url.'.conf'); if ($disableWebsite->failed()) { - throw new DeleteWebsiteException('Failed to disable (a2dissite) website: ' . $disableWebsite->errorOutput()); + throw new DeleteWebsiteException('Failed to disable (a2dissite) website: '.$disableWebsite->errorOutput()); } } private function removeVhostFile(): void { - $removeVhostFile = Process::run('sudo rm /etc/apache2/sites-available/' . $this->website->url . '.conf'); + $removeVhostFile = Process::run('sudo rm /etc/apache2/sites-available/'.$this->website->url.'.conf'); if ($removeVhostFile->failed()) { - throw new DeleteWebsiteException('Failed to remove vhost file: ' . $removeVhostFile->errorOutput()); + throw new DeleteWebsiteException('Failed to remove vhost file: '.$removeVhostFile->errorOutput()); } } @@ -65,7 +87,6 @@ public function syncPhpFpmPools(): void ->where('php_version_id', $phpVersion->id) ->count(); - if ($sitesUsingThisPHPVersion > 1) { return; } @@ -73,13 +94,13 @@ public function syncPhpFpmPools(): void // user doesn't have other websites with the same php version, remove pool $removePhpFpmPool = Process::run([ 'sudo', - config('laranode.laranode_bin_path') . '/laranode-remove-php-fpm-pool-for-user.sh', + config('laranode.laranode_bin_path').'/laranode-remove-php-fpm-pool-for-user.sh', $this->website->user->systemUsername, $thisPhpVersion, ]); if ($removePhpFpmPool->failed()) { - throw new DeleteWebsiteException('Failed to remove php-fpm pool: ' . $removePhpFpmPool->errorOutput()); + throw new DeleteWebsiteException('Failed to remove php-fpm pool: '.$removePhpFpmPool->errorOutput()); } } } diff --git a/app/Services/Websites/InstallRuntimeService.php b/app/Services/Websites/InstallRuntimeService.php new file mode 100644 index 0000000..9efb517 --- /dev/null +++ b/app/Services/Websites/InstallRuntimeService.php @@ -0,0 +1,34 @@ +failed()) { + throw new InstallRuntimeException( + "Failed to install {$runtime} runtime: ".$result->errorOutput() + ); + } + } +} diff --git a/app/Services/Websites/PortAllocatorService.php b/app/Services/Websites/PortAllocatorService.php new file mode 100644 index 0000000..acabbf5 --- /dev/null +++ b/app/Services/Websites/PortAllocatorService.php @@ -0,0 +1,40 @@ +where('id', '!=', $excludeWebsite->id) + ->pluck('runtime_port') + ->toArray(); + + $usedSet = array_flip($used); + + for ($port = self::PORT_MIN; $port <= self::PORT_MAX; $port++) { + if (! isset($usedSet[$port])) { + return $port; + } + } + + throw new \RuntimeException('No available runtime ports in range 9100–9499.'); + } +} diff --git a/app/Services/Websites/SwitchRuntimeService.php b/app/Services/Websites/SwitchRuntimeService.php new file mode 100644 index 0000000..9b9c14c --- /dev/null +++ b/app/Services/Websites/SwitchRuntimeService.php @@ -0,0 +1,237 @@ +runtime, self::SUPPORTED_RUNTIMES, true)) { + throw new SwitchRuntimeException("Unsupported runtime: {$this->runtime}"); + } + + $website = $this->website; + $emit = $this->emit; + $domain = $website->url; + + // Step 1: Validate domain at PHP level before any privileged calls. + if (! $this->isValidDomain($domain)) { + throw new SwitchRuntimeException( + "Invalid domain name '{$domain}': must match ^[a-zA-Z0-9][a-zA-Z0-9.-]+\$ with no consecutive dots." + ); + } + + $binPath = config('laranode.laranode_bin_path'); + $templateDir = base_path('laranode-scripts/templates'); + $systemUser = $website->user->systemUsername; + $phpVersion = $website->phpVersion->version; + $documentRoot = $website->document_root; + $oldRuntime = $website->runtime; + + // Step 2: Refuse FrankenPHP for SSL-enabled sites (punch-list item 2). + // v1 limitation: FrankenPHP's :80 proxy would break certbot --webroot renewal. + // SSL coexistence is deferred to a future version; document the known gap. + if ($this->runtime === 'frankenphp' && $website->ssl_enabled) { + throw new SwitchRuntimeException( + 'Cannot switch to FrankenPHP: SSL is enabled on this site. ' + .'FrankenPHP is not supported for SSL-enabled sites in v1 because the :80 proxy ' + .'would break certbot --webroot renewal. Disable SSL first, then switch runtime.' + ); + } + + // Step 3: Stop old non-FPM unit if applicable. + if ($oldRuntime !== 'php-fpm') { + $oldUnit = "laranode-{$oldRuntime}-{$domain}.service"; + $emit("Stopping old {$oldRuntime} unit ({$oldUnit})..."); + Process::run(['sudo', $binPath.'/laranode-runtime-manage.sh', 'stop', $oldUnit]); + // Non-zero exit is logged but does not block switching. + } + + $port = 0; + $portOrNull = null; + + // Step 4: Non-FPM runtime setup. + if ($this->runtime !== 'php-fpm') { + // 4a. Allocate port. + $port = (new PortAllocatorService)->allocate($website); + $emit("Allocated port {$port} for {$this->runtime}."); + + // 4b. Install runtime binary. + (new InstallRuntimeService)->ensureInstalled($this->runtime, $emit); + + // 4c. Write systemd unit + daemon-reload. + $emit("Writing systemd unit for {$domain}..."); + $unitResult = Process::run([ + 'sudo', + $binPath.'/laranode-runtime-unit.sh', + 'write-unit', + $domain, + (string) $port, + $systemUser, + $documentRoot, + $templateDir, + ]); + if ($unitResult->failed()) { + throw new SwitchRuntimeException( + 'Failed to write systemd unit: '.$unitResult->errorOutput() + ); + } + + // 4d. Enable unit. + $unitName = "laranode-{$this->runtime}-{$domain}.service"; + $emit("Enabling {$unitName}..."); + $enableResult = Process::run(['sudo', $binPath.'/laranode-runtime-manage.sh', 'enable', $unitName]); + if ($enableResult->failed()) { + throw new SwitchRuntimeException( + "Failed to enable {$unitName}: ".$enableResult->errorOutput() + ); + } + + // 4e. Start unit. + $emit("Starting {$unitName}..."); + $startResult = Process::run(['sudo', $binPath.'/laranode-runtime-manage.sh', 'start', $unitName]); + if ($startResult->failed()) { + // Rollback: restart the previous runtime so site is never left 502 (punch-list item 3). + $this->rollbackToPreviousRuntime($oldRuntime, $domain, $binPath, $emit); + + // Port NOT saved on failed start (FIXED: review fix #9). + throw new SwitchRuntimeException( + "Failed to start {$unitName}: ".$startResult->errorOutput() + ); + } + + // 4f. Start succeeded — commit port for DB write. + $portOrNull = $port; + } + + // Step 5: Switch Apache vhost. + $emit("Switching Apache vhost for {$domain} to {$this->runtime}..."); + $vhostResult = Process::run([ + 'sudo', + $binPath.'/laranode-vhost-switch.sh', + $domain, + $this->runtime, + (string) $port, + $systemUser, + $phpVersion, + $documentRoot, + $templateDir, + ]); + if ($vhostResult->failed()) { + throw new SwitchRuntimeException( + 'Failed to switch Apache vhost: '.$vhostResult->errorOutput() + ); + } + + // Step 6: Persist runtime + port. + $website->update([ + 'runtime' => $this->runtime, + 'runtime_port' => $portOrNull, + ]); + + // Step 7: Emit success. + $emit("Runtime switched to {$this->runtime} successfully."); + } + + /** + * Validate a domain name. + * + * Rules (punch-list item 4): + * - Must match ^[a-zA-Z0-9][a-zA-Z0-9.-]+$ + * - Must NOT contain consecutive dots (..) + * - Must NOT contain path separators (/) or (..) sequences + */ + private function isValidDomain(string $domain): bool + { + if (! preg_match(self::DOMAIN_PATTERN, $domain)) { + return false; + } + + // Reject consecutive dots (e.g. 'foo..bar.test') + if (str_contains($domain, '..')) { + return false; + } + + // Reject path traversal (should be caught by regex, but belt-and-suspenders) + if (str_contains($domain, '/') || str_contains($domain, '\\')) { + return false; + } + + return true; + } + + /** + * Attempt to restart the previous runtime after a new runtime start failure. + * + * This prevents leaving the site in a 502 state (punch-list item 3). + * Failure to restart the previous runtime is logged but does not throw — + * the primary exception (start failure of new runtime) is surfaced instead. + */ + private function rollbackToPreviousRuntime( + string $oldRuntime, + string $domain, + string $binPath, + callable $emit + ): void { + if ($oldRuntime === 'php-fpm') { + // FPM is managed via the pool config — restart via PHP-FPM service. + // The FPM pool was never stopped (we only stopped non-FPM units), so + // no explicit restart is needed; FPM continues serving the site. + $emit('New runtime failed to start. Site remains on PHP-FPM (no action needed).'); + + return; + } + + // Old runtime was non-FPM: attempt to restart its unit. + $oldUnit = "laranode-{$oldRuntime}-{$domain}.service"; + $emit("New runtime failed to start. Attempting rollback: restarting {$oldUnit}..."); + $rollbackResult = Process::run([ + 'sudo', + $binPath.'/laranode-runtime-manage.sh', + 'restart', + $oldUnit, + ]); + + if ($rollbackResult->failed()) { + $emit("WARNING: Rollback failed — {$oldUnit} could not be restarted. Site may be unavailable."); + } else { + $emit("Rollback succeeded: {$oldUnit} is running again."); + } + } +} diff --git a/app/Services/Websites/UpdateWebsitePHPVersionService.php b/app/Services/Websites/UpdateWebsitePHPVersionService.php index 6f94a2e..4fb406a 100644 --- a/app/Services/Websites/UpdateWebsitePHPVersionService.php +++ b/app/Services/Websites/UpdateWebsitePHPVersionService.php @@ -2,10 +2,9 @@ namespace App\Services\Websites; -use App\Services\Laranode\AddVhostEntryService; -use App\Services\Laranode\CreatePhpFpmPoolService; use App\Models\PhpVersion; use App\Models\Website; +use App\Services\Laranode\CreatePhpFpmPoolService; use Illuminate\Support\Facades\Process; class UpdateWebsitePHPVersionService @@ -14,6 +13,10 @@ public function __construct(private Website $website, private int $phpVersionId) public function handle(): void { + if ($this->website->runtime !== 'php-fpm') { + throw new \InvalidArgumentException('PHP version switching is not supported for this runtime.'); + } + // ensure selected PHP version is active $phpVersion = PhpVersion::active()->findOrFail($this->phpVersionId); @@ -24,18 +27,15 @@ public function handle(): void Process::run([ 'sudo', - $laranodeBinPath . '/laranode-update-php-version.sh', + $laranodeBinPath.'/laranode-update-php-version.sh', $this->website->url, $this->website->phpVersion->version, $phpVersion->version, ]); - // update website with the selected active PHP version $this->website->update([ 'php_version_id' => $phpVersion->id, ]); } } - - diff --git a/bootstrap/app.php b/bootstrap/app.php index 3e4b266..894fb9b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,9 +6,9 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( - web: __DIR__ . '/../routes/web.php', - commands: __DIR__ . '/../routes/console.php', - channels: __DIR__ . '/../routes/channels.php', + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + channels: __DIR__.'/../routes/channels.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware) { @@ -21,4 +21,44 @@ }) ->withExceptions(function (Exceptions $exceptions) { // - })->create(); + }) + ->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) { + $schedule->command('model:prune', ['--model' => [ + \App\Models\Operation::class, + \App\Models\Backup::class, + \App\Models\UserResourceSnapshot::class, + \App\Models\UserSiteStat::class, + ]])->daily(); + $schedule->job(new \App\Jobs\RunScheduledBackupsJob)->everyMinute(); + + $schedule->call(new \App\Actions\SSL\SendSslExpiryNotificationsAction) + ->dailyAt('08:00') + ->description('ssl.expiring notifications'); + + $schedule->call(function () { + \App\Models\User::chunkById(50, function ($users) { + foreach ($users as $user) { + $operation = \App\Models\Operation::create([ + 'user_id' => $user->id, + 'type' => 'analytics.resource-rollup', + 'target' => $user->username, + ]); + \App\Jobs\Analytics\RollupUserResourceSnapshotJob::dispatch($operation, $user); + } + }); + })->daily()->name('analytics.resource-rollup'); + + $schedule->call(function () { + \App\Models\User::chunkById(50, function ($users) { + foreach ($users as $user) { + $operation = \App\Models\Operation::create([ + 'user_id' => $user->id, + 'type' => 'analytics.site-rollup', + 'target' => $user->username, + ]); + \App\Jobs\Analytics\RollupSiteStatsJob::dispatch($operation, $user); + } + }); + })->hourly()->name('analytics.site-rollup'); + }) + ->create(); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 9948d50..3a7e05c 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -3,5 +3,6 @@ return [ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, + App\Providers\DatabaseServiceProvider::class, Lab404\Impersonate\ImpersonateServiceProvider::class, ]; diff --git a/composer.json b/composer.json index 42732f3..f606e79 100644 --- a/composer.json +++ b/composer.json @@ -10,12 +10,14 @@ "license": "MIT", "require": { "php": "^8.2", + "dragonmantank/cron-expression": "^3.6", "inertiajs/inertia-laravel": "^2.0", "lab404/laravel-impersonate": "^1.7", "laravel/framework": "^12.0", "laravel/reverb": "^1.4", "laravel/sanctum": "^4.0", "laravel/tinker": "^2.9", + "league/flysystem-aws-s3-v3": "^3.35", "league/flysystem-memory": "^3.29", "league/mime-type-detection": "^1.16", "tightenco/ziggy": "^2.0" diff --git a/composer.lock b/composer.lock index 4c0c65e..5f2b78e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,159 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "86d0bc95872bde3f41dc360d0f9adba3", + "content-hash": "2a762046b84981cf9e37c145ed38f728", "packages": [ + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.386.1", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "e36bc0e97e82d68acc92b9e06f5dc544913d819a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e36bc0e97e82d68acc92b9e06f5dc544913d819a", + "reference": "e36bc0e97e82d68acc92b9e06f5dc544913d819a", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/promises": "^2.0", + "guzzlehttp/psr7": "^2.4.5", + "mtdowling/jmespath.php": "^2.9.1", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^v0.5.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^10.0", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "https://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "https://aws.amazon.com/sdk-for-php", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.386.1" + }, + "time": "2026-06-23T01:24:07+00:00" + }, { "name": "brick/math", "version": "0.12.3", @@ -510,29 +661,28 @@ }, { "name": "dragonmantank/cron-expression", - "version": "v3.4.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "8c784d071debd117328803d86b2097615b457500" + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", - "reference": "8c784d071debd117328803d86b2097615b457500", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", "shasum": "" }, "require": { - "php": "^7.2|^8.0", - "webmozart/assert": "^1.0" + "php": "^8.2|^8.3|^8.4|^8.5" }, "replace": { "mtdowling/cron-expression": "^1.0" }, "require-dev": { - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.0", - "phpunit/phpunit": "^7.0|^8.0|^9.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" }, "type": "library", "extra": { @@ -563,7 +713,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" }, "funding": [ { @@ -571,7 +721,7 @@ "type": "github" } ], - "time": "2024-10-09T13:47:03+00:00" + "time": "2025-10-31T18:51:33+00:00" }, { "name": "egulias/email-validator", @@ -2191,6 +2341,61 @@ }, "time": "2024-10-08T08:58:34+00:00" }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "3.35.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94", + "reference": "3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.371.5", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3V3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AWS S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.1" + }, + "time": "2026-06-25T06:51:08+00:00" + }, { "name": "league/flysystem-local", "version": "3.29.0", @@ -2621,6 +2826,72 @@ ], "time": "2024-12-05T17:15:07+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.9.1", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "9c208ba27ae7d90853c288b3795d6702eb251d34" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/9c208ba27ae7d90853c288b3795d6702eb251d34", + "reference": "9c208ba27ae7d90853c288b3795d6702eb251d34", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.52" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.9-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.9.1" + }, + "time": "2026-06-11T10:43:56+00:00" + }, { "name": "nesbot/carbon", "version": "3.8.6", @@ -5070,6 +5341,77 @@ ], "time": "2024-09-25T14:20:29+00:00" }, + { + "name": "symfony/filesystem", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/finder", "version": "v7.2.2", @@ -7080,64 +7422,6 @@ } ], "time": "2024-11-21T01:49:47+00:00" - }, - { - "name": "webmozart/assert", - "version": "1.11.0", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "php": "^7.2 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<4.6.1 || 4.6.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5.13" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.10-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.11.0" - }, - "time": "2022-06-03T18:03:27+00:00" } ], "packages-dev": [ @@ -10413,6 +10697,64 @@ } ], "time": "2024-03-03T12:36:25+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.11.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": "^7.2 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<4.6.1 || 4.6.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.11.0" + }, + "time": "2022-06-03T18:03:27+00:00" } ], "aliases": [], @@ -10424,5 +10766,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/database.php b/config/database.php index 125949e..57eeb47 100644 --- a/config/database.php +++ b/config/database.php @@ -112,6 +112,70 @@ // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), ], + // Privileged admin connections for Laranode database management. + // These use dedicated MYSQL_ADMIN_* / PGSQL_* env vars — never share + // credentials with the panel's own DB_* connection. + + 'mysql_admin' => [ + 'driver' => 'mysql', + 'url' => null, + 'host' => env('MYSQL_ADMIN_HOST', env('DB_HOST', '127.0.0.1')), + 'port' => env('MYSQL_ADMIN_PORT', env('DB_PORT', '3306')), + 'database' => env('MYSQL_ADMIN_DB', 'mysql'), + 'username' => env('MYSQL_ADMIN_USERNAME', env('DB_USERNAME', 'root')), + 'password' => env('MYSQL_ADMIN_PASSWORD', env('DB_PASSWORD', '')), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + // PDO_EMULATE_PREPARES enables client-side binding for DDL statements + // like "CREATE USER ... IDENTIFIED BY ?" which MySQL's server-side + // prepared statement protocol does not support for DDL clauses. + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + PDO::ATTR_EMULATE_PREPARES => true, + ]) : [PDO::ATTR_EMULATE_PREPARES => true], + ], + + 'mariadb_admin' => [ + 'driver' => 'mariadb', + 'url' => null, + 'host' => env('MYSQL_ADMIN_HOST', env('DB_HOST', '127.0.0.1')), + 'port' => env('MYSQL_ADMIN_PORT', env('DB_PORT', '3306')), + 'database' => env('MYSQL_ADMIN_DB', 'mysql'), + 'username' => env('MYSQL_ADMIN_USERNAME', env('DB_USERNAME', 'root')), + 'password' => env('MYSQL_ADMIN_PASSWORD', env('DB_PASSWORD', '')), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + PDO::ATTR_EMULATE_PREPARES => true, + ]) : [PDO::ATTR_EMULATE_PREPARES => true], + ], + + 'pgsql_admin' => [ + 'driver' => 'pgsql', + 'url' => null, + 'host' => env('PGSQL_HOST', '127.0.0.1'), + 'port' => env('PGSQL_PORT', '5432'), + 'database' => env('PGSQL_DB', 'postgres'), + 'username' => env('PGSQL_USERNAME', 'postgres'), + 'password' => env('PGSQL_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + ], /* diff --git a/config/filesystems.php b/config/filesystems.php index 3d671bd..339a306 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -47,6 +47,12 @@ 'report' => false, ], + 'backups' => [ + 'driver' => 'local', + 'root' => env('BACKUP_LOCAL_ROOT', '/home'), + 'throw' => true, + ], + 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), diff --git a/config/laranode.php b/config/laranode.php index b93fb43..1b05bb2 100644 --- a/config/laranode.php +++ b/config/laranode.php @@ -10,7 +10,7 @@ | binary. This is used to create and delete system users. | */ - 'laranode_bin_path' => base_path('laranode-scripts/bin'), + 'laranode_bin_path' => env('LARANODE_BIN_PATH', base_path('laranode-scripts/bin')), /* |-------------------------------------------------------------------------- @@ -34,6 +34,16 @@ */ 'apache_vhost_template' => base_path('laranode-scripts/templates/apache-vhost.template'), + /* + |-------------------------------------------------------------------------- + | Laranode FrankenPHP Apache Virtual Host Template + |-------------------------------------------------------------------------- + | + | Path to the Apache vhost template used when a site runs under FrankenPHP. + | Uses mod_proxy to forward traffic to the FrankenPHP process on loopback, + | with a ProxyPass exception for ACME challenge renewals. + */ + 'apache_vhost_frankenphp_template' => base_path('laranode-scripts/templates/apache-vhost-frankenphp.template'), /* |-------------------------------------------------------------------------- @@ -43,8 +53,22 @@ | This option allows you to specify the mime types that can be edited | in the file manager. */ - 'editable_mime_types' => - [ + /* + |-------------------------------------------------------------------------- + | Laranode Database Engines + |-------------------------------------------------------------------------- + | + | Maps engine keys to their systemd service name and default port. + | Used by EngineManager to detect which engines are active. + | + */ + 'db_engines' => [ + 'mysql' => ['service' => 'mysql', 'port' => 3306], + 'mariadb' => ['service' => 'mariadb', 'port' => 3306], + 'postgres' => ['service' => 'postgresql', 'port' => 5432], + ], + + 'editable_mime_types' => [ 'text/plain', // .txt, .log, .ini, .env, .conf, .md, .sh, .bash, .zsh 'text/html', // .html, .htm 'text/css', // .css @@ -72,6 +96,6 @@ 'text/rtf', 'application/x-sh', // .sh 'application/x-sql', // .sql - ] + ], ]; diff --git a/database/factories/BackupFactory.php b/database/factories/BackupFactory.php new file mode 100644 index 0000000..da207ca --- /dev/null +++ b/database/factories/BackupFactory.php @@ -0,0 +1,41 @@ + + */ +class BackupFactory extends Factory +{ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'type' => fake()->randomElement(['db', 'files']), + 'target' => fake()->slug(2).'_ln', + 'storage' => 'local', + 'disk_name' => 'backups', + 'path' => null, + 'size_bytes' => null, + 'status' => 'pending', + ]; + } + + public function completed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'completed', + 'size_bytes' => fake()->numberBetween(1024, 10485760), + ]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'failed', + ]); + } +} diff --git a/database/factories/DatabaseFactory.php b/database/factories/DatabaseFactory.php new file mode 100644 index 0000000..8857941 --- /dev/null +++ b/database/factories/DatabaseFactory.php @@ -0,0 +1,30 @@ + + */ +class DatabaseFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->unique()->slug(2).'_ln', + 'db_user' => fake()->unique()->slug(2).'_ln', + 'db_password' => 'secret', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'engine' => 'mysql', + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/factories/ScheduledBackupFactory.php b/database/factories/ScheduledBackupFactory.php new file mode 100644 index 0000000..7c16bb5 --- /dev/null +++ b/database/factories/ScheduledBackupFactory.php @@ -0,0 +1,49 @@ + + */ +class ScheduledBackupFactory extends Factory +{ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'type' => fake()->randomElement(['db', 'files']), + 'target' => fake()->slug(2).'_ln', + 'storage' => 'local', + 'disk_name' => 'backups', + 'cron_expression' => '0 2 * * *', + 'retention_count' => 7, + 's3_key' => null, + 's3_secret' => null, + 's3_region' => null, + 's3_bucket' => null, + 's3_endpoint' => null, + 'enabled' => true, + 'last_run_at' => null, + ]; + } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => ['enabled' => false]); + } + + public function withS3(): static + { + return $this->state(fn (array $attributes) => [ + 'storage' => 's3', + 's3_key' => 'AKIAIOSFODNN7EXAMPLE', + 's3_secret' => 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + 's3_region' => 'us-east-1', + 's3_bucket' => 'my-backups', + 's3_endpoint' => null, + ]); + } +} diff --git a/database/factories/WebsiteFactory.php b/database/factories/WebsiteFactory.php new file mode 100644 index 0000000..fa267ba --- /dev/null +++ b/database/factories/WebsiteFactory.php @@ -0,0 +1,29 @@ + + */ +class WebsiteFactory extends Factory +{ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'url' => fake()->unique()->domainName(), + 'document_root' => '/public', + 'php_version_id' => PhpVersion::factory(), + 'ssl_enabled' => false, + 'ssl_status' => 'inactive', + 'ssl_expires_at' => null, + 'ssl_generated_at' => null, + 'runtime' => 'php-fpm', + 'runtime_port' => null, + ]; + } +} diff --git a/database/migrations/2026_06_25_000001_create_operations_table.php b/database/migrations/2026_06_25_000001_create_operations_table.php new file mode 100644 index 0000000..51236cc --- /dev/null +++ b/database/migrations/2026_06_25_000001_create_operations_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('type'); // e.g. ssl.generate + $table->string('target')->nullable(); // human label, e.g. the domain + $table->string('status')->default('queued'); // queued|running|succeeded|failed + $table->longText('output')->nullable(); + $table->integer('exit_code')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('operations'); + } +}; diff --git a/database/migrations/2026_06_26_000001_add_engine_to_databases_table.php b/database/migrations/2026_06_26_000001_add_engine_to_databases_table.php new file mode 100644 index 0000000..015fab2 --- /dev/null +++ b/database/migrations/2026_06_26_000001_add_engine_to_databases_table.php @@ -0,0 +1,32 @@ +string('engine')->nullable()->after('collation'); + $table->string('charset')->nullable()->change(); + $table->string('collation')->nullable()->change(); + }); + + // Backfill: rows with NULL or '' engine are MySQL (pre-existing rows) + DB::table('databases') + ->where(fn ($q) => $q->whereNull('engine')->orWhere('engine', '')) + ->update(['engine' => 'mysql']); + } + + public function down(): void + { + Schema::table('databases', function (Blueprint $table) { + $table->dropColumn('engine'); + $table->string('charset')->default('utf8mb4')->change(); + $table->string('collation')->default('utf8mb4_unicode_ci')->change(); + }); + } +}; diff --git a/database/migrations/2026_06_26_000002_create_backups_table.php b/database/migrations/2026_06_26_000002_create_backups_table.php new file mode 100644 index 0000000..d3185a9 --- /dev/null +++ b/database/migrations/2026_06_26_000002_create_backups_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('operation_id')->nullable()->constrained()->nullOnDelete(); + $table->string('type'); // db | files + $table->string('target'); // db name or website URL + $table->string('storage'); // local | s3 + $table->string('disk_name')->nullable(); // backups | backups_s3 etc. + $table->string('path')->nullable(); // relative path on disk + $table->bigInteger('size_bytes')->nullable(); + $table->string('status')->default('pending'); // pending | completed | failed + $table->timestamps(); + + $table->index(['user_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('backups'); + } +}; diff --git a/database/migrations/2026_06_26_000003_create_scheduled_backups_table.php b/database/migrations/2026_06_26_000003_create_scheduled_backups_table.php new file mode 100644 index 0000000..e58b36b --- /dev/null +++ b/database/migrations/2026_06_26_000003_create_scheduled_backups_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('type'); // db | files + $table->string('target'); // db name or website URL + $table->string('storage'); // local | s3 + $table->string('disk_name')->nullable(); + $table->string('cron_expression')->default('0 2 * * *'); + $table->unsignedSmallInteger('retention_count')->default(7); + $table->text('s3_key')->nullable(); + $table->text('s3_secret')->nullable(); + $table->string('s3_region')->nullable(); + $table->string('s3_bucket')->nullable(); + $table->string('s3_endpoint')->nullable(); + $table->boolean('enabled')->default(true); + $table->timestamp('last_run_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'enabled']); + }); + } + + public function down(): void + { + Schema::dropIfExists('scheduled_backups'); + } +}; diff --git a/database/migrations/2026_06_26_000004_add_s3_credentials_to_backups_table.php b/database/migrations/2026_06_26_000004_add_s3_credentials_to_backups_table.php new file mode 100644 index 0000000..737e1b8 --- /dev/null +++ b/database/migrations/2026_06_26_000004_add_s3_credentials_to_backups_table.php @@ -0,0 +1,26 @@ +text('s3_key')->nullable()->after('disk_name'); + $table->text('s3_secret')->nullable()->after('s3_key'); + $table->string('s3_region')->nullable()->after('s3_secret'); + $table->string('s3_bucket')->nullable()->after('s3_region'); + $table->string('s3_endpoint')->nullable()->after('s3_bucket'); + }); + } + + public function down(): void + { + Schema::table('backups', function (Blueprint $table) { + $table->dropColumn(['s3_key', 's3_secret', 's3_region', 's3_bucket', 's3_endpoint']); + }); + } +}; diff --git a/database/migrations/2026_06_27_000001_create_cron_jobs_table.php b/database/migrations/2026_06_27_000001_create_cron_jobs_table.php new file mode 100644 index 0000000..b7322bd --- /dev/null +++ b/database/migrations/2026_06_27_000001_create_cron_jobs_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('schedule', 100); + $table->string('command', 500); + $table->string('label', 255)->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + + $table->index(['user_id', 'active']); + $table->unique(['user_id', 'schedule', 'command']); + }); + } + + public function down(): void + { + Schema::dropIfExists('cron_jobs'); + } +}; diff --git a/database/migrations/2026_06_27_000001_create_options_table.php b/database/migrations/2026_06_27_000001_create_options_table.php new file mode 100644 index 0000000..cde27d9 --- /dev/null +++ b/database/migrations/2026_06_27_000001_create_options_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('option_name')->unique(); + $table->longText('option_value')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('options'); + } +}; diff --git a/database/migrations/2026_06_27_000002_create_user_resource_snapshots_table.php b/database/migrations/2026_06_27_000002_create_user_resource_snapshots_table.php new file mode 100644 index 0000000..271c19d --- /dev/null +++ b/database/migrations/2026_06_27_000002_create_user_resource_snapshots_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamp('snapshotted_at'); + $table->unsignedBigInteger('disk_bytes'); + $table->unsignedBigInteger('apache_request_count')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'snapshotted_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_resource_snapshots'); + } +}; diff --git a/database/migrations/2026_06_27_000003_create_user_site_stats_table.php b/database/migrations/2026_06_27_000003_create_user_site_stats_table.php new file mode 100644 index 0000000..14ebf05 --- /dev/null +++ b/database/migrations/2026_06_27_000003_create_user_site_stats_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('website_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamp('snapshotted_at'); + $table->unsignedBigInteger('disk_bytes'); + $table->timestamps(); + + $table->index(['website_id', 'snapshotted_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_site_stats'); + } +}; diff --git a/database/migrations/2026_06_27_000004_add_runtime_to_websites_table.php b/database/migrations/2026_06_27_000004_add_runtime_to_websites_table.php new file mode 100644 index 0000000..158d4d3 --- /dev/null +++ b/database/migrations/2026_06_27_000004_add_runtime_to_websites_table.php @@ -0,0 +1,29 @@ +string('runtime', 20)->default('php-fpm')->after('php_version_id'); + $table->unsignedSmallInteger('runtime_port')->nullable()->after('runtime'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('websites', function (Blueprint $table) { + $table->dropColumn(['runtime', 'runtime_port']); + }); + } +}; diff --git a/database/migrations/2026_06_27_020009_create_notifications_table.php b/database/migrations/2026_06_27_020009_create_notifications_table.php new file mode 100644 index 0000000..dcbefec --- /dev/null +++ b/database/migrations/2026_06_27_020009_create_notifications_table.php @@ -0,0 +1,32 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + $table->index(['notifiable_type', 'notifiable_id', 'read_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_06_27_020010_add_webhook_url_to_users_table.php b/database/migrations/2026_06_27_020010_add_webhook_url_to_users_table.php new file mode 100644 index 0000000..d178e17 --- /dev/null +++ b/database/migrations/2026_06_27_020010_add_webhook_url_to_users_table.php @@ -0,0 +1,28 @@ +string('webhook_url')->nullable()->after('email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('webhook_url'); + }); + } +}; diff --git a/database/migrations/2026_06_27_021437_create_notification_preferences_table.php b/database/migrations/2026_06_27_021437_create_notification_preferences_table.php new file mode 100644 index 0000000..5c0cacd --- /dev/null +++ b/database/migrations/2026_06_27_021437_create_notification_preferences_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('event_type'); + $table->string('channel'); + $table->boolean('enabled')->default(true); + $table->timestamps(); + + $table->unique(['user_id', 'event_type', 'channel']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notification_preferences'); + } +}; diff --git a/laranode-scripts/bin/laranode-backup-files.sh b/laranode-scripts/bin/laranode-backup-files.sh new file mode 100755 index 0000000..8942a44 --- /dev/null +++ b/laranode-scripts/bin/laranode-backup-files.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -euo pipefail + +# Usage: laranode-backup-files.sh +SITE_ROOT="$1" +OUT_FILE="$2" +SYS_USER="$3" + +# Reject any value that could be smuggled as a CLI flag to tar/chown. +for v in "$SITE_ROOT" "$OUT_FILE" "$SYS_USER"; do + case "$v" in -*) echo "Argument may not start with '-': $v" >&2; exit 1 ;; esac +done + +tar -czf "$OUT_FILE" -C "$SITE_ROOT" . +chown www-data:www-data -- "$OUT_FILE" diff --git a/laranode-scripts/bin/laranode-cron.sh b/laranode-scripts/bin/laranode-cron.sh new file mode 100644 index 0000000..9285d73 --- /dev/null +++ b/laranode-scripts/bin/laranode-cron.sh @@ -0,0 +1,182 @@ +#!/bin/bash +set -euo pipefail + +# laranode-cron.sh — manage per-user crontab entries for Laranode panel. +# +# Usage: +# laranode-cron.sh set +# laranode-cron.sh remove +# laranode-cron.sh list +# +# Security: +# - $SYSTEM_USER must match ^[a-zA-Z0-9_]+$, end in _ln, exist, and not be root. +# - Each line in must be: 5 schedule fields command (no embedded newlines). +# - Command portion must start with 'php /home//' (allowlist: php only, v1). +# - Flag-smuggling: any argument starting with '-' is rejected. +# - sudoers grants only (www-data) not (ALL); sudo only elevates to www-data. + +MARKER="# laranode-managed" + +# ── Reject flag-smuggling ───────────────────────────────────────────────────── +for arg in "$@"; do + case "$arg" in + -*) + echo "ERROR: argument may not start with '-': $arg" >&2 + exit 1 + ;; + esac +done + +SUBCMD="${1:-}" +SYSTEM_USER="${2:-}" + +# ── Validate SYSTEM_USER ────────────────────────────────────────────────────── +if [ -z "$SYSTEM_USER" ]; then + echo "ERROR: SYSTEM_USER is required" >&2 + exit 1 +fi + +# Must match safe identifier pattern (no flags, no special chars) +if ! echo "$SYSTEM_USER" | grep -qE '^[a-zA-Z0-9_]+$'; then + echo "ERROR: invalid system user name: $SYSTEM_USER" >&2 + exit 1 +fi + +# Must end in _ln (panel-owned accounts only) +if ! echo "$SYSTEM_USER" | grep -qE '_ln$'; then + echo "ERROR: system user must end in _ln: $SYSTEM_USER" >&2 + exit 1 +fi + +# Must not be root +if [ "$SYSTEM_USER" = "root" ]; then + echo "ERROR: refusing to manage root crontab" >&2 + exit 1 +fi + +# Must exist on the system +if ! id "$SYSTEM_USER" >/dev/null 2>&1; then + echo "ERROR: system user does not exist: $SYSTEM_USER" >&2 + exit 1 +fi + +# ── Sub-commands ────────────────────────────────────────────────────────────── + +case "$SUBCMD" in + + set) + TMP_FILE="${3:-}" + if [ -z "$TMP_FILE" ]; then + echo "ERROR: tmp_file is required for 'set'" >&2 + exit 1 + fi + + if [ ! -f "$TMP_FILE" ]; then + echo "ERROR: tmp_file not found: $TMP_FILE" >&2 + exit 1 + fi + + # Trap: clean up our own reference to TMP_FILE on any error/exit. + # (PHP service also deletes it in finally{}; this is defence-in-depth.) + trap 'rm -f "$TMP_FILE"' EXIT INT TERM + + # Validate every non-empty line in the tmp file. + # Each line must be: TAB + # Command allowlist (v1): must start with 'php /home//' + ALLOWED_CMD_PREFIX="php /home/${SYSTEM_USER}/" + while IFS= read -r line || [ -n "$line" ]; do + # Skip empty lines + [ -z "$line" ] && continue + + # Reject lines containing embedded carriage-return + case "$line" in + *$'\r'*) + echo "ERROR: embedded carriage-return in crontab line" >&2 + exit 1 + ;; + esac + + # Must match: 5 whitespace-separated schedule fields followed by a TAB and a command + if ! printf '%s\n' "$line" | grep -qP '^\S+\s+\S+\s+\S+\s+\S+\s+\S+\t\S'; then + echo "ERROR: invalid crontab line format (need 5 schedule fields + TAB + command): $line" >&2 + exit 1 + fi + + # Extract the command portion: everything after the first TAB (field 2 onward). + # cut uses TAB as default delimiter; field 1 = "f1 f2 f3 f4 f5", field 2 = command. + CMD_PART=$(printf '%s\n' "$line" | cut -f2-) + + # Allowlist check: command must start with 'php /home//' + case "$CMD_PART" in + "$ALLOWED_CMD_PREFIX"*) + ;; # allowed + *) + echo "ERROR: command not allowed (must start with '${ALLOWED_CMD_PREFIX}'): $CMD_PART" >&2 + exit 1 + ;; + esac + done < "$TMP_FILE" + + # Get existing crontab minus managed lines. + # 'crontab -l' exits non-zero when the crontab is empty on some systems; + # the '|| true' prevents set -e from aborting on an empty crontab. + EXISTING=$(crontab -l -u "$SYSTEM_USER" 2>/dev/null || true) + + # Strip old managed block. + # 'grep -v' exits 1 when no lines remain; '|| true' handles that under set -e. + STRIPPED=$(printf '%s\n' "$EXISTING" | grep -v "$MARKER" || true) + + # Build new managed block from non-empty lines in the tmp file + MANAGED_LINES="" + while IFS= read -r line || [ -n "$line" ]; do + [ -z "$line" ] && continue + MANAGED_LINES="${MANAGED_LINES}${line} ${MARKER}"$'\n' + done < "$TMP_FILE" + + # Write crontab: managed block first, then remaining non-managed entries. + # '--' separates options from positional args; '-' means read from stdin. + if [ -z "$MANAGED_LINES" ] && [ -z "$STRIPPED" ]; then + # Nothing left — install an empty crontab + printf '' | crontab -u "$SYSTEM_USER" -- - + elif [ -z "$MANAGED_LINES" ]; then + # No managed lines, only preserve existing + printf '%s\n' "$STRIPPED" | crontab -u "$SYSTEM_USER" -- - + elif [ -z "$STRIPPED" ]; then + # Only managed lines + printf '%s\n' "$MANAGED_LINES" | crontab -u "$SYSTEM_USER" -- - + else + # Both managed and pre-existing non-managed lines + printf '%s\n%s\n' "$MANAGED_LINES" "$STRIPPED" | crontab -u "$SYSTEM_USER" -- - + fi + + # Disarm trap (PHP service owns the file; don't double-delete) + trap - EXIT INT TERM + + echo "OK: crontab updated for $SYSTEM_USER" + ;; + + remove) + # Strip all managed lines from the crontab + EXISTING=$(crontab -l -u "$SYSTEM_USER" 2>/dev/null || true) + STRIPPED=$(printf '%s\n' "$EXISTING" | grep -v "$MARKER" || true) + + if [ -z "$STRIPPED" ]; then + # Empty crontab + printf '' | crontab -u "$SYSTEM_USER" -- - + else + printf '%s\n' "$STRIPPED" | crontab -u "$SYSTEM_USER" -- - + fi + + echo "OK: managed crontab entries removed for $SYSTEM_USER" + ;; + + list) + crontab -l -u "$SYSTEM_USER" 2>/dev/null || true + ;; + + *) + echo "ERROR: unknown sub-command: $SUBCMD" >&2 + echo "Usage: laranode-cron.sh [tmp_file]" >&2 + exit 1 + ;; +esac diff --git a/laranode-scripts/bin/laranode-db-backup.sh b/laranode-scripts/bin/laranode-db-backup.sh new file mode 100755 index 0000000..1ccccae --- /dev/null +++ b/laranode-scripts/bin/laranode-db-backup.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -euo pipefail + +# Usage: laranode-db-backup.sh +ENGINE="$1" +DB_NAME="$2" +DB_USER="$3" +CNF_FILE="$4" +OUT_FILE="$5" + +# Reject any value that could be smuggled as a CLI flag to mysqldump/gzip. +for v in "$ENGINE" "$DB_NAME" "$DB_USER" "$CNF_FILE" "$OUT_FILE"; do + case "$v" in -*) echo "Argument may not start with '-': $v" >&2; exit 1 ;; esac +done + +# Strict identifier allowlist for values interpolated into the dump command. +[[ "$DB_NAME" =~ ^[A-Za-z0-9_]+$ ]] || { echo "Invalid db name: $DB_NAME" >&2; exit 1; } +[[ "$DB_USER" =~ ^[A-Za-z0-9_]+$ ]] || { echo "Invalid db user: $DB_USER" >&2; exit 1; } + +case "$ENGINE" in + mysql) + mysqldump --defaults-extra-file="$CNF_FILE" --user="$DB_USER" \ + --single-transaction --quick --lock-tables=false -- "$DB_NAME" | gzip > "$OUT_FILE" + ;; + *) + echo "Unsupported engine: $ENGINE" >&2 + exit 1 + ;; +esac diff --git a/laranode-scripts/bin/laranode-db-service.sh b/laranode-scripts/bin/laranode-db-service.sh new file mode 100755 index 0000000..8335609 --- /dev/null +++ b/laranode-scripts/bin/laranode-db-service.sh @@ -0,0 +1,70 @@ +#!/bin/bash +set -euo pipefail + +# Manage a database engine service (start / stop / restart) +# Usage: laranode-db-service.sh +# Example: laranode-db-service.sh restart mysql + +if [ $# -ne 2 ]; then + echo "Usage: $0 {start|stop|restart} {mysql|mariadb|postgres}" >&2 + exit 1 +fi + +ACTION=$1 +ENGINE=$2 + +# Leading-dash guard — prevents flag injection +if [[ "$ACTION" == -* ]]; then + echo "ERROR: invalid action (leading dash not allowed): $ACTION" >&2 + exit 1 +fi + +if [[ "$ENGINE" == -* ]]; then + echo "ERROR: invalid engine (leading dash not allowed): $ENGINE" >&2 + exit 1 +fi + +# Validate action +case "$ACTION" in + start|stop|restart) + ;; + *) + echo "ERROR: invalid action '$ACTION'. Allowed: start, stop, restart" >&2 + exit 1 + ;; +esac + +# Validate engine and resolve service name. +# Keep in sync with EngineManager::$extraCandidates in app/Databases/EngineManager.php +case "$ENGINE" in + mysql) + SERVICE=mysql + ;; + mariadb) + SERVICE=mariadb + ;; + postgres) + SERVICE=postgresql + ;; + *) + echo "ERROR: unknown engine '$ENGINE'. Allowed: mysql, mariadb, postgres" >&2 + exit 1 + ;; +esac + +echo "Running: systemctl $ACTION $ENGINE" + +if [ "$ENGINE" = "postgres" ]; then + # Try the generic alias first; fall back to versioned unit on Ubuntu. + # Keep in sync with EngineManager::$extraCandidates in app/Databases/EngineManager.php + if ! systemctl "$ACTION" "$SERVICE" 2>/dev/null; then + if ! systemctl "$ACTION" "postgresql@16-main"; then + echo "ERROR: systemctl $ACTION postgresql@16-main also failed" >&2 + exit 1 + fi + fi +else + systemctl "$ACTION" "$SERVICE" +fi + +echo "Done: systemctl $ACTION $ENGINE" diff --git a/laranode-scripts/bin/laranode-installer.sh b/laranode-scripts/bin/laranode-installer.sh index 3b8ef1a..95ed806 100755 --- a/laranode-scripts/bin/laranode-installer.sh +++ b/laranode-scripts/bin/laranode-installer.sh @@ -5,6 +5,15 @@ export DEBIAN_FRONTEND=noninteractive +# Where the panel lives, and where to clone it from. LARANODE_REPO is overridable +# so the clean-room installer test can inject a local checkout instead of GitHub. +PANEL_PATH=/home/laranode_ln/panel +LARANODE_REPO="${LARANODE_REPO:-https://github.com/alexandre433/laranode.git}" + +# ============================================================================== +# 1. System packages (no repo needed yet) +# ============================================================================== + echo -e "\033[34m" echo "--------------------------------------------------------------------------------" echo "Installing software-properties-common and git" @@ -12,7 +21,8 @@ echo "-------------------------------------------------------------------------- echo -e "\033[0m" apt update -apt install -y software-properties-common git +# curl/ca-certificates/sudo are used throughout but aren't guaranteed on a bare image +apt install -y software-properties-common git curl ca-certificates sudo openssl echo -e "\033[34m" echo "--------------------------------------------------------------------------------" @@ -22,7 +32,6 @@ echo -e "\033[0m" apt install -y apache2 - echo -e "\033[34m" echo "--------------------------------------------------------------------------------" echo "Installing Sysstat" @@ -30,11 +39,8 @@ echo "-------------------------------------------------------------------------- echo -e "\033[0m" apt-get install -y sysstat -sed -i 's/ENABLED="false"/ENABLED="true"/' /etc/default/sysstat -systemctl restart sysstat systemctl enable sysstat - echo -e "\033[34m" echo "--------------------------------------------------------------------------------" echo "Enabling and starting apache2" @@ -54,7 +60,6 @@ apt install -y mysql-server systemctl enable mysql systemctl start mysql - echo -e "\033[34m" echo "--------------------------------------------------------------------------------" echo "Creating Laranode MySQL User & Database" @@ -94,7 +99,6 @@ apt install -y php8.4 php8.4-fpm php8.4-cli php8.4-common php8.4-curl php8.4-mbs php8.4-gd php8.4-imagick php8.4-intl php8.4-readline php8.4-tokenizer php8.4-fileinfo \ php8.4-soap php8.4-opcache unzip curl - echo -e "\033[34m" echo "--------------------------------------------------------------------------------" echo "Enabling and starting PHP-FPM" @@ -106,128 +110,150 @@ systemctl start php8.4-fpm echo -e "\033[34m" echo "--------------------------------------------------------------------------------" -echo "Enabling proxy_fcgi apache module" +echo "Enabling required apache modules" echo "--------------------------------------------------------------------------------" echo -e "\033[0m" a2enmod proxy_fcgi - -echo -e "\033[34m" -echo "--------------------------------------------------------------------------------" -echo "Enabling rewrite_module apache module" -echo "--------------------------------------------------------------------------------" -echo -e "\033[0m" a2enmod rewrite - -echo -e "\033[34m" -echo "--------------------------------------------------------------------------------" -echo "Enabling setenvif apache module" -echo "--------------------------------------------------------------------------------" -echo -e "\033[0m" a2enmod setenvif - - -echo -e "\033[34m" -echo "--------------------------------------------------------------------------------" -echo "Enabling headers apache module" -echo "--------------------------------------------------------------------------------" -echo -e "\033[0m" a2enmod headers +a2enmod ssl +a2enmod proxy proxy_http +a2enconf php8.4-fpm echo -e "\033[34m" echo "--------------------------------------------------------------------------------" -echo "Enabling ssl apache module" +echo "Installing certbot" echo "--------------------------------------------------------------------------------" echo -e "\033[0m" -a2enmod ssl +apt -y install certbot python3-certbot-apache echo -e "\033[34m" echo "--------------------------------------------------------------------------------" -echo "Installing certbot" +echo "Installing PostgreSQL server + client" echo "--------------------------------------------------------------------------------" echo -e "\033[0m" -apt -y install certbot python3-certbot-apache - +apt install -y postgresql postgresql-client echo -e "\033[34m" echo "--------------------------------------------------------------------------------" -echo "Enabling php8.4-fpm apache configuration" +echo "Installing Composer" echo "--------------------------------------------------------------------------------" echo -e "\033[0m" -a2enconf php8.4-fpm +curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer echo -e "\033[34m" echo "--------------------------------------------------------------------------------" -echo "Restarting apache2" +echo "Installing NodeJS" echo "--------------------------------------------------------------------------------" echo -e "\033[0m" +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt install -y nodejs -systemctl restart apache2 +# ============================================================================== +# 2. Fetch the panel (must happen before anything that reads repo files) +# ============================================================================== echo -e "\033[34m" echo "--------------------------------------------------------------------------------" -echo "Adding www-data to sudoers and allowing to run laranode scripts" +echo "Creating Laranode User" echo "--------------------------------------------------------------------------------" echo -e "\033[0m" - -echo "www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/*.sh, /usr/sbin/a2dissite, /bin/rm /etc/apache2/sites-available/*.conf" >> /etc/sudoers +useradd -m -s /bin/bash laranode_ln 2>/dev/null || true +usermod -aG laranode_ln www-data echo -e "\033[34m" echo "--------------------------------------------------------------------------------" -echo "Installing Composer" +echo "Cloning Laranode from ${LARANODE_REPO}" echo "--------------------------------------------------------------------------------" echo -e "\033[0m" - -curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer +# Idempotent: skip if a checkout is already present (the installer test injects one). +if [ ! -d "${PANEL_PATH}/laranode-scripts" ]; then + git clone "${LARANODE_REPO}" "${PANEL_PATH}" +else + echo "Repo already present at ${PANEL_PATH}, skipping clone." +fi echo -e "\033[34m" echo "--------------------------------------------------------------------------------" -echo "Installing NodeJS" +echo "Installing PHP dependencies + generating app key" echo "--------------------------------------------------------------------------------" echo -e "\033[0m" +cd "${PANEL_PATH}" +composer install --no-interaction +[ -f .env ] || cp .env.example .env +sed -i "s#DB_PASSWORD=.*#DB_PASSWORD=\"$LARANODE_RANDOM_PASS\"#" ".env" +sed -i "s#APP_URL=.*#APP_URL=\"http://$(curl -s icanhazip.com)\"#" ".env" +php artisan key:generate --force -curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - -sudo apt install -y nodejs - +# ============================================================================== +# 3. Privileged plumbing that depends on the repo + .env existing +# ============================================================================== echo -e "\033[34m" echo "--------------------------------------------------------------------------------" -echo "Creating Laranode User" -useradd -m -s /bin/bash laranode_ln -usermod -aG laranode_ln www-data +echo "Installing sudoers drop-ins for www-data" echo "--------------------------------------------------------------------------------" echo -e "\033[0m" +for drop in laranode-panel laranode-cron laranode-runtimes laranode-ufw; do + SRC="${PANEL_PATH}/laranode-scripts/etc/sudoers.d/${drop}" + if ! visudo -c -f "${SRC}"; then + echo "ERROR: sudoers file ${drop} failed syntax check — aborting install" >&2 + exit 1 + fi + install -m 440 "${SRC}" "/etc/sudoers.d/${drop}" +done +# Remove legacy monolithic drop-in if it exists +rm -f /etc/sudoers.d/laranode-postgres + echo -e "\033[34m" echo "--------------------------------------------------------------------------------" -echo "Cloning Laranode" -echo -e "\033[0m" - -git clone https://github.com/crivion/laranode.git /home/laranode_ln/panel +echo "Provisioning PostgreSQL stats-reader role (laranode_pg_reader)" echo "--------------------------------------------------------------------------------" +echo -e "\033[0m" +# Enable and start the versioned unit for Ubuntu 24.04 (so it survives reboots) +systemctl enable --now postgresql@16-main 2>/dev/null || systemctl enable --now postgresql || true + +# Generate a random password for the stats-reader role and write it to .env +PGSQL_READER_PASS=$(openssl rand -base64 18) +PGSQL_PG_TAG=$(head -c 16 /dev/urandom | base64 | tr -dc 'a-z' | head -c 8) +sudo -u postgres psql -v ON_ERROR_STOP=1 --dbname=postgres <> "${PANEL_PATH}/.env" + +# ============================================================================== +# 4. App provisioning: DB, assets, reverb, GPU, vhost +# ============================================================================== echo -e "\033[34m" echo "--------------------------------------------------------------------------------" -echo "Installing Laranode" +echo "Migrating database, seeding, building assets" echo "--------------------------------------------------------------------------------" echo -e "\033[0m" - -cd /home/laranode_ln/panel -composer install -cp .env.example .env -sed -i "s#DB_PASSWORD=.*#DB_PASSWORD=\"$LARANODE_RANDOM_PASS\"#" ".env" -sed -i "s#APP_URL=.*#APP_URL=\"http://$(curl icanhazip.com)\"#" ".env" - -php artisan key:generate -php artisan migrate -php artisan db:seed +php artisan migrate --force +php artisan db:seed --force php artisan storage:link -php artisan reverb:install +php artisan reverb:install --no-interaction +php artisan laranode:detect-gpu -sed -i "s#VITE_REVERB_HOST=.*#VITE_REVERB_HOST=$(curl icanhazip.com)#" ".env" -sed -i "s#REVERB_HOST=.*#REVERB_HOST=$(curl icanhazip.com)#" ".env" +sed -i "s#VITE_REVERB_HOST=.*#VITE_REVERB_HOST=$(curl -s icanhazip.com)#" "${PANEL_PATH}/.env" +sed -i "s#REVERB_HOST=.*#REVERB_HOST=$(curl -s icanhazip.com)#" "${PANEL_PATH}/.env" -cp /home/laranode_ln/panel/laranode-scripts/templates/apache2-default.template /etc/apache2/sites-available/000-default.conf +cp "${PANEL_PATH}/laranode-scripts/templates/apache2-default.template" /etc/apache2/sites-available/000-default.conf echo -e "\033[34m" echo "--------------------------------------------------------------------------------" @@ -237,6 +263,9 @@ echo -e "\033[0m" npm install npm run build +# ============================================================================== +# 5. Services, firewall, permissions +# ============================================================================== echo -e "\033[34m" echo "--------------------------------------------------------------------------------" @@ -244,11 +273,10 @@ echo "Adding systemd services (queue worker and reverb)" echo "--------------------------------------------------------------------------------" echo -e "\033[0m" -cp /home/laranode_ln/panel/laranode-scripts/templates/laranode-queue-worker.service /etc/systemd/system/laranode-queue-worker.service -cp /home/laranode_ln/panel/laranode-scripts/templates/laranode-reverb.service /etc/systemd/system/laranode-reverb.service +cp "${PANEL_PATH}/laranode-scripts/templates/laranode-queue-worker.service" /etc/systemd/system/laranode-queue-worker.service +cp "${PANEL_PATH}/laranode-scripts/templates/laranode-reverb.service" /etc/systemd/system/laranode-reverb.service - -echo -e"\033[34m" +echo -e "\033[34m" echo "--------------------------------------------------------------------------------" echo "Adding default UFW rules for SSH | HTTP | HTTPS | REVERB WEBSOCKETS" echo "--------------------------------------------------------------------------------" @@ -258,7 +286,6 @@ ufw allow 80 ufw allow 443 ufw allow 8080 - echo -e "\033[34m" echo "--------------------------------------------------------------------------------" echo "Setting permissions" @@ -271,7 +298,6 @@ find /home/laranode_ln -type f -exec chmod 660 {} \; find /home/laranode_ln/panel/laranode-scripts/bin -type f -exec chmod 100 {} \; find /home/laranode_ln/panel/storage /home/laranode_ln/panel/bootstrap -type d -exec chmod 775 {} \; - systemctl daemon-reload systemctl enable laranode-queue-worker.service systemctl enable laranode-reverb.service @@ -280,7 +306,6 @@ systemctl start laranode-reverb.service systemctl restart apache2 systemctl restart php8.4-fpm - echo "================================================================================" echo "================================================================================" echo -e "\033[32m --- NOTES ---\033[0m" diff --git a/laranode-scripts/bin/laranode-postgres-sudoers b/laranode-scripts/bin/laranode-postgres-sudoers new file mode 100644 index 0000000..3f1f0ec --- /dev/null +++ b/laranode-scripts/bin/laranode-postgres-sudoers @@ -0,0 +1 @@ +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-postgres.sh diff --git a/laranode-scripts/bin/laranode-postgres.sh b/laranode-scripts/bin/laranode-postgres.sh new file mode 100644 index 0000000..df3d0ff --- /dev/null +++ b/laranode-scripts/bin/laranode-postgres.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# laranode-postgres.sh — Privileged PostgreSQL management helper. +# Called via sudo from the PHP process (www-data). Never store credentials in argv. +# +# Usage: +# laranode-postgres.sh create-db +# laranode-postgres.sh create-user (password via stdin) +# laranode-postgres.sh grant +# laranode-postgres.sh revoke +# laranode-postgres.sh drop-db +# laranode-postgres.sh drop-user +# laranode-postgres.sh update-user-password (password via stdin) + +set -euo pipefail + +# ---- helpers ---------------------------------------------------------------- + +die() { echo "ERROR: $*" >&2; exit 1; } + +# Validate identifier: only [a-zA-Z0-9_] allowed. +assert_safe() { + local val="$1" + if ! echo "$val" | grep -qE '^[a-zA-Z0-9_]+$'; then + die "Unsafe identifier: '$val' — only [a-zA-Z0-9_] allowed." + fi +} + +# Validate an encoding name (e.g. UTF8, LATIN1, SQL_ASCII): only [a-zA-Z0-9_]. +assert_encoding() { + local val="$1" + if ! echo "$val" | grep -qE '^[a-zA-Z0-9_]+$'; then + die "Unsafe encoding: '$val' — only [a-zA-Z0-9_] allowed." + fi +} + +# Validate a locale (e.g. en_US.UTF-8, C, POSIX): only [a-zA-Z0-9_.@-]. +assert_locale() { + local val="$1" + if ! echo "$val" | grep -qE '^[a-zA-Z0-9_.@-]+$'; then + die "Unsafe locale: '$val' — only [a-zA-Z0-9_.@-] allowed." + fi +} + +# Generate a random dollar-quote tag (lowercase alpha, 8 chars) to avoid +# dollar-quoting breakout attacks. +random_tag() { + head -c 16 /dev/urandom | base64 | tr -dc 'a-z' | head -c 8 +} + +# Run SQL as the postgres superuser via stdin (never as a command argument). +run_as_postgres() { + sudo -u postgres psql -v ON_ERROR_STOP=1 "$@" +} + +# Convert a libc-style locale (e.g. "en_US.UTF-8") to an ICU locale tag +# (e.g. "en-US") for PostgreSQL 16+ on Ubuntu 24.04 which uses ICU by default. +# Returns the ICU tag via stdout. Falls back to "und" (undetermined) if not parseable. +libc_to_icu_locale() { + local libc_locale="$1" + # Strip encoding suffix (e.g. .UTF-8, .utf8) + local lang_part="${libc_locale%%.*}" + # Convert underscore to hyphen (en_US -> en-US) + echo "${lang_part//_/-}" +} + +# ---- actions ---------------------------------------------------------------- + +cmd_create_db() { + local name="$1" encoding="${2:-UTF8}" locale="${3:-en_US.UTF-8}" + assert_safe "$name" + assert_encoding "$encoding" + assert_locale "$locale" + + # Detect whether we should use ICU (PostgreSQL 16+ on Ubuntu 24.04). + # We try libc locale first; if PostgreSQL reports "invalid LC_COLLATE locale name" + # we fall back to ICU locale provider. + local icu_locale + icu_locale=$(libc_to_icu_locale "$locale") + + # Try ICU locale provider first (works on PostgreSQL 16 + Ubuntu 24.04). + # Fall back to libc locale if ICU fails. + if run_as_postgres --dbname=postgres </dev/null; then +CREATE DATABASE "$name" + ENCODING '$encoding' + LOCALE_PROVIDER icu + ICU_LOCALE '$icu_locale' + TEMPLATE template0; +REVOKE CONNECT ON DATABASE "$name" FROM PUBLIC; +SQL + return 0 + fi + + # Fallback: libc locale (older PostgreSQL or systems with full locale support) + run_as_postgres --dbname=postgres < +CNF_FILE="$1" +DUMP_FILE="$2" +DB_NAME="$3" + +# Reject any value that could be smuggled as a CLI flag to zcat/mysql. +for v in "$CNF_FILE" "$DUMP_FILE" "$DB_NAME"; do + case "$v" in -*) echo "Argument may not start with '-': $v" >&2; exit 1 ;; esac +done + +[[ "$DB_NAME" =~ ^[A-Za-z0-9_]+$ ]] || { echo "Invalid db name: $DB_NAME" >&2; exit 1; } + +zcat -- "$DUMP_FILE" | mysql --defaults-extra-file="$CNF_FILE" -- "$DB_NAME" diff --git a/laranode-scripts/bin/laranode-restore-files.sh b/laranode-scripts/bin/laranode-restore-files.sh new file mode 100755 index 0000000..7546408 --- /dev/null +++ b/laranode-scripts/bin/laranode-restore-files.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +# Usage: laranode-restore-files.sh +TAR_FILE="$1" +DEST_DIR="$2" +SYS_USER="$3" + +# Reject any value that could be smuggled as a CLI flag to tar/chown. +for v in "$TAR_FILE" "$DEST_DIR" "$SYS_USER"; do + case "$v" in -*) echo "Argument may not start with '-': $v" >&2; exit 1 ;; esac +done +# System user must be a plain account name (no path/colon). +[[ "$SYS_USER" =~ ^[A-Za-z0-9_]+$ ]] || { echo "Invalid system user: $SYS_USER" >&2; exit 1; } + +mkdir -p -- "$DEST_DIR" +# Harden extraction against ownership/permission abuse and path traversal. +# GNU tar strips leading '/' and refuses members escaping the destination by default; +# these flags additionally prevent restoring archived ownership/permissions or +# clobbering existing directory metadata. +tar --no-same-owner --no-same-permissions --no-overwrite-dir \ + -xzf "$TAR_FILE" -C "$DEST_DIR" +chown -R -- "$SYS_USER:$SYS_USER" "$DEST_DIR" diff --git a/laranode-scripts/bin/laranode-runtime-install.sh b/laranode-scripts/bin/laranode-runtime-install.sh new file mode 100644 index 0000000..56ba872 --- /dev/null +++ b/laranode-scripts/bin/laranode-runtime-install.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -euo pipefail + +# laranode-runtime-install.sh +# Installs the given alternative PHP runtime binary. +# Called via sudo by InstallRuntimeService. + +# ---- arg validation ---- +if [ $# -ne 1 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +RUNTIME="$1" + +# Reject leading-dash args +case "$RUNTIME" in + -*) echo "Invalid runtime: $RUNTIME" >&2; exit 1 ;; +esac + +if ! echo "$RUNTIME" | grep -qE '^(frankenphp|swoole)$'; then + echo "Invalid runtime '$RUNTIME'. Must be: frankenphp|swoole" >&2 + exit 1 +fi + +# ---- FrankenPHP ---- +if [ "$RUNTIME" = "frankenphp" ]; then + FRANKENPHP_VERSION="v1.12.4" + FRANKENPHP_SHA256="becd9efc79783a4946fb4802433dc00be32de7e025b60fcab53db4d283a136e9" + FRANKENPHP_URL="https://github.com/dunglas/frankenphp/releases/download/${FRANKENPHP_VERSION}/frankenphp-linux-x86_64" + FRANKENPHP_BIN="/usr/local/bin/frankenphp" + FRANKENPHP_TMP="/tmp/frankenphp" + + # Idempotent: if binary exists and --version works, skip re-download + if [ -f "$FRANKENPHP_BIN" ]; then + if "$FRANKENPHP_BIN" --version >/dev/null 2>&1; then + echo "FrankenPHP ${FRANKENPHP_VERSION} already installed and functional. Skipping." + exit 0 + else + # Binary exists but is corrupt — re-download + echo "FrankenPHP binary exists but '--version' failed (corrupt?). Re-downloading..." + fi + fi + + echo "Downloading FrankenPHP ${FRANKENPHP_VERSION}..." + curl -sfL -o "$FRANKENPHP_TMP" "$FRANKENPHP_URL" + + echo "Verifying SHA-256 checksum..." + if ! echo "${FRANKENPHP_SHA256} ${FRANKENPHP_TMP}" | sha256sum -c -; then + echo "SHA-256 mismatch — aborting install." >&2 + rm -f "$FRANKENPHP_TMP" + exit 1 + fi + + mv "$FRANKENPHP_TMP" "$FRANKENPHP_BIN" + chmod 0755 "$FRANKENPHP_BIN" + + echo "Verifying FrankenPHP binary..." + "$FRANKENPHP_BIN" --version + echo "FrankenPHP installed successfully." + exit 0 +fi + +# ---- Swoole (v2 placeholder) ---- +if [ "$RUNTIME" = "swoole" ]; then + echo "Swoole runtime install not implemented in v1." >&2 + exit 1 +fi diff --git a/laranode-scripts/bin/laranode-runtime-manage.sh b/laranode-scripts/bin/laranode-runtime-manage.sh new file mode 100644 index 0000000..bcf45f4 --- /dev/null +++ b/laranode-scripts/bin/laranode-runtime-manage.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail + +# laranode-runtime-manage.sh +# Manages systemd lifecycle for a Laranode runtime unit. +# Called via sudo by SwitchRuntimeService. + +# ---- arg validation ---- +if [ $# -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +ACTION="$1" +UNIT="$2" + +# Reject leading-dash args +case "$ACTION" in -*) echo "Invalid action: $ACTION" >&2; exit 1 ;; esac +case "$UNIT" in -*) echo "Invalid unit: $UNIT" >&2; exit 1 ;; esac + +# Validate action +if ! echo "$ACTION" | grep -qE '^(enable|disable|start|stop|restart|status|remove)$'; then + echo "Invalid action '$ACTION'. Must be: enable|disable|start|stop|restart|status|remove" >&2 + exit 1 +fi + +# Validate unit name — must be a Laranode runtime unit only +# Pattern: laranode-(frankenphp|swoole)-.service +# Slug: alphanumeric, dots, hyphens, underscores — NO slashes (prevents path traversal) +if ! echo "$UNIT" | grep -qE '^laranode-(frankenphp|swoole)-[a-zA-Z0-9._-]+\.service$'; then + echo "Invalid unit name '$UNIT'. Must match laranode-(frankenphp|swoole)-.service" >&2 + exit 1 +fi +# Reject consecutive dots in unit name (punch-list #4 — prevents foo..bar.service traversal) +if echo "$UNIT" | grep -qE '\.\.'; then + echo "Invalid unit name '$UNIT': consecutive dots not allowed." >&2 + exit 1 +fi + +# ---- dispatch ---- +if [ "$ACTION" = "remove" ]; then + systemctl disable --now "$UNIT" 2>/dev/null || true + rm -f "/etc/systemd/system/$UNIT" + systemctl daemon-reload + echo "Removed unit $UNIT" + exit 0 +fi + +systemctl "$ACTION" "$UNIT" diff --git a/laranode-scripts/bin/laranode-runtime-unit.sh b/laranode-scripts/bin/laranode-runtime-unit.sh new file mode 100644 index 0000000..b39da06 --- /dev/null +++ b/laranode-scripts/bin/laranode-runtime-unit.sh @@ -0,0 +1,81 @@ +#!/bin/bash +set -euo pipefail + +# laranode-runtime-unit.sh +# Writes a systemd unit file for a Laranode runtime and reloads the daemon. +# Called via sudo by SwitchRuntimeService. + +# ---- arg validation ---- +if [ $# -ne 6 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +SUBCMD="$1" +DOMAIN="$2" +PORT="$3" +SYSTEM_USER="$4" +DOCUMENT_ROOT="$5" +TEMPLATE_DIR="$6" + +# Reject leading-dash args (ALL args — punch-list #6) +for arg in "$SUBCMD" "$DOMAIN" "$PORT" "$SYSTEM_USER" "$DOCUMENT_ROOT" "$TEMPLATE_DIR"; do + case "$arg" in -*) echo "Invalid argument: $arg" >&2; exit 1 ;; esac +done + +# Validate sub-command +if ! echo "$SUBCMD" | grep -qE '^(write-unit)$'; then + echo "Invalid sub-command '$SUBCMD'. Must be: write-unit" >&2 + exit 1 +fi + +# Validate domain — no leading dot, no consecutive dots, no path traversal +# Must start with alphanumeric, contain only alphanumeric/dot/hyphen +if ! echo "$DOMAIN" | grep -qE '^[a-zA-Z0-9][a-zA-Z0-9.-]+$'; then + echo "Invalid domain '$DOMAIN'." >&2 + exit 1 +fi +# Reject consecutive dots (e.g. ..evil.com) +if echo "$DOMAIN" | grep -qE '\.\.'; then + echo "Invalid domain '$DOMAIN': consecutive dots not allowed." >&2 + exit 1 +fi + +# Validate port — must be numeric and in range 9100–9499 +if ! echo "$PORT" | grep -qE '^[0-9]+$'; then + echo "Invalid port '$PORT': must be numeric." >&2 + exit 1 +fi +if [ "$PORT" -lt 9100 ] || [ "$PORT" -gt 9499 ]; then + echo "Port $PORT out of allowed range 9100–9499." >&2 + exit 1 +fi + +# Validate system_user — must end in _ln +if ! echo "$SYSTEM_USER" | grep -qE '_ln$'; then + echo "Invalid system_user '$SYSTEM_USER': must end in _ln." >&2 + exit 1 +fi + +# ---- write-unit ---- +TEMPLATE_FILE="$TEMPLATE_DIR/laranode-frankenphp.service.template" +UNIT_NAME="laranode-frankenphp-${DOMAIN}.service" +UNIT_PATH="/etc/systemd/system/${UNIT_NAME}" + +if [ ! -f "$TEMPLATE_FILE" ]; then + echo "Template not found: $TEMPLATE_FILE" >&2 + exit 1 +fi + +UNIT_CONTENT=$(cat "$TEMPLATE_FILE") +UNIT_CONTENT=$(echo "$UNIT_CONTENT" | sed "s#{user}#${SYSTEM_USER}#g") +UNIT_CONTENT=$(echo "$UNIT_CONTENT" | sed "s#{domain}#${DOMAIN}#g") +UNIT_CONTENT=$(echo "$UNIT_CONTENT" | sed "s#{port}#${PORT}#g") +UNIT_CONTENT=$(echo "$UNIT_CONTENT" | sed "s#{document_root}#${DOCUMENT_ROOT}#g") + +echo "$UNIT_CONTENT" > "$UNIT_PATH" +echo "Written unit file: $UNIT_PATH" + +# daemon-reload MUST happen before enable/start (FIXED: sequencing) +systemctl daemon-reload +echo "daemon-reload complete." diff --git a/laranode-scripts/bin/laranode-vhost-switch.sh b/laranode-scripts/bin/laranode-vhost-switch.sh new file mode 100644 index 0000000..80a5ce3 --- /dev/null +++ b/laranode-scripts/bin/laranode-vhost-switch.sh @@ -0,0 +1,94 @@ +#!/bin/bash +set -euo pipefail + +# laranode-vhost-switch.sh +# Writes Apache vhost for the given runtime. Does NOT touch systemd units. +# Called via sudo by SwitchRuntimeService. + +# ---- arg validation ---- +if [ $# -ne 7 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +DOMAIN="$1" +RUNTIME="$2" +PORT="$3" +SYSTEM_USER="$4" +PHP_VERSION="$5" +DOCUMENT_ROOT="$6" +TEMPLATE_DIR="$7" + +# Reject leading-dash args (ALL args — punch-list #6) +for arg in "$DOMAIN" "$RUNTIME" "$PORT" "$SYSTEM_USER" "$PHP_VERSION" "$DOCUMENT_ROOT" "$TEMPLATE_DIR"; do + case "$arg" in -*) echo "Invalid argument: $arg" >&2; exit 1 ;; esac +done + +# Validate domain — no leading dot, no consecutive dots, no path traversal +if ! echo "$DOMAIN" | grep -qE '^[a-zA-Z0-9][a-zA-Z0-9.-]+$'; then + echo "Invalid domain '$DOMAIN'." >&2 + exit 1 +fi +if echo "$DOMAIN" | grep -qE '\.\.'; then + echo "Invalid domain '$DOMAIN': consecutive dots not allowed." >&2 + exit 1 +fi + +# Validate runtime +if ! echo "$RUNTIME" | grep -qE '^(php-fpm|frankenphp|swoole)$'; then + echo "Invalid runtime '$RUNTIME'. Must be: php-fpm|frankenphp|swoole" >&2 + exit 1 +fi + +# Validate port — must be numeric +if ! echo "$PORT" | grep -qE '^[0-9]+$'; then + echo "Invalid port '$PORT': must be numeric." >&2 + exit 1 +fi + +# Port range check ONLY when runtime is not php-fpm +# (FPM revert passes port=0 which is intentionally outside range — FIXED) +if [ "$RUNTIME" != "php-fpm" ]; then + if [ "$PORT" -lt 9100 ] || [ "$PORT" -gt 9499 ]; then + echo "Port $PORT out of allowed range 9100–9499 for runtime '$RUNTIME'." >&2 + exit 1 + fi +fi + +# Validate system_user — must end in _ln +if ! echo "$SYSTEM_USER" | grep -qE '_ln$'; then + echo "Invalid system_user '$SYSTEM_USER': must end in _ln." >&2 + exit 1 +fi + +# ---- select template ---- +if [ "$RUNTIME" = "frankenphp" ] || [ "$RUNTIME" = "swoole" ]; then + TEMPLATE_FILE="$TEMPLATE_DIR/apache-vhost-frankenphp.template" +else + # php-fpm + TEMPLATE_FILE="$TEMPLATE_DIR/apache-vhost.template" +fi + +if [ ! -f "$TEMPLATE_FILE" ]; then + echo "Template not found: $TEMPLATE_FILE" >&2 + exit 1 +fi + +# ---- substitute placeholders ---- +VHOST_CONTENT=$(cat "$TEMPLATE_FILE") +VHOST_CONTENT=$(echo "$VHOST_CONTENT" | sed "s#{domain}#${DOMAIN}#g") +VHOST_CONTENT=$(echo "$VHOST_CONTENT" | sed "s#{user}#${SYSTEM_USER}#g") +VHOST_CONTENT=$(echo "$VHOST_CONTENT" | sed "s#{document_root}#${DOCUMENT_ROOT}#g") +VHOST_CONTENT=$(echo "$VHOST_CONTENT" | sed "s#{port}#${PORT}#g") +# {phpVersion} is used by FPM template; FrankenPHP template has no {phpVersion} placeholder +# (FrankenPHP uses its bundled PHP). The php_version arg is accepted but unused for FrankenPHP. +VHOST_CONTENT=$(echo "$VHOST_CONTENT" | sed "s#{phpVersion}#${PHP_VERSION}#g") + +# Write vhost (Apache only — no unit file here) +echo "$VHOST_CONTENT" > "/etc/apache2/sites-available/${DOMAIN}.conf" +echo "Written vhost: /etc/apache2/sites-available/${DOMAIN}.conf" + +# Enable + reload Apache +a2ensite "$DOMAIN" +apache2ctl graceful +echo "Apache reloaded for $DOMAIN." diff --git a/laranode-scripts/etc/sudoers.d/laranode-cron b/laranode-scripts/etc/sudoers.d/laranode-cron new file mode 100644 index 0000000..0605aa3 --- /dev/null +++ b/laranode-scripts/etc/sudoers.d/laranode-cron @@ -0,0 +1,2 @@ +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-cron.sh !requiretty +www-data ALL=(www-data) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-cron.sh diff --git a/laranode-scripts/etc/sudoers.d/laranode-panel b/laranode-scripts/etc/sudoers.d/laranode-panel new file mode 100644 index 0000000..4b6ac11 --- /dev/null +++ b/laranode-scripts/etc/sudoers.d/laranode-panel @@ -0,0 +1,51 @@ +# Laranode panel sudoers drop-in +# Mode 0440. Managed by laranode-installer.sh — do not edit manually. +# One explicit NOPASSWD entry per privileged script; no wildcards. + +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-add-php-fpm-pool.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-add-vhost.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-backup-files.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-create-directory.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-db-backup.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-file-permissions.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-php-install.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-php-list.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-php-service.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-php-uninstall.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-postgres.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-remove-all-user-php-fpm-pools.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-remove-php-fpm-pool-for-user.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-restart-php-fpm.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-restore-db.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-restore-files.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-ssl-manager.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-update-php-version.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-update-sh-access.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-update-sh-password.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-user-manager.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-db-service.sh !requiretty + +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-add-php-fpm-pool.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-add-vhost.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-backup-files.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-create-directory.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-db-backup.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-file-permissions.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-php-install.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-php-list.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-php-service.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-php-uninstall.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-postgres.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-remove-all-user-php-fpm-pools.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-remove-php-fpm-pool-for-user.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-restart-php-fpm.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-restore-db.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-restore-files.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-ssl-manager.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-update-php-version.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-update-sh-access.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-update-sh-password.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-user-manager.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-db-service.sh +www-data ALL=(ALL) NOPASSWD: /usr/sbin/a2dissite +www-data ALL=(ALL) NOPASSWD: /bin/rm /etc/apache2/sites-available/*.conf diff --git a/laranode-scripts/etc/sudoers.d/laranode-runtimes b/laranode-scripts/etc/sudoers.d/laranode-runtimes new file mode 100644 index 0000000..9dd6f81 --- /dev/null +++ b/laranode-scripts/etc/sudoers.d/laranode-runtimes @@ -0,0 +1,9 @@ +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-runtime-install.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-runtime-manage.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-runtime-unit.sh !requiretty +Defaults!/home/laranode_ln/panel/laranode-scripts/bin/laranode-vhost-switch.sh !requiretty + +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-runtime-install.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-runtime-manage.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-runtime-unit.sh +www-data ALL=(ALL) NOPASSWD: /home/laranode_ln/panel/laranode-scripts/bin/laranode-vhost-switch.sh diff --git a/laranode-scripts/etc/sudoers.d/laranode-ufw b/laranode-scripts/etc/sudoers.d/laranode-ufw new file mode 100644 index 0000000..9694d56 --- /dev/null +++ b/laranode-scripts/etc/sudoers.d/laranode-ufw @@ -0,0 +1,9 @@ +# Laranode firewall (UFW) sudoers drop-in +# Mode 0440. Managed by laranode-installer.sh — do not edit manually. +# The firewall Actions call `sudo ufw ...` directly (status/show/allow/deny/ +# delete/enable/disable). Grant www-data NOPASSWD for the ufw binary only, +# and disable requiretty so it works from the web SAPI (no controlling tty). +# Admin-gated in-app; enabling is protected by the lockout guard. + +Defaults!/usr/sbin/ufw !requiretty +www-data ALL=(ALL) NOPASSWD: /usr/sbin/ufw diff --git a/laranode-scripts/templates/apache-vhost-frankenphp.template b/laranode-scripts/templates/apache-vhost-frankenphp.template new file mode 100644 index 0000000..fd6dc68 --- /dev/null +++ b/laranode-scripts/templates/apache-vhost-frankenphp.template @@ -0,0 +1,27 @@ + + ServerName {domain} + ServerAlias www.{domain} + + DocumentRoot /home/{user}/domains/{domain}{document_root} + + ErrorLog /home/{user}/logs/apache-error.log + CustomLog /home/{user}/logs/apache-access.log combined + + # ACME challenge served from disk (required for certbot --webroot renewal) + ProxyPass /.well-known/acme-challenge/ ! + + Options None + AllowOverride None + Require all granted + + + ProxyPreserveHost On + ProxyPass / http://127.0.0.1:{port}/ + ProxyPassReverse / http://127.0.0.1:{port}/ + + + AllowOverride None + Require all granted + + + diff --git a/laranode-scripts/templates/laranode-frankenphp.service.template b/laranode-scripts/templates/laranode-frankenphp.service.template new file mode 100644 index 0000000..7acce14 --- /dev/null +++ b/laranode-scripts/templates/laranode-frankenphp.service.template @@ -0,0 +1,16 @@ +[Unit] +Description=Laranode FrankenPHP site server for {domain} +After=network-online.target +Wants=network-online.target + +[Service] +User={user} +Group={user} +WorkingDirectory=/home/{user}/domains/{domain}{document_root} +ExecStart=/usr/local/bin/frankenphp php-server --listen 127.0.0.1:{port} --root /home/{user}/domains/{domain}{document_root} +Restart=on-failure +RestartSec=5s +SyslogIdentifier=laranode-frankenphp-{domain} + +[Install] +WantedBy=multi-user.target diff --git a/local-dev/.env.docker b/local-dev/.env.docker new file mode 100644 index 0000000..d3f2ae1 --- /dev/null +++ b/local-dev/.env.docker @@ -0,0 +1,56 @@ +# ============================================================================= +# LOCAL DEV ONLY — do NOT use on a public/production host. +# Ships APP_DEBUG=true and fixed, insecure credentials (DB + admin password). +# The production installer uses .env.example and never reads this file. +# ============================================================================= +APP_NAME=Laranode +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost + +LOG_CHANNEL=stack +LOG_LEVEL=debug + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=laranode +DB_USERNAME=laranode +DB_PASSWORD=laranode_local_dev_pw + +SESSION_DRIVER=database +QUEUE_CONNECTION=database +CACHE_STORE=database +BROADCAST_CONNECTION=reverb +FILESYSTEM_DISK=local + +REVERB_APP_ID=laranode +REVERB_APP_KEY=laranode-key +REVERB_APP_SECRET=laranode-secret +REVERB_HOST=localhost +REVERB_PORT=8080 +REVERB_SCHEME=http + +VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" +VITE_REVERB_HOST=localhost +VITE_REVERB_PORT=8080 +VITE_REVERB_SCHEME=http + +MYSQL_ADMIN_HOST=127.0.0.1 +MYSQL_ADMIN_PORT=3306 +MYSQL_ADMIN_DB=mysql +MYSQL_ADMIN_USERNAME=laranode +MYSQL_ADMIN_PASSWORD=laranode_local_dev_pw + +PGSQL_HOST=127.0.0.1 +PGSQL_PORT=5432 +PGSQL_DB=postgres +PGSQL_USERNAME=laranode_pg_reader +PGSQL_PASSWORD=pg_reader_local_dev + +# Local-dev only — consumed by entrypoint-setup.sh, NOT by upstream code paths +LARANODE_BIN_PATH=/opt/laranode/bin +LARANODE_ACME_SERVER=https://pebble:14000/dir +ADMIN_EMAIL=admin@laranode.test +ADMIN_PASSWORD=password diff --git a/local-dev/Dockerfile b/local-dev/Dockerfile new file mode 100644 index 0000000..71f35f9 --- /dev/null +++ b/local-dev/Dockerfile @@ -0,0 +1,54 @@ +# Proven on this machine: jrei/systemd-ubuntu:24.04 boots systemd as PID 1 under +# Docker Desktop / WSL2 (cgroup2fs). It sets STOPSIGNAL + CMD [/lib/systemd/systemd] +# and masks the noisy units for us. +FROM jrei/systemd-ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Block package post-install scripts from trying to start services during BUILD +# (no systemd running in a build layer). Runtime systemctl is unaffected. +RUN printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d && chmod +x /usr/sbin/policy-rc.d + +# Base tooling + Apache + MySQL + PostgreSQL + sysstat + ufw + certbot + the ondrej PPA. +RUN apt-get update && apt-get install -y \ + software-properties-common git curl unzip openssl ca-certificates \ + iproute2 dbus sudo \ + locales \ + apache2 \ + mysql-server \ + postgresql-16 postgresql-client-16 \ + sysstat \ + ufw \ + certbot python3-certbot-apache \ + && add-apt-repository -y ppa:ondrej/php \ + && apt-get update \ + && locale-gen en_US.UTF-8 \ + && rm -rf /var/lib/apt/lists/* + +# PHP 8.4 + the exact extension set from laranode-scripts/bin/laranode-installer.sh +RUN apt-get update && apt-get install -y \ + php8.4 php8.4-fpm php8.4-cli php8.4-common php8.4-curl php8.4-mbstring \ + php8.4-xml php8.4-bcmath php8.4-zip php8.4-mysql php8.4-sqlite3 php8.4-pgsql \ + php8.4-gd php8.4-imagick php8.4-intl php8.4-readline php8.4-tokenizer php8.4-fileinfo \ + php8.4-soap php8.4-opcache \ + && rm -rf /var/lib/apt/lists/* + +# Composer (php is present now) + Node 22 +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Apache modules + php-fpm conf, enabled at build (no service start needed for a2enmod) +RUN a2enmod proxy_fcgi rewrite setenvif headers ssl && a2enconf php8.4-fpm + +# Panel system user; www-data shares its group so Apache can read panel files +RUN useradd -m -s /bin/bash laranode_ln && usermod -aG laranode_ln www-data \ + && mkdir -p /home/laranode_ln/logs + +# Snapshot the privileged scripts to a Linux-native path (entrypoint copies these +# to /opt/laranode/bin with +x; the bind-mounted copies can't be relied on for exec). +COPY laranode-scripts/bin/ /opt/laranode/bin-src/ +RUN chmod -R 0755 /opt/laranode/bin-src + +# systemd remains PID 1 from the base image (CMD + STOPSIGNAL inherited). diff --git a/local-dev/Makefile b/local-dev/Makefile new file mode 100644 index 0000000..f3bc83c --- /dev/null +++ b/local-dev/Makefile @@ -0,0 +1,64 @@ +# Local dev environment — Docker-based "VPS-in-a-box" for Laranode. +# +# WINDOWS REQUIREMENT: Run make and docker compose from PowerShell or cmd. +# Git Bash (MSYS) strips the Windows environment that docker.exe needs to find +# its compose plugin — recipes will fail with "unknown command: docker compose". +# Plain `docker exec laranode-lab ...` works from any shell. +# +# Run all recipes from the REPO ROOT (not from inside local-dev/). +# +# MSYS (the make/sh that ships with Git Bash & MSYS2) rewrites in-container +# absolute paths like /home/... into Windows paths (C:/msys64/home/...) when it +# calls the native docker.exe — which breaks `exec` of in-container scripts. +# Disable that conversion for every recipe. No-op on Linux/macOS. +export MSYS_NO_PATHCONV := 1 +export MSYS2_ARG_CONV_EXCL := * + +COMPOSE = docker compose -f local-dev/docker-compose.yml +EXEC = $(COMPOSE) exec laranode bash -lc + +.PHONY: up provision sh verify test test-system build-assets sync-scripts logs nuke ssl-test + +up: + $(COMPOSE) up -d --build + $(MAKE) -f local-dev/Makefile provision + +provision: + $(EXEC) '/home/laranode_ln/panel/local-dev/entrypoint-setup.sh' + +sh: + $(COMPOSE) exec laranode bash + +verify: + $(EXEC) 'ps -p 1 -o comm=; systemctl is-system-running || true; for s in apache2 mysql php8.4-fpm laranode-reverb laranode-queue-worker; do printf "%s: " "$$s"; systemctl is-active $$s; done' + @echo "--- HTTP check ---" + @curl -s -o /dev/null -w "panel http status: %{http_code}\n" http://localhost || true + +# Clear cached config first: with config cached, phpunit.xml's DB_CONNECTION=sqlite +# override is ignored and RefreshDatabase runs against the live MySQL panel DB, +# wiping it. Re-cache afterwards (Reverb relies on it), preserving the exit code. +test: + $(EXEC) 'cd /home/laranode_ln/panel && php artisan config:clear && php artisan test; rc=$$?; php artisan config:cache >/dev/null; exit $$rc' + +test-system: + $(EXEC) 'cd /home/laranode_ln/panel && php artisan config:clear && LARANODE_SYSTEM_TESTS=1 php artisan test; rc=$$?; php artisan config:cache >/dev/null; exit $$rc' + +build-assets: + $(EXEC) 'cd /home/laranode_ln/panel && npm run build' + +# Clean-room test of the REAL production installer on a vanilla ubuntu:24.04. +install-test: + bash local-dev/install-test/run.sh + +sync-scripts: + $(EXEC) 'cp -f /opt/laranode/bin-src/*.sh /opt/laranode/bin/ && cp -f /home/laranode_ln/panel/local-dev/bin/laranode-ssl-manager.sh /opt/laranode/bin/ && chmod -R 0755 /opt/laranode/bin' + +ssl-test: + $(COMPOSE) --profile ssl up -d + $(EXEC) 'sudo LARANODE_ACME_SERVER=$${LARANODE_ACME_SERVER:-https://pebble:14000/dir} /opt/laranode/bin/laranode-ssl-manager.sh status localhost || true' + +logs: + $(COMPOSE) logs -f + +nuke: + $(COMPOSE) --profile ssl down -v diff --git a/local-dev/bin/laranode-ssl-manager.sh b/local-dev/bin/laranode-ssl-manager.sh new file mode 100644 index 0000000..b93f9bc --- /dev/null +++ b/local-dev/bin/laranode-ssl-manager.sh @@ -0,0 +1,279 @@ +#!/bin/bash + +# SSL Certificate Manager for Laranode — LOCAL-DEV PATCHED COPY +# Patched copy of laranode-scripts/bin/laranode-ssl-manager.sh with local deltas: +# (1) check_domain_accessibility warns instead of exit 1 (local domains aren't public) +# (2) certbot targets $LARANODE_ACME_SERVER (defaults to the Pebble sidecar) with --no-verify-ssl +# Re-sync these deltas if the upstream script changes. + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +WEBROOT_PATH="/var/www/html" +CERTBOT_PATH="/usr/bin/certbot" +APACHE_SITES_PATH="/etc/apache2/sites-available" +APACHE_ENABLED_PATH="/etc/apache2/sites-enabled" +SSL_CERTS_PATH="/etc/letsencrypt/live" + +# Local-dev: default the ACME server to the Pebble sidecar so the panel's SSL +# toggle (GenerateWebsiteSslAction → sudo this script, which does not pass env) +# targets Pebble instead of real Let's Encrypt. Export LARANODE_ACME_SERVER to override. +LARANODE_ACME_SERVER="${LARANODE_ACME_SERVER:-https://pebble:14000/dir}" + +# Function to print colored output +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to check if certbot is installed +check_certbot() { + if ! command -v certbot &> /dev/null; then + print_error "Certbot is not installed. Please install it first:" + echo "sudo apt update && sudo apt install certbot python3-certbot-apache" + exit 1 + fi +} + +# Function to check if domain is accessible +check_domain_accessibility() { + local domain=$1 + print_status "Checking if domain $domain is accessible..." + if ! curl -s --connect-timeout 10 "http://$domain" > /dev/null; then + print_warning "Domain $domain not reachable over HTTP — continuing anyway (local dev)." + else + print_status "Domain $domain is accessible" + fi +} + +# Function to generate SSL certificate +generate_ssl_certificate() { + local domain=$1 + local email=$2 + local document_root=$3 + local webroot_path + + # Prefer provided document root; fallback to default WEBROOT_PATH + if [ -n "$document_root" ]; then + webroot_path="$document_root" + else + webroot_path="$WEBROOT_PATH" + fi + + print_status "Generating SSL certificate for $domain..." + + # Check if certificate already exists + if [ -d "$SSL_CERTS_PATH/$domain" ]; then + print_warning "SSL certificate for $domain already exists" + return 0 + fi + + local acme_args=() + if [ -n "$LARANODE_ACME_SERVER" ]; then + acme_args=(--server "$LARANODE_ACME_SERVER" --no-verify-ssl) + fi + + if certbot certonly \ + --webroot \ + --webroot-path="$webroot_path" \ + --email "$email" \ + --agree-tos \ + --no-eff-email \ + --domains "$domain" \ + --non-interactive \ + "${acme_args[@]}"; then + print_status "SSL certificate generated successfully for $domain" + return 0 + else + print_error "Failed to generate SSL certificate for $domain" + return 1 + fi +} + +# Function to create SSL-enabled Apache virtual host +create_ssl_vhost() { + local domain=$1 + local document_root=$2 + + print_status "Creating SSL-enabled virtual host for $domain..." + + local non_ssl_vhost="$APACHE_SITES_PATH/$domain.conf" + local vhost_file="$APACHE_SITES_PATH/$domain-ssl.conf" + + if [[ ! -f "$non_ssl_vhost" ]]; then + print_error "Non-SSL vhost file not found: $non_ssl_vhost" + return 1 + fi + + # Extract everything between and + local inner_content + inner_content=$(awk ' + //{flag=0} + flag + ' "$non_ssl_vhost") + + { + echo "" + echo + echo " SSLEngine on" + echo " SSLCertificateFile $SSL_CERTS_PATH/$domain/fullchain.pem" + echo " SSLCertificateKeyFile $SSL_CERTS_PATH/$domain/privkey.pem" + echo + echo "$inner_content" | sed 's/^/ /' + echo "" + echo + echo "# Redirect HTTP to HTTPS" + echo "" + echo " ServerName $domain" + echo " Redirect permanent / https://$domain/" + echo "" + } > "$vhost_file" + + # Enable the SSL site + a2ensite "$domain-ssl.conf" + + # Test Apache configuration + if apache2ctl configtest; then + systemctl reload apache2 + print_status "SSL virtual host created and enabled for $domain" + return 0 + else + print_error "Apache configuration test failed" + return 1 + fi +} + + +# Function to remove SSL certificate +remove_ssl_certificate() { + local domain=$1 + + print_status "Removing SSL certificate for $domain..." + + # Disable SSL site + if [ -f "$APACHE_SITES_PATH/$domain-ssl.conf" ]; then + a2dissite "$domain-ssl.conf" + rm -f "$APACHE_SITES_PATH/$domain-ssl.conf" + fi + + # Remove certificate files + if [ -d "$SSL_CERTS_PATH/$domain" ]; then + certbot delete --cert-name "$domain" --non-interactive + print_status "SSL certificate removed for $domain" + else + print_warning "No SSL certificate found for $domain" + fi + + # Reload Apache + systemctl reload apache2 + print_status "SSL configuration removed for $domain" +} + +# Function to check SSL certificate status +check_ssl_status() { + local domain=$1 + + if [ -d "$SSL_CERTS_PATH/$domain" ]; then + # Check if certificate is valid and not expired + local cert_file="$SSL_CERTS_PATH/$domain/fullchain.pem" + if [ -f "$cert_file" ]; then + local expiry_date=$(openssl x509 -in "$cert_file" -noout -enddate | cut -d= -f2) + local expiry_timestamp=$(date -d "$expiry_date" +%s) + local current_timestamp=$(date +%s) + + if [ $expiry_timestamp -gt $current_timestamp ]; then + echo "active" + return 0 + else + echo "expired" + return 1 + fi + fi + fi + + echo "inactive" + return 1 +} + +# Function to renew SSL certificates +renew_ssl_certificates() { + print_status "Renewing SSL certificates..." + + if certbot renew --quiet; then + systemctl reload apache2 + print_status "SSL certificates renewed successfully" + return 0 + else + print_error "Failed to renew SSL certificates" + return 1 + fi +} + +# Main script logic +case "$1" in + "generate") + if [ $# -lt 3 ]; then + echo "Usage: $0 generate [document_root]" + exit 1 + fi + + domain=$2 + email=$3 + document_root=$4 + + check_certbot + check_domain_accessibility "$domain" + generate_ssl_certificate "$domain" "$email" "$document_root" + create_ssl_vhost "$domain" "$document_root" + ;; + + "remove") + if [ $# -ne 2 ]; then + echo "Usage: $0 remove " + exit 1 + fi + + domain=$2 + remove_ssl_certificate "$domain" + ;; + + "status") + if [ $# -ne 2 ]; then + echo "Usage: $0 status " + exit 1 + fi + + domain=$2 + status=$(check_ssl_status "$domain") + echo "$status" + ;; + + "renew") + renew_ssl_certificates + ;; + + *) + echo "Usage: $0 {generate|remove|status|renew}" + echo "" + echo "Commands:" + echo " generate [document_root] - Generate SSL certificate for domain" + echo " remove - Remove SSL certificate for domain" + echo " status - Check SSL certificate status" + echo " renew - Renew all SSL certificates" + exit 1 + ;; +esac diff --git a/local-dev/docker-compose.yml b/local-dev/docker-compose.yml new file mode 100644 index 0000000..3c0e085 --- /dev/null +++ b/local-dev/docker-compose.yml @@ -0,0 +1,71 @@ +services: + laranode: + build: + context: .. + dockerfile: local-dev/Dockerfile + image: laranode-lab:dev + container_name: laranode-lab + privileged: true + cgroup: host + cap_add: + - NET_ADMIN + - NET_RAW + stop_signal: SIGRTMIN+3 + volumes: + - ../:/home/laranode_ln/panel + - /sys/fs/cgroup:/sys/fs/cgroup:rw + - laranode-vendor:/home/laranode_ln/panel/vendor + - laranode-node-modules:/home/laranode_ln/panel/node_modules + - laranode-mysql:/var/lib/mysql + - laranode-postgres:/var/lib/postgresql + tmpfs: + - /run + - /run/lock + - /tmp + ports: + - "80:80" + - "443:443" + - "8080:8080" + - "5173:5173" + - "3306:3306" + networks: + default: + # fixed IP so the container can't DHCP-grab pebble's 10.30.50.2 (ssl profile) + ipv4_address: 10.30.50.10 + + pebble: + image: ghcr.io/letsencrypt/pebble:latest + profiles: ["ssl"] + command: -config /test/config/pebble-config.json -dnsserver 10.30.50.3:8053 + environment: + PEBBLE_VA_ALWAYS_VALID: "1" + ports: + - "14000:14000" + - "15000:15000" + networks: + default: + ipv4_address: 10.30.50.2 + depends_on: + - challtestsrv + + challtestsrv: + image: ghcr.io/letsencrypt/pebble-challtestsrv:latest + profiles: ["ssl"] + command: -defaultIPv4 "" + ports: + - "8055:8055" + networks: + default: + ipv4_address: 10.30.50.3 + +networks: + default: + ipam: + config: + - subnet: 10.30.50.0/24 + +volumes: + laranode-vendor: + laranode-node-modules: + laranode-mysql: + laranode-postgres: diff --git a/local-dev/entrypoint-setup.sh b/local-dev/entrypoint-setup.sh new file mode 100644 index 0000000..efa3cf7 --- /dev/null +++ b/local-dev/entrypoint-setup.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +set -euo pipefail + +PANEL=/home/laranode_ln/panel +BIN=/opt/laranode/bin +SENTINEL=/home/laranode_ln/.laranode-setup-done + +log() { echo -e "\033[34m[setup]\033[0m $*"; } + +[ -f "$SENTINEL" ] && { log "already provisioned; skipping."; exit 0; } + +# --- wait for systemd --- +log "waiting for systemd..." +for i in $(seq 1 30); do + state=$(systemctl is-system-running 2>/dev/null || true) + [ "$state" = running ] || [ "$state" = degraded ] && break + sleep 1 +done + +# --- install PostgreSQL if not present --- +if ! dpkg -l postgresql-16 >/dev/null 2>&1; then + log "installing postgresql-16 + client" + apt-get update -qq + apt-get install -y -qq postgresql-16 postgresql-client-16 +fi + +# --- install cron (crontab binary + daemon) if not present (cron-tasks feature) --- +if ! command -v crontab >/dev/null 2>&1; then + log "installing cron" + apt-get update -qq + apt-get install -y -qq cron +fi + +# --- core services --- +log "enabling + starting core services" +sed -i 's/ENABLED="false"/ENABLED="true"/' /etc/default/sysstat || true +systemctl enable --now apache2 mysql php8.4-fpm sysstat cron + +# --- PostgreSQL: start --- +log "starting postgresql@16-main" +systemctl enable --now postgresql@16-main + +# Wait for Postgres socket +for i in $(seq 1 30); do + sudo -u postgres psql -c "SELECT 1" >/dev/null 2>&1 && break + sleep 1 +done +sudo -u postgres psql -c "SELECT 1" >/dev/null 2>&1 || { log "ERROR: postgresql did not start"; exit 1; } + +# --- wait for mysql socket --- +log "waiting for mysql..." +for i in $(seq 1 30); do + mysqladmin -u root ping >/dev/null 2>&1 && break + sleep 1 +done +mysqladmin -u root ping >/dev/null 2>&1 || { log "ERROR: mysql did not start"; exit 1; } + +# --- load env (for DB_PASSWORD, ADMIN_*, PGSQL_PASSWORD, etc.) --- +set -a; . "$PANEL/local-dev/.env.docker"; set +a + +# --- linux-native bin dir with executable scripts + patched ssl-manager --- +log "populating $BIN" +mkdir -p "$BIN" +cp -f /opt/laranode/bin-src/*.sh "$BIN"/ +cp -f "$PANEL/local-dev/bin/laranode-ssl-manager.sh" "$BIN/laranode-ssl-manager.sh" +# Copy any scripts added directly to the panel's bin dir (e.g. laranode-cron.sh) +for f in "$PANEL/laranode-scripts/bin/"*.sh; do + [ -f "$f" ] && cp -f "$f" "$BIN/$(basename "$f")" +done +chmod -R 0755 "$BIN" + +# --- PostgreSQL: provision stats-reader role --- +# PGSQL_PASSWORD is now available from .env.docker (loaded above). +log "provisioning laranode_pg_reader role" +PGSQL_PASSWORD="${PGSQL_PASSWORD:-pg_reader_local_dev}" +PG_TAG=$(head -c 16 /dev/urandom | base64 | tr -dc 'a-z' | head -c 8) +sudo -u postgres psql -v ON_ERROR_STOP=1 --dbname=postgres < /etc/sudoers.d/laranode </dev/null)" ] || composer install --no-interaction +grep -q '^APP_KEY=base64' .env || php artisan key:generate --force +php artisan migrate --force +php artisan db:seed --force || true +php artisan storage:link || true +php artisan reverb:install --no-interaction || true + +# --- node deps + build (only if missing) --- +[ -d node_modules ] && [ -n "$(ls -A node_modules 2>/dev/null)" ] || npm install +[ -d public/build ] || npm run build + +# --- seed admin non-interactively (username 'laranode' to match systemUsername laranode_ln) --- +# ADMIN_EMAIL/ADMIN_PASSWORD live in .env.docker (Laravel env), not the shell, so +# default them here or the admin gets an empty email and you can't log in. +# updateOrCreate (not firstOrCreate) repairs a stale row's email/password too. +ADMIN_EMAIL="${ADMIN_EMAIL:-admin@laranode.test}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-password}" +log "seeding admin (${ADMIN_EMAIL})" +php artisan tinker --execute="\App\Models\User::updateOrCreate(['username' => 'laranode'], ['name' => 'Admin', 'email' => '${ADMIN_EMAIL}', 'password' => bcrypt('${ADMIN_PASSWORD}'), 'role' => 'admin', 'ssh_access' => true, 'email_verified_at' => now()]);" + +# --- detect GPU once (stores the result; not re-probed every poll) --- +log "detecting GPU" +php artisan laranode:detect-gpu || true + +# --- provision test system users for CronJob system tests --- +log "provisioning test system users (testuser_ln, testuser2_ln)" +useradd -m -s /bin/bash testuser_ln 2>/dev/null || true +useradd -m -s /bin/bash testuser2_ln 2>/dev/null || true + +# --- apache default vhost (serves the panel from /public) --- +cp -f laranode-scripts/templates/apache2-default.template /etc/apache2/sites-available/000-default.conf +systemctl reload apache2 + +# --- seed one sysstat sample so dashboard history isn't empty --- +mkdir -p /var/log/sysstat +sadc 1 1 "/var/log/sysstat/sa$(date +%d)" 2>/dev/null || true + +# --- firewall (container netns only) --- +ufw --force enable || true +for p in 22 80 443 8080; do ufw allow "$p" || true; done + +# --- panel services: reverb + queue worker --- +cp -f laranode-scripts/templates/laranode-queue-worker.service /etc/systemd/system/laranode-queue-worker.service +cp -f laranode-scripts/templates/laranode-reverb.service /etc/systemd/system/laranode-reverb.service +systemctl daemon-reload +systemctl enable --now laranode-queue-worker.service laranode-reverb.service +systemctl restart apache2 php8.4-fpm + +# --- ownership (best-effort over bind mount) --- +chown -R laranode_ln:laranode_ln /home/laranode_ln/logs || true + +touch "$SENTINEL" +log "DONE. Panel at http://localhost (admin: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD})" diff --git a/local-dev/install-test/run.sh b/local-dev/install-test/run.sh new file mode 100644 index 0000000..731d6b3 --- /dev/null +++ b/local-dev/install-test/run.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Clean-room install test. +# +# Boots a VANILLA ubuntu:24.04 + systemd container (nothing pre-installed), +# injects the current working tree, runs the REAL laranode-installer.sh end to +# end, and asserts the panel actually comes up. This is what proves a from-clean +# `curl | bash` install works — the normal `make up` lab uses a different, +# pre-provisioned image and can't catch installer ordering/packaging drift. +# +# bash local-dev/install-test/run.sh # run + teardown +# KEEP=1 bash local-dev/install-test/run.sh # keep container for inspection +# +# Uses docker run/exec only (no compose). MSYS guards keep git-bash on Windows +# from mangling in-container paths. + +set -uo pipefail +export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' + +NAME=laranode-install-test +IMAGE=jrei/systemd-ubuntu:24.04 +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +KEEP="${KEEP:-0}" + +cleanup() { [ "$KEEP" = 1 ] || docker rm -f "$NAME" >/dev/null 2>&1 || true; } +fail() { echo "FAIL: $1"; [ "$KEEP" = 1 ] && echo "(container kept: docker exec -it $NAME bash)"; cleanup; exit 1; } + +docker rm -f "$NAME" >/dev/null 2>&1 || true + +echo "[1/5] Booting clean $IMAGE with systemd..." +docker run -d --name "$NAME" --privileged --cgroupns=host \ + --cap-add NET_ADMIN --cap-add NET_RAW --stop-signal SIGRTMIN+3 \ + -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ + -v "$REPO":/src:ro \ + --tmpfs /run --tmpfs /run/lock --tmpfs /tmp \ + "$IMAGE" >/dev/null || fail "container did not start" + +for _ in $(seq 1 30); do + state=$(docker exec "$NAME" systemctl is-system-running 2>/dev/null || true) + case "$state" in running | degraded | starting) break ;; esac + sleep 2 +done + +echo "[2/5] Injecting working tree (fresh — no host vendor/.env/cache)..." +docker exec "$NAME" mkdir -p /home/laranode_ln/panel +docker exec "$NAME" bash -c 'tar -C /src \ + --exclude=./vendor --exclude=./node_modules --exclude=./.git \ + --exclude=./public/build --exclude=./.env --exclude=./.env.local-backup \ + -cf - . | tar -C /home/laranode_ln/panel -xf -' || fail "repo injection failed" +# Keep bootstrap/cache (Laravel needs the dir) but drop any stale cached config +# carried over from the host so the fresh .env/key actually take effect. +docker exec "$NAME" bash -c 'rm -f /home/laranode_ln/panel/bootstrap/cache/*.php' || true + +echo "[3/5] Running the REAL installer (installs everything; can take 10+ min)..." +docker exec "$NAME" bash /home/laranode_ln/panel/laranode-scripts/bin/laranode-installer.sh \ + || fail "installer exited non-zero" + +echo "[4/5] Seeding an admin..." +docker exec "$NAME" bash -lc 'cd /home/laranode_ln/panel && php artisan tinker --execute="App\Models\User::updateOrCreate([\"username\"=>\"laranode\"],[\"name\"=>\"Admin\",\"email\"=>\"admin@laranode.test\",\"password\"=>bcrypt(\"password\"),\"role\"=>\"admin\",\"ssh_access\"=>true,\"email_verified_at\"=>now()]);"' \ + || fail "admin creation failed" + +echo "[5/5] Assertions:" +ok=1 +for svc in apache2 mysql php8.4-fpm laranode-reverb laranode-queue-worker; do + st=$(docker exec "$NAME" systemctl is-active "$svc" 2>/dev/null || echo inactive) + printf " %-26s %s\n" "$svc" "$st" + [ "$st" = active ] || ok=0 +done +pg=$(docker exec "$NAME" bash -c 'systemctl is-active postgresql@16-main 2>/dev/null || systemctl is-active postgresql 2>/dev/null || echo inactive') +printf " %-26s %s\n" "postgresql" "$pg" +[ "$pg" = active ] || ok=0 + +code=$(docker exec "$NAME" curl -s -o /dev/null -w '%{http_code}' http://localhost/login 2>/dev/null || echo 000) +printf " %-26s %s\n" "GET /login" "$code" +[ "$code" = 200 ] || ok=0 + +login=$(docker exec "$NAME" bash -lc 'cd /home/laranode_ln/panel && php artisan tinker --execute="echo Illuminate\Support\Facades\Auth::attempt([\"email\"=>\"admin@laranode.test\",\"password\"=>\"password\"])?\"yes\":\"no\";"' 2>/dev/null | tail -1) +printf " %-26s %s\n" "admin login" "$login" +echo "$login" | grep -q yes || ok=0 + +if [ "$ok" = 1 ]; then + echo "RESULT: PASS — clean from-scratch install works." + cleanup + exit 0 +else + echo "RESULT: FAIL — a check above did not pass." + [ "$KEEP" = 1 ] && echo "(container kept for inspection)" + cleanup + exit 1 +fi diff --git a/local-dev/parallel/docker-compose.parallel.yml b/local-dev/parallel/docker-compose.parallel.yml new file mode 100644 index 0000000..44f063f --- /dev/null +++ b/local-dev/parallel/docker-compose.parallel.yml @@ -0,0 +1,50 @@ +# Parallel lab instance — a standalone, port-parameterized copy of the main +# local-dev lab, for building a feature in an isolated git worktree. +# +# Isolation comes from `docker compose -p laranode-`: the project name +# namespaces the container, the default network, and ALL named volumes +# (vendor/node-modules/mysql/postgres) so instances never share state. +# +# No pebble/SSL profile and no fixed IP/custom subnet here (the main lab keeps +# those) — parallel instances use Docker's per-project default network so several +# can run at once without subnet clashes. +# +# Paths are relative to THIS file (local-dev/parallel/), so when launched from a +# worktree the build context + bind mount resolve to that worktree's root. +# Launch via local-dev/parallel/up-instance.ps1. +services: + laranode: + build: + context: ../.. + dockerfile: local-dev/Dockerfile + image: laranode-lab:dev + container_name: ${INSTANCE_NAME:-laranode-lab-parallel} + privileged: true + cgroup: host + cap_add: + - NET_ADMIN + - NET_RAW + stop_signal: SIGRTMIN+3 + volumes: + - ../../:/home/laranode_ln/panel + - /sys/fs/cgroup:/sys/fs/cgroup:rw + - vendor:/home/laranode_ln/panel/vendor + - node-modules:/home/laranode_ln/panel/node_modules + - mysql:/var/lib/mysql + - postgres:/var/lib/postgresql + tmpfs: + - /run + - /run/lock + - /tmp + ports: + - "${HTTP_PORT:-8091}:80" + - "${HTTPS_PORT:-8441}:443" + - "${REVERB_PORT:-8101}:8080" + - "${VITE_PORT:-5181}:5173" + - "${MYSQL_PORT:-33061}:3306" + +volumes: + vendor: + node-modules: + mysql: + postgres: diff --git a/local-dev/parallel/down-instance.ps1 b/local-dev/parallel/down-instance.ps1 new file mode 100644 index 0000000..d17b93e --- /dev/null +++ b/local-dev/parallel/down-instance.ps1 @@ -0,0 +1,20 @@ +# Tear down a parallel lab instance. +# ./local-dev/parallel/down-instance.ps1 -Name notifications # stop + remove container/network +# ./local-dev/parallel/down-instance.ps1 -Name notifications -Nuke # also remove the per-instance volumes +param( + [Parameter(Mandatory)][string]$Name, + [switch]$Nuke, + [string]$WorktreePath = (Get-Location).Path +) +$ErrorActionPreference = 'Stop' + +$compose = Join-Path $WorktreePath 'local-dev\parallel\docker-compose.parallel.yml' +$project = "laranode-$Name" + +if ($Nuke) { + Write-Host "[$Name] down + remove volumes (project $project) ..." + docker compose -p $project -f $compose down -v +} else { + Write-Host "[$Name] down (project $project) ..." + docker compose -p $project -f $compose down +} diff --git a/local-dev/parallel/up-instance.ps1 b/local-dev/parallel/up-instance.ps1 new file mode 100644 index 0000000..ac040f1 --- /dev/null +++ b/local-dev/parallel/up-instance.ps1 @@ -0,0 +1,36 @@ +# Spin up a parallel lab instance bound to a feature worktree. +# +# ./local-dev/parallel/up-instance.ps1 -Name notifications -Index 1 -WorktreePath C:\...\laranode-wt-notifications +# +# Ports are derived from -Index so multiple instances never clash: +# HTTP 8090+i HTTPS 8440+i REVERB 8100+i VITE 5180+i MYSQL 33060+i +# Container name: laranode-lab- Project: laranode- +# Run tests with: docker exec laranode-lab- bash -lc 'cd /home/laranode_ln/panel && php artisan test' +param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][int]$Index, + [string]$WorktreePath = (Get-Location).Path +) +$ErrorActionPreference = 'Stop' + +$compose = Join-Path $WorktreePath 'local-dev\parallel\docker-compose.parallel.yml' +if (-not (Test-Path $compose)) { throw "compose not found at $compose (is -WorktreePath a Laranode worktree?)" } + +$env:INSTANCE_NAME = "laranode-lab-$Name" +$env:HTTP_PORT = 8090 + $Index +$env:HTTPS_PORT = 8440 + $Index +$env:REVERB_PORT = 8100 + $Index +$env:VITE_PORT = 5180 + $Index +$env:MYSQL_PORT = 33060 + $Index +$project = "laranode-$Name" + +Write-Host "[$Name] starting project '$project' from $WorktreePath (HTTP :$($env:HTTP_PORT)) ..." +docker compose -p $project -f $compose up -d +if ($LASTEXITCODE -ne 0) { throw "compose up failed for $Name" } + +Write-Host "[$Name] provisioning (composer/npm/migrate/mysql/postgres/seed) — first boot is slow ..." +docker exec "laranode-lab-$Name" bash -lc '/home/laranode_ln/panel/local-dev/entrypoint-setup.sh' +if ($LASTEXITCODE -ne 0) { throw "provision failed for $Name" } + +Write-Host "[$Name] READY. Panel http://localhost:$($env:HTTP_PORT)" +Write-Host "[$Name] test: docker exec laranode-lab-$Name bash -lc 'cd /home/laranode_ln/panel && php artisan test'" diff --git a/package.json b/package.json index 4f74a10..a1a8b02 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,24 @@ "type": "module", "scripts": { "build": "vite build", - "dev": "vite" + "dev": "vite", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test" }, "devDependencies": { "@headlessui/react": "^2.0.0", "@inertiajs/react": "^2.0.0", + "@playwright/test": "^1.61.1", "@tailwindcss/forms": "^0.5.3", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.4.12", "axios": "^1.7.4", "concurrently": "^9.0.1", + "jsdom": "^29.1.1", "laravel-echo": "^1.19.0", "laravel-vite-plugin": "^1.2.0", "postcss": "^8.4.31", @@ -20,7 +28,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "tailwindcss": "^3.2.1", - "vite": "^6.0.11" + "vite": "^6.0.11", + "vitest": "^4.1.9" }, "dependencies": { "chart.js": "^4.4.7", diff --git a/phpunit.xml b/phpunit.xml index 506b9a3..dca393f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,11 +19,13 @@ + + - - + + diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..3d2dfc6 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,8 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 30000, + use: { baseURL: process.env.APP_URL || 'http://localhost', headless: true }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/resources/js/Components/NotificationBell.jsx b/resources/js/Components/NotificationBell.jsx new file mode 100644 index 0000000..d860f30 --- /dev/null +++ b/resources/js/Components/NotificationBell.jsx @@ -0,0 +1,23 @@ +import { Link } from '@inertiajs/react'; +import useNotifications from '@/hooks/useNotifications'; + +export default function NotificationBell({ unreadCount: unreadCountProp }) { + const { unreadCount: hookCount } = useNotifications(); + const count = unreadCountProp !== undefined ? unreadCountProp : hookCount; + + return ( + + + + + {count > 0 && ( + + {count} + + )} + + ); +} diff --git a/resources/js/Components/NotificationBell.test.jsx b/resources/js/Components/NotificationBell.test.jsx new file mode 100644 index 0000000..a2bae11 --- /dev/null +++ b/resources/js/Components/NotificationBell.test.jsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import { test, expect, vi } from 'vitest'; +import NotificationBell from '@/Components/NotificationBell'; + +// Mock inertia Link and usePage so the component can render without a router +vi.mock('@inertiajs/react', () => ({ + Link: ({ href, children }) => {children}, + usePage: () => ({ + props: { + auth: { user: { id: 1 } }, + notifications: { unreadCount: 0 }, + }, + }), +})); + +// Mock Echo so useNotifications hook does not fail when no unreadCount prop supplied +beforeEach(() => { + window.Echo = { + private: () => ({ + listen: (_event, _cb) => ({ stopListening: vi.fn() }), + stopListening: vi.fn(), + }), + leave: vi.fn(), + }; +}); + +test('renders no badge when unreadCount prop is 0', () => { + render(); + expect(screen.queryByTestId('notification-badge')).toBeNull(); +}); + +test('renders badge with count when unreadCount prop is 3', () => { + render(); + const badge = screen.getByTestId('notification-badge'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent('3'); +}); diff --git a/resources/js/Components/OperationProgress.jsx b/resources/js/Components/OperationProgress.jsx new file mode 100644 index 0000000..1a788de --- /dev/null +++ b/resources/js/Components/OperationProgress.jsx @@ -0,0 +1,25 @@ +import { useEffect, useRef } from 'react'; +import useOperation from '@/hooks/useOperation'; + +const badge = { queued: 'text-gray-500', running: 'text-blue-600', succeeded: 'text-green-600', failed: 'text-red-600' }; + +export default function OperationProgress({ operationId, onDone }) { + const { status, lines, exitCode } = useOperation(operationId); + const firedRef = useRef(false); + + useEffect(() => { firedRef.current = false; }, [operationId]); + + useEffect(() => { + if (!firedRef.current && (status === 'succeeded' || status === 'failed') && onDone) { + firedRef.current = true; + onDone(status); + } + }, [status, onDone]); + + return ( +
+
Status: {status}{exitCode !== null ? ` (exit ${exitCode})` : ''}
+
{lines.join('\n') || '…'}
+
+ ); +} diff --git a/resources/js/Components/OperationProgress.test.jsx b/resources/js/Components/OperationProgress.test.jsx new file mode 100644 index 0000000..c2e0d8e --- /dev/null +++ b/resources/js/Components/OperationProgress.test.jsx @@ -0,0 +1,26 @@ +import { render, screen, act } from '@testing-library/react'; +import { test, expect, vi, beforeEach } from 'vitest'; +import OperationProgress from '@/Components/OperationProgress'; + +let captured; +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ props: { auth: { user: { id: 1 } } } }), +})); + +beforeEach(() => { + captured = null; + window.Echo = { + private: () => ({ listen: (_name, cb) => { captured = cb; }, stopListening: vi.fn() }), + leave: vi.fn(), + }; +}); + +test('renders streamed lines and the terminal status', () => { + const onDone = vi.fn(); + render(); + act(() => captured({ operationId: 5, kind: 'line', line: 'building...' })); + act(() => captured({ operationId: 5, kind: 'status', status: 'succeeded', exitCode: 0 })); + expect(screen.getByText(/building\.\.\./)).toBeInTheDocument(); + expect(screen.getByText(/Status: succeeded/)).toBeInTheDocument(); + expect(onDone).toHaveBeenCalledWith('succeeded'); +}); diff --git a/resources/js/Layouts/AuthenticatedLayout.jsx b/resources/js/Layouts/AuthenticatedLayout.jsx index e964bed..91e61fc 100644 --- a/resources/js/Layouts/AuthenticatedLayout.jsx +++ b/resources/js/Layouts/AuthenticatedLayout.jsx @@ -8,6 +8,7 @@ export default function AuthenticatedLayout({ header, children }) { const user = usePage().props.auth.user; const { flash } = usePage().props; const [showingNavigationDropdown, setShowingNavigationDropdown] = useState(false); + const [isCollapsed, setIsCollapsed] = useState(() => localStorage.getItem('laranode_sidebar_collapsed') === 'true'); useEffect(() => { if (flash.success) { @@ -24,9 +25,9 @@ export default function AuthenticatedLayout({ header, children }) {
- + -
+
{header && (
@@ -34,7 +35,7 @@ export default function AuthenticatedLayout({ header, children }) {
)} -
+
{children}
diff --git a/resources/js/Layouts/Partials/SidebarNavi.jsx b/resources/js/Layouts/Partials/SidebarNavi.jsx index e210927..54f5e17 100644 --- a/resources/js/Layouts/Partials/SidebarNavi.jsx +++ b/resources/js/Layouts/Partials/SidebarNavi.jsx @@ -1,29 +1,32 @@ import { Link, usePage } from "@inertiajs/react"; -import { useState } from "react"; import { RiDashboard3Fill, RiMvFill } from "react-icons/ri"; import { ImProfile } from "react-icons/im"; import { FaPhp, FaUsers } from "react-icons/fa6"; import { VscFileSubmodule } from "react-icons/vsc"; -import { TbBrandMysql } from "react-icons/tb"; -import { MdSecurity } from "react-icons/md"; +import { TbDatabase, TbChartBar, TbWorldWww } from "react-icons/tb"; +import { MdSecurity, MdOutlineListAlt, MdSchedule, MdBackup } from "react-icons/md"; import { IoLockClosedOutline } from "react-icons/io5"; -import { TbWorldWww } from "react-icons/tb"; -const SidebarNavi = () => { +const SidebarNavi = ({ isCollapsed, setIsCollapsed }) => { const { auth } = usePage().props; - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - return (
+ const labelClass = `ml-2 text-sm tracking-wide truncate${isCollapsed ? ' hidden' : ''}`; + + return (
  • -
    +
    Menu
    -
    @@ -31,7 +34,6 @@ const SidebarNavi = () => {
  • - {
    - Dashboard + Dashboard
  • @@ -52,7 +54,7 @@ const SidebarNavi = () => {
    - Accounts + Accounts )} @@ -65,7 +67,7 @@ const SidebarNavi = () => {
    - Websites + Websites @@ -78,7 +80,21 @@ const SidebarNavi = () => {
    - Firewall + Firewall + + + )} + + {auth.user.role == 'admin' && ( +
  • + +
    + +
    + Operations
  • )} @@ -91,19 +107,43 @@ const SidebarNavi = () => {
    - File Manager + File Manager + + + +
  • + +
    + +
    + Analytics + +
  • + +
  • + +
    + +
    + Databases
  • - +
    - MySQL DBs + Cron Jobs
  • @@ -116,11 +156,23 @@ const SidebarNavi = () => {
    - PHP Manager + PHP Manager )} +
  • + +
    + +
    + Backups + +
  • +
  • {
    - My Profile + My Profile
-

+

LaraNode Hosting Control Panel

diff --git a/resources/js/Layouts/Partials/SidebarNavi.test.jsx b/resources/js/Layouts/Partials/SidebarNavi.test.jsx new file mode 100644 index 0000000..12f9fde --- /dev/null +++ b/resources/js/Layouts/Partials/SidebarNavi.test.jsx @@ -0,0 +1,208 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { test, expect, vi, describe, beforeEach } from 'vitest'; +import SidebarNavi from './SidebarNavi'; + +// Mock @inertiajs/react +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ props: { auth: { user: { id: 1, role: 'user' } } } }), + Link: ({ href, children, ...rest }) => {children}, +})); + +// Mock react-icons — SidebarNavi imports several icon packages +vi.mock('react-icons/ri', () => ({ + RiDashboard3Fill: () => RiDashboard3Fill, + RiMvFill: () => RiMvFill, +})); +vi.mock('react-icons/im', () => ({ ImProfile: () => ImProfile })); +vi.mock('react-icons/fa6', () => ({ + FaPhp: () => FaPhp, + FaUsers: () => FaUsers, +})); +vi.mock('react-icons/vsc', () => ({ VscFileSubmodule: () => VscFileSubmodule })); +vi.mock('react-icons/tb', () => ({ + // D7: TbDatabase replaces TbBrandMysql for the Databases link + TbDatabase: () => TbDatabase, + TbBrandMysql: () => TbBrandMysql, + TbChartBar: () => TbChartBar, + TbWorldWww: () => TbWorldWww, +})); +vi.mock('react-icons/md', () => ({ + MdSecurity: () => MdSecurity, + MdOutlineListAlt: () => MdOutlineListAlt, + MdSchedule: () => MdSchedule, + MdBackup: () => MdBackup, +})); +vi.mock('react-icons/io5', () => ({ IoLockClosedOutline: () => IoLockClosedOutline })); + +// Mock route() global — provide mappings for every route used in SidebarNavi +global.route = (name) => { + const map = { + 'dashboard': '/dashboard', + 'accounts.index': '/accounts', + 'websites.index': '/websites', + 'firewall.index': '/firewall', + 'operations.index': '/operations', + 'analytics.index': '/analytics', + 'databases.index': '/databases', + 'cron-jobs.index': '/cron-jobs', + 'php.index': '/php', + 'backups.index': '/backups', + 'profile.edit': '/profile/edit', + }; + return map[name] ?? `/${name}`; +}; + +// Helper: default props for the new collapsible sidebar +const defaultProps = { + isCollapsed: false, + setIsCollapsed: vi.fn(), +}; + +// ─── Existing tests (preserved) ─────────────────────────────────────────────── + +test('Analytics link renders with correct href and label for authenticated user', () => { + render(); + + const analyticsLink = screen.getByRole('link', { name: /analytics/i }); + expect(analyticsLink).toBeInTheDocument(); + expect(analyticsLink).toHaveAttribute('href', '/analytics'); +}); + +test('Analytics link text is exactly "Analytics"', () => { + render(); + + expect(screen.getByText('Analytics')).toBeInTheDocument(); +}); + +test('Analytics link is visible to non-admin users', () => { + // usePage mock returns role: 'user' — Analytics must still render + render(); + + expect(screen.getByRole('link', { name: /analytics/i })).toBeInTheDocument(); +}); + +test('Analytics link is visible to admin users', () => { + // Re-mock usePage with admin role + vi.mocked(vi.importActual).mockReturnValue?.(undefined); // no-op to avoid reset warnings + // Override the mock for this test by re-mocking + vi.doMock('@inertiajs/react', () => ({ + usePage: () => ({ props: { auth: { user: { id: 2, role: 'admin' } } } }), + Link: ({ href, children, ...rest }) => {children}, + })); + + // The module is already loaded; admin check for Analytics doesn't gate it, + // so the standard render (user role) already covers visibility. + // We assert the link still appears (no admin guard). + render(); + expect(screen.getByRole('link', { name: /analytics/i })).toBeInTheDocument(); +}); + +test('Analytics link appears after File Manager in the sidebar', () => { + render(); + + const links = screen.getAllByRole('link'); + const fileManagerIdx = links.findIndex((l) => l.textContent.includes('File Manager')); + const analyticsIdx = links.findIndex((l) => l.textContent.includes('Analytics')); + + expect(fileManagerIdx).toBeGreaterThanOrEqual(0); + expect(analyticsIdx).toBeGreaterThan(fileManagerIdx); +}); + +// ─── D7: TbDatabase icon for Databases link ─────────────────────────────────── + +describe('D7 — engine-agnostic DB icon', () => { + test('Databases link renders TbDatabase icon (not TbBrandMysql)', () => { + render(); + + // TbDatabase icon should be present + expect(screen.getByTestId('icon-TbDatabase')).toBeInTheDocument(); + // TbBrandMysql icon should NOT be present anywhere in the sidebar + expect(screen.queryByTestId('icon-TbBrandMysql')).not.toBeInTheDocument(); + }); +}); + +// ─── D5: Collapsible sidebar ────────────────────────────────────────────────── + +describe('D5 — collapsible sidebar', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('shows nav label spans when not collapsed (isCollapsed=false)', () => { + render(); + + const dashboardSpan = screen.getByText('Dashboard'); + expect(dashboardSpan).not.toHaveClass('hidden'); + + const databasesSpan = screen.getByText('Databases'); + expect(databasesSpan).not.toHaveClass('hidden'); + }); + + test('hides nav label spans when collapsed (isCollapsed=true)', () => { + render(); + + const dashboardSpan = screen.getByText('Dashboard'); + expect(dashboardSpan).toHaveClass('hidden'); + + const databasesSpan = screen.getByText('Databases'); + expect(databasesSpan).toHaveClass('hidden'); + }); + + test('calls setIsCollapsed(true) and sets localStorage when toggle clicked from expanded state', () => { + const mockSetIsCollapsed = vi.fn(); + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); + + render(); + + // Find the toggle button (hamburger) + const toggleBtn = screen.getByRole('button'); + fireEvent.click(toggleBtn); + + expect(mockSetIsCollapsed).toHaveBeenCalledWith(true); + expect(setItemSpy).toHaveBeenCalledWith('laranode_sidebar_collapsed', 'true'); + }); + + test('calls setIsCollapsed(false) and sets localStorage when toggle clicked from collapsed state', () => { + const mockSetIsCollapsed = vi.fn(); + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); + + render(); + + const toggleBtn = screen.getByRole('button'); + fireEvent.click(toggleBtn); + + expect(mockSetIsCollapsed).toHaveBeenCalledWith(false); + expect(setItemSpy).toHaveBeenCalledWith('laranode_sidebar_collapsed', 'false'); + }); + + // When collapsed, the "Menu" heading and footer must fully hide (drop + // md:block). If they kept md:block they would consume the narrow w-14 + // column and push the hamburger toggle past the overflow-x-hidden edge, + // leaving no visible button to re-expand the sidebar. + test('drops md:block on the Menu heading and footer when collapsed', () => { + render(); + + const menuLabel = screen.getByText('Menu'); + expect(menuLabel.className).toContain('hidden'); + expect(menuLabel.className).not.toContain('md:block'); + + const footer = screen.getByText('LaraNode').closest('p'); + expect(footer.className).toContain('hidden'); + expect(footer.className).not.toContain('md:block'); + }); + + test('keeps md:block on the Menu heading and footer when expanded', () => { + render(); + + expect(screen.getByText('Menu').className).toContain('md:block'); + expect(screen.getByText('LaraNode').closest('p').className).toContain('md:block'); + }); + + test('toggle button stays the only button and is reachable when collapsed', () => { + render(); + + // The hamburger is the single button; it must still render so the + // sidebar can be re-expanded. + expect(screen.getByRole('button')).toBeInTheDocument(); + }); +}); diff --git a/resources/js/Layouts/Partials/TopNavi.jsx b/resources/js/Layouts/Partials/TopNavi.jsx index 6f4e24d..3c09732 100644 --- a/resources/js/Layouts/Partials/TopNavi.jsx +++ b/resources/js/Layouts/Partials/TopNavi.jsx @@ -1,5 +1,6 @@ import { Link, usePage } from '@inertiajs/react'; import ApplicationLogo from '@/Components/ApplicationLogo'; +import NotificationBell from '@/Components/NotificationBell'; import { useState, useEffect } from 'react'; import { MdOutlineLogout, MdOutlineLightMode, MdOutlineDarkMode } from "react-icons/md"; import { HiOutlineLogin } from "react-icons/hi"; @@ -32,6 +33,8 @@ const TopNavi = () => {
+ + +
+ ), +})); + +// Mock route() global +global.route = (name, params) => { + const map = { + 'backups.store': '/backups', + 'backups.destroy': '/backups/delete', + 'backups.restore': '/backups/restore', + 'backups.download': '/backups/download', + 'backups.schedules.destroy': '/backups/schedules/delete', + 'backups.index': '/backups', + }; + return map[name] ?? `/${name}`; +}; + +const sampleBackups = { + data: [ + { + id: 1, + type: 'db', + target: 'mydb', + storage: 'local', + status: 'completed', + size_bytes: 1048576, + created_at: '2026-06-26 02:00:00', + path: '1/db/mydb/2026-06-26-020000.sql.gz', + disk_name: 'backups', + }, + { + id: 2, + type: 'files', + target: 'example.com', + storage: 'local', + status: 'pending', + size_bytes: null, + created_at: '2026-06-26 03:00:00', + path: null, + disk_name: 'backups', + }, + ], +}; + +const sampleSchedules = [ + { + id: 1, + type: 'db', + target: 'mydb', + cron_expression: '0 2 * * *', + retention_count: 7, + last_run_at: null, + enabled: true, + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('backup rows render with type, target, and status', () => { + render(); + + // First row: db / mydb / completed + expect(screen.getByText('db')).toBeInTheDocument(); + expect(screen.getByText('mydb')).toBeInTheDocument(); + expect(screen.getByText('completed')).toBeInTheDocument(); + + // Second row: files / example.com / pending + expect(screen.getByText('files')).toBeInTheDocument(); + expect(screen.getByText('example.com')).toBeInTheDocument(); + expect(screen.getByText('pending')).toBeInTheDocument(); +}); + +test('on-demand backup form submit calls axios and renders OperationProgress', async () => { + axios.post = vi.fn().mockResolvedValue({ data: { operation_id: 42 } }); + + render(); + + // Fill target + const targetInput = screen.getByPlaceholderText(/database name/i); + await userEvent.clear(targetInput); + await userEvent.type(targetInput, 'mydb'); + + // Submit the form + const runBtn = screen.getByRole('button', { name: /run backup/i }); + await userEvent.click(runBtn); + + await waitFor(() => { + expect(axios.post).toHaveBeenCalledWith('/backups', expect.objectContaining({ target: 'mydb' })); + }); + + await waitFor(() => { + expect(screen.getByTestId('operation-progress')).toBeInTheDocument(); + expect(screen.getByText('Progress for 42')).toBeInTheDocument(); + }); +}); + +test('restore button opens modal with new_target input and warning about original not touched', async () => { + render(); + + // Click "Restore" for the completed db backup (row 1) + const restoreBtn = screen.getByRole('button', { name: /restore/i }); + await userEvent.click(restoreBtn); + + // Modal should appear with labelled new_target input + expect(screen.getByLabelText(/new target name/i)).toBeInTheDocument(); + + // Warning text must contain "original is not touched" + expect(screen.getByText(/original is not touched/i)).toBeInTheDocument(); +}); diff --git a/resources/js/Pages/Backups/Index.jsx b/resources/js/Pages/Backups/Index.jsx new file mode 100644 index 0000000..30b191c --- /dev/null +++ b/resources/js/Pages/Backups/Index.jsx @@ -0,0 +1,340 @@ +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { Head, router, usePage } from '@inertiajs/react'; +import { MdBackup } from 'react-icons/md'; +import { TiDelete } from 'react-icons/ti'; +import { toast } from 'react-toastify'; +import { useState } from 'react'; +import axios from 'axios'; +import OperationProgress from '@/Components/OperationProgress'; + +export default function BackupsIndex({ backups = { data: [] }, schedules = [] }) { + const { auth } = usePage().props; + + // On-demand backup form state + const [form, setForm] = useState({ type: 'db', target: '', storage: 'local' }); + const [activeOp, setActiveOp] = useState(null); + + // Restore modal state + const [restoreModal, setRestoreModal] = useState(null); // { backup } + const [newTarget, setNewTarget] = useState(''); + const [restoreOp, setRestoreOp] = useState(null); + + const handleFormChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value })); + + const submitBackup = (e) => { + e.preventDefault(); + axios.post(route('backups.store'), form) + .then((res) => setActiveOp(res.data.operation_id)) + .catch(() => toast.error('Failed to start backup')); + }; + + const deleteBackup = (backup) => { + router.delete(route('backups.destroy', { backup: backup.id }), { + onBefore: () => toast('Deleting backup…'), + onError: () => toast.error('Failed to delete backup'), + }); + }; + + const openRestoreModal = (backup) => { + setRestoreModal({ backup }); + setNewTarget(''); + setRestoreOp(null); + }; + + const closeRestoreModal = () => { + setRestoreModal(null); + setRestoreOp(null); + setNewTarget(''); + }; + + const submitRestore = (e) => { + e.preventDefault(); + axios.post(route('backups.restore', { backup: restoreModal.backup.id }), { new_target: newTarget }) + .then((res) => setRestoreOp(res.data.operation_id)) + .catch((err) => { + const msg = err.response?.data?.errors?.new_target?.[0] ?? 'Failed to start restore'; + toast.error(msg); + }); + }; + + const deleteSchedule = (schedule) => { + router.delete(route('backups.schedules.destroy', { scheduledBackup: schedule.id }), { + onBefore: () => toast('Deleting schedule…'), + onError: () => toast.error('Failed to delete schedule'), + }); + }; + + const backupRows = backups.data ?? []; + + return ( + +

+ + Backups +

+
+ } + > + + +
+ + {/* On-demand backup form */} +
+

Create On-Demand Backup

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + {activeOp && ( +
+
Backup in progress…
+ { setActiveOp(null); router.reload(); }} + /> +
+ )} +
+ + {/* Backup rows table */} +
+

Backup History

+ {backupRows.length === 0 ? ( +

No backups found.

+ ) : ( +
+ + + + + + + + + + + + + + {backupRows.map((b) => ( + + + + + + + + + + ))} + +
TypeTargetStorageStatusSizeCreatedActions
{b.type}{b.target}{b.storage} + + {b.status} + + + {b.size_bytes ? `${(b.size_bytes / 1024 / 1024).toFixed(2)} MB` : '—'} + {b.created_at} +
+ {b.status === 'completed' && ( + <> + + Download + + {b.type === 'db' && ( + + )} + + )} + +
+
+
+ )} +
+ + {/* Scheduled backups table */} +
+

Scheduled Backups

+ {schedules.length === 0 ? ( +

No scheduled backups configured.

+ ) : ( +
+ + + + + + + + + + + + + + {schedules.map((s) => ( + + + + + + + + + + ))} + +
TypeTargetCronKeepLast RunEnabledActions
{s.type}{s.target}{s.cron_expression}{s.retention_count}{s.last_run_at ?? '—'} + + {s.enabled ? 'Yes' : 'No'} + + + +
+
+ )} +
+
+ + {/* Restore modal */} + {restoreModal && ( +
+
+

+ Restore Backup: {restoreModal.backup.target} +

+ +

+ The original is not touched — a new database will be created with the name you provide below. +

+ + {restoreOp ? ( +
+ { + if (status === 'succeeded') { + toast.success('Restore completed'); + closeRestoreModal(); + router.reload(); + } + }} + /> + +
+ ) : ( +
+
+ + setNewTarget(e.target.value)} + placeholder="e.g. mydb_restored" + className="w-full border border-gray-300 rounded px-3 py-2 text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200" + /> +
+
+ + +
+
+ )} +
+
+ )} + + ); +} diff --git a/resources/js/Pages/CronJobs/CronJobs.test.jsx b/resources/js/Pages/CronJobs/CronJobs.test.jsx new file mode 100644 index 0000000..22f2ebc --- /dev/null +++ b/resources/js/Pages/CronJobs/CronJobs.test.jsx @@ -0,0 +1,209 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { test, expect, vi, beforeEach } from 'vitest'; +import CronJobsIndex from './Index'; +import CreateCronJobForm from './Partials/CreateCronJobForm'; + +// ── Inertia mock ───────────────────────────────────────────────────────────── +const mockRouterPost = vi.fn(); +const mockRouterDelete = vi.fn(); + +vi.mock('@inertiajs/react', () => ({ + Head: ({ title }) => {title}, + router: { + post: (...args) => mockRouterPost(...args), + delete: (...args) => mockRouterDelete(...args), + }, + usePage: () => ({ props: { auth: { user: { id: 1, role: 'user', username: 'testuser' } } } }), + Link: ({ href, children, ...rest }) => {children}, +})); + +// ── Layout mock ─────────────────────────────────────────────────────────────── +vi.mock('@/Layouts/AuthenticatedLayout', () => ({ + default: ({ children, header }) => ( +
+
{header}
+
{children}
+
+ ), +})); + +// ── Icon mocks ──────────────────────────────────────────────────────────────── +vi.mock('react-icons/md', () => ({ MdSchedule: () => MdSchedule })); +vi.mock('react-icons/ti', () => ({ TiDelete: () => Delete })); + +// ── Toast mock ──────────────────────────────────────────────────────────────── +vi.mock('react-toastify', () => ({ + toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }), +})); + +// ── ConfirmationButton mock ─────────────────────────────────────────────────── +vi.mock('@/Components/ConfirmationButton', () => ({ + default: ({ children, doAction }) => ( + + ), +})); + +// ── route() global ──────────────────────────────────────────────────────────── +global.route = (name, params) => { + const map = { + 'cron-jobs.store': '/cron-jobs', + 'cron-jobs.destroy': '/cron-jobs/delete', + 'cron-jobs.toggle': '/cron-jobs/toggle', + 'cron-jobs.index': '/cron-jobs', + }; + if (params) return (map[name] ?? `/${name}`) + '/' + (params.cronJob ?? ''); + return map[name] ?? `/${name}`; +}; + +// ── Fixtures ────────────────────────────────────────────────────────────────── +const sampleJobs = [ + { + id: 1, + schedule: '* * * * *', + command: 'php /home/testuser_ln/artisan inspire', + label: 'My cron label', + active: true, + }, + { + id: 2, + schedule: '0 2 * * *', + command: 'php /home/testuser_ln/artisan schedule:run', + label: null, + active: false, + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ── Index tests ─────────────────────────────────────────────────────────────── + +test('renders cron job rows with schedule, command, label and status', () => { + render(); + + // Row 1 + expect(screen.getByText('* * * * *')).toBeInTheDocument(); + expect(screen.getByText('php /home/testuser_ln/artisan inspire')).toBeInTheDocument(); + expect(screen.getByText('My cron label')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + + // Row 2 — null label renders as em-dash + expect(screen.getByText('0 2 * * *')).toBeInTheDocument(); + expect(screen.getByText('Paused')).toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); +}); + +test('renders add-form button (Add Cron Job submit button)', () => { + render(); + + expect(screen.getByRole('button', { name: /add cron job/i })).toBeInTheDocument(); +}); + +test('shows empty state when no cron jobs', () => { + render(); + + expect(screen.getByText(/no cron jobs configured/i)).toBeInTheDocument(); +}); + +test('delete button calls router.delete with correct route', async () => { + render(); + + const deleteButtons = screen.getAllByTestId('confirm-btn'); + // First job is id=1 + await userEvent.click(deleteButtons[0]); + + expect(mockRouterDelete).toHaveBeenCalledWith( + '/cron-jobs/delete/1', + expect.any(Object) + ); +}); + +test('toggle button calls router.post with correct route', async () => { + render(); + + // "Active" button for job id=1 + const activeBtn = screen.getByRole('button', { name: /pause cron job/i }); + await userEvent.click(activeBtn); + + expect(mockRouterPost).toHaveBeenCalledWith( + '/cron-jobs/toggle/1', + {}, + expect.any(Object) + ); +}); + +// ── CreateCronJobForm tests ─────────────────────────────────────────────────── + +test('submitting the form calls router.post with schedule, command and label', async () => { + render(); + + // Default schedule preset is '* * * * *' + const commandInput = screen.getByLabelText(/cron command/i); + const labelInput = screen.getByLabelText(/cron label/i); + const submitBtn = screen.getByRole('button', { name: /add cron job/i }); + + await userEvent.type(commandInput, 'php /home/testuser_ln/artisan schedule:run'); + await userEvent.type(labelInput, 'Scheduler'); + await userEvent.click(submitBtn); + + expect(mockRouterPost).toHaveBeenCalledWith( + '/cron-jobs', + { + schedule: '* * * * *', + command: 'php /home/testuser_ln/artisan schedule:run', + label: 'Scheduler', + }, + expect.objectContaining({ onError: expect.any(Function) }) + ); +}); + +test('selecting Custom… reveals the custom expression input', async () => { + render(); + + const scheduleSelect = screen.getByLabelText(/schedule preset/i); + await userEvent.selectOptions(scheduleSelect, 'custom'); + + expect(screen.getByLabelText(/custom cron expression/i)).toBeInTheDocument(); +}); + +test('custom expression is sent to router.post when Custom preset used', async () => { + render(); + + const scheduleSelect = screen.getByLabelText(/schedule preset/i); + await userEvent.selectOptions(scheduleSelect, 'custom'); + + const customInput = screen.getByLabelText(/custom cron expression/i); + await userEvent.type(customInput, '30 4 * * 0'); + + const commandInput = screen.getByLabelText(/cron command/i); + await userEvent.type(commandInput, 'php /home/testuser_ln/artisan inspire'); + + await userEvent.click(screen.getByRole('button', { name: /add cron job/i })); + + expect(mockRouterPost).toHaveBeenCalledWith( + '/cron-jobs', + expect.objectContaining({ schedule: '30 4 * * 0' }), + expect.objectContaining({ onError: expect.any(Function) }) + ); +}); + +test('onError callback populates field-level error messages', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: /add cron job/i })); + + // Grab the onError callback passed to router.post and invoke it + const [, , options] = mockRouterPost.mock.calls[0]; + act(() => { + options.onError({ schedule: 'Invalid cron expression.', command: 'Command not allowed.' }); + }); + + await waitFor(() => { + expect(screen.getByText('Invalid cron expression.')).toBeInTheDocument(); + expect(screen.getByText('Command not allowed.')).toBeInTheDocument(); + }); +}); diff --git a/resources/js/Pages/CronJobs/Index.jsx b/resources/js/Pages/CronJobs/Index.jsx new file mode 100644 index 0000000..cb74e8b --- /dev/null +++ b/resources/js/Pages/CronJobs/Index.jsx @@ -0,0 +1,113 @@ +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { Head, router } from '@inertiajs/react'; +import { MdSchedule } from 'react-icons/md'; +import { TiDelete } from 'react-icons/ti'; +import { toast } from 'react-toastify'; +import CreateCronJobForm from './Partials/CreateCronJobForm'; +import ConfirmationButton from '@/Components/ConfirmationButton'; + +export default function Index({ cronJobs = [] }) { + const toggleActive = (cronJob) => { + router.post( + route('cron-jobs.toggle', { cronJob: cronJob.id }), + {}, + { + onBefore: () => toast(`${cronJob.active ? 'Pausing' : 'Activating'} cron job…`), + onError: () => toast.error('Failed to toggle cron job.'), + } + ); + }; + + const deleteCronJob = (cronJob) => { + router.delete( + route('cron-jobs.destroy', { cronJob: cronJob.id }), + { + onBefore: () => toast('Deleting cron job…'), + onError: () => toast.error('Failed to delete cron job.'), + } + ); + }; + + return ( + +

+ + Cron Jobs ({cronJobs.length}) +

+
+ } + > + + +
+
+

+ Add Cron Job +

+ +
+ +
+

+ Scheduled Jobs +

+ {cronJobs.length === 0 ? ( +

No cron jobs configured.

+ ) : ( +
+ + + + + + + + + + + + {cronJobs.map((job) => ( + + + + + + + + ))} + +
ScheduleCommandLabelStatusActions
+ {job.schedule} + + {job.command} + + {job.label ?? '—'} + + + + deleteCronJob(job)}> + + +
+
+ )} +
+
+ + ); +} diff --git a/resources/js/Pages/CronJobs/Partials/CreateCronJobForm.jsx b/resources/js/Pages/CronJobs/Partials/CreateCronJobForm.jsx new file mode 100644 index 0000000..dac7bb5 --- /dev/null +++ b/resources/js/Pages/CronJobs/Partials/CreateCronJobForm.jsx @@ -0,0 +1,137 @@ +import { router } from '@inertiajs/react'; +import { useState } from 'react'; +import { MdSchedule } from 'react-icons/md'; + +const PRESETS = [ + { label: 'Every minute', value: '* * * * *' }, + { label: 'Every hour', value: '0 * * * *' }, + { label: 'Daily at 2am', value: '0 2 * * *' }, + { label: 'Weekly (Mon 0:00)', value: '0 0 * * 1' }, + { label: 'Monthly (1st 0:00)', value: '0 0 1 * *' }, + { label: 'Custom…', value: 'custom' }, +]; + +export default function CreateCronJobForm() { + const [preset, setPreset] = useState('* * * * *'); + const [customSchedule, setCustomSchedule] = useState(''); + const [command, setCommand] = useState(''); + const [label, setLabel] = useState(''); + const [errors, setErrors] = useState({}); + + const isCustom = preset === 'custom'; + const schedule = isCustom ? customSchedule : preset; + + const handlePresetChange = (e) => { + setPreset(e.target.value); + if (e.target.value !== 'custom') { + setCustomSchedule(''); + } + }; + + const handleSubmit = (e) => { + e.preventDefault(); + setErrors({}); + router.post(route('cron-jobs.store'), { schedule, command, label }, { + onError: (e) => setErrors(e), + }); + }; + + return ( +
+
+ + + {errors.schedule && ( +

{errors.schedule}

+ )} +
+ + {isCustom && ( +
+ + setCustomSchedule(e.target.value)} + placeholder="* * * * *" + className="border border-gray-300 rounded px-3 py-2 text-sm dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200" + aria-label="Custom cron expression" + /> +
+ )} + +
+ + setCommand(e.target.value)} + placeholder="php /home/username_ln/artisan schedule:run" + className="border border-gray-300 rounded px-3 py-2 text-sm w-80 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200" + aria-label="Cron command" + /> + {errors.command && ( +

{errors.command}

+ )} +
+ +
+ + setLabel(e.target.value)} + placeholder="e.g. Daily cleanup" + className="border border-gray-300 rounded px-3 py-2 text-sm dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200" + aria-label="Cron label" + /> +
+ + +
+ ); +} diff --git a/resources/js/Pages/Dashboard/Admin/AdminDashboard.jsx b/resources/js/Pages/Dashboard/Admin/AdminDashboard.jsx index da3aceb..01b7f97 100644 --- a/resources/js/Pages/Dashboard/Admin/AdminDashboard.jsx +++ b/resources/js/Pages/Dashboard/Admin/AdminDashboard.jsx @@ -1,30 +1,33 @@ import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; -import { Head, Link } from '@inertiajs/react'; +import { Head, Link, router } from '@inertiajs/react'; import { RiDashboard3Fill } from "react-icons/ri"; +import { FaGear } from "react-icons/fa6"; import { useEffect, useState } from "react"; import TopProcesses from './Components/TopProcesses'; +import ResourceShareCharts from './Components/ResourceShareCharts'; +import GpuLive from './Components/GpuLive'; import CPULive from './Components/CPULive'; import MemoryLive from './Components/MemoryLive'; import DiskLive from './Components/DiskLive'; import NetworkLive from './Components/NetworkLive'; -import MySQLLive from './Components/MySQLLive'; +import DbEnginesLive from './Components/DbEnginesLive'; import PHPFPMLive from './Components/PHPFPMLive'; -export default function Dashboard() { +export default function Dashboard({ initialStats }) { - const [liveStats, setLiveStats] = useState([]); + const [liveStats, setLiveStats] = useState(initialStats ?? []); + const [topStats, setTopStats] = useState([]); + const [sortBy, setSortBy] = useState("cpu"); + const [topSpinner, setTopSpinner] = useState(false); const echo = window.Echo; useEffect(() => { - const dashboardChannel = echo.private("systemstats"); - dashboardChannel.listen("SystemStatsEvent", (data) => { setLiveStats(data); }); - const whisperInterval = setInterval(() => { dashboardChannel.whisper("typing", { requesting: "dashboard-realtime-stats" }); }, 2000); @@ -35,6 +38,41 @@ export default function Dashboard() { }; }, []); + // Top-process stats feed the by-process doughnuts AND the processes table. + // Subscribed once here so the two consumers can't fight over leaving the channel. + useEffect(() => { + const topStatsChannel = echo.private("topstats"); + + window.axios.get("/dashboard/admin/get/top-sort").then((response) => { + setSortBy(response.data.sortBy); + }); + + topStatsChannel.listen("TopStatsEvent", (data) => { + setTopStats(data); + setTopSpinner(false); + }); + + const whisperInterval = setInterval(() => { + topStatsChannel.whisper("typing", { requesting: "dashboard-top-stats" }); + }, 2000); + + return () => { + clearInterval(whisperInterval); + echo.leave("topstats"); + }; + }, []); + + const changeSort = (next) => { + window.axios.patch("/dashboard/admin/set/top-sort", { sortBy: next }).then((response) => { + setSortBy(response.data.sortBy); + setTopSpinner(true); + }); + }; + + const rescanGpu = () => { + router.post(route('dashboard.admin.gpuRescan'), {}, { onSuccess: () => router.reload() }); + }; + return ( Dashboard -
- +
+ +
+ +
} @@ -59,6 +107,13 @@ export default function Dashboard() {
+ {/* By-process CPU + RAM doughnuts */} + + {/* CPU Usage*/} @@ -76,14 +131,17 @@ export default function Dashboard() {
- +
+ {/* GPU — only rendered when one was detected */} + +
- +
diff --git a/resources/js/Pages/Dashboard/Admin/AdminDashboard.test.jsx b/resources/js/Pages/Dashboard/Admin/AdminDashboard.test.jsx new file mode 100644 index 0000000..9bfc5ae --- /dev/null +++ b/resources/js/Pages/Dashboard/Admin/AdminDashboard.test.jsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import AdminDashboard from './AdminDashboard'; + +// Mock window.axios (used by TopProcesses) +const mockAxiosGet = vi.fn(() => Promise.resolve({ data: { sortBy: 'cpu' } })); +const mockAxiosPatch = vi.fn(() => Promise.resolve({ data: { sortBy: 'cpu' } })); + +// Mock window.Echo +const mockWhisper = vi.fn(); +const mockListen = vi.fn().mockReturnValue({ whisper: mockWhisper }); +const mockLeave = vi.fn(); + +function makeEchoChannel() { + return { listen: mockListen, whisper: mockWhisper }; +} + +beforeEach(() => { + window.Echo = { + private: vi.fn().mockReturnValue(makeEchoChannel()), + leave: mockLeave, + }; + window.axios = { + get: mockAxiosGet, + patch: mockAxiosPatch, + }; + vi.clearAllMocks(); + // Re-assign after clearAllMocks + mockAxiosGet.mockReturnValue(Promise.resolve({ data: { sortBy: 'cpu' } })); + mockAxiosPatch.mockReturnValue(Promise.resolve({ data: { sortBy: 'cpu' } })); + window.Echo = { + private: vi.fn().mockReturnValue({ listen: vi.fn().mockReturnValue({}), whisper: vi.fn() }), + leave: vi.fn(), + }; + window.axios = { + get: mockAxiosGet, + patch: mockAxiosPatch, + }; +}); + +// Mock @inertiajs/react +vi.mock('@inertiajs/react', () => ({ + Head: ({ title }) => {title}, + Link: ({ href, children }) => {children}, +})); + +// Mock AuthenticatedLayout to just render children +vi.mock('@/Layouts/AuthenticatedLayout', () => ({ + default: ({ children, header }) =>
{header}{children}
, +})); + +// Minimal fixture matching shape AdminDashboard.jsx reads +const nonEmptyStats = { + cpuStats: { + usage: '42', + loadTimes: '0.10, 0.05, 0.01', + uptime: 'up 1 hour', + processCount: '42', + }, + memoryStats: { free: '1024', used: '512', buffcache: '256', total: '2048' }, + diskStats: { size: '100G', used: '20G', free: '80G', percent: '20%' }, + network: [], + mysql: { pid: '123', memory: '64M', cpuTime: '0h1m', uptime: '2 days' }, + phpFpm: {}, + apache: { status: 'active', memory: '32M' }, +}; + +describe('AdminDashboard', () => { + it('shows CPU usage immediately when initialStats is non-empty (no spinner wait)', () => { + render(); + // CPULive renders "42%" when cpuStats.usage is truthy + expect(screen.getByText('42%')).toBeInTheDocument(); + }); + + it('does not crash when initialStats is empty array', () => { + expect(() => render()).not.toThrow(); + }); + + it('does not crash when initialStats is undefined', () => { + expect(() => render()).not.toThrow(); + }); +}); diff --git a/resources/js/Pages/Dashboard/Admin/Components/DbEnginesLive.jsx b/resources/js/Pages/Dashboard/Admin/Components/DbEnginesLive.jsx new file mode 100644 index 0000000..275b34d --- /dev/null +++ b/resources/js/Pages/Dashboard/Admin/Components/DbEnginesLive.jsx @@ -0,0 +1,63 @@ +import { FaArrowUpShortWide, FaMicrochip } from "react-icons/fa6"; +import { TbBrandMysql, TbDatabase } from "react-icons/tb"; +import { LuMemoryStick } from "react-icons/lu"; + +/** + * Capitalise first letter of each word in the engine key. + * mysql -> MySQL, mariadb -> MariaDB, postgres -> Postgres + */ +function formatLabel(engineKey) { + const map = { + mysql: 'MySQL', + mariadb: 'MariaDB', + postgres: 'Postgres', + }; + return map[engineKey] ?? (engineKey.charAt(0).toUpperCase() + engineKey.slice(1)); +} + +function EngineIcon({ engineKey }) { + if (engineKey === 'mysql' || engineKey === 'mariadb') { + return ; + } + return ; +} + +const DbEnginesLive = ({ dbEngines }) => { + if (!dbEngines || Object.keys(dbEngines).length === 0) { + return null; + } + + return ( + <> + {Object.entries(dbEngines).map(([engineKey, stats]) => ( +
+
+
+ +
+
+ {formatLabel(engineKey)} +
+
+ +
+
+ + {stats?.memory ? stats.memory : '--'} +
+
+ + {stats?.cpuTime ? stats.cpuTime : '--'} +
+
+ + {stats?.uptime ? stats.uptime : '--'} +
+
+
+ ))} + + ); +}; + +export default DbEnginesLive; diff --git a/resources/js/Pages/Dashboard/Admin/Components/DbEnginesLive.test.jsx b/resources/js/Pages/Dashboard/Admin/Components/DbEnginesLive.test.jsx new file mode 100644 index 0000000..4170609 --- /dev/null +++ b/resources/js/Pages/Dashboard/Admin/Components/DbEnginesLive.test.jsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import DbEnginesLive from './DbEnginesLive'; + +const mysqlStats = { memory: '64M', cpuTime: '0h1m', uptime: '2 days', pid: '123' }; +const postgresStats = { memory: '128M', cpuTime: '0h2m', uptime: '1 day', pid: '456' }; + +describe('DbEnginesLive', () => { + it('renders MySQL card when dbEngines has mysql key', () => { + render(); + expect(screen.getByText('MySQL')).toBeInTheDocument(); + expect(screen.getByText('64M')).toBeInTheDocument(); + }); + + it('renders Postgres card when dbEngines has postgres key', () => { + render(); + expect(screen.getByText('Postgres')).toBeInTheDocument(); + expect(screen.getByText('128M')).toBeInTheDocument(); + }); + + it('renders nothing when dbEngines is empty object', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when dbEngines is undefined', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders both MySQL and Postgres cards when both are present', () => { + render(); + expect(screen.getByText('MySQL')).toBeInTheDocument(); + expect(screen.getByText('Postgres')).toBeInTheDocument(); + }); +}); diff --git a/resources/js/Pages/Dashboard/Admin/Components/GpuLive.jsx b/resources/js/Pages/Dashboard/Admin/Components/GpuLive.jsx new file mode 100644 index 0000000..32ed60a --- /dev/null +++ b/resources/js/Pages/Dashboard/Admin/Components/GpuLive.jsx @@ -0,0 +1,106 @@ +import { Doughnut } from 'react-chartjs-2'; +import { ArcElement, Chart as ChartJS, Legend, Tooltip } from 'chart.js'; +import { BsGpuCard } from 'react-icons/bs'; +import { FaTemperatureHalf, FaBolt } from 'react-icons/fa6'; +import { GiProgression } from 'react-icons/gi'; +import { FaMemory } from 'react-icons/fa6'; + +ChartJS.register(ArcElement, Tooltip, Legend); + +const USED_COLOR = '#6366f1'; +const HEADROOM_COLOR = '#1f9d55'; // green idle/free + +function Gauge({ usedLabel, usedValue, restLabel, restValue, unit = '%' }) { + return ( + // aspectRatio sizing avoids the zero-height-mount doughnut collapse. +
+ `${ctx.label} — ${ctx.parsed}${unit}` } }, + }, + }} + /> +
+ ); +} + +function StatCard({ icon, label, value }) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +} + +/** + * GPU section — only rendered when a GPU was detected (liveStats.gpu is null + * otherwise). Two gauges (utilisation, VRAM) plus stat cards. + */ +const GpuLive = ({ gpu }) => { + if (!gpu) return null; + + const util = Number(gpu.util) || 0; + const idle = Math.max(0, 100 - util); + const vramUsed = Number(gpu.vramUsed) || 0; + const vramTotal = Number(gpu.vramTotal) || 0; + const vramFree = Math.max(0, vramTotal - vramUsed); + + return ( +
+
+ +
GPU
+
+ {gpu.name} {gpu.vendor ? `(${gpu.vendor})` : ''} +
+
+ +
+
+
+ Utilisation + {util}% +
+ +
+ +
+
+ VRAM + {vramUsed} / {vramTotal} GB +
+ +
+
+ +
+ } label="Utilisation" value={`${util}%`} /> + } label="VRAM" value={`${vramUsed} / ${vramTotal} GB`} /> + } label="Temp" value={`${gpu.temp ?? '--'}°C`} /> + } label="Power" value={`${gpu.power ?? '--'} W`} /> +
+
+ ); +}; + +export default GpuLive; diff --git a/resources/js/Pages/Dashboard/Admin/Components/GpuLive.test.jsx b/resources/js/Pages/Dashboard/Admin/Components/GpuLive.test.jsx new file mode 100644 index 0000000..dd2b443 --- /dev/null +++ b/resources/js/Pages/Dashboard/Admin/Components/GpuLive.test.jsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import GpuLive from './GpuLive'; + +vi.mock('react-chartjs-2', () => ({ + Doughnut: () => , +})); + +vi.mock('chart.js', () => { + const Chart = { register: () => {} }; + return { default: Chart, Chart, ArcElement: {}, Tooltip: {}, Legend: {} }; +}); + +describe('GpuLive', () => { + it('renders nothing when no GPU was detected', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders gauges and stat cards when a GPU is present', () => { + render( + + ); + + expect(screen.getByText(/RTX 3080/)).toBeInTheDocument(); + expect(screen.getAllByTestId('gpu-doughnut')).toHaveLength(2); // util + VRAM + expect(screen.getAllByText('42%').length).toBeGreaterThan(0); + expect(screen.getAllByText(/1\.5 \/ 8 GB/).length).toBeGreaterThan(0); + expect(screen.getByText(/61°C/)).toBeInTheDocument(); + expect(screen.getByText(/120 W/)).toBeInTheDocument(); + }); +}); diff --git a/resources/js/Pages/Dashboard/Admin/Components/MySQLLive.jsx b/resources/js/Pages/Dashboard/Admin/Components/MySQLLive.jsx deleted file mode 100644 index a117e7b..0000000 --- a/resources/js/Pages/Dashboard/Admin/Components/MySQLLive.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import { FaArrowUpShortWide, FaMicrochip } from "react-icons/fa6"; -import { ImSpinner9 } from "react-icons/im"; -import { TbBrandMysql } from "react-icons/tb"; -import { LuMemoryStick } from "react-icons/lu"; - -const MySQLLive = ({ mysqlStats }) => { - - if (!mysqlStats) return; - - return ( -
-
-
- -
-
MySQL
-
- -
-
- - {mysqlStats?.memory ? mysqlStats.memory : '--'} -
-
- - {mysqlStats?.cpuTime ? mysqlStats.cpuTime : '--'} -
-
- - {mysqlStats?.uptime ? mysqlStats.uptime : '--'} -
-
-
- ); -} - -export default MySQLLive; diff --git a/resources/js/Pages/Dashboard/Admin/Components/ResourceShareCharts.jsx b/resources/js/Pages/Dashboard/Admin/Components/ResourceShareCharts.jsx new file mode 100644 index 0000000..2e4cbbd --- /dev/null +++ b/resources/js/Pages/Dashboard/Admin/Components/ResourceShareCharts.jsx @@ -0,0 +1,89 @@ +import { Doughnut } from 'react-chartjs-2'; +import { ArcElement, Chart as ChartJS, Legend, Tooltip } from 'chart.js'; +import { FaMicrochip, FaMemory } from 'react-icons/fa6'; +import { ImSpinner9 } from 'react-icons/im'; +import { buildCpuShare, buildMemoryShare } from './processShares'; + +ChartJS.register(ArcElement, Tooltip, Legend); + +function ShareDoughnut({ title, headline, icon, model }) { + return ( +
+
+
+ {icon} + {title} +
+ {headline && ( + {headline} + )} +
+ + {!model ? ( +
+ +
+ ) : ( + // maintainAspectRatio:true sizes from width (never a zero-height + // parent), so the doughnut can't collapse to a dot on mount. +
+ s.label), + datasets: [ + { + data: model.slices.map((s) => s.value), + backgroundColor: model.slices.map((s) => s.color), + borderWidth: 1, + }, + ], + }} + options={{ + responsive: true, + maintainAspectRatio: true, + aspectRatio: 1.3, + cutout: '60%', + plugins: { + legend: { + position: 'bottom', + labels: { boxWidth: 12, font: { size: 11 }, color: '#6b7280' }, + }, + tooltip: { + callbacks: { label: (ctx) => `${ctx.label} — ${ctx.parsed}%` }, + }, + }, + }} + /> +
+ )} +
+ ); +} + +/** + * Two doughnuts above the dashboard stat cards: CPU and RAM usage broken down by + * process, each with an Idle / Free headroom slice. + */ +const ResourceShareCharts = ({ topStats, cpuStats, memoryStats }) => { + const cpu = buildCpuShare(topStats, cpuStats); + const mem = buildMemoryShare(topStats, memoryStats); + + return ( +
+ } + model={cpu} + /> + } + model={mem} + /> +
+ ); +}; + +export default ResourceShareCharts; diff --git a/resources/js/Pages/Dashboard/Admin/Components/ResourceShareCharts.test.jsx b/resources/js/Pages/Dashboard/Admin/Components/ResourceShareCharts.test.jsx new file mode 100644 index 0000000..9ad8f5b --- /dev/null +++ b/resources/js/Pages/Dashboard/Admin/Components/ResourceShareCharts.test.jsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import ResourceShareCharts from './ResourceShareCharts'; + +const captured = []; + +vi.mock('react-chartjs-2', () => ({ + Doughnut: ({ data }) => { + captured.push(data); + return ; + }, +})); + +vi.mock('chart.js', () => { + const Chart = { register: () => {} }; + return { default: Chart, Chart, ArcElement: {}, Tooltip: {}, Legend: {} }; +}); + +const procs = [ + { mainCmd: 'mysqld', cpu: '10', mem: '12' }, + { mainCmd: 'php', cpu: '5', mem: '8' }, +]; + +beforeEach(() => { + captured.length = 0; +}); + +describe('ResourceShareCharts', () => { + it('renders two doughnuts with Idle and Free slices when data is present', () => { + render( + + ); + + expect(screen.getAllByTestId('doughnut')).toHaveLength(2); + const allLabels = captured.flatMap((d) => d.labels); + expect(allLabels).toContain('Idle'); + expect(allLabels).toContain('Free'); + expect(allLabels).toContain('mysqld'); + }); + + it('shows spinners (no canvas) until stats arrive', () => { + render(); + // memoryStats has no total -> mem model null -> spinner; cpu usage 0 still renders. + // At minimum the memory card must not render a canvas. + expect(screen.queryAllByTestId('doughnut').length).toBeLessThan(2); + }); +}); diff --git a/resources/js/Pages/Dashboard/Admin/Components/TopProcesses.jsx b/resources/js/Pages/Dashboard/Admin/Components/TopProcesses.jsx index 622c1e3..978dd02 100644 --- a/resources/js/Pages/Dashboard/Admin/Components/TopProcesses.jsx +++ b/resources/js/Pages/Dashboard/Admin/Components/TopProcesses.jsx @@ -1,50 +1,11 @@ import { Tooltip } from 'react-tooltip' -import { useEffect, useState } from "react"; import { FaSitemap, FaArrowDown91 } from 'react-icons/fa6'; import { ImSpinner9 } from "react-icons/im"; +import TopProcessesChart from './TopProcessesChart'; - -const TopProcesses = () => { - const [topStats, setTopStats] = useState([]); - const [sortBy, setSortBy] = useState("cpu"); - const [spinner, showSpinner] = useState(false); - - const echo = window.Echo; - - const setSortPreferrence = (sortBy) => { - window.axios.patch("/dashboard/admin/set/top-sort", { sortBy }).then((response) => { - setSortBy(response.data.sortBy); - showSpinner(true); - }); - } - - useEffect(() => { - - const topStatsChannel = echo.private("topstats"); - - window.axios.get("/dashboard/admin/get/top-sort").then((response) => { - setSortBy(response.data.sortBy); - }); - - topStatsChannel.listen("TopStatsEvent", (data) => { - setTopStats(data); - showSpinner(false); - }); - - // Set interval to "whisper" every 2 seconds - // Makes it so we get stats via sockets - const whisperInterval = setInterval(() => { - topStatsChannel.whisper("typing", { requesting: "dashboard-top-stats" }); - }, 2000); - - return () => { - clearInterval(whisperInterval); - echo.leave("topstats"); - }; - }, []); - - - { topStats?.error && } +// Presentational: data + sort control are owned by AdminDashboard so the +// `topstats` channel is subscribed exactly once for the whole page. +const TopProcesses = ({ topStats = [], sortBy = "cpu", onSort, spinner = false }) => { return (<>
@@ -55,17 +16,18 @@ const TopProcesses = () => {
{spinner ? : (<> - - )}
+
diff --git a/resources/js/Pages/Dashboard/Admin/Components/TopProcesses.test.jsx b/resources/js/Pages/Dashboard/Admin/Components/TopProcesses.test.jsx new file mode 100644 index 0000000..b9e8568 --- /dev/null +++ b/resources/js/Pages/Dashboard/Admin/Components/TopProcesses.test.jsx @@ -0,0 +1,102 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import TopProcessesChart from './TopProcessesChart'; + +// Capture data passed to the Doughnut component so we can verify aggregation logic. +// We mock react-chartjs-2 to avoid the canvas/WebGL environment unavailable in jsdom. +let capturedChartData = null; +let capturedChartOptions = null; + +vi.mock('react-chartjs-2', () => ({ + Doughnut: ({ data, options }) => { + capturedChartData = data; + capturedChartOptions = options; + return ; + }, +})); + +// chart.js registers are no-ops in tests +vi.mock('chart.js', () => { + const noop = () => {}; + const Chart = { register: noop, defaults: {} }; + return { + default: Chart, + Chart, + ArcElement: {}, + Tooltip: {}, + Legend: {}, + DoughnutController: {}, + }; +}); + +// Sample topStats with 12 processes, most under 1% CPU so "Other" is expected +const makeSample = (n = 12) => + Array.from({ length: n }, (_, i) => ({ + pid: String(i + 1), + cpu: i === 0 ? '30.5' : '0.4', // first dominates, rest are under 1% + mem: i === 0 ? '20.0' : '0.3', + user: 'root', + mainCmd: `proc${i}`, + restOfCmd: [], + })); + +beforeEach(() => { + capturedChartData = null; + capturedChartOptions = null; + vi.clearAllMocks(); +}); + +describe('TopProcessesChart', () => { + it('renders a canvas element when topStats has entries', () => { + render(); + expect(screen.getByTestId('doughnut-chart')).toBeInTheDocument(); + }); + + it('contains an "Other" label when many processes are each under 1%', () => { + render(); + expect(capturedChartData).not.toBeNull(); + expect(capturedChartData.labels).toContain('Other'); + }); + + it('renders nothing (no canvas) when topStats is empty', () => { + render(); + expect(screen.queryByTestId('doughnut-chart')).not.toBeInTheDocument(); + }); + + it('sizes from width via a fixed aspect ratio, not the parent height', () => { + // Regression guard: with maintainAspectRatio:false the chart mounts on + // the first websocket payload while the parent height can be 0, sizes to + // that, and never recovers — rendering a tiny dot. Deriving height from + // width (maintainAspectRatio:true + aspectRatio) avoids the zero-height trap. + render(); + expect(capturedChartOptions).not.toBeNull(); + expect(capturedChartOptions.maintainAspectRatio).toBe(true); + expect(capturedChartOptions.aspectRatio).toBeGreaterThan(0); + expect(capturedChartOptions.plugins.legend.position).toBe('bottom'); + }); + + it('uses memory metric when sortBy is "memory"', () => { + const stats = [ + { pid: '1', cpu: '5.0', mem: '40.0', user: 'root', mainCmd: 'bigmem', restOfCmd: [] }, + { pid: '2', cpu: '0.1', mem: '0.2', user: 'root', mainCmd: 'tiny', restOfCmd: [] }, + ]; + render(); + expect(capturedChartData).not.toBeNull(); + // bigmem should have the largest slice (40.0 vs 0.2) + const bigmemIndex = capturedChartData.labels.indexOf('bigmem'); + expect(bigmemIndex).toBeGreaterThanOrEqual(0); + expect(capturedChartData.datasets[0].data[bigmemIndex]).toBeCloseTo(40.0); + }); + + it('uses CPU metric when sortBy is "cpu" (default)', () => { + const stats = [ + { pid: '1', cpu: '50.0', mem: '5.0', user: 'root', mainCmd: 'highcpu', restOfCmd: [] }, + { pid: '2', cpu: '0.1', mem: '0.2', user: 'root', mainCmd: 'low', restOfCmd: [] }, + ]; + render(); + expect(capturedChartData).not.toBeNull(); + const highCpuIndex = capturedChartData.labels.indexOf('highcpu'); + expect(highCpuIndex).toBeGreaterThanOrEqual(0); + expect(capturedChartData.datasets[0].data[highCpuIndex]).toBeCloseTo(50.0); + }); +}); diff --git a/resources/js/Pages/Dashboard/Admin/Components/TopProcessesChart.jsx b/resources/js/Pages/Dashboard/Admin/Components/TopProcessesChart.jsx new file mode 100644 index 0000000..5a001fd --- /dev/null +++ b/resources/js/Pages/Dashboard/Admin/Components/TopProcessesChart.jsx @@ -0,0 +1,103 @@ +import { Doughnut } from 'react-chartjs-2'; +import { ArcElement, Chart as ChartJS, Legend, Tooltip } from 'chart.js'; + +ChartJS.register(ArcElement, Tooltip, Legend); + +const COLORS = [ + '#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', + '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#06b6d4', + '#84cc16', '#a78bfa', +]; + +/** + * Aggregates topStats by mainCmd. + * Items where each command is under 1% of the total are merged into "Other". + * + * @param {Array} topStats + * @param {'cpu'|'memory'} sortBy + * @returns {{ labels: string[], values: number[] }} + */ +function aggregate(topStats, sortBy) { + const byCmd = {}; + for (const item of topStats) { + const val = parseFloat(sortBy === 'memory' ? item.mem : item.cpu) || 0; + byCmd[item.mainCmd] = (byCmd[item.mainCmd] || 0) + val; + } + + const labels = []; + const values = []; + let other = 0; + + for (const [cmd, val] of Object.entries(byCmd)) { + if (val < 1) { + other += val; + } else { + labels.push(cmd); + values.push(val); + } + } + + if (other > 0) { + labels.push('Other'); + values.push(Math.round(other * 100) / 100); + } + + return { labels, values }; +} + +/** + * Doughnut chart showing top processes by CPU or memory usage. + * + * @param {{ topStats: Array, sortBy: 'cpu'|'memory' }} props + */ +const TopProcessesChart = ({ topStats, sortBy = 'cpu' }) => { + if (!topStats || topStats.length === 0) { + return null; + } + + const { labels, values } = aggregate(topStats, sortBy); + const metricLabel = sortBy === 'memory' ? 'MEM' : 'CPU'; + + const data = { + labels, + datasets: [ + { + data: values, + backgroundColor: labels.map((_, i) => COLORS[i % COLORS.length]), + borderWidth: 1, + }, + ], + }; + + const options = { + responsive: true, + // Derive height from width (aspect ratio) rather than the parent's + // height. With maintainAspectRatio:false the chart mounts on the first + // websocket payload — at which point the parent's height can momentarily + // be 0 — and chart.js sizes to that and never re-measures a height + // change, leaving the doughnut a tiny dot. Width is always non-zero and + // chart.js does observe width changes, so this stays correctly sized. + maintainAspectRatio: true, + aspectRatio: 1.6, + plugins: { + legend: { position: 'bottom' }, + tooltip: { + callbacks: { + label: (ctx) => { + const item = topStats.find((p) => p.mainCmd === ctx.label); + if (!item) return `${ctx.label} — ${ctx.parsed}% ${metricLabel}`; + return `${ctx.label} — ${item.cpu}% CPU / ${item.mem}% MEM`; + }, + }, + }, + }, + }; + + return ( +
+ +
+ ); +}; + +export default TopProcessesChart; diff --git a/resources/js/Pages/Dashboard/Admin/Components/processShares.js b/resources/js/Pages/Dashboard/Admin/Components/processShares.js new file mode 100644 index 0000000..88cefe0 --- /dev/null +++ b/resources/js/Pages/Dashboard/Admin/Components/processShares.js @@ -0,0 +1,113 @@ +/** + * Pure helpers that turn the live top-process list into doughnut chart data: + * top processes by a metric, an "Other" bucket, and a final remainder slice + * (Free RAM / Idle CPU). Kept framework-free so it is unit-testable. + */ + +export const SHARE_COLORS = [ + '#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', +]; +export const OTHER_COLOR = '#9ca3af'; // gray-400 +export const REMAINDER_COLOR = '#1f9d55'; // green — Free/Idle headroom + +const round = (n) => Math.round(n * 100) / 100; + +/** + * Aggregate processes by command for a metric, keep the top N, merge the rest + * into "Other". + * + * @param {Array} topStats list of { mainCmd, cpu, mem } + * @param {'cpu'|'mem'} metric + * @param {number} topN + * @returns {{ labels: string[], values: number[] }} + */ +export function aggregateByCommand(topStats, metric, topN = 6) { + const byCmd = {}; + for (const item of topStats ?? []) { + const val = parseFloat(metric === 'mem' ? item.mem : item.cpu) || 0; + if (val <= 0) continue; + byCmd[item.mainCmd] = (byCmd[item.mainCmd] || 0) + val; + } + + const sorted = Object.entries(byCmd).sort((a, b) => b[1] - a[1]); + const labels = []; + const values = []; + let other = 0; + + sorted.forEach(([cmd, val], i) => { + if (i < topN) { + labels.push(cmd); + values.push(val); + } else { + other += val; + } + }); + + return { labels, values, other }; +} + +/** + * RAM doughnut: top processes by %MEM + Other used + Free. + * Process %MEM is already a share of total RAM, so slices + Free ≈ 100%. + * + * @param {Array} topStats + * @param {{ total:number, free:number }} memoryStats MB + */ +export function buildMemoryShare(topStats, memoryStats) { + const total = parseFloat(memoryStats?.total) || 0; + const free = parseFloat(memoryStats?.free) || 0; + if (total <= 0) return null; + + const freePct = Math.min(100, Math.max(0, (free / total) * 100)); + const usedPct = 100 - freePct; + + const { labels, values, other } = aggregateByCommand(topStats, 'mem'); + const topSum = values.reduce((a, b) => a + b, 0); + // Whatever used memory isn't attributed to the top processes (incl. kernel, + // buffers, untracked procs) becomes the Other-used slice. + const otherUsed = Math.max(0, usedPct - topSum) + other; + + const slices = labels.map((l, i) => ({ label: l, value: round(values[i]), color: SHARE_COLORS[i % SHARE_COLORS.length] })); + if (otherUsed > 0.1) slices.push({ label: 'Other used', value: round(otherUsed), color: OTHER_COLOR }); + slices.push({ label: 'Free', value: round(freePct), color: REMAINDER_COLOR }); + + return { + slices, + centerLabel: `${round((total - free) / 1024)} / ${round(total / 1024)} GB`, + centerSub: 'used', + }; +} + +/** + * CPU doughnut: top processes by %CPU (normalised into the overall busy share) + * + Other busy + Idle. Process %CPU is per-core and can sum past 100, so scale + * the process shares to fill the overall usage%, leaving a coherent Idle slice. + * + * @param {Array} topStats + * @param {{ usage:number|string }} cpuStats overall usage % + */ +export function buildCpuShare(topStats, cpuStats) { + const usage = Math.min(100, Math.max(0, parseFloat(cpuStats?.usage) || 0)); + const idlePct = 100 - usage; + + const { labels, values, other } = aggregateByCommand(topStats, 'cpu'); + const procSum = values.reduce((a, b) => a + b, 0) + other; + + // Scale per-core process percentages into the real busy share. + const scale = procSum > 0 ? usage / procSum : 0; + + const slices = labels.map((l, i) => ({ + label: l, + value: round(values[i] * scale), + color: SHARE_COLORS[i % SHARE_COLORS.length], + })); + const otherBusy = round(other * scale); + if (otherBusy > 0.1) slices.push({ label: 'Other', value: otherBusy, color: OTHER_COLOR }); + slices.push({ label: 'Idle', value: round(idlePct), color: REMAINDER_COLOR }); + + return { + slices, + centerLabel: `${round(usage)}%`, + centerSub: 'busy', + }; +} diff --git a/resources/js/Pages/Dashboard/Admin/Components/processShares.test.js b/resources/js/Pages/Dashboard/Admin/Components/processShares.test.js new file mode 100644 index 0000000..ad26577 --- /dev/null +++ b/resources/js/Pages/Dashboard/Admin/Components/processShares.test.js @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { aggregateByCommand, buildMemoryShare, buildCpuShare } from './processShares'; + +const procs = [ + { mainCmd: 'mysqld', cpu: '10', mem: '12' }, + { mainCmd: 'php', cpu: '5', mem: '8' }, + { mainCmd: 'php', cpu: '5', mem: '2' }, // same command aggregates + { mainCmd: 'a', cpu: '1', mem: '1' }, + { mainCmd: 'b', cpu: '1', mem: '1' }, + { mainCmd: 'c', cpu: '1', mem: '1' }, + { mainCmd: 'd', cpu: '1', mem: '1' }, + { mainCmd: 'e', cpu: '1', mem: '1' }, // 8 commands -> beyond topN=6 -> Other +]; + +describe('aggregateByCommand', () => { + it('sums duplicate commands and keeps only the top N, rest into other', () => { + const { labels, values, other } = aggregateByCommand(procs, 'cpu', 6); + // php is 5+5=10, ties mysqld at top + expect(labels).toContain('php'); + expect(labels).toContain('mysqld'); + expect(labels.length).toBe(6); + expect(other).toBeGreaterThan(0); // the 7th+8th commands + }); + + it('ignores zero/negative metric values', () => { + const { labels } = aggregateByCommand([{ mainCmd: 'idle', cpu: '0', mem: '0' }], 'cpu'); + expect(labels).toHaveLength(0); + }); +}); + +describe('buildMemoryShare', () => { + it('appends a Free slice from memoryStats and returns a GB center label', () => { + const model = buildMemoryShare(procs, { total: 32000, free: 20000 }); + const free = model.slices.find((s) => s.label === 'Free'); + expect(free).toBeDefined(); + // free = 20000/32000 = 62.5% + expect(free.value).toBeCloseTo(62.5, 1); + expect(model.centerLabel).toMatch(/GB/); + }); + + it('returns null when total memory is unknown', () => { + expect(buildMemoryShare(procs, { total: 0, free: 0 })).toBeNull(); + }); +}); + +describe('buildCpuShare', () => { + it('adds an Idle slice equal to 100 - usage and labels the center with usage%', () => { + const model = buildCpuShare(procs, { usage: '40' }); + const idle = model.slices.find((s) => s.label === 'Idle'); + expect(idle.value).toBeCloseTo(60, 1); + expect(model.centerLabel).toBe('40%'); + }); + + it('scales per-core process percentages into the overall busy share (slices ~ 100%)', () => { + const model = buildCpuShare(procs, { usage: '40' }); + const sum = model.slices.reduce((a, s) => a + s.value, 0); + expect(sum).toBeCloseTo(100, 0); + }); +}); diff --git a/resources/js/Pages/Databases/DbServiceControl.test.jsx b/resources/js/Pages/Databases/DbServiceControl.test.jsx new file mode 100644 index 0000000..3f57d09 --- /dev/null +++ b/resources/js/Pages/Databases/DbServiceControl.test.jsx @@ -0,0 +1,178 @@ +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { test, expect, vi, beforeEach, describe } from 'vitest'; +import DbServiceControl from './Partials/DbServiceControl'; + +// Mock OperationProgress as a simple stub +vi.mock('@/Components/OperationProgress', () => ({ + default: ({ operationId, onDone }) => ( +
+ {operationId} + +
+ ), +})); + +// Mock axios +vi.mock('axios'); +import axios from 'axios'; + +// Mock route() global +global.route = (name) => + ({ + 'databases.service.status': '/admin/databases/service/status', + 'databases.service.action': '/admin/databases/service', + }[name]); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('DbServiceControl', () => { + test('renders status table with engine, service, active badge and action buttons', async () => { + axios.get = vi.fn().mockResolvedValue({ + data: { statuses: { mysql: { service: 'mysql', active: true } } }, + }); + + render(); + + await waitFor(() => { + expect(screen.getAllByText('mysql').length).toBeGreaterThan(0); + }); + + expect(screen.getByText('active')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^start$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^stop$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^restart$/i })).toBeInTheDocument(); + }); + + test('active badge has green class; inactive badge has red class', async () => { + axios.get = vi.fn().mockResolvedValue({ + data: { + statuses: { + mysql: { service: 'mysql', active: true }, + postgres: { service: 'postgresql', active: false }, + }, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getAllByText('mysql').length).toBeGreaterThan(0); + }); + + const activeBadge = screen.getByText('active'); + expect(activeBadge.className).toMatch(/green/); + + const inactiveBadge = screen.getByText('inactive'); + expect(inactiveBadge.className).toMatch(/red/); + }); + + test('dispatches action and renders OperationProgress with operationId', async () => { + axios.get = vi.fn().mockResolvedValue({ + data: { statuses: { mysql: { service: 'mysql', active: true } } }, + }); + axios.post = vi.fn().mockResolvedValue({ data: { operation_id: 42 } }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^restart$/i })).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button', { name: /^restart$/i })); + + await waitFor(() => { + expect(axios.post).toHaveBeenCalledWith('/admin/databases/service', { + engine: 'mysql', + action: 'restart', + }); + }); + + await waitFor(() => { + expect(screen.getByTestId('op-progress')).toBeInTheDocument(); + expect(screen.getByTestId('op-progress').dataset.operationId).toBe('42'); + }); + }); + + test('buttons disabled during operation (after click, before onDone)', async () => { + axios.get = vi.fn().mockResolvedValue({ + data: { statuses: { mysql: { service: 'mysql', active: true } } }, + }); + axios.post = vi.fn().mockResolvedValue({ data: { operation_id: 99 } }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^restart$/i })).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button', { name: /^restart$/i })); + + await waitFor(() => { + expect(screen.getByTestId('op-progress')).toBeInTheDocument(); + }); + + // All action buttons should be disabled while operation is in flight + expect(screen.getByRole('button', { name: /^start$/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /^stop$/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /^restart$/i })).toBeDisabled(); + }); + + test('status re-fetched after onDone fires', async () => { + axios.get = vi.fn().mockResolvedValue({ + data: { statuses: { mysql: { service: 'mysql', active: true } } }, + }); + axios.post = vi.fn().mockResolvedValue({ data: { operation_id: 55 } }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^restart$/i })).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button', { name: /^restart$/i })); + + await waitFor(() => { + expect(screen.getByTestId('trigger-done')).toBeInTheDocument(); + }); + + // axios.get called once on mount + expect(axios.get).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByTestId('trigger-done')); + + await waitFor(() => { + expect(axios.get).toHaveBeenCalledTimes(2); + }); + }); + + test('error path: axios.post reject sets error message and buttons not disabled', async () => { + axios.get = vi.fn().mockResolvedValue({ + data: { statuses: { mysql: { service: 'mysql', active: true } } }, + }); + axios.post = vi.fn().mockRejectedValue({ + response: { data: { message: 'Forbidden' } }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^restart$/i })).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button', { name: /^restart$/i })); + + await waitFor(() => { + expect(screen.getByText('Forbidden')).toBeInTheDocument(); + }); + + // Buttons must NOT be permanently disabled after error + expect(screen.getByRole('button', { name: /^start$/i })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: /^stop$/i })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: /^restart$/i })).not.toBeDisabled(); + }); +}); diff --git a/resources/js/Pages/Databases/Index.jsx b/resources/js/Pages/Databases/Index.jsx new file mode 100644 index 0000000..5a52487 --- /dev/null +++ b/resources/js/Pages/Databases/Index.jsx @@ -0,0 +1,93 @@ +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { Head, router, usePage } from '@inertiajs/react'; +import { TbDatabase } from 'react-icons/tb'; +import { TiDelete } from 'react-icons/ti'; +import { toast } from 'react-toastify'; +import CreateDatabaseForm from './Partials/CreateDatabaseForm'; +import DbServiceControl from './Partials/DbServiceControl'; +import EditDatabaseForm from './Partials/EditDatabaseForm'; +import ConfirmationButton from '@/Components/ConfirmationButton'; +import { Tooltip } from 'react-tooltip'; + +export default function DatabasesIndex({ databases = [] }) { + + const { auth } = usePage().props; + + const deleteDb = (id) => { + router.delete(route('databases.destroy'), { + data: { id }, + onBefore: () => toast('Deleting database...'), + onError: () => toast('Failed to delete database.'), + }); + }; + + return ( + +

+ + Databases ({databases.length}/{auth.user.database_limit || 'unlimited'}) +

+ + + } + > + + +
+ {databases.length === 0 ? ( +

No databases found.

+ ) : ( +
+
+ + + + + + + + + + + + + + {databases.map((db, index) => ( + + + + + + + + + + + ))} + +
DatabaseUserEngineTablesSize (MB)CharsetCollationActions
{db.name}{db.db_user} + + {db.engine} + + {db.tables}{db.sizeMb} + {db.engine === 'postgres' ? '—' : (db.charset || '—')} + + {db.engine === 'postgres' ? '—' : (db.collation || '—')} + +
+ + deleteDb(db.id)}> + + +
+
+
+ )} + + {auth.user.role === 'admin' && } + + + ); +} diff --git a/resources/js/Pages/Databases/Partials/CreateDatabaseForm.jsx b/resources/js/Pages/Databases/Partials/CreateDatabaseForm.jsx new file mode 100644 index 0000000..fb90bf5 --- /dev/null +++ b/resources/js/Pages/Databases/Partials/CreateDatabaseForm.jsx @@ -0,0 +1,274 @@ +import Modal from '@/Components/Modal'; +import PrimaryButton from '@/Components/PrimaryButton'; +import SecondaryButton from '@/Components/SecondaryButton'; +import InputLabel from '@/Components/InputLabel'; +import TextInput from '@/Components/TextInput'; +import InputError from '@/Components/InputError'; +import { useForm, usePage } from '@inertiajs/react'; +import { useState, useEffect } from 'react'; +import { TbDatabase } from 'react-icons/tb'; +import axios from 'axios'; + +export default function CreateDatabaseForm() { + const { auth } = usePage().props; + const [showModal, setShowModal] = useState(false); + const [engines, setEngines] = useState([]); + const [selectedEngine, setSelectedEngine] = useState(''); + const [capabilities, setCapabilities] = useState(null); + const [loading, setLoading] = useState(false); + + const { data, setData, post, processing, reset, clearErrors, errors, transform } = useForm({ + name_suffix: '', + db_user_suffix: '', + db_pass: '', + engine: '', + charset: '', + collation: '', + }); + + useEffect(() => { + if (showModal) { + fetchEngineList(); + } + }, [showModal]); + + const fetchEngineList = async () => { + setLoading(true); + try { + const response = await axios.get(route('databases.engine-options')); + setEngines(response.data.engines || []); + setCapabilities(response.data.capabilities || null); + } catch (error) { + console.error('Error fetching engine options:', error); + } finally { + setLoading(false); + } + }; + + const handleEngineChange = async (engine) => { + setSelectedEngine(engine); + setData('engine', engine); + + if (!engine) { + setCapabilities(null); + return; + } + + setLoading(true); + try { + const response = await axios.get( + route('databases.engine-options') + '?engine=' + encodeURIComponent(engine) + ); + setCapabilities(response.data.capabilities || null); + } catch (error) { + console.error('Error fetching engine capabilities:', error); + } finally { + setLoading(false); + } + }; + + const showCreateModal = () => setShowModal(true); + + const closeModal = () => { + setShowModal(false); + clearErrors(); + reset(); + setEngines([]); + setSelectedEngine(''); + setCapabilities(null); + }; + + const createDatabase = (e) => { + e.preventDefault(); + const prefix = auth.user.username + '_'; + const name = data.name_suffix ? `${prefix}${data.name_suffix}` : ''; + const db_user = data.db_user_suffix ? `${prefix}${data.db_user_suffix}` : ''; + + transform((form) => ({ + ...form, + name, + db_user, + })); + + post(route('databases.store'), { + preserveScroll: true, + onSuccess: closeModal, + onFinish: () => transform((form) => form), + }); + }; + + const prefix = auth.user.username + '_'; + const optionFields = capabilities?.optionFields || []; + + return ( + <> + + + +
+

+ + Add a New Database +

+ +
+ {engines.length === 0 && !loading ? ( +

+ No database engine is currently active. +

+ ) : ( + <> +
+ + + +
+ +
+ +
+ + {prefix} + +
+ setData('name_suffix', e.target.value)} + className="flex-1 rounded-l-none w-full" + placeholder="mydb" + required + /> +
+
+ +
+ +
+ +
+ + {prefix} + +
+ setData('db_user_suffix', e.target.value)} + className="flex-1 rounded-l-none w-full" + placeholder="user" + required + /> +
+
+ +
+ +
+ + setData('db_pass', e.target.value)} + className="mt-1 block w-full" + required + /> + +
+ + {optionFields.includes('charset') && ( +
+ + setData('charset', e.target.value)} + className="mt-1 block w-full" + placeholder="utf8mb4" + /> + +
+ )} + + {optionFields.includes('collation') && ( +
+ + setData('collation', e.target.value)} + className="mt-1 block w-full" + placeholder="utf8mb4_unicode_ci" + /> + +
+ )} + + {optionFields.includes('encoding') && ( +
+ + setData('encoding', e.target.value)} + className="mt-1 block w-full" + placeholder="UTF8" + /> + +
+ )} + + {optionFields.includes('locale') && ( +
+ + setData('locale', e.target.value)} + className="mt-1 block w-full" + placeholder="en_US.UTF-8" + /> + +
+ )} + +
+ + Add Database + + Cancel +
+ + )} +
+
+
+ + ); +} diff --git a/resources/js/Pages/Databases/Partials/CreateDatabaseForm.test.jsx b/resources/js/Pages/Databases/Partials/CreateDatabaseForm.test.jsx new file mode 100644 index 0000000..5fbd177 --- /dev/null +++ b/resources/js/Pages/Databases/Partials/CreateDatabaseForm.test.jsx @@ -0,0 +1,145 @@ +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { test, expect, vi, beforeEach } from 'vitest'; +import CreateDatabaseForm from './CreateDatabaseForm'; + +// Mock @inertiajs/react +vi.mock('@inertiajs/react', () => ({ + useForm: () => ({ + data: { name_suffix: '', db_user_suffix: '', db_pass: '', engine: '', charset: '', collation: '' }, + setData: vi.fn(), + post: vi.fn(), + processing: false, + reset: vi.fn(), + clearErrors: vi.fn(), + errors: {}, + transform: vi.fn(), + }), + usePage: () => ({ props: { auth: { user: { username: 'testuser', database_limit: 5 } } } }), +})); + +// Mock axios +vi.mock('axios'); +import axios from 'axios'; + +// Mock route() global +global.route = (name, params) => { + const routes = { + 'databases.engine-options': '/databases/engine-options', + 'databases.store': '/databases', + }; + if (params) { + return routes[name] + '?' + new URLSearchParams(params).toString(); + } + return routes[name] || `/${name}`; +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('engine selector populates from engine-options response', async () => { + axios.get = vi.fn().mockResolvedValue({ + data: { engines: ['mysql', 'postgres'], capabilities: null }, + }); + + render(); + + // Open the modal + const createBtn = screen.getByRole('button', { name: /create database/i }); + await userEvent.click(createBtn); + + await waitFor(() => { + expect(axios.get).toHaveBeenCalledWith(expect.stringContaining('/databases/engine-options')); + }); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /mysql/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /postgres/i })).toBeInTheDocument(); + }); +}); + +test('selecting MySQL triggers re-fetch with ?engine=mysql and renders charset/collation fields', async () => { + // Initial fetch: list of engines + axios.get = vi.fn() + .mockResolvedValueOnce({ + data: { engines: ['mysql', 'postgres'], capabilities: null }, + }) + .mockResolvedValueOnce({ + data: { + engines: ['mysql', 'postgres'], + capabilities: { label: 'MySQL', hasUsers: true, optionFields: ['charset', 'collation'] }, + }, + }); + + render(); + + const createBtn = screen.getByRole('button', { name: /create database/i }); + await userEvent.click(createBtn); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /mysql/i })).toBeInTheDocument(); + }); + + // Select mysql engine + const engineSelect = screen.getByRole('combobox', { name: /engine/i }); + await userEvent.selectOptions(engineSelect, 'mysql'); + + await waitFor(() => { + expect(axios.get).toHaveBeenCalledWith(expect.stringContaining('engine=mysql')); + }); + + await waitFor(() => { + expect(screen.getByLabelText(/charset/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/collation/i)).toBeInTheDocument(); + }); +}); + +test('selecting Postgres does not render charset/collation fields', async () => { + axios.get = vi.fn() + .mockResolvedValueOnce({ + data: { engines: ['mysql', 'postgres'], capabilities: null }, + }) + .mockResolvedValueOnce({ + data: { + engines: ['mysql', 'postgres'], + capabilities: { label: 'PostgreSQL', hasUsers: true, optionFields: ['encoding', 'locale'] }, + }, + }); + + render(); + + const createBtn = screen.getByRole('button', { name: /create database/i }); + await userEvent.click(createBtn); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /postgres/i })).toBeInTheDocument(); + }); + + const engineSelect = screen.getByRole('combobox', { name: /engine/i }); + await userEvent.selectOptions(engineSelect, 'postgres'); + + await waitFor(() => { + expect(axios.get).toHaveBeenCalledWith(expect.stringContaining('engine=postgres')); + }); + + await waitFor(() => { + expect(screen.queryByLabelText(/charset/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/collation/i)).not.toBeInTheDocument(); + }); +}); + +test('empty engines array shows empty state message', async () => { + axios.get = vi.fn().mockResolvedValue({ + data: { engines: [], capabilities: null }, + }); + + render(); + + const createBtn = screen.getByRole('button', { name: /create database/i }); + await userEvent.click(createBtn); + + await waitFor(() => { + expect(screen.getByText(/no database engine is currently active/i)).toBeInTheDocument(); + }); +}); diff --git a/resources/js/Pages/Databases/Partials/DbServiceControl.jsx b/resources/js/Pages/Databases/Partials/DbServiceControl.jsx new file mode 100644 index 0000000..ea13207 --- /dev/null +++ b/resources/js/Pages/Databases/Partials/DbServiceControl.jsx @@ -0,0 +1,106 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; +import OperationProgress from '@/Components/OperationProgress'; + +export default function DbServiceControl() { + const [statuses, setStatuses] = useState({}); + const [operationId, setOperationId] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchStatuses = () => { + axios.get(route('databases.service.status')).then((r) => { + setStatuses(r.data.statuses ?? {}); + }); + }; + + useEffect(() => { + fetchStatuses(); + }, []); + + const doAction = async (engine, action) => { + try { + setLoading(true); + setError(null); + const r = await axios.post(route('databases.service.action'), { engine, action }); + setOperationId(r.data.operation_id); + } catch (err) { + setLoading(false); + setError(err.response?.data?.message ?? 'Request failed.'); + } + }; + + const handleDone = () => { + setLoading(false); + fetchStatuses(); + }; + + return ( +
+

+ DB Service Control +

+ + {error && ( +
{error}
+ )} + +
+ + + + + + + + + + + {Object.entries(statuses).map(([engine, info]) => ( + + + + + + + ))} + +
EngineServiceStatusActions
+ {engine} + + {info.service} + + {info.active ? ( + + active + + ) : ( + + inactive + + )} + +
+ {['start', 'stop', 'restart'].map((action) => ( + + ))} +
+
+
+ + {operationId !== null && ( + + )} +
+ ); +} diff --git a/resources/js/Pages/Databases/Partials/EditDatabaseForm.jsx b/resources/js/Pages/Databases/Partials/EditDatabaseForm.jsx new file mode 100644 index 0000000..be4294b --- /dev/null +++ b/resources/js/Pages/Databases/Partials/EditDatabaseForm.jsx @@ -0,0 +1,206 @@ +import Modal from '@/Components/Modal'; +import PrimaryButton from '@/Components/PrimaryButton'; +import SecondaryButton from '@/Components/SecondaryButton'; +import InputLabel from '@/Components/InputLabel'; +import TextInput from '@/Components/TextInput'; +import InputError from '@/Components/InputError'; +import { useForm } from '@inertiajs/react'; +import { useState, useEffect } from 'react'; +import { TbDatabase } from 'react-icons/tb'; +import { FaEdit } from 'react-icons/fa'; +import axios from 'axios'; + +export default function EditDatabaseForm({ database }) { + const [showModal, setShowModal] = useState(false); + const [capabilities, setCapabilities] = useState(null); + const [loading, setLoading] = useState(false); + + const { data, setData, patch, processing, reset, clearErrors, errors } = useForm({ + id: database.id || 0, + charset: database.charset || '', + collation: database.collation || '', + db_password: '', + encoding: database.encoding || '', + locale: database.locale || '', + }); + + useEffect(() => { + if (showModal && database.engine) { + fetchCapabilities(database.engine); + } + }, [showModal]); + + const fetchCapabilities = async (engine) => { + setLoading(true); + try { + const url = route('databases.engine-options') + '?engine=' + encodeURIComponent(engine); + const response = await axios.get(url); + setCapabilities(response.data.capabilities || null); + } catch (error) { + console.error('Error fetching engine capabilities:', error); + } finally { + setLoading(false); + } + }; + + const showEditModal = () => { + setShowModal(true); + setData({ + id: database.id, + charset: database.charset || '', + collation: database.collation || '', + db_password: '', + encoding: database.encoding || '', + locale: database.locale || '', + }); + }; + + const closeModal = () => { + setShowModal(false); + clearErrors(); + reset(); + setCapabilities(null); + }; + + const updateDatabase = (e) => { + e.preventDefault(); + patch(route('databases.update'), { + preserveScroll: true, + onSuccess: closeModal, + }); + }; + + const optionFields = capabilities?.optionFields || []; + + return ( + <> + + + +
+

+ + Edit Database: {database.name} +

+ +
+
+ + +
+ +
+ + +
+ +
+ + setData('db_password', e.target.value)} + className="mt-1 block w-full" + placeholder="Enter new password or leave blank" + /> + +
+ + {optionFields.includes('charset') && ( +
+ + setData('charset', e.target.value)} + className="mt-1 block w-full" + disabled={loading} + /> + +
+ )} + + {optionFields.includes('collation') && ( +
+ + setData('collation', e.target.value)} + className="mt-1 block w-full" + disabled={loading} + /> + +
+ )} + + {optionFields.includes('encoding') && ( +
+ + setData('encoding', e.target.value)} + className="mt-1 block w-full" + disabled={loading} + /> + +
+ )} + + {optionFields.includes('locale') && ( +
+ + setData('locale', e.target.value)} + className="mt-1 block w-full" + disabled={loading} + /> + +
+ )} + +
+ + Update + + Cancel +
+
+
+
+ + ); +} diff --git a/resources/js/Pages/Filemanager/Components/Breadcrumb.jsx b/resources/js/Pages/Filemanager/Components/Breadcrumb.jsx new file mode 100644 index 0000000..3880af6 --- /dev/null +++ b/resources/js/Pages/Filemanager/Components/Breadcrumb.jsx @@ -0,0 +1,46 @@ +/** + * Breadcrumb — D14 + * Props: + * path (string) — current directory path (backend-sandboxed) + * onNavigate (fn) — called with fullPath when a segment button is clicked + */ +const Breadcrumb = ({ path, onNavigate }) => { + // Build segments: always include root, then each non-empty path part + const parts = (path || '/').split('/').filter((s) => s !== ''); + + // [{ label, fullPath }, ...] + const segments = [ + { label: '/', fullPath: '/' }, + ...parts.map((label, i) => ({ + label, + fullPath: '/' + parts.slice(0, i + 1).join('/'), + })), + ]; + + return ( +
+ {segments.map((seg, i) => { + const isLast = i === segments.length - 1; + + return ( + + {i > 0 && /} + {isLast ? ( + {seg.label} + ) : ( + + )} + + ); + })} +
+ ); +}; + +export default Breadcrumb; diff --git a/resources/js/Pages/Filemanager/Components/Breadcrumb.test.jsx b/resources/js/Pages/Filemanager/Components/Breadcrumb.test.jsx new file mode 100644 index 0000000..a6dd65b --- /dev/null +++ b/resources/js/Pages/Filemanager/Components/Breadcrumb.test.jsx @@ -0,0 +1,63 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import Breadcrumb from './Breadcrumb'; + +describe('Breadcrumb', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders 3 clickable buttons and 1 non-button span for /home/alice_ln/domains', () => { + const onNavigate = vi.fn(); + render(); + + // Buttons: /, home, alice_ln (all except last segment) + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(3); + + // Last segment is a span, not a button + const lastSpan = screen.getByText('domains'); + expect(lastSpan.tagName).toBe('SPAN'); + }); + + it('clicking home button calls onNavigate with /home', () => { + const onNavigate = vi.fn(); + render(); + + const homeBtn = screen.getByRole('button', { name: 'home' }); + fireEvent.click(homeBtn); + + expect(onNavigate).toHaveBeenCalledWith('/home'); + }); + + it('clicking root / button calls onNavigate with /', () => { + const onNavigate = vi.fn(); + render(); + + const rootBtn = screen.getByRole('button', { name: '/' }); + fireEvent.click(rootBtn); + + expect(onNavigate).toHaveBeenCalledWith('/'); + }); + + it('last segment (domains) is not a button', () => { + const onNavigate = vi.fn(); + render(); + + const lastSpan = screen.getByText('domains'); + expect(lastSpan.tagName).not.toBe('BUTTON'); + expect(lastSpan.tagName).toBe('SPAN'); + }); + + it('renders root-only path with just the / span (no buttons)', () => { + const onNavigate = vi.fn(); + render(); + + // path='/' — segments are empty after filter, so only root segment + // Root is the only segment → it's the last → rendered as span + const spans = screen.getAllByText('/'); + expect(spans.length).toBeGreaterThan(0); + const buttons = screen.queryAllByRole('button'); + expect(buttons).toHaveLength(0); + }); +}); diff --git a/resources/js/Pages/Filemanager/Filemanager.jsx b/resources/js/Pages/Filemanager/Filemanager.jsx index ce3f5e3..bf31e53 100644 --- a/resources/js/Pages/Filemanager/Filemanager.jsx +++ b/resources/js/Pages/Filemanager/Filemanager.jsx @@ -7,6 +7,7 @@ import { RiFolderReceivedLine } from "react-icons/ri"; import { FileIcon, defaultStyles } from 'react-file-icon'; import { ToastContainer, toast } from 'react-toastify'; import Checkbox from '@/Components/Checkbox'; +import Breadcrumb from './Components/Breadcrumb'; import CreateFile from './Components/CreateFile'; import EditFile from './Components/EditFile'; import DeleteFiles from './Components/DeleteFiles'; @@ -40,6 +41,7 @@ const Filemanager = () => { const [showUploadFile, setShowUploadFile] = useState(false); const [copyFiles, setCopyFiles] = useState(false); const [cutFiles, setCutFiles] = useState(false); + const [hintDismissed, setHintDismissed] = useState(() => localStorage.getItem('laranode_fm_hint_dismissed') === 'true'); useEffect(() => { @@ -276,19 +278,31 @@ const Filemanager = () => { - {goBack && goBack != "" && (
-
-
-
- Path: {path} -
-
)} +
+ {goBack && goBack != "" && ( +
+ +
+ )} + +
+ {files .filter(file => !file.path.includes('laranode-scripts')) diff --git a/resources/js/Pages/Filemanager/Filemanager.test.jsx b/resources/js/Pages/Filemanager/Filemanager.test.jsx new file mode 100644 index 0000000..bd998c1 --- /dev/null +++ b/resources/js/Pages/Filemanager/Filemanager.test.jsx @@ -0,0 +1,201 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import Filemanager from './Filemanager'; + +// Mock AuthenticatedLayout — render children directly +vi.mock('@/Layouts/AuthenticatedLayout', () => ({ + default: ({ children }) =>
{children}
, +})); + +// Mock react-toastify +vi.mock('react-toastify', () => ({ + toast: vi.fn(), + ToastContainer: () => null, +})); + +// Mock react-file-icon +vi.mock('react-file-icon', () => ({ + FileIcon: () => FileIcon, + defaultStyles: {}, +})); + +// Mock Filemanager child components (modals) +vi.mock('./Components/CreateFile', () => ({ default: () => null })); +vi.mock('./Components/EditFile', () => ({ default: () => null })); +vi.mock('./Components/DeleteFiles', () => ({ default: () => null })); +vi.mock('./Components/RenameFile', () => ({ default: () => null })); +vi.mock('./Components/UploadFile', () => ({ default: () => null })); + +// Mock Breadcrumb so we can control it independently +vi.mock('./Components/Breadcrumb', () => ({ + default: ({ path, onNavigate }) => ( +
Breadcrumb
+ ), +})); + +// Stub global fetch to return empty files at root (goBack=false → no breadcrumb) +global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + body: { + getReader: () => { + let done = false; + return { + read: () => { + if (done) return Promise.resolve({ value: undefined, done: true }); + done = true; + const json = JSON.stringify({ files: [], goBack: false }); + return Promise.resolve({ + value: new TextEncoder().encode(json), + done: false, + }); + }, + }; + }, + }, + }) +); + +// Stub window.axios used by pasteFiles +global.window = global.window || {}; +global.axios = { patch: vi.fn() }; + +describe('Filemanager — D10 hint banner', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + it('shows hint text on first render', async () => { + render(); + + // Wait for spinner to clear (fetch resolves) + await waitFor(() => + expect(screen.queryByText('Loading files list...')).not.toBeInTheDocument() + ); + + expect( + screen.getByText(/Double-click a folder to enter it/i) + ).toBeInTheDocument(); + }); + + it('dismiss button removes hint from DOM', async () => { + render(); + + await waitFor(() => + expect(screen.queryByText('Loading files list...')).not.toBeInTheDocument() + ); + + const dismissBtn = screen.getByRole('button', { name: /dismiss hint/i }); + fireEvent.click(dismissBtn); + + expect( + screen.queryByText(/Double-click a folder to enter it/i) + ).not.toBeInTheDocument(); + }); + + it('dismiss sets localStorage key', async () => { + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); + + render(); + + await waitFor(() => + expect(screen.queryByText('Loading files list...')).not.toBeInTheDocument() + ); + + const dismissBtn = screen.getByRole('button', { name: /dismiss hint/i }); + fireEvent.click(dismissBtn); + + expect(setItemSpy).toHaveBeenCalledWith('laranode_fm_hint_dismissed', 'true'); + }); + + it('does not show hint when localStorage already set to true', async () => { + localStorage.setItem('laranode_fm_hint_dismissed', 'true'); + + render(); + + await waitFor(() => + expect(screen.queryByText('Loading files list...')).not.toBeInTheDocument() + ); + + expect( + screen.queryByText(/Double-click a folder to enter it/i) + ).not.toBeInTheDocument(); + }); +}); + +describe('Filemanager — current path + back navigation', () => { + // fetch stub whose payload reports the given goBack value + const fetchReturning = (goBack) => + vi.fn(() => + Promise.resolve({ + ok: true, + body: { + getReader: () => { + let done = false; + return { + read: () => { + if (done) return Promise.resolve({ value: undefined, done: true }); + done = true; + const json = JSON.stringify({ files: [], goBack }); + return Promise.resolve({ + value: new TextEncoder().encode(json), + done: false, + }); + }, + }; + }, + }, + }) + ); + + beforeEach(() => { + // hide the hint banner so it doesn't interfere + localStorage.setItem('laranode_fm_hint_dismissed', 'true'); + vi.clearAllMocks(); + }); + + it('always renders the breadcrumb (current path) even at the root with no goBack', async () => { + global.fetch = fetchReturning(false); + render(); + await waitFor(() => + expect(screen.queryByText('Loading files list...')).not.toBeInTheDocument() + ); + + const crumb = screen.getByTestId('breadcrumb'); + expect(crumb).toBeInTheDocument(); + expect(crumb).toHaveAttribute('data-path', '/'); + }); + + it('hides the Back button at the root (no goBack)', async () => { + global.fetch = fetchReturning(false); + render(); + await waitFor(() => + expect(screen.queryByText('Loading files list...')).not.toBeInTheDocument() + ); + + expect(screen.queryByText('Back')).not.toBeInTheDocument(); + }); + + it('shows Back inside a folder and navigates up on a SINGLE click', async () => { + const fetchMock = fetchReturning('/parent'); + global.fetch = fetchMock; + render(); + await waitFor(() => + expect(screen.queryByText('Loading files list...')).not.toBeInTheDocument() + ); + + const backBtn = screen.getByText('Back'); + expect(backBtn).toBeInTheDocument(); + + // one click must fetch the parent directory (regression guard: was onDoubleClick) + fireEvent.click(backBtn); + await waitFor(() => + expect( + fetchMock.mock.calls.some( + ([url]) => typeof url === 'string' && url.includes('path=/parent') + ) + ).toBe(true) + ); + }); +}); diff --git a/resources/js/Pages/Firewall/Index.jsx b/resources/js/Pages/Firewall/Index.jsx index b3a4946..5f07dab 100644 --- a/resources/js/Pages/Firewall/Index.jsx +++ b/resources/js/Pages/Firewall/Index.jsx @@ -10,21 +10,39 @@ import { FaToggleOn, FaToggleOff, FaCheck, FaTimes } from 'react-icons/fa'; import { TiDelete } from 'react-icons/ti'; import { FaArrowLeft, FaArrowRight } from 'react-icons/fa6'; -export default function FirewallIndex({ status, rules }) { +export default function FirewallIndex({ status, rules, safety }) { const { auth } = usePage().props; const [newRule, setNewRule] = useState(''); const [ruleType, setRuleType] = useState('allow'); const isEnabled = (status || '').toLowerCase().includes('active') && !(status || '').toLowerCase().includes('inactive'); + // Lockout protection: only safe to enable once SSH and the panel/web ports are allowed. + const missing = safety?.missing ?? []; + const canEnableSafely = (safety?.coversSsh ?? true) && (safety?.coversWeb ?? true); + const detectedIp = safety?.detectedIp; + const toggleFirewall = () => { + // Block the client-side path too — the backend refuses regardless. + if (!isEnabled && !canEnableSafely) { + toast('Add an SSH and a panel/website allow rule first, or use Safe Setup — enabling now would lock you out.', { type: 'error' }); + return; + } router.post(route('firewall.toggle'), { enabled: !isEnabled }, { onBefore: () => toast(`${!isEnabled ? 'Enabling' : 'Disabling'} firewall...`), - onSuccess: () => router.reload({ only: ['status'] }), + onSuccess: () => router.reload({ only: ['status', 'safety'] }), onError: () => toast('Failed to toggle firewall') }); }; + const safeSetup = (sshFromIp = null) => { + router.post(route('firewall.safe-setup'), sshFromIp ? { ssh_from_ip: sshFromIp } : {}, { + onBefore: () => toast('Setting up a safe baseline and enabling firewall...'), + onSuccess: () => router.reload(), + onError: () => toast('Safe Setup failed', { type: 'error' }) + }); + }; + // rule creation moved to modal form component const deleteRule = (idOrSpec) => { @@ -59,6 +77,38 @@ export default function FirewallIndex({ status, rules }) { >
+ + {!isEnabled && !canEnableSafely && ( +
+
+ + Enabling the firewall now would lock you out +
+

+ The firewall defaults to blocking everything. You have no allow rule for{' '} + {missing.length ? missing.join('; ') : 'SSH and the panel/websites'}. Add the + missing rule(s) yourself, or use Safe Setup to stage them and enable safely: +

+
+ + {detectedIp && ( + + )} +
+
+ )} +
diff --git a/resources/js/Pages/Firewall/Index.test.jsx b/resources/js/Pages/Firewall/Index.test.jsx new file mode 100644 index 0000000..2fbf1d9 --- /dev/null +++ b/resources/js/Pages/Firewall/Index.test.jsx @@ -0,0 +1,100 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +const post = vi.fn(); +const reload = vi.fn(); + +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ props: { auth: { user: { id: 1 } }, flash: {} } }), + router: { post: (...a) => post(...a), reload: (...a) => reload(...a) }, + Head: ({ title }) => {title}, +})); + +vi.mock('@/Layouts/AuthenticatedLayout', () => ({ + default: ({ header, children }) =>
{header}{children}
, +})); + +// CreateFirewallRuleForm is a complex modal — out of scope here +vi.mock('./Partials/CreateFirewallRuleForm', () => ({ default: () => null })); + +// ConfirmationButton: render a button that fires doAction immediately +vi.mock('@/Components/ConfirmationButton', () => ({ + default: ({ doAction, children }) => ( + + ), +})); + +vi.mock('react-toastify', () => ({ toast: vi.fn() })); + +// route() returns the name so assertions can match on it +global.route = (name) => name; + +import FirewallIndex from './Index'; + +const unsafe = { + panelPort: 80, + coversSsh: false, + coversWeb: false, + missing: ['SSH (port 22) — you would lose remote access to the server'], + detectedIp: '203.0.113.9', +}; + +const safe = { panelPort: 80, coversSsh: true, coversWeb: true, missing: [], detectedIp: '203.0.113.9' }; + +describe('Firewall/Index lockout protection', () => { + beforeEach(() => { + post.mockClear(); + reload.mockClear(); + }); + + it('shows the lockout warning + Safe Setup when disabled and unprotected', () => { + render(); + + expect(screen.getByText(/would lock you out/i)).toBeInTheDocument(); + expect(screen.getByText(/Safe Setup \(allow SSH, HTTP, HTTPS\)/i)).toBeInTheDocument(); + }); + + it('Safe Setup posts to the safe-setup route', () => { + render(); + + fireEvent.click(screen.getByText(/Safe Setup \(allow SSH, HTTP, HTTPS\)/i)); + + expect(post).toHaveBeenCalledTimes(1); + expect(post.mock.calls[0][0]).toBe('firewall.safe-setup'); + expect(post.mock.calls[0][1]).toEqual({}); // SSH from anywhere + }); + + it('offers an IP-restricted Safe Setup that sends ssh_from_ip', () => { + render(); + + fireEvent.click(screen.getByText(/restrict SSH to my IP/i)); + + expect(post.mock.calls[0][0]).toBe('firewall.safe-setup'); + expect(post.mock.calls[0][1]).toEqual({ ssh_from_ip: '203.0.113.9' }); + }); + + it('does NOT post toggle when enabling would lock the user out', () => { + render(); + + fireEvent.click(screen.getByTestId('confirm-toggle')); + + // blocked client-side; the only allowed post is Safe Setup, not toggle + expect(post).not.toHaveBeenCalledWith('firewall.toggle', expect.anything(), expect.anything()); + }); + + it('hides the warning when SSH and web are already covered', () => { + render(); + + expect(screen.queryByText(/would lock you out/i)).not.toBeInTheDocument(); + }); + + it('allows enabling (posts toggle) once protections are in place', () => { + render(); + + fireEvent.click(screen.getByTestId('confirm-toggle')); + + expect(post).toHaveBeenCalledTimes(1); + expect(post.mock.calls[0][0]).toBe('firewall.toggle'); + expect(post.mock.calls[0][1]).toEqual({ enabled: true }); + }); +}); diff --git a/resources/js/Pages/Mysql/Index.jsx b/resources/js/Pages/Mysql/Index.jsx deleted file mode 100644 index 2b9ff07..0000000 --- a/resources/js/Pages/Mysql/Index.jsx +++ /dev/null @@ -1,78 +0,0 @@ -import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; -import { Head, router, usePage } from '@inertiajs/react'; -import { TbDatabase } from 'react-icons/tb'; -import { TiDelete } from 'react-icons/ti'; -import { toast } from 'react-toastify'; -import CreateDatabaseForm from './Partials/CreateDatabaseForm'; -import EditDatabaseForm from './Partials/EditDatabaseForm'; -import ConfirmationButton from '@/Components/ConfirmationButton'; -import { Tooltip } from 'react-tooltip'; - -export default function MysqlIndex({ databases = [] }) { - - const { auth } = usePage().props; - - const deleteDb = (id) => { - router.delete(route('mysql.destroy'), { - data: { id }, - onBefore: () => toast('Deleting database...'), - onError: () => toast('Failed to delete database.'), - }); - }; - - return ( - -

- - MySQL Databases ({databases.length}/{auth.user.database_limit || 'unlimited'}) -

- - - } - > - - -
-
-
- - - - - - - - - - - - - {databases.map((db, index) => ( - - - - - - - - - - ))} - -
DatabaseUserTablesSize (MB)CharsetCollationActions
{db.name}{db.db_user}{db.tables}{db.sizeMb}{db.charset || '-'}{db.collation || '-'} -
- - deleteDb(db.id)}> - - -
-
-
-
- - ); -} - - diff --git a/resources/js/Pages/Mysql/Partials/CreateDatabaseForm.jsx b/resources/js/Pages/Mysql/Partials/CreateDatabaseForm.jsx deleted file mode 100644 index 25c6ddd..0000000 --- a/resources/js/Pages/Mysql/Partials/CreateDatabaseForm.jsx +++ /dev/null @@ -1,193 +0,0 @@ -import Modal from '@/Components/Modal'; -import PrimaryButton from '@/Components/PrimaryButton'; -import SecondaryButton from '@/Components/SecondaryButton'; -import InputLabel from '@/Components/InputLabel'; -import TextInput from '@/Components/TextInput'; -import InputError from '@/Components/InputError'; -import SearchableDropdown from '@/Components/SearchableDropdown'; -import { useForm, usePage } from '@inertiajs/react'; -import { useState, useEffect } from 'react'; -import { TbDatabase } from 'react-icons/tb'; -import axios from 'axios'; - -export default function CreateDatabaseForm() { - const { auth } = usePage().props; - const [showModal, setShowModal] = useState(false); - const [charsets, setCharsets] = useState([]); - const [collations, setCollations] = useState([]); - const [filteredCollations, setFilteredCollations] = useState([]); - const [loading, setLoading] = useState(false); - - const { data, setData, post, processing, reset, clearErrors, errors, transform } = useForm({ - name_suffix: '', - db_user_suffix: '', - db_pass: '', - charset: 'utf8mb4', - collation: 'utf8mb4_unicode_ci', - }); - - useEffect(() => { - if (showModal) { - fetchCharsetsAndCollations(); - } - }, [showModal]); - - useEffect(() => { - // Filter collations based on selected charset - if (data.charset && collations.length > 0) { - const filtered = collations.filter(collation => collation.charset === data.charset); - setFilteredCollations(filtered); - - // If current collation is not valid for the selected charset, set to default - if (data.collation && !filtered.find(c => c.name === data.collation)) { - // Find the default collation for this charset - const defaultCollation = filtered.find(c => c.default === 'Yes') || filtered[0]; - if (defaultCollation) { - setData('collation', defaultCollation.name); - } - } - } else { - setFilteredCollations(collations); - } - }, [data.charset, collations]); - - const fetchCharsetsAndCollations = async () => { - setLoading(true); - try { - const response = await axios.get(route('mysql.charsets-collations')); - setCharsets(response.data.charsets); - setCollations(response.data.collations); - - // Set default collation for utf8mb4 if not already set - if (data.charset === 'utf8mb4' && !data.collation) { - const utf8mb4Collations = response.data.collations.filter(c => c.charset === 'utf8mb4'); - const defaultCollation = utf8mb4Collations.find(c => c.default === 'Yes') || utf8mb4Collations.find(c => c.name === 'utf8mb4_unicode_ci') || utf8mb4Collations[0]; - if (defaultCollation) { - setData('collation', defaultCollation.name); - } - } - - setFilteredCollations(response.data.collations); - } catch (error) { - console.error('Error fetching charsets and collations:', error); - } finally { - setLoading(false); - } - }; - - const showCreateModal = () => setShowModal(true); - - const closeModal = () => { - setShowModal(false); - clearErrors(); - reset(); - }; - - const createDatabase = (e) => { - e.preventDefault(); - const prefix = auth.user.username + '_'; - const name = data.name_suffix ? `${prefix}${data.name_suffix}` : ''; - const db_user = data.db_user_suffix ? `${prefix}${data.db_user_suffix}` : ''; - - transform((form) => ({ - ...form, - name, - db_user, - })); - - post(route('mysql.store'), { - preserveScroll: true, - onSuccess: closeModal, - onFinish: () => transform((form) => form), // reset transform - }); - }; - - const prefix = auth.user.username + '_'; - - return ( - <> - - - -
-

- - Add a New Database -

- -
-
- -
- - {prefix} - -
- setData('name_suffix', e.target.value)} className="flex-1 rounded-l-none w-full" placeholder={'mydb'} required /> -
-
- -
-
- -
- - {prefix} - - -
- setData('db_user_suffix', e.target.value)} className="flex-1 rounded-l-none w-full" placeholder={'user'} required /> -
-
- -
-
- - setData('db_pass', e.target.value)} className="mt-1 block w-full" required /> - -
-
- - - -
-
- - setData('collation', collation.name)} - placeholder="Select a collation..." - className="mt-1" - disabled={loading || filteredCollations.length === 0} - /> - -
-
- Add Database - Cancel -
-
-
-
- - ); -} - - diff --git a/resources/js/Pages/Mysql/Partials/EditDatabaseForm.jsx b/resources/js/Pages/Mysql/Partials/EditDatabaseForm.jsx deleted file mode 100644 index 3b06b72..0000000 --- a/resources/js/Pages/Mysql/Partials/EditDatabaseForm.jsx +++ /dev/null @@ -1,185 +0,0 @@ -import Modal from '@/Components/Modal'; -import PrimaryButton from '@/Components/PrimaryButton'; -import SecondaryButton from '@/Components/SecondaryButton'; -import InputLabel from '@/Components/InputLabel'; -import TextInput from '@/Components/TextInput'; -import InputError from '@/Components/InputError'; -import SearchableDropdown from '@/Components/SearchableDropdown'; -import { useForm, usePage } from '@inertiajs/react'; -import { useState, useEffect } from 'react'; -import { TbDatabase } from 'react-icons/tb'; -import { FaEdit } from 'react-icons/fa'; -import axios from 'axios'; - -export default function EditDatabaseForm({ database }) { - const { auth } = usePage().props; - const [showModal, setShowModal] = useState(false); - const [charsets, setCharsets] = useState([]); - const [collations, setCollations] = useState([]); - const [filteredCollations, setFilteredCollations] = useState([]); - const [loading, setLoading] = useState(false); - - const { data, setData, patch, processing, reset, clearErrors, errors } = useForm({ - id: database.id || 0, - charset: database.charset || 'utf8mb4', - collation: database.collation || 'utf8mb4_unicode_ci', - db_password: '', - }); - - useEffect(() => { - if (showModal) { - fetchCharsetsAndCollations(); - } - }, [showModal]); - - useEffect(() => { - // Filter collations based on selected charset - if (data.charset && collations.length > 0) { - const filtered = collations.filter(collation => collation.charset === data.charset); - setFilteredCollations(filtered); - - // If current collation is not valid for the selected charset, set to default - if (data.collation && !filtered.find(c => c.name === data.collation)) { - // Find the default collation for this charset - const defaultCollation = filtered.find(c => c.default === 'Yes') || filtered[0]; - if (defaultCollation) { - setData('collation', defaultCollation.name); - } - } - } else { - setFilteredCollations(collations); - } - }, [data.charset, collations]); - - const fetchCharsetsAndCollations = async () => { - setLoading(true); - try { - const response = await axios.get(route('mysql.charsets-collations')); - setCharsets(response.data.charsets); - setCollations(response.data.collations); - - // Set default collation for current charset if not already set - if (data.charset && !data.collation) { - const charsetCollations = response.data.collations.filter(c => c.charset === data.charset); - const defaultCollation = charsetCollations.find(c => c.default === 'Yes') || charsetCollations[0]; - if (defaultCollation) { - setData('collation', defaultCollation.name); - } - } - - setFilteredCollations(response.data.collations); - } catch (error) { - console.error('Error fetching charsets and collations:', error); - } finally { - setLoading(false); - } - }; - - const showEditModal = () => { - setShowModal(true); - // Reset form with current database values - setData({ - id: database.id, - name: database.name, - charset: database.charset || 'utf8mb4', - collation: database.collation || 'utf8mb4_unicode_ci', - }); - }; - - const closeModal = () => { - setShowModal(false); - clearErrors(); - reset(); - }; - - const updateDatabase = (e) => { - e.preventDefault(); - patch(route('mysql.update'), { - preserveScroll: true, - onSuccess: closeModal, - }); - }; - - return ( - <> - - - -
-

- - Edit Database: {database.name} -

- -
-
- - -
-
- - setData('db_password', e.target.value)} - className="mt-1 block w-full" - placeholder="Enter new password or leave blank" - /> - -
-
- - - -
-
- - setData('collation', collation.name)} - placeholder="Select a collation..." - className="mt-1" - disabled={loading || filteredCollations.length === 0} - /> - -
-
- Update - Cancel -
-
-
-
- - ); -} diff --git a/resources/js/Pages/Notifications/Index.jsx b/resources/js/Pages/Notifications/Index.jsx new file mode 100644 index 0000000..926ae70 --- /dev/null +++ b/resources/js/Pages/Notifications/Index.jsx @@ -0,0 +1,96 @@ +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { Head, router } from '@inertiajs/react'; +import { useState } from 'react'; +import useNotifications from '@/hooks/useNotifications'; + +export default function Index({ notifications }) { + const { refresh } = useNotifications(); + const [markingAll, setMarkingAll] = useState(false); + + const handleMarkAllRead = async () => { + setMarkingAll(true); + try { + await fetch('/notifications/read-all', { + method: 'PATCH', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '', + 'Content-Type': 'application/json', + }, + }); + refresh(); + router.reload({ only: ['notifications'] }); + } finally { + setMarkingAll(false); + } + }; + + const handleMarkRead = async (id) => { + await fetch(`/notifications/${id}/read`, { + method: 'PATCH', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '', + 'Content-Type': 'application/json', + }, + }); + router.reload({ only: ['notifications'] }); + }; + + const unreadCount = notifications.filter((n) => n.read_at === null).length; + + return ( + + +
+
+

Notifications

+ {unreadCount > 0 && ( + + )} +
+ + {notifications.length === 0 ? ( +

No notifications yet.

+ ) : ( +
    + {notifications.map((notification) => ( +
  • +
    +
    +

    + {notification.data?.title ?? notification.type} +

    + {notification.data?.message && ( +

    + {notification.data.message} +

    + )} +

    + {notification.created_at} +

    +
    + {!notification.read_at && ( + + )} +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/resources/js/Pages/Notifications/Index.test.jsx b/resources/js/Pages/Notifications/Index.test.jsx new file mode 100644 index 0000000..10c2359 --- /dev/null +++ b/resources/js/Pages/Notifications/Index.test.jsx @@ -0,0 +1,136 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { test, expect, vi, beforeEach } from 'vitest'; +import Index from './Index'; + +// Hoist mocks so variables are available inside vi.mock factories +const { mockReload, mockRefresh } = vi.hoisted(() => ({ + mockReload: vi.fn(), + mockRefresh: vi.fn(), +})); + +// Mock @inertiajs/react +vi.mock('@inertiajs/react', () => ({ + Head: ({ title }) => {title}, + router: { reload: mockReload }, + usePage: () => ({ + props: { + auth: { user: { id: 1 } }, + notifications: { unreadCount: 0 }, + }, + }), + Link: ({ href, children }) => {children}, +})); + +// Mock AuthenticatedLayout — render children directly +vi.mock('@/Layouts/AuthenticatedLayout', () => ({ + default: ({ children }) =>
{children}
, +})); + +// Mock useNotifications hook +vi.mock('@/hooks/useNotifications', () => ({ + default: () => ({ unreadCount: 0, refresh: mockRefresh }), +})); + +beforeEach(() => { + vi.clearAllMocks(); + // Mock fetch globally + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + // Provide csrf token meta tag + document.head.innerHTML = ''; +}); + +const sampleNotifications = [ + { + id: 'uuid-1', + type: 'App\\Notifications\\OperationFinishedNotification', + data: { title: 'Operation finished', message: 'SSL was installed.' }, + read_at: null, + created_at: '2026-06-26 10:00:00', + }, + { + id: 'uuid-2', + type: 'App\\Notifications\\SslExpiringNotification', + data: { title: 'SSL expiring soon', message: 'Your cert expires in 7 days.' }, + read_at: '2026-06-26 11:00:00', + created_at: '2026-06-25 10:00:00', + }, +]; + +test('renders notification list with titles and timestamps', () => { + render(); + expect(screen.getByText('Operation finished')).toBeInTheDocument(); + expect(screen.getByText('SSL was installed.')).toBeInTheDocument(); + expect(screen.getByText('SSL expiring soon')).toBeInTheDocument(); + expect(screen.getByText('2026-06-26 10:00:00')).toBeInTheDocument(); +}); + +test('shows empty state when notification list is empty', () => { + render(); + expect(screen.getByText(/no notifications yet/i)).toBeInTheDocument(); +}); + +test('shows "Mark all as read" button when unread notifications exist', () => { + render(); + expect(screen.getByRole('button', { name: /mark all as read/i })).toBeInTheDocument(); +}); + +test('does not show "Mark all as read" button when all notifications are read', () => { + const allRead = sampleNotifications.map((n) => ({ ...n, read_at: '2026-06-26 12:00:00' })); + render(); + expect(screen.queryByRole('button', { name: /mark all as read/i })).toBeNull(); +}); + +test('clicking "Mark all as read" calls fetch PATCH /notifications/read-all and refresh()', async () => { + render(); + + const btn = screen.getByRole('button', { name: /mark all as read/i }); + await userEvent.click(btn); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + '/notifications/read-all', + expect.objectContaining({ method: 'PATCH' }), + ); + }); + + await waitFor(() => { + expect(mockRefresh).toHaveBeenCalled(); + }); +}); + +test('clicking "Mark all as read" calls router.reload with only notifications', async () => { + render(); + + const btn = screen.getByRole('button', { name: /mark all as read/i }); + await userEvent.click(btn); + + await waitFor(() => { + expect(mockReload).toHaveBeenCalledWith({ only: ['notifications'] }); + }); +}); + +test('shows individual "Mark read" button only for unread notifications', () => { + render(); + // sampleNotifications has 1 unread (uuid-1) and 1 read (uuid-2) + const markReadButtons = screen.getAllByRole('button', { name: /mark read/i }); + expect(markReadButtons).toHaveLength(1); +}); + +test('clicking individual "Mark read" calls fetch PATCH /notifications/{id}/read', async () => { + render(); + + const markReadBtn = screen.getByRole('button', { name: /^mark read$/i }); + await userEvent.click(markReadBtn); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + '/notifications/uuid-1/read', + expect.objectContaining({ method: 'PATCH' }), + ); + }); + + await waitFor(() => { + expect(mockReload).toHaveBeenCalledWith({ only: ['notifications'] }); + }); +}); diff --git a/resources/js/Pages/Operations/Index.jsx b/resources/js/Pages/Operations/Index.jsx new file mode 100644 index 0000000..ace7929 --- /dev/null +++ b/resources/js/Pages/Operations/Index.jsx @@ -0,0 +1,53 @@ +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { Head, Link } from '@inertiajs/react'; +import { useState } from 'react'; + +const badge = { + queued: 'bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200', + running: 'bg-blue-200 text-blue-800 dark:bg-blue-800 dark:text-blue-200', + succeeded: 'bg-green-200 text-green-800 dark:bg-green-800 dark:text-green-200', + failed: 'bg-red-200 text-red-800 dark:bg-red-800 dark:text-red-200', +}; + +export default function Index({ operations }) { + const [open, setOpen] = useState(null); + return ( + + +
+

Operations

+ + + + + + + + {operations.data.map((op) => ( + setOpen(open === op.id ? null : op.id)}> + + + + + + + ))} + +
WhenActorTypeTargetStatus
{op.created_at}{op.user?.username ?? '—'}{op.type}{op.target ?? '—'}{op.status} + {open === op.id && ( +
{op.output ?? '(no output)'}
+ )} +
+
+ {operations.prev_page_url + ? Previous + : Previous} + Page {operations.current_page} of {operations.last_page} + {operations.next_page_url + ? Next + : Next} +
+
+
+ ); +} diff --git a/resources/js/Pages/Operations/Index.test.jsx b/resources/js/Pages/Operations/Index.test.jsx new file mode 100644 index 0000000..f2fa1bc --- /dev/null +++ b/resources/js/Pages/Operations/Index.test.jsx @@ -0,0 +1,114 @@ +import { render, screen } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import Index from './Index'; + +// Mock @inertiajs/react +vi.mock('@inertiajs/react', () => ({ + Head: ({ title }) => {title}, + Link: ({ href, children, className, ...rest }) => ( + + {children} + + ), +})); + +// Mock AuthenticatedLayout — render children directly +vi.mock('@/Layouts/AuthenticatedLayout', () => ({ + default: ({ children }) =>
{children}
, +})); + +global.route = (name, params) => `/${name.replace(/\./g, '/')}/${params ? Object.values(params).join('/') : ''}`; + +const sampleOperations = { + data: [ + { + id: 1, + created_at: '2026-06-27 10:00:00', + user: { username: 'admin' }, + type: 'ssl.enable', + target: 'example.com', + status: 'succeeded', + output: 'ok', + }, + ], + prev_page_url: null, + next_page_url: '/operations?page=2', + current_page: 1, + last_page: 2, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('Operations Index', () => { + it('renders table headers', () => { + render(); + expect(screen.getByText('When')).toBeInTheDocument(); + expect(screen.getByText('Actor')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Target')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + }); + + it('renders the operation row with succeeded badge', () => { + render(); + expect(screen.getByText('example.com')).toBeInTheDocument(); + expect(screen.getByText('succeeded')).toBeInTheDocument(); + }); + + it('renders Previous as disabled span when prev_page_url is null', () => { + render(); + const prev = screen.getByText('Previous'); + // Should be a span (no href) when disabled + expect(prev.tagName).toBe('SPAN'); + expect(prev).toHaveClass('opacity-50'); + }); + + it('renders Next as a Link (anchor) with href when next_page_url is present', () => { + render(); + const next = screen.getByText('Next'); + expect(next.tagName).toBe('A'); + expect(next).toHaveAttribute('href', '/operations?page=2'); + }); + + it('thead tr has dark mode text and border classes', () => { + render(); + const thead = document.querySelector('thead tr'); + expect(thead).toBeTruthy(); + expect(thead.className).toContain('dark:text-gray-300'); + expect(thead.className).toContain('dark:border-gray-700'); + }); + + it('page container has max-w-7xl class', () => { + render(); + // The container div should have max-w-7xl — query by class directly + const container = document.querySelector('.max-w-7xl'); + expect(container).toBeTruthy(); + expect(container.className).toContain('max-w-7xl'); + }); + + it('succeeded badge has dark mode classes', () => { + render(); + const badge = screen.getByText('succeeded'); + expect(badge.className).toContain('dark:bg-green-800'); + expect(badge.className).toContain('dark:text-green-200'); + }); + + it('renders operation with failed status with correct dark badge', () => { + const failedOps = { + ...sampleOperations, + data: [ + { + ...sampleOperations.data[0], + id: 2, + status: 'failed', + }, + ], + }; + render(); + const badge = screen.getByText('failed'); + expect(badge.className).toContain('dark:bg-red-800'); + expect(badge.className).toContain('dark:text-red-200'); + }); +}); diff --git a/resources/js/Pages/PHP/Index.jsx b/resources/js/Pages/PHP/Index.jsx index ab5b3ac..109555a 100644 --- a/resources/js/Pages/PHP/Index.jsx +++ b/resources/js/Pages/PHP/Index.jsx @@ -107,7 +107,7 @@ export default function PHPIndex() { PHP Versions - + } > diff --git a/resources/js/Pages/PHP/Partials/InstallPHPForm.jsx b/resources/js/Pages/PHP/Partials/InstallPHPForm.jsx index 24eeb10..b2bf2d3 100644 --- a/resources/js/Pages/PHP/Partials/InstallPHPForm.jsx +++ b/resources/js/Pages/PHP/Partials/InstallPHPForm.jsx @@ -6,7 +6,7 @@ import { router } from '@inertiajs/react'; import { toast } from 'react-toastify'; import { TbBrandPhp } from 'react-icons/tb'; -export default function InstallPHPForm() { +export default function InstallPHPForm({ installedVersions = [] }) { const [showModal, setShowModal] = useState(false); const [version, setVersion] = useState(''); const [isInstalling, setIsInstalling] = useState(false); @@ -69,11 +69,14 @@ export default function InstallPHPForm() { className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-white sm:text-sm" > - {availableVersions.map((v) => ( - - ))} + {availableVersions.map((v) => { + const isInstalled = installedVersions.some((p) => p.version === v); + return ( + + ); + })} diff --git a/resources/js/Pages/PHP/Partials/InstallPHPForm.test.jsx b/resources/js/Pages/PHP/Partials/InstallPHPForm.test.jsx new file mode 100644 index 0000000..9ef0025 --- /dev/null +++ b/resources/js/Pages/PHP/Partials/InstallPHPForm.test.jsx @@ -0,0 +1,53 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import InstallPHPForm from './InstallPHPForm'; + +// Mock toast +vi.mock('react-toastify', () => ({ toast: { error: vi.fn() } })); + +// Mock Inertia router +vi.mock('@inertiajs/react', () => ({ router: { post: vi.fn() } })); + +// Mock window.route +beforeEach(() => { + window.route = vi.fn(() => '/php/install'); +}); + +describe('InstallPHPForm', () => { + it('disables the installed version option and shows (installed) label', () => { + const installedVersions = [{ version: '8.4', status: 'active', enabled: true }]; + render(); + + // Open modal + fireEvent.click(screen.getByText('Install New Version')); + + const opt84 = screen.getByRole('option', { name: 'PHP 8.4 (installed)' }); + expect(opt84).toBeDisabled(); + expect(opt84).toHaveAttribute('value', '8.4'); + + // 8.3 should not be disabled + const opt83 = screen.getByRole('option', { name: 'PHP 8.3' }); + expect(opt83).not.toBeDisabled(); + }); + + it('all options enabled when installedVersions is empty', () => { + render(); + + fireEvent.click(screen.getByText('Install New Version')); + + const opts = screen.getAllByRole('option'); + // first option is the placeholder "Select a version" + const versionOpts = opts.filter((o) => o.value !== ''); + versionOpts.forEach((o) => expect(o).not.toBeDisabled()); + }); + + it('all options enabled when installedVersions defaults (no prop)', () => { + render(); + + fireEvent.click(screen.getByText('Install New Version')); + + const opts = screen.getAllByRole('option'); + const versionOpts = opts.filter((o) => o.value !== ''); + versionOpts.forEach((o) => expect(o).not.toBeDisabled()); + }); +}); diff --git a/resources/js/Pages/Profile/Edit.jsx b/resources/js/Pages/Profile/Edit.jsx index 2510938..edf1a67 100644 --- a/resources/js/Pages/Profile/Edit.jsx +++ b/resources/js/Pages/Profile/Edit.jsx @@ -1,5 +1,5 @@ import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; -import { Head } from '@inertiajs/react'; +import { Head, Link } from '@inertiajs/react'; import DeleteUserForm from './Partials/DeleteUserForm'; import UpdatePasswordForm from './Partials/UpdatePasswordForm'; import UpdateProfileInformationForm from './Partials/UpdateProfileInformationForm'; @@ -36,6 +36,21 @@ export default function Edit({ mustVerifyEmail, status }) {
+ +
+

+ Notification Preferences +

+

+ Manage which notifications you receive and how they are delivered. +

+ + Manage Notifications + +
diff --git a/resources/js/Pages/Profile/Notifications.jsx b/resources/js/Pages/Profile/Notifications.jsx new file mode 100644 index 0000000..5abb09c --- /dev/null +++ b/resources/js/Pages/Profile/Notifications.jsx @@ -0,0 +1,155 @@ +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { Head } from '@inertiajs/react'; +import axios from 'axios'; +import { useState } from 'react'; + +const CHANNEL_LABELS = { + database: 'In-app', + mail: 'Email', + webhook: 'Webhook', +}; + +const EVENT_LABELS = { + 'operation.finished': 'Operation finished', + 'operation.failed': 'Operation failed', + 'ssl.expiring': 'SSL expiring', + 'ssl.issued': 'SSL issued', + 'backup.result': 'Backup result', + 'fail2ban.ban': 'Fail2ban ban', + 'resource.threshold': 'Resource threshold', + 'deploy.success': 'Deploy success', + 'deploy.failed': 'Deploy failed', +}; + +function buildMatrix(eventTypes, channels, preferences) { + const matrix = {}; + for (const eventType of eventTypes) { + matrix[eventType] = {}; + for (const channel of channels) { + const row = preferences.find( + (p) => p.event_type === eventType && p.channel === channel, + ); + matrix[eventType][channel] = row ? Boolean(row.enabled) : true; + } + } + return matrix; +} + +export default function Notifications({ eventTypes, channels, preferences, webhookUrl }) { + const [matrix, setMatrix] = useState(() => buildMatrix(eventTypes, channels, preferences)); + const [webhookInput, setWebhookInput] = useState(webhookUrl ?? ''); + const [webhookError, setWebhookError] = useState(''); + const [webhookSaved, setWebhookSaved] = useState(false); + + const handleToggle = async (eventType, channel, enabled) => { + setMatrix((prev) => ({ + ...prev, + [eventType]: { ...prev[eventType], [channel]: enabled }, + })); + try { + await axios.patch('/profile/notifications', { event_type: eventType, channel, enabled }); + } catch { + // Revert on failure + setMatrix((prev) => ({ + ...prev, + [eventType]: { ...prev[eventType], [channel]: !enabled }, + })); + } + }; + + const handleWebhookSave = async (e) => { + e.preventDefault(); + setWebhookError(''); + setWebhookSaved(false); + try { + await axios.patch('/profile/notifications/webhook', { + webhook_url: webhookInput || null, + }); + setWebhookSaved(true); + } catch (err) { + const msg = + err?.response?.data?.errors?.webhook_url?.[0] ?? + 'Failed to save webhook URL.'; + setWebhookError(msg); + } + }; + + return ( + + +
+

Notification Preferences

+ + {/* Preference matrix */} +
+ + + + + {channels.map((ch) => ( + + ))} + + + + {eventTypes.map((eventType) => ( + + + {channels.map((channel) => ( + + ))} + + ))} + +
Event + {CHANNEL_LABELS[ch] ?? ch} +
+ {EVENT_LABELS[eventType] ?? eventType} + + + handleToggle(eventType, channel, e.target.checked) + } + className="w-4 h-4 accent-indigo-600" + /> +
+
+ + {/* Webhook URL */} +
+

Webhook URL

+

+ Notifications will be POSTed as JSON to this URL. Leave blank to disable. +

+
+ { + setWebhookInput(e.target.value); + setWebhookSaved(false); + setWebhookError(''); + }} + placeholder="https://hooks.example.com/..." + className="border rounded px-3 py-2 text-sm dark:bg-gray-900 dark:border-gray-700 dark:text-gray-200" + /> + {webhookError && ( +

{webhookError}

+ )} + {webhookSaved && ( +

Webhook URL saved.

+ )} + +
+
+
+
+ ); +} diff --git a/resources/js/Pages/Profile/Notifications.test.jsx b/resources/js/Pages/Profile/Notifications.test.jsx new file mode 100644 index 0000000..696dba2 --- /dev/null +++ b/resources/js/Pages/Profile/Notifications.test.jsx @@ -0,0 +1,263 @@ +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { test, expect, vi, beforeEach } from 'vitest'; +import Notifications from './Notifications'; + +// Mock @inertiajs/react +vi.mock('@inertiajs/react', () => ({ + Head: ({ title }) => {title}, + usePage: () => ({ + props: { auth: { user: { id: 1 } } }, + }), +})); + +// Mock AuthenticatedLayout — render children directly +vi.mock('@/Layouts/AuthenticatedLayout', () => ({ + default: ({ children }) =>
{children}
, +})); + +// Mock axios +vi.mock('axios'); +import axios from 'axios'; + +const EVENT_TYPES = ['operation.finished', 'ssl.expiring']; +const CHANNELS = ['database', 'mail', 'webhook']; + +const samplePreferences = [ + { event_type: 'operation.finished', channel: 'database', enabled: true }, + { event_type: 'operation.finished', channel: 'mail', enabled: false }, + { event_type: 'ssl.expiring', channel: 'webhook', enabled: false }, +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('renders preference matrix with channel labels In-app, Email, Webhook', () => { + render( + , + ); + expect(screen.getByText('In-app')).toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + expect(screen.getByText('Webhook')).toBeInTheDocument(); +}); + +test('renders event type rows in the matrix', () => { + render( + , + ); + expect(screen.getByText('Operation finished')).toBeInTheDocument(); + expect(screen.getByText('SSL expiring')).toBeInTheDocument(); +}); + +test('buildMatrix defaults to true (checked) when preference row is missing', () => { + render( + , + ); + const checkboxes = screen.getAllByRole('checkbox'); + // Missing row → defaults to true → checkbox should be checked + expect(checkboxes[0]).toBeChecked(); +}); + +test('buildMatrix reflects disabled preference as unchecked checkbox', () => { + render( + , + ); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes[0]).not.toBeChecked(); +}); + +test('toggling a checkbox calls axios.patch /profile/notifications with correct payload', async () => { + axios.patch = vi.fn().mockResolvedValue({ data: {} }); + + render( + , + ); + + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeChecked(); + + await userEvent.click(checkbox); + + await waitFor(() => { + expect(axios.patch).toHaveBeenCalledWith('/profile/notifications', { + event_type: 'operation.finished', + channel: 'database', + enabled: false, + }); + }); +}); + +test('toggle reverts to previous state when axios.patch fails', async () => { + axios.patch = vi.fn().mockRejectedValue(new Error('Server error')); + + render( + , + ); + + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeChecked(); + + await userEvent.click(checkbox); + + // After failure, should revert back to checked + await waitFor(() => { + expect(checkbox).toBeChecked(); + }); +}); + +test('webhook URL input is pre-populated from webhookUrl prop (not auth.user)', () => { + render( + , + ); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('https://hooks.example.com/abc'); +}); + +test('webhook URL input is empty when webhookUrl prop is null', () => { + render( + , + ); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue(''); +}); + +test('saving a valid webhook URL calls axios.patch /profile/notifications/webhook and shows success', async () => { + axios.patch = vi.fn().mockResolvedValue({ data: {} }); + + render( + , + ); + + const input = screen.getByRole('textbox'); + await userEvent.clear(input); + await userEvent.type(input, 'https://hooks.slack.com/services/x'); + + const saveBtn = screen.getByRole('button', { name: /save/i }); + await userEvent.click(saveBtn); + + await waitFor(() => { + expect(axios.patch).toHaveBeenCalledWith('/profile/notifications/webhook', { + webhook_url: 'https://hooks.slack.com/services/x', + }); + }); + + await waitFor(() => { + expect(screen.getByText(/webhook url saved/i)).toBeInTheDocument(); + }); +}); + +test('saving webhook URL shows error message when axios returns 422', async () => { + axios.patch = vi.fn().mockRejectedValue({ + response: { + data: { + errors: { + webhook_url: ['The webhook url must be a valid URL with http or https scheme.'], + }, + }, + }, + }); + + render( + , + ); + + const input = screen.getByRole('textbox'); + await userEvent.clear(input); + await userEvent.type(input, 'ftp://bad.example.com'); + + const saveBtn = screen.getByRole('button', { name: /save/i }); + await userEvent.click(saveBtn); + + await waitFor(() => { + expect( + screen.getByText(/the webhook url must be a valid URL with http or https scheme/i), + ).toBeInTheDocument(); + }); +}); + +test('saving webhook URL shows generic error when no specific error in response', async () => { + axios.patch = vi.fn().mockRejectedValue(new Error('Network error')); + + render( + , + ); + + const saveBtn = screen.getByRole('button', { name: /save/i }); + await userEvent.click(saveBtn); + + await waitFor(() => { + expect(screen.getByText(/failed to save webhook url/i)).toBeInTheDocument(); + }); +}); + +test('full matrix renders correct number of checkboxes', () => { + render( + , + ); + // 2 event types × 3 channels = 6 checkboxes + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(6); +}); diff --git a/resources/js/Pages/Websites/Index.jsx b/resources/js/Pages/Websites/Index.jsx index e9e8876..794849c 100644 --- a/resources/js/Pages/Websites/Index.jsx +++ b/resources/js/Pages/Websites/Index.jsx @@ -9,11 +9,15 @@ import { MdLock, MdLockOpen } from "react-icons/md"; import { FaToggleOn, FaToggleOff } from "react-icons/fa"; import CreateWebsiteForm from "./Partials/CreateWebsiteForm"; import { useEffect, useState } from "react"; +import axios from 'axios'; +import OperationProgress from '@/Components/OperationProgress'; export default function Websites({ websites, serverIp }) { const { auth } = usePage().props; const [phpVersions, setPhpVersions] = useState([]); + const [sslOp, setSslOp] = useState(null); + const [runtimeOp, setRuntimeOp] = useState(null); useEffect(() => { // fetch available PHP versions once @@ -40,20 +44,22 @@ export default function Websites({ websites, serverIp }) { }; const toggleSsl = (website) => { - const isEnabled = website.ssl_enabled; - const action = isEnabled ? 'disable' : 'enable'; - - router.post(route('websites.ssl.toggle', { website: website.id }), - { enabled: !isEnabled }, - { - onBefore: () => toast(`${action === 'enable' ? 'Enabling' : 'Disabling'} SSL...`), - onSuccess: () => { - toast.success(`SSL ${action === 'enable' ? 'enabled' : 'disabled'} successfully`); - router.reload(); - }, - onError: () => toast.error(`Failed to ${action} SSL`), - } - ); + const enabling = !website.ssl_enabled; + if (enabling) { + axios.post(route('websites.ssl.toggle', { website: website.id }), { enabled: true }) + .then((res) => setSslOp({ id: res.data.operation_id, url: website.url })) + .catch(() => toast.error('Failed to start SSL generation')); + } else { + router.post(route('websites.ssl.toggle', { website: website.id }), { enabled: false }, { + preserveScroll: true, onSuccess: () => router.reload(), + }); + } + }; + + const switchRuntime = (website, runtime) => { + axios.post(route('websites.runtime.switch', { website: website.id }), { runtime }) + .then((res) => setRuntimeOp({ id: res.data.operation_id, url: website.url, runtime })) + .catch(() => toast.error('Failed to start runtime switch')); }; return ( @@ -72,6 +78,23 @@ export default function Websites({ websites, serverIp }) {
+ {sslOp && ( +
+
Issuing SSL for {sslOp.url}
+ { setSslOp(null); router.reload(); }} /> +
+ )} + + {runtimeOp && ( +
+
Switching {runtimeOp.url} to {runtimeOp.runtime}...
+ { setRuntimeOp(null); router.reload(); }} + /> +
+ )} +
@@ -80,6 +103,7 @@ export default function Websites({ websites, serverIp }) { + {auth.user.role == 'admin' && ( )} @@ -91,15 +115,15 @@ export default function Websites({ websites, serverIp }) { - + + {auth.user.role == 'admin' && (
SSL Status Document Root PHP VersionRuntimeUser
- + {website.url}
)} - {website.ssl_status === 'active' ? 'SSL Active' : + {website.ssl_status === 'active' ? 'SSL Active' : website.ssl_status === 'expired' ? 'SSL Expired' : website.ssl_status === 'pending' ? 'SSL Pending' : 'SSL Inactive'}
@@ -123,8 +147,10 @@ export default function Websites({ websites, serverIp }) {
+
+
+ {website.runtime_label || website.runtime} +
+ + {website.runtime === 'frankenphp' && ( +
+ FrankenPHP runs on port {website.runtime_port}. SSL not supported in v1. +
+ )} +
+
@@ -160,8 +220,8 @@ export default function Websites({ websites, serverIp }) { toggleSsl(website)}>