@@ -151,17 +151,17 @@ if (isset($_GET['stream'])) {
-
@@ -171,26 +171,14 @@ if (isset($_GET['stream'])) {
-
-
+
+
-
+
-
\ No newline at end of file
+
diff --git a/Web/content/ftp/serverstat.php b/Web/content/ftp/serverstat.php
index 0b27ac7..ca5ee0f 100644
--- a/Web/content/ftp/serverstat.php
+++ b/Web/content/ftp/serverstat.php
@@ -12,47 +12,20 @@ if (empty($_SESSION['admin']) || $_SESSION['admin'] !== true) {
exit();
}
-require_once __DIR__ . '/../config/config.php';
-
-use phpseclib3\Net\SSH2;
-
-// $ssh_host = 'localhost';
-// $ssh_username = 'uname';
-// $ssh_password = 'pswd';
+require_once __DIR__ . '/../../config/config.php';
+require_once __DIR__ . '/../../includes/workspace.php';
+// The configuration helper authenticates using SSH_HOST, SSH_USER and SSH_PASS.
$ssh = getSSHConnection();
-if (!$ssh->login($ssh_username, $ssh_password)) {
- die('SSH login failed');
-}
-function getDiskData($ssh, $device) {
- return $ssh->exec("df -h $device");
-}
+require_once __DIR__ . '/lib/serverstats.php';
-function getSystemData($ssh) {
- // Get memory info using awk to clean up the output
- $meminfo = $ssh->exec("free -m | awk 'NR==2{printf \"%s,%s,%s,\", $2,$3,$7} NR==3{printf \"%s,%s,%s\", $2,$3,$4}'");
-
- // Use vmstat to get CPU idle percentage, more reliable and commonly available
- $cpuIdle = $ssh->exec("vmstat 1 2 | tail -1 | awk '{print $15}'");
-
- return [
- 'storage' => [
- 'system' => parseDiskData($ssh->exec('df -h /dev/ubuntu-vg/ubuntu-lv')),
- 'data' => parseDiskData($ssh->exec('df -h /dev/sdb1'))
- ],
- 'raid' => parseRaidStatus($ssh->exec('cat /proc/mdstat')),
- 'resources' => parseSystemStats($cpuIdle, $meminfo),
- 'lastUpdate' => date('Y-m-d H:i:s')
- ];
-}
-
-$systemData = getSystemData($ssh);
+$systemData = getSystemData($ssh, $defaultPath);
$monitors = [
[
'id' => 'SystemDisk',
- 'title' => 'System Disk (sda)',
+ 'title' => 'System filesystem',
'icon' => 'hdd',
'color' => 'primary',
'type' => 'storage',
@@ -60,7 +33,7 @@ $monitors = [
],
[
'id' => 'DataDisk',
- 'title' => 'Data Disk (sdb)',
+ 'title' => 'Storage filesystem',
'icon' => 'hdd',
'color' => 'primary',
'type' => 'storage',
@@ -91,349 +64,101 @@ $monitors = [
]
];
-function parseDiskData($output) {
- $lines = explode("\n", trim($output));
- if (count($lines) < 2) return null;
-
- $values = preg_split('/\s+/', trim($lines[1]));
- return [
- 'size' => $values[1] ?? 'N/A',
- 'used' => $values[2] ?? 'N/A',
- 'available' => $values[3] ?? 'N/A',
- 'usage' => $values[4] ?? '0%'
- ];
-}
-
-function parseRaidStatus($output) {
- if (empty($output)) {
- return [
- 'active' => false,
- 'status' => 'unknown',
- 'type' => 'N/A'
- ];
- }
-
- $status = [
- 'active' => false,
- 'status' => 'unknown',
- 'type' => 'N/A'
- ];
-
- $lines = explode("\n", trim($output));
- foreach ($lines as $line) {
- if (preg_match('/active\s+(\w+)/', $line, $matches)) {
- $status['active'] = true;
- $status['type'] = $matches[1] ?? 'N/A';
- }
- if (strpos($line, '[UU]') !== false) {
- $status['status'] = 'healthy';
- } elseif (strpos($line, '_') !== false) {
- $status['status'] = 'degraded';
- }
- }
-
- return $status;
-}
-
-function parseSystemStats($cpuOutput, $memOutput) {
- // Parse CPU stats - convert idle percentage to usage percentage
- $idlePercent = floatval(trim(str_replace(',', '.', $cpuOutput)));
- $cpuUsage = 100 - $idlePercent; // Convert idle to usage percentage
- if (is_nan($cpuUsage) || $cpuUsage < 0 || $cpuUsage > 100) {
- $cpuUsage = 0; // Default to 0% if parsing fails
- }
- $cpu = ['usage' => number_format($cpuUsage, 1)];
-
- // Parse memory stats
- $parts = explode(',', $memOutput);
- if (count($parts) >= 6) {
- $memTotal = intval($parts[0]);
- $memUsed = intval($parts[1]);
- $memAvail = intval($parts[2]);
- $swapTotal = intval($parts[3]);
- $swapUsed = intval($parts[4]);
- $swapFree = intval($parts[5]);
-
- $memory = [
- 'total' => number_format($memTotal / 1024, 2) . 'G',
- 'used' => number_format($memUsed / 1024, 2) . 'G',
- 'available' => number_format($memAvail / 1024, 2) . 'G',
- 'usage' => ($memTotal > 0 ? round(($memUsed / $memTotal) * 100, 1) : 0) . '%',
- 'swap_total' => number_format($swapTotal / 1024, 2) . 'G',
- 'swap_used' => number_format($swapUsed / 1024, 2) . 'G',
- 'swap_free' => number_format($swapFree / 1024, 2) . 'G',
- 'swap_usage' => ($swapTotal > 0 ? round(($swapUsed / $swapTotal) * 100, 1) : 0) . '%'
- ];
- } else {
- $memory = [
- 'total' => '0G',
- 'used' => '0G',
- 'available' => '0G',
- 'usage' => '0%',
- 'swap_total' => '0G',
- 'swap_used' => '0G',
- 'swap_free' => '0G',
- 'swap_usage' => '0%'
- ];
- }
-
- return ['cpu' => $cpu, 'memory' => $memory];
-}
-
-function convertToBytes($size) {
- if (preg_match('/^([\d.]+)([KMGT]?)i?B?$/', trim($size), $matches)) {
- $value = floatval($matches[1]);
- $unit = strtoupper($matches[2]);
-
- switch ($unit) {
- case 'P': $value *= 1024;
- case 'T': $value *= 1024;
- case 'G': $value *= 1024;
- case 'M': $value *= 1024;
- case 'K': $value *= 1024;
- }
-
- return $value;
- }
- return 0;
-}
-
-function formatBytes($bytes, $forceUnit = '') {
- $units = ['B', 'K', 'M', 'G', 'T', 'P'];
- $bytes = max($bytes, 0);
-
- if ($forceUnit) {
- $unitIndex = array_search($forceUnit, $units);
- if ($unitIndex !== false) {
- $bytes /= pow(1024, $unitIndex);
- return round($bytes, 2) . $forceUnit;
- }
- }
-
- $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
- $pow = min($pow, count($units) - 1);
- $bytes /= pow(1024, $pow);
-
- return round($bytes, 2) . $units[$pow];
-}
?>
-
+
Server Status
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
+ '../../',
+ 'active' => 'server',
+ 'section' => 'Server status',
+ 'eyebrow' => 'Server overview',
+ 'title' => 'Server status',
+ 'description' => 'Keep an eye on your storage, RAID health and system resources.',
+ 'connected' => true,
+ 'class' => 'server-page',
+]); ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Storage Usage
- = $diskData['usage'] ?>
-
-
-
-
-
-
-
Total
-
= $diskData['size'] ?>
-
-
-
-
-
Used
-
= $diskData['used'] ?>
-
-
-
-
-
Free
-
= $diskData['available'] ?>
-
-
-
-
-
-
-
-
-
= $systemData['raid']['type'] ?> Array
-
- = ucfirst($systemData['raid']['status']) ?>
-
-
-
-
-
-
-
-
-
- Memory Usage
- = $memData['usage'] ?>
-
-
-
-
-
-
-
Total
-
= $memData['total'] ?>
-
-
-
-
-
Used
-
= $memData['used'] ?>
-
-
-
-
-
Available
-
= $memData['available'] ?>
-
-
-
-
-
- Swap Usage
- = $memData['swap_usage'] ?>
-
-
-
-
-
-
-
Total
-
= $memData['swap_total'] ?>
-
-
-
-
-
Used
-
= $memData['swap_used'] ?>
-
-
-
-
-
Free
-
= $memData['swap_free'] ?>
-
-
-
-
-
-
-
+
Live statistics · refreshes every 5 seconds
= htmlspecialchars($systemData['lastUpdate']) ?>
+
+
+
+
+
+
+
+
+
Storage statistics are unavailable for this path.
+
+
= htmlspecialchars($diskData['filesystem']) ?> · = htmlspecialchars($monitor['source'] === 'system' ? '/' : $defaultPath) ?>
+
Storage usage= htmlspecialchars($diskData['usage']) ?>
+
+
+ 'Total', 'used' => 'Used', 'available' => 'Free'] as $key => $label): ?>
+
= $label ?>
= htmlspecialchars($diskData[$key]) ?>
+
+
+
+
+
+
+
= $systemData['raid']['arrays'] ? htmlspecialchars($systemData['raid']['type']) . ' Array' : 'Linux MD RAID' ?>
+
= htmlspecialchars(ucfirst($systemData['raid']['status'])) ?>
+
+
= htmlspecialchars($array['name'] . ' · ' . $array['type'] . ' · ' . $array['status']) ?>
-
+
+
+
CPU statistics are unavailable.
+
+
+
+
+
+
Memory statistics are unavailable.
+
+ 'Memory usage', 'swap_usage' => 'Swap usage'] as $usageKey => $label):
+ $usagePercent = (float) rtrim($memData[$usageKey], '%');
+ $fields = $usageKey === 'usage' ? ['total' => 'Total', 'used' => 'Used', 'available' => 'Available'] : ['swap_total' => 'Total', 'swap_used' => 'Used', 'swap_free' => 'Free'];
+ ?>
+
+
= $label ?>= htmlspecialchars($memData[$usageKey]) ?>
+
+
+ $fieldLabel): ?>
+
= $fieldLabel ?>
= htmlspecialchars($memData[$key]) ?>
+
+
+
+
+
+
-
+
+
+
-
-
-
-
\ No newline at end of file
+
diff --git a/Web/content/ftp/shared.php b/Web/content/ftp/shared.php
index 009733c..9f08bba 100644
--- a/Web/content/ftp/shared.php
+++ b/Web/content/ftp/shared.php
@@ -5,6 +5,7 @@ ini_set('display_errors', 1);
error_reporting(E_ALL);
require_once __DIR__ . '/../../config/config.php';
+require_once __DIR__ . '/../../includes/workspace.php';
// getDatabaseConnection relocated to central config
@@ -98,11 +99,11 @@ if (isset($_GET['stream'])) {
$matches = [];
if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) {
$start = intval($matches[1]);
-
+
if (!empty($matches[2])) {
$end = intval($matches[2]);
}
-
+
$length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content');
@@ -135,13 +136,13 @@ if (isset($_GET['stream'])) {
$readSize = min($chunkSize, $bytesRemaining);
$chunkData = $sftp->get($filePath, false, $currentPosition, $readSize);
-
+
if ($chunkData !== false) {
$bytesSent = strlen($chunkData);
echo $chunkData;
$bytesRemaining -= $bytesSent;
$currentPosition += $bytesSent;
-
+
if (ob_get_level()) {
ob_flush();
}
@@ -175,25 +176,25 @@ if (isset($_GET['download'])) {
if (!$downloadAllowed) {
die("Download not permitted for this share.");
}
-
+
// Download file code similar to download.php
header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=\"$fileName\"");
header("Content-Length: $fileSize");
-
+
$minChunkSize = 64 * 1024;
$maxChunkSize = 2 * 1024 * 1024;
$chunkSize = 256 * 1024;
-
+
$currentPosition = 0;
$bytesRemaining = $fileSize;
-
+
set_time_limit(0);
-
+
while ($bytesRemaining > 0) {
$readSize = min($chunkSize, $bytesRemaining);
$chunkData = $sftp->get($filePath, false, $currentPosition, $readSize);
-
+
if ($chunkData !== false) {
$bytesRead = strlen($chunkData);
echo $chunkData;
@@ -218,35 +219,32 @@ function formatBytes($bytes, $precision = 2) {
?>
-
+
Shared Media - = htmlspecialchars($fileName) ?>
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+ '../../',
+ 'active' => 'shared',
+ 'section' => 'Shared file',
+ 'eyebrow' => 'Shared with you',
+ 'title' => $fileName,
+ 'description' => 'Preview and access the file shared with you.',
+ 'guest' => true,
+ 'class' => 'media-page',
+]); ?>
-
+
+Shared linkAvailable until = htmlspecialchars(date('F j, Y, g:i a', strtotime($share['expiration']))) ?>
+
+
+
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+ '../../',
+ 'active' => 'files',
+ 'section' => 'Media preview',
+ 'eyebrow' => 'Media preview',
+ 'title' => $fileName,
+ 'description' => 'Preview your media, download a copy or create a shared link.',
+ 'connected' => true,
+ 'back' => 'index.php?path=' . urlencode(dirname($filePath)),
+ 'class' => 'media-page',
+]); ?>
-
+
+
-
+
@@ -319,7 +318,7 @@ if (isset($_GET['stream'])) {
- Copy
+ Copy
This link will expire on = date('F j, Y, g:i a', strtotime($shareExpiration)) ?>
@@ -356,33 +355,20 @@ if (isset($_GET['stream'])) {
-
-
+
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ Your storage. Your workspace.
A home for
every file.
Connect to your RAID storage and keep your files within reach. One simple workspace, powered by SFTP.
USB RAID SFTP
01
02
03
REMOTE STORAGE / LOCAL CONTROL
+ Welcome back
Sign in to your workspace
Use your account to access your files.
Need access? Contact your administrator.
+
+
diff --git a/Web/js/theme.js b/Web/js/theme.js
index 2f64187..0e6161c 100644
--- a/Web/js/theme.js
+++ b/Web/js/theme.js
@@ -1,40 +1,26 @@
-document.addEventListener('DOMContentLoaded', function() {
- const themeToggle = document.getElementById('themeToggle');
- const html = document.documentElement;
- const themeText = document.getElementById('themeText');
- const themeIcon = themeToggle.querySelector('.bi');
-
+(() => {
+ const preference = window.matchMedia('(prefers-color-scheme: dark)');
+ let saved;
+ try { saved = localStorage.getItem('theme'); } catch (_) { /* Storage may be disabled. */ }
function setTheme(theme) {
- html.setAttribute('data-bs-theme', theme);
- document.body.classList.remove('theme-light', 'theme-dark');
- document.body.classList.add('theme-' + theme);
- localStorage.setItem('theme', theme);
-
- if (theme === 'dark') {
- themeText.textContent = 'Light Mode';
- themeIcon.className = 'bi bi-sun';
- themeToggle.classList.remove('btn-dark');
- themeToggle.classList.add('btn-light');
- } else {
- themeText.textContent = 'Dark Mode';
- themeIcon.className = 'bi bi-moon';
- themeToggle.classList.remove('btn-light');
- themeToggle.classList.add('btn-dark');
+ document.documentElement.dataset.theme = theme;
+ const button = document.getElementById('themeToggle');
+ if (button) {
+ button.setAttribute('aria-label', `Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`);
+ const label = document.getElementById('themeText');
+ if (label) label.textContent = theme === 'dark' ? 'Light mode' : 'Dark mode';
+ const icon = button.querySelector('i');
+ if (icon) { icon.textContent = theme === 'dark' ? '☀' : '◐'; icon.setAttribute('aria-hidden', 'true'); }
}
}
-
- const savedTheme = localStorage.getItem('theme');
- const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
-
- if (savedTheme) {
- setTheme(savedTheme);
- } else {
- setTheme(prefersDark ? 'dark' : 'light');
- }
-
- themeToggle.addEventListener('click', function() {
- const currentTheme = html.getAttribute('data-bs-theme');
- const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
- setTheme(newTheme);
+ setTheme(saved === 'dark' || saved === 'light' ? saved : preference.matches ? 'dark' : 'light');
+ document.addEventListener('DOMContentLoaded', () => {
+ setTheme(document.documentElement.dataset.theme);
+ document.getElementById('themeToggle')?.addEventListener('click', () => {
+ saved = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
+ setTheme(saved);
+ try { localStorage.setItem('theme', saved); } catch (_) { /* Keep this session usable. */ }
+ });
});
-});
\ No newline at end of file
+ preference.addEventListener('change', () => { if (saved !== 'dark' && saved !== 'light') setTheme(preference.matches ? 'dark' : 'light'); });
+})();
diff --git a/Web/js/ui.js b/Web/js/ui.js
new file mode 100644
index 0000000..c10c579
--- /dev/null
+++ b/Web/js/ui.js
@@ -0,0 +1,10 @@
+// Native dialogs supply focus containment, Escape and focus restoration.
+document.addEventListener('click', event => {
+ const trigger = event.target.closest('[data-dialog]');
+ if (trigger) document.getElementById(trigger.dataset.dialog)?.showModal();
+ if (event.target.closest('[data-dialog-close]')) event.target.closest('dialog')?.close();
+ if (event.target instanceof HTMLDialogElement) {
+ const rect = event.target.getBoundingClientRect();
+ if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) event.target.close();
+ }
+});
diff --git a/tests/serverstats_test.php b/tests/serverstats_test.php
new file mode 100644
index 0000000..925c6c5
--- /dev/null
+++ b/tests/serverstats_test.php
@@ -0,0 +1,57 @@
+\n";
+check(parseRaidStatus($healthy)['status'] === 'healthy', 'Three-disk RAID5 should be healthy');
+check(parseRaidStatus(str_replace('[UUU]', '[U_U]', $healthy))['status'] === 'degraded', 'Missing RAID member should be degraded');
+check(parseRaidStatus($healthy . "md1 : active raid1 sdd[0] sde[1]\n 1000 blocks [2/1] [U_]\n")['status'] === 'degraded', 'One failed array must make overall status degraded');
+check(parseRaidStatus(str_replace('[UUU]', "[UUU]\n resync = 42.0%", $healthy))['status'] === 'syncing', 'RAID resync is visible');
+check(parseRaidStatus("md0 : active raid0 sda[0] sdb[1]\n 1000 blocks super 1.2 512k chunks\n")['status'] === 'active', 'RAID0 does not require a redundancy bitmap');
+check(parseRaidStatus("md0 : inactive sda[0](S)\n 1000 blocks\n")['status'] === 'inactive', 'Inactive array remains inactive');
+check(parseRaidStatus("Personalities : [raid1]\nunused devices: \n")['status'] === 'not configured', 'No configured Linux MD array');
+check(parseRaidStatus(false)['status'] === 'unavailable', 'Unreadable mdstat is unavailable');
+$resources = parseSystemStats('97,5', '1024,256,768,0,0,0');
+check($resources['cpu']['usage'] === '2.5' && $resources['memory']['usage'] === '25%' && $resources['memory']['swap_usage'] === '0%', 'Resource values and zero swap');
+check(!parseSystemStats(false, false)['cpu']['available_data'], 'Missing CPU output must not show 100%');
+check(!parseSystemStats('not a number', '')['memory']['available_data'], 'Missing memory output must be unavailable');
+
+// Check remote command construction and path quoting with a harmless stub.
+$ssh = new class {
+ public $commands = [];
+ function exec($command) { $this->commands[] = $command; return false; }
+};
+$defaultPath = "/storage/owner's files";
+$systemData = getSystemData($ssh, $defaultPath);
+check(in_array("LC_ALL=C df -Pk -- '/' 2>/dev/null", $ssh->commands, true), 'System filesystem uses root instead of a device name');
+check(in_array("LC_ALL=C df -Pk -- '/storage/owner'\\''s files' 2>/dev/null", $ssh->commands, true), 'Storage path is quoted for remote Linux even on Windows');
+
+// Exercise the actual page template with missing output and with valid output.
+$source = file_get_contents(__DIR__ . '/../Web/content/ftp/serverstat.php');
+$template = substr($source, strpos($source, '$monitors ='));
+foreach ([false, true] as $available) {
+ if ($available) {
+ $systemData['storage'] = ['system' => $disk, 'data' => $disk];
+ $systemData['resources'] = $resources;
+ $systemData['raid'] = parseRaidStatus($healthy);
+ }
+ ob_start();
+ eval($template);
+ $html = ob_get_clean();
+ check(strpos($html, 'Server status') !== false, 'Status page renders');
+ check($available ? strpos($html, '1 GiB') !== false : strpos($html, 'Storage statistics are unavailable') !== false, 'Template handles available and missing data');
+}
+echo "Server statistics: parser, RAID, command quoting and page rendering checks passed.\n";
diff --git a/tests/workspace_test.php b/tests/workspace_test.php
new file mode 100644
index 0000000..7daa995
--- /dev/null
+++ b/tests/workspace_test.php
@@ -0,0 +1,89 @@
+ 'michal', 'admin' => $admin, 'upPer' => true, 'downPer' => true, 'delPer' => true];
+ $defaultPath = '/mnt/raid'; $currentPath = '/mnt/raid/Workspace';
+ $filePath = $currentPath . '/Example.pdf'; $fileName = basename($filePath); $parentDir = $currentPath;
+ $fileExtension = 'png'; $fileSize = 245760; $mimeType = 'image/png';
+ $isImage = true; $isVideo = false; $isAudio = false;
+ $editable = true; $showLineNumbers = true; $content = "{\n \"workspace\": \"USB RAID\",\n \"status\": \"connected\"\n}\n";
+ $message = ''; $messageType = ''; $message_type = ''; $shareCreated = false; $shareError = '';
+ $share = ['expiration' => '2030-01-01']; $downloadAllowed = false; $token = 'test-only';
+ $items = ['Documents', 'Photos', 'README.md', 'Example.pdf'];
+ $imageExtensions = ['png']; $videoExtensions = []; $audioExtensions = []; $editableExtensions = ['md']; $archiveExtensions = [];
+ $sftp = new class {
+ function is_dir($name) { return strpos($name, '.') === false; }
+ function stat($path) { return ['size' => 245760]; }
+ };
+ $result = new class {
+ private $rows = [
+ ['uname' => 'michal', 'admin' => true, 'defPath' => '/mnt/raid', 'delPer' => true, 'downPer' => true, 'upPer' => true],
+ ['uname' => 'guest', 'admin' => false, 'defPath' => '/mnt/raid/shared', 'delPer' => false, 'downPer' => true, 'upPer' => false],
+ ];
+ function fetch_assoc() { return array_shift($this->rows); }
+ };
+ $systemData = [
+ 'storage' => array_fill_keys(['system', 'data'], parseDiskData("Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/md0 1048576 262144 786432 25% /mnt/raid\n")),
+ 'raid' => parseRaidStatus("md0 : active raid5 sda[0] sdb[1] sdc[2]\n 1000 blocks [3/3] [UUU]\n"),
+ 'resources' => parseSystemStats('93', '8192,2048,6144,1024,0,1024'), 'lastUpdate' => '2026-10-16 10:00:00',
+ ];
+ $top = in_array($name, ['adminpanel', 'changepassword'], true);
+ $source = file_get_contents(__DIR__ . '/../Web/content/' . ($top ? '' : 'ftp/') . $name . '.php');
+ if ($name === 'serverstat') {
+ $start = strpos($source, '$monitors =');
+ eval(substr($source, $start, strpos($source, '?>', $start) - $start));
+ }
+ $start = strpos($source, '');
+ $template = substr($source, $start, strpos($source, '', $start) + 7 - $start);
+ ob_start(); eval('?>' . $template); return ob_get_clean();
+}
+
+$preview = in_array('--preview', $argv, true);
+$directory = __DIR__ . '/../Web/.layout-preview';
+if ($preview && !is_dir($directory)) mkdir($directory);
+$pages = ['index', 'serverstat', 'adminpanel', 'changepassword', 'open', 'view', 'pdf', 'shared'];
+foreach ($pages as $name) {
+ $html = renderWorkspaceFixture($name);
+ $dom = new DOMDocument();
+ @$dom->loadHTML($html);
+ $xpath = new DOMXPath($dom);
+ expectWorkspace($xpath->query('//body')->length === 1, "$name has one body");
+ expectWorkspace($xpath->query('//main')->length === 1, "$name has one main region");
+ expectWorkspace($xpath->query('//aside[contains(@class,"sidebar")]')->length === 1, "$name has shared sidebar");
+ expectWorkspace($xpath->query('//*[@id="themeToggle"]')->length === 1, "$name has one theme switch");
+ expectWorkspace($xpath->query('//a[@aria-current="page"]')->length === 1, "$name has one active navigation item");
+ expectWorkspace($xpath->query('//main//footer')->length === 1, "$name footer is inside the main region");
+ if ($name === 'shared') expectWorkspace(strpos($html, 'User management') === false && strpos($html, 'Account settings') === false, 'Public shared preview does not expose account navigation');
+ if ($preview) {
+ $base = $name === 'adminpanel' || $name === 'changepassword' ? '../content/' : '../content/ftp/';
+ $html = str_replace('', '', $html);
+ // Browser previews never request a real remote file or perform mutations.
+ $image = 'data:image/svg+xml,' . rawurlencode('');
+ $html = preg_replace('/src="(?:view|shared)\.php\?[^"\n]+"/', 'src="' . $image . '"', $html);
+ $html = str_replace("const url = 'pdf.php?file=%2Fmnt%2Fraid%2FWorkspace%2FExample.pdf&stream=1';", "const url = '/Web/.layout-preview/sample.pdf';", $html);
+ file_put_contents($directory . '/' . $name . '.html', $html);
+ }
+}
+$member = renderWorkspaceFixture('index', false);
+expectWorkspace(strpos($member, 'User management') === false && strpos($member, '> Server status') === false, 'Member navigation omits admin pages');
+expectWorkspace(strpos($member, 'Account settings') !== false, 'Members can access account settings');
+if ($preview) {
+ // One-page PDF fixture for viewer navigation and fullscreen checks.
+ $stream = "BT /F1 24 Tf 60 700 Td (USB RAID workspace preview) Tj ET";
+ $objects = ['<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', '<< /Length ' . strlen($stream) . ">>\nstream\n" . $stream . "\nendstream"];
+ $pdf = "%PDF-1.4\n"; $offsets = [0];
+ foreach ($objects as $i => $object) { $offsets[] = strlen($pdf); $pdf .= ($i + 1) . " 0 obj\n$object\nendobj\n"; }
+ $xref = strlen($pdf); $pdf .= "xref\n0 6\n0000000000 65535 f \n";
+ foreach (array_slice($offsets, 1) as $offset) $pdf .= sprintf("%010d 00000 n \n", $offset);
+ $pdf .= "trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n$xref\n%%EOF";
+ file_put_contents($directory . '/sample.pdf', $pdf);
+}
+echo "Workspace: all eight page templates, navigation roles and shared layout checks passed.\n";