From 73f384aee0cfcbc00f9a6305c704092686969dbd Mon Sep 17 00:00:00 2001 From: Michael Ahlers <58474751+ahlers2mi@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:30:22 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20v1.5.0=20=E2=80=94=20Ordner=20anleg?= =?UTF-8?q?en=20und=20Navigation=20in=20Unterverzeichnisse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FHEM/98_FileManager.pm | 391 ++++++++++++++++++++++++++++----------- controls_FileManager.txt | 2 +- 2 files changed, 289 insertions(+), 104 deletions(-) diff --git a/FHEM/98_FileManager.pm b/FHEM/98_FileManager.pm index b90a1ad..594f26d 100644 --- a/FHEM/98_FileManager.pm +++ b/FHEM/98_FileManager.pm @@ -1,8 +1,9 @@ # $Id: 98_FileManager.pm -# Version: 1.4.0 +# Version: 1.5.0 # FHEM-Modul: Browser-basierter Dateimanager (Upload & Download über FHEMWEB) # # Changelog: +# 1.5.0 - Feature: Ordner anlegen und in Unterverzeichnisse navigieren # 1.4.0 - Fix: POST-Body steckt in $arg, Methode aus Request-Line ermitteln # 1.3.0 - Debug-Version # 1.2.0 - Fix: FHEMWEB speichert Upload in FORM{file} + FORM{"file.name"} @@ -25,7 +26,7 @@ use POSIX qw(strftime); use vars qw($FW_ME $FW_CSRF $FW_wname $FW_chash); use vars qw(%FW_webArgs @FW_httpheader %FW_httpheader); -my $FileManager_Version = '1.4.0'; +my $FileManager_Version = '1.5.0'; # ------------------------------------------------------------------ sub FileManager_toUTF8 { @@ -36,6 +37,16 @@ sub FileManager_toUTF8 { return $@ ? decode('latin-1', $str) : $dec; } +# ------------------------------------------------------------------ +# Bereinigt einen relativen Pfad – verhindert Path-Traversal +# ------------------------------------------------------------------ +sub FileManager_SanitizePath { + my ($p) = @_; + return '' unless defined $p && $p ne ''; + my @parts = grep { $_ ne '' && $_ ne '.' && $_ ne '..' } split m{/}, $p; + return join('/', @parts); +} + # ------------------------------------------------------------------ sub FileManager_Initialize { my ($hash) = @_; @@ -110,10 +121,37 @@ sub FileManager_Get { return "Unbekannter Befehl: $cmd"; } +# ------------------------------------------------------------------ +# Hilfsfunktion: URL-kodiert einen Pfad (Segmente einzeln kodieren) +# ------------------------------------------------------------------ +sub FileManager_EncodeURIPath { + my ($path) = @_; + my @segs = map { + my $s = $_; + $s =~ s/([^A-Za-z0-9\-_.~])/sprintf('%%%02X', ord($1))/ge; + $s; + } split m{/}, $path; + return join('/', @segs); +} + +# ------------------------------------------------------------------ +# Parst einen application/x-www-form-urlencoded Body +# ------------------------------------------------------------------ +sub FileManager_ParseFormBody { + my ($body) = @_; + my %p; + for my $pair (split /[&;]/, $body) { + my ($k, $v) = split /=/, $pair, 2; + next unless defined $k && $k ne ''; + $v //= ''; + for ($k, $v) { s/\+/ /g; s/%([0-9A-Fa-f]{2})/chr(hex($1))/ge; } + $p{$k} = $v; + } + return %p; +} + # ------------------------------------------------------------------ # FHEMWEB übergibt: $arg = URL-Pfad + \n + POST-Body (alles zusammen) -# Die HTTP-Methode steht als Key in %FW_httpheader z.B.: -# "POST /fhem/FileManager/myFM HTTP/1.1" => "" # ------------------------------------------------------------------ sub FileManager_WebHandler { my ($arg) = @_; @@ -153,19 +191,32 @@ sub FileManager_WebHandler { return ('text/plain; charset=utf-8', 'Modul deaktiviert') if IsDisabled($devName); + # ---- Aktueller Unterpfad aus Query-String (%FW_webArgs) ------- + my $subPath = FileManager_SanitizePath($FW_webArgs{path} // ''); + # ---- DOWNLOAD -------------------------------------------------- if ($urlPart =~ m{/download/(.+)$}) { return FileManager_HandleDownload($hash, $devName, $dir, $1); } - # ---- UPLOAD (POST) -------------------------------------------- + # ---- POST: Ordner anlegen oder Upload -------------------------- if ($method eq 'POST') { - return FileManager_HandleUpload($hash, $devName, $dir, $arg); + my $ct = ''; + for my $hk (keys %FW_httpheader) { + if (lc($hk) eq 'content-type') { $ct = $FW_httpheader{$hk}; last; } + } + + if ($ct =~ /multipart\/form-data/i) { + return FileManager_HandleUpload($hash, $devName, $dir, $arg, $subPath); + } else { + # application/x-www-form-urlencoded → Ordner anlegen + return FileManager_HandleMkdir($hash, $devName, $dir, $postBody, $subPath); + } } # ---- Hauptseite (GET) ----------------------------------------- return ('text/html; charset=utf-8', - FileManager_RenderPage($hash, $devName, $dir)); + FileManager_RenderPage($hash, $devName, $dir, $subPath)); } # ------------------------------------------------------------------ @@ -173,10 +224,11 @@ sub FileManager_HandleDownload { my ($hash, $name, $dir, $rawFile) = @_; $rawFile =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/ge; - my $file = FileManager_toUTF8(basename($rawFile)); - my $fullPath = "$dir/$file"; + # Sicherstellen dass kein Path-Traversal möglich ist + my $safeRel = FileManager_SanitizePath($rawFile); + my $fullPath = "$dir/$safeRel"; - return ('text/plain; charset=utf-8', "Datei nicht gefunden: $file") + return ('text/plain; charset=utf-8', "Datei nicht gefunden: $safeRel") unless -f $fullPath; open(my $fh, '<:raw', $fullPath) @@ -185,7 +237,8 @@ sub FileManager_HandleDownload { my $content = <$fh>; close $fh; - my $ext = lc($file =~ /\.([^.]+)$/ ? $1 : ''); + my $fileName = basename($safeRel); + my $ext = lc($fileName =~ /\.([^.]+)$/ ? $1 : ''); my %mimes = ( jpg => 'image/jpeg', jpeg => 'image/jpeg', png => 'image/png', gif => 'image/gif', pdf => 'application/pdf', @@ -197,31 +250,62 @@ sub FileManager_HandleDownload { if ($FW_chash) { $FW_chash->{extraHeaders} //= ''; $FW_chash->{extraHeaders} .= - "Content-Disposition: attachment; filename=\"$file\"\r\n"; + "Content-Disposition: attachment; filename=\"$fileName\"\r\n"; } readingsBeginUpdate($hash); - readingsBulkUpdate($hash, 'lastDownloadFile', $file); + readingsBulkUpdate($hash, 'lastDownloadFile', $safeRel); readingsBulkUpdate($hash, 'lastDownloadTime', strftime('%Y-%m-%d %H:%M:%S', localtime)); readingsEndUpdate($hash, 1); - Log3($name, 3, "FileManager ($name): Download: $file"); + Log3($name, 3, "FileManager ($name): Download: $safeRel"); return ($mime, $content); } # ------------------------------------------------------------------ -# POST-Body steckt komplett in $arg (URL + \n + Body zusammen) +# Ordner anlegen (application/x-www-form-urlencoded POST) +# ------------------------------------------------------------------ +sub FileManager_HandleMkdir { + my ($hash, $name, $dir, $postBody, $subPath) = @_; + + my %form = FileManager_ParseFormBody($postBody); + my $subPathForm = FileManager_SanitizePath($form{path} // $subPath); + my $dirName = FileManager_SanitizePath($form{dirname} // ''); + + my $currentDir = $subPathForm ? "$dir/$subPathForm" : $dir; + + unless ($dirName && $dirName !~ m{/}) { + return ('text/html; charset=utf-8', + FileManager_RenderPage($hash, $name, $dir, $subPathForm, + 'Ungültiger Ordnername.')); + } + + my $newDir = "$currentDir/$dirName"; + if (-d $newDir) { + return ('text/html; charset=utf-8', + FileManager_RenderPage($hash, $name, $dir, $subPathForm, + "Ordner '$dirName' existiert bereits.")); + } + + make_path($newDir) + or return ('text/html; charset=utf-8', + FileManager_RenderPage($hash, $name, $dir, $subPathForm, + "Ordner konnte nicht erstellt werden: $!")); + + Log3($name, 3, "FileManager ($name): Ordner erstellt: $newDir"); + return ('text/html; charset=utf-8', + FileManager_RenderPage($hash, $name, $dir, $subPathForm, + undef, "✓ Ordner '$dirName' erstellt.")); +} + # ------------------------------------------------------------------ sub FileManager_HandleUpload { - my ($hash, $name, $dir, $arg) = @_; + my ($hash, $name, $dir, $arg, $subPath) = @_; my $ct = ''; for my $hk (keys %FW_httpheader) { - if (lc($hk) eq 'content-type') { - $ct = $FW_httpheader{$hk}; - last; - } + if (lc($hk) eq 'content-type') { $ct = $FW_httpheader{$hk}; last; } } my ($boundary) = $ct =~ /boundary=["']?([^"';\s\r\n]+)/i; @@ -229,33 +313,43 @@ sub FileManager_HandleUpload { unless ($boundary) { Log3($name, 2, "FileManager ($name): Kein Boundary in Content-Type: '$ct'"); return ('text/html; charset=utf-8', - FileManager_RenderPage($hash, $name, $dir, + FileManager_RenderPage($hash, $name, $dir, $subPath, "Upload-Fehler: Kein Multipart-Boundary gefunden.")); } - Log3($name, 3, "FileManager ($name): Upload boundary='$boundary'"); + my ($fileName, $fileData, $pathField) = FileManager_ParseMultipart($arg, $boundary); + # Pfad aus Formfeld hat Vorrang vor Query-String + $subPath = FileManager_SanitizePath($pathField) if defined $pathField && $pathField ne ''; - my ($fileName, $fileData) = FileManager_ParseMultipart($arg, $boundary); + my $currentDir = $subPath ? "$dir/$subPath" : $dir; unless (defined $fileName && $fileName ne '' && defined $fileData && length($fileData) > 0) { - Log3($name, 2, "FileManager ($name): Multipart-Parse lieferte nichts. " - . "fileName='" . ($fileName // 'undef') . "' " - . "dataLen=" . length($fileData // '')); + Log3($name, 2, "FileManager ($name): Multipart-Parse lieferte nichts."); return ('text/html; charset=utf-8', - FileManager_RenderPage($hash, $name, $dir, + FileManager_RenderPage($hash, $name, $dir, $subPath, 'Upload-Fehler: Datei konnte nicht gelesen werden.')); } - return FileManager_SaveFile($hash, $name, $dir, $fileName, $fileData); + return FileManager_SaveFile($hash, $name, $dir, $currentDir, $subPath, $fileName, $fileData); } # ------------------------------------------------------------------ sub FileManager_ParseMultipart { my ($body, $boundary) = @_; - my ($fileName, $fileData) = ('', undef); + my ($fileName, $fileData, $pathField) = ('', undef, ''); my @parts = split(/--\Q$boundary\E/, $body); for my $part (@parts) { + # Pfad-Formfeld (kein filename) + if ($part =~ /name="path"/i && $part !~ /filename=/i) { + if ($part =~ /\r\n\r\n(.*)/s) { + $pathField = $1; + $pathField =~ s/\r?\n.*//s; + $pathField =~ s/\s+$//; + } + next; + } + next unless $part =~ /filename="([^"]+)"/i; $fileName = $1; @@ -272,12 +366,12 @@ sub FileManager_ParseMultipart { } last; } - return ($fileName, $fileData); + return ($fileName, $fileData, $pathField); } # ------------------------------------------------------------------ sub FileManager_SaveFile { - my ($hash, $name, $dir, $fileName, $fileData) = @_; + my ($hash, $name, $dir, $currentDir, $subPath, $fileName, $fileData) = @_; $fileName = FileManager_toUTF8(basename($fileName)); $fileName =~ s/[^\w\s.\-äöüÄÖÜß]/_/g; @@ -288,68 +382,122 @@ sub FileManager_SaveFile { my @exts = split /,/, $allowed; unless (grep { lc($_) eq $ext } @exts) { return ('text/html; charset=utf-8', - FileManager_RenderPage($hash, $name, $dir, + FileManager_RenderPage($hash, $name, $dir, $subPath, "Dateiendung '$ext' nicht erlaubt.")); } } - my $targetPath = "$dir/$fileName"; + make_path($currentDir) unless -d $currentDir; + + my $targetPath = "$currentDir/$fileName"; open(my $fh, '>:raw', $targetPath) or return ('text/html; charset=utf-8', - FileManager_RenderPage($hash, $name, $dir, + FileManager_RenderPage($hash, $name, $dir, $subPath, "Schreibfehler: $!")); print $fh $fileData; close $fh; my $size = -s $targetPath; + my $displayPath = $subPath ? "$subPath/$fileName" : $fileName; readingsBeginUpdate($hash); - readingsBulkUpdate($hash, 'lastUploadFile', $fileName); + readingsBulkUpdate($hash, 'lastUploadFile', $displayPath); readingsBulkUpdate($hash, 'lastUploadTime', strftime('%Y-%m-%d %H:%M:%S', localtime)); readingsBulkUpdate($hash, 'lastUploadSize', "$size Bytes"); readingsEndUpdate($hash, 1); - Log3($name, 3, "FileManager ($name): Upload OK: $fileName ($size Bytes)"); + Log3($name, 3, "FileManager ($name): Upload OK: $displayPath ($size Bytes)"); return ('text/html; charset=utf-8', - FileManager_RenderPage($hash, $name, $dir, + FileManager_RenderPage($hash, $name, $dir, $subPath, undef, "✓ '$fileName' hochgeladen ($size Bytes).")); } # ------------------------------------------------------------------ sub FileManager_RenderPage { - my ($hash, $name, $dir, $error, $success) = @_; + my ($hash, $name, $dir, $subPath, $error, $success) = @_; + $subPath //= ''; - opendir(my $dh, $dir) or return "
Fehler: $!"; - my @files = sort grep { !/^\./ && -f "$dir/$_" } readdir($dh); + my $currentDir = $subPath ? "$dir/$subPath" : $dir; + + opendir(my $dh, $currentDir) + or return "Verzeichnis nicht gefunden: $currentDir"; + my @entries = sort grep { !/^\./ } readdir($dh); closedir $dh; + my @dirs = grep { -d "$currentDir/$_" } @entries; + my @files = grep { -f "$currentDir/$_" } @entries; + my $me = $FW_ME // '/fhem'; my $baseUrl = "$me/FileManager/$name"; my $csrfInput = ''; + my $csrfQuery = ''; if ($FW_wname && $defs{$FW_wname} && $defs{$FW_wname}{CSRFTOKEN}) { my $token = $defs{$FW_wname}{CSRFTOKEN}; $csrfInput = qq{}; + $csrfQuery = "fwcsrf=$token&"; + } + + # ---- Breadcrumb ----------------------------------------------- + my $breadcrumb = qq{🏠 $name}; + if ($subPath) { + my @segs = split m{/}, $subPath; + my $built = ''; + for my $seg (@segs) { + $built = $built ? "$built/$seg" : $seg; + my $encBuilt = FileManager_EncodeURIPath($built); + $breadcrumb .= qq{ › $seg}; + } } + # ---- "Hoch"-Link ---------------------------------------------- + my $upLink = ''; + if ($subPath) { + my $parentPath = $subPath =~ m{^(.+)/[^/]+$} ? $1 : ''; + my $encParent = FileManager_EncodeURIPath($parentPath); + my $upHref = $parentPath + ? "$baseUrl?${csrfQuery}path=$encParent" + : $baseUrl; + $upLink = qq{↑ Eine Ebene höher}; + } + + # ---- Verzeichnis-Zeilen --------------------------------------- my $rows = ''; - if (@files) { - for my $f (@files) { - my $fUtf = FileManager_toUTF8($f); - my $size = -s "$dir/$f"; - my $mtime = strftime('%Y-%m-%d %H:%M', - localtime((stat("$dir/$f"))[9])); - my $sizeStr = $size >= 1_048_576 - ? sprintf('%.1f MB', $size / 1_048_576) - : $size >= 1_024 - ? sprintf('%.1f KB', $size / 1_024) - : "$size B"; - my $encF = $fUtf; - $encF =~ s/([^A-Za-z0-9\-_.])/sprintf('%%%02X', ord($1))/ge; - - $rows .= <<"END_ROW"; + for my $d (@dirs) { + my $dUtf = FileManager_toUTF8($d); + my $relPath = $subPath ? "$subPath/$d" : $d; + my $encP = FileManager_EncodeURIPath($relPath); + my $mtime = strftime('%Y-%m-%d %H:%M', + localtime((stat("$currentDir/$d"))[9])); + $rows .= <<"END_ROW"; +Verzeichnis: $dir
Wurzel: $dir
| Dateiname | +Name | Größe | Geändert | Aktion | @@ -450,8 +635,8 @@ END_HTML =pod =item device -=item summary Browser-based file manager for FHEM (upload & download via web UI) -=item summary_DE Browser-Dateimanager: Dateien per Webbrowser hoch- und runterladen +=item summary Browser-based file manager for FHEM (upload, download, folders) +=item summary_DE Browser-Dateimanager: Dateien und Ordner per Webbrowser verwalten =begin html diff --git a/controls_FileManager.txt b/controls_FileManager.txt index fd5825e..b060b26 100644 --- a/controls_FileManager.txt +++ b/controls_FileManager.txt @@ -1 +1 @@ -UPD 2026-06-01_11:06:39 16453 FHEM/98_FileManager.pm +UPD 2026-06-01_00:00:00 0 FHEM/98_FileManager.pm From 354da2ec40ebe469d9830430781aa36c0597e737 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]"
|---|