Files
pleno-vue/scripts/release/repair-public-htaccess.sh
T

219 lines
7.7 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
MODE="${1:-audit}"
FTP_HOST="${PRODUCTION_FTP_HOST:-}"
FTP_USER="${PRODUCTION_FTP_USER:-}"
FTP_PASSWORD="${PRODUCTION_FTP_PASSWORD:-}"
FTP_PATH="${PRODUCTION_FTP_PATH:-}"
WEBROOT="${PRODUCTION_CPANEL_WEBROOT:-public_html}"
FRONTEND_URL="${PRODUCTION_FRONTEND_URL:-https://truckwash.io}"
SOURCE_HTACCESS="${SOURCE_HTACCESS:-public/.htaccess}"
REPORT_DIR="${ROOT_REPAIR_REPORT_DIR:-output/cpanel-root-ftps}"
RUN_ID="${GITHUB_RUN_ID:-local}"
RUN_ATTEMPT="${GITHUB_RUN_ATTEMPT:-1}"
if [[ "$MODE" != "audit" && "$MODE" != "repair" ]]; then
echo "Usage: $0 [audit|repair]" >&2
exit 2
fi
if [[ -z "$FTP_HOST" || -z "$FTP_USER" || -z "$FTP_PASSWORD" || -z "$FTP_PATH" ]]; then
echo "Production FTPS credentials and path are required." >&2
exit 2
fi
if [[ "$FTP_USER" == *$'\n'* || "$FTP_USER" == *$'\r'* || "$FTP_PASSWORD" == *$'\n'* || "$FTP_PASSWORD" == *$'\r'* ]]; then
echo "Production FTPS credentials contain unsupported control characters." >&2
exit 2
fi
if [[ ! "$FTP_HOST" =~ ^[A-Za-z0-9.-]+(:[0-9]{1,5})?$ ]]; then
echo "PRODUCTION_FTP_HOST is invalid." >&2
exit 2
fi
normalized_ftp_path="${FTP_PATH#/}"
normalized_ftp_path="${normalized_ftp_path%/}"
if [[ ! "$normalized_ftp_path" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$ ]]; then
echo "PRODUCTION_FTP_PATH must contain only safe path components." >&2
exit 2
fi
if [[ ! "$WEBROOT" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "PRODUCTION_CPANEL_WEBROOT must be one safe path component." >&2
exit 2
fi
if [[ ! "$RUN_ID" =~ ^[A-Za-z0-9._-]+$ || ! "$RUN_ATTEMPT" =~ ^[0-9]+$ ]]; then
echo "GitHub run identity is invalid." >&2
exit 2
fi
if [[ ! -f "$SOURCE_HTACCESS" ]] || ! grep -Eq '^DirectoryIndex[[:space:]]+index\.html[[:space:]]*$' "$SOURCE_HTACCESS"; then
echo "The reviewed source .htaccess must declare DirectoryIndex index.html." >&2
exit 2
fi
if ! command -v lftp >/dev/null 2>&1; then
echo "lftp is required." >&2
exit 2
fi
mkdir -p "$REPORT_DIR"
original="$REPORT_DIR/original.htaccess"
remote_part_copy="$REPORT_DIR/uploaded-part.htaccess"
pre_activation_copy="$REPORT_DIR/pre-activation.htaccess"
active_copy="$REPORT_DIR/active.htaccess"
backup_name=".htaccess.before-directory-index-${RUN_ID}-${RUN_ATTEMPT}"
part_name=".htaccess.directory-index-${RUN_ID}-${RUN_ATTEMPT}.part"
failed_name=".htaccess.failed-directory-index-${RUN_ID}-${RUN_ATTEMPT}"
lftp_quote() {
local value="${1//\'/\'\\\'\'}"
printf "'%s'" "$value"
}
write_connection() {
printf 'set cmd:fail-exit yes\n'
printf 'set cmd:interactive no\n'
printf 'set ftp:ssl-allow yes\n'
printf 'set ftp:ssl-force yes\n'
printf 'set ftp:ssl-protect-data yes\n'
printf 'set ssl:verify-certificate yes\n'
printf 'set ssl:check-hostname yes\n'
printf 'set net:max-retries 3\n'
printf 'set net:timeout 20\n'
printf 'open -u %s,%s %s\n' \
"$(lftp_quote "$FTP_USER")" \
"$(lftp_quote "$FTP_PASSWORD")" \
"$(lftp_quote "ftp://$FTP_HOST")"
printf 'cd %s\n' "$(lftp_quote "$FTP_PATH")"
IFS='/' read -r -a path_components <<< "$normalized_ftp_path"
for _component in "${path_components[@]}"; do
printf "cd '..'\n"
done
printf 'cd %s\n' "$(lftp_quote "$WEBROOT")"
}
run_lftp() {
{
write_connection
printf '%s\n' "$@"
printf 'bye\n'
} | lftp -f /dev/stdin
}
write_report() {
local state="$1"
local original_sha source_sha active_sha has_directory_index
original_sha="$(sha256sum "$original" | awk '{print $1}')"
source_sha="$(sha256sum "$SOURCE_HTACCESS" | awk '{print $1}')"
active_sha=""
if [[ -f "$active_copy" ]]; then
active_sha="$(sha256sum "$active_copy" | awk '{print $1}')"
fi
has_directory_index=false
if grep -Eq '^DirectoryIndex[[:space:]]+index\.html[[:space:]]*$' "$original"; then
has_directory_index=true
fi
REPORT_STATE="$state" \
REPORT_WEBROOT="$WEBROOT" \
REPORT_BACKUP="$backup_name" \
REPORT_ORIGINAL_SHA="$original_sha" \
REPORT_SOURCE_SHA="$source_sha" \
REPORT_ACTIVE_SHA="$active_sha" \
REPORT_HAS_DIRECTORY_INDEX="$has_directory_index" \
node --input-type=module <<'NODE' > "$REPORT_DIR/report.json"
const report = {
state: process.env.REPORT_STATE,
webroot: process.env.REPORT_WEBROOT,
backup: process.env.REPORT_BACKUP,
originalSha256: process.env.REPORT_ORIGINAL_SHA,
expectedSha256: process.env.REPORT_SOURCE_SHA,
activeSha256: process.env.REPORT_ACTIVE_SHA || null,
originalHasDirectoryIndex: process.env.REPORT_HAS_DIRECTORY_INDEX === "true",
};
console.log(`${JSON.stringify(report, null, 2)}\n`);
NODE
}
verify_live() {
VERIFY_BASE_URL="$FRONTEND_URL" VERIFY_RUN_ID="$RUN_ID" node --input-type=module <<'NODE'
const baseUrl = new URL(process.env.VERIFY_BASE_URL);
const checks = ["/", "/index.html", "/release-manifest.json", "/guest/book/wash"];
for (const path of checks) {
const url = new URL(path, baseUrl);
url.searchParams.set("root-repair", process.env.VERIFY_RUN_ID);
const response = await fetch(url, { headers: { Accept: "text/html,application/json" } });
if (!response.ok) throw new Error(`${path} returned HTTP ${response.status}`);
const body = await response.text();
if ((path === "/" || path === "/index.html" || path === "/guest/book/wash") &&
(!body.includes('<div id="app"') || body.includes("Index of /"))) {
throw new Error(`${path} did not render the Vue application shell`);
}
}
console.log("Live root, index, manifest, and deep route verified.");
NODE
}
run_lftp "get $(lftp_quote '.htaccess') -o $(lftp_quote "$original")"
write_report "audited"
if [[ "$MODE" == "audit" ]]; then
cat "$REPORT_DIR/report.json"
exit 0
fi
source_sha="$(sha256sum "$SOURCE_HTACCESS" | awk '{print $1}')"
original_sha="$(sha256sum "$original" | awk '{print $1}')"
if [[ "$source_sha" == "$original_sha" ]]; then
cp "$original" "$active_copy"
verify_live
write_report "already-current"
cat "$REPORT_DIR/report.json"
exit 0
fi
run_lftp \
"put $(lftp_quote "$SOURCE_HTACCESS") -o $(lftp_quote "$part_name")" \
"get $(lftp_quote "$part_name") -o $(lftp_quote "$remote_part_copy")" \
"get $(lftp_quote '.htaccess') -o $(lftp_quote "$pre_activation_copy")"
if [[ "$(sha256sum "$remote_part_copy" | awk '{print $1}')" != "$source_sha" ]]; then
echo "Uploaded .htaccess staging file failed checksum verification." >&2
exit 1
fi
if [[ "$(sha256sum "$pre_activation_copy" | awk '{print $1}')" != "$original_sha" ]]; then
echo "Live .htaccess changed after audit; refusing to overwrite it." >&2
exit 1
fi
if ! run_lftp "mv $(lftp_quote '.htaccess') $(lftp_quote "$backup_name")"; then
echo "Could not retain the original .htaccess; no activation was attempted." >&2
exit 1
fi
if ! run_lftp "mv $(lftp_quote "$part_name") $(lftp_quote '.htaccess')"; then
run_lftp "mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
echo "FTPS activation failed; rollback was attempted." >&2
exit 1
fi
if ! run_lftp "get $(lftp_quote '.htaccess') -o $(lftp_quote "$active_copy")"; then
run_lftp \
"mv $(lftp_quote '.htaccess') $(lftp_quote "$failed_name")" \
"mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
echo "Could not verify the active .htaccess; rollback was attempted." >&2
exit 1
fi
if [[ "$(sha256sum "$active_copy" | awk '{print $1}')" != "$source_sha" ]]; then
run_lftp \
"mv $(lftp_quote '.htaccess') $(lftp_quote "$failed_name")" \
"mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
echo "Active .htaccess checksum mismatched; rollback was attempted." >&2
exit 1
fi
if ! verify_live; then
run_lftp \
"mv $(lftp_quote '.htaccess') $(lftp_quote "$failed_name")" \
"mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
echo "Live verification failed; the original .htaccess was restored." >&2
exit 1
fi
write_report "repaired"
cat "$REPORT_DIR/report.json"