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()
BOT_TOKEN = os.getenv("BOT_TOKEN")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
if not BOT_TOKEN:
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 = {
"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):
bookings = load_bookings()
for b in bookings:
+110 -15
View File
@@ -1,7 +1,12 @@
"""Admin handler for spa-telegram-bot — secure with password."""
import hmac
import time
from aiogram import Router, F
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.config import ADMIN_PASSWORD
from app.logger import logger
@@ -10,30 +15,54 @@ router = Router()
ADMIN_IDS = {991309145}
# 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:
"""Check if user is admin: by user_id, by password, or by approved session."""
if user_id in ADMIN_IDS:
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
if password and password == ADMIN_PASSWORD:
_approved.add(user_id)
if expires_at:
_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 False
@router.message(Command("admin"))
async def admin_panel(message: Message, command: CommandObject):
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)
async def _show_admin_panel(message: Message) -> None:
logger.info("Admin {uid} opened admin panel", uid=message.from_user.id)
bookings = load_bookings()
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_"))
async def admin_cancel_booking(callback: CallbackQuery):
if not is_admin(callback.from_user.id):
await callback.answer("⛔ Нет доступа")
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)
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):
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)
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):
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)
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.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.my_booking import get_booking_actions
from app.logger import logger
@@ -55,7 +55,18 @@ async def my_bookings(
F.data.startswith("cancel_booking_")
)
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)
cancel_booking(
booking_id
@@ -73,7 +84,17 @@ async def edit_booking_handler(
callback: CallbackQuery,
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(
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.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
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
python-dotenv
python-dotenv
loguru