diff --git a/.build-v12/.gitignore b/.build-v12/.gitignore new file mode 100644 index 0000000..0003ce2 --- /dev/null +++ b/.build-v12/.gitignore @@ -0,0 +1,5 @@ +# Isolated v12 analysis toolchain: only the manifests are tracked. +/* +!.gitignore +!composer.json +!phpstan.neon diff --git a/.build-v12/composer.json b/.build-v12/composer.json new file mode 100644 index 0000000..4f6be37 --- /dev/null +++ b/.build-v12/composer.json @@ -0,0 +1,31 @@ +{ + "name": "fixpunkt/fp-fileprotector-cs-tools-v12", + "description": "Isolated tooling to static-analyse fp_fileprotector against TYPO3 12.4 (lowest supported). Mirror of .build/ but pinned to the v12 line.", + "autoload": { + "psr-4": { + "Fixpunkt\\FpFileprotector\\": "../Classes/" + } + }, + "require-dev": { + "phpstan/extension-installer": "^1.4", + "saschaegerer/phpstan-typo3": "^1.10", + "typo3/cms-core": "^12.4", + "typo3/cms-extbase": "*", + "typo3/cms-fluid": "*", + "typo3/cms-frontend": "*", + "typo3/cms-backend": "*", + "typo3/cms-install": "*" + }, + "config": { + "allow-plugins": { + "typo3/class-alias-loader": true, + "typo3/cms-composer-installers": true, + "phpstan/extension-installer": true + }, + "policy": { + "advisories": { + "block": false + } + } + } +} diff --git a/.build-v12/phpstan.neon b/.build-v12/phpstan.neon new file mode 100644 index 0000000..947b0b2 --- /dev/null +++ b/.build-v12/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 5 + paths: + - ../Classes + bootstrapFiles: + - vendor/autoload.php diff --git a/.build-v13/.gitignore b/.build-v13/.gitignore new file mode 100644 index 0000000..9238e59 --- /dev/null +++ b/.build-v13/.gitignore @@ -0,0 +1,5 @@ +# Isolated dev toolchain: only the manifest is tracked, everything that +# composer/TYPO3 generates here (vendor, lock, published public assets, …) is not. +/* +!.gitignore +!composer.json diff --git a/.build-v13/composer.json b/.build-v13/composer.json new file mode 100644 index 0000000..6df5654 --- /dev/null +++ b/.build-v13/composer.json @@ -0,0 +1,26 @@ +{ + "name": "fixpunkt/fp-fileprotector-cs-tools-v13", + "description": "Isolated dev tooling (coding standards + static analysis) for fp_fileprotector — not part of the extension package. Mirror of .build/ but pinned to the v13 line.", + "autoload": { + "psr-4": { + "Fixpunkt\\FpFileprotector\\": "../Classes/" + } + }, + "require-dev": { + "phpstan/extension-installer": "^1.4", + "saschaegerer/phpstan-typo3": "^2.1", + "typo3/cms-core": "^13.4", + "typo3/cms-extbase": "*", + "typo3/cms-fluid": "*", + "typo3/cms-frontend": "*", + "typo3/cms-backend": "*", + "typo3/cms-install": "*" + }, + "config": { + "allow-plugins": { + "typo3/class-alias-loader": true, + "typo3/cms-composer-installers": true, + "phpstan/extension-installer": true + } + } +} diff --git a/.build-v13/phpstan.neon b/.build-v13/phpstan.neon new file mode 100644 index 0000000..947b0b2 --- /dev/null +++ b/.build-v13/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 5 + paths: + - ../Classes + bootstrapFiles: + - vendor/autoload.php diff --git a/.build/.gitignore b/.build/.gitignore new file mode 100644 index 0000000..90990e7 --- /dev/null +++ b/.build/.gitignore @@ -0,0 +1,5 @@ +# Isolated v14 analysis toolchain: only the manifests are tracked. +/* +!.gitignore +!composer.json +!phpstan.neon diff --git a/.build/composer.json b/.build/composer.json new file mode 100644 index 0000000..46b1c53 --- /dev/null +++ b/.build/composer.json @@ -0,0 +1,27 @@ +{ + "name": "fixpunkt/fp-fileprotector-cs-tools", + "description": "Isolated tooling to static-analyse fp_fileprotector against TYPO3 14.3 (highest supported).", + "autoload": { + "psr-4": { + "Fixpunkt\\FpFileprotector\\": "../Classes/" + } + }, + "require-dev": { + "typo3/coding-standards": "^0.8", + "phpstan/extension-installer": "^1.4", + "saschaegerer/phpstan-typo3": "^3", + "typo3/cms-core": "^14.3", + "typo3/cms-extbase": "*", + "typo3/cms-fluid": "*", + "typo3/cms-frontend": "*", + "typo3/cms-backend": "*", + "typo3/cms-install": "*" + }, + "config": { + "allow-plugins": { + "typo3/class-alias-loader": true, + "typo3/cms-composer-installers": true, + "phpstan/extension-installer": true + } + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eb887a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +# Static analysis + coding standards across the supported TYPO3 versions. +# Reuses the isolated toolchains in .build/ (v14), .build-v13/ (v13, also hosts +# the coding-standards tooling) and .build-v12/ (v12) — see DEVELOPMENT.md. +# All jobs run on PHP 8.4 because fp-social-bridge (dev-feature/typo3-14) requires ^8.4. + +on: + push: + pull_request: + +jobs: + phpstan: + name: PHPStan (TYPO3 ${{ matrix.typo3 }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { typo3: '12.4', php: '8.4', build: '.build-v12', config: '.build-v12/phpstan.neon' } + - { typo3: '13.4', php: '8.4', build: '.build-v13', config: '.build-v13/phpstan.neon' } + - { typo3: '14.3', php: '8.4', build: '.build', config: 'phpstan.neon' } + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '${{ matrix.php }}' + tools: composer:v2 + coverage: none + - name: Install tooling (${{ matrix.build }}) + run: composer update --working-dir=${{ matrix.build }} --no-progress --no-interaction + - name: PHPStan + run: ${{ matrix.build }}/vendor/bin/phpstan analyse -c ${{ matrix.config }} --no-progress + + coding-standards: + name: Coding Standards + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + tools: composer:v2 + coverage: none + - name: Install tooling + run: composer update --working-dir=.build --no-progress --no-interaction + - name: php-cs-fixer (dry-run) + run: .build/vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --using-cache=no diff --git a/.gitignore b/.gitignore index 055339d..7642d29 100644 --- a/.gitignore +++ b/.gitignore @@ -7,12 +7,14 @@ vendor/ vendor/* Documentation-GENERATED-temp/ Documentation-GENERATED-temp/* +.php-cs-fixer.cache .env public public/* index.html composer.lock +.DS_Store # User-specific stuff: diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache deleted file mode 100644 index 51e7fcf..0000000 --- a/.php-cs-fixer.cache +++ /dev/null @@ -1 +0,0 @@ -{"php":"8.4.12","version":"3.95.2:v3.95.2#a28d88a5e172b27e78d0816992b15a9df3da20f1","indent":" ","lineEnding":"\n","rules":{"doctrine_annotation_array_assignment":{"operator":":"},"doctrine_annotation_braces":true,"doctrine_annotation_indentation":true,"doctrine_annotation_spaces":{"before_array_assignments_colon":false},"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_anonymous_functions":false,"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":{"space":"none"},"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"alpha"},"return_type_declaration":{"space_before":"none"},"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":true,"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"none"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":true,"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_indentation":true,"array_syntax":{"syntax":"short"},"cast_spaces":{"space":"none"},"concat_space":{"spacing":"one"},"declare_parentheses":true,"dir_constant":true,"function_to_constant":{"functions":["get_called_class","get_class","get_class_this","php_sapi_name","phpversion","pi"]},"type_declaration_spaces":true,"global_namespace_import":{"import_classes":false,"import_constants":false,"import_functions":false},"list_syntax":{"syntax":"short"},"modernize_strpos":true,"modernize_types_casting":true,"native_function_casing":true,"no_alias_functions":true,"no_blank_lines_after_phpdoc":true,"no_empty_phpdoc":true,"no_empty_statement":true,"no_leading_namespace_whitespace":true,"no_null_property_initialization":true,"no_short_bool_cast":true,"no_singleline_whitespace_before_semicolons":true,"no_superfluous_elseif":true,"no_trailing_comma_in_singleline":true,"no_unneeded_control_parentheses":true,"no_unused_imports":true,"no_useless_else":true,"no_useless_nullsafe_operator":true,"php_unit_construct":{"assertions":["assertEquals","assertSame","assertNotEquals","assertNotSame"]},"php_unit_mock_short_will_return":true,"php_unit_test_case_static_method_calls":{"call_type":"self"},"phpdoc_no_access":true,"phpdoc_no_empty_return":true,"phpdoc_no_package":true,"phpdoc_scalar":true,"phpdoc_trim":true,"phpdoc_types":true,"phpdoc_types_order":{"null_adjustment":"always_last","sort_algorithm":"none"},"single_quote":true,"single_line_comment_style":{"comment_types":["hash"]},"single_line_empty_body":true,"trailing_comma_in_multiline":{"elements":["arrays"]},"whitespace_after_comma_in_array":{"ensure_single_space":true},"yoda_style":{"equal":false,"identical":false,"less_and_greater":false}},"ruleCustomisationPolicyVersion":"null-policy","hashes":{"Classes\/Middleware\/AccessMiddleware.php":"311483c94efaf0dadfcf794073970873","Classes\/Controller\/StartController.php":"5b4b331a6a8a703e6c9f21056a90fe98","Classes\/Controller\/ProtectionController.php":"67c78eb64099532d2a2dad6a8025d8cb","Classes\/Controller\/FileStorageController.php":"36152c0c86616b51e389c7d653795074","Classes\/Controller\/FolderController.php":"6d9b2ca525a66652fadc0be32843a0b9","Classes\/Resource\/ResourceStorage.php":"b315c04560c8b44daf05a12c3e4e349a","Classes\/Resource\/Folder.php":"640723988f95aba989b3c8e1b310fbf9","Classes\/Domain\/Repository\/FolderRepository.php":"e0c2cf5f1d27d48eac6f15ace15b46c4","Classes\/Domain\/Repository\/FrontendUserRepository.php":"f49a807af02707e8cb019d821a05fdb2","Classes\/Domain\/Repository\/FileStorageRepository.php":"7ffe5ab1194a8d1e55db19df862d3a0c","Classes\/Domain\/Repository\/ProtectionRepository.php":"a20546bebd1f1ad0ca0158dbc1ff2190","Classes\/Domain\/Repository\/FrontendUserGroupRepository.php":"86079045c3ac1f9ae8fef5f384aaba76","Classes\/Domain\/Model\/FrontendUser.php":"08db50d4abfa9e242d24eccfe93dfd87","Classes\/Domain\/Model\/Protection.php":"4eed69e6f9f0ffdeed0ccb59a1d67851","Classes\/Domain\/Model\/FrontendUserGroup.php":"a1476b99031905684c806f60bc43e0aa","Classes\/Utility\/HtaccessUtility.php":"7062630e6ea0c7843608bf6b9a2d5d06","Classes\/Utility\/FrontendUserUtility.php":"76b6e4743db22452782ba3eca158879f"}} \ No newline at end of file diff --git a/Classes/AccessType/AccessTypeInterface.php b/Classes/AccessType/AccessTypeInterface.php new file mode 100644 index 0000000..7c7549e --- /dev/null +++ b/Classes/AccessType/AccessTypeInterface.php @@ -0,0 +1,12 @@ + $protection Raw protection database record */ + public function isGranted(array $protection): bool; + public function getPartials(): string; +} diff --git a/Classes/AccessType/BeLoginAccessType.php b/Classes/AccessType/BeLoginAccessType.php new file mode 100644 index 0000000..ee3d81b --- /dev/null +++ b/Classes/AccessType/BeLoginAccessType.php @@ -0,0 +1,43 @@ + $protection Raw protection database record */ + public function isGranted(array $protection): bool + { + /** @var BackendUserAuthentication|null $beUser */ + $beUser = $GLOBALS['BE_USER'] ?? null; + if (!$beUser || empty($beUser->user['uid'])) { + return false; + } + + if ($beUser->isAdmin()) { + return true; + } + + foreach ($beUser->getFileStorages() as $storage) { + if ($storage->getUid() !== (int)$protection['storage']) { + continue; + } + try { + $folder = $storage->getFolder((string)$protection['folder']); + return $storage->isWithinFileMountBoundaries($folder, false); + } catch (\Exception) { + return false; + } + } + + return false; + } + + public function getPartials(): string + { + return 'Access/Be'; + } +} diff --git a/Classes/AccessType/FeLoginAccessType.php b/Classes/AccessType/FeLoginAccessType.php new file mode 100644 index 0000000..da8adac --- /dev/null +++ b/Classes/AccessType/FeLoginAccessType.php @@ -0,0 +1,78 @@ + $protection Raw protection database record */ + public function isGranted(array $protection): bool + { + if (!(bool)$protection['fe_login']) { + return false; + } + + $feUser = $this->frontendUserUtility->getCurrentFrontendUser(); + if (!$feUser || !$feUser->isLoggedIn()) { + return false; + } + + $protectionUid = (int)$protection['uid']; + $userUids = $this->getRelatedUids(self::USERS_MM, $protectionUid); + $userGroupUids = $this->getRelatedUids(self::GROUPS_MM, $protectionUid); + + if ($userGroupUids === [] && $userUids === []) { + return true; + } + + if (in_array($feUser->get('id'), $userUids, true)) { + return true; + } + + foreach ($feUser->get('groupIds') as $userGroupId) { + if (in_array($userGroupId, $userGroupUids, true)) { + return true; + } + } + + return false; + } + + /** + * Reads the related foreign uids from an MM table for a protection record. + * + * @return int[] + */ + private function getRelatedUids(string $mmTable, int $protectionUid): array + { + $queryBuilder = $this->connectionPool->getConnectionForTable($mmTable)->createQueryBuilder(); + $uids = $queryBuilder + ->select('uid_foreign') + ->from($mmTable) + ->where( + $queryBuilder->expr()->eq('uid_local', $queryBuilder->createNamedParameter($protectionUid, ParameterType::INTEGER)) + ) + ->executeQuery() + ->fetchFirstColumn(); + + return array_map('intval', $uids); + } + + public function getPartials(): string + { + return 'Access/Fe'; + } +} diff --git a/Classes/Controller/FileStorageController.php b/Classes/Controller/FileStorageController.php index 6626f3f..7cbc617 100644 --- a/Classes/Controller/FileStorageController.php +++ b/Classes/Controller/FileStorageController.php @@ -40,7 +40,6 @@ public function editAction(string $id, int $fileStorageUid): ResponseInterface /** * Updates settings of a file storage. * - * @param int $id * @param int $fileStorageUid * @param bool $protected * @param bool $protectedByDefault diff --git a/Classes/Controller/FolderController.php b/Classes/Controller/FolderController.php index 7060aab..b984804 100644 --- a/Classes/Controller/FolderController.php +++ b/Classes/Controller/FolderController.php @@ -6,6 +6,8 @@ use Fixpunkt\FpFileprotector\Domain\Repository\FolderRepository; use Fixpunkt\FpFileprotector\Resource\Folder; +use Fixpunkt\FpFileprotector\Service\AccessService; +use Fixpunkt\FpFileprotector\Service\ProtectionService; use Psr\Http\Message\ResponseInterface; use TYPO3\CMS\Backend\Attribute\AsController; use TYPO3\CMS\Backend\Template\Components\ButtonBar; @@ -29,6 +31,8 @@ public function __construct( protected readonly IconFactory $iconFactory, protected readonly FolderRepository $folderRepository, protected readonly StorageRepository $storageRepository, + protected readonly AccessService $accessService, + protected readonly ProtectionService $protectionService, ) {} /** @@ -37,7 +41,7 @@ public function __construct( * @param string $id * @return ResponseInterface */ - public function showAction(string $id = "", bool $refreshFolderTree = false): ResponseInterface + public function showAction(string $id = '', bool $refreshFolderTree = false): ResponseInterface { // modify id or redirect to first folder $id = $this->modifyId($id); @@ -56,15 +60,37 @@ public function showAction(string $id = "", bool $refreshFolderTree = false): Re $moduleTemplate = $this->moduleTemplateFactory->create($this->request); $this->initializeDocHeader($moduleTemplate, $folder); $this->statusCheck($folder); - $moduleTemplate->assign('folder', $folder); + + $protection = $folder->getProtection(); + $moduleTemplate->assignMultiple([ + 'folder' => $folder, + 'accessPartials' => $this->accessService->getPartials(), + 'protectionDisplay' => $protection ? $this->protectionService->getDisplayValues((int)$protection['uid']) : [], + 'inheritedFolder' => $this->getInheritedFolder($folder, $protection), + ]); return $moduleTemplate->renderResponse('Folder/Show'); } + /** + * Returns the folder an inherited protection originates from, or null when + * the folder has its own (or no) protection. + * + * @param array|null $protection + */ + protected function getInheritedFolder(Folder $folder, ?array $protection): ?Folder + { + if ($protection === null || $folder->getOwnProtection() !== null) { + return null; + } + $combinedIdentifier = $protection['storage'] . ':' . $protection['folder']; + return $this->folderRepository->findOneByCombinedIdentifier($combinedIdentifier); + } + protected function statusCheck(Folder $folder): void { // show information if the storage is NOT protected if (!$folder->getStorage()->isProtected()) { - if ($folder->isProtected()) { + if ($folder->getProtection() !== null) { $this->addFlashMessage( LocalizationUtility::translate('folder.show.storage_not_protected_with_rule', 'FpFileprotector'), LocalizationUtility::translate('folder.show.storage_not_protected', 'FpFileprotector'), @@ -88,16 +114,15 @@ protected function statusCheck(Folder $folder): void * * @param ModuleTemplate $moduleTemplate * @param Folder $folder - * @return void */ protected function initializeDocHeader(ModuleTemplate $moduleTemplate, Folder $folder): void { $moduleTemplate->getDocHeaderComponent()->setMetaInformationForResource($folder); $editStorageUri = $this->uriBuilder->reset() - ->uriFor('edit', ['fileStorageUid' => $folder->getStorage()->getUid()], 'FileStorage'); + ->uriFor('edit', ['fileStorageUid' => $folder->getStorage()->getUid(), 'id' => $folder->getCombinedIdentifier()], 'FileStorage'); $htaccessUri = $this->uriBuilder->reset() - ->uriFor('htaccess', ['fileStorageUid' => $folder->getStorage()->getUid()], 'FileStorage'); + ->uriFor('htaccess', ['fileStorageUid' => $folder->getStorage()->getUid(), 'id' => $folder->getCombinedIdentifier()], 'FileStorage'); // add buttons $buttonBar = $moduleTemplate->getDocHeaderComponent()->getButtonBar(); @@ -114,17 +139,19 @@ protected function initializeDocHeader(ModuleTemplate $moduleTemplate, Folder $f ->setIcon($this->iconFactory->getIcon('tx-fpfileprotector-folder-public')) ->setLabel(LocalizationUtility::translate('module.storage_is_unprotected', 'FpFileprotector')); } + $storageSettingsItem = GeneralUtility::makeInstance(DropDownItem::class); + $storageSettingsItem + ->setLabel(LocalizationUtility::translate('module.dropdown.storage_settings', 'FpFileprotector')) + ->setHref($editStorageUri); + + $htaccessItem = GeneralUtility::makeInstance(DropDownItem::class); + $htaccessItem + ->setLabel(LocalizationUtility::translate('module.dropdown.htaccess_update', 'FpFileprotector')) + ->setHref($htaccessUri); + $dropdownButton - ->addItem( - GeneralUtility::makeInstance(DropDownItem::class) - ->setLabel(LocalizationUtility::translate('module.dropdown.storage_settings', 'FpFileprotector')) - ->setHref($editStorageUri) - ) - ->addItem( - GeneralUtility::makeInstance(DropDownItem::class) - ->setLabel(LocalizationUtility::translate('module.dropdown.htaccess_update', 'FpFileprotector')) - ->setHref($htaccessUri) - ); + ->addItem($storageSettingsItem) + ->addItem($htaccessItem); $buttonBar->addButton($dropdownButton, ButtonBar::BUTTON_POSITION_RIGHT); } @@ -154,8 +181,8 @@ protected function modifyId(string $id): string $moduleData->set('id', $id); $GLOBALS['BE_USER']->pushModuleData($moduleData->getModuleIdentifier(), $moduleData->toArray()); return $id; - } else { - return $moduleData->get('id', ''); } + return $moduleData->get('id', ''); + } } diff --git a/Classes/Controller/ProtectionController.php b/Classes/Controller/ProtectionController.php index e4ea5d7..9834cb5 100644 --- a/Classes/Controller/ProtectionController.php +++ b/Classes/Controller/ProtectionController.php @@ -4,18 +4,16 @@ namespace Fixpunkt\FpFileprotector\Controller; -use Fixpunkt\FpFileprotector\Domain\Model\Protection; use Fixpunkt\FpFileprotector\Domain\Repository\FolderRepository; use Fixpunkt\FpFileprotector\Domain\Repository\FrontendUserGroupRepository; use Fixpunkt\FpFileprotector\Domain\Repository\FrontendUserRepository; -use Fixpunkt\FpFileprotector\Domain\Repository\ProtectionRepository; +use Fixpunkt\FpFileprotector\Service\AccessService; +use Fixpunkt\FpFileprotector\Service\ProtectionService; use Psr\Http\Message\ResponseInterface; use TYPO3\CMS\Backend\Attribute\AsController; use TYPO3\CMS\Backend\Template\ModuleTemplateFactory; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; -use TYPO3\CMS\Extbase\Persistence\Exception\IllegalObjectTypeException; -use TYPO3\CMS\Extbase\Persistence\Exception\UnknownObjectException; use TYPO3\CMS\Extbase\Persistence\Generic\Typo3QuerySettings; use TYPO3\CMS\Extbase\Utility\LocalizationUtility; @@ -24,10 +22,11 @@ class ProtectionController extends ActionController { public function __construct( protected readonly ModuleTemplateFactory $moduleTemplateFactory, - protected readonly ProtectionRepository $protectionRepository, protected readonly FrontendUserGroupRepository $userGroupRepository, protected readonly FrontendUserRepository $userRepository, protected readonly FolderRepository $folderRepository, + protected readonly AccessService $accessService, + protected readonly ProtectionService $protectionService, ) { $querySettings = GeneralUtility::makeInstance(Typo3QuerySettings::class); $querySettings->setRespectStoragePage(false); @@ -37,19 +36,17 @@ public function __construct( /** * Provides the folder protection creation form. - * - * @param string $combinedIdentifier - * @return ResponseInterface */ public function newAction(string $combinedIdentifier): ResponseInterface { $moduleTemplate = $this->moduleTemplateFactory->create($this->request); $moduleTemplate->assignMultiple([ - 'protection' => GeneralUtility::makeInstance(Protection::class), 'folder' => $this->folderRepository->findOneByCombinedIdentifier($combinedIdentifier), 'userGroups' => $this->userGroupRepository->findAll(), 'users' => $this->userRepository->findAll(), + 'accessPartials' => $this->accessService->getPartials(), + 'record' => [], ]); return $moduleTemplate->renderResponse('Protection/New'); @@ -58,71 +55,70 @@ public function newAction(string $combinedIdentifier): ResponseInterface /** * Creates a new folder protection. * - * @param Protection $protection - * @return ResponseInterface - * @throws IllegalObjectTypeException + * @param array $protection Raw access-type fields keyed by TCA column */ - public function createAction(Protection $protection): ResponseInterface + public function createAction(string $combinedIdentifier, array $protection = []): ResponseInterface { - $this->protectionRepository->add($protection); + $folder = $this->folderRepository->findOneByCombinedIdentifier($combinedIdentifier); + $this->protectionService->create( + $folder->getStorage()->getUid(), + $folder->getIdentifier(), + $protection + ); + $this->addFlashMessage(LocalizationUtility::translate( 'tx_fpfileprotector_domain_model_protection.flashmessages.created', 'FpFileprotector' )); - return $this->redirect('show', 'Folder', null, ['id' => $protection->getFolderObject()->getCombinedIdentifier(), 'refreshFolderTree' => true]); + return $this->redirect('show', 'Folder', null, ['id' => $combinedIdentifier, 'refreshFolderTree' => true]); } /** * Provides the folder protection edit form. - * - * @param Protection $protection - * @return ResponseInterface */ - public function editAction(Protection $protection): ResponseInterface + public function editAction(int $protectionUid, string $combinedIdentifier): ResponseInterface { $moduleTemplate = $this->moduleTemplateFactory->create($this->request); $moduleTemplate->assignMultiple([ - 'protection' => $protection, - 'folder' => $protection->getFolderObject(), + 'folder' => $this->folderRepository->findOneByCombinedIdentifier($combinedIdentifier), 'userGroups' => $this->userGroupRepository->findAll(), 'users' => $this->userRepository->findAll(), + 'accessPartials' => $this->accessService->getPartials(), + 'protectionUid' => $protectionUid, + 'record' => $this->protectionService->getFormValues($protectionUid), ]); return $moduleTemplate->renderResponse('Protection/Edit'); } /** - * @param Protection $protection - * @return ResponseInterface - * @throws IllegalObjectTypeException - * @throws UnknownObjectException + * Updates an existing folder protection. + * + * @param array $protection Raw access-type fields keyed by TCA column */ - public function updateAction(Protection $protection): ResponseInterface + public function updateAction(int $protectionUid, string $combinedIdentifier, array $protection = []): ResponseInterface { - $this->protectionRepository->update($protection); + $this->protectionService->update($protectionUid, $protection); + $this->addFlashMessage(LocalizationUtility::translate( 'tx_fpfileprotector_domain_model_protection.flashmessages.updated', 'FpFileprotector' )); - - return $this->redirect('show', 'Folder', null, ['id' => $protection->getFolderObject()->getCombinedIdentifier(), 'refreshFolderTree' => true]); + return $this->redirect('show', 'Folder', null, ['id' => $combinedIdentifier, 'refreshFolderTree' => true]); } /** * Removes folder protection. - * - * @param Protection $protection - * @return ResponseInterface - * @throws IllegalObjectTypeException */ - public function deleteAction(Protection $protection): ResponseInterface + public function deleteAction(int $protectionUid, string $combinedIdentifier): ResponseInterface { - $this->protectionRepository->remove($protection); + $this->protectionService->delete($protectionUid); + $this->addFlashMessage(LocalizationUtility::translate( 'tx_fpfileprotector_domain_model_protection.flashmessages.deleted', 'FpFileprotector' )); - return $this->redirect('show', 'Folder', null, ['id' => $protection->getFolderObject()->getCombinedIdentifier(), 'refreshFolderTree' => true]); + return $this->redirect('show', 'Folder', null, ['id' => $combinedIdentifier, 'refreshFolderTree' => true]); } -} \ No newline at end of file +} diff --git a/Classes/Domain/Model/Protection.php b/Classes/Domain/Model/Protection.php deleted file mode 100644 index 069073e..0000000 --- a/Classes/Domain/Model/Protection.php +++ /dev/null @@ -1,226 +0,0 @@ -|null */ - protected ?ObjectStorage $userGroups = null; - /** @var ObjectStorage|null */ - protected ?ObjectStorage $users = null; - - public function __construct() - { - $this->userGroups = new ObjectStorage(); - $this->users = new ObjectStorage(); - } - - /** - * @return int - */ - public function getStorage(): int - { - return $this->storage; - } - /** - * @param int $storage - */ - public function setStorage(int $storage): void - { - $this->storage = $storage; - } - - /** - * @return string - */ - public function getFolder(): string - { - return $this->folder; - } - /** - * @param string $folder - */ - public function setFolder(string $folder): void - { - $this->folder = $folder; - } - /** - * Returns the folder as an object. - * - * @return Folder|null - */ - public function getFolderObject(): ?Folder - { - /** @var FolderRepository $folderRepository */ - $folderRepository = GeneralUtility::makeInstance(FolderRepository::class); - return $folderRepository->findOneByCombinedIdentifier($this->getStorage() . ':' . $this->getFolder()); - } - - /** - * @return bool - */ - public function isFeLogin(): bool - { - return $this->feLogin; - } - /** - * @param bool $feLogin - */ - public function setFeLogin(bool $feLogin): void - { - $this->feLogin = $feLogin; - } - - /** - * @return bool - */ - public function isBeLogin(): bool - { - return $this->beLogin; - } - /** - * @param bool $beLogin - */ - public function setBeLogin(bool $beLogin): void - { - $this->beLogin = $beLogin; - } - - /** - * @return ObjectStorage|null - */ - public function getUserGroups(): ?ObjectStorage - { - return $this->userGroups; - } - public function getUserGroupsUids(): array - { - $uids = []; - /** @var FrontendUserGroup $userGroup */ - foreach ($this->getUserGroups() as $userGroup) { - $uids[] = $userGroup->getUid(); - } - return $uids; - } - /** - * @param ObjectStorage $userGroups - */ - public function setUserGroups(ObjectStorage $userGroups): void - { - $this->userGroups = $userGroups; - } - /** - * @param FrontendUserGroup $userGroup - */ - public function addUserGroup(FrontendUserGroup $userGroup): void - { - $this->userGroups->attach($userGroup); - } - /** - * @param FrontendUserGroup $userGroup - */ - public function removeUserGroup(FrontendUserGroup $userGroup): void - { - $this->userGroups->detach($userGroup); - } - - /** - * @return ObjectStorage|null - */ - public function getUsers(): ?ObjectStorage - { - return $this->users; - } - public function getUsersUids(): array - { - $uids = []; - /** @var FrontendUser $user */ - foreach ($this->getUsers() as $user) { - $uids[] = $user->getUid(); - } - return $uids; - } - /** - * @param ObjectStorage $users - */ - public function setUsers(ObjectStorage $users): void - { - $this->users = $users; - } - /** - * @param FrontendUser $user - */ - public function addUser(FrontendUser $user): void - { - $this->users->attach($user); - } - /** - * @param FrontendUser $user - */ - public function removeUser(FrontendUser $user): void - { - $this->users->detach($user); - } - - /** - * Checks whether the current user is allowed to access the resource. - * - * @return bool - */ - public function isGranted(): bool - { - if ($this->isFeLogin()) { - $frontendUserUtility = GeneralUtility::makeInstance(FrontendUserUtility::class); - $feUser = $frontendUserUtility->getCurrentFrontendUser(); - if ($feUser && $feUser->isLoggedIn()) { - if ($this->getUserGroups()->count() === 0 && $this->getUsers()->count() === 0) { - return true; - } - - if (in_array($feUser->get('id'), $this->getUsersUids(), true)) { - return true; - } - - foreach ($feUser->get('groupIds') as $userGroupId) { - if (in_array($userGroupId, $this->getUserGroupsUids(), true)) { - return true; - } - } - } - } - - $context = GeneralUtility::makeInstance(Context::class); - if ( - $this->isBeLogin() - && (bool)$context->getPropertyFromAspect('backend.user', 'isLoggedIn') - ) { - return true; - } - - return false; - } - - /** - * Returns whether the folder protection applies any restrictions. - * - * @return bool - */ - public function isProtected(): bool - { - return $this->isFeLogin() || $this->isBeLogin(); - } -} diff --git a/Classes/Domain/Repository/FileStorageRepository.php b/Classes/Domain/Repository/FileStorageRepository.php index 17e6b3f..3351629 100644 --- a/Classes/Domain/Repository/FileStorageRepository.php +++ b/Classes/Domain/Repository/FileStorageRepository.php @@ -4,15 +4,17 @@ namespace Fixpunkt\FpFileprotector\Domain\Repository; -use Doctrine\DBAL\Driver\Exception; use Fixpunkt\FpFileprotector\Resource\ResourceStorage; use TYPO3\CMS\Core\Database\ConnectionPool; -use TYPO3\CMS\Core\Database\Query\QueryBuilder; -use TYPO3\CMS\Core\Resource\ResourceFactory; -use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Resource\StorageRepository; class FileStorageRepository { + public function __construct( + private readonly StorageRepository $storageRepository, + private readonly ConnectionPool $connectionPool, + ) {} + /** * Returns all file storages. * @@ -20,48 +22,42 @@ class FileStorageRepository */ public function findAll(): array { - /** @var QueryBuilder $queryBuilder */ - $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_storage')->createQueryBuilder(); - $query = $queryBuilder - ->select('uid')->from('sys_file_storage')->executeQuery(); - $fileStorages = []; - $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); - try { - foreach ($query->fetchAllAssociative() as $data) { - $fileStorages[] = $resourceFactory->getStorageObject($data['uid']); + foreach ($this->storageRepository->findAll() as $storage) { + // The core storage is XCLASSed to our subclass (see ext_localconf.php). + if ($storage instanceof ResourceStorage) { + $fileStorages[] = $storage; } - } catch (Exception) { } return $fileStorages; } /** * Returns a single resource storage. - * - * @param int $fileStorageUid - * @return ResourceStorage */ public function findByIdentifier(int $fileStorageUid): ResourceStorage { - /** @var ResourceFactory $resourceFactory */ - $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); - return $resourceFactory->getStorageObject($fileStorageUid); + $storage = $this->storageRepository->findByUid($fileStorageUid); + // The core storage is XCLASSed to our subclass (see ext_localconf.php). + if (!$storage instanceof ResourceStorage) { + throw new \RuntimeException( + 'Expected an instance of ' . ResourceStorage::class . ', got ' . ($storage === null ? 'null' : $storage::class) . '.', + 1752480001 + ); + } + return $storage; } /** * Updates a file storage. - * - * @param ResourceStorage $fileStorage */ public function update(ResourceStorage $fileStorage): void { - /** @var QueryBuilder $queryBuilder */ - $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_storage')->createQueryBuilder(); + $queryBuilder = $this->connectionPool->getConnectionForTable('sys_file_storage')->createQueryBuilder(); $queryBuilder ->update('sys_file_storage') - ->set('protected', $fileStorage->isProtected()) - ->set('protected_by_default', $fileStorage->isProtectedByDefault() ?: 0) + ->set('protected', (int)$fileStorage->isProtected()) + ->set('protected_by_default', (int)$fileStorage->isProtectedByDefault()) ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($fileStorage->getUid()))) ->executeStatement(); } diff --git a/Classes/Domain/Repository/FolderRepository.php b/Classes/Domain/Repository/FolderRepository.php index b21d2e6..32dda7d 100644 --- a/Classes/Domain/Repository/FolderRepository.php +++ b/Classes/Domain/Repository/FolderRepository.php @@ -4,7 +4,7 @@ namespace Fixpunkt\FpFileprotector\Domain\Repository; -use TYPO3\CMS\Core\Resource\Folder; +use Fixpunkt\FpFileprotector\Resource\Folder; use TYPO3\CMS\Core\Resource\ResourceFactory; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -19,7 +19,8 @@ class FolderRepository public function findOneByCombinedIdentifier(string $combinedIdentifier): ?Folder { $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); - /** @var Folder $folder */ - return $resourceFactory->getFolderObjectFromCombinedIdentifier($combinedIdentifier); + $folder = $resourceFactory->getFolderObjectFromCombinedIdentifier($combinedIdentifier); + // The core Folder is XCLASSed to our subclass (see ext_localconf.php). + return $folder instanceof Folder ? $folder : null; } } diff --git a/Classes/Domain/Repository/ProtectionRepository.php b/Classes/Domain/Repository/ProtectionRepository.php deleted file mode 100644 index 0c46c2d..0000000 --- a/Classes/Domain/Repository/ProtectionRepository.php +++ /dev/null @@ -1,84 +0,0 @@ -createQuery(); - $query->getQuerySettings()->setRespectStoragePage(false); - $query->matching( - $query->logicalAnd( - $query->equals('storage', $folder->getStorage()->getUid()), - $query->equals('folder', $folder->getIdentifier()) - ) - ); - $results = $query->execute(); - - return $results->current() ?: null; - } - - /** - * Finds folder protection for a folder or one of its parent folders. - * - * @param FolderInterface $folder - * @param bool $recursive - * @return Protection|null - */ - public function getProtection(FolderInterface $folder, bool $recursive = true): ?Protection - { - $protection = $this->findOneByFolder($folder); - if (!$protection && $recursive && $folder->hasParentFolder()) { - return $this->getProtection($folder->getParentFolder()); - } - return $protection; - } - - /** - * Static function to get protection data for middleware. - * Injecting the repository itself into the middleware caused errors in some instances. - * - * @param FolderInterface $folder - * @return Protection|null - */ - public static function getProtectionStatic(FolderInterface $folder): ?Protection - { - // find protection data from database - $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_fpfileprotector_domain_model_protection'); - $statement = $queryBuilder - ->select('*') - ->from('tx_fpfileprotector_domain_model_protection') - ->where( - $queryBuilder->expr()->eq('storage', $queryBuilder->createNamedParameter($folder->getStorage()->getUid(), ParameterType::INTEGER)), - $queryBuilder->expr()->eq('folder', $queryBuilder->createNamedParameter($folder->getIdentifier())) - ) - ->executeQuery(); - - // Convert data to object and return - $dataMapper = GeneralUtility::makeInstance(DataMapper::class); - $protections = $dataMapper->map(Protection::class, $statement->fetchAllAssociative()); - $protection = count($protections) ? $protections[0] : null; - - if (!$protection && $folder->hasParentFolder()) { - return self::getProtectionStatic($folder->getParentFolder()); - } - return $protection; - } -} diff --git a/Classes/EventListener/ModifyIconForResourcePropertiesListener.php b/Classes/EventListener/ModifyIconForResourcePropertiesListener.php index 62117c1..c634ae4 100644 --- a/Classes/EventListener/ModifyIconForResourcePropertiesListener.php +++ b/Classes/EventListener/ModifyIconForResourcePropertiesListener.php @@ -7,7 +7,6 @@ use Fixpunkt\FpFileprotector\Resource\Folder; use Fixpunkt\FpFileprotector\Resource\ResourceStorage; use TYPO3\CMS\Core\Imaging\Event\ModifyIconForResourcePropertiesEvent; -use TYPO3\CMS\Core\Resource\ResourceInterface; class ModifyIconForResourcePropertiesListener { @@ -37,4 +36,4 @@ public function __invoke(ModifyIconForResourcePropertiesEvent $event): void } } } -} \ No newline at end of file +} diff --git a/Classes/Middleware/AccessMiddleware.php b/Classes/Middleware/AccessMiddleware.php index 5bfe457..8ded215 100644 --- a/Classes/Middleware/AccessMiddleware.php +++ b/Classes/Middleware/AccessMiddleware.php @@ -4,7 +4,7 @@ namespace Fixpunkt\FpFileprotector\Middleware; -use Fixpunkt\FpFileprotector\Domain\Repository\ProtectionRepository; +use Fixpunkt\FpFileprotector\Service\AccessService; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; @@ -16,10 +16,11 @@ use TYPO3\CMS\Core\Resource\ResourceStorage; use TYPO3\CMS\Core\Resource\StorageRepository; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Extbase\Utility\LocalizationUtility; class AccessMiddleware implements MiddlewareInterface { + public function __construct(private readonly AccessService $accessService) {} + public function process( ServerRequestInterface $request, RequestHandlerInterface $handler @@ -44,7 +45,7 @@ public function process( $storage = $this->getStorage($storageIdentifier); if (!$storage) { - return $this->createError(LocalizationUtility::translate('sys_file_storage.errors.storage_not_found', 'FpFileprotector')); + return $this->createError('The storage could not be found.'); } $protected = $storage->getStorageRecord()['protected']; $protectedByDefault = $storage->getStorageRecord()['protected_by_default']; @@ -65,11 +66,11 @@ public function process( ) ) ) { - return $this->createError(LocalizationUtility::translate('sys_file_storage.errors.file_not_found', 'FpFileprotector')); + return $this->createError('The file could not be found.'); } $originalFile = $file; if ($originalFile instanceof ProcessedFile) { - $originalFile = $file->getOriginalFile(); + $originalFile = $originalFile->getOriginalFile(); } if (!$protected) { @@ -77,15 +78,12 @@ public function process( } $folder = $originalFile->getParentFolder(); - if (!$folder) { - return $this->createError(LocalizationUtility::translate('sys_file_storage.errors.folder_not_found', 'FpFileprotector')); - } - $protection = ProtectionRepository::getProtectionStatic($folder); - if ((!$protection && !$protectedByDefault) || ($protection && $protection->isGranted())) { + $protection = $this->accessService->getProtection($folder); + if ((!$protection && !$protectedByDefault) || ($protection && $this->accessService->isGranted($protection))) { return $this->releaseFile($storage, $filePath); } - return $this->createError(LocalizationUtility::translate('sys_file_storage.errors.access_denied', 'FpFileprotector'), 500); + return $this->createError('You do not have permission to access this file.', 500); } /** @@ -133,9 +131,6 @@ private function createError(string $reason, int $status = 404): Response private function releaseFile(ResourceStorage $storage, string $fileIdentifier): Response { $file = $storage->getFile($fileIdentifier); - if (!$file) { - return $this->createError(LocalizationUtility::translate('sys_file_storage.errors.file_release_not_found', 'FpFileprotector')); - } $body = new Stream('php://temp', 'rw'); $body->write($file->getContents()); @@ -143,4 +138,4 @@ private function releaseFile(ResourceStorage $storage, string $fileIdentifier): ->withHeader('Content-Type', $file->getMimeType()) ->withBody($body); } -} \ No newline at end of file +} diff --git a/Classes/Resource/Folder.php b/Classes/Resource/Folder.php index 8259a41..d034326 100644 --- a/Classes/Resource/Folder.php +++ b/Classes/Resource/Folder.php @@ -4,8 +4,8 @@ namespace Fixpunkt\FpFileprotector\Resource; -use Fixpunkt\FpFileprotector\Domain\Model\Protection; -use Fixpunkt\FpFileprotector\Domain\Repository\ProtectionRepository; +use Fixpunkt\FpFileprotector\Service\AccessService; +use Fixpunkt\FpFileprotector\Service\ProtectionService; use TYPO3\CMS\Core\Resource as Core; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -14,27 +14,40 @@ */ class Folder extends Core\Folder { - /** @var ResourceStorage */ - protected $storage; + /** + * Returns the storage this folder belongs to. + * + * The core storage is XCLASSed to our subclass (see ext_localconf.php), + * so we can safely narrow the return type. + */ + public function getStorage(): ResourceStorage + { + $storage = parent::getStorage(); + if (!$storage instanceof ResourceStorage) { + throw new \RuntimeException( + 'Expected an instance of ' . ResourceStorage::class . ', got ' . $storage::class . '.', + 1752480000 + ); + } + return $storage; + } /** - * Returns protection for this folder or one of its parent folders. + * Returns the raw protection record for this folder or one of its parent + * folders. * - * @param bool $recursive - * @return Protection|null + * @return array|null */ - public function getProtection(bool $recursive = true): ?Protection + public function getProtection(bool $recursive = true): ?array { - /** @var ProtectionRepository $protectionRepository */ - $protectionRepository = GeneralUtility::makeInstance(ProtectionRepository::class); - return $protectionRepository->getProtection($this, $recursive); + return GeneralUtility::makeInstance(ProtectionService::class)->getRecord($this, $recursive); } /** - * Returns protection assigned directly to this folder. + * Returns the raw protection record assigned directly to this folder. * - * @return Protection|null + * @return array|null */ - public function getOwnProtection(): ?Protection + public function getOwnProtection(): ?array { return $this->getProtection(false); } @@ -65,13 +78,17 @@ public function hasParentFolder(): bool public function getProtectionStatus(): string { $protection = $this->getProtection(); - if ($protection && $protection->isProtected()) { - // protection is set - return $this->getOwnProtection() ? 'protected' : 'protected_by_parent'; - } else { - // no protection is set - return $this->storage->isProtectedByDefault() ? 'no_access' : 'public'; + if ($protection) { + // A rule applies: "protected" means the current user is granted + // access by at least one access utility; otherwise it is locked. + if (GeneralUtility::makeInstance(AccessService::class)->isGranted($protection)) { + return $this->getOwnProtection() ? 'protected' : 'protected_by_parent'; + } + return 'no_access'; } + // no rule applies + return $this->getStorage()->isProtectedByDefault() ? 'no_access' : 'public'; + } /** diff --git a/Classes/Service/AccessService.php b/Classes/Service/AccessService.php new file mode 100644 index 0000000..d3e5528 --- /dev/null +++ b/Classes/Service/AccessService.php @@ -0,0 +1,48 @@ + $accessTypes */ + public function __construct( + private readonly iterable $accessTypes, + private readonly ProtectionService $protectionService, + ) {} + + /** + * Reads the raw protection record for a folder or one of its parent folders. + * + * @return array|null + */ + public function getProtection(FolderInterface $folder): ?array + { + return $this->protectionService->getRecord($folder); + } + + /** @param array $protection Raw protection database record */ + public function isGranted(array $protection): bool + { + foreach ($this->accessTypes as $accessType) { + if ($accessType->isGranted($protection)) { + return true; + } + } + return false; + } + + /** @return string[] */ + public function getPartials(): array + { + $partials = []; + foreach ($this->accessTypes as $accessType) { + $partials[] = $accessType->getPartials(); + } + return $partials; + } +} diff --git a/Classes/Service/ProtectionService.php b/Classes/Service/ProtectionService.php new file mode 100644 index 0000000..4197b30 --- /dev/null +++ b/Classes/Service/ProtectionService.php @@ -0,0 +1,271 @@ +|null + */ + public function getRecord(FolderInterface $folder, bool $recursive = true): ?array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE); + $record = $queryBuilder + ->select('*') + ->from(self::TABLE) + ->where( + $queryBuilder->expr()->eq('storage', $queryBuilder->createNamedParameter($folder->getStorage()->getUid(), ParameterType::INTEGER)), + $queryBuilder->expr()->eq('folder', $queryBuilder->createNamedParameter($folder->getIdentifier())) + ) + ->executeQuery() + ->fetchAssociative(); + + if ($record !== false) { + return $record; + } + + // hasParentFolder() only exists on our XCLASSed Folder subclass. + if ($recursive && $folder instanceof Folder && $folder->hasParentFolder()) { + return $this->getRecord($folder->getParentFolder(), true); + } + return null; + } + + /** + * Creates a new protection record and returns its uid. + * + * @param array $accessFields Raw access-type fields (keyed by TCA column) + */ + public function create(int $storage, string $folder, array $accessFields): int + { + $placeholder = StringUtility::getUniqueId('NEW'); + $data = [ + self::TABLE => [ + $placeholder => array_merge($this->sanitizeAccessFields($accessFields), [ + 'pid' => 0, + 'storage' => $storage, + 'folder' => $folder, + ]), + ], + ]; + + $dataHandler = $this->getDataHandler(); + $dataHandler->start($data, []); + $dataHandler->process_datamap(); + + return (int)($dataHandler->substNEWwithIDs[$placeholder] ?? 0); + } + + /** + * Updates the access-type fields of an existing protection record. + * + * @param array $accessFields Raw access-type fields (keyed by TCA column) + */ + public function update(int $uid, array $accessFields): void + { + $data = [ + self::TABLE => [ + $uid => $this->sanitizeAccessFields($accessFields), + ], + ]; + + $dataHandler = $this->getDataHandler(); + $dataHandler->start($data, []); + $dataHandler->process_datamap(); + } + + public function delete(int $uid): void + { + $cmd = [ + self::TABLE => [ + $uid => ['delete' => 1], + ], + ]; + + $dataHandler = $this->getDataHandler(); + $dataHandler->start([], $cmd); + $dataHandler->process_cmdmap(); + } + + /** + * Reads a protection record for form prefill. Scalar columns are returned + * as-is; every column backed by an MM relation is resolved to an array of + * the currently selected foreign uids. + * + * @return array + */ + public function getFormValues(int $uid): array + { + $record = $this->fetchByUid($uid); + if ($record === null) { + return []; + } + + foreach ($this->getMmColumns() as $field => $mmTable) { + $record[$field] = $this->getRelationUids($mmTable, $uid); + } + + return $record; + } + + /** + * Reads a protection record for read-only display. Scalar columns are + * returned as-is; every column backed by an MM relation is resolved to a + * list of ['uid' => int, 'label' => string] entries. + * + * @return array + */ + public function getDisplayValues(int $uid): array + { + $record = $this->fetchByUid($uid); + if ($record === null) { + return []; + } + + foreach ($this->getMmColumns() as $field => $mmTable) { + $foreignTable = (string)($GLOBALS['TCA'][self::TABLE]['columns'][$field]['config']['foreign_table'] ?? ''); + $record[$field] = $this->resolveLabels($foreignTable, $this->getRelationUids($mmTable, $uid)); + } + + return $record; + } + + /** + * Restricts submitted fields to the access-type columns of the table so + * that neither the identifying columns nor system columns can be injected. + * + * @param array $accessFields + * @return array + */ + private function sanitizeAccessFields(array $accessFields): array + { + $columns = array_keys($GLOBALS['TCA'][self::TABLE]['columns'] ?? []); + $allowed = array_diff($columns, ['storage', 'folder']); + return array_intersect_key($accessFields, array_flip($allowed)); + } + + /** + * @return array|null + */ + private function fetchByUid(int $uid): ?array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE); + $record = $queryBuilder + ->select('*') + ->from(self::TABLE) + ->where( + $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, ParameterType::INTEGER)) + ) + ->executeQuery() + ->fetchAssociative(); + + return $record === false ? null : $record; + } + + /** + * Returns all columns of the protection table that are backed by an MM + * relation, mapped to their MM table. + * + * @return array + */ + private function getMmColumns(): array + { + $columns = []; + foreach (($GLOBALS['TCA'][self::TABLE]['columns'] ?? []) as $field => $config) { + $mmTable = $config['config']['MM'] ?? null; + if ($mmTable !== null) { + $columns[$field] = (string)$mmTable; + } + } + return $columns; + } + + /** + * @return int[] + */ + private function getRelationUids(string $mmTable, int $uid): array + { + $queryBuilder = $this->connectionPool->getConnectionForTable($mmTable)->createQueryBuilder(); + $uids = $queryBuilder + ->select('uid_foreign') + ->from($mmTable) + ->where( + $queryBuilder->expr()->eq('uid_local', $queryBuilder->createNamedParameter($uid, ParameterType::INTEGER)) + ) + ->orderBy('sorting') + ->executeQuery() + ->fetchFirstColumn(); + + return array_map('intval', $uids); + } + + /** + * Resolves foreign uids to ['uid' => int, 'label' => string] entries, + * preserving the given order. The label field is taken from the foreign + * table's TCA ctrl definition. + * + * @param int[] $uids + * @return array + */ + private function resolveLabels(string $foreignTable, array $uids): array + { + if ($foreignTable === '' || $uids === []) { + return []; + } + + $labelField = (string)($GLOBALS['TCA'][$foreignTable]['ctrl']['label'] ?? 'uid'); + + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($foreignTable); + $rows = $queryBuilder + ->select('uid', $labelField) + ->from($foreignTable) + ->where( + $queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter($uids, ArrayParameterType::INTEGER)) + ) + ->executeQuery() + ->fetchAllAssociative(); + + $labels = []; + foreach ($rows as $row) { + $labels[(int)$row['uid']] = (string)$row[$labelField]; + } + + $result = []; + foreach ($uids as $uid) { + if (isset($labels[$uid])) { + $result[] = ['uid' => $uid, 'label' => $labels[$uid]]; + } + } + return $result; + } + + private function getDataHandler(): DataHandler + { + return GeneralUtility::makeInstance(DataHandler::class); + } +} diff --git a/Classes/Utility/FrontendUserUtility.php b/Classes/Utility/FrontendUserUtility.php index 630a315..d612ff2 100644 --- a/Classes/Utility/FrontendUserUtility.php +++ b/Classes/Utility/FrontendUserUtility.php @@ -20,7 +20,6 @@ public function getCurrentFrontendUser(): ?UserAspect try { /** @var Context $context */ $context = GeneralUtility::makeInstance(Context::class); - /** @var UserAspect $userAspect */ return $context->getAspect('frontend.user'); } catch (\Exception) { return null; diff --git a/Configuration/Icons.php b/Configuration/Icons.php index 0dd90ec..5601623 100644 --- a/Configuration/Icons.php +++ b/Configuration/Icons.php @@ -4,12 +4,21 @@ use TYPO3\CMS\Core\Imaging\IconProvider\BitmapIconProvider; use TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider; +use TYPO3\CMS\Core\Information\Typo3Version; +use TYPO3\CMS\Core\Utility\GeneralUtility; -return [ - 'tx-fpfileprotector-module' => [ +$moduleIcon = (GeneralUtility::makeInstance(Typo3Version::class)->getMajorVersion() >= 14) + ? [ + 'provider' => SvgIconProvider::class, + 'source' => 'EXT:fp_fileprotector/Resources/Public/Icons/Modules/protection.svg', + ] + : [ 'provider' => BitmapIconProvider::class, 'source' => 'EXT:fp_fileprotector/Resources/Public/Icons/Modules/protection.jpg', - ], + ]; + +return [ + 'tx-fpfileprotector-module' => $moduleIcon, // Icons for Folder-Tree diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index de9f1ed..d015d24 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -5,12 +5,22 @@ services: autoconfigure: true public: true + _instanceof: + Fixpunkt\FpFileprotector\AccessType\AccessTypeInterface: + tags: + - { name: 'fp_fileprotector.access' } + Fixpunkt\FpFileprotector\: resource: '../Classes/*' exclude: - '../Classes/Domain/Model/*' - '../Classes/Resource/*' - - '../Classes/Utility/*' + - '../Classes/Utility/HtaccessUtility.php' + + # Inject all tagged access utilities into AccessService + Fixpunkt\FpFileprotector\Service\AccessService: + arguments: + $accessTypes: !tagged_iterator 'fp_fileprotector.access' Fixpunkt\FpFileprotector\EventListener\ModifyIconForResourcePropertiesListener: tags: diff --git a/Configuration/TCA/Overrides/access_fe.php b/Configuration/TCA/Overrides/access_fe.php new file mode 100644 index 0000000..9d56232 --- /dev/null +++ b/Configuration/TCA/Overrides/access_fe.php @@ -0,0 +1,58 @@ + [ + 'label' => 'LLL:EXT:fp_fileprotector/Resources/Private/Language/locallang.xlf:tx_fpfileprotector_domain_model_protection.fe_login', + 'exclude' => 1, + 'onChange' => 'reload', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + ] + ], + 'user_groups' => [ + 'exclude' => 1, + 'label' => 'LLL:EXT:fp_fileprotector/Resources/Private/Language/locallang.xlf:tx_fpfileprotector_domain_model_protection.user_groups', + 'displayCond' => 'FIELD:fe_login:REQ:true', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectMultipleSideBySide', + 'foreign_table' => 'fe_groups', + 'MM' => 'tx_fpfileprotector_protection_fegroups_mm' + ], + ], + 'users' => [ + 'exclude' => 1, + 'label' => 'LLL:EXT:fp_fileprotector/Resources/Private/Language/locallang.xlf:tx_fpfileprotector_domain_model_protection.users', + 'displayCond' => 'FIELD:fe_login:REQ:true', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectMultipleSideBySide', + 'foreign_table' => 'fe_users', + 'MM' => 'tx_fpfileprotector_protection_feusers_mm' + ], + ], +]; + +// Add fields to the general record description. +\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTCAcolumns($table, $tempColumns); + +// Group the FE fields in their own palette. +\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addFieldsToPalette( + $table, + 'fe', + 'fe_login,--linebreak--,user_groups,--linebreak--,users' +); + +// Render the palette in the backend form. +\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addToAllTCAtypes( + $table, + '--palette--;LLL:EXT:fp_fileprotector/Resources/Private/Language/locallang.xlf:tx_fpfileprotector_domain_model_protection.palette.fe;fe' +); diff --git a/Configuration/TCA/Overrides/sys_template.php b/Configuration/TCA/Overrides/sys_template.php deleted file mode 100644 index 7ebccaf..0000000 --- a/Configuration/TCA/Overrides/sys_template.php +++ /dev/null @@ -1,7 +0,0 @@ - 'crdate', 'delete' => 'deleted', 'searchFields' => 'folder', - 'iconfile' => 'EXT:fp_fileprotector/Resources/Public/Icons/Models/tx_fpfileprotector_domain_model_protection.svg' + // Protection rules are stored globally on the root level (pid=0), so no + // storage page needs to be configured. rootLevel=1 permits records on + // pid=0, and ignoreRootLevelRestriction lets non-admin backend users + // (the module is available to "user") create and edit them there. + 'rootLevel' => 1, + 'security' => [ + 'ignoreRootLevelRestriction' => true, + ], + 'iconfile' => 'EXT:fp_fileprotector/Resources/Public/Icons/Models/tx_fpfileprotector_domain_model_protection.svg', ], 'palettes' => [ 'folder' => [ 'label' => 'LLL:EXT:fp_fileprotector/Resources/Private/Language/locallang.xlf:tx_fpfileprotector_domain_model_protection.palette.folder', 'showitem' => 'storage,folder', ], - 'fe' => [ - 'label' => 'LLL:EXT:fp_fileprotector/Resources/Private/Language/locallang.xlf:tx_fpfileprotector_domain_model_protection.palette.fe', - 'showitem' => 'fe_login,--linebreak--,user_groups,--linebreak--,users', - ], - 'be' => [ - 'label' => 'LLL:EXT:fp_fileprotector/Resources/Private/Language/locallang.xlf:tx_fpfileprotector_domain_model_protection.palette.be', - 'showitem' => 'be_login', - ], ], 'types' => [ - 0 => ['showitem' => '--palette--;;folder,--palette--;;fe,--palette--;;be'], + 0 => ['showitem' => '--palette--;;folder'], ], 'columns' => [ 'storage' => [ @@ -37,7 +37,7 @@ 'type' => 'select', 'renderType' => 'selectSingle', 'items' => [ - ['label' => '', 'value' => 0] + ['label' => '', 'value' => 0], ], 'foreign_table' => 'sys_file_storage', 'foreign_table_where' => 'AND {#sys_file_storage}.{#protected} = 1', @@ -45,7 +45,7 @@ 'minitems' => 0, 'maxitems' => 1, 'default' => 0, - ] + ], ], 'folder' => [ 'label' => 'LLL:EXT:fp_fileprotector/Resources/Private/Language/locallang.xlf:tx_fpfileprotector_domain_model_protection.folder', @@ -55,45 +55,6 @@ 'items' => [], 'itemsProcFunc' => 'TYPO3\\CMS\\Core\\Resource\\Service\\UserFileMountService->renderTceformsSelectDropdown', 'default' => '', - ] - ], - 'fe_login' => [ - 'label' => 'LLL:EXT:fp_fileprotector/Resources/Private/Language/locallang.xlf:tx_fpfileprotector_domain_model_protection.fe_login', - 'exclude' => 1, - 'onChange' => 'reload', - 'config' => [ - 'type' => 'check', - 'renderType' => 'checkboxToggle', - ] - ], - 'be_login' => [ - 'label' => 'LLL:EXT:fp_fileprotector/Resources/Private/Language/locallang.xlf:tx_fpfileprotector_domain_model_protection.be_login', - 'exclude' => 1, - 'config' => [ - 'type' => 'check', - 'renderType' => 'checkboxToggle', - ] - ], - 'user_groups' => [ - 'exclude' => 1, - 'label' => 'LLL:EXT:fp_fileprotector/Resources/Private/Language/locallang.xlf:tx_fpfileprotector_domain_model_protection.user_groups', - 'displayCond' => 'FIELD:fe_login:REQ:true', - 'config' => [ - 'type' => 'select', - 'renderType' => 'selectMultipleSideBySide', - 'foreign_table' => 'fe_groups', - 'MM' => 'tx_fpfileprotector_protection_fegroups_mm' - ], - ], - 'users' => [ - 'exclude' => 1, - 'label' => 'LLL:EXT:fp_fileprotector/Resources/Private/Language/locallang.xlf:tx_fpfileprotector_domain_model_protection.users', - 'displayCond' => 'FIELD:fe_login:REQ:true', - 'config' => [ - 'type' => 'select', - 'renderType' => 'selectMultipleSideBySide', - 'foreign_table' => 'fe_users', - 'MM' => 'tx_fpfileprotector_protection_feusers_mm' ], ], ], diff --git a/Configuration/TypoScript/constants.typoscript b/Configuration/TypoScript/constants.typoscript deleted file mode 100644 index a36c816..0000000 --- a/Configuration/TypoScript/constants.typoscript +++ /dev/null @@ -1,16 +0,0 @@ -module.tx_fpfileprotector { - view { - # cat=plugin.tx_fpfileprotector/file; type=string; label=Path to template root (FE) - templateRootPath = EXT:fp_fileprotector/Resources/Private/Backend/Templates/ - # cat=plugin.tx_fpfileprotector/file; type=string; label=Path to template partials (FE) - partialRootPath = EXT:fp_fileprotector/Resources/Private/Backend/Partials/ - # cat=plugin.tx_fpfileprotector/file; type=string; label=Path to template layouts (FE) - layoutRootPath = EXT:fp_fileprotector/Resources/Private/Backend/Layouts/ - } - persistence { - # cat=plugin.tx_fpfileprotector//a; type=string; label=Default storage PID - storagePid = - } -} - - diff --git a/Configuration/TypoScript/setup.typoscript b/Configuration/TypoScript/setup.typoscript deleted file mode 100644 index 02e7373..0000000 --- a/Configuration/TypoScript/setup.typoscript +++ /dev/null @@ -1,16 +0,0 @@ -module.tx_fpfileprotector { - view { - templateRootPaths { - 0 = {$module.tx_fpfileprotector.view.templateRootPath} - } - partialRootPaths { - 0 = {$module.tx_fpfileprotector.view.partialRootPath} - } - layoutRootPaths { - 0 = {$module.tx_fpfileprotector.view.layoutRootPath} - } - } - persistence { - storagePid = {$module.tx_fpfileprotector.persistence.storagePid} - } -} \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..6463d6f --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,64 @@ +# Entwicklung / Dev-Tooling + +Diese Extension wird über ein **eigenständiges DDEV-Projekt** entwickelt. Das +Tooling (Coding Standards + statische Analyse) liegt isoliert in +* `.build/` (gegen TYPO3 14.3) +* `.build-v13/` (gegen TYPO3 13.4) +* `.build-v12/` (gegen TYPO3 12.4) + +damit die `composer.json` der Extension selbst sauber bleibt. Eingecheckt sind nur die +Tooling-Manifeste; `vendor/`, Lockfiles und generierte Assets sind ignoriert. + +## Einrichtung + +```bash +ddev start +ddev exec "cd .build && composer install" +``` + +> Das Tooling zieht TYPO3-Core, PHPStan und die Runtime-Deps zur +> Typauflösung. + +## Coding Standards (php-cs-fixer) + +Grundlage: `typo3/coding-standards` (PSR-12 / PER-CS) inkl. erzwungenem +`declare(strict_types=1)`. Konfiguration: `.php-cs-fixer.dist.php`, `.editorconfig`. + +```bash +# Nur prüfen – ändert nichts: +ddev exec ".build/vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --using-cache=no" + +# Automatisch korrigieren: +ddev exec ".build/vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --using-cache=no" +``` + +## Statische Analyse (PHPStan) + +Level 5 über `Classes/`, TYPO3-Regeln via `saschaegerer/phpstan-typo3`. + +```bash +# gegen TYPO3 14.3 (Standard-Toolchain, phpstan.neon wird automatisch gefunden): +ddev exec ".build/vendor/bin/phpstan analyse" + +# gegen TYPO3 13.4 (niedrigste unterstützte Version): +ddev exec "cd .build-v13 && composer install" # einmalig +ddev exec ".build-v1/vendor/bin/phpstan analyse" + +# gegen TYPO3 12.4 (niedrigste unterstützte Version): +ddev exec "cd .build-v12 && composer install" # einmalig +ddev exec ".build-v12/vendor/bin/phpstan analyse -c .build-v12/phpstan.neon" +``` + +## Continuous Integration + +`.github/workflows/ci.yml` fährt bei Push/Pull-Request PHPStan (Matrix +TYPO3 12.4 + 13.4 + 14.3) sowie php-cs-fixer und nutzt dafür dieselben `.build`-/ `.build-12`-/ +`.build-v13`-Toolchains. + +## Tooling aktualisieren + +```bash +ddev exec "cd .build && composer update" +ddev exec "cd .build-v12 && composer update" +ddev exec "cd .build-v13 && composer update" +``` diff --git a/Documentation/AccessTypes/Be/Index.rst b/Documentation/AccessTypes/Be/Index.rst new file mode 100644 index 0000000..03d7ffb --- /dev/null +++ b/Documentation/AccessTypes/Be/Index.rst @@ -0,0 +1,41 @@ +.. include:: /Includes.rst.txt + +.. _access-type-be: + +========================== +Backend Login Access Type +========================== + +The backend access type grants access based on the login status **and the file +permissions** of a **backend user**. It is implemented in +:php:`Fixpunkt\FpFileprotector\AccessType\BeLoginAccessType`. + +Unlike the frontend access type, the backend access type does not rely on a +list of users or groups stored on the protection rule, and it has **no +configuration of its own**. It is always evaluated for every protected folder +and reads the access rights **directly from the logged-in backend user**, +reusing the file mount and file storage permissions that TYPO3 already manages +for that user. + +How it works +============ + +The access type evaluates the request in the following order: + +#. If no backend user is currently logged in, access is denied. + +#. If the backend user is an **administrator**, access is always granted. + +#. Otherwise, the access type looks up the file storage referenced by the + protection rule among the user's own file storages. Access is granted if + the protected folder lies **within the file mount boundaries** of that + storage for the current user. + +This means a backend user can access exactly those protected folders they are +already allowed to see and manage in the TYPO3 file list — no additional +configuration per user or group is required. + +.. note:: + There is no toggle to enable or disable this access type per folder. Since + it only ever grants access to backend users who already have the necessary + file permissions, it is safe to evaluate it on every protected folder. diff --git a/Documentation/AccessTypes/Fe/Index.rst b/Documentation/AccessTypes/Fe/Index.rst new file mode 100644 index 0000000..8b54191 --- /dev/null +++ b/Documentation/AccessTypes/Fe/Index.rst @@ -0,0 +1,56 @@ +.. include:: /Includes.rst.txt + +.. _access-type-fe: + +=========================== +Frontend Login Access Type +=========================== + +The frontend access type grants access based on the login status of a +**frontend user**. It is implemented in +:php:`Fixpunkt\FpFileprotector\AccessType\FeLoginAccessType`. + +How it works +============ + +The access type evaluates the protection record in the following order: + +#. If the *Enable access for frontend users* option is **disabled** for the + folder, the frontend access type never grants access. + +#. If no frontend user is currently logged in, access is denied. + +#. If neither individual users nor user groups are configured on the + protection rule, **every** logged-in frontend user is granted access. + +#. Otherwise, access is granted if the current frontend user is one of the + selected **users**, or is a member of one of the selected **user groups**. + +.. note:: + Users and user groups are combined with an **OR** conjunction: it is enough + to match either a selected user or a selected group. + +Configuration +============= + +The relevant fields are configured directly on the access rule in the +**File Protection** backend module: + +.. confval:: Enable access for frontend users + :name: access-type-fe-fe-login + + Master switch for this access type. If disabled, none of the fields below + have any effect and the frontend access type will not grant access. + +.. confval:: Frontend Users + :name: access-type-fe-users + + Optional list of individual frontend users who may access the folder. + +.. confval:: Frontend User Groups + :name: access-type-fe-user-groups + + Optional list of frontend user groups whose members may access the folder. + +If both the users and the user groups list are left empty, any logged-in +frontend user is granted access. diff --git a/Documentation/AccessTypes/Index.rst b/Documentation/AccessTypes/Index.rst new file mode 100644 index 0000000..3e5ff11 --- /dev/null +++ b/Documentation/AccessTypes/Index.rst @@ -0,0 +1,54 @@ +.. include:: /Includes.rst.txt + +.. _access-types: + +============ +Access Types +============ + +An **access type** is a single, self-contained rule that can grant access to a +protected folder. Every access type answers one simple question for a given +protection record: *"Should the current visitor be allowed in?"* + +When a folder is requested, fp-fileprotector asks every registered access type +in turn. Access is granted as soon as **one** access type grants it — the +access types are combined with an **OR** conjunction. If no access type grants +access, the request is denied. + +fp-fileprotector ships with two access types out of the box: + +.. card-grid:: + :columns: 1 + :columns-md: 2 + :gap: 4 + :card-height: 100 + + .. card:: Frontend Login + + Grant access to logged-in frontend users, optionally limited to + specific users or user groups. + + .. card-footer:: :ref:`How the frontend access type works ` + :button-style: btn btn-secondary stretched-link + + .. card:: Backend Login + + Grant access to backend users based on their own file storage + permissions. + + .. card-footer:: :ref:`How the backend access type works ` + :button-style: btn btn-secondary stretched-link + +.. note:: + The two shipped access types can also be combined on a single folder. A + visitor is then granted access if they satisfy the frontend rule **or** the + backend rule. + +You are not limited to these two access types. See +:ref:`For Developers ` to learn how to add your own. + +.. toctree:: + :hidden: + + Fe/Index + Be/Index diff --git a/Documentation/ForDevelopers/Index.rst b/Documentation/ForDevelopers/Index.rst new file mode 100644 index 0000000..e11da75 --- /dev/null +++ b/Documentation/ForDevelopers/Index.rst @@ -0,0 +1,208 @@ +.. include:: /Includes.rst.txt + +.. _for-developers: + +============== +For Developers +============== + +fp-fileprotector can be extended with your own :ref:`access types `. +An access type is a small PHP class that decides whether the current visitor may +access a protected folder, plus the Fluid partials used to display and edit its +settings in the backend module. + +At runtime, all registered access types are collected and combined with an +**OR** conjunction: access is granted as soon as one of them grants it. Adding +your own access type therefore never restricts existing rules — it only adds a +new way to *grant* access. + +A custom access type consists of the following parts. + +.. contents:: + :local: + +The access type class +====================== + +The heart of every access type is a class that implements +:php:`Fixpunkt\FpFileprotector\AccessType\AccessTypeInterface`. Place it under +:file:`Classes/AccessType/AccessType.php` in your own extension. + +.. code-block:: php + :caption: Classes/AccessType/AccessTypeInterface.php + + interface AccessTypeInterface + { + /** @param array $protection Raw protection database record */ + public function isGranted(array $protection): bool; + public function getPartials(): string; + } + +:php:`isGranted()` + Receives the raw protection database record as an associative array and + returns whether the current visitor should be granted access. Return + :php:`true` to grant access, :php:`false` to abstain. Because access types + are combined with **OR**, returning :php:`false` never blocks another + access type from granting access. + +:php:`getPartials()` + Returns the partial path used to render this access type in the backend + module, e.g. :php:`'Access/MyType'`. The module renders + :file:`/Show.html` to display the rule and :file:`/Edit.html` + to edit it (see :ref:`the partials section `). + +.. code-block:: php + :caption: Classes/AccessType/MyTypeAccessType.php + + $protection Raw protection database record */ + public function isGranted(array $protection): bool + { + // Your access decision, based on the protection record. + return (bool)($protection['my_field'] ?? false); + } + + public function getPartials(): string + { + return 'Access/MyType'; + } + } + +Registering the class +---------------------- + +fp-fileprotector discovers access types through the service container. Your +class must be registered as a service and tagged with +``fp_fileprotector.access``. Add the tag in your extension's +:file:`Configuration/Services.yaml`: + +.. code-block:: yaml + :caption: Configuration/Services.yaml + + services: + _defaults: + autowire: true + autoconfigure: true + + Vendor\MyExtension\: + resource: '../Classes/*' + + Vendor\MyExtension\AccessType\MyTypeAccessType: + tags: + - { name: 'fp_fileprotector.access' } + +.. _for-developers-partials: + +The partials +============ + +Each access type provides two Fluid partials, ideally located under +:file:`Access//` so they match the path returned by +:php:`getPartials()`: + +:file:`Show.html` + Renders the current settings of the rule read-only. It receives the + protection record as the :html:`{protection}` variable. + +:file:`Edit.html` + Renders the form fields used to create or edit the rule. It receives all + available variables (:html:`{_all}`), including the current record. + +.. code-block:: html + :caption: Resources/Private/Partials/Access/MyType/Show.html + +
+
+
+ + Yes + No + +
+
+ +.. code-block:: html + :caption: Resources/Private/Partials/Access/MyType/Edit.html + +
+ +
+ +Making the partials available to the backend module +---------------------------------------------------- + +Because the partials live in your own extension, you have to tell the backend +module where to find them. fp-fileprotector ships no TypoScript for this; +instead use the TSconfig-based backend template override introduced in +TYPO3 v12 (see `Feature #96812 +`__). +It registers an additional, higher-priority root path for the templates of a +given extension. + +Add the following to your extension's :file:`Configuration/page.tsconfig` +(automatically loaded) using the pattern +``templates.. = :``. +The key names the extension whose templates you extend +(``fixpunkt/fp-fileprotector``), the number only has to be unique, and the value +is your own composer package name followed by the path that contains your +:file:`Partials/` folder: + +.. code-block:: typoscript + :caption: Configuration/page.tsconfig + + templates.fixpunkt/fp-fileprotector.1643293191 = my_vendor/my_extension:Resources/Private + +TYPO3 automatically appends the :file:`Templates/`, :file:`Partials/` and +:file:`Layouts/` subdirectories. The partial path returned by +:php:`getPartials()` (e.g. ``Access/MyType``) is therefore resolved to +:file:`EXT:my_extension/Resources/Private/Partials/Access/MyType/` inside your extension. + +The TCA override (optional) +=========================== + +If your access type needs additional fields on the protection record — for +example a checkbox or a relation — add them via a TCA override for the table +:sql:`tx_fpfileprotector_domain_model_protection`. + +This step is **optional**: if your access decision does not require any extra +data stored on the rule, you can skip it entirely. + +.. code-block:: php + :caption: Configuration/TCA/Overrides/tx_fpfileprotector_domain_model_protection.php + + [ + 'label' => 'My setting', + 'exclude' => 1, + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + ], + ], + ]); + +.. note:: + The values written by your :file:`Edit.html` partial (the ``protection[...]`` + form fields) are what end up in the protection record and are handed to + :php:`isGranted()`. Make sure the field names in the partial, the TCA + override and the class match. diff --git a/Documentation/FuturePlans/Index.rst b/Documentation/FuturePlans/Index.rst index b6140da..d803515 100644 --- a/Documentation/FuturePlans/Index.rst +++ b/Documentation/FuturePlans/Index.rst @@ -6,7 +6,5 @@ Future Plans ============ -* Removing the Backend Access rule and get the access rights directly from the logged in backend user -* Making it possible to easily extend the extension by own protection rule classes. * Making it possible to create more detailed protection rules with conjunctions other than OR. * Allow protection rules to override inherited rules, so that a subfolder can be made accessible again despite a restrictive parent rule (solve problem of "disallow by default" storages) \ No newline at end of file diff --git a/Documentation/Index.rst b/Documentation/Index.rst index 58ecb99..6d7e392 100644 --- a/Documentation/Index.rst +++ b/Documentation/Index.rst @@ -64,6 +64,20 @@ and folders .. card-footer:: :ref:`Learn how fp-fileprotector is used ` :button-style: btn btn-secondary stretched-link + .. card:: Access Types + + How the frontend and backend access types work. + + .. card-footer:: :ref:`Understand the access types ` + :button-style: btn btn-secondary stretched-link + + .. card:: For Developers + + How to extend fp-fileprotector with your own access types. + + .. card-footer:: :ref:`Extend fp-fileprotector ` + :button-style: btn btn-secondary stretched-link + .. card:: Troubleshooting Common issues and solutions. @@ -76,6 +90,8 @@ and folders Introduction/Index Usage/Index + AccessTypes/Index + ForDevelopers/Index TroubleShooting/Index FuturePlans/Index Sitemap diff --git a/Documentation/TroubleShooting/Index.rst b/Documentation/TroubleShooting/Index.rst index c40cd7d..8f549b6 100644 --- a/Documentation/TroubleShooting/Index.rst +++ b/Documentation/TroubleShooting/Index.rst @@ -26,7 +26,7 @@ files (:bash:`AllowOverride All`). User groups and/or users are being ignored ========================================== -Check whether the option **Must be logged in on the frontend** is enabled in +Check whether the option **Enable access for frontend users** is enabled in the access rule. Without this checkbox, group and user restrictions have no effect. diff --git a/Documentation/Usage/Folders/Index.rst b/Documentation/Usage/Folders/Index.rst index 9970e85..65b6c4a 100644 --- a/Documentation/Usage/Folders/Index.rst +++ b/Documentation/Usage/Folders/Index.rst @@ -41,26 +41,8 @@ Creating or Editing a Protection Rule Select who is allowed to access the contents of the respective folder (including subfolders). .. note:: - The selection of groups and users is an **OR** conjunction. Both members - of the selected groups and the individually selected users will have access. - Also the selection of frontend and backend access is an **OR** conjunction. + All conditions are combined with an **OR** conjunction: a visitor is granted + access as soon as any one of them is satisfied. -.. confval:: Must be logged in to the frontend - - If enabled, all users logged into a frontend account can access the folder. To restrict access to specific users or groups, use the **Frontend Users** and **Frontend User Groups** options below. - -.. confval:: Frontend Users - - Select users, which are allowed to access the folder. This selection is an **OR** conjunction. - - This option only applies, if *Must be logged in to the frontend* is enabled! - -.. confval:: Frontend User Groups - - Select user groups, which are allowed to access the folder. This selection is an **OR** conjunction. - - This option only applies, if *Must be logged in to the frontend* is enabled! - -.. confval:: Must be logged in to the backend - - If enabled, all users, that are logged into an backend account can access the folder. \ No newline at end of file +The available conditions are provided by the :ref:`access types `. +See there for a detailed explanation of each condition and how it is evaluated. \ No newline at end of file diff --git a/README.md b/README.md index 8cc215b..9d31c70 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ The extension **fp-fileprotector** allows you to restrict access to file storage ## Features -- Protect TYPO3 file storages from public access -- Define access rules per folder with inheritance to subfolders -- Restrict access based on frontend login, user groups, or individual users -- Whitelist and blacklist modes +- **Secure file protection through `.htaccess` and middleware** — protected files are shielded at the web server level and served through a TYPO3 middleware that enforces access on every request. +- **File access based on backend user permissions** — backend users reach exactly those protected files they are already allowed to see and manage in the TYPO3 file list, reusing their existing file mount and storage permissions. +- **File access based on frontend users and groups** — grant access to logged-in frontend users, optionally limited to specific users or user groups. +- **Above all: easy extensibility with your own access types** — add custom access rules with just a small class and a partial, without touching the extension's core. See the [developer documentation](Documentation/ForDevelopers/Index.rst). ## Requirements diff --git a/Resources/Private/Language/de.locallang.xlf b/Resources/Private/Language/de.locallang.xlf index 8153aba..10be221 100644 --- a/Resources/Private/Language/de.locallang.xlf +++ b/Resources/Private/Language/de.locallang.xlf @@ -47,8 +47,8 @@ Ordner - Must be logged in to the frontend - Muss im Frontend eingeloggt sein + Enable access for frontend users + Zugriff für Frontend User gewähren User Groups @@ -58,9 +58,21 @@ Users Benutzer:innen - - Must be logged in to the backend - Muss im Backend eingeloggt sein + + + + Access granted via frontend login + Zugriff via Frontend Login + + + + + Access granted via backend login + Zugriff via Backend Login + + + The access is granted on base of access rules given for the backend user and the groups that the user belongs to. + Der Zugriff wird auf Basis der Berechtigungen des Backend-Users und der Berechtigungen ihrer:seiner Gruppen gewährt. diff --git a/Resources/Private/Language/locallang.xlf b/Resources/Private/Language/locallang.xlf index 5e16160..0602f8c 100644 --- a/Resources/Private/Language/locallang.xlf +++ b/Resources/Private/Language/locallang.xlf @@ -37,7 +37,7 @@ Folder - Must be logged in to the frontend + Enable access for frontend users User Groups @@ -45,8 +45,18 @@ Users - - Must be logged in to the backend + + + + Access granted via frontend login + + + + + Access granted via backend login + + + The access is granted on base of access rules given for the backend user and the groups that the user belongs to. diff --git a/Resources/Private/Partials/Access/Be/Edit.html b/Resources/Private/Partials/Access/Be/Edit.html new file mode 100644 index 0000000..b54ab94 --- /dev/null +++ b/Resources/Private/Partials/Access/Be/Edit.html @@ -0,0 +1,4 @@ +

+ : +

+ diff --git a/Resources/Private/Partials/Access/Be/Show.html b/Resources/Private/Partials/Access/Be/Show.html new file mode 100644 index 0000000..b8f34c5 --- /dev/null +++ b/Resources/Private/Partials/Access/Be/Show.html @@ -0,0 +1,4 @@ +

+ : +

+ diff --git a/Resources/Private/Partials/Access/Fe/Edit.html b/Resources/Private/Partials/Access/Fe/Edit.html new file mode 100644 index 0000000..c3c56e3 --- /dev/null +++ b/Resources/Private/Partials/Access/Fe/Edit.html @@ -0,0 +1,54 @@ +

+ : +

+ +
+ +
+
+
+
+ + +
+
+ +
+ +
+ +
+
+
+
+
+
+
+ + +
+
+ +
+ +
+ +
+
+
+
+
+
diff --git a/Resources/Private/Partials/Access/Fe/Show.html b/Resources/Private/Partials/Access/Fe/Show.html new file mode 100644 index 0000000..5546905 --- /dev/null +++ b/Resources/Private/Partials/Access/Fe/Show.html @@ -0,0 +1,45 @@ +

+ : +

+ +
+
+
+ + + + +
+ + +
+
+ +
    + +
  • {userGroup.label}
  • +
    +
+
+
+
+ +
+
+ +
    + +
  • {user.label}
  • +
    +
+
+
+
+
+
\ No newline at end of file diff --git a/Resources/Private/Partials/FileStorage/FormFields.html b/Resources/Private/Partials/FileStorage/FormFields.html index 7495198..996b109 100644 --- a/Resources/Private/Partials/FileStorage/FormFields.html +++ b/Resources/Private/Partials/FileStorage/FormFields.html @@ -1,10 +1,12 @@
\ No newline at end of file diff --git a/Resources/Private/Partials/Protection/FormFields.html b/Resources/Private/Partials/Protection/FormFields.html index 613e821..ec8e2cc 100644 --- a/Resources/Private/Partials/Protection/FormFields.html +++ b/Resources/Private/Partials/Protection/FormFields.html @@ -1,47 +1,10 @@ -

- -

-
- -
-
-
-
- - -
-
- -
-
-
+ +
+
-
-
-
- - -
-
- -
-
-
-
-
-
- -

- -

-
- -
\ No newline at end of file + +
+
+
+
+ \ No newline at end of file diff --git a/Resources/Private/Partials/Protection/Properties.html b/Resources/Private/Partials/Protection/Properties.html index de0a61d..e3f2d05 100644 --- a/Resources/Private/Partials/Protection/Properties.html +++ b/Resources/Private/Partials/Protection/Properties.html @@ -1,52 +1,10 @@ -
-
-
-
- - - - + +
+
-
- - -
-
- -
    - -
  • {userGroup.title}
  • -
    -
+ +
+
-
- -
-
- -
    - -
  • {user.username}
  • -
    -
-
-
-
- -
-
-
- - - - -
\ No newline at end of file + diff --git a/Resources/Private/Templates/Folder/Show.html b/Resources/Private/Templates/Folder/Show.html index 7be8fbf..613e18d 100644 --- a/Resources/Private/Templates/Folder/Show.html +++ b/Resources/Private/Templates/Folder/Show.html @@ -17,7 +17,7 @@

{folder.speakingName}

- +
@@ -44,7 +44,7 @@

{folder.speakingName}

- +
@@ -56,7 +56,7 @@

{folder.speakingName}

- +
@@ -83,10 +83,10 @@

{folder.speakingName}

- + - + diff --git a/Resources/Private/Templates/Protection/Edit.html b/Resources/Private/Templates/Protection/Edit.html index 441ff59..240b4b7 100644 --- a/Resources/Private/Templates/Protection/Edit.html +++ b/Resources/Private/Templates/Protection/Edit.html @@ -3,11 +3,15 @@

{folder.name}

- + + + + +
- +
-
\ No newline at end of file + diff --git a/Resources/Private/Templates/Protection/New.html b/Resources/Private/Templates/Protection/New.html index 62e47ae..6be45e5 100644 --- a/Resources/Private/Templates/Protection/New.html +++ b/Resources/Private/Templates/Protection/New.html @@ -3,9 +3,8 @@

{folder.name}

- - - + + @@ -14,4 +13,4 @@

{folder.na

- \ No newline at end of file + diff --git a/Resources/Public/Icons/Modules/protection.afphoto b/Resources/Public/Icons/Modules/protection.afphoto deleted file mode 100644 index 03d6aa9..0000000 Binary files a/Resources/Public/Icons/Modules/protection.afphoto and /dev/null differ diff --git a/Resources/Public/Icons/Modules/protection.svg b/Resources/Public/Icons/Modules/protection.svg new file mode 100644 index 0000000..1b7b390 --- /dev/null +++ b/Resources/Public/Icons/Modules/protection.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composer.json b/composer.json index b9b3d5f..a69e8a0 100644 --- a/composer.json +++ b/composer.json @@ -25,8 +25,8 @@ } ], "require": { - "typo3/cms-core": "^13 || ^12", - "php": "^8.2" + "typo3/cms-core": "^12 || ^13 || ^14", + "php": "^8.4" }, "require-dev": { "typo3/coding-standards": "*", diff --git a/ext_emconf.php b/ext_emconf.php index c5c04e1..4703de0 100644 --- a/ext_emconf.php +++ b/ext_emconf.php @@ -11,7 +11,7 @@ 'version' => '3.2.0', 'constraints' => [ 'depends' => [ - 'typo3' => '12.0.0-13.99.99', + 'typo3' => '12.4.0-14.99.99', ], 'conflicts' => [], 'suggests' => [], diff --git a/ext_tables.sql b/ext_tables.sql index 76d3b92..da16492 100644 --- a/ext_tables.sql +++ b/ext_tables.sql @@ -10,8 +10,13 @@ CREATE TABLE sys_file_storage ( CREATE TABLE tx_fpfileprotector_domain_model_protection ( storage int(11) unsigned DEFAULT 0 NOT NULL, folder varchar(255) DEFAULT '' NOT NULL, +); + +# +# Table structure for table 'tx_fpfileprotector_domain_model_protection' for fe_login check +# +CREATE TABLE tx_fpfileprotector_domain_model_protection ( fe_login tinyint(3) DEFAULT 0 NOT NULL, - be_login tinyint(3) DEFAULT 0 NOT NULL, user_groups int(11) unsigned DEFAULT 0 NOT NULL, users int(11) unsigned DEFAULT 0 NOT NULL, ); \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..fb8db88 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,12 @@ +# PHPStan configuration for the fp_fileprotector extension. +# The toolchain (PHPStan + saschaegerer/phpstan-typo3 + TYPO3 core for type +# resolution) lives isolated in .build/ — see .build/composer.json. +# phpstan-typo3 is auto-registered via phpstan/extension-installer. +# +# Run: ddev exec ".build/vendor/bin/phpstan analyse" +parameters: + level: 5 + paths: + - Classes + bootstrapFiles: + - .build/vendor/autoload.php