From c8997cc2e1f8be0c565590b4c180f50899729071 Mon Sep 17 00:00:00 2001 From: Christoph Date: Fri, 11 Sep 2026 22:20:59 +0200 Subject: [PATCH] Add script 'recover_bad_signature.sh'. --- recover_bad_signature.sh | 1612 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1612 insertions(+) create mode 100755 recover_bad_signature.sh diff --git a/recover_bad_signature.sh b/recover_bad_signature.sh new file mode 100755 index 0000000..5b4c90b --- /dev/null +++ b/recover_bad_signature.sh @@ -0,0 +1,1612 @@ +#!/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" + +# - Das Recovery-Verzeichnis wird bewusst NICHT unter script_dir +# - angelegt: liegen die Scripte z.B. unter /root/..., kann der +# - www-data-Prozess (der die entschluesselten Dateien tatsaechlich +# - schreibt) /root gar nicht erst durchqueren (Modus 700), selbst +# - wenn der Zielordner selbst an www-data gechownt wird - jedes +# - fopen() dort wuerde mit einem Fehler fehlschlagen. Stattdessen ein +# - eigener Pfad ausserhalb von /root und ausserhalb des Web-Docroots, +# - per conf-Datei ueberschreibbar (RECOVERY_BASE_DIR). +# - +DEFAULT_RECOVERY_BASE_DIR="/var/nc-recovery" + +declare -a unsorted_website_arr +declare -a website_arr + +declare -a unsorted_account_arr +declare -a account_arr + +declare -a unsorted_selected_user_arr +declare -a selected_user_arr + +LOCK_DIR="/tmp/${script_name%%.*}.LOCK" +log_file="${LOCK_DIR}/${script_name%%.*}.log" + +run_date=$(date +%Y-%m-%d-%H%M) + +_encryption_flag_changed=false +_orig_skip_check="" + + +# ============= +# --- Some functions +# ============= + +usage() { + + [[ -n "$1" ]] && error "$1" + + [[ $terminal ]] && echo -e " +\033[1mUsage:\033[m + + $(basename $0) -s + +\033[1mDescription\033[m + + Attempts to recover files that were reported as 'Bad Signature' by + scan_bad_signature.sh, by temporarily ignoring the Server-Side + Encryption signature check (Nextcloud's own + 'encryption_skip_signature_check' config switch) and decrypting the + affected files anyway. + + This only works for files whose signature check fails due to a + computation mismatch, NOT for files whose encrypted content itself + is corrupted on disk - those will still produce garbage. Every + recovered file is therefore validated afterwards (file-type magic + bytes, PDF/zip integrity where possible) so you can tell the two + cases apart. + + IMPORTANT: + - Original files are NEVER touched, modified or deleted. Every + recovered file is written to a SEPARATE directory outside of + Nextcloud's own storage (default: ${DEFAULT_RECOVERY_BASE_DIR}/, + override with RECOVERY_BASE_DIR in the conf file) - the exact + path is shown before the confirmation prompt. + - The 'encryption_skip_signature_check' config switch is set + instance-wide for the duration of this script and restored to + its previous value afterwards (also on interrupt/error). + - Recovered files may contain unencrypted personal data outside + of Nextcloud's storage - handle and delete the recovery + directory accordingly once you are done reviewing it. + + Unless '-V' is given on the command line, you will be asked + interactively whether to run a normal recovery or a revalidate-only + pass (see '-V' below). You will then be asked which existing scan + report (produced by scan_bad_signature.sh) to use, and then which + account(s) from that report to attempt recovery for: + + - a blank separated list of account names + - or 'all' to attempt every account found in the report + +\033[1mOptions\033[m + + -s + The site of the nextcloud instance. + + -V + Revalidate-only mode: does NOT touch 'encryption_skip_signature_check' + and does NOT decrypt anything again. Instead it re-runs the + (possibly improved) validation checks against files that were + already recovered by a previous, normal run - useful after this + script gained new file-type checks, without repeating the + instance-wide signature-skip window. 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 + + Re-validates already recovered files on 'cloud-01.oopen.de' with + the current checks, without decrypting anything again: + + $(basename $0) -V -s cloud-01.oopen.de +" + + clean_up 1 + +} + + +restore_encryption_flag() { + + # - Setzt encryption_skip_signature_check auf den Ursprungswert + # - zurueck, egal ob das Skript normal durchlaeuft oder + # - unterbrochen wird. Kein-Op, falls das Flag nie geaendert wurde. + # - + if [[ "$_encryption_flag_changed" = true ]] ; then + echononl " Restoring encryption_skip_signature_check.." + if [[ -z "$_orig_skip_check" ]] ; then + su -c "$PHP_BIN $INSTALL_DIR/occ config:system:delete encryption_skip_signature_check" -s /bin/bash $HTTP_USER > /dev/null 2> "$log_file" + else + su -c "$PHP_BIN $INSTALL_DIR/occ config:system:set encryption_skip_signature_check --value=$_orig_skip_check --type=boolean" -s /bin/bash $HTTP_USER > /dev/null 2> "$log_file" + fi + + if [[ $? -eq 0 ]] ; then + echo_ok + _encryption_flag_changed=false + else + echo_failed + error "Could not restore encryption_skip_signature_check automatically! Please check/fix manually on the server: occ config:system:get encryption_skip_signature_check (should be '${_orig_skip_check:-}'). $(cat "$log_file")" + fi + fi + +} + + +clean_up() { + + # Perform program exit housekeeping + restore_encryption_flag + [[ -n "$recovery_php_file" ]] && rm -f "$recovery_php_file" 2> /dev/null + rm -rf "$LOCK_DIR" + blank_line + exit $1 +} + +is_number() { + + return $(test ! -z "${1##*[!0-9]*}" > /dev/null 2>&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" +} + +## - Check if a given array (parameter 2) contains a given string (parameter 1) +## - +containsElement () { + local e + for e in "${@:2}"; do [[ "$e" == "$1" ]] && return 0; done + return 1 +} + +## - 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 +} + +## - Best-effort structural validation of a recovered (signature-skip +## - decrypted) file. Dispatches primarily on the original file +## - extension; falls back to content-based detection (libmagic / +## - 'file') for unknown or missing extensions, or as a last resort +## - a text/binary heuristic + size comparison. Prints "CLASS|detail" - +## - CLASS is one of VALID / INVALID / UNVERIFIED. +## - +validate_recovered_file() { + local _f="$1" _origpath="$2" _origsize="$3" + local _base="$(basename -- "$_origpath")" + local _ext="" + if [[ "$_base" == *.* ]] ; then + _ext="${_origpath##*.}" + _ext="$(echo "$_ext" | tr '[:upper:]' '[:lower:]')" + fi + local _size + _size=$(stat -c%s "$_f" 2> /dev/null) + [[ -z "$_size" ]] && _size=0 + + case "$_ext" in + pdf) + if _pdf_check "$_f" ; then + echo "VALID|$_pdf_check_detail" + else + echo "INVALID|$_pdf_check_detail" + fi + ;; + jpg|jpeg) + if _jpeg_check "$_f" ; then + echo "VALID|$_jpeg_check_detail" + else + echo "INVALID|$_jpeg_check_detail" + fi + ;; + png) + local _head + _head="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" + if [[ "$_head" = "89504e470d0a1a0a" ]] ; then + echo "VALID|PNG signature ok" + else + echo "INVALID|PNG signature missing" + fi + ;; + gif) + local _head6 + _head6="$(head -c6 "$_f" 2> /dev/null)" + if [[ "$_head6" = "GIF87a" || "$_head6" = "GIF89a" ]] ; then + echo "VALID|GIF signature ok" + else + echo "INVALID|GIF signature missing" + fi + ;; + bmp) + if [[ "$(head -c2 "$_f" 2> /dev/null)" = "BM" ]] ; then + echo "VALID|BMP signature ok" + else + echo "INVALID|BMP signature missing" + fi + ;; + tif|tiff) + local _head4 + _head4="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" + if [[ "$_head4" = "49492a00" || "$_head4" = "4d4d002a" ]] ; then + echo "VALID|TIFF signature ok" + else + echo "INVALID|TIFF signature missing" + fi + ;; + pnm|pgm|ppm|pbm) + local _head2 + _head2="$(head -c2 "$_f" 2> /dev/null)" + if [[ "$_head2" =~ ^P[1-6]$ ]] ; then + echo "VALID|PNM/PGM/PPM magic ok" + else + echo "INVALID|PNM/PGM/PPM magic (P1-P6) missing" + fi + ;; + odt|ods|odp|odg|odf|ott|ots|otp|otg|docx|xlsx|pptx|docm|xlsm|pptm|dotm|xltm|potm|ppsm|ppsx|zip|epub) + # A password-protected MS Office file (Standard/Agile encryption, + # MS-OFFCRYPTO) is NOT a zip at all, regardless of its docx/xlsx/ + # pptx extension: the whole package is wrapped in an OLE2/CFBF + # compound-file container (the same format legacy .doc/.xls/.ppt + # use), with the real zip payload encrypted inside an internal + # stream. 'unzip -t' correctly fails on that - not because + # anything is corrupt, but because it is legitimately not a zip + # file. Recognize this case before treating a failed zip check + # as corruption. + local _head8o + _head8o="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" + 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" + elif command -v unzip > /dev/null 2>&1 ; then + if unzip -tq "$_f" > /dev/null 2>&1 ; then + echo "VALID|zip integrity ok" + else + local _badentry + _badentry="$(trim "$(unzip -t "$_f" 2>&1 | grep -v '^Archive:' | grep -v '^[[:space:]]*$' | head -1)")" + echo "INVALID|zip integrity check failed${_badentry:+ (${_badentry})}" + fi + else + echo "UNVERIFIED|unzip not installed" + fi + ;; + mp4|mov|m4v) + if head -c 64 "$_f" 2> /dev/null | grep -aq "ftyp" ; then + echo "VALID|mp4 ftyp box found" + else + echo "INVALID|mp4 ftyp box not found" + fi + ;; + doc|xls|ppt|ole|msi) + local _head8 + _head8="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" + if [[ "$_head8" = "d0cf11e0a1b11ae1" ]] ; then + echo "VALID|OLE2 Compound File signature ok (legacy Office)" + else + echo "INVALID|OLE2 Compound File signature missing" + fi + ;; + azw3|mobi|prc) + # PalmDOC/Mobipocket container: 8-byte type/creator id + # 'BOOKMOBI' at fixed offset 60 in the header. + local _sig + _sig="$(dd if="$_f" bs=1 skip=60 count=8 2> /dev/null)" + if [[ "$_sig" = "BOOKMOBI" ]] ; then + echo "VALID|BOOKMOBI (Kindle/Mobi) signature ok" + else + echo "INVALID|BOOKMOBI signature missing at offset 60" + fi + ;; + ai) + # Modern Illustrator files are PDF-compatible by default; + # older ones are plain PostScript/EPS. + if [[ "$(head -c5 "$_f" 2> /dev/null)" = "%PDF-" ]] ; then + if _pdf_check "$_f" ; then + echo "VALID|AI (PDF-compatible) - $_pdf_check_detail" + else + echo "UNVERIFIED|AI (PDF-compatible) but $_pdf_check_detail" + fi + elif head -c 12 "$_f" 2> /dev/null | grep -qa '^%!PS-Adobe' ; then + echo "VALID|AI (legacy PostScript/EPS) header ok" + else + echo "INVALID|neither PDF- nor %!PS-Adobe header found" + fi + ;; + htm|html) + if head -c 512 "$_f" 2> /dev/null | tr '[:upper:]' '[:lower:]' | grep -qE '/ found near start" + fi + ;; + css) + # Plain text + balanced braces is a solid signal: decryption + # garbage is essentially never valid, balanced-brace text. + if LC_ALL=C grep -qP '[\x00-\x08\x0E-\x1F\x7F]' "$_f" 2> /dev/null ; then + echo "INVALID|contains binary/control bytes, not plausible CSS text" + else + local _open _close + _open=$(grep -o '{' "$_f" 2> /dev/null | wc -l) + _close=$(grep -o '}' "$_f" 2> /dev/null | wc -l) + if [[ "$_open" -gt 0 && "$_open" -eq "$_close" ]] ; then + echo "VALID|plain text, ${_open} matching { } pairs" + else + echo "INVALID|plain text but { (${_open}) / } (${_close}) counts don't match" + fi + fi + ;; + ttf|otf) + local _head4f + _head4f="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" + # 00010000 = TrueType, 4F54544F = 'OTTO' (OpenType/CFF), + # 74727565 = 'true' (old Mac TrueType), 74746366 = 'ttcf' (collection) + if [[ "$_head4f" = "00010000" || "$_head4f" = "4f54544f" || "$_head4f" = "74727565" || "$_head4f" = "74746366" ]] ; then + echo "VALID|TrueType/OpenType font signature ok" + else + echo "INVALID|TrueType/OpenType font signature missing (head=$_head4f)" + fi + ;; + woff) + if [[ "$(head -c4 "$_f" 2> /dev/null)" = "wOFF" ]] ; then + echo "VALID|WOFF font signature ok" + else + echo "INVALID|WOFF font signature missing" + fi + ;; + woff2) + if [[ "$(head -c4 "$_f" 2> /dev/null)" = "wOF2" ]] ; then + echo "VALID|WOFF2 font signature ok" + else + echo "INVALID|WOFF2 font signature missing" + fi + ;; + psd) + if [[ "$(head -c4 "$_f" 2> /dev/null)" = "8BPS" ]] ; then + echo "VALID|Photoshop (8BPS) signature ok" + else + echo "INVALID|Photoshop (8BPS) signature missing" + fi + ;; + xcf) + if head -c 9 "$_f" 2> /dev/null | grep -qa '^gimp xcf' ; then + echo "VALID|GIMP (gimp xcf) signature ok" + else + echo "INVALID|GIMP (gimp xcf) signature missing" + fi + ;; + wav) + local _riff _wave + _riff="$(head -c4 "$_f" 2> /dev/null)" + _wave="$(dd if="$_f" bs=1 skip=8 count=4 2> /dev/null)" + if [[ "$_riff" = "RIFF" && "$_wave" = "WAVE" ]] ; then + echo "VALID|RIFF/WAVE signature ok" + else + echo "INVALID|RIFF/WAVE signature missing" + fi + ;; + ico) + local _head4i + _head4i="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" + if [[ "$_head4i" = "00000100" ]] ; then + echo "VALID|ICO signature ok" + else + echo "INVALID|ICO signature missing (head=$_head4i)" + fi + ;; + rtf) + if head -c 6 "$_f" 2> /dev/null | grep -qa '^{\\rtf' ; then + echo "VALID|RTF signature ok" + else + echo "INVALID|RTF signature missing" + fi + ;; + exe|dll|sys|scr|ocx|cpl) + # Windows PE: 'MZ' at offset 0, then at the offset stored (as a + # little-endian uint32) at 0x3C there must be a 'PE\0\0' signature. + local _mz _pe_off_hex _pe_off _pe_sig + _mz="$(head -c2 "$_f" 2> /dev/null)" + if [[ "$_mz" != "MZ" ]] ; then + echo "INVALID|PE 'MZ' header missing" + else + _pe_off_hex="$(dd if="$_f" bs=1 skip=60 count=4 2> /dev/null | od -An -tx1 | tr -d ' \n')" + # little-endian: reverse the byte order before interpreting as hex + _pe_off=$((16#${_pe_off_hex:6:2}${_pe_off_hex:4:2}${_pe_off_hex:2:2}${_pe_off_hex:0:2})) + _pe_sig="$(dd if="$_f" bs=1 skip="$_pe_off" count=4 2> /dev/null | od -An -tx1 | tr -d ' \n')" + if [[ "$_pe_sig" = "50450000" ]] ; then + echo "VALID|PE ('MZ' + 'PE\\0\\0') signature ok" + else + echo "INVALID|'MZ' header ok but 'PE' signature missing at offset $_pe_off (got $_pe_sig)" + fi + fi + ;; + cab) + if [[ "$(head -c4 "$_f" 2> /dev/null)" = "MSCF" ]] ; then + echo "VALID|Microsoft Cabinet (MSCF) signature ok" + else + echo "INVALID|Microsoft Cabinet (MSCF) signature missing" + fi + ;; + js|mjs|json|xml|svg|txt|csv|tsv|ini|cfg|conf|log|md|yml|yaml|properties|sql|sh|bash|py|php|java|c|h|cpp|hpp) + # Plain-text formats: broken decryption output is essentially + # never clean, printable text over any non-trivial length, so + # "no binary/control bytes" is itself strong positive evidence - + # much stronger than for an unknown/binary extension, where the + # same check only feeds the generic fallback below. + if LC_ALL=C grep -qP '[\x00-\x08\x0E-\x1F\x7F]' "$_f" 2> /dev/null ; then + echo "INVALID|contains binary/control bytes, not plausible '.${_ext}' text" + else + echo "VALID|plain text, no binary/control bytes (no deeper '.${_ext}' syntax check performed)" + fi + ;; + indd|indt|indl) + # Adobe InDesign document/template/library container. There is + # no open decoder to fully parse it, but every valid file (since + # CS2/CS4) starts with this fixed 16-byte GUID-like signature - + # a corrupted/garbage decryption result will essentially never + # reproduce it by chance. + local _head16 + _head16="$(head -c16 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" + if [[ "$_head16" = "0606edf5d81d46e5bd31efe7fe74b71d" ]] ; then + echo "VALID|InDesign container signature ok (content/layout itself not parsed - open in InDesign to fully confirm)" + else + echo "INVALID|InDesign container signature missing (head=$_head16)" + fi + ;; + *) + _validate_by_content_or_heuristic "$_f" "$_ext" "$_size" "$_origsize" + ;; + esac +} + +## - PDF structural check shared by the 'pdf' case and the 'ai' +## - (PDF-compatible) case. Sets $_pdf_check_detail, returns 0/1. +## - +_pdf_check() { + local _f="$1" + if [[ "$(head -c5 "$_f" 2> /dev/null)" != "%PDF-" ]] ; then + _pdf_check_detail="missing %PDF- header" + return 1 + fi + if command -v qpdf > /dev/null 2>&1 ; then + if qpdf --check "$_f" > /dev/null 2>&1 ; then + _pdf_check_detail="qpdf --check ok" + return 0 + else + _pdf_check_detail="qpdf --check failed" + return 1 + fi + fi + _pdf_check_detail="PDF header ok (qpdf not installed, no deep check)" + return 0 +} + +## - JPEG structural check. A raw "does the file end in FFD9" byte check +## - produces false positives: many real-world JPEGs (EXIF/thumbnail +## - trailers, app-specific padding, camera-written footers) legally carry +## - a few extra bytes AFTER the actual End-Of-Image marker, so the file's +## - very last 2 bytes are often NOT ffd9 even though the image is 100% +## - intact and every viewer opens it fine. We therefore prefer an actual +## - JPEG decoder (which only cares whether the compressed image data is +## - intact, not what trails after it) and only fall back to the old +## - marker heuristic if no decoder tool is available. +## - Sets $_jpeg_check_detail, returns 0/1. +_jpeg_check() { + local _f="$1" + if command -v identify > /dev/null 2>&1 ; then + if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then + _jpeg_check_detail="ImageMagick 'identify' decoded the image ok" + return 0 + else + _jpeg_check_detail="ImageMagick 'identify' failed to decode the image" + return 1 + fi + fi + if command -v php > /dev/null 2>&1 ; then + local _phpout + _phpout="$(php -r ' + error_reporting(0); + if (!function_exists("imagecreatefromjpeg")) { echo "NOGD"; exit(0); } + $im = @imagecreatefromjpeg($argv[1]); + if ($im === false) { echo "BAD"; exit(0); } + imagedestroy($im); + echo "OK"; + ' "$_f" 2> /dev/null)" + case "$_phpout" in + OK) + _jpeg_check_detail="PHP GD (imagecreatefromjpeg) decoded the image ok" + return 0 + ;; + BAD) + _jpeg_check_detail="PHP GD (imagecreatefromjpeg) failed to decode the image" + return 1 + ;; + esac + # NOGD or empty output: GD extension not available, fall through + fi + # last resort: the old, less reliable marker heuristic + local _head _tail + _head="$(head -c3 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" + _tail="$(tail -c2 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')" + if [[ "$_head" = "ffd8ff" && "$_tail" = "ffd9" ]] ; then + _jpeg_check_detail="JPEG SOI/EOI markers ok (no decoder available for a deep check)" + return 0 + elif [[ "$_head" = "ffd8ff" ]] ; then + _jpeg_check_detail="JPEG SOI marker ok but no decoder available for a deep check; raw EOI-at-EOF heuristic failed (head=$_head tail=$_tail) - this is NOT reliable, many valid JPEGs carry trailer bytes after EOI, verify manually or install ImageMagick/php-gd" + return 1 + else + _jpeg_check_detail="JPEG SOI marker missing (head=$_head)" + return 1 + fi +} + +## - Fallback for extensions without a dedicated structural check (or +## - no extension at all): use 'file'/libmagic to see what the content +## - actually looks like. If it is confidently one of our known strong +## - types (this can happen for misnamed files, e.g. a '.pdf' saved +## - without the dot), re-run the matching dedicated check instead of +## - just guessing. Otherwise report the detected type plus a +## - text/binary plausibility check - never auto-classifies as INVALID +## - purely from an unrecognised type, since libmagic not knowing a +## - rare/proprietary format is not proof of corruption. +## - +_validate_by_content_or_heuristic() { + local _f="$1" _ext="$2" _size="$3" _origsize="$4" + local _magic="" + + if command -v file > /dev/null 2>&1 ; then + _magic="$(file --mime-type -b "$_f" 2> /dev/null)" + fi + + case "$_magic" in + application/pdf) + if _pdf_check "$_f" ; then + echo "VALID|content-detected PDF ('.${_ext:-}' extension), $_pdf_check_detail" + else + echo "UNVERIFIED|content-detected PDF ('.${_ext:-}' extension), but $_pdf_check_detail" + fi + return + ;; + image/jpeg|image/png|image/gif|image/bmp|image/tiff|application/zip|video/mp4|video/quicktime) + echo "UNVERIFIED|content-detected as $_magic (extension was '.${_ext:-}') - re-run with that extension to structurally validate, or check manually" + return + ;; + esac + + # - Kein starker Treffer ueber libmagic: zumindest grob zwischen + # - "sieht nach Text aus" und "enthaelt binaere/Steuerzeichen" + # - unterscheiden - reine Zufalls-/Entschluesselungs-Reste sind so + # - gut wie nie durchgehend druckbarer Text. + # - + local _looks_like_text="unknown" + if LC_ALL=C grep -qP '[\x00-\x08\x0E-\x1F\x7F]' "$_f" 2> /dev/null ; then + _looks_like_text="no (contains binary/control bytes)" + else + _looks_like_text="yes (no binary/control bytes found)" + fi + + # Note: recovered (plaintext) is ALWAYS smaller than original + # (ciphertext on disk, incl. IV/signature/padding overhead per 8192-byte + # block) - roughly 74-86% of it for this instance. That's normal and + # expected, not a sign of corruption; we only flag it if the ratio is + # implausible (e.g. way too small, suggesting truncation). + local _size_note + if [[ -n "$_origsize" && "$_origsize" =~ ^[0-9]+$ && "$_origsize" -gt 0 && "$_size" -gt 0 ]] ; then + local _ratio_pct=$(( _size * 100 / _origsize )) + if [[ "$_ratio_pct" -ge 65 && "$_ratio_pct" -le 95 ]] ; then + _size_note="size ratio plausible (recovered ${_size} / original ${_origsize} = ${_ratio_pct}%, expected ~74-86% for this instance)" + else + _size_note="size ratio LOOKS OFF (recovered ${_size} / original ${_origsize} = ${_ratio_pct}%, expected ~74-86% - check manually)" + fi + else + _size_note="size comparison not possible (recovered ${_size} bytes, original ${_origsize:-?} bytes)" + fi + + echo "UNVERIFIED|no dedicated check for '.${_ext:-}', detected type: ${_magic:-file/libmagic not installed}, looks like text: ${_looks_like_text}, ${_size_note}" +} + +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 (chown/chmod of the recovery +# - directory, 'su' into the webserver user, 'occ config:system:set'). +# - Fail fast with a clear message instead of a confusing permission +# - error somewhere in the middle of the run. +# - +if [[ "$(id -u)" -ne 0 ]] ; then + fatal "This script must be run as root (it needs to chown/chmod the recovery directory, 'su' into the webserver user and run 'occ config:system:set'). 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 +# ------------- +REVALIDATE_ONLY=false +_revalidate_only_explicit=false + +while getopts hVs: opt ; do + case $opt in + h) usage ;; + V) REVALIDATE_ONLY=true; _revalidate_only_explicit=true ;; + s) WEBSITE=$OPTARG ;; + \?) usage + esac +done + + +# ============= +# --- Ask which mode to run in, unless '-V' was already given on the +# --- command line (that still works unchanged for scripted/non- +# --- interactive invocations). +# ============= +if ! $_revalidate_only_explicit && $terminal ; then + + blank_line + echo -e "\033[37m\033[1mWhich mode should this run use?\033[m" + 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}/ + 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." + echo -n " Select mode by number [1]: " + read _mode_choice + + case "$(trim "$_mode_choice")" in + 2) REVALIDATE_ONLY=true ;; + ""|1) REVALIDATE_ONLY=false ;; + *) fatal "Invalid selection '$_mode_choice'." ;; + esac + +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)" + +# - Recovery-Zielverzeichnis: bewusst ausserhalb von script_dir/WEB_BASE_DIR +# - (siehe Kommentar bei DEFAULT_RECOVERY_BASE_DIR weiter oben) - eine +# - per-Website Unterordner, den 'www-data' auch tatsaechlich erreichen +# - kann. +# - +[[ -n "$RECOVERY_BASE_DIR" ]] || RECOVERY_BASE_DIR=$DEFAULT_RECOVERY_BASE_DIR +recovery_dir="${RECOVERY_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 + +#clear + +if $terminal ; then + echo "" + echo -e "\033[32m-----\033[m" + echo -e "Attempt to recover \033[1mBad Signature\033[m files on system \033[1m${WEB_BASE_DIR}\033[m" + echo -e "\033[1m + Original files are never touched. Recovered content is written to a + separate directory and must be reviewed manually.\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 + + +# ============= +# --- Check Server-Side Encryption status +# ============= + +blank_line +echononl " Check Server-Side Encryption status.." + +_encryption_status_out="$(su -c "$PHP_BIN $INSTALL_DIR/occ encryption:status" -s /bin/bash $HTTP_USER 2> "$log_file")" + +ENCRYPTION_ENABLED="unknown" + +if [[ -s "$log_file" ]] ; then + echo_failed + error "$(cat "$log_file")" +elif echo "$_encryption_status_out" | grep -qiE 'enabled:[[:space:]]*true' ; then + echo_ok + ENCRYPTION_ENABLED="yes" +else + echo_warning + ENCRYPTION_ENABLED="no" +fi + +if [[ "$ENCRYPTION_ENABLED" != "yes" && "$REVALIDATE_ONLY" = false ]] ; then + fatal "Server-Side Encryption is not enabled on ${WEBSITE} - a signature-skip recovery attempt makes no sense here." +fi + + +# ============= +# --- Determine available bad-signature reports +# ============= + +mapfile -t _available_reports < <(ls -t "${report_dir}"/bad_signature_*.tsv 2> /dev/null) + +if [[ ${#_available_reports[@]} -eq 0 ]] ; then + fatal "No scan report files (bad_signature_*.tsv) found in '${report_dir}'. Run scan_bad_signature.sh first." +fi + +blank_line +if $terminal ; then + echo -e "\033[37m\033[1mAvailable bad-signature reports (newest first)\033[m" + echo "" + _i=1 + for _f in "${_available_reports[@]}" ; do + printf " [%2d] %s\n" "$_i" "$(basename "$_f")" + (( _i++ )) + done + echo "" +fi + +echo -n " Select report by number: " +read _report_choice + +if ! [[ "$_report_choice" =~ ^[0-9]+$ ]] || [[ "$_report_choice" -lt 1 ]] || [[ "$_report_choice" -gt ${#_available_reports[@]} ]] ; then + fatal "Invalid selection '$_report_choice'." +fi + +chosen_report="${_available_reports[$((_report_choice-1))]}" + + +# ============= +# --- Determine accounts contained in the chosen report +# ============= + +unsorted_account_arr=($(awk -F'\t' 'NF==5 && $3 ~ /^[0-9]+$/ {print $1}' "$chosen_report" | sort -u)) +IFS=$'\n' account_arr=($(sort <<<"${unsorted_account_arr[*]}")) +IFS=$CUR_IFS + +if [[ ${#account_arr[@]} -eq 0 ]] ; then + fatal "No bad-signature entries found in '$(basename "$chosen_report")'." +fi + +blank_line +if $terminal ; then + echo -e "\033[37m\033[1mAccounts with 'Bad Signature' entries in this report\033[m" + echo "" + for _a in "${account_arr[@]}" ; do + echo " - $_a" + done + echo "" +fi + +echo -n " Account(s) to attempt recovery for (blank separated, or 'all'): " +read _input + +while true ; do + + IFS=' ' read -r -a unsorted_selected_user_arr <<< "$(trim "$_input")" + + if [[ "${#unsorted_selected_user_arr[@]}" -eq 1 && "${unsorted_selected_user_arr[0],,}" = "all" ]] ; then + selected_user_arr=("${account_arr[@]}") + break + fi + + _all_valid=true + for _u in "${unsorted_selected_user_arr[@]}" ; do + if ! containsElement "$_u" "${account_arr[@]}" ; then + error "Unknown account '$_u' (not found in the selected report)." + _all_valid=false + fi + done + + if $_all_valid && [[ "${#unsorted_selected_user_arr[@]}" -gt 0 ]] ; then + IFS=$'\n' selected_user_arr=($(sort <<<"${unsorted_selected_user_arr[*]}")) + IFS=$CUR_IFS + break + fi + + echo -n " Account(s) to attempt recovery for (blank separated, or 'all'): " + read _input + +done + + +# ============= +# --- Confirmation +# ============= + +mkdir -p "$report_dir" 2> /dev/null +recovery_report_file="${report_dir}/recovery_${WEBSITE}_${run_date}.tsv" + +if $terminal ; then + echo "" + if $REVALIDATE_ONLY ; then + echo -e "\033[1;32mStarting revalidate-only run for \033[1;37m${WEBSITE}\033[m" + else + echo -e "\033[1;32mStarting recovery attempt for \033[1;37m${WEBSITE}\033[m" + fi + echo "" + echo -e " Cloud instance to be scanned.........: $WEBSITE" + echo "" + echo -e " Report used...........................: $(basename "$chosen_report")" + echo -e " Account(s) to recover.................: \033[33m${selected_user_arr[*]}\033[m" + echo "" + if $REVALIDATE_ONLY ; then + echo -e " Existing recovered files under........: $recovery_dir" + echo -e " Revalidate-only report..................: reports/$(basename "${recovery_report_file}")" + else + echo -e " Recovered files will be written to....: $recovery_dir" + echo -e " Recovery report........................: reports/$(basename "${recovery_report_file}")" + fi + echo "" + + if $REVALIDATE_ONLY ; then + info "Revalidate-only mode: nothing is decrypted again, no config value is changed. Only the current (possibly newer) validation checks are re-run against files already recovered by a previous, normal run." + else + warn "This will TEMPORARILY set 'encryption_skip_signature_check' to true instance-wide, so already-broken files can be decrypted despite failing the signature check. It is restored to its previous value automatically when this script ends (also on Ctrl-C or error). Files with genuinely corrupted ciphertext will still come out as garbage - every recovered file is validated afterwards, but please still spot-check important results yourself before relying on them." + 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 attempt recovery for each selected account on \033[1;37m$WEBSITE \033[m" + else + fatal "Abort by user request - Answer as not 'YES'" + fi +fi + + +{ + echo "==================================================================" + echo " Bad-Signature-Recovery-Versuch ${WEBSITE} ${run_date}" + echo "==================================================================" + echo "" + echo " Basis-Report: $(basename "$chosen_report")" + echo " Wiederhergestellte Dateien liegen unter: ${recovery_dir}" + echo "" + echo -e "path\trecovered_bytes\toriginal_size\tstatus\tvalidation\tdetail" +} > "$recovery_report_file" + + +if ! $REVALIDATE_ONLY ; then + +# ============= +# --- Temporarily enable encryption_skip_signature_check +# ============= + +blank_line +echononl " Reading current encryption_skip_signature_check value.." +_orig_skip_check="$(su -c "$PHP_BIN $INSTALL_DIR/occ config:system:get encryption_skip_signature_check" -s /bin/bash $HTTP_USER 2> /dev/null)" +_orig_skip_check="$(trim "$_orig_skip_check")" +echo_done + +echononl " Enabling encryption_skip_signature_check (temporary)..." +su -c "$PHP_BIN $INSTALL_DIR/occ config:system:set encryption_skip_signature_check --value=true --type=boolean" -s /bin/bash $HTTP_USER > /dev/null 2> "$log_file" +if [[ $? -eq 0 ]] ; then + echo_ok + _encryption_flag_changed=true + trap 'restore_encryption_flag; clean_up 1' SIGHUP SIGINT SIGTERM +else + echo_failed + fatal "Could not enable encryption_skip_signature_check: $(cat "$log_file")" +fi + +fi + + +if ! $REVALIDATE_ONLY ; then + +# ============= +# --- Write the embedded PHP recovery script +# ============= + +recovery_php_file="${INSTALL_DIR}/.recover_bad_signature_$$.php" + +cat > "$recovery_php_file" <<'PHP_RECOVER_EOF' + + * listFile: TSV with "path\tsize\tmtime" per line (no header) + */ + +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +fwrite(STDERR, "DEBUG: Recovery-Skript gestartet, PID=" . getmypid() . "\n"); + +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; + $outDir = $argv[3] ?? null; + + if ($uid === null || $listFile === null || $outDir === null) { + fwrite(STDERR, "Usage: php recover_bad_signature.php \n"); + exit(1); + } + if (!is_file($listFile)) { + fwrite(STDERR, "Liste nicht gefunden: $listFile\n"); + exit(1); + } + if (!is_dir($outDir)) { + @mkdir($outDir, 0750, true); + } + + $rootFolder = \OC::$server->get(\OCP\Files\IRootFolder::class); + \OC_Util::setupFS($uid); + + $lines = file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + $lines = []; + } + + $total = 0; + $ok = 0; + $failed = 0; + + echo "path\trecovered_bytes\toriginal_size\tstatus\n"; + + foreach ($lines as $line) { + $cols = explode("\t", $line); + $path = $cols[0] ?? ''; + $origSize = $cols[1] ?? ''; + if ($path === '') { + continue; + } + $total++; + + try { + $node = $rootFolder->get($path); + } catch (\Throwable $e) { + $failed++; + $msg = str_replace(["\t", "\n", "\r"], ' ', $e->getMessage()); + echo "$path\t0\t$origSize\tNOT_FOUND: $msg\n"; + flush(); + continue; + } + + if (!($node instanceof \OCP\Files\File)) { + $failed++; + echo "$path\t0\t$origSize\tNOT_A_FILE\n"; + flush(); + continue; + } + + // Zielpfad unterhalb outDir, gleiche relative Struktur wie + // innerhalb der Nextcloud-Dateien des Users (ohne //files/) + $relative = preg_replace('#^/' . preg_quote($uid, '#') . '/files/#', '', $path); + if ($relative === null || $relative === '') { + $relative = ltrim($path, '/'); + } + $targetPath = rtrim($outDir, '/') . '/' . $relative; + $targetDir = dirname($targetPath); + if (!is_dir($targetDir)) { + @mkdir($targetDir, 0750, true); + } + + try { + $in = $node->fopen('r'); + if ($in === false) { + throw new \RuntimeException('fopen lieferte false'); + } + $out = fopen($targetPath, 'w'); + if ($out === false) { + throw new \RuntimeException("Zieldatei konnte nicht geoeffnet werden: $targetPath"); + } + $bytes = stream_copy_to_stream($in, $out); + fclose($in); + fclose($out); + + if ($bytes === false) { + throw new \RuntimeException('stream_copy_to_stream schlug fehl'); + } + + $ok++; + echo "$path\t$bytes\t$origSize\tOK\n"; + flush(); + } catch (\Throwable $e) { + $failed++; + $msg = str_replace(["\t", "\n", "\r"], ' ', $e->getMessage()); + echo "$path\t0\t$origSize\tERROR: " . get_class($e) . ": $msg\n"; + flush(); + @unlink($targetPath); + } + } + + fwrite(STDERR, "\nFertig. Dateien verarbeitet: $total, erfolgreich gelesen: $ok, 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_RECOVER_EOF + +chmod 644 "$recovery_php_file" + +fi + + +# ----- +# - Main part of the script +# ----- + +if $terminal ; then + echo "" + echo "" + echo -e "\033[37m\033[1mMain part of the script\033[m" + echo "" +fi + +if $REVALIDATE_ONLY ; then + if [[ ! -d "$recovery_dir" ]] ; then + fatal "Revalidate-only mode: recovery directory '$recovery_dir' does not exist - run this script without -V first." + fi +else + +mkdir -p "$recovery_dir" 2> /dev/null + +# - 'mkdir -p' creates missing parent directories (e.g. RECOVERY_BASE_DIR +# - itself) subject to the current umask - if that happens to be +# - restrictive, the PARENT of recovery_dir can end up blocking +# - traversal for $HTTP_USER even though recovery_dir itself is fine. +# - Explicitly (re-)open both levels for traversal (RECOVERY_BASE_DIR is +# - only ever traversed, never written into directly). +# - +chmod 755 "$RECOVERY_BASE_DIR" 2> /dev/null +chmod 755 "$recovery_dir" 2> /dev/null + +# - recovery_dir itself still belongs to root at this point though - +# - mode 755 only grants $HTTP_USER (as 'other', not the owner) read + +# - traverse, NOT write. Files are later written per-account into +# - freshly created (and per-account chown'ed) subdirectories, but +# - recovery_dir itself also needs to be owned by $HTTP_USER so the +# - write test below (and 'mkdir' calls done by the PHP process +# - running as $HTTP_USER) can actually create things directly in it. +# - +chown "$HTTP_USER":"$HTTP_GROUP" "$recovery_dir" 2> /dev/null + +# - Frueher, klarer Fehler statt 780x der gleiche stille Flop: pruefen, +# - ob der Webserver-User ($HTTP_USER) hier tatsaechlich schreiben kann, +# - BEVOR der eigentliche Recovery-Lauf beginnt. Faengt u.a. den Fall +# - ab, dass RECOVERY_BASE_DIR auf ein fuer $HTTP_USER unerreichbares +# - Verzeichnis (z.B. unterhalb von /root) gesetzt wurde. +# - +_write_test_file="${recovery_dir}/.write_test_$$" +su -c "touch '$_write_test_file'" -s /bin/bash $HTTP_USER 2> "$log_file" +if [[ ! -f "$_write_test_file" ]] ; then + fatal "Cannot write to recovery directory '$recovery_dir' as user '$HTTP_USER' - check the permissions of every path component (in particular that none of them, e.g. a parent directory, is only accessible to root). $(cat "$log_file")" +fi +rm -f "$_write_test_file" 2> /dev/null + +fi + +declare -i total_processed=0 +declare -i total_valid=0 +declare -i total_invalid=0 +declare -i total_unverified=0 +declare -i total_read_errors=0 +declare -i total_failed_users=0 + +for _user in "${selected_user_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" + } >> "$recovery_report_file" + + list_file="${LOCK_DIR}/list_${_user}.tsv" + awk -F'\t' -v u="$_user" 'NF==5 && $1==u && $3 ~ /^[0-9]+$/ {print $2"\t"$3"\t"$4}' "$chosen_report" > "$list_file" + + _user_total=$(wc -l < "$list_file") + + if [[ $_user_total -eq 0 ]] ; then + warn "No bad-signature entries for account '${_user}' found in the selected report - skipping." + echo " [ keine Eintraege im gewaehlten Report ]" >> "$recovery_report_file" + continue + fi + + user_out_dir="${recovery_dir}/${_user}" + user_result_tsv="${LOCK_DIR}/result_${_user}.tsv" + + if $REVALIDATE_ONLY ; then + + echononl " Revalidating account \033[1;37m${_user}\033[m (${_user_total} files).." + { + echo -e "path\trecovered_bytes\toriginal_size\tstatus" + while IFS=$'\t' read -r _p _osize _omtime ; do + [[ -z "$_p" ]] && continue + _rel="${_p#/${_user}/files/}" + _local="${user_out_dir}/${_rel}" + if [[ -f "$_local" ]] ; then + _b=$(stat -c%s "$_local" 2> /dev/null) + echo -e "${_p}\t${_b:-0}\t${_osize}\tOK" + else + echo -e "${_p}\t0\t${_osize}\tNOT_RECOVERED: no file found under ${user_out_dir} (run without -V first)" + fi + done < "$list_file" + } > "$user_result_tsv" + _rc=0 + + else + + mkdir -p "$user_out_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).." + su -c "$PHP_BIN $recovery_php_file $_user $list_file $user_out_dir" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file" + _rc=$? + + fi + + if [[ $_rc -ne 0 ]]; then + echo_failed + error "$(cat "$log_file")" + echo " [ FEHLER beim Recovery-Lauf - siehe Server-Log ]" >> "$recovery_report_file" + (( total_failed_users++ )) + continue + fi + + echo_done + $terminal && echo "" + + declare -i _user_valid=0 + declare -i _user_invalid=0 + declare -i _user_unverified=0 + declare -i _user_read_errors=0 + declare -a _user_invalid_lines=() + declare -a _user_unverified_lines=() + + while IFS=$'\t' read -r _path _recbytes _origsize _status ; do + + [[ "$_path" = "path" ]] && continue + [[ -z "$_path" ]] && continue + + if [[ "$_status" == OK* ]] ; then + _rel="${_path#/${_user}/files/}" + _local="${user_out_dir}/${_rel}" + _val_result="$(validate_recovered_file "$_local" "$_path" "$_origsize")" + _val_class="${_val_result%%|*}" + _val_detail="${_val_result#*|}" + case "$_val_class" in + VALID) (( _user_valid++ )) ;; + INVALID) (( _user_invalid++ )); _user_invalid_lines+=("${_path}"$'\t'"${_val_detail}") ;; + *) (( _user_unverified++ )); _user_unverified_lines+=("${_path}"$'\t'"${_val_detail}") ;; + esac + else + _val_class="READ_ERROR" + _val_detail="$_status" + (( _user_read_errors++ )) + fi + + echo -e "${_path}\t${_recbytes}\t${_origsize}\t${_status}\t${_val_class}\t${_val_detail}" >> "$recovery_report_file" + + done < "$user_result_tsv" + + _user_pct_valid="$(calc_percent "$_user_valid" "$_user_total")" + + if $terminal ; then + echo -e " \033[1;37mAccount ${_user}\033[m" + echo -e " Verarbeitete Dateien.....................: ${_user_total}" + if [[ $_user_valid -gt 0 ]] ; then + echo -e " Wiederhergestellt (valide)...............: \033[1;32m${_user_valid} (${_user_pct_valid} %)\033[m" + else + echo -e " Wiederhergestellt (valide)...............: ${_user_valid} (${_user_pct_valid} %)" + fi + if [[ $_user_invalid -gt 0 ]] ; then + echo -e " Datenmuell (ungueltig)...................: \033[1;31m${_user_invalid}\033[m" + fi + if [[ $_user_unverified -gt 0 ]] ; then + echo -e " Nicht pruefbar...........................: \033[33m${_user_unverified}\033[m" + fi + if [[ $_user_read_errors -gt 0 ]] ; then + echo -e " Weiterhin Lesefehler.....................: \033[1;31m${_user_read_errors}\033[m" + fi + echo "" + fi + + # - 'Datenmuell' and 'Nicht pruefbar' files listed as their own, + # - visually set-off block(s), before the account statistics - + # - easier to scan/copy than digging them out of the full path list + # - above. + # - + { + if [[ $_user_invalid -gt 0 ]] ; then + echo "" + echo " ------------------------------------------------------------------" + echo " Datenmuell (ungueltig) - Account ${_user} (${_user_invalid})" + echo " ------------------------------------------------------------------" + for _line in "${_user_invalid_lines[@]}" ; do + echo -e " ${_line}" + done + fi + if [[ $_user_unverified -gt 0 ]] ; then + echo "" + echo " ------------------------------------------------------------------" + echo " Nicht pruefbar - Account ${_user} (${_user_unverified})" + echo " ------------------------------------------------------------------" + for _line in "${_user_unverified_lines[@]}" ; do + echo -e " ${_line}" + done + fi + } >> "$recovery_report_file" + + { + echo "" + echo " Account ${_user}" + echo " Verarbeitete Dateien.....................: ${_user_total}" + echo " Wiederhergestellt (valide)...............: ${_user_valid} (${_user_pct_valid} %)" + [[ $_user_invalid -gt 0 ]] && echo " Datenmuell (ungueltig)...................: ${_user_invalid}" + [[ $_user_unverified -gt 0 ]] && echo " Nicht pruefbar...........................: ${_user_unverified}" + [[ $_user_read_errors -gt 0 ]] && echo " Weiterhin Lesefehler.....................: ${_user_read_errors}" + } >> "$recovery_report_file" + + (( total_processed += _user_total )) + (( total_valid += _user_valid )) + (( total_invalid += _user_invalid )) + (( total_unverified += _user_unverified )) + (( total_read_errors += _user_read_errors )) + +done + + +# ============= +# --- Restore encryption_skip_signature_check as early as possible, +# --- before printing the overall summary. +# ============= + +restore_encryption_flag + + +total_pct_valid="$(calc_percent "$total_valid" "$total_processed")" + +{ + echo "" + echo "" + echo "==================================================================" + echo " Gesamtergebnis" + echo "==================================================================" + echo "" + echo "Gescannte Accounts..........................: ${#selected_user_arr[@]}" + [[ $total_failed_users -gt 0 ]] && echo "Accounts mit Recovery-Fehler................: ${total_failed_users}" + echo "Verarbeitete Dateien insgesamt..............: ${total_processed}" + echo "Wiederhergestellt insgesamt (valide)........: ${total_valid} (${total_pct_valid} %)" + [[ $total_invalid -gt 0 ]] && echo "Datenmuell insgesamt (ungueltig)............: ${total_invalid}" + [[ $total_unverified -gt 0 ]] && echo "Nicht pruefbar insgesamt....................: ${total_unverified}" + [[ $total_read_errors -gt 0 ]] && echo "Weiterhin Lesefehler insgesamt..............: ${total_read_errors}" + echo "" + echo "Wiederhergestellte Dateien liegen unter: ${recovery_dir}" + echo "Bitte Ergebnisse vor Weiterverwendung stichprobenartig pruefen," + echo "und das Recovery-Verzeichnis danach sicher aufraeumen (enthaelt" + echo "unverschluesselte, ggf. sensible Nutzerdaten ausserhalb von Nextcloud)." +} >> "$recovery_report_file" + +blank_line + +if $terminal ; then + echo -e "\033[37m\033[1mErgebnis\033[m" + echo "" + echo -e " Gescannte Accounts..........................: ${#selected_user_arr[@]}" + [[ $total_failed_users -gt 0 ]] && echo -e " Accounts mit Recovery-Fehler................: \033[1;31m${total_failed_users}\033[m" + echo -e " Verarbeitete Dateien insgesamt..............: ${total_processed}" + if [[ $total_valid -gt 0 ]] ; then + echo -e " Wiederhergestellt insgesamt (valide)........: \033[1;32m${total_valid} (${total_pct_valid} %)\033[m" + else + echo -e " Wiederhergestellt insgesamt (valide)........: ${total_valid} (${total_pct_valid} %)" + fi + [[ $total_invalid -gt 0 ]] && echo -e " Datenmuell insgesamt (ungueltig)............: \033[1;31m${total_invalid}\033[m" + [[ $total_unverified -gt 0 ]] && echo -e " Nicht pruefbar insgesamt....................: \033[33m${total_unverified}\033[m" + [[ $total_read_errors -gt 0 ]] && echo -e " Weiterhin Lesefehler insgesamt..............: \033[1;31m${total_read_errors}\033[m" + echo "" + echo -e " Recovery-Verzeichnis.........................: $recovery_dir" + echo -e " Recovery-Report..............................: reports/$(basename "${recovery_report_file}")" + echo "" + warn "Wiederhergestellte Dateien vor Weiterverwendung stichprobenartig pruefen und das Recovery-Verzeichnis danach sicher aufraeumen - es enthaelt unverschluesselte, ggf. sensible Nutzerdaten ausserhalb von Nextcloud." +fi + +clean_up 0