#!/usr/bin/env bash set -Eeuo pipefail PUBLIC_URL="${FINANCE_BOT_PUBLIC_URL:-https://disk.yandex.ru/d/hhbDTO08CB030Q}" DEFAULT_INSTALL_DIR="/opt/finance-bot" YANDEX_API="https://cloud-api.yandex.net/v1/disk/public/resources/download" TMP_DIR="" SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" if [[ -t 1 ]]; then BOLD='\033[1m' GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m' RESET='\033[0m' else BOLD='' GREEN='' YELLOW='' RED='' RESET='' fi info() { printf '%b\n' "${GREEN}✓${RESET} $*"; } warn() { printf '%b\n' "${YELLOW}!${RESET} $*"; } fail() { printf '%b\n' "${RED}Ошибка:${RESET} $*" >&2; exit 1; } cleanup() { if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then rm -rf -- "$TMP_DIR" fi } trap cleanup EXIT require_root() { if [[ "${EUID}" -ne 0 ]]; then fail "запустите установщик с правами администратора: sudo bash install.sh" fi } install_base_tools() { local missing=() local command_name for command_name in curl unzip python3; do command -v "$command_name" >/dev/null 2>&1 || missing+=("$command_name") done [[ -e /usr/share/zoneinfo/UTC ]] || missing+=("tzdata") if (( ${#missing[@]} == 0 )); then return fi command -v apt-get >/dev/null 2>&1 \ || fail "установите curl, unzip и python3, затем запустите скрипт повторно" warn "Устанавливаю системные утилиты: ${missing[*]}" apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl unzip python3 tzdata } install_docker() { if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then info "Docker и Docker Compose уже установлены" return fi local answer read -r -p "Docker Compose не найден. Установить Docker из официального репозитория? [Y/n]: " answer answer="${answer:-Y}" [[ "$answer" =~ ^[YyДд]$ ]] || fail "для запуска бота необходимы Docker Engine и Docker Compose" [[ -r /etc/os-release ]] || fail "не удалось определить дистрибутив Linux" # shellcheck disable=SC1091 . /etc/os-release case "${ID:-}" in ubuntu|debian) ;; *) fail "автоматическая установка Docker поддерживает Ubuntu и Debian; установите Docker вручную" ;; esac info "Подключаю официальный репозиторий Docker" apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl install -m 0755 -d /etc/apt/keyrings curl -fsSL "https://download.docker.com/linux/${ID}/gpg" -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc local codename architecture codename="${UBUNTU_CODENAME:-${VERSION_CODENAME:-}}" [[ -n "$codename" ]] || fail "не удалось определить кодовое имя дистрибутива" architecture="$(dpkg --print-architecture)" cat > /etc/apt/sources.list.d/docker.sources </dev/null 2>&1 || fail "Docker Compose не запустился после установки" info "Docker установлен" } prompt_install_dir() { local entered read -r -p "Папка установки [${DEFAULT_INSTALL_DIR}]: " entered INSTALL_DIR="${entered:-$DEFAULT_INSTALL_DIR}" [[ "$INSTALL_DIR" == /* ]] || fail "путь установки должен быть абсолютным" case "$INSTALL_DIR" in /|/etc|/usr|/var|/opt) fail "выберите отдельную папку, например ${DEFAULT_INSTALL_DIR}" ;; esac } read_existing_value() { local key="$1" local env_file="$2" [[ -f "$env_file" ]] || return 0 sed -n "s/^${key}=//p" "$env_file" | tail -n 1 } prompt_bot_settings() { local env_file="${INSTALL_DIR}/.env" local existing_token existing_timezone existing_report_time keep answer existing_token="$(read_existing_value BOT_TOKEN "$env_file")" existing_timezone="$(read_existing_value BOT_TIMEZONE "$env_file")" existing_report_time="$(read_existing_value DAILY_REPORT_TIME "$env_file")" BOT_TOKEN="" if [[ -n "$existing_token" ]]; then read -r -p "Найдены прежние настройки. Сохранить текущий токен бота? [Y/n]: " keep keep="${keep:-Y}" if [[ "$keep" =~ ^[YyДд]$ ]]; then BOT_TOKEN="$existing_token" fi fi while [[ ! "$BOT_TOKEN" =~ ^[0-9]{6,}:[A-Za-z0-9_-]{20,}$ ]]; do read -r -s -p "Токен от @BotFather: " BOT_TOKEN printf '\n' [[ "$BOT_TOKEN" =~ ^[0-9]{6,}:[A-Za-z0-9_-]{20,}$ ]] \ || warn "Токен выглядит некорректно. Попробуйте ещё раз." done local default_timezone="${existing_timezone:-Asia/Novosibirsk}" while true; do read -r -p "Часовой пояс [${default_timezone}]: " answer BOT_TIMEZONE="${answer:-$default_timezone}" if [[ "$BOT_TIMEZONE" =~ ^[A-Za-z0-9_+.-]+(/[A-Za-z0-9_+.-]+)+$ ]] \ && [[ -e "/usr/share/zoneinfo/${BOT_TIMEZONE}" ]]; then break fi warn "Неизвестный часовой пояс. Пример: Asia/Novosibirsk" done local default_report_time="${existing_report_time:-21:00}" while true; do read -r -p "Время ежедневного отчёта [${default_report_time}]: " answer DAILY_REPORT_TIME="${answer:-$default_report_time}" if [[ "$DAILY_REPORT_TIME" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]]; then break fi warn "Введите время в формате ЧЧ:ММ, например 21:00" done } download_project() { TMP_DIR="$(mktemp -d)" local api_response download_url archive project_root archive="${TMP_DIR}/finance-bot.zip" info "Получаю проект с Яндекс.Диска" api_response="$(curl -fsS --get --data-urlencode "public_key=${PUBLIC_URL}" "$YANDEX_API")" \ || fail "Яндекс.Диск не вернул ссылку на скачивание" download_url="$(printf '%s' "$api_response" | python3 -c ' import json, sys try: payload = json.load(sys.stdin) print(payload["href"]) except (KeyError, ValueError): raise SystemExit(1) ')" || fail "не удалось разобрать ответ Яндекс.Диска; проверьте публичную ссылку" curl -fL --retry 3 --connect-timeout 20 "$download_url" -o "$archive" \ || fail "не удалось скачать архив проекта" [[ -s "$archive" ]] || fail "Яндекс.Диск вернул пустой архив" if (( $(stat -c '%s' "$archive") > 209715200 )); then fail "архив больше 200 МБ; установка остановлена" fi unzip -tq "$archive" >/dev/null || fail "скачанный файл не является исправным ZIP-архивом" if unzip -Z1 "$archive" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then fail "в архиве обнаружены небезопасные пути" fi mkdir -p "${TMP_DIR}/extracted" unzip -q "$archive" -d "${TMP_DIR}/extracted" COMPOSE_FILE="$(find "${TMP_DIR}/extracted" -maxdepth 4 -type f -name compose.yaml -print -quit)" [[ -n "$COMPOSE_FILE" ]] || fail "в архиве не найден compose.yaml" project_root="$(dirname "$COMPOSE_FILE")" [[ -f "${project_root}/Dockerfile" && -f "${project_root}/pyproject.toml" && -d "${project_root}/app" ]] \ || fail "структура проекта в архиве неполная" DOWNLOADED_PROJECT_DIR="$project_root" info "Проект скачан и проверен" } locate_project() { if [[ -f "${SCRIPT_DIR}/compose.yaml" \ && -f "${SCRIPT_DIR}/Dockerfile" \ && -f "${SCRIPT_DIR}/pyproject.toml" \ && -d "${SCRIPT_DIR}/app" ]]; then DOWNLOADED_PROJECT_DIR="$SCRIPT_DIR" info "Использую проект из ${SCRIPT_DIR}" return fi warn "Рядом с установщиком не найден проект — загружаю резервную копию с Яндекс.Диска" download_project } deploy_project() { mkdir -p "$INSTALL_DIR" cp -a "${DOWNLOADED_PROJECT_DIR}/." "$INSTALL_DIR/" umask 077 cat > "${INSTALL_DIR}/.env" <