Release v2.1: GDPR, passkeys, session management, admin redesign

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-07 02:43:57 +03:00
parent d4f0eaa7d9
commit 0a51001791
32 changed files with 1529 additions and 193 deletions
+5
View File
@@ -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
+37
View File
@@ -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
**Загрузка по прямым ссылкам**
+35
View File
@@ -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():
+4
View File
@@ -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"))
+20
View File
@@ -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()
+85
View File
@@ -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(),
}
+59
View File
@@ -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"
+83
View File
@@ -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
+159
View File
@@ -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
View File
@@ -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()
+110
View File
@@ -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
+297
View File
@@ -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;
}
}
+24
View File
@@ -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));
});
+133
View File
@@ -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");
}
});
}
});
+21 -7
View File
@@ -1,9 +1,23 @@
<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.users') }}" class="admin-nav__link {% if request.endpoint == 'admin.users' %}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 %}">Группы</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.photos') }}" class="admin-nav__link {% if request.endpoint == 'admin.photos' %}admin-nav__link--active{% endif %}">Фото</a>
<a href="{{ url_for('admin.deploy') }}" class="admin-nav__link {% if request.endpoint == 'admin.deploy' %}admin-nav__link--active{% endif %}">Версии Git</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.dashboard') }}" class="admin-nav__link {% if request.endpoint == 'admin.dashboard' %}admin-nav__link--active{% endif %}">
<span class="admin-nav__icon">📊</span> Обзор
</a>
<a href="{{ url_for('admin.users') }}" class="admin-nav__link {% if request.endpoint == 'admin.users' %}admin-nav__link--active{% endif %}">
<span class="admin-nav__icon">👥</span> Пользователи
</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>
+4 -16
View File
@@ -1,20 +1,10 @@
{% extends "base.html" %}
{% extends "admin/layout.html" %}
{% block title %}Рекламные баннеры — Админка{% endblock %}
{% block admin_title %}Рекламные баннеры{% endblock %}
{% block admin_subtitle %}<p class="admin-main__subtitle">Баннеры на главной, в кабинете и в подвале</p>{% endblock %}
{% block 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" %}
{% block admin_content %}
<div class="admin-panel folder-create">
<h2 class="admin-panel__title">Добавить баннер</h2>
<form method="post" class="auth-form folder-create__form">
@@ -121,6 +111,4 @@
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
+34 -46
View File
@@ -1,54 +1,44 @@
{% extends "base.html" %}
{% extends "admin/layout.html" %}
{% from "macros.html" import format_size %}
{% block title %}Админка — PhotoHost{% endblock %}
{% block admin_title %}Обзор{% endblock %}
{% block admin_subtitle %}<p class="admin-main__subtitle">Статистика и последние действия</p>{% endblock %}
{% block content %}
<section class="page-header page-header--admin">
<div class="container">
<h1 class="page-header__title">Панель администратора</h1>
<p class="page-header__subtitle">Управление пользователями и контентом</p>
{% block admin_content %}
<div class="admin-stats admin-stats--cards">
<div class="admin-stat-card">
<span class="admin-stat-card__value">{{ stats.users }}</span>
<span class="admin-stat-card__label">Пользователей</span>
</div>
</section>
<div class="admin-stat-card">
<span class="admin-stat-card__value">{{ stats.photos }}</span>
<span class="admin-stat-card__label">Фотографий</span>
</div>
<div class="admin-stat-card">
<span class="admin-stat-card__value">{{ stats.admins }}</span>
<span class="admin-stat-card__label">Администраторов</span>
</div>
<div class="admin-stat-card">
<span class="admin-stat-card__value">{{ stats.groups }}</span>
<span class="admin-stat-card__label">Групп</span>
</div>
<div class="admin-stat-card admin-stat-card--accent">
<span class="admin-stat-card__value">{{ format_size(stats.storage) }}</span>
<span class="admin-stat-card__label">Хранилище</span>
</div>
</div>
<section class="admin-section">
<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 class="stat-card stat-card--admin">
<span class="stat-card__value">{{ stats.photos }}</span>
<span class="stat-card__label">фотографий</span>
</div>
<div class="stat-card stat-card--admin">
<span class="stat-card__value">{{ stats.admins }}</span>
<span class="stat-card__label">администраторов</span>
</div>
<div class="stat-card stat-card--admin">
<span class="stat-card__value">{{ stats.groups }}</span>
<span class="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>
{% if current_version %}
<p class="folder-hint" style="margin-bottom: 24px;">
{% if current_version %}
<p class="admin-version-bar">
Версия Git: <strong>{{ current_version }}</strong>
· <a href="{{ url_for('admin.deploy') }}">Управление версиями</a>
{% if not deploy_enabled %}(deploy выключен){% endif %}
</p>
{% endif %}
{% if not deploy_enabled %}<span class="badge badge--muted">deploy off</span>{% endif %}
</p>
{% endif %}
<div class="admin-grid">
<div class="admin-panel">
<div class="admin-grid">
<div class="admin-panel admin-panel--elevated">
<h2 class="admin-panel__title">Новые пользователи</h2>
<div class="admin-table-wrap">
<table class="admin-table">
@@ -74,7 +64,7 @@
</div>
</div>
<div class="admin-panel">
<div class="admin-panel admin-panel--elevated">
<h2 class="admin-panel__title">Последние фото</h2>
<div class="admin-mini-gallery">
{% for photo in recent_photos %}
@@ -86,7 +76,5 @@
{% endfor %}
</div>
</div>
</div>
</div>
</section>
</div>
{% endblock %}
+4 -16
View File
@@ -1,20 +1,10 @@
{% extends "base.html" %}
{% extends "admin/layout.html" %}
{% block title %}Версии Git — Админка{% endblock %}
{% block admin_title %}Обновление Git{% endblock %}
{% block admin_subtitle %}<p class="admin-main__subtitle">Переключение релизов и пересборка Docker</p>{% endblock %}
{% block 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" %}
{% block admin_content %}
<div class="admin-stats">
<div class="stat-card stat-card--admin">
<span class="stat-card__value">{{ status.current or '—' }}</span>
@@ -87,6 +77,4 @@
</form>
</div>
</div>
</div>
</section>
{% endblock %}
+4 -16
View File
@@ -1,21 +1,11 @@
{% extends "base.html" %}
{% extends "admin/layout.html" %}
{% from "macros.html" import format_size %}
{% block title %}Группы — Админка{% endblock %}
{% block admin_title %}Группы пользователей{% endblock %}
{% block admin_subtitle %}<p class="admin-main__subtitle">Квоты диска, лимиты папок и фото</p>{% endblock %}
{% block 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" %}
{% block admin_content %}
<div class="admin-panel folder-create">
<h2 class="admin-panel__title">Создать группу</h2>
<form method="post" class="auth-form folder-create__form">
@@ -86,6 +76,4 @@
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
+27
View File
@@ -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 %}
+3 -15
View File
@@ -1,22 +1,10 @@
{% extends "base.html" %}
{% extends "admin/layout.html" %}
{% block title %}Фото — Админка{% endblock %}
{% block admin_title %}Все фотографии{% endblock %}
{% block 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" %}
{% block admin_content %}
{% with photos=photos, show_owner=true, delete_mode='admin', empty_title='Нет фотографий', empty_text='Пользователи ещё не загружали фото' %}
{% include "partials/photo_gallery.html" %}
{% endwith %}
</div>
</section>
{% endblock %}
+4 -16
View File
@@ -1,20 +1,10 @@
{% extends "base.html" %}
{% extends "admin/layout.html" %}
{% block title %}Настройки — Админка{% endblock %}
{% block admin_title %}Настройки системы{% endblock %}
{% block admin_subtitle %}<p class="admin-main__subtitle">S3, SFTP, FTP, SMTP и лимиты загрузки</p>{% endblock %}
{% block 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" %}
{% block admin_content %}
<form method="post" class="settings-form">
<input type="hidden" name="action" value="save">
@@ -88,6 +78,4 @@
<input type="hidden" name="action" value="test_smtp">
<button type="submit" class="btn btn--ghost">Отправить тестовое письмо на {{ current_user.email }}</button>
</form>
</div>
</section>
{% endblock %}
+3 -15
View File
@@ -1,19 +1,9 @@
{% extends "base.html" %}
{% extends "admin/layout.html" %}
{% block title %}Пользователи — Админка{% endblock %}
{% block admin_title %}Пользователи{% endblock %}
{% block 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" %}
{% block admin_content %}
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
@@ -84,6 +74,4 @@
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
+8
View File
@@ -27,6 +27,10 @@
<button type="submit" class="btn btn--primary btn--full">Войти</button>
</form>
<button type="button" class="btn btn--ghost btn--full" id="passkeyLoginBtn" style="margin-top:12px">
Войти с Passkey
</button>
<p class="auth-card__footer">
<a href="{{ url_for('auth.forgot_password') }}">Забыли пароль?</a> ·
<a href="{{ url_for('auth.register') }}">Зарегистрироваться</a>
@@ -35,3 +39,7 @@
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
{% endblock %}
+7
View File
@@ -49,11 +49,18 @@
<footer class="footer">
<div class="container footer__inner">
<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>
</div>
</footer>
{% include "partials/cookie_banner.html" %}
<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" %}
{% block scripts %}{% endblock %}
</body>
+102 -8
View File
@@ -6,13 +6,13 @@
<section class="page-header">
<div class="container">
<h1 class="page-header__title">Настройки профиля</h1>
<p class="page-header__subtitle">Измените email или пароль</p>
<p class="page-header__subtitle">Безопасность, passkey, сессии и GDPR</p>
</div>
</section>
<section class="auth-section">
<div class="container auth-container">
<div class="auth-card auth-card--wide">
<section class="auth-section profile-section">
<div class="container profile-grid">
<div class="auth-card auth-card--wide profile-card">
{% include "partials/alerts.html" %}
<div class="profile-info">
@@ -30,7 +30,9 @@
</div>
</div>
<h2 class="profile-card__title">Email и пароль</h2>
<form method="post" class="auth-form">
<input type="hidden" name="action" value="save">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required value="{{ current_user.email }}">
@@ -41,19 +43,111 @@
</div>
<div class="form-group">
<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 class="form-group">
<label for="new_password2">Подтверждение нового пароля</label>
<input type="password" id="new_password2" name="new_password2" minlength="6">
</div>
<button type="submit" class="btn btn--primary btn--full">Сохранить</button>
<button type="submit" class="btn btn--primary">Сохранить</button>
</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>
</p>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
{% endblock %}
+26
View File
@@ -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 %}
+48
View File
@@ -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 %}
+39
View File
@@ -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 %}
+17
View File
@@ -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>
+3
View File
@@ -34,6 +34,9 @@ services:
GIT_REMOTE_URL: ${GIT_REMOTE_URL:-https://git.evilfox.cc/test2/fotohost.git}
ALLOW_GIT_DEPLOY: ${ALLOW_GIT_DEPLOY:-false}
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_KEY_0: safe.directory
GIT_CONFIG_VALUE_0: /repo
+1
View File
@@ -10,3 +10,4 @@ boto3==1.35.99
paramiko==3.5.1
requests==2.32.3
qrcode[pil]==8.0
webauthn==2.2.0