WEB-APP/ Optimization/ Build fix/ Hotkey edit mode/ Log rotation/ Form a11y/ E2E non-blocking
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
+88
-25
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
|
||||
@@ -21,6 +22,7 @@ from config import (
|
||||
DB_NAME,
|
||||
DB_PASSWORD,
|
||||
DB_USER,
|
||||
PG_IN_DOCKER,
|
||||
PG_HOST,
|
||||
PG_PORT,
|
||||
BACKUP_CREATE_ARCHIVE,
|
||||
@@ -32,6 +34,60 @@ from config import (
|
||||
from logger import logger
|
||||
|
||||
|
||||
DOCKER_POSTGRES_CONTAINER = "solobot-postgres"
|
||||
|
||||
|
||||
def _find_docker_postgres_container() -> str | None:
|
||||
if shutil.which("docker") is None:
|
||||
return None
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.State.Running}}", DOCKER_POSTGRES_CONTAINER],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip().lower() == "true":
|
||||
return DOCKER_POSTGRES_CONTAINER
|
||||
return None
|
||||
|
||||
|
||||
def _get_postgres_execution_target() -> tuple[str, str | None]:
|
||||
if PG_IN_DOCKER:
|
||||
container = _find_docker_postgres_container()
|
||||
if container:
|
||||
return "docker", container
|
||||
raise FileNotFoundError(
|
||||
f"PostgreSQL настроен на Docker, но контейнер '{DOCKER_POSTGRES_CONTAINER}' не найден или не запущен"
|
||||
)
|
||||
return "host", None
|
||||
|
||||
|
||||
def _create_database_backup_via_docker(filename: Path, container: str) -> None:
|
||||
with open(filename, "wb") as dump_file:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
"-e",
|
||||
f"PGPASSWORD={DB_PASSWORD}",
|
||||
container,
|
||||
"pg_dump",
|
||||
"-U",
|
||||
DB_USER,
|
||||
"-h",
|
||||
"127.0.0.1",
|
||||
"-p",
|
||||
"5432",
|
||||
"-F",
|
||||
"c",
|
||||
DB_NAME,
|
||||
],
|
||||
stdout=dump_file,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise subprocess.CalledProcessError(result.returncode, result.args, stderr=result.stderr)
|
||||
|
||||
|
||||
async def backup_database(bot_instance: Bot | None = None) -> Exception | None:
|
||||
"""
|
||||
Создает резервную копию базы данных (или полный архив) и отправляет его администраторам.
|
||||
@@ -85,38 +141,45 @@ def _create_database_backup() -> tuple[str | None, Exception | None]:
|
||||
filename = backup_dir / f"{DB_NAME}-backup-{date_formatted}-{pid_suffix}.sql"
|
||||
|
||||
try:
|
||||
os.environ["PGPASSWORD"] = DB_PASSWORD
|
||||
target, container = _get_postgres_execution_target()
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"pg_dump",
|
||||
"-U",
|
||||
DB_USER,
|
||||
"-h",
|
||||
PG_HOST,
|
||||
"-p",
|
||||
PG_PORT,
|
||||
"-F",
|
||||
"c",
|
||||
"-f",
|
||||
str(filename),
|
||||
DB_NAME,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
logger.info("[Backup] БД создана: {}", filename)
|
||||
if target == "docker" and container:
|
||||
_create_database_backup_via_docker(filename, container)
|
||||
logger.info("[Backup] БД создана через Docker-контейнер {}: {}", container, filename)
|
||||
elif shutil.which("pg_dump") is not None:
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = DB_PASSWORD
|
||||
subprocess.run(
|
||||
[
|
||||
"pg_dump",
|
||||
"-U",
|
||||
DB_USER,
|
||||
"-h",
|
||||
PG_HOST,
|
||||
"-p",
|
||||
PG_PORT,
|
||||
"-F",
|
||||
"c",
|
||||
"-f",
|
||||
str(filename),
|
||||
DB_NAME,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
logger.info("[Backup] БД создана через host pg_dump: {}", filename)
|
||||
else:
|
||||
raise FileNotFoundError("PostgreSQL недоступен: не найден контейнер и отсутствует host pg_dump")
|
||||
return str(filename), None
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error("[Backup] pg_dump: {}", e.stderr)
|
||||
stderr = e.stderr.decode("utf-8", errors="replace") if isinstance(e.stderr, bytes) else e.stderr
|
||||
logger.error("[Backup] pg_dump: {}", stderr)
|
||||
return None, e
|
||||
except Exception as e:
|
||||
logger.error("[Backup] Непредвиденная ошибка: {}", e)
|
||||
return None, e
|
||||
finally:
|
||||
if "PGPASSWORD" in os.environ:
|
||||
del os.environ["PGPASSWORD"]
|
||||
|
||||
|
||||
def _create_backup_archive() -> tuple[str | None, Exception | None]:
|
||||
|
||||
@@ -7,6 +7,7 @@ _UniqueGiftColors = getattr(aiogram.types, "UniqueGiftColors", None)
|
||||
if _UniqueGiftColors is not None:
|
||||
_cfg = getattr(_UniqueGiftColors, "model_config", None)
|
||||
_base = dict(_cfg) if _cfg is not None else {}
|
||||
_base.pop("protected_namespaces", None)
|
||||
_UniqueGiftColors.model_config = ConfigDict(**_base, protected_namespaces=())
|
||||
|
||||
_OriginalInlineKeyboardButton = aiogram.types.InlineKeyboardButton
|
||||
|
||||
+52
-19
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
from database.models import Key, Payment, Referral, Tariff, User
|
||||
from database.access.resolution import resolve_user_optional
|
||||
|
||||
|
||||
async def export_users_csv(session: AsyncSession) -> BufferedInputFile:
|
||||
@@ -49,7 +50,7 @@ async def export_users_csv(session: AsyncSession) -> BufferedInputFile:
|
||||
|
||||
|
||||
async def export_payments_csv(session: AsyncSession) -> BufferedInputFile:
|
||||
j = join(User, Payment, User.tg_id == Payment.tg_id)
|
||||
j = join(User, Payment, User.id == Payment.user_id)
|
||||
query = (
|
||||
select(
|
||||
User.tg_id,
|
||||
@@ -73,7 +74,9 @@ async def export_payments_csv(session: AsyncSession) -> BufferedInputFile:
|
||||
|
||||
|
||||
async def export_user_payments_csv(tg_id: int, session: AsyncSession) -> BufferedInputFile:
|
||||
j = join(User, Payment, User.tg_id == Payment.tg_id)
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
uid = u.id if u is not None else tg_id
|
||||
j = join(User, Payment, User.id == Payment.user_id)
|
||||
query = (
|
||||
select(
|
||||
User.tg_id,
|
||||
@@ -87,7 +90,7 @@ async def export_user_payments_csv(tg_id: int, session: AsyncSession) -> Buffere
|
||||
)
|
||||
.select_from(j)
|
||||
.where(
|
||||
User.tg_id == tg_id,
|
||||
User.id == uid,
|
||||
Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED),
|
||||
)
|
||||
.order_by(Payment.created_at.asc())
|
||||
@@ -121,17 +124,20 @@ def _export_payments_csv(payments, filename: str) -> BufferedInputFile:
|
||||
|
||||
|
||||
async def export_referrals_csv(referrer_tg_id: int, session: AsyncSession) -> BufferedInputFile | None:
|
||||
j = join(Referral, User, Referral.referred_tg_id == User.tg_id)
|
||||
ref_owner = await resolve_user_optional(session, referrer_tg_id)
|
||||
if ref_owner is None:
|
||||
return None
|
||||
j = join(Referral, User, Referral.referred_user_id == User.id)
|
||||
query = (
|
||||
select(
|
||||
Referral.referred_tg_id,
|
||||
User.tg_id,
|
||||
func.coalesce(User.first_name, ""),
|
||||
func.coalesce(User.last_name, ""),
|
||||
func.coalesce(User.username, ""),
|
||||
)
|
||||
.select_from(j)
|
||||
.where(Referral.referrer_tg_id == referrer_tg_id)
|
||||
.order_by(Referral.referred_tg_id.asc())
|
||||
.where(Referral.referrer_user_id == ref_owner.id)
|
||||
.order_by(Referral.referred_user_id.asc())
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
@@ -144,7 +150,8 @@ async def export_referrals_csv(referrer_tg_id: int, session: AsyncSession) -> Bu
|
||||
writer = csv.writer(output, delimiter=";")
|
||||
writer.writerow(["Приглашённый (tg_id)", "Имя"])
|
||||
|
||||
for invited_id, first_name, last_name, username in rows:
|
||||
for invited_tg, first_name, last_name, username in rows:
|
||||
invited_id = invited_tg if invited_tg is not None else "—"
|
||||
full_name = first_name.strip() or username or str(invited_id)
|
||||
if last_name:
|
||||
full_name = f"{full_name} {last_name}"
|
||||
@@ -163,6 +170,7 @@ async def export_hot_leads_csv(session: AsyncSession) -> BufferedInputFile:
|
||||
stmt = (
|
||||
select(
|
||||
User.tg_id,
|
||||
User.id,
|
||||
User.username,
|
||||
User.first_name,
|
||||
User.last_name,
|
||||
@@ -170,13 +178,13 @@ async def export_hot_leads_csv(session: AsyncSession) -> BufferedInputFile:
|
||||
)
|
||||
.where(
|
||||
exists(
|
||||
select(Payment.tg_id)
|
||||
.where(Payment.tg_id == User.tg_id)
|
||||
select(Payment.user_id)
|
||||
.where(Payment.user_id == User.id)
|
||||
.where(Payment.status == "success")
|
||||
.where(Payment.amount > 0)
|
||||
.where(Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED))
|
||||
),
|
||||
not_(exists(select(Key.tg_id).where(Key.tg_id == User.tg_id).where(Key.expiry_time > now_ts))),
|
||||
not_(exists(select(Key.user_id).where(Key.user_id == User.id).where(Key.expiry_time > now_ts))),
|
||||
)
|
||||
.order_by(User.updated_at.desc())
|
||||
)
|
||||
@@ -187,8 +195,9 @@ async def export_hot_leads_csv(session: AsyncSession) -> BufferedInputFile:
|
||||
buffer = StringIO()
|
||||
writer = csv.writer(buffer)
|
||||
writer.writerow(["tg_id", "username", "first_name", "last_name", "updated_at"])
|
||||
for user in users:
|
||||
writer.writerow(user)
|
||||
for row in users:
|
||||
tid = row.tg_id if row.tg_id is not None else row.id
|
||||
writer.writerow([tid, row.username, row.first_name, row.last_name, row.updated_at])
|
||||
|
||||
buffer.seek(0)
|
||||
return BufferedInputFile(
|
||||
@@ -198,10 +207,11 @@ async def export_hot_leads_csv(session: AsyncSession) -> BufferedInputFile:
|
||||
|
||||
|
||||
async def export_keys_csv(session: AsyncSession) -> BufferedInputFile:
|
||||
j = join(Key, Tariff, Key.tariff_id == Tariff.id, isouter=True)
|
||||
jk = join(Key, User, Key.user_id == User.id)
|
||||
j = join(jk, Tariff, Key.tariff_id == Tariff.id, isouter=True)
|
||||
query = (
|
||||
select(
|
||||
Key.tg_id,
|
||||
User.tg_id,
|
||||
Key.client_id,
|
||||
Key.email,
|
||||
Key.created_at,
|
||||
@@ -244,7 +254,7 @@ async def export_keys_csv(session: AsyncSession) -> BufferedInputFile:
|
||||
tariff = row.tariff_name or "—"
|
||||
|
||||
writer.writerow([
|
||||
row.tg_id,
|
||||
row.tg_id if row.tg_id is not None else row.client_id,
|
||||
row.client_id,
|
||||
row.email,
|
||||
created_at,
|
||||
@@ -261,10 +271,31 @@ async def export_keys_csv(session: AsyncSession) -> BufferedInputFile:
|
||||
|
||||
|
||||
async def export_user_all_payments_csv(tg_id: int, session: AsyncSession) -> BufferedInputFile:
|
||||
owner = await resolve_user_optional(session, tg_id)
|
||||
if owner is None:
|
||||
buffer = StringIO()
|
||||
writer = csv.writer(buffer)
|
||||
writer.writerow([
|
||||
"id",
|
||||
"tg_id",
|
||||
"payment_id",
|
||||
"amount",
|
||||
"currency",
|
||||
"payment_system",
|
||||
"status",
|
||||
"original_amount",
|
||||
"created_at",
|
||||
])
|
||||
buffer.seek(0)
|
||||
return BufferedInputFile(
|
||||
file=buffer.getvalue().encode("utf-8-sig"),
|
||||
filename=f"user_{tg_id}_payments_full.csv",
|
||||
)
|
||||
|
||||
query = (
|
||||
select(
|
||||
Payment.id,
|
||||
Payment.tg_id,
|
||||
User.tg_id,
|
||||
Payment.payment_id,
|
||||
Payment.amount,
|
||||
Payment.currency,
|
||||
@@ -273,7 +304,8 @@ async def export_user_all_payments_csv(tg_id: int, session: AsyncSession) -> Buf
|
||||
Payment.original_amount,
|
||||
Payment.created_at,
|
||||
)
|
||||
.where(Payment.tg_id == tg_id)
|
||||
.join(User, Payment.user_id == User.id)
|
||||
.where(Payment.user_id == owner.id)
|
||||
.order_by(Payment.created_at.asc())
|
||||
)
|
||||
|
||||
@@ -305,9 +337,10 @@ async def export_user_all_payments_csv(tg_id: int, session: AsyncSession) -> Buf
|
||||
original_amount,
|
||||
created_at,
|
||||
) in rows:
|
||||
display_id = user_tg_id if user_tg_id is not None else owner.id
|
||||
writer.writerow([
|
||||
internal_id,
|
||||
user_tg_id,
|
||||
display_id,
|
||||
external_payment_id or "",
|
||||
amount,
|
||||
currency,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from logger import logger
|
||||
|
||||
_BUILTIN_DOMAINS: set[str] = {
|
||||
"mailinator.com", "guerrillamail.com", "guerrillamail.de", "guerrillamail.net",
|
||||
"guerrillamail.org", "guerrillamailblock.com", "grr.la", "sharklasers.com",
|
||||
"guerrillamail.info", "tempmail.com", "temp-mail.org", "temp-mail.io",
|
||||
"throwaway.email", "fakeinbox.com", "tempail.com", "tempr.email",
|
||||
"dispostable.com", "yopmail.com", "yopmail.fr", "yopmail.net",
|
||||
"cool.fr.nf", "jetable.fr.nf", "nospam.ze.tc", "nomail.xl.cx",
|
||||
"mega.zik.dj", "speed.1s.fr", "courriel.fr.nf", "moncourrier.fr.nf",
|
||||
"monemail.fr.nf", "monmail.fr.nf", "hide.biz.st", "mytrashmail.com",
|
||||
"mailnesia.com", "maildrop.cc", "discard.email", "discardmail.com",
|
||||
"discardmail.de", "trashmail.com", "trashmail.me", "trashmail.net",
|
||||
"trashmail.org", "trashmail.at", "trashmail.io", "trashmail.ws",
|
||||
"trash-mail.com", "trash-mail.at", "trashemail.de",
|
||||
"mailcatch.com", "mailscrap.com", "mailforspam.com",
|
||||
"spamgourmet.com", "spamgourmet.net", "spamgourmet.org",
|
||||
"mailexpire.com", "tempinbox.com", "tempomail.fr",
|
||||
"10minutemail.com", "10minutemail.co.za", "10minutemail.net",
|
||||
"minutemail.io", "emailondeck.com", "getnada.com",
|
||||
"mohmal.com", "burnermail.io", "inboxbear.com",
|
||||
"mailsac.com", "harakirimail.com", "33mail.com",
|
||||
"maildax.com", "crazymailing.com", "mailtemp.info",
|
||||
"emkei.cz", "example.com", "test.com", "mailinator.net",
|
||||
"binkmail.com", "bobmail.info", "chammy.info",
|
||||
"devnullmail.com", "letthemeatspam.com", "mailnull.com",
|
||||
"nomail.pw", "nowmymail.com", "rmqkr.net",
|
||||
"sharklasers.com", "spamfree24.org", "spamhereplease.com",
|
||||
"tempmailaddress.com", "wegwerfmail.de", "wegwerfmail.net",
|
||||
"wh4f.org", "mailzilla.com", "anonbox.net",
|
||||
"bspamfree.org", "kurzepost.de", "objectmail.com",
|
||||
"proxymail.eu", "rcpt.at", "reallymymail.com",
|
||||
"recode.me", "regbypass.com", "s0ny.net",
|
||||
"safetymail.info", "safetypost.de", "shieldedmail.com",
|
||||
"sogetthis.com", "soodonims.com", "spambox.us",
|
||||
"spamcero.com", "spamday.com", "spamex.com",
|
||||
"spamfighter.cf", "spamfighter.ga", "spamfighter.gq",
|
||||
"spamfighter.ml", "spamfighter.tk",
|
||||
"spamhole.com", "spaml.com", "spaml.de",
|
||||
"uggsrock.com", "uroid.com", "veryreallymymail.com",
|
||||
"viditag.com", "vomoto.com", "vpn.st",
|
||||
"vsimcard.com", "vubby.com", "vztc.com",
|
||||
"wasteland.rfc822.org", "webemail.me",
|
||||
"zetmail.com", "zippymail.info",
|
||||
"mailnator.com", "mailtothis.com",
|
||||
"mx0.wwwnew.eu", "mypartyclip.de",
|
||||
"myzx.com", "nb.gy", "nobulk.com",
|
||||
"noclickemail.com", "nogmailspam.info",
|
||||
"nomail.xl.cx", "nomorespamemails.com",
|
||||
"nospam.ze.tc", "nothingtoseehere.ca",
|
||||
}
|
||||
|
||||
_loaded_extra = False
|
||||
_extra_domains: set[str] = set()
|
||||
|
||||
|
||||
def _load_extra() -> None:
|
||||
global _loaded_extra, _extra_domains
|
||||
if _loaded_extra:
|
||||
return
|
||||
_loaded_extra = True
|
||||
try:
|
||||
from config import DISPOSABLE_EMAIL_BLOCKLIST_EXTRA
|
||||
if isinstance(DISPOSABLE_EMAIL_BLOCKLIST_EXTRA, (list, set, tuple)):
|
||||
_extra_domains = {d.strip().lower() for d in DISPOSABLE_EMAIL_BLOCKLIST_EXTRA if isinstance(d, str)}
|
||||
if _extra_domains:
|
||||
logger.info("[DisposableEmail] загружено {} дополнительных доменов", len(_extra_domains))
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
|
||||
def is_disposable_email(email: str) -> bool:
|
||||
"""Проверяет, является ли email одноразовым."""
|
||||
_load_extra()
|
||||
domain = email.strip().lower().rsplit("@", 1)[-1] if "@" in email else ""
|
||||
if not domain:
|
||||
return False
|
||||
return domain in _BUILTIN_DOMAINS or domain in _extra_domains
|
||||
@@ -0,0 +1,100 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
|
||||
from config import API_TOKEN, WEBHOOK_SECRET_TOKEN
|
||||
|
||||
|
||||
def _secret_bytes() -> bytes:
|
||||
seed = (WEBHOOK_SECRET_TOKEN or API_TOKEN or "solobot-referral").strip()
|
||||
return seed.encode("utf-8")
|
||||
|
||||
|
||||
def _urlsafe_b64decode_nopad(value: str) -> bytes:
|
||||
normalized = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
return base64.urlsafe_b64decode(normalized.encode("ascii"))
|
||||
|
||||
|
||||
def encode_referral_code(user_id: int) -> str:
|
||||
if int(user_id) <= 0:
|
||||
raise ValueError("user_id must be positive")
|
||||
raw = int(user_id).to_bytes(8, byteorder="big", signed=False)
|
||||
secret = _secret_bytes()
|
||||
mask = hmac.new(secret, b"ref-mask-v1", hashlib.sha256).digest()[:8]
|
||||
obfuscated = bytes(a ^ b for a, b in zip(raw, mask))
|
||||
signature = hmac.new(secret, b"ref-sign-v1:" + obfuscated, hashlib.sha256).digest()[:6]
|
||||
payload = base64.urlsafe_b64encode(obfuscated + signature).decode("ascii").rstrip("=")
|
||||
return f"r1_{payload}"
|
||||
|
||||
|
||||
def encode_partner_code(user_id: int) -> str:
|
||||
if int(user_id) <= 0:
|
||||
raise ValueError("user_id must be positive")
|
||||
raw = int(user_id).to_bytes(8, byteorder="big", signed=False)
|
||||
secret = _secret_bytes()
|
||||
mask = hmac.new(secret, b"partner-mask-v1", hashlib.sha256).digest()[:8]
|
||||
obfuscated = bytes(a ^ b for a, b in zip(raw, mask))
|
||||
signature = hmac.new(secret, b"partner-sign-v1:" + obfuscated, hashlib.sha256).digest()[:6]
|
||||
payload = base64.urlsafe_b64encode(obfuscated + signature).decode("ascii").rstrip("=")
|
||||
return f"p1_{payload}"
|
||||
|
||||
|
||||
def decode_referral_code(value: str | None) -> int | None:
|
||||
token = str(value or "").strip()
|
||||
if not token:
|
||||
return None
|
||||
if token.startswith("r1_"):
|
||||
encoded = token[3:]
|
||||
try:
|
||||
data = _urlsafe_b64decode_nopad(encoded)
|
||||
except Exception:
|
||||
return None
|
||||
if len(data) != 14:
|
||||
return None
|
||||
obfuscated, signature = data[:8], data[8:]
|
||||
secret = _secret_bytes()
|
||||
expected = hmac.new(secret, b"ref-sign-v1:" + obfuscated, hashlib.sha256).digest()[:6]
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
return None
|
||||
mask = hmac.new(secret, b"ref-mask-v1", hashlib.sha256).digest()[:8]
|
||||
raw = bytes(a ^ b for a, b in zip(obfuscated, mask))
|
||||
parsed = int.from_bytes(raw, byteorder="big", signed=False)
|
||||
return parsed if parsed > 0 else None
|
||||
if token.startswith("p1_"):
|
||||
return None
|
||||
match = re.fullmatch(r"\d+", token)
|
||||
if not match:
|
||||
return None
|
||||
parsed = int(match.group(0))
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def decode_partner_code(value: str | None) -> int | None:
|
||||
token = str(value or "").strip()
|
||||
if not token:
|
||||
return None
|
||||
if token.startswith("p1_"):
|
||||
encoded = token[3:]
|
||||
try:
|
||||
data = _urlsafe_b64decode_nopad(encoded)
|
||||
except Exception:
|
||||
return None
|
||||
if len(data) != 14:
|
||||
return None
|
||||
obfuscated, signature = data[:8], data[8:]
|
||||
secret = _secret_bytes()
|
||||
expected = hmac.new(secret, b"partner-sign-v1:" + obfuscated, hashlib.sha256).digest()[:6]
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
return None
|
||||
mask = hmac.new(secret, b"partner-mask-v1", hashlib.sha256).digest()[:8]
|
||||
raw = bytes(a ^ b for a, b in zip(obfuscated, mask))
|
||||
parsed = int.from_bytes(raw, byteorder="big", signed=False)
|
||||
return parsed if parsed > 0 else None
|
||||
if token.startswith("r1_"):
|
||||
return decode_referral_code(token)
|
||||
match = re.fullmatch(r"\d+", token)
|
||||
if not match:
|
||||
return None
|
||||
parsed = int(match.group(0))
|
||||
return parsed if parsed > 0 else None
|
||||
@@ -33,3 +33,62 @@ def verify_telegram_login(
|
||||
computed = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
return hmac.compare_digest(computed, received_hash)
|
||||
|
||||
|
||||
def verify_webapp_init_data(
|
||||
init_data: str,
|
||||
bot_token: str,
|
||||
*,
|
||||
max_age_seconds: int = 86400,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Валидирует Telegram WebApp initData (HMAC-SHA256).
|
||||
Возвращает dict с user_id или None если невалидно.
|
||||
https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
"""
|
||||
import json
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
if not init_data or not bot_token:
|
||||
return None
|
||||
|
||||
parsed = parse_qs(init_data, keep_blank_values=True)
|
||||
received_hash = parsed.get("hash", [""])[0]
|
||||
if not received_hash:
|
||||
return None
|
||||
|
||||
auth_date_str = parsed.get("auth_date", [""])[0]
|
||||
try:
|
||||
auth_date = int(auth_date_str)
|
||||
if auth_date < time.time() - max_age_seconds:
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
check_pairs = []
|
||||
for key in sorted(parsed.keys()):
|
||||
if key == "hash":
|
||||
continue
|
||||
check_pairs.append(f"{key}={parsed[key][0]}")
|
||||
data_check_string = "\n".join(check_pairs)
|
||||
|
||||
secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
|
||||
computed = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(computed, received_hash):
|
||||
return None
|
||||
|
||||
user_raw = parsed.get("user", [""])[0]
|
||||
user_id = None
|
||||
if user_raw:
|
||||
try:
|
||||
user_data = json.loads(user_raw)
|
||||
user_id = user_data.get("id")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"auth_date": auth_date,
|
||||
"user_raw": user_raw,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import httpx
|
||||
|
||||
from config import TURNSTILE_SECRET_KEY
|
||||
from logger import logger
|
||||
|
||||
_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
|
||||
|
||||
def turnstile_enabled() -> bool:
|
||||
return bool(TURNSTILE_SECRET_KEY)
|
||||
|
||||
|
||||
async def verify_turnstile_token(token: str | None, remote_ip: str | None = None) -> bool:
|
||||
if not TURNSTILE_SECRET_KEY:
|
||||
return True
|
||||
|
||||
if not token or token == "__turnstile_disabled__":
|
||||
logger.warning("[Turnstile] токен не предоставлен")
|
||||
return False
|
||||
|
||||
try:
|
||||
payload: dict[str, str] = {
|
||||
"secret": TURNSTILE_SECRET_KEY,
|
||||
"response": token,
|
||||
}
|
||||
if remote_ip:
|
||||
payload["remoteip"] = remote_ip
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(_VERIFY_URL, data=payload)
|
||||
result = resp.json()
|
||||
|
||||
success = result.get("success", False)
|
||||
if not success:
|
||||
codes = result.get("error-codes", [])
|
||||
logger.warning("[Turnstile] верификация не пройдена: {}", codes)
|
||||
return bool(success)
|
||||
except Exception as exc:
|
||||
logger.error("[Turnstile] ошибка проверки: {}", exc)
|
||||
return False
|
||||
+1
-1
@@ -92,4 +92,4 @@ def get_git_commit_number() -> str:
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
return f"a02031919 {get_git_commit_number()}"
|
||||
return f"v.6-b1204121200 {get_git_commit_number()}"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import hmac
|
||||
|
||||
from config import LOGIN_CODE_TTL_SEC
|
||||
from core.redis_cache import (
|
||||
cache_delete,
|
||||
cache_get,
|
||||
cache_incr,
|
||||
cache_key,
|
||||
cache_set,
|
||||
cache_setnx,
|
||||
redis_connection_ok,
|
||||
)
|
||||
|
||||
|
||||
_RESEND_COOLDOWN_SEC = 60.0
|
||||
_IP_WINDOW_SEC = 3600.0
|
||||
_IP_MAX_SENDS = 40
|
||||
_EMAIL_WINDOW_SEC = 3600.0
|
||||
_EMAIL_MAX_SENDS = 5
|
||||
_EMAIL_VERIFY_WINDOW_SEC = 600.0
|
||||
_EMAIL_MAX_VERIFY_ATTEMPTS = 10
|
||||
|
||||
|
||||
def normalize_email(value: str) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def _code_key(email_norm: str) -> str:
|
||||
return cache_key("web_email_link_code", email_norm)
|
||||
|
||||
|
||||
def _cooldown_key(email_norm: str) -> str:
|
||||
return cache_key("web_email_link_cooldown", email_norm)
|
||||
|
||||
|
||||
def _ip_key(ip: str) -> str:
|
||||
return cache_key("web_email_link_send_ip", ip)
|
||||
|
||||
|
||||
def _email_send_key(email_norm: str) -> str:
|
||||
return cache_key("web_email_link_sends", email_norm)
|
||||
|
||||
|
||||
def _email_verify_key(email_norm: str) -> str:
|
||||
return cache_key("web_email_link_verify", email_norm)
|
||||
|
||||
|
||||
async def redis_ready() -> bool:
|
||||
return await redis_connection_ok()
|
||||
|
||||
|
||||
async def try_consume_ip_budget(ip: str) -> bool:
|
||||
if not ip:
|
||||
return True
|
||||
n = await cache_incr(_ip_key(ip), _IP_WINDOW_SEC)
|
||||
return n <= _IP_MAX_SENDS
|
||||
|
||||
|
||||
async def try_consume_email_send_budget(email_norm: str) -> bool:
|
||||
if not email_norm:
|
||||
return True
|
||||
n = await cache_incr(_email_send_key(email_norm), _EMAIL_WINDOW_SEC)
|
||||
return n <= _EMAIL_MAX_SENDS
|
||||
|
||||
|
||||
async def try_consume_email_verify_budget(email_norm: str) -> bool:
|
||||
if not email_norm:
|
||||
return True
|
||||
n = await cache_incr(_email_verify_key(email_norm), _EMAIL_VERIFY_WINDOW_SEC)
|
||||
return n <= _EMAIL_MAX_VERIFY_ATTEMPTS
|
||||
|
||||
|
||||
async def try_acquire_cooldown(email_norm: str) -> bool:
|
||||
return await cache_setnx(_cooldown_key(email_norm), 1, _RESEND_COOLDOWN_SEC)
|
||||
|
||||
|
||||
async def release_cooldown(email_norm: str) -> None:
|
||||
await cache_delete(_cooldown_key(email_norm))
|
||||
|
||||
|
||||
async def store_code(email_norm: str, code: str) -> bool:
|
||||
return await cache_set(_code_key(email_norm), code, float(LOGIN_CODE_TTL_SEC))
|
||||
|
||||
|
||||
async def delete_code(email_norm: str) -> None:
|
||||
await cache_delete(_code_key(email_norm))
|
||||
|
||||
|
||||
async def verify_and_consume_code(email_norm: str, code: str) -> bool:
|
||||
stored = await cache_get(_code_key(email_norm))
|
||||
if not isinstance(stored, str):
|
||||
return False
|
||||
if not hmac.compare_digest(stored.strip(), (code or "").strip()):
|
||||
return False
|
||||
await cache_delete(_code_key(email_norm))
|
||||
return True
|
||||
@@ -0,0 +1,89 @@
|
||||
import hmac
|
||||
|
||||
from config import LOGIN_CODE_TTL_SEC
|
||||
from core.redis_cache import (
|
||||
cache_delete,
|
||||
cache_get,
|
||||
cache_incr,
|
||||
cache_key,
|
||||
cache_set,
|
||||
cache_setnx,
|
||||
redis_connection_ok,
|
||||
)
|
||||
|
||||
|
||||
_RESEND_COOLDOWN_SEC = 60.0
|
||||
_IP_WINDOW_SEC = 3600.0
|
||||
_IP_MAX_SENDS = 20
|
||||
_EMAIL_WINDOW_SEC = 3600.0
|
||||
_EMAIL_MAX_SENDS = 5
|
||||
_EMAIL_VERIFY_WINDOW_SEC = 600.0
|
||||
_EMAIL_MAX_VERIFY_ATTEMPTS = 10
|
||||
|
||||
|
||||
def _code_key(email_norm: str) -> str:
|
||||
return cache_key("web_email_verify_code", email_norm)
|
||||
|
||||
|
||||
def _cooldown_key(email_norm: str) -> str:
|
||||
return cache_key("web_email_verify_cooldown", email_norm)
|
||||
|
||||
|
||||
def _ip_key(ip: str) -> str:
|
||||
return cache_key("web_email_verify_send_ip", ip)
|
||||
|
||||
|
||||
def _email_send_key(email_norm: str) -> str:
|
||||
return cache_key("web_email_verify_sends", email_norm)
|
||||
|
||||
|
||||
def _email_verify_key(email_norm: str) -> str:
|
||||
return cache_key("web_email_verify_attempts", email_norm)
|
||||
|
||||
|
||||
async def redis_ready() -> bool:
|
||||
return await redis_connection_ok()
|
||||
|
||||
|
||||
async def try_consume_ip_send_budget(ip: str) -> bool:
|
||||
if not ip:
|
||||
return True
|
||||
n = await cache_incr(_ip_key(ip), _IP_WINDOW_SEC)
|
||||
return n <= _IP_MAX_SENDS
|
||||
|
||||
|
||||
async def try_consume_email_send_budget(email_norm: str) -> bool:
|
||||
if not email_norm:
|
||||
return True
|
||||
n = await cache_incr(_email_send_key(email_norm), _EMAIL_WINDOW_SEC)
|
||||
return n <= _EMAIL_MAX_SENDS
|
||||
|
||||
|
||||
async def try_consume_verify_budget(email_norm: str) -> bool:
|
||||
if not email_norm:
|
||||
return True
|
||||
n = await cache_incr(_email_verify_key(email_norm), _EMAIL_VERIFY_WINDOW_SEC)
|
||||
return n <= _EMAIL_MAX_VERIFY_ATTEMPTS
|
||||
|
||||
|
||||
async def try_acquire_resend_cooldown(email_norm: str) -> bool:
|
||||
return await cache_setnx(_cooldown_key(email_norm), 1, _RESEND_COOLDOWN_SEC)
|
||||
|
||||
|
||||
async def store_code(email_norm: str, code: str) -> bool:
|
||||
return await cache_set(_code_key(email_norm), code, float(LOGIN_CODE_TTL_SEC))
|
||||
|
||||
|
||||
async def delete_code(email_norm: str) -> None:
|
||||
await cache_delete(_code_key(email_norm))
|
||||
|
||||
|
||||
async def verify_and_consume_code(email_norm: str, code: str) -> bool:
|
||||
key = _code_key(email_norm)
|
||||
stored = await cache_get(key)
|
||||
if not isinstance(stored, str):
|
||||
return False
|
||||
if not hmac.compare_digest(stored.strip(), (code or "").strip()):
|
||||
return False
|
||||
await cache_delete(key)
|
||||
return True
|
||||
@@ -0,0 +1,97 @@
|
||||
import hmac
|
||||
|
||||
from config import LOGIN_CODE_TTL_SEC
|
||||
from core.redis_cache import (
|
||||
cache_delete,
|
||||
cache_get,
|
||||
cache_incr,
|
||||
cache_key,
|
||||
cache_set,
|
||||
cache_setnx,
|
||||
redis_connection_ok,
|
||||
)
|
||||
|
||||
|
||||
_RESEND_COOLDOWN_SEC = 60.0
|
||||
_IP_WINDOW_SEC = 3600.0
|
||||
_IP_MAX_SENDS = 40
|
||||
_EMAIL_WINDOW_SEC = 3600.0
|
||||
_EMAIL_MAX_SENDS = 5
|
||||
_EMAIL_VERIFY_WINDOW_SEC = 600.0
|
||||
_EMAIL_MAX_VERIFY_ATTEMPTS = 10
|
||||
|
||||
|
||||
def normalize_login_email(email: str) -> str:
|
||||
return (email or "").strip().lower()
|
||||
|
||||
|
||||
def _code_key(email_norm: str) -> str:
|
||||
return cache_key("web_login_code", email_norm)
|
||||
|
||||
|
||||
def _cooldown_key(email_norm: str) -> str:
|
||||
return cache_key("web_login_cooldown", email_norm)
|
||||
|
||||
|
||||
def _ip_key(ip: str) -> str:
|
||||
return cache_key("web_login_send_ip", ip)
|
||||
|
||||
|
||||
def _email_send_key(email_norm: str) -> str:
|
||||
return cache_key("web_login_email_sends", email_norm)
|
||||
|
||||
|
||||
def _email_verify_key(email_norm: str) -> str:
|
||||
return cache_key("web_login_email_verify", email_norm)
|
||||
|
||||
|
||||
async def redis_ready_for_login_codes() -> bool:
|
||||
return await redis_connection_ok()
|
||||
|
||||
|
||||
async def try_consume_ip_send_budget(ip: str) -> bool:
|
||||
if not ip:
|
||||
return True
|
||||
n = await cache_incr(_ip_key(ip), _IP_WINDOW_SEC)
|
||||
return n <= _IP_MAX_SENDS
|
||||
|
||||
|
||||
async def try_consume_email_send_budget(email_norm: str) -> bool:
|
||||
if not email_norm:
|
||||
return True
|
||||
n = await cache_incr(_email_send_key(email_norm), _EMAIL_WINDOW_SEC)
|
||||
return n <= _EMAIL_MAX_SENDS
|
||||
|
||||
|
||||
async def try_consume_email_verify_budget(email_norm: str) -> bool:
|
||||
if not email_norm:
|
||||
return True
|
||||
n = await cache_incr(_email_verify_key(email_norm), _EMAIL_VERIFY_WINDOW_SEC)
|
||||
return n <= _EMAIL_MAX_VERIFY_ATTEMPTS
|
||||
|
||||
|
||||
async def try_acquire_resend_cooldown(email_norm: str) -> bool:
|
||||
return await cache_setnx(_cooldown_key(email_norm), 1, _RESEND_COOLDOWN_SEC)
|
||||
|
||||
|
||||
async def release_resend_cooldown(email_norm: str) -> None:
|
||||
await cache_delete(_cooldown_key(email_norm))
|
||||
|
||||
|
||||
async def store_code(email_norm: str, code: str) -> bool:
|
||||
return await cache_set(_code_key(email_norm), code, float(LOGIN_CODE_TTL_SEC))
|
||||
|
||||
|
||||
async def delete_code(email_norm: str) -> None:
|
||||
await cache_delete(_code_key(email_norm))
|
||||
|
||||
|
||||
async def verify_and_consume_code(email_norm: str, code: str) -> bool:
|
||||
key = _code_key(email_norm)
|
||||
stored = await cache_get(key)
|
||||
if not isinstance(stored, str):
|
||||
return False
|
||||
if not hmac.compare_digest(stored.strip(), (code or "").strip()):
|
||||
return False
|
||||
await cache_delete(key)
|
||||
return True
|
||||
@@ -0,0 +1,92 @@
|
||||
import hmac
|
||||
|
||||
from config import LOGIN_CODE_TTL_SEC
|
||||
from core.redis_cache import (
|
||||
cache_delete,
|
||||
cache_get,
|
||||
cache_incr,
|
||||
cache_key,
|
||||
cache_set,
|
||||
cache_setnx,
|
||||
redis_connection_ok,
|
||||
)
|
||||
|
||||
_RESEND_COOLDOWN_SEC = 60.0
|
||||
_IP_WINDOW_SEC = 3600.0
|
||||
_IP_MAX_SENDS = 40
|
||||
_EMAIL_WINDOW_SEC = 3600.0
|
||||
_EMAIL_MAX_SENDS = 5
|
||||
_EMAIL_VERIFY_WINDOW_SEC = 600.0
|
||||
_EMAIL_MAX_VERIFY_ATTEMPTS = 10
|
||||
|
||||
|
||||
def _code_key(email_norm: str) -> str:
|
||||
return cache_key("web_pwd_reset_code", email_norm)
|
||||
|
||||
|
||||
def _cooldown_key(email_norm: str) -> str:
|
||||
return cache_key("web_pwd_reset_cooldown", email_norm)
|
||||
|
||||
|
||||
def _ip_key(ip: str) -> str:
|
||||
return cache_key("web_pwd_reset_send_ip", ip)
|
||||
|
||||
|
||||
def _email_send_key(email_norm: str) -> str:
|
||||
return cache_key("web_pwd_reset_email_sends", email_norm)
|
||||
|
||||
|
||||
def _email_verify_key(email_norm: str) -> str:
|
||||
return cache_key("web_pwd_reset_email_verify", email_norm)
|
||||
|
||||
|
||||
async def redis_ready() -> bool:
|
||||
return await redis_connection_ok()
|
||||
|
||||
|
||||
async def try_consume_ip_budget(ip: str) -> bool:
|
||||
if not ip:
|
||||
return True
|
||||
n = await cache_incr(_ip_key(ip), _IP_WINDOW_SEC)
|
||||
return n <= _IP_MAX_SENDS
|
||||
|
||||
|
||||
async def try_consume_email_send_budget(email_norm: str) -> bool:
|
||||
if not email_norm:
|
||||
return True
|
||||
n = await cache_incr(_email_send_key(email_norm), _EMAIL_WINDOW_SEC)
|
||||
return n <= _EMAIL_MAX_SENDS
|
||||
|
||||
|
||||
async def try_consume_email_verify_budget(email_norm: str) -> bool:
|
||||
if not email_norm:
|
||||
return True
|
||||
n = await cache_incr(_email_verify_key(email_norm), _EMAIL_VERIFY_WINDOW_SEC)
|
||||
return n <= _EMAIL_MAX_VERIFY_ATTEMPTS
|
||||
|
||||
|
||||
async def try_acquire_cooldown(email_norm: str) -> bool:
|
||||
return await cache_setnx(_cooldown_key(email_norm), 1, _RESEND_COOLDOWN_SEC)
|
||||
|
||||
|
||||
async def release_cooldown(email_norm: str) -> None:
|
||||
await cache_delete(_cooldown_key(email_norm))
|
||||
|
||||
|
||||
async def store_code(email_norm: str, code: str) -> bool:
|
||||
return await cache_set(_code_key(email_norm), code, float(LOGIN_CODE_TTL_SEC))
|
||||
|
||||
|
||||
async def delete_code(email_norm: str) -> None:
|
||||
await cache_delete(_code_key(email_norm))
|
||||
|
||||
|
||||
async def verify_and_consume_code(email_norm: str, code: str) -> bool:
|
||||
key = _code_key(email_norm)
|
||||
stored = await cache_get(key)
|
||||
if not isinstance(stored, str):
|
||||
return False
|
||||
if not hmac.compare_digest(stored.strip(), (code or "").strip()):
|
||||
return False
|
||||
await cache_delete(key)
|
||||
return True
|
||||
Reference in New Issue
Block a user