Replaces every native confirm() with a shared P.confirm() modal (a hung native dialog was found live to block the whole tab, including auto-refresh, and once even double-confirmed an unrelated deletion). Also fixes Edit Container silently resetting to Bridge/blanking the Static IP for any container on a custom network, and a context menu losing its anchor to a mid-read auto-refresh. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
678 lines
27 KiB
PHP
678 lines
27 KiB
PHP
<?php
|
|
/**
|
|
* ajax/templates.php
|
|
*
|
|
* Backs the Templates panel — reusable container configs, saved and
|
|
* loaded as XML in the same schema Unraid's own Docker Manager uses for
|
|
* its Community Applications templates (<Container version="2"> with
|
|
* <Config Type="Port"|"Path"|"Variable"> entries). Deliberately the SAME
|
|
* schema, not a podman-specific one of our own: it's the one users and
|
|
* template authors already know, and it means a template exported here
|
|
* carries over the fields (image, ports, paths, variables, icon,
|
|
* category, overview) a Docker template would too, even though the two
|
|
* ecosystems' XML isn't fully interchangeable (this plugin's Config
|
|
* entries don't have every attribute dockerMan's does, e.g. no GPU/USB/
|
|
* device passthrough yet — see docs/ARCHITECTURE.md section 18).
|
|
*
|
|
* Stored at $bootDir/templates/<name>.xml — same boot-persistence
|
|
* reasoning as compose/autostart/networks (see ajax/compose.php).
|
|
*
|
|
* Actions (?action=...):
|
|
* list GET -> [{name, image, icon, category, overview}, ...]
|
|
* get GET (&name=...) -> full parsed config, for prefilling the
|
|
* Create Container form ("Use template")
|
|
* export GET (&name=...) -> {xml: "<raw XML text>"} for download
|
|
* save POST {"name": "...", "image": "...", "icon": "...", "category": "...",
|
|
* "overview": "...", "webUrl": "...", "networkMode": "...", "privileged": false,
|
|
* "restartPolicy": "...", "ports": [...], "volumes": [...], "env": [...]}
|
|
* import POST {"xml": "<raw XML text>"} -> parses + saves as a new template
|
|
* import_url POST {"url": "https://..."} -> fetches the URL server-side
|
|
* (see template_fetch_url() for the SSRF protections this
|
|
* needs, since the URL comes straight from the client) and
|
|
* imports it the same way as `import`
|
|
* list_local GET -> [{file, name, image, icon}, ...] from Unraid's own
|
|
* dockerMan template directories (real existing Docker
|
|
* templates the user already has — see
|
|
* local_dockerman_templates_list()), for a "browse local
|
|
* templates" picker instead of copy-pasting XML by hand
|
|
* import_local POST {"file": "gitea.xml"} -> imports one by filename
|
|
* (validated against the same directories list_local
|
|
* scanned, never an arbitrary path from the client)
|
|
* apps_search GET (&q=...&page=1&sort=newest|alpha) -> {results:
|
|
* [{name, image, icon, overview, templateUrl}, ...],
|
|
* total, page, pageSize, feedUpdatedAt} — searches
|
|
* Community Applications' own public app feed (the same
|
|
* catalog CA's own plugin uses, see ca_feed_search()) by
|
|
* name, so a template doesn't have to already be known-by-
|
|
* URL or already sitting in dockerMan's local directories
|
|
* to import it. An empty/omitted q browses the whole feed
|
|
* instead of searching, ordered by `sort` (default
|
|
* "newest"); a non-empty q always sorts alphabetically
|
|
* regardless of `sort` (see ca_feed_search() for why).
|
|
* Importing a match is just `import_url` again with that
|
|
* result's templateUrl — no separate import path needed.
|
|
* remove POST {"name": "..."}
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* The public catalog Unraid's own Community Applications plugin is built
|
|
* on — one JSON file listing every CA app (name, repository, icon,
|
|
* overview, and critically TemplateURL, the same kind of link
|
|
* template_fetch_url() already knows how to fetch). ~20MB and only
|
|
* updated a few times a day upstream, so it's cached to a temp file
|
|
* rather than re-downloaded on every search keystroke.
|
|
*/
|
|
const CA_FEED_URL = 'https://raw.githubusercontent.com/Squidly271/AppFeed/master/applicationFeed.json';
|
|
const CA_FEED_MAX_AGE_SECONDS = 86400;
|
|
const CA_FEED_PAGE_SIZE = 24;
|
|
|
|
require __DIR__ . '/../include/bootstrap.php';
|
|
|
|
$templatesDir = $podmanConfig->bootDir . '/templates';
|
|
$action = $_GET['action'] ?? '';
|
|
|
|
switch ($action) {
|
|
case 'list':
|
|
podman_json_response(templates_list($templatesDir));
|
|
break;
|
|
|
|
case 'get':
|
|
podman_json_response(template_read(require_template_path($templatesDir, (string) ($_GET['name'] ?? ''))));
|
|
break;
|
|
|
|
case 'export':
|
|
$path = require_template_path($templatesDir, (string) ($_GET['name'] ?? ''));
|
|
podman_json_response(['xml' => file_get_contents($path)]);
|
|
break;
|
|
|
|
case 'save':
|
|
$body = podman_read_json_body();
|
|
$name = trim((string) ($body['name'] ?? ''));
|
|
if (!is_valid_template_name($name)) {
|
|
podman_json_error('Template name must be non-empty and contain only letters, digits, "-", "_".', 400);
|
|
}
|
|
template_write($templatesDir, $name, $body);
|
|
podman_json_response(['status' => 'saved', 'name' => $name]);
|
|
break;
|
|
|
|
case 'import':
|
|
$body = podman_read_json_body();
|
|
$xml = (string) ($body['xml'] ?? '');
|
|
if (trim($xml) === '') {
|
|
podman_json_error('Missing xml in request body', 400);
|
|
}
|
|
$name = template_import($templatesDir, $xml);
|
|
podman_json_response(['status' => 'imported', 'name' => $name]);
|
|
break;
|
|
|
|
case 'import_url':
|
|
$body = podman_read_json_body();
|
|
$url = trim((string) ($body['url'] ?? ''));
|
|
if ($url === '') {
|
|
podman_json_error('Missing url in request body', 400);
|
|
}
|
|
$name = template_import($templatesDir, template_fetch_url($url));
|
|
podman_json_response(['status' => 'imported', 'name' => $name]);
|
|
break;
|
|
|
|
case 'list_local':
|
|
podman_json_response(local_dockerman_templates_list());
|
|
break;
|
|
|
|
case 'import_local':
|
|
$body = podman_read_json_body();
|
|
$file = (string) ($body['file'] ?? '');
|
|
$name = template_import($templatesDir, local_dockerman_template_read($file));
|
|
podman_json_response(['status' => 'imported', 'name' => $name]);
|
|
break;
|
|
|
|
case 'apps_search':
|
|
$q = trim((string) ($_GET['q'] ?? ''));
|
|
if ($q !== '' && mb_strlen($q) < 2) {
|
|
podman_json_error('Search term must be at least 2 characters', 400);
|
|
}
|
|
$page = max(1, (int) ($_GET['page'] ?? 1));
|
|
$sort = (string) ($_GET['sort'] ?? 'newest');
|
|
podman_json_response(ca_feed_search($q, $page, $sort));
|
|
break;
|
|
|
|
case 'remove':
|
|
$body = podman_read_json_body();
|
|
$path = require_template_path($templatesDir, (string) ($body['name'] ?? ''));
|
|
unlink($path);
|
|
podman_json_response(['status' => 'removed']);
|
|
break;
|
|
|
|
default:
|
|
podman_json_error("Unknown action '{$action}'", 400);
|
|
}
|
|
|
|
/**
|
|
* Template names become filenames — restricted to a fixed safe character
|
|
* set BEFORE ever being used to build a filesystem path, the same
|
|
* pattern ajax/compose.php's is_valid_project_name() uses for the same
|
|
* reason.
|
|
*/
|
|
function is_valid_template_name(string $name): bool
|
|
{
|
|
return $name !== '' && preg_match('/^[a-zA-Z0-9_-]+$/', $name) === 1;
|
|
}
|
|
|
|
function require_template_path(string $templatesDir, string $name): string
|
|
{
|
|
if (!is_valid_template_name($name)) {
|
|
podman_json_error('Invalid template name', 400);
|
|
}
|
|
$path = $templatesDir . '/' . $name . '.xml';
|
|
if (!is_file($path)) {
|
|
podman_json_error("Template '{$name}' not found", 404);
|
|
}
|
|
return $path;
|
|
}
|
|
|
|
/** @return array<int,array<string,mixed>> */
|
|
function templates_list(string $templatesDir): array
|
|
{
|
|
if (!is_dir($templatesDir)) {
|
|
return [];
|
|
}
|
|
$out = [];
|
|
foreach (scandir($templatesDir) ?: [] as $entry) {
|
|
if (!str_ends_with($entry, '.xml')) {
|
|
continue;
|
|
}
|
|
$name = substr($entry, 0, -4);
|
|
if (!is_valid_template_name($name)) {
|
|
continue;
|
|
}
|
|
try {
|
|
$parsed = template_read($templatesDir . '/' . $entry);
|
|
} catch (\Throwable $e) {
|
|
continue; // a hand-edited/corrupt file shouldn't break the whole list
|
|
}
|
|
$out[] = [
|
|
'name' => $name,
|
|
'image' => $parsed['image'],
|
|
'icon' => $parsed['icon'],
|
|
'category' => $parsed['category'],
|
|
'overview' => $parsed['overview'],
|
|
];
|
|
}
|
|
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Parses one template XML file into the same shape
|
|
* ajax/containers.php's build_container_spec() (Create Container form)
|
|
* consumes, so "Use template" can feed straight into that form/action.
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
function template_read(string $path): array
|
|
{
|
|
$xml = @simplexml_load_file($path);
|
|
if ($xml === false) {
|
|
podman_json_error("Could not parse template XML: {$path}", 500);
|
|
}
|
|
|
|
$ports = [];
|
|
$volumes = [];
|
|
$env = [];
|
|
foreach ($xml->Config as $cfg) {
|
|
$attrs = $cfg->attributes();
|
|
$type = (string) ($attrs['Type'] ?? '');
|
|
$value = trim((string) $cfg);
|
|
$target = (string) ($attrs['Target'] ?? '');
|
|
if ($value === '') {
|
|
continue;
|
|
}
|
|
switch ($type) {
|
|
case 'Port':
|
|
$ports[] = [
|
|
'hostPort' => (int) $value,
|
|
'containerPort' => (int) ($target !== '' ? $target : $value),
|
|
'protocol' => (string) ($attrs['Mode'] ?? 'tcp') ?: 'tcp',
|
|
];
|
|
break;
|
|
case 'Path':
|
|
$volumes[] = [
|
|
'kind' => 'path',
|
|
'source' => $value,
|
|
'containerPath' => $target !== '' ? $target : $value,
|
|
// dockerMan's own convention for a read-only bind — this
|
|
// plugin's own exports write it the same way (see
|
|
// template_write() below), and real CA templates use it
|
|
// too (e.g. a storage-stats sidecar mounting /mnt/user
|
|
// read-only rather than read-write for no reason).
|
|
'readOnly' => strtolower((string) ($attrs['Mode'] ?? 'rw')) === 'ro',
|
|
];
|
|
break;
|
|
case 'Variable':
|
|
$env[] = ['key' => $target !== '' ? $target : (string) ($attrs['Name'] ?? ''), 'value' => $value];
|
|
break;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'image' => (string) $xml->Repository,
|
|
'networkMode' => (string) ($xml->Network ?: 'bridge'),
|
|
'privileged' => strtolower((string) $xml->Privileged) === 'true',
|
|
'icon' => (string) $xml->Icon,
|
|
'category' => (string) $xml->Category,
|
|
'overview' => (string) $xml->Overview,
|
|
'webUrl' => (string) $xml->WebUI,
|
|
'ports' => $ports,
|
|
'volumes' => $volumes,
|
|
'env' => $env,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Writes a template XML file from the Create Container form's field
|
|
* shapes (same as build_container_spec() in ajax/containers.php takes)
|
|
* plus template-only metadata (icon/category/overview). Uses DOMDocument
|
|
* rather than string concatenation so every value is properly escaped —
|
|
* no risk of a "<" or "&" in an image name/description breaking the XML.
|
|
*
|
|
* @param array<string,mixed> $body
|
|
*/
|
|
function template_write(string $templatesDir, string $name, array $body): void
|
|
{
|
|
if (!is_dir($templatesDir) && !mkdir($templatesDir, 0755, true) && !is_dir($templatesDir)) {
|
|
podman_json_error("Could not create {$templatesDir}", 500);
|
|
}
|
|
|
|
$doc = new \DOMDocument('1.0');
|
|
$doc->formatOutput = true;
|
|
$root = $doc->createElement('Container');
|
|
$root->setAttribute('version', '2');
|
|
$doc->appendChild($root);
|
|
|
|
$append = static function (string $tag, string $value) use ($doc, $root): void {
|
|
$root->appendChild($doc->createElement($tag, $value));
|
|
};
|
|
$append('Name', $name);
|
|
$append('Repository', trim((string) ($body['image'] ?? '')));
|
|
$append('Network', (string) ($body['networkMode'] ?? 'bridge'));
|
|
$append('Privileged', ($body['privileged'] ?? false) ? 'true' : 'false');
|
|
$append('Overview', (string) ($body['overview'] ?? ''));
|
|
$append('Category', (string) ($body['category'] ?? ''));
|
|
$append('Icon', (string) ($body['icon'] ?? ''));
|
|
$append('WebUI', (string) ($body['webUrl'] ?? ''));
|
|
|
|
foreach (($body['ports'] ?? []) as $row) {
|
|
$hostPort = (string) ($row['hostPort'] ?? '');
|
|
$containerPort = (string) ($row['containerPort'] ?? '');
|
|
if ($hostPort === '' || $containerPort === '') {
|
|
continue;
|
|
}
|
|
$cfg = $doc->createElement('Config', $hostPort);
|
|
$cfg->setAttribute('Name', 'Port ' . $containerPort);
|
|
$cfg->setAttribute('Target', $containerPort);
|
|
$cfg->setAttribute('Mode', (string) ($row['protocol'] ?? 'tcp'));
|
|
$cfg->setAttribute('Type', 'Port');
|
|
$root->appendChild($cfg);
|
|
}
|
|
foreach (($body['volumes'] ?? []) as $row) {
|
|
$source = (string) ($row['source'] ?? '');
|
|
$containerPath = (string) ($row['containerPath'] ?? '');
|
|
if ($source === '' || $containerPath === '' || ($row['kind'] ?? 'named') !== 'path') {
|
|
continue; // named (podman-managed) volumes aren't portable across hosts, so templates only capture host-path binds
|
|
}
|
|
$cfg = $doc->createElement('Config', $source);
|
|
$cfg->setAttribute('Name', basename($containerPath));
|
|
$cfg->setAttribute('Target', $containerPath);
|
|
$cfg->setAttribute('Mode', ($row['readOnly'] ?? false) ? 'ro' : 'rw');
|
|
$cfg->setAttribute('Type', 'Path');
|
|
$root->appendChild($cfg);
|
|
}
|
|
foreach (($body['env'] ?? []) as $row) {
|
|
$key = (string) ($row['key'] ?? '');
|
|
if ($key === '') {
|
|
continue;
|
|
}
|
|
$cfg = $doc->createElement('Config', (string) ($row['value'] ?? ''));
|
|
$cfg->setAttribute('Name', $key);
|
|
$cfg->setAttribute('Target', $key);
|
|
$cfg->setAttribute('Type', 'Variable');
|
|
$root->appendChild($cfg);
|
|
}
|
|
|
|
if ($doc->save($templatesDir . '/' . $name . '.xml') === false) {
|
|
podman_json_error("Could not write {$templatesDir}/{$name}.xml", 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Imports a pasted/uploaded template XML (this plugin's own schema, or
|
|
* a plain Unraid dockerMan template — both use the same <Container>/
|
|
* <Config> shape). Validates it parses and has a usable <Name> before
|
|
* writing it under our own templates dir with a sanitized filename.
|
|
*/
|
|
function template_import(string $templatesDir, string $xmlText): string
|
|
{
|
|
$xml = @simplexml_load_string($xmlText);
|
|
if ($xml === false || $xml->getName() !== 'Container') {
|
|
podman_json_error('Not a valid template XML (expected a <Container> root element)', 400);
|
|
}
|
|
|
|
$rawName = trim((string) $xml->Name);
|
|
$name = preg_replace('/[^a-zA-Z0-9_-]/', '-', $rawName);
|
|
if (!is_valid_template_name((string) $name)) {
|
|
podman_json_error('Template XML has no usable <Name>', 400);
|
|
}
|
|
|
|
if (!is_dir($templatesDir) && !mkdir($templatesDir, 0755, true) && !is_dir($templatesDir)) {
|
|
podman_json_error("Could not create {$templatesDir}", 500);
|
|
}
|
|
if (file_put_contents($templatesDir . '/' . $name . '.xml', $xmlText, LOCK_EX) === false) {
|
|
podman_json_error("Could not write {$templatesDir}/{$name}.xml", 500);
|
|
}
|
|
return (string) $name;
|
|
}
|
|
|
|
/**
|
|
* Fetches a template XML from a user-supplied URL for "Import from URL".
|
|
* Only plain http(s) with a hostname that resolves EXCLUSIVELY to public
|
|
* addresses is allowed — checked with FILTER_FLAG_NO_PRIV_RANGE |
|
|
* FILTER_FLAG_NO_RES_RANGE, which also covers loopback/link-local, not
|
|
* just RFC1918. curl is then pinned to that exact validated IP via
|
|
* CURLOPT_RESOLVE rather than letting it resolve the host itself again:
|
|
* resolving once here and connecting separately would leave a window for
|
|
* a DNS answer that changes between the check and the request (DNS
|
|
* rebinding) to point curl at an internal address anyway. Redirects are
|
|
* not followed for the same reason — a redirect target needs this same
|
|
* validation, and silently trusting one would reopen the hole this whole
|
|
* function exists to close. Response size is capped well above what any
|
|
* real template XML needs.
|
|
*/
|
|
function template_fetch_url(string $url): string
|
|
{
|
|
$parts = parse_url($url);
|
|
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
|
|
$host = (string) ($parts['host'] ?? '');
|
|
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
|
|
podman_json_error('URL must be a plain http:// or https:// address', 400);
|
|
}
|
|
$port = (int) ($parts['port'] ?? ($scheme === 'https' ? 443 : 80));
|
|
|
|
$ips = [];
|
|
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
|
$ips[] = $host;
|
|
} else {
|
|
foreach (@dns_get_record($host, DNS_A + DNS_AAAA) ?: [] as $rec) {
|
|
$ip = $rec['type'] === 'AAAA' ? ($rec['ipv6'] ?? '') : ($rec['ip'] ?? '');
|
|
if ($ip !== '') {
|
|
$ips[] = $ip;
|
|
}
|
|
}
|
|
}
|
|
if (empty($ips)) {
|
|
podman_json_error("Could not resolve host '{$host}'", 400);
|
|
}
|
|
foreach ($ips as $ip) {
|
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
|
|
podman_json_error("Refusing to fetch from '{$host}': resolves to a private/reserved address ({$ip})", 400);
|
|
}
|
|
}
|
|
|
|
$maxBytes = 512 * 1024; // a template XML is a few KB; this is generous headroom
|
|
$received = '';
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RESOLVE => ["{$host}:{$port}:{$ips[0]}"],
|
|
CURLOPT_RETURNTRANSFER => false,
|
|
CURLOPT_FOLLOWLOCATION => false,
|
|
CURLOPT_TIMEOUT => 10,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
|
CURLOPT_HTTPHEADER => ['Accept: application/xml, text/xml, */*'],
|
|
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$received, $maxBytes) {
|
|
$received .= $chunk;
|
|
return strlen($received) > $maxBytes ? 0 : strlen($chunk);
|
|
},
|
|
]);
|
|
curl_exec($ch);
|
|
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
$error = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($status >= 300 && $status < 400) {
|
|
podman_json_error('The URL returned a redirect — fetch its final target URL directly instead (redirects are not followed here, to keep this from becoming a way around the checks above).', 400);
|
|
}
|
|
if ($status !== 200) {
|
|
podman_json_error('Fetching the URL failed (HTTP ' . $status . ($error !== '' ? ": {$error}" : '') . ')', 400);
|
|
}
|
|
if (trim($received) === '') {
|
|
podman_json_error('The URL returned an empty response', 400);
|
|
}
|
|
return $received;
|
|
}
|
|
|
|
/**
|
|
* Path of the cached CA app feed. Deliberately sys_get_temp_dir(), not
|
|
* $bootDir — this is a large (~20MB), frequently-refreshed derived
|
|
* artifact, not source-of-truth config, so it doesn't belong on the flash
|
|
* drive (see ARCHITECTURE.md 4.1 on what belongs on /boot vs. not) and is
|
|
* fine to just re-download after a reboot.
|
|
*/
|
|
function ca_feed_cache_path(): string
|
|
{
|
|
return sys_get_temp_dir() . '/podman-ca-appfeed.json';
|
|
}
|
|
|
|
/**
|
|
* Downloads the CA feed if the cache is missing or older than
|
|
* CA_FEED_MAX_AGE_SECONDS, returning the path to a usable (possibly
|
|
* stale) cache file. A failed download falls back to a stale cache
|
|
* rather than failing the search outright — an out-of-date app list is
|
|
* still far more useful than none, and the feed realistically doesn't
|
|
* change meaningfully within a day anyway.
|
|
*/
|
|
function ca_feed_ensure_cached(): string
|
|
{
|
|
$path = ca_feed_cache_path();
|
|
if (is_file($path) && (time() - filemtime($path)) < CA_FEED_MAX_AGE_SECONDS) {
|
|
return $path;
|
|
}
|
|
|
|
$tmpPath = $path . '.' . getmypid() . '.tmp';
|
|
$fh = fopen($tmpPath, 'wb');
|
|
if ($fh === false) {
|
|
if (is_file($path)) {
|
|
return $path;
|
|
}
|
|
podman_json_error('Could not write app feed cache', 500);
|
|
}
|
|
|
|
$ch = curl_init(CA_FEED_URL);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_FILE => $fh,
|
|
CURLOPT_TIMEOUT => 30,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
|
]);
|
|
$ok = curl_exec($ch);
|
|
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
curl_close($ch);
|
|
fclose($fh);
|
|
|
|
if (!$ok || $status !== 200 || filesize($tmpPath) < 1000) {
|
|
@unlink($tmpPath);
|
|
if (is_file($path)) {
|
|
return $path;
|
|
}
|
|
podman_json_error('Could not download the Community Applications feed', 502);
|
|
}
|
|
rename($tmpPath, $path);
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* Searches the CA feed by name (and its ExtraSearchTerms, the same field
|
|
* CA's own search matches against) and returns one page of just enough
|
|
* per result to render a picker + import it — never the raw feed itself,
|
|
* which is far too large to ship to the client for what is, per page, a
|
|
* couple dozen entries.
|
|
*
|
|
* An empty query is "browse" mode instead of "search": ordered by
|
|
* $sort — "newest" (default, by the feed's own FirstSeen timestamp — when
|
|
* CA's feed first picked the template up) or "alpha". A non-empty query
|
|
* always sorts alphabetically regardless of $sort: the feed's "downloads"
|
|
* figure (the only other candidate) turns out to just be the underlying
|
|
* Docker image's Docker Hub pull count (verified live: dozens of unrelated
|
|
* templates that all happen to wrap the official nginx/postgres/redis
|
|
* images share the exact same, enormous number), which would make search
|
|
* results look ranked by relevance while actually just favoring whichever
|
|
* match wraps the most-pulled base image.
|
|
*
|
|
* @return array{results: array<int,array<string,string>>, total: int, page: int, pageSize: int, feedUpdatedAt: int}
|
|
*/
|
|
function ca_feed_search(string $query, int $page = 1, string $sort = 'newest'): array
|
|
{
|
|
$path = ca_feed_ensure_cached();
|
|
|
|
// Parsing ~20MB of JSON into PHP arrays needs more headroom than the
|
|
// default limit on some setups; scoped to this request only.
|
|
ini_set('memory_limit', '256M');
|
|
|
|
$data = json_decode((string) file_get_contents($path), true);
|
|
$apps = is_array($data) ? ($data['applist'] ?? []) : [];
|
|
|
|
$needle = mb_strtolower($query);
|
|
$matches = [];
|
|
foreach ($apps as $app) {
|
|
$name = (string) ($app['Name'] ?? '');
|
|
$templateUrl = (string) ($app['TemplateURL'] ?? '');
|
|
if ($name === '' || $templateUrl === '') {
|
|
continue;
|
|
}
|
|
// CA's feed lists real Unraid OS plugins (.plg installers — system
|
|
// add-ons like GPU drivers, mover tuning, Unraid Connect) alongside
|
|
// actual Docker app templates, both under the same "applist" — the
|
|
// giveaway is a Repository ending in ".plg" instead of a Docker
|
|
// image reference (verified against every one of the 268 entries
|
|
// tagged with CA's own "Plugins" category — every single one had
|
|
// exactly this). Podman only runs containers, so these would just
|
|
// fail nonsensically if "installed" as one.
|
|
if (str_ends_with(strtolower((string) ($app['Repository'] ?? '')), '.plg')) {
|
|
continue;
|
|
}
|
|
if ($needle !== '') {
|
|
$haystack = mb_strtolower($name . ' ' . (string) ($app['ExtraSearchTerms'] ?? ''));
|
|
if (mb_strpos($haystack, $needle) === false) {
|
|
continue;
|
|
}
|
|
}
|
|
// CA's own overview text uses forum-style bbcode ([b]/[br]/[li]/…)
|
|
// rather than plain text or HTML — strip the tags for a clean,
|
|
// short plain-text excerpt instead of showing the raw markup.
|
|
$overview = trim((string) preg_replace('/\s+/', ' ', (string) preg_replace('/\[[^\]]*\]/', ' ', (string) ($app['Overview'] ?? ''))));
|
|
if (mb_strlen($overview) > 140) {
|
|
$overview = mb_substr($overview, 0, 137) . '…';
|
|
}
|
|
$matches[] = [
|
|
'name' => $name,
|
|
'image' => (string) ($app['Repository'] ?? ''),
|
|
'icon' => (string) ($app['Icon'] ?? ''),
|
|
'overview' => $overview,
|
|
'templateUrl' => $templateUrl,
|
|
'firstSeen' => (int) ($app['FirstSeen'] ?? 0),
|
|
];
|
|
}
|
|
|
|
if ($needle !== '' || $sort === 'alpha') {
|
|
usort($matches, static fn($a, $b) => strcasecmp($a['name'], $b['name']));
|
|
} else {
|
|
usort($matches, static fn($a, $b) => $b['firstSeen'] <=> $a['firstSeen']);
|
|
}
|
|
|
|
$total = count($matches);
|
|
$pageSize = CA_FEED_PAGE_SIZE;
|
|
$lastPage = max(1, (int) ceil($total / $pageSize));
|
|
$page = min(max(1, $page), $lastPage);
|
|
$slice = array_slice($matches, ($page - 1) * $pageSize, $pageSize);
|
|
foreach ($slice as &$m) {
|
|
unset($m['firstSeen']);
|
|
}
|
|
unset($m);
|
|
|
|
return [
|
|
'results' => $slice,
|
|
'total' => $total,
|
|
'page' => $page,
|
|
'pageSize' => $pageSize,
|
|
'feedUpdatedAt' => filemtime($path),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Unraid's own Docker Manager plugin stores every template a user has
|
|
* ever saved/customized under templates-user/, plus a local cache of
|
|
* Community Applications' own catalog under templates-community/ (may
|
|
* be empty/absent if CA was never installed) — both in the same
|
|
* <Container>/<Config> XML shape this plugin reads. Read-only: this
|
|
* plugin never writes into dockerMan's own directories.
|
|
*
|
|
* @return array<int,string>
|
|
*/
|
|
function dockerman_template_dirs(): array
|
|
{
|
|
return array_values(array_filter([
|
|
'/boot/config/plugins/dockerMan/templates-user',
|
|
'/boot/config/plugins/dockerMan/templates-community',
|
|
], 'is_dir'));
|
|
}
|
|
|
|
/** @return array<int,array<string,string>> */
|
|
function local_dockerman_templates_list(): array
|
|
{
|
|
$out = [];
|
|
foreach (dockerman_template_dirs() as $dir) {
|
|
foreach (scandir($dir) ?: [] as $entry) {
|
|
if (!str_ends_with($entry, '.xml')) {
|
|
continue;
|
|
}
|
|
$xml = @simplexml_load_file($dir . '/' . $entry);
|
|
if ($xml === false) {
|
|
continue; // skip anything that doesn't parse rather than failing the whole list
|
|
}
|
|
$out[] = [
|
|
'file' => $entry,
|
|
'name' => (string) $xml->Name,
|
|
'image' => (string) $xml->Repository,
|
|
'icon' => (string) $xml->Icon,
|
|
];
|
|
}
|
|
}
|
|
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Reads one local dockerMan template's raw XML by filename. $file comes
|
|
* straight from client input — reduced to its basename and required to
|
|
* actually exist in one of dockerman_template_dirs() (the same list
|
|
* list_local scanned), never treated as an arbitrary path.
|
|
*/
|
|
function local_dockerman_template_read(string $file): string
|
|
{
|
|
$file = basename($file);
|
|
if (!str_ends_with($file, '.xml')) {
|
|
podman_json_error('Invalid template file', 400);
|
|
}
|
|
foreach (dockerman_template_dirs() as $dir) {
|
|
$path = $dir . '/' . $file;
|
|
if (is_file($path)) {
|
|
$content = file_get_contents($path);
|
|
if ($content !== false) {
|
|
return $content;
|
|
}
|
|
}
|
|
}
|
|
podman_json_error("Local template '{$file}' not found", 404);
|
|
}
|