diff --git a/.env.example b/.env.example index cd14689..fe2124c 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,8 @@ DEFAULT_GROUP_MAX_PHOTOS=500 # Git deploy from admin panel (requires repo mount and docker socket) ALLOW_GIT_DEPLOY=false 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 diff --git a/README.md b/README.md index d810771..4a411fb 100644 --- a/README.md +++ b/README.md @@ -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 **Загрузка по прямым ссылкам** diff --git a/app/__init__.py b/app/__init__.py index 4a79df3..3874690 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -51,13 +51,18 @@ def create_app(setup_database=True): from .auth import bp as auth_bp from .admin import bp as admin_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(cabinet_bp) app.register_blueprint(auth_bp) app.register_blueprint(admin_bp) app.register_blueprint(folders_bp) + app.register_blueprint(legal_bp) + app.register_blueprint(passkey_bp) + register_request_hooks(app) register_cli(app) # Ensure models are registered even when DB setup runs in init_db.py. @@ -72,6 +77,8 @@ def create_app(setup_database=True): SiteSettings, User, UserGroup, + UserPasskey, + UserSession, ) @app.context_processor @@ -103,6 +110,34 @@ def create_app(setup_database=True): 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): @app.cli.command("create-admin") def create_admin_command(): diff --git a/app/auth.py b/app/auth.py index 1f26c4e..f9b9109 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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 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.folder_utils import process_pending_invites from app.models import PasswordResetToken, User, UserGroup @@ -41,6 +42,7 @@ def register(): db.session.add(user) db.session.commit() login_user(user) + create_user_session(user) accepted = process_pending_invites(user) send_welcome_email(user) flash("Регистрация успешна. Добро пожаловать!", "success") @@ -71,6 +73,7 @@ def login(): flash("Аккаунт заблокирован", "error") else: login_user(user, remember=remember) + create_user_session(user, remember=remember) accepted = process_pending_invites(user) flash(f"Добро пожаловать, {user.username}!", "success") if accepted: @@ -137,6 +140,7 @@ def reset_password(token): @bp.route("/logout") def logout(): + revoke_current_session() logout_user() flash("Вы вышли из аккаунта", "success") return redirect(url_for("main.index")) diff --git a/app/bootstrap.py b/app/bootstrap.py index 8c3f05c..7cc0eae 100644 --- a/app/bootstrap.py +++ b/app/bootstrap.py @@ -104,9 +104,29 @@ def ensure_photo_storage_column(): 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(): ensure_schema() ensure_group_limit_columns() + ensure_user_privacy_columns() from app.folders import ensure_folder_schema ensure_folder_schema() diff --git a/app/legal.py b/app/legal.py new file mode 100644 index 0000000..1409f9f --- /dev/null +++ b/app/legal.py @@ -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(), + } diff --git a/app/models.py b/app/models.py index 143a497..3d6563f 100644 --- a/app/models.py +++ b/app/models.py @@ -96,6 +96,8 @@ class User(UserMixin, db.Model): is_admin = db.Column(db.Boolean, nullable=False, default=False) 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) + gdpr_accepted_at = db.Column(db.DateTime, nullable=True) + cookie_analytics = db.Column(db.Boolean, nullable=False, default=False) created_at = db.Column( db.DateTime, nullable=False, @@ -126,6 +128,63 @@ class User(UserMixin, db.Model): 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): __tablename__ = "user_groups" diff --git a/app/passkey.py b/app/passkey.py new file mode 100644 index 0000000..0d3d591 --- /dev/null +++ b/app/passkey.py @@ -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 diff --git a/app/passkey_service.py b/app/passkey_service.py new file mode 100644 index 0000000..6c9e365 --- /dev/null +++ b/app/passkey_service.py @@ -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 diff --git a/app/routes.py b/app/routes.py index 1825c3c..0a96ec9 100644 --- a/app/routes.py +++ b/app/routes.py @@ -7,6 +7,7 @@ from flask import ( current_app, flash, jsonify, + make_response, redirect, render_template, request, @@ -209,9 +210,44 @@ def index(): @cabinet_bp.route("/profile", methods=["GET", "POST"]) @login_required 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": + 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() current_password = request.form.get("current_password", "") new_password = request.form.get("new_password", "") @@ -234,4 +270,53 @@ def profile(): flash("Профиль обновлён", "success") 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() diff --git a/app/session_service.py b/app/session_service.py new file mode 100644 index 0000000..f4eb13f --- /dev/null +++ b/app/session_service.py @@ -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 diff --git a/app/static/css/style.css b/app/static/css/style.css index 0ee3893..f9f267c 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1319,3 +1319,300 @@ body { body.modal-open { 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; + } +} diff --git a/app/static/js/cookie-consent.js b/app/static/js/cookie-consent.js new file mode 100644 index 0000000..a48c100 --- /dev/null +++ b/app/static/js/cookie-consent.js @@ -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)); +}); diff --git a/app/static/js/passkey.js b/app/static/js/passkey.js new file mode 100644 index 0000000..3f622be --- /dev/null +++ b/app/static/js/passkey.js @@ -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"); + } + }); + } +}); diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html index 5eac24a..83e525b 100644 --- a/app/templates/admin/_nav.html +++ b/app/templates/admin/_nav.html @@ -1,9 +1,23 @@ diff --git a/app/templates/admin/banners.html b/app/templates/admin/banners.html index e478169..e99dc45 100644 --- a/app/templates/admin/banners.html +++ b/app/templates/admin/banners.html @@ -1,20 +1,10 @@ -{% extends "base.html" %} +{% extends "admin/layout.html" %} {% block title %}Рекламные баннеры — Админка{% endblock %} +{% block admin_title %}Рекламные баннеры{% endblock %} +{% block admin_subtitle %}
Баннеры на главной, в кабинете и в подвале
{% endblock %} -{% block content %} -Баннеры на главной, в кабинете и в подвале сайта
-Статистика и последние действия
{% endblock %} -{% block content %} -Управление пользователями и контентом
+{% block admin_content %} +- Версия Git: {{ current_version }} - · Управление версиями - {% if not deploy_enabled %}(deploy выключен){% endif %} -
- {% endif %} - -| Логин | -Дата | -|
|---|---|---|
| {{ user.username }}{% if user.is_admin %} admin{% endif %} | -{{ user.email }} | -{{ user.created_at.strftime('%d.%m.%Y') }} | -
| Нет пользователей | ||
| Логин | +Дата | +|
|---|---|---|
| {{ user.username }}{% if user.is_admin %} admin{% endif %} | +{{ user.email }} | +{{ user.created_at.strftime('%d.%m.%Y') }} | +
| Нет пользователей | ||
Нет фотографий
+ {% endfor %} +Переключение релизов и пересборка Docker
{% endblock %} -{% block content %} -Переключение между релизами и пересборка Docker
-Квоты диска, лимиты папок и фото
{% endblock %} -{% block content %} -Квоты диска, лимиты папок и фото для каждой группы
-S3, SFTP, FTP, SMTP и лимиты загрузки
{% endblock %} -{% block content %} -S3, SFTP, FTP, SMTP и лимиты загрузки
-