feature: Initial commit

This commit is contained in:
dev
2026-01-01 19:20:06 +00:00
commit ae491f1720
8 changed files with 1503 additions and 0 deletions

3
.gitconfig Normal file
View File

@@ -0,0 +1,3 @@
[user]
name = VDI Devs
email = dev@vincentdesign.ca

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.ssh/
.wp-cli/
22222/
.well-known/
logs/
*.sqlite

View File

@@ -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/<dev panel URL>/htdocs/index.php`
- `/var/www/<dev panel URL>/htdocs/includes/db.php`
- `/var/www/<dev panel URL>/htdocs/includes/functions.php`
- `/var/www/<dev panel URL>/htdocs/style.css`
- `/var/www/<dev panel URL>/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 <dev panel URL> --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/<dev panel URL>/htdocs/index.php`
- `style.css``/var/www/<dev panel URL>/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/<dev panel URL>/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 <devUser1>
sudo usermod -aG webdev <devUser2>
...
# 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, youll 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/<serviceUser>/.ssh/` depending on your model
**Ensure correct perms:**
- `/var/www/.ssh` or `/home/<serviceUser>/.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.
**Dont 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/<site>/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
Dont 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 isnt streaming output
Bootstrap output streams (proc_open + flush)
WordOps sometimes buffers; thats normal. The panel will still show output when it arrives.
## Security notes
The panels `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

View File

@@ -0,0 +1,48 @@
<?php
$dbPath = __DIR__ . '/../dev_panel.db';
function getDb() {
static $pdo = null;
global $dbPath;
if ($pdo === null) {
$pdo = new PDO('sqlite:' . $dbPath);
$pdo->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];
}

View File

@@ -0,0 +1,128 @@
<?php
require_once __DIR__ . '/db.php';
function getCurrentUser() {
if (!isset($_SESSION['user_id'])) {
return null;
}
return [
'id' => $_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;
}

View File

@@ -0,0 +1,837 @@
<?php
// Simple WordOps Panel - vanilla PHP with auth + roles + SQLite
// ---------- DB + Auth Helpers ----------
require_once __DIR__ . '/includes/functions.php';
session_start();
// Disable PHP output buffering for this script as much as we can
while (ob_get_level() > 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.';
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>WordOps Dev Panel - Login</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div>
<h1 style="margin:0;font-size:1.4rem;">WordOps Dev Panel</h1>
<p class="muted" style="margin-top:0.25rem;">Sign in to manage dev sites.</p>
</div>
</header>
<div class="card" style="max-width:420px;margin:2rem auto;">
<h2 style="margin-top:0;">Login</h2>
<?php
if (!empty($seedInfo['adminCreated'])) {
echo '<p class="muted"><strong>Initial admin created.</strong><br>Username: <code>admin</code><br>Password: <code>change-me</code>. Please log in and change it later.</p>';
}
if ($loginError) {
echo '<p><strong>' . htmlspecialchars($loginError) . '</strong></p>';
}
?>
<form method="post">
<label>
Username
<input type="text" name="username" required>
</label>
<label>
Password
<input type="password" name="password" required>
</label>
<div style="margin-top:1rem;">
<button type="submit" class="btn btn-primary">Login</button>
</div>
</form>
</div>
</body>
</html>
<?php
exit;
}
// ---------- Self password change (processed for any authenticated action) ----------
$selfPasswordError = null;
$selfPasswordMessage = null;
$passwordDialogOpen = false;
if (
$user &&
$_SERVER['REQUEST_METHOD'] === 'POST' &&
isset($_POST['op']) &&
$_POST['op'] === 'self_change_password'
) {
$currentPassword = $_POST['current_password'] ?? '';
$newPassword = $_POST['new_password'] ?? '';
$newPasswordConfirm = $_POST['new_password_confirm'] ?? '';
$passwordDialogOpen = true; // ensure dialog opens after post
if ($currentPassword === '' || $newPassword === '' || $newPasswordConfirm === '') {
$selfPasswordError = 'All password fields are required.';
} elseif ($newPassword !== $newPasswordConfirm) {
$selfPasswordError = 'New passwords do not match.';
} elseif (strlen($newPassword) < 8) {
$selfPasswordError = 'New password must be at least 8 characters.';
} else {
$db = getDb();
$stmt = $db->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) ----------
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>WordOps Dev Panel</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div>
<h1 style="margin:0;font-size:1.4rem;">WordOps Dev Panel</h1>
<p class="muted" style="margin-top:0.25rem;">Create and manage dev sites.</p>
</div>
<nav style="display:flex;align-items:center;gap:0.75rem;">
<a href="?action=list" class="btn">Sites</a>
<a href="?action=create" class="btn btn-primary">New site</a>
<?php if (isAdmin()): ?>
<a href="?action=users" class="btn">Users</a>
<?php endif; ?>
<div class="user-menu" style="position:relative;margin-left:auto;">
<button id="userMenuToggle" class="btn" type="button">
<?php echo htmlspecialchars($user['username']); ?>
<span class="muted" style="font-size:0.8rem;">(<?php echo htmlspecialchars($user['role']); ?>)</span>
</button>
<div id="userMenu" style="display:none;position:absolute;right:0;margin-top:0.25rem;min-width:180px;">
<div class="card" style="padding:0.5rem;">
<a href="#" id="changePasswordLink" class="muted" style="display:block;padding:0.25rem 0;">Change password</a>
<a href="?action=logout" class="muted danger" style="display:block;padding:0.25rem 0;">Logout</a>
</div>
</div>
</div>
</nav>
</header>
<?php
// ---------- Actions for authenticated users ----------
if ($action === 'create') {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (!isset($_POST['op']) || $_POST['op'] !== 'self_change_password')) {
$rawDomain = $_POST['domain'] ?? '';
$domain = sanitizeDomain($rawDomain);
$projectName = trim($_POST['project_name'] ?? '');
$stackType = $_POST['stack_type'] ?? 'wp';
$isMultisite = isset($_POST['multisite']) ? $_POST['multisite'] : 'no';
$bootstrapProfile = $_POST['bootstrap_profile'] ?? 'none';
if (!$projectName) {
$projectName = $domain;
}
if (!$domain) {
echo '<div class="card"><strong>Invalid domain.</strong> Use only letters, numbers, dots and dashes.</div>';
} 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 '<div class="card">';
echo '<h2 style="margin-top:0;">Provisioning site: ' . htmlspecialchars($domain) . '</h2>';
echo '<p class="muted">WordOps log:</p>';
echo '<pre>';
@ob_flush();
@flush();
list($ok, $woLines) = runCommandStreaming($cmd, function ($line) {
echo htmlspecialchars($line) . "\n";
@ob_flush();
@flush();
});
echo '</pre>';
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 '<p><strong>Site created successfully.</strong></p>';
// 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 '<p class="muted" style="margin-top:1rem;">Bootstrap log:</p>';
echo '<pre>';
@ob_flush();
@flush();
list($bootstrapOk, $bootstrapLines) = runCommandStreaming($bootstrapCmd, function ($line) {
echo htmlspecialchars($line) . "\n";
@ob_flush();
@flush();
});
echo '</pre>';
if ($bootstrapOk) {
echo '<p><strong>Bootstrap completed:</strong> standard dev stack applied.</p>';
} else {
echo '<p><strong>Bootstrap failed:</strong> see log above.</p>';
}
}
echo '<p><a class="btn" href="?action=list">Back to site list</a></p>';
} else {
echo '<p><strong>Site creation failed.</strong> See WordOps log above.</p>';
echo '<p><a class="btn" href="?action=list">Back to site list</a></p>';
}
echo '</div>';
}
}
?>
<div class="card">
<h2 style="margin-top:0;">Create new site</h2>
<form method="post">
<label>
Domain
<input type="text" name="domain" placeholder="client-project.dev.internal" required>
<span class="muted">Use the same naming convention you plan for the real server.</span>
</label>
<label>
Project name
<input type="text" name="project_name" placeholder="Client Project Name">
<span class="muted">Used for site title and theme naming. Defaults to the domain if left blank.</span>
</label>
<label>
Stack type
<select name="stack_type">
<option value="wp">WordPress (no cache)</option>
<option value="wpfc">WordPress + fastcgi cache</option>
<option value="plain">Plain PHP (no WP, for tools)</option>
</select>
</label>
<label>
Multisite (optional)
<select name="multisite">
<option value="no">No (single site)</option>
<option value="subdir">Multisite (subdirectory)</option>
<option value="subdomain">Multisite (subdomain)</option>
</select>
</label>
<label>
Bootstrap profile
<select name="bootstrap_profile">
<option value="none">None (bare site only)</option>
<option value="standard">Standard dev stack (plugins, pages, theme)</option>
</select>
<span class="muted">“Standard” runs the shared bootstrap script after site creation.</span>
</label>
<div style="margin-top:1rem;">
<button type="submit" class="btn btn-primary">Create site</button>
<a href="?action=list" class="btn">Cancel</a>
</div>
</form>
</div>
<?php
} elseif ($action === 'delete' && isset($_GET['domain'])) {
$domain = sanitizeDomain($_GET['domain']);
if (!$domain) {
echo '<div class="card"><strong>Invalid domain.</strong></div>';
} 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 '<div class="card"><strong>Confirmation text did not match domain.</strong></div>';
} 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 '<div class="card"><strong>You do not have permission to delete this site.</strong></div>';
} else {
$cmd = sprintf(
'sudo %s site delete %s --no-prompt',
escapeshellcmd($woPath),
escapeshellarg($domain)
);
$output = [];
$status = null;
$ok = runCommand($cmd, $output, $status);
echo '<div class="card">';
if ($ok) {
// Remove from metadata
$stmt = $db->prepare('DELETE FROM sites WHERE domain = ?');
$stmt->execute([$domain]);
echo '<strong>Site deleted:</strong> ' . htmlspecialchars($domain) . '<br>';
} else {
echo '<strong>Failed to delete site.</strong> Check the log below.';
}
echo '<pre>' . htmlspecialchars(implode("\n", $output)) . '</pre>';
echo '<p><a class="btn" href="?action=list">Back to site list</a></p>';
echo '</div>';
}
}
} else {
?>
<div class="card">
<h2 style="margin-top:0;">Delete site</h2>
<p>You're about to delete <strong><?php echo htmlspecialchars($domain); ?></strong>.</p>
<p class="muted">This will remove the vhost and files. Databases are handled by WordOps according to its defaults.</p>
<form method="post">
<label>
Type the domain to confirm
<input type="text" name="confirm_text" placeholder="<?php echo htmlspecialchars($domain); ?>" required>
</label>
<input type="hidden" name="confirm" value="yes">
<div style="margin-top:1rem;">
<button type="submit" class="btn btn-danger">Yes, delete this site</button>
<a href="?action=list" class="btn">Cancel</a>
</div>
</form>
</div>
<?php
}
} elseif ($action === 'users' && isAdmin()) {
$db = getDb();
$userMessage = null;
$userError = null;
$generatedPasswordInfo = null;
if (
$_SERVER['REQUEST_METHOD'] === 'POST' &&
isset($_POST['op']) &&
$_POST['op'] !== 'self_change_password'
) {
$op = $_POST['op'] ?? '';
if ($op === 'create') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$passwordConfirm = $_POST['password_confirm'] ?? '';
$role = $_POST['role'] ?? 'dev';
if ($username === '' || $password === '' || $passwordConfirm === '') {
$userError = 'Username and both password fields are required.';
} elseif ($password !== $passwordConfirm) {
$userError = 'Passwords do not match.';
} elseif (!in_array($role, ['admin', 'dev'], true)) {
$userError = 'Invalid role selected.';
} else {
// Check uniqueness
$stmt = $db->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);
?>
<div class="card">
<h2 style="margin-top:0;">User Management</h2>
<?php
if ($userMessage) {
echo '<p><strong>' . $userMessage . '</strong></p>';
}
if ($userError) {
echo '<p><strong>' . htmlspecialchars($userError) . '</strong></p>';
}
if ($generatedPasswordInfo) {
echo '<p class="muted">New password for <strong>' . htmlspecialchars($generatedPasswordInfo['username']) . '</strong>: ';
echo '<code>' . htmlspecialchars($generatedPasswordInfo['password']) . '</code></p>';
}
?>
<h3>Existing users</h3>
<?php if (empty($users)): ?>
<p class="muted">No users found.</p>
<?php else: ?>
<table style="width:100%;border-collapse:collapse;margin-bottom:1rem;">
<thead>
<tr>
<th style="text-align:left;border-bottom:1px solid #1f2937;padding:0.4rem 0.2rem;">Username</th>
<th style="text-align:left;border-bottom:1px solid #1f2937;padding:0.4rem 0.2rem;">Role</th>
<th style="text-align:left;border-bottom:1px solid #1f2937;padding:0.4rem 0.2rem;">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $u): ?>
<tr>
<td style="padding:0.3rem 0.2rem;"><?php echo htmlspecialchars($u['username']); ?></td>
<td style="padding:0.3rem 0.2rem;"><?php echo htmlspecialchars($u['role']); ?></td>
<td style="padding:0.3rem 0.2rem;">
<form method="post" style="display:inline;">
<input type="hidden" name="op" value="reset_password">
<input type="hidden" name="user_id" value="<?php echo (int)$u['id']; ?>">
<button type="submit" class="btn btn-danger" style="font-size:0.75rem;padding:0.2rem 0.6rem;">
Reset password
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h3>Create new user</h3>
<form method="post">
<input type="hidden" name="op" value="create">
<label>
Username
<input type="text" name="username" required>
</label>
<label>
Role
<select name="role">
<option value="dev">Developer</option>
<option value="admin">Admin</option>
</select>
</label>
<label>
Password
<input type="password" name="password" required>
</label>
<label>
Confirm password
<input type="password" name="password_confirm" required>
</label>
<div style="margin-top:1rem;">
<button type="submit" class="btn btn-primary">Create user</button>
</div>
</form>
</div>
<?php
} else {
// Default: site list
$cmd = sprintf('sudo %s site list', escapeshellcmd($woPath));
$output = [];
$status = null;
$ok = runCommand($cmd, $output, $status);
echo '<div class="card">';
echo '<h2 style="margin-top:0;">Sites</h2>';
if (!$ok) {
echo '<strong>Could not retrieve site list.</strong>';
echo '<pre>' . htmlspecialchars(implode("\n", $output)) . '</pre>';
} 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 '<p class="muted">No sites found for your account yet. <a href="?action=create">Create your first site</a>.</p>';
} else {
echo '<ul style="list-style:none;padding-left:0;">';
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 '<li style="margin-bottom:0.75rem;">';
echo '<strong>' . $siteEsc . '</strong>';
echo ' <span class="tag">dev</span>';
if ($owner) {
echo ' <span class="muted" style="margin-left:0.5rem;">Owner: ' . htmlspecialchars($owner) . '</span>';
} else {
echo ' <span class="muted" style="margin-left:0.5rem;">Owner: Unassigned</span>';
}
echo '<br>';
echo '<a href="' . $url . '" target="_blank" rel="noopener">Open site</a> · ';
echo '<a href="' . $adminUrl . '" target="_blank" rel="noopener">WP Admin</a> · ';
echo '<a href="?action=delete&amp;domain=' . urlencode($site) . '" class="muted danger">Delete…</a>';
echo '</li>';
}
echo '</ul>';
}
}
echo '</div>';
}
?>
<!-- Change Password Modal -->
<div id="passwordModal" style="display:none;position:fixed;inset:0;z-index:50;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);">
<div class="card" style="max-width:420px;width:90%;margin:0 auto;">
<h2 style="margin-top:0;">Change password</h2>
<?php if ($selfPasswordError): ?>
<p><strong><?php echo htmlspecialchars($selfPasswordError); ?></strong></p>
<?php endif; ?>
<?php if ($selfPasswordMessage): ?>
<p><strong><?php echo htmlspecialchars($selfPasswordMessage); ?></strong></p>
<?php endif; ?>
<form method="post">
<input type="hidden" name="op" value="self_change_password">
<label>
Current password
<input type="password" name="current_password" required>
</label>
<label>
New password
<input type="password" name="new_password" required>
</label>
<label>
Confirm new password
<input type="password" name="new_password_confirm" required>
</label>
<div style="margin-top:1rem;display:flex;gap:0.5rem;justify-content:flex-end;">
<button type="button" class="btn" id="passwordModalClose">Cancel</button>
<button type="submit" class="btn btn-primary">Update password</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const userMenuToggle = document.getElementById('userMenuToggle');
const userMenu = document.getElementById('userMenu');
const changePasswordLink = document.getElementById('changePasswordLink');
const passwordModal = document.getElementById('passwordModal');
const passwordModalClose = document.getElementById('passwordModalClose');
if (userMenuToggle && userMenu) {
userMenuToggle.addEventListener('click', function (e) {
e.preventDefault();
userMenu.style.display = (userMenu.style.display === 'block') ? 'none' : 'block';
});
document.addEventListener('click', function (e) {
if (!userMenu.contains(e.target) && e.target !== userMenuToggle) {
userMenu.style.display = 'none';
}
});
}
function openPasswordModal() {
if (passwordModal) {
passwordModal.style.display = 'flex';
}
}
function closePasswordModal() {
if (passwordModal) {
passwordModal.style.display = 'none';
}
}
if (changePasswordLink) {
changePasswordLink.addEventListener('click', function (e) {
e.preventDefault();
openPasswordModal();
});
}
if (passwordModalClose) {
passwordModalClose.addEventListener('click', function (e) {
e.preventDefault();
closePasswordModal();
});
}
if (passwordModal) {
passwordModal.addEventListener('click', function (e) {
if (e.target === passwordModal) {
closePasswordModal();
}
});
}
<?php if ($passwordDialogOpen): ?>
// Re-open dialog after POST for feedback
openPasswordModal();
<?php endif; ?>
});
</script>
</body>
</html>

View File

@@ -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;
}
}

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
body {
width: 35em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a></p>
<p>
Please use the <tt>reportbug</tt> tool to report bugs in the
nginx package with Debian. However, check <a
href="http://bugs.debian.org/cgi-bin/pkgreport.cgi?ordering=normal;archive=0;src=nginx;repeatmerged=0">existing
bug reports</a> before reporting a new bug.
</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>