nextcloud-shortcuts/lib/Service/ShortcutService.php
Lukas Schaefer c5c1de0108
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
Create initial version that supports shortcut files
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
2026-08-01 12:28:04 -04:00

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;
}
}