Skip to content
Open
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
2 changes: 2 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
return [
'routes' => [
['name' => 'display#showPdfViewer', 'url' => '/', 'verb' => 'GET'],
// The version segment is a cache buster, see AssetController::serve()
['name' => 'asset#serve', 'url' => '/assets/{version}/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+']],
],
'ocs' => [
['name' => 'settings#getSettings', 'url' => '/api/v1/settings', 'verb' => 'GET'],
Expand Down
4 changes: 2 additions & 2 deletions js/files_pdfviewer-main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/files_pdfviewer-main.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions js/files_pdfviewer-src_views_PDFView_vue.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/files_pdfviewer-src_views_PDFView_vue.js.map

Large diffs are not rendered by default.

137 changes: 137 additions & 0 deletions lib/Controller/AssetController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Files_PDFViewer\Controller;

use OCA\Files_PDFViewer\AppInfo\Application;
use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\StreamResponse;
use OCP\IRequest;

/**
* Serves the data files pdf.js loads at runtime: the localization, the CMaps
* needed for CJK documents and the standard fonts.
*
* pdf.js requests these relative to a base URL, so they cannot go through
* addScript() and the like. Serving them from a controller instead of as
* plain files makes them independent of which file extensions the web server
* passes through directly: with pretty URLs enabled, Apache hands everything
* that is not on its static extension list to index.php, which used to answer
* 404 for .json, .ftl, .bcmap and .pfb.
*/
class AssetController extends Controller {
/**
* The directories below js/pdfjs/web/ that may be served, with the
* content type of each file extension allowed inside them.
*/
private const DIRECTORIES = [
'locale' => [
'json' => 'application/json',
'ftl' => 'text/plain; charset=utf-8',
],
'cmaps' => [
'bcmap' => 'application/octet-stream',
],
'standard_fonts' => [
'pfb' => 'application/x-font-type1',
'ttf' => 'font/ttf',
],
];

/**
* Same lifetime the web server gives the other viewer assets.
*/
private const CACHE_SECONDS = 15778463;

public function __construct(
IRequest $request,
private IAppManager $appManager,
) {
parent::__construct(Application::APP_ID, $request);
}

/**
* The route also carries a version segment that is not passed in here:
* it is a cache buster only. The viewer template puts the same hash there
* that it appends to its other assets, so a new app or server version
* yields new URLs.
*
* @param string $path Path below js/pdfjs/web/, for example
* "locale/de/viewer.ftl" or "cmaps/Adobe-Japan1-UCS2.bcmap"
*/
#[PublicPage]
#[NoCSRFRequired]
public function serve(string $path): Response {
$file = $this->resolve($path);
if ($file === null) {
return new DataDisplayResponse('', Http::STATUS_NOT_FOUND);
}

[$filePath, $contentType] = $file;

$response = new StreamResponse($filePath, Http::STATUS_OK, [
'Content-Type' => $contentType,
'Content-Length' => (string)filesize($filePath),
]);
$response->cacheFor(self::CACHE_SECONDS, false, true);

return $response;
}

/**
* Maps a request path to a file inside one of the allowed directories.
*
* @return array{0: string, 1: string}|null The absolute file path and its
* content type, or null if the
* path does not point to a
* servable file
*/
private function resolve(string $path): ?array {
$segments = explode('/', $path);
$directory = array_shift($segments);

if (!isset(self::DIRECTORIES[$directory]) || $segments === []) {
return null;
}

foreach ($segments as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
return null;
}
}

$extension = strtolower(pathinfo($segments[count($segments) - 1], PATHINFO_EXTENSION));
$contentType = self::DIRECTORIES[$directory][$extension] ?? null;
if ($contentType === null) {
return null;
}

$appPath = $this->appManager->getAppPath(Application::APP_ID);
$directoryPath = realpath($appPath . '/js/pdfjs/web/' . $directory);
if ($directoryPath === false) {
return null;
}

// realpath() resolves symlinks too, so the prefix check below holds
// even if a segment survived the checks above in some unexpected form.
$filePath = realpath($directoryPath . '/' . implode('/', $segments));
if ($filePath === false
|| !str_starts_with($filePath, $directoryPath . DIRECTORY_SEPARATOR)
|| !is_file($filePath)) {
return null;
}

return [$filePath, $contentType];
}
}
1 change: 1 addition & 0 deletions src/views/PDFView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export default {
PDFViewerApplicationOptions.set('isEvalSupported', false)
PDFViewerApplicationOptions.set('workerSrc', this.getViewerTemplateParameter('workersrc'))
PDFViewerApplicationOptions.set('cMapUrl', this.getViewerTemplateParameter('cmapurl'))
PDFViewerApplicationOptions.set('standardFontDataUrl', this.getViewerTemplateParameter('standardfontdataurl'))
PDFViewerApplicationOptions.set('sandboxBundleSrc', this.getViewerTemplateParameter('sandbox'))
PDFViewerApplicationOptions.set('enablePermissions', true)
PDFViewerApplicationOptions.set('imageResourcesPath', this.getViewerTemplateParameter('imageresourcespath'))
Expand Down
5 changes: 3 additions & 2 deletions templates/viewer.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
<head data-workersrc="<?php p($urlGenerator->linkTo('files_pdfviewer', 'js/pdfjs/build/pdf.worker.mjs')) ?>?v=<?php p($version) ?>"
data-enablescripting="<?php p($enableScripting ? 'true' : 'false') ?>"
data-sandbox="<?php p($urlGenerator->linkTo('files_pdfviewer', 'js/pdfjs/build/pdf.sandbox.mjs'))?>?v=<?php p($version) ?>"
data-cmapurl="<?php p($urlGenerator->linkTo('files_pdfviewer', 'js/pdfjs/web/cmaps/')) ?>"
data-cmapurl="<?php p($urlGenerator->linkToRoute('files_pdfviewer.asset.serve', ['version' => $version, 'path' => 'cmaps'])) ?>/"
data-standardfontdataurl="<?php p($urlGenerator->linkToRoute('files_pdfviewer.asset.serve', ['version' => $version, 'path' => 'standard_fonts'])) ?>/"
data-imageresourcespath="<?php p($urlGenerator->linkTo('files_pdfviewer', 'js/pdfjs/web/images/')) ?>">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
Expand All @@ -58,7 +59,7 @@


<!-- This snippet is used in production (included from viewer.html) -->
<link rel="resource" type="application/l10n" href="<?php p($urlGenerator->linkTo('files_pdfviewer', 'js/pdfjs/web/locale/locale.json')) ?>?v=<?php p($version) ?>"/>
<link rel="resource" type="application/l10n" href="<?php p($urlGenerator->linkToRoute('files_pdfviewer.asset.serve', ['version' => $version, 'path' => 'locale/locale.json'])) ?>"/>
<script nonce="<?php p($cspNonceManager->getNonce()) ?>" src="<?php p($urlGenerator->linkTo('files_pdfviewer', 'js/pdfjs/build/pdf.mjs')) ?>?v=<?php p($version) ?>" type="module"></script>
<script nonce="<?php p($cspNonceManager->getNonce()) ?>" src="<?php p($urlGenerator->linkTo('files_pdfviewer', 'js/pdfjs/web/viewer.mjs')) ?>?v=<?php p($version) ?>" type="module"></script>
<script nonce="<?php p($cspNonceManager->getNonce()) ?>" src="<?php p($urlGenerator->linkTo('files_pdfviewer', 'js/files_pdfviewer-workersrc.js')) ?>?v=<?php p($version) ?>"></script>
Expand Down
135 changes: 135 additions & 0 deletions tests/Unit/Controller/AssetControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Files_PDFViewer\Tests\Unit\Controller;

use OCA\Files_PDFViewer\AppInfo\Application;
use OCA\Files_PDFViewer\Controller\AssetController;
use OCP\App\IAppManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\StreamResponse;
use OCP\IRequest;
use PHPUnit\Framework\Attributes\DataProvider;
use Test\TestCase;

class AssetControllerTest extends TestCase {
/**
* Files created below a temporary app directory, relative to it. The ones
* outside js/pdfjs/web/{locale,cmaps,standard_fonts} must never be served.
*/
private const FILES = [
'js/pdfjs/web/locale/locale.json',
'js/pdfjs/web/locale/de/viewer.ftl',
'js/pdfjs/web/locale/README.md',
'js/pdfjs/web/cmaps/Adobe-Japan1-UCS2.bcmap',
'js/pdfjs/web/standard_fonts/FoxitSans.pfb',
'js/pdfjs/web/standard_fonts/LiberationSans-Regular.ttf',
'js/pdfjs/web/viewer.mjs',
'js/pdfjs/build/pdf.mjs',
'appinfo/info.xml',
];

private string $appPath;

private AssetController $controller;

protected function setUp(): void {
parent::setUp();

$this->appPath = sys_get_temp_dir() . '/files_pdfviewer-asset-test-' . uniqid();
foreach (self::FILES as $file) {
$path = $this->appPath . '/' . $file;
if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0700, true);
}
file_put_contents($path, $file);
}

$appManager = $this->createMock(IAppManager::class);
$appManager->method('getAppPath')
->with(Application::APP_ID)
->willReturn($this->appPath);

$this->controller = new AssetController(
$this->createMock(IRequest::class),
$appManager,
);
}

protected function tearDown(): void {
$this->removeDirectory($this->appPath);

parent::tearDown();
}

public static function dataServe(): array {
return [
['locale/locale.json', 'application/json'],
['locale/de/viewer.ftl', 'text/plain; charset=utf-8'],
['cmaps/Adobe-Japan1-UCS2.bcmap', 'application/octet-stream'],
['standard_fonts/FoxitSans.pfb', 'application/x-font-type1'],
['standard_fonts/LiberationSans-Regular.ttf', 'font/ttf'],
];
}

#[DataProvider('dataServe')]
public function testServe(string $path, string $contentType): void {
$response = $this->controller->serve($path);

$this->assertInstanceOf(StreamResponse::class, $response);
$this->assertSame(Http::STATUS_OK, $response->getStatus());

$headers = $response->getHeaders();
$this->assertSame($contentType, $headers['Content-Type']);
// The file content is its own relative path, see setUp()
$this->assertSame((string)strlen('js/pdfjs/web/' . $path), $headers['Content-Length']);
$this->assertStringContainsString('immutable', $headers['Cache-Control']);
$this->assertStringContainsString('max-age=15778463', $headers['Cache-Control']);
}

public static function dataNotFound(): array {
return [
'file outside the allowed directories' => ['viewer.mjs'],
'directory outside the allowed directories' => ['build/pdf.mjs'],
'traversal out of the app' => ['locale/../../../../appinfo/info.xml'],
'traversal inside js/pdfjs' => ['locale/de/../../viewer.mjs'],
'current directory segment' => ['locale/./locale.json'],
'extension not allowed in the directory' => ['locale/README.md'],
'extension allowed elsewhere' => ['locale/de/viewer.pfb'],
'allowed directory itself' => ['locale'],
'allowed directory with trailing slash' => ['locale/'],
'subdirectory' => ['locale/de'],
'missing file' => ['cmaps/Adobe-Korea1-UCS2.bcmap'],
'empty path' => [''],
];
}

#[DataProvider('dataNotFound')]
public function testServeNotFound(string $path): void {
$response = $this->controller->serve($path);

$this->assertInstanceOf(DataDisplayResponse::class, $response);
$this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus());
}

private function removeDirectory(string $directory): void {
foreach (scandir($directory) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $directory . '/' . $entry;
if (is_dir($path)) {
$this->removeDirectory($path);
} else {
unlink($path);
}
}
rmdir($directory);
}
}