Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/Assets/JsAssetEntry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
*/

namespace Horde\Core\Assets;

/**
* A single resolved JavaScript asset: its filesystem path, public URI and
* the application it was resolved against.
*
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
*/
final class JsAssetEntry
{
public function __construct(
public readonly string $fsPath,
public readonly string $uri,
public readonly ?string $app = null,
) {}
}
7 changes: 7 additions & 0 deletions src/Assets/JsDiscoverer.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,11 @@ public function resolve(string $file, string $app = 'horde'): ?string;

/** @return array<string, ?string> Map of file => uri (null if not found) */
public function resolveMany(array $files, string $app = 'horde'): array;

/**
* Resolve theme-shipped JavaScript for a request.
*
* Implementations without theme knowledge return an empty result.
*/
public function discoverTheme(JsDiscoveryRequest $request): JsDiscoveryResult;
}
39 changes: 39 additions & 0 deletions src/Assets/JsDiscoveryRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
*/

namespace Horde\Core\Assets;

/**
* Describes a theme-aware JavaScript discovery request.
*
* Parallel to {@see CssDiscoveryRequest}. When $files is empty the discoverer
* derives the file list from the theme's own declarations (info.php
* $theme_scripts).
*
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
*/
final class JsDiscoveryRequest
{
/** @param list<string> $files */
public function __construct(
public readonly array $files = [],
public readonly string $app = 'horde',
public readonly string $theme = 'default',
) {}
}
75 changes: 75 additions & 0 deletions src/Assets/JsDiscoveryResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
*/

namespace Horde\Core\Assets;

use ArrayIterator;
use Countable;
use IteratorAggregate;

/**
* The ordered set of JavaScript assets discovered for a theme.
*
* Parallel to {@see CssDiscoveryResult}.
*
* @implements IteratorAggregate<int, JsAssetEntry>
*
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
*/
final class JsDiscoveryResult implements IteratorAggregate, Countable
{
/** @param list<JsAssetEntry> $entries */
public function __construct(
private readonly array $entries,
private readonly string $theme,
private readonly string $app,
) {}

/** @return ArrayIterator<int, JsAssetEntry> */
public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->entries);
}

public function count(): int
{
return count($this->entries);
}

public function getTheme(): string
{
return $this->theme;
}

public function getApp(): string
{
return $this->app;
}

public function isEmpty(): bool
{
return $this->entries === [];
}

/** @return list<JsAssetEntry> */
public function toArray(): array
{
return $this->entries;
}
}
7 changes: 7 additions & 0 deletions src/Assets/PathBasedJsDiscoverer.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,11 @@ public function resolveMany(array $files, string $app = 'horde'): array

return $result;
}

public function discoverTheme(JsDiscoveryRequest $request): JsDiscoveryResult
{
/* This discoverer has no theme knowledge; theme scripts are handled
* by ThemeJsDiscoverer. */
return new JsDiscoveryResult([], $request->theme, $request->app);
}
}
105 changes: 105 additions & 0 deletions src/Assets/PhpThemeInfoReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
*/

namespace Horde\Core\Assets;

use Horde\Core\Path\PathBuilderInterface;

/**
* Reads theme asset declarations from a theme's info.php on the local
* filesystem.
*
* Applies the same validation as the legacy Horde_Themes_Cache::themeScripts():
* theme names and script file names are constrained so a declaration can never
* escape the theme directory.
*
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
*/
class PhpThemeInfoReader implements ThemeInfoReader
{
/**
* Plain directory name: no separators, no '..'. Theme names originate from
* user prefs/options, so this guards the info.php include against path
* traversal.
*/
private const THEME_NAME = '/^[A-Za-z0-9_-]+$/';

/**
* Plain *.js file name. Combined with the explicit '..' check below this
* keeps a declared script inside the theme directory.
*/
private const SCRIPT_NAME = '/^[A-Za-z0-9_.-]+\.js$/';

public function __construct(
private readonly PathBuilderInterface $pathBuilder,
private readonly AssetFilesystem $filesystem,
) {}

public function readScripts(string $app, string $theme): array
{
if (!preg_match(self::THEME_NAME, $theme)) {
return [];
}

$scripts = [];
foreach ($this->declaredScripts($app, $theme) as $script) {
$script = (string) $script;
if (!preg_match(self::SCRIPT_NAME, $script) || strpos($script, '..') !== false) {
continue;
}

$fsPath = (string) $this->pathBuilder
->withAppThemesDir($app)
->withSlug($theme)
->withPart($script);

if ($this->filesystem->isReadable($fsPath)) {
$scripts[] = $script;
}
}

return $scripts;
}

/**
* Include the theme's info.php in an isolated scope and return its
* declared $theme_scripts. Any read/parse failure yields an empty array.
*
* @return array<int|string, mixed>
*/
private function declaredScripts(string $app, string $theme): array
{
$info = (string) $this->pathBuilder
->withAppThemesDir($app)
->withSlug($theme)
->withPart('info.php');

if (!$this->filesystem->isReadable($info)) {
return [];
}

/* Declared before the include so the theme file only ever augments a
* known-shape local; nothing from the outer scope leaks in. */
$theme_scripts = [];

include $info;

return (array) $theme_scripts;
}
}
52 changes: 52 additions & 0 deletions src/Assets/ThemeInfoReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
*/

namespace Horde\Core\Assets;

use Horde\Core\Factory\ThemeInfoReaderFactory;
use Horde\Injector\Attribute\Factory;

/**
* Reads a theme's own asset declarations from its info.php.
*
* Themes may ship JavaScript alongside their CSS, images and sounds by
* declaring plain file names in the theme directory's info.php:
*
* $theme_scripts = array('theme.js');
*
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
*/
#[Factory(factory: ThemeInfoReaderFactory::class, method: 'create')]
interface ThemeInfoReader
{
/**
* Return the validated, readable script file names declared by a theme.
*
* Only plain *.js file names are returned; anything with a directory
* separator, a '..' segment, a non-.js extension or a missing/unreadable
* target file is dropped. A theme name that is not a plain directory name
* yields an empty list.
*
* @param string $app Application the theme belongs to.
* @param string $theme Theme name (as stored in user prefs/options).
*
* @return list<string> Validated script file names, in declaration order.
*/
public function readScripts(string $app, string $theme): array;
}
Loading
Loading