Skip to content

Commit 7b0fcde

Browse files
authored
Merge pull request #46 from buildplan/improved_restore
Improved restore function
2 parents b37cbca + 9ba58c3 commit 7b0fcde

File tree

3 files changed

+262
-166
lines changed

3 files changed

+262
-166
lines changed

README.md

Lines changed: 131 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ To run the backup automatically, edit the root crontab.
184184
185185
```ini
186186
# =================================================================
187-
# Configuration for rsync Backup Script v0.32
187+
# Configuration for rsync Backup Script v0.33
188188
# =================================================================
189189
# !! IMPORTANT !! Set file permissions to 600 (chmod 600 backup.conf)
190190
@@ -310,7 +310,7 @@ END_EXCLUDES
310310
311311
```bash
312312
#!/bin/bash
313-
# ===================== v0.32 - 2025.08.13 ========================
313+
# ===================== v0.33 - 2025.08.15 ========================
314314
#
315315
# =================================================================
316316
# SCRIPT INITIALIZATION & SETUP
@@ -616,8 +616,11 @@ run_preflight_checks() {
616616
if [[ "$test_mode" == "true" ]]; then printf "${C_GREEN}✅ Local disk space OK.${C_RESET}\n"; fi
617617
fi
618618
}
619+
print_header() {
620+
printf "\n%b--- %s ---%b\n" "${C_BOLD}" "$1" "${C_RESET}"
621+
}
619622
run_restore_mode() {
620-
printf "${C_BOLD}${C_CYAN}--- RESTORE MODE ACTIVATED ---${C_RESET}\n"
623+
print_header "RESTORE MODE ACTIVATED"
621624
run_preflight_checks "restore"
622625
local DIRS_ARRAY; read -ra DIRS_ARRAY <<< "$BACKUP_DIRS"
623626
local RECYCLE_OPTION="[ Restore from Recycle Bin ]"
@@ -627,149 +630,193 @@ run_restore_mode() {
627630
fi
628631
all_options+=("Cancel")
629632
printf "${C_YELLOW}Available backup sets to restore from:${C_RESET}\n"
633+
PS3="Your choice: "
630634
select dir_choice in "${all_options[@]}"; do
631635
if [[ -n "$dir_choice" ]]; then break;
632636
else echo "Invalid selection. Please try again."; fi
633637
done
638+
PS3="#? "
634639
local full_remote_source=""
635640
local default_local_dest=""
636641
local item_for_display=""
637642
local restore_path=""
638643
local is_full_directory_restore=false
639644
if [[ "$dir_choice" == "$RECYCLE_OPTION" ]]; then
640-
printf "${C_BOLD}${C_CYAN}--- Browse Recycle Bin ---${C_RESET}\n"
645+
print_header "Browse Recycle Bin"
646+
local date_folders=()
641647
local remote_recycle_path="${BOX_DIR%/}/${RECYCLE_BIN_DIR%/}"
642-
local date_folders
643-
date_folders=$(ssh "${SSH_OPTS_ARRAY[@]}" "${SSH_DIRECT_OPTS[@]}" "$BOX_ADDR" "ls -1 \"$remote_recycle_path\"" 2>/dev/null) || true
644-
if [[ -z "$date_folders" ]]; then
645-
echo "❌ No dated folders found in the recycle bin. Nothing to restore." >&2
646-
return 1
647-
fi
648+
mapfile -t date_folders < <(ssh "${SSH_OPTS_ARRAY[@]}" "${SSH_DIRECT_OPTS[@]}" "$BOX_ADDR" "ls -1 \"$remote_recycle_path\"" 2>/dev/null | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}$')
649+
if [[ ${#date_folders[@]} -eq 0 ]]; then
650+
printf "${C_YELLOW}❌ The remote recycle bin is empty or contains no valid backup folders.${C_RESET}\n"
651+
return 1
652+
fi
648653
printf "${C_YELLOW}Select a backup run (date_time) to browse:${C_RESET}\n"
649-
select date_choice in $date_folders "Cancel"; do
654+
PS3="Your choice: "
655+
select date_choice in "${date_folders[@]}" "Cancel"; do
650656
if [[ "$date_choice" == "Cancel" ]]; then echo "Restore cancelled."; return 0;
651657
elif [[ -n "$date_choice" ]]; then break;
652658
else echo "Invalid selection. Please try again."; fi
653659
done
660+
PS3="#? "
654661
local remote_date_path="${remote_recycle_path}/${date_choice}"
655-
printf "${C_BOLD}--- Files available from ${date_choice} (showing first 20) ---${C_RESET}\n"
662+
print_header "Files available from ${date_choice} (showing first 20)"
656663
local remote_listing_source="${BOX_ADDR}:${remote_date_path}/"
657664
rsync -r -n --out-format='%n' -e "$SSH_CMD" "$remote_listing_source" . 2>/dev/null | head -n 20 || echo "No files found for this date."
658-
printf "${C_BOLD}--------------------------------------------------------${C_RESET}\n"
659-
printf "${C_YELLOW}Enter the full original path of the item to restore (e.g., home/user/file.txt): ${C_RESET}"
660-
read -r specific_path
665+
printf "%b--------------------------------------------------------%b\n" "${C_BOLD}" "${C_RESET}"
666+
printf "${C_YELLOW}Enter the full original path of the item to restore (e.g., home/user/file.txt): ${C_RESET}"; read -r specific_path
667+
if [[ "$specific_path" == /* || "$specific_path" =~ (^|/)\.\.(/|$) ]]; then
668+
echo "❌ Invalid restore path: must be relative and contain no '..'" >&2; return 1
669+
fi
661670
specific_path=$(echo "$specific_path" | sed 's#^/##')
662671
if [[ -z "$specific_path" ]]; then echo "❌ Path cannot be empty. Aborting."; return 1; fi
663672
full_remote_source="${BOX_ADDR}:${remote_date_path}/${specific_path}"
664673
if ! rsync -r -n -e "$SSH_CMD" "$full_remote_source" . >/dev/null 2>&1; then
665-
echo "❌ ERROR: The path '${specific_path}' was not found in the recycle bin for ${date_choice}. Aborting." >&2
666-
return 1
674+
echo "❌ ERROR: The path '${specific_path}' was not found in the recycle bin for ${date_choice}. Aborting." >&2; return 1
667675
fi
668-
default_local_dest="/${specific_path}"
669-
item_for_display="(from Recycle Bin) '${specific_path}'"
676+
default_local_dest="/${specific_path}"; item_for_display="(from Recycle Bin) '${specific_path}'"
670677
elif [[ "$dir_choice" == "Cancel" ]]; then
671-
echo "Restore cancelled."
672-
return 0
678+
echo "Restore cancelled."; return 0
673679
else
674680
item_for_display="the entire directory '${dir_choice}'"
675681
while true; do
676-
printf "\n${C_YELLOW}Restore the entire directory or a specific file/subfolder? [entire/specific]: ${C_RESET}"
677-
read -r choice
682+
printf "\n${C_YELLOW}Restore the entire directory or a specific file/subfolder? [entire/specific]: ${C_RESET}"; read -r choice
678683
case "$choice" in
679-
entire)
680-
is_full_directory_restore=true
681-
break
682-
;;
684+
entire) is_full_directory_restore=true; break ;;
683685
specific)
684-
local specific_path_prompt
685-
printf -v specific_path_prompt "Enter the path relative to '%s' to restore: " "$dir_choice"
686-
printf "${C_YELLOW}%s${C_RESET}" "$specific_path_prompt"
687-
read -er specific_path
686+
local relative_path_browse="${dir_choice#*./}"
687+
local remote_browse_source="${REMOTE_TARGET}${relative_path_browse}"
688+
print_header "Files available in ${dir_choice} (showing first 20)"
689+
rsync -r -n --out-format='%n' -e "$SSH_CMD" "$remote_browse_source" . 2>/dev/null | head -n 20 || echo "No files found for this backup set."
690+
printf "%b--------------------------------------------------------%b\n" "${C_BOLD}" "${C_RESET}"
691+
printf -v specific_path_prompt "Enter the path relative to '%s' to restore (e.g., subfolder/file.txt): " "$dir_choice"; printf "${C_YELLOW}%s${C_RESET}" "$specific_path_prompt"; read -er specific_path
692+
if [[ "$specific_path" == /* || "$specific_path" =~ (^|/)\.\.(/|$) ]]; then
693+
echo "❌ Invalid restore path: must be relative and contain no '..'" >&2; return 1
694+
fi
688695
specific_path=$(echo "$specific_path" | sed 's#^/##')
689696
if [[ -n "$specific_path" ]]; then
690-
restore_path="$specific_path"
691-
item_for_display="'$restore_path' from '${dir_choice}'"
692-
break
697+
restore_path="$specific_path"; item_for_display="'$restore_path' from '${dir_choice}'"; break
693698
else
694699
echo "Path cannot be empty. Please try again or choose 'entire'."
695-
fi
696-
;;
700+
fi ;;
697701
*) echo "Invalid choice. Please answer 'entire' or 'specific'." ;;
698702
esac
699703
done
700704
local relative_path="${dir_choice#*./}"
701-
full_remote_source="${REMOTE_TARGET}${relative_path}${restore_path}"
705+
local remote_base="${REMOTE_TARGET%/}"
706+
full_remote_source="${remote_base}/${relative_path#/}"
707+
if [[ -n "$restore_path" ]]; then
708+
full_remote_source="${full_remote_source%/}/${restore_path#/}"
709+
fi
702710
if [[ -n "$restore_path" ]]; then
703-
default_local_dest=$(echo "${dir_choice}${restore_path}" | sed 's#/\./#/#')
711+
default_local_dest=$(echo "${dir_choice}${restore_path}" | sed 's#/\./#/#g')
704712
else
705-
default_local_dest=$(echo "$dir_choice" | sed 's#/\./#/#')
713+
default_local_dest=$(echo "$dir_choice" | sed 's#/\./#/#g')
706714
fi
707715
fi
708-
local final_dest
709-
printf "\n${C_YELLOW}Enter the destination path.\n${C_DIM}Press [Enter] to use the original location (%s):${C_RESET} " "$default_local_dest"
710-
read -r final_dest
716+
local final_dest
717+
print_header "Restore Destination"
718+
printf "Enter the absolute destination path for the restore.\n\n"
719+
printf "%bDefault (original location):%b\n" "${C_YELLOW}" "${C_RESET}"
720+
printf "%b%s%b\n\n" "${C_CYAN}" "$default_local_dest" "${C_RESET}"
721+
printf "Press [Enter] to use the default path, or enter a new one.\n"
722+
read -rp "> " final_dest
711723
: "${final_dest:=$default_local_dest}"
724+
local path_validation_attempts=0
725+
local max_attempts=5
726+
while true; do
727+
((path_validation_attempts++))
728+
if (( path_validation_attempts > max_attempts )); then
729+
printf "\n${C_RED}❌ Too many invalid attempts. Exiting restore mode.${C_RESET}\n"; return 1
730+
fi
731+
if [[ "$final_dest" != "/" ]]; then final_dest="${final_dest%/}"; fi
732+
local parent_dir; parent_dir=$(dirname -- "$final_dest")
733+
if [[ "$final_dest" != /* ]]; then
734+
printf "\n${C_RED}❌ Error: Please provide an absolute path (starting with '/').${C_RESET}\n"
735+
elif [[ -e "$final_dest" && ! -d "$final_dest" ]]; then
736+
printf "\n${C_RED}❌ Error: The destination '%s' exists but is a file. Please choose a different path.${C_RESET}\n" "$final_dest"
737+
elif [[ -e "$parent_dir" && ! -w "$parent_dir" ]]; then
738+
printf "\n${C_RED}❌ Error: The parent directory '%s' exists but is not writable.${C_RESET}\n" "$parent_dir"
739+
elif [[ -d "$final_dest" ]]; then
740+
printf "${C_GREEN}✅ Destination '%s' exists and is accessible.${C_RESET}\n" "$final_dest"
741+
if [[ "$final_dest" != "$default_local_dest" && -z "$restore_path" ]]; then
742+
local warning_msg="⚠️ WARNING: Custom destination directory already exists. Files may be overwritten."
743+
printf "${C_YELLOW}%s${C_RESET}\n" "$warning_msg"; log_message "$warning_msg"
744+
fi
745+
break
746+
else
747+
printf "\n${C_YELLOW}⚠️ The destination '%s' does not exist.${C_RESET}\n" "$final_dest"
748+
printf "${C_YELLOW}Choose an action:${C_RESET}\n"
749+
PS3="Your choice: "
750+
select action in "Create the destination path" "Enter a different path" "Cancel"; do
751+
case "$action" in
752+
"Create the destination path")
753+
if mkdir -p "$final_dest"; then
754+
printf "${C_GREEN}✅ Successfully created directory '%s'.${C_RESET}\n" "$final_dest"
755+
if [[ "${is_full_directory_restore:-false}" == "true" ]]; then
756+
chmod 700 "$final_dest"; log_message "Set permissions to 700 on newly created restore directory: $final_dest"
757+
else
758+
chmod 755 "$final_dest"
759+
fi
760+
break 2
761+
else
762+
printf "\n${C_RED}❌ Failed to create directory '%s'. Check permissions.${C_RESET}\n" "$final_dest"; break
763+
fi ;;
764+
"Enter a different path") break ;;
765+
"Cancel") echo "Restore cancelled by user."; return 0 ;;
766+
*) echo "Invalid option. Please try again." ;;
767+
esac
768+
done
769+
PS3="#? "
770+
fi
771+
if (( path_validation_attempts < max_attempts )); then
772+
printf "\n${C_YELLOW}Please enter a new destination path: ${C_RESET}"; read -r final_dest
773+
if [[ -z "$final_dest" ]]; then
774+
final_dest="$default_local_dest"; printf "${C_DIM}Empty input, using default location: %s${C_RESET}\n" "$final_dest"
775+
fi
776+
fi
777+
done
712778
local extra_rsync_opts=()
713779
local dest_user=""
714780
if [[ "$final_dest" == /home/* ]]; then
715781
dest_user=$(echo "$final_dest" | cut -d/ -f3)
716782
if [[ -n "$dest_user" ]] && id -u "$dest_user" &>/dev/null; then
717783
printf "${C_CYAN}ℹ️ Home directory detected. Restored files will be owned by '${dest_user}'.${C_RESET}\n"
718784
extra_rsync_opts+=("--chown=${dest_user}:${dest_user}")
785+
chown "${dest_user}:${dest_user}" "$final_dest" 2>/dev/null || true
719786
else
720787
dest_user=""
721788
fi
722789
fi
723-
local dest_created=false
724-
if [[ ! -e "$final_dest" ]]; then
725-
dest_created=true
726-
fi
727-
local dest_parent
728-
dest_parent=$(dirname "$final_dest")
729-
if ! mkdir -p "$dest_parent"; then
730-
echo "❌ FATAL: Could not create parent destination directory '$dest_parent'. Aborting." >&2
731-
return 1
732-
fi
733-
if [[ -n "$dest_user" ]]; then
734-
chown "${dest_user}:${dest_user}" "$dest_parent"
735-
fi
736-
if [[ "$final_dest" != "$default_local_dest" && -d "$final_dest" && -z "$restore_path" ]]; then
737-
local warning_msg="⚠️ WARNING: The custom destination directory '$final_dest' already exists. Files may be overwritten."
738-
echo "$warning_msg"; log_message "$warning_msg"
739-
fi
740-
if [[ "$dest_created" == "true" && "${is_full_directory_restore:-false}" == "true" ]]; then
741-
chmod 700 "$final_dest"; log_message "Set permissions to 700 on newly created restore directory: $final_dest"
742-
fi
743-
printf "Restore destination is set to: ${C_BOLD}%s${C_RESET}\n" "$final_dest"
744-
printf "\n${C_BOLD}${C_YELLOW}--- PERFORMING DRY RUN. NO FILES WILL BE CHANGED. ---${C_RESET}\n"
790+
print_header "Restore Summary"
791+
printf " Source: %s\n" "$item_for_display"
792+
printf " Destination: %b%s%b\n" "${C_BOLD}" "$final_dest" "${C_RESET}"
793+
print_header "PERFORMING DRY RUN (NO CHANGES MADE)"
745794
log_message "Starting restore dry-run of ${item_for_display} from ${full_remote_source} to ${final_dest}"
746-
local rsync_restore_opts=(-avhi --progress --exclude-from="$EXCLUDE_FILE_TMP" -e "$SSH_CMD")
795+
local rsync_restore_opts=(-avhi --safe-links --progress --exclude-from="$EXCLUDE_FILE_TMP" -e "$SSH_CMD")
747796
if ! rsync "${rsync_restore_opts[@]}" "${extra_rsync_opts[@]}" --dry-run "$full_remote_source" "$final_dest"; then
748-
echo "❌ DRY RUN FAILED. Rsync reported an error. Aborting." >&2; return 1
797+
printf "${C_RED}❌ DRY RUN FAILED. Rsync reported an error. Check connectivity and permissions.${C_RESET}\n" >&2
798+
log_message "Restore dry-run failed for ${item_for_display}"; return 1
749799
fi
750-
printf "${C_BOLD}${C_GREEN}--- DRY RUN COMPLETE ---${C_RESET}\n"
751-
local confirmation
800+
print_header "DRY RUN COMPLETE"
752801
while true; do
753-
printf "\n${C_YELLOW}Are you sure you want to proceed with restoring %s to '%s'? [yes/no]: ${C_RESET}" "$item_for_display" "$final_dest"
754-
read -r confirmation
755-
756-
case "$confirmation" in
757-
yes) break ;;
758-
no) echo "Restore aborted by user." ; return 0 ;;
759-
*) echo "Please answer yes or no." ;;
802+
printf "\n${C_YELLOW}Proceed with restoring %s to '%s'? [yes/no]: ${C_RESET}" "$item_for_display" "$final_dest"; read -r confirmation
803+
case "${confirmation,,}" in
804+
yes|y) break ;;
805+
no|n) echo "Restore cancelled by user."; return 0 ;;
806+
*) echo "Please answer 'yes' or 'no'." ;;
760807
esac
761808
done
762-
printf "\n${C_BOLD}--- PROCEEDING WITH RESTORE... ---${C_RESET}\n"
763-
log_message "Starting REAL restore of ${item_for_display} from ${full_remote_source} to ${final_dest}"
809+
print_header "EXECUTING RESTORE"
810+
log_message "Starting actual restore of ${item_for_display} from ${full_remote_source} to ${final_dest}"
764811
if rsync "${rsync_restore_opts[@]}" "${extra_rsync_opts[@]}" "$full_remote_source" "$final_dest"; then
765812
log_message "Restore completed successfully."
766813
printf "${C_GREEN}✅ Restore of %s to '%s' completed successfully.${C_RESET}\n" "$item_for_display" "$final_dest"
767814
send_notification "Restore SUCCESS: ${HOSTNAME}" "white_check_mark" "${NTFY_PRIORITY_SUCCESS}" "success" "Successfully restored ${item_for_display} to ${final_dest}"
768815
else
769-
log_message "Restore FAILED with rsync exit code $?."
816+
local rsync_exit_code=$?
817+
log_message "Restore FAILED with rsync exit code ${rsync_exit_code}."
770818
printf "${C_RED}❌ Restore FAILED. Check the rsync output and log for details.${C_RESET}\n"
771-
send_notification "Restore FAILED: ${HOSTNAME}" "x" "${NTFY_PRIORITY_FAILURE}" "failure" "Restore of ${item_for_display} to ${final_dest} failed."
772-
return 1
819+
send_notification "Restore FAILED: ${HOSTNAME}" "x" "${NTFY_PRIORITY_FAILURE}" "failure" "Restore of ${item_for_display} to ${final_dest} failed (exit code: ${rsync_exit_code})"; return 1
773820
fi
774821
}
775822
run_recycle_bin_cleanup() {
@@ -778,10 +825,10 @@ run_recycle_bin_cleanup() {
778825
local remote_cleanup_path="${BOX_DIR%/}/${RECYCLE_BIN_DIR%/}"
779826
local list_command="ls -1 \"$remote_cleanup_path\""
780827
local all_folders
781-
all_folders=$(ssh "${SSH_OPTS_ARRAY[@]}" "${SSH_DIRECT_OPTS[@]}" "$BOX_ADDR" "$list_command" 2>> "${LOG_FILE:-/dev/null}") || {
828+
if ! all_folders=$(ssh "${SSH_OPTS_ARRAY[@]}" "${SSH_DIRECT_OPTS[@]}" "$BOX_ADDR" "$list_command" 2>> "${LOG_FILE:-/dev/null}"); then
782829
log_message "Recycle bin not found or unable to list contents. Nothing to clean."
783830
return 0
784-
}
831+
fi
785832
if [[ -z "$all_folders" ]]; then
786833
log_message "No daily folders in recycle bin to check."
787834
return 0
@@ -929,7 +976,8 @@ for dir in "${DIRS_ARRAY[@]}"; do
929976
RSYNC_EXIT_CODE=${PIPESTATUS[0]}
930977
else
931978
RSYNC_OPTS+=(--info=stats2)
932-
nice -n 19 ionice -c 3 rsync "${RSYNC_OPTS[@]}" "$dir" "$REMOTE_TARGET" > "$RSYNC_LOG_TMP" 2>&1 || RSYNC_EXIT_CODE=$?
979+
nice -n 19 ionice -c 3 rsync "${RSYNC_OPTS[@]}" "$dir" "$REMOTE_TARGET" > "$RSYNC_LOG_TMP" 2>&1
980+
RSYNC_EXIT_CODE=$?
933981
fi
934982
cat "$RSYNC_LOG_TMP" >> "$LOG_FILE"; full_rsync_output+=$'\n'"$(<"$RSYNC_LOG_TMP")"
935983
rm -f "$RSYNC_LOG_TMP"

backup.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# =================================================================
2-
# Configuration for rsync Backup Script v0.32
2+
# Configuration for rsync Backup Script v0.33
33
# =================================================================
44
# !! IMPORTANT !! Set file permissions to 600 (chmod 600 backup.conf)
55

0 commit comments

Comments
 (0)