Skip to content

Commit 4c628fe

Browse files
Port upstream PR nextcloud#209
1 parent 79930f6 commit 4c628fe

4 files changed

Lines changed: 147 additions & 6 deletions

File tree

lib/Service/GoogleDriveAPIService.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use OC\User\NoUserException;
1818
use OCA\Google\AppInfo\Application;
1919
use OCA\Google\BackgroundJob\ImportDriveJob;
20+
use OCA\Google\Service\Utils\FileUtils;
2021
use OCP\BackgroundJob\IJobList;
2122
use OCP\Files\File;
2223
use OCP\Files\Folder;
@@ -51,6 +52,7 @@ public function __construct(
5152
private IJobList $jobList,
5253
private UserScopeService $userScopeService,
5354
private GoogleAPIService $googleApiService,
55+
private FileUtils $fileUtils,
5456
) {
5557
}
5658

@@ -540,7 +542,7 @@ private function createDirsUnder(array &$directoriesById, Folder $currentFolder,
540542
// create dir if we are on top OR if its parent is current dir
541543
if (($currentFolderId === '' && !array_key_exists($parentId, $directoriesById))
542544
|| $parentId === $currentFolderId) {
543-
$name = $dir['name'];
545+
$name = $this->fileUtils->sanitizeFilename((string)($dir['name']), (string)$id);
544546
if (!$currentFolder->nodeExists($name)) {
545547
$newDir = $currentFolder->newFolder($name);
546548
} else {
@@ -550,7 +552,7 @@ private function createDirsUnder(array &$directoriesById, Folder $currentFolder,
550552
}
551553
}
552554
$directoriesById[$id]['node'] = $newDir;
553-
$success = $this->createDirsUnder($directoriesById, $newDir, $id);
555+
$success = $this->createDirsUnder($directoriesById, $newDir, (string)$id);
554556
if (!$success) {
555557
return false;
556558
}
@@ -623,7 +625,7 @@ private function downloadAndSaveFile(
623625
* @return string name of the file to be saved
624626
*/
625627
private function getFileName(array $fileItem, string $userId, bool $hasNameConflict): string {
626-
$fileName = preg_replace('/\/|\n|[^._A-Za-z0-9-]/', '-', $fileItem['name'] ?? 'Untitled');
628+
$fileName = $this->fileUtils->sanitizeFilename((string)($fileItem['name']), (string)$fileItem['id']);
627629

628630
if (in_array($fileItem['mimeType'], array_values(self::DOCUMENT_MIME_TYPES))) {
629631
$documentFormat = $this->getUserDocumentFormat($userId);

lib/Service/GooglePhotosAPIService.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Exception;
1717
use OCA\Google\AppInfo\Application;
1818
use OCA\Google\BackgroundJob\ImportPhotosJob;
19+
use OCA\Google\Service\Utils\FileUtils;
1920
use OCP\BackgroundJob\IJobList;
2021
use OCP\Files\FileInfo;
2122
use OCP\Files\Folder;
@@ -40,6 +41,7 @@ public function __construct(
4041
private IJobList $jobList,
4142
private UserScopeService $userScopeService,
4243
private GoogleAPIService $googleApiService,
44+
private FileUtils $fileUtils,
4345
) {
4446
}
4547

@@ -277,7 +279,7 @@ public function importPhotos(
277279
$seenIds = [];
278280
foreach ($albums as $album) {
279281
$albumId = $album['id'];
280-
$albumName = preg_replace('/\//', '_', $album['title'] ?? 'Untitled');
282+
$albumName = $this->fileUtils->sanitizeFilename((string)($album['title']), (string)$album['id']);
281283
if (!$folder->nodeExists($albumName)) {
282284
$albumFolder = $folder->newFolder($albumName);
283285
} else {
@@ -372,7 +374,7 @@ public function importPhotos(
372374
* @throws \OCP\Files\NotPermittedException
373375
*/
374376
private function getPhoto(string $userId, array $photo, Folder $albumFolder): ?int {
375-
$photoName = preg_replace('/\//', '_', $photo['filename'] ?? 'Untitled');
377+
$photoName = $this->fileUtils->sanitizeFilename($photo['filename'], (string)$photo['id']);
376378
if ($albumFolder->nodeExists($photoName)) {
377379
$photoName = $photo['id'] . '_' . $photoName;
378380
}

lib/Service/Utils/FileUtils.php

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
namespace OCA\Google\Service\Utils;
4+
5+
use OCP\Files\EmptyFileNameException;
6+
use OCP\Files\FileNameTooLongException;
7+
use OCP\Files\InvalidCharacterInPathException;
8+
use OCP\Files\InvalidDirectoryException;
9+
use OCP\Files\ReservedWordException;
10+
use Psr\Container\ContainerExceptionInterface;
11+
use Psr\Container\NotFoundExceptionInterface;
12+
use Psr\Log\LoggerInterface;
13+
14+
final class FileUtils {
15+
16+
public function __construct(
17+
private \OCP\Files\IFilenameValidator $validator,
18+
private \OCP\IConfig $config,
19+
private LoggerInterface $logger,
20+
) {
21+
}
22+
23+
/**
24+
* Sanitize the filename to ensure it is valid, does not exceed length limits.
25+
*
26+
* @param string $filename The original filename to sanitize.
27+
* @param string $id A unique ID to append if necessary to ensure uniqueness.
28+
* @param int $recursionDepth The current recursion depth (used to prevent infinite loops).
29+
* @param string|null $originalFilename The original filename for logging.
30+
* @return string The sanitized and validated filename.
31+
*/
32+
public function sanitizeFilename(
33+
string $filename,
34+
string $id,
35+
int $recursionDepth = 0,
36+
?string $originalFilename = null,
37+
): string {
38+
if ($recursionDepth > 15) {
39+
$filename = 'Untitled_' . $id;
40+
$this->logger->warning('Maximum recursion depth reached while sanitizing filename: ' . ($originalFilename ?? $filename) . ' renaming to ' . $filename);
41+
return $filename;
42+
}
43+
44+
if ($originalFilename === null) {
45+
$originalFilename = $filename;
46+
}
47+
48+
// Use Nextcloud 32+ validator if available
49+
if (version_compare($this->config->getSystemValueString('version', '0.0.0'), '32.0.0', '>=')) {
50+
$this->logger->debug('Using Nextcloud 32+ filename validator for sanitization.');
51+
try {
52+
return $this->validator->sanitizeFilename($filename);
53+
} catch (\InvalidArgumentException|NotFoundExceptionInterface|ContainerExceptionInterface $exception) {
54+
$this->logger->error('Unable to sanitize filename: ' . $filename, ['exception' => $exception]);
55+
return 'Untitled_' . $id;
56+
}
57+
} else {
58+
$this->logger->debug('Using legacy filename sanitization method.');
59+
}
60+
61+
// Trim whitespace and trailing dots
62+
$filename = rtrim(trim($filename), '.');
63+
64+
// Append ID if needed
65+
if ($originalFilename !== $filename && strpos($filename, $id) === false) {
66+
$filename = self::appendIdBeforeExtension($filename, $id);
67+
}
68+
69+
// Enforce max length
70+
$maxLength = 254;
71+
if (mb_strlen($filename) > $maxLength) {
72+
$filename = self::truncateAndAppendId($filename, $id, $maxLength);
73+
}
74+
75+
try {
76+
$this->validator->validateFilename($filename);
77+
if ($recursionDepth > 0) {
78+
$this->logger->info('Filename sanitized successfully: "' . $filename . '" (original: "' . $originalFilename . '")');
79+
}
80+
return $filename;
81+
} catch (\Throwable $exception) {
82+
$this->logger->warning('Exception during filename validation: ' . $filename, ['exception' => $exception]);
83+
$filename = self::handleFilenameException($filename, $id, $exception, $this->logger);
84+
if (strpos($filename, $id) === false) {
85+
$filename = self::appendIdBeforeExtension($filename, $id);
86+
}
87+
return $this->sanitizeFilename($filename, $id, $recursionDepth + 1, $originalFilename);
88+
}
89+
}
90+
91+
private static function appendIdBeforeExtension(string $filename, string $id): string {
92+
$pathInfo = pathinfo($filename);
93+
if (isset($pathInfo['extension'])) {
94+
return $pathInfo['filename'] . '_' . $id . '.' . $pathInfo['extension'];
95+
}
96+
return $filename . '_' . $id;
97+
}
98+
99+
private static function truncateAndAppendId(string $filename, string $id, int $maxLength): string {
100+
$pathInfo = pathinfo($filename);
101+
$baseLength = $maxLength - mb_strlen($id) - 2;
102+
if (isset($pathInfo['extension'])) {
103+
$baseLength -= mb_strlen($pathInfo['extension']);
104+
return mb_substr($pathInfo['filename'], 0, $baseLength) . '_' . $id . '.' . $pathInfo['extension'];
105+
}
106+
return mb_substr($filename, 0, $baseLength) . '_' . $id;
107+
}
108+
109+
private static function handleFilenameException(string $filename, string $id, \Throwable $exception, LoggerInterface $logger): string {
110+
if ($exception instanceof FileNameTooLongException) {
111+
return mb_substr($filename, 0, 254 - mb_strlen($id) - 2);
112+
}
113+
if ($exception instanceof EmptyFileNameException) {
114+
return 'Untitled';
115+
}
116+
if ($exception instanceof InvalidCharacterInPathException) {
117+
if (preg_match('/"(.*?)"/', $exception->getMessage(), $matches)) {
118+
$invalidChars = array_merge(str_split($matches[1]), ['"']);
119+
return str_replace($invalidChars, '-', $filename);
120+
}
121+
}
122+
if ($exception instanceof InvalidDirectoryException) {
123+
$logger->error('Invalid directory detected in filename: ' . $exception->getMessage());
124+
return 'Untitled';
125+
}
126+
if ($exception instanceof ReservedWordException) {
127+
if (preg_match('/"(.*?)"/', $exception->getMessage(), $matches)) {
128+
$reservedWord = $matches[1];
129+
return str_ireplace($reservedWord, '-' . $reservedWord . '-', $filename);
130+
}
131+
}
132+
$logger->error('Unknown exception encountered during filename sanitization: ' . $filename);
133+
return 'Untitled';
134+
}
135+
}

psalm.xml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<?xml version="1.0"?>
22
<psalm
3-
errorLevel="1"
3+
errorLevel="4"
44
phpVersion="8.1"
55
resolveFromConfigFile="true"
6+
findUnusedCode="false"
7+
findUnusedBaselineEntry="false"
68
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
79
xmlns="https://getpsalm.org/schema/config"
810
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"

0 commit comments

Comments
 (0)