arc/deploy.sh

4143 lines
153 KiB
Shell
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# ============================================================================
# Hesabix - Comprehensive Open Source Accounting System
# ============================================================================
#
# Hesabix is a complete, modern accounting system with a powerful API
# (FastAPI + PostgreSQL) and a Flutter Web user interface.
#
# This software is distributed under the GNU General Public License v3.0.
# Full license text:
# http://www.gnu.org/licenses/gpl-3.0.txt
#
# Developers: Hesabix Team
# Website: https://hesabix.ir
# Repository: https://source.hesabix.ir/hesabix/arc.git
# Support: https://hesabix.ir/support
#
# ============================================================================
# Deployment Script
# ============================================================================
#
# This script installs and configures Hesabix automatically:
# - Clone from: https://source.hesabix.ir/hesabix/arc.git
# - Prompt for API and UI domains
# - Install prerequisites, database (PostgreSQL), backend (FastAPI),
# frontend (Flutter Web), Nginx, and SSL
#
# Usage:
# sudo bash deploy.sh
# # or
# API_DOMAIN=api.example.com UI_DOMAIN=app.example.com BRANCH=main DB_PASSWORD=secure_password sudo -E bash deploy.sh
#
# Notes:
# - Designed for Ubuntu 22.04+/Debian 12+
# - Minimum install RAM: ~5.5 GiB — see check_minimum_ram
# - Standalone curl|bash: deploy.sh must not require sibling files before clone.
# Python/mirror helpers are inlined when scripts/ is missing; after clone,
# DEPLOY_SCRIPT_DIR is rebound to ${APP_ROOT}/app so scripts/ resolve.
# - Nginx/domains: after install, use `sudo hesabix -domains set` or `sudo hesabix -domains apply`;
# legacy: scripts/update_nginx_domains.sh. SSL: `sudo hesabix -ssl enable`. Let's Encrypt or SSL_LETSENCRYPT_LIVE in .deploy_env.
# - Web build API URL: auto http/https from certificate at
# /etc/letsencrypt/live/<API_DOMAIN>; for TLS without that path, export API_PUBLIC_SCHEME.
# Manual hesabix-api HTTP-only config can send https traffic to the wrong default 443 vhost (e.g. pgAdmin).
# - Resume from failure: if a step fails, re-run the script to continue from that step
# (completed steps are skipped using .deploy_state)
# - Saved inputs: last entered domain, branch, pgAdmin4 options, etc. are stored in .deploy_saved_vars
# and used as defaults on next run (override by env vars or leave blank to be prompted again).
# - For full re-run/upgrade (e.g. pull latest code and rebuild): use RESET_STATE=y
# - pip / Flutter mirrors: interactive choice in prompt_vars (scripts/mirror_config.sh) — Hesabix, official,
# China mirrors, pub-azs.ir, or custom URL. Non-interactive: PIP_MIRROR=hesabix|official|tuna|aliyun|custom
# and FLUTTER_MIRROR=hesabix|pub_azs|flutter_io_cn|tuna|sjtu|official|custom. Saved in .deploy_saved_vars and .deploy_env.
# Direct URL override: PIP_INDEX_URL, PUB_HOSTED_URL, FLUTTER_STORAGE_BASE_URL.
# Aliyun (China) is always attached as pip extra-index unless PIP_DISABLE_CHINA_FALLBACK=1.
# - Flutter SDK tarball: shell.hesabix.ir (internal); pub packages use selected FLUTTER_MIRROR.
# - Flutter SDK git clone: official (GitHub) is tried first; if it fails, alternatives are tried (FLUTTER_SDK_GIT_URL if set, then Tsinghua, Gitee).
# - Flutter SDK: first try internal tarball (FLUTTER_SDK_TARBALL_URL_INTERNAL = shell.hesabix.ir/...), then snap, then git clone; pub packages via PUB_HOSTED_URL.
# Large tarball (~2GB): download uses --progress-bar, resume (-C -), FLUTTER_SDK_CONNECT_TIMEOUT (default 120s),
# FLUTTER_SDK_DOWNLOAD_MAX_TIME (default 0 = no overall time limit; was 120s and caused false failures).
# Unreliable links: FLUTTER_SDK_TARBALL_ATTEMPTS (default 3), post-download tar test, optional FLUTTER_SDK_TARBALL_SHA256,
# FLUTTER_SDK_CURL_HTTP11=1 (default) sends --http1.1 to reduce flaky proxy/CDN issues.
# - Flutter PATH: /etc/profile.d/hesabix-flutter.sh (+ one line in /etc/bash.bashrc for non-login interactive shells).
# - Ubuntu APT mirror (only when ID=ubuntu): UBUNTU_APT_MIRROR=keep|arvan|official (non-interactive);
# default keep. arvan uses http://mirror.arvancloud.ir/ubuntu for archive.ubuntu.com and security.ubuntu.com.
# official restores those URLs from Arvan (per-stanza: *-security → security.ubuntu.com).
# - Debian: mirror prompt skipped; sources unchanged.
# - PostgreSQL pgvector: scripts/ensure_pgvector.sh installs postgresql-N-pgvector when available
# (deploy install_prereqs + deploy_backend; hesabix -update before migrations). Non-fatal if missing.
# - AI voice chat (local STT/TTS): scripts/ensure_voice_chat.sh — optional at prompt (INSTALL_VOICE);
# libav dev packages + pip install -e ".[voice]" (no cloud speech APIs).
# - Apt/needrestart: by default NEEDRESTART_SUSPEND=1 during deploy so post-install service
# restarts (e.g. fwupd-refresh) do not run and fail on headless VPS. Set NEEDRESTART_SUSPEND=0
# to allow needrestart behavior.
# - Install telemetry (optional): after a successful deploy, a non-blocking POST is sent to
# https://hesabix.ir/wp-json/hesabix-stats/v1/event with domain, public IP, RAM/CPU, OS, branch.
# Opt out: HESABIX_TELEMETRY=0. Override endpoint/token: HESABIX_STATS_URL / HESABIX_STATS_TOKEN.
# Persistent anonymous id: INSTALL_ID in ${APP_ROOT}/.deploy_env (see scripts/hesabix_telemetry.sh).
#
# ============================================================================
REPO_URL="https://source.hesabix.ir/hesabix/arc.git"
APP_ROOT="/opt/hesabix"
# Internal Flutter SDK tarball mirror; pub packages use selected FLUTTER_MIRROR
FLUTTER_SDK_TARBALL_URL_INTERNAL="https://shell.hesabix.ir/flutter_linux_3.41.1-stable.tar.xz"
# Large SDK tarball; 0 = no overall download time limit (avoids premature abort)
: "${FLUTTER_SDK_CONNECT_TIMEOUT:=120}"
: "${FLUTTER_SDK_DOWNLOAD_MAX_TIME:=0}"
: "${FLUTTER_SDK_TARBALL_ATTEMPTS:=3}"
: "${FLUTTER_SDK_CURL_HTTP11:=1}"
STATE_FILE="${APP_ROOT}/.deploy_state"
LOG_FILE="${APP_ROOT}/deploy.log"
CHECK_MARK=$'\xE2\x9C\x94'
CROSS_MARK=$'\xE2\x9D\x8C'
WARNING_MARK=$'\xE2\x9A\xA0'
DEPLOY_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# When deploy.sh is curl'd to /tmp/installer.sh, DEPLOY_SCRIPT_DIR is /tmp and has no scripts/.
# After the repo is cloned (or already present), rebind to the app tree so scripts/ resolve.
hesabix_bind_deploy_script_dir_to_repo() {
if [[ -d "${APP_ROOT}/app/scripts" ]]; then
DEPLOY_SCRIPT_DIR="${APP_ROOT}/app"
fi
}
# Prefer cloned repo scripts, then the directory beside this deploy.sh (dev checkout).
hesabix_find_repo_script() {
local name="$1"
local f
for f in \
"${APP_ROOT}/app/scripts/${name}" \
"${DEPLOY_SCRIPT_DIR}/scripts/${name}"; do
if [[ -f "${f}" ]]; then
printf '%s' "${f}"
return 0
fi
done
return 1
}
# If already installed (resume / re-run), bind early so helper sources can use repo scripts.
hesabix_bind_deploy_script_dir_to_repo
# shellcheck source=scripts/api_public_scheme.sh
if [[ -r "${DEPLOY_SCRIPT_DIR}/scripts/api_public_scheme.sh" ]]; then
# shellcheck disable=SC1091
source "${DEPLOY_SCRIPT_DIR}/scripts/api_public_scheme.sh"
fi
if ! declare -F hesabix_resolve_api_public_scheme >/dev/null 2>&1; then
hesabix_resolve_api_public_scheme() {
if [[ -n "${API_DOMAIN:-}" ]] && [[ -d "/etc/letsencrypt/live/${API_DOMAIN}" ]]; then
printf '%s' "https"; return 0
fi
local s="${API_PUBLIC_SCHEME:-}"
s="${s,,}"
case "$s" in http|https) printf '%s' "$s"; return 0 ;; esac
printf '%s' "http"
}
fi
# shellcheck source=scripts/hesabix_python.sh
for _hesabix_py_lib in \
"${DEPLOY_SCRIPT_DIR}/scripts/hesabix_python.sh" \
"${APP_ROOT}/app/scripts/hesabix_python.sh"; do
if [[ -r "${_hesabix_py_lib}" ]]; then
# shellcheck disable=SC1090
source "${_hesabix_py_lib}"
break
fi
done
unset _hesabix_py_lib
if ! declare -F hesabix_resolve_backend_python >/dev/null 2>&1; then
# Standalone: curl raw deploy.sh to /tmp — no scripts/ beside installer.sh
# shellcheck disable=SC1091
source /dev/stdin <<'HESABIX_PYTHON_INLINE'
: "${HESABIX_MIN_PYTHON_MAJOR:=3}"
: "${HESABIX_MIN_PYTHON_MINOR:=11}"
hesabix_python_version_ge() {
local ver="${1#Python }"
ver="${ver%% *}"
local req_major="$2" req_minor="$3"
local major minor
IFS=. read -r major minor _ <<< "${ver}"
major=${major:-0}
minor=${minor:-0}
if [[ "${major}" -gt "${req_major}" ]]; then return 0; fi
if [[ "${major}" -lt "${req_major}" ]]; then return 1; fi
[[ "${minor}" -ge "${req_minor}" ]]
}
hesabix_python_cmd_version() {
local cmd="$1"
"$cmd" --version 2>&1 | awk '{print $2}'
}
hesabix_python_cmd_meets_minimum() {
local cmd="$1" ver
command -v "${cmd}" >/dev/null 2>&1 || return 1
ver=$(hesabix_python_cmd_version "${cmd}")
hesabix_python_version_ge "${ver}" "${HESABIX_MIN_PYTHON_MAJOR}" "${HESABIX_MIN_PYTHON_MINOR}"
}
hesabix_resolve_backend_python() {
local cmd ver path
if [[ -n "${HESABIX_PYTHON:-}" ]]; then
if [[ -x "${HESABIX_PYTHON}" ]] && hesabix_python_cmd_meets_minimum "${HESABIX_PYTHON}"; then
printf '%s' "${HESABIX_PYTHON}"
return 0
fi
return 1
fi
for cmd in python3.13 python3.12 python3.11 python3; do
if hesabix_python_cmd_meets_minimum "${cmd}"; then
path=$(command -v "${cmd}")
HESABIX_PYTHON="${path}"
printf '%s' "${path}"
return 0
fi
done
return 1
}
hesabix_install_backend_python_packages() {
if hesabix_resolve_backend_python >/dev/null 2>&1; then return 0; fi
if ! command -v apt-get >/dev/null 2>&1; then return 1; fi
export DEBIAN_FRONTEND=noninteractive
local minor pkgs=()
for minor in 12 11; do
pkgs=("python3.${minor}" "python3.${minor}-venv" "python3.${minor}-dev")
if apt-get install -y "${pkgs[@]}"; then
if command -v "python3.${minor}" >/dev/null 2>&1; then return 0; fi
fi
done
return 1
}
hesabix_ensure_backend_python() {
if hesabix_resolve_backend_python >/dev/null 2>&1; then return 0; fi
hesabix_install_backend_python_packages || return 1
hesabix_resolve_backend_python >/dev/null 2>&1
}
hesabix_ensure_backend_venv() {
local api_dir="$1" python_bin="$2"
local venv_dir="${api_dir}/.venv"
local venv_py="${venv_dir}/bin/python"
HESABIX_VENV_RECREATED=0
if [[ ! -x "${python_bin}" ]]; then return 1; fi
if ! hesabix_python_cmd_meets_minimum "${python_bin}"; then return 1; fi
if [[ -d "${venv_dir}" ]] && [[ -x "${venv_py}" ]]; then
local venv_ver
venv_ver=$(hesabix_python_cmd_version "${venv_py}")
if ! hesabix_python_version_ge "${venv_ver}" "${HESABIX_MIN_PYTHON_MAJOR}" "${HESABIX_MIN_PYTHON_MINOR}"; then
rm -rf "${venv_dir}"
HESABIX_VENV_RECREATED=1
fi
fi
if [[ ! -d "${venv_dir}" ]]; then
"${python_bin}" -m venv "${venv_dir}" || return 1
HESABIX_VENV_RECREATED=1
fi
[[ -x "${venv_py}" ]]
}
HESABIX_PYTHON_INLINE
fi
# Load PyPI/Flutter mirror helpers: repo scripts/mirror_config.sh, or inline fallback for curl installer.
hesabix_load_mirror_config() {
if declare -F configure_pip_hesabix_mirror >/dev/null 2>&1; then
return 0
fi
local f
for f in \
"${DEPLOY_SCRIPT_DIR}/scripts/mirror_config.sh" \
"${APP_ROOT}/app/scripts/mirror_config.sh"; do
if [[ -r "${f}" ]]; then
# shellcheck disable=SC1090
source "${f}"
return 0
fi
done
# Standalone: curl raw deploy.sh to /tmp — no scripts/ beside installer.sh
# shellcheck disable=SC1091
source /dev/stdin <<'HESABIX_MIRROR_CONFIG_INLINE'
HESABIX_PIP_INDEX_URL="https://p.mirror.hesabix.ir/simple"
HESABIX_PIP_TRUSTED_HOST="p.mirror.hesabix.ir"
HESABIX_PIP_CHINA_INDEX_URL="https://mirrors.aliyun.com/pypi/simple"
HESABIX_PIP_CHINA_TRUSTED_HOST="mirrors.aliyun.com"
HESABIX_PIP_CHINA_SECONDARY_INDEX_URL="https://mirrors.cloud.tencent.com/pypi/simple"
HESABIX_PUB_HOSTED_URL="https://f.mirror.hesabix.ir/pub"
HESABIX_FLUTTER_STORAGE_BASE_URL="https://f.mirror.hesabix.ir/gcs"
hesabix_mirror_log_info() {
if declare -F log_info >/dev/null 2>&1; then log_info "$@"; else echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; fi
}
hesabix_mirror_log_warning() {
if declare -F log_warning >/dev/null 2>&1; then log_warning "$@"
elif declare -F log_warn >/dev/null 2>&1; then log_warn "$@"
else echo "WARNING: $*" >&2; fi
}
hesabix_normalize_pip_index_url() {
local url="${1%/}"
case "${url}" in */simple) printf '%s' "${url}" ;; *) printf '%s/simple' "${url}" ;; esac
}
hesabix_pip_trusted_host_from_url() {
local index_url="$1"
[[ "${index_url}" == *"pypi.org"* ]] && return 0
echo "${index_url}" | sed -n 's|https\?://\([^/]*\).*|\1|p'
}
hesabix_set_pip_mirror_for_url() {
local index_url
index_url="$(hesabix_normalize_pip_index_url "$1")"
export PIP_INDEX_URL="${index_url}"
if [[ "${index_url}" != *"pypi.org"* ]]; then
export PIP_TRUSTED_HOST="$(hesabix_pip_trusted_host_from_url "${index_url}")"
else unset PIP_TRUSTED_HOST; fi
}
hesabix_pip_append_trusted_host() {
local host="$1"; [[ -n "${host}" ]] || return 0
case " ${PIP_TRUSTED_HOST:-} " in *" ${host} "*) return 0 ;; esac
if [[ -n "${PIP_TRUSTED_HOST:-}" ]]; then export PIP_TRUSTED_HOST="${PIP_TRUSTED_HOST} ${host}"
else export PIP_TRUSTED_HOST="${host}"; fi
}
hesabix_apply_pip_china_fallback() {
[[ "${PIP_DISABLE_CHINA_FALLBACK:-0}" == "1" ]] && return 0
local china="${HESABIX_PIP_CHINA_INDEX_URL}" primary="${PIP_INDEX_URL:-}"
if [[ -z "${PIP_EXTRA_INDEX_URL:-}" ]]; then
if [[ "${primary}" == "${china}" ]]; then
export PIP_EXTRA_INDEX_URL="${HESABIX_PIP_CHINA_SECONDARY_INDEX_URL}"
hesabix_pip_append_trusted_host "mirrors.cloud.tencent.com"
else
export PIP_EXTRA_INDEX_URL="${china}"
hesabix_pip_append_trusted_host "${HESABIX_PIP_CHINA_TRUSTED_HOST}"
fi
else
local extra_host; extra_host="$(hesabix_pip_trusted_host_from_url "${PIP_EXTRA_INDEX_URL%% *}")"
[[ -n "${extra_host}" ]] && hesabix_pip_append_trusted_host "${extra_host}"
fi
hesabix_mirror_log_info "PyPI extra index (China fallback): ${PIP_EXTRA_INDEX_URL}"
}
hesabix_pip_index_fallback_urls() {
local -a urls=(); local u
[[ -n "${PIP_INDEX_URL:-}" ]] && urls+=("${PIP_INDEX_URL}")
[[ -n "${PIP_EXTRA_INDEX_URL:-}" ]] && urls+=("${PIP_EXTRA_INDEX_URL%% *}")
urls+=("${HESABIX_PIP_CHINA_INDEX_URL}" "${HESABIX_PIP_CHINA_SECONDARY_INDEX_URL}" "${HESABIX_PIP_INDEX_URL}")
local -A seen=()
for u in "${urls[@]}"; do
u="${u%/}"; [[ -n "$u" && -z "${seen[$u]:-}" ]] || continue
seen[$u]=1; printf '%s\n' "$u"
done
}
hesabix_pip_cmd_with_fallback() {
local pip_bin="$1"; shift
if [[ "${PIP_DISABLE_CHINA_FALLBACK:-0}" == "1" ]]; then "${pip_bin}" "$@"; return $?; fi
"${pip_bin}" "$@" && return 0
local url orig_index orig_extra orig_trust
orig_index="${PIP_INDEX_URL:-}"; orig_extra="${PIP_EXTRA_INDEX_URL:-}"; orig_trust="${PIP_TRUSTED_HOST:-}"
while IFS= read -r url; do
[[ -n "$url" && "$url" != "${orig_index}" ]] || continue
hesabix_mirror_log_warning "pip failed on ${orig_index:-<unset>}; retrying ${url}"
hesabix_set_pip_mirror_for_url "$url"
if [[ "$url" == "${HESABIX_PIP_CHINA_INDEX_URL}" ]]; then export PIP_EXTRA_INDEX_URL="${HESABIX_PIP_CHINA_SECONDARY_INDEX_URL}"
else export PIP_EXTRA_INDEX_URL="${HESABIX_PIP_CHINA_INDEX_URL}"; fi
hesabix_pip_append_trusted_host "$(hesabix_pip_trusted_host_from_url "${PIP_EXTRA_INDEX_URL}")"
if "${pip_bin}" "$@"; then hesabix_mirror_log_info "pip succeeded from ${url}"; return 0; fi
done < <(hesabix_pip_index_fallback_urls)
export PIP_INDEX_URL="${orig_index}" PIP_EXTRA_INDEX_URL="${orig_extra}" PIP_TRUSTED_HOST="${orig_trust}"
return 1
}
hesabix_resolve_pip_mirror_from_preset() {
local preset="${1:-hesabix}"; preset="${preset,,}"
case "${preset}" in
hesabix|default) hesabix_set_pip_mirror_for_url "${HESABIX_PIP_INDEX_URL}" ;;
official|pypi) hesabix_set_pip_mirror_for_url "https://pypi.org/simple" ;;
tuna|tsinghua) hesabix_set_pip_mirror_for_url "https://pypi.tuna.tsinghua.edu.cn/simple" ;;
aliyun) hesabix_set_pip_mirror_for_url "https://mirrors.aliyun.com/pypi/simple" ;;
custom)
if [[ -z "${PIP_INDEX_URL:-}" ]]; then
hesabix_mirror_log_warning "PIP_MIRROR=custom but PIP_INDEX_URL empty; using Hesabix."
PIP_MIRROR=hesabix; export PIP_MIRROR
hesabix_set_pip_mirror_for_url "${HESABIX_PIP_INDEX_URL}"; return 0
fi
hesabix_set_pip_mirror_for_url "${PIP_INDEX_URL}" ;;
*)
hesabix_mirror_log_warning "Unknown PIP_MIRROR='${preset}'; using Hesabix."
PIP_MIRROR=hesabix; export PIP_MIRROR
hesabix_set_pip_mirror_for_url "${HESABIX_PIP_INDEX_URL}" ;;
esac
}
hesabix_resolve_flutter_mirror_from_preset() {
local preset="${1:-hesabix}"; preset="${preset,,}"
case "${preset}" in
hesabix|default)
export PUB_HOSTED_URL="${HESABIX_PUB_HOSTED_URL}"
export FLUTTER_STORAGE_BASE_URL="${HESABIX_FLUTTER_STORAGE_BASE_URL}" ;;
pub_azs|pub-azs|azs)
export PUB_HOSTED_URL="https://pub-azs.ir"
export FLUTTER_STORAGE_BASE_URL="https://pub-azs.ir" ;;
flutter_io_cn|china|flutter-io)
export PUB_HOSTED_URL="https://pub.flutter-io.cn"
export FLUTTER_STORAGE_BASE_URL="https://storage.flutter-io.cn" ;;
tuna|tsinghua)
export PUB_HOSTED_URL="https://mirrors.tuna.tsinghua.edu.cn/dart-pub"
export FLUTTER_STORAGE_BASE_URL="https://mirrors.tuna.tsinghua.edu.cn/flutter" ;;
sjtu)
export PUB_HOSTED_URL="https://mirror.sjtu.edu.cn/dart-pub"
export FLUTTER_STORAGE_BASE_URL="https://mirror.sjtu.edu.cn" ;;
official|pubdev)
export PUB_HOSTED_URL="https://pub.dev"
export FLUTTER_STORAGE_BASE_URL="https://storage.googleapis.com" ;;
custom)
if [[ -z "${PUB_HOSTED_URL:-}" || -z "${FLUTTER_STORAGE_BASE_URL:-}" ]]; then
hesabix_mirror_log_warning "FLUTTER_MIRROR=custom but URLs missing; using Hesabix."
FLUTTER_MIRROR=hesabix; export FLUTTER_MIRROR
export PUB_HOSTED_URL="${HESABIX_PUB_HOSTED_URL}"
export FLUTTER_STORAGE_BASE_URL="${HESABIX_FLUTTER_STORAGE_BASE_URL}"; return 0
fi
export PUB_HOSTED_URL FLUTTER_STORAGE_BASE_URL ;;
*)
hesabix_mirror_log_warning "Unknown FLUTTER_MIRROR='${preset}'; using Hesabix."
FLUTTER_MIRROR=hesabix; export FLUTTER_MIRROR
export PUB_HOSTED_URL="${HESABIX_PUB_HOSTED_URL}"
export FLUTTER_STORAGE_BASE_URL="${HESABIX_FLUTTER_STORAGE_BASE_URL}" ;;
esac
}
hesabix_apply_pip_mirror_env() {
if [[ -n "${PIP_INDEX_URL:-}" ]]; then
hesabix_mirror_log_info "Using PyPI index from environment: PIP_INDEX_URL=${PIP_INDEX_URL}"
hesabix_set_pip_mirror_for_url "${PIP_INDEX_URL}"
hesabix_apply_pip_china_fallback; return 0
fi
PIP_MIRROR="${PIP_MIRROR:-hesabix}"; export PIP_MIRROR
hesabix_resolve_pip_mirror_from_preset "${PIP_MIRROR}"
hesabix_apply_pip_china_fallback
hesabix_mirror_log_info "Using PyPI mirror (${PIP_MIRROR}): ${PIP_INDEX_URL}"
}
hesabix_apply_flutter_mirror_env() {
if [[ -n "${PUB_HOSTED_URL:-}" && -n "${FLUTTER_STORAGE_BASE_URL:-}" ]]; then
export PUB_HOSTED_URL FLUTTER_STORAGE_BASE_URL
hesabix_mirror_log_info "Using Flutter mirrors from environment: PUB_HOSTED_URL=${PUB_HOSTED_URL}"; return 0
fi
FLUTTER_MIRROR="${FLUTTER_MIRROR:-hesabix}"; export FLUTTER_MIRROR
hesabix_resolve_flutter_mirror_from_preset "${FLUTTER_MIRROR}"
hesabix_mirror_log_info "Flutter pub/storage (${FLUTTER_MIRROR}): PUB_HOSTED_URL=${PUB_HOSTED_URL}"
}
hesabix_configure_pip_mirror() {
command -v python3 >/dev/null 2>&1 || return 0
hesabix_apply_pip_mirror_env
python3 -m pip config --user set global.index "${PIP_INDEX_URL}" 2>/dev/null || true
python3 -m pip config --user set global.index-url "${PIP_INDEX_URL}" 2>/dev/null || true
local primary_host; primary_host="$(hesabix_pip_trusted_host_from_url "${PIP_INDEX_URL}")"
[[ -n "${primary_host}" ]] && python3 -m pip config --user set global.trusted-host "${primary_host}" 2>/dev/null || true
[[ -n "${PIP_EXTRA_INDEX_URL:-}" ]] && python3 -m pip config --user set global.extra-index-url "${PIP_EXTRA_INDEX_URL%% *}" 2>/dev/null || true
hesabix_mirror_log_info "pip user config: ${PIP_INDEX_URL}"
}
hesabix_prompt_pip_mirror() {
[[ -n "${PIP_INDEX_URL:-}" ]] && return 0
PIP_MIRROR="${PIP_MIRROR:-}"; PIP_MIRROR="${PIP_MIRROR,,}"
[[ -n "${PIP_MIRROR}" ]] && return 0
echo
echo "Python package index (pip / PyPI) — for backend dependencies:"
echo " [1] Hesabix — p.mirror.hesabix.ir (default; recommended for Iran)"
echo " [2] Official — pypi.org"
echo " [3] Tsinghua mirror (China) — pypi.tuna.tsinghua.edu.cn"
echo " [4] Aliyun mirror (China) — mirrors.aliyun.com/pypi"
echo " [5] Custom URL"
read -rp "Choose [1-5] (default 1): " _pip_choice
_pip_choice=${_pip_choice:-1}
case "${_pip_choice}" in
2) PIP_MIRROR=official ;; 3) PIP_MIRROR=tuna ;; 4) PIP_MIRROR=aliyun ;;
5) PIP_MIRROR=custom; read -rp "Index URL (e.g. https://my.mirror/simple): " _pip_custom
[[ -n "${_pip_custom}" ]] && { PIP_INDEX_URL="$(hesabix_normalize_pip_index_url "${_pip_custom}")"; export PIP_INDEX_URL; } ;;
*) PIP_MIRROR=hesabix ;;
esac
export PIP_MIRROR
}
hesabix_prompt_flutter_mirror() {
[[ -n "${PUB_HOSTED_URL:-}" && -n "${FLUTTER_STORAGE_BASE_URL:-}" ]] && return 0
FLUTTER_MIRROR="${FLUTTER_MIRROR:-}"; FLUTTER_MIRROR="${FLUTTER_MIRROR,,}"
[[ -n "${FLUTTER_MIRROR}" ]] && return 0
echo
echo "Flutter/Dart package mirror (pub get and engine) — for UI build:"
echo " [1] Hesabix — f.mirror.hesabix.ir (default; recommended for Iran)"
echo " [2] pub-azs.ir — direct (Iran)"
echo " [3] China — pub.flutter-io.cn / storage.flutter-io.cn"
echo " [4] Tsinghua mirror (China)"
echo " [5] SJTU mirror (China)"
echo " [6] Official — pub.dev / storage.googleapis.com"
echo " [7] Custom URLs"
read -rp "Choose [1-7] (default 1): " _flutter_choice
_flutter_choice=${_flutter_choice:-1}
case "${_flutter_choice}" in
2) FLUTTER_MIRROR=pub_azs ;; 3) FLUTTER_MIRROR=flutter_io_cn ;; 4) FLUTTER_MIRROR=tuna ;;
5) FLUTTER_MIRROR=sjtu ;; 6) FLUTTER_MIRROR=official ;;
7) FLUTTER_MIRROR=custom
read -rp "PUB_HOSTED_URL (e.g. https://pub.example): " _pub_url
read -rp "FLUTTER_STORAGE_BASE_URL (e.g. https://storage.example): " _storage_url
[[ -n "${_pub_url}" && -n "${_storage_url}" ]] && {
export PUB_HOSTED_URL="${_pub_url%/}"; export FLUTTER_STORAGE_BASE_URL="${_storage_url%/}"; } ;;
*) FLUTTER_MIRROR=hesabix ;;
esac
export FLUTTER_MIRROR
}
hesabix_mirror_summary_pip() {
hesabix_apply_pip_mirror_env >/dev/null 2>&1 || true
if [[ "${PIP_MIRROR:-}" == "custom" ]]; then
echo " • PyPI (pip): custom — ${PIP_INDEX_URL:-}"
else echo " • PyPI (pip): ${PIP_MIRROR:-hesabix} — ${PIP_INDEX_URL:-}"; fi
if [[ -n "${PIP_EXTRA_INDEX_URL:-}" && "${PIP_DISABLE_CHINA_FALLBACK:-0}" != "1" ]]; then
echo " • PyPI extra: ${PIP_EXTRA_INDEX_URL}"; fi
}
hesabix_mirror_summary_flutter() {
hesabix_apply_flutter_mirror_env >/dev/null 2>&1 || true
if [[ "${FLUTTER_MIRROR:-}" == "custom" ]]; then
echo " • Flutter pub: custom — ${PUB_HOSTED_URL:-}"
echo " • Flutter storage: ${FLUTTER_STORAGE_BASE_URL:-}"
else
echo " • Flutter pub: ${FLUTTER_MIRROR:-hesabix} — ${PUB_HOSTED_URL:-}"
echo " • Flutter storage: ${FLUTTER_STORAGE_BASE_URL:-}"
fi
}
configure_pip_hesabix_mirror() { hesabix_configure_pip_mirror "$@"; }
set_pip_mirror_env() { hesabix_apply_pip_mirror_env "$@"; }
set_flutter_mirror_env() { hesabix_apply_flutter_mirror_env "$@"; }
HESABIX_MIRROR_CONFIG_INLINE
}
hesabix_load_mirror_config
# Initialize log file
init_log_file() {
mkdir -p "${APP_ROOT}"
local log_header="========================================
Hesabix Deployment Log
Started: $(date '+%Y-%m-%d %H:%M:%S')
========================================"
echo "${log_header}" > "${LOG_FILE}"
chmod 644 "${LOG_FILE}"
}
# Logging functions
log_info() {
local message="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[INFO] ${timestamp} - ${message}" >> "${LOG_FILE}"
echo "${message}"
}
log_success() {
local message="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[SUCCESS] ${timestamp} - ${message}" >> "${LOG_FILE}"
echo "$CHECK_MARK ${message}"
}
log_warning() {
local message="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[WARNING] ${timestamp} - ${message}" >> "${LOG_FILE}"
echo "$WARNING_MARK ${message}"
}
log_error() {
local message="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[ERROR] ${timestamp} - ${message}" >> "${LOG_FILE}"
echo "$CROSS_MARK ${message}" >&2
}
log_step() {
local message="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[STEP] ${timestamp} - ${message}" >> "${LOG_FILE}"
echo ">> ${message}"
}
# Generate random password if not provided
generate_password() {
openssl rand -base64 32 | tr -d "=+/" | cut -c1-25
}
# Calculate optimal worker count based on CPU cores
calculate_optimal_workers() {
local cpu_cores
if command -v nproc >/dev/null 2>&1; then
cpu_cores=$(nproc)
elif [[ -f /proc/cpuinfo ]]; then
cpu_cores=$(grep -c "^processor" /proc/cpuinfo)
else
cpu_cores=4 # Default fallback
fi
# Formula: (2 * CPU cores) + 1 for optimal performance
echo $((2 * cpu_cores + 1))
}
# Calculate optimal database pool settings based on worker count
calculate_db_pool_settings() {
local workers=$1
local pool_size max_overflow
# Base pool size per worker: 20 connections
# Max overflow per worker: 30 connections
# Total per worker: 50 connections
# With safety margin: use 80% of calculated value
pool_size=$((workers * 20))
max_overflow=$((workers * 30))
# Set reasonable limits (min 20, max 200 for pool_size)
if [[ $pool_size -lt 20 ]]; then
pool_size=20
elif [[ $pool_size -gt 200 ]]; then
pool_size=200
fi
# Set reasonable limits for max_overflow (min 30, max 300)
if [[ $max_overflow -lt 30 ]]; then
max_overflow=30
elif [[ $max_overflow -gt 300 ]]; then
max_overflow=300
fi
echo "${pool_size}:${max_overflow}"
}
# MemTotal from /proc/meminfo (kilobytes)
get_mem_total_kb() {
if [[ -r /proc/meminfo ]]; then
awk '/^MemTotal:/ {print int($2); exit}' /proc/meminfo 2>/dev/null || echo "0"
else
echo "0"
fi
}
# Minimum 5.5 GiB RAM required for Hesabix install
check_minimum_ram() {
local mem_kb min_kb mem_mb min_mb
mem_kb=$(get_mem_total_kb)
if [[ ! "${mem_kb}" =~ ^[0-9]+$ ]] || [[ "${mem_kb}" -eq 0 ]]; then
log_error "Cannot read RAM from /proc/meminfo. Installation aborted."
exit 1
fi
# 5.5 GiB = 5.5 * 1024 * 1024 kB
min_kb=$(( (11 * 1024 * 1024) / 2 ))
mem_mb=$((mem_kb / 1024))
min_mb=$((min_kb / 1024))
if [[ "${mem_kb}" -lt "${min_kb}" ]]; then
log_error "Hesabix requires at least ${min_mb} MB RAM (~5.5 GiB). MemTotal is ~${mem_mb} MB."
log_error "Upgrade the server or add sufficient swap/RAM, then run again."
exit 1
fi
log_success "RAM check passed: ~${mem_mb} MB (minimum required: ${min_mb} MB)"
}
# Set PostgreSQL memory profile variables from MemTotal (kB)
assign_pg_memory_profile_from_mem_kb() {
local mem_kb="${1:-0}"
if [[ ! "${mem_kb}" =~ ^[0-9]+$ ]]; then
mem_kb=0
fi
# Thresholds in kB; ~7.5GiB tier for advertised "8GB" VPS (MemTotal ~7.8GiB)
# 16GiB=16777216, 32GiB=33554432
if [[ "${mem_kb}" -ge 33554432 ]]; then
PG_SHARED_BUFFERS="8GB"
PG_EFFECTIVE_CACHE_SIZE="24GB"
PG_WORK_MEM="16MB"
PG_MAINTENANCE_WORK_MEM="512MB"
PG_MAX_CONN_CAP=800
elif [[ "${mem_kb}" -ge 16777216 ]]; then
PG_SHARED_BUFFERS="4GB"
PG_EFFECTIVE_CACHE_SIZE="12GB"
PG_WORK_MEM="12MB"
PG_MAINTENANCE_WORK_MEM="256MB"
PG_MAX_CONN_CAP=500
elif [[ "${mem_kb}" -ge 7864320 ]]; then
PG_SHARED_BUFFERS="1GB"
PG_EFFECTIVE_CACHE_SIZE="6GB"
PG_WORK_MEM="8MB"
PG_MAINTENANCE_WORK_MEM="128MB"
PG_MAX_CONN_CAP=280
else
PG_SHARED_BUFFERS="512MB"
PG_EFFECTIVE_CACHE_SIZE="3GB"
PG_WORK_MEM="4MB"
PG_MAINTENANCE_WORK_MEM="64MB"
PG_MAX_CONN_CAP=200
fi
}
# Reduce workers and pool so total DB connections stay within a RAM-based cap
tune_deployment_for_system_ram() {
local mem_kb need pool_settings cap
mem_kb=$(get_mem_total_kb)
if [[ ! "${mem_kb}" =~ ^[0-9]+$ ]] || [[ "${mem_kb}" -eq 0 ]]; then
log_warning "Could not read RAM; skipping automatic worker/pool tuning."
return 0
fi
assign_pg_memory_profile_from_mem_kb "${mem_kb}"
cap="${PG_MAX_CONN_CAP}"
need=$(( UVICORN_WORKERS * (DB_POOL_SIZE + DB_MAX_OVERFLOW) + 100 ))
while [[ "${need}" -gt "${cap}" ]] && [[ "${UVICORN_WORKERS}" -gt 1 ]]; do
UVICORN_WORKERS=$((UVICORN_WORKERS - 1))
pool_settings=$(calculate_db_pool_settings "${UVICORN_WORKERS}")
DB_POOL_SIZE=$(echo "${pool_settings}" | cut -d: -f1)
DB_MAX_OVERFLOW=$(echo "${pool_settings}" | cut -d: -f2)
need=$(( UVICORN_WORKERS * (DB_POOL_SIZE + DB_MAX_OVERFLOW) + 100 ))
log_warning "Reduced UVICORN_WORKERS to ${UVICORN_WORKERS} to fit PostgreSQL connection cap (~${cap}) for this RAM."
done
while [[ "${need}" -gt "${cap}" ]]; do
if [[ "${DB_POOL_SIZE}" -gt 20 ]]; then
DB_POOL_SIZE=$((DB_POOL_SIZE - 10))
elif [[ "${DB_MAX_OVERFLOW}" -gt 30 ]]; then
DB_MAX_OVERFLOW=$((DB_MAX_OVERFLOW - 10))
else
log_warning "Required connections (${need}) exceed suggested cap (${cap}); PostgreSQL max_connections will be set above cap."
break
fi
need=$(( UVICORN_WORKERS * (DB_POOL_SIZE + DB_MAX_OVERFLOW) + 100 ))
log_warning "Reduced DB_POOL_SIZE/DB_MAX_OVERFLOW to fit RAM budget (approx. connections needed: ${need})."
done
log_info "Final RAM-based tuning: workers=${UVICORN_WORKERS}, pool_size=${DB_POOL_SIZE}, max_overflow=${DB_MAX_OVERFLOW}, suggested PG connection cap≈${cap}"
}
# Write conf.d/hesabix-optimization.conf from RAM profile and computed load
write_postgresql_hesabix_optimization_conf() {
local pg_version="$1"
local mem_kb max_conn pg_conf_dest tmpf workers pool ov
mem_kb=$(get_mem_total_kb)
assign_pg_memory_profile_from_mem_kb "${mem_kb}"
workers="${UVICORN_WORKERS:-1}"
pool="${DB_POOL_SIZE:-20}"
ov="${DB_MAX_OVERFLOW:-30}"
max_conn=$(( workers * (pool + ov) + 100 ))
if [[ "${max_conn}" -lt 100 ]]; then
max_conn=100
fi
if [[ "${max_conn}" -lt $((pool + ov + 20)) ]]; then
max_conn=$((pool + ov + 20))
fi
pg_conf_dest="/etc/postgresql/${pg_version}/main/conf.d/hesabix-optimization.conf"
sudo mkdir -p "/etc/postgresql/${pg_version}/main/conf.d"
tmpf="$(mktemp)"
{
echo "# Generated by Hesabix deploy.sh — do not edit by hand (re-run deploy or adjust deploy.sh)."
echo "# MemTotal ~ $((mem_kb / 1024)) MB — profile shared_buffers=${PG_SHARED_BUFFERS}"
echo ""
echo "# Hesabix app: uvicorn workers × (pool_size + max_overflow) + headroom"
echo "max_connections = ${max_conn}"
echo ""
echo "shared_buffers = ${PG_SHARED_BUFFERS}"
echo "effective_cache_size = ${PG_EFFECTIVE_CACHE_SIZE}"
echo "work_mem = ${PG_WORK_MEM}"
echo "maintenance_work_mem = ${PG_MAINTENANCE_WORK_MEM}"
echo ""
echo "random_page_cost = 1.1"
} > "${tmpf}"
sudo install -o postgres -g postgres -m 0644 "${tmpf}" "${pg_conf_dest}"
rm -f "${tmpf}"
log_success "PostgreSQL optimization config written: ${pg_conf_dest}"
}
# Detect major PostgreSQL version without a running server (rewrite conf before start)
detect_postgresql_major_version_for_config() {
local v d
if command -v pg_lsclusters >/dev/null 2>&1; then
v=$(pg_lsclusters 2>/dev/null | awk 'NR>1 && $1 ~ /^[0-9]+$/ {print $1; exit}')
if [[ -n "${v}" ]]; then
echo "${v}"
return 0
fi
fi
shopt -s nullglob
for d in /etc/postgresql/*/main; do
[[ -d "${d}" ]] || continue
v=$(basename "$(dirname "${d}")")
if [[ "${v}" =~ ^[0-9]+$ ]]; then
echo "${v}"
shopt -u nullglob
return 0
fi
done
shopt -u nullglob
return 1
}
# Reload PostgreSQL after conf.d change (postgresql@VERSION-main or meta service)
restart_postgresql_after_config_change() {
local pgv="${1:-}"
local meta_svc="${2:-}"
local ctl_out=""
[[ -z "${pgv}" ]] && return 0
systemctl daemon-reload 2>/dev/null || true
systemctl enable "postgresql@${pgv}-main" 2>/dev/null || true
if [[ -n "${meta_svc}" ]] && [[ "${meta_svc}" != "postgresql@${pgv}-main" ]]; then
systemctl restart "${meta_svc}" 2>/dev/null || true
fi
if systemctl restart "postgresql@${pgv}-main" 2>/dev/null; then
sleep 3
return 0
fi
log_warning "systemctl restart postgresql@${pgv}-main failed; trying pg_ctlcluster..."
if command -v pg_ctlcluster >/dev/null 2>&1; then
ctl_out=$(pg_ctlcluster "${pgv}" main start 2>&1) || true
[[ -n "${ctl_out}" ]] && log_info "${ctl_out}"
fi
systemctl restart postgresql 2>/dev/null || true
sleep 3
}
# Validate domain format
validate_domain() {
local domain="$1"
if [[ ! "${domain}" =~ ^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$ ]]; then
echo "$CROSS_MARK Invalid domain format: ${domain}"
return 1
fi
return 0
}
# Check if service is running
check_service() {
local service="$1"
if systemctl is-active --quiet "${service}"; then
return 0
else
return 1
fi
}
# Wait for PostgreSQL to be ready
wait_for_db() {
local max_attempts=45
local attempt=0
log_info "Waiting for PostgreSQL to be ready..."
while [ $attempt -lt $max_attempts ]; do
if command -v pg_isready >/dev/null 2>&1 && pg_isready -q 2>/dev/null; then
log_success "PostgreSQL is ready (pg_isready)"
return 0
fi
if sudo -u postgres psql -c "SELECT 1" >/dev/null 2>&1; then
log_success "PostgreSQL is ready"
return 0
fi
attempt=$((attempt + 1))
sleep 2
done
log_error "Database not ready after $max_attempts attempts"
log_error "Details: journalctl -xeu postgresql@*-main.service --no-pager | tail -100"
return 1
}
# Create or alter PostgreSQL role hesabix with password (any characters; uses dollar-quoting + format %L).
apply_postgres_hesabix_password() {
DB_PASSWORD="${DB_PASSWORD}" python3 <<'PY'
import os, subprocess, secrets
pw = os.environ["DB_PASSWORD"]
for _ in range(32):
delim = "h" + secrets.token_hex(16)
boundary = f"${delim}$"
if boundary not in pw:
break
else:
raise SystemExit("Could not pick a safe dollar-quote delimiter for the database password")
lit = boundary + pw + boundary
sql = (
"DO $do$\n"
"BEGIN\n"
" IF NOT EXISTS (SELECT FROM pg_user WHERE usename = 'hesabix') THEN\n"
" EXECUTE format('CREATE USER hesabix WITH PASSWORD %L', " + lit + ");\n"
" ELSE\n"
" EXECUTE format('ALTER USER hesabix WITH PASSWORD %L', " + lit + ");\n"
" END IF;\n"
"END\n"
"$do$;"
)
subprocess.run(
["sudo", "-u", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-c", sql],
check=True,
)
PY
}
# Merge production keys into hesabixAPI .env (values with special chars are quoted for dotenv).
merge_hesabix_api_env_file() {
local env_file="$1"
export MERGE_ENV_PATH="${env_file}"
export MERGE_DB_PASSWORD="${DB_PASSWORD}"
export MERGE_DB_POOL_SIZE="${DB_POOL_SIZE}"
export MERGE_DB_MAX_OVERFLOW="${DB_MAX_OVERFLOW}"
python3 <<'PY'
import os, re
path = os.environ["MERGE_ENV_PATH"]
def fmt_val(v: str) -> str:
if v is None:
return ""
if re.search(r'[\s#"\'\\]', v) or v.startswith("#"):
return '"' + v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'
return v
updates = {
"ENVIRONMENT": "production",
"DEBUG": "false",
"DB_USER": "hesabix",
"DB_PASSWORD": os.environ["MERGE_DB_PASSWORD"],
"DB_HOST": "127.0.0.1",
"DB_PORT": "5432",
"DB_NAME": "hesabix",
"LOG_LEVEL": "INFO",
"DB_POOL_SIZE": os.environ["MERGE_DB_POOL_SIZE"],
"DB_MAX_OVERFLOW": os.environ["MERGE_DB_MAX_OVERFLOW"],
"DB_POOL_TIMEOUT": "30",
"DB_POOL_RECYCLE": "300",
"CORS_ALLOWED_ORIGINS": '["*"]',
}
lines = []
if os.path.isfile(path):
with open(path, encoding="utf-8", errors="replace") as f:
lines = f.readlines()
keys_done = set()
key_re = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=")
out = []
for line in lines:
m = key_re.match(line)
if m and m.group(1) in updates:
k = m.group(1)
out.append(f"{k}={fmt_val(updates[k])}\n")
keys_done.add(k)
else:
out.append(line)
for k, v in updates.items():
if k not in keys_done:
out.append(f"{k}={fmt_val(v)}\n")
with open(path, "w", encoding="utf-8") as f:
f.writelines(out)
PY
}
# Check disk space (requires at least 2GB free)
check_disk_space() {
local available_space
local min_avail=999999999999
local space
for mnt in / "${APP_ROOT}"; do
[[ -d "${mnt}" ]] || continue
space=$(df -P "${mnt}" 2>/dev/null | tail -1 | awk '{print $4}')
[[ "${space}" =~ ^[0-9]+$ ]] || continue
if [ "${space}" -lt "${min_avail}" ]; then
min_avail="${space}"
fi
done
available_space="${min_avail}"
if [[ ! "${available_space}" =~ ^[0-9]+$ ]]; then
log_warning "Could not read free disk space; skipping disk check."
return 0
fi
if [ "$available_space" -lt 2097152 ]; then
log_warning "Low disk space (less than 2GB). This may cause issues."
read -rp "Continue anyway? (y/N): " continue_anyway
if [[ ! "${continue_anyway}" =~ ^[Yy]$ ]]; then
log_error "Installation aborted by user due to low disk space"
exit 1
fi
log_info "User chose to continue despite low disk space"
fi
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "$CROSS_MARK Required tool not found: $1"
exit 1
fi
}
# Check if OS is Debian or Ubuntu
check_os_compatibility() {
if [[ ! -f /etc/os-release ]]; then
log_error "Cannot detect operating system. /etc/os-release not found."
log_error "This script supports only Debian and Ubuntu."
exit 1
fi
# Source os-release to get distribution info
# shellcheck disable=SC1091
source /etc/os-release
local os_id="${ID:-}"
local os_id_like="${ID_LIKE:-}"
# Check if it's Ubuntu or Debian
if [[ "${os_id}" == "ubuntu" ]] || [[ "${os_id}" == "debian" ]]; then
log_success "Detected compatible OS: ${PRETTY_NAME:-${os_id}}"
return 0
fi
# Check ID_LIKE for compatibility (e.g., Ubuntu is based on Debian)
if [[ "${os_id_like}" == *"debian"* ]] || [[ "${os_id_like}" == *"ubuntu"* ]]; then
log_success "Detected compatible OS: ${PRETTY_NAME:-${os_id}} (based on Debian/Ubuntu)"
return 0
fi
log_error "Unsupported operating system detected: ${PRETTY_NAME:-${os_id}}"
log_error "This script supports only Debian and Ubuntu distributions."
log_error "Detected OS ID: ${os_id}"
if [[ -n "${os_id_like}" ]]; then
log_error "OS ID_LIKE: ${os_id_like}"
fi
exit 1
}
# State tracking functions for resume capability
mark_step_completed() {
local step="$1"
mkdir -p "${APP_ROOT}"
echo "${step}" >> "${STATE_FILE}"
}
check_step_completed() {
local step="$1"
if [[ -f "${STATE_FILE}" ]] && grep -q "^${step}$" "${STATE_FILE}"; then
return 0
fi
return 1
}
clear_deployment_state() {
if [[ -f "${STATE_FILE}" ]]; then
rm -f "${STATE_FILE}"
fi
}
# Load saved deploy inputs (domain, branch, pgAdmin4, etc.) as defaults. Only sets vars that are not already set (env overrides).
load_saved_deploy_vars() {
local file="${APP_ROOT}/.deploy_saved_vars"
[[ ! -f "${file}" ]] && return 0
log_info "Loading saved inputs from previous run (use env vars to override)..."
local line key val
while IFS= read -r line; do
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || continue
key="${BASH_REMATCH[1]}"
val="${BASH_REMATCH[2]}"
# Only set if not already set by environment
if [[ -z "${!key:-}" ]]; then
export "${key}=${val}"
fi
done < "${file}"
}
# Save current deploy inputs so next run can use them as defaults (resume after failure without re-entering).
save_deploy_saved_vars() {
mkdir -p "${APP_ROOT}"
local file="${APP_ROOT}/.deploy_saved_vars"
{
echo "API_DOMAIN=${API_DOMAIN:-}"
echo "UI_DOMAIN=${UI_DOMAIN:-}"
echo "BRANCH=${BRANCH:-main}"
echo "INSTALL_PGADMIN4=${INSTALL_PGADMIN4:-N}"
echo "PGADMIN4_DOMAIN=${PGADMIN4_DOMAIN:-}"
echo "PGADMIN4_EMAIL=${PGADMIN4_EMAIL:-}"
echo "PGADMIN4_PASSWORD=${PGADMIN4_PASSWORD:-}"
echo "UBUNTU_APT_MIRROR=${UBUNTU_APT_MIRROR:-}"
echo "INSTALL_VOICE=${INSTALL_VOICE:-N}"
echo "PIP_MIRROR=${PIP_MIRROR:-hesabix}"
echo "FLUTTER_MIRROR=${FLUTTER_MIRROR:-hesabix}"
echo "PIP_INDEX_URL=${PIP_INDEX_URL:-}"
echo "PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL:-}"
echo "PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST:-}"
echo "PUB_HOSTED_URL=${PUB_HOSTED_URL:-}"
echo "FLUTTER_STORAGE_BASE_URL=${FLUTTER_STORAGE_BASE_URL:-}"
} > "${file}"
chmod 600 "${file}"
log_info "Saved inputs for next run (${file})"
}
# Persist UBUNTU_APT_MIRROR into .deploy_saved_vars (after apt mirror step).
persist_ubuntu_apt_mirror_to_saved_vars() {
local f="${APP_ROOT}/.deploy_saved_vars"
[[ ! -f "${f}" ]] && return 0
local tmp
tmp=$(mktemp)
grep -v '^UBUNTU_APT_MIRROR=' "${f}" > "${tmp}" 2>/dev/null || true
echo "UBUNTU_APT_MIRROR=${UBUNTU_APT_MIRROR:-keep}" >> "${tmp}"
mv "${tmp}" "${f}"
chmod 600 "${f}"
}
# Ubuntu only: optional switch to Arvan apt mirror before apt-get update (deb822 + classic deb lines).
configure_ubuntu_apt_mirror() {
local id=""
# shellcheck disable=SC1091
[[ -f /etc/os-release ]] && source /etc/os-release
[[ "${ID:-}" == "ubuntu" ]] || return 0
local codename="${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}"
log_info "Ubuntu apt mirror: detected codename ${codename:-unknown}"
UBUNTU_APT_MIRROR="${UBUNTU_APT_MIRROR:-}"
UBUNTU_APT_MIRROR="${UBUNTU_APT_MIRROR,,}"
if [[ -z "${UBUNTU_APT_MIRROR}" ]]; then
echo
echo "APT package sources (Ubuntu only):"
echo " [1] Keep current system configuration (default)"
echo " [2] Arvan Cloud mirror — mirror.arvancloud.ir (replaces archive.ubuntu.com and security.ubuntu.com)"
echo " [3] Restore official Ubuntu — only if Arvan was configured (archive + security)"
read -rp "Choose [1/2/3] (default 1): " _apt_mirror_choice
_apt_mirror_choice=${_apt_mirror_choice:-1}
case "${_apt_mirror_choice}" in
2) UBUNTU_APT_MIRROR=arvan ;;
3) UBUNTU_APT_MIRROR=official ;;
*) UBUNTU_APT_MIRROR=keep ;;
esac
fi
export UBUNTU_APT_MIRROR
if [[ "${UBUNTU_APT_MIRROR}" == "keep" ]]; then
log_info "APT sources: leaving unchanged (UBUNTU_APT_MIRROR=keep)."
persist_ubuntu_apt_mirror_to_saved_vars
return 0
fi
if [[ "${UBUNTU_APT_MIRROR}" != "arvan" && "${UBUNTU_APT_MIRROR}" != "official" ]]; then
log_warning "Invalid UBUNTU_APT_MIRROR='${UBUNTU_APT_MIRROR}', using keep."
UBUNTU_APT_MIRROR=keep
export UBUNTU_APT_MIRROR
persist_ubuntu_apt_mirror_to_saved_vars
return 0
fi
local backup_dir="/etc/apt/hesabix-apt-mirror-backup-$(date +%Y%m%d%H%M%S)"
mkdir -p "${backup_dir}"
local candidates=()
local f
[[ -f /etc/apt/sources.list ]] && candidates+=("/etc/apt/sources.list")
for f in /etc/apt/sources.list.d/*.sources /etc/apt/sources.list.d/*.list; do
[[ -f "${f}" ]] || continue
candidates+=("${f}")
done
local files_to_edit=()
for f in "${candidates[@]}"; do
if grep -qE 'archive\.ubuntu\.com/ubuntu|security\.ubuntu\.com/ubuntu|mirror\.arvancloud\.ir/ubuntu' "${f}" 2>/dev/null; then
files_to_edit+=("${f}")
local rel="${f#/}"
rel="${rel//\//__}"
cp -a "${f}" "${backup_dir}/${rel}.bak"
fi
done
if [[ ${#files_to_edit[@]} -eq 0 ]]; then
log_info "No Ubuntu archive/security/Arvan lines found in apt sources; nothing to change."
persist_ubuntu_apt_mirror_to_saved_vars
return 0
fi
log_info "APT mirror mode: ${UBUNTU_APT_MIRROR} (backup: ${backup_dir})"
local apt_file_list
apt_file_list=$(printf '%s\n' "${files_to_edit[@]}")
UBUNTU_APT_MIRROR_MODE="${UBUNTU_APT_MIRROR}" HESABIX_APT_FILE_LIST="${apt_file_list}" python3 <<'PY'
import os, re, sys
mode = os.environ.get("UBUNTU_APT_MIRROR_MODE", "")
paths = [p for p in os.environ.get("HESABIX_APT_FILE_LIST", "").splitlines() if p.strip()]
ARVAN = "http://mirror.arvancloud.ir/ubuntu"
ARCHIVE = "http://archive.ubuntu.com/ubuntu"
SECURITY = "http://security.ubuntu.com/ubuntu"
def apply_arvan(text: str) -> str:
if "archive.ubuntu.com/ubuntu" not in text and "security.ubuntu.com/ubuntu" not in text:
return text
t = re.sub(r"https?://archive\.ubuntu\.com/ubuntu\b", ARVAN, text)
t = re.sub(r"https?://security\.ubuntu\.com/ubuntu\b", ARVAN, t)
return t
def apply_official(text: str) -> str:
if "mirror.arvancloud.ir" not in text:
return text
if re.search(r"(?m)^Types:\s*deb", text) and re.search(r"(?m)^URIs:", text):
blocks = re.split(r"\n{2,}", text)
out = []
for b in blocks:
if not b.strip():
continue
if re.search(r"(?m)^URIs:\s*https?://mirror\.arvancloud\.ir/ubuntu\b", b):
sm = re.search(r"(?m)^Suites:(.*)$", b)
suites = sm.group(1) if sm else ""
uri = SECURITY if re.search(r"\b[\w.-]+-security\b", suites) else ARCHIVE
b = re.sub(r"(?m)^URIs:\s*[^\n]+", "URIs: " + uri, b, count=1)
out.append(b)
joined = "\n\n".join(out)
if text.endswith("\n") and not joined.endswith("\n"):
joined += "\n"
return joined
lines = []
for line in text.splitlines(True):
if re.match(r"^\s*deb(-src)?\s+", line) and "mirror.arvancloud.ir" in line:
parts = line.split()
if len(parts) >= 3 and parts[0] in ("deb", "deb-src"):
suite = parts[2]
parts[1] = SECURITY if "-security" in suite else ARCHIVE
line = " ".join(parts) + ("\n" if line.endswith("\n") else "")
lines.append(line)
return "".join(lines)
for path in paths:
try:
with open(path, encoding="utf-8", errors="replace") as fh:
content = fh.read()
except OSError as e:
print(f"skip read {path}: {e}", file=sys.stderr)
continue
if mode == "arvan":
new = apply_arvan(content)
elif mode == "official":
new = apply_official(content)
else:
new = content
if new != content:
with open(path, "w", encoding="utf-8") as fh:
fh.write(new)
print(path)
PY
if ! apt-get update -y; then
log_error "apt-get update failed after mirror change; restoring from ${backup_dir}"
for f in "${files_to_edit[@]}"; do
local rel="${f#/}"
rel="${rel//\//__}"
if [[ -f "${backup_dir}/${rel}.bak" ]]; then
cp -a "${backup_dir}/${rel}.bak" "${f}"
fi
done
UBUNTU_APT_MIRROR=keep
export UBUNTU_APT_MIRROR
persist_ubuntu_apt_mirror_to_saved_vars
if apt-get update -y; then
log_warning "Restored previous apt sources; continuing with prerequisite install."
return 0
fi
return 1
fi
log_success "APT sources updated (${UBUNTU_APT_MIRROR}); apt-get update succeeded."
persist_ubuntu_apt_mirror_to_saved_vars
return 0
}
# Save deployment config for hesabix CLI (-update, -services, …) and install /usr/local/bin/hesabix
install_hesabix_command() {
mkdir -p "${APP_ROOT}"
local env_file="${APP_ROOT}/.deploy_env"
if declare -F hesabix_apply_pip_mirror_env >/dev/null 2>&1; then
hesabix_apply_pip_mirror_env
hesabix_apply_flutter_mirror_env
fi
# Stable anonymous install id for telemetry (preserve across re-deploys).
local telem=""
if telem="$(hesabix_find_repo_script hesabix_telemetry.sh 2>/dev/null)"; then
# shellcheck source=scripts/hesabix_telemetry.sh
# shellcheck disable=SC1090
source "${telem}"
hesabix_ensure_install_id || true
elif [[ -z "${INSTALL_ID:-}" ]]; then
if command -v uuidgen >/dev/null 2>&1; then
INSTALL_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')"
elif [[ -r /proc/sys/kernel/random/uuid ]]; then
INSTALL_ID="$(tr '[:upper:]' '[:lower:]' < /proc/sys/kernel/random/uuid)"
fi
fi
cat > "${env_file}" <<ENV
API_DOMAIN=${API_DOMAIN}
UI_DOMAIN=${UI_DOMAIN}
BRANCH=${BRANCH}
REPO_URL=${REPO_URL}
INSTALL_VOICE=${INSTALL_VOICE:-N}
PIP_MIRROR=${PIP_MIRROR:-hesabix}
FLUTTER_MIRROR=${FLUTTER_MIRROR:-hesabix}
PIP_INDEX_URL=${PIP_INDEX_URL:-}
PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL:-}
PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST:-}
PUB_HOSTED_URL=${PUB_HOSTED_URL:-}
FLUTTER_STORAGE_BASE_URL=${FLUTTER_STORAGE_BASE_URL:-}
INSTALL_ID=${INSTALL_ID:-}
ENV
chmod 600 "${env_file}"
log_info "Saved deployment config to ${env_file}"
local bin_hesabix="/usr/local/bin/hesabix"
local cli_src=""
if ! cli_src="$(hesabix_find_repo_script hesabix)"; then
log_error "CLI source not found: scripts/hesabix (expected at ${APP_ROOT}/app/scripts/hesabix)"
return 1
fi
if command -v install >/dev/null 2>&1; then
install -m 755 "${cli_src}" "${bin_hesabix}"
else
cp -f "${cli_src}" "${bin_hesabix}"
chmod 755 "${bin_hesabix}" 2>/dev/null || true
fi
log_success "Command installed: hesabix (e.g. sudo hesabix -update | sudo hesabix -domains show | sudo hesabix -ssl status | sudo hesabix -cli reload)"
}
reset_deployment_state() {
: "${RESET_STATE:=}"
if [[ -z "${RESET_STATE}" ]] && [[ -f "${STATE_FILE}" ]]; then
echo
read -rp "Previous deployment state found. Reset and start from beginning? (y/N): " RESET_STATE
RESET_STATE=${RESET_STATE:-N}
fi
if [[ "${RESET_STATE}" =~ ^[Yy]$ ]]; then
clear_deployment_state
echo "$CHECK_MARK Deployment state reset. Starting from beginning."
fi
}
show_license_info() {
cat <<LICENSE
╔═══════════════════════════════════════════════════════════════════════╗
║ Hesabix - Comprehensive Accounting System ║
║ Open Source Software under GPL v3 ║
╚═══════════════════════════════════════════════════════════════════════╝
📋 About the Software:
Hesabix is a complete and modern accounting system that includes:
• Powerful API (FastAPI + PostgreSQL)
• Beautiful User Interface (Flutter Web)
• Open source and free
👨‍💻 Developers:
Hesabix Team
Website: https://hesabix.ir
Support: https://hesabix.ir/support
📦 Project Repository:
https://source.hesabix.ir/hesabix/arc.git
📄 License:
This software is distributed under the GNU General Public License v3.0 (GPL-3.0).
Full license text: http://www.gnu.org/licenses/gpl-3.0.txt
Summary of Rights:
✓ You are free to run the software
✓ You are free to study and modify the software
✓ You are free to distribute the software
✓ You are free to distribute improved versions
Condition: Any distribution or modified version must be under the same GPL v3
license and source code must be made available.
⚠️ This software is provided WITHOUT ANY WARRANTY.
╔═══════════════════════════════════════════════════════════════════════╗
║ GNU GENERAL PUBLIC LICENSE ║
║ Version 3, 29 June 2007 ║
║ ║
║ Copyright (C) 2024 Hesabix Team <https://hesabix.ir> ║
║ ║
║ This program is free software: you can redistribute it and/or ║
║ modify it under the terms of the GNU General Public License as ║
║ published by the Free Software Foundation, either version 3 of the ║
║ License, or (at your option) any later version. ║
║ ║
║ This program is distributed in the hope that it will be useful, ║
║ but WITHOUT ANY WARRANTY; without even the implied warranty of ║
║ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU ║
║ General Public License for more details. ║
║ ║
║ You should have received a copy of the GNU General Public License ║
║ along with this program. If not, see ║
║ <http://www.gnu.org/licenses/>. ║
╚═══════════════════════════════════════════════════════════════════════╝
LICENSE
}
accept_license() {
: "${ACCEPT_LICENSE:=}"
if [[ -z "${ACCEPT_LICENSE}" ]]; then
echo
echo "⚠️ To continue installation, you must agree to the GNU GPL v3 license terms."
echo
read -rp "Do you agree to the terms of the GNU General Public License v3.0? (yes/no): " ACCEPT_LICENSE
fi
case "${ACCEPT_LICENSE}" in
[Yy][Ee][Ss]|y|Y)
echo "$CHECK_MARK License agreement accepted (GNU GPL v3.0)."
return 0
;;
*)
echo "$CROSS_MARK You must agree to the license terms to continue installation."
echo
echo "To view the full license text, visit:"
echo " http://www.gnu.org/licenses/gpl-3.0.txt"
echo
exit 1
;;
esac
}
prompt_vars() {
: "${API_DOMAIN:=}"
: "${UI_DOMAIN:=}"
: "${BRANCH:=main}"
: "${DB_PASSWORD:=}"
# Display in summary; default internal tarball FLUTTER_SDK_TARBALL_URL_INTERNAL ≈ 3.41.x stable
: "${FLUTTER_VERSION:=3.41.1}"
: "${UVICORN_WORKERS:=}"
# Calculate optimal worker count based on CPU cores if not provided
if [[ -z "${UVICORN_WORKERS}" ]]; then
UVICORN_WORKERS=$(calculate_optimal_workers)
log_info "Auto-calculated optimal worker count: ${UVICORN_WORKERS} (based on CPU cores)"
fi
# Calculate optimal database pool settings based on worker count
local pool_settings
pool_settings=$(calculate_db_pool_settings "${UVICORN_WORKERS}")
DB_POOL_SIZE=$(echo "${pool_settings}" | cut -d: -f1)
DB_MAX_OVERFLOW=$(echo "${pool_settings}" | cut -d: -f2)
log_info "Auto-calculated database pool settings: pool_size=${DB_POOL_SIZE}, max_overflow=${DB_MAX_OVERFLOW}"
tune_deployment_for_system_ram
if [[ -z "${API_DOMAIN}" ]]; then
read -rp "API domain (e.g., api.example.com): " API_DOMAIN
fi
if ! validate_domain "${API_DOMAIN}"; then
exit 1
fi
if [[ -z "${UI_DOMAIN}" ]]; then
read -rp "Frontend domain (e.g., app.example.com): " UI_DOMAIN
fi
if ! validate_domain "${UI_DOMAIN}"; then
exit 1
fi
if [[ -z "${BRANCH}" ]]; then
read -rp "Branch name (default: main): " BRANCH
BRANCH=${BRANCH:-main}
fi
# Generate or prompt for DB password
if [[ -z "${DB_PASSWORD}" ]]; then
if [[ -f "${APP_ROOT}/.db_password" ]]; then
DB_PASSWORD=$(cat "${APP_ROOT}/.db_password")
echo "$CHECK_MARK Using existing password from previous run"
else
read -rsp "Database password (empty for auto-generate): " DB_PASSWORD
echo
if [[ -z "${DB_PASSWORD}" ]]; then
DB_PASSWORD=$(generate_password)
echo "$CHECK_MARK Password auto-generated"
fi
# Save password for future runs
mkdir -p "${APP_ROOT}"
echo -n "${DB_PASSWORD}" > "${APP_ROOT}/.db_password"
chmod 600 "${APP_ROOT}/.db_password"
fi
else
# Save provided password
mkdir -p "${APP_ROOT}"
echo -n "${DB_PASSWORD}" > "${APP_ROOT}/.db_password"
chmod 600 "${APP_ROOT}/.db_password"
fi
# Prompt for pgAdmin4 installation (optional)
: "${INSTALL_PGADMIN4:=}"
: "${PGADMIN4_DOMAIN:=}"
: "${PGADMIN4_EMAIL:=}"
: "${PGADMIN4_PASSWORD:=}"
if [[ -z "${INSTALL_PGADMIN4}" ]]; then
echo
read -rp "Install pgAdmin4 (PostgreSQL web admin)? (y/N): " INSTALL_PGADMIN4
INSTALL_PGADMIN4=${INSTALL_PGADMIN4:-N}
fi
if [[ "${INSTALL_PGADMIN4}" =~ ^[Yy]$ ]]; then
if [[ -z "${PGADMIN4_DOMAIN}" ]]; then
read -rp "pgAdmin4 domain (e.g., pgadmin.example.com): " PGADMIN4_DOMAIN
fi
if ! validate_domain "${PGADMIN4_DOMAIN}"; then
echo "$WARNING_MARK Invalid pgAdmin4 domain. Skipping pgAdmin4 installation."
INSTALL_PGADMIN4="N"
else
if [[ -z "${PGADMIN4_EMAIL}" ]]; then
read -rp "pgAdmin4 admin email: " PGADMIN4_EMAIL
fi
if [[ -z "${PGADMIN4_PASSWORD}" ]]; then
read -rsp "pgAdmin4 admin password: " PGADMIN4_PASSWORD
echo
fi
fi
fi
# AI voice chat — optional (local STT/TTS)
: "${INSTALL_VOICE:=}"
if [[ -z "${INSTALL_VOICE}" ]]; then
echo
echo "AI voice chat: on-server speech recognition and TTS (no cloud API; needs more RAM/CPU)."
read -rp "Install AI voice chat dependencies (pip [voice], ~2–4GB disk)? (y/N): " INSTALL_VOICE
INSTALL_VOICE=${INSTALL_VOICE:-N}
fi
if declare -F hesabix_prompt_pip_mirror >/dev/null 2>&1; then
hesabix_prompt_pip_mirror
hesabix_prompt_flutter_mirror
hesabix_apply_pip_mirror_env
hesabix_apply_flutter_mirror_env
fi
save_deploy_saved_vars
export API_DOMAIN UI_DOMAIN BRANCH DB_PASSWORD UVICORN_WORKERS FLUTTER_VERSION INSTALL_PGADMIN4 PGADMIN4_DOMAIN PGADMIN4_EMAIL PGADMIN4_PASSWORD INSTALL_VOICE DB_POOL_SIZE DB_MAX_OVERFLOW PIP_MIRROR FLUTTER_MIRROR PIP_INDEX_URL PIP_EXTRA_INDEX_URL PIP_TRUSTED_HOST PUB_HOSTED_URL FLUTTER_STORAGE_BASE_URL
}
# Show configuration summary and ask for confirmation
show_config_summary() {
echo
echo "=========================================="
echo " Configuration Summary"
echo "=========================================="
echo
echo "Domains:"
echo " • API Domain: ${API_DOMAIN}"
echo " • UI Domain: ${UI_DOMAIN}"
echo
echo "Repository Settings:"
echo " • Branch: ${BRANCH}"
echo " • Repository: ${REPO_URL}"
echo
echo "Database Settings:"
echo " • Database Name: hesabix"
echo " • Database User: hesabix"
echo " • Database Host: 127.0.0.1:5432"
if [[ -f "${APP_ROOT}/.db_password" ]]; then
echo " • Password: (Using existing password)"
else
echo " • Password: (Auto-generated)"
fi
echo
echo "Server Settings:"
echo " • Uvicorn Workers: ${UVICORN_WORKERS} (auto-calculated based on CPU cores)"
echo " • Flutter Version: ${FLUTTER_VERSION}"
echo " • DB Pool Size: ${DB_POOL_SIZE} (auto-calculated)"
echo " • DB Max Overflow: ${DB_MAX_OVERFLOW} (auto-calculated)"
echo
if [[ "${INSTALL_PGADMIN4}" =~ ^[Yy]$ ]]; then
echo "pgAdmin4:"
echo " • Domain: ${PGADMIN4_DOMAIN}"
echo " • Admin Email: ${PGADMIN4_EMAIL}"
echo
else
echo "pgAdmin4: Not installed"
echo
fi
if [[ "${INSTALL_VOICE}" =~ ^[Yy]$ ]]; then
echo "AI Voice Chat: Enabled (local Whisper + Coqui TTS)"
echo " • Data dir: /var/lib/hesabix/voice-data"
else
echo "AI Voice Chat: Not installed (optional)"
echo
fi
echo "Package mirrors:"
if declare -F hesabix_mirror_summary_pip >/dev/null 2>&1; then
hesabix_mirror_summary_pip
hesabix_mirror_summary_flutter
else
echo " • PyPI (pip): hesabix (default)"
echo " • Flutter: hesabix (default)"
fi
echo
echo "Installation Paths:"
echo " • Application: ${APP_ROOT}/app"
echo " • Frontend: /var/www/${UI_DOMAIN}"
echo " • Log File: ${LOG_FILE}"
echo
echo "=========================================="
echo
}
# Ask user for final confirmation before starting installation
confirm_installation() {
: "${CONFIRM_INSTALL:=}"
if [[ -z "${CONFIRM_INSTALL}" ]]; then
echo "⚠️ Do you want to start installation with these settings?"
echo
read -rp "Confirm installation (yes/no): " CONFIRM_INSTALL
fi
case "${CONFIRM_INSTALL}" in
[Yy][Ee][Ss]|yes|y|Y)
log_info "User confirmed installation. Starting deployment..."
return 0
;;
*)
log_info "Installation cancelled by user."
echo
echo "$CROSS_MARK Installation cancelled."
echo "To start again, please run the script again."
echo
exit 0
;;
esac
}
install_prereqs() {
log_step "Installing prerequisites..."
export DEBIAN_FRONTEND=noninteractive
configure_ubuntu_apt_mirror
# Update package list
log_info "Updating package list..."
apt-get update -y
# hesabix-api requires Python >= 3.11 (pyproject.toml). Ubuntu 24.04: python3=3.12;
# Ubuntu 22.04: python3=3.10 — install python3.11 when needed (scripts/hesabix_python.sh).
# WeasyPrint (PDF) requires: libcairo2, libpango*, libgdk-pixbuf-2.0-0 (note: hyphen in package name on Ubuntu 24)
log_info "Installing: git, curl, unzip, xz-utils, ca-certificates, rsync, python3, python3-venv, python3-pip, build-essential, nginx, postgresql, postgresql-contrib, postgresql-client, redis-server, WeasyPrint system deps (libpango/cairo)..."
apt-get install -y git curl unzip xz-utils ca-certificates rsync \
python3 python3-venv python3-pip build-essential \
nginx postgresql postgresql-contrib postgresql-client redis-server \
libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf-2.0-0 libffi-dev shared-mime-info
if ! hesabix_ensure_backend_python; then
log_error "hesabix-api requires Python >= 3.11. Install python3.11 (or newer) and re-run deploy."
exit 1
fi
local backend_python backend_py_ver
backend_python=$(hesabix_resolve_backend_python)
backend_py_ver=$("${backend_python}" --version 2>&1)
log_info "Backend Python: ${backend_py_ver} (${backend_python})"
if command -v python3 >/dev/null 2>&1; then
PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}')
log_info "System default python3: ${PYTHON_VERSION}"
fi
# Ensure PostgreSQL service is enabled and started
if command -v systemctl >/dev/null 2>&1; then
# Check if postgresql service exists (may be postgresql or postgresql@*)
if systemctl list-unit-files 2>/dev/null | grep -qE "postgresql(@|\.service)"; then
log_info "Enabling and starting PostgreSQL service..."
systemctl enable postgresql 2>/dev/null || true
systemctl start postgresql 2>/dev/null || true
# Also try specific version service (e.g., postgresql@16-main)
local pg_service
pg_service=$(systemctl list-units --type=service --state=inactive,active 2>/dev/null | grep -oE "postgresql@[0-9]+-main" | head -1 || echo "")
if [[ -n "${pg_service}" ]]; then
log_info "Starting PostgreSQL service: ${pg_service}"
systemctl enable "${pg_service}" 2>/dev/null || true
systemctl start "${pg_service}" 2>/dev/null || true
fi
fi
# Ensure Redis service is enabled and started
if systemctl list-unit-files 2>/dev/null | grep -qE "redis(@|\.service|server)"; then
log_info "Enabling and starting Redis service..."
systemctl enable redis-server 2>/dev/null || systemctl enable redis 2>/dev/null || true
systemctl start redis-server 2>/dev/null || systemctl start redis 2>/dev/null || true
fi
fi
# pgvector for semantic RAG search (optional; non-fatal if apt package missing)
local pgvector_script=""
if pgvector_script="$(hesabix_find_repo_script ensure_pgvector.sh)"; then
chmod +x "${pgvector_script}" 2>/dev/null || true
log_info "Ensuring PostgreSQL pgvector package (optional)..."
if bash "${pgvector_script}"; then
log_success "pgvector package check completed."
else
log_warning "pgvector package install skipped or failed (non-fatal)."
fi
fi
log_success "Prerequisites installed (or already present)."
}
clone_repo() {
log_step "Cloning/updating repository..."
mkdir -p "${APP_ROOT}"
cd "${APP_ROOT}"
# If app exists but .git is missing (e.g. previous clone failed halfway), remove for fresh clone
if [[ -d "${APP_ROOT}/app" ]] && [[ ! -d "${APP_ROOT}/app/.git" ]]; then
log_info "Removing incomplete app directory for fresh clone..."
rm -rf "${APP_ROOT}/app"
fi
if [[ ! -d "${APP_ROOT}/app/.git" ]]; then
log_info "Cloning repository..."
# Try to clone with specified branch first
if git clone -b "${BRANCH}" "${REPO_URL}" app 2>/dev/null; then
cd app
log_success "Repository cloned successfully on branch: ${BRANCH}"
else
# If branch doesn't exist, clone without branch and use default
log_warning "Branch '${BRANCH}' not found. Cloning default branch..."
if ! git clone "${REPO_URL}" app; then
log_error "Error cloning repository"
exit 1
fi
cd app
# Detect default branch (git clone automatically checks out default branch)
local default_branch
default_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
if [[ -n "${default_branch}" ]]; then
BRANCH="${default_branch}"
log_info "Using default branch: ${default_branch}"
else
log_warning "Could not detect default branch. Using current HEAD."
fi
fi
else
log_info "Updating existing repository..."
cd app
# Save current branch
local current_branch
current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
# Fetch all branches
if ! git fetch --all --prune; then
log_warning "Error fetching. Continuing with current state..."
fi
# Checkout target branch
if ! git checkout "${BRANCH}" 2>/dev/null; then
log_warning "Branch ${BRANCH} not found. Using current branch: ${current_branch}"
BRANCH="${current_branch}"
fi
# Try to pull, but don't fail if it's not a fast-forward
if ! git pull --ff-only 2>/dev/null; then
log_warning "Pull failed (may need merge). Using current state..."
git reset --hard "origin/${BRANCH}" 2>/dev/null || true
fi
fi
# Verify we're on the right branch
local actual_branch
actual_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
log_success "Repository ready at ${APP_ROOT}/app (branch: ${actual_branch})"
# Standalone curl installs: switch script root from /tmp to the cloned tree.
hesabix_bind_deploy_script_dir_to_repo
}
setup_db() {
log_step "Configuring database (PostgreSQL)..."
# If conf.d was written with unrealistic shared_buffers, PostgreSQL won't start and
# wait_for_db never succeeds; detect version from disk first, write conf, then restart.
local pg_opt_applied_early=false
local pg_ver_early=""
pg_ver_early=$(detect_postgresql_major_version_for_config 2>/dev/null || true)
if [[ -n "${pg_ver_early}" ]] && [[ "${pg_ver_early}" =~ ^[0-9]+$ ]]; then
log_info "Applying preventive PostgreSQL settings (version ${pg_ver_early}) so the service starts with valid conf..."
write_postgresql_hesabix_optimization_conf "${pg_ver_early}"
restart_postgresql_after_config_change "${pg_ver_early}" ""
pg_opt_applied_early=true
fi
# Start and enable PostgreSQL service
# PostgreSQL service may be named 'postgresql' or 'postgresql@VERSION-main'
local pg_service_found=false
local pg_service=""
local unit_files=""
unit_files=$(systemctl list-unit-files --no-pager --no-legend 2>/dev/null || true)
# list-units hides inactive units; list-unit-files lists all installed units
if echo "${unit_files}" | grep -qE '^postgresql@[0-9]+-main\.service[[:space:]]'; then
pg_service=$(echo "${unit_files}" | awk '/^postgresql@[0-9]+-main\.service[[:space:]]/ { sub(/\.service$/, "", $1); print $1; exit }')
pg_service_found=true
elif echo "${unit_files}" | grep -qE '^postgresql\.service[[:space:]]'; then
pg_service="postgresql"
pg_service_found=true
fi
if [[ "${pg_service_found}" == "true" ]]; then
log_info "Starting PostgreSQL service: ${pg_service:-postgresql}..."
systemctl enable "${pg_service:-postgresql}" 2>/dev/null || true
systemctl start "${pg_service:-postgresql}" 2>/dev/null || true
# Wait a moment for service to start
sleep 2
else
# Check if PostgreSQL is actually installed
if ! command -v psql >/dev/null 2>&1 && ! command -v postgres >/dev/null 2>&1; then
log_error "PostgreSQL is not installed. Please ensure prerequisites installation completed successfully."
exit 1
fi
log_warning "PostgreSQL systemd unit not found in list-unit-files. Trying meta service and pg_ctlcluster..."
systemctl daemon-reload 2>/dev/null || true
systemctl enable postgresql 2>/dev/null || true
systemctl start postgresql 2>/dev/null || true
if command -v pg_ctlcluster >/dev/null 2>&1; then
local pg_clust_ver=""
pg_clust_ver="${pg_ver_early:-}"
if [[ -z "${pg_clust_ver}" ]]; then
pg_clust_ver=$(pg_lsclusters 2>/dev/null | awk 'NR>1 && $1 ~ /^[0-9]+$/ {print $1; exit}')
fi
if [[ -n "${pg_clust_ver}" ]]; then
log_info "Starting PostgreSQL cluster ${pg_clust_ver}/main via pg_ctlcluster..."
local pg_start_out=""
pg_start_out=$(pg_ctlcluster "${pg_clust_ver}" main start 2>&1) || true
[[ -n "${pg_start_out}" ]] && log_info "${pg_start_out}"
fi
fi
sleep 2
fi
# If we know the version but the cluster is still down, try explicit enable/start once more
if [[ -n "${pg_ver_early}" ]] && [[ "${pg_ver_early}" =~ ^[0-9]+$ ]]; then
systemctl enable "postgresql@${pg_ver_early}-main" 2>/dev/null || true
systemctl start "postgresql@${pg_ver_early}-main" 2>/dev/null || true
if command -v pg_ctlcluster >/dev/null 2>&1; then
if command -v pg_isready >/dev/null 2>&1 && ! pg_isready -q 2>/dev/null; then
local pg_boot_out=""
pg_boot_out=$(pg_ctlcluster "${pg_ver_early}" main start 2>&1) || true
[[ -n "${pg_boot_out}" ]] && log_info "${pg_boot_out}"
fi
fi
sleep 1
fi
# Wait for database to be ready
if ! wait_for_db; then
log_error "Database not ready. Please check PostgreSQL installation and service status."
log_error "You can check PostgreSQL status with: systemctl status postgresql"
exit 1
fi
# Configure PostgreSQL to allow local connections (if needed)
local pg_version
local ver_num
ver_num=$(sudo -u postgres psql -tAc "SHOW server_version_num;" 2>/dev/null | tr -d '[:space:]')
if [[ -n "${ver_num}" ]] && [[ "${ver_num}" =~ ^[0-9]+$ ]]; then
pg_version=$((10#${ver_num} / 10000))
else
pg_version=$(sudo -u postgres psql -tAc "SHOW server_version;" 2>/dev/null | cut -d. -f1 | tr -d '[:space:]')
fi
if [[ -z "${pg_version}" ]] || [[ ! "${pg_version}" =~ ^[0-9]+$ ]]; then
log_error "Could not detect PostgreSQL major version for config paths."
exit 1
fi
local pg_hba="/etc/postgresql/${pg_version}/main/pg_hba.conf"
# Allow password authentication for localhost connections
# Use scram-sha-256 (more secure than md5) if PostgreSQL version supports it
# For PostgreSQL 10+, scram-sha-256 is the default and recommended method
if [[ -f "${pg_hba}" ]] && ! grep -q "host.*hesabix.*127.0.0.1/32" "${pg_hba}"; then
# Try scram-sha-256 first (PostgreSQL 10+), fallback to md5 for older versions
if [[ "${pg_version}" -ge 10 ]]; then
echo "host hesabix hesabix 127.0.0.1/32 scram-sha-256" >> "${pg_hba}"
else
echo "host hesabix hesabix 127.0.0.1/32 md5" >> "${pg_hba}"
fi
systemctl reload postgresql || true
fi
# Create database and user (password may contain quotes/special characters)
if ! apply_postgres_hesabix_password; then
log_error "Failed to create/update PostgreSQL user hesabix (password apply failed)."
exit 1
fi
# Create database separately to avoid issues with conditional creation
if ! sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname = 'hesabix'" | grep -q 1; then
sudo -u postgres createdb -O hesabix -E UTF8 -T template0 hesabix
fi
# Grant privileges
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE hesabix TO hesabix;"
# hesabix must own public schema so pg_restore/CREATE TABLE works (Ubuntu default owner is postgres).
sudo -u postgres psql -d hesabix -v ON_ERROR_STOP=1 <<'SQL'
ALTER SCHEMA public OWNER TO hesabix;
GRANT ALL ON SCHEMA public TO hesabix;
GRANT CREATE ON SCHEMA public TO hesabix;
SQL
# Verify connection
if PGPASSWORD="${DB_PASSWORD}" psql -U hesabix -h 127.0.0.1 -d hesabix -c "SELECT 1" >/dev/null 2>&1; then
log_success "Database and user created, connection verified."
else
log_warning "Connection test failed, but database may still be accessible. Continuing..."
fi
if [[ "${pg_opt_applied_early}" != "true" ]]; then
log_info "Applying PostgreSQL settings after service is up (version ${pg_version})..."
write_postgresql_hesabix_optimization_conf "${pg_version}"
restart_postgresql_after_config_change "${pg_version}" "${pg_service:-}"
fi
}
hesabix_db_table_exists() {
local table="$1"
# Use postgres superuser: pg_restore --no-owner leaves objects owned by postgres;
# hesabix cannot see them in information_schema until grants/ownership are fixed.
sudo -u postgres psql -d hesabix -tAc \
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='${table}');" \
2>/dev/null | tr -d '[:space:]'
}
hesabix_current_alembic_revision() {
if [[ "$(hesabix_db_table_exists alembic_version)" != "t" ]]; then
return 0
fi
PGPASSWORD="${DB_PASSWORD}" psql -h 127.0.0.1 -p 5432 -U hesabix -d hesabix -tAc \
"SELECT version_num FROM alembic_version LIMIT 1;" 2>/dev/null | tr -d '[:space:]'
}
hesabix_validate_seed_schema() {
local t missing=()
for t in documents document_lines businesses users accounts; do
if [[ "$(hesabix_db_table_exists "${t}")" != "t" ]]; then
missing+=("${t}")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
log_error "Seed import incomplete. Missing core tables: ${missing[*]}"
log_error "See ${APP_ROOT}/pg_restore_seed.log — drop/recreate DB and re-run deploy, or fix the seed dump."
exit 1
fi
}
# True when seed/base schema is present (not just alembic_version from a failed run).
hesabix_db_is_fully_initialized() {
[[ "$(hesabix_db_table_exists businesses)" == "t" ]] \
&& [[ "$(hesabix_db_table_exists document_lines)" == "t" ]] \
&& [[ "$(hesabix_db_table_exists documents)" == "t" ]]
}
hesabix_dump_archive_format_version() {
local dump="$1"
python3 - "${dump}" <<'PY'
import struct, sys
path = sys.argv[1]
try:
with open(path, "rb") as f:
if f.read(5) != b"PGDMP":
raise SystemExit(0)
vmaj, vmin = struct.unpack(">BB", f.read(2))
print(f"{vmaj}.{vmin}")
except OSError:
pass
PY
}
# Minimum pg_restore major required to read a custom archive format (not the dump source version).
hesabix_pg_major_for_archive_format() {
local fmt="${1:-}"
case "${fmt}" in
1.16) printf '%s' "17" ;;
1.15|1.14) printf '%s' "16" ;;
1.13) printf '%s' "15" ;;
1.12) printf '%s' "14" ;;
1.11|1.10) printf '%s' "13" ;;
*) return 1 ;;
esac
}
hesabix_detect_postgresql_server_major() {
local ver_num v
ver_num=$(sudo -u postgres psql -tAc "SHOW server_version_num;" 2>/dev/null | tr -d '[:space:]')
if [[ -n "${ver_num}" ]] && [[ "${ver_num}" =~ ^[0-9]+$ ]]; then
echo $((10#${ver_num} / 10000))
return 0
fi
detect_postgresql_major_version_for_config 2>/dev/null || return 1
}
hesabix_list_installed_pg_client_majors() {
local m bin
for m in 18 17 16 15 14 13; do
bin="/usr/lib/postgresql/${m}/bin/pg_restore"
[[ -x "${bin}" ]] && echo "${m}"
done
}
hesabix_ensure_pgdg_apt_repo() {
if [[ -f /etc/apt/sources.list.d/pgdg.list ]]; then
return 0
fi
export DEBIAN_FRONTEND=noninteractive
apt-get install -y curl ca-certificates gnupg lsb-release >/dev/null 2>&1 || true
install -d /usr/share/postgresql-common/pgdg
if ! curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --batch --yes --dearmor -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg 2>/dev/null; then
return 1
fi
local codename
codename=$(. /etc/os-release 2>/dev/null && printf '%s' "${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}")
[[ -z "${codename}" ]] && return 1
printf '%s\n' \
"deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
apt-get update -y >/dev/null 2>&1 || return 1
}
hesabix_ensure_postgresql_client_major() {
local major="$1"
local bin="/usr/lib/postgresql/${major}/bin/pg_restore"
[[ -x "${bin}" ]] && return 0
log_info "Installing postgresql-client-${major} (required to read seed dump format)..."
hesabix_ensure_pgdg_apt_repo || return 1
apt-get install -y "postgresql-client-${major}" || return 1
[[ -x "${bin}" ]]
}
# Pick pg_restore that can read the dump and preferably matches the target server major
# (pg_restore newer than server may emit SET transaction_timeout on PG < 17).
hesabix_resolve_pg_restore_for_dump() {
local dump="$1" fmt min_major server_major m bin install_m
local -a installed=()
fmt=$(hesabix_dump_archive_format_version "${dump}")
min_major=""
if [[ -n "${fmt}" ]]; then
min_major=$(hesabix_pg_major_for_archive_format "${fmt}" 2>/dev/null) || min_major=""
fi
server_major=$(hesabix_detect_postgresql_server_major 2>/dev/null) || server_major=""
while IFS= read -r m; do
[[ -n "${m}" ]] && installed+=("${m}")
done < <(hesabix_list_installed_pg_client_majors)
if [[ -n "${min_major}" ]]; then
if [[ -n "${server_major}" ]] && [[ "${server_major}" -ge "${min_major}" ]]; then
bin="/usr/lib/postgresql/${server_major}/bin/pg_restore"
if [[ -x "${bin}" ]]; then
log_info "pg_restore: PostgreSQL ${server_major} client (matches server; dump format ${fmt})."
printf '%s' "${bin}"
return 0
fi
fi
if [[ -n "${server_major}" ]]; then
for m in "${installed[@]}"; do
if [[ "${m}" -ge "${min_major}" ]] && [[ "${m}" -le "${server_major}" ]]; then
log_info "pg_restore: PostgreSQL ${m} client (dump format ${fmt}; server ${server_major})."
printf '%s' "/usr/lib/postgresql/${m}/bin/pg_restore"
return 0
fi
done
fi
for m in "${installed[@]}"; do
if [[ "${m}" -ge "${min_major}" ]]; then
if [[ -n "${server_major}" ]] && [[ "${m}" -gt "${server_major}" ]]; then
log_warning "pg_restore ${m} for dump format ${fmt} on PostgreSQL ${server_major} server — may cause transaction_timeout errors; prefer matching client or upgrade PostgreSQL."
fi
printf '%s' "/usr/lib/postgresql/${m}/bin/pg_restore"
return 0
fi
done
install_m="${min_major}"
if [[ -n "${server_major}" ]] && [[ "${server_major}" -ge "${min_major}" ]]; then
install_m="${server_major}"
fi
hesabix_ensure_postgresql_client_major "${install_m}" || true
bin="/usr/lib/postgresql/${install_m}/bin/pg_restore"
if [[ -x "${bin}" ]]; then
printf '%s' "${bin}"
return 0
fi
for m in 18 17 16 15 14 13; do
[[ "${m}" -ge "${min_major}" ]] || continue
hesabix_ensure_postgresql_client_major "${m}" || continue
bin="/usr/lib/postgresql/${m}/bin/pg_restore"
if [[ -x "${bin}" ]]; then
if [[ -n "${server_major}" ]] && [[ "${m}" -gt "${server_major}" ]]; then
log_warning "Installed pg_restore ${m} for format ${fmt}; target server is PostgreSQL ${server_major}."
fi
printf '%s' "${bin}"
return 0
fi
done
fi
if [[ -n "${server_major}" ]]; then
bin="/usr/lib/postgresql/${server_major}/bin/pg_restore"
[[ -x "${bin}" ]] && { printf '%s' "${bin}"; return 0; }
fi
for m in 18 17 16 15 14 13; do
bin="/usr/lib/postgresql/${m}/bin/pg_restore"
[[ -x "${bin}" ]] && { printf '%s' "${bin}"; return 0; }
done
command -v pg_restore 2>/dev/null || true
}
# Revision bundled with seed dump (sidecar file, env, or embedded alembic_version data).
hesabix_read_seed_alembic_revision() {
local dump="$1" backup_dir="$2" rev="" f pg_restore_bin
if [[ -n "${HESABIX_SEED_ALEMBIC_REVISION:-}" ]]; then
printf '%s' "${HESABIX_SEED_ALEMBIC_REVISION}"
return 0
fi
for f in "${dump}.revision" "${backup_dir}/hesabix_seed.revision"; do
if [[ -f "${f}" ]]; then
rev=$(tr -d '[:space:]' < "${f}")
if [[ -n "${rev}" ]]; then
printf '%s' "${rev}"
return 0
fi
fi
done
if [[ -f "${dump}" ]]; then
pg_restore_bin=$(hesabix_resolve_pg_restore_for_dump "${dump}")
if [[ -n "${pg_restore_bin}" && -x "${pg_restore_bin}" ]]; then
rev=$(
"${pg_restore_bin}" -a -t alembic_version "${dump}" 2>/dev/null | awk '
/^COPY / { copy=1; next }
copy && $0 == "\\." { exit }
copy && $0 !~ /^--/ && NF { gsub(/\r/, ""); print; exit }
'
)
if [[ -n "${rev}" ]]; then
printf '%s' "${rev}"
return 0
fi
fi
fi
return 1
}
hesabix_ensure_alembic_version_schema() {
log_info "Ensuring alembic_version schema compatibility..."
sudo -u postgres psql -d hesabix -v ON_ERROR_STOP=0 <<'SQL' 2>/dev/null || true
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='alembic_version') THEN
ALTER TABLE public.alembic_version ALTER COLUMN version_num TYPE VARCHAR(255);
ELSE
CREATE TABLE public.alembic_version (version_num VARCHAR(255) PRIMARY KEY);
END IF;
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
SQL
}
hesabix_reset_public_schema_for_seed() {
local table_count="$1"
log_warning "Database is not fully initialized (${table_count} table(s), core schema missing)."
log_warning "Resetting public schema before seed import (safe for fresh install; no Hesabix data yet)."
sudo -u postgres psql -d hesabix -v ON_ERROR_STOP=1 <<'SQL'
DROP SCHEMA IF EXISTS public CASCADE;
CREATE SCHEMA public AUTHORIZATION hesabix;
GRANT ALL ON SCHEMA public TO hesabix;
GRANT ALL ON SCHEMA public TO public;
SQL
}
hesabix_run_pg_restore_seed() {
local seed_dump="$1" restore_log="$2"
local pg_restore_bin
pg_restore_bin=$(hesabix_resolve_pg_restore_for_dump "${seed_dump}")
if [[ -z "${pg_restore_bin}" || ! -x "${pg_restore_bin}" ]]; then
log_error "pg_restore not found."
return 1
fi
log_info "Using $(${pg_restore_bin} --version 2>&1 | head -1)"
sudo -u postgres "${pg_restore_bin}" -d hesabix --no-owner --no-acl "${seed_dump}" >>"${restore_log}" 2>&1
}
hesabix_seed_restore_failed_fatal() {
local restore_log="$1"
[[ -f "${restore_log}" ]] || return 1
grep -qE 'unsupported version|could not read from input file|input file appears to be a text format|unrecognized configuration parameter .transaction_timeout' "${restore_log}" 2>/dev/null
}
hesabix_seed_restore_client_server_mismatch() {
local restore_log="$1"
[[ -f "${restore_log}" ]] || return 1
grep -qE 'unrecognized configuration parameter .transaction_timeout' "${restore_log}" 2>/dev/null
}
hesabix_ensure_public_schema_owner() {
sudo -u postgres psql -d hesabix -v ON_ERROR_STOP=1 <<'SQL' 2>/dev/null || true
ALTER SCHEMA public OWNER TO hesabix;
GRANT ALL ON SCHEMA public TO hesabix;
GRANT CREATE ON SCHEMA public TO hesabix;
SQL
}
# pg_restore --no-owner leaves objects owned by postgres without ACLs.
# Grant and reassign owners so hesabix (app user) can run Alembic and the API.
hesabix_fixup_seed_object_privileges() {
local fixup_script=""
if fixup_script="$(hesabix_find_repo_script hesabix_fixup_db_privileges.sh)"; then
chmod +x "${fixup_script}" 2>/dev/null || true
bash "${fixup_script}"
return 0
fi
sudo -u postgres psql -d hesabix -v ON_ERROR_STOP=1 <<'SQL'
ALTER SCHEMA public OWNER TO hesabix;
GRANT ALL ON SCHEMA public TO hesabix;
GRANT ALL ON ALL TABLES IN SCHEMA public TO hesabix;
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO hesabix;
GRANT ALL ON ALL FUNCTIONS IN SCHEMA public TO hesabix;
DO $$
DECLARE r RECORD;
BEGIN
FOR r IN
SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relkind IN ('r', 'p', 'v', 'm')
AND pg_get_userbyid(c.relowner) = 'postgres'
LOOP
EXECUTE format('ALTER TABLE public.%I OWNER TO hesabix', r.relname);
END LOOP;
END $$;
SQL
}
hesabix_import_seed_database() {
local seed_dump="$1" backup_dir="$2"
local restore_log="${APP_ROOT}/pg_restore_seed.log"
echo "Importing seed database from: ${seed_dump}"
: > "${restore_log}"
if hesabix_run_pg_restore_seed "${seed_dump}" "${restore_log}"; then
log_success "Seed database imported successfully."
else
if hesabix_seed_restore_failed_fatal "${restore_log}"; then
if hesabix_seed_restore_client_server_mismatch "${restore_log}"; then
log_error "pg_restore client is newer than the PostgreSQL server (see ${restore_log})."
log_error "Use a pg_restore matching the server major (e.g. postgresql-client-16 on Ubuntu 24.04), or upgrade PostgreSQL."
log_error "Alternatively regenerate the seed dump with pg_dump from the same major as the target server."
else
log_error "Seed dump format is newer than pg_restore on this server (see ${restore_log})."
log_error "Install postgresql-client-N from PGDG when possible, or regenerate seed with pg_dump / plain SQL for this server version."
fi
exit 1
fi
if PGPASSWORD="${DB_PASSWORD}" psql -h 127.0.0.1 -p 5432 -U hesabix -d hesabix -c "SELECT 1" >/dev/null 2>&1; then
log_warning "Seed import finished with warnings (see ${restore_log})."
else
log_error "Error importing seed database. Check ${restore_log}"
exit 1
fi
fi
hesabix_fixup_seed_object_privileges
hesabix_validate_seed_schema
hesabix_ensure_alembic_revision_after_seed "${seed_dump}" "${backup_dir}"
}
hesabix_ensure_alembic_revision_after_seed() {
local seed_dump="$1" backup_dir="$2" current rev
current=$(hesabix_current_alembic_revision)
if [[ -n "${current}" ]]; then
log_info "Alembic revision after seed: ${current}"
return 0
fi
if ! rev=$(hesabix_read_seed_alembic_revision "${seed_dump}" "${backup_dir}"); then
log_warning "alembic_version empty after seed; Alembic will run incremental migrations from base."
return 0
fi
log_info "Stamping database to seed Alembic revision: ${rev}"
if ! alembic stamp "${rev}"; then
log_error "alembic stamp ${rev} failed"
exit 1
fi
}
deploy_backend() {
log_step "Deploying backend..."
local api_dir="${APP_ROOT}/app/hesabixAPI"
if [[ ! -d "${api_dir}" ]]; then
log_error "Backend path not found: ${api_dir}"
exit 1
fi
cd "${api_dir}"
configure_pip_hesabix_mirror
set_pip_mirror_env
local backend_python
if ! backend_python=$(hesabix_resolve_backend_python); then
log_info "Python >= 3.11 not found; installing packages..."
if ! hesabix_install_backend_python_packages || ! backend_python=$(hesabix_resolve_backend_python); then
log_error "hesabix-api requires Python >= 3.11. Could not install or locate a suitable interpreter."
exit 1
fi
fi
if ! hesabix_ensure_backend_venv "${api_dir}" "${backend_python}"; then
log_error "Failed to create backend virtualenv with ${backend_python}"
exit 1
fi
if [[ "${HESABIX_VENV_RECREATED:-0}" == "1" ]]; then
log_info "Backend virtualenv created/rebuilt with Python >= 3.11."
fi
# shellcheck disable=SC1091
source .venv/bin/activate
log_info "Installing backend deps from PyPI: ${PIP_INDEX_URL}"
local backend_pip_ok=0
if declare -F hesabix_pip_cmd_with_fallback >/dev/null 2>&1; then
if hesabix_pip_cmd_with_fallback pip install --upgrade pip setuptools wheel \
&& hesabix_pip_cmd_with_fallback pip install -e .; then
backend_pip_ok=1
fi
elif pip install --upgrade pip setuptools wheel && pip install -e .; then
backend_pip_ok=1
fi
if [[ "${backend_pip_ok}" == "1" ]]; then
log_success "Backend dependencies installed from ${PIP_INDEX_URL}"
else
log_error "Failed to install backend dependencies from ${PIP_INDEX_URL}. Check mirror reachability or PIP_INDEX_URL override."
exit 1
fi
# env.example as base for first install; later deploys only merge keys (preserve secrets).
local env_file=".env"
if [[ ! -f "${env_file}" ]]; then
if [[ -f "env.example" ]]; then
cp env.example "${env_file}"
else
: > "${env_file}"
fi
fi
merge_hesabix_api_env_file "${env_file}"
local ensure_secrets=""
if ! ensure_secrets="$(hesabix_find_repo_script ensure_api_production_secrets.sh)"; then
log_error "ensure_api_production_secrets.sh not found (expected under ${APP_ROOT}/app/scripts after clone)."
log_error "Standalone install must complete the repository clone step first."
exit 1
fi
chmod +x "${ensure_secrets}" 2>/dev/null || true
log_info "Ensuring API production secrets in .env..."
if APP_ROOT="${APP_ROOT}" bash "${ensure_secrets}"; then
log_success "API production secrets verified."
else
log_error "Failed to ensure API production secrets."
exit 1
fi
if [[ "${INSTALL_VOICE:-N}" =~ ^[Yy]$ ]]; then
local voice_script=""
if voice_script="$(hesabix_find_repo_script ensure_voice_chat.sh)"; then
chmod +x "${voice_script}" 2>/dev/null || true
log_info "Installing AI voice chat prerequisites (local STT/TTS)..."
if INSTALL_VOICE=Y bash "${voice_script}" --non-interactive; then
log_success "AI voice chat dependencies installed."
else
log_warning "AI voice chat install failed (non-fatal). Run later: INSTALL_VOICE=Y bash ${voice_script}"
fi
else
log_warning "ensure_voice_chat.sh not found; skip voice install."
fi
fi
log_info "Database connection pool configured: pool_size=${DB_POOL_SIZE}, max_overflow=${DB_MAX_OVERFLOW}, timeout=30s, recycle=300s"
# Secure .env file permissions (contains sensitive data like DB_PASSWORD)
chmod 600 "${env_file}"
chown www-data:www-data "${env_file}"
# Verify database connection before init
echo "Verifying database connection..."
if ! .venv/bin/python -c "
import sys
sys.path.insert(0, '.')
from app.core.settings import get_settings
settings = get_settings()
from sqlalchemy import create_engine, text
engine = create_engine(settings.postgresql_dsn)
with engine.connect() as conn:
conn.execute(text('SELECT 1'))
print('Connection successful')
" 2>/dev/null; then
echo "$WARNING_MARK Database connection failed. Initial data import may fail."
fi
# Import seed when DB is empty, then always run migrations
local backup_dir="${APP_ROOT}/app/backup"
local seed_dump
seed_dump=$(ls -t "${backup_dir}"/hesabix_seed*.dump 2>/dev/null | head -1)
local table_count
table_count=$(sudo -u postgres psql -d hesabix -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE';" 2>/dev/null | tr -d '[:space:]')
if [[ ! "${table_count}" =~ ^[0-9]+$ ]]; then
table_count=999
fi
if [[ -n "${seed_dump}" && -f "${seed_dump}" ]]; then
hesabix_ensure_public_schema_owner
if hesabix_db_is_fully_initialized; then
log_info "Database already initialized (${table_count} tables). Skipping seed import."
else
if [[ "${table_count}" -gt 0 ]]; then
hesabix_reset_public_schema_for_seed "${table_count}"
table_count=0
fi
hesabix_import_seed_database "${seed_dump}" "${backup_dir}"
table_count=$(sudo -u postgres psql -d hesabix -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE';" 2>/dev/null | tr -d '[:space:]')
fi
elif [[ "${table_count}" -eq 0 ]]; then
echo "$WARNING_MARK Seed dump not found in ${backup_dir}/hesabix_seed*.dump"
elif ! hesabix_db_is_fully_initialized; then
log_error "Database has ${table_count} tables but core schema is missing and no seed dump was found."
log_error "Add hesabix_seed*.dump under ${backup_dir} or drop/recreate the hesabix database."
exit 1
else
log_info "Database already initialized (${table_count} tables). Skipping seed import."
fi
# pgvector (for servers where prereqs step was skipped earlier)
local pgvector_script=""
if pgvector_script="$(hesabix_find_repo_script ensure_pgvector.sh)"; then
chmod +x "${pgvector_script}" 2>/dev/null || true
log_info "Ensuring PostgreSQL pgvector package before migrations..."
bash "${pgvector_script}" || log_warning "pgvector install skipped (non-fatal)."
fi
# Always run migrations (after optional seed import, or when DB was already initialized)
if ! hesabix_db_is_fully_initialized && [[ "${table_count}" -gt 0 ]]; then
log_error "Core schema still missing after seed/migration prep (${table_count} tables)."
log_error "Check ${APP_ROOT}/pg_restore_seed.log or recreate the hesabix database."
exit 1
fi
log_info "Ensuring hesabix owns public schema objects (Alembic/API access)..."
hesabix_fixup_seed_object_privileges
hesabix_ensure_alembic_version_schema
log_step "Running Alembic migrations..."
if ! alembic upgrade head; then
echo "$CROSS_MARK Error running migrations"
exit 1
fi
log_success "Migrations completed."
# Check if www-data user exists
if ! id -u www-data >/dev/null 2>&1; then
echo "$WARNING_MARK User www-data not found. Creating user..."
useradd -r -s /bin/false www-data || true
fi
# Set ownership
chown -R www-data:www-data "${api_dir}"
chmod +x "${api_dir}/deployment/systemd/hesabix-api-prestart.sh" 2>/dev/null || true
# systemd service
# Type=simple: uvicorn does not send sd_notify; Type=notify would cause start timeout.
# TimeoutStartSec=300: app startup (WeasyPrint/imports) can take 60-90+ seconds.
cat > /etc/systemd/system/hesabix-api.service <<UNIT
[Unit]
Description=Hesabix API (FastAPI/Uvicorn)
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
TimeoutStartSec=300
User=www-data
Group=www-data
WorkingDirectory=${api_dir}
Environment=PATH=/usr/bin:/usr/local/bin:${api_dir}/.venv/bin
Environment=PYTHONUNBUFFERED=1
Environment=TZ=UTC
# ! = run as root: daemon-reload + pip from deploy mirrors if needed (before alembic)
ExecStartPre=!${api_dir}/deployment/systemd/hesabix-api-prestart.sh
# Before start/restart: run migrations; service does not start if migrations fail
ExecStartPre=${api_dir}/.venv/bin/python -m alembic -c ${api_dir}/alembic.ini upgrade head
ExecStart=${api_dir}/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS}
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
# Stop service if running so the new unit definition is used on next start
if check_service hesabix-api; then
systemctl stop hesabix-api
fi
systemctl enable hesabix-api
systemctl start hesabix-api
# Wait for service to become active (startup with WeasyPrint can take 30-90s)
log_info "Waiting for hesabix-api to become active (up to 120s)..."
for i in $(seq 1 24); do
if systemctl is-active --quiet hesabix-api 2>/dev/null; then
log_success "Backend started (service: hesabix-api)."
break
fi
if [ "$i" -eq 24 ]; then
log_error "Backend failed to start within 120s. Check logs: journalctl -xeu hesabix-api"
exit 1
fi
sleep 5
done
# RQ Worker service for background jobs
# redis-server.service = Debian/Ubuntu; redis.service = other distros
cat > /etc/systemd/system/hesabix-rq-worker.service <<UNIT
[Unit]
Description=Hesabix RQ Worker (Background Jobs)
After=network.target postgresql.service redis-server.service redis.service
Wants=redis-server.service redis.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=${api_dir}
Environment=PATH=${api_dir}/.venv/bin
Environment=PYTHONUNBUFFERED=1
Environment=TZ=UTC
ExecStart=${api_dir}/.venv/bin/python ${api_dir}/rq_worker.py
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
# Stop service if running to avoid conflicts
if check_service hesabix-rq-worker; then
systemctl stop hesabix-rq-worker
fi
systemctl enable hesabix-rq-worker
# Always start RQ worker: if Redis is disabled in system settings, the script exits 0
# and Restart=on-failure no longer causes a pointless restart loop.
systemctl start hesabix-rq-worker
sleep 2
if systemctl is-active --quiet hesabix-rq-worker 2>/dev/null; then
log_success "RQ Worker is running (hesabix-rq-worker)."
else
log_warning "RQ Worker is not active. If Redis is disabled in system settings, this is expected; enable Redis then: systemctl start hesabix-rq-worker"
log_warning "Otherwise check: journalctl -u hesabix-rq-worker -n 50"
fi
# Notification Moderation Worker service
# redis-server.service = Debian/Ubuntu; redis.service = other distros
cat > /etc/systemd/system/hesabix-notification-moderation.service <<UNIT
[Unit]
Description=Hesabix Notification Moderation Worker
Documentation=https://hesabix.com/docs/notification-moderation
After=network.target postgresql.service redis-server.service redis.service
Wants=postgresql.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=${api_dir}
Environment=PATH=${api_dir}/.venv/bin
Environment=PYTHONUNBUFFERED=1
Environment=TZ=UTC
Environment=PYTHONPATH=${api_dir}
ExecStart=${api_dir}/.venv/bin/python -m app.workers.notification_moderation_worker
Restart=always
RestartSec=10
StartLimitInterval=5min
StartLimitBurst=3
StandardOutput=journal
StandardError=journal
SyslogIdentifier=hesabix-notification-moderation
# Security
NoNewPrivileges=true
PrivateTmp=true
# Resource limits
MemoryMax=512M
CPUQuota=50%
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
# Stop service if running to avoid conflicts
if check_service hesabix-notification-moderation; then
systemctl stop hesabix-notification-moderation
fi
systemctl enable hesabix-notification-moderation
# Start notification moderation worker (uses DB; not the same as RQ/Redis)
systemctl start hesabix-notification-moderation
sleep 2
if check_service hesabix-notification-moderation; then
log_success "Notification Moderation Worker started (service: hesabix-notification-moderation)."
else
log_warning "Notification Moderation Worker failed to start. Check logs: journalctl -u hesabix-notification-moderation"
fi
}
# pip / Flutter mirrors: scripts/mirror_config.sh or inline fallback (curl installer)
# Persist Flutter PATH for new shells (login / bash --login). POSIX-sh file for /etc/profile.d.
persist_flutter_path_in_profile_d() {
local f="/etc/profile.d/hesabix-flutter.sh"
if [[ ! -x /opt/flutter/bin/flutter && ! -x /snap/bin/flutter ]]; then
return 0
fi
cat > "${f}" <<'PROFILE'
# Hesabix: add Flutter to PATH for login shells (deploy.sh / update.sh); avoid manual edits if possible.
if [ -x /opt/flutter/bin/flutter ]; then
case ":${PATH}:" in
*:/opt/flutter/bin:*) ;;
*) PATH="/opt/flutter/bin${PATH:+:$PATH}"; export PATH ;;
esac
fi
if [ -x /snap/bin/flutter ]; then
case ":${PATH}:" in
*:/snap/bin:*) ;;
*) PATH="/snap/bin${PATH:+:$PATH}"; export PATH ;;
esac
fi
PROFILE
chmod 644 "${f}" 2>/dev/null || true
log_success "Flutter added to PATH for login shells: ${f}"
# Graphical Ubuntu/Debian terminals are usually non-login; idempotent line in bash.bashrc
local marker="# hesabix-flutter-PATH (deploy.sh)"
if [[ -f /etc/bash.bashrc ]] && ! grep -qF "${marker}" /etc/bash.bashrc 2>/dev/null; then
printf '\n%s\n[ -r /etc/profile.d/hesabix-flutter.sh ] && . /etc/profile.d/hesabix-flutter.sh\n' "${marker}" >> /etc/bash.bashrc
log_success "Interactive bash: sourced ${f} from /etc/bash.bashrc."
fi
}
# After curl, validate .tar.xz (truncated or corrupted transfers can still exit 0).
hesabix_verify_flutter_sdk_tarball() {
local f="$1"
local err
[[ -f "$f" ]] || return 1
if [[ -n "${FLUTTER_SDK_TARBALL_SHA256:-}" ]]; then
if printf '%s %s\n' "${FLUTTER_SDK_TARBALL_SHA256}" "$f" | sha256sum -c --status 2>/dev/null; then
return 0
fi
log_warning "SHA256 mismatch for ${f} (expected FLUTTER_SDK_TARBALL_SHA256 from mirror/docs)."
return 1
fi
err="$(mktemp -t flutter_tar_test.XXXXXX 2>/dev/null || echo /tmp/flutter_tar_test.err)"
log_info "Verifying tarball integrity (tar -tJf; may take 1-3 min for ~1GB)..."
if ! tar -tJf "$f" >/dev/null 2>"${err}"; then
log_warning "Tarball integrity check failed (corrupt or incomplete file). First lines: $(head -3 "${err}" 2>/dev/null | tr '\n' ' ')"
rm -f "${err}" 2>/dev/null || true
return 1
fi
rm -f "${err}" 2>/dev/null || true
return 0
}
# Ensure Flutter SDK is available (install if missing). Exports PATH for current shell.
# Must be called after set_flutter_mirror_env so first-run Dart SDK download uses mirror.
# Order: 1) Existing 2) internal mirror (FLUTTER_SDK_TARBALL_URL_INTERNAL) 3) Snap 4) Git clone.
ensure_flutter_sdk() {
local opt_flutter="/opt/flutter/bin"
local use_mirror=0
if [[ -n "${FLUTTER_STORAGE_BASE_URL:-}" && "${FLUTTER_STORAGE_BASE_URL}" != *"storage.googleapis.com"* ]]; then
use_mirror=1
log_info "Mirror is set (non-Google); /opt/flutter will be preferred for engine downloads."
fi
# 1) Use existing Flutter if already available
# With non-Google mirror, only /opt/flutter is acceptable (snap downloads from storage.googleapis.com)
if [[ $use_mirror -eq 1 ]]; then
if [[ -x "${opt_flutter}/flutter" ]]; then
export PATH="${opt_flutter}:$PATH"
git config --global --add safe.directory /opt/flutter 2>/dev/null || true
log_info "Using Flutter from ${opt_flutter} (mirror mode; snap ignored so engine uses mirror)"
return 0
fi
log_info "Mirror mode: skipping snap Flutter; will use /opt/flutter (tarball or git clone)."
else
if command -v flutter >/dev/null 2>&1; then
log_info "Flutter found in PATH: $(command -v flutter)"
return 0
fi
if [[ -x "${opt_flutter}/flutter" ]]; then
export PATH="${opt_flutter}:$PATH"
git config --global --add safe.directory /opt/flutter 2>/dev/null || true
log_info "Using Flutter from ${opt_flutter}"
return 0
fi
local snap_bin="$HOME/snap/flutter/current/flutter/bin"
local snap_bin_common="$HOME/snap/flutter/common/flutter/bin"
if [[ -d "${snap_bin}" && -x "${snap_bin}/flutter" ]]; then
export PATH="${snap_bin}:$PATH"
log_info "Using Flutter from snap (current): ${snap_bin}"
return 0
fi
if [[ -d "${snap_bin_common}" && -x "${snap_bin_common}/flutter" ]]; then
export PATH="${snap_bin_common}:$PATH"
log_info "Using Flutter from snap (common): ${snap_bin_common}"
return 0
fi
fi
# 2) First: internal mirror (exact FLUTTER_SDK_TARBALL_URL_INTERNAL) — SDK only; pub packages use mirror
if [[ ! -d /opt/flutter ]]; then
log_info "Trying Flutter SDK from internal mirror (first): ${FLUTTER_SDK_TARBALL_URL_INTERNAL}"
apt-get install -y -qq curl xz-utils >/dev/null 2>&1 || true
local tarball_ok=0
local flutter_curl_maxtime=()
if [[ -n "${FLUTTER_SDK_DOWNLOAD_MAX_TIME:-}" && "${FLUTTER_SDK_DOWNLOAD_MAX_TIME}" != "0" ]]; then
flutter_curl_maxtime=(--max-time "${FLUTTER_SDK_DOWNLOAD_MAX_TIME}")
fi
local flutter_curl_http=()
[[ "${FLUTTER_SDK_CURL_HTTP11:-1}" != "0" ]] && flutter_curl_http=(--http1.1)
local flutter_tb="/tmp/flutter_sdk.tar.xz"
local tb_attempt tb_max="${FLUTTER_SDK_TARBALL_ATTEMPTS:-3}"
[[ "${tb_max}" =~ ^[0-9]+$ ]] && [[ "${tb_max}" -lt 1 ]] && tb_max=1
[[ "${tb_max}" =~ ^[0-9]+$ ]] || tb_max=3
for ((tb_attempt = 1; tb_attempt <= tb_max; tb_attempt++)); do
log_info "Flutter tarball attempt ${tb_attempt}/${tb_max}: download (progress, resume) then verify+extract."
log_info "curl: connect_timeout=${FLUTTER_SDK_CONNECT_TIMEOUT}s max_time=${FLUTTER_SDK_DOWNLOAD_MAX_TIME:-0}s http1.1=${FLUTTER_SDK_CURL_HTTP11:-1}"
local flutter_curl_ok=0
if curl -fL "${flutter_curl_maxtime[@]}" "${flutter_curl_http[@]}" --connect-timeout "${FLUTTER_SDK_CONNECT_TIMEOUT}" \
--retry 5 --retry-delay 8 --retry-connrefused \
--progress-bar -C - -o "${flutter_tb}" \
-w '\n[curl] finished: %{size_download} bytes in %{time_total}s (avg %{speed_download} B/s)\n' \
"${FLUTTER_SDK_TARBALL_URL_INTERNAL}"; then
flutter_curl_ok=1
fi
if [[ "${flutter_curl_ok}" -ne 1 ]]; then
log_warning "Download failed (attempt ${tb_attempt}/${tb_max})."
rm -f "${flutter_tb}"
continue
fi
if ! hesabix_verify_flutter_sdk_tarball "${flutter_tb}"; then
log_warning "Removing corrupt/incomplete tarball; retrying download (attempt ${tb_attempt}/${tb_max})."
rm -f "${flutter_tb}"
continue
fi
local tar_xz_err
tar_xz_err="$(mktemp -t flutter_tar_extract.XXXXXX 2>/dev/null || echo /tmp/flutter_tar_extract.err)"
if tar -xJf "${flutter_tb}" -C /opt 2>"${tar_xz_err}"; then
rm -f "${flutter_tb}" "${tar_xz_err}" 2>/dev/null || true
if [[ -d /opt/flutter && -x /opt/flutter/bin/flutter ]]; then
tarball_ok=1
else
local single_dir
single_dir=$(ls -1 /opt 2>/dev/null | grep -E '^flutter' | head -1)
if [[ -n "${single_dir}" && -d "/opt/${single_dir}" && -x "/opt/${single_dir}/bin/flutter" ]]; then
mv "/opt/${single_dir}" /opt/flutter 2>/dev/null && tarball_ok=1
fi
fi
if [[ $tarball_ok -eq 1 ]]; then
break
fi
log_warning "Tarball verified but Flutter binary not found after extract; cleaning and retrying."
rm -rf /opt/flutter /opt/flutter_linux* 2>/dev/null || true
rm -f "${flutter_tb}" 2>/dev/null || true
else
log_warning "tar -xJf failed: $(head -5 "${tar_xz_err}" 2>/dev/null | tr '\n' ' ')"
rm -f "${tar_xz_err}" 2>/dev/null || true
rm -rf /opt/flutter /opt/flutter_linux* 2>/dev/null || true
rm -f "${flutter_tb}"
fi
done
if [[ $tarball_ok -eq 0 ]]; then
log_info "Internal tarball path exhausted after ${tb_max} attempt(s); trying next method."
fi
if [[ $tarball_ok -eq 1 ]]; then
export PATH="/opt/flutter/bin:$PATH"
git config --global --add safe.directory /opt/flutter 2>/dev/null || true
log_success "Flutter SDK installed from internal mirror (${FLUTTER_SDK_TARBALL_URL_INTERNAL}). Pub packages will use PUB_HOSTED_URL."
log_info "Running flutter doctor (first run may download packages from mirror)..."
if ! flutter doctor -v 2>&1; then
log_warning "flutter doctor had issues; continuing. Packages will be fetched via mirror during build."
fi
log_success "Flutter SDK ready at /opt/flutter."
return 0
fi
fi
# 3) Official install: snap
if command -v snap >/dev/null 2>&1 && ! snap list flutter 2>/dev/null | grep -q flutter; then
log_info "Trying official install: snap install flutter (this may take a few minutes)..."
if snap install flutter --classic 2>/dev/null; then
export PATH="/snap/bin:$PATH"
if command -v flutter >/dev/null 2>&1; then
log_success "Flutter installed via snap (official)."
return 0
fi
else
log_info "Snap install failed or unavailable; will try git clone next."
fi
fi
# 4) Git clone to /opt/flutter (official GitHub first, then alternative mirrors)
log_info "Installing Flutter SDK via git clone..."
apt-get install -y -qq git curl unzip xz-utils zip libglu1-mesa >/dev/null 2>&1 || true
if [[ ! -d /opt/flutter ]]; then
local flutter_cloned=0
# 4a) Official source first: GitHub
log_info "Trying to clone Flutter SDK from official source (GitHub)..."
if git clone --depth 1 --branch stable "https://github.com/flutter/flutter.git" /opt/flutter 2>/dev/null; then
flutter_cloned=1
log_success "Flutter SDK cloned from official source (GitHub)."
fi
# 2) If official failed, try alternative mirrors
if [[ $flutter_cloned -eq 0 ]]; then
log_info "Official source failed; trying alternative mirrors..."
local flutter_git_urls=()
[[ -n "${FLUTTER_SDK_GIT_URL:-}" ]] && flutter_git_urls+=("${FLUTTER_SDK_GIT_URL}")
flutter_git_urls+=(
"https://mirrors.tuna.tsinghua.edu.cn/git/flutter-sdk.git"
"https://gitee.com/mirrors/Flutter.git"
)
for repo_url in "${flutter_git_urls[@]}"; do
log_info "Trying alternative: ${repo_url} ..."
if git clone --depth 1 --branch stable "${repo_url}" /opt/flutter 2>/dev/null; then
flutter_cloned=1
log_success "Flutter SDK cloned from alternative: ${repo_url}"
break
fi
rm -rf /opt/flutter 2>/dev/null || true
done
fi
if [[ $flutter_cloned -eq 0 ]]; then
log_error "Failed to install Flutter SDK (internal tarball, snap, and git clone all failed). Internal URL tried first: ${FLUTTER_SDK_TARBALL_URL_INTERNAL}. For git clone you can set FLUTTER_SDK_GIT_URL. See: https://docs.flutter.dev/get-started/install/linux"
exit 1
fi
else
local ensure_flutter_script=""
export HESABIX_UPDATE_FLUTTER_SDK="${HESABIX_UPDATE_FLUTTER_SDK:-1}"
if ensure_flutter_script="$(hesabix_find_repo_script ensure_flutter_sdk_for_update.sh)"; then
chmod +x "${ensure_flutter_script}" 2>/dev/null || true
if ! bash "${ensure_flutter_script}"; then
log_warning "Flutter SDK ensure failed after git update; trying flutter doctor..."
fi
else
(cd /opt/flutter && git fetch --depth 1 origin stable && git reset --hard origin/stable) 2>/dev/null || true
fi
fi
export PATH="/opt/flutter/bin:$PATH"
git config --global --add safe.directory /opt/flutter 2>/dev/null || true
if ! command -v flutter >/dev/null 2>&1; then
log_error "Flutter binary not found after install. Check /opt/flutter/bin."
exit 1
fi
log_info "Running flutter doctor (first run may download Dart SDK from mirror)..."
if ! flutter doctor -v 2>&1; then
log_warning "flutter doctor had issues; continuing. If build fails, check f.mirror.hesabix.ir and hesabixAPI/f.mirror.hesabix.ir.conf."
fi
log_success "Flutter SDK ready at /opt/flutter."
}
# On low-RAM servers, add swap so Flutter/dart2js build is not OOM-killed (exit -9).
ensure_swap_for_flutter_build() {
local total_mb avail_mb avail_kb
if [[ -r /proc/meminfo ]]; then
total_mb=$(awk '/^MemTotal:/ {print int($2 / 1024)}' /proc/meminfo)
avail_kb=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)
if [[ -n "${avail_kb}" ]] && [[ "${avail_kb}" =~ ^[0-9]+$ ]]; then
avail_mb=$((avail_kb / 1024))
else
avail_mb=$(free -m 2>/dev/null | awk '/^Mem:/ {print $7}')
fi
else
total_mb=$(free -m 2>/dev/null | awk '/^Mem:/ {print $2}')
avail_mb=$(free -m 2>/dev/null | awk '/^Mem:/ {print $7}')
fi
total_mb=${total_mb:-0}
avail_mb=${avail_mb:-0}
if [ "${total_mb}" -lt 2500 ] || [ "${avail_mb}" -lt 1000 ]; then
local swapfile="${APP_ROOT}/swap.flutter.bin"
# On <2.5GB RAM use 2.5GB swap so dart compile js can finish
local swap_mb=2560
[ "${total_mb:-0}" -ge 2500 ] && swap_mb=1536
local swap_bytes=$((swap_mb * 1024 * 1024))
if [ ! -f "$swapfile" ] || [ "$(stat -c%s "$swapfile" 2>/dev/null)" -lt "$swap_bytes" ]; then
log_info "Low memory (total ${total_mb}MB, available ${avail_mb}MB). Creating ${swap_mb}MB swap for Flutter build..."
dd if=/dev/zero of="$swapfile" bs=1M count="$swap_mb" status=none 2>/dev/null || true
chmod 600 "$swapfile"
mkswap "$swapfile" 2>/dev/null || true
fi
swapon "$swapfile" 2>/dev/null || true
fi
}
install_flutter_and_build_frontend() {
log_step "Building Flutter frontend..."
set_flutter_mirror_env
if declare -F hesabix_resolve_flutter_pub_hosted_url >/dev/null 2>&1; then
hesabix_resolve_flutter_pub_hosted_url || true
fi
if declare -F hesabix_resolve_flutter_storage_base_url >/dev/null 2>&1; then
hesabix_resolve_flutter_storage_base_url || true
fi
ensure_flutter_sdk
ensure_swap_for_flutter_build
export PATH="/opt/flutter/bin:/snap/bin:$PATH"
if ! command -v flutter >/dev/null 2>&1; then
log_error "Flutter not in PATH after ensure_flutter_sdk. PATH=$PATH"
exit 1
fi
persist_flutter_path_in_profile_d
local app_dir="${APP_ROOT}/app"
if [[ ! -d "${app_dir}" ]]; then
log_error "App directory not found: ${app_dir}"
exit 1
fi
local build_script="${app_dir}/build_web.sh"
if [[ ! -f "${build_script}" ]]; then
log_error "build_web.sh not found: ${build_script}"
exit 1
fi
# Make build script executable
chmod +x "${build_script}"
local api_scheme
api_scheme="$(hesabix_resolve_api_public_scheme)"
local api_url="${api_scheme}://${API_DOMAIN}"
echo "Building Flutter web with:"
echo " Mode: release"
echo " API URL: ${api_url} (scheme from TLS detection or API_PUBLIC_SCHEME)"
echo " Output: /var/www/${UI_DOMAIN}"
echo " Branding: ${BRANDING_MODE:-default}"
echo
echo "$CHECK_MARK Flutter build uses mirror: ${PUB_HOSTED_URL:-f.mirror.hesabix.ir}"
cd "${app_dir}"
log_info "Building Flutter web (pub/storage via ${PUB_HOSTED_URL})"
if ! env PATH="/opt/flutter/bin:/snap/bin:$PATH" \
PUB_HOSTED_URL="${PUB_HOSTED_URL}" FLUTTER_STORAGE_BASE_URL="${FLUTTER_STORAGE_BASE_URL}" \
BRANDING_MODE="${BRANDING_MODE:-}" \
BRANDING_DIR="${BRANDING_DIR:-}" \
APP_NAME_FA="${APP_NAME_FA:-}" \
APP_NAME_EN="${APP_NAME_EN:-}" \
bash build_web.sh \
--mode release \
--api-base-url "${api_url}" \
--clean \
--install-deps; then
log_error "Flutter build failed. Check network, DNS, and pub mirror (${PUB_HOSTED_URL:-unknown})."
exit 1
fi
log_success "Flutter web build succeeded"
# Find the build output directory
local ui_project_dir="${app_dir}/hesabixUI/hesabix_ui"
local build_output="${ui_project_dir}/build/web"
log_info "Checking build output..."
log_info " Build directory: ${build_output}"
if [[ ! -d "${build_output}" ]]; then
log_error "Build output directory not found: ${build_output}"
exit 1
fi
# Verify that index.html exists
if [[ ! -f "${build_output}/index.html" ]]; then
log_error "index.html not found in build output. Build may have failed."
exit 1
fi
log_success "Build output verified (${build_output})"
# Deploy to web directory
log_info "Deploying to web directory..."
log_info " Source: ${build_output}/"
log_info " Destination: /var/www/${UI_DOMAIN}/"
mkdir -p "/var/www/${UI_DOMAIN}"
if ! command -v rsync >/dev/null 2>&1; then
log_info "Installing rsync (required to publish Flutter web build)..."
apt-get install -y -qq rsync >/dev/null 2>&1 || true
fi
if ! command -v rsync >/dev/null 2>&1; then
log_error "rsync is required but not installed. Run: apt-get install -y rsync"
exit 1
fi
rsync -a --delete "${build_output}/" "/var/www/${UI_DOMAIN}/"
# Verify deployment
if [[ ! -f "/var/www/${UI_DOMAIN}/index.html" ]]; then
log_error "Deployment failed: index.html not found in destination"
exit 1
fi
chown -R www-data:www-data "/var/www/${UI_DOMAIN}"
log_success "Frontend built and deployed to /var/www/${UI_DOMAIN}."
}
configure_nginx_api() {
echo ">> Configuring Nginx for API..."
# Check if nginx is installed
if ! command -v nginx >/dev/null 2>&1; then
echo "$CROSS_MARK Nginx is not installed"
exit 1
fi
# Remove default site if exists
if [[ -L /etc/nginx/sites-enabled/default ]]; then
rm -f /etc/nginx/sites-enabled/default
fi
# Create rate limiting zone configuration (included in http context)
if [[ -d /etc/nginx/conf.d ]]; then
cat > /etc/nginx/conf.d/rate-limit-api.conf <<RATELIMIT
# Rate limiting zone for API
# OPTIONS (CORS preflight) is not counted in quota; each XHR is usually OPTIONS + METHOD.
map \$request_method \$api_limit_key {
default \$binary_remote_addr;
OPTIONS "";
}
limit_req_zone \$api_limit_key zone=api_limit:10m rate=40r/s;
RATELIMIT
fi
# Create API-specific configuration
cat > /etc/nginx/sites-available/hesabix-api.conf <<NGINX
# Backend API
server {
listen 80;
server_name ${API_DOMAIN};
# Security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Root route (service info and version)
location = / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
}
# Public share link: /p/{code} → backend (307 redirect)
location /p/ {
proxy_pass http://127.0.0.1:8000/p/;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 30;
proxy_connect_timeout 10;
proxy_send_timeout 30;
}
# Invoice/document share link: /i/{code} → backend (307 redirect to /public/invoice-link/ in Flutter)
location /i/ {
proxy_pass http://127.0.0.1:8000/i/;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 30;
proxy_connect_timeout 10;
proxy_send_timeout 30;
}
# When API and UI share one domain: serve /public/ paths from UI root (SPA)
# Named location fallback so /index.html does not hit location / { return 404; }
location /public/ {
root /var/www/${UI_DOMAIN};
try_files \$uri \$uri/ @hesabix_public_spa;
}
location @hesabix_public_spa {
root /var/www/${UI_DOMAIN};
rewrite ^ /index.html break;
}
# Swagger / OpenAPI from backend (^~ for /docs/* e.g. oauth2-redirect; /docs-custom under same prefix)
location ^~ /docs {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
}
location = /openapi.json {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
}
location ^~ /redoc {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
}
# Flutter web /assets (MaterialIcons fonts, shaders, …) must not always proxy to API;
# otherwise when API_DOMAIN and UI_DOMAIN match (merged nginx server), web icons/assets return empty/404.
location ^~ /assets/swagger/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
}
location = /assets/logo-blue.png {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
}
location ^~ /assets/icons/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
}
location /assets/ {
root /var/www/${UI_DOMAIN};
try_files \$uri \$uri/ @hesabix_api_assets_fallback;
}
location @hesabix_api_assets_fallback {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
}
location / {
return 404;
}
# Public web chat: rate limits only in app (firewall_rate_policies table)
location /api/v1/public/crm-chat/ {
proxy_pass http://127.0.0.1:8000/api/v1/public/crm-chat/;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
client_max_body_size 1g;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
}
# AI chat SSE: stream chunks immediately (no proxy buffering)
location ^~ /api/v1/ai/chat/ {
limit_req zone=api_limit burst=120 nodelay;
proxy_pass http://127.0.0.1:8000/api/v1/ai/chat/;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 600;
proxy_connect_timeout 60;
proxy_send_timeout 600;
client_max_body_size 1g;
proxy_buffering off;
proxy_request_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering no always;
}
location /api/ {
limit_req zone=api_limit burst=120 nodelay;
proxy_pass http://127.0.0.1:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
client_max_body_size 1g;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
}
# WebSocket endpoints (/ws/notifications, /ws/ai/voice, etc.)
location /ws/ {
proxy_pass http://127.0.0.1:8000/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}
NGINX
ln -sf /etc/nginx/sites-available/hesabix-api.conf /etc/nginx/sites-enabled/hesabix-api.conf
# Test nginx configuration
if ! nginx -t; then
echo "$CROSS_MARK Error in Nginx configuration"
exit 1
fi
# Reload nginx
if systemctl reload nginx; then
echo "$CHECK_MARK Nginx configured and reloaded for API."
else
echo "$CROSS_MARK Error reloading Nginx"
exit 1
fi
}
configure_nginx_ui() {
echo ">> Configuring Nginx for UI..."
# Check if nginx is installed
if ! command -v nginx >/dev/null 2>&1; then
echo "$CROSS_MARK Nginx is not installed"
exit 1
fi
# Create UI-specific configuration
cat > /etc/nginx/sites-available/hesabix-ui.conf <<NGINX
# Frontend (Flutter Web)
server {
listen 80;
server_name ${UI_DOMAIN};
root /var/www/${UI_DOMAIN};
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Public share link: /p/{code} → backend (307 redirect)
location /p/ {
proxy_pass http://127.0.0.1:8000/p/;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 30;
proxy_connect_timeout 10;
proxy_send_timeout 30;
}
# Invoice share link: /i/{code} → backend (307 redirect)
location /i/ {
proxy_pass http://127.0.0.1:8000/i/;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 30;
proxy_connect_timeout 10;
proxy_send_timeout 30;
}
# Proxy /api/ and /ws/ to backend (when API and UI share same domain)
# AI chat SSE: stream chunks immediately (no proxy buffering)
location ^~ /api/v1/ai/chat/ {
proxy_pass http://127.0.0.1:8000/api/v1/ai/chat/;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 600;
proxy_connect_timeout 60;
proxy_send_timeout 600;
client_max_body_size 1g;
proxy_buffering off;
proxy_request_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering no always;
}
location /api/ {
proxy_pass http://127.0.0.1:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 300;
proxy_send_timeout 300;
client_max_body_size 1g;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
}
location /ws/ {
proxy_pass http://127.0.0.1:8000/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
# Same UI+API domain: without this block, /docs hits try_files and returns Flutter index.html.
# Only /assets/swagger/ and doc logo proxy to backend to avoid clashing with Flutter web assets.
location ^~ /docs {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
}
location = /openapi.json {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
}
location ^~ /redoc {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
}
location ^~ /assets/swagger/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
}
location = /assets/logo-blue.png {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
}
location ^~ /assets/icons/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
}
# SPA: public paths (share links, etc.) → index.html
location /public/ {
try_files \$uri \$uri/ /index.html;
}
# Build version (always fresh; client uses this to detect updates)
location = /version.json {
add_header Cache-Control "no-store" always;
expires off;
try_files \$uri =404;
}
# Service Worker must not be locked with one-year immutable cache
location = /flutter_service_worker.js {
add_header Cache-Control "no-cache, must-revalidate" always;
expires off;
try_files \$uri =404;
}
# Flutter entry JS filenames are usually stable; one-year immutable cache runs old JS after deploy
location = /flutter_bootstrap.js {
add_header Cache-Control "no-cache, must-revalidate" always;
expires off;
try_files \$uri =404;
}
location = /main.dart.js {
add_header Cache-Control "no-cache, must-revalidate" always;
expires off;
try_files \$uri =404;
}
location / {
try_files \$uri \$uri/ /index.html;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml text/xml application/xml application/xml+rss;
gzip_comp_level 6;
}
NGINX
ln -sf /etc/nginx/sites-available/hesabix-ui.conf /etc/nginx/sites-enabled/hesabix-ui.conf
# Test nginx configuration
if ! nginx -t; then
echo "$CROSS_MARK Error in Nginx configuration"
exit 1
fi
# Reload nginx
if systemctl reload nginx; then
echo "$CHECK_MARK Nginx configured and reloaded for UI."
else
echo "$CROSS_MARK Error reloading Nginx"
exit 1
fi
}
configure_api_ssl() {
echo ">> Configuring SSL for API domain..."
: "${ENABLE_API_SSL:=}"
if [[ -z "${ENABLE_API_SSL}" ]]; then
echo
read -rp "Enable SSL/TLS for API domain (${API_DOMAIN}) with Let's Encrypt? (y/N): " ENABLE_API_SSL
ENABLE_API_SSL=${ENABLE_API_SSL:-N}
fi
if [[ "${ENABLE_API_SSL}" =~ ^[Yy]$ ]]; then
echo ">> Installing and configuring TLS for API..."
if ! command -v certbot >/dev/null 2>&1; then
if ! apt-get install -y certbot python3-certbot-nginx; then
log_error "Failed to install certbot (API SSL)."
return 1
fi
fi
: "${CERTBOT_EMAIL:=admin@${API_DOMAIN}}"
if [[ -z "${CERTBOT_EMAIL}" ]] || [[ "${CERTBOT_EMAIL}" == "admin@${API_DOMAIN}" ]]; then
read -rp "Email for SSL certificate (default: admin@${API_DOMAIN}): " input_email
CERTBOT_EMAIL=${input_email:-admin@${API_DOMAIN}}
fi
if [[ ! "${CERTBOT_EMAIL}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "$WARNING_MARK Invalid email format. Using default: admin@${API_DOMAIN}"
CERTBOT_EMAIL="admin@${API_DOMAIN}"
fi
if certbot --nginx -d "${API_DOMAIN}" --redirect --non-interactive --agree-tos -m "${CERTBOT_EMAIL}" 2>&1; then
echo "$CHECK_MARK SSL/TLS enabled for API domain."
systemctl enable certbot.timer 2>/dev/null || true
systemctl start certbot.timer 2>/dev/null || true
echo "$CHECK_MARK SSL certificate auto-renewal enabled."
return 0
else
echo "$WARNING_MARK Error issuing SSL certificate for API domain. You can run manually later:"
echo " certbot --nginx -d ${API_DOMAIN}"
return 1
fi
else
echo "SSL/TLS skipped for API domain; you can run certbot later:"
echo " certbot --nginx -d ${API_DOMAIN}"
return 0
fi
}
configure_ui_ssl() {
echo ">> Configuring SSL for UI domain..."
: "${ENABLE_UI_SSL:=}"
if [[ -z "${ENABLE_UI_SSL}" ]]; then
echo
read -rp "Enable SSL/TLS for UI domain (${UI_DOMAIN}) with Let's Encrypt? (y/N): " ENABLE_UI_SSL
ENABLE_UI_SSL=${ENABLE_UI_SSL:-N}
fi
if [[ "${ENABLE_UI_SSL}" =~ ^[Yy]$ ]]; then
echo ">> Installing and configuring TLS for UI..."
if ! command -v certbot >/dev/null 2>&1; then
if ! apt-get install -y certbot python3-certbot-nginx; then
log_error "Failed to install certbot (UI SSL)."
return 1
fi
fi
: "${CERTBOT_EMAIL:=admin@${UI_DOMAIN}}"
if [[ -z "${CERTBOT_EMAIL}" ]] || [[ "${CERTBOT_EMAIL}" == "admin@${UI_DOMAIN}" ]]; then
read -rp "Email for SSL certificate (default: admin@${UI_DOMAIN}): " input_email
CERTBOT_EMAIL=${input_email:-admin@${UI_DOMAIN}}
fi
if [[ ! "${CERTBOT_EMAIL}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "$WARNING_MARK Invalid email format. Using default: admin@${UI_DOMAIN}"
CERTBOT_EMAIL="admin@${UI_DOMAIN}"
fi
if certbot --nginx -d "${UI_DOMAIN}" --redirect --non-interactive --agree-tos -m "${CERTBOT_EMAIL}" 2>&1; then
echo "$CHECK_MARK SSL/TLS enabled for UI domain."
if ! systemctl is-enabled certbot.timer >/dev/null 2>&1; then
systemctl enable certbot.timer 2>/dev/null || true
systemctl start certbot.timer 2>/dev/null || true
echo "$CHECK_MARK SSL certificate auto-renewal enabled."
fi
return 0
else
echo "$WARNING_MARK Error issuing SSL certificate for UI domain. You can run manually later:"
echo " certbot --nginx -d ${UI_DOMAIN}"
return 1
fi
else
echo "SSL/TLS skipped for UI domain; you can run certbot later:"
echo " certbot --nginx -d ${UI_DOMAIN}"
return 0
fi
}
install_pgadmin4() {
echo ">> Installing pgAdmin4 (Nginx + Gunicorn, no Apache)..."
local pgadmin_venv="/opt/pgadmin4/venv"
local pgadmin_data="/var/lib/pgadmin4"
local pgadmin_log="/var/log/pgadmin4"
if [[ -x "${pgadmin_venv}/bin/gunicorn" ]] && [[ -d "${pgadmin_venv}/lib" ]]; then
echo "$CHECK_MARK pgAdmin4 (Gunicorn) is already installed. Skipping..."
return 0
fi
configure_pip_hesabix_mirror
set_pip_mirror_env
export DEBIAN_FRONTEND=noninteractive
apt-get install -y -qq python3-venv python3-pip libpq-dev >/dev/null 2>&1
mkdir -p /opt/pgadmin4
if [[ ! -d "${pgadmin_venv}" ]]; then
echo "Creating Python venv for pgAdmin4..."
python3 -m venv "${pgadmin_venv}"
log_info "Installing pgAdmin4 from PyPI: ${PIP_INDEX_URL}"
local pgadmin_pip_ok=0
if declare -F hesabix_pip_cmd_with_fallback >/dev/null 2>&1; then
if hesabix_pip_cmd_with_fallback "${pgadmin_venv}/bin/pip" install -U pip -q \
&& hesabix_pip_cmd_with_fallback "${pgadmin_venv}/bin/pip" install pgadmin4 gunicorn -q; then
pgadmin_pip_ok=1
fi
elif "${pgadmin_venv}/bin/pip" install -U pip -q && "${pgadmin_venv}/bin/pip" install pgadmin4 gunicorn -q; then
pgadmin_pip_ok=1
fi
if [[ "${pgadmin_pip_ok}" == "1" ]]; then
log_success "pgAdmin4 installed from ${PIP_INDEX_URL}"
else
log_error "Failed to install pgAdmin4 from ${PIP_INDEX_URL}. Check mirror reachability or PIP_INDEX_URL override."
return 1
fi
fi
mkdir -p "${pgadmin_data}"/{sessions,storage,azurecredentialcache,kerberoscache}
mkdir -p "${pgadmin_log}"
chown -R www-data:www-data "${pgadmin_data}" "${pgadmin_log}"
local pgadmin_site
pgadmin_site=$("${pgadmin_venv}/bin/python3" -c "import pgadmin4; print(pgadmin4.__path__[0])" 2>/dev/null)
if [[ -z "$pgadmin_site" || ! -d "$pgadmin_site" ]]; then
echo "$CROSS_MARK Could not find pgAdmin4 package path."
return 1
fi
# config_local.py for server mode
cat > "${pgadmin_site}/config_local.py" <<CONFIG
SERVER_MODE = True
LOG_FILE = '${pgadmin_log}/pgadmin4.log'
SQLITE_PATH = '${pgadmin_data}/pgadmin4.db'
SESSION_DB_PATH = '${pgadmin_data}/sessions'
STORAGE_DIR = '${pgadmin_data}/storage'
AZURE_CREDENTIAL_CACHE_DIR = '${pgadmin_data}/azurecredentialcache'
KERBEROS_CCACHE_DIR = '${pgadmin_data}/kerberoscache'
CONFIG
chown www-data:www-data "${pgadmin_site}/config_local.py"
# Setup database (must run as www-data)
if [[ ! -f "${pgadmin_data}/pgadmin4.db" ]]; then
echo "Initializing pgAdmin4 database..."
sudo -u www-data "${pgadmin_venv}/bin/python3" "${pgadmin_site}/setup.py" setup-db 2>/dev/null || true
fi
# Systemd service: Gunicorn (single worker as required by pgAdmin for connection affinity)
cat > /etc/systemd/system/pgadmin4.service <<UNIT
[Unit]
Description=pgAdmin4 (Gunicorn)
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=${pgadmin_site}
Environment="PATH=${pgadmin_venv}/bin"
ExecStart=${pgadmin_venv}/bin/gunicorn --bind 127.0.0.1:5050 --workers 1 --threads 25 --timeout 300 pgAdmin4:app
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
# Stop if already running so the new unit definition is used on next start
if check_service pgadmin4; then
systemctl stop pgadmin4
fi
systemctl enable pgadmin4
systemctl start pgadmin4
if [[ -n "${PGADMIN4_EMAIL}" ]] && [[ -n "${PGADMIN4_PASSWORD}" ]]; then
echo " First login: open https://${PGADMIN4_DOMAIN:-pgadmin.example.com} and register with email: ${PGADMIN4_EMAIL}"
fi
echo "$CHECK_MARK pgAdmin4 installed (Gunicorn on 127.0.0.1:5050). Nginx will proxy to it."
}
configure_nginx_pgadmin4() {
echo ">> Configuring Nginx for pgAdmin4..."
if [[ -z "${PGADMIN4_DOMAIN}" ]]; then
echo "$WARNING_MARK pgAdmin4 domain not set. Skipping Nginx configuration."
return 1
fi
if ! command -v nginx >/dev/null 2>&1; then
echo "$CROSS_MARK Nginx is not installed"
return 1
fi
if ! systemctl is-active --quiet pgadmin4; then
echo "Starting pgAdmin4 (Gunicorn) service..."
systemctl start pgadmin4
systemctl enable pgadmin4
fi
# Nginx reverse proxy to Gunicorn (no Apache)
cat > /etc/nginx/sites-available/pgadmin4.conf <<NGINX
# pgAdmin4 (reverse proxy to Gunicorn on 127.0.0.1:5050)
server {
listen 80;
server_name ${PGADMIN4_DOMAIN};
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://127.0.0.1:5050;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_redirect off;
proxy_read_timeout 300;
proxy_connect_timeout 60;
proxy_send_timeout 300;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
}
}
NGINX
ln -sf /etc/nginx/sites-available/pgadmin4.conf /etc/nginx/sites-enabled/pgadmin4.conf
# Test nginx configuration
if ! nginx -t; then
echo "$CROSS_MARK Error in Nginx configuration"
return 1
fi
# Reload nginx
if systemctl reload nginx; then
echo "$CHECK_MARK Nginx configured and reloaded for pgAdmin4."
else
echo "$CROSS_MARK Error reloading Nginx"
return 1
fi
}
configure_pgadmin4_ssl() {
echo ">> Configuring SSL for pgAdmin4 domain..."
if [[ -z "${PGADMIN4_DOMAIN}" ]]; then
echo "$WARNING_MARK pgAdmin4 domain not set. Skipping SSL configuration."
return 0
fi
: "${ENABLE_PGADMIN4_SSL:=}"
if [[ -z "${ENABLE_PGADMIN4_SSL}" ]]; then
echo
read -rp "Enable SSL/TLS for pgAdmin4 domain (${PGADMIN4_DOMAIN}) with Let's Encrypt? (y/N): " ENABLE_PGADMIN4_SSL
ENABLE_PGADMIN4_SSL=${ENABLE_PGADMIN4_SSL:-N}
fi
if [[ "${ENABLE_PGADMIN4_SSL}" =~ ^[Yy]$ ]]; then
echo ">> Installing and configuring TLS for pgAdmin4..."
if ! command -v certbot >/dev/null 2>&1; then
if ! apt-get install -y certbot python3-certbot-nginx; then
log_error "Failed to install certbot (pgAdmin4 SSL)."
return 1
fi
fi
: "${CERTBOT_EMAIL:=${PGADMIN4_EMAIL:-admin@${PGADMIN4_DOMAIN}}}"
if [[ -z "${CERTBOT_EMAIL}" ]] || [[ "${CERTBOT_EMAIL}" == "admin@${PGADMIN4_DOMAIN}" ]]; then
read -rp "Email for SSL certificate (default: ${CERTBOT_EMAIL}): " input_email
CERTBOT_EMAIL=${input_email:-${CERTBOT_EMAIL}}
fi
if [[ ! "${CERTBOT_EMAIL}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "$WARNING_MARK Invalid email format. Using default: ${PGADMIN4_EMAIL:-admin@${PGADMIN4_DOMAIN}}"
CERTBOT_EMAIL="${PGADMIN4_EMAIL:-admin@${PGADMIN4_DOMAIN}}"
fi
if certbot --nginx -d "${PGADMIN4_DOMAIN}" --redirect --non-interactive --agree-tos -m "${CERTBOT_EMAIL}" 2>&1; then
echo "$CHECK_MARK SSL/TLS enabled for pgAdmin4 domain."
if ! systemctl is-enabled certbot.timer >/dev/null 2>&1; then
systemctl enable certbot.timer 2>/dev/null || true
systemctl start certbot.timer 2>/dev/null || true
echo "$CHECK_MARK SSL certificate auto-renewal enabled."
fi
return 0
else
echo "$WARNING_MARK Error issuing SSL certificate for pgAdmin4 domain. You can run manually later:"
echo " certbot --nginx -d ${PGADMIN4_DOMAIN}"
return 1
fi
else
echo "SSL/TLS skipped for pgAdmin4 domain; you can run certbot later:"
echo " certbot --nginx -d ${PGADMIN4_DOMAIN}"
return 0
fi
}
main() {
if [[ $EUID -ne 0 ]]; then
echo "$CROSS_MARK Please run this script with root privileges (e.g. sudo bash deploy.sh)."
exit 1
fi
# Unattended apt: avoid needrestart restarting services at end of install (fwupd-refresh etc.
# often fail or hang on minimal/headless servers; unrelated to Hesabix). Override: NEEDRESTART_SUSPEND=0
export DEBIAN_FRONTEND=noninteractive
export APT_LISTCHANGES_FRONTEND=none
export NEEDRESTART_SUSPEND="${NEEDRESTART_SUSPEND:-1}"
# Initialize log file
init_log_file
# Check OS compatibility (must be Debian/Ubuntu)
check_os_compatibility
# Minimum RAM check (before license prompt so user time is not wasted)
check_minimum_ram
# Show license information
show_license_info
# Require license acceptance
accept_license
echo
echo "=========================================="
echo " Hesabix Deployment Script"
echo "=========================================="
echo
log_info "Starting deployment process..."
log_info "Please provide the required information when prompted."
echo
# Check disk space
check_disk_space
# Check if port 8000 is in use (ss preferred; netstat optional)
if command -v ss >/dev/null 2>&1; then
if ss -tuln | grep -qE ':8000(\s|$)'; then
log_warning "Port 8000 is in use. You may need to stop the previous service."
fi
elif command -v netstat >/dev/null 2>&1; then
if netstat -tuln | grep -q ":8000 "; then
log_warning "Port 8000 is in use. You may need to stop the previous service."
fi
fi
# Load saved inputs from previous run (so re-run after failure doesn't require re-entering)
load_saved_deploy_vars
if [[ -f "${APP_ROOT}/.deploy_saved_vars" ]]; then
echo "Loaded saved defaults from a previous run (domains, branch, pgAdmin4, etc.)."
echo "To override: set environment variables (e.g. API_DOMAIN=api.example.com) or press Enter at each prompt to be asked again."
echo
fi
# Prompt user for configuration
prompt_vars
# Show configuration summary
show_config_summary
# Ask for final confirmation
confirm_installation
echo
# Reset state if requested
reset_deployment_state
echo
# Install prerequisites (skip if already done)
if ! check_step_completed "prereqs"; then
install_prereqs
mark_step_completed "prereqs"
else
echo "$CHECK_MARK Prerequisites already installed. Skipping..."
fi
echo
# Clone repository (skip if already done; resume will retry from here if previous run failed after repo)
if ! check_step_completed "repo"; then
clone_repo
mark_step_completed "repo"
else
echo "$CHECK_MARK Repository already cloned/updated. Skipping..."
fi
# Critical for curl|bash installs: DEPLOY_SCRIPT_DIR must point at cloned app (not /tmp).
hesabix_bind_deploy_script_dir_to_repo
if [[ ! -d "${DEPLOY_SCRIPT_DIR}/scripts" ]]; then
log_error "App scripts directory missing after clone: ${APP_ROOT}/app/scripts"
log_error "Cannot continue standalone deploy without repository scripts."
exit 1
fi
# Prefer full mirror helpers from the cloned repo when available.
if [[ -r "${DEPLOY_SCRIPT_DIR}/scripts/mirror_config.sh" ]]; then
# shellcheck disable=SC1090
source "${DEPLOY_SCRIPT_DIR}/scripts/mirror_config.sh" || true
fi
echo
# Setup database (idempotent)
if ! check_step_completed "db"; then
setup_db
mark_step_completed "db"
else
echo "$CHECK_MARK Database already configured. Skipping..."
fi
echo
# Deploy backend (skip if already done; resume from here if previous run failed during/after backend)
if ! check_step_completed "backend"; then
deploy_backend
mark_step_completed "backend"
else
echo "$CHECK_MARK Backend already deployed. Skipping..."
fi
echo
# Configure Nginx API (skip if already done)
if ! check_step_completed "nginx_api"; then
configure_nginx_api
mark_step_completed "nginx_api"
else
echo "$CHECK_MARK Nginx for API already configured. Skipping..."
fi
echo
# Configure Nginx UI (skip if already done)
if ! check_step_completed "nginx_ui"; then
configure_nginx_ui
mark_step_completed "nginx_ui"
else
echo "$CHECK_MARK Nginx for UI already configured. Skipping..."
fi
echo
# Configure SSL API (mark done only on success or explicit skip — not on certbot failure)
if ! check_step_completed "ssl_api"; then
if configure_api_ssl; then
mark_step_completed "ssl_api"
else
log_warning "API SSL step incomplete; re-run deploy.sh to retry Let's Encrypt for ${API_DOMAIN}."
fi
else
echo "$CHECK_MARK SSL for API already configured. Skipping..."
fi
echo
# Build frontend after API TLS attempt so dart-define matches http vs https (see scripts/api_public_scheme.sh).
if ! check_step_completed "frontend"; then
install_flutter_and_build_frontend
mark_step_completed "frontend"
else
echo "$CHECK_MARK Frontend already built and deployed. Skipping..."
persist_flutter_path_in_profile_d
fi
echo
if ! check_step_completed "ssl_ui"; then
if configure_ui_ssl; then
mark_step_completed "ssl_ui"
else
log_warning "UI SSL step incomplete; re-run deploy.sh to retry Let's Encrypt for ${UI_DOMAIN}."
fi
else
echo "$CHECK_MARK SSL for UI already configured. Skipping..."
fi
echo
# Install and configure pgAdmin4 (optional)
if [[ "${INSTALL_PGADMIN4}" =~ ^[Yy]$ ]]; then
if ! check_step_completed "pgadmin4"; then
install_pgadmin4
mark_step_completed "pgadmin4"
else
echo "$CHECK_MARK pgAdmin4 already installed. Skipping..."
fi
echo
if ! check_step_completed "nginx_pgadmin4"; then
configure_nginx_pgadmin4
mark_step_completed "nginx_pgadmin4"
else
echo "$CHECK_MARK Nginx for pgAdmin4 already configured. Skipping..."
fi
echo
if ! check_step_completed "ssl_pgadmin4"; then
if configure_pgadmin4_ssl; then
mark_step_completed "ssl_pgadmin4"
else
log_warning "pgAdmin4 SSL step incomplete; re-run deploy.sh to retry Let's Encrypt for ${PGADMIN4_DOMAIN}."
fi
else
echo "$CHECK_MARK SSL for pgAdmin4 already configured. Skipping..."
fi
echo
fi
# Save config and install hesabix command for future updates
install_hesabix_command
echo
# Clear state file on successful completion
clear_deployment_state
echo "=========================================="
log_success "Deployment completed!"
echo "=========================================="
echo
log_info "Access URLs:"
local _api_s _ui_s
_api_s="$(hesabix_resolve_api_public_scheme)"
if [[ -d "/etc/letsencrypt/live/${UI_DOMAIN}" ]]; then _ui_s=https; else _ui_s=http; fi
echo " API: ${_api_s}://${API_DOMAIN}/api/v1/health"
echo " UI: ${_ui_s}://${UI_DOMAIN}/"
if [[ "${INSTALL_PGADMIN4}" =~ ^[Yy]$ ]] && [[ -n "${PGADMIN4_DOMAIN}" ]]; then
if [[ -d "/etc/letsencrypt/live/${PGADMIN4_DOMAIN}" ]]; then
echo " pgAdmin4: https://${PGADMIN4_DOMAIN}/"
else
echo " pgAdmin4: http://${PGADMIN4_DOMAIN}/"
fi
fi
if [[ "${INSTALL_VOICE:-N}" =~ ^[Yy]$ ]]; then
echo " Voice WS: wss://${API_DOMAIN}/ws/ai/voice (local STT/TTS)"
fi
echo
log_info "Service management:"
echo " systemctl status hesabix-api # API status"
echo " systemctl restart hesabix-api # Restart API"
echo " journalctl -u hesabix-api -f # View API logs"
echo " systemctl status hesabix-rq-worker # RQ Worker status"
echo " systemctl status hesabix-notification-moderation # Notification Moderation Worker status"
echo " systemctl status nginx # Nginx status"
echo
log_info "Deployment log file:"
echo " ${LOG_FILE}"
echo
log_info "To update (pull, migrate, rebuild, restart):"
echo " sudo hesabix -update"
echo " sudo hesabix -update -source https://source.hesabix.ir/hesabix/arc.git # override repo"
echo " sudo hesabix -cli reload # update /usr/local/bin/hesabix from repo"
echo
log_info "To change domains or SSL after install:"
echo " sudo hesabix -domains show"
echo " sudo hesabix -domains set --api api.example.com --ui app.example.com --ssl"
echo " sudo hesabix -domains apply # refresh Nginx from .deploy_env"
echo " sudo hesabix -ssl status"
echo " sudo hesabix -ssl enable --all"
echo
log_info "To start/stop/restart all Hesabix app services (systemd):"
echo " sudo hesabix -services start"
echo " sudo hesabix -services stop"
echo " sudo hesabix -services restart"
echo " sudo hesabix -services status # alias: show"
echo " # includes hesabix-api-media (Softphone Media Edge) when installed"
echo
log_info "To re-run deploy (resume from last step or full upgrade):"
echo " BRANCH=${BRANCH} API_DOMAIN=${API_DOMAIN} UI_DOMAIN=${UI_DOMAIN} sudo -E bash deploy.sh"
echo " (Use RESET_STATE=y to run all steps from the beginning)"
echo
log_info "Database password is stored in:"
echo " ${APP_ROOT}/.db_password"
echo
# Anonymous install telemetry (non-blocking; never fails the deploy).
# Opt out: HESABIX_TELEMETRY=0
local telem_script=""
if telem_script="$(hesabix_find_repo_script hesabix_telemetry.sh 2>/dev/null)"; then
# shellcheck source=scripts/hesabix_telemetry.sh
# shellcheck disable=SC1090
source "${telem_script}"
if hesabix_telemetry_enabled; then
log_info "Sending anonymous install stats to hesabix.ir (set HESABIX_TELEMETRY=0 to disable)..."
hesabix_telemetry_send "install" "deploy" || true
else
log_info "Install telemetry disabled (HESABIX_TELEMETRY=0)."
fi
fi
}
main "$@"