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 @@