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