From 5100866ca1432bf132b460454c7a4da67f20f13f Mon Sep 17 00:00:00 2001 From: Christoph Date: Wed, 30 Sep 2026 00:16:18 +0200 Subject: [PATCH] Add script 'delete_bad_signature.sh'. --- delete_bad_signature.sh | 1167 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 1167 insertions(+) create mode 100755 delete_bad_signature.sh diff --git a/delete_bad_signature.sh b/delete_bad_signature.sh new file mode 100755 index 0000000..60aece7 --- /dev/null +++ b/delete_bad_signature.sh @@ -0,0 +1,1167 @@ +#!/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__*.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//data. +# WEB_BASE_DIR — Nextcloud web root (e.g. /var/www/). +# 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 ] [-n] [-a] [-x ]... + +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 ] [-n] [-a] [-x ]... + +\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__*.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 + 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 + 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:-})" + +# 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//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__*.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 +# --- +# --- 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 status 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 ' 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 ' 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