1964ea5fb4
Endpoint GET /api/share/search?q= parcourant tout le partage en largeur (garde-fous 200 résultats / 2000 dossiers). Le champ de l'explorateur déclenche une recherche globale debouncée dès 2 caractères (filtre local en deçà), avec affichage du dossier parent de chaque résultat.
197 lines
6.1 KiB
PHP
197 lines
6.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service\Share;
|
|
|
|
use App\Entity\ShareConfiguration;
|
|
use App\Repository\ShareConfigurationRepository;
|
|
use App\Service\Share\Exception\ShareConnectionException;
|
|
use App\Service\Share\Exception\ShareNotConfiguredException;
|
|
use App\Service\TokenEncryptor;
|
|
use Icewind\SMB\BasicAuth;
|
|
use Icewind\SMB\IFileInfo;
|
|
use Icewind\SMB\IShare;
|
|
use Icewind\SMB\ServerFactory;
|
|
use Symfony\Component\Mime\MimeTypes;
|
|
use Throwable;
|
|
|
|
use function count;
|
|
|
|
final class SmbFileSource implements FileSource
|
|
{
|
|
/** Garde-fou : nombre maximum de dossiers explorés par recherche (évite de bloquer sur un très gros partage). */
|
|
private const int SEARCH_MAX_DIRS = 2000;
|
|
|
|
public function __construct(
|
|
private readonly ShareConfigurationRepository $configRepository,
|
|
private readonly TokenEncryptor $tokenEncryptor,
|
|
private readonly SharePathResolver $pathResolver,
|
|
) {}
|
|
|
|
public function dir(string $relativePath): array
|
|
{
|
|
$config = $this->requireUsableConfig();
|
|
$share = $this->connect($config);
|
|
$full = $this->pathResolver->fullPath((string) $config->getBasePath(), $relativePath);
|
|
|
|
try {
|
|
$infos = $share->dir($full);
|
|
} catch (Throwable $e) {
|
|
throw new ShareConnectionException($e->getMessage(), 0, $e);
|
|
}
|
|
|
|
$entries = array_map(fn (IFileInfo $i): FileEntry => $this->toEntry($i, $relativePath), $infos);
|
|
|
|
$this->sortEntries($entries);
|
|
|
|
return $entries;
|
|
}
|
|
|
|
public function search(string $query, int $limit = 200): array
|
|
{
|
|
$needle = trim($query);
|
|
|
|
if ('' === $needle) {
|
|
return [];
|
|
}
|
|
|
|
$config = $this->requireUsableConfig();
|
|
$share = $this->connect($config);
|
|
$base = (string) $config->getBasePath();
|
|
|
|
$results = [];
|
|
$queue = ['']; // chemins relatifs des dossiers à explorer, racine en premier (parcours en largeur)
|
|
$visitedDirs = 0;
|
|
|
|
while ([] !== $queue && count($results) < $limit && $visitedDirs < self::SEARCH_MAX_DIRS) {
|
|
$relative = array_shift($queue);
|
|
$full = $this->pathResolver->fullPath($base, $relative);
|
|
|
|
try {
|
|
$infos = $share->dir($full);
|
|
} catch (Throwable) {
|
|
continue; // dossier illisible (droits, lien mort…) : on l'ignore et on poursuit
|
|
}
|
|
++$visitedDirs;
|
|
|
|
foreach ($infos as $info) {
|
|
$entry = $this->toEntry($info, $relative);
|
|
|
|
if ($entry->isDir) {
|
|
$queue[] = $entry->path;
|
|
}
|
|
|
|
if (false !== mb_stripos($entry->name, $needle)) {
|
|
$results[] = $entry;
|
|
|
|
if (count($results) >= $limit) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->sortEntries($results);
|
|
|
|
return $results;
|
|
}
|
|
|
|
public function read(string $relativePath)
|
|
{
|
|
$config = $this->requireUsableConfig();
|
|
$share = $this->connect($config);
|
|
$full = $this->pathResolver->fullPath((string) $config->getBasePath(), $relativePath);
|
|
|
|
try {
|
|
return $share->read($full);
|
|
} catch (Throwable $e) {
|
|
throw new ShareConnectionException($e->getMessage(), 0, $e);
|
|
}
|
|
}
|
|
|
|
public function test(): ShareTestResult
|
|
{
|
|
try {
|
|
$config = $this->requireUsableConfig();
|
|
$share = $this->connect($config);
|
|
$share->dir($this->pathResolver->fullPath((string) $config->getBasePath(), ''));
|
|
|
|
return new ShareTestResult(true);
|
|
} catch (ShareNotConfiguredException $e) {
|
|
return new ShareTestResult(false, 'Configuration incomplète ou désactivée.');
|
|
} catch (Throwable $e) {
|
|
return new ShareTestResult(false, $e->getMessage());
|
|
}
|
|
}
|
|
|
|
private function requireUsableConfig(): ShareConfiguration
|
|
{
|
|
$config = $this->configRepository->findSingleton();
|
|
|
|
if (null === $config || !$config->isUsable()) {
|
|
throw new ShareNotConfiguredException('Share is not configured or disabled.');
|
|
}
|
|
|
|
return $config;
|
|
}
|
|
|
|
private function connect(ShareConfiguration $config): IShare
|
|
{
|
|
$password = null !== $config->getEncryptedPassword()
|
|
? $this->tokenEncryptor->decrypt($config->getEncryptedPassword())
|
|
: '';
|
|
|
|
$auth = new BasicAuth(
|
|
(string) $config->getUsername(),
|
|
$config->getDomain() ?: 'WORKGROUP',
|
|
$password,
|
|
);
|
|
$server = new ServerFactory()->createServer((string) $config->getHost(), $auth);
|
|
|
|
try {
|
|
return $server->getShare((string) $config->getShareName());
|
|
} catch (Throwable $e) {
|
|
throw new ShareConnectionException($e->getMessage(), 0, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trie en place : dossiers d'abord, puis tri alphabétique insensible à la casse.
|
|
*
|
|
* @param FileEntry[] $entries
|
|
*/
|
|
private function sortEntries(array &$entries): void
|
|
{
|
|
usort($entries, static function (FileEntry $a, FileEntry $b): int {
|
|
if ($a->isDir !== $b->isDir) {
|
|
return $a->isDir ? -1 : 1;
|
|
}
|
|
|
|
return strcasecmp($a->name, $b->name);
|
|
});
|
|
}
|
|
|
|
private function toEntry(IFileInfo $info, string $parentRelative): FileEntry
|
|
{
|
|
$parent = '' === $parentRelative ? '' : rtrim($parentRelative, '/').'/';
|
|
$path = $parent.$info->getName();
|
|
$isDir = $info->isDirectory();
|
|
|
|
$mime = 'application/octet-stream';
|
|
if (!$isDir) {
|
|
$guessed = MimeTypes::getDefault()->getMimeTypes(pathinfo($info->getName(), PATHINFO_EXTENSION));
|
|
$mime = $guessed[0] ?? 'application/octet-stream';
|
|
}
|
|
|
|
return new FileEntry(
|
|
name: $info->getName(),
|
|
path: $path,
|
|
isDir: $isDir,
|
|
size: $isDir ? 0 : $info->getSize(),
|
|
modifiedAt: $info->getMTime(),
|
|
mimeType: $mime,
|
|
);
|
|
}
|
|
}
|