Compare commits

...
12 Commits
Author SHA1 Message Date
chris 69d038fe98 Make the mode selection clearer. 2026-09-17 11:10:53 +02:00
chris 5b811260a5 fix: update early signal trap in recover_bad_signature.sh to pass exit code
Line 1003: changed
       to
2026-09-17 00:58:49 +02:00
chrisandClaude Sonnet 4.6 dfacd0ce83 fix/feat: validation improvements and EXIT trap for all bad-signature scripts
fix: treat qpdf exit code 3 (warnings only) as VALID in PDF check
  - exit 0 and exit 3 both map to VALID; only exit 2 is a structural error
  - error detail now includes first matching error line from qpdf output

fix: add EXIT trap so encryption flag and temp files are always cleaned up
  - changed signal trap from  to
  - EXIT fires for any bash exit including syntax errors and unexpected crashes
  - clean_up() now runs  first to prevent re-entry / infinite loop
  - applies to recover_bad_signature.sh (where the flag matters most),
    restore_bad_signature.sh and recreate_bad_signature.sh

feat: check optional validation tools at startup and offer apt install
  - new check_optional_validation_tools() prompts [j/N] in interactive mode
  - covers: imagemagick (identify), ffmpeg (ffprobe), mp3val, flac, vorbis-tools (ogginfo)

feat: use optional tools for deeper file validation when available
  - PNG / GIF / BMP / TIFF: identify -regard-warnings (full decode) with magic-byte fallback
  - MP4 / MOV / M4V: ffprobe -show_streams (container parse) with ftyp-box fallback
  - MP3: new dedicated case — mp3val frame check with ID3/sync-word fallback
  - FLAC: new dedicated case — flac --silent --test with fLaC-signature fallback
  - OGG / OGA / OGV / OPUS: new dedicated case — ogginfo with OggS-signature fallback

Affects: recover_bad_signature.sh, restore_bad_signature.sh, recreate_bad_signature.sh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfXh5sRbEaXiEX6KjAPivc
2026-09-16 19:06:03 +02:00
chris 8234c2d9f3 fix: add EXIT trap to ensure encryption flag and temp files are cleaned up on crash 2026-09-16 14:10:47 +02:00
chrisandClaude Sonnet 4.6 8bfc4d04b7 feat(ux): show temp log file paths after YES confirmation in all scripts
After the user confirms with YES, each script now prints the paths of
the two temporary files that are written continuously during the run,
so they can be monitored with tail -f without having to know the paths
by heart. Affects scan_, recover_, restore_, recreate_bad_signature.sh.
recreate shows per-account log file names since it writes one per user.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfXh5sRbEaXiEX6KjAPivc
2026-09-16 09:20:55 +02:00
chris eef48a9c8d fix(validate): fix unzip hanging on password-protected ZIPs via setsid
unzip -tq on a password-protected archive tries to open /dev/tty to
prompt for the password. Inside a tmux session this generates SIGTTIN,
which stops the process (ps state T). A stopped process cannot receive
SIGTERM, so timeout waited indefinitely for a child that would never exit.

Fix: wrap all unzip calls with setsid so the process runs in a new
session without a controlling terminal. The /dev/tty open then fails
immediately with ENXIO and unzip exits with a non-zero code instead of
blocking. Additional hardening:
- timeout -k 5: send SIGKILL 5 s after SIGTERM as a last resort
- < /dev/null: also cut off stdin as a secondary safeguard
- exit 137 (128+9, SIGKILL) treated as timeout alongside 124
- error output matching "password"/"encrypt"/"need PK compat" reported
  as UNVERIFIED instead of INVALID

Affects: recover_bad_signature.sh, restore_bad_signature.sh,
         recreate_bad_signature.sh
2026-09-16 08:55:50 +02:00
chris 6677f4f0c2 fix(validate): add 120s timeout to unzip integrity checks
unzip -tq can block indefinitely on very large or partially corrupt
ZIP archives (stalls in I/O rather than exiting with a CRC error).
All three scripts that call validate_recovered_file() are affected:
recover_, restore_, recreate_bad_signature.sh.

Both the quick check (unzip -tq) and the verbose error pass (unzip -t)
are now wrapped with `timeout 120`. Exit code 124 (timed out) is
reported as UNVERIFIED with a hint for manual follow-up; any other
non-zero exit is still reported as INVALID with the first error line.
2026-09-16 00:44:02 +02:00
chris b33b859cf2 Fix: Vorzeitiger Abbruch bei großen Accounts nach 3600s Laufzeit
Betroffen: scan_, recover_, restore_, recreate_bad_signature.sh
           und diagnose_share_key.sh

Bei Accounts mit sehr vielen bzw. sehr großen Dateien konnte der
jeweilige Pro-Account-Durchlauf länger als eine Stunde dauern und
wurde dann von PHP mit "Maximum execution time of 3600 seconds
exceeded" abgebrochen - mitten im Lauf, ohne jedes Ergebnis.

- su -c "$PHP_BIN ..." ruft PHP jetzt zusätzlich mit
  -d max_execution_time=0 auf.
- Das allein reicht nicht: Nextclouds eigenes lib/base.php setzt
  beim Bootstrap unbedingt (fest einprogrammiert, nicht
  konfigurierbar) set_time_limit(3600) und überschreibt damit den
  CLI-Flag wieder. Daher zusätzlich direkt nach dem require von
  lib/base.php ein erneutes set_time_limit(0) in jedem der fünf
  eingebetteten PHP-Scripte, das Nextclouds Reset seinerseits
  rückgängig macht.

Kein Verhaltensunterschied für kleine/normale Accounts, betrifft
nur die maximale Laufzeit pro Account.
2026-09-15 21:01:03 +02:00
chris 44f143fbe8 diagnose_share_key.sh: some changes on script output. 2026-09-15 01:17:59 +02:00
chris b1e02b737c Add sone documentation and README's. 2026-09-15 00:27:01 +02:00
chris a8887aeeea delete_files.sh: neues, generisches Script zum gezielten Löschen von Dateien 2026-09-15 00:26:19 +02:00
chris 93e0576777 Some minor changes on scriptb output. 2026-09-15 00:24:31 +02:00
11 changed files with 2659 additions and 151 deletions
+226
View File
@@ -0,0 +1,226 @@
# Bad-Signature-Toolkit
Werkzeuge zum Aufspüren und Beheben von **"Bad Signature"-Fehlern** der
Nextcloud Server-Side-Encryption (Dateien, die sich wegen einer
fehlerhaften Verschlüsselungs-Signatur nicht mehr öffnen lassen).
Alle Scripte laufen als **root** auf dem jeweiligen Nextcloud-Server und
fragen die Website interaktiv ab, falls `-s <website>` nicht angegeben
wird (Voraussetzung: eine passende `.conf`-Datei liegt im `conf/`-
Verzeichnis neben den Scripten).
## Der Ablauf auf einen Blick
```
1. scan_bad_signature.sh Betroffene Dateien FINDEN (read-only)
2. recover_bad_signature.sh Dateien probeweise ENTSCHLÜSSELN (read-only,
schreibt nur außerhalb von Nextcloud)
3. restore_bad_signature.sh Geprüfte Dateien ZURÜCKSCHREIBEN (live-Schreibzugriff)
4. recreate_bad_signature.sh Fallback für die Fälle, die (3) (live-Schreibzugriff,
nicht reparieren kann bricht Freigaben!)
delete_files.sh Explizite Dateiliste LÖSCHEN (live-Schreibzugriff,
(unabhängig vom obigen Ablauf) -> Papierkorb)
```
Jeder Schritt liest den Report des vorherigen Schritts. Reports landen
alle unter `reports/` (Dateiname verrät den Schritt: `bad_signature_…`,
`recovery_…`, `restore_…`, `recreate_…`, `delete_…`).
---
## 1. `scan_bad_signature.sh` – Betroffene Dateien finden
Liest einmal jede verschlüsselte Datei eines/mehrerer/aller Accounts und
protokolliert, welche dabei mit einem Signaturfehler scheitert. **Rein
lesend** – verändert, verschiebt oder löscht nichts.
```bash
./scan_bad_signature.sh -s <website>
```
→ Ergebnis: `reports/bad_signature_<website>_<datum>.tsv`
---
## 2. `recover_bad_signature.sh` – Versuchsweise entschlüsseln
Schaltet die Signaturprüfung *instanzweit, nur für die Laufzeit des
Scripts* ab (`encryption_skip_signature_check`) und versucht, die
betroffenen Dateien trotzdem zu entschlüsseln. Funktioniert nur, wenn
die Signaturprüfung selbst das Problem ist – nicht bei tatsächlich
beschädigtem Chiffretext. Jede gewonnene Datei wird automatisch geprüft
(Dateityp-Signatur, PDF-/ZIP-Integrität, Plausibilität der Dateigröße
o. Ä.) und als `VALID`, `UNVERIFIED` ("Nicht prüfbar") oder `INVALID`
("Datenmüll") eingestuft.
**Wichtig:** Original-Dateien in Nextcloud werden nie angefasst. Alles
landet in einem separaten Verzeichnis außerhalb von Nextclouds eigener
Ablage (`/var/nc-recovery/<website>/…`) – dort liegen danach
**unverschlüsselte** Daten, also nach Gebrauch aufräumen.
```bash
./recover_bad_signature.sh -s <website>
# Nur erneut validieren (z. B. nach einem Script-Update mit neuen
# Prüfregeln), ohne nochmal zu entschlüsseln:
./recover_bad_signature.sh -V -s <website>
```
→ Ergebnis: `reports/recovery_<website>_<datum>.tsv`
---
## 3. `restore_bad_signature.sh` – Zurückschreiben (Regelfall)
Schreibt die in Schritt 2 gewonnenen Dateien über die **normale
Nextcloud-Files-API** an ihren ursprünglichen Pfad zurück – der einzige
Weg, der Datei-ID und bestehende Freigaben erhält. Danach ist die Datei
wieder ganz normal verschlüsselt, mit frischer, korrekter Signatur.
**Seit dem letzten Update werden standardmäßig zwei Kategorien
zurückgeschrieben**, klar unterscheidbar im Report (Spalte `origin`):
| validation im Recovery-Report | Bedeutung | wird restauriert |
|---|---|---|
| `VALID` | dedizierte Strukturprüfung hat den Inhalt bestätigt | ja |
| `UNVERIFIED`, Größe plausibel | kein dedizierter Check, aber nichts sieht falsch aus | ja |
| `UNVERIFIED`, Größe "LOOKS OFF" | Größenverhältnis auffällig – echtes Warnsignal | **nein** |
| `INVALID` ("Datenmüll") | Strukturprüfung fehlgeschlagen | **nein** |
```bash
./restore_bad_signature.sh -s <website>
# nur Dry-Run (nichts wird geschrieben):
./restore_bad_signature.sh -n -s <website>
```
Fragt danach interaktiv: welcher Recovery-Report, welche(r) Account(s).
**Sicherheit:** Neuvalidierung direkt vor jedem Schreiben · aktueller
(noch kaputter) Chiffretext wird vorher byte-genau gesichert
(`/var/nc-restore-backup/<website>`) · jede Datei wird nach dem
Schreiben normal zurückgelesen und per SHA-256 verglichen · Dry-Run ist
Default, echter Lauf braucht eine ausdrückliche `YES`-Bestätigung.
⚠️ **Ein erneuter Lauf wählt wieder *alle* passenden Einträge des
gewählten Accounts aus dem Report** – auch bereits erfolgreich
restaurierte, nicht nur neue. Für ein gezieltes Nachziehen einzelner,
noch offener Dateien lieber einen auf diese Dateien reduzierten
Mini-Report verwenden, statt den ganzen Account-Bestand erneut zu
überschreiben.
→ Ergebnis: `reports/restore_<website>_<datum>.tsv`
---
## 4. `recreate_bad_signature.sh` – Fallback für Schlüsselmaterial-Fehler
Nur nötig, wenn `restore_bad_signature.sh` bei einer Datei mit einem
**Schlüsselmaterial-Fehler** scheitert (`MultiKeyDecryptException` /
"probably this is a shared file…") statt mit "Bad Signature". Löscht
die kaputte Datei auf reiner Dateisystem-Ebene (inkl. altem
Schlüssel-Verzeichnis) und legt sie komplett neu an, mit frischem
Schlüssel.
⚠️ **Invasiver als restore:** Die Datei bekommt eine **neue Datei-ID**
– bestehende Freigaben, Kommentare, Tags und Versionshistorie dieser
Datei gehen dabei verloren und müssten danach manuell neu eingerichtet
werden. Nur verwenden, wenn Schritt 3 tatsächlich mit diesem
spezifischen Fehler gescheitert ist.
```bash
./recreate_bad_signature.sh -s <website>
# Dateien mit aktiven Freigaben standardmäßig übersprungen,
# nur mit Bedacht einschließen:
./recreate_bad_signature.sh -f -s <website>
```
**Sicherheit:** Nur Dateien mit passendem Fehler aus dem
restore-Report werden angefasst · Chiffretext UND Schlüssel-Verzeichnis
werden vorher gesichert und die Sicherung vor dem Löschen verifiziert
(`/var/nc-recreate-backup/<website>`) · Dateien mit aktiven Freigaben
werden standardmäßig übersprungen · Verifikation nach dem Schreiben wie
bei restore.
→ Ergebnis: `reports/recreate_<website>_<datum>.tsv`
---
## `delete_files.sh` – Gezielt nicht benötigte Dateien löschen
Generisches, von Account und Site unabhängiges Script für den zweiten
Teil des Aufräum-Workflows: eine **von Hand geprüfte** Liste an
Dateien entfernen, die nicht erhaltenswert sind (z. B. macOS-Spotlight-
Indexdateien, Fragmente aus "Webseite speichern"). Kein Scannen, kein
automatisches Erraten – jede Zeile in der Liste ist eine bewusste
Entscheidung.
Pfadliste: einfache Textdatei, ein Pfad pro Zeile (`#`-Kommentare und
Leerzeilen erlaubt), Account wird automatisch aus dem Pfad erkannt:
```
/inge/files/Ordner/Datei.ext
/anderer-account/files/Anderer/Pfad/datei2.ext
```
```bash
./delete_files.sh -s <website> -f <pfadliste.txt>
# nur Dry-Run:
./delete_files.sh -n -s <website> -f <pfadliste.txt>
```
Löscht über die normale Files-API (→ **Papierkorb**, sofern
`files_trashbin` aktiv ist) und sichert den Chiffretext zusätzlich
byte-genau vorher (`/var/nc-delete-backup/<website>`). Ein bereits
nicht mehr existierender Pfad wird als `NOT_FOUND` gemeldet, nicht als
Fehler.
→ Ergebnis: `reports/delete_<website>_<datum>.tsv`
---
## Typischer Ablauf
```bash
./scan_bad_signature.sh -s cloud-01.oopen.de # betroffene Dateien finden
./recover_bad_signature.sh -s cloud-01.oopen.de # entschlüsseln + prüfen
./restore_bad_signature.sh -s cloud-01.oopen.de # VALID + plausible zurückschreiben
# nur bei einzelnen WRITE_ERROR mit Schlüsselmaterial-Fehler nötig:
./recreate_bad_signature.sh -s cloud-01.oopen.de
# optional: von Hand geprüfte, nicht erhaltenswerte Dateien entfernen
./delete_files.sh -s cloud-01.oopen.de -f nicht_benoetigt.txt
# zur Kontrolle: sollte jetzt (für erledigte Accounts) 0 melden
./scan_bad_signature.sh -s cloud-01.oopen.de
```
## Verzeichnisse
| Zweck | Standardpfad | Override (conf-Datei) |
|---|---|---|
| Recovery-Kopien (unverschlüsselt!) | `/var/nc-recovery/<website>` | `RECOVERY_BASE_DIR` |
| Backup vor restore-Überschreiben | `/var/nc-restore-backup/<website>` | `RESTORE_BACKUP_BASE_DIR` |
| Backup vor recreate-Löschen | `/var/nc-recreate-backup/<website>` | `RECREATE_BACKUP_BASE_DIR` |
| Backup vor delete_files-Löschen | `/var/nc-delete-backup/<website>` | `DELETE_BACKUP_BASE_DIR` |
| Reports aller Scripte | `reports/` (neben den Scripten) | – |
## Sicherheitsprinzipien (gelten für alle schreibenden Scripte)
- **Dry-Run ist Standard** – ein echter Lauf braucht eine ausdrückliche `YES`-Bestätigung.
- **Immer zuerst sichern**, dann erst schreiben/löschen – byte-genaue Kopie, unabhängig von Nextcloud.
- **Immer neu validieren** unmittelbar vor dem Zugriff, nicht blind aus einem alten Report übernehmen.
- **Immer verifizieren** nach dem Schreiben (Rücklesen + SHA-256-Vergleich).
- Jedes Script kann per `Strg-C` sicher unterbrochen werden – kein halb geschriebener Report.
## Kurzreferenz aller Flags
| Script | `-s` | weitere Optionen |
|---|---|---|
| `scan_bad_signature.sh` | Website | – |
| `recover_bad_signature.sh` | Website | `-V` nur revalidieren |
| `restore_bad_signature.sh` | Website | `-n` Dry-Run erzwingen |
| `recreate_bad_signature.sh` | Website | `-n` Dry-Run erzwingen · `-f` Dateien mit aktiven Freigaben einschließen |
| `delete_files.sh` | Website | `-n` Dry-Run erzwingen · `-f <datei>` Pfadliste (**Pflicht**) |
`-h` zeigt bei jedem Script die ausführliche Hilfe.
+246
View File
@@ -0,0 +1,246 @@
# Bad-Signature-Toolkit
Werkzeuge zum Aufspüren und Beheben von **"Bad Signature"-Fehlern** der
Nextcloud Server-Side-Encryption (Dateien, die sich wegen einer
fehlerhaften Verschlüsselungs-Signatur nicht mehr öffnen lassen).
Alle Scripte laufen als **root** auf dem jeweiligen Nextcloud-Server und
können auch **ganz ohne Parameter** aufgerufen werden – `-s <website>`
ist optional, nicht Pflicht. Fehlt es, ermittelt das Script alle
konfigurierten Instanzen anhand der `.conf`-Dateien im `conf/`-
Verzeichnis neben den Scripten. Gibt es davon **mehrere** (mehrere
Nextcloud-Instanzen auf demselben Server), wird interaktiv eine
Auswahlliste angezeigt, aus der die gewünschte Website ausgewählt wird.
Das gilt für alle fünf Scripte gleichermaßen.
## Der Ablauf auf einen Blick
```
1. scan_bad_signature.sh Betroffene Dateien FINDEN (read-only)
2. recover_bad_signature.sh Dateien probeweise ENTSCHLÜSSELN (read-only,
schreibt nur außerhalb von Nextcloud)
3. restore_bad_signature.sh Geprüfte Dateien ZURÜCKSCHREIBEN (live-Schreibzugriff)
4. recreate_bad_signature.sh Fallback für die Fälle, die (3) (live-Schreibzugriff,
nicht reparieren kann bricht Freigaben!)
delete_files.sh Explizite Dateiliste LÖSCHEN (live-Schreibzugriff,
(unabhängig vom obigen Ablauf) -> Papierkorb)
```
Jeder Schritt liest den Report des vorherigen Schritts. Reports landen
alle unter `reports/` (Dateiname verrät den Schritt: `bad_signature_…`,
`recovery_…`, `restore_…`, `recreate_…`, `delete_…`).
---
## 1. `scan_bad_signature.sh` – Betroffene Dateien finden
Liest einmal jede verschlüsselte Datei eines/mehrerer/aller Accounts und
protokolliert, welche dabei mit einem Signaturfehler scheitert. **Rein
lesend** – verändert, verschiebt oder löscht nichts.
```bash
./scan_bad_signature.sh -s <website>
# oder ganz ohne Parameter - Website wird interaktiv abgefragt
# (bei mehreren konfigurierten Instanzen als Auswahlliste):
./scan_bad_signature.sh
```
→ Ergebnis: `reports/bad_signature_<website>_<datum>.tsv`
---
## 2. `recover_bad_signature.sh` – Versuchsweise entschlüsseln
Schaltet die Signaturprüfung *instanzweit, nur für die Laufzeit des
Scripts* ab (`encryption_skip_signature_check`) und versucht, die
betroffenen Dateien trotzdem zu entschlüsseln. Funktioniert nur, wenn
die Signaturprüfung selbst das Problem ist – nicht bei tatsächlich
beschädigtem Chiffretext. Jede gewonnene Datei wird automatisch geprüft
(Dateityp-Signatur, PDF-/ZIP-Integrität, Plausibilität der Dateigröße
o. Ä.) und als `VALID`, `UNVERIFIED` ("Nicht prüfbar") oder `INVALID`
("Datenmüll") eingestuft.
**Wichtig:** Original-Dateien in Nextcloud werden nie angefasst. Alles
landet in einem separaten Verzeichnis außerhalb von Nextclouds eigener
Ablage (`/var/nc-recovery/<website>/…`) – dort liegen danach
**unverschlüsselte** Daten, also nach Gebrauch aufräumen.
```bash
./recover_bad_signature.sh -s <website>
# oder ganz ohne Parameter - Website wird interaktiv abgefragt:
./recover_bad_signature.sh
# Nur erneut validieren (z. B. nach einem Script-Update mit neuen
# Prüfregeln), ohne nochmal zu entschlüsseln:
./recover_bad_signature.sh -V -s <website>
```
→ Ergebnis: `reports/recovery_<website>_<datum>.tsv`
---
## 3. `restore_bad_signature.sh` – Zurückschreiben (Regelfall)
Schreibt die in Schritt 2 gewonnenen Dateien über die **normale
Nextcloud-Files-API** an ihren ursprünglichen Pfad zurück – der einzige
Weg, der Datei-ID und bestehende Freigaben erhält. Danach ist die Datei
wieder ganz normal verschlüsselt, mit frischer, korrekter Signatur.
**Seit dem letzten Update werden standardmäßig zwei Kategorien
zurückgeschrieben**, klar unterscheidbar im Report (Spalte `origin`):
| validation im Recovery-Report | Bedeutung | wird restauriert |
|---|---|---|
| `VALID` | dedizierte Strukturprüfung hat den Inhalt bestätigt | ja |
| `UNVERIFIED`, Größe plausibel | kein dedizierter Check, aber nichts sieht falsch aus | ja |
| `UNVERIFIED`, Größe "LOOKS OFF" | Größenverhältnis auffällig – echtes Warnsignal | **nein** |
| `INVALID` ("Datenmüll") | Strukturprüfung fehlgeschlagen | **nein** |
```bash
./restore_bad_signature.sh -s <website>
# oder ganz ohne Parameter - Website wird interaktiv abgefragt:
./restore_bad_signature.sh
# nur Dry-Run (nichts wird geschrieben):
./restore_bad_signature.sh -n -s <website>
```
Fragt danach interaktiv: welcher Recovery-Report, welche(r) Account(s).
**Sicherheit:** Neuvalidierung direkt vor jedem Schreiben · aktueller
(noch kaputter) Chiffretext wird vorher byte-genau gesichert
(`/var/nc-restore-backup/<website>`) · jede Datei wird nach dem
Schreiben normal zurückgelesen und per SHA-256 verglichen · Dry-Run ist
Default, echter Lauf braucht eine ausdrückliche `YES`-Bestätigung.
⚠️ **Ein erneuter Lauf wählt wieder *alle* passenden Einträge des
gewählten Accounts aus dem Report** – auch bereits erfolgreich
restaurierte, nicht nur neue. Für ein gezieltes Nachziehen einzelner,
noch offener Dateien lieber einen auf diese Dateien reduzierten
Mini-Report verwenden, statt den ganzen Account-Bestand erneut zu
überschreiben.
→ Ergebnis: `reports/restore_<website>_<datum>.tsv`
---
## 4. `recreate_bad_signature.sh` – Fallback für Schlüsselmaterial-Fehler
Nur nötig, wenn `restore_bad_signature.sh` bei einer Datei mit einem
**Schlüsselmaterial-Fehler** scheitert (`MultiKeyDecryptException` /
"probably this is a shared file…") statt mit "Bad Signature". Löscht
die kaputte Datei auf reiner Dateisystem-Ebene (inkl. altem
Schlüssel-Verzeichnis) und legt sie komplett neu an, mit frischem
Schlüssel.
⚠️ **Invasiver als restore:** Die Datei bekommt eine **neue Datei-ID**
– bestehende Freigaben, Kommentare, Tags und Versionshistorie dieser
Datei gehen dabei verloren und müssten danach manuell neu eingerichtet
werden. Nur verwenden, wenn Schritt 3 tatsächlich mit diesem
spezifischen Fehler gescheitert ist.
```bash
./recreate_bad_signature.sh -s <website>
# oder ganz ohne Parameter - Website wird interaktiv abgefragt:
./recreate_bad_signature.sh
# Dateien mit aktiven Freigaben standardmäßig übersprungen,
# nur mit Bedacht einschließen:
./recreate_bad_signature.sh -f -s <website>
```
**Sicherheit:** Nur Dateien mit passendem Fehler aus dem
restore-Report werden angefasst · Chiffretext UND Schlüssel-Verzeichnis
werden vorher gesichert und die Sicherung vor dem Löschen verifiziert
(`/var/nc-recreate-backup/<website>`) · Dateien mit aktiven Freigaben
werden standardmäßig übersprungen · Verifikation nach dem Schreiben wie
bei restore.
→ Ergebnis: `reports/recreate_<website>_<datum>.tsv`
---
## `delete_files.sh` – Gezielt nicht benötigte Dateien löschen
Generisches, von Account und Site unabhängiges Script für den zweiten
Teil des Aufräum-Workflows: eine **von Hand geprüfte** Liste an
Dateien entfernen, die nicht erhaltenswert sind (z. B. macOS-Spotlight-
Indexdateien, Fragmente aus "Webseite speichern"). Kein Scannen, kein
automatisches Erraten – jede Zeile in der Liste ist eine bewusste
Entscheidung.
Pfadliste: einfache Textdatei, ein Pfad pro Zeile (`#`-Kommentare und
Leerzeilen erlaubt), Account wird automatisch aus dem Pfad erkannt:
```
/inge/files/Ordner/Datei.ext
/anderer-account/files/Anderer/Pfad/datei2.ext
```
```bash
./delete_files.sh -s <website> -f <pfadliste.txt>
# oder ganz ohne Parameter - Website UND Pfad zur Liste werden
# interaktiv abgefragt:
./delete_files.sh
# nur Dry-Run:
./delete_files.sh -n -s <website> -f <pfadliste.txt>
```
Löscht über die normale Files-API (→ **Papierkorb**, sofern
`files_trashbin` aktiv ist) und sichert den Chiffretext zusätzlich
byte-genau vorher (`/var/nc-delete-backup/<website>`). Ein bereits
nicht mehr existierender Pfad wird als `NOT_FOUND` gemeldet, nicht als
Fehler.
→ Ergebnis: `reports/delete_<website>_<datum>.tsv`
---
## Typischer Ablauf
```bash
./scan_bad_signature.sh -s cloud-01.oopen.de # betroffene Dateien finden
./recover_bad_signature.sh -s cloud-01.oopen.de # entschlüsseln + prüfen
./restore_bad_signature.sh -s cloud-01.oopen.de # VALID + plausible zurückschreiben
# nur bei einzelnen WRITE_ERROR mit Schlüsselmaterial-Fehler nötig:
./recreate_bad_signature.sh -s cloud-01.oopen.de
# optional: von Hand geprüfte, nicht erhaltenswerte Dateien entfernen
./delete_files.sh -s cloud-01.oopen.de -f nicht_benoetigt.txt
# zur Kontrolle: sollte jetzt (für erledigte Accounts) 0 melden
./scan_bad_signature.sh -s cloud-01.oopen.de
```
## Verzeichnisse
| Zweck | Standardpfad | Override (conf-Datei) |
|---|---|---|
| Recovery-Kopien (unverschlüsselt!) | `/var/nc-recovery/<website>` | `RECOVERY_BASE_DIR` |
| Backup vor restore-Überschreiben | `/var/nc-restore-backup/<website>` | `RESTORE_BACKUP_BASE_DIR` |
| Backup vor recreate-Löschen | `/var/nc-recreate-backup/<website>` | `RECREATE_BACKUP_BASE_DIR` |
| Backup vor delete_files-Löschen | `/var/nc-delete-backup/<website>` | `DELETE_BACKUP_BASE_DIR` |
| Reports aller Scripte | `reports/` (neben den Scripten) | – |
## Sicherheitsprinzipien (gelten für alle schreibenden Scripte)
- **Dry-Run ist Standard** – ein echter Lauf braucht eine ausdrückliche `YES`-Bestätigung.
- **Immer zuerst sichern**, dann erst schreiben/löschen – byte-genaue Kopie, unabhängig von Nextcloud.
- **Immer neu validieren** unmittelbar vor dem Zugriff, nicht blind aus einem alten Report übernehmen.
- **Immer verifizieren** nach dem Schreiben (Rücklesen + SHA-256-Vergleich).
- Jedes Script kann per `Strg-C` sicher unterbrochen werden – kein halb geschriebener Report.
## Kurzreferenz aller Flags
`-s <website>` ist bei **allen** Scripten optional – fehlt es, wird die
Website interaktiv abgefragt (Auswahlliste bei mehreren konfigurierten
Instanzen).
| Script | `-s` (optional) | weitere Optionen |
|---|---|---|
| `scan_bad_signature.sh` | Website | – |
| `recover_bad_signature.sh` | Website | `-V` nur revalidieren |
| `restore_bad_signature.sh` | Website | `-n` Dry-Run erzwingen |
| `recreate_bad_signature.sh` | Website | `-n` Dry-Run erzwingen · `-f` Dateien mit aktiven Freigaben einschließen |
| `delete_files.sh` | Website | `-n` Dry-Run erzwingen · `-f <datei>` Pfadliste (ohne Angabe interaktiv abgefragt) |
`-h` zeigt bei jedem Script die ausführliche Hilfe.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,134 @@
# Bad-Signature-Toolkit: Zusammenfassung der neuen/aktualisierten Scripte
Stand: 14.09.2026
## Ausgangslage
Im Rahmen der Bereinigung von "Bad Signature"-Fehlern (fehlerhafte Server-Side-Encryption-Signaturen) auf mehreren Nextcloud-Instanzen wurde das bestehende Toolkit um zwei Punkte erweitert:
1. **`restore_bad_signature.sh`** schreibt jetzt standardmäßig nicht nur streng geprüfte ("VALID"), sondern auch plausible, aber nicht abschließend prüfbare Dateien zurück ("Nicht prüfbar"/UNVERIFIED).
2. **`delete_files.sh`** ist ein neues, generisches Script, um eine explizite Liste nicht benötigter Dateien zu löschen – unabhängig von Account oder Site, ohne Sonder-/Einmal-Scripte pro Vorfall.
Damit lässt sich der komplette Workflow konsequent nach einem einfachen Prinzip abbilden:
> **Unauffällige Dateien schreiben wir zurück. Dateien, die es nicht braucht, löschen wir.**
Beide Scripte reihen sich in das bestehende Toolkit ein:
```
scan_bad_signature.sh -> findet betroffene Dateien (read-only)
recover_bad_signature.sh -> entschlüsselt/prüft sie in ein separates Verzeichnis (read-only)
restore_bad_signature.sh -> schreibt geprüfte Dateien an ihren Originalort zurück
recreate_bad_signature.sh -> Fallback: löscht+erstellt neu (bricht Freigaben, nur für die schwierigere Fehlerklasse)
delete_files.sh -> NEU: löscht eine explizite Liste nicht benötigter Dateien
```
---
## 1. `restore_bad_signature.sh` (aktualisiert)
### Zweck (unverändert)
Schreibt bereits wiederhergestellte und geprüfte Dateien (Ergebnis eines vorherigen `recover_bad_signature.sh`-Laufs) über die normale Nextcloud-Files-API an ihren **ursprünglichen Pfad** zurück. Das ist der einzige Schreibweg, der Datei-ID und Freigaben erhält (im Gegensatz zu `recreate_bad_signature.sh`).
### Was sich geändert hat
Bisher wurden nur Dateien zurückgeschrieben, deren Recovery-Report-Eintrag `validation=VALID` trug – also Dateien, bei denen eine dedizierte Strukturprüfung (PDF-Header, ZIP-Integrität, Bildsignatur usw.) den Inhalt tatsächlich bestätigen konnte.
**Jetzt werden standardmäßig zwei Kategorien zurückgeschrieben:**
| Kategorie | Bedeutung | Wird restauriert? |
|---|---|---|
| `VALID` | Dedizierte Strukturprüfung hat den Inhalt bestätigt | Ja (wie bisher) |
| `UNVERIFIED` ("Nicht prüfbar"), Größenverhältnis plausibel | Kein dedizierter Check für diesen Dateityp vorhanden, aber nichts sieht falsch aus | **Ja, neu** |
| `UNVERIFIED`, Größenverhältnis "LOOKS OFF" | Wie oben, aber das Größenverhältnis der Datei ist auffällig | Nein – wie `INVALID`/"Datenmüll" ausgeschlossen |
| `INVALID` ("Datenmüll") | Strukturprüfung ist fehlgeschlagen | Nein (unverändert) |
Der einzige Fall, der tatsächlich ein Warnsignal ist (auffälliges Größenverhältnis), bleibt also weiterhin ausgeschlossen – kein Override möglich. Alles andere, das lediglich "nicht abschließend prüfbar, aber unauffällig" ist, wird ab sofort mit restauriert, ohne dass dafür ein Sonder-Script pro Account nötig wäre.
### Transparenz
Damit eine zurückgeschriebene UNVERIFIED-Datei nie mit einer echten VALID-Verifikation verwechselt wird:
- Der Restore-Report bekommt eine eigene Spalte `origin` (`VALID` oder `UNVERIFIED`).
- Eigene Zähler pro Account und in der Gesamtsumme ("davon 'Nicht prüfbar'/UNVERIFIED: N").
- Ausdrücklicher Hinweis im Report und am Ende des Laufs, diese Dateien stichprobenartig zu prüfen.
### Sicherheitsmechanismen (unverändert)
- Jede Datei wird unmittelbar vor dem Schreiben mit den *aktuellen* Prüfregeln erneut validiert (nicht blind aus dem alten Report übernommen).
- Der aktuell noch kaputte Chiffretext wird vor dem Überschreiben byte-genau gesichert (`/var/nc-restore-backup/<website>`).
- Nach jedem Schreiben wird die Datei über den normalen Lesepfad zurückgelesen und per SHA-256 mit der Quelle verglichen.
- Dry-Run ist Standard, ohne `-n` wird interaktiv gefragt; eine ausdrückliche `YES`-Bestätigung ist für den echten Lauf nötig.
### Aufruf
```bash
./restore_bad_signature.sh -s <website>
# oder, nur Dry-Run:
./restore_bad_signature.sh -n -s <website>
```
Danach interaktiv: Auswahl des Recovery-Reports, Auswahl der Accounts ("VALID + plausibel UNVERIFIED"-Einträge).
> **Hinweis:** Ein erneuter Lauf über denselben Report wählt wieder *alle* passenden Einträge des jeweiligen Accounts aus – auch bereits erfolgreich restaurierte. Um gezielt nur einzelne, noch offene Dateien nachzuziehen (z. B. nachträglich als "unauffällig" eingestufte Dateien), empfiehlt es sich, einen auf diese Dateien reduzierten Mini-Report zu verwenden, statt den kompletten Account-Bestand erneut zu überschreiben.
---
## 2. `delete_files.sh` (neu)
### Zweck
Generischer, wiederverwendbarer Begleiter zu `restore_bad_signature.sh` für die andere Hälfte des Workflows: eine explizite, von Hand geprüfte Liste von Dateien aus der Nextcloud-Live-Ablage entfernen – z. B. Systemdateien (macOS-Spotlight-Index), Fragmente aus "Webseite speichern"-Aktionen oder andere Dateien, die nach Durchsicht als nicht erhaltenswert eingestuft wurden.
**Nicht** account- oder site-spezifisch – ein einziges Script für alle Fälle, kein Einmal-Script pro Vorfall mehr nötig.
### Eingabeformat
Einfache Textdatei, ein Nextcloud-Pfad pro Zeile, im selben Format wie die `path`-Spalte der Toolkit-Reports:
```
/inge/files/Ordner/Datei.ext
# Kommentarzeilen mit '#' und Leerzeilen werden ignoriert
/anderer-account/files/Anderer/Pfad/datei2.ext
```
Der jeweilige Account wird automatisch aus dem ersten Pfadsegment erkannt – eine Liste kann also Dateien mehrerer Accounts derselben Site mischen.
### Was passiert beim Löschen
- Löschen erfolgt über die normale Nextcloud-Files-API (`$node->delete()`) – **derselbe Weg wie im Webinterface**. Ist die App `files_trashbin` aktiv (Standard), landet die Datei im Papierkorb des jeweiligen Accounts und ist von dort wiederherstellbar.
- Vor dem Löschen wird der aktuelle Chiffretext zusätzlich byte-genau in ein eigenes Backup-Verzeichnis gesichert (`/var/nc-delete-backup/<website>`) – unabhängig von Nextcloud/Papierkorb.
- Ein Pfad, der gar nicht mehr existiert, wird als `NOT_FOUND` gemeldet, nicht als Fehler.
- Dry-Run ist Standard, ohne `-n` wird interaktiv gefragt; eine ausdrückliche `YES`-Bestätigung ist für den echten Lauf nötig.
### Aufruf
```bash
./delete_files.sh -s <website> -f <pfadliste.txt>
# oder, nur Dry-Run:
./delete_files.sh -n -s <website> -f <pfadliste.txt>
```
---
## Zusammenspiel der beiden Scripte
Für einen typischen "Nicht prüfbar"-Restbestand nach einem `recover_bad_signature.sh`-Lauf:
1. `restore_bad_signature.sh` läuft ganz normal für den Account – nimmt automatisch alle VALID- und plausiblen UNVERIFIED-Dateien mit.
2. Was danach als nicht erhaltenswert erkannt wird (z. B. Systemdateien), wandert in eine einfache Pfadliste.
3. `delete_files.sh` räumt diese Liste ab – unabhängig davon, ob die Dateien vorher restauriert wurden oder nicht.
Kein Account und kein Vorfall braucht dafür mehr ein eigenes Script – beide Werkzeuge sind allgemein einsetzbar und für jede Site/jeden Account nutzbar.
---
## Beispiel aus der aktuellen Bereinigung (cloud-01.oopen.de)
Beim Recovery-Lauf für `cloud-01.oopen.de` (Accounts `chris` + `inge`) blieben 11 von 6249 Dateien als "Nicht prüfbar" übrig:
- **2 Dateien** (Ressourcen eines Lernspiels, `.ctf`/`.md8`) wurden als plausibel eingestuft und werden per `restore_bad_signature.sh` zurückgeschrieben.
- **9 Dateien** (7 macOS-Spotlight-Indexdateien, 2 Fragmente aus "Webseite speichern") wurden als nicht erhaltenswert eingestuft und werden per `delete_files.sh` entfernt.
Beide Scripte behandeln diesen Fall jetzt mit ihrer regulären, generischen Logik – ohne das ursprünglich dafür gebaute Einmal-Script `cleanup_11_misc_files_cloud01.sh`, das damit hinfällig ist.
+991
View File
@@ -0,0 +1,991 @@
#!/usr/bin/env bash
CUR_IFS=$IFS
script_name="$(basename $(realpath $0))"
script_dir="$(dirname $(realpath $0))"
conf_dir="${script_dir}/conf"
snippet_dir="${script_dir}/snippets"
report_dir="${script_dir}/reports"
# - Before this script deletes a file from Nextcloud's live storage, it
# - keeps a raw, byte-for-byte copy of the CURRENT on-disk ciphertext
# - (the file exactly as it is right now) in a separate directory - a
# - plain filesystem copy, independent of Nextcloud/its Trash entirely.
# - This is an extra safety net on top of (not a replacement for)
# - Nextcloud's own Trash, which is where a normal delete through the
# - Files API already sends the file if the 'files_trashbin' app is
# - enabled.
# -
DEFAULT_DELETE_BACKUP_BASE_DIR="/var/nc-delete-backup"
declare -a unsorted_website_arr
declare -a website_arr
declare -a unsorted_account_arr
declare -a account_arr
LOCK_DIR="/tmp/${script_name%%.*}.LOCK"
log_file="${LOCK_DIR}/${script_name%%.*}.log"
run_date=$(date +%Y-%m-%d-%H%M)
# =============
# --- Some functions
# =============
usage() {
[[ -n "$1" ]] && error "$1"
[[ $terminal ]] && echo -e "
\033[1mUsage:\033[m
$(basename $0) -s <website> -f <pathlist-file>
\033[1mDescription\033[m
Generic, reusable companion to restore_bad_signature.sh: deletes an
explicit, hand-picked list of files from a Nextcloud instance's live
storage, through Nextcloud's normal Files API - the same delete path
the web interface, WebDAV, or a sync client uses. If the
'files_trashbin' app is enabled (the default), the file ends up in
that account's Trash, not gone outright.
This is a plain, general-purpose tool - NOT specific to any one
account, site, or incident. It is meant for the second half of the
'bad signature' recovery workflow: after recover_bad_signature.sh
and restore_bad_signature.sh have taken care of every file that
validates cleanly or at least plausibly, a handful of files
sometimes remain that a human should look at and decide on one by
one (system/index files that don't belong in the cloud at all,
fragments from a browser 'save page' action, or anything else you
simply don't want kept). Once you've decided, list their paths in a
plain text file and point this script at it.
IMPORTANT:
- The path list file (-f) is plain text, one Nextcloud path per
line, in the SAME format used throughout this toolkit's own
reports (the 'path' column, e.g.
'/inge/files/Some/Folder/file.ext' - '/<uid>/files/...'). Blank
lines and lines starting with '#' are ignored. The account
(uid) is taken from each path's own first segment, so one list
can freely mix files from several accounts on the same site -
no separate account selection is needed.
- Nothing is guessed or auto-selected: every path this script
acts on is one YOU explicitly listed. There is no scanning, no
heuristic, no 'delete everything that looks like junk' mode
here - that judgment call happens before this script runs, not
inside it.
- Before each file is deleted (unless dry-run), the CURRENT
on-disk ciphertext is copied byte-for-byte to a separate backup
directory (default ${DEFAULT_DELETE_BACKUP_BASE_DIR}/<website>,
override with DELETE_BACKUP_BASE_DIR in the conf file) - a
plain filesystem copy that bypasses Nextcloud/encryption and
Trash entirely. This only works for local/default storage; it
is best-effort and a missing source is reported but does not
abort the run.
- The delete itself goes through Nextcloud's normal Files API
(\$node->delete()), so all of Nextcloud's own rules still
apply: if 'files_trashbin' is enabled the file is moved to that
account's Trash (recoverable there, subject to the instance's
own Trash retention settings); if it is disabled, the delete is
immediate and permanent.
- A path already absent (no matching file found) is reported as
NOT_FOUND, not as an error - nothing to do there.
Unless '-n' is given on the command line, you will be asked
interactively whether to run a dry-run (nothing is touched, just
reports what would happen) or the real thing.
\033[1mOptions\033[m
-s <website>
The site of the nextcloud instance.
-f <pathlist-file>
Plain text file with one Nextcloud path to delete per line (see
above). Required - if omitted, you will be asked for it.
-n
Dry-run: goes through selection and reporting, but does NOT back
up or delete anything. Passing it here skips the interactive mode
question mentioned above.
\033[1mExample:\033[m
Runs this script on system 'cloud-01.oopen.de'
$(basename $0) -s cloud-01.oopen.de -f /root/to_delete.txt
Dry-run only, nothing is deleted:
$(basename $0) -n -s cloud-01.oopen.de -f /root/to_delete.txt
"
clean_up 1
}
clean_up() {
# Perform program exit housekeeping
[[ -n "$delete_php_file" ]] && rm -f "$delete_php_file" 2> /dev/null
rm -rf "$LOCK_DIR"
blank_line
exit $1
}
echononl(){
if $terminal ; then
echo X\\c > /tmp/shprompt$$
if [ `wc -c /tmp/shprompt$$ | awk '{print $1}'` -eq 1 ]; then
echo -e -n "$*\\c" 1>&2
else
echo -e -n "$*" 1>&2
fi
rm /tmp/shprompt$$
fi
}
echo_done() {
if $terminal ; then
echo -e "\033[75G[ \033[32mdone\033[m ]"
fi
}
echo_ok() {
if $terminal ; then
echo -e "\033[75G[ \033[32mok\033[m ]"
fi
}
echo_warning() {
if $terminal ; then
echo -e "\033[75G[ \033[33m\033[1mwarn\033[m ]"
fi
}
echo_failed(){
if $terminal ; then
echo -e "\033[75G[ \033[1;31mfailed\033[m ]"
fi
}
echo_skipped() {
if $terminal ; then
echo -e "\033[75G[ \033[37mskipped\033[m ]"
fi
}
fatal (){
echo ""
echo ""
if $terminal ; then
echo -e " [ \033[31m\033[1mFatal\033[m ]: \033[37m\033[1m$*\033[m"
echo ""
echo -e " \033[31m\033[1mScript will be interrupted..\033[m!"
else
echo " [ Fatal ]: $*"
echo ""
echo " Script was terminated...."
fi
clean_up 1
}
error(){
echo ""
if $terminal ; then
echo -e " [ \033[31m\033[1mError\033[m ]: $*"
else
echo " [ Error ]: $*"
fi
echo ""
}
warn (){
if $terminal ; then
echo ""
echo -e " [ \033[33m\033[1mWarning\033[m ]: $*"
echo ""
fi
}
info (){
if $terminal ; then
echo ""
echo -e " [ \033[32m\033[1mInfo\033[m ]: $*"
echo ""
fi
}
# - Remove leading/trailling whitespaces
# -
trim() {
local var="$*"
var="${var#"${var%%[![:space:]]*}"}" # remove leading whitespace characters
var="${var%"${var##*[![:space:]]}"}" # remove trailing whitespace characters
echo -n "$var"
}
## - Percentage (1 decimal) of parameter 1 (part) against parameter 2 (total)
## - Returns "0.0" if total is empty, zero or non-numeric.
## -
calc_percent() {
local _part="$1"
local _total="$2"
if [[ -z "$_total" ]] || ! [[ "$_total" =~ ^[0-9]+$ ]] || [[ "$_total" -eq 0 ]] ; then
echo "0.0"
else
awk -v b="$_part" -v t="$_total" 'BEGIN { printf "%.1f", (b/t)*100 }'
fi
}
blank_line() {
if $terminal ; then
echo ""
fi
}
# - Running in a terminal?
# -
if [[ -t 1 ]] ; then
terminal=true
else
terminal=false
fi
# - This script needs root privileges (reads the Nextcloud data
# - directory for the raw ciphertext backup, 'su' into the webserver
# - user to perform the actual delete).
# -
if [[ "$(id -u)" -ne 0 ]] ; then
fatal "This script must be run as root (it needs to read the Nextcloud data directory for backups and 'su' into the webserver user). Please re-run as root, e.g. via sudo."
fi
# ----------
# - Jobhandling
# ----------
if pgrep -f "$(basename $0)" | grep -q -v $$ ; then
msg="A previos instance of script \"`basename $0`\" seems already be running."
echo ""
if $terminal ; then
echo -e "[ \033[31m\033[1mFatal\033[m ]: $msg"
echo ""
echo -e " \033[31m\033[1mScript was interupted\033[m!"
else
echo " [ Fatal ]: $msg"
echo ""
echo " Script was interupted!"
fi
echo
exit 1
else
if [[ -d "$LOCK_DIR" ]] ; then
rm -rf "$LOCK_DIR" 2> /dev/null
fi
fi
# - If job already runs, stop execution..
# -
if mkdir "$LOCK_DIR" 2> /dev/null ; then
# - Remove lockdir when the script finishes, or when it receives a signal
# -
trap clean_up SIGHUP SIGINT SIGTERM
else
msg="A previos instance of script \"`basename $0`\" seems already be running."
echo ""
if $terminal ; then
echo -e "[ \033[31m\033[1mFatal\033[m ]: $msg"
echo ""
echo -e " \033[31m\033[1mScript was interupted\033[m!"
else
echo " [ Fatal ]: $msg"
echo ""
echo " Script was interupted!"
fi
echo
exit 1
fi
# -------------
# - Read in Commandline arguments
# -------------
DRY_RUN=false
_dry_run_explicit=false
PATHLIST_FILE=""
while getopts hns:f: opt ; do
case $opt in
h) usage ;;
n) DRY_RUN=true; _dry_run_explicit=true ;;
s) WEBSITE=$OPTARG ;;
f) PATHLIST_FILE=$OPTARG ;;
\?) usage
esac
done
# =============
# --- Ask which mode to run in, unless '-n' was already given on the
# --- command line. The SAFE choice (dry-run) is the default here on
# --- purpose - this script deletes from live Nextcloud storage.
# =============
if ! $_dry_run_explicit && $terminal ; then
blank_line
echo -e "\033[37m\033[1mWhich mode should this run use?\033[m"
echo ""
echo -e " \033[1m[1] Dry-run\033[m - go through the list and report what would happen, but touch NOTHING"
echo ""
echo " [2] Real delete - actually back up and delete each listed file in Nextcloud"
info "Just press Return to use the default: [1] Dry-run."
echo -n " Select mode by number [1]: "
read _mode_choice
case "$(trim "$_mode_choice")" in
2) DRY_RUN=false ;;
""|1) DRY_RUN=true ;;
*) fatal "Invalid selection '$_mode_choice'." ;;
esac
fi
if [[ -z "$PATHLIST_FILE" ]] ; then
blank_line
echo -n " Path to the file listing the Nextcloud paths to delete (-f): "
read PATHLIST_FILE
fi
PATHLIST_FILE="$(trim "$PATHLIST_FILE")"
if [[ -z "$PATHLIST_FILE" ]] ; then
fatal "No path list file given."
fi
if [[ ! -f "$PATHLIST_FILE" ]] ; then
fatal "Path list file '$PATHLIST_FILE' not found."
fi
if [[ -z "$WEBSITE" ]] ; then
while IFS='' read -r -d '' _conf_file ; do
source $_conf_file
if [[ -n "$WEBSITE" ]] ; then
unsorted_website_arr+=("${WEBSITE}:$_conf_file")
fi
WEBSITE=""
done < <(find "${conf_dir}" -maxdepth 1 -type f -name "*.conf" -print0)
# - Sort array
# -
IFS=$'\n' website_arr=($(sort <<<"${unsorted_website_arr[*]}"))
# Which cloud instance (website) would you like to update
#
source ${snippet_dir}/get-cloud-instance-to-update.sh
else
while IFS='' read -r -d '' _conf_file ; do
if $(grep -E -q "WEBSITE=\"?${WEBSITE}\"?" ${_conf_file} 2> /dev/null) ; then
conf_file="${_conf_file}"
break
fi
done < <(find "${conf_dir}" -maxdepth 1 -type f -name "*.conf" -print0)
fi
# - Reset IFS
# -
IFS=$CUR_IFS
DEFAULT_SRC_BASE_DIR="/usr/local/src/nextcloud"
DEFAULT_HTTP_USER="www-data"
DEFAULT_HTTP_GROUP="www-data"
DEFAULT_PHP_ENGINE='FPM'
blank_line
echononl " Include Configuration file '$(basename "${conf_file}")'.."
if [[ ! -f $conf_file ]]; then
echo_skipped
fatal "Missing configuration file '$conf_file'."
else
source $conf_file
echo_ok
fi
DEFAULT_WEB_BASE_DIR="/var/www/${WEBSITE}"
[[ -n "$WEB_BASE_DIR" ]] || WEB_BASE_DIR=$DEFAULT_WEB_BASE_DIR
if [[ ! -d ${WEB_BASE_DIR} ]] ; then
fatal "Web base directory '$WEB_BASE_DIR' not found!"
fi
DATA_DIR="$(realpath ${WEB_BASE_DIR}/data)"
[[ -n "$PHP_ENGINE" ]] || PHP_ENGINE=$DEFAULT_PHP_ENGINE
INSTALL_DIR="$(realpath ${WEB_BASE_DIR}/nextcloud)"
CURRENT_VERSION="$(basename $INSTALL_DIR | cut -d"-" -f2)"
[[ -n "$DELETE_BACKUP_BASE_DIR" ]] || DELETE_BACKUP_BASE_DIR=$DEFAULT_DELETE_BACKUP_BASE_DIR
backup_dir="${DELETE_BACKUP_BASE_DIR}/${WEBSITE}"
# =============
# --- Some
# =============
# - Support systemd ?
# -
SYSTEMD_EXISTS=false
systemd=$(which systemd)
systemctl=$(which systemctl)
if [[ -n "$systemd" ]] || [[ -n "$systemctl" ]] ; then
SYSTEMD_EXISTS=true
fi
if $terminal ; then
echo ""
echo -e "\033[32m-----\033[m"
echo -e "Delete an explicit, hand-picked list of files from system \033[1m${WEB_BASE_DIR}\033[m"
echo -e "\033[1m
This deletes from Nextcloud's live storage (goes to that account's
Trash if 'files_trashbin' is enabled). Only the exact paths listed
in '${PATHLIST_FILE}' are ever touched - nothing is scanned,
guessed or auto-selected. The current on-disk ciphertext is backed
up first, independent of Nextcloud/Trash entirely.\033[m"
echo -e "\033[32m-----\033[m"
fi
# =============
# --- Some checks
# =============
DEFAULT_HTTP_USER="www-data"
DEFAULT_HTTP_GROUP="www-data"
NGINX_IS_ENABLED=false
APACHE2_IS_ENABLED=false
# Get Webservice environment as IS_HTTPD_RUNNING, HTTP_USER, HTTP_GROUP..
#
source ${snippet_dir}/get-webservice-environment.sh
# Check PHP Version
#
source ${snippet_dir}/get-php-major-version.sh
# Get full qualified PHP command
#
source ${snippet_dir}/get-path-of-php-command.sh
if [[ ! -x "$PHP_BIN" ]]; then
fatal "No PHP binary found!"
fi
# =============
# --- Read and parse the path list file: one Nextcloud path per line,
# --- blank lines and '#' comments ignored. The account (uid) for each
# --- path is taken from the path's own first segment
# --- ('/<uid>/files/...'), so one list can span several accounts.
# =============
parsed_entries_file="${LOCK_DIR}/parsed_entries.tsv"
declare -i total_malformed=0
declare -a malformed_lines=()
> "$parsed_entries_file"
while IFS= read -r _line || [[ -n "$_line" ]] ; do
_line="$(trim "$_line")"
[[ -z "$_line" ]] && continue
[[ "$_line" == \#* ]] && continue
if [[ "$_line" =~ ^/([^/]+)/files/ ]] ; then
_uid="${BASH_REMATCH[1]}"
printf '%s\t%s\n' "$_uid" "$_line" >> "$parsed_entries_file"
else
(( total_malformed++ ))
malformed_lines+=("$_line")
fi
done < "$PATHLIST_FILE"
if [[ ! -s "$parsed_entries_file" ]] ; then
fatal "No usable paths found in '$PATHLIST_FILE' (expected one '/<uid>/files/...' path per line)."
fi
unsorted_account_arr=($(cut -f1 "$parsed_entries_file" | sort -u))
IFS=$'\n' account_arr=($(sort <<<"${unsorted_account_arr[*]}"))
IFS=$CUR_IFS
blank_line
if $terminal ; then
echo -e "\033[37m\033[1mAccounts found in '$(basename "$PATHLIST_FILE")'\033[m"
echo ""
for _a in "${account_arr[@]}" ; do
_a_count=$(awk -F'\t' -v u="$_a" '$1==u' "$parsed_entries_file" | wc -l)
echo " - $_a (${_a_count})"
done
echo ""
if [[ $total_malformed -gt 0 ]] ; then
warn "${total_malformed} line(s) in '$PATHLIST_FILE' did not look like a '/<uid>/files/...' path and were IGNORED:"
for _l in "${malformed_lines[@]}" ; do
echo " $_l"
done
fi
fi
# =============
# --- Confirmation
# =============
mkdir -p "$report_dir" 2> /dev/null
delete_report_file="${report_dir}/delete_${WEBSITE}_${run_date}.tsv"
if $terminal ; then
echo ""
if $DRY_RUN ; then
echo -e "\033[1;32mStarting DRY-RUN delete for \033[1;37m${WEBSITE}\033[m"
else
echo -e "\033[1;31m\033[1mStarting REAL delete (removes from live Nextcloud storage) for \033[1;37m${WEBSITE}\033[m"
fi
echo ""
echo -e " Cloud instance..........................: $WEBSITE"
echo -e " Path list file...........................: $PATHLIST_FILE"
echo -e " Accounts affected........................: \033[33m${account_arr[*]}\033[m"
echo ""
if $DRY_RUN ; then
echo -e " Nothing will be touched - dry-run only."
else
echo -e " Ciphertext backup (before delete)........: $backup_dir"
echo -e " Files deleted at their ORIGINAL path in Nextcloud (-> Trash, if enabled)."
fi
echo -e " Delete report.............................: reports/$(basename "${delete_report_file}")"
echo ""
if $DRY_RUN ; then
info "Dry-run: nothing is backed up or deleted."
else
warn "This DELETES from Nextcloud's live storage (through the normal Files API - goes to Trash if 'files_trashbin' is enabled). Only the exact paths listed in '${PATHLIST_FILE}' are touched. The current on-disk ciphertext is copied byte-for-byte to '${backup_dir}' first, independent of Nextcloud/Trash entirely."
fi
echo ""
echo -n " Type upper case 'YES' to continue executing with this parameters: "
read OK
if [[ "$OK" = "YES" ]] ; then
echo ""
echo ""
echo -e "\033[1;32mGoing to delete the listed files for each affected account on \033[1;37m$WEBSITE \033[m"
else
fatal "Abort by user request - Answer as not 'YES'"
fi
fi
{
echo "=================================================================="
echo " Delete-Versuch (Liste) ${WEBSITE} ${run_date}"
echo "=================================================================="
echo ""
echo " Pfadliste: $(basename "$PATHLIST_FILE")"
echo " Nur die dort explizit aufgefuehrten Pfade werden geloescht."
$DRY_RUN && echo " DRY-RUN: es wurde NICHTS gesichert oder geloescht."
! $DRY_RUN && echo " Chiffretext-Sicherung (vor dem Loeschen) liegt unter: ${backup_dir}"
[[ $total_malformed -gt 0 ]] && echo " ${total_malformed} Zeile(n) aus der Pfadliste wurden ignoriert (kein '/<uid>/files/...'-Pfad)."
echo ""
echo -e "path\tstatus\tdetail"
} > "$delete_report_file"
# =============
# --- Write the embedded PHP delete script
# =============
delete_php_file="${INSTALL_DIR}/.delete_files_$$.php"
cat > "$delete_php_file" <<'PHP_DELETE_EOF'
<?php
/**
* delete_files.php
*
* Deletes an explicit list of files from Nextcloud's own storage, at
* their ORIGINAL path, through the normal Files API ($node->delete())
* - the same delete path the web interface, WebDAV, or a sync client
* uses. If the 'files_trashbin' app is enabled, Nextcloud moves the
* file to that account's Trash itself; this script does not bypass
* that.
*
* Usage: php delete_files.php <uid> <listFile> [--dry-run]
* listFile: one Nextcloud path per line (no header, no other columns)
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
register_shutdown_function(function () {
$err = error_get_last();
if ($err !== null && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
fwrite(STDERR, "DEBUG: FATAL bei Shutdown: [{$err['type']}] {$err['message']} in {$err['file']}:{$err['line']}\n");
}
});
define('OC_CONSOLE', 1);
$oldWorkingDir = getcwd();
if ($oldWorkingDir === false) {
fwrite(STDERR, "Konnte aktuelles Arbeitsverzeichnis nicht ermitteln. Bitte mit absolutem Pfad aufrufen.\n");
exit(1);
}
chdir(__DIR__);
require_once __DIR__ . '/lib/base.php';
chdir($oldWorkingDir);
if (function_exists('posix_getuid') && posix_getuid() === 0) {
fwrite(STDERR, "Bitte NICHT als root ausfuehren, sondern als Webserver-User.\n");
exit(1);
}
try {
$uid = $argv[1] ?? null;
$listFile = $argv[2] ?? null;
$dryRun = in_array('--dry-run', $argv, true);
if ($uid === null || $listFile === null) {
fwrite(STDERR, "Usage: php delete_files.php <uid> <listFile> [--dry-run]\n");
exit(1);
}
if (!is_file($listFile)) {
fwrite(STDERR, "Liste nicht gefunden: $listFile\n");
exit(1);
}
$rootFolder = \OC::$server->get(\OCP\Files\IRootFolder::class);
\OC_Util::setupFS($uid);
$userFolder = $rootFolder->getUserFolder($uid);
$lines = file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
$lines = [];
}
$total = 0;
$deleted = 0;
$notFound = 0;
$failed = 0;
echo "path\tstatus\tdetail\n";
foreach ($lines as $path) {
$path = trim($path);
if ($path === '') {
continue;
}
$total++;
try {
$node = null;
try {
$node = $rootFolder->get($path);
} catch (\Throwable $e) {
$node = null;
}
if ($node === null) {
$notFound++;
echo "$path\tNOT_FOUND\talready absent - nothing to delete\n";
flush();
continue;
}
if ($dryRun) {
$deleted++;
$size = ($node instanceof \OCP\Files\File) ? $node->getSize() : 0;
echo "$path\tDRY_RUN\twould delete now (${size} bytes) - goes to Trash if 'files_trashbin' is enabled\n";
flush();
continue;
}
$node->delete();
// Verify: the normal read path should no longer find it
// (it may still exist in Trash - that is Nextcloud's own
// behaviour, not a failure of this delete).
clearstatcache();
$stillThere = null;
try {
$stillThere = $rootFolder->get($path);
} catch (\Throwable $e) {
$stillThere = null;
}
if ($stillThere === null) {
$deleted++;
echo "$path\tOK\tdeleted (moved to Trash, if 'files_trashbin' is enabled)\n";
} else {
$failed++;
echo "$path\tDELETE_ERROR\tdelete() did not throw, but the path is still resolvable afterwards\n";
}
flush();
} catch (\Throwable $e) {
$failed++;
$msg = str_replace(["\t", "\n", "\r"], ' ', $e->getMessage());
echo "$path\tDELETE_ERROR\t" . get_class($e) . ": $msg\n";
flush();
}
}
fwrite(STDERR, "\nFertig. Dateien verarbeitet: $total, geloescht: $deleted, nicht gefunden: $notFound, Fehler: $failed\n");
} catch (\Throwable $e) {
fwrite(STDERR, "\n!!! UNBEHANDELTE AUSNAHME !!!\n");
fwrite(STDERR, get_class($e) . ": " . $e->getMessage() . "\n");
fwrite(STDERR, "in " . $e->getFile() . ":" . $e->getLine() . "\n");
fwrite(STDERR, $e->getTraceAsString() . "\n");
exit(2);
}
PHP_DELETE_EOF
chmod 644 "$delete_php_file"
if ! $DRY_RUN ; then
mkdir -p "$backup_dir" 2> /dev/null
fi
declare -i total_selected=0
declare -i total_ok=0
declare -i total_not_found=0
declare -i total_errors=0
declare -i total_failed_users=0
for _user in "${account_arr[@]}" ; do
_sep_len=${#_user}
[[ $_sep_len -lt 9 ]] && _sep_len=9
_sep_line="$(printf '%*s' "$_sep_len" '' | tr ' ' '-')"
{
echo ""
echo ""
echo "$_sep_line"
echo "$_user"
echo "$_sep_line"
} >> "$delete_report_file"
declare -i _user_selected=0
list_file="${LOCK_DIR}/list_${_user}.tsv"
> "$list_file"
while IFS=$'\t' read -r _u _path ; do
[[ "$_u" != "$_user" ]] && continue
(( _user_selected++ ))
if ! $DRY_RUN ; then
_rel="${_path#/${_user}/files/}"
_orig_ciphertext="${DATA_DIR}/${_user}/files/${_rel}"
if [[ -f "$_orig_ciphertext" ]] ; then
_backup_target="${backup_dir}/${_user}/${_rel}"
mkdir -p "$(dirname "$_backup_target")" 2> /dev/null
cp -p "$_orig_ciphertext" "$_backup_target" 2> /dev/null
fi
fi
printf '%s\n' "$_path" >> "$list_file"
done < "$parsed_entries_file"
total_selected+=$_user_selected
user_result_tsv="${LOCK_DIR}/result_${_user}.tsv"
if $DRY_RUN ; then
echononl " Dry-run for account \033[1;37m${_user}\033[m (${_user_selected} to check).."
else
echononl " Deleting for account \033[1;37m${_user}\033[m (${_user_selected} to delete).."
fi
if [[ -s "$list_file" ]] ; then
if $DRY_RUN ; then
su -c "$PHP_BIN $delete_php_file $_user $list_file --dry-run" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file"
else
su -c "$PHP_BIN $delete_php_file $_user $list_file" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file"
fi
_rc=$?
else
echo -e "path\tstatus\tdetail" > "$user_result_tsv"
_rc=0
fi
if [[ $_rc -ne 0 ]]; then
echo_failed
error "$(cat "$log_file")"
echo " [ FEHLER beim Delete-Lauf - siehe Server-Log ]" >> "$delete_report_file"
(( total_failed_users++ ))
unset _user_selected
continue
fi
echo_done
$terminal && echo ""
declare -i _user_ok=0
declare -i _user_not_found=0
declare -i _user_errors=0
declare -a _user_error_lines=()
while IFS=$'\t' read -r _path _status _detail ; do
[[ "$_path" = "path" ]] && continue
[[ -z "$_path" ]] && continue
case "$_status" in
OK|DRY_RUN) (( _user_ok++ )) ;;
NOT_FOUND) (( _user_not_found++ )) ;;
*) (( _user_errors++ )); _user_error_lines+=("${_path}"$'\t'"${_status}: ${_detail}") ;;
esac
printf '%s\t%s\t%s\n' "$_path" "$_status" "$_detail" >> "$delete_report_file"
done < "$user_result_tsv"
_user_pct_ok="$(calc_percent "$_user_ok" "$_user_selected")"
if $terminal ; then
echo -e " \033[1;37mAccount ${_user}\033[m"
echo -e " Ausgewaehlt (aus der Pfadliste)............: ${_user_selected}"
if $DRY_RUN ; then
echo -e " Wuerde geloescht werden.....................: \033[1;32m${_user_ok} (${_user_pct_ok} %)\033[m"
else
if [[ $_user_ok -gt 0 ]] ; then
echo -e " Geloescht....................................: \033[1;32m${_user_ok} (${_user_pct_ok} %)\033[m"
else
echo -e " Geloescht....................................: ${_user_ok} (${_user_pct_ok} %)"
fi
fi
if [[ $_user_not_found -gt 0 ]] ; then
echo -e " Bereits nicht mehr vorhanden................: ${_user_not_found}"
fi
if [[ $_user_errors -gt 0 ]] ; then
echo -e " Fehler beim Loeschen.........................: \033[1;31m${_user_errors}\033[m"
fi
echo ""
fi
{
if [[ $_user_errors -gt 0 ]] ; then
echo ""
echo " ------------------------------------------------------------------"
echo " Fehler beim Loeschen - Account ${_user} (${_user_errors})"
echo " ------------------------------------------------------------------"
for _line in "${_user_error_lines[@]}" ; do
printf ' %s\n' "$_line"
done
fi
} >> "$delete_report_file"
{
echo ""
echo " Account ${_user}"
echo " Ausgewaehlt (aus der Pfadliste)............: ${_user_selected}"
if $DRY_RUN ; then
echo " Wuerde geloescht werden.....................: ${_user_ok} (${_user_pct_ok} %)"
else
echo " Geloescht....................................: ${_user_ok} (${_user_pct_ok} %)"
fi
[[ $_user_not_found -gt 0 ]] && echo " Bereits nicht mehr vorhanden................: ${_user_not_found}"
[[ $_user_errors -gt 0 ]] && echo " Fehler beim Loeschen.........................: ${_user_errors}"
} >> "$delete_report_file"
(( total_ok += _user_ok ))
(( total_not_found += _user_not_found ))
(( total_errors += _user_errors ))
unset _user_selected _user_ok _user_not_found _user_errors _user_error_lines
done
total_pct_ok="$(calc_percent "$total_ok" "$total_selected")"
{
echo ""
echo ""
echo "=================================================================="
echo " Gesamtergebnis"
echo "=================================================================="
echo ""
echo "Betroffene Accounts..........................: ${#account_arr[@]}"
[[ $total_failed_users -gt 0 ]] && echo "Accounts mit Delete-Fehler...................: ${total_failed_users}"
echo "Ausgewaehlt (aus der Pfadliste)..............: ${total_selected}"
if $DRY_RUN ; then
echo "Wuerde geloescht werden insgesamt............: ${total_ok} (${total_pct_ok} %)"
else
echo "Geloescht insgesamt...........................: ${total_ok} (${total_pct_ok} %)"
fi
[[ $total_not_found -gt 0 ]] && echo "Bereits nicht mehr vorhanden insgesamt.......: ${total_not_found}"
[[ $total_errors -gt 0 ]] && echo "Fehler beim Loeschen insgesamt................: ${total_errors}"
echo ""
if $DRY_RUN ; then
echo "DRY-RUN: es wurde nichts gesichert oder geloescht."
else
echo "Chiffretext-Sicherung (vor dem Loeschen) liegt unter: ${backup_dir}"
fi
} >> "$delete_report_file"
blank_line
if $terminal ; then
echo -e "\033[37m\033[1mErgebnis\033[m"
echo ""
echo -e " Betroffene Accounts..........................: ${#account_arr[@]}"
[[ $total_failed_users -gt 0 ]] && echo -e " Accounts mit Delete-Fehler...................: \033[1;31m${total_failed_users}\033[m"
echo -e " Ausgewaehlt (aus der Pfadliste)..............: ${total_selected}"
if $DRY_RUN ; then
echo -e " Wuerde geloescht werden insgesamt............: \033[1;32m${total_ok} (${total_pct_ok} %)\033[m"
else
if [[ $total_ok -gt 0 ]] ; then
echo -e " Geloescht insgesamt...........................: \033[1;32m${total_ok} (${total_pct_ok} %)\033[m"
else
echo -e " Geloescht insgesamt...........................: ${total_ok} (${total_pct_ok} %)"
fi
fi
[[ $total_not_found -gt 0 ]] && echo -e " Bereits nicht mehr vorhanden insgesamt.......: ${total_not_found}"
[[ $total_errors -gt 0 ]] && echo -e " Fehler beim Loeschen insgesamt................: \033[1;31m${total_errors}\033[m"
echo ""
echo -e " Delete-Report.................................: reports/$(basename "${delete_report_file}")"
if ! $DRY_RUN ; then
echo -e " Chiffretext-Sicherung.........................: $backup_dir"
fi
echo ""
if $DRY_RUN ; then
info "Dry-run beendet - es wurde nichts geloescht. Ohne '-n' (oder mit [2] bei der Modusabfrage) fuer den echten Delete erneut ausfuehren."
else
warn "Bitte pruefen, ob die geloeschten Dateien wie erwartet im Trash der jeweiligen Accounts liegen (sofern 'files_trashbin' aktiviert ist)."
fi
fi
clean_up 0
+36 -9
View File
@@ -48,17 +48,30 @@ usage() {
fix - these are NOT signature problems, they are Server-Side fix - these are NOT signature problems, they are Server-Side
Encryption KEY-MATERIAL problems (a per-user 'share key' for a file Encryption KEY-MATERIAL problems (a per-user 'share key' for a file
is either missing on disk or fails to decrypt with that user's is either missing on disk or fails to decrypt with that user's
private key). This script accepts either a recovery_*.tsv or a private key).
restore_*.tsv report as input.
\033[1mThis script accepts either of two report types as input, and\033[m
\033[1mevaluates a different row/status from each:\033[m
\033[1mrecovery_*.tsv\033[m report (from recover_bad_signature.sh)
-> evaluates every row whose validation is \033[1mREAD_ERROR\033[m
\033[1mrestore_*.tsv\033[m report (from restore_bad_signature.sh)
-> evaluates every row whose status is \033[1mWRITE_ERROR\033[m
This script changes NOTHING. It never decrypts a file, never This script changes NOTHING. It never decrypts a file, never
touches 'encryption_skip_signature_check', and never writes touches 'encryption_skip_signature_check', and never writes
anywhere except its own report file. It only: anywhere except its own report file. It only:
1. Reads a chosen recovery_*.tsv report (produced by 1. Reads the chosen report and picks out the matching rows:
recover_bad_signature.sh) and picks out every row whose
validation is READ_ERROR and whose detail text matches one of - from a \033[1mrecovery_*.tsv\033[m report: every row whose
the two known key-material error signatures above. validation is \033[1mREAD_ERROR\033[m
- from a \033[1mrestore_*.tsv\033[m report: every row whose
status is \033[1mWRITE_ERROR\033[m
...and, in both cases, whose detail text matches one of the
two known key-material error signatures above.
2. For each such file, resolves - via Nextcloud's normal, 2. For each such file, resolves - via Nextcloud's normal,
read-only Files API (\$node->getOwner(), \$folder->getById()) read-only Files API (\$node->getOwner(), \$folder->getById())
- who currently owns the file, i.e. whether the affected - who currently owns the file, i.e. whether the affected
@@ -405,8 +418,11 @@ if $terminal ; then
Nextcloud storage and never touches 'encryption_skip_signature_check'. Nextcloud storage and never touches 'encryption_skip_signature_check'.
It only inspects, on disk and via the normal Files API, whether the It only inspects, on disk and via the normal Files API, whether the
expected encryption key files are present for accounts/files that expected encryption key files are present for accounts/files that
recover_bad_signature.sh could not read (READ_ERROR, not 'Bad recover_bad_signature.sh or restore_bad_signature.sh could not
Signature').\033[m" process (not 'Bad Signature').\033[m"
echo ""
echo -e " \033[1mrecovery_*.tsv\033[m report -> evaluates \033[1mREAD_ERROR\033[m rows"
echo -e " \033[1mrestore_*.tsv\033[m report -> evaluates \033[1mWRITE_ERROR\033[m rows"
echo -e "\033[32m-----\033[m" echo -e "\033[32m-----\033[m"
fi fi
@@ -629,6 +645,13 @@ if ($oldWorkingDir === false) {
} }
chdir(__DIR__); chdir(__DIR__);
require_once __DIR__ . '/lib/base.php'; require_once __DIR__ . '/lib/base.php';
// Nextcloud's own bootstrap (lib/base.php) unconditionally calls
// set_time_limit(3600) as part of the require above - this OVERRIDES
// whatever -d max_execution_time was passed on the PHP command line,
// since it is a later, explicit runtime call. Undo that here, now that
// the require is done, so diagnosing a large number of files isn't
// killed after exactly one hour regardless of the CLI flag.
set_time_limit(0);
chdir($oldWorkingDir); chdir($oldWorkingDir);
if (function_exists('posix_getuid') && posix_getuid() === 0) { if (function_exists('posix_getuid') && posix_getuid() === 0) {
@@ -775,7 +798,11 @@ for _user in "${selected_user_arr[@]}" ; do
owner_result_tsv="${LOCK_DIR}/owner_${_user}.tsv" owner_result_tsv="${LOCK_DIR}/owner_${_user}.tsv"
echononl " Resolving ownership for account \033[1;37m${_user}\033[m (${_user_selected} files).." echononl " Resolving ownership for account \033[1;37m${_user}\033[m (${_user_selected} files).."
su -c "$PHP_BIN $diag_php_file $_user $path_list_file" -s /bin/bash $HTTP_USER > "$owner_result_tsv" 2> "$log_file" # -d max_execution_time=0: resolving ownership/key-material for a
# large number of files can run well over an hour; without this the
# PHP CLI process is killed by PHP's own execution-time limit
# (typically inherited from the webserver's php.ini) mid-run.
su -c "$PHP_BIN -d max_execution_time=0 $diag_php_file $_user $path_list_file" -s /bin/bash $HTTP_USER > "$owner_result_tsv" 2> "$log_file"
_rc=$? _rc=$?
if [[ $_rc -ne 0 ]]; then if [[ $_rc -ne 0 ]]; then
+284 -14
View File
@@ -148,6 +148,8 @@ restore_encryption_flag() {
clean_up() { clean_up() {
# Perform program exit housekeeping # Perform program exit housekeeping
# Clear EXIT trap first so that the exit below does not fire it again.
trap - EXIT
restore_encryption_flag restore_encryption_flag
[[ -n "$recovery_php_file" ]] && rm -f "$recovery_php_file" 2> /dev/null [[ -n "$recovery_php_file" ]] && rm -f "$recovery_php_file" 2> /dev/null
rm -rf "$LOCK_DIR" rm -rf "$LOCK_DIR"
@@ -155,6 +157,82 @@ clean_up() {
exit $1 exit $1
} }
check_optional_validation_tools() {
# Checks whether optional tools that improve file-validation quality
# are installed. In interactive mode the user is offered to install
# any that are missing via apt. In non-interactive mode the function
# is a quiet no-op (missing tools degrade quality but do not abort).
local _missing_tools=()
local _missing_pkgs=()
local _desc=()
command -v identify > /dev/null 2>&1 || {
_missing_tools+=("identify")
_missing_pkgs+=("imagemagick")
_desc+=("identify (imagemagick) — PNG / TIFF / BMP / GIF: vollst. Dekodierung statt nur Magic-Bytes")
}
command -v ffprobe > /dev/null 2>&1 || {
_missing_tools+=("ffprobe")
_missing_pkgs+=("ffmpeg")
_desc+=("ffprobe (ffmpeg) — MP4 / MOV / M4V: Container-Parsing statt nur ftyp-Box-Suche")
}
command -v mp3val > /dev/null 2>&1 || {
_missing_tools+=("mp3val")
_missing_pkgs+=("mp3val")
_desc+=("mp3val (mp3val) — MP3: Frame-Struktur-Check statt nur ID3-Signatur")
}
command -v flac > /dev/null 2>&1 || {
_missing_tools+=("flac")
_missing_pkgs+=("flac")
_desc+=("flac (flac) — FLAC: Decode-Test statt nur fLaC-Signatur")
}
command -v ogginfo > /dev/null 2>&1 || {
_missing_tools+=("ogginfo")
_missing_pkgs+=("vorbis-tools")
_desc+=("ogginfo (vorbis-tools) — OGG / OGA / OPUS: Container-Check statt nur OggS-Signatur")
}
[[ ${#_missing_tools[@]} -eq 0 ]] && return 0
if ! $terminal ; then
return 0
fi
local _pkg_list="${_missing_pkgs[*]}"
echo ""
echo -e " \033[33mOptionale Validierungs-Tools fehlen – Checks laufen mit reduzierter Genauigkeit:\033[m"
echo ""
local _d
for _d in "${_desc[@]}" ; do
echo " ${_d}"
done
echo ""
echo -n " Jetzt installieren? apt install ${_pkg_list} [j/N]: "
read -r _yn
echo ""
if [[ "$_yn" =~ ^[jJyY]$ ]] ; then
echononl " Installiere Pakete: ${_pkg_list}.."
# shellcheck disable=SC2086
if apt-get install -y ${_missing_pkgs[*]} > /dev/null 2>&1 ; then
echo_ok
else
echo_failed
echo ""
echo " Bitte manuell installieren:"
echo " apt install ${_pkg_list}"
fi
else
echo " Übersprungen — Validierung läuft mit reduzierter Genauigkeit."
fi
echo ""
}
is_number() { is_number() {
return $(test ! -z "${1##*[!0-9]*}" > /dev/null 2>&1); return $(test ! -z "${1##*[!0-9]*}" > /dev/null 2>&1);
@@ -303,38 +381,71 @@ validate_recovered_file() {
fi fi
;; ;;
png) png)
if command -v identify > /dev/null 2>&1 ; then
# Full decode: catches truncated data, corrupt IDAT chunks, bad CRCs
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded PNG ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode PNG"
fi
else
local _head local _head
_head="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" _head="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head" = "89504e470d0a1a0a" ]] ; then if [[ "$_head" = "89504e470d0a1a0a" ]] ; then
echo "VALID|PNG signature ok" echo "VALID|PNG signature ok (identify not installed — no deep check)"
else else
echo "INVALID|PNG signature missing" echo "INVALID|PNG signature missing"
fi fi
fi
;; ;;
gif) gif)
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded GIF ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode GIF"
fi
else
local _head6 local _head6
_head6="$(head -c6 "$_f" 2> /dev/null)" _head6="$(head -c6 "$_f" 2> /dev/null)"
if [[ "$_head6" = "GIF87a" || "$_head6" = "GIF89a" ]] ; then if [[ "$_head6" = "GIF87a" || "$_head6" = "GIF89a" ]] ; then
echo "VALID|GIF signature ok" echo "VALID|GIF signature ok (identify not installed — no deep check)"
else else
echo "INVALID|GIF signature missing" echo "INVALID|GIF signature missing"
fi fi
fi
;; ;;
bmp) bmp)
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded BMP ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode BMP"
fi
else
if [[ "$(head -c2 "$_f" 2> /dev/null)" = "BM" ]] ; then if [[ "$(head -c2 "$_f" 2> /dev/null)" = "BM" ]] ; then
echo "VALID|BMP signature ok" echo "VALID|BMP signature ok (identify not installed — no deep check)"
else else
echo "INVALID|BMP signature missing" echo "INVALID|BMP signature missing"
fi fi
fi
;; ;;
tif|tiff) tif|tiff)
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded TIFF ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode TIFF"
fi
else
local _head4 local _head4
_head4="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" _head4="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head4" = "49492a00" || "$_head4" = "4d4d002a" ]] ; then if [[ "$_head4" = "49492a00" || "$_head4" = "4d4d002a" ]] ; then
echo "VALID|TIFF signature ok" echo "VALID|TIFF signature ok (identify not installed — no deep check)"
else else
echo "INVALID|TIFF signature missing" echo "INVALID|TIFF signature missing"
fi fi
fi
;; ;;
pnm|pgm|ppm|pbm) pnm|pgm|ppm|pbm)
local _head2 local _head2
@@ -360,23 +471,59 @@ validate_recovered_file() {
if [[ "$_head8o" = "d0cf11e0a1b11ae1" ]] ; then if [[ "$_head8o" = "d0cf11e0a1b11ae1" ]] ; then
echo "VALID|OLE2/CFBF container signature ok - this is a password-protected Office file (encrypted package), not a plain zip, so the zip check does not apply; open it with the password to verify content" echo "VALID|OLE2/CFBF container signature ok - this is a password-protected Office file (encrypted package), not a plain zip, so the zip check does not apply; open it with the password to verify content"
elif command -v unzip > /dev/null 2>&1 ; then elif command -v unzip > /dev/null 2>&1 ; then
if unzip -tq "$_f" > /dev/null 2>&1 ; then # Run unzip inside setsid so it has no controlling terminal.
# Without setsid, a password-protected ZIP causes unzip to try
# opening /dev/tty to prompt for the password. When running in
# the background of a tmux session this generates SIGTTIN, which
# STOPS the process (ps state T). A stopped process cannot receive
# SIGTERM, so timeout hangs indefinitely waiting for a child that
# will never exit. With setsid the /dev/tty open fails immediately
# with ENXIO and unzip exits with a non-zero code instead.
# -k 5: send SIGKILL 5 s after SIGTERM in case the process is
# still alive (e.g. stopped or in uninterruptible sleep).
local _unzip_exit
timeout -k 5 120 setsid unzip -tq "$_f" < /dev/null > /dev/null 2>&1
_unzip_exit=$?
if [[ $_unzip_exit -eq 0 ]] ; then
echo "VALID|zip integrity ok" echo "VALID|zip integrity ok"
elif [[ $_unzip_exit -eq 124 || $_unzip_exit -eq 137 ]] ; then
# 124 = killed by SIGTERM after timeout, 137 = killed by SIGKILL (128+9)
echo "UNVERIFIED|zip integrity check timed out after 120 s (file may be very large or corrupt; check manually with: unzip -t \"$_f\")"
else else
local _badentry local _badentry
_badentry="$(trim "$(unzip -t "$_f" 2>&1 | grep -v '^Archive:' | grep -v '^[[:space:]]*$' | head -1)")" _badentry="$(trim "$(timeout -k 5 120 setsid unzip -t "$_f" < /dev/null 2>&1 | grep -v '^Archive:' | grep -v '^[[:space:]]*$' | head -1)")"
if echo "$_badentry" | grep -qi "password\|encrypt\|need PK compat" ; then
echo "UNVERIFIED|zip is password-protected (cannot verify without password${_badentry:+; unzip says: ${_badentry}})"
else
echo "INVALID|zip integrity check failed${_badentry:+ (${_badentry})}" echo "INVALID|zip integrity check failed${_badentry:+ (${_badentry})}"
fi fi
fi
else else
echo "UNVERIFIED|unzip not installed" echo "UNVERIFIED|unzip not installed"
fi fi
;; ;;
mp4|mov|m4v) mp4|mov|m4v)
if command -v ffprobe > /dev/null 2>&1 ; then
# Full container parse: detects truncated/corrupt streams
local _ffprobe_out _ffprobe_exit
_ffprobe_out="$(ffprobe -v error -show_streams "$_f" 2>&1)"
_ffprobe_exit=$?
if [[ $_ffprobe_exit -eq 0 ]] ; then
local _streams
_streams="$(echo "$_ffprobe_out" | grep -c '\[STREAM\]' || true)"
echo "VALID|ffprobe parsed container ok (${_streams} stream(s) found)"
else
local _ffprobe_err
_ffprobe_err="$(echo "$_ffprobe_out" | head -1)"
echo "INVALID|ffprobe failed to parse container${_ffprobe_err:+ (${_ffprobe_err})}"
fi
else
if head -c 64 "$_f" 2> /dev/null | grep -aq "ftyp" ; then if head -c 64 "$_f" 2> /dev/null | grep -aq "ftyp" ; then
echo "VALID|mp4 ftyp box found" echo "VALID|mp4 ftyp box found (ffprobe not installed — no deep check)"
else else
echo "INVALID|mp4 ftyp box not found" echo "INVALID|mp4 ftyp box not found"
fi fi
fi
;; ;;
doc|xls|ppt|ole|msi) doc|xls|ppt|ole|msi)
local _head8 local _head8
@@ -475,6 +622,75 @@ validate_recovered_file() {
echo "INVALID|GIMP (gimp xcf) signature missing" echo "INVALID|GIMP (gimp xcf) signature missing"
fi fi
;; ;;
mp3)
if command -v mp3val > /dev/null 2>&1 ; then
# mp3val always exits 0 but prints "No errors found" on success
local _mp3val_out
_mp3val_out="$(mp3val "$_f" 2>&1)"
if echo "$_mp3val_out" | grep -q 'No errors found' ; then
echo "VALID|mp3val: no errors found"
else
local _mp3_err
_mp3_err="$(echo "$_mp3val_out" | grep -iv '^mp3val\|^$' | head -2 | tr '\n' ' ' | sed 's/ $//')"
echo "INVALID|mp3val reported errors${_mp3_err:+ (${_mp3_err})}"
fi
else
local _head3 _head2hex
_head3="$(head -c3 "$_f" 2>/dev/null)"
_head2hex="$(head -c2 "$_f" 2>/dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head3" = "ID3" ]] || echo "$_head2hex" | grep -qE '^fff[bef2]' ; then
echo "VALID|MP3 ID3 tag / MPEG sync ok (mp3val not installed — no deep check)"
else
echo "INVALID|MP3: no ID3 header or MPEG frame sync found"
fi
fi
;;
flac)
if command -v flac > /dev/null 2>&1 ; then
# --test decodes without writing output; exit 0 = file is intact
local _flac_out _flac_exit
_flac_out="$(flac --silent --test "$_f" 2>&1)"
_flac_exit=$?
if [[ $_flac_exit -eq 0 ]] ; then
echo "VALID|flac --test: ok"
else
local _flac_err
_flac_err="$(echo "$_flac_out" | grep -v '^$' | tail -1)"
echo "INVALID|flac --test failed${_flac_err:+ (${_flac_err})}"
fi
else
local _head4hex
_head4hex="$(head -c4 "$_f" 2>/dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head4hex" = "664c6143" ]] ; then # "fLaC"
echo "VALID|FLAC 'fLaC' signature ok (flac not installed — no deep check)"
else
echo "INVALID|FLAC 'fLaC' signature missing"
fi
fi
;;
ogg|oga|ogv|opus)
if command -v ogginfo > /dev/null 2>&1 ; then
# ogginfo exits 0 if the Ogg container is intact
local _ogg_out _ogg_exit
_ogg_out="$(ogginfo "$_f" 2>&1)"
_ogg_exit=$?
if [[ $_ogg_exit -eq 0 ]] ; then
echo "VALID|ogginfo: container parsed ok"
else
local _ogg_err
_ogg_err="$(echo "$_ogg_out" | grep -iv '^Processing\|^$' | head -2 | tr '\n' ' ' | sed 's/ $//')"
echo "INVALID|ogginfo failed${_ogg_err:+ (${_ogg_err})}"
fi
else
local _head4ogg
_head4ogg="$(head -c4 "$_f" 2>/dev/null)"
if [[ "$_head4ogg" = "OggS" ]] ; then
echo "VALID|OGG 'OggS' signature ok (ogginfo not installed — no deep check)"
else
echo "INVALID|OGG 'OggS' signature missing"
fi
fi
;;
wav) wav)
local _riff _wave local _riff _wave
_riff="$(head -c4 "$_f" 2> /dev/null)" _riff="$(head -c4 "$_f" 2> /dev/null)"
@@ -562,6 +778,15 @@ validate_recovered_file() {
## - PDF structural check shared by the 'pdf' case and the 'ai' ## - PDF structural check shared by the 'pdf' case and the 'ai'
## - (PDF-compatible) case. Sets $_pdf_check_detail, returns 0/1. ## - (PDF-compatible) case. Sets $_pdf_check_detail, returns 0/1.
## - ## -
## - qpdf exit codes:
## - 0 no problems
## - 2 structural errors → file is genuinely corrupt/unreadable
## - 3 warnings only → minor spec non-conformances; every real
## - viewer opens the file without issues
## - Only exit code 2 is treated as INVALID here. Exit code 3 (warnings)
## - is reported as VALID with a note, preventing false positives for
## - real-world PDFs that have trivial non-conformances.
## -
_pdf_check() { _pdf_check() {
local _f="$1" local _f="$1"
if [[ "$(head -c5 "$_f" 2> /dev/null)" != "%PDF-" ]] ; then if [[ "$(head -c5 "$_f" 2> /dev/null)" != "%PDF-" ]] ; then
@@ -569,11 +794,22 @@ _pdf_check() {
return 1 return 1
fi fi
if command -v qpdf > /dev/null 2>&1 ; then if command -v qpdf > /dev/null 2>&1 ; then
if qpdf --check "$_f" > /dev/null 2>&1 ; then local _qpdf_out _qpdf_exit
_qpdf_out="$(qpdf --check "$_f" 2>&1)"
_qpdf_exit=$?
if [[ $_qpdf_exit -eq 0 ]] ; then
_pdf_check_detail="qpdf --check ok" _pdf_check_detail="qpdf --check ok"
return 0 return 0
elif [[ $_qpdf_exit -eq 3 ]] ; then
# Warnings only — no structural errors. File is readable by all
# standard PDF viewers; non-conformances are minor/cosmetic.
_pdf_check_detail="qpdf --check ok (warnings only — file is readable)"
return 0
else else
_pdf_check_detail="qpdf --check failed" # Exit code 2 (or unexpected): genuine structural errors.
local _first_err
_first_err="$(echo "$_qpdf_out" | grep -i 'error' | head -1 | sed 's/^[[:space:]]*//')"
_pdf_check_detail="qpdf --check failed (exit ${_qpdf_exit}${_first_err:+: ${_first_err}})"
return 1 return 1
fi fi
fi fi
@@ -764,7 +1000,7 @@ if mkdir "$LOCK_DIR" 2> /dev/null ; then
# - Remove lockdir when the script finishes, or when it receives a signal # - Remove lockdir when the script finishes, or when it receives a signal
# - # -
trap clean_up SIGHUP SIGINT SIGTERM trap 'clean_up 1' SIGHUP SIGINT SIGTERM
else else
@@ -813,8 +1049,14 @@ if ! $_revalidate_only_explicit && $terminal ; then
blank_line blank_line
echo -e "\033[37m\033[1mWhich mode should this run use?\033[m" echo -e "\033[37m\033[1mWhich mode should this run use?\033[m"
echo "" echo ""
echo -e " \033[1m[1] Recovery\033[m - decrypt bad-signature files (temporarily skips the signature check), write them under ${DEFAULT_RECOVERY_BASE_DIR}/<website> and validate them" echo -e " \033[1m[1] Recovery\033[m - decrypt bad-signature files (temporarily skips the signature check),
echo " [2] Revalidate-only - re-run just the validation checks against files a previous recovery run already wrote to disk (same as '-V'); nothing is decrypted again and no config value is touched" write them under
\033[1m${DEFAULT_RECOVERY_BASE_DIR}/<website>\033[m
and validate them"
echo ""
echo " [2] Revalidate-only - re-run just the validation checks against files a previous recovery run
already wrote to disk (same as '-V'); nothing is decrypted again and
no config value is touched"
info "Just press Return to use the default: [1] Recovery." info "Just press Return to use the default: [1] Recovery."
echo -n " Select mode by number [1]: " echo -n " Select mode by number [1]: "
read _mode_choice read _mode_choice
@@ -1077,6 +1319,8 @@ done
mkdir -p "$report_dir" 2> /dev/null mkdir -p "$report_dir" 2> /dev/null
recovery_report_file="${report_dir}/recovery_${WEBSITE}_${run_date}.tsv" recovery_report_file="${report_dir}/recovery_${WEBSITE}_${run_date}.tsv"
check_optional_validation_tools
if $terminal ; then if $terminal ; then
echo "" echo ""
if $REVALIDATE_ONLY ; then if $REVALIDATE_ONLY ; then
@@ -1117,6 +1361,18 @@ if $terminal ; then
fi fi
fi fi
if $terminal ; then
echo ""
echo -e " \033[1mTemporäre Dateien – werden laufend aktualisiert:\033[m"
echo ""
echo -e " Fortschritt / Debug-Meldungen (PHP-Ausgabe auf STDERR):"
echo -e " \033[1mtail -f ${log_file}\033[m"
echo ""
echo -e " Rohergebnis pro Account (PHP-Ausgabe auf STDOUT, Datei für Datei):"
echo -e " \033[1mtail -f ${LOCK_DIR}/result_<account>.tsv\033[m"
echo ""
fi
{ {
echo "==================================================================" echo "=================================================================="
@@ -1147,7 +1403,10 @@ su -c "$PHP_BIN $INSTALL_DIR/occ config:system:set encryption_skip_signature_che
if [[ $? -eq 0 ]] ; then if [[ $? -eq 0 ]] ; then
echo_ok echo_ok
_encryption_flag_changed=true _encryption_flag_changed=true
trap 'restore_encryption_flag; clean_up 1' SIGHUP SIGINT SIGTERM # Trap signals AND normal/abnormal exit so that the encryption flag
# and temp files are always cleaned up — even on a syntax error or
# an unexpected crash (EXIT fires for any bash exit, including errors).
trap 'clean_up 1' SIGHUP SIGINT SIGTERM EXIT
else else
echo_failed echo_failed
fatal "Could not enable encryption_skip_signature_check: $(cat "$log_file")" fatal "Could not enable encryption_skip_signature_check: $(cat "$log_file")"
@@ -1199,6 +1458,13 @@ if ($oldWorkingDir === false) {
} }
chdir(__DIR__); chdir(__DIR__);
require_once __DIR__ . '/lib/base.php'; require_once __DIR__ . '/lib/base.php';
// Nextcloud's own bootstrap (lib/base.php) unconditionally calls
// set_time_limit(3600) as part of the require above - this OVERRIDES
// whatever -d max_execution_time was passed on the PHP command line,
// since it is a later, explicit runtime call. Undo that here, now that
// the require is done, so recovering a large account isn't killed
// after exactly one hour regardless of the CLI flag.
set_time_limit(0);
chdir($oldWorkingDir); chdir($oldWorkingDir);
if (function_exists('posix_getuid') && posix_getuid() === 0) { if (function_exists('posix_getuid') && posix_getuid() === 0) {
@@ -1433,7 +1699,11 @@ for _user in "${selected_user_arr[@]}" ; do
chown -R "$HTTP_USER":"$HTTP_GROUP" "$recovery_dir" 2> /dev/null chown -R "$HTTP_USER":"$HTTP_GROUP" "$recovery_dir" 2> /dev/null
echononl " Recovering account \033[1;37m${_user}\033[m (${_user_total} files).." echononl " Recovering account \033[1;37m${_user}\033[m (${_user_total} files).."
su -c "$PHP_BIN $recovery_php_file $_user $list_file $user_out_dir" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file" # -d max_execution_time=0: decrypting/validating every file of a
# large account can run well over an hour; without this the PHP
# CLI process is killed by PHP's own execution-time limit
# (typically inherited from the webserver's php.ini) mid-run.
su -c "$PHP_BIN -d max_execution_time=0 $recovery_php_file $_user $list_file $user_out_dir" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file"
_rc=$? _rc=$?
fi fi
+269 -13
View File
@@ -183,6 +183,8 @@ usage() {
clean_up() { clean_up() {
# Perform program exit housekeeping # Perform program exit housekeeping
# Clear EXIT trap first so that the exit below does not fire it again.
trap - EXIT
[[ -n "$recreate_php_file" ]] && rm -f "$recreate_php_file" 2> /dev/null [[ -n "$recreate_php_file" ]] && rm -f "$recreate_php_file" 2> /dev/null
rm -rf "$LOCK_DIR" rm -rf "$LOCK_DIR"
blank_line blank_line
@@ -190,6 +192,77 @@ clean_up() {
} }
check_optional_validation_tools() {
local _missing_tools=()
local _missing_pkgs=()
local _desc=()
command -v identify > /dev/null 2>&1 || {
_missing_tools+=("identify")
_missing_pkgs+=("imagemagick")
_desc+=("identify (imagemagick) — PNG / TIFF / BMP / GIF: vollst. Dekodierung statt nur Magic-Bytes")
}
command -v ffprobe > /dev/null 2>&1 || {
_missing_tools+=("ffprobe")
_missing_pkgs+=("ffmpeg")
_desc+=("ffprobe (ffmpeg) — MP4 / MOV / M4V: Container-Parsing statt nur ftyp-Box-Suche")
}
command -v mp3val > /dev/null 2>&1 || {
_missing_tools+=("mp3val")
_missing_pkgs+=("mp3val")
_desc+=("mp3val (mp3val) — MP3: Frame-Struktur-Check statt nur ID3-Signatur")
}
command -v flac > /dev/null 2>&1 || {
_missing_tools+=("flac")
_missing_pkgs+=("flac")
_desc+=("flac (flac) — FLAC: Decode-Test statt nur fLaC-Signatur")
}
command -v ogginfo > /dev/null 2>&1 || {
_missing_tools+=("ogginfo")
_missing_pkgs+=("vorbis-tools")
_desc+=("ogginfo (vorbis-tools) — OGG / OGA / OPUS: Container-Check statt nur OggS-Signatur")
}
[[ ${#_missing_tools[@]} -eq 0 ]] && return 0
if ! $terminal ; then
return 0
fi
local _pkg_list="${_missing_pkgs[*]}"
echo ""
echo -e " \033[33mOptionale Validierungs-Tools fehlen – Checks laufen mit reduzierter Genauigkeit:\033[m"
echo ""
local _d
for _d in "${_desc[@]}" ; do
echo " ${_d}"
done
echo ""
echo -n " Jetzt installieren? apt install ${_pkg_list} [j/N]: "
read -r _yn
echo ""
if [[ "$_yn" =~ ^[jJyY]$ ]] ; then
echononl " Installiere Pakete: ${_pkg_list}.."
# shellcheck disable=SC2086
if apt-get install -y ${_missing_pkgs[*]} > /dev/null 2>&1 ; then
echo_ok
else
echo_failed
echo ""
echo " Bitte manuell installieren:"
echo " apt install ${_pkg_list}"
fi
else
echo " Übersprungen — Validierung läuft mit reduzierter Genauigkeit."
fi
echo ""
}
is_number() { is_number() {
return $(test ! -z "${1##*[!0-9]*}" > /dev/null 2>&1); return $(test ! -z "${1##*[!0-9]*}" > /dev/null 2>&1);
@@ -338,38 +411,70 @@ validate_recovered_file() {
fi fi
;; ;;
png) png)
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded PNG ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode PNG"
fi
else
local _head local _head
_head="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" _head="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head" = "89504e470d0a1a0a" ]] ; then if [[ "$_head" = "89504e470d0a1a0a" ]] ; then
echo "VALID|PNG signature ok" echo "VALID|PNG signature ok (identify not installed — no deep check)"
else else
echo "INVALID|PNG signature missing" echo "INVALID|PNG signature missing"
fi fi
fi
;; ;;
gif) gif)
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded GIF ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode GIF"
fi
else
local _head6 local _head6
_head6="$(head -c6 "$_f" 2> /dev/null)" _head6="$(head -c6 "$_f" 2> /dev/null)"
if [[ "$_head6" = "GIF87a" || "$_head6" = "GIF89a" ]] ; then if [[ "$_head6" = "GIF87a" || "$_head6" = "GIF89a" ]] ; then
echo "VALID|GIF signature ok" echo "VALID|GIF signature ok (identify not installed — no deep check)"
else else
echo "INVALID|GIF signature missing" echo "INVALID|GIF signature missing"
fi fi
fi
;; ;;
bmp) bmp)
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded BMP ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode BMP"
fi
else
if [[ "$(head -c2 "$_f" 2> /dev/null)" = "BM" ]] ; then if [[ "$(head -c2 "$_f" 2> /dev/null)" = "BM" ]] ; then
echo "VALID|BMP signature ok" echo "VALID|BMP signature ok (identify not installed — no deep check)"
else else
echo "INVALID|BMP signature missing" echo "INVALID|BMP signature missing"
fi fi
fi
;; ;;
tif|tiff) tif|tiff)
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded TIFF ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode TIFF"
fi
else
local _head4 local _head4
_head4="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" _head4="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head4" = "49492a00" || "$_head4" = "4d4d002a" ]] ; then if [[ "$_head4" = "49492a00" || "$_head4" = "4d4d002a" ]] ; then
echo "VALID|TIFF signature ok" echo "VALID|TIFF signature ok (identify not installed — no deep check)"
else else
echo "INVALID|TIFF signature missing" echo "INVALID|TIFF signature missing"
fi fi
fi
;; ;;
pnm|pgm|ppm|pbm) pnm|pgm|ppm|pbm)
local _head2 local _head2
@@ -386,23 +491,58 @@ validate_recovered_file() {
if [[ "$_head8o" = "d0cf11e0a1b11ae1" ]] ; then if [[ "$_head8o" = "d0cf11e0a1b11ae1" ]] ; then
echo "VALID|OLE2/CFBF container signature ok - this is a password-protected Office file (encrypted package), not a plain zip, so the zip check does not apply; open it with the password to verify content" echo "VALID|OLE2/CFBF container signature ok - this is a password-protected Office file (encrypted package), not a plain zip, so the zip check does not apply; open it with the password to verify content"
elif command -v unzip > /dev/null 2>&1 ; then elif command -v unzip > /dev/null 2>&1 ; then
if unzip -tq "$_f" > /dev/null 2>&1 ; then # Run unzip inside setsid so it has no controlling terminal.
# Without setsid, a password-protected ZIP causes unzip to try
# opening /dev/tty to prompt for the password. When running in
# the background of a tmux session this generates SIGTTIN, which
# STOPS the process (ps state T). A stopped process cannot receive
# SIGTERM, so timeout hangs indefinitely waiting for a child that
# will never exit. With setsid the /dev/tty open fails immediately
# with ENXIO and unzip exits with a non-zero code instead.
# -k 5: send SIGKILL 5 s after SIGTERM in case the process is
# still alive (e.g. stopped or in uninterruptible sleep).
local _unzip_exit
timeout -k 5 120 setsid unzip -tq "$_f" < /dev/null > /dev/null 2>&1
_unzip_exit=$?
if [[ $_unzip_exit -eq 0 ]] ; then
echo "VALID|zip integrity ok" echo "VALID|zip integrity ok"
elif [[ $_unzip_exit -eq 124 || $_unzip_exit -eq 137 ]] ; then
# 124 = killed by SIGTERM after timeout, 137 = killed by SIGKILL (128+9)
echo "UNVERIFIED|zip integrity check timed out after 120 s (file may be very large or corrupt; check manually with: unzip -t \"$_f\")"
else else
local _badentry local _badentry
_badentry="$(trim "$(unzip -t "$_f" 2>&1 | grep -v '^Archive:' | grep -v '^[[:space:]]*$' | head -1)")" _badentry="$(trim "$(timeout -k 5 120 setsid unzip -t "$_f" < /dev/null 2>&1 | grep -v '^Archive:' | grep -v '^[[:space:]]*$' | head -1)")"
if echo "$_badentry" | grep -qi "password\|encrypt\|need PK compat" ; then
echo "UNVERIFIED|zip is password-protected (cannot verify without password${_badentry:+; unzip says: ${_badentry}})"
else
echo "INVALID|zip integrity check failed${_badentry:+ (${_badentry})}" echo "INVALID|zip integrity check failed${_badentry:+ (${_badentry})}"
fi fi
fi
else else
echo "UNVERIFIED|unzip not installed" echo "UNVERIFIED|unzip not installed"
fi fi
;; ;;
mp4|mov|m4v) mp4|mov|m4v)
if command -v ffprobe > /dev/null 2>&1 ; then
local _ffprobe_out _ffprobe_exit
_ffprobe_out="$(ffprobe -v error -show_streams "$_f" 2>&1)"
_ffprobe_exit=$?
if [[ $_ffprobe_exit -eq 0 ]] ; then
local _streams
_streams="$(echo "$_ffprobe_out" | grep -c '\[STREAM\]' || true)"
echo "VALID|ffprobe parsed container ok (${_streams} stream(s) found)"
else
local _ffprobe_err
_ffprobe_err="$(echo "$_ffprobe_out" | head -1)"
echo "INVALID|ffprobe failed to parse container${_ffprobe_err:+ (${_ffprobe_err})}"
fi
else
if head -c 64 "$_f" 2> /dev/null | grep -aq "ftyp" ; then if head -c 64 "$_f" 2> /dev/null | grep -aq "ftyp" ; then
echo "VALID|mp4 ftyp box found" echo "VALID|mp4 ftyp box found (ffprobe not installed — no deep check)"
else else
echo "INVALID|mp4 ftyp box not found" echo "INVALID|mp4 ftyp box not found"
fi fi
fi
;; ;;
doc|xls|ppt|ole|msi) doc|xls|ppt|ole|msi)
local _head8 local _head8
@@ -493,6 +633,72 @@ validate_recovered_file() {
echo "INVALID|GIMP (gimp xcf) signature missing" echo "INVALID|GIMP (gimp xcf) signature missing"
fi fi
;; ;;
mp3)
if command -v mp3val > /dev/null 2>&1 ; then
local _mp3val_out
_mp3val_out="$(mp3val "$_f" 2>&1)"
if echo "$_mp3val_out" | grep -q 'No errors found' ; then
echo "VALID|mp3val: no errors found"
else
local _mp3_err
_mp3_err="$(echo "$_mp3val_out" | grep -iv '^mp3val\|^$' | head -2 | tr '\n' ' ' | sed 's/ $//')"
echo "INVALID|mp3val reported errors${_mp3_err:+ (${_mp3_err})}"
fi
else
local _head3 _head2hex
_head3="$(head -c3 "$_f" 2>/dev/null)"
_head2hex="$(head -c2 "$_f" 2>/dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head3" = "ID3" ]] || echo "$_head2hex" | grep -qE '^fff[bef2]' ; then
echo "VALID|MP3 ID3 tag / MPEG sync ok (mp3val not installed — no deep check)"
else
echo "INVALID|MP3: no ID3 header or MPEG frame sync found"
fi
fi
;;
flac)
if command -v flac > /dev/null 2>&1 ; then
local _flac_out _flac_exit
_flac_out="$(flac --silent --test "$_f" 2>&1)"
_flac_exit=$?
if [[ $_flac_exit -eq 0 ]] ; then
echo "VALID|flac --test: ok"
else
local _flac_err
_flac_err="$(echo "$_flac_out" | grep -v '^$' | tail -1)"
echo "INVALID|flac --test failed${_flac_err:+ (${_flac_err})}"
fi
else
local _head4hex
_head4hex="$(head -c4 "$_f" 2>/dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head4hex" = "664c6143" ]] ; then
echo "VALID|FLAC 'fLaC' signature ok (flac not installed — no deep check)"
else
echo "INVALID|FLAC 'fLaC' signature missing"
fi
fi
;;
ogg|oga|ogv|opus)
if command -v ogginfo > /dev/null 2>&1 ; then
local _ogg_out _ogg_exit
_ogg_out="$(ogginfo "$_f" 2>&1)"
_ogg_exit=$?
if [[ $_ogg_exit -eq 0 ]] ; then
echo "VALID|ogginfo: container parsed ok"
else
local _ogg_err
_ogg_err="$(echo "$_ogg_out" | grep -iv '^Processing\|^$' | head -2 | tr '\n' ' ' | sed 's/ $//')"
echo "INVALID|ogginfo failed${_ogg_err:+ (${_ogg_err})}"
fi
else
local _head4ogg
_head4ogg="$(head -c4 "$_f" 2>/dev/null)"
if [[ "$_head4ogg" = "OggS" ]] ; then
echo "VALID|OGG 'OggS' signature ok (ogginfo not installed — no deep check)"
else
echo "INVALID|OGG 'OggS' signature missing"
fi
fi
;;
wav) wav)
local _riff _wave local _riff _wave
_riff="$(head -c4 "$_f" 2> /dev/null)" _riff="$(head -c4 "$_f" 2> /dev/null)"
@@ -564,6 +770,15 @@ validate_recovered_file() {
esac esac
} }
## - qpdf exit codes:
## - 0 no problems
## - 2 structural errors → file is genuinely corrupt/unreadable
## - 3 warnings only → minor spec non-conformances; every real
## - viewer opens the file without issues
## - Only exit code 2 is treated as INVALID here. Exit code 3 (warnings)
## - is reported as VALID with a note, preventing false positives for
## - real-world PDFs that have trivial non-conformances.
## -
_pdf_check() { _pdf_check() {
local _f="$1" local _f="$1"
if [[ "$(head -c5 "$_f" 2> /dev/null)" != "%PDF-" ]] ; then if [[ "$(head -c5 "$_f" 2> /dev/null)" != "%PDF-" ]] ; then
@@ -571,11 +786,22 @@ _pdf_check() {
return 1 return 1
fi fi
if command -v qpdf > /dev/null 2>&1 ; then if command -v qpdf > /dev/null 2>&1 ; then
if qpdf --check "$_f" > /dev/null 2>&1 ; then local _qpdf_out _qpdf_exit
_qpdf_out="$(qpdf --check "$_f" 2>&1)"
_qpdf_exit=$?
if [[ $_qpdf_exit -eq 0 ]] ; then
_pdf_check_detail="qpdf --check ok" _pdf_check_detail="qpdf --check ok"
return 0 return 0
elif [[ $_qpdf_exit -eq 3 ]] ; then
# Warnings only — no structural errors. File is readable by all
# standard PDF viewers; non-conformances are minor/cosmetic.
_pdf_check_detail="qpdf --check ok (warnings only — file is readable)"
return 0
else else
_pdf_check_detail="qpdf --check failed" # Exit code 2 (or unexpected): genuine structural errors.
local _first_err
_first_err="$(echo "$_qpdf_out" | grep -i 'error' | head -1 | sed 's/^[[:space:]]*//')"
_pdf_check_detail="qpdf --check failed (exit ${_qpdf_exit}${_first_err:+: ${_first_err}})"
return 1 return 1
fi fi
fi fi
@@ -731,7 +957,9 @@ fi
# - # -
if mkdir "$LOCK_DIR" 2> /dev/null ; then if mkdir "$LOCK_DIR" 2> /dev/null ; then
trap clean_up SIGHUP SIGINT SIGTERM # Trap signals AND normal/abnormal exit so that temp files and the
# lock directory are always removed — even on an unexpected crash.
trap 'clean_up 1' SIGHUP SIGINT SIGTERM EXIT
else else
@@ -782,8 +1010,11 @@ if ! $_dry_run_explicit && $terminal ; then
blank_line blank_line
echo -e "\033[37m\033[1mWhich mode should this run use?\033[m" echo -e "\033[37m\033[1mWhich mode should this run use?\033[m"
echo "" echo ""
echo -e " \033[1m[1] Dry-run\033[m - go through everything (selection, re-validation, share check, reporting), but delete, back up, create or verify NOTHING" echo -e " \033[1m[1] Dry-run\033[m - go through everything (selection, re-validation, share check,
echo " [2] Real recreate - actually back up, DELETE the broken file, create it fresh and verify each one" reporting), but delete, back up, create or verify NOTHING"
echo ""
echo " [2] Real recreate - actually back up, DELETE the broken file, create it fresh
and verify each one"
info "Just press Return to use the default: [1] Dry-run." info "Just press Return to use the default: [1] Dry-run."
echo -n " Select mode by number [1]: " echo -n " Select mode by number [1]: "
read _mode_choice read _mode_choice
@@ -1037,6 +1268,8 @@ done
mkdir -p "$report_dir" 2> /dev/null mkdir -p "$report_dir" 2> /dev/null
recreate_report_file="${report_dir}/recreate_${WEBSITE}_${run_date}.tsv" recreate_report_file="${report_dir}/recreate_${WEBSITE}_${run_date}.tsv"
check_optional_validation_tools
if $terminal ; then if $terminal ; then
echo "" echo ""
if $DRY_RUN ; then if $DRY_RUN ; then
@@ -1080,6 +1313,18 @@ if $terminal ; then
fi fi
fi fi
if $terminal ; then
echo ""
echo -e " \033[1mTemporäre Dateien – werden laufend aktualisiert:\033[m"
echo ""
echo -e " Fortschritt / Debug-Meldungen (PHP-Ausgabe auf STDERR, pro Account):"
echo -e " \033[1mtail -f ${LOCK_DIR}/${script_name%%.*}_<account>.log\033[m"
echo ""
echo -e " Rohergebnis pro Account (PHP-Ausgabe auf STDOUT, Datei für Datei):"
echo -e " \033[1mtail -f ${LOCK_DIR}/result_<account>.tsv\033[m"
echo ""
fi
{ {
echo "==================================================================" echo "=================================================================="
@@ -1162,6 +1407,13 @@ if ($oldWorkingDir === false) {
} }
chdir(__DIR__); chdir(__DIR__);
require_once __DIR__ . '/lib/base.php'; require_once __DIR__ . '/lib/base.php';
// Nextcloud's own bootstrap (lib/base.php) unconditionally calls
// set_time_limit(3600) as part of the require above - this OVERRIDES
// whatever -d max_execution_time was passed on the PHP command line,
// since it is a later, explicit runtime call. Undo that here, now that
// the require is done, so recreating a large account isn't killed
// after exactly one hour regardless of the CLI flag.
set_time_limit(0);
chdir($oldWorkingDir); chdir($oldWorkingDir);
if (function_exists('posix_getuid') && posix_getuid() === 0) { if (function_exists('posix_getuid') && posix_getuid() === 0) {
@@ -1763,7 +2015,11 @@ for _user in "${selected_user_arr[@]}" ; do
_php_args=("$recreate_php_file" "$_user" "$list_file" "$DATA_DIR") _php_args=("$recreate_php_file" "$_user" "$list_file" "$DATA_DIR")
$DRY_RUN && _php_args+=("--dry-run") $DRY_RUN && _php_args+=("--dry-run")
$FORCE_SHARED && _php_args+=("--force") $FORCE_SHARED && _php_args+=("--force")
su -c "$PHP_BIN ${_php_args[*]}" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file" # -d max_execution_time=0: recreating every selected file of a
# large account can run well over an hour; without this the PHP
# CLI process is killed by PHP's own execution-time limit
# (typically inherited from the webserver's php.ini) mid-run.
su -c "$PHP_BIN -d max_execution_time=0 ${_php_args[*]}" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file"
_rc=$? _rc=$?
else else
echo -e "path\tbytes_written\tstatus\tdetail" > "$user_result_tsv" echo -e "path\tbytes_written\tstatus\tdetail" > "$user_result_tsv"
+395 -62
View File
@@ -59,15 +59,14 @@ usage() {
\033[1mDescription\033[m \033[1mDescription\033[m
Writes already-recovered, already-validated files (produced by a Writes already-recovered, already-validated files (produced by a
PRIOR run of recover_bad_signature.sh, validation column = VALID in PRIOR run of recover_bad_signature.sh) back into Nextcloud's own
its report) back into Nextcloud's own storage, at their ORIGINAL storage, at their ORIGINAL path - through Nextcloud's normal Files
path - through Nextcloud's normal Files API (the same write path a API (the same write path a regular upload/sync client write uses),
regular upload/sync client write uses), so the Server-Side so the Server-Side Encryption layer transparently (re-)encrypts the
Encryption layer transparently (re-)encrypts the content with a content with a fresh IV and a correct signature. Afterwards the
fresh IV and a correct signature. Afterwards the file is a file is a completely normal, healthy encrypted file again: readable
completely normal, healthy encrypted file again: readable in the in the web interface, over WebDAV, and by sync clients, with no
web interface, over WebDAV, and by sync clients, with no more 'Bad more 'Bad Signature' and no server-side flag left toggled.
Signature' and no server-side flag left toggled.
This is the ONLY script in this toolkit that writes into This is the ONLY script in this toolkit that writes into
Nextcloud's live storage - scan_bad_signature.sh and Nextcloud's live storage - scan_bad_signature.sh and
@@ -75,14 +74,35 @@ usage() {
itself. Treat it accordingly. itself. Treat it accordingly.
IMPORTANT: IMPORTANT:
- Only files whose recovery report marks them 'VALID' are ever - Two categories of entries from the recovery report are
considered - 'Datenmuell'/INVALID and 'Nicht pruefbar'/ restored, both by default, no separate flag needed:
UNVERIFIED entries are never written back, no override exists. * validation=VALID - a dedicated structural check (PDF
header, zip integrity, image signature, ...) actually
confirmed the recovered content.
* validation=UNVERIFIED ('Nicht pruefbar' in the recovery
report) - unusual/unrecognized file type, no dedicated
check exists, but nothing about it looks wrong either
(recovered/original size ratio is in the expected range,
or no size comparison applies to that check path). This
is the 'unauffaellig' case: we cannot prove it's correct,
but we also have no concrete reason to think it's not.
Entries where the size ratio actively 'looks off' (a genuine
negative signal, not just 'unchecked') are treated like
INVALID and are NEVER restored automatically, same as
'Datenmuell'/INVALID entries - no override exists for either.
Every restored UNVERIFIED file is clearly marked as such (its
own report column, its own counters) so this is always
transparent afterwards - never silently indistinguishable from
a properly verified VALID restore. If you'd rather review the
UNVERIFIED/'Nicht pruefbar' files yourself first, use
delete_files.sh to remove the ones you don't want kept, run
this script for 'VALID' coverage, then decide on the rest by
hand.
- Every file is re-validated with the CURRENT checks right - Every file is re-validated with the CURRENT checks right
before writing (not just trusted from the old report), in case before writing (not just trusted from the old report), in case
this script's validation logic has improved since that report this script's validation logic has improved since that report
was produced - files that no longer validate are skipped, not was produced - files that no longer validate (as VALID or as
forced through. plausible UNVERIFIED) are skipped, not forced through.
- Before each file is overwritten, the CURRENT on-disk ciphertext - Before each file is overwritten, the CURRENT on-disk ciphertext
(the still-broken original, exactly as it is right now) is (the still-broken original, exactly as it is right now) is
copied byte-for-byte to a separate backup directory (default copied byte-for-byte to a separate backup directory (default
@@ -113,7 +133,7 @@ usage() {
reports what would happen) or the real thing. You will then be reports what would happen) or the real thing. You will then be
asked which existing recovery report (produced by asked which existing recovery report (produced by
recover_bad_signature.sh) to use, and then which account(s) from its recover_bad_signature.sh) to use, and then which account(s) from its
VALID entries to restore for: restorable (VALID + plausible UNVERIFIED) entries to restore for:
- a blank separated list of account names - a blank separated list of account names
- or 'all' to attempt every account found in the report - or 'all' to attempt every account found in the report
@@ -147,6 +167,8 @@ usage() {
clean_up() { clean_up() {
# Perform program exit housekeeping # Perform program exit housekeeping
# Clear EXIT trap first so that the exit below does not fire it again.
trap - EXIT
[[ -n "$restore_php_file" ]] && rm -f "$restore_php_file" 2> /dev/null [[ -n "$restore_php_file" ]] && rm -f "$restore_php_file" 2> /dev/null
rm -rf "$LOCK_DIR" rm -rf "$LOCK_DIR"
blank_line blank_line
@@ -154,6 +176,77 @@ clean_up() {
} }
check_optional_validation_tools() {
local _missing_tools=()
local _missing_pkgs=()
local _desc=()
command -v identify > /dev/null 2>&1 || {
_missing_tools+=("identify")
_missing_pkgs+=("imagemagick")
_desc+=("identify (imagemagick) — PNG / TIFF / BMP / GIF: vollst. Dekodierung statt nur Magic-Bytes")
}
command -v ffprobe > /dev/null 2>&1 || {
_missing_tools+=("ffprobe")
_missing_pkgs+=("ffmpeg")
_desc+=("ffprobe (ffmpeg) — MP4 / MOV / M4V: Container-Parsing statt nur ftyp-Box-Suche")
}
command -v mp3val > /dev/null 2>&1 || {
_missing_tools+=("mp3val")
_missing_pkgs+=("mp3val")
_desc+=("mp3val (mp3val) — MP3: Frame-Struktur-Check statt nur ID3-Signatur")
}
command -v flac > /dev/null 2>&1 || {
_missing_tools+=("flac")
_missing_pkgs+=("flac")
_desc+=("flac (flac) — FLAC: Decode-Test statt nur fLaC-Signatur")
}
command -v ogginfo > /dev/null 2>&1 || {
_missing_tools+=("ogginfo")
_missing_pkgs+=("vorbis-tools")
_desc+=("ogginfo (vorbis-tools) — OGG / OGA / OPUS: Container-Check statt nur OggS-Signatur")
}
[[ ${#_missing_tools[@]} -eq 0 ]] && return 0
if ! $terminal ; then
return 0
fi
local _pkg_list="${_missing_pkgs[*]}"
echo ""
echo -e " \033[33mOptionale Validierungs-Tools fehlen – Checks laufen mit reduzierter Genauigkeit:\033[m"
echo ""
local _d
for _d in "${_desc[@]}" ; do
echo " ${_d}"
done
echo ""
echo -n " Jetzt installieren? apt install ${_pkg_list} [j/N]: "
read -r _yn
echo ""
if [[ "$_yn" =~ ^[jJyY]$ ]] ; then
echononl " Installiere Pakete: ${_pkg_list}.."
# shellcheck disable=SC2086
if apt-get install -y ${_missing_pkgs[*]} > /dev/null 2>&1 ; then
echo_ok
else
echo_failed
echo ""
echo " Bitte manuell installieren:"
echo " apt install ${_pkg_list}"
fi
else
echo " Übersprungen — Validierung läuft mit reduzierter Genauigkeit."
fi
echo ""
}
is_number() { is_number() {
return $(test ! -z "${1##*[!0-9]*}" > /dev/null 2>&1); return $(test ! -z "${1##*[!0-9]*}" > /dev/null 2>&1);
@@ -303,38 +396,70 @@ validate_recovered_file() {
fi fi
;; ;;
png) png)
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded PNG ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode PNG"
fi
else
local _head local _head
_head="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" _head="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head" = "89504e470d0a1a0a" ]] ; then if [[ "$_head" = "89504e470d0a1a0a" ]] ; then
echo "VALID|PNG signature ok" echo "VALID|PNG signature ok (identify not installed — no deep check)"
else else
echo "INVALID|PNG signature missing" echo "INVALID|PNG signature missing"
fi fi
fi
;; ;;
gif) gif)
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded GIF ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode GIF"
fi
else
local _head6 local _head6
_head6="$(head -c6 "$_f" 2> /dev/null)" _head6="$(head -c6 "$_f" 2> /dev/null)"
if [[ "$_head6" = "GIF87a" || "$_head6" = "GIF89a" ]] ; then if [[ "$_head6" = "GIF87a" || "$_head6" = "GIF89a" ]] ; then
echo "VALID|GIF signature ok" echo "VALID|GIF signature ok (identify not installed — no deep check)"
else else
echo "INVALID|GIF signature missing" echo "INVALID|GIF signature missing"
fi fi
fi
;; ;;
bmp) bmp)
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded BMP ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode BMP"
fi
else
if [[ "$(head -c2 "$_f" 2> /dev/null)" = "BM" ]] ; then if [[ "$(head -c2 "$_f" 2> /dev/null)" = "BM" ]] ; then
echo "VALID|BMP signature ok" echo "VALID|BMP signature ok (identify not installed — no deep check)"
else else
echo "INVALID|BMP signature missing" echo "INVALID|BMP signature missing"
fi fi
fi
;; ;;
tif|tiff) tif|tiff)
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
echo "VALID|ImageMagick 'identify' decoded TIFF ok"
else
echo "INVALID|ImageMagick 'identify' failed to decode TIFF"
fi
else
local _head4 local _head4
_head4="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" _head4="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head4" = "49492a00" || "$_head4" = "4d4d002a" ]] ; then if [[ "$_head4" = "49492a00" || "$_head4" = "4d4d002a" ]] ; then
echo "VALID|TIFF signature ok" echo "VALID|TIFF signature ok (identify not installed — no deep check)"
else else
echo "INVALID|TIFF signature missing" echo "INVALID|TIFF signature missing"
fi fi
fi
;; ;;
pnm|pgm|ppm|pbm) pnm|pgm|ppm|pbm)
local _head2 local _head2
@@ -360,23 +485,58 @@ validate_recovered_file() {
if [[ "$_head8o" = "d0cf11e0a1b11ae1" ]] ; then if [[ "$_head8o" = "d0cf11e0a1b11ae1" ]] ; then
echo "VALID|OLE2/CFBF container signature ok - this is a password-protected Office file (encrypted package), not a plain zip, so the zip check does not apply; open it with the password to verify content" echo "VALID|OLE2/CFBF container signature ok - this is a password-protected Office file (encrypted package), not a plain zip, so the zip check does not apply; open it with the password to verify content"
elif command -v unzip > /dev/null 2>&1 ; then elif command -v unzip > /dev/null 2>&1 ; then
if unzip -tq "$_f" > /dev/null 2>&1 ; then # Run unzip inside setsid so it has no controlling terminal.
# Without setsid, a password-protected ZIP causes unzip to try
# opening /dev/tty to prompt for the password. When running in
# the background of a tmux session this generates SIGTTIN, which
# STOPS the process (ps state T). A stopped process cannot receive
# SIGTERM, so timeout hangs indefinitely waiting for a child that
# will never exit. With setsid the /dev/tty open fails immediately
# with ENXIO and unzip exits with a non-zero code instead.
# -k 5: send SIGKILL 5 s after SIGTERM in case the process is
# still alive (e.g. stopped or in uninterruptible sleep).
local _unzip_exit
timeout -k 5 120 setsid unzip -tq "$_f" < /dev/null > /dev/null 2>&1
_unzip_exit=$?
if [[ $_unzip_exit -eq 0 ]] ; then
echo "VALID|zip integrity ok" echo "VALID|zip integrity ok"
elif [[ $_unzip_exit -eq 124 || $_unzip_exit -eq 137 ]] ; then
# 124 = killed by SIGTERM after timeout, 137 = killed by SIGKILL (128+9)
echo "UNVERIFIED|zip integrity check timed out after 120 s (file may be very large or corrupt; check manually with: unzip -t \"$_f\")"
else else
local _badentry local _badentry
_badentry="$(trim "$(unzip -t "$_f" 2>&1 | grep -v '^Archive:' | grep -v '^[[:space:]]*$' | head -1)")" _badentry="$(trim "$(timeout -k 5 120 setsid unzip -t "$_f" < /dev/null 2>&1 | grep -v '^Archive:' | grep -v '^[[:space:]]*$' | head -1)")"
if echo "$_badentry" | grep -qi "password\|encrypt\|need PK compat" ; then
echo "UNVERIFIED|zip is password-protected (cannot verify without password${_badentry:+; unzip says: ${_badentry}})"
else
echo "INVALID|zip integrity check failed${_badentry:+ (${_badentry})}" echo "INVALID|zip integrity check failed${_badentry:+ (${_badentry})}"
fi fi
fi
else else
echo "UNVERIFIED|unzip not installed" echo "UNVERIFIED|unzip not installed"
fi fi
;; ;;
mp4|mov|m4v) mp4|mov|m4v)
if command -v ffprobe > /dev/null 2>&1 ; then
local _ffprobe_out _ffprobe_exit
_ffprobe_out="$(ffprobe -v error -show_streams "$_f" 2>&1)"
_ffprobe_exit=$?
if [[ $_ffprobe_exit -eq 0 ]] ; then
local _streams
_streams="$(echo "$_ffprobe_out" | grep -c '\[STREAM\]' || true)"
echo "VALID|ffprobe parsed container ok (${_streams} stream(s) found)"
else
local _ffprobe_err
_ffprobe_err="$(echo "$_ffprobe_out" | head -1)"
echo "INVALID|ffprobe failed to parse container${_ffprobe_err:+ (${_ffprobe_err})}"
fi
else
if head -c 64 "$_f" 2> /dev/null | grep -aq "ftyp" ; then if head -c 64 "$_f" 2> /dev/null | grep -aq "ftyp" ; then
echo "VALID|mp4 ftyp box found" echo "VALID|mp4 ftyp box found (ffprobe not installed — no deep check)"
else else
echo "INVALID|mp4 ftyp box not found" echo "INVALID|mp4 ftyp box not found"
fi fi
fi
;; ;;
doc|xls|ppt|ole|msi) doc|xls|ppt|ole|msi)
local _head8 local _head8
@@ -475,6 +635,72 @@ validate_recovered_file() {
echo "INVALID|GIMP (gimp xcf) signature missing" echo "INVALID|GIMP (gimp xcf) signature missing"
fi fi
;; ;;
mp3)
if command -v mp3val > /dev/null 2>&1 ; then
local _mp3val_out
_mp3val_out="$(mp3val "$_f" 2>&1)"
if echo "$_mp3val_out" | grep -q 'No errors found' ; then
echo "VALID|mp3val: no errors found"
else
local _mp3_err
_mp3_err="$(echo "$_mp3val_out" | grep -iv '^mp3val\|^$' | head -2 | tr '\n' ' ' | sed 's/ $//')"
echo "INVALID|mp3val reported errors${_mp3_err:+ (${_mp3_err})}"
fi
else
local _head3 _head2hex
_head3="$(head -c3 "$_f" 2>/dev/null)"
_head2hex="$(head -c2 "$_f" 2>/dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head3" = "ID3" ]] || echo "$_head2hex" | grep -qE '^fff[bef2]' ; then
echo "VALID|MP3 ID3 tag / MPEG sync ok (mp3val not installed — no deep check)"
else
echo "INVALID|MP3: no ID3 header or MPEG frame sync found"
fi
fi
;;
flac)
if command -v flac > /dev/null 2>&1 ; then
local _flac_out _flac_exit
_flac_out="$(flac --silent --test "$_f" 2>&1)"
_flac_exit=$?
if [[ $_flac_exit -eq 0 ]] ; then
echo "VALID|flac --test: ok"
else
local _flac_err
_flac_err="$(echo "$_flac_out" | grep -v '^$' | tail -1)"
echo "INVALID|flac --test failed${_flac_err:+ (${_flac_err})}"
fi
else
local _head4hex
_head4hex="$(head -c4 "$_f" 2>/dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head4hex" = "664c6143" ]] ; then
echo "VALID|FLAC 'fLaC' signature ok (flac not installed — no deep check)"
else
echo "INVALID|FLAC 'fLaC' signature missing"
fi
fi
;;
ogg|oga|ogv|opus)
if command -v ogginfo > /dev/null 2>&1 ; then
local _ogg_out _ogg_exit
_ogg_out="$(ogginfo "$_f" 2>&1)"
_ogg_exit=$?
if [[ $_ogg_exit -eq 0 ]] ; then
echo "VALID|ogginfo: container parsed ok"
else
local _ogg_err
_ogg_err="$(echo "$_ogg_out" | grep -iv '^Processing\|^$' | head -2 | tr '\n' ' ' | sed 's/ $//')"
echo "INVALID|ogginfo failed${_ogg_err:+ (${_ogg_err})}"
fi
else
local _head4ogg
_head4ogg="$(head -c4 "$_f" 2>/dev/null)"
if [[ "$_head4ogg" = "OggS" ]] ; then
echo "VALID|OGG 'OggS' signature ok (ogginfo not installed — no deep check)"
else
echo "INVALID|OGG 'OggS' signature missing"
fi
fi
;;
wav) wav)
local _riff _wave local _riff _wave
_riff="$(head -c4 "$_f" 2> /dev/null)" _riff="$(head -c4 "$_f" 2> /dev/null)"
@@ -562,6 +788,15 @@ validate_recovered_file() {
## - PDF structural check shared by the 'pdf' case and the 'ai' ## - PDF structural check shared by the 'pdf' case and the 'ai'
## - (PDF-compatible) case. Sets $_pdf_check_detail, returns 0/1. ## - (PDF-compatible) case. Sets $_pdf_check_detail, returns 0/1.
## - ## -
## - qpdf exit codes:
## - 0 no problems
## - 2 structural errors → file is genuinely corrupt/unreadable
## - 3 warnings only → minor spec non-conformances; every real
## - viewer opens the file without issues
## - Only exit code 2 is treated as INVALID here. Exit code 3 (warnings)
## - is reported as VALID with a note, preventing false positives for
## - real-world PDFs that have trivial non-conformances.
## -
_pdf_check() { _pdf_check() {
local _f="$1" local _f="$1"
if [[ "$(head -c5 "$_f" 2> /dev/null)" != "%PDF-" ]] ; then if [[ "$(head -c5 "$_f" 2> /dev/null)" != "%PDF-" ]] ; then
@@ -569,11 +804,22 @@ _pdf_check() {
return 1 return 1
fi fi
if command -v qpdf > /dev/null 2>&1 ; then if command -v qpdf > /dev/null 2>&1 ; then
if qpdf --check "$_f" > /dev/null 2>&1 ; then local _qpdf_out _qpdf_exit
_qpdf_out="$(qpdf --check "$_f" 2>&1)"
_qpdf_exit=$?
if [[ $_qpdf_exit -eq 0 ]] ; then
_pdf_check_detail="qpdf --check ok" _pdf_check_detail="qpdf --check ok"
return 0 return 0
elif [[ $_qpdf_exit -eq 3 ]] ; then
# Warnings only — no structural errors. File is readable by all
# standard PDF viewers; non-conformances are minor/cosmetic.
_pdf_check_detail="qpdf --check ok (warnings only — file is readable)"
return 0
else else
_pdf_check_detail="qpdf --check failed" # Exit code 2 (or unexpected): genuine structural errors.
local _first_err
_first_err="$(echo "$_qpdf_out" | grep -i 'error' | head -1 | sed 's/^[[:space:]]*//')"
_pdf_check_detail="qpdf --check failed (exit ${_qpdf_exit}${_first_err:+: ${_first_err}})"
return 1 return 1
fi fi
fi fi
@@ -741,7 +987,9 @@ if mkdir "$LOCK_DIR" 2> /dev/null ; then
# - Remove lockdir when the script finishes, or when it receives a signal # - Remove lockdir when the script finishes, or when it receives a signal
# - # -
trap clean_up SIGHUP SIGINT SIGTERM # Trap signals AND normal/abnormal exit so that temp files and the
# lock directory are always removed — even on an unexpected crash.
trap 'clean_up 1' SIGHUP SIGINT SIGTERM EXIT
else else
@@ -792,7 +1040,9 @@ if ! $_dry_run_explicit && $terminal ; then
blank_line blank_line
echo -e "\033[37m\033[1mWhich mode should this run use?\033[m" echo -e "\033[37m\033[1mWhich mode should this run use?\033[m"
echo "" echo ""
echo -e " \033[1m[1] Dry-run\033[m - go through everything (selection, re-validation, reporting), but write, back up and verify NOTHING" echo -e " \033[1m[1] Dry-run\033[m - go through everything (selection, re-validation, reporting),
but write, back up and verify NOTHING"
echo ""
echo " [2] Real restore - actually back up, overwrite and verify each file in Nextcloud" echo " [2] Real restore - actually back up, overwrite and verify each file in Nextcloud"
info "Just press Return to use the default: [1] Dry-run." info "Just press Return to use the default: [1] Dry-run."
echo -n " Select mode by number [1]: " echo -n " Select mode by number [1]: "
@@ -900,6 +1150,7 @@ if $terminal ; then
echo -e "Restore already-recovered \033[1mBad Signature\033[m files back into system \033[1m${WEB_BASE_DIR}\033[m" echo -e "Restore already-recovered \033[1mBad Signature\033[m files back into system \033[1m${WEB_BASE_DIR}\033[m"
echo -e "\033[1m echo -e "\033[1m
This WRITES into Nextcloud's live storage. Only files marked VALID This WRITES into Nextcloud's live storage. Only files marked VALID
or plausible/UNVERIFIED ('Nicht pruefbar', but nothing looks wrong)
by a prior recover_bad_signature.sh run are ever restored, each is by a prior recover_bad_signature.sh run are ever restored, each is
re-validated again right before writing, the current (still broken) re-validated again right before writing, the current (still broken)
original is backed up first, and every write is verified by reading original is backed up first, and every write is verified by reading
@@ -999,12 +1250,26 @@ chosen_report="${_available_reports[$((_report_choice-1))]}"
# ============= # =============
# --- Extract "user<TAB>path" pairs for every VALID entry in the chosen # --- Extract "user<TAB>path<TAB>original_size<TAB>validation" quadruples
# --- report. The report has no per-row user column - accounts are # --- for every restorable entry in the chosen report. The report has no
# --- section headers ('---------' / '<user>' / '---------'). Only # --- per-row user column - accounts are section headers ('---------' /
# --- 6-tab-field data rows belong to the following case; the header # --- '<user>' / '---------'). Only 6-tab-field data rows belong to the
# --- row, the 'Datenmuell'/'Nicht pruefbar' detail blocks (2 fields) # --- following case; the header row, the 'Datenmuell'/'Nicht pruefbar'
# --- and the account-statistics lines (no tabs) never match NF==6. # --- detail blocks (2 fields) and the account-statistics lines (no
# --- tabs) never match NF==6.
# ---
# --- Two validation values are picked up, both restored by default:
# --- VALID - a dedicated structural check confirmed the file.
# --- UNVERIFIED - no dedicated check exists ('Nicht pruefbar' in the
# --- recovery report), but nothing about it looks
# --- actively wrong either. The one sub-case that IS a
# --- concrete negative signal - the detail text says
# --- the recovered/original size ratio 'LOOKS OFF' -
# --- is deliberately excluded here, same treatment as
# --- INVALID/'Datenmuell': not restored automatically.
# --- original_size ($3) is carried through so this same distinction
# --- (plausible size ratio or not) can be re-checked with real data
# --- right before writing, not just trusted from the old report.
# ============= # =============
valid_entries_file="${LOCK_DIR}/valid_entries.tsv" valid_entries_file="${LOCK_DIR}/valid_entries.tsv"
@@ -1016,8 +1281,8 @@ awk -F'\t' '
} }
{ {
if (state == 1) { user = $0; state = 2; next } if (state == 1) { user = $0; state = 2; next }
if (NF == 6 && $1 != "path" && $5 == "VALID") { if (NF == 6 && $1 != "path" && ($5 == "VALID" || ($5 == "UNVERIFIED" && $6 !~ /LOOKS OFF/))) {
print user "\t" $1 print user "\t" $1 "\t" $3 "\t" $5
} }
} }
' "$chosen_report" > "$valid_entries_file" ' "$chosen_report" > "$valid_entries_file"
@@ -1027,21 +1292,26 @@ IFS=$'\n' account_arr=($(sort <<<"${unsorted_account_arr[*]}"))
IFS=$CUR_IFS IFS=$CUR_IFS
if [[ ${#account_arr[@]} -eq 0 ]] ; then if [[ ${#account_arr[@]} -eq 0 ]] ; then
fatal "No 'VALID' entries found in '$(basename "$chosen_report")' - nothing to restore." fatal "No restorable (VALID or plausible UNVERIFIED) entries found in '$(basename "$chosen_report")' - nothing to restore."
fi fi
blank_line blank_line
if $terminal ; then if $terminal ; then
echo -e "\033[37m\033[1mAccounts with 'VALID' entries in this report\033[m" echo -e "\033[37m\033[1mAccounts with restorable (VALID + plausible UNVERIFIED) entries in this report\033[m"
echo "" echo ""
for _a in "${account_arr[@]}" ; do for _a in "${account_arr[@]}" ; do
_a_count=$(awk -F'\t' -v u="$_a" '$1==u' "$valid_entries_file" | wc -l) _a_count=$(awk -F'\t' -v u="$_a" '$1==u' "$valid_entries_file" | wc -l)
_a_count_unverified=$(awk -F'\t' -v u="$_a" '$1==u && $4=="UNVERIFIED"' "$valid_entries_file" | wc -l)
if [[ "$_a_count_unverified" -gt 0 ]] ; then
echo " - $_a (${_a_count}, davon ${_a_count_unverified} 'Nicht pruefbar'/plausibel)"
else
echo " - $_a (${_a_count})" echo " - $_a (${_a_count})"
fi
done done
echo "" echo ""
fi fi
echo -n " Account(s) to restore VALID files for (blank separated, or 'all'): " echo -n " Account(s) to restore VALID/plausible-UNVERIFIED files for (blank separated, or 'all'): "
read _input read _input
while true ; do while true ; do
@@ -1067,7 +1337,7 @@ while true ; do
break break
fi fi
echo -n " Account(s) to restore VALID files for (blank separated, or 'all'): " echo -n " Account(s) to restore VALID/plausible-UNVERIFIED files for (blank separated, or 'all'): "
read _input read _input
done done
@@ -1080,6 +1350,8 @@ done
mkdir -p "$report_dir" 2> /dev/null mkdir -p "$report_dir" 2> /dev/null
restore_report_file="${report_dir}/restore_${WEBSITE}_${run_date}.tsv" restore_report_file="${report_dir}/restore_${WEBSITE}_${run_date}.tsv"
check_optional_validation_tools
if $terminal ; then if $terminal ; then
echo "" echo ""
if $DRY_RUN ; then if $DRY_RUN ; then
@@ -1106,7 +1378,7 @@ if $terminal ; then
if $DRY_RUN ; then if $DRY_RUN ; then
info "Dry-run: selection and re-validation run for real, nothing is backed up, written or verified." info "Dry-run: selection and re-validation run for real, nothing is backed up, written or verified."
else else
warn "This WRITES into Nextcloud's live storage, overwriting the still-broken original at its ORIGINAL path with the recovered content - the first script in this toolkit that does so. Only files re-validated as VALID right now are touched. The current (still broken) original is copied byte-for-byte to '${backup_dir}' first, and every write is read back and verified afterwards, but please still spot-check a few results yourself." warn "This WRITES into Nextcloud's live storage, overwriting the still-broken original at its ORIGINAL path with the recovered content - the first script in this toolkit that does so. Only files re-validated as VALID, or as plausible UNVERIFIED (no dedicated check, but nothing looks wrong - clearly marked as such in the report), right now are touched. The current (still broken) original is copied byte-for-byte to '${backup_dir}' first, and every write is read back and verified afterwards, but please still spot-check a few results yourself - especially the UNVERIFIED ones."
fi fi
echo "" echo ""
@@ -1115,12 +1387,24 @@ if $terminal ; then
if [[ "$OK" = "YES" ]] ; then if [[ "$OK" = "YES" ]] ; then
echo "" echo ""
echo "" echo ""
echo -e "\033[1;32mGoing to restore VALID files for each selected account on \033[1;37m$WEBSITE \033[m" echo -e "\033[1;32mGoing to restore VALID/plausible-UNVERIFIED files for each selected account on \033[1;37m$WEBSITE \033[m"
else else
fatal "Abort by user request - Answer as not 'YES'" fatal "Abort by user request - Answer as not 'YES'"
fi fi
fi fi
if $terminal ; then
echo ""
echo -e " \033[1mTemporäre Dateien – werden laufend aktualisiert:\033[m"
echo ""
echo -e " Fortschritt / Debug-Meldungen (PHP-Ausgabe auf STDERR):"
echo -e " \033[1mtail -f ${log_file}\033[m"
echo ""
echo -e " Rohergebnis pro Account (PHP-Ausgabe auf STDOUT, Datei für Datei):"
echo -e " \033[1mtail -f ${LOCK_DIR}/result_<account>.tsv\033[m"
echo ""
fi
{ {
echo "==================================================================" echo "=================================================================="
@@ -1128,11 +1412,13 @@ fi
echo "==================================================================" echo "=================================================================="
echo "" echo ""
echo " Basis-Report (Recovery): $(basename "$chosen_report")" echo " Basis-Report (Recovery): $(basename "$chosen_report")"
echo " Nur Eintraege mit validation=VALID aus diesem Report werden zurueckgeschrieben." echo " Eintraege mit validation=VALID sowie validation=UNVERIFIED ('Nicht pruefbar',"
echo " aber Groessenverhaeltnis plausibel) aus diesem Report werden zurueckgeschrieben -"
echo " letztere sind in der Spalte 'origin' unten als UNVERIFIED gekennzeichnet."
$DRY_RUN && echo " DRY-RUN: es wurde NICHTS geschrieben, gesichert oder verifiziert." $DRY_RUN && echo " DRY-RUN: es wurde NICHTS geschrieben, gesichert oder verifiziert."
! $DRY_RUN && echo " Chiffretext-Sicherung (vor dem Ueberschreiben) liegt unter: ${backup_dir}" ! $DRY_RUN && echo " Chiffretext-Sicherung (vor dem Ueberschreiben) liegt unter: ${backup_dir}"
echo "" echo ""
echo -e "path\tbytes_written\tstatus\tdetail" echo -e "path\tbytes_written\tstatus\torigin\tdetail"
} > "$restore_report_file" } > "$restore_report_file"
@@ -1183,6 +1469,13 @@ if ($oldWorkingDir === false) {
} }
chdir(__DIR__); chdir(__DIR__);
require_once __DIR__ . '/lib/base.php'; require_once __DIR__ . '/lib/base.php';
// Nextcloud's own bootstrap (lib/base.php) unconditionally calls
// set_time_limit(3600) as part of the require above - this OVERRIDES
// whatever -d max_execution_time was passed on the PHP command line,
// since it is a later, explicit runtime call. Undo that here, now that
// the require is done, so restoring a large account isn't killed
// after exactly one hour regardless of the CLI flag.
set_time_limit(0);
chdir($oldWorkingDir); chdir($oldWorkingDir);
if (function_exists('posix_getuid') && posix_getuid() === 0) { if (function_exists('posix_getuid') && posix_getuid() === 0) {
@@ -1356,8 +1649,10 @@ if ! $DRY_RUN ; then
fi fi
declare -i total_selected=0 declare -i total_selected=0
declare -i total_selected_unverified=0
declare -i total_prevalidation_failed=0 declare -i total_prevalidation_failed=0
declare -i total_ok=0 declare -i total_ok=0
declare -i total_ok_unverified=0
declare -i total_write_errors=0 declare -i total_write_errors=0
declare -i total_failed_users=0 declare -i total_failed_users=0
@@ -1378,18 +1673,24 @@ for _user in "${selected_user_arr[@]}" ; do
user_recovery_dir="${recovery_dir}/${_user}" user_recovery_dir="${recovery_dir}/${_user}"
declare -i _user_selected=0 declare -i _user_selected=0
declare -i _user_selected_unverified=0
declare -i _user_prevalidation_failed=0 declare -i _user_prevalidation_failed=0
declare -a _user_prevalidation_failed_lines=() declare -a _user_prevalidation_failed_lines=()
declare -A _origin_of_path=()
list_file="${LOCK_DIR}/list_${_user}.tsv" list_file="${LOCK_DIR}/list_${_user}.tsv"
> "$list_file" > "$list_file"
# ============= # =============
# --- Re-validate every VALID-marked entry against the CURRENT # --- Re-validate every VALID- or plausible-UNVERIFIED-marked entry
# --- checks right before writing anything, and (unless dry-run) # --- against the CURRENT checks right before writing anything, and
# --- back up the current on-disk ciphertext byte-for-byte first. # --- (unless dry-run) back up the current on-disk ciphertext
# --- byte-for-byte first. original_size (from the recovery report,
# --- $3 of valid_entries_file) is passed through so the size-ratio
# --- part of the UNVERIFIED check can run for real here too, not
# --- just be trusted from the old report.
# ============= # =============
while IFS=$'\t' read -r _u _path ; do while IFS=$'\t' read -r _u _path _origsize _recorded_class ; do
[[ "$_u" != "$_user" ]] && continue [[ "$_u" != "$_user" ]] && continue
(( _user_selected++ )) (( _user_selected++ ))
@@ -1402,13 +1703,17 @@ for _user in "${selected_user_arr[@]}" ; do
continue continue
fi fi
_val_result="$(validate_recovered_file "$_local" "$_path" "")" _val_result="$(validate_recovered_file "$_local" "$_path" "$_origsize")"
_val_class="${_val_result%%|*}" _val_class="${_val_result%%|*}"
_val_detail="${_val_result#*|}" _val_detail="${_val_result#*|}"
if [[ "$_val_class" != "VALID" ]] ; then if [[ "$_val_class" == "VALID" ]] ; then
: # dedicated structural check confirmed it - restore as usual
elif [[ "$_val_class" == "UNVERIFIED" && "$_val_detail" != *"LOOKS OFF"* ]] ; then
(( _user_selected_unverified++ ))
else
(( _user_prevalidation_failed++ )) (( _user_prevalidation_failed++ ))
_user_prevalidation_failed_lines+=("${_path}"$'\t'"no longer validates as VALID (${_val_class}: ${_val_detail}) - skipped, not restored") _user_prevalidation_failed_lines+=("${_path}"$'\t'"no longer validates as restorable (${_val_class}: ${_val_detail}) - skipped, not restored")
continue continue
fi fi
@@ -1421,13 +1726,14 @@ for _user in "${selected_user_arr[@]}" ; do
fi fi
fi fi
_origin_of_path["$_path"]="$_val_class"
printf '%s\t%s\n' "$_path" "$_local" >> "$list_file" printf '%s\t%s\n' "$_path" "$_local" >> "$list_file"
done < "$valid_entries_file" done < "$valid_entries_file"
if [[ $_user_selected -eq 0 ]] ; then if [[ $_user_selected -eq 0 ]] ; then
warn "No 'VALID' entries for account '${_user}' found in the selected report - skipping." warn "No restorable (VALID or plausible UNVERIFIED) entries for account '${_user}' found in the selected report - skipping."
echo " [ keine VALID-Eintraege im gewaehlten Report ]" >> "$restore_report_file" echo " [ keine VALID/UNVERIFIED-Eintraege im gewaehlten Report ]" >> "$restore_report_file"
continue continue
fi fi
@@ -1440,10 +1746,15 @@ for _user in "${selected_user_arr[@]}" ; do
fi fi
if [[ -s "$list_file" ]] ; then if [[ -s "$list_file" ]] ; then
# -d max_execution_time=0: re-validating and writing back every
# selected file of a large account can run well over an hour;
# without this the PHP CLI process is killed by PHP's own
# execution-time limit (typically inherited from the webserver's
# php.ini) mid-run.
if $DRY_RUN ; then if $DRY_RUN ; then
su -c "$PHP_BIN $restore_php_file $_user $list_file --dry-run" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file" su -c "$PHP_BIN -d max_execution_time=0 $restore_php_file $_user $list_file --dry-run" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file"
else else
su -c "$PHP_BIN $restore_php_file $_user $list_file" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file" su -c "$PHP_BIN -d max_execution_time=0 $restore_php_file $_user $list_file" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file"
fi fi
_rc=$? _rc=$?
else else
@@ -1463,6 +1774,7 @@ for _user in "${selected_user_arr[@]}" ; do
$terminal && echo "" $terminal && echo ""
declare -i _user_ok=0 declare -i _user_ok=0
declare -i _user_ok_unverified=0
declare -i _user_write_errors=0 declare -i _user_write_errors=0
declare -a _user_write_error_lines=() declare -a _user_write_error_lines=()
@@ -1471,8 +1783,13 @@ for _user in "${selected_user_arr[@]}" ; do
[[ "$_path" = "path" ]] && continue [[ "$_path" = "path" ]] && continue
[[ -z "$_path" ]] && continue [[ -z "$_path" ]] && continue
_origin="${_origin_of_path[$_path]:-VALID}"
case "$_status" in case "$_status" in
OK|DRY_RUN) (( _user_ok++ )) ;; OK|DRY_RUN)
(( _user_ok++ ))
[[ "$_origin" == "UNVERIFIED" ]] && (( _user_ok_unverified++ ))
;;
*) (( _user_write_errors++ )); _user_write_error_lines+=("${_path}"$'\t'"${_status}: ${_detail}") ;; *) (( _user_write_errors++ )); _user_write_error_lines+=("${_path}"$'\t'"${_status}: ${_detail}") ;;
esac esac
@@ -1481,7 +1798,7 @@ for _user in "${selected_user_arr[@]}" ; do
# reinterpret those backslashes as escape sequences and silently # reinterpret those backslashes as escape sequences and silently
# mangle/eat parts of the text. printf's %s never reinterprets its # mangle/eat parts of the text. printf's %s never reinterprets its
# argument, only the literal \t/\n in the format string itself. # argument, only the literal \t/\n in the format string itself.
printf '%s\t%s\t%s\t%s\n' "$_path" "$_bytes" "$_status" "$_detail" >> "$restore_report_file" printf '%s\t%s\t%s\t%s\t%s\n' "$_path" "$_bytes" "$_status" "$_origin" "$_detail" >> "$restore_report_file"
done < "$user_result_tsv" done < "$user_result_tsv"
@@ -1489,7 +1806,10 @@ for _user in "${selected_user_arr[@]}" ; do
if $terminal ; then if $terminal ; then
echo -e " \033[1;37mAccount ${_user}\033[m" echo -e " \033[1;37mAccount ${_user}\033[m"
echo -e " Ausgewaehlt (validation=VALID im Report)..: ${_user_selected}" echo -e " Ausgewaehlt (VALID + plausibel UNVERIFIED).: ${_user_selected}"
if [[ $_user_selected_unverified -gt 0 ]] ; then
echo -e " davon 'Nicht pruefbar'/UNVERIFIED.......: \033[33m${_user_selected_unverified}\033[m"
fi
if [[ $_user_prevalidation_failed -gt 0 ]] ; then if [[ $_user_prevalidation_failed -gt 0 ]] ; then
echo -e " Vor dem Schreiben aussortiert..............: \033[33m${_user_prevalidation_failed}\033[m" echo -e " Vor dem Schreiben aussortiert..............: \033[33m${_user_prevalidation_failed}\033[m"
fi fi
@@ -1501,6 +1821,9 @@ for _user in "${selected_user_arr[@]}" ; do
else else
echo -e " Zurueckgeschrieben & verifiziert...........: ${_user_ok} (${_user_pct_ok} %)" echo -e " Zurueckgeschrieben & verifiziert...........: ${_user_ok} (${_user_pct_ok} %)"
fi fi
if [[ $_user_ok_unverified -gt 0 ]] ; then
echo -e " davon UNVERIFIED (bitte stichprobenartig pruefen): \033[33m${_user_ok_unverified}\033[m"
fi
fi fi
if [[ $_user_write_errors -gt 0 ]] ; then if [[ $_user_write_errors -gt 0 ]] ; then
echo -e " Fehler beim Schreiben/Verifizieren.........: \033[1;31m${_user_write_errors}\033[m" echo -e " Fehler beim Schreiben/Verifizieren.........: \033[1;31m${_user_write_errors}\033[m"
@@ -1536,23 +1859,27 @@ for _user in "${selected_user_arr[@]}" ; do
{ {
echo "" echo ""
echo " Account ${_user}" echo " Account ${_user}"
echo " Ausgewaehlt (validation=VALID im Report)..: ${_user_selected}" echo " Ausgewaehlt (VALID + plausibel UNVERIFIED).: ${_user_selected}"
[[ $_user_selected_unverified -gt 0 ]] && echo " davon 'Nicht pruefbar'/UNVERIFIED.......: ${_user_selected_unverified}"
[[ $_user_prevalidation_failed -gt 0 ]] && echo " Vor dem Schreiben aussortiert..............: ${_user_prevalidation_failed}" [[ $_user_prevalidation_failed -gt 0 ]] && echo " Vor dem Schreiben aussortiert..............: ${_user_prevalidation_failed}"
if $DRY_RUN ; then if $DRY_RUN ; then
echo " Wuerde geschrieben & verifiziert werden....: ${_user_ok} (${_user_pct_ok} %)" echo " Wuerde geschrieben & verifiziert werden....: ${_user_ok} (${_user_pct_ok} %)"
else else
echo " Zurueckgeschrieben & verifiziert...........: ${_user_ok} (${_user_pct_ok} %)" echo " Zurueckgeschrieben & verifiziert...........: ${_user_ok} (${_user_pct_ok} %)"
fi fi
[[ $_user_ok_unverified -gt 0 ]] && echo " davon UNVERIFIED (bitte pruefen).........: ${_user_ok_unverified}"
[[ $_user_write_errors -gt 0 ]] && echo " Fehler beim Schreiben/Verifizieren.........: ${_user_write_errors}" [[ $_user_write_errors -gt 0 ]] && echo " Fehler beim Schreiben/Verifizieren.........: ${_user_write_errors}"
} >> "$restore_report_file" } >> "$restore_report_file"
(( total_selected += _user_selected )) (( total_selected += _user_selected ))
(( total_selected_unverified += _user_selected_unverified ))
(( total_prevalidation_failed += _user_prevalidation_failed )) (( total_prevalidation_failed += _user_prevalidation_failed ))
(( total_ok += _user_ok )) (( total_ok += _user_ok ))
(( total_ok_unverified += _user_ok_unverified ))
(( total_write_errors += _user_write_errors )) (( total_write_errors += _user_write_errors ))
unset _user_valid _user_prevalidation_failed _user_prevalidation_failed_lines unset _user_valid _user_selected_unverified _user_prevalidation_failed _user_prevalidation_failed_lines
unset _user_ok _user_write_errors _user_write_error_lines unset _user_ok _user_ok_unverified _user_write_errors _user_write_error_lines _origin_of_path
done done
@@ -1568,20 +1895,24 @@ total_pct_ok="$(calc_percent "$total_ok" "$total_selected")"
echo "" echo ""
echo "Ausgewaehlte Accounts.......................: ${#selected_user_arr[@]}" echo "Ausgewaehlte Accounts.......................: ${#selected_user_arr[@]}"
[[ $total_failed_users -gt 0 ]] && echo "Accounts mit Restore-Fehler.................: ${total_failed_users}" [[ $total_failed_users -gt 0 ]] && echo "Accounts mit Restore-Fehler.................: ${total_failed_users}"
echo "Ausgewaehlt (validation=VALID im Report)....: ${total_selected}" echo "Ausgewaehlt (VALID + plausibel UNVERIFIED)..: ${total_selected}"
[[ $total_selected_unverified -gt 0 ]] && echo " davon 'Nicht pruefbar'/UNVERIFIED.........: ${total_selected_unverified}"
[[ $total_prevalidation_failed -gt 0 ]] && echo "Vor dem Schreiben aussortiert...............: ${total_prevalidation_failed}" [[ $total_prevalidation_failed -gt 0 ]] && echo "Vor dem Schreiben aussortiert...............: ${total_prevalidation_failed}"
if $DRY_RUN ; then if $DRY_RUN ; then
echo "Wuerde geschrieben & verifiziert werden.....: ${total_ok} (${total_pct_ok} %)" echo "Wuerde geschrieben & verifiziert werden.....: ${total_ok} (${total_pct_ok} %)"
else else
echo "Zurueckgeschrieben & verifiziert insgesamt...: ${total_ok} (${total_pct_ok} %)" echo "Zurueckgeschrieben & verifiziert insgesamt...: ${total_ok} (${total_pct_ok} %)"
fi fi
[[ $total_ok_unverified -gt 0 ]] && echo " davon UNVERIFIED (bitte pruefen)..........: ${total_ok_unverified}"
[[ $total_write_errors -gt 0 ]] && echo "Fehler beim Schreiben/Verifizieren insgesamt: ${total_write_errors}" [[ $total_write_errors -gt 0 ]] && echo "Fehler beim Schreiben/Verifizieren insgesamt: ${total_write_errors}"
echo "" echo ""
if $DRY_RUN ; then if $DRY_RUN ; then
echo "DRY-RUN: es wurde nichts geschrieben, gesichert oder veraendert." echo "DRY-RUN: es wurde nichts geschrieben, gesichert oder veraendert."
else else
echo "Chiffretext-Sicherung (vor dem Ueberschreiben) liegt unter: ${backup_dir}" echo "Chiffretext-Sicherung (vor dem Ueberschreiben) liegt unter: ${backup_dir}"
echo "Bitte Ergebnisse trotz Verifikation stichprobenartig pruefen." echo "Bitte Ergebnisse trotz Verifikation stichprobenartig pruefen - das gilt"
echo "besonders fuer die oben als UNVERIFIED markierten Dateien: dort gibt es"
echo "keine dedizierte Strukturpruefung, nur ein plausibles Groessenverhaeltnis."
fi fi
} >> "$restore_report_file" } >> "$restore_report_file"
@@ -1592,7 +1923,8 @@ if $terminal ; then
echo "" echo ""
echo -e " Ausgewaehlte Accounts.......................: ${#selected_user_arr[@]}" echo -e " Ausgewaehlte Accounts.......................: ${#selected_user_arr[@]}"
[[ $total_failed_users -gt 0 ]] && echo -e " Accounts mit Restore-Fehler.................: \033[1;31m${total_failed_users}\033[m" [[ $total_failed_users -gt 0 ]] && echo -e " Accounts mit Restore-Fehler.................: \033[1;31m${total_failed_users}\033[m"
echo -e " Ausgewaehlt (validation=VALID im Report)....: ${total_selected}" echo -e " Ausgewaehlt (VALID + plausibel UNVERIFIED)..: ${total_selected}"
[[ $total_selected_unverified -gt 0 ]] && echo -e " davon 'Nicht pruefbar'/UNVERIFIED.........: \033[33m${total_selected_unverified}\033[m"
[[ $total_prevalidation_failed -gt 0 ]] && echo -e " Vor dem Schreiben aussortiert...............: \033[33m${total_prevalidation_failed}\033[m" [[ $total_prevalidation_failed -gt 0 ]] && echo -e " Vor dem Schreiben aussortiert...............: \033[33m${total_prevalidation_failed}\033[m"
if $DRY_RUN ; then if $DRY_RUN ; then
echo -e " Wuerde geschrieben & verifiziert werden.....: \033[1;32m${total_ok} (${total_pct_ok} %)\033[m" echo -e " Wuerde geschrieben & verifiziert werden.....: \033[1;32m${total_ok} (${total_pct_ok} %)\033[m"
@@ -1602,6 +1934,7 @@ if $terminal ; then
else else
echo -e " Zurueckgeschrieben & verifiziert insgesamt...: ${total_ok} (${total_pct_ok} %)" echo -e " Zurueckgeschrieben & verifiziert insgesamt...: ${total_ok} (${total_pct_ok} %)"
fi fi
[[ $total_ok_unverified -gt 0 ]] && echo -e " davon UNVERIFIED (bitte pruefen)..........: \033[33m${total_ok_unverified}\033[m"
fi fi
[[ $total_write_errors -gt 0 ]] && echo -e " Fehler beim Schreiben/Verifizieren insgesamt: \033[1;31m${total_write_errors}\033[m" [[ $total_write_errors -gt 0 ]] && echo -e " Fehler beim Schreiben/Verifizieren insgesamt: \033[1;31m${total_write_errors}\033[m"
echo "" echo ""
+26 -1
View File
@@ -582,6 +582,14 @@ fwrite(STDERR, "DEBUG: vor require lib/base.php\n");
require_once __DIR__ . '/lib/base.php'; require_once __DIR__ . '/lib/base.php';
fwrite(STDERR, "DEBUG: nach require lib/base.php - Bootstrap abgeschlossen\n"); fwrite(STDERR, "DEBUG: nach require lib/base.php - Bootstrap abgeschlossen\n");
// Nextcloud's own bootstrap (lib/base.php) unconditionally calls
// set_time_limit(3600) as part of require_once above - this OVERRIDES
// whatever -d max_execution_time was passed on the PHP command line,
// since it is a later, explicit runtime call. Undo that here, now that
// the require is done, so a full-content scan of a large account isn't
// killed after exactly one hour regardless of the CLI flag.
set_time_limit(0);
chdir($oldWorkingDir); chdir($oldWorkingDir);
if (function_exists('posix_getuid') && posix_getuid() === 0) { if (function_exists('posix_getuid') && posix_getuid() === 0) {
@@ -771,6 +779,18 @@ if $terminal ; then
fi fi
fi fi
if $terminal ; then
echo ""
echo -e " \033[1mTemporäre Dateien – werden laufend aktualisiert:\033[m"
echo ""
echo -e " Fortschritt / Debug-Meldungen (PHP-Ausgabe auf STDERR):"
echo -e " \033[1mtail -f ${log_file}\033[m"
echo ""
echo -e " Rohergebnis pro Account (PHP-Ausgabe auf STDOUT, Datei für Datei):"
echo -e " \033[1mtail -f ${LOCK_DIR}/scan_<account>.tsv\033[m"
echo ""
fi
# ----- # -----
# - Main part of the script # - Main part of the script
@@ -810,7 +830,12 @@ for _user in "${selected_user_arr[@]}" ; do
} >> "$report_file" } >> "$report_file"
echononl " Scanning account \033[1;37m${_user}\033[m.." echononl " Scanning account \033[1;37m${_user}\033[m.."
su -c "$PHP_BIN $scan_php_file $_user" -s /bin/bash $HTTP_USER > "$user_tsv" 2> "$log_file" # -d max_execution_time=0: a full-content read of every file of a
# large account can easily run well over an hour; without this the
# PHP CLI process is killed by PHP's own execution-time limit
# (typically inherited from the webserver's php.ini) mid-scan, long
# before any actual error in the account's files.
su -c "$PHP_BIN -d max_execution_time=0 $scan_php_file $_user" -s /bin/bash $HTTP_USER > "$user_tsv" 2> "$log_file"
_rc=$? _rc=$?
if [[ $_rc -ne 0 ]]; then if [[ $_rc -ne 0 ]]; then