From d39052c1ea7710585e0e24a2fd1125c2eb54caf8 Mon Sep 17 00:00:00 2001
From: Server-Bibliothek <server-bibliothek@lokal>
Date: Do, 23 Jul 2026 14:53:39 +0200
Subject: [PATCH] Installer-Dateien direkt ins Einrichtungs-Paket buendeln

---
 app.py                    |   98 +++++++++++++++---------
 ARCHITEKTUR.md            |   43 ++++++++--
 CHANGELOG.md              |   20 +++++
 templates/einrichten.html |   24 +++--
 README.md                 |   29 +++++++
 5 files changed, 154 insertions(+), 60 deletions(-)

diff --git a/ARCHITEKTUR.md b/ARCHITEKTUR.md
index 3d69a05..e90bc6b 100644
--- a/ARCHITEKTUR.md
+++ b/ARCHITEKTUR.md
@@ -146,32 +146,53 @@
 ## 6. "Rechner einrichten" – Verteilungs-Mechanismus
 
 Route `/einrichten` (app.py) erzeugt bei Bedarf ein individuelles
-Installations-Paket (ZIP mit `einrichten.ps1` + `Rechner-einrichten-starten.bat`),
+Installations-Paket (ZIP mit `einrichten.ps1` + `Rechner-einrichten-starten.bat`
+**+ den echten Installer-Dateien/ZIPs selbst** unter `tools/<id>.<endung>`),
 statt vorab für jede Mitarbeiter/Tool-Kombination ein festes Skript zu pflegen.
 
+**Wichtige Design-Entscheidung (Runde r16):** Die Installer-Dateien werden
+bereits beim Erzeugen des Pakets (auf dem PC, der die Checkliste ausfüllt)
+eingebettet, nicht erst beim Ausführen auf dem Zielrechner nachgeladen. Ein
+früherer Ansatz lud die Dateien per `Invoke-WebRequest` zur Laufzeit vom
+Server nach – das erforderte Netzwerkzugriff des Zielrechners auf den Server
+in genau dem Moment der Einrichtung, was in der Praxis nicht immer gegeben
+ist (z. B. Rechner noch nicht ans Firmennetz angebunden). Jetzt braucht der
+Zielrechner für die eigentliche Installation **kein Netzwerk mehr** – nur die
+abschließende, optionale Meldung ans zentrale Protokoll (`/zuweisungen/neu`)
+versucht noch einen Netzwerkzugriff, scheitert dieser, bleibt trotzdem das
+lokale Protokoll (`protokoll.log`) vollständig.
+
 Ablauf beim Ausführen auf dem Zielrechner:
-1. `.bat` kopiert sich selbst + das `.ps1` nach `C:\ClientBibliothek`
+1. `.bat` kopiert den **kompletten entpackten Ordner** (inkl. `tools/`-
+   Unterordner mit den echten Installer-Dateien) nach `C:\ClientBibliothek`
+   (per `xcopy /E /I /Y`)
 2. Self-Elevation (Admin-Rechte-Anfrage) über `Start-Process -Verb RunAs`
 3. Automatische Windows-Defender-Ausnahme für `C:\ClientBibliothek`
    (`Add-MpPreference -ExclusionPath`) – ein einziger Pfad statt vieler
    Einzelausnahmen
-4. Pro ausgewähltem Tool: entweder stille Installation (Download + Ausführung
-   mit hinterlegtem Silent-Parameter, `.msi` läuft über `msiexec`) oder – bei
-   Tools ohne einzelne Installer-Datei ("Ordner-Typ", z. B. portable
-   Programme wie Blender) – Download als ZIP nach `C:\ClientBibliothek\pakete\`,
-   **bewusst ohne automatisches Entpacken** (Entscheidung in Runde r14: der
-   Zielort für portable Tools variiert, das soll eine Person bewusst
+4. Pro ausgewähltem Tool: entweder stille Installation aus der **lokal
+   mitgelieferten** Datei unter `tools/<id>.<endung>` (mit hinterlegtem
+   Silent-Parameter, `.msi` läuft über `msiexec`) oder – bei Tools ohne
+   einzelne Installer-Datei ("Ordner-Typ", z. B. portable Programme wie
+   Blender) – die mitgelieferte ZIP-Datei wird nach `C:\ClientBibliothek\pakete\`
+   kopiert, **bewusst ohne automatisches Entpacken** (Entscheidung in Runde
+   r14: der Zielort für portable Tools variiert, das soll eine Person bewusst
    entscheiden)
 5. Lokales Protokoll unter `C:\ClientBibliothek\protokoll.log`
-6. Automatischer POST an `/zuweisungen/neu` auf dem Server, um das zentrale
+6. Optionaler POST an `/zuweisungen/neu` auf dem Server, um das zentrale
    Protokoll zu befüllen (Rechnername wird dabei über `$env:COMPUTERNAME`
-   automatisch erkannt)
+   automatisch erkannt) – best effort, kein Abbruch bei Fehlschlag
 
 Die Ordner-Struktur unter `C:\ClientBibliothek`:
-- `\downloads` – transiente Installer-Dateien, werden nach Gebrauch gelöscht
+- `\tools` – die mitgelieferten Installer-Dateien/ZIPs (Quelle für die
+  Installation, kommen direkt aus dem heruntergeladenen Paket)
 - `\pakete` – ZIPs von portablen Tools, bleiben bewusst liegen
 - `\protokoll.log` – lokale Mitschrift
 
+Serverseitig teilen sich `/einrichten` (POST) und `/software/<id>/download`
+die Hilfsfunktion `_zippe_ordner_in_bytes()` für das ZIP-Packen eines
+Ordners, um Code-Duplikation zu vermeiden.
+
 **Der Client-Ordner-Pfad ist eine Konstante** (`CLIENT_ORDNER` in `app.py`,
 aktuell `C:\ClientBibliothek`) – zentral anpassbar, falls die Infrastruktur
 einen anderen Standardpfad vorgibt.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e0b841..edf3a0c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -93,6 +93,26 @@
   automatisch selbst eingetragen (`Add-MpPreference`)
 - Firewall-Freigabe-Skript ergänzt, nachdem der Test im echten
   Firmennetzwerk anstand (Port 5000 war durch die Windows-Firewall blockiert)
+- **Installer-Dateien werden direkt ins Paket gebündelt statt zur Laufzeit
+  nachzuladen.** Auslöser: beim ersten Test im Firmennetz enthielt der
+  Download nur die Skript-Dateien, die eigentlichen Installer wurden erst
+  beim Ausführen auf dem Zielrechner per Netzwerk nachgeladen – das setzt
+  Netzwerkzugriff des Zielrechners auf den Server in genau dem Moment
+  voraus. Jetzt werden die Dateien beim Erzeugen des Pakets direkt mit
+  eingepackt, der Zielrechner braucht für die Installation selbst kein
+  Netzwerk mehr (nur die abschließende, optionale Protokoll-Meldung).
+
+## Offene Design-Frage: Skript-Ansatz vs. automatische Verteilung
+Nach dem ersten Test im Firmennetz wurde nochmal grundsätzlich hinterfragt,
+ob der Skript-Ansatz (manueller Download + Ausführen auf dem Zielrechner)
+der richtige Weg ist, oder ob Daten "automatisch" auf dem Zielrechner landen
+sollten. Eine echte automatische Verteilung ohne manuellen Schritt am
+Zielrechner würde entweder eine dauerhaft laufende Helfer-Software auf jedem
+Zielrechner oder Windows-Remoting (PowerShell Remoting) voraussetzen – beide
+Wege brauchen im Kern eine Domänen-Infrastruktur bzw. zentrale
+Zugangsdaten-Verwaltung, die aktuell nicht vorhanden ist. Diese Frage wurde
+bewusst nicht in diesem Prototyp entschieden, sondern als offener Punkt für
+die Übernahme festgehalten (siehe README.md).
 
 ## Testmethodik (durchgehend)
 Da die Zielumgebung Windows ist, die Entwicklung aber in einer Linux-Umgebung
diff --git a/README.md b/README.md
index 7d74d6d..5d6ffd4 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,35 @@
 
 ---
 
+## 3a. Rechner einrichten (Verteilung an neue Rechner)
+
+Menüpunkt "Rechner einrichten": Häkchen bei den benötigten Tools setzen,
+Mitarbeiter eintragen, "Installations-Paket herunterladen" klicken. Das
+erzeugte ZIP enthält die ausgewählten Installer-Dateien **direkt mit** (kein
+Nachladen vom Server nötig).
+
+Auf dem **neuen Rechner**: ZIP entpacken, `Rechner-einrichten-starten.bat`
+doppelklicken. Das Skript:
+- fragt bei Bedarf automatisch nach Admin-Rechten
+- installiert jedes Tool still mit dem hinterlegten Silent-Parameter
+- trägt automatisch eine Windows-Defender-Ausnahme für `C:\ClientBibliothek`
+  ein (dort landen alle Dateien gesammelt)
+- meldet das Ergebnis am Ende automatisch ans zentrale Protokoll zurück
+  (Rechnername wird dabei selbst erkannt)
+
+Tools ohne einzelne Installer-Datei (z. B. bereits entpackte/portable Tools
+wie Blender) werden als ZIP nach `C:\ClientBibliothek\pakete\` gelegt –
+bewusst ohne automatisches Entpacken, das entscheidet ihr von Fall zu Fall
+selbst.
+
+⚠� Die Windows-spezifischen Teile (Admin-Rechte-Anfrage, stille Installation)
+wurden nicht auf einem echten Windows-Rechner entwickelt und getestet, nur
+mit dem echten PowerShell-Parser auf Syntaxfehler geprüft sowie die
+Netzwerk-/Datei-Logik separat verifiziert. Bei größerem Rollout zuerst an
+1–2 unkritischen Rechnern gegentesten.
+
+---
+
 ## 4. Automatische Aktualisierung
 
 Bei jedem Tool kann beim Anlegen/Bearbeiten "Automatische Aktualisierung
diff --git a/app.py b/app.py
index dd6e9f4..a50b718 100644
--- a/app.py
+++ b/app.py
@@ -22,7 +22,7 @@
 # jederzeit im Fuss der Seitenleiste sichtbar ist, ob wirklich die neueste
 # Version laeuft (nicht nur auf der Festplatte liegt, sondern auch vom
 # gestarteten Programm geladen wurde).
-APP_VERSION = "2026-07-22 · r15 (Firewall-Freigabe-Skript ergänzt)"
+APP_VERSION = "2026-07-23 · r16 (Installer-Dateien direkt im Paket, kein Netzwerk zur Laufzeit nötig)"
 
 
 @app.context_processor
@@ -189,8 +189,7 @@
     "@echo off\r\n"
     "set ZIEL=C:\\ClientBibliothek\r\n"
     'if not exist "%ZIEL%" mkdir "%ZIEL%"\r\n'
-    'copy /Y "%~dp0einrichten.ps1" "%ZIEL%\\einrichten.ps1" >nul\r\n'
-    'copy /Y "%~f0" "%ZIEL%\\Rechner-einrichten-starten.bat" >nul\r\n'
+    'xcopy "%~dp0*" "%ZIEL%\\" /E /I /Y >nul\r\n'
     "echo ============================================================\r\n"
     "echo  Rechner-Einrichtung wird gestartet...\r\n"
     "echo  Admin-Rechte werden bei Bedarf per Windows-Abfrage angefragt.\r\n"
@@ -203,10 +202,12 @@
 
 
 def _erstelle_einrichtungs_skript(server_url, mitarbeiter, tools):
-    """Baut den Inhalt der einrichten.ps1 zusammen: laedt jedes ausgewaehlte
-    Tool ueber die bestehende Download-Route herunter, installiert es mit
-    dem hinterlegten Silent-Parameter und meldet das Ergebnis am Ende als
-    Protokoll an die Server-Bibliothek zurueck."""
+    """Baut den Inhalt der einrichten.ps1 zusammen: installiert jedes
+    ausgewaehlte Tool aus der MITGELIEFERTEN Datei (liegt im 'tools'-Unterordner
+    desselben Pakets, kein Netzwerkzugriff auf den Server noetig) und meldet
+    das Ergebnis am Ende BEST-EFFORT als Protokoll an die Server-Bibliothek
+    zurueck (falls Netzwerkzugriff vorhanden ist - schlaegt das fehl, bleibt
+    trotzdem das lokale Protokoll erhalten)."""
     zeilen = [
         '$ErrorActionPreference = "Stop"',
         f"$server = '{_ps_escape(server_url)}'",
@@ -218,10 +219,9 @@
         "    exit",
         "}",
         "",
-        "$tempOrdner = Join-Path $clientOrdner \"downloads\"",
+        "$toolsOrdner = Join-Path $clientOrdner \"tools\"",
         "$paketeOrdner = Join-Path $clientOrdner \"pakete\"",
         "$protokollDatei = Join-Path $clientOrdner \"protokoll.log\"",
-        "New-Item -ItemType Directory -Force -Path $tempOrdner | Out-Null",
         "New-Item -ItemType Directory -Force -Path $paketeOrdner | Out-Null",
         "",
         "try {",
@@ -246,41 +246,38 @@
         "",
         "function InstalliereTool($id, $name, $endung, $parameter) {",
         '    Write-Host "Installiere: $name ..." -ForegroundColor Cyan',
-        '    $zielDatei = Join-Path $tempOrdner ("sb_" + $id + $endung)',
-        "    try {",
-        '        Invoke-WebRequest -Uri "$server/software/$id/download" -OutFile $zielDatei -UseBasicParsing',
-        "    } catch {",
-        '        Write-Host ("  FEHLER beim Herunterladen von " + $name + ": " + $_) -ForegroundColor Red',
-        '        Protokolliere ($name + ": FEHLER beim Herunterladen")',
+        '    $quelle = Join-Path $toolsOrdner ($id.ToString() + $endung)',
+        "    if (-not (Test-Path $quelle)) {",
+        '        Write-Host ("  FEHLER: Datei fehlt im Paket unter " + $quelle) -ForegroundColor Red',
+        '        Protokolliere ($name + ": FEHLER, Datei fehlt im Paket")',
         "        return $false",
         "    }",
         "    try {",
         '        if ($endung -eq ".msi") {',
-        "            Start-Process \"msiexec.exe\" -ArgumentList ('/i \"' + $zielDatei + '\" ' + $parameter) -Wait",
+        "            Start-Process \"msiexec.exe\" -ArgumentList ('/i \"' + $quelle + '\" ' + $parameter) -Wait",
         "        } else {",
-        "            Start-Process $zielDatei -ArgumentList $parameter -Wait",
+        "            Start-Process $quelle -ArgumentList $parameter -Wait",
         "        }",
         "    } catch {",
         '        Write-Host ("  FEHLER bei der Installation von " + $name + ": " + $_) -ForegroundColor Red',
         '        Protokolliere ($name + ": FEHLER bei der Installation")',
         "        return $false",
         "    }",
-        "    Remove-Item -Path $zielDatei -Force -ErrorAction SilentlyContinue",
         '    Protokolliere ($name + ": installiert")',
         '    Write-Host "  fertig." -ForegroundColor Green',
         "    return $true",
         "}",
         "",
         "function BereitstellenPortablesTool($id, $name) {",
-        '    Write-Host "Lade herunter (portables Tool): $name ..." -ForegroundColor Cyan',
-        '    $zielZip = Join-Path $paketeOrdner ($name + ".zip")',
-        "    try {",
-        '        Invoke-WebRequest -Uri "$server/software/$id/download" -OutFile $zielZip -UseBasicParsing',
-        "    } catch {",
-        '        Write-Host ("  FEHLER beim Herunterladen von " + $name + ": " + $_) -ForegroundColor Red',
-        '        Protokolliere ($name + ": FEHLER beim Herunterladen")',
+        '    Write-Host "Portables Tool: $name ..." -ForegroundColor Cyan',
+        '    $quelle = Join-Path $toolsOrdner ($id.ToString() + ".zip")',
+        "    if (-not (Test-Path $quelle)) {",
+        '        Write-Host ("  FEHLER: ZIP fehlt im Paket unter " + $quelle) -ForegroundColor Red',
+        '        Protokolliere ($name + ": FEHLER, ZIP fehlt im Paket")',
         "        return $false",
         "    }",
+        '    $zielZip = Join-Path $paketeOrdner ($name + ".zip")',
+        "    Copy-Item -Path $quelle -Destination $zielZip -Force",
         '    Protokolliere ($name + ": als ZIP bereitgestellt unter " + $zielZip)',
         '    Write-Host ("  fertig, liegt als ZIP unter " + $zielZip) -ForegroundColor Green',
         '    Write-Host "  (bewusst NICHT automatisch entpackt - bitte manuell an den Zielort entpacken)" -ForegroundColor DarkGray',
@@ -304,7 +301,7 @@
     zeilen += [
         "",
         'Write-Host ""',
-        'Write-Host "Protokoll wird auf dem Server gespeichert..."',
+        'Write-Host "Protokoll wird an den Server gemeldet (optional, braucht Netzwerkzugriff)..."',
         '$koerper = "mitarbeiter=" + [uri]::EscapeDataString($mitarbeiter)',
         '$koerper += "&rechnername=" + [uri]::EscapeDataString($env:COMPUTERNAME)',
         '$koerper += "&installiert_von=" + [uri]::EscapeDataString("Automatisches Einrichtungs-Skript")',
@@ -312,9 +309,9 @@
         "foreach ($id in $erfolgreicheIds) { $koerper += \"&software_ids=$id\" }",
         "try {",
         '    Invoke-RestMethod -Uri "$server/zuweisungen/neu" -Method POST -Body $koerper -ContentType "application/x-www-form-urlencoded" | Out-Null',
-        '    Write-Host "Protokoll gespeichert." -ForegroundColor Green',
+        '    Write-Host "Zentrales Protokoll auf dem Server aktualisiert." -ForegroundColor Green',
         "} catch {",
-        '    Write-Host ("Konnte Protokoll nicht speichern: " + $_) -ForegroundColor Yellow',
+        '    Write-Host "Kein Netzwerkzugriff auf den Server - zentrales Protokoll nicht aktualisiert (lokales Protokoll unter protokoll.log ist trotzdem vollstaendig)." -ForegroundColor Yellow',
         "}",
         "",
         'Write-Host ""',
@@ -353,6 +350,7 @@
                 "typ": "installer",
                 "endung": os.path.splitext(gefunden)[1].lower(),
                 "parameter": sw["install_parameter"] or "",
+                "installer_pfad": pfad,
             })
             continue
 
@@ -367,6 +365,7 @@
                     "typ": "ordner",
                     "endung": "",
                     "parameter": "",
+                    "installer_pfad": pfad,
                 })
                 continue
 
@@ -388,6 +387,20 @@
         with zipfile.ZipFile(speicher, "w", zipfile.ZIP_DEFLATED) as zf:
             zf.writestr("einrichten.ps1", ps1_inhalt)
             zf.writestr("Rechner-einrichten-starten.bat", _BAT_LAUNCHER)
+
+            # Die echten Installer-Dateien/ZIPs direkt mit ins Paket packen,
+            # damit auf dem Zielrechner beim Ausfuehren KEIN Netzwerkzugriff
+            # auf den Server mehr noetig ist (nur die abschliessende
+            # Protokoll-Rueckmeldung ist optional und best-effort).
+            for t in ausgewaehlt:
+                if t["typ"] == "installer":
+                    quelldatei = updater.verwaltete_datei_pfad(t["installer_pfad"], t["name"])
+                    if quelldatei and os.path.isfile(quelldatei):
+                        zf.write(quelldatei, f"tools/{t['id']}{t['endung']}")
+                else:
+                    ordner = updater.effektiver_ablageordner(t["installer_pfad"], t["name"])
+                    zf.writestr(f"tools/{t['id']}.zip", _zippe_ordner_in_bytes(ordner))
+
         speicher.seek(0)
         return send_file(
             speicher, as_attachment=True,
@@ -397,6 +410,21 @@
     return render_template(
         "einrichten.html", installierbar=installierbar, nicht_installierbar=nicht_installierbar
     )
+
+
+def _zippe_ordner_in_bytes(ordner):
+    """Packt einen Ordner (ohne '_sicherungen'-Unterordner) in ein ZIP im
+    Arbeitsspeicher und gibt die rohen Bytes zurueck. Wird sowohl fuer den
+    Download-Button als auch fuer das Einrichtungs-Paket verwendet."""
+    speicher = io.BytesIO()
+    with zipfile.ZipFile(speicher, "w", zipfile.ZIP_DEFLATED) as zf:
+        for wurzel, unterordner, dateien in os.walk(ordner):
+            unterordner[:] = [o for o in unterordner if o != "_sicherungen"]
+            for datei in dateien:
+                voller_pfad = os.path.join(wurzel, datei)
+                rel_pfad = os.path.relpath(voller_pfad, ordner)
+                zf.write(voller_pfad, rel_pfad)
+    return speicher.getvalue()
 
 
 def _ordner_fuer_installer_sicherstellen(installer_pfad, name):
@@ -913,17 +941,11 @@
         flash(f"Ordner nicht gefunden unter: {ordner}", "fehler")
         return redirect(url_for("software_detail", software_id=software_id))
 
-    speicher = io.BytesIO()
-    with zipfile.ZipFile(speicher, "w", zipfile.ZIP_DEFLATED) as zf:
-        for wurzel, unterordner, dateien in os.walk(ordner):
-            unterordner[:] = [o for o in unterordner if o != "_sicherungen"]
-            for datei in dateien:
-                voller_pfad = os.path.join(wurzel, datei)
-                rel_pfad = os.path.relpath(voller_pfad, ordner)
-                zf.write(voller_pfad, rel_pfad)
-    speicher.seek(0)
+    zip_bytes = _zippe_ordner_in_bytes(ordner)
     zip_name = os.path.basename(ordner.rstrip("\\/")) + ".zip"
-    return send_file(speicher, as_attachment=True, download_name=zip_name, mimetype="application/zip")
+    return send_file(
+        io.BytesIO(zip_bytes), as_attachment=True, download_name=zip_name, mimetype="application/zip"
+    )
 
 
 if __name__ == "__main__":
diff --git a/templates/einrichten.html b/templates/einrichten.html
index 7086d46..52e392c 100644
--- a/templates/einrichten.html
+++ b/templates/einrichten.html
@@ -12,17 +12,19 @@
 <div class="panel" style="margin-bottom:18px;">
   <p style="margin:0 0 8px; font-size:13.5px;">
     Häkchen bei den benötigten Tools setzen, Mitarbeiter eintragen und
-    herunterladen. Auf dem <strong>neuen Rechner</strong> das ZIP entpacken
-    und <code>Rechner-einrichten-starten.bat</code> doppelklicken. Alles landet
-    dabei gesammelt unter <code>C:\ClientBibliothek</code> – dafür wird
-    automatisch eine Windows-Defender-Ausnahme für genau diesen einen Ordner
-    eingetragen. Tools mit Installer-Datei werden still installiert
-    (<code>\downloads</code>, danach automatisch aufgeräumt). Tools ohne
-    Installer ("als ZIP bereitgestellt", z. B. portable Tools) landen
-    unangetastet als ZIP in <code>\pakete</code> – die entpackt ihr bewusst
-    von Hand an den gewünschten Zielort. Anschließend trägt das Skript selbst
-    ein Protokoll ein (zentral auf dem Server und lokal unter
-    <code>\protokoll.log</code>).
+    herunterladen. Die ausgewählten Installer-Dateien werden dabei direkt mit
+    ins Paket gepackt – auf dem <strong>neuen Rechner</strong> braucht ihr nur
+    das ZIP zu entpacken und <code>Rechner-einrichten-starten.bat</code>
+    doppelzuklicken, ganz ohne dass der Zielrechner Netzwerkzugriff auf
+    diesen Server haben muss. Alles landet dabei gesammelt unter
+    <code>C:\ClientBibliothek</code> – dafür wird automatisch eine
+    Windows-Defender-Ausnahme für genau diesen einen Ordner eingetragen.
+    Tools mit Installer-Datei werden still installiert. Tools ohne Installer
+    ("als ZIP bereitgestellt", z. B. portable Tools) landen unangetastet als
+    ZIP in <code>\pakete</code> – die entpackt ihr bewusst von Hand an den
+    gewünschten Zielort. Nur die abschließende Meldung ans zentrale Protokoll
+    braucht (optional) Netzwerkzugriff – schlägt das fehl, bleibt trotzdem
+    das lokale Protokoll unter <code>\protokoll.log</code> vollständig.
   </p>
 </div>
 

--
Gitblit v1.9.3