Merge pull request #2808 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-03-23 16:56:36 +03:00
committed by GitHub
110 changed files with 4283 additions and 767 deletions
+2 -1
View File
@@ -33,7 +33,8 @@ WORKDIR /app
COPY --chown=app:app . .
RUN mkdir -p logs data && chown app:app logs data
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails && \
chown -R app:app logs data uploads
USER app
+2 -2
View File
@@ -133,11 +133,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
chat_type_filter = ChatTypeFilterMiddleware()
dp.message.middleware(chat_type_filter)
dp.callback_query.middleware(chat_type_filter)
dp.message.middleware(LoggingMiddleware())
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(GlobalErrorMiddleware())
dp.callback_query.middleware(GlobalErrorMiddleware())
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
dp.message.middleware(LoggingMiddleware())
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(MaintenanceMiddleware())
dp.callback_query.middleware(MaintenanceMiddleware())
blacklist_middleware = BlacklistMiddleware()
+12
View File
@@ -13,6 +13,10 @@ from .admin_channels import router as admin_channels_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_landings import router as admin_landings_router
from .admin_menu_layout import router as admin_menu_layout_router
from .admin_news import router as admin_news_router
from .admin_news_categories import router as admin_news_categories_router
from .admin_news_media import router as admin_news_media_router
from .admin_news_tags import router as admin_news_tags_router
from .admin_partners import router as admin_partners_router
from .admin_payment_methods import router as admin_payment_methods_router
from .admin_payments import router as admin_payments_router
@@ -42,6 +46,7 @@ from .gift import router as gift_router
from .info import router as info_router
from .landing import router as landing_router
from .media import router as media_router
from .news import router as news_router
from .notifications import router as notifications_router
from .oauth import router as oauth_router
from .partner_application import router as partner_application_router
@@ -85,6 +90,7 @@ router.include_router(info_router)
router.include_router(branding_router)
router.include_router(landing_router)
router.include_router(media_router)
router.include_router(news_router)
# Wheel routes
router.include_router(wheel_router)
@@ -126,6 +132,12 @@ router.include_router(admin_apps_router)
router.include_router(admin_roles_router)
router.include_router(admin_policies_router)
router.include_router(admin_audit_log_router)
# Categories/tags/media routers MUST be before the main news router
# to avoid /admin/news/{article_id} catching /admin/news/categories etc.
router.include_router(admin_news_categories_router)
router.include_router(admin_news_tags_router)
router.include_router(admin_news_media_router)
router.include_router(admin_news_router)
# WebSocket route
router.include_router(websocket_router)
+343
View File
@@ -0,0 +1,343 @@
"""Admin routes for managing news articles in cabinet."""
from datetime import UTC, datetime
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.news import (
create_news_article,
delete_news_article,
get_all_news,
get_all_news_count,
get_news_article_by_id,
unfeature_all_news,
update_news_article,
)
from app.database.crud.news_categories import get_category_by_id
from app.database.crud.news_tags import get_tag_by_id
from app.database.models import NewsArticle, User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.news import (
NewsArticleListItem,
NewsArticleResponse,
NewsCreateRequest,
NewsListResponse,
NewsToggleResponse,
NewsUpdateRequest,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/news', tags=['Cabinet Admin News'])
def _article_to_detail(article: NewsArticle) -> dict[str, Any]:
"""Convert NewsArticle ORM instance to full detail dict.
Expects the ``author`` relationship to be eagerly loaded.
"""
author_name: str | None = None
if article.author:
author_name = article.author.first_name or article.author.username or f'#{article.author.id}'
return {
'id': article.id,
'title': article.title,
'slug': article.slug,
'content': article.content,
'excerpt': article.excerpt,
'category': article.category,
'category_color': article.category_color,
'tag': article.tag,
'category_id': article.category_id,
'tag_id': article.tag_id,
'featured_image_url': article.featured_image_url,
'is_published': article.is_published,
'is_featured': article.is_featured,
'published_at': article.published_at,
'read_time_minutes': article.read_time_minutes,
'views_count': article.views_count,
'author_name': author_name,
'created_at': article.created_at,
'updated_at': article.updated_at,
}
@router.get('', response_model=NewsListResponse)
async def list_all_news(
admin: User = Depends(require_permission('news:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> NewsListResponse:
"""Get all news articles (admin view, includes unpublished)."""
try:
articles = await get_all_news(db, limit=limit, offset=offset)
total = await get_all_news_count(db)
items = [NewsArticleListItem.model_validate(a) for a in articles]
return NewsListResponse(items=items, total=total)
except HTTPException:
raise
except Exception:
logger.exception('Failed to list all news')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load news articles',
)
@router.get('/{article_id}', response_model=NewsArticleResponse)
async def get_article_detail(
article_id: int,
admin: User = Depends(require_permission('news:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsArticleResponse:
"""Get a single news article by ID (admin view)."""
article = await get_news_article_by_id(db, article_id)
if not article:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Article not found',
)
return NewsArticleResponse(**_article_to_detail(article))
@router.post('', response_model=NewsArticleResponse, status_code=status.HTTP_201_CREATED)
async def create_article(
request: NewsCreateRequest,
admin: User = Depends(require_permission('news:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsArticleResponse:
"""Create a new news article."""
try:
# Resolve category from FK -- sync legacy string fields from the managed entity
category_name = request.category
category_color = request.category_color
if request.category_id is not None:
cat = await get_category_by_id(db, request.category_id)
if not cat:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f'Category with id={request.category_id} not found',
)
category_name = cat.name
category_color = cat.color
# Resolve tag from FK -- sync legacy string field from the managed entity
tag_name = request.tag
if request.tag_id is not None:
tag_obj = await get_tag_by_id(db, request.tag_id)
if not tag_obj:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f'Tag with id={request.tag_id} not found',
)
tag_name = tag_obj.name
if request.is_featured:
await unfeature_all_news(db)
article = await create_news_article(
db,
title=request.title,
slug=request.slug,
content=request.content,
excerpt=request.excerpt,
category=category_name,
category_color=category_color,
tag=tag_name,
category_id=request.category_id,
tag_id=request.tag_id,
featured_image_url=request.featured_image_url,
is_published=request.is_published,
is_featured=request.is_featured,
read_time_minutes=request.read_time_minutes,
created_by=admin.id,
)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='An article with this slug already exists',
)
except Exception:
logger.exception('Failed to create news article')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create article',
)
# Reload with author relationship
article = await get_news_article_by_id(db, article.id)
if not article:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to reload article after creation',
)
return NewsArticleResponse(**_article_to_detail(article))
@router.put('/{article_id}', response_model=NewsArticleResponse)
async def update_article(
article_id: int,
request: NewsUpdateRequest,
admin: User = Depends(require_permission('news:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsArticleResponse:
"""Update an existing news article."""
article = await get_news_article_by_id(db, article_id)
if not article:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Article not found',
)
try:
update_data = request.model_dump(exclude_unset=True)
# Resolve category from FK -- sync legacy string fields from the managed entity
if 'category_id' in update_data and update_data['category_id'] is not None:
cat = await get_category_by_id(db, update_data['category_id'])
if not cat:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f'Category with id={update_data["category_id"]} not found',
)
update_data['category'] = cat.name
update_data['category_color'] = cat.color
# Resolve tag from FK -- sync legacy string field from the managed entity
if 'tag_id' in update_data and update_data['tag_id'] is not None:
tag_obj = await get_tag_by_id(db, update_data['tag_id'])
if not tag_obj:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f'Tag with id={update_data["tag_id"]} not found',
)
update_data['tag'] = tag_obj.name
if update_data.get('is_featured'):
await unfeature_all_news(db)
article = await update_news_article(db, article, **update_data)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='An article with this slug already exists',
)
except Exception:
logger.exception('Failed to update news article', article_id=article_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update article',
)
# Reload with author relationship (update used bulk UPDATE, author not populated)
article = await get_news_article_by_id(db, article.id)
if not article:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to reload article after update',
)
return NewsArticleResponse(**_article_to_detail(article))
@router.delete('/{article_id}', status_code=status.HTTP_204_NO_CONTENT)
async def remove_article(
article_id: int,
admin: User = Depends(require_permission('news:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete a news article."""
article = await get_news_article_by_id(db, article_id)
if not article:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Article not found',
)
try:
await delete_news_article(db, article)
except Exception:
logger.exception('Failed to delete news article', article_id=article_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to delete article',
)
@router.post('/{article_id}/publish', response_model=NewsToggleResponse)
async def toggle_publish(
article_id: int,
admin: User = Depends(require_permission('news:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsToggleResponse:
"""Toggle the published status of a news article."""
article = await get_news_article_by_id(db, article_id)
if not article:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Article not found',
)
new_published = not article.is_published
update_kwargs: dict[str, Any] = {'is_published': new_published}
# Auto-set published_at on first publish
if new_published and article.published_at is None:
update_kwargs['published_at'] = datetime.now(UTC)
try:
article = await update_news_article(db, article, **update_kwargs)
return NewsToggleResponse(
id=article.id,
is_published=article.is_published,
is_featured=article.is_featured,
published_at=article.published_at,
)
except Exception:
logger.exception('Failed to toggle publish', article_id=article_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to toggle publish status',
)
@router.post('/{article_id}/feature', response_model=NewsToggleResponse)
async def toggle_featured(
article_id: int,
admin: User = Depends(require_permission('news:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsToggleResponse:
"""Toggle the featured status of a news article."""
article = await get_news_article_by_id(db, article_id)
if not article:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Article not found',
)
try:
new_featured = not article.is_featured
# Only one article can be featured at a time — unfeature all others first
if new_featured:
await unfeature_all_news(db)
article = await update_news_article(db, article, is_featured=new_featured)
return NewsToggleResponse(
id=article.id,
is_published=article.is_published,
is_featured=article.is_featured,
published_at=article.published_at,
)
except Exception:
logger.exception('Failed to toggle featured', article_id=article_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to toggle featured status',
)
@@ -0,0 +1,90 @@
"""Admin routes for managing news categories."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.news_categories import (
create_category,
delete_category,
get_all_categories,
get_category_by_id,
update_category,
)
from app.database.models import User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.news_categories import NewsCategoryCreate, NewsCategoryResponse, NewsCategoryUpdate
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/news/categories', tags=['Cabinet Admin News Categories'])
@router.get('', response_model=list[NewsCategoryResponse])
async def list_categories(
admin: User = Depends(require_permission('news:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[NewsCategoryResponse]:
"""Get all news categories."""
categories = await get_all_categories(db)
return [NewsCategoryResponse.model_validate(c) for c in categories]
@router.post('', response_model=NewsCategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_new_category(
request: NewsCategoryCreate,
admin: User = Depends(require_permission('news:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsCategoryResponse:
"""Create a new news category."""
try:
category = await create_category(db, name=request.name, color=request.color)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Category already exists',
)
return NewsCategoryResponse.model_validate(category)
@router.put('/{category_id}', response_model=NewsCategoryResponse)
async def update_existing_category(
category_id: int,
request: NewsCategoryUpdate,
admin: User = Depends(require_permission('news:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsCategoryResponse:
"""Update an existing news category."""
category = await get_category_by_id(db, category_id)
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Category not found',
)
try:
category = await update_category(db, category, **request.model_dump(exclude_unset=True))
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Category name already exists',
)
return NewsCategoryResponse.model_validate(category)
@router.delete('/{category_id}', status_code=status.HTTP_204_NO_CONTENT)
async def remove_category(
category_id: int,
admin: User = Depends(require_permission('news:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete a news category. Articles using it will have category_id set to NULL."""
category = await get_category_by_id(db, category_id)
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Category not found',
)
await delete_category(db, category)
+157
View File
@@ -0,0 +1,157 @@
"""Admin routes for managing news article media (images/videos)."""
import asyncio
import re
import structlog
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from PIL import Image as PILImage
from app.config import settings
from app.database.models import User
from app.services.news_media_service import (
SavedMedia,
delete_media_file,
detect_file_type,
ensure_upload_dirs,
save_image,
save_video,
)
from ..dependencies import require_permission
from ..schemas.news_media import NewsMediaUploadResponse
logger = structlog.get_logger(__name__)
_BYTES_PER_MB = 1024 * 1024
# Only allow UUID-hex filenames with expected extensions (path traversal defense-in-depth).
# thumb_ prefix is NOT allowed — thumbnails are cleaned up automatically when the main file is deleted.
_SAFE_FILENAME_RE = re.compile(r'^[0-9a-f]{32}\.(jpg|mp4|webm)$')
router = APIRouter(prefix='/admin/news/media', tags=['Cabinet Admin News Media'])
_ALLOWED_SCHEMES = frozenset({'http', 'https'})
def _build_media_url(request: Request, relative_path: str) -> str:
"""Build a full URL for a media file, respecting reverse proxy headers."""
proto = request.headers.get('X-Forwarded-Proto', request.url.scheme).split(',')[0].strip()
if proto not in _ALLOWED_SCHEMES:
proto = 'https'
host = request.headers.get('X-Forwarded-Host', request.headers.get('Host', request.url.netloc))
host = host.split(',')[0].strip()
return f'{proto}://{host}/uploads/{relative_path}'
def _build_response(request: Request, saved: SavedMedia) -> NewsMediaUploadResponse:
"""Convert SavedMedia to API response with full URLs."""
thumbnail_url = _build_media_url(request, saved.thumbnail_path) if saved.thumbnail_path else None
return NewsMediaUploadResponse(
url=_build_media_url(request, saved.relative_path),
thumbnail_url=thumbnail_url,
media_type=saved.media_type,
filename=saved.filename,
size_bytes=saved.size_bytes,
width=saved.width,
height=saved.height,
)
@router.post('/upload', response_model=NewsMediaUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_media(
request: Request,
file: UploadFile = File(...),
admin: User = Depends(require_permission('news:edit')),
) -> NewsMediaUploadResponse:
"""Upload an image or video for a news article."""
# Read with a hard budget to prevent memory exhaustion from huge uploads.
# Read slightly over the max allowed size so we can detect oversized files.
absolute_max_bytes = settings.MEDIA_MAX_VIDEO_SIZE_MB * _BYTES_PER_MB + 1
data = await file.read(absolute_max_bytes)
await file.close()
if not data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Empty file',
)
if len(data) >= absolute_max_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f'File too large. Absolute maximum: {settings.MEDIA_MAX_VIDEO_SIZE_MB} MB',
)
# Detect type from magic bytes
try:
media_type, _ext = detect_file_type(data)
except ValueError:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail='Unsupported file type. Allowed: JPEG, PNG, WebP, MP4, WebM',
) from None
# Enforce per-type size limits
max_size_mb = settings.MEDIA_MAX_IMAGE_SIZE_MB if media_type == 'image' else settings.MEDIA_MAX_VIDEO_SIZE_MB
if len(data) > max_size_mb * _BYTES_PER_MB:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f'File too large. Maximum size for {media_type}: {max_size_mb} MB',
)
upload_path = settings.get_media_upload_path()
await asyncio.to_thread(ensure_upload_dirs, upload_path)
try:
if media_type == 'image':
saved = await save_image(
data,
upload_path,
max_dim=settings.MEDIA_IMAGE_MAX_DIMENSION,
quality=settings.MEDIA_JPEG_QUALITY,
)
else:
saved = await save_video(data, upload_path)
except (ValueError, OSError, PILImage.DecompressionBombError) as exc:
logger.warning('Failed to save uploaded media', media_type=media_type, error=str(exc))
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail='Failed to process uploaded file',
) from None
logger.info(
'Media uploaded',
filename=saved.filename,
media_type=saved.media_type,
size_bytes=saved.size_bytes,
admin_id=admin.id,
)
return _build_response(request, saved)
@router.delete('/{filename}', status_code=status.HTTP_204_NO_CONTENT)
async def delete_media(
filename: str,
admin: User = Depends(require_permission('news:delete')),
) -> None:
"""Delete a previously uploaded media file."""
if not _SAFE_FILENAME_RE.match(filename):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid filename',
)
upload_path = settings.get_media_upload_path()
deleted = await asyncio.to_thread(delete_media_file, filename, upload_path)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='File not found',
)
logger.info('Media deleted', filename=filename, admin_id=admin.id)
+90
View File
@@ -0,0 +1,90 @@
"""Admin routes for managing news tags."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.news_tags import (
create_tag,
delete_tag,
get_all_tags,
get_tag_by_id,
update_tag,
)
from app.database.models import User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.news_tags import NewsTagCreate, NewsTagResponse, NewsTagUpdate
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/news/tags', tags=['Cabinet Admin News Tags'])
@router.get('', response_model=list[NewsTagResponse])
async def list_tags(
admin: User = Depends(require_permission('news:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[NewsTagResponse]:
"""Get all news tags."""
tags = await get_all_tags(db)
return [NewsTagResponse.model_validate(t) for t in tags]
@router.post('', response_model=NewsTagResponse, status_code=status.HTTP_201_CREATED)
async def create_new_tag(
request: NewsTagCreate,
admin: User = Depends(require_permission('news:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsTagResponse:
"""Create a new news tag."""
try:
tag = await create_tag(db, name=request.name, color=request.color)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Tag already exists',
)
return NewsTagResponse.model_validate(tag)
@router.put('/{tag_id}', response_model=NewsTagResponse)
async def update_existing_tag(
tag_id: int,
request: NewsTagUpdate,
admin: User = Depends(require_permission('news:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsTagResponse:
"""Update an existing news tag."""
tag = await get_tag_by_id(db, tag_id)
if not tag:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tag not found',
)
try:
tag = await update_tag(db, tag, **request.model_dump(exclude_unset=True))
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Tag name already exists',
)
return NewsTagResponse.model_validate(tag)
@router.delete('/{tag_id}', status_code=status.HTTP_204_NO_CONTENT)
async def remove_tag(
tag_id: int,
admin: User = Depends(require_permission('news:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete a news tag. Articles using it will have tag_id set to NULL."""
tag = await get_tag_by_id(db, tag_id)
if not tag:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tag not found',
)
await delete_tag(db, tag)
+116 -1
View File
@@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Integer, and_, delete as sa_delete, func, or_, select
from sqlalchemy import Integer, and_, delete as sa_delete, func, literal, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -42,12 +42,15 @@ from app.database.models import (
UserPromoGroup,
UserStatus,
)
from app.services.permission_service import PermissionService
from app.utils.timezone import panel_datetime_to_utc
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.users import (
AdminUserGiftItem,
AdminUserGiftsResponse,
AssignReferrerRequest,
AssignReferrerResponse,
DeleteDeviceResponse,
DeleteUserRequest,
DeleteUserResponse,
@@ -1696,6 +1699,13 @@ async def update_user_referral_commission(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user's individual referral commission percentage."""
# Prevent admin from modifying their own commission
if user_id == admin.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Admin cannot modify their own referral commission',
)
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
@@ -1706,6 +1716,14 @@ async def update_user_referral_commission(
old_commission = user.referral_commission_percent
user.referral_commission_percent = request.commission_percent
user.updated_at = datetime.now(UTC)
await PermissionService.log_action(
db,
user_id=admin.id,
action='update_referral_commission',
resource_type='user',
resource_id=str(user_id),
details={'old_commission': old_commission, 'new_commission': request.commission_percent},
)
await db.commit()
logger.info(
@@ -1724,6 +1742,103 @@ async def update_user_referral_commission(
)
# === Assign Referrer ===
@router.post('/{user_id}/assign-referrer', response_model=AssignReferrerResponse)
async def assign_user_referrer(
user_id: int,
request: AssignReferrerRequest,
admin: User = Depends(require_permission('users:referral')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Manually assign a referrer to a user (e.g. cabinet-registered users without telegram_id).
Bonuses are NOT triggered immediately — they will apply on the user's next topup.
"""
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
if user_id == request.referrer_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='User cannot be their own referrer',
)
# Prevent admin self-enrichment
if request.referrer_id == admin.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Admin cannot assign themselves as referrer',
)
referrer = await get_user_by_id(db, request.referrer_id)
if not referrer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Referrer user not found',
)
# Prevent circular referral chains of any depth via recursive CTE
if await _would_create_referral_cycle(db, user_id, request.referrer_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Circular referral: assigning this referrer would create a cycle in the referral chain',
)
old_referrer_id = user.referred_by_id
user.referred_by_id = request.referrer_id
user.updated_at = datetime.now(UTC)
await PermissionService.log_action(
db,
user_id=admin.id,
action='assign_referrer',
resource_type='user',
resource_id=str(user_id),
details={'old_referrer_id': old_referrer_id, 'new_referrer_id': request.referrer_id},
)
await db.commit()
logger.info(
'Admin assigned referrer to user',
admin_id=admin.id,
user_id=user_id,
old_referrer_id=old_referrer_id,
new_referrer_id=request.referrer_id,
)
return AssignReferrerResponse(
success=True,
old_referrer_id=old_referrer_id,
new_referrer_id=request.referrer_id,
message='Referrer assigned successfully. Bonuses will apply on next user topup.',
)
async def _would_create_referral_cycle(db: AsyncSession, user_id: int, referrer_id: int) -> bool:
"""Walk the referrer's ancestor chain; if user_id appears, a cycle would form."""
max_depth = 50
anchor = (
select(User.id, User.referred_by_id, literal(0).label('depth'))
.where(User.id == referrer_id)
.cte(name='ancestors', recursive=True)
)
rpart = (
select(User.id, User.referred_by_id, (anchor.c.depth + 1).label('depth'))
.join(anchor, User.id == anchor.c.referred_by_id)
.where(anchor.c.depth < max_depth)
)
ancestors_cte = anchor.union_all(rpart)
result = await db.execute(
select(literal(1)).where(ancestors_cte.c.id == user_id).select_from(ancestors_cte).limit(1)
)
return result.scalar_one_or_none() is not None
# === Devices ===
+27 -11
View File
@@ -360,7 +360,7 @@ async def create_topup(
option = (request.payment_option or '').strip().lower()
# Use description with telegram_id for tax receipts
description = settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
)
if option == 'sbp':
result = await payment_service.create_yookassa_sbp_payment(
@@ -423,7 +423,7 @@ async def create_topup(
amount_usd=amount_usd,
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
payload=f'cabinet_topup_{user.id}_{request.amount_kopeks}',
)
@@ -484,7 +484,7 @@ async def create_topup(
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_code=method_code,
@@ -513,7 +513,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_return_url,
success_url=cabinet_success_url,
@@ -540,7 +542,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
@@ -570,7 +574,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
@@ -610,7 +616,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_success_url,
failed_url=cabinet_failed_url,
@@ -637,7 +645,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
telegram_id=user.telegram_id,
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_success_url,
@@ -665,7 +675,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
@@ -695,7 +707,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_system_id=ps_id,
@@ -722,7 +736,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
success_url=cabinet_success_url,
fail_url=cabinet_failed_url,
+175
View File
@@ -0,0 +1,175 @@
"""Public news routes for cabinet - user-facing news/blog section."""
import time
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.news import (
get_news_article_by_slug,
get_news_categories,
get_published_news,
get_published_news_count,
increment_views,
)
from app.database.models import NewsArticle, User
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.news import (
NewsArticleListItem,
NewsArticleResponse,
NewsListResponse,
)
logger = structlog.get_logger(__name__)
# Slug constraint: alphanumeric, hyphens, underscores, max 500 chars
_SLUG_MAX_LENGTH: int = 500
_SLUG_PATTERN: str = r'^[a-zA-Z0-9_-]+$'
# --- View counter deduplication ---
# In-memory TTL cache to prevent a single user from inflating view counts.
# Key: (user_id, article_id), Value: timestamp of last counted view.
# Views from the same user on the same article within _VIEW_DEDUP_SECONDS are ignored.
_VIEW_DEDUP_SECONDS: int = 300 # 5 minutes
_VIEW_DEDUP_MAX_SIZE: int = 10_000 # max entries before eviction
_view_dedup_cache: dict[tuple[int, int], float] = {}
def _should_count_view(user_id: int, article_id: int) -> bool:
"""Return True if this view should be counted (not a duplicate within TTL)."""
now = time.monotonic()
key = (user_id, article_id)
last_seen = _view_dedup_cache.get(key)
if last_seen is not None and (now - last_seen) < _VIEW_DEDUP_SECONDS:
return False
# Evict stale entries if cache grows too large
if len(_view_dedup_cache) >= _VIEW_DEDUP_MAX_SIZE:
cutoff = now - _VIEW_DEDUP_SECONDS
stale_keys = [k for k, v in _view_dedup_cache.items() if v < cutoff]
for k in stale_keys:
del _view_dedup_cache[k]
_view_dedup_cache[key] = now
return True
router = APIRouter(prefix='/news', tags=['Cabinet News'])
def _article_to_response(article: NewsArticle, *, include_content: bool = True) -> dict[str, Any]:
"""Convert NewsArticle ORM instance to response dict.
``author_name`` is only resolved when ``include_content=True`` (single-article
detail view) because the author relationship is not eagerly loaded for list
queries -- accessing it there would trigger a lazy-load or raise
``MissingGreenlet`` in async context.
"""
data: dict[str, Any] = {
'id': article.id,
'title': article.title,
'slug': article.slug,
'excerpt': article.excerpt,
'category': article.category,
'category_color': article.category_color,
'tag': article.tag,
'featured_image_url': article.featured_image_url,
'is_published': article.is_published,
'is_featured': article.is_featured,
'published_at': article.published_at,
'read_time_minutes': article.read_time_minutes,
'views_count': article.views_count,
}
if include_content:
author_name: str | None = None
if article.author:
author_name = article.author.first_name or article.author.username or f'#{article.author.id}'
data['content'] = article.content
data['author_name'] = author_name
data['created_at'] = article.created_at
data['updated_at'] = article.updated_at
return data
# NOTE: /categories MUST be declared before /{slug} to avoid route conflict
@router.get('/categories', response_model=list[str])
async def list_categories(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[str]:
"""Get list of distinct news categories."""
try:
return await get_news_categories(db)
except Exception:
logger.exception('Failed to get news categories')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load categories',
)
@router.get('', response_model=NewsListResponse)
async def list_published_news(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
category: str | None = Query(None, max_length=100),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> NewsListResponse:
"""Get paginated list of published news articles.
SQLAlchemy AsyncSession does not support concurrent operations, so
queries run sequentially.
"""
try:
articles = await get_published_news(db, category=category, limit=limit, offset=offset)
total = await get_published_news_count(db, category=category)
categories = await get_news_categories(db)
items = [NewsArticleListItem(**_article_to_response(a, include_content=False)) for a in articles]
return NewsListResponse(items=items, total=total, categories=categories)
except HTTPException:
raise
except Exception:
logger.exception('Failed to list published news')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load news',
)
@router.get('/{slug}', response_model=NewsArticleResponse)
async def get_article_by_slug(
slug: str = Path(..., max_length=_SLUG_MAX_LENGTH, pattern=_SLUG_PATTERN),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsArticleResponse:
"""Get a single published news article by slug. Increments view count."""
article = await get_news_article_by_slug(db, slug)
if not article or not article.is_published:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Article not found',
)
# Increment views with per-user deduplication (5-min TTL).
# Prevents view count inflation from repeated requests by the same user.
if _should_count_view(user.id, article.id):
try:
new_count = await increment_views(db, article.id)
# Patch the ORM instance so the response reflects the new count
# without an extra db.refresh() round-trip
article.views_count = new_count
except Exception:
logger.warning('Failed to increment views', article_id=article.id)
return NewsArticleResponse(**_article_to_response(article, include_content=True))
+97 -6
View File
@@ -1888,6 +1888,26 @@ async def purchase_tariff(
else:
period_days = request.period_days
# Validate period_days against tariff's configured periods (prevent arbitrary periods)
if tariff.period_prices:
available_periods = [int(p) for p in tariff.period_prices.keys()]
else:
available_periods = []
# Allow custom days only if tariff explicitly supports them
custom_days_allowed = (
hasattr(tariff, 'can_purchase_custom_days')
and tariff.can_purchase_custom_days()
and hasattr(tariff, 'get_price_for_custom_days')
and tariff.get_price_for_custom_days(period_days) is not None
)
if period_days not in available_periods and not custom_days_allowed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Selected period is not available for this tariff',
)
# Determine traffic limit (custom traffic support)
traffic_limit_gb = tariff.traffic_limit_gb
custom_traffic_gb = None
@@ -1921,6 +1941,13 @@ async def purchase_tariff(
promo_offer_discount_value = result.promo_offer_discount
price_before_promo_offer = price_kopeks + promo_offer_discount_value
# Safety guard: reject zero-price purchases for non-daily tariffs (defense in depth)
if price_kopeks <= 0 and result.base_price <= 0 and not is_daily_tariff:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid tariff period or pricing configuration',
)
# Check balance
if user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
@@ -4394,17 +4421,81 @@ async def toggle_subscription_pause(
# Sync with RemnaWave only when resuming from DISABLED state
if not new_paused_state and was_disabled:
# Restore connected_squads from tariff if cleared by deactivation sync
try:
if not user.subscription.connected_squads:
squads = tariff.allowed_squads or []
if not squads:
from app.database.crud.server_squad import get_all_server_squads
all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000)
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
if squads:
user.subscription.connected_squads = squads
await db.commit()
await db.refresh(user.subscription)
except Exception as sq_err:
logger.warning('Failed to restore connected_squads', error=sq_err)
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
user.subscription,
reset_traffic=False,
reset_reason=None,
)
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(
db,
user.subscription,
reset_traffic=False,
reset_reason=None,
sync_squads=True,
)
else:
await subscription_service.create_remnawave_user(
db,
user.subscription,
reset_traffic=False,
reset_reason=None,
)
# POST /api/users may ignore activeInternalSquads —
# follow up with PATCH to ensure internal squads are assigned
await db.refresh(user)
if getattr(user, 'remnawave_uuid', None) and user.subscription.connected_squads:
try:
await subscription_service.update_remnawave_user(
db,
user.subscription,
reset_traffic=False,
sync_squads=True,
)
except Exception as squad_err:
logger.warning('Failed to sync squads after user creation', error=squad_err)
except Exception as e:
logger.error('Error syncing RemnaWave user on resume', error=e)
# Send admin notification about daily subscription resume
if resume_transaction is not None:
try:
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_purchase_notification(
db=db,
user=user,
subscription=user.subscription,
transaction=resume_transaction,
period_days=1,
was_trial_conversion=False,
amount_kopeks=daily_price,
purchase_type='renewal',
)
finally:
await bot.session.close()
except Exception as notif_err:
logger.error('Failed to send admin notification for daily resume', error=notif_err)
if new_paused_state:
message = 'Daily subscription paused'
else:
+337
View File
@@ -0,0 +1,337 @@
"""Schemas for news articles in cabinet.
Security notes:
- featured_image_url is validated to only accept http/https schemes.
- category_color is validated as a strict hex color (#RGB, #RRGGBB, etc.).
- Slug is sanitized to only allow [a-zA-Z0-9_-].
- Content is server-side sanitized to strip <script>, event handlers, and
dangerous URI schemes as a defense-in-depth measure. The frontend also
sanitizes via DOMPurify, but server-side sanitization protects against
alternative consumers (mobile apps, RSS, email digests) and compromised
frontends.
"""
import re
from datetime import datetime
from urllib.parse import urlparse
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# Pre-compiled regex for hex color validation (reused across validators)
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
# Pre-compiled regex for collapsing repeated hyphens in slugs
_MULTI_HYPHEN_RE: re.Pattern[str] = re.compile(r'-+')
# Maximum slug length (matches DB column constraint)
_MAX_SLUG_LENGTH: int = 500
# Allowed URL schemes for user-supplied URLs (featured_image_url)
_SAFE_URL_SCHEMES: frozenset[str] = frozenset({'http', 'https'})
# Cyrillic-to-Latin transliteration map for slug generation
_TRANSLIT_MAP: dict[str, str] = {
'а': 'a',
'б': 'b',
'в': 'v',
'г': 'g',
'д': 'd',
'е': 'e',
'ё': 'yo',
'ж': 'zh',
'з': 'z',
'и': 'i',
'й': 'y',
'к': 'k',
'л': 'l',
'м': 'm',
'н': 'n',
'о': 'o',
'п': 'p',
'р': 'r',
'с': 's',
'т': 't',
'у': 'u',
'ф': 'f',
'х': 'kh',
'ц': 'ts',
'ч': 'ch',
'ш': 'sh',
'щ': 'shch',
'ъ': '',
'ы': 'y',
'ь': '',
'э': 'e',
'ю': 'yu',
'я': 'ya',
}
def _slugify(title: str) -> str:
"""Generate a URL-safe slug from a title, transliterating Cyrillic."""
slug = title.lower()
result: list[str] = []
for ch in slug:
if ch in _TRANSLIT_MAP:
result.append(_TRANSLIT_MAP[ch])
elif ch.isascii() and (ch.isalnum() or ch in '-_'):
result.append(ch)
elif ch == ' ':
result.append('-')
slug = ''.join(result)
slug = _MULTI_HYPHEN_RE.sub('-', slug).strip('-')
return slug[:_MAX_SLUG_LENGTH] or 'untitled'
def _validate_hex_color(v: str) -> str:
"""Validate a hex color string. Raises ValueError on invalid input."""
if not _HEX_COLOR_RE.match(v):
msg = 'category_color must be a valid hex color (e.g. #00e5a0)'
raise ValueError(msg)
return v
def _validate_safe_url(v: str) -> str:
"""Validate that a URL uses http or https scheme only.
Prevents javascript:, data:, vbscript:, and other dangerous URI schemes
from being stored in the database and later rendered in <img> or <a> tags.
"""
try:
parsed = urlparse(v)
except Exception:
msg = 'Invalid URL format'
raise ValueError(msg)
if parsed.scheme not in _SAFE_URL_SCHEMES:
msg = f'URL scheme must be http or https, got: {parsed.scheme!r}'
raise ValueError(msg)
if not parsed.netloc:
msg = 'URL must have a valid host'
raise ValueError(msg)
return v
# --- Server-side HTML content sanitization ---
# Pre-compiled patterns for stripping the most dangerous HTML constructs.
# This is a defense-in-depth measure: the frontend also sanitizes via DOMPurify.
# Uses regex rather than a full HTML parser to avoid adding a new dependency.
# Strips: <script>, <style>, <object>, <embed>, <applet>, <base>, <form>,
# <link>, <meta> tags and all on* event handler attributes.
_DANGEROUS_TAGS_RE: re.Pattern[str] = re.compile(
r'<\s*/?\s*(script|style|object|embed|applet|base|form|link(?:\s)|meta)\b[^>]*>',
re.IGNORECASE | re.DOTALL,
)
# Match on* event handler attributes, e.g. onclick="...", onerror='...'
_EVENT_HANDLER_RE: re.Pattern[str] = re.compile(
r'\s+on[a-z]+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)',
re.IGNORECASE,
)
# Match javascript:, vbscript:, data: in href/src attributes
_DANGEROUS_URI_RE: re.Pattern[str] = re.compile(
r'((?:href|src)\s*=\s*["\'])\s*(javascript|vbscript|data)\s*:',
re.IGNORECASE,
)
def _sanitize_html_content(html: str) -> str:
"""Strip dangerous HTML constructs from article content.
This is NOT a replacement for DOMPurify on the frontend. It is a
defense-in-depth layer that removes the most obvious XSS vectors
at the storage boundary. A full HTML sanitizer (nh3, bleach) would
be stronger, but this avoids adding a new dependency.
"""
if not html:
return html
# 1. Remove dangerous tags and their content
result = _DANGEROUS_TAGS_RE.sub('', html)
# Also strip <script>...</script> content (tag + body)
result = re.sub(r'<script\b[^>]*>[\s\S]*?</script>', '', result, flags=re.IGNORECASE)
result = re.sub(r'<style\b[^>]*>[\s\S]*?</style>', '', result, flags=re.IGNORECASE)
# 2. Remove event handler attributes
result = _EVENT_HANDLER_RE.sub('', result)
# 3. Neutralize dangerous URI schemes in href/src
result = _DANGEROUS_URI_RE.sub(r'\1about:', result)
return result
class NewsArticleResponse(BaseModel):
"""Full news article response (detail view)."""
id: int
title: str
slug: str
content: str
excerpt: str | None
category: str
category_color: str
tag: str | None
category_id: int | None = None
tag_id: int | None = None
featured_image_url: str | None
is_published: bool
is_featured: bool
published_at: datetime | None
read_time_minutes: int
views_count: int
author_name: str | None = None
created_at: datetime
updated_at: datetime | None
model_config = ConfigDict(from_attributes=True)
class NewsArticleListItem(BaseModel):
"""Compact news article for list views."""
id: int
title: str
slug: str
excerpt: str | None
category: str
category_color: str
tag: str | None
category_id: int | None = None
tag_id: int | None = None
featured_image_url: str | None
is_published: bool
is_featured: bool
published_at: datetime | None
read_time_minutes: int
views_count: int
model_config = ConfigDict(from_attributes=True)
class NewsListResponse(BaseModel):
"""Paginated list of news articles."""
items: list[NewsArticleListItem]
total: int
categories: list[str] = Field(default_factory=list)
class NewsCreateRequest(BaseModel):
"""Request to create a news article."""
title: str = Field(..., min_length=1, max_length=500)
slug: str | None = Field(None, min_length=1, max_length=500)
content: str = Field(default='', max_length=500_000)
excerpt: str | None = Field(None, max_length=1000)
category: str = Field(..., min_length=1, max_length=100)
category_color: str = Field(default='#00e5a0', max_length=20)
tag: str | None = Field(None, max_length=50)
category_id: int | None = None
tag_id: int | None = None
featured_image_url: str | None = Field(None, max_length=2000)
is_published: bool = False
is_featured: bool = False
read_time_minutes: int = Field(default=1, ge=1, le=60)
@field_validator('content')
@classmethod
def sanitize_content(cls, v: str) -> str:
"""Strip dangerous HTML from article content (defense-in-depth)."""
return _sanitize_html_content(v)
@field_validator('category_color')
@classmethod
def validate_hex_color(cls, v: str) -> str:
return _validate_hex_color(v)
@field_validator('featured_image_url')
@classmethod
def validate_featured_image_url(cls, v: str | None) -> str | None:
"""Reject javascript:, data:, and other dangerous URL schemes."""
if v is not None:
return _validate_safe_url(v)
return v
@model_validator(mode='before')
@classmethod
def auto_generate_slug(cls, data: dict) -> dict: # type: ignore[type-arg]
"""Generate slug from title when not explicitly provided."""
if isinstance(data, dict) and not data.get('slug'):
title = data.get('title', '')
data['slug'] = _slugify(title) if isinstance(title, str) else 'untitled'
return data
@field_validator('slug')
@classmethod
def sanitize_slug(cls, v: str | None) -> str | None:
"""Ensure slug contains only URL-safe characters."""
if v is not None:
# Strip anything that isn't alphanumeric, hyphen, or underscore
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '-', v)
sanitized = _MULTI_HYPHEN_RE.sub('-', sanitized).strip('-')
return sanitized or 'untitled'
return v
class NewsUpdateRequest(BaseModel):
"""Request to update a news article."""
title: str | None = Field(None, min_length=1, max_length=500)
slug: str | None = Field(None, min_length=1, max_length=500)
content: str | None = Field(None, max_length=500_000)
excerpt: str | None = None
category: str | None = Field(None, min_length=1, max_length=100)
category_color: str | None = Field(None, max_length=20)
tag: str | None = None
category_id: int | None = None
tag_id: int | None = None
featured_image_url: str | None = Field(None, max_length=2000)
is_published: bool | None = None
is_featured: bool | None = None
read_time_minutes: int | None = Field(None, ge=1, le=60)
@field_validator('content')
@classmethod
def sanitize_content(cls, v: str | None) -> str | None:
"""Strip dangerous HTML from article content (defense-in-depth)."""
if v is not None:
return _sanitize_html_content(v)
return v
@field_validator('category_color')
@classmethod
def validate_hex_color(cls, v: str | None) -> str | None:
if v is not None:
return _validate_hex_color(v)
return v
@field_validator('featured_image_url')
@classmethod
def validate_featured_image_url(cls, v: str | None) -> str | None:
"""Reject javascript:, data:, and other dangerous URL schemes."""
if v is not None:
return _validate_safe_url(v)
return v
@field_validator('slug')
@classmethod
def sanitize_slug(cls, v: str | None) -> str | None:
"""Ensure slug contains only URL-safe characters."""
if v is not None:
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '-', v)
sanitized = _MULTI_HYPHEN_RE.sub('-', sanitized).strip('-')
return sanitized or 'untitled'
return v
class NewsToggleResponse(BaseModel):
"""Response after toggling publish/featured status."""
id: int
is_published: bool
is_featured: bool
published_at: datetime | None
+48
View File
@@ -0,0 +1,48 @@
"""Schemas for news categories."""
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
class NewsCategoryCreate(BaseModel):
"""Request to create a news category."""
name: str = Field(..., min_length=1, max_length=100)
color: str = Field(default='#00e5a0', max_length=20)
@field_validator('color')
@classmethod
def validate_color(cls, v: str) -> str:
if not _HEX_COLOR_RE.match(v):
msg = 'Invalid hex color'
raise ValueError(msg)
return v
class NewsCategoryUpdate(BaseModel):
"""Request to update a news category."""
name: str | None = Field(None, min_length=1, max_length=100)
color: str | None = Field(None, max_length=20)
@field_validator('color')
@classmethod
def validate_color(cls, v: str | None) -> str | None:
if v is not None and not _HEX_COLOR_RE.match(v):
msg = 'Invalid hex color'
raise ValueError(msg)
return v
class NewsCategoryResponse(BaseModel):
"""News category response."""
id: int
name: str
color: str
model_config = ConfigDict(from_attributes=True)
+17
View File
@@ -0,0 +1,17 @@
"""Schemas for news media upload responses."""
from typing import Literal
from pydantic import BaseModel
class NewsMediaUploadResponse(BaseModel):
"""Response returned after a successful media upload."""
url: str
thumbnail_url: str | None = None
media_type: Literal['image', 'video']
filename: str
size_bytes: int
width: int | None = None
height: int | None = None
+48
View File
@@ -0,0 +1,48 @@
"""Schemas for news tags."""
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
class NewsTagCreate(BaseModel):
"""Request to create a news tag."""
name: str = Field(..., min_length=1, max_length=50)
color: str = Field(default='#94a3b8', max_length=20)
@field_validator('color')
@classmethod
def validate_color(cls, v: str) -> str:
if not _HEX_COLOR_RE.match(v):
msg = 'Invalid hex color'
raise ValueError(msg)
return v
class NewsTagUpdate(BaseModel):
"""Request to update a news tag."""
name: str | None = Field(None, min_length=1, max_length=50)
color: str | None = Field(None, max_length=20)
@field_validator('color')
@classmethod
def validate_color(cls, v: str | None) -> str | None:
if v is not None and not _HEX_COLOR_RE.match(v):
msg = 'Invalid hex color'
raise ValueError(msg)
return v
class NewsTagResponse(BaseModel):
"""News tag response."""
id: int
name: str
color: str
model_config = ConfigDict(from_attributes=True)
+15
View File
@@ -387,6 +387,21 @@ class UpdateReferralCommissionResponse(BaseModel):
message: str
class AssignReferrerRequest(BaseModel):
"""Request to manually assign a referrer to a user."""
referrer_id: int = Field(..., gt=0, description='ID of the referrer user')
class AssignReferrerResponse(BaseModel):
"""Response after referrer assignment."""
success: bool
old_referrer_id: int | None = None
new_referrer_id: int | None = None
message: str
class DeviceInfo(BaseModel):
"""Individual device info."""
+18 -2
View File
@@ -367,6 +367,7 @@ class Settings(BaseSettings):
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_RECURRENT_ENABLED: bool = False
YOOKASSA_RECURRENT_REQUIRED: bool = False
YOOKASSA_TEST_MODE: bool = False
SUPPORT_TOPUP_ENABLED: bool = True
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
@@ -462,6 +463,7 @@ class Settings(BaseSettings):
PLATEGA_FAILED_URL: str | None = None
PLATEGA_CURRENCY: str = 'RUB'
PLATEGA_ACTIVE_METHODS: str = '2,10,11,12,13'
PLATEGA_INLINE_METHODS: bool = True
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
PLATEGA_WEBHOOK_PATH: str = '/platega-webhook'
@@ -582,6 +584,13 @@ class Settings(BaseSettings):
CONNECT_BUTTON_MODE: str = 'miniapp_subscription'
MINIAPP_CUSTOM_URL: str = ''
MINIAPP_STATIC_PATH: str = 'miniapp'
# Media upload settings (news article images/videos)
MEDIA_UPLOAD_DIR: str = './uploads'
MEDIA_MAX_IMAGE_SIZE_MB: int = 10
MEDIA_MAX_VIDEO_SIZE_MB: int = 50
MEDIA_IMAGE_MAX_DIMENSION: int = 2048
MEDIA_JPEG_QUALITY: int = 85
MINIAPP_PURCHASE_URL: str = ''
MINIAPP_SERVICE_NAME_EN: str = 'Bedolaga VPN'
MINIAPP_SERVICE_NAME_RU: str = 'Bedolaga VPN'
@@ -2207,13 +2216,17 @@ class Settings(BaseSettings):
except (ValueError, AttributeError):
return [30, 60, 90, 180, 360]
def get_balance_payment_description(self, amount_kopeks: int, telegram_user_id: int | None = None) -> str:
def get_balance_payment_description(
self, amount_kopeks: int, telegram_user_id: int | None = None, user_db_id: int | None = None
) -> str:
# Базовое описание
description = f'{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}'
# Если передан user_id, добавляем его
# Добавляем идентификатор пользователя (TG ID приоритет, fallback на DB ID)
if telegram_user_id is not None:
description += f' (ID {telegram_user_id})'
elif user_db_id is not None:
description += f' (U{user_db_id})'
# Формируем финальную строку по шаблону
return self.PAYMENT_BALANCE_TEMPLATE.format(service_name=self.PAYMENT_SERVICE_NAME, description=description)
@@ -2626,6 +2639,9 @@ class Settings(BaseSettings):
raw_path = 'miniapp'
return Path(raw_path)
def get_media_upload_path(self) -> Path:
return Path(self.MEDIA_UPLOAD_DIR)
# Cabinet methods
def is_cabinet_enabled(self) -> bool:
return bool(self.CABINET_ENABLED)
+288
View File
@@ -0,0 +1,288 @@
"""CRUD operations for news articles."""
from datetime import UTC, datetime
from typing import Any
import structlog
from sqlalchemy import delete, func, nullslast, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import NewsArticle
logger = structlog.get_logger(__name__)
# Fields that can be set via update_news_article
_ALLOWED_UPDATE_FIELDS: frozenset[str] = frozenset(
{
'title',
'slug',
'content',
'excerpt',
'category',
'category_color',
'tag',
'category_id',
'tag_id',
'featured_image_url',
'is_published',
'is_featured',
'published_at',
'read_time_minutes',
}
)
# Fields that can be explicitly set to None
_NULLABLE_UPDATE_FIELDS: frozenset[str] = frozenset(
{
'excerpt',
'tag',
'category_id',
'tag_id',
'featured_image_url',
'published_at',
}
)
async def create_news_article(
db: AsyncSession,
*,
title: str,
slug: str,
content: str = '',
excerpt: str | None = None,
category: str = '',
category_color: str = '#00e5a0',
tag: str | None = None,
category_id: int | None = None,
tag_id: int | None = None,
featured_image_url: str | None = None,
is_published: bool = False,
is_featured: bool = False,
published_at: datetime | None = None,
read_time_minutes: int = 1,
created_by: int | None = None,
) -> NewsArticle:
"""Create a new news article.
Raises:
IntegrityError: if slug is not unique (caller must handle).
"""
# Auto-set published_at when publishing without explicit date
if is_published and published_at is None:
published_at = datetime.now(UTC)
article = NewsArticle(
title=title,
slug=slug,
content=content,
excerpt=excerpt,
category=category,
category_color=category_color,
tag=tag,
category_id=category_id,
tag_id=tag_id,
featured_image_url=featured_image_url,
is_published=is_published,
is_featured=is_featured,
published_at=published_at,
read_time_minutes=read_time_minutes,
created_by=created_by,
)
db.add(article)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(article)
logger.info(
'Created news article',
article_id=article.id,
slug=article.slug,
is_published=article.is_published,
)
return article
async def get_news_article_by_id(db: AsyncSession, article_id: int) -> NewsArticle | None:
"""Get a news article by ID with author, category, and tag relationships."""
result = await db.execute(
select(NewsArticle)
.options(
selectinload(NewsArticle.author),
selectinload(NewsArticle.category_obj),
selectinload(NewsArticle.tag_obj),
)
.where(NewsArticle.id == article_id)
)
return result.scalar_one_or_none()
async def get_news_article_by_slug(db: AsyncSession, slug: str) -> NewsArticle | None:
"""Get a news article by slug with author, category, and tag relationships."""
result = await db.execute(
select(NewsArticle)
.options(
selectinload(NewsArticle.author),
selectinload(NewsArticle.category_obj),
selectinload(NewsArticle.tag_obj),
)
.where(NewsArticle.slug == slug)
)
return result.scalar_one_or_none()
async def get_published_news(
db: AsyncSession,
*,
category: str | None = None,
limit: int = 20,
offset: int = 0,
) -> list[NewsArticle]:
"""Get published news articles, ordered by published_at descending.
Does NOT load the author relationship -- list views do not need it.
"""
stmt = select(NewsArticle).where(NewsArticle.is_published.is_(True))
if category:
stmt = stmt.where(NewsArticle.category == category)
# NULLs last so articles without published_at don't float to the top in DESC
stmt = stmt.order_by(nullslast(NewsArticle.published_at.desc())).offset(offset).limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_published_news_count(
db: AsyncSession,
*,
category: str | None = None,
) -> int:
"""Get count of published news articles, optionally filtered by category."""
stmt = select(func.count(NewsArticle.id)).where(NewsArticle.is_published.is_(True))
if category:
stmt = stmt.where(NewsArticle.category == category)
result = await db.execute(stmt)
return result.scalar_one() or 0
async def get_all_news(
db: AsyncSession,
*,
limit: int = 50,
offset: int = 0,
) -> list[NewsArticle]:
"""Get all news articles (admin), ordered by created_at descending."""
stmt = select(NewsArticle).order_by(NewsArticle.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_all_news_count(db: AsyncSession) -> int:
"""Get total count of all news articles."""
result = await db.execute(select(func.count(NewsArticle.id)))
return result.scalar_one() or 0
async def get_news_categories(db: AsyncSession) -> list[str]:
"""Get distinct categories from published articles."""
result = await db.execute(
select(NewsArticle.category)
.where(NewsArticle.is_published.is_(True))
.where(NewsArticle.category != '')
.distinct()
.order_by(NewsArticle.category)
)
return list(result.scalars().all())
async def unfeature_all_news(db: AsyncSession) -> None:
"""Remove featured flag from all articles (so only one can be featured).
Does NOT commit. The caller must commit the session to persist this change.
This is intentional the caller should commit both this operation and the
subsequent feature operation atomically.
"""
await db.execute(update(NewsArticle).where(NewsArticle.is_featured.is_(True)).values(is_featured=False))
async def update_news_article(
db: AsyncSession,
article: NewsArticle,
**kwargs: Any,
) -> NewsArticle:
"""Update a news article. Only whitelisted fields are applied.
Raises:
IntegrityError: if slug conflicts with another article (caller must handle).
"""
update_data: dict[str, Any] = {}
for key, value in kwargs.items():
if key not in _ALLOWED_UPDATE_FIELDS:
continue
if value is None and key not in _NULLABLE_UPDATE_FIELDS:
continue
update_data[key] = value
# Auto-set published_at when transitioning to published
if update_data.get('is_published') and not article.is_published and not update_data.get('published_at'):
if article.published_at is None:
update_data['published_at'] = datetime.now(UTC)
if not update_data:
return article
update_data['updated_at'] = datetime.now(UTC)
await db.execute(update(NewsArticle).where(NewsArticle.id == article.id).values(**update_data))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(article)
logger.info(
'Updated news article',
article_id=article.id,
slug=article.slug,
updated_fields=list(update_data.keys()),
)
return article
async def delete_news_article(db: AsyncSession, article: NewsArticle) -> None:
"""Delete a news article."""
# Capture fields before commit expires the ORM instance attributes
article_id = article.id
article_slug = article.slug
await db.execute(delete(NewsArticle).where(NewsArticle.id == article_id))
await db.commit()
logger.info('Deleted news article', article_id=article_id, slug=article_slug)
async def increment_views(db: AsyncSession, article_id: int) -> int:
"""Atomically increment the views counter and return the new count.
Uses UPDATE RETURNING so the caller can patch the ORM instance directly
without issuing a second SELECT (db.refresh).
"""
result = await db.execute(
update(NewsArticle)
.where(NewsArticle.id == article_id)
.values(views_count=NewsArticle.views_count + 1)
.returning(NewsArticle.views_count)
)
await db.commit()
row = result.fetchone()
return row[0] if row else 0
+87
View File
@@ -0,0 +1,87 @@
"""CRUD operations for news categories."""
import structlog
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import NewsArticle, NewsCategory
logger = structlog.get_logger(__name__)
async def get_all_categories(db: AsyncSession) -> list[NewsCategory]:
"""Get all news categories ordered by name."""
result = await db.execute(select(NewsCategory).order_by(NewsCategory.name))
return list(result.scalars().all())
async def get_category_by_id(db: AsyncSession, category_id: int) -> NewsCategory | None:
"""Get a single news category by primary key."""
result = await db.execute(select(NewsCategory).where(NewsCategory.id == category_id))
return result.scalar_one_or_none()
async def create_category(db: AsyncSession, *, name: str, color: str = '#00e5a0') -> NewsCategory:
"""Create a new news category.
Raises:
IntegrityError: if a category with the same name already exists (caller must handle).
"""
category = NewsCategory(name=name.strip(), color=color)
db.add(category)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(category)
logger.info('Created news category', category_id=category.id, name=category.name)
return category
async def update_category(
db: AsyncSession,
category: NewsCategory,
**kwargs: str | None,
) -> NewsCategory:
"""Update an existing news category.
Supported kwargs: name, color.
Raises:
IntegrityError: if the new name conflicts with an existing category.
"""
update_data: dict[str, str] = {}
if 'name' in kwargs and kwargs['name'] is not None:
update_data['name'] = kwargs['name'].strip()
if 'color' in kwargs and kwargs['color'] is not None:
update_data['color'] = kwargs['color']
if not update_data:
return category
await db.execute(update(NewsCategory).where(NewsCategory.id == category.id).values(**update_data))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(category)
logger.info('Updated news category', category_id=category.id, updated_fields=list(update_data.keys()))
return category
async def delete_category(db: AsyncSession, category: NewsCategory) -> None:
"""Delete a news category and clear category fields from all linked articles."""
cat_id, cat_name = category.id, category.name
# Clear legacy string fields on articles that reference this category
await db.execute(
update(NewsArticle)
.where(NewsArticle.category_id == cat_id)
.values(category='', category_color='#00e5a0', category_id=None)
)
await db.delete(category)
await db.commit()
logger.info('Deleted news category', category_id=cat_id, name=cat_name)
+83
View File
@@ -0,0 +1,83 @@
"""CRUD operations for news tags."""
import structlog
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import NewsArticle, NewsTag
logger = structlog.get_logger(__name__)
async def get_all_tags(db: AsyncSession) -> list[NewsTag]:
"""Get all news tags ordered by name."""
result = await db.execute(select(NewsTag).order_by(NewsTag.name))
return list(result.scalars().all())
async def get_tag_by_id(db: AsyncSession, tag_id: int) -> NewsTag | None:
"""Get a single news tag by primary key."""
result = await db.execute(select(NewsTag).where(NewsTag.id == tag_id))
return result.scalar_one_or_none()
async def create_tag(db: AsyncSession, *, name: str, color: str = '#94a3b8') -> NewsTag:
"""Create a new news tag.
Raises:
IntegrityError: if a tag with the same name already exists (caller must handle).
"""
tag = NewsTag(name=name.strip(), color=color)
db.add(tag)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(tag)
logger.info('Created news tag', tag_id=tag.id, name=tag.name)
return tag
async def update_tag(
db: AsyncSession,
tag: NewsTag,
**kwargs: str | None,
) -> NewsTag:
"""Update an existing news tag.
Supported kwargs: name, color.
Raises:
IntegrityError: if the new name conflicts with an existing tag.
"""
update_data: dict[str, str] = {}
if 'name' in kwargs and kwargs['name'] is not None:
update_data['name'] = kwargs['name'].strip()
if 'color' in kwargs and kwargs['color'] is not None:
update_data['color'] = kwargs['color']
if not update_data:
return tag
await db.execute(update(NewsTag).where(NewsTag.id == tag.id).values(**update_data))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(tag)
logger.info('Updated news tag', tag_id=tag.id, updated_fields=list(update_data.keys()))
return tag
async def delete_tag(db: AsyncSession, tag: NewsTag) -> None:
"""Delete a news tag and clear tag fields from all linked articles."""
tag_id, tag_name = tag.id, tag.name
# Clear legacy string field on articles that reference this tag
await db.execute(update(NewsArticle).where(NewsArticle.tag_id == tag_id).values(tag=None, tag_id=None))
await db.delete(tag)
await db.commit()
logger.info('Deleted news tag', tag_id=tag_id, name=tag_name)
+8 -2
View File
@@ -34,6 +34,8 @@ async def record_notification(
subscription_id: int,
notification_type: str,
days_before: int | None = None,
*,
commit: bool = True,
) -> None:
already_exists = await notification_sent(db, user_id, subscription_id, notification_type, days_before)
if already_exists:
@@ -45,7 +47,8 @@ async def record_notification(
days_before=days_before,
)
db.add(notification)
await db.commit()
if commit:
await db.commit()
async def clear_notifications(db: AsyncSession, subscription_id: int, *, commit: bool = True) -> None:
@@ -58,6 +61,8 @@ async def clear_notification_by_type(
db: AsyncSession,
subscription_id: int,
notification_type: str,
*,
commit: bool = True,
) -> None:
await db.execute(
delete(SentNotification).where(
@@ -65,4 +70,5 @@ async def clear_notification_by_type(
SentNotification.notification_type == notification_type,
)
)
await db.commit()
if commit:
await db.commit()
+8 -6
View File
@@ -826,18 +826,19 @@ async def update_subscription_autopay(
return subscription
async def deactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
async def deactivate_subscription(db: AsyncSession, subscription: Subscription, *, commit: bool = True) -> Subscription:
subscription.status = SubscriptionStatus.DISABLED.value
subscription.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
if commit:
await db.commit()
await db.refresh(subscription)
logger.info('❌ Подписка пользователя деактивирована', user_id=subscription.user_id)
return subscription
async def reactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
async def reactivate_subscription(db: AsyncSession, subscription: Subscription, *, commit: bool = True) -> Subscription:
"""Реактивация подписки (например, после повторной подписки на канал или докупки трафика).
Активирует если подписка была DISABLED или EXPIRED и ещё не истекла по времени.
@@ -861,8 +862,9 @@ async def reactivate_subscription(db: AsyncSession, subscription: Subscription)
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = now
await db.commit()
await db.refresh(subscription)
if commit:
await db.commit()
await db.refresh(subscription)
logger.info(
'✅ Подписка реактивирована',
+9
View File
@@ -139,6 +139,7 @@ async def find_phantom_user_by_username(db: AsyncSession, username: str) -> User
.where(
User.telegram_id.is_(None),
User.auth_type == 'telegram',
User.status != UserStatus.DELETED.value,
func.lower(User.username) == normalized,
)
.with_for_update()
@@ -459,6 +460,14 @@ async def add_user_balance(
)
user = locked_result.scalar_one()
if amount_kopeks < 0:
logger.error(
'add_user_balance вызван с отрицательной суммой — используйте subtract_user_balance',
amount_kopeks=amount_kopeks,
user_id=user.id,
)
return False
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.now(UTC)
+5 -4
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime
import structlog
@@ -221,10 +222,10 @@ def replace_placeholders(text: str, user) -> str:
first_name = first_name.strip() if first_name else None
username = username.strip() if username else None
user_name = first_name or username or 'друг'
display_first_name = first_name or 'друг'
display_username = f'@{username}' if username else (first_name or 'друг')
clean_username = username or first_name or 'друг'
user_name = html.escape(first_name or username or 'друг')
display_first_name = html.escape(first_name or 'друг')
display_username = f'@{html.escape(username)}' if username else html.escape(first_name or 'друг')
clean_username = html.escape(username or first_name or 'друг')
replacements = {
'{user_name}': user_name,
+74 -1
View File
@@ -28,6 +28,7 @@ from sqlalchemy import (
Time,
TypeDecorator,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base
@@ -2364,7 +2365,7 @@ class SubscriptionServer(Base):
__tablename__ = 'subscription_servers'
id = Column(Integer, primary_key=True, index=True)
subscription_id = Column(Integer, ForeignKey('subscriptions.id'), nullable=False)
subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='CASCADE'), nullable=False, index=True)
server_squad_id = Column(Integer, ForeignKey('server_squads.id'), nullable=False)
connected_at = Column(AwareDateTime(), default=func.now())
@@ -3299,3 +3300,75 @@ class GuestPurchase(Base):
def __repr__(self) -> str:
token_prefix = self.token[:5] if self.token else '?'
return f"<GuestPurchase token='{token_prefix}...' status='{self.status}'>"
class NewsArticle(Base):
"""News article for the cabinet news section."""
__tablename__ = 'news_articles'
__table_args__ = (
# Covers the main public list query: WHERE is_published = true ORDER BY published_at DESC
Index('ix_news_articles_published_at_published', 'is_published', 'published_at'),
# Covers the category-filtered public list: WHERE is_published = true AND category = ?
Index('ix_news_articles_published_category', 'is_published', 'category'),
# Covers the admin list query: ORDER BY created_at DESC
Index('ix_news_articles_created_at', 'created_at'),
)
id = Column(Integer, primary_key=True, index=True)
title = Column(String(500), nullable=False)
slug = Column(String(500), unique=True, nullable=False, index=True)
content = Column(Text, nullable=False, default='', server_default='')
excerpt = Column(Text, nullable=True)
category = Column(String(100), nullable=False, default='', server_default='')
category_color = Column(String(20), nullable=False, default='#00e5a0', server_default='#00e5a0')
tag = Column(String(50), nullable=True)
featured_image_url = Column(Text, nullable=True)
is_published = Column(Boolean, nullable=False, default=False, server_default='false')
is_featured = Column(Boolean, nullable=False, default=False, server_default='false')
published_at = Column(AwareDateTime(), nullable=True)
read_time_minutes = Column(Integer, nullable=False, default=1, server_default='1')
views_count = Column(Integer, nullable=False, default=0, server_default='0')
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
category_id = Column(Integer, ForeignKey('news_categories.id', ondelete='SET NULL'), nullable=True)
tag_id = Column(Integer, ForeignKey('news_tags.id', ondelete='SET NULL'), nullable=True)
author = relationship('User', backref='created_news_articles', foreign_keys=[created_by])
category_obj = relationship('NewsCategory', foreign_keys=[category_id], lazy='noload')
tag_obj = relationship('NewsTag', foreign_keys=[tag_id], lazy='noload')
def __repr__(self) -> str:
return f"<NewsArticle id={self.id} slug='{self.slug}' published={self.is_published}>"
class NewsCategory(Base):
"""Managed news category with a display color."""
__tablename__ = 'news_categories'
__table_args__ = (Index('ix_news_categories_name_lower', text('lower(name)'), unique=True),)
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False)
color = Column(String(20), nullable=False, server_default='#00e5a0')
created_at = Column(AwareDateTime(), server_default=func.now(), nullable=False)
def __repr__(self) -> str:
return f"<NewsCategory id={self.id} name='{self.name}'>"
class NewsTag(Base):
"""Managed news tag with a display color."""
__tablename__ = 'news_tags'
__table_args__ = (Index('ix_news_tags_name_lower', text('lower(name)'), unique=True),)
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), nullable=False)
color = Column(String(20), nullable=False, server_default='#94a3b8')
created_at = Column(AwareDateTime(), server_default=func.now(), nullable=False)
def __repr__(self) -> str:
return f"<NewsTag id={self.id} name='{self.name}'>"
+2 -2
View File
@@ -125,8 +125,8 @@ class CryptoBotService:
# По документации CryptoBot, ключ ВСЕГДА SHA256 от API токена
token = self.api_token
if not token:
logger.warning('CryptoBot API token не настроен, пропуск проверки подписи')
return True
logger.error('CryptoBot API token не настроен, отклоняем webhook')
return False
try:
secret_hash = hashlib.sha256(token.encode()).digest()
+2 -2
View File
@@ -155,8 +155,8 @@ class HeleketService:
def verify_webhook_signature(self, payload: dict[str, Any]) -> bool:
if not self.is_configured:
logger.warning('Heleket сервис не настроен, подпись пропускается')
return True
logger.error('Heleket сервис не настроен, отклоняем webhook')
return False
if not isinstance(payload, dict):
logger.error('Heleket webhook payload не dict', payload=payload)
+3 -1
View File
@@ -2,6 +2,8 @@
Обработчики админ-панели для управления черным списком
"""
import html
import structlog
from aiogram import types
from aiogram.filters import StateFilter
@@ -147,7 +149,7 @@ async def show_blacklist_users(callback: types.CallbackQuery, db_user: User, sta
# Показываем первые 20 записей
for i, (tg_id, username, reason) in enumerate(blacklist_users[:20], 1):
text += f'{i}. <code>{tg_id}</code> {username or ""}{reason}\n'
text += f'{i}. <code>{tg_id}</code> {html.escape(username or "")}{html.escape(reason or "")}\n'
if len(blacklist_users) > 20:
text += f'\n... и еще {len(blacklist_users) - 20} записей'
+2 -1
View File
@@ -5,6 +5,7 @@
и выполнять очистку БД и панели Remnawave.
"""
import html
from datetime import UTC, datetime
from enum import Enum
from typing import Any
@@ -437,7 +438,7 @@ async def show_blocked_list(
name = user_data.get('full_name') or user_data.get('username') or 'Без имени'
telegram_id = user_data.get('telegram_id', '?')
text += BlockedUsersText.BLOCKED_USER_ROW.value.format(
name=name,
name=html.escape(name),
telegram_id=telegram_id,
)
+1 -1
View File
@@ -1906,7 +1906,7 @@ async def test_payment_provider(
return
amount_kopeks = 10 * 100
description = (settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),)
description = settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id)
payment_result = await payment_service.create_yookassa_payment(
db=db,
user_id=db_user.id,
+8 -7
View File
@@ -1,3 +1,4 @@
import html
import re
import structlog
@@ -67,8 +68,8 @@ def _format_campaign_summary(campaign, texts) -> str:
bonus_info = '❓ Неизвестный тип бонуса'
return (
f'<b>{campaign.name}</b>\n'
f'Стартовый параметр: <code>{campaign.start_parameter}</code>\n'
f'<b>{html.escape(campaign.name)}</b>\n'
f'Стартовый параметр: <code>{html.escape(campaign.start_parameter)}</code>\n'
f'Статус: {status}\n'
f'{bonus_info}\n'
)
@@ -244,7 +245,7 @@ async def show_campaigns_list(
total_balance = sum(r.balance_bonus_kopeks or 0 for r in regs)
status = '🟢' if campaign.is_active else ''
line = (
f'{status} <b>{campaign.name}</b> — <code>{campaign.start_parameter}</code>\n'
f'{status} <b>{html.escape(campaign.name)}</b> — <code>{html.escape(campaign.start_parameter)}</code>\n'
f' Регистраций: {registrations}, баланс: {texts.format_price(total_balance)}'
)
if campaign.is_subscription_bonus:
@@ -383,7 +384,7 @@ async def start_edit_campaign_name(
await callback.message.edit_text(
(
'✏️ <b>Изменение названия кампании</b>\n\n'
f'Текущее название: <b>{campaign.name}</b>\n'
f'Текущее название: <b>{html.escape(campaign.name)}</b>\n'
'Введите новое название (3-100 символов):'
),
reply_markup=types.InlineKeyboardMarkup(
@@ -1183,8 +1184,8 @@ async def confirm_delete_campaign(
text = (
'🗑️ <b>Удаление кампании</b>\n\n'
f'Название: <b>{campaign.name}</b>\n'
f'Параметр: <code>{campaign.start_parameter}</code>\n\n'
f'Название: <b>{html.escape(campaign.name)}</b>\n'
f'Параметр: <code>{html.escape(campaign.start_parameter)}</code>\n\n'
'Вы уверены, что хотите удалить кампанию?'
)
@@ -1591,7 +1592,7 @@ async def select_campaign_tariff(
await state.update_data(campaign_tariff_id=tariff_id, campaign_tariff_name=tariff.name)
await state.set_state(AdminStates.creating_campaign_tariff_days)
await callback.message.edit_text(
f'🎁 Выбран тариф: <b>{tariff.name}</b>\n\n📅 Введите длительность тарифа в днях (1-730):',
f'🎁 Выбран тариф: <b>{html.escape(tariff.name)}</b>\n\n📅 Введите длительность тарифа в днях (1-730):',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_campaigns')]]
),
+16 -15
View File
@@ -1,3 +1,4 @@
import html
import math
from datetime import UTC, datetime, time
from zoneinfo import ZoneInfo
@@ -70,7 +71,7 @@ def _format_contest_summary(contest, texts, tz: ZoneInfo) -> str:
f'Дневная сводка: <b>{summary_times}</b>',
]
if contest.prize_text:
parts.append(texts.t('ADMIN_CONTEST_PRIZE', 'Приз: {prize}').format(prize=contest.prize_text))
parts.append(texts.t('ADMIN_CONTEST_PRIZE', 'Приз: {prize}').format(prize=html.escape(contest.prize_text)))
if contest.last_daily_summary_date:
parts.append(
texts.t('ADMIN_CONTEST_LAST_DAILY', 'Последняя сводка: {date}').format(
@@ -188,7 +189,7 @@ async def list_contests(
lines.append(texts.t('ADMIN_CONTESTS_EMPTY', 'Пока нет созданных конкурсов.'))
else:
for contest in contests:
lines.append(f'• <b>{contest.title}</b> (#{contest.id})')
lines.append(f'• <b>{html.escape(contest.title)}</b> (#{contest.id})')
contest_tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
lines.append(_format_contest_summary(contest, texts, contest_tz))
lines.append('')
@@ -250,21 +251,21 @@ async def show_contest_details(
total_events = await get_contest_events_count(db, contest.id) + virtual_count
lines = [
f'🏆 <b>{contest.title}</b>',
f'🏆 <b>{html.escape(contest.title)}</b>',
_format_contest_summary(contest, texts, tz),
texts.t('ADMIN_CONTEST_TOTAL_EVENTS', 'Зачётов: <b>{count}</b>').format(count=total_events),
]
if contest.description:
lines.append('')
lines.append(contest.description)
lines.append(html.escape(contest.description))
if leaderboard:
lines.append('')
lines.append(texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'))
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
virt_mark = ' 👻' if is_virtual else ''
lines.append(f'{idx}. {name}{virt_mark}{score}')
lines.append(f'{idx}. {html.escape(name)}{virt_mark}{score}')
await callback.message.edit_text(
'\n'.join(lines),
@@ -444,7 +445,7 @@ async def show_leaderboard(
]
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
virt_mark = ' 👻' if is_virtual else ''
lines.append(f'{idx}. {name}{virt_mark}{score}')
lines.append(f'{idx}. {html.escape(name)}{virt_mark}{score}')
await callback.message.edit_text(
'\n'.join(lines),
@@ -690,7 +691,7 @@ async def show_detailed_stats(
# Общее сообщение с основной статистикой
general_lines = [
'📈 <b>Статистика конкурса</b>',
f'🏆 {contest.title}',
f'🏆 {html.escape(contest.title)}',
'',
f'👥 Участников (рефереров): <b>{stats["total_participants"]}</b>',
f'📨 Приглашено рефералов: <b>{stats["total_invited"]}</b>',
@@ -751,7 +752,7 @@ async def show_detailed_stats_page(
for p in page_participants:
lines.extend(
[
f'• <b>{p["full_name"]}</b>',
f'• <b>{html.escape(p["full_name"] or "")}</b>',
f' 📨 Приглашено: {p["total_referrals"]}',
f' 💰 Оплатили: {p["paid_referrals"]}',
f' ❌ Не оплатили: {p["unpaid_referrals"]}',
@@ -828,7 +829,7 @@ async def sync_contest(
lines = [
'✅ <b>Синхронизация завершена!</b>',
'',
f'📊 <b>Конкурс:</b> {contest.title}',
f'📊 <b>Конкурс:</b> {html.escape(contest.title)}',
f'📅 <b>Период:</b> {contest.start_at.strftime("%d.%m.%Y")} - {contest.end_at.strftime("%d.%m.%Y")}',
'🔍 <b>Фильтр транзакций:</b>',
f' <code>{start_str}</code>',
@@ -870,7 +871,7 @@ async def sync_contest(
# Обновляем основное сообщение с новой статистикой
detailed_stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
general_lines = [
f'🏆 <b>{contest.title}</b>',
f'🏆 <b>{html.escape(contest.title)}</b>',
f'📅 Период: {contest.start_at.strftime("%d.%m.%Y")} - {contest.end_at.strftime("%d.%m.%Y")}',
'',
f'👥 Участников (рефереров): <b>{detailed_stats["total_participants"]}</b>',
@@ -927,7 +928,7 @@ async def debug_contest_transactions(
lines = [
'🔍 <b>Отладка транзакций конкурса</b>',
'',
f'📊 <b>Конкурс:</b> {contest.title}',
f'📊 <b>Конкурс:</b> {html.escape(contest.title)}',
'📅 <b>Период фильтрации:</b>',
f' Начало: <code>{debug_data.get("contest_start")}</code>',
f' Конец: <code>{debug_data.get("contest_end")}</code>',
@@ -1002,10 +1003,10 @@ async def show_virtual_participants(
vps = await list_virtual_participants(db, contest_id)
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
lines = [f'👻 <b>Виртуальные участники</b> — {html.escape(contest.title)}', '']
if vps:
for vp in vps:
lines.append(f'{vp.display_name}{vp.referral_count} реф.')
lines.append(f'{html.escape(vp.display_name)}{vp.referral_count} реф.')
else:
lines.append('Пока нет виртуальных участников.')
@@ -1156,10 +1157,10 @@ async def delete_virtual_participant_handler(
vps = await list_virtual_participants(db, contest_id)
contest = await get_referral_contest(db, contest_id)
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
lines = [f'👻 <b>Виртуальные участники</b> — {html.escape(contest.title)}', '']
if vps:
for v in vps:
lines.append(f'{v.display_name}{v.referral_count} реф.')
lines.append(f'{html.escape(v.display_name)}{v.referral_count} реф.')
else:
lines.append('Пока нет виртуальных участников.')
+5 -1
View File
@@ -1,3 +1,5 @@
import html
import structlog
from aiogram import Dispatcher, F, types
from aiogram.filters import Command
@@ -135,6 +137,8 @@ async def show_support_submenu(callback: types.CallbackQuery, db_user: User, db:
# Moderator panel entry (from main menu quick button)
@admin_required
@error_handler
async def show_moderator_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
kb = InlineKeyboardMarkup(
@@ -283,7 +287,7 @@ async def clear_rules_command(message: types.Message, db_user: User, db: AsyncSe
f'📊 <b>Статистика:</b>\n'
f'• Очищено правил: {stats["total_active"]}\n'
f'• Язык: {db_user.language}\n'
f'• Выполнил: {db_user.full_name}\n\n'
f'• Выполнил: {html.escape(db_user.full_name or "")}\n\n'
f'Теперь используются стандартные правила по умолчанию.'
)
+3 -1
View File
@@ -1,3 +1,5 @@
import html
import structlog
from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
@@ -133,7 +135,7 @@ async def process_maintenance_reason(message: types.Message, db_user: User, db:
if success:
response_text = 'Режим техработ включен'
if reason:
response_text += f'\nПричина: {reason}'
response_text += f'\nПричина: {html.escape(reason)}'
else:
response_text = 'Ошибка включения режима техработ'
+2 -2
View File
@@ -643,7 +643,7 @@ async def show_messages_history(callback: types.CallbackQuery, db_user: User, db
{status_emoji} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
🎯 Аудитория: {get_target_name(broadcast.target_type)}
👤 Админ: {broadcast.admin_name}
👤 Админ: {html.escape(broadcast.admin_name or '')}
📝 Сообщение: {message_preview}
"""
@@ -1477,7 +1477,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
f'• Не доставлено: {failed_count}\n'
f'• Всего пользователей: {total_users_count}\n'
f'• Успешность: {success_rate}%{media_info}\n\n'
f'<b>Администратор:</b> {admin_name}'
f'<b>Администратор:</b> {html.escape(admin_name)}'
)
back_keyboard = types.InlineKeyboardMarkup(
+2 -1
View File
@@ -1,4 +1,5 @@
import asyncio
import html
from datetime import UTC, date, datetime, timedelta
import structlog
@@ -741,7 +742,7 @@ async def traffic_check_callback(callback: CallbackQuery):
if violations:
text += '\n⚠️ <b>Превышения дельты:</b>\n'
for v in violations[:10]:
name = v.full_name or v.user_uuid[:8]
name = html.escape(v.full_name or '') or v.user_uuid[:8]
text += f'{name}: +{v.used_traffic_gb:.1f} ГБ\n'
if len(violations) > 10:
text += f'... и ещё {len(violations) - 10}\n'
+1 -1
View File
@@ -886,7 +886,7 @@ async def _render_poll_details(poll: Poll, language: str) -> str:
texts = get_texts(language)
lines = [f'🗳️ <b>{html.escape(poll.title)}</b>']
if poll.description:
lines.append(poll.description)
lines.append(html.escape(poll.description))
lines.append(_format_reward_text(poll, language))
lines.append(texts.t('ADMIN_POLLS_QUESTIONS_COUNT', 'Вопросов: {count}').format(count=len(poll.questions)))
+20 -18
View File
@@ -1,3 +1,4 @@
import html
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
import structlog
@@ -293,7 +294,7 @@ def _build_edit_menu_content(
header = texts.t(
'ADMIN_PROMO_GROUP_EDIT_MENU_TITLE',
'✏️ Настройки промогруппы «{name}»',
).format(name=group.name)
).format(name=html.escape(group.name))
lines = [header]
lines.extend(_format_discount_lines(texts, group))
@@ -468,7 +469,7 @@ async def show_promo_groups_menu(
'ADMIN_PROMO_GROUPS_MEMBERS_COUNT',
'Участников: {count}',
).format(count=member_count)
lines.append(f'{icon} <b>{group.name}</b>{default_suffix}{members_label}')
lines.append(f'{icon} <b>{html.escape(group.name)}</b>{default_suffix}{members_label}')
keyboard_rows.append(
[
types.InlineKeyboardButton(
@@ -524,7 +525,7 @@ async def show_promo_group_details(
texts.t(
'ADMIN_PROMO_GROUP_DETAILS_TITLE',
'💳 <b>Промогруппа:</b> {name}',
).format(name=group.name)
).format(name=html.escape(group.name))
]
lines.extend(_format_discount_lines(texts, group))
lines.append(_format_auto_assign_line(texts, group))
@@ -802,7 +803,7 @@ async def process_create_group_auto_assign(
await state.clear()
await message.answer(
texts.t('ADMIN_PROMO_GROUP_CREATED', 'Промогруппа «{name}» создана.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_CREATED', 'Промогруппа «{name}» создана.').format(name=html.escape(group.name)),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
@@ -875,7 +876,7 @@ async def prompt_edit_promo_group_field(
prompt = texts.t(
'ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT',
'Введите новое название промогруппы (текущее: {name}):',
).format(name=group.name)
).format(name=html.escape(group.name))
elif field == 'priority':
await state.set_state(AdminStates.editing_promo_group_priority)
prompt = texts.t(
@@ -951,7 +952,7 @@ async def process_edit_group_name(
texts,
group,
data.get('language', db_user.language),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
)
@@ -993,7 +994,7 @@ async def process_edit_group_priority(
texts,
group,
data.get('language', db_user.language),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
)
@@ -1028,7 +1029,7 @@ async def process_edit_group_traffic(
texts,
group,
data.get('language', db_user.language),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
)
@@ -1063,7 +1064,7 @@ async def process_edit_group_servers(
texts,
group,
data.get('language', db_user.language),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
)
@@ -1098,7 +1099,7 @@ async def process_edit_group_devices(
texts,
group,
data.get('language', db_user.language),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
)
@@ -1138,7 +1139,7 @@ async def process_edit_group_period_discounts(
texts,
group,
data.get('language', db_user.language),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
)
@@ -1182,7 +1183,7 @@ async def process_edit_group_auto_assign(
texts,
group,
data.get('language', db_user.language),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
)
@@ -1212,19 +1213,20 @@ async def show_promo_group_members(
title = texts.t(
'ADMIN_PROMO_GROUP_MEMBERS_TITLE',
'👥 Участники группы {name}',
).format(name=group.name)
).format(name=html.escape(group.name))
if not members:
body = texts.t('ADMIN_PROMO_GROUP_MEMBERS_EMPTY', 'В этой группе пока нет участников.')
else:
lines = []
for index, user in enumerate(members, start=offset + 1):
username = f'@{user.username}' if user.username else ''
username = f'@{html.escape(user.username)}' if user.username else ''
safe_name = html.escape(user.full_name or '')
if user.telegram_id:
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
user_link = f'<a href="tg://user?id={user.telegram_id}">{safe_name}</a>'
tg_display = str(user.telegram_id)
else:
user_link = f'<b>{user.full_name}</b>'
user_link = f'<b>{safe_name}</b>'
tg_display = user.email or f'#{user.id}'
lines.append(f'{index}. {user_link} (ID {user.id}, {username}, TG {tg_display})')
body = '\n'.join(lines)
@@ -1273,7 +1275,7 @@ async def request_delete_promo_group(
confirm_text = texts.t(
'ADMIN_PROMO_GROUP_DELETE_CONFIRM',
'Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.',
).format(name=group.name)
).format(name=html.escape(group.name))
await callback.message.edit_text(
confirm_text,
@@ -1308,7 +1310,7 @@ async def delete_promo_group_confirmed(
return
await callback.message.edit_text(
texts.t('ADMIN_PROMO_GROUP_DELETED', 'Промогруппа «{name}» удалена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_DELETED', 'Промогруппа «{name}» удалена.').format(name=html.escape(group.name)),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_promo_groups')]]
),
+1 -1
View File
@@ -677,7 +677,7 @@ def _describe_offer(
label = texts.t(config.get('label_key', ''), config.get('default_label', template.offer_type))
icon = config.get('icon', '📨')
lines = [f'{icon} <b>{template.name}</b>', '']
lines = [f'{icon} <b>{html.escape(template.name)}</b>', '']
lines.append(texts.t('ADMIN_PROMO_OFFER_TYPE', 'Тип: {label}').format(label=label))
lines.append(texts.t('ADMIN_PROMO_OFFER_VALID', 'Срок действия: {hours} ч').format(hours=template.valid_hours))
+9 -6
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime, timedelta
import structlog
@@ -94,7 +95,7 @@ async def show_promocodes_list(callback: types.CallbackQuery, db_user: User, db:
text += f'📅 Дней: {promo.subscription_days}\n'
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
text += f'🏷️ Промогруппа: {promo.promo_group.name}\n'
text += f'🏷️ Промогруппа: {html.escape(promo.promo_group.name)}\n'
elif promo.type == PromoCodeType.DISCOUNT.value:
discount_hours = promo.subscription_days
if discount_hours > 0:
@@ -170,7 +171,7 @@ async def show_promocode_management(callback: types.CallbackQuery, db_user: User
text += f'📅 <b>Дней:</b> {promo.subscription_days}\n'
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
text += f'🏷️ <b>Промогруппа:</b> {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n'
text += f'🏷️ <b>Промогруппа:</b> {html.escape(promo.promo_group.name)} (приоритет: {promo.promo_group.priority})\n'
elif promo.promo_group_id:
text += f'🏷️ <b>Промогруппа ID:</b> {promo.promo_group_id} (не найдена)\n'
elif promo.type == PromoCodeType.DISCOUNT.value:
@@ -472,7 +473,9 @@ async def process_promocode_code(message: types.Message, db_user: User, state: F
text = f'🏷️ <b>Промокод:</b> <code>{code}</code>\n\nВыберите промогруппу для назначения:\n\n'
for promo_group, user_count in groups_with_counts:
text += f'{promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n'
text += (
f'{html.escape(promo_group.name)} (приоритет: {promo_group.priority}, пользователей: {user_count})\n'
)
keyboard.append(
[
types.InlineKeyboardButton(
@@ -509,7 +512,7 @@ async def process_promo_group_selection(
await callback.message.edit_text(
f'🏷️ <b>Промокод для промогруппы</b>\n\n'
f'Промогруппа: {promo_group.name}\n'
f'Промогруппа: {html.escape(promo_group.name)}\n'
f'Приоритет: {promo_group.priority}\n\n'
f'📊 Введите количество использований промокода (или 0 для безлимита):'
)
@@ -1039,9 +1042,9 @@ async def show_promocode_stats(callback: types.CallbackQuery, db_user: User, db:
use_date = format_datetime(use.used_at)
if hasattr(use, 'user_username') and use.user_username:
user_display = f'@{use.user_username}'
user_display = f'@{html.escape(use.user_username)}'
elif hasattr(use, 'user_full_name') and use.user_full_name:
user_display = use.user_full_name
user_display = html.escape(use.user_full_name)
elif hasattr(use, 'user_telegram_id'):
user_display = f'ID{use.user_telegram_id}'
else:
+64 -34
View File
@@ -1,4 +1,5 @@
import asyncio
import html
import json
from datetime import UTC, datetime, timedelta
@@ -218,9 +219,9 @@ async def _show_top_referrers_filtered(callback: types.CallbackQuery, db: AsyncS
id_display = telegram_id or user_email or f'#{user_id}' if user_id else 'N/A'
if username:
display_text = f'@{username} (ID{id_display})'
display_text = f'@{html.escape(username)} (ID{id_display})'
elif display_name and display_name != f'ID{id_display}':
display_text = f'{display_name} (ID{id_display})'
display_text = f'{html.escape(display_name)} (ID{id_display})'
else:
display_text = f'ID{id_display}'
@@ -312,7 +313,7 @@ async def show_pending_withdrawal_requests(callback: types.CallbackQuery, db_use
for req in requests[:10]:
user = await get_user_by_id(db, req.user_id)
user_name = user.full_name if user else 'Неизвестно'
user_name = html.escape(user.full_name) if user and user.full_name else 'Неизвестно'
user_tg_id = user.telegram_id if user else 'N/A'
risk_emoji = (
@@ -359,7 +360,7 @@ async def view_withdrawal_request(callback: types.CallbackQuery, db_user: User,
return
user = await get_user_by_id(db, request.user_id)
user_name = user.full_name if user else 'Неизвестно'
user_name = html.escape(user.full_name) if user and user.full_name else 'Неизвестно'
user_tg_id = (user.telegram_id or user.email or f'#{user.id}') if user else 'N/A'
analysis = json.loads(request.risk_analysis) if request.risk_analysis else {}
@@ -381,7 +382,7 @@ async def view_withdrawal_request(callback: types.CallbackQuery, db_user: User,
📊 Статус: {status_text}
💳 <b>Реквизиты:</b>
<code>{request.payment_details}</code>
<code>{html.escape(request.payment_details or '')}</code>
📅 Создана: {request.created_at.strftime('%d.%m.%Y %H:%M')}
@@ -639,7 +640,7 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
await message.answer(
f'✅ <b>Тестовое начисление создано!</b>\n\n'
f'👤 Пользователь: {target_user.full_name or "Без имени"}\n'
f'👤 Пользователь: {html.escape(target_user.full_name) if target_user.full_name else "Без имени"}\n'
f'🆔 ID: <code>{target_telegram_id}</code>\n'
f'💰 Сумма: <b>{amount_rubles:.0f}₽</b>\n'
f'💳 Новый баланс: <b>{target_user.balance_kopeks / 100:.0f}₽</b>\n\n'
@@ -736,14 +737,17 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS
status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
# Имя или ID
user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
if lost.username:
user_name = f'@{lost.username}'
user_name = f'@{html.escape(lost.username)}'
elif lost.full_name:
user_name = html.escape(lost.full_name)
else:
user_name = f'ID{lost.telegram_id}'
# Ожидаемый реферер
referrer_info = ''
if lost.expected_referrer_name:
referrer_info = f'{lost.expected_referrer_name}'
referrer_info = f'{html.escape(lost.expected_referrer_name)}'
elif lost.expected_referrer_id:
referrer_info = f' → ID{lost.expected_referrer_id}'
@@ -751,7 +755,7 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS
time_str = lost.click_time.strftime('%H:%M')
text += f'{i}. {user_name}{status}\n'
text += f' <code>{lost.referral_code}</code>{referrer_info} ({time_str})\n'
text += f' <code>{html.escape(lost.referral_code)}</code>{referrer_info} ({time_str})\n'
if len(report.lost_referrals) > 15:
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
@@ -872,16 +876,22 @@ async def preview_referral_fixes(callback: types.CallbackQuery, db_user: User, d
# Показываем первые 10 деталей
for i, detail in enumerate(fix_report.details[:10], 1):
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
if detail.username:
user_name = f'@{detail.username}'
user_name = f'@{html.escape(detail.username)}'
elif detail.full_name:
user_name = html.escape(detail.full_name)
else:
user_name = f'ID{detail.telegram_id}'
if detail.error:
text += f'{i}. {user_name} — ❌ {detail.error}\n'
text += f'{i}. {user_name} — ❌ {html.escape(str(detail.error))}\n'
else:
text += f'{i}. {user_name}\n'
if detail.referred_by_set:
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
referrer_display = (
html.escape(detail.referrer_name) if detail.referrer_name else f'ID{detail.referrer_id}'
)
text += f' • Реферер: {referrer_display}\n'
if detail.had_first_topup:
text += f' • Первое пополнение: {settings.format_price(detail.topup_amount_kopeks)}\n'
if detail.bonus_to_referral_kopeks > 0:
@@ -967,13 +977,19 @@ async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db:
for detail in fix_report.details:
if not detail.error and success_count < 10:
success_count += 1
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
if detail.username:
user_name = f'@{user_name}'
user_name = f'@{html.escape(detail.username)}'
elif detail.full_name:
user_name = html.escape(detail.full_name)
else:
user_name = f'ID{detail.telegram_id}'
text += f'{success_count}. {user_name}\n'
if detail.referred_by_set:
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
referrer_display = (
html.escape(detail.referrer_name) if detail.referrer_name else f'ID{detail.referrer_id}'
)
text += f' • Реферер: {referrer_display}\n'
if detail.bonus_to_referral_kopeks > 0:
text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n'
if detail.bonus_to_referrer_kopeks > 0:
@@ -989,8 +1005,13 @@ async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db:
for detail in fix_report.details:
if detail.error and error_count < 5:
error_count += 1
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
text += f'{user_name}: {detail.error}\n'
if detail.username:
user_name = f'@{html.escape(detail.username)}'
elif detail.full_name:
user_name = html.escape(detail.full_name)
else:
user_name = f'ID{detail.telegram_id}'
text += f'{user_name}: {html.escape(str(detail.error))}\n'
if fix_report.errors > 5:
text += f'<i>... и ещё {fix_report.errors - 5} ошибок</i>\n'
@@ -1055,8 +1076,12 @@ async def check_missing_bonuses(callback: types.CallbackQuery, db_user: User, db
👤 <b>Список ({len(report.missing_bonuses)} чел.):</b>
"""
for i, mb in enumerate(report.missing_bonuses[:15], 1):
referral_name = mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id)
referrer_name = mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id)
referral_name = html.escape(
mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id)
)
referrer_name = html.escape(
mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id)
)
text += f'\n{i}. <b>{referral_name}</b>'
text += f'\n └ Пригласил: {referrer_name}'
text += f'\n └ Пополнение: {mb.first_topup_amount_kopeks / 100:.0f}'
@@ -1191,9 +1216,9 @@ async def sync_referrals_with_contest(
total_created += stats.get('created', 0)
total_updated += stats.get('updated', 0)
total_skipped += stats.get('skipped', 0)
contest_results.append(f'{contest.title}: +{stats.get("created", 0)} новых')
contest_results.append(f'{html.escape(contest.title)}: +{stats.get("created", 0)} новых')
else:
contest_results.append(f'{contest.title}: ошибка')
contest_results.append(f'{html.escape(contest.title)}: ошибка')
text = f"""
🏆 <b>Синхронизация с конкурсами завершена!</b>
@@ -1275,7 +1300,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
if file_ext not in ['.log', '.txt']:
await message.answer(
f'❌ Неверный формат файла: {file_ext}\n\nПоддерживаются только текстовые файлы (.log, .txt)',
f'❌ Неверный формат файла: {html.escape(file_ext)}\n\nПоддерживаются только текстовые файлы (.log, .txt)',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
@@ -1299,7 +1324,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
# Информируем о начале загрузки
status_message = await message.answer(
f'📥 Загружаю файл {file_name} ({message.document.file_size / 1024 / 1024:.1f} MB)...'
f'📥 Загружаю файл {html.escape(file_name)} ({message.document.file_size / 1024 / 1024:.1f} MB)...'
)
temp_file_path = None
@@ -1316,7 +1341,9 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
logger.info('📥 Файл загружен: ( байт)', temp_file_path=temp_file_path, file_size=message.document.file_size)
# Обновляем статус
await status_message.edit_text(f'🔍 Анализирую файл {file_name}...\n\nЭто может занять некоторое время.')
await status_message.edit_text(
f'🔍 Анализирую файл {html.escape(file_name)}...\n\nЭто может занять некоторое время.'
)
# Анализируем файл
from app.services.referral_diagnostics_service import referral_diagnostics_service
@@ -1325,7 +1352,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
# Формируем отчёт
text = f"""
🔍 <b>Анализ лог-файла: {file_name}</b>
🔍 <b>Анализ лог-файла: {html.escape(file_name)}</b>
<b>📊 Статистика переходов:</b>
Всего кликов по реф-ссылкам: {report.total_ref_clicks}
@@ -1348,14 +1375,17 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
# Имя или ID
user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
if lost.username:
user_name = f'@{lost.username}'
user_name = f'@{html.escape(lost.username)}'
elif lost.full_name:
user_name = html.escape(lost.full_name)
else:
user_name = f'ID{lost.telegram_id}'
# Ожидаемый реферер
referrer_info = ''
if lost.expected_referrer_name:
referrer_info = f'{lost.expected_referrer_name}'
referrer_info = f'{html.escape(lost.expected_referrer_name)}'
elif lost.expected_referrer_id:
referrer_info = f' → ID{lost.expected_referrer_id}'
@@ -1363,7 +1393,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
time_str = lost.click_time.strftime('%d.%m.%Y %H:%M')
text += f'{i}. {user_name}{status}\n'
text += f' <code>{lost.referral_code}</code>{referrer_info} ({time_str})\n'
text += f' <code>{html.escape(lost.referral_code)}</code>{referrer_info} ({time_str})\n'
if len(report.lost_referrals) > 15:
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
@@ -1408,8 +1438,8 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
try:
await status_message.edit_text(
f'❌ <b>Ошибка при анализе файла</b>\n\n'
f'Файл: {file_name}\n'
f'Ошибка: {e!s}\n\n'
f'Файл: {html.escape(file_name)}\n'
f'Ошибка: {html.escape(str(e))}\n\n'
f'Проверьте, что файл является текстовым логом бота.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
@@ -1428,7 +1458,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
)
except:
await message.answer(
f'❌ Ошибка при анализе файла: {e!s}',
f'❌ Ошибка при анализе файла: {html.escape(str(e))}',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_referral_diagnostics')]
+22 -20
View File
@@ -1,5 +1,7 @@
"""Управление тарифами в админ-панели."""
import html
import structlog
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
@@ -317,7 +319,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
price_block = f'<b>Цены:</b>\n{prices_display}'
tariff_type = '📅 Периодный'
return f"""📦 <b>Тариф: {tariff.name}</b>
return f"""📦 <b>Тариф: {html.escape(tariff.name)}</b>
{status} | {tariff_type}
🎚 Уровень: {tariff.tier_level}
@@ -343,7 +345,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
📊 Подписок на тарифе: {subs_count}
{f'📝 {tariff.description}' if tariff.description else ''}"""
{f'📝 {html.escape(tariff.description)}' if tariff.description else ''}"""
@admin_required
@@ -591,7 +593,7 @@ async def start_edit_daily_price(
await callback.message.edit_text(
f'💰 <b>Редактирование суточной цены</b>\n\n'
f'Тариф: {tariff.name}\n'
f'Тариф: {html.escape(tariff.name)}\n'
f'Текущая цена: {format_price_kopeks(current_price)}/день\n\n'
'Введите новую цену за день в рублях.\n'
'Пример: <code>50</code> или <code>99.90</code>',
@@ -1011,7 +1013,7 @@ async def start_edit_tariff_name(
await state.update_data(tariff_id=tariff_id, language=db_user.language)
await callback.message.edit_text(
f'✏️ <b>Редактирование названия</b>\n\nТекущее название: <b>{tariff.name}</b>\n\nВведите новое название:',
f'✏️ <b>Редактирование названия</b>\n\nТекущее название: <b>{html.escape(tariff.name)}</b>\n\nВведите новое название:',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]]
),
@@ -1801,7 +1803,7 @@ async def start_edit_tariff_traffic_topup(
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')])
await callback.message.edit_text(
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
f'Статус: {status}\n\n'
f'<b>Пакеты:</b>\n{packages_display}\n\n'
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
@@ -1887,7 +1889,7 @@ async def toggle_tariff_traffic_topup(
try:
await callback.message.edit_text(
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
f'Статус: {status}\n\n'
f'<b>Пакеты:</b>\n{packages_display}\n\n'
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
@@ -1931,7 +1933,7 @@ async def start_edit_traffic_topup_packages(
await callback.message.edit_text(
f'📦 <b>Настройка пакетов докупки трафика</b>\n\n'
f'Тариф: <b>{tariff.name}</b>\n\n'
f'Тариф: <b>{html.escape(tariff.name)}</b>\n\n'
f'<b>Текущие пакеты:</b>\n{packages_display}\n\n'
'Введите пакеты в формате:\n'
f'<code>{current_packages}</code>\n\n'
@@ -2010,7 +2012,7 @@ async def process_edit_traffic_topup_packages(
await message.answer(
f'✅ <b>Пакеты обновлены!</b>\n\n'
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
f'Статус: ✅ Включено\n\n'
f'<b>Пакеты:</b>\n{packages_display}\n\n'
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
@@ -2051,7 +2053,7 @@ async def start_edit_max_topup_traffic(
await callback.message.edit_text(
f'📊 <b>Максимальный лимит трафика</b>\n\n'
f'Тариф: <b>{tariff.name}</b>\n'
f'Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'Текущий лимит: <b>{current_display}</b>\n\n'
f'Введите максимальный общий объем трафика (в ГБ), который может быть на подписке после всех докупок.\n\n'
f'• Например, если тариф дает 100 ГБ и лимит 200 ГБ — пользователь сможет докупить еще 100 ГБ\n'
@@ -2127,7 +2129,7 @@ async def process_edit_max_topup_traffic(
await message.answer(
f'✅ <b>Лимит обновлен!</b>\n\n'
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
f'Статус: ✅ Включено\n\n'
f'<b>Пакеты:</b>\n{packages_display}\n\n'
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
@@ -2163,7 +2165,7 @@ async def confirm_delete_tariff(
warning = f'\n\n⚠️ <b>Внимание!</b> На этом тарифе {subs_count} подписок.\nОни будут отвязаны от тарифа.'
await callback.message.edit_text(
f'🗑️ <b>Удаление тарифа</b>\n\nВы действительно хотите удалить тариф <b>{tariff.name}</b>?{warning}',
f'🗑️ <b>Удаление тарифа</b>\n\nВы действительно хотите удалить тариф <b>{html.escape(tariff.name)}</b>?{warning}',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
@@ -2278,7 +2280,7 @@ async def start_edit_tariff_squads(
selected_count = len(current_squads)
await callback.message.edit_text(
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Выбрано: {selected_count} из {len(squads)}\n\n'
'Если не выбран ни один сервер - доступны все.\n'
'Нажмите на сервер для выбора/отмены:',
@@ -2341,7 +2343,7 @@ async def toggle_tariff_squad(
try:
await callback.message.edit_text(
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Выбрано: {len(current_squads)} из {len(squads)}\n\n'
'Если не выбран ни один сервер - доступны все.\n'
'Нажмите на сервер для выбора/отмены:',
@@ -2406,7 +2408,7 @@ async def clear_tariff_squads(
try:
await callback.message.edit_text(
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Выбрано: 0 из {len(squads)}\n\n'
'Если не выбран ни один сервер - доступны все.\n'
'Нажмите на сервер для выбора/отмены:',
@@ -2470,7 +2472,7 @@ async def select_all_tariff_squads(
try:
await callback.message.edit_text(
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Выбрано: {len(squads)} из {len(squads)}\n\n'
'Если не выбран ни один сервер - доступны все.\n'
'Нажмите на сервер для выбора/отмены:',
@@ -2540,7 +2542,7 @@ async def start_edit_tariff_promo_groups(
selected_count = len(current_groups)
await callback.message.edit_text(
f'👥 <b>Промогруппы для тарифа «{tariff.name}»</b>\n\n'
f'👥 <b>Промогруппы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Выбрано: {selected_count}\n\n'
'Если не выбрана ни одна группа - тариф доступен всем.\n'
'Выберите группы, которым доступен этот тариф:',
@@ -2608,7 +2610,7 @@ async def toggle_tariff_promo_group(
try:
await callback.message.edit_text(
f'👥 <b>Промогруппы для тарифа «{tariff.name}»</b>\n\n'
f'👥 <b>Промогруппы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Выбрано: {len(current_groups)}\n\n'
'Если не выбрана ни одна группа - тариф доступен всем.\n'
'Выберите группы, которым доступен этот тариф:',
@@ -2665,7 +2667,7 @@ async def clear_tariff_promo_groups(
try:
await callback.message.edit_text(
f'👥 <b>Промогруппы для тарифа «{tariff.name}»</b>\n\n'
f'👥 <b>Промогруппы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Выбрано: 0\n\n'
'Если не выбрана ни одна группа - тариф доступен всем.\n'
'Выберите группы, которым доступен этот тариф:',
@@ -2731,7 +2733,7 @@ async def start_edit_traffic_reset_mode(
current_mode = getattr(tariff, 'traffic_reset_mode', None)
await callback.message.edit_text(
f'🔄 <b>Режим сброса трафика для тарифа «{tariff.name}»</b>\n\n'
f'🔄 <b>Режим сброса трафика для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Текущий режим: {_format_traffic_reset_mode(current_mode)}\n\n'
'Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n'
'• <b>Глобальная настройка</b> — использовать значение из конфига бота\n'
@@ -2775,7 +2777,7 @@ async def set_traffic_reset_mode(
# Обновляем клавиатуру
await callback.message.edit_text(
f'🔄 <b>Режим сброса трафика для тарифа «{tariff.name}»</b>\n\n'
f'🔄 <b>Режим сброса трафика для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Текущий режим: {mode_display}\n\n'
'Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n'
'• <b>Глобальная настройка</b> — использовать значение из конфига бота\n'
+10 -8
View File
@@ -232,8 +232,10 @@ async def view_admin_ticket(
TicketStatus.PENDING.value: texts.t('TICKET_STATUS_PENDING', 'В ожидании'),
}.get(ticket.status, ticket.status)
user_name = ticket.user.full_name if ticket.user else 'Unknown'
telegram_id_display = (ticket.user.telegram_id or ticket.user.email or f'#{ticket.user.id}') if ticket.user else ''
user_name = html.escape(ticket.user.full_name) if ticket.user else 'Unknown'
telegram_id_display = (
html.escape(str(ticket.user.telegram_id or ticket.user.email or f'#{ticket.user.id}')) if ticket.user else ''
)
username_value = ticket.user.username if ticket.user else None
id_label = 'Telegram ID' if (ticket.user and ticket.user.telegram_id) else 'ID'
@@ -245,7 +247,7 @@ async def view_admin_ticket(
header += f'📱 Username: @{safe_username}\n'
else:
header += '📱 Username: отсутствует\n'
header += f'📝 Заголовок: {ticket.title}\n'
header += f'📝 Заголовок: {html.escape(ticket.title)}\n'
header += f'📊 Статус: {ticket.status_emoji} {status_text}\n'
header += f'📅 Создан: {ticket.created_at.strftime("%d.%m.%Y %H:%M")}\n\n'
@@ -261,7 +263,7 @@ async def view_admin_ticket(
message_blocks.append(f'💬 Сообщения ({len(ticket.messages)}):\n\n')
for msg in ticket.messages:
sender = '👤 Пользователь' if msg.is_user_message else '🛠️ Поддержка'
block = f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n{msg.message_text}\n\n'
block = f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n{html.escape(msg.message_text)}\n\n'
if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo':
block += '📎 Вложение: фото\n\n'
message_blocks.append(block)
@@ -801,10 +803,10 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo
TicketStatus.CLOSED.value: texts.t('TICKET_STATUS_CLOSED', 'Закрыт'),
TicketStatus.PENDING.value: texts.t('TICKET_STATUS_PENDING', 'В ожидании'),
}.get(updated.status, updated.status)
user_name = updated.user.full_name if updated.user else 'Unknown'
user_name = html.escape(updated.user.full_name) if updated.user else 'Unknown'
ticket_text = f'🎫 Тикет #{updated.id}\n\n'
ticket_text += f'👤 Пользователь: {user_name}\n'
ticket_text += f'📝 Заголовок: {updated.title}\n'
ticket_text += f'📝 Заголовок: {html.escape(updated.title)}\n'
ticket_text += f'📊 Статус: {updated.status_emoji} {status_text}\n'
ticket_text += f'📅 Создан: {updated.created_at.strftime("%d.%m.%Y %H:%M")}\n'
ticket_text += f'🔄 Обновлен: {updated.updated_at.strftime("%d.%m.%Y %H:%M")}\n'
@@ -823,7 +825,7 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo
ticket_text += f'🔗 Чат по ID: <a href="{chat_link}">{chat_link}</a>\n'
elif updated.user:
# Email-only user
user_id_display = updated.user.email or f'#{updated.user.id}'
user_id_display = html.escape(str(updated.user.email or f'#{updated.user.id}'))
ticket_text += f'🆔 ID: <code>{user_id_display}</code>\n'
ticket_text += '📧 Тип: Email-пользователь\n'
ticket_text += '\n'
@@ -837,7 +839,7 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo
for msg in updated.messages:
sender = '👤 Пользователь' if msg.is_user_message else '🛠️ Поддержка'
ticket_text += f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n'
ticket_text += f'{msg.message_text}\n\n'
ticket_text += f'{html.escape(msg.message_text)}\n\n'
if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo':
ticket_text += '📎 Вложение: фото\n\n'
+61 -109
View File
@@ -47,6 +47,7 @@ from app.services.user_service import UserService
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_time_ago
from app.utils.formatting import user_html_link
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
@@ -822,12 +823,8 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
subscription = profile['subscription']
text = '📱 <b>Подписка и настройки пользователя</b>\n\n'
if user.telegram_id:
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
user_id_display = user.telegram_id
else:
user_link = f'<b>{user.full_name}</b>'
user_id_display = user.email or f'#{user.id}'
user_link = user_html_link(user)
user_id_display = user.telegram_id or user.email or f'#{user.id}'
text += f'👤 {user_link} (ID: <code>{user_id_display}</code>)\n\n'
keyboard = []
@@ -849,7 +846,7 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
if subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
text += f'<b>Тариф:</b> 📦 {tariff.name}\n'
text += f'<b>Тариф:</b> 📦 {html.escape(tariff.name)}\n'
else:
text += f'<b>Тариф:</b> ID {subscription.tariff_id} (удалён)\n'
@@ -961,12 +958,8 @@ async def show_user_transactions(callback: types.CallbackQuery, db_user: User, d
transactions = await get_user_transactions(db, user_id, limit=10)
text = '💳 <b>Транзакции пользователя</b>\n\n'
if user.telegram_id:
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
user_id_display = user.telegram_id
else:
user_link = f'<b>{user.full_name}</b>'
user_id_display = user.email or f'#{user.id}'
user_link = user_html_link(user)
user_id_display = user.telegram_id or user.email or f'#{user.id}'
text += f'👤 {user_link} (ID: <code>{user_id_display}</code>)\n'
text += f'💰 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n'
@@ -976,7 +969,7 @@ async def show_user_transactions(callback: types.CallbackQuery, db_user: User, d
for transaction in transactions:
type_emoji = '📈' if transaction.amount_kopeks > 0 else '📉'
text += f'{type_emoji} {settings.format_price(abs(transaction.amount_kopeks))}\n'
text += f'📋 {transaction.description}\n'
text += f'📋 {html.escape(transaction.description or "")}\n'
text += f'📅 {format_datetime(transaction.created_at)}\n\n'
else:
text += '📭 <b>Транзакции отсутствуют</b>'
@@ -1057,7 +1050,7 @@ async def process_user_search(message: types.Message, db_user: User, state: FSMC
if not search_results['users']:
await message.answer(
f"🔍 По запросу '<b>{query}</b>' ничего не найдено",
f"🔍 По запросу '<b>{html.escape(query)}</b>' ничего не найдено",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')]]
),
@@ -1065,7 +1058,7 @@ async def process_user_search(message: types.Message, db_user: User, state: FSMC
await state.clear()
return
text = f"🔍 <b>Результаты поиска:</b> '{query}'\n\n"
text = f"🔍 <b>Результаты поиска:</b> '{html.escape(query)}'\n\n"
text += 'Выберите пользователя:'
keyboard = []
@@ -1175,7 +1168,7 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
sections = [
texts.ADMIN_USER_MANAGEMENT_PROFILE.format(
name=user.full_name,
name=html.escape(user.full_name),
telegram_id=user.telegram_id,
username=username_display,
status=status_text,
@@ -1223,11 +1216,11 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
texts.t(
'ADMIN_USER_PROMO_GROUPS_PRIMARY',
'⭐ Основная: {name} (Priority: {priority})',
).format(name=primary_group.name, priority=getattr(primary_group, 'priority', 0))
).format(name=html.escape(primary_group.name), priority=getattr(primary_group, 'priority', 0))
)
sections.append(
texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP.format(
name=primary_group.name,
name=html.escape(primary_group.name),
server_discount=primary_group.server_discount_percent,
traffic_discount=primary_group.traffic_discount_percent,
device_discount=primary_group.device_discount_percent,
@@ -1249,7 +1242,7 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
)
)
for group in additional_groups:
sections.append(f'{group.name} (Priority: {getattr(group, "priority", 0)})')
sections.append(f'{html.escape(group.name)} (Priority: {getattr(group, "priority", 0)})')
else:
sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE)
@@ -1264,7 +1257,7 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
restriction_lines.append(' • 🚫 Продление/покупка запрещена')
restriction_reason = getattr(user, 'restriction_reason', None)
if restriction_reason:
restriction_lines.append(f' 📝 Причина: {restriction_reason}')
restriction_lines.append(f' 📝 Причина: {html.escape(restriction_reason)}')
sections.append('\n'.join(restriction_lines))
text = '\n\n'.join(sections)
@@ -1321,7 +1314,7 @@ async def _build_user_referrals_view(
'ADMIN_USER_REFERRALS_SUMMARY',
'👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}',
).format(
name=user.full_name,
name=html.escape(user.full_name),
telegram_id=user.telegram_id,
count=len(referrals),
)
@@ -1356,11 +1349,12 @@ async def _build_user_referrals_view(
items = []
for referral in referrals[:limit]:
username_part = f', @{referral.username}' if referral.username else ''
safe_name = html.escape(referral.full_name)
if referral.telegram_id:
referral_link = f'<a href="tg://user?id={referral.telegram_id}">{referral.full_name}</a>'
referral_link = f'<a href="tg://user?id={referral.telegram_id}">{safe_name}</a>'
referral_id_display = referral.telegram_id
else:
referral_link = f'<b>{referral.full_name}</b>'
referral_link = f'<b>{safe_name}</b>'
referral_id_display = referral.email or f'#{referral.id}'
items.append(
texts.t(
@@ -1737,7 +1731,7 @@ async def start_edit_user_referrals(
'Или нажмите кнопку ниже, чтобы отменить.'
),
).format(
name=user.full_name,
name=html.escape(user.full_name),
telegram_id=user.telegram_id,
)
@@ -1972,7 +1966,7 @@ async def _render_user_promo_group(message: types.Message, language: str, user:
current_line = texts.t(
'ADMIN_USER_PROMO_GROUPS_PRIMARY',
'⭐ Основная: {name} (Priority: {priority})',
).format(name=primary_group.name, priority=getattr(primary_group, 'priority', 0))
).format(name=html.escape(primary_group.name), priority=getattr(primary_group, 'priority', 0))
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS.format(
servers=primary_group.server_discount_percent,
@@ -1997,7 +1991,7 @@ async def _render_user_promo_group(message: types.Message, language: str, user:
+ '\n'
)
for group in additional_groups:
additional_line += f'{group.name} (Priority: {getattr(group, "priority", 0)})\n'
additional_line += f'{html.escape(group.name)} (Priority: {getattr(group, "priority", 0)})\n'
discount_line += additional_line
else:
current_line = texts.t(
@@ -2388,7 +2382,7 @@ async def show_user_restrictions(callback: types.CallbackQuery, db_user: User, d
text_lines = [
'⚠️ <b>Ограничения пользователя</b>',
f'👤 {user.full_name}',
f'👤 {html.escape(user.full_name)}',
'',
'✅ — разрешено, 🚫 — запрещено',
'',
@@ -2398,7 +2392,7 @@ async def show_user_restrictions(callback: types.CallbackQuery, db_user: User, d
if restriction_reason:
text_lines.append('')
text_lines.append(f'📝 <b>Причина:</b> {restriction_reason}')
text_lines.append(f'📝 <b>Причина:</b> {html.escape(restriction_reason)}')
keyboard = get_user_restrictions_keyboard(
user_id=user_id,
@@ -2479,7 +2473,7 @@ async def ask_restriction_reason(callback: types.CallbackQuery, db_user: User, d
'выполнить запрещённое действие.\n\n'
)
if current_reason:
text += f'Текущая причина: <i>{current_reason}</i>\n\n'
text += f'Текущая причина: <i>{html.escape(current_reason)}</i>\n\n'
text += 'Отправьте новую причину или /cancel для отмены:'
await callback.message.edit_text(
@@ -2525,12 +2519,12 @@ async def save_restriction_reason(message: types.Message, db_user: User, db: Asy
'✅ <b>Причина ограничения сохранена</b>',
'',
'⚠️ <b>Ограничения пользователя</b>',
f'👤 {user.full_name}',
f'👤 {html.escape(user.full_name)}',
'',
f'{"🚫" if restriction_topup else ""} Пополнение баланса',
f'{"🚫" if restriction_subscription else ""} Продление/покупка подписки',
'',
f'📝 <b>Причина:</b> {reason}',
f'📝 <b>Причина:</b> {html.escape(reason)}',
]
keyboard = get_user_restrictions_keyboard(
@@ -2596,12 +2590,8 @@ async def show_inactive_users(callback: types.CallbackQuery, db_user: User, db:
text += '\n'
for user in inactive_users[:10]:
if user.telegram_id:
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
user_id_display = user.telegram_id
else:
user_link = f'<b>{user.full_name}</b>'
user_id_display = user.email or f'#{user.id}'
user_link = user_html_link(user)
user_id_display = user.telegram_id or user.email or f'#{user.id}'
has_active = user.subscription and user.subscription.is_active
sub_badge = ' 🛡️' if has_active else ''
text += f'👤 {user_link}{sub_badge}\n'
@@ -2691,12 +2681,8 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db:
campaign_stats = await get_campaign_statistics(db, campaign_registration.campaign_id)
text = '📊 <b>Статистика пользователя</b>\n\n'
if user.telegram_id:
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
user_id_display = user.telegram_id
else:
user_link = f'<b>{user.full_name}</b>'
user_id_display = user.email or f'#{user.id}'
user_link = user_html_link(user)
user_id_display = user.telegram_id or user.email or f'#{user.id}'
text += f'👤 {user_link} (ID: <code>{user_id_display}</code>)\n\n'
text += '<b>Основная информация:</b>\n'
@@ -2721,13 +2707,13 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db:
if user.referred_by_id:
referrer = await get_user_by_id(db, user.referred_by_id)
if referrer:
text += f'• Пришел по реферальной ссылке от <b>{referrer.full_name}</b>\n'
text += f'• Пришел по реферальной ссылке от <b>{html.escape(referrer.full_name)}</b>\n'
else:
text += '• Пришел по реферальной ссылке (реферер не найден)\n'
if campaign_registration and campaign_registration.campaign:
text += f'• Дополнительно зарегистрирован через кампанию <b>{campaign_registration.campaign.name}</b>\n'
text += f'• Дополнительно зарегистрирован через кампанию <b>{html.escape(campaign_registration.campaign.name)}</b>\n'
elif campaign_registration and campaign_registration.campaign:
text += f'• Регистрация через рекламную кампанию <b>{campaign_registration.campaign.name}</b>\n'
text += f'• Регистрация через рекламную кампанию <b>{html.escape(campaign_registration.campaign.name)}</b>\n'
if campaign_registration.created_at:
text += f'• Дата регистрации по кампании: {campaign_registration.created_at.strftime("%d.%m.%Y %H:%M")}\n'
else:
@@ -2737,7 +2723,7 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db:
if campaign_registration and campaign_registration.campaign and campaign_stats:
text += '<b>Рекламная кампания:</b>\n'
text += f'• Название: <b>{campaign_registration.campaign.name}</b>'
text += f'• Название: <b>{html.escape(campaign_registration.campaign.name)}</b>'
if campaign_registration.campaign.start_parameter:
text += f' (параметр: <code>{campaign_registration.campaign.start_parameter}</code>)'
text += '\n'
@@ -2771,7 +2757,7 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db:
if referral_stats['referrals_detail']:
text += '\n<b>Детали по рефералам:</b>\n'
for detail in referral_stats['referrals_detail'][:5]:
referral_name = detail['referral_name']
referral_name = html.escape(detail['referral_name'])
earned = settings.format_price(detail['total_earned_kopeks'])
status = '🟢' if detail['is_active'] else '🔴'
text += f'{status} {referral_name}: {earned}\n'
@@ -4226,7 +4212,7 @@ async def change_subscription_type(callback: types.CallbackQuery, db_user: User,
current_type = '🎁 Триал' if subscription.is_trial else '💎 Платная'
text = '🔄 <b>Смена типа подписки</b>\n\n'
text += f'👤 {profile["user"].full_name}\n'
text += f'👤 {html.escape(profile["user"].full_name)}\n'
text += f'📱 Текущий тип: {current_type}\n\n'
text += 'Выберите новый тип подписки:'
@@ -4307,12 +4293,8 @@ async def admin_buy_subscription(callback: types.CallbackQuery, db_user: User, d
)
text = '💳 <b>Покупка подписки для пользователя</b>\n\n'
if target_user.telegram_id:
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
target_user_id_display = target_user.telegram_id
else:
target_user_link = f'<b>{target_user.full_name}</b>'
target_user_id_display = target_user.email or f'#{target_user.id}'
target_user_link = user_html_link(target_user)
target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}'
text += f'👤 {target_user_link} (ID: {target_user_id_display})\n'
text += f'💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n'
traffic_text = 'Безлимит' if (subscription.traffic_limit_gb or 0) <= 0 else f'{subscription.traffic_limit_gb} ГБ'
@@ -4400,12 +4382,8 @@ async def admin_buy_subscription_confirm(callback: types.CallbackQuery, db_user:
return
text = '💳 <b>Подтверждение покупки подписки</b>\n\n'
if target_user.telegram_id:
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
target_user_id_display = target_user.telegram_id
else:
target_user_link = f'<b>{target_user.full_name}</b>'
target_user_id_display = target_user.email or f'#{target_user.id}'
target_user_link = user_html_link(target_user)
target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}'
text += f'👤 {target_user_link} (ID: {target_user_id_display})\n'
text += f'📅 Период подписки: {period_days} дней\n'
text += f'💰 Стоимость: {settings.format_price(price_kopeks)}\n'
@@ -4646,12 +4624,8 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
else:
message = '❌ Ошибка: у пользователя нет существующей подписки'
if target_user.telegram_id:
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
target_user_id_display = target_user.telegram_id
else:
target_user_link = f'<b>{target_user.full_name}</b>'
target_user_id_display = target_user.email or f'#{target_user.id}'
target_user_link = user_html_link(target_user)
target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}'
await callback.message.edit_text(
f'{message}\n\n'
f'👤 {target_user_link} (ID: {target_user_id_display})\n'
@@ -4727,12 +4701,8 @@ async def admin_buy_tariff(callback: types.CallbackQuery, db_user: User, db: Asy
await callback.answer()
return
if target_user.telegram_id:
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
target_user_id_display = target_user.telegram_id
else:
target_user_link = f'<b>{target_user.full_name}</b>'
target_user_id_display = target_user.email or f'#{target_user.id}'
target_user_link = user_html_link(target_user)
target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}'
text = '💳 <b>Покупка тарифа для пользователя</b>\n\n'
text += f'👤 {target_user_link} (ID: {target_user_id_display})\n'
text += f'💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n'
@@ -4742,7 +4712,7 @@ async def admin_buy_tariff(callback: types.CallbackQuery, db_user: User, db: Asy
traffic = '♾️' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ'
prices = tariff.period_prices or {}
min_price = min(prices.values()) if prices else 0
text += f'<b>{tariff.name}</b> — {traffic} / {tariff.device_limit} 📱 от {settings.format_price(min_price)}\n'
text += f'<b>{html.escape(tariff.name)}</b> — {traffic} / {tariff.device_limit} 📱 от {settings.format_price(min_price)}\n'
keyboard = []
for tariff in tariffs:
@@ -4787,18 +4757,14 @@ async def admin_buy_tariff_period(callback: types.CallbackQuery, db_user: User,
await callback.answer('❌ Тариф недоступен', show_alert=True)
return
if target_user.telegram_id:
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
target_user_id_display = target_user.telegram_id
else:
target_user_link = f'<b>{target_user.full_name}</b>'
target_user_id_display = target_user.email or f'#{target_user.id}'
target_user_link = user_html_link(target_user)
target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}'
traffic = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ'
text = '💳 <b>Покупка тарифа для пользователя</b>\n\n'
text += f'👤 {target_user_link} (ID: {target_user_id_display})\n'
text += f'💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n'
text += f'📦 <b>Тариф: {tariff.name}</b>\n'
text += f'📦 <b>Тариф: {html.escape(tariff.name)}</b>\n'
text += f'📊 Трафик: {traffic}\n'
text += f'📱 Устройств: {tariff.device_limit}\n'
text += f'🌐 Серверов: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}\n\n'
@@ -4876,18 +4842,14 @@ async def admin_buy_tariff_confirm(callback: types.CallbackQuery, db_user: User,
await callback.answer()
return
if target_user.telegram_id:
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
target_user_id_display = target_user.telegram_id
else:
target_user_link = f'<b>{target_user.full_name}</b>'
target_user_id_display = target_user.email or f'#{target_user.id}'
target_user_link = user_html_link(target_user)
target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}'
traffic = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ'
text = '💳 <b>Подтверждение покупки тарифа</b>\n\n'
text += f'👤 {target_user_link} (ID: {target_user_id_display})\n'
text += f'💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n'
text += f'📦 <b>Тариф: {tariff.name}</b>\n'
text += f'📦 <b>Тариф: {html.escape(tariff.name)}</b>\n'
text += f'📊 Трафик: {traffic}\n'
text += f'📱 Устройств: {tariff.device_limit}\n'
text += f'📅 Период: {period} дней\n'
@@ -5055,18 +5017,14 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
description=f'Покупка тарифа {tariff.name} на {period} дней (администратор)',
)
if target_user.telegram_id:
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
target_user_id_display = target_user.telegram_id
else:
target_user_link = f'<b>{target_user.full_name}</b>'
target_user_id_display = target_user.email or f'#{target_user.id}'
target_user_link = user_html_link(target_user)
target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}'
traffic = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ'
await callback.message.edit_text(
f'✅ <b>Тариф успешно куплен!</b>\n\n'
f'👤 {target_user_link} (ID: {target_user_id_display})\n'
f'📦 Тариф: {tariff.name}\n'
f'📦 Тариф: {html.escape(tariff.name)}\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'📅 Период: {period} дней\n'
@@ -5090,7 +5048,7 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
await callback.bot.send_message(
chat_id=target_user.telegram_id,
text=f'💳 <b>Администратор оформил вам тариф</b>\n\n'
f'📦 Тариф: {tariff.name}\n'
f'📦 Тариф: {html.escape(tariff.name)}\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'📅 Период: {period} дней\n'
@@ -5234,14 +5192,11 @@ async def show_admin_tariff_change(callback: types.CallbackQuery, db_user: User,
current_tariff = await get_tariff_by_id(db, subscription.tariff_id)
text = '📦 <b>Смена тарифа пользователя</b>\n\n'
if user.telegram_id:
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
else:
user_link = f'<b>{user.full_name}</b> ({user.email or f"#{user.id}"})'
user_link = user_html_link(user)
text += f'👤 {user_link}\n\n'
if current_tariff:
text += f'<b>Текущий тариф:</b> {current_tariff.name}\n\n'
text += f'<b>Текущий тариф:</b> {html.escape(current_tariff.name)}\n\n'
else:
text += '<b>Текущий тариф:</b> не установлен\n\n'
@@ -5307,12 +5262,9 @@ async def select_admin_tariff_change(callback: types.CallbackQuery, db_user: Use
servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0
text = '📦 <b>Подтверждение смены тарифа</b>\n\n'
if user.telegram_id:
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
else:
user_link = f'<b>{user.full_name}</b> ({user.email or f"#{user.id}"})'
user_link = user_html_link(user)
text += f'👤 {user_link}\n\n'
text += f'<b>Новый тариф:</b> {tariff.name}\n'
text += f'<b>Новый тариф:</b> {html.escape(tariff.name)}\n'
text += f'• Устройства: {tariff.device_limit}\n'
text += f'• Трафик: {traffic_str}\n'
text += f'• Серверы: {servers_count}\n\n'
@@ -5431,7 +5383,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
await callback.message.edit_text(
f'✅ <b>Тариф успешно изменен</b>\n\n'
f'Новый тариф: <b>{tariff.name}</b>\n'
f'Новый тариф: <b>{html.escape(tariff.name)}</b>\n'
f'• Устройства: {subscription.device_limit}\n'
f'• Трафик: {"♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"}\n'
f'• Серверы: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}',
@@ -5451,7 +5403,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
await db.rollback()
await callback.message.edit_text(
f'❌ <b>Ошибка смены тарифа</b>\n\nДетали: {e!s}',
f'❌ <b>Ошибка смены тарифа</b>\n\nДетали: {html.escape(str(e))}',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
+4 -2
View File
@@ -1,5 +1,7 @@
"""Handler for CloudPayments balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
@@ -137,7 +139,7 @@ async def process_cloudpayments_payment_amount(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -203,7 +205,7 @@ async def start_cloudpayments_payment(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
+4 -2
View File
@@ -1,3 +1,5 @@
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
@@ -21,7 +23,7 @@ async def start_cryptobot_payment(callback: types.CallbackQuery, db_user: User,
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -85,7 +87,7 @@ async def process_cryptobot_payment_amount(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
+2 -2
View File
@@ -161,7 +161,7 @@ async def process_freekassa_payment_amount(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -250,7 +250,7 @@ async def _start_freekassa_topup_impl(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
+3 -2
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime
import structlog
@@ -28,7 +29,7 @@ async def start_heleket_payment(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -99,7 +100,7 @@ async def process_heleket_payment_amount(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
+4 -2
View File
@@ -1,5 +1,7 @@
"""Handler for KassaAI balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
@@ -45,7 +47,7 @@ async def _check_topup_restriction(callback: types.CallbackQuery, db_user: User)
if not getattr(db_user, 'restriction_topup', False):
return False
texts = get_texts(db_user.language)
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -173,7 +175,7 @@ async def process_kassa_ai_payment_amount(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
+19 -4
View File
@@ -1,3 +1,5 @@
import html
import structlog
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
@@ -233,7 +235,7 @@ async def show_balance_history(callback: types.CallbackQuery, db_user: User, db:
)
text += f'{emoji} {amount_text}\n'
text += f'📝 {transaction.description}\n'
text += f'📝 {html.escape(transaction.description or "")}\n'
text += f'📅 {transaction.created_at.strftime("%d.%m.%Y %H:%M")}\n\n'
keyboard = []
@@ -266,7 +268,7 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -563,7 +565,16 @@ async def handle_topup_amount_callback(
try:
# Особые случаи, требующие специальной логики
if method == 'platega':
if method.startswith('platega_m'):
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
platega_method_code = int(method[len('platega_m') :])
await state.update_data(payment_method='platega', platega_method=platega_method_code)
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state)
elif method == 'platega':
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount, start_platega_payment
@@ -633,13 +644,17 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith('pal24_method_'),
)
from .platega import handle_platega_method_selection, start_platega_payment
from .platega import handle_platega_method_selection, start_platega_direct_method, start_platega_payment
dp.callback_query.register(start_platega_payment, F.data == 'topup_platega')
dp.callback_query.register(
handle_platega_method_selection,
F.data.startswith('platega_method_'),
)
dp.callback_query.register(
start_platega_direct_method,
F.data.regexp(r'^topup_platega_m\d+$'),
)
from .yookassa import check_yookassa_payment_status
+4 -2
View File
@@ -1,3 +1,5 @@
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
@@ -25,7 +27,7 @@ async def start_mulenpay_payment(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -92,7 +94,7 @@ async def process_mulenpay_payment_amount(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
+2 -2
View File
@@ -262,7 +262,7 @@ async def start_pal24_payment(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -330,7 +330,7 @@ async def process_pal24_payment_amount(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
+53 -2
View File
@@ -1,5 +1,7 @@
"""Handlers for Platega balance interactions."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
@@ -98,7 +100,7 @@ async def start_platega_payment(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -189,6 +191,55 @@ async def handle_platega_method_selection(
await callback.answer()
@error_handler
async def start_platega_direct_method(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
"""Handle direct Platega method selection from the main payment screen (inline mode)."""
texts = get_texts(db_user.language)
try:
method_code = int(callback.data.removeprefix('topup_platega_m'))
except (ValueError, IndexError):
await callback.answer('❌ Некорректный способ оплаты', show_alert=True)
return
if getattr(db_user, 'restriction_topup', False):
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n'
'Если вы считаете это ошибкой, вы можете обжаловать решение.',
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
return
if not settings.is_platega_enabled():
await callback.answer(
texts.t(
'PLATEGA_TEMPORARILY_UNAVAILABLE',
'❌ Оплата через Platega временно недоступна',
),
show_alert=True,
)
return
if method_code not in _get_active_methods():
await callback.answer('⚠️ Этот способ сейчас недоступен', show_alert=True)
return
await _prompt_amount(callback.message, db_user, state, method_code)
await callback.answer()
@error_handler
async def process_platega_payment_amount(
message: types.Message,
@@ -201,7 +252,7 @@ async def process_platega_payment_amount(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
+4 -2
View File
@@ -1,5 +1,7 @@
"""Handler for RioPay balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
@@ -142,7 +144,7 @@ async def process_riopay_payment_amount(
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
@@ -202,7 +204,7 @@ async def start_riopay_topup(
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
+4 -2
View File
@@ -1,5 +1,7 @@
"""Handler for SeverPay balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
@@ -142,7 +144,7 @@ async def process_severpay_payment_amount(
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
@@ -202,7 +204,7 @@ async def start_severpay_topup(
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
+4 -2
View File
@@ -1,3 +1,5 @@
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
@@ -25,7 +27,7 @@ async def start_stars_payment(callback: types.CallbackQuery, db_user: User, stat
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -62,7 +64,7 @@ async def process_stars_payment_amount(message: types.Message, db_user: User, am
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
+3 -1
View File
@@ -1,3 +1,5 @@
import html
import structlog
from aiogram import types
@@ -19,7 +21,7 @@ async def start_tribute_payment(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
+3 -2
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime
import structlog
@@ -28,7 +29,7 @@ async def start_wata_payment(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -88,7 +89,7 @@ async def process_wata_payment_amount(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
+5 -4
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime
import structlog
@@ -24,7 +25,7 @@ async def start_yookassa_payment(callback: types.CallbackQuery, db_user: User, s
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -70,7 +71,7 @@ async def start_yookassa_sbp_payment(callback: types.CallbackQuery, db_user: Use
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -118,7 +119,7 @@ async def process_yookassa_payment_amount(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -273,7 +274,7 @@ async def process_yookassa_sbp_payment_amount(
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
+3 -2
View File
@@ -1183,12 +1183,13 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
if tariff:
is_daily_tariff = getattr(tariff, 'is_daily', False)
# Формируем краткий блок информации о тарифе для главного меню
tariff_info_block = f'\n📦 Тариф: {tariff.name}'
tariff_info_block = f'\n📦 Тариф: {html.escape(tariff.name)}'
except Exception as e:
logger.debug('Не удалось загрузить тариф для главного меню', error=e)
base_text = texts.MAIN_MENU.format(
user_name=user.full_name, subscription_status=_get_subscription_status(user, texts, is_daily_tariff)
user_name=html.escape(user.full_name or ''),
subscription_status=_get_subscription_status(user, texts, is_daily_tariff),
)
# Добавляем информацию о тарифе перед "Выберите действие"
+2 -1
View File
@@ -1,4 +1,5 @@
import asyncio
import html
from datetime import UTC, datetime
import structlog
@@ -39,7 +40,7 @@ async def _render_question_text(
current=current_index,
total=total,
)
lines = [f'🗳️ <b>{poll_title}</b>', '', header, '', question.text]
lines = [f'🗳️ <b>{html.escape(poll_title)}</b>', '', header, '', html.escape(question.text)]
return '\n'.join(lines)
+19 -34
View File
@@ -507,10 +507,9 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username)
cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code)
invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
bonus_block = ''
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
invite_text += '\n\n' + texts.t(
bonus_block = '\n\n' + texts.t(
'REFERRAL_INVITE_BONUS',
'💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!',
).format(
@@ -518,40 +517,26 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
)
invite_text += (
'\n\n'
+ texts.t('REFERRAL_INVITE_FEATURE_FAST', '🚀 Быстрое подключение')
+ '\n'
+ texts.t('REFERRAL_INVITE_FEATURE_SERVERS', '🌍 Серверы по всему миру')
+ '\n'
+ texts.t('REFERRAL_INVITE_FEATURE_SECURE', '🔒 Надежная защита')
+ '\n\n'
+ texts.t('REFERRAL_INVITE_LINK_PROMPT', '👇 Переходи по ссылке:')
+ f'\n{bot_referral_link}'
)
cabinet_block = ''
if cabinet_referral_link:
invite_text += (
'\n\n'
+ texts.t('REFERRAL_INVITE_CABINET_LINK', '🌐 Или через личный кабинет:')
+ f'\n{cabinet_referral_link}'
)
cabinet_block = f'\n\n🌐 {cabinet_referral_link}'
# Compact share text for switch_inline_query (256-char limit)
share_text = invite_text
if len(share_text) > 256:
share_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') + f'\n\n👇 {bot_referral_link}'
if cabinet_referral_link and len(share_text) + len(cabinet_referral_link) + 5 <= 256:
share_text += f'\n🌐 {cabinet_referral_link}'
share_text = share_text[:256]
invite_text = texts.t(
'REFERRAL_INVITE_TEXT',
'🎉 Присоединяйся к VPN сервису!{bonus_block}\n\n'
'🚀 Быстрое подключение\n'
'🌍 Серверы по всему миру\n'
'🔒 Надежная защита\n\n'
'👇 Переходи по ссылке:\n'
'{link}{cabinet_block}',
).format(
bonus_block=bonus_block,
link=bot_referral_link,
cabinet_block=cabinet_block,
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t('REFERRAL_SHARE_BUTTON', '📤 Поделиться'), switch_inline_query=share_text
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')],
]
)
@@ -563,10 +548,10 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
+ '\n\n'
+ texts.t(
'REFERRAL_INVITE_CREATED_INSTRUCTION',
'Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:',
'Нажмите на текст ниже, чтобы скопировать:',
)
+ '\n\n'
f'<code>{html_escape(invite_text)}</code>'
f'<blockquote><code>{html_escape(invite_text)}</code></blockquote>'
),
keyboard,
)
+1 -1
View File
@@ -44,7 +44,7 @@ async def start_simple_subscription_purchase(
# Проверка ограничения на покупку/продление подписки
if getattr(db_user, 'restriction_subscription', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
+2 -1
View File
@@ -1,3 +1,4 @@
import html
from decimal import ROUND_HALF_UP, Decimal
import structlog
@@ -136,7 +137,7 @@ async def _handle_wheel_spin_payment(
emoji = selected_prize.emoji or '🎁'
await message.answer(
f'🎰 <b>Колесо удачи!</b>\n\n'
f'{emoji} <b>{selected_prize.display_name}</b>\n\n'
f'{emoji} <b>{html.escape(selected_prize.display_name)}</b>\n\n'
f'{prize_message}\n\n'
f'⭐ Потрачено: {stars_amount} Stars',
parse_mode='HTML',
+66 -159
View File
@@ -1,3 +1,4 @@
import html
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
@@ -8,7 +9,6 @@ from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -43,6 +43,7 @@ from app.services.admin_notification_service import AdminNotificationService
from app.services.campaign_service import AdvertisingCampaignService
from app.services.channel_subscription_service import channel_subscription_service
from app.services.main_menu_button_service import MainMenuButtonService
from app.services.phantom_service import claim_phantom, merge_phantom_into_user, sync_remnawave_after_phantom_merge
from app.services.pinned_message_service import (
deliver_pinned_message_to_user,
get_active_pinned_message,
@@ -117,7 +118,7 @@ async def _activate_pending_gift_after_registration(
gift_purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
await db.flush()
await svc_activate(db, gift_purchase.token, skip_notification=True)
tariff_name = gift_purchase.tariff.name if gift_purchase.tariff else ''
tariff_name = html.escape(gift_purchase.tariff.name) if gift_purchase.tariff else ''
await answer_func(
f'🎁 <b>Подарок активирован!</b>\n'
f'{tariff_name}{gift_purchase.period_days} дн.\n\n'
@@ -131,147 +132,6 @@ async def _activate_pending_gift_after_registration(
)
async def _claim_phantom_user(
db: AsyncSession,
phantom: 'User',
*,
telegram_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
language: str,
referrer_id: int | None,
) -> tuple[bool, 'User | None']:
"""Claim a phantom user by backfilling Telegram profile data.
Returns (success, user). On IntegrityError falls back to existing user lookup.
Note: Phantom users created when Bot.get_chat() fails at purchase time are matched
by username only. Since Telegram usernames are changeable and reassignable, this is
inherently vulnerable to username change attacks. When Bot.get_chat() succeeds at
purchase time, telegram_id is stored on the user and the phantom path is not used.
"""
from app.utils.validators import sanitize_telegram_name
phantom.telegram_id = telegram_id
phantom.username = username
phantom.first_name = sanitize_telegram_name(first_name)
phantom.last_name = sanitize_telegram_name(last_name)
phantom.language = language
phantom.status = UserStatus.ACTIVE.value
if referrer_id and referrer_id != phantom.id:
phantom.referred_by_id = referrer_id
if not phantom.referral_code:
phantom.referral_code = await generate_unique_referral_code(db, telegram_id)
phantom.updated_at = datetime.now(UTC)
phantom.last_activity = datetime.now(UTC)
try:
await db.commit()
except IntegrityError:
await db.rollback()
logger.warning(
'IntegrityError claiming phantom user, falling back to existing user lookup',
phantom_user_id=phantom.id,
telegram_id=telegram_id,
)
existing = await get_user_by_telegram_id(db, telegram_id)
return False, existing
await db.refresh(phantom, ['subscription'])
logger.info(
'Claimed phantom user from guest purchase',
phantom_user_id=phantom.id,
telegram_id=telegram_id,
)
# Sync Remnawave panel with updated user data (telegram_id, username, etc.)
if phantom.subscription:
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, phantom.subscription)
except Exception as exc:
logger.warning(
'Failed to update Remnawave panel after phantom claim',
phantom_user_id=phantom.id,
error=str(exc),
)
return True, phantom
async def _merge_phantom_into_active_user(
db: AsyncSession,
phantom: 'User',
active_user: 'User',
) -> None:
"""Merge a phantom user (created by guest landing purchase) into an existing active user.
Transfers GuestPurchase records and handles subscription conflict.
The phantom is soft-deleted (status=DELETED, username cleared) to preserve
audit trail and avoid CASCADE deletion of payment/transaction records.
"""
from sqlalchemy import update
logger.info(
'Merging phantom user into active user',
phantom_id=phantom.id,
active_user_id=active_user.id,
phantom_username=phantom.username,
)
# Transfer GuestPurchase.user_id references
await db.execute(update(GuestPurchase).where(GuestPurchase.user_id == phantom.id).values(user_id=active_user.id))
# Transfer GuestPurchase.buyer_user_id references
await db.execute(
update(GuestPurchase).where(GuestPurchase.buyer_user_id == phantom.id).values(buyer_user_id=active_user.id)
)
# Transfer balance
if phantom.balance_kopeks and phantom.balance_kopeks > 0:
active_user.balance_kopeks = (active_user.balance_kopeks or 0) + phantom.balance_kopeks
logger.info('Transferred balance from phantom', amount_kopeks=phantom.balance_kopeks)
# Handle subscription
await db.refresh(phantom, ['subscription'])
await db.refresh(active_user, ['subscription'])
if phantom.subscription and not active_user.subscription:
# Transfer subscription from phantom to active user
phantom.subscription.user_id = active_user.id
# Transfer remnawave_uuid
if phantom.remnawave_uuid and not active_user.remnawave_uuid:
active_user.remnawave_uuid = phantom.remnawave_uuid
phantom.remnawave_uuid = None
await db.flush()
logger.info(
'Transferred subscription from phantom to active user',
subscription_id=phantom.subscription.id,
)
elif phantom.subscription:
# Both have subscriptions — disable phantom's Remnawave user and free server slots
logger.warning(
'Both phantom and active user have subscriptions, disabling phantom',
phantom_subscription_id=phantom.subscription.id,
active_subscription_id=active_user.subscription.id,
)
if phantom.remnawave_uuid:
try:
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(phantom.remnawave_uuid)
except Exception as exc:
logger.warning('Failed to disable phantom Remnawave user', error=str(exc))
await decrement_subscription_server_counts(db, phantom.subscription)
# Soft-delete phantom: clear identifiers to prevent future matches,
# preserve record for audit trail and avoid CASCADE deletion of payments/transactions
phantom.status = UserStatus.DELETED.value
phantom.username = None
phantom.remnawave_uuid = None
await db.flush()
logger.info('Phantom user merged and soft-deleted', phantom_id=phantom.id, active_user_id=active_user.id)
def _calculate_subscription_flags(subscription):
if not subscription:
return False, False
@@ -322,13 +182,13 @@ async def _apply_campaign_bonus_if_needed(
amount_text = texts.format_price(result.balance_kopeks)
return texts.CAMPAIGN_BONUS_BALANCE.format(
amount=amount_text,
name=campaign.name,
name=html.escape(campaign.name),
)
if result.bonus_type == 'subscription':
traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0)
return texts.CAMPAIGN_BONUS_SUBSCRIPTION.format(
name=campaign.name,
name=html.escape(campaign.name),
days=result.subscription_days,
traffic=traffic_text,
devices=result.subscription_device_limit,
@@ -703,10 +563,14 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
phantom = await find_phantom_user_by_username(db, message.from_user.username)
if phantom and phantom.id != user.id:
try:
await _merge_phantom_into_active_user(db, phantom, user)
sub_transferred = await merge_phantom_into_user(db, phantom, user)
await db.commit()
await db.refresh(user, ['subscription'])
if sub_transferred:
await sync_remnawave_after_phantom_merge(db, user)
except Exception:
await db.rollback()
await db.refresh(user, ['subscription'])
logger.exception(
'Failed to merge phantom user',
phantom_id=phantom.id,
@@ -1461,7 +1325,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
texts.t(
'WELCOME_FALLBACK',
'Добро пожаловать, {user_name}!',
).format(user_name=existing_user.full_name)
).format(user_name=html.escape(existing_user.full_name or ''))
)
await state.clear()
@@ -1516,7 +1380,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
else None
)
if phantom:
claimed, user = await _claim_phantom_user(
claimed, user = await claim_phantom(
db,
phantom,
telegram_id=callback.from_user.id,
@@ -1527,8 +1391,23 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
referrer_id=referrer_id,
)
if not claimed and user:
# IntegrityError fallback — use existing user
# Phantom claim failed (IntegrityError — user with this telegram_id already exists).
# Merge phantom's data into the existing user via full account merge service.
sub_transferred = False
if phantom.id != user.id:
try:
sub_transferred = await merge_phantom_into_user(db, phantom, user)
await db.commit()
except Exception:
await db.rollback()
logger.exception(
'Failed to merge phantom into existing user during registration',
phantom_id=phantom.id,
active_user_id=user.id,
)
await db.refresh(user, ['subscription'])
if sub_transferred:
await sync_remnawave_after_phantom_merge(db, user)
elif not claimed:
logger.critical(
'Phantom claim failed with no fallback user, proceeding to normal registration',
@@ -1695,7 +1574,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
texts.t(
'WELCOME_FALLBACK',
'Добро пожаловать, {user_name}!',
).format(user_name=user.full_name)
).format(user_name=html.escape(user.full_name or ''))
)
logger.info('✅ Регистрация завершена для пользователя', telegram_id=user.telegram_id)
@@ -1763,7 +1642,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
texts.t(
'WELCOME_FALLBACK',
'Добро пожаловать, {user_name}!',
).format(user_name=existing_user.full_name)
).format(user_name=html.escape(existing_user.full_name or ''))
)
await state.clear()
@@ -1816,7 +1695,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
await find_phantom_user_by_username(db, message.from_user.username) if message.from_user.username else None
)
if phantom:
claimed, user = await _claim_phantom_user(
claimed, user = await claim_phantom(
db,
phantom,
telegram_id=message.from_user.id,
@@ -1827,7 +1706,23 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
referrer_id=referrer_id,
)
if not claimed and user:
# Phantom claim failed (IntegrityError — user with this telegram_id already exists).
# Merge phantom's data into the existing user via full account merge service.
sub_transferred = False
if phantom.id != user.id:
try:
sub_transferred = await merge_phantom_into_user(db, phantom, user)
await db.commit()
except Exception:
await db.rollback()
logger.exception(
'Failed to merge phantom into existing user during registration',
phantom_id=phantom.id,
active_user_id=user.id,
)
await db.refresh(user, ['subscription'])
if sub_transferred:
await sync_remnawave_after_phantom_merge(db, user)
elif not claimed:
logger.critical(
'Phantom claim failed with no fallback user, proceeding to normal registration',
@@ -1898,7 +1793,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
logger.warning(
'⚠️ Не удалось активировать промокод',
promocode_to_activate=promocode_to_activate,
get=promocode_result.get('error'),
error=promocode_result.get('error'),
)
except Exception as e:
logger.error('❌ Ошибка при активации промокода', promocode_to_activate=promocode_to_activate, error=e)
@@ -2030,7 +1925,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
texts.t(
'WELCOME_FALLBACK',
'Добро пожаловать, {user_name}!',
).format(user_name=user.full_name)
).format(user_name=html.escape(user.full_name or ''))
)
logger.info('✅ Регистрация завершена для пользователя', telegram_id=user.telegram_id)
@@ -2140,8 +2035,6 @@ def get_referral_code_keyboard(language: str):
async def get_main_menu_text(user, texts, db: AsyncSession):
import html
base_text = texts.MAIN_MENU.format(
user_name=html.escape(user.full_name or ''), subscription_status=_get_subscription_status(user, texts)
)
@@ -2189,8 +2082,6 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
import html
base_text = texts.MAIN_MENU.format(
user_name=html.escape(user_name or ''), subscription_status=_get_subscription_status_simple(texts)
)
@@ -2425,7 +2316,7 @@ async def required_sub_channel_check(
else None
)
if phantom:
claimed, user = await _claim_phantom_user(
claimed, user = await claim_phantom(
db,
phantom,
telegram_id=query.from_user.id,
@@ -2436,7 +2327,23 @@ async def required_sub_channel_check(
referrer_id=referrer_id,
)
if not claimed and user:
# Phantom claim failed (IntegrityError — user with this telegram_id already exists).
# Merge phantom's data into the existing user via full account merge service.
sub_transferred = False
if phantom.id != user.id:
try:
sub_transferred = await merge_phantom_into_user(db, phantom, user)
await db.commit()
except Exception:
await db.rollback()
logger.exception(
'Failed to merge phantom into existing user during registration',
phantom_id=phantom.id,
active_user_id=user.id,
)
await db.refresh(user, ['subscription'])
if sub_transferred:
await sync_remnawave_after_phantom_merge(db, user)
elif not claimed:
logger.critical(
'Phantom claim failed with no fallback user, proceeding to normal registration',
+69 -11
View File
@@ -336,7 +336,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
tariff_type_str = '🔄 Суточный' if is_daily else '📅 Периодный'
tariff_info_lines = [
f'<b>📦 {tariff.name}</b>',
f'<b>📦 {html.escape(tariff.name)}</b>',
f'Тип: {tariff_type_str}',
f'Трафик: {tariff.traffic_limit_gb} ГБ' if tariff.traffic_limit_gb > 0 else 'Трафик: ∞ Безлимит',
f'Устройства: {tariff.device_limit}',
@@ -453,7 +453,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
device_limit_display = str(subscription.device_limit)
message = message_template.format(
full_name=db_user.full_name,
full_name=html.escape(db_user.full_name or ''),
balance=settings.format_price(db_user.balance_kopeks),
status_emoji=status_emoji,
status_display=status_display,
@@ -761,7 +761,7 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
# Проверка ограничения на покупку/продление подписки
if getattr(db_user, 'restriction_subscription', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
@@ -2068,7 +2068,7 @@ async def devices_continue(callback: types.CallbackQuery, state: FSMContext, db_
async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
# Проверка ограничения на покупку/продление подписки
if getattr(db_user, 'restriction_subscription', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
texts = get_texts(db_user.language)
support_url = settings.get_support_contact_url()
keyboard = []
@@ -2921,6 +2921,7 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
return
if needs_resume:
resume_transaction = None
# Списываем суточную оплату ДО активации (чтобы не было бесплатного дня)
if daily_price > 0 and is_inactive:
from app.database.crud.user import subtract_user_balance
@@ -2946,7 +2947,7 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
from app.database.models import TransactionType
try:
await create_transaction(
resume_transaction = await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
@@ -2961,22 +2962,79 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
subscription = await resume_daily_subscription(db, subscription)
message = texts.t('DAILY_SUBSCRIPTION_RESUMED', '▶️ Подписка возобновлена!')
# Восстанавливаем connected_squads из тарифа, если очищены деактивацией
try:
if not subscription.connected_squads:
squads = tariff.allowed_squads or []
if not squads:
from app.database.crud.server_squad import get_all_server_squads
all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000)
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
if squads:
subscription.connected_squads = squads
await db.commit()
await db.refresh(subscription)
except Exception as sq_err:
logger.warning('Не удалось восстановить connected_squads', error=sq_err)
# Синхронизируем с Remnawave - активируем пользователя
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
if getattr(db_user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
sync_squads=True,
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
# POST может игнорировать activeInternalSquads — отправляем PATCH
await db.refresh(db_user)
if getattr(db_user, 'remnawave_uuid', None) and subscription.connected_squads:
try:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=False,
sync_squads=True,
)
except Exception as patch_err:
logger.warning('Не удалось синхронизировать сквады после создания', error=patch_err)
logger.info(
'✅ Синхронизировано с Remnawave после возобновления суточной подписки', subscription_id=subscription.id
)
except Exception as e:
logger.error('Ошибка синхронизации с Remnawave при возобновлении', error=e)
# Отправляем уведомление администраторам о возобновлении суточной подписки
if resume_transaction is not None:
try:
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
db=db,
user=db_user,
subscription=subscription,
transaction=resume_transaction,
period_days=1,
was_trial_conversion=False,
amount_kopeks=daily_price,
purchase_type='renewal',
)
except Exception as notif_err:
logger.error('Не удалось отправить уведомление администраторам при возобновлении', error=notif_err)
else:
# Подписка активна, ставим на паузу
subscription = await toggle_daily_subscription_pause(db, subscription)
+48 -47
View File
@@ -1,5 +1,6 @@
"""Покупка подписки по тарифам."""
import html
from datetime import UTC, datetime, timedelta
import structlog
@@ -102,11 +103,11 @@ def format_tariffs_list_text(
price_text = f'от {format_price_kopeks(min_price, compact=True)}{discount_icon}'
# Компактный формат: Название — 250 ГБ / 10 📱 от 179₽🔥
lines.append(f'<b>{tariff.name}</b> — {traffic} / {tariff.device_limit} 📱 {price_text}')
lines.append(f'<b>{html.escape(tariff.name)}</b> — {traffic} / {tariff.device_limit} 📱 {price_text}')
# Описание тарифа если есть
if tariff.description:
lines.append(f'<i>{tariff.description}</i>')
lines.append(f'<i>{html.escape(tariff.description)}</i>')
lines.append('')
@@ -238,7 +239,7 @@ def format_tariff_info_for_user(
traffic = format_traffic(tariff.traffic_limit_gb)
text = f"""📦 <b>{tariff.name}</b>
text = f"""📦 <b>{html.escape(tariff.name)}</b>
<b>Параметры:</b>
Трафик: {traffic}
@@ -246,7 +247,7 @@ def format_tariff_info_for_user(
"""
if tariff.description:
text += f'\n📝 {tariff.description}\n'
text += f'\n📝 {html.escape(tariff.description)}\n'
if discount_percent > 0:
text += f'\n🎁 <b>Ваша скидка: {discount_percent}%</b>\n'
@@ -438,7 +439,7 @@ async def format_custom_tariff_preview(
traffic_display = f'{traffic_gb} ГБ' if traffic_gb > 0 else format_traffic(tariff.traffic_limit_gb)
text = f"""📦 <b>{tariff.name}</b>
text = f"""📦 <b>{html.escape(tariff.name)}</b>
<b>Настройте параметры:</b>
"""
@@ -554,7 +555,7 @@ async def select_tariff(
if user_balance >= daily_price:
await callback.message.edit_text(
f'✅ <b>Подтверждение покупки</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: <b>Суточный</b>\n\n'
@@ -589,7 +590,7 @@ async def select_tariff(
await callback.message.edit_text(
f'❌ <b>Недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'🔄 Тип: Суточный\n'
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
f'{discount_text}\n\n'
@@ -991,7 +992,7 @@ async def handle_custom_confirm(
await callback.message.edit_text(
f'🎉 <b>Подписка успешно оформлена!</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic_display}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'📅 Период: {format_period(custom_days)}\n'
@@ -1115,7 +1116,7 @@ async def select_tariff_period(
await callback.message.edit_text(
f'✅ <b>Подтверждение покупки</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'📅 Период: {format_period(period)}\n'
@@ -1150,7 +1151,7 @@ async def select_tariff_period(
await callback.message.edit_text(
f'❌ <b>Недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📅 Период: {format_period(period)}\n'
f'💰 Стоимость: {format_price_kopeks(final_price)}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
@@ -1364,7 +1365,7 @@ async def confirm_tariff_purchase(
await callback.message.edit_text(
f'🎉 <b>Подписка успешно оформлена!</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'📅 Период: {format_period(period)}\n'
@@ -1597,7 +1598,7 @@ async def confirm_daily_tariff_purchase(
await callback.message.edit_text(
f'🎉 <b>Суточная подписка оформлена!</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: Суточный\n'
@@ -1738,7 +1739,7 @@ async def show_tariff_extend(
await callback.message.edit_text(
f'🔄 <b>Продление подписки</b>{discount_hint}\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {actual_device_limit}\n\n'
'Выберите период продления:',
@@ -1805,7 +1806,7 @@ async def select_tariff_extend_period(
await callback.message.edit_text(
f'✅ <b>Подтверждение продления</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {actual_device_limit}\n'
f'📅 Период: {format_period(period)}\n'
@@ -1840,7 +1841,7 @@ async def select_tariff_extend_period(
await callback.message.edit_text(
f'❌ <b>Недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📅 Период: {format_period(period)}\n'
f'💰 К оплате: {format_price_kopeks(final_price)}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
@@ -1982,7 +1983,7 @@ async def confirm_tariff_extend(
await callback.message.edit_text(
f'🎉 <b>Подписка успешно продлена!</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {actual_device_limit}\n'
f'📅 Добавлено: {format_period(period)}\n'
@@ -2059,10 +2060,10 @@ def format_tariff_switch_list_text(
discount_icon = '🔥'
price_text = f'от {format_price_kopeks(min_price, compact=True)}{discount_icon}'
lines.append(f'<b>{tariff.name}</b> — {traffic} / {tariff.device_limit} 📱 {price_text}')
lines.append(f'<b>{html.escape(tariff.name)}</b> — {traffic} / {tariff.device_limit} 📱 {price_text}')
if tariff.description:
lines.append(f'<i>{tariff.description}</i>')
lines.append(f'<i>{html.escape(tariff.description)}</i>')
lines.append('')
@@ -2198,7 +2199,7 @@ async def show_tariff_switch_list(
if current_tariff_id:
current_tariff = await get_tariff_by_id(db, current_tariff_id)
if current_tariff:
current_tariff_name = current_tariff.name
current_tariff_name = html.escape(current_tariff.name)
# Проверяем есть ли у пользователя скидки по периодам
promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
@@ -2269,7 +2270,7 @@ async def select_tariff_switch(
if user_balance >= daily_price:
await callback.message.edit_text(
f'✅ <b>Подтверждение смены тарифа</b>\n\n'
f'📦 Новый тариф: <b>{tariff.name}</b>\n'
f'📦 Новый тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: <b>Суточный</b>\n\n'
@@ -2295,7 +2296,7 @@ async def select_tariff_switch(
missing = daily_price - user_balance
await callback.message.edit_text(
f'❌ <b>Недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'🔄 Тип: Суточный\n'
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
f'{discount_text}\n\n'
@@ -2312,7 +2313,7 @@ async def select_tariff_switch(
)
else:
# Для обычного тарифа показываем выбор периода
info_text = f"""📦 <b>{tariff.name}</b>
info_text = f"""📦 <b>{html.escape(tariff.name)}</b>
<b>Параметры нового тарифа:</b>
Трафик: {traffic}
@@ -2320,7 +2321,7 @@ async def select_tariff_switch(
"""
if tariff.description:
info_text += f'\n📝 {tariff.description}\n'
info_text += f'\n📝 {html.escape(tariff.description)}\n'
info_text += '\n⚠️ Оплачивается полная стоимость тарифа.\nВыберите период:'
@@ -2381,7 +2382,7 @@ async def select_tariff_switch_period(
if current_tariff_id:
current_tariff = await get_tariff_by_id(db, current_tariff_id)
if current_tariff:
current_tariff_name = current_tariff.name
current_tariff_name = html.escape(current_tariff.name)
# Получаем текущую подписку для расчёта оставшегося времени
subscription = await get_subscription_by_user_id(db, db_user.id)
@@ -2399,7 +2400,7 @@ async def select_tariff_switch_period(
await callback.message.edit_text(
f'✅ <b>Подтверждение переключения тарифа</b>\n\n'
f'📌 Текущий тариф: <b>{current_tariff_name}</b>\n'
f'📦 Новый тариф: <b>{tariff.name}</b>\n'
f'📦 Новый тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'{time_info}\n'
@@ -2414,7 +2415,7 @@ async def select_tariff_switch_period(
missing = final_price - user_balance
await callback.message.edit_text(
f'❌ <b>Недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📅 Период: {format_period(period)}\n'
f'💰 К оплате: {format_price_kopeks(final_price)}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
@@ -2591,7 +2592,7 @@ async def confirm_tariff_switch(
await callback.message.edit_text(
f'🎉 <b>Тариф успешно изменён!</b>\n\n'
f'📦 Новый тариф: <b>{tariff.name}</b>\n'
f'📦 Новый тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'💰 Списано: {format_price_kopeks(final_price)}\n'
@@ -2788,7 +2789,7 @@ async def confirm_daily_tariff_switch(
await callback.message.edit_text(
f'🎉 <b>Тариф успешно изменён!</b>\n\n'
f'📦 Новый тариф: <b>{tariff.name}</b>\n'
f'📦 Новый тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: Суточный\n'
@@ -2864,7 +2865,7 @@ def format_instant_switch_list_text(
"""Форматирует текст со списком тарифов для мгновенного переключения."""
lines = [
'📦 <b>Мгновенная смена тарифа</b>',
f'📌 Текущий: <b>{current_tariff.name}</b>',
f'📌 Текущий: <b>{html.escape(current_tariff.name)}</b>',
f'⏰ Осталось: <b>{remaining_days} дн.</b>',
'',
'💡 При переключении остаток дней сохраняется.',
@@ -2888,10 +2889,10 @@ def format_instant_switch_list_text(
else:
cost_text = '⬇️ Бесплатно'
lines.append(f'<b>{tariff.name}</b> — {traffic} / {tariff.device_limit} 📱 {cost_text}')
lines.append(f'<b>{html.escape(tariff.name)}</b> — {traffic} / {tariff.device_limit} 📱 {cost_text}')
if tariff.description:
lines.append(f'<i>{tariff.description}</i>')
lines.append(f'<i>{html.escape(tariff.description)}</i>')
lines.append('')
@@ -3109,10 +3110,10 @@ async def preview_instant_switch(
if user_balance >= daily_price:
await callback.message.edit_text(
f'🔄 <b>Переключение на суточный тариф</b>\n\n'
f'📌 Текущий: <b>{current_tariff.name}</b>\n'
f'📌 Текущий: <b>{html.escape(current_tariff.name)}</b>\n'
f' • Трафик: {current_traffic}\n'
f' • Устройств: {current_tariff.device_limit}\n\n'
f'📦 Новый: <b>{new_tariff.name}</b>\n'
f'📦 Новый: <b>{html.escape(new_tariff.name)}</b>\n'
f' • Трафик: {traffic}\n'
f' • Устройств: {new_tariff.device_limit}\n'
f' • Тип: 🔄 Суточный\n\n'
@@ -3128,7 +3129,7 @@ async def preview_instant_switch(
missing = daily_price - user_balance
await callback.message.edit_text(
f'❌ <b>Недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{new_tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(new_tariff.name)}</b>\n'
f'🔄 Тип: Суточный\n'
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
f'{discount_text}\n\n'
@@ -3154,10 +3155,10 @@ async def preview_instant_switch(
if user_balance >= upgrade_cost:
await callback.message.edit_text(
f'⬆️ <b>Повышение тарифа</b>\n\n'
f'📌 Текущий: <b>{current_tariff.name}</b>\n'
f'📌 Текущий: <b>{html.escape(current_tariff.name)}</b>\n'
f' • Трафик: {current_traffic}\n'
f' • Устройств: {current_tariff.device_limit}\n\n'
f'📦 Новый: <b>{new_tariff.name}</b>\n'
f'📦 Новый: <b>{html.escape(new_tariff.name)}</b>\n'
f' • Трафик: {traffic}\n'
f' • Устройств: {new_tariff.device_limit}\n\n'
f'⏰ Осталось дней: <b>{remaining_days}</b>\n'
@@ -3171,7 +3172,7 @@ async def preview_instant_switch(
missing = upgrade_cost - user_balance
await callback.message.edit_text(
f'❌ <b>Недостаточно средств</b>\n\n'
f'📦 Новый тариф: <b>{new_tariff.name}</b>\n'
f'📦 Новый тариф: <b>{html.escape(new_tariff.name)}</b>\n'
f'💰 Требуется доплата: {format_price_kopeks(upgrade_cost)}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>',
@@ -3182,10 +3183,10 @@ async def preview_instant_switch(
# Downgrade или тот же уровень - бесплатно
await callback.message.edit_text(
f'⬇️ <b>Переключение тарифа</b>\n\n'
f'📌 Текущий: <b>{current_tariff.name}</b>\n'
f'📌 Текущий: <b>{html.escape(current_tariff.name)}</b>\n'
f' • Трафик: {current_traffic}\n'
f' • Устройств: {current_tariff.device_limit}\n\n'
f'📦 Новый: <b>{new_tariff.name}</b>\n'
f'📦 Новый: <b>{html.escape(new_tariff.name)}</b>\n'
f' • Трафик: {traffic}\n'
f' • Устройств: {new_tariff.device_limit}\n\n'
f'⏰ Осталось дней: <b>{remaining_days}</b>\n'
@@ -3433,7 +3434,7 @@ async def confirm_instant_switch(
if is_new_daily:
await callback.message.edit_text(
f'🎉 <b>Тариф успешно изменён!</b>\n\n'
f'📦 Новый тариф: <b>{new_tariff.name}</b>\n'
f'📦 Новый тариф: <b>{html.escape(new_tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {new_tariff.device_limit}\n'
f'🔄 Тип: Суточный\n'
@@ -3455,7 +3456,7 @@ async def confirm_instant_switch(
await callback.message.edit_text(
f'🎉 <b>Тариф успешно изменён!</b>\n\n'
f'📦 Новый тариф: <b>{new_tariff.name}</b>\n'
f'📦 Новый тариф: <b>{html.escape(new_tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {new_tariff.device_limit}\n'
f'⏰ Осталось дней: {remaining_days}\n'
@@ -3509,7 +3510,7 @@ async def return_to_saved_tariff_cart(
if cart_mode == 'daily_tariff_purchase':
await callback.message.edit_text(
f'❌ <b>Все еще недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'🔄 Тип: Суточный\n'
f'💰 Стоимость: {format_price_kopeks(total_price)}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
@@ -3521,7 +3522,7 @@ async def return_to_saved_tariff_cart(
period = cart_data.get('period_days', 30)
await callback.message.edit_text(
f'❌ <b>Все еще недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📅 Период: {format_period(period)}\n'
f'💰 Стоимость: {format_price_kopeks(total_price)}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
@@ -3533,7 +3534,7 @@ async def return_to_saved_tariff_cart(
period = cart_data.get('period_days', 30)
await callback.message.edit_text(
f'❌ <b>Все еще недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📅 Период: {format_period(period)}\n'
f'💰 Стоимость: {format_price_kopeks(total_price)}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
@@ -3552,7 +3553,7 @@ async def return_to_saved_tariff_cart(
await callback.message.edit_text(
f'✅ <b>Подтверждение покупки</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: Суточный\n'
@@ -3572,7 +3573,7 @@ async def return_to_saved_tariff_cart(
await callback.message.edit_text(
f'✅ <b>Подтверждение продления</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'📅 Период: {format_period(period)}\n'
@@ -3602,7 +3603,7 @@ async def return_to_saved_tariff_cart(
await callback.message.edit_text(
f'✅ <b>Подтверждение покупки</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'📅 Период: {format_period(period)}\n'
+15 -11
View File
@@ -1,4 +1,5 @@
import asyncio
import html
import time
import structlog
@@ -256,7 +257,7 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
texts = get_texts(db_user.language)
# Ограничим длину подтверждения чтобы не упереться в лимиты
safe_title = title if len(title) <= 200 else (title[:197] + '...')
safe_title = html.escape(title if len(title) <= 200 else (title[:197] + '...'))
creation_text = (
f'✅ <b>Тикет #{ticket.id} создан</b>\n\n'
f'📝 Заголовок: {safe_title}\n'
@@ -542,7 +543,7 @@ async def view_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSes
header = (
f'🎫 Тикет #{ticket.id}\n\n'
f'📝 Заголовок: {ticket.title}\n'
f'📝 Заголовок: {html.escape(ticket.title or "")}\n'
f'📊 Статус: {ticket.status_emoji} {status_text}\n'
f'📅 Создан: {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n\n'
)
@@ -551,7 +552,7 @@ async def view_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSes
message_blocks.append(f'💬 Сообщения ({len(ticket.messages)}):\n\n')
for msg in ticket.messages:
sender = '👤 Вы' if msg.is_user_message else '🛠️ Поддержка'
block = f'{sender} ({format_local_datetime(msg.created_at, "%d.%m %H:%M")}):\n{msg.message_text}\n\n'
block = f'{sender} ({format_local_datetime(msg.created_at, "%d.%m %H:%M")}):\n{html.escape(msg.message_text or "")}\n\n'
if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo':
block += '📎 Вложение: фото\n\n'
message_blocks.append(block)
@@ -1006,9 +1007,9 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
user = await get_user_by_id(db, ticket.user_id)
except Exception:
user = None
full_name = user.full_name if user else 'Unknown'
full_name = html.escape(user.full_name or '') if user else 'Unknown'
telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else ''
username_display = (user.username or 'отсутствует') if user else 'отсутствует'
username_display = html.escape((user.username or 'отсутствует') if user else 'отсутствует')
# Загружаем первое сообщение для получения медиа и превью текста
first_message = await TicketMessageCRUD.get_first_message(db, ticket.id)
@@ -1022,17 +1023,19 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
if msg_text:
message_preview = msg_text[:200] + '...' if len(msg_text) > 200 else msg_text
safe_title = html.escape(title) if title else ''
notification_text = (
f'🎫 <b>НОВЫЙ ТИКЕТ</b>\n\n'
f'🆔 <b>ID:</b> <code>{ticket.id}</code>\n'
f'👤 <b>Пользователь:</b> {full_name}\n'
f'🆔 <b>ID:</b> <code>{telegram_id_display}</code>\n'
f'📱 <b>Username:</b> @{username_display}\n'
f'📝 <b>Заголовок:</b> {title or ""}\n'
f'📝 <b>Заголовок:</b> {safe_title}\n'
)
if message_preview:
notification_text += f'\n📩 <b>Сообщение:</b>\n{message_preview}\n'
notification_text += f'\n📩 <b>Сообщение:</b>\n{html.escape(message_preview)}\n'
notification_text += f'\n📅 <b>Создан:</b> {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n'
@@ -1076,20 +1079,21 @@ async def notify_admins_about_ticket_reply(
user = await get_user_by_id(db, ticket.user_id)
except Exception:
user = None
full_name = user.full_name if user else 'Unknown'
full_name = html.escape(user.full_name or '') if user else 'Unknown'
telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else ''
username_display = (user.username or 'отсутствует') if user else 'отсутствует'
username_display = html.escape((user.username or 'отсутствует') if user else 'отсутствует')
reply_preview = reply_text[:200] + '...' if len(reply_text) > 200 else reply_text
safe_title = html.escape(title) if title else ''
notification_text = (
f'💬 <b>ОТВЕТ НА ТИКЕТ</b>\n\n'
f'🆔 <b>ID тикета:</b> <code>{ticket.id}</code>\n'
f'📝 <b>Заголовок:</b> {title or ""}\n'
f'📝 <b>Заголовок:</b> {safe_title}\n'
f'👤 <b>Пользователь:</b> {full_name}\n'
f'🆔 <b>ID:</b> <code>{telegram_id_display}</code>\n'
f'📱 <b>Username:</b> @{username_display}\n\n'
f'📩 <b>Сообщение:</b>\n{reply_preview}\n'
f'📩 <b>Сообщение:</b>\n{html.escape(reply_preview)}\n'
)
from app.services.maintenance_service import maintenance_service
+19 -7
View File
@@ -1612,14 +1612,26 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
if settings.is_platega_enabled() and settings.get_platega_active_methods():
platega_name = settings.get_platega_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_PLATEGA', f'💳 {platega_name}'),
callback_data=_build_callback('platega'),
if settings.PLATEGA_INLINE_METHODS:
for method_code in settings.get_platega_active_methods():
title = settings.get_platega_method_display_title(method_code)
keyboard.append(
[
InlineKeyboardButton(
text=f'{title} ({platega_name})',
callback_data=_build_callback(f'platega_m{method_code}'),
)
]
)
]
)
else:
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_PLATEGA', f'💳 {platega_name}'),
callback_data=_build_callback('platega'),
)
]
)
has_direct_payment_methods = True
if settings.is_cryptobot_enabled():
+3 -5
View File
@@ -1306,7 +1306,7 @@
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up",
"REFERRAL_INFO": "\n🤝 <b>Referral program</b>\n\n👥 <b>Invited:</b> {referrals_count} friends\n💰 <b>Earned:</b> {earned_amount}\n\n🔗 <b>Your referral link:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Your promo code:</b>\n<code>{referral_code}</code>\n\n💰 <b>Terms:</b>\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n",
"REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!",
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:",
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the text below to copy:",
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Invitation created!</b>",
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection",
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection",
@@ -1314,6 +1314,7 @@
"REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!",
"REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:",
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Invitation to the VPN service</b>\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n",
"REFERRAL_INVITE_TEXT": "🎉 Join the VPN service!{bonus_block}\n\n🚀 Fast connection\n🌍 Servers worldwide\n🔒 Reliable protection\n\n👇 Follow the link:\n{link}{cabinet_block}",
"REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!",
"REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>Your referral link:</b>",
@@ -1724,19 +1725,16 @@
"MODEM_PRICE_WITH_DISCOUNT": "Cost: <s>{base_price}</s> <b>{final_price}</b> (for {months} months)\n🎁 Discount {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Cost: {price} (for {months} months)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Confirm modem connection</b>\n\n{price_text}\n\nWhen connecting a modem:\n• An additional device will be added to your subscription\n• Monthly fee will increase by {monthly_price}\n\nConfirm connection?",
"ADMIN_USER_RESTRICTIONS": "⚠️ Restrict",
"USER_RESTRICTION_TOPUP_BLOCKED": "🚫 <b>Top-up restricted</b>\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.",
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 <b>Subscription purchase/renewal restricted</b>\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.",
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Appeal",
"PAUSE_DAILY_BUTTON": "⏸️ Pause subscription",
"RESUME_DAILY_BUTTON": "▶️ Resume subscription",
"DAILY_SWITCH_WARNING": "⚠️ <b>Warning!</b> You have {days} days left.\nThey will be lost when switching to daily tariff!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!",
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Subscription resumed!</b>\n\nYour daily plan «{tariff_name}» has been resumed after balance top-up.\n\n💳 Charged: {amount}\n💰 Remaining: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew to restore VPN access.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>Subscription disabled</b>\n\nYour subscription has been disabled by the administrator.",
"WEBHOOK_SUB_ENABLED": "✅ <b>Subscription activated</b>\n\nYour subscription is active again. Enjoy!",
@@ -1755,4 +1753,4 @@
"WEBHOOK_DEVICE_ADDED": "📱 <b>New device</b>\n\nA new device has been added to your subscription: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>Device removed</b>\n\nA device has been removed from your subscription: <code>{device}</code>",
"WEBHOOK_CLOSE_BUTTON": "✖️ Close"
}
}
+2 -1
View File
@@ -1327,7 +1327,7 @@
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 اولین شارژ",
"REFERRAL_INFO": "🤝 <b>دعوت دوستان</b>\n\nلینک دعوت را به اشتراک بگذارید و از هر خرید کمیسیون بگیرید!",
"REFERRAL_INVITE_BONUS": "💎 با اولین شارژ از {minimum} مبلغ {bonus} پاداش دریافت می‌کنی!",
"REFERRAL_INVITE_CREATED_INSTRUCTION": "دکمه «📤 اشتراک‌گذاری» را بزنید یا متن زیر را کپی کنید:",
"REFERRAL_INVITE_CREATED_INSTRUCTION": "روی متن زیر بزنید تا کپی شود:",
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>دعوت‌نامه ایجاد شد!</b>",
"REFERRAL_INVITE_FEATURE_FAST": "🚀 اتصال سریع",
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 امنیت بالا",
@@ -1335,6 +1335,7 @@
"REFERRAL_INVITE_FOOTER": "📢 دوستان را دعوت کنید و درآمد کسب کنید!",
"REFERRAL_INVITE_LINK_PROMPT": "👇 از لینک زیر استفاده کن:",
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>دعوت به سرویس VPN</b>\n\nسلام! به سرویس VPN عالی دعوتت می‌کنم!\n\n🎁 با لینک من پاداش بگیر: {bonus}\n\n🔗 لینک: {link}\n🎫 یا کد: {code}\n\n💪 سریع، امن، مقرون‌به‌صرفه!\n",
"REFERRAL_INVITE_TEXT": "🎉 به سرویس VPN بپیوند!{bonus_block}\n\n🚀 اتصال سریع\n🌍 سرورهای سراسر جهان\n🔒 امنیت بالا\n\n👇 از لینک زیر استفاده کن:\n{link}{cabinet_block}",
"REFERRAL_INVITE_TITLE": "🎉 به سرویس VPN بپیوند!",
"REFERRAL_LINK_CAPTION": "🔗 لینک دعوت شما:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>لینک دعوت شما:</b>",
+2 -1
View File
@@ -1327,7 +1327,7 @@
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение",
"REFERRAL_INFO": "\n🤝 <b>Реферальная программа</b>\n\n👥 <b>Приглашено:</b> {referrals_count} друзей\n💰 <b>Заработано:</b> {earned_amount}\n\n🔗 <b>Ваша реферальная ссылка:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Ваш промокод:</b>\n<code>{referral_code}</code>\n\n💰 <b>Условия:</b>\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n",
"REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:",
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите на текст ниже, чтобы скопировать:",
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Приглашение создано!</b>",
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение",
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита",
@@ -1335,6 +1335,7 @@
"REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!",
"REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:",
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Приглашение в VPN сервис</b>\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n",
"REFERRAL_INVITE_TEXT": "🎉 Присоединяйся к VPN сервису!{bonus_block}\n\n🚀 Быстрое подключение\n🌍 Серверы по всему миру\n🔒 Надежная защита\n\n👇 Переходи по ссылке:\n{link}{cabinet_block}",
"REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!",
"REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>Ваша реферальная ссылка:</b>",
+2 -1
View File
@@ -1243,7 +1243,7 @@
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Перше поповнення",
"REFERRAL_INFO": "\n🤝 <b>Реферальна програма</b>\n\n👥 <b>Запрошено:</b> {referrals_count} друзів\n💰 <b>Зароблено:</b> {earned_amount}\n\n🔗 <b>Ваше реферальне посилання:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Ваш промокод:</b>\n<code>{referral_code}</code>\n\n💰 <b>Умови:</b>\n• За кожного друга: {registration_bonus}\n• Відсоток з поповнень: {commission_percent}%\n",
"REFERRAL_INVITE_BONUS": "💎 При першому поповненні від {minimum} ти отримаєш {bonus} бонусом на баланс!",
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Натисніть кнопку «📤 Поділитися», щоб надіслати запрошення в будь-який чат, або скопіюйте текст нижче:",
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Натисніть на текст нижче, щоб скопіювати:",
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Запрошення створено!</b>",
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Швидке підключення",
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надійний захист",
@@ -1251,6 +1251,7 @@
"REFERRAL_INVITE_FOOTER": "📢 Запрошуйте друзів та заробляйте!",
"REFERRAL_INVITE_LINK_PROMPT": "👇 Переходь за посиланням:",
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Запрошення до VPN сервісу</b>\n\nПривіт! Запрошую тебе у відмінний VPN сервіс!\n\n🎁 За моїм посиланням ти отримаєш бонус: {bonus}\n\n🔗 Переходь: {link}\n🎫 Або використовуй промокод: {code}\n\n💪 Швидко, надійно, недорого!\n",
"REFERRAL_INVITE_TEXT": "🎉 Приєднуйся до VPN сервісу!{bonus_block}\n\n🚀 Швидке підключення\n🌍 Сервери по всьому світу\n🔒 Надійний захист\n\n👇 Переходь за посиланням:\n{link}{cabinet_block}",
"REFERRAL_INVITE_TITLE": "🎉 Приєднуйся до VPN сервісу!",
"REFERRAL_LINK_CAPTION": "🔗 Ваше реферальне посилання:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>Ваше реферальне посилання:</b>",
+2 -1
View File
@@ -1241,7 +1241,7 @@
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉首次充值",
"REFERRAL_INFO": "\n🤝<b>推荐计划</b>\n\n👥<b>已邀请:</b>{referrals_count}位朋友\n💰<b>已赚取:</b>{earned_amount}\n\n🔗<b>您的推荐链接:</b>\n<code>{referral_link}</code>\n\n🎫<b>您的优惠码:</b>\n<code>{referral_code}</code>\n\n💰<b>条件:</b>\n•每位朋友:{registration_bonus}\n•充值百分比:{commission_percent}%\n",
"REFERRAL_INVITE_BONUS": "💎首次充值{minimum}起,您将获得{bonus}余额奖励!",
"REFERRAL_INVITE_CREATED_INSTRUCTION": "点击“📤分享”按钮将邀请发送到任何聊天,或复制以下文本",
"REFERRAL_INVITE_CREATED_INSTRUCTION": "点击下方文本即可复制",
"REFERRAL_INVITE_CREATED_TITLE": "📝<b>邀请已创建!</b>",
"REFERRAL_INVITE_FEATURE_FAST": "🚀快速连接",
"REFERRAL_INVITE_FEATURE_SECURE": "🔒可靠保护",
@@ -1249,6 +1249,7 @@
"REFERRAL_INVITE_FOOTER": "📢邀请朋友并赚钱!",
"REFERRAL_INVITE_LINK_PROMPT": "👇点击链接:",
"REFERRAL_INVITE_MESSAGE": "\n🎯<b>邀请加入VPN服务</b>\n\n嗨!我邀请您加入一个很棒的VPN服务!\n\n🎁通过我的链接,您将获得奖励:{bonus}\n\n🔗点击:{link}\n🎫或使用优惠码:{code}\n\n💪快速、可靠、不贵!\n",
"REFERRAL_INVITE_TEXT": "🎉加入VPN服务!{bonus_block}\n\n🚀快速连接\n🌍全球服务器\n🔒可靠保护\n\n👇点击链接:\n{link}{cabinet_block}",
"REFERRAL_INVITE_TITLE": "🎉加入VPN服务!",
"REFERRAL_LINK_CAPTION": "🔗您的推荐链接:\n{link}",
"REFERRAL_LINK_TITLE": "🔗<b>您的推荐链接:</b>",
+5 -1
View File
@@ -243,9 +243,13 @@ class AuthMiddleware(BaseMiddleware):
logger.debug('AuthMiddleware: bot blocked by user, skipping')
return None
except TelegramBadRequest as e:
if 'query is too old' in str(e):
error_msg = str(e).lower()
if 'query is too old' in error_msg:
logger.debug('AuthMiddleware: callback query expired, skipping')
return None
if 'message is not modified' in error_msg:
logger.debug('AuthMiddleware: message not modified, skipping')
return None
raise
except Exception as e:
logger.error('Ошибка в AuthMiddleware', error=e)
+32 -4
View File
@@ -23,9 +23,11 @@ from app.database.models import (
CryptoBotPayment,
DiscountOffer,
FreekassaPayment,
GuestPurchase,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
NewsArticle,
Pal24Payment,
PartnerApplication,
PartnerStatus,
@@ -40,10 +42,14 @@ from app.database.models import (
ReferralContest,
ReferralContestEvent,
ReferralEarning,
RioPayPayment,
SavedPaymentMethod,
SentNotification,
SeverPayPayment,
Subscription,
SubscriptionConversion,
SubscriptionEvent,
SubscriptionServer,
SupportAuditLog,
Ticket,
TicketMessage,
@@ -78,6 +84,8 @@ _PAYMENT_MODELS: tuple[type, ...] = (
MulenPayPayment,
Pal24Payment,
PlategaPayment,
RioPayPayment,
SeverPayPayment,
WataPayment,
YooKassaPayment,
)
@@ -280,10 +288,12 @@ async def _handle_subscription_merge(
if not has_primary_sub and has_secondary_sub:
assert secondary_sub is not None
secondary_sub.user_id = primary.id
# Переносим remnawave_uuid с secondary на primary
# Переносим remnawave_uuid (clear→flush→assign — unique constraint safety)
if secondary.remnawave_uuid:
primary.remnawave_uuid = secondary.remnawave_uuid
uuid_to_transfer = secondary.remnawave_uuid
secondary.remnawave_uuid = None
await db.flush()
primary.remnawave_uuid = uuid_to_transfer
await db.flush()
logger.info(
'Мерж подписок: перенесена подписка secondary на primary',
@@ -301,15 +311,19 @@ async def _handle_subscription_merge(
if primary.remnawave_uuid:
await _delete_remnawave_user_with_fallback(primary.remnawave_uuid)
primary.remnawave_uuid = None
# Явно удаляем subscription_servers перед подпиской (CASCADE настроен, но делаем явно для ясности)
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == primary_sub.id))
# Удаляем запись подписки primary
await db.delete(primary_sub)
await db.flush()
# Переносим подписку secondary на primary
secondary_sub.user_id = primary.id
# Переносим remnawave_uuid
# Переносим remnawave_uuid (clear→flush→assign — unique constraint safety)
if secondary.remnawave_uuid:
primary.remnawave_uuid = secondary.remnawave_uuid
uuid_to_transfer = secondary.remnawave_uuid
secondary.remnawave_uuid = None
await db.flush()
primary.remnawave_uuid = uuid_to_transfer
# Flush сразу — гарантируем, что DELETE предшествует UPDATE (unique constraint на subscription.user_id)
await db.flush()
logger.info(
@@ -323,6 +337,8 @@ async def _handle_subscription_merge(
if secondary.remnawave_uuid:
await _delete_remnawave_user_with_fallback(secondary.remnawave_uuid)
secondary.remnawave_uuid = None
# Явно удаляем subscription_servers перед подпиской (CASCADE настроен, но делаем явно для ясности)
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == secondary_sub.id))
# Удаляем запись подписки secondary
await db.delete(secondary_sub)
await db.flush()
@@ -492,6 +508,11 @@ async def execute_merge(
for payment_model in _PAYMENT_MODELS:
await db.execute(update(payment_model).where(payment_model.user_id == secondary.id).values(user_id=primary.id))
# 7b. Переназначение saved_payment_methods (FK без ondelete)
await db.execute(
update(SavedPaymentMethod).where(SavedPaymentMethod.user_id == secondary.id).values(user_id=primary.id)
)
# 8. Переназначение referral_earnings
# 8a. Удаляем cross-referral записи между участниками мержа (иначе станут self-referral)
await db.execute(
@@ -702,6 +723,13 @@ async def execute_merge(
await db.execute(update(PinnedMessage).where(PinnedMessage.created_by == secondary.id).values(created_by=None))
await db.execute(update(AdminRole).where(AdminRole.created_by == secondary.id).values(created_by=None))
await db.execute(update(AccessPolicy).where(AccessPolicy.created_by == secondary.id).values(created_by=None))
await db.execute(update(NewsArticle).where(NewsArticle.created_by == secondary.id).values(created_by=None))
# 10s. Переназначение guest_purchases (оба FK — buyer_user_id и user_id)
await db.execute(
update(GuestPurchase).where(GuestPurchase.buyer_user_id == secondary.id).values(buyer_user_id=primary.id)
)
await db.execute(update(GuestPurchase).where(GuestPurchase.user_id == secondary.id).values(user_id=primary.id))
# 11. Инвалидация refresh-токенов обоих пользователей (после мержа будет создан новый)
now = datetime.now(UTC)
+45 -33
View File
@@ -77,11 +77,11 @@ class AdminNotificationService:
return f'ID {referred_by_id} (не найден)'
if referrer.username:
return f'@{referrer.username} (ID: {referred_by_id})'
return f'@{html.escape(referrer.username)} (ID: {referred_by_id})'
if referrer.telegram_id:
return f'ID {referrer.telegram_id}'
if referrer.email:
return f'📧 {referrer.email}'
return f'📧 {html.escape(referrer.email)}'
return f'User#{referred_by_id}'
except Exception as e:
@@ -118,17 +118,17 @@ class AdminNotificationService:
def _get_user_display(self, user: User) -> str:
first_name = getattr(user, 'first_name', '') or ''
if first_name:
return first_name
return html.escape(first_name)
username = getattr(user, 'username', '') or ''
if username:
return username
return html.escape(username)
telegram_id = getattr(user, 'telegram_id', None)
if telegram_id is None:
email = getattr(user, 'email', None)
if email:
return email
return html.escape(email)
return f'User#{getattr(user, "id", "Unknown")}'
return f'ID{telegram_id}'
@@ -140,7 +140,7 @@ class AdminNotificationService:
email = getattr(user, 'email', None)
if email:
return f'📧 {email}'
return f'📧 {html.escape(email)}'
return f'User#{getattr(user, "id", "Unknown")}'
@@ -249,7 +249,7 @@ class AdminNotificationService:
if not promo_group:
return f'{icon} <b>{title}:</b> —'
lines = [f'{icon} <b>{title}:</b> {promo_group.name}']
lines = [f'{icon} <b>{title}:</b> {html.escape(promo_group.name)}']
discount_lines = self._format_promo_group_discounts(promo_group)
if discount_lines:
@@ -265,6 +265,8 @@ class AdminNotificationService:
PromoCodeType.BALANCE.value: '💰 Бонус на баланс',
PromoCodeType.SUBSCRIPTION_DAYS.value: '⏰ Доп. дни подписки',
PromoCodeType.TRIAL_SUBSCRIPTION.value: '🎁 Триал подписка',
PromoCodeType.PROMO_GROUP.value: '👥 Промогруппа',
PromoCodeType.DISCOUNT.value: '💸 Скидка',
}
if not promo_type:
@@ -357,14 +359,14 @@ class AdminNotificationService:
'',
f'👤 <b>Пользователь:</b> {user_display}',
f'🆔 <b>{user_id_label}:</b> {user_id_display}',
f'📱 <b>Username:</b> @{getattr(user, "username", None) or "отсутствует"}',
f'📱 <b>Username:</b> @{html.escape(getattr(user, "username", None) or "отсутствует")}',
f'👥 <b>Статус:</b> {user_status}',
'',
]
# Промогруппа — только название, без скидок
if promo_group:
message_lines.append(f'🏷️ <b>Промогруппа:</b> {promo_group.name}')
message_lines.append(f'🏷️ <b>Промогруппа:</b> {html.escape(promo_group.name)}')
else:
message_lines.append('🏷️ <b>Промогруппа:</b> —')
@@ -417,7 +419,7 @@ class AdminNotificationService:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
return tariff.name
return html.escape(tariff.name)
except Exception:
pass
return None
@@ -494,7 +496,7 @@ class AdminNotificationService:
# Добавляем username только если есть
username = getattr(user, 'username', None)
if username:
message_lines.append(f'📱 @{username}')
message_lines.append(f'📱 @{html.escape(username)}')
message_lines.append(f'📋 {user_status}')
@@ -657,13 +659,13 @@ class AdminNotificationService:
username = getattr(user, 'username', None)
if username:
message_lines.append(f'📱 @{username}')
message_lines.append(f'📱 @{html.escape(username)}')
message_lines.append(f'💳 {topup_status}')
# Промогруппа -- только название
if promo_group:
message_lines.append(f'🏷️ Промогруппа: {promo_group.name}')
message_lines.append(f'🏷️ Промогруппа: {html.escape(promo_group.name)}')
message_lines.append('')
@@ -697,7 +699,7 @@ class AdminNotificationService:
desc = transaction.description
if len(desc) > 120:
desc = desc[:117] + '...'
detail_lines.append(f'Описание: {desc}')
detail_lines.append(f'Описание: {html.escape(desc)}')
if transaction.created_at:
detail_lines.append(f'Создана: {format_local_datetime(transaction.created_at, "%d.%m.%Y %H:%M:%S")}')
@@ -916,7 +918,7 @@ class AdminNotificationService:
👤 <b>Пользователь:</b> {user_display}
🆔 <b>{user_id_label}:</b> {user_id_display}
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
📱 <b>Username:</b> @{html.escape(getattr(user, 'username', None) or 'отсутствует')}
{promo_block}
@@ -1003,7 +1005,7 @@ class AdminNotificationService:
'',
f'👤 <b>Пользователь:</b> {user_display}',
f'🆔 <b>{user_id_label}:</b> {user_id_display}',
f'📱 <b>Username:</b> @{getattr(user, "username", None) or "отсутствует"}',
f'📱 <b>Username:</b> @{html.escape(getattr(user, "username", None) or "отсутствует")}',
'',
promo_block,
'',
@@ -1013,13 +1015,21 @@ class AdminNotificationService:
f'📊 Использования: {usage_info}',
]
promo_type = promocode_data.get('type')
balance_bonus = promocode_data.get('balance_bonus_kopeks', 0)
if balance_bonus:
message_lines.append(f'💰 Бонус на баланс: {settings.format_price(balance_bonus)}')
subscription_days = promocode_data.get('subscription_days', 0)
if subscription_days:
message_lines.append(f'📅 Доп. дни подписки: {subscription_days}')
if promo_type == PromoCodeType.DISCOUNT.value:
message_lines.append(f'💸 Скидка: {balance_bonus}%')
if subscription_days:
message_lines.append(f'⏳ Срок действия скидки: {subscription_days} ч.')
else:
message_lines.append('⏳ Срок действия скидки: до первой покупки')
else:
if balance_bonus:
message_lines.append(f'💰 Бонус на баланс: {settings.format_price(balance_bonus)}')
if subscription_days:
message_lines.append(f'📅 Доп. дни подписки: {subscription_days}')
valid_until = promocode_data.get('valid_until')
if valid_until:
@@ -1094,13 +1104,13 @@ class AdminNotificationService:
message_lines = [
'📣 <b>ПЕРЕХОД ПО РК</b>',
'',
f'🧾 {campaign.name} (<code>{campaign.start_parameter}</code>)',
f'🧾 {html.escape(campaign.name)} (<code>{html.escape(campaign.start_parameter)}</code>)',
'',
f'👤 {full_name} (<code>{telegram_user.id}</code>)',
f'👤 {html.escape(full_name)} (<code>{telegram_user.id}</code>)',
]
if telegram_user.username:
message_lines.append(f'📱 @{telegram_user.username}')
message_lines.append(f'📱 @{html.escape(telegram_user.username)}')
message_lines.append(f'📋 {user_status}')
@@ -1108,7 +1118,7 @@ class AdminNotificationService:
if user:
promo_group = await self._get_user_promo_group(db, user)
if promo_group:
message_lines.append(f'🏷️ Промогруппа: {promo_group.name}')
message_lines.append(f'🏷️ Промогруппа: {html.escape(promo_group.name)}')
message_lines.append('')
@@ -1120,7 +1130,7 @@ class AdminNotificationService:
tariff = await get_tariff_by_id(db, campaign.tariff_id)
if tariff:
tariff_name = tariff.name
tariff_name = html.escape(tariff.name)
except Exception:
pass
@@ -1186,7 +1196,9 @@ class AdminNotificationService:
title = '🤖 АВТОМАТИЧЕСКАЯ СМЕНА ПРОМОГРУППЫ' if automatic else '👥 СМЕНА ПРОМОГРУППЫ'
initiator_line = None
if initiator:
initiator_line = f'👮 <b>Инициатор:</b> {initiator.full_name} (ID: {initiator.telegram_id})'
initiator_line = (
f'👮 <b>Инициатор:</b> {html.escape(initiator.full_name)} (ID: {initiator.telegram_id})'
)
elif automatic:
initiator_line = '🤖 Автоматическое назначение'
user_display = self._get_user_display(user)
@@ -1198,7 +1210,7 @@ class AdminNotificationService:
'',
f'👤 <b>Пользователь:</b> {user_display}',
f'🆔 <b>{user_id_label}:</b> {user_id_display}',
f'📱 <b>Username:</b> @{getattr(user, "username", None) or "отсутствует"}',
f'📱 <b>Username:</b> @{html.escape(getattr(user, "username", None) or "отсутствует")}',
'',
self._format_promo_group_block(new_group, title='Новая промогруппа', icon='🏆'),
]
@@ -1643,7 +1655,7 @@ class AdminNotificationService:
elif status == 'maintenance':
if details.get('maintenance_reason'):
message_parts.append(f'🔧 <b>Причина:</b> {details["maintenance_reason"]}')
message_parts.append(f'🔧 <b>Причина:</b> {html.escape(details["maintenance_reason"])}')
if details.get('estimated_duration'):
message_parts.append(f'⏰ <b>Ожидаемая длительность:</b> {details["estimated_duration"]}')
@@ -1700,7 +1712,7 @@ class AdminNotificationService:
# Добавляем username только если есть
username = getattr(user, 'username', None)
if username:
message_lines.append(f'📱 @{username}')
message_lines.append(f'📱 @{html.escape(username)}')
# Тариф (если есть)
if tariff_name:
@@ -1806,7 +1818,7 @@ class AdminNotificationService:
username = getattr(user, 'username', None)
if username:
message_lines.append(f'📱 @{username}')
message_lines.append(f'📱 @{html.escape(username)}')
message_lines.append('')
@@ -1861,7 +1873,7 @@ class AdminNotificationService:
username = getattr(user, 'username', None)
if username:
message_lines.append(f'📱 @{username}')
message_lines.append(f'📱 @{html.escape(username)}')
message_lines.extend(
[
@@ -1906,7 +1918,7 @@ class AdminNotificationService:
message_lines = [
'🛑 <b>МАССОВАЯ БЛОКИРОВКА ПОЛЬЗОВАТЕЛЕЙ</b>',
'',
f'👮 <b>Администратор:</b> {admin_name}',
f'👮 <b>Администратор:</b> {html.escape(admin_name)}',
f'🆔 <b>ID администратора:</b> {admin_user_id}',
'',
'📊 <b>Результаты:</b>',
+12 -2
View File
@@ -79,9 +79,19 @@ class ChannelSubscriptionService:
return ch
return None
def should_disable_subscription(self, channel: dict, is_trial: bool) -> bool:
"""Check if a channel's settings require subscription deactivation."""
@staticmethod
def should_disable_subscription(channel: dict, is_trial: bool) -> bool:
"""Check if a channel's settings require subscription deactivation.
Respects both global and per-channel settings:
- Global CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=False overrides per-channel for trials
- Per-channel disable_trial_on_leave / disable_paid_on_leave for fine-grained control
"""
from app.config import settings
if is_trial:
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
return False
return channel.get('disable_trial_on_leave', True)
return channel.get('disable_paid_on_leave', False)
+43 -6
View File
@@ -197,17 +197,54 @@ class DailySubscriptionService:
user_id_display=user_id_display,
)
# Восстанавливаем connected_squads из тарифа, если очищены деактивацией
try:
if not subscription.connected_squads:
squads = tariff.allowed_squads or []
if not squads:
from app.database.crud.server_squad import get_all_server_squads
all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000)
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
if squads:
subscription.connected_squads = squads
await db.commit()
await db.refresh(subscription)
except Exception as sq_err:
logger.warning('Не удалось восстановить connected_squads', error=sq_err)
# Синхронизируем с Remnawave (обновляем срок подписки)
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
sync_squads=True,
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
# POST может игнорировать activeInternalSquads — отправляем PATCH
await db.refresh(user)
if getattr(user, 'remnawave_uuid', None) and subscription.connected_squads:
try:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=False,
sync_squads=True,
)
except Exception as patch_err:
logger.warning('Не удалось синхронизировать сквады после создания', error=patch_err)
except Exception as e:
logger.warning('Не удалось обновить Remnawave', error=e)
+49 -16
View File
@@ -1,4 +1,5 @@
import asyncio
import html
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
@@ -25,6 +26,7 @@ from app.database.crud.subscription import (
get_expired_subscriptions,
get_expiring_subscriptions,
get_subscriptions_for_autopay,
reactivate_subscription,
)
from app.database.crud.user import (
cleanup_expired_promo_offer_discounts,
@@ -585,15 +587,11 @@ class MonitoringService:
When CHANNEL_REQUIRED_FOR_ALL is True, checks ALL active subscriptions
(not just trials). Otherwise only checks trial subscriptions.
"""
from app.database.crud.subscription import is_active_paid_subscription, is_recently_updated_by_webhook
from app.database.crud.subscription import is_recently_updated_by_webhook
if not settings.CHANNEL_IS_REQUIRED_SUB:
return
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
logger.debug('Channel unsubscribe check disabled')
return
if not self.bot:
logger.debug('Skipping channel subscription check - bot unavailable')
return
@@ -606,6 +604,14 @@ class MonitoringService:
if not channels:
return
# When no channel has any disable-on-leave rule, skip deactivation but
# still run reactivation to restore orphaned DISABLED subscriptions
# (e.g., admin turned off disable flags after subscriptions were already disabled).
has_any_disable_rule = any(
ch.get('disable_trial_on_leave', True) or ch.get('disable_paid_on_leave', False) for ch in channels
)
skip_deactivation = not has_any_disable_rule and not settings.CHANNEL_REQUIRED_FOR_ALL
# Ensure bot is set on service
if not channel_subscription_service.bot:
channel_subscription_service.bot = self.bot
@@ -623,9 +629,13 @@ class MonitoringService:
last_id = 0
# Build the trial/all filter based on CHANNEL_REQUIRED_FOR_ALL setting
# Also include paid subs if any channel has disable_paid_on_leave=True,
# so monitoring can reconcile missed real-time events for paid users.
from sqlalchemy import true as sa_true
is_trial_filter = sa_true() if settings.CHANNEL_REQUIRED_FOR_ALL else Subscription.is_trial.is_(True)
has_paid_disable_rule = any(ch.get('disable_paid_on_leave', False) for ch in channels)
include_all = settings.CHANNEL_REQUIRED_FOR_ALL or has_paid_disable_rule
is_trial_filter = sa_true() if include_all else Subscription.is_trial.is_(True)
while True:
# Fresh session per batch to avoid long-running connections
@@ -666,6 +676,10 @@ class MonitoringService:
if not user or not user.telegram_id:
continue
# Skip admins -- consistent with channel_member.py and channel_checker.py
if settings.is_admin(user.telegram_id):
continue
# Existing guard: skip if recently updated by webhook
if is_recently_updated_by_webhook(subscription):
logger.debug(
@@ -678,6 +692,7 @@ class MonitoringService:
# Rate-limited check for ALL channels
all_subscribed = True
unsubscribed_channels: list[dict] = []
for ch in channels:
is_member = await channel_subscription_service._rate_limited_check(
user.telegram_id, ch['channel_id']
@@ -688,14 +703,22 @@ class MonitoringService:
if not is_member:
all_subscribed = False
unsubscribed_channels.append(ch)
# DEACTIVATE: was active, now not subscribed to all
if subscription.status == SubscriptionStatus.ACTIVE.value and not all_subscribed:
# Guard: always skip paid subscriptions (user paid money)
if is_active_paid_subscription(subscription):
if skip_deactivation:
continue
subscription = await deactivate_subscription(batch_db, subscription)
# Respect per-channel disable_trial_on_leave / disable_paid_on_leave settings
should_disable = any(
channel_subscription_service.should_disable_subscription(ch, subscription.is_trial)
for ch in unsubscribed_channels
)
if not should_disable:
continue
subscription = await deactivate_subscription(batch_db, subscription, commit=False)
disabled_count += 1
logger.info(
'Subscription deactivated (channel unsubscribe)',
@@ -728,6 +751,7 @@ class MonitoringService:
user.id,
subscription.id,
'trial_channel_unsubscribed',
commit=False,
)
# REACTIVATE: was disabled, now subscribed to all
@@ -761,10 +785,12 @@ class MonitoringService:
)
continue
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = datetime.now(UTC)
restored_count += 1
subscription = await reactivate_subscription(batch_db, subscription, commit=False)
if subscription.status != SubscriptionStatus.ACTIVE.value:
# reactivate_subscription silently skipped (expired or wrong status)
continue
restored_count += 1
logger.info(
'Subscription restored (channel resubscribe)',
telegram_id=user.telegram_id,
@@ -774,8 +800,11 @@ class MonitoringService:
try:
if user.remnawave_uuid:
await self.subscription_service.update_remnawave_user(batch_db, subscription)
await self.subscription_service.enable_remnawave_user(user.remnawave_uuid)
else:
# create_remnawave_user calls db.commit() internally --
# flush accumulated batch state first to preserve atomicity.
await batch_db.commit()
await self.subscription_service.create_remnawave_user(batch_db, subscription)
except Exception as api_error:
logger.error(
@@ -788,6 +817,7 @@ class MonitoringService:
batch_db,
subscription.id,
'trial_channel_unsubscribed',
commit=False,
)
# Commit all changes for this batch
@@ -1886,9 +1916,12 @@ class MonitoringService:
title = title[:57] + '...'
# Детали пользователя: имя, Telegram ID и username
full_name = ticket.user.full_name if ticket.user else 'Unknown'
full_name = html.escape(ticket.user.full_name or '') if ticket.user else 'Unknown'
telegram_id_display = ticket.user.telegram_id if ticket.user else ''
username_display = (ticket.user.username or 'отсутствует') if ticket.user else 'отсутствует'
username_display = html.escape(
(ticket.user.username or 'отсутствует') if ticket.user else 'отсутствует'
)
safe_title = html.escape(title) if title else ''
text = (
f'⏰ <b>Ожидание ответа на тикет превышено</b>\n\n'
@@ -1896,7 +1929,7 @@ class MonitoringService:
f'👤 <b>Пользователь:</b> {full_name}\n'
f'🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n'
f'📱 <b>Username:</b> @{username_display}\n'
f'📝 <b>Заголовок:</b> {title or ""}\n'
f'📝 <b>Заголовок:</b> {safe_title}\n'
f'⏱️ <b>Ожидает ответа:</b> {waited_minutes} мин\n'
)
+8 -3
View File
@@ -158,7 +158,9 @@ class NalogoQueueService:
# Логируем количество попыток (чек никогда не удаляется из очереди)
if attempts >= 10:
logger.warning('Чек уже попыток, продолжаем пытаться...', payment_id=payment_id, attempts=attempts)
logger.warning(
'Чек уже много попыток, продолжаем пытаться...', payment_id=payment_id, attempts=attempts
)
# Пытаемся отправить чек
try:
@@ -181,11 +183,14 @@ class NalogoQueueService:
# Формируем описание заново из настроек (если есть данные)
if amount_kopeks is not None:
receipt_name = settings.get_balance_payment_description(amount_kopeks, telegram_user_id)
receipt_name = settings.get_balance_payment_description(
amount_kopeks, telegram_user_id=telegram_user_id
)
else:
# Fallback на сохранённое имя
receipt_name = receipt_data.get(
'name', settings.get_balance_payment_description(int(amount * 100), telegram_user_id)
'name',
settings.get_balance_payment_description(int(amount * 100), telegram_user_id=telegram_user_id),
)
receipt_uuid = await self._nalogo_service.create_receipt(
+325
View File
@@ -0,0 +1,325 @@
"""Media processing service for news article images and videos.
Handles file validation (magic bytes), image resizing via Pillow,
thumbnail generation, and atomic file writes with UUID filenames.
"""
from __future__ import annotations
import asyncio
import io
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
import structlog
from PIL import Image, ImageOps
logger = structlog.get_logger(__name__)
# Hard limit on decompressed image pixels to prevent decompression bombs.
# 25M pixels ≈ 5000x5000, roughly 75 MB of raw RGB data — safe for a news editor.
Image.MAX_IMAGE_PIXELS = 25_000_000
# Minimum file size to attempt magic byte detection
_MIN_MAGIC_BYTES = 12
# --- Magic byte signatures for file type detection ---
ALLOWED_IMAGE_SIGNATURES: dict[bytes, str] = {
b'\xff\xd8\xff': '.jpg',
b'\x89PNG': '.png',
# WebP: starts with RIFF....WEBP (bytes 0-3 = RIFF, bytes 8-11 = WEBP)
}
ALLOWED_VIDEO_SIGNATURES: dict[bytes, str] = {
# MP4: bytes 4-7 = 'ftyp'
b'\x1a\x45\xdf\xa3': '.webm',
}
# Known ISO base media file format brands for video.
# Rejects HEIC/HEIF image brands (heic, heix, mif1, msf1, avif) that share the ftyp box format.
_MP4_VIDEO_BRANDS: frozenset[bytes] = frozenset(
{
b'isom',
b'iso2',
b'iso3',
b'iso4',
b'iso5',
b'iso6',
b'mp41',
b'mp42',
b'mp71',
b'M4V ',
b'M4VH',
b'M4VP',
b'MSNV',
b'avc1',
b'mmp4',
b'dash',
b'3gp4',
b'3gp5',
b'3gp6',
b'NDAS',
b'NDSC',
b'NDSH',
b'NDSS',
b'NDSM',
b'NDSP',
b'qt ',
}
)
_IMAGES_DIR = 'images'
_VIDEOS_DIR = 'videos'
_THUMBNAILS_DIR = 'thumbnails'
_THUMBNAIL_SIZE = (400, 400)
@dataclass(frozen=True, slots=True)
class SavedMedia:
"""Result of saving a media file."""
filename: str
relative_path: str
thumbnail_path: str | None
media_type: Literal['image', 'video']
content_type: str
size_bytes: int
width: int | None
height: int | None
def ensure_upload_dirs(upload_path: Path) -> None:
"""Create images/, videos/, thumbnails/ subdirectories under upload_path."""
for subdir in (_IMAGES_DIR, _VIDEOS_DIR, _THUMBNAILS_DIR):
(upload_path / subdir).mkdir(parents=True, exist_ok=True)
MediaType = Literal['image', 'video']
def detect_file_type(data: bytes) -> tuple[MediaType, str]:
"""Detect media type and extension from magic bytes.
Returns:
Tuple of (media_type, extension), e.g. ('image', '.jpg').
Raises:
ValueError: If file type is not recognized.
"""
if len(data) < _MIN_MAGIC_BYTES:
msg = 'File too small to identify'
raise ValueError(msg)
# Check WebP: RIFF at offset 0, WEBP at offset 8
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
return 'image', '.webp'
# Check standard image signatures
for signature, ext in ALLOWED_IMAGE_SIGNATURES.items():
if data[: len(signature)] == signature:
return 'image', ext
# Check MP4/MOV: bytes 4-7 must be 'ftyp', bytes 8-12 must be a known video brand.
# Rejects HEIC/HEIF images (ftypheic, ftypmif1, etc.) which share the ftyp box format.
if data[4:8] == b'ftyp':
brand = data[8:12]
if brand in _MP4_VIDEO_BRANDS:
return 'video', '.mp4'
logger.warning('Unknown ftyp brand rejected', brand=brand.decode('ascii', errors='replace'))
# Check standard video signatures
for signature, ext in ALLOWED_VIDEO_SIGNATURES.items():
if data[: len(signature)] == signature:
return 'video', ext
msg = 'Unsupported file type: magic bytes do not match any allowed format'
raise ValueError(msg)
def _process_and_save_image(
data: bytes,
upload_path: Path,
max_dim: int,
quality: int,
) -> SavedMedia:
"""Process image: validate, resize, convert to JPEG, generate thumbnail.
This is a CPU-bound function intended to be run via asyncio.to_thread.
"""
img = Image.open(io.BytesIO(data))
try:
# Double-check pixel count (defense-in-depth alongside Image.MAX_IMAGE_PIXELS)
if img.size[0] * img.size[1] > 25_000_000:
msg = 'Image dimensions too large'
raise ValueError(msg)
# Fix EXIF orientation (rotated photos from phones)
transposed = ImageOps.exif_transpose(img)
if transposed is not None:
old_img = img
img = transposed
old_img.close()
# Normalize to RGB for consistent JPEG output
if img.mode != 'RGB':
old_img = img
img = img.convert('RGB')
old_img.close()
original_width, original_height = img.size
# Resize if any dimension exceeds max_dim (preserving aspect ratio)
if original_width > max_dim or original_height > max_dim:
img.thumbnail((max_dim, max_dim), Image.LANCZOS)
width, height = img.size
filename = f'{uuid.uuid4().hex}.jpg'
image_dir = upload_path / _IMAGES_DIR
target_path = image_dir / filename
# Atomic write: save to temp file, then rename
tmp_path = target_path.with_suffix('.tmp')
try:
img.save(tmp_path, format='JPEG', quality=quality, optimize=True)
tmp_path.rename(target_path)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
size_bytes = target_path.stat().st_size
# Generate thumbnail
thumbnail_filename = f'thumb_{filename}'
thumbnail_dir = upload_path / _THUMBNAILS_DIR
thumbnail_target = thumbnail_dir / thumbnail_filename
tmp_thumb = thumbnail_target.with_suffix('.tmp')
try:
thumb = img.copy()
try:
thumb.thumbnail(_THUMBNAIL_SIZE, Image.LANCZOS)
thumb.save(tmp_thumb, format='JPEG', quality=quality, optimize=True)
tmp_thumb.rename(thumbnail_target)
finally:
thumb.close()
except Exception:
tmp_thumb.unlink(missing_ok=True)
# Non-fatal: log and continue without thumbnail
logger.warning('Failed to generate thumbnail', filename=filename, exc_info=True)
thumbnail_filename = None
relative_path = f'{_IMAGES_DIR}/{filename}'
thumbnail_path = f'{_THUMBNAILS_DIR}/{thumbnail_filename}' if thumbnail_filename else None
return SavedMedia(
filename=filename,
relative_path=relative_path,
thumbnail_path=thumbnail_path,
media_type='image',
content_type='image/jpeg',
size_bytes=size_bytes,
width=width,
height=height,
)
finally:
img.close()
async def save_image(
data: bytes,
upload_path: Path,
max_dim: int,
quality: int,
) -> SavedMedia:
"""Validate, resize, and save an image file. Runs PIL operations in a thread."""
return await asyncio.to_thread(_process_and_save_image, data, upload_path, max_dim, quality)
def _save_video_sync(data: bytes, upload_path: Path) -> SavedMedia:
"""Save a video file. CPU-bound function for asyncio.to_thread."""
media_type, ext = detect_file_type(data)
if media_type != 'video':
msg = 'Data does not contain a recognized video format'
raise ValueError(msg)
filename = f'{uuid.uuid4().hex}{ext}'
video_dir = upload_path / _VIDEOS_DIR
target_path = video_dir / filename
content_type_map: dict[str, str] = {
'.mp4': 'video/mp4',
'.webm': 'video/webm',
}
# Atomic write
tmp_path = target_path.with_suffix('.tmp')
try:
tmp_path.write_bytes(data)
tmp_path.rename(target_path)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
size_bytes = target_path.stat().st_size
return SavedMedia(
filename=filename,
relative_path=f'{_VIDEOS_DIR}/{filename}',
thumbnail_path=None,
media_type='video',
content_type=content_type_map.get(ext, 'application/octet-stream'),
size_bytes=size_bytes,
width=None,
height=None,
)
async def save_video(data: bytes, upload_path: Path) -> SavedMedia:
"""Validate and save a video file. Runs I/O in a thread."""
return await asyncio.to_thread(_save_video_sync, data, upload_path)
def delete_media_file(filename: str, upload_path: Path) -> bool:
"""Delete a media file by filename with path traversal protection.
Searches images/, videos/, thumbnails/ directories.
Returns:
True if at least one file was deleted, False otherwise.
"""
deleted = False
for subdir in (_IMAGES_DIR, _VIDEOS_DIR, _THUMBNAILS_DIR):
candidate = (upload_path / subdir / filename).resolve()
base_dir = (upload_path / subdir).resolve()
# Path traversal guard
if not candidate.is_relative_to(base_dir):
logger.warning(
'Path traversal attempt blocked',
filename=filename,
resolved=str(candidate),
)
continue
if candidate.is_file():
candidate.unlink()
deleted = True
logger.info('Deleted media file', path=str(candidate))
# Also try to delete matching thumbnail
if not filename.startswith('thumb_'):
thumb_name = f'thumb_{filename}'
thumb_path = (upload_path / _THUMBNAILS_DIR / thumb_name).resolve()
thumb_base = (upload_path / _THUMBNAILS_DIR).resolve()
if thumb_path.is_relative_to(thumb_base) and thumb_path.is_file():
thumb_path.unlink()
logger.info('Deleted thumbnail', path=str(thumb_path))
return deleted
+9
View File
@@ -147,6 +147,15 @@ class CloudPaymentsPaymentMixin:
token = webhook_data.get('token')
test_mode = webhook_data.get('test_mode', False)
# Reject test-mode payments when not in test mode
if test_mode and not getattr(settings, 'CLOUDPAYMENTS_TEST_MODE', False):
logger.warning(
'CloudPayments: rejecting test_mode payment in production',
invoice_id=invoice_id,
test_mode=test_mode,
)
return False
if not invoice_id:
logger.error('CloudPayments webhook без invoice_id')
return False
+18
View File
@@ -253,6 +253,24 @@ class Pal24PaymentMixin:
return True
if status in {'PAID', 'SUCCESS', 'OVERPAID'}:
# Verify payment amount matches expected
callback_amount_str = callback.get('OutSum') or callback.get('out_sum') or callback.get('Amount')
if callback_amount_str is not None:
try:
from decimal import Decimal
received_kopeks = int(Decimal(str(callback_amount_str)) * 100)
if abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'Pal24 amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
bill_id=payment.bill_id,
)
return False
except (ValueError, TypeError) as e:
logger.warning('Pal24: не удалось распарсить сумму из callback', error=str(e))
metadata = getattr(payment, 'metadata_json', {}) or {}
if not isinstance(metadata, dict):
metadata = {}
+11 -1
View File
@@ -409,6 +409,14 @@ class YooKassaPaymentMixin:
)
return True
# Reject test-mode payments in production
if getattr(payment, 'test_mode', False) and not getattr(settings, 'YOOKASSA_TEST_MODE', False):
logger.warning(
'YooKassa: rejecting test_mode payment in production',
yookassa_payment_id=payment.yookassa_payment_id,
)
return False
payment_module = import_module('app.services.payment_service')
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
@@ -1243,7 +1251,9 @@ class YooKassaPaymentMixin:
try:
amount_rubles = payment.amount_kopeks / 100
# Формируем описание из настроек (включает сумму и ID пользователя)
receipt_name = settings.get_balance_payment_description(payment.amount_kopeks, telegram_user_id)
receipt_name = settings.get_balance_payment_description(
payment.amount_kopeks, telegram_user_id=telegram_user_id
)
receipt_uuid = await self.nalogo_service.create_receipt(
name=receipt_name,
+202
View File
@@ -0,0 +1,202 @@
"""Service layer for phantom user claiming and merging.
Phantom users are created during guest landing purchases when Bot.get_chat() fails
the user record has @username but no telegram_id. When the real user later presses /start,
we match by username and either claim or merge the phantom into their active account.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Literal
import structlog
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.rbac import AuditLogCRUD
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import User, UserStatus
from app.services.account_merge_service import execute_merge
from app.services.subscription_service import SubscriptionService
from app.utils.user_utils import generate_unique_referral_code
from app.utils.validators import sanitize_telegram_name
logger = structlog.get_logger(__name__)
async def claim_phantom(
db: AsyncSession,
phantom: User,
*,
telegram_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
language: str,
referrer_id: int | None,
) -> tuple[bool, User | None]:
"""Claim a phantom user by backfilling Telegram profile data.
Commits internally on success; rolls back on IntegrityError.
Returns (success, user). On IntegrityError falls back to existing user lookup.
Note: Phantom users created when Bot.get_chat() fails at purchase time are matched
by username only. Since Telegram usernames are changeable and reassignable, this is
inherently vulnerable to username change attacks. When Bot.get_chat() succeeds at
purchase time, telegram_id is stored on the user and the phantom path is not used.
"""
phantom.telegram_id = telegram_id
phantom.username = username
phantom.first_name = sanitize_telegram_name(first_name)
phantom.last_name = sanitize_telegram_name(last_name)
phantom.language = language
phantom.status = UserStatus.ACTIVE.value
if referrer_id and referrer_id != phantom.id:
phantom.referred_by_id = referrer_id
if not phantom.referral_code:
phantom.referral_code = await generate_unique_referral_code(db, telegram_id)
phantom.updated_at = datetime.now(UTC)
phantom.last_activity = datetime.now(UTC)
# Write audit log in a savepoint — if it fails, the claim mutations are not affected
try:
async with db.begin_nested():
await AuditLogCRUD.create(
db,
user_id=phantom.id,
action='phantom_claimed',
resource_type='user',
resource_id=str(phantom.id),
details={
'telegram_id': telegram_id,
'username': username,
},
status='success',
)
except Exception:
logger.warning('Failed to write phantom claim audit log', phantom_id=phantom.id, exc_info=True)
try:
await db.commit()
except IntegrityError:
await db.rollback()
logger.warning(
'IntegrityError claiming phantom user, falling back to existing user lookup',
phantom_user_id=phantom.id,
telegram_id=telegram_id,
)
existing = await get_user_by_telegram_id(db, telegram_id)
return False, existing
await db.refresh(phantom, ['subscription'])
# SECURITY NOTE: Phantom matched by username only (telegram_id was unknown at purchase time).
# Telegram usernames are changeable/reassignable, so the claimer may not be the intended
# recipient. This is logged at WARNING for admin audit. A confirmation flow would be needed
# to fully prevent username spoofing attacks on phantom claims.
logger.warning(
'Phantom user claimed by username match (verify intended recipient)',
phantom_user_id=phantom.id,
telegram_id=telegram_id,
username=username,
has_subscription=phantom.subscription is not None,
)
# Sync Remnawave panel with updated user data (telegram_id, username, etc.)
if phantom.subscription:
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, phantom.subscription)
except Exception:
logger.warning(
'Failed to update Remnawave panel after phantom claim',
phantom_user_id=phantom.id,
exc_info=True,
)
return True, phantom
async def merge_phantom_into_user(
db: AsyncSession,
phantom: User,
active_user: User,
) -> bool:
"""Merge phantom user into active user using the full account merge service.
Uses execute_merge which handles 30+ tables, subscription transfer, balance,
unique constraint safety, and soft-deletion. Caller is responsible for commit/rollback.
Returns True if a subscription was transferred from phantom (caller should sync
Remnawave panel AFTER commit via ``sync_remnawave_after_phantom_merge``).
"""
# Determine which subscription to keep: phantom's if active user has none, otherwise active's
await db.refresh(phantom, ['subscription'])
await db.refresh(active_user, ['subscription'])
keep_from: Literal['primary', 'secondary'] = (
'secondary' if phantom.subscription and not active_user.subscription else 'primary'
)
logger.warning(
'Merging phantom user into active user via execute_merge',
phantom_id=phantom.id,
active_user_id=active_user.id,
keep_subscription_from=keep_from,
phantom_has_sub=phantom.subscription is not None,
active_has_sub=active_user.subscription is not None,
)
await execute_merge(
db,
primary_user_id=active_user.id,
secondary_user_id=phantom.id,
keep_subscription_from=keep_from,
provider='phantom_merge',
)
# Durable audit log in a savepoint — if it fails, the merge itself is not affected
try:
async with db.begin_nested():
await AuditLogCRUD.create(
db,
user_id=active_user.id,
action='phantom_merged',
resource_type='user',
resource_id=str(phantom.id),
details={
'phantom_id': phantom.id,
'active_user_id': active_user.id,
'keep_subscription_from': keep_from,
'phantom_username': phantom.username,
},
status='success',
)
except Exception:
logger.warning(
'Failed to write phantom merge audit log',
phantom_id=phantom.id,
active_user_id=active_user.id,
exc_info=True,
)
return keep_from == 'secondary'
async def sync_remnawave_after_phantom_merge(db: AsyncSession, user: User) -> None:
"""Sync Remnawave panel after a phantom merge that transferred a subscription.
Must be called AFTER db.commit() to avoid holding FOR UPDATE locks during HTTP calls.
"""
await db.refresh(user, ['subscription'])
if not user.subscription:
return
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, user.subscription)
except Exception:
logger.warning(
'Failed to update Remnawave panel after phantom merge',
user_id=user.id,
exc_info=True,
)
+3 -2
View File
@@ -1,4 +1,5 @@
import asyncio
import html
from collections.abc import Iterable
from types import SimpleNamespace
@@ -28,9 +29,9 @@ logger = structlog.get_logger(__name__)
def _build_poll_invitation_text(poll: Poll, language: str) -> str:
texts = get_texts(language)
lines: list[str] = [f'🗳️ <b>{poll.title}</b>']
lines: list[str] = [f'🗳️ <b>{html.escape(poll.title)}</b>']
if poll.description:
lines.append(poll.description)
lines.append(html.escape(poll.description))
if poll.reward_enabled and poll.reward_amount_kopeks > 0:
reward_line = texts.t(
+5
View File
@@ -479,6 +479,11 @@ class PricingEngine:
Prevents purchased top-ups from inflating the tier lookup."""
total_gb = traffic_limit_gb or 0
purchased_gb = purchased_traffic_gb or 0
# 0 = unlimited traffic — has its own price tier, return directly
if total_gb == 0:
return settings.get_traffic_price(0)
base_gb = max(0, total_gb - purchased_gb)
base_price = settings.get_traffic_price(base_gb) if base_gb > 0 else 0
+3 -1
View File
@@ -277,7 +277,9 @@ async def _process_single_subscription(
logger.warning('YooKassa сервис не сконфигурирован для рекуррентных платежей')
return 'skipped'
description = settings.get_balance_payment_description(topup_amount_kopeks)
description = settings.get_balance_payment_description(
topup_amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
)
metadata = {
'user_id': str(user.id),
'user_telegram_id': str(user.telegram_id) if user.telegram_id else '',
+9 -8
View File
@@ -1,4 +1,5 @@
import asyncio
import html
from collections.abc import Sequence
from datetime import UTC, date, datetime, time, timedelta
from zoneinfo import ZoneInfo
@@ -281,7 +282,7 @@ class ReferralContestService:
lines = [
'🏆 <b>Конкурс рефералов</b>',
f'Название: <b>{contest.title}</b>',
f'Название: <b>{html.escape(contest.title)}</b>',
f'Статус: {"финал" if is_final else "дневная сводка"}',
f'Временная зона: <code>{tz.key}</code>',
f'Всего рефералов: <b>{total_events}</b>',
@@ -292,13 +293,13 @@ class ReferralContestService:
if leaderboard:
for idx, (name, score, _, is_virtual) in enumerate(leaderboard[:5], start=1):
virt_mark = ' 👻' if is_virtual else ''
lines.append(f'{idx}. {name}{virt_mark}{score}')
lines.append(f'{idx}. {html.escape(name)}{virt_mark}{score}')
else:
lines.append('Пока нет участников.')
if contest.prize_text:
lines.append('')
lines.append(f'Приз: {contest.prize_text}')
lines.append(f'Приз: {html.escape(contest.prize_text)}')
try:
await self.bot.send_message(
@@ -330,7 +331,7 @@ class ReferralContestService:
return
lines = [
f'🏆 {contest.title}',
f'🏆 {html.escape(contest.title)}',
'🏁 Итоги конкурса' if is_final else '📊 Промежуточные итоги',
f'Время зоны: {tz.key}',
f'Всего участников: <b>{len(leaderboard)}</b>',
@@ -340,13 +341,13 @@ class ReferralContestService:
if leaderboard:
for idx, (name, score, _, _is_virtual) in enumerate(leaderboard[:5], start=1):
lines.append(f'{idx}. {name}{score}')
lines.append(f'{idx}. {html.escape(name)}{score}')
else:
lines.append('Пока нет участников.')
if contest.prize_text:
lines.append('')
lines.append(f'Приз: {contest.prize_text}')
lines.append(f'Приз: {html.escape(contest.prize_text)}')
try:
await self.bot.send_message(
@@ -372,7 +373,7 @@ class ReferralContestService:
) -> str:
status_line = '🏁 Итоги конкурса' if is_final else '📊 Промежуточные итоги'
lines = [
f'🏆 {contest.title}',
f'🏆 {html.escape(contest.title)}',
status_line,
'',
f'Ваше место: <b>{rank}</b>',
@@ -383,7 +384,7 @@ class ReferralContestService:
if contest.prize_text:
lines.append('')
lines.append(f'Призовой фонд: {contest.prize_text}')
lines.append(f'Призовой фонд: {html.escape(contest.prize_text)}')
if not is_final:
remaining = contest.end_at - datetime.now(UTC)
+8 -6
View File
@@ -1,3 +1,5 @@
import html
import structlog
from aiogram import Bot
from sqlalchemy import delete
@@ -116,7 +118,7 @@ async def process_referral_registration(db: AsyncSession, new_user_id: int, refe
commission_percent = get_effective_referral_commission_percent(referrer)
referral_notification = (
f'🎉 <b>Добро пожаловать!</b>\n\n'
f'Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!'
f'Вы перешли по реферальной ссылке пользователя <b>{html.escape(referrer.full_name)}</b>!'
)
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
referral_notification += (
@@ -127,7 +129,7 @@ async def process_referral_registration(db: AsyncSession, new_user_id: int, refe
inviter_notification = (
f'👥 <b>Новый реферал!</b>\n\n'
f'По вашей ссылке зарегистрировался пользователь <b>{new_user.full_name}</b>!\n\n'
f'По вашей ссылке зарегистрировался пользователь <b>{html.escape(new_user.full_name)}</b>!\n\n'
f'💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, '
)
if settings.REFERRAL_INVITER_BONUS_KOPEKS > 0:
@@ -227,7 +229,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
if bot:
commission_notification = (
f'💰 <b>Реферальная комиссия!</b>\n\n'
f'Ваш реферал <b>{user.full_name}</b> пополнил баланс на '
f'Ваш реферал <b>{html.escape(user.full_name)}</b> пополнил баланс на '
f'{settings.format_price(topup_amount_kopeks)}\n\n'
f'🎁 Ваша комиссия ({commission_percent}%): '
f'{settings.format_price(commission_amount)}\n\n'
@@ -344,7 +346,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
bonus_breakdown = ' + '.join(bonus_parts)
inviter_bonus_notification = (
f'💰 <b>Реферальная награда!</b>\n\n'
f'Ваш реферал <b>{user.full_name}</b> сделал первое пополнение '
f'Ваш реферал <b>{html.escape(user.full_name)}</b> сделал первое пополнение '
f'на {settings.format_price(topup_amount_kopeks)}!\n\n'
f'🎁 Ваша награда: {settings.format_price(inviter_bonus)}'
f' ({bonus_breakdown})\n\n'
@@ -398,7 +400,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
if bot:
commission_notification = (
f'💰 <b>Реферальная комиссия!</b>\n\n'
f'Ваш реферал <b>{user.full_name}</b> пополнил баланс на '
f'Ваш реферал <b>{html.escape(user.full_name)}</b> пополнил баланс на '
f'{settings.format_price(topup_amount_kopeks)}\n\n'
f'🎁 Ваша комиссия ({commission_percent}%): '
f'{settings.format_price(commission_amount)}\n\n'
@@ -479,7 +481,7 @@ async def process_referral_purchase(
if bot:
purchase_commission_notification = (
f'💰 <b>Комиссия с покупки!</b>\n\n'
f'Ваш реферал <b>{user.full_name}</b> совершил покупку на '
f'Ваш реферал <b>{html.escape(user.full_name)}</b> совершил покупку на '
f'{settings.format_price(purchase_amount_kopeks)}\n\n'
f'🎁 Ваша комиссия ({commission_percent}%): '
f'{settings.format_price(commission_amount)}\n\n'
+2 -1
View File
@@ -3,6 +3,7 @@
с анализом на подозрительную активность (отмывание денег).
"""
import html
import json
from datetime import UTC, datetime, timedelta
@@ -656,7 +657,7 @@ class ReferralWithdrawalService:
if details.get('suspicious_referrals'):
text += '\n🚨 <b>Подозрительные рефералы:</b>\n'
for sr in details['suspicious_referrals'][:5]:
text += f'{sr["name"]}: {sr["deposits_count"]} поп., {sr["deposits_total"] / 100:.0f}\n'
text += f'{html.escape(sr["name"])}: {sr["deposits_count"]} поп., {sr["deposits_total"] / 100:.0f}\n'
text += f' Флаги: {", ".join(sr["flags"])}\n'
# Источники дохода
@@ -2,6 +2,7 @@
from __future__ import annotations
import html
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
@@ -180,6 +181,35 @@ async def _prepare_auto_extend_context(
if tariff_id:
tariff_id = _safe_int(tariff_id)
# Validate period_days against tariff or global renewal periods
if tariff_id:
from app.database.crud.tariff import get_tariff_by_id as _get_tariff
_tariff = await _get_tariff(db, tariff_id)
if _tariff and _tariff.period_prices and not getattr(_tariff, 'is_daily', False):
available_periods = [int(p) for p in _tariff.period_prices.keys()]
if period_days not in available_periods:
logger.warning(
'🔁 Автопокупка: period_days из корзины не входит в доступные периоды тарифа',
period_days=period_days,
available_periods=available_periods,
tariff_id=tariff_id,
format_user_id=_format_user_id(user),
)
return None
else:
from app.config import settings as _settings
available_periods = _settings.get_available_renewal_periods()
if period_days not in available_periods:
logger.warning(
'🔁 Автопокупка: period_days из корзины не входит в доступные периоды продления',
period_days=period_days,
available_periods=available_periods,
format_user_id=_format_user_id(user),
)
return None
from app.database.crud.user import lock_user_for_pricing
from app.services.pricing_engine import pricing_engine as _pricing_engine
from app.utils.promo_offer import get_user_active_promo_discount_percent
@@ -621,6 +651,29 @@ async def _auto_purchase_tariff(
)
return False
# Validate period_days against tariff's configured periods (prevent arbitrary periods from saved cart)
is_daily_tariff = getattr(tariff, 'is_daily', False)
if not is_daily_tariff:
if tariff.period_prices:
available_periods = [int(p) for p in tariff.period_prices.keys()]
else:
available_periods = []
custom_days_allowed = (
hasattr(tariff, 'can_purchase_custom_days')
and tariff.can_purchase_custom_days()
and hasattr(tariff, 'get_price_for_custom_days')
and tariff.get_price_for_custom_days(period_days) is not None
)
if period_days not in available_periods and not custom_days_allowed:
logger.warning(
'🔁 Автопокупка тарифа: period_days не входит в доступные периоды тарифа',
tariff_id=tariff_id,
period_days=period_days,
available_periods=available_periods,
format_user_id=_format_user_id(user),
)
return False
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
from app.database.crud.user import lock_user_for_pricing
@@ -1157,7 +1210,7 @@ async def _auto_purchase_daily_tariff(
texts = get_texts(getattr(user, 'language', 'ru'))
message = (
f'✅ <b>Суточный тариф «{tariff.name}» активирован!</b>\n\n'
f'✅ <b>Суточный тариф «{html.escape(tariff.name)}» активирован!</b>\n\n'
f'💰 Списано: {final_price / 100:.0f} ₽ за первый день\n'
f'🔄 Средства будут списываться автоматически раз в сутки.\n\n'
f'ℹ️ Вы можете приостановить подписку в любой момент.'
@@ -2375,15 +2428,60 @@ async def try_resume_disabled_daily_after_topup(
error=error,
)
# Restore connected_squads from tariff if cleared by deactivation sync
try:
if not subscription.connected_squads:
squads = tariff.allowed_squads or []
if not squads:
from app.database.crud.server_squad import get_all_server_squads
all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000)
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
if squads:
subscription.connected_squads = squads
await db.commit()
await db.refresh(subscription)
except Exception as error:
logger.warning(
'⚠️ Авто-возобновление daily: не удалось восстановить connected_squads',
format_user_id=_format_user_id(user),
error=error,
)
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
sync_squads=True,
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
# POST may ignore activeInternalSquads — follow up with PATCH
await db.refresh(user)
if getattr(user, 'remnawave_uuid', None) and subscription.connected_squads:
try:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=False,
sync_squads=True,
)
except Exception as patch_err:
logger.warning(
'⚠️ Авто-возобновление daily: не удалось синхронизировать сквады',
format_user_id=_format_user_id(user),
error=patch_err,
)
except Exception as error:
logger.error(
'⚠️ Авто-возобновление daily: не удалось обновить RemnaWave',
@@ -2426,7 +2524,7 @@ async def try_resume_disabled_daily_after_topup(
'💳 Списано: {amount}\n'
'💰 Остаток: {balance}',
).format(
tariff_name=tariff.name,
tariff_name=html.escape(tariff.name),
amount=settings.format_price(daily_price),
balance=settings.format_price(user.balance_kopeks),
)
+3 -4
View File
@@ -4,6 +4,7 @@
"""
import asyncio
import html
from dataclasses import dataclass
from datetime import UTC, datetime, time, timedelta
@@ -694,11 +695,9 @@ class TrafficMonitoringServiceV2:
db_user = await get_user_by_remnawave_uuid(db, violation.user_uuid)
if db_user:
user_id_display = db_user.telegram_id or db_user.email or f'#{db_user.id}'
user_info = (
f'👤 <b>{db_user.full_name or "Без имени"}</b>\n🆔 ID: <code>{user_id_display}</code>\n'
)
user_info = f'👤 <b>{html.escape(db_user.full_name or "Без имени")}</b>\n🆔 ID: <code>{user_id_display}</code>\n'
if db_user.username:
user_info += f'📱 Username: @{db_user.username}\n'
user_info += f'📱 Username: @{html.escape(db_user.username)}\n'
if violation.check_type == 'fast':
check_type_emoji = ''
+15
View File
@@ -1,5 +1,20 @@
"""Shared formatting utilities for traffic, price, and period display."""
import html
def safe_html_name(name: str | None) -> str:
"""HTML-escape a display name for Telegram HTML messages."""
return html.escape(name or '')
def user_html_link(user) -> str:
"""Build an HTML-safe clickable user link for Telegram messages."""
safe = safe_html_name(user.full_name)
if getattr(user, 'telegram_id', None):
return f'<a href="tg://user?id={user.telegram_id}">{safe}</a>'
return f'<b>{safe}</b>'
def format_traffic(gb: int) -> str:
"""Форматирует трафик."""
+10 -1
View File
@@ -218,7 +218,16 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
if is_topic_required_error(error):
return None
raise
return await _original_edit_text(self, text, **kwargs)
try:
return await _original_edit_text(self, text, **kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
return None
if 'MESSAGE_ID_INVALID' in str(error) or 'message to edit not found' in str(error).lower():
return None
if 'message is not modified' in str(error).lower():
return None
raise
if self.photo:
language = _get_language(self)
# Если caption потенциально слишком длинный — отправим как текст вместо caption
+30 -9
View File
@@ -113,15 +113,28 @@ def get_available_payment_methods() -> list[dict[str, str]]:
if settings.is_platega_enabled() and settings.get_platega_active_methods():
platega_name = settings.get_platega_display_name()
methods.append(
{
'id': 'platega',
'name': 'Банковская карта',
'icon': '💳',
'description': f'через {platega_name} (карты + СБП)',
'callback': 'topup_platega',
}
)
if settings.PLATEGA_INLINE_METHODS:
for method_code in settings.get_platega_active_methods():
info = settings.get_platega_method_definitions().get(method_code, {})
methods.append(
{
'id': f'platega_m{method_code}',
'name': info.get('name', f'Метод {method_code}'),
'icon': info.get('title', '💳').split(' ', 1)[0] if info.get('title') else '💳',
'description': f'через {platega_name}',
'callback': f'topup_platega_m{method_code}',
}
)
else:
methods.append(
{
'id': 'platega',
'name': 'Банковская карта',
'icon': '💳',
'description': f'через {platega_name} (карты + СБП)',
'callback': 'topup_platega',
}
)
if settings.is_cloudpayments_enabled():
cloudpayments_name = settings.get_cloudpayments_display_name()
@@ -282,6 +295,14 @@ def is_payment_method_available(method_id: str) -> bool:
return settings.is_heleket_enabled()
if method_id == 'platega':
return settings.is_platega_enabled() and bool(settings.get_platega_active_methods())
if method_id.startswith('platega_m'):
if not settings.is_platega_enabled():
return False
try:
code = int(method_id[len('platega_m') :])
except ValueError:
return False
return code in settings.get_platega_active_methods()
if method_id == 'cloudpayments':
return settings.is_cloudpayments_enabled()
if method_id == 'freekassa':
+156 -18
View File
@@ -603,6 +603,14 @@ async def _resolve_user_from_init_data(
detail='User not found',
)
# Block access for banned/deleted users
user_status = getattr(user, 'status', None)
if user_status in ('blocked', 'deleted'):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail='Account is blocked or deleted',
)
return user, webapp_data
@@ -898,6 +906,12 @@ async def create_payment_link(
) -> MiniAppPaymentCreateResponse:
user, _ = await _resolve_user_from_init_data(db, payload.init_data)
if getattr(user, 'restriction_topup', False):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail='Balance top-up is restricted for this account',
)
method = (payload.method or '').strip().lower()
if not method:
raise HTTPException(
@@ -934,7 +948,9 @@ async def create_payment_link(
payment_service = PaymentService(bot)
invoice_link = await payment_service.create_stars_invoice(
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
description=settings.get_balance_payment_description(
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
payload=invoice_payload,
stars_amount=stars_amount,
)
@@ -971,7 +987,9 @@ async def create_payment_link(
db=db,
user_id=user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
description=settings.get_balance_payment_description(
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
)
confirmation_url = result.get('confirmation_url') if result else None
if not result or not confirmation_url:
@@ -1009,7 +1027,9 @@ async def create_payment_link(
db=db,
user_id=user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
description=settings.get_balance_payment_description(
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
)
if not result or not result.get('confirmation_url'):
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment')
@@ -1041,7 +1061,9 @@ async def create_payment_link(
db=db,
user_id=user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
description=settings.get_balance_payment_description(
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=user.language,
)
if not result or not result.get('payment_url'):
@@ -1083,7 +1105,9 @@ async def create_payment_link(
db=db,
user_id=user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
description=settings.get_balance_payment_description(
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=user.language or settings.DEFAULT_LANGUAGE,
payment_method_code=method_code,
)
@@ -1121,7 +1145,9 @@ async def create_payment_link(
db=db,
user_id=user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
description=settings.get_balance_payment_description(
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=user.language,
)
payment_url = result.get('payment_url') if result else None
@@ -1160,7 +1186,9 @@ async def create_payment_link(
db=db,
user_id=user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
description=settings.get_balance_payment_description(
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=user.language or settings.DEFAULT_LANGUAGE,
)
if not result:
@@ -1238,7 +1266,9 @@ async def create_payment_link(
user_id=user.id,
amount_usd=amount_usd,
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
description=settings.get_balance_payment_description(
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
payload=f'balance_{user.id}_{amount_kopeks}',
)
if not result:
@@ -1288,7 +1318,9 @@ async def create_payment_link(
db=db,
user_id=user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
description=settings.get_balance_payment_description(
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=user.language or settings.DEFAULT_LANGUAGE,
)
@@ -1333,7 +1365,9 @@ async def create_payment_link(
db=db,
user_id=user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
description=settings.get_balance_payment_description(
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
telegram_id=user.telegram_id,
language=user.language or settings.DEFAULT_LANGUAGE,
)
@@ -1374,7 +1408,9 @@ async def create_payment_link(
db=db,
user_id=user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
description=settings.get_balance_payment_description(
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=user.language or settings.DEFAULT_LANGUAGE,
)
@@ -1405,7 +1441,9 @@ async def create_payment_link(
payment_url = await tribute_service.create_payment_link(
user_id=user.telegram_id,
amount_kopeks=amount_kopeks or 0,
description=settings.get_balance_payment_description(amount_kopeks or 0),
description=settings.get_balance_payment_description(
amount_kopeks or 0, telegram_user_id=user.telegram_id, user_db_id=user.id
),
)
finally:
await bot.session.close()
@@ -4692,6 +4730,14 @@ async def _authorize_miniapp_user(
detail={'code': 'user_not_found', 'message': 'User not found'},
)
# Block access for banned/deleted users
user_status = getattr(user, 'status', None)
if user_status in ('blocked', 'deleted'):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail={'code': 'account_blocked', 'message': 'Account is blocked or deleted'},
)
return user
@@ -5101,6 +5147,16 @@ async def submit_subscription_renewal_endpoint(
db: AsyncSession = Depends(get_db_session),
) -> MiniAppSubscriptionRenewalResponse:
user = await _authorize_miniapp_user(payload.init_data, db)
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail={
'code': 'subscription_restricted',
'message': 'Subscription purchases are restricted for this account',
},
)
subscription = _ensure_paid_subscription(
user,
allowed_statuses={'active', 'trial', 'expired'},
@@ -5414,6 +5470,15 @@ async def subscription_purchase_endpoint(
) -> MiniAppSubscriptionPurchaseResponse:
user = await _authorize_miniapp_user(payload.init_data, db)
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail={
'code': 'subscription_restricted',
'message': 'Subscription purchases are restricted for this account',
},
)
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
@@ -6363,6 +6428,15 @@ async def purchase_tariff_endpoint(
"""Покупка или смена тарифа."""
user = await _authorize_miniapp_user(payload.init_data, db)
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail={
'code': 'subscription_restricted',
'message': 'Subscription purchases are restricted for this account',
},
)
if not settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -7268,17 +7342,81 @@ async def toggle_daily_subscription_pause_endpoint(
# Синхронизация с RemnaWave только при возобновлении из DISABLED/EXPIRED
if not new_paused_state and was_disabled:
# Restore connected_squads from tariff if cleared by deactivation sync
try:
if not subscription.connected_squads:
squads = tariff.allowed_squads or []
if not squads:
from app.database.crud.server_squad import get_all_server_squads
all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000)
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
if squads:
subscription.connected_squads = squads
await db.commit()
await db.refresh(subscription)
except Exception as sq_err:
logger.warning('Failed to restore connected_squads (miniapp)', error=sq_err)
# Sync with RemnaWave
try:
service = SubscriptionService()
await service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
if getattr(user, 'remnawave_uuid', None):
await service.update_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
sync_squads=True,
)
else:
await service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
# POST /api/users may ignore activeInternalSquads —
# follow up with PATCH to ensure internal squads are assigned
await db.refresh(user)
if getattr(user, 'remnawave_uuid', None) and subscription.connected_squads:
try:
await service.update_remnawave_user(
db,
subscription,
reset_traffic=False,
sync_squads=True,
)
except Exception as squad_err:
logger.warning('Failed to sync squads after user creation (miniapp)', error=squad_err)
except Exception as e:
logger.error('Ошибка синхронизации с RemnaWave при возобновлении', error=e)
# Send admin notification about daily subscription resume
if resume_transaction is not None:
try:
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_purchase_notification(
db=db,
user=user,
subscription=subscription,
transaction=resume_transaction,
period_days=1,
was_trial_conversion=False,
amount_kopeks=daily_price,
purchase_type='renewal',
)
finally:
await bot.session.close()
except Exception as notif_err:
logger.error('Failed to send admin notification for daily resume (miniapp)', error=notif_err)
lang = getattr(user, 'language', settings.DEFAULT_LANGUAGE)
if new_paused_state:
message = 'Суточная подписка приостановлена' if lang == 'ru' else 'Daily subscription paused'
+19 -8
View File
@@ -22,6 +22,7 @@ from app.database.crud.user import (
get_user_by_id,
get_user_by_referral_code,
get_user_by_telegram_id,
subtract_user_balance,
update_user,
)
from app.database.models import PaymentMethod, PromoGroup, Subscription, User, UserStatus
@@ -311,14 +312,24 @@ async def update_balance(
if not found_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
success = await add_user_balance(
db,
found_user,
amount_kopeks=payload.amount_kopeks,
description=payload.description or 'Корректировка через веб-API',
create_transaction=payload.create_transaction,
payment_method=PaymentMethod.MANUAL,
)
if payload.amount_kopeks > 0:
success = await add_user_balance(
db,
found_user,
amount_kopeks=payload.amount_kopeks,
description=payload.description or 'Корректировка через веб-API',
create_transaction=payload.create_transaction,
payment_method=PaymentMethod.MANUAL,
)
else:
success = await subtract_user_balance(
db,
found_user,
amount_kopeks=abs(payload.amount_kopeks),
description=payload.description or 'Корректировка через веб-API',
create_transaction=payload.create_transaction,
payment_method=PaymentMethod.MANUAL,
)
if not success:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Failed to update balance')
+6 -6
View File
@@ -28,24 +28,24 @@ class SubscriptionResponse(BaseModel):
class SubscriptionCreateRequest(BaseModel):
user_id: int
is_trial: bool = False
duration_days: int | None = None
traffic_limit_gb: int | None = None
device_limit: int | None = None
duration_days: int | None = Field(None, ge=1, le=36500)
traffic_limit_gb: int | None = Field(None, ge=0, le=1_000_000)
device_limit: int | None = Field(None, ge=1, le=10_000)
squad_uuid: str | None = None
connected_squads: list[str] | None = None
replace_existing: bool = False
class SubscriptionExtendRequest(BaseModel):
days: int = Field(..., gt=0)
days: int = Field(..., gt=0, le=36500)
class SubscriptionTrafficRequest(BaseModel):
gb: int = Field(..., gt=0)
gb: int = Field(..., gt=0, le=1_000_000)
class SubscriptionDevicesRequest(BaseModel):
devices: int = Field(..., gt=0)
devices: int = Field(..., gt=0, le=10_000)
class SubscriptionSquadRequest(BaseModel):

Some files were not shown because too many files have changed in this diff Show More