#!/usr/bin/env bash # songbird-deploy-version: 0.12.4 set -uo pipefail COLOR_RESET="" COLOR_LOG="" COLOR_WARN="" COLOR_ERROR="" COLOR_SUCCESS="" if [[ -z "${NO_COLOR:-}" && ( "${FORCE_COLOR:-0}" != "0" || ( "${TERM:-}" != "dumb" && ( -t 1 || -t 2 ) ) ) ]]; then COLOR_RESET=$'\033[0m' COLOR_LOG=$'\033[1;38;2;42;186;130m' COLOR_WARN=$'\033[1;33m' COLOR_ERROR=$'\033[1;31m' COLOR_SUCCESS=$'\033[1;32m' fi handle_exit() { local exit_code=$? if [[ "$exit_code" -eq 0 ]]; then if [[ "${SKIP_CLEAR_ON_EXIT:-0}" != "1" ]]; then clear fi return 0 fi printf "\n%b[SONGBIRD] Script exited with code %s.%b\n" "$COLOR_ERROR" "$exit_code" "$COLOR_RESET" >&2 if [[ -f "$LOG_FILE" ]]; then printf "%b[SONGBIRD] Review the log at %s for the last recorded step.%b\n" "$COLOR_ERROR" "$LOG_FILE" "$COLOR_RESET" >&2 fi } handle_interrupt() { handle_exit exit 130 } trap 'handle_interrupt' INT TERM trap 'handle_exit' EXIT APP_NAME="songbird" INSTALL_DIR="/opt/songbird" LOG_FILE="/opt/songbird/logs/install.log" DATA_COMMAND_LAUNCHER="${DATA_COMMAND_LAUNCHER:-${INSTALL_DIR}/scripts/run-data-command.sh}" REPO_URL="${REPO_URL:-https://github.com/bllackbull/Songbird.git}" SERVICE_USER="songbird" SERVICE_GROUP="songbird" SERVICE_NAME="songbird" SERVICE_FILE="/etc/systemd/system/songbird.service" WORKER_SERVICE_NAME="songbird-worker" WORKER_SERVICE_FILE="/etc/systemd/system/songbird-worker.service" LEGO_RENEW_SERVICE_FILE="/etc/systemd/system/songbird-lego-renew.service" LEGO_RENEW_TIMER_FILE="/etc/systemd/system/songbird-lego-renew.timer" NGINX_SITE_FILE="/etc/nginx/sites-available/songbird" NGINX_ENABLED_FILE="/etc/nginx/sites-enabled/songbird" DEFAULT_SERVER_PORT="5174" DEFAULT_CLIENT_PORT="80" DEFAULT_WORKER_PORT="8080" DEFAULT_FILE_UPLOAD="true" DEFAULT_FILE_UPLOAD_MAX_SIZE_MB="25" DEFAULT_MAX_UPLOAD_MB="75" DEFAULT_RETENTION_DAYS="7" DEFAULT_TEXT_RETENTION_DAYS="0" DEFAULT_SIGN_UP="true" DEFAULT_CHAT_VOICE_WAVEFORM_MAX_DECODE_MB="5" DEFAULT_CHAT_VOICE_WAVEFORM_MAX_DECODE_SECONDS="480" NODE_MAJOR="24" SCRIPT_REMOTE_URL="${SCRIPT_REMOTE_URL:-https://raw.githubusercontent.com/bllackbull/Songbird/main/scripts/install.sh}" LOG_LINES="${LOG_LINES:-100}" CERT_INSTALL_DIR="/etc/ssl/songbird" ACME_WEBROOT="/var/lib/songbird/certbot" LEGO_STATE_DIR="/var/lib/songbird/lego" LEGO_BIN="/usr/local/bin/lego" GLOBAL_COMMAND_PATH="/usr/local/bin/songbird-deploy" SONGBIRD_SOCIAL_GITHUB="https://github.com/bllackbull/Songbird" SONGBIRD_SOCIAL_TELEGRAM="https://t.me/songbirdapp" SONGBIRD_SOCIAL_WEBSITE="https://chat.songbird.website/invite/songbird" SONGBIRD_NOWPAYMENTS="https://nowpayments.io/donation/blackbull" # Mirror URLs MIRROR_NODESOURCE="${MIRROR_NODESOURCE:-}" MIRROR_APT_EXTRA="${MIRROR_APT_EXTRA:-}" MIRROR_NPM="${MIRROR_NPM:-}" SUDO="" OS_ID="" OS_ID_LIKE="" DEPLOY_MODE="ip" DOMAIN_NAMES=() DOMAIN_GROUPS=() MANUAL_CERT_GROUP_FULLCHAIN_PATHS=() MANUAL_CERT_GROUP_PRIVKEY_PATHS=() CERTBOT_EMAIL="" DEFAULT_DB_CLIENT="sqlite3" DEFAULT_POSTGRES_HOST="127.0.0.1" DEFAULT_POSTGRES_PORT="5432" DEFAULT_POSTGRES_DB="songbird" DEFAULT_POSTGRES_USER="postgres" DEFAULT_POSTGRES_PASSWORD="postgres" DEFAULT_POSTGRES_URL="" DEFAULT_POSTGRES_SSL="false" POSTGRES_URL="" POSTGRES_SSL="false" SERVER_PORT="$DEFAULT_SERVER_PORT" CLIENT_PORT="$DEFAULT_CLIENT_PORT" WORKER_PORT="$DEFAULT_WORKER_PORT" FILE_UPLOAD="$DEFAULT_FILE_UPLOAD" MAX_UPLOAD_MB="$DEFAULT_MAX_UPLOAD_MB" RETENTION_DAYS="$DEFAULT_RETENTION_DAYS" TEXT_RETENTION_DAYS="$DEFAULT_TEXT_RETENTION_DAYS" ACCOUNT_CREATION="$DEFAULT_SIGN_UP" NGINX_SERVER_NAME="_" CURRENT_ENV_FILE="" PROMPT_FD=0 PROMPT_FD_OUT=1 DB_BACKUP_PATH="" RESTORE_BACKUP_QUIET="no" LAST_UNZIP_OUTPUT="" LAST_UNZIP_STATUS=0 SOURCE_MODE="" SOURCE_ZIP_PATH="" CERT_MODE="http" CERTBOT_IP_ADDRESS="" MANUAL_CERT_FULLCHAIN_PATH="" MANUAL_CERT_PRIVKEY_PATH="" NODE_EXEC_PATH="" NPM_EXEC_PATH="" SKIP_CLEAR_ON_EXIT="0" MENU_ACTION_PAUSED="0" resolve_current_script_path() { local source_hint="${BASH_SOURCE[0]:-$0}" if [[ -z "$source_hint" ]]; then return 1 fi if [[ "$source_hint" != /* ]]; then source_hint="$(pwd)/$source_hint" fi if [[ -f "$source_hint" ]]; then printf "%s" "$source_hint" return 0 fi return 1 } read_first_line() { local file_path="$1" if [[ ! -f "$file_path" ]]; then return 1 fi head -n 1 "$file_path" | tr -d '\r\n' } read_version_file_value() { local version_file="$1" local version="" version="$(read_first_line "$version_file" 2>/dev/null || true)" version="$(printf "%s" "$version" | xargs)" if [[ -z "$version" ]]; then return 1 fi printf "%s" "$version" } read_script_version_header() { local file_path="$1" if [[ ! -f "$file_path" ]]; then return 1 fi local header="" header="$(grep -E '^# songbird-deploy-version:' "$file_path" | head -n 1 | cut -d ':' -f 2- | xargs || true)" if [[ -z "$header" || "$header" == "auto" ]]; then return 1 fi printf "%s" "$header" } resolve_script_version_for_path() { local script_path="$1" local header_version="" header_version="$(read_script_version_header "$script_path" 2>/dev/null || true)" if [[ -n "$header_version" ]]; then printf "%s" "$header_version" return 0 fi local script_dir="" script_dir="$(cd -- "$(dirname -- "$script_path")" && pwd -P 2>/dev/null || true)" local repo_root="" if [[ -n "$script_dir" ]]; then repo_root="$(cd -- "$script_dir/.." && pwd -P 2>/dev/null || true)" fi if [[ -n "$repo_root" && -f "$repo_root/VERSION" ]]; then read_version_file_value "$repo_root/VERSION" return $? fi if [[ -f "$INSTALL_DIR/VERSION" ]]; then read_version_file_value "$INSTALL_DIR/VERSION" return $? fi return 1 } CURRENT_SCRIPT_PATH="$(resolve_current_script_path 2>/dev/null || true)" SCRIPT_VERSION="$(resolve_script_version_for_path "$CURRENT_SCRIPT_PATH" 2>/dev/null || printf "0.0.0")" LAST_GLOBAL_COMMAND_VERSION="" log() { local timestamp="$(date '+%Y-%m-%d %H:%M:%S')" local log_dir="$(dirname "$LOG_FILE")" printf "%b[SONGBIRD] %s%b\n" "$COLOR_LOG" "$1" "$COLOR_RESET" if [[ -d "$log_dir" ]]; then printf "[%s] [SONGBIRD] %s\n" "$timestamp" "$1" >> "$LOG_FILE" 2>/dev/null || true fi } ensure_log_dir() { local log_dir="$(dirname "$LOG_FILE")" if [[ ! -d "$log_dir" ]]; then run_as_root mkdir -p "$log_dir" || return 1 log "Created log directory: $log_dir" fi } warn() { local timestamp="$(date '+%Y-%m-%d %H:%M:%S')" printf "%b[%s] WARNING: %s%b\n" "$COLOR_WARN" "SONGBIRD" "$*" "$COLOR_RESET" >&2 if [[ -f "$LOG_FILE" ]]; then printf "[%s] [SONGBIRD] WARNING: %s\n" "$timestamp" "$*" >> "$LOG_FILE" 2>/dev/null || true fi } fail() { local timestamp="$(date '+%Y-%m-%d %H:%M:%S')" printf "%b[%s] ERROR: %s%b\n" "$COLOR_ERROR" "SONGBIRD" "$*" "$COLOR_RESET" >&2 if [[ -f "$LOG_FILE" ]]; then printf "[%s] [SONGBIRD] ERROR: %s\n" "$timestamp" "$*" >> "$LOG_FILE" 2>/dev/null || true fi exit 1 } terminal_columns() { local cols="" if cols="$(tput cols 2>/dev/null)" && [[ "$cols" =~ ^[0-9]+$ ]] && (( cols > 0 )); then printf "%s" "$cols" return 0 fi if [[ "${COLUMNS:-}" =~ ^[0-9]+$ ]] && (( COLUMNS > 0 )); then printf "%s" "$COLUMNS" return 0 fi printf "80" } wrap_success_line() { local text="$1" local width="$2" if (( width < 1 )); then printf "%s\n" "$text" return 0 fi if [[ -z "$text" ]]; then printf "\n" return 0 fi local remaining="$text" while (( ${#remaining} > width )); do local chunk="${remaining:0:width}" local break_pos=-1 local idx=0 for (( idx=${#chunk}-1; idx>=0; idx-- )); do if [[ "${chunk:idx:1}" == " " ]]; then break_pos="$idx" break fi done if (( break_pos > 0 )); then printf "%s\n" "${remaining:0:break_pos}" remaining="${remaining:break_pos+1}" while [[ "$remaining" == " "* ]]; do remaining="${remaining:1}" done else printf "%s\n" "$chunk" remaining="${remaining:width}" fi done printf "%s\n" "$remaining" } print_plain_success_block() { local title="$1" shift || true local lines=("$@") local line="" printf "\n%b[SONGBIRD] %s%b\n" "$COLOR_SUCCESS" "$title" "$COLOR_RESET" for line in "${lines[@]}"; do if [[ -z "$line" ]]; then printf "\n" else printf "%b%s%b\n" "$COLOR_SUCCESS" "$line" "$COLOR_RESET" fi done printf "\n" } print_success_frame() { local title="$1" shift || true local lines=("$@") local terminal_width="" terminal_width="$(terminal_columns)" local frame_width=$((terminal_width - 4)) if (( frame_width < 40 )); then print_plain_success_block "$title" "${lines[@]}" if [[ -f "$LOG_FILE" ]]; then local timestamp="$(date '+%Y-%m-%d %H:%M:%S')" printf "[%s] [SONGBIRD] %s\n" "$timestamp" "$title" >> "$LOG_FILE" 2>/dev/null || true local line="" for line in "${lines[@]}"; do printf "[%s] [SONGBIRD] %s\n" "$timestamp" "$line" >> "$LOG_FILE" 2>/dev/null || true done fi return 0 fi if (( frame_width > 100 )); then frame_width=100 fi local content_width=$((frame_width - 4)) local wrapped_lines=() local line="" local segment="" for line in "${lines[@]}"; do while IFS= read -r segment; do wrapped_lines+=("$segment") done < <(wrap_success_line "$line" "$content_width") done local border="" printf -v border '%*s' "$((content_width + 2))" "" border="${border// /-}" printf "\n%b+%s+%b\n" "$COLOR_SUCCESS" "$border" "$COLOR_RESET" while IFS= read -r segment; do printf "%b| %-*s |%b\n" "$COLOR_SUCCESS" "$content_width" "$segment" "$COLOR_RESET" done < <(wrap_success_line "$title" "$content_width") printf "%b+%s+%b\n" "$COLOR_SUCCESS" "$border" "$COLOR_RESET" for line in "${wrapped_lines[@]}"; do printf "%b| %-*s |%b\n" "$COLOR_SUCCESS" "$content_width" "$line" "$COLOR_RESET" done printf "%b+%s+%b\n\n" "$COLOR_SUCCESS" "$border" "$COLOR_RESET" if [[ -f "$LOG_FILE" ]]; then local timestamp="$(date '+%Y-%m-%d %H:%M:%S')" printf "[%s] [SONGBIRD] %s\n" "$timestamp" "$title" >> "$LOG_FILE" 2>/dev/null || true for line in "${lines[@]}"; do printf "[%s] [SONGBIRD] %s\n" "$timestamp" "$line" >> "$LOG_FILE" 2>/dev/null || true done fi } show_deployment_success_frame() { local action="$1" local title="" local lines=() case "$action" in install) title="Songbird Installation Complete" lines+=("Songbird has been installed successfully.") lines+=("") lines+=("App:") if [[ "$CERT_MODE" == "http" ]]; then if [[ "$DEPLOY_MODE" == "domain" ]]; then local d="" for d in "${DOMAIN_NAMES[@]}"; do lines+=("Visit: http://${d}:${CLIENT_PORT}") done else lines+=("Visit: http://:${CLIENT_PORT}") fi elif [[ "$CERT_MODE" == "selfsigned" ]]; then local visit_ip="${CERTBOT_IP_ADDRESS:-}" [[ "$DEPLOY_MODE" == "domain" && "${#DOMAIN_NAMES[@]}" -gt 0 ]] && visit_ip="${DOMAIN_NAMES[0]}" if [[ "$CLIENT_PORT" == "443" ]]; then lines+=("Visit: https://${visit_ip}") else lines+=("Visit: https://${visit_ip}:${CLIENT_PORT}") fi lines+=("Note: Self-signed cert โ€” accept the browser security warning to proceed.") elif [[ "$DEPLOY_MODE" == "domain" ]]; then local d="" for d in "${DOMAIN_NAMES[@]}"; do if [[ "$CLIENT_PORT" == "443" ]]; then lines+=("Visit: https://${d}") else lines+=("Visit: https://${d}:${CLIENT_PORT}") fi done else local visit_ip="${CERTBOT_IP_ADDRESS:-}" if [[ "$CERT_MODE" == "files" ]]; then visit_ip="" fi if [[ "$CLIENT_PORT" == "443" ]]; then lines+=("Visit: https://${visit_ip}") else lines+=("Visit: https://${visit_ip}:${CLIENT_PORT}") fi fi ;; update) title="Songbird Update Complete" lines+=("Songbird has been updated successfully.") lines+=("Service restarted and nginx reloaded.") ;; *) title="Songbird Success" lines+=("Operation completed successfully.") ;; esac lines+=("") lines+=("Links:") lines+=("GitHub: ${SONGBIRD_SOCIAL_GITHUB}") lines+=("Telegram: ${SONGBIRD_SOCIAL_TELEGRAM}") lines+=("Main Server Channel: ${SONGBIRD_SOCIAL_WEBSITE}") lines+=("Support: ${SONGBIRD_NOWPAYMENTS}") print_success_frame "$title" "${lines[@]}" } have_cmd() { command -v "$1" >/dev/null 2>&1 } press_enter_to_continue() { MENU_ACTION_PAUSED="1" printf "\nPress Enter to return to the main menu..." >&$PROMPT_FD_OUT if ! IFS= read -r -u "$PROMPT_FD" _; then _="" fi } run_menu_action() { local action="$1" shift || true MENU_ACTION_PAUSED="0" "$action" "$@" local status=$? if [[ "$status" -eq 0 ]]; then return 0 fi if [[ "${MENU_ACTION_PAUSED:-0}" != "1" ]]; then warn "Action failed. Review ${LOG_FILE} for details, then retry from the menu." press_enter_to_continue fi return "$status" } run_silent() { local output # Append command being run to log file if [[ -f "$LOG_FILE" ]]; then printf "[%s] Running: %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE" 2>/dev/null || true fi if ! output="$("$@" 2>&1)"; then # Log the failure if [[ -f "$LOG_FILE" ]]; then printf "[%s] FAILED: %s\n%s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" "$output" >> "$LOG_FILE" 2>/dev/null || true fi # Show error to user printf "\n%b[ERROR] Command failed: %s%b\n" "$COLOR_ERROR" "$*" "$COLOR_RESET" printf "%s\n" "$output" return 1 else # Log success + output if [[ -f "$LOG_FILE" ]]; then printf "[%s] SUCCESS: %s\n%s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" "$output" >> "$LOG_FILE" 2>/dev/null || true fi fi } run_logged_quiet() { local output if [[ -f "$LOG_FILE" ]]; then printf "[%s] Running: %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE" 2>/dev/null || true fi if ! output="$("$@" 2>&1)"; then if [[ -f "$LOG_FILE" ]]; then printf "[%s] FAILED: %s\n%s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" "$output" >> "$LOG_FILE" 2>/dev/null || true fi return 1 fi if [[ -f "$LOG_FILE" ]]; then printf "[%s] SUCCESS: %s\n%s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" "$output" >> "$LOG_FILE" 2>/dev/null || true fi } run_unzip_capture() { local output="" local status=0 if output="$(run_as_root "$@" &1)"; then status=0 else status=$? fi LAST_UNZIP_OUTPUT="$output" LAST_UNZIP_STATUS="$status" [[ "$status" -eq 0 ]] } output_looks_password_related() { local text="" text="$(printf "%s" "${1:-}" | tr '[:upper:]' '[:lower:]')" [[ "$text" == *password* || "$text" == *encrypted* || "$text" == *"unable to get password"* || "$text" == *"incorrect password"* || "$text" == *"skipping:"* ]] } run_as_root() { if [[ -n "$SUDO" ]]; then $SUDO "$@" else "$@" fi } run_as_root_output() { if [[ -n "$SUDO" ]]; then $SUDO "$@" else "$@" fi } bootstrap_data_command_launcher() { local launcher_dir="" local launcher_source="" local remote_script_url="" local remote_launcher_url="" local temporary_launcher="" launcher_dir="$(dirname "$DATA_COMMAND_LAUNCHER")" if [[ -n "$CURRENT_SCRIPT_PATH" ]]; then launcher_source="$(dirname "$CURRENT_SCRIPT_PATH")/run-data-command.sh" fi if [[ -f "$launcher_source" ]]; then log "Installing missing data command launcher..." run_silent run_as_root mkdir -p "$launcher_dir" || return 1 run_silent run_as_root install -m 755 "$launcher_source" "$DATA_COMMAND_LAUNCHER" || return 1 return 0 fi remote_script_url="${SCRIPT_REMOTE_URL%%\?*}" remote_script_url="${remote_script_url%%\#*}" remote_launcher_url="${remote_script_url%/*}/run-data-command.sh" temporary_launcher="$(mktemp)" || return 1 if ! curl -fsSL "$remote_launcher_url" > "$temporary_launcher"; then rm -f "$temporary_launcher" warn "Data command launcher source is unavailable: ${remote_launcher_url}" return 1 fi if [[ ! -s "$temporary_launcher" ]] || ! /bin/bash -n "$temporary_launcher"; then rm -f "$temporary_launcher" warn "Downloaded data command launcher is invalid: ${remote_launcher_url}" return 1 fi log "Installing missing data command launcher..." run_silent run_as_root mkdir -p "$launcher_dir" || { rm -f "$temporary_launcher" return 1 } run_silent run_as_root install -m 755 "$temporary_launcher" "$DATA_COMMAND_LAUNCHER" || { rm -f "$temporary_launcher" return 1 } rm -f "$temporary_launcher" } run_data_command() { if [[ -L "$DATA_COMMAND_LAUNCHER" ]]; then warn "Data command launcher must not be a symlink: ${DATA_COMMAND_LAUNCHER}" return 1 fi if [[ ! -x "$DATA_COMMAND_LAUNCHER" ]]; then if [[ -f "$DATA_COMMAND_LAUNCHER" ]]; then log "Restoring execute permission on data command launcher..." run_silent run_as_root chmod 755 "$DATA_COMMAND_LAUNCHER" || { warn "Data command launcher is not executable: ${DATA_COMMAND_LAUNCHER}" return 1 } else bootstrap_data_command_launcher || { warn "Data command launcher not found or not executable: ${DATA_COMMAND_LAUNCHER}" return 1 } fi fi if [[ -n "$SUDO" ]]; then $SUDO "$DATA_COMMAND_LAUNCHER" "$@" else "$DATA_COMMAND_LAUNCHER" "$@" fi } run_in_install_dir() { local path_prefix="" local path_export="" path_prefix="$(node_tools_path_prefix)" if [[ -n "$path_prefix" ]]; then path_export="export PATH=$(printf '%q' "$path_prefix"):\$PATH; " fi run_silent run_as_root bash -lc "cd '$INSTALL_DIR' && ${path_export}$*" } run_in_install_dir_output() { local path_prefix="" local path_export="" path_prefix="$(node_tools_path_prefix)" if [[ -n "$path_prefix" ]]; then path_export="export PATH=$(printf '%q' "$path_prefix"):\$PATH; " fi if [[ -n "$SUDO" ]]; then $SUDO bash -lc "cd '$INSTALL_DIR' && ${path_export}$*" else bash -lc "cd '$INSTALL_DIR' && ${path_export}$*" fi } dir_has_entries() { local target_dir="$1" local first_entry="" first_entry="$(run_as_root_output find "$target_dir" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null || true)" [[ -n "$first_entry" ]] } init_prompt_io() { if [[ -r /dev/tty && -w /dev/tty ]]; then exec 3/dev/tty PROMPT_FD=3 PROMPT_FD_OUT=4 return 0 fi if [[ -t 0 ]]; then PROMPT_FD=0 PROMPT_FD_OUT=1 return 0 fi fail "No interactive TTY detected. Run this script in an interactive shell." } prompt_read() { local __prompt_text="$1" local __prompt_result_var="$2" local __prompt_value="" printf "%s" "$__prompt_text" >&$PROMPT_FD_OUT if ! IFS= read -r -u "$PROMPT_FD" __prompt_value; then __prompt_value="" fi printf -v "$__prompt_result_var" "%s" "$__prompt_value" } prompt_non_empty() { local prompt="$1" local value="" while true; do prompt_read "$prompt: " value value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" if [[ -n "$value" ]]; then printf "%s" "$value" return 0 fi printf "Please provide a value.\n" done } prompt_secret() { local prompt="$1" local value="" while true; do printf "%s: " "$prompt" >&$PROMPT_FD_OUT if ! IFS= read -ers -u "$PROMPT_FD" value; then value="" fi printf "\n" >&$PROMPT_FD_OUT value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" if [[ -n "$value" ]]; then printf "%s" "$value" return 0 fi printf "Please provide a value.\n" done } prompt_secret_optional() { local prompt="$1" local value="" printf "%s: " "$prompt" >&$PROMPT_FD_OUT if ! IFS= read -ers -u "$PROMPT_FD" value; then value="" fi printf "\n" >&$PROMPT_FD_OUT value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" printf "%s" "$value" } prompt_yes_no() { local prompt="$1" local default="$2" local value="" while true; do prompt_read "$prompt [y/n] (default: $default): " value value="$(printf "%s" "$value" | tr '[:upper:]' '[:lower:]')" if [[ -z "$value" ]]; then value="$default" fi case "$value" in y|yes) printf "yes"; return 0 ;; n|no) printf "no"; return 0 ;; *) printf "Please answer y or n.\n" ;; esac done } prompt_port() { local value="" while true; do prompt_read "Enter server port (default: $DEFAULT_SERVER_PORT): " value if [[ -z "$value" ]]; then printf "%s" "$DEFAULT_SERVER_PORT" return 0 fi if [[ "$value" =~ ^[0-9]+$ ]] && (( value >= 1 && value <= 65535 )); then printf "%s" "$value" return 0 fi printf "Port must be an integer between 1 and 65535.\n" done } prompt_client_port() { local default_port="${1:-$DEFAULT_CLIENT_PORT}" local value="" while true; do prompt_read "Enter client (nginx) port (default: $default_port): " value if [[ -z "$value" ]]; then printf "%s" "$default_port" return 0 fi if [[ "$value" =~ ^[0-9]+$ ]] && (( value >= 1 && value <= 65535 )); then printf "%s" "$value" return 0 fi printf "Port must be an integer between 1 and 65535.\n" done } prompt_worker_port() { local default_port="${1:-$DEFAULT_WORKER_PORT}" local value="" while true; do prompt_read "Enter media worker port (default: $default_port): " value if [[ -z "$value" ]]; then printf "%s" "$default_port" return 0 fi if [[ "$value" =~ ^[0-9]+$ ]] && (( value >= 1 && value <= 65535 )); then if [[ "$value" == "$SERVER_PORT" || "$value" == "$CLIENT_PORT" ]]; then printf "Media worker port cannot conflict with server port (%s) or client port (%s).\n" "$SERVER_PORT" "$CLIENT_PORT" continue fi printf "%s" "$value" return 0 fi printf "Port must be an integer between 1 and 65535.\n" done } normalize_path_input() { local value="$1" if [[ "$value" == "~"* ]]; then printf "%s" "${value/#\~/$HOME}" return 0 fi printf "%s" "$value" } strip_surrounding_quotes() { local value="$1" # Strip nested layers (e.g. '"0.11.4"') so copy-pasted quoting still resolves. # A lone quote can never be a surrounding pair; guard the substring below. while (( ${#value} >= 2 )); do local first="${value:0:1}" local last="${value: -1}" if [[ ( "$first" == "\"" && "$last" == "\"" ) || ( "$first" == "'" && "$last" == "'" ) ]]; then value="${value:1:${#value}-2}" else break fi done printf "%s" "$value" } strip_carriage_returns() { local value="$1" printf "%s" "$value" | tr -d '\r' } # Canonical sanitizer: CR-strip -> trim -> unquote -> trim. sanitize_input_value() { local value="$1" value="$(strip_carriage_returns "$value")" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" value="$(strip_surrounding_quotes "$value")" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" printf "%s" "$value" } file_exists_path() { local path="$1" if [[ -f "$path" ]]; then return 0 fi if [[ -n "$SUDO" ]]; then $SUDO test -f "$path" return $? fi return 1 } resolve_file_path() { local raw="$1" local value="" value="$(sanitize_input_value "$raw")" value="$(normalize_path_input "$value")" if [[ -z "$value" ]]; then return 1 fi if [[ "$value" == /* ]]; then if file_exists_path "$value"; then printf "%s" "$value" return 0 fi return 1 fi local candidates=( "$PWD/$value" "$HOME/$value" "/root/$value" "/$value" ) local candidate="" for candidate in "${candidates[@]}"; do if file_exists_path "$candidate"; then printf "%s" "$candidate" return 0 fi done return 1 } clear_mirror_values() { MIRROR_NODESOURCE="" MIRROR_APT_EXTRA="" MIRROR_NPM="" } configure_mirrors_menu() { while true; do clear show_banner printf "\n" printf "Configure Mirrors\n" printf $'1) ๐Ÿ”— Set NodeSource mirror (current: %s)\n' "${MIRROR_NODESOURCE:-}" printf $'2) ๐Ÿ”— Set apt mirror source (current: %s)\n' "${MIRROR_APT_EXTRA:-}" printf $'3) ๐Ÿ”— Set npm registry mirror (current: %s)\n' "${MIRROR_NPM:-}" printf $'4) ๐Ÿ”„๏ธ Restore defaults (clear mirrors)\n' printf $'5) โ†ฉ๏ธ Go back\n' printf $'0) ๐Ÿšช Exit\n\n' prompt_read "Choose an option [0-5]: " choice case "$choice" in 1) local val="" prompt_read "NodeSource mirror base URL (blank to clear): " val val="${val#"${val%%[![:space:]]*}"}" val="${val%"${val##*[![:space:]]}"}" MIRROR_NODESOURCE="$val" ;; 2) local val="" prompt_read "Extra apt source line for packages (blank to clear): " val val="${val#"${val%%[![:space:]]*}"}" val="${val%"${val##*[![:space:]]}"}" MIRROR_APT_EXTRA="$val" ;; 3) local val="" prompt_read "npm registry mirror URL (blank to clear): " val val="${val#"${val%%[![:space:]]*}"}" val="${val%"${val##*[![:space:]]}"}" MIRROR_NPM="$val" ;; 4) clear_mirror_values ;; 5) return ;; 0) exit 0 ;; *) printf "Invalid choice. Select a number from 0 to 5.\n" ;; esac done } prompt_source_mode() { local mode="" while true; do printf "\n%bSource Mode%b\n" "$COLOR_LOG" "$COLOR_RESET" printf " 1) GitHub\n" printf " 2) Offline\n" prompt_read "Choose an option [1-2]: " mode mode="${mode#"${mode%%[![:space:]]*}"}" mode="${mode%"${mode##*[![:space:]]}"}" case "$mode" in 1) SOURCE_MODE="github" break ;; 2) SOURCE_MODE="offline" break ;; *) printf "Choose 1 or 2.\n" ;; esac done } prompt_deploy_mode() { local mode="" while true; do printf "\n%bDeploy Mode%b\n" "$COLOR_LOG" "$COLOR_RESET" printf " 1) Domain\n" printf " 2) IP\n" prompt_read "Choose an option [1-2]: " mode mode="${mode#"${mode%%[![:space:]]*}"}" mode="${mode%"${mode##*[![:space:]]}"}" case "$mode" in 1) DEPLOY_MODE="domain" break ;; 2) DEPLOY_MODE="ip" break ;; *) printf "Choose 1 or 2.\n" ;; esac done } prompt_cert_mode() { local mode="" while true; do local option_one_label="Obtain certificate" if [[ "$DEPLOY_MODE" == "domain" ]]; then option_one_label="Obtain cert for domain" else option_one_label="Obtain 6-day cert for IP" fi printf "\n%bCertificate Mode%b\n" "$COLOR_LOG" "$COLOR_RESET" printf " 1) %s\n" "$option_one_label" printf " 2) TLS certificate files\n" printf " 3) HTTP only\n" printf " 4) Self-signed certificate (not recommended)\n" prompt_read "Choose an option [1-4]: " mode mode="${mode#"${mode%%[![:space:]]*}"}" mode="${mode%"${mode##*[![:space:]]}"}" case "$mode" in 1) CERT_MODE="certbot" break ;; 2) CERT_MODE="files" break ;; 3) CERT_MODE="http" break ;; 4) CERT_MODE="selfsigned" break ;; *) printf "Choose 1, 2, 3, or 4.\n" ;; esac done } database_backup_extension() { if [[ "$DB_CLIENT" == "postgres" || "$DB_CLIENT" == "postgresql" || "$DB_CLIENT" == "pg" ]]; then printf ".dump" return 0 fi printf ".db" } prompt_backup_path() { local extension="" local backup_input="" extension="$(database_backup_extension)" while true; do prompt_read "Enter the full path to the backup ${extension} file: " backup_input if [[ -z "$backup_input" ]]; then printf "Please provide a file path.\n" continue fi local resolved="" resolved="$(resolve_file_path "$backup_input")" || resolved="" if [[ -z "$resolved" ]]; then printf "File not found. Tried: %s\n" "$backup_input" continue fi if [[ "${resolved,,}" != *"${extension}" ]]; then printf "Backup file must be a %s file.\n" "$extension" continue fi printf "%s" "$resolved" return 0 done } select_backup_path() { local use_detected="" local detected="" detected="$(find_restore_backup_path)" || detected="" if [[ -n "$detected" ]]; then use_detected="$(prompt_yes_no "Use detected backup ${detected}?" "yes")" if [[ "$use_detected" == "yes" ]]; then printf "%s" "$detected" return 0 fi fi prompt_backup_path } prompt_database_choice() { DB_CLIENT="sqlite3" POSTGRES_HOST="$DEFAULT_POSTGRES_HOST" POSTGRES_PORT="$DEFAULT_POSTGRES_PORT" POSTGRES_DB="$DEFAULT_POSTGRES_DB" POSTGRES_USER="$DEFAULT_POSTGRES_USER" POSTGRES_PASSWORD="$DEFAULT_POSTGRES_PASSWORD" POSTGRES_URL="$DEFAULT_POSTGRES_URL" POSTGRES_SSL="$DEFAULT_POSTGRES_SSL" printf "\n%bSelect Database Engine:%b\n" "$COLOR_LOG" "$COLOR_RESET" printf " 1) SQLite (Default)\n" printf " 2) PostgreSQL (Recommended for +500 Users)\n" local choice="" while true; do prompt_read "Choice [1-2] (default 1): " choice choice="${choice:-1}" case "$choice" in 1) DB_CLIENT="sqlite3" break ;; 2) DB_CLIENT="postgres" prompt_read "PostgreSQL Host or URL [$DEFAULT_POSTGRES_HOST]: " POSTGRES_HOST POSTGRES_HOST="${POSTGRES_HOST#"${POSTGRES_HOST%%[![:space:]]*}"}" POSTGRES_HOST="${POSTGRES_HOST%"${POSTGRES_HOST##*[![:space:]]}"}" POSTGRES_HOST="${POSTGRES_HOST:-$DEFAULT_POSTGRES_HOST}" if [[ "$POSTGRES_HOST" =~ ^postgres(ql)?:// ]]; then POSTGRES_URL="$POSTGRES_HOST" if [[ "$POSTGRES_URL" == *"sslmode=require"* || "$POSTGRES_URL" == *"ssl=true"* ]]; then POSTGRES_SSL="true" fi break fi prompt_read "PostgreSQL Port [$DEFAULT_POSTGRES_PORT]: " POSTGRES_PORT POSTGRES_PORT="${POSTGRES_PORT#"${POSTGRES_PORT%%[![:space:]]*}"}" POSTGRES_PORT="${POSTGRES_PORT%"${POSTGRES_PORT##*[![:space:]]}"}" POSTGRES_PORT="${POSTGRES_PORT:-$DEFAULT_POSTGRES_PORT}" prompt_read "PostgreSQL Database Name [$DEFAULT_POSTGRES_DB]: " POSTGRES_DB POSTGRES_DB="${POSTGRES_DB#"${POSTGRES_DB%%[![:space:]]*}"}" POSTGRES_DB="${POSTGRES_DB%"${POSTGRES_DB##*[![:space:]]}"}" POSTGRES_DB="${POSTGRES_DB:-$DEFAULT_POSTGRES_DB}" prompt_read "PostgreSQL Username [$DEFAULT_POSTGRES_USER]: " POSTGRES_USER POSTGRES_USER="${POSTGRES_USER#"${POSTGRES_USER%%[![:space:]]*}"}" POSTGRES_USER="${POSTGRES_USER%"${POSTGRES_USER##*[![:space:]]}"}" POSTGRES_USER="${POSTGRES_USER:-$DEFAULT_POSTGRES_USER}" prompt_read "PostgreSQL Password [$DEFAULT_POSTGRES_PASSWORD]: " POSTGRES_PASSWORD POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-$DEFAULT_POSTGRES_PASSWORD}" local default_ssl="no" if [[ "$POSTGRES_HOST" != "127.0.0.1" && "$POSTGRES_HOST" != "0.0.0.0" && "$POSTGRES_HOST" != "localhost" ]]; then default_ssl="yes" fi if [[ "$(prompt_yes_no "Enable SSL for PostgreSQL connection?" "$default_ssl")" == "yes" ]]; then POSTGRES_SSL="true" else POSTGRES_SSL="false" fi break ;; *) printf "Choose 1 or 2.\n" ;; esac done } prompt_install_backup_restore() { local extension="" DB_BACKUP_PATH="" extension="$(database_backup_extension)" if [[ "$(prompt_yes_no "Restore database from a backup ${extension} file during installation?" "no")" != "yes" ]]; then return 0 fi DB_BACKUP_PATH="$(select_backup_path)" return 0 } detect_os() { [[ -f /etc/os-release ]] || fail "Cannot detect OS (/etc/os-release missing)." # shellcheck disable=SC1091 source /etc/os-release OS_ID="${ID:-}" OS_ID_LIKE="${ID_LIKE:-}" if [[ "$OS_ID" == "ubuntu" || "$OS_ID" == "debian" || "$OS_ID_LIKE" == *"debian"* ]]; then return 0 fi fail "Unsupported OS: ${PRETTY_NAME:-unknown}. This script supports Debian/Ubuntu only." } ensure_sudo() { if [[ "$EUID" -ne 0 ]]; then have_cmd sudo || fail "This script needs root privileges. Install sudo or run as root." SUDO="sudo" $SUDO -v fi } install_required_packages() { local required_pkgs=( git curl ca-certificates gnupg lsb-release build-essential nginx ffmpeg nano zip unzip ) if [[ "$DB_CLIENT" == "postgres" || "$DB_CLIENT" == "postgresql" || "$DB_CLIENT" == "pg" ]]; then required_pkgs+=(postgresql-client) fi if [[ "$DB_CLIENT" == "postgres" && -z "$POSTGRES_URL" && ( "$POSTGRES_HOST" == "127.0.0.1" || "$POSTGRES_HOST" == "0.0.0.0" || "$POSTGRES_HOST" == "localhost" ) ]]; then required_pkgs+=(postgresql postgresql-contrib) fi if [[ "$CERT_MODE" == "certbot" && "$DEPLOY_MODE" == "domain" ]]; then required_pkgs+=(python3-certbot-nginx) fi local missing_pkgs=() local pkg="" for pkg in "${required_pkgs[@]}"; do if ! dpkg -s "$pkg" >/dev/null 2>&1; then missing_pkgs+=("$pkg") fi done local codename="" codename="$(lsb_release -sc)" || return 1 if [[ -n "$MIRROR_APT_EXTRA" ]]; then log "Refreshing apt package index (temporary mirror: ${MIRROR_APT_EXTRA})..." if ! run_silent run_as_root apt-get update \ -o Dir::Etc::sourcelist=/dev/null \ -o Dir::Etc::sourceparts=/dev/null \ -o Dir::Etc::sourcelist=- \ -o Dir::Etc::sourceparts=- < 0 )); then log "Installing missing packages: ${missing_pkgs[*]}" run_silent run_as_root apt-get install -y --allow-downgrades "${missing_pkgs[@]}" || return 1 else log "All required base packages are already installed." fi } ensure_nodejs_from_nodesource() { if command -v node &>/dev/null; then local current_major current_major="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))')" if (( current_major >= NODE_MAJOR )); then log "Node.js ${current_major}.x already installed. Skipping." return 0 fi fi if [[ -n "$MIRROR_NODESOURCE" ]]; then # Detect tarball vs setup-script mirror by checking for .tar.gz suffix if [[ "$MIRROR_NODESOURCE" == *.tar.gz ]]; then log "Installing Node.js from tarball mirror: ${MIRROR_NODESOURCE}" local tmp_dir tmp_dir="$(mktemp -d)" local tarball="${tmp_dir}/node.tar.gz" curl -fsSL "$MIRROR_NODESOURCE" -o "$tarball" || { rm -rf "$tmp_dir" return 1 } local install_dir="/usr/local" run_silent run_as_root tar -xzf "$tarball" -C "$install_dir" --strip-components=1 || { rm -rf "$tmp_dir" return 1 } rm -rf "$tmp_dir" log "Node.js installed from tarball to ${install_dir}." return 0 else # NodeSource-compatible mirror: mirror base URL + /setup_XX.x local setup_url="${MIRROR_NODESOURCE%/}/setup_${NODE_MAJOR}.x" log "Installing Node.js ${NODE_MAJOR}.x via NodeSource-style mirror: ${setup_url}" if [[ -n "$SUDO" ]]; then curl -fsSL "$setup_url" | $SUDO -E bash - || return 1 else curl -fsSL "$setup_url" | bash - || return 1 fi run_silent run_as_root apt-get install -y nodejs || return 1 return 0 fi fi # Default: official NodeSource local setup_url="https://deb.nodesource.com/setup_${NODE_MAJOR}.x" log "Installing Node.js ${NODE_MAJOR}.x via NodeSource..." if [[ -n "$SUDO" ]]; then curl -fsSL "$setup_url" | $SUDO -E bash - || return 1 else curl -fsSL "$setup_url" | bash - || return 1 fi run_silent run_as_root apt-get install -y nodejs || return 1 } resolve_node_exec_path() { local node_path="" if have_cmd node; then node_path="$(command -v node)" elif have_cmd nodejs; then node_path="$(command -v nodejs)" elif [[ -f "$SERVICE_FILE" ]]; then node_path="$(grep -E '^ExecStart=' "$SERVICE_FILE" | head -n 1 | sed -E 's/^ExecStart=([^[:space:]]+).*/\1/' || true)" if [[ ! -x "$node_path" ]]; then node_path="" fi fi if [[ -z "$node_path" ]]; then warn "Node.js executable not found in PATH after installation." return 1 fi NODE_EXEC_PATH="$node_path" log "Using Node.js executable for systemd service: ${NODE_EXEC_PATH}" } resolve_npm_exec_path() { local npm_path="" if have_cmd npm; then npm_path="$(command -v npm)" fi if [[ -z "$npm_path" && -z "$NODE_EXEC_PATH" ]]; then resolve_node_exec_path >/dev/null 2>&1 || true fi if [[ -z "$npm_path" && -n "$NODE_EXEC_PATH" && -x "$(dirname "$NODE_EXEC_PATH")/npm" ]]; then npm_path="$(dirname "$NODE_EXEC_PATH")/npm" elif [[ -z "$npm_path" && -x /usr/bin/npm ]]; then npm_path="/usr/bin/npm" elif [[ -z "$npm_path" && -x /usr/local/bin/npm ]]; then npm_path="/usr/local/bin/npm" fi if [[ -z "$npm_path" ]]; then warn "npm executable not found. Install Node.js/npm or rerun the install/update flow." return 1 fi NPM_EXEC_PATH="$npm_path" } node_tools_path_prefix() { local dirs=() local dir="" local existing="" if [[ -z "$NODE_EXEC_PATH" ]]; then resolve_node_exec_path >/dev/null 2>&1 || true fi if [[ -z "$NPM_EXEC_PATH" ]]; then resolve_npm_exec_path >/dev/null 2>&1 || true fi if [[ -n "$NODE_EXEC_PATH" ]]; then dir="$(dirname "$NODE_EXEC_PATH")" [[ -d "$dir" ]] && dirs+=("$dir") fi if [[ -n "$NPM_EXEC_PATH" ]]; then dir="$(dirname "$NPM_EXEC_PATH")" [[ -d "$dir" ]] && dirs+=("$dir") fi local unique=() for dir in "${dirs[@]}"; do local seen="no" for existing in "${unique[@]}"; do if [[ "$existing" == "$dir" ]]; then seen="yes" break fi done [[ "$seen" == "no" ]] && unique+=("$dir") done local prefix="" for dir in "${unique[@]}"; do if [[ -z "$prefix" ]]; then prefix="$dir" else prefix="${prefix}:${dir}" fi done printf "%s" "$prefix" } ensure_service_user_exists() { # Create user if it doesn't exist if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then log "Creating dedicated system user: ${SERVICE_USER}" run_silent run_as_root useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" || return 1 else log "Service user ${SERVICE_USER} already exists." fi # Add the service user to groups needed for log access. # adm โ€” grants read access to /var/log/nginx/* (and other system logs) # systemd-journal โ€” grants access to journalctl without root local groups_to_add=() if getent group adm >/dev/null 2>&1; then groups_to_add+=("adm") fi if getent group systemd-journal >/dev/null 2>&1; then groups_to_add+=("systemd-journal") fi for grp in "${groups_to_add[@]}"; do if ! id -nG "$SERVICE_USER" 2>/dev/null | grep -qw "$grp"; then log "Adding ${SERVICE_USER} to group: ${grp}" run_silent run_as_root usermod -aG "$grp" "$SERVICE_USER" || true else log "User ${SERVICE_USER} already in group: ${grp}" fi done # Install a passwordless sudoers drop-in so the service process can # control its own systemd unit and read nginx/service logs via sudo -n. local sudoers_drop_in="/etc/sudoers.d/songbird" local sudoers_content="# Songbird โ€” allow the service user to control its own unit # and read privileged logs without a password. ${SERVICE_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl restart ${SERVICE_NAME} ${SERVICE_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl stop ${SERVICE_NAME} ${SERVICE_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl restart ${WORKER_SERVICE_NAME} ${SERVICE_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl stop ${WORKER_SERVICE_NAME} ${SERVICE_USER} ALL=(root) NOPASSWD: /usr/bin/journalctl -u ${SERVICE_NAME} * ${SERVICE_USER} ALL=(root) NOPASSWD: /usr/bin/journalctl -u ${WORKER_SERVICE_NAME} * ${SERVICE_USER} ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/error.log ${SERVICE_USER} ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/access.log ${SERVICE_USER} ALL=(root) NOPASSWD: /usr/bin/nginx -t ${SERVICE_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx " # Always validate and install/update the sudoers drop-in if run_as_root bash -c "printf '%s' $(printf '%q' "$sudoers_content") | visudo -c -f /dev/stdin" >/dev/null 2>&1; then # Check if the file exists and has the correct content local needs_update="yes" if [[ -f "$sudoers_drop_in" ]]; then local existing_content="" existing_content="$(run_as_root cat "$sudoers_drop_in" 2>/dev/null || true)" if [[ "$existing_content" == "$sudoers_content" ]]; then needs_update="no" log "Sudoers drop-in at ${sudoers_drop_in} is already up to date." fi fi if [[ "$needs_update" == "yes" ]]; then log "Installing sudoers drop-in at ${sudoers_drop_in}..." run_silent run_as_root bash -c "printf '%s' $(printf '%q' "$sudoers_content") > '$sudoers_drop_in'" run_silent run_as_root chmod 440 "$sudoers_drop_in" || true fi else warn "Could not validate sudoers drop-in โ€” skipping. Service control from the admin panel may not work." fi } map_lego_arch() { local machine="" machine="$(uname -m)" case "$machine" in x86_64|amd64) printf "amd64" ;; aarch64|arm64) printf "arm64" ;; armv7l|armv7) printf "armv7" ;; armv6l|armv6) printf "armv6" ;; i386|i686) printf "386" ;; *) warn "Unsupported architecture for lego binary install: ${machine}" return 1 ;; esac } lego_supports_shortlived_profile() { [[ -x "$LEGO_BIN" ]] || return 1 local help_text="" help_text="$("$LEGO_BIN" run --help 2>&1 || true)" [[ "$help_text" == *"--profile"* ]] } ensure_lego_installed() { if [[ -x "$LEGO_BIN" ]]; then if lego_supports_shortlived_profile; then log "lego binary already installed at ${LEGO_BIN}." return 0 fi warn "Existing lego binary at ${LEGO_BIN} does not support --profile; upgrading." fi local latest_url="" latest_url="$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/go-acme/lego/releases/latest)" || { warn "Unable to resolve latest lego release URL." return 1 } local latest_tag="${latest_url##*/}" if [[ -z "$latest_tag" ]]; then warn "Unable to determine latest lego release tag." return 1 fi local arch="" arch="$(map_lego_arch)" || return 1 local asset="lego_${latest_tag}_linux_${arch}.tar.gz" local download_url="https://github.com/go-acme/lego/releases/download/${latest_tag}/${asset}" local tmp_dir="" tmp_dir="$(mktemp -d)" log "Downloading lego ${latest_tag} for linux/${arch}..." curl -fsSL "$download_url" -o "${tmp_dir}/${asset}" || { run_silent run_as_root rm -rf "$tmp_dir" warn "Unable to download lego binary from ${download_url}" return 1 } run_silent run_as_root tar -xzf "${tmp_dir}/${asset}" -C "$tmp_dir" || { run_silent run_as_root rm -rf "$tmp_dir" return 1 } run_silent run_as_root install -m 755 "${tmp_dir}/lego" "$LEGO_BIN" || { run_silent run_as_root rm -rf "$tmp_dir" return 1 } run_silent run_as_root rm -rf "$tmp_dir" || return 1 log "Installed lego at ${LEGO_BIN}." if ! lego_supports_shortlived_profile; then warn "Installed lego at ${LEGO_BIN} does not support --profile. Please verify the binary and try again." return 1 fi } clone_repo() { run_silent run_as_root mkdir -p "$INSTALL_DIR" || return 1 if run_as_root test -d "$INSTALL_DIR/.git"; then log "Repository exists at ${INSTALL_DIR}. Updating source..." run_in_install_dir "git fetch --all --prune" || return 1 run_in_install_dir "git checkout main" || return 1 run_in_install_dir "git pull --ff-only origin main" || return 1 return 0 fi if dir_has_entries "$INSTALL_DIR"; then if [[ "$(prompt_yes_no "${INSTALL_DIR} exists and is not empty. Delete it and re-clone from GitHub?" "no")" != "yes" ]]; then warn "Installation canceled. Clear ${INSTALL_DIR} or use offline mode." return 1 fi run_as_root rm -rf "$INSTALL_DIR" || return 1 run_silent run_as_root mkdir -p "$INSTALL_DIR" || return 1 fi log "Cloning Songbird repository..." run_silent run_as_root git clone "$REPO_URL" "$INSTALL_DIR" || return 1 } prepare_install_dir_for_offline() { run_silent run_as_root mkdir -p "$INSTALL_DIR" if dir_has_entries "$INSTALL_DIR"; then warn "${INSTALL_DIR} is not empty. Clear it before installation." press_enter_to_continue return 1 fi return 0 } find_offline_source_zip() { local zip_name="songbird.zip" local candidates=( "$(pwd)/${zip_name}" "$HOME/${zip_name}" "/root/${zip_name}" "/${zip_name}" ) local candidate="" for candidate in "${candidates[@]}"; do if file_exists_path "$candidate"; then printf "%s" "$candidate" return 0 fi done return 1 } find_restore_backup_path() { local extension="" extension="$(database_backup_extension)" local candidates=( "/opt/songbird/data/backups" "/root" ) local dir="" for dir in "${candidates[@]}"; do local found="" found="$(run_as_root_output bash -lc "find '$dir' -maxdepth 1 -type f -name 'songbird-backup-*${extension}' -printf '%T@\t%p\n' 2>/dev/null | sort -nr | head -1 | cut -f2-" | tr -d '\r\n')" || found="" if [[ -n "$found" && -f "$found" ]]; then printf "%s" "$found" return 0 fi done return 1 } resolve_offline_source_root() { local tmp_dir="$1" if [[ -f "$tmp_dir/package.json" && -d "$tmp_dir/server" && -d "$tmp_dir/client" && -d "$tmp_dir/worker" ]]; then printf "%s" "$tmp_dir" return 0 fi local match="" local dir="" while IFS= read -r dir; do [[ -z "$dir" ]] && continue if [[ -f "$dir/package.json" && -d "$dir/server" && -d "$dir/client" && -d "$dir/worker" ]]; then if [[ -n "$match" ]]; then return 1 fi match="$dir" fi done < <(find "$tmp_dir" -mindepth 1 -maxdepth 1 -type d) if [[ -n "$match" ]]; then printf "%s" "$match" return 0 fi return 1 } ensure_offline_source_ready() { local mode_label="$1" local zip_path="" zip_path="$(find_offline_source_zip)" || zip_path="" if [[ -z "$zip_path" ]]; then warn "Offline ${mode_label} requires songbird.zip to be available in the current directory, user home, /root, or /." press_enter_to_continue return 1 fi SOURCE_ZIP_PATH="$zip_path" return 0 } extract_offline_source_zip() { local zip_path="$1" local mode_label="$2" if ! have_cmd unzip; then warn "unzip is required for offline ${mode_label}s. Install it first and retry." return 1 fi local tmp_dir tmp_dir="$(mktemp -d)" if ! run_silent run_as_root unzip -q "$zip_path" -d "$tmp_dir"; then run_silent run_as_root rm -rf "$tmp_dir" return 1 fi local source_root source_root="$(resolve_offline_source_root "$tmp_dir")" || { run_silent run_as_root rm -rf "$tmp_dir" warn "Source zip does not appear to contain Songbird (missing server/client/worker/package.json)." return 1 } printf "%s|%s" "$tmp_dir" "$source_root" } read_version_value() { local version_file="$1" if [[ ! -f "$version_file" ]]; then return 1 fi local version="" version="$(head -n 1 "$version_file" | tr -d '\r\n' | xargs)" if [[ -z "$version" ]]; then return 1 fi printf "%s" "$version" } compare_offline_source_version() { local __cosv_source_root="$1" local __cosv_install_root="$2" local __cosv_op_or_var="${3:-}" local __cosv_source_version_file="${__cosv_source_root}/VERSION" local __cosv_install_version_file="${__cosv_install_root}/VERSION" local __cosv_source_version="" local __cosv_install_version="" __cosv_source_version="$(read_version_value "$__cosv_source_version_file")" || { warn "Offline source is missing VERSION. Skipping update." return 1 } __cosv_install_version="$(read_version_value "$__cosv_install_version_file")" || __cosv_install_version="" if [[ -z "$__cosv_install_version" ]]; then log "Installed app is missing VERSION. Treating offline source ${__cosv_source_version} as newer." case "$__cosv_op_or_var" in gt|newer|higher) return 0 ;; lt|lower|eq|same|equal) return 1 ;; "") printf "%s\n" "newer" return 0 ;; *) printf -v "$__cosv_op_or_var" "%s" "newer" return 0 ;; esac fi local __cosv_relation="" if dpkg --compare-versions "$__cosv_source_version" gt "$__cosv_install_version"; then log "Offline source version ${__cosv_source_version} is newer than installed version ${__cosv_install_version}." __cosv_relation="newer" elif dpkg --compare-versions "$__cosv_source_version" lt "$__cosv_install_version"; then log "Offline source version ${__cosv_source_version} is lower than installed version ${__cosv_install_version}." __cosv_relation="lower" elif dpkg --compare-versions "$__cosv_source_version" eq "$__cosv_install_version"; then log "Offline source version ${__cosv_source_version} is the same as installed version ${__cosv_install_version}." __cosv_relation="same" else return 1 fi case "$__cosv_op_or_var" in gt|newer|higher) [[ "$__cosv_relation" == "newer" ]] return $? ;; lt|lower) [[ "$__cosv_relation" == "lower" ]] return $? ;; eq|same|equal) [[ "$__cosv_relation" == "same" ]] return $? ;; "") printf "%s\n" "$__cosv_relation" return 0 ;; *) printf -v "$__cosv_op_or_var" "%s" "$__cosv_relation" return 0 ;; esac } offline_source_is_newer() { compare_offline_source_version "$1" "$2" "newer" } offline_source_is_lower() { compare_offline_source_version "$1" "$2" "lower" } offline_source_is_same() { compare_offline_source_version "$1" "$2" "same" } resolve_git_version_ref() { local version="$1" version="$(sanitize_input_value "$version")" if [[ -z "$version" ]]; then return 1 fi local escaped_ver="" escaped_ver="$(printf '%s' "$version" | tr -d "'")" local ref="" ref="$(run_in_install_dir_output "git rev-parse --verify --quiet 'refs/tags/${escaped_ver}'" 2>/dev/null | tr -d '\r\n')" if [[ -n "$ref" ]]; then printf "%s" "refs/tags/${escaped_ver}" return 0 fi ref="$(run_in_install_dir_output "git rev-parse --verify --quiet 'refs/tags/v${escaped_ver}'" 2>/dev/null | tr -d '\r\n')" if [[ -n "$ref" ]]; then printf "%s" "refs/tags/v${escaped_ver}" return 0 fi ref="$(run_in_install_dir_output "git rev-parse --verify --quiet '${escaped_ver}'" 2>/dev/null | tr -d '\r\n')" if [[ -n "$ref" ]]; then printf "%s" "${escaped_ver}" return 0 fi return 1 } install_source_from_zip() { local zip_path="$1" prepare_install_dir_for_offline || return 1 local extract_result="" extract_result="$(extract_offline_source_zip "$zip_path" "install")" || return 1 local tmp_dir="${extract_result%%|*}" local source_root="${extract_result#*|}" run_silent run_as_root cp -a "$source_root"/. "$INSTALL_DIR"/ || { run_silent run_as_root rm -rf "$tmp_dir" return 1 } if [[ -f "$source_root/.env.example" ]]; then run_silent run_as_root cp -a "$source_root/.env.example" "$INSTALL_DIR/.env.example" || { run_silent run_as_root rm -rf "$tmp_dir" return 1 } fi apply_ownership || { run_silent run_as_root rm -rf "$tmp_dir" return 1 } run_silent run_as_root rm -rf "$tmp_dir" || return 1 return 0 } update_source_from_zip() { local zip_path="$1" local extract_result="" extract_result="$(extract_offline_source_zip "$zip_path" "update")" || return 1 local tmp_dir="${extract_result%%|*}" local source_root="${extract_result#*|}" run_silent run_as_root cp -a "$source_root"/. "$INSTALL_DIR"/ || { run_silent run_as_root rm -rf "$tmp_dir" return 1 } if [[ -f "$source_root/.env.example" ]]; then run_silent run_as_root cp -a "$source_root/.env.example" "$INSTALL_DIR/.env.example" || { run_silent run_as_root rm -rf "$tmp_dir" return 1 } fi apply_ownership || { run_silent run_as_root rm -rf "$tmp_dir" return 1 } run_silent run_as_root rm -rf "$tmp_dir" || return 1 } install_songbird_dependencies() { local npm_registry_arg="" if [[ -n "$MIRROR_NPM" ]]; then npm_registry_arg="--registry $(printf '%q' "$MIRROR_NPM")" fi log "Installing server dependencies..." run_in_install_dir "npm ${npm_registry_arg} --prefix server install" || return 1 log "Installing worker dependencies..." run_in_install_dir "npm ${npm_registry_arg} --prefix worker install" || return 1 log "Installing client dependencies..." run_in_install_dir "npm ${npm_registry_arg} --prefix client install" || return 1 log "Building client..." run_in_install_dir "npm --prefix client run build" || return 1 } get_existing_env_value() { local key="$1" local default="$2" local env_file="$INSTALL_DIR/.env" if [[ ! -f "$env_file" ]]; then printf "%s" "$default" return 0 fi local existing existing="$(grep -E "^${key}=" "$env_file" | tail -n 1 | cut -d "=" -f 2- || true)" if [[ -z "$existing" ]]; then printf "%s" "$default" else printf "%s" "$existing" fi } get_existing_env_value_with_fallback() { local primary="$1" local fallback="$2" local default="$3" local env_file="$INSTALL_DIR/.env" if [[ ! -f "$env_file" ]]; then printf "%s" "$default" return 0 fi local existing existing="$(grep -E "^${primary}=" "$env_file" | tail -n 1 | cut -d "=" -f 2- || true)" if [[ -n "$existing" ]]; then printf "%s" "$existing" return 0 fi if [[ -n "$fallback" ]]; then existing="$(grep -E "^${fallback}=" "$env_file" | tail -n 1 | cut -d "=" -f 2- || true)" if [[ -n "$existing" ]]; then printf "%s" "$existing" return 0 fi fi printf "%s" "$default" } bytes_to_mb_rounded_up() { local bytes="$(printf "%s" "$1" | tr -d '[:space:]')" if [[ -z "$bytes" || ! "$bytes" =~ ^[0-9]+$ ]]; then return 1 fi printf "%s" "$(((bytes + 1048576 - 1) / 1048576))" } get_existing_env_mb_value_with_fallback() { local primary="$1" local legacy_bytes_key="$2" local default="$3" local env_file="$INSTALL_DIR/.env" if [[ ! -f "$env_file" ]]; then printf "%s" "$default" return 0 fi local existing existing="$(grep -E "^${primary}=" "$env_file" | tail -n 1 | cut -d "=" -f 2- || true)" if [[ -n "$existing" ]]; then printf "%s" "$existing" return 0 fi local legacy_bytes legacy_bytes="$(grep -E "^${legacy_bytes_key}=" "$env_file" | tail -n 1 | cut -d "=" -f 2- || true)" if [[ -n "$legacy_bytes" ]]; then local converted="" converted="$(bytes_to_mb_rounded_up "$legacy_bytes")" || converted="" if [[ -n "$converted" ]]; then printf "%s" "$converted" return 0 fi fi printf "%s" "$default" } replace_env_value() { local env_file="$1" local key="$2" local value="$3" local escaped escaped="$(printf '%s' "$value" | sed 's/[&/]/\\&/g')" if grep -qE "^${key}=" "$env_file"; then run_silent run_as_root sed -i "s|^${key}=.*|${key}=${escaped}|" "$env_file" else run_silent run_as_root bash -lc "printf '%s\n' '${key}=${escaped}' >> '$env_file'" fi } write_env_from_example() { local env_file="${INSTALL_DIR}/.env" write_env_fallback "$env_file" || return 1 CURRENT_ENV_FILE="$env_file" } # 32 random bytes as lowercase hex. generate_secret_hex() { local value="" value="$(od -An -tx1 -N32 /dev/urandom 2>/dev/null | tr -d ' \n' || true)" if [[ ! "$value" =~ ^[0-9a-f]{64}$ ]]; then return 1 fi printf "%s" "$value" } # 32 random bytes as unpadded base64url. generate_secret_base64url() { local value="" value="$(head -c 32 /dev/urandom 2>/dev/null | base64 2>/dev/null | tr -d '\n' | tr '+/' '-_' | tr -d '=' || true)" if [[ ! "$value" =~ ^[A-Za-z0-9_-]{43}$ ]]; then return 1 fi printf "%s" "$value" } # Query one secret from a PostgreSQL database. read_postgres_secret() { local key="$1" local value="" local postgres_url postgres_url="$(get_existing_env_value "POSTGRES_URL" "${POSTGRES_URL:-}")" local ssl_mode="prefer" if [[ "$(get_existing_env_value "POSTGRES_SSL" "${POSTGRES_SSL:-false}")" == "true" ]]; then ssl_mode="require" fi if [[ -n "$postgres_url" ]]; then value="$(psql "$postgres_url" -tA -c "SELECT value FROM app_settings WHERE key='${key}' LIMIT 1" 2>/dev/null || true)" else value="$(PGPASSWORD="$POSTGRES_PASSWORD" PGSSLMODE="$ssl_mode" psql \ -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" \ -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ -tA -c "SELECT value FROM app_settings WHERE key='${key}' LIMIT 1" 2>/dev/null || true)" fi value="$(printf "%s" "$value" | tr -d '\r\n ' || true)" if [[ -z "$value" ]]; then return 1 fi printf "%s" "$value" } # True when PostgreSQL is reachable. postgres_is_reachable() { local postgres_url postgres_url="$(get_existing_env_value "POSTGRES_URL" "${POSTGRES_URL:-}")" local ssl_mode="prefer" if [[ "$(get_existing_env_value "POSTGRES_SSL" "${POSTGRES_SSL:-false}")" == "true" ]]; then ssl_mode="require" fi if [[ -n "$postgres_url" ]]; then psql "$postgres_url" -tA -c "SELECT 1" >/dev/null 2>&1 else PGPASSWORD="$POSTGRES_PASSWORD" PGSSLMODE="$ssl_mode" psql \ -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" \ -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ -tA -c "SELECT 1" >/dev/null 2>&1 fi } # True when PostgreSQL has existing tables in the public schema. postgres_has_tables() { local count="" local postgres_url postgres_url="$(get_existing_env_value "POSTGRES_URL" "${POSTGRES_URL:-}")" local ssl_mode="prefer" if [[ "$(get_existing_env_value "POSTGRES_SSL" "${POSTGRES_SSL:-false}")" == "true" ]]; then ssl_mode="require" fi if [[ -n "$postgres_url" ]]; then count="$(psql "$postgres_url" -tA -c "SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' LIMIT 1" 2>/dev/null || true)" else count="$(PGPASSWORD="$POSTGRES_PASSWORD" PGSSLMODE="$ssl_mode" psql \ -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" \ -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ -tA -c "SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' LIMIT 1" 2>/dev/null || true)" fi count="$(printf "%s" "$count" | tr -d '\r\n ' || true)" [[ "$count" == "1" ]] } is_postgres_client() { [[ "$DB_CLIENT" == "postgres" || "$DB_CLIENT" == "postgresql" || "$DB_CLIENT" == "pg" ]] } # Ensure one secret exists in .env without ever rotating a live value. # Generation happens only when no keyed source exists anywhere: # - a non-empty .env value always wins (never rotate live secrets); # - a restored backup is authoritative (the server restores from the DB); # - a reachable database holding the key is adopted, not replaced; # - an existing database with tables is left for the server to restore from; # - otherwise the database must be provably fresh (absent sqlite file, or # reachable postgres with no tables). # Anything uncertain is left empty for the server, which applies the same ensure_env_secret() { local key="$1" local format="$2" local env_file="${INSTALL_DIR}/.env" local current="" current="$(get_existing_env_value "$key" "")" if [[ -n "$current" ]]; then return 0 fi local generated="" if is_postgres_client; then local db_value="" if db_value="$(read_postgres_secret "$key")"; then replace_env_value "$env_file" "$key" "$db_value" || return 1 log "Adopted existing ${key} from the database." return 0 fi if postgres_has_tables; then log "Existing PostgreSQL tables found; leaving ${key} for the server to restore from it." return 0 fi # Reachable and confirmed empty of tables means provably fresh. if postgres_is_reachable; then if [[ "$format" == "hex" ]]; then generated="$(generate_secret_hex || true)" else generated="$(generate_secret_base64url || true)" fi else log "PostgreSQL is not reachable; leaving ${key} for the server to resolve on first boot." return 0 fi else local data_dir="" data_dir="$(get_existing_env_value "DATA_DIR" "${INSTALL_DIR}/data")" if [[ -f "${data_dir}/songbird.db" ]]; then log "Existing SQLite database found; leaving ${key} for the server to restore from it." return 0 fi if [[ "$format" == "hex" ]]; then generated="$(generate_secret_hex || true)" else generated="$(generate_secret_base64url || true)" fi fi if [[ -z "$generated" ]]; then warn "Could not generate ${key}; leaving it for the server to resolve on first boot." return 0 fi replace_env_value "$env_file" "$key" "$generated" || return 1 log "Generated ${key} during install." return 0 } # Pre-generate server secrets into .env . pregenerate_missing_secrets() { if [[ -n "${DB_BACKUP_PATH:-}" ]]; then log "Backup was restored; leaving secrets for the server to restore from the database." return 0 fi ensure_env_secret "STORAGE_ENCRYPTION_KEY" "base64url" || true ensure_env_secret "WEBHOOK_SECRET" "hex" || true ensure_env_secret "ADMIN_API_TOKEN" "base64url" || true return 0 } write_env_fallback() { local env_file="$1" local existing_storage_encryption_key local existing_admin_api_token local existing_webhook_secret local existing_public_key local existing_private_key local existing_subject existing_storage_encryption_key="$(get_existing_env_value "STORAGE_ENCRYPTION_KEY" "")" existing_admin_api_token="$(get_existing_env_value "ADMIN_API_TOKEN" "")" existing_webhook_secret="$(get_existing_env_value "WEBHOOK_SECRET" "")" existing_public_key="$(get_existing_env_value "VAPID_PUBLIC_KEY" "")" existing_private_key="$(get_existing_env_value "VAPID_PRIVATE_KEY" "")" existing_subject="$(get_existing_env_value "VAPID_SUBJECT" "mailto:admin@example.com")" run_silent run_as_root bash -lc "cat > '$env_file' <<'EOF' # Server Configuration SERVER_PORT=${SERVER_PORT} CLIENT_PORT=${CLIENT_PORT} # Application Settings APP_ENV=production # Database Configuration DB_CLIENT=${DB_CLIENT:-sqlite3} POSTGRES_HOST=${POSTGRES_HOST:-127.0.0.1} POSTGRES_PORT=${POSTGRES_PORT:-5432} POSTGRES_DB=${POSTGRES_DB:-songbird} POSTGRES_USER=${POSTGRES_USER:-postgres} POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} POSTGRES_URL=${POSTGRES_URL:-} POSTGRES_SSL=${POSTGRES_SSL:-false} # Security & Encryption # Generated during install when safe to do so, otherwise auto-generated by # the server on first boot if left empty. Never edited while set: rotating # these would orphan encrypted data and desync the media worker. STORAGE_ENCRYPTION_KEY=${existing_storage_encryption_key} # Token gating the local-only admin endpoint. Same generation rules as above. ADMIN_API_TOKEN=${existing_admin_api_token} # Shared secret authenticating server <-> media worker callbacks. WEBHOOK_SECRET=${existing_webhook_secret} # Push Notifications VAPID_PUBLIC_KEY=${existing_public_key} VAPID_PRIVATE_KEY=${existing_private_key} VAPID_SUBJECT=${existing_subject} # Media Processing Worker WORKER_PORT=${WORKER_PORT} WORKER_URL=http://127.0.0.1:${WORKER_PORT} WEBHOOK_URL=http://127.0.0.1:${SERVER_PORT}/api/uploads/webhook/processed # NOTE: All other application settings (sign-up, file uploads, retention, # limits, remote channel, client tuning, push proxy, etc.) are configured from # the in-app Admin Panel after installation. EOF" || return 1 if [[ -n "$CERTBOT_EMAIL" ]]; then replace_env_value "$env_file" "VAPID_SUBJECT" "mailto:${CERTBOT_EMAIL}" || return 1 fi log "Wrote environment config to ${env_file}." } ensure_vapid_keys() { local env_file="${INSTALL_DIR}/.env" local public_key local private_key public_key="$(get_existing_env_value "VAPID_PUBLIC_KEY" "")" private_key="$(get_existing_env_value "VAPID_PRIVATE_KEY" "")" if [[ -n "$public_key" && -n "$private_key" ]]; then log "VAPID keys already present. Skipping generation." return 0 fi if [[ -n "${DB_BACKUP_PATH:-}" ]]; then log "Backup was restored; leaving VAPID keys for the server to restore from the database." return 0 fi if is_postgres_client && postgres_has_tables; then log "Existing PostgreSQL tables found; leaving VAPID keys for the server to restore from the database." return 0 fi local data_dir="" data_dir="$(get_existing_env_value "DATA_DIR" "${INSTALL_DIR}/data")" if ! is_postgres_client && [[ -f "${data_dir}/songbird.db" ]]; then log "Existing SQLite database found; leaving VAPID keys for the server to restore from the database." return 0 fi log "Generating VAPID keys..." local keys local path_prefix="" local path_export="" path_prefix="$(node_tools_path_prefix)" if [[ -n "$path_prefix" ]]; then path_export="export PATH=$(printf '%q' "$path_prefix"):\$PATH; " fi local stdout_file="" local stderr_file="" local generate_command="" stdout_file="$(mktemp)" || return 1 stderr_file="$(mktemp)" || { rm -f "$stdout_file" return 1 } generate_command="cd '$INSTALL_DIR/server' && ${path_export}node --input-type=module -e \"import pkg from 'web-push'; const { generateVAPIDKeys } = pkg; const k = generateVAPIDKeys(); console.log(k.publicKey); console.log(k.privateKey);\"" if [[ -f "$LOG_FILE" ]]; then printf "[%s] Running: generate VAPID keys\n" "$(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE" 2>/dev/null || true fi if ! run_as_root bash -lc "$generate_command" >"$stdout_file" 2>"$stderr_file"; then local vapid_error="" vapid_error="$(cat "$stderr_file" "$stdout_file" 2>/dev/null || true)" if [[ -f "$LOG_FILE" ]]; then printf "[%s] FAILED: generate VAPID keys\n%s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$vapid_error" >> "$LOG_FILE" 2>/dev/null || true fi [[ -n "$vapid_error" ]] && printf "%s\n" "$vapid_error" rm -f "$stdout_file" "$stderr_file" warn "Failed to generate VAPID keys. Make sure server dependencies are installed." return 1 fi keys="$(cat "$stdout_file" 2>/dev/null || true)" rm -f "$stdout_file" "$stderr_file" local new_public="" local new_private="" new_public="$(printf "%s\n" "$keys" | sed -n '1p' | tr -d '\r')" new_private="$(printf "%s\n" "$keys" | sed -n '2p' | tr -d '\r')" if [[ -z "$new_public" || -z "$new_private" ]]; then if [[ -f "$LOG_FILE" ]]; then printf "[%s] FAILED: generate VAPID keys\nExpected public/private key output, but received incomplete output. Raw key output redacted.\n" "$(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE" 2>/dev/null || true fi warn "VAPID key generation returned incomplete output." return 1 fi if [[ -f "$LOG_FILE" ]]; then printf "[%s] SUCCESS: generate VAPID keys\n(output redacted)\n" "$(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE" 2>/dev/null || true fi replace_env_value "$env_file" "VAPID_PUBLIC_KEY" "$new_public" || return 1 replace_env_value "$env_file" "VAPID_PRIVATE_KEY" "$new_private" || return 1 log "VAPID keys generated and saved to ${env_file}." } open_env_editor() { local env_file="$1" local editor_cmd="${EDITOR:-nano}" if ! have_cmd "$editor_cmd"; then editor_cmd="vi" fi log "Opening ${env_file} with ${editor_cmd}. Save and close to continue." if [[ -n "$SUDO" ]]; then $SUDO -t "$editor_cmd" "$env_file" else "$editor_cmd" "$env_file" fi clear } sync_values_from_env() { local env_file="$INSTALL_DIR/.env" SERVER_PORT="$(get_existing_env_value_with_fallback "SERVER_PORT" "PORT" "$DEFAULT_SERVER_PORT")" CLIENT_PORT="$(get_existing_env_value "CLIENT_PORT" "$DEFAULT_CLIENT_PORT")" FILE_UPLOAD="$(get_existing_env_value "FILE_UPLOAD" "$DEFAULT_FILE_UPLOAD")" MAX_UPLOAD_MB="$(get_existing_env_mb_value_with_fallback "FILE_UPLOAD_MAX_TOTAL_SIZE_MB" "FILE_UPLOAD_MAX_TOTAL_SIZE" "$DEFAULT_MAX_UPLOAD_MB")" RETENTION_DAYS="$(get_existing_env_value "MESSAGE_FILE_RETENTION" "$DEFAULT_RETENTION_DAYS")" TEXT_RETENTION_DAYS="$(get_existing_env_value "MESSAGE_TEXT_RETENTION" "$DEFAULT_TEXT_RETENTION_DAYS")" ACCOUNT_CREATION="$(get_existing_env_value_with_fallback "SIGN_UP" "ACCOUNT_CREATION" "$DEFAULT_SIGN_UP")" DB_CLIENT="$(get_existing_env_value "DB_CLIENT" "$DEFAULT_DB_CLIENT")" POSTGRES_HOST="$(get_existing_env_value "POSTGRES_HOST" "$DEFAULT_POSTGRES_HOST")" POSTGRES_PORT="$(get_existing_env_value "POSTGRES_PORT" "$DEFAULT_POSTGRES_PORT")" POSTGRES_DB="$(get_existing_env_value "POSTGRES_DB" "$DEFAULT_POSTGRES_DB")" POSTGRES_USER="$(get_existing_env_value "POSTGRES_USER" "$DEFAULT_POSTGRES_USER")" POSTGRES_PASSWORD="$(get_existing_env_value "POSTGRES_PASSWORD" "$DEFAULT_POSTGRES_PASSWORD")" POSTGRES_URL="$(get_existing_env_value "POSTGRES_URL" "$DEFAULT_POSTGRES_URL")" POSTGRES_SSL="$(get_existing_env_value "POSTGRES_SSL" "$DEFAULT_POSTGRES_SSL")" CURRENT_ENV_FILE="$env_file" } ensure_local_postgres_setup() { sync_values_from_env if [[ "$DB_CLIENT" != "postgres" && "$DB_CLIENT" != "postgresql" && "$DB_CLIENT" != "pg" ]]; then return 0 fi if [[ -n "$POSTGRES_URL" ]]; then if [[ "$POSTGRES_URL" != *"@127.0.0.1"* && "$POSTGRES_URL" != *"@localhost"* && "$POSTGRES_URL" != *"@0.0.0.0"* ]]; then return 0 fi fi if [[ "$POSTGRES_HOST" != "127.0.0.1" && "$POSTGRES_HOST" != "0.0.0.0" && "$POSTGRES_HOST" != "localhost" ]]; then return 0 fi log "Enabling local PostgreSQL service and database configuration..." if have_cmd systemctl; then run_silent run_as_root systemctl enable --now postgresql || true fi local pg_ready=false local i for (( i=1; i<=10; i++ )); do if run_as_root sudo -u postgres psql -c '\q' >/dev/null 2>&1; then pg_ready=true break fi sleep 1 done if [[ "$pg_ready" != "true" ]]; then warn "Local PostgreSQL server is not responding to psql." return 1 fi local safe_user safe_pass safe_db safe_user_literal safe_db_literal safe_user="$(printf '%s' "$POSTGRES_USER" | sed 's/"/""/g')" safe_pass="$(printf '%s' "$POSTGRES_PASSWORD" | sed "s/'/''/g")" safe_db="$(printf '%s' "$POSTGRES_DB" | sed 's/"/""/g')" safe_user_literal="$(printf '%s' "$POSTGRES_USER" | sed "s/'/''/g")" safe_db_literal="$(printf '%s' "$POSTGRES_DB" | sed "s/'/''/g")" # Create role if it does not exist local role_exists role_exists="$(run_as_root sudo -u postgres psql -t -A -c \ "SELECT 1 FROM pg_roles WHERE rolname = '${safe_user_literal}';" 2>/dev/null || true)" role_exists="$(printf '%s' "$role_exists" | tr -d '[:space:]')" if [[ "$role_exists" != "1" ]]; then run_silent run_as_root sudo -u postgres psql -v ON_ERROR_STOP=1 -c \ "CREATE ROLE \"${safe_user}\" WITH LOGIN PASSWORD '${safe_pass}';" || return 1 else # Ensure password is set / updated for existing role run_silent run_as_root sudo -u postgres psql -v ON_ERROR_STOP=1 -c \ "ALTER ROLE \"${safe_user}\" WITH LOGIN PASSWORD '${safe_pass}';" || return 1 fi # Create database if it does not exist local db_exists db_exists="$(run_as_root sudo -u postgres psql -t -A -c \ "SELECT 1 FROM pg_database WHERE datname = '${safe_db_literal}';" 2>/dev/null || true)" db_exists="$(printf '%s' "$db_exists" | tr -d '[:space:]')" if [[ "$db_exists" != "1" ]]; then run_silent run_as_root sudo -u postgres psql -v ON_ERROR_STOP=1 -c \ "CREATE DATABASE \"${safe_db}\" OWNER \"${safe_user}\";" || return 1 fi # Grant privileges on database to role run_silent run_as_root sudo -u postgres psql -v ON_ERROR_STOP=1 -c \ "GRANT ALL PRIVILEGES ON DATABASE \"${safe_db}\" TO \"${safe_user}\";" || return 1 # Ensure role has permissions on public schema in the target database (PostgreSQL 15+) run_silent run_as_root sudo -u postgres psql -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -c \ "GRANT ALL ON SCHEMA public TO \"${safe_user}\";" 2>/dev/null || true log "Local PostgreSQL database '${POSTGRES_DB}' and user '${POSTGRES_USER}' configured." } parse_domain_input() { local raw="$1" DOMAIN_NAMES=() DOMAIN_GROUPS=() local seen_names="" local IFS=',' local d for d in $raw; do d="${d#"${d%%[![:space:]]*}"}" d="${d%"${d##*[![:space:]]}"}" d="${d#http://}" d="${d#https://}" d="${d%%/*}" d="${d%.}" d="$(printf '%s' "$d" | tr '[:upper:]' '[:lower:]')" if [[ -n "$d" && "$seen_names" != *"|${d}|"* ]]; then DOMAIN_NAMES+=("$d") seen_names="${seen_names}|${d}|" fi done # Restore default word-splitting before build_domain_groups runs. Its # www/apex pairing logic relies on space-joined membership checks # (" ${DOMAIN_NAMES[*]} "), which break while IFS is still a comma. unset IFS build_domain_groups NGINX_SERVER_NAME="${DOMAIN_NAMES[*]}" } build_domain_groups() { DOMAIN_GROUPS=() if (( ${#DOMAIN_NAMES[@]} == 0 )); then return 0 fi local grouped_names="|" local domain="" for domain in "${DOMAIN_NAMES[@]}"; do if [[ "$grouped_names" == *"|${domain}|"* ]]; then continue fi local partner="" if [[ "$domain" == www.* ]]; then partner="${domain#www.}" if [[ -n "$partner" && "$grouped_names" != *"|${partner}|"* && " ${DOMAIN_NAMES[*]} " == *" ${partner} "* ]]; then DOMAIN_GROUPS+=("${partner},${domain}") grouped_names="${grouped_names}${partner}|${domain}|" continue fi else partner="www.${domain}" if [[ "$grouped_names" != *"|${partner}|"* && " ${DOMAIN_NAMES[*]} " == *" ${partner} "* ]]; then DOMAIN_GROUPS+=("${domain},${partner}") grouped_names="${grouped_names}${domain}|${partner}|" continue fi fi DOMAIN_GROUPS+=("${domain}") grouped_names="${grouped_names}${domain}|" done } group_csv_to_space_list() { local csv="${1:-}" printf '%s' "${csv//,/ }" } group_primary_domain() { local csv="${1:-}" printf '%s' "${csv%%,*}" } manual_cert_group_install_dir() { local group_csv="${1:-}" local primary_domain="" primary_domain="$(group_primary_domain "$group_csv")" printf '%s/%s' "$CERT_INSTALL_DIR" "$primary_domain" } manual_group_fullchain_path() { local group_csv="${1:-}" printf '%s/fullchain.pem' "$(manual_cert_group_install_dir "$group_csv")" } manual_group_privkey_path() { local group_csv="${1:-}" printf '%s/privkey.pem' "$(manual_cert_group_install_dir "$group_csv")" } log_domain_groups() { if (( ${#DOMAIN_GROUPS[@]} == 0 )); then return 0 fi local idx=1 local group_csv="" for group_csv in "${DOMAIN_GROUPS[@]}"; do log "Detected domain group ${idx}: $(group_csv_to_space_list "$group_csv")" idx=$((idx + 1)) done } build_domain_cert_groups() { local __result_var="$1" local serialized="" if [[ ! "$__result_var" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then return 1 fi eval "$__result_var=()" if [[ "$DEPLOY_MODE" != "domain" ]]; then return 0 fi serialized="$(declare -p DOMAIN_GROUPS)" serialized="${serialized#declare -a DOMAIN_GROUPS=}" eval "$__result_var=$serialized" } collect_install_options() { prompt_deploy_mode if [[ "$DEPLOY_MODE" == "domain" ]]; then local raw_domains="" while true; do prompt_read "Enter your domain(s), comma-separated (e.g. example.com, www.example.com): " raw_domains raw_domains="${raw_domains#"${raw_domains%%[![:space:]]*}"}" raw_domains="${raw_domains%"${raw_domains##*[![:space:]]}"}" if [[ -n "$raw_domains" ]]; then parse_domain_input "$raw_domains" if (( ${#DOMAIN_NAMES[@]} > 0 )); then log_domain_groups break fi fi printf "Please enter at least one domain.\n" done else NGINX_SERVER_NAME="_" fi prompt_cert_mode case "$CERT_MODE" in certbot) CERTBOT_EMAIL="$(prompt_non_empty "Enter email for Let's Encrypt renewal notices")" if [[ "$DEPLOY_MODE" == "ip" ]]; then CERTBOT_IP_ADDRESS="$(prompt_non_empty "Enter the public IP address for the TLS certificate")" NGINX_SERVER_NAME="$CERTBOT_IP_ADDRESS" fi ;; files) MANUAL_CERT_GROUP_FULLCHAIN_PATHS=() MANUAL_CERT_GROUP_PRIVKEY_PATHS=() if [[ "$DEPLOY_MODE" == "domain" ]]; then local group_csv="" for group_csv in "${DOMAIN_GROUPS[@]}"; do while true; do MANUAL_CERT_FULLCHAIN_PATH="$(prompt_non_empty "Enter path to fullchain.pem for $(group_csv_to_space_list "$group_csv")")" MANUAL_CERT_FULLCHAIN_PATH="$(resolve_file_path "$MANUAL_CERT_FULLCHAIN_PATH")" || MANUAL_CERT_FULLCHAIN_PATH="" if [[ -n "$MANUAL_CERT_FULLCHAIN_PATH" ]]; then MANUAL_CERT_GROUP_FULLCHAIN_PATHS+=("$MANUAL_CERT_FULLCHAIN_PATH") break fi printf "Could not find that fullchain.pem file.\n" done while true; do MANUAL_CERT_PRIVKEY_PATH="$(prompt_non_empty "Enter path to privkey.pem for $(group_csv_to_space_list "$group_csv")")" MANUAL_CERT_PRIVKEY_PATH="$(resolve_file_path "$MANUAL_CERT_PRIVKEY_PATH")" || MANUAL_CERT_PRIVKEY_PATH="" if [[ -n "$MANUAL_CERT_PRIVKEY_PATH" ]]; then MANUAL_CERT_GROUP_PRIVKEY_PATHS+=("$MANUAL_CERT_PRIVKEY_PATH") break fi printf "Could not find that privkey.pem file.\n" done done else while true; do MANUAL_CERT_FULLCHAIN_PATH="$(prompt_non_empty "Enter path to fullchain.pem")" MANUAL_CERT_FULLCHAIN_PATH="$(resolve_file_path "$MANUAL_CERT_FULLCHAIN_PATH")" || MANUAL_CERT_FULLCHAIN_PATH="" if [[ -n "$MANUAL_CERT_FULLCHAIN_PATH" ]]; then break fi printf "Could not find that fullchain.pem file.\n" done while true; do MANUAL_CERT_PRIVKEY_PATH="$(prompt_non_empty "Enter path to privkey.pem")" MANUAL_CERT_PRIVKEY_PATH="$(resolve_file_path "$MANUAL_CERT_PRIVKEY_PATH")" || MANUAL_CERT_PRIVKEY_PATH="" if [[ -n "$MANUAL_CERT_PRIVKEY_PATH" ]]; then break fi printf "Could not find that privkey.pem file.\n" done fi ;; esac SERVER_PORT="$(prompt_port)" if [[ "$CERT_MODE" == "http" ]]; then CLIENT_PORT="$(prompt_client_port "$DEFAULT_CLIENT_PORT")" else CLIENT_PORT="$(prompt_client_port "443")" fi WORKER_PORT="$(prompt_worker_port "$DEFAULT_WORKER_PORT")" if [[ "$CERT_MODE" != "http" ]]; then log "Using HTTP redirect on port 80 and HTTPS on port ${CLIENT_PORT}." fi } write_full_env_with_defaults() { run_silent run_as_root mkdir -p "$INSTALL_DIR" || return 1 write_env_from_example || return 1 } apply_ownership() { log "Ensuring service user file ownership..." run_silent run_as_root chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "$INSTALL_DIR" || return 1 run_silent run_as_root git config --global --add safe.directory "$INSTALL_DIR" || return 1 } # Wait for the server to materialize generated secrets on first boot. wait_for_server_secrets() { local env_file="${INSTALL_DIR}/.env" local timeout="${1:-90}" local waited=0 log "Waiting for the server to generate shared secrets..." while (( waited < timeout )); do if run_as_root test -f "$env_file" \ && run_as_root grep -qE '^STORAGE_ENCRYPTION_KEY=.+' "$env_file" 2>/dev/null \ && run_as_root grep -qE '^WEBHOOK_SECRET=.+' "$env_file" 2>/dev/null; then log "Shared secrets are present. Starting the media worker." return 0 fi sleep 1 waited=$((waited + 1)) done warn "Timed out waiting for secrets in ${env_file}. The media worker will start without encryption keys and pick them up on its next restart once the server is healthy." return 1 } configure_systemd_service() { log "Creating systemd service at ${SERVICE_FILE}..." if [[ -z "$NODE_EXEC_PATH" ]]; then resolve_node_exec_path || return 1 fi if ! run_silent run_as_root tee "$SERVICE_FILE" >/dev/null </dev/null </dev/null < "$FULLCHAIN_FILE" if [[ -f "$ISSUER_FILE" ]]; then cat "$ISSUER_FILE" >> "$FULLCHAIN_FILE" fi ' || { warn "Unable to assemble fullchain.pem from lego certificate files." return 1 } run_silent run_as_root cp -Lf "$key_file" "$CERT_INSTALL_DIR/privkey.pem" || return 1 run_silent run_as_root chmod 644 "$CERT_INSTALL_DIR/fullchain.pem" || return 1 run_silent run_as_root chmod 600 "$CERT_INSTALL_DIR/privkey.pem" || return 1 install_ssl_files_into_nginx "$CERT_INSTALL_DIR/fullchain.pem" "$CERT_INSTALL_DIR/privkey.pem" || return 1 } configure_lego_ip_ssl() { if [[ -z "$CERTBOT_IP_ADDRESS" ]]; then warn "Missing public IP address for Certbot IP certificate setup." return 1 fi ensure_lego_installed || return 1 run_silent run_as_root mkdir -p "$ACME_WEBROOT/.well-known/acme-challenge" || return 1 run_silent run_as_root mkdir -p "$LEGO_STATE_DIR" || return 1 log "Requesting 6-day IP certificate for ${CERTBOT_IP_ADDRESS} with lego..." run_logged_quiet run_as_root "$LEGO_BIN" \ --accept-tos \ --email "$CERTBOT_EMAIL" \ --path "$LEGO_STATE_DIR" \ --disable-cn \ --http \ --http.webroot "$ACME_WEBROOT" \ --domains "$CERTBOT_IP_ADDRESS" \ run \ --profile shortlived || { warn "ERROR: lego failed for IP ${CERTBOT_IP_ADDRESS}" return 1 } install_lego_certificate_files "$CERTBOT_IP_ADDRESS" || return 1 configure_lego_renewal_timer || return 1 log "Nginx SSL configured for IP ${CERTBOT_IP_ADDRESS}." } configure_lego_renewal_timer() { if [[ ! -x "$LEGO_BIN" ]]; then warn "lego binary not found at ${LEGO_BIN}." return 1 fi log "Creating lego renewal service at ${LEGO_RENEW_SERVICE_FILE}..." if ! run_silent run_as_root tee "$LEGO_RENEW_SERVICE_FILE" >/dev/null < "${CERT_INSTALL_DIR}/fullchain.pem" && if [[ -f "${LEGO_STATE_DIR}/certificates/${CERTBOT_IP_ADDRESS}.issuer.crt" ]]; then cat "${LEGO_STATE_DIR}/certificates/${CERTBOT_IP_ADDRESS}.issuer.crt" >> "${CERT_INSTALL_DIR}/fullchain.pem"; fi && cp -Lf "${LEGO_STATE_DIR}/certificates/${CERTBOT_IP_ADDRESS}.key" "${CERT_INSTALL_DIR}/privkey.pem" && chmod 644 "${CERT_INSTALL_DIR}/fullchain.pem" && chmod 600 "${CERT_INSTALL_DIR}/privkey.pem" && systemctl reload nginx' [Install] WantedBy=multi-user.target EOF then return 1 fi log "Creating lego renewal timer at ${LEGO_RENEW_TIMER_FILE}..." if ! run_silent run_as_root tee "$LEGO_RENEW_TIMER_FILE" >/dev/null </certs/ by default, but for a bare-metal install # we want the certs in CERT_INSTALL_DIR (/etc/ssl/songbird). We temporarily # override the output directory by symlinking, then copy to the final location. run_silent run_as_root mkdir -p "$CERT_INSTALL_DIR" || return 1 # Run gen-certs.sh โ€” it handles openssl installation automatically if missing. # It writes certs/cert.pem and certs/key.pem relative to the repo root. run_silent run_as_root bash "$gen_script" "$cn" || return 1 local repo_cert="${INSTALL_DIR}/certs/cert.pem" local repo_key="${INSTALL_DIR}/certs/key.pem" run_silent run_as_root cp -f "$repo_cert" "$CERT_INSTALL_DIR/fullchain.pem" || return 1 run_silent run_as_root cp -f "$repo_key" "$CERT_INSTALL_DIR/privkey.pem" || return 1 run_silent run_as_root chmod 644 "$CERT_INSTALL_DIR/fullchain.pem" || return 1 run_silent run_as_root chmod 600 "$CERT_INSTALL_DIR/privkey.pem" || return 1 install_ssl_files_into_nginx "$CERT_INSTALL_DIR/fullchain.pem" "$CERT_INSTALL_DIR/privkey.pem" || return 1 log "Self-signed certificate installed." } configure_ssl_if_needed() { case "$CERT_MODE" in http) log "HTTP-only mode selected. Skipping TLS setup." return 0 ;; selfsigned) log "Generating self-signed TLS certificate..." configure_selfsigned_ssl || return 1 return 0 ;; files) log "Installing TLS certificate files into nginx..." configure_manual_ssl_files || return 1 return 0 ;; esac if [[ "$DEPLOY_MODE" == "ip" ]]; then configure_lego_ip_ssl || return 1 return 0 fi local domain_group_csv="" for domain_group_csv in "${DOMAIN_GROUPS[@]}"; do local cert_name cert_name="$(group_primary_domain "$domain_group_csv")" local group_names=() local domain_entry="" IFS=',' read -r -a group_names <<< "$domain_group_csv" local group_covered="yes" local cert_details="" cert_details="$(run_as_root certbot certificates --cert-name "$cert_name" 2>/dev/null || true)" if [[ -z "$cert_details" ]]; then group_covered="no" fi for domain_entry in "${group_names[@]}"; do local escaped escaped="$(printf '%s' "$domain_entry" | sed 's/[.[\*^$]/\\&/g')" if [[ "$group_covered" == "yes" ]] && ! echo "$cert_details" | grep -qP "^\s+Domains:.*\b${escaped}\b"; then group_covered="no" break fi done if [[ "$group_covered" == "yes" ]]; then log "Certificate ${cert_name} already covers: $(group_csv_to_space_list "$domain_group_csv")" continue fi local certbot_d_args=() for domain_entry in "${group_names[@]}"; do certbot_d_args+=(-d "$domain_entry") done log "Requesting SSL certificate for group: $(group_csv_to_space_list "$domain_group_csv")" run_as_root certbot certonly \ --nginx \ --cert-name "$cert_name" \ --https-port "$CLIENT_PORT" \ --non-interactive \ --agree-tos \ --email "$CERTBOT_EMAIL" \ "${certbot_d_args[@]}" || { log "ERROR: Certbot failed for group: $(group_csv_to_space_list "$domain_group_csv")"; return 1; } done write_nginx_site_config "ssl" || return 1 run_as_root nginx -t || return 1 run_as_root systemctl reload nginx || return 1 log "Nginx SSL configured for domain groups: ${DOMAIN_GROUPS[*]}" } restore_backup_if_provided() { if [[ -z "$DB_BACKUP_PATH" ]]; then return 0 fi if [[ ! -f "$DB_BACKUP_PATH" ]]; then warn "Backup file not found: $DB_BACKUP_PATH" return 1 fi if [[ ! -d "$INSTALL_DIR/server" ]]; then warn "Server directory not found at ${INSTALL_DIR}/server. Cannot restore backup." return 1 fi log "Restoring database from backup: $DB_BACKUP_PATH" local cmd=(npm --prefix server run db:restore -- -y --file "$DB_BACKUP_PATH") if [[ "${RESTORE_BACKUP_QUIET:-no}" == "yes" ]]; then if ! run_db_command_logged_quiet "${cmd[@]}"; then return 1 fi return 0 fi if ! run_db_command "${cmd[@]}"; then return 1 fi } backup_database() { if [[ ! -d "$INSTALL_DIR/server" ]]; then warn "Server directory not found; skipping DB backup." return 0 fi log "Backing up database before update..." if ! run_in_install_dir "npm --prefix server run db:backup"; then warn "DB backup command failed. Update aborted." return 1 fi } preserve_backup_and_restore_data() { log "Data directory (${INSTALL_DIR}/data) remains untouched during update." } run_migrations() { if [[ ! -d "$INSTALL_DIR/server" ]]; then warn "Server directory not found; skipping DB migrations." return 0 fi log "Running database migrations..." run_in_install_dir "npm --prefix server run db:migrate" || return 1 } songbird_service_is_running() { if have_cmd systemctl && systemctl is-active --quiet songbird.service 2>/dev/null; then return 0 fi return 1 } songbird_healthcheck_responds() { local port="$1" if have_cmd curl; then curl --fail --silent --show-error --max-time 1 "http://127.0.0.1:${port}/api/health" >/dev/null 2>&1 return $? fi if have_cmd wget; then wget -q -T 1 -O /dev/null "http://127.0.0.1:${port}/api/health" >/dev/null 2>&1 return $? fi if have_cmd node; then node -e ' const http = require("node:http"); const port = Number(process.argv[1]); if (!Number.isFinite(port) || port <= 0) process.exit(1); const req = http.request( { hostname: "127.0.0.1", port, path: "/api/health", method: "GET", timeout: 600, }, (res) => { res.resume(); process.exit(res.statusCode >= 200 && res.statusCode < 300 ? 0 : 1); }, ); req.on("timeout", () => req.destroy()); req.on("error", () => process.exit(1)); req.end(); setTimeout(() => process.exit(1), 800).unref(); ' "$port" >/dev/null 2>&1 return $? fi return 1 } ensure_songbird_stopped_for_update() { local port="" port="$(get_existing_env_value_with_fallback "SERVER_PORT" "PORT" "$DEFAULT_SERVER_PORT")" port="$(sanitize_input_value "$port")" if [[ ! "$port" =~ ^[0-9]+$ ]]; then port="$DEFAULT_SERVER_PORT" fi log "Checking whether Songbird is running before update..." if songbird_service_is_running; then log "Songbird service is running. Stopping it before update..." if ! run_as_root systemctl stop songbird.service; then warn "Failed to stop Songbird service. Stop it manually before updating." press_enter_to_continue return 1 fi if have_cmd systemctl && systemctl is-active --quiet songbird-worker.service 2>/dev/null; then run_as_root systemctl stop songbird-worker.service || true fi log "Songbird service stopped." fi if songbird_healthcheck_responds "$port"; then warn "Songbird is still running on port ${port}. Stop it before updating." press_enter_to_continue return 1 fi return 0 } update_nginx_runtime_values() { if [[ ! -f "$NGINX_SITE_FILE" ]]; then warn "Nginx site config not found at ${NGINX_SITE_FILE}. Skipping nginx update." return 1 fi local backup_file="${NGINX_SITE_FILE}.bak.$(date +%Y%m%d%H%M%S)" run_silent run_as_root cp "$NGINX_SITE_FILE" "$backup_file" run_silent run_as_root sed -i -E \ "s|proxy_pass http://127\\.0\\.0\\.1:[0-9]+;|proxy_pass http://127.0.0.1:${SERVER_PORT};|g" \ "$NGINX_SITE_FILE" local existing_cert="" local existing_key="" existing_cert="$(run_as_root_output grep -E '^\s*ssl_certificate ' "$NGINX_SITE_FILE" | head -n 1 | sed -E 's/^\s*ssl_certificate\s+([^;]+);/\1/' | tr -d '\r\n')" || existing_cert="" existing_key="$(run_as_root_output grep -E '^\s*ssl_certificate_key ' "$NGINX_SITE_FILE" | head -n 1 | sed -E 's/^\s*ssl_certificate_key\s+([^;]+);/\1/' | tr -d '\r\n')" || existing_key="" if [[ -n "$existing_cert" && -n "$existing_key" ]]; then write_nginx_site_config "ssl" "$existing_cert" "$existing_key" else write_nginx_site_config "http" fi if run_as_root nginx -t; then run_as_root systemctl reload nginx log "Nginx updated with SERVER_PORT=${SERVER_PORT}, CLIENT_PORT=${CLIENT_PORT}, MAX_UPLOAD_MB=${MAX_UPLOAD_MB}." log "Backup saved at ${backup_file}." return 0 fi warn "Nginx config test failed. Restoring previous config." run_silent run_as_root cp "$backup_file" "$NGINX_SITE_FILE" run_as_root nginx -t || true return 1 } rebuild_and_restart_after_settings_change() { local needs_nginx="${1:-no}" sync_values_from_env log "Rebuilding client after settings change..." run_in_install_dir "npm --prefix client run build" || return 1 log "Restarting Songbird service..." run_as_root systemctl restart songbird.service || return 1 if have_cmd systemctl && systemctl list-unit-files | grep -q "^songbird-worker.service"; then run_as_root systemctl restart songbird-worker.service || true fi if [[ "$needs_nginx" == "yes" ]]; then log "Updating Nginx config for SERVER_PORT/CLIENT_PORT/MAX_UPLOAD_MB changes..." update_nginx_runtime_values || return 1 else log "Nginx update not required (SERVER_PORT/CLIENT_PORT/MAX_UPLOAD_MB unchanged)." fi } update_songbird() { if [[ ! -d "$INSTALL_DIR" ]]; then warn "No Songbird install found at ${INSTALL_DIR}." press_enter_to_continue return 0 fi ensure_songbird_stopped_for_update || return 1 if [[ "$(prompt_yes_no "Create a database backup before updating?" "no")" == "yes" ]]; then backup_database || return 1 else log "Skipping pre-update backup." fi prompt_source_mode if [[ "$SOURCE_MODE" == "offline" ]]; then ensure_offline_source_ready "update" || return 0 local offline_zip_path="$SOURCE_ZIP_PATH" local extract_result="" extract_result="$(extract_offline_source_zip "$offline_zip_path" "update")" || return 1 local tmp_dir="${extract_result%%|*}" local source_root="${extract_result#*|}" local source_ver="" local install_ver="" source_ver="$(read_version_value "$source_root/VERSION")" || source_ver="" install_ver="$(read_version_value "$INSTALL_DIR/VERSION")" || install_ver="" local is_downgrade="no" local version_rel="" if ! compare_offline_source_version "$source_root" "$INSTALL_DIR" version_rel; then run_silent run_as_root rm -rf "$tmp_dir" press_enter_to_continue return 0 fi case "$version_rel" in newer) log "Offline update available. Preparing to update Songbird..." ;; lower) local should_downgrade="" should_downgrade="$(prompt_yes_no "Local zip version (${source_ver}) is lower than installed version (${install_ver}). Do you want to downgrade to version ${source_ver}?" "no")" if [[ "$should_downgrade" != "yes" ]]; then run_silent run_as_root rm -rf "$tmp_dir" log "Songbird is already up to date. No rebuild needed." press_enter_to_continue return 0 fi is_downgrade="yes" log "Offline downgrade requested. Preparing to downgrade Songbird to ${source_ver}..." ;; same) local should_update="" should_update="$(prompt_yes_no "Local zip version (${source_ver}) is the same as installed version (${install_ver}). Do you want to update to version ${source_ver}?" "no")" if [[ "$should_update" != "yes" ]]; then run_silent run_as_root rm -rf "$tmp_dir" log "Songbird is already up to date. No rebuild needed." press_enter_to_continue return 0 fi log "Offline update requested. Preparing to update Songbird to ${source_ver}..." ;; *) run_silent run_as_root rm -rf "$tmp_dir" log "Songbird is already up to date. No rebuild needed." press_enter_to_continue return 0 ;; esac run_silent run_as_root rm -rf "$tmp_dir" preserve_backup_and_restore_data update_source_from_zip "$offline_zip_path" || return 1 log "Installing dependencies..." install_songbird_dependencies || return 1 ensure_vapid_keys || return 1 log "Synchronizing database schema with latest version..." run_migrations || return 1 ensure_service_user_exists || return 1 apply_ownership || return 1 if install_global_command_from_path "$INSTALL_DIR/scripts/install.sh"; then log "Global command synchronized from updated install script." else warn "Failed to synchronize global command after update." fi # Unit files are fully generated: refresh them on every update. if have_cmd systemctl && [[ -f "$SERVICE_FILE" ]]; then log "Refreshing systemd services..." configure_systemd_service || true fi log "Restarting Songbird service..." run_as_root systemctl restart songbird.service || return 1 if have_cmd systemctl && systemctl list-unit-files | grep -q "^songbird-worker.service"; then run_as_root systemctl restart songbird-worker.service || true fi run_as_root systemctl reload nginx || return 1 show_deployment_success_frame "update" press_enter_to_continue return 0 fi if [[ -d "$INSTALL_DIR/.git" ]]; then : else warn "No git checkout found at ${INSTALL_DIR}. Update requires GitHub mode." press_enter_to_continue return 0 fi # Fetch latest from remote log "Checking for updates..." if ! run_in_install_dir "git fetch --all --prune --tags"; then warn "Failed to fetch from remote. Check your network and credentials." press_enter_to_continue return 1 fi # Get local and remote commit hashes local local_commit remote_commit local_commit="$(run_in_install_dir_output "git rev-parse HEAD" | tr -d '\r\n')" remote_commit="$(run_in_install_dir_output "git rev-parse origin/main" | tr -d '\r\n')" if [[ -z "$local_commit" || -z "$remote_commit" ]]; then warn "Failed to determine current version. Check git repository status." press_enter_to_continue return 1 fi local is_downgrade="no" local target_ref="main" local target_version="" # Check if update is available if [[ "$local_commit" == "$remote_commit" ]]; then log "Songbird is already up to date." local should_downgrade="" should_downgrade="$(prompt_yes_no "Do you want to downgrade?" "no")" if [[ "$should_downgrade" != "yes" ]]; then log "No rebuild needed." press_enter_to_continue return 0 fi prompt_read "Enter version number to install: " target_version target_version="$(sanitize_input_value "$target_version")" if [[ -z "$target_version" ]]; then warn "No version specified. Downgrade canceled." press_enter_to_continue return 0 fi target_ref="$(resolve_git_version_ref "$target_version")" || target_ref="" if [[ -z "$target_ref" ]]; then warn "Version '${target_version}' not found in repository." press_enter_to_continue return 1 fi is_downgrade="yes" fi if [[ "$is_downgrade" == "yes" ]]; then log "Downgrade requested. Preparing to downgrade Songbird to ${target_version}..." preserve_backup_and_restore_data if ! run_in_install_dir "git checkout ${target_ref}"; then warn "Failed to checkout ${target_version}." press_enter_to_continue return 1 fi else # Update is available - proceed with safe update log "Update available. Preparing to update Songbird..." preserve_backup_and_restore_data # Ensure we're on main branch and pull latest if ! run_in_install_dir "git checkout main"; then warn "Failed to checkout main branch." press_enter_to_continue return 1 fi if ! run_in_install_dir "git pull --ff-only origin main"; then warn "Failed to pull updates. Repository may have non-fast-forward changes." press_enter_to_continue return 1 fi fi log "Installing dependencies..." install_songbird_dependencies || return 1 ensure_vapid_keys || return 1 log "Synchronizing database schema with latest version..." run_migrations || return 1 ensure_service_user_exists || return 1 apply_ownership || return 1 if install_global_command_from_path "$INSTALL_DIR/scripts/install.sh"; then log "Global command synchronized from updated install script." else warn "Failed to synchronize global command after update." fi # Unit files are fully generated: refresh them on every update. if have_cmd systemctl && [[ -f "$SERVICE_FILE" ]]; then log "Refreshing systemd services..." configure_systemd_service || true fi log "Restarting Songbird service..." run_as_root systemctl restart songbird.service || return 1 if have_cmd systemctl && systemctl list-unit-files | grep -q "^songbird-worker.service"; then run_as_root systemctl restart songbird-worker.service || true fi run_as_root systemctl reload nginx || return 1 show_deployment_success_frame "update" press_enter_to_continue } restart_songbird() { log "Restarting Songbird service..." run_as_root systemctl restart songbird.service || return 1 if have_cmd systemctl && systemctl list-unit-files | grep -q "^songbird-worker.service"; then run_as_root systemctl restart songbird-worker.service || true fi run_as_root systemctl reload nginx || return 1 log "Songbird restarted successfully." press_enter_to_continue } edit_settings() { local env_file="${INSTALL_DIR}/.env" if [[ ! -f "$env_file" ]]; then warn "No .env found at ${env_file}. Run install first." press_enter_to_continue return 0 fi local legacy_port existing_server existing_client legacy_port="$(get_existing_env_value "PORT" "")" existing_server="$(get_existing_env_value "SERVER_PORT" "")" existing_client="$(get_existing_env_value "CLIENT_PORT" "")" if [[ -n "$legacy_port" && -z "$existing_server" ]]; then replace_env_value "$env_file" "SERVER_PORT" "$legacy_port" fi if [[ -z "$existing_client" ]]; then replace_env_value "$env_file" "CLIENT_PORT" "$DEFAULT_CLIENT_PORT" fi local before_server before_client before_max before_server="$(get_existing_env_value_with_fallback "SERVER_PORT" "PORT" "$DEFAULT_SERVER_PORT")" before_client="$(get_existing_env_value "CLIENT_PORT" "$DEFAULT_CLIENT_PORT")" before_max="$(get_existing_env_mb_value_with_fallback "FILE_UPLOAD_MAX_TOTAL_SIZE_MB" "FILE_UPLOAD_MAX_TOTAL_SIZE" "$DEFAULT_MAX_UPLOAD_MB")" local before after before="$(sha256sum "$env_file" | awk '{print $1}')" open_env_editor "$env_file" after="$(sha256sum "$env_file" | awk '{print $1}')" if [[ "$before" == "$after" ]]; then log "No changes detected in .env. Skipping rebuild." return 0 fi local after_server after_client after_max after_server="$(get_existing_env_value_with_fallback "SERVER_PORT" "PORT" "$DEFAULT_SERVER_PORT")" after_client="$(get_existing_env_value "CLIENT_PORT" "$DEFAULT_CLIENT_PORT")" after_max="$(get_existing_env_mb_value_with_fallback "FILE_UPLOAD_MAX_TOTAL_SIZE_MB" "FILE_UPLOAD_MAX_TOTAL_SIZE" "$DEFAULT_MAX_UPLOAD_MB")" local needs_nginx="no" if [[ "$before_server" != "$after_server" || "$before_client" != "$after_client" || "$before_max" != "$after_max" ]]; then needs_nginx="yes" fi log "Changes detected. Applying updates..." ensure_local_postgres_setup || return 1 rebuild_and_restart_after_settings_change "$needs_nginx" log "Settings applied." press_enter_to_continue } # Print PIDs listening on a TCP port (empty when free). Requires ss. pids_listening_on_port() { local port="$1" run_as_root ss -ltnp "sport = :${port}" 2>/dev/null \ | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u || true } # Ensure nothing still holds a service port. free_service_port() { local port="$1" if [[ ! "$port" =~ ^[0-9]+$ ]]; then warn "Skipping port cleanup for invalid port value: ${port:-}." return 0 fi if ! have_cmd ss; then warn "ss not available; cannot verify port ${port} is free." return 0 fi local pids="" pids="$(pids_listening_on_port "$port")" if [[ -z "${pids//[[:space:]]/}" ]]; then return 0 fi local summary="" summary="$(run_as_root ps -o pid=,comm=,args= -p $(printf "%s" "$pids" | tr '\n' ',' | sed 's/,$//') 2>/dev/null | tr '\n' ';' || true)" log "Port ${port} still in use (${summary:-PIDs: $pids}); terminating..." run_as_root kill $pids 2>/dev/null || true sleep 2 local remaining="" remaining="$(pids_listening_on_port "$port")" if [[ -n "${remaining//[[:space:]]/}" ]]; then warn "Port ${port} still held after SIGTERM (${remaining}); escalating to SIGKILL." run_as_root kill -9 $remaining 2>/dev/null || true sleep 1 remaining="$(pids_listening_on_port "$port")" fi if [[ -n "${remaining//[[:space:]]/}" ]]; then warn "Port ${port} is still occupied (${remaining}) after SIGKILL. Reboot or investigate manually." return 1 fi log "Port ${port} is free." return 0 } remove_songbird() { if [[ ! -d "$INSTALL_DIR" ]]; then warn "No install found at ${INSTALL_DIR}." press_enter_to_continue return 0 fi if [[ "$(prompt_yes_no "This will remove Songbird from this server. Continue?" "no")" != "yes" ]]; then log "Removal canceled." return 0 fi # Capture ports for later. local server_port worker_port server_port="$(get_existing_env_value "SERVER_PORT" "$DEFAULT_SERVER_PORT")" worker_port="$(get_existing_env_value "WORKER_PORT" "$DEFAULT_WORKER_PORT")" if run_as_root systemctl list-unit-files | grep -q "^songbird.service"; then run_as_root systemctl disable --now songbird.service || true fi if run_as_root systemctl list-unit-files | grep -q "^songbird-worker.service"; then run_as_root systemctl disable --now songbird-worker.service || true fi if run_as_root systemctl list-unit-files | grep -q "^songbird-lego-renew.timer"; then run_as_root systemctl disable --now songbird-lego-renew.timer || true fi run_as_root rm -f "$SERVICE_FILE" "$WORKER_SERVICE_FILE" run_as_root rm -f "$LEGO_RENEW_SERVICE_FILE" "$LEGO_RENEW_TIMER_FILE" run_as_root systemctl daemon-reload free_service_port "$server_port" || true free_service_port "$worker_port" || true run_as_root rm -f "$NGINX_ENABLED_FILE" run_as_root rm -f "$NGINX_SITE_FILE" if run_as_root nginx -t >/dev/null 2>&1; then run_as_root systemctl reload nginx fi if [[ -d "$INSTALL_DIR" ]]; then run_as_root rm -rf "$INSTALL_DIR" fi if id -u "$SERVICE_USER" >/dev/null 2>&1; then run_as_root userdel "$SERVICE_USER" || true fi log "Songbird removed." if [[ -f "$GLOBAL_COMMAND_PATH" ]]; then if [[ "$(prompt_yes_no "Remove global command (songbird-deploy) as well?" "no")" == "yes" ]]; then run_as_root rm -f "$GLOBAL_COMMAND_PATH" log "Global command removed." fi fi press_enter_to_continue } configure_push_proxy() { local env_file="$INSTALL_DIR/.env" if [[ ! -f "$env_file" ]]; then warn ".env file not found. Install Songbird first." press_enter_to_continue return 1 fi clear show_banner printf "\n" printf "Configure Push Notification Proxy\n" printf "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n\n" local current_proxy="" current_proxy="$(get_existing_env_value "PUSH_PROXY_URL" "")" if [[ -n "$current_proxy" ]]; then printf "Current proxy: %s\n\n" "$current_proxy" else printf "No proxy currently configured.\n\n" fi printf "Use a proxy when your server cannot directly reach push service endpoints:\n" printf " โ€ข fcm.googleapis.com (Chrome/Edge)\n" printf " โ€ข *.push.services.mozilla.com (Firefox)\n" printf " โ€ข web.push.apple.com (Safari)\n\n" printf "Proxy URL formats:\n" printf " โ€ข HTTP: http://proxy.example.com:3128\n" printf " โ€ข With auth: http://user:pass@proxy.example.com:8080\n" printf " โ€ข SOCKS5: socks5://proxy.example.com:1080\n\n" local proxy_url="" prompt_read "Enter proxy URL (leave blank to disable proxy): " proxy_url proxy_url="${proxy_url#"${proxy_url%%[![:space:]]*}"}" proxy_url="${proxy_url%"${proxy_url##*[![:space:]]}"}" if [[ -z "$proxy_url" ]]; then log "Clearing proxy configuration..." replace_env_value "$env_file" "PUSH_PROXY_URL" "" else # Test proxy connectivity if curl is available if have_cmd curl; then printf "\nTesting proxy connectivity...\n" if curl -x "$proxy_url" -s -o /dev/null -w "%{http_code}" --connect-timeout 5 https://fcm.googleapis.com 2>/dev/null | grep -q "200\|301\|302"; then printf "โœ“ Proxy is reachable!\n" else warn "Could not verify proxy connectivity. Proceeding anyway..." fi fi log "Setting proxy to: $proxy_url" replace_env_value "$env_file" "PUSH_PROXY_URL" "$proxy_url" fi log "Installing dependencies..." if ! run_in_install_dir "cd server && npm install"; then warn "Failed to install dependencies." press_enter_to_continue return 1 fi log "Restarting Songbird service..." if ! run_logged_quiet run_as_root systemctl restart songbird; then warn "Failed to restart service." press_enter_to_continue return 1 fi printf "\n" log "Proxy configuration applied successfully!" press_enter_to_continue } install_songbird() { prompt_source_mode collect_install_options prompt_database_choice prompt_install_backup_restore install_required_packages || return 1 ensure_nodejs_from_nodesource || return 1 ensure_service_user_exists || return 1 if [[ "$SOURCE_MODE" == "offline" ]]; then ensure_offline_source_ready "install" || return 0 install_source_from_zip "$SOURCE_ZIP_PATH" || return 1 else clone_repo || { warn "Failed to clone repository. Installation canceled." press_enter_to_continue return 1 } fi ensure_log_dir || return 1 write_full_env_with_defaults || return 1 ensure_local_postgres_setup || return 1 install_songbird_dependencies || return 1 RESTORE_BACKUP_QUIET="yes" if ! restore_backup_if_provided; then RESTORE_BACKUP_QUIET="no" warn "Backup restore failed. Installation aborted. Review ${LOG_FILE} for details." press_enter_to_continue return 1 fi RESTORE_BACKUP_QUIET="no" ensure_vapid_keys || return 1 pregenerate_missing_secrets || true apply_ownership || return 1 configure_systemd_service || return 1 log "Starting nginx setup..." configure_nginx || return 1 log "Starting TLS setup..." configure_ssl_if_needed || return 1 if install_global_command_from_path "$INSTALL_DIR/scripts/install.sh"; then log "Global command synchronized to script version ${LAST_GLOBAL_COMMAND_VERSION:-$SCRIPT_VERSION}." else warn "Failed to synchronize global command after install. You can retry from the menu." fi show_deployment_success_frame "install" create_owner_user || return 1 press_enter_to_continue } install_global_command_from_path() { local source_path="$1" [[ -n "$source_path" && -f "$source_path" ]] || return 1 local install_version="" install_version="$(resolve_script_version_for_path "$source_path" 2>/dev/null || printf "%s" "$SCRIPT_VERSION")" local temp_target="" temp_target="$(mktemp)" cp "$source_path" "$temp_target" || { rm -f "$temp_target" return 1 } if ! sed -i "1s|^# songbird-deploy-version:.*$|# songbird-deploy-version: ${install_version}|" "$temp_target"; then rm -f "$temp_target" return 1 fi if ! run_silent run_as_root install -m 755 "$temp_target" "$GLOBAL_COMMAND_PATH"; then rm -f "$temp_target" return 1 fi rm -f "$temp_target" LAST_GLOBAL_COMMAND_VERSION="$install_version" return 0 } install_global_command_from_remote() { local install_version="$SCRIPT_VERSION" local temp_target="" temp_target="$(mktemp)" if ! curl -fsSL "$SCRIPT_REMOTE_URL" > "$temp_target"; then rm -f "$temp_target" return 1 fi if ! sed -i "1s|^# songbird-deploy-version:.*$|# songbird-deploy-version: ${install_version}|" "$temp_target"; then rm -f "$temp_target" return 1 fi if ! run_silent run_as_root install -m 755 "$temp_target" "$GLOBAL_COMMAND_PATH"; then rm -f "$temp_target" return 1 fi rm -f "$temp_target" LAST_GLOBAL_COMMAND_VERSION="$install_version" return 0 } install_global_command_core() { local source_path="${1:-$CURRENT_SCRIPT_PATH}" if [[ -n "$source_path" && -f "$source_path" ]]; then install_global_command_from_path "$source_path" return $? fi log "Script source path is not a regular file. Installing global command from remote..." install_global_command_from_remote } fetch_remote_installer_script() { local target_file="$1" if ! have_cmd curl; then return 1 fi curl -fsSL --connect-timeout 10 --max-time 30 "$SCRIPT_REMOTE_URL" > "$target_file" || return 1 [[ -s "$target_file" ]] || return 1 } update_menu() { log "Checking for menu updates from GitHub..." local temp_remote="" temp_remote="$(mktemp)" if ! fetch_remote_installer_script "$temp_remote"; then rm -f "$temp_remote" warn "Failed to fetch installer script from GitHub." local reinstall_choice="" reinstall_choice="$(prompt_yes_no "Do you want to reinstall the current menu again?" "no")" if [[ "$reinstall_choice" == "yes" ]]; then if install_global_command_core "$CURRENT_SCRIPT_PATH"; then log "Menu reinstalled successfully (version ${LAST_GLOBAL_COMMAND_VERSION:-$SCRIPT_VERSION})." else warn "Failed to reinstall menu." fi else log "Reinstall canceled." fi press_enter_to_continue return 0 fi local remote_version="" remote_version="$(read_script_version_header "$temp_remote" 2>/dev/null || true)" if [[ -z "$remote_version" ]]; then rm -f "$temp_remote" warn "Could not determine remote script version from GitHub." local reinstall_choice="" reinstall_choice="$(prompt_yes_no "Do you want to reinstall the current menu again?" "no")" if [[ "$reinstall_choice" == "yes" ]]; then if install_global_command_core "$CURRENT_SCRIPT_PATH"; then log "Menu reinstalled successfully (version ${LAST_GLOBAL_COMMAND_VERSION:-$SCRIPT_VERSION})." else warn "Failed to reinstall menu." fi else log "Reinstall canceled." fi press_enter_to_continue return 0 fi if dpkg --compare-versions "$remote_version" gt "$SCRIPT_VERSION"; then log "Newer version available (${remote_version} > ${SCRIPT_VERSION}). Updating menu..." if ! run_silent run_as_root install -m 755 "$temp_remote" "$GLOBAL_COMMAND_PATH"; then rm -f "$temp_remote" warn "Failed to update menu." press_enter_to_continue return 1 fi rm -f "$temp_remote" LAST_GLOBAL_COMMAND_VERSION="$remote_version" log "Menu updated to version ${remote_version}." log "Run it from anywhere with: songbird-deploy" press_enter_to_continue return 0 fi rm -f "$temp_remote" log "Menu is already up to date (version ${SCRIPT_VERSION})." local reinstall_choice="" reinstall_choice="$(prompt_yes_no "Do you want to reinstall the current menu again?" "no")" if [[ "$reinstall_choice" == "yes" ]]; then if install_global_command_core "$CURRENT_SCRIPT_PATH"; then log "Menu reinstalled successfully (version ${LAST_GLOBAL_COMMAND_VERSION:-$SCRIPT_VERSION})." else warn "Failed to reinstall menu." fi else log "Reinstall canceled." fi press_enter_to_continue return 0 } install_global_command() { update_menu } installed_global_command_version() { local version="" version="$(run_as_root bash -lc "if [[ -f '$GLOBAL_COMMAND_PATH' ]]; then grep -E '^# songbird-deploy-version:' '$GLOBAL_COMMAND_PATH' | head -n 1 | cut -d ':' -f 2- | xargs; fi" 2>/dev/null || true)" if [[ -z "$version" || "$version" == "auto" ]]; then return 1 fi printf "%s" "$version" } ensure_global_command_on_first_run() { local installed_version="" if ! run_as_root test -x "$GLOBAL_COMMAND_PATH"; then log "Global command not found. Installing it automatically..." if ! install_global_command_core "$CURRENT_SCRIPT_PATH"; then warn "Automatic global command installation failed. You can retry from the menu." fi return 0 fi installed_version="$(installed_global_command_version 2>/dev/null || true)" if [[ -z "$installed_version" ]]; then log "Global command version could not be determined. Reinstalling it automatically..." if ! install_global_command_core "$CURRENT_SCRIPT_PATH"; then warn "Automatic global command reinstall failed. You can retry from the menu." fi return 0 fi if dpkg --compare-versions "$installed_version" lt "$SCRIPT_VERSION"; then log "Global command version ${installed_version} is older than script version ${SCRIPT_VERSION}. Updating it automatically..." if ! install_global_command_core "$CURRENT_SCRIPT_PATH"; then warn "Automatic global command update failed. You can retry from the menu." fi fi } show_logs() { if [[ -f "$LOG_FILE" ]]; then clear printf "\n Last %s lines of script log:\n\n" "$LOG_LINES" tail -n "$LOG_LINES" "$LOG_FILE" else printf "\n No script log found at: %s\n" "$LOG_FILE" fi press_enter_to_continue } show_service_logs() { if systemctl list-units --type=service --all | grep songbird; then clear printf "\n Last %s lines of songbird service log:\n\n" "$LOG_LINES" run_as_root journalctl -u songbird --no-pager -n "$LOG_LINES" else printf "\n Songbird service not found.\n" fi press_enter_to_continue } show_worker_service_logs() { if systemctl list-units --type=service --all | grep songbird-worker; then clear printf "\n Last %s lines of songbird-worker service log:\n\n" "$LOG_LINES" run_as_root journalctl -u songbird-worker --no-pager -n "$LOG_LINES" else printf "\n Songbird worker service not found.\n" fi press_enter_to_continue } show_nginx_access_logs() { local log_file="/var/log/nginx/access.log" if [[ -f "$log_file" ]]; then clear printf "\n Last %s lines of nginx access log:\n\n" "$LOG_LINES" run_as_root tail -n "$LOG_LINES" "$log_file" else printf "\n Nginx access log not found: %s\n" "$log_file" fi press_enter_to_continue } show_nginx_error_logs() { local log_file="/var/log/nginx/error.log" if [[ -f "$log_file" ]]; then clear printf "\n Last %s lines of nginx error log:\n\n" "$LOG_LINES" run_as_root tail -n "$LOG_LINES" "$log_file" else printf "\n Nginx error log not found: %s\n" "$log_file" fi press_enter_to_continue } show_banner() { printf '\033[1;38;2;42;186;130m' # bold Songbird green (#2ABA82) cat << 'EOF' โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— โ•‘ โ•‘ โ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ•‘ โ•‘ โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ• โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•— โ•‘ โ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ•‘ โ•‘ โ•šโ•โ•โ•โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ•‘ โ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘ โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ• โ•‘ โ•‘ โ•šโ•โ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ• โ•šโ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•šโ•โ• โ•šโ•โ•โ•šโ•โ•โ•โ•โ•โ• โ•‘ โ•‘ โ•‘ โ•‘ D E P L O Y T O O L โ•‘ โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• EOF printf 'Script Version: %s\n' "$SCRIPT_VERSION" printf '\033[0m' # reset } show_menu() { clear show_banner printf "\n" printf "Songbird Deploy Menu\n" printf $'1) ๐Ÿ“ฅ Install Songbird\n' printf $'2) ๐Ÿ”„๏ธ Update Songbird\n' printf $'3) โ™ป๏ธ Restart Songbird\n' printf $'4) โš™๏ธ Edit Settings (.env)\n' printf $'5) ๐Ÿ—ƒ๏ธ Manage Database\n' printf $'6) ๐Ÿ—‘๏ธ Remove Songbird\n' printf $'7) ๐Ÿ”„๏ธ Update menu\n' printf $'8) ๐ŸŒ Configure mirrors\n' printf $'9) ๐Ÿ“‹ View Logs\n' printf $'0) ๐Ÿšช Exit\n\n' } show_logs_menu() { while true; do clear show_banner printf "\n" printf "Logs Menu\n" printf $'1) ๐Ÿ“‹ View script logs\n' printf $'2) ๐Ÿ“‹ View service logs\n' printf $'3) ๐Ÿ“‹ View worker service logs\n' printf $'4) ๐Ÿ“‹ View nginx access logs\n' printf $'5) ๐Ÿ“‹ View nginx error logs\n' printf $'6) โ†ฉ๏ธ Go back\n' printf $'0) ๐Ÿšช Exit\n\n' prompt_read "Choose an option [0-6]: " choice case "$choice" in 1) show_logs ;; 2) show_service_logs ;; 3) show_worker_service_logs ;; 4) show_nginx_access_logs ;; 5) show_nginx_error_logs ;; 6) return ;; 0) exit 0 ;; *) printf "Invalid choice. Select a number from 0 to 6.\n" ;; esac done } run_db_command_with_runner() { local command_runner="$1" shift local args=("$@") local escaped="" local part="" local path_prefix="" local path_export="" if [[ "${args[0]:-}" == "npm" ]]; then resolve_npm_exec_path || return 1 args[0]="$NPM_EXEC_PATH" elif [[ "${args[0]:-}" == "node" ]]; then resolve_node_exec_path || return 1 args[0]="$NODE_EXEC_PATH" fi path_prefix="$(node_tools_path_prefix)" if [[ -n "$path_prefix" ]]; then path_export="export PATH=$(printf '%q' "$path_prefix"):\$PATH; " fi for part in "${args[@]}"; do escaped+=" $(printf '%q' "$part")" done "$command_runner" bash -lc "cd '$INSTALL_DIR' && ${path_export}${escaped:1} /dev/tty 2>&1" } run_db_command_logged_quiet() { local args=("$@") local escaped="" local part="" local path_prefix="" local path_export="" if [[ "${args[0]:-}" == "npm" ]]; then resolve_npm_exec_path || return 1 args[0]="$NPM_EXEC_PATH" elif [[ "${args[0]:-}" == "node" ]]; then resolve_node_exec_path || return 1 args[0]="$NODE_EXEC_PATH" fi path_prefix="$(node_tools_path_prefix)" if [[ -n "$path_prefix" ]]; then path_export="export PATH=$(printf '%q' "$path_prefix"):\$PATH; " fi for part in "${args[@]}"; do escaped+=" $(printf '%q' "$part")" done run_logged_quiet run_data_command bash -lc "cd '$INSTALL_DIR' && ${path_export}${escaped:1} /dev/null || true)" fi if [[ "$effective_visibility" == "private" ]]; then prompt_read "Member invites setting for this private chat (allow/deny, default: allow): " invites invites="${invites#"${invites%%[![:space:]]*}"}" invites="${invites%"${invites##*[![:space:]]}"}" [[ -z "$invites" ]] && invites="allow" else log "Skipping member invites prompt because public chats always allow invites." fi if [[ "${invites,,}" == "allow" ]]; then args+=(--allow-member-invites) elif [[ "${invites,,}" == "deny" || "${invites,,}" == "disallow" ]]; then args+=(--disallow-member-invites) fi run_db_command npm --prefix server run db:chat:edit -- "${args[@]}" press_enter_to_continue } db_chat_verify() { local chat="" chat="$(prompt_non_empty "Chat id or username")" if [[ "$(prompt_yes_no "Toggle verification state for ${chat} ?" "no")" != "yes" ]]; then log "Verify/unverify canceled." press_enter_to_continue return 0 fi run_db_command npm --prefix server run db:chat:verify -- "$chat" press_enter_to_continue } db_user_edit() { local user="" local username="" local nickname="" local avatar_url="" local status="" local color="" local role="" local args=() user="$(prompt_non_empty "User id or username")" prompt_read "New username (optional): " username prompt_read "New display name (optional): " nickname prompt_read "Avatar URL (optional): " avatar_url prompt_read "Status (online/invisible, optional): " status prompt_read "Color hex (optional, example: #10b981): " color while true; do prompt_read "Role (user/admin/owner, optional โ€” leave blank to keep current): " role role="${role#"${role%%[![:space:]]*}"}" role="${role%"${role##*[![:space:]]}"}" if [[ -z "$role" ]]; then break fi case "$role" in user|admin|owner) break ;; *) printf "Invalid role. Choose: user, admin, owner, or leave blank.\n" ;; esac done args+=("$user") [[ -n "$username" ]] && args+=(--username "$username") [[ -n "$nickname" ]] && args+=(--nickname "$nickname") [[ -n "$avatar_url" ]] && args+=(--avatar-url "$avatar_url") [[ -n "$status" ]] && args+=(--status "$status") [[ -n "$color" ]] && args+=(--color "$color") [[ -n "$role" ]] && args+=(--role "$role") run_db_command npm --prefix server run db:user:edit -- "${args[@]}" press_enter_to_continue } db_user_ban() { local user="" user="$(prompt_non_empty "User id or username")" if [[ "$(prompt_yes_no "Toggle ban state for ${user} ?" "no")" != "yes" ]]; then log "Ban/unban canceled." press_enter_to_continue return 0 fi run_db_command npm --prefix server run db:user:ban -- -y "$user" press_enter_to_continue } db_remote_configure() { local current_api_id="" local current_api_hash="" local current_proxy_url="" local api_id="" local api_hash="" local proxy_url="" local phone_number="" local two_step_password="" local force_sms="no" local args=() if [[ ! -d "${INSTALL_DIR}/server" ]]; then warn "Songbird server directory not found at ${INSTALL_DIR}/server." press_enter_to_continue return 1 fi current_api_id="$(sanitize_input_value "$(get_existing_env_value "REMOTE_CHANNEL_TELEGRAM_API_ID" "")")" current_api_hash="$(sanitize_input_value "$(get_existing_env_value "REMOTE_CHANNEL_TELEGRAM_API_HASH" "")")" current_proxy_url="$(sanitize_input_value "$(get_existing_env_value "REMOTE_CHANNEL_TELEGRAM_PROXY_URL" "$(get_existing_env_value "REMOTE_CHANNEL_PROXY_URL" "")")")" [[ "$current_api_id" == "0" ]] && current_api_id="" while true; do if [[ -n "$current_api_id" ]]; then prompt_read "Telegram API ID (default: ${current_api_id}): " api_id [[ -z "$api_id" ]] && api_id="$current_api_id" else prompt_read "Telegram API ID: " api_id fi api_id="${api_id#"${api_id%%[![:space:]]*}"}" api_id="${api_id%"${api_id##*[![:space:]]}"}" if [[ "$api_id" =~ ^[0-9]+$ && "$api_id" -gt 0 ]]; then break fi printf "Please enter a positive numeric Telegram API ID.\n" done if [[ -n "$current_api_hash" ]]; then api_hash="$(prompt_secret_optional "Telegram API hash (leave blank to keep existing)")" [[ -z "$api_hash" ]] && api_hash="$current_api_hash" else api_hash="$(prompt_secret "Telegram API hash")" fi if [[ -n "$current_proxy_url" ]]; then prompt_read "Telegram proxy URL (optional; default: ${current_proxy_url}; type none to clear): " proxy_url [[ -z "$proxy_url" ]] && proxy_url="$current_proxy_url" else prompt_read "Telegram proxy URL (optional): " proxy_url fi proxy_url="${proxy_url#"${proxy_url%%[![:space:]]*}"}" proxy_url="${proxy_url%"${proxy_url##*[![:space:]]}"}" if [[ "${proxy_url,,}" == "none" || "${proxy_url,,}" == "no" || "${proxy_url,,}" == "direct" ]]; then proxy_url="" fi phone_number="$(prompt_non_empty "Telegram phone number (with country code)")" two_step_password="$(prompt_secret_optional "Telegram two-step password (leave blank if disabled)")" force_sms="$(prompt_yes_no "Force SMS delivery for the login code?" "no")" printf "\nTelegram will send a login code now. Enter that code when the helper asks for it.\n" args=( node server/scripts/configure-remote-channel.js --env-file "${INSTALL_DIR}/.env" --api-id "$api_id" --api-hash "$api_hash" --phone-number "$phone_number" ) if [[ -n "$proxy_url" ]]; then args+=(--proxy-url "$proxy_url") else args+=(--no-proxy) fi if [[ -n "$two_step_password" ]]; then args+=(--password "$two_step_password") fi if [[ "$force_sms" == "yes" ]]; then args+=(--force-sms) fi if ! run_db_command_interactive "${args[@]}"; then press_enter_to_continue return 1 fi press_enter_to_continue } db_restore_backup() { local resolved="" resolved="$(select_backup_path)" if [[ "$(prompt_yes_no "This will replace the current database with ${resolved}. Continue?" "no")" != "yes" ]]; then log "Restore canceled." return 0 fi DB_BACKUP_PATH="$resolved" restore_backup_if_provided press_enter_to_continue } db_user_verify() { local user="" user="$(prompt_non_empty "User id or username")" if [[ "$(prompt_yes_no "Toggle verification state for ${user} ?" "no")" != "yes" ]]; then log "Verify/unverify canceled." press_enter_to_continue return 0 fi run_db_command npm --prefix server run db:user:verify -- "$user" press_enter_to_continue } show_db_menu() { sync_values_from_env while true; do clear show_banner printf "\n" printf "Manage Database\n" printf "โ”Œโ”€โ”€โ”€โ”€ Inspect โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”\n" printf "โ”‚ โ”‚\n" printf "โ”‚ 1) ๐Ÿ‘๏ธ Inspect database (summary) โ”‚\n" printf "โ”‚ 2) ๐Ÿ‘๏ธ Inspect chats metadata โ”‚\n" printf "โ”‚ 3) ๐Ÿ‘๏ธ Inspect users โ”‚\n" printf "โ”‚ 4) ๐Ÿ‘๏ธ Inspect files โ”‚\n" printf "โ”‚ โ”‚\n" printf "โ”œโ”€โ”€โ”€โ”€ Backup & Repair โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค\n" printf "โ”‚ โ”‚\n" printf "โ”‚ 5) ๐Ÿ“ค Backup database โ”‚\n" printf "โ”‚ 6) โ™ป๏ธ Restore backup โ”‚\n" printf "โ”‚ 7) ๐Ÿงน Vacuum database โ”‚\n" printf "โ”‚ 8) ๐Ÿ”„๏ธ Reset database โ”‚\n" printf "โ”‚ 9) ๐Ÿ—‘๏ธ Delete database โ”‚\n" printf "โ”‚ โ”‚\n" printf "โ”œโ”€โ”€โ”€โ”€ User & Chat Management โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค\n" printf "โ”‚ โ”‚\n" printf "โ”‚ 10) ๐Ÿ‘ค Create user โ”‚\n" printf "โ”‚ 11) ๐Ÿ‘ฅ Generate users (bulk) โ”‚\n" printf "โ”‚ 12) โœ๏ธ Edit user โ”‚\n" printf "โ”‚ 13) ๐Ÿšซ Ban/unban user โ”‚\n" printf "โ”‚ 14) โœ… Verify/unverify user โ”‚\n" printf "โ”‚ 15) ๐Ÿ’ฌ Create group/channel โ”‚\n" printf "โ”‚ 16) โž• Add members to chat โ”‚\n" printf "โ”‚ 17) โœ๏ธ Edit chat โ”‚\n" printf "โ”‚ 18) โœ… Verify/unverify chat โ”‚\n" printf "โ”‚ โ”‚\n" printf "โ”œโ”€โ”€โ”€โ”€ Remote Channels โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค\n" printf "โ”‚ โ”‚\n" printf "โ”‚ 19) ๐Ÿ“ก Configure Remote Channel โ”‚\n" printf "โ”‚ โ”‚\n" printf "โ”œโ”€โ”€โ”€โ”€ Destructive Actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค\n" printf "โ”‚ โ”‚\n" printf "โ”‚ 20) ๐Ÿ—‘๏ธ Delete chats โ”‚\n" printf "โ”‚ 21) ๐Ÿ—‘๏ธ Delete users โ”‚\n" printf "โ”‚ 22) ๐Ÿ—‘๏ธ Delete files โ”‚\n" printf "โ”‚ โ”‚\n" printf "โ”œโ”€โ”€โ”€โ”€ Help & Navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค\n" printf "โ”‚ โ”‚\n" printf "โ”‚ 23) โ” Show help โ”‚\n" printf "โ”‚ 24) โ†ฉ๏ธ Go back โ”‚\n" printf "โ”‚ 0) ๐Ÿšช Exit โ”‚\n" printf "โ”‚ โ”‚\n" printf "โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜\n\n" prompt_read "Choose an option [0-24]: " choice case "$choice" in 1) db_inspect "all" ;; 2) db_inspect "chat" ;; 3) db_inspect "user" ;; 4) db_inspect "file" ;; 5) db_backup ;; 6) db_restore ;; 7) db_vacuum ;; 8) db_reset ;; 9) db_delete ;; 10) db_user_create ;; 11) db_user_generate ;; 12) db_user_edit ;; 13) db_user_ban ;; 14) db_user_verify ;; 15) db_chat_create ;; 16) db_chat_add ;; 17) db_chat_edit ;; 18) db_chat_verify ;; 19) db_remote_configure ;; 20) db_chat_delete ;; 21) db_user_delete ;; 22) db_file_delete ;; 23) db_help ;; 24) return ;; 0) exit 0 ;; *) printf "Invalid choice. Select a number from 0 to 24.\n" ;; esac done } main() { if [[ "${1:-}" == "--version" || "${1:-}" == "-v" ]]; then SKIP_CLEAR_ON_EXIT="1" printf "%s\n" "$SCRIPT_VERSION" return 0 fi init_prompt_io detect_os ensure_sudo ensure_global_command_on_first_run trap 'handle_exit' EXIT trap 'handle_interrupt' INT TERM local choice="" while true; do show_menu prompt_read "Choose an option [0-9]: " choice case "$choice" in 1) run_menu_action install_songbird ;; 2) run_menu_action update_songbird ;; 3) run_menu_action restart_songbird ;; 4) run_menu_action edit_settings ;; 5) run_menu_action show_db_menu ;; 6) run_menu_action remove_songbird ;; 7) run_menu_action update_menu ;; 8) run_menu_action configure_mirrors_menu ;; 9) run_menu_action show_logs_menu ;; 0) break ;; *) printf "Invalid choice. Select a number from 0 to 9.\n" ;; esac done } main "$@"