#!/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" # - Source of the files this script writes back into Nextcloud: the # - already-recovered, already-validated copies produced by a PRIOR # - recover_bad_signature.sh run (same default as recover/restore - # - see the comments there). # - DEFAULT_RECOVERY_BASE_DIR="/var/nc-recovery" # - Before this script DELETES a file's current (still broken) node, # - it keeps a raw, byte-for-byte copy of the CURRENT on-disk # - ciphertext AND the current encryption-key directory for that file # - (fileKey/shareKey files) in a separate directory - both bypass # - Nextcloud/its encryption layer entirely, same rationale as # - restore_bad_signature.sh's backup. This is a SEPARATE backup # - location from restore_bad_signature.sh's, because this script # - backs up the CURRENT state (which may already differ from what # - restore_bad_signature.sh backed up earlier, if a restore attempt # - already touched this file once). # - DEFAULT_RECREATE_BACKUP_BASE_DIR="/var/nc-recreate-backup" 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) # ============= # --- Some functions # ============= usage() { [[ -n "$1" ]] && error "$1" [[ $terminal ]] && echo -e " \033[1mUsage:\033[m $(basename $0) -s \033[1mDescription\033[m For files where restore_bad_signature.sh's normal OVERWRITE fails with a key-material error ('OCA\\Encryption\\Exceptions\\MultiKeyDecryptException' or 'Cannot decrypt this file, probably this is a shared file...') - this script takes a DIFFERENT approach: instead of overwriting the existing (broken) file in place, which forces Nextcloud to try to reuse/decrypt the EXISTING file key first (exactly what fails), this DELETES the broken node entirely and creates a brand-new file at the same path. A brand-new file never needs to decrypt any old key material at all - Nextcloud just generates a fresh symmetric key and wraps it fresh for the current access list. This sidesteps the whole class of key-mismatch errors that plague an in-place overwrite on this instance. IMPORTANT - read before using: - This is more invasive than restore_bad_signature.sh. Deleting a file's node and recreating it gives the file a NEW internal file id. Any existing Nextcloud SHARES, comments, tags, and the version history of that specific file are tied to the OLD file id and are lost/broken by this - they are NOT automatically recreated. A share would need to be set up again manually afterwards if still needed. - The old (broken) file is removed with a plain filesystem unlink() on the raw ciphertext - NOT via Nextcloud's normal delete/trash API, and NOT via the storage layer's own unlink() either. Both of those were tried and, in a real run on this instance, both failed with the exact same key-material error this script exists to work around: it turns out that EVERY operation the encryption storage wrapper performs on one of these files - reading, writing, or deleting alike - first needs to establish a valid file-key context, which can never succeed for these files. A plain filesystem delete never goes through that wrapper (or any Nextcloud code) at all, so it sidesteps the problem completely. Consequence: the file does NOT end up in "Deleted files" and cannot be restored from the Nextcloud trash bin afterwards - both the ciphertext and the key directory are already backed up to plain filesystem locations (see below) before the file is touched, independent of the trash bin, and that backup is verified (size compared) before anything is deleted - if it cannot be verified, the file is left untouched and reported instead. - The OLD key directory (the file's shareKey/fileKey entries under files_encryption/keys/files/...) is, after being backed up, ALSO physically removed - not just the ciphertext. This turned out to be necessary: even with the ciphertext already gone, Nextcloud's encryption wrapper still finds the old key material at that path when creating the new file, tries to reuse it, and fails with the same decrypt error (confirmed in a real run). Only with no key material left at the path at all is Nextcloud forced to generate a completely fresh file key. - Because of this, every target file is checked (read-only, before anything is touched) for currently active Nextcloud shares. A file WITH active shares is SKIPPED by default (status SHARES_FOUND_SKIPPED in the report, share details listed) - pass '-f' to include such files anyway, only once you have decided that is acceptable. - Only files whose restore report shows a WRITE_ERROR with one of the two known key-material error signatures are ever considered - this never touches a file that simply hasn't been tried yet, or failed for an unrelated reason. - Every candidate file is re-validated (against the current recovered local copy) right before being touched - the same safety check restore_bad_signature.sh performs. - Before deleting anything, BOTH the current on-disk ciphertext AND the current encryption-key directory (fileKey/shareKey files) for that file are copied byte-for-byte to a separate backup directory (default ${DEFAULT_RECREATE_BACKUP_BASE_DIR}/, override with RECREATE_BACKUP_BASE_DIR in the conf file) - this is a plain filesystem copy, independent of Nextcloud, and is a SEPARATE, additional backup on top of whatever restore_bad_signature.sh already backed up earlier. - After creating the new file, it is immediately read back through the NORMAL Nextcloud read path and compared byte-for-byte (SHA-256) against the source. - This does NOT touch 'encryption_skip_signature_check' and does NOT decrypt anything itself - it only reads the already- recovered plaintext copies under ${DEFAULT_RECOVERY_BASE_DIR}/ (override with RECOVERY_BASE_DIR). Unless '-n' is given on the command line, you will be asked interactively whether to run a dry-run (nothing is deleted, backed up or written, just reports what would happen) or the real thing. You will then be asked which existing restore report (produced by restore_bad_signature.sh) to use, and then which account(s) from its WRITE_ERROR/key-material entries to process: - 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. -n Dry-run: goes through selection, re-validation, the share check and reporting, but does NOT back up, delete, create or verify anything. Passing it here skips the interactive mode question mentioned above. -f Force: also process files that currently have active Nextcloud shares (normally skipped - see IMPORTANT above). Use only once you have accepted that those shares will break. \033[1mExample:\033[m Runs this script on system 'cloud-01.oopen.de' $(basename $0) -s cloud-01.oopen.de Dry-run only, nothing is touched: $(basename $0) -n -s cloud-01.oopen.de " clean_up 1 } clean_up() { # Perform program exit housekeeping # Clear EXIT trap first so that the exit below does not fire it again. trap - EXIT [[ -n "$recreate_php_file" ]] && rm -f "$recreate_php_file" 2> /dev/null 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 file - IDENTICAL ## - to (and must be kept in sync with) validate_recovered_file() and ## - its helpers in recover_bad_signature.sh / restore_bad_signature.sh. ## - Duplicated here on purpose - sourcing would also execute that ## - script's own top-level flow. 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) 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) 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) 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) 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')" 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) 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')" _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) 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) 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_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_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 fi 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 } _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 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 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 (reads/copies other users' files # - under the Nextcloud data directory for backups, 'su' into the # - webserver user to perform the actual delete+recreate). # - if [[ "$(id -u)" -ne 0 ]] ; then fatal "This script must be run as root (it needs to read/copy the Nextcloud data directory for backups and 'su' into the webserver user). Please re-run as root, e.g. via sudo." fi # ---------- # - Jobhandling # ---------- if pgrep -f "$(basename $0)" | grep -q -v $$ ; then msg="A previos instance of script \"`basename $0`\" seems already be running." echo "" if $terminal ; then echo -e "[ \033[31m\033[1mFatal\033[m ]: $msg" echo "" echo -e " \033[31m\033[1mScript was interupted\033[m!" else echo " [ Fatal ]: $msg" echo "" echo " Script was interupted!" fi echo exit 1 else if [[ -d "$LOCK_DIR" ]] ; then rm -rf "$LOCK_DIR" 2> /dev/null fi fi # - If job already runs, stop execution.. # - if mkdir "$LOCK_DIR" 2> /dev/null ; then # Trap signals AND normal/abnormal exit so that temp files and the # lock directory are always removed — even on an unexpected crash. trap 'clean_up 1' SIGHUP SIGINT SIGTERM EXIT else msg="A previos instance of script \"`basename $0`\" seems already be running." echo "" if $terminal ; then echo -e "[ \033[31m\033[1mFatal\033[m ]: $msg" echo "" echo -e " \033[31m\033[1mScript was interupted\033[m!" else echo " [ Fatal ]: $msg" echo "" echo " Script was interupted!" fi echo exit 1 fi # ------------- # - Read in Commandline arguments # ------------- DRY_RUN=false _dry_run_explicit=false FORCE_SHARED=false while getopts hnfs: opt ; do case $opt in h) usage ;; n) DRY_RUN=true; _dry_run_explicit=true ;; f) FORCE_SHARED=true ;; s) WEBSITE=$OPTARG ;; \?) usage esac done # ============= # --- Ask which mode to run in, unless '-n' was already given. The # --- SAFE choice (dry-run) is the default - this script deletes and # --- recreates files. # ============= if ! $_dry_run_explicit && $terminal ; then blank_line echo -e "\033[37m\033[1mWhich mode should this run use?\033[m" echo "" echo -e " \033[1m[1] Dry-run\033[m - go through everything (selection, re-validation, share check, reporting), but delete, back up, create or verify NOTHING" echo " [2] Real recreate - actually back up, DELETE the broken file, create it fresh and verify each one" info "Just press Return to use the default: [1] Dry-run." echo -n " Select mode by number [1]: " read _mode_choice case "$(trim "$_mode_choice")" in 2) DRY_RUN=false ;; ""|1) DRY_RUN=true ;; *) fatal "Invalid selection '$_mode_choice'." ;; esac fi if [[ -z "$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) IFS=$'\n' website_arr=($(sort <<<"${unsorted_website_arr[*]}")) # Which cloud instance (website) would you like to update # source ${snippet_dir}/get-cloud-instance-to-update.sh else while IFS='' read -r -d '' _conf_file ; do if $(grep -E -q "WEBSITE=\"?${WEBSITE}\"?" ${_conf_file} 2> /dev/null) ; then conf_file="${_conf_file}" break fi done < <(find "${conf_dir}" -maxdepth 1 -type f -name "*.conf" -print0) fi # - Reset IFS # - IFS=$CUR_IFS DEFAULT_SRC_BASE_DIR="/usr/local/src/nextcloud" DEFAULT_HTTP_USER="www-data" DEFAULT_HTTP_GROUP="www-data" DEFAULT_PHP_ENGINE='FPM' blank_line echononl " Include Configuration file '$(basename "${conf_file}")'.." if [[ ! -f $conf_file ]]; then echo_skipped fatal "Missing configuration file '$conf_file'." else source $conf_file echo_ok fi DEFAULT_WEB_BASE_DIR="/var/www/${WEBSITE}" [[ -n "$WEB_BASE_DIR" ]] || WEB_BASE_DIR=$DEFAULT_WEB_BASE_DIR if [[ ! -d ${WEB_BASE_DIR} ]] ; then fatal "Web base directory '$WEB_BASE_DIR' not found!" fi DATA_DIR="$(realpath ${WEB_BASE_DIR}/data)" [[ -n "$PHP_ENGINE" ]] || PHP_ENGINE=$DEFAULT_PHP_ENGINE INSTALL_DIR="$(realpath ${WEB_BASE_DIR}/nextcloud)" CURRENT_VERSION="$(basename $INSTALL_DIR | cut -d"-" -f2)" [[ -n "$RECOVERY_BASE_DIR" ]] || RECOVERY_BASE_DIR=$DEFAULT_RECOVERY_BASE_DIR recovery_dir="${RECOVERY_BASE_DIR}/${WEBSITE}" [[ -n "$RECREATE_BACKUP_BASE_DIR" ]] || RECREATE_BACKUP_BASE_DIR=$DEFAULT_RECREATE_BACKUP_BASE_DIR recreate_backup_dir="${RECREATE_BACKUP_BASE_DIR}/${WEBSITE}" # ============= # --- Some # ============= SYSTEMD_EXISTS=false systemd=$(which systemd) systemctl=$(which systemctl) if [[ -n "$systemd" ]] || [[ -n "$systemctl" ]] ; then SYSTEMD_EXISTS=true fi if $terminal ; then echo "" echo -e "\033[32m-----\033[m" echo -e "Delete+recreate still-broken \033[1mBad Signature\033[m files on system \033[1m${WEB_BASE_DIR}\033[m" echo -e "\033[1m This DELETES the existing (broken) file node and creates a fresh one at the same path - more invasive than restore_bad_signature.sh. Files with active shares are skipped by default (see -f). Both the current ciphertext and the current key files are backed up first.\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 # ============= # --- Determine available restore reports (output of # --- restore_bad_signature.sh) - we need WRITE_ERROR rows, so ONLY # --- restore_*.tsv reports qualify here (unlike diagnose_share_key.sh, # --- which also accepts recovery_*.tsv). # ============= mapfile -t _available_reports < <(ls -t "${report_dir}"/restore_*.tsv 2> /dev/null) if [[ ${#_available_reports[@]} -eq 0 ]] ; then fatal "No restore report files (restore_*.tsv) found in '${report_dir}'. Run restore_bad_signature.sh first." fi blank_line if $terminal ; then echo -e "\033[37m\033[1mAvailable restore 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))]}" # ============= # --- Extract "userpath" for every WRITE_ERROR row in the chosen # --- restore report whose detail matches one of the two known # --- key-material error signatures. Same section-header state machine # --- as the sibling scripts. # ============= targets_file="${LOCK_DIR}/targets.tsv" awk -F'\t' ' /^-+$/ { if (state == 0) { state = 1 } else if (state == 2) { state = 0 } next } { if (state == 1) { user = $0; state = 2; next } if (NF == 4 && $1 != "path" && $3 == "WRITE_ERROR") { if (index($4, "probably this is a shared file") > 0 || index($4, "MultiKeyDecryptException") > 0) { print user "\t" $1 } } } ' "$chosen_report" > "$targets_file" unsorted_account_arr=($(cut -f1 "$targets_file" | sort -u)) IFS=$'\n' account_arr=($(sort <<<"${unsorted_account_arr[*]}")) IFS=$CUR_IFS if [[ ${#account_arr[@]} -eq 0 ]] ; then fatal "No key-material WRITE_ERROR entries found in '$(basename "$chosen_report")' - nothing to recreate." fi blank_line if $terminal ; then echo -e "\033[37m\033[1mAccounts with key-material WRITE_ERROR entries in this report\033[m" echo "" for _a in "${account_arr[@]}" ; do _a_count=$(awk -F'\t' -v u="$_a" '$1==u' "$targets_file" | wc -l) echo " - $_a (${_a_count})" done echo "" fi echo -n " Account(s) to recreate broken files 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 recreate broken files for (blank separated, or 'all'): " read _input done # ============= # --- Confirmation # ============= mkdir -p "$report_dir" 2> /dev/null recreate_report_file="${report_dir}/recreate_${WEBSITE}_${run_date}.tsv" if $terminal ; then echo "" if $DRY_RUN ; then echo -e "\033[1;32mStarting DRY-RUN recreate for \033[1;37m${WEBSITE}\033[m" else echo -e "\033[1;31m\033[1mStarting REAL recreate (DELETES and recreates files in live Nextcloud storage) for \033[1;37m${WEBSITE}\033[m" fi echo "" echo -e " Cloud instance..........................: $WEBSITE" echo "" echo -e " Restore report used......................: $(basename "$chosen_report")" echo -e " Recovered files read from................: $recovery_dir" echo -e " Account(s) to process....................: \033[33m${selected_user_arr[*]}\033[m" $FORCE_SHARED && echo -e " \033[33m-f\033[m given: files with active shares will ALSO be processed (shares will break)." ! $FORCE_SHARED && echo -e " Files with active shares will be SKIPPED (no -f given)." echo "" if $DRY_RUN ; then echo -e " Nothing will be deleted or written - dry-run only." else echo -e " Ciphertext + key backup (before delete)..: $recreate_backup_dir" echo -e " Files DELETED and recreated at their ORIGINAL path in Nextcloud." fi echo -e " Recreate report...........................: reports/$(basename "${recreate_report_file}")" echo "" if $DRY_RUN ; then info "Dry-run: selection, re-validation and share check run for real, nothing is backed up, deleted, created or verified." else warn "This DELETES the existing (broken) file and creates a fresh one at the same path - the file gets a NEW internal id, so any existing shares/comments/version history for it are lost unless already accounted for by the share check above. The current ciphertext AND current key files are backed up first, and every new file is read back and verified afterwards, but please still spot-check a few results yourself." 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 recreate broken files for each selected account on \033[1;37m$WEBSITE \033[m" else fatal "Abort by user request - Answer as not 'YES'" fi fi if $terminal ; then echo "" echo -e " \033[1mTemporäre Dateien – werden laufend aktualisiert:\033[m" echo "" echo -e " Fortschritt / Debug-Meldungen (PHP-Ausgabe auf STDERR, pro Account):" echo -e " \033[1mtail -f ${LOCK_DIR}/${script_name%%.*}_.log\033[m" echo "" echo -e " Rohergebnis pro Account (PHP-Ausgabe auf STDOUT, Datei für Datei):" echo -e " \033[1mtail -f ${LOCK_DIR}/result_.tsv\033[m" echo "" fi { echo "==================================================================" echo " Bad-Signature-Recreate-Versuch ${WEBSITE} ${run_date}" echo "==================================================================" echo "" echo " Basis-Report (Restore): $(basename "$chosen_report")" echo " Nur WRITE_ERROR-Eintraege mit Schluessel-Fehlerbild werden bearbeitet." $DRY_RUN && echo " DRY-RUN: es wurde NICHTS geloescht, gesichert oder veraendert." ! $DRY_RUN && echo " Chiffretext- und Schluessel-Sicherung (vor dem Loeschen) liegt unter: ${recreate_backup_dir}" $FORCE_SHARED && echo " -f gegeben: auch Dateien mit aktiven Freigaben wurden bearbeitet." echo "" printf 'path\tbytes_written\tstatus\tdetail\n' } > "$recreate_report_file" # ============= # --- Write the embedded PHP recreate script # ============= recreate_php_file="${INSTALL_DIR}/.recreate_bad_signature_$$.php" cat > "$recreate_php_file" <<'PHP_RECREATE_EOF' [--dry-run] [--force] * listFile: TSV "path\tlocalRecoveredFile" per line (no header) */ error_reporting(E_ALL); ini_set('display_errors', '1'); define('OC_CONSOLE', 1); /** * The exception this instance throws for a broken/undecryptable file * ('Cannot decrypt this file, probably this is a shared file...') is * only the OUTER, generic wrapper - Nextcloud chains the REAL, much * more specific cause underneath via getPrevious() (this is exactly * what a direct manual decrypt test on this instance showed: a * MultiKeyDecryptException with an underlying openssl RSA-OAEP error). * Earlier versions of this script only logged the outer message, * discarding that detail. This walks the full chain so the report * shows what is ACTUALLY failing, not just the generic headline. */ function describe_exception_chain(\Throwable $e): string { $parts = []; $current = $e; $depth = 0; while ($current !== null && $depth < 6) { $short = basename(str_replace('\\', '/', get_class($current))); $loc = basename($current->getFile()) . ':' . $current->getLine(); $msg = str_replace(["\t", "\n", "\r"], ' ', $current->getMessage()); $parts[] = "{$short}[{$loc}]: {$msg}"; $current = $current->getPrevious(); $depth++; } return implode(' <= caused by <= ', $parts); } $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 recreating 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; $dataDir = $argv[3] ?? null; $dryRun = in_array('--dry-run', $argv, true); $force = in_array('--force', $argv, true); if ($uid === null || $listFile === null) { fwrite(STDERR, "Usage: php recreate_bad_signature.php [--dry-run] [--force]\n"); exit(1); } if (!is_file($listFile)) { fwrite(STDERR, "Liste nicht gefunden: $listFile\n"); exit(1); } $rootFolder = \OC::$server->get(\OCP\Files\IRootFolder::class); \OC_Util::setupFS($uid); $shareManager = null; try { $shareManager = \OC::$server->get(\OCP\Share\IManager::class); } catch (\Throwable $e) { $shareManager = null; } // Used only for direct, API-level diagnostics (see keyManagerNote below) - // completely separate from and in addition to the raw filesystem // shareKey probe, so we can tell apart "key file missing/wrong path on // disk" from "key file present on disk, but Nextcloud's own KeyManager // singleton does not consider it usable for this uid/path". $keyManager = null; try { $keyManager = \OC::$server->get(\OCA\Encryption\KeyManager::class); } catch (\Throwable $e) { $keyManager = null; } $lines = file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); if ($lines === false) { $lines = []; } echo "path\tbytes_written\tstatus\tdetail\n"; foreach ($lines as $line) { $cols = explode("\t", $line); $path = $cols[0] ?? ''; $local = $cols[1] ?? ''; if ($path === '' || $local === '') { continue; } if (!is_file($local) || !is_readable($local)) { echo "$path\t0\tERROR\tlocal recovered file not readable: $local\n"; flush(); continue; } $localSize = filesize($local); $localHash = hash_file('sha256', $local); $shareKeyNote = ''; try { $node = null; try { $node = $rootFolder->get($path); } catch (\Throwable $e) { $node = null; } // Share check - read-only, always computed regardless of mode. $shareSummary = ''; $shareCount = 0; if ($node !== null && $shareManager !== null) { try { $shares = $shareManager->getSharesByPath($node); } catch (\Throwable $e) { $shares = []; } if (!empty($shares)) { $shareCount = count($shares); $parts = []; foreach ($shares as $s) { try { $parts[] = 'type=' . $s->getShareType() . ' with=' . ($s->getSharedWith() ?: '?'); } catch (\Throwable $e) { // ignore malformed share entry } } $shareSummary = implode('; ', $parts); } } if ($dryRun) { $note = $shareSummary !== '' ? " (WARNING: has {$shareCount} active share(s) that would break: $shareSummary" . ($force ? ', but -f given' : ' - would be SKIPPED without -f') . ')' : ''; echo "$path\t$localSize\tDRY_RUN\twould delete existing node and recreate with $localSize bytes (sha256 $localHash)$note\n"; flush(); continue; } if ($shareCount > 0 && !$force) { echo "$path\t0\tSHARES_FOUND_SKIPPED\t{$shareCount} active share(s): $shareSummary - re-run with --force to include anyway (existing shares will break)\n"; flush(); continue; } if ($node !== null) { if (!($node instanceof \OCP\Files\File)) { echo "$path\t0\tERROR\ttarget exists but is not a regular file\n"; flush(); continue; } try { // IMPORTANT: this deliberately does NOT call // $node->delete() (goes via files_trashbin, which tries // to copy the encrypted content into trash first) NOR // $node->getStorage()->unlink() (an earlier version of // this script tried exactly that - confirmed in a real // run to fail with the *same* // DecryptionFailedException/'probably this is a shared // file' error on all 85 candidates, 0 succeeded). On // this instance, EVERY operation the encryption storage // wrapper performs on one of these files - read, write, // or delete alike - first needs to establish a valid // file-key context, which can never succeed for these // files (that is the root problem itself). So the raw // ciphertext is instead deleted directly on the // filesystem by the bash wrapper BEFORE this PHP helper // even runs (see recreate_bad_signature.sh) - a plain // filesystem unlink() that never goes through any // Nextcloud/encryption code at all. All that remains to // do here is drop the now-stale filecache entry (pure // database metadata, unrelated to the encryption // wrapper) so the following newFile() does not get // confused by leftover metadata for the already-removed // node. $storage = $node->getStorage(); $internalPath = $node->getInternalPath(); $cache = $storage->getCache(); if ($cache !== null) { $cache->remove($internalPath); } } catch (\Throwable $e) { echo "$path\t0\tDELETE_ERROR\t" . describe_exception_chain($e) . "\n"; flush(); continue; } } $userFolder = $rootFolder->getUserFolder($uid); $relative = preg_replace('#^/' . preg_quote($uid, '#') . '/files/#', '', $path); if ($relative === null || $relative === '') { $relative = ltrim($path, '/'); } $newNode = $userFolder->newFile($relative); // DIAGNOSTIC: this is the exact same call Nextcloud's own // encryption stream wrapper makes internally right before // begin()/end() (OC\Files\Stream\Encryption::stream_open(): // $accessList = $this->file->getAccessList($sharePath), where // $this->file is NOT a Files\Node but the small helper service // OC\Encryption\File / OCP\Encryption\IFile) to decide which // users' public keys to encrypt a fresh share key for. If the // owner's own uid is missing from this list for a brand-new // node, end() would never create a share key the owner can // actually decrypt later - even though *some* share key file // might still end up on disk. (An earlier version of this // diagnostic incorrectly called ->getAccessList() directly on // the Files\Node, which does not have that method at all - that // was a bug in the diagnostic itself, now fixed.) $accessListNote = ''; try { $encFileHelper = \OC::$server->get(\OCP\Encryption\IFile::class); $accessListDump = $encFileHelper->getAccessList($path); $accessListNote = ' [getAccessList() vor putContent(): ' . json_encode($accessListDump) . ']'; } catch (\Throwable $e) { $accessListNote = ' [getAccessList()-Fehler: ' . describe_exception_chain($e) . ']'; } // DIAGNOSTIC: the access list is correct (owner is always // unconditionally included by OC\Encryption\File::getAccessList() // itself), so a missing-owner theory is ruled out. The remaining // suspect is the PUBLIC KEY used to encrypt the fresh share key // at write time: if it does not actually match the REAL usable // private key for this account (e.g. a stale/cached value, or a // leftover from the historic keypair-regeneration event), the // share key would look completely normal (right size, right // uid) yet never be decryptable by the real owner. Compare the // public key KeyManager hands us (used for OUR write) against // the public key freshly re-read directly from disk (bypassing // any in-memory cache) via the exact same at-rest decryption // Nextcloud's own key storage uses (OCP\Security\ICrypto). Only // hashes are logged - public keys are not sensitive, but we // still avoid printing the raw key material. $pubKeyNote = ''; try { $kmPubKey = $keyManager->getPublicKey($uid); $kmHash = hash('sha256', (string)$kmPubKey); } catch (\Throwable $e) { $kmHash = 'FEHLER: ' . describe_exception_chain($e); } try { $rawPubKeyPath = null; if ($dataDir !== null && $dataDir !== '') { $rawPubKeyPath = rtrim($dataDir, '/') . '/' . $uid . '/files_encryption/OC_DEFAULT_MODULE/' . $uid . '.publicKey'; } if ($rawPubKeyPath !== null && is_file($rawPubKeyPath)) { $rawEncrypted = file_get_contents($rawPubKeyPath); $crypto = \OC::$server->get(\OCP\Security\ICrypto::class); $rawDecrypted = $crypto->decrypt($rawEncrypted); $rawData = json_decode($rawDecrypted, true); $rawKeyValue = (is_array($rawData) && isset($rawData['key'])) ? base64_decode($rawData['key']) : ''; $rawHash = hash('sha256', (string)$rawKeyValue); } else { $rawHash = 'DATEI FEHLT (' . ($rawPubKeyPath ?? 'kein dataDir') . ')'; } } catch (\Throwable $e) { $rawHash = 'FEHLER: ' . describe_exception_chain($e); } $pubKeyNote = " [PublicKey-Hash via KeyManager=$kmHash, via Rohdatei-direkt=$rawHash]"; $accessListNote .= $pubKeyNote; $shareKeyNote = ''; $in = fopen($local, 'r'); if ($in === false) { throw new \RuntimeException("lokale Datei konnte nicht geoeffnet werden: $local"); } $newNode->putContent($in); if (is_resource($in)) { fclose($in); } // DIAGNOSTIC: check directly on the physical filesystem // (bypassing Nextcloud/encryption entirely) whether putContent() // actually persisted a usable shareKey for THIS uid on the new // file. This tells us, independent of any further read/decrypt // attempt, whether the write step itself produced complete key // material or not. $shareKeyNote = $accessListNote; if ($dataDir !== null && $dataDir !== '') { $shareKeyPath = rtrim($dataDir, '/') . '/' . $uid . '/files_encryption/keys/files/' . $relative . '/OC_DEFAULT_MODULE/' . $uid . '.shareKey'; clearstatcache(true, $shareKeyPath); if (is_file($shareKeyPath)) { $shareKeyNote .= " [shareKey nach putContent(): vorhanden, " . filesize($shareKeyPath) . " bytes]"; } else { $shareKeyNote .= " [shareKey nach putContent(): FEHLT unter $shareKeyPath]"; } } // DIAGNOSTIC (API-level, independent of the raw filesystem probe // above): ask Nextcloud's OWN live KeyManager singleton, via its // public getShareKey()/getFileKey() methods, whether IT considers // a usable share key / file key to exist for this exact $path and // $uid - using the *same* path format ("/uid/files/...") that the // encryption storage wrapper itself uses internally in // begin()/end() (see OCA\Encryption\Crypto\Encryption:: // getPathToRealFile()), not our own guessed on-disk layout. This // tells us whether a mismatch between the two probes points at a // path/lookup problem inside Nextcloud rather than a missing key // on disk. if ($keyManager !== null) { try { $apiShareKey = $keyManager->getShareKey($path, $uid); $apiShareKeyLen = is_string($apiShareKey) ? strlen($apiShareKey) : -1; } catch (\Throwable $e) { $apiShareKeyLen = -2; } try { $apiFileKey = $keyManager->getFileKey($path, null); $apiFileKeyLen = is_string($apiFileKey) ? strlen($apiFileKey) : -1; } catch (\Throwable $e) { $apiFileKeyLen = -2; } $shareKeyNote .= " [API getShareKey()=" . $apiShareKeyLen . " bytes, getFileKey()=" . $apiFileKeyLen . " bytes" . " (-1=leer/keine Daten, -2=Exception)]"; // DEEPER DIAGNOSTIC (reflection): getFileKey() internally // resolves the uid via $this->userSession->getUser()?->getUID() // (NOT the $uid we pass on the command line) and reads the // private key via $this->session->getPrivateKey() (an // OCA\Encryption\Session wrapping the current PHP/CLI // session, populated only on a real login). Both are private // properties of KeyManager, so peek at them via Reflection // to see directly what getFileKey() is actually using // internally, without guessing from the outside. try { $refl = new \ReflectionClass($keyManager); $userSessionProp = $refl->getProperty('userSession'); $userSessionProp->setAccessible(true); $innerUserSession = $userSessionProp->getValue($keyManager); $innerUser = $innerUserSession->getUser(); $innerUid = $innerUser !== null ? $innerUser->getUID() : null; $sessionProp = $refl->getProperty('session'); $sessionProp->setAccessible(true); $innerSession = $sessionProp->getValue($keyManager); $privateKeySet = $innerSession->isPrivateKeySet(); $privateKeyLen = -1; if ($privateKeySet) { try { $pk = $innerSession->getPrivateKey(); $privateKeyLen = is_string($pk) ? strlen($pk) : -1; } catch (\Throwable $e) { $privateKeyLen = -2; } } $shareKeyNote .= " [REFLECT userSession->getUser()=uid:" . ($innerUid ?? 'NULL') . " (Skript-uid war: $uid), session isPrivateKeySet()=" . ($privateKeySet ? 'true' : 'false') . ", privateKey-Laenge=" . $privateKeyLen . "]"; } catch (\Throwable $e) { $shareKeyNote .= " [REFLECT-Fehler: " . describe_exception_chain($e) . "]"; } } // Verify: re-read through the SAME normal API path. // // IMPORTANT CAVEAT (root-caused via source-level analysis of // OCA\Encryption\KeyManager::getFileKey()): this script never // establishes a real per-user login - it only ever calls // \OC_Util::setupFS($uid), never IUserSession::setUser() - so // $publicAccess = !$this->userSession->isLoggedIn() is TRUE for // this entire process. In that state, getFileKey() does NOT use // the real owner's own share key/private key at all: it only // ever tries the "public link share" system keypair's share // key (KeyManager::getPublicShareKeyId()). For an ordinary // PRIVATE file (not shared via a public link, accessList // "public":false), no such share key was ever created for it - // so THIS SELF-CHECK CAN NEVER SUCCEED, regardless of whether // the write itself, and the real owner's own share key on // disk, are perfectly correct. Confirmed empirically: a // brand-new control file, written by this exact script via the // exact same newFile()+putContent() call and never touched // afterwards, fails this very re-read check the same way - yet // opens completely normally for the real owner via a real // browser login. // // So a decryption failure with exactly this well-known message, // happening ONLY here (not earlier, e.g. not during // putContent() itself), is reported as WRITTEN_UNVERIFIED, not // WRITE_ERROR: the bytes were written, but this script cannot // confirm decryptability without a real login - that has to be // checked manually (real browser login, or an account with a // known valid password). clearstatcache(); try { $freshNode = $rootFolder->get($path); $readBack = $freshNode->fopen('r'); if ($readBack === false) { echo "$path\t0\tWRITE_ERROR\tputContent() did not throw, but re-read (fopen) failed$shareKeyNote\n"; flush(); continue; } $ctx = hash_init('sha256'); $bytesRead = 0; while (!feof($readBack)) { $chunk = fread($readBack, 1048576); if ($chunk === false) { break; } $bytesRead += strlen($chunk); hash_update($ctx, $chunk); } fclose($readBack); $readBackHash = hash_final($ctx); if ($readBackHash === $localHash && $bytesRead === $localSize) { echo "$path\t$bytesRead\tOK\tverified: re-read $bytesRead bytes, sha256 matches source ($readBackHash)\n"; } else { $hashNote = ($readBackHash === $localHash) ? 'sha256 matches' : 'sha256 MISMATCH'; echo "$path\t$bytesRead\tVERIFY_MISMATCH\tread back $bytesRead bytes (expected $localSize), $hashNote\n"; } } catch (\Throwable $e) { $msg = $e->getMessage(); $cls = get_class($e); if ( stripos($cls, 'DecryptionFailedException') !== false && stripos($msg, 'probably this is a shared file') !== false ) { echo "$path\t$localSize\tWRITTEN_UNVERIFIED\tDatei wurde geschrieben ($localSize bytes), aber ohne echten Benutzer-Login kann dieses Skript sie nicht selbst entschluesseln/verifizieren (bekannte Einschraenkung von KeyManager::getFileKey() im 'public access'-Modus, siehe Kommentar im Skript) - bitte manuell per echtem Browser-Login pruefen.$shareKeyNote\n"; } else { echo "$path\t0\tWRITE_ERROR\t" . describe_exception_chain($e) . " [beim Verifikations-Reread]$shareKeyNote\n"; } } flush(); } catch (\Throwable $e) { $note = isset($shareKeyNote) ? $shareKeyNote : ''; echo "$path\t0\tWRITE_ERROR\t" . describe_exception_chain($e) . "$note\n"; flush(); } } } 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_RECREATE_EOF chmod 644 "$recreate_php_file" # ----- # - 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 [[ ! -d "$recovery_dir" ]] ; then fatal "Recovery directory '$recovery_dir' does not exist - run recover_bad_signature.sh first." fi if ! $DRY_RUN ; then mkdir -p "$recreate_backup_dir" 2> /dev/null fi declare -i total_selected=0 declare -i total_prevalidation_failed=0 declare -i total_delete_failed=0 declare -i total_ok=0 declare -i total_shares_skipped=0 declare -i total_written_unverified=0 declare -i total_write_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" } >> "$recreate_report_file" user_recovery_dir="${recovery_dir}/${_user}" declare -i _user_selected=0 declare -i _user_prevalidation_failed=0 declare -a _user_prevalidation_failed_lines=() declare -i _user_delete_failed=0 declare -a _user_delete_failed_lines=() list_file="${LOCK_DIR}/list_${_user}.tsv" > "$list_file" # ============= # --- Re-validate every target entry against the CURRENT checks # --- right before touching anything, and (unless dry-run) back up # --- the CURRENT on-disk ciphertext AND the CURRENT key directory # --- byte-for-byte first. # ============= while IFS=$'\t' read -r _u _path ; do [[ "$_u" != "$_user" ]] && continue (( _user_selected++ )) _rel="${_path#/${_user}/files/}" _local="${user_recovery_dir}/${_rel}" if [[ ! -f "$_local" ]] ; then (( _user_prevalidation_failed++ )) _user_prevalidation_failed_lines+=("${_path}"$'\t'"recovered file missing under ${user_recovery_dir} - re-run recover_bad_signature.sh") continue fi _val_result="$(validate_recovered_file "$_local" "$_path" "")" _val_class="${_val_result%%|*}" _val_detail="${_val_result#*|}" if [[ "$_val_class" != "VALID" ]] ; then (( _user_prevalidation_failed++ )) _user_prevalidation_failed_lines+=("${_path}"$'\t'"no longer validates as VALID (${_val_class}: ${_val_detail}) - skipped, not recreated") continue fi if ! $DRY_RUN ; then _live_ciphertext="${DATA_DIR}/${_user}/files/${_rel}" _backup_ok=true if [[ -f "$_live_ciphertext" ]] ; then _backup_target="${recreate_backup_dir}/${_user}/${_rel}" mkdir -p "$(dirname "$_backup_target")" 2> /dev/null cp -p "$_live_ciphertext" "$_backup_target" 2> /dev/null if [[ ! -f "$_backup_target" ]] \ || [[ "$(stat -c%s "$_live_ciphertext" 2> /dev/null)" != "$(stat -c%s "$_backup_target" 2> /dev/null)" ]] ; then _backup_ok=false fi fi _key_src="${DATA_DIR}/${_user}/files_encryption/keys/files/${_rel}" if [[ -d "$_key_src" ]] ; then _key_backup="${recreate_backup_dir}/${_user}/_keys/${_rel}" mkdir -p "$(dirname "$_key_backup")" 2> /dev/null cp -rp "$_key_src" "$_key_backup" 2> /dev/null [[ -d "$_key_backup" ]] || _backup_ok=false fi if ! $_backup_ok ; then (( _user_delete_failed++ )) _user_delete_failed_lines+=("${_path}"$'\t'"Sicherung von Chiffretext/Schluessel-Verzeichnis konnte nicht verifiziert werden - NICHT geloescht, uebersprungen") continue fi # Delete the raw ciphertext directly on the filesystem, bypassing # Nextcloud's storage/encryption layer entirely. Every attempt to # remove this file THROUGH Nextcloud - Node::delete() (goes via # files_trashbin) and even a plain Storage::unlink() alike - has # been confirmed (in a real run on this instance) to fail with # the exact same DecryptionFailedException ('probably this is a # shared file') that also blocks reading/writing it: the # encryption wrapper apparently needs to establish a valid # file-key context before ANY operation on the path, and for # these files that context can never be established - that is # the root problem itself. A plain filesystem unlink() bypasses # that wrapper completely, since it never goes through any # Nextcloud PHP code at all. The now-stale filecache entry (pure # database metadata, unrelated to the encryption wrapper) is # cleaned up afterwards by the PHP helper below. if [[ -f "$_live_ciphertext" ]] ; then if ! rm -f -- "$_live_ciphertext" 2> /dev/null ; then (( _user_delete_failed++ )) _user_delete_failed_lines+=("${_path}"$'\t'"physisches Loeschen (rm) von '${_live_ciphertext}' fehlgeschlagen") continue fi fi # ALSO remove the old key directory itself (not just back it up). # This turned out to be the real remaining cause of the write # failures: even after the broken ciphertext is gone, Nextcloud's # encryption wrapper still finds the OLD shareKey/fileKey material # sitting at this path when creating the new file, tries to reuse # it (to decide whether this is an update vs a genuinely new # file), and fails with the exact same decrypt error - confirmed # empirically: a real run with the ciphertext already deleted but # the key directory left in place still failed 85/85 with a # WRITE_ERROR at the recreate step. Only with NO key material left # at all at this path is Nextcloud forced to generate a completely # fresh file key and shareKeys for the new file. if [[ -d "$_key_src" ]] ; then if ! rm -rf -- "$_key_src" 2> /dev/null ; then (( _user_delete_failed++ )) _user_delete_failed_lines+=("${_path}"$'\t'"physisches Loeschen (rm -rf) des alten Schluessel-Verzeichnisses '${_key_src}' fehlgeschlagen") continue fi fi fi printf '%s\t%s\n' "$_path" "$_local" >> "$list_file" done < <(awk -F'\t' -v u="$_user" '$1==u' "$targets_file") if [[ $_user_selected -eq 0 ]] ; then warn "No target entries for account '${_user}' found in the selected report - skipping." echo " [ keine passenden Eintraege im gewaehlten Report ]" >> "$recreate_report_file" continue fi user_result_tsv="${LOCK_DIR}/result_${_user}.tsv" # per-account log file (NOT the shared $log_file, which gets overwritten # on every loop iteration) so nothing gets lost if something unexpected # ends up on STDERR for one account while others are still being # processed. log_file="${LOCK_DIR}/${script_name%%.*}_${_user}.log" if $DRY_RUN ; then echononl " Dry-run for account \033[1;37m${_user}\033[m (${_user_selected} selected, $(wc -l < "$list_file") to check).." else echononl " Recreating account \033[1;37m${_user}\033[m (${_user_selected} selected, $(wc -l < "$list_file") to process).." fi if [[ -s "$list_file" ]] ; then _php_args=("$recreate_php_file" "$_user" "$list_file" "$DATA_DIR") $DRY_RUN && _php_args+=("--dry-run") $FORCE_SHARED && _php_args+=("--force") # -d max_execution_time=0: recreating every selected file of a # large account can run well over an hour; without this the PHP # CLI process is killed by PHP's own execution-time limit # (typically inherited from the webserver's php.ini) mid-run. su -c "$PHP_BIN -d max_execution_time=0 ${_php_args[*]}" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file" _rc=$? else echo -e "path\tbytes_written\tstatus\tdetail" > "$user_result_tsv" _rc=0 fi if [[ $_rc -ne 0 ]]; then echo_failed error "$(cat "$log_file")" echo " [ FEHLER beim Recreate-Lauf - siehe Server-Log ]" >> "$recreate_report_file" (( total_failed_users++ )) continue fi echo_done $terminal && echo "" declare -i _user_ok=0 declare -i _user_shares_skipped=0 declare -i _user_written_unverified=0 declare -i _user_write_errors=0 declare -a _user_shares_skipped_lines=() declare -a _user_written_unverified_lines=() declare -a _user_write_error_lines=() while IFS=$'\t' read -r _path _bytes _status _detail ; do [[ "$_path" = "path" ]] && continue [[ -z "$_path" ]] && continue case "$_status" in OK|DRY_RUN) (( _user_ok++ )) ;; SHARES_FOUND_SKIPPED) (( _user_shares_skipped++ )); _user_shares_skipped_lines+=("${_path}"$'\t'"${_detail}") ;; WRITTEN_UNVERIFIED) (( _user_written_unverified++ )); _user_written_unverified_lines+=("${_path}"$'\t'"${_detail}") ;; *) (( _user_write_errors++ )); _user_write_error_lines+=("${_path}"$'\t'"${_status}: ${_detail}") ;; esac printf '%s\t%s\t%s\t%s\n' "$_path" "$_bytes" "$_status" "$_detail" >> "$recreate_report_file" done < "$user_result_tsv" _user_pct_ok="$(calc_percent "$_user_ok" "$_user_selected")" if $terminal ; then echo -e " \033[1;37mAccount ${_user}\033[m" echo -e " Ausgewaehlt (Schluessel-Fehler im Report)..: ${_user_selected}" if [[ $_user_prevalidation_failed -gt 0 ]] ; then echo -e " Vor dem Bearbeiten aussortiert.............: \033[33m${_user_prevalidation_failed}\033[m" fi if [[ $_user_delete_failed -gt 0 ]] ; then echo -e " Sicherung/Loeschen fehlgeschlagen..........: \033[1;31m${_user_delete_failed}\033[m" fi if $DRY_RUN ; then echo -e " Wuerde geloescht & neu angelegt werden.....: \033[1;32m${_user_ok} (${_user_pct_ok} %)\033[m" else if [[ $_user_ok -gt 0 ]] ; then echo -e " Geloescht, neu angelegt & verifiziert......: \033[1;32m${_user_ok} (${_user_pct_ok} %)\033[m" else echo -e " Geloescht, neu angelegt & verifiziert......: ${_user_ok} (${_user_pct_ok} %)" fi fi if [[ $_user_shares_skipped -gt 0 ]] ; then echo -e " Wegen aktiver Freigaben uebersprungen......: \033[33m${_user_shares_skipped}\033[m" fi if [[ $_user_written_unverified -gt 0 ]] ; then echo -e " Geschrieben, aber ohne Login unverifiziert.: \033[33m${_user_written_unverified}\033[m (siehe Hinweis im Report - bitte manuell mit echtem Login pruefen)" fi if [[ $_user_write_errors -gt 0 ]] ; then echo -e " Fehler beim Loeschen/Neuanlegen............: \033[1;31m${_user_write_errors}\033[m" fi echo "" fi { if [[ $_user_prevalidation_failed -gt 0 ]] ; then echo "" echo " ------------------------------------------------------------------" echo " Vor dem Bearbeiten aussortiert - Account ${_user} (${_user_prevalidation_failed})" echo " ------------------------------------------------------------------" for _line in "${_user_prevalidation_failed_lines[@]}" ; do printf ' %s\n' "$_line" done fi if [[ $_user_delete_failed -gt 0 ]] ; then echo "" echo " ------------------------------------------------------------------" echo " Sicherung/Loeschen fehlgeschlagen - Account ${_user} (${_user_delete_failed})" echo " ------------------------------------------------------------------" for _line in "${_user_delete_failed_lines[@]}" ; do printf ' %s\n' "$_line" done fi if [[ $_user_shares_skipped -gt 0 ]] ; then echo "" echo " ------------------------------------------------------------------" echo " Wegen aktiver Freigaben uebersprungen - Account ${_user} (${_user_shares_skipped})" echo " ------------------------------------------------------------------" for _line in "${_user_shares_skipped_lines[@]}" ; do printf ' %s\n' "$_line" done fi if [[ $_user_written_unverified -gt 0 ]] ; then echo "" echo " ------------------------------------------------------------------" echo " Geschrieben, aber ohne Login unverifiziert - Account ${_user} (${_user_written_unverified})" echo " (dieses Skript hat keinen echten Benutzer-Login und kann fuer private," echo " nicht oeffentlich freigegebene Dateien deshalb NICHT selbst pruefen, ob" echo " die neue Datei fuer den echten Besitzer entschluesselbar ist - das ist" echo " eine Einschraenkung dieser Verifikation, kein Hinweis auf einen Fehler." echo " Bitte jede hier gelistete Datei einmal per echtem Browser-Login pruefen.)" echo " ------------------------------------------------------------------" for _line in "${_user_written_unverified_lines[@]}" ; do printf ' %s\n' "$_line" done fi if [[ $_user_write_errors -gt 0 ]] ; then echo "" echo " ------------------------------------------------------------------" echo " Fehler beim Loeschen/Neuanlegen - Account ${_user} (${_user_write_errors})" echo " ------------------------------------------------------------------" for _line in "${_user_write_error_lines[@]}" ; do printf ' %s\n' "$_line" done fi } >> "$recreate_report_file" { echo "" echo " Account ${_user}" echo " Ausgewaehlt (Schluessel-Fehler im Report)..: ${_user_selected}" [[ $_user_prevalidation_failed -gt 0 ]] && echo " Vor dem Bearbeiten aussortiert.............: ${_user_prevalidation_failed}" [[ $_user_delete_failed -gt 0 ]] && echo " Sicherung/Loeschen fehlgeschlagen..........: ${_user_delete_failed}" if $DRY_RUN ; then echo " Wuerde geloescht & neu angelegt werden.....: ${_user_ok} (${_user_pct_ok} %)" else echo " Geloescht, neu angelegt & verifiziert......: ${_user_ok} (${_user_pct_ok} %)" fi [[ $_user_shares_skipped -gt 0 ]] && echo " Wegen aktiver Freigaben uebersprungen......: ${_user_shares_skipped}" [[ $_user_written_unverified -gt 0 ]] && echo " Geschrieben, aber ohne Login unverifiziert.: ${_user_written_unverified}" [[ $_user_write_errors -gt 0 ]] && echo " Fehler beim Loeschen/Neuanlegen............: ${_user_write_errors}" } >> "$recreate_report_file" (( total_selected += _user_selected )) (( total_prevalidation_failed += _user_prevalidation_failed )) (( total_delete_failed += _user_delete_failed )) (( total_ok += _user_ok )) (( total_shares_skipped += _user_shares_skipped )) (( total_written_unverified += _user_written_unverified )) (( total_write_errors += _user_write_errors )) unset _user_ok _user_shares_skipped _user_written_unverified _user_write_errors unset _user_shares_skipped_lines _user_written_unverified_lines _user_write_error_lines unset _user_prevalidation_failed _user_prevalidation_failed_lines unset _user_delete_failed _user_delete_failed_lines done total_pct_ok="$(calc_percent "$total_ok" "$total_selected")" { echo "" echo "" echo "==================================================================" echo " Gesamtergebnis" echo "==================================================================" echo "" echo "Ausgewaehlte Accounts.......................: ${#selected_user_arr[@]}" [[ $total_failed_users -gt 0 ]] && echo "Accounts mit Recreate-Fehler................: ${total_failed_users}" echo "Ausgewaehlt (Schluessel-Fehler im Report)...: ${total_selected}" [[ $total_prevalidation_failed -gt 0 ]] && echo "Vor dem Bearbeiten aussortiert...............: ${total_prevalidation_failed}" [[ $total_delete_failed -gt 0 ]] && echo "Sicherung/Loeschen fehlgeschlagen.............: ${total_delete_failed}" if $DRY_RUN ; then echo "Wuerde geloescht & neu angelegt werden.......: ${total_ok} (${total_pct_ok} %)" else echo "Geloescht, neu angelegt & verifiziert insgesamt: ${total_ok} (${total_pct_ok} %)" fi [[ $total_shares_skipped -gt 0 ]] && echo "Wegen aktiver Freigaben uebersprungen.........: ${total_shares_skipped}" [[ $total_written_unverified -gt 0 ]] && echo "Geschrieben, aber ohne Login unverifiziert....: ${total_written_unverified}" [[ $total_write_errors -gt 0 ]] && echo "Fehler beim Loeschen/Neuanlegen insgesamt.....: ${total_write_errors}" echo "" if $DRY_RUN ; then echo "DRY-RUN: es wurde nichts geloescht, gesichert oder veraendert." else echo "Chiffretext- und Schluessel-Sicherung (vor dem Loeschen) liegt unter: ${recreate_backup_dir}" echo "Bitte Ergebnisse trotz Verifikation stichprobenartig pruefen. Fuer uebersprungene/erfolgreiche Dateien mit vorheriger Freigabe muss die Freigabe manuell neu eingerichtet werden." if [[ $total_written_unverified -gt 0 ]] ; then echo "" echo "WICHTIG zu 'Geschrieben, aber ohne Login unverifiziert': Dieses Skript hat keinen" echo "echten Benutzer-Login und kann bei privaten (nicht oeffentlich freigegebenen)" echo "Dateien deshalb grundsaetzlich NICHT selbst pruefen, ob die neu geschriebene" echo "Datei fuer den echten Besitzer entschluesselbar ist (bekannte Einschraenkung von" echo "KeyManager::getFileKey() im 'public access'-Modus). Das ist kein Hinweis auf" echo "einen tatsaechlichen Fehler - bitte jede betroffene Datei einmal per echtem" echo "Login/Browser pruefen." fi fi } >> "$recreate_report_file" blank_line if $terminal ; then echo -e "\033[37m\033[1mErgebnis\033[m" echo "" echo -e " Ausgewaehlte Accounts.......................: ${#selected_user_arr[@]}" [[ $total_failed_users -gt 0 ]] && echo -e " Accounts mit Recreate-Fehler................: \033[1;31m${total_failed_users}\033[m" echo -e " Ausgewaehlt (Schluessel-Fehler im Report)...: ${total_selected}" [[ $total_prevalidation_failed -gt 0 ]] && echo -e " Vor dem Bearbeiten aussortiert...............: \033[33m${total_prevalidation_failed}\033[m" [[ $total_delete_failed -gt 0 ]] && echo -e " Sicherung/Loeschen fehlgeschlagen.............: \033[1;31m${total_delete_failed}\033[m" if $DRY_RUN ; then echo -e " Wuerde geloescht & neu angelegt werden.......: \033[1;32m${total_ok} (${total_pct_ok} %)\033[m" else if [[ $total_ok -gt 0 ]] ; then echo -e " Geloescht, neu angelegt & verifiziert insgesamt: \033[1;32m${total_ok} (${total_pct_ok} %)\033[m" else echo -e " Geloescht, neu angelegt & verifiziert insgesamt: ${total_ok} (${total_pct_ok} %)" fi fi [[ $total_shares_skipped -gt 0 ]] && echo -e " Wegen aktiver Freigaben uebersprungen.........: \033[33m${total_shares_skipped}\033[m" [[ $total_written_unverified -gt 0 ]] && echo -e " Geschrieben, aber ohne Login unverifiziert....: \033[33m${total_written_unverified}\033[m" [[ $total_write_errors -gt 0 ]] && echo -e " Fehler beim Loeschen/Neuanlegen insgesamt.....: \033[1;31m${total_write_errors}\033[m" echo "" echo -e " Recreate-Report...............................: reports/$(basename "${recreate_report_file}")" if ! $DRY_RUN ; then echo -e " Chiffretext- und Schluessel-Sicherung.........: $recreate_backup_dir" fi echo "" if $DRY_RUN ; then info "Dry-run beendet - es wurde nichts veraendert. Ohne '-n' (oder mit [2] bei der Modusabfrage) fuer den echten Lauf erneut ausfuehren." else warn "Bitte die neu angelegten Dateien trotz Verifikation stichprobenartig im Webinterface pruefen. Fuer jede uebersprungene Datei mit aktiven Freigaben (siehe Report) muss die Freigabe danach manuell neu eingerichtet werden, falls die Datei per '-f' spaeter doch bearbeitet wird." fi fi clean_up 0