captaincore/lib/remote-scripts/vault
2026-03-19 21:56:33 -04:00

1552 lines
56 KiB
Bash
Executable file
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
# ----------------------------------------------------
# Command: vault
# Description: Manages secure, full site snapshots in a remote Restic repository backed by Backblaze B2.
# Forked from: https://github.com/CaptainCore/do (vault functions only)
# Author: Austin Ginder
# License: MIT
# ----------------------------------------------------
# --- Global Variables ---
GUM_VERSION="0.14.4"
RCLONE_VERSION="1.73.1"
RESTIC_VERSION="0.18.1"
GUM_CMD=""
WP_CLI_CMD=""
RESTIC_CMD=""
RCLONE_CMD=""
# --- Helper Functions ---
# ----------------------------------------------------
# Intelligently finds or creates a private directory.
# Sets a global variable CAPTAINCORE_PRIVATE_DIR and echoes the path.
# ----------------------------------------------------
function _get_private_dir() {
# Return immediately if already found
if [[ -n "$CAPTAINCORE_PRIVATE_DIR" ]]; then
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
local wp_root=""
local parent_dir=""
# --- Tier 1: Preferred WP-CLI Method (if in a WP directory) ---
# Check if wp-cli is set up and if we are in a WP installation.
if setup_wp_cli && "$WP_CLI_CMD" core is-installed --quiet &>/dev/null; then
local wp_config_path
wp_config_path=$("$WP_CLI_CMD" config path --quiet 2>/dev/null | tail -1)
if [ -n "$wp_config_path" ] && [ -f "$wp_config_path" ]; then
wp_root=$(dirname "$wp_config_path")
parent_dir=$(dirname "$wp_root")
# --- WPE Specific Checks ---
# A. Check for _wpeprivate inside the WP root directory first (most common).
if [ -d "${wp_root}/_wpeprivate" ]; then
CAPTAINCORE_PRIVATE_DIR="${wp_root}/_wpeprivate"
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# B. Check for _wpeprivate in the parent directory.
if [ -d "${parent_dir}/_wpeprivate" ]; then
CAPTAINCORE_PRIVATE_DIR="${parent_dir}/_wpeprivate"
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# --- Standard Checks (relative to WP root's parent) ---
# Check for a standard ../private directory
if [ -d "${parent_dir}/private" ]; then
CAPTAINCORE_PRIVATE_DIR="${parent_dir}/private"
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# Try to create a ../private directory, suppressing errors
if mkdir -p "${parent_dir}/private" 2>/dev/null; then
CAPTAINCORE_PRIVATE_DIR="${parent_dir}/private"
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# Fallback to ../tmp if it exists
if [ -d "${parent_dir}/tmp" ]; then
CAPTAINCORE_PRIVATE_DIR="${parent_dir}/tmp"
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
fi
fi
# --- Tier 2: Manual Fallback (if WP-CLI fails or not in a WP install) ---
local current_dir
current_dir=$(pwd)
# WPE check in current directory
if [ -d "${current_dir}/_wpeprivate" ]; then
CAPTAINCORE_PRIVATE_DIR="${current_dir}/_wpeprivate"
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# Relative private check
if [ -d "../private" ]; then
CAPTAINCORE_PRIVATE_DIR=$(cd ../private && pwd)
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# Attempt to create relative private, suppressing errors
if mkdir -p "../private" 2>/dev/null; then
CAPTAINCORE_PRIVATE_DIR=$(cd ../private && pwd)
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# Relative tmp check
if [ -d "../tmp" ]; then
CAPTAINCORE_PRIVATE_DIR=$(cd ../tmp && pwd)
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
# --- Tier 3: Last Resort Fallback to Home Directory ---
# Suppress errors in case $HOME is not writable
if mkdir -p "$HOME/private" 2>/dev/null; then
CAPTAINCORE_PRIVATE_DIR="$HOME/private"
echo "$CAPTAINCORE_PRIVATE_DIR"
return 0
fi
echo "Error: Could not find or create a suitable private, writable directory." >&2
return 1
}
# --- Setup Functions ---
# ----------------------------------------------------
# Checks for and installs 'gum' if not present. Sets GUM_CMD on success.
# ----------------------------------------------------
function setup_gum() {
# Return if already found
if [[ -n "$GUM_CMD" ]]; then return 0; fi
# If gum is already in the PATH, we're good to go.
if command -v gum &> /dev/null; then
GUM_CMD="gum"
return 0
fi
# Find the private directory for storing tools
local private_dir
if ! private_dir=$(_get_private_dir); then
echo "Error: Cannot find a writable directory to install gum." >&2
return 1
fi
# Find the executable inside the private directory if it's already installed
local existing_executable
existing_executable=$(find "$private_dir" -name gum -type f 2>/dev/null | head -n 1)
if [ -n "$existing_executable" ] && [ -x "$existing_executable" ] && "$existing_executable" --version &> /dev/null; then
GUM_CMD="$existing_executable"
return 0
fi
echo "Required tool 'gum' not found. Installing to '${private_dir}'..." >&2
local original_dir; original_dir=$(pwd)
cd "$private_dir" || { echo "Error: Could not enter private directory '${private_dir}'." >&2; return 1; }
local gum_dir_name="gum_${GUM_VERSION}_Linux_x86_64"
local gum_tarball="${gum_dir_name}.tar.gz"
if ! curl -sSL "https://github.com/charmbracelet/gum/releases/download/v${GUM_VERSION}/${gum_tarball}" -o "${gum_tarball}"; then
echo "Error: Failed to download gum." >&2; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
# Find the path of the 'gum' binary WITHIN the tarball before extracting
local gum_path_in_tar
gum_path_in_tar=$(tar -tf "${gum_tarball}" | grep '/gum$' | head -n 1)
if [ -z "$gum_path_in_tar" ]; then
echo "Error: Could not find 'gum' executable within the downloaded tarball." >&2
rm -f "${gum_tarball}"; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
# Now extract the tarball
if ! tar -xf "${gum_tarball}"; then
echo "Error: Failed to extract gum from tarball." >&2
rm -f "${gum_tarball}"; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
rm -f "${gum_tarball}"
# The full path to the executable is the private dir + the path from the tarball
local gum_executable="${private_dir}/${gum_path_in_tar}"
if [ -f "$gum_executable" ]; then
chmod +x "$gum_executable"
else
echo "Error: gum executable not found at expected path after extraction: ${gum_executable}" >&2
cd "$original_dir" > /dev/null 2>&1; return 1;
fi
# Final check
if [ -x "$gum_executable" ] && "$gum_executable" --version &> /dev/null; then
echo "'gum' installed successfully." >&2
GUM_CMD="$gum_executable"
else
echo "Error: gum installation failed. The binary at ${gum_executable} might not be executable or compatible." >&2
cd "$original_dir" > /dev/null 2>&1; return 1;
fi
cd "$original_dir" > /dev/null 2>&1; return 0;
}
# ----------------------------------------------------
# Checks for and installs 'rclone' if not present.
# Sets RCLONE_CMD on success.
# ----------------------------------------------------
function setup_rclone() {
# Return if already found and version-checked
if [[ -n "$RCLONE_CMD" ]]; then return 0; fi
# Helper: extract version number from "rclone vX.Y.Z" output
_rclone_ver() { "$1" --version 2>/dev/null | head -n1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1; }
# Helper: returns 0 if $1 >= $2 using version comparison
_ver_gte() {
[ "$1" = "$2" ] && return 0
local IFS='.'; local i a=($1) b=($2)
for ((i=0; i<3; i++)); do
if (( ${a[i]:-0} > ${b[i]:-0} )); then return 0; fi
if (( ${a[i]:-0} < ${b[i]:-0} )); then return 1; fi
done
return 0
}
# Check system rclone
if command -v rclone &> /dev/null; then
local sys_ver; sys_ver=$(_rclone_ver rclone)
if [[ -n "$sys_ver" ]] && _ver_gte "$sys_ver" "$RCLONE_VERSION"; then
RCLONE_CMD="rclone"; return 0
fi
echo "System rclone v${sys_ver} is outdated (need v${RCLONE_VERSION}). Upgrading..." >&2
fi
if ! command -v unzip &>/dev/null; then echo "Error: 'unzip' command is required for rclone installation." >&2; return 1; fi
local private_dir
if ! private_dir=$(_get_private_dir </dev/null); then
echo "Error: Cannot find a writable directory to install rclone." >&2; return 1;
fi
# Check existing private dir copy — look for the target version first
local existing_executable
local target_dir="${private_dir}/rclone-v${RCLONE_VERSION}-linux-amd64"
if [ -x "${target_dir}/rclone" ]; then
existing_executable="${target_dir}/rclone"
else
existing_executable=$(find "$private_dir" -name rclone -type f 2>/dev/null | while read -r f; do
local v; v=$(_rclone_ver "$f")
if [[ -n "$v" ]] && _ver_gte "$v" "$RCLONE_VERSION"; then echo "$f"; break; fi
done)
fi
if [ -n "$existing_executable" ] && [ -x "$existing_executable" ]; then
local existing_ver; existing_ver=$(_rclone_ver "$existing_executable")
if [[ -n "$existing_ver" ]] && _ver_gte "$existing_ver" "$RCLONE_VERSION"; then
RCLONE_CMD="$existing_executable"; return 0
fi
echo "Local rclone v${existing_ver} is outdated (need v${RCLONE_VERSION}). Upgrading..." >&2
fi
# Download target version
if [ -z "$existing_executable" ]; then
echo "Required tool 'rclone' not found. Installing to '${private_dir}'..." >&2
fi
local original_dir; original_dir=$(pwd)
cd "$private_dir" || { echo "Error: Could not enter private directory '${private_dir}'." >&2; return 1; }
local rclone_zip="rclone-v${RCLONE_VERSION}-linux-amd64.zip"
if ! curl -sSL "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/${rclone_zip}" -o "${rclone_zip}"; then
echo "Error: Failed to download rclone." >&2; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
local rclone_path_in_zip
rclone_path_in_zip=$(unzip -l "${rclone_zip}" | grep '/rclone$' | awk '{print $4}' | head -n 1)
if [ -z "$rclone_path_in_zip" ]; then
echo "Error: Could not find 'rclone' executable within the downloaded zip." >&2
rm -f "${rclone_zip}"; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
if ! unzip -q -o "${rclone_zip}"; then
echo "Error: Failed to extract rclone." >&2; rm -f "${rclone_zip}"; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
rm -f "${rclone_zip}"
local rclone_executable="${private_dir}/${rclone_path_in_zip}"
if [ -f "$rclone_executable" ]; then
chmod +x "$rclone_executable";
else
echo "Error: rclone executable not found at expected path: ${rclone_executable}" >&2; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
if [ -x "$rclone_executable" ] && "$rclone_executable" --version &> /dev/null; then
echo "rclone v${RCLONE_VERSION} installed successfully." >&2; RCLONE_CMD="$rclone_executable";
else
echo "Error: rclone installation failed." >&2; cd "$original_dir" > /dev/null 2>&1; return 1;
fi
cd "$original_dir" > /dev/null 2>&1; return 0;
}
# ----------------------------------------------------
# Checks for and installs 'restic' if not present.
# Sets RESTIC_CMD on success.
# ----------------------------------------------------
function setup_restic() {
# Return if already found and version-checked
if [[ -n "$RESTIC_CMD" ]]; then return 0; fi
# Helper: extract version number from "restic X.Y.Z ..." output
_restic_ver() { "$1" version 2>/dev/null | head -n1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1; }
# Helper: returns 0 if $1 >= $2 using version comparison
_ver_gte() {
[ "$1" = "$2" ] && return 0
local IFS='.'; local i a=($1) b=($2)
for ((i=0; i<3; i++)); do
if (( ${a[i]:-0} > ${b[i]:-0} )); then return 0; fi
if (( ${a[i]:-0} < ${b[i]:-0} )); then return 1; fi
done
return 0
}
# Check system restic
if command -v restic &> /dev/null; then
local sys_ver; sys_ver=$(_restic_ver restic)
if [[ -n "$sys_ver" ]] && _ver_gte "$sys_ver" "$RESTIC_VERSION"; then
RESTIC_CMD="restic"; return 0
fi
echo "System restic v${sys_ver} is outdated (need v${RESTIC_VERSION}). Upgrading..." >&2
fi
# Check for local installation in private dir
local restic_executable="$HOME/private/restic"
if [ -f "$restic_executable" ] && [ -x "$restic_executable" ]; then
local existing_ver; existing_ver=$(_restic_ver "$restic_executable")
if [[ -n "$existing_ver" ]] && _ver_gte "$existing_ver" "$RESTIC_VERSION"; then
RESTIC_CMD="$restic_executable"; return 0
fi
echo "Local restic v${existing_ver} is outdated (need v${RESTIC_VERSION}). Upgrading..." >&2
fi
# Download target version
if ! command -v bunzip2 &>/dev/null; then echo "Error: 'bunzip2' command is required for installation." >&2; return 1; fi
mkdir -p "$HOME/private"
local original_dir; original_dir=$(pwd)
cd "$HOME/private" || { echo "Error: Could not enter ~/private." >&2; return 1; }
echo "Installing restic v${RESTIC_VERSION}..." >&2
local restic_archive="restic_${RESTIC_VERSION}_linux_amd64.bz2"
if ! curl -sL "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/${restic_archive}" -o "${restic_archive}"; then
echo "Error: Failed to download restic." >&2
cd "$original_dir" > /dev/null 2>&1; return 1
fi
# Decompress and extract the binary
bunzip2 -c "${restic_archive}" > restic_temp && mv restic_temp restic
rm -f "${restic_archive}"
chmod +x restic
# Final check
restic_executable="$HOME/private/restic"
if [ -f "$restic_executable" ] && "$restic_executable" version &> /dev/null; then
echo "restic v${RESTIC_VERSION} installed successfully." >&2
RESTIC_CMD="$restic_executable"
else
echo "Error: restic installation failed." >&2
cd "$original_dir" > /dev/null 2>&1; return 1
fi
cd "$original_dir" > /dev/null 2>&1; return 0
}
# ----------------------------------------------------
# Checks for WP-CLI. Sets WP_CLI_CMD on success.
# ----------------------------------------------------
function setup_wp_cli() {
# Return if already found
if [[ -n "$WP_CLI_CMD" ]]; then return 0; fi
# 1. Check if 'wp' is already in the PATH (covers interactive shells)
if command -v wp &> /dev/null; then
WP_CLI_CMD="wp"
return 0
fi
# 2. If not in PATH, check common absolute paths for cron environments
local common_paths=(
"/usr/local/bin/wp"
"$HOME/bin/wp"
"/opt/wp-cli/wp"
)
for path in "${common_paths[@]}"; do
if [ -x "$path" ]; then
WP_CLI_CMD="$path"
return 0
fi
done
# 3. If still not found, error out
echo "❌ Error: 'wp' command not found. Please ensure WP-CLI is installed and in your PATH." >&2
return 1
}
# --- Vault Helper Functions ---
# ----------------------------------------------------
# (Helper) Reads credentials and sets up the restic environment.
# ----------------------------------------------------
function _setup_vault_env() {
# --- Read Credentials ---
local b2_bucket b2_path b2_key_id b2_app_key restic_password
# Prioritize stdin: If data is being piped in, read from it.
if ! [ -t 0 ]; then
read -r stdin_b2_bucket
read -r stdin_b2_path
read -r stdin_b2_key_id
read -r stdin_b2_app_key
read -r stdin_restic_password
b2_bucket=${stdin_b2_bucket}
b2_path=${stdin_b2_path}
b2_key_id=${stdin_b2_key_id}
b2_app_key=${stdin_b2_app_key}
restic_password=${stdin_restic_password}
fi
# If stdin empty or incomplete, then attempt to load from environment variables.
if [ -z "$b2_bucket" ] || [ -z "$b2_path" ] || [ -z "$b2_key_id" ] || [ -z "$b2_app_key" ] || [ -z "$restic_password" ]; then
b2_bucket="$B2_BUCKET"
b2_path="$B2_PATH"
b2_key_id="$B2_ACCOUNT_ID"
b2_app_key="$B2_ACCOUNT_KEY"
restic_password="$RESTIC_PASSWORD"
fi
if [ -z "$b2_bucket" ] || [ -z "$b2_path" ] || [ -z "$b2_key_id" ] || [ -z "$b2_app_key" ] || [ -z "$restic_password" ]; then
echo "❌ Error: One or more required credentials were not provided or were empty." >&2
return 1
fi
export B2_ACCOUNT_ID="$b2_key_id"
export B2_ACCOUNT_KEY="$b2_app_key"
export RESTIC_PASSWORD="$restic_password"
export RESTIC_REPOSITORY="rclone::b2,account=${b2_key_id},key=${b2_app_key}:${b2_bucket}/${b2_path}"
export RCLONE_CONFIG=/dev/null
export B2_BUCKET="$b2_bucket"
export B2_PATH="$b2_path"
return 0
}
# ----------------------------------------------------
# (Helper) Caches the file list for a snapshot.
# ----------------------------------------------------
function _cache_snapshot_files() {
local snapshot_id="$1"
local cache_file="$2"
# Show a spinner while caching the entire file list.
if ! "$GUM_CMD" spin --spinner dot --title "Caching file list for snapshot ${snapshot_id}..." -- \
"$RESTIC_CMD" ls --json --long --recursive "${snapshot_id}" > "$cache_file";
then
echo "❌ Error: Could not cache the file list for snapshot ${snapshot_id}."
>&2
rm -f "$cache_file" # Clean up partial cache file
return 1
fi
return 0
}
# ----------------------------------------------------
# (Helper) Provides an interactive menu for a selected file.
# ----------------------------------------------------
function _file_action_menu() {
local snapshot_id="$1"
local file_path="$2"
local choice
choice=$("$GUM_CMD" choose "View Content" "Download File" "Restore File" "Back")
case "$choice" in
"View Content")
echo "📄 Viewing content of '$file_path'... (Press 'q' to quit)"
local temp_file
temp_file=$(mktemp)
if [ -z "$temp_file" ];
then
echo "❌ Error: Could not create a temporary file."
>&2
sleep 2
return
fi
# Dump the file content to the temporary file
if ! "$RESTIC_CMD" dump "${snapshot_id}" "${file_path}" > "$temp_file"; then
echo "❌ Error: Could not dump file content from repository."
>&2
rm -f "$temp_file"
sleep 2
return
fi
# View the temporary file with less, ensuring it's interactive
less -RN "$temp_file" </dev/tty
# Clean up the temporary file
rm -f "$temp_file"
;;
"Download File")
local filename
filename=$(basename "$file_path")
echo "⬇️ Downloading '$filename' to current directory..."
if "$GUM_CMD" confirm "Download '${filename}' to '$(pwd)'?";
then
if "$RESTIC_CMD" dump "${snapshot_id}" "${file_path}" > "$filename";
then
local size
size=$(ls -lh "$filename" | awk '{print $5}')
echo "✅ File downloaded: $filename ($size)"
else
echo "❌ Error: Failed to download file."
rm -f "$filename"
fi
else
echo "Download cancelled."
fi
"$GUM_CMD" input --placeholder="Press Enter to continue..." > /dev/null
;;
"Restore File")
echo "🔄 Restoring '$file_path' to current directory..."
if "$GUM_CMD" confirm "Restore '${file_path}' to '$(pwd)'?";
then
if "$RESTIC_CMD" restore "${snapshot_id}" --include "${file_path}" --target ".";
then
echo "✅ File restored successfully."
else
echo "❌ Error: Failed to restore file."
fi
else
echo "Restore cancelled."
fi
# Add a pause so the user can see the result before returning to the browser.
"$GUM_CMD" input --placeholder="Press Enter to continue..." > /dev/null
;;
"Back")
return
;;
*)
esac
}
# ----------------------------------------------------
# (Helper) Handles the download/restore of a full folder.
# ----------------------------------------------------
function _download_folder_action() {
local snapshot_id="$1"
local folder_path_in_repo="$2"
echo "📦 Preparing to download folder: '${folder_path_in_repo}'"
echo "This action will restore the selected folder and its full directory structure from the snapshot's root into your current working directory."
echo "For example, restoring '/wp-content/plugins/' will create the path './wp-content/plugins/' here."
if "$GUM_CMD" confirm "Proceed with restoring '${folder_path_in_repo}' to '$(pwd)'?"; then
echo " - Restoring files..."
if "$RESTIC_CMD" restore "${snapshot_id}" --include "${folder_path_in_repo}" --target "."; then
echo "✅ Folder and its contents restored successfully."
else
echo "❌ Error: Failed to restore folder."
fi
else
echo "Download cancelled."
fi
"$GUM_CMD" input --placeholder="Press Enter to continue..." > /dev/null
}
# ----------------------------------------------------
# (Helper) Provides an interactive file browser for a snapshot.
# ----------------------------------------------------
function _browse_snapshot() {
local snapshot_id="$1"
local cache_file="$2"
local current_path="/"
while true;
do
clear
echo "🗂 Browse Snapshot: ${snapshot_id} | Path: ${current_path}"
# PHP script to parse the cached JSON and format it for the current directory
local php_parser_code='
<?php
$cache_file = $argv[1] ?? "";
$current_path = $argv[2] ?? "/";
if (!file_exists($cache_file)) {
fwrite(STDERR, "Cache file not found.\n");
exit(1);
}
$file_content = file_get_contents($cache_file);
$lines = explode("\n", trim($file_content));
$items_in_current_dir = [];
$dirs_in_current_dir = [];
foreach ($lines as $line) {
if (empty($line)) continue;
$item = json_decode($line, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($item["path"])) continue;
$item_path = $item["path"];
if (strpos($item_path, $current_path) !== 0) continue;
$relative_path = substr($item_path, strlen($current_path));
if (empty($relative_path)) continue;
if (strpos($relative_path, "/") === false) {
if ($item["type"] === "dir") {
$dirs_in_current_dir[$relative_path] = true;
} else {
$items_in_current_dir[$relative_path] = $item;
}
} else {
$dir_name = explode("/", $relative_path)[0];
$dirs_in_current_dir[$dir_name] = true;
}
}
ksort($dirs_in_current_dir);
ksort($items_in_current_dir);
if ($current_path !== "/") {
echo "⤴️ ../ (Up one level)|up|..\n";
}
foreach (array_keys($dirs_in_current_dir) as $dir) {
echo "📁 " . $dir . "/|dir|" . $dir . "\n";
}
foreach ($items_in_current_dir as $name => $item) {
$size_bytes = $item["size"] ?? 0;
$size_formatted = "0 B";
if ($size_bytes >= 1048576) { $size_formatted = round($size_bytes / 1048576, 2) . " MB"; }
elseif ($size_bytes >= 1024) { $size_formatted = round($size_bytes / 1024, 2) . " KB"; }
elseif ($size_bytes > 0) { $size_formatted = $size_bytes . " B"; }
echo "📄 " . $name . " (" . $size_formatted . ")|file|" . $name . "\n";
}
echo "\n";
echo "💾 Download this directory ({$current_path})|download_dir|.\n";
'
# Execute PHP script to get a formatted list from the cache
local temp_script_file;
temp_script_file=$(mktemp)
echo "$php_parser_code" > "$temp_script_file"
local formatted_list;
formatted_list=$(php -f "$temp_script_file" "$cache_file" "$current_path")
rm "$temp_script_file"
# --- Display and selection logic ---
local display_items=()
local data_items=()
while IFS='|'
read -r display_part type_part name_part; do
if [ -z "$display_part" ];
then continue; fi
display_items+=("$display_part")
data_items+=("${type_part}|${name_part}")
done <<< "$formatted_list"
local selected_display
# Use a for loop to pipe items to gum filter, avoiding argument list limits.
selected_display=$(
for item in "${display_items[@]}"; do
echo "$item"
done | "$GUM_CMD" filter --height=20 --prompt="👇 Select a snapshot to browse" --indicator="→" --placeholder=""
)
if [ -z "$selected_display" ];
then
return # Exit the browser
fi
local selected_index=-1
for i in "${!display_items[@]}";
do
if [[ "${display_items[$i]}" == "$selected_display" ]];
then
selected_index=$i
break
fi
done
if [ "$selected_index" -eq -1 ];
then continue; fi
local selected_data="${data_items[selected_index]}"
local item_type;
item_type=$(echo "$selected_data" | cut -d'|' -f1)
local item_name;
item_name=$(echo "$selected_data" | cut -d'|' -f2)
case "$item_type" in
"dir")
current_path="${current_path}${item_name}/"
;;
"file")
_file_action_menu "${snapshot_id}" "${current_path}${item_name}"
;;
"up")
if [[ "$current_path" != "/" ]]; then
# Get parent directory
parent_path=$(dirname "${current_path%/}")
# If the parent is the root, the new path is simply "/".
# Otherwise, it's the parent path with a trailing slash.
if [[ "$parent_path" == "/" ]]; then
current_path="/"
else
current_path="${parent_path}/"
fi
fi
;;
"download_dir")
_download_folder_action "${snapshot_id}" "${current_path}"
;;
esac
done
}
# --- Vault Command Functions ---
# ----------------------------------------------------
# Creates a new snapshot of the current site.
# ----------------------------------------------------
function vault_create() {
echo "🚀 Starting secure snapshot to Restic B2 repository..."
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_rclone; then return 1; fi
if [[ -n "$RCLONE_CMD" && "$RCLONE_CMD" != "rclone" ]]; then
export PATH="$(dirname "$RCLONE_CMD"):$PATH"
fi
if ! setup_wp_cli; then return 1; fi
# Check if WP, but allow proceeding if not
local is_wordpress=false
if "$WP_CLI_CMD" core is-installed --quiet; then
is_wordpress=true
else
echo " This does not appear to be a WordPress installation. Proceeding with file-only backup." >&2
fi
# --- Setup Restic Environment ---
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
# --- Rclone Cache Size Check ---
if [ -n "$EMAIL_NOTIFY" ]; then
local rclone_cache_dir="$HOME/.cache/rclone"
if [ -d "$rclone_cache_dir" ]; then
local cache_size_bytes
cache_size_bytes=$(du -sb "$rclone_cache_dir" | awk '{print $1}')
local size_limit_bytes=10737418240 # 10 GB
if (( cache_size_bytes > size_limit_bytes )); then
local site_url
site_url=$("$WP_CLI_CMD" option get home 2>/dev/null || echo "Unknown Site")
local cache_size_gb
cache_size_gb=$(awk -v bytes="$cache_size_bytes" 'BEGIN { printf "%.2f", bytes / 1024 / 1024 / 1024 }')
local email_subject="Rclone Cache Warning for ${site_url}"
local email_message="Warning: The rclone cache folder at ${rclone_cache_dir} is larger than 10GB. Current size: ${cache_size_gb}GB. This might cause issues with 'vault create' operations."
echo " - ⚠️ Rclone cache is large (${cache_size_gb}GB). Sending email notification to $EMAIL_NOTIFY..."
"$WP_CLI_CMD" eval "wp_mail( '$EMAIL_NOTIFY', '$email_subject', '$email_message', ['Content-Type: text/html; charset=UTF-8'] );" 2>/dev/null
fi
fi
fi
# --- Check/Initialize Restic Repository ---
echo " - Checking for repository at $(echo "$RESTIC_REPOSITORY" | sed 's/account=[^,]*/account=***/;s/key=[^:]*/key=***/')..."
if ! "$RESTIC_CMD" cat config > /dev/null 2>&1; then
echo " - Repository not found or is invalid. Attempting to initialize..."
if ! "$RESTIC_CMD" init; then
echo "❌ Error: Failed to initialize Restic repository." >&2
return 1
fi
echo " - ✅ Repository initialized successfully."
else
echo " - ✅ Repository found."
fi
# --- Create DB Dump in Private Directory ---
local private_dir
if ! private_dir=$(_get_private_dir); then
return 1
fi
local sql_file_path="${private_dir}/database-backup.sql"
local wp_root_dir
wp_root_dir=$(realpath ".")
local temporary_sql_path_in_root="${wp_root_dir}/database-backup.sql"
if [[ "$is_wordpress" == "true" ]]; then
echo " - Generating database dump at: ${sql_file_path}"
# Try with --single-transaction first for InnoDB consistency, fall back without it if FLUSH TABLES privilege is missing
if ! "$WP_CLI_CMD" db export "$sql_file_path" --add-drop-table --single-transaction --quick --max_allowed_packet=512M > /dev/null 2>&1; then
if ! "$WP_CLI_CMD" db export "$sql_file_path" --add-drop-table --quick --max_allowed_packet=512M > /dev/null; then
echo "❌ Error: Database export failed." >&2
return 1
fi
fi
# --- Move DB dump to root for snapshot ---
echo " - Temporarily moving database dump to web root for snapshotting."
if ! mv "$sql_file_path" "$temporary_sql_path_in_root"; then
echo "❌ Error: Could not move database dump to web root." >&2
return 1
fi
fi
# --- Run Restic Backup ---
local original_dir
original_dir=$(pwd)
echo " - Changing to root ($wp_root_dir) for clean snapshot paths..."
cd "$wp_root_dir" || {
echo "❌ Error: Could not change to root directory." >&2
if [[ "$is_wordpress" == "true" ]]; then
echo " - Attempting to move database dump back to private directory..."
mv "$temporary_sql_path_in_root" "$sql_file_path"
fi
return 1
}
local human_readable_size
human_readable_size=$(du -sh . | awk '{print $1}')
local tag_args=()
if [[ -n "$human_readable_size" ]]; then
tag_args+=(--tag "size:${human_readable_size}")
fi
echo " - Backing up current directory (.)..."
if ! "$RESTIC_CMD" backup "." \
"${tag_args[@]}" \
--exclude '**.DS_Store' \
--exclude '*timthumb.txt' \
--exclude 'debug.log' \
--exclude 'error_log' \
--exclude 'phperror_log' \
--exclude 'wp-content/updraft' \
--exclude 'wp-content/cache' \
--exclude 'wp-content/et-cache' \
--exclude 'wp-content/.wps-slots' \
--exclude 'wp-content/wflogs' \
--exclude 'wp-content/uploads/sessions' \
--exclude 'wp-snapshots' \
--exclude 'node_modules'; then
echo "❌ Error: Restic backup command failed." >&2
cd "$original_dir"
if [[ "$is_wordpress" == "true" ]]; then
echo " - Moving database dump back to private directory..."
mv "$temporary_sql_path_in_root" "$sql_file_path"
fi
return 1
fi
# --- Cleanup: Move DB dump back to private directory ---
cd "$original_dir"
if [[ "$is_wordpress" == "true" ]]; then
echo " - Moving database dump back to private directory..."
mv "$temporary_sql_path_in_root" "$sql_file_path"
fi
# Piggyback snapshot listing for the control server.
# This lets list-generate and verify consume remote JSON instead of running
# restic locally, which avoids building up ~/.cache/restic on the control server.
echo "---CAPTAINCORE_SNAPSHOTS_BEGIN---"
"$RESTIC_CMD" snapshots --json 2>/dev/null
echo "---CAPTAINCORE_SNAPSHOTS_END---"
# Unset variables for security
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
echo "✅ Vault snapshot complete!"
}
# ----------------------------------------------------
# Lists all snapshots in the Restic repository.
# ----------------------------------------------------
function vault_snapshots() {
local output_mode="$1"
if ! setup_restic; then return 1; fi
if ! setup_rclone; then return 1; fi
if [[ -n "$RCLONE_CMD" && "$RCLONE_CMD" != "rclone" ]]; then
export PATH="$(dirname "$RCLONE_CMD"):$PATH"
fi
if ! setup_gum; then return 1; fi
if ! command -v php &>/dev/null; then echo "❌ Error: The 'php' command is required for this operation." >&2; return 1; fi
if ! command -v mktemp &>/dev/null; then echo "❌ Error: The 'mktemp' command is required for this operation." >&2; return 1; fi
if ! command -v wc &>/dev/null; then echo "❌ Error: The 'wc' command is required for this operation." >&2; return 1; fi
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
local snapshots_json
snapshots_json=$("$GUM_CMD" spin --spinner dot --title "Fetching snapshots from repository..." -- \
"$RESTIC_CMD" snapshots --json
)
if [[ ! "$snapshots_json" =~ ^\[ ]]; then
echo "Error: Failed to fetch snapshots. Restic output:" >&2
echo "$snapshots_json" >&2
exit 1
fi
local total_count
read -r -d '' php_script << 'EOF'
$json_string = file_get_contents("php://stdin");
$snapshots = json_decode($json_string, true);
if (json_last_error() === JSON_ERROR_NONE) {
echo count($snapshots);
} else {
echo 0;
}
EOF
total_count=$("$GUM_CMD" spin --spinner dot --title "Counting total snapshots..." -- \
bash -c 'php -r "$1"' _ "$php_script" <<< "$snapshots_json"
)
echo "🔎 Fetching ${total_count} snapshots..."
if [[ "$snapshots_json" == "[]" ]]; then
echo " No snapshots found in the repository."
return 0
fi
local php_parser_code='
<?php
if (defined("STDIN")) {
$json_data = file_get_contents("php://stdin");
$snapshots = json_decode($json_data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
fwrite(STDERR, "PHP Error: Failed to decode JSON - " . json_last_error_msg() . "\n");
exit(1);
}
if (empty($snapshots) || !is_array($snapshots)) {
exit(0);
}
usort($snapshots, function($a, $b) { return strtotime($b["time"]) - strtotime($a["time"]); });
foreach ($snapshots as $snap) {
$size_formatted = "N/A";
if (isset($snap["summary"]["total_bytes_processed"])) {
$size_bytes = (float)$snap["summary"]["total_bytes_processed"];
if ($size_bytes >= 1073741824) {
$size_formatted = round($size_bytes / 1073741824, 2) . " GB";
} elseif ($size_bytes >= 1048576) {
$size_formatted = round($size_bytes / 1048576, 2) . " MB";
} elseif ($size_bytes >= 1024) {
$size_formatted = round($size_bytes / 1024, 2) . " KB";
} elseif ($size_bytes > 0) {
$size_formatted = $size_bytes . " B";
}
}
echo $snap["short_id"] . "|" .
(new DateTime($snap["time"]))->format("Y-m-d H:i:s") . "|" .
$size_formatted . "\n";
}
}
'
local php_script_file
php_script_file=$(mktemp)
if [ -z "$php_script_file" ]; then
echo "❌ Error: Could not create a temporary file for the PHP script." >&2
return 1
fi
echo "$php_parser_code" > "$php_script_file"
local snapshot_list
snapshot_list=$(printf "%s" "$snapshots_json" | php -f "$php_script_file" 2>/dev/null)
rm -f "$php_script_file"
if [ -z "$snapshot_list" ]; then
echo "❌ Error parsing snapshot list." >&2
return 1
fi
local display_items=()
local data_items=()
while IFS='|' read -r id time size; do
if [ -z "$id" ]; then
continue
fi
display_items+=("$(printf "%-9s | %-20s | %-10s" "$id" "$time" "$size")")
data_items+=("$id")
done <<< "$snapshot_list"
if [[ "$output_mode" == "true" ]]; then
printf "%s\n" "${display_items[@]}"
return 0
fi
local selected_display
selected_display=$(printf "%s\n" "${display_items[@]}" | "$GUM_CMD" filter --height=20 --prompt="👇 Select a snapshot to browse" --indicator="→" --placeholder="")
if [ -z "$selected_display" ]; then echo "No snapshot selected."; return 0; fi
local selected_id
selected_id=$(echo "$selected_display" | awk '{print $1}')
local cache_file;
cache_file=$(mktemp)
if ! _cache_snapshot_files "$selected_id" "$cache_file"; then
[ -f "$cache_file" ] && rm -f "$cache_file"
return 1
fi
_browse_snapshot "$selected_id" "$cache_file"
rm -f "$cache_file"
}
# ----------------------------------------------------
# Mounts the Restic repository to a local directory.
# ----------------------------------------------------
function vault_mount() {
echo "🚀 Preparing to mount Restic repository..."
if ! setup_restic; then return 1; fi
if ! setup_rclone; then return 1; fi
if [[ -n "$RCLONE_CMD" && "$RCLONE_CMD" != "rclone" ]]; then
export PATH="$(dirname "$RCLONE_CMD"):$PATH"
fi
if ! _setup_vault_env; then return 1; fi
local mount_point="/tmp/restic_mount_$(date +%s)"
mkdir -p "$mount_point"
echo " - Mount point created at: $mount_point"
echo " - To unmount, run: umount \"$mount_point\""
echo " - Press Ctrl+C to stop the foreground mount process."
"$RESTIC_CMD" mount "$mount_point"
}
# ----------------------------------------------------
# Displays statistics about the Restic repository.
# ----------------------------------------------------
function vault_info() {
echo "🔎 Gathering repository information..."
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_gum; then return 1; fi
if ! setup_rclone; then return 1; fi
if [[ -n "$RCLONE_CMD" && "$RCLONE_CMD" != "rclone" ]]; then
export PATH="$(dirname "$RCLONE_CMD"):$PATH"
fi
if ! command -v php &>/dev/null; then echo "❌ Error: The 'php' command is required for this operation." >&2; return 1; fi
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
# --- Get repo size and file count using rclone ---
local rclone_remote_string=":b2,account='${B2_ACCOUNT_ID}',key='${B2_ACCOUNT_KEY}':${B2_BUCKET}/${B2_PATH}"
local size_json
size_json=$("$GUM_CMD" spin --spinner dot --title "Calculating repository size with rclone..." -- \
"$RCLONE_CMD" size --json "$rclone_remote_string"
)
local size_data=""
if [[ "$size_json" == *"bytes"* ]]; then
local size_parser_code='
$json_str = file_get_contents("php://stdin");
$data = json_decode($json_str, true);
if (json_last_error() === JSON_ERROR_NONE) {
$bytes = $data["bytes"] ?? 0;
$count = $data["count"] ?? 0;
$size_formatted = "0 B";
if ($bytes >= 1073741824) { $size_formatted = round($bytes / 1073741824, 2) . " GiB"; }
elseif ($bytes >= 1048576) { $size_formatted = round($bytes / 1048576, 2) . " MiB"; }
elseif ($bytes >= 1024) { $size_formatted = round($bytes / 1024, 2) . " KiB"; }
elseif ($bytes > 0) { $size_formatted = $bytes . " B"; }
echo "Total Size," . $size_formatted . "\n";
echo "Total Files," . $count . "\n";
}
'
size_data=$(echo "$size_json" | php -r "$size_parser_code")
fi
# --- Get Snapshot Info from Restic ---
local snapshots_json
snapshots_json=$("$GUM_CMD" spin --spinner dot --title "Fetching snapshot list..." -- \
"$RESTIC_CMD" snapshots --json)
if [ $? -ne 0 ]; then
echo "❌ Error fetching snapshots from restic." >&2
echo "Restic output: $snapshots_json" >&2
return 1
fi
# Verify JSON and default to empty array if invalid
if ! printf "%s" "$snapshots_json" | "$GUM_CMD" format >/dev/null 2>&1; then
snapshots_json="[]"
fi
# --- MODIFIED: Pipe Restic JSON directly to PHP parser ---
local php_parser_code_info='
$json_data = file_get_contents("php://stdin");
$snapshots = json_decode($json_data, true);
// Exit if JSON is invalid or empty after decoding
if (json_last_error() !== JSON_ERROR_NONE || !is_array($snapshots)) { exit(0); }
$snapshot_count = count($snapshots);
$oldest_date = "N/A";
$newest_date = "N/A";
if ($snapshot_count > 0) {
$timestamps = array_map(function($s) {
return isset($s["time"]) ? strtotime($s["time"]) : 0;
}, $snapshots);
$timestamps = array_filter($timestamps);
if(count($timestamps) > 0) {
$oldest_ts = min($timestamps);
$newest_ts = max($timestamps);
$oldest_date = date("Y-m-d H:i:s T", $oldest_ts);
$newest_date = date("Y-m-d H:i:s T", $newest_ts);
}
}
echo "Snapshot Count," . $snapshot_count . "\n";
echo "Oldest Snapshot," . $oldest_date . "\n";
echo "Newest Snapshot," . $newest_date . "\n";
'
local info_data
info_data=$(printf "%s" "$snapshots_json" | php -r "$php_parser_code_info")
echo "--- Repository Information ---"
(
echo "Statistic,Value"
echo "B2 Bucket,${b2_bucket}"
echo "B2 Path,${b2_path}"
if [ -n "$size_data" ]; then echo "$size_data"; fi
if [ -n "$info_data" ]; then echo "$info_data"; fi
) | "$GUM_CMD" table --print --separator "," --widths=20,40
}
# ----------------------------------------------------
# Prunes the Restic repository to remove unneeded data.
# ----------------------------------------------------
function vault_prune() {
echo "🚀 Preparing to prune the Restic repository..."
echo "This command removes old data that is no longer needed."
echo "It can be a long-running process and will lock the repository."
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_rclone; then return 1; fi
if [[ -n "$RCLONE_CMD" && "$RCLONE_CMD" != "rclone" ]]; then
export PATH="$(dirname "$RCLONE_CMD"):$PATH"
fi
if ! setup_gum; then return 1; fi
# --- Setup Restic Environment ---
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
# --- User Confirmation ---
echo "Repository: $(echo "$RESTIC_REPOSITORY" | sed 's/account=[^,]*/account=***/;s/key=[^:]*/key=***/')"
if ! "$GUM_CMD" confirm "Are you sure you want to prune this repository?"; then
echo "Prune operation cancelled."
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 0
fi
# --- Run Restic Prune with lock detection ---
echo " - Starting prune operation. This may take a while..."
local prune_output
# Capture all output (stdout and stderr) to check for the lock message
prune_output=$("$RESTIC_CMD" prune 2>&1)
local prune_exit_code=$?
# Check if the prune command failed
if [ $prune_exit_code -ne 0 ]; then
# If it failed, check if it was due to a lock
if echo "$prune_output" | grep -q "unable to create lock"; then
echo "⚠️ The repository is locked. A previous operation may have failed or is still running."
echo "$prune_output" # Show the user the detailed lock info from restic
if "$GUM_CMD" confirm "Do you want to attempt to remove the stale lock and retry?"; then
echo " - Attempting to unlock repository..."
if ! "$RESTIC_CMD" unlock; then
echo "❌ Error: Failed to unlock the repository. Please check it manually." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
echo " - Unlock successful. Retrying prune operation..."
if ! "$RESTIC_CMD" prune; then
echo "❌ Error: Restic prune command failed even after unlocking." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
else
echo "Prune operation cancelled due to locked repository."
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 0
fi
else
# The failure was for a reason other than a lock
echo "❌ Error: Restic prune command failed." >&2
echo "$prune_output" >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
fi
# --- Cleanup ---
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
echo "✅ Vault prune complete!"
}
# ----------------------------------------------------
# Deletes a specific snapshot from the Restic repository.
# ----------------------------------------------------
function vault_delete() {
local snapshot_id="$1"
if [ -z "$snapshot_id" ]; then
echo "❌ Error: You must provide a snapshot ID to delete." >&2
show_help >&2
return 1
fi
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_rclone; then return 1; fi
if [[ -n "$RCLONE_CMD" && "$RCLONE_CMD" != "rclone" ]]; then
export PATH="$(dirname "$RCLONE_CMD"):$PATH"
fi
if ! setup_gum; then return 1; fi
# --- Setup Restic Environment ---
if ! _setup_vault_env; then
return 1 # Error message printed in helper
fi
echo "You are about to permanently delete snapshot: ${snapshot_id}"
echo "This action cannot be undone."
if ! "$GUM_CMD" confirm "Are you sure you want to delete this snapshot?"; then
echo "Delete operation cancelled."
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 0
fi
echo " - Deleting snapshot ${snapshot_id}..."
local forget_output
# Capture stdout and stderr to check for errors
forget_output=$("$RESTIC_CMD" forget "$snapshot_id" 2>&1)
local forget_exit_code=$?
# Check if the command failed
if [ $forget_exit_code -ne 0 ]; then
# If it failed, check if it was due to a lock
if echo "$forget_output" | grep -q "unable to create lock"; then
echo "⚠️ The repository is locked. A previous operation may have failed or is still running."
echo "$forget_output"
if "$GUM_CMD" confirm "Do you want to attempt to remove the stale lock and retry?"; then
echo " - Attempting to unlock repository..."
if ! "$RESTIC_CMD" unlock; then
echo "❌ Error: Failed to unlock the repository. Please check it manually." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
echo " - Unlock successful. Retrying delete operation..."
if ! "$RESTIC_CMD" forget "$snapshot_id"; then
echo "❌ Error: Restic forget command failed even after unlocking." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
else
echo "Delete operation cancelled due to locked repository."
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 0
fi
else
# The failure was for a reason other than a lock
echo "❌ Error: Failed to delete snapshot ${snapshot_id}." >&2
echo "$forget_output" >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
else
# Print the success output from the first attempt
echo "$forget_output"
fi
# --- Cleanup ---
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
echo "✅ Snapshot ${snapshot_id} has been forgotten."
echo "💡 Note: This only removes the snapshot reference. To free up storage space by removing the underlying data, run 'vault prune'."
}
# ----------------------------------------------------
# Displays detailed information for a single snapshot.
# ----------------------------------------------------
function vault_snapshot_info() {
local snapshot_id="$1"
if [ -z "$snapshot_id" ]; then
echo "❌ Error: You must provide a snapshot ID."
show_help >&2
return 1
fi
# --- Pre-flight Checks ---
if ! setup_restic; then return 1; fi
if ! setup_rclone; then return 1; fi
if [[ -n "$RCLONE_CMD" && "$RCLONE_CMD" != "rclone" ]]; then
export PATH="$(dirname "$RCLONE_CMD"):$PATH"
fi
if ! setup_gum; then return 1; fi
if ! command -v php &>/dev/null; then
echo "❌ Error: The 'php' command is required for this operation."
>&2
return 1
fi
# --- Setup Restic Environment ---
if ! _setup_vault_env; then
return 1
fi
# --- Fetch Snapshot Data ---
echo "🔎 Fetching information for snapshot ${snapshot_id}..."
local snapshots_json
snapshots_json=$("$GUM_CMD" spin --spinner dot --title "Fetching repository data..." -- \
"$RESTIC_CMD" snapshots --json)
if [ $? -ne 0 ]; then
echo "❌ Error: Failed to fetch snapshot list from the repository."
>&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
# --- PHP Parser ---
# This script searches the JSON list for the specific snapshot ID and falls back to 'restic stats' if needed.
local php_parser_code='
$target_id = $argv[1] ?? "";
$restic_cmd = $argv[2] ?? "restic";
if (empty($target_id)) { exit(1);
}
$json_data = file_get_contents("php://stdin");
$snapshots = json_decode($json_data, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($snapshots)) {
exit(1);
}
$found_snap = null;
foreach ($snapshots as $snap) {
if ((isset($snap["id"]) && strpos($snap["id"], $target_id) === 0) || (isset($snap["short_id"]) && $snap["short_id"] === $target_id)) {
$found_snap = $snap;
break;
}
}
if ($found_snap === null) {
fwrite(STDERR, "Snapshot with ID starting with \"$target_id\" not found.\n");
exit(1);
}
$snap = $found_snap;
function format_bytes($bytes) {
$bytes = (float)$bytes;
if ($bytes >= 1073741824) { return round($bytes / 1073741824, 2) . " GB"; }
elseif ($bytes >= 1048576) { return round($bytes / 1048576, 2) . " MB"; }
elseif ($bytes >= 1024) { return round($bytes / 1024, 2) . " KB"; }
elseif ($bytes > 0) { return $bytes . " B"; }
else { return "0 B"; }
}
$output = [];
$output["ID"] = $snap["short_id"] ?? "N/A";
$output["Time"] = isset($snap["time"]) ? (new DateTime($snap["time"]))->format("Y-m-d H:i:s T") : "N/A";
$output["Parent"] = $snap["parent"] ?? "None";
$output["Paths"] = isset($snap["paths"]) && is_array($snap["paths"]) ? implode("\n", $snap["paths"]) : "N/A";
$size_formatted = "N/A";
$data_added_formatted = "N/A";
if (isset($snap["summary"]["total_bytes_processed"])) {
$size_formatted = format_bytes($snap["summary"]["total_bytes_processed"]);
if (isset($snap["summary"]["data_added"])) {
$data_added_formatted = format_bytes($snap["summary"]["data_added"]);
}
} else {
fwrite(STDERR, "Snapshot summary not found. Calculating size with '\''restic stats'\''.\n");
$full_snapshot_id = $snap["id"];
$stats_json = shell_exec(escapeshellarg($restic_cmd) . " stats --json " . escapeshellarg($full_snapshot_id));
if ($stats_json) {
$stats_data = json_decode($stats_json, true);
if (json_last_error() === JSON_ERROR_NONE && isset($stats_data["total_size"])) {
$size_formatted = format_bytes($stats_data["total_size"]);
}
}
}
$output["Size (Full)"] = $size_formatted;
$output["Data Added (Unique)"] = $data_added_formatted;
foreach($output as $key => $value) {
// Using addslashes and quoting to handle multi-line paths and other special characters
echo $key . "," . "\"" . addslashes($value) . "\"\n";
}
'
# --- Process and Display ---
local info_data
info_data=$(echo "$snapshots_json" | php -r "$php_parser_code" "$snapshot_id" "$RESTIC_CMD")
# If the PHP script returns no data, the snapshot was not found in the JSON.
if [ -z "$info_data" ]; then
echo "❌ Error: Could not find data for snapshot '${snapshot_id}' in the repository list." >&2
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
return 1
fi
# Unset variables for security
unset B2_ACCOUNT_ID B2_ACCOUNT_KEY RESTIC_PASSWORD RESTIC_REPOSITORY
# Display the final table
echo "--- Snapshot Information ---"
(
echo "Property,Value"
# The PHP script now correctly escapes output for the table
echo "$info_data"
) |
"$GUM_CMD" table --print --separator "," --widths=25,0
}
# --- Main Dispatcher ---
function show_help() {
echo "Manages secure, full site snapshots in a remote Restic repository."
echo
echo "Usage: vault <subcommand> [arguments]"
echo
echo "Subcommands:"
echo " create Creates a new snapshot of the current site."
echo " snapshots [--output] Lists available snapshots. Use --output for a non-interactive list."
echo " snapshot-info <id> Displays detailed information for a single snapshot."
echo " delete <id> Deletes a specific snapshot by its ID."
echo " mount Mounts the entire repository to a local folder for browsing."
echo " info Displays statistics about the repository."
echo " prune Removes unnecessary data from the repository."
echo
echo "Authentication:"
echo " This command requires B2 and Restic credentials, provided either by"
echo " environment variables (B2_ACCOUNT_ID, B2_ACCOUNT_KEY, RESTIC_PASSWORD,"
echo " B2_BUCKET, B2_PATH) or by piping a 5-line secrets file via stdin."
echo
echo "Example (stdin):"
echo " cat secrets.txt | vault snapshots"
}
function main() {
# --- Argument Parser ---
local output_flag=""
local positional_args=()
while [[ $# -gt 0 ]]; do
case $1 in
--output)
output_flag=true
shift
;;
--help|-h)
show_help
exit 0
;;
-*)
echo "Error: Unknown flag: $1" >&2
show_help
exit 1
;;
*)
positional_args+=("$1")
shift
;;
esac
done
# The first positional argument is the main command.
local command="${positional_args[0]}"
# If invoked as `bash -s -- vault <subcommand>`, skip the "vault" prefix
if [[ "$command" == "vault" ]]; then
positional_args=("${positional_args[@]:1}")
command="${positional_args[0]}"
fi
case "$command" in
create)
vault_create
;;
delete)
local snapshot_id_to_delete="${positional_args[1]}"
vault_delete "$snapshot_id_to_delete"
;;
snapshot-info)
local snapshot_id_to_show="${positional_args[1]}"
vault_snapshot_info "$snapshot_id_to_show"
;;
snapshots)
vault_snapshots "$output_flag"
;;
mount)
vault_mount
;;
info)
vault_info
;;
prune)
vault_prune
;;
*)
show_help >&2
exit 0
;;
esac
}
# Pass all script arguments to the main function.
main "$@"