commit ae491f17204a96d3d6cf71c283cd4f01beb53e64 Author: dev Date: Thu Jan 1 19:20:06 2026 +0000 ✨feature: Initial commit diff --git a/.gitconfig b/.gitconfig new file mode 100644 index 0000000..8978ae5 --- /dev/null +++ b/.gitconfig @@ -0,0 +1,3 @@ +[user] + name = VDI Devs + email = dev@vincentdesign.ca diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3a38f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.ssh/ +.wp-cli/ +22222/ +.well-known/ +logs/ +*.sqlite diff --git a/dev-panel.vincentdevelopment.ca/htdocs/README.md b/dev-panel.vincentdevelopment.ca/htdocs/README.md new file mode 100644 index 0000000..e50b1c3 --- /dev/null +++ b/dev-panel.vincentdevelopment.ca/htdocs/README.md @@ -0,0 +1,259 @@ +# WordOps Dev Panel + +A lightweight internal control panel for managing WordOps WordPress sites without forcing developers onto the CLI. + +## What this does + +- Lists WordOps sites (hides `dev-panel.local`) +- Create / delete sites via WordOps +- Optional bootstrap step after site creation (`wp-dev-bootstrap.sh`) +- User auth + roles: + - `admin` can see/manage all sites + manage users + - `dev` can only see/manage sites they created +- SQLite-backed storage (no external DB required) +- Password change modal + logout under a username dropdown +- Streaming output for long-running bootstrap (WordOps output may still arrive in chunks) + +## Requirements + +### Server + +- Up-to-date Linux OS (built and tested on Ubuntu 24.04) +- [WordOps](https://wordops.net/) installed and working (`/usr/local/bin/wo`) +- [WP-CLI](https://wp-cli.org/) installed and working (`wp` in PATH) +- Nginx + PHP-FPM (WordOps provides this) + +### PHP packages + +Install SQLite support for PHP (required): + +```bash +sudo apt update +sudo apt install -y php-sqlite3 +sudo systemctl restart php8.3-fpm || true +sudo systemctl restart php-fpm || true +``` +> Adjust `php8.3-fpm` to your PHP version if needed. + +### File layout + +Panel site lives at: + - `/var/www//htdocs/index.php` + - `/var/www//htdocs/includes/db.php` + - `/var/www//htdocs/includes/functions.php` + - `/var/www//htdocs/style.css` + - `/var/www//htdocs/panel.sqlite (auto-created)` + +Scripts live at: + - `/usr/local/bin/wp-dev-bootstrap.sh` + - `/usr/local/bin/wo-fix-perms.sh` (optional) + + +## Install steps + +1. Create the WordOps “panel” site + + `sudo wo site create --php` + + Add host entry on your workstation (or internal DNS), then confirm you can load the site. + +2. Drop in the panel files + + Copy: + - `index.php` → `/var/www//htdocs/index.php` + - `style.css` → `/var/www//htdocs/style.css` + Make sure the web server can write the SQLite DB (the panel will create it on first load): + + `sudo chown -R www-data:www-data /var/www//htdocs` + > Optional: lock down later once seeded; see permissions section below + +3. Install bootstrap + helper scripts + + Copy: + - `wp-dev-bootstrap.sh` → `/usr/local/bin/wp-dev-bootstrap.sh` (custom bootstrap script) + - `wo-fix-perms.sh` → `/usr/local/bin/wo-fix-perms.sh` (optional) + + Then: + - `sudo chmod +x /usr/local/bin/wp-dev-bootstrap.sh` + - `sudo chmod +x /usr/local/bin/wo-fix-perms.sh` + +4. Allow www-data to run WordOps + scripts via sudo + + Create sudoers file: `sudo visudo -f /etc/sudoers.d/dev-panel` + + Contents: + ```text + www-data ALL=(root) NOPASSWD: /usr/local/bin/wo * + www-data ALL=(root) NOPASSWD: /usr/local/bin/wp-dev-bootstrap.sh * + www-data ALL=(root) NOPASSWD: /usr/local/bin/wo-fix-perms.sh * + ``` + + This lets the panel (running as www-data) execute the exact commands it needs as root. + + Do not add `www-data` to the sudo group. + + Test: + `sudo -u www-data sudo /usr/local/bin/wo site list` + + +## First login / seeding + +On first load, if there are no users, the panel auto-creates: + +```text +Username: admin +Password: change-me +``` + +Log in and change it immediately using the user dropdown → “Change password”. + +### Ownership & permissions strategy + +You have two competing needs: + +1. WordPress / PHP needs to write certain files +2. Developers need to edit themes/plugins without being root + +A safe, simple model is group-based permissions. + +```bash +# Create a shared dev group +sudo groupadd webdev || true + +# Add all devs to group +sudo usermod -aG webdev +sudo usermod -aG webdev +... + +# Add web server user +sudo usermod -aG webdev www-data +``` + +Log out and back in for group membership to apply. + +### Set group ownership + setgid under `/var/www` + +```bash +sudo chown -R root:webdev /var/www + +# Directories: 2775 (setgid + group writable) +sudo find /var/www -type d -exec chmod 2775 {} \; + +# Files: 664 (group writable) +sudo find /var/www -type f -exec chmod 664 {} \; +``` + +### Ensure new files stay group-writable + +Make sure your bootstrap script starts with `umask 0002` + +If WordOps creates files with different perms, run the optional fixer after site creation: + +`sudo /usr/local/bin/wo-fix-perms.sh example.local` + +### SSH keys for private repos (bootstrap theme cloning) + +If your bootstrap clones private repos, you’ll need a key that can access them. + +**Option A** (recommended): deploy key or bot account key for the server + +Create `/var/www/.ssh/` or `/home//.ssh/` depending on your model + +**Ensure correct perms:** + +- `/var/www/.ssh` or `/home//.ssh` = 700 +- private key = 600 + +Add to GitHub as a deploy key or bot account key + +Ensure `known_hosts` contains github.com to avoid prompts: + +`sudo -u www-data ssh-keyscan github.com >> /var/www/.ssh/known_hosts` + +**Option B:** keep bootstrap theme cloning optional and run theme cloning from a dev account via VSCode Remote. + +**Don’t store a personal private key in a shared server environment.** + + +## Daily workflow + +**Option A:** (recommended) [VSCode Remote - SSH](https://code.visualstudio.com/docs/remote/ssh) + +- Devs connect via SSH to the server +- Edit project files directly under `/var/www//htdocs` +- Panel handles provisioning + bootstrap + ownership metadata +- No need for tooling (`node`, `php`, etc) on user machines beyond VSCode + SSH + +**Option B:** SMB shares (with SSH tunnel) + +- Export `/var/www` (or per-site roots) via Samba +- Use group permissions (webdev) so edits behave identically to SSH +- Map network drives on dev machines +- Requires tooling (`node`, `php`, etc) on user machines for composer, Tailwind, etc + + +## Troubleshooting + +### “PDOException: could not find driver” + +PHP SQLite extension missing: + +```bash +sudo apt install -y php-sqlite3 +sudo systemctl restart php8.3-fpm || sudo systemctl restart php-fpm +``` + +### WordOps fails when run as www-data + +Don’t run wo as www-data directly: + +**Correct** (what the panel does): + +`sudo -u www-data sudo /usr/local/bin/wo site list` + +**Incorrect**: + +`sudo -u www-data /usr/local/bin/wo site list` + +### WordOps delete prompts / EOFError + +Use `--no-prompt` on deletes (panel already does). + +### Panel isn’t streaming output + +Bootstrap output streams (proc_open + flush) + +WordOps sometimes buffers; that’s normal. The panel will still show output when it arrives. + + +## Security notes + +The panel’s `sudoers` file is the main security boundary: + +- Keep it as narrow as possible +- Avoid wildcarding unrelated commands +- Consider restricting panel access by: + - Internal network only + - VPN only + - HTTP basic auth in front of it +- Keep OS patches current +- Regularly audit panel users + roles + + +## Backups (minimum viable) + +At minimum, back up: + +- /var/www (all site roots) +- Databases (WordOps MariaDB/MySQL) +- `/etc/nginx` and WordOps configs (optional but helpful) +- Panel SQLite DB: + - `/var/www/dev-panel.local/htdocs/panel.sqlite` + + +## Next steps / nice-to-haves + +- “Fix perms” button in the panel post-create +- Per-site notes (who/why) for management visibility +- Audit log for create/delete/bootstrap actions +- Optional “clone template site” support diff --git a/dev-panel.vincentdevelopment.ca/htdocs/includes/db.php b/dev-panel.vincentdevelopment.ca/htdocs/includes/db.php new file mode 100644 index 0000000..c674d75 --- /dev/null +++ b/dev-panel.vincentdevelopment.ca/htdocs/includes/db.php @@ -0,0 +1,48 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + } + + return $pdo; +} + +function initDb() { + $db = getDb(); + $db->exec(' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ("admin","dev")) + ); + '); + + $db->exec(' + CREATE TABLE IF NOT EXISTS sites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL UNIQUE, + owner_id INTEGER, + created_at TEXT NOT NULL, + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL + ); + '); + + // Seed initial admin if no users exist + $count = (int) $db->query('SELECT COUNT(*) FROM users')->fetchColumn(); + if ($count === 0) { + $hash = password_hash('change-me', PASSWORD_DEFAULT); + $stmt = $db->prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)'); + $stmt->execute(['admin', $hash, 'admin']); + return ['adminCreated' => true, 'username' => 'admin', 'password' => 'change-me']; + } + + return ['adminCreated' => false]; +} + diff --git a/dev-panel.vincentdevelopment.ca/htdocs/includes/functions.php b/dev-panel.vincentdevelopment.ca/htdocs/includes/functions.php new file mode 100644 index 0000000..4762fc6 --- /dev/null +++ b/dev-panel.vincentdevelopment.ca/htdocs/includes/functions.php @@ -0,0 +1,128 @@ + $_SESSION['user_id'], + 'username' => $_SESSION['username'], + 'role' => $_SESSION['role'], + ]; +} + +function isAdmin() { + $user = getCurrentUser(); + return $user && $user['role'] === 'admin'; +} + +function requireLogin($action) { + $publicActions = ['login']; + if (!in_array($action, $publicActions, true) && !isset($_SESSION['user_id'])) { + header('Location: ?action=login'); + exit; + } +} + +// ---------- Misc helpers ---------- + +function generatePassword($length = 16) { + $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'; + $pass = ''; + $max = strlen($chars) - 1; + for ($i = 0; $i < $length; $i++) { + $pass .= $chars[random_int(0, $max)]; + } + return $pass; +} + +// ---------- CLI Helpers ---------- + +function stripAnsi($str) { + // Remove ANSI escape sequences like \e[94m ... \e[0m + return preg_replace('/\x1B\[[0-9;]*[A-Za-z]/', '', $str); +} + +function runCommand($cmd, &$output = null, &$status = null) { + $output = []; + exec($cmd . ' 2>&1', $output, $status); + + // Strip ANSI codes from each line + foreach ($output as &$line) { + $line = stripAnsi($line); + } + + return $status === 0; +} + +function runCommandStreaming($cmd, callable $onLine) { + $descriptorspec = [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ]; + + $process = proc_open($cmd . ' 2>&1', $descriptorspec, $pipes); + + if (!is_resource($process)) { + return [false, ['Failed to start process']]; + } + + // We don't need stdin + fclose($pipes[0]); + + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $allLines = []; + + while (true) { + $status = proc_get_status($process); + + $out = fgets($pipes[1]); + $err = fgets($pipes[2]); + + foreach ([$out, $err] as $chunk) { + if ($chunk !== false && $chunk !== '') { + $chunk = stripAnsi($chunk); + $lines = preg_split("/\r\n|\n|\r/", $chunk); + foreach ($lines as $line) { + if ($line === '') { + continue; + } + $allLines[] = $line; + $onLine($line); + } + + @ob_flush(); + @flush(); + } + } + + if (!$status['running']) { + break; + } + + usleep(50000); // 50ms + } + + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + return [$exitCode === 0, $allLines]; +} + +function sanitizeDomain($domain) { + $domain = trim($domain); + if ($domain === '') { + return null; + } + // Allow letters, numbers, dots, and dashes + if (!preg_match('/^[a-zA-Z0-9.-]+$/', $domain)) { + return null; + } + return $domain; +} diff --git a/dev-panel.vincentdevelopment.ca/htdocs/index.php b/dev-panel.vincentdevelopment.ca/htdocs/index.php new file mode 100644 index 0000000..fa1c3df --- /dev/null +++ b/dev-panel.vincentdevelopment.ca/htdocs/index.php @@ -0,0 +1,837 @@ + 0) { + ob_end_flush(); +} +ob_implicit_flush(true); + +// Send headers early +header('Content-Type: text/html; charset=utf-8'); +header('Cache-Control: no-cache'); +header('X-Accel-Buffering: no'); // Hint for Nginx: disable buffering + +// Basic config +$woPath = '/usr/local/bin/wo'; +$bootstrapScript = '/usr/local/bin/wp-dev-bootstrap.sh'; + +// Defaults for bootstrap script +$bootstrapAdminUser = 'vdidev'; +$bootstrapAdminEmail = 'dev@vincentdevelopment.ca'; + +// Optional theme config for bootstrap script +$themeStarterRepo = 'git@github.com:Vincent-Design-Inc/VDI-Starter-v5.git'; +$themeRemoteOrigin = ''; // e.g. 'git@github.com:your-org/client-theme-repo.git' + +// SQLite config +$dbPath = __DIR__ . '/panel.sqlite'; + +// ---------- Init DB ---------- + +$seedInfo = initDb(); + +// ---------- Routing / Auth Gate ---------- + +$action = isset($_GET['action']) ? $_GET['action'] : 'list'; + +// Logout handler +if ($action === 'logout') { + $_SESSION = []; + session_destroy(); + header('Location: ?action=login'); + exit; +} + +requireLogin($action); +$user = getCurrentUser(); + +// ---------- Login Action ---------- + +if ($action === 'login') { + $loginError = null; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $username = trim($_POST['username'] ?? ''); + $password = $_POST['password'] ?? ''; + + if ($username !== '' && $password !== '') { + $db = getDb(); + $stmt = $db->prepare('SELECT id, username, password_hash, role FROM users WHERE username = ?'); + $stmt->execute([$username]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($row && password_verify($password, $row['password_hash'])) { + $_SESSION['user_id'] = $row['id']; + $_SESSION['username'] = $row['username']; + $_SESSION['role'] = $row['role']; + + header('Location: ?action=list'); + exit; + } else { + $loginError = 'Invalid username or password.'; + } + } else { + $loginError = 'Username and password are required.'; + } + } + + ?> + + + + + WordOps Dev Panel - Login + + + + +
+
+

WordOps Dev Panel

+

Sign in to manage dev sites.

+
+
+ +
+

Login

+ + Initial admin created.
Username: admin
Password: change-me. Please log in and change it later.

'; + } + + if ($loginError) { + echo '

' . htmlspecialchars($loginError) . '

'; + } + ?> + +
+ + + + +
+ +
+
+
+ + + prepare('SELECT password_hash FROM users WHERE id = ?'); + $stmt->execute([$user['id']]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row || !password_verify($currentPassword, $row['password_hash'])) { + $selfPasswordError = 'Current password is incorrect.'; + } else { + $hash = password_hash($newPassword, PASSWORD_DEFAULT); + $update = $db->prepare('UPDATE users SET password_hash = ? WHERE id = ?'); + $update->execute([$hash, $user['id']]); + $selfPasswordMessage = 'Your password has been updated.'; + } + } +} + +// ---------- Page Layout (for authenticated users) ---------- + +?> + + + + + WordOps Dev Panel + + + + + +
+
+

WordOps Dev Panel

+

Create and manage dev sites.

+
+ + +
+ +Invalid domain. Use only letters, numbers, dots and dashes.'; + } else { + // Build WordOps command + $flags = []; + + if ($stackType === 'wp') { + $flags[] = '--wp'; + } elseif ($stackType === 'wpfc') { + $flags[] = '--wpfc'; + } elseif ($stackType === 'plain') { + $flags[] = '--php'; + } + + if ($isMultisite === 'subdir') { + $flags[] = '--wpsubdir'; + } elseif ($isMultisite === 'subdomain') { + $flags[] = '--wpsubdomain'; + } + + $cmd = sprintf( + 'sudo %s site create %s %s', + escapeshellcmd($woPath), + escapeshellarg($domain), + implode(' ', array_map('escapeshellarg', $flags)) + ); + + echo '
'; + echo '

Provisioning site: ' . htmlspecialchars($domain) . '

'; + echo '

WordOps log:

'; + echo '
';
+
+      @ob_flush();
+      @flush();
+
+      list($ok, $woLines) = runCommandStreaming($cmd, function ($line) {
+        echo htmlspecialchars($line) . "\n";
+        @ob_flush();
+        @flush();
+      });
+
+      echo '
'; + + if ($ok) { + // Record site owner in SQLite + $db = getDb(); + $stmt = $db->prepare('INSERT OR REPLACE INTO sites (domain, owner_id, created_at) VALUES (?, ?, datetime("now"))'); + $stmt->execute([$domain, $user['id']]); + + echo '

Site created successfully.

'; + + // Run bootstrap if requested + if ($bootstrapProfile === 'standard') { + $adminUser = $bootstrapAdminUser; + $adminEmail = $bootstrapAdminEmail; + + $bootstrapArgs = [ + '--domain', $domain, + '--project-name', $projectName, + '--admin-user', $adminUser, + '--admin-email', $adminEmail, + ]; + + if ($themeStarterRepo !== '') { + $bootstrapArgs[] = '--theme-starter-repo'; + $bootstrapArgs[] = $themeStarterRepo; + } + if ($themeRemoteOrigin !== '') { + $bootstrapArgs[] = '--theme-remote-origin'; + $bootstrapArgs[] = $themeRemoteOrigin; + } + + $bootstrapCmd = escapeshellcmd($bootstrapScript); + foreach ($bootstrapArgs as $arg) { + $bootstrapCmd .= ' ' . escapeshellarg($arg); + } + + echo '

Bootstrap log:

'; + echo '
';
+
+          @ob_flush();
+          @flush();
+
+          list($bootstrapOk, $bootstrapLines) = runCommandStreaming($bootstrapCmd, function ($line) {
+            echo htmlspecialchars($line) . "\n";
+            @ob_flush();
+            @flush();
+          });
+
+          echo '
'; + + if ($bootstrapOk) { + echo '

Bootstrap completed: standard dev stack applied.

'; + } else { + echo '

Bootstrap failed: see log above.

'; + } + } + + echo '

Back to site list

'; + } else { + echo '

Site creation failed. See WordOps log above.

'; + echo '

Back to site list

'; + } + + echo '
'; + } + } + + ?> + +
+

Create new site

+ +
+ + + + + + + + + + +
+ + Cancel +
+
+
+ + Invalid domain.'; + } elseif ( + $_SERVER['REQUEST_METHOD'] === 'POST' && + isset($_POST['confirm']) && + $_POST['confirm'] === 'yes' && + (!isset($_POST['op']) || $_POST['op'] !== 'self_change_password') + ) { + $confirmText = $_POST['confirm_text'] ?? ''; + + if ($confirmText !== $domain) { + echo '
Confirmation text did not match domain.
'; + } else { + // Enforce ownership for dev users + $db = getDb(); + $ownerRow = $db->prepare('SELECT owner_id FROM sites WHERE domain = ?'); + $ownerRow->execute([$domain]); + $ownerId = $ownerRow->fetchColumn(); + + if (!isAdmin() && (int)$ownerId !== (int)$user['id']) { + echo '
You do not have permission to delete this site.
'; + } else { + $cmd = sprintf( + 'sudo %s site delete %s --no-prompt', + escapeshellcmd($woPath), + escapeshellarg($domain) + ); + + $output = []; + $status = null; + $ok = runCommand($cmd, $output, $status); + + echo '
'; + + if ($ok) { + // Remove from metadata + $stmt = $db->prepare('DELETE FROM sites WHERE domain = ?'); + $stmt->execute([$domain]); + + echo 'Site deleted: ' . htmlspecialchars($domain) . '
'; + } else { + echo 'Failed to delete site. Check the log below.'; + } + + echo '
' . htmlspecialchars(implode("\n", $output)) . '
'; + echo '

Back to site list

'; + echo '
'; + } + } + } else { + ?> +
+

Delete site

+

You're about to delete .

+

This will remove the vhost and files. Databases are handled by WordOps according to its defaults.

+ +
+ + + + +
+ + Cancel +
+
+
+ prepare('SELECT COUNT(*) FROM users WHERE username = ?'); + $stmt->execute([$username]); + if ((int)$stmt->fetchColumn() > 0) { + $userError = 'Username already exists.'; + } else { + $hash = password_hash($password, PASSWORD_DEFAULT); + $stmt = $db->prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)'); + $stmt->execute([$username, $hash, $role]); + $userMessage = 'User "' . htmlspecialchars($username) . '" created successfully.'; + } + } + } elseif ($op === 'reset_password') { + $userId = (int)($_POST['user_id'] ?? 0); + if ($userId > 0) { + $stmt = $db->prepare('SELECT username FROM users WHERE id = ?'); + $stmt->execute([$userId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + $userError = 'User not found.'; + } else { + $newPass = generatePassword(16); + $hash = password_hash($newPass, PASSWORD_DEFAULT); + $update = $db->prepare('UPDATE users SET password_hash = ? WHERE id = ?'); + $update->execute([$hash, $userId]); + + $generatedPasswordInfo = [ + 'username' => $row['username'], + 'password' => $newPass, + ]; + $userMessage = 'Password reset for user "' . htmlspecialchars($row['username']) . '".'; + } + } else { + $userError = 'Invalid user ID.'; + } + } + } + + // Fetch users for listing + $stmt = $db->query('SELECT id, username, role FROM users ORDER BY username ASC'); + $users = $stmt->fetchAll(PDO::FETCH_ASSOC); + + ?> +
+

User Management

+ + ' . $userMessage . '

'; + } + if ($userError) { + echo '

' . htmlspecialchars($userError) . '

'; + } + if ($generatedPasswordInfo) { + echo '

New password for ' . htmlspecialchars($generatedPasswordInfo['username']) . ': '; + echo '' . htmlspecialchars($generatedPasswordInfo['password']) . '

'; + } + ?> + +

Existing users

+ +

No users found.

+ + + + + + + + + + + + + + + + + + +
UsernameRoleActions
+
+ + + +
+
+ + +

Create new user

+
+ + + + + + + + + + +
+ +
+
+
+ '; + echo '

Sites

'; + + if (!$ok) { + echo 'Could not retrieve site list.'; + echo '
' . htmlspecialchars(implode("\n", $output)) . '
'; + } else { + $db = getDb(); + + // Map of domain => [owner_id, username, role] + $meta = []; + $stmt = $db->query(' + SELECT s.domain, s.owner_id, u.username, u.role + FROM sites s + LEFT JOIN users u ON s.owner_id = u.id + '); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $meta[$row['domain']] = $row; + } + + $sites = []; + foreach ($output as $line) { + $line = trim($line); + + // Skip header / separator lines and the panel site itself + if ( + $line === '' || + strpos($line, 'Site') !== false || + strpos($line, 'site') !== false || + strpos($line, '---') !== false || + $line === 'dev-panel.local' + ) { + continue; + } + + // Take the first column as the domain + $parts = preg_split('/\s+/', $line); + if (!$parts || !isset($parts[0])) { + continue; + } + + $domain = sanitizeDomain($parts[0]); + if (!$domain) { + continue; + } + + $ownerInfo = $meta[$domain] ?? null; + + // Role-based filtering: dev sees only their own; admin sees all + if (!isAdmin()) { + if (!$ownerInfo || (int)$ownerInfo['owner_id'] !== (int)$user['id']) { + continue; + } + } + + $sites[] = [ + 'domain' => $domain, + 'owner' => $ownerInfo['username'] ?? null, + 'ownerRole' => $ownerInfo['role'] ?? null, + 'ownerId' => $ownerInfo['owner_id'] ?? null, + ]; + } + + if (empty($sites) || (count($sites) === 1 && strPos($sites[0]['domain'], 'dev-panel') !== false)) { + echo '

No sites found for your account yet. Create your first site.

'; + } else { + echo '
    '; + foreach ($sites as $siteInfo) { + if (strPos($siteInfo['domain'], 'dev-panel') !== false) { + continue; + } + + $site = $siteInfo['domain']; + $owner = $siteInfo['owner']; + $siteEsc = htmlspecialchars($site); + $url = 'http://' . $site; + $adminUrl = 'http://' . $site . '/wp-admin/'; + + echo '
  • '; + echo '' . $siteEsc . ''; + echo ' dev'; + if ($owner) { + echo ' Owner: ' . htmlspecialchars($owner) . ''; + } else { + echo ' Owner: Unassigned'; + } + echo '
    '; + echo 'Open site · '; + echo 'WP Admin · '; + echo 'Delete…'; + echo '
  • '; + } + echo '
'; + } + } + + echo ''; +} + +?> + + + + + + + + diff --git a/dev-panel.vincentdevelopment.ca/htdocs/style.css b/dev-panel.vincentdevelopment.ca/htdocs/style.css new file mode 100644 index 0000000..ef76c10 --- /dev/null +++ b/dev-panel.vincentdevelopment.ca/htdocs/style.css @@ -0,0 +1,190 @@ +:root { + --bg: #020617; + --card-bg: #020617; + --border: #1f2937; + --text: #e5e7eb; + --muted: #9ca3af; + --accent: #38bdf8; + --accent-soft: rgba(56, 189, 248, 0.1); + --danger: #f97373; + --danger-soft: rgba(248, 113, 113, 0.1); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; + background: radial-gradient(circle at top, #020617 0, #020617 40%, #020617 100%); + color: var(--text); +} + +body { + min-height: 100vh; +} + +/* Layout */ + +header { + max-width: 960px; + margin: 1.5rem auto 1rem; + padding: 0 1rem; + display: flex; + align-items: center; + justify-content: space-between; +} + +nav { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.card { + max-width: 960px; + margin: 0 auto 1rem; + padding: 1rem 1.25rem; + background: var(--card-bg); + border-radius: 0.75rem; + border: 1px solid var(--border); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); +} + +/* Typography */ + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.muted { + color: var(--muted); + font-size: 0.9rem; +} + +/* Buttons */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.35rem 0.75rem; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(15, 23, 42, 0.9); + color: var(--text); + font-size: 0.9rem; + cursor: pointer; + text-decoration: none; + white-space: nowrap; +} + +.btn:hover { + border-color: var(--accent); + background: rgba(15, 23, 42, 1); +} + +.btn-primary { + border-color: var(--accent); + background: var(--accent-soft); +} + +.btn-primary:hover { + background: rgba(56, 189, 248, 0.2); +} + +.btn-danger { + border-color: var(--danger); + background: var(--danger-soft); + color: var(--danger); +} + +.btn-danger:hover { + background: rgba(248, 113, 113, 0.2); +} + +/* Danger links */ + +.danger { + color: var(--danger); +} + +.danger:hover { + color: #fecaca; +} + +/* Forms */ + +label { + display: block; + margin-bottom: 0.75rem; +} + +input[type="text"], +input[type="password"], +select { + width: 100%; + padding: 0.4rem 0.6rem; + border-radius: 0.5rem; + border: 1px solid var(--border); + background: #020617; + color: var(--text); + margin-top: 0.25rem; +} + +input[type="text"]:focus, +input[type="password"]:focus, +select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.7); +} + +/* Tags */ + +.tag { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(15, 23, 42, 0.8); + font-size: 0.75rem; +} + +/* Pre */ + +pre { + background: #020617; + border-radius: 0.5rem; + padding: 0.6rem 0.8rem; + border: 1px solid var(--border); + overflow-x: auto; + font-size: 0.8rem; +} + +/* Small screens */ + +@media (max-width: 640px) { + header { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + nav { + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + } +} diff --git a/html/index.nginx-debian.html b/html/index.nginx-debian.html new file mode 100644 index 0000000..5e56555 --- /dev/null +++ b/html/index.nginx-debian.html @@ -0,0 +1,32 @@ + + + +Welcome to nginx! + + + +

Welcome to nginx!

+

If you see this page, the nginx web server is successfully installed and +working. Further configuration is required.

+ +

For online documentation and support please refer to +nginx.org

+ +

+ Please use the reportbug tool to report bugs in the + nginx package with Debian. However, check existing + bug reports before reporting a new bug. +

+ +

Thank you for using nginx.

+ + + +