Skip to content
5 changes: 3 additions & 2 deletions lib/Service/GoogleDriveAPIService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -540,7 +541,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 = FileUtils::sanitizeFilename((string)($dir['name']), $id, $this->logger);
if (!$currentFolder->nodeExists($name)) {
$newDir = $currentFolder->newFolder($name);
} else {
Expand Down Expand Up @@ -623,7 +624,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 = FileUtils::sanitizeFilename((string)($fileItem['name']), $fileItem['id'], $this->logger);

if (in_array($fileItem['mimeType'], array_values(self::DOCUMENT_MIME_TYPES))) {
$documentFormat = $this->getUserDocumentFormat($userId);
Expand Down
5 changes: 3 additions & 2 deletions lib/Service/GooglePhotosAPIService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -277,7 +278,7 @@ public function importPhotos(
$seenIds = [];
foreach ($albums as $album) {
$albumId = $album['id'];
$albumName = preg_replace('/\//', '_', $album['title'] ?? 'Untitled');
$albumName = FileUtils::sanitizeFilename((string)($album['title']), $album['id'], $this->logger);
if (!$folder->nodeExists($albumName)) {
$albumFolder = $folder->newFolder($albumName);
} else {
Expand Down Expand Up @@ -372,7 +373,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 = FileUtils::sanitizeFilename((string)($photo['filename']), $photo['id'], $this->logger);
if ($albumFolder->nodeExists($photoName)) {
$photoName = $photo['id'] . '_' . $photoName;
}
Expand Down
134 changes: 134 additions & 0 deletions lib/Service/Utils/FileUtils.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

namespace OCA\Google\Service\Utils;

use OCP\Files\FileNameTooLongException;
use OCP\Files\EmptyFileNameException;
use OCP\Files\InvalidCharacterInPathException;
use OCP\Files\InvalidDirectoryException;
use OCP\Files\ReservedWordException;
use OCP\Files\InvalidPathException;
use Psr\Log\LoggerInterface;
use OC;

class FileUtils {

/**
* Sanitize the filename to ensure it is valid, does not exceed length limits.
*
* @param string $filename The original filename to sanitize.
* @param string $id A unique ID to append if necessary to ensure uniqueness.
* @param int $recursionDepth The current recursion depth (used to prevent infinite loops).
* @return string The sanitized and validated filename.
*/
public static function sanitizeFilename(
string $filename,
string $id,
LoggerInterface $logger,
int $recursionDepth = 0,
string $originalFilename = null
): string {
// Prevent infinite recursion by limiting the depth.
if ($recursionDepth > 15) {
$filename = 'Untitled_' . $id;
$logger->warning('Maximum recursion depth reached while sanitizing filename: ' . $originalFilename . ' renaming to ' . $filename);
return $filename;
}

// If the original filename is not provided, use the current filename.
if ($originalFilename === null) {
$originalFilename = $filename;
}

// Trim leading/trailing whitespace and trailing dots.
$filename = rtrim(trim($filename), '.');

// Check if trimming altered the filename.
$trimmed = ($originalFilename !== $filename);

// Helper function to append the ID before the file extension.
$appendIdBeforeExtension = function ($filename, $id) {
$pathInfo = pathinfo($filename);
if (isset($pathInfo['extension'])) {
return $pathInfo['filename'] . '_' . $id . '.' . $pathInfo['extension'];
} else {
return $filename . '_' . $id;
}
};

// Append the ID if trimming occurred and the ID is not already present.
if ($trimmed && !str_contains($filename, $id)) {
$filename = $appendIdBeforeExtension($filename, $id);
}

// Ensure the filename length does not exceed the maximum allowed length.
$maxLength = 254;
if (mb_strlen($filename) > $maxLength) {
$pathInfo = pathinfo($filename);
$baseLength = $maxLength - mb_strlen($id) - 2; // Account for '_' and '.'.
if (isset($pathInfo['extension'])) {
$baseLength -= mb_strlen($pathInfo['extension']);
$filename = mb_substr($pathInfo['filename'], 0, $baseLength) . '_' . $id . '.' . $pathInfo['extension'];
} else {
$filename = mb_substr($filename, 0, $baseLength) . '_' . $id;
}
}

try {
// Validate the filename using the Nextcloud filename validator.
\OC::$server->get(\OCP\Files\IFilenameValidator::class)->validateFilename($filename);

// if recursion depth is greater than 0, log the change.
if ($recursionDepth > 0) {
$logger->info('Filename sanitized successfully: "' . $filename . '" (original: "' . $originalFilename . '")');
}

return $filename;
} catch (InvalidPathException $exception) {
$logger->warning('Invalid filename detected during sanitization: ' . $filename, ['exception' => $exception]);
}

// Handle specific exceptions and adjust the filename accordingly.
switch (true) {
case $exception instanceof FileNameTooLongException:
$filename = mb_substr($filename, 0, $maxLength - mb_strlen($id) - 2);
break;

case $exception instanceof EmptyFileNameException:
$filename = 'Untitled';
break;

case $exception instanceof InvalidCharacterInPathException:
if (preg_match('/"(.*?)"/', $exception->getMessage(), $matches)) {
$invalidChars = array_merge(str_split($matches[1]), ['"']);
$filename = str_replace($invalidChars, '-', $filename);
}
break;

case $exception instanceof InvalidDirectoryException:
$logger->error('Invalid directory detected in filename: ' . $exception->getMessage());
$filename = 'Untitled';
break;

case $exception instanceof ReservedWordException:
if (preg_match('/"(.*?)"/', $exception->getMessage(), $matches)) {
$reservedWord = $matches[1];
$filename = str_ireplace($reservedWord, '-' . $reservedWord . '-', $filename);
}
break;

default:
$logger->error('Unknown exception encountered during filename sanitization: ' . $filename);
$filename = 'Untitled';
break;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, this is a really elaborate. Thank you for the effort you put into this! I was actually thinking more of something like this: https://github.com/nextcloud/server/pull/51608/files#diff-911ea9939fad17c78ada50c38706874091e2478e37a2ef5a155481c0c4a81a98R144-R165 But it turns out the methods used there are not in OCP :(

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm inquiring now if we can get a proper sanitization method in OCP to avoid parsing error messages from the validator, which seems a bit brittle

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nextcloud 32 will have that method in OCP

}

// Append the ID if the filename was modified and does not already contain the ID.
if (!str_contains($filename, $id)) {
$filename = $appendIdBeforeExtension($filename, $id);
}

// Recursively validate the adjusted filename.
return self::sanitizeFilename($filename, $id, $logger, $recursionDepth + 1, $originalFilename);
}
}