Files
nextcloud/delete_bad_signature.sh
T

1168 lines
40 KiB
Bash
Executable File

#!/usr/bin/env bash
# delete_bad_signature.sh
#
# Deletes Nextcloud files whose Server-Side-Encryption signature is
# broken AND which could not be or were not restored by
# restore_bad_signature.sh / recreate_bad_signature.sh.
#
# These files cannot be removed via the normal Nextcloud UI, WebDAV or
# occ commands: the encryption wrapper raises DecryptionFailedException
# on every operation (including delete). This script bypasses the
# encryption layer entirely:
#
# 1. The raw ciphertext is removed directly on the filesystem (rm -f).
# 2. The key material directory is removed (rm -rf).
# 3. The filecache entry, share records and file locks are removed
# from the Nextcloud database via direct mysql queries.
#
# A backup copy of the ciphertext and key directory is written to a
# configurable backup directory before deletion so the operation can
# be reviewed and, if needed, manually undone at filesystem level.
#
# Background: broken-signature files block Nextcloud's archive/download
# feature because the archive builder hits the same DecryptionFailedException
# when trying to include them. Deleting them via this script resolves
# both problems.
#
# Input:
# A scan report produced by scan_bad_signature.sh (required).
#
# Default behaviour (safe):
# All recreate_<website>_*.tsv reports found in the reports/ directory
# are evaluated automatically. Files already successfully recreated
# (status OK or WRITTEN_UNVERIFIED) are excluded from deletion.
# This is the intended default: only delete what could not be saved.
#
# To delete ALL bad-signature files regardless of recreate status, use -a.
#
# Required conf file variables:
# DATABASE_NAME — Nextcloud database name
# DATABASE_TYPE — "mysql" (postgres not yet supported here)
# MYSQL_CREDENTIALS — mysql client auth args (e.g. -u root -S /run/mysqld/mysqld.sock)
#
# Optional conf file variables:
# DATA_DIR — Nextcloud data directory. If not set, the script
# tries WEB_BASE_DIR/data and /var/www/<website>/data.
# WEB_BASE_DIR — Nextcloud web root (e.g. /var/www/<website>).
# Used only to derive DATA_DIR when DATA_DIR is unset.
# DB_TABLE_PREFIX — Nextcloud DB table prefix (default: oc_)
# DELETE_BACKUP_BASE_DIR — where to store pre-deletion backups
# (default: one level above DATA_DIR)
#
# INSTALL_DIR, HTTP_USER and PHP are NOT required: filecache cleanup
# is done directly via mysql, not via a PHP helper.
#
# Usage:
# delete_bad_signature.sh [-s <website>] [-n] [-a] [-x <recreate_report>]...
CUR_IFS=$IFS
script_name="$(basename $(realpath $0))"
script_dir="$(dirname $(realpath $0))"
conf_dir="${script_dir}/conf"
report_dir="${script_dir}/reports"
LOCK_DIR="/tmp/${script_name%%.*}.LOCK"
log_file="${LOCK_DIR}/${script_name%%.*}.log"
run_date=$(date +%Y-%m-%d-%H%M)
declare -a unsorted_account_arr
declare -a account_arr
declare -a selected_user_arr
# =============
# --- Helper functions
# =============
terminal=false
[[ -t 1 ]] && terminal=true
echononl() {
$terminal && echo -en " $*"
}
echo_ok() {
$terminal && echo -e " \033[0;32m[ ok ]\033[m"
}
echo_done() {
$terminal && echo -e " \033[0;32m[ done ]\033[m"
}
echo_failed() {
$terminal && echo -e " \033[0;31m[failed]\033[m"
}
echo_skipped() {
$terminal && echo -e " \033[0;33m[ skip ]\033[m"
}
blank_line() {
$terminal && echo ""
}
trim() {
local _t="$*"
_t="${_t#"${_t%%[![:space:]]*}"}"
_t="${_t%"${_t##*[![:space:]]}"}"
echo "$_t"
}
info() {
$terminal && echo -e "\n\033[32m [INFO] $*\033[m\n"
}
warn() {
$terminal && echo -e "\n\033[33m [WARN] $*\033[m\n"
}
error() {
if $terminal ; then
echo -e "\n\033[1;31m [ERROR] $*\033[m\n" >&2
else
echo "[ERROR] $*" >&2
fi
}
fatal() {
if $terminal ; then
echo -e "\n\033[1;31m [FATAL] $*\033[m\n" >&2
else
echo "[FATAL] $*" >&2
fi
clean_up 1
}
calc_percent() {
local _part="$1"
local _total="$2"
if [[ "$_total" -eq 0 ]] ; then
echo "0"
else
awk -v b="$_part" -v t="$_total" 'BEGIN { printf "%.1f", (b/t)*100 }'
fi
}
# =============
# --- Cleanup
# =============
clean_up() {
local _exit_code="${1:-0}"
# Prevent re-entry when clean_up itself calls exit (EXIT trap fires again).
trap - EXIT
[[ -d "$LOCK_DIR" ]] && rm -rf "$LOCK_DIR"
exit "$_exit_code"
}
# =============
# --- Usage
# =============
usage() {
[[ -n "$1" ]] && error "$1"
$terminal && echo -e "
\033[1mUsage:\033[m
$(basename $0) [-s <website>] [-n] [-a] [-x <recreate_report>]...
\033[1mDescription\033[m
Deletes Nextcloud files whose Server-Side-Encryption signature is
broken AND which could not be or were not restored by
restore_bad_signature.sh / recreate_bad_signature.sh.
These files cannot be removed via the normal Nextcloud UI or WebDAV
because the encryption wrapper raises DecryptionFailedException on
every operation, including delete. This script bypasses the encryption
layer entirely:
1. The raw ciphertext is removed directly on the filesystem (rm -f).
2. The key material directory is removed (rm -rf).
3. The filecache entry, share records and file locks are removed
from the Nextcloud database via direct mysql queries.
A backup of the ciphertext and key directory is written to the
configured DELETE_BACKUP_BASE_DIR (or a sensible default next to
DATA_DIR) BEFORE deletion so the operation can be reviewed and, if
needed, manually undone at filesystem level.
You will be asked which scan report (from scan_bad_signature.sh) to
use, which accounts to process, and asked for confirmation before
anything is deleted.
Background: broken-signature files block Nextcloud's archive/download
feature (same DecryptionFailedException). Removing them fixes this.
\033[1mDefault behaviour (safe)\033[m
All recreate_<website>_*.tsv reports found in the reports/ directory
are evaluated automatically. Files that were already successfully
recreated (status OK or WRITTEN_UNVERIFIED) are excluded from
deletion. This is the intended default: only files that could not be
saved (and files where recovery was never attempted) are deleted.
Use -a to override this and delete everything.
\033[1mOptions\033[m
-s <website>
The Nextcloud site/instance identifier. If omitted, all conf
files in the conf/ directory are scanned and you choose
interactively.
-n
Dry-run: show what WOULD be deleted but do not actually delete,
back up, or modify anything.
-a
Delete ALL bad-signature files, including those that were already
successfully recreated. Skips the automatic exclusion of recreated
files and ignores any -x reports. Use with care.
-x <recreate_report>
Path to an additional recreate report from recreate_bad_signature.sh
to include in the exclusion set (on top of the automatically found
reports). Files with status OK or WRITTEN_UNVERIFIED in that report
are excluded from deletion. May be specified more than once.
Ignored when -a is given.
"
exit 1
}
# =============
# --- Default configuration values
# --- (overridden by the conf file sourced below)
# =============
WEBSITE=""
DRY_RUN=false
DELETE_ALL=false
declare -a EXCLUDE_REPORTS=()
DATA_DIR=""
WEB_BASE_DIR=""
DATABASE_NAME=""
DATABASE_TYPE="mysql"
MYSQL_CREDENTIALS=""
DB_TABLE_PREFIX="oc_"
# Optional: set DELETE_BACKUP_BASE_DIR in conf file to override the
# default (one directory above DATA_DIR).
DELETE_BACKUP_BASE_DIR=""
# =============
# --- Command-line arguments
# =============
while getopts ":s:nax:" opt ; do
case $opt in
s) WEBSITE="$OPTARG" ;;
n) DRY_RUN=true ;;
a) DELETE_ALL=true ;;
x) EXCLUDE_REPORTS+=("$OPTARG") ;;
:) usage "Option -${OPTARG} requires an argument." ;;
\?) usage "Unknown option: -${OPTARG}" ;;
esac
done
# =============
# --- Select website / conf file
# --- If -s was not given, scan the conf/ directory and ask interactively.
# =============
if [[ -z "$WEBSITE" ]] ; then
if [[ ! -d "$conf_dir" ]] ; then
fatal "Conf-Verzeichnis nicht gefunden: ${conf_dir}"
fi
declare -a _conf_websites=()
declare -a _conf_files=()
while IFS= read -r -d '' _cf ; do
# Extract WEBSITE value from conf file without fully sourcing it.
_w=$(grep -E '^\s*WEBSITE\s*=' "$_cf" 2>/dev/null \
| head -1 | sed -E 's/^\s*WEBSITE\s*=\s*"?([^"#]+)"?.*/\1/' | tr -d '[:space:]')
if [[ -n "$_w" ]] ; then
_conf_websites+=("$_w")
_conf_files+=("$_cf")
fi
done < <(find "$conf_dir" -maxdepth 1 -name "*.conf" -type f -print0 2>/dev/null | sort -z)
if [[ ${#_conf_websites[@]} -eq 0 ]] ; then
fatal "Keine conf-Dateien mit WEBSITE-Eintrag in '${conf_dir}' gefunden."
fi
if [[ ${#_conf_websites[@]} -eq 1 ]] ; then
WEBSITE="${_conf_websites[0]}"
conf_file="${_conf_files[0]}"
if $terminal ; then
echo ""
echo -e " Einzige verfügbare Instanz: \033[1;37m${WEBSITE}\033[m"
echo -n " Mit dieser Instanz fortfahren? [J/n] "
read -r _confirm_ws
_confirm_ws="$(echo "$_confirm_ws" | tr '[:upper:]' '[:lower:]')"
if [[ "$_confirm_ws" == "n" ]] ; then
info "Abgebrochen."
clean_up 0
fi
fi
else
if $terminal ; then
blank_line
echo -e "\033[37m\033[1mNextcloud-Instanz auswählen\033[m"
echo ""
for _i in "${!_conf_websites[@]}" ; do
printf " [%d] %s\n" $((_i+1)) "${_conf_websites[$_i]}"
done
echo ""
read -rp " Auswahl [1-${#_conf_websites[@]}]: " _ws_choice
if [[ -z "$_ws_choice" || ! "$_ws_choice" =~ ^[0-9]+$
|| "$_ws_choice" -lt 1 || "$_ws_choice" -gt ${#_conf_websites[@]} ]] ; then
fatal "Ungültige Auswahl."
fi
WEBSITE="${_conf_websites[$((_ws_choice-1))]}"
conf_file="${_conf_files[$((_ws_choice-1))]}"
else
# Non-interactive: use the first one found.
WEBSITE="${_conf_websites[0]}"
conf_file="${_conf_files[0]}"
echo "Verwende Instanz: ${WEBSITE}" >&2
fi
fi
else
conf_file="${conf_dir}/${WEBSITE}.conf"
[[ ! -f "$conf_file" ]] && fatal "Konfigurationsdatei nicht gefunden: ${conf_file}"
fi
# =============
# --- Read conf file
# =============
# shellcheck source=/dev/null
source "$conf_file"
[[ -z "$DATABASE_NAME" ]] && fatal "DATABASE_NAME nicht gesetzt in ${conf_file}"
[[ -z "$MYSQL_CREDENTIALS" ]] && fatal "MYSQL_CREDENTIALS nicht gesetzt in ${conf_file}"
[[ "$DATABASE_TYPE" != "mysql" ]] && fatal "DATABASE_TYPE muss 'mysql' sein (gefunden: ${DATABASE_TYPE:-<unset>})"
# Apply default table prefix if not set in conf.
: "${DB_TABLE_PREFIX:=oc_}"
# --- Auto-detect DATA_DIR if not set in conf ---
if [[ -z "$DATA_DIR" ]] ; then
_data_candidate=""
# 1. Try WEB_BASE_DIR/data (set in some conf files from update_nextcloud.sh)
if [[ -n "$WEB_BASE_DIR" && -d "${WEB_BASE_DIR}/data" ]] ; then
_data_candidate="${WEB_BASE_DIR}/data"
# 2. Convention: /var/www/<website>/data
elif [[ -d "/var/www/${WEBSITE}/data" ]] ; then
_data_candidate="/var/www/${WEBSITE}/data"
fi
if [[ -n "$_data_candidate" ]] ; then
DATA_DIR="$(realpath "$_data_candidate")"
if $terminal ; then
echo ""
echo -e " DATA_DIR automatisch erkannt: \033[1;37m${DATA_DIR}\033[m"
echo -n " Mit diesem Datenverzeichnis fortfahren? [J/n] "
read -r _confirm_data
_confirm_data="$(echo "$_confirm_data" | tr '[:upper:]' '[:lower:]')"
if [[ "$_confirm_data" == "n" ]] ; then
fatal "DATA_DIR nicht bestätigt. Bitte 'DATA_DIR=/korrekter/pfad' in ${conf_file} eintragen."
fi
else
echo "DATA_DIR automatisch erkannt: ${DATA_DIR}" >&2
fi
else
fatal "DATA_DIR nicht in ${conf_file} gesetzt und konnte nicht automatisch ermittelt werden.
Bitte 'DATA_DIR=/pfad/zu/nextcloud/data' in ${conf_file} eintragen,
oder 'WEB_BASE_DIR=/var/www/${WEBSITE}' wenn das Datenverzeichnis unter WEB_BASE_DIR/data liegt."
fi
fi
if [[ ! -d "$DATA_DIR" ]] ; then
fatal "DATA_DIR existiert nicht oder ist kein Verzeichnis: ${DATA_DIR}"
fi
# Resolve backup base directory.
if [[ -z "$DELETE_BACKUP_BASE_DIR" ]] ; then
DELETE_BACKUP_BASE_DIR="$(dirname "$DATA_DIR")/nc-delete-backup"
fi
delete_backup_dir="${DELETE_BACKUP_BASE_DIR}/${WEBSITE}/${run_date}"
# =============
# --- Verify basic tools / paths
# =============
if ! command -v mysql > /dev/null 2>&1 ; then
fatal "mysql client not found in PATH. Please install the MySQL client."
fi
if [[ ! -d "$report_dir" ]] ; then
fatal "Report directory not found: ${report_dir}"
fi
for _xr in "${EXCLUDE_REPORTS[@]}" ; do
[[ ! -f "$_xr" ]] && fatal "Exclude report not found: ${_xr}"
done
# =============
# --- Lock
# =============
if ! mkdir "$LOCK_DIR" 2> /dev/null ; then
fatal "Lock directory already exists: ${LOCK_DIR}. Is another instance running?"
fi
# Trap signals so the lock dir is always cleaned up.
trap 'clean_up 1' SIGHUP SIGINT SIGTERM EXIT
> "$log_file"
# =============
# --- Header
# =============
if $terminal ; then
echo ""
echo ""
echo -e "\033[37m\033[1m${script_name} ${WEBSITE} ${run_date}\033[m"
if $DRY_RUN ; then
echo -e " \033[33m[DRY-RUN - nichts wird gelöscht oder verändert]\033[m"
fi
echo ""
fi
# =============
# --- Select scan report
# =============
blank_line
$terminal && echo -e "\033[37m\033[1mScan-Report auswählen\033[m"
blank_line
declare -a _available_reports=()
while IFS= read -r -d '' _f ; do
_available_reports+=("$_f")
done < <(find "$report_dir" -maxdepth 1 \
\( -name "scan_${WEBSITE}_*.tsv" -o -name "bad_signature_${WEBSITE}_*.tsv" \) \
-type f -print0 2>/dev/null | sort -z)
if [[ ${#_available_reports[@]} -eq 0 ]] ; then
fatal "Kein Scan-Report in '${report_dir}' gefunden.
Erwartet: scan_${WEBSITE}_*.tsv oder bad_signature_${WEBSITE}_*.tsv
Bitte zuerst scan_bad_signature.sh ausführen."
fi
if [[ ${#_available_reports[@]} -eq 1 ]] ; then
chosen_report="${_available_reports[0]}"
info "Verwende einzigen verfügbaren Scan-Report: $(basename "$chosen_report")"
elif $terminal ; then
echo " Verfügbare Scan-Reports:"
for _i in "${!_available_reports[@]}" ; do
printf " [%d] %s\n" $((_i+1)) "$(basename "${_available_reports[$_i]}")"
done
echo ""
read -rp " Auswahl [1-${#_available_reports[@]}]: " _report_choice
if [[ -z "$_report_choice" || ! "$_report_choice" =~ ^[0-9]+$
|| "$_report_choice" -lt 1 || "$_report_choice" -gt ${#_available_reports[@]} ]] ; then
fatal "Ungültige Auswahl."
fi
chosen_report="${_available_reports[$((_report_choice-1))]}"
else
# Non-interactive: use the most recent report
chosen_report="${_available_reports[-1]}"
echo "Verwende neuesten Scan-Report: $(basename "$chosen_report")" >&2
fi
# =============
# --- Extract all (user, path) targets from the scan report.
# ---
# --- The scan report has flat TSV rows:
# --- user path size ... (>= 5 fields, $3 is numeric size)
# --- Same format / filter as recover_bad_signature.sh uses.
# =============
targets_file="${LOCK_DIR}/targets.tsv"
awk -F'\t' 'NF>=5 && $3 ~ /^[0-9]+$/ {print $1 "\t" $2}' "$chosen_report" > "$targets_file"
unsorted_account_arr=($(cut -f1 "$targets_file" | sort -u))
IFS=$'\n' account_arr=($(sort <<<"${unsorted_account_arr[*]}"))
IFS=$CUR_IFS
if [[ ${#account_arr[@]} -eq 0 ]] ; then
fatal "Keine Einträge mit Signaturfehlern in '$(basename "$chosen_report")' gefunden."
fi
_total_in_report=$(wc -l < "$targets_file" | tr -d ' ')
$terminal && echo " Einträge im Scan-Report: ${_total_in_report} Dateien in ${#account_arr[@]} Account(s)"
# =============
# --- Build exclusion set from recreate reports.
# ---
# --- DEFAULT (safe): all recreate_<website>_*.tsv reports found in
# --- the reports/ directory are used automatically, plus any reports
# --- given via -x. Files with status OK or WRITTEN_UNVERIFIED are
# --- excluded from deletion — they were already successfully recreated.
# ---
# --- With -a (delete all): the exclusion step is skipped entirely.
# ---
# --- Recreate reports use a section-based format:
# --- --- (opening separator)
# --- username (account name)
# --- --- (closing separator)
# --- path bytes status detail (4-column data rows, NF==4)
# =============
exclude_file="${LOCK_DIR}/exclude_paths.txt"
> "$exclude_file"
if $DELETE_ALL ; then
if [[ ${#EXCLUDE_REPORTS[@]} -gt 0 ]] ; then
warn "-a (delete all) ist gesetzt: -x Angaben werden ignoriert."
fi
$terminal && echo " Modus: ALLE kaputten Signaturdateien löschen (-a gesetzt, kein Ausschluss)"
else
# Auto-detect all recreate reports for this website.
declare -a _auto_reports=()
while IFS= read -r -d '' _f ; do
_auto_reports+=("$_f")
done < <(find "$report_dir" -maxdepth 1 -name "recreate_${WEBSITE}_*.tsv" -type f -print0 2>/dev/null | sort -z)
# Merge auto-detected + manually specified reports (deduplicated).
declare -a _all_exclude_reports=()
declare -A _seen_reports=()
for _r in "${_auto_reports[@]}" "${EXCLUDE_REPORTS[@]}" ; do
_rkey="$(realpath "$_r" 2>/dev/null || echo "$_r")"
if [[ -z "${_seen_reports[$_rkey]+x}" ]] ; then
_seen_reports[$_rkey]=1
_all_exclude_reports+=("$_r")
fi
done
if [[ ${#_all_exclude_reports[@]} -eq 0 ]] ; then
$terminal && echo " Keine Recreate-Reports gefunden — alle kaputten Signaturdateien werden gelöscht."
$terminal && echo " (Tipp: Mit -a explizit bestätigen, wenn das beabsichtigt ist.)"
else
$terminal && echo " Modus: Erfolgreich recreatete Dateien werden automatisch ausgeschlossen"
fi
for _xreport in "${_all_exclude_reports[@]}" ; do
if [[ ! -f "$_xreport" ]] ; then
warn "Recreate-Report nicht lesbar, wird übersprungen: ${_xreport}"
continue
fi
_before=$(wc -l < "$exclude_file" | tr -d ' ')
awk -F'\t' '
/^-+$/ {
if (state == 0) { state = 1 }
else if (state == 2) { state = 0 }
next
}
{
if (state == 1) { state = 2; next }
if (NF == 4 && $1 != "path" && ($3 == "OK" || $3 == "WRITTEN_UNVERIFIED")) {
print $1
}
}
' "$_xreport" >> "$exclude_file"
_after=$(wc -l < "$exclude_file" | tr -d ' ')
_added=$(( _after - _before ))
$terminal && echo " Recreate-Report '$(basename "$_xreport")': ${_added} erfolgreich recreatete Pfade geschützt"
done
if [[ -s "$exclude_file" ]] ; then
sort -u "$exclude_file" -o "$exclude_file"
_excl_total=$(wc -l < "$exclude_file" | tr -d ' ')
# Remove excluded paths from targets (compare on column 2 = path).
_filtered="${LOCK_DIR}/targets_filtered.tsv"
awk -F'\t' 'NR==FNR {exc[$1]=1; next} !exc[$2]' "$exclude_file" "$targets_file" > "$_filtered"
mv "$_filtered" "$targets_file"
# Rebuild account list after filtering.
unsorted_account_arr=($(cut -f1 "$targets_file" | sort -u))
IFS=$'\n' account_arr=($(sort <<<"${unsorted_account_arr[*]}"))
IFS=$CUR_IFS
_remaining=$(wc -l < "$targets_file" | tr -d ' ')
$terminal && echo " Nach Ausschluss: ${_remaining} Dateien zum Löschen (${_excl_total} geschützt)"
fi
fi
if [[ ${#account_arr[@]} -eq 0 ]] ; then
info "Keine zu löschenden Dateien verbleiben (alle erfolgreich recreated). Nichts zu tun."
clean_up 0
fi
# =============
# --- Account selection
# =============
blank_line
if $terminal ; then
echo -e "\033[37m\033[1mAccounts mit zu löschenden Dateien\033[m"
echo ""
for _a in "${account_arr[@]}" ; do
_a_count=$(awk -F'\t' -v u="$_a" '$1==u' "$targets_file" | wc -l)
echo " - $_a (${_a_count} Dateien)"
done
echo ""
echo " Auswahl: 'all' für alle Accounts, oder leerzeichen-getrennte Account-Namen:"
read -r _user_input
_user_input="$(trim "$_user_input")"
else
_user_input="all"
fi
if [[ "$_user_input" == "all" ]] ; then
selected_user_arr=("${account_arr[@]}")
else
for _u in $_user_input ; do
_found=false
for _a in "${account_arr[@]}" ; do
[[ "$_u" == "$_a" ]] && _found=true && break
done
if ! $_found ; then
warn "Account '${_u}' nicht im Report - wird ignoriert."
else
selected_user_arr+=("$_u")
fi
done
fi
if [[ ${#selected_user_arr[@]} -eq 0 ]] ; then
fatal "Keine gültigen Accounts ausgewählt."
fi
# =============
# --- Count total selected targets and show confirmation
# =============
declare -i _total_targets=0
for _u in "${selected_user_arr[@]}" ; do
_c=$(awk -F'\t' -v u="$_u" '$1==u' "$targets_file" | wc -l)
(( _total_targets += _c ))
done
delete_report_file="${report_dir}/delete_${WEBSITE}_${run_date}.tsv"
if $terminal ; then
blank_line
echo -e "\033[37m\033[1mÜbersicht\033[m"
echo ""
echo -e " Scan-Report...............................: $(basename "$chosen_report")"
if $DELETE_ALL ; then
echo -e " Modus.....................................: \033[1;31mALLE kaputten Signaturdateien (-a)\033[m"
else
if [[ -s "$exclude_file" ]] ; then
_excl_disp=$(wc -l < "$exclude_file" | tr -d ' ')
echo -e " Modus.....................................: Nur nicht-wiederhergestellte Dateien (Standard)"
echo -e " Geschützte Dateien (erfolgreich recreated): ${_excl_disp}"
else
echo -e " Modus.....................................: Alle (keine Recreate-Reports gefunden)"
fi
fi
echo -e " Ausgewählte Accounts......................: ${#selected_user_arr[@]}"
echo -e " Zu löschende Dateien insgesamt............: ${_total_targets}"
if ! $DRY_RUN ; then
echo -e " Backup-Verzeichnis........................: ${delete_backup_dir}"
fi
echo -e " Delete-Report.............................: reports/$(basename "${delete_report_file}")"
echo ""
if $DRY_RUN ; then
echo -e " \033[33mDRY-RUN: Es wird nichts gelöscht oder verändert.\033[m"
echo ""
else
echo -e " \033[1;31m╔═══════════════════════════════════════════════════════════╗\033[m"
echo -e " \033[1;31m║ ACHTUNG: Diese Operation löscht Dateien unwiderruflich. ║\033[m"
echo -e " \033[1;31m║ Ein Backup wird angelegt, aber nur fortfahren wenn Sie ║\033[m"
echo -e " \033[1;31m║ sicher sind, dass diese Dateien nicht benötigt werden. ║\033[m"
echo -e " \033[1;31m╚═══════════════════════════════════════════════════════════╝\033[m"
echo ""
read -rp " Jetzt ${_total_targets} Datei(en) endgültig löschen? [j/N] " _confirm
if [[ "$_confirm" != "j" && "$_confirm" != "J" ]] ; then
info "Abgebrochen. Nichts wurde verändert."
clean_up 0
fi
fi
fi
# =============
# --- MySQL filecache / share / lock cleanup function
# ---
# --- db_cleanup_user <uid> <list_file>
# ---
# --- list_file: one Nextcloud path (/uid/files/...) per line
# ---
# --- Called AFTER ciphertext and key directory have already been
# --- removed from the filesystem. Cleans up database metadata:
# --- - oc_filecache entry for each deleted file
# --- - oc_share entries referencing that file (by fileid)
# --- - oc_file_locks entry
# --- - marks parent folder size dirty (-1) for lazy recalculation
# ---
# --- Outputs one TSV line per path to stdout:
# --- path <TAB> status <TAB> detail
# --- status: OK | ALREADY_GONE | SKIP | STORAGE_NOT_FOUND | MYSQL_FATAL
# ---
# --- Returns non-zero on mysql invocation error.
# =============
db_cleanup_user() {
local _uid="$1"
local _list_file="$2"
local _prefix="/${_uid}/files/"
local _prefix_len="${#_prefix}"
local _sql_file="${LOCK_DIR}/cleanup_${_uid}_$$.sql"
# --- Parse paths, validate format ---
local -a _nc_paths=()
local -a _cache_paths=()
local -a _parent_paths=()
while IFS= read -r _ncpath ; do
[[ -z "$_ncpath" ]] && continue
if [[ "${_ncpath:0:${_prefix_len}}" != "$_prefix" ]] ; then
printf '%s\tSKIP\tPfad hat nicht das erwartete Format (%s...)\n' \
"$_ncpath" "$_prefix"
continue
fi
local _cp="files/${_ncpath:${_prefix_len}}"
_nc_paths+=("$_ncpath")
_cache_paths+=("$_cp")
_parent_paths+=("$(dirname "$_cp")")
done < "$_list_file"
if [[ ${#_nc_paths[@]} -eq 0 ]] ; then
return 0
fi
# --- Look up storage numeric_id for this user ---
local _storage_id
_storage_id=$(mysql $MYSQL_CREDENTIALS "$DATABASE_NAME" \
--batch --skip-column-names -e \
"SELECT numeric_id FROM \`${DB_TABLE_PREFIX}storages\`
WHERE id IN ('home::${_uid}', 'object::user::${_uid}') LIMIT 1;" 2>&1)
local _rc=$?
if [[ $_rc -ne 0 ]] ; then
printf '%s\tMYSQL_FATAL\tmysql Fehler rc=%d: %s\n' \
"[Storage-Lookup fuer ${_uid}]" "$_rc" "$(echo "$_storage_id" | head -1)"
return $_rc
fi
_storage_id="$(echo "$_storage_id" | tr -d '[:space:]')"
if [[ -z "$_storage_id" || ! "$_storage_id" =~ ^[0-9]+$ ]] ; then
# Storage entry not found for this user — cannot clean filecache.
for _ncpath in "${_nc_paths[@]}" ; do
printf '%s\tSTORAGE_NOT_FOUND\tStorage-Eintrag fuer uid '"'"'%s'"'"' nicht in %sstorages gefunden\n' \
"$_ncpath" "$_uid" "$DB_TABLE_PREFIX"
done
return 1
fi
# --- Build SQL batch for all files of this user ---
{
for _i in "${!_nc_paths[@]}" ; do
local _ncpath="${_nc_paths[$_i]}"
local _cp="${_cache_paths[$_i]}"
local _pp="${_parent_paths[$_i]}"
# Escape single quotes (standard SQL escaping).
local _cp_esc="${_cp//\'/\'\'}"
local _ncpath_esc="${_ncpath//\'/\'\'}"
local _pp_esc="${_pp//\'/\'\'}"
printf "SET @fileid = NULL;\n"
printf "SELECT fileid INTO @fileid FROM \`%sfilecache\`" "$DB_TABLE_PREFIX"
printf " WHERE storage = %s AND path = '%s' LIMIT 1;\n" \
"$_storage_id" "$_cp_esc"
printf "DELETE FROM \`%sshare\`" "$DB_TABLE_PREFIX"
printf " WHERE @fileid IS NOT NULL AND file_source = @fileid;\n"
printf "DELETE FROM \`%sfile_locks\`" "$DB_TABLE_PREFIX"
printf " WHERE @fileid IS NOT NULL AND \`key\` = CAST(@fileid AS CHAR);\n"
printf "DELETE FROM \`%sfilecache\`" "$DB_TABLE_PREFIX"
printf " WHERE @fileid IS NOT NULL AND fileid = @fileid;\n"
printf "UPDATE \`%sfilecache\` SET size = -1, unencrypted_size = -1" "$DB_TABLE_PREFIX"
printf " WHERE @fileid IS NOT NULL AND storage = %s AND path = '%s';\n" \
"$_storage_id" "$_pp_esc"
printf "SELECT '%s'," "$_ncpath_esc"
printf " IF(@fileid IS NULL, 'ALREADY_GONE', 'OK'),"
printf " IF(@fileid IS NULL,"
printf " 'Kein Filecache-Eintrag (bereits entfernt)',"
printf " CONCAT('fileid=', @fileid, ' entfernt'));\n\n"
done
} > "$_sql_file"
# --- Execute SQL batch ---
mysql $MYSQL_CREDENTIALS "$DATABASE_NAME" \
--batch --skip-column-names \
< "$_sql_file" 2>&1
_rc=$?
return $_rc
}
# =============
# --- Report header
# =============
{
echo "=================================================================="
echo " Bad-Signature-Löschung ${WEBSITE} ${run_date}"
echo "=================================================================="
echo ""
echo " Scan-Report: $(basename "$chosen_report")"
if $DELETE_ALL ; then
echo " Modus: ALLE kaputten Signaturdateien (-a)"
else
if [[ -s "$exclude_file" ]] ; then
_excl_rep=$(wc -l < "$exclude_file" | tr -d ' ')
echo " Modus: Nur nicht-wiederhergestellte Dateien (Standard)"
echo " Geschützte Pfade (erfolgreich recreated): ${_excl_rep}"
else
echo " Modus: Alle (keine Recreate-Reports gefunden)"
fi
fi
if $DRY_RUN ; then
echo " DRY-RUN: nichts wurde gelöscht."
else
echo " Backup: ${delete_backup_dir}"
fi
echo ""
echo -e "path\tstatus\tdetail"
} > "$delete_report_file"
# =============
# --- Main deletion loop
# =============
blank_line
$terminal && echo -e "\033[37m\033[1mLöschung\033[m"
blank_line
declare -i total_ok=0
declare -i total_already_gone=0
declare -i total_backup_failed=0
declare -i total_rm_failed=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"
} >> "$delete_report_file"
declare -i _user_ok=0
declare -i _user_already_gone=0
declare -i _user_backup_failed=0
declare -i _user_rm_failed=0
declare -a _user_backup_failed_lines=()
declare -a _user_rm_failed_lines=()
# list_file: paths to hand to the MySQL cleanup function
# (files that were successfully rm'd from disk, or already missing
# on disk but still present in filecache).
list_file="${LOCK_DIR}/list_${_user}.txt"
> "$list_file"
_user_total=$(awk -F'\t' -v u="$_user" '$1==u' "$targets_file" | wc -l)
if $DRY_RUN ; then
echononl " Dry-run für Account \033[1;37m${_user}\033[m (${_user_total} Dateien).."
else
echononl " Verarbeite Account \033[1;37m${_user}\033[m (${_user_total} Dateien).."
fi
while IFS=$'\t' read -r _u _path ; do
[[ "$_u" != "$_user" ]] && continue
_rel="${_path#/${_user}/files/}"
_live_ciphertext="${DATA_DIR}/${_user}/files/${_rel}"
_key_src="${DATA_DIR}/${_user}/files_encryption/keys/files/${_rel}"
if $DRY_RUN ; then
printf '%s\tDRY_RUN\tWürde gelöscht werden\n' "$_path" >> "$delete_report_file"
(( _user_ok++ ))
continue
fi
# ---- Backup ----
_backup_ok=true
if [[ -f "$_live_ciphertext" ]] ; then
_backup_ct="${delete_backup_dir}/${_user}/files/${_rel}"
mkdir -p "$(dirname "$_backup_ct")" 2> /dev/null
cp -p "$_live_ciphertext" "$_backup_ct" 2> /dev/null
if [[ ! -f "$_backup_ct" ]] \
|| [[ "$(stat -c%s "$_live_ciphertext" 2>/dev/null)" \
!= "$(stat -c%s "$_backup_ct" 2>/dev/null)" ]] ; then
_backup_ok=false
fi
fi
if [[ -d "$_key_src" ]] ; then
_backup_key="${delete_backup_dir}/${_user}/_keys/${_rel}"
mkdir -p "$(dirname "$_backup_key")" 2> /dev/null
cp -rp "$_key_src" "$_backup_key" 2> /dev/null
[[ -d "$_backup_key" ]] || _backup_ok=false
fi
if ! $_backup_ok ; then
(( _user_backup_failed++ ))
_user_backup_failed_lines+=("$_path")
printf '%s\tBACKUP_FAILED\tSicherung konnte nicht verifiziert werden - NICHT gelöscht\n' \
"$_path" >> "$delete_report_file"
continue
fi
# ---- Delete ciphertext ----
if [[ -f "$_live_ciphertext" ]] ; then
if ! rm -f -- "$_live_ciphertext" 2> /dev/null ; then
(( _user_rm_failed++ ))
_user_rm_failed_lines+=("$_path"$'\t'"rm -f Chiffretext fehlgeschlagen: ${_live_ciphertext}")
printf '%s\tRM_FAILED\trm -f Chiffretext fehlgeschlagen\n' "$_path" >> "$delete_report_file"
continue
fi
fi
# ---- Delete key directory ----
if [[ -d "$_key_src" ]] ; then
if ! rm -rf -- "$_key_src" 2> /dev/null ; then
(( _user_rm_failed++ ))
_user_rm_failed_lines+=("$_path"$'\t'"rm -rf Schlüsselverzeichnis fehlgeschlagen: ${_key_src}")
printf '%s\tRM_FAILED\trm -rf Schlüsselverzeichnis fehlgeschlagen\n' "$_path" >> "$delete_report_file"
continue
fi
fi
# Filesystem done — queue for DB cleanup.
echo "$_path" >> "$list_file"
done < <(awk -F'\t' -v u="$_user" '$1==u' "$targets_file")
echo_done
# ---- MySQL filecache cleanup ----
if ! $DRY_RUN && [[ -s "$list_file" ]] ; then
echononl " Bereinige Filecache / Shares für Account \033[1;37m${_user}\033[m.."
_mysql_result="${LOCK_DIR}/mysql_result_${_user}.tsv"
_mysql_err="${LOCK_DIR}/mysql_err_${_user}.log"
db_cleanup_user "$_user" "$list_file" > "$_mysql_result" 2> "$_mysql_err"
_rc=$?
if [[ $_rc -ne 0 ]] ; then
echo_failed
error "MySQL-Bereinigung fehlgeschlagen (rc=${_rc}): $(head -5 "$_mysql_err")"
printf '%s\tMYSQL_FATAL\tMySQL rc=%d - Filecache NICHT bereinigt\n' \
"[alle Eintraege fuer ${_user}]" "$_rc" >> "$delete_report_file"
(( _user_rm_failed += $(wc -l < "$list_file") ))
else
echo_done
while IFS=$'\t' read -r _p _st _det ; do
[[ -z "$_p" ]] && continue
case "$_st" in
OK)
(( _user_ok++ ))
printf '%s\tOK\t%s\n' "$_p" "$_det" >> "$delete_report_file"
;;
ALREADY_GONE)
(( _user_already_gone++ ))
printf '%s\tALREADY_GONE\t%s\n' "$_p" "$_det" >> "$delete_report_file"
;;
SKIP)
(( _user_rm_failed++ ))
_user_rm_failed_lines+=("$_p"$'\t'"${_det}")
printf '%s\tSKIP\t%s\n' "$_p" "$_det" >> "$delete_report_file"
;;
STORAGE_NOT_FOUND)
(( _user_rm_failed++ ))
_user_rm_failed_lines+=("$_p"$'\t'"${_det}")
printf '%s\tSTORAGE_NOT_FOUND\t%s\n' "$_p" "$_det" >> "$delete_report_file"
;;
ERROR|MYSQL_FATAL|*)
(( _user_rm_failed++ ))
_user_rm_failed_lines+=("$_p"$'\t'"MySQL-Fehler: ${_st}: ${_det}")
printf '%s\t%s\t%s\n' "$_p" "$_st" "$_det" >> "$delete_report_file"
;;
esac
done < "$_mysql_result"
fi
fi
# ---- Per-account summary (terminal) ----
_user_pct_ok="$(calc_percent "$(( _user_ok + _user_already_gone ))" "$_user_total")"
if $terminal ; then
echo ""
echo -e " \033[1;37mAccount ${_user}\033[m"
echo -e " Zu löschen........................: ${_user_total}"
if $DRY_RUN ; then
echo -e " Würde gelöscht werden.............: \033[1;32m${_user_ok}\033[m"
else
[[ $_user_ok -gt 0 ]] \
&& echo -e " Erfolgreich gelöscht..............: \033[1;32m${_user_ok}\033[m" \
|| echo -e " Erfolgreich gelöscht..............: ${_user_ok}"
[[ $_user_already_gone -gt 0 ]] \
&& echo -e " Bereits nicht vorhanden...........: \033[33m${_user_already_gone}\033[m"
[[ $_user_backup_failed -gt 0 ]] \
&& echo -e " Backup fehlgeschlagen (nicht gel.).: \033[1;31m${_user_backup_failed}\033[m"
[[ $_user_rm_failed -gt 0 ]] \
&& echo -e " Fehler beim Löschen...............: \033[1;31m${_user_rm_failed}\033[m"
fi
echo ""
fi
# ---- Per-account summary (report) ----
{
echo ""
echo " Account ${_user}"
echo " Zu löschen........................: ${_user_total}"
if $DRY_RUN ; then
echo " Würde gelöscht werden.............: ${_user_ok}"
else
echo " Erfolgreich gelöscht..............: ${_user_ok}"
[[ $_user_already_gone -gt 0 ]] \
&& echo " Bereits nicht vorhanden...........: ${_user_already_gone}"
[[ $_user_backup_failed -gt 0 ]] \
&& echo " Backup fehlgeschlagen (nicht gel.).: ${_user_backup_failed}"
[[ $_user_rm_failed -gt 0 ]] \
&& echo " Fehler beim Löschen...............: ${_user_rm_failed}"
if [[ ${#_user_backup_failed_lines[@]} -gt 0 ]] ; then
echo ""
echo " ------------------------------------------------------------------"
echo " Backup fehlgeschlagen - Account ${_user} (${_user_backup_failed})"
echo " ------------------------------------------------------------------"
for _l in "${_user_backup_failed_lines[@]}" ; do
printf ' %s\n' "$_l"
done
fi
if [[ ${#_user_rm_failed_lines[@]} -gt 0 ]] ; then
echo ""
echo " ------------------------------------------------------------------"
echo " Fehler beim Löschen - Account ${_user} (${_user_rm_failed})"
echo " ------------------------------------------------------------------"
for _l in "${_user_rm_failed_lines[@]}" ; do
printf ' %s\n' "$_l"
done
fi
fi
} >> "$delete_report_file"
(( total_ok += _user_ok ))
(( total_already_gone += _user_already_gone ))
(( total_backup_failed += _user_backup_failed ))
(( total_rm_failed += _user_rm_failed ))
unset _user_ok _user_already_gone _user_backup_failed _user_rm_failed
unset _user_backup_failed_lines _user_rm_failed_lines
done
# =============
# --- Overall summary
# =============
total_pct_ok="$(calc_percent "$(( total_ok + total_already_gone ))" "$_total_targets")"
{
echo ""
echo ""
echo "=================================================================="
echo " Gesamtergebnis"
echo "=================================================================="
echo ""
echo "Ausgewählte Accounts....................: ${#selected_user_arr[@]}"
echo "Zu löschende Dateien insgesamt..........: ${_total_targets}"
if $DRY_RUN ; then
echo "Würde gelöscht werden...................: ${total_ok}"
echo ""
echo "DRY-RUN: es wurde nichts gelöscht oder verändert."
echo "Ohne '-n' für den echten Lauf erneut ausführen."
else
echo "Erfolgreich gelöscht (Filecache bereinigt): ${total_ok} (${total_pct_ok} %)"
[[ $total_already_gone -gt 0 ]] \
&& echo "Bereits nicht vorhanden (Filecache bereinigt): ${total_already_gone}"
[[ $total_backup_failed -gt 0 ]] \
&& echo "Backup fehlgeschlagen (NICHT gelöscht).........: ${total_backup_failed}"
[[ $total_rm_failed -gt 0 ]] \
&& echo "Fehler beim Löschen............................: ${total_rm_failed}"
echo ""
echo "Backup (Chiffretext + Schlüssel) liegt unter: ${delete_backup_dir}"
echo ""
echo "WICHTIG: Ordnergrößen in Nextcloud werden erst nach"
echo "'occ files:scan <user>' aktualisiert. Empfehlung: nach"
echo "dem Löschen für jeden betroffenen Account einmalig ausführen:"
for _u in "${selected_user_arr[@]}" ; do
echo " occ files:scan ${_u}"
done
fi
} >> "$delete_report_file"
if $terminal ; then
blank_line
echo -e "\033[37m\033[1mErgebnis\033[m"
echo ""
echo -e " Ausgewählte Accounts....................: ${#selected_user_arr[@]}"
echo -e " Zu löschende Dateien insgesamt..........: ${_total_targets}"
if $DRY_RUN ; then
echo -e " Würde gelöscht werden...................: \033[1;32m${total_ok}\033[m"
else
if [[ $total_ok -gt 0 ]] ; then
echo -e " Erfolgreich gelöscht (Filecache ber.)...: \033[1;32m${total_ok} (${total_pct_ok} %)\033[m"
else
echo -e " Erfolgreich gelöscht (Filecache ber.)...: ${total_ok} (${total_pct_ok} %)"
fi
[[ $total_already_gone -gt 0 ]] \
&& echo -e " Bereits nicht vorhanden (FC ber.).......: \033[33m${total_already_gone}\033[m"
[[ $total_backup_failed -gt 0 ]] \
&& echo -e " Backup fehlgeschlagen (nicht gelöscht).: \033[1;31m${total_backup_failed}\033[m"
[[ $total_rm_failed -gt 0 ]] \
&& echo -e " Fehler beim Löschen....................: \033[1;31m${total_rm_failed}\033[m"
fi
echo ""
echo -e " Delete-Report..........................: reports/$(basename "${delete_report_file}")"
if ! $DRY_RUN ; then
echo -e " Backup (Chiffretext + Schlüssel).......: ${delete_backup_dir}"
echo ""
warn "Nach dem Löschen für jeden betroffenen Account 'occ files:scan <user>' ausführen, um Ordnergrößen zu aktualisieren."
fi
echo ""
if $DRY_RUN ; then
info "Dry-run beendet - nichts wurde verändert. Ohne '-n' für den echten Lauf erneut ausführen."
fi
fi
clean_up 0