1003 lines
30 KiB
PHP
1003 lines
30 KiB
PHP
<?php
|
|
|
|
namespace Leantime\Core\UI;
|
|
|
|
use Exception;
|
|
use Illuminate\Contracts\Container\BindingResolutionException;
|
|
use Illuminate\Filesystem\Filesystem;
|
|
use Illuminate\Support\Facades\Blade;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\View\View;
|
|
use Illuminate\View\ViewException;
|
|
use Leantime\Core\Configuration\AppSettings;
|
|
use Leantime\Core\Configuration\Environment;
|
|
use Leantime\Core\Controller\Frontcontroller;
|
|
use Leantime\Core\Events\DispatchesEvents;
|
|
use Leantime\Core\Events\Htmx\HtmxEvent;
|
|
use Leantime\Core\Events\Htmx\HtmxEvents;
|
|
use Leantime\Core\Events\Htmx\HtmxUiEvents;
|
|
use Leantime\Core\Http\IncomingRequest;
|
|
use Leantime\Core\Language;
|
|
use Leantime\Core\Support\DateTimeInfoEnum;
|
|
use Leantime\Domain\Auth\Models\Roles;
|
|
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
/**
|
|
* Template class
|
|
* Support Leantime UI with custom template roues and various UI helpers
|
|
*/
|
|
class Template
|
|
{
|
|
use DispatchesEvents;
|
|
|
|
/** @var array - vars that are set in the action */
|
|
private array $vars = [];
|
|
|
|
private string $hookContext = '';
|
|
|
|
public string $tmpError = '';
|
|
|
|
public string $template = '';
|
|
|
|
protected array $headers = [];
|
|
|
|
public $viewFactory;
|
|
|
|
public array $picture = [
|
|
'calendar' => 'fa-calendar',
|
|
'clients' => 'fa-people-group',
|
|
'dashboard' => 'fa-th-large',
|
|
'files' => 'fa-picture',
|
|
'leads' => 'fa-signal',
|
|
'messages' => 'fa-envelope',
|
|
'projects' => 'fa-bar-chart',
|
|
'setting' => 'fa-cogs',
|
|
'tickets' => 'fa-pushpin',
|
|
'timesheets' => 'fa-table',
|
|
'users' => 'fa-people-group',
|
|
'default' => 'fa-off',
|
|
];
|
|
|
|
/**
|
|
* __construct - get instance of frontcontroller
|
|
*
|
|
* @throws BindingResolutionException
|
|
* @throws \ReflectionException
|
|
*/
|
|
public function __construct(
|
|
/** @var Language */
|
|
public Language $language,
|
|
|
|
/** @var IncomingRequest */
|
|
public IncomingRequest $incomingRequest,
|
|
|
|
/** @var Environment */
|
|
private Environment $config,
|
|
|
|
/** @var AppSettings */
|
|
private AppSettings $settings,
|
|
|
|
/** @var AuthService */
|
|
private AuthService $login,
|
|
|
|
/** @var Roles */
|
|
private Roles $roles,
|
|
|
|
private Filesystem $files,
|
|
|
|
) {
|
|
|
|
$this->setupGlobalVars();
|
|
}
|
|
|
|
/**
|
|
* List of all optional JS component bundles that can be conditionally loaded.
|
|
* By default all are enabled for backward compatibility.
|
|
*/
|
|
public const OPTIONAL_COMPONENTS = [
|
|
'calendar',
|
|
'table',
|
|
'tiptap',
|
|
'gantt',
|
|
'chart',
|
|
];
|
|
|
|
/**
|
|
* Components required by the current page.
|
|
* Defaults to all components for backward compatibility.
|
|
*
|
|
* @var array<string>
|
|
*/
|
|
private array $requiredComponents = [];
|
|
|
|
/**
|
|
* Declare which optional JS component bundles are needed for the current page.
|
|
* Call from controllers before rendering to reduce JS payload.
|
|
*
|
|
* @param array<string> $components Array of component names (calendar, table, tiptap, gantt, chart)
|
|
*/
|
|
public function requireComponents(array $components): void
|
|
{
|
|
$this->requiredComponents = array_merge($this->requiredComponents, $components);
|
|
}
|
|
|
|
/**
|
|
* Check if a specific optional component should be loaded on this page.
|
|
*
|
|
* @param string $component The component name to check.
|
|
* @return bool True if the component should be loaded.
|
|
*/
|
|
public function needsComponent(string $component): bool
|
|
{
|
|
// If no components were explicitly required, load everything for backward compatibility
|
|
if (empty($this->requiredComponents)) {
|
|
return true;
|
|
}
|
|
|
|
return in_array($component, $this->requiredComponents);
|
|
}
|
|
|
|
/**
|
|
* setupGlobalVars - setup global vars
|
|
*/
|
|
public function setupGlobalVars(): void
|
|
{
|
|
app('view')->share([
|
|
'frontController' => app('frontcontroller'),
|
|
'config' => $this->config,
|
|
/** @todo remove settings after renaming all uses to appSettings */
|
|
'settings' => $this->settings,
|
|
'appSettings' => $this->settings,
|
|
'login' => $this->login,
|
|
'roles' => $this->roles,
|
|
'language' => $this->language,
|
|
'dateTimeInfoEnum' => DateTimeInfoEnum::class,
|
|
'tpl' => $this,
|
|
'request' => $this->incomingRequest,
|
|
]);
|
|
|
|
$this->viewFactory = app('view');
|
|
}
|
|
|
|
/**
|
|
* assign - assign variables in the action for template
|
|
*
|
|
* @param string $name Name of variable
|
|
* @param mixed $value Value of variable
|
|
*/
|
|
public function assign(string $name, mixed $value): void
|
|
{
|
|
/**
|
|
* Filter to access template variable names after they have been assigned
|
|
*
|
|
* @var mixed $value The current value of the variable.
|
|
*/
|
|
$value = self::dispatchFilter("var.$name", $value);
|
|
|
|
$this->vars[$name] = $value;
|
|
}
|
|
|
|
/**
|
|
* get - get assigned values
|
|
*/
|
|
public function get(string $name): mixed
|
|
{
|
|
if (! isset($this->vars[$name])) {
|
|
return null;
|
|
}
|
|
|
|
return $this->vars[$name];
|
|
}
|
|
|
|
/**
|
|
* getAll - get all assigned values
|
|
*
|
|
**/
|
|
public function getAll(): array
|
|
{
|
|
return $this->vars ?? [];
|
|
}
|
|
|
|
/**
|
|
* Sets a header in the response object.
|
|
*
|
|
* @param string $key The key of the header.
|
|
* @param string $value The value of the header.
|
|
*/
|
|
public function setResponseHeader(string $key, string $value): void
|
|
{
|
|
$this->headers[$key] = $value;
|
|
}
|
|
|
|
/**
|
|
* Sets the response header to trigger an htmx event
|
|
*
|
|
**/
|
|
public function setHTMXEvent(HtmxEvent|string $eventName): void
|
|
{
|
|
$this->headers['HX-Trigger'] ??= [];
|
|
$this->headers['HX-Trigger'][] = $eventName instanceof HtmxEvent ? $eventName->event() : $eventName;
|
|
}
|
|
|
|
/**
|
|
* Queue one or more client (HTMX) events on the HX-Trigger response header.
|
|
* Accepts HtmxEvent enum cases (preferred) or raw strings.
|
|
*/
|
|
public function emit(HtmxEvent|string ...$events): void
|
|
{
|
|
foreach ($events as $event) {
|
|
$this->setHTMXEvent($event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finalize queued headers for a response: expand legacy event aliases on HX-Trigger and
|
|
* collapse the queued event list to the comma-separated string htmx expects.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function responseHeaders(): array
|
|
{
|
|
$headers = $this->headers;
|
|
|
|
if (! empty($headers['HX-Trigger'])) {
|
|
$headers['HX-Trigger'] = HtmxEvents::triggerHeader((array) $headers['HX-Trigger']);
|
|
}
|
|
|
|
return $headers;
|
|
}
|
|
|
|
/**
|
|
* Refreshes (main url page in the background)
|
|
**/
|
|
public function htmxRefresh(): void
|
|
{
|
|
|
|
$hxCurrentUrl = $this->incomingRequest->headers->get('hx-current-url');
|
|
$mainPageUrl = Str::before($hxCurrentUrl, '#');
|
|
$this->headers['HX-Location'] = $mainPageUrl;
|
|
|
|
}
|
|
|
|
/**
|
|
* Sets the response header to trigger an htmx event
|
|
**/
|
|
public function closeModal(): void
|
|
{
|
|
$this->setHTMXEvent(HtmxUiEvents::ModalClose);
|
|
}
|
|
|
|
public function getHeaders()
|
|
{
|
|
return $this->headers;
|
|
}
|
|
|
|
/**
|
|
* display - display template from folder template including main layout wrapper
|
|
*
|
|
* @throws Exception
|
|
*/
|
|
public function display(string $template, string $layout = 'app', int $responseCode = 200, array $headers = []): Response
|
|
{
|
|
|
|
$template = self::dispatchFilter('template', $template);
|
|
$template = self::dispatchFilter("template.$template", $template);
|
|
|
|
$this->template = $template;
|
|
|
|
$layout = $this->confirmLayoutName($layout, $template);
|
|
|
|
$templateParts = $this->parseViewPath($template);
|
|
|
|
$loadFile = $this->getTemplatePath($templateParts['module'], $templateParts['path']);
|
|
|
|
// app('view')->share([]);
|
|
|
|
$this->hookContext = 'tpl.'.$templateParts['module'].'.'.$templateParts['path'];
|
|
|
|
$viewFactory = app('view');
|
|
|
|
/** @var View $view */
|
|
$view = app('view')->make($loadFile);
|
|
|
|
$path = $view->getPath();
|
|
|
|
$this->setHookContext($templateParts, $path);
|
|
|
|
/** @todo this can be reduced to just the 'if' code after removal of php template support */
|
|
if (str_ends_with($path, 'blade.php')) {
|
|
$view->with(array_merge(
|
|
$this->vars,
|
|
['layout' => $layout]
|
|
));
|
|
} else {
|
|
$view = app('view')->make($layout, array_merge(
|
|
$this->vars,
|
|
['module' => strtolower($templateParts['module']), 'action' => $templateParts['path']]
|
|
));
|
|
}
|
|
|
|
$content = $view->render();
|
|
|
|
return new Response($content, $responseCode, array_merge($headers, $this->responseHeaders()));
|
|
}
|
|
|
|
/**
|
|
* displaySubmodule - display a submodule for a given module
|
|
*
|
|
* @throws Exception
|
|
*/
|
|
public function displaySubmodule(string $alias): void
|
|
{
|
|
if (! str_contains($alias, '-')) {
|
|
throw new Exception('Submodule alias must be in the format module-submodule');
|
|
}
|
|
|
|
[$module, $submodule] = explode('-', $alias);
|
|
|
|
$relative_path = $this->getTemplatePath($module, "submodules.$submodule");
|
|
|
|
echo app('view')->make($relative_path, array_merge($this->vars, ['tpl' => $this]))->render();
|
|
}
|
|
|
|
public function getRenderedTemplate(string $template): string
|
|
{
|
|
|
|
[$module, $partial] = explode('.', $template);
|
|
|
|
$relative_path = $this->getTemplatePath($module, "partials.$partial");
|
|
|
|
return app('view')->make($relative_path, array_merge($this->vars, ['tpl' => $this]))->render();
|
|
}
|
|
|
|
public function emptyResponse($responseCode = 200)
|
|
{
|
|
return new Response('', $responseCode, $this->responseHeaders());
|
|
}
|
|
|
|
/**
|
|
* Display JSON content with an optional response code.
|
|
*
|
|
* @param array|object|string $jsonContent The JSON content to be displayed.
|
|
* @param int $statusCode The HTTP response code to be returned (default: 200).
|
|
* @return Response The response object after displaying the JSON content.
|
|
*
|
|
* @deprecated
|
|
*/
|
|
public function displayJson(array|object|string $jsonContent, int $statusCode = 200): Response
|
|
{
|
|
$response = new Response(json_encode(['error' => 'Invalid Json']), 500);
|
|
$response->headers->set('Content-Type', 'application/json; charset=utf-8');
|
|
|
|
if (is_array($jsonContent) || is_object($jsonContent)) {
|
|
$collection = collect($jsonContent);
|
|
$jsonContent = $collection->toJson();
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
return $response;
|
|
}
|
|
}
|
|
|
|
$response = $response->setContent($jsonContent);
|
|
$response = $response->setStatusCode($statusCode);
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Display JSON content with an optional response code.
|
|
*
|
|
* @param string $content The content to be displayed.
|
|
* @param int $statusCode The HTTP response code to be returned (default: 200).
|
|
* @return Response The response object after displaying the JSON content.
|
|
*
|
|
* @deprecated
|
|
*/
|
|
public function displayRaw(string $content, int $statusCode = 200): Response
|
|
{
|
|
$response = new Response($content, 200);
|
|
$response->headers->set('Content-Type', 'text/plain; charset=utf-8');
|
|
|
|
$response = $response->setStatusCode($statusCode);
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Display a partial template with an optional response code.
|
|
*
|
|
* @param string $template The path to the partial template file.
|
|
* @param int $responseCode The HTTP response code to be returned (default: 200).
|
|
* @return Response The response object after displaying the partial template.
|
|
*/
|
|
public function displayPartial(string $template, int $responseCode = 200): Response
|
|
{
|
|
return $this->display($template, 'blank', $responseCode);
|
|
}
|
|
|
|
/**
|
|
* gives HTMX response
|
|
*
|
|
* @param string $viewPath The blade view path.
|
|
* @param string $fragment The fragment key.
|
|
*/
|
|
public function displayFragment(string $viewPath, string $fragment = ''): Response
|
|
{
|
|
$layout = $this->confirmLayoutName('blank', ! empty($fragment) ? "$viewPath.fragment" : $viewPath);
|
|
|
|
app('view')->share(['tpl' => $this]);
|
|
|
|
/** @var View $view */
|
|
$view = app('view')->make($viewPath, array_merge($this->vars, ['layout' => $layout]));
|
|
|
|
$path = $view->getPath();
|
|
$viewPathExplode = explode('::', $viewPath);
|
|
|
|
$this->setHookContext(['module' => $viewPathExplode[0] ?? '', 'path' => $viewPathExplode[1] ?? ''], $path);
|
|
|
|
return new Response($view->fragmentIf(! empty($fragment), $fragment));
|
|
}
|
|
|
|
/**
|
|
* Confirm the layout name based on the provided parameters.
|
|
*
|
|
* @param string $layoutName The layout name to be confirmed.
|
|
* @param string $template The template name associated with the layout.
|
|
* @return bool|string The confirmed layout name, or false if not found.
|
|
*/
|
|
protected function confirmLayoutName(string $layoutName, string $template): bool|string
|
|
{
|
|
$layout = htmlspecialchars($layoutName);
|
|
$layout = self::dispatchFilter('layout', $layout);
|
|
$layout = self::dispatchFilter("layout.$template", $layout);
|
|
|
|
$layout = $this->getTemplatePath('global', "layouts.$layout");
|
|
|
|
return $layout;
|
|
}
|
|
|
|
/**
|
|
* Parse the view path from the given view name.
|
|
*
|
|
* @param string $viewName The name of the view.
|
|
* @return array An associative array containing the module and path parts of the view path.
|
|
*
|
|
* @throws ViewException If the view name cannot be parsed.
|
|
*/
|
|
protected function parseViewPath(string $viewName)
|
|
{
|
|
|
|
$pathParts = [
|
|
'module' => '',
|
|
'path' => '',
|
|
];
|
|
|
|
// view path style
|
|
// module::path.name
|
|
if (str_contains($viewName, '::')) {
|
|
$parts = explode('::', $viewName);
|
|
$pathParts['module'] = $parts[0];
|
|
$pathParts['path'] = $parts[1];
|
|
|
|
return $pathParts;
|
|
}
|
|
|
|
// leantime path
|
|
// module.name
|
|
if (str_contains($viewName, '.')) {
|
|
$parts = explode('.', $viewName);
|
|
$pathParts['module'] = $parts[0];
|
|
$pathParts['path'] = $parts[1];
|
|
|
|
return $pathParts;
|
|
}
|
|
|
|
throw new ViewException("View name $viewName could not be parsed");
|
|
}
|
|
|
|
/**
|
|
* getTemplatePath - Find template in custom and src directories
|
|
*
|
|
* @param string $namespace The namespace the template is for.
|
|
* @param string $path The path to the template.
|
|
* @return string Full template path or false if file does not exist
|
|
*
|
|
* @throws Exception If template not found.
|
|
*/
|
|
public function getTemplatePath(string $namespace, string $path): string
|
|
{
|
|
if ($namespace == '' || $path == '') {
|
|
throw new Exception('Both namespace and path must be provided');
|
|
}
|
|
|
|
$namespace = strtolower($namespace);
|
|
$fullpath = self::dispatchFilter(
|
|
"template_path__{$namespace}_{$path}",
|
|
"$namespace::$path",
|
|
[
|
|
'namespace' => $namespace,
|
|
'path' => $path,
|
|
]
|
|
);
|
|
|
|
if (app('view')->exists($fullpath)) {
|
|
return $fullpath;
|
|
}
|
|
|
|
throw new ViewException("Template $fullpath not found");
|
|
}
|
|
|
|
/**
|
|
* getNotification - pulls notification from the current session
|
|
*/
|
|
public function getNotification(): array
|
|
{
|
|
if (session()->exists('notificationType') && session()->exists('notification')) {
|
|
$event_id = session('event_id') ?? '';
|
|
|
|
return ['type' => session('notificationType'), 'msg' => session('notification'), 'event_id' => $event_id];
|
|
} else {
|
|
return ['type' => '', 'msg' => '', 'event_id' => ''];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* displayNotification - display notification
|
|
*
|
|
* @throws BindingResolutionException
|
|
*/
|
|
public function displayNotification(): string
|
|
{
|
|
$notification = '';
|
|
$note = $this->getNotification();
|
|
$language = $this->language;
|
|
$message_id = $note['msg'];
|
|
|
|
$message = self::dispatchFilter(
|
|
'message',
|
|
$language->__($message_id),
|
|
$note
|
|
);
|
|
$message = self::dispatchFilter(
|
|
"message_{$message_id}",
|
|
$message,
|
|
$note
|
|
);
|
|
|
|
if (! empty($note) && $note['msg'] != '' && $note['type'] != '') {
|
|
$notification .= app('blade.compiler')::render(
|
|
'<script type="text/javascript">jQuery.growl({message: "{!! $message !!}", style: "{{ $style }}"});</script>',
|
|
[
|
|
'message' => $message,
|
|
'style' => $note['type'],
|
|
]
|
|
);
|
|
|
|
self::dispatchEvent('notification_displayed', $note);
|
|
|
|
session(['notification' => '']);
|
|
session(['notificationType' => '']);
|
|
session(['event_id' => '']);
|
|
}
|
|
|
|
if (session()->exists('confettiInYourFace') && session('confettiInYourFace') === true) {
|
|
$notification .= app('blade.compiler')::render(
|
|
'<script type="text/javascript">confetti({
|
|
spread: 70,
|
|
origin: { y: 1.2 },
|
|
disableForReducedMotion: true
|
|
});</script>',
|
|
[]
|
|
);
|
|
|
|
session(['confettiInYourFace' => false]);
|
|
}
|
|
|
|
return $notification;
|
|
}
|
|
|
|
/**
|
|
* displayInlineNotification - display notification
|
|
*
|
|
* @throws BindingResolutionException
|
|
*
|
|
* @deprecated Component
|
|
*/
|
|
public function displayInlineNotification(): string
|
|
{
|
|
$notification = '';
|
|
$note = $this->getNotification();
|
|
$language = $this->language;
|
|
$message_id = $note['msg'];
|
|
|
|
$message = self::dispatchFilter(
|
|
'message',
|
|
$language->__($message_id),
|
|
$note
|
|
);
|
|
$message = self::dispatchFilter(
|
|
"message_{$message_id}",
|
|
$message,
|
|
$note
|
|
);
|
|
|
|
if (! empty($note) && $note['msg'] != '' && $note['type'] != '') {
|
|
$notification = app('blade.compiler')::render(
|
|
'<div class="inputwrapper login-alert login-{{ $type }}" style="position: relative;">
|
|
<div class="alert alert-{{ $type }}" style="padding:15px;" >
|
|
<strong>{!! $message !!}</strong>
|
|
</div>
|
|
</div>',
|
|
[
|
|
'type' => $note['type'],
|
|
'message' => $message,
|
|
],
|
|
deleteCachedView: true
|
|
);
|
|
|
|
self::dispatchEvent('notification_displayed', $note);
|
|
|
|
session(['notification' => '']);
|
|
session(['notificationType' => '']);
|
|
session(['event_id' => '']);
|
|
}
|
|
|
|
if (session()->exists('confettiInYourFace') && session('confettiInYourFace') === true) {
|
|
$notification .= app('blade.compiler')::render(
|
|
'<script type="text/javascript">confetti({
|
|
spread: 70,
|
|
origin: { y: 1.2 },
|
|
disableForReducedMotion: true
|
|
});
|
|
</script>',
|
|
[]
|
|
);
|
|
|
|
session(['confettiInYourFace' => false]);
|
|
}
|
|
|
|
return $notification;
|
|
}
|
|
|
|
/**
|
|
* setNotification - assign errors to the template
|
|
*
|
|
* @param string $event_id as a string for further identification
|
|
*/
|
|
public function setNotification(string $msg, string $type, string $event_id = ''): void
|
|
{
|
|
session(['notification' => $msg]);
|
|
session(['notificationType' => $type]);
|
|
session(['event_id' => $event_id]);
|
|
|
|
$this->setHTMXEvent(HtmxUiEvents::Notify);
|
|
}
|
|
|
|
/**
|
|
* getToggleState - retrieves the toggle state of a submenu by name from the session
|
|
*
|
|
* @param string $name - the name of the submenu toggle
|
|
* @return string|false - the toggle state of the submenu ("true"/"false"), or false if unset
|
|
*
|
|
* @deprecated this should be in a component
|
|
*/
|
|
public function getToggleState(string $name): string|false
|
|
{
|
|
if (session()->exists('usersettings.submenuToggle.'.$name)) {
|
|
return session('usersettings.submenuToggle.'.$name);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Set a flag to indicate that confetti should be displayed.
|
|
* Will be displayed next time a notification is displayed
|
|
*
|
|
* @return void confetti, duh
|
|
*/
|
|
public function sendConfetti()
|
|
{
|
|
session(['confettiInYourFace' => true]);
|
|
}
|
|
|
|
/**
|
|
* __ - returns a language specific string. wraps language class method
|
|
*/
|
|
public function __(string $index): string
|
|
{
|
|
return $this->language->__($index);
|
|
}
|
|
|
|
/**
|
|
* e - echos and escapes content
|
|
*/
|
|
public function e(?string $content): void
|
|
{
|
|
$content = $this->convertRelativePaths($content);
|
|
$escaped = $this->escape($content);
|
|
|
|
echo $escaped;
|
|
}
|
|
|
|
/**
|
|
* escape - escapes content
|
|
*/
|
|
public function escape(?string $content): string
|
|
{
|
|
if (! is_null($content)) {
|
|
$content = $this->convertRelativePaths($content);
|
|
|
|
// htmlspecialchars, not htmlentities: both escape every character that matters for
|
|
// XSS (< > & " ') identically, but htmlentities ALSO converts non-ASCII to named
|
|
// entities, so "Müller" became "Müller". Most call sites sit inside {{ }},
|
|
// which escapes the ampersand again, and the user was shown a literal "Müller"
|
|
// in dropdowns, filters and Ideas (#3636). Escaping strength is unchanged for the
|
|
// call sites that render through {!! !!} and rely on this for safety.
|
|
return htmlspecialchars($content, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* escapeMinimal - escapes content
|
|
*/
|
|
public function escapeMinimal(?string $content): string
|
|
{
|
|
$content = $this->convertRelativePaths($content);
|
|
$config = [
|
|
'safe' => 1,
|
|
'style_pass' => 1,
|
|
'cdata' => 1,
|
|
'comment' => 1,
|
|
'deny_attribute' => '* -href -style',
|
|
'keep_bad' => 0,
|
|
];
|
|
|
|
if (! is_null($content)) {
|
|
return htmLawed($content, [
|
|
'comments' => 0,
|
|
'cdata' => 0,
|
|
'deny_attribute' => 'on*',
|
|
'elements' => '* -applet -canvas -embed -object -script -svg -math -iframe -form -input -textarea -button -select -base -meta -link -style',
|
|
'schemes' => 'href: aim, feed, file, ftp, gopher, http, https, irc, mailto, news, nntp, sftp, ssh, tel, telnet; style: !; *:file, http, https',
|
|
]);
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* truncate - truncate text
|
|
*
|
|
* @see https://stackoverflow.com/questions/1193500/truncate-text-containing-html-ignoring-tags
|
|
*
|
|
* @author Søren Løvborg <https://stackoverflow.com/users/136796/s%c3%b8ren-l%c3%b8vborg>
|
|
*/
|
|
public function truncate(string $html, int $maxLength = 100, string $ending = '(...)', bool $exact = true, bool $considerHtml = false): string
|
|
{
|
|
$printedLength = 0;
|
|
$position = 0;
|
|
$tags = [];
|
|
$truncate = '';
|
|
$html = $this->convertRelativePaths($html);
|
|
// For UTF-8, we need to count multibyte sequences as one character.
|
|
$re = '{</?([a-z]+)[^>]*>|&#?[a-zA-Z0-9]+;|[\x80-\xFF][\x80-\xBF]*}';
|
|
|
|
while ($printedLength < $maxLength && preg_match($re, $html, $match, PREG_OFFSET_CAPTURE, $position)) {
|
|
[$tag, $tagPosition] = $match[0];
|
|
|
|
// Print text leading up to the tag.
|
|
$str = substr($html, $position, $tagPosition - $position);
|
|
if ($printedLength + strlen($str) > $maxLength) {
|
|
$truncate .= substr($str, 0, $maxLength - $printedLength);
|
|
$printedLength = $maxLength;
|
|
break;
|
|
}
|
|
|
|
$truncate .= $str;
|
|
$printedLength += strlen($str);
|
|
if ($printedLength >= $maxLength) {
|
|
break;
|
|
}
|
|
|
|
if ($tag[0] == '&' || ord($tag) >= 0x80) {
|
|
// Pass the entity or UTF-8 multibyte sequence through unchanged.
|
|
$truncate .= $tag;
|
|
$printedLength++;
|
|
} else {
|
|
// Handle the tag.
|
|
$tagName = $match[1][0];
|
|
if ($tag[1] == '/') {
|
|
// This is a closing tag.
|
|
|
|
$openingTag = array_pop($tags);
|
|
assert($openingTag == $tagName); // check that tags are properly nested.
|
|
|
|
$truncate .= $tag;
|
|
} elseif ($tag[strlen($tag) - 2] == '/') {
|
|
// Self-closing tag.
|
|
$truncate .= $tag;
|
|
} else {
|
|
// Opening tag.
|
|
$truncate .= $tag;
|
|
$tags[] = $tagName;
|
|
}
|
|
}
|
|
|
|
// Continue after the tag.
|
|
$position = $tagPosition + strlen($tag);
|
|
}
|
|
|
|
// Print any remaining text.
|
|
if ($printedLength < $maxLength && $position < strlen($html)) {
|
|
$truncate .= sprintf(substr($html, $position, $maxLength - $printedLength));
|
|
}
|
|
|
|
// Close any open tags.
|
|
while (! empty($tags)) {
|
|
$truncate .= sprintf('</%s>', array_pop($tags));
|
|
}
|
|
|
|
if (strlen($truncate) >= $maxLength) {
|
|
$truncate .= $ending;
|
|
}
|
|
|
|
return $truncate;
|
|
}
|
|
|
|
/**
|
|
* convertRelativePaths - convert relative paths to absolute paths
|
|
*
|
|
*
|
|
* @deprecated
|
|
*/
|
|
public function convertRelativePaths(?string $text): ?string
|
|
{
|
|
if (is_null($text)) {
|
|
return $text;
|
|
}
|
|
|
|
$base = BASE_URL;
|
|
|
|
// base url needs trailing /
|
|
$base = rtrim($base, '/').'/';
|
|
|
|
// Replace links
|
|
$text = preg_replace(
|
|
'/<a([^>]*) href="((?!(http|ftp|https|mailto|#))[^"]*)"/',
|
|
"<a\${1} href=\"$base\${2}\"",
|
|
$text
|
|
);
|
|
|
|
// Replace images
|
|
$text = preg_replace(
|
|
'/<img([^>]*) src="((?!(http|ftp|https))[^"]*)"/',
|
|
"<img\${1} src=\"$base\${2}\"",
|
|
$text
|
|
);
|
|
|
|
// Done
|
|
return $text;
|
|
}
|
|
|
|
/**
|
|
* patchDownloadUrlToFilenameOrAwsUrl - Replace all local files/get references in <img src=""> tags
|
|
* by either local filenames or AWS URLs that can be accesse without being authenticated
|
|
*
|
|
* Note: This patch is required by the PDF generating engine as it retrieves URL data without being
|
|
* authenticated
|
|
*
|
|
* @param string $textHtml HTML text, potentially containing <img srv="https://local.domain/files/get?xxxx"> tags
|
|
* @return string HTML text with the https://local.domain/files/get?xxxx replaced by either full qualified
|
|
* local filenames or AWS URLs
|
|
*/
|
|
public function patchDownloadUrlToFilenameOrAwsUrl(string $textHtml): string
|
|
{
|
|
$patchedTextHtml = $this->convertRelativePaths($textHtml);
|
|
|
|
// TO DO: Replace local files/get
|
|
$patchedTextHtml = $patchedTextHtml;
|
|
|
|
return $patchedTextHtml;
|
|
}
|
|
|
|
/**
|
|
* Dispatch a template event with an optional payload.
|
|
*
|
|
* @param string $hookName The name of the event hook.
|
|
* @param mixed $payload The payload to be passed to the event hook (default: null).
|
|
*/
|
|
public function dispatchTplEvent(string $hookName, mixed $payload = null): void
|
|
{
|
|
try {
|
|
$this->dispatchTplHook('event', $hookName, $payload);
|
|
} catch (\Exception $e) {
|
|
// If some plugin or other event decides to go rouge it shouldn't take down the entire page
|
|
report($e);
|
|
}
|
|
}
|
|
|
|
public function dispatchTplFilter(string $hookName, mixed $payload, array $available_params = []): mixed
|
|
{
|
|
try {
|
|
|
|
return $this->dispatchTplHook('filter', $hookName, $payload, $available_params);
|
|
|
|
} catch (\Exception $e) {
|
|
// If some plugin or other event decides to go rouge it shouldn't take down the entire page
|
|
report($e);
|
|
|
|
return $payload;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return null|mixed
|
|
*/
|
|
private function dispatchTplHook(string $type, string $hookName, mixed $payload, array $available_params = []): mixed
|
|
{
|
|
if (! is_string($type) || ! in_array($type, ['event', 'filter'])) {
|
|
return null;
|
|
}
|
|
|
|
if ($type == 'filter') {
|
|
return self::dispatchFilter($hookName, $payload, $available_params, $this->hookContext);
|
|
}
|
|
|
|
self::dispatchEvent($hookName, $payload, $this->hookContext);
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* redirect - redirect to a given url
|
|
*
|
|
*
|
|
* @deprecated
|
|
*/
|
|
public function redirect(string $url): RedirectResponse
|
|
{
|
|
return Frontcontroller::redirect($url);
|
|
}
|
|
|
|
/**
|
|
* getModulePicture - get module picture
|
|
*
|
|
* @throws BindingResolutionException
|
|
*/
|
|
public function getModulePicture(): string
|
|
{
|
|
$module = Frontcontroller::getModuleName($this->template);
|
|
|
|
$picture = $this->picture['default'];
|
|
if (isset($this->picture[$module])) {
|
|
$picture = $this->picture[$module];
|
|
}
|
|
|
|
return $picture;
|
|
}
|
|
|
|
protected function setHookContext($templateParts, $path)
|
|
{
|
|
|
|
if (str_contains($path, 'app/Plugins')) {
|
|
$this->hookContext = 'leantime.plugins.'.$templateParts['module'].'.templates.'.$templateParts['path'];
|
|
} else {
|
|
$this->hookContext = 'leantime.domain.'.$templateParts['module'].'.templates.'.$templateParts['path'];
|
|
}
|
|
|
|
}
|
|
|
|
public function clearViewPathCache()
|
|
{
|
|
|
|
$viewPathCachePath = storage_path('framework/viewPaths.php');
|
|
$this->files->delete($viewPathCachePath);
|
|
|
|
}
|
|
}
|