Files
nextcloud/recreate_bad_signature.sh
T

2049 lines
82 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <website>
\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}/<website>,
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}/<website>
(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 <website>
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 '<html|<!doctype html' ; then
echo "VALID|HTML tag found near start"
else
echo "INVALID|no <html>/<!DOCTYPE html> 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:-<none>}' extension), $_pdf_check_detail"
else
echo "UNVERIFIED|content-detected PDF ('.${_ext:-<none>}' 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:-<none>}') - 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:-<none>}', 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 "user<TAB>path" 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%%.*}_<account>.log\033[m"
echo ""
echo -e " Rohergebnis pro Account (PHP-Ausgabe auf STDOUT, Datei für Datei):"
echo -e " \033[1mtail -f ${LOCK_DIR}/result_<account>.tsv\033[m"
echo ""
fi
{
echo "=================================================================="
echo " 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'
<?php
/**
* recreate_bad_signature.php
*
* For files where a normal overwrite fails because Nextcloud cannot
* decrypt/reuse the EXISTING file key (MultiKeyDecryptException, or
* the generic 'probably this is a shared file' message) - this
* DELETES the existing (broken) node first and creates a brand-new
* file at the same path, so Nextcloud never has to decrypt any old
* key material; a fresh symmetric key is generated and freshly
* wrapped for the current access list.
*
* SAFETY: before deleting anything, this checks (read-only) whether
* the file currently has any active Nextcloud shares. If it does, the
* file is SKIPPED (status SHARES_FOUND_SKIPPED) unless --force is
* given - deleting a shared file's node breaks every existing share.
*
* Usage: php recreate_bad_signature.php <uid> <listFile> [--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 <uid> <listFile> <dataDir> [--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