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:
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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="Приглашение применено",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
File diff suppressed because one or more lines are too long
+19
-7
@@ -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)
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+1
-1
@@ -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()}"
|
||||
|
||||
Reference in New Issue
Block a user