#!/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 # 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" 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 local _badentry _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})}" fi 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 " [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'; // 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); 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) printf '%s\t%s\t%s\t%s\n' "$_p" "${_b:-0}" "$_osize" "OK" else printf '%s\t%s\t%s\t%s\n' "$_p" "0" "$_osize" "NOT_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).." # -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=$? 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 # printf, not 'echo -e': $_val_detail (or $_status for a # READ_ERROR row) can contain a raw PHP exception message (e.g. # 'OCA\Encryption\Exceptions\...') - 'echo -e' would reinterpret # those backslashes as escape sequences and silently mangle/eat # parts of the text. printf's %s never reinterprets its argument, # only the literal \t in the format string itself. printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$_path" "$_recbytes" "$_origsize" "$_status" "$_val_class" "$_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 printf ' %s\n' "$_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 printf ' %s\n' "$_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