diff --git a/Dockerfile b/Dockerfile
index 2dc7695c..8174ec6b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/app/bot.py b/app/bot.py
index 0b7ce98d..a6504e44 100644
--- a/app/bot.py
+++ b/app/bot.py
@@ -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()
diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py
index d0924ddf..5c92a825 100644
--- a/app/cabinet/routes/__init__.py
+++ b/app/cabinet/routes/__init__.py
@@ -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)
diff --git a/app/cabinet/routes/admin_news.py b/app/cabinet/routes/admin_news.py
new file mode 100644
index 00000000..924ed624
--- /dev/null
+++ b/app/cabinet/routes/admin_news.py
@@ -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',
+ )
diff --git a/app/cabinet/routes/admin_news_categories.py b/app/cabinet/routes/admin_news_categories.py
new file mode 100644
index 00000000..88a842ba
--- /dev/null
+++ b/app/cabinet/routes/admin_news_categories.py
@@ -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)
diff --git a/app/cabinet/routes/admin_news_media.py b/app/cabinet/routes/admin_news_media.py
new file mode 100644
index 00000000..26715fe8
--- /dev/null
+++ b/app/cabinet/routes/admin_news_media.py
@@ -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)
diff --git a/app/cabinet/routes/admin_news_tags.py b/app/cabinet/routes/admin_news_tags.py
new file mode 100644
index 00000000..ce1b631e
--- /dev/null
+++ b/app/cabinet/routes/admin_news_tags.py
@@ -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)
diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py
index 30ec3d3c..813fb9ce 100644
--- a/app/cabinet/routes/admin_users.py
+++ b/app/cabinet/routes/admin_users.py
@@ -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 ===
diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py
index f31c0b21..603cbf38 100644
--- a/app/cabinet/routes/balance.py
+++ b/app/cabinet/routes/balance.py
@@ -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,
diff --git a/app/cabinet/routes/news.py b/app/cabinet/routes/news.py
new file mode 100644
index 00000000..5165150d
--- /dev/null
+++ b/app/cabinet/routes/news.py
@@ -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))
diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py
index 16990032..f4e1702e 100644
--- a/app/cabinet/routes/subscription.py
+++ b/app/cabinet/routes/subscription.py
@@ -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:
diff --git a/app/cabinet/schemas/news.py b/app/cabinet/schemas/news.py
new file mode 100644
index 00000000..d1c7cda8
--- /dev/null
+++ b/app/cabinet/schemas/news.py
@@ -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 content (tag + body)
+ result = re.sub(r'', '', result, flags=re.IGNORECASE)
+ result = re.sub(r'', '', 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
diff --git a/app/cabinet/schemas/news_categories.py b/app/cabinet/schemas/news_categories.py
new file mode 100644
index 00000000..9e0c951f
--- /dev/null
+++ b/app/cabinet/schemas/news_categories.py
@@ -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)
diff --git a/app/cabinet/schemas/news_media.py b/app/cabinet/schemas/news_media.py
new file mode 100644
index 00000000..c523eb7c
--- /dev/null
+++ b/app/cabinet/schemas/news_media.py
@@ -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
diff --git a/app/cabinet/schemas/news_tags.py b/app/cabinet/schemas/news_tags.py
new file mode 100644
index 00000000..dcee0e27
--- /dev/null
+++ b/app/cabinet/schemas/news_tags.py
@@ -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)
diff --git a/app/cabinet/schemas/users.py b/app/cabinet/schemas/users.py
index 84482acc..7c1f0a8d 100644
--- a/app/cabinet/schemas/users.py
+++ b/app/cabinet/schemas/users.py
@@ -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."""
diff --git a/app/config.py b/app/config.py
index 65bbb744..81c8b43d 100644
--- a/app/config.py
+++ b/app/config.py
@@ -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)
diff --git a/app/database/crud/news.py b/app/database/crud/news.py
new file mode 100644
index 00000000..122829b2
--- /dev/null
+++ b/app/database/crud/news.py
@@ -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
diff --git a/app/database/crud/news_categories.py b/app/database/crud/news_categories.py
new file mode 100644
index 00000000..5777064b
--- /dev/null
+++ b/app/database/crud/news_categories.py
@@ -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)
diff --git a/app/database/crud/news_tags.py b/app/database/crud/news_tags.py
new file mode 100644
index 00000000..28fb87a9
--- /dev/null
+++ b/app/database/crud/news_tags.py
@@ -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)
diff --git a/app/database/crud/notification.py b/app/database/crud/notification.py
index 3029112c..3f2b1fe7 100644
--- a/app/database/crud/notification.py
+++ b/app/database/crud/notification.py
@@ -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()
diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py
index e73bdec9..82bdb733 100644
--- a/app/database/crud/subscription.py
+++ b/app/database/crud/subscription.py
@@ -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(
'✅ Подписка реактивирована',
diff --git a/app/database/crud/user.py b/app/database/crud/user.py
index cb1570b1..15c36d8d 100644
--- a/app/database/crud/user.py
+++ b/app/database/crud/user.py
@@ -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)
diff --git a/app/database/crud/welcome_text.py b/app/database/crud/welcome_text.py
index cf305dd6..ba0a5cdb 100644
--- a/app/database/crud/welcome_text.py
+++ b/app/database/crud/welcome_text.py
@@ -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,
diff --git a/app/database/models.py b/app/database/models.py
index 2d6cc9d8..294a5346 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -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""
+
+
+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""
+
+
+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""
+
+
+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""
diff --git a/app/external/cryptobot.py b/app/external/cryptobot.py
index f992fa74..a4230f18 100644
--- a/app/external/cryptobot.py
+++ b/app/external/cryptobot.py
@@ -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()
diff --git a/app/external/heleket.py b/app/external/heleket.py
index 5694c94f..3cdfc6d4 100644
--- a/app/external/heleket.py
+++ b/app/external/heleket.py
@@ -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)
diff --git a/app/handlers/admin/blacklist.py b/app/handlers/admin/blacklist.py
index ab0fdd5f..40f200e4 100644
--- a/app/handlers/admin/blacklist.py
+++ b/app/handlers/admin/blacklist.py
@@ -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}. {tg_id} {username or ""} — {reason}\n'
+ text += f'{i}. {tg_id} {html.escape(username or "")} — {html.escape(reason or "")}\n'
if len(blacklist_users) > 20:
text += f'\n... и еще {len(blacklist_users) - 20} записей'
diff --git a/app/handlers/admin/blocked_users.py b/app/handlers/admin/blocked_users.py
index 5407c7f6..6867f2f1 100644
--- a/app/handlers/admin/blocked_users.py
+++ b/app/handlers/admin/blocked_users.py
@@ -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,
)
diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py
index a035d3c4..199086ea 100644
--- a/app/handlers/admin/bot_configuration.py
+++ b/app/handlers/admin/bot_configuration.py
@@ -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,
diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py
index ddb33817..f67ab004 100644
--- a/app/handlers/admin/campaigns.py
+++ b/app/handlers/admin/campaigns.py
@@ -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'{campaign.name}\n'
- f'Стартовый параметр: {campaign.start_parameter}\n'
+ f'{html.escape(campaign.name)}\n'
+ f'Стартовый параметр: {html.escape(campaign.start_parameter)}\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} {campaign.name} — {campaign.start_parameter}\n'
+ f'{status} {html.escape(campaign.name)} — {html.escape(campaign.start_parameter)}\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(
(
'✏️ Изменение названия кампании\n\n'
- f'Текущее название: {campaign.name}\n'
+ f'Текущее название: {html.escape(campaign.name)}\n'
'Введите новое название (3-100 символов):'
),
reply_markup=types.InlineKeyboardMarkup(
@@ -1183,8 +1184,8 @@ async def confirm_delete_campaign(
text = (
'🗑️ Удаление кампании\n\n'
- f'Название: {campaign.name}\n'
- f'Параметр: {campaign.start_parameter}\n\n'
+ f'Название: {html.escape(campaign.name)}\n'
+ f'Параметр: {html.escape(campaign.start_parameter)}\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'🎁 Выбран тариф: {tariff.name}\n\n📅 Введите длительность тарифа в днях (1-730):',
+ f'🎁 Выбран тариф: {html.escape(tariff.name)}\n\n📅 Введите длительность тарифа в днях (1-730):',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_campaigns')]]
),
diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py
index 75a63a8d..e744cd1a 100644
--- a/app/handlers/admin/contests.py
+++ b/app/handlers/admin/contests.py
@@ -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'Дневная сводка: {summary_times}',
]
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'• {contest.title} (#{contest.id})')
+ lines.append(f'• {html.escape(contest.title)} (#{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'🏆 {contest.title}',
+ f'🏆 {html.escape(contest.title)}',
_format_contest_summary(contest, texts, tz),
texts.t('ADMIN_CONTEST_TOTAL_EVENTS', 'Зачётов: {count}').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 = [
'📈 Статистика конкурса',
- f'🏆 {contest.title}',
+ f'🏆 {html.escape(contest.title)}',
'',
f'👥 Участников (рефереров): {stats["total_participants"]}',
f'📨 Приглашено рефералов: {stats["total_invited"]}',
@@ -751,7 +752,7 @@ async def show_detailed_stats_page(
for p in page_participants:
lines.extend(
[
- f'• {p["full_name"]}',
+ f'• {html.escape(p["full_name"] or "")}',
f' 📨 Приглашено: {p["total_referrals"]}',
f' 💰 Оплатили: {p["paid_referrals"]}',
f' ❌ Не оплатили: {p["unpaid_referrals"]}',
@@ -828,7 +829,7 @@ async def sync_contest(
lines = [
'✅ Синхронизация завершена!',
'',
- f'📊 Конкурс: {contest.title}',
+ f'📊 Конкурс: {html.escape(contest.title)}',
f'📅 Период: {contest.start_at.strftime("%d.%m.%Y")} - {contest.end_at.strftime("%d.%m.%Y")}',
'🔍 Фильтр транзакций:',
f' {start_str}',
@@ -870,7 +871,7 @@ async def sync_contest(
# Обновляем основное сообщение с новой статистикой
detailed_stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
general_lines = [
- f'🏆 {contest.title}',
+ f'🏆 {html.escape(contest.title)}',
f'📅 Период: {contest.start_at.strftime("%d.%m.%Y")} - {contest.end_at.strftime("%d.%m.%Y")}',
'',
f'👥 Участников (рефереров): {detailed_stats["total_participants"]}',
@@ -927,7 +928,7 @@ async def debug_contest_transactions(
lines = [
'🔍 Отладка транзакций конкурса',
'',
- f'📊 Конкурс: {contest.title}',
+ f'📊 Конкурс: {html.escape(contest.title)}',
'📅 Период фильтрации:',
f' Начало: {debug_data.get("contest_start")}',
f' Конец: {debug_data.get("contest_end")}',
@@ -1002,10 +1003,10 @@ async def show_virtual_participants(
vps = await list_virtual_participants(db, contest_id)
- lines = [f'👻 Виртуальные участники — {contest.title}', '']
+ lines = [f'👻 Виртуальные участники — {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'👻 Виртуальные участники — {contest.title}', '']
+ lines = [f'👻 Виртуальные участники — {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('Пока нет виртуальных участников.')
diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py
index 1e2e9405..346df446 100644
--- a/app/handlers/admin/main.py
+++ b/app/handlers/admin/main.py
@@ -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'📊 Статистика:\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'Теперь используются стандартные правила по умолчанию.'
)
diff --git a/app/handlers/admin/maintenance.py b/app/handlers/admin/maintenance.py
index 7510a5e1..eedf29e7 100644
--- a/app/handlers/admin/maintenance.py
+++ b/app/handlers/admin/maintenance.py
@@ -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 = 'Ошибка включения режима техработ'
diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py
index 9b523b82..2c5563a7 100644
--- a/app/handlers/admin/messages.py
+++ b/app/handlers/admin/messages.py
@@ -643,7 +643,7 @@ async def show_messages_history(callback: types.CallbackQuery, db_user: User, db
{status_emoji} {broadcast.created_at.strftime('%d.%m.%Y %H:%M')}
📊 Отправлено: {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'Администратор: {admin_name}'
+ f'Администратор: {html.escape(admin_name)}'
)
back_keyboard = types.InlineKeyboardMarkup(
diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py
index 3390643a..80f90299 100644
--- a/app/handlers/admin/monitoring.py
+++ b/app/handlers/admin/monitoring.py
@@ -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⚠️ Превышения дельты:\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'
diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py
index d51000b9..67bb3920 100644
--- a/app/handlers/admin/polls.py
+++ b/app/handlers/admin/polls.py
@@ -886,7 +886,7 @@ async def _render_poll_details(poll: Poll, language: str) -> str:
texts = get_texts(language)
lines = [f'🗳️ {html.escape(poll.title)}']
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)))
diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py
index 337c9c83..640ac4ed 100644
--- a/app/handlers/admin/promo_groups.py
+++ b/app/handlers/admin/promo_groups.py
@@ -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} {group.name}{default_suffix} — {members_label}')
+ lines.append(f'{icon} {html.escape(group.name)}{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',
'💳 Промогруппа: {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'{user.full_name}'
+ user_link = f'{safe_name}'
tg_display = str(user.telegram_id)
else:
- user_link = f'{user.full_name}'
+ user_link = f'{safe_name}'
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')]]
),
diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py
index e614af0e..8d08674b 100644
--- a/app/handlers/admin/promo_offers.py
+++ b/app/handlers/admin/promo_offers.py
@@ -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} {template.name}', '']
+ lines = [f'{icon} {html.escape(template.name)}', '']
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))
diff --git a/app/handlers/admin/promocodes.py b/app/handlers/admin/promocodes.py
index 60c6ff55..d35a1614 100644
--- a/app/handlers/admin/promocodes.py
+++ b/app/handlers/admin/promocodes.py
@@ -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'📅 Дней: {promo.subscription_days}\n'
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
- text += f'🏷️ Промогруппа: {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n'
+ text += f'🏷️ Промогруппа: {html.escape(promo.promo_group.name)} (приоритет: {promo.promo_group.priority})\n'
elif promo.promo_group_id:
text += f'🏷️ Промогруппа ID: {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'🏷️ Промокод: {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'🏷️ Промокод для промогруппы\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:
diff --git a/app/handlers/admin/referrals.py b/app/handlers/admin/referrals.py
index 65e679c4..8776302a 100644
--- a/app/handlers/admin/referrals.py
+++ b/app/handlers/admin/referrals.py
@@ -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}
💳 Реквизиты:
-{request.payment_details}
+{html.escape(request.payment_details or '')}
📅 Создана: {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'✅ Тестовое начисление создано!\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: {target_telegram_id}\n'
f'💰 Сумма: {amount_rubles:.0f}₽\n'
f'💳 Новый баланс: {target_user.balance_kopeks / 100:.0f}₽\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' {lost.referral_code}{referrer_info} ({time_str})\n'
+ text += f' {html.escape(lost.referral_code)}{referrer_info} ({time_str})\n'
if len(report.lost_referrals) > 15:
text += f'\n... и ещё {len(report.lost_referrals) - 15}\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'... и ещё {fix_report.errors - 5} ошибок\n'
@@ -1055,8 +1076,12 @@ async def check_missing_bonuses(callback: types.CallbackQuery, db_user: User, db
👤 Список ({len(report.missing_bonuses)} чел.):
"""
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}. {referral_name}'
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"""
🏆 Синхронизация с конкурсами завершена!
@@ -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"""
-🔍 Анализ лог-файла: {file_name}
+🔍 Анализ лог-файла: {html.escape(file_name)}
📊 Статистика переходов:
• Всего кликов по реф-ссылкам: {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' {lost.referral_code}{referrer_info} ({time_str})\n'
+ text += f' {html.escape(lost.referral_code)}{referrer_info} ({time_str})\n'
if len(report.lost_referrals) > 15:
text += f'\n... и ещё {len(report.lost_referrals) - 15}\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'❌ Ошибка при анализе файла\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')]
diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py
index 27b39e45..866bf096 100644
--- a/app/handlers/admin/tariffs.py
+++ b/app/handlers/admin/tariffs.py
@@ -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'Цены:\n{prices_display}'
tariff_type = '📅 Периодный'
- return f"""📦 Тариф: {tariff.name}
+ return f"""📦 Тариф: {html.escape(tariff.name)}
{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'💰 Редактирование суточной цены\n\n'
- f'Тариф: {tariff.name}\n'
+ f'Тариф: {html.escape(tariff.name)}\n'
f'Текущая цена: {format_price_kopeks(current_price)}/день\n\n'
'Введите новую цену за день в рублях.\n'
'Пример: 50 или 99.90',
@@ -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'✏️ Редактирование названия\n\nТекущее название: {tariff.name}\n\nВведите новое название:',
+ f'✏️ Редактирование названия\n\nТекущее название: {html.escape(tariff.name)}\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'📈 Докупка трафика для «{tariff.name}»\n\n'
+ f'📈 Докупка трафика для «{html.escape(tariff.name)}»\n\n'
f'Статус: {status}\n\n'
f'Пакеты:\n{packages_display}\n\n'
f'Макс. лимит: {max_limit_display}\n\n'
@@ -1887,7 +1889,7 @@ async def toggle_tariff_traffic_topup(
try:
await callback.message.edit_text(
- f'📈 Докупка трафика для «{tariff.name}»\n\n'
+ f'📈 Докупка трафика для «{html.escape(tariff.name)}»\n\n'
f'Статус: {status}\n\n'
f'Пакеты:\n{packages_display}\n\n'
f'Макс. лимит: {max_limit_display}\n\n'
@@ -1931,7 +1933,7 @@ async def start_edit_traffic_topup_packages(
await callback.message.edit_text(
f'📦 Настройка пакетов докупки трафика\n\n'
- f'Тариф: {tariff.name}\n\n'
+ f'Тариф: {html.escape(tariff.name)}\n\n'
f'Текущие пакеты:\n{packages_display}\n\n'
'Введите пакеты в формате:\n'
f'{current_packages}\n\n'
@@ -2010,7 +2012,7 @@ async def process_edit_traffic_topup_packages(
await message.answer(
f'✅ Пакеты обновлены!\n\n'
- f'📈 Докупка трафика для «{tariff.name}»\n\n'
+ f'📈 Докупка трафика для «{html.escape(tariff.name)}»\n\n'
f'Статус: ✅ Включено\n\n'
f'Пакеты:\n{packages_display}\n\n'
f'Макс. лимит: {max_limit_display}\n\n'
@@ -2051,7 +2053,7 @@ async def start_edit_max_topup_traffic(
await callback.message.edit_text(
f'📊 Максимальный лимит трафика\n\n'
- f'Тариф: {tariff.name}\n'
+ f'Тариф: {html.escape(tariff.name)}\n'
f'Текущий лимит: {current_display}\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'✅ Лимит обновлен!\n\n'
- f'📈 Докупка трафика для «{tariff.name}»\n\n'
+ f'📈 Докупка трафика для «{html.escape(tariff.name)}»\n\n'
f'Статус: ✅ Включено\n\n'
f'Пакеты:\n{packages_display}\n\n'
f'Макс. лимит: {max_limit_display}\n\n'
@@ -2163,7 +2165,7 @@ async def confirm_delete_tariff(
warning = f'\n\n⚠️ Внимание! На этом тарифе {subs_count} подписок.\nОни будут отвязаны от тарифа.'
await callback.message.edit_text(
- f'🗑️ Удаление тарифа\n\nВы действительно хотите удалить тариф {tariff.name}?{warning}',
+ f'🗑️ Удаление тарифа\n\nВы действительно хотите удалить тариф {html.escape(tariff.name)}?{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'🌐 Серверы для тарифа «{tariff.name}»\n\n'
+ f'🌐 Серверы для тарифа «{html.escape(tariff.name)}»\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'🌐 Серверы для тарифа «{tariff.name}»\n\n'
+ f'🌐 Серверы для тарифа «{html.escape(tariff.name)}»\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'🌐 Серверы для тарифа «{tariff.name}»\n\n'
+ f'🌐 Серверы для тарифа «{html.escape(tariff.name)}»\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'🌐 Серверы для тарифа «{tariff.name}»\n\n'
+ f'🌐 Серверы для тарифа «{html.escape(tariff.name)}»\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'👥 Промогруппы для тарифа «{tariff.name}»\n\n'
+ f'👥 Промогруппы для тарифа «{html.escape(tariff.name)}»\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'👥 Промогруппы для тарифа «{tariff.name}»\n\n'
+ f'👥 Промогруппы для тарифа «{html.escape(tariff.name)}»\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'👥 Промогруппы для тарифа «{tariff.name}»\n\n'
+ f'👥 Промогруппы для тарифа «{html.escape(tariff.name)}»\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'🔄 Режим сброса трафика для тарифа «{tariff.name}»\n\n'
+ f'🔄 Режим сброса трафика для тарифа «{html.escape(tariff.name)}»\n\n'
f'Текущий режим: {_format_traffic_reset_mode(current_mode)}\n\n'
'Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n'
'• Глобальная настройка — использовать значение из конфига бота\n'
@@ -2775,7 +2777,7 @@ async def set_traffic_reset_mode(
# Обновляем клавиатуру
await callback.message.edit_text(
- f'🔄 Режим сброса трафика для тарифа «{tariff.name}»\n\n'
+ f'🔄 Режим сброса трафика для тарифа «{html.escape(tariff.name)}»\n\n'
f'Текущий режим: {mode_display}\n\n'
'Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n'
'• Глобальная настройка — использовать значение из конфига бота\n'
diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py
index ac4c8a2e..caf714cc 100644
--- a/app/handlers/admin/tickets.py
+++ b/app/handlers/admin/tickets.py
@@ -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: {chat_link}\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: {user_id_display}\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'
diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py
index 103151e9..182d30af 100644
--- a/app/handlers/admin/users.py
+++ b/app/handlers/admin/users.py
@@ -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 = '📱 Подписка и настройки пользователя\n\n'
- if user.telegram_id:
- user_link = f'{user.full_name}'
- user_id_display = user.telegram_id
- else:
- user_link = f'{user.full_name}'
- 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: {user_id_display})\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'Тариф: 📦 {tariff.name}\n'
+ text += f'Тариф: 📦 {html.escape(tariff.name)}\n'
else:
text += f'Тариф: 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 = '💳 Транзакции пользователя\n\n'
- if user.telegram_id:
- user_link = f'{user.full_name}'
- user_id_display = user.telegram_id
- else:
- user_link = f'{user.full_name}'
- 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: {user_id_display})\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 += '📭 Транзакции отсутствуют'
@@ -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"🔍 По запросу '{query}' ничего не найдено",
+ f"🔍 По запросу '{html.escape(query)}' ничего не найдено",
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"🔍 Результаты поиска: '{query}'\n\n"
+ text = f"🔍 Результаты поиска: '{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: {telegram_id})\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'{referral.full_name}'
+ referral_link = f'{safe_name}'
referral_id_display = referral.telegram_id
else:
- referral_link = f'{referral.full_name}'
+ referral_link = f'{safe_name}'
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 = [
'⚠️ Ограничения пользователя',
- 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'📝 Причина: {restriction_reason}')
+ text_lines.append(f'📝 Причина: {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'Текущая причина: {current_reason}\n\n'
+ text += f'Текущая причина: {html.escape(current_reason)}\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
'✅ Причина ограничения сохранена',
'',
'⚠️ Ограничения пользователя',
- f'👤 {user.full_name}',
+ f'👤 {html.escape(user.full_name)}',
'',
f'{"🚫" if restriction_topup else "✅"} Пополнение баланса',
f'{"🚫" if restriction_subscription else "✅"} Продление/покупка подписки',
'',
- f'📝 Причина: {reason}',
+ f'📝 Причина: {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'{user.full_name}'
- user_id_display = user.telegram_id
- else:
- user_link = f'{user.full_name}'
- 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 = '📊 Статистика пользователя\n\n'
- if user.telegram_id:
- user_link = f'{user.full_name}'
- user_id_display = user.telegram_id
- else:
- user_link = f'{user.full_name}'
- 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: {user_id_display})\n\n'
text += 'Основная информация:\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'• Пришел по реферальной ссылке от {referrer.full_name}\n'
+ text += f'• Пришел по реферальной ссылке от {html.escape(referrer.full_name)}\n'
else:
text += '• Пришел по реферальной ссылке (реферер не найден)\n'
if campaign_registration and campaign_registration.campaign:
- text += f'• Дополнительно зарегистрирован через кампанию {campaign_registration.campaign.name}\n'
+ text += f'• Дополнительно зарегистрирован через кампанию {html.escape(campaign_registration.campaign.name)}\n'
elif campaign_registration and campaign_registration.campaign:
- text += f'• Регистрация через рекламную кампанию {campaign_registration.campaign.name}\n'
+ text += f'• Регистрация через рекламную кампанию {html.escape(campaign_registration.campaign.name)}\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 += 'Рекламная кампания:\n'
- text += f'• Название: {campaign_registration.campaign.name}'
+ text += f'• Название: {html.escape(campaign_registration.campaign.name)}'
if campaign_registration.campaign.start_parameter:
text += f' (параметр: {campaign_registration.campaign.start_parameter})'
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Детали по рефералам:\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 = '🔄 Смена типа подписки\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 = '💳 Покупка подписки для пользователя\n\n'
- if target_user.telegram_id:
- target_user_link = f'{target_user.full_name}'
- target_user_id_display = target_user.telegram_id
- else:
- target_user_link = f'{target_user.full_name}'
- 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 = '💳 Подтверждение покупки подписки\n\n'
- if target_user.telegram_id:
- target_user_link = f'{target_user.full_name}'
- target_user_id_display = target_user.telegram_id
- else:
- target_user_link = f'{target_user.full_name}'
- 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'{target_user.full_name}'
- target_user_id_display = target_user.telegram_id
- else:
- target_user_link = f'{target_user.full_name}'
- 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'{target_user.full_name}'
- target_user_id_display = target_user.telegram_id
- else:
- target_user_link = f'{target_user.full_name}'
- 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 = '💳 Покупка тарифа для пользователя\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'{tariff.name} — {traffic} / {tariff.device_limit} 📱 от {settings.format_price(min_price)}\n'
+ text += f'{html.escape(tariff.name)} — {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'{target_user.full_name}'
- target_user_id_display = target_user.telegram_id
- else:
- target_user_link = f'{target_user.full_name}'
- 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 = '💳 Покупка тарифа для пользователя\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'📦 Тариф: {tariff.name}\n'
+ text += f'📦 Тариф: {html.escape(tariff.name)}\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'{target_user.full_name}'
- target_user_id_display = target_user.telegram_id
- else:
- target_user_link = f'{target_user.full_name}'
- 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 = '💳 Подтверждение покупки тарифа\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'📦 Тариф: {tariff.name}\n'
+ text += f'📦 Тариф: {html.escape(tariff.name)}\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'{target_user.full_name}'
- target_user_id_display = target_user.telegram_id
- else:
- target_user_link = f'{target_user.full_name}'
- 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'✅ Тариф успешно куплен!\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'💳 Администратор оформил вам тариф\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 = '📦 Смена тарифа пользователя\n\n'
- if user.telegram_id:
- user_link = f'{user.full_name}'
- else:
- user_link = f'{user.full_name} ({user.email or f"#{user.id}"})'
+ user_link = user_html_link(user)
text += f'👤 {user_link}\n\n'
if current_tariff:
- text += f'Текущий тариф: {current_tariff.name}\n\n'
+ text += f'Текущий тариф: {html.escape(current_tariff.name)}\n\n'
else:
text += 'Текущий тариф: не установлен\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 = '📦 Подтверждение смены тарифа\n\n'
- if user.telegram_id:
- user_link = f'{user.full_name}'
- else:
- user_link = f'{user.full_name} ({user.email or f"#{user.id}"})'
+ user_link = user_html_link(user)
text += f'👤 {user_link}\n\n'
- text += f'Новый тариф: {tariff.name}\n'
+ text += f'Новый тариф: {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'✅ Тариф успешно изменен\n\n'
- f'Новый тариф: {tariff.name}\n'
+ f'Новый тариф: {html.escape(tariff.name)}\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'❌ Ошибка смены тарифа\n\nДетали: {e!s}',
+ f'❌ Ошибка смены тарифа\n\nДетали: {html.escape(str(e))}',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
diff --git a/app/handlers/balance/cloudpayments.py b/app/handlers/balance/cloudpayments.py
index 7aa6420a..9a955a36 100644
--- a/app/handlers/balance/cloudpayments.py
+++ b/app/handlers/balance/cloudpayments.py
@@ -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:
diff --git a/app/handlers/balance/cryptobot.py b/app/handlers/balance/cryptobot.py
index 916eaeb1..d00f62ce 100644
--- a/app/handlers/balance/cryptobot.py
+++ b/app/handlers/balance/cryptobot.py
@@ -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:
diff --git a/app/handlers/balance/freekassa.py b/app/handlers/balance/freekassa.py
index 5aa28b92..68ef848b 100644
--- a/app/handlers/balance/freekassa.py
+++ b/app/handlers/balance/freekassa.py
@@ -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:
diff --git a/app/handlers/balance/heleket.py b/app/handlers/balance/heleket.py
index 30082e23..fbf57792 100644
--- a/app/handlers/balance/heleket.py
+++ b/app/handlers/balance/heleket.py
@@ -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:
diff --git a/app/handlers/balance/kassa_ai.py b/app/handlers/balance/kassa_ai.py
index b1555ca5..281f03aa 100644
--- a/app/handlers/balance/kassa_ai.py
+++ b/app/handlers/balance/kassa_ai.py
@@ -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:
diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py
index 0e880558..57483bdb 100644
--- a/app/handlers/balance/main.py
+++ b/app/handlers/balance/main.py
@@ -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
diff --git a/app/handlers/balance/mulenpay.py b/app/handlers/balance/mulenpay.py
index 8098b116..cd4d04c2 100644
--- a/app/handlers/balance/mulenpay.py
+++ b/app/handlers/balance/mulenpay.py
@@ -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:
diff --git a/app/handlers/balance/pal24.py b/app/handlers/balance/pal24.py
index 5114afad..7956a3c9 100644
--- a/app/handlers/balance/pal24.py
+++ b/app/handlers/balance/pal24.py
@@ -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:
diff --git a/app/handlers/balance/platega.py b/app/handlers/balance/platega.py
index 50a13e11..f496e988 100644
--- a/app/handlers/balance/platega.py
+++ b/app/handlers/balance/platega.py
@@ -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'🚫 Пополнение ограничено\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:
diff --git a/app/handlers/balance/riopay.py b/app/handlers/balance/riopay.py
index 0c79403c..bd6649e1 100644
--- a/app/handlers/balance/riopay.py
+++ b/app/handlers/balance/riopay.py
@@ -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'🚫 Пополнение ограничено\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'🚫 Пополнение ограничено\n\n{reason}',
parse_mode='HTML',
diff --git a/app/handlers/balance/severpay.py b/app/handlers/balance/severpay.py
index 6d994d09..450b1d36 100644
--- a/app/handlers/balance/severpay.py
+++ b/app/handlers/balance/severpay.py
@@ -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'🚫 Пополнение ограничено\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'🚫 Пополнение ограничено\n\n{reason}',
parse_mode='HTML',
diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py
index bc1eb0ff..dc55be3b 100644
--- a/app/handlers/balance/stars.py
+++ b/app/handlers/balance/stars.py
@@ -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:
diff --git a/app/handlers/balance/tribute.py b/app/handlers/balance/tribute.py
index 973eb2f3..7306204c 100644
--- a/app/handlers/balance/tribute.py
+++ b/app/handlers/balance/tribute.py
@@ -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:
diff --git a/app/handlers/balance/wata.py b/app/handlers/balance/wata.py
index c086abea..f5f7b2ca 100644
--- a/app/handlers/balance/wata.py
+++ b/app/handlers/balance/wata.py
@@ -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:
diff --git a/app/handlers/balance/yookassa.py b/app/handlers/balance/yookassa.py
index a6f9e025..92f71266 100644
--- a/app/handlers/balance/yookassa.py
+++ b/app/handlers/balance/yookassa.py
@@ -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:
diff --git a/app/handlers/menu.py b/app/handlers/menu.py
index 26b5894e..251bb0eb 100644
--- a/app/handlers/menu.py
+++ b/app/handlers/menu.py
@@ -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),
)
# Добавляем информацию о тарифе перед "Выберите действие"
diff --git a/app/handlers/polls.py b/app/handlers/polls.py
index 69022c9b..bfae89a7 100644
--- a/app/handlers/polls.py
+++ b/app/handlers/polls.py
@@ -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'🗳️ {poll_title}', '', header, '', question.text]
+ lines = [f'🗳️ {html.escape(poll_title)}', '', header, '', html.escape(question.text)]
return '\n'.join(lines)
diff --git a/app/handlers/referral.py b/app/handlers/referral.py
index 56b0f947..ee91eb32 100644
--- a/app/handlers/referral.py
+++ b/app/handlers/referral.py
@@ -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'{html_escape(invite_text)}'
+ f'{html_escape(invite_text)}
'
),
keyboard,
)
diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py
index 36d2c7d2..0bff4c33 100644
--- a/app/handlers/simple_subscription.py
+++ b/app/handlers/simple_subscription.py
@@ -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:
diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py
index a7105a1e..0af072f7 100644
--- a/app/handlers/stars_payments.py
+++ b/app/handlers/stars_payments.py
@@ -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'🎰 Колесо удачи!\n\n'
- f'{emoji} {selected_prize.display_name}\n\n'
+ f'{emoji} {html.escape(selected_prize.display_name)}\n\n'
f'{prize_message}\n\n'
f'⭐ Потрачено: {stars_amount} Stars',
parse_mode='HTML',
diff --git a/app/handlers/start.py b/app/handlers/start.py
index 537b310d..de6a3fd2 100644
--- a/app/handlers/start.py
+++ b/app/handlers/start.py
@@ -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'🎁 Подарок активирован!\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',
diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py
index 56318228..55015bd1 100644
--- a/app/handlers/subscription/purchase.py
+++ b/app/handlers/subscription/purchase.py
@@ -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'📦 {tariff.name}',
+ f'📦 {html.escape(tariff.name)}',
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)
diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py
index f3b19ac2..014ef90b 100644
--- a/app/handlers/subscription/tariff_purchase.py
+++ b/app/handlers/subscription/tariff_purchase.py
@@ -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'{tariff.name} — {traffic} / {tariff.device_limit} 📱 {price_text}')
+ lines.append(f'{html.escape(tariff.name)} — {traffic} / {tariff.device_limit} 📱 {price_text}')
# Описание тарифа если есть
if tariff.description:
- lines.append(f'{tariff.description}')
+ lines.append(f'{html.escape(tariff.description)}')
lines.append('')
@@ -238,7 +239,7 @@ def format_tariff_info_for_user(
traffic = format_traffic(tariff.traffic_limit_gb)
- text = f"""📦 {tariff.name}
+ text = f"""📦 {html.escape(tariff.name)}
Параметры:
• Трафик: {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🎁 Ваша скидка: {discount_percent}%\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"""📦 {tariff.name}
+ text = f"""📦 {html.escape(tariff.name)}
Настройте параметры:
"""
@@ -554,7 +555,7 @@ async def select_tariff(
if user_balance >= daily_price:
await callback.message.edit_text(
f'✅ Подтверждение покупки\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: Суточный\n\n'
@@ -589,7 +590,7 @@ async def select_tariff(
await callback.message.edit_text(
f'❌ Недостаточно средств\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'🎉 Подписка успешно оформлена!\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'✅ Подтверждение покупки\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'❌ Недостаточно средств\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'🎉 Подписка успешно оформлена!\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'🎉 Суточная подписка оформлена!\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'🔄 Продление подписки{discount_hint}\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'✅ Подтверждение продления\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'❌ Недостаточно средств\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'🎉 Подписка успешно продлена!\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'{tariff.name} — {traffic} / {tariff.device_limit} 📱 {price_text}')
+ lines.append(f'{html.escape(tariff.name)} — {traffic} / {tariff.device_limit} 📱 {price_text}')
if tariff.description:
- lines.append(f'{tariff.description}')
+ lines.append(f'{html.escape(tariff.description)}')
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'✅ Подтверждение смены тарифа\n\n'
- f'📦 Новый тариф: {tariff.name}\n'
+ f'📦 Новый тариф: {html.escape(tariff.name)}\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: Суточный\n\n'
@@ -2295,7 +2296,7 @@ async def select_tariff_switch(
missing = daily_price - user_balance
await callback.message.edit_text(
f'❌ Недостаточно средств\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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"""📦 {tariff.name}
+ info_text = f"""📦 {html.escape(tariff.name)}
Параметры нового тарифа:
• Трафик: {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'✅ Подтверждение переключения тарифа\n\n'
f'📌 Текущий тариф: {current_tariff_name}\n'
- f'📦 Новый тариф: {tariff.name}\n'
+ f'📦 Новый тариф: {html.escape(tariff.name)}\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'❌ Недостаточно средств\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'🎉 Тариф успешно изменён!\n\n'
- f'📦 Новый тариф: {tariff.name}\n'
+ f'📦 Новый тариф: {html.escape(tariff.name)}\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'🎉 Тариф успешно изменён!\n\n'
- f'📦 Новый тариф: {tariff.name}\n'
+ f'📦 Новый тариф: {html.escape(tariff.name)}\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: Суточный\n'
@@ -2864,7 +2865,7 @@ def format_instant_switch_list_text(
"""Форматирует текст со списком тарифов для мгновенного переключения."""
lines = [
'📦 Мгновенная смена тарифа',
- f'📌 Текущий: {current_tariff.name}',
+ f'📌 Текущий: {html.escape(current_tariff.name)}',
f'⏰ Осталось: {remaining_days} дн.',
'',
'💡 При переключении остаток дней сохраняется.',
@@ -2888,10 +2889,10 @@ def format_instant_switch_list_text(
else:
cost_text = '⬇️ Бесплатно'
- lines.append(f'{tariff.name} — {traffic} / {tariff.device_limit} 📱 {cost_text}')
+ lines.append(f'{html.escape(tariff.name)} — {traffic} / {tariff.device_limit} 📱 {cost_text}')
if tariff.description:
- lines.append(f'{tariff.description}')
+ lines.append(f'{html.escape(tariff.description)}')
lines.append('')
@@ -3109,10 +3110,10 @@ async def preview_instant_switch(
if user_balance >= daily_price:
await callback.message.edit_text(
f'🔄 Переключение на суточный тариф\n\n'
- f'📌 Текущий: {current_tariff.name}\n'
+ f'📌 Текущий: {html.escape(current_tariff.name)}\n'
f' • Трафик: {current_traffic}\n'
f' • Устройств: {current_tariff.device_limit}\n\n'
- f'📦 Новый: {new_tariff.name}\n'
+ f'📦 Новый: {html.escape(new_tariff.name)}\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'❌ Недостаточно средств\n\n'
- f'📦 Тариф: {new_tariff.name}\n'
+ f'📦 Тариф: {html.escape(new_tariff.name)}\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'⬆️ Повышение тарифа\n\n'
- f'📌 Текущий: {current_tariff.name}\n'
+ f'📌 Текущий: {html.escape(current_tariff.name)}\n'
f' • Трафик: {current_traffic}\n'
f' • Устройств: {current_tariff.device_limit}\n\n'
- f'📦 Новый: {new_tariff.name}\n'
+ f'📦 Новый: {html.escape(new_tariff.name)}\n'
f' • Трафик: {traffic}\n'
f' • Устройств: {new_tariff.device_limit}\n\n'
f'⏰ Осталось дней: {remaining_days}\n'
@@ -3171,7 +3172,7 @@ async def preview_instant_switch(
missing = upgrade_cost - user_balance
await callback.message.edit_text(
f'❌ Недостаточно средств\n\n'
- f'📦 Новый тариф: {new_tariff.name}\n'
+ f'📦 Новый тариф: {html.escape(new_tariff.name)}\n'
f'💰 Требуется доплата: {format_price_kopeks(upgrade_cost)}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
f'⚠️ Не хватает: {format_price_kopeks(missing)}',
@@ -3182,10 +3183,10 @@ async def preview_instant_switch(
# Downgrade или тот же уровень - бесплатно
await callback.message.edit_text(
f'⬇️ Переключение тарифа\n\n'
- f'📌 Текущий: {current_tariff.name}\n'
+ f'📌 Текущий: {html.escape(current_tariff.name)}\n'
f' • Трафик: {current_traffic}\n'
f' • Устройств: {current_tariff.device_limit}\n\n'
- f'📦 Новый: {new_tariff.name}\n'
+ f'📦 Новый: {html.escape(new_tariff.name)}\n'
f' • Трафик: {traffic}\n'
f' • Устройств: {new_tariff.device_limit}\n\n'
f'⏰ Осталось дней: {remaining_days}\n'
@@ -3433,7 +3434,7 @@ async def confirm_instant_switch(
if is_new_daily:
await callback.message.edit_text(
f'🎉 Тариф успешно изменён!\n\n'
- f'📦 Новый тариф: {new_tariff.name}\n'
+ f'📦 Новый тариф: {html.escape(new_tariff.name)}\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'🎉 Тариф успешно изменён!\n\n'
- f'📦 Новый тариф: {new_tariff.name}\n'
+ f'📦 Новый тариф: {html.escape(new_tariff.name)}\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'❌ Все еще недостаточно средств\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'❌ Все еще недостаточно средств\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'❌ Все еще недостаточно средств\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'✅ Подтверждение покупки\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'✅ Подтверждение продления\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\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'✅ Подтверждение покупки\n\n'
- f'📦 Тариф: {tariff.name}\n'
+ f'📦 Тариф: {html.escape(tariff.name)}\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'📅 Период: {format_period(period)}\n'
diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py
index ff3b922f..7dbbee33 100644
--- a/app/handlers/tickets.py
+++ b/app/handlers/tickets.py
@@ -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'✅ Тикет #{ticket.id} создан\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'🎫 НОВЫЙ ТИКЕТ\n\n'
f'🆔 ID: {ticket.id}\n'
f'👤 Пользователь: {full_name}\n'
f'🆔 ID: {telegram_id_display}\n'
f'📱 Username: @{username_display}\n'
- f'📝 Заголовок: {title or "—"}\n'
+ f'📝 Заголовок: {safe_title}\n'
)
if message_preview:
- notification_text += f'\n📩 Сообщение:\n{message_preview}\n'
+ notification_text += f'\n📩 Сообщение:\n{html.escape(message_preview)}\n'
notification_text += f'\n📅 Создан: {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'💬 ОТВЕТ НА ТИКЕТ\n\n'
f'🆔 ID тикета: {ticket.id}\n'
- f'📝 Заголовок: {title or "—"}\n'
+ f'📝 Заголовок: {safe_title}\n'
f'👤 Пользователь: {full_name}\n'
f'🆔 ID: {telegram_id_display}\n'
f'📱 Username: @{username_display}\n\n'
- f'📩 Сообщение:\n{reply_preview}\n'
+ f'📩 Сообщение:\n{html.escape(reply_preview)}\n'
)
from app.services.maintenance_service import maintenance_service
diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index d9700b06..c5eaa64a 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -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():
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index a89a5e24..a35ddfe9 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -1306,7 +1306,7 @@
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up",
"REFERRAL_INFO": "\n🤝 Referral program\n\n👥 Invited: {referrals_count} friends\n💰 Earned: {earned_amount}\n\n🔗 Your referral link:\n{referral_link}\n\n🎫 Your promo code:\n{referral_code}\n\n💰 Terms:\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": "📝 Invitation created!",
"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🎯 Invitation to the VPN service\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": "🔗 Your referral link:",
@@ -1724,19 +1725,16 @@
"MODEM_PRICE_WITH_DISCOUNT": "Cost: {base_price} {final_price} (for {months} months)\n🎁 Discount {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Cost: {price} (for {months} months)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 Confirm modem connection\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": "🚫 Top-up restricted\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.",
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 Subscription purchase/renewal restricted\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": "⚠️ Warning! 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": "✅ Subscription resumed!\n\nYour daily plan «{tariff_name}» has been resumed after balance top-up.\n\n💳 Charged: {amount}\n💰 Remaining: {balance}",
-
"WEBHOOK_SUB_EXPIRED": "❌ Subscription expired\n\nYour subscription has ended. Renew to restore VPN access.",
"WEBHOOK_SUB_DISABLED": "🚫 Subscription disabled\n\nYour subscription has been disabled by the administrator.",
"WEBHOOK_SUB_ENABLED": "✅ Subscription activated\n\nYour subscription is active again. Enjoy!",
@@ -1755,4 +1753,4 @@
"WEBHOOK_DEVICE_ADDED": "📱 New device\n\nA new device has been added to your subscription: {device}",
"WEBHOOK_DEVICE_DELETED": "📱 Device removed\n\nA device has been removed from your subscription: {device}",
"WEBHOOK_CLOSE_BUTTON": "✖️ Close"
-}
+}
\ No newline at end of file
diff --git a/app/localization/locales/fa.json b/app/localization/locales/fa.json
index ca5ba305..cbbd6920 100644
--- a/app/localization/locales/fa.json
+++ b/app/localization/locales/fa.json
@@ -1327,7 +1327,7 @@
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 اولین شارژ",
"REFERRAL_INFO": "🤝 دعوت دوستان\n\nلینک دعوت را به اشتراک بگذارید و از هر خرید کمیسیون بگیرید!",
"REFERRAL_INVITE_BONUS": "💎 با اولین شارژ از {minimum} مبلغ {bonus} پاداش دریافت میکنی!",
- "REFERRAL_INVITE_CREATED_INSTRUCTION": "دکمه «📤 اشتراکگذاری» را بزنید یا متن زیر را کپی کنید:",
+ "REFERRAL_INVITE_CREATED_INSTRUCTION": "روی متن زیر بزنید تا کپی شود:",
"REFERRAL_INVITE_CREATED_TITLE": "📝 دعوتنامه ایجاد شد!",
"REFERRAL_INVITE_FEATURE_FAST": "🚀 اتصال سریع",
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 امنیت بالا",
@@ -1335,6 +1335,7 @@
"REFERRAL_INVITE_FOOTER": "📢 دوستان را دعوت کنید و درآمد کسب کنید!",
"REFERRAL_INVITE_LINK_PROMPT": "👇 از لینک زیر استفاده کن:",
"REFERRAL_INVITE_MESSAGE": "\n🎯 دعوت به سرویس VPN\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": "🔗 لینک دعوت شما:",
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index 99dea881..70e5d38e 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -1327,7 +1327,7 @@
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение",
"REFERRAL_INFO": "\n🤝 Реферальная программа\n\n👥 Приглашено: {referrals_count} друзей\n💰 Заработано: {earned_amount}\n\n🔗 Ваша реферальная ссылка:\n{referral_link}\n\n🎫 Ваш промокод:\n{referral_code}\n\n💰 Условия:\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n",
"REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
- "REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:",
+ "REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите на текст ниже, чтобы скопировать:",
"REFERRAL_INVITE_CREATED_TITLE": "📝 Приглашение создано!",
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение",
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита",
@@ -1335,6 +1335,7 @@
"REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!",
"REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:",
"REFERRAL_INVITE_MESSAGE": "\n🎯 Приглашение в VPN сервис\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": "🔗 Ваша реферальная ссылка:",
diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json
index 4842e4bc..2bcb7562 100644
--- a/app/localization/locales/ua.json
+++ b/app/localization/locales/ua.json
@@ -1243,7 +1243,7 @@
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Перше поповнення",
"REFERRAL_INFO": "\n🤝 Реферальна програма\n\n👥 Запрошено: {referrals_count} друзів\n💰 Зароблено: {earned_amount}\n\n🔗 Ваше реферальне посилання:\n{referral_link}\n\n🎫 Ваш промокод:\n{referral_code}\n\n💰 Умови:\n• За кожного друга: {registration_bonus}\n• Відсоток з поповнень: {commission_percent}%\n",
"REFERRAL_INVITE_BONUS": "💎 При першому поповненні від {minimum} ти отримаєш {bonus} бонусом на баланс!",
- "REFERRAL_INVITE_CREATED_INSTRUCTION": "Натисніть кнопку «📤 Поділитися», щоб надіслати запрошення в будь-який чат, або скопіюйте текст нижче:",
+ "REFERRAL_INVITE_CREATED_INSTRUCTION": "Натисніть на текст нижче, щоб скопіювати:",
"REFERRAL_INVITE_CREATED_TITLE": "📝 Запрошення створено!",
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Швидке підключення",
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надійний захист",
@@ -1251,6 +1251,7 @@
"REFERRAL_INVITE_FOOTER": "📢 Запрошуйте друзів та заробляйте!",
"REFERRAL_INVITE_LINK_PROMPT": "👇 Переходь за посиланням:",
"REFERRAL_INVITE_MESSAGE": "\n🎯 Запрошення до VPN сервісу\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": "🔗 Ваше реферальне посилання:",
diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json
index 0abc11f5..68654fec 100644
--- a/app/localization/locales/zh.json
+++ b/app/localization/locales/zh.json
@@ -1241,7 +1241,7 @@
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉首次充值",
"REFERRAL_INFO": "\n🤝推荐计划\n\n👥已邀请:{referrals_count}位朋友\n💰已赚取:{earned_amount}\n\n🔗您的推荐链接:\n{referral_link}\n\n🎫您的优惠码:\n{referral_code}\n\n💰条件:\n•每位朋友:{registration_bonus}\n•充值百分比:{commission_percent}%\n",
"REFERRAL_INVITE_BONUS": "💎首次充值{minimum}起,您将获得{bonus}余额奖励!",
-"REFERRAL_INVITE_CREATED_INSTRUCTION": "点击“📤分享”按钮将邀请发送到任何聊天,或复制以下文本:",
+"REFERRAL_INVITE_CREATED_INSTRUCTION": "点击下方文本即可复制:",
"REFERRAL_INVITE_CREATED_TITLE": "📝邀请已创建!",
"REFERRAL_INVITE_FEATURE_FAST": "🚀快速连接",
"REFERRAL_INVITE_FEATURE_SECURE": "🔒可靠保护",
@@ -1249,6 +1249,7 @@
"REFERRAL_INVITE_FOOTER": "📢邀请朋友并赚钱!",
"REFERRAL_INVITE_LINK_PROMPT": "👇点击链接:",
"REFERRAL_INVITE_MESSAGE": "\n🎯邀请加入VPN服务\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": "🔗您的推荐链接:",
diff --git a/app/middlewares/auth.py b/app/middlewares/auth.py
index 803ae1fc..79cf5720 100644
--- a/app/middlewares/auth.py
+++ b/app/middlewares/auth.py
@@ -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)
diff --git a/app/services/account_merge_service.py b/app/services/account_merge_service.py
index 4e01a63d..e03332c7 100644
--- a/app/services/account_merge_service.py
+++ b/app/services/account_merge_service.py
@@ -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)
diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py
index 43b7cbb2..a866e916 100644
--- a/app/services/admin_notification_service.py
+++ b/app/services/admin_notification_service.py
@@ -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} {title}: —'
- lines = [f'{icon} {title}: {promo_group.name}']
+ lines = [f'{icon} {title}: {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'👤 Пользователь: {user_display}',
f'🆔 {user_id_label}: {user_id_display}',
- f'📱 Username: @{getattr(user, "username", None) or "отсутствует"}',
+ f'📱 Username: @{html.escape(getattr(user, "username", None) or "отсутствует")}',
f'👥 Статус: {user_status}',
'',
]
# Промогруппа — только название, без скидок
if promo_group:
- message_lines.append(f'🏷️ Промогруппа: {promo_group.name}')
+ message_lines.append(f'🏷️ Промогруппа: {html.escape(promo_group.name)}')
else:
message_lines.append('🏷️ Промогруппа: —')
@@ -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:
👤 Пользователь: {user_display}
🆔 {user_id_label}: {user_id_display}
-📱 Username: @{getattr(user, 'username', None) or 'отсутствует'}
+📱 Username: @{html.escape(getattr(user, 'username', None) or 'отсутствует')}
{promo_block}
@@ -1003,7 +1005,7 @@ class AdminNotificationService:
'',
f'👤 Пользователь: {user_display}',
f'🆔 {user_id_label}: {user_id_display}',
- f'📱 Username: @{getattr(user, "username", None) or "отсутствует"}',
+ f'📱 Username: @{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 = [
'📣 ПЕРЕХОД ПО РК',
'',
- f'🧾 {campaign.name} ({campaign.start_parameter})',
+ f'🧾 {html.escape(campaign.name)} ({html.escape(campaign.start_parameter)})',
'',
- f'👤 {full_name} ({telegram_user.id})',
+ f'👤 {html.escape(full_name)} ({telegram_user.id})',
]
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'👮 Инициатор: {initiator.full_name} (ID: {initiator.telegram_id})'
+ initiator_line = (
+ f'👮 Инициатор: {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'👤 Пользователь: {user_display}',
f'🆔 {user_id_label}: {user_id_display}',
- f'📱 Username: @{getattr(user, "username", None) or "отсутствует"}',
+ f'📱 Username: @{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'🔧 Причина: {details["maintenance_reason"]}')
+ message_parts.append(f'🔧 Причина: {html.escape(details["maintenance_reason"])}')
if details.get('estimated_duration'):
message_parts.append(f'⏰ Ожидаемая длительность: {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 = [
'🛑 МАССОВАЯ БЛОКИРОВКА ПОЛЬЗОВАТЕЛЕЙ',
'',
- f'👮 Администратор: {admin_name}',
+ f'👮 Администратор: {html.escape(admin_name)}',
f'🆔 ID администратора: {admin_user_id}',
'',
'📊 Результаты:',
diff --git a/app/services/channel_subscription_service.py b/app/services/channel_subscription_service.py
index 5751b5f7..679070a4 100644
--- a/app/services/channel_subscription_service.py
+++ b/app/services/channel_subscription_service.py
@@ -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)
diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py
index c7f92cb5..9a47c0d1 100644
--- a/app/services/daily_subscription_service.py
+++ b/app/services/daily_subscription_service.py
@@ -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)
diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py
index 5c35dd5a..1c699bde 100644
--- a/app/services/monitoring_service.py
+++ b/app/services/monitoring_service.py
@@ -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'⏰ Ожидание ответа на тикет превышено\n\n'
@@ -1896,7 +1929,7 @@ class MonitoringService:
f'👤 Пользователь: {full_name}\n'
f'🆔 Telegram ID: {telegram_id_display}\n'
f'📱 Username: @{username_display}\n'
- f'📝 Заголовок: {title or "—"}\n'
+ f'📝 Заголовок: {safe_title}\n'
f'⏱️ Ожидает ответа: {waited_minutes} мин\n'
)
diff --git a/app/services/nalogo_queue_service.py b/app/services/nalogo_queue_service.py
index 2a91b8ce..d7706821 100644
--- a/app/services/nalogo_queue_service.py
+++ b/app/services/nalogo_queue_service.py
@@ -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(
diff --git a/app/services/news_media_service.py b/app/services/news_media_service.py
new file mode 100644
index 00000000..765b57dc
--- /dev/null
+++ b/app/services/news_media_service.py
@@ -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
diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py
index f33100eb..0908291f 100644
--- a/app/services/payment/cloudpayments.py
+++ b/app/services/payment/cloudpayments.py
@@ -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
diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py
index d7c23c0c..5bf69e33 100644
--- a/app/services/payment/pal24.py
+++ b/app/services/payment/pal24.py
@@ -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 = {}
diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py
index e95b4285..44c49aa8 100644
--- a/app/services/payment/yookassa.py
+++ b/app/services/payment/yookassa.py
@@ -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,
diff --git a/app/services/phantom_service.py b/app/services/phantom_service.py
new file mode 100644
index 00000000..62ba5a23
--- /dev/null
+++ b/app/services/phantom_service.py
@@ -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,
+ )
diff --git a/app/services/poll_service.py b/app/services/poll_service.py
index 7be577f1..c482f229 100644
--- a/app/services/poll_service.py
+++ b/app/services/poll_service.py
@@ -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'🗳️ {poll.title}']
+ lines: list[str] = [f'🗳️ {html.escape(poll.title)}']
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(
diff --git a/app/services/pricing_engine.py b/app/services/pricing_engine.py
index 9174a228..8e72267c 100644
--- a/app/services/pricing_engine.py
+++ b/app/services/pricing_engine.py
@@ -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
diff --git a/app/services/recurrent_payment_service.py b/app/services/recurrent_payment_service.py
index e9155187..0b63d66c 100644
--- a/app/services/recurrent_payment_service.py
+++ b/app/services/recurrent_payment_service.py
@@ -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 '',
diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py
index 9ff377f7..1a091289 100644
--- a/app/services/referral_contest_service.py
+++ b/app/services/referral_contest_service.py
@@ -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 = [
'🏆 Конкурс рефералов',
- f'Название: {contest.title}',
+ f'Название: {html.escape(contest.title)}',
f'Статус: {"финал" if is_final else "дневная сводка"}',
f'Временная зона: {tz.key}',
f'Всего рефералов: {total_events}',
@@ -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'Всего участников: {len(leaderboard)}',
@@ -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'Ваше место: {rank}',
@@ -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)
diff --git a/app/services/referral_service.py b/app/services/referral_service.py
index c28a1cbd..956bc710 100644
--- a/app/services/referral_service.py
+++ b/app/services/referral_service.py
@@ -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'🎉 Добро пожаловать!\n\n'
- f'Вы перешли по реферальной ссылке пользователя {referrer.full_name}!'
+ f'Вы перешли по реферальной ссылке пользователя {html.escape(referrer.full_name)}!'
)
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'👥 Новый реферал!\n\n'
- f'По вашей ссылке зарегистрировался пользователь {new_user.full_name}!\n\n'
+ f'По вашей ссылке зарегистрировался пользователь {html.escape(new_user.full_name)}!\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'💰 Реферальная комиссия!\n\n'
- f'Ваш реферал {user.full_name} пополнил баланс на '
+ f'Ваш реферал {html.escape(user.full_name)} пополнил баланс на '
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'💰 Реферальная награда!\n\n'
- f'Ваш реферал {user.full_name} сделал первое пополнение '
+ f'Ваш реферал {html.escape(user.full_name)} сделал первое пополнение '
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'💰 Реферальная комиссия!\n\n'
- f'Ваш реферал {user.full_name} пополнил баланс на '
+ f'Ваш реферал {html.escape(user.full_name)} пополнил баланс на '
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'💰 Комиссия с покупки!\n\n'
- f'Ваш реферал {user.full_name} совершил покупку на '
+ f'Ваш реферал {html.escape(user.full_name)} совершил покупку на '
f'{settings.format_price(purchase_amount_kopeks)}\n\n'
f'🎁 Ваша комиссия ({commission_percent}%): '
f'{settings.format_price(commission_amount)}\n\n'
diff --git a/app/services/referral_withdrawal_service.py b/app/services/referral_withdrawal_service.py
index adcea99b..6b1141ac 100644
--- a/app/services/referral_withdrawal_service.py
+++ b/app/services/referral_withdrawal_service.py
@@ -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🚨 Подозрительные рефералы:\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'
# Источники дохода
diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py
index 3ba80ba3..efc9aea5 100644
--- a/app/services/subscription_auto_purchase_service.py
+++ b/app/services/subscription_auto_purchase_service.py
@@ -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'✅ Суточный тариф «{tariff.name}» активирован!\n\n'
+ f'✅ Суточный тариф «{html.escape(tariff.name)}» активирован!\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),
)
diff --git a/app/services/traffic_monitoring_service.py b/app/services/traffic_monitoring_service.py
index 0ac17576..b22f8981 100644
--- a/app/services/traffic_monitoring_service.py
+++ b/app/services/traffic_monitoring_service.py
@@ -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'👤 {db_user.full_name or "Без имени"}\n🆔 ID: {user_id_display}\n'
- )
+ user_info = f'👤 {html.escape(db_user.full_name or "Без имени")}\n🆔 ID: {user_id_display}\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 = '⚡'
diff --git a/app/utils/formatting.py b/app/utils/formatting.py
index 7df11e0b..2d60c373 100644
--- a/app/utils/formatting.py
+++ b/app/utils/formatting.py
@@ -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'{safe}'
+ return f'{safe}'
+
def format_traffic(gb: int) -> str:
"""Форматирует трафик."""
diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py
index 0409191e..8b6dbff9 100644
--- a/app/utils/message_patch.py
+++ b/app/utils/message_patch.py
@@ -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
diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py
index c4c9c1d6..31fa926d 100644
--- a/app/utils/payment_utils.py
+++ b/app/utils/payment_utils.py
@@ -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':
diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py
index 726dfabd..b732030c 100644
--- a/app/webapi/routes/miniapp.py
+++ b/app/webapi/routes/miniapp.py
@@ -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'
diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py
index 14d14cc5..ad33c05a 100644
--- a/app/webapi/routes/users.py
+++ b/app/webapi/routes/users.py
@@ -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')
diff --git a/app/webapi/schemas/subscriptions.py b/app/webapi/schemas/subscriptions.py
index 6b05c4ac..28c6d9b3 100644
--- a/app/webapi/schemas/subscriptions.py
+++ b/app/webapi/schemas/subscriptions.py
@@ -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):
diff --git a/app/webapi/schemas/users.py b/app/webapi/schemas/users.py
index 1154c06f..96cca72f 100644
--- a/app/webapi/schemas/users.py
+++ b/app/webapi/schemas/users.py
@@ -82,7 +82,7 @@ class UserUpdateRequest(BaseModel):
class BalanceUpdateRequest(BaseModel):
- amount_kopeks: int
+ amount_kopeks: int = Field(..., ge=-100_000_000, le=100_000_000)
description: str | None = Field(default='Корректировка через веб-API')
create_transaction: bool = True
diff --git a/app/webserver/payments.py b/app/webserver/payments.py
index f3ef44f9..e48cd879 100644
--- a/app/webserver/payments.py
+++ b/app/webserver/payments.py
@@ -203,7 +203,14 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_401_UNAUTHORIZED,
)
- if settings.TRIBUTE_API_KEY and not tribute_api.verify_webhook_signature(payload, signature):
+ if not settings.TRIBUTE_API_KEY:
+ logger.error('Tribute webhook received but API key is not configured, rejecting')
+ return JSONResponse(
+ {'status': 'error', 'reason': 'service_not_configured'},
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ )
+
+ if not tribute_api.verify_webhook_signature(payload, signature):
return JSONResponse(
{'status': 'error', 'reason': 'invalid_signature'},
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -313,20 +320,26 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
signature = request.headers.get('Crypto-Pay-API-Signature')
secret = settings.CRYPTOBOT_API_TOKEN
- if secret:
- if not signature:
- return JSONResponse(
- {'status': 'error', 'reason': 'missing_signature'},
- status_code=status.HTTP_401_UNAUTHORIZED,
- )
+ if not secret:
+ logger.error('CryptoBot webhook received but API token is not configured, rejecting')
+ return JSONResponse(
+ {'status': 'error', 'reason': 'service_not_configured'},
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ )
- from app.external.cryptobot import CryptoBotService
+ if not signature:
+ return JSONResponse(
+ {'status': 'error', 'reason': 'missing_signature'},
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ )
- if not CryptoBotService().verify_webhook_signature(payload_text, signature):
- return JSONResponse(
- {'status': 'error', 'reason': 'invalid_signature'},
- status_code=status.HTTP_401_UNAUTHORIZED,
- )
+ from app.external.cryptobot import CryptoBotService
+
+ if not CryptoBotService().verify_webhook_signature(payload_text, signature):
+ return JSONResponse(
+ {'status': 'error', 'reason': 'invalid_signature'},
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ )
try:
success = await _process_payment_service_callback(
@@ -686,7 +699,10 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
if not merchant_id and not secret and not raw_body.strip():
logger.info('Platega webhook verification ping (no auth headers, empty body)')
return JSONResponse({'status': 'ok'})
- if merchant_id != (settings.PLATEGA_MERCHANT_ID or '') or secret != (settings.PLATEGA_SECRET or ''):
+ if not (
+ hmac.compare_digest(merchant_id, settings.PLATEGA_MERCHANT_ID or '')
+ and hmac.compare_digest(secret, settings.PLATEGA_SECRET or '')
+ ):
return JSONResponse(
{'status': 'error', 'reason': 'unauthorized'},
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -766,21 +782,18 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
headers=dict(request.headers),
)
- # Проверяем подпись только если она пришла и API_SECRET настроен
+ # Проверяем подпись если API_SECRET настроен
# CloudPayments использует заголовок X-Content-HMAC или Content-HMAC
signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or ''
- if settings.CLOUDPAYMENTS_API_SECRET and signature:
+ if settings.CLOUDPAYMENTS_API_SECRET:
+ if not signature:
+ logger.warning('CloudPayments webhook: signature header missing, rejecting')
+ return JSONResponse({'code': 13})
if not cloudpayments_service.verify_webhook_signature(
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
):
- logger.warning(
- 'CloudPayments check webhook: invalid signature, sig=...',
- signature=signature[:20] if signature else 'empty',
- )
- return JSONResponse({'code': 13}) # Отклонить
- elif settings.CLOUDPAYMENTS_API_SECRET and not signature:
- # Подпись не пришла, но API_SECRET настроен - пропускаем проверку с предупреждением
- logger.warning('CloudPayments check webhook: no signature header, skipping verification')
+ logger.warning('CloudPayments webhook: invalid signature')
+ return JSONResponse({'code': 13})
# Разрешаем платёж
logger.info('CloudPayments check webhook: allowing payment, returning code=0')
@@ -796,13 +809,16 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
"""Pay webhook - вызывается после успешной оплаты."""
raw_body = await request.body()
- # Проверяем подпись только если она пришла и API_SECRET настроен
+ # Проверяем подпись если API_SECRET настроен
signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or ''
- if settings.CLOUDPAYMENTS_API_SECRET and signature:
+ if settings.CLOUDPAYMENTS_API_SECRET:
+ if not signature:
+ logger.warning('CloudPayments webhook: signature header missing, rejecting')
+ return JSONResponse({'code': 13})
if not cloudpayments_service.verify_webhook_signature(
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
):
- logger.warning('CloudPayments pay webhook: invalid signature')
+ logger.warning('CloudPayments webhook: invalid signature')
return JSONResponse({'code': 13})
# Парсим данные формы
@@ -828,13 +844,16 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
"""Fail webhook - вызывается при неуспешной оплате."""
raw_body = await request.body()
- # Проверяем подпись только если она пришла и API_SECRET настроен
+ # Проверяем подпись если API_SECRET настроен
signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or ''
- if settings.CLOUDPAYMENTS_API_SECRET and signature:
+ if settings.CLOUDPAYMENTS_API_SECRET:
+ if not signature:
+ logger.warning('CloudPayments webhook: signature header missing, rejecting')
+ return JSONResponse({'code': 13})
if not cloudpayments_service.verify_webhook_signature(
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
):
- logger.warning('CloudPayments fail webhook: invalid signature')
+ logger.warning('CloudPayments webhook: invalid signature')
return JSONResponse({'code': 13})
# Парсим данные формы
@@ -868,9 +887,12 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
headers=dict(request.headers),
)
- # Проверяем подпись только если она пришла и API_SECRET настроен
+ # Проверяем подпись если API_SECRET настроен
signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or ''
- if settings.CLOUDPAYMENTS_API_SECRET and signature:
+ if settings.CLOUDPAYMENTS_API_SECRET:
+ if not signature:
+ logger.warning('CloudPayments webhook: signature header missing, rejecting')
+ return JSONResponse({'code': 13})
if not cloudpayments_service.verify_webhook_signature(
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
):
@@ -958,16 +980,10 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
@router.post(settings.FREEKASSA_WEBHOOK_PATH)
async def freekassa_webhook(request: Request) -> Response:
- # Получаем IP клиента с учетом прокси
- x_forwarded_for = request.headers.get('X-Forwarded-For')
- if x_forwarded_for:
- client_ip = x_forwarded_for.split(',')[0].strip()
- else:
- real_ip = request.headers.get('X-Real-IP')
- if real_ip:
- client_ip = real_ip.strip()
- else:
- client_ip = request.client.host if request.client else '127.0.0.1'
+ # Use transport-layer IP as primary source; only trust proxy headers
+ # when the direct connection comes from a known proxy.
+ # This prevents X-Forwarded-For spoofing by external attackers.
+ client_ip = request.client.host if request.client else '127.0.0.1'
# Получаем данные формы
try:
diff --git a/app/webserver/unified_app.py b/app/webserver/unified_app.py
index 52be32d7..e63508da 100644
--- a/app/webserver/unified_app.py
+++ b/app/webserver/unified_app.py
@@ -89,6 +89,17 @@ def _create_base_app() -> FastAPI:
return app
+def _mount_uploads_static(app: FastAPI) -> None:
+ """Mount the media uploads directory as a static file server at /uploads."""
+ uploads_path = settings.get_media_upload_path()
+ uploads_path.mkdir(parents=True, exist_ok=True)
+ try:
+ app.mount('/uploads', StaticFiles(directory=uploads_path), name='media-uploads')
+ logger.info('Media uploads static files mounted at /uploads', uploads_path=str(uploads_path))
+ except RuntimeError as error: # pragma: no cover - defensive guard
+ logger.warning('Failed to mount media uploads static files', error=error)
+
+
def _mount_miniapp_static(app: FastAPI) -> tuple[bool, Path]:
static_path: Path = settings.get_miniapp_static_path()
if not static_path.exists():
@@ -175,6 +186,7 @@ def create_unified_app(
await disposable_email_service.stop()
miniapp_mounted, miniapp_path = _mount_miniapp_static(app)
+ _mount_uploads_static(app)
unified_health_path = '/health/unified' if settings.is_web_api_enabled() else '/health'
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index fb7f1626..037b83b8 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -69,6 +69,7 @@ services:
- ./locales:/app/locales:rw
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
+ - ./uploads:/app/uploads:rw
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- '${WEB_API_PORT:-8080}:8080'
diff --git a/docker-compose.yml b/docker-compose.yml
index eb7f3a38..ee0e3138 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -72,6 +72,8 @@ services:
# Timezone
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
+ # Загруженные медиафайлы (изображения/видео для новостей)
+ - ./uploads:/app/uploads:rw
# Логотип для сообщений
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
diff --git a/migrations/alembic/versions/0046_add_news_articles.py b/migrations/alembic/versions/0046_add_news_articles.py
new file mode 100644
index 00000000..8a2a51a3
--- /dev/null
+++ b/migrations/alembic/versions/0046_add_news_articles.py
@@ -0,0 +1,66 @@
+"""add news_articles table
+
+Revision ID: 0046
+Revises: 0045
+Create Date: 2026-03-23
+
+Adds news_articles table for the cabinet news/blog feature.
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = '0046'
+down_revision: str | None = '0045'
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'news_articles',
+ sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column('title', sa.String(500), nullable=False),
+ sa.Column('slug', sa.String(500), nullable=False),
+ sa.Column('content', sa.Text(), nullable=False, server_default=''),
+ sa.Column('excerpt', sa.Text(), nullable=True),
+ sa.Column('category', sa.String(100), nullable=False, server_default=''),
+ sa.Column('category_color', sa.String(20), nullable=False, server_default='#00e5a0'),
+ sa.Column('tag', sa.String(50), nullable=True),
+ sa.Column('featured_image_url', sa.Text(), nullable=True),
+ sa.Column('is_published', sa.Boolean(), nullable=False, server_default=sa.text('false')),
+ sa.Column('is_featured', sa.Boolean(), nullable=False, server_default=sa.text('false')),
+ sa.Column('published_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('read_time_minutes', sa.Integer(), nullable=False, server_default='1'),
+ sa.Column('views_count', sa.Integer(), nullable=False, server_default='0'),
+ sa.Column('created_by', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
+ )
+
+ # Unique index on slug (model: unique=True, index=True)
+ op.create_index('ix_news_articles_slug', 'news_articles', ['slug'], unique=True)
+
+ # Composite: covers WHERE is_published = true ORDER BY published_at DESC
+ op.create_index(
+ 'ix_news_articles_published_at_published',
+ 'news_articles',
+ ['is_published', 'published_at'],
+ )
+
+ # Composite: covers WHERE is_published = true AND category = ?
+ op.create_index(
+ 'ix_news_articles_published_category',
+ 'news_articles',
+ ['is_published', 'category'],
+ )
+
+ # Covers admin list: ORDER BY created_at DESC
+ op.create_index('ix_news_articles_created_at', 'news_articles', ['created_at'])
+
+
+def downgrade() -> None:
+ # drop_table cascades to all indexes on the table automatically
+ op.drop_table('news_articles')
diff --git a/migrations/alembic/versions/0047_add_cascade_to_subscription_servers.py b/migrations/alembic/versions/0047_add_cascade_to_subscription_servers.py
new file mode 100644
index 00000000..0ced8d60
--- /dev/null
+++ b/migrations/alembic/versions/0047_add_cascade_to_subscription_servers.py
@@ -0,0 +1,74 @@
+"""add ON DELETE CASCADE and index to subscription_servers.subscription_id
+
+Revision ID: 0047
+Revises: 0046
+Create Date: 2026-03-23
+
+Recreates the FK constraint on subscription_servers.subscription_id
+with ON DELETE CASCADE so that deleting a subscription automatically
+removes dependent subscription_servers rows. Also adds an index
+on subscription_id for efficient CASCADE deletes and joins.
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+from sqlalchemy import text
+
+revision: str = '0047'
+down_revision: str | None = '0046'
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _get_actual_fk_name(connection, table: str, column: str) -> str | None:
+ """Look up actual FK constraint name from pg_constraint."""
+ result = connection.execute(
+ text("""
+ SELECT con.conname
+ FROM pg_constraint con
+ JOIN pg_class rel ON rel.oid = con.conrelid
+ JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
+ JOIN pg_attribute att ON att.attrelid = con.conrelid
+ AND att.attnum = ANY(con.conkey)
+ WHERE rel.relname = :table
+ AND att.attname = :column
+ AND con.contype = 'f'
+ AND nsp.nspname = 'public'
+ LIMIT 1
+ """),
+ {'table': table, 'column': column},
+ )
+ row = result.fetchone()
+ return row[0] if row else None
+
+
+def upgrade() -> None:
+ connection = op.get_bind()
+ actual_fk = _get_actual_fk_name(connection, 'subscription_servers', 'subscription_id')
+ if actual_fk:
+ op.drop_constraint(actual_fk, 'subscription_servers', type_='foreignkey')
+ op.create_foreign_key(
+ 'subscription_servers_subscription_id_fkey',
+ 'subscription_servers',
+ 'subscriptions',
+ ['subscription_id'],
+ ['id'],
+ ondelete='CASCADE',
+ )
+ op.create_index('ix_subscription_servers_subscription_id', 'subscription_servers', ['subscription_id'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_subscription_servers_subscription_id', 'subscription_servers')
+ connection = op.get_bind()
+ actual_fk = _get_actual_fk_name(connection, 'subscription_servers', 'subscription_id')
+ if actual_fk:
+ op.drop_constraint(actual_fk, 'subscription_servers', type_='foreignkey')
+ op.create_foreign_key(
+ 'subscription_servers_subscription_id_fkey',
+ 'subscription_servers',
+ 'subscriptions',
+ ['subscription_id'],
+ ['id'],
+ )
diff --git a/migrations/alembic/versions/0048_add_lower_username_index.py b/migrations/alembic/versions/0048_add_lower_username_index.py
new file mode 100644
index 00000000..c30000a5
--- /dev/null
+++ b/migrations/alembic/versions/0048_add_lower_username_index.py
@@ -0,0 +1,35 @@
+"""add functional index on lower(username) for phantom user lookup
+
+Revision ID: 0048
+Revises: 0047
+Create Date: 2026-03-23
+
+The find_phantom_user_by_username query uses func.lower(User.username)
+which cannot use a regular B-tree index on username. This adds a
+functional index to avoid sequential scans on the users table.
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = '0048'
+down_revision: str | None = '0047'
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ with op.get_context().autocommit_block():
+ op.execute(
+ sa.text(
+ 'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_users_username_lower '
+ 'ON users (lower(username))'
+ )
+ )
+
+
+def downgrade() -> None:
+ with op.get_context().autocommit_block():
+ op.execute(sa.text('DROP INDEX CONCURRENTLY IF EXISTS ix_users_username_lower'))
diff --git a/migrations/alembic/versions/0049_add_news_categories_and_tags.py b/migrations/alembic/versions/0049_add_news_categories_and_tags.py
new file mode 100644
index 00000000..81dd6989
--- /dev/null
+++ b/migrations/alembic/versions/0049_add_news_categories_and_tags.py
@@ -0,0 +1,127 @@
+"""add news_categories and news_tags tables with FK columns on news_articles
+
+Revision ID: 0049
+Revises: 0048
+Create Date: 2026-03-23
+
+Adds managed categories and tags for news articles.
+- Creates news_categories table with case-insensitive unique name index
+- Creates news_tags table with case-insensitive unique name index
+- Adds category_id and tag_id FK columns to news_articles
+- Backfills categories/tags from existing article data
+- Populates FK columns via UPDATE ... FROM matching on lower(name)
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = '0049'
+down_revision: str | None = '0048'
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ # --- news_categories ---
+ op.create_table(
+ 'news_categories',
+ sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column('name', sa.String(100), nullable=False),
+ sa.Column('color', sa.String(20), nullable=False, server_default='#00e5a0'),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ )
+ op.execute(
+ sa.text(
+ "CREATE UNIQUE INDEX ix_news_categories_name_lower ON news_categories (lower(name))"
+ )
+ )
+
+ # --- news_tags ---
+ op.create_table(
+ 'news_tags',
+ sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column('name', sa.String(50), nullable=False),
+ sa.Column('color', sa.String(20), nullable=False, server_default='#94a3b8'),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ )
+ op.execute(
+ sa.text(
+ "CREATE UNIQUE INDEX ix_news_tags_name_lower ON news_tags (lower(name))"
+ )
+ )
+
+ # --- FK columns on news_articles ---
+ op.add_column('news_articles', sa.Column('category_id', sa.Integer(), nullable=True))
+ op.add_column('news_articles', sa.Column('tag_id', sa.Integer(), nullable=True))
+
+ op.create_foreign_key(
+ 'fk_news_articles_category_id',
+ 'news_articles',
+ 'news_categories',
+ ['category_id'],
+ ['id'],
+ ondelete='SET NULL',
+ )
+ op.create_foreign_key(
+ 'fk_news_articles_tag_id',
+ 'news_articles',
+ 'news_tags',
+ ['tag_id'],
+ ['id'],
+ ondelete='SET NULL',
+ )
+
+ # --- Indexes on FK columns for efficient lookups and ON DELETE SET NULL ---
+ op.create_index('ix_news_articles_category_id', 'news_articles', ['category_id'])
+ op.create_index('ix_news_articles_tag_id', 'news_articles', ['tag_id'])
+
+ # --- Backfill: seed categories from existing article data ---
+ op.execute(
+ sa.text(
+ "INSERT INTO news_categories (name, color) "
+ "SELECT DISTINCT category, category_color FROM news_articles "
+ "WHERE category IS NOT NULL AND category != '' "
+ "ON CONFLICT DO NOTHING"
+ )
+ )
+
+ # --- Backfill: seed tags from existing article data ---
+ op.execute(
+ sa.text(
+ "INSERT INTO news_tags (name) "
+ "SELECT DISTINCT tag FROM news_articles "
+ "WHERE tag IS NOT NULL AND tag != '' "
+ "ON CONFLICT DO NOTHING"
+ )
+ )
+
+ # --- Populate FK columns ---
+ op.execute(
+ sa.text(
+ "UPDATE news_articles SET category_id = nc.id "
+ "FROM news_categories nc "
+ "WHERE lower(news_articles.category) = lower(nc.name) "
+ "AND news_articles.category IS NOT NULL AND news_articles.category != ''"
+ )
+ )
+ op.execute(
+ sa.text(
+ "UPDATE news_articles SET tag_id = nt.id "
+ "FROM news_tags nt "
+ "WHERE lower(news_articles.tag) = lower(nt.name) "
+ "AND news_articles.tag IS NOT NULL AND news_articles.tag != ''"
+ )
+ )
+
+
+def downgrade() -> None:
+ op.execute(sa.text('DROP INDEX IF EXISTS ix_news_articles_tag_id'))
+ op.execute(sa.text('DROP INDEX IF EXISTS ix_news_articles_category_id'))
+ op.drop_constraint('fk_news_articles_tag_id', 'news_articles', type_='foreignkey')
+ op.drop_constraint('fk_news_articles_category_id', 'news_articles', type_='foreignkey')
+ op.drop_column('news_articles', 'tag_id')
+ op.drop_column('news_articles', 'category_id')
+ op.drop_table('news_tags')
+ op.drop_table('news_categories')
diff --git a/tests/test_pricing_engine.py b/tests/test_pricing_engine.py
index aa7f27d5..b7a9c20e 100644
--- a/tests/test_pricing_engine.py
+++ b/tests/test_pricing_engine.py
@@ -232,12 +232,19 @@ class TestCalculateTrafficPrice:
price = engine._calculate_traffic_price(traffic_limit_gb=125, purchased_traffic_gb=100)
assert price == 11000 # NOT 12000
- def test_zero_traffic(self):
+ def test_unlimited_traffic_has_price(self):
engine = PricingEngine()
with patch('app.services.pricing_engine.settings') as ms:
- ms.get_traffic_price.return_value = 0
+ ms.get_traffic_price.side_effect = lambda gb: {0: 20000, 5: 2000}.get(gb, 0)
price = engine._calculate_traffic_price(traffic_limit_gb=0, purchased_traffic_gb=0)
- assert price == 0
+ assert price == 20000 # 0 GB = unlimited, charged at unlimited tier
+
+ def test_unlimited_traffic_ignores_purchased(self):
+ engine = PricingEngine()
+ with patch('app.services.pricing_engine.settings') as ms:
+ ms.get_traffic_price.side_effect = lambda gb: {0: 20000, 50: 5000}.get(gb, 0)
+ price = engine._calculate_traffic_price(traffic_limit_gb=0, purchased_traffic_gb=50)
+ assert price == 20000 # unlimited tier, purchased ignored
def test_purchased_exceeds_total(self):
engine = PricingEngine()