Skip to content

Commit 6705162

Browse files
authored
Implement resized image protection (#13)
1 parent 059bef4 commit 6705162

15 files changed

+723
-124
lines changed

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,29 @@ Now, when a user which has not logged in yet opens the link to a file, he will b
3131

3232
Since version `2.3.0` you are also able to grant front end users access to the files in their user home directory in the settings of the member.
3333

34+
## Protect Resized Images
35+
36+
Since version `2.4.0` it is possible to also automatically protect any resized images (thumbnails) of protected files
37+
which would otherwise be publicly available under `assets/images`. You can enable this feature in your config:
38+
39+
```yaml
40+
# config/config.yaml
41+
contao_file_access:
42+
protect_resized_images: true
43+
```
44+
45+
Note that this will however put additional load on your application as all requests to any resized protected image must
46+
be processed by the application.
47+
48+
Also note that due to technical limitations you will always have access to these images (i.e. see these images) if you
49+
are logged into the back end in your current browser session.
50+
3451
## Important Notes
3552
3653
Since this access restriction is done via PHP, the file is also sent to the client via PHP. This means that the `max_execution_time` needs to be sufficiently large, so that any file can be transferred to the client before the script is terminated. Thus you should be aware that problems can occur if a file is either very large or the client's connection to the server is very slow, or both. The script tries to disable the `max_execution_time`, though there is no guarantee that this will work. Also there can be other timeouts in the webserver.
3754

38-
Also currently any automatically generated images by Contao are __not__ protected. So if you use thumbnails of protected images, the URLs to these thumbnails can still be accessed by anyone. Though it is planned to also be able to protect those in a future version.
55+
If you did not enable `protect_resized_images` (see above) and you use thumbnails of protected images, the URL to these
56+
thumbnails can still be accessed by anyone.
3957

4058
## Acknowledgements
4159

composer.json

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,19 @@
2626
}
2727
],
2828
"require": {
29-
"php": "^7.1 || ^8.0",
30-
"contao/core-bundle": "^4.9 || ^5.0",
31-
"symfony/config": "^4.4 || ^5.2 || ^6.0",
32-
"symfony/dependency-injection": "^4.4 || ^5.2 || ^6.0",
33-
"symfony/http-foundation": "^4.4 || ^5.2 || ^6.0",
34-
"symfony/http-kernel": "^4.4 || ^5.2 || ^6.0",
35-
"webmozart/path-util": "^2.3"
29+
"php": "^7.4 || ^8.0",
30+
"contao/core-bundle": "^4.13 || ^5.0",
31+
"contao/image": "^1.2",
32+
"symfony/config": "^5.2 || ^6.0",
33+
"symfony/dependency-injection": "^5.2 || ^6.0",
34+
"symfony/http-foundation": "^5.2 || ^6.0",
35+
"symfony/http-kernel": "^5.2 || ^6.0",
36+
"symfony/security-core": "^5.2 || ^6.0",
37+
"webmozart/path-util": "^2.3",
38+
"symfony/service-contracts": "^2.5 || ^3.0"
39+
},
40+
"require-dev": {
41+
"friendsofphp/php-cs-fixer": "^3.0"
3642
},
3743
"autoload": {
3844
"psr-4": {
@@ -41,5 +47,11 @@
4147
},
4248
"extra": {
4349
"contao-manager-plugin": "InspiredMinds\\ContaoFileAccessBundle\\ContaoManagerPlugin"
50+
},
51+
"config": {
52+
"allow-plugins": {
53+
"contao-components/installer": true,
54+
"php-http/discovery": false
55+
}
4456
}
4557
}

src/ContaoFileAccessBundle.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,16 @@
1212

1313
namespace InspiredMinds\ContaoFileAccessBundle;
1414

15+
use InspiredMinds\ContaoFileAccessBundle\DependencyInjection\Compiler\AdjustProtectedResizerServicePass;
16+
use InspiredMinds\ContaoFileAccessBundle\DependencyInjection\Compiler\AdjustResizerServicePass;
17+
use Symfony\Component\DependencyInjection\ContainerBuilder;
1518
use Symfony\Component\HttpKernel\Bundle\Bundle;
1619

1720
class ContaoFileAccessBundle extends Bundle
1821
{
22+
public function build(ContainerBuilder $container): void
23+
{
24+
$container->addCompilerPass(new AdjustProtectedResizerServicePass());
25+
$container->addCompilerPass(new AdjustResizerServicePass());
26+
}
1927
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the ContaoFileAccessBundle.
7+
*
8+
* (c) inspiredminds
9+
*
10+
* @license LGPL-3.0-or-later
11+
*/
12+
13+
namespace InspiredMinds\ContaoFileAccessBundle\Controller;
14+
15+
use Contao\Controller;
16+
use Contao\CoreBundle\Exception\AccessDeniedException;
17+
use Contao\CoreBundle\Exception\InsufficientAuthenticationException;
18+
use Contao\CoreBundle\Exception\PageNotFoundException;
19+
use Contao\CoreBundle\Security\Authentication\Token\TokenChecker;
20+
use Contao\Date;
21+
use Contao\FilesModel;
22+
use Contao\FrontendUser;
23+
use Contao\PageModel;
24+
use Doctrine\DBAL\Connection;
25+
use Symfony\Component\HttpFoundation\Request;
26+
use Symfony\Component\Security\Core\Security;
27+
use Symfony\Contracts\Service\ServiceSubscriberInterface;
28+
use Symfony\Contracts\Service\ServiceSubscriberTrait;
29+
30+
abstract class AbstractFilesController implements ServiceSubscriberInterface
31+
{
32+
use ServiceSubscriberTrait;
33+
34+
/**
35+
* @throws PageNotFoundException
36+
* @throws AccessDeniedException
37+
* @throws InsufficientAuthenticationException
38+
*/
39+
protected function checkFilePermissions(FilesModel $filesModel): void
40+
{
41+
// Check folder permissions
42+
$allowLogin = false;
43+
$allowAccess = false;
44+
45+
// Get the current user
46+
$user = $this->security()->getUser();
47+
48+
// Check if the current user can access their home directory
49+
$canAccessHomeDir = $user instanceof FrontendUser && !empty($user->homeDir) && $user->accessHomeDir;
50+
51+
do {
52+
// Check if this is a folder
53+
if ('folder' === $filesModel->type) {
54+
// Check if the current directory is an accessible user home
55+
$isHomeDir = (bool) $this->connection()->fetchOne('SELECT COUNT(*) FROM tl_member WHERE accessHomeDir = 1 AND homeDir = ?', [$filesModel->uuid]);
56+
57+
// Only check when member groups have been set or the folder is a user home
58+
if (null !== $filesModel->groups || $isHomeDir) {
59+
$allowLogin = true;
60+
61+
// Set the model to protected on the fly
62+
$filesModel->protected = true;
63+
64+
// Check if this is the user's home directory
65+
$isUserHomeDir = $user instanceof FrontendUser && $user->homeDir === $filesModel->uuid;
66+
67+
// Check access
68+
if (($canAccessHomeDir && $isUserHomeDir) || Controller::isVisibleElement($filesModel)) {
69+
$allowAccess = true;
70+
break;
71+
}
72+
}
73+
}
74+
75+
// Get the parent folder
76+
$filesModel = $filesModel->pid ? FilesModel::findById($filesModel->pid) : null;
77+
} while (null !== $filesModel);
78+
79+
// Throw 404 exception, if there were no user homes or folders with member groups
80+
if (!$allowLogin) {
81+
throw new PageNotFoundException();
82+
}
83+
84+
// Deny access
85+
if (!$allowAccess) {
86+
// If a user is authenticated or the 401 exception does not exist, throw 403 exception
87+
if ($this->security()->isGranted('ROLE_MEMBER')) {
88+
throw new AccessDeniedException();
89+
}
90+
91+
// Otherwise throw 401 exception
92+
throw new InsufficientAuthenticationException();
93+
}
94+
}
95+
96+
protected function setRootPage(Request $request): void
97+
{
98+
$root = $this->findFirstPublishedRootByHostAndLanguage($request->getHost(), $request->getLocale());
99+
100+
if (null !== $root) {
101+
$root->loadDetails();
102+
$request->attributes->set('pageModel', $root);
103+
$GLOBALS['objPage'] = $root;
104+
}
105+
}
106+
107+
protected function connection(): Connection
108+
{
109+
return $this->container->get(__METHOD__);
110+
}
111+
112+
protected function security(): Security
113+
{
114+
return $this->container->get(__METHOD__);
115+
}
116+
117+
protected function tokenChecker(): TokenChecker
118+
{
119+
return $this->container->get(__METHOD__);
120+
}
121+
122+
private function findFirstPublishedRootByHostAndLanguage(string $host, string $language): ?PageModel
123+
{
124+
$t = PageModel::getTable();
125+
$columns = ["$t.type='root' AND ($t.dns=? OR $t.dns='') AND ($t.language=? OR $t.fallback='1')"];
126+
$values = [$host, $language];
127+
$options = ['order' => "$t.dns DESC, $t.fallback"];
128+
129+
if (!$this->tokenChecker()->isPreviewMode()) {
130+
$time = Date::floorToMinute();
131+
$columns[] = "$t.published='1' AND ($t.start='' OR $t.start<='$time') AND ($t.stop='' OR $t.stop>'$time')";
132+
}
133+
134+
return PageModel::findOneBy($columns, $values, $options);
135+
}
136+
}

src/Controller/FilesController.php

Lines changed: 10 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -12,41 +12,25 @@
1212

1313
namespace InspiredMinds\ContaoFileAccessBundle\Controller;
1414

15-
use Contao\Controller;
16-
use Contao\CoreBundle\Exception\AccessDeniedException;
17-
use Contao\CoreBundle\Exception\InsufficientAuthenticationException;
1815
use Contao\CoreBundle\Exception\PageNotFoundException;
1916
use Contao\CoreBundle\Framework\ContaoFramework;
20-
use Contao\CoreBundle\Security\Authentication\Token\TokenChecker;
21-
use Contao\Date;
2217
use Contao\Dbafs;
2318
use Contao\FilesModel;
24-
use Contao\FrontendUser;
2519
use Contao\PageModel;
26-
use Doctrine\DBAL\Connection;
2720
use Symfony\Component\HttpFoundation\BinaryFileResponse;
2821
use Symfony\Component\HttpFoundation\Request;
2922
use Symfony\Component\HttpFoundation\Session\Session;
30-
use Symfony\Component\Security\Core\Security;
3123
use Webmozart\PathUtil\Path;
3224

33-
class FilesController
25+
class FilesController extends AbstractFilesController
3426
{
35-
protected $rootDir;
36-
protected $session;
3727
protected $framework;
38-
protected $security;
39-
protected $db;
40-
protected $tokenChecker;
28+
protected $projectDir;
4129

42-
public function __construct(string $rootDir, Session $session, ContaoFramework $framework, Security $security, Connection $db, TokenChecker $tokenChecker)
30+
public function __construct(ContaoFramework $framework, string $projectDir)
4331
{
44-
$this->rootDir = $rootDir;
45-
$this->session = $session;
4632
$this->framework = $framework;
47-
$this->security = $security;
48-
$this->db = $db;
49-
$this->tokenChecker = $tokenChecker;
33+
$this->projectDir = $projectDir;
5034
}
5135

5236
public function fileAction(Request $request, string $file): BinaryFileResponse
@@ -62,16 +46,10 @@ public function fileAction(Request $request, string $file): BinaryFileResponse
6246
$this->framework->initialize(true);
6347

6448
// Set the root page for the domain as the pageModel attribute
65-
$root = $this->findFirstPublishedRootByHostAndLanguage($request->getHost(), $request->getLocale());
66-
67-
if (null !== $root) {
68-
$root->loadDetails();
69-
$request->attributes->set('pageModel', $root);
70-
$GLOBALS['objPage'] = $root;
71-
}
49+
$this->setRootPage($request);
7250

7351
// Check whether the file exists
74-
if (!is_file(Path::join($this->rootDir, $file))) {
52+
if (!is_file(Path::join($this->projectDir, $file))) {
7553
throw new PageNotFoundException();
7654
}
7755

@@ -88,82 +66,16 @@ public function fileAction(Request $request, string $file): BinaryFileResponse
8866
throw new PageNotFoundException();
8967
}
9068

91-
// Check folder permissions
92-
$allowLogin = false;
93-
$allowAccess = false;
94-
95-
// Get the current user
96-
$user = $this->security->getUser();
97-
98-
// Check if the current user can access their home directory
99-
$canAccessHomeDir = $user instanceof FrontendUser && !empty($user->homeDir) && $user->accessHomeDir;
100-
101-
do {
102-
// Check if this is a folder
103-
if ('folder' === $filesModel->type) {
104-
// Check if the current directory is an accessible user home
105-
$isHomeDir = (bool) $this->db->fetchOne('SELECT COUNT(*) FROM tl_member WHERE accessHomeDir = 1 AND homeDir = ?', [$filesModel->uuid]);
106-
107-
// Only check when member groups have been set or the folder is a user home
108-
if (null !== $filesModel->groups || $isHomeDir) {
109-
$allowLogin = true;
110-
111-
// Set the model to protected on the fly
112-
$filesModel->protected = true;
113-
114-
// Check if this is the user's home directory
115-
$isUserHomeDir = $user instanceof FrontendUser && $user->homeDir === $filesModel->uuid;
116-
117-
// Check access
118-
if (($canAccessHomeDir && $isUserHomeDir) || Controller::isVisibleElement($filesModel)) {
119-
$allowAccess = true;
120-
break;
121-
}
122-
}
123-
}
124-
125-
// Get the parent folder
126-
$filesModel = $filesModel->pid ? FilesModel::findById($filesModel->pid) : null;
127-
} while (null !== $filesModel);
128-
129-
// Throw 404 exception, if there were no user homes or folders with member groups
130-
if (!$allowLogin) {
131-
throw new PageNotFoundException();
132-
}
133-
134-
// Deny access
135-
if (!$allowAccess) {
136-
// If a user is authenticated or the 401 exception does not exist, throw 403 exception
137-
if ($this->security->isGranted('ROLE_MEMBER')) {
138-
throw new AccessDeniedException();
139-
}
140-
141-
// Otherwise throw 401 exception
142-
throw new InsufficientAuthenticationException();
143-
}
69+
// Check the permissions
70+
$this->checkFilePermissions($filesModel);
14471

14572
// Close the session
146-
$this->session->save();
73+
$request->getSession()->save();
14774

14875
// Try to override max_execution_time
14976
@ini_set('max_execution_time', '0');
15077

15178
// Return file to browser
152-
return new BinaryFileResponse(Path::join($this->rootDir, $file));
153-
}
154-
155-
protected function findFirstPublishedRootByHostAndLanguage(string $host, string $language): ?PageModel
156-
{
157-
$t = PageModel::getTable();
158-
$columns = ["$t.type='root' AND ($t.dns=? OR $t.dns='') AND ($t.language=? OR $t.fallback='1')"];
159-
$values = [$host, $language];
160-
$options = ['order' => "$t.dns DESC, $t.fallback"];
161-
162-
if (!$this->tokenChecker->isPreviewMode()) {
163-
$time = Date::floorToMinute();
164-
$columns[] = "$t.published='1' AND ($t.start='' OR $t.start<='$time') AND ($t.stop='' OR $t.stop>'$time')";
165-
}
166-
167-
return PageModel::findOneBy($columns, $values, $options);
79+
return new BinaryFileResponse(Path::join($this->projectDir, $file));
16880
}
16981
}

0 commit comments

Comments
 (0)