Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
fix(files): properly forward open params from short urls
Signed-off-by: skjnldsv <[email protected]>
  • Loading branch information
skjnldsv committed Feb 18, 2025
commit 2e50a39265e09ac5e8de6ec53cff1546aafe8629
40 changes: 26 additions & 14 deletions apps/files/lib/Controller/ViewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,18 @@ protected function getStorageInfo(string $dir = '/') {
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function showFile(?string $fileid = null): Response {
public function showFile(?string $fileid = null, ?string $opendetails = null, ?string $openfile = null): Response {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I meant is adding openfile here is a bugfix ✅
Adding opendetails is a feature I do not see the usecase on this endpoint.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right, it's kinda both you're right!
I'll adjust the backports to only cover the openfile anyway :)

if (!$fileid) {
return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index'));
}

// This is the entry point from the `/f/{fileid}` URL which is hardcoded in the server.
try {
return $this->redirectToFile((int)$fileid);
return $this->redirectToFile((int)$fileid, $opendetails, $openfile);
} catch (NotFoundException $e) {
return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index', ['fileNotFound' => true]));
// Keep the fileid even if not found, it will be used
// to detect the file could not be found and warn the user
return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.indexViewFileid', ['fileid' => $fileid, 'view' => 'files']));
}
}

Expand All @@ -100,38 +102,35 @@ public function showFile(?string $fileid = null): Response {
* @param string $dir
* @param string $view
* @param string $fileid
* @param bool $fileNotFound
* @return TemplateResponse|RedirectResponse
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function indexView($dir = '', $view = '', $fileid = null, $fileNotFound = false) {
return $this->index($dir, $view, $fileid, $fileNotFound);
public function indexView($dir = '', $view = '', $fileid = null) {
return $this->index($dir, $view, $fileid);
}

/**
* @param string $dir
* @param string $view
* @param string $fileid
* @param bool $fileNotFound
* @return TemplateResponse|RedirectResponse
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function indexViewFileid($dir = '', $view = '', $fileid = null, $fileNotFound = false) {
return $this->index($dir, $view, $fileid, $fileNotFound);
public function indexViewFileid($dir = '', $view = '', $fileid = null) {
return $this->index($dir, $view, $fileid);
}

/**
* @param string $dir
* @param string $view
* @param string $fileid
* @param bool $fileNotFound
* @return TemplateResponse|RedirectResponse
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function index($dir = '', $view = '', $fileid = null, $fileNotFound = false) {
public function index($dir = '', $view = '', $fileid = null) {
if ($fileid !== null && $view !== 'trashbin') {
try {
return $this->redirectToFileIfInTrashbin((int)$fileid);
Expand Down Expand Up @@ -159,8 +158,6 @@ public function index($dir = '', $view = '', $fileid = null, $fileNotFound = fal
if (count($nodes) === 1 && $relativePath !== $dir && $nodePath !== $dir) {
return $this->redirectToFile((int)$fileid);
}
} else { // fileid does not exist anywhere
$fileNotFound = true;
}
}

Expand Down Expand Up @@ -249,10 +246,12 @@ private function redirectToFileIfInTrashbin($fileId): RedirectResponse {
* Redirects to the file list and highlight the given file id
*
* @param int $fileId file id to show
* @param string|null $openDetails open details parameter
* @param string|null $openFile open file parameter
* @return RedirectResponse redirect response or not found response
* @throws NotFoundException
*/
private function redirectToFile(int $fileId) {
private function redirectToFile(int $fileId, ?string $openDetails = null, ?string $openFile = null): RedirectResponse {
$uid = $this->userSession->getUser()->getUID();
$baseFolder = $this->rootFolder->getUserFolder($uid);
$node = $baseFolder->getFirstNodeById($fileId);
Expand All @@ -274,6 +273,19 @@ private function redirectToFile(int $fileId) {
// open the file by default (opening the viewer)
$params['openfile'] = 'true';
}

// Forward open parameters if any.
// - openfile is true by default
// - opendetails is undefined by default
// - both will be evaluated as truthy
if ($openDetails !== null) {
$params['opendetails'] = $openDetails !== 'false' ? 'true' : 'false';
}

if ($openFile !== null) {
$params['openfile'] = $openFile !== 'false' ? 'true' : 'false';
}

return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.indexViewFileid', $params));
}

Expand Down
14 changes: 9 additions & 5 deletions apps/files/src/components/FilesListVirtual.vue
Original file line number Diff line number Diff line change
Expand Up @@ -220,23 +220,25 @@ export default defineComponent({
},

openFile: {
async handler(openFile) {
handler(openFile) {
if (!openFile || !this.fileId) {
return
}

await this.handleOpenFile(this.fileId)
this.handleOpenFile(this.fileId)
},
immediate: true,
},

openDetails: {
handler() {
handler(openDetails) {
// wait for scrolling and updating the actions to settle
this.$nextTick(() => {
if (this.fileId && this.openDetails) {
this.openSidebarForFile(this.fileId)
if (!openDetails || !this.fileId) {
return
}

this.openSidebarForFile(this.fileId)
})
},
immediate: true,
Expand Down Expand Up @@ -276,7 +278,9 @@ export default defineComponent({
if (node && sidebarAction?.enabled?.([node], this.currentView)) {
logger.debug('Opening sidebar on file ' + node.path, { node })
sidebarAction.exec(node, this.currentView, this.currentFolder.path)
return
}
logger.error(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node })
},

scrollToFile(fileId: number|null, warn = true) {
Expand Down
142 changes: 114 additions & 28 deletions apps/files/tests/Controller/ViewControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
namespace OCA\Files\Tests\Controller;

use OC\Files\FilenameValidator;
use OC\Route\Router;
use OC\URLGenerator;
use OCA\Files\Controller\ViewController;
use OCA\Files\Service\UserConfig;
use OCA\Files\Service\ViewConfig;
Expand All @@ -16,66 +18,106 @@
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Diagnostics\IEventLogger;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\Template\ITemplateManager;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Test\TestCase;

/**
* Class ViewControllerTest
*
* @group RoutingWeirdness
*
* @package OCA\Files\Tests\Controller
*/
class ViewControllerTest extends TestCase {
private IRequest&MockObject $request;
private IURLGenerator&MockObject $urlGenerator;
private IL10N&MockObject $l10n;
private ContainerInterface&MockObject $container;
private IAppManager&MockObject $appManager;
private ICacheFactory&MockObject $cacheFactory;
private IConfig&MockObject $config;
private IEventDispatcher $eventDispatcher;
private IUser&MockObject $user;
private IUserSession&MockObject $userSession;
private IAppManager&MockObject $appManager;
private IRootFolder&MockObject $rootFolder;
private IEventLogger&MockObject $eventLogger;
private IInitialState&MockObject $initialState;
private IL10N&MockObject $l10n;
private IRequest&MockObject $request;
private IRootFolder&MockObject $rootFolder;
private ITemplateManager&MockObject $templateManager;
private IURLGenerator $urlGenerator;
private IUser&MockObject $user;
private IUserSession&MockObject $userSession;
private LoggerInterface&MockObject $logger;
private UserConfig&MockObject $userConfig;
private ViewConfig&MockObject $viewConfig;
private Router $router;

private ViewController&MockObject $viewController;

protected function setUp(): void {
parent::setUp();
$this->request = $this->getMockBuilder(IRequest::class)->getMock();
$this->urlGenerator = $this->getMockBuilder(IURLGenerator::class)->getMock();
$this->l10n = $this->getMockBuilder(IL10N::class)->getMock();
$this->config = $this->getMockBuilder(IConfig::class)->getMock();
$this->appManager = $this->createMock(IAppManager::class);
$this->config = $this->createMock(IConfig::class);
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
$this->userSession = $this->getMockBuilder(IUserSession::class)->getMock();
$this->appManager = $this->getMockBuilder('\OCP\App\IAppManager')->getMock();
$this->initialState = $this->createMock(IInitialState::class);
$this->l10n = $this->createMock(IL10N::class);
$this->request = $this->createMock(IRequest::class);
$this->rootFolder = $this->createMock(IRootFolder::class);
$this->templateManager = $this->createMock(ITemplateManager::class);
$this->userConfig = $this->createMock(UserConfig::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->viewConfig = $this->createMock(ViewConfig::class);

$this->user = $this->getMockBuilder(IUser::class)->getMock();
$this->user->expects($this->any())
->method('getUID')
->willReturn('testuser1');
$this->userSession->expects($this->any())
->method('getUser')
->willReturn($this->user);
$this->rootFolder = $this->getMockBuilder('\OCP\Files\IRootFolder')->getMock();
$this->initialState = $this->createMock(IInitialState::class);
$this->templateManager = $this->createMock(ITemplateManager::class);
$this->userConfig = $this->createMock(UserConfig::class);
$this->viewConfig = $this->createMock(ViewConfig::class);

$filenameValidator = $this->createMock(FilenameValidator::class);
// Make sure we know the app is enabled
$this->appManager->expects($this->any())
->method('cleanAppId')
->willReturnArgument(0);
$this->appManager->expects($this->any())
->method('getAppPath')
->willReturnCallback(fn (string $appid): string => \OC::$SERVERROOT . '/apps/' . $appid);

$this->cacheFactory = $this->createMock(ICacheFactory::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->eventLogger = $this->createMock(IEventLogger::class);
$this->container = $this->createMock(ContainerInterface::class);
$this->router = new Router(
$this->logger,
$this->request,
$this->config,
$this->eventLogger,
$this->container,
$this->appManager,
);

// Create a real URLGenerator instance to generate URLs
$this->urlGenerator = new URLGenerator(
$this->config,
$this->userSession,
$this->cacheFactory,
$this->request,
$this->router
);

$filenameValidator = $this->createMock(FilenameValidator::class);
$this->viewController = $this->getMockBuilder(ViewController::class)
->setConstructorArgs([
'files',
Expand Down Expand Up @@ -147,10 +189,60 @@ public function testIndexWithRegularBrowser(): void {
$this->assertEquals($expected, $this->viewController->index('MyDir', 'MyView'));
}

public function dataTestShortRedirect(): array {
// openfile is true by default
// opendetails is undefined by default
// both will be evaluated as truthy
return [
[null, null, '/index.php/apps/files/files/123456?openfile=true'],
['', null, '/index.php/apps/files/files/123456?openfile=true'],
[null, '', '/index.php/apps/files/files/123456?openfile=true&opendetails=true'],
['', '', '/index.php/apps/files/files/123456?openfile=true&opendetails=true'],
['false', '', '/index.php/apps/files/files/123456?openfile=false'],
[null, 'false', '/index.php/apps/files/files/123456?openfile=true&opendetails=false'],
['true', 'false', '/index.php/apps/files/files/123456?openfile=true&opendetails=false'],
['false', 'true', '/index.php/apps/files/files/123456?openfile=false&opendetails=true'],
['false', 'false', '/index.php/apps/files/files/123456?openfile=false&opendetails=false'],
];
}

/**
* @dataProvider dataTestShortRedirect
*/
public function testShortRedirect($openfile, $opendetails, $result) {
$this->appManager->expects($this->any())
->method('isEnabledForUser')
->with('files')
->willReturn(true);

$baseFolderFiles = $this->getMockBuilder(Folder::class)->getMock();
$this->rootFolder->expects($this->any())
->method('getUserFolder')
->with('testuser1')
->willReturn($baseFolderFiles);

$parentNode = $this->getMockBuilder(Folder::class)->getMock();
$parentNode->expects($this->once())
->method('getPath')
->willReturn('testuser1/files/Folder');

$node = $this->getMockBuilder(File::class)->getMock();
$node->expects($this->once())
->method('getParent')
->willReturn($parentNode);

$baseFolderFiles->expects($this->any())
->method('getFirstNodeById')
->with(123456)
->willReturn($node);

$response = $this->viewController->showFile(123456, $opendetails, $openfile);
$this->assertStringContainsString($result, $response->getHeaders()['Location']);
}

public function testShowFileRouteWithTrashedFile(): void {
$this->appManager->expects($this->once())
$this->appManager->expects($this->exactly(2))
->method('isEnabledForUser')
->with('files_trashbin')
->willReturn(true);

$parentNode = $this->getMockBuilder(Folder::class)->getMock();
Expand Down Expand Up @@ -189,13 +281,7 @@ public function testShowFileRouteWithTrashedFile(): void {
->with('testuser1/files_trashbin/files/test.d1462861890/sub')
->willReturn('/test.d1462861890/sub');

$this->urlGenerator
->expects($this->once())
->method('linkToRoute')
->with('files.view.indexViewFileid', ['view' => 'trashbin', 'dir' => '/test.d1462861890/sub', 'fileid' => '123'])
->willReturn('/apps/files/trashbin/123?dir=/test.d1462861890/sub');

$expected = new RedirectResponse('/apps/files/trashbin/123?dir=/test.d1462861890/sub');
$expected = new RedirectResponse('/index.php/apps/files/trashbin/123?dir=/test.d1462861890/sub');
$this->assertEquals($expected, $this->viewController->index('', '', '123'));
}
}
5 changes: 3 additions & 2 deletions lib/private/Route/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,12 @@ public function loadRoutes($app = null) {
$this->root->addCollection($collection);
}
}

if (!isset($this->loadedApps['core'])) {
$this->loadedApps['core'] = true;
$this->useCollection('root');
$this->setupRoutes($this->getAttributeRoutes('core'), 'core');
require_once __DIR__ . '/../../../core/routes.php';
require __DIR__ . '/../../../core/routes.php';
Copy link
Member Author

@skjnldsv skjnldsv Feb 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an important point.
After a debug session with Christoph, we realised that it prevented routes.php files to be included again when running tests. Making any subsequent route generation fail.

Considering we already test that app have been loaded with isset($this->loadedApps['core']) and isset($this->loadedApps[$app]), we assumed it was safe to require/include without _once.


// Also add the OCS collection
$collection = $this->getCollection('root.ocs');
Expand Down Expand Up @@ -486,7 +487,7 @@ private function getAttributeRoutes(string $app): array {
* @param string $appName
*/
private function requireRouteFile($file, $appName) {
$this->setupRoutes(include_once $file, $appName);
$this->setupRoutes(include $file, $appName);
}


Expand Down