Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
316f6fe
feat(http): centralize Content-Disposition header generation
joshtrichards Mar 22, 2026
a0f7b89
chore: add ContentDisposition to autoload_static
joshtrichards Mar 22, 2026
716691f
chore: add ContentDisposition to autoload_classmap
joshtrichards Mar 22, 2026
e7acb9d
test(http): add tests for ContentDisposition
joshtrichards Mar 22, 2026
fbefe53
fix(AppFramework): improve Content-Disposition filename handling
joshtrichards Mar 22, 2026
891e914
fix(AppFramework): improve filename handling in FileDisplayResponse
joshtrichards Mar 22, 2026
3bd312e
chore(dav) switch FilesPlugin to use ContentDisposition helper
joshtrichards Mar 22, 2026
53f8de3
fix(files_versions): improve filename handling during downloads
joshtrichards Mar 22, 2026
6588900
fix(trashbin): improve download/get filename handling
joshtrichards Mar 22, 2026
80d5bfa
fix(CardDAV): ImageExportPlugin download filename handling
joshtrichards Mar 22, 2026
59f62bf
fix(CardDAV): better MultiGetExportPlugin filename handling
joshtrichards Mar 22, 2026
13a830f
fix(TaskProcessing): improve TaskProcessingApiController filename han…
joshtrichards Mar 22, 2026
df238d7
chore(settings): use ContentDisposition helper class
joshtrichards Mar 22, 2026
9fa7bd3
test(http): handle encoding tests in ContentDispositionTest
joshtrichards Mar 22, 2026
5909d16
test: adjust dav-v2.feature integration for new ContentDisposition im…
joshtrichards Mar 22, 2026
a3731e6
test(dav): adjust FilesPluginTest for new ContentDisposition class
joshtrichards Mar 22, 2026
7499538
chore: fixup typo
joshtrichards Mar 22, 2026
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
3 changes: 2 additions & 1 deletion apps/dav/lib/CardDAV/ImageExportPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
namespace OCA\DAV\CardDAV;

use OC\Http\ContentDisposition;
use OCP\AppFramework\Http;
use OCP\Files\NotFoundException;
use Sabre\CardDAV\Card;
Expand Down Expand Up @@ -86,7 +87,7 @@ public function httpGet(RequestInterface $request, ResponseInterface $response)
$file = $this->cache->get($addressbook->getResourceId(), $node->getName(), $size, $node);
$response->setHeader('Content-Type', $file->getMimeType());
$fileName = $node->getName() . '.' . PhotoCache::ALLOWED_CONTENT_TYPES[$file->getMimeType()];
$response->setHeader('Content-Disposition', "attachment; filename=$fileName");
$response->setHeader('Content-Disposition', ContentDisposition::make('attachment', $fileName));
$response->setStatus(Http::STATUS_OK);

$response->setBody($file->getContent());
Expand Down
3 changes: 2 additions & 1 deletion apps/dav/lib/CardDAV/MultiGetExportPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/
namespace OCA\DAV\CardDAV;

use OC\Http\ContentDisposition;
use OCP\AppFramework\Http;
use Sabre\DAV;
use Sabre\DAV\Server;
Expand Down Expand Up @@ -63,7 +64,7 @@ public function httpReport(RequestInterface $request, ResponseInterface $respons

// Build and override the response
$filename = 'vcfexport-' . date('Y-m-d') . '.vcf';
$response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
$response->setHeader('Content-Disposition', ContentDisposition::make('attachment', $filename));
$response->setHeader('Content-Type', 'text/vcard');

$response->setStatus(Http::STATUS_OK);
Expand Down
15 changes: 2 additions & 13 deletions apps/dav/lib/Connector/Sabre/FilesPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
*/
namespace OCA\DAV\Connector\Sabre;

use OC\AppFramework\Http\Request;
use OC\FilesMetadata\Model\FilesMetadata;
use OC\Http\ContentDisposition;
use OC\User\NoUserException;
use OCA\DAV\Connector\Sabre\Exception\InvalidPath;
use OCA\Files_Sharing\External\Mount as SharingExternalMount;
Expand Down Expand Up @@ -257,18 +257,7 @@ public function httpGet(RequestInterface $request, ResponseInterface $response)
// header has been set before
if ($this->downloadAttachment
&& $response->getHeader('Content-Disposition') === null) {
$filename = $node->getName();
if ($this->request->isUserAgent(
[
Request::USER_AGENT_IE,
Request::USER_AGENT_ANDROID_MOBILE_CHROME,
Request::USER_AGENT_FREEBOX,
])) {
$response->addHeader('Content-Disposition', 'attachment; filename="' . rawurlencode($filename) . '"');
} else {
$response->addHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . rawurlencode($filename)
. '; filename="' . rawurlencode($filename) . '"');
}
$response->addHeader('Content-Disposition', ContentDisposition::make('attachment', $node->getName()));
}

if ($node instanceof File) {
Expand Down
23 changes: 2 additions & 21 deletions apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -601,21 +601,7 @@ public function testCopyDestinationInvalid(): void {
$this->plugin->checkCopy('FolderA/test.txt', 'invalid\\path.txt');
}

public static function downloadHeadersProvider(): array {
return [
[
false,
'attachment; filename*=UTF-8\'\'somefile.xml; filename="somefile.xml"'
],
[
true,
'attachment; filename="somefile.xml"'
],
];
}

#[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'downloadHeadersProvider')]
public function testDownloadHeaders(bool $isClumsyAgent, string $contentDispositionHeader): void {
public function testDownloadHeaders(): void {
$request = $this->createMock(RequestInterface::class);
$response = $this->createMock(ResponseInterface::class);

Expand All @@ -636,13 +622,8 @@ public function testDownloadHeaders(bool $isClumsyAgent, string $contentDisposit
->with('test/somefile.xml')
->willReturn($node);

$this->request
->expects($this->once())
->method('isUserAgent')
->willReturn($isClumsyAgent);

$calls = [
['Content-Disposition', $contentDispositionHeader],
['Content-Disposition', 'attachment; filename="somefile.xml"'],
['X-Accel-Buffering', 'no'],
];
$response
Expand Down
6 changes: 2 additions & 4 deletions apps/files_trashbin/lib/Sabre/TrashbinPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

use OC\Files\FileInfo;
use OC\Files\View;
use OC\Http\ContentDisposition;
use OCA\DAV\Connector\Sabre\FilesPlugin;
use OCA\Files_Trashbin\Trash\ITrashItem;
use OCP\IPreview;
Expand Down Expand Up @@ -127,10 +128,7 @@ public function httpGet(RequestInterface $request, ResponseInterface $response):
return;
}

$response->addHeader(
'Content-Disposition',
'attachment; filename="' . $node->getFilename() . '"' // TODO: Confirm `filename` value is ASCII; add `filename*=UTF-8` support w/ encoding
);
$response->addHeader('Content-Disposition', ContentDisposition::make('attachment', $node->getFilename()));
}

/**
Expand Down
47 changes: 3 additions & 44 deletions apps/files_versions/lib/Sabre/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@
*/
namespace OCA\Files_Versions\Sabre;

use OC\AppFramework\Http\Request;
use OC\Http\ContentDisposition;
use OCA\DAV\Connector\Sabre\FilesPlugin;
use OCP\IPreview;
use OCP\IRequest;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\INode;
use Sabre\DAV\PropFind;
Expand All @@ -32,15 +31,10 @@ class Plugin extends ServerPlugin {
public const AUTHOR = 'author';
public const VERSION_LABEL = '{http://nextcloud.org/ns}version-label';
public const VERSION_AUTHOR = '{http://nextcloud.org/ns}version-author';
private const LEGACY_FILENAME_HEADER_USER_AGENTS = [ // Quirky clients
Request::USER_AGENT_IE,
Request::USER_AGENT_ANDROID_MOBILE_CHROME,
Request::USER_AGENT_FREEBOX,
];

private Server $server;

public function __construct(
private readonly IRequest $request,
private readonly IPreview $previewManager,
) {
}
Expand Down Expand Up @@ -77,7 +71,7 @@ public function afterGet(RequestInterface $request, ResponseInterface $response)
}

$filename = $node->getVersion()->getSourceFileName();
$this->addContentDispositionHeader($response, $filename);
$response->addHeader('Content-Disposition', ContentDisposition::make('attachment', $filename));
}

/**
Expand Down Expand Up @@ -122,39 +116,4 @@ public function propPatch(string $path, PropPatch $propPatch): void {
fn (string $label) => $node->setMetadataValue(self::LABEL, $label)
);
}

/**
* Add a Content-Disposition header in a way that attempts to be broadly compatible with various user agents.
*
* Sends both 'filename' (legacy quoted) and 'filename*' (UTF-8 encoded) per RFC 6266,
* except for known quirky agents known to mishandle the `filename*`, which only get `filename`.
*
* Note: The quoting/escaping should strictly follow RFC 6266 and RFC 5987.
*
* TODO: Currently uses rawurlencode($filename) for both parameters, which is wrong: filename= should be plain
* quoted ASCII (with necessary escaping), while filename* should be UTF-8 percent-encoded.
* TODO: This logic appears elsewhere (sometimes with different quoting/filename handling) and could benefit
* from a shared utility function. See Symfony example:
* - https://github.com/symfony/symfony/blob/175775eb21508becf7e7a16d65959488e522c39a/src/Symfony/Component/HttpFoundation/BinaryFileResponse.php#L146-L155
* - https://github.com/symfony/symfony/blob/175775eb21508becf7e7a16d65959488e522c39a/src/Symfony/Component/HttpFoundation/HeaderUtils.php#L152-L165
*
* @param ResponseInterface $response HTTP response object to add the header to
* @param string $filename Download filename
*/
private function addContentDispositionHeader(ResponseInterface $response, string $filename): void {
if (!$this->request->isUserAgent(self::LEGACY_FILENAME_HEADER_USER_AGENTS)) {
// Modern clients will use 'filename*'; older clients will refer to `filename`.
// The older fallback must be listed first per RFC.
// In theory this is all we actually need to handle both client types.
$response->addHeader(
'Content-Disposition',
'attachment; filename="' . rawurlencode($filename) . '"; filename*=UTF-8\'\'' . rawurlencode($filename)
);
} else {
// Quirky clients that choke on `filename*`: only send `filename=`
$response->addHeader(
'Content-Disposition',
'attachment; filename="' . rawurlencode($filename) . '"');
}
}
}
3 changes: 2 additions & 1 deletion apps/settings/lib/Controller/LogSettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
namespace OCA\Settings\Controller;

use OC\Http\ContentDisposition;
use OC\Log;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
Expand All @@ -28,7 +29,7 @@
/**
* download logfile
*
* @return StreamResponse<Http::STATUS_OK, array{Content-Type: 'application/octet-stream', 'Content-Disposition': 'attachment; filename="nextcloud.log"'}>

Check failure on line 32 in apps/settings/lib/Controller/LogSettingsController.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

MoreSpecificReturnType

apps/settings/lib/Controller/LogSettingsController.php:32:13: MoreSpecificReturnType: The declared return type 'OCP\AppFramework\Http\StreamResponse<200, array{'Content-Disposition': 'attachment; filename=\"nextcloud.log\"', 'Content-Type': 'application/octet-stream'}>' for OCA\Settings\Controller\LogSettingsController::download is more specific than the inferred return type 'OCP\AppFramework\Http\StreamResponse<200, array{'Content-Disposition': string, 'Content-Type': 'application/octet-stream'}>' (see https://psalm.dev/070)
*
* 200: Logfile returned
*/
Expand All @@ -38,12 +39,12 @@
if (!$this->log instanceof Log) {
throw new \UnexpectedValueException('Log file not available');
}
return new StreamResponse(

Check failure on line 42 in apps/settings/lib/Controller/LogSettingsController.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

LessSpecificReturnStatement

apps/settings/lib/Controller/LogSettingsController.php:42:10: LessSpecificReturnStatement: The type 'OCP\AppFramework\Http\StreamResponse<200, array{'Content-Disposition': string, 'Content-Type': 'application/octet-stream'}>' is more general than the declared return type 'OCP\AppFramework\Http\StreamResponse<200, array{'Content-Disposition': 'attachment; filename=\"nextcloud.log\"', 'Content-Type': 'application/octet-stream'}>' for OCA\Settings\Controller\LogSettingsController::download (see https://psalm.dev/129)
$this->log->getLogPath(),
Http::STATUS_OK,
[
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="nextcloud.log"',
'Content-Disposition' => ContentDisposition::make('attachment', 'nextcloud.log'),
],
);
}
Expand Down
2 changes: 1 addition & 1 deletion build/integration/dav_features/dav-v2.feature
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Feature: dav-v2
And As an "admin"
When Downloading file "/welcome.txt"
Then The following headers should be set
|Content-Disposition|attachment; filename*=UTF-8''welcome.txt; filename="welcome.txt"|
|Content-Disposition|attachment; filename="welcome.txt"|
|Content-Security-Policy|default-src 'none';|
|X-Content-Type-Options |nosniff|
|X-Frame-Options|SAMEORIGIN|
Expand Down
6 changes: 2 additions & 4 deletions core/Controller/TaskProcessingApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

use OC\Core\ResponseDefinitions;
use OC\Files\SimpleFS\SimpleFile;
use OC\Http\ContentDisposition;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\ExAppRequired;
Expand Down Expand Up @@ -546,10 +547,7 @@ private function getFileContentsInternal(Task $task, int $fileId): StreamRespons
}

$response = new StreamResponse($node->fopen('rb'));
$response->addHeader(
'Content-Disposition',
'attachment; filename="' . rawurldecode($node->getName()) . '"'
);
$response->addHeader('Content-Disposition', ContentDisposition::make('attachment', $node->getName()));
$response->addHeader('Content-Type', $contentType);
return $response;
}
Expand Down
1 change: 1 addition & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -1861,6 +1861,7 @@
'OC\\Http\\Client\\GuzzlePromiseAdapter' => $baseDir . '/lib/private/Http/Client/GuzzlePromiseAdapter.php',
'OC\\Http\\Client\\NegativeDnsCache' => $baseDir . '/lib/private/Http/Client/NegativeDnsCache.php',
'OC\\Http\\Client\\Response' => $baseDir . '/lib/private/Http/Client/Response.php',
'OC\\Http\\ContentDisposition' => $baseDir . '/lib/private/Http/ContentDisposition.php',
'OC\\Http\\CookieHelper' => $baseDir . '/lib/private/Http/CookieHelper.php',
'OC\\Http\\WellKnown\\RequestManager' => $baseDir . '/lib/private/Http/WellKnown/RequestManager.php',
'OC\\Image' => $baseDir . '/lib/private/Image.php',
Expand Down
1 change: 1 addition & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -1902,6 +1902,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Http\\Client\\GuzzlePromiseAdapter' => __DIR__ . '/../../..' . '/lib/private/Http/Client/GuzzlePromiseAdapter.php',
'OC\\Http\\Client\\NegativeDnsCache' => __DIR__ . '/../../..' . '/lib/private/Http/Client/NegativeDnsCache.php',
'OC\\Http\\Client\\Response' => __DIR__ . '/../../..' . '/lib/private/Http/Client/Response.php',
'OC\\Http\\ContentDisposition' => __DIR__ . '/../../..' . '/lib/private/Http/ContentDisposition.php',
'OC\\Http\\CookieHelper' => __DIR__ . '/../../..' . '/lib/private/Http/CookieHelper.php',
'OC\\Http\\WellKnown\\RequestManager' => __DIR__ . '/../../..' . '/lib/private/Http/WellKnown/RequestManager.php',
'OC\\Image' => __DIR__ . '/../../..' . '/lib/private/Image.php',
Expand Down
67 changes: 67 additions & 0 deletions lib/private/Http/ContentDisposition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Http;

use Symfony\Component\HttpFoundation\HeaderUtils;

/**
* Centralized Content-Disposition header value generation.
*
* Thin wrapper around Symfony's HeaderUtils::makeDisposition() that
* auto-generates a multibyte-safe ASCII fallback filename.
*
* Fallback generation is adapted from Symfony's BinaryFileResponse::setContentDisposition().
* @see https://github.com/symfony/symfony/blob/7.4/src/Symfony/Component/HttpFoundation/BinaryFileResponse.php
*/
class ContentDisposition {

/**
* Generate a Content-Disposition header value.
*
* @param string $disposition 'attachment' or 'inline'
* @param string $filename The desired filename (UTF-8)
* @return string The complete header value, e.g. 'attachment; filename="report.pdf"'
*/
public static function make(string $disposition, string $filename): string {
$fallback = self::toAsciiFallback($filename);
return HeaderUtils::makeDisposition($disposition, $filename, $fallback);
}

/**
* Generate an ASCII-safe fallback filename.
*
* Uses multibyte-aware iteration so that one logical character
* (even if multi-byte) maps to exactly one '_' replacement.
*
* @param string $filename UTF-8 filename
* @return string ASCII-only fallback filename
*/
private static function toAsciiFallback(string $filename): string {
// Pure ASCII and no '%' — usable as-is
if (preg_match('/^[\x20-\x7E]*$/', $filename) && !str_contains($filename, '%')) {
return $filename;
}

$fallback = '';
$length = mb_strlen($filename, 'UTF-8');
for ($i = 0; $i < $length; ++$i) {
$char = mb_substr($filename, $i, 1, 'UTF-8');

if ($char === '%') {
$fallback .= '_';
} elseif (preg_match('/^[\x20-\x7E]$/', $char) === 1) {
$fallback .= $char;
} else {
$fallback .= '_';
}
}

return $fallback;
}
}
5 changes: 2 additions & 3 deletions lib/public/AppFramework/Http/DownloadResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
namespace OCP\AppFramework\Http;

use OC\Http\ContentDisposition;
use OCP\AppFramework\Http;

/**
Expand All @@ -29,9 +30,7 @@ class DownloadResponse extends Response {
public function __construct(string $filename, string $contentType, int $status = Http::STATUS_OK, array $headers = []) {
parent::__construct($status, $headers);

$filename = strtr($filename, ['"' => '\\"', '\\' => '\\\\']);

$this->addHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
$this->addHeader('Content-Disposition', ContentDisposition::make('attachment', $filename));
$this->addHeader('Content-Type', $contentType);
}
}
3 changes: 2 additions & 1 deletion lib/public/AppFramework/Http/FileDisplayResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
namespace OCP\AppFramework\Http;

use OC\Http\ContentDisposition;
use OCP\AppFramework\Http;
use OCP\Files\File;
use OCP\Files\SimpleFS\ISimpleFile;
Expand Down Expand Up @@ -33,7 +34,7 @@ public function __construct(File|ISimpleFile $file, int $statusCode = Http::STAT
parent::__construct($statusCode, $headers);

$this->file = $file;
$this->addHeader('Content-Disposition', 'inline; filename="' . rawurldecode($file->getName()) . '"');
$this->addHeader('Content-Disposition', ContentDisposition::make('inline', $file->getName()));

$this->setETag($file->getEtag());
$lastModified = new \DateTime();
Expand Down
13 changes: 5 additions & 8 deletions tests/lib/AppFramework/Http/DownloadResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,14 @@ public function testFilenameEncoding(string $input, string $expected): void {
$response = new ChildDownloadResponse($input, 'content');
$headers = $response->getHeaders();

$this->assertEquals('attachment; filename="' . $expected . '"', $headers['Content-Disposition']);
$this->assertStringContainsString('attachment', $headers['Content-Disposition']);
$this->assertStringContainsString($expected, $headers['Content-Disposition']);
}

public static function filenameEncodingProvider() : array {
return [
['TestName.txt', 'TestName.txt'],
['A "Quoted" Filename.txt', 'A \\"Quoted\\" Filename.txt'],
['A "Quoted" Filename.txt', 'A \\"Quoted\\" Filename.txt'],
['A "Quoted" Filename With A Backslash \\.txt', 'A \\"Quoted\\" Filename With A Backslash \\\\.txt'],
['A "Very" Weird Filename \ / & <> " >\'""""\.text', 'A \\"Very\\" Weird Filename \\\\ / & <> \\" >\'\\"\\"\\"\\"\\\\.text'],
['\\\\\\\\\\\\', '\\\\\\\\\\\\\\\\\\\\\\\\'],
];
['TestName.txt', 'filename="TestName.txt"'],
['A "Quoted" Filename.txt', 'filename="A \\"Quoted\\" Filename.txt"'],
['file with spaces.txt', 'filename="file with spaces.txt"'], ];
}
}
Loading
Loading