Files
nextcloud/restore_bad_signature.sh
T

1621 lines
55 KiB
Bash
Executable File

#!/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 that script - see the
# - comment there for why it lives outside script_dir/root).
# -
DEFAULT_RECOVERY_BASE_DIR="/var/nc-recovery"
# - Before this script overwrites the still-broken original in
# - Nextcloud's own storage, it keeps a raw, byte-for-byte copy of the
# - CURRENT on-disk ciphertext (the file exactly as it is right now,
# - still failing signature checks) in a separate directory - a plain
# - filesystem copy, independent of Nextcloud/its encryption layer
# - entirely (which is the whole point: that layer can't "read" this
# - file cleanly right now anyway). This is a rollback path on top of
# - (not a replacement for) the already-recovered/validated copies under
# - DEFAULT_RECOVERY_BASE_DIR.
# -
DEFAULT_RESTORE_BACKUP_BASE_DIR="/var/nc-restore-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
Writes already-recovered, already-validated files (produced by a
PRIOR run of recover_bad_signature.sh, validation column = VALID in
its report) back into Nextcloud's own storage, at their ORIGINAL
path - through Nextcloud's normal Files API (the same write path a
regular upload/sync client write uses), so the Server-Side
Encryption layer transparently (re-)encrypts the content with a
fresh IV and a correct signature. Afterwards the file is a
completely normal, healthy encrypted file again: readable in the
web interface, over WebDAV, and by sync clients, with no more 'Bad
Signature' and no server-side flag left toggled.
This is the ONLY script in this toolkit that writes into
Nextcloud's live storage - scan_bad_signature.sh and
recover_bad_signature.sh are both read-only towards Nextcloud
itself. Treat it accordingly.
IMPORTANT:
- Only files whose recovery report marks them 'VALID' are ever
considered - 'Datenmuell'/INVALID and 'Nicht pruefbar'/
UNVERIFIED entries are never written back, no override exists.
- Every file is re-validated with the CURRENT checks right
before writing (not just trusted from the old report), in case
this script's validation logic has improved since that report
was produced - files that no longer validate are skipped, not
forced through.
- Before each file is overwritten, the CURRENT on-disk ciphertext
(the still-broken original, exactly as it is right now) is
copied byte-for-byte to a separate backup directory (default
${DEFAULT_RESTORE_BACKUP_BASE_DIR}/<website>, override with
RESTORE_BACKUP_BASE_DIR in the conf file) - a plain filesystem
copy that bypasses Nextcloud/encryption entirely, so it works
even though the file cannot currently be read normally. This
only works for local/default storage; it is best-effort and a
missing source is reported but does not abort the run.
- This only reads the already-recovered copies under
${DEFAULT_RECOVERY_BASE_DIR}/<website> (override with
RECOVERY_BASE_DIR) - it does NOT decrypt anything itself and
does NOT touch 'encryption_skip_signature_check'. Run
recover_bad_signature.sh first if that directory/report is
missing or stale.
- After writing, every file is immediately read back through the
NORMAL Nextcloud read path (no signature-check bypass) and
compared byte-for-byte (SHA-256) against the source - proof
that the file is genuinely healed, not just that a write
happened without error.
- If the 'files_versions' app is enabled, Nextcloud will also
keep its own snapshot of the overwritten (still-broken)
content as a file version automatically - an additional,
built-in safety net on top of the raw backup above.
Unless '-n' is given on the command line, you will be asked
interactively whether to run a dry-run (nothing is written, just
reports what would happen) or the real thing. You will then be
asked which existing recovery report (produced by
recover_bad_signature.sh) to use, and then which account(s) from its
VALID entries to restore for:
- 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 and reporting, but
does NOT back up, overwrite or verify anything. Passing it here
skips the interactive mode question mentioned above.
\033[1mExample:\033[m
Runs this script on system 'cloud-01.oopen.de'
$(basename $0) -s cloud-01.oopen.de
Dry-run only, nothing is written:
$(basename $0) -n -s cloud-01.oopen.de
"
clean_up 1
}
clean_up() {
# Perform program exit housekeeping
[[ -n "$restore_php_file" ]] && rm -f "$restore_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. Duplicated here on
## - purpose rather than sourced from that script: sourcing it would
## - also execute its top-level report-selection flow, not just define
## - functions. Prints "CLASS|detail" - CLASS is one of VALID / INVALID
## - / UNVERIFIED.
## -
validate_recovered_file() {
local _f="$1" _origpath="$2" _origsize="$3"
local _base="$(basename -- "$_origpath")"
local _ext=""
if [[ "$_base" == *.* ]] ; then
_ext="${_origpath##*.}"
_ext="$(echo "$_ext" | tr '[:upper:]' '[:lower:]')"
fi
local _size
_size=$(stat -c%s "$_f" 2> /dev/null)
[[ -z "$_size" ]] && _size=0
case "$_ext" in
pdf)
if _pdf_check "$_f" ; then
echo "VALID|$_pdf_check_detail"
else
echo "INVALID|$_pdf_check_detail"
fi
;;
jpg|jpeg)
if _jpeg_check "$_f" ; then
echo "VALID|$_jpeg_check_detail"
else
echo "INVALID|$_jpeg_check_detail"
fi
;;
png)
local _head
_head="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head" = "89504e470d0a1a0a" ]] ; then
echo "VALID|PNG signature ok"
else
echo "INVALID|PNG signature missing"
fi
;;
gif)
local _head6
_head6="$(head -c6 "$_f" 2> /dev/null)"
if [[ "$_head6" = "GIF87a" || "$_head6" = "GIF89a" ]] ; then
echo "VALID|GIF signature ok"
else
echo "INVALID|GIF signature missing"
fi
;;
bmp)
if [[ "$(head -c2 "$_f" 2> /dev/null)" = "BM" ]] ; then
echo "VALID|BMP signature ok"
else
echo "INVALID|BMP signature missing"
fi
;;
tif|tiff)
local _head4
_head4="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head4" = "49492a00" || "$_head4" = "4d4d002a" ]] ; then
echo "VALID|TIFF signature ok"
else
echo "INVALID|TIFF signature missing"
fi
;;
pnm|pgm|ppm|pbm)
local _head2
_head2="$(head -c2 "$_f" 2> /dev/null)"
if [[ "$_head2" =~ ^P[1-6]$ ]] ; then
echo "VALID|PNM/PGM/PPM magic ok"
else
echo "INVALID|PNM/PGM/PPM magic (P1-P6) missing"
fi
;;
odt|ods|odp|odg|odf|ott|ots|otp|otg|docx|xlsx|pptx|docm|xlsm|pptm|dotm|xltm|potm|ppsm|ppsx|zip|epub)
# A password-protected MS Office file (Standard/Agile encryption,
# MS-OFFCRYPTO) is NOT a zip at all, regardless of its docx/xlsx/
# pptx extension: the whole package is wrapped in an OLE2/CFBF
# compound-file container (the same format legacy .doc/.xls/.ppt
# use), with the real zip payload encrypted inside an internal
# stream. 'unzip -t' correctly fails on that - not because
# anything is corrupt, but because it is legitimately not a zip
# file. Recognize this case before treating a failed zip check
# as corruption.
local _head8o
_head8o="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head8o" = "d0cf11e0a1b11ae1" ]] ; then
echo "VALID|OLE2/CFBF container signature ok - this is a password-protected Office file (encrypted package), not a plain zip, so the zip check does not apply; open it with the password to verify content"
elif command -v unzip > /dev/null 2>&1 ; then
if unzip -tq "$_f" > /dev/null 2>&1 ; then
echo "VALID|zip integrity ok"
else
local _badentry
_badentry="$(trim "$(unzip -t "$_f" 2>&1 | grep -v '^Archive:' | grep -v '^[[:space:]]*$' | head -1)")"
echo "INVALID|zip integrity check failed${_badentry:+ (${_badentry})}"
fi
else
echo "UNVERIFIED|unzip not installed"
fi
;;
mp4|mov|m4v)
if head -c 64 "$_f" 2> /dev/null | grep -aq "ftyp" ; then
echo "VALID|mp4 ftyp box found"
else
echo "INVALID|mp4 ftyp box not found"
fi
;;
doc|xls|ppt|ole|msi)
local _head8
_head8="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head8" = "d0cf11e0a1b11ae1" ]] ; then
echo "VALID|OLE2 Compound File signature ok (legacy Office)"
else
echo "INVALID|OLE2 Compound File signature missing"
fi
;;
azw3|mobi|prc)
# PalmDOC/Mobipocket container: 8-byte type/creator id
# 'BOOKMOBI' at fixed offset 60 in the header.
local _sig
_sig="$(dd if="$_f" bs=1 skip=60 count=8 2> /dev/null)"
if [[ "$_sig" = "BOOKMOBI" ]] ; then
echo "VALID|BOOKMOBI (Kindle/Mobi) signature ok"
else
echo "INVALID|BOOKMOBI signature missing at offset 60"
fi
;;
ai)
# Modern Illustrator files are PDF-compatible by default;
# older ones are plain PostScript/EPS.
if [[ "$(head -c5 "$_f" 2> /dev/null)" = "%PDF-" ]] ; then
if _pdf_check "$_f" ; then
echo "VALID|AI (PDF-compatible) - $_pdf_check_detail"
else
echo "UNVERIFIED|AI (PDF-compatible) but $_pdf_check_detail"
fi
elif head -c 12 "$_f" 2> /dev/null | grep -qa '^%!PS-Adobe' ; then
echo "VALID|AI (legacy PostScript/EPS) header ok"
else
echo "INVALID|neither PDF- nor %!PS-Adobe header found"
fi
;;
htm|html)
if head -c 512 "$_f" 2> /dev/null | tr '[:upper:]' '[:lower:]' | grep -qE '<html|<!doctype html' ; then
echo "VALID|HTML tag found near start"
else
echo "INVALID|no <html>/<!DOCTYPE html> found near start"
fi
;;
css)
# Plain text + balanced braces is a solid signal: decryption
# garbage is essentially never valid, balanced-brace text.
if LC_ALL=C grep -qP '[\x00-\x08\x0E-\x1F\x7F]' "$_f" 2> /dev/null ; then
echo "INVALID|contains binary/control bytes, not plausible CSS text"
else
local _open _close
_open=$(grep -o '{' "$_f" 2> /dev/null | wc -l)
_close=$(grep -o '}' "$_f" 2> /dev/null | wc -l)
if [[ "$_open" -gt 0 && "$_open" -eq "$_close" ]] ; then
echo "VALID|plain text, ${_open} matching { } pairs"
else
echo "INVALID|plain text but { (${_open}) / } (${_close}) counts don't match"
fi
fi
;;
ttf|otf)
local _head4f
_head4f="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
# 00010000 = TrueType, 4F54544F = 'OTTO' (OpenType/CFF),
# 74727565 = 'true' (old Mac TrueType), 74746366 = 'ttcf' (collection)
if [[ "$_head4f" = "00010000" || "$_head4f" = "4f54544f" || "$_head4f" = "74727565" || "$_head4f" = "74746366" ]] ; then
echo "VALID|TrueType/OpenType font signature ok"
else
echo "INVALID|TrueType/OpenType font signature missing (head=$_head4f)"
fi
;;
woff)
if [[ "$(head -c4 "$_f" 2> /dev/null)" = "wOFF" ]] ; then
echo "VALID|WOFF font signature ok"
else
echo "INVALID|WOFF font signature missing"
fi
;;
woff2)
if [[ "$(head -c4 "$_f" 2> /dev/null)" = "wOF2" ]] ; then
echo "VALID|WOFF2 font signature ok"
else
echo "INVALID|WOFF2 font signature missing"
fi
;;
psd)
if [[ "$(head -c4 "$_f" 2> /dev/null)" = "8BPS" ]] ; then
echo "VALID|Photoshop (8BPS) signature ok"
else
echo "INVALID|Photoshop (8BPS) signature missing"
fi
;;
xcf)
if head -c 9 "$_f" 2> /dev/null | grep -qa '^gimp xcf' ; then
echo "VALID|GIMP (gimp xcf) signature ok"
else
echo "INVALID|GIMP (gimp xcf) signature missing"
fi
;;
wav)
local _riff _wave
_riff="$(head -c4 "$_f" 2> /dev/null)"
_wave="$(dd if="$_f" bs=1 skip=8 count=4 2> /dev/null)"
if [[ "$_riff" = "RIFF" && "$_wave" = "WAVE" ]] ; then
echo "VALID|RIFF/WAVE signature ok"
else
echo "INVALID|RIFF/WAVE signature missing"
fi
;;
ico)
local _head4i
_head4i="$(head -c4 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head4i" = "00000100" ]] ; then
echo "VALID|ICO signature ok"
else
echo "INVALID|ICO signature missing (head=$_head4i)"
fi
;;
rtf)
if head -c 6 "$_f" 2> /dev/null | grep -qa '^{\\rtf' ; then
echo "VALID|RTF signature ok"
else
echo "INVALID|RTF signature missing"
fi
;;
exe|dll|sys|scr|ocx|cpl)
# Windows PE: 'MZ' at offset 0, then at the offset stored (as a
# little-endian uint32) at 0x3C there must be a 'PE\0\0' signature.
local _mz _pe_off_hex _pe_off _pe_sig
_mz="$(head -c2 "$_f" 2> /dev/null)"
if [[ "$_mz" != "MZ" ]] ; then
echo "INVALID|PE 'MZ' header missing"
else
_pe_off_hex="$(dd if="$_f" bs=1 skip=60 count=4 2> /dev/null | od -An -tx1 | tr -d ' \n')"
# little-endian: reverse the byte order before interpreting as hex
_pe_off=$((16#${_pe_off_hex:6:2}${_pe_off_hex:4:2}${_pe_off_hex:2:2}${_pe_off_hex:0:2}))
_pe_sig="$(dd if="$_f" bs=1 skip="$_pe_off" count=4 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_pe_sig" = "50450000" ]] ; then
echo "VALID|PE ('MZ' + 'PE\\0\\0') signature ok"
else
echo "INVALID|'MZ' header ok but 'PE' signature missing at offset $_pe_off (got $_pe_sig)"
fi
fi
;;
cab)
if [[ "$(head -c4 "$_f" 2> /dev/null)" = "MSCF" ]] ; then
echo "VALID|Microsoft Cabinet (MSCF) signature ok"
else
echo "INVALID|Microsoft Cabinet (MSCF) signature missing"
fi
;;
js|mjs|json|xml|svg|txt|csv|tsv|ini|cfg|conf|log|md|yml|yaml|properties|sql|sh|bash|py|php|java|c|h|cpp|hpp)
# Plain-text formats: broken decryption output is essentially
# never clean, printable text over any non-trivial length, so
# "no binary/control bytes" is itself strong positive evidence -
# much stronger than for an unknown/binary extension, where the
# same check only feeds the generic fallback below.
if LC_ALL=C grep -qP '[\x00-\x08\x0E-\x1F\x7F]' "$_f" 2> /dev/null ; then
echo "INVALID|contains binary/control bytes, not plausible '.${_ext}' text"
else
echo "VALID|plain text, no binary/control bytes (no deeper '.${_ext}' syntax check performed)"
fi
;;
indd|indt|indl)
# Adobe InDesign document/template/library container. There is
# no open decoder to fully parse it, but every valid file (since
# CS2/CS4) starts with this fixed 16-byte GUID-like signature -
# a corrupted/garbage decryption result will essentially never
# reproduce it by chance.
local _head16
_head16="$(head -c16 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head16" = "0606edf5d81d46e5bd31efe7fe74b71d" ]] ; then
echo "VALID|InDesign container signature ok (content/layout itself not parsed - open in InDesign to fully confirm)"
else
echo "INVALID|InDesign container signature missing (head=$_head16)"
fi
;;
*)
_validate_by_content_or_heuristic "$_f" "$_ext" "$_size" "$_origsize"
;;
esac
}
## - PDF structural check shared by the 'pdf' case and the 'ai'
## - (PDF-compatible) case. Sets $_pdf_check_detail, returns 0/1.
## -
_pdf_check() {
local _f="$1"
if [[ "$(head -c5 "$_f" 2> /dev/null)" != "%PDF-" ]] ; then
_pdf_check_detail="missing %PDF- header"
return 1
fi
if command -v qpdf > /dev/null 2>&1 ; then
if qpdf --check "$_f" > /dev/null 2>&1 ; then
_pdf_check_detail="qpdf --check ok"
return 0
else
_pdf_check_detail="qpdf --check failed"
return 1
fi
fi
_pdf_check_detail="PDF header ok (qpdf not installed, no deep check)"
return 0
}
## - JPEG structural check - see recover_bad_signature.sh for the full
## - rationale (a raw "ends in FFD9" byte check produces false
## - positives; prefer an actual decoder). Sets $_jpeg_check_detail,
## - returns 0/1.
_jpeg_check() {
local _f="$1"
if command -v identify > /dev/null 2>&1 ; then
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
_jpeg_check_detail="ImageMagick 'identify' decoded the image ok"
return 0
else
_jpeg_check_detail="ImageMagick 'identify' failed to decode the image"
return 1
fi
fi
if command -v php > /dev/null 2>&1 ; then
local _phpout
_phpout="$(php -r '
error_reporting(0);
if (!function_exists("imagecreatefromjpeg")) { echo "NOGD"; exit(0); }
$im = @imagecreatefromjpeg($argv[1]);
if ($im === false) { echo "BAD"; exit(0); }
imagedestroy($im);
echo "OK";
' "$_f" 2> /dev/null)"
case "$_phpout" in
OK)
_jpeg_check_detail="PHP GD (imagecreatefromjpeg) decoded the image ok"
return 0
;;
BAD)
_jpeg_check_detail="PHP GD (imagecreatefromjpeg) failed to decode the image"
return 1
;;
esac
# NOGD or empty output: GD extension not available, fall through
fi
# last resort: the old, less reliable marker heuristic
local _head _tail
_head="$(head -c3 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
_tail="$(tail -c2 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
if [[ "$_head" = "ffd8ff" && "$_tail" = "ffd9" ]] ; then
_jpeg_check_detail="JPEG SOI/EOI markers ok (no decoder available for a deep check)"
return 0
elif [[ "$_head" = "ffd8ff" ]] ; then
_jpeg_check_detail="JPEG SOI marker ok but no decoder available for a deep check; raw EOI-at-EOF heuristic failed (head=$_head tail=$_tail) - this is NOT reliable, many valid JPEGs carry trailer bytes after EOI, verify manually or install ImageMagick/php-gd"
return 1
else
_jpeg_check_detail="JPEG SOI marker missing (head=$_head)"
return 1
fi
}
## - Fallback for extensions without a dedicated structural check (or
## - no extension at all) - see recover_bad_signature.sh for the full
## - rationale.
## -
_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 other users' files under
# - the Nextcloud data directory for the raw ciphertext backup, 'su'
# - into the webserver user to perform the actual restore writes).
# -
if [[ "$(id -u)" -ne 0 ]] ; then
fatal "This script must be run as root (it needs to read the Nextcloud data directory for backups and 'su' into the webserver user). Please re-run as root, e.g. via sudo."
fi
# ----------
# - Jobhandling
# ----------
if pgrep -f "$(basename $0)" | grep -q -v $$ ; then
msg="A previos instance of script \"`basename $0`\" seems already be running."
echo ""
if $terminal ; then
echo -e "[ \033[31m\033[1mFatal\033[m ]: $msg"
echo ""
echo -e " \033[31m\033[1mScript was interupted\033[m!"
else
echo " [ Fatal ]: $msg"
echo ""
echo " Script was interupted!"
fi
echo
exit 1
else
if [[ -d "$LOCK_DIR" ]] ; then
rm -rf "$LOCK_DIR" 2> /dev/null
fi
fi
# - If job already runs, stop execution..
# -
if mkdir "$LOCK_DIR" 2> /dev/null ; then
# - Remove lockdir when the script finishes, or when it receives a signal
# -
trap clean_up SIGHUP SIGINT SIGTERM
else
msg="A previos instance of script \"`basename $0`\" seems already be running."
echo ""
if $terminal ; then
echo -e "[ \033[31m\033[1mFatal\033[m ]: $msg"
echo ""
echo -e " \033[31m\033[1mScript was interupted\033[m!"
else
echo " [ Fatal ]: $msg"
echo ""
echo " Script was interupted!"
fi
echo
exit 1
fi
# -------------
# - Read in Commandline arguments
# -------------
DRY_RUN=false
_dry_run_explicit=false
while getopts hns: opt ; do
case $opt in
h) usage ;;
n) DRY_RUN=true; _dry_run_explicit=true ;;
s) WEBSITE=$OPTARG ;;
\?) usage
esac
done
# =============
# --- Ask which mode to run in, unless '-n' was already given on the
# --- command line (that still works unchanged for scripted/non-
# --- interactive invocations). Unlike recover_bad_signature.sh, the
# --- SAFE choice (dry-run) is the default here on purpose - this
# --- script writes into live Nextcloud storage.
# =============
if ! $_dry_run_explicit && $terminal ; then
blank_line
echo -e "\033[37m\033[1mWhich mode should this run use?\033[m"
echo ""
echo -e " \033[1m[1] Dry-run\033[m - go through everything (selection, re-validation, reporting), but write, back up and verify NOTHING"
echo " [2] Real restore - actually back up, overwrite and verify each file in Nextcloud"
info "Just press Return to use the default: [1] Dry-run."
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)
# - Sort array
# -
IFS=$'\n' website_arr=($(sort <<<"${unsorted_website_arr[*]}"))
# Which cloud instance (website) would you like to update
#
source ${snippet_dir}/get-cloud-instance-to-update.sh
else
while IFS='' read -r -d '' _conf_file ; do
if $(grep -E -q "WEBSITE=\"?${WEBSITE}\"?" ${_conf_file} 2> /dev/null) ; then
conf_file="${_conf_file}"
break
fi
done < <(find "${conf_dir}" -maxdepth 1 -type f -name "*.conf" -print0)
fi
# - Reset IFS
# -
IFS=$CUR_IFS
DEFAULT_SRC_BASE_DIR="/usr/local/src/nextcloud"
DEFAULT_HTTP_USER="www-data"
DEFAULT_HTTP_GROUP="www-data"
DEFAULT_PHP_ENGINE='FPM'
blank_line
echononl " Include Configuration file '$(basename "${conf_file}")'.."
if [[ ! -f $conf_file ]]; then
echo_skipped
fatal "Missing configuration file '$conf_file'."
else
source $conf_file
echo_ok
fi
DEFAULT_WEB_BASE_DIR="/var/www/${WEBSITE}"
[[ -n "$WEB_BASE_DIR" ]] || WEB_BASE_DIR=$DEFAULT_WEB_BASE_DIR
if [[ ! -d ${WEB_BASE_DIR} ]] ; then
fatal "Web base directory '$WEB_BASE_DIR' not found!"
fi
DATA_DIR="$(realpath ${WEB_BASE_DIR}/data)"
[[ -n "$PHP_ENGINE" ]] || PHP_ENGINE=$DEFAULT_PHP_ENGINE
INSTALL_DIR="$(realpath ${WEB_BASE_DIR}/nextcloud)"
CURRENT_VERSION="$(basename $INSTALL_DIR | cut -d"-" -f2)"
# - Muss auf denselben Ort zeigen wie beim recover_bad_signature.sh-Lauf,
# - der die hier verwendeten Dateien erzeugt hat.
# -
[[ -n "$RECOVERY_BASE_DIR" ]] || RECOVERY_BASE_DIR=$DEFAULT_RECOVERY_BASE_DIR
recovery_dir="${RECOVERY_BASE_DIR}/${WEBSITE}"
[[ -n "$RESTORE_BACKUP_BASE_DIR" ]] || RESTORE_BACKUP_BASE_DIR=$DEFAULT_RESTORE_BACKUP_BASE_DIR
backup_dir="${RESTORE_BACKUP_BASE_DIR}/${WEBSITE}"
# =============
# --- Some
# =============
# - Support systemd ?
# -
SYSTEMD_EXISTS=false
systemd=$(which systemd)
systemctl=$(which systemctl)
if [[ -n "$systemd" ]] || [[ -n "$systemctl" ]] ; then
SYSTEMD_EXISTS=true
fi
if $terminal ; then
echo ""
echo -e "\033[32m-----\033[m"
echo -e "Restore already-recovered \033[1mBad Signature\033[m files back into system \033[1m${WEB_BASE_DIR}\033[m"
echo -e "\033[1m
This WRITES into Nextcloud's live storage. Only files marked VALID
by a prior recover_bad_signature.sh run are ever restored, each is
re-validated again right before writing, the current (still broken)
original is backed up first, and every write is verified by reading
it back afterwards.\033[m"
echo -e "\033[32m-----\033[m"
fi
# =============
# --- Some checks
# =============
DEFAULT_HTTP_USER="www-data"
DEFAULT_HTTP_GROUP="www-data"
NGINX_IS_ENABLED=false
APACHE2_IS_ENABLED=false
# Get Webservice environment as IS_HTTPD_RUNNING, HTTP_USER, HTTP_GROUP..
#
source ${snippet_dir}/get-webservice-environment.sh
# Check PHP Version
#
source ${snippet_dir}/get-php-major-version.sh
# Get full qualified PHP command
#
source ${snippet_dir}/get-path-of-php-command.sh
if [[ ! -x "$PHP_BIN" ]]; then
fatal "No PHP binary found!"
fi
# =============
# --- Check Server-Side Encryption status (informational - this script
# --- works either way, but the whole point of it assumes SSE is on)
# =============
blank_line
echononl " Check Server-Side Encryption status.."
_encryption_status_out="$(su -c "$PHP_BIN $INSTALL_DIR/occ encryption:status" -s /bin/bash $HTTP_USER 2> "$log_file")"
ENCRYPTION_ENABLED="unknown"
if [[ -s "$log_file" ]] ; then
echo_failed
error "$(cat "$log_file")"
elif echo "$_encryption_status_out" | grep -qiE 'enabled:[[:space:]]*true' ; then
echo_ok
ENCRYPTION_ENABLED="yes"
else
echo_warning
ENCRYPTION_ENABLED="no"
warn "Server-Side Encryption appears to be disabled on ${WEBSITE} right now. Restoring will still work (files are simply written unencrypted), but double-check this is expected."
fi
# =============
# --- Determine available recovery reports (output of
# --- recover_bad_signature.sh - NOT the bad_signature_*.tsv scan
# --- reports)
# =============
mapfile -t _available_reports < <(ls -t "${report_dir}"/recovery_*.tsv 2> /dev/null)
if [[ ${#_available_reports[@]} -eq 0 ]] ; then
fatal "No recovery report files (recovery_*.tsv) found in '${report_dir}'. Run recover_bad_signature.sh first."
fi
blank_line
if $terminal ; then
echo -e "\033[37m\033[1mAvailable recovery 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" pairs for every VALID entry in the chosen
# --- report. The report has no per-row user column - accounts are
# --- section headers ('---------' / '<user>' / '---------'). Only
# --- 6-tab-field data rows belong to the following case; the header
# --- row, the 'Datenmuell'/'Nicht pruefbar' detail blocks (2 fields)
# --- and the account-statistics lines (no tabs) never match NF==6.
# =============
valid_entries_file="${LOCK_DIR}/valid_entries.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 == 6 && $1 != "path" && $5 == "VALID") {
print user "\t" $1
}
}
' "$chosen_report" > "$valid_entries_file"
unsorted_account_arr=($(cut -f1 "$valid_entries_file" | sort -u))
IFS=$'\n' account_arr=($(sort <<<"${unsorted_account_arr[*]}"))
IFS=$CUR_IFS
if [[ ${#account_arr[@]} -eq 0 ]] ; then
fatal "No 'VALID' entries found in '$(basename "$chosen_report")' - nothing to restore."
fi
blank_line
if $terminal ; then
echo -e "\033[37m\033[1mAccounts with 'VALID' entries in this report\033[m"
echo ""
for _a in "${account_arr[@]}" ; do
_a_count=$(awk -F'\t' -v u="$_a" '$1==u' "$valid_entries_file" | wc -l)
echo " - $_a (${_a_count})"
done
echo ""
fi
echo -n " Account(s) to restore VALID 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 restore VALID files for (blank separated, or 'all'): "
read _input
done
# =============
# --- Confirmation
# =============
mkdir -p "$report_dir" 2> /dev/null
restore_report_file="${report_dir}/restore_${WEBSITE}_${run_date}.tsv"
if $terminal ; then
echo ""
if $DRY_RUN ; then
echo -e "\033[1;32mStarting DRY-RUN restore for \033[1;37m${WEBSITE}\033[m"
else
echo -e "\033[1;31m\033[1mStarting REAL restore (writes into live Nextcloud storage) for \033[1;37m${WEBSITE}\033[m"
fi
echo ""
echo -e " Cloud instance........................: $WEBSITE"
echo ""
echo -e " Recovery report used...................: $(basename "$chosen_report")"
echo -e " Recovered files read from..............: $recovery_dir"
echo -e " Account(s) to restore..................: \033[33m${selected_user_arr[*]}\033[m"
echo ""
if $DRY_RUN ; then
echo -e " Nothing will be written - dry-run only."
else
echo -e " Ciphertext backup (before overwrite)...: $backup_dir"
echo -e " Files written back to their ORIGINAL path in Nextcloud."
fi
echo -e " Restore report..........................: reports/$(basename "${restore_report_file}")"
echo ""
if $DRY_RUN ; then
info "Dry-run: selection and re-validation run for real, nothing is backed up, written or verified."
else
warn "This WRITES into Nextcloud's live storage, overwriting the still-broken original at its ORIGINAL path with the recovered content - the first script in this toolkit that does so. Only files re-validated as VALID right now are touched. The current (still broken) original is copied byte-for-byte to '${backup_dir}' first, and every write is read back and verified afterwards, but please still spot-check a few results yourself."
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 restore VALID files for each selected account on \033[1;37m$WEBSITE \033[m"
else
fatal "Abort by user request - Answer as not 'YES'"
fi
fi
{
echo "=================================================================="
echo " Bad-Signature-Restore-Versuch ${WEBSITE} ${run_date}"
echo "=================================================================="
echo ""
echo " Basis-Report (Recovery): $(basename "$chosen_report")"
echo " Nur Eintraege mit validation=VALID aus diesem Report werden zurueckgeschrieben."
$DRY_RUN && echo " DRY-RUN: es wurde NICHTS geschrieben, gesichert oder verifiziert."
! $DRY_RUN && echo " Chiffretext-Sicherung (vor dem Ueberschreiben) liegt unter: ${backup_dir}"
echo ""
echo -e "path\tbytes_written\tstatus\tdetail"
} > "$restore_report_file"
# =============
# --- Write the embedded PHP restore script
# =============
restore_php_file="${INSTALL_DIR}/.restore_bad_signature_$$.php"
cat > "$restore_php_file" <<'PHP_RESTORE_EOF'
<?php
/**
* restore_bad_signature.php
*
* Writes already-recovered, already re-validated plaintext files back
* into Nextcloud's own storage, at their ORIGINAL path, through the
* normal Files API - the same write path a regular upload/sync client
* write uses. Server-Side Encryption (if enabled) transparently
* (re-)encrypts the content with a fresh IV and a correct signature;
* 'encryption_skip_signature_check' is never touched by this script -
* a fresh write does not need it.
*
* After each write, the file is immediately read back through the
* SAME normal read path (no signature-check bypass) and compared
* byte-for-byte (SHA-256) against the source - proof the file is
* genuinely healed, not just that a write happened without error.
*
* Usage: php restore_bad_signature.php <uid> <listFile> [--dry-run]
* listFile: TSV with "path\tlocalRecoveredFile" per line (no header)
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
register_shutdown_function(function () {
$err = error_get_last();
if ($err !== null && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
fwrite(STDERR, "DEBUG: FATAL bei Shutdown: [{$err['type']}] {$err['message']} in {$err['file']}:{$err['line']}\n");
}
});
define('OC_CONSOLE', 1);
$oldWorkingDir = getcwd();
if ($oldWorkingDir === false) {
fwrite(STDERR, "Konnte aktuelles Arbeitsverzeichnis nicht ermitteln. Bitte mit absolutem Pfad aufrufen.\n");
exit(1);
}
chdir(__DIR__);
require_once __DIR__ . '/lib/base.php';
chdir($oldWorkingDir);
if (function_exists('posix_getuid') && posix_getuid() === 0) {
fwrite(STDERR, "Bitte NICHT als root ausfuehren, sondern als Webserver-User.\n");
exit(1);
}
try {
$uid = $argv[1] ?? null;
$listFile = $argv[2] ?? null;
$dryRun = in_array('--dry-run', $argv, true);
if ($uid === null || $listFile === null) {
fwrite(STDERR, "Usage: php restore_bad_signature.php <uid> <listFile> [--dry-run]\n");
exit(1);
}
if (!is_file($listFile)) {
fwrite(STDERR, "Liste nicht gefunden: $listFile\n");
exit(1);
}
$rootFolder = \OC::$server->get(\OCP\Files\IRootFolder::class);
\OC_Util::setupFS($uid);
$lines = file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
$lines = [];
}
$total = 0;
$ok = 0;
$failed = 0;
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;
}
$total++;
if (!is_file($local) || !is_readable($local)) {
$failed++;
echo "$path\t0\tERROR\tlocal recovered file not readable: $local\n";
flush();
continue;
}
$localSize = filesize($local);
$localHash = hash_file('sha256', $local);
if ($dryRun) {
$ok++;
echo "$path\t$localSize\tDRY_RUN\twould write $localSize bytes and verify readback (sha256 $localHash)\n";
flush();
continue;
}
try {
$node = null;
try {
$node = $rootFolder->get($path);
} catch (\Throwable $e) {
$node = null;
}
if ($node !== null && !($node instanceof \OCP\Files\File)) {
$failed++;
echo "$path\t0\tERROR\ttarget exists but is not a regular file\n";
flush();
continue;
}
if ($node === null) {
// Original missing entirely (unexpected - the file this
// was recovered from should still be there, just
// unreadable). Create it fresh at the same path rather
// than silently skipping it.
$userFolder = $rootFolder->getUserFolder($uid);
$relative = preg_replace('#^/' . preg_quote($uid, '#') . '/files/#', '', $path);
if ($relative === null || $relative === '') {
$relative = ltrim($path, '/');
}
$node = $userFolder->newFile($relative);
}
$in = fopen($local, 'r');
if ($in === false) {
throw new \RuntimeException("lokale Datei konnte nicht geoeffnet werden: $local");
}
$node->putContent($in);
if (is_resource($in)) {
fclose($in);
}
// Verify: re-read through the SAME normal API path (no
// signature-check bypass) and compare byte-for-byte.
clearstatcache();
$freshNode = $rootFolder->get($path);
$readBack = $freshNode->fopen('r');
if ($readBack === false) {
$failed++;
echo "$path\t0\tWRITE_ERROR\tputContent() did not throw, but re-read (fopen) failed\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) {
$ok++;
echo "$path\t$bytesRead\tOK\tverified: re-read $bytesRead bytes, sha256 matches source ($readBackHash)\n";
} else {
$failed++;
$hashNote = ($readBackHash === $localHash) ? 'sha256 matches' : 'sha256 MISMATCH';
echo "$path\t$bytesRead\tVERIFY_MISMATCH\tread back $bytesRead bytes (expected $localSize), $hashNote\n";
}
flush();
} catch (\Throwable $e) {
$failed++;
$msg = str_replace(["\t", "\n", "\r"], ' ', $e->getMessage());
echo "$path\t0\tWRITE_ERROR\t" . get_class($e) . ": $msg\n";
flush();
}
}
fwrite(STDERR, "\nFertig. Dateien verarbeitet: $total, ok: $ok, Fehler: $failed\n");
} catch (\Throwable $e) {
fwrite(STDERR, "\n!!! UNBEHANDELTE AUSNAHME !!!\n");
fwrite(STDERR, get_class($e) . ": " . $e->getMessage() . "\n");
fwrite(STDERR, "in " . $e->getFile() . ":" . $e->getLine() . "\n");
fwrite(STDERR, $e->getTraceAsString() . "\n");
exit(2);
}
PHP_RESTORE_EOF
chmod 644 "$restore_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 "$backup_dir" 2> /dev/null
fi
declare -i total_selected=0
declare -i total_prevalidation_failed=0
declare -i total_ok=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"
} >> "$restore_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=()
list_file="${LOCK_DIR}/list_${_user}.tsv"
> "$list_file"
# =============
# --- Re-validate every VALID-marked entry against the CURRENT
# --- checks right before writing anything, and (unless dry-run)
# --- back up the current on-disk ciphertext 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 restored")
continue
fi
if ! $DRY_RUN ; then
_orig_ciphertext="${DATA_DIR}/${_user}/files/${_rel}"
if [[ -f "$_orig_ciphertext" ]] ; then
_backup_target="${backup_dir}/${_user}/${_rel}"
mkdir -p "$(dirname "$_backup_target")" 2> /dev/null
cp -p "$_orig_ciphertext" "$_backup_target" 2> /dev/null
fi
fi
printf '%s\t%s\n' "$_path" "$_local" >> "$list_file"
done < "$valid_entries_file"
if [[ $_user_selected -eq 0 ]] ; then
warn "No 'VALID' entries for account '${_user}' found in the selected report - skipping."
echo " [ keine VALID-Eintraege im gewaehlten Report ]" >> "$restore_report_file"
continue
fi
user_result_tsv="${LOCK_DIR}/result_${_user}.tsv"
if $DRY_RUN ; then
echononl " Dry-run for account \033[1;37m${_user}\033[m (${_user_selected} selected, $(wc -l < "$list_file") to check).."
else
echononl " Restoring account \033[1;37m${_user}\033[m (${_user_selected} selected, $(wc -l < "$list_file") to write).."
fi
if [[ -s "$list_file" ]] ; then
if $DRY_RUN ; then
su -c "$PHP_BIN $restore_php_file $_user $list_file --dry-run" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file"
else
su -c "$PHP_BIN $restore_php_file $_user $list_file" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file"
fi
_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 Restore-Lauf - siehe Server-Log ]" >> "$restore_report_file"
(( total_failed_users++ ))
continue
fi
echo_done
$terminal && echo ""
declare -i _user_ok=0
declare -i _user_write_errors=0
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++ )) ;;
*) (( _user_write_errors++ )); _user_write_error_lines+=("${_path}"$'\t'"${_status}: ${_detail}") ;;
esac
# printf, not 'echo -e': $_detail can contain a raw PHP exception
# message (e.g. 'OCA\Encryption\Exceptions\...') - 'echo -e' would
# reinterpret those backslashes as escape sequences and silently
# mangle/eat parts of the text. printf's %s never reinterprets its
# argument, only the literal \t/\n in the format string itself.
printf '%s\t%s\t%s\t%s\n' "$_path" "$_bytes" "$_status" "$_detail" >> "$restore_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 (validation=VALID im Report)..: ${_user_selected}"
if [[ $_user_prevalidation_failed -gt 0 ]] ; then
echo -e " Vor dem Schreiben aussortiert..............: \033[33m${_user_prevalidation_failed}\033[m"
fi
if $DRY_RUN ; then
echo -e " Wuerde geschrieben & verifiziert werden....: \033[1;32m${_user_ok} (${_user_pct_ok} %)\033[m"
else
if [[ $_user_ok -gt 0 ]] ; then
echo -e " Zurueckgeschrieben & verifiziert...........: \033[1;32m${_user_ok} (${_user_pct_ok} %)\033[m"
else
echo -e " Zurueckgeschrieben & verifiziert...........: ${_user_ok} (${_user_pct_ok} %)"
fi
fi
if [[ $_user_write_errors -gt 0 ]] ; then
echo -e " Fehler beim Schreiben/Verifizieren.........: \033[1;31m${_user_write_errors}\033[m"
fi
echo ""
fi
# - Problem files listed as their own, visually set-off block(s),
# - before the account statistics - same convention as
# - recover_bad_signature.sh.
# -
{
if [[ $_user_prevalidation_failed -gt 0 ]] ; then
echo ""
echo " ------------------------------------------------------------------"
echo " Vor dem Schreiben aussortiert - Account ${_user} (${_user_prevalidation_failed})"
echo " ------------------------------------------------------------------"
for _line in "${_user_prevalidation_failed_lines[@]}" ; do
printf ' %s\n' "$_line"
done
fi
if [[ $_user_write_errors -gt 0 ]] ; then
echo ""
echo " ------------------------------------------------------------------"
echo " Fehler beim Schreiben/Verifizieren - Account ${_user} (${_user_write_errors})"
echo " ------------------------------------------------------------------"
for _line in "${_user_write_error_lines[@]}" ; do
printf ' %s\n' "$_line"
done
fi
} >> "$restore_report_file"
{
echo ""
echo " Account ${_user}"
echo " Ausgewaehlt (validation=VALID im Report)..: ${_user_selected}"
[[ $_user_prevalidation_failed -gt 0 ]] && echo " Vor dem Schreiben aussortiert..............: ${_user_prevalidation_failed}"
if $DRY_RUN ; then
echo " Wuerde geschrieben & verifiziert werden....: ${_user_ok} (${_user_pct_ok} %)"
else
echo " Zurueckgeschrieben & verifiziert...........: ${_user_ok} (${_user_pct_ok} %)"
fi
[[ $_user_write_errors -gt 0 ]] && echo " Fehler beim Schreiben/Verifizieren.........: ${_user_write_errors}"
} >> "$restore_report_file"
(( total_selected += _user_selected ))
(( total_prevalidation_failed += _user_prevalidation_failed ))
(( total_ok += _user_ok ))
(( total_write_errors += _user_write_errors ))
unset _user_valid _user_prevalidation_failed _user_prevalidation_failed_lines
unset _user_ok _user_write_errors _user_write_error_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 Restore-Fehler.................: ${total_failed_users}"
echo "Ausgewaehlt (validation=VALID im Report)....: ${total_selected}"
[[ $total_prevalidation_failed -gt 0 ]] && echo "Vor dem Schreiben aussortiert...............: ${total_prevalidation_failed}"
if $DRY_RUN ; then
echo "Wuerde geschrieben & verifiziert werden.....: ${total_ok} (${total_pct_ok} %)"
else
echo "Zurueckgeschrieben & verifiziert insgesamt...: ${total_ok} (${total_pct_ok} %)"
fi
[[ $total_write_errors -gt 0 ]] && echo "Fehler beim Schreiben/Verifizieren insgesamt: ${total_write_errors}"
echo ""
if $DRY_RUN ; then
echo "DRY-RUN: es wurde nichts geschrieben, gesichert oder veraendert."
else
echo "Chiffretext-Sicherung (vor dem Ueberschreiben) liegt unter: ${backup_dir}"
echo "Bitte Ergebnisse trotz Verifikation stichprobenartig pruefen."
fi
} >> "$restore_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 Restore-Fehler.................: \033[1;31m${total_failed_users}\033[m"
echo -e " Ausgewaehlt (validation=VALID im Report)....: ${total_selected}"
[[ $total_prevalidation_failed -gt 0 ]] && echo -e " Vor dem Schreiben aussortiert...............: \033[33m${total_prevalidation_failed}\033[m"
if $DRY_RUN ; then
echo -e " Wuerde geschrieben & verifiziert werden.....: \033[1;32m${total_ok} (${total_pct_ok} %)\033[m"
else
if [[ $total_ok -gt 0 ]] ; then
echo -e " Zurueckgeschrieben & verifiziert insgesamt...: \033[1;32m${total_ok} (${total_pct_ok} %)\033[m"
else
echo -e " Zurueckgeschrieben & verifiziert insgesamt...: ${total_ok} (${total_pct_ok} %)"
fi
fi
[[ $total_write_errors -gt 0 ]] && echo -e " Fehler beim Schreiben/Verifizieren insgesamt: \033[1;31m${total_write_errors}\033[m"
echo ""
echo -e " Restore-Report...............................: reports/$(basename "${restore_report_file}")"
if ! $DRY_RUN ; then
echo -e " Chiffretext-Sicherung........................: $backup_dir"
fi
echo ""
if $DRY_RUN ; then
info "Dry-run beendet - es wurde nichts geschrieben. Ohne '-n' (oder mit [2] bei der Modusabfrage) fuer den echten Restore erneut ausfuehren."
else
warn "Bitte die zurueckgeschriebenen Dateien trotz Verifikation stichprobenartig im Webinterface pruefen."
fi
fi
clean_up 0