Skip to content
Draft
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
110 changes: 61 additions & 49 deletions apps/theming/lib/Controller/IconController.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\EmptyContentSecurityPolicy;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\Response;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\IConfig;
use OCP\IRequest;

Expand Down Expand Up @@ -75,10 +77,10 @@ public function getThemedIcon(string $app, string $image): Response {
}

/**
* Return a 32x32 favicon as png
* Return a favicon as svg
*
* @param string $app ID of the app
* @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/png'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
* @throws \Exception
*
* 200: Favicon returned
Expand All @@ -92,46 +94,32 @@ public function getFavicon(string $app = 'core'): Response {
$app = 'core';
}

$response = null;
$iconFile = null;
// retrieve instance favicon
try {
$iconFile = $this->imageManager->getImage('favicon', false);
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
} catch (NotFoundException $e) {
$customFavicon = $this->getCustomFaviconResponse();
if ($customFavicon !== null) {
return $customFavicon;
}
// retrieve or generate app specific favicon, but only if no custom favicon was uploaded
if ($iconFile === null && ($this->imageManager->canConvert('PNG') || $this->imageManager->canConvert('SVG')) && $this->imageManager->canConvert('ICO')) {
$color = $this->themingDefaults->getColorPrimary();
try {
$iconFile = $this->imageManager->getCachedImage('favIcon-' . $app . $color);
} catch (NotFoundException $exception) {
$icon = $this->iconBuilder->getFavicon($app);
if ($icon === false || $icon === '') {
return new NotFoundResponse();
}
$iconFile = $this->imageManager->setCachedImage('favIcon-' . $app . $color, $icon);

$cacheKey = 'favIconSvg-' . $app . $this->themingDefaults->getColorPrimary();
try {
$iconFile = $this->imageManager->getCachedImage($cacheKey);
} catch (NotFoundException $exception) {
$icon = $this->iconBuilder->getFavicon($app);
if ($icon === false || $icon === '') {
return new NotFoundResponse();
}
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
}
// fallback to core favicon
if ($response === null) {
$fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon.png';
$response = new DataDisplayResponse($this->fileAccessHelper->file_get_contents($fallbackLogo), Http::STATUS_OK, ['Content-Type' => 'image/png']);
$iconFile = $this->imageManager->setCachedImage($cacheKey, $icon);
}
$response->cacheFor(86400);
return $response;
return $this->createIconResponse($iconFile, 'image/svg+xml');
}

/**
* Return a 512x512 icon for touch devices
*
* @param string $app ID of the app
* @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/png'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
* @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/png'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
* @throws \Exception
*
* 200: Touch icon returned
* 404: Touch icon not found
*/
#[PublicPage]
#[NoCSRFRequired]
Expand All @@ -141,33 +129,57 @@ public function getTouchIcon(string $app = 'core'): Response {
$app = 'core';
}

$response = null;
$iconFile = null;
// retrieve instance favicon
try {
$iconFile = $this->imageManager->getImage('favicon');
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => $iconFile->getMimeType()]);
} catch (NotFoundException $e) {
$customFavicon = $this->getCustomFaviconResponse();
if ($customFavicon !== null) {
return $customFavicon;
}
// retrieve or generate app specific touch icon, but only if no custom favicon was uploaded
if ($iconFile === null && $this->imageManager->canConvert('PNG')) {
$color = $this->themingDefaults->getColorPrimary();

// touch icons need to be png, which can only be rendered with imagick
if ($this->imageManager->canConvert('PNG')) {
$iconFile = null;
$cacheKey = 'touchIcon-' . $app . $this->themingDefaults->getColorPrimary();
try {
$iconFile = $this->imageManager->getCachedImage('touchIcon-' . $app . $color);
$iconFile = $this->imageManager->getCachedImage($cacheKey);
} catch (NotFoundException $exception) {
$icon = $this->iconBuilder->getTouchIcon($app);
if ($icon === false || $icon === '') {
return new NotFoundResponse();
if ($icon !== false && $icon !== '') {
$iconFile = $this->imageManager->setCachedImage($cacheKey, $icon);
}
$iconFile = $this->imageManager->setCachedImage('touchIcon-' . $app . $color, $icon);
}
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/png']);
if ($iconFile !== null) {
return $this->createIconResponse($iconFile, 'image/png');
}
}
// fallback to core touch icon
if ($response === null) {
$fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon-touch.png';
$response = new DataDisplayResponse($this->fileAccessHelper->file_get_contents($fallbackLogo), Http::STATUS_OK, ['Content-Type' => 'image/png']);

$fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon-touch.png';
$response = new DataDisplayResponse($this->fileAccessHelper->file_get_contents($fallbackLogo), Http::STATUS_OK, ['Content-Type' => 'image/png']);
$response->cacheFor(86400);
return $response;
}

/**
* An uploaded favicon is used for all apps and served as-is
*
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|null
*/
private function getCustomFaviconResponse(): ?FileDisplayResponse {
try {
$iconFile = $this->imageManager->getImage('favicon');
} catch (NotFoundException $e) {
return null;
}
return $this->createIconResponse($iconFile, $this->imageManager->getImageMime('favicon'));
}

/**
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
*/
private function createIconResponse(ISimpleFile $iconFile, string $mime): FileDisplayResponse {
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => $mime]);
// the generated svg embeds the app icon as data uri
$csp = new EmptyContentSecurityPolicy();
$csp->addAllowedImageDomain('data:');
$response->setContentSecurityPolicy($csp);
$response->cacheFor(86400);
return $response;
}
Expand Down
6 changes: 4 additions & 2 deletions apps/theming/lib/Controller/ThemingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,8 @@ public function getManifest(string $app): JSONResponse {
* @var string $description
* @var string $shortName
*/
// the icon endpoints serve an uploaded favicon as-is
$customFaviconType = $this->imageManager->hasImage('favicon') ? $this->imageManager->getImageMime('favicon') : null;
$responseJS = [
'name' => $name,
'short_name' => $shortName,
Expand All @@ -496,13 +498,13 @@ public function getManifest(string $app): JSONResponse {
[
'src' => $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon',
['app' => $app]) . '?v=' . $cacheBusterValue,
'type' => 'image/png',
'type' => $customFaviconType ?? 'image/png',
'sizes' => '512x512'
],
[
'src' => $this->urlGenerator->linkToRoute('theming.Icon.getFavicon',
['app' => $app]) . '?v=' . $cacheBusterValue,
'type' => 'image/svg+xml',
'type' => $customFaviconType ?? 'image/svg+xml',
'sizes' => '16x16'
]
],
Expand Down
77 changes: 43 additions & 34 deletions apps/theming/lib/IconBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,47 +28,56 @@ public function __construct(
}

/**
* @param $app string app name
* @return string|false image blob
* Render app icon on themed background color as SVG
* fallback to logo
*
* @param string $app app name
* @return string|false content of the svg file
*/
public function getFavicon($app) {
if (!$this->imageManager->canConvert('PNG')) {
public function getFavicon(string $app): string|false {
$appIcon = $this->util->getAppIcon($app);
if ($appIcon instanceof ISimpleFile) {
$appIconContent = $appIcon->getContent();
} elseif (!file_exists($appIcon)) {
return false;
} else {
$appIconContent = file_get_contents($appIcon);
}
try {
$icon = $this->renderAppIcon($app, 128);
if ($icon === false) {
return false;
}
$icon->setImageFormat('PNG32');

$favicon = new Imagick();
$favicon->setFormat('ICO');

$clone = clone $icon;
$clone->scaleImage(16, 0);
$favicon->addImage($clone);

$clone = clone $icon;
$clone->scaleImage(32, 0);
$favicon->addImage($clone);

$clone = clone $icon;
$clone->scaleImage(64, 0);
$favicon->addImage($clone);
if ($appIconContent === false || $appIconContent === '') {
return false;
}

$clone = clone $icon;
$clone->scaleImage(128, 0);
$favicon->addImage($clone);
// the custom logo is stored without file extension, so the mime type is detected from the content
$mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($appIconContent);
if (!str_starts_with($mime, 'image/') || $mime === 'image/svg') {
if (!str_contains($appIconContent, '<svg')) {
return false;
}
$mime = 'image/svg+xml';
}
$color = $this->themingDefaults->getColorPrimary();

$data = $favicon->getImagesBlob();
$favicon->destroy();
$icon->destroy();
$clone->destroy();
return $data;
} catch (\ImagickException $e) {
return false;
/**
* invert app icons for bright primary colors
* the default nextcloud logo and custom logos will not be inverted
*/
$filter = '';
$filterAttribute = '';
if ($this->util->isBrightColor($color)
&& !$appIcon instanceof ISimpleFile
&& $app !== 'core'
) {
$filter = '<filter id="invert" color-interpolation-filters="sRGB"><feColorMatrix values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0"/></filter>';
$filterAttribute = ' filter="url(#invert)"';
}

return '<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 100 100">'
. $filter
. '<rect width="100" height="100" rx="20" fill="' . htmlspecialchars($color, ENT_XML1 | ENT_QUOTES) . '"/>'
. '<image x="7.5" y="7.5" width="85" height="85"' . $filterAttribute
. ' href="data:' . htmlspecialchars($mime, ENT_XML1 | ENT_QUOTES) . ';base64,' . base64_encode($appIconContent) . '"/>'
. '</svg>';
}

/**
Expand Down
8 changes: 1 addition & 7 deletions apps/theming/lib/ImageManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -329,18 +329,12 @@ private function shouldOptimizeBackgroundImage(string $mimeType, int $contentSiz

/**
* Returns a list of supported mime types for image uploads.
* "favicon" images are only allowed to be SVG when imagemagick with SVG support is available.
*
* @param string $key The image key, e.g. "favicon"
* @return string[]
*/
public function getSupportedUploadImageFormats(string $key): array {
$supportedFormats = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];

if ($key !== 'favicon' || $this->canConvert('SVG') === true) {
$supportedFormats[] = 'image/svg+xml';
$supportedFormats[] = 'image/svg';
}
$supportedFormats = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/svg'];

if ($key === 'favicon') {
$supportedFormats[] = 'image/x-icon';
Expand Down
32 changes: 21 additions & 11 deletions apps/theming/lib/SetupChecks/PhpImagickModule.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace OCA\Theming\SetupChecks;

use OCA\Theming\ImageManager;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\SetupCheck\ISetupCheck;
Expand All @@ -18,6 +19,7 @@ class PhpImagickModule implements ISetupCheck {
public function __construct(
private IL10N $l10n,
private IURLGenerator $urlGenerator,
private ImageManager $imageManager,
) {
}

Expand All @@ -33,18 +35,26 @@ public function getCategory(): string {

#[\Override]
public function run(): SetupResult {
if (!extension_loaded('imagick')) {
return SetupResult::info(
$this->l10n->t('The PHP module "imagick" is not enabled although the theming app is. For favicon generation to work correctly, you need to install and enable this module.'),
$this->urlGenerator->linkToDocs('admin-php-modules')
);
} elseif (count(\Imagick::queryFormats('SVG')) === 0) {
return SetupResult::info(
$this->l10n->t('The PHP module "imagick" in this instance has no SVG support. For better compatibility it is recommended to install it.'),
$this->urlGenerator->linkToDocs('admin-php-modules')
);
} else {
if ($this->imageManager->canConvert('SVG') && $this->imageManager->canConvert('PNG')) {
return SetupResult::success();
}

$issues = [];
// an uploaded favicon is used as touch icon as-is
if (!$this->imageManager->hasImage('favicon')) {
$issues[] = $this->l10n->t('Icons for the home screen of mobile devices and for "Add to Dock" in Safari cannot be themed and show the default icon instead. Upload a PNG favicon or install the module with SVG support to avoid this.');
}
$logoMime = $this->imageManager->getImageMime('logo');
if ($logoMime === 'image/svg+xml' || $logoMime === 'image/svg') {
$issues[] = $this->l10n->t('The custom logo was uploaded as SVG and cannot be converted to PNG, so it will be missing in emails for many mail clients (e.g. Gmail and Outlook) that do not display SVG images. Upload the logo as PNG or install the module with SVG support to avoid this.');
}
if ($issues === []) {
return SetupResult::success();
}

return SetupResult::info(
$this->l10n->t('The PHP module "imagick" is not enabled or has no SVG support.') . ' ' . implode(' ', $issues),
$this->urlGenerator->linkToDocs('admin-php-modules')
);
}
}
15 changes: 13 additions & 2 deletions apps/theming/lib/ThemingDefaults.php
Original file line number Diff line number Diff line change
Expand Up @@ -407,10 +407,10 @@ public function replaceImagePath($app, $image) {
}

$route = false;
if ($image === 'favicon.ico' && ($this->imageManager->canConvert('ICO') || $this->getCustomFavicon() !== null)) {
if ($image === 'favicon.ico') {
$route = $this->urlGenerator->linkToRoute('theming.Icon.getFavicon', ['app' => $app]);
}
if (($image === 'favicon-touch.png' || $image === 'favicon-fb.png') && ($this->imageManager->canConvert('PNG') || $this->getCustomFavicon() !== null)) {
if ($image === 'favicon-touch.png' || ($image === 'favicon-fb.png' && $this->useTouchIconForSocialPreview())) {
$route = $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon', ['app' => $app]);
}
if ($image === 'manifest.json') {
Expand All @@ -434,6 +434,17 @@ public function replaceImagePath($app, $image) {
return false;
}

/**
* Social media previews only support raster images, so the touch icon replaces
* the default preview image only if it is an uploaded raster favicon or a themed png
*/
private function useTouchIconForSocialPreview(): bool {
if ($this->getCustomFavicon() === null) {
return $this->imageManager->canConvert('PNG');
}
return in_array($this->imageManager->getImageMime('favicon'), ['image/png', 'image/jpeg', 'image/gif'], true);
}

protected function getCustomFavicon(): ?ISimpleFile {
try {
return $this->imageManager->getImage('favicon');
Expand Down
Loading
Loading