Release v2.1: GDPR, passkeys, session management, admin redesign
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,3 +22,8 @@ DEFAULT_GROUP_MAX_PHOTOS=500
|
|||||||
# Git deploy from admin panel (requires repo mount and docker socket)
|
# Git deploy from admin panel (requires repo mount and docker socket)
|
||||||
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_RP_ID=localhost
|
||||||
|
WEBAUTHN_RP_NAME=PhotoHost
|
||||||
|
WEBAUTHN_ORIGIN=http://localhost:8080
|
||||||
|
|||||||
@@ -323,6 +323,43 @@ docker compose up -d --build
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Релиз v2.1
|
||||||
|
|
||||||
|
**GDPR и cookies**
|
||||||
|
|
||||||
|
- Политика конфиденциальности: `/legal/privacy`
|
||||||
|
- Политика cookies: `/legal/cookies`
|
||||||
|
- GDPR-права: `/legal/gdpr`
|
||||||
|
- Баннер согласия на cookies
|
||||||
|
- Экспорт данных и удаление аккаунта в профиле
|
||||||
|
|
||||||
|
**Passkey (WebAuthn)**
|
||||||
|
|
||||||
|
- Регистрация passkey в профиле
|
||||||
|
- Вход кнопкой «Войти с Passkey» на странице входа
|
||||||
|
- Переменные `WEBAUTHN_RP_ID`, `WEBAUTHN_ORIGIN` в `.env`
|
||||||
|
|
||||||
|
**Управление сессиями**
|
||||||
|
|
||||||
|
- Список активных сессий в профиле (устройство, IP, время)
|
||||||
|
- Завершение отдельной сессии или всех кроме текущей
|
||||||
|
|
||||||
|
**Обновлённая админка**
|
||||||
|
|
||||||
|
- Боковое меню, карточки статистики, улучшенная вёрстка
|
||||||
|
|
||||||
|
**Обновление до v2.1 на сервере:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/fotohost
|
||||||
|
git fetch --tags
|
||||||
|
git checkout v2.1
|
||||||
|
# добавьте WEBAUTHN_RP_ID и WEBAUTHN_ORIGIN в .env
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Релиз v2.0
|
## Релиз v2.0
|
||||||
|
|
||||||
**Загрузка по прямым ссылкам**
|
**Загрузка по прямым ссылкам**
|
||||||
|
|||||||
@@ -51,13 +51,18 @@ def create_app(setup_database=True):
|
|||||||
from .auth import bp as auth_bp
|
from .auth import bp as auth_bp
|
||||||
from .admin import bp as admin_bp
|
from .admin import bp as admin_bp
|
||||||
from .folders import bp as folders_bp
|
from .folders import bp as folders_bp
|
||||||
|
from .legal import bp as legal_bp
|
||||||
|
from .passkey import bp as passkey_bp
|
||||||
|
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
app.register_blueprint(cabinet_bp)
|
app.register_blueprint(cabinet_bp)
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
app.register_blueprint(folders_bp)
|
app.register_blueprint(folders_bp)
|
||||||
|
app.register_blueprint(legal_bp)
|
||||||
|
app.register_blueprint(passkey_bp)
|
||||||
|
|
||||||
|
register_request_hooks(app)
|
||||||
register_cli(app)
|
register_cli(app)
|
||||||
|
|
||||||
# Ensure models are registered even when DB setup runs in init_db.py.
|
# Ensure models are registered even when DB setup runs in init_db.py.
|
||||||
@@ -72,6 +77,8 @@ def create_app(setup_database=True):
|
|||||||
SiteSettings,
|
SiteSettings,
|
||||||
User,
|
User,
|
||||||
UserGroup,
|
UserGroup,
|
||||||
|
UserPasskey,
|
||||||
|
UserSession,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
@@ -103,6 +110,34 @@ def create_app(setup_database=True):
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def register_request_hooks(app):
|
||||||
|
@app.before_request
|
||||||
|
def validate_tracked_session():
|
||||||
|
from flask import flash, redirect, request, session, url_for
|
||||||
|
from flask_login import current_user, logout_user
|
||||||
|
|
||||||
|
from app.session_service import ensure_user_session, touch_user_session, validate_user_session
|
||||||
|
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return None
|
||||||
|
|
||||||
|
endpoint = request.endpoint or ""
|
||||||
|
if endpoint.startswith("static") or endpoint.startswith("passkey.") or endpoint.startswith("legal."):
|
||||||
|
return None
|
||||||
|
if endpoint in ("main.health",):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not validate_user_session(current_user.id):
|
||||||
|
if session.get("sid"):
|
||||||
|
logout_user()
|
||||||
|
flash("Сессия завершена. Войдите снова.", "error")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
ensure_user_session(current_user)
|
||||||
|
else:
|
||||||
|
touch_user_session()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def register_cli(app):
|
def register_cli(app):
|
||||||
@app.cli.command("create-admin")
|
@app.cli.command("create-admin")
|
||||||
def create_admin_command():
|
def create_admin_command():
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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.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
|
||||||
@@ -41,6 +42,7 @@ def register():
|
|||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
login_user(user)
|
login_user(user)
|
||||||
|
create_user_session(user)
|
||||||
accepted = process_pending_invites(user)
|
accepted = process_pending_invites(user)
|
||||||
send_welcome_email(user)
|
send_welcome_email(user)
|
||||||
flash("Регистрация успешна. Добро пожаловать!", "success")
|
flash("Регистрация успешна. Добро пожаловать!", "success")
|
||||||
@@ -71,6 +73,7 @@ def login():
|
|||||||
flash("Аккаунт заблокирован", "error")
|
flash("Аккаунт заблокирован", "error")
|
||||||
else:
|
else:
|
||||||
login_user(user, remember=remember)
|
login_user(user, remember=remember)
|
||||||
|
create_user_session(user, remember=remember)
|
||||||
accepted = process_pending_invites(user)
|
accepted = process_pending_invites(user)
|
||||||
flash(f"Добро пожаловать, {user.username}!", "success")
|
flash(f"Добро пожаловать, {user.username}!", "success")
|
||||||
if accepted:
|
if accepted:
|
||||||
@@ -137,6 +140,7 @@ def reset_password(token):
|
|||||||
|
|
||||||
@bp.route("/logout")
|
@bp.route("/logout")
|
||||||
def logout():
|
def logout():
|
||||||
|
revoke_current_session()
|
||||||
logout_user()
|
logout_user()
|
||||||
flash("Вы вышли из аккаунта", "success")
|
flash("Вы вышли из аккаунта", "success")
|
||||||
return redirect(url_for("main.index"))
|
return redirect(url_for("main.index"))
|
||||||
|
|||||||
@@ -104,9 +104,29 @@ def ensure_photo_storage_column():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_user_privacy_columns():
|
||||||
|
inspector = inspect(db.engine)
|
||||||
|
if "users" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
db.session.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS "
|
||||||
|
"gdpr_accepted_at TIMESTAMP WITH TIME ZONE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS "
|
||||||
|
"cookie_analytics BOOLEAN NOT NULL DEFAULT FALSE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
def run_schema_migrations():
|
def run_schema_migrations():
|
||||||
ensure_schema()
|
ensure_schema()
|
||||||
ensure_group_limit_columns()
|
ensure_group_limit_columns()
|
||||||
|
ensure_user_privacy_columns()
|
||||||
from app.folders import ensure_folder_schema
|
from app.folders import ensure_folder_schema
|
||||||
|
|
||||||
ensure_folder_schema()
|
ensure_folder_schema()
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, make_response, render_template, request, session
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import Folder, Photo
|
||||||
|
bp = Blueprint("legal", __name__, url_prefix="/legal")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/privacy")
|
||||||
|
def privacy():
|
||||||
|
return render_template("legal/privacy.html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/cookies")
|
||||||
|
def cookies():
|
||||||
|
return render_template("legal/cookies.html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/gdpr")
|
||||||
|
def gdpr():
|
||||||
|
return render_template("legal/gdpr.html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/cookie-consent", methods=["POST"])
|
||||||
|
def cookie_consent():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
essential = bool(data.get("essential", True))
|
||||||
|
analytics = bool(data.get("analytics", False))
|
||||||
|
|
||||||
|
session["cookie_consent"] = {
|
||||||
|
"essential": essential,
|
||||||
|
"analytics": analytics,
|
||||||
|
"accepted_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
current_user.cookie_analytics = analytics
|
||||||
|
if not current_user.gdpr_accepted_at:
|
||||||
|
current_user.gdpr_accepted_at = datetime.now(timezone.utc)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
response = make_response(jsonify({"ok": True}))
|
||||||
|
response.set_cookie(
|
||||||
|
"photohost_consent",
|
||||||
|
f"1:{int(analytics)}",
|
||||||
|
max_age=60 * 60 * 24 * 365,
|
||||||
|
samesite="Lax",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def export_user_data(user):
|
||||||
|
photos = Photo.query.filter_by(user_id=user.id).all()
|
||||||
|
folders = Folder.query.filter_by(owner_id=user.id).all()
|
||||||
|
return {
|
||||||
|
"user": {
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"created_at": user.created_at.isoformat(),
|
||||||
|
"gdpr_accepted_at": user.gdpr_accepted_at.isoformat()
|
||||||
|
if user.gdpr_accepted_at
|
||||||
|
else None,
|
||||||
|
},
|
||||||
|
"photos": [
|
||||||
|
{
|
||||||
|
"original_name": p.original_name,
|
||||||
|
"url": p.url,
|
||||||
|
"file_size": p.file_size,
|
||||||
|
"created_at": p.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for p in photos
|
||||||
|
],
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"name": f.name,
|
||||||
|
"photo_count": f.photo_count,
|
||||||
|
"created_at": f.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for f in folders
|
||||||
|
],
|
||||||
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
@@ -96,6 +96,8 @@ class User(UserMixin, db.Model):
|
|||||||
is_admin = db.Column(db.Boolean, nullable=False, default=False)
|
is_admin = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
group_id = db.Column(db.Integer, db.ForeignKey("user_groups.id"), nullable=True, index=True)
|
group_id = db.Column(db.Integer, db.ForeignKey("user_groups.id"), nullable=True, index=True)
|
||||||
|
gdpr_accepted_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
cookie_analytics = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
created_at = db.Column(
|
created_at = db.Column(
|
||||||
db.DateTime,
|
db.DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
@@ -126,6 +128,63 @@ class User(UserMixin, db.Model):
|
|||||||
return int(result or 0)
|
return int(result or 0)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSession(db.Model):
|
||||||
|
__tablename__ = "user_sessions"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
session_key = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||||
|
ip_address = db.Column(db.String(45), nullable=True)
|
||||||
|
user_agent = db.Column(db.String(512), nullable=True)
|
||||||
|
created_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
last_seen_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
revoked = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
user = db.relationship("User", backref=db.backref("sessions", lazy="dynamic"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_label(self):
|
||||||
|
if not self.user_agent:
|
||||||
|
return "Неизвестное устройство"
|
||||||
|
ua = self.user_agent.lower()
|
||||||
|
if "mobile" in ua or "android" in ua or "iphone" in ua:
|
||||||
|
return "Мобильное устройство"
|
||||||
|
if "windows" in ua:
|
||||||
|
return "Windows"
|
||||||
|
if "mac" in ua:
|
||||||
|
return "macOS"
|
||||||
|
if "linux" in ua:
|
||||||
|
return "Linux"
|
||||||
|
return "Браузер"
|
||||||
|
|
||||||
|
|
||||||
|
class UserPasskey(db.Model):
|
||||||
|
__tablename__ = "user_passkeys"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
credential_id = db.Column(db.String(512), unique=True, nullable=False, index=True)
|
||||||
|
public_key = db.Column(db.Text, nullable=False)
|
||||||
|
sign_count = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
name = db.Column(db.String(120), nullable=False, default="Passkey")
|
||||||
|
created_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
last_used_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
user = db.relationship("User", backref=db.backref("passkeys", lazy="dynamic"))
|
||||||
|
|
||||||
|
|
||||||
class UserGroup(db.Model):
|
class UserGroup(db.Model):
|
||||||
__tablename__ = "user_groups"
|
__tablename__ = "user_groups"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from flask import Blueprint, flash, jsonify, redirect, request, url_for
|
||||||
|
from flask_login import current_user, login_required, login_user
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.passkey_service import (
|
||||||
|
authentication_options,
|
||||||
|
delete_passkey,
|
||||||
|
registration_options,
|
||||||
|
verify_authentication,
|
||||||
|
verify_registration,
|
||||||
|
)
|
||||||
|
from webauthn.helpers.options_to_json import options_to_json
|
||||||
|
|
||||||
|
bp = Blueprint("passkey", __name__, url_prefix="/auth/passkey")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/register/options", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def register_options():
|
||||||
|
options = registration_options(current_user)
|
||||||
|
return jsonify(json.loads(options_to_json(options)))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/register/verify", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def register_verify():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
name = data.get("name", "Passkey")
|
||||||
|
credential = data.get("credential")
|
||||||
|
if not credential:
|
||||||
|
return jsonify({"error": "Нет данных passkey"}), 400
|
||||||
|
try:
|
||||||
|
passkey = verify_registration(current_user, credential, name)
|
||||||
|
return jsonify({"ok": True, "id": passkey.id, "name": passkey.name})
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/login/options", methods=["POST"])
|
||||||
|
def login_options():
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
username = (request.get_json(silent=True) or {}).get("username", "").strip()
|
||||||
|
if not username:
|
||||||
|
return jsonify({"error": "Укажите логин или email"}), 400
|
||||||
|
|
||||||
|
user = User.query.filter(
|
||||||
|
(User.username == username) | (User.email == username.lower())
|
||||||
|
).first()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
return jsonify({"error": "Passkey не найден для этого аккаунта"}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
options = authentication_options(user)
|
||||||
|
return jsonify(json.loads(options_to_json(options)))
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/login/verify", methods=["POST"])
|
||||||
|
def login_verify():
|
||||||
|
from app.folder_utils import process_pending_invites
|
||||||
|
from app.session_service import create_user_session
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
credential = data.get("credential")
|
||||||
|
remember = bool(data.get("remember"))
|
||||||
|
if not credential:
|
||||||
|
return jsonify({"error": "Нет данных passkey"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = verify_authentication(credential)
|
||||||
|
if not user.is_active:
|
||||||
|
return jsonify({"error": "Аккаунт заблокирован"}), 403
|
||||||
|
login_user(user, remember=remember)
|
||||||
|
create_user_session(user, remember=remember)
|
||||||
|
process_pending_invites(user)
|
||||||
|
redirect_url = url_for("admin.dashboard") if user.is_admin else url_for("cabinet.index")
|
||||||
|
return jsonify({"ok": True, "redirect": redirect_url})
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import base64
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from flask import current_app, session
|
||||||
|
from webauthn import (
|
||||||
|
generate_authentication_options,
|
||||||
|
generate_registration_options,
|
||||||
|
verify_authentication_response,
|
||||||
|
verify_registration_response,
|
||||||
|
)
|
||||||
|
from webauthn.helpers.parse_authentication_credential_json import (
|
||||||
|
parse_authentication_credential_json,
|
||||||
|
)
|
||||||
|
from webauthn.helpers.parse_registration_credential_json import (
|
||||||
|
parse_registration_credential_json,
|
||||||
|
)
|
||||||
|
from webauthn.helpers.structs import (
|
||||||
|
AuthenticatorSelectionCriteria,
|
||||||
|
PublicKeyCredentialDescriptor,
|
||||||
|
ResidentKeyRequirement,
|
||||||
|
UserVerificationRequirement,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import UserPasskey
|
||||||
|
|
||||||
|
|
||||||
|
def _rp_id():
|
||||||
|
return os.getenv("WEBAUTHN_RP_ID", "localhost")
|
||||||
|
|
||||||
|
|
||||||
|
def _rp_name():
|
||||||
|
return os.getenv("WEBAUTHN_RP_NAME", "PhotoHost")
|
||||||
|
|
||||||
|
|
||||||
|
def _origin():
|
||||||
|
return os.getenv("WEBAUTHN_ORIGIN", "http://localhost:8080")
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_encode(data):
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(data):
|
||||||
|
padding = "=" * (-len(data) % 4)
|
||||||
|
return base64.urlsafe_b64decode(data + padding)
|
||||||
|
|
||||||
|
|
||||||
|
def registration_options(user):
|
||||||
|
existing = UserPasskey.query.filter_by(user_id=user.id).all()
|
||||||
|
exclude = [
|
||||||
|
PublicKeyCredentialDescriptor(id=_b64url_decode(item.credential_id))
|
||||||
|
for item in existing
|
||||||
|
]
|
||||||
|
|
||||||
|
options = generate_registration_options(
|
||||||
|
rp_id=_rp_id(),
|
||||||
|
rp_name=_rp_name(),
|
||||||
|
user_id=str(user.id).encode("utf-8"),
|
||||||
|
user_name=user.username,
|
||||||
|
user_display_name=user.username,
|
||||||
|
exclude_credentials=exclude,
|
||||||
|
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||||
|
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||||
|
user_verification=UserVerificationRequirement.PREFERRED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session["passkey_reg_challenge"] = _b64url_encode(options.challenge)
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def verify_registration(user, credential_json, name):
|
||||||
|
challenge = session.pop("passkey_reg_challenge", None)
|
||||||
|
if not challenge:
|
||||||
|
raise ValueError("Сессия регистрации passkey истекла")
|
||||||
|
|
||||||
|
verification = verify_registration_response(
|
||||||
|
credential=parse_registration_credential_json(json.dumps(credential_json)),
|
||||||
|
expected_challenge=_b64url_decode(challenge),
|
||||||
|
expected_rp_id=_rp_id(),
|
||||||
|
expected_origin=_origin(),
|
||||||
|
)
|
||||||
|
|
||||||
|
passkey = UserPasskey(
|
||||||
|
user_id=user.id,
|
||||||
|
credential_id=credential_json.get("id") or _b64url_encode(verification.credential_id),
|
||||||
|
public_key=_b64url_encode(verification.credential_public_key),
|
||||||
|
sign_count=verification.sign_count,
|
||||||
|
name=(name or "Passkey").strip()[:120] or "Passkey",
|
||||||
|
)
|
||||||
|
db.session.add(passkey)
|
||||||
|
db.session.commit()
|
||||||
|
return passkey
|
||||||
|
|
||||||
|
|
||||||
|
def authentication_options(user):
|
||||||
|
passkeys = UserPasskey.query.filter_by(user_id=user.id).all()
|
||||||
|
if not passkeys:
|
||||||
|
raise ValueError("Passkey не настроен")
|
||||||
|
|
||||||
|
allow = [
|
||||||
|
PublicKeyCredentialDescriptor(id=_b64url_decode(item.credential_id))
|
||||||
|
for item in passkeys
|
||||||
|
]
|
||||||
|
options = generate_authentication_options(
|
||||||
|
rp_id=_rp_id(),
|
||||||
|
allow_credentials=allow,
|
||||||
|
user_verification=UserVerificationRequirement.PREFERRED,
|
||||||
|
)
|
||||||
|
session["passkey_auth_challenge"] = _b64url_encode(options.challenge)
|
||||||
|
session["passkey_auth_user_id"] = user.id
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def verify_authentication(credential_json):
|
||||||
|
challenge = session.pop("passkey_auth_challenge", None)
|
||||||
|
user_id = session.pop("passkey_auth_user_id", None)
|
||||||
|
if not challenge or not user_id:
|
||||||
|
raise ValueError("Сессия входа passkey истекла")
|
||||||
|
|
||||||
|
credential_id = credential_json.get("id") or credential_json.get("rawId")
|
||||||
|
if isinstance(credential_id, dict):
|
||||||
|
credential_id = credential_id.get("id")
|
||||||
|
if not credential_id:
|
||||||
|
raise ValueError("Некорректные данные passkey")
|
||||||
|
|
||||||
|
passkey = UserPasskey.query.filter_by(
|
||||||
|
user_id=user_id, credential_id=credential_id
|
||||||
|
).first()
|
||||||
|
if not passkey:
|
||||||
|
passkey = UserPasskey.query.filter_by(credential_id=credential_id).first()
|
||||||
|
if not passkey:
|
||||||
|
raise ValueError("Passkey не найден")
|
||||||
|
|
||||||
|
verification = verify_authentication_response(
|
||||||
|
credential=parse_authentication_credential_json(json.dumps(credential_json)),
|
||||||
|
expected_challenge=_b64url_decode(challenge),
|
||||||
|
expected_rp_id=_rp_id(),
|
||||||
|
expected_origin=_origin(),
|
||||||
|
credential_public_key=_b64url_decode(passkey.public_key),
|
||||||
|
credential_current_sign_count=passkey.sign_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
passkey.sign_count = verification.new_sign_count
|
||||||
|
passkey.last_used_at = datetime.now(timezone.utc)
|
||||||
|
db.session.commit()
|
||||||
|
return passkey.user
|
||||||
|
|
||||||
|
|
||||||
|
def delete_passkey(user, passkey_id):
|
||||||
|
passkey = UserPasskey.query.filter_by(id=passkey_id, user_id=user.id).first()
|
||||||
|
if not passkey:
|
||||||
|
return False
|
||||||
|
db.session.delete(passkey)
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
+87
-2
@@ -7,6 +7,7 @@ from flask import (
|
|||||||
current_app,
|
current_app,
|
||||||
flash,
|
flash,
|
||||||
jsonify,
|
jsonify,
|
||||||
|
make_response,
|
||||||
redirect,
|
redirect,
|
||||||
render_template,
|
render_template,
|
||||||
request,
|
request,
|
||||||
@@ -209,9 +210,44 @@ def index():
|
|||||||
@cabinet_bp.route("/profile", methods=["GET", "POST"])
|
@cabinet_bp.route("/profile", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def profile():
|
def profile():
|
||||||
from app.models import User
|
from app.models import User, UserPasskey
|
||||||
|
from app.passkey_service import delete_passkey
|
||||||
|
from app.session_service import (
|
||||||
|
get_current_session_key,
|
||||||
|
list_user_sessions,
|
||||||
|
revoke_all_sessions,
|
||||||
|
revoke_session,
|
||||||
|
)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
action = request.form.get("action", "save")
|
||||||
|
|
||||||
|
if action == "revoke_session":
|
||||||
|
session_id = request.form.get("session_id", type=int)
|
||||||
|
if session_id and revoke_session(session_id, current_user.id):
|
||||||
|
flash("Сессия завершена", "success")
|
||||||
|
return redirect(url_for("cabinet.profile"))
|
||||||
|
|
||||||
|
if action == "revoke_all_sessions":
|
||||||
|
count = revoke_all_sessions(current_user.id, except_current=True)
|
||||||
|
flash(f"Завершено сессий: {count}", "success")
|
||||||
|
return redirect(url_for("cabinet.profile"))
|
||||||
|
|
||||||
|
if action == "delete_passkey":
|
||||||
|
passkey_id = request.form.get("passkey_id", type=int)
|
||||||
|
if passkey_id and delete_passkey(current_user, passkey_id):
|
||||||
|
flash("Passkey удалён", "success")
|
||||||
|
return redirect(url_for("cabinet.profile"))
|
||||||
|
|
||||||
|
if action == "delete_account":
|
||||||
|
password = request.form.get("delete_password", "")
|
||||||
|
if not current_user.check_password(password):
|
||||||
|
flash("Неверный пароль", "error")
|
||||||
|
else:
|
||||||
|
_delete_user_account(current_user)
|
||||||
|
flash("Аккаунт удалён", "success")
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
email = request.form.get("email", "").strip().lower()
|
email = request.form.get("email", "").strip().lower()
|
||||||
current_password = request.form.get("current_password", "")
|
current_password = request.form.get("current_password", "")
|
||||||
new_password = request.form.get("new_password", "")
|
new_password = request.form.get("new_password", "")
|
||||||
@@ -234,4 +270,53 @@ def profile():
|
|||||||
flash("Профиль обновлён", "success")
|
flash("Профиль обновлён", "success")
|
||||||
return redirect(url_for("cabinet.profile"))
|
return redirect(url_for("cabinet.profile"))
|
||||||
|
|
||||||
return render_template("cabinet/profile.html")
|
sessions = list_user_sessions(current_user.id)
|
||||||
|
passkeys = current_user.passkeys.order_by(UserPasskey.created_at.desc()).all()
|
||||||
|
current_sid = get_current_session_key()
|
||||||
|
return render_template(
|
||||||
|
"cabinet/profile.html",
|
||||||
|
sessions=sessions,
|
||||||
|
passkeys=passkeys,
|
||||||
|
current_sid=current_sid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cabinet_bp.route("/profile/export")
|
||||||
|
@login_required
|
||||||
|
def export_profile():
|
||||||
|
from app.legal import export_user_data
|
||||||
|
import json
|
||||||
|
|
||||||
|
data = export_user_data(current_user)
|
||||||
|
response = make_response(json.dumps(data, ensure_ascii=False, indent=2))
|
||||||
|
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||||
|
response.headers["Content-Disposition"] = (
|
||||||
|
f'attachment; filename="photohost-{current_user.username}.json"'
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_user_account(user):
|
||||||
|
from app.models import UserPasskey, UserSession
|
||||||
|
from app.session_service import revoke_all_sessions, revoke_current_session
|
||||||
|
from app.storage_service import delete_photo_file
|
||||||
|
from flask_login import logout_user
|
||||||
|
|
||||||
|
revoke_all_sessions(user.id, except_current=False)
|
||||||
|
UserSession.query.filter_by(user_id=user.id).delete()
|
||||||
|
UserPasskey.query.filter_by(user_id=user.id).delete()
|
||||||
|
|
||||||
|
for photo in user.photos.all():
|
||||||
|
delete_photo_file(photo.filename, photo.storage_backend)
|
||||||
|
db.session.delete(photo)
|
||||||
|
|
||||||
|
for folder in user.folders.all():
|
||||||
|
for photo in folder.photos.all():
|
||||||
|
delete_photo_file(photo.filename, photo.storage_backend)
|
||||||
|
db.session.delete(photo)
|
||||||
|
db.session.delete(folder)
|
||||||
|
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
revoke_current_session()
|
||||||
|
logout_user()
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from flask import request, session
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import UserSession
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_session_key():
|
||||||
|
return session.get("sid")
|
||||||
|
|
||||||
|
|
||||||
|
def create_user_session(user, remember=False):
|
||||||
|
session_key = secrets.token_hex(32)
|
||||||
|
record = UserSession(
|
||||||
|
user_id=user.id,
|
||||||
|
session_key=session_key,
|
||||||
|
ip_address=_client_ip(),
|
||||||
|
user_agent=_client_user_agent(),
|
||||||
|
)
|
||||||
|
db.session.add(record)
|
||||||
|
db.session.commit()
|
||||||
|
session["sid"] = session_key
|
||||||
|
session.permanent = bool(remember)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_user_session(user):
|
||||||
|
key = get_current_session_key()
|
||||||
|
if not key:
|
||||||
|
return create_user_session(user)
|
||||||
|
|
||||||
|
record = UserSession.query.filter_by(
|
||||||
|
session_key=key, user_id=user.id, revoked=False
|
||||||
|
).first()
|
||||||
|
if record:
|
||||||
|
return record
|
||||||
|
return create_user_session(user)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_user_session(user_id):
|
||||||
|
key = get_current_session_key()
|
||||||
|
if not key:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
UserSession.query.filter_by(
|
||||||
|
session_key=key, user_id=user_id, revoked=False
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def touch_user_session():
|
||||||
|
key = get_current_session_key()
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
record = UserSession.query.filter_by(session_key=key, revoked=False).first()
|
||||||
|
if record:
|
||||||
|
record.last_seen_at = datetime.now(timezone.utc)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def list_user_sessions(user_id):
|
||||||
|
return (
|
||||||
|
UserSession.query.filter_by(user_id=user_id, revoked=False)
|
||||||
|
.order_by(UserSession.last_seen_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_session(session_id, user_id):
|
||||||
|
record = UserSession.query.filter_by(id=session_id, user_id=user_id).first()
|
||||||
|
if not record:
|
||||||
|
return False
|
||||||
|
record.revoked = True
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_all_sessions(user_id, except_current=True):
|
||||||
|
current_key = get_current_session_key() if except_current else None
|
||||||
|
query = UserSession.query.filter_by(user_id=user_id, revoked=False)
|
||||||
|
if current_key:
|
||||||
|
query = query.filter(UserSession.session_key != current_key)
|
||||||
|
count = query.update({"revoked": True})
|
||||||
|
db.session.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_current_session():
|
||||||
|
key = get_current_session_key()
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
UserSession.query.filter_by(session_key=key).update({"revoked": True})
|
||||||
|
db.session.commit()
|
||||||
|
session.pop("sid", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip():
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For", "")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.remote_addr
|
||||||
|
|
||||||
|
|
||||||
|
def _client_user_agent():
|
||||||
|
if request.user_agent and request.user_agent.string:
|
||||||
|
return request.user_agent.string[:512]
|
||||||
|
return None
|
||||||
@@ -1319,3 +1319,300 @@ body {
|
|||||||
body.modal-open {
|
body.modal-open {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr;
|
||||||
|
min-height: calc(100vh - 72px);
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar {
|
||||||
|
background: rgba(10, 12, 20, 0.95);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 24px 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 72px;
|
||||||
|
height: calc(100vh - 72px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar__head strong {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar__head span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar__logo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar__back {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 12px 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-main {
|
||||||
|
padding: 28px 32px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-main__header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-main__title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-main__subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav__link:hover,
|
||||||
|
.admin-nav__link--active {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav__icon {
|
||||||
|
width: 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats--cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-card {
|
||||||
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(15, 23, 42, 0.8));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-card--accent {
|
||||||
|
background: linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(15, 23, 42, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-card__value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-card__label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel--elevated {
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-version-bar {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card__title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card__hint,
|
||||||
|
.profile-card__empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item--current {
|
||||||
|
background: rgba(34, 197, 94, 0.06);
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item__meta {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-actions {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-delete-form {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-footer {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-section {
|
||||||
|
padding: 48px 0 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-container {
|
||||||
|
max-width: 820px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-container h1 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-updated {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-container h2 {
|
||||||
|
margin: 28px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookie-banner {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1100;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookie-banner[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookie-banner__inner {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 20px;
|
||||||
|
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookie-banner__text p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookie-banner__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__links {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__links a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.admin-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar {
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookie-banner__inner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const banner = document.getElementById("cookieBanner");
|
||||||
|
const acceptBtn = document.getElementById("cookieAcceptBtn");
|
||||||
|
const rejectBtn = document.getElementById("cookieRejectBtn");
|
||||||
|
if (!banner) return;
|
||||||
|
|
||||||
|
const consent = localStorage.getItem("photohost_cookie_consent");
|
||||||
|
if (!consent) {
|
||||||
|
banner.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConsent(analytics) {
|
||||||
|
await fetch("/legal/cookie-consent", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ essential: true, analytics }),
|
||||||
|
});
|
||||||
|
localStorage.setItem("photohost_cookie_consent", analytics ? "all" : "essential");
|
||||||
|
banner.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptBtn?.addEventListener("click", () => saveConsent(true));
|
||||||
|
rejectBtn?.addEventListener("click", () => saveConsent(false));
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
function bufferDecode(value) {
|
||||||
|
const padding = "=".repeat((4 - (value.length % 4)) % 4);
|
||||||
|
const base64 = (value + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const raw = window.atob(base64);
|
||||||
|
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferEncode(value) {
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(value)))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerPasskey() {
|
||||||
|
const nameInput = document.getElementById("passkeyName");
|
||||||
|
const name = nameInput ? nameInput.value.trim() : "Passkey";
|
||||||
|
|
||||||
|
const optionsResp = await fetch("/auth/passkey/register/options", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
const options = await optionsResp.json();
|
||||||
|
if (!optionsResp.ok) throw new Error(options.error || "Ошибка passkey");
|
||||||
|
|
||||||
|
options.challenge = bufferDecode(options.challenge);
|
||||||
|
options.user.id = bufferDecode(options.user.id);
|
||||||
|
if (options.excludeCredentials) {
|
||||||
|
options.excludeCredentials = options.excludeCredentials.map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: bufferDecode(item.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.create({ publicKey: options });
|
||||||
|
const verifyResp = await fetch("/auth/passkey/register/verify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
credential: {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: bufferEncode(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
attestationObject: bufferEncode(credential.response.attestationObject),
|
||||||
|
clientDataJSON: bufferEncode(credential.response.clientDataJSON),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await verifyResp.json();
|
||||||
|
if (!verifyResp.ok) throw new Error(result.error || "Не удалось сохранить passkey");
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithPasskey(username, remember) {
|
||||||
|
const optionsResp = await fetch("/auth/passkey/login/options", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
const options = await optionsResp.json();
|
||||||
|
if (!optionsResp.ok) throw new Error(options.error || "Passkey недоступен");
|
||||||
|
|
||||||
|
options.challenge = bufferDecode(options.challenge);
|
||||||
|
if (options.allowCredentials) {
|
||||||
|
options.allowCredentials = options.allowCredentials.map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: bufferDecode(item.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.get({ publicKey: options });
|
||||||
|
const verifyResp = await fetch("/auth/passkey/login/verify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
remember,
|
||||||
|
credential: {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: bufferEncode(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
authenticatorData: bufferEncode(credential.response.authenticatorData),
|
||||||
|
clientDataJSON: bufferEncode(credential.response.clientDataJSON),
|
||||||
|
signature: bufferEncode(credential.response.signature),
|
||||||
|
userHandle: credential.response.userHandle
|
||||||
|
? bufferEncode(credential.response.userHandle)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await verifyResp.json();
|
||||||
|
if (!verifyResp.ok) throw new Error(result.error || "Ошибка входа");
|
||||||
|
window.location.href = result.redirect || "/cabinet/";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const addBtn = document.getElementById("addPasskeyBtn");
|
||||||
|
if (addBtn) {
|
||||||
|
addBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
if (!window.PublicKeyCredential) {
|
||||||
|
alert("Passkey не поддерживается в этом браузере");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await registerPasskey();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || "Ошибка passkey");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginBtn = document.getElementById("passkeyLoginBtn");
|
||||||
|
if (loginBtn) {
|
||||||
|
loginBtn.addEventListener("click", async () => {
|
||||||
|
const loginInput = document.getElementById("login");
|
||||||
|
const remember = document.querySelector('input[name="remember"]')?.checked;
|
||||||
|
const username = loginInput ? loginInput.value.trim() : "";
|
||||||
|
if (!username) {
|
||||||
|
alert("Введите логин или email");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await loginWithPasskey(username, remember);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || "Ошибка passkey");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
<nav class="admin-nav">
|
<nav class="admin-nav">
|
||||||
<a href="{{ url_for('admin.dashboard') }}" class="admin-nav__link {% if request.endpoint == 'admin.dashboard' %}admin-nav__link--active{% endif %}">Обзор</a>
|
<a href="{{ url_for('admin.dashboard') }}" class="admin-nav__link {% if request.endpoint == 'admin.dashboard' %}admin-nav__link--active{% endif %}">
|
||||||
<a href="{{ url_for('admin.users') }}" class="admin-nav__link {% if request.endpoint == 'admin.users' %}admin-nav__link--active{% endif %}">Пользователи</a>
|
<span class="admin-nav__icon">📊</span> Обзор
|
||||||
<a href="{{ url_for('admin.groups') }}" class="admin-nav__link {% if request.endpoint in ['admin.groups', 'admin.edit_group', 'admin.delete_group'] %}admin-nav__link--active{% endif %}">Группы</a>
|
</a>
|
||||||
<a href="{{ url_for('admin.banners') }}" class="admin-nav__link {% if request.endpoint in ['admin.banners', 'admin.edit_banner', 'admin.delete_banner', 'admin.toggle_banner'] %}admin-nav__link--active{% endif %}">Баннеры</a>
|
<a href="{{ url_for('admin.users') }}" class="admin-nav__link {% if request.endpoint == 'admin.users' %}admin-nav__link--active{% endif %}">
|
||||||
<a href="{{ url_for('admin.photos') }}" class="admin-nav__link {% if request.endpoint == 'admin.photos' %}admin-nav__link--active{% endif %}">Фото</a>
|
<span class="admin-nav__icon">👥</span> Пользователи
|
||||||
<a href="{{ url_for('admin.deploy') }}" class="admin-nav__link {% if request.endpoint == 'admin.deploy' %}admin-nav__link--active{% endif %}">Версии Git</a>
|
</a>
|
||||||
<a href="{{ url_for('admin.settings') }}" class="admin-nav__link {% if request.endpoint == 'admin.settings' %}admin-nav__link--active{% endif %}">Настройки</a>
|
<a href="{{ url_for('admin.groups') }}" class="admin-nav__link {% if request.endpoint in ['admin.groups', 'admin.edit_group', 'admin.delete_group'] %}admin-nav__link--active{% endif %}">
|
||||||
|
<span class="admin-nav__icon">🏷️</span> Группы
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin.banners') }}" class="admin-nav__link {% if request.endpoint in ['admin.banners', 'admin.edit_banner', 'admin.delete_banner', 'admin.toggle_banner'] %}admin-nav__link--active{% endif %}">
|
||||||
|
<span class="admin-nav__icon">📢</span> Баннеры
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin.photos') }}" class="admin-nav__link {% if request.endpoint == 'admin.photos' %}admin-nav__link--active{% endif %}">
|
||||||
|
<span class="admin-nav__icon">🖼️</span> Фото
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin.deploy') }}" class="admin-nav__link {% if request.endpoint == 'admin.deploy' %}admin-nav__link--active{% endif %}">
|
||||||
|
<span class="admin-nav__icon">🚀</span> Версии Git
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin.settings') }}" class="admin-nav__link {% if request.endpoint == 'admin.settings' %}admin-nav__link--active{% endif %}">
|
||||||
|
<span class="admin-nav__icon">🔧</span> Настройки
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "admin/layout.html" %}
|
||||||
|
|
||||||
{% block title %}Рекламные баннеры — Админка{% endblock %}
|
{% block title %}Рекламные баннеры — Админка{% endblock %}
|
||||||
|
{% block admin_title %}Рекламные баннеры{% endblock %}
|
||||||
|
{% block admin_subtitle %}<p class="admin-main__subtitle">Баннеры на главной, в кабинете и в подвале</p>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block admin_content %}
|
||||||
<section class="page-header page-header--admin">
|
|
||||||
<div class="container">
|
|
||||||
<h1 class="page-header__title">Рекламные баннеры</h1>
|
|
||||||
<p class="page-header__subtitle">Баннеры на главной, в кабинете и в подвале сайта</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="admin-section">
|
|
||||||
<div class="container">
|
|
||||||
{% include "admin/_nav.html" %}
|
|
||||||
{% include "partials/alerts.html" %}
|
|
||||||
|
|
||||||
<div class="admin-panel folder-create">
|
<div class="admin-panel folder-create">
|
||||||
<h2 class="admin-panel__title">Добавить баннер</h2>
|
<h2 class="admin-panel__title">Добавить баннер</h2>
|
||||||
<form method="post" class="auth-form folder-create__form">
|
<form method="post" class="auth-form folder-create__form">
|
||||||
@@ -121,6 +111,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,54 +1,44 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "admin/layout.html" %}
|
||||||
{% from "macros.html" import format_size %}
|
{% from "macros.html" import format_size %}
|
||||||
|
|
||||||
{% block title %}Админка — PhotoHost{% endblock %}
|
{% block title %}Админка — PhotoHost{% endblock %}
|
||||||
|
{% block admin_title %}Обзор{% endblock %}
|
||||||
|
{% block admin_subtitle %}<p class="admin-main__subtitle">Статистика и последние действия</p>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block admin_content %}
|
||||||
<section class="page-header page-header--admin">
|
<div class="admin-stats admin-stats--cards">
|
||||||
<div class="container">
|
<div class="admin-stat-card">
|
||||||
<h1 class="page-header__title">Панель администратора</h1>
|
<span class="admin-stat-card__value">{{ stats.users }}</span>
|
||||||
<p class="page-header__subtitle">Управление пользователями и контентом</p>
|
<span class="admin-stat-card__label">Пользователей</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="admin-stat-card">
|
||||||
|
<span class="admin-stat-card__value">{{ stats.photos }}</span>
|
||||||
<section class="admin-section">
|
<span class="admin-stat-card__label">Фотографий</span>
|
||||||
<div class="container">
|
|
||||||
{% include "admin/_nav.html" %}
|
|
||||||
{% include "partials/alerts.html" %}
|
|
||||||
|
|
||||||
<div class="admin-stats">
|
|
||||||
<div class="stat-card stat-card--admin">
|
|
||||||
<span class="stat-card__value">{{ stats.users }}</span>
|
|
||||||
<span class="stat-card__label">пользователей</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-card--admin">
|
<div class="admin-stat-card">
|
||||||
<span class="stat-card__value">{{ stats.photos }}</span>
|
<span class="admin-stat-card__value">{{ stats.admins }}</span>
|
||||||
<span class="stat-card__label">фотографий</span>
|
<span class="admin-stat-card__label">Администраторов</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-card--admin">
|
<div class="admin-stat-card">
|
||||||
<span class="stat-card__value">{{ stats.admins }}</span>
|
<span class="admin-stat-card__value">{{ stats.groups }}</span>
|
||||||
<span class="stat-card__label">администраторов</span>
|
<span class="admin-stat-card__label">Групп</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-card--admin">
|
<div class="admin-stat-card admin-stat-card--accent">
|
||||||
<span class="stat-card__value">{{ stats.groups }}</span>
|
<span class="admin-stat-card__value">{{ format_size(stats.storage) }}</span>
|
||||||
<span class="stat-card__label">групп</span>
|
<span class="admin-stat-card__label">Хранилище</span>
|
||||||
</div>
|
|
||||||
<div class="stat-card stat-card--admin">
|
|
||||||
<span class="stat-card__value">{{ format_size(stats.storage) }}</span>
|
|
||||||
<span class="stat-card__label">хранилище</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if current_version %}
|
{% if current_version %}
|
||||||
<p class="folder-hint" style="margin-bottom: 24px;">
|
<p class="admin-version-bar">
|
||||||
Версия Git: <strong>{{ current_version }}</strong>
|
Версия Git: <strong>{{ current_version }}</strong>
|
||||||
· <a href="{{ url_for('admin.deploy') }}">Управление версиями</a>
|
· <a href="{{ url_for('admin.deploy') }}">Управление версиями</a>
|
||||||
{% if not deploy_enabled %}(deploy выключен){% endif %}
|
{% if not deploy_enabled %}<span class="badge badge--muted">deploy off</span>{% endif %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="admin-grid">
|
<div class="admin-grid">
|
||||||
<div class="admin-panel">
|
<div class="admin-panel admin-panel--elevated">
|
||||||
<h2 class="admin-panel__title">Новые пользователи</h2>
|
<h2 class="admin-panel__title">Новые пользователи</h2>
|
||||||
<div class="admin-table-wrap">
|
<div class="admin-table-wrap">
|
||||||
<table class="admin-table">
|
<table class="admin-table">
|
||||||
@@ -74,7 +64,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-panel">
|
<div class="admin-panel admin-panel--elevated">
|
||||||
<h2 class="admin-panel__title">Последние фото</h2>
|
<h2 class="admin-panel__title">Последние фото</h2>
|
||||||
<div class="admin-mini-gallery">
|
<div class="admin-mini-gallery">
|
||||||
{% for photo in recent_photos %}
|
{% for photo in recent_photos %}
|
||||||
@@ -87,6 +77,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "admin/layout.html" %}
|
||||||
|
|
||||||
{% block title %}Версии Git — Админка{% endblock %}
|
{% block title %}Версии Git — Админка{% endblock %}
|
||||||
|
{% block admin_title %}Обновление Git{% endblock %}
|
||||||
|
{% block admin_subtitle %}<p class="admin-main__subtitle">Переключение релизов и пересборка Docker</p>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block admin_content %}
|
||||||
<section class="page-header page-header--admin">
|
|
||||||
<div class="container">
|
|
||||||
<h1 class="page-header__title">Обновление и версии Git</h1>
|
|
||||||
<p class="page-header__subtitle">Переключение между релизами и пересборка Docker</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="admin-section">
|
|
||||||
<div class="container">
|
|
||||||
{% include "admin/_nav.html" %}
|
|
||||||
{% include "partials/alerts.html" %}
|
|
||||||
|
|
||||||
<div class="admin-stats">
|
<div class="admin-stats">
|
||||||
<div class="stat-card stat-card--admin">
|
<div class="stat-card stat-card--admin">
|
||||||
<span class="stat-card__value">{{ status.current or '—' }}</span>
|
<span class="stat-card__value">{{ status.current or '—' }}</span>
|
||||||
@@ -87,6 +77,4 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "admin/layout.html" %}
|
||||||
{% from "macros.html" import format_size %}
|
{% from "macros.html" import format_size %}
|
||||||
|
|
||||||
{% block title %}Группы — Админка{% endblock %}
|
{% block title %}Группы — Админка{% endblock %}
|
||||||
|
{% block admin_title %}Группы пользователей{% endblock %}
|
||||||
|
{% block admin_subtitle %}<p class="admin-main__subtitle">Квоты диска, лимиты папок и фото</p>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block admin_content %}
|
||||||
<section class="page-header page-header--admin">
|
|
||||||
<div class="container">
|
|
||||||
<h1 class="page-header__title">Группы пользователей</h1>
|
|
||||||
<p class="page-header__subtitle">Квоты диска, лимиты папок и фото для каждой группы</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="admin-section">
|
|
||||||
<div class="container">
|
|
||||||
{% include "admin/_nav.html" %}
|
|
||||||
{% include "partials/alerts.html" %}
|
|
||||||
|
|
||||||
<div class="admin-panel folder-create">
|
<div class="admin-panel folder-create">
|
||||||
<h2 class="admin-panel__title">Создать группу</h2>
|
<h2 class="admin-panel__title">Создать группу</h2>
|
||||||
<form method="post" class="auth-form folder-create__form">
|
<form method="post" class="auth-form folder-create__form">
|
||||||
@@ -86,6 +76,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="admin-shell">
|
||||||
|
<aside class="admin-sidebar">
|
||||||
|
<div class="admin-sidebar__head">
|
||||||
|
<span class="admin-sidebar__logo">⚙️</span>
|
||||||
|
<div>
|
||||||
|
<strong>PhotoHost</strong>
|
||||||
|
<span>Админ-панель</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include "admin/_nav.html" %}
|
||||||
|
<a href="{{ url_for('main.index') }}" class="admin-sidebar__back">← На сайт</a>
|
||||||
|
</aside>
|
||||||
|
<div class="admin-main">
|
||||||
|
<div class="admin-main__header">
|
||||||
|
<div>
|
||||||
|
<h1 class="admin-main__title">{% block admin_title %}Админка{% endblock %}</h1>
|
||||||
|
{% block admin_subtitle %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include "partials/alerts.html" %}
|
||||||
|
{% block admin_content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,22 +1,10 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "admin/layout.html" %}
|
||||||
|
|
||||||
{% block title %}Фото — Админка{% endblock %}
|
{% block title %}Фото — Админка{% endblock %}
|
||||||
|
{% block admin_title %}Все фотографии{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block admin_content %}
|
||||||
<section class="page-header page-header--admin">
|
|
||||||
<div class="container">
|
|
||||||
<h1 class="page-header__title">Все фотографии</h1>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="admin-section">
|
|
||||||
<div class="container">
|
|
||||||
{% include "admin/_nav.html" %}
|
|
||||||
{% include "partials/alerts.html" %}
|
|
||||||
|
|
||||||
{% with photos=photos, show_owner=true, delete_mode='admin', empty_title='Нет фотографий', empty_text='Пользователи ещё не загружали фото' %}
|
{% with photos=photos, show_owner=true, delete_mode='admin', empty_title='Нет фотографий', empty_text='Пользователи ещё не загружали фото' %}
|
||||||
{% include "partials/photo_gallery.html" %}
|
{% include "partials/photo_gallery.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "admin/layout.html" %}
|
||||||
|
|
||||||
{% block title %}Настройки — Админка{% endblock %}
|
{% block title %}Настройки — Админка{% endblock %}
|
||||||
|
{% block admin_title %}Настройки системы{% endblock %}
|
||||||
|
{% block admin_subtitle %}<p class="admin-main__subtitle">S3, SFTP, FTP, SMTP и лимиты загрузки</p>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block admin_content %}
|
||||||
<section class="page-header page-header--admin">
|
|
||||||
<div class="container">
|
|
||||||
<h1 class="page-header__title">Настройки системы</h1>
|
|
||||||
<p class="page-header__subtitle">S3, SFTP, FTP, SMTP и лимиты загрузки</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="admin-section">
|
|
||||||
<div class="container">
|
|
||||||
{% include "admin/_nav.html" %}
|
|
||||||
{% include "partials/alerts.html" %}
|
|
||||||
|
|
||||||
<form method="post" class="settings-form">
|
<form method="post" class="settings-form">
|
||||||
<input type="hidden" name="action" value="save">
|
<input type="hidden" name="action" value="save">
|
||||||
|
|
||||||
@@ -88,6 +78,4 @@
|
|||||||
<input type="hidden" name="action" value="test_smtp">
|
<input type="hidden" name="action" value="test_smtp">
|
||||||
<button type="submit" class="btn btn--ghost">Отправить тестовое письмо на {{ current_user.email }}</button>
|
<button type="submit" class="btn btn--ghost">Отправить тестовое письмо на {{ current_user.email }}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "admin/layout.html" %}
|
||||||
|
|
||||||
{% block title %}Пользователи — Админка{% endblock %}
|
{% block title %}Пользователи — Админка{% endblock %}
|
||||||
|
{% block admin_title %}Пользователи{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block admin_content %}
|
||||||
<section class="page-header page-header--admin">
|
|
||||||
<div class="container">
|
|
||||||
<h1 class="page-header__title">Пользователи</h1>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="admin-section">
|
|
||||||
<div class="container">
|
|
||||||
{% include "admin/_nav.html" %}
|
|
||||||
{% include "partials/alerts.html" %}
|
|
||||||
|
|
||||||
<div class="admin-table-wrap">
|
<div class="admin-table-wrap">
|
||||||
<table class="admin-table">
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -84,6 +74,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
<button type="submit" class="btn btn--primary btn--full">Войти</button>
|
<button type="submit" class="btn btn--primary btn--full">Войти</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn--ghost btn--full" id="passkeyLoginBtn" style="margin-top:12px">
|
||||||
|
Войти с Passkey
|
||||||
|
</button>
|
||||||
|
|
||||||
<p class="auth-card__footer">
|
<p class="auth-card__footer">
|
||||||
<a href="{{ url_for('auth.forgot_password') }}">Забыли пароль?</a> ·
|
<a href="{{ url_for('auth.forgot_password') }}">Забыли пароль?</a> ·
|
||||||
<a href="{{ url_for('auth.register') }}">Зарегистрироваться</a>
|
<a href="{{ url_for('auth.register') }}">Зарегистрироваться</a>
|
||||||
@@ -35,3 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -49,11 +49,18 @@
|
|||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container footer__inner">
|
<div class="container footer__inner">
|
||||||
<p>PhotoHost — Python + PostgreSQL + Docker</p>
|
<p>PhotoHost — Python + PostgreSQL + Docker</p>
|
||||||
|
<p class="footer__links">
|
||||||
|
<a href="{{ url_for('legal.privacy') }}">Конфиденциальность</a> ·
|
||||||
|
<a href="{{ url_for('legal.cookies') }}">Cookies</a> ·
|
||||||
|
<a href="{{ url_for('legal.gdpr') }}">GDPR</a>
|
||||||
|
</p>
|
||||||
<p class="footer__muted">Храните и делитесь фотографиями просто</p>
|
<p class="footer__muted">Храните и делитесь фотографиями просто</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{% include "partials/cookie_banner.html" %}
|
||||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/cookie-consent.js') }}"></script>
|
||||||
{% include "partials/share_modal.html" %}
|
{% include "partials/share_modal.html" %}
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
<section class="page-header">
|
<section class="page-header">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="page-header__title">Настройки профиля</h1>
|
<h1 class="page-header__title">Настройки профиля</h1>
|
||||||
<p class="page-header__subtitle">Измените email или пароль</p>
|
<p class="page-header__subtitle">Безопасность, passkey, сессии и GDPR</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="auth-section">
|
<section class="auth-section profile-section">
|
||||||
<div class="container auth-container">
|
<div class="container profile-grid">
|
||||||
<div class="auth-card auth-card--wide">
|
<div class="auth-card auth-card--wide profile-card">
|
||||||
{% include "partials/alerts.html" %}
|
{% include "partials/alerts.html" %}
|
||||||
|
|
||||||
<div class="profile-info">
|
<div class="profile-info">
|
||||||
@@ -30,7 +30,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 class="profile-card__title">Email и пароль</h2>
|
||||||
<form method="post" class="auth-form">
|
<form method="post" class="auth-form">
|
||||||
|
<input type="hidden" name="action" value="save">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email</label>
|
<label for="email">Email</label>
|
||||||
<input type="email" id="email" name="email" required value="{{ current_user.email }}">
|
<input type="email" id="email" name="email" required value="{{ current_user.email }}">
|
||||||
@@ -41,19 +43,111 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="new_password">Новый пароль (необязательно)</label>
|
<label for="new_password">Новый пароль (необязательно)</label>
|
||||||
<input type="password" id="new_password" name="new_password" minlength="6" placeholder="оставьте пустым, если не меняете">
|
<input type="password" id="new_password" name="new_password" minlength="6">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="new_password2">Подтверждение нового пароля</label>
|
<label for="new_password2">Подтверждение нового пароля</label>
|
||||||
<input type="password" id="new_password2" name="new_password2" minlength="6">
|
<input type="password" id="new_password2" name="new_password2" minlength="6">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn--primary btn--full">Сохранить</button>
|
<button type="submit" class="btn btn--primary">Сохранить</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="auth-card__footer">
|
<div class="auth-card auth-card--wide profile-card">
|
||||||
|
<h2 class="profile-card__title">Passkey</h2>
|
||||||
|
<p class="profile-card__hint">Вход без пароля через Face ID, Touch ID, Windows Hello или ключ безопасности.</p>
|
||||||
|
|
||||||
|
{% if passkeys %}
|
||||||
|
<ul class="session-list">
|
||||||
|
{% for passkey in passkeys %}
|
||||||
|
<li class="session-item">
|
||||||
|
<div>
|
||||||
|
<strong>{{ passkey.name }}</strong>
|
||||||
|
<span class="session-item__meta">Добавлен {{ passkey.created_at.strftime('%d.%m.%Y') }}</span>
|
||||||
|
</div>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="delete_passkey">
|
||||||
|
<input type="hidden" name="passkey_id" value="{{ passkey.id }}">
|
||||||
|
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="profile-card__empty">Passkey не настроен</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top:16px">
|
||||||
|
<label for="passkeyName">Название устройства</label>
|
||||||
|
<input type="text" id="passkeyName" value="Моё устройство" maxlength="120">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn--ghost" id="addPasskeyBtn">Добавить passkey</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-card auth-card--wide profile-card">
|
||||||
|
<h2 class="profile-card__title">Активные сессии</h2>
|
||||||
|
<p class="profile-card__hint">Все устройства, где выполнен вход в ваш аккаунт.</p>
|
||||||
|
|
||||||
|
{% if sessions %}
|
||||||
|
<ul class="session-list">
|
||||||
|
{% for item in sessions %}
|
||||||
|
<li class="session-item {% if item.session_key == current_sid %}session-item--current{% endif %}">
|
||||||
|
<div>
|
||||||
|
<strong>{{ item.device_label }}</strong>
|
||||||
|
{% if item.session_key == current_sid %}<span class="badge badge--success">текущая</span>{% endif %}
|
||||||
|
<span class="session-item__meta">
|
||||||
|
{{ item.ip_address or 'IP неизвестен' }} ·
|
||||||
|
{{ item.last_seen_at.strftime('%d.%m.%Y %H:%M') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if item.session_key != current_sid %}
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="revoke_session">
|
||||||
|
<input type="hidden" name="session_id" value="{{ item.id }}">
|
||||||
|
<button type="submit" class="btn btn--ghost btn--sm">Завершить</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<form method="post" style="margin-top:16px">
|
||||||
|
<input type="hidden" name="action" value="revoke_all_sessions">
|
||||||
|
<button type="submit" class="btn btn--danger btn--sm" onclick="return confirm('Завершить все сессии кроме текущей?');">
|
||||||
|
Выйти на всех устройствах
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="profile-card__empty">Нет активных сессий</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-card auth-card--wide profile-card">
|
||||||
|
<h2 class="profile-card__title">GDPR и данные</h2>
|
||||||
|
<p class="profile-card__hint">
|
||||||
|
Вы можете скачать копию своих данных или удалить аккаунт.
|
||||||
|
<a href="{{ url_for('legal.gdpr') }}">Подробнее о правах</a>
|
||||||
|
</p>
|
||||||
|
<div class="profile-actions">
|
||||||
|
<a href="{{ url_for('cabinet.export_profile') }}" class="btn btn--ghost">Скачать мои данные (JSON)</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="profile-delete-form" onsubmit="return confirm('Удалить аккаунт без возможности восстановления?');">
|
||||||
|
<input type="hidden" name="action" value="delete_account">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="delete_password">Пароль для удаления аккаунта</label>
|
||||||
|
<input type="password" id="delete_password" name="delete_password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--danger">Удалить аккаунт</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="auth-card__footer profile-footer">
|
||||||
<a href="{{ url_for('cabinet.index') }}">← Вернуться в кабинет</a>
|
<a href="{{ url_for('cabinet.index') }}">← Вернуться в кабинет</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Политика cookies — PhotoHost{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="legal-section">
|
||||||
|
<div class="container legal-container">
|
||||||
|
<h1>Политика cookies</h1>
|
||||||
|
|
||||||
|
<h2>Обязательные cookies</h2>
|
||||||
|
<p>Нужны для входа, CSRF-защиты и работы личного кабинета. Отключить их нельзя.</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>session</code> — идентификатор сессии Flask</li>
|
||||||
|
<li><code>photohost_consent</code> — ваш выбор по cookies</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Аналитические cookies</h2>
|
||||||
|
<p>Используются только при вашем согласии. Помогают понять, как улучшать сервис.</p>
|
||||||
|
|
||||||
|
<h2>Управление согласием</h2>
|
||||||
|
<p>Вы можете изменить выбор через баннер cookies или в профиле после входа.</p>
|
||||||
|
|
||||||
|
<p><a href="{{ url_for('legal.privacy') }}">Политика конфиденциальности</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}GDPR — PhotoHost{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="legal-section">
|
||||||
|
<div class="container legal-container">
|
||||||
|
<h1>GDPR — ваши права</h1>
|
||||||
|
<p>В соответствии с Общим регламентом защиты данных (EU GDPR) вы имеете следующие права:</p>
|
||||||
|
|
||||||
|
<div class="legal-cards">
|
||||||
|
<article class="legal-card">
|
||||||
|
<h3>Право на доступ</h3>
|
||||||
|
<p>Узнать, какие данные мы храним о вас.</p>
|
||||||
|
</article>
|
||||||
|
<article class="legal-card">
|
||||||
|
<h3>Право на исправление</h3>
|
||||||
|
<p>Обновить email и пароль в профиле.</p>
|
||||||
|
</article>
|
||||||
|
<article class="legal-card">
|
||||||
|
<h3>Право на переносимость</h3>
|
||||||
|
<p>Скачать данные в JSON из <a href="{{ url_for('cabinet.profile') }}">профиля</a>.</p>
|
||||||
|
</article>
|
||||||
|
<article class="legal-card">
|
||||||
|
<h3>Право на удаление</h3>
|
||||||
|
<p>Удалить аккаунт и все связанные фото в профиле.</p>
|
||||||
|
</article>
|
||||||
|
<article class="legal-card">
|
||||||
|
<h3>Право на ограничение</h3>
|
||||||
|
<p>Отключить аналитические cookies в баннере согласия.</p>
|
||||||
|
</article>
|
||||||
|
<article class="legal-card">
|
||||||
|
<h3>Право отозвать согласие</h3>
|
||||||
|
<p>Изменить настройки cookies в любой момент.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Как воспользоваться</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Войдите в аккаунт</li>
|
||||||
|
<li>Откройте <a href="{{ url_for('cabinet.profile') }}">Профиль</a></li>
|
||||||
|
<li>Экспортируйте данные или удалите аккаунт</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p>Для запросов к администратору используйте email, указанный при регистрации.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Политика конфиденциальности — PhotoHost{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="legal-section">
|
||||||
|
<div class="container legal-container">
|
||||||
|
<h1>Политика конфиденциальности</h1>
|
||||||
|
<p class="legal-updated">Последнее обновление: {{ "2026-06-06" }}</p>
|
||||||
|
|
||||||
|
<h2>1. Какие данные мы обрабатываем</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Учётные данные: имя пользователя, email, хеш пароля</li>
|
||||||
|
<li>Загруженные фото и метаданные (имя файла, размер, дата)</li>
|
||||||
|
<li>Технические данные: IP-адрес, user-agent, cookies сессии</li>
|
||||||
|
<li>Passkey (публичный ключ WebAuthn, без хранения биометрии)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>2. Цели обработки</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Регистрация, авторизация и предоставление сервиса</li>
|
||||||
|
<li>Хранение и публикация загруженных изображений</li>
|
||||||
|
<li>Безопасность аккаунта и управление сессиями</li>
|
||||||
|
<li>Выполнение требований GDPR</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Правовые основания (GDPR)</h2>
|
||||||
|
<p>Обработка осуществляется на основании исполнения договора (п. 6(1)(b) GDPR) и законного интереса по обеспечению безопасности (п. 6(1)(f) GDPR).</p>
|
||||||
|
|
||||||
|
<h2>4. Срок хранения</h2>
|
||||||
|
<p>Данные хранятся до удаления аккаунта пользователем или до получения запроса на удаление.</p>
|
||||||
|
|
||||||
|
<h2>5. Ваши права</h2>
|
||||||
|
<p>Вы можете запросить доступ, исправление, экспорт или удаление данных в <a href="{{ url_for('cabinet.profile') }}">профиле</a> или связавшись с администратором сайта.</p>
|
||||||
|
|
||||||
|
<p><a href="{{ url_for('legal.gdpr') }}">GDPR — подробнее о правах</a> · <a href="{{ url_for('legal.cookies') }}">Политика cookies</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<div class="cookie-banner" id="cookieBanner" hidden>
|
||||||
|
<div class="cookie-banner__inner">
|
||||||
|
<div class="cookie-banner__text">
|
||||||
|
<strong>Мы используем cookies</strong>
|
||||||
|
<p>
|
||||||
|
Обязательные cookies нужны для входа и работы сайта.
|
||||||
|
Аналитические cookies помогают улучшать сервис.
|
||||||
|
<a href="{{ url_for('legal.cookies') }}">Политика cookies</a> ·
|
||||||
|
<a href="{{ url_for('legal.privacy') }}">Конфиденциальность</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cookie-banner__actions">
|
||||||
|
<button type="button" class="btn btn--ghost btn--sm" id="cookieRejectBtn">Только необходимые</button>
|
||||||
|
<button type="button" class="btn btn--primary btn--sm" id="cookieAcceptBtn">Принять все</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -34,6 +34,9 @@ services:
|
|||||||
GIT_REMOTE_URL: ${GIT_REMOTE_URL:-https://git.evilfox.cc/test2/fotohost.git}
|
GIT_REMOTE_URL: ${GIT_REMOTE_URL:-https://git.evilfox.cc/test2/fotohost.git}
|
||||||
ALLOW_GIT_DEPLOY: ${ALLOW_GIT_DEPLOY:-false}
|
ALLOW_GIT_DEPLOY: ${ALLOW_GIT_DEPLOY:-false}
|
||||||
CONTAINER_NAME: photohost-web
|
CONTAINER_NAME: photohost-web
|
||||||
|
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
|
||||||
|
WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-PhotoHost}
|
||||||
|
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-http://localhost:8080}
|
||||||
GIT_CONFIG_COUNT: "1"
|
GIT_CONFIG_COUNT: "1"
|
||||||
GIT_CONFIG_KEY_0: safe.directory
|
GIT_CONFIG_KEY_0: safe.directory
|
||||||
GIT_CONFIG_VALUE_0: /repo
|
GIT_CONFIG_VALUE_0: /repo
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ boto3==1.35.99
|
|||||||
paramiko==3.5.1
|
paramiko==3.5.1
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
qrcode[pil]==8.0
|
qrcode[pil]==8.0
|
||||||
|
webauthn==2.2.0
|
||||||
|
|||||||
Reference in New Issue
Block a user