TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:00 +02:00
commit f9941541b7
1178 changed files with 135377 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Backend\Bookmark;
/**
* Value object representing a bookmark in the backend.
*
* Bookmarks provide quick access to frequently used backend locations
* and are displayed in the bookmark toolbar dropdown.
*
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
readonly class Bookmark implements \JsonSerializable
{
public function __construct(
public int $id,
public string $route,
public string $arguments,
public string $title,
public int|string $groupId,
public string $iconIdentifier,
public string $iconOverlayIdentifier,
public string $module,
public string $href,
public bool $editable = false,
public bool $accessible = false,
) {}
public function toArray(): array
{
return [
'id' => $this->id,
'route' => $this->route,
'arguments' => $this->arguments,
'title' => $this->title,
'groupId' => $this->groupId,
'iconIdentifier' => $this->iconIdentifier,
'iconOverlayIdentifier' => $this->iconOverlayIdentifier,
'module' => $this->module,
'href' => $this->href,
'editable' => $this->editable,
'accessible' => $this->accessible,
];
}
public function jsonSerialize(): array
{
return $this->toArray();
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Backend\Bookmark;
/**
* Value object representing a bookmark group.
*
* Groups are used to organize bookmarks in the backend toolbar.
*
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
readonly class BookmarkGroup implements \JsonSerializable
{
public function __construct(
public int|string $id,
public string $label,
public BookmarkGroupType $type,
public int $sorting = 0,
public bool $editable = false,
public bool $selectable = true,
) {}
public function toArray(): array
{
return [
'id' => $this->id,
'label' => $this->label,
'type' => $this->type->value,
'priority' => $this->type->getPriority(),
'sorting' => $this->sorting,
'editable' => $this->editable,
'selectable' => $this->selectable,
];
}
public function jsonSerialize(): array
{
return $this->toArray();
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Backend\Bookmark;
/**
* Defines the different types/sources of bookmark groups.
*
* @internal
*/
enum BookmarkGroupType: string
{
/**
* System groups defined via UserTSconfig (options.bookmarkGroups.X = "Label").
* These have positive integer IDs and include the default bookmark groups.
*/
case SYSTEM = 'system';
/**
* Global groups (negative IDs) - bookmarks visible to all users but only admins can add to them.
* These mirror the system groups but with negative IDs.
*/
case GLOBAL = 'global';
/**
* User-created groups stored in sys_be_shortcuts_group table.
* These have UUID identifiers and are specific to the user who created them.
*/
case USER = 'user';
/**
* Returns the priority for this group type.
* Lower values appear first: user → system → global
*/
public function getPriority(): int
{
return match ($this) {
self::USER => 0,
self::SYSTEM => 1,
self::GLOBAL => 2,
};
}
}
@@ -0,0 +1,432 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Backend\Bookmark;
use Symfony\Component\Uid\Uuid;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
/**
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
readonly class BookmarkRepository
{
protected const TABLE_NAME = 'sys_be_shortcuts';
protected const GROUP_TABLE_NAME = 'sys_be_shortcuts_group';
public function __construct(
protected ConnectionPool $connectionPool,
) {}
public function findById(int $id): ?array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$row = $queryBuilder->select('*')
->from(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($id, Connection::PARAM_INT)
)
)
->executeQuery()
->fetchAssociative();
return $row !== false ? $row : null;
}
/**
* @param list<int> $ids
* @return array<int, array> Indexed by bookmark ID
*/
public function findByIds(array $ids): array
{
if ($ids === []) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$result = $queryBuilder->select('*')
->from(self::TABLE_NAME)
->where(
$queryBuilder->expr()->in(
'uid',
$queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
)
)
->executeQuery();
$bookmarks = [];
while ($row = $result->fetchAssociative()) {
$bookmarks[(int)$row['uid']] = $row;
}
return $bookmarks;
}
public function findByUser(int $userId): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$constraints = [];
// User's own bookmarks with a non-negative sc_group value.
// Bookmarks in user-created groups also match here, as their sc_group defaults to 0.
$constraints[] = $queryBuilder->expr()->and(
$queryBuilder->expr()->eq(
'userid',
$queryBuilder->createNamedParameter($userId, Connection::PARAM_INT)
),
$queryBuilder->expr()->gte(
'sc_group',
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
)
);
// Global bookmarks (negative sc_group) - visible to all users
$constraints[] = $queryBuilder->expr()->lt(
'sc_group',
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
);
$result = $queryBuilder->select('*')
->from(self::TABLE_NAME)
->where($queryBuilder->expr()->or(...$constraints))
->orderBy('sc_group')
->addOrderBy('sorting')
->executeQuery();
$bookmarks = [];
while ($row = $result->fetchAssociative()) {
$bookmarks[] = $row;
}
return $bookmarks;
}
public function exists(int $userId, string $routeIdentifier, string $arguments): bool
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->getRestrictions()->removeAll();
$uid = $queryBuilder->select('uid')
->from(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq(
'userid',
$queryBuilder->createNamedParameter($userId, Connection::PARAM_INT)
),
$queryBuilder->expr()->eq('route', $queryBuilder->createNamedParameter($routeIdentifier)),
$queryBuilder->expr()->eq('arguments', $queryBuilder->createNamedParameter($arguments))
)
->executeQuery()
->fetchOne();
return (bool)$uid;
}
/**
* @return int|false The new bookmark ID or false on failure
*/
public function insert(int $userId, string $routeIdentifier, string $arguments, string $title): int|false
{
$connection = $this->connectionPool->getConnectionForTable(self::TABLE_NAME);
$affectedRows = $connection->insert(
self::TABLE_NAME,
[
'userid' => $userId,
'route' => $routeIdentifier,
'arguments' => $arguments,
'description' => $title ?: 'Bookmark',
'sorting' => $GLOBALS['EXEC_TIME'],
]
);
if ($affectedRows === 1) {
return (int)$connection->lastInsertId();
}
return false;
}
public function update(
int $id,
?int $userId,
string $title,
int|string $groupId,
bool $allowGlobalGroups = true
): int {
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->update(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($id, Connection::PARAM_INT)
)
)
->set('description', $title);
if (is_string($groupId)) {
$queryBuilder->set('group_uuid', $groupId);
$queryBuilder->set('sc_group', BookmarkService::GROUP_DEFAULT);
} else {
$effectiveGroupId = $allowGlobalGroups ? $groupId : max(BookmarkService::GROUP_DEFAULT, $groupId);
$queryBuilder->set('sc_group', $effectiveGroupId);
$queryBuilder->set('group_uuid', null);
}
if ($userId !== null) {
$queryBuilder->andWhere(
$queryBuilder->expr()->eq(
'userid',
$queryBuilder->createNamedParameter($userId, Connection::PARAM_INT)
)
);
if (!$allowGlobalGroups) {
$queryBuilder->andWhere(
$queryBuilder->expr()->gte(
'sc_group',
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
)
);
}
}
return $queryBuilder->executeStatement();
}
public function delete(int $id, ?int $userId = null): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->delete(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($id, Connection::PARAM_INT)
)
);
if ($userId !== null) {
$queryBuilder->andWhere(
$queryBuilder->expr()->eq(
'userid',
$queryBuilder->createNamedParameter($userId, Connection::PARAM_INT)
)
);
}
return $queryBuilder->executeStatement();
}
/**
* @param array<int> $ids
*/
public function deleteMultiple(array $ids, int $userId): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
return $queryBuilder->delete(self::TABLE_NAME)
->where(
$queryBuilder->expr()->in(
'uid',
$queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
),
$queryBuilder->expr()->eq(
'userid',
$queryBuilder->createNamedParameter($userId, Connection::PARAM_INT)
)
)
->executeStatement();
}
public function updateSorting(int $id, int $userId, int $sorting): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
return $queryBuilder->update(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($id, Connection::PARAM_INT)
),
$queryBuilder->expr()->eq(
'userid',
$queryBuilder->createNamedParameter($userId, Connection::PARAM_INT)
)
)
->set('sorting', $sorting)
->executeStatement();
}
/**
* @param array<int> $ids
*/
public function moveToGroup(array $ids, int $userId, int|string $groupId, bool $allowGlobalGroups = true): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->update(self::TABLE_NAME)
->where(
$queryBuilder->expr()->in(
'uid',
$queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
),
$queryBuilder->expr()->eq(
'userid',
$queryBuilder->createNamedParameter($userId, Connection::PARAM_INT)
)
);
if (is_string($groupId)) {
$queryBuilder->set('group_uuid', $groupId);
$queryBuilder->set('sc_group', BookmarkService::GROUP_DEFAULT);
} else {
$effectiveGroupId = $allowGlobalGroups ? $groupId : max(BookmarkService::GROUP_DEFAULT, $groupId);
$queryBuilder->set('sc_group', $effectiveGroupId);
$queryBuilder->set('group_uuid', null);
}
return $queryBuilder->executeStatement();
}
public function findGroupsByUser(int $userId): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::GROUP_TABLE_NAME);
$result = $queryBuilder
->select('*')
->from(self::GROUP_TABLE_NAME)
->where(
$queryBuilder->expr()->eq('userid', $queryBuilder->createNamedParameter($userId, Connection::PARAM_INT))
)
->orderBy('sorting', 'ASC')
->executeQuery();
$groups = [];
while ($row = $result->fetchAssociative()) {
$groups[] = $row;
}
return $groups;
}
public function findGroupByUuid(string $uuid): ?array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::GROUP_TABLE_NAME);
$row = $queryBuilder
->select('*')
->from(self::GROUP_TABLE_NAME)
->where(
$queryBuilder->expr()->eq('uuid', $queryBuilder->createNamedParameter($uuid))
)
->executeQuery()
->fetchAssociative();
return $row !== false ? $row : null;
}
public function createGroup(int $userId, string $label): ?string
{
$uuid = (string)Uuid::v4();
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::GROUP_TABLE_NAME);
$maxSorting = $queryBuilder
->select('sorting')
->from(self::GROUP_TABLE_NAME)
->where(
$queryBuilder->expr()->eq('userid', $queryBuilder->createNamedParameter($userId, Connection::PARAM_INT))
)
->orderBy('sorting', 'DESC')
->setMaxResults(1)
->executeQuery()
->fetchOne();
$sorting = ($maxSorting !== false ? (int)$maxSorting : 0) + 1;
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::GROUP_TABLE_NAME);
$affectedRows = $queryBuilder
->insert(self::GROUP_TABLE_NAME)
->values([
'uuid' => $uuid,
'userid' => $userId,
'label' => $label,
'sorting' => $sorting,
])
->executeStatement();
return $affectedRows === 1 ? $uuid : null;
}
public function updateGroup(string $uuid, string $label): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::GROUP_TABLE_NAME);
return $queryBuilder
->update(self::GROUP_TABLE_NAME)
->where(
$queryBuilder->expr()->eq('uuid', $queryBuilder->createNamedParameter($uuid))
)
->set('label', $label)
->executeStatement();
}
public function deleteGroup(string $uuid): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::GROUP_TABLE_NAME);
return $queryBuilder
->delete(self::GROUP_TABLE_NAME)
->where(
$queryBuilder->expr()->eq('uuid', $queryBuilder->createNamedParameter($uuid))
)
->executeStatement();
}
/**
* @param list<string> $uuids
*/
public function reorderGroups(array $uuids, int $userId): void
{
$sorting = 0;
foreach ($uuids as $uuid) {
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::GROUP_TABLE_NAME);
$queryBuilder
->update(self::GROUP_TABLE_NAME)
->where(
$queryBuilder->expr()->eq('uuid', $queryBuilder->createNamedParameter($uuid)),
$queryBuilder->expr()->eq('userid', $queryBuilder->createNamedParameter($userId, Connection::PARAM_INT))
)
->set('sorting', $sorting++)
->executeStatement();
}
}
public function moveBookmarksFromGroupToDefault(string $uuid, int $userId): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
return $queryBuilder
->update(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq('group_uuid', $queryBuilder->createNamedParameter($uuid)),
$queryBuilder->expr()->eq('userid', $queryBuilder->createNamedParameter($userId, Connection::PARAM_INT))
)
->set('group_uuid', null)
->set('sc_group', BookmarkService::GROUP_DEFAULT)
->executeStatement();
}
}
@@ -0,0 +1,622 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Backend\Bookmark;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Backend\Bookmark\Security\BookmarkGroupVoter;
use TYPO3\CMS\Backend\Backend\Bookmark\Security\BookmarkVoter;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\Router;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
/**
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
#[Autoconfigure(public: true)]
readonly class BookmarkService
{
use Traits\RouteParserTrait;
public const GROUP_DEFAULT = 0;
public const GROUP_SUPERGLOBAL = -100;
public function __construct(
protected BookmarkRepository $bookmarkRepository,
protected BookmarkVoter $bookmarkVoter,
protected BookmarkGroupVoter $bookmarkGroupVoter,
protected IconFactory $iconFactory,
protected ModuleProvider $moduleProvider,
protected Router $router,
protected UriBuilder $uriBuilder,
) {}
protected function getRouter(): Router
{
return $this->router;
}
public function isEnabled(): bool
{
return (bool)($this->getBackendUser()->getTSConfig()['options.']['enableBookmarks'] ?? false);
}
/**
* @return Bookmark[]
*/
public function getBookmarks(): array
{
$backendUser = $this->getBackendUser();
$userId = (int)$backendUser->user['uid'];
$rows = $this->bookmarkRepository->findByUser($userId);
// Build list of valid group IDs from existing groups
$groups = $this->getGroups();
$validGroupIds = array_map(static fn(BookmarkGroup $g) => $g->id, $groups);
$bookmarks = [];
foreach ($rows as $row) {
// Skip bookmarks the user cannot read (e.g., global bookmarks they can't navigate to)
if (!$this->bookmarkVoter->vote(BookmarkVoter::READ, $row, $backendUser)) {
continue;
}
$row = $this->migrateBookmarkGroup($row, $validGroupIds);
$bookmark = $this->createBookmarkFromRow($row, $backendUser);
if ($bookmark !== null) {
$bookmarks[] = $bookmark;
}
}
return $bookmarks;
}
/**
* Ensures bookmark is assigned to a valid group for output.
*/
private function migrateBookmarkGroup(array $row, array $validGroupIds): array
{
$groupUuid = $row['group_uuid'] ?? null;
$scGroup = (int)($row['sc_group'] ?? 0);
$groupId = $groupUuid !== null ? $groupUuid : $scGroup;
if (in_array($groupId, $validGroupIds, true)) {
return $row;
}
$row['group_uuid'] = null;
$row['sc_group'] = $scGroup < 0 ? self::GROUP_SUPERGLOBAL : self::GROUP_DEFAULT;
return $row;
}
/**
* @return BookmarkGroup[]
*/
public function getGroups(): array
{
$groups = [];
$backendUser = $this->getBackendUser();
$languageService = $this->getLanguageService();
$globalPrefix = $languageService->sL('core.bookmarks:global');
// User-created groups
$userId = (int)$backendUser->user['uid'];
$userGroupRows = $this->bookmarkRepository->findGroupsByUser($userId);
foreach ($userGroupRows as $index => $row) {
$groups[] = $this->createBookmarkGroup(
$row['uuid'],
$row['label'] ?? '',
BookmarkGroupType::USER,
(int)($row['sorting'] ?? $index),
$backendUser,
isset($row['userid']) ? (int)$row['userid'] : null,
);
}
// TSconfig groups and their global counterparts
$configGroups = $backendUser->getTSConfig()['options.']['bookmarkGroups.'] ?? [];
$tsconfigIndex = 0;
if (is_array($configGroups)) {
foreach ($configGroups as $groupId => $configLabel) {
$groupId = (int)$groupId;
if ($groupId <= 0 || $groupId === 100 || $configLabel === '' || $configLabel === null) {
continue;
}
$label = $languageService->sL((string)$configLabel);
$sorting = $tsconfigIndex++;
$groups[] = $this->createBookmarkGroup(
$groupId,
$label,
BookmarkGroupType::SYSTEM,
$sorting,
$backendUser,
);
$groups[] = $this->createBookmarkGroup(
-$groupId,
$globalPrefix . ': ' . $label,
BookmarkGroupType::GLOBAL,
$sorting,
$backendUser,
);
}
}
// Superglobal group
$groups[] = $this->createBookmarkGroup(
self::GROUP_SUPERGLOBAL,
$globalPrefix . ': ' . $languageService->sL('core.bookmarks:all'),
BookmarkGroupType::GLOBAL,
0,
$backendUser,
);
// Default group
$groups[] = $this->createBookmarkGroup(
self::GROUP_DEFAULT,
$languageService->sL('core.bookmarks:group_default'),
BookmarkGroupType::SYSTEM,
100,
$backendUser,
);
// Sort by type priority, then by sorting value
$priorityKeys = array_map(static fn(BookmarkGroup $g) => $g->type->getPriority(), $groups);
$sortingKeys = array_map(static fn(BookmarkGroup $g) => $g->sorting, $groups);
array_multisort($priorityKeys, SORT_ASC, SORT_NUMERIC, $sortingKeys, SORT_ASC, SORT_NUMERIC, $groups);
return $groups;
}
public function getBookmark(int $id): ?Bookmark
{
$row = $this->bookmarkRepository->findById($id);
if ($row === null) {
return null;
}
$backendUser = $this->getBackendUser();
if (!$this->bookmarkVoter->vote(BookmarkVoter::READ, $row, $backendUser)) {
return null;
}
$groups = $this->getGroups();
$validGroupIds = array_map(static fn(BookmarkGroup $g) => $g->id, $groups);
$row = $this->migrateBookmarkGroup($row, $validGroupIds);
return $this->createBookmarkFromRow($row, $backendUser);
}
public function hasBookmark(string $routeIdentifier, string $arguments): bool
{
$userId = (int)$this->getBackendUser()->user['uid'];
return $this->bookmarkRepository->exists($userId, $routeIdentifier, $arguments);
}
/**
* @return int|false The ID of the newly created bookmark, or false on failure
*/
public function createBookmark(string $routeIdentifier, string $arguments = '', string $title = ''): int|false
{
$backendUser = $this->getBackendUser();
if (!$this->bookmarkVoter->vote(BookmarkVoter::CREATE, [], $backendUser)) {
return false;
}
if (!$this->router->hasRoute($routeIdentifier)) {
return false;
}
if ($arguments !== '' && !json_validate($arguments)) {
return false;
}
$userId = (int)$backendUser->user['uid'];
$result = $this->bookmarkRepository->insert($userId, $routeIdentifier, $arguments, $title);
return $result;
}
/**
* @return array{success: bool, error?: string}
*/
public function updateBookmark(int $id, string $title, int|string $groupId): array
{
$backendUser = $this->getBackendUser();
// Check if bookmark exists
$bookmark = $this->bookmarkRepository->findById($id);
if ($bookmark === null) {
return $this->errorResponse(
'core.bookmarks:error.notFound.message'
);
}
// Check edit permission
if (!$this->bookmarkVoter->vote(BookmarkVoter::EDIT, $bookmark, $backendUser)) {
return $this->errorResponse(
'core.bookmarks:error.accessDenied.message'
);
}
// Check if user can use global groups
if (is_int($groupId) && $groupId < 0 && !$backendUser->isAdmin()) {
return $this->errorResponse(
'core.bookmarks:error.globalGroupNotAllowed.message'
);
}
$userId = $backendUser->isAdmin() ? null : (int)$backendUser->user['uid'];
$allowGlobalGroups = $backendUser->isAdmin();
$this->bookmarkRepository->update(
$id,
$userId,
$title,
$groupId,
$allowGlobalGroups
);
return ['success' => true];
}
/**
* @return array{success: false, error: string}
*/
private function errorResponse(string $label): array
{
return [
'success' => false,
'error' => $this->getLanguageService()->sL($label),
];
}
/**
* @return array{success: bool, error?: string}
*/
public function deleteBookmark(int $id): array
{
$backendUser = $this->getBackendUser();
$bookmark = $this->bookmarkRepository->findById($id);
if ($bookmark === null) {
return $this->errorResponse(
'core.bookmarks:error.notFound.message'
);
}
if (!$this->bookmarkVoter->vote(BookmarkVoter::DELETE, $bookmark, $backendUser)) {
return $this->errorResponse(
'core.bookmarks:error.accessDenied.message'
);
}
$affectedRows = $this->bookmarkRepository->delete($id);
if ($affectedRows !== 1) {
return $this->errorResponse(
'core.bookmarks:error.deleteFailed.message'
);
}
return ['success' => true];
}
/**
* @param int[] $bookmarkIds
*/
public function reorderBookmarks(array $bookmarkIds): bool
{
$backendUser = $this->getBackendUser();
$userId = (int)$backendUser->user['uid'];
// Fetch all bookmarks in one query
$bookmarks = $this->bookmarkRepository->findByIds($bookmarkIds);
$sorting = 0;
foreach ($bookmarkIds as $bookmarkId) {
$bookmark = $bookmarks[$bookmarkId] ?? null;
if ($bookmark !== null && $this->bookmarkVoter->vote(BookmarkVoter::EDIT, $bookmark, $backendUser)) {
$this->bookmarkRepository->updateSorting($bookmarkId, $userId, $sorting++);
}
}
return true;
}
/**
* @param int[] $bookmarkIds
*/
public function deleteBookmarks(array $bookmarkIds): bool
{
$backendUser = $this->getBackendUser();
$userId = (int)$backendUser->user['uid'];
// Fetch all bookmarks in one query
$bookmarks = $this->bookmarkRepository->findByIds($bookmarkIds);
$manageableIds = [];
foreach ($bookmarkIds as $bookmarkId) {
$bookmark = $bookmarks[$bookmarkId] ?? null;
if ($bookmark !== null && $this->bookmarkVoter->vote(BookmarkVoter::DELETE, $bookmark, $backendUser)) {
$manageableIds[] = $bookmarkId;
}
}
if ($manageableIds !== []) {
$this->bookmarkRepository->deleteMultiple($manageableIds, $userId);
}
return true;
}
/**
* @param int[] $bookmarkIds
*/
public function moveBookmarks(array $bookmarkIds, int|string $groupId): bool
{
$backendUser = $this->getBackendUser();
$userId = (int)$backendUser->user['uid'];
$allowGlobalGroups = $backendUser->isAdmin();
// Validate target group access for user-created groups
if (is_string($groupId)) {
$group = $this->bookmarkRepository->findGroupByUuid($groupId);
if ($group === null || !$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::READ, $group, $backendUser)) {
return false;
}
}
// Fetch all bookmarks in one query and filter to those user can edit
$bookmarks = $this->bookmarkRepository->findByIds($bookmarkIds);
$manageableIds = [];
foreach ($bookmarkIds as $bookmarkId) {
$bookmark = $bookmarks[$bookmarkId] ?? null;
if ($bookmark !== null && $this->bookmarkVoter->vote(BookmarkVoter::EDIT, $bookmark, $backendUser)) {
$manageableIds[] = $bookmarkId;
}
}
if ($manageableIds === []) {
return false;
}
$this->bookmarkRepository->moveToGroup(
$manageableIds,
$userId,
$groupId,
$allowGlobalGroups
);
return true;
}
public function createGroup(string $label): ?BookmarkGroup
{
$backendUser = $this->getBackendUser();
if (!$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::CREATE, [], $backendUser)) {
return null;
}
$userId = (int)$backendUser->user['uid'];
$uuid = $this->bookmarkRepository->createGroup($userId, $label);
if ($uuid === null) {
return null;
}
$row = $this->bookmarkRepository->findGroupByUuid($uuid);
if ($row === null) {
return null;
}
return $this->createBookmarkGroup(
$row['uuid'],
$row['label'] ?? '',
BookmarkGroupType::USER,
0,
$backendUser,
isset($row['userid']) ? (int)$row['userid'] : null,
);
}
public function updateGroup(string $uuid, string $label): bool
{
$backendUser = $this->getBackendUser();
$group = $this->bookmarkRepository->findGroupByUuid($uuid);
if ($group === null || !$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::EDIT, $group, $backendUser)) {
return false;
}
$this->bookmarkRepository->updateGroup($uuid, $label);
return true;
}
public function deleteGroup(string $uuid): bool
{
$backendUser = $this->getBackendUser();
$group = $this->bookmarkRepository->findGroupByUuid($uuid);
if ($group === null || !$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::DELETE, $group, $backendUser)) {
return false;
}
$userId = (int)$backendUser->user['uid'];
$this->bookmarkRepository->moveBookmarksFromGroupToDefault($uuid, $userId);
$affectedRows = $this->bookmarkRepository->deleteGroup($uuid);
return $affectedRows > 0;
}
/**
* @param string[] $uuids
*/
public function reorderGroups(array $uuids): bool
{
$backendUser = $this->getBackendUser();
$userId = (int)$backendUser->user['uid'];
// Get all user's groups in one query
$userGroups = $this->bookmarkRepository->findGroupsByUser($userId);
$groupsByUuid = [];
foreach ($userGroups as $group) {
$groupsByUuid[$group['uuid']] = $group;
}
// Verify all provided UUIDs can be edited by the user
foreach ($uuids as $uuid) {
$group = $groupsByUuid[$uuid] ?? null;
if ($group === null || !$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::EDIT, $group, $backendUser)) {
return false;
}
}
$this->bookmarkRepository->reorderGroups($uuids, $userId);
return true;
}
private function createBookmarkFromRow(array $row, BackendUserAuthentication $user): ?Bookmark
{
$routeIdentifier = $row['route'] ?? '';
try {
$arguments = json_decode($row['arguments'] ?? '', true, 64, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
if (!is_array($arguments)) {
return null;
}
$moduleName = $this->getModuleNameFromRouteIdentifier($routeIdentifier);
if ($moduleName === '') {
return null;
}
$accessible = $this->bookmarkVoter->vote(BookmarkVoter::NAVIGATE, $row, $user);
$editable = $this->bookmarkVoter->vote(BookmarkVoter::EDIT, $row, $user);
$groupUuid = $row['group_uuid'] ?? null;
$groupId = $groupUuid !== null ? $groupUuid : (int)$row['sc_group'];
$bookmarkData = $this->parseRecordEditData($routeIdentifier, $arguments);
$iconData = $this->resolveBookmarkIcon($routeIdentifier, $moduleName, $bookmarkData);
$href = $accessible ? (string)$this->uriBuilder->buildUriFromRoute($routeIdentifier, $arguments) : '';
return new Bookmark(
id: (int)$row['uid'],
route: $routeIdentifier,
arguments: $row['arguments'] ?? '',
title: ($row['description'] ?? false) ?: 'Bookmark',
groupId: $groupId,
iconIdentifier: $iconData['identifier'],
iconOverlayIdentifier: $iconData['overlay'],
module: $moduleName,
href: $href,
editable: $editable,
accessible: $accessible,
);
}
/**
* @return array{identifier: string, overlay: string}
*/
private function resolveBookmarkIcon(string $routeIdentifier, string $moduleName, array $bookmarkData): array
{
$identifier = '';
$overlay = '';
switch ($routeIdentifier) {
case 'record_edit':
$table = $bookmarkData['table'] ?? '';
$recordid = $bookmarkData['recordid'] ?? 0;
$action = $bookmarkData['action'] ?? '';
if ($action === 'edit') {
$row = BackendUtility::getRecordWSOL($table, (int)$recordid) ?? [];
$icon = $this->iconFactory->getIconForRecord($table, $row, IconSize::SMALL);
} elseif ($action === 'new') {
$icon = $this->iconFactory->getIconForRecord($table, [], IconSize::SMALL);
} else {
$icon = $this->iconFactory->getIcon('empty-empty', IconSize::SMALL);
}
$identifier = $icon->getIdentifier();
$overlay = $icon->getOverlayIcon()?->getIdentifier() ?? '';
break;
case 'file_edit':
$identifier = 'mimetypes-text-html';
break;
default:
$iconIdentifier = '';
if ($module = $this->moduleProvider->getModule($moduleName, null, false)) {
$iconIdentifier = $module->getIconIdentifier();
if ($iconIdentifier === '' && $module->getParentModule()) {
$iconIdentifier = $module->getParentModule()->getIconIdentifier();
}
}
if ($iconIdentifier === '') {
$iconIdentifier = 'empty-empty';
}
$identifier = $iconIdentifier;
}
return [
'identifier' => $identifier,
'overlay' => $overlay,
];
}
private function createBookmarkGroup(
int|string $id,
string $label,
BookmarkGroupType $type,
int $sorting,
BackendUserAuthentication $user,
?int $userid = null
): BookmarkGroup {
$voterData = ['id' => $id, 'userid' => $userid];
$editable = $this->bookmarkGroupVoter->vote(BookmarkGroupVoter::EDIT, $voterData, $user);
$selectable = $this->bookmarkGroupVoter->vote(BookmarkGroupVoter::SELECT, $voterData, $user);
return new BookmarkGroup(
id: $id,
label: $label,
type: $type,
sorting: $sorting,
editable: $editable,
selectable: $selectable,
);
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Backend\Bookmark\Security;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
/**
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
#[Autoconfigure(public: true)]
final readonly class BookmarkGroupVoter
{
public const string READ = 'read';
public const string CREATE = 'create';
public const string EDIT = 'edit';
public const string DELETE = 'delete';
public const string SELECT = 'select';
public function vote(string $attribute, array $group, BackendUserAuthentication $user): bool
{
return match ($attribute) {
self::READ => $this->canRead($group, $user),
self::CREATE => $this->canCreate($user),
self::EDIT => $this->canEdit($group, $user),
self::DELETE => $this->canDelete($group, $user),
self::SELECT => $this->canSelect($group, $user),
default => false,
};
}
private function canCreate(BackendUserAuthentication $user): bool
{
return isset($user->user['uid']) && (int)$user->user['uid'] > 0;
}
private function canRead(array $group, BackendUserAuthentication $user): bool
{
$userId = (int)$user->user['uid'];
return (int)$group['userid'] === $userId;
}
private function canEdit(array $group, BackendUserAuthentication $user): bool
{
$groupId = $group['id'] ?? null;
// System/global groups (integer IDs) are not editable
if (is_int($groupId) || is_numeric($groupId)) {
return false;
}
// User-created groups (UUID strings) are editable only if user owns them
if (!isset($group['userid'])) {
return false;
}
$userId = (int)$user->user['uid'];
return (int)$group['userid'] === $userId;
}
private function canDelete(array $group, BackendUserAuthentication $user): bool
{
// Delete permission mirrors edit permission
return $this->canEdit($group, $user);
}
private function canSelect(array $group, BackendUserAuthentication $user): bool
{
$groupId = $group['id'] ?? 0;
// Global groups (negative IDs) are only selectable by admins
if (is_int($groupId) && $groupId < 0) {
return $user->isAdmin();
}
return true;
}
}
@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Backend\Bookmark\Security;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Backend\Bookmark\Traits\RouteParserTrait;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\Router;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
/**
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
#[Autoconfigure(public: true)]
final readonly class BookmarkVoter
{
use RouteParserTrait;
public const string READ = 'read';
public const string CREATE = 'create';
public const string EDIT = 'edit';
public const string DELETE = 'delete';
public const string NAVIGATE = 'navigate';
public function __construct(
protected ModuleProvider $moduleProvider,
protected Router $router,
protected LoggerInterface $logger,
protected StorageRepository $storageRepository,
) {}
protected function getRouter(): Router
{
return $this->router;
}
public function vote(string $attribute, array $bookmark, BackendUserAuthentication $user): bool
{
return match ($attribute) {
self::READ => $this->canRead($bookmark, $user),
self::CREATE => $this->canCreate($user),
self::EDIT => $this->canEdit($bookmark, $user),
self::DELETE => $this->canDelete($bookmark, $user),
self::NAVIGATE => $this->canNavigate($bookmark, $user),
default => false,
};
}
private function canCreate(BackendUserAuthentication $user): bool
{
return isset($user->user['uid']) && (int)$user->user['uid'] > 0;
}
private function canRead(array $bookmark, BackendUserAuthentication $user): bool
{
$userId = (int)$user->user['uid'];
// User's own bookmarks are always readable
if ((int)$bookmark['userid'] === $userId) {
return true;
}
// Global bookmarks are only readable if user can navigate to them
if ((int)$bookmark['sc_group'] < 0) {
return $this->canNavigate($bookmark, $user);
}
return false;
}
private function canEdit(array $bookmark, BackendUserAuthentication $user): bool
{
if (!$this->canRead($bookmark, $user)) {
return false;
}
$userId = (int)$user->user['uid'];
$isOwner = (int)$bookmark['userid'] === $userId;
$groupId = isset($bookmark['group_uuid']) && $bookmark['group_uuid'] !== '' ? $bookmark['group_uuid'] : (int)$bookmark['sc_group'];
$isGlobal = is_int($groupId) && $groupId < 0;
if ($isOwner && !$isGlobal) {
return true;
}
if ($user->isAdmin() && $isGlobal) {
return true;
}
return false;
}
private function canDelete(array $bookmark, BackendUserAuthentication $user): bool
{
$userId = (int)$user->user['uid'];
if ((int)$bookmark['userid'] === $userId) {
return true;
}
if ($user->isAdmin() && (int)$bookmark['sc_group'] < 0) {
return true;
}
return false;
}
private function canNavigate(array $bookmark, BackendUserAuthentication $user): bool
{
$routeIdentifier = $bookmark['route'] ?? '';
try {
$arguments = json_decode($bookmark['arguments'] ?? '', true, 64, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return false;
}
if (!is_array($arguments)) {
return false;
}
$moduleName = $this->getModuleNameFromRouteIdentifier($routeIdentifier);
if ($moduleName === '') {
return false;
}
if (!$this->canAccessModule($routeIdentifier, $moduleName, $user)) {
return false;
}
if (!$this->canAccessFile($moduleName, $arguments)) {
return false;
}
if (!$this->canAccessRecord($moduleName, $routeIdentifier, $arguments, $user)) {
return false;
}
return true;
}
private function canAccessModule(string $routeIdentifier, string $moduleName, BackendUserAuthentication $user): bool
{
// record_edit has its own access checks via canAccessRecord
if ($routeIdentifier === 'record_edit') {
return true;
}
return $this->moduleProvider->accessGranted($moduleName, $user);
}
private function canAccessFile(string $moduleName, array $arguments): bool
{
if ($moduleName !== 'file_FilelistList' && $moduleName !== 'media_management') {
return true;
}
$combinedIdentifier = (string)($arguments['id'] ?? '');
if ($combinedIdentifier === '') {
return true;
}
$storage = $this->storageRepository->findByCombinedIdentifier($combinedIdentifier);
if ($storage === null || $storage->isFallbackStorage()) {
return false;
}
$folderIdentifier = substr($combinedIdentifier, strpos($combinedIdentifier, ':') + 1);
try {
$storage->getFolder($folderIdentifier);
} catch (InsufficientFolderAccessPermissionsException) {
return false;
} catch (FolderDoesNotExistException) {
return true;
} catch (\Throwable $e) {
$this->logger->error('Failed to resolve folder identifier "{folder}" in backend user bookmark: {message}', [
'folder' => $folderIdentifier,
'message' => $e->getMessage(),
]);
return false;
}
return true;
}
private function canAccessRecord(
string $moduleName,
string $routeIdentifier,
array $arguments,
BackendUserAuthentication $user
): bool {
if ($moduleName === 'file_FilelistList' || $moduleName === 'media_management') {
return true;
}
$bookmarkData = $this->parseRecordEditData($routeIdentifier, $arguments);
$pageId = 0;
if ($moduleName === 'record_edit' && isset($bookmarkData['table'], $bookmarkData['recordid'])) {
if (!$user->check('tables_modify', $bookmarkData['table'])) {
return false;
}
$action = $bookmarkData['action'] ?? '';
$recordId = (int)$bookmarkData['recordid'];
if ($action === 'edit' || ($action === 'new' && $recordId < 0)) {
$record = BackendUtility::getRecord($bookmarkData['table'], abs($recordId));
if ($record === null || $record === []) {
return false;
}
$pageId = ($bookmarkData['table'] === 'pages' ? (int)($record['uid'] ?? 0) : (int)($record['pid'] ?? 0));
} elseif ($action === 'new' && $recordId > 0) {
$pageId = $recordId;
}
} else {
$pageId = (int)($arguments['id'] ?? 0);
}
if ($pageId > 0 && !$user->isAdmin()) {
if ($user->isInWebMount($pageId) === null) {
return false;
}
$pageRow = BackendUtility::getRecord('pages', $pageId);
if ($pageRow === null || !$user->doesUserHaveAccess($pageRow, Permission::PAGE_SHOW)) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Backend\Bookmark\Traits;
use TYPO3\CMS\Backend\Routing\Router;
/**
* Shared route parsing functionality for bookmark components.
*
* @internal
*/
trait RouteParserTrait
{
abstract protected function getRouter(): Router;
private function getModuleNameFromRouteIdentifier(string $routeIdentifier): string
{
if (in_array($routeIdentifier, ['record_edit', 'file_edit'], true)) {
return $routeIdentifier;
}
return (string)($this->getRouter()->getRoute($routeIdentifier)?->getOption('module')?->getIdentifier() ?? '');
}
/**
* @return array{table?: string, recordid?: string, action?: string}
*/
private function parseRecordEditData(string $routeIdentifier, array $arguments): array
{
if ($routeIdentifier !== 'record_edit' || !is_array($arguments['edit'] ?? null)) {
return [];
}
$table = key($arguments['edit']);
$tableData = current($arguments['edit']);
$recordId = is_array($tableData) ? key($tableData) : null;
if (!is_string($table) || (!is_string($recordId) && !is_int($recordId))) {
return [];
}
$recordId = (string)$recordId;
if (str_ends_with($recordId, ',')) {
$recordId = substr($recordId, 0, -1);
}
return [
'table' => $table,
'recordid' => $recordId,
'action' => $arguments['edit'][$table][$recordId] ?? '',
];
}
}