fix(theming): fix broken custom images introduced by #58224

PR #58224 introduced a raster→SVG conversion path in ImageManager::getImage()
that breaks display of custom theming images. The root cause is a three-part
bug chain:

1. getImage() attempted to convert raster images (PNG/JPEG) to SVG format,
   which Imagick cannot do meaningfully and produces broken output.
2. getMimeType() returns 'application/octet-stream' for extensionless stored
   files, so the Content-Type response header was wrong.
3. Stale .svg cache files persisted after image replacement, causing
   subsequent requests to serve the wrong format.

Fix by:
- Restricting the Imagick conversion to SVG→PNG only (not raster→SVG)
- Reading the stored MIME type from IAppConfig for extensionless files in
  ThemingController::getImage()
- Deleting .svg cache files in ImageManager::delete()
- Injecting IAppConfig into ImageManager and reading the cachebuster via
  IAppConfig::getAppValueInt() so the URL returned after upload always
  carries the freshly-incremented value (IConfig::getAppValue() can return
  a stale cached value within the same request)
- Updating the FileInputField Vue component to use a reactive cacheKey ref
  that increments on every upload, so the thumbnail refreshes even when the
  MIME type of the new image is the same as the old one
- Updating unit tests for ImageManager and ThemingController
- Updating psalm baseline for stable32

Backport of #60198

AI-Assisted-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Anna Larch <anna@nextcloud.com>
Signed-off-by: nextcloud-command <nextcloud-command@users.noreply.github.com>
This commit is contained in:
Anna Larch 2026-05-20 13:17:52 +02:00 committed by nextcloud-command
parent d0c2d97202
commit 638b2c20c6
9 changed files with 169 additions and 90 deletions

View file

@ -356,7 +356,13 @@ class ThemingController extends Controller {
$csp->allowInlineStyle();
$response->setContentSecurityPolicy($csp);
$response->cacheFor(3600);
$response->addHeader('Content-Type', $file->getMimeType());
// The original stored file has no extension (e.g. "logo"), so getMimeType() returns
// application/octet-stream for it. Use the config-stored MIME type for the original
// file, and getMimeType() only for converted files which have a proper extension.
$mimeType = $file->getName() === $key
? $this->appConfig->getAppValueString($key . 'Mime', '')
: $file->getMimeType();
$response->addHeader('Content-Type', $mimeType);
$response->addHeader('Content-Disposition', 'attachment; filename="' . $key . '"');
return $response;
}
@ -435,7 +441,7 @@ class ThemingController extends Controller {
#[BruteForceProtection(action: 'manifest')]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getManifest(string $app): JSONResponse {
$cacheBusterValue = $this->config->getAppValue('theming', 'cachebuster', '0');
$cacheBusterValue = $this->appConfig->getAppValueString('cachebuster', '0');
if ($app === 'core' || $app === 'settings') {
$name = $this->themingDefaults->getName();
$shortName = $this->themingDefaults->getName();

View file

@ -8,6 +8,7 @@ namespace OCA\Theming;
use OCA\Theming\AppInfo\Application;
use OCA\Theming\Service\BackgroundService;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
@ -30,6 +31,7 @@ class ImageManager {
private LoggerInterface $logger,
private ITempManager $tempManager,
private BackgroundService $backgroundService,
private IAppConfig $appConfig,
) {
}
@ -40,7 +42,7 @@ class ImageManager {
* @return string the image url
*/
public function getImageUrl(string $key): string {
$cacheBusterCounter = $this->config->getAppValue(Application::APP_ID, 'cachebuster', '0');
$cacheBusterCounter = (string)$this->appConfig->getAppValueInt(ConfigLexicon::CACHE_BUSTER);
if ($this->hasImage($key)) {
return $this->urlGenerator->linkToRoute('theming.Theming.getImage', [ 'key' => $key ]) . '?v=' . $cacheBusterCounter;
} elseif ($key === 'backgroundDark' && $this->hasImage('background')) {
@ -85,31 +87,14 @@ class ImageManager {
public function getImage(string $key, bool $useSvg = true): ISimpleFile {
$mime = $this->config->getAppValue('theming', $key . 'Mime', '');
$folder = $this->getRootFolder()->getFolder('images');
$useSvg = $useSvg && $this->canConvert('SVG');
if ($mime === '' || !$folder->fileExists($key)) {
throw new NotFoundException();
}
// if SVG was requested and is supported
if ($useSvg) {
if (!$folder->fileExists($key . '.svg')) {
try {
$finalIconFile = new \Imagick();
$finalIconFile->setBackgroundColor('none');
$finalIconFile->readImageBlob($folder->getFile($key)->getContent());
$finalIconFile->setImageFormat('SVG');
$svgFile = $folder->newFile($key . '.svg');
$svgFile->putContent($finalIconFile->getImageBlob());
return $svgFile;
} catch (\ImagickException $e) {
$this->logger->info('The image was requested to be no SVG file, but converting it to SVG failed: ' . $e->getMessage());
}
} else {
return $folder->getFile($key . '.svg');
}
}
// if SVG was not requested, but PNG is supported
if (!$useSvg && $this->canConvert('PNG')) {
// only convert SVG originals to PNG when SVG output is not desired;
// converting raster images to SVG produces broken output and is not useful
$isOriginalSvg = ($mime === 'image/svg+xml' || $mime === 'image/svg');
if ($isOriginalSvg && !$useSvg && $this->canConvert('SVG') && $this->canConvert('PNG')) {
if (!$folder->fileExists($key . '.png')) {
try {
$finalIconFile = new \Imagick();
@ -120,13 +105,12 @@ class ImageManager {
$pngFile->putContent($finalIconFile->getImageBlob());
return $pngFile;
} catch (\ImagickException $e) {
$this->logger->info('The image was requested to be no SVG file, but converting it to PNG failed: ' . $e->getMessage());
$this->logger->info('Converting SVG to PNG failed: ' . $e->getMessage());
}
} else {
return $folder->getFile($key . '.png');
}
}
// fallback to the original file
return $folder->getFile($key);
}
@ -157,7 +141,7 @@ class ImageManager {
* @throws NotPermittedException
*/
public function getCacheFolder(): ISimpleFolder {
$cacheBusterValue = $this->config->getAppValue('theming', 'cachebuster', '0');
$cacheBusterValue = (string)$this->appConfig->getAppValueInt(ConfigLexicon::CACHE_BUSTER);
try {
$folder = $this->getRootFolder()->getFolder($cacheBusterValue);
} catch (NotFoundException $e) {
@ -214,6 +198,12 @@ class ImageManager {
} catch (NotFoundException $e) {
} catch (NotPermittedException $e) {
}
try {
$file = $this->getRootFolder()->getFolder('images')->getFile($key . '.svg');
$file->delete();
} catch (NotFoundException $e) {
} catch (NotPermittedException $e) {
}
if ($key === 'logo') {
$this->config->deleteAppValue('theming', 'logoDimensions');

View file

@ -45,7 +45,8 @@
:class="{
'field__preview--logoheader': name === 'logoheader',
'field__preview--favicon': name === 'favicon',
}" />
}"
:style="previewStyle" />
<NcNoteCard v-if="errorMessage"
type="error"
@ -124,6 +125,7 @@ export default {
data() {
return {
showLoading: false,
cacheKey: Date.now(),
acceptMime: (allowedMimeTypes[this.name]
|| ['image/jpeg', 'image/png', 'image/gif', 'image/webp']).join(','),
}
@ -145,6 +147,13 @@ export default {
}
return false
},
previewStyle() {
const url = generateUrl('/apps/theming/image/{key}', { key: this.name })
return {
backgroundImage: `url(${url}?v=${this.cacheKey}&m=${encodeURIComponent(this.mimeValue)})`,
}
},
},
methods: {
@ -167,6 +176,7 @@ export default {
this.showLoading = true
const { data } = await axios.post(url, formData)
this.showLoading = false
this.cacheKey = Date.now()
this.$emit('update:mime-value', file.type)
this.$emit('uploaded', data.data.url)
this.handleSuccess()

View file

@ -666,6 +666,29 @@ class ThemingControllerTest extends TestCase {
}
public function testGetLogoOriginalFile(): void {
$file = $this->createMock(ISimpleFile::class);
$file->method('getName')->willReturn('logo');
$file->method('getMTime')->willReturn(42);
$this->imageManager->expects($this->once())
->method('getImage')
->willReturn($file);
$this->appConfig
->expects($this->once())
->method('getAppValueString')
->with('logoMime', '')
->willReturn('image/png');
@$expected = new FileDisplayResponse($file);
$expected->cacheFor(3600);
$expected->addHeader('Content-Type', 'image/png');
$expected->addHeader('Content-Disposition', 'attachment; filename="logo"');
$csp = new ContentSecurityPolicy();
$csp->allowInlineStyle();
$expected->setContentSecurityPolicy($csp);
@$this->assertEquals($expected, $this->themingController->getImage('logo', false));
}
public function testGetLoginBackgroundNotExistent(): void {
$this->imageManager->method('getImage')
->with($this->equalTo('background'))
@ -708,10 +731,10 @@ class ThemingControllerTest extends TestCase {
#[\PHPUnit\Framework\Attributes\DataProvider('dataGetManifest')]
public function testGetManifest(bool $standalone): void {
$this->config
$this->appConfig
->expects($this->once())
->method('getAppValue')
->with('theming', 'cachebuster', '0')
->method('getAppValueString')
->with('cachebuster', '0')
->willReturn('0');
$this->themingDefaults
->expects($this->any())

View file

@ -9,6 +9,7 @@ namespace OCA\Theming\Tests;
use OCA\Theming\ImageManager;
use OCA\Theming\Service\BackgroundService;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
@ -29,6 +30,7 @@ class ImageManagerTest extends TestCase {
private LoggerInterface&MockObject $logger;
private ITempManager&MockObject $tempManager;
private ISimpleFolder&MockObject $rootFolder;
private IAppConfig&MockObject $appConfig;
protected ImageManager $imageManager;
protected function setUp(): void {
@ -41,6 +43,7 @@ class ImageManagerTest extends TestCase {
$this->tempManager = $this->createMock(ITempManager::class);
$this->rootFolder = $this->createMock(ISimpleFolder::class);
$backgroundService = $this->createMock(BackgroundService::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->imageManager = new ImageManager(
$this->config,
$this->appData,
@ -49,6 +52,7 @@ class ImageManagerTest extends TestCase {
$this->logger,
$this->tempManager,
$backgroundService,
$this->appConfig,
);
$this->appData
->expects($this->any())
@ -79,26 +83,14 @@ class ImageManagerTest extends TestCase {
->with('logo')
->willThrowException(new NotFoundException());
} else {
$file->expects($this->once())
->method('getContent')
->willReturn(file_get_contents(__DIR__ . '/../../../tests/data/testimage.png'));
$folder->expects($this->exactly(2))
$folder->expects($this->once())
->method('fileExists')
->willReturnMap([
['logo', true],
['logo.png', false],
]);
->with('logo')
->willReturn(true);
$folder->expects($this->once())
->method('getFile')
->with('logo')
->willReturn($file);
$newFile = $this->createMock(ISimpleFile::class);
$folder->expects($this->once())
->method('newFile')
->with('logo.png')
->willReturn($newFile);
$newFile->expects($this->once())
->method('putContent');
$this->rootFolder->expects($this->once())
->method('getFolder')
->with('images')
@ -108,12 +100,14 @@ class ImageManagerTest extends TestCase {
public function testGetImageUrl(): void {
$this->checkImagick();
$this->config->expects($this->exactly(2))
$this->appConfig->expects($this->once())
->method('getAppValueInt')
->with('cachebuster')
->willReturn(0);
$this->config->expects($this->once())
->method('getAppValue')
->willReturnMap([
['theming', 'cachebuster', '0', '0'],
['theming', 'logoMime', '', '0'],
]);
->with('theming', 'logoMime', '')
->willReturn('image/png');
$this->urlGenerator->expects($this->once())
->method('linkToRoute')
->willReturn('url-to-image');
@ -121,12 +115,14 @@ class ImageManagerTest extends TestCase {
}
public function testGetImageUrlDefault(): void {
$this->config->expects($this->exactly(2))
$this->appConfig->expects($this->once())
->method('getAppValueInt')
->with('cachebuster')
->willReturn(0);
$this->config->expects($this->once())
->method('getAppValue')
->willReturnMap([
['theming', 'cachebuster', '0', '0'],
['theming', 'logoMime', '', ''],
]);
->with('theming', 'logoMime', '')
->willReturn('');
$this->urlGenerator->expects($this->once())
->method('imagePath')
->with('core', 'logo/logo.png')
@ -136,12 +132,14 @@ class ImageManagerTest extends TestCase {
public function testGetImageUrlAbsolute(): void {
$this->checkImagick();
$this->config->expects($this->exactly(2))
$this->appConfig->expects($this->once())
->method('getAppValueInt')
->with('cachebuster')
->willReturn(0);
$this->config->expects($this->once())
->method('getAppValue')
->willReturnMap([
['theming', 'cachebuster', '0', '0'],
['theming', 'logoMime', '', ''],
]);
->with('theming', 'logoMime', '')
->willReturn('');
$this->urlGenerator->expects($this->any())
->method('getAbsoluteUrl')
->willReturn('url-to-image-absolute?v=0');
@ -149,15 +147,69 @@ class ImageManagerTest extends TestCase {
}
public function testGetImage(): void {
$this->checkImagick();
$this->config->expects($this->once())
->method('getAppValue')->with('theming', 'logoMime', false)
->willReturn('png');
->method('getAppValue')->with('theming', 'logoMime', '')
->willReturn('image/png');
$file = $this->createMock(ISimpleFile::class);
$this->mockGetImage('logo', $file);
$this->assertEquals($file, $this->imageManager->getImage('logo', false));
}
public function testGetImageSvgToSvg(): void {
$this->config->expects($this->once())
->method('getAppValue')->with('theming', 'logoMime', '')
->willReturn('image/svg+xml');
$folder = $this->createMock(ISimpleFolder::class);
$file = $this->createMock(ISimpleFile::class);
$folder->expects($this->once())
->method('fileExists')
->with('logo')
->willReturn(true);
$folder->expects($this->once())
->method('getFile')
->with('logo')
->willReturn($file);
$this->rootFolder->expects($this->once())
->method('getFolder')
->with('images')
->willReturn($folder);
$this->assertEquals($file, $this->imageManager->getImage('logo', true));
}
public function testGetImageSvgToPng(): void {
$this->checkImagick();
$this->config->expects($this->once())
->method('getAppValue')->with('theming', 'logoMime', '')
->willReturn('image/svg+xml');
$folder = $this->createMock(ISimpleFolder::class);
$svgFile = $this->createMock(ISimpleFile::class);
$pngFile = $this->createMock(ISimpleFile::class);
$svgFile->expects($this->once())
->method('getContent')
->willReturn(file_get_contents(__DIR__ . '/../../../core/img/logo/logo.svg'));
$folder->expects($this->exactly(2))
->method('fileExists')
->willReturnMap([
['logo', true],
['logo.png', false],
]);
$folder->expects($this->once())
->method('getFile')
->with('logo')
->willReturn($svgFile);
$folder->expects($this->once())
->method('newFile')
->with('logo.png')
->willReturn($pngFile);
$pngFile->expects($this->once())
->method('putContent');
$this->rootFolder->expects($this->once())
->method('getFolder')
->with('images')
->willReturn($folder);
$this->assertEquals($pngFile, $this->imageManager->getImage('logo', false));
}
public function testGetImageUnset(): void {
$this->expectException(NotFoundException::class);
@ -170,10 +222,10 @@ class ImageManagerTest extends TestCase {
public function testGetCacheFolder(): void {
$folder = $this->createMock(ISimpleFolder::class);
$this->config->expects($this->once())
->method('getAppValue')
->with('theming', 'cachebuster', '0')
->willReturn('0');
$this->appConfig->expects($this->once())
->method('getAppValueInt')
->with('cachebuster')
->willReturn(0);
$this->rootFolder->expects($this->once())
->method('getFolder')
->with('0')
@ -182,10 +234,10 @@ class ImageManagerTest extends TestCase {
}
public function testGetCacheFolderCreate(): void {
$folder = $this->createMock(ISimpleFolder::class);
$this->config->expects($this->exactly(2))
->method('getAppValue')
->with('theming', 'cachebuster', '0')
->willReturn('0');
$this->appConfig->expects($this->exactly(2))
->method('getAppValueInt')
->with('cachebuster')
->willReturn(0);
$this->rootFolder->expects($this->exactly(2))
->method('getFolder')
->with('0')
@ -261,10 +313,10 @@ class ImageManagerTest extends TestCase {
private function setupCacheFolder() {
$folder = $this->createMock(ISimpleFolder::class);
$this->config->expects($this->once())
->method('getAppValue')
->with('theming', 'cachebuster', '0')
->willReturn('0');
$this->appConfig->expects($this->once())
->method('getAppValueInt')
->with('cachebuster')
->willReturn(0);
$this->rootFolder->expects($this->once())
->method('getFolder')
->with('0')
@ -286,10 +338,10 @@ class ImageManagerTest extends TestCase {
$folders[0]->expects($this->once())->method('delete');
$folders[1]->expects($this->once())->method('delete');
$folders[2]->expects($this->never())->method('delete');
$this->config->expects($this->once())
->method('getAppValue')
->with('theming', 'cachebuster', '0')
->willReturn('2');
$this->appConfig->expects($this->once())
->method('getAppValueInt')
->with('cachebuster')
->willReturn(2);
$this->rootFolder->expects($this->once())
->method('getDirectoryListing')
->willReturn($folders);

View file

@ -2319,13 +2319,6 @@
<code><![CDATA[getAppValue]]></code>
</DeprecatedMethod>
</file>
<file src="apps/theming/lib/Controller/ThemingController.php">
<DeprecatedMethod>
<code><![CDATA[getAppValue]]></code>
<code><![CDATA[getAppValue]]></code>
<code><![CDATA[getAppValue]]></code>
</DeprecatedMethod>
</file>
<file src="apps/theming/lib/ImageManager.php">
<DeprecatedMethod>
<code><![CDATA[deleteAppValue]]></code>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1030,6 +1030,11 @@ class Server extends ServerContainer implements IServerContainer {
$c->get(LoggerInterface::class),
$c->get(ITempManager::class),
$backgroundService,
new AppConfig(
$c->get(\OCP\IConfig::class),
$c->get(IAppConfig::class),
'theming',
),
);
return new ThemingDefaults(
$c->get(\OCP\IConfig::class),