All checks were successful
PHPUnit SQLite / versions (pull_request) Successful in 4s
PHPUnit SQLite / changes (pull_request) Successful in 1s
Lint info.xml / info.xml lint (pull_request) Successful in 3s
PHP / versions (pull_request) Successful in 5s
Static analysis / versions (pull_request) Successful in 3s
REUSE Compliance Check / reuse-compliance-check (pull_request) Successful in 3s
Node / NPM (pull_request) Successful in 21s
PHP / PHP 8.5 (8.5) (pull_request) Successful in 54s
PHP / PHP 8.2 (8.2) (pull_request) Successful in 2m16s
PHPUnit SQLite / SQLite PHP 8.5 Nextcloud master (8.5, master) (pull_request) Successful in 2m27s
PHPUnit SQLite / phpunit-sqlite-summary (pull_request) Successful in 1s
Static analysis / static-psalm-analysis dev-stable34 (dev-stable34, 8.5) (pull_request) Successful in 1m48s
Static analysis / static-psalm-analysis dev-stable33 (dev-stable33, 8.5) (pull_request) Successful in 2m30s
Static analysis / static-psalm-analysis dev-master (dev-master, 8.5) (pull_request) Successful in 2m49s
Static analysis / static-psalm-analysis-summary (pull_request) Successful in 1s
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
215 lines
6.6 KiB
PHP
215 lines
6.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: 2026 Lukas Schaefer
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
namespace OCA\Shortcuts\Service;
|
|
|
|
use OCA\Shortcuts\AppInfo\Application;
|
|
use OCA\Shortcuts\Exception\ConflictException;
|
|
use OCA\Shortcuts\Exception\ForbiddenException;
|
|
use OCA\Shortcuts\Exception\InvalidPathException;
|
|
use OCA\Shortcuts\Exception\NotFoundException;
|
|
use OCP\Constants;
|
|
use OCP\Files\File;
|
|
use OCP\Files\Folder;
|
|
use OCP\Files\IRootFolder;
|
|
use OCP\Files\Node;
|
|
use OCP\Files\NotFoundException as FilesNotFoundException;
|
|
use OCP\Files\NotPermittedException as FilesNotPermittedException;
|
|
use OCP\IUser;
|
|
|
|
class ShortcutService {
|
|
/** @psalm-suppress PossiblyUnusedMethod */
|
|
public function __construct(
|
|
private IRootFolder $rootFolder,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @return array{fileId: int, name: string, path: string, targetFileId: int, targetType: 'file'|'folder'}
|
|
*
|
|
* @throws ConflictException
|
|
* @throws ForbiddenException
|
|
* @throws InvalidPathException
|
|
* @throws NotFoundException
|
|
*/
|
|
public function create(IUser $user, int $targetFileId, string $destinationPath, ?string $shortcutName = null): array {
|
|
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
|
$target = $this->resolveAccessibleNode($userFolder, $targetFileId);
|
|
$target = $this->canonicalizeTarget($userFolder, $target);
|
|
|
|
$destinationFolder = $this->resolveDestinationFolder($userFolder, $destinationPath);
|
|
if (($destinationFolder->getPermissions() & Constants::PERMISSION_CREATE) !== Constants::PERMISSION_CREATE) {
|
|
throw new ForbiddenException('No create permission on destination folder');
|
|
}
|
|
|
|
if ($shortcutName === null) {
|
|
$shortcutName = $target->getName() . '.' . Application::SHORTCUT_EXTENSION;
|
|
}
|
|
$this->assertValidName($shortcutName);
|
|
$this->assertNoCollision($destinationFolder, $shortcutName);
|
|
|
|
try {
|
|
$file = $destinationFolder->newFile($shortcutName, (string)$target->getId());
|
|
} catch (FilesNotPermittedException $e) {
|
|
throw new ForbiddenException('Cannot create shortcut file in destination folder', 0, $e);
|
|
}
|
|
|
|
$relativePath = $this->getRelativePath($userFolder, $file);
|
|
return [
|
|
'fileId' => $file->getId(),
|
|
'name' => $file->getName(),
|
|
'path' => $relativePath,
|
|
'targetFileId' => $target->getId(),
|
|
'targetType' => $target instanceof Folder ? 'folder' : 'file',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Resolve a shortcut stub file to its target.
|
|
*
|
|
* @return array{targetFileId: int, targetType: 'file'|'folder', name: string, path: string, dirname: string}
|
|
*
|
|
* @throws ForbiddenException
|
|
* @throws InvalidPathException
|
|
* @throws NotFoundException
|
|
*/
|
|
public function resolve(IUser $user, int $shortcutFileId): array {
|
|
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
|
$shortcut = $this->resolveAccessibleNode($userFolder, $shortcutFileId);
|
|
if (!($shortcut instanceof File)) {
|
|
throw new InvalidPathException('Shortcut must be a file');
|
|
}
|
|
if (!$this->isShortcutFile($shortcut)) {
|
|
throw new InvalidPathException('Not a shortcut file');
|
|
}
|
|
|
|
$targetId = $this->parseTargetId($shortcut->getContent());
|
|
$target = $this->resolveAccessibleNode($userFolder, $targetId);
|
|
// Follow one level if the target is itself a shortcut.
|
|
$target = $this->canonicalizeTarget($userFolder, $target);
|
|
|
|
$relativePath = $this->getRelativePath($userFolder, $target);
|
|
$dirname = $this->getRelativePath($userFolder, $target->getParent());
|
|
|
|
return [
|
|
'targetFileId' => $target->getId(),
|
|
'targetType' => $target instanceof Folder ? 'folder' : 'file',
|
|
'name' => $target->getName(),
|
|
'path' => $relativePath,
|
|
'dirname' => $dirname === '' ? '/' : $dirname,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Checks if a node is a shortcut file.
|
|
*/
|
|
public function isShortcutFile(Node $node): bool {
|
|
if (!($node instanceof File)) {
|
|
return false;
|
|
}
|
|
return $node->getMimeType() === Application::SHORTCUT_MIME;
|
|
}
|
|
|
|
/**
|
|
* @throws ForbiddenException
|
|
* @throws NotFoundException
|
|
*/
|
|
private function resolveAccessibleNode(Folder $userFolder, int $fileId): Node {
|
|
$node = $userFolder->getFirstNodeById($fileId);
|
|
if ($node === null) {
|
|
throw new NotFoundException('File not found');
|
|
}
|
|
return $node;
|
|
}
|
|
|
|
/**
|
|
* @throws ForbiddenException
|
|
* @throws InvalidPathException
|
|
* @throws NotFoundException
|
|
*/
|
|
private function canonicalizeTarget(Folder $userFolder, Node $node): Node {
|
|
if (!$this->isShortcutFile($node) || !($node instanceof File)) {
|
|
return $node;
|
|
}
|
|
$targetId = $this->parseTargetId($node->getContent());
|
|
$target = $this->resolveAccessibleNode($userFolder, $targetId);
|
|
if ($this->isShortcutFile($target)) {
|
|
throw new InvalidPathException('Nested shortcuts are not supported beyond one level');
|
|
}
|
|
return $target;
|
|
}
|
|
|
|
/**
|
|
* @throws InvalidPathException
|
|
*/
|
|
private function parseTargetId(string $content): int {
|
|
$content = trim($content);
|
|
if ($content === '' || strlen($content) > 100 || !ctype_digit($content)) {
|
|
throw new InvalidPathException('Invalid shortcut contents');
|
|
}
|
|
return (int)$content;
|
|
}
|
|
|
|
/**
|
|
* @throws InvalidPathException
|
|
* @throws NotFoundException
|
|
*/
|
|
private function resolveDestinationFolder(Folder $userFolder, string $path): Folder {
|
|
try {
|
|
$node = $userFolder->get($path);
|
|
} catch (FilesNotFoundException|FilesNotPermittedException $e) {
|
|
throw new NotFoundException('Destination folder not found', 0, $e);
|
|
}
|
|
if (!($node instanceof Folder)) {
|
|
throw new InvalidPathException('Destination is not a folder');
|
|
}
|
|
return $node;
|
|
}
|
|
|
|
/**
|
|
* Ensures that the name is a valid shortcut name.
|
|
*
|
|
* @throws InvalidPathException
|
|
*/
|
|
private function assertValidName(string $name): void {
|
|
$ext = '.' . Application::SHORTCUT_EXTENSION;
|
|
if ($name === '' || $name === '.' || $name === '..' || str_contains($name, '/')) {
|
|
throw new InvalidPathException('Invalid shortcut name');
|
|
}
|
|
if (!str_ends_with(strtolower($name), $ext)) {
|
|
throw new InvalidPathException('Shortcut name must end with ' . $ext);
|
|
}
|
|
$base = substr($name, 0, -strlen($ext));
|
|
if ($base === '' || $base === '.' || $base === '..') {
|
|
throw new InvalidPathException('Invalid shortcut name');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensures that the file doesn't already exist in the destination folder.
|
|
*
|
|
* @throws ConflictException
|
|
*/
|
|
private function assertNoCollision(Folder $destinationFolder, string $name): void {
|
|
if ($destinationFolder->nodeExists($name)) {
|
|
throw new ConflictException('A file with this name already exists');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Turns /alice/files/Documents/foo.txt into /Documents/foo.txt.
|
|
*/
|
|
private function getRelativePath(Folder $userFolder, Node $node): string {
|
|
$path = $userFolder->getRelativePath($node->getPath());
|
|
if ($path === null || $path === '') {
|
|
return '/';
|
|
}
|
|
return $path;
|
|
}
|
|
}
|