Webapp hardening + perf / CSP nonce-only / plugin-builder API guard + token autogen / cookie Secure / XSS fixes (QR, postMessage) / auth-me SWR dedup / SSR fetch cache / entry-capture redesign (gift/partner/referral) / payment webhooks (yoomoney sign, tribute trb_user_id) / email-only partners+referrals / INSTALL guide

This commit is contained in:
Vladless
2026-04-18 18:12:05 +00:00
parent cc2c9fbdba
commit 0b17346cb6
12 changed files with 122 additions and 15 deletions
+14
View File
@@ -119,6 +119,20 @@ async def register_by_email(
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
if referrer_user is not None and not await get_referral_by_referred_id(session, billing_user_id):
await add_referral(session, billing_user_id, referrer_user.id)
if referrer_user.tg_id is not None:
try:
from database.web_notifications import notify_web
await notify_web(
session,
tg_id=int(referrer_user.tg_id),
type="referral_joined",
title="Ваш реферал присоединился",
message="Новый пользователь зарегистрировался по вашей реферальной ссылке.",
data={"referred_user_id": int(billing_user_id)},
)
except Exception:
pass
if smtp_configured():
try:
code = f"{secrets.randbelow(900000) + 100000}"
+21 -1
View File
@@ -109,8 +109,15 @@ async def _resolve_partner_user(session: AsyncSession, request: Request, identit
{"user_id": int(billing_user_id)},
)
).first()
if row is None or row[1] is None:
if row is None:
raise HTTPException(status_code=400, detail="Партнерский профиль недоступен")
if row[1] is None:
synthetic = -int(row[0])
await session.execute(
text("UPDATE users SET tg_id = :tg_id WHERE id = :user_id"),
{"tg_id": synthetic, "user_id": int(row[0])},
)
return int(row[0]), synthetic
return int(row[0]), int(row[1])
@@ -207,6 +214,19 @@ async def partner_apply(
),
{"partner_tg_id": int(referrer_tg_id), "joined_tg_id": int(joined_tg_id)},
)
try:
from database.web_notifications import notify_web
await notify_web(
session,
tg_id=int(referrer_tg_id),
type="partner_joined",
title="К вам присоединился партнёр",
message="Новый пользователь перешёл по вашей партнёрской ссылке.",
data={"joined_tg_id": int(joined_tg_id), "joined_user_id": int(joined_user_id)},
)
except Exception:
pass
await session.commit()
return PartnerApplyResponse(
ok=True,
+17
View File
@@ -93,6 +93,23 @@ async def apply_referral(
raise HTTPException(status_code=409, detail="Реферальная связь уже сохранена")
await add_referral(session, billing_uid, referrer_u.id)
referred_u = await resolve_user_optional(session, billing_uid)
if referrer_u.tg_id is not None:
try:
from database.web_notifications import notify_web
await notify_web(
session,
tg_id=int(referrer_u.tg_id),
type="referral_joined",
title="Ваш реферал присоединился",
message="Новый пользователь зарегистрировался по вашей реферальной ссылке.",
data={
"referred_tg_id": int(referred_u.tg_id) if referred_u and referred_u.tg_id else None,
"referred_user_id": int(billing_uid),
},
)
except Exception:
pass
return ReferralApplyResponse(
ok=True,
message="Приглашение применено",
+41
View File
@@ -2,6 +2,7 @@ import json
import locale
import os
import re
import secrets
import shutil
import subprocess
import sys
@@ -1139,6 +1140,31 @@ def _ensure_web_logs_dir() -> None:
pass
def _read_env_value(env_path: str, key: str) -> str:
"""Читает значение ключа из .env файла, если файл существует."""
if not os.path.exists(env_path):
return ""
try:
with open(env_path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip()
except Exception:
pass
return ""
def _ensure_plugin_builder_token(env_path: str) -> tuple[str, bool]:
"""Возвращает (token, is_new): существующий PLUGIN_BUILDER_TOKEN из .env или свежий 64-hex."""
existing = _read_env_value(env_path, "PLUGIN_BUILDER_TOKEN")
if existing and len(existing) >= 32:
return existing, False
return secrets.token_hex(32), True
def _generate_vapid_keys() -> tuple[str, str] | None:
"""VAPID keypair (P-256). Returns (public_b64url, private_b64url) или None."""
try:
@@ -1705,6 +1731,7 @@ def install_website():
api_port_from_url = "80"
env_path = os.path.join(WEB_DIR, ".env")
plugin_builder_token, plugin_builder_token_is_new = _ensure_plugin_builder_token(env_path)
with open(env_path, "w") as f:
f.write(f"API_URL={api_url}\n")
f.write(f"API_BASE_URL={api_url}\n")
@@ -1716,6 +1743,7 @@ def install_website():
f.write(f"NEXT_PUBLIC_TURNSTILE_SITE_KEY={turnstile_key}\n")
f.write("NEXT_PUBLIC_LOG_LEVEL=info\n")
f.write(f"WEB_PORT={web_port}\n")
f.write(f"PLUGIN_BUILDER_TOKEN={plugin_builder_token}\n")
if tg_bot_username:
f.write(f"NEXT_PUBLIC_TELEGRAM_BOT_USERNAME={tg_bot_username}\n")
if smtp_host:
@@ -1725,6 +1753,19 @@ def install_website():
f.write(f"EMAIL_SMTP_PASSWORD={smtp_password}\n")
f.write(f"EMAIL_FROM={smtp_from}\n")
if plugin_builder_token_is_new:
console.print(
Panel(
f"[bold]PLUGIN_BUILDER_TOKEN[/bold] = {plugin_builder_token}\n\n"
"[yellow]Токен защищает plugin-builder API от посторонних.\n"
"Сохраните, если планируете использовать внешний билд-воркер для custom-elements —\n"
"воркер должен слать этот же токен в заголовке Authorization: Bearer <token>.[/yellow]",
border_style="yellow",
title="[bold yellow]PLUGIN_BUILDER_TOKEN — сгенерирован[/bold yellow]",
padding=(1, 2),
)
)
src_dir = os.path.join(WEB_DIR, "src")
if not _ensure_web_image(src_dir, web_tag):
return
+5
View File
@@ -378,10 +378,15 @@ async def ensure_billing_user_for_identity(session: AsyncSession, identity: Iden
res = await session.execute(select(User).where(User.identity_id == identity.id))
row = res.scalars().first()
if row is not None:
if row.tg_id is None:
synthetic = -int(row.id)
await session.execute(update(User).where(User.id == row.id).values(tg_id=synthetic))
return int(row.id)
new_u = User(identity_id=identity.id, tg_id=None)
session.add(new_u)
await session.flush()
synthetic = -int(new_u.id)
await session.execute(update(User).where(User.id == new_u.id).values(tg_id=synthetic))
return int(new_u.id)
+1 -3
View File
@@ -19,12 +19,10 @@ async def get_site_revision(session: AsyncSession) -> int:
async def bump_site_revision(session: AsyncSession) -> int:
"""Инкрементирует глобальный счётчик контента сайта. Клиенты опрашивают его
и инвалидируют свои кэши при изменении значения."""
result = await session.execute(select(Setting).where(Setting.key == _KEY))
setting = result.scalar_one_or_none()
if setting is None:
session.add(Setting(key=_KEY, value=1, description="Счётчик ревизии контента сайта — инкремент на любом сохранении"))
session.add(Setting(key=_KEY, value=1))
return 1
try:
current = int(setting.value or 0)
+2 -2
View File
@@ -89,7 +89,7 @@ async def count_unread_for_identity(
.select_from(WebNotification)
.where(
WebNotification.identity_id == identity_id,
WebNotification.read is False,
WebNotification.read == False, # noqa: E712 SQLAlchemy expression
)
)
return result.scalar() or 0
@@ -103,7 +103,7 @@ async def mark_all_read_for_identity(
update(WebNotification)
.where(
WebNotification.identity_id == identity_id,
WebNotification.read is False,
WebNotification.read == False, # noqa: E712 SQLAlchemy expression
)
.values(read=True)
)
Executable → Regular
+1 -1
View File
File diff suppressed because one or more lines are too long
+19 -7
View File
@@ -159,13 +159,25 @@ async def redeem_gift(
try:
from database.web_notifications import notify_web
await notify_web(
session,
tg_id=wu.tg_id,
type="gift_received",
template_vars={"name": tariff["name"], "duration": duration_text},
data={"gift_id": gift_info.gift_id, "tariff_id": int(tariff["id"])},
)
if wu.tg_id is not None:
await notify_web(
session,
tg_id=wu.tg_id,
type="gift_received",
template_vars={"name": tariff["name"], "duration": duration_text},
data={"gift_id": gift_info.gift_id, "tariff_id": int(tariff["id"])},
)
if gift_info.sender_user_id:
sender = await resolve_user_optional(session, gift_info.sender_user_id)
if sender and sender.tg_id is not None:
await notify_web(
session,
tg_id=int(sender.tg_id),
type="gift_redeemed",
title="Ваш подарок активирован",
message=f"Получатель активировал подарок — подписка на {duration_text}.",
data={"gift_id": gift_info.gift_id, "tariff_id": int(tariff["id"])},
)
except Exception as e:
logger.warning("[Gifts] Ошибка отправки уведомления о подарке: {}", e)
+1 -1
View File
@@ -92,4 +92,4 @@ def get_git_commit_number() -> str:
def get_version() -> str:
return f"v.6-b1704261000 {get_git_commit_number()}"
return f"v.6-b1804261111 {get_git_commit_number()}"