", nur das muss getauscht werden. Anschließend alle Dateien löschen und den Scan nochmals ganz von vorne machen - also alles neu im Adminbereich. Das Verzeichnis "assets" muss nach wie vor angehakt sein - nur wird es jetzt in allen Projekten ausgeschlossen.
Code: Alles auswählen
<?php
declare(strict_types=1);
require __DIR__ . '/auth.php';
vnSearchRequireLogin();
require __DIR__ . '/../db.php';
require __DIR__ . '/../html-helper.php';
header('Content-Type: application/json; charset=utf-8');
function vnRespondScan(bool $success, string $message, array $extra = []): void
{
http_response_code($success ? 200 : 400);
echo json_encode(array_merge(['success' => $success, 'message' => $message], $extra), JSON_UNESCAPED_UNICODE);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !vnSearchCheckCsrf()) {
vnRespondScan(false, 'Ungültige Anfrage.');
}
$pdo = vnSearchDb();
// Fehler/Warnungen ab hier sammeln (z.B. open_basedir-Einschraenkungen) -
// muss VOR der Root-Aufloesung installiert werden, da genau dort (beim
// Versuch, einen ueberschriebenen Root ausserhalb des erlaubten Bereichs
// aufzuloesen) solche Warnungen typischerweise zuerst auftreten.
$accessWarnings = [];
set_error_handler(function ($errno, $errstr) use (&$accessWarnings) {
$accessWarnings[] = $errstr;
return true;
});
$folderRaw = trim((string)($_POST['folder'] ?? ''));
// Leer = kompletter Server (ab Webroot). Sonst: ab diesem Unterordner,
// jetzt IMMER rekursiv (auch Unterordner des Unterordners werden erfasst).
// Zusaetzliche Dateiendungen (kommagetrennt), z.B. "mbrext, zip" - PDF ist
// immer automatisch mit dabei. Fuer Nicht-PDF-Dateien wird nur der Dateiname
// durchsuchbar gemacht (kein Textinhalt moeglich bei z.B. ZIP-Dateien).
// HARTE Sperrliste: diese Endungen werden NIEMALS erfasst, egal was der
// Admin bei "Zusaetzliche Dateitypen" eintraegt - verhindert, dass aus
// Versehen sensible Dateien (Zugangsdaten, Datenbanken, Zertifikate)
// durchsuchbar/auffindbar gemacht werden.
$hardBlocklist = [
'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'pht', 'phar',
'db', 'sqlite', 'sqlite3', 'sql', 'env', 'ini', 'key', 'pem', 'crt',
'cer', 'pfx', 'p12', 'htaccess', 'htpasswd', 'conf', 'config', 'log',
'bak', 'backup', 'swp', 'git', 'ssh',
];
// PHP-Seiten sind ein Sonderfall: sie bleiben in der Sperrliste fuer die
// normale Datei-Erfassung (Quellcode wird NIE gelesen), koennen aber optional
// zusaetzlich gefunden und dann sicher per HTTP abgerufen werden (wie beim
// manuellen "URL hinzufuegen" - der Server liefert das fertige HTML, kein
// Quellcode-Zugriff). Nur wenn explizit angehakt.
$includePhpPages = !empty($_POST['includePhpPages']);
$extraExtRaw = trim((string)($_POST['extraExtensions'] ?? ''));
$extraExts = ['pdf', 'html', 'htm'];
if ($extraExtRaw !== '') {
foreach (explode(',', $extraExtRaw) as $e) {
$e = strtolower(trim($e, ". \t\n\r\0\x0B"));
// Nur harmlose Datei-Endungen zulassen (Buchstaben/Zahlen), keine
// Sonderzeichen die als Glob-Muster missbraucht werden koennten,
// und niemals etwas von der Sperrliste - selbst wenn explizit angefragt.
if ($e !== '' && preg_match('/^[a-z0-9]+$/', $e) && !in_array($e, $extraExts, true) && !in_array($e, $hardBlocklist, true)) {
$extraExts[] = $e;
}
}
}
// Website-Wurzel: drei Ebenen ueber assets/vn-search/admin/ liegt der Webroot
// (assets/vn-search/admin/ -> assets/vn-search/ -> assets/ -> Webroot) -
// kann vom Admin ueberschrieben werden (z.B. bei Shared-Hosting mit
// mehreren Projekten im selben Account, siehe vnSearchResolveScanRoot).
$defaultWebRoot = realpath(__DIR__ . '/../../..');
if ($defaultWebRoot === false) {
vnRespondScan(false, 'Server-Wurzelverzeichnis konnte nicht ermittelt werden.');
}
$webRoot = vnSearchResolveScanRoot($pdo, $defaultWebRoot);
// Falls der Root ausserhalb der aktuellen open_basedir-Beschraenkung liegt,
// versuchen wir sie zur Laufzeit zu erweitern - klappt nicht auf jedem
// Hosting (manche setzen das serverseitig fest, PHP_INI_SYSTEM), ist aber
// einen Versuch wert und schadet nicht, wenn es fehlschlaegt.
$currentBasedir = ini_get('open_basedir');
if ($currentBasedir !== '' && $currentBasedir !== false) {
@ini_set('open_basedir', $currentBasedir . PATH_SEPARATOR . $webRoot);
}
if ($folderRaw === '') {
$realRequestedPath = $webRoot;
} else {
// Pfad-Traversal verhindern: nur innerhalb des Webroots erlauben
$requestedPath = $webRoot . '/' . ltrim($folderRaw, '/');
$realRequestedPath = realpath($requestedPath);
if ($realRequestedPath === false || strpos($realRequestedPath, $webRoot) !== 0) {
vnRespondScan(false, 'Ordner nicht gefunden oder außerhalb des erlaubten Bereichs.');
}
if (!is_dir($realRequestedPath)) {
vnRespondScan(false, 'Das ist kein Ordner.');
}
if (vnSearchIsHtaccessProtected($realRequestedPath)) {
vnRespondScan(false, 'Dieser Ordner ist per .htaccess passwortgeschützt und wird aus Sicherheitsgründen nie direkt gelesen.');
}
}
// Ordner, die NIE mit erfasst werden sollen - eigene Datenbank/Uploads nicht
// nochmal sich selbst durchsuchen, sowie uebliche System-/versteckte Ordner.
// Der eigene Ordner wird relativ zum TATSAECHLICH genutzten Root berechnet
// (nicht fest "assets/vn-search"), da der Root ja ueberschrieben sein kann
// und damit auf einer anderen Ebene liegt.
$ownFolderAbs = realpath(__DIR__ . '/..'); // .../assets/vn-search
$ownFolderRel = $ownFolderAbs !== false ? ltrim(str_replace($webRoot, '', $ownFolderAbs), '/') : 'assets/vn-search';
$alwaysExcluded = [$ownFolderRel, '.git', 'node_modules', '.well-known'];
// Zusaetzlich vom Admin im Zahnrad/Panel ausgewaehlte Ordner (nur relevant,
// wenn der komplette Server oder ein Elternordner gescannt wird).
$userExcludedJson = vnSearchGetSetting($pdo, 'excluded_folders', '[]');
$userExcluded = json_decode((string)$userExcludedJson, true);
if (is_array($userExcluded)) {
foreach ($userExcluded as $uf) {
$uf = trim((string)$uf, '/');
if ($uf !== '') {
$alwaysExcluded[] = $uf;
}
}
}
$pdfFiles = [];
$otherFiles = [];
$htmlFiles = [];
$phpFiles = [];
// Geschuetzte Dateimuster laden (z.B. "config.php, *.key") - werden bei
// JEDER gefundenen Datei geprueft, unabhaengig vom Dateityp.
$protectedPatternsJson = vnSearchGetSetting($pdo, 'protected_patterns', '[]');
$protectedPatterns = json_decode((string)$protectedPatternsJson, true);
if (!is_array($protectedPatterns)) {
$protectedPatterns = [];
}
$protectedPatterns = array_filter(array_map('trim', $protectedPatterns));
/**
* Prueft, ob ein Ordner per .htaccess passwortgeschuetzt ist (AuthType/
* Require-Direktiven) - solche Ordner werden beim Scan NIE gelesen, da PHPs
* direkter Dateizugriff (anders als ein normaler Browser-Aufruf) den
* .htaccess-Schutz sonst komplett umgehen wuerde.
*/
function vnSearchIsHtaccessProtected(string $dirPath): bool
{
$htaccessPath = rtrim($dirPath, '/') . '/.htaccess';
if (!file_exists($htaccessPath) || !is_readable($htaccessPath)) {
return false;
}
$content = @file_get_contents($htaccessPath);
if ($content === false) {
return false;
}
return (bool)preg_match('/^\s*(AuthType|AuthUserFile|Require\s+valid-user|Require\s+user)/mi', $content);
}
/**
* Prueft, ob ein relativer Verzeichnispfad durch einen der gespeicherten
* Ordnerausschluesse erfasst wird.
*
* Ausschluesse ohne Schraegstrich, zum Beispiel "assets", gelten fuer
* jeden gleichnamigen Ordner in beliebiger Verzeichnistiefe:
*
* /assets/
* /VN-Block-Galerie/assets/
* /Mobirise-Tutorials/assets/
*
* Ausschluesse mit Schraegstrich, zum Beispiel "assets/vn-search" oder
* "Projekt/private", gelten dagegen als konkreter relativer Pfad und
* schliessen diesen Pfad samt seiner Unterordner aus.
*/
function vnSearchIsExcludedDirectory(string $relativePath, array $excludedFolders): bool
{
$relativePath = trim(str_replace('\\', '/', $relativePath), '/');
$cleanPath = preg_replace('~/+~', '/', $relativePath);
if (is_string($cleanPath)) {
$relativePath = $cleanPath;
}
$pathParts = preg_split(
'~/+~',
$relativePath,
-1,
PREG_SPLIT_NO_EMPTY
);
if (!is_array($pathParts)) {
$pathParts = [];
}
foreach ($excludedFolders as $excludedFolder) {
$excludedFolder = trim(
str_replace('\\', '/', (string)$excludedFolder),
'/'
);
if ($excludedFolder === '') {
continue;
}
$cleanExcluded = preg_replace('~/+~', '/', $excludedFolder);
if (is_string($cleanExcluded)) {
$excludedFolder = $cleanExcluded;
}
/*
* Ein einfacher Ordnername gilt ueberall im Pfad.
*/
if (!str_contains($excludedFolder, '/')) {
foreach ($pathParts as $pathPart) {
if (strcasecmp($pathPart, $excludedFolder) === 0) {
return true;
}
}
continue;
}
/*
* Ein konkreter relativer Pfad gilt fuer genau diesen Ordner und
* alle darunterliegenden Verzeichnisse.
*/
if (
strcasecmp($relativePath, $excludedFolder) === 0
|| strncasecmp(
$relativePath,
$excludedFolder . '/',
strlen($excludedFolder) + 1
) === 0
) {
return true;
}
}
return false;
}
try {
$dirIterator = new RecursiveDirectoryIterator($realRequestedPath, FilesystemIterator::SKIP_DOTS);
$filterIterator = new RecursiveCallbackFilterIterator($dirIterator, function ($current, $key, $iterator) use ($webRoot, $alwaysExcluded) {
if ($current->isDir()) {
$relPath = ltrim(str_replace($webRoot, '', $current->getPathname()), '/\\');
$baseName = $current->getBasename();
// versteckte Ordner grundsaetzlich nicht betreten
if ($baseName !== '' && $baseName[0] === '.') {
return false;
}
// Vom Administrator ausgewaehlte einfache Ordnernamen wie
// "assets" werden jetzt in JEDER Verzeichnistiefe erkannt.
// Konkrete Pfade wie "assets/vn-search" bleiben ebenfalls
// moeglich und gelten fuer diesen Pfad samt Unterordnern.
if (vnSearchIsExcludedDirectory($relPath, $alwaysExcluded)) {
return false;
}
// Passwortgeschuetzte Ordner (.htaccess) nie betreten - PHPs
// direkter Dateizugriff wuerde den Passwortschutz sonst umgehen.
if (vnSearchIsHtaccessProtected($current->getPathname())) {
return false;
}
}
return true;
});
$iterator = new RecursiveIteratorIterator($filterIterator, RecursiveIteratorIterator::LEAVES_ONLY);
$iterator->setMaxDepth(-1);
} catch (\Throwable $e) {
restore_error_handler();
vnRespondScan(false, 'Ordner konnte nicht gelesen werden: ' . $e->getMessage());
}
foreach ($iterator as $item) {
try {
if (!$item->isFile()) {
continue;
}
} catch (\Throwable $e) {
// Einzelner Eintrag nicht lesbar (z.B. kaputter Symlink, Rechteproblem) -
// ueberspringen statt den kompletten Scan abzubrechen.
$accessWarnings[] = $e->getMessage();
continue;
}
$absPath = $item->getPathname();
$ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));
if (vnSearchIsProtected($absPath, $webRoot, $protectedPatterns)) {
continue;
}
if ($includePhpPages && $ext === 'php') {
$phpFiles[] = $absPath;
continue;
}
if (!in_array($ext, $extraExts, true)) {
continue;
}
if ($ext === 'pdf') {
$pdfFiles[] = $absPath;
} elseif ($ext === 'html' || $ext === 'htm') {
$htmlFiles[] = $absPath;
} else {
$otherFiles[] = $absPath;
}
}
restore_error_handler();
if (empty($pdfFiles) && empty($otherFiles) && empty($htmlFiles) && empty($phpFiles)) {
$msg = 'Keine passenden Dateien gefunden.';
if (!empty($accessWarnings)) {
// Vermutlich open_basedir-Einschraenkung oder Rechteproblem - das
// ist auf manchem Shared-Hosting (z.B. IONOS) so konfiguriert, dass
// PHP nur innerhalb eines bestimmten Ordners lesen darf, auch wenn
// der Pfad selbst (realpath) noch aufgeloest werden kann.
$msg .= ' Möglicher Grund: Zugriffsbeschränkung deines Servers (z.B. "open_basedir") verhindert das Lesen außerhalb deines Website-Ordners. Technische Details: ' . implode(' | ', array_slice(array_unique($accessWarnings), 0, 3));
}
vnRespondScan(true, $msg, ['found' => 0, 'new' => 0, 'accessWarnings' => array_values(array_unique($accessWarnings))]);
}
$existing = $pdo->query('SELECT stored_filename FROM pdf_files')->fetchAll(PDO::FETCH_COLUMN);
$existingSet = array_flip($existing);
$newCount = 0;
// --- PDFs: wie bisher zur spaeteren Text-Indexierung vormerken ---
$insPdf = $pdo->prepare('INSERT INTO pdf_files (original_name, stored_filename, filesize, source, file_ext, indexed_at) VALUES (:o, :s, :f, "scanned", "pdf", NULL)');
foreach ($pdfFiles as $filePath) {
$relativePath = ltrim(str_replace($webRoot, '', $filePath), '/');
if (isset($existingSet[$relativePath])) {
continue;
}
$insPdf->execute(['o' => basename($filePath), 's' => $relativePath, 'f' => (int)filesize($filePath)]);
$newCount++;
}
// --- Andere Dateitypen: kein Textinhalt moeglich, direkt anhand des
// Dateinamens indexieren (kein Umweg ueber die Batch-Verarbeitung noetig) ---
$insOther = $pdo->prepare('INSERT INTO pdf_files (original_name, stored_filename, filesize, source, file_ext, indexed_at, fts_rowid) VALUES (:o, :s, :f, "scanned", :ext, :ia, :fr)');
foreach ($otherFiles as $filePath) {
$relativePath = ltrim(str_replace($webRoot, '', $filePath), '/');
if (isset($existingSet[$relativePath])) {
continue;
}
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$baseName = pathinfo($filePath, PATHINFO_FILENAME);
// Durchsuchbarer "Inhalt" ist hier einfach der (lesbarer gemachte) Dateiname -
// z.B. "vn-formrender-v80" statt nur "vn-formrender-v80.mbrext"
$searchableContent = str_replace(['-', '_'], ' ', $baseName) . ' ' . $ext;
$ftsRowid = vnSearchIndexUpsert($pdo, null, basename($filePath), $searchableContent, '/' . $relativePath, 'file');
$insOther->execute([
'o' => basename($filePath),
's' => $relativePath,
'f' => (int)filesize($filePath),
'ext' => $ext,
'ia' => time(),
'fr' => $ftsRowid,
]);
$newCount++;
}
$totalFound = count($pdfFiles) + count($otherFiles) + count($htmlFiles) + count($phpFiles);
// --- HTML-Dateien: echte Textextraktion (Header/Footer/Nav werden
// rausgefiltert), landen bei "Seiten" statt bei den PDF-Dateien, da es
// sich ja um echte Website-Seiten handelt (z.B. andere Projekte in
// Unterordnern ohne eigene Sitemap-Anbindung). Da lokal von der
// Festplatte gelesen wird (kein HTTP-Request noetig), passiert das
// sofort, ohne Umweg ueber die Batch-Verarbeitung. ---
$existingPages = $pdo->query('SELECT url FROM indexed_pages')->fetchAll(PDO::FETCH_COLUMN);
$existingPagesSet = [];
foreach ($existingPages as $existingUrl) {
$existingPagesSet[vnSearchNormalizeUrl($existingUrl)] = true;
}
$insPage = $pdo->prepare('INSERT INTO indexed_pages (url, title, indexed_at, fts_rowid) VALUES (:u, :t, :ia, :fr)');
foreach ($htmlFiles as $filePath) {
$relativePath = ltrim(str_replace($webRoot, '', $filePath), '/');
// Als URL-Pfad verwenden wir den relativen Server-Pfad mit fuehrendem
// Slash - funktioniert fuer den Link im Suchergebnis genauso wie eine
// ganz normale Seiten-URL.
$urlPath = '/' . $relativePath;
$normalizedUrlPath = vnSearchNormalizeUrl($urlPath);
if (isset($existingPagesSet[$normalizedUrlPath])) {
continue;
}
$html = @file_get_contents($filePath);
if ($html === false) {
continue;
}
[$title, $text] = vnSearchExtractPageContent($html);
if ($title === '') {
$title = basename($filePath);
}
$ftsRowid = vnSearchIndexUpsert($pdo, null, $title, $text, $urlPath, 'page');
$insPage->execute(['u' => $urlPath, 't' => $title, 'ia' => time(), 'fr' => $ftsRowid]);
$existingPagesSet[$normalizedUrlPath] = true;
$newCount++;
}
// --- PHP-Seiten: werden NIE direkt von der Festplatte gelesen (Sicherheit -
// koennten Zugangsdaten im Code enthalten). Stattdessen genau wie beim
// manuellen "URL hinzufuegen" nur vorgemerkt und dann per HTTP abgerufen
// (macht die bereits vorhandene Seiten-Batch-Verarbeitung) - das kann
// etwas dauern, deshalb hier nur die Warteliste befuellen statt sofort
// abzurufen. ---
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$ownHost = $_SERVER['HTTP_HOST'] ?? '';
$phpQueued = 0;
foreach ($phpFiles as $filePath) {
$relativePath = ltrim(str_replace($webRoot, '', $filePath), '/');
$fullUrl = $scheme . '://' . $ownHost . '/' . $relativePath;
$normalizedFullUrl = vnSearchNormalizeUrl($fullUrl);
if (isset($existingPagesSet[$normalizedFullUrl])) {
continue;
}
$insPage->execute(['u' => $fullUrl, 't' => null, 'ia' => null, 'fr' => null]);
$existingPagesSet[$normalizedFullUrl] = true;
$phpQueued++;
$newCount++;
}
$msg = $newCount . ' neue Datei(en) gefunden (PDFs werden noch nach Text durchsucht, HTML-Seiten sind sofort mit echtem Inhalt durchsuchbar, andere Dateien nur per Dateiname).';
if ($phpQueued > 0) {
$msg .= ' ' . $phpQueued . ' PHP-Seite(n) werden im Hintergrund per HTTP abgerufen und durchsuchbar gemacht.';
}
vnRespondScan(true, $msg, [
'found' => $totalFound,
'new' => $newCount,
'phpQueued' => $phpQueued,
]);
Die Menüleiste wird nach wie vor durchsucht und mit einbezogen - aber ist egal - vielleicht sogar ganz gut, nur weil Du behauptet hast, dass die Menüleiste und der Footer ausgeschlossen sind.