3 Commits
Author SHA1 Message Date
prog1764 6785918bdc docker: подготовка к запуску в контейнере (Telegram-бот)
- Dockerfile: python:3.12-slim, зависимости отдельным слоем
- docker-compose.yml: env из .env, volume для /app/data, LOG_DIR=/dev/null
- .dockerignore: .env, data, logs, кэш
- fix: loguru не было в requirements.txt (бот не стартовал бы) — добавлен
- logger.py: LOG_DIR переопределяется через env (в контейнере в /dev/null)
2026-08-04 20:21:31 +04:00
prog1764 49d7af2d18 fix: пароль админа больше не остаётся в истории чата (PRJ-3)
- /admin не принимает пароль аргументом команды (оставался в истории Telegram)
- пароль вводится отдельным сообщением через FSM-состояние waiting_password
- сообщение с паролем удаляется сразу после проверки (best-effort)
- добавлена защита от брутфорса: 5 неудачных попыток за 5 минут
2026-08-04 19:36:19 +04:00
prog1764 896c26bd57 fix: закрыть IDOR в бронях, пароль админа и обработку callback_data (PRJ-3)
- IDOR: отмена/редактирование брони только владельцем (проверка user_id)
- пароль админа: убран дефолт admin123, обязателен из env; сравнение через hmac.compare_digest; TTL сессии 1ч
- callback_data: безопасный парсинг int + .get() вместо прямого индексирования (KeyError/ValueError)
2026-08-04 19:33:08 +04:00
10 changed files with 208 additions and 22 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.gitignore
.idea
__pycache__
*.pyc
*.pyo
.env
.venv
venv
data
logs
*.log
+19
View File
@@ -0,0 +1,19 @@
# Python 3.12 slim
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Сначала зависимости — чтобы кэшировать слой
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Код приложения
COPY . .
# Логи пишем в stdout/stderr, а не в файл внутри контейнера
ENV LOG_DIR=/dev/null
CMD ["python", "-m", "app.main"]
+4 -1
View File
@@ -4,11 +4,14 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
BOT_TOKEN = os.getenv("BOT_TOKEN") BOT_TOKEN = os.getenv("BOT_TOKEN")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123") ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
if not BOT_TOKEN: if not BOT_TOKEN:
raise ValueError("BOT_TOKEN not found in environment") raise ValueError("BOT_TOKEN not found in environment")
if not ADMIN_PASSWORD:
raise ValueError("ADMIN_PASSWORD not found in environment (no default allowed)")
# Services catalog # Services catalog
SERVICES = { SERVICES = {
"service_massage": "💪 Массаж", "service_massage": "💪 Массаж",
+9
View File
@@ -88,6 +88,15 @@ def get_user_bookings(user_id):
] ]
def get_booking(booking_id):
"""Return a booking by id regardless of status, or None."""
bookings = load_bookings()
for b in bookings:
if b["id"] == booking_id:
return b
return None
def cancel_booking(booking_id): def cancel_booking(booking_id):
bookings = load_bookings() bookings = load_bookings()
for b in bookings: for b in bookings:
+110 -15
View File
@@ -1,7 +1,12 @@
"""Admin handler for spa-telegram-bot — secure with password.""" """Admin handler for spa-telegram-bot — secure with password."""
import hmac
import time
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command, CommandObject from aiogram.filters import Command, CommandObject, StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import StatesGroup, State
from app.db.memory import load_bookings from app.db.memory import load_bookings
from app.config import ADMIN_PASSWORD from app.config import ADMIN_PASSWORD
from app.logger import logger from app.logger import logger
@@ -10,30 +15,54 @@ router = Router()
ADMIN_IDS = {991309145} ADMIN_IDS = {991309145}
# Store approved sessions — user_ids that passed password check # Store approved sessions — user_ids that passed password check
_approved = set() _approved: dict[int, float] = {}
APPROVED_TTL_SECONDS = 60 * 60 # 1 hour
# Failed attempt tracking (per user) to slow down brute force
_failed_attempts: dict[int, list[float]] = {}
MAX_ATTEMPTS = 5
ATTEMPT_WINDOW_SECONDS = 300 # 5 minutes
class AdminState(StatesGroup):
waiting_password = State()
def _is_rate_limited(user_id: int) -> bool:
"""Return True if the user exceeded the allowed number of failed attempts."""
now = time.time()
attempts = [t for t in _failed_attempts.get(user_id, []) if now - t < ATTEMPT_WINDOW_SECONDS]
_failed_attempts[user_id] = attempts
return len(attempts) >= MAX_ATTEMPTS
def _register_failure(user_id: int) -> None:
_failed_attempts.setdefault(user_id, []).append(time.time())
def _clear_failures(user_id: int) -> None:
_failed_attempts.pop(user_id, None)
def is_admin(user_id: int, password: str | None = None) -> bool: def is_admin(user_id: int, password: str | None = None) -> bool:
"""Check if user is admin: by user_id, by password, or by approved session.""" """Check if user is admin: by user_id, by password, or by approved session."""
if user_id in ADMIN_IDS: if user_id in ADMIN_IDS:
return True return True
if user_id in _approved: now = time.time()
expires_at = _approved.get(user_id)
if expires_at and expires_at > now:
return True return True
if password and password == ADMIN_PASSWORD: if expires_at:
_approved.add(user_id) _approved.pop(user_id, None) # expired session
if password and hmac.compare_digest(password.encode("utf-8"), ADMIN_PASSWORD.encode("utf-8")):
_approved[user_id] = now + APPROVED_TTL_SECONDS
_clear_failures(user_id)
return True return True
return False return False
@router.message(Command("admin")) async def _show_admin_panel(message: Message) -> None:
async def admin_panel(message: Message, command: CommandObject): logger.info("Admin {uid} opened admin panel", uid=message.from_user.id)
uid = message.from_user.id
password = command.args
if not is_admin(uid, password=password):
return # Silent
logger.info("Admin {uid} opened admin panel", uid=uid)
bookings = load_bookings() bookings = load_bookings()
active = [b for b in bookings if b.get("status") == "active"] active = [b for b in bookings if b.get("status") == "active"]
@@ -63,13 +92,79 @@ async def admin_panel(message: Message, command: CommandObject):
) )
async def _scrub_message(message: Message) -> None:
"""Delete a message that may contain a password. Best effort."""
try:
await message.delete()
except Exception:
logger.debug("Could not delete message with password (no rights?)")
@router.message(Command("admin"))
async def admin_panel(message: Message, command: CommandObject, state: FSMContext):
uid = message.from_user.id
# Never accept the password via command arguments — it stays in chat history.
# Scrub the command message too, in case someone typed a password after /admin.
if command.args:
await _scrub_message(message)
await message.answer("🔒 Не передавайте пароль в команде — он остаётся в истории чата.")
if is_admin(uid):
await _show_admin_panel(message)
return
if is_admin(uid):
await _show_admin_panel(message)
return
if _is_rate_limited(uid):
logger.warning("Admin password brute-force blocked for {uid}", uid=uid)
await message.answer("⛔ Слишком много попыток. Подождите 5 минут.")
return
await state.set_state(AdminState.waiting_password)
await message.answer(
"🔐 Введите пароль администратора отдельным сообщением.\n"
"Сообщение с паролем будет удалено и не останется в истории чата."
)
@router.message(StateFilter(AdminState.waiting_password), F.text)
async def admin_password_input(message: Message, state: FSMContext):
uid = message.from_user.id
password = message.text or ""
# Always delete the message containing the password — do not keep it in history.
await _scrub_message(message)
if _is_rate_limited(uid):
await state.clear()
await message.answer("⛔ Слишком много попыток. Подождите 5 минут.")
return
if is_admin(uid, password=password):
await state.clear()
await _show_admin_panel(message)
return
_register_failure(uid)
await state.clear()
logger.warning("Failed admin password attempt from {uid}", uid=uid)
await message.answer("⛔ Неверный пароль. Попробуйте ещё раз: /admin")
@router.callback_query(F.data.startswith("admin_cancel_")) @router.callback_query(F.data.startswith("admin_cancel_"))
async def admin_cancel_booking(callback: CallbackQuery): async def admin_cancel_booking(callback: CallbackQuery):
if not is_admin(callback.from_user.id): if not is_admin(callback.from_user.id):
await callback.answer("⛔ Нет доступа") await callback.answer("⛔ Нет доступа")
return return
booking_id = int(callback.data.split("_")[2]) try:
booking_id = int(callback.data.split("_", 2)[2])
except (ValueError, IndexError):
await callback.answer("Некорректные данные")
return
logger.info("Admin {uid} cancelled booking #{bid}", uid=callback.from_user.id, bid=booking_id) logger.info("Admin {uid} cancelled booking #{bid}", uid=callback.from_user.id, bid=booking_id)
from app.db.memory import cancel_booking from app.db.memory import cancel_booking
+10 -2
View File
@@ -61,7 +61,11 @@ async def start_booking(message: Message, state: FSMContext):
async def choose_service(callback: CallbackQuery, state: FSMContext): async def choose_service(callback: CallbackQuery, state: FSMContext):
from app.config import SERVICES from app.config import SERVICES
service_name = SERVICES[callback.data] service_name = SERVICES.get(callback.data)
if not service_name:
await callback.answer("Услуга не найдена")
return
logger.debug("User {uid} selected first service: {srv}", uid=callback.from_user.id, srv=service_name) logger.debug("User {uid} selected first service: {srv}", uid=callback.from_user.id, srv=service_name)
await state.update_data(service=service_name, extra_services=[]) await state.update_data(service=service_name, extra_services=[])
@@ -133,7 +137,11 @@ async def choose_extra_service(callback: CallbackQuery, state: FSMContext):
async def choose_master(callback: CallbackQuery, state: FSMContext): async def choose_master(callback: CallbackQuery, state: FSMContext):
from app.config import MASTERS from app.config import MASTERS
master_name = MASTERS[callback.data] master_name = MASTERS.get(callback.data)
if not master_name:
await callback.answer("Мастер не найден")
return
logger.debug("User {uid} selected master: {m}", uid=callback.from_user.id, m=master_name) logger.debug("User {uid} selected master: {m}", uid=callback.from_user.id, m=master_name)
await state.update_data(master=master_name) await state.update_data(master=master_name)
+24 -3
View File
@@ -2,7 +2,7 @@ from aiogram import Router, F
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from app.db.memory import get_user_bookings, cancel_booking, update_booking from app.db.memory import get_user_bookings, get_booking, cancel_booking, update_booking
from app.keyboards.menu import menu from app.keyboards.menu import menu
from app.keyboards.my_booking import get_booking_actions from app.keyboards.my_booking import get_booking_actions
from app.logger import logger from app.logger import logger
@@ -55,7 +55,18 @@ async def my_bookings(
F.data.startswith("cancel_booking_") F.data.startswith("cancel_booking_")
) )
async def cancel_booking_handler(callback: CallbackQuery): async def cancel_booking_handler(callback: CallbackQuery):
booking_id = int(callback.data.split("_")[2]) try:
booking_id = int(callback.data.split("_", 2)[2])
except (ValueError, IndexError):
await callback.answer("Некорректные данные")
return
booking = get_booking(booking_id)
if not booking or booking.get("user_id") != callback.from_user.id:
logger.warning("User {uid} tried to cancel foreign booking #{bid}", uid=callback.from_user.id, bid=booking_id)
await callback.answer("⛔ Это не ваша запись")
return
logger.info("User {uid} cancelled booking #{bid}", uid=callback.from_user.id, bid=booking_id) logger.info("User {uid} cancelled booking #{bid}", uid=callback.from_user.id, bid=booking_id)
cancel_booking( cancel_booking(
booking_id booking_id
@@ -73,7 +84,17 @@ async def edit_booking_handler(
callback: CallbackQuery, callback: CallbackQuery,
state: FSMContext state: FSMContext
): ):
booking_id = int(callback.data.split("_")[2]) try:
booking_id = int(callback.data.split("_", 2)[2])
except (ValueError, IndexError):
await callback.answer("Некорректные данные")
return
booking = get_booking(booking_id)
if not booking or booking.get("user_id") != callback.from_user.id:
logger.warning("User {uid} tried to edit foreign booking #{bid}", uid=callback.from_user.id, bid=booking_id)
await callback.answer("⛔ Это не ваша запись")
return
update_booking( update_booking(
booking_id=booking_id, booking_id=booking_id,
+4
View File
@@ -6,6 +6,10 @@ from loguru import logger
LOG_DIR = Path(__file__).resolve().parent.parent / "logs" LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
LOG_DIR.mkdir(exist_ok=True) LOG_DIR.mkdir(exist_ok=True)
# В контейнере можно переопределить через env (например LOG_DIR=/dev/null)
LOG_DIR = Path(__import__("os").environ.get("LOG_DIR", str(LOG_DIR)))
LOG_DIR.mkdir(exist_ok=True)
# Remove default handler # Remove default handler
logger.remove() logger.remove()
+14
View File
@@ -0,0 +1,14 @@
# Telegram-бот: секреты из .env (см. .env.example)
services:
bot:
build: .
env_file:
- .env
environment:
- LOG_DIR=/dev/null
volumes:
- bot_data:/app/data
restart: unless-stopped
volumes:
bot_data:
+2 -1
View File
@@ -1,2 +1,3 @@
aiogram aiogram
python-dotenv python-dotenv
loguru