1 Commits

Author SHA1 Message Date
test2 0584ebdc74 Release v2.2: admin auth settings, Passkey RP ID, Cloudflare and Google captcha
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 02:57:49 +03:00
18 changed files with 458 additions and 4 deletions
+2 -1
View File
@@ -23,7 +23,8 @@ DEFAULT_GROUP_MAX_PHOTOS=500
ALLOW_GIT_DEPLOY=false ALLOW_GIT_DEPLOY=false
GIT_REMOTE_URL=https://git.evilfox.cc/test2/fotohost.git GIT_REMOTE_URL=https://git.evilfox.cc/test2/fotohost.git
# WebAuthn / Passkey (use your domain in production) # WebAuthn / Passkey — можно задать в админке (Настройки → Passkey)
# или через переменные окружения (админка имеет приоритет)
WEBAUTHN_RP_ID=localhost WEBAUTHN_RP_ID=localhost
WEBAUTHN_RP_NAME=PhotoHost WEBAUTHN_RP_NAME=PhotoHost
WEBAUTHN_ORIGIN=http://localhost:8080 WEBAUTHN_ORIGIN=http://localhost:8080
+27
View File
@@ -323,6 +323,33 @@ docker compose up -d --build
--- ---
## Релиз v2.2
**Настройки авторизации в админке**
- Включение/отключение регистрации, входа по паролю и Passkey
- Passkey RP ID, RP Name и Origin — без правки `.env`
- Captcha: Cloudflare Turnstile, Google reCAPTCHA v2 и v3
- Выбор страниц с captcha: вход, регистрация, сброс пароля
**Captcha**
- Один активный провайдер на сайт (настраивается в админке)
- reCAPTCHA v3 — проверка score на сервере (порог 0–1)
**Обновление до v2.2 на сервере:**
```bash
cd ~/fotohost
git fetch --tags
git checkout v2.2
docker compose up -d --build
```
После деплоя откройте **Админка → Настройки** и задайте Passkey (RP ID / Origin) и captcha.
---
## Релиз v2.1 ## Релиз v2.1
**GDPR и cookies** **GDPR и cookies**
+26
View File
@@ -90,6 +90,32 @@ def create_app(setup_database=True):
except Exception: except Exception:
return {"site_banners": {}} return {"site_banners": {}}
@app.context_processor
def inject_auth_settings():
from app.settings_service import get_auth_public_settings
try:
return {"auth_settings": get_auth_public_settings()}
except Exception:
return {
"auth_settings": {
"registration_enabled": True,
"password_login_enabled": True,
"passkey_enabled": True,
}
}
@app.context_processor
def inject_captcha():
from flask import request
from app.captcha_service import get_captcha_config_for_request
try:
return {"captcha_config": get_captcha_config_for_request(request.endpoint)}
except Exception:
return {"captcha_config": None}
if setup_database: if setup_database:
with app.app_context(): with app.app_context():
from app.bootstrap import ( from app.bootstrap import (
+34 -1
View File
@@ -2,20 +2,35 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_user, logout_user from flask_login import current_user, login_user, logout_user
from app import db from app import db
from app.captcha_service import verify_captcha
from app.session_service import create_user_session, revoke_current_session from app.session_service import create_user_session, revoke_current_session
from app.email_service import send_password_reset_email, send_welcome_email from app.email_service import send_password_reset_email, send_welcome_email
from app.folder_utils import process_pending_invites from app.folder_utils import process_pending_invites
from app.models import PasswordResetToken, User, UserGroup from app.models import PasswordResetToken, User, UserGroup
from app.settings_service import get_auth_public_settings, get_settings
bp = Blueprint("auth", __name__, url_prefix="/auth") bp = Blueprint("auth", __name__, url_prefix="/auth")
def _registration_allowed():
return get_settings().registration_enabled
@bp.route("/register", methods=["GET", "POST"]) @bp.route("/register", methods=["GET", "POST"])
def register(): def register():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for("cabinet.index")) return redirect(url_for("cabinet.index"))
if not _registration_allowed():
flash("Регистрация новых пользователей отключена", "error")
return redirect(url_for("auth.login"))
if request.method == "POST": if request.method == "POST":
ok, captcha_msg = verify_captcha(request, "register")
if not ok:
flash(captcha_msg, "error")
return render_template("auth/register.html")
username = request.form.get("username", "").strip() username = request.form.get("username", "").strip()
email = request.form.get("email", "").strip().lower() email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "") password = request.form.get("password", "")
@@ -58,7 +73,18 @@ def login():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for("cabinet.index")) return redirect(url_for("cabinet.index"))
auth = get_auth_public_settings()
if not auth["password_login_enabled"] and not auth["passkey_enabled"]:
flash("Вход временно недоступен", "error")
return render_template("auth/login.html")
if request.method == "POST": if request.method == "POST":
if auth["password_login_enabled"]:
ok, captcha_msg = verify_captcha(request, "login")
if not ok:
flash(captcha_msg, "error")
return render_template("auth/login.html")
login = request.form.get("login", "").strip() login = request.form.get("login", "").strip()
password = request.form.get("password", "") password = request.form.get("password", "")
remember = request.form.get("remember") == "on" remember = request.form.get("remember") == "on"
@@ -67,7 +93,9 @@ def login():
(User.username == login) | (User.email == login.lower()) (User.username == login) | (User.email == login.lower())
).first() ).first()
if user is None or not user.check_password(password): if not auth["password_login_enabled"]:
flash("Вход по паролю отключён. Используйте Passkey.", "error")
elif user is None or not user.check_password(password):
flash("Неверный логин или пароль", "error") flash("Неверный логин или пароль", "error")
elif not user.is_active: elif not user.is_active:
flash("Аккаунт заблокирован", "error") flash("Аккаунт заблокирован", "error")
@@ -94,6 +122,11 @@ def forgot_password():
return redirect(url_for("cabinet.index")) return redirect(url_for("cabinet.index"))
if request.method == "POST": if request.method == "POST":
ok, captcha_msg = verify_captcha(request, "forgot_password")
if not ok:
flash(captcha_msg, "error")
return render_template("auth/forgot_password.html")
email = request.form.get("email", "").strip().lower() email = request.form.get("email", "").strip().lower()
user = User.query.filter_by(email=email).first() user = User.query.filter_by(email=email).first()
if user: if user:
+32
View File
@@ -104,6 +104,37 @@ def ensure_photo_storage_column():
db.session.commit() db.session.commit()
def ensure_site_settings_auth_columns():
inspector = inspect(db.engine)
if "site_settings" not in inspector.get_table_names():
return
columns = [
"registration_enabled BOOLEAN NOT NULL DEFAULT TRUE",
"password_login_enabled BOOLEAN NOT NULL DEFAULT TRUE",
"passkey_enabled BOOLEAN NOT NULL DEFAULT TRUE",
"webauthn_rp_id VARCHAR(255)",
"webauthn_rp_name VARCHAR(120) DEFAULT 'PhotoHost'",
"webauthn_origin VARCHAR(255)",
"captcha_provider VARCHAR(20) NOT NULL DEFAULT 'none'",
"turnstile_site_key VARCHAR(255)",
"turnstile_secret_key VARCHAR(255)",
"recaptcha_v2_site_key VARCHAR(255)",
"recaptcha_v2_secret_key VARCHAR(255)",
"recaptcha_v3_site_key VARCHAR(255)",
"recaptcha_v3_secret_key VARCHAR(255)",
"recaptcha_v3_min_score DOUBLE PRECISION NOT NULL DEFAULT 0.5",
"captcha_on_login BOOLEAN NOT NULL DEFAULT FALSE",
"captcha_on_register BOOLEAN NOT NULL DEFAULT TRUE",
"captcha_on_forgot_password BOOLEAN NOT NULL DEFAULT FALSE",
]
for column in columns:
name = column.split()[0]
db.session.execute(
text(f"ALTER TABLE site_settings ADD COLUMN IF NOT EXISTS {name} {column[len(name) + 1:]}")
)
db.session.commit()
def ensure_user_privacy_columns(): def ensure_user_privacy_columns():
inspector = inspect(db.engine) inspector = inspect(db.engine)
if "users" not in inspector.get_table_names(): if "users" not in inspector.get_table_names():
@@ -126,6 +157,7 @@ def ensure_user_privacy_columns():
def run_schema_migrations(): def run_schema_migrations():
ensure_schema() ensure_schema()
ensure_group_limit_columns() ensure_group_limit_columns()
ensure_site_settings_auth_columns()
ensure_user_privacy_columns() ensure_user_privacy_columns()
from app.folders import ensure_folder_schema from app.folders import ensure_folder_schema
+123
View File
@@ -0,0 +1,123 @@
import requests
from app.settings_service import get_settings
CAPTCHA_PROVIDERS = ("none", "turnstile", "recaptcha_v2", "recaptcha_v3")
ENDPOINT_PAGE_MAP = {
"auth.login": "login",
"auth.register": "register",
"auth.forgot_password": "forgot_password",
}
def get_captcha_config(page):
settings = get_settings()
provider = (settings.captcha_provider or "none").strip()
if provider not in CAPTCHA_PROVIDERS or provider == "none":
return None
page_flags = {
"login": settings.captcha_on_login,
"register": settings.captcha_on_register,
"forgot_password": settings.captcha_on_forgot_password,
}
if not page_flags.get(page):
return None
config = {"provider": provider, "page": page}
if provider == "turnstile":
config["site_key"] = (settings.turnstile_site_key or "").strip()
elif provider == "recaptcha_v2":
config["site_key"] = (settings.recaptcha_v2_site_key or "").strip()
elif provider == "recaptcha_v3":
config["site_key"] = (settings.recaptcha_v3_site_key or "").strip()
config["action"] = page
if not config.get("site_key"):
return None
return config
def get_captcha_config_for_request(endpoint):
page = ENDPOINT_PAGE_MAP.get(endpoint)
if not page:
return None
return get_captcha_config(page)
def verify_captcha(request, page):
config = get_captcha_config(page)
if not config:
return True, None
settings = get_settings()
provider = config["provider"]
if provider == "turnstile":
token = (request.form.get("cf-turnstile-response") or "").strip()
secret = (settings.turnstile_secret_key or "").strip()
if not token:
return False, "Подтвердите captcha"
if not secret:
return False, "Captcha не настроена (нет secret key)"
return _verify_turnstile(token, secret)
token = (request.form.get("g-recaptcha-response") or "").strip()
if provider == "recaptcha_v2":
secret = (settings.recaptcha_v2_secret_key or "").strip()
if not token:
return False, "Подтвердите captcha"
if not secret:
return False, "Captcha не настроена (нет secret key)"
return _verify_recaptcha(token, secret)
secret = (settings.recaptcha_v3_secret_key or "").strip()
if not token:
return False, "Подтвердите captcha"
if not secret:
return False, "Captcha не настроена (нет secret key)"
ok, msg, score = _verify_recaptcha_with_score(token, secret)
if not ok:
return False, msg
min_score = settings.recaptcha_v3_min_score if settings.recaptcha_v3_min_score is not None else 0.5
if score < min_score:
return False, "Подозрительная активность, попробуйте снова"
return True, None
def _verify_turnstile(token, secret):
try:
response = requests.post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
data={"secret": secret, "response": token},
timeout=10,
)
data = response.json()
except requests.RequestException:
return False, "Не удалось проверить captcha"
if data.get("success"):
return True, None
return False, "Captcha не пройдена"
def _verify_recaptcha(token, secret):
ok, msg, _score = _verify_recaptcha_with_score(token, secret)
return ok, msg
def _verify_recaptcha_with_score(token, secret):
try:
response = requests.post(
"https://www.google.com/recaptcha/api/siteverify",
data={"secret": secret, "response": token},
timeout=10,
)
data = response.json()
except requests.RequestException:
return False, "Не удалось проверить captcha", 0.0
if data.get("success"):
return True, None, float(data.get("score") or 1.0)
return False, "Captcha не пройдена", 0.0
+19
View File
@@ -45,6 +45,25 @@ class SiteSettings(db.Model):
smtp_from_name = db.Column(db.String(120), nullable=True, default="PhotoHost") smtp_from_name = db.Column(db.String(120), nullable=True, default="PhotoHost")
smtp_use_tls = db.Column(db.Boolean, nullable=False, default=True) smtp_use_tls = db.Column(db.Boolean, nullable=False, default=True)
registration_enabled = db.Column(db.Boolean, nullable=False, default=True)
password_login_enabled = db.Column(db.Boolean, nullable=False, default=True)
passkey_enabled = db.Column(db.Boolean, nullable=False, default=True)
webauthn_rp_id = db.Column(db.String(255), nullable=True)
webauthn_rp_name = db.Column(db.String(120), nullable=True, default="PhotoHost")
webauthn_origin = db.Column(db.String(255), nullable=True)
captcha_provider = db.Column(db.String(20), nullable=False, default="none")
turnstile_site_key = db.Column(db.String(255), nullable=True)
turnstile_secret_key = db.Column(db.String(255), nullable=True)
recaptcha_v2_site_key = db.Column(db.String(255), nullable=True)
recaptcha_v2_secret_key = db.Column(db.String(255), nullable=True)
recaptcha_v3_site_key = db.Column(db.String(255), nullable=True)
recaptcha_v3_secret_key = db.Column(db.String(255), nullable=True)
recaptcha_v3_min_score = db.Column(db.Float, nullable=False, default=0.5)
captcha_on_login = db.Column(db.Boolean, nullable=False, default=False)
captcha_on_register = db.Column(db.Boolean, nullable=False, default=True)
captcha_on_forgot_password = db.Column(db.Boolean, nullable=False, default=False)
updated_at = db.Column( updated_at = db.Column(
db.DateTime, db.DateTime,
nullable=False, nullable=False,
+9
View File
@@ -7,6 +7,7 @@ from app import db
from app.passkey_service import ( from app.passkey_service import (
authentication_options, authentication_options,
delete_passkey, delete_passkey,
is_passkey_enabled,
registration_options, registration_options,
verify_authentication, verify_authentication,
verify_registration, verify_registration,
@@ -19,6 +20,8 @@ bp = Blueprint("passkey", __name__, url_prefix="/auth/passkey")
@bp.route("/register/options", methods=["POST"]) @bp.route("/register/options", methods=["POST"])
@login_required @login_required
def register_options(): def register_options():
if not is_passkey_enabled():
return jsonify({"error": "Passkey отключён администратором"}), 403
options = registration_options(current_user) options = registration_options(current_user)
return jsonify(json.loads(options_to_json(options))) return jsonify(json.loads(options_to_json(options)))
@@ -26,6 +29,8 @@ def register_options():
@bp.route("/register/verify", methods=["POST"]) @bp.route("/register/verify", methods=["POST"])
@login_required @login_required
def register_verify(): def register_verify():
if not is_passkey_enabled():
return jsonify({"error": "Passkey отключён администратором"}), 403
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
name = data.get("name", "Passkey") name = data.get("name", "Passkey")
credential = data.get("credential") credential = data.get("credential")
@@ -40,6 +45,8 @@ def register_verify():
@bp.route("/login/options", methods=["POST"]) @bp.route("/login/options", methods=["POST"])
def login_options(): def login_options():
if not is_passkey_enabled():
return jsonify({"error": "Passkey отключён администратором"}), 403
from app.models import User from app.models import User
username = (request.get_json(silent=True) or {}).get("username", "").strip() username = (request.get_json(silent=True) or {}).get("username", "").strip()
@@ -61,6 +68,8 @@ def login_options():
@bp.route("/login/verify", methods=["POST"]) @bp.route("/login/verify", methods=["POST"])
def login_verify(): def login_verify():
if not is_passkey_enabled():
return jsonify({"error": "Passkey отключён администратором"}), 403
from app.folder_utils import process_pending_invites from app.folder_utils import process_pending_invites
from app.session_service import create_user_session from app.session_service import create_user_session
+15
View File
@@ -26,17 +26,32 @@ from webauthn.helpers.structs import (
from app import db from app import db
from app.models import UserPasskey from app.models import UserPasskey
from app.settings_service import get_settings
def is_passkey_enabled():
settings = get_settings()
return settings.passkey_enabled
def _rp_id(): def _rp_id():
settings = get_settings()
if settings.webauthn_rp_id:
return settings.webauthn_rp_id.strip()
return os.getenv("WEBAUTHN_RP_ID", "localhost") return os.getenv("WEBAUTHN_RP_ID", "localhost")
def _rp_name(): def _rp_name():
settings = get_settings()
if settings.webauthn_rp_name:
return settings.webauthn_rp_name.strip()
return os.getenv("WEBAUTHN_RP_NAME", "PhotoHost") return os.getenv("WEBAUTHN_RP_NAME", "PhotoHost")
def _origin(): def _origin():
settings = get_settings()
if settings.webauthn_origin:
return settings.webauthn_origin.strip()
return os.getenv("WEBAUTHN_ORIGIN", "http://localhost:8080") return os.getenv("WEBAUTHN_ORIGIN", "http://localhost:8080")
+37
View File
@@ -52,5 +52,42 @@ def update_settings_from_form(form):
settings.smtp_from_name = form.get("smtp_from_name", "").strip() or "PhotoHost" settings.smtp_from_name = form.get("smtp_from_name", "").strip() or "PhotoHost"
settings.smtp_use_tls = form.get("smtp_use_tls") == "on" settings.smtp_use_tls = form.get("smtp_use_tls") == "on"
settings.registration_enabled = form.get("registration_enabled") == "on"
settings.password_login_enabled = form.get("password_login_enabled") == "on"
settings.passkey_enabled = form.get("passkey_enabled") == "on"
settings.webauthn_rp_id = form.get("webauthn_rp_id", "").strip() or None
settings.webauthn_rp_name = form.get("webauthn_rp_name", "").strip() or "PhotoHost"
settings.webauthn_origin = form.get("webauthn_origin", "").strip() or None
provider = form.get("captcha_provider", "none").strip()
if provider not in ("none", "turnstile", "recaptcha_v2", "recaptcha_v3"):
provider = "none"
settings.captcha_provider = provider
settings.turnstile_site_key = form.get("turnstile_site_key", "").strip() or None
if form.get("turnstile_secret_key", "").strip():
settings.turnstile_secret_key = form.get("turnstile_secret_key", "").strip()
settings.recaptcha_v2_site_key = form.get("recaptcha_v2_site_key", "").strip() or None
if form.get("recaptcha_v2_secret_key", "").strip():
settings.recaptcha_v2_secret_key = form.get("recaptcha_v2_secret_key", "").strip()
settings.recaptcha_v3_site_key = form.get("recaptcha_v3_site_key", "").strip() or None
if form.get("recaptcha_v3_secret_key", "").strip():
settings.recaptcha_v3_secret_key = form.get("recaptcha_v3_secret_key", "").strip()
try:
settings.recaptcha_v3_min_score = max(0.0, min(1.0, float(form.get("recaptcha_v3_min_score") or 0.5)))
except ValueError:
settings.recaptcha_v3_min_score = 0.5
settings.captcha_on_login = form.get("captcha_on_login") == "on"
settings.captcha_on_register = form.get("captcha_on_register") == "on"
settings.captcha_on_forgot_password = form.get("captcha_on_forgot_password") == "on"
db.session.commit() db.session.commit()
return settings return settings
def get_auth_public_settings():
settings = get_settings()
return {
"registration_enabled": settings.registration_enabled,
"password_login_enabled": settings.password_login_enabled,
"passkey_enabled": settings.passkey_enabled,
}
+53 -1
View File
@@ -2,7 +2,7 @@
{% block title %}Настройки — Админка{% endblock %} {% block title %}Настройки — Админка{% endblock %}
{% block admin_title %}Настройки системы{% endblock %} {% block admin_title %}Настройки системы{% endblock %}
{% block admin_subtitle %}<p class="admin-main__subtitle">S3, SFTP, FTP, SMTP и лимиты загрузки</p>{% endblock %} {% block admin_subtitle %}<p class="admin-main__subtitle">Авторизация, captcha, S3, SFTP, FTP, SMTP и лимиты загрузки</p>{% endblock %}
{% block admin_content %} {% block admin_content %}
<form method="post" class="settings-form"> <form method="post" class="settings-form">
@@ -16,6 +16,58 @@
</div> </div>
</div> </div>
<div class="admin-panel" style="margin-top:24px">
<h2 class="admin-panel__title">Регистрация и авторизация</h2>
<label class="form-checkbox"><input type="checkbox" name="registration_enabled" {% if settings.registration_enabled %}checked{% endif %}><span>Разрешить регистрацию</span></label>
<label class="form-checkbox"><input type="checkbox" name="password_login_enabled" {% if settings.password_login_enabled %}checked{% endif %}><span>Вход по паролю</span></label>
<label class="form-checkbox"><input type="checkbox" name="passkey_enabled" {% if settings.passkey_enabled %}checked{% endif %}><span>Passkey (WebAuthn)</span></label>
</div>
<div class="admin-panel" style="margin-top:24px">
<h2 class="admin-panel__title">Passkey — RP ID и Origin</h2>
<p class="folder-hint">Для production укажите домен сайта. Значения из админки имеют приоритет над <code>.env</code>.</p>
<div class="settings-grid">
<div class="form-group"><label>RP ID (домен)</label><input type="text" name="webauthn_rp_id" value="{{ settings.webauthn_rp_id or '' }}" placeholder="example.com"></div>
<div class="form-group"><label>RP Name</label><input type="text" name="webauthn_rp_name" value="{{ settings.webauthn_rp_name or 'PhotoHost' }}"></div>
<div class="form-group"><label>Origin (полный URL)</label><input type="text" name="webauthn_origin" value="{{ settings.webauthn_origin or '' }}" placeholder="https://example.com"></div>
</div>
</div>
<div class="admin-panel" style="margin-top:24px">
<h2 class="admin-panel__title">Captcha</h2>
<div class="form-group">
<label for="captcha_provider">Провайдер</label>
<select id="captcha_provider" name="captcha_provider">
<option value="none" {% if settings.captcha_provider == 'none' %}selected{% endif %}>Отключена</option>
<option value="turnstile" {% if settings.captcha_provider == 'turnstile' %}selected{% endif %}>Cloudflare Turnstile</option>
<option value="recaptcha_v2" {% if settings.captcha_provider == 'recaptcha_v2' %}selected{% endif %}>Google reCAPTCHA v2</option>
<option value="recaptcha_v3" {% if settings.captcha_provider == 'recaptcha_v3' %}selected{% endif %}>Google reCAPTCHA v3</option>
</select>
</div>
<label class="form-checkbox"><input type="checkbox" name="captcha_on_login" {% if settings.captcha_on_login %}checked{% endif %}><span>На странице входа</span></label>
<label class="form-checkbox"><input type="checkbox" name="captcha_on_register" {% if settings.captcha_on_register %}checked{% endif %}><span>На странице регистрации</span></label>
<label class="form-checkbox"><input type="checkbox" name="captcha_on_forgot_password" {% if settings.captcha_on_forgot_password %}checked{% endif %}><span>На сбросе пароля</span></label>
<h3 class="admin-panel__subtitle" style="margin-top:16px">Cloudflare Turnstile</h3>
<div class="settings-grid">
<div class="form-group"><label>Site Key</label><input type="text" name="turnstile_site_key" value="{{ settings.turnstile_site_key or '' }}"></div>
<div class="form-group"><label>Secret Key</label><input type="password" name="turnstile_secret_key" placeholder="оставьте пустым, если не меняете"></div>
</div>
<h3 class="admin-panel__subtitle" style="margin-top:16px">Google reCAPTCHA v2</h3>
<div class="settings-grid">
<div class="form-group"><label>Site Key</label><input type="text" name="recaptcha_v2_site_key" value="{{ settings.recaptcha_v2_site_key or '' }}"></div>
<div class="form-group"><label>Secret Key</label><input type="password" name="recaptcha_v2_secret_key" placeholder="оставьте пустым, если не меняете"></div>
</div>
<h3 class="admin-panel__subtitle" style="margin-top:16px">Google reCAPTCHA v3</h3>
<div class="settings-grid">
<div class="form-group"><label>Site Key</label><input type="text" name="recaptcha_v3_site_key" value="{{ settings.recaptcha_v3_site_key or '' }}"></div>
<div class="form-group"><label>Secret Key</label><input type="password" name="recaptcha_v3_secret_key" placeholder="оставьте пустым, если не меняете"></div>
<div class="form-group"><label>Мин. score (01)</label><input type="number" step="0.1" min="0" max="1" name="recaptcha_v3_min_score" value="{{ settings.recaptcha_v3_min_score or 0.5 }}"></div>
</div>
</div>
<div class="admin-panel" style="margin-top:24px"> <div class="admin-panel" style="margin-top:24px">
<h2 class="admin-panel__title">Amazon S3 / совместимое хранилище</h2> <h2 class="admin-panel__title">Amazon S3 / совместимое хранилище</h2>
<label class="form-checkbox"><input type="checkbox" name="s3_enabled" {% if settings.s3_enabled %}checked{% endif %}><span>Включить S3</span></label> <label class="form-checkbox"><input type="checkbox" name="s3_enabled" {% if settings.s3_enabled %}checked{% endif %}><span>Включить S3</span></label>
+5
View File
@@ -14,6 +14,7 @@
<label for="email">Email</label> <label for="email">Email</label>
<input type="email" id="email" name="email" required autocomplete="email"> <input type="email" id="email" name="email" required autocomplete="email">
</div> </div>
{% include "partials/captcha.html" %}
<button type="submit" class="btn btn--primary btn--full">Отправить ссылку</button> <button type="submit" class="btn btn--primary btn--full">Отправить ссылку</button>
</form> </form>
<p class="auth-card__footer"><a href="{{ url_for('auth.login') }}">← Вернуться ко входу</a></p> <p class="auth-card__footer"><a href="{{ url_for('auth.login') }}">← Вернуться ко входу</a></p>
@@ -21,3 +22,7 @@
</div> </div>
</section> </section>
{% endblock %} {% endblock %}
{% block scripts %}
{% include "partials/captcha_scripts.html" %}
{% endblock %}
+14 -1
View File
@@ -11,6 +11,7 @@
{% include "partials/alerts.html" %} {% include "partials/alerts.html" %}
{% if auth_settings.password_login_enabled %}
<form method="post" class="auth-form"> <form method="post" class="auth-form">
<div class="form-group"> <div class="form-group">
<label for="login">Логин или email</label> <label for="login">Логин или email</label>
@@ -24,16 +25,25 @@
<input type="checkbox" name="remember"> <input type="checkbox" name="remember">
<span>Запомнить меня</span> <span>Запомнить меня</span>
</label> </label>
{% include "partials/captcha.html" %}
<button type="submit" class="btn btn--primary btn--full">Войти</button> <button type="submit" class="btn btn--primary btn--full">Войти</button>
</form> </form>
{% endif %}
{% if auth_settings.passkey_enabled %}
<button type="button" class="btn btn--ghost btn--full" id="passkeyLoginBtn" style="margin-top:12px"> <button type="button" class="btn btn--ghost btn--full" id="passkeyLoginBtn" style="margin-top:12px">
Войти с Passkey Войти с Passkey
</button> </button>
{% endif %}
<p class="auth-card__footer"> <p class="auth-card__footer">
<a href="{{ url_for('auth.forgot_password') }}">Забыли пароль?</a> · {% if auth_settings.password_login_enabled %}
<a href="{{ url_for('auth.forgot_password') }}">Забыли пароль?</a>
{% if auth_settings.registration_enabled %} · {% endif %}
{% endif %}
{% if auth_settings.registration_enabled %}
<a href="{{ url_for('auth.register') }}">Зарегистрироваться</a> <a href="{{ url_for('auth.register') }}">Зарегистрироваться</a>
{% endif %}
</p> </p>
</div> </div>
</div> </div>
@@ -41,5 +51,8 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{% include "partials/captcha_scripts.html" %}
{% if auth_settings.passkey_enabled %}
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script> <script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
{% endif %}
{% endblock %} {% endblock %}
+5
View File
@@ -28,6 +28,7 @@
<label for="password2">Подтверждение пароля</label> <label for="password2">Подтверждение пароля</label>
<input type="password" id="password2" name="password2" required minlength="6" autocomplete="new-password" placeholder="повторите пароль"> <input type="password" id="password2" name="password2" required minlength="6" autocomplete="new-password" placeholder="повторите пароль">
</div> </div>
{% include "partials/captcha.html" %}
<button type="submit" class="btn btn--primary btn--full">Создать аккаунт</button> <button type="submit" class="btn btn--primary btn--full">Создать аккаунт</button>
</form> </form>
@@ -38,3 +39,7 @@
</div> </div>
</section> </section>
{% endblock %} {% endblock %}
{% block scripts %}
{% include "partials/captcha_scripts.html" %}
{% endblock %}
+2
View File
@@ -32,8 +32,10 @@
<a href="{{ url_for('auth.logout') }}" class="nav__link">Выйти</a> <a href="{{ url_for('auth.logout') }}" class="nav__link">Выйти</a>
{% else %} {% else %}
<a href="{{ url_for('auth.login') }}" class="nav__link">Вход</a> <a href="{{ url_for('auth.login') }}" class="nav__link">Вход</a>
{% if auth_settings.registration_enabled %}
<a href="{{ url_for('auth.register') }}" class="nav__link nav__link--accent">Регистрация</a> <a href="{{ url_for('auth.register') }}" class="nav__link nav__link--accent">Регистрация</a>
{% endif %} {% endif %}
{% endif %}
</nav> </nav>
</div> </div>
</header> </header>
+7
View File
@@ -54,6 +54,7 @@
</div> </div>
<div class="auth-card auth-card--wide profile-card"> <div class="auth-card auth-card--wide profile-card">
{% if auth_settings.passkey_enabled %}
<h2 class="profile-card__title">Passkey</h2> <h2 class="profile-card__title">Passkey</h2>
<p class="profile-card__hint">Вход без пароля через Face ID, Touch ID, Windows Hello или ключ безопасности.</p> <p class="profile-card__hint">Вход без пароля через Face ID, Touch ID, Windows Hello или ключ безопасности.</p>
@@ -82,6 +83,10 @@
<input type="text" id="passkeyName" value="Моё устройство" maxlength="120"> <input type="text" id="passkeyName" value="Моё устройство" maxlength="120">
</div> </div>
<button type="button" class="btn btn--ghost" id="addPasskeyBtn">Добавить passkey</button> <button type="button" class="btn btn--ghost" id="addPasskeyBtn">Добавить passkey</button>
{% else %}
<h2 class="profile-card__title">Passkey</h2>
<p class="profile-card__hint">Passkey отключён администратором сайта.</p>
{% endif %}
</div> </div>
<div class="auth-card auth-card--wide profile-card"> <div class="auth-card auth-card--wide profile-card">
@@ -149,5 +154,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{% if auth_settings.passkey_enabled %}
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script> <script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
{% endif %}
{% endblock %} {% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% if captcha_config %}
<div class="form-group captcha-widget">
{% if captcha_config.provider == 'turnstile' %}
<div class="cf-turnstile" data-sitekey="{{ captcha_config.site_key }}"></div>
{% elif captcha_config.provider == 'recaptcha_v2' %}
<div class="g-recaptcha" data-sitekey="{{ captcha_config.site_key }}"></div>
{% elif captcha_config.provider == 'recaptcha_v3' %}
<input type="hidden" name="g-recaptcha-response" id="recaptchaV3Token" value="">
<p class="folder-hint">Защита reCAPTCHA v3 активна</p>
{% endif %}
</div>
{% endif %}
@@ -0,0 +1,36 @@
{% if captcha_config %}
{% if captcha_config.provider == 'turnstile' %}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% elif captcha_config.provider == 'recaptcha_v2' %}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
{% elif captcha_config.provider == 'recaptcha_v3' %}
<script src="https://www.google.com/recaptcha/api.js?render={{ captcha_config.site_key }}"></script>
<script>
(function () {
function bindRecaptchaV3() {
const form = document.querySelector(".auth-form");
const tokenInput = document.getElementById("recaptchaV3Token");
if (!form || !tokenInput || !window.grecaptcha) return;
form.addEventListener("submit", function (event) {
if (tokenInput.value) return;
event.preventDefault();
grecaptcha.ready(function () {
grecaptcha.execute("{{ captcha_config.site_key }}", { action: "{{ captcha_config.action }}" })
.then(function (token) {
tokenInput.value = token;
form.submit();
});
});
});
}
if (window.grecaptcha) {
bindRecaptchaV3();
} else {
window.addEventListener("load", bindRecaptchaV3);
}
})();
</script>
{% endif %}
{% endif %}