From 4da977a22c7bfaaa9c1b214d42797489b920d1e3 Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Wed, 20 Aug 2025 12:32:16 -0500 Subject: [PATCH 1/2] Admin: Enable logo changes with custom styles - refs #5578 --- assets/vue/components/layout/PlatformLogo.vue | 53 ++- assets/vue/router/admin.js | 6 + assets/vue/services/themeLogoService.js | 33 ++ assets/vue/views/admin/AdminBranding.vue | 399 ++++++++++++++++++ .../Admin/IndexBlocksController.php | 6 + src/CoreBundle/Controller/ThemeController.php | 194 ++++++++- src/CoreBundle/Helpers/ThemeHelper.php | 16 + .../Twig/Extension/ChamiloExtension.php | 6 + 8 files changed, 681 insertions(+), 32 deletions(-) create mode 100644 assets/vue/services/themeLogoService.js create mode 100644 assets/vue/views/admin/AdminBranding.vue diff --git a/assets/vue/components/layout/PlatformLogo.vue b/assets/vue/components/layout/PlatformLogo.vue index cc6aa154dfa..d0c4aad4606 100644 --- a/assets/vue/components/layout/PlatformLogo.vue +++ b/assets/vue/components/layout/PlatformLogo.vue @@ -1,40 +1,69 @@ - diff --git a/assets/vue/router/admin.js b/assets/vue/router/admin.js index ed0b34d8424..07d47a44bc7 100644 --- a/assets/vue/router/admin.js +++ b/assets/vue/router/admin.js @@ -16,6 +16,12 @@ export default { meta: { requiresAdmin: true, requiresSessionAdmin: true, showBreadcrumb: true }, component: () => import("../views/admin/AdminConfigureColors.vue"), }, + { + path: "configuration/branding", + name: "AdminBranding", + meta: { requiresAdmin: true, requiresSessionAdmin: true, showBreadcrumb: true }, + component: () => import("../views/admin/AdminBranding.vue"), + }, { path: "gdpr/third-parties", name: "ThirdPartyManager", diff --git a/assets/vue/services/themeLogoService.js b/assets/vue/services/themeLogoService.js new file mode 100644 index 00000000000..b4b1c5a7e43 --- /dev/null +++ b/assets/vue/services/themeLogoService.js @@ -0,0 +1,33 @@ +async function upload(slug, { headerSvg, headerPng, emailSvg, emailPng }) { + const fd = new FormData() + if (headerSvg) fd.append("header_svg", headerSvg) + if (headerPng) fd.append("header_png", headerPng) + if (emailSvg) fd.append("email_svg", emailSvg) + if (emailPng) fd.append("email_png", emailPng) + + const res = await fetch(`/themes/${encodeURIComponent(slug)}/logos`, { + method: "POST", + body: fd, + credentials: "same-origin", + }) + + if (!res.ok) { + const text = await res.text().catch(() => "") + throw new Error(text || `Upload failed (${res.status})`) + } + return res.json() +} + +async function remove(slug, type) { + const res = await fetch(`/themes/${encodeURIComponent(slug)}/logos/${type}`, { + method: "DELETE", + credentials: "same-origin", + }) + if (!res.ok) { + const text = await res.text().catch(() => "") + throw new Error(text || `Delete failed (${res.status})`) + } + return res.json() +} + +export default { upload, remove } diff --git a/assets/vue/views/admin/AdminBranding.vue b/assets/vue/views/admin/AdminBranding.vue new file mode 100644 index 00000000000..3dd506a8982 --- /dev/null +++ b/assets/vue/views/admin/AdminBranding.vue @@ -0,0 +1,399 @@ + + diff --git a/src/CoreBundle/Controller/Admin/IndexBlocksController.php b/src/CoreBundle/Controller/Admin/IndexBlocksController.php index 0a93b46e806..a6279567ff1 100644 --- a/src/CoreBundle/Controller/Admin/IndexBlocksController.php +++ b/src/CoreBundle/Controller/Admin/IndexBlocksController.php @@ -595,6 +595,12 @@ private function getItemsSettings(): array 'label' => $this->translator->trans('Colors'), ]; + $items[] = [ + 'class' => 'item-branding', + 'route' => ['name' => 'AdminBranding'], + 'label' => $this->translator->trans('Branding'), + ]; + $items[] = [ 'class' => 'item-file-info', 'url' => '/admin/files_info', diff --git a/src/CoreBundle/Controller/ThemeController.php b/src/CoreBundle/Controller/ThemeController.php index fe7c279fe8c..db36b3b7d1b 100644 --- a/src/CoreBundle/Controller/ThemeController.php +++ b/src/CoreBundle/Controller/ThemeController.php @@ -7,14 +7,16 @@ namespace Chamilo\CoreBundle\Controller; use Chamilo\CoreBundle\Helpers\ThemeHelper; -use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemOperator; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/themes')] class ThemeController extends AbstractController @@ -24,45 +26,197 @@ public function __construct( ) {} /** - * @throws FilesystemException + * Upload logos (SVG/PNG) for theme header/email. */ - #[Route('/{name}/{path}', name: 'theme_asset', requirements: ['path' => '.+'])] + #[Route( + '/{slug}/logos', + name: 'theme_logos_upload', + methods: ['POST'], + priority: 10 + )] + #[IsGranted('ROLE_ADMIN')] + public function uploadLogos( + string $slug, + Request $request, + #[Autowire(service: 'oneup_flysystem.themes_filesystem')] FilesystemOperator $fs + ): JsonResponse { + $map = [ + 'header_svg' => 'images/header-logo.svg', + 'header_png' => 'images/header-logo.png', + 'email_svg' => 'images/email-logo.svg', + 'email_png' => 'images/email-logo.png', + ]; + + if (!$fs->directoryExists($slug)) { + $fs->createDirectory($slug); + } + if (!$fs->directoryExists("$slug/images")) { + $fs->createDirectory("$slug/images"); + } + + $results = []; + + foreach ($map as $field => $relativePath) { + $file = $request->files->get($field); + if (!$file) { $results[$field] = 'skipped'; continue; } + + $ext = strtolower((string) $file->getClientOriginalExtension()); + $mime = (string) $file->getMimeType(); + + // SVG + if (str_ends_with($field, '_svg')) { + if ($mime !== 'image/svg+xml' && $ext !== 'svg') { + $results[$field] = 'invalid_mime'; continue; + } + $content = @file_get_contents($file->getPathname()) ?: ''; + $content = $this->sanitizeSvg($content); + $this->ensureDir($fs, $slug.'/images'); + $fs->write($slug.'/'.$relativePath, $content); + $results[$field] = 'uploaded'; continue; + } + + // PNG + if ($mime !== 'image/png' && $ext !== 'png') { + $results[$field] = 'invalid_mime'; continue; + } + $info = @getimagesize($file->getPathname()); + if (!$info) { $results[$field] = 'invalid_image'; continue; } + [$w, $h] = $info; + + if ($field === 'header_png' && ($w > 190 || $h > 60)) { + $results[$field] = 'invalid_dimensions_header_png'; continue; + } + + $this->ensureDir($fs, $slug.'/images'); + $stream = fopen($file->getPathname(), 'rb'); + $fs->writeStream($slug.'/'.$relativePath, $stream); + if (is_resource($stream)) { fclose($stream); } + + $results[$field] = 'uploaded'; + } + + return $this->json(['status' => 'ok', 'results' => $results], Response::HTTP_CREATED); + } + + /** + * Delete a specific logo. + */ + #[Route( + '/{slug}/logos/{type}', + name: 'theme_logos_delete', + requirements: ['type' => 'header_svg|header_png|email_svg|email_png'], + methods: ['DELETE'], + priority: 10 + )] + #[IsGranted('ROLE_ADMIN')] + public function deleteLogo( + string $slug, + string $type, + #[Autowire(service: 'oneup_flysystem.themes_filesystem')] FilesystemOperator $fs + ): JsonResponse { + $map = [ + 'header_svg' => 'images/header-logo.svg', + 'header_png' => 'images/header-logo.png', + 'email_svg' => 'images/email-logo.svg', + 'email_png' => 'images/email-logo.png', + ]; + + $path = $slug.'/'.$map[$type]; + if ($fs->fileExists($path)) { + $fs->delete($path); + } + + return $this->json(['status' => 'deleted']); + } + + /** + * Serve an asset from the theme. + * - If ?strict=1 → only serves {name}/{path}. If it doesn't exist, 404 (no fallback). + * - If strict isn't available → tries {name}/{path} and then the default theme. + */ + #[Route( + '/{name}/{path}', + name: 'theme_asset', + requirements: ['path' => '.+'], + methods: ['GET'], + priority: -10 + )] public function index( string $name, string $path, - #[Autowire(service: 'oneup_flysystem.themes_filesystem')] - FilesystemOperator $filesystem + Request $request, + #[Autowire(service: 'oneup_flysystem.themes_filesystem')] FilesystemOperator $filesystem ): Response { $themeDir = basename($name); + $strict = $request->query->getBoolean('strict', false); if (!$filesystem->directoryExists($themeDir)) { throw $this->createNotFoundException('The folder name does not exist.'); } - $filePath = $this->themeHelper->getFileLocation($path); - - if (!$filePath) { - throw $this->createNotFoundException('The requested file does not exist.'); + $filePath = null; + + if ($strict) { + $candidate = $themeDir.DIRECTORY_SEPARATOR.$path; + if ($filesystem->fileExists($candidate)) { + $filePath = $candidate; + } else { + throw $this->createNotFoundException('The requested file does not exist.'); + } + } else { + $candidates = [ + $themeDir.DIRECTORY_SEPARATOR.$path, + ThemeHelper::DEFAULT_THEME.DIRECTORY_SEPARATOR.$path, + ]; + foreach ($candidates as $c) { + if ($filesystem->fileExists($c)) { + $filePath = $c; + break; + } + } + if (!$filePath) { + throw $this->createNotFoundException('The requested file does not exist.'); + } } $response = new StreamedResponse(function () use ($filesystem, $filePath): void { - $outputStream = fopen('php://output', 'wb'); - - $fileStream = $filesystem->readStream($filePath); - - stream_copy_to_stream($fileStream, $outputStream); - - fclose($outputStream); - fclose($fileStream); + $out = fopen('php://output', 'wb'); + $in = $filesystem->readStream($filePath); + stream_copy_to_stream($in, $out); + fclose($out); + fclose($in); }); - $mimeType = $filesystem->mimeType($filePath); + $mimeType = $filesystem->mimeType($filePath) ?: 'application/octet-stream'; + if (str_ends_with(strtolower($filePath), '.svg')) { + $mimeType = 'image/svg+xml'; + } - $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_INLINE, basename($path)); + $disposition = $response->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_INLINE, + basename($path) + ); $response->headers->set('Content-Disposition', $disposition); - $response->headers->set('Content-Type', $mimeType ?: 'application/octet-stream'); + $response->headers->set('Content-Type', $mimeType); + $response->headers->set('Cache-Control', 'no-store'); return $response; } + + private function ensureDir(FilesystemOperator $fs, string $dir): void + { + if (!$fs->directoryExists($dir)) { + $fs->createDirectory($dir); + } + } + + private function sanitizeSvg(string $svg): string + { + $svg = preg_replace('#]*>.*?#is', '', $svg) ?? $svg; + $svg = preg_replace('/ on\w+="[^"]*"/i', '', $svg) ?? $svg; + $svg = preg_replace("/ on\w+='[^']*'/i", '', $svg) ?? $svg; + $svg = preg_replace('/xlink:href=["\']\s*javascript:[^"\']*["\']/i', 'xlink:href="#"', $svg) ?? $svg; + return $svg; + } } diff --git a/src/CoreBundle/Helpers/ThemeHelper.php b/src/CoreBundle/Helpers/ThemeHelper.php index ebc94193931..ef658e1c6fb 100644 --- a/src/CoreBundle/Helpers/ThemeHelper.php +++ b/src/CoreBundle/Helpers/ThemeHelper.php @@ -164,4 +164,20 @@ public function getAssetBase64Encoded(string $path): string return ''; } + + public function getPreferredLogoUrl(string $type = 'header', bool $absoluteUrl = false): string + { + $candidates = $type === 'email' + ? ['images/email-logo.svg', 'images/email-logo.png'] + : ['images/header-logo.svg', 'images/header-logo.png']; + + foreach ($candidates as $relPath) { + $url = $this->getThemeAssetUrl($relPath, $absoluteUrl); + if ($url !== '') { + return $url; + } + } + + return ''; + } } diff --git a/src/CoreBundle/Twig/Extension/ChamiloExtension.php b/src/CoreBundle/Twig/Extension/ChamiloExtension.php index 94114fb2655..c10fd6ed6b6 100644 --- a/src/CoreBundle/Twig/Extension/ChamiloExtension.php +++ b/src/CoreBundle/Twig/Extension/ChamiloExtension.php @@ -75,9 +75,15 @@ public function getFunctions(): array new TwigFunction('theme_asset', $this->getThemeAssetUrl(...)), new TwigFunction('theme_asset_link_tag', $this->getThemeAssetLinkTag(...), ['is_safe' => ['html']]), new TwigFunction('theme_asset_base64', $this->getThemeAssetBase64Encoded(...)), + new TwigFunction('theme_logo', $this->getThemeLogoUrl(...)), ]; } + public function getThemeLogoUrl(string $type = 'header', bool $absoluteUrl = false): string + { + return $this->themeHelper->getPreferredLogoUrl($type, $absoluteUrl); + } + public function completeUserNameWithLink(User $user): string { $url = $this->router->generate( From 155b0f2c0d8c61d29084a51eda46b65f0807aa33 Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Fri, 29 Aug 2025 00:28:52 -0500 Subject: [PATCH 2/2] Admin: Integrate Branding (logos) inside Colors page, target current visual theme - refs #5578 --- .../vue/components/admin/BrandingSection.vue | 354 ++++++++++++++ .../vue/components/admin/ColorThemeForm.vue | 448 ++++++++---------- .../platform/ColorThemeSelector.vue | 90 +++- assets/vue/composables/theme.js | 235 ++++++--- assets/vue/router/admin.js | 6 - assets/vue/services/colorThemeService.js | 66 ++- .../vue/views/admin/AdminConfigureColors.vue | 82 +++- .../Admin/IndexBlocksController.php | 6 - 8 files changed, 906 insertions(+), 381 deletions(-) create mode 100644 assets/vue/components/admin/BrandingSection.vue diff --git a/assets/vue/components/admin/BrandingSection.vue b/assets/vue/components/admin/BrandingSection.vue new file mode 100644 index 00000000000..2144bd1f584 --- /dev/null +++ b/assets/vue/components/admin/BrandingSection.vue @@ -0,0 +1,354 @@ + + + diff --git a/assets/vue/components/admin/ColorThemeForm.vue b/assets/vue/components/admin/ColorThemeForm.vue index db2fd29704a..3b5deb56e8f 100644 --- a/assets/vue/components/admin/ColorThemeForm.vue +++ b/assets/vue/components/admin/ColorThemeForm.vue @@ -1,5 +1,5 @@ diff --git a/assets/vue/components/platform/ColorThemeSelector.vue b/assets/vue/components/platform/ColorThemeSelector.vue index a737cc9d74d..3513c55b523 100644 --- a/assets/vue/components/platform/ColorThemeSelector.vue +++ b/assets/vue/components/platform/ColorThemeSelector.vue @@ -1,48 +1,87 @@