diff --git a/.dockerignore b/.dockerignore
index ec4e39e..a200884 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -9,3 +9,7 @@ __pycache__
venv
*.log
logs
+# БД с персональными данными не должна попадать в docker-образ
+consultations.db
+data/
+*.db
diff --git a/.env.example b/.env.example
index cb4f44e..1b4de44 100644
--- a/.env.example
+++ b/.env.example
@@ -1,7 +1,9 @@
# Обязательные переменные. Без них приложение не запустится.
-SECRET_KEY=сгенерируйте-случайную-строку-50-символов
-ADMIN_PASSWORD=надёжный-пароль-админки
+SECRET_KEY=change-me-strong-random-secret
+ADMIN_PASSWORD=change-me-strong-admin-password
+
+# Secure-куки: сайт работает только по HTTPS, держать включённым (=1)
+COOKIE_SECURE=1
# Необязательные
# FLASK_DEBUG=1 # только для локальной разработки
-# COOKIE_SECURE=1 # включить при работе по HTTPS
diff --git a/.gitignore b/.gitignore
index a7a2373..17b21ff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ __pycache__/
*.pyc
.env
consultations.db
+data/
diff --git a/Dockerfile b/Dockerfile
index 35c1e54..830def8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,6 +16,13 @@ COPY . .
# Не dev-режим по умолчанию
ENV FLASK_DEBUG=0
+# Не запускать от имени root
+RUN useradd --create-home --uid 10001 appuser \
+ && mkdir -p /app/data \
+ && chown -R appuser:appuser /app
+USER appuser
+
EXPOSE 5000
-CMD ["python", "app.py"]
+# Production: gunicorn вместо dev-сервера Werkzeug
+CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "60", "app:app"]
diff --git a/app.py b/app.py
index 0f306d4..7463274 100644
--- a/app.py
+++ b/app.py
@@ -14,7 +14,7 @@ from flask_wtf import CSRFProtect
BASE_DIR = Path(__file__).resolve().parent
-DATABASE_PATH = BASE_DIR / "consultations.db"
+DATABASE_PATH = BASE_DIR / "data" / "consultations.db"
SUBJECTS = {"math": "Математика"}
EXAMS = {"oge": "ОГЭ", "ege": "ЕГЭ", "other": "Пока не определились"}
FORMATS = {"online": "Онлайн", "offline": "Очно", "other": "Пока не определились"}
@@ -37,7 +37,10 @@ if not app.config["SECRET_KEY"]:
raise RuntimeError("SECRET_KEY не задан. Установите переменную окружения SECRET_KEY.")
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
-app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "0") == "1"
+# Secure-куки по умолчанию включены (сайт работает только по HTTPS)
+app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "1") == "1"
+app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 12 # 12 часов
+app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 # 64 КБ — защита от гигантских POST
csrf = CSRFProtect(app)
@@ -49,7 +52,23 @@ limiter = Limiter(
)
+@app.after_request
+def set_security_headers(response):
+ """Базовые защитные HTTP-заголовки."""
+ response.headers.setdefault("X-Content-Type-Options", "nosniff")
+ response.headers.setdefault("X-Frame-Options", "DENY")
+ response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
+ response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
+ response.headers.setdefault(
+ "Content-Security-Policy",
+ "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; "
+ "font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
+ )
+ return response
+
+
def get_connection():
+ DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(DATABASE_PATH)
connection.row_factory = sqlite3.Row
return connection
@@ -112,6 +131,11 @@ def submit_request():
format_key = request.form.get("format", "").strip()
preferred_time_key = request.form.get("preferred_time", "").strip()
message = request.form.get("message", "").strip()[:2000]
+ consent = request.form.get("consent", "").strip()
+
+ if consent != "on":
+ flash("Необходимо согласие на обработку персональных данных.", "error")
+ return redirect(url_for("index") + "#contact")
if (
not name
@@ -155,6 +179,11 @@ def success():
return render_template("success.html", name=request.args.get("name", ""))
+@app.get("/privacy")
+def privacy_policy():
+ return render_template("privacy.html")
+
+
def admin_required(view):
@wraps(view)
def wrapped_view(*args, **kwargs):
@@ -230,11 +259,20 @@ def fetch_stats(connection):
@app.route("/admin/login", methods=["GET", "POST"])
-@limiter.limit("10 per minute")
+@limiter.limit("5 per minute")
def admin_login():
if session.get("is_admin"):
return redirect(url_for("admin_dashboard"))
+ # анти-брутфорс: 5 неудачных попыток -> блокировка на 15 минут
+ failed = session.get("login_failed", 0)
+ locked_until = session.get("login_locked_until", 0)
+ now = datetime.now().timestamp()
+ if locked_until and now < locked_until:
+ remaining = int(locked_until - now)
+ flash(f"Слишком много попыток входа. Подождите {remaining // 60 + 1} мин.", "error")
+ return render_template("admin_login.html")
+
if request.method == "POST":
password = request.form.get("password", "")
admin_password = os.environ.get("ADMIN_PASSWORD")
@@ -246,7 +284,14 @@ def admin_login():
session["is_admin"] = True
return redirect(url_for("admin_dashboard"))
- flash("Неверный пароль.", "error")
+ failed += 1
+ if failed >= 5:
+ session["login_locked_until"] = now + 15 * 60
+ session["login_failed"] = 0
+ flash("Слишком много попыток входа. Вход заблокирован на 15 минут.", "error")
+ else:
+ session["login_failed"] = failed
+ flash(f"Неверный пароль. Осталось попыток: {5 - failed}.", "error")
return render_template("admin_login.html")
diff --git a/docker-compose.yml b/docker-compose.yml
index 3bfa8cd..7e46148 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,4 +6,10 @@ services:
- "5000:5000"
env_file:
- .env
+ volumes:
+ # БД с заявками (ПДн) — на постоянном volume, не теряется при rebuild
+ - sqlite_data:/app/data
restart: unless-stopped
+
+volumes:
+ sqlite_data:
diff --git a/requirements.txt b/requirements.txt
index ff04e54..5628833 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
Flask>=3.0,<4.0
Flask-WTF>=1.2,<2.0
Flask-Limiter>=3.5,<4.0
+gunicorn>=22.0,<24.0
diff --git a/templates/index.html b/templates/index.html
index 3335912..673a70e 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -160,6 +160,7 @@
Формат занятий * Онлайн Очно Не знаю
Комментарий необязательно
+ Я согласен на обработку персональных данных в соответствии с политикой конфиденциальности . *
Отправить заявку ↗
Нажимая кнопку, вы соглашаетесь на обработку персональных данных.
diff --git a/templates/privacy.html b/templates/privacy.html
new file mode 100644
index 0000000..d5589e4
--- /dev/null
+++ b/templates/privacy.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+ Политика конфиденциальности — Анастасия Панкратенкова
+
+
+
+
+
+
+
+ Юридическая информация
+ Политикаконфиденциальности
+ Этот сайт использует персональные данные только для обработки заявок на консультации.
+
+ Какие данные собираются
+ При заполнении формы могут быть указаны имя, контакт (телефон или Telegram), предмет, экзамен, формат занятий, желаемое время и комментарий к заявке.
+
+ Зачем они нужны
+ Данные используются исключительно для связи с заявителем, ответа на вопросы и подготовки к занятиям.
+
+ Хранение и защита
+ Заявки хранятся в защищённой базе данных. Доступ к ним имеет только преподаватель. Данные не передаются третьим лицам и не используются для рекламных рассылок.
+
+ Отзыв согласия
+ Чтобы уточнить, изменить или удалить сведения из заявки, свяжитесь с преподавателем тем же способом, который был указан при записи.
+
+ Вернуться к записи ↗
+
+
+
+
+
+