diff --git a/lib/Service/GoogleDriveAPIService.php b/lib/Service/GoogleDriveAPIService.php index 70900877..a7882cb3 100644 --- a/lib/Service/GoogleDriveAPIService.php +++ b/lib/Service/GoogleDriveAPIService.php @@ -17,6 +17,7 @@ use OC\User\NoUserException; use OCA\Google\AppInfo\Application; use OCA\Google\BackgroundJob\ImportDriveJob; +use OCA\Google\Service\Utils\FileUtils; use OCP\BackgroundJob\IJobList; use OCP\Files\File; use OCP\Files\Folder; @@ -51,6 +52,7 @@ public function __construct( private IJobList $jobList, private UserScopeService $userScopeService, private GoogleAPIService $googleApiService, + private FileUtils $fileUtils, ) { } @@ -540,7 +542,7 @@ private function createDirsUnder(array &$directoriesById, Folder $currentFolder, // create dir if we are on top OR if its parent is current dir if (($currentFolderId === '' && !array_key_exists($parentId, $directoriesById)) || $parentId === $currentFolderId) { - $name = $dir['name']; + $name = $this->fileUtils->sanitizeFilename((string)($dir['name']), (string)$id); if (!$currentFolder->nodeExists($name)) { $newDir = $currentFolder->newFolder($name); } else { @@ -550,7 +552,7 @@ private function createDirsUnder(array &$directoriesById, Folder $currentFolder, } } $directoriesById[$id]['node'] = $newDir; - $success = $this->createDirsUnder($directoriesById, $newDir, $id); + $success = $this->createDirsUnder($directoriesById, $newDir, (string)$id); if (!$success) { return false; } @@ -623,7 +625,7 @@ private function downloadAndSaveFile( * @return string name of the file to be saved */ private function getFileName(array $fileItem, string $userId, bool $hasNameConflict): string { - $fileName = preg_replace('/\/|\n|[^._A-Za-z0-9-]/', '-', $fileItem['name'] ?? 'Untitled'); + $fileName = $this->fileUtils->sanitizeFilename((string)($fileItem['name']), (string)$fileItem['id']); if (in_array($fileItem['mimeType'], array_values(self::DOCUMENT_MIME_TYPES))) { $documentFormat = $this->getUserDocumentFormat($userId); diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php index 87c8d196..692409dc 100644 --- a/lib/Service/GooglePhotosAPIService.php +++ b/lib/Service/GooglePhotosAPIService.php @@ -16,6 +16,7 @@ use Exception; use OCA\Google\AppInfo\Application; use OCA\Google\BackgroundJob\ImportPhotosJob; +use OCA\Google\Service\Utils\FileUtils; use OCP\BackgroundJob\IJobList; use OCP\Files\FileInfo; use OCP\Files\Folder; @@ -40,6 +41,7 @@ public function __construct( private IJobList $jobList, private UserScopeService $userScopeService, private GoogleAPIService $googleApiService, + private FileUtils $fileUtils, ) { } @@ -277,7 +279,7 @@ public function importPhotos( $seenIds = []; foreach ($albums as $album) { $albumId = $album['id']; - $albumName = preg_replace('/\//', '_', $album['title'] ?? 'Untitled'); + $albumName = $this->fileUtils->sanitizeFilename((string)($album['title']), (string)$album['id']); if (!$folder->nodeExists($albumName)) { $albumFolder = $folder->newFolder($albumName); } else { @@ -372,7 +374,7 @@ public function importPhotos( * @throws \OCP\Files\NotPermittedException */ private function getPhoto(string $userId, array $photo, Folder $albumFolder): ?int { - $photoName = preg_replace('/\//', '_', $photo['filename'] ?? 'Untitled'); + $photoName = $this->fileUtils->sanitizeFilename($photo['filename'], (string)$photo['id']); if ($albumFolder->nodeExists($photoName)) { $photoName = $photo['id'] . '_' . $photoName; } diff --git a/lib/Service/Utils/FileUtils.php b/lib/Service/Utils/FileUtils.php new file mode 100644 index 00000000..f5cea353 --- /dev/null +++ b/lib/Service/Utils/FileUtils.php @@ -0,0 +1,135 @@ + 15) { + $filename = 'Untitled_' . $id; + $this->logger->warning('Maximum recursion depth reached while sanitizing filename: ' . ($originalFilename ?? $filename) . ' renaming to ' . $filename); + return $filename; + } + + if ($originalFilename === null) { + $originalFilename = $filename; + } + + // Use Nextcloud 32+ validator if available + if (version_compare($this->config->getSystemValueString('version', '0.0.0'), '32.0.0', '>=')) { + $this->logger->debug('Using Nextcloud 32+ filename validator for sanitization.'); + try { + return $this->validator->sanitizeFilename($filename); + } catch (\InvalidArgumentException|NotFoundExceptionInterface|ContainerExceptionInterface $exception) { + $this->logger->error('Unable to sanitize filename: ' . $filename, ['exception' => $exception]); + return 'Untitled_' . $id; + } + } else { + $this->logger->debug('Using legacy filename sanitization method.'); + } + + // Trim whitespace and trailing dots + $filename = rtrim(trim($filename), '.'); + + // Append ID if needed + if ($originalFilename !== $filename && strpos($filename, $id) === false) { + $filename = self::appendIdBeforeExtension($filename, $id); + } + + // Enforce max length + $maxLength = 254; + if (mb_strlen($filename) > $maxLength) { + $filename = self::truncateAndAppendId($filename, $id, $maxLength); + } + + try { + $this->validator->validateFilename($filename); + if ($recursionDepth > 0) { + $this->logger->info('Filename sanitized successfully: "' . $filename . '" (original: "' . $originalFilename . '")'); + } + return $filename; + } catch (\Throwable $exception) { + $this->logger->warning('Exception during filename validation: ' . $filename, ['exception' => $exception]); + $filename = self::handleFilenameException($filename, $id, $exception, $this->logger); + if (strpos($filename, $id) === false) { + $filename = self::appendIdBeforeExtension($filename, $id); + } + return $this->sanitizeFilename($filename, $id, $recursionDepth + 1, $originalFilename); + } + } + + private static function appendIdBeforeExtension(string $filename, string $id): string { + $pathInfo = pathinfo($filename); + if (isset($pathInfo['extension'])) { + return $pathInfo['filename'] . '_' . $id . '.' . $pathInfo['extension']; + } + return $filename . '_' . $id; + } + + private static function truncateAndAppendId(string $filename, string $id, int $maxLength): string { + $pathInfo = pathinfo($filename); + $baseLength = $maxLength - mb_strlen($id) - 2; + if (isset($pathInfo['extension'])) { + $baseLength -= mb_strlen($pathInfo['extension']); + return mb_substr($pathInfo['filename'], 0, $baseLength) . '_' . $id . '.' . $pathInfo['extension']; + } + return mb_substr($filename, 0, $baseLength) . '_' . $id; + } + + private static function handleFilenameException(string $filename, string $id, \Throwable $exception, LoggerInterface $logger): string { + if ($exception instanceof FileNameTooLongException) { + return mb_substr($filename, 0, 254 - mb_strlen($id) - 2); + } + if ($exception instanceof EmptyFileNameException) { + return 'Untitled'; + } + if ($exception instanceof InvalidCharacterInPathException) { + if (preg_match('/"(.*?)"/', $exception->getMessage(), $matches)) { + $invalidChars = array_merge(str_split($matches[1]), ['"']); + return str_replace($invalidChars, '-', $filename); + } + } + if ($exception instanceof InvalidDirectoryException) { + $logger->error('Invalid directory detected in filename: ' . $exception->getMessage()); + return 'Untitled'; + } + if ($exception instanceof ReservedWordException) { + if (preg_match('/"(.*?)"/', $exception->getMessage(), $matches)) { + $reservedWord = $matches[1]; + return str_ireplace($reservedWord, '-' . $reservedWord . '-', $filename); + } + } + $logger->error('Unknown exception encountered during filename sanitization: ' . $filename); + return 'Untitled'; + } +} diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 742c1692..7b9067a8 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,118 +1,34 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -127,41 +43,6 @@ - - - - - - - - - $e->getMessage()]]]> - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -195,12 +76,6 @@ - - - - - - @@ -219,71 +94,6 @@ caldavBackend]]> - - - getMessage()]]> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -293,143 +103,12 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - cdBackend]]> cdBackend]]> @@ -470,16 +149,8 @@ - - - - - - - - @@ -488,141 +159,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -631,161 +172,43 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/psalm.xml b/psalm.xml index 8a4c58ad..f770534d 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,8 +1,10 @@