623 lines
20 KiB
PHP
623 lines
20 KiB
PHP
<?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'];
|
|
}
|
|
}
|