fix: treat qpdf exit code 3 (warnings only) as VALID in PDF check
- exit 0 and exit 3 both map to VALID; only exit 2 is a structural error
- error detail now includes first matching error line from qpdf output
fix: add EXIT trap so encryption flag and temp files are always cleaned up
- changed signal trap from to
- EXIT fires for any bash exit including syntax errors and unexpected crashes
- clean_up() now runs first to prevent re-entry / infinite loop
- applies to recover_bad_signature.sh (where the flag matters most),
restore_bad_signature.sh and recreate_bad_signature.sh
feat: check optional validation tools at startup and offer apt install
- new check_optional_validation_tools() prompts [j/N] in interactive mode
- covers: imagemagick (identify), ffmpeg (ffprobe), mp3val, flac, vorbis-tools (ogginfo)
feat: use optional tools for deeper file validation when available
- PNG / GIF / BMP / TIFF: identify -regard-warnings (full decode) with magic-byte fallback
- MP4 / MOV / M4V: ffprobe -show_streams (container parse) with ftyp-box fallback
- MP3: new dedicated case — mp3val frame check with ID3/sync-word fallback
- FLAC: new dedicated case — flac --silent --test with fLaC-signature fallback
- OGG / OGA / OGV / OPUS: new dedicated case — ogginfo with OggS-signature fallback
Affects: recover_bad_signature.sh, restore_bad_signature.sh, recreate_bad_signature.sh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfXh5sRbEaXiEX6KjAPivc
1877 lines
67 KiB
Bash
Executable File
1877 lines
67 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"
|
||
|
||
# - Das Recovery-Verzeichnis wird bewusst NICHT unter script_dir
|
||
# - angelegt: liegen die Scripte z.B. unter /root/..., kann der
|
||
# - www-data-Prozess (der die entschluesselten Dateien tatsaechlich
|
||
# - schreibt) /root gar nicht erst durchqueren (Modus 700), selbst
|
||
# - wenn der Zielordner selbst an www-data gechownt wird - jedes
|
||
# - fopen() dort wuerde mit einem Fehler fehlschlagen. Stattdessen ein
|
||
# - eigener Pfad ausserhalb von /root und ausserhalb des Web-Docroots,
|
||
# - per conf-Datei ueberschreibbar (RECOVERY_BASE_DIR).
|
||
# -
|
||
DEFAULT_RECOVERY_BASE_DIR="/var/nc-recovery"
|
||
|
||
declare -a unsorted_website_arr
|
||
declare -a website_arr
|
||
|
||
declare -a unsorted_account_arr
|
||
declare -a account_arr
|
||
|
||
declare -a unsorted_selected_user_arr
|
||
declare -a selected_user_arr
|
||
|
||
LOCK_DIR="/tmp/${script_name%%.*}.LOCK"
|
||
log_file="${LOCK_DIR}/${script_name%%.*}.log"
|
||
|
||
run_date=$(date +%Y-%m-%d-%H%M)
|
||
|
||
_encryption_flag_changed=false
|
||
_orig_skip_check=""
|
||
|
||
|
||
# =============
|
||
# --- Some functions
|
||
# =============
|
||
|
||
usage() {
|
||
|
||
[[ -n "$1" ]] && error "$1"
|
||
|
||
[[ $terminal ]] && echo -e "
|
||
\033[1mUsage:\033[m
|
||
|
||
$(basename $0) -s <website>
|
||
|
||
\033[1mDescription\033[m
|
||
|
||
Attempts to recover files that were reported as 'Bad Signature' by
|
||
scan_bad_signature.sh, by temporarily ignoring the Server-Side
|
||
Encryption signature check (Nextcloud's own
|
||
'encryption_skip_signature_check' config switch) and decrypting the
|
||
affected files anyway.
|
||
|
||
This only works for files whose signature check fails due to a
|
||
computation mismatch, NOT for files whose encrypted content itself
|
||
is corrupted on disk - those will still produce garbage. Every
|
||
recovered file is therefore validated afterwards (file-type magic
|
||
bytes, PDF/zip integrity where possible) so you can tell the two
|
||
cases apart.
|
||
|
||
IMPORTANT:
|
||
- Original files are NEVER touched, modified or deleted. Every
|
||
recovered file is written to a SEPARATE directory outside of
|
||
Nextcloud's own storage (default: ${DEFAULT_RECOVERY_BASE_DIR}/<website>,
|
||
override with RECOVERY_BASE_DIR in the conf file) - the exact
|
||
path is shown before the confirmation prompt.
|
||
- The 'encryption_skip_signature_check' config switch is set
|
||
instance-wide for the duration of this script and restored to
|
||
its previous value afterwards (also on interrupt/error).
|
||
- Recovered files may contain unencrypted personal data outside
|
||
of Nextcloud's storage - handle and delete the recovery
|
||
directory accordingly once you are done reviewing it.
|
||
|
||
Unless '-V' is given on the command line, you will be asked
|
||
interactively whether to run a normal recovery or a revalidate-only
|
||
pass (see '-V' below). You will then be asked which existing scan
|
||
report (produced by scan_bad_signature.sh) to use, and then which
|
||
account(s) from that report to attempt recovery for:
|
||
|
||
- a blank separated list of account names
|
||
- or 'all' to attempt every account found in the report
|
||
|
||
\033[1mOptions\033[m
|
||
|
||
-s <website>
|
||
The site of the nextcloud instance.
|
||
|
||
-V
|
||
Revalidate-only mode: does NOT touch 'encryption_skip_signature_check'
|
||
and does NOT decrypt anything again. Instead it re-runs the
|
||
(possibly improved) validation checks against files that were
|
||
already recovered by a previous, normal run - useful after this
|
||
script gained new file-type checks, without repeating the
|
||
instance-wide signature-skip window. Passing it here skips the
|
||
interactive mode question mentioned above.
|
||
|
||
\033[1mExample:\033[m
|
||
|
||
Runs this script on system 'cloud-01.oopen.de'
|
||
|
||
$(basename $0) -s cloud-01.oopen.de
|
||
|
||
Re-validates already recovered files on 'cloud-01.oopen.de' with
|
||
the current checks, without decrypting anything again:
|
||
|
||
$(basename $0) -V -s cloud-01.oopen.de
|
||
"
|
||
|
||
clean_up 1
|
||
|
||
}
|
||
|
||
|
||
restore_encryption_flag() {
|
||
|
||
# - Setzt encryption_skip_signature_check auf den Ursprungswert
|
||
# - zurueck, egal ob das Skript normal durchlaeuft oder
|
||
# - unterbrochen wird. Kein-Op, falls das Flag nie geaendert wurde.
|
||
# -
|
||
if [[ "$_encryption_flag_changed" = true ]] ; then
|
||
echononl " Restoring encryption_skip_signature_check.."
|
||
if [[ -z "$_orig_skip_check" ]] ; then
|
||
su -c "$PHP_BIN $INSTALL_DIR/occ config:system:delete encryption_skip_signature_check" -s /bin/bash $HTTP_USER > /dev/null 2> "$log_file"
|
||
else
|
||
su -c "$PHP_BIN $INSTALL_DIR/occ config:system:set encryption_skip_signature_check --value=$_orig_skip_check --type=boolean" -s /bin/bash $HTTP_USER > /dev/null 2> "$log_file"
|
||
fi
|
||
|
||
if [[ $? -eq 0 ]] ; then
|
||
echo_ok
|
||
_encryption_flag_changed=false
|
||
else
|
||
echo_failed
|
||
error "Could not restore encryption_skip_signature_check automatically! Please check/fix manually on the server: occ config:system:get encryption_skip_signature_check (should be '${_orig_skip_check:-<not set>}'). $(cat "$log_file")"
|
||
fi
|
||
fi
|
||
|
||
}
|
||
|
||
|
||
clean_up() {
|
||
|
||
# Perform program exit housekeeping
|
||
# Clear EXIT trap first so that the exit below does not fire it again.
|
||
trap - EXIT
|
||
restore_encryption_flag
|
||
[[ -n "$recovery_php_file" ]] && rm -f "$recovery_php_file" 2> /dev/null
|
||
rm -rf "$LOCK_DIR"
|
||
blank_line
|
||
exit $1
|
||
}
|
||
|
||
|
||
check_optional_validation_tools() {
|
||
|
||
# Checks whether optional tools that improve file-validation quality
|
||
# are installed. In interactive mode the user is offered to install
|
||
# any that are missing via apt. In non-interactive mode the function
|
||
# is a quiet no-op (missing tools degrade quality but do not abort).
|
||
|
||
local _missing_tools=()
|
||
local _missing_pkgs=()
|
||
local _desc=()
|
||
|
||
command -v identify > /dev/null 2>&1 || {
|
||
_missing_tools+=("identify")
|
||
_missing_pkgs+=("imagemagick")
|
||
_desc+=("identify (imagemagick) — PNG / TIFF / BMP / GIF: vollst. Dekodierung statt nur Magic-Bytes")
|
||
}
|
||
command -v ffprobe > /dev/null 2>&1 || {
|
||
_missing_tools+=("ffprobe")
|
||
_missing_pkgs+=("ffmpeg")
|
||
_desc+=("ffprobe (ffmpeg) — MP4 / MOV / M4V: Container-Parsing statt nur ftyp-Box-Suche")
|
||
}
|
||
command -v mp3val > /dev/null 2>&1 || {
|
||
_missing_tools+=("mp3val")
|
||
_missing_pkgs+=("mp3val")
|
||
_desc+=("mp3val (mp3val) — MP3: Frame-Struktur-Check statt nur ID3-Signatur")
|
||
}
|
||
command -v flac > /dev/null 2>&1 || {
|
||
_missing_tools+=("flac")
|
||
_missing_pkgs+=("flac")
|
||
_desc+=("flac (flac) — FLAC: Decode-Test statt nur fLaC-Signatur")
|
||
}
|
||
command -v ogginfo > /dev/null 2>&1 || {
|
||
_missing_tools+=("ogginfo")
|
||
_missing_pkgs+=("vorbis-tools")
|
||
_desc+=("ogginfo (vorbis-tools) — OGG / OGA / OPUS: Container-Check statt nur OggS-Signatur")
|
||
}
|
||
|
||
[[ ${#_missing_tools[@]} -eq 0 ]] && return 0
|
||
|
||
if ! $terminal ; then
|
||
return 0
|
||
fi
|
||
|
||
local _pkg_list="${_missing_pkgs[*]}"
|
||
|
||
echo ""
|
||
echo -e " \033[33mOptionale Validierungs-Tools fehlen – Checks laufen mit reduzierter Genauigkeit:\033[m"
|
||
echo ""
|
||
local _d
|
||
for _d in "${_desc[@]}" ; do
|
||
echo " ${_d}"
|
||
done
|
||
echo ""
|
||
echo -n " Jetzt installieren? apt install ${_pkg_list} [j/N]: "
|
||
read -r _yn
|
||
echo ""
|
||
if [[ "$_yn" =~ ^[jJyY]$ ]] ; then
|
||
echononl " Installiere Pakete: ${_pkg_list}.."
|
||
# shellcheck disable=SC2086
|
||
if apt-get install -y ${_missing_pkgs[*]} > /dev/null 2>&1 ; then
|
||
echo_ok
|
||
else
|
||
echo_failed
|
||
echo ""
|
||
echo " Bitte manuell installieren:"
|
||
echo " apt install ${_pkg_list}"
|
||
fi
|
||
else
|
||
echo " Übersprungen — Validierung läuft mit reduzierter Genauigkeit."
|
||
fi
|
||
echo ""
|
||
|
||
}
|
||
|
||
|
||
is_number() {
|
||
|
||
return $(test ! -z "${1##*[!0-9]*}" > /dev/null 2>&1);
|
||
}
|
||
|
||
echononl(){
|
||
if $terminal ; then
|
||
echo X\\c > /tmp/shprompt$$
|
||
if [ `wc -c /tmp/shprompt$$ | awk '{print $1}'` -eq 1 ]; then
|
||
echo -e -n "$*\\c" 1>&2
|
||
else
|
||
echo -e -n "$*" 1>&2
|
||
fi
|
||
rm /tmp/shprompt$$
|
||
fi
|
||
}
|
||
echo_done() {
|
||
if $terminal ; then
|
||
echo -e "\033[75G[ \033[32mdone\033[m ]"
|
||
fi
|
||
}
|
||
echo_ok() {
|
||
if $terminal ; then
|
||
echo -e "\033[75G[ \033[32mok\033[m ]"
|
||
fi
|
||
}
|
||
echo_warning() {
|
||
if $terminal ; then
|
||
echo -e "\033[75G[ \033[33m\033[1mwarn\033[m ]"
|
||
fi
|
||
}
|
||
echo_failed(){
|
||
if $terminal ; then
|
||
echo -e "\033[75G[ \033[1;31mfailed\033[m ]"
|
||
fi
|
||
}
|
||
echo_skipped() {
|
||
if $terminal ; then
|
||
echo -e "\033[75G[ \033[37mskipped\033[m ]"
|
||
fi
|
||
}
|
||
|
||
fatal (){
|
||
echo ""
|
||
echo ""
|
||
if $terminal ; then
|
||
echo -e " [ \033[31m\033[1mFatal\033[m ]: \033[37m\033[1m$*\033[m"
|
||
echo ""
|
||
echo -e " \033[31m\033[1mScript will be interrupted..\033[m!"
|
||
else
|
||
echo " [ Fatal ]: $*"
|
||
echo ""
|
||
echo " Script was terminated...."
|
||
fi
|
||
clean_up 1
|
||
}
|
||
|
||
error(){
|
||
echo ""
|
||
if $terminal ; then
|
||
echo -e " [ \033[31m\033[1mError\033[m ]: $*"
|
||
else
|
||
echo " [ Error ]: $*"
|
||
fi
|
||
echo ""
|
||
}
|
||
|
||
warn (){
|
||
if $terminal ; then
|
||
echo ""
|
||
echo -e " [ \033[33m\033[1mWarning\033[m ]: $*"
|
||
echo ""
|
||
fi
|
||
}
|
||
|
||
info (){
|
||
if $terminal ; then
|
||
echo ""
|
||
echo -e " [ \033[32m\033[1mInfo\033[m ]: $*"
|
||
echo ""
|
||
fi
|
||
}
|
||
|
||
# - Remove leading/trailling whitespaces
|
||
# -
|
||
trim() {
|
||
local var="$*"
|
||
var="${var#"${var%%[![:space:]]*}"}" # remove leading whitespace characters
|
||
var="${var%"${var##*[![:space:]]}"}" # remove trailing whitespace characters
|
||
echo -n "$var"
|
||
}
|
||
|
||
## - Check if a given array (parameter 2) contains a given string (parameter 1)
|
||
## -
|
||
containsElement () {
|
||
local e
|
||
for e in "${@:2}"; do [[ "$e" == "$1" ]] && return 0; done
|
||
return 1
|
||
}
|
||
|
||
## - Percentage (1 decimal) of parameter 1 (part) against parameter 2 (total)
|
||
## - Returns "0.0" if total is empty, zero or non-numeric.
|
||
## -
|
||
calc_percent() {
|
||
local _part="$1"
|
||
local _total="$2"
|
||
if [[ -z "$_total" ]] || ! [[ "$_total" =~ ^[0-9]+$ ]] || [[ "$_total" -eq 0 ]] ; then
|
||
echo "0.0"
|
||
else
|
||
awk -v b="$_part" -v t="$_total" 'BEGIN { printf "%.1f", (b/t)*100 }'
|
||
fi
|
||
}
|
||
|
||
## - Best-effort structural validation of a recovered (signature-skip
|
||
## - decrypted) file. Dispatches primarily on the original file
|
||
## - extension; falls back to content-based detection (libmagic /
|
||
## - 'file') for unknown or missing extensions, or as a last resort
|
||
## - a text/binary heuristic + size comparison. Prints "CLASS|detail" -
|
||
## - CLASS is one of VALID / INVALID / UNVERIFIED.
|
||
## -
|
||
validate_recovered_file() {
|
||
local _f="$1" _origpath="$2" _origsize="$3"
|
||
local _base="$(basename -- "$_origpath")"
|
||
local _ext=""
|
||
if [[ "$_base" == *.* ]] ; then
|
||
_ext="${_origpath##*.}"
|
||
_ext="$(echo "$_ext" | tr '[:upper:]' '[:lower:]')"
|
||
fi
|
||
local _size
|
||
_size=$(stat -c%s "$_f" 2> /dev/null)
|
||
[[ -z "$_size" ]] && _size=0
|
||
|
||
case "$_ext" in
|
||
pdf)
|
||
if _pdf_check "$_f" ; then
|
||
echo "VALID|$_pdf_check_detail"
|
||
else
|
||
echo "INVALID|$_pdf_check_detail"
|
||
fi
|
||
;;
|
||
jpg|jpeg)
|
||
if _jpeg_check "$_f" ; then
|
||
echo "VALID|$_jpeg_check_detail"
|
||
else
|
||
echo "INVALID|$_jpeg_check_detail"
|
||
fi
|
||
;;
|
||
png)
|
||
if command -v identify > /dev/null 2>&1 ; then
|
||
# Full decode: catches truncated data, corrupt IDAT chunks, bad CRCs
|
||
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
|
||
echo "VALID|ImageMagick 'identify' decoded PNG ok"
|
||
else
|
||
echo "INVALID|ImageMagick 'identify' failed to decode PNG"
|
||
fi
|
||
else
|
||
local _head
|
||
_head="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
|
||
if [[ "$_head" = "89504e470d0a1a0a" ]] ; then
|
||
echo "VALID|PNG signature ok (identify not installed — no deep check)"
|
||
else
|
||
echo "INVALID|PNG signature missing"
|
||
fi
|
||
fi
|
||
;;
|
||
gif)
|
||
if command -v identify > /dev/null 2>&1 ; then
|
||
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
|
||
echo "VALID|ImageMagick 'identify' decoded GIF ok"
|
||
else
|
||
echo "INVALID|ImageMagick 'identify' failed to decode GIF"
|
||
fi
|
||
else
|
||
local _head6
|
||
_head6="$(head -c6 "$_f" 2> /dev/null)"
|
||
if [[ "$_head6" = "GIF87a" || "$_head6" = "GIF89a" ]] ; then
|
||
echo "VALID|GIF signature ok (identify not installed — no deep check)"
|
||
else
|
||
echo "INVALID|GIF signature missing"
|
||
fi
|
||
fi
|
||
;;
|
||
bmp)
|
||
if command -v identify > /dev/null 2>&1 ; then
|
||
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
|
||
echo "VALID|ImageMagick 'identify' decoded BMP ok"
|
||
else
|
||
echo "INVALID|ImageMagick 'identify' failed to decode BMP"
|
||
fi
|
||
else
|
||
if [[ "$(head -c2 "$_f" 2> /dev/null)" = "BM" ]] ; then
|
||
echo "VALID|BMP signature ok (identify not installed — no deep check)"
|
||
else
|
||
echo "INVALID|BMP signature missing"
|
||
fi
|
||
fi
|
||
;;
|
||
tif|tiff)
|
||
if command -v identify > /dev/null 2>&1 ; then
|
||
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
|
||
echo "VALID|ImageMagick 'identify' decoded TIFF ok"
|
||
else
|
||
echo "INVALID|ImageMagick 'identify' failed to decode TIFF"
|
||
fi
|
||
else
|
||
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 (identify not installed — no deep check)"
|
||
else
|
||
echo "INVALID|TIFF signature missing"
|
||
fi
|
||
fi
|
||
;;
|
||
pnm|pgm|ppm|pbm)
|
||
local _head2
|
||
_head2="$(head -c2 "$_f" 2> /dev/null)"
|
||
if [[ "$_head2" =~ ^P[1-6]$ ]] ; then
|
||
echo "VALID|PNM/PGM/PPM magic ok"
|
||
else
|
||
echo "INVALID|PNM/PGM/PPM magic (P1-P6) missing"
|
||
fi
|
||
;;
|
||
odt|ods|odp|odg|odf|ott|ots|otp|otg|docx|xlsx|pptx|docm|xlsm|pptm|dotm|xltm|potm|ppsm|ppsx|zip|epub)
|
||
# A password-protected MS Office file (Standard/Agile encryption,
|
||
# MS-OFFCRYPTO) is NOT a zip at all, regardless of its docx/xlsx/
|
||
# pptx extension: the whole package is wrapped in an OLE2/CFBF
|
||
# compound-file container (the same format legacy .doc/.xls/.ppt
|
||
# use), with the real zip payload encrypted inside an internal
|
||
# stream. 'unzip -t' correctly fails on that - not because
|
||
# anything is corrupt, but because it is legitimately not a zip
|
||
# file. Recognize this case before treating a failed zip check
|
||
# as corruption.
|
||
local _head8o
|
||
_head8o="$(head -c8 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
|
||
if [[ "$_head8o" = "d0cf11e0a1b11ae1" ]] ; then
|
||
echo "VALID|OLE2/CFBF container signature ok - this is a password-protected Office file (encrypted package), not a plain zip, so the zip check does not apply; open it with the password to verify content"
|
||
elif command -v unzip > /dev/null 2>&1 ; then
|
||
# Run unzip inside setsid so it has no controlling terminal.
|
||
# Without setsid, a password-protected ZIP causes unzip to try
|
||
# opening /dev/tty to prompt for the password. When running in
|
||
# the background of a tmux session this generates SIGTTIN, which
|
||
# STOPS the process (ps state T). A stopped process cannot receive
|
||
# SIGTERM, so timeout hangs indefinitely waiting for a child that
|
||
# will never exit. With setsid the /dev/tty open fails immediately
|
||
# with ENXIO and unzip exits with a non-zero code instead.
|
||
# -k 5: send SIGKILL 5 s after SIGTERM in case the process is
|
||
# still alive (e.g. stopped or in uninterruptible sleep).
|
||
local _unzip_exit
|
||
timeout -k 5 120 setsid unzip -tq "$_f" < /dev/null > /dev/null 2>&1
|
||
_unzip_exit=$?
|
||
if [[ $_unzip_exit -eq 0 ]] ; then
|
||
echo "VALID|zip integrity ok"
|
||
elif [[ $_unzip_exit -eq 124 || $_unzip_exit -eq 137 ]] ; then
|
||
# 124 = killed by SIGTERM after timeout, 137 = killed by SIGKILL (128+9)
|
||
echo "UNVERIFIED|zip integrity check timed out after 120 s (file may be very large or corrupt; check manually with: unzip -t \"$_f\")"
|
||
else
|
||
local _badentry
|
||
_badentry="$(trim "$(timeout -k 5 120 setsid unzip -t "$_f" < /dev/null 2>&1 | grep -v '^Archive:' | grep -v '^[[:space:]]*$' | head -1)")"
|
||
if echo "$_badentry" | grep -qi "password\|encrypt\|need PK compat" ; then
|
||
echo "UNVERIFIED|zip is password-protected (cannot verify without password${_badentry:+; unzip says: ${_badentry}})"
|
||
else
|
||
echo "INVALID|zip integrity check failed${_badentry:+ (${_badentry})}"
|
||
fi
|
||
fi
|
||
else
|
||
echo "UNVERIFIED|unzip not installed"
|
||
fi
|
||
;;
|
||
mp4|mov|m4v)
|
||
if command -v ffprobe > /dev/null 2>&1 ; then
|
||
# Full container parse: detects truncated/corrupt streams
|
||
local _ffprobe_out _ffprobe_exit
|
||
_ffprobe_out="$(ffprobe -v error -show_streams "$_f" 2>&1)"
|
||
_ffprobe_exit=$?
|
||
if [[ $_ffprobe_exit -eq 0 ]] ; then
|
||
local _streams
|
||
_streams="$(echo "$_ffprobe_out" | grep -c '\[STREAM\]' || true)"
|
||
echo "VALID|ffprobe parsed container ok (${_streams} stream(s) found)"
|
||
else
|
||
local _ffprobe_err
|
||
_ffprobe_err="$(echo "$_ffprobe_out" | head -1)"
|
||
echo "INVALID|ffprobe failed to parse container${_ffprobe_err:+ (${_ffprobe_err})}"
|
||
fi
|
||
else
|
||
if head -c 64 "$_f" 2> /dev/null | grep -aq "ftyp" ; then
|
||
echo "VALID|mp4 ftyp box found (ffprobe not installed — no deep check)"
|
||
else
|
||
echo "INVALID|mp4 ftyp box not found"
|
||
fi
|
||
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
|
||
;;
|
||
mp3)
|
||
if command -v mp3val > /dev/null 2>&1 ; then
|
||
# mp3val always exits 0 but prints "No errors found" on success
|
||
local _mp3val_out
|
||
_mp3val_out="$(mp3val "$_f" 2>&1)"
|
||
if echo "$_mp3val_out" | grep -q 'No errors found' ; then
|
||
echo "VALID|mp3val: no errors found"
|
||
else
|
||
local _mp3_err
|
||
_mp3_err="$(echo "$_mp3val_out" | grep -iv '^mp3val\|^$' | head -2 | tr '\n' ' ' | sed 's/ $//')"
|
||
echo "INVALID|mp3val reported errors${_mp3_err:+ (${_mp3_err})}"
|
||
fi
|
||
else
|
||
local _head3 _head2hex
|
||
_head3="$(head -c3 "$_f" 2>/dev/null)"
|
||
_head2hex="$(head -c2 "$_f" 2>/dev/null | od -An -tx1 | tr -d ' \n')"
|
||
if [[ "$_head3" = "ID3" ]] || echo "$_head2hex" | grep -qE '^fff[bef2]' ; then
|
||
echo "VALID|MP3 ID3 tag / MPEG sync ok (mp3val not installed — no deep check)"
|
||
else
|
||
echo "INVALID|MP3: no ID3 header or MPEG frame sync found"
|
||
fi
|
||
fi
|
||
;;
|
||
flac)
|
||
if command -v flac > /dev/null 2>&1 ; then
|
||
# --test decodes without writing output; exit 0 = file is intact
|
||
local _flac_out _flac_exit
|
||
_flac_out="$(flac --silent --test "$_f" 2>&1)"
|
||
_flac_exit=$?
|
||
if [[ $_flac_exit -eq 0 ]] ; then
|
||
echo "VALID|flac --test: ok"
|
||
else
|
||
local _flac_err
|
||
_flac_err="$(echo "$_flac_out" | grep -v '^$' | tail -1)"
|
||
echo "INVALID|flac --test failed${_flac_err:+ (${_flac_err})}"
|
||
fi
|
||
else
|
||
local _head4hex
|
||
_head4hex="$(head -c4 "$_f" 2>/dev/null | od -An -tx1 | tr -d ' \n')"
|
||
if [[ "$_head4hex" = "664c6143" ]] ; then # "fLaC"
|
||
echo "VALID|FLAC 'fLaC' signature ok (flac not installed — no deep check)"
|
||
else
|
||
echo "INVALID|FLAC 'fLaC' signature missing"
|
||
fi
|
||
fi
|
||
;;
|
||
ogg|oga|ogv|opus)
|
||
if command -v ogginfo > /dev/null 2>&1 ; then
|
||
# ogginfo exits 0 if the Ogg container is intact
|
||
local _ogg_out _ogg_exit
|
||
_ogg_out="$(ogginfo "$_f" 2>&1)"
|
||
_ogg_exit=$?
|
||
if [[ $_ogg_exit -eq 0 ]] ; then
|
||
echo "VALID|ogginfo: container parsed ok"
|
||
else
|
||
local _ogg_err
|
||
_ogg_err="$(echo "$_ogg_out" | grep -iv '^Processing\|^$' | head -2 | tr '\n' ' ' | sed 's/ $//')"
|
||
echo "INVALID|ogginfo failed${_ogg_err:+ (${_ogg_err})}"
|
||
fi
|
||
else
|
||
local _head4ogg
|
||
_head4ogg="$(head -c4 "$_f" 2>/dev/null)"
|
||
if [[ "$_head4ogg" = "OggS" ]] ; then
|
||
echo "VALID|OGG 'OggS' signature ok (ogginfo not installed — no deep check)"
|
||
else
|
||
echo "INVALID|OGG 'OggS' signature missing"
|
||
fi
|
||
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.
|
||
## -
|
||
## - qpdf exit codes:
|
||
## - 0 no problems
|
||
## - 2 structural errors → file is genuinely corrupt/unreadable
|
||
## - 3 warnings only → minor spec non-conformances; every real
|
||
## - viewer opens the file without issues
|
||
## - Only exit code 2 is treated as INVALID here. Exit code 3 (warnings)
|
||
## - is reported as VALID with a note, preventing false positives for
|
||
## - real-world PDFs that have trivial non-conformances.
|
||
## -
|
||
_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
|
||
local _qpdf_out _qpdf_exit
|
||
_qpdf_out="$(qpdf --check "$_f" 2>&1)"
|
||
_qpdf_exit=$?
|
||
if [[ $_qpdf_exit -eq 0 ]] ; then
|
||
_pdf_check_detail="qpdf --check ok"
|
||
return 0
|
||
elif [[ $_qpdf_exit -eq 3 ]] ; then
|
||
# Warnings only — no structural errors. File is readable by all
|
||
# standard PDF viewers; non-conformances are minor/cosmetic.
|
||
_pdf_check_detail="qpdf --check ok (warnings only — file is readable)"
|
||
return 0
|
||
else
|
||
# Exit code 2 (or unexpected): genuine structural errors.
|
||
local _first_err
|
||
_first_err="$(echo "$_qpdf_out" | grep -i 'error' | head -1 | sed 's/^[[:space:]]*//')"
|
||
_pdf_check_detail="qpdf --check failed (exit ${_qpdf_exit}${_first_err:+: ${_first_err}})"
|
||
return 1
|
||
fi
|
||
fi
|
||
_pdf_check_detail="PDF header ok (qpdf not installed, no deep check)"
|
||
return 0
|
||
}
|
||
|
||
## - JPEG structural check. A raw "does the file end in FFD9" byte check
|
||
## - produces false positives: many real-world JPEGs (EXIF/thumbnail
|
||
## - trailers, app-specific padding, camera-written footers) legally carry
|
||
## - a few extra bytes AFTER the actual End-Of-Image marker, so the file's
|
||
## - very last 2 bytes are often NOT ffd9 even though the image is 100%
|
||
## - intact and every viewer opens it fine. We therefore prefer an actual
|
||
## - JPEG decoder (which only cares whether the compressed image data is
|
||
## - intact, not what trails after it) and only fall back to the old
|
||
## - marker heuristic if no decoder tool is available.
|
||
## - Sets $_jpeg_check_detail, returns 0/1.
|
||
_jpeg_check() {
|
||
local _f="$1"
|
||
if command -v identify > /dev/null 2>&1 ; then
|
||
if identify -regard-warnings "$_f" > /dev/null 2>&1 ; then
|
||
_jpeg_check_detail="ImageMagick 'identify' decoded the image ok"
|
||
return 0
|
||
else
|
||
_jpeg_check_detail="ImageMagick 'identify' failed to decode the image"
|
||
return 1
|
||
fi
|
||
fi
|
||
if command -v php > /dev/null 2>&1 ; then
|
||
local _phpout
|
||
_phpout="$(php -r '
|
||
error_reporting(0);
|
||
if (!function_exists("imagecreatefromjpeg")) { echo "NOGD"; exit(0); }
|
||
$im = @imagecreatefromjpeg($argv[1]);
|
||
if ($im === false) { echo "BAD"; exit(0); }
|
||
imagedestroy($im);
|
||
echo "OK";
|
||
' "$_f" 2> /dev/null)"
|
||
case "$_phpout" in
|
||
OK)
|
||
_jpeg_check_detail="PHP GD (imagecreatefromjpeg) decoded the image ok"
|
||
return 0
|
||
;;
|
||
BAD)
|
||
_jpeg_check_detail="PHP GD (imagecreatefromjpeg) failed to decode the image"
|
||
return 1
|
||
;;
|
||
esac
|
||
# NOGD or empty output: GD extension not available, fall through
|
||
fi
|
||
# last resort: the old, less reliable marker heuristic
|
||
local _head _tail
|
||
_head="$(head -c3 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
|
||
_tail="$(tail -c2 "$_f" 2> /dev/null | od -An -tx1 | tr -d ' \n')"
|
||
if [[ "$_head" = "ffd8ff" && "$_tail" = "ffd9" ]] ; then
|
||
_jpeg_check_detail="JPEG SOI/EOI markers ok (no decoder available for a deep check)"
|
||
return 0
|
||
elif [[ "$_head" = "ffd8ff" ]] ; then
|
||
_jpeg_check_detail="JPEG SOI marker ok but no decoder available for a deep check; raw EOI-at-EOF heuristic failed (head=$_head tail=$_tail) - this is NOT reliable, many valid JPEGs carry trailer bytes after EOI, verify manually or install ImageMagick/php-gd"
|
||
return 1
|
||
else
|
||
_jpeg_check_detail="JPEG SOI marker missing (head=$_head)"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
## - Fallback for extensions without a dedicated structural check (or
|
||
## - no extension at all): use 'file'/libmagic to see what the content
|
||
## - actually looks like. If it is confidently one of our known strong
|
||
## - types (this can happen for misnamed files, e.g. a '.pdf' saved
|
||
## - without the dot), re-run the matching dedicated check instead of
|
||
## - just guessing. Otherwise report the detected type plus a
|
||
## - text/binary plausibility check - never auto-classifies as INVALID
|
||
## - purely from an unrecognised type, since libmagic not knowing a
|
||
## - rare/proprietary format is not proof of corruption.
|
||
## -
|
||
_validate_by_content_or_heuristic() {
|
||
local _f="$1" _ext="$2" _size="$3" _origsize="$4"
|
||
local _magic=""
|
||
|
||
if command -v file > /dev/null 2>&1 ; then
|
||
_magic="$(file --mime-type -b "$_f" 2> /dev/null)"
|
||
fi
|
||
|
||
case "$_magic" in
|
||
application/pdf)
|
||
if _pdf_check "$_f" ; then
|
||
echo "VALID|content-detected PDF ('.${_ext:-<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
|
||
|
||
# - Kein starker Treffer ueber libmagic: zumindest grob zwischen
|
||
# - "sieht nach Text aus" und "enthaelt binaere/Steuerzeichen"
|
||
# - unterscheiden - reine Zufalls-/Entschluesselungs-Reste sind so
|
||
# - gut wie nie durchgehend druckbarer Text.
|
||
# -
|
||
local _looks_like_text="unknown"
|
||
if LC_ALL=C grep -qP '[\x00-\x08\x0E-\x1F\x7F]' "$_f" 2> /dev/null ; then
|
||
_looks_like_text="no (contains binary/control bytes)"
|
||
else
|
||
_looks_like_text="yes (no binary/control bytes found)"
|
||
fi
|
||
|
||
# Note: recovered (plaintext) is ALWAYS smaller than original
|
||
# (ciphertext on disk, incl. IV/signature/padding overhead per 8192-byte
|
||
# block) - roughly 74-86% of it for this instance. That's normal and
|
||
# expected, not a sign of corruption; we only flag it if the ratio is
|
||
# implausible (e.g. way too small, suggesting truncation).
|
||
local _size_note
|
||
if [[ -n "$_origsize" && "$_origsize" =~ ^[0-9]+$ && "$_origsize" -gt 0 && "$_size" -gt 0 ]] ; then
|
||
local _ratio_pct=$(( _size * 100 / _origsize ))
|
||
if [[ "$_ratio_pct" -ge 65 && "$_ratio_pct" -le 95 ]] ; then
|
||
_size_note="size ratio plausible (recovered ${_size} / original ${_origsize} = ${_ratio_pct}%, expected ~74-86% for this instance)"
|
||
else
|
||
_size_note="size ratio LOOKS OFF (recovered ${_size} / original ${_origsize} = ${_ratio_pct}%, expected ~74-86% - check manually)"
|
||
fi
|
||
else
|
||
_size_note="size comparison not possible (recovered ${_size} bytes, original ${_origsize:-?} bytes)"
|
||
fi
|
||
|
||
echo "UNVERIFIED|no dedicated check for '.${_ext:-<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 (chown/chmod of the recovery
|
||
# - directory, 'su' into the webserver user, 'occ config:system:set').
|
||
# - Fail fast with a clear message instead of a confusing permission
|
||
# - error somewhere in the middle of the run.
|
||
# -
|
||
if [[ "$(id -u)" -ne 0 ]] ; then
|
||
fatal "This script must be run as root (it needs to chown/chmod the recovery directory, 'su' into the webserver user and run 'occ config:system:set'). Please re-run as root, e.g. via sudo."
|
||
fi
|
||
|
||
# ----------
|
||
# - Jobhandling
|
||
# ----------
|
||
|
||
if pgrep -f "$(basename $0)" | grep -q -v $$ ; then
|
||
|
||
msg="A previos instance of script \"`basename $0`\" seems already be running."
|
||
|
||
echo ""
|
||
if $terminal ; then
|
||
echo -e "[ \033[31m\033[1mFatal\033[m ]: $msg"
|
||
echo ""
|
||
echo -e " \033[31m\033[1mScript was interupted\033[m!"
|
||
else
|
||
echo " [ Fatal ]: $msg"
|
||
echo ""
|
||
echo " Script was interupted!"
|
||
fi
|
||
echo
|
||
|
||
exit 1
|
||
else
|
||
if [[ -d "$LOCK_DIR" ]] ; then
|
||
rm -rf "$LOCK_DIR" 2> /dev/null
|
||
fi
|
||
fi
|
||
|
||
|
||
# - If job already runs, stop execution..
|
||
# -
|
||
if mkdir "$LOCK_DIR" 2> /dev/null ; then
|
||
|
||
# - Remove lockdir when the script finishes, or when it receives a signal
|
||
# -
|
||
trap clean_up SIGHUP SIGINT SIGTERM
|
||
|
||
else
|
||
|
||
msg="A previos instance of script \"`basename $0`\" seems already be running."
|
||
|
||
echo ""
|
||
if $terminal ; then
|
||
echo -e "[ \033[31m\033[1mFatal\033[m ]: $msg"
|
||
echo ""
|
||
echo -e " \033[31m\033[1mScript was interupted\033[m!"
|
||
else
|
||
echo " [ Fatal ]: $msg"
|
||
echo ""
|
||
echo " Script was interupted!"
|
||
fi
|
||
echo
|
||
|
||
exit 1
|
||
|
||
fi
|
||
|
||
|
||
# -------------
|
||
# - Read in Commandline arguments
|
||
# -------------
|
||
REVALIDATE_ONLY=false
|
||
_revalidate_only_explicit=false
|
||
|
||
while getopts hVs: opt ; do
|
||
case $opt in
|
||
h) usage ;;
|
||
V) REVALIDATE_ONLY=true; _revalidate_only_explicit=true ;;
|
||
s) WEBSITE=$OPTARG ;;
|
||
\?) usage
|
||
esac
|
||
done
|
||
|
||
|
||
# =============
|
||
# --- Ask which mode to run in, unless '-V' was already given on the
|
||
# --- command line (that still works unchanged for scripted/non-
|
||
# --- interactive invocations).
|
||
# =============
|
||
if ! $_revalidate_only_explicit && $terminal ; then
|
||
|
||
blank_line
|
||
echo -e "\033[37m\033[1mWhich mode should this run use?\033[m"
|
||
echo ""
|
||
echo -e " \033[1m[1] Recovery\033[m - decrypt bad-signature files (temporarily skips the signature check), write them under ${DEFAULT_RECOVERY_BASE_DIR}/<website> and validate them"
|
||
echo " [2] Revalidate-only - re-run just the validation checks against files a previous recovery run already wrote to disk (same as '-V'); nothing is decrypted again and no config value is touched"
|
||
info "Just press Return to use the default: [1] Recovery."
|
||
echo -n " Select mode by number [1]: "
|
||
read _mode_choice
|
||
|
||
case "$(trim "$_mode_choice")" in
|
||
2) REVALIDATE_ONLY=true ;;
|
||
""|1) REVALIDATE_ONLY=false ;;
|
||
*) fatal "Invalid selection '$_mode_choice'." ;;
|
||
esac
|
||
|
||
fi
|
||
|
||
if [[ -z "$WEBSITE" ]] ; then
|
||
|
||
while IFS='' read -r -d '' _conf_file ; do
|
||
source $_conf_file
|
||
if [[ -n "$WEBSITE" ]] ; then
|
||
unsorted_website_arr+=("${WEBSITE}:$_conf_file")
|
||
fi
|
||
WEBSITE=""
|
||
done < <(find "${conf_dir}" -maxdepth 1 -type f -name "*.conf" -print0)
|
||
|
||
# - Sort array
|
||
# -
|
||
IFS=$'\n' website_arr=($(sort <<<"${unsorted_website_arr[*]}"))
|
||
|
||
# Which cloud instance (website) would you like to update
|
||
#
|
||
source ${snippet_dir}/get-cloud-instance-to-update.sh
|
||
|
||
else
|
||
|
||
while IFS='' read -r -d '' _conf_file ; do
|
||
if $(grep -E -q "WEBSITE=\"?${WEBSITE}\"?" ${_conf_file} 2> /dev/null) ; then
|
||
conf_file="${_conf_file}"
|
||
break
|
||
fi
|
||
done < <(find "${conf_dir}" -maxdepth 1 -type f -name "*.conf" -print0)
|
||
|
||
fi
|
||
|
||
|
||
# - Reset IFS
|
||
# -
|
||
IFS=$CUR_IFS
|
||
|
||
DEFAULT_SRC_BASE_DIR="/usr/local/src/nextcloud"
|
||
DEFAULT_HTTP_USER="www-data"
|
||
DEFAULT_HTTP_GROUP="www-data"
|
||
DEFAULT_PHP_ENGINE='FPM'
|
||
|
||
blank_line
|
||
echononl " Include Configuration file '$(basename "${conf_file}")'.."
|
||
if [[ ! -f $conf_file ]]; then
|
||
echo_skipped
|
||
fatal "Missing configuration file '$conf_file'."
|
||
else
|
||
source $conf_file
|
||
echo_ok
|
||
fi
|
||
|
||
DEFAULT_WEB_BASE_DIR="/var/www/${WEBSITE}"
|
||
[[ -n "$WEB_BASE_DIR" ]] || WEB_BASE_DIR=$DEFAULT_WEB_BASE_DIR
|
||
|
||
if [[ ! -d ${WEB_BASE_DIR} ]] ; then
|
||
fatal "Web base directory '$WEB_BASE_DIR' not found!"
|
||
fi
|
||
|
||
DATA_DIR="$(realpath ${WEB_BASE_DIR}/data)"
|
||
|
||
[[ -n "$PHP_ENGINE" ]] || PHP_ENGINE=$DEFAULT_PHP_ENGINE
|
||
|
||
INSTALL_DIR="$(realpath ${WEB_BASE_DIR}/nextcloud)"
|
||
CURRENT_VERSION="$(basename $INSTALL_DIR | cut -d"-" -f2)"
|
||
|
||
# - Recovery-Zielverzeichnis: bewusst ausserhalb von script_dir/WEB_BASE_DIR
|
||
# - (siehe Kommentar bei DEFAULT_RECOVERY_BASE_DIR weiter oben) - eine
|
||
# - per-Website Unterordner, den 'www-data' auch tatsaechlich erreichen
|
||
# - kann.
|
||
# -
|
||
[[ -n "$RECOVERY_BASE_DIR" ]] || RECOVERY_BASE_DIR=$DEFAULT_RECOVERY_BASE_DIR
|
||
recovery_dir="${RECOVERY_BASE_DIR}/${WEBSITE}"
|
||
|
||
|
||
# =============
|
||
# --- Some
|
||
# =============
|
||
|
||
# - Support systemd ?
|
||
# -
|
||
SYSTEMD_EXISTS=false
|
||
systemd=$(which systemd)
|
||
systemctl=$(which systemctl)
|
||
|
||
if [[ -n "$systemd" ]] || [[ -n "$systemctl" ]] ; then
|
||
SYSTEMD_EXISTS=true
|
||
fi
|
||
|
||
#clear
|
||
|
||
if $terminal ; then
|
||
echo ""
|
||
echo -e "\033[32m-----\033[m"
|
||
echo -e "Attempt to recover \033[1mBad Signature\033[m files on system \033[1m${WEB_BASE_DIR}\033[m"
|
||
echo -e "\033[1m
|
||
Original files are never touched. Recovered content is written to a
|
||
separate directory and must be reviewed manually.\033[m"
|
||
echo -e "\033[32m-----\033[m"
|
||
fi
|
||
|
||
|
||
# =============
|
||
# --- Some checks
|
||
# =============
|
||
|
||
DEFAULT_HTTP_USER="www-data"
|
||
DEFAULT_HTTP_GROUP="www-data"
|
||
|
||
|
||
NGINX_IS_ENABLED=false
|
||
APACHE2_IS_ENABLED=false
|
||
|
||
# Get Webservice environment as IS_HTTPD_RUNNING, HTTP_USER, HTTP_GROUP..
|
||
#
|
||
source ${snippet_dir}/get-webservice-environment.sh
|
||
|
||
|
||
# Check PHP Version
|
||
#
|
||
source ${snippet_dir}/get-php-major-version.sh
|
||
|
||
|
||
# Get full qualified PHP command
|
||
#
|
||
source ${snippet_dir}/get-path-of-php-command.sh
|
||
|
||
|
||
if [[ ! -x "$PHP_BIN" ]]; then
|
||
fatal "No PHP binary found!"
|
||
fi
|
||
|
||
|
||
# =============
|
||
# --- Check Server-Side Encryption status
|
||
# =============
|
||
|
||
blank_line
|
||
echononl " Check Server-Side Encryption status.."
|
||
|
||
_encryption_status_out="$(su -c "$PHP_BIN $INSTALL_DIR/occ encryption:status" -s /bin/bash $HTTP_USER 2> "$log_file")"
|
||
|
||
ENCRYPTION_ENABLED="unknown"
|
||
|
||
if [[ -s "$log_file" ]] ; then
|
||
echo_failed
|
||
error "$(cat "$log_file")"
|
||
elif echo "$_encryption_status_out" | grep -qiE 'enabled:[[:space:]]*true' ; then
|
||
echo_ok
|
||
ENCRYPTION_ENABLED="yes"
|
||
else
|
||
echo_warning
|
||
ENCRYPTION_ENABLED="no"
|
||
fi
|
||
|
||
if [[ "$ENCRYPTION_ENABLED" != "yes" && "$REVALIDATE_ONLY" = false ]] ; then
|
||
fatal "Server-Side Encryption is not enabled on ${WEBSITE} - a signature-skip recovery attempt makes no sense here."
|
||
fi
|
||
|
||
|
||
# =============
|
||
# --- Determine available bad-signature reports
|
||
# =============
|
||
|
||
mapfile -t _available_reports < <(ls -t "${report_dir}"/bad_signature_*.tsv 2> /dev/null)
|
||
|
||
if [[ ${#_available_reports[@]} -eq 0 ]] ; then
|
||
fatal "No scan report files (bad_signature_*.tsv) found in '${report_dir}'. Run scan_bad_signature.sh first."
|
||
fi
|
||
|
||
blank_line
|
||
if $terminal ; then
|
||
echo -e "\033[37m\033[1mAvailable bad-signature reports (newest first)\033[m"
|
||
echo ""
|
||
_i=1
|
||
for _f in "${_available_reports[@]}" ; do
|
||
printf " [%2d] %s\n" "$_i" "$(basename "$_f")"
|
||
(( _i++ ))
|
||
done
|
||
echo ""
|
||
fi
|
||
|
||
echo -n " Select report by number: "
|
||
read _report_choice
|
||
|
||
if ! [[ "$_report_choice" =~ ^[0-9]+$ ]] || [[ "$_report_choice" -lt 1 ]] || [[ "$_report_choice" -gt ${#_available_reports[@]} ]] ; then
|
||
fatal "Invalid selection '$_report_choice'."
|
||
fi
|
||
|
||
chosen_report="${_available_reports[$((_report_choice-1))]}"
|
||
|
||
|
||
# =============
|
||
# --- Determine accounts contained in the chosen report
|
||
# =============
|
||
|
||
unsorted_account_arr=($(awk -F'\t' 'NF==5 && $3 ~ /^[0-9]+$/ {print $1}' "$chosen_report" | sort -u))
|
||
IFS=$'\n' account_arr=($(sort <<<"${unsorted_account_arr[*]}"))
|
||
IFS=$CUR_IFS
|
||
|
||
if [[ ${#account_arr[@]} -eq 0 ]] ; then
|
||
fatal "No bad-signature entries found in '$(basename "$chosen_report")'."
|
||
fi
|
||
|
||
blank_line
|
||
if $terminal ; then
|
||
echo -e "\033[37m\033[1mAccounts with 'Bad Signature' entries in this report\033[m"
|
||
echo ""
|
||
for _a in "${account_arr[@]}" ; do
|
||
echo " - $_a"
|
||
done
|
||
echo ""
|
||
fi
|
||
|
||
echo -n " Account(s) to attempt recovery for (blank separated, or 'all'): "
|
||
read _input
|
||
|
||
while true ; do
|
||
|
||
IFS=' ' read -r -a unsorted_selected_user_arr <<< "$(trim "$_input")"
|
||
|
||
if [[ "${#unsorted_selected_user_arr[@]}" -eq 1 && "${unsorted_selected_user_arr[0],,}" = "all" ]] ; then
|
||
selected_user_arr=("${account_arr[@]}")
|
||
break
|
||
fi
|
||
|
||
_all_valid=true
|
||
for _u in "${unsorted_selected_user_arr[@]}" ; do
|
||
if ! containsElement "$_u" "${account_arr[@]}" ; then
|
||
error "Unknown account '$_u' (not found in the selected report)."
|
||
_all_valid=false
|
||
fi
|
||
done
|
||
|
||
if $_all_valid && [[ "${#unsorted_selected_user_arr[@]}" -gt 0 ]] ; then
|
||
IFS=$'\n' selected_user_arr=($(sort <<<"${unsorted_selected_user_arr[*]}"))
|
||
IFS=$CUR_IFS
|
||
break
|
||
fi
|
||
|
||
echo -n " Account(s) to attempt recovery for (blank separated, or 'all'): "
|
||
read _input
|
||
|
||
done
|
||
|
||
|
||
# =============
|
||
# --- Confirmation
|
||
# =============
|
||
|
||
mkdir -p "$report_dir" 2> /dev/null
|
||
recovery_report_file="${report_dir}/recovery_${WEBSITE}_${run_date}.tsv"
|
||
|
||
check_optional_validation_tools
|
||
|
||
if $terminal ; then
|
||
echo ""
|
||
if $REVALIDATE_ONLY ; then
|
||
echo -e "\033[1;32mStarting revalidate-only run for \033[1;37m${WEBSITE}\033[m"
|
||
else
|
||
echo -e "\033[1;32mStarting recovery attempt for \033[1;37m${WEBSITE}\033[m"
|
||
fi
|
||
echo ""
|
||
echo -e " Cloud instance to be scanned.........: $WEBSITE"
|
||
echo ""
|
||
echo -e " Report used...........................: $(basename "$chosen_report")"
|
||
echo -e " Account(s) to recover.................: \033[33m${selected_user_arr[*]}\033[m"
|
||
echo ""
|
||
if $REVALIDATE_ONLY ; then
|
||
echo -e " Existing recovered files under........: $recovery_dir"
|
||
echo -e " Revalidate-only report..................: reports/$(basename "${recovery_report_file}")"
|
||
else
|
||
echo -e " Recovered files will be written to....: $recovery_dir"
|
||
echo -e " Recovery report........................: reports/$(basename "${recovery_report_file}")"
|
||
fi
|
||
echo ""
|
||
|
||
if $REVALIDATE_ONLY ; then
|
||
info "Revalidate-only mode: nothing is decrypted again, no config value is changed. Only the current (possibly newer) validation checks are re-run against files already recovered by a previous, normal run."
|
||
else
|
||
warn "This will TEMPORARILY set 'encryption_skip_signature_check' to true instance-wide, so already-broken files can be decrypted despite failing the signature check. It is restored to its previous value automatically when this script ends (also on Ctrl-C or error). Files with genuinely corrupted ciphertext will still come out as garbage - every recovered file is validated afterwards, but please still spot-check important results yourself before relying on them."
|
||
fi
|
||
|
||
echo ""
|
||
echo -n " Type upper case 'YES' to continue executing with this parameters: "
|
||
read OK
|
||
if [[ "$OK" = "YES" ]] ; then
|
||
echo ""
|
||
echo ""
|
||
echo -e "\033[1;32mGoing to attempt recovery for each selected account on \033[1;37m$WEBSITE \033[m"
|
||
else
|
||
fatal "Abort by user request - Answer as not 'YES'"
|
||
fi
|
||
fi
|
||
|
||
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):"
|
||
echo -e " \033[1mtail -f ${log_file}\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-Recovery-Versuch ${WEBSITE} ${run_date}"
|
||
echo "=================================================================="
|
||
echo ""
|
||
echo " Basis-Report: $(basename "$chosen_report")"
|
||
echo " Wiederhergestellte Dateien liegen unter: ${recovery_dir}"
|
||
echo ""
|
||
echo -e "path\trecovered_bytes\toriginal_size\tstatus\tvalidation\tdetail"
|
||
} > "$recovery_report_file"
|
||
|
||
|
||
if ! $REVALIDATE_ONLY ; then
|
||
|
||
# =============
|
||
# --- Temporarily enable encryption_skip_signature_check
|
||
# =============
|
||
|
||
blank_line
|
||
echononl " Reading current encryption_skip_signature_check value.."
|
||
_orig_skip_check="$(su -c "$PHP_BIN $INSTALL_DIR/occ config:system:get encryption_skip_signature_check" -s /bin/bash $HTTP_USER 2> /dev/null)"
|
||
_orig_skip_check="$(trim "$_orig_skip_check")"
|
||
echo_done
|
||
|
||
echononl " Enabling encryption_skip_signature_check (temporary)..."
|
||
su -c "$PHP_BIN $INSTALL_DIR/occ config:system:set encryption_skip_signature_check --value=true --type=boolean" -s /bin/bash $HTTP_USER > /dev/null 2> "$log_file"
|
||
if [[ $? -eq 0 ]] ; then
|
||
echo_ok
|
||
_encryption_flag_changed=true
|
||
# Trap signals AND normal/abnormal exit so that the encryption flag
|
||
# and temp files are always cleaned up — even on a syntax error or
|
||
# an unexpected crash (EXIT fires for any bash exit, including errors).
|
||
trap 'clean_up 1' SIGHUP SIGINT SIGTERM EXIT
|
||
else
|
||
echo_failed
|
||
fatal "Could not enable encryption_skip_signature_check: $(cat "$log_file")"
|
||
fi
|
||
|
||
fi
|
||
|
||
|
||
if ! $REVALIDATE_ONLY ; then
|
||
|
||
# =============
|
||
# --- Write the embedded PHP recovery script
|
||
# =============
|
||
|
||
recovery_php_file="${INSTALL_DIR}/.recover_bad_signature_$$.php"
|
||
|
||
cat > "$recovery_php_file" <<'PHP_RECOVER_EOF'
|
||
<?php
|
||
/**
|
||
* recover_bad_signature.php
|
||
*
|
||
* Attempts to decrypt a known list of 'Bad Signature' files while
|
||
* 'encryption_skip_signature_check' is enabled (set by the calling
|
||
* wrapper script), and writes the resulting bytes to a separate
|
||
* output directory. Never touches the original file.
|
||
*
|
||
* Usage: php recover_bad_signature.php <uid> <listFile> <outDir>
|
||
* listFile: TSV with "path\tsize\tmtime" per line (no header)
|
||
*/
|
||
|
||
error_reporting(E_ALL);
|
||
ini_set('display_errors', '1');
|
||
|
||
fwrite(STDERR, "DEBUG: Recovery-Skript gestartet, PID=" . getmypid() . "\n");
|
||
|
||
register_shutdown_function(function () {
|
||
$err = error_get_last();
|
||
if ($err !== null && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
|
||
fwrite(STDERR, "DEBUG: FATAL bei Shutdown: [{$err['type']}] {$err['message']} in {$err['file']}:{$err['line']}\n");
|
||
}
|
||
});
|
||
|
||
define('OC_CONSOLE', 1);
|
||
|
||
$oldWorkingDir = getcwd();
|
||
if ($oldWorkingDir === false) {
|
||
fwrite(STDERR, "Konnte aktuelles Arbeitsverzeichnis nicht ermitteln. Bitte mit absolutem Pfad aufrufen.\n");
|
||
exit(1);
|
||
}
|
||
chdir(__DIR__);
|
||
require_once __DIR__ . '/lib/base.php';
|
||
// Nextcloud's own bootstrap (lib/base.php) unconditionally calls
|
||
// set_time_limit(3600) as part of the require above - this OVERRIDES
|
||
// whatever -d max_execution_time was passed on the PHP command line,
|
||
// since it is a later, explicit runtime call. Undo that here, now that
|
||
// the require is done, so recovering a large account isn't killed
|
||
// after exactly one hour regardless of the CLI flag.
|
||
set_time_limit(0);
|
||
chdir($oldWorkingDir);
|
||
|
||
if (function_exists('posix_getuid') && posix_getuid() === 0) {
|
||
fwrite(STDERR, "Bitte NICHT als root ausfuehren, sondern als Webserver-User.\n");
|
||
exit(1);
|
||
}
|
||
|
||
try {
|
||
$uid = $argv[1] ?? null;
|
||
$listFile = $argv[2] ?? null;
|
||
$outDir = $argv[3] ?? null;
|
||
|
||
if ($uid === null || $listFile === null || $outDir === null) {
|
||
fwrite(STDERR, "Usage: php recover_bad_signature.php <uid> <listFile> <outDir>\n");
|
||
exit(1);
|
||
}
|
||
if (!is_file($listFile)) {
|
||
fwrite(STDERR, "Liste nicht gefunden: $listFile\n");
|
||
exit(1);
|
||
}
|
||
if (!is_dir($outDir)) {
|
||
@mkdir($outDir, 0750, true);
|
||
}
|
||
|
||
$rootFolder = \OC::$server->get(\OCP\Files\IRootFolder::class);
|
||
\OC_Util::setupFS($uid);
|
||
|
||
$lines = file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||
if ($lines === false) {
|
||
$lines = [];
|
||
}
|
||
|
||
$total = 0;
|
||
$ok = 0;
|
||
$failed = 0;
|
||
|
||
echo "path\trecovered_bytes\toriginal_size\tstatus\n";
|
||
|
||
foreach ($lines as $line) {
|
||
$cols = explode("\t", $line);
|
||
$path = $cols[0] ?? '';
|
||
$origSize = $cols[1] ?? '';
|
||
if ($path === '') {
|
||
continue;
|
||
}
|
||
$total++;
|
||
|
||
try {
|
||
$node = $rootFolder->get($path);
|
||
} catch (\Throwable $e) {
|
||
$failed++;
|
||
$msg = str_replace(["\t", "\n", "\r"], ' ', $e->getMessage());
|
||
echo "$path\t0\t$origSize\tNOT_FOUND: $msg\n";
|
||
flush();
|
||
continue;
|
||
}
|
||
|
||
if (!($node instanceof \OCP\Files\File)) {
|
||
$failed++;
|
||
echo "$path\t0\t$origSize\tNOT_A_FILE\n";
|
||
flush();
|
||
continue;
|
||
}
|
||
|
||
// Zielpfad unterhalb outDir, gleiche relative Struktur wie
|
||
// innerhalb der Nextcloud-Dateien des Users (ohne /<uid>/files/)
|
||
$relative = preg_replace('#^/' . preg_quote($uid, '#') . '/files/#', '', $path);
|
||
if ($relative === null || $relative === '') {
|
||
$relative = ltrim($path, '/');
|
||
}
|
||
$targetPath = rtrim($outDir, '/') . '/' . $relative;
|
||
$targetDir = dirname($targetPath);
|
||
if (!is_dir($targetDir)) {
|
||
@mkdir($targetDir, 0750, true);
|
||
}
|
||
|
||
try {
|
||
$in = $node->fopen('r');
|
||
if ($in === false) {
|
||
throw new \RuntimeException('fopen lieferte false');
|
||
}
|
||
$out = fopen($targetPath, 'w');
|
||
if ($out === false) {
|
||
throw new \RuntimeException("Zieldatei konnte nicht geoeffnet werden: $targetPath");
|
||
}
|
||
$bytes = stream_copy_to_stream($in, $out);
|
||
fclose($in);
|
||
fclose($out);
|
||
|
||
if ($bytes === false) {
|
||
throw new \RuntimeException('stream_copy_to_stream schlug fehl');
|
||
}
|
||
|
||
$ok++;
|
||
echo "$path\t$bytes\t$origSize\tOK\n";
|
||
flush();
|
||
} catch (\Throwable $e) {
|
||
$failed++;
|
||
$msg = str_replace(["\t", "\n", "\r"], ' ', $e->getMessage());
|
||
echo "$path\t0\t$origSize\tERROR: " . get_class($e) . ": $msg\n";
|
||
flush();
|
||
@unlink($targetPath);
|
||
}
|
||
}
|
||
|
||
fwrite(STDERR, "\nFertig. Dateien verarbeitet: $total, erfolgreich gelesen: $ok, Fehler: $failed\n");
|
||
} catch (\Throwable $e) {
|
||
fwrite(STDERR, "\n!!! UNBEHANDELTE AUSNAHME !!!\n");
|
||
fwrite(STDERR, get_class($e) . ": " . $e->getMessage() . "\n");
|
||
fwrite(STDERR, "in " . $e->getFile() . ":" . $e->getLine() . "\n");
|
||
fwrite(STDERR, $e->getTraceAsString() . "\n");
|
||
exit(2);
|
||
}
|
||
PHP_RECOVER_EOF
|
||
|
||
chmod 644 "$recovery_php_file"
|
||
|
||
fi
|
||
|
||
|
||
# -----
|
||
# - Main part of the script
|
||
# -----
|
||
|
||
if $terminal ; then
|
||
echo ""
|
||
echo ""
|
||
echo -e "\033[37m\033[1mMain part of the script\033[m"
|
||
echo ""
|
||
fi
|
||
|
||
if $REVALIDATE_ONLY ; then
|
||
if [[ ! -d "$recovery_dir" ]] ; then
|
||
fatal "Revalidate-only mode: recovery directory '$recovery_dir' does not exist - run this script without -V first."
|
||
fi
|
||
else
|
||
|
||
mkdir -p "$recovery_dir" 2> /dev/null
|
||
|
||
# - 'mkdir -p' creates missing parent directories (e.g. RECOVERY_BASE_DIR
|
||
# - itself) subject to the current umask - if that happens to be
|
||
# - restrictive, the PARENT of recovery_dir can end up blocking
|
||
# - traversal for $HTTP_USER even though recovery_dir itself is fine.
|
||
# - Explicitly (re-)open both levels for traversal (RECOVERY_BASE_DIR is
|
||
# - only ever traversed, never written into directly).
|
||
# -
|
||
chmod 755 "$RECOVERY_BASE_DIR" 2> /dev/null
|
||
chmod 755 "$recovery_dir" 2> /dev/null
|
||
|
||
# - recovery_dir itself still belongs to root at this point though -
|
||
# - mode 755 only grants $HTTP_USER (as 'other', not the owner) read +
|
||
# - traverse, NOT write. Files are later written per-account into
|
||
# - freshly created (and per-account chown'ed) subdirectories, but
|
||
# - recovery_dir itself also needs to be owned by $HTTP_USER so the
|
||
# - write test below (and 'mkdir' calls done by the PHP process
|
||
# - running as $HTTP_USER) can actually create things directly in it.
|
||
# -
|
||
chown "$HTTP_USER":"$HTTP_GROUP" "$recovery_dir" 2> /dev/null
|
||
|
||
# - Frueher, klarer Fehler statt 780x der gleiche stille Flop: pruefen,
|
||
# - ob der Webserver-User ($HTTP_USER) hier tatsaechlich schreiben kann,
|
||
# - BEVOR der eigentliche Recovery-Lauf beginnt. Faengt u.a. den Fall
|
||
# - ab, dass RECOVERY_BASE_DIR auf ein fuer $HTTP_USER unerreichbares
|
||
# - Verzeichnis (z.B. unterhalb von /root) gesetzt wurde.
|
||
# -
|
||
_write_test_file="${recovery_dir}/.write_test_$$"
|
||
su -c "touch '$_write_test_file'" -s /bin/bash $HTTP_USER 2> "$log_file"
|
||
if [[ ! -f "$_write_test_file" ]] ; then
|
||
fatal "Cannot write to recovery directory '$recovery_dir' as user '$HTTP_USER' - check the permissions of every path component (in particular that none of them, e.g. a parent directory, is only accessible to root). $(cat "$log_file")"
|
||
fi
|
||
rm -f "$_write_test_file" 2> /dev/null
|
||
|
||
fi
|
||
|
||
declare -i total_processed=0
|
||
declare -i total_valid=0
|
||
declare -i total_invalid=0
|
||
declare -i total_unverified=0
|
||
declare -i total_read_errors=0
|
||
declare -i total_failed_users=0
|
||
|
||
for _user in "${selected_user_arr[@]}" ; do
|
||
|
||
_sep_len=${#_user}
|
||
[[ $_sep_len -lt 9 ]] && _sep_len=9
|
||
_sep_line="$(printf '%*s' "$_sep_len" '' | tr ' ' '-')"
|
||
|
||
{
|
||
echo ""
|
||
echo ""
|
||
echo "$_sep_line"
|
||
echo "$_user"
|
||
echo "$_sep_line"
|
||
} >> "$recovery_report_file"
|
||
|
||
list_file="${LOCK_DIR}/list_${_user}.tsv"
|
||
awk -F'\t' -v u="$_user" 'NF==5 && $1==u && $3 ~ /^[0-9]+$/ {print $2"\t"$3"\t"$4}' "$chosen_report" > "$list_file"
|
||
|
||
_user_total=$(wc -l < "$list_file")
|
||
|
||
if [[ $_user_total -eq 0 ]] ; then
|
||
warn "No bad-signature entries for account '${_user}' found in the selected report - skipping."
|
||
echo " [ keine Eintraege im gewaehlten Report ]" >> "$recovery_report_file"
|
||
continue
|
||
fi
|
||
|
||
user_out_dir="${recovery_dir}/${_user}"
|
||
user_result_tsv="${LOCK_DIR}/result_${_user}.tsv"
|
||
|
||
if $REVALIDATE_ONLY ; then
|
||
|
||
echononl " Revalidating account \033[1;37m${_user}\033[m (${_user_total} files).."
|
||
{
|
||
echo -e "path\trecovered_bytes\toriginal_size\tstatus"
|
||
while IFS=$'\t' read -r _p _osize _omtime ; do
|
||
[[ -z "$_p" ]] && continue
|
||
_rel="${_p#/${_user}/files/}"
|
||
_local="${user_out_dir}/${_rel}"
|
||
if [[ -f "$_local" ]] ; then
|
||
_b=$(stat -c%s "$_local" 2> /dev/null)
|
||
printf '%s\t%s\t%s\t%s\n' "$_p" "${_b:-0}" "$_osize" "OK"
|
||
else
|
||
printf '%s\t%s\t%s\t%s\n' "$_p" "0" "$_osize" "NOT_RECOVERED: no file found under ${user_out_dir} (run without -V first)"
|
||
fi
|
||
done < "$list_file"
|
||
} > "$user_result_tsv"
|
||
_rc=0
|
||
|
||
else
|
||
|
||
mkdir -p "$user_out_dir" 2> /dev/null
|
||
chown -R "$HTTP_USER":"$HTTP_GROUP" "$recovery_dir" 2> /dev/null
|
||
|
||
echononl " Recovering account \033[1;37m${_user}\033[m (${_user_total} files).."
|
||
# -d max_execution_time=0: decrypting/validating every file of a
|
||
# large account can run well over an hour; without this the PHP
|
||
# CLI process is killed by PHP's own execution-time limit
|
||
# (typically inherited from the webserver's php.ini) mid-run.
|
||
su -c "$PHP_BIN -d max_execution_time=0 $recovery_php_file $_user $list_file $user_out_dir" -s /bin/bash $HTTP_USER > "$user_result_tsv" 2> "$log_file"
|
||
_rc=$?
|
||
|
||
fi
|
||
|
||
if [[ $_rc -ne 0 ]]; then
|
||
echo_failed
|
||
error "$(cat "$log_file")"
|
||
echo " [ FEHLER beim Recovery-Lauf - siehe Server-Log ]" >> "$recovery_report_file"
|
||
(( total_failed_users++ ))
|
||
continue
|
||
fi
|
||
|
||
echo_done
|
||
$terminal && echo ""
|
||
|
||
declare -i _user_valid=0
|
||
declare -i _user_invalid=0
|
||
declare -i _user_unverified=0
|
||
declare -i _user_read_errors=0
|
||
declare -a _user_invalid_lines=()
|
||
declare -a _user_unverified_lines=()
|
||
|
||
while IFS=$'\t' read -r _path _recbytes _origsize _status ; do
|
||
|
||
[[ "$_path" = "path" ]] && continue
|
||
[[ -z "$_path" ]] && continue
|
||
|
||
if [[ "$_status" == OK* ]] ; then
|
||
_rel="${_path#/${_user}/files/}"
|
||
_local="${user_out_dir}/${_rel}"
|
||
_val_result="$(validate_recovered_file "$_local" "$_path" "$_origsize")"
|
||
_val_class="${_val_result%%|*}"
|
||
_val_detail="${_val_result#*|}"
|
||
case "$_val_class" in
|
||
VALID) (( _user_valid++ )) ;;
|
||
INVALID) (( _user_invalid++ )); _user_invalid_lines+=("${_path}"$'\t'"${_val_detail}") ;;
|
||
*) (( _user_unverified++ )); _user_unverified_lines+=("${_path}"$'\t'"${_val_detail}") ;;
|
||
esac
|
||
else
|
||
_val_class="READ_ERROR"
|
||
_val_detail="$_status"
|
||
(( _user_read_errors++ ))
|
||
fi
|
||
|
||
# printf, not 'echo -e': $_val_detail (or $_status for a
|
||
# READ_ERROR row) can contain a raw PHP exception message (e.g.
|
||
# 'OCA\Encryption\Exceptions\...') - 'echo -e' would reinterpret
|
||
# those backslashes as escape sequences and silently mangle/eat
|
||
# parts of the text. printf's %s never reinterprets its argument,
|
||
# only the literal \t in the format string itself.
|
||
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$_path" "$_recbytes" "$_origsize" "$_status" "$_val_class" "$_val_detail" >> "$recovery_report_file"
|
||
|
||
done < "$user_result_tsv"
|
||
|
||
_user_pct_valid="$(calc_percent "$_user_valid" "$_user_total")"
|
||
|
||
if $terminal ; then
|
||
echo -e " \033[1;37mAccount ${_user}\033[m"
|
||
echo -e " Verarbeitete Dateien.....................: ${_user_total}"
|
||
if [[ $_user_valid -gt 0 ]] ; then
|
||
echo -e " Wiederhergestellt (valide)...............: \033[1;32m${_user_valid} (${_user_pct_valid} %)\033[m"
|
||
else
|
||
echo -e " Wiederhergestellt (valide)...............: ${_user_valid} (${_user_pct_valid} %)"
|
||
fi
|
||
if [[ $_user_invalid -gt 0 ]] ; then
|
||
echo -e " Datenmuell (ungueltig)...................: \033[1;31m${_user_invalid}\033[m"
|
||
fi
|
||
if [[ $_user_unverified -gt 0 ]] ; then
|
||
echo -e " Nicht pruefbar...........................: \033[33m${_user_unverified}\033[m"
|
||
fi
|
||
if [[ $_user_read_errors -gt 0 ]] ; then
|
||
echo -e " Weiterhin Lesefehler.....................: \033[1;31m${_user_read_errors}\033[m"
|
||
fi
|
||
echo ""
|
||
fi
|
||
|
||
# - 'Datenmuell' and 'Nicht pruefbar' files listed as their own,
|
||
# - visually set-off block(s), before the account statistics -
|
||
# - easier to scan/copy than digging them out of the full path list
|
||
# - above.
|
||
# -
|
||
{
|
||
if [[ $_user_invalid -gt 0 ]] ; then
|
||
echo ""
|
||
echo " ------------------------------------------------------------------"
|
||
echo " Datenmuell (ungueltig) - Account ${_user} (${_user_invalid})"
|
||
echo " ------------------------------------------------------------------"
|
||
for _line in "${_user_invalid_lines[@]}" ; do
|
||
printf ' %s\n' "$_line"
|
||
done
|
||
fi
|
||
if [[ $_user_unverified -gt 0 ]] ; then
|
||
echo ""
|
||
echo " ------------------------------------------------------------------"
|
||
echo " Nicht pruefbar - Account ${_user} (${_user_unverified})"
|
||
echo " ------------------------------------------------------------------"
|
||
for _line in "${_user_unverified_lines[@]}" ; do
|
||
printf ' %s\n' "$_line"
|
||
done
|
||
fi
|
||
} >> "$recovery_report_file"
|
||
|
||
{
|
||
echo ""
|
||
echo " Account ${_user}"
|
||
echo " Verarbeitete Dateien.....................: ${_user_total}"
|
||
echo " Wiederhergestellt (valide)...............: ${_user_valid} (${_user_pct_valid} %)"
|
||
[[ $_user_invalid -gt 0 ]] && echo " Datenmuell (ungueltig)...................: ${_user_invalid}"
|
||
[[ $_user_unverified -gt 0 ]] && echo " Nicht pruefbar...........................: ${_user_unverified}"
|
||
[[ $_user_read_errors -gt 0 ]] && echo " Weiterhin Lesefehler.....................: ${_user_read_errors}"
|
||
} >> "$recovery_report_file"
|
||
|
||
(( total_processed += _user_total ))
|
||
(( total_valid += _user_valid ))
|
||
(( total_invalid += _user_invalid ))
|
||
(( total_unverified += _user_unverified ))
|
||
(( total_read_errors += _user_read_errors ))
|
||
|
||
done
|
||
|
||
|
||
# =============
|
||
# --- Restore encryption_skip_signature_check as early as possible,
|
||
# --- before printing the overall summary.
|
||
# =============
|
||
|
||
restore_encryption_flag
|
||
|
||
|
||
total_pct_valid="$(calc_percent "$total_valid" "$total_processed")"
|
||
|
||
{
|
||
echo ""
|
||
echo ""
|
||
echo "=================================================================="
|
||
echo " Gesamtergebnis"
|
||
echo "=================================================================="
|
||
echo ""
|
||
echo "Gescannte Accounts..........................: ${#selected_user_arr[@]}"
|
||
[[ $total_failed_users -gt 0 ]] && echo "Accounts mit Recovery-Fehler................: ${total_failed_users}"
|
||
echo "Verarbeitete Dateien insgesamt..............: ${total_processed}"
|
||
echo "Wiederhergestellt insgesamt (valide)........: ${total_valid} (${total_pct_valid} %)"
|
||
[[ $total_invalid -gt 0 ]] && echo "Datenmuell insgesamt (ungueltig)............: ${total_invalid}"
|
||
[[ $total_unverified -gt 0 ]] && echo "Nicht pruefbar insgesamt....................: ${total_unverified}"
|
||
[[ $total_read_errors -gt 0 ]] && echo "Weiterhin Lesefehler insgesamt..............: ${total_read_errors}"
|
||
echo ""
|
||
echo "Wiederhergestellte Dateien liegen unter: ${recovery_dir}"
|
||
echo "Bitte Ergebnisse vor Weiterverwendung stichprobenartig pruefen,"
|
||
echo "und das Recovery-Verzeichnis danach sicher aufraeumen (enthaelt"
|
||
echo "unverschluesselte, ggf. sensible Nutzerdaten ausserhalb von Nextcloud)."
|
||
} >> "$recovery_report_file"
|
||
|
||
blank_line
|
||
|
||
if $terminal ; then
|
||
echo -e "\033[37m\033[1mErgebnis\033[m"
|
||
echo ""
|
||
echo -e " Gescannte Accounts..........................: ${#selected_user_arr[@]}"
|
||
[[ $total_failed_users -gt 0 ]] && echo -e " Accounts mit Recovery-Fehler................: \033[1;31m${total_failed_users}\033[m"
|
||
echo -e " Verarbeitete Dateien insgesamt..............: ${total_processed}"
|
||
if [[ $total_valid -gt 0 ]] ; then
|
||
echo -e " Wiederhergestellt insgesamt (valide)........: \033[1;32m${total_valid} (${total_pct_valid} %)\033[m"
|
||
else
|
||
echo -e " Wiederhergestellt insgesamt (valide)........: ${total_valid} (${total_pct_valid} %)"
|
||
fi
|
||
[[ $total_invalid -gt 0 ]] && echo -e " Datenmuell insgesamt (ungueltig)............: \033[1;31m${total_invalid}\033[m"
|
||
[[ $total_unverified -gt 0 ]] && echo -e " Nicht pruefbar insgesamt....................: \033[33m${total_unverified}\033[m"
|
||
[[ $total_read_errors -gt 0 ]] && echo -e " Weiterhin Lesefehler insgesamt..............: \033[1;31m${total_read_errors}\033[m"
|
||
echo ""
|
||
echo -e " Recovery-Verzeichnis.........................: $recovery_dir"
|
||
echo -e " Recovery-Report..............................: reports/$(basename "${recovery_report_file}")"
|
||
echo ""
|
||
warn "Wiederhergestellte Dateien vor Weiterverwendung stichprobenartig pruefen und das Recovery-Verzeichnis danach sicher aufraeumen - es enthaelt unverschluesselte, ggf. sensible Nutzerdaten ausserhalb von Nextcloud."
|
||
fi
|
||
|
||
clean_up 0
|