#!/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