mirror of
https://github.com/unraid/api.git
synced 2026-01-06 00:30:22 -06:00
feat: mount vue apps, not web components (#1639)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Standalone web bundle with auto-mount utilities and a self-contained test page. * New responsive modal components for consistent mobile/desktop dialogs. * Header actions to copy OS/API versions. * **Improvements** * Refreshed UI styles (muted borders), accessibility and animation refinements. * Theming updates and Tailwind v4–aligned, component-scoped styles. * Runtime GraphQL endpoint override and CSRF header support. * **Bug Fixes** * Safer network fetching and improved manifest/asset loading with duplicate protection. * **Tests/Chores** * Parallel plugin tests, new extractor test suite, and updated build/test scripts. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -4,10 +4,6 @@ $docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
|
||||
class WebComponentsExtractor
|
||||
{
|
||||
private const PREFIXED_PATH = '/plugins/dynamix.my.servers/unraid-components/';
|
||||
private const RICH_COMPONENTS_ENTRY = 'unraid-components.client.mjs';
|
||||
private const RICH_COMPONENTS_ENTRY_JS = 'unraid-components.client.js';
|
||||
private const UI_ENTRY = 'src/register.ts';
|
||||
private const UI_STYLES_ENTRY = 'style.css';
|
||||
|
||||
private static ?WebComponentsExtractor $instance = null;
|
||||
|
||||
@@ -21,15 +17,6 @@ class WebComponentsExtractor
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function findManifestFiles(string $manifestName): array
|
||||
{
|
||||
$basePath = '/usr/local/emhttp' . self::PREFIXED_PATH;
|
||||
$escapedBasePath = escapeshellarg($basePath);
|
||||
$escapedManifestName = escapeshellarg($manifestName);
|
||||
$command = "find {$escapedBasePath} -name {$escapedManifestName}";
|
||||
exec($command, $files);
|
||||
return $files;
|
||||
}
|
||||
|
||||
public function getAssetPath(string $asset, string $subfolder = ''): string
|
||||
{
|
||||
@@ -42,6 +29,11 @@ class WebComponentsExtractor
|
||||
$relative = str_replace($basePath, '', $fullPath);
|
||||
return dirname($relative);
|
||||
}
|
||||
|
||||
private function sanitizeForId(string $input): string
|
||||
{
|
||||
return preg_replace('/[^a-zA-Z0-9-]/', '-', $input);
|
||||
}
|
||||
|
||||
public function getManifestContents(string $manifestPath): array
|
||||
{
|
||||
@@ -49,117 +41,108 @@ class WebComponentsExtractor
|
||||
return $contents ? json_decode($contents, true) : [];
|
||||
}
|
||||
|
||||
|
||||
private function getRichComponentsFile(): string
|
||||
private function processManifestFiles(): string
|
||||
{
|
||||
$manifestFiles = $this->findManifestFiles('manifest.json');
|
||||
// Find all manifest files (*.manifest.json or manifest.json)
|
||||
$manifestFiles = $this->findAllManifestFiles();
|
||||
|
||||
if (empty($manifestFiles)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$scripts = [];
|
||||
|
||||
// Process each manifest file
|
||||
foreach ($manifestFiles as $manifestPath) {
|
||||
$manifest = $this->getManifestContents($manifestPath);
|
||||
if (empty($manifest)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$subfolder = $this->getRelativePath($manifestPath);
|
||||
|
||||
foreach ($manifest as $key => $value) {
|
||||
// Skip timestamp entries
|
||||
if ($key === 'ts' || !is_array($value)) {
|
||||
// Process each entry in the manifest
|
||||
foreach ($manifest as $key => $entry) {
|
||||
// Skip if not an array with a 'file' key
|
||||
if (!is_array($entry) || !isset($entry['file']) || empty($entry['file'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for both old format (direct key match) and new format (path-based key)
|
||||
$matchesMjs = strpos($key, self::RICH_COMPONENTS_ENTRY) !== false;
|
||||
$matchesJs = strpos($key, self::RICH_COMPONENTS_ENTRY_JS) !== false;
|
||||
// Build the file path
|
||||
$filePath = ($subfolder ? $subfolder . '/' : '') . $entry['file'];
|
||||
$fullPath = $this->getAssetPath($filePath);
|
||||
|
||||
if (($matchesMjs || $matchesJs) && isset($value["file"])) {
|
||||
return ($subfolder ? $subfolder . '/' : '') . $value["file"];
|
||||
// Determine file type and generate appropriate tag
|
||||
$extension = pathinfo($entry['file'], PATHINFO_EXTENSION);
|
||||
|
||||
// Sanitize subfolder and key for ID generation
|
||||
$sanitizedSubfolder = $subfolder ? $this->sanitizeForId($subfolder) . '-' : '';
|
||||
$sanitizedKey = $this->sanitizeForId($key);
|
||||
|
||||
// Escape attributes for HTML safety
|
||||
$safeId = htmlspecialchars('unraid-' . $sanitizedSubfolder . $sanitizedKey, ENT_QUOTES, 'UTF-8');
|
||||
$safePath = htmlspecialchars($fullPath, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
if ($extension === 'js' || $extension === 'mjs') {
|
||||
// Generate script tag with unique ID based on subfolder and key
|
||||
$scripts[] = '<script id="' . $safeId . '" type="module" src="' . $safePath . '" data-unraid="1"></script>';
|
||||
// Also emit any CSS referenced by this entry (Vite manifest "css": [])
|
||||
if (!empty($entry['css']) && is_array($entry['css'])) {
|
||||
foreach ($entry['css'] as $cssFile) {
|
||||
if (!is_string($cssFile) || $cssFile === '') continue;
|
||||
$cssPath = ($subfolder ? $subfolder . '/' : '') . $cssFile;
|
||||
$cssFull = $this->getAssetPath($cssPath);
|
||||
$cssId = htmlspecialchars(
|
||||
'unraid-' . $sanitizedSubfolder . $sanitizedKey . '-css-' . $this->sanitizeForId(basename($cssFile)),
|
||||
ENT_QUOTES,
|
||||
'UTF-8'
|
||||
);
|
||||
$cssHref = htmlspecialchars($cssFull, ENT_QUOTES, 'UTF-8');
|
||||
$scripts[] = '<link id="' . $cssId . '" rel="stylesheet" href="' . $cssHref . '" data-unraid="1">';
|
||||
}
|
||||
}
|
||||
} elseif ($extension === 'css') {
|
||||
// Generate link tag for CSS files with unique ID
|
||||
$scripts[] = '<link id="' . $safeId . '" rel="stylesheet" href="' . $safePath . '" data-unraid="1">';
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function getRichComponentsScript(): string
|
||||
{
|
||||
$jsFile = $this->getRichComponentsFile();
|
||||
if (empty($jsFile)) {
|
||||
return '<script>console.error("%cNo matching key containing \'' . self::RICH_COMPONENTS_ENTRY . '\' or \'' . self::RICH_COMPONENTS_ENTRY_JS . '\' found.", "font-weight: bold; color: white; background-color: red");</script>';
|
||||
|
||||
if (empty($scripts)) {
|
||||
return '';
|
||||
}
|
||||
// Add a unique identifier to prevent duplicate script loading
|
||||
$scriptId = 'unraid-rich-components-script';
|
||||
return '<script id="' . $scriptId . '" src="' . $this->getAssetPath($jsFile) . '"></script>
|
||||
|
||||
// Add deduplication script
|
||||
$deduplicationScript = '
|
||||
<script>
|
||||
// Remove duplicate script tags to prevent multiple loads
|
||||
// Remove duplicate resource tags to prevent multiple loads
|
||||
(function() {
|
||||
var scripts = document.querySelectorAll(\'script[id="' . $scriptId . '"]\');
|
||||
if (scripts.length > 1) {
|
||||
for (var i = 1; i < scripts.length; i++) {
|
||||
scripts[i].remove();
|
||||
var elements = document.querySelectorAll(\'[data-unraid="1"]\');
|
||||
var seen = {};
|
||||
for (var i = 0; i < elements.length; i++) {
|
||||
var el = elements[i];
|
||||
if (seen[el.id]) {
|
||||
el.remove();
|
||||
} else {
|
||||
seen[el.id] = true;
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>';
|
||||
|
||||
return implode("\n", $scripts) . $deduplicationScript;
|
||||
}
|
||||
|
||||
private function getUnraidUiScriptHtml(): string
|
||||
|
||||
private function findAllManifestFiles(): array
|
||||
{
|
||||
$manifestFiles = $this->findManifestFiles('ui.manifest.json');
|
||||
$basePath = '/usr/local/emhttp' . self::PREFIXED_PATH;
|
||||
$escapedBasePath = escapeshellarg($basePath);
|
||||
|
||||
if (empty($manifestFiles)) {
|
||||
error_log("No ui.manifest.json found");
|
||||
return '';
|
||||
}
|
||||
|
||||
$manifestPath = $manifestFiles[0]; // Use the first found manifest
|
||||
$manifest = $this->getManifestContents($manifestPath);
|
||||
$subfolder = $this->getRelativePath($manifestPath);
|
||||
|
||||
if (!isset($manifest[self::UI_ENTRY]) || !isset($manifest[self::UI_STYLES_ENTRY])) {
|
||||
error_log("Required entries not found in ui.manifest.json");
|
||||
return '';
|
||||
}
|
||||
|
||||
$jsFile = ($subfolder ? $subfolder . '/' : '') . $manifest[self::UI_ENTRY]['file'];
|
||||
$cssFile = ($subfolder ? $subfolder . '/' : '') . $manifest[self::UI_STYLES_ENTRY]['file'];
|
||||
// Find all files ending with .manifest.json or exactly named manifest.json
|
||||
$command = "find {$escapedBasePath} -type f \\( -name '*.manifest.json' -o -name 'manifest.json' \\) 2>/dev/null";
|
||||
exec($command, $files);
|
||||
|
||||
// Read the CSS file content
|
||||
$cssPath = '/usr/local/emhttp' . $this->getAssetPath($cssFile);
|
||||
$cssContent = @file_get_contents($cssPath);
|
||||
|
||||
if ($cssContent === false) {
|
||||
error_log("Failed to read CSS file: " . $cssPath);
|
||||
$cssContent = '';
|
||||
}
|
||||
|
||||
// Escape the CSS content for JavaScript
|
||||
$escapedCssContent = json_encode($cssContent);
|
||||
|
||||
// Use a data attribute to ensure this only runs once per page
|
||||
return '<script data-unraid-ui-register defer type="module">
|
||||
(async function() {
|
||||
// Check if components have already been registered
|
||||
if (window.__unraidUiComponentsRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as registered immediately to prevent race conditions
|
||||
window.__unraidUiComponentsRegistered = true;
|
||||
|
||||
try {
|
||||
const { registerAllComponents } = await import("' . $this->getAssetPath($jsFile) . '");
|
||||
registerAllComponents({ sharedCssContent: ' . $escapedCssContent . ' });
|
||||
} catch (error) {
|
||||
console.error("[Unraid UI] Failed to register components:", error);
|
||||
// Reset flag on error so it can be retried
|
||||
window.__unraidUiComponentsRegistered = false;
|
||||
}
|
||||
|
||||
// Clean up duplicate script tags
|
||||
const scripts = document.querySelectorAll(\'script[data-unraid-ui-register]\');
|
||||
if (scripts.length > 1) {
|
||||
for (let i = 1; i < scripts.length; i++) {
|
||||
scripts[i].remove();
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>';
|
||||
return $files;
|
||||
}
|
||||
|
||||
public function getScriptTagHtml(): string
|
||||
@@ -168,12 +151,12 @@ class WebComponentsExtractor
|
||||
static $scriptsOutput = false;
|
||||
|
||||
if ($scriptsOutput) {
|
||||
return '<!-- Web components scripts already loaded -->';
|
||||
return '<!-- Resources already loaded -->';
|
||||
}
|
||||
|
||||
try {
|
||||
$scriptsOutput = true;
|
||||
return $this->getRichComponentsScript() . $this->getUnraidUiScriptHtml();
|
||||
return $this->processManifestFiles();
|
||||
} catch (\Exception $e) {
|
||||
error_log("Error in WebComponentsExtractor::getScriptTagHtml: " . $e->getMessage());
|
||||
$scriptsOutput = false; // Reset on error
|
||||
|
||||
@@ -100,6 +100,57 @@ class UnraidOsCheck
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe version of http_get_contents that falls back to system function if defined
|
||||
* @param string $url The URL to fetch
|
||||
* @param array $opts Array of options to pass to curl_setopt()
|
||||
* @param array $getinfo Empty array passed by reference, will contain results of curl_getinfo and curl_error
|
||||
* Note: CURLINFO_HEADER_OUT exposes request headers (not response headers) for curl_getinfo
|
||||
* @return string|false $out The fetched content
|
||||
*/
|
||||
private function safe_http_get_contents(string $url, array $opts = [], array &$getinfo = NULL) {
|
||||
// Use system http_get_contents if it exists
|
||||
if (function_exists('http_get_contents')) {
|
||||
return http_get_contents($url, $opts, $getinfo);
|
||||
}
|
||||
|
||||
// Otherwise use our implementation
|
||||
$ch = curl_init();
|
||||
if(isset($getinfo)) {
|
||||
curl_setopt($ch, CURLINFO_HEADER_OUT, TRUE);
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_FRESH_CONNECT, true);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 45);
|
||||
curl_setopt($ch, CURLOPT_ENCODING, "");
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_REFERER, "");
|
||||
curl_setopt($ch, CURLOPT_FAILONERROR, true);
|
||||
// Explicitly enforce TLS verification
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||
if(is_array($opts) && $opts) {
|
||||
foreach($opts as $key => $val) {
|
||||
curl_setopt($ch, $key, $val);
|
||||
}
|
||||
}
|
||||
$out = curl_exec($ch);
|
||||
if(isset($getinfo)) {
|
||||
$getinfo = curl_getinfo($ch);
|
||||
}
|
||||
if (curl_errno($ch)) {
|
||||
$msg = curl_error($ch) . " {$url}";
|
||||
if(isset($getinfo)) {
|
||||
$getinfo['error'] = $msg;
|
||||
}
|
||||
my_logger($msg, "safe_http_get_contents");
|
||||
}
|
||||
curl_close($ch);
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @todo clean up this method to be more extensible */
|
||||
public function checkForUpdate()
|
||||
{
|
||||
@@ -136,7 +187,7 @@ class UnraidOsCheck
|
||||
$urlbase = $parsedAltUrl ?? $defaultUrl;
|
||||
$url = $urlbase.'?'.http_build_query($params);
|
||||
$curlinfo = [];
|
||||
$response = http_get_contents($url,[],$curlinfo);
|
||||
$response = $this->safe_http_get_contents($url,[],$curlinfo);
|
||||
if (array_key_exists('error', $curlinfo)) {
|
||||
$response = json_encode(array('error' => $curlinfo['error']), JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user