constructPath($preview); $this->createParentFiles($previewPath); return file_put_contents($previewPath, $stream); } #[Override] public function readPreview(Preview $preview): mixed { $previewPath = $this->constructPath($preview); $resource = @fopen($previewPath, 'r'); if ($resource === false) { throw new NotFoundException('Unable to open preview stream at ' . $previewPath); } return $resource; } #[Override] public function deletePreview(Preview $preview): void { $previewPath = $this->constructPath($preview); if (!@unlink($previewPath) && is_file($previewPath)) { throw new NotPermittedException('Unable to delete preview at ' . $previewPath); } } public function getRootFolder(): string { return $this->config->getSystemValueString('datadirectory', OC::$SERVERROOT . '/data'); } public function getPreviewRootFolder(): string { return $this->getRootFolder() . '/' . $this->rootFolder->getAppDataDirectoryName() . '/preview/'; } private function constructPath(Preview $preview): string { return $this->getPreviewRootFolder() . implode('/', str_split(substr(md5((string)$preview->getFileId()), 0, 7))) . '/' . $preview->getFileId() . '/' . $preview->getName(); } private function createParentFiles(string $path): void { $dirname = dirname($path); if (!is_dir($dirname)) { mkdir($dirname, recursive: true); } if (!is_dir($dirname)) { throw new NotPermittedException("Unable to create directory '$dirname'"); } } #[Override] public function migratePreview(Preview $preview, SimpleFile $file): void { // legacy flat directory $sourcePath = $this->getPreviewRootFolder() . $preview->getFileId() . '/' . $preview->getName(); if (!file_exists($sourcePath)) { return; } $destinationPath = $this->constructPath($preview); if (file_exists($destinationPath)) { @unlink($sourcePath); // We already have a new preview, just delete the old one return; } $this->createParentFiles($destinationPath); $ok = rename($sourcePath, $destinationPath); if (!$ok) { throw new LogicException('Failed to move ' . $sourcePath . ' to ' . $destinationPath); } } #[Override] public function scan(): int { $checkForFileCache = !$this->appConfig->getValueBool('core', 'previewMovedDone'); if (!file_exists($this->getPreviewRootFolder())) { return 0; } $scanner = new RecursiveDirectoryIterator($this->getPreviewRootFolder()); $previewsFound = 0; /** * Use an associative array keyed by path for O(1) lookup instead of * the O(n) in_array() the original code used. * * @var array $skipPaths */ $skipPaths = []; /** * Pending previews grouped by fileId. A single original file can have * many preview variants (different sizes/formats), so we group them to * issue one filecache lookup per original file rather than one per * preview variant. * * @var array> $pendingByFileId */ $pendingByFileId = []; /** * path_hash => realPath for legacy filecache entries that need to be * cleaned up. Only populated when $checkForFileCache is true. * * @var array $pendingPathHashes */ $pendingPathHashes = []; $pendingCount = 0; foreach (new RecursiveIteratorIterator($scanner) as $file) { if (!$file->isFile()) { continue; } $filePath = $file->getPathname(); if (isset($skipPaths[$filePath])) { continue; } $preview = Preview::fromPath($filePath, $this->mimeTypeDetector); if ($preview === false) { $this->logger->error('Unable to parse preview information for ' . $file->getRealPath()); continue; } $preview->setSize($file->getSize()); $preview->setMtime($file->getMtime()); $preview->setEncrypted(false); $realPath = $file->getRealPath(); $pendingByFileId[$preview->getFileId()][] = [ 'preview' => $preview, 'filePath' => $filePath, 'realPath' => $realPath, ]; $pendingCount++; if ($checkForFileCache) { $relativePath = str_replace($this->getRootFolder() . '/', '', $realPath); $pendingPathHashes[md5($relativePath)] = $realPath; } if ($pendingCount >= self::SCAN_BATCH_SIZE) { $this->connection->beginTransaction(); try { $previewsFound += $this->processScanBatch($pendingByFileId, $pendingPathHashes, $checkForFileCache, $skipPaths); $this->connection->commit(); } catch (\Exception $e) { $this->connection->rollBack(); $this->logger->error($e->getMessage(), ['exception' => $e]); throw $e; } $pendingByFileId = []; $pendingPathHashes = []; $pendingCount = 0; } } if ($pendingCount > 0) { $this->connection->beginTransaction(); try { $previewsFound += $this->processScanBatch($pendingByFileId, $pendingPathHashes, $checkForFileCache, $skipPaths); $this->connection->commit(); } catch (\Exception $e) { $this->connection->rollBack(); $this->logger->error($e->getMessage(), ['exception' => $e]); throw $e; } } return $previewsFound; } /** * Process one batch of preview files collected during scan(). * * @param array> $pendingByFileId * @param array $pendingPathHashes path_hash => realPath * @param array $skipPaths Modified in place: newly-moved paths are added so the outer iterator skips them. */ private function processScanBatch( array $pendingByFileId, array $pendingPathHashes, bool $checkForFileCache, array &$skipPaths, ): int { $filecacheByFileId = $this->fetchFilecacheByFileIds(array_keys($pendingByFileId)); $legacyByPathHash = []; if ($checkForFileCache && $pendingPathHashes !== []) { $legacyByPathHash = $this->fetchFilecacheByPathHashes(array_keys($pendingPathHashes)); } $previewsFound = 0; foreach ($pendingByFileId as $fileId => $items) { if (!isset($filecacheByFileId[$fileId])) { // Original file has been deleted – clean up all its previews. foreach ($items as $item) { $this->logger->warning('Original file ' . $fileId . ' was not found. Deleting preview at ' . $item['realPath']); @unlink($item['realPath']); } continue; } $filecacheRow = $filecacheByFileId[$fileId]; foreach ($items as $item) { $preview = $item['preview']; if ($checkForFileCache) { $relativePath = str_replace($this->getRootFolder() . '/', '', $item['realPath']); $pathHash = md5($relativePath); if (isset($legacyByPathHash[$pathHash])) { $legacyRow = $legacyByPathHash[$pathHash]; $qb = $this->connection->getTypedQueryBuilder(); $qb->delete('filecache') ->where($qb->expr()->eq('fileid', $qb->createNamedParameter($legacyRow['fileid']))) ->andWhere($qb->expr()->eq('storage', $qb->createNamedParameter($legacyRow['storage']))) ->executeStatement(); $this->deleteParentsFromFileCache((int)$legacyRow['parent'], (int)$legacyRow['storage']); } } $preview->setStorageId((int)$filecacheRow['storage']); $preview->setEtag($filecacheRow['etag']); $preview->setSourceMimetype($this->mimeTypeLoader->getMimetypeById((int)$filecacheRow['mimetype'])); $preview->generateId(); $this->connection->beginTransaction(); try { $this->previewMapper->insert($preview); $this->connection->commit(); } catch (Exception $e) { $this->connection->rollBack(); if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { throw $e; } } // Move old flat preview to new nested directory format. $dirName = str_replace($this->getPreviewRootFolder(), '', $item['filePath']); if (preg_match('/[0-9a-e]\/[0-9a-e]\/[0-9a-e]\/[0-9a-e]\/[0-9a-e]\/[0-9a-e]\/[0-9a-e]\/[0-9]+/', $dirName) !== 1) { $previewPath = $this->constructPath($preview); $this->createParentFiles($previewPath); $ok = rename($item['realPath'], $previewPath); if (!$ok) { throw new LogicException('Failed to move ' . $item['realPath'] . ' to ' . $previewPath); } // Mark the destination so the outer iterator skips it if it encounters the path later. $skipPaths[$previewPath] = true; } $previewsFound++; } } return $previewsFound; } /** * Bulk-fetch filecache rows for a set of fileIds. * * @param int[] $fileIds */ private function fetchFilecacheByFileIds(array $fileIds): array { if (empty($fileIds)) { return []; } $result = []; $qb = $this->connection->getTypedQueryBuilder(); $qb->selectColumns('fileid', 'storage', 'etag', 'mimetype') ->from('filecache'); foreach (array_chunk($fileIds, 1000) as $chunk) { $qb->andWhere( $qb->expr()->in('fileid', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)) ); } $rows = $qb->runAcrossAllShards() ->executeQuery(); while ($row = $rows->fetchAssociative()) { $result[(int)$row['fileid']] = $row; } $rows->closeCursor(); return $result; } /** * Bulk-fetch filecache rows for a set of path_hashes (legacy migration). * * @param string[] $pathHashes */ private function fetchFilecacheByPathHashes(array $pathHashes): array { if (empty($pathHashes)) { return []; } $result = []; $qb = $this->connection->getTypedQueryBuilder(); $qb->selectColumns('fileid', 'storage', 'etag', 'mimetype', 'parent', 'path_hash') ->from('filecache'); foreach (array_chunk($pathHashes, 1000) as $chunk) { $qb->andWhere( $qb->expr()->in('path_hash', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY)) ); } $rows = $qb->runAcrossAllShards() ->executeQuery(); while ($row = $rows->fetchAssociative()) { $result[$row['path_hash']] = $row; } $rows->closeCursor(); return $result; } /** * Recursive method that deletes the folder and its parent folders if it's not * empty. */ private function deleteParentsFromFileCache(int $folderId, int $storageId): void { $qb = $this->connection->getTypedQueryBuilder(); $result = $qb->selectColumns('fileid', 'path', 'storage', 'parent') ->from('filecache') ->where($qb->expr()->eq('parent', $qb->createNamedParameter($folderId))) ->setMaxResults(1) ->runAcrossAllShards() ->executeQuery(); $row = $result->fetchAssociative(); $result->closeCursor(); if ($row !== false) { // there are other files in the directory, don't delete yet return; } // Get new parent $qb = $this->connection->getTypedQueryBuilder(); $result = $qb->selectColumns('fileid', 'path', 'parent') ->from('filecache') ->where($qb->expr()->eq('fileid', $qb->createNamedParameter($folderId))) ->andWhere($qb->expr()->eq('storage', $qb->createNamedParameter($storageId))) ->setMaxResults(1) ->executeQuery(); $row = $result->fetchAssociative(); $result->closeCursor(); if ($row !== false) { $parentFolderId = (int)$row['parent']; $qb = $this->connection->getTypedQueryBuilder(); $qb->delete('filecache') ->where($qb->expr()->eq('fileid', $qb->createNamedParameter($folderId))) ->andWhere($qb->expr()->eq('storage', $qb->createNamedParameter($storageId))) ->executeStatement(); $this->deleteParentsFromFileCache($parentFolderId, $storageId); } } }