788 Commits

Author SHA1 Message Date
Vladless 02fbf89d09 version 4.0 2025-03-02 21:53:20 +03:00
Oleg Kraev 305fbde885 Update README.md 2025-03-02 16:39:09 +03:00
Vladless 3d94037d4e version 4.0 2025-03-02 15:31:47 +03:00
Vladless 4a5aae06c3 add screening username 2025-03-02 12:33:52 +03:00
Vladislav Lisitsyn 19e476aa3f Update README.md 2025-03-02 03:23:07 +03:00
Vladless 5c62ed8d92 Added an extension check 2025-03-02 02:50:12 +03:00
Vladislav Lisitsyn f138572b38 Update README.md 2025-03-01 12:11:23 +03:00
Vladislav Lisitsyn f907906d04 Update README.md 2025-03-01 02:17:41 +03:00
Vladless 097ce51b2c add preview_2 2025-03-01 02:15:57 +03:00
Vladless 1fa553f246 add preview 2025-03-01 00:21:15 +03:00
Vladless 32c65b3194 up bot version 2025-02-28 22:44:39 +03:00
Vladless a4d11f67fa fix captha and stars 2025-02-28 20:30:04 +03:00
Vladless 1775d90bab fix Hiddify subscription 2025-02-26 12:46:33 +03:00
Vladless fa2d3eaa0a minor bug fixes 2025-02-25 22:06:44 +03:00
Vladless 148e99ea6f minor improvements 2025-02-25 20:19:41 +03:00
Vladless 64f99ac660 up bot version 2025-02-25 20:10:26 +03:00
Vladless bded99263a refactoring and minor improvements 2025-02-25 19:56:42 +03:00
Vladislav Lisitsyn aded5606a8 Merge pull request #160 from izzzzzi/main
Рефакторинг и оптимизация обработки подписок
2025-02-25 18:43:14 +03:00
izzzzzi 79982f5aa6 Рефакторинг и оптимизация импортов в модулях проекта
- Упорядочивание и группировка импортов в соответствии с PEP 8
- Удаление неиспользуемых импортов
- Перемещение локальных импортов в начало списка
- Добавление пустых строк для улучшения читаемости
- Систематизация порядка импортов в различных модулях проекта
2025-02-25 04:14:58 +05:00
izzzzzi dc27a6e69b Рефакторинг и улучшение модуля резервного копирования базы данных
- Добавлена расширенная типизация и аннотации типов
- Улучшена обработка ошибок и логирование в функциях бэкапа
- Оптимизирован процесс создания и очистки резервных копий с использованием pathlib
- Добавлены информативные docstring комментарии
- Усовершенствована отправка бэкапов администраторам с обработкой исключений
- Использованы современные подходы к работе с файловой системой и переменными окружения
2025-02-25 02:51:02 +05:00
izzzzzi 8af59ffbfe Улучшение обработки пользователей и middleware с возвратом данных
- Модифицирована функция upsert_user для возврата полных данных пользователя
- Обновлен UserMiddleware для сохранения данных пользователя в контексте
- Расширена функциональность register_middleware с поддержкой опционального исключения middleware
- Добавлена типизация и улучшена обработка middleware
2025-02-25 02:46:59 +05:00
izzzzzi 013b26ecc8 Оптимизация и улучшение middleware и обработки сессий базы данных
- Рефакторинг middlewares с добавлением типизации и улучшенной логики
- Оптимизация SessionMiddleware с использованием пула соединений
- Улучшение обработки пользователей и логирования в UserMiddleware
- Расширение функциональности ThrottlingMiddleware с более гибким управлением
- Добавление дополнительных проверок и логирования в AdminMiddleware
- Оптимизация функции upsert_user с поддержкой передачи существующей сессии
2025-02-25 02:39:44 +05:00
izzzzzi 7df831c084 Рефакторинг и оптимизация обработки подписок
- Значительное улучшение структуры кода в subscriptions.py
- Добавлены типизация и аннотации типов
- Разделение логики на отдельные функции с четкой ответственностью
- Улучшена обработка часовых поясов с использованием pytz
- Оптимизирована логика получения и обработки подписок
- Добавлены информативные логи и комментарии
2025-02-25 01:00:41 +05:00
Vladless 507e0b5e1d refactoring and fix refferal_functions 2025-02-23 23:04:08 +03:00
Vladless f2a12b8d98 fix country_mode 2025-02-23 17:03:31 +03:00
Vladless 1d089cce7d add_commands: coupon/invite 2025-02-22 19:50:53 +03:00
Vladislav Lisitsyn ba89c054fd Merge pull request #159 from izzzzzi/main
Refactor inline referral handler and admin stats time display
2025-02-22 14:12:53 +03:00
izzzzzi 33801f58c4 Refactor inline referral handler and admin stats time display
- Simplified inline referral handler by removing unnecessary try-except block
- Increased cache time for inline query results
- Added Moscow timezone for admin stats update time display
2025-02-22 14:27:56 +05:00
Vladislav Lisitsyn a963f4ad12 Merge pull request #158 from Vladless/feature/time-in-admin-stats
Время обновления в главном меню стастистики Администратора
2025-02-22 11:27:41 +03:00
hteppl b8472ee428 Add update_time in admin stats main page 2025-02-22 11:22:56 +03:00
Vladless 769f5eaf9a minor improvements 2025-02-21 22:36:03 +03:00
Vladislav Lisitsyn c9e791fa76 Merge pull request #157 from Capybara-z/patch-9
Оптимизация subscriptions.py
2025-02-21 17:33:16 +03:00
Capybara-z a32d9d422f Конвертатор байтов
- Добавлен конвертатор байтов.
2025-02-20 20:07:58 +03:00
Capybara-z 39baa1640c Оптимизация трафика
- Оптимизирован траифик, спасибо @hteppl
- Исправлен баг с логами в хедере хидифая.
2025-02-20 20:06:27 +03:00
Capybara-z 37ebf4cdee Апдейт
- Время подписок берется из бд
- Облегчен парсер данных
- Добавлена поддержка хидифай
- Улучшена работа отдачи и обновления подписки.
2025-02-19 22:26:01 +03:00
Vladislav Lisitsyn bb0085a438 Merge pull request #156 from Capybara-z/patch-8
changing the subscription issue
2025-02-19 02:33:33 +03:00
Capybara-z b05e57fb16 фикс парсера времени
- фикс парсера времени для старых панелей
2025-02-19 01:31:15 +03:00
Capybara-z 4c977d07ce чистка кода
- Почитсил коментарии.
2025-02-18 23:57:38 +03:00
Capybara-z bd39825cf9 фикс расчета остатка
- Не посмотрел что TOTAL_GB указывается в байтах
2025-02-18 19:40:47 +03:00
Capybara-z e01a7efe4a фикс подписок
- Поправлено время для SUPERNODA = False
- Добавлен подсчет использованного трафика для Happ
2025-02-18 17:35:40 +03:00
Capybara-z 8b159ab75d фикс парсинга времени
- исправлен парсинг времени если передается без запятой
2025-02-18 14:31:43 +03:00
Capybara-z 316bd191a2 Указал коменты 2025-02-18 13:45:59 +03:00
Capybara-z 29f6fe40e0 фикс отображения
- Исправлено отображение с трафиком и без для спуперноды вкл/выкл
- Исправлен парсинг в общем для разных режимов.
2025-02-18 13:30:46 +03:00
Capybara-z 40f1f1b14e Правки для Happ
- Добавлен хедер трафика и даты завершения
2025-02-18 12:12:34 +03:00
Capybara-z 31822ee634 поправлены отступы 2025-02-18 11:19:52 +03:00
Capybara-z 773db02746 Фикс отображения данных
- Фикс отображения данных для Happ
2025-02-18 11:17:33 +03:00
Capybara-z 2ba9615151 фикс отображения трафика
- Корректное отображения трафика
2025-02-18 10:51:51 +03:00
Capybara-z 19c4c0496a Единое время обновления подписки
- Единое время обновления подписки
2025-02-18 10:30:18 +03:00
Capybara-z 903cde9a78 фикс часов
- фискс отображения когда остаются только часы
2025-02-18 10:19:05 +03:00
Capybara-z 3592eccea9 Фикс подпсиок
- Убираем профиль
- Отказ от SUB_MESSAGE
- Перадача подпсики и времени работы вместо SUB_MESSAGE
- Отдельная итеграция для Happ по хукам.
2025-02-18 09:21:10 +03:00
Vladless 4afd1668c9 add_change_domain/bug_fixes_16 2025-02-18 02:05:14 +03:00
Vladislav Lisitsyn f799dd8766 Merge pull request #154 from Capybara-z/patch-7
subscriptions update
2025-02-18 00:09:00 +03:00
Capybara-z 254c489010 Апдейт подписок.
- Добавление профиля.
- Выводит имейл и время подписки
- Страны становятся чистыми
- Профиль всегда на 1 месте
- Перемашка стран.
2025-02-17 02:50:21 +03:00
Vladislav Lisitsyn 53bc6f6167 Merge pull request #153 from JustYay/main
small fix for showing error
2025-02-17 02:47:00 +03:00
Boris Kovalskii b48cb94b12 small fix for show error 2025-02-16 17:27:52 +10:00
Vladislav Lisitsyn 0813ea2897 Merge pull request #152 from JustYay/main
Optimize and fix refferals
2025-02-16 10:20:54 +03:00
Boris Kovalskii 770f40f4ff Opimize and fix coupons and refferals 2025-02-16 17:09:16 +10:00
Boris Kovalskii dba8752436 optimize database 2025-02-16 17:08:38 +10:00
Vladless 4b503ce31a edit_text/lots of improvements 2025-02-16 07:17:35 +03:00
Vladislav Lisitsyn 8b9c00f53e Merge pull request #151 from izzzzzi/main
Add client toggle functionality for 3x-ui servers
2025-02-15 00:10:53 +03:00
Zakhar Izmaylov 55811aff56 Add inline referral query handler with customizable offers 2025-02-14 23:20:40 +03:00
Zakhar Izmaylov 310aa9bce6 Add client toggle functionality for 3x-ui servers 2025-02-14 23:06:45 +03:00
Vladislav Lisitsyn 2dcd1e65db Update README.md 2025-02-14 05:17:20 +03:00
Vladislav Lisitsyn 4f840c171a Update README.md 2025-02-14 05:08:36 +03:00
Vladless bfefdef304 optimization notifications and get_traffic 2025-02-14 04:36:37 +03:00
Vladless 3de049e436 NOTIFICATIONS 2.0/bug fixes and improvments 2025-02-14 00:30:06 +03:00
Vladislav Lisitsyn 74bb6b3f6d Merge pull request #149 from izzzzzi/main
Add notification tracking to prevent duplicate notifications for key expiry and renewal
2025-02-10 21:57:46 +03:00
Zakhar Izmaylov 56e6dc3ae6 Migrate from requirements.txt to pyproject.toml for dependency management 2025-02-09 12:08:22 +03:00
Zakhar Izmaylov c34626aec0 Add logging support for inline queries in middleware 2025-02-09 10:52:02 +03:00
Zakhar Izmaylov d4187149b8 Add notification tracking to prevent duplicate notifications for key expiry and renewal 2025-02-08 11:10:11 +03:00
Zakhar Izmaylov e982a5b718 Ignore GitHub Actions workflow directory in .gitignore 2025-02-08 11:00:00 +03:00
Zakhar Izmaylov a9a17bd693 Remove Ruff code formatting GitHub Actions workflow 2025-02-08 10:59:50 +03:00
GitHub Actions aef1973720 Auto-format code with Ruff using pyproject.toml 2025-02-08 07:46:14 +00:00
Vladless 5a77f29bd5 add traffic statistic/share refferal link and more 2025-02-07 21:38:46 +03:00
Vladless 0789b3fe9d Improve stability and minor fixes 2025-02-06 19:01:20 +03:00
Vladless c82f64980b fix_amount 2025-02-06 14:56:47 +03:00
Vladless 7623f339bc ruff formatting 2025-02-06 13:53:02 +03:00
Vladless 3de1635efa Major bug fixes 2025-02-06 02:03:12 +03:00
Vladless eb824162d2 fixes: float_stats/delete_user/message_errors 2025-02-05 05:58:03 +03:00
Vladless 54d5df72af minor_fixes 2025-02-03 00:08:55 +03:00
Vladless 74c991cf85 limit_coupon_usage 2025-02-02 23:15:55 +03:00
Vladless 46e6102412 minor fixes 2025-02-02 19:40:17 +03:00
Vladless 698b8a9484 minor fixes 2025-02-02 19:00:22 +03:00
Vladless 94f3c00628 fix_negative cashback 2025-02-02 17:49:43 +03:00
Vladless 630cf61f84 change_location/CASHBACK/logging/bug_fixes 2025-02-02 05:50:14 +03:00
Vladless d3b384aa61 change_location/CASHBACK/logging/bug_fixes 2025-02-02 02:42:28 +03:00
Vladislav Lisitsyn d209cf11c7 Merge pull request #147 from Capybara-z/patch-5
reorganizing notifications
2025-02-02 02:14:08 +03:00
Vladless 49eb0e68a1 change_location/CASHBACK/logging/bug_fixes 2025-02-02 02:11:57 +03:00
Capybara-z 3e7bebf189 Корректировка уведомлений.
- Исправлена работа оповещений 10/24 часа.
- Восстановлена и переработана работа истекших подписок.
- Переработана работа уведомлений при их удалении.
2025-02-02 00:58:07 +03:00
Vladless 73fbc22324 fix coupons error 2025-02-01 19:15:47 +03:00
Vladless 9774db74e5 Merge remote-tracking branch 'origin/fix/admin-actions' 2025-02-01 18:32:38 +03:00
Vladislav Lisitsyn 82d8b50956 Merge pull request #144 from izzzzzi/main
Enhance error handling with start menu fallback
2025-02-01 18:11:23 +03:00
Zakhar Izmaylov 587bf76c61 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-02-01 11:34:33 +03:00
Zakhar Izmaylov c2e4719f18 Enhance error handling with start menu fallback
- Add fallback mechanism to display start menu after error occurs
- Attempt to show start command or start callback query in error handler
- Log any additional errors during start menu display
2025-02-01 11:28:02 +03:00
Vladislav Lisitsyn 7b4bddfa28 Merge pull request #143 from izzzzzi/main
Update get_key_details query and return value
2025-01-31 21:40:13 +03:00
Zakhar Izmaylov 8be57829fb Update get_key_details query and return value
- Reorder SELECT columns in database query for get_key_details
- Add server_id to the returned key details dictionary
- Ensure consistent and complete key information retrieval
2025-01-31 21:05:55 +03:00
Vladislav Lisitsyn ceb3e160da Merge pull request #141 from izzzzzi/main
Fix key
2025-01-31 14:30:13 +03:00
Zakhar Izmaylov 007f3a7bcf Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-31 11:01:40 +03:00
Zakhar Izmaylov cc42900142 Update server name input handler with session parameter
- Add session parameter to server name input handler
- Modify server name uniqueness check to use session and cluster name
- Improve data retrieval and validation in server creation flow
2025-01-31 10:54:42 +03:00
hteppl 1009bb4001 Temporary fix for key expiry change 2025-01-31 06:46:53 +03:00
hteppl e4727dd0a4 Missing doc for field in update_key_expiry 2025-01-31 06:45:56 +03:00
GitHub Actions 3d6acf51f6 Auto-format code with Ruff using pyproject.toml 2025-01-31 03:45:45 +00:00
hteppl 1634588be9 Delete admin_user_editor.py 2025-01-31 06:45:24 +03:00
Zakhar Izmaylov c379011fd2 Fix key renewal and expiry update function signatures
- Update `change_expiry_time` to pass session to `update_key_expiry`
- Adjust `complete_key_renewal` connection closing order
- Minor code structure improvements in key handling functions
2025-01-31 06:44:36 +03:00
hteppl 8483ef1df0 Fix cluster selection 2025-01-31 06:10:37 +03:00
hteppl 9be291c1e8 Oops legacy codestyle 2025-01-31 05:19:52 +03:00
hteppl 6871f2d15e Get back cluster sync option 2025-01-31 05:14:40 +03:00
hteppl 60c9a4f1e6 Get back cluster sender option 2025-01-31 05:03:07 +03:00
hteppl 5a894e0e10 Fix type mismatch for build_admin_back_btn 2025-01-31 04:21:03 +03:00
Zakhar Izmaylov 45b9c56282 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-31 03:14:14 +03:00
Zahar Izmailov 88c4d940e8 Merge branch 'Vladless:main' into main 2025-01-31 03:09:45 +03:00
Zakhar Izmaylov 3741e4f3bf Standardize import order in handler modules
- Reorder imports in multiple handler files to follow a consistent pattern
- Move type-specific imports like Message and CallbackQuery to be more organized
- Ensure clean and uniform import statements across admin and handler modules
2025-01-31 03:01:46 +03:00
Zakhar Izmaylov 28e7c78601 Update type hints in admin and handler modules
- Replace types.Message and types.CallbackQuery with explicit Message and CallbackQuery imports
- Improve type annotations in multiple handler files
- Ensure consistent type hinting across admin and handler modules
2025-01-31 02:59:57 +03:00
Zakhar Izmaylov 37fbd35e8f Add trailing comma in handlers router inclusion
- Minor formatting adjustment in router configuration
- Ensure consistent code style with trailing comma
2025-01-31 02:44:48 +03:00
Zakhar Izmaylov c41b77d365 Update database query and key handling in admin user editor
- Modify get_keys function to return all columns in keys table
- Update admin user editor to handle full key record instead of just email
- Cast tg_id to integer in user record processing
- Adjust key display and callback data generation to use full key record
2025-01-31 02:41:40 +03:00
GitHub Actions 1d19d8b744 Auto-format code with Ruff using pyproject.toml 2025-01-30 21:15:14 +00:00
Zakhar Izmaylov b04bc4dca5 Remove user router and related blocking/unblocking logic
- Deleted handlers/user.py file
- Removed user_router from main handlers router
- Eliminated user blocking/unblocking event handlers
2025-01-31 00:14:56 +03:00
Vladislav Lisitsyn 4dd4d829d7 Merge pull request #139 from izzzzzi/main
Fix
2025-01-31 00:09:44 +03:00
Zakhar Izmaylov bf35156455 Remove check_online_users function and minor import reorganization
- Removed unused check_online_users function from notifications handler
- Reorganized imports in notifications.py and utils.py
- Fixed TimeoutError import in subscriptions.py
2025-01-31 00:08:31 +03:00
Zakhar Izmaylov e6a1b55045 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-30 23:37:28 +03:00
Zakhar Izmaylov 455ab76c8a Minor text formatting in captcha generation message 2025-01-30 23:36:56 +03:00
GitHub Actions 720c145aa3 Auto-format code with Ruff using pyproject.toml 2025-01-30 20:36:20 +00:00
Zakhar Izmaylov 580ce90442 Remove unused is_bot_blocked function from notifications handler 2025-01-30 23:36:02 +03:00
Zakhar Izmaylov 239426f6d5 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-30 23:35:50 +03:00
Zakhar Izmaylov 3c4ae34997 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-30 22:36:10 +03:00
Zakhar Izmaylov 899da825cb Refactor and improve security with cryptographically secure random generation
- Replace `random` module with `secrets` for cryptographically secure random choices
- Update type hints for async API methods in client.py
- Optimize file reading with aiofiles in multiple handlers
- Improve error handling and logging in various modules
- Update Python target version to 3.12 in pyproject.toml
- Remove unused imports and improve code quality
2025-01-30 22:35:09 +03:00
Vladislav Lisitsyn 59e0d965da Merge pull request #138 from Capybara-z/patch-2
Расчет времени для исекших уведомлений для DELETE_KEYS_DELAY
2025-01-30 21:58:33 +03:00
Vladislav Lisitsyn b70e27932d Merge pull request #137 from Capybara-z/patch-1
Задержка перед удалением подписки.
2025-01-30 21:58:22 +03:00
Capybara-z 69dcef073e Расчет времени для исекших уведомлений для DELETE_KEYS_DELAY
Добавлена format_time_until_deletion для расчета времени при уведомлении когда подписка истекала.
2025-01-30 21:50:29 +03:00
Capybara-z 89394ef630 Задержка перед удалением подписки.
Добавлена переменная:
DELETE_KEYS_DELAY = 0  # Задержка в секундах перед удалением ключа после истечения. Установите 0, если функция не требуется.

При ее работе не удаляет ключ, а ждет опредленное время.

Поправлена работа def handle_expired_keys, ранее брался запас 45 мин для выгрузки, сейчас EXPIRED_KEYS_CHECK_INTERVAL * 1.5 для корректной проверки и запаса при повторных проверках.

При работе DELETE_KEYS_DELAY добавляется кнопка продления.
2025-01-30 21:48:19 +03:00
GitHub Actions 79a9a95ffc Auto-format code with Ruff using pyproject.toml 2025-01-29 04:18:09 +00:00
Zakhar Izmaylov ee9e65d692 Minor whitespace adjustment in database connection closing 2025-01-29 07:17:53 +03:00
Zakhar Izmaylov 9bc135a51a Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-29 07:17:28 +03:00
Zakhar Izmaylov fabdfc6ab6 Improve type hinting for database backup function
Update return type annotation for `_create_database_backup()` to use explicit tuple type syntax, enhancing type clarity and compatibility with modern Python type hinting practices.
2025-01-29 07:16:21 +03:00
GitHub Actions dff378ad81 Auto-format code with Ruff using pyproject.toml 2025-01-29 04:15:40 +00:00
Zakhar Izmaylov b1739cf4ea Optimize import statements and remove unused imports across multiple files
This commit focuses on cleaning up import statements by:
- Removing unused imports
- Organizing import order
- Removing redundant imports
- Ensuring consistent import style across different modules
2025-01-29 07:15:20 +03:00
GitHub Actions d47952fe3e Auto-format code with Ruff using pyproject.toml 2025-01-29 04:01:20 +00:00
Zakhar Izmaylov 74d58ce310 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-29 07:01:01 +03:00
Vladislav Lisitsyn 5f43bb6e55 add admin-panel by hteppl
reorganizing and optimizing the admin panel
2025-01-28 19:36:54 +03:00
Oleg Kraev f7c906f608 Merge branch 'main' into dev 2025-01-24 23:35:19 +03:00
Zakhar Izmaylov e97e3060f7 Add inline button for user profile in admin panel message processing. Enhanced user interaction by including a button that directs users to their personal account, improving the overall user experience. 2025-01-23 14:43:39 +03:00
Zakhar Izmaylov b6ebfd5c1e Enhance error handling in backup cleanup process. Added a generic exception handler to log errors during the removal of old backups, improving robustness and maintainability of the backup functionality. 2025-01-23 12:10:08 +03:00
Zakhar Izmaylov 3d67311e61 Update button labels from "🔙 Назад" to "⬅️ Назад" across multiple admin handlers and key management files for improved user interface consistency. This change enhances the visual clarity of navigation options within the application. 2025-01-23 12:06:38 +03:00
Zakhar Izmaylov 7bfbf89054 Refactor trial management function names for clarity. Renamed set_trial to update_trial in database.py and updated all related handler calls in start.py, admin_user_editor.py, and trial_key.py to reflect this change. This improves code readability and consistency across the codebase. 2025-01-23 12:03:01 +03:00
GitHub Actions 43959a996e Auto-format code with Ruff using pyproject.toml 2025-01-23 09:00:26 +00:00
Zakhar Izmaylov 591370ce60 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-23 12:00:10 +03:00
Zakhar Izmaylov bb803ab145 Implement new database functions for server and coupon management. Added check_server_name_by_cluster, get_coupon_details, get_referral_by_referred_id, and get_all_keys to enhance data retrieval capabilities. Updated handlers to utilize these new functions, improving code clarity and maintainability. Refactored existing database calls in start.py, utils.py, and keys.py for consistency and better session management. 2025-01-23 11:58:35 +03:00
GitHub Actions 07d14a301b Auto-format code with Ruff using pyproject.toml 2025-01-23 08:40:44 +00:00
Zakhar Izmaylov 56af0a7885 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-23 11:40:25 +03:00
Zakhar Izmaylov c2ba4e9ab6 Refactor server management and database interactions. Renamed get_servers_from_db to get_servers for clarity and updated related handlers to utilize session management. Refactored add_server_to_db to create_server for consistency. Improved code readability and maintainability across coupon and notification handlers by streamlining database calls and enhancing error handling. 2025-01-23 11:36:15 +03:00
GitHub Actions 7b372e095e Auto-format code with Ruff using pyproject.toml 2025-01-23 08:28:39 +00:00
Zakhar Izmaylov 33f98276ce Enhance database interaction in coupon and server management. Updated check_unique_server_name to accept session and cluster name parameters for improved uniqueness checks. Introduced new functions: get_coupon_by_code, delete_server, create_coupon_usage, check_coupon_usage, and update_coupon_usage_count for better coupon management. Refactored handlers to utilize these new functions, improving code clarity and maintainability. 2025-01-23 11:28:21 +03:00
Zakhar Izmaylov 8818d91232 Refactor database interaction in key management functions to utilize session objects for improved performance and consistency. Updated get_keys, get_keys_by_server, update_balance, and update_key_expiry functions to accept a session parameter, allowing for better session management across handlers. Adjusted related handler functions to pass the session, enhancing code clarity and maintainability. 2025-01-23 11:06:25 +03:00
Zakhar Izmaylov 0aab2096a5 Refactor database interaction functions in database.py and update related handlers. Renamed functions for clarity: save_temporary_data to create_temporary_data, add_blocked_user to create_blocked_user, remove_blocked_user to delete_blocked_user, and delete_coupon_from_db to delete_coupon. Updated trial management function from restore_trial to set_trial to better reflect its purpose. Adjusted corresponding calls in notifications, start, user, and admin handlers for consistency and improved readability. 2025-01-23 10:38:32 +03:00
Zakhar Izmaylov 5a3f54c3a7 Refactor client management in key_utils.py and trial_key.py to utilize ClientConfig dataclass for improved parameter handling. This change enhances code clarity and maintainability by consolidating client configuration parameters into a single structured object, streamlining the add_client function calls across multiple handlers. 2025-01-23 10:24:16 +03:00
Zakhar Izmaylov 79ebe6b6bc Refactor backup sending logic in backup.py to remove redundant document sending for admin IDs. Streamlined the process by ensuring only the necessary admin IDs receive the backup document, enhancing code clarity and reducing potential errors in the backup notification process. 2025-01-23 08:54:22 +03:00
Zakhar Izmaylov 6c323dc7df Refactor client configuration and improve code clarity in client.py and admin_servers.py. Added a new line in ClientConfig for better readability and updated the extend_client_key function signature for consistency. Corrected a TODO comment format in admin_servers.py for improved code standards. 2025-01-23 08:50:04 +03:00
Zakhar Izmaylov 94980d4524 Refactor client management functions in client.py to use a dataclass for client configuration. Update add_client, extend_client_key, and delete_client functions to improve parameter handling and logging. Enhance error handling and documentation for better clarity and maintainability. 2025-01-23 08:49:03 +03:00
GitHub Actions 78c00b5454 Auto-format code with Ruff using pyproject.toml 2025-01-23 05:42:01 +00:00
Zakhar Izmaylov 9437ee9c90 Refactor key retrieval and management logic across multiple handlers. Update get_key_details to include email in the fetched record and replace direct SQL queries with dedicated functions for improved code clarity and maintainability. Add a TODO comment in admin_servers.py for future enhancements. 2025-01-23 08:41:44 +03:00
Zakhar Izmaylov 4addb772a0 Add server management functionality by introducing add_server_to_db function for streamlined database interactions. Refactor server addition logic in sync_servers_with_db and admin handlers to utilize the new function, enhancing code clarity and session management. 2025-01-23 08:03:58 +03:00
Zakhar Izmaylov a1d051a3f1 Refactor key management functions to improve database interactions by consolidating key deletion logic into a single function. Introduce timezone-aware expiry date calculations for keys and enhance key retrieval processes across various handlers. Update the start command to utilize a dedicated function for adding connections, ensuring cleaner code and better session management. 2025-01-23 07:49:21 +03:00
Zakhar Izmaylov c4d3c921bd Refactor code to utilize aiofiles for asynchronous file handling across multiple modules. Added a new function to remove blocked users from the database and updated user blocking/unblocking logic to use this new function. Improved database interaction in admin panel handlers by passing session objects instead of creating new connections. This enhances performance and maintains consistency in file operations. 2025-01-23 06:34:45 +03:00
GitHub Actions 1596c85bee Auto-format code with Ruff using pyproject.toml 2025-01-23 03:15:11 +00:00
Zakhar Izmaylov 3d4a72d3fd Update .gitignore to include logs and setup.py, and remove unnecessary entries 2025-01-23 06:14:48 +03:00
Zakhar Izmaylov ad9c49f841 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-23 06:14:33 +03:00
Zakhar Izmaylov cc2b9b52c5 Update balance retrieval to round values to one decimal place in database.py 2025-01-23 05:49:24 +03:00
Vladless 5a1d4c1b24 minor_fixes 2025-01-22 18:46:16 +03:00
Zakhar Izmaylov 99cc4b26bc Add directory creation for database backups in backup.py 2025-01-22 10:31:03 +03:00
Vladless 970511ca1d location-selection/major_update_notifications/gifts and more fixes 2025-01-21 22:43:18 +03:00
Vladless b81a0f289f location-selection/major_update_notifications/gifts and more fixes 2025-01-21 22:43:18 +03:00
hteppl 13259b435b Implement .github folder for github-related data 2025-01-21 18:04:41 +03:00
Zakhar Izmaylov 44cadc29ba Refactor file handling to use aiofiles for asynchronous operations
- Replaced synchronous file reading with asynchronous file handling using aiofiles in multiple handlers and the backup module.
- Improved performance and responsiveness when sending images and backups by utilizing async file operations.
- Ensured consistent use of BufferedInputFile across all relevant functions for better code maintainability.
2025-01-21 12:03:56 +03:00
Zakhar Izmaylov a2c71a49f2 Refactor and clean up code formatting across multiple files
- Consolidated and simplified code formatting by removing unnecessary line breaks and improving readability in various functions.
- Updated the handling of backup file sending in backup.py for better clarity.
- Streamlined error handling and logging messages in several handlers to enhance consistency.
- Adjusted the Makefile to exclude specific files during Ruff checks and formatting.
- Made minor adjustments to function signatures and parameter handling for improved clarity and consistency.

This commit enhances code maintainability and readability without altering functionality.
2025-01-21 08:57:12 +03:00
Zakhar Izmaylov e7d54ae317 Refactor private message filter in bot.py
- Renamed IsPrivate to IsPrivateFilter for clarity and consistency.
- Updated message and callback query filters to use the new IsPrivateFilter class.
2025-01-20 22:38:52 +03:00
Zakhar Izmaylov 1459bdacfa Refactor captcha response handling in check_captcha function
- Removed the deletion of the original message upon correct captcha response.
- Changed the method of sending the captcha text and markup from edit_text to answer for better user experience.
2025-01-20 22:31:14 +03:00
Zakhar Izmaylov a9e111bd4f Add captcha functionality to start command handler
- Included captcha router in main handlers.
- Integrated captcha generation in the start command, conditional on CAPTCHA_ENABLE setting.
2025-01-20 22:27:39 +03:00
Zakhar Izmaylov b63c3fb115 Update work from blocked_users 2025-01-20 03:47:16 +03:00
Vladislav Lisitsyn a0a6686058 Merge pull request #133 from hteppl/admin-redo
Обновление логики работы панели для Администраторов
2025-01-20 03:39:38 +03:00
Zakhar Izmaylov 4c4a1a297c Fix errors_handler 2025-01-19 09:16:56 +03:00
Zakhar Izmaylov fc4bb88db8 Fix and update Middleware 2025-01-19 09:15:42 +03:00
Zakhar Izmaylov e17cde7c72 Update errors_handler 2025-01-19 08:59:43 +03:00
Zakhar Izmaylov 3d7684b72b Small fix 2025-01-19 08:49:46 +03:00
Zakhar Izmaylov 51558aeeda Update requirements.txt 2025-01-19 08:36:18 +03:00
Zakhar Izmaylov d83da5daaa Add filter and middleware 2025-01-19 08:29:10 +03:00
hteppl 67b2da3447 Implement expiry time printing in key names v2 2025-01-19 06:05:45 +03:00
hteppl eeb330b21f Implement expiry time printing in key names 2025-01-19 06:05:22 +03:00
hteppl efff92b2b3 Fix some imports 2025-01-19 05:37:01 +03:00
hteppl 4d5cc954b5 Introduce changing key expiry date keyboard 2025-01-19 05:35:20 +03:00
hteppl 1e204a3e29 Remove get_key_details unused fields for now 2025-01-18 04:54:37 +03:00
hteppl 98d0c03aee Feat https://github.com/Vladless/Solo_bot/commit/b6184d4c90deed7162f618bdebefa44f3e0ee348 2025-01-18 04:36:59 +03:00
hteppl 1a29bb6dd5 Fix bouncing messages while handle user input for changing balance 2025-01-18 04:21:40 +03:00
hteppl 0959742f5b Fix some telegram exceptions while editing the same content + add forward option to users search 2025-01-18 04:09:21 +03:00
hteppl 1c505b74d5 Add and fix some keyboards while creating new cluster or server 2025-01-18 04:08:31 +03:00
Oleg Kraev 96a6612124 Merge pull request #9 from hteppl/main
Changes from upstream
2025-01-17 21:51:08 +03:00
hteppl 78181e7230 Changes from upstream 2025-01-17 21:50:35 +03:00
hteppl 66bba43ec4 Reimage update_subscription callback to work as function 2025-01-17 21:44:54 +03:00
hteppl 4140fbae78 Update balance changing system + fix some formatting 2025-01-17 20:42:45 +03:00
hteppl e4a4d68758 Oops fix imports 2025-01-17 18:25:50 +03:00
hteppl 478d2ee0e6 Introduce utils module + move csv exports logic 2025-01-17 18:25:09 +03:00
Zakhar Izmaylov 588c32453d Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-17 01:54:11 +03:00
Zakhar Izmaylov ce6186fa17 Update backlog 2025-01-17 01:49:24 +03:00
Zahar Izmailov a617b940c1 Merge branch 'Vladless:main' into main 2025-01-17 01:43:03 +03:00
hteppl c370d32154 Reorganise backups creation 2025-01-16 11:00:38 +03:00
hteppl 89f329458c Rename handlers functions with actual names + delete parse_mode 2025-01-16 09:20:33 +03:00
hteppl 4df9140cf8 Delete legacy admin_user_editor.py 2025-01-16 09:11:18 +03:00
hteppl 8e74c12bca Implement bot version in admin main menu 2025-01-16 09:08:35 +03:00
Vladislav Lisitsyn 4e280d1b3e Merge pull request #132 from JustYay/main
optimize subscriptions
2025-01-16 04:16:15 +03:00
Vladless c158c1339f backup_update 2025-01-16 04:15:17 +03:00
Vladless 9db8fdf79b expired_time 2025-01-16 04:03:53 +03:00
Vladless b6184d4c90 fix cluster_name in admin_panel 2025-01-16 03:03:37 +03:00
Vladless 981dc0db01 pytz for notifications 2025-01-16 01:50:34 +03:00
Oleg Kraev 0403ad5688 Merge pull request #6 from hteppl/main
Changes from upstream
2025-01-15 22:05:55 +03:00
hteppl f05b7416ef Admin update 2025-01-15 22:05:32 +03:00
Vladless 1d433d2cfd fix trial_key for SuperNode 2025-01-15 20:16:52 +03:00
Vladless 17a0fa0b21 SuperNode/SyncTimeout/pytz 2025-01-15 18:46:33 +03:00
hteppl 7154374fdc Update stats, fix some text input flows to working with delete middleware 2025-01-15 14:29:33 +03:00
hteppl 34b1927c22 Some missing logic and fixups 2025-01-15 14:07:49 +03:00
Boris Kovalskii 1e9367ab93 optimize subscriptions
Get and update subscriptions faster
2025-01-15 21:05:00 +10:00
hteppl a7d0cca66c Do not import bot directly in admin sender 2025-01-15 09:01:56 +03:00
hteppl e3bb7f6663 Remove direct key search, use common user searcher anytime 2025-01-15 09:01:10 +03:00
hteppl 450b8e17f0 Update delete middleware: pass all admin callbacks and states 2025-01-15 08:59:54 +03:00
hteppl f176a11ff9 Remove legacy admin_user_editor from release 2025-01-15 08:02:04 +03:00
hteppl aadd2edf45 Optimize and fix admin handlers logic 2025-01-15 08:01:16 +03:00
hteppl a02062a4b4 Optimize and fix keyboards 2025-01-15 08:00:41 +03:00
hteppl 0092764f8f Implement banned by users handler 2025-01-15 07:58:46 +03:00
hteppl 55b065f784 Admin keyboards adjustments 2025-01-15 03:37:01 +03:00
Zakhar Izmaylov eafe0a78d2 Add page coupons 2025-01-15 02:39:01 +03:00
Zakhar Izmaylov 9da808a024 Delete parse_mode 2025-01-15 02:15:21 +03:00
Vladless 3d295743e2 SuperNode/SyncTimeout/pytz 2025-01-14 23:35:19 +03:00
Vladless 6f8bc2b08d SuperNode/SyncTimeout/pytz 2025-01-14 15:53:27 +03:00
Vladless 32d0ac81bd SuperNode/SyncTimeout/pytz 2025-01-14 15:12:06 +03:00
Vladless 35f214a45f SuperNode/SyncTimeout/pytz 2025-01-14 12:16:14 +03:00
Vladless 8fdc78c2e8 FlowPayRenew/fixNotify/pay_history/custom/statistic/minorFixes 2025-01-14 04:06:55 +03:00
Vladless 6943794e50 add_stats 2025-01-13 03:58:58 +03:00
Vladless 086e3fe5b0 fix_profile 2025-01-13 02:03:09 +03:00
Vladislav Lisitsyn d247b764f7 Merge pull request #130 from izzzzzi/main
Backlog
2025-01-13 02:00:32 +03:00
Zakhar Izmaylov 283016f043 Delete Throttle 2025-01-12 18:19:49 +03:00
hteppl 7c19492c05 Fix some typo and quotes 2025-01-12 17:35:42 +03:00
hteppl 054d710d6c Register all the missing routers 2025-01-12 17:32:11 +03:00
hteppl f11b014acf Implement admin sender handler 2025-01-12 17:29:48 +03:00
hteppl ab27328cb7 Implement admin servers handler 2025-01-12 17:07:57 +03:00
hteppl 9b98faedfd Rename admin users editor handler to users handler 2025-01-12 17:01:39 +03:00
hteppl e957fbdfa1 Implement admin users editor handler 2025-01-12 16:58:17 +03:00
Oleg Kraev 94273b5ee2 Merge pull request #5 from hteppl/upstream1
Changes from upstream
2025-01-12 16:56:52 +03:00
Oleg Kraev 87d92b7dae Merge branch 'admin-redo' into upstream1 2025-01-12 16:56:41 +03:00
Zakhar Izmaylov 9a823eddf3 Add check_users_and_update_blocked 2025-01-11 20:29:01 +03:00
Zakhar Izmaylov 52d124c879 Add timeout 2025-01-11 18:43:14 +03:00
Zakhar Izmaylov 287d0a635b Update stats 2025-01-11 18:10:43 +03:00
Zakhar Izmaylov 1057fd6ef6 Add balance_history 2025-01-11 18:03:59 +03:00
Zakhar Izmaylov d935a9cd49 Update config lint 2025-01-11 17:51:24 +03:00
hteppl 97d598a293 Implement admin stats handler 2025-01-11 02:24:16 +03:00
hteppl c9664c136b Implement admin restart handler + also handle AdminPanelCallback for back buttons 2025-01-11 02:17:54 +03:00
hteppl 0b0fca8648 Add more base functions for creating singleton admin keyboards 2025-01-11 02:17:02 +03:00
Vladislav Lisitsyn 9577c8272f Merge pull request #129 from izzzzzi/main
Add stats
2025-01-11 02:01:40 +03:00
Zakhar Izmaylov 0d82294bdb Add antiflood and lint 2025-01-10 14:51:13 +03:00
Zakhar Izmaylov d40c845d62 Add stats 2025-01-10 14:38:28 +03:00
Vladless 70e3f5c714 syncClaster/picGifts/mailingCluster/renewButtin and more 2025-01-10 00:02:13 +03:00
hteppl 8a15d0320c Implement admin backups handler 2025-01-09 15:49:23 +03:00
hteppl 73bf682f97 Update admin coupons handler 2025-01-09 15:48:32 +03:00
hteppl ba53e9e199 Update admin main panel 2025-01-09 15:43:22 +03:00
hteppl e13937b965 Introducing keyboards package 2025-01-09 15:13:36 +03:00
Vladless 7758415ddf fiatCryptobot/flowPayments/Buttons/fixNotify/BanOfUsers/fixTrial and more 2025-01-09 06:00:50 +03:00
Vladless a19c325e15 fiatCryptobot/flowPayments/Buttons/fixNotify/BanOfUsers/fixTrial and more 2025-01-09 03:16:53 +03:00
Vladless 10df771a15 fiatCryptobot/flowPayments/Buttons/fixNotify/BanOfUsers/fixTrial and more 2025-01-09 02:40:39 +03:00
Vladislav Lisitsyn 6cd7e8acee Merge pull request #128 from izzzzzi/main
Fix process_message_to_all
2025-01-06 19:20:45 +03:00
Zakhar Izmaylov d8944a9c52 Update version 2025-01-06 15:29:24 +03:00
Zahar Izmailov 930ce0fcdc Merge branch 'Vladless:main' into main 2025-01-06 12:10:08 +03:00
Zakhar Izmaylov fceff14d0a Fix process_message_to_all 2025-01-06 12:09:15 +03:00
Vladislav Lisitsyn 16e31d4abb Merge pull request #127 from izzzzzi/main
Backlog
2025-01-06 02:57:04 +03:00
Zakhar Izmaylov 5289548fd9 Fix float get_usd_rate 2025-01-05 23:46:45 +03:00
Zakhar Izmaylov 681fd7d92c Add get_usd_rate 2025-01-05 23:43:04 +03:00
Zakhar Izmaylov 932ef51de4 Add update_all_keys 2025-01-05 23:32:18 +03:00
Zakhar Izmaylov 080a99cd55 Update update_subscription user 2025-01-05 23:24:01 +03:00
Zakhar Izmaylov 93ac01592f Update send_to_alls 2025-01-05 23:19:45 +03:00
Zakhar Izmaylov 1742519659 Add update user 2025-01-05 23:11:05 +03:00
Vladislav Lisitsyn 954c6b6ae7 Merge pull request #126 from hteppl/fix-trial
Фикс проверки тестовых подписок
2025-01-05 18:54:58 +03:00
hteppl 50811afa30 fix: typo in error message 2025-01-04 20:34:49 +03:00
hteppl afc913a2ff fix: check trial key already used 2025-01-04 15:30:34 +03:00
Vladislav Lisitsyn bab6a807f3 Merge pull request #125 from hteppl/fix-tv-preview
Удаление превью ссылок в сообщении текста для ТВ
2024-12-29 02:26:05 +03:00
hteppl 6329dfd3ef Fix CONNECT_TV_TEXT: add disable_web_page_preview=True 2024-12-29 02:05:47 +03:00
Vladless 6484bf2c6e TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:46:20 +03:00
Vladless 1e64817e3f TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:28:38 +03:00
Vladless 15fef698d9 TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:22:56 +03:00
Vladislav Lisitsyn c108ec4b2d Merge pull request #124 from hteppl/mics-updates
Небольшие изменения
2024-12-26 23:03:38 +03:00
hteppl 5d70576582 Fix key back button 2024-12-26 03:40:26 +03:00
hteppl 177cbb8881 Remove Telegram URL prefix when finding a user by username 2024-12-26 03:36:54 +03:00
hteppl 7d8050ce1c Monthly payments in admin stats 2024-12-26 03:36:28 +03:00
Vladislav Lisitsyn fadafecb43 Merge pull request #123 from hteppl/license
Лицензия и обновленный README
2024-12-24 17:40:48 +03:00
Vladless ab6af88114 gift_links 2024-12-24 15:02:48 +03:00
Vladless 3b7bb5436b buttons_pay/security_update/MacOS 2024-12-24 02:30:53 +03:00
Vladless bfe8ed8b3c buttons_pay/security_update 2024-12-24 02:30:09 +03:00
hteppl 95b338a1c2 Update README.md 2024-12-23 16:01:16 +03:00
hteppl 072a90a028 Add CC BY-NC 4.0 LICENSE 2024-12-23 16:00:48 +03:00
hteppl 933c8100e4 Remove legacy installation scripts 2024-12-23 16:00:26 +03:00
Vladless 4951a5cf78 minor update 2024-12-23 01:23:34 +03:00
Vladless 5e904f3045 gift_code/buttons/commands 2024-12-23 01:14:28 +03:00
Vladless 0840a07ed9 add support for business/optimization 2024-12-20 23:29:27 +03:00
Vladless 2becee7f02 fix_refferal/GIFTS/redirect/name_sub and more 2024-12-19 20:39:07 +03:00
Vladless ae22950ce3 fix_refferal/GIFTS/redirect/name_sub and more 2024-12-19 20:37:41 +03:00
Vladislav Lisitsyn 169b276ef1 Merge pull request #122 from hteppl/fix-referrals
WIP: фикс уявзимости, где айди приглашенного реферала, совпадает с айди пригласившего
2024-12-16 23:41:29 +03:00
hteppl 5966555ae9 Fix self-referred bug 2024-12-16 23:19:39 +03:00
Vladislav Lisitsyn de27fa24d4 Merge pull request #120 from crell2100/main
add CHANEL_EXISTS variable
2024-12-16 23:09:42 +03:00
crell2100 0b0b002c82 donats_enable->donations_enable (rename variable) 2024-12-16 16:40:01 +03:00
crell2100 0e4f59a242 Merge branch 'main' into main 2024-12-15 22:51:32 +03:00
crell2100 051714e613 add DONATS_ENABLE variable 2024-12-15 22:30:20 +03:00
crell2100 c780b1ffd7 add CHANEL_EXISTS variable 2024-12-15 18:07:52 +03:00
Vladless 88caa0bab3 remove gifts 2024-12-15 00:23:08 +03:00
Vladless e6b071f64d add import 2024-12-15 00:23:08 +03:00
Vladless 9a809aa646 add import 2024-12-15 00:23:08 +03:00
Vladislav Lisitsyn 52bdf10e0e Update README.md 2024-12-14 21:24:42 +03:00
Vladislav Lisitsyn 1642bde410 Merge pull request #119 from TrackLine/main
Add Yoomoney pay service
2024-12-14 19:07:25 +03:00
TrackLine 2fdb29d517 Add Yoomoney pay service 2024-12-14 19:49:54 +04:00
Vladless ad8d28f7f0 sub_name 2024-12-14 18:46:43 +03:00
Vladless a4776d7682 fix middleware 2024-12-14 17:39:39 +03:00
Vladislav Lisitsyn 3c7451e8fc Merge pull request #118 from izzzzzi/main
Migrate to ruff
2024-12-14 02:02:09 +03:00
Vladislav Lisitsyn f65ed3eada Merge branch 'main' into main 2024-12-14 02:02:00 +03:00
Vladless b4c47faac9 vat_code 2024-12-14 00:37:20 +03:00
Vladless 217bb457a1 sub_name/delete user 2024-12-13 23:01:58 +03:00
Zakhar Izmaylov ce79caa58d Fix requirements.txt 2024-12-13 12:13:50 +03:00
Zakhar Izmaylov 76d72ba203 Migrate to ruff 2024-12-13 12:09:20 +03:00
Vladless 035ef47619 rm wrench 2024-12-11 15:41:45 +03:00
Vladislav Lisitsyn 4792abc6d5 Merge pull request #113 from Vladless/hteppl-optimize-sources
Hteppl optimize sources
2024-12-10 04:14:24 +02:00
Vladless 42ac740589 pic for tariffs 2024-12-10 05:13:54 +03:00
Vladless d5371e4c04 pic for tariffs 2024-12-10 05:12:49 +03:00
Vladless 7c883b07f9 Revert "Merge pull request #112 from Vladless/hteppl-optimize-sources"
This reverts commit fa681fac92, reversing
changes made to b31c9d2e10.
2024-12-10 03:53:07 +03:00
Vladislav Lisitsyn fa681fac92 Merge pull request #112 from Vladless/hteppl-optimize-sources
Hteppl optimize sources
2024-12-10 02:27:34 +02:00
Vladless d1a9691c87 Merge branch 'optimize-sources' of https://github.com/hteppl/Solo_bot into hteppl-optimize-sources 2024-12-10 03:26:42 +03:00
Vladislav Lisitsyn b31c9d2e10 Merge pull request #108 from hteppl/optimize-routing-system-v2
Оптимизация системы рутинга 2 // LP
2024-12-10 00:54:32 +02:00
Vladislav Lisitsyn 11176a16bc Merge pull request #104 from hteppl/create-sql-schema
Оптимизация создания базы данных // LP
2024-12-10 00:54:22 +02:00
hteppl 39aaa88532 Make utils as package + fix python unresolved imports 2024-12-08 05:32:23 +03:00
hteppl cf6fcdbb76 Move payments utils into utils folder 2024-12-08 05:13:38 +03:00
hteppl 4e09e04dd9 Move keys utils into utils folder 2024-12-08 05:11:31 +03:00
hteppl e0639c7116 Move utils.py to the utils folder 2024-12-08 05:02:41 +03:00
hteppl 1d915f849c Move util-related files into separated folder + refactor imports 2024-12-08 04:57:20 +03:00
Oleg Kraev 404d502d2f Merge branch 'Vladless:main' into optimize-routing-system-v2 2024-12-08 04:37:31 +03:00
hteppl bcfb4c7942 Implement global router 2024-12-08 04:36:59 +03:00
hteppl bd5cc56441 Include routers only if payment method enabled in the config 2024-12-08 04:23:59 +03:00
hteppl bc42b0e52b Create and implement router for admin 2024-12-08 04:21:37 +03:00
hteppl 71e549866b Create and implement router for instructions 2024-12-08 04:20:09 +03:00
hteppl ca9dd17450 Create and implement router for keys 2024-12-08 04:19:16 +03:00
hteppl 91f12fe627 Create and implement router for payments 2024-12-08 04:17:50 +03:00
hteppl 1406e87ab3 Create basic main router for handlers 2024-12-08 04:15:52 +03:00
Vladislav Lisitsyn 9398199f25 Merge pull request #107 from hteppl/linux-start-scripts
Linux cкрипты для автоматической настройки зависимостей и запуска
2024-12-07 15:32:56 +02:00
Vladless 6ed83097de add pay button 2024-12-07 16:32:16 +03:00
Vladislav Lisitsyn 4d38ab0d3a Update README.md 2024-12-07 00:27:53 +03:00
Vladislav Lisitsyn d955a6ccfc Update README.md 2024-12-07 00:17:48 +03:00
hteppl 82da0cbf53 Add linux shell scripts to configure dependencies automatically 2024-12-06 18:39:35 +03:00
Vladless 360d73dd26 ping servers and notifications 2024-12-06 05:19:44 +03:00
Vladless 14400018c9 ping servers and notifications 2024-12-06 04:48:58 +03:00
Vladless f845baa3d0 back button in custom pay 2024-12-06 02:25:40 +03:00
Vladless 54e6c2aaaa back button in custom pay 2024-12-06 02:25:18 +03:00
Vladless 240ad42beb preview pc_instruction disable 2024-12-06 01:42:14 +03:00
Vladless a8d8972855 Server availability 2024-12-06 01:14:25 +03:00
Vladless b70b432d05 logs folder and autoremove 2024-12-06 00:46:54 +03:00
Vladless 77d6b8c67c sales in texts 2024-12-05 23:56:06 +03:00
Vladless 9251087d2c backup for 3x-ui 2024-12-05 23:31:48 +03:00
Vladless 5f2f3ad683 fix refferal 2024-12-05 22:59:34 +03:00
Vladless 0f507aa101 fix refferal 2024-12-05 22:45:10 +03:00
Vladless 99574ce751 backup time 2024-12-05 21:09:37 +03:00
hteppl 441e7ef696 Create schema.sql database template + optimize init_db 2024-12-05 07:59:49 +03:00
Vladless 8395c29ae2 fix trial 2024-12-04 17:25:32 +03:00
Vladless ff37c48579 change version 2024-12-04 03:10:15 +03:00
Vladless 8e778eb7af unique server_name/ link moderation 2024-12-04 03:02:29 +03:00
Vladless 2f6b85d4a6 unique server_name 2024-12-04 02:46:57 +03:00
Vladless bb034ba6c6 plan on create new key after trial 2024-12-04 01:28:28 +03:00
Vladless d86acbfb9d admin_update 2024-12-04 00:16:23 +03:00
Vladless baf0e2d695 add button for MacOS 2024-12-04 00:06:21 +03:00
Vladless b58079d86f Clusters on/off in config 2024-12-02 04:58:14 +03:00
Vladless ee2ae3f14d profile/reorganization and news 2024-12-02 04:46:05 +03:00
Vladless 5420c38592 sale size 2024-12-02 04:20:32 +03:00
Vladless 2a3d2098c9 rename server info 2024-12-02 04:08:36 +03:00
Vladless 36a44f3669 move button 2024-12-02 04:06:46 +03:00
Vladless ccbe82a140 pc button for trial_key 2024-12-02 03:54:07 +03:00
Vladless 8b6afcf571 pc button for trial_key 2024-12-02 03:37:28 +03:00
Vladless 74353143cf add/delete servers in admin 2024-12-02 03:25:06 +03:00
Vladless 7f48f5b7b5 servers in admin_editor 2024-12-02 00:28:33 +03:00
Vladless 5bb83fe904 servers in database 2024-12-01 23:40:37 +03:00
Vladless bfdf5f9f41 servers in database 2024-12-01 23:24:53 +03:00
Vladless d7d0e3da42 autoconfig servers in database 2024-12-01 23:24:13 +03:00
Vladless 11c84eae82 notification inactive users 2024-12-01 22:24:00 +03:00
Vladless cd7db1330e sudo in restart 2024-11-29 00:01:48 +03:00
Vladless 24c1f8b9cb autoname for trial in profile 2024-11-28 20:48:34 +03:00
Vladless 8c62573664 /start 2024-11-28 15:37:21 +03:00
Vladless fc1dda5597 link Successful payment 2024-11-27 21:18:07 +03:00
Vladless 9e899f4ec6 fix stars 2024-11-27 17:30:53 +03:00
Vladless bb2fea1396 update subs 2024-11-26 20:10:14 +03:00
Vladless 9a05ff82e4 fix admin 2024-11-26 20:03:21 +03:00
Vladless 21e82eed79 notifications 2024-11-26 17:48:41 +03:00
Vladless ca1a279144 fix renew 2024-11-26 17:30:41 +03:00
Vladless 4e6eb4ea7f random email for add device 2024-11-26 17:21:15 +03:00
Vladislav Lisitsyn e3787a041c Merge pull request #102 from izzzzzi/main
Refactor
2024-11-26 03:25:33 +03:00
Zakhar Izmaylov cd411c6b49 Fix start 2024-11-26 03:21:43 +03:00
Zakhar Izmaylov 9acc9d8304 Small fix 2024-11-26 02:54:32 +03:00
Zakhar Izmaylov cb19c3829f Enhanced notifications and admin: CSV export for users/payments, better feedback/navigation, DEV_MODE checks for notifications, .csv in .gitignore, fixed trial expiry with TRIAL_TIME. 2024-11-26 02:52:15 +03:00
Zahar Izmailov b42cc6b484 Merge branch 'Vladless:main' into main 2024-11-26 00:27:58 +03:00
Vladless 4cf1f5dad8 back button 2024-11-25 01:16:06 +03:00
Vladless d863eb1ed9 fix session 2024-11-25 00:58:46 +03:00
Zakhar Izmaylov cbd0902e42 Refactor notification and admin user editor handlers to improve user feedback. Comment out inactive trial user notifications in notifications.py. Enhance error messages with consistent "Назад" button for better navigation in admin_user_editor.py. 2024-11-25 00:29:06 +03:00
Zakhar Izmaylov fa2b45104c Update button texts in start and admin user editor handlers for improved navigation. Change "Личный кабинет" to "Поддержка" in start.py and adjust "Назад" callback data in admin_user_editor.py for consistency. 2024-11-25 00:16:12 +03:00
Zakhar Izmaylov bac2dbaf85 Refactor import statements in notification, profile, key management, and keys handlers for improved organization and consistency. Ensure RENEWAL_PLANS is included in all relevant files. 2024-11-24 23:45:38 +03:00
Zakhar Izmaylov f9e29f4fa9 Refactor notification and profile handlers to include RENEWAL_PLANS from config. Update key management and keys handlers to streamline imports and enhance code organization. 2024-11-24 23:45:27 +03:00
Zahar Izmailov 82985aa96e Merge branch 'Vladless:main' into main 2024-11-24 23:37:58 +03:00
Vladless d34f49c9b2 3.2 2024-11-24 23:10:22 +03:00
Vladless bbe355008a reset traffic 2024-11-24 23:02:34 +03:00
Zakhar Izmaylov ae115ba13b Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 22:37:23 +03:00
Zakhar Izmaylov dc7da4441a Remove unused __init__.py file and update payment handler callback buttons to unify navigation. Change "back_to_profile" callback to "pay" in multiple payment processing files for consistency. 2024-11-24 22:35:43 +03:00
Vladless d91465c435 ID_Inbound 2024-11-24 22:29:32 +03:00
Vladless 9cb9b9441a ID_Inbound 2024-11-24 22:29:05 +03:00
Vladislav Lisitsyn ac3a4a397f Merge pull request #100 from izzzzzi/main
Refactoring and Update
2024-11-24 21:54:53 +03:00
Vladislav Lisitsyn 995964f70a Merge branch 'main' into main 2024-11-24 21:54:30 +03:00
Zakhar Izmaylov 708b0be31f Refactor database notification query to remove unnecessary string conversion for hours parameter, improving data handling efficiency. 2024-11-24 20:48:16 +03:00
Zakhar Izmaylov 052124d483 Refactor database and notification handlers for improved readability and consistency. Clean up import statements, enhance logging messages, and streamline function calls across multiple files. Ensure proper session handling in payment processing functions. 2024-11-24 20:29:22 +03:00
Vladless b40bf3f833 fix back cryptobot 2024-11-24 20:19:23 +03:00
Zakhar Izmaylov 11d7005954 Enhance notification system for inactive trial users by adding notification time checks and logging. Implement functionality to record notifications sent to users, ensuring they are notified no more than once every 24 hours. 2024-11-24 20:02:40 +03:00
Zakhar Izmaylov 7893be6ef8 Refactor referral system to support multi-level bonuses and enhance logging. Update database interactions for referral handling and statistics retrieval. Change key display format in notifications and admin user editor for better readability. 2024-11-24 19:38:59 +03:00
Zakhar Izmaylov 18ca2b275c Update table 2024-11-24 14:14:06 +03:00
Zakhar Izmaylov bc333b12c2 Fix delete msg 2024-11-24 13:51:18 +03:00
Zakhar Izmaylov c3e052f73c notify_inactive_trial_users 2024-11-24 11:58:43 +03:00
Zakhar Izmaylov 29f606736b Fix admin 2024-11-24 11:51:17 +03:00
Zakhar Izmaylov ce17181be5 Update connection_exists 2024-11-24 11:42:29 +03:00
Zakhar Izmaylov 041c8e1511 Fix srssion 2024-11-24 11:26:54 +03:00
Zakhar Izmaylov 270f01511d Update id 2024-11-24 11:07:52 +03:00
Zakhar Izmaylov a352901d85 Fix 2024-11-24 10:54:08 +03:00
Zakhar Izmaylov 8e5ce5768a Fix get_trial 2024-11-24 10:35:12 +03:00
Zakhar Izmaylov 97c03d0396 Add logging 2024-11-24 09:15:09 +03:00
Zakhar Izmaylov 1b9b3e6729 Fix trial_status 2024-11-24 08:27:27 +03:00
Zakhar Izmaylov 3f336919ad Fix 2024-11-24 08:13:52 +03:00
Zakhar Izmaylov 8f8e89667d Update InlineKeyboardBuilder 2024-11-24 01:57:42 +03:00
Zakhar Izmaylov 88b05547de Fix 2024-11-24 01:47:37 +03:00
Zakhar Izmaylov c35d067306 Add notify_inactive_trial_users 2024-11-24 01:34:39 +03:00
Zakhar Izmaylov 06489967f8 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 01:24:10 +03:00
Zakhar Izmaylov 47e45cf596 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 01:19:06 +03:00
Zakhar Izmaylov bc77c6a5ff Fix typing 2024-11-24 01:19:00 +03:00
Zakhar Izmaylov 31eb551b6d Refactoring 2024-11-24 01:18:42 +03:00
Vladless 122b82253e update clusters 2024-11-23 20:21:47 +03:00
Vladless 5c79302cd6 fix users 2024-11-23 17:30:39 +03:00
Vladislav Lisitsyn 34a99ceb98 Merge pull request #98 from izzzzzi/main
fix ref/serach from username and new stats in admin
2024-11-23 08:18:10 +03:00
Zakhar Izmaylov 576a315baa Fix admin 2024-11-23 07:14:58 +03:00
Zakhar Izmaylov 73965b64dd Add pylint 2024-11-22 21:34:56 +03:00
Zakhar Izmaylov 9673f10af7 Update config 2024-11-22 21:24:55 +03:00
Zakhar Izmaylov 8a9825f629 Fix ref 2024-11-22 17:45:38 +03:00
Zakhar Izmaylov fc830a427c Update text admin 2024-11-22 01:14:23 +03:00
Zakhar Izmaylov 26e8e91cb7 Update push 2024-11-22 01:12:19 +03:00
Zakhar Izmaylov 4cae37ef33 Add search from username 2024-11-22 00:43:23 +03:00
Zakhar Izmaylov a04a6bd5d2 Update user_stats_menu 2024-11-21 21:14:44 +03:00
Zakhar Izmaylov ac632a5e96 Update instructions 2024-11-21 20:49:58 +03:00
Zakhar Izmaylov 6701222697 Update text and add view_tariffs 2024-11-21 20:31:24 +03:00
Zakhar Izmaylov f42fe4236e Fix send_to_alls 2024-11-20 17:30:10 +03:00
Zakhar Izmaylov 3041e0a208 Update admin 2024-11-20 17:18:28 +03:00
Zakhar Izmaylov 61bf3cd129 Add new table and add stats in admin 2024-11-20 16:47:21 +03:00
Zakhar Izmaylov fb34af114f Update logger 2024-11-20 15:46:46 +03:00
Zakhar Izmaylov 56678abbb4 Fix img and logger add button back for donate 2024-11-20 15:36:25 +03:00
Zahar Izmailov 347805a8ae Merge branch 'Vladless:main' into main 2024-11-20 15:28:44 +03:00
Zakhar Izmaylov 2438ac58e1 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-20 09:43:22 +03:00
Zakhar Izmaylov 23b60389c0 Fix 2024-11-20 09:43:16 +03:00
Vladless a58e45133a fix bugs 2024-11-20 00:18:29 +03:00
Vladless 6fc26754bf clusters update 2024-11-19 19:31:31 +03:00
Vladless 8bfdc6e319 Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-19 14:58:25 +03:00
Vladless 8d4e32c86c security update 1.0 2024-11-19 14:58:07 +03:00
Vladislav Lisitsyn f16226e48e Update start.py 2024-11-19 11:52:06 +03:00
Vladless 05bef3d389 security update 1.0 2024-11-19 11:43:22 +03:00
Vladless d811ba5623 security update 1.0 2024-11-19 11:21:34 +03:00
Vladislav Lisitsyn abd6c03ba0 Merge pull request #97 from izzzzzi/main
Fix key and session
2024-11-19 09:03:20 +03:00
Zakhar Izmaylov 84a7c1c930 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-19 08:32:33 +03:00
Zakhar Izmaylov 96b8ad8555 Fix key and session 2024-11-19 08:32:30 +03:00
Vladislav Lisitsyn e4f9301591 Merge pull request #96 from izzzzzi/main
Add back button for coupons
2024-11-18 22:51:16 +03:00
Vladless 1eb60dc0c2 renew/delete 2024-11-18 22:40:59 +03:00
Zakhar Izmaylov cbcae665e4 Fix admin 2024-11-18 22:19:17 +03:00
Zakhar Izmaylov 4da82282de Add back button for coupons 2024-11-18 22:03:51 +03:00
Vladless 1548907ead add time in robokassa 2024-11-18 20:03:45 +03:00
Vladless 854c6fa536 fix update_sub 2024-11-18 16:04:29 +03:00
Vladless 09ca65de0c Fixed unexpected behavior of the device selection button 2024-11-18 14:57:12 +03:00
Vladless 52613d9feb Fixed unexpected behavior of the device selection button 2024-11-18 14:56:41 +03:00
Vladislav Lisitsyn 4878cc2eab Merge pull request #95 from izzzzzi/main
Add admin middleware
2024-11-18 12:54:05 +03:00
Zakhar Izmaylov 7e2f56f20b Update Middleware 2024-11-17 23:42:09 +03:00
Zakhar Izmaylov 6b2914bff4 Fix 2024-11-17 16:24:12 +03:00
Zakhar Izmaylov 10b45ec226 Add button 2024-11-17 16:19:47 +03:00
Zakhar Izmaylov 23ec7e3ccb Formatting 2024-11-17 16:15:51 +03:00
Zakhar Izmaylov 3aaa8d1ea3 Fix Middleware 2024-11-17 16:14:35 +03:00
Zakhar Izmaylov a46e8d3a5a Fix admin 2024-11-17 16:10:58 +03:00
Zakhar Izmaylov 6f9ceb1a89 Fix admin 2024-11-17 16:05:20 +03:00
Zakhar Izmaylov d5dbb39444 Add default admin 2024-11-17 16:01:18 +03:00
Zakhar Izmaylov 0f0af0c194 Fix start 2024-11-17 15:53:31 +03:00
Zakhar Izmaylov 545345a020 Fix commands 2024-11-17 15:47:31 +03:00
Zakhar Izmaylov 0766cda29e Fix start 2024-11-17 15:44:41 +03:00
Zakhar Izmaylov 6563b7dc67 Fix 2024-11-17 15:39:53 +03:00
Zakhar Izmaylov 5b8b950aaf Fix admin middleware 2024-11-17 15:39:27 +03:00
Zakhar Izmaylov 8020fd9a7d Fix send backup 2024-11-17 15:29:56 +03:00
Zakhar Izmaylov 8836dc577e Fix admin and create coupons 2024-11-17 15:26:03 +03:00
Zakhar Izmaylov a2be86b134 Fix admin 2024-11-17 15:10:28 +03:00
Zakhar Izmaylov e9bc47680a Update img 2024-11-17 14:17:51 +03:00
Zakhar Izmaylov da62ab5a90 Fix img 2024-11-17 14:15:27 +03:00
Zakhar Izmaylov dd84c8dd3a Fix 2024-11-17 14:09:05 +03:00
Zakhar Izmaylov 7e40a1f96c Update delete models 2024-11-17 14:08:51 +03:00
Zakhar Izmaylov 69f880af2b Add new table and middleware 2024-11-17 14:05:31 +03:00
Zakhar Izmaylov 7c93c8cc31 Refactoring and fix 2024-11-17 13:33:17 +03:00
Zakhar Izmaylov c2d06e10b8 Add admin middleware 2024-11-17 13:11:49 +03:00
Zahar Izmailov 9c8fafabb1 Merge branch 'Vladless:main' into main 2024-11-17 10:42:43 +03:00
Vladless c63708cb83 version 2024-11-17 03:20:06 +03:00
Vladless 8f9b84277b add version 2024-11-17 01:32:52 +03:00
Vladless 8363d319c2 robokassa release 2024-11-17 00:01:26 +03:00
Zakhar Izmaylov a353d41738 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-16 23:56:54 +03:00
Zakhar Izmaylov 0cfd656d86 #TODO Admin and bash 2024-11-16 23:56:45 +03:00
Vladislav Lisitsyn 5001fc94fe Merge pull request #94 from izzzzzi/main
Add scripts
2024-11-16 22:54:50 +03:00
Zahar Izmailov a014596d51 Merge branch 'Vladless:main' into main 2024-11-16 21:06:04 +03:00
Zakhar Izmaylov 64b4cfd1b2 Delete nginx config 2024-11-16 21:04:06 +03:00
Zakhar Izmaylov dcc7d5e218 Update 2024-11-16 21:03:39 +03:00
Zakhar Izmaylov ca8c5691d3 Add bash for node 2024-11-16 19:05:09 +03:00
Vladislav Lisitsyn b257d8c66d Merge pull request #93 from izzzzzi/main
Add dry logging
2024-11-16 18:46:12 +03:00
Zakhar Izmaylov 601598dfd2 Add dry logging 2024-11-16 18:01:28 +03:00
Vladless aef9c344ab fix delete 2024-11-16 00:44:12 +03:00
Vladless f6f1227dd8 ip 2024-11-15 23:59:42 +03:00
Vladless 6d750c91f8 fix back_button 2024-11-15 23:27:08 +03:00
Vladless 533d4fa64c Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-15 23:15:41 +03:00
Vladless 0be6ed0631 fix logging 2024-11-15 23:15:19 +03:00
Vladislav Lisitsyn 346dfaee2e Update start.py 2024-11-15 22:06:23 +03:00
Vladless e2796d22e2 pc instructions and button 2024-11-15 21:49:27 +03:00
Vladless b96c42c153 fix cluster 2024-11-15 20:30:57 +03:00
Vladless 83b5a8608a changing notifications 2024-11-15 20:15:19 +03:00
Vladless 1abe1c7166 logging for add client 2024-11-15 19:24:09 +03:00
Vladless c35bd82ebe formatting 2024-11-15 19:19:13 +03:00
Vladless b6ed49ad82 rm DEBUG 2024-11-15 19:16:37 +03:00
Vladless e70a2d74ba total_gb 2024-11-15 19:16:06 +03:00
Vladislav Lisitsyn d3f00acd5c Merge pull request #92 from izzzzzi/main
Fix pay stars
2024-11-15 18:03:04 +03:00
Zakhar Izmaylov 00b0823eb3 Fix router 2024-11-15 17:28:54 +03:00
Zakhar Izmaylov ab488ed0a3 Fix pay stars 2024-11-15 17:28:06 +03:00
Zakhar Izmaylov 12046b9c5c Fix pay stars 2024-11-15 17:25:42 +03:00
Vladless a457598109 fix donate 2024-11-15 15:48:52 +03:00
Vladless 1f2f6ee82a fix donate 2024-11-15 15:40:20 +03:00
Vladislav Lisitsyn 8d41b19767 Merge pull request #91 from izzzzzi/main
Add donate
2024-11-15 15:09:46 +03:00
Vladless cb59a1d176 Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-15 15:09:01 +03:00
Vladless 2961477ecf robokassa_beta 2024-11-15 15:07:51 +03:00
Zakhar Izmaylov 5db30c05f7 Add donate 2024-11-15 14:38:14 +03:00
Vladislav Lisitsyn 90aa095621 Merge pull request #90 from izzzzzi/main
Added support for the list of admins
2024-11-15 13:51:33 +03:00
Vladless c7d5ff94c0 fix message delete 2024-11-15 13:51:12 +03:00
Zahar Izmailov 136832b22a Merge branch 'Vladless:main' into main 2024-11-15 13:37:57 +03:00
Zakhar Izmaylov 79c723ec06 Added support for the list of admins 2024-11-15 13:37:24 +03:00
Vladislav Lisitsyn 43883a3c06 Merge pull request #89 from izzzzzi/main
Fix stars
2024-11-15 13:30:46 +03:00
Zakhar Izmaylov 0eba072826 Fix stars 2024-11-15 13:25:57 +03:00
Vladislav Lisitsyn 0604675be6 Merge pull request #88 from izzzzzi/main
Add dev mode and fix menu and fix stars and cryptobot
2024-11-15 12:53:05 +03:00
Zakhar Izmaylov 75147eb5a1 Add multy pay 2024-11-15 11:58:48 +03:00
Zakhar Izmaylov 5dd1fab7e9 Add dev mode and fix menu and fix stars and cryptobot 2024-11-15 11:40:25 +03:00
Vladless 31fa90aef1 coupons initial 2024-11-15 03:03:24 +03:00
Vladless 4f0df51abd fix payments 2024-11-14 15:13:52 +03:00
Vladless 584fec2dc8 servers in clusters/total_gb 2024-11-14 14:17:16 +03:00
Vladless 5744d03ad9 servers in clusters/total_gb 2024-11-14 03:04:23 +03:00
Vladless 4aa521c6b9 servers in clusters/total_gb 2024-11-14 03:03:32 +03:00
Vladless 545ceac0b8 custom trial time 2024-11-13 18:51:25 +03:00
Vladislav Lisitsyn bd699b5700 Merge pull request #87 from izzzzzi/main
Update keyboards and restructuring for filters
2024-11-13 18:04:38 +03:00
Vladless cb7684b015 custom bonus 2024-11-13 17:27:08 +03:00
Zakhar Izmaylov 567c04d74e Add models.py #TODO 2024-11-13 17:10:25 +03:00
Zakhar Izmaylov ac3451a209 Add DatabaseMiddleware #TODO 2024-11-13 17:03:18 +03:00
Zakhar Izmaylov 8f4403accd Restructuring for filters 2024-11-13 16:53:23 +03:00
Zakhar Izmaylov 8b19cf0956 Update keyboards, update texts.py 2024-11-13 16:48:56 +03:00
Vladless 5db5c8377b add TOTAL_GB 2024-11-13 16:18:16 +03:00
Vladless 59ca46908e small fix 2024-11-13 15:38:20 +03:00
Vladless efd56e513d small fix 2024-11-13 05:41:49 +03:00
Vladless 6857e2b9c8 redesign/bug fixes/admin trial 2024-11-13 04:05:43 +03:00
Vladless 72438fea7e redesign/bug fixes/admin trial 2024-11-13 04:04:40 +03:00
Vladless 7a4aa5099d redesign/bug fixes/admin trial 2024-11-13 04:01:13 +03:00
Vladless 13efb0cc45 redesign/bug fixes/admin trial 2024-11-13 03:59:34 +03:00
Vladislav Lisitsyn 0c65d378cd Merge pull request #86 from izzzzzi/main
Refactoring admin
2024-11-12 13:11:09 +03:00
Zakhar Izmaylov e73b8e6538 Refactoring user editor 2024-11-12 08:10:40 +03:00
Zakhar Izmaylov 5bbc248a4b Refactoring 2024-11-12 08:10:28 +03:00
Zakhar Izmaylov b37c724aa0 Refactoring admin 2024-11-12 07:51:54 +03:00
Vladislav Lisitsyn b89b0fe1ce Merge pull request #85 from izzzzzi/main
Fix formating and freekassa
2024-11-12 00:17:33 +03:00
Zakhar Izmaylov 577592479b Fix formating and freekassa 2024-11-12 00:11:33 +03:00
Vladless 20031eb843 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot into izzzzzi-main 2024-11-11 23:35:49 +03:00
Zakhar Izmaylov 35939d89b8 Fix texts 2024-11-11 23:35:01 +03:00
Zakhar Izmaylov 2ae03cb6b0 Small refactoring admin 2024-11-11 23:17:09 +03:00
Zakhar Izmaylov 47be269ddf Fix bot.py 2024-11-11 23:11:14 +03:00
Zakhar Izmaylov ee30f5ab95 Fix, Update menu 2024-11-11 23:07:54 +03:00
Vladislav Lisitsyn e473988750 Merge pull request #83 from izzzzzi/main
Add multypay
2024-11-11 23:03:26 +03:00
Zakhar Izmaylov 2a768568a9 Remove btn 2024-11-11 22:49:45 +03:00
Zakhar Izmaylov 1b45580390 Add multypay 2024-11-11 22:45:11 +03:00
Vladislav Lisitsyn 37f8cf9881 Merge pull request #82 from izzzzzi/main
Add stars pay(beta)
2024-11-11 22:38:17 +03:00
Vladless 6c84c873cf fix freekassa 2024-11-11 22:37:41 +03:00
Zakhar Izmaylov a2a3d85cf5 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-11 22:33:03 +03:00
Zakhar Izmaylov 59db7344a0 Fix cryptobot 2024-11-11 22:31:52 +03:00
Zahar Izmailov 8f2d027a83 Merge branch 'Vladless:main' into main 2024-11-11 22:28:00 +03:00
Zakhar Izmaylov 0a37596471 Add stars pay(beta) 2024-11-11 22:26:10 +03:00
Vladislav Lisitsyn 2f0208a905 Merge pull request #81 from izzzzzi/main
Add crypto(beta), fix import, fix requirements.txt
2024-11-11 22:25:45 +03:00
Zakhar Izmaylov af8ecffb6d Add crypto(beta), fix import, fix requirements.txt 2024-11-11 21:51:34 +03:00
Vladislav Lisitsyn 08d38e651b Update README.md 2024-11-11 17:51:44 +03:00
Vladislav Lisitsyn 0db832473a Update README.md 2024-11-11 17:51:12 +03:00
Vladislav Lisitsyn e7f682f02d Merge pull request #80 from izzzzzi/main
Add base cryptobot
2024-11-11 17:48:31 +03:00
Vladless e002035518 minPython 3.10/py3xui/subs/rm_artefacts 2024-11-11 17:47:26 +03:00
Zakhar Izmaylov c16f2e8c72 Add base cryptobot 2024-11-11 14:26:00 +03:00
Vladless d588726838 isort settings 2024-11-11 09:22:14 +03:00
Vladless b5c8f4fa28 bug fix 2024-11-11 07:42:22 +03:00
Vladislav Lisitsyn dab1a8d754 Merge pull request #79 from izzzzzi/main
Add make and config for formatting
2024-11-10 23:12:42 +03:00
Zakhar Izmaylov 27f5b9d43a Add make and config for formatting 2024-11-10 11:02:15 +03:00
Vladless 4bf5b3a87d reset traffic 2024-11-10 03:31:19 +03:00
Vladless adaae41826 bug and fix 2024-11-10 02:25:36 +03:00
Vladislav Lisitsyn aaa754f66f Merge pull request #78 from izzzzzi/main
Refactoring and fix
2024-11-09 23:59:29 +03:00
Zakhar Izmaylov a294f123d2 Fix 2024-11-09 23:20:08 +03:00
Zakhar Izmaylov 617e599e74 Small fix 2024-11-09 16:23:39 +03:00
Zakhar Izmaylov 426e2a881a Refactoring admin_panel.py 2024-11-09 15:54:16 +03:00
Zakhar Izmaylov 96c9792dce Refactoring notifications.py and commands.py 2024-11-09 15:49:05 +03:00
Zakhar Izmaylov 0864a8a1ce Refactoring profile.py 2024-11-09 15:35:22 +03:00
Zakhar Izmaylov 5963cdc854 Refactoring start.py 2024-11-09 14:38:30 +03:00
Zakhar Izmaylov 04c7616661 Add middleware logging 2024-11-09 12:20:07 +03:00
Zakhar Izmaylov ca68eb58d3 Add middlewaer datatabase # TODO 2024-11-09 12:17:07 +03:00
Zakhar Izmaylov b8b6f29c48 Delete wraps 2024-11-09 12:01:24 +03:00
Zakhar Izmaylov 1d9aa3b432 isort and black 2024-11-09 11:44:15 +03:00
Zakhar Izmaylov b12bb33242 Migrate logging for loguru 2024-11-09 11:41:48 +03:00
Zakhar Izmaylov 1f7d0a263f Fix logging 2024-11-09 11:33:17 +03:00
Zakhar Izmaylov 34e213cc78 Fix callback_query 2024-11-09 11:17:11 +03:00
Zakhar Izmaylov 6651034413 Fix PAYMENT_OPTIONS and RENEWAL_PLANS 2024-11-09 10:52:41 +03:00
Zakhar Izmaylov 800a86c500 Refactoring and fix 2024-11-09 10:36:16 +03:00
Vladislav Lisitsyn 1806a3db4b Update README.md 2024-11-08 07:13:42 +03:00
Vladislav Lisitsyn 6a1f3f810e Update README.md 2024-11-08 07:08:20 +03:00
Vladislav Lisitsyn cf923e1e9a Merge pull request #77 from Vladless/subscriptions-feature
preview v2.
2024-11-08 07:07:00 +03:00
Vladless d122b59c6d preview v2. 2024-11-08 07:06:32 +03:00
Vladislav Lisitsyn 2bd4d802a3 Merge pull request #76 from Vladless/subscriptions-feature
preview v3.
2024-11-08 07:05:30 +03:00
Vladless 012ed787e8 preview v3. 2024-11-08 07:04:51 +03:00
Vladislav Lisitsyn 6ada73b6cb Merge pull request #75 from Vladless/subscriptions-feature
v3. Поправлены уведомления для новой версии.
2024-11-08 06:53:12 +03:00
Vladless 719865fe83 v3. Поправлены уведомления для новой версии. 2024-11-08 06:52:37 +03:00
Vladislav Lisitsyn c34c5ef7af Merge pull request #74 from Vladless/subscriptions-feature
v3. Админка переведена на новую версию.
2024-11-08 06:43:15 +03:00
Vladless 9c85ae31e5 v3. Админка переведена на новую версию. 2024-11-08 06:42:12 +03:00
Vladislav Lisitsyn 987602551e Merge pull request #73 from Vladless/subscriptions-feature
Админка
2024-11-08 06:03:34 +03:00
Vladless 9dcbc7d736 v3. Админка переведена на новую версию. 2024-11-08 06:02:29 +03:00
Vladless 92fbe6ced5 v3. Админка переведена на новую версию. 2024-11-08 06:02:07 +03:00
Vladislav Lisitsyn d79d7004ec Update README.md 2024-11-07 07:58:58 +03:00
Vladislav Lisitsyn 4d1a75939e Update README.md 2024-11-07 07:58:41 +03:00
Vladislav Lisitsyn 1c9af07a7d Merge pull request #72 from Vladless/subscriptions-feature
v3. Полноценные подписки
2024-11-07 07:56:55 +03:00
Vladless 8f16fa4c1a v3. Полноценные подписки 2024-11-07 07:53:29 +03:00
Vladislav Lisitsyn fe2ae18f20 Merge pull request #71 from Vladless/subscriptions-feature
Проверка ключей
2024-11-05 22:02:36 +03:00
Vladless aa73bdd066 Проверка ключей 2024-11-05 22:00:32 +03:00
Vladislav Lisitsyn a48bc4a0d8 Merge pull request #70 from Vladless/subscriptions-feature
Админка. Расширение
2024-11-04 01:10:40 +03:00
Vladless 3461f47cfd Админка. Расширение 2024-11-04 01:10:02 +03:00
Vladislav Lisitsyn 38d99b2a47 Merge pull request #69 from Vladless/subscriptions-feature
Переработка уведомлений
2024-11-03 23:42:07 +03:00
Vladless 25a51549ac Переработка уведомлений 2024-11-03 23:41:26 +03:00
Vladislav Lisitsyn bca136a55e Merge pull request #68 from Vladless/subscriptions-feature
Профиль. Логи
2024-11-03 21:54:11 +03:00
Vladless 1953e7c0cb Профиль. Логи 2024-11-03 21:52:59 +03:00
Vladislav Lisitsyn 6754a7da93 Merge pull request #67 from Vladless/subscriptions-feature
Уведомления/Профиль. Логгирование
2024-11-03 21:40:41 +03:00
Vladless 8fb3b1691f Профиль. Логи 2024-11-03 21:39:56 +03:00
Vladless 91bc384508 Уведомления. Правки 2024-11-03 21:38:26 +03:00
Vladislav Lisitsyn 666c3b34af Merge pull request #66 from Vladless/subscriptions-feature
Уведомления. Правки
2024-11-03 01:49:54 +03:00
Vladless 38817c82f7 Уведомления. Правки 2024-11-03 01:49:02 +03:00
Vladislav Lisitsyn 10a6179b7c Update README.md 2024-11-03 00:34:00 +03:00
Vladislav Lisitsyn 411647585d Merge pull request #65 from Vladless/subscriptions-feature
добавление freekassa
2024-11-03 00:32:38 +03:00
Vladless f5c15546e2 добавление freekassa 2024-11-03 00:31:57 +03:00
Vladislav Lisitsyn 109a968941 Update README.md 2024-11-02 04:30:23 +03:00
Vladislav Lisitsyn b32cf0a9a0 Update README.md 2024-11-02 04:30:01 +03:00
Vladislav Lisitsyn bbd68becfc Update README.md 2024-11-02 04:29:12 +03:00
Vladislav Lisitsyn c4400466dd Merge pull request #64 from Vladless/subscriptions-feature
Подписки. Обновление ключей. Кнопки
2024-11-02 04:25:49 +03:00
Vladislav Lisitsyn da4ed7dd22 Merge branch 'main' into subscriptions-feature 2024-11-02 04:25:39 +03:00
Vladless c0fc972ce5 удобная смена подписки 2024-11-02 04:17:54 +03:00
Vladless cdcfcb7987 перевод на v2raytun 2024-11-02 03:10:49 +03:00
Vladless 48528e611f перевод на v2raytun 2024-11-02 01:17:04 +03:00
Vladless 0e982e8361 перевод на v2raytun 2024-11-01 23:52:21 +03:00
Vladislav Lisitsyn cc9715ca7b Update README.md 2024-10-31 23:59:20 +03:00
Vladislav Lisitsyn 6cb6aa4c67 Merge pull request #63 from Vladless/subscriptions-feature
Кнопка автодобавления конфигурации в клиентское приложение
2024-10-30 20:07:12 +03:00
Vladless 040e32a50a Кнопка автодобавления конфигурации в клиентское приложение 2024-10-30 20:05:38 +03:00
Vladislav Lisitsyn 40092c7085 Merge pull request #62 from Vladless/subscriptions-feature
Подписки. Полная версия
2024-10-30 18:03:36 +03:00
Vladless 1bd138e78b Подписки. Полная версия 2024-10-30 18:03:04 +03:00
Vladless fae10faf7e ReadMe 2024-10-30 04:50:53 +03:00
Vladless 51d0b71e1d ReadMe 2024-10-30 03:57:27 +03:00
Vladless 9cf29cc59c Подписки. initial commit 2024-10-30 02:35:27 +03:00
Vladislav Lisitsyn 3e680fd31d Merge pull request #61 from Vladless/new1
правки
2024-10-30 00:02:43 +03:00
Vladislav Lisitsyn 2e5d581fb5 Delete handlers/__pycache__ directory 2024-10-30 00:02:27 +03:00
Vladless 8a235e129d правки 2024-10-30 00:00:18 +03:00
Vladislav Lisitsyn d899ddf663 Merge pull request #60 from Vladless/new1
исправлен траффик при продлении
2024-10-29 23:46:37 +03:00
Vladless cd6d05320e исправлен траффик при продлении 2024-10-29 23:45:27 +03:00
Vladislav Lisitsyn 4ef0ce9d19 Merge pull request #59 from Vladless/new1
Уведомления. Имя сервера вместо id
2024-10-29 23:20:22 +03:00
Vladless c78fee4a6b Уведомления. Имя сервера вместо id 2024-10-29 23:19:54 +03:00
Vladislav Lisitsyn abc46bf172 Merge pull request #57 from Vladless/new1
rm update_bot
2024-10-28 19:50:29 +03:00
Vladless 3680d0606a rm update_bot 2024-10-28 19:49:03 +03:00
Vladislav Lisitsyn 45e4b2f696 Merge pull request #56 from Vladless/new1
New1
2024-10-28 01:01:44 +03:00
Vladislav Lisitsyn f11e3f255e Merge branch 'main' into new1 2024-10-28 01:01:34 +03:00
Vladless 3915f37e55 ReadMe 2024-10-28 01:00:20 +03:00
Vladless 0a4e1e8c50 rm 2024-10-28 00:54:22 +03:00
Vladless 0d390bb9cc rm 2024-10-28 00:47:09 +03:00
Vladislav Lisitsyn 15f3e89e61 Merge pull request #55 from Vladless/new1
rm
2024-10-28 00:39:58 +03:00
Vladislav Lisitsyn 869a0e8472 rm 2024-10-28 00:35:07 +03:00
Vladislav Lisitsyn 4f0c64b786 rm 2024-10-28 00:30:27 +03:00
Vladislav Lisitsyn 10e10b7157 Merge pull request #54 from Vladless/new1
Уведомления. Продление по кнопке
2024-10-27 23:48:03 +03:00
Vladless b192b43bd3 Уведомления. Продление по кнопке 2024-10-27 22:58:56 +03:00
Vladislav Lisitsyn e129a06d9b Merge pull request #53 from Vladless/new1
/admin. GUI интерфейс в боте
2024-10-27 22:47:09 +03:00
Vladless c678a9bfce /admin. GUI интерфейс в боте 2024-10-27 22:45:16 +03:00
Vladislav Lisitsyn c8cdfc980b Merge pull request #52 from Vladless/new1
Админ. Установка времени ключей
2024-10-27 06:16:55 +03:00
Vladless 146512b2ab Админ. Установка времени ключей 2024-10-27 06:15:34 +03:00
Vladislav Lisitsyn 30c03677fe Merge pull request #51 from Vladless/new1
New1
2024-10-27 03:55:35 +03:00
Vladless 2baf63dccd обложка 2024-10-27 03:54:52 +03:00
Vladless 363bf80bf4 обложка 2024-10-27 03:54:19 +03:00
Vladislav Lisitsyn 50146be4a6 Delete handlers/__pycache__ directory 2024-10-27 03:20:45 +03:00
Vladislav Lisitsyn 09673a145c Merge pull request #50 from Vladless/new1
Полная реструктуризация. Тексты все в texts.py
2024-10-27 03:17:40 +03:00
Vladless 16561610b6 Полная реструктуризация. Тексты все в texts.py 2024-10-27 03:16:58 +03:00
Vladislav Lisitsyn a71afc554e Merge pull request #49 from Vladless/new1
Порядок. Исправления мелких недочетов
2024-10-27 01:30:08 +03:00
Vladless 7512868219 Порядок. Исправления мелких недочетов 2024-10-27 01:29:34 +03:00
Vladislav Lisitsyn 9008ba3b99 Merge pull request #48 from Vladless/new1
Порядок. Исправления мелких недочетов
2024-10-27 01:10:58 +03:00
Vladless 83d589fff5 Порядок. Исправления мелких недочетов 2024-10-27 01:03:00 +03:00
Vladislav Lisitsyn 5cb07aa68a Update trial_key.py 2024-10-26 16:05:58 +03:00
Vladislav Lisitsyn dd5463881d Merge pull request #47 from Vladless/new1
админ. команда баланса
2024-10-26 03:11:00 +03:00
Vladless 77084b1ff2 админ. команда баланса 2024-10-26 03:10:28 +03:00
Vladislav Lisitsyn e965652e1f Merge pull request #46 from Vladless/new1
админка в боте
2024-10-26 02:45:43 +03:00
Vladless 1c3d24527f админка в боте 2024-10-26 02:45:19 +03:00
Vladislav Lisitsyn 9bfb39f1ae Merge pull request #45 from Vladless/new1
админка в боте
2024-10-26 02:40:03 +03:00
Vladless 0c46a70f53 админка в боте 2024-10-26 02:39:35 +03:00
Vladislav Lisitsyn dedeac394b Merge pull request #44 from Vladless/new1
админка в боте
2024-10-26 02:34:39 +03:00
Vladless 57e813e3d4 админка в боте 2024-10-26 02:34:05 +03:00
Vladislav Lisitsyn 02847dbb93 Merge pull request #43 from Vladless/new1
ReadMe
2024-10-26 02:19:28 +03:00
Vladless 470a05018e ReadMe 2024-10-26 02:18:48 +03:00
Vladislav Lisitsyn 649d8060df Merge pull request #42 from Vladless/new1
Docker и docker-compose
2024-10-26 02:13:46 +03:00
Vladless 5a04b360db Docker и docker-compose 2024-10-26 02:12:34 +03:00
Vladislav Lisitsyn 76d2f28128 Merge pull request #41 from Vladless/new1
создание ключа в 1 кнопку
2024-10-26 00:57:22 +03:00
Vladless 37561c086f создание ключа в 1 кнопку 2024-10-26 00:55:03 +03:00
Vladislav Lisitsyn 262b7c9489 Merge pull request #40 from Vladless/new1
Автопродление ключа
2024-10-25 22:21:07 +03:00
Vladless 73cfe74c1c Автопродление ключа 2024-10-25 22:20:12 +03:00
Vladislav Lisitsyn 68f6994b94 Merge pull request #39 from Vladless/new1
Логи. Уведомления. Тексты
2024-10-25 20:56:48 +03:00
Vladless 57ecdbbda5 Логи. Уведомления. Тексты 2024-10-25 20:56:02 +03:00
Vladislav Lisitsyn 3ff07e4c76 Merge pull request #38 from Vladless/new1
Логи. Уведомления. Тексты
2024-10-25 20:46:56 +03:00
Vladless 8d4bec79b3 Логи. Уведомления. Тексты 2024-10-25 20:45:12 +03:00
Vladislav Lisitsyn 546f4c52cb Merge pull request #37 from Vladless/new1
New1
2024-10-22 02:07:33 +03:00
Vladless b223b4463a основной текст в отдельном файле 2024-10-22 02:06:58 +03:00
Vladless 258f8fe2f7 основной текст в отдельном файле 2024-10-22 02:02:17 +03:00
Vladislav Lisitsyn c697498cb4 Merge pull request #36 from Vladless/new1
основной текст в отдельном файле
2024-10-22 01:55:10 +03:00
Vladless 4b1e8b555a основной текст в отдельном файле 2024-10-22 01:53:50 +03:00
Vladless ec4217fc4f поправка подсчета людей 2024-10-22 01:00:56 +03:00
Vladless 49fa3693ad автоввод паролей 2024-10-22 00:30:30 +03:00
Vladless 845a7a287b Бэкапы. команда ручного бэкапа 2024-10-22 00:25:16 +03:00
Vladless b5928722eb Бэкапы. команда ручного бэкапа 2024-10-22 00:13:13 +03:00
Vladless 9f0d0acb53 Бэкапы. Отправка админу 2024-10-21 23:59:26 +03:00
Vladless b4af296384 requirements 2024-10-21 23:40:50 +03:00
Vladless 3466157550 версия бота. Релиз 2024-10-21 03:36:29 +03:00
Vladless 754a33cbc7 версия бота. Релиз 2024-10-21 02:52:34 +03:00
Vladless 8538050fb5 напоминание за сутки у активного клиента 2024-10-21 02:47:59 +03:00
Vladless 165e2955e0 ReadMe.md 2024-10-21 02:35:53 +03:00
Vladless bcce80a52d напоминание за сутки у активного клиента 2024-10-21 02:26:15 +03:00
Vladless 811ca4ff30 ReadMe.md 2024-10-21 02:16:37 +03:00
Vladless 80249609af завершение работы без убивания процесса 2024-10-21 02:12:15 +03:00
Vladless 63039e3537 завершщение работы без убивания процесса 2024-10-21 02:08:47 +03:00
Vladless def9d5a016 резервные копии базы данных и уведомления 2024-10-21 01:23:27 +03:00
Vladless 41dd64f7c8 автопродление ключа 2024-10-13 23:11:39 +03:00
Vladless a0bada3e76 уведомление о неиспользованном триале 2024-10-13 04:02:47 +03:00
Vladless a405f0a5ad ввод своей суммы для пополнения баланса 2024-10-13 03:37:14 +03:00
Vladless 7bd484528a значительная переработка интерфейса 2024-10-13 02:51:45 +03:00
Vladless 2c6b2b9784 значительная переработка интерфейса 2024-10-12 03:14:01 +03:00
Vladless 41ca9b704f ReadMe 2024-10-09 04:01:08 +03:00
Vladislav Lisitsyn 06a1360714 Merge pull request #35 from Vladless/youkassa
ReadMe
2024-10-09 03:41:23 +03:00
Vladislav Lisitsyn 6583f2e6cc Merge branch 'main' into youkassa 2024-10-09 03:41:11 +03:00
Vladless eae768615c ReadMe 2024-10-09 03:40:31 +03:00
Vladislav Lisitsyn 10b4562982 Merge pull request #34 from Vladless/youkassa
Уведомления
2024-10-09 02:16:07 +03:00
Vladless ed5a129b6a логика, отправка уведомлений всем 2024-10-09 02:14:41 +03:00
Vladless dc661dabcc логика, отправка уведомлений всем 2024-10-09 02:13:42 +03:00
Vladless c5e9cb9b18 логика, отправка уведомлений всем 2024-10-09 02:09:22 +03:00
Vladislav Lisitsyn 576a389028 Merge pull request #33 from Vladless/youkassa
aiohttp, уведомления, правки
2024-10-09 01:10:30 +03:00
Vladislav Lisitsyn 709c1064a5 Merge branch 'main' into youkassa 2024-10-09 01:10:23 +03:00
Vladless 8588b89421 aiohttp, уведомления, правки 2024-10-09 00:51:22 +03:00
Vladislav Lisitsyn f7886dcfea Merge pull request #31 from Vladless/youkassa
правка сообщения пригласить
2024-10-04 03:10:09 +03:00
Vladless 60fa6cb4f1 правка сообщения пригласить 2024-10-04 03:09:32 +03:00
Vladislav Lisitsyn 98832d7ade Merge pull request #30 from Vladless/youkassa
правка сообщения пригласить
2024-10-04 02:45:26 +03:00
Vladless dbfed0dc2a правка сообщения пригласить 2024-10-04 02:44:32 +03:00
Vladislav Lisitsyn a674c321de Merge pull request #29 from Vladless/youkassa
Youkassa
2024-10-04 02:39:12 +03:00
Vladless d91e9180ea правка сообщения пригласить 2024-10-04 02:35:48 +03:00
Vladless fbe7e9f820 реферальная система 2024-10-04 02:31:05 +03:00
Vladislav Lisitsyn c7fb1045c2 Merge pull request #28 from Vladless/youkassa
Youkassa
2024-10-04 02:13:12 +03:00
Vladless 4ba3e14961 реферальная система 2024-10-04 02:06:16 +03:00
Vlad 7746835832 чистота кода 2024-10-03 07:20:13 +03:00
Vladislav Lisitsyn 264d6186fa Merge pull request #27 from Vladless/youkassa
Youkassa
2024-10-03 07:09:08 +03:00
Vlad 3f5c449817 чистота кода 2024-10-03 07:08:17 +03:00
Vlad d03907b43c уведомления за 10 часов и при истечении 2024-10-03 06:52:24 +03:00
Vladislav Lisitsyn 56bd72c3bc Merge pull request #26 from Vladless/youkassa
Выбор сервера
2024-10-03 06:45:59 +03:00
Vlad f42ca88e01 поправлен тег 2024-10-03 06:45:07 +03:00
Vlad 87095772fe заполненность серверов в процентах 2024-10-03 06:42:40 +03:00
Vlad bfca706b31 работающий перенос ключей 2024-10-03 06:31:44 +03:00
Vlad bba0e3ebdb работающий перенос ключей 2024-10-03 06:20:38 +03:00
Vlad 4958061a19 выбор сервера, переработка ссылок 2024-10-03 04:43:55 +03:00
Vladislav Lisitsyn 8aec92fe43 Merge pull request #25 from Vladless/youkassa
смена слогана
2024-10-02 05:16:33 +03:00
Vlad f84293f270 смена слогана 2024-10-02 05:12:55 +03:00
Vladislav Lisitsyn aa830c9a37 Merge pull request #24 from Vladless/youkassa
Youkassa
2024-10-02 05:04:01 +03:00
Vlad 58357f2f8b смена слогана 2024-10-02 05:03:08 +03:00
Vlad 19f40c8fad смена слогана 2024-10-02 04:51:48 +03:00
Vlad e1aadf9781 смена слогана 2024-10-02 04:46:05 +03:00
Vladislav Lisitsyn 3198cd13f4 Merge pull request #23 from Vladless/youkassa
новостные сообщения, поправленный requirements
2024-10-02 04:16:36 +03:00
Vlad 67b85478aa новостные сообщения, поправленный requirements 2024-10-02 04:16:04 +03:00
Vladislav Lisitsyn 08cbeceea4 Merge pull request #22 from Vladless/youkassa
истекшие ключи автоматически удаляются
2024-10-02 02:29:44 +03:00
Vlad 60d7e89452 истекшие ключи автоматически удаляются 2024-10-02 02:06:42 +03:00
Vlad 3937cb105c порядок в стурктуре проекта, isort, импорты 2024-10-02 01:35:39 +03:00
Vlad 16c776bf82 Доработка платежей, уведомления и переработка интерфейса 2024-10-02 01:19:48 +03:00
Vladislav Lisitsyn 467557fb59 Merge pull request #21 from Vladless/youkassa
Доработка платежей, уведомления и переработка интерфейса
2024-10-02 01:13:35 +03:00
Vlad 140020838b Доработка платежей, уведомления и переработка интерфейса 2024-10-02 01:13:04 +03:00
Vladislav Lisitsyn 18f329b529 Merge pull request #20 from Vladless/youkassa
Оплата и пополнение баланса через Юкасса
2024-10-01 14:27:58 +03:00
Vlad 271ea09380 Оплата и пополнение баланса через Юкасса 2024-10-01 14:27:16 +03:00
Vladislav Lisitsyn d6dc9ab365 Merge pull request #19 from Vladless/new
WebHooks for YouKassa
2024-10-01 02:10:28 +03:00
Vlad 376cd07b5c WebHooks for YouKassa 2024-10-01 01:20:29 +03:00
541 changed files with 121352 additions and 78685 deletions
-36
View File
@@ -1,36 +0,0 @@
---
name: Issue
about: Сообщить об ошибке, предложить улучшение или указать на уязвимость
title: "[ISSUE] "
labels: triage
assignees: ''
---
### Тип
- [ ] Баг
- [ ] Фича
- [ ] Безопасность
### Кратко
Опишите суть задачи.
### Шаги воспроизведения (для багов/безопасности)
1.
2.
3.
### Ожидаемое / Фактическое
-
### Окружение
- Ветка: main/dev
- Коммит:
- ОС:
- Python:
### Дополнительно (для фич)
Опишите мотивацию, альтернативы.
### Примечание по безопасности
Не публикуйте PoC публично.
Для сообщений об уязвимостях свяжитесь напрямую: [@solonet_sup](https://t.me/solonet_sup).
-17
View File
@@ -1,17 +0,0 @@
#!/bin/sh
echo "Сохранение текущих изменений..."
git stash -q --keep-index
echo "Запуск Ruff format..."
ruff format . --config pyproject.toml --exclude main.py,handlers/payments
echo "Запуск Ruff check с автоисправлением..."
ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
git add -u
echo "Восстановление сохраненных изменений..."
git stash pop -q
exit 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

-21
View File
@@ -1,21 +0,0 @@
### Описание изменений
Опишите, что изменилось и зачем.
### Ветка назначения
- [ ] dev (основная ветка разработки)
- [ ] main (только для критических исправлений безопасности)
### Тип изменений
- [ ] Новая функциональность
- [ ] Исправление ошибки
- [ ] Рефакторинг
- [ ] Документация
- [ ] Другое
### Чек-лист
- [ ] Изменения протестированы локально
- [ ] Документация обновлена (если требуется)
- [ ] Нет лишних изменений в коде
### Связанные задачи
Укажите номер issue, если PR его закрывает (например: Closes #123).
+9 -32
View File
@@ -7,7 +7,6 @@ __pycache__/
dist/
build/
*.egg-info/
alembic/
# Virtual environments
venv/
@@ -27,19 +26,14 @@ env/
*.db
# Sensitive configuration files
/config.py
/config.ini
/alembic.ini
/.env
config.py
config.ini
.env
# Backup files
*.bak
*.swp
*~
backups/
backup_bot/
Solo_backup/
.cursor/
# Specific project files
vpn_users.db
@@ -47,6 +41,7 @@ database.db
bot_old.py
bot_old_2.py
backup_pg.sh
docker-compose.yml
config copy.py
handlers/texts.py
@@ -55,29 +50,11 @@ handlers/texts.py
Thumbs.db
nginx.conf
/scripts/load_balancer.py
/scripts/__pycache__
scripts
models.py
Dockerfile
.csv
/logs
setup.py
.ruff_cache
modules/
storage/
static/web_uploads/
.license_state
Solo_backup/
# Node / Next.js artifacts (root — web-app has its own)
node_modules/
.next/
.vite/
.claude/
.cursor/hooks.json
.cursor/hooks/after-agent-response.cjs
.cursor/hooks/before-submit-prompt.cjs
.cursor/hooks/after-agent-response.js
.cursor/hooks/before-submit-prompt.js
.cursor/cursor-notifier.json
.cursor/cursor-notifier-start.json
nuitka-crash-report*
.github/workflows/
+1
View File
@@ -0,0 +1 @@
3.12
-25
View File
@@ -1,25 +0,0 @@
# Code of Conduct
## Общие правила
- Уважайте друг друга. Недопустимы оскорбления, дискриминация или токсичное поведение.
- Обсуждения должны оставаться по теме и в конструктивной форме.
- Личные и конфиденциальные данные публиковать запрещено.
- Нарушения могут привести к предупреждению, ограничению участия или блокировке по усмотрению мейнтейнеров.
## Участники GitHub
- Могут предлагать изменения через **Issues** и **Pull Requests**.
- Их вклад рассматривается и принимается по решению мейнтейнеров.
- Дискуссии ограничены рамками кода и технических вопросов.
## Участники сообщества в Telegram
- Имеют приоритетное взаимодействие с разработчиком.
- Могут обсуждать проект шире: идеи, улучшения, новые направления.
- Влияние на развитие кода выше, чем у участников извне.
- Общение остаётся в рамках уважения и конструктивности.
## Контакты
По вопросам поведения используйте Issues с меткой `conduct` или обращайтесь через сообщество в Telegram.
-29
View File
@@ -1,29 +0,0 @@
# Contributing Guidelines
Спасибо, что хотите внести вклад в развитие проекта!
## Как помочь
- Создавайте **Issues** для конкретных целей или описания найденных багов.
- Отправляйте **Pull Requests** с улучшениями и исправлениями. Каждое предложение будет рассмотрено, и мы примем решение о включении.
- Делайте **форки** репозитория и дорабатывайте код — лучшие практики мы обязательно объединим в общее дело.
## Стиль кода
- Соблюдайте стандарт **PEP8**.
- Для автоматической проверки можно использовать **ruff**.
## Правила коммитов
- Сообщения должны быть осмысленными и отражать суть изменений.
- Если в одном коммите несколько изменений, перечисляйте их через `/`.
## Лицензия
Creative Commons Attribution-NonCommercial 4.0 International Public License © Vladless, 2024.
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Внося изменения, вы соглашаетесь, что ваш вклад распространяется на условиях этой лицензии. Перепродажа или коммерческое распространение этого кода без явного письменного согласия правообладателя не допускаются.
---
Ваш вклад помогает проекту расти и становиться лучше.
-28
View File
@@ -1,28 +0,0 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
tzdata \
git \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
COPY . .
RUN rm -rf /app/venv \
&& python -m venv /app/venv \
&& /app/venv/bin/pip install --upgrade pip \
&& /app/venv/bin/pip install -r requirements.txt
RUN adduser --disabled-password --gecos "" appuser \
&& mkdir -p /app/backups \
&& chown -R appuser:appuser /app
USER appuser
CMD ["/app/venv/bin/python", "main.py"]
+3 -15
View File
@@ -1,19 +1,7 @@
format:
formatting:
@echo "Running Ruff format..." && ruff format . --config pyproject.toml --exclude main.py,handlers/payments
@echo "Running Ruff..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
lint:
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
format-payments:
@echo "Running Ruff format ONLY on handlers/payments..." && ruff format handlers/payments --config pyproject.toml
@echo "Running Ruff check ONLY on handlers/payments..." && ruff check handlers/payments --config pyproject.toml --fix
test:
@echo "Running unit tests..." && cd /tmp && PYTHONPATH="$(CURDIR)" "$(CURDIR)/venv/bin/python" -m unittest discover -s "$(CURDIR)/tests" -q
test-sudo:
@echo "Running unit tests with sudo..." && cd /tmp && sudo env PYTHONPATH="$(CURDIR)" "$(CURDIR)/venv/bin/python" -m unittest discover -s "$(CURDIR)/tests" -q
smoke:
@echo "Running smoke checks..." && bash "$(CURDIR)/tests/smoke_runner.sh"
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
+95 -200
View File
@@ -1,211 +1,106 @@
<div align="center" style="max-width:980px;margin:0 auto;padding:0 12px">
# 🚀 SoloBot
![IMG_1565](https://github.com/user-attachments/assets/71cfb259-9889-4066-8626-b804ae4cf7fb)
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
<h1 style="margin:0;font-weight:900;letter-spacing:.3px">SoloBot</h1>
<p style="margin:6px 0 0;font-size:16px;opacity:.9"><b>Идеальный помощник для 3x-UI и Remnawave (VLESS)</b></p>
<img src="./img/preview.gif" alt="SoloBot" width="1600">
# Описание
Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
# Основные возможности
**SoloBot** реализует множество функций, включая:
### Управление подписками
- Выдача подписок на различные периоды (**1 месяц, 3 месяца, полгода, год**).
- Использование **тестового периода** подписки.
- Продление ключей на указанные периоды.
### Полный контроль клиента над своими ключами
- Просмотр информации о ключе (**сервер, оставшееся время, сам ключ**).
- Продление ключей.
- Удаление ключей.
- Просмотр полной информации вплоть до минут истечения
- Смена локации (**перемещение ключа между серверами**).
- Поддержка **нескольких ключей и устройств** для одного клиента.
### Реферальная программа
- Уникальная ссылка для приглашений.
- Инлайн режим или обычные сообщения
### Интеграция платёжных систем
В боте реализована интеграция касс как для физических лиц, так и для самозанятых и ИП:
- **Юкасса** (Самозанятость и ИП)
- **Юмани** (Физические Лица) (TrackLine)
- **Robokassa** (Физические Лица)
- **Cryptobot** (Криптовалюта) (izzzzzi)
- **Звезды Telegram**
### Безопасность и стабильность
- **Периодические бэкапы** базы данных клиентов и их восстановление.
- **Смена домена** в случае переезда.
### Уведомления
- **Произвольные сообщения** через панель администратора.
- Уведомления о **неиспользованных пробниках**.
- Уведомления об **истекающих ключах** (_за сутки, за 6 часов и в момент истечения_).
- **Воронка продаж**. Если клиент не взял пробник предлагаются более выгодные условия.
- **Рассылка лично или всем.** С сохранением форматирования и отправкой картинок
### Серверы
- **Мультисерверность** — добавление сервера прямо в админке.
- **Проверка на доступность** — бот следит за серверами и их откликом.
- **Балансировка** — бот выдает подписку на наименее загруженный сервер.
- **Сихронизация** — перемещайте клиентов на другие сервера бесшовно.
### Удобная админ-панель
- **Поиск клиента** — по айди, нику или ключу.
- **Управление подпиской клиента** — перевыпуск, изменение баланса и остальное.
- **Управление ботом** — перезагрузка, списки банов, смена домена.
- *А также рассылки, создание купонов, просмотр детальной статистики и многое другое*
<p style="margin:16px 0 0">
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
<img height="46" src="https://img.shields.io/badge/➡_Website-visit-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=firefox&logoColor=ff6a00" alt="Website">
</a>&nbsp;
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
<img height="46" src="https://img.shields.io/badge/Try_in_Telegram-demo-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Telegram">
</a>&nbsp;
<a href="https://pocomacho.ru/solonetbot/features" target="_blank">
<img height="46" src="https://img.shields.io/badge/Features-overview-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=readthedocs&logoColor=ff6a00" alt="Features">
</a>&nbsp;
<a href="https://github.com/Vladless/Solo_bot/releases" target="_blank">
<img height="46" src="https://img.shields.io/badge/Releases-history-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=github&logoColor=ff6a00" alt="Releases">
</a>
</p>
<img src="./img/preview_2.gif" alt="SoloBot" width="1600">
<p style="margin:10px 0 0">
<a href="LICENSE" target="_blank">
<img height="32" src="https://img.shields.io/badge/License-CC_BY--NC_4.0-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=creative-commons&logoColor=ff6a00" alt="License">
</a>
</p>
### Кастомизация
- **Пробник** — Сами решаете будет или нет. Количество дней пробника также кастомизируется.
- **Реферальная программа** — процент с каждого пополнения или фиксированная сумма.
- **Все меню** — меняйте все меню как вам захочется, описания и тексты.
- **Кешбэк** — начисляйте кэшбек за пополнения, если хотите.
- **Настройки** — капча, подписка на канал, купоны и прочее.
- **Автоматика** — удалять ли ключи после продления, продлевать ли их и остальное.
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<p><b>Многофункциональный бот, кастомизируемый под ваш бренд.</b></p>
</div>
### *Настраивайте как вам удобно!*
<h2 align="center">Основные возможности</h2>
## Список версий
<table align="center" style="max-width:980px;width:100%">
<tr>
<th align="left" style="width:30%">📌 Раздел</th>
<th align="left">💡 Возможности</th>
</tr>
<tr>
<td><b>Мультипанель</b></td>
<td>3x-ui<br>Remnawave<br>Совместимый режим</td>
</tr>
<tr>
<td><b>Подписки</b></td>
<td>Произвольный срок и триал<br>Продление по тарифу<br>Happ / Hiddify / v2RayTun<br>Кастом заголовков</td>
</tr>
<tr>
<td><b>Тарификация</b></td>
<td>Лимиты устройств<br>Лимиты трафика<br>Лимиты серверов в подписках</td>
</tr>
<tr>
<td><b>Клиент</b></td>
<td>Ключ / сервер / TTL<br>Продление / удаление / начисление дней / отключение<br>Смена локации<br>Несколько устройств</td>
</tr>
<tr>
<td><b>Поддержка роутеров</b></td>
<td>Выдача VLESS подписки за отдельную цену</td>
</tr>
<tr>
<td><b>Рефералы</b></td>
<td>Уникальные ссылки<br>Инлайн и обычные сообщения<br>% или фикс</td>
</tr>
<tr>
<td><b>UTM-аналитика</b></td>
<td>Трекинг переходов<br>Привязка к рефералам / купонам / триалам<br>Конверсии<br>Просмотр в админке</td>
</tr>
<tr>
<td><b>Оплата</b></td>
<td>YooKassa (ИП/Самозанятые)<br>YooMoney (Физ. лица) <a href="https://github.com/TrackLine">@TrackLine</a><br>Robokassa (ИП)<br>Telegram Stars<br>Heleket (Физ. лица) <a href="https://github.com/JustYay">@JustYay</a><br>Wata (Физ. лица) <a href="https://github.com/TrackLine">@TrackLine</a><br>Kassai (Физ. лица) <a href="https://github.com/JustYay">@JustYay</a><br>И другие <a href="https://pocomacho.ru/solonetbot/modules/">см. Модули</a></td>
</tr>
<tr>
<td><b>Валютность</b></td>
<td>Оплата в разных валютах</td>
</tr>
<tr>
<td><b>Надёжность</b></td>
<td>Бэкапы<br>Смена домена<br>Проверка доступности<br>Уведомления об аптайме</td>
</tr>
<tr>
<td><b>Уведомления</b></td>
<td>О скором окончании подписки<br>О непотраченном трафике</td>
</tr>
<tr>
<td><b>Рассылки</b></td>
<td>По заданным группам<br>С картинками или видео</td>
</tr>
<tr>
<td><b>Серверы</b></td>
<td>Мультисерверность<br>Одна локация или подписка<br>Автопроверка и балансировка<br>Синхронизация и лимиты<br>Вкл/выкл</td>
</tr>
<tr>
<td><b>Воронка продаж</b></td>
<td>Доп. дни к триалу: не взял триал → оффер → активация<br>Горячие лиды: платил → перестал → таргет/КП → возврат</td>
</tr>
<tr>
<td><b>Админка</b></td>
<td>Поиск TG/username/ключ/email<br>Баланс / подписки / заморозки<br>Перезагрузка<br>Бан-лист · купоны · UTM · статистика<br>Синхронизация / импорт из панели</td>
</tr>
<tr>
<td><b>Модульность</b></td>
<td>Подключаемые модули<br>Вкл/выкл и перезапуск из админки<br>Обновления и версии<br>Возможность дорабатывать код не теряя обновления</td>
</tr>
<tr>
<td><b>Кастомизация</b></td>
<td>Гибкая настройка функций и логики<br>Возможность отключения отдельных функций</td>
</tr>
</table>
<div style="height:22px"></div>
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
<div style="height:22px"></div>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Обновления</h2>
<p align="center">Регулярные релизы. Обновление через CLI. Ветки: <code>main</code> — стабильные версии, <code>dev</code> — бета.</p>
<p align="center">
<a href="https://github.com/Vladless/Solo_bot/releases" target="_blank">
<img height="46" src="https://img.shields.io/badge/➡_Все_версии-смотреть-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=github&logoColor=ff6a00" alt="Все версии">
</a>
</p>
<div style="height:22px"></div>
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
<div style="height:22px"></div>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Гайды и расширения</h2>
<p align="center" style="max-width:900px;margin:8px auto 14px">
На сайте всегда лежат актуальные файлы для запуска разных версий бота. Есть собственное Wiki с пошаговыми инструкциями по установке и обновлениям. Доступна система модулей: бот легко дополняется готовыми расширениями и вашим кастомным функционалом.
</p>
<p align="center">
<img src="https://github.com/user-attachments/assets/7d0a954d-41b7-4d7c-8618-aee1f4459792" alt="Guides" width="100%">
</p>
<p align="center">
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
<img height="44" src="https://img.shields.io/badge/➡_Сайт_SoloBot-перейти-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=firefox&logoColor=ff6a00" alt="Site">
</a>&nbsp;
<a href="https://pocomacho.ru/solonetbot/reviews/" target="_blank">
<img height="44" src="https://img.shields.io/badge/Отзывы-на_сайте-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=discourse&logoColor=ff6a00" alt="Reviews">
</a>
</p>
<div style="height:22px"></div>
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
<div style="height:22px"></div>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Полная версия</h2>
<p align="center">
<img src="https://github.com/user-attachments/assets/597e6c4e-68be-4d8f-826b-35754c682a30" alt="Community" width="100%">
</p>
<p align="center" style="max-width:900px;margin:8px auto 14px">
SoloBot уже помог более чем 500 пользователям из сообщества. Присоединяйтесь: попробуйте бота или напишите в поддержку, чтобы получить доступ и консультацию.
</p>
<p align="center">
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
<img height="46" src="https://img.shields.io/badge/Попробовать_бота-открыть_Telegram-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Try bot">
</a>&nbsp;
<a href="https://t.me/solonet_sup" target="_blank">
<img height="46" src="https://img.shields.io/badge/Написать_в_поддержку-получить_доступ-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Support">
</a>
</p>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Лицензия</h2>
<table align="center" style="max-width:900px;width:100%">
<tr>
<td style="width:8%;text-align:center">❗</td>
<td><b>Этот проект распространяется по лицензии <a href="LICENSE">CC BY-NC 4.0</a></b></td>
</tr>
<tr>
<td style="text-align:center">⛔</td>
<td><b>Перепродажа кода запрещена.</b> Нельзя продавать или перепродавать код без разрешения автора.</td>
</tr>
<tr>
<td style="text-align:center">✅</td>
<td><b>Для личного использования.</b> Код можно использовать и модифицировать для личных проектов.</td>
</tr>
</table>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Участники</h2>
<p align="center">
<a href="https://github.com/Vladless/Solo_bot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Vladless/Solo_bot" alt="Contributors">
</a>
</p>
#### Бот постоянно развивается, обрастая новыми возможностями.
#### Для полного рассмотрения каждой версии ознакомьтесь с [**релизами**](https://github.com/Vladless/Solo_bot/releases).
## 🔗 SoloBot в Telegram и Полная версия
#### Наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
![image](https://github.com/user-attachments/assets/28f317f0-6b26-4d86-a501-df9800646131)
Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot).
#### Отзывы пользователей:
SoloBot уже помог сотням пользователей в нашем сообществе:
![image](https://github.com/user-attachments/assets/fe13a00f-94d3-419c-afd0-d1c1efec5179)
**Читать** [**➡ Отзывы**](https://pocomacho.ru/solonetbot/reviews/)
Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин
и пароль от сайта, получить доступ в наш чат сообщества, а также задать необходимые вопросы!
## Права на использование
#### Перепродажа кода запрещена, ознакомьтесь с лицензией
[Этот проект использует лицензию CC BY-NC 4.0](LICENSE)
-32
View File
@@ -1,32 +0,0 @@
# Security Policy
## Поддерживаемые ветки
- Рассматриваются только `main` (последняя стабильная) и `dev` (текущая разработка).
- Другие ветки и теги не поддерживаются.
## Как сообщить об уязвимости
- Не создавайте публичные Issues или Pull Requests.
- Сообщите напрямую владельцу: [@solonet_sup](https://t.me/solonet_sup).
- Также можно уведомить через сообщество в Telegram (доступно после вступления).
- Укажите: ветку (`main`/`dev`), версию/коммит, шаги воспроизведения, ожидаемое и фактическое поведение, возможное влияние, логи или скриншоты.
## Процесс
1. Подтверждаем получение отчёта.
2. Анализируем и определяем критичность.
3. Исправления выпускаются в `dev`.
4. Если уязвимость критическая — фикс публикуется напрямую в `main`.
5. После релиза уведомляем сообщество и при необходимости публикуем рекомендации по обновлению.
## Область действия
- Бот (aiogram), веб-админка (flask), внутренние модули/плагины и API проекта.
- Вне области: сторонние сервисы и библиотеки (платёжные провайдеры, Telegram, панели и т. п.). Для них обращайтесь к поставщику напрямую.
## Ответственное раскрытие
- Просим не публиковать детали до выхода исправления.
- Тесты проводите только на своих данных и аккаунтах.
- Атаки на инфраструктуру или пользователей строго запрещены.
## Лучшие практики при отчёте
- Минимальный PoC без риска для данных и доступности.
- Чёткие и воспроизводимые шаги.
- Возможные предложения по снижению риска до исправления.
-259
View File
@@ -1,259 +0,0 @@
import hashlib
from collections.abc import AsyncGenerator
from urllib.parse import urlparse
from fastapi import Depends, HTTPException, Header, Query, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from audit import set_api_actor
from database import (
async_session_maker,
identities as idb,
identity_sessions as idsess,
)
from database.access.resolution import ResolvedActor, resolve_actor_from_identity
from database.models import Admin, Identity
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def bind_identity_actor(
request: Request | None,
session: AsyncSession,
identity: Identity,
) -> ResolvedActor:
actor = await resolve_actor_from_identity(session, identity)
set_api_actor(request, identity_id=actor.identity_id, tg_id=actor.telegram_chat_id)
if request is not None:
request.state.actor = actor
return actor
def get_request_actor(request: Request | None) -> ResolvedActor | None:
if request is None:
return None
return getattr(request.state, "actor", None)
async def verify_admin_token(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
session: AsyncSession = Depends(get_session),
) -> Admin:
hashed = hash_token(token)
result = await session.execute(select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed))
admin = result.scalar_one_or_none()
if not admin:
raise HTTPException(status_code=401, detail="Unauthorized")
set_api_actor(request, tg_id=admin.tg_id)
return admin
AUTH_COOKIE_NAME = "auth_token"
IS_ADMIN_COOKIE_NAME = "is_admin"
AUTH_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
def _is_secure_request(request: Request | None) -> bool:
if request is None:
return False
if request.url.scheme == "https":
return True
forwarded_proto = request.headers.get("x-forwarded-proto", "").lower()
return forwarded_proto == "https"
def set_auth_cookie(response: Response, token: str, request: Request | None = None) -> None:
"""Устанавливает HttpOnly cookie с auth-токеном на ответ. Используется во всех login-ручках."""
response.set_cookie(
key=AUTH_COOKIE_NAME,
value=token,
max_age=AUTH_COOKIE_MAX_AGE_SECONDS,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
def clear_auth_cookie(response: Response, request: Request | None = None) -> None:
"""Удаляет auth cookie на стороне браузера."""
response.delete_cookie(
key=AUTH_COOKIE_NAME,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
clear_is_admin_cookie(response, request)
def set_is_admin_cookie(response: Response, identity: Identity, request: Request | None = None) -> None:
"""Ставит/гасит `is_admin` cookie в зависимости от текущей identity."""
if getattr(identity, "is_admin", False):
response.set_cookie(
key=IS_ADMIN_COOKIE_NAME,
value="1",
max_age=AUTH_COOKIE_MAX_AGE_SECONDS,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
else:
clear_is_admin_cookie(response, request)
def clear_is_admin_cookie(response: Response, request: Request | None = None) -> None:
response.delete_cookie(
key=IS_ADMIN_COOKIE_NAME,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
def _read_auth_cookie(request: Request | None) -> str | None:
if request is None:
return None
raw = request.cookies.get(AUTH_COOKIE_NAME)
if not raw:
return None
raw = raw.strip()
return raw or None
async def _identity_from_cookie(session: AsyncSession, request: Request | None) -> Identity | None:
token = _read_auth_cookie(request)
if not token:
return None
token_hash = hash_token(token)
sess = await idsess.get_session_by_token_hash(session, token_hash)
if sess is None:
return None
if sess.expires_at is not None and sess.expires_at <= datetime.utcnow():
return None
identity = await idb.get_identity_by_id(session, sess.identity_id)
if identity is None:
return None
await idsess.touch_session_last_seen(session, sess)
if request is not None:
try:
request.state.auth_session = sess
except Exception:
pass
return identity
async def verify_identity_token(
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Проверяет токен из HttpOnly cookie `auth_token`; возвращает Identity."""
from database.site_state import mark_site_initialized
identity = await _identity_from_cookie(session, request)
if identity is None:
raise HTTPException(status_code=401, detail="Unauthorized")
await bind_identity_actor(request, session, identity)
if getattr(identity, "is_admin", False):
await mark_site_initialized(session)
return identity
async def verify_identity_admin(
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Проверяет токен из cookie и что identity.is_admin; для админских ручек v2."""
from database.site_state import mark_site_initialized
identity = await _identity_from_cookie(session, request)
if identity is None:
raise HTTPException(status_code=401, detail="Unauthorized")
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
await bind_identity_actor(request, session, identity)
await mark_site_initialized(session)
return identity
async def verify_identity_admin_short(
request: Request,
):
"""Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД."""
identity = None
actor = None
async with async_session_maker() as session:
identity = await _identity_from_cookie(session, request)
if identity:
actor = await resolve_actor_from_identity(session, identity)
await session.commit()
if not identity:
raise HTTPException(status_code=401, detail="Unauthorized")
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
if actor is not None:
set_api_actor(request, identity_id=identity.id, tg_id=actor.telegram_chat_id)
if request is not None:
request.state.actor = actor
else:
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return identity
async def verify_admin_token_short(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
) -> Admin:
"""Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД."""
hashed = hash_token(token)
async with async_session_maker() as session:
result = await session.execute(select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed))
admin = result.scalar_one_or_none()
await session.commit()
if not admin:
raise HTTPException(status_code=401, detail="Unauthorized")
set_api_actor(request, tg_id=admin.tg_id)
return admin
def validate_redirect_url(url: str, base_url: str) -> str:
"""Validate redirect URL is same-origin or relative. Returns safe URL or base_url fallback."""
url = url.strip()
if not url:
return base_url
if url.startswith("/"):
return url
try:
parsed = urlparse(url)
base_parsed = urlparse(base_url)
if parsed.scheme in ("http", "https") and parsed.netloc == base_parsed.netloc:
return url
except Exception:
pass
return base_url
-219
View File
@@ -1,219 +0,0 @@
import asyncio
import hashlib
import os
from time import perf_counter
from fastapi import Depends, FastAPI, Request
from fastapi.responses import ORJSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.responses import Response as StarletteResponse
from starlette.staticfiles import StaticFiles
from audit import ensure_api_context, log_api_access, record_api_access_event_background
from config import API_LOGGING, API_VERSION, API_CORS_ORIGINS
from database import async_session_maker
from logger import logger
if API_VERSION == 1:
from api.v1 import router as api_router, VERSION as API_DOC_VERSION
else:
from api.v2 import VERSION as API_DOC_VERSION
from api.v2.router import router as api_router
app = FastAPI(
title=f"SoloBot API (Alpha) — API v{API_DOC_VERSION}",
version=API_DOC_VERSION,
description=f"Версия API: **v{API_DOC_VERSION}**.",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
default_response_class=ORJSONResponse,
)
_cors_origins = API_CORS_ORIGINS if API_CORS_ORIGINS != ["*"] else API_CORS_ORIGINS
_cors_credentials = API_CORS_ORIGINS != ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_credentials=_cors_credentials,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["X-Identity-Id", "X-Token", "Content-Type", "Authorization"],
)
app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=6)
@app.exception_handler(Exception)
async def _generic_exception_handler(request: Request, exc: Exception):
from audit import ensure_api_context
context = ensure_api_context(request)
logger.exception("[API] Unhandled exception at {} {}: {}", request.method, request.url.path, exc)
return ORJSONResponse(
status_code=500,
content={"detail": "Внутренняя ошибка сервера", "request_id": context.request_id},
)
_ETAG_MAX_BODY_BYTES = 256 * 1024
@app.middleware("http")
async def security_and_cache_middleware(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("X-XSS-Protection", "1; mode=block")
content_type = response.headers.get("content-type", "")
path = request.url.path
if path.startswith("/api/web/uploads/") and request.method == "GET" and response.status_code == 200:
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
return response
if request.method == "GET" and response.status_code == 200 and "application/json" in content_type:
content_length_header = response.headers.get("content-length")
try:
cl = int(content_length_header) if content_length_header is not None else None
except (TypeError, ValueError):
cl = None
if cl is not None and cl > _ETAG_MAX_BODY_BYTES:
response.headers.setdefault("Cache-Control", "no-cache")
return response
chunks: list[bytes] = []
total = 0
too_big = False
async for chunk in response.body_iterator:
total += len(chunk)
if total > _ETAG_MAX_BODY_BYTES:
chunks.append(chunk)
too_big = True
async for remaining in response.body_iterator:
chunks.append(remaining)
break
chunks.append(chunk)
body = b"".join(chunks)
if too_big:
headers = dict(response.headers)
headers["Cache-Control"] = "no-cache"
headers.pop("content-length", None)
return StarletteResponse(content=body, status_code=200, headers=headers, media_type=response.media_type)
etag = '"' + hashlib.md5(body).hexdigest() + '"'
if_none_match = request.headers.get("if-none-match", "")
client_etags = [t.strip() for t in if_none_match.split(",") if t.strip()]
if etag in client_etags or if_none_match.strip() == "*":
return StarletteResponse(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
headers = dict(response.headers)
headers["ETag"] = etag
headers["Cache-Control"] = "no-cache"
return StarletteResponse(content=body, status_code=200, headers=headers, media_type=response.media_type)
response.headers.setdefault("Cache-Control", "no-store")
return response
@app.middleware("http")
async def api_access_log_middleware(request: Request, call_next):
context = ensure_api_context(request)
started = perf_counter()
if not API_LOGGING:
response = await call_next(request)
response.headers["X-Request-Id"] = context.request_id
response.headers["X-Response-Time"] = f"{int((perf_counter() - started) * 1000)}ms"
return response
try:
response = await call_next(request)
except Exception as exc:
duration_ms = int((perf_counter() - started) * 1000)
log_api_access(
request,
status_code=500,
duration_ms=duration_ms,
result="fail",
reason=type(exc).__name__,
)
asyncio.create_task(
record_api_access_event_background(
async_session_maker,
request,
result="fail",
reason=type(exc).__name__,
status_code=500,
)
)
raise
duration_ms = int((perf_counter() - started) * 1000)
response.headers["X-Request-Id"] = context.request_id
response.headers["X-Response-Time"] = f"{duration_ms}ms"
result = "success" if response.status_code < 400 else "fail"
log_api_access(
request,
status_code=response.status_code,
duration_ms=duration_ms,
result=result,
)
asyncio.create_task(
record_api_access_event_background(
async_session_maker,
request,
result=result,
reason=None if response.status_code < 400 else str(response.status_code),
status_code=response.status_code,
)
)
return response
@app.get("/api/health", include_in_schema=False)
async def health():
return {"status": "ok"}
from api.depends import get_session as _get_session, verify_identity_admin as _verify_admin
@app.get("/api/health/detailed", include_in_schema=False)
async def health_detailed(
session: AsyncSession = Depends(_get_session),
_identity=Depends(_verify_admin),
):
import time
from sqlalchemy import text
from core.redis_cache import _get_redis
checks: dict[str, object] = {"status": "ok", "timestamp": int(time.time())}
try:
await session.execute(text("SELECT 1"))
checks["db"] = {"ok": True}
except Exception as e:
checks["db"] = {"ok": False, "error": str(e)[:200]}
checks["status"] = "degraded"
try:
client = await _get_redis()
if client is not None:
await client.ping()
checks["redis"] = {"ok": True}
else:
checks["redis"] = {"ok": False, "error": "unavailable"}
checks["status"] = "degraded"
except Exception as e:
checks["redis"] = {"ok": False, "error": str(e)[:200]}
checks["status"] = "degraded"
return checks
app.include_router(api_router)
_web_uploads_dir = "static/web_uploads"
os.makedirs(_web_uploads_dir, exist_ok=True)
app.mount("/api/web/uploads", StaticFiles(directory=_web_uploads_dir), name="web_uploads")
-62
View File
@@ -1,62 +0,0 @@
from __future__ import annotations
from fastapi import HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import _identity_from_cookie
async def enforce_rate_limit(
request: Request,
session: AsyncSession,
*,
bucket: str,
max_per_window: int,
window_sec: int,
identity_aware: bool = True,
) -> None:
try:
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_incr_checked
except Exception:
return
owner = "anon"
if identity_aware:
try:
identity = await _identity_from_cookie(session, request)
if identity is not None and getattr(identity, "id", None):
owner = f"id:{identity.id}"
except Exception:
pass
if owner == "anon":
ip = (request.client.host if request.client else "") or "unknown"
owner = f"ip:{ip}"
key = f"rl:{bucket}:{owner}"
try:
count, redis_ok = await cache_incr_checked(key, window_sec)
if not redis_ok:
count = check_and_increment(key, max_per_window, window_sec)
except Exception:
return
if count > max_per_window:
raise HTTPException(status_code=429, detail="Слишком много запросов, подождите и попробуйте снова")
def rate_limit_dependency(*, bucket: str, max_per_window: int, window_sec: int):
from api.depends import get_session
from fastapi import Depends
async def _dep(request: Request, session: AsyncSession = Depends(get_session)) -> None:
await enforce_rate_limit(
request,
session,
bucket=bucket,
max_per_window=max_per_window,
window_sec=window_sec,
)
return _dep
-6
View File
@@ -1,6 +0,0 @@
from api.v1.router import router
VERSION = "1.0.0"
__all__ = ("router", "VERSION")
-38
View File
@@ -1,38 +0,0 @@
from fastapi import APIRouter
from api.v1.routes import (
coupons,
gifts,
keys,
management,
misc,
modules,
partners,
referrals,
servers,
settings,
tariffs,
users,
)
router = APIRouter()
@router.get("/api", include_in_schema=False)
async def root():
return {"message": "Welcome to SoloBot API", "docs": "/api/docs"}
router.include_router(users.router, prefix="/api/users", tags=["Users"])
router.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
router.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
router.include_router(misc.router, prefix="/api")
router.include_router(modules.router, prefix="/api")
router.include_router(management.router, prefix="/api/management", tags=["Management"])
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
-206
View File
@@ -1,206 +0,0 @@
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy import (
inspect as sa_inspect,
select,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.attributes import InstrumentedAttribute
from api.depends import get_session, verify_admin_token
from database.access.resolution import resolve_user_optional
from database.models import Admin
from handlers.texts import get_site_gift_link, get_telegram_gift_link
def _apply_user_relationship_loader(model: type, stmt):
if model.__name__ in ("ManualBan", "BlockedUser", "TemporaryData"):
return stmt.options(selectinload(model.user))
return stmt
def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
column_type = type(field.property.columns[0].type).__name__
if column_type in ("Integer", "BigInteger"):
return int(value)
return value
def normalize_outgoing_object(obj: object) -> None:
if hasattr(obj, "vless") and obj.vless is None:
obj.vless = False
cls_name = type(obj).__name__
if cls_name == "Gift":
gift_id = getattr(obj, "gift_id", None)
if gift_id:
obj.telegram_gift_link = get_telegram_gift_link(gift_id)
obj.site_gift_link = get_site_gift_link(gift_id)
if cls_name in ("ManualBan", "BlockedUser", "TemporaryData"):
insp = sa_inspect(obj)
stored = getattr(obj, "tg_id", None)
if "user" in insp.unloaded:
obj.tg_id = stored
return
rel = getattr(obj, "user", None)
rel_tg = getattr(rel, "tg_id", None) if rel is not None else None
obj.tg_id = stored if stored is not None else rel_tg
def to_schema(schema_response: type, obj: object):
normalize_outgoing_object(obj)
return schema_response.model_validate(obj, from_attributes=True)
def generate_crud_router(
*,
model: type,
schema_response: type,
schema_create: type,
schema_update: type,
identifier_field: str = "tg_id",
parameter_name: str = "tg_id",
extra_get_by_email: bool = False,
telegram_path_to_user_id: bool = False,
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
) -> APIRouter:
router = APIRouter()
async def _path_filter(session: AsyncSession, value: int | str):
if telegram_path_to_user_id:
u = await resolve_user_optional(session, int(value))
if u is None:
return None
return model.user_id, u.id
field = getattr(model, identifier_field)
return field, cast_identifier_type(field, value)
if "get_all" in enabled_methods:
@router.get("/", response_model=list[schema_response])
async def get_all(
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(_apply_user_relationship_loader(model, select(model)))
items = result.scalars().all()
for item in items:
normalize_outgoing_object(item)
return [schema_response.model_validate(item, from_attributes=True) for item in items]
if "get_by_email" in enabled_methods and extra_get_by_email:
@router.get("/by_email", response_model=schema_response)
async def get_by_email(
email: str = Query(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model).where(model.email == email))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Not found by email")
return to_schema(schema_response, obj)
if "get_one" in enabled_methods:
@router.get(f"/{{{parameter_name}}}", response_model=schema_response)
async def get_one(
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return to_schema(schema_response, obj)
if "get_all_by_field" in enabled_methods:
@router.get(f"/all/{{{parameter_name}}}", response_model=list[schema_response])
async def get_all_by_field(
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
objs = result.scalars().all()
if not objs:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
for obj in objs:
normalize_outgoing_object(obj)
return [schema_response.model_validate(obj, from_attributes=True) for obj in objs]
if "create" in enabled_methods:
@router.post("/", response_model=schema_response)
async def create(
payload: Any = Body(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
validated = schema_create.model_validate(payload)
data = validated.model_dump(exclude_unset=True)
if "days" in data and data["days"] == 0:
data["days"] = None
obj = model(**data)
session.add(obj)
await session.refresh(obj)
return to_schema(schema_response, obj)
if "update" in enabled_methods:
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
async def update(
payload: Any = Body(...),
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
validated = schema_update.model_validate(payload)
for k, v in validated.model_dump(exclude_unset=True).items():
setattr(obj, k, v)
await session.refresh(obj)
return to_schema(schema_response, obj)
if "delete" in enabled_methods:
@router.delete(f"/{{{parameter_name}}}", response_model=dict)
async def delete(
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
await session.delete(obj)
return {"detail": f"{model.__name__} deleted"}
return router
-16
View File
@@ -1,16 +0,0 @@
from fastapi import APIRouter
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import CouponBase, CouponResponse, CouponUpdate
from database.models import Coupon
router: APIRouter = generate_crud_router(
model=Coupon,
schema_response=CouponResponse,
schema_create=CouponBase,
schema_update=CouponUpdate,
identifier_field="code",
parameter_name="code",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
-68
View File
@@ -1,68 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from database.access.resolution import resolve_user_optional
from database.models import Admin, Gift, GiftUsage
router = APIRouter()
gift_router = generate_crud_router(
model=Gift,
schema_response=GiftResponse,
schema_create=GiftBase,
schema_update=GiftUpdate,
identifier_field="gift_id",
parameter_name="gift_id",
enabled_methods=["get_all", "get_one", "create", "update"],
)
router.include_router(gift_router, prefix="", tags=["Gifts"])
@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
async def get_gifts_by_tg_id(
tg_id: int = Path(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Gifts not found")
result = await session.execute(select(Gift).where(Gift.sender_user_id == u.id))
gifts = result.scalars().all()
if not gifts:
raise HTTPException(status_code=404, detail="Gifts not found")
return gifts
gift_usage_router = generate_crud_router(
model=GiftUsage,
schema_response=GiftUsageResponse,
schema_create=None,
schema_update=None,
identifier_field="gift_id",
enabled_methods=["get_all", "get_one", "delete"],
)
router.include_router(gift_usage_router, prefix="/usages", tags=["GiftUsages"])
router.include_router(gift_usage_router, prefix="/usages", tags=["Gifts"])
@router.delete("/{gift_id}", response_model=dict, tags=["Gifts"])
async def delete_gift_with_usages(
gift_id: str = Path(..., description="ID подарка"),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
gift = result.scalar_one_or_none()
if not gift:
raise HTTPException(status_code=404, detail="Gift not found")
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
await session.delete(gift)
return {"message": "Подарок и связанные использования удалены"}
-143
View File
@@ -1,143 +0,0 @@
from datetime import datetime
from fastapi import Body, Depends, HTTPException, Path, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from database.access.resolution import resolve_user_optional
from database.models import Admin, Key, Tariff
from logger import logger
from services.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
router = generate_crud_router(
model=Key,
schema_response=KeyResponse,
schema_create=KeyBase,
schema_update=KeyUpdate,
identifier_field="tg_id",
extra_get_by_email=True,
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
)
@router.delete("/by_email/{email}", response_model=dict)
async def delete_key_by_email(
email: str = Path(..., description="Email клиента"),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
try:
await delete_key_from_cluster(
session=session,
email=db_key.email,
client_id=db_key.client_id,
cluster_id=db_key.server_id,
)
await session.delete(db_key)
logger.info(f"[API] Ключ удалён: {db_key.client_id}")
return {"message": "Ключ успешно удалён"}
except Exception as e:
logger.error(f"[API] Ошибка при удалении ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении ключа")
@router.get("/routers/{tg_id}", response_model=list[KeyResponse])
async def get_router_keys_by_tg_id(
tg_id: int = Path(..., description="Telegram ID пользователя"),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
tariffs_result = await session.execute(select(Tariff.id).where(Tariff.group_code == "routers"))
tariff_ids = [row[0] for row in tariffs_result.all()]
if not tariff_ids:
return []
u = await resolve_user_optional(session, tg_id)
if u is None:
return []
keys_result = await session.execute(select(Key).where(Key.user_id == u.id, Key.tariff_id.in_(tariff_ids)))
keys = keys_result.scalars().all()
return keys
@router.patch("/edit/by_email/{email}", response_model=KeyResponse)
async def edit_key_by_email(
email: str = Path(..., description="Email клиента"),
key_update: KeyUpdate = Body(...),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
for field, value in key_update.model_dump(exclude_unset=True).items():
if field == "expiry_time" and value is not None:
if isinstance(value, int):
ms = value
elif isinstance(value, datetime):
ms = int(value.timestamp() * 1000)
else:
raise HTTPException(status_code=400, detail="Некорректный формат времени")
setattr(db_key, field, ms)
else:
setattr(db_key, field, value)
try:
new_expiry_time = db_key.expiry_time
await renew_key_in_cluster(
cluster_id=db_key.server_id,
email=db_key.email,
client_id=db_key.client_id,
new_expiry_time=new_expiry_time,
total_gb=getattr(db_key, "traffic_limit", None),
session=session,
hwid_device_limit=getattr(db_key, "device_limit", None),
reset_traffic=True,
)
logger.info(f"[API] Ключ обновлён: {db_key.client_id}")
return db_key
except Exception as e:
logger.error(f"[API] Ошибка при обновлении ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при обновлении ключа")
@router.post("/create", response_model=dict, status_code=status.HTTP_201_CREATED)
async def create_key_api(
payload: KeyCreateRequest = Body(...),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
try:
await create_key_on_cluster(
cluster_id=payload.cluster_id,
tg_id=payload.tg_id,
client_id=payload.client_id,
email=payload.email or f"{payload.tg_id}_key",
expiry_timestamp=payload.expiry_timestamp,
plan=payload.tariff_id,
session=session,
remnawave_link=payload.remnawave_link,
hwid_limit=payload.hwid_limit,
traffic_limit_bytes=payload.traffic_limit_bytes,
is_trial=payload.is_trial or False,
)
return {"message": "Ключ успешно создан"}
except Exception as e:
logger.error(f"[API] Ошибка при создании ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
-375
View File
@@ -1,375 +0,0 @@
import asyncio
import os
import re
import subprocess
import sys
from datetime import datetime, timezone
from typing import Literal
import psutil
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import distinct, exists, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token, verify_admin_token_short
from config import API_TOKEN, BOT_SERVICE
from core.bootstrap import MANAGEMENT_CONFIG
from core.executor import run_io
from core.settings.management_config import update_management_config
from database import async_session_maker
from database.models import Key, ScheduledBroadcast, Server, User
from database.scheduled_broadcasts import (
cancel_scheduled_broadcast,
create_scheduled_broadcast,
get_scheduled_broadcast,
list_scheduled_broadcasts,
mark_scheduled_broadcast_failed,
mark_scheduled_broadcast_sent,
start_scheduled_broadcast,
update_scheduled_broadcast,
)
from handlers.admin.sender.scheduled_service import (
ensure_utc_datetime,
execute_broadcast_payload,
execute_scheduled_broadcast,
prepare_broadcast_payload,
scheduled_broadcast_to_dict,
)
from logger import logger
from utils.backup import backup_database
router = APIRouter()
class MaintenanceUpdate(BaseModel):
enabled: bool
class DomainChange(BaseModel):
domain: str
class BroadcastLaunchPayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
channel: Literal["bot", "site", "both"] = "both"
text: str
photo: str | None = None
cluster_name: str | None = None
workers: int = 5
messages_per_second: int = 35
class ScheduledBroadcastCreatePayload(BroadcastLaunchPayload):
scheduled_for: datetime
class ScheduledBroadcastUpdatePayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] | None = None
channel: Literal["bot", "site", "both"] | None = None
text: str | None = None
photo: str | None = None
cluster_name: str | None = None
workers: int | None = None
messages_per_second: int | None = None
scheduled_for: datetime | None = None
_broadcast_bot: Bot | None = None
def _get_broadcast_bot() -> Bot:
global _broadcast_bot
if _broadcast_bot is None:
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
return _broadcast_bot
def _require_future_schedule(value: datetime) -> datetime:
scheduled_for = ensure_utc_datetime(value)
if scheduled_for <= datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="scheduled_for must be in the future")
return scheduled_for
def _resolve_update_payload(
payload: ScheduledBroadcastUpdatePayload,
current: ScheduledBroadcast,
) -> dict:
fields = payload.model_fields_set
text_changed = "text" in fields
send_to = payload.send_to if "send_to" in fields else current.send_to
channel = payload.channel if "channel" in fields else current.channel
text = payload.text if "text" in fields else current.text
photo = payload.photo if "photo" in fields else current.photo
cluster_name = payload.cluster_name if "cluster_name" in fields else current.cluster_name
workers = payload.workers if "workers" in fields else current.workers
messages_per_second = (
payload.messages_per_second if "messages_per_second" in fields else current.messages_per_second
)
prepared = prepare_broadcast_payload(
send_to=send_to,
text=text,
photo=photo,
cluster_name=cluster_name,
workers=workers,
messages_per_second=messages_per_second,
channel=channel,
)
if not text_changed:
prepared["text"] = current.text
prepared["keyboard_json"] = current.keyboard_json
if "scheduled_for" in fields:
prepared["scheduled_for"] = _require_future_schedule(payload.scheduled_for)
return prepared
async def _restart_bot() -> None:
await asyncio.sleep(1)
try:
parent = psutil.Process(os.getpid()).parent()
is_systemd = parent and "systemd" in parent.name().lower()
if is_systemd:
await run_io(lambda: subprocess.run(["sudo", "systemctl", "restart", BOT_SERVICE], check=True))
else:
python_exe = sys.executable
script_path = os.path.abspath(sys.argv[0])
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
except Exception:
os._exit(1)
@router.get("/status")
async def get_status(admin=Depends(verify_admin_token)):
return {
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
"management": dict(MANAGEMENT_CONFIG or {}),
}
@router.post("/maintenance")
async def set_maintenance(
payload: MaintenanceUpdate,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
current_config = dict(MANAGEMENT_CONFIG or {})
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
await update_management_config(session, current_config)
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
@router.post("/restart")
async def restart_bot(
background: BackgroundTasks,
admin=Depends(verify_admin_token),
):
background.add_task(_restart_bot)
return {"status": "restarting"}
@router.post("/change-domain")
async def change_domain(
payload: DomainChange,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
domain = payload.domain.strip()
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
raise HTTPException(status_code=400, detail="Invalid domain")
new_domain_url = f"https://{domain}"
stmt = (
update(Key)
.values(
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
)
.where(
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
)
)
result = await session.execute(stmt)
return {"updated": result.rowcount or 0}
@router.post("/restore-trials")
async def restore_trials(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
stmt = (
update(User)
.where(
User.trial == 1,
~exists(select(Key.user_id).where(Key.user_id == User.id)),
)
.values(trial=0)
)
result = await session.execute(stmt)
return {"restored": result.rowcount or 0}
@router.post("/backup")
async def trigger_backup(admin=Depends(verify_admin_token)):
async def _run_backup() -> None:
exception = await backup_database()
if exception:
logger.error(f"[Management] Backup finished with error: {exception}")
asyncio.create_task(_run_backup())
return {"status": "backup_started"}
@router.get("/broadcast/clusters")
async def get_broadcast_clusters(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
clusters = sorted([row[0] for row in result.all() if row and row[0]])
return {"clusters": clusters}
@router.post("/broadcast")
async def launch_broadcast(
payload: BroadcastLaunchPayload,
admin=Depends(verify_admin_token_short),
):
"""Запуск рассылки. Сессия БД не держится на время рассылки."""
try:
prepared = prepare_broadcast_payload(
send_to=payload.send_to,
text=payload.text,
photo=payload.photo,
cluster_name=payload.cluster_name,
workers=payload.workers,
messages_per_second=payload.messages_per_second,
channel=payload.channel,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return await execute_broadcast_payload(prepared, bot=_get_broadcast_bot())
@router.post("/broadcast/scheduled")
async def create_broadcast_schedule(
payload: ScheduledBroadcastCreatePayload,
admin=Depends(verify_admin_token_short),
session: AsyncSession = Depends(get_session),
):
try:
prepared = prepare_broadcast_payload(
send_to=payload.send_to,
text=payload.text,
photo=payload.photo,
cluster_name=payload.cluster_name,
workers=payload.workers,
messages_per_second=payload.messages_per_second,
channel=payload.channel,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
broadcast = await create_scheduled_broadcast(
session,
created_by_tg_id=getattr(admin, "tg_id", None),
send_to=prepared["send_to"],
channel=prepared["channel"],
cluster_name=prepared["cluster_name"],
text=prepared["text"],
photo=prepared["photo"],
keyboard_json=prepared["keyboard_json"],
scheduled_for=_require_future_schedule(payload.scheduled_for),
workers=prepared["workers"],
messages_per_second=prepared["messages_per_second"],
)
return {"success": True, "item": scheduled_broadcast_to_dict(broadcast)}
@router.get("/broadcast/scheduled")
async def list_broadcast_schedules(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
status: str | None = Query(None),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
statuses = [item.strip() for item in (status or "").split(",") if item.strip()] or None
items = await list_scheduled_broadcasts(session, statuses=statuses, limit=limit, offset=offset)
return {"items": [scheduled_broadcast_to_dict(item) for item in items], "limit": limit, "offset": offset}
@router.get("/broadcast/scheduled/{broadcast_id}")
async def get_broadcast_schedule(
broadcast_id: str,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
item = await get_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
return {"item": scheduled_broadcast_to_dict(item)}
@router.patch("/broadcast/scheduled/{broadcast_id}")
async def update_broadcast_schedule(
broadcast_id: str,
payload: ScheduledBroadcastUpdatePayload,
admin=Depends(verify_admin_token_short),
session: AsyncSession = Depends(get_session),
):
current = await get_scheduled_broadcast(session, broadcast_id)
if current is None:
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
try:
values = _resolve_update_payload(payload, current)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
updated = await update_scheduled_broadcast(session, broadcast_id, **values)
if updated is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be edited")
return {"success": True, "item": scheduled_broadcast_to_dict(updated)}
@router.post("/broadcast/scheduled/{broadcast_id}/cancel")
async def cancel_broadcast_schedule(
broadcast_id: str,
admin=Depends(verify_admin_token_short),
session: AsyncSession = Depends(get_session),
):
item = await cancel_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be cancelled")
return {"success": True, "item": scheduled_broadcast_to_dict(item)}
@router.post("/broadcast/scheduled/{broadcast_id}/send-now")
async def send_broadcast_schedule_now(
broadcast_id: str,
admin=Depends(verify_admin_token_short),
session: AsyncSession = Depends(get_session),
):
item = await start_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be sent now")
try:
result = await execute_scheduled_broadcast(item, bot=_get_broadcast_bot())
except Exception as exc:
await mark_scheduled_broadcast_failed(session, broadcast_id, str(exc))
raise HTTPException(status_code=500, detail=str(exc)) from exc
if result.get("success"):
item = await mark_scheduled_broadcast_sent(session, broadcast_id, result)
else:
item = await mark_scheduled_broadcast_failed(session, broadcast_id, result.get("message", "Broadcast failed"))
return {"success": bool(result.get("success")), "item": scheduled_broadcast_to_dict(item), "result": result}
-166
View File
@@ -1,166 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import (
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
from database import get_tracking_source_stats
from database.access.resolution import resolve_user_optional
from database.models import (
Admin,
BlockedUser,
ManualBan,
Notification,
Payment,
TemporaryData,
TrackingSource,
)
router = APIRouter()
router.include_router(
generate_crud_router(
model=Payment,
schema_response=PaymentResponse,
schema_create=None,
schema_update=None,
identifier_field="id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/payments",
tags=["Payments"],
dependencies=[Depends(verify_admin_token)],
)
@router.get("/payments/by_tg_id/{tg_id}", response_model=list[PaymentResponse], tags=["Payments"])
async def get_payments_by_tg_id(
tg_id: int = Path(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Payments not found")
result = await session.execute(select(Payment).where(Payment.user_id == u.id))
payments = result.scalars().all()
if not payments:
raise HTTPException(status_code=404, detail="Payments not found")
return payments
router.include_router(
generate_crud_router(
model=Notification,
schema_response=NotificationResponse,
schema_create=None,
schema_update=None,
identifier_field="user_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/notifications",
tags=["Notifications"],
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
generate_crud_router(
model=ManualBan,
schema_response=ManualBanResponse,
schema_create=None,
schema_update=None,
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/manual-bans",
tags=["Bans"],
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
generate_crud_router(
model=BlockedUser,
schema_response=BlockedUserResponse,
schema_create=None,
schema_update=None,
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/blocked-users",
tags=["Bans"],
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
generate_crud_router(
model=TemporaryData,
schema_response=TemporaryDataResponse,
schema_create=None,
schema_update=None,
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/temporary-data",
tags=["TemporaryData"],
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
generate_crud_router(
model=TrackingSource,
schema_response=TrackingSourceResponse,
schema_create=None,
schema_update=None,
identifier_field="id",
enabled_methods=["get_all", "delete"],
),
prefix="/tracking-sources",
tags=["TrackingSources"],
dependencies=[Depends(verify_admin_token)],
)
@router.get(
"/tracking-sources/{code}", response_model=TrackingSourceResponse, dependencies=[Depends(verify_admin_token)]
)
async def get_tracking_source_with_stats(
code: str,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(TrackingSource).where(TrackingSource.code == code))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Tracking source not found")
stats = await get_tracking_source_stats(session, code)
return TrackingSourceResponse(
id=source.id,
name=source.name,
code=source.code,
type=source.type,
created_by=source.created_by,
created_at=source.created_at,
registrations=(stats["registrations"] if stats else 0),
trials=(stats["trials"] if stats else 0),
payments=(stats["payments"] if stats else 0),
total_amount=(float(stats["total_amount"]) if stats else 0.0),
monthly=(stats["monthly"] if stats and "monthly" in stats else []),
)
-125
View File
@@ -1,125 +0,0 @@
import pkgutil
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.depends import verify_admin_token
from core.executor import run_io
from utils.modules_loader import _is_safe_module_name
from utils.modules_manager import manager
router = APIRouter(prefix="/modules", tags=["Modules"])
MODULES_DIR = Path(__file__).resolve().parents[3] / "modules"
class ModuleAction(BaseModel):
action: Literal["start", "stop", "restart"]
def _available_module_names() -> list[str]:
candidates: set[str] = set()
if MODULES_DIR.is_dir():
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
name = (name or "").strip()
if name and _is_safe_module_name(name):
candidates.add(name)
return sorted(n for n in candidates if _is_safe_module_name(n))
def _prune_missing_state(installed: set[str]) -> None:
changed = False
stale_disabled = {name for name in manager.disabled if name not in installed}
if stale_disabled:
for name in stale_disabled:
manager.disabled.discard(name)
changed = True
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
if stale_registry:
for name in stale_registry:
manager.registry.pop(name, None)
changed = True
if changed:
save_state = getattr(manager, "_save_state", None)
if callable(save_state):
save_state()
def _module_state(name: str) -> dict:
normalized = name.strip()
record = manager.registry.get(normalized)
is_enabled = manager.is_enabled(normalized)
return {
"name": normalized,
"enabled": is_enabled,
"loaded": bool(record and record.enabled),
"autostart": manager.should_autostart(normalized),
}
def _read_local_module_version(name: str) -> str | None:
version_file = MODULES_DIR / name / "VERSION"
if not version_file.exists() or not version_file.is_file():
return None
try:
with version_file.open("r", encoding="utf-8") as handle:
for line in handle:
value = line.strip()
if value:
return value
except Exception:
return None
return None
def sync_list_modules() -> list:
"""Вся синхронная работа со списком модулей (файлы, состояние). Вызывать через run_io()."""
refresh = getattr(manager, "refresh_state", None) or getattr(manager, "_load_state", None)
if callable(refresh):
refresh()
module_names = _available_module_names()
_prune_missing_state(set(module_names))
modules = [_module_state(name) for name in module_names]
for item in modules:
name = str(item.get("name") or "").strip()
item["local_version"] = _read_local_module_version(name)
return modules
@router.get("/")
async def list_modules(admin=Depends(verify_admin_token)):
modules = await run_io(sync_list_modules)
return {"items": modules}
@router.post("/{module_name}/actions")
async def control_module(module_name: str, payload: ModuleAction, admin=Depends(verify_admin_token)):
name = (module_name or "").strip()
if not _is_safe_module_name(name):
raise HTTPException(status_code=404, detail="Module not found")
try:
if payload.action == "start":
await manager.start(name)
elif payload.action == "stop":
await manager.stop(name)
elif payload.action == "restart":
await manager.restart(name)
else:
raise HTTPException(status_code=400, detail="Unsupported action")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"item": _module_state(name)}
-954
View File
@@ -1,954 +0,0 @@
import csv
from datetime import datetime
from io import StringIO
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import ORJSONResponse, StreamingResponse
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
try:
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
except Exception:
PARTNER_BONUS_PERCENTAGES = {1: 0.0}
router = APIRouter()
def _parse_percent(value: float) -> float | None:
"""Normalize percent input to 0-100 range."""
try:
val = float(value)
except (TypeError, ValueError):
return None
if 0.0 <= val <= 1.0:
val *= 100.0
if 0.0 <= val <= 100.0:
return val
return None
def _default_partner_percent() -> float:
try:
return float(PARTNER_BONUS_PERCENTAGES.get(1, 0.0)) * 100.0
except Exception:
return 0.0
def _row_dt_iso(value) -> str | None:
if isinstance(value, datetime):
return value.isoformat()
return None
@router.get("/all")
async def get_all_partners(
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список всех партнёров со статистикой.
Структура ответа:
{
"total": int,
"items": [
{
"tg_id": int,
"balance": float,
"percent": float,
"code": str | None,
"method": str | None,
"referred_count": int
}
]
}
"""
partners_sql = text(
"""
SELECT
p.partner_tg_id AS tg_id,
COALESCE(u.partner_balance, 0) AS partner_balance,
u.partner_percent,
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
u.partner_code,
u.payout_method,
COUNT(p.joined_tg_id) as joined_count
FROM partners p
LEFT JOIN users u ON u.tg_id = p.partner_tg_id
WHERE p.partner_tg_id IS NOT NULL
GROUP BY p.partner_tg_id, u.partner_balance, u.partner_percent, u.partner_percent_custom, u.partner_code, u.payout_method
ORDER BY partner_balance DESC
LIMIT :limit OFFSET :offset
"""
)
count_sql = text(
"""
SELECT COUNT(DISTINCT partner_tg_id) FROM partners
WHERE partner_tg_id IS NOT NULL
"""
)
result = await session.execute(partners_sql, {"limit": limit, "offset": offset})
partners = result.fetchall()
count_result = await session.execute(count_sql)
total = count_result.scalar() or 0
partners_list = []
default_percent = _default_partner_percent()
for partner in partners:
percent_value = partner[2]
percent_custom = bool(partner[3])
if percent_custom and percent_value is not None:
percent = float(percent_value)
else:
percent = float(default_percent)
partners_list.append({
"tg_id": int(partner[0]),
"balance": float(partner[1] or 0),
"percent": percent,
"code": partner[4] or None,
"method": partner[5] or None,
"referred_count": int(partner[6] or 0),
})
return ORJSONResponse(content={"total": total, "items": partners_list})
@router.get("/stats/all")
async def get_partners_stats(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает общую статистику партнёрской программы.
Структура ответа:
{
"total_partners": int,
"partners_today": int,
"total_referred": int,
"total_balance": float,
"top_partner_tg_id": int,
"top_partner_refs": int
}
"""
stats_sql = text(
"""
WITH partner_refs AS (
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS ref_count
FROM partners
WHERE partner_tg_id IS NOT NULL
GROUP BY partner_tg_id
)
SELECT
(SELECT COUNT(*) FROM partner_refs) AS total_partners,
(
SELECT COUNT(DISTINCT partner_tg_id)
FROM partners
WHERE partner_tg_id IS NOT NULL
AND DATE(created_at) = CURRENT_DATE
) AS partners_today,
(SELECT COUNT(DISTINCT joined_tg_id) FROM partners WHERE partner_tg_id IS NOT NULL) AS total_referred,
(
SELECT COALESCE(SUM(u.partner_balance), 0.0)
FROM users u
WHERE u.tg_id IN (SELECT partner_tg_id FROM partner_refs)
) AS total_balance,
(SELECT partner_tg_id FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_tg_id,
(SELECT ref_count FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_refs
"""
)
stats_result = await session.execute(stats_sql)
stats_row = stats_result.fetchone()
if stats_row:
stats = {
"total_partners": int(stats_row[0] or 0),
"partners_today": int(stats_row[1] or 0),
"total_referred": int(stats_row[2] or 0),
"total_balance": float(stats_row[3] or 0.0),
"top_partner_tg_id": int(stats_row[4] or 0),
"top_partner_refs": int(stats_row[5] or 0),
}
else:
stats = {
"total_partners": 0,
"partners_today": 0,
"total_referred": 0,
"total_balance": 0.0,
"top_partner_tg_id": 0,
"top_partner_refs": 0,
}
return ORJSONResponse(content=stats)
@router.patch("/{tg_id}")
async def update_partner(
tg_id: int = Path(..., description="Telegram ID партнёра"),
balance: float = Query(..., description="Новый баланс партнёра"),
percent: float = Query(..., description="Новый процент партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет данные партнёра (баланс и процент).
Структура ответа:
{
"success": bool,
"message": str
}
"""
try:
stmt = text(
"""
UPDATE users
SET partner_balance = :balance, partner_percent = :percent
WHERE tg_id = :tg_id
"""
)
result = await session.execute(stmt, {"tg_id": tg_id, "balance": balance, "percent": percent})
if result.rowcount > 0:
return ORJSONResponse(
content={"success": True, "message": f"Партнёр {tg_id} успешно обновлён"},
status_code=200,
)
else:
return ORJSONResponse(
content={"success": False, "message": "Партнёр не найден"},
status_code=404,
)
except Exception as e:
await session.rollback()
return ORJSONResponse(
content={"success": False, "message": str(e)},
status_code=500,
)
@router.get("/{tg_id}")
async def get_partner_data(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает партнёрские данные для указанного `tg_id`.
Структура ответа:
{
"tg_id": int,
"partner_balance": float,
"partner_percent": float,
"partner_code": str | None,
"payout_method": str | None,
"invited": [
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
]
}
"""
meta_sql = text(
"""
SELECT
COALESCE(u.partner_balance, 0) AS partner_balance,
u.partner_percent,
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
u.partner_code,
u.payout_method
FROM users u
WHERE u.tg_id = :tg_id
"""
)
invited_sql = text(
"""
SELECT
pr.joined_tg_id,
pr.created_at,
COALESCE(u.balance, 0) AS user_balance,
(
SELECT COUNT(*) FROM keys k
WHERE k.tg_id = pr.joined_tg_id
) AS subs_count,
(
SELECT COUNT(*) FROM payments pay
WHERE pay.tg_id = pr.joined_tg_id
AND lower(pay.status) = 'success'
) AS payments_count
FROM partners pr
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
WHERE pr.partner_tg_id = :tg_id
ORDER BY pr.created_at DESC
"""
)
meta_res = await session.execute(meta_sql, {"tg_id": tg_id})
meta_row = meta_res.fetchone()
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
invited_rows = invited_res.fetchall()
default_percent = _default_partner_percent()
percent = default_percent
if meta_row:
percent_value = meta_row[1]
percent_custom = bool(meta_row[2])
if percent_custom and percent_value is not None:
percent = float(percent_value)
response = {
"tg_id": tg_id,
"partner_balance": float(meta_row[0] or 0) if meta_row else 0.0,
"partner_percent": percent,
"partner_code": meta_row[3] if meta_row else None,
"payout_method": meta_row[4] if meta_row else None,
"invited": [
{
"tg_id": row[0],
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
"balance": float(row[2] or 0),
"subs_count": int(row[3] or 0),
"payments_count": int(row[4] or 0),
}
for row in invited_rows
],
}
return ORJSONResponse(content=response)
@router.post("/{tg_id}/invited")
async def add_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
joined_tg_id: int = Query(..., description="Telegram ID приглашённого"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Добавляет приглашённого пользователю партнёра."""
if joined_tg_id == tg_id:
return ORJSONResponse(
content={"success": False, "message": "Нельзя привязать пользователя к самому себе"},
status_code=400,
)
try:
partner_exists = await session.execute(
text("SELECT 1 FROM users WHERE tg_id = :tg_id"),
{"tg_id": tg_id},
)
if not partner_exists.scalar():
return ORJSONResponse(
content={"success": False, "message": "Партнёр не найден"},
status_code=404,
)
invited_exists = await session.execute(
text("SELECT 1 FROM users WHERE tg_id = :joined_tg_id"),
{"joined_tg_id": joined_tg_id},
)
if not invited_exists.scalar():
return ORJSONResponse(
content={"success": False, "message": "Приглашённый пользователь не найден"},
status_code=404,
)
existing = await session.execute(
text("SELECT partner_tg_id FROM partners WHERE joined_tg_id = :joined_tg_id"),
{"joined_tg_id": joined_tg_id},
)
existing_partner = existing.scalar()
if existing_partner is not None:
return ORJSONResponse(
content={
"success": False,
"message": f"Пользователь уже привязан к партнёру {existing_partner}",
},
status_code=409,
)
await session.execute(
text(
"""
INSERT INTO partners (partner_tg_id, joined_tg_id)
VALUES (:partner_tg_id, :joined_tg_id)
"""
),
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
)
return ORJSONResponse(
content={
"success": True,
"message": "Приглашённый добавлен",
"partner_tg_id": tg_id,
"joined_tg_id": joined_tg_id,
},
status_code=201,
)
except Exception as e:
await session.rollback()
return ORJSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.delete("/{tg_id}/invited/{joined_tg_id}")
async def delete_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
joined_tg_id: int = Path(..., description="Telegram ID приглашённого"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Удаляет приглашённого у партнёра."""
try:
result = await session.execute(
text(
"""
DELETE FROM partners
WHERE partner_tg_id = :partner_tg_id
AND joined_tg_id = :joined_tg_id
"""
),
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
)
if result.rowcount > 0:
return ORJSONResponse(
content={
"success": True,
"message": "Приглашённый удалён",
"partner_tg_id": tg_id,
"joined_tg_id": joined_tg_id,
},
status_code=200,
)
return ORJSONResponse(
content={"success": False, "message": "Связка партнёр-приглашённый не найдена"},
status_code=404,
)
except Exception as e:
await session.rollback()
return ORJSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.patch("/{tg_id}/percent")
async def update_partner_percent(
tg_id: int = Path(..., description="Telegram ID партнёра"),
percent: float = Query(..., description="Новый персональный процент (0-100 или 0.0-1.0)"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет персональный процент партнёра."""
normalized = _parse_percent(percent)
if normalized is None:
return ORJSONResponse(
content={"success": False, "message": "Неверный процент. Допустимо 0-100 или 0.0-1.0"},
status_code=400,
)
try:
result = await session.execute(
text(
"""
UPDATE users
SET partner_percent = :percent, partner_percent_custom = true
WHERE tg_id = :tg_id
"""
),
{"tg_id": tg_id, "percent": normalized},
)
if result.rowcount > 0:
return ORJSONResponse(
content={"success": True, "message": "Процент обновлён", "percent": normalized},
status_code=200,
)
return ORJSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
except Exception as e:
await session.rollback()
return ORJSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.patch("/{tg_id}/balance")
async def update_partner_balance(
tg_id: int = Path(..., description="Telegram ID партнёра"),
amount: float = Query(..., description="Сумма операции"),
mode: str = Query("set", description="Режим: set, add, subtract"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Изменяет баланс партнёрской программы."""
mode_normalized = (mode or "set").strip().lower()
if mode_normalized not in {"set", "add", "subtract"}:
return ORJSONResponse(
content={"success": False, "message": "Неверный режим. Используйте set, add или subtract"},
status_code=400,
)
try:
amount_val = float(amount)
except (TypeError, ValueError):
return ORJSONResponse(
content={"success": False, "message": "Неверная сумма"},
status_code=400,
)
if amount_val < 0:
return ORJSONResponse(
content={"success": False, "message": "Сумма не может быть отрицательной"},
status_code=400,
)
try:
current_res = await session.execute(
text("SELECT partner_balance FROM users WHERE tg_id = :tg_id"),
{"tg_id": tg_id},
)
current_balance = current_res.scalar()
if current_balance is None:
return ORJSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
current_balance = float(current_balance or 0.0)
if mode_normalized == "set":
new_balance = amount_val
elif mode_normalized == "add":
new_balance = current_balance + amount_val
else:
if current_balance < amount_val:
return ORJSONResponse(
content={"success": False, "message": "Недостаточно средств"},
status_code=400,
)
new_balance = current_balance - amount_val
await session.execute(
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
{"tg_id": tg_id, "balance": new_balance},
)
return ORJSONResponse(
content={"success": True, "message": "Баланс обновлён", "balance": new_balance},
status_code=200,
)
except Exception as e:
await session.rollback()
return ORJSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.get("/{tg_id}/invited")
async def get_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список приглашённых пользователей конкретного партнёра.
Структура ответа:
[
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
]
"""
invited_sql = text(
"""
SELECT
pr.joined_tg_id,
pr.created_at,
COALESCE(u.balance, 0) AS user_balance,
(
SELECT COUNT(*) FROM keys k
WHERE k.tg_id = pr.joined_tg_id
) AS subs_count,
(
SELECT COUNT(*) FROM payments pay
WHERE pay.tg_id = pr.joined_tg_id
AND lower(pay.status) = 'success'
) AS payments_count
FROM partners pr
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
WHERE pr.partner_tg_id = :tg_id
ORDER BY pr.created_at DESC
"""
)
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
invited_rows = invited_res.fetchall()
invited_list = [
{
"tg_id": row[0],
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
"balance": float(row[2] or 0),
"subs_count": int(row[3] or 0),
"payments_count": int(row[4] or 0),
}
for row in invited_rows
]
return ORJSONResponse(content=invited_list)
@router.get("/payouts/pending")
async def get_partner_payouts_pending(
limit: int = Query(50, ge=1, le=200, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
partner_tg_id: int | None = Query(None, description="Фильтр по TG ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список ожидающих заявок на вывод."""
where_clause = "WHERE pr.status = 'pending'"
params = {"limit": limit, "offset": offset}
if partner_tg_id is not None:
where_clause += " AND pr.tg_id = :partner_tg_id"
params["partner_tg_id"] = partner_tg_id
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
rows_sql = text(
f"""
SELECT
pr.id,
pr.tg_id,
pr.amount,
pr.status,
pr.created_at,
COALESCE(pr.method, u.payout_method) AS method,
COALESCE(pr.destination, u.card_number) AS destination
FROM payout_requests pr
LEFT JOIN users u ON u.tg_id = pr.tg_id
{where_clause}
ORDER BY pr.created_at ASC, pr.id ASC
LIMIT :limit OFFSET :offset
"""
)
total = await session.scalar(count_sql) or 0
result = await session.execute(rows_sql, params)
items = []
for row in result.fetchall():
items.append({
"id": int(row[0]),
"tg_id": int(row[1]),
"amount": float(row[2] or 0.0),
"status": row[3] or "pending",
"created_at": _row_dt_iso(row[4]),
"method": row[5] or None,
"destination": row[6] or None,
})
return ORJSONResponse(content={"total": int(total), "items": items})
@router.get("/payouts/history")
async def get_partner_payouts_history(
limit: int = Query(50, ge=1, le=200, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
partner_tg_id: int | None = Query(None, description="Фильтр по TG ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает историю выплат (approved/rejected)."""
where_clause = "WHERE pr.status IN ('approved','rejected')"
params = {"limit": limit, "offset": offset}
if partner_tg_id is not None:
where_clause += " AND pr.tg_id = :partner_tg_id"
params["partner_tg_id"] = partner_tg_id
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
rows_sql = text(
f"""
SELECT
pr.id,
pr.tg_id,
pr.amount,
pr.status,
pr.created_at,
COALESCE(pr.method, u.payout_method) AS method,
COALESCE(pr.destination, u.card_number) AS destination
FROM payout_requests pr
LEFT JOIN users u ON u.tg_id = pr.tg_id
{where_clause}
ORDER BY pr.created_at DESC, pr.id DESC
LIMIT :limit OFFSET :offset
"""
)
total = await session.scalar(count_sql) or 0
result = await session.execute(rows_sql, params)
items = []
for row in result.fetchall():
items.append({
"id": int(row[0]),
"tg_id": int(row[1]),
"amount": float(row[2] or 0.0),
"status": row[3] or "",
"created_at": _row_dt_iso(row[4]),
"method": row[5] or None,
"destination": row[6] or None,
})
return ORJSONResponse(content={"total": int(total), "items": items})
@router.post("/payouts/{payout_id}/approve")
async def approve_partner_payout(
payout_id: int = Path(..., description="ID заявки"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Одобряет заявку на вывод."""
req_row = await session.execute(
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
{"id": payout_id},
)
req = req_row.fetchone()
if not req:
return ORJSONResponse(
content={"success": False, "message": "Заявка не найдена или уже обработана"},
status_code=404,
)
user_row = await session.execute(
text("SELECT payout_method, card_number FROM users WHERE tg_id = :tg_id"),
{"tg_id": req[1]},
)
user = user_row.fetchone()
payout_method = (user[0] if user else None) or "card"
destination = (user[1] if user else None) or None
destination = (destination or "").strip() or None
await session.execute(
text(
"""
UPDATE payout_requests
SET status = 'approved', method = :method, destination = :destination
WHERE id = :id
"""
),
{"id": payout_id, "method": payout_method, "destination": destination},
)
return ORJSONResponse(content={"success": True, "message": "Заявка одобрена"}, status_code=200)
@router.post("/payouts/{payout_id}/reject")
async def reject_partner_payout(
payout_id: int = Path(..., description="ID заявки"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Отклоняет заявку на вывод и возвращает сумму на баланс."""
req_row = await session.execute(
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
{"id": payout_id},
)
req = req_row.fetchone()
if not req:
return ORJSONResponse(
content={"success": False, "message": "Заявка не найдена или уже обработана"},
status_code=404,
)
user_row = await session.execute(
text("SELECT payout_method, card_number, partner_balance FROM users WHERE tg_id = :tg_id"),
{"tg_id": req[1]},
)
user = user_row.fetchone()
payout_method = (user[0] if user else None) or "card"
destination = (user[1] if user else None) or None
destination = (destination or "").strip() or None
await session.execute(
text(
"""
UPDATE payout_requests
SET status = 'rejected', method = :method, destination = :destination
WHERE id = :id
"""
),
{"id": payout_id, "method": payout_method, "destination": destination},
)
if user is not None:
current_balance = float(user[2] or 0.0)
await session.execute(
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
{"balance": current_balance + float(req[2] or 0.0), "tg_id": req[1]},
)
return ORJSONResponse(content={"success": True, "message": "Заявка отклонена"}, status_code=200)
@router.patch("/{tg_id}/percent/reset")
async def reset_partner_percent(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает персональный процент партнёра к дефолту."""
result = await session.execute(
text(
"""
UPDATE users
SET partner_percent = NULL, partner_percent_custom = false
WHERE tg_id = :tg_id
"""
),
{"tg_id": tg_id},
)
if result.rowcount > 0:
return ORJSONResponse(content={"success": True, "message": "Процент сброшен"}, status_code=200)
return ORJSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
@router.patch("/{tg_id}/code")
async def update_partner_code(
tg_id: int = Path(..., description="Telegram ID партнёра"),
code: str = Query(..., description="Новый код партнёра (латиница/цифры/_)"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет код партнёрской ссылки."""
raw = (code or "").strip().lower()
if not raw:
return ORJSONResponse(content={"success": False, "message": "Код не может быть пустым"}, status_code=400)
import re
if not re.fullmatch(r"[a-z0-9_]{3,32}", raw):
return ORJSONResponse(
content={"success": False, "message": "Неверный код. Разрешены a-z, 0-9, _ (3-32 символа)"},
status_code=400,
)
exists = await session.execute(
text("SELECT 1 FROM users WHERE partner_code = :code AND tg_id != :tg_id"),
{"code": raw, "tg_id": tg_id},
)
if exists.first():
return ORJSONResponse(content={"success": False, "message": "Такой код уже занят"}, status_code=409)
result = await session.execute(
text("UPDATE users SET partner_code = :code WHERE tg_id = :tg_id"),
{"code": raw, "tg_id": tg_id},
)
if result.rowcount > 0:
return ORJSONResponse(content={"success": True, "message": "Код обновлён", "code": raw}, status_code=200)
return ORJSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
@router.post("/reset-disabled-methods")
async def reset_disabled_payout_methods(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает реквизиты для отключённых способов вывода."""
try:
from modules.partner_program import buttons as B
from modules.partner_program.settings import (
ENABLE_PAYOUT_CARD,
ENABLE_PAYOUT_SBP,
ENABLE_PAYOUT_TON,
ENABLE_PAYOUT_USDT,
)
except Exception:
ENABLE_PAYOUT_CARD = True
ENABLE_PAYOUT_USDT = True
ENABLE_PAYOUT_TON = True
ENABLE_PAYOUT_SBP = True
B = None
disabled = []
if not ENABLE_PAYOUT_CARD and B:
disabled.append(B.METHOD_CARD)
if not ENABLE_PAYOUT_USDT and B:
disabled.append(B.METHOD_USDT)
if not ENABLE_PAYOUT_TON and B:
disabled.append(B.METHOD_TON)
if not ENABLE_PAYOUT_SBP and B:
disabled.append(B.METHOD_SBP)
if not disabled:
return ORJSONResponse(content={"success": True, "message": "Отключённых методов нет"}, status_code=200)
await session.execute(
text(
"""
UPDATE users
SET card_number = NULL
WHERE payout_method = ANY(:methods)
"""
),
{"methods": disabled},
)
return ORJSONResponse(content={"success": True, "message": "Отключённые методы сброшены"}, status_code=200)
@router.get("/{tg_id}/export")
async def export_partner_invites_csv(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Экспортирует приглашённых партнёром в CSV."""
rows = await session.execute(
text(
"""
SELECT joined_tg_id, created_at
FROM partners
WHERE partner_tg_id = :tg_id
ORDER BY created_at ASC
"""
),
{"tg_id": tg_id},
)
data = rows.fetchall()
if not data:
return ORJSONResponse(content={"success": False, "message": "Нет приглашённых"}, status_code=404)
buffer = StringIO()
writer = csv.writer(buffer, delimiter=";")
writer.writerow(["joined_tg_id", "created_at"])
for joined_tg_id, created_at in data:
writer.writerow([int(joined_tg_id), created_at.isoformat() if created_at else ""])
content = buffer.getvalue().encode("utf-8-sig")
filename = f"partner_invites_{tg_id}.csv"
return StreamingResponse(
iter([content]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
-45
View File
@@ -1,45 +0,0 @@
from fastapi import Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import ReferralResponse
from database.access.resolution import resolve_user_optional
from database.models import Admin, Referral
router = generate_crud_router(
model=Referral,
schema_response=ReferralResponse,
schema_create=None,
schema_update=None,
identifier_field="referrer_user_id",
parameter_name="referrer_tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "get_all_by_field"],
)
@router.delete("/one")
async def delete_one_referral(
referrer_tg_id: int = Query(..., description="ID пригласившего"),
referred_tg_id: int = Query(..., description="ID приглашённого"),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
ru_ref = await resolve_user_optional(session, referrer_tg_id)
rd_ref = await resolve_user_optional(session, referred_tg_id)
if ru_ref is None or rd_ref is None:
raise HTTPException(status_code=404, detail="Referral not found")
result = await session.execute(
select(Referral).where(
Referral.referrer_user_id == ru_ref.id,
Referral.referred_user_id == rd_ref.id,
)
)
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Referral not found")
await session.delete(obj)
return {"status": "deleted_one"}
-16
View File
@@ -1,16 +0,0 @@
from fastapi import APIRouter
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import ServerBase, ServerResponse, ServerUpdate
from database.models import Server
router: APIRouter = generate_crud_router(
model=Server,
schema_response=ServerResponse,
schema_create=ServerBase,
schema_update=ServerUpdate,
identifier_field="server_name",
parameter_name="server_name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
-151
View File
@@ -1,151 +0,0 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.v1.schemas.settings import SettingResponse, SettingUpsert
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
from core.settings.modes_config import MODES_CONFIG, update_modes_config
from core.settings.money_config import MONEY_CONFIG, update_money_config
from core.settings.notifications_config import NOTIFICATIONS_CONFIG, update_notifications_config
from core.settings.payments_config import PAYMENTS_CONFIG, update_payments_config
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
from database.models import Setting
from database.settings import set_setting
from database.settings_cache import settings_cache
router = APIRouter()
class ConfigUpdatePayload(BaseModel):
value: dict[str, Any] | None = None
@router.get("/", response_model=list[SettingResponse])
async def get_all_settings(admin=Depends(verify_admin_token)):
"""Список всех настроек (из кэша, без запроса к БД)."""
return settings_cache.get_all()
@router.get("/configs")
async def get_configs(admin=Depends(verify_admin_token)):
return {
"payments": dict(PAYMENTS_CONFIG),
"buttons": dict(BUTTONS_CONFIG),
"notifications": dict(NOTIFICATIONS_CONFIG),
"modes": dict(MODES_CONFIG),
"money": dict(MONEY_CONFIG),
"providers_order": dict(PROVIDERS_ORDER),
"tariffs": dict(TARIFFS_CONFIG),
}
@router.post("/configs/{scope}")
async def update_config_scope(
scope: str,
payload: ConfigUpdatePayload,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
data = dict(payload.value or {})
normalized = scope.strip().lower().replace("-", "_")
if normalized == "payments":
cleaned = {key: bool(value) for key, value in data.items()}
await update_payments_config(session, cleaned)
return {"payments": dict(PAYMENTS_CONFIG)}
if normalized == "buttons":
cleaned = {key: bool(value) for key, value in data.items()}
await update_buttons_config(session, cleaned)
return {"buttons": dict(BUTTONS_CONFIG)}
if normalized == "notifications":
await update_notifications_config(session, data)
return {"notifications": dict(NOTIFICATIONS_CONFIG)}
if normalized == "modes":
cleaned = {key: bool(value) for key, value in data.items()}
await update_modes_config(session, cleaned)
return {"modes": dict(MODES_CONFIG)}
if normalized == "money":
await update_money_config(session, data)
return {"money": dict(MONEY_CONFIG)}
if normalized == "providers_order":
cleaned: dict[str, int] = {}
for key, value in data.items():
try:
cleaned[key] = int(value)
except (TypeError, ValueError):
continue
await update_providers_order(session, cleaned)
return {"providers_order": dict(PROVIDERS_ORDER)}
if normalized == "tariffs":
cleaned = dict(data)
if "ALLOW_DOWNGRADE" in cleaned:
cleaned["ALLOW_DOWNGRADE"] = bool(cleaned.get("ALLOW_DOWNGRADE"))
if "KEY_ADDONS_RECALC_PRICE" in cleaned:
cleaned["KEY_ADDONS_RECALC_PRICE"] = bool(cleaned.get("KEY_ADDONS_RECALC_PRICE"))
if "KEY_ADDONS_PACK_MODE" in cleaned:
mode = str(cleaned.get("KEY_ADDONS_PACK_MODE") or "").strip().lower()
cleaned["KEY_ADDONS_PACK_MODE"] = mode if mode in {"", "traffic", "devices", "all"} else ""
await update_tariffs_config(session, cleaned)
return {"tariffs": dict(TARIFFS_CONFIG)}
raise HTTPException(status_code=404, detail="Unsupported config scope")
@router.get("/{key}", response_model=SettingResponse)
async def get_setting_by_key(key: str, admin=Depends(verify_admin_token)):
"""Настройка по ключу (из кэша, без запроса к БД)."""
obj = settings_cache.get(key)
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
return obj
@router.post("/{key}", response_model=SettingResponse)
async def upsert_setting(
key: str,
payload: SettingUpsert,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
obj = await set_setting(
session=session,
key=key,
value=payload.value,
description=payload.description,
)
await session.refresh(obj)
settings_cache.update(
key,
obj.value,
obj.description,
created_at=getattr(obj, "created_at", None),
updated_at=getattr(obj, "updated_at", None),
)
return obj
@router.delete("/{key}", response_model=dict)
async def delete_setting(
key: str,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Setting).where(Setting.key == key))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
await session.delete(obj)
settings_cache.delete(key)
return {"detail": "Setting deleted"}
-16
View File
@@ -1,16 +0,0 @@
from fastapi import APIRouter
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import TariffBase, TariffResponse, TariffUpdate
from database.models import Tariff
router: APIRouter = generate_crud_router(
model=Tariff,
schema_response=TariffResponse,
schema_create=TariffBase,
schema_update=TariffUpdate,
identifier_field="name",
parameter_name="name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
-64
View File
@@ -1,64 +0,0 @@
import asyncio
from fastapi import Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas.users import UserBase, UserResponse, UserUpdate
from database import async_session_maker, delete_user_data, get_servers
from database.access.resolution import resolve_user_optional
from database.models import Key, User
from logger import logger
from services.operations import delete_key_from_cluster
router = generate_crud_router(
model=User,
schema_response=UserResponse,
schema_create=UserBase,
schema_update=UserUpdate,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "get_by_email", "create", "update"],
)
@router.delete("/{tg_id}", response_model=dict)
async def delete_user(
tg_id: int = Path(..., description="Telegram ID пользователя"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
try:
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
key_records = result.all()
async with async_session_maker() as s:
servers = await get_servers(session=s)
cluster_ids = list(servers.keys())
async def _delete_one(cluster_id: str, email: str, client_id: str):
async with async_session_maker() as s:
await delete_key_from_cluster(cluster_id, email, client_id, s)
try:
tasks = [
_delete_one(cluster_id, email, client_id)
for email, client_id in key_records
for cluster_id in cluster_ids
]
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
await delete_user_data(session, tg_id)
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
-16
View File
@@ -1,16 +0,0 @@
from .coupons import CouponBase, CouponResponse, CouponUpdate, CouponUsageResponse
from .gifts import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from .keys import KeyDetailsResponse, KeyResponse
from .misc import (
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
from .referrals import ReferralResponse
from .servers import ServerBase, ServerResponse, ServerUpdate
from .settings import SettingResponse, SettingUpsert
from .tariffs import TariffBase, TariffResponse, TariffUpdate
from .users import UserBase, UserResponse, UserUpdate
-97
View File
@@ -1,97 +0,0 @@
from datetime import datetime
from pydantic import BaseModel, Field, model_validator
class CouponBase(BaseModel):
code: str
amount: int | None = Field(default=None)
usage_limit: int
usage_count: int = 0
is_used: bool = False
days: int | None = Field(default=None)
percent: int | None = Field(default=None)
max_discount_amount: int | None = Field(default=None)
min_order_amount: int | None = Field(default=None)
new_users_only: bool = False
@model_validator(mode="after")
def check_coupon_type(self) -> "CouponBase":
has_amount = self.amount not in (None, 0)
has_days = self.days is not None
has_percent = self.percent is not None
provided = int(has_amount) + int(has_days) + int(has_percent)
if provided != 1:
raise ValueError("Coupon must have exactly one of: 'amount', 'days', 'percent'")
if has_days and self.days is not None and self.days <= 0:
raise ValueError("'days' must be > 0")
if has_percent and self.percent is not None and not (1 <= self.percent <= 100):
raise ValueError("'percent' must be between 1 and 100")
if has_percent:
if self.min_order_amount is not None and self.min_order_amount < 0:
raise ValueError("'min_order_amount' must be >= 0")
if self.max_discount_amount is not None and self.max_discount_amount < 0:
raise ValueError("'max_discount_amount' must be >= 0")
return self
class CouponResponse(CouponBase):
id: int
class Config:
from_attributes = True
class CouponUpdate(BaseModel):
code: str | None = None
amount: int | None = None
usage_limit: int | None = None
usage_count: int | None = None
is_used: bool | None = None
days: int | None = Field(default=None)
percent: int | None = None
max_discount_amount: int | None = None
min_order_amount: int | None = None
new_users_only: bool | None = None
@model_validator(mode="after")
def validate_coupon_update(self) -> "CouponUpdate":
has_amount = self.amount not in (None, 0)
has_days = self.days is not None
has_percent = self.percent is not None
provided = int(has_amount) + int(has_days) + int(has_percent)
if provided > 1:
raise ValueError("Specify only one of: 'amount', 'days', 'percent'")
if has_days and self.days is not None and self.days <= 0:
raise ValueError("'days' must be > 0")
if has_percent and self.percent is not None and not (1 <= self.percent <= 100):
raise ValueError("'percent' must be between 1 and 100")
if has_percent:
if self.min_order_amount is not None and self.min_order_amount < 0:
raise ValueError("'min_order_amount' must be >= 0")
if self.max_discount_amount is not None and self.max_discount_amount < 0:
raise ValueError("'max_discount_amount' must be >= 0")
return self
class CouponUsageResponse(BaseModel):
coupon_id: int
user_id: int
used_at: datetime
class Config:
from_attributes = True
-50
View File
@@ -1,50 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
class GiftBase(BaseModel):
sender_user_id: int
recipient_user_id: int | None = None
selected_months: int | None = None
expiry_time: datetime
gift_link: str
telegram_gift_link: str | None = None
site_gift_link: str | None = None
is_used: bool = False
is_unlimited: bool | None = False
max_usages: int | None = None
tariff_id: int | None = None
class GiftResponse(GiftBase):
gift_id: str
created_at: datetime
class Config:
from_attributes = True
class GiftUsageResponse(BaseModel):
gift_id: str
tg_id: int
used_at: datetime
class Config:
from_attributes = True
class GiftUpdate(BaseModel):
recipient_user_id: int | None = None
selected_months: int | None = None
expiry_time: datetime | None = None
gift_link: str | None = None
telegram_gift_link: str | None = None
site_gift_link: str | None = None
is_used: bool | None = None
is_unlimited: bool | None = None
max_usages: int | None = None
tariff_id: int | None = None
class Config:
from_attributes = True
-98
View File
@@ -1,98 +0,0 @@
from pydantic import BaseModel, Field
class KeyBase(BaseModel):
user_id: int
client_id: str
tg_id: int | None = None
email: str | None = None
created_at: int | None = None
expiry_time: int
key: str | None = None
server_id: str | None = None
remnawave_link: str | None = None
tariff_id: int | None = None
is_frozen: bool | None = False
alias: str | None = None
notified: bool | None = False
notified_24h: bool | None = False
selected_device_limit: int | None = None
selected_traffic_limit: int | None = None
selected_price_rub: int | None = None
current_device_limit: int | None = None
current_traffic_limit: int | None = None
class KeyResponse(KeyBase):
class Config:
from_attributes = True
class KeyDetailsResponse(BaseModel):
key: str | None
remnawave_link: str | None
server_id: str | None
created_at: int | None
expiry_time: int | None
client_id: str
tg_id: int
email: str | None
is_frozen: bool
balance: float
alias: str | None
expiry_date: str
days_left_message: str
link: str | None
cluster_name: str | None
location_name: str | None
tariff_id: int | None
selected_device_limit: int | None = None
selected_traffic_limit: int | None = None
selected_price_rub: int | None = None
current_device_limit: int | None = None
current_traffic_limit: int | None = None
class Config:
from_attributes = True
class KeyUpdate(BaseModel):
email: str | None = None
expiry_time: int | None = None
key: str | None = None
server_id: str | None = None
remnawave_link: str | None = None
tariff_id: int | None = None
is_frozen: bool | None = None
alias: str | None = None
notified: bool | None = None
notified_24h: bool | None = None
selected_device_limit: int | None = None
selected_traffic_limit: int | None = None
selected_price_rub: int | None = None
current_device_limit: int | None = None
current_traffic_limit: int | None = None
class Config:
from_attributes = True
class KeyCreateRequest(BaseModel):
tg_id: int = Field(..., description="Telegram ID пользователя")
cluster_id: str = Field(..., description="Имя кластера или сервера")
tariff_id: int = Field(..., description="ID тарифа из базы данных")
client_id: str = Field(..., description="UUID клиента (уникальный)")
expiry_timestamp: int = Field(..., description="Срок окончания в миллисекундах")
email: str | None = Field(None, description="Условное имя подписки")
alias: str | None = Field(None, description="пользовательское имя")
remnawave_link: str | None = Field(None, description="Ссылка на подписку Remnawave")
hwid_limit: int | None = Field(None, description="Ограничение по HWID")
traffic_limit_bytes: int | None = Field(None, description="Ограничение трафика в байтах")
is_trial: bool | None = Field(False, description="Флаг триального ключа")
-128
View File
@@ -1,128 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
class PaymentBase(BaseModel):
user_id: int
tg_id: int | None = None
amount: float
payment_system: str
status: str
class PaymentResponse(PaymentBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class ReferralResponse(BaseModel):
referred_user_id: int
referrer_user_id: int
reward_issued: bool = False
class Config:
from_attributes = True
class NotificationResponse(BaseModel):
tg_id: int
notification_type: str
last_notification_time: datetime
class Config:
from_attributes = True
class GiftBase(BaseModel):
sender_user_id: int
recipient_user_id: int | None = None
selected_months: int
expiry_time: datetime
gift_link: str
telegram_gift_link: str | None = None
site_gift_link: str | None = None
is_used: bool = False
is_unlimited: bool = False
max_usages: int | None = None
tariff_id: int | None = None
class GiftResponse(GiftBase):
gift_id: str
created_at: datetime
class Config:
from_attributes = True
class GiftUsageResponse(BaseModel):
gift_id: str
tg_id: int
used_at: datetime
class Config:
from_attributes = True
class ManualBanResponse(BaseModel):
user_id: int
tg_id: int | None = None
banned_at: datetime
reason: str
banned_by: int
until: datetime | None = None
class Config:
from_attributes = True
class TemporaryDataResponse(BaseModel):
user_id: int
tg_id: int | None = None
state: str
data: dict
updated_at: datetime
class Config:
from_attributes = True
class BlockedUserResponse(BaseModel):
user_id: int
tg_id: int | None = None
class Config:
from_attributes = True
class MonthlyStats(BaseModel):
month: str
registrations: int
trials: int
new_purchases_count: int
new_purchases_amount: float
repeat_purchases_count: int
repeat_purchases_amount: float
class TrackingSourceResponse(BaseModel):
id: int
name: str
code: str
type: str
created_by: int
created_at: datetime
registrations: int = 0
trials: int = 0
payments: int = 0
total_amount: float = 0.0
monthly: list[MonthlyStats] = []
class Config:
from_attributes = True
-10
View File
@@ -1,10 +0,0 @@
from pydantic import BaseModel
class ReferralResponse(BaseModel):
referred_user_id: int
referrer_user_id: int
reward_issued: bool = False
class Config:
from_attributes = True
-35
View File
@@ -1,35 +0,0 @@
from pydantic import BaseModel
class ServerBase(BaseModel):
cluster_name: str
server_name: str
api_url: str
subscription_url: str | None = None
inbound_id: str
panel_type: str
max_keys: int | None = None
tariff_group: str | None = ""
enabled: bool = True
class ServerResponse(ServerBase):
id: int
class Config:
from_attributes = True
class ServerUpdate(BaseModel):
cluster_name: str | None = None
server_name: str | None = None
api_url: str | None = None
subscription_url: str | None = None
inbound_id: str | None = None
panel_type: str | None = None
max_keys: int | None = None
tariff_group: str | None = None
enabled: bool | None = None
class Config:
from_attributes = True
-20
View File
@@ -1,20 +0,0 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class SettingUpsert(BaseModel):
value: Any | None = None
description: str | None = None
class SettingResponse(BaseModel):
key: str
value: Any | None = None
description: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
-66
View File
@@ -1,66 +0,0 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class TariffBase(BaseModel):
name: str
group_code: str
duration_days: int
price_rub: int
traffic_limit: int | None = None
device_limit: int | None = None
is_active: bool = True
subgroup_title: str | None = None
sort_order: int | None = None
vless: bool = False
external_squad: str | None = None
configurable: bool = False
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
device_step_rub: int | None = None
device_overrides: dict[str, int] | None = None
traffic_step_rub: int | None = None
traffic_overrides: dict[str, int] | None = None
class TariffResponse(TariffBase):
id: int
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
class TariffUpdate(BaseModel):
name: str | None = None
group_code: str | None = None
duration_days: int | None = None
price_rub: int | None = None
traffic_limit: int | None = None
device_limit: int | None = None
is_active: bool | None = None
subgroup_title: str | None = None
sort_order: int | None = None
vless: bool | None = None
external_squad: str | None = None
configurable: bool | None = None
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
device_step_rub: int | None = None
device_overrides: dict[str, int] | None = None
traffic_step_rub: int | None = None
traffic_overrides: dict[str, int] | None = None
class Config:
from_attributes = True
-37
View File
@@ -1,37 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
class UserBase(BaseModel):
tg_id: int
username: str | None = None
first_name: str | None = None
last_name: str | None = None
language_code: str | None = None
is_bot: bool | None = False
balance: float | None = 0.0
trial: int | None = 0
source_code: str | None = None
class UserResponse(UserBase):
created_at: datetime | None
updated_at: datetime | None
class Config:
from_attributes = True
class UserUpdate(BaseModel):
username: str | None = None
first_name: str | None = None
last_name: str | None = None
language_code: str | None = None
is_bot: bool | None = None
balance: float | None = None
trial: int | None = None
source_code: str | None = None
class Config:
from_attributes = True
-10
View File
@@ -1,10 +0,0 @@
VERSION = "2.0.0"
__all__ = ("router", "VERSION")
def __getattr__(name: str):
if name == "router":
from api.v2.router import router
return router
raise AttributeError(name)
-171
View File
@@ -1,171 +0,0 @@
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute
from api.depends import get_session, verify_identity_admin
from api.v1.routes.base_crud import (
_apply_user_relationship_loader,
cast_identifier_type,
normalize_outgoing_object,
to_schema,
)
from database.access.resolution import resolve_user_optional
def generate_crud_router(
*,
model: type,
schema_response: type,
schema_create: type,
schema_update: type,
identifier_field: str = "tg_id",
parameter_name: str = "tg_id",
extra_get_by_email: bool = False,
telegram_path_to_user_id: bool = False,
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
) -> APIRouter:
router = APIRouter()
async def _path_filter(session: AsyncSession, value: int | str):
if telegram_path_to_user_id:
u = await resolve_user_optional(session, int(value))
if u is None:
return None
return model.user_id, u.id
field = getattr(model, identifier_field)
return field, cast_identifier_type(field, value)
if "get_all" in enabled_methods:
@router.get("/", response_model=list[schema_response])
async def get_all(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(_apply_user_relationship_loader(model, select(model)))
items = result.scalars().all()
for item in items:
normalize_outgoing_object(item)
return [schema_response.model_validate(item, from_attributes=True) for item in items]
if "get_by_email" in enabled_methods and extra_get_by_email:
@router.get("/by_email", response_model=schema_response)
async def get_by_email(
email: str = Query(...),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model).where(model.email == email))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Not found by email")
return to_schema(schema_response, obj)
if "get_one" in enabled_methods:
@router.get(f"/{{{parameter_name}}}", response_model=schema_response)
async def get_one(
value: int | str = Path(..., alias=parameter_name),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return to_schema(schema_response, obj)
if "get_all_by_field" in enabled_methods:
@router.get(f"/all/{{{parameter_name}}}", response_model=list[schema_response])
async def get_all_by_field(
value: int | str = Path(..., alias=parameter_name),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
objs = result.scalars().all()
if not objs:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
for obj in objs:
normalize_outgoing_object(obj)
return [schema_response.model_validate(obj, from_attributes=True) for obj in objs]
if "create" in enabled_methods:
@router.post("/", response_model=schema_response)
async def create(
payload: Any = Body(...),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
validated = schema_create.model_validate(payload)
data = validated.model_dump(exclude_unset=True)
if "days" in data and data["days"] == 0:
data["days"] = None
obj = model(**data)
session.add(obj)
await session.commit()
await session.refresh(obj)
return to_schema(schema_response, obj)
if "update" in enabled_methods:
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
async def update(
payload: Any = Body(...),
value: int | str = Path(..., alias=parameter_name),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
validated = schema_update.model_validate(payload)
for k, v in validated.model_dump(exclude_unset=True).items():
setattr(obj, k, v)
await session.commit()
await session.refresh(obj)
return to_schema(schema_response, obj)
if "delete" in enabled_methods:
@router.delete(f"/{{{parameter_name}}}", response_model=dict)
async def delete(
value: int | str = Path(..., alias=parameter_name),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
await session.delete(obj)
await session.commit()
return {"detail": f"{model.__name__} deleted"}
return router
__all__ = ("generate_crud_router", "to_schema", "normalize_outgoing_object", "cast_identifier_type")
-49
View File
@@ -1,49 +0,0 @@
from fastapi import APIRouter
from api.v2.routes import (
auth,
coupons,
flows,
gifts,
identities,
keys,
management,
misc,
modules,
notifications,
partners,
payment_links,
referrals,
root_router,
servers,
settings,
tariffs,
users,
web,
)
router = APIRouter()
router.include_router(root_router)
router.include_router(auth.router, prefix="/api")
router.include_router(users.router, prefix="/api/users", tags=["Users"])
router.include_router(keys.user_router, prefix="/api/keys", tags=["Keys"])
router.include_router(keys.router, prefix="/api/admin/keys", tags=["AdminKeys"])
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
router.include_router(tariffs.public_router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(tariffs.user_tariff_router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
router.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
router.include_router(payment_links.router, prefix="/api/payment-links", tags=["PaymentLinks"])
router.include_router(identities.router, prefix="/api/identities", tags=["Identities"])
router.include_router(misc.router, prefix="/api")
router.include_router(modules.router, prefix="/api")
router.include_router(management.router, prefix="/api/management", tags=["Management"])
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
router.include_router(web.router, prefix="", tags=["Web"])
router.include_router(flows.router, prefix="/api", tags=["Flows"])
router.include_router(notifications.router, prefix="/api", tags=["Notifications"])
-9
View File
@@ -1,9 +0,0 @@
__all__ = ("root_router",)
def __getattr__(name: str):
if name == "root_router":
from api.v2.routes.root import router
return router
raise AttributeError(name)
-115
View File
@@ -1,115 +0,0 @@
from __future__ import annotations
import base64
import re
import uuid
from pathlib import Path
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
UPLOAD_DIR = Path("static/web_uploads")
DATA_URI_THRESHOLD_BYTES = 2048
_DATA_URI_RE = re.compile(r"^data:([\w./+-]+);base64,(.+)$", re.DOTALL)
_MIME_TO_EXT = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
"video/mp4": ".mp4",
"video/webm": ".webm",
}
def _save_data_uri_to_file(data_uri: str) -> str | None:
match = _DATA_URI_RE.match(data_uri)
if not match:
return None
mime = match.group(1).strip().lower()
payload = match.group(2)
ext = _MIME_TO_EXT.get(mime)
if not ext:
return None
try:
cleaned = "".join(payload.split())
decoded = base64.b64decode(cleaned, validate=False)
except Exception:
return None
if not decoded:
return None
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
name = f"{uuid.uuid4().hex}{ext}"
(UPLOAD_DIR / name).write_bytes(decoded)
return f"/api/web/uploads/{name}"
def migrate_json_data_uris(value: Any) -> tuple[Any, int]:
replaced = 0
def walk(node: Any) -> Any:
nonlocal replaced
if isinstance(node, str):
if not node.startswith("data:"):
return node
if len(node) < DATA_URI_THRESHOLD_BYTES:
return node
url = _save_data_uri_to_file(node)
if url is None:
return node
replaced += 1
return url
if isinstance(node, list):
return [walk(item) for item in node]
if isinstance(node, dict):
return {key: walk(item) for key, item in node.items()}
return node
return walk(value), replaced
async def run_startup_data_uri_migration(session: AsyncSession) -> tuple[int, int]:
from database.models import (
WebBlock,
WebPageVariant,
WebPageVariantBlock,
WebTheme,
)
rows_updated = 0
uris_replaced = 0
for theme in (await session.execute(select(WebTheme))).scalars().all():
cleaned, replaced = migrate_json_data_uris(theme.tokens or {})
if replaced:
theme.tokens = cleaned
rows_updated += 1
uris_replaced += replaced
for variant in (await session.execute(select(WebPageVariant))).scalars().all():
cleaned, replaced = migrate_json_data_uris(variant.theme_tokens or {})
if replaced:
variant.theme_tokens = cleaned
rows_updated += 1
uris_replaced += replaced
for block in (await session.execute(select(WebBlock))).scalars().all():
cleaned, replaced = migrate_json_data_uris(block.data or {})
if replaced:
block.data = cleaned
rows_updated += 1
uris_replaced += replaced
for block in (await session.execute(select(WebPageVariantBlock))).scalars().all():
cleaned, replaced = migrate_json_data_uris(block.data or {})
if replaced:
block.data = cleaned
rows_updated += 1
uris_replaced += replaced
return rows_updated, uris_replaced
-15
View File
@@ -1,15 +0,0 @@
from fastapi import APIRouter
from api.v2.routes.auth import email_verify, google, link, password, session, telegram, yandex
router = APIRouter(prefix="/auth", tags=["Auth"])
router.include_router(password.router)
router.include_router(telegram.router)
router.include_router(google.router)
router.include_router(yandex.router)
router.include_router(link.router)
router.include_router(email_verify.router)
router.include_router(session.router)
__all__ = ["router"]
-144
View File
@@ -1,144 +0,0 @@
from fastapi import Request
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from api.v2.schemas.identities import IdentityResponse, LoginResponse
from config import API_TOKEN_TTL_DAYS
from logger import logger
from utils.referral_codes import encode_partner_code
TOKEN_TTL_HINT = "бессрочно" if API_TOKEN_TTL_DAYS is None else f"{API_TOKEN_TTL_DAYS} дн."
TELEGRAM_LOGIN_MAX_AGE = 86400
def build_login_response(identity) -> LoginResponse:
return LoginResponse(
identity_id=identity.id,
identity=IdentityResponse.model_validate(identity),
)
_TRUSTED_PROXY_CIDRS: list[str] = []
def _client_ip(request: Request) -> str:
client_host = (request.client.host if request.client else "") or ""
forwarded = request.headers.get("x-forwarded-for") or request.headers.get("X-Forwarded-For")
if not forwarded:
return client_host
if not _TRUSTED_PROXY_CIDRS and client_host not in ("127.0.0.1", "::1"):
return client_host
return forwarded.split(",")[0].strip() or client_host
async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int) -> dict[str, object]:
partner_feature_enabled = False
default_percent = 0.0
try:
from modules.partner_program import settings as partner_settings
partner_feature_enabled = True
raw_percent = getattr(partner_settings, "PARTNER_BONUS_PERCENTAGES", {}).get(1, 0.0)
default_percent = float(raw_percent) * 100.0
except Exception:
partner_feature_enabled = False
default_percent = 0.0
payload: dict[str, object] = {
"partner_enabled": partner_feature_enabled,
"partner_code": "",
"partner_balance": 0.0,
"partner_percent": default_percent,
"partner_percent_custom": False,
"partner_referred_total": 0,
"partner_referred_paid": 0,
"partner_payout_method": None,
}
try:
partner_row = (
await session.execute(
text(
"""
SELECT
tg_id,
COALESCE(partner_balance, 0),
partner_percent,
COALESCE(partner_percent_custom, false),
partner_code,
payout_method
FROM users
WHERE id = :user_id
LIMIT 1
"""
),
{"user_id": int(billing_user_id)},
)
).first()
except Exception:
partner_row = None
if partner_row is None:
return payload
tg_id = int(partner_row[0]) if partner_row[0] is not None else None
balance = float(partner_row[1] or 0.0)
percent_raw = partner_row[2]
percent_custom = bool(partner_row[3])
percent_value = float(percent_raw) if (percent_custom and percent_raw is not None) else float(default_percent)
code = str(partner_row[4] or "").strip()
if (not code or code.isdigit() or code.startswith("r1_")) and int(billing_user_id) > 0:
generated_code = encode_partner_code(int(billing_user_id))
code = generated_code
try:
await session.execute(
text("UPDATE users SET partner_code = :code WHERE id = :id"),
{"code": generated_code, "id": int(billing_user_id)},
)
await session.flush()
except Exception as e:
logger.warning("[Auth] Ошибка сохранения partner_code для billing_user_id={}: {}", billing_user_id, e)
payout_method = str(partner_row[5] or "").strip() or None
referred_total = 0
referred_paid = 0
if tg_id is not None:
try:
referred_total = int(
(
await session.execute(
text("SELECT COUNT(*) FROM partners WHERE partner_tg_id = :tg_id"),
{"tg_id": int(tg_id)},
)
).scalar()
or 0
)
except Exception:
referred_total = 0
try:
referred_paid = int(
(
await session.execute(
text(
"SELECT COUNT(DISTINCT pr.joined_tg_id) "
"FROM partners pr "
"WHERE pr.partner_tg_id = :tg_id "
"AND EXISTS ("
" SELECT 1 FROM payments pay "
" WHERE pay.tg_id = pr.joined_tg_id "
" AND lower(pay.status) = 'success'"
")"
),
{"tg_id": int(tg_id)},
)
).scalar()
or 0
)
except Exception:
referred_paid = 0
payload.update({
"partner_enabled": bool(partner_feature_enabled or code or referred_total > 0 or balance > 0),
"partner_code": code,
"partner_balance": balance,
"partner_percent": percent_value,
"partner_percent_custom": percent_custom,
"partner_referred_total": referred_total,
"partner_referred_paid": referred_paid,
"partner_payout_method": payout_method,
})
return payload
-44
View File
@@ -1,44 +0,0 @@
"""In-memory rate limit fallback для случаев когда Redis недоступен.
Используется только когда Redis не ответил — чтобы критичные auth-эндпоинты
не теряли защиту при кратковременных Redis-сбоях. Per-process, не шарится
между репликами — поэтому на нескольких инстансах лимит будет N*limit.
"""
import time
from collections import deque
from threading import Lock
_BUCKETS: dict[str, deque[float]] = {}
_LOCK = Lock()
_MAX_KEYS = 10000
def _prune(bucket: deque[float], window_sec: int) -> None:
threshold = time.monotonic() - window_sec
while bucket and bucket[0] < threshold:
bucket.popleft()
def _evict_if_full() -> None:
if len(_BUCKETS) < _MAX_KEYS:
return
now = time.monotonic()
dead = [k for k, b in _BUCKETS.items() if not b or b[-1] < now - 3600]
for k in dead:
del _BUCKETS[k]
if len(_BUCKETS) >= _MAX_KEYS:
oldest = min(_BUCKETS.keys(), key=lambda k: _BUCKETS[k][0] if _BUCKETS[k] else 0)
del _BUCKETS[oldest]
def check_and_increment(key: str, limit: int, window_sec: int) -> int:
"""Возвращает текущее значение счётчика после инкремента. Если >= limit — превышение."""
with _LOCK:
_evict_if_full()
bucket = _BUCKETS.setdefault(key, deque())
_prune(bucket, window_sec)
bucket.append(time.monotonic())
return len(bucket)
-78
View File
@@ -1,78 +0,0 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import (
BaseModel,
Field as PydanticField,
)
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from api.v2.routes.auth._common import _client_ip
from mail import send_email_verify_code_email, smtp_configured
from utils import web_email_verify_code as verify_util
router = APIRouter()
class VerifyEmailRequest(BaseModel):
code: str = PydanticField(..., min_length=1, max_length=10)
@router.post("/send-verify-code")
async def send_email_verify_code(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Отправить код подтверждения email. Требует авторизации."""
if not smtp_configured():
raise HTTPException(status_code=503, detail="Почтовый сервер не настроен")
email = (identity.email or "").strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email не привязан к аккаунту")
if getattr(identity, "email_verified", False):
return {"ok": True, "detail": "Email уже подтверждён"}
if not await verify_util.redis_ready():
raise HTTPException(status_code=503, detail="Сервис временно недоступен")
ip = _client_ip(request)
if not await verify_util.try_consume_ip_send_budget(ip):
raise HTTPException(status_code=429, detail="Слишком много запросов, попробуйте позже")
if not await verify_util.try_consume_email_send_budget(email):
raise HTTPException(status_code=429, detail="Слишком много запросов на этот email")
if not await verify_util.try_acquire_resend_cooldown(email):
raise HTTPException(status_code=429, detail="Подождите минуту перед повторной отправкой")
code = f"{secrets.randbelow(900000) + 100000}"
await verify_util.store_code(email, code)
try:
await send_email_verify_code_email(email, code)
except Exception:
await verify_util.delete_code(email)
raise HTTPException(status_code=503, detail="Не удалось отправить письмо")
return {"ok": True}
@router.post("/verify-email")
async def verify_email(
body: VerifyEmailRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Подтвердить email по коду."""
email = (identity.email or "").strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email не привязан к аккаунту")
if getattr(identity, "email_verified", False):
return {"ok": True, "detail": "Email уже подтверждён"}
if not await verify_util.try_consume_verify_budget(email):
raise HTTPException(status_code=429, detail="Слишком много попыток, попробуйте позже")
if not await verify_util.verify_and_consume_code(email, body.code.strip()):
raise HTTPException(status_code=400, detail="Неверный или просроченный код")
from sqlalchemy import update
from database.models import Identity as IdentityModel
await session.execute(update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True))
return {"ok": True}
-210
View File
@@ -1,210 +0,0 @@
import base64
import hashlib
import hmac
import secrets
import time
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
get_session,
set_auth_cookie,
set_is_admin_cookie,
)
from api.v2.routes.auth._common import _client_ip
from database import identities as idb
from logger import logger
try:
from config import GOOGLE_CLIENT_ID as _GOOGLE_CLIENT_ID
except ImportError:
_GOOGLE_CLIENT_ID = ""
try:
from config import GOOGLE_CLIENT_SECRET as _GOOGLE_CLIENT_SECRET
except ImportError:
_GOOGLE_CLIENT_SECRET = ""
try:
from config import GOOGLE_REDIRECT_URI as _GOOGLE_REDIRECT_URI
except ImportError:
_GOOGLE_REDIRECT_URI = ""
try:
from config import OAUTH_SUCCESS_URI as _OAUTH_SUCCESS_URI
except ImportError:
_OAUTH_SUCCESS_URI = "/dashboard"
try:
from config import API_TOKEN as _GOOGLE_STATE_SECRET
except ImportError:
_GOOGLE_STATE_SECRET = "solo-google-state-fallback"
GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_ENDPOINT = "https://openidconnect.googleapis.com/v1/userinfo"
STATE_TTL_SECONDS = 600
router = APIRouter()
def google_configured() -> bool:
return bool(_GOOGLE_CLIENT_ID and _GOOGLE_CLIENT_SECRET and _GOOGLE_REDIRECT_URI)
def _sign_state(payload: str) -> str:
mac = hmac.new(str(_GOOGLE_STATE_SECRET).encode(), payload.encode(), hashlib.sha256).digest()
return base64.urlsafe_b64encode(mac).decode().rstrip("=")
def _make_state(return_to: str) -> str:
nonce = secrets.token_urlsafe(16)
ts = str(int(time.time()))
payload = f"{nonce}.{ts}.{return_to}"
sig = _sign_state(payload)
raw = f"{payload}.{sig}".encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _verify_state(state: str) -> str | None:
try:
padded = state + "=" * (-len(state) % 4)
raw = base64.urlsafe_b64decode(padded).decode()
except Exception:
return None
parts = raw.rsplit(".", 1)
if len(parts) != 2:
return None
payload, sig = parts
expected = _sign_state(payload)
if not hmac.compare_digest(sig, expected):
return None
chunks = payload.split(".", 2)
if len(chunks) != 3:
return None
_nonce, ts, return_to = chunks
try:
if int(time.time()) - int(ts) > STATE_TTL_SECONDS:
return None
except Exception:
return None
return return_to or _OAUTH_SUCCESS_URI
@router.get("/google/authorize")
async def google_authorize(
request: Request,
return_to: str = Query(default=""),
):
"""Начинает OAuth-флоу Google: редиректит юзера на Google consent screen."""
if not google_configured():
raise HTTPException(status_code=503, detail="Google Sign-In не настроен на этом сервере")
safe_return = return_to if return_to.startswith("/") else _OAUTH_SUCCESS_URI
state = _make_state(safe_return)
params = {
"client_id": _GOOGLE_CLIENT_ID,
"redirect_uri": _GOOGLE_REDIRECT_URI,
"response_type": "code",
"scope": "openid email profile",
"state": state,
"access_type": "online",
"prompt": "select_account",
}
url = f"{GOOGLE_AUTH_ENDPOINT}?{urlencode(params)}"
logger.info("[Auth] Google authorize: ip={}", _client_ip(request))
return RedirectResponse(url, status_code=302)
@router.get("/google/callback")
async def google_callback(
request: Request,
response: Response,
code: str = Query(default=""),
state: str = Query(default=""),
error: str = Query(default=""),
session: AsyncSession = Depends(get_session),
):
"""Коллбек Google: обмен code → token → userinfo → identity."""
if not google_configured():
raise HTTPException(status_code=503, detail="Google Sign-In не настроен на этом сервере")
if error:
logger.warning("[Auth] Google callback error: {} ip={}", error, _client_ip(request))
return RedirectResponse(f"/login?error=google_{error}", status_code=302)
if not code or not state:
raise HTTPException(status_code=400, detail="Отсутствует code или state")
return_to = _verify_state(state)
if return_to is None:
logger.warning("[Auth] Google callback: invalid/expired state ip={}", _client_ip(request))
raise HTTPException(status_code=400, detail="Неверный или просроченный state")
async with httpx.AsyncClient(timeout=10.0) as client:
try:
token_res = await client.post(
GOOGLE_TOKEN_ENDPOINT,
data={
"code": code,
"client_id": _GOOGLE_CLIENT_ID,
"client_secret": _GOOGLE_CLIENT_SECRET,
"redirect_uri": _GOOGLE_REDIRECT_URI,
"grant_type": "authorization_code",
},
headers={"Accept": "application/json"},
)
except httpx.HTTPError as e:
logger.warning("[Auth] Google token exchange network error: {}", e)
raise HTTPException(status_code=502, detail="Не удалось связаться с Google") from e
if token_res.status_code != 200:
logger.warning("[Auth] Google token exchange failed: {} {}", token_res.status_code, token_res.text[:200])
raise HTTPException(status_code=401, detail="Google отклонил токен")
token_payload = token_res.json()
access_token = token_payload.get("access_token")
if not access_token:
raise HTTPException(status_code=401, detail="Google не вернул access_token")
try:
info_res = await client.get(
GOOGLE_USERINFO_ENDPOINT,
headers={"Authorization": f"Bearer {access_token}"},
)
except httpx.HTTPError as e:
logger.warning("[Auth] Google userinfo network error: {}", e)
raise HTTPException(status_code=502, detail="Не удалось получить профиль Google") from e
if info_res.status_code != 200:
raise HTTPException(status_code=401, detail="Google не вернул профиль")
info = info_res.json()
google_sub = str(info.get("sub") or "").strip()
email = (info.get("email") or "").strip().lower() or None
email_verified = bool(info.get("email_verified"))
if not google_sub:
raise HTTPException(status_code=401, detail="Google не вернул идентификатор пользователя")
identity = await idb.get_or_create_identity_for_google(
session,
google_sub=google_sub,
email=email if (email and email_verified) else None,
)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity, request=request)
logger.info(
"[Auth] Login success: identity={}, google_sub={}, ip={}, method=google",
identity.id,
google_sub,
_client_ip(request),
)
redirect = RedirectResponse(return_to, status_code=302)
set_auth_cookie(redirect, token, request)
set_is_admin_cookie(redirect, identity, request)
return redirect
@router.get("/google/status")
async def google_status():
"""Позволяет фронтенду узнать, настроен ли Google-вход на этом сервере."""
return {"enabled": google_configured()}
-124
View File
@@ -1,124 +0,0 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
get_session,
set_is_admin_cookie,
verify_identity_token,
)
from api.v2.routes.auth._common import _client_ip
from api.v2.schemas.identities import (
IdentityResponse,
LinkEmailConfirmRequest,
LinkEmailSendCodeRequest,
)
from database import identities as idb
from mail import send_email_link_code_email, smtp_configured
from utils import web_email_link_code as email_link_code
router = APIRouter()
@router.post("/link-email/send-code")
async def link_email_send_code(
body: LinkEmailSendCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
email_norm = email_link_code.normalize_email(body.email)
if not email_norm:
raise HTTPException(status_code=400, detail="Укажите корректный email")
if identity.email and str(identity.email).strip().lower() == email_norm:
raise HTTPException(status_code=409, detail="Этот email уже привязан к аккаунту")
if not smtp_configured():
raise HTTPException(
status_code=503,
detail="Отправка кода недоступна: почта не настроена на сервере",
)
if not await email_link_code.redis_ready():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
existing = await idb.get_identity_by_email(session, email_norm)
if existing and existing.id != identity.id:
our_tg = identity.tg_id
their_tg = existing.tg_id
can_merge = their_tg is None or (our_tg is not None and int(their_tg) == int(our_tg))
if not can_merge:
raise HTTPException(
status_code=409,
detail="Этот email уже привязан к другому аккаунту",
)
ip = _client_ip(request)
if not await email_link_code.try_consume_ip_budget(ip):
raise HTTPException(
status_code=429,
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
)
if not await email_link_code.try_consume_email_send_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
)
if not await email_link_code.try_acquire_cooldown(email_norm):
raise HTTPException(
status_code=429,
detail="Код уже отправлен. Подождите перед повторной отправкой.",
)
code = "".join(secrets.choice("0123456789") for _ in range(6))
if not await email_link_code.store_code(email_norm, code):
await email_link_code.release_cooldown(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось сохранить код. Попробуйте позже.",
)
try:
await send_email_link_code_email(email_norm, code)
except Exception:
await email_link_code.release_cooldown(email_norm)
await email_link_code.delete_code(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось отправить письмо. Попробуйте позже.",
) from None
return {"ok": True, "message": "Код подтверждения отправлен на почту"}
@router.post("/link-email/confirm", response_model=IdentityResponse)
async def link_email_confirm(
body: LinkEmailConfirmRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
email_norm = email_link_code.normalize_email(body.email)
if not email_norm or not body.code or not str(body.code).strip():
raise HTTPException(status_code=400, detail="Email и код обязательны")
if not await email_link_code.redis_ready():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
if not await email_link_code.try_consume_email_verify_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много попыток. Запросите новый код.",
)
if not await email_link_code.verify_and_consume_code(email_norm, str(body.code).strip()):
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
result = await idb.attach_email(session, identity.id, email_norm)
if not result:
raise HTTPException(
status_code=409,
detail="Этот email уже привязан к другой идентичности",
)
await bind_identity_actor(request, session, result)
set_is_admin_cookie(response, result, request)
return IdentityResponse.model_validate(result)
-430
View File
@@ -1,430 +0,0 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
get_session,
set_auth_cookie,
set_is_admin_cookie,
)
from api.v2.routes.auth._common import TOKEN_TTL_HINT, _client_ip, build_login_response
from api.v2.schemas.identities import (
ConfirmPasswordResetRequest,
LoginByCodeRequest,
LoginRequest,
LoginResponse,
RegisterByEmailRequest,
RegisterResponse,
SendLoginCodeRequest,
)
from database import (
add_referral,
get_referral_by_referred_id,
identities as idb,
)
from database.access.resolution import resolve_user_optional
from logger import logger
from mail import (
send_email_verify_code_email,
send_login_code_email,
send_password_reset_code_email,
smtp_configured,
)
from utils import (
web_email_verify_code as email_verify,
web_password_reset_code as pwd_reset,
)
from utils.disposable_emails import is_disposable_email
from utils.referral_codes import decode_referral_code
from utils.turnstile import turnstile_enabled, verify_turnstile_token
from utils.web_login_code import (
delete_code,
normalize_login_email,
redis_ready_for_login_codes,
release_resend_cooldown,
store_code,
try_acquire_resend_cooldown,
try_consume_email_send_budget,
try_consume_email_verify_budget,
try_consume_ip_send_budget,
verify_and_consume_code,
)
router = APIRouter()
_RESET_OK_MESSAGE = {
"ok": True,
"message": "Если для этого адреса есть аккаунт с паролем, мы отправили код. Проверьте почту.",
}
@router.post("/register", response_model=RegisterResponse)
async def register_by_email(
body: RegisterByEmailRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
(
"""Регистрация по почте и паролю: создаётся идентичность, выдаётся токен. Срок действия токена: """
+ TOKEN_TTL_HINT
+ "."
)
ip = _client_ip(request)
try:
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_incr_checked
count, redis_ok = await cache_incr_checked(f"register_rate:{ip}", 3600)
if not redis_ok:
count = check_and_increment(f"register_rate:{ip}", 5, 3600)
if count > 5:
raise HTTPException(status_code=429, detail="Слишком много регистраций с этого IP. Попробуйте позже.")
except HTTPException:
raise
except Exception:
pass
if turnstile_enabled():
if not await verify_turnstile_token(body.turnstile_token, ip):
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
email = body.email.strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
if is_disposable_email(email):
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
if not body.password or len(body.password) < 8:
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
existing = await idb.get_identity_by_email(session, email)
if existing:
raise HTTPException(status_code=409, detail="Идентичность с таким email уже существует")
raw_referral = str(body.referral_code or "").strip()
if "/referral/" in raw_referral:
raw_referral = raw_referral.split("/referral/", 1)[-1]
if "start=referral_" in raw_referral:
raw_referral = raw_referral.split("start=referral_", 1)[-1]
raw_referral = raw_referral.split("?", 1)[0].split("#", 1)[0].strip()
referrer_legacy = decode_referral_code(raw_referral)
referrer_user = None
if body.referral_code and referrer_legacy is None:
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
if referrer_legacy is not None:
referrer_user = await resolve_user_optional(session, referrer_legacy)
if referrer_user is None:
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
identity, token = await idb.create_identity_with_token(
session, email=email, password=body.password, request=request
)
await bind_identity_actor(request, session, identity)
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
if referrer_user is not None and not await get_referral_by_referred_id(session, billing_user_id):
await add_referral(session, billing_user_id, referrer_user.id)
if referrer_user.tg_id is not None:
try:
from database.web_notifications import notify_web
await notify_web(
session,
tg_id=int(referrer_user.tg_id),
type="referral_joined",
title="Ваш реферал присоединился",
message="Новый пользователь зарегистрировался по вашей реферальной ссылке.",
data={"referred_user_id": int(billing_user_id)},
)
except Exception:
pass
if smtp_configured():
try:
code = f"{secrets.randbelow(900000) + 100000}"
await email_verify.store_code(email, code)
await send_email_verify_code_email(email, code)
except Exception as e:
logger.warning("[Auth] Не удалось отправить код подтверждения email при регистрации: {}", e)
logger.info("[Auth] Register success: identity={}, email={}, ip={}", identity.id, email, _client_ip(request))
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return RegisterResponse(identity_id=identity.id)
@router.post("/login", response_model=LoginResponse)
async def login(
body: LoginRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Вход по email и паролю. Возвращает identity_id и новый токен. Срок действия токена: """ + TOKEN_TTL_HINT + "."
email = body.email.strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
ip = _client_ip(request)
try:
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_get, cache_incr_checked
lockout_key = f"login_lockout:{email}"
locked = await cache_get(lockout_key)
if locked:
raise HTTPException(status_code=429, detail="Аккаунт временно заблокирован. Попробуйте через 15 минут.")
rkey = f"login_pwd_rate:{ip}"
count, redis_ok = await cache_incr_checked(rkey, 900)
if not redis_ok:
count = check_and_increment(rkey, 10, 900)
if count > 10:
raise HTTPException(status_code=429, detail="Слишком много попыток. Попробуйте позже.")
except HTTPException:
raise
except Exception as e:
logger.warning("[Auth] Ошибка rate-limit проверки для email-логина: {}", e)
result = await idb.login_by_email(session, email, body.password, request=request)
if not result:
from database.setup.web_admin_bootstrap import ensure_web_admin
try:
await ensure_web_admin(session)
await session.flush()
result = await idb.login_by_email(session, email, body.password, request=request)
except Exception as exc:
logger.warning("[Auth] lazy web-admin bootstrap failed: {}", exc)
if not result:
try:
from core.redis_cache import cache_incr, cache_set
fail_key = f"login_fail:{email}"
fails = await cache_incr(fail_key, 900)
if fails >= 10:
await cache_set(f"login_lockout:{email}", "1", 900)
except Exception:
pass
raise HTTPException(status_code=401, detail="Неверный email или пароль")
try:
from core.redis_cache import cache_delete
await cache_delete(f"login_fail:{email}")
except Exception:
pass
identity, token = result
await bind_identity_actor(request, session, identity)
if getattr(identity, "is_admin", False):
from database.site_state import mark_site_initialized
await mark_site_initialized(session)
logger.info("[Auth] Login success: identity={}, email={}, ip={}, method=password", identity.id, email, ip)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return build_login_response(identity)
@router.post("/send-login-code")
async def send_login_code(
body: SendLoginCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Отправить код входа на email (SMTP + Redis)."""
ip = _client_ip(request)
try:
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_incr_checked
count, redis_ok = await cache_incr_checked(f"send_code_rate:{ip}", 3600)
if not redis_ok:
count = check_and_increment(f"send_code_rate:{ip}", 10, 3600)
if count > 10:
raise HTTPException(status_code=429, detail="Слишком много запросов кодов. Попробуйте позже.")
except HTTPException:
raise
except Exception:
pass
if turnstile_enabled():
if not await verify_turnstile_token(body.turnstile_token, ip):
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
email_norm = normalize_login_email(body.email)
if not email_norm:
raise HTTPException(status_code=400, detail="Email обязателен")
if is_disposable_email(email_norm):
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
if not smtp_configured():
raise HTTPException(
status_code=503,
detail="Отправка кода недоступна: почта не настроена на сервере",
)
if not await redis_ready_for_login_codes():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
identity = await idb.get_identity_by_email(session, email_norm)
if not identity:
if not body.allow_register:
return {"ok": True, "message": "Код отправлен на почту"}
identity = await idb.create_identity(session, email=email_norm)
ip = _client_ip(request)
if not await try_consume_ip_send_budget(ip):
raise HTTPException(
status_code=429,
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
)
if not await try_consume_email_send_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
)
if not await try_acquire_resend_cooldown(email_norm):
raise HTTPException(
status_code=429,
detail="Код уже отправлен. Подождите перед повторной отправкой.",
)
code = "".join(secrets.choice("0123456789") for _ in range(6))
if not await store_code(email_norm, code):
await release_resend_cooldown(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось сохранить код. Попробуйте позже.",
)
try:
await send_login_code_email(email_norm, code)
except Exception:
await release_resend_cooldown(email_norm)
await delete_code(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось отправить письмо. Попробуйте позже.",
) from None
return {"ok": True, "message": "Код отправлен на почту"}
@router.post("/login-by-code", response_model=LoginResponse)
async def login_by_code(
body: LoginByCodeRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Вход по email и коду из письма."""
email_norm = normalize_login_email(body.email)
if not email_norm or not body.code or not body.code.strip():
raise HTTPException(status_code=400, detail="Email и код обязательны")
if not await redis_ready_for_login_codes():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
if not await try_consume_email_verify_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много попыток. Запросите новый код.",
)
if not await verify_and_consume_code(email_norm, body.code.strip()):
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
identity = await idb.get_identity_by_email(session, email_norm)
if not identity:
raise HTTPException(status_code=401, detail="Аккаунт не найден")
if not getattr(identity, "email_verified", False):
from sqlalchemy import update as sa_update
from database.models import Identity as IdentityModel
await session.execute(
sa_update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True)
)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity, request=request)
if getattr(identity, "is_admin", False):
from database.site_state import mark_site_initialized
await mark_site_initialized(session)
logger.info("[Auth] Login success: identity={}, email={}, method=code", identity.id, email_norm)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return build_login_response(identity)
@router.post("/request-password-reset")
async def request_password_reset(
body: SendLoginCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
email_norm = normalize_login_email(body.email)
if not email_norm:
raise HTTPException(status_code=400, detail="Email обязателен")
if not smtp_configured() or not await pwd_reset.redis_ready():
return _RESET_OK_MESSAGE
identity = await idb.get_identity_by_email(session, email_norm)
if not identity or not identity.password_hash:
return _RESET_OK_MESSAGE
ip = _client_ip(request)
if not await pwd_reset.try_consume_ip_budget(ip):
raise HTTPException(
status_code=429,
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
)
if not await pwd_reset.try_consume_email_send_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
)
if not await pwd_reset.try_acquire_cooldown(email_norm):
raise HTTPException(
status_code=429,
detail="Код уже отправлен. Подождите перед повторной отправкой.",
)
code = "".join(secrets.choice("0123456789") for _ in range(6))
if not await pwd_reset.store_code(email_norm, code):
await pwd_reset.release_cooldown(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось сохранить код. Попробуйте позже.",
)
try:
await send_password_reset_code_email(email_norm, code)
except Exception:
await pwd_reset.release_cooldown(email_norm)
await pwd_reset.delete_code(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось отправить письмо. Попробуйте позже.",
) from None
return _RESET_OK_MESSAGE
@router.post("/confirm-password-reset", response_model=LoginResponse)
async def confirm_password_reset(
body: ConfirmPasswordResetRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
email_norm = normalize_login_email(body.email)
if not email_norm or not body.code or not body.code.strip():
raise HTTPException(status_code=400, detail="Email и код обязательны")
if body.password != body.password_confirm:
raise HTTPException(status_code=400, detail="Пароли не совпадают")
if len(body.password) < 8:
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
if not await pwd_reset.redis_ready():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
if not await pwd_reset.try_consume_email_verify_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много попыток. Запросите новый код.",
)
if not await pwd_reset.verify_and_consume_code(email_norm, body.code.strip()):
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
identity = await idb.get_identity_by_email(session, email_norm)
if not identity:
raise HTTPException(status_code=400, detail="Аккаунт не найден")
updated = await idb.set_password_for_identity(session, identity.id, body.password)
if not updated:
raise HTTPException(status_code=400, detail="Не удалось обновить пароль")
await bind_identity_actor(request, session, updated)
token = await idb.issue_token_for_identity(session, updated, request=request)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, updated, request)
return build_login_response(updated)
-601
View File
@@ -1,601 +0,0 @@
import re
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlalchemy import String, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
clear_auth_cookie,
get_request_actor,
get_session,
hash_token,
verify_identity_token,
)
from api.depends import AUTH_COOKIE_NAME
from api.v2.routes.auth._common import _resolve_partner_snapshot
from api.v2.schemas.identities import (
ChangePasswordRequest,
IdentityResponse,
IdentitySessionItem,
IdentitySessionsResponse,
SetPasswordRequest,
)
from api.v2.schemas.web_public import (
AccountSearchHit,
AccountSearchResponse,
AccountSummaryResponse,
)
from database import (
get_balance,
get_keys,
get_trial,
identities as idb,
identity_sessions as idsess,
)
from database.models import CouponUsage, Gift, GiftUsage, IdentityNotifPref, Key, Payment, WebNotification
from database.referrals import get_referral_stats
from database.web_notifications import count_unread_for_identity
from utils.referral_codes import encode_referral_code
router = APIRouter()
@router.get("/me", response_model=IdentityResponse)
async def me(
identity=Depends(verify_identity_token),
):
"""Текущая идентичность по HttpOnly cookie `auth_token`."""
return IdentityResponse.model_validate(identity)
def _current_token_hash(request: Request) -> str | None:
raw = request.cookies.get(AUTH_COOKIE_NAME)
if not raw or not raw.strip():
return None
return hash_token(raw.strip())
@router.get("/sessions", response_model=IdentitySessionsResponse)
async def list_my_sessions(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Возвращает активные сессии текущей identity (все устройства)."""
current_hash = _current_token_hash(request)
rows = await idsess.list_sessions_for_identity(session, identity.id)
items = [
IdentitySessionItem(
id=row.id,
device_label=row.device_label,
ip=row.ip,
created_at=row.created_at,
last_seen_at=row.last_seen_at,
expires_at=row.expires_at,
is_current=bool(current_hash and row.token_hash == current_hash),
)
for row in rows
]
return IdentitySessionsResponse(sessions=items)
@router.delete("/sessions/{session_id}")
async def revoke_my_session(
session_id: str,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Удаляет одну сессию текущей identity. Если удалена текущая — очищаем cookie."""
ok = await idsess.delete_session_by_id(
session, session_id=session_id, identity_id=identity.id
)
if not ok:
raise HTTPException(status_code=404, detail="Сессия не найдена")
current_hash = _current_token_hash(request)
rows = await idsess.list_sessions_for_identity(session, identity.id)
if current_hash and not any(r.token_hash == current_hash for r in rows):
clear_auth_cookie(response, request)
return {"ok": True}
@router.post("/sessions/revoke-others")
async def revoke_other_sessions(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Удаляет все сессии текущей identity кроме текущей."""
current_hash = _current_token_hash(request)
if not current_hash:
raise HTTPException(status_code=400, detail="Текущая сессия не определена")
removed = await idsess.delete_other_sessions(
session, identity_id=identity.id, keep_token_hash=current_hash
)
return {"ok": True, "removed": removed}
@router.post("/logout")
async def logout(
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Удаляет текущую сессию из БД и очищает auth cookie. Не требует валидной сессии."""
raw = request.cookies.get(AUTH_COOKIE_NAME)
if raw and raw.strip():
try:
await idsess.delete_session_by_token_hash(session, hash_token(raw.strip()))
except Exception:
pass
clear_auth_cookie(response, request)
return {"ok": True}
@router.post("/me/onboarding/complete", response_model=IdentityResponse)
async def onboarding_complete(
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Отмечает, что админ прошёл/скипнул онбординг-тур."""
from datetime import datetime as _dt
if identity.onboarding_completed_at is None:
identity.onboarding_completed_at = _dt.utcnow()
return IdentityResponse.model_validate(identity)
@router.post("/me/onboarding/reset", response_model=IdentityResponse)
async def onboarding_reset(
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Сбрасывает флаг онбординга — туториал запустится снова."""
identity.onboarding_completed_at = None
identity.onboarding_stage = "landing"
return IdentityResponse.model_validate(identity)
_ONBOARDING_STAGES = {"landing", "header", "cabinet", "flow", "elements", "done"}
@router.post("/me/onboarding/stage", response_model=IdentityResponse)
async def onboarding_set_stage(
body: dict,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Переводит админа на указанный этап онбординга."""
from datetime import datetime as _dt
stage = str(body.get("stage") or "").strip()
if stage not in _ONBOARDING_STAGES:
raise HTTPException(status_code=400, detail="Неизвестный этап онбординга")
identity.onboarding_stage = stage
if stage == "done" and identity.onboarding_completed_at is None:
identity.onboarding_completed_at = _dt.utcnow()
return IdentityResponse.model_validate(identity)
@router.get("/summary", response_model=AccountSummaryResponse)
async def auth_summary(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
balance = float(await get_balance(session, billing_user_id))
trial_status = await get_trial(session, billing_user_id)
keys = await get_keys(session, billing_user_id)
keys_total = len(keys) if keys else 0
gifts_sent_r = await session.execute(
select(func.count()).select_from(Gift).where(Gift.sender_user_id == billing_user_id)
)
gifts_sent = gifts_sent_r.scalar_one() or 0
gifts_claimed_r = await session.execute(
select(func.count()).select_from(GiftUsage).where(GiftUsage.user_id == billing_user_id)
)
gifts_claimed = gifts_claimed_r.scalar_one() or 0
coupons_r = await session.execute(
select(func.count()).select_from(CouponUsage).where(CouponUsage.user_id == billing_user_id)
)
coupons_used = coupons_r.scalar_one() or 0
ref = await get_referral_stats(session, billing_user_id)
partner = await _resolve_partner_snapshot(session, int(billing_user_id))
unread_notifications = await count_unread_for_identity(session, identity.id)
return AccountSummaryResponse(
identity_id=identity.id,
email=identity.email,
tg_id=identity.tg_id,
linked_telegram=identity.tg_id is not None,
created_at=identity.created_at.isoformat() if identity.created_at else None,
password_set=bool(identity.password_set),
referral_code=encode_referral_code(int(billing_user_id)),
balance=balance,
trial_status=int(trial_status),
keys_total=keys_total,
referrals_total=int(ref.get("total_referrals") or 0),
referrals_active=int(ref.get("active_referrals") or 0),
referral_bonus_total=float(ref.get("total_referral_bonus") or 0),
gifts_sent=int(gifts_sent),
gifts_claimed=int(gifts_claimed),
coupons_used=int(coupons_used),
partner_enabled=bool(partner.get("partner_enabled", False)),
partner_code=str(partner.get("partner_code") or ""),
partner_balance=float(partner.get("partner_balance") or 0.0),
partner_percent=float(partner.get("partner_percent") or 0.0),
partner_percent_custom=bool(partner.get("partner_percent_custom", False)),
partner_referred_total=int(partner.get("partner_referred_total") or 0),
partner_referred_paid=int(partner.get("partner_referred_paid") or 0),
partner_payout_method=partner.get("partner_payout_method"),
unread_notifications=int(unread_notifications),
)
class MyPaymentItem(BaseModel):
id: int
payment_id: str | None
amount: float
currency: str
status: str
provider: str
created_at: str | None
purpose: str | None
class MyPaymentsResponse(BaseModel):
ok: bool = True
payments: list[MyPaymentItem]
@router.get("/me/payments", response_model=MyPaymentsResponse)
async def my_payments(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
limit: int = 50,
):
"""История платежей текущего юзера. Привязка через Identity → User → Payment."""
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
if billing_user_id is None:
return MyPaymentsResponse(ok=True, payments=[])
safe_limit = max(1, min(200, int(limit) if limit else 50))
rows = await session.execute(
select(Payment)
.where(Payment.user_id == billing_user_id)
.order_by(Payment.created_at.desc())
.limit(safe_limit)
)
payments = rows.scalars().all()
items: list[MyPaymentItem] = []
for p in payments:
meta = p.metadata_ if isinstance(p.metadata_, dict) else None
purpose = None
if meta:
purpose = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
if purpose is not None:
purpose = str(purpose)
items.append(
MyPaymentItem(
id=int(p.id),
payment_id=str(p.payment_id) if p.payment_id else None,
amount=float(p.amount or 0),
currency=str(p.currency or "RUB"),
status=str(p.status or ""),
provider=str(p.payment_system or ""),
created_at=p.created_at.isoformat() if p.created_at else None,
purpose=purpose,
)
)
return MyPaymentsResponse(ok=True, payments=items)
def _esc(value: object) -> str:
s = "" if value is None else str(value)
return (
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#39;")
)
@router.get("/me/payments/{payment_id}/invoice", response_class=HTMLResponse)
async def get_my_payment_invoice(
payment_id: int = Path(..., ge=1),
request: Request = None, # type: ignore[assignment]
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""HTML-инвойс по конкретному платежу. Браузер может сохранить как PDF (Cmd+P → Save as PDF)."""
actor = get_request_actor(request) if request is not None else None
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
if billing_user_id is None:
raise HTTPException(status_code=404, detail="Платёж не найден")
payment = (
await session.execute(
select(Payment).where(Payment.id == payment_id, Payment.user_id == billing_user_id).limit(1)
)
).scalar_one_or_none()
if payment is None:
raise HTTPException(status_code=404, detail="Платёж не найден")
meta = payment.metadata_ if isinstance(payment.metadata_, dict) else {}
purpose = ""
if meta:
v = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
if v is not None:
purpose = str(v)
created = payment.created_at.strftime("%d.%m.%Y %H:%M") if payment.created_at else ""
amount_value = float(payment.amount or 0)
currency = str(payment.currency or "RUB").upper()
status_raw = str(payment.status or "")
status_norm = status_raw.lower()
status_label = "ОПЛАЧЕН" if status_norm in {"completed", "success", "paid"} else "ОЖИДАЕТ" if status_norm in {"pending", "processing"} else "ОТКЛОНЁН"
provider = str(payment.payment_system or "").upper() or ""
user_label = identity.email or (f"tg · {identity.tg_id}" if identity.tg_id else identity.id)
payment_identifier = str(payment.payment_id).strip() if payment.payment_id else ""
title_suffix = payment_identifier if payment_identifier else f"#{payment.id}"
html = f"""<!DOCTYPE html>
<html lang=\"ru\">
<head>
<meta charset=\"utf-8\" />
<title>Квитанция {_esc(title_suffix)}</title>
<style>
@page {{ size: A4; margin: 18mm; }}
body {{ font-family: 'JetBrains Mono', ui-monospace, monospace; color: #111; background: #fff; max-width: 720px; margin: 0 auto; padding: 24px; }}
h1 {{ font-size: 24px; letter-spacing: -0.02em; margin: 0 0 4px; text-transform: uppercase; }}
.sub {{ color: #888; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; margin-bottom: 32px; }}
table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
td {{ padding: 11px 0; border-bottom: 1px dashed #ddd; vertical-align: top; }}
td.k {{ color: #888; width: 35%; letter-spacing: 0.08em; text-transform: uppercase; font-size: 11px; }}
td.v {{ font-weight: 600; }}
.amount {{ font-size: 32px; font-weight: 800; letter-spacing: -0.02em; margin: 24px 0 8px; }}
.badge {{ display: inline-block; padding: 4px 10px; border: 1px solid #111; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; }}
.footer {{ margin-top: 48px; font-size: 10px; color: #aaa; letter-spacing: 0.12em; text-transform: uppercase; text-align: center; }}
@media print {{ .no-print {{ display: none; }} }}
.print-btn {{ position: fixed; top: 16px; right: 16px; padding: 10px 16px; background: #111; color: #fff; border: 0; cursor: pointer; font-family: inherit; font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; }}
</style>
</head>
<body>
<button class=\"print-btn no-print\" onclick=\"window.print()\">Сохранить PDF</button>
<h1>Квитанция {_esc(title_suffix)}</h1>
<div class=\"sub\">// {_esc(created)}</div>
<div class=\"amount\">{amount_value:,.2f} {_esc(currency)}</div>
<span class=\"badge\">{_esc(status_label)}</span>
<table>
<tr><td class=\"k\">Назначение</td><td class=\"v\">{_esc(purpose) or ""}</td></tr>
<tr><td class=\"k\">Провайдер</td><td class=\"v\">{_esc(provider)}</td></tr>
<tr><td class=\"k\">Дата</td><td class=\"v\">{_esc(created)}</td></tr>
<tr><td class=\"k\">Получатель</td><td class=\"v\">{_esc(user_label)}</td></tr>
<tr><td class=\"k\">Идентификатор платежа</td><td class=\"v\" style=\"font-size:11px;color:#666\">{_esc(payment_identifier) if payment_identifier else ""}</td></tr>
</table>
<div class=\"footer\">Документ сгенерирован автоматически. Не требует подписи и печати.</div>
</body>
</html>"""
return HTMLResponse(content=html, status_code=200)
class NotifChannelPref(BaseModel):
channel: str
enabled: bool
class NotifChannelPrefsResponse(BaseModel):
ok: bool = True
channels: list[NotifChannelPref]
class NotifChannelPrefsUpdateRequest(BaseModel):
channels: list[NotifChannelPref]
_NOTIF_CHANNEL_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
@router.get("/me/notification-prefs", response_model=NotifChannelPrefsResponse)
async def get_my_notification_prefs(
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
rows = (
await session.execute(
select(IdentityNotifPref).where(IdentityNotifPref.identity_id == identity.id)
)
).scalars().all()
return NotifChannelPrefsResponse(
ok=True,
channels=[NotifChannelPref(channel=str(r.channel), enabled=bool(r.enabled)) for r in rows],
)
@router.put("/me/notification-prefs", response_model=NotifChannelPrefsResponse)
async def set_my_notification_prefs(
body: NotifChannelPrefsUpdateRequest,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
for entry in body.channels:
channel = str(entry.channel or "").strip()
if not channel or not _NOTIF_CHANNEL_RE.match(channel):
raise HTTPException(status_code=422, detail=f"Некорректный канал: {channel!r}")
existing = (
await session.execute(
select(IdentityNotifPref).where(
IdentityNotifPref.identity_id == identity.id,
IdentityNotifPref.channel == channel,
)
)
).scalar_one_or_none()
if existing is None:
session.add(
IdentityNotifPref(identity_id=identity.id, channel=channel, enabled=bool(entry.enabled))
)
else:
existing.enabled = bool(entry.enabled)
await session.flush()
rows = (
await session.execute(
select(IdentityNotifPref).where(IdentityNotifPref.identity_id == identity.id)
)
).scalars().all()
return NotifChannelPrefsResponse(
ok=True,
channels=[NotifChannelPref(channel=str(r.channel), enabled=bool(r.enabled)) for r in rows],
)
@router.get("/me/search", response_model=AccountSearchResponse)
async def my_search(
q: str,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
limit: int = 8,
):
"""Поиск по подпискам, платежам, уведомлениям текущего user'а. Простое ILIKE."""
query_raw = (q or "").strip()
if len(query_raw) < 2:
return AccountSearchResponse(query=query_raw, hits=[], total=0)
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
if billing_user_id is None:
return AccountSearchResponse(query=query_raw, hits=[], total=0)
safe_limit = max(1, min(20, int(limit) if limit else 8))
pattern = f"%{query_raw.lower()}%"
hits: list[AccountSearchHit] = []
# Keys: alias / email / server_id
keys_rows = (
await session.execute(
select(Key)
.where(Key.user_id == billing_user_id)
.where(
func.lower(func.coalesce(Key.alias, ""))
.like(pattern)
| func.lower(func.coalesce(Key.email, "")).like(pattern)
| func.lower(func.coalesce(Key.server_id, "")).like(pattern)
| func.lower(func.coalesce(Key.client_id, "")).like(pattern)
)
.limit(safe_limit)
)
).scalars().all()
for k in keys_rows:
label = (k.alias or k.email or k.client_id or "").strip() or ""
sublabel = (k.server_id or "").strip() or ""
hits.append(AccountSearchHit(kind="subscription", label=label, sublabel=sublabel, href="/dashboard/keys", meta=str(k.client_id)))
# Payments: provider / metadata.purpose
payments_rows = (
await session.execute(
select(Payment)
.where(Payment.user_id == billing_user_id)
.where(
func.lower(func.coalesce(Payment.payment_system, "")).like(pattern)
| func.cast(Payment.metadata_, String).ilike(pattern)
)
.order_by(Payment.created_at.desc())
.limit(safe_limit)
)
).scalars().all()
for p in payments_rows:
meta = p.metadata_ if isinstance(p.metadata_, dict) else None
purpose = ""
if meta:
v = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
if v is not None:
purpose = str(v)
amount_label = f"{float(p.amount or 0):,.0f} {(p.currency or 'RUB').upper()}"
hits.append(AccountSearchHit(
kind="payment",
label=purpose or amount_label,
sublabel=f"{(p.payment_system or '').upper()} · {amount_label}",
href="/dashboard",
meta=str(p.id),
))
# Notifications: title / message
notif_rows = (
await session.execute(
select(WebNotification)
.where(WebNotification.identity_id == identity.id)
.where(
func.lower(WebNotification.title).like(pattern)
| func.lower(WebNotification.message).like(pattern)
)
.order_by(WebNotification.created_at.desc())
.limit(safe_limit)
)
).scalars().all()
for n in notif_rows:
hits.append(AccountSearchHit(
kind="notification",
label=str(n.title or ""),
sublabel=(str(n.message or "")[:80]),
href="/dashboard/notifications",
meta=str(n.id),
))
return AccountSearchResponse(query=query_raw, hits=hits, total=len(hits))
@router.post("/set-password")
async def set_password(
body: SetPasswordRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if body.password != body.password_confirm:
raise HTTPException(status_code=400, detail="Пароли не совпадают")
updated = await idb.set_initial_password(session, identity.id, body.password)
if not updated:
raise HTTPException(
status_code=409,
detail="Пароль уже установлен или аккаунт недоступен",
)
await bind_identity_actor(request, session, updated)
return {"ok": True}
@router.post("/change-password")
async def change_password(
body: ChangePasswordRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if body.password != body.password_confirm:
raise HTTPException(status_code=400, detail="Новые пароли не совпадают")
err = await idb.change_identity_password(
session,
identity.id,
body.current_password,
body.password,
)
if err == "no_password":
raise HTTPException(
status_code=409,
detail="Пароль ещё не установлен. Сначала задайте пароль в кабинете.",
)
if err == "wrong_password":
raise HTTPException(status_code=401, detail="Неверный текущий пароль")
refreshed = await idb.get_identity_by_id(session, identity.id)
if refreshed:
await bind_identity_actor(request, session, refreshed)
return {"ok": True}
-236
View File
@@ -1,236 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import (
BaseModel,
Field as PydanticField,
)
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
get_session,
set_auth_cookie,
set_is_admin_cookie,
verify_identity_token,
)
from api.v2.routes.auth._common import TELEGRAM_LOGIN_MAX_AGE, TOKEN_TTL_HINT, _client_ip, build_login_response
from api.v2.schemas.identities import (
IdentityResponse,
LinkTelegramRequest,
LoginResponse,
LoginTelegramRequest,
)
from config import API_TOKEN
from database import identities as idb
from logger import logger
from utils.telegram_login import verify_telegram_login
router = APIRouter()
def _get_oidc_credentials() -> tuple[str, str]:
try:
from config import TELEGRAM_CLIENT_ID, TELEGRAM_CLIENT_SECRET
cid = str(TELEGRAM_CLIENT_ID).strip()
secret = str(TELEGRAM_CLIENT_SECRET).strip()
if cid and secret:
return cid, secret
except ImportError:
pass
return "", ""
class LoginTelegramWebAppRequest(BaseModel):
init_data: str = PydanticField(..., min_length=1)
@router.post("/login-telegram", response_model=LoginResponse)
async def login_telegram(
body: LoginTelegramRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
(
"""Вход через Telegram Login Widget (кнопка на сайте). По tg_id находим или создаём Identity, выдаём токен. Срок действия токена: """
+ TOKEN_TTL_HINT
+ "."
)
payload = body.model_dump(mode="json")
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
identity = await idb.get_or_create_identity_for_tg(session, body.id)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity, request=request)
logger.info(
"[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_widget",
identity.id,
body.id,
_client_ip(request),
)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return build_login_response(identity)
@router.post("/login-telegram-webapp", response_model=LoginResponse)
async def login_telegram_webapp(
body: LoginTelegramWebAppRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Вход через Telegram WebApp initData. Валидирует HMAC, находит/создаёт Identity по tg_id."""
from utils.telegram_login import verify_webapp_init_data
result = verify_webapp_init_data(body.init_data, API_TOKEN)
if not result:
raise HTTPException(status_code=401, detail="Неверная подпись initData")
tg_id = result.get("user_id")
if not tg_id:
raise HTTPException(status_code=401, detail="Не удалось определить пользователя из initData")
identity = await idb.get_or_create_identity_for_tg(session, int(tg_id))
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity, request=request)
logger.info(
"[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_webapp",
identity.id,
tg_id,
_client_ip(request),
)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return build_login_response(identity)
class LoginTelegramOIDCRequest(BaseModel):
code: str = PydanticField(..., min_length=1)
redirect_uri: str = PydanticField(..., min_length=1)
code_verifier: str = PydanticField(default="", description="PKCE code_verifier")
@router.post("/login-telegram-oidc", response_model=LoginResponse)
async def login_telegram_oidc(
body: LoginTelegramOIDCRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Вход через Telegram OIDC (authorization code flow). Обменивает code на id_token, верифицирует JWT."""
import base64
import aiohttp
import jwt as pyjwt
client_id, client_secret = _get_oidc_credentials()
if not client_id or not client_secret:
logger.warning("[Auth] Telegram OIDC credentials missing in config")
raise HTTPException(status_code=503, detail="Вход через Telegram временно недоступен")
token_data = {
"grant_type": "authorization_code",
"code": body.code,
"redirect_uri": body.redirect_uri,
}
if body.code_verifier:
token_data["code_verifier"] = body.code_verifier
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
headers = {"Authorization": f"Basic {basic}", "Content-Type": "application/x-www-form-urlencoded"}
async with aiohttp.ClientSession() as http:
async with http.post("https://oauth.telegram.org/token", data=token_data, headers=headers) as resp:
if resp.status != 200:
err_text = await resp.text()
logger.warning("[Auth] Telegram OIDC token exchange failed: {} {}", resp.status, err_text[:200])
raise HTTPException(status_code=401, detail="Не удалось обменять код авторизации")
token_response = await resp.json()
id_token = token_response.get("id_token")
if not id_token:
raise HTTPException(status_code=401, detail="Telegram не вернул id_token")
async with aiohttp.ClientSession() as http:
async with http.get("https://oauth.telegram.org/.well-known/jwks.json") as resp:
jwks_data = await resp.json()
try:
signing_key = pyjwt.PyJWKClient.__new__(pyjwt.PyJWKClient)
from jwt.api_jwk import PyJWKSet
jwk_set = PyJWKSet.from_dict(jwks_data)
unverified_header = pyjwt.get_unverified_header(id_token)
kid = unverified_header.get("kid")
key = None
for k in jwk_set.keys:
if k.key_id == kid:
key = k
break
if not key:
raise HTTPException(status_code=401, detail="Ключ подписи не найден в JWKS")
claims = pyjwt.decode(
id_token,
key.key,
algorithms=["RS256"],
audience=client_id,
issuer="https://oauth.telegram.org",
)
except pyjwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="ID токен истёк")
except pyjwt.InvalidTokenError as exc:
logger.warning("[Auth] Telegram OIDC JWT invalid: {}", exc)
raise HTTPException(status_code=401, detail="Невалидный ID токен")
logger.info("[Auth] Telegram OIDC claims: {}", {k: v for k, v in claims.items() if k not in ("iat", "exp", "iss", "aud")})
tg_id = claims.get("id") or claims.get("telegram_id")
if not tg_id:
raise HTTPException(status_code=401, detail="Не удалось определить пользователя из id_token")
try:
tg_id_int = int(tg_id)
except (TypeError, ValueError):
raise HTTPException(status_code=401, detail="Не удалось определить пользователя") from None
if tg_id_int <= 0 or tg_id_int > 2**53:
raise HTTPException(status_code=401, detail="Не удалось определить пользователя")
identity = await idb.get_or_create_identity_for_tg(session, tg_id_int)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity, request=request)
if getattr(identity, "is_admin", False):
from database.site_state import mark_site_initialized
await mark_site_initialized(session)
logger.info(
"[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_oidc",
identity.id,
tg_id,
_client_ip(request),
)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return build_login_response(identity)
@router.post("/link-telegram", response_model=IdentityResponse)
async def link_telegram(
body: LinkTelegramRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Привязывает Telegram к текущей идентичности. Требуется подпись от Telegram Login Widget (доказательство владения аккаунтом)."""
payload = body.model_dump(mode="json")
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
result = await idb.attach_telegram(session, identity.id, body.id)
if not result:
raise HTTPException(
status_code=409,
detail="Этот Telegram уже привязан к другой идентичности",
)
await bind_identity_actor(request, session, result)
set_is_admin_cookie(response, result, request)
return IdentityResponse.model_validate(result)
-207
View File
@@ -1,207 +0,0 @@
import base64
import hashlib
import hmac
import secrets
import time
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
get_session,
set_auth_cookie,
set_is_admin_cookie,
)
from api.v2.routes.auth._common import _client_ip
from database import identities as idb
from logger import logger
try:
from config import YANDEX_CLIENT_ID as _YANDEX_CLIENT_ID
except ImportError:
_YANDEX_CLIENT_ID = ""
try:
from config import YANDEX_CLIENT_SECRET as _YANDEX_CLIENT_SECRET
except ImportError:
_YANDEX_CLIENT_SECRET = ""
try:
from config import YANDEX_REDIRECT_URI as _YANDEX_REDIRECT_URI
except ImportError:
_YANDEX_REDIRECT_URI = ""
try:
from config import OAUTH_SUCCESS_URI as _OAUTH_SUCCESS_URI
except ImportError:
_OAUTH_SUCCESS_URI = "/dashboard"
try:
from config import API_TOKEN as _YANDEX_STATE_SECRET
except ImportError:
_YANDEX_STATE_SECRET = "solo-yandex-state-fallback"
YANDEX_AUTH_ENDPOINT = "https://oauth.yandex.ru/authorize"
YANDEX_TOKEN_ENDPOINT = "https://oauth.yandex.ru/token"
YANDEX_USERINFO_ENDPOINT = "https://login.yandex.ru/info"
STATE_TTL_SECONDS = 600
router = APIRouter()
def yandex_configured() -> bool:
return bool(_YANDEX_CLIENT_ID and _YANDEX_CLIENT_SECRET and _YANDEX_REDIRECT_URI)
def _sign_state(payload: str) -> str:
mac = hmac.new(str(_YANDEX_STATE_SECRET).encode(), payload.encode(), hashlib.sha256).digest()
return base64.urlsafe_b64encode(mac).decode().rstrip("=")
def _make_state(return_to: str) -> str:
nonce = secrets.token_urlsafe(16)
ts = str(int(time.time()))
payload = f"{nonce}.{ts}.{return_to}"
sig = _sign_state(payload)
raw = f"{payload}.{sig}".encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _verify_state(state: str) -> str | None:
try:
padded = state + "=" * (-len(state) % 4)
raw = base64.urlsafe_b64decode(padded).decode()
except Exception:
return None
parts = raw.rsplit(".", 1)
if len(parts) != 2:
return None
payload, sig = parts
expected = _sign_state(payload)
if not hmac.compare_digest(sig, expected):
return None
chunks = payload.split(".", 2)
if len(chunks) != 3:
return None
_nonce, ts, return_to = chunks
try:
if int(time.time()) - int(ts) > STATE_TTL_SECONDS:
return None
except Exception:
return None
return return_to or _OAUTH_SUCCESS_URI
@router.get("/yandex/authorize")
async def yandex_authorize(
request: Request,
return_to: str = Query(default=""),
):
"""Начинает OAuth-флоу Яндекс ID: редиректит юзера на consent screen."""
if not yandex_configured():
raise HTTPException(status_code=503, detail="Вход через Яндекс не настроен на этом сервере")
safe_return = return_to if return_to.startswith("/") else _OAUTH_SUCCESS_URI
state = _make_state(safe_return)
params = {
"client_id": _YANDEX_CLIENT_ID,
"redirect_uri": _YANDEX_REDIRECT_URI,
"response_type": "code",
"state": state,
"force_confirm": "yes",
}
url = f"{YANDEX_AUTH_ENDPOINT}?{urlencode(params)}"
logger.info("[Auth] Yandex authorize: ip={}", _client_ip(request))
return RedirectResponse(url, status_code=302)
@router.get("/yandex/callback")
async def yandex_callback(
request: Request,
response: Response,
code: str = Query(default=""),
state: str = Query(default=""),
error: str = Query(default=""),
session: AsyncSession = Depends(get_session),
):
"""Коллбек Яндекс ID: обмен code → token → userinfo → identity."""
if not yandex_configured():
raise HTTPException(status_code=503, detail="Вход через Яндекс не настроен на этом сервере")
if error:
logger.warning("[Auth] Yandex callback error: {} ip={}", error, _client_ip(request))
return RedirectResponse(f"/login?error=yandex_{error}", status_code=302)
if not code or not state:
raise HTTPException(status_code=400, detail="Отсутствует code или state")
return_to = _verify_state(state)
if return_to is None:
logger.warning("[Auth] Yandex callback: invalid/expired state ip={}", _client_ip(request))
raise HTTPException(status_code=400, detail="Неверный или просроченный state")
async with httpx.AsyncClient(timeout=10.0) as client:
try:
token_res = await client.post(
YANDEX_TOKEN_ENDPOINT,
data={
"grant_type": "authorization_code",
"code": code,
"client_id": _YANDEX_CLIENT_ID,
"client_secret": _YANDEX_CLIENT_SECRET,
},
headers={"Accept": "application/json"},
)
except httpx.HTTPError as e:
logger.warning("[Auth] Yandex token exchange network error: {}", e)
raise HTTPException(status_code=502, detail="Не удалось связаться с Яндекс") from e
if token_res.status_code != 200:
logger.warning("[Auth] Yandex token exchange failed: {} {}", token_res.status_code, token_res.text[:200])
raise HTTPException(status_code=401, detail="Яндекс отклонил токен")
token_payload = token_res.json()
access_token = token_payload.get("access_token")
if not access_token:
raise HTTPException(status_code=401, detail="Яндекс не вернул access_token")
try:
info_res = await client.get(
YANDEX_USERINFO_ENDPOINT,
headers={"Authorization": f"OAuth {access_token}"},
params={"format": "json"},
)
except httpx.HTTPError as e:
logger.warning("[Auth] Yandex userinfo network error: {}", e)
raise HTTPException(status_code=502, detail="Не удалось получить профиль Яндекс") from e
if info_res.status_code != 200:
raise HTTPException(status_code=401, detail="Яндекс не вернул профиль")
info = info_res.json()
yandex_sub = str(info.get("id") or "").strip()
email = (info.get("default_email") or "").strip().lower() or None
if not yandex_sub:
raise HTTPException(status_code=401, detail="Яндекс не вернул идентификатор пользователя")
identity = await idb.get_or_create_identity_for_yandex(
session,
yandex_sub=yandex_sub,
email=email,
)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity, request=request)
logger.info(
"[Auth] Login success: identity={}, yandex_sub={}, ip={}, method=yandex",
identity.id,
yandex_sub,
_client_ip(request),
)
redirect = RedirectResponse(return_to, status_code=302)
set_auth_cookie(redirect, token, request)
set_is_admin_cookie(redirect, identity, request)
return redirect
@router.get("/yandex/status")
async def yandex_status():
"""Позволяет фронтенду узнать, настроен ли вход через Яндекс на этом сервере."""
return {"enabled": yandex_configured()}
-31
View File
@@ -1,31 +0,0 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from services.coupons import resolve_percent_coupon
from services.errors import ServiceError
async def resolve_percent_coupon_pricing(
session: AsyncSession,
billing_user_id: int,
base_price_rub: int,
coupon_code: str | None,
) -> tuple[int, int, int | None, str | None]:
"""Применяет процентный купон. Бросает HTTPException при ошибке."""
try:
return await resolve_percent_coupon(
session=session,
billing_user_id=billing_user_id,
base_price_rub=base_price_rub,
coupon_code=coupon_code,
)
except ServiceError as e:
status_map = {
"not_found": 404,
"limit_exceeded": 409,
"validation_error": 400,
}
raise HTTPException(
status_code=status_map.get(e.code, 400),
detail=e.message,
)
-73
View File
@@ -1,73 +0,0 @@
from fastapi import Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_request_actor, get_session, verify_identity_token
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import CouponBase, CouponResponse, CouponUpdate
from api.v2.schemas.web_public import CouponApplyRequest, CouponApplyResponse
from database import identities as idb
from database.models import Coupon
from services.coupons import apply_fixed_coupon
from services.errors import LimitExceededError, NotFoundError, ServiceError, ValidationError
router = generate_crud_router(
model=Coupon,
schema_response=CouponResponse,
schema_create=CouponBase,
schema_update=CouponUpdate,
identifier_field="code",
parameter_name="code",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
async def _resolve_coupon_user_id(session: AsyncSession, request: Request, identity) -> tuple[int, int | None]:
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
tg_id = actor.telegram_chat_id if actor else None
return int(billing_user_id), tg_id
def _service_error_to_http(e: ServiceError) -> HTTPException:
status_map = {
"not_found": 404,
"limit_exceeded": 409,
"validation_error": 400,
"forbidden": 403,
}
return HTTPException(status_code=status_map.get(e.code, 400), detail=e.message)
@router.post("/apply", response_model=CouponApplyResponse)
async def apply_coupon(
body: CouponApplyRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from api.ratelimit import enforce_rate_limit
await enforce_rate_limit(request, session, bucket="coupon_apply", max_per_window=10, window_sec=60)
user_id, tg_id = await _resolve_coupon_user_id(session, request, identity)
try:
result = await apply_fixed_coupon(
session=session,
user_id=user_id,
tg_id=tg_id,
code=str(body.code or ""),
)
return CouponApplyResponse(
ok=True,
message="Купон успешно активирован",
coupon_code=result.coupon_code,
amount=result.amount,
balance=result.balance,
)
except ServiceError as e:
await session.rollback()
raise _service_error_to_http(e)
except Exception:
await session.rollback()
raise HTTPException(status_code=500, detail="Ошибка активации купона")
-117
View File
@@ -1,117 +0,0 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas.flows import FlowCreate, FlowResponse, FlowUpdate
from database.models import WebFlow
from database.site_revision import bump_site_revision
router = APIRouter()
def _flow_to_response(flow: WebFlow) -> FlowResponse:
return FlowResponse(
id=flow.id,
name=flow.name,
nodes=flow.nodes or [],
edges=flow.edges or [],
entry_node_id=flow.entry_node_id,
version=flow.version,
)
@router.get("/flows/{flow_id}", response_model=FlowResponse, response_model_by_alias=True)
async def get_flow_public(flow_id: str, session: AsyncSession = Depends(get_session)):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
return _flow_to_response(flow)
@router.get("/admin/flows", response_model=list[FlowResponse], response_model_by_alias=True)
async def list_flows(
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
result = await session.execute(select(WebFlow))
return [_flow_to_response(f) for f in result.scalars().all()]
@router.get("/admin/flows/{flow_id}", response_model=FlowResponse, response_model_by_alias=True)
async def get_flow_admin(
flow_id: str,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
return _flow_to_response(flow)
@router.post("/admin/flows", response_model=FlowResponse, status_code=201, response_model_by_alias=True)
async def create_flow(
body: FlowCreate,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
existing = await session.get(WebFlow, body.id)
if existing:
raise HTTPException(409, "Flow with this ID already exists")
flow = WebFlow(
id=body.id,
name=body.name,
nodes=[n.model_dump() for n in body.nodes],
edges=[e.model_dump() for e in body.edges],
entry_node_id=body.entry_node_id,
version=1,
updated_at=datetime.now(UTC),
)
session.add(flow)
await bump_site_revision(session)
await session.refresh(flow)
return _flow_to_response(flow)
@router.put("/admin/flows/{flow_id}", response_model=FlowResponse, response_model_by_alias=True)
async def update_flow(
flow_id: str,
body: FlowUpdate,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
if body.name is not None:
flow.name = body.name
flow.nodes = [n.model_dump() for n in body.nodes]
flow.edges = [e.model_dump() for e in body.edges]
flow.entry_node_id = body.entry_node_id
flow.version = flow.version + 1
flow.updated_at = datetime.now(UTC)
await bump_site_revision(session)
await session.refresh(flow)
return _flow_to_response(flow)
@router.delete("/admin/flows/{flow_id}", status_code=204)
async def delete_flow(
flow_id: str,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
await session.delete(flow)
await bump_site_revision(session)
-337
View File
@@ -1,337 +0,0 @@
from math import ceil
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
get_request_actor,
get_session,
validate_redirect_url,
verify_identity_admin,
verify_identity_token,
)
from api.v2.base_crud import generate_crud_router
from api.v2.routes.tariffs import _resolve_default_web_payment_provider, _resolve_public_base_url
from api.v2.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from api.v2.schemas.web_public import (
GiftCreatePreviewResponse,
GiftCreateRequest,
GiftCreateResponse,
GiftRedeemRequest,
GiftRedeemResponse,
GiftUsageEntry,
MyGiftItem,
MyGiftsResponse,
)
from config import GIFT_BUTTON
from core.bootstrap import BUTTONS_CONFIG
from database import (
get_balance,
identities as idb,
)
from database.access.resolution import resolve_user_optional
from database.models import Gift, GiftUsage, Tariff
from database.tariffs import get_tariff_by_id
from database.temporary_data import create_temporary_data
from services.errors import NotFoundError, ValidationError
from services.formatting import get_site_gift_link
from services.gifts import (
create_gift as service_create_gift,
redeem_gift as service_redeem_gift,
)
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
from services.tariffs import calculate_config_price
router = APIRouter()
def _check_gifts_enabled():
if not bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)):
raise HTTPException(status_code=403, detail="Подарки отключены")
@router.post("/create", tags=["Gifts"])
async def create_gift_for_user(
body: GiftCreateRequest,
request: Request,
preview: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from api.ratelimit import enforce_rate_limit
if not preview:
await enforce_rate_limit(request, session, bucket="gift_create", max_per_window=10, window_sec=60)
_check_gifts_enabled()
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
tariff = await get_tariff_by_id(session, body.tariff_id)
if not tariff or tariff.get("group_code") != "gifts" or not tariff.get("is_active", True):
raise HTTPException(status_code=404, detail="Тариф не найден")
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
balance = float(await get_balance(session, billing_user_id))
required_amount = int(max(0, ceil(float(price) - balance)))
if preview:
return GiftCreatePreviewResponse(
ok=True,
price_rub=price,
balance_rub=balance,
sufficient_funds=balance >= price,
tariff_name=str(tariff.get("name", "")),
duration_days=int(tariff.get("duration_days") or 0),
)
if required_amount > 0:
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
payment_request = PaymentLinkRequest(
legacy_user_ref=int(billing_user_id),
amount=required_amount,
currency="RUB",
provider_id=provider_id,
success_url=success_url,
failure_url=failure_url,
metadata={
"payment_flow": "gift_create",
"tariff_id": int(body.tariff_id),
"selected_device_limit": body.selected_device_limit,
"selected_traffic_gb": body.selected_traffic_gb,
"selected_price_rub": int(price),
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(billing_user_id),
"waiting_for_payment",
{
"payment_flow": "gift_create",
"tariff_id": int(body.tariff_id),
"required_amount": int(required_amount),
"selected_price_rub": int(price),
"selected_device_limit": body.selected_device_limit,
"selected_traffic_gb": body.selected_traffic_gb,
},
)
return GiftCreateResponse(
ok=True,
message="Требуется оплата для создания подарка",
payment_required=True,
required_amount_rub=required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
from services.errors import InsufficientFundsError, NotFoundError
try:
result = await service_create_gift(
session=session,
sender_user_ref=billing_user_id,
tariff_id=body.tariff_id,
selected_device_limit=body.selected_device_limit,
selected_traffic_gb=body.selected_traffic_gb,
selected_price_rub=price,
)
except InsufficientFundsError as e:
raise HTTPException(status_code=400, detail=str(e)) from None
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from None
new_balance = float(await get_balance(session, billing_user_id))
return GiftCreateResponse(
ok=True,
message=f"Подарок создан — {result.tariff_name} на {result.duration_text}",
gift_id=result.gift_id,
site_gift_link=result.site_gift_link,
tariff_name=result.tariff_name,
duration_days=result.duration_days,
price_charged=result.price_charged,
balance_rub=new_balance,
)
@router.get("/my", response_model=MyGiftsResponse, tags=["Gifts"])
async def get_my_gifts(
request: Request,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
_check_gifts_enabled()
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
base_filter = Gift.sender_user_id == billing_user_id
total = (await session.execute(select(func.count()).select_from(Gift).where(base_filter))).scalar_one()
result = await session.execute(
select(Gift).where(base_filter).order_by(Gift.created_at.desc()).limit(limit).offset(offset)
)
gifts = result.scalars().all()
tariff_ids = {g.tariff_id for g in gifts if g.tariff_id}
tariff_map: dict[int, str] = {}
duration_map: dict[int, int] = {}
if tariff_ids:
tariff_rows = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
for t in tariff_rows.scalars().all():
tariff_map[t.id] = t.name or ""
duration_map[t.id] = int(t.duration_days or 0)
gift_ids = [g.gift_id for g in gifts]
usages_map: dict[str, list[GiftUsageEntry]] = {gid: [] for gid in gift_ids}
if gift_ids:
usage_rows = await session.execute(select(GiftUsage).where(GiftUsage.gift_id.in_(gift_ids)))
for u in usage_rows.scalars().all():
usages_map.setdefault(u.gift_id, []).append(
GiftUsageEntry(
user_id=int(u.user_id),
used_at=u.used_at.isoformat() if u.used_at else None,
)
)
items = []
for g in gifts:
items.append(
MyGiftItem(
gift_id=g.gift_id,
tariff_name=tariff_map.get(g.tariff_id, ""),
duration_days=duration_map.get(g.tariff_id, 0),
price_rub=int(g.selected_price_rub or 0),
created_at=g.created_at.isoformat() if g.created_at else None,
expiry_time=g.expiry_time.isoformat() if g.expiry_time else None,
is_used=bool(g.is_used),
is_unlimited=bool(g.is_unlimited),
max_usages=g.max_usages,
site_gift_link=get_site_gift_link(g.gift_id),
usages=usages_map.get(g.gift_id, []),
)
)
return MyGiftsResponse(ok=True, gifts=items, total=total, limit=limit, offset=offset)
@router.delete("/my/{gift_id}", response_model=dict, tags=["Gifts"])
async def delete_my_gift(
request: Request,
gift_id: str = Path(...),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Удаляет свой подарок."""
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
gift = result.scalar_one_or_none()
if not gift or gift.sender_user_id != billing_user_id:
raise HTTPException(status_code=404, detail="Подарок не найден")
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
await session.delete(gift)
return {"ok": True, "message": "Подарок удалён"}
gift_router = generate_crud_router(
model=Gift,
schema_response=GiftResponse,
schema_create=GiftBase,
schema_update=GiftUpdate,
identifier_field="gift_id",
parameter_name="gift_id",
enabled_methods=["get_all", "get_one", "create", "update"],
)
router.include_router(gift_router, prefix="", tags=["Gifts"])
@router.post("/redeem", response_model=GiftRedeemResponse, tags=["Gifts"])
async def redeem_gift(
body: GiftRedeemRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from api.ratelimit import enforce_rate_limit
await enforce_rate_limit(request, session, bucket="gift_redeem", max_per_window=10, window_sec=60)
_check_gifts_enabled()
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
try:
result = await service_redeem_gift(session, body.gift_code, billing_user_id)
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e)) from None
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from None
except Exception:
raise HTTPException(status_code=500, detail="Не удалось активировать подарок") from None
return GiftRedeemResponse(
ok=True,
message=result.message,
gift_id=result.gift_id,
tariff_id=result.tariff_id,
duration_days=result.duration_days,
)
@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
async def get_gifts_by_tg_id(
tg_id: int = Path(...),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Список подарков по tg_id отправителя."""
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Gifts not found")
result = await session.execute(select(Gift).where(Gift.sender_user_id == u.id))
gifts = result.scalars().all()
if not gifts:
raise HTTPException(status_code=404, detail="Gifts not found")
return gifts
gift_usage_router = generate_crud_router(
model=GiftUsage,
schema_response=GiftUsageResponse,
schema_create=None,
schema_update=None,
identifier_field="gift_id",
enabled_methods=["get_all", "get_one", "delete"],
)
router.include_router(gift_usage_router, prefix="/usages", tags=["Gifts", "GiftUsages"])
@router.delete("/{gift_id}", response_model=dict, tags=["Gifts"])
async def delete_gift_with_usages(
gift_id: str = Path(..., description="ID подарка"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Удаляет подарок и все его использования."""
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
gift = result.scalar_one_or_none()
if not gift:
raise HTTPException(status_code=404, detail="Gift not found")
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
await session.delete(gift)
return {"message": "Подарок и связанные использования удалены"}
-101
View File
@@ -1,101 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas.identities import (
IdentityAttachEmail,
IdentityAttachTelegram,
IdentityCreate,
IdentityResponse,
)
from database import identities as idb
router = APIRouter(tags=["Identities"])
@router.post("/", response_model=IdentityResponse)
async def create_identity(
body: IdentityCreate,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Создаёт идентичность; можно указать email и/или tg_id."""
email = body.email.strip().lower() if body.email and body.email.strip() else None
if not email and not body.tg_id:
raise HTTPException(status_code=400, detail="Укажите email и/или tg_id")
identity = await idb.create_identity(session, email=email, tg_id=body.tg_id)
return IdentityResponse.model_validate(identity)
@router.get("/{identity_id}", response_model=IdentityResponse)
async def get_identity(
identity_id: str = Path(...),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Возвращает идентичность по id."""
identity = await idb.get_identity_by_id(session, identity_id)
if not identity:
raise HTTPException(status_code=404, detail="Identity not found")
return IdentityResponse.model_validate(identity)
@router.get("/by/email", response_model=IdentityResponse)
async def get_identity_by_email(
email: str = Query(..., min_length=1),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Возвращает идентичность по email."""
identity = await idb.get_identity_by_email(session, email)
if not identity:
raise HTTPException(status_code=404, detail="Identity not found")
return IdentityResponse.model_validate(identity)
@router.get("/by/tg_id/{tg_id}", response_model=IdentityResponse)
async def get_identity_by_tg_id(
tg_id: int = Path(...),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Возвращает идентичность по Telegram ID."""
identity = await idb.get_identity_by_tg_id(session, tg_id)
if not identity:
raise HTTPException(status_code=404, detail="Identity not found")
return IdentityResponse.model_validate(identity)
@router.patch("/{identity_id}/attach-email", response_model=IdentityResponse)
async def attach_email(
identity_id: str = Path(...),
body: IdentityAttachEmail = ...,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Привязывает email к идентичности."""
identity = await idb.attach_email(session, identity_id, body.email)
if not identity:
raise HTTPException(
status_code=404,
detail="Identity not found или email уже привязан к другой идентичности",
)
return IdentityResponse.model_validate(identity)
@router.patch("/{identity_id}/attach-telegram", response_model=IdentityResponse)
async def attach_telegram(
identity_id: str = Path(...),
body: IdentityAttachTelegram = ...,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Привязывает Telegram (tg_id) к идентичности."""
identity = await idb.attach_telegram(session, identity_id, body.tg_id)
if not identity:
raise HTTPException(
status_code=404,
detail="Identity not found или tg_id уже привязан к другой идентичности",
)
return IdentityResponse.model_validate(identity)
-5
View File
@@ -1,5 +0,0 @@
from . import admin, user # noqa: F401 — import triggers endpoint registration
from ._common import router, user_router
__all__ = ["router", "user_router"]
-271
View File
@@ -1,271 +0,0 @@
import asyncio
import re
from base64 import b64encode
from datetime import datetime
from io import BytesIO
from math import ceil
from typing import Any
from urllib.parse import urlsplit
import qrcode
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
get_request_actor,
get_session,
validate_redirect_url,
verify_identity_admin,
verify_identity_token,
)
from api.v2.base_crud import generate_crud_router
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
from api.v2.schemas import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from api.v2.schemas.web_public import (
AccountKeyActionResponse,
AccountKeyActionsAvailability,
AccountKeyActionsConfigResponse,
AccountKeyAddonOptionResponse,
AccountKeyAddonsPreviewRequest,
AccountKeyAddonsPreviewResponse,
AccountKeyAliasUpdateRequest,
AccountKeyApplyAddonsResponse,
AccountKeyChangeLocationRequest,
AccountKeyChangeLocationResponse,
AccountKeyConnectionResponse,
AccountKeyDetailsResponse,
AccountKeyLocationOptionResponse,
AccountKeyLocationsResponse,
AccountKeyQrResponse,
AccountKeyRenewRequest,
AccountKeyRenewResponse,
AccountKeyResetHwidResponse,
AccountKeyResponse,
)
from config import (
ENABLE_DELETE_KEY_BUTTON,
HWID_RESET_BUTTON,
INSTRUCTIONS_BUTTON,
QRCODE,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
USE_COUNTRY_SELECTION,
)
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, PAYMENTS_CONFIG, TARIFFS_CONFIG
from core.settings.tariffs_config import normalize_tariff_config
from database import (
check_server_name_by_cluster,
filter_cluster_by_subgroup,
get_balance,
get_key_details,
get_keys,
get_tariff_by_id,
identities as idb,
save_key_config_with_mode,
update_balance,
)
from database.access.resolution import resolve_user_optional
from database.coupons import mark_coupon_used
from database.models import Key, Server, ServerSpecialgroup, Tariff
from database.temporary_data import create_temporary_data
from handlers.buttons import CONNECT_DEVICE, ROUTER_BUTTON, TV_BUTTON
from handlers.keys.key_view import build_key_view_payload
from handlers.tariffs.addons.key_addons_pack import calc_pack_full_price_rub, get_pack_flags
from handlers.tariffs.addons.utils import calc_remaining_ratio_seconds, is_not_downgrade
from handlers.utils import ALLOWED_GROUP_CODES, is_full_remnawave_cluster
from logger import logger
from panels._3xui import delete_client, get_xui_instance
from panels.remnawave import RemnawaveAPI, get_vless_link_for_remnawave_by_username
from panels.remnawave_runtime import get_remnawave_profile, invalidate_remnawave_profile, with_remnawave_api
from services.operations import (
create_client_on_server,
create_key_on_cluster,
delete_key_from_cluster,
renew_key_in_cluster,
)
from services.operations.aggregated_links import make_aggregated_link
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
from services.payments.providers import WEB_LINK_PROVIDER_IDS
from services.tariffs import calculate_config_price
from services.tariffs.tariff_display import GB, get_effective_limits_for_key, get_key_tariff_addons_state
router = generate_crud_router(
model=Key,
schema_response=KeyResponse,
schema_create=KeyBase,
schema_update=KeyUpdate,
identifier_field="tg_id",
extra_get_by_email=True,
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
)
user_router = APIRouter()
def _key_actions_config() -> AccountKeyActionsConfigResponse:
addons_mode = str(TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE", "") or "").strip().lower()
if addons_mode not in {"", "traffic", "devices", "all"}:
addons_mode = ""
addons_enabled_default = addons_mode in {"", "traffic", "devices", "all"}
return AccountKeyActionsConfigResponse(
renew_enabled=True,
delete_enabled=bool(BUTTONS_CONFIG.get("DELETE_KEY_BUTTON_ENABLE", ENABLE_DELETE_KEY_BUTTON)),
qr_enabled=bool(BUTTONS_CONFIG.get("QRCODE_BUTTON_ENABLE", QRCODE)),
hwid_reset_enabled=bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
country_change_enabled=bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)),
instructions_enabled=bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
addons_enabled=addons_enabled_default,
addons_mode=addons_mode,
tv_connect_enabled=bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE")),
)
def _extract_key_actions_from_markup(markup) -> AccountKeyActionsAvailability:
actions = AccountKeyActionsAvailability()
rows = getattr(markup, "inline_keyboard", None) or []
for row in rows:
for button in row:
callback_data = str(getattr(button, "callback_data", "") or "")
text = str(getattr(button, "text", "") or "")
has_url = bool(getattr(button, "url", None))
has_web_app = bool(getattr(button, "web_app", None))
if callback_data.startswith("connect_router|") or text == ROUTER_BUTTON:
actions.can_connect_router = True
if callback_data.startswith("connect_tv|") or text == TV_BUTTON:
actions.can_connect_tv = True
if callback_data.startswith("connect_device|") or (text == CONNECT_DEVICE and (has_url or has_web_app)):
actions.can_connect_device = True
if callback_data.startswith("renew_key|"):
actions.can_renew = True
if callback_data.startswith("key_addons|"):
actions.can_addons = True
if callback_data.startswith("reset_hwid|"):
actions.can_reset_hwid = True
if callback_data.startswith("show_qr|"):
actions.can_qr = True
if callback_data.startswith("delete_key|"):
actions.can_delete = True
if callback_data.startswith("change_location|"):
actions.can_change_location = True
return actions
async def _resolve_available_location_servers(session: AsyncSession, db_key: Key) -> list[str]:
current_server = str(getattr(db_key, "server_id", "") or "")
if not current_server:
return []
cluster_info = await check_server_name_by_cluster(session, current_server)
if not cluster_info:
return []
cluster_name = str(cluster_info.get("cluster_name") or "")
if not cluster_name:
return []
q = (
select(
Server.id,
Server.server_name,
Server.api_url,
Server.panel_type,
Server.enabled,
Server.max_keys,
)
.where(Server.cluster_name == cluster_name)
.where(Server.server_name != current_server)
)
servers = [dict(m) for m in (await session.execute(q)).mappings().all()]
if not servers:
return []
server_ids = [s["id"] for s in servers if s.get("id") is not None]
groups_map: dict[int, list[str]] = {}
if server_ids:
r = await session.execute(
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
ServerSpecialgroup.server_id.in_(server_ids)
)
)
for sid, gc in r.all():
groups_map.setdefault(int(sid), []).append(gc)
for server in servers:
sid_raw = server.get("id")
sid = int(sid_raw) if sid_raw is not None else -1
server["special_groups"] = [g for g in groups_map.get(sid, []) if g in ALLOWED_GROUP_CODES]
key_tariff_id = getattr(db_key, "tariff_id", None)
subgroup_title = None
tariff_dict = None
if key_tariff_id:
tariff_dict = await get_tariff_by_id(session, int(key_tariff_id))
if tariff_dict:
subgroup_title = tariff_dict.get("subgroup_title")
available_servers = [s for s in servers if bool(s.get("enabled", True))]
if subgroup_title and available_servers:
filtered_servers = await filter_cluster_by_subgroup(
session=session,
cluster=available_servers,
target_subgroup=str(subgroup_title).strip(),
cluster_id=cluster_name,
tariff_id=int(key_tariff_id) if key_tariff_id else None,
)
if filtered_servers:
available_servers = filtered_servers
else:
available_servers = []
if available_servers and tariff_dict:
special = None
gc = str(tariff_dict.get("group_code") or "").lower()
if gc and gc in ALLOWED_GROUP_CODES:
special = gc
if special:
bound_servers = [s for s in available_servers if special in (s.get("special_groups") or [])]
if bound_servers:
available_servers = bound_servers
names = sorted({
str(s.get("server_name") or "").strip() for s in available_servers if str(s.get("server_name") or "").strip()
})
return names
async def _resolve_billing_user_id(request: Request, identity, session: AsyncSession) -> int:
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
return int(billing_user_id)
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith(("http://", "https://")):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith(("http://", "https://")):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
host = forwarded_host or str(request.headers.get("host") or "").strip()
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
if host:
return f"{scheme}://{host}".rstrip("/")
return str(request.base_url).rstrip("/")
def _resolve_default_web_payment_provider() -> str | None:
for provider_id in WEB_LINK_PROVIDER_IDS:
if bool(PAYMENTS_CONFIG.get(provider_id)):
return provider_id
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
def _normalize_expiry_ms(raw_value: int | float | None) -> int:
if not raw_value:
return 0
value = int(raw_value)
if value > 10**13:
value //= 1000
elif value < 10**10:
value *= 1000
return value
-115
View File
@@ -1,115 +0,0 @@
from ._common import * # noqa: F401,F403
from ._common import router, user_router # noqa: F401
@router.delete("/by_email/{email}", response_model=dict)
async def delete_key_by_email(
email: str = Path(..., description="Email клиента"),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Удаляет ключ по email с кластера и из БД."""
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
try:
await delete_key_from_cluster(
session=session,
email=db_key.email,
client_id=db_key.client_id,
cluster_id=db_key.server_id,
)
await session.delete(db_key)
logger.info(f"[API] Ключ удалён: {db_key.client_id}")
return {"message": "Ключ успешно удалён"}
except Exception as e:
logger.error(f"[API] Ошибка при удалении ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении ключа")
@router.get("/routers/{tg_id}", response_model=list[KeyResponse])
async def get_router_keys_by_tg_id(
tg_id: int = Path(..., description="Telegram ID пользователя"),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Список ключей пользователя с тарифами группы routers."""
tariffs_result = await session.execute(select(Tariff.id).where(Tariff.group_code == "routers"))
tariff_ids = [row[0] for row in tariffs_result.all()]
if not tariff_ids:
return []
u = await resolve_user_optional(session, tg_id)
if u is None:
return []
keys_result = await session.execute(select(Key).where(Key.user_id == u.id, Key.tariff_id.in_(tariff_ids)))
return keys_result.scalars().all()
@router.patch("/edit/by_email/{email}", response_model=KeyResponse)
async def edit_key_by_email(
email: str = Path(..., description="Email клиента"),
key_update: KeyUpdate = Body(...),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Обновляет ключ по email и синхронизирует с кластером."""
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
for field, value in key_update.model_dump(exclude_unset=True).items():
if field == "expiry_time" and value is not None:
if isinstance(value, int):
ms = value
elif isinstance(value, datetime):
ms = int(value.timestamp() * 1000)
else:
raise HTTPException(status_code=400, detail="Некорректный формат времени")
setattr(db_key, field, ms)
else:
setattr(db_key, field, value)
try:
new_expiry_time = db_key.expiry_time
await renew_key_in_cluster(
cluster_id=db_key.server_id,
email=db_key.email,
client_id=db_key.client_id,
new_expiry_time=new_expiry_time,
total_gb=getattr(db_key, "traffic_limit", None),
session=session,
hwid_device_limit=getattr(db_key, "device_limit", None),
reset_traffic=True,
)
logger.info(f"[API] Ключ обновлён: {db_key.client_id}")
return db_key
except Exception as e:
logger.error(f"[API] Ошибка при обновлении ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при обновлении ключа")
@router.post("/create", response_model=dict, status_code=status.HTTP_201_CREATED)
async def create_key_api(
payload: KeyCreateRequest = Body(...),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Создаёт ключ на кластере."""
try:
await create_key_on_cluster(
cluster_id=payload.cluster_id,
tg_id=payload.tg_id,
client_id=payload.client_id,
email=payload.email or f"{payload.tg_id}_key",
expiry_timestamp=payload.expiry_timestamp,
plan=payload.tariff_id,
session=session,
remnawave_link=payload.remnawave_link,
hwid_limit=payload.hwid_limit,
traffic_limit_bytes=payload.traffic_limit_bytes,
is_trial=payload.is_trial or False,
)
return {"message": "Ключ успешно создан"}
except Exception as e:
logger.error(f"[API] Ошибка при создании ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
-1
View File
@@ -1 +0,0 @@
from . import addons, core, hwid, location, renew # noqa: F401 — trigger registration
-662
View File
@@ -1,662 +0,0 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
router,
user_router,
)
@user_router.get("/{client_id}/addons-preview", response_model=AccountKeyAddonsPreviewResponse)
async def user_key_addons_preview(
client_id: str,
request: Request,
selected_device_limit: int | None = Query(None),
selected_traffic_gb: int | None = Query(None),
include_device: bool | None = Query(None),
include_traffic: bool | None = Query(None),
coupon_code: str | None = Query(None),
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.addons_enabled:
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
tariff_id = getattr(db_key, "tariff_id", None)
if not tariff_id:
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
tariff = await get_tariff_by_id(session, int(tariff_id))
if not tariff:
raise HTTPException(status_code=404, detail="Тариф не найден")
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
if not key_details:
raise HTTPException(status_code=404, detail="Подписка не найдена")
(
_tariff_name,
_subgroup_title,
_traffic_limit_gb,
_device_limit,
_panel,
is_tariff_configurable,
addons_devices_enabled,
addons_traffic_enabled,
) = await get_key_tariff_addons_state(session=session, key_record=key_details, db_key=db_key)
if not is_tariff_configurable:
raise HTTPException(status_code=400, detail="Тариф не поддерживает доп. опции")
cfg = normalize_tariff_config(tariff)
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
device_options: list[int] = []
for value in raw_device_options:
try:
device_options.append(int(value))
except (TypeError, ValueError):
continue
traffic_options: list[int] = []
for value in raw_traffic_options:
try:
traffic_options.append(int(value))
except (TypeError, ValueError):
continue
device_options = sorted(set(device_options), key=lambda val: (int(val == 0), val))
traffic_options = sorted(set(traffic_options), key=lambda val: (int(val == 0), val))
has_device_option = bool(device_options) and bool(addons_devices_enabled)
has_traffic_option = bool(traffic_options) and bool(addons_traffic_enabled)
pack_devices, pack_traffic, pack_mode = get_pack_flags()
if pack_mode:
has_device_option = has_device_option and bool(pack_devices)
has_traffic_option = has_traffic_option and bool(pack_traffic)
if not has_device_option:
device_options = []
if not has_traffic_option:
traffic_options = []
if not has_device_option and not has_traffic_option:
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
selected_device_limit_db = key_details.get("selected_device_limit")
selected_traffic_limit_db = key_details.get("selected_traffic_limit")
current_device_limit_db = key_details.get("current_device_limit")
current_traffic_limit_db = key_details.get("current_traffic_limit")
base_devices = tariff.get("device_limit")
base_devices = int(base_devices) if base_devices is not None else None
base_traffic_bytes = tariff.get("traffic_limit")
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
current_device_limit = (
int(current_device_limit_db)
if current_device_limit_db is not None
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
)
current_traffic_gb = (
int(current_traffic_limit_db)
if current_traffic_limit_db is not None
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
)
if pack_mode and current_device_limit is not None and int(current_device_limit) == 0:
has_device_option = False
device_options = []
if pack_mode and current_traffic_gb is not None and int(current_traffic_gb) == 0:
has_traffic_option = False
traffic_options = []
if not has_device_option and not has_traffic_option:
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
if pack_mode:
include_device_effective = (
bool(include_device) if include_device is not None else selected_device_limit is not None
)
include_traffic_effective = (
bool(include_traffic) if include_traffic is not None else selected_traffic_gb is not None
)
selected_device = selected_device_limit if selected_device_limit is not None else None
selected_traffic = selected_traffic_gb if selected_traffic_gb is not None else None
else:
include_device_effective = has_device_option
include_traffic_effective = has_traffic_option
selected_device = selected_device_limit if selected_device_limit is not None else current_device_limit
selected_traffic = selected_traffic_gb if selected_traffic_gb is not None else current_traffic_gb
if (
has_device_option
and include_device_effective
and selected_device is not None
and int(selected_device) not in device_options
):
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
if (
has_traffic_option
and include_traffic_effective
and selected_traffic is not None
and int(selected_traffic) not in traffic_options
):
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
base_price_for_current = int(
calculate_config_price(
tariff=tariff,
selected_device_limit=current_devices_for_price,
selected_traffic_gb=current_traffic_for_price,
)
)
if pack_mode:
diff_full = int(
calc_pack_full_price_rub(
tariff=tariff,
has_device_option=bool(has_device_option and include_device_effective),
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
selected_devices=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
)
)
recalc_enabled = bool(
MODES_CONFIG.get(
"KEY_ADDONS_RECALC_PRICE",
TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False),
)
)
if recalc_enabled:
remaining_seconds, total_seconds = calc_remaining_ratio_seconds(
key_details.get("expiry_time"),
tariff,
)
extra_price_rub = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds)
else:
extra_price_rub = diff_full
total_price_rub = int(base_price_for_current + diff_full)
else:
total_price_rub = int(
calculate_config_price(
tariff=tariff,
selected_device_limit=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
)
)
extra_price_rub = int(max(0, total_price_rub - base_price_for_current))
final_extra_price_rub, discount_rub, _coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
session=session,
billing_user_id=int(billing_user_id),
base_price_rub=int(max(0, extra_price_rub)),
coupon_code=coupon_code,
)
return AccountKeyAddonsPreviewResponse(
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=int(tariff_id),
addons_mode=str(pack_mode or ""),
has_device_option=bool(has_device_option),
has_traffic_option=bool(has_traffic_option),
current_device_limit=int(current_device_limit) if current_device_limit is not None else None,
current_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
selected_device_limit=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
device_options=[
AccountKeyAddonOptionResponse(
value=int(val),
label=(
"Безлимит устройств"
if int(val) <= 0
else (f"+{int(val)} устройств" if pack_mode else f"{int(val)} устройств")
),
)
for val in device_options
],
traffic_options=[
AccountKeyAddonOptionResponse(
value=int(val),
label=("Безлимит трафика" if int(val) <= 0 else (f"+{int(val)} ГБ" if pack_mode else f"{int(val)} ГБ")),
)
for val in traffic_options
],
total_price_rub=int(total_price_rub),
extra_price_rub=int(max(0, extra_price_rub)),
discount_rub=int(discount_rub),
final_price_rub=int(max(0, final_extra_price_rub)),
applied_coupon_code=applied_coupon_code,
balance_rub=float(await get_balance(session, int(billing_user_id))),
)
@user_router.post("/{client_id}/apply-addons", response_model=AccountKeyApplyAddonsResponse)
async def user_key_apply_addons(
client_id: str,
body: AccountKeyAddonsPreviewRequest,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.addons_enabled:
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
tariff_id = getattr(db_key, "tariff_id", None)
if not tariff_id:
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
tariff = await get_tariff_by_id(session, int(tariff_id))
if not tariff:
raise HTTPException(status_code=404, detail="Тариф не найден")
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
if not key_details:
raise HTTPException(status_code=404, detail="Подписка не найдена")
(
_tariff_name,
_subgroup_title,
_traffic_limit_gb,
_device_limit,
_panel,
is_tariff_configurable,
addons_devices_enabled,
addons_traffic_enabled,
) = await get_key_tariff_addons_state(session=session, key_record=key_details, db_key=db_key)
if not is_tariff_configurable:
raise HTTPException(status_code=400, detail="Тариф не поддерживает доп. опции")
cfg = normalize_tariff_config(tariff)
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
device_options: list[int] = []
for value in raw_device_options:
try:
device_options.append(int(value))
except (TypeError, ValueError):
continue
traffic_options: list[int] = []
for value in raw_traffic_options:
try:
traffic_options.append(int(value))
except (TypeError, ValueError):
continue
device_options = sorted(set(device_options), key=lambda val: (int(val == 0), val))
traffic_options = sorted(set(traffic_options), key=lambda val: (int(val == 0), val))
has_device_option = bool(device_options) and bool(addons_devices_enabled)
has_traffic_option = bool(traffic_options) and bool(addons_traffic_enabled)
pack_devices, pack_traffic, pack_mode = get_pack_flags()
if pack_mode:
has_device_option = has_device_option and bool(pack_devices)
has_traffic_option = has_traffic_option and bool(pack_traffic)
if not has_device_option:
device_options = []
if not has_traffic_option:
traffic_options = []
if not has_device_option and not has_traffic_option:
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
selected_device_limit_db = key_details.get("selected_device_limit")
selected_traffic_limit_db = key_details.get("selected_traffic_limit")
current_device_limit_db = key_details.get("current_device_limit")
current_traffic_limit_db = key_details.get("current_traffic_limit")
base_devices = tariff.get("device_limit")
base_devices = int(base_devices) if base_devices is not None else None
base_traffic_bytes = tariff.get("traffic_limit")
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
current_device_limit = (
int(current_device_limit_db)
if current_device_limit_db is not None
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
)
current_traffic_gb = (
int(current_traffic_limit_db)
if current_traffic_limit_db is not None
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
)
if pack_mode and current_device_limit is not None and int(current_device_limit) == 0:
has_device_option = False
device_options = []
if pack_mode and current_traffic_gb is not None and int(current_traffic_gb) == 0:
has_traffic_option = False
traffic_options = []
if not has_device_option and not has_traffic_option:
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
if pack_mode:
include_device_effective = (
bool(body.include_device) if body.include_device is not None else body.selected_device_limit is not None
)
include_traffic_effective = (
bool(body.include_traffic) if body.include_traffic is not None else body.selected_traffic_gb is not None
)
selected_device = body.selected_device_limit if body.selected_device_limit is not None else None
selected_traffic = body.selected_traffic_gb if body.selected_traffic_gb is not None else None
else:
include_device_effective = has_device_option
include_traffic_effective = has_traffic_option
selected_device = body.selected_device_limit if body.selected_device_limit is not None else current_device_limit
selected_traffic = body.selected_traffic_gb if body.selected_traffic_gb is not None else current_traffic_gb
if (
has_device_option
and include_device_effective
and selected_device is not None
and int(selected_device) not in device_options
):
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
if (
has_traffic_option
and include_traffic_effective
and selected_traffic is not None
and int(selected_traffic) not in traffic_options
):
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
base_price_for_current = int(
calculate_config_price(
tariff=tariff,
selected_device_limit=current_devices_for_price,
selected_traffic_gb=current_traffic_for_price,
)
)
total_price_after_purchase = base_price_for_current
if pack_mode:
diff_full = int(
calc_pack_full_price_rub(
tariff=tariff,
has_device_option=bool(has_device_option and include_device_effective),
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
selected_devices=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
)
)
recalc_enabled = bool(
MODES_CONFIG.get(
"KEY_ADDONS_RECALC_PRICE",
TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False),
)
)
if recalc_enabled:
remaining_seconds, total_seconds = calc_remaining_ratio_seconds(
key_details.get("expiry_time"),
tariff,
)
extra_price_rub = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds)
else:
extra_price_rub = diff_full
total_price_after_purchase = int(base_price_for_current + diff_full)
else:
selected_total_price = int(
calculate_config_price(
tariff=tariff,
selected_device_limit=int(selected_device)
if has_device_option and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and selected_traffic is not None
else None,
)
)
extra_price_rub = int(max(0, selected_total_price - base_price_for_current))
total_price_after_purchase = selected_total_price
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
device_downgrade = (
allow_downgrade
and has_device_option
and current_device_limit is not None
and selected_device is not None
and not is_not_downgrade(current_device_limit, selected_device)
)
traffic_downgrade = (
allow_downgrade
and has_traffic_option
and current_traffic_gb is not None
and selected_traffic is not None
and not is_not_downgrade(current_traffic_gb, selected_traffic)
)
if device_downgrade or traffic_downgrade:
raise HTTPException(status_code=400, detail="Снижение параметров через сайт пока не поддерживается")
final_extra_price_rub, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
session=session,
billing_user_id=int(billing_user_id),
base_price_rub=int(max(0, extra_price_rub)),
coupon_code=body.coupon_code,
)
balance = float(await get_balance(session, int(billing_user_id)))
required_amount = int(max(0, ceil(float(final_extra_price_rub) - balance)))
if extra_price_rub <= 0:
return AccountKeyApplyAddonsResponse(
ok=True,
message="Доплата не требуется",
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=int(tariff_id),
total_price_rub=int(total_price_after_purchase),
extra_price_rub=0,
discount_rub=0,
final_price_rub=0,
applied_coupon_code=None,
charged_rub=0,
balance_rub=balance,
)
expiry_time = int(getattr(db_key, "expiry_time", 0) or 0)
email = str(getattr(db_key, "email", "") or "")
server_id = str(getattr(db_key, "server_id", "") or "")
if not email or not server_id:
raise HTTPException(status_code=400, detail="Некорректные данные подписки")
if required_amount > 0:
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
payment_request = PaymentLinkRequest(
legacy_user_ref=int(billing_user_id),
amount=required_amount,
currency="RUB",
provider_id=provider_id,
success_url=success_url,
failure_url=failure_url,
metadata={
"payment_flow": "key_addons",
"tariff_id": int(tariff_id),
"email": email,
"selected_device_limit": int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
"selected_traffic_gb": int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
"original_price": int(base_price_for_current),
"base_price_rub": int(max(0, extra_price_rub)),
"discount_rub": int(discount_rub),
"applied_coupon_code": applied_coupon_code,
"coupon_id": int(coupon_id) if coupon_id is not None else None,
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(billing_user_id),
"waiting_for_addons_payment",
{
"tariff_id": int(tariff_id),
"email": email,
"required_amount": int(required_amount),
"selected_device_limit": int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
"selected_traffic_gb": int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
"original_price": int(base_price_for_current),
"base_price_rub": int(max(0, extra_price_rub)),
"discount_rub": int(discount_rub),
"applied_coupon_code": applied_coupon_code,
"coupon_id": int(coupon_id) if coupon_id is not None else None,
},
)
return AccountKeyApplyAddonsResponse(
ok=True,
message="Требуется оплата для применения доп. опций",
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=int(tariff_id),
total_price_rub=int(total_price_after_purchase),
extra_price_rub=int(extra_price_rub),
discount_rub=int(discount_rub),
final_price_rub=int(final_extra_price_rub),
applied_coupon_code=applied_coupon_code,
charged_rub=0,
balance_rub=balance,
payment_required=True,
required_amount_rub=required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
target_subgroup = tariff.get("subgroup_title")
current_subgroup = None
current_tariff_id = key_details.get("tariff_id")
if current_tariff_id:
current_tariff = await get_tariff_by_id(session, int(current_tariff_id))
if current_tariff:
current_subgroup = current_tariff.get("subgroup_title")
if pack_mode:
device_limit_effective_current, traffic_limit_bytes_effective_current = await get_effective_limits_for_key(
session=session,
tariff_id=int(tariff_id),
selected_device_limit=int(current_device_limit) if current_device_limit is not None else None,
selected_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
)
traffic_limit_gb_effective_current = (
int(traffic_limit_bytes_effective_current / GB) if traffic_limit_bytes_effective_current else 0
)
new_device_limit_effective = device_limit_effective_current
new_traffic_limit_gb_effective = traffic_limit_gb_effective_current
if has_device_option and include_device_effective and selected_device is not None:
pack_devices_val = int(selected_device)
if pack_devices_val <= 0 or (
new_device_limit_effective is not None and int(new_device_limit_effective) <= 0
):
new_device_limit_effective = 0
else:
if new_device_limit_effective is None:
new_device_limit_effective = pack_devices_val
else:
new_device_limit_effective = int(new_device_limit_effective) + pack_devices_val
if has_traffic_option and include_traffic_effective and selected_traffic is not None:
pack_traffic_val = int(selected_traffic)
if pack_traffic_val <= 0 or int(new_traffic_limit_gb_effective) <= 0:
new_traffic_limit_gb_effective = 0
else:
new_traffic_limit_gb_effective = int(new_traffic_limit_gb_effective) + pack_traffic_val
await renew_key_in_cluster(
cluster_id=server_id,
email=email,
client_id=str(getattr(db_key, "client_id", "") or ""),
new_expiry_time=expiry_time,
total_gb=int(new_traffic_limit_gb_effective),
session=session,
hwid_device_limit=int(new_device_limit_effective) if new_device_limit_effective is not None else 0,
reset_traffic=False,
target_subgroup=target_subgroup,
old_subgroup=current_subgroup,
plan=int(tariff_id),
)
await save_key_config_with_mode(
session=session,
email=email,
selected_devices=int(new_device_limit_effective) if new_device_limit_effective is not None else None,
selected_traffic_gb=int(new_traffic_limit_gb_effective)
if new_traffic_limit_gb_effective is not None
else None,
total_price=int(total_price_after_purchase),
has_device_choice=bool(has_device_option and include_device_effective),
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
config_mode="pack",
)
else:
selected_device_for_effective = (
int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None
)
selected_traffic_for_effective = (
int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else 0
)
device_limit_effective_new, traffic_limit_bytes_effective_new = await get_effective_limits_for_key(
session=session,
tariff_id=int(tariff_id),
selected_device_limit=selected_device_for_effective,
selected_traffic_gb=selected_traffic_for_effective,
)
traffic_limit_gb_effective = (
int(traffic_limit_bytes_effective_new / GB) if traffic_limit_bytes_effective_new else 0
)
await renew_key_in_cluster(
cluster_id=server_id,
email=email,
client_id=str(getattr(db_key, "client_id", "") or ""),
new_expiry_time=expiry_time,
total_gb=int(traffic_limit_gb_effective),
session=session,
hwid_device_limit=int(device_limit_effective_new) if device_limit_effective_new is not None else 0,
reset_traffic=False,
target_subgroup=target_subgroup,
old_subgroup=current_subgroup,
plan=int(tariff_id),
)
await save_key_config_with_mode(
session=session,
email=email,
selected_devices=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
total_price=int(total_price_after_purchase),
has_device_choice=bool(has_device_option and include_device_effective),
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
config_mode="addon",
)
await update_balance(session, int(billing_user_id), -int(final_extra_price_rub))
if coupon_id is not None:
await mark_coupon_used(session, int(coupon_id), int(billing_user_id))
return AccountKeyApplyAddonsResponse(
ok=True,
message="Доп. опции применены",
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=int(tariff_id),
total_price_rub=int(total_price_after_purchase),
extra_price_rub=int(extra_price_rub),
discount_rub=int(discount_rub),
final_price_rub=int(final_extra_price_rub),
applied_coupon_code=applied_coupon_code,
charged_rub=int(final_extra_price_rub),
balance_rub=float(await get_balance(session, int(billing_user_id))),
)
-299
View File
@@ -1,299 +0,0 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
import time
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
router,
user_router,
)
@user_router.get("", response_model=list[AccountKeyResponse])
async def user_keys(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
billing_user_id = await _resolve_billing_user_id(request, identity, session)
keys = await get_keys(session, billing_user_id)
result: list[AccountKeyResponse] = []
for key in keys:
key_actions = AccountKeyActionsAvailability()
try:
key_ref = str(getattr(key, "client_id", "") or getattr(key, "email", "") or "")
_, markup, _ = await build_key_view_payload(session, int(billing_user_id), key_ref)
key_actions = _extract_key_actions_from_markup(markup)
except Exception:
key_actions = AccountKeyActionsAvailability()
result.append(
AccountKeyResponse(
email=str(getattr(key, "email", "") or ""),
alias=getattr(key, "alias", None),
client_id=str(getattr(key, "client_id", "") or ""),
tariff_id=getattr(key, "tariff_id", None),
server_id=str(getattr(key, "server_id", "") or ""),
created_at=int(getattr(key, "created_at", 0) or 0),
expiry_time=int(getattr(key, "expiry_time", 0) or 0),
key=getattr(key, "key", None),
remnawave_link=getattr(key, "remnawave_link", None),
is_frozen=bool(getattr(key, "is_frozen", False)),
actions=key_actions,
)
)
return result
@user_router.get("/actions-config", response_model=AccountKeyActionsConfigResponse)
async def user_keys_actions_config(
identity=Depends(verify_identity_token),
):
_ = identity
return _key_actions_config()
@user_router.get("/{client_id}/connection", response_model=AccountKeyConnectionResponse)
async def user_key_connection(
client_id: str,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Лёгкая инфо о текущей подписке: онлайн/offline, сервер, протокол, дни до окончания."""
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
server_name = str(getattr(db_key, "server_id", "") or "")
cluster_name = ""
panel_type = ""
if server_name:
srv = (
await session.execute(
select(Server).where(Server.server_name == server_name).limit(1)
)
).scalar_one_or_none()
if srv is not None:
cluster_name = str(getattr(srv, "cluster_name", "") or "")
panel_type = str(getattr(srv, "panel_type", "") or "").lower()
expiry_ms = int(getattr(db_key, "expiry_time", 0) or 0)
is_frozen = bool(getattr(db_key, "is_frozen", False))
now_ms = int(time.time() * 1000)
online = not is_frozen and expiry_ms > now_ms
expires_in_days = max(0, int((expiry_ms - now_ms) / (1000 * 60 * 60 * 24))) if expiry_ms > 0 else 0
if panel_type == "remnawave":
protocol = "VLESS"
elif panel_type == "marzban":
protocol = "VLESS"
elif panel_type == "3xui":
protocol = "VLESS"
else:
protocol = panel_type.upper() or "VLESS"
return AccountKeyConnectionResponse(
client_id=str(getattr(db_key, "client_id", "") or ""),
online=online,
is_frozen=is_frozen,
expiry_time=expiry_ms,
expires_in_days=expires_in_days,
server_name=server_name,
cluster_name=cluster_name,
panel_type=panel_type,
protocol=protocol,
)
@user_router.get("/{client_id}/details", response_model=AccountKeyDetailsResponse)
async def user_key_details(
client_id: str,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
if not key_details:
raise HTTPException(status_code=404, detail="Подписка не найдена")
tariff_name = ""
subgroup_title = ""
traffic_limit_gb = 0
device_limit = 0
is_tariff_configurable = False
addons_devices_enabled = False
addons_traffic_enabled = False
(
tariff_name,
subgroup_title,
traffic_limit_gb,
device_limit,
_,
is_tariff_configurable,
addons_devices_enabled,
addons_traffic_enabled,
) = await get_key_tariff_addons_state(
session=session,
key_record=key_details,
db_key=db_key,
)
connected_devices = 0
used_traffic_gb = None
try:
profile = await get_remnawave_profile(
session,
str(getattr(db_key, "server_id", "") or ""),
client_id,
fallback_any=True,
)
if profile:
connected_devices = int(profile.get("hwid_count") or 0)
used_raw = profile.get("used_gb")
used_traffic_gb = float(used_raw) if used_raw is not None else None
traffic_limit_bytes_actual = profile.get("traffic_limit_bytes")
if traffic_limit_bytes_actual is not None:
try:
traffic_limit_bytes_actual = int(traffic_limit_bytes_actual)
traffic_limit_gb = int(traffic_limit_bytes_actual / GB) if traffic_limit_bytes_actual > 0 else 0
except (TypeError, ValueError):
pass
except Exception:
connected_devices = 0
used_traffic_gb = None
return AccountKeyDetailsResponse(
client_id=str(getattr(db_key, "client_id", "") or ""),
email=str(getattr(db_key, "email", "") or ""),
alias=getattr(db_key, "alias", None),
expiry_time=int(getattr(db_key, "expiry_time", 0) or 0),
is_frozen=bool(getattr(db_key, "is_frozen", False)),
tariff_name=str(tariff_name or ""),
subgroup_title=str(subgroup_title or ""),
traffic_limit_gb=int(traffic_limit_gb or 0),
used_traffic_gb=used_traffic_gb,
device_limit=int(device_limit or 0),
connected_devices=int(connected_devices or 0),
is_tariff_configurable=bool(is_tariff_configurable),
addons_devices_enabled=bool(addons_devices_enabled),
addons_traffic_enabled=bool(addons_traffic_enabled),
)
@user_router.get("/{client_id}/qr", response_model=AccountKeyQrResponse)
async def user_key_qr(
client_id: str,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from api.ratelimit import enforce_rate_limit
await enforce_rate_limit(request, session, bucket="key_qr", max_per_window=30, window_sec=60)
actions = _key_actions_config()
if not force_web and not actions.qr_enabled:
raise HTTPException(status_code=403, detail="QR для подписок отключен в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
qr_data = str(getattr(db_key, "key", "") or "").strip() or str(getattr(db_key, "remnawave_link", "") or "").strip()
if not qr_data:
raise HTTPException(status_code=400, detail="Ссылка для подключения отсутствует")
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
image_data = b64encode(buffer.getvalue()).decode("ascii")
return AccountKeyQrResponse(
ok=True,
message="QR-код готов",
link=qr_data,
image_data_url=f"data:image/png;base64,{image_data}",
)
@user_router.patch("/{client_id}/alias", response_model=AccountKeyResponse)
async def user_key_update_alias(
client_id: str,
body: AccountKeyAliasUpdateRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from api.ratelimit import enforce_rate_limit
await enforce_rate_limit(request, session, bucket="key_alias", max_per_window=20, window_sec=60)
alias = str(body.alias or "").strip()
if not alias:
raise HTTPException(status_code=400, detail="Укажите alias")
if len(alias) > 10:
raise HTTPException(status_code=400, detail="Alias должен быть не длиннее 10 символов")
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
raise HTTPException(status_code=400, detail="Alias содержит недопустимые символы")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
db_key.alias = alias
return AccountKeyResponse(
email=str(getattr(db_key, "email", "") or ""),
alias=getattr(db_key, "alias", None),
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=getattr(db_key, "tariff_id", None),
server_id=str(getattr(db_key, "server_id", "") or ""),
created_at=int(getattr(db_key, "created_at", 0) or 0),
expiry_time=int(getattr(db_key, "expiry_time", 0) or 0),
key=getattr(db_key, "key", None),
remnawave_link=getattr(db_key, "remnawave_link", None),
is_frozen=bool(getattr(db_key, "is_frozen", False)),
)
@user_router.delete("/{client_id}", response_model=AccountKeyActionResponse)
async def user_key_delete(
client_id: str,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from api.ratelimit import enforce_rate_limit
await enforce_rate_limit(request, session, bucket="key_delete", max_per_window=10, window_sec=60)
actions = _key_actions_config()
if not force_web and not actions.delete_enabled:
raise HTTPException(status_code=403, detail="Удаление подписки отключено в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
cluster_id = str(getattr(db_key, "server_id", "") or "")
email = str(getattr(db_key, "email", "") or "")
if cluster_id and email:
await delete_key_from_cluster(
cluster_id=cluster_id,
email=email,
client_id=client_id,
session=session,
)
await session.delete(db_key)
return AccountKeyActionResponse(ok=True, message="Подписка удалена")
-73
View File
@@ -1,73 +0,0 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
router,
user_router,
)
@user_router.post("/{client_id}/reset-hwid", response_model=AccountKeyResetHwidResponse)
async def user_key_reset_hwid(
client_id: str,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.hwid_reset_enabled:
raise HTTPException(status_code=403, detail="Сброс устройств отключен в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
server_id = str(getattr(db_key, "server_id", "") or "")
if not server_id:
raise HTTPException(status_code=400, detail="У подписки не указан сервер")
async def _reset_devices(api):
devices = await api.get_user_hwid_devices(client_id)
if not devices:
return 0, 0
reset_local = 0
for device in devices:
hwid = device.get("hwid")
if hwid and await api.delete_user_hwid_device(client_id, hwid):
reset_local += 1
return len(devices), reset_local
reset_result = await with_remnawave_api(
session,
server_id,
_reset_devices,
fallback_any=True,
timeout_sec=12.0,
)
if reset_result is None:
raise HTTPException(status_code=502, detail="Не удалось выполнить сброс устройств")
total_devices, reset_devices = reset_result
await invalidate_remnawave_profile(
session,
server_id,
str(client_id),
fallback_any=True,
)
return AccountKeyResetHwidResponse(
ok=True,
message="Устройства сброшены" if total_devices > 0 else "Устройства не были привязаны",
total_devices=int(total_devices),
reset_devices=int(reset_devices),
)
-225
View File
@@ -1,225 +0,0 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
router,
user_router,
)
@user_router.get("/{client_id}/locations", response_model=AccountKeyLocationsResponse)
async def user_key_locations(
client_id: str,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.country_change_enabled:
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
names = await _resolve_available_location_servers(session, db_key)
return AccountKeyLocationsResponse(
client_id=str(getattr(db_key, "client_id", "") or ""),
current_server=str(getattr(db_key, "server_id", "") or ""),
locations=[AccountKeyLocationOptionResponse(server_name=name) for name in names],
)
@user_router.post("/{client_id}/change-location", response_model=AccountKeyChangeLocationResponse)
async def user_key_change_location(
client_id: str,
body: AccountKeyChangeLocationRequest,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.country_change_enabled:
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
target_server = str(body.server_name or "").strip()
if not target_server:
raise HTTPException(status_code=400, detail="Укажите целевую локацию")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
current_server = str(getattr(db_key, "server_id", "") or "")
if not current_server:
raise HTTPException(status_code=400, detail="У подписки не указан текущий сервер")
if current_server == target_server:
raise HTTPException(status_code=400, detail="Подписка уже в этой локации")
available_names = await _resolve_available_location_servers(session, db_key)
if target_server not in available_names:
raise HTTPException(status_code=400, detail="Выбранная локация недоступна")
email = str(getattr(db_key, "email", "") or "")
if not email:
raise HTTPException(status_code=400, detail="У подписки отсутствует email")
key_details = await get_key_details(session, email)
if not key_details:
raise HTTPException(status_code=404, detail="Подписка не найдена")
old_server_info = (
await session.execute(select(Server).where(Server.server_name == current_server).limit(1))
).scalar_one_or_none()
if old_server_info:
old_panel_type = str(getattr(old_server_info, "panel_type", "") or "").lower()
try:
if old_panel_type == "3x-ui":
xui = await get_xui_instance(str(getattr(old_server_info, "api_url", "") or ""))
await delete_client(
xui,
int(getattr(old_server_info, "inbound_id", 0) or 0),
email,
str(getattr(db_key, "client_id", "") or ""),
)
elif old_panel_type == "remnawave":
remna_del = RemnawaveAPI(str(getattr(old_server_info, "api_url", "") or ""))
if await remna_del.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna_del.delete_user(str(getattr(db_key, "client_id", "") or ""))
except Exception:
pass
target_server_info = (
await session.execute(select(Server).where(Server.server_name == target_server).limit(1))
).scalar_one_or_none()
if target_server_info is None:
raise HTTPException(status_code=404, detail="Целевая локация не найдена")
tariff_id = getattr(db_key, "tariff_id", None)
tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff_id else None
need_vless_key = bool(tariff.get("vless")) if tariff else False
external_squad_uuid = (tariff.get("external_squad") if tariff else None) or None
selected_traffic_gb = getattr(db_key, "selected_traffic_limit", None)
selected_device_limit = getattr(db_key, "selected_device_limit", None)
if selected_traffic_gb is not None:
traffic_limit_bytes = int(selected_traffic_gb) * GB
else:
raw_traffic_limit = int(tariff.get("traffic_limit") or 0) if tariff else 0
traffic_limit_bytes = raw_traffic_limit * GB if raw_traffic_limit > 0 else 0
if selected_device_limit is not None:
device_limit = int(selected_device_limit)
else:
device_limit = int(tariff.get("device_limit") or 0) if tariff else 0
key_client_id = str(getattr(db_key, "client_id", "") or "")
expiry_timestamp = int(getattr(db_key, "expiry_time", 0) or 0)
target_cluster_info = await check_server_name_by_cluster(session, target_server)
target_cluster_name = str((target_cluster_info or {}).get("cluster_name") or "")
full_remnawave_cluster = (
await is_full_remnawave_cluster(target_cluster_name, session) if target_cluster_name else False
)
panel_type = str(getattr(target_server_info, "panel_type", "") or "").lower()
remnawave_link = None
if panel_type == "remnawave" or full_remnawave_cluster:
remna = RemnawaveAPI(str(getattr(target_server_info, "api_url", "") or ""))
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
raise HTTPException(status_code=502, detail="Не удалось авторизоваться в Remnawave")
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
user_data: dict[str, Any] = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at,
"telegramId": int(key_details.get("tg_id") or 0),
"activeInternalSquads": [getattr(target_server_info, "inbound_id", None)],
"uuid": key_client_id,
}
if traffic_limit_bytes > 0:
user_data["trafficLimitBytes"] = traffic_limit_bytes
if device_limit > 0:
user_data["hwidDeviceLimit"] = device_limit
if external_squad_uuid:
user_data["externalSquadUuid"] = external_squad_uuid
result = await remna.create_user(user_data)
if not result:
raise HTTPException(status_code=502, detail="Не удалось создать подписку в новой локации")
key_client_id = str(result.get("uuid") or result.get("id") or key_client_id)
if need_vless_key:
try:
remnawave_link = await get_vless_link_for_remnawave_by_username(remna, email, email)
except Exception:
remnawave_link = None
if not remnawave_link:
try:
sub = await remna.get_subscription_by_username(email)
except Exception:
sub = None
if sub:
links = sub.get("links") or []
remnawave_link = (
next(
(link for link in links if isinstance(link, str) and link.lower().startswith("vless://")),
None,
)
if need_vless_key
else None
)
if not remnawave_link:
remnawave_link = sub.get("subscriptionUrl")
if panel_type == "3x-ui":
await create_client_on_server(
{
"api_url": str(getattr(target_server_info, "api_url", "") or ""),
"inbound_id": getattr(target_server_info, "inbound_id", None),
"server_name": str(getattr(target_server_info, "server_name", "") or ""),
"panel_type": str(getattr(target_server_info, "panel_type", "") or ""),
},
int(key_details.get("tg_id") or 0),
key_client_id,
email,
expiry_timestamp,
asyncio.Semaphore(1),
plan=int(tariff_id) if tariff_id else None,
session=session,
is_trial=False,
total_traffic_limit_bytes=traffic_limit_bytes,
device_limit_value=device_limit,
)
subgroup_code = tariff.get("subgroup_title") if tariff and tariff.get("subgroup_title") else None
public_link = await make_aggregated_link(
session=session,
cluster_all=[
{
"server_name": str(getattr(target_server_info, "server_name", "") or ""),
"api_url": str(getattr(target_server_info, "api_url", "") or ""),
"panel_type": str(getattr(target_server_info, "panel_type", "") or ""),
"inbound_id": getattr(target_server_info, "inbound_id", None),
"enabled": True,
"max_keys": getattr(target_server_info, "max_keys", None),
}
],
cluster_id=target_cluster_name or target_server,
email=email,
client_id=key_client_id,
tg_id=int(key_details.get("tg_id") or 0),
subgroup_code=subgroup_code,
remna_link_override=remnawave_link,
plan=int(tariff_id) if tariff_id else None,
)
db_key.server_id = target_server
db_key.client_id = key_client_id
db_key.key = public_link if isinstance(public_link, str) and public_link.strip() else None
db_key.remnawave_link = remnawave_link
return AccountKeyChangeLocationResponse(
ok=True,
message="Локация успешно изменена",
client_id=str(getattr(db_key, "client_id", "") or ""),
server_id=str(getattr(db_key, "server_id", "") or ""),
link=str(getattr(db_key, "key", "") or ""),
remnawave_link=getattr(db_key, "remnawave_link", None),
)
-192
View File
@@ -1,192 +0,0 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
router,
user_router,
)
@user_router.post("/{client_id}/renew", response_model=AccountKeyRenewResponse)
async def user_key_renew(
client_id: str,
body: AccountKeyRenewRequest,
request: Request,
force_web: bool = Query(False),
preview: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from api.ratelimit import enforce_rate_limit
from services.errors import ServiceError
from services.keys import (
calculate_renewal_pricing,
execute_renewal,
normalize_expiry_ms as _svc_normalize_expiry,
)
if not preview:
await enforce_rate_limit(request, session, bucket="key_renew", max_per_window=10, window_sec=60)
actions = _key_actions_config()
if not force_web and not actions.renew_enabled:
raise HTTPException(status_code=403, detail="Продление подписки отключено в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
if bool(getattr(db_key, "is_frozen", False)):
raise HTTPException(status_code=400, detail="Продление для замороженной подписки недоступно")
tariff_id = getattr(db_key, "tariff_id", None)
if not tariff_id:
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
key_email = str(getattr(db_key, "email", "") or "")
key_server_id = str(getattr(db_key, "server_id", "") or "")
try:
pricing = await calculate_renewal_pricing(
session=session,
billing_user_id=int(billing_user_id),
key_email=key_email,
tariff_id=int(tariff_id),
coupon_code=body.coupon_code,
)
except ServiceError as e:
raise HTTPException(status_code=400, detail=e.message)
if preview:
return AccountKeyRenewResponse(
ok=True,
message="Расчет обновлен",
client_id=str(client_id),
tariff_id=int(tariff_id),
charged_rub=0,
balance_rub=pricing.balance,
base_price_rub=pricing.base_price_rub,
discount_rub=pricing.discount_rub,
final_price_rub=pricing.final_price_rub,
applied_coupon_code=pricing.applied_coupon_code,
payment_required=pricing.payment_required,
required_amount_rub=pricing.required_amount,
payment_id=None,
payment_url=None,
)
if pricing.payment_required:
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
payment_request = PaymentLinkRequest(
legacy_user_ref=int(billing_user_id),
amount=pricing.required_amount,
currency="RUB",
provider_id=provider_id,
success_url=success_url,
failure_url=failure_url,
metadata={
"payment_flow": "key_renewal",
"tariff_id": int(tariff_id),
"client_id": str(client_id),
"email": key_email,
"cost": pricing.final_price_rub,
"selected_duration_days": pricing.duration_days,
"selected_device_limit": pricing.selected_device_limit,
"selected_traffic_limit": pricing.selected_traffic_limit,
"selected_price_rub": pricing.final_price_rub,
"total_gb": pricing.total_gb,
"base_price_rub": pricing.base_price_rub,
"discount_rub": pricing.discount_rub,
"applied_coupon_code": pricing.applied_coupon_code,
"coupon_id": pricing.coupon_id,
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(billing_user_id),
"waiting_for_renewal_payment",
{
"tariff_id": int(tariff_id),
"client_id": str(client_id),
"email": key_email,
"cost": pricing.final_price_rub,
"required_amount": pricing.required_amount,
"selected_duration_days": pricing.duration_days,
"selected_device_limit": pricing.selected_device_limit,
"selected_traffic_limit": pricing.selected_traffic_limit,
"selected_price_rub": pricing.final_price_rub,
"total_gb": pricing.total_gb,
"base_price_rub": pricing.base_price_rub,
"discount_rub": pricing.discount_rub,
"applied_coupon_code": pricing.applied_coupon_code,
"coupon_id": pricing.coupon_id,
},
)
return AccountKeyRenewResponse(
ok=True,
message="Требуется оплата для продления подписки",
client_id=str(client_id),
tariff_id=int(tariff_id),
charged_rub=0,
balance_rub=pricing.balance,
base_price_rub=pricing.base_price_rub,
discount_rub=pricing.discount_rub,
final_price_rub=pricing.final_price_rub,
applied_coupon_code=pricing.applied_coupon_code,
payment_required=True,
required_amount_rub=pricing.required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
expiry_raw = _normalize_expiry_ms(getattr(db_key, "expiry_time", None))
now_ms = int(datetime.utcnow().timestamp() * 1000)
base_expiry = now_ms if expiry_raw <= now_ms else expiry_raw
new_expiry_time = int(base_expiry + pricing.duration_days * 24 * 60 * 60 * 1000)
if not key_email or not key_server_id:
raise HTTPException(status_code=400, detail="Некорректные данные подписки")
try:
result = await execute_renewal(
session=session,
billing_user_id=int(billing_user_id),
client_id=str(client_id),
key_email=key_email,
key_server_id=key_server_id,
tariff_id=int(tariff_id),
new_expiry_time=new_expiry_time,
total_gb=pricing.total_gb,
cost=float(pricing.final_price_rub),
selected_device_limit=pricing.selected_device_limit,
selected_traffic_limit=pricing.selected_traffic_limit,
selected_price_rub=pricing.final_price_rub,
coupon_id=pricing.coupon_id,
)
except ServiceError as e:
raise HTTPException(status_code=400, detail=e.message)
return AccountKeyRenewResponse(
ok=True,
message="Подписка продлена",
client_id=result.client_id,
tariff_id=result.tariff_id,
charged_rub=result.charged_rub,
balance_rub=result.balance_rub,
base_price_rub=pricing.base_price_rub,
discount_rub=pricing.discount_rub,
final_price_rub=pricing.final_price_rub,
applied_coupon_code=pricing.applied_coupon_code,
)
-514
View File
@@ -1,514 +0,0 @@
import asyncio
import os
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from typing import Literal
import psutil
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import distinct, exists, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin, verify_identity_admin_short
from api.v2.schemas.audit import (
AuditEventListResponse,
AuditEventResponse,
AuditStatsResponse,
)
from audit import drain_audit_redis_to_db, get_audit_funnel, get_audit_stats, list_audit_events
from config import API_TOKEN, BOT_SERVICE
from core.bootstrap import MANAGEMENT_CONFIG
from core.executor import run_io
from core.redis_cache import cache_incr
from core.settings.management_config import update_management_config
from database import async_session_maker
from database.models import Key, ScheduledBroadcast, Server, User
from database.scheduled_broadcasts import (
cancel_scheduled_broadcast,
create_scheduled_broadcast,
get_scheduled_broadcast,
list_scheduled_broadcasts,
mark_scheduled_broadcast_failed,
mark_scheduled_broadcast_sent,
start_scheduled_broadcast,
update_scheduled_broadcast,
)
from handlers.admin.sender.scheduled_service import (
ensure_utc_datetime,
execute_broadcast_payload,
execute_scheduled_broadcast,
prepare_broadcast_payload,
scheduled_broadcast_to_dict,
)
from logger import logger
from utils.backup import backup_database
router = APIRouter()
async def _admin_rate_limit(request_or_identity, action: str, max_calls: int, window_sec: int) -> None:
identity_id = getattr(request_or_identity, "id", "unknown")
key = f"admin_rl:{action}:{identity_id}"
count = await cache_incr(key, window_sec)
if count > max_calls:
raise HTTPException(status_code=429, detail="Слишком много запросов. Попробуйте позже.")
class MaintenanceUpdate(BaseModel):
enabled: bool
class DomainChange(BaseModel):
domain: str
class BroadcastLaunchPayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
channel: Literal["bot", "site", "both"] = "both"
text: str
photo: str | None = None
cluster_name: str | None = None
workers: int = 5
messages_per_second: int = 35
class ScheduledBroadcastCreatePayload(BroadcastLaunchPayload):
scheduled_for: datetime
class ScheduledBroadcastUpdatePayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] | None = None
channel: Literal["bot", "site", "both"] | None = None
text: str | None = None
photo: str | None = None
cluster_name: str | None = None
workers: int | None = None
messages_per_second: int | None = None
scheduled_for: datetime | None = None
_broadcast_bot: Bot | None = None
def _get_broadcast_bot() -> Bot:
"""Возвращает экземпляр бота для рассылки."""
global _broadcast_bot
if _broadcast_bot is None:
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
return _broadcast_bot
def _require_future_schedule(value: datetime) -> datetime:
scheduled_for = ensure_utc_datetime(value)
if scheduled_for <= datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="scheduled_for must be in the future")
return scheduled_for
def _resolve_update_payload(
payload: ScheduledBroadcastUpdatePayload,
current: ScheduledBroadcast,
) -> dict:
fields = payload.model_fields_set
text_changed = "text" in fields
send_to = payload.send_to if "send_to" in fields else current.send_to
channel = payload.channel if "channel" in fields else current.channel
text = payload.text if "text" in fields else current.text
photo = payload.photo if "photo" in fields else current.photo
cluster_name = payload.cluster_name if "cluster_name" in fields else current.cluster_name
workers = payload.workers if "workers" in fields else current.workers
messages_per_second = (
payload.messages_per_second if "messages_per_second" in fields else current.messages_per_second
)
prepared = prepare_broadcast_payload(
send_to=send_to,
text=text,
photo=photo,
cluster_name=cluster_name,
workers=workers,
messages_per_second=messages_per_second,
channel=channel,
)
if not text_changed:
prepared["text"] = current.text
prepared["keyboard_json"] = current.keyboard_json
if "scheduled_for" in fields:
prepared["scheduled_for"] = _require_future_schedule(payload.scheduled_for)
return prepared
async def _restart_bot() -> None:
"""Перезапуск процесса бота (systemctl или execv)."""
await asyncio.sleep(1)
try:
parent = psutil.Process(os.getpid()).parent()
is_systemd = parent and "systemd" in parent.name().lower()
if is_systemd:
await run_io(lambda: subprocess.run(["sudo", "systemctl", "restart", BOT_SERVICE], check=True))
else:
python_exe = sys.executable
script_path = os.path.abspath(sys.argv[0])
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
except Exception:
os._exit(1)
@router.get("/status")
async def get_status(identity=Depends(verify_identity_admin)):
"""Текущий статус: maintenance и management config."""
return {
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
"management": dict(MANAGEMENT_CONFIG or {}),
}
@router.post("/maintenance")
async def set_maintenance(
payload: MaintenanceUpdate,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Включение/выключение режима обслуживания."""
current_config = dict(MANAGEMENT_CONFIG or {})
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
await update_management_config(session, current_config)
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
@router.post("/restart")
async def restart_bot(
background: BackgroundTasks,
identity=Depends(verify_identity_admin),
):
"""Запуск перезапуска бота в фоне."""
await _admin_rate_limit(identity, "restart", max_calls=3, window_sec=60)
background.add_task(_restart_bot)
return {"status": "restarting"}
@router.post("/change-domain")
async def change_domain(
payload: DomainChange,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Массовая замена домена в ключах и remnawave_link."""
await _admin_rate_limit(identity, "change_domain", max_calls=3, window_sec=300)
domain = payload.domain.strip()
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
raise HTTPException(status_code=400, detail="Invalid domain")
new_domain_url = f"https://{domain}"
stmt = (
update(Key)
.values(
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
)
.where(
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
)
)
result = await session.execute(stmt)
return {"updated": result.rowcount or 0}
@router.post("/restore-trials")
async def restore_trials(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает trial=0 у пользователей без ключей."""
await _admin_rate_limit(identity, "restore_trials", max_calls=3, window_sec=300)
stmt = (
update(User)
.where(
User.trial == 1,
~exists(select(Key.user_id).where(Key.user_id == User.id)),
)
.values(trial=0)
)
result = await session.execute(stmt)
return {"restored": result.rowcount or 0}
@router.post("/backup")
async def trigger_backup(identity=Depends(verify_identity_admin)):
"""Запуск бэкапа БД в фоне."""
await _admin_rate_limit(identity, "backup", max_calls=2, window_sec=300)
async def _run_backup() -> None:
exception = await backup_database()
if exception:
logger.error(f"[Management] Backup finished with error: {exception}")
asyncio.create_task(_run_backup())
return {"status": "backup_started"}
@router.get("/broadcast/clusters")
async def get_broadcast_clusters(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Список кластеров для рассылки по кластеру."""
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
clusters = sorted([row[0] for row in result.all() if row and row[0]])
return {"clusters": clusters}
def _parse_date_range(
date: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> tuple[datetime, datetime]:
"""Возвращает (date_from, date_to) в UTC. Либо date=YYYY-MM-DD (один день), либо date_from + date_to."""
tz = timezone.utc
if date:
try:
d = datetime.strptime(date, "%Y-%m-%d").date()
start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
end = start + timedelta(days=1)
return start, end
except ValueError:
raise HTTPException(status_code=400, detail="date должен быть YYYY-MM-DD")
if date_from and date_to:
try:
start = datetime.fromisoformat(date_from.replace("Z", "+00:00"))
end = datetime.fromisoformat(date_to.replace("Z", "+00:00"))
if start.tzinfo is None:
start = start.replace(tzinfo=tz)
if end.tzinfo is None:
end = end.replace(tzinfo=tz)
if start >= end:
raise HTTPException(status_code=400, detail="date_from должен быть раньше date_to")
return start, end
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Неверный формат дат: {e}")
end = datetime.now(tz).replace(hour=0, minute=0, second=0, microsecond=0)
start = end - timedelta(days=1)
return start, end
@router.get("/audit-stats", response_model=AuditStatsResponse)
async def get_audit_stats_endpoint(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
date: str | None = Query(None, description="Один день: YYYY-MM-DD"),
date_from: str | None = Query(None, description="Начало периода (ISO)"),
date_to: str | None = Query(None, description="Конец периода (ISO)"),
):
"""Статистика аудита за период: какие пути отрабатывают хорошо/плохо, воронка старт→оплата.
Данные только из БД (события из Redis учитываются после drain)."""
start, end = _parse_date_range(date=date, date_from=date_from, date_to=date_to)
stats = await get_audit_stats(session, date_from=start, date_to=end)
funnel = await get_audit_funnel(session, date_from=start, date_to=end)
return AuditStatsResponse(
summary=stats["summary"],
by_path=stats["by_path"],
funnel=funnel,
)
@router.get("/audit-events", response_model=AuditEventListResponse)
async def get_audit_events_history(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
identity_id: str | None = Query(None, description="Фильтр по identity_id"),
tg_id: int | None = Query(None, description="Фильтр по Telegram user id"),
channel: str | None = Query(None, description="api или telegram"),
event_type: str | None = Query(None, description="Точный event_type"),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""История аудита клиента по identity_id и/или tg_id."""
if identity_id is None and tg_id is None:
raise HTTPException(status_code=400, detail="Укажите identity_id или tg_id")
events = await list_audit_events(
session,
identity_id=identity_id,
tg_id=tg_id,
channel=channel,
event_type=event_type,
limit=limit,
offset=offset,
)
return AuditEventListResponse(
items=[
AuditEventResponse(
id=getattr(event, "id", None),
event_type=event.event_type,
channel=event.channel,
actor_identity_id=event.actor_identity_id,
actor_tg_id=event.actor_tg_id,
path_or_handler=event.path_or_handler,
entity_type=event.entity_type,
entity_id=event.entity_id,
result=event.result,
reason=event.reason,
metadata=event.metadata_,
request_id=event.request_id,
created_at=event.created_at,
)
for event in events
],
limit=limit,
offset=offset,
)
@router.post("/audit-drain")
async def post_audit_drain(identity=Depends(verify_identity_admin_short)):
"""Выгружает буфер аудита из Redis в БД. Для вызова по крону (например 0 0 * * * в 00:00)."""
try:
count = await drain_audit_redis_to_db(async_session_maker)
return {"success": True, "drained": count}
except Exception as exc:
logger.warning("audit-drain failed: {}", exc)
raise HTTPException(status_code=500, detail="Внутренняя ошибка при дренаже аудита") from exc
@router.post("/broadcast")
async def launch_broadcast(
payload: BroadcastLaunchPayload,
identity=Depends(verify_identity_admin_short),
):
"""Запуск рассылки по выбранной аудитории. Сессия БД не держится на время рассылки."""
await _admin_rate_limit(identity, "broadcast", max_calls=5, window_sec=300)
try:
prepared = prepare_broadcast_payload(
send_to=payload.send_to,
text=payload.text,
photo=payload.photo,
cluster_name=payload.cluster_name,
workers=payload.workers,
messages_per_second=payload.messages_per_second,
channel=payload.channel,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return await execute_broadcast_payload(prepared, bot=_get_broadcast_bot())
@router.post("/broadcast/scheduled")
async def create_broadcast_schedule(
payload: ScheduledBroadcastCreatePayload,
identity=Depends(verify_identity_admin_short),
session: AsyncSession = Depends(get_session),
):
try:
prepared = prepare_broadcast_payload(
send_to=payload.send_to,
text=payload.text,
photo=payload.photo,
cluster_name=payload.cluster_name,
workers=payload.workers,
messages_per_second=payload.messages_per_second,
channel=payload.channel,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
broadcast = await create_scheduled_broadcast(
session,
created_by_tg_id=getattr(identity, "tg_id", None),
send_to=prepared["send_to"],
channel=prepared["channel"],
cluster_name=prepared["cluster_name"],
text=prepared["text"],
photo=prepared["photo"],
keyboard_json=prepared["keyboard_json"],
scheduled_for=_require_future_schedule(payload.scheduled_for),
workers=prepared["workers"],
messages_per_second=prepared["messages_per_second"],
)
return {"success": True, "item": scheduled_broadcast_to_dict(broadcast)}
@router.get("/broadcast/scheduled")
async def list_broadcast_schedules(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
status: str | None = Query(None, description="Фильтр статусов через запятую"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
statuses = [item.strip() for item in (status or "").split(",") if item.strip()] or None
items = await list_scheduled_broadcasts(session, statuses=statuses, limit=limit, offset=offset)
return {"items": [scheduled_broadcast_to_dict(item) for item in items], "limit": limit, "offset": offset}
@router.get("/broadcast/scheduled/{broadcast_id}")
async def get_broadcast_schedule(
broadcast_id: str,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
item = await get_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
return {"item": scheduled_broadcast_to_dict(item)}
@router.patch("/broadcast/scheduled/{broadcast_id}")
async def update_broadcast_schedule(
broadcast_id: str,
payload: ScheduledBroadcastUpdatePayload,
identity=Depends(verify_identity_admin_short),
session: AsyncSession = Depends(get_session),
):
current = await get_scheduled_broadcast(session, broadcast_id)
if current is None:
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
try:
values = _resolve_update_payload(payload, current)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
updated = await update_scheduled_broadcast(session, broadcast_id, **values)
if updated is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be edited")
return {"success": True, "item": scheduled_broadcast_to_dict(updated)}
@router.post("/broadcast/scheduled/{broadcast_id}/cancel")
async def cancel_broadcast_schedule(
broadcast_id: str,
identity=Depends(verify_identity_admin_short),
session: AsyncSession = Depends(get_session),
):
item = await cancel_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be cancelled")
return {"success": True, "item": scheduled_broadcast_to_dict(item)}
@router.post("/broadcast/scheduled/{broadcast_id}/send-now")
async def send_broadcast_schedule_now(
broadcast_id: str,
identity=Depends(verify_identity_admin_short),
session: AsyncSession = Depends(get_session),
):
await _admin_rate_limit(identity, "broadcast_now", max_calls=5, window_sec=300)
item = await start_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be sent now")
try:
result = await execute_scheduled_broadcast(item, bot=_get_broadcast_bot())
except Exception as exc:
logger.error("[Broadcast] send-now failed for {}: {}", broadcast_id, exc)
await mark_scheduled_broadcast_failed(session, broadcast_id, str(exc))
raise HTTPException(status_code=500, detail="Ошибка при выполнении рассылки") from exc
if result.get("success"):
item = await mark_scheduled_broadcast_sent(session, broadcast_id, result)
else:
item = await mark_scheduled_broadcast_failed(session, broadcast_id, result.get("message", "Broadcast failed"))
return {"success": bool(result.get("success")), "item": scheduled_broadcast_to_dict(item), "result": result}
-165
View File
@@ -1,165 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import (
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
from database import get_tracking_source_stats
from database.access.resolution import resolve_user_optional
from database.models import (
BlockedUser,
ManualBan,
Notification,
Payment,
TemporaryData,
TrackingSource,
)
router = APIRouter()
router.include_router(
generate_crud_router(
model=Payment,
schema_response=PaymentResponse,
schema_create=None,
schema_update=None,
identifier_field="id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/payments",
tags=["Payments"],
dependencies=[Depends(verify_identity_admin)],
)
@router.get("/payments/by_tg_id/{tg_id}", response_model=list[PaymentResponse], tags=["Payments"])
async def get_payments_by_tg_id(
tg_id: int = Path(...),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Список платежей по tg_id пользователя."""
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Payments not found")
result = await session.execute(select(Payment).where(Payment.user_id == u.id))
payments = result.scalars().all()
if not payments:
raise HTTPException(status_code=404, detail="Payments not found")
return payments
router.include_router(
generate_crud_router(
model=Notification,
schema_response=NotificationResponse,
schema_create=None,
schema_update=None,
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/notifications",
tags=["Notifications"],
dependencies=[Depends(verify_identity_admin)],
)
router.include_router(
generate_crud_router(
model=ManualBan,
schema_response=ManualBanResponse,
schema_create=None,
schema_update=None,
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/manual-bans",
tags=["Bans"],
dependencies=[Depends(verify_identity_admin)],
)
router.include_router(
generate_crud_router(
model=BlockedUser,
schema_response=BlockedUserResponse,
schema_create=None,
schema_update=None,
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/blocked-users",
tags=["Bans"],
dependencies=[Depends(verify_identity_admin)],
)
router.include_router(
generate_crud_router(
model=TemporaryData,
schema_response=TemporaryDataResponse,
schema_create=None,
schema_update=None,
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/temporary-data",
tags=["TemporaryData"],
dependencies=[Depends(verify_identity_admin)],
)
router.include_router(
generate_crud_router(
model=TrackingSource,
schema_response=TrackingSourceResponse,
schema_create=None,
schema_update=None,
identifier_field="id",
enabled_methods=["get_all", "delete"],
),
prefix="/tracking-sources",
tags=["TrackingSources"],
dependencies=[Depends(verify_identity_admin)],
)
@router.get(
"/tracking-sources/{code}", response_model=TrackingSourceResponse, dependencies=[Depends(verify_identity_admin)]
)
async def get_tracking_source_with_stats(
code: str,
session: AsyncSession = Depends(get_session),
):
"""Источник по коду со статистикой регистраций и платежей."""
result = await session.execute(select(TrackingSource).where(TrackingSource.code == code))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Tracking source not found")
stats = await get_tracking_source_stats(session, code)
return TrackingSourceResponse(
id=source.id,
name=source.name,
code=source.code,
type=source.type,
created_by=source.created_by,
created_at=source.created_at,
registrations=(stats["registrations"] if stats else 0),
trials=(stats["trials"] if stats else 0),
payments=(stats["payments"] if stats else 0),
total_amount=(float(stats["total_amount"]) if stats else 0.0),
monthly=(stats["monthly"] if stats and "monthly" in stats else []),
)
-125
View File
@@ -1,125 +0,0 @@
import pkgutil
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.depends import verify_identity_admin
from core.executor import run_io
from logger import logger
from utils.modules_loader import _is_safe_module_name
from utils.modules_manager import manager
router = APIRouter(prefix="/modules", tags=["Modules"])
MODULES_DIR = Path(__file__).resolve().parents[3] / "modules"
class ModuleAction(BaseModel):
action: Literal["start", "stop", "restart"]
def _available_module_names() -> list[str]:
"""Имена модулей из папки modules, прошедшие проверку безопасности."""
candidates: set[str] = set()
if MODULES_DIR.is_dir():
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
name = (name or "").strip()
if name and _is_safe_module_name(name):
candidates.add(name)
return sorted(n for n in candidates if _is_safe_module_name(n))
def _prune_missing_state(installed: set[str]) -> None:
"""Удаляет из состояния менеджера модули, которых нет в файловой системе."""
changed = False
stale_disabled = {name for name in manager.disabled if name not in installed}
if stale_disabled:
for name in stale_disabled:
manager.disabled.discard(name)
changed = True
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
if stale_registry:
for name in stale_registry:
manager.registry.pop(name, None)
changed = True
if changed:
save_state = getattr(manager, "_save_state", None)
if callable(save_state):
save_state()
def _module_state(name: str) -> dict:
"""Состояние модуля: enabled, loaded, autostart."""
normalized = name.strip()
record = manager.registry.get(normalized)
is_enabled = manager.is_enabled(normalized)
return {
"name": normalized,
"enabled": is_enabled,
"loaded": bool(record and record.enabled),
"autostart": manager.should_autostart(normalized),
}
def _read_local_module_version(name: str) -> str | None:
"""Читает VERSION из папки модуля."""
version_file = MODULES_DIR / name / "VERSION"
if not version_file.exists() or not version_file.is_file():
return None
try:
with version_file.open("r", encoding="utf-8") as handle:
for line in handle:
value = line.strip()
if value:
return value
except Exception:
return None
return None
def sync_list_modules() -> list:
"""Вся синхронная работа со списком модулей (файлы, состояние). Вызывать через run_io()."""
refresh = getattr(manager, "refresh_state", None) or getattr(manager, "_load_state", None)
if callable(refresh):
refresh()
module_names = _available_module_names()
_prune_missing_state(set(module_names))
modules = [_module_state(name) for name in module_names]
for item in modules:
name = str(item.get("name") or "").strip()
item["local_version"] = _read_local_module_version(name)
return modules
@router.get("/")
async def list_modules(identity=Depends(verify_identity_admin)):
"""Список модулей с состоянием и локальной версией."""
modules = await run_io(sync_list_modules)
return {"items": modules}
@router.post("/{module_name}/actions")
async def control_module(module_name: str, payload: ModuleAction, identity=Depends(verify_identity_admin)):
"""Запуск, остановка или перезапуск модуля."""
name = (module_name or "").strip()
if not _is_safe_module_name(name):
raise HTTPException(status_code=404, detail="Module not found")
try:
if payload.action == "start":
await manager.start(name)
elif payload.action == "stop":
await manager.stop(name)
elif payload.action == "restart":
await manager.restart(name)
else:
raise HTTPException(status_code=400, detail="Unsupported action")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
logger.error("[Modules] action failed for {}: {}", name, exc)
raise HTTPException(status_code=500, detail="Ошибка при выполнении операции модуля") from exc
return {"item": _module_state(name)}
-112
View File
@@ -1,112 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from database import web_notifications as wn_db
from database.models import Identity
router = APIRouter()
class PushSubscribeRequest(BaseModel):
endpoint: str
keys: dict
class NotificationItem(BaseModel):
id: str
type: str
title: str
message: str
read: bool
created_at: str
data: dict | None = None
class NotificationsResponse(BaseModel):
ok: bool = True
notifications: list[NotificationItem]
unread_count: int
@router.post("/push/subscribe", tags=["Notifications"])
async def push_subscribe(
body: PushSubscribeRequest,
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
user_id = identity.tg_id or 0
await wn_db.upsert_push_subscription(
session,
user_id=user_id,
identity_id=identity.id,
endpoint=body.endpoint,
keys_json=body.keys,
)
return {"ok": True}
@router.get("/notifications", response_model=NotificationsResponse, tags=["Notifications"])
async def get_notifications(
limit: int = Query(20, ge=1, le=100),
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
notifications = await wn_db.get_notifications_for_identity(
session,
identity.id,
limit=limit,
)
unread_count = await wn_db.count_unread_for_identity(session, identity.id)
items = [
NotificationItem(
id=n.id,
type=n.type,
title=n.title,
message=n.message,
read=n.read,
created_at=n.created_at.isoformat() if n.created_at else "",
data=n.data,
)
for n in notifications
]
return NotificationsResponse(notifications=items, unread_count=unread_count)
@router.post("/notifications/read-all", tags=["Notifications"])
async def read_all_notifications(
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
count = await wn_db.mark_all_read_for_identity(session, identity.id)
return {"ok": True, "updated": count}
@router.post("/notifications/{notification_id}/read", tags=["Notifications"])
async def read_one_notification(
notification_id: str = Path(..., min_length=1, max_length=64),
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
"""Пометить одно уведомление прочитанным. 404 если не найдено или не принадлежит юзеру."""
ok = await wn_db.mark_one_read_for_identity(session, identity.id, notification_id)
if not ok:
raise HTTPException(status_code=404, detail="Уведомление не найдено")
return {"ok": True}
@router.delete("/notifications/{notification_id}", tags=["Notifications"])
async def delete_one_notification(
notification_id: str = Path(..., min_length=1, max_length=64),
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
"""Удалить одно уведомление. 404 если не найдено или не принадлежит юзеру."""
ok = await wn_db.delete_one_for_identity(session, identity.id, notification_id)
if not ok:
raise HTTPException(status_code=404, detail="Уведомление не найдено")
return {"ok": True}
File diff suppressed because it is too large Load Diff
-272
View File
@@ -1,272 +0,0 @@
import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from api.v2.schemas.payment_links import PaymentLinkCreateRequest, PaymentLinkCreateResponse, PaymentLinkStatusResponse
from config import REDIS_URL
from database import (
async_session_maker,
get_payment_by_payment_id,
get_payment_from_db_by_payment_id,
identities as idb,
)
from database.temporary_data import create_temporary_data
from logger import logger
from services.payments.payment_events import payment_events_channel
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
router = APIRouter(tags=["PaymentLinks"])
async def _store_payment_intent(
session: AsyncSession,
billing_user_ref: int,
metadata: dict | None,
amount: int | float,
) -> None:
if not isinstance(metadata, dict):
return
payment_flow = str(metadata.get("payment_flow") or "").strip().lower()
required_amount = int(round(float(amount)))
if payment_flow == "tariff_purchase":
tariff_id = metadata.get("tariff_id")
if tariff_id in (None, ""):
return
payload: dict[str, int | str] = {
"tariff_id": int(tariff_id),
"required_amount": required_amount,
"selected_price_rub": int(metadata.get("selected_price_rub") or required_amount),
}
selected_device_limit = metadata.get("selected_device_limit")
if selected_device_limit not in (None, ""):
payload["selected_device_limit"] = int(selected_device_limit)
selected_traffic_gb = metadata.get("selected_traffic_gb")
if selected_traffic_gb not in (None, ""):
payload["selected_traffic_limit_gb"] = int(selected_traffic_gb)
selected_duration_days = metadata.get("selected_duration_days")
if selected_duration_days not in (None, ""):
payload["selected_duration_days"] = int(selected_duration_days)
coupon_id = metadata.get("coupon_id")
if coupon_id not in (None, ""):
payload["coupon_id"] = int(coupon_id)
discount_rub = metadata.get("discount_rub")
if discount_rub not in (None, ""):
payload["discount_rub"] = int(discount_rub)
base_price_rub = metadata.get("base_price_rub")
if base_price_rub not in (None, ""):
payload["base_price_rub"] = int(base_price_rub)
applied_coupon_code = metadata.get("applied_coupon_code")
if applied_coupon_code not in (None, ""):
payload["applied_coupon_code"] = str(applied_coupon_code)
await create_temporary_data(session, billing_user_ref, "waiting_for_payment", payload)
return
if payment_flow == "key_renewal":
required_fields = ("tariff_id", "client_id", "email", "cost")
if any(metadata.get(field) in (None, "") for field in required_fields):
return
payload: dict[str, int | str] = {
"tariff_id": int(metadata["tariff_id"]),
"client_id": str(metadata["client_id"]),
"email": str(metadata["email"]),
"cost": int(metadata["cost"]),
"required_amount": required_amount,
"selected_price_rub": int(metadata.get("selected_price_rub") or metadata["cost"]),
}
selected_duration_days = metadata.get("selected_duration_days")
if selected_duration_days not in (None, ""):
payload["selected_duration_days"] = int(selected_duration_days)
selected_device_limit = metadata.get("selected_device_limit")
if selected_device_limit not in (None, ""):
payload["selected_device_limit"] = int(selected_device_limit)
selected_traffic_limit = metadata.get("selected_traffic_limit")
if selected_traffic_limit not in (None, ""):
payload["selected_traffic_limit"] = int(selected_traffic_limit)
total_gb = metadata.get("total_gb")
if total_gb not in (None, ""):
payload["total_gb"] = int(total_gb)
coupon_id = metadata.get("coupon_id")
if coupon_id not in (None, ""):
payload["coupon_id"] = int(coupon_id)
discount_rub = metadata.get("discount_rub")
if discount_rub not in (None, ""):
payload["discount_rub"] = int(discount_rub)
base_price_rub = metadata.get("base_price_rub")
if base_price_rub not in (None, ""):
payload["base_price_rub"] = int(base_price_rub)
applied_coupon_code = metadata.get("applied_coupon_code")
if applied_coupon_code not in (None, ""):
payload["applied_coupon_code"] = str(applied_coupon_code)
await create_temporary_data(session, billing_user_ref, "waiting_for_renewal_payment", payload)
return
if payment_flow == "key_addons":
required_fields = ("tariff_id", "email", "original_price")
if any(metadata.get(field) in (None, "") for field in required_fields):
return
payload: dict[str, int | str] = {
"tariff_id": int(metadata["tariff_id"]),
"email": str(metadata["email"]),
"original_price": int(metadata["original_price"]),
"required_amount": required_amount,
}
selected_device_limit = metadata.get("selected_device_limit")
if selected_device_limit not in (None, ""):
payload["selected_device_limit"] = int(selected_device_limit)
selected_traffic_gb = metadata.get("selected_traffic_gb")
if selected_traffic_gb not in (None, ""):
payload["selected_traffic_gb"] = int(selected_traffic_gb)
current_device_limit = metadata.get("current_device_limit")
if current_device_limit not in (None, ""):
payload["current_device_limit"] = int(current_device_limit)
current_traffic_gb = metadata.get("current_traffic_gb")
if current_traffic_gb not in (None, ""):
payload["current_traffic_gb"] = int(current_traffic_gb)
coupon_id = metadata.get("coupon_id")
if coupon_id not in (None, ""):
payload["coupon_id"] = int(coupon_id)
discount_rub = metadata.get("discount_rub")
if discount_rub not in (None, ""):
payload["discount_rub"] = int(discount_rub)
base_price_rub = metadata.get("base_price_rub")
if base_price_rub not in (None, ""):
payload["base_price_rub"] = int(base_price_rub)
applied_coupon_code = metadata.get("applied_coupon_code")
if applied_coupon_code not in (None, ""):
payload["applied_coupon_code"] = str(applied_coupon_code)
await create_temporary_data(session, billing_user_ref, "waiting_for_addons_payment", payload)
@router.post("/", response_model=PaymentLinkCreateResponse)
async def create_link(
body: PaymentLinkCreateRequest,
http_request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Создаёт платёжную ссылку для текущего авторизованного пользователя."""
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
payment_request = PaymentLinkRequest(
legacy_user_ref=billing_user_ref,
amount=body.amount,
currency=body.currency or "RUB",
provider_id=body.provider_id,
success_url=body.success_url,
failure_url=body.failure_url,
metadata=body.metadata,
)
result = await create_payment_link(session, payment_request)
if result.success:
await _store_payment_intent(
session=session,
billing_user_ref=billing_user_ref,
metadata=body.metadata,
amount=body.amount,
)
return PaymentLinkCreateResponse(
success=result.success,
payment_id=result.payment_id,
payment_url=result.payment_url,
error=result.error,
)
@router.get("/stream")
async def payment_events_stream(
request: Request,
x_identity_id: str = "",
token: str = "",
):
identity_id = str(request.headers.get("X-Identity-Id") or x_identity_id or "").strip()
token = str(request.headers.get("X-Token") or token or "").strip()
if not identity_id or not token:
raise HTTPException(status_code=401, detail="Unauthorized")
async with async_session_maker() as session:
identity = await idb.verify_identity_token(session, identity_id, token)
if not identity:
raise HTTPException(status_code=401, detail="Unauthorized")
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
await session.commit()
async def event_generator():
redis_client = None
pubsub = None
channel = payment_events_channel(int(billing_user_ref))
try:
from redis.asyncio import from_url
redis_client = from_url(REDIS_URL, encoding="utf-8", decode_responses=True, max_connections=8)
pubsub = redis_client.pubsub(ignore_subscribe_messages=True)
await pubsub.subscribe(channel)
logger.info(f"[Payments] SSE subscribed: user_ref={billing_user_ref}, channel={channel}")
yield "retry: 1500\n\n"
while True:
if await request.is_disconnected():
logger.info(f"[Payments] SSE disconnected by client: user_ref={billing_user_ref}")
break
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=15.0)
if message and message.get("type") == "message":
raw_data = message.get("data")
payload = json.loads(raw_data) if isinstance(raw_data, str) else raw_data
if isinstance(payload, dict):
logger.info(
f"[Payments] SSE emit: user_ref={billing_user_ref}, "
f"status={payload.get('status')}, flow={payload.get('flow')}"
)
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
continue
yield ": keepalive\n\n"
await asyncio.sleep(0.1)
finally:
if pubsub is not None:
try:
await pubsub.unsubscribe(channel)
await pubsub.close()
except Exception:
pass
if redis_client is not None:
try:
await redis_client.aclose()
except Exception:
pass
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/{payment_id}", response_model=PaymentLinkStatusResponse)
async def get_link_status(
payment_id: str,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
payment = await get_payment_from_db_by_payment_id(session, payment_id)
if payment is None:
payment = await get_payment_by_payment_id(session, payment_id)
if not payment:
raise HTTPException(status_code=404, detail="Payment not found")
owner_ref = payment.get("user_id")
if owner_ref is None:
owner_ref = payment.get("tg_id")
if owner_ref is None or int(owner_ref) != int(billing_user_ref):
raise HTTPException(status_code=404, detail="Payment not found")
status = str(payment.get("status") or "").lower() or None
return PaymentLinkStatusResponse(
success=True,
payment_id=payment_id,
status=status,
completed=status in {"success", "failed", "cancelled"},
paid=status == "success",
)
-250
View File
@@ -1,250 +0,0 @@
from base64 import b64encode
from io import BytesIO
from urllib.parse import urlsplit
import qrcode
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from api.v2.schemas.web_public import (
ReferralApplyRequest,
ReferralApplyResponse,
ReferralConditionsResponse,
ReferralListEntry,
ReferralListResponse,
ReferralQrResponse,
ReferralTopEntryResponse,
ReferralTopResponse,
)
from sqlalchemy import select
from database.models import Referral
from config import (
CHECK_REFERRAL_REWARD_ISSUED,
REFERRAL_BONUS_PERCENTAGES,
REFERRAL_BUTTON,
REFERRAL_QR,
TOP_REFERRAL_BUTTON,
)
from core.bootstrap import BUTTONS_CONFIG
from database import (
add_referral,
get_referral_by_referred_id,
get_user_referral_count,
identities as idb,
)
from database.access.resolution import resolve_user_optional
from database.referrals import get_referral_position, get_top_referrals
from utils.referral_codes import decode_referral_code, encode_referral_code
router = APIRouter()
def _normalize_referrer_code(value: str | None, fallback_tg_id: int | None) -> int | None:
raw = str(value or "").strip()
if raw:
if "/referral/" in raw:
raw = raw.split("/referral/", 1)[-1]
if "start=referral_" in raw:
raw = raw.split("start=referral_", 1)[-1]
raw = raw.split("?", 1)[0].split("#", 1)[0].strip()
parsed = decode_referral_code(raw)
if parsed is not None:
return parsed
if fallback_tg_id is not None and int(fallback_tg_id) > 0:
return int(fallback_tg_id)
return None
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith(("http://", "https://")):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith(("http://", "https://")):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
host = forwarded_host or str(request.headers.get("host") or "").strip()
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
if host:
return f"{scheme}://{host}".rstrip("/")
return str(request.base_url).rstrip("/")
@router.post("/apply", response_model=ReferralApplyResponse, tags=["Referrals"])
async def apply_referral(
body: ReferralApplyRequest,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
referrer_legacy = _normalize_referrer_code(body.referrer_code, body.referrer_tg_id)
if referrer_legacy is None:
raise HTTPException(status_code=400, detail="Приглашение недействительно")
referrer_u = await resolve_user_optional(session, referrer_legacy)
if referrer_u is None:
raise HTTPException(status_code=400, detail="Приглашение недействительно")
if billing_uid == referrer_u.id:
raise HTTPException(status_code=400, detail="Нельзя использовать собственную ссылку")
if await get_referral_by_referred_id(session, billing_uid):
raise HTTPException(status_code=409, detail="Реферальная связь уже сохранена")
await add_referral(session, billing_uid, referrer_u.id)
referred_u = await resolve_user_optional(session, billing_uid)
if referrer_u.tg_id is not None:
try:
from database.web_notifications import notify_web
await notify_web(
session,
tg_id=int(referrer_u.tg_id),
type="referral_joined",
title="Ваш реферал присоединился",
message="Новый пользователь зарегистрировался по вашей реферальной ссылке.",
data={
"referred_tg_id": int(referred_u.tg_id) if referred_u and referred_u.tg_id else None,
"referred_user_id": int(billing_uid),
},
)
except Exception:
pass
return ReferralApplyResponse(
ok=True,
message="Приглашение применено",
referrer_code=str(referrer_u.id),
referrer_user_id=int(referrer_u.id),
referrer_tg_id=referrer_u.tg_id,
referred_user_id=int(billing_uid),
referred_tg_id=referred_u.tg_id if referred_u is not None else None,
)
@router.get("/top", response_model=ReferralTopResponse, tags=["Referrals"])
async def referral_top(
limit: int = Query(5, ge=1, le=20),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
if not bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Топ рефералов отключен в настройках")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
user_referral_count = int(await get_user_referral_count(session, billing_uid))
user_position = int(await get_referral_position(session, user_referral_count)) if user_referral_count > 0 else None
top_rows = await get_top_referrals(session, limit=limit)
top: list[ReferralTopEntryResponse] = []
for index, row in enumerate(top_rows, 1):
referrer_user_id = int(row.get("referrer_user_id") or 0)
referrals_count = int(row.get("referral_count") or 0)
display_id = encode_referral_code(referrer_user_id)
top.append(
ReferralTopEntryResponse(
position=index,
referrer_user_id=referrer_user_id,
referrals_count=referrals_count,
display_id=display_id,
)
)
return ReferralTopResponse(
user_referrals_count=user_referral_count,
user_position=user_position,
top=top,
)
@router.get("/list", response_model=ReferralListResponse, tags=["Referrals"])
async def referral_list(
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
rows_stmt = (
select(Referral)
.where(Referral.referrer_user_id == int(billing_uid))
.limit(limit)
)
result = await session.execute(rows_stmt)
rows = result.scalars().all()
items = [
ReferralListEntry(
referred_user_id=int(r.referred_user_id),
referred_tg_id=int(r.referred_tg_id) if r.referred_tg_id is not None else None,
display_id=encode_referral_code(int(r.referred_user_id)),
reward_issued=bool(r.reward_issued),
)
for r in rows
]
return ReferralListResponse(total=len(items), items=items)
@router.get("/qr", response_model=ReferralQrResponse, tags=["Referrals"])
async def referral_qr(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
if not bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)):
raise HTTPException(status_code=403, detail="QR реферальной ссылки отключен в настройках")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
base_url = _resolve_public_base_url(request)
referral_link = f"{base_url}/referral/{encode_referral_code(int(billing_uid))}"
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(referral_link)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
image_data = b64encode(buffer.getvalue()).decode("ascii")
return ReferralQrResponse(
ok=True,
link=referral_link,
image_data_url=f"data:image/png;base64,{image_data}",
)
@router.get("/conditions", response_model=ReferralConditionsResponse, tags=["Referrals"])
async def referral_conditions(
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
del identity
level_lines: list[str] = []
for level in sorted(REFERRAL_BONUS_PERCENTAGES.keys()):
value = REFERRAL_BONUS_PERCENTAGES[level]
if isinstance(value, float):
label = f"{int(value * 100)}% от суммы оплаты"
else:
label = f"{float(value):g} RUB"
level_lines.append(f"{level} уровень: {label}")
one_time_mode = bool(CHECK_REFERRAL_REWARD_ISSUED)
bonus_mode = "one_time" if one_time_mode else "each_payment"
bonus_mode_label = (
"Бонус за первую успешную оплату реферала" if one_time_mode else "Бонус за каждую успешную оплату реферала"
)
rules = [
"Бонус начисляется только за реальных приглашённых пользователей.",
"Нельзя использовать собственную реферальную ссылку.",
"Реферальную связь можно применить только один раз.",
"Размер бонуса зависит от уровня реферальной программы.",
]
return ReferralConditionsResponse(
title="Условия реферальной программы",
summary=f"Режим начисления: {bonus_mode_label}.",
bonus_mode=bonus_mode,
bonus_mode_label=bonus_mode_label,
level_lines=level_lines,
rules=rules,
)
-353
View File
@@ -1,353 +0,0 @@
import asyncio
import os
import re
import time
import aiohttp
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session
from config import (
BALANCE_BUTTON,
CAPTCHA_ENABLE,
CHANNEL_EXISTS,
CHANNEL_REQUIRED,
DONATIONS_ENABLE,
GIFT_BUTTON,
HAPP_CRYPTOLINK,
HWID_RESET_BUTTON,
INSTRUCTIONS_BUTTON,
PROJECT_NAME,
REFERRAL_BUTTON,
REFERRAL_QR,
REMNAWAVE_WEBAPP,
REMNAWAVE_WEBAPP_OPEN_IN_BROWSER,
TELEGRAM_WEBAPP_DIRECT_LINK,
TELEGRAM_WEBAPP_SHORT_NAME,
TOP_REFERRAL_BUTTON,
TRIAL_TIME_DISABLE,
USERNAME_BOT,
USE_COUNTRY_SELECTION,
)
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, MONEY_CONFIG, PAYMENTS_CONFIG
from core.settings.money_config import get_currency_mode
from core.settings.web_config import WEB_CONFIG
from services.payments.providers import PROVIDERS_BASE, TELEGRAM_ONLY_PROVIDER_IDS, WEB_LINK_PROVIDER_IDS
router = APIRouter(tags=["Root"])
def _telegram_web_app_return_base() -> str | None:
direct = str(TELEGRAM_WEBAPP_DIRECT_LINK or "").strip().rstrip("/")
if direct:
if direct.lower().startswith("http://"):
direct = "https://" + direct[7:]
if direct.lower().startswith("https://t.me/"):
return direct
bot = USERNAME_BOT.replace("@", "").strip()
sn = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip()
if bot and sn:
return f"https://t.me/{bot}/{sn}"
if bot:
return f"https://t.me/{bot}"
return None
def _partner_feature_enabled() -> bool:
try:
from modules.partner_program import settings as partner_settings
except Exception:
return False
for key in ("PARTNER_PROGRAM_ENABLED", "PARTNER_BUTTON_ENABLED", "PARTNER_ENABLED"):
value = getattr(partner_settings, key, None)
if isinstance(value, bool):
return value
return True
@router.get("/api", include_in_schema=False)
async def root():
return {"message": "SoloBot API v2", "docs": "/api/docs"}
@router.get("/api/version", include_in_schema=True)
async def version():
return {"version": 2, "api": "v2"}
@router.get("/api/telegram-widget-bot", include_in_schema=True)
async def telegram_widget_bot():
"""Имя бота и имя проекта для веб-клиента."""
bot_username = str(USERNAME_BOT or "").replace("@", "").strip()
project_name = (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo"
telegram_client_id = ""
try:
from config import TELEGRAM_CLIENT_ID
telegram_client_id = str(TELEGRAM_CLIENT_ID).strip()
except ImportError:
pass
return {
"bot_username": bot_username,
"project_name": project_name,
"telegram_client_id": telegram_client_id,
}
@router.get("/api/site/init-state", include_in_schema=True)
async def site_init_state(session: AsyncSession = Depends(get_session)):
"""Прошёл ли сайт первую настройку админом. Используется middleware веб-клиента."""
from database.site_state import is_site_initialized
initialized = await is_site_initialized(session)
return {"initialized": bool(initialized)}
@router.get("/api/site/revision", include_in_schema=True)
async def site_revision(session: AsyncSession = Depends(get_session)):
"""Глобальный счётчик ревизии контента. Фронт опрашивает его в фоне и при
изменении инвалидирует свои SWR-кэши, подтягивая свежие правки админа."""
from database.site_revision import get_site_revision
revision = await get_site_revision(session)
return {"revision": int(revision)}
@router.get("/api/site-config", include_in_schema=True)
async def site_config():
"""Настройки витрины и кабинета для веб-клиента (флаги из runtime-конфигов бота)."""
bot_username = USERNAME_BOT.replace("@", "").strip()
pay_flags = {name: bool(PAYMENTS_CONFIG.get(name)) for name in PROVIDERS_BASE}
any_pay = any(pay_flags.values())
web_link_provider_ids = [provider_id for provider_id in WEB_LINK_PROVIDER_IDS if pay_flags.get(provider_id, False)]
telegram_only_provider_ids = [
provider_id for provider_id in TELEGRAM_ONLY_PROVIDER_IDS if pay_flags.get(provider_id, False)
]
currency_mode, currency_one_screen = get_currency_mode()
try:
cb_raw = MONEY_CONFIG.get("CASHBACK", 0)
cashback_percent = float(cb_raw) if cb_raw not in (None, False) else 0.0
except (TypeError, ValueError):
cashback_percent = 0.0
webapp_short = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip() or None
webapp_return_base = _telegram_web_app_return_base()
return {
"bot_username": bot_username or None,
"telegram_web_app_short_name": webapp_short,
"telegram_web_app_return_base": webapp_return_base,
"project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
"site_mode": str(WEB_CONFIG.get("SITE_MODE", "full")).strip() or "full",
"auth": {
"telegram_login_enabled": bool(bot_username),
"email_code_login_enabled": bool(MODES_CONFIG.get("WEB_EMAIL_CODE_LOGIN_ENABLED", True)),
},
"mobile": {
"prefer_mini_app_on_telegram_mobile": bool(MODES_CONFIG.get("PREFER_MINI_APP_ON_TELEGRAM_MOBILE", False)),
},
"features": {
"channel_enabled": bool(BUTTONS_CONFIG.get("CHANNEL_BUTTON_ENABLE", CHANNEL_EXISTS)),
"donations_enabled": bool(BUTTONS_CONFIG.get("DONATIONS_BUTTON_ENABLE", DONATIONS_ENABLE)),
"balance_enabled": bool(BUTTONS_CONFIG.get("BALANCE_BUTTON_ENABLE", BALANCE_BUTTON)),
"referral_qr_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)),
"instructions_enabled": bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
"gift_enabled": bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)),
"referral_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)),
"top_referral_enabled": bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)),
"coupon_enabled": bool(BUTTONS_CONFIG.get("COUPON_BUTTON_ENABLE", True)),
"qr_subscription_enabled": bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)),
"hwid_reset_enabled": bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
"country_selection_enabled": bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)),
"captcha_enabled": bool(MODES_CONFIG.get("CAPTCHA_ENABLED", CAPTCHA_ENABLE)),
"channel_check_enabled": bool(MODES_CONFIG.get("CHANNEL_CHECK_ENABLED", CHANNEL_REQUIRED)),
"trial_enabled": not bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE)),
"mini_app_enabled": bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP)),
"mini_app_open_in_browser": bool(
MODES_CONFIG.get("REMNAWAVE_WEBAPP_OPEN_IN_BROWSER", REMNAWAVE_WEBAPP_OPEN_IN_BROWSER)
),
"partner_enabled": bool(_partner_feature_enabled()),
},
"payments": {
"any_enabled": any_pay,
"any_web_link_enabled": bool(web_link_provider_ids),
"any_telegram_only_enabled": bool(telegram_only_provider_ids),
"web_link_provider_ids": web_link_provider_ids,
"telegram_only_provider_ids": telegram_only_provider_ids,
"yookassa_enabled": pay_flags.get("YOOKASSA", False),
"yoomoney_enabled": pay_flags.get("YOOMONEY", False),
"robokassa_enabled": pay_flags.get("ROBOKASSA", False),
"kassai_cards_enabled": pay_flags.get("KASSAI_CARDS", False),
"kassai_sbp_enabled": pay_flags.get("KASSAI_SBP", False),
"tribute_enabled": pay_flags.get("TRIBUTE", False),
"heleket_enabled": pay_flags.get("HELEKET", False),
"cryptobot_enabled": pay_flags.get("CRYPTOBOT", False),
"freekassa_enabled": pay_flags.get("FREEKASSA", False),
"stars_enabled": pay_flags.get("STARS", False),
},
"money": {
"currency_mode": currency_mode,
"currency_one_screen": currency_one_screen,
"cashback_enabled": cashback_percent > 0,
"cashback_percent": cashback_percent,
},
}
_UPDATE_CHECK_CACHE: dict[str, object] = {"fetched_at": 0.0, "data": None}
_UPDATE_CHECK_LOCK = asyncio.Lock()
_UPDATE_CHECK_TTL_SEC = 600
_SEMVER_RE = re.compile(
r"^v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<pre>[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$"
)
def _parse_semver(tag: str) -> tuple[int, int, int, int, tuple[tuple[int, int | str], ...]] | None:
"""Returns a tuple comparable per semver spec.
Release > prerelease (second-to-last slot: 1 for release, 0 for prerelease).
Last slot — tuple of prerelease identifiers; numeric ids compare numerically,
alphanumeric ids compare lexically, numeric < alphanumeric.
"""
match = _SEMVER_RE.match(tag.strip())
if not match:
return None
major = int(match.group("major"))
minor = int(match.group("minor"))
patch = int(match.group("patch"))
pre_raw = match.group("pre")
if not pre_raw:
return (major, minor, patch, 1, ())
identifiers: list[tuple[int, int | str]] = []
for part in pre_raw.split("."):
if part.isdigit():
identifiers.append((0, int(part)))
else:
identifiers.append((1, part))
return (major, minor, patch, 0, tuple(identifiers))
async def _fetch_ghcr_tags(image: str) -> list[str]:
"""Возвращает все теги образа в GHCR. Поддерживает paginate через Link header."""
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) as session:
token_url = f"https://ghcr.io/token?scope=repository:{image}:pull"
async with session.get(token_url) as token_resp:
if token_resp.status != 200:
return []
token_data = await token_resp.json()
token = str(token_data.get("token") or "").strip()
if not token:
return []
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
all_tags: list[str] = []
next_url: str | None = f"https://ghcr.io/v2/{image}/tags/list?n=1000"
guard = 0
while next_url and guard < 20:
guard += 1
async with session.get(next_url, headers=headers) as tags_resp:
if tags_resp.status != 200:
break
payload = await tags_resp.json()
page_tags = payload.get("tags") or []
if isinstance(page_tags, list):
all_tags.extend(str(t) for t in page_tags)
link_header = tags_resp.headers.get("Link") or ""
next_url = None
for part in link_header.split(","):
part = part.strip()
if not part or 'rel="next"' not in part:
continue
inner = part.split(";", 1)[0].strip()
if inner.startswith("<") and inner.endswith(">"):
inner = inner[1:-1]
if inner.startswith("/"):
next_url = f"https://ghcr.io{inner}"
else:
next_url = inner
break
return all_tags
def _is_dev_version(v: str) -> bool:
return "-dev" in v or "dev." in v
def _latest_for_channel(tags: list[str], dev: bool) -> str | None:
versions = []
for raw in tags:
parsed = _parse_semver(str(raw))
if parsed is None:
continue
is_dev = _is_dev_version(str(raw))
if is_dev == dev:
versions.append((parsed, str(raw)))
if not versions:
return None
versions.sort(key=lambda item: item[0], reverse=True)
return versions[0][1]
@router.get("/api/meta/update-check", include_in_schema=True)
async def update_check(current: str | None = Query(default=None)):
"""Сравнивает переданную версию с последним тегом в GHCR. Канал (dev/release) определяется по current."""
current_v = (current or "").strip()
image = (os.environ.get("GHCR_IMAGE") or "vladless/solo-brick").strip()
if not image:
return {"current": current_v or None, "latest": None, "hasUpdate": False}
now = time.time()
is_dev = _is_dev_version(current_v) if current_v else True
cache_key = f"data-{'dev' if is_dev else 'release'}"
cached = _UPDATE_CHECK_CACHE.get(cache_key)
fetched_at = float(_UPDATE_CHECK_CACHE.get("fetched_at") or 0.0)
if cached is not None and now - fetched_at < _UPDATE_CHECK_TTL_SEC:
cached_resp = dict(cached)
cached_resp["current"] = current_v or None
if current_v:
cur = _parse_semver(current_v)
nxt = _parse_semver(str(cached_resp.get("latest") or ""))
cached_resp["hasUpdate"] = bool(cur and nxt and nxt > cur)
return cached_resp
latest: str | None = None
async with _UPDATE_CHECK_LOCK:
fetched_at = float(_UPDATE_CHECK_CACHE.get("fetched_at") or 0.0)
if now - fetched_at < _UPDATE_CHECK_TTL_SEC:
cached = _UPDATE_CHECK_CACHE.get(cache_key)
if cached is not None:
cached_resp = dict(cached)
cached_resp["current"] = current_v or None
if current_v:
cur = _parse_semver(current_v)
nxt = _parse_semver(str(cached_resp.get("latest") or ""))
cached_resp["hasUpdate"] = bool(cur and nxt and nxt > cur)
return cached_resp
try:
tags = await _fetch_ghcr_tags(image)
latest_dev = _latest_for_channel(tags, dev=True)
latest_rel = _latest_for_channel(tags, dev=False)
_UPDATE_CHECK_CACHE["data-dev"] = {"latest": latest_dev, "hasUpdate": False, "image": image}
_UPDATE_CHECK_CACHE["data-release"] = {"latest": latest_rel, "hasUpdate": False, "image": image}
_UPDATE_CHECK_CACHE["fetched_at"] = now
latest = latest_dev if is_dev else latest_rel
except Exception:
latest = None
has_update = False
if current_v and latest:
cur = _parse_semver(current_v)
nxt = _parse_semver(latest)
if cur and nxt:
has_update = nxt > cur
return {
"current": current_v or None,
"latest": latest,
"hasUpdate": has_update,
"image": image or None,
"channel": "dev" if is_dev else "release",
"checkedAt": int(now),
}
-16
View File
@@ -1,16 +0,0 @@
from fastapi import APIRouter
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import ServerBase, ServerResponse, ServerUpdate
from database.models import Server
router = generate_crud_router(
model=Server,
schema_response=ServerResponse,
schema_create=ServerBase,
schema_update=ServerUpdate,
identifier_field="server_name",
parameter_name="server_name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
-147
View File
@@ -1,147 +0,0 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas import SettingResponse, SettingUpsert
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
from core.settings.modes_config import MODES_CONFIG, update_modes_config
from core.settings.money_config import MONEY_CONFIG, update_money_config
from core.settings.notifications_config import NOTIFICATIONS_CONFIG, update_notifications_config
from core.settings.payments_config import PAYMENTS_CONFIG, update_payments_config
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
from database.models import Setting
from database.settings import set_setting
from database.settings_cache import settings_cache
router = APIRouter()
class ConfigUpdatePayload(BaseModel):
value: dict[str, Any] | None = None
@router.get("/", response_model=list[SettingResponse])
async def get_all_settings(identity=Depends(verify_identity_admin)):
"""Список всех настроек (из кэша, без запроса к БД)."""
return settings_cache.get_all()
@router.get("/configs")
async def get_configs(identity=Depends(verify_identity_admin)):
"""Все конфиги (payments, buttons, notifications, modes, money, providers_order, tariffs)."""
return {
"payments": dict(PAYMENTS_CONFIG),
"buttons": dict(BUTTONS_CONFIG),
"notifications": dict(NOTIFICATIONS_CONFIG),
"modes": dict(MODES_CONFIG),
"money": dict(MONEY_CONFIG),
"providers_order": dict(PROVIDERS_ORDER),
"tariffs": dict(TARIFFS_CONFIG),
}
@router.post("/configs/{scope}")
async def update_config_scope(
scope: str,
payload: ConfigUpdatePayload,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Обновление конфига по scope (payments, buttons, notifications, modes, money, providers_order, tariffs)."""
data = dict(payload.value or {})
normalized = scope.strip().lower().replace("-", "_")
if normalized == "payments":
cleaned = {key: bool(value) for key, value in data.items()}
await update_payments_config(session, cleaned)
return {"payments": dict(PAYMENTS_CONFIG)}
if normalized == "buttons":
cleaned = {key: bool(value) for key, value in data.items()}
await update_buttons_config(session, cleaned)
return {"buttons": dict(BUTTONS_CONFIG)}
if normalized == "notifications":
await update_notifications_config(session, data)
return {"notifications": dict(NOTIFICATIONS_CONFIG)}
if normalized == "modes":
cleaned = {key: bool(value) for key, value in data.items()}
await update_modes_config(session, cleaned)
return {"modes": dict(MODES_CONFIG)}
if normalized == "money":
await update_money_config(session, data)
return {"money": dict(MONEY_CONFIG)}
if normalized == "providers_order":
cleaned: dict[str, int] = {}
for key, value in data.items():
try:
cleaned[key] = int(value)
except (TypeError, ValueError):
continue
await update_providers_order(session, cleaned)
return {"providers_order": dict(PROVIDERS_ORDER)}
if normalized == "tariffs":
cleaned = dict(data)
if "ALLOW_DOWNGRADE" in cleaned:
cleaned["ALLOW_DOWNGRADE"] = bool(cleaned.get("ALLOW_DOWNGRADE"))
if "KEY_ADDONS_RECALC_PRICE" in cleaned:
cleaned["KEY_ADDONS_RECALC_PRICE"] = bool(cleaned.get("KEY_ADDONS_RECALC_PRICE"))
if "KEY_ADDONS_PACK_MODE" in cleaned:
mode = str(cleaned.get("KEY_ADDONS_PACK_MODE") or "").strip().lower()
cleaned["KEY_ADDONS_PACK_MODE"] = mode if mode in {"", "traffic", "devices", "all"} else ""
await update_tariffs_config(session, cleaned)
return {"tariffs": dict(TARIFFS_CONFIG)}
raise HTTPException(status_code=404, detail="Unsupported config scope")
@router.get("/{key}", response_model=SettingResponse)
async def get_setting_by_key(key: str, identity=Depends(verify_identity_admin)):
"""Настройка по ключу (из кэша, без запроса к БД)."""
obj = settings_cache.get(key)
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
return obj
@router.post("/{key}", response_model=SettingResponse)
async def upsert_setting(
key: str,
payload: SettingUpsert,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Создание или обновление настройки по ключу."""
obj = await set_setting(
session=session,
key=key,
value=payload.value,
description=payload.description,
)
await session.refresh(obj)
settings_cache.update(
key,
obj.value,
obj.description,
created_at=getattr(obj, "created_at", None),
updated_at=getattr(obj, "updated_at", None),
)
return obj
@router.delete("/{key}", response_model=dict)
async def delete_setting(
key: str,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Удаление настройки по ключу."""
result = await session.execute(select(Setting).where(Setting.key == key))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
await session.delete(obj)
settings_cache.delete(key)
return {"detail": "Setting deleted"}
-452
View File
@@ -1,452 +0,0 @@
from datetime import datetime, timedelta
from math import ceil
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pytz import timezone as tz_moscow
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, validate_redirect_url, verify_identity_token
from api.v2.base_crud import generate_crud_router
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
from api.v2.schemas import TariffBase, TariffResponse, TariffUpdate
from api.v2.schemas.tariffs import TariffGroup, TariffPublic
from api.v2.schemas.web_public import (
TariffConfigPriceResponse,
TariffPurchaseRequest,
TariffPurchaseResponse,
)
from core.bootstrap import PAYMENTS_CONFIG
from core.redis_cache import cache_get, cache_key, cache_set
from database import (
get_balance,
identities as idb,
)
from database.coupons import mark_coupon_used
from database.models import Tariff
from database.tariffs import get_tariff_by_id
from database.temporary_data import create_temporary_data
from logger import logger
from services.keys import create_vpn_key_headless
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
from services.payments.providers import WEB_LINK_PROVIDER_IDS
from services.tariffs import calculate_config_price
def _tariff_to_public(t: Tariff) -> TariffPublic:
dev_opts = getattr(t, "device_options", None)
tr_opts = getattr(t, "traffic_options_gb", None)
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
if isinstance(dev_opts, list):
device_options = []
for x in dev_opts:
try:
device_options.append(int(x))
except (TypeError, ValueError):
continue
if not device_options:
device_options = None
if isinstance(tr_opts, list):
traffic_options_gb = []
for x in tr_opts:
try:
traffic_options_gb.append(int(x))
except (TypeError, ValueError):
continue
if not traffic_options_gb:
traffic_options_gb = None
return TariffPublic(
id=t.id,
name=t.name or "",
group_code=t.group_code or "",
duration_days=t.duration_days or 0,
price_rub=t.price_rub or 0,
traffic_limit=t.traffic_limit,
device_limit=t.device_limit,
subgroup_title=t.subgroup_title,
sort_order=t.sort_order,
vless=bool(getattr(t, "vless", False)),
configurable=bool(getattr(t, "configurable", False)),
device_options=device_options,
traffic_options_gb=traffic_options_gb,
)
public_router = APIRouter()
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith(("http://", "https://")):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith(("http://", "https://")):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
host = forwarded_host or str(request.headers.get("host") or "").strip()
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
if host:
return f"{scheme}://{host}".rstrip("/")
return str(request.base_url).rstrip("/")
def _resolve_default_web_payment_provider() -> str | None:
for provider_id in WEB_LINK_PROVIDER_IDS:
if bool(PAYMENTS_CONFIG.get(provider_id)):
return provider_id
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
def _public_tariffs_cache_key(
group_code: str | None,
tariff_ids: str | None,
filter_vless: str | None,
) -> str:
normalized_group = (group_code or "").strip().lower()
normalized_ids = ",".join(part.strip() for part in (tariff_ids or "").split(",") if part.strip())
normalized_vless = (filter_vless or "").strip().lower()
return cache_key("tariffs_public", normalized_group or "-", normalized_ids or "-", normalized_vless or "-")
@public_router.get("/groups", response_model=list[TariffGroup])
async def get_tariff_groups(session: AsyncSession = Depends(get_session)):
"""Публичный список групп тарифов — уникальные значения колонки group_code."""
q = (
select(Tariff.group_code)
.where(Tariff.is_active.is_(True), Tariff.group_code.isnot(None), Tariff.group_code != "")
.distinct()
.order_by(Tariff.group_code)
)
result = await session.execute(q)
values = result.scalars().all()
return [TariffGroup(group_code=v or "") for v in values]
@public_router.get("/public", response_model=list[TariffPublic])
async def get_tariffs_public(
group_code: str | None = Query(None, description="Фильтр по группе тарифов"),
tariff_ids: str | None = Query(None, description="ID тарифов через запятую (приоритет над группой)"),
filter_vless: str | None = Query(
None,
description="vless: только для роутера (vless=True), app: только для приложения (vless=False), иначе все",
),
session: AsyncSession = Depends(get_session),
):
"""Публичный список активных тарифов (без авторизации)."""
cache_token = _public_tariffs_cache_key(group_code, tariff_ids, filter_vless)
cached = await cache_get(cache_token)
if isinstance(cached, list):
return cached
q = (
select(Tariff)
.where(Tariff.is_active.is_(True))
.order_by(Tariff.sort_order.asc().nulls_last(), Tariff.price_rub.asc())
)
if tariff_ids:
try:
ids = [int(x.strip()) for x in tariff_ids.split(",") if x.strip()]
if not ids:
return []
q = q.where(Tariff.id.in_(ids))
except ValueError:
raise HTTPException(status_code=422, detail="Некорректный параметр tariff_ids")
elif group_code:
q = q.where(Tariff.group_code == group_code)
if filter_vless == "router":
q = q.where(Tariff.vless.is_(True))
elif filter_vless == "app":
q = q.where(Tariff.vless.is_(False))
result = await session.execute(q)
rows = result.scalars().all()
payload = [_tariff_to_public(t).model_dump() for t in rows]
await cache_set(cache_token, payload, 30)
return payload
@public_router.get("/config-price", response_model=TariffConfigPriceResponse)
async def get_tariff_config_price(
tariff_id: int = Query(..., ge=1),
selected_device_limit: int | None = Query(None),
selected_traffic_gb: int | None = Query(None),
session: AsyncSession = Depends(get_session),
):
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff or not tariff.get("is_active", True):
raise HTTPException(status_code=404, detail="Тариф не найден")
price = int(calculate_config_price(tariff, selected_device_limit, selected_traffic_gb))
return TariffConfigPriceResponse(price_rub=price)
user_tariff_router = APIRouter()
@user_tariff_router.post("/purchase", response_model=TariffPurchaseResponse)
async def purchase_tariff_with_balance(
body: TariffPurchaseRequest,
request: Request,
preview: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
tariff = await get_tariff_by_id(session, body.tariff_id)
if not tariff or not tariff.get("is_active", True):
raise HTTPException(status_code=404, detail="Тариф не найден")
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
if price <= 0:
raise HTTPException(status_code=400, detail="Некорректная цена тарифа")
final_price, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
session=session,
billing_user_id=int(tg_id),
base_price_rub=int(price),
coupon_code=body.coupon_code,
)
balance = float(await get_balance(session, tg_id))
duration = int(tariff.get("duration_days") or 0)
if duration <= 0:
raise HTTPException(status_code=400, detail="Некорректная длительность тарифа")
required_amount = int(max(0, ceil(float(final_price) - balance)))
if preview:
return TariffPurchaseResponse(
ok=True,
message="Расчет обновлен",
key_email=None,
charged_rub=0,
base_price_rub=int(price),
discount_rub=int(discount_rub),
final_price_rub=int(final_price),
applied_coupon_code=applied_coupon_code,
payment_required=required_amount > 0,
required_amount_rub=int(required_amount),
payment_id=None,
payment_url=None,
)
if required_amount > 0:
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
payment_request = PaymentLinkRequest(
legacy_user_ref=int(tg_id),
amount=required_amount,
currency="RUB",
provider_id=provider_id,
success_url=success_url,
failure_url=failure_url,
metadata={
"payment_flow": "tariff_purchase",
"tariff_id": int(body.tariff_id),
"selected_device_limit": body.selected_device_limit,
"selected_traffic_gb": body.selected_traffic_gb,
"selected_duration_days": int(duration),
"selected_price_rub": int(final_price),
"base_price_rub": int(price),
"discount_rub": int(discount_rub),
"applied_coupon_code": applied_coupon_code,
"coupon_id": int(coupon_id) if coupon_id is not None else None,
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(tg_id),
"waiting_for_payment",
{
"tariff_id": int(body.tariff_id),
"required_amount": int(required_amount),
"selected_price_rub": int(final_price),
"selected_device_limit": body.selected_device_limit,
"selected_traffic_limit_gb": body.selected_traffic_gb,
"selected_duration_days": int(duration),
"base_price_rub": int(price),
"discount_rub": int(discount_rub),
"applied_coupon_code": applied_coupon_code,
"coupon_id": int(coupon_id) if coupon_id is not None else None,
},
)
return TariffPurchaseResponse(
ok=True,
message="Требуется оплата для оформления подписки",
key_email=None,
charged_rub=0,
base_price_rub=int(price),
discount_rub=int(discount_rub),
final_price_rub=int(final_price),
applied_coupon_code=applied_coupon_code,
payment_required=True,
required_amount_rub=required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
moscow_tz = tz_moscow("Europe/Moscow")
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
try:
await create_vpn_key_headless(
session=session,
tg_id=tg_id,
expiry_time=expiry,
plan=body.tariff_id,
selected_device_limit=body.selected_device_limit,
selected_traffic_gb=body.selected_traffic_gb,
selected_price_rub=final_price,
)
if coupon_id is not None:
await mark_coupon_used(session, int(coupon_id), int(tg_id))
except Exception:
logger.exception("web tariff purchase failed")
raise HTTPException(status_code=500, detail="Не удалось оформить подписку") from None
return TariffPurchaseResponse(
ok=True,
message="Подписка оформлена. Ключ в разделе «Мои ключи».",
key_email=None,
charged_rub=final_price,
base_price_rub=int(price),
discount_rub=int(discount_rub),
final_price_rub=int(final_price),
applied_coupon_code=applied_coupon_code,
)
@user_tariff_router.post("/trial", response_model=TariffPurchaseResponse)
async def activate_trial(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Активация триала (бесплатного или платного). Доступно 1 раз."""
from database import get_trial, update_trial
from database.tariffs import get_tariffs
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
trial_status = await get_trial(session, tg_id)
if trial_status not in (0, -1):
raise HTTPException(status_code=409, detail="Пробная подписка уже использована")
trial_tariffs = await get_tariffs(session, group_code="trial")
if not trial_tariffs:
raise HTTPException(status_code=404, detail="Пробный тариф не найден")
tariff = trial_tariffs[0]
price = int(tariff.get("price_rub", 0) or 0)
duration = int(tariff.get("duration_days") or 0)
if duration <= 0:
raise HTTPException(status_code=400, detail="Некорректная длительность триала")
if price <= 0:
moscow_tz = tz_moscow("Europe/Moscow")
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
try:
await create_vpn_key_headless(
session=session,
tg_id=tg_id,
expiry_time=expiry,
plan=int(tariff["id"]),
selected_price_rub=0,
skip_balance_charge=True,
is_trial=True,
)
await update_trial(session, tg_id, 1)
except Exception:
logger.exception("web trial activation failed")
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
return TariffPurchaseResponse(
ok=True,
message="Пробная подписка активирована!",
charged_rub=0,
base_price_rub=0,
final_price_rub=0,
)
balance = float(await get_balance(session, tg_id))
required_amount = int(max(0, ceil(float(price) - balance)))
if required_amount <= 0:
moscow_tz = tz_moscow("Europe/Moscow")
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
try:
await create_vpn_key_headless(
session=session,
tg_id=tg_id,
expiry_time=expiry,
plan=int(tariff["id"]),
selected_price_rub=price,
is_trial=True,
)
await update_trial(session, tg_id, 1)
except Exception:
logger.exception("web paid trial activation failed")
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
return TariffPurchaseResponse(
ok=True,
message="Пробная подписка активирована!",
charged_rub=price,
base_price_rub=price,
final_price_rub=price,
)
provider_id = str(_resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
payment_request = PaymentLinkRequest(
legacy_user_ref=int(tg_id),
amount=required_amount,
currency="RUB",
provider_id=provider_id,
success_url=f"{base_url}/payment-success",
failure_url=f"{base_url}/payment-failure",
metadata={
"payment_flow": "trial_purchase",
"tariff_id": int(tariff["id"]),
"selected_price_rub": price,
"selected_duration_days": duration,
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(tg_id),
"waiting_for_payment",
{
"payment_flow": "trial_purchase",
"tariff_id": int(tariff["id"]),
"required_amount": required_amount,
"selected_price_rub": price,
"selected_duration_days": duration,
},
)
return TariffPurchaseResponse(
ok=True,
message="Требуется оплата для активации пробной подписки",
charged_rub=0,
base_price_rub=price,
final_price_rub=price,
payment_required=True,
required_amount_rub=required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
router = generate_crud_router(
model=Tariff,
schema_response=TariffResponse,
schema_create=TariffBase,
schema_update=TariffUpdate,
identifier_field="name",
parameter_name="name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
-63
View File
@@ -1,63 +0,0 @@
import asyncio
from fastapi import Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import UserBase, UserResponse, UserUpdate
from database import async_session_maker, delete_user_data, get_servers
from database.access.resolution import resolve_user_optional
from database.models import Key, User
from logger import logger
from services.operations import delete_key_from_cluster
router = generate_crud_router(
model=User,
schema_response=UserResponse,
schema_create=UserBase,
schema_update=UserUpdate,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "create", "update"],
)
@router.delete("/{tg_id}", response_model=dict)
async def delete_user(
tg_id: int = Path(..., description="Telegram ID пользователя"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Удаляет пользователя и его ключи на серверах."""
try:
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
key_records = result.all()
async with async_session_maker() as s:
servers = await get_servers(session=s)
cluster_ids = list(servers.keys())
async def _delete_one(cluster_id: str, email: str, client_id: str):
async with async_session_maker() as s:
await delete_key_from_cluster(cluster_id, email, client_id, s)
try:
tasks = [
_delete_one(cluster_id, email, client_id)
for email, client_id in key_records
for cluster_id in cluster_ids
]
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
await delete_user_data(session, tg_id)
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
-1128
View File
File diff suppressed because it is too large Load Diff
-40
View File
@@ -1,40 +0,0 @@
from api.v1.schemas import (
BlockedUserResponse,
CouponBase,
CouponResponse,
CouponUpdate,
CouponUsageResponse,
GiftBase,
GiftResponse,
GiftUpdate,
GiftUsageResponse,
KeyDetailsResponse,
KeyResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
ReferralResponse,
ServerBase,
ServerResponse,
ServerUpdate,
TariffBase,
TariffResponse,
TariffUpdate,
TemporaryDataResponse,
TrackingSourceResponse,
UserBase,
UserResponse,
UserUpdate,
)
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyUpdate
from api.v1.schemas.settings import SettingResponse, SettingUpsert
from api.v2.schemas.web import (
WebBlockResponse,
WebPageResponse,
WebPageUpdate,
WebPageVariantCreate,
WebPageVariantSummary,
WebPageVariantUpdate,
WebPageVariantsResponse,
WebTheme,
)
-58
View File
@@ -1,58 +0,0 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class AuditEventResponse(BaseModel):
id: int | None = None
event_type: str
channel: str
actor_identity_id: str | None
actor_tg_id: int | None
path_or_handler: str
entity_type: str | None
entity_id: str | None
result: str
reason: str | None
metadata: dict[str, Any] | None
request_id: str | None
created_at: datetime | None
class AuditEventListResponse(BaseModel):
items: list[AuditEventResponse]
limit: int
offset: int
class AuditPathStat(BaseModel):
step: str
label: str
total: int
success: int
fail: int
unique_users: int
fail_rate_pct: float
class AuditFunnelStep(BaseModel):
step: str
label: str
count: int
conversion_from_prev_pct: float | None
class AuditStatsSummary(BaseModel):
date_from: str
date_to: str
total_events: int
unique_users: int
class AuditStatsResponse(BaseModel):
summary: AuditStatsSummary
by_path: list[AuditPathStat]
funnel: list[AuditFunnelStep]
-91
View File
@@ -1,91 +0,0 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
_FLOW_CONFIG = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class EdgeConditionSchema(BaseModel):
model_config = _FLOW_CONFIG
field: str
operator: str
value: Any = None
class EdgeConditionGroupSchema(BaseModel):
model_config = _FLOW_CONFIG
logic: Literal["and", "or"] = "and"
conditions: list[EdgeConditionSchema] = []
class FlowEdgeSchema(BaseModel):
model_config = _FLOW_CONFIG
id: str
source: str
target: str
condition: EdgeConditionSchema | None = None
condition_group: EdgeConditionGroupSchema | None = None
label: str | None = None
priority: int | None = None
class FlowNodeActionConfigSchema(BaseModel):
model_config = _FLOW_CONFIG
action_type: str
params: dict[str, Any] = {}
class FlowNodeSchema(BaseModel):
model_config = _FLOW_CONFIG
id: str
type: str
label: str
label_en: str | None = None
enabled: bool = True
page_slug: str | None = None
cabinet_tab: str | None = None
screen_group: str | None = None
screen_id: str | None = None
auto_skip_if: EdgeConditionSchema | EdgeConditionGroupSchema | None = None
action_config: FlowNodeActionConfigSchema | None = None
config: dict = {}
position: dict
class FlowResponse(BaseModel):
model_config = _FLOW_CONFIG
id: str
name: str
nodes: list[FlowNodeSchema]
edges: list[FlowEdgeSchema]
entry_node_id: str | None
version: int
class FlowUpdate(BaseModel):
model_config = _FLOW_CONFIG
name: str | None = None
nodes: list[FlowNodeSchema]
edges: list[FlowEdgeSchema]
entry_node_id: str | None = None
class FlowCreate(BaseModel):
model_config = _FLOW_CONFIG
id: str
name: str
nodes: list[FlowNodeSchema] = []
edges: list[FlowEdgeSchema] = []
entry_node_id: str | None = None
-132
View File
@@ -1,132 +0,0 @@
from datetime import datetime
from pydantic import BaseModel, Field
class IdentityCreate(BaseModel):
email: str | None = Field(None, description="Почта для привязки")
tg_id: int | None = Field(None, description="Telegram ID для привязки")
class IdentityResponse(BaseModel):
id: str
email: str | None
tg_id: int | None
is_admin: bool = False
email_verified: bool = False
password_set: bool = False
onboarding_completed: bool = False
onboarding_stage: str | None = None
created_at: datetime | None
updated_at: datetime | None
class Config:
from_attributes = True
class RegisterByEmailRequest(BaseModel):
email: str = Field(..., min_length=1)
password: str = Field(..., min_length=8, description="Пароль (минимум 8 символов)")
referral_code: str | None = Field(None, min_length=1)
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
class RegisterResponse(BaseModel):
identity_id: str
class LoginRequest(BaseModel):
email: str = Field(..., min_length=1)
password: str = Field(...)
class SetPasswordRequest(BaseModel):
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
password_confirm: str = Field(..., min_length=8)
class ChangePasswordRequest(BaseModel):
current_password: str = Field(...)
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
password_confirm: str = Field(..., min_length=8)
class LoginResponse(BaseModel):
identity_id: str
identity: IdentityResponse
class SendLoginCodeRequest(BaseModel):
email: str = Field(..., min_length=1)
allow_register: bool = Field(
default=True,
description="Если true и email новый — создать идентичность и отправить код (passwordless flow)",
)
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
class LoginByCodeRequest(BaseModel):
email: str = Field(..., min_length=1)
code: str = Field(..., min_length=1)
class ConfirmPasswordResetRequest(BaseModel):
email: str = Field(..., min_length=1)
code: str = Field(..., min_length=1)
password: str = Field(..., min_length=8)
password_confirm: str = Field(..., min_length=8)
class LoginTelegramRequest(BaseModel):
"""Данные от Telegram Login Widget (кнопка «Войти через Telegram»)."""
id: int = Field(..., description="Telegram user id (tg_id)")
first_name: str = Field("")
last_name: str | None = None
username: str | None = None
photo_url: str | None = None
auth_date: int = Field(..., description="Unix timestamp от Telegram")
hash: str = Field(..., description="HMAC подпись для проверки на бэкенде")
class LinkTelegramRequest(BaseModel):
"""Данные от Telegram Login Widget — обязательны для доказательства владения аккаунтом при привязке."""
id: int = Field(..., description="Telegram user id (tg_id)")
first_name: str = Field("")
last_name: str | None = None
username: str | None = None
photo_url: str | None = None
auth_date: int = Field(..., description="Unix timestamp от Telegram")
hash: str = Field(..., description="HMAC подпись для проверки на бэкенде")
class IdentityAttachEmail(BaseModel):
email: str = Field(..., min_length=1)
class LinkEmailSendCodeRequest(BaseModel):
email: str = Field(..., min_length=1)
class LinkEmailConfirmRequest(BaseModel):
email: str = Field(..., min_length=1)
code: str = Field(..., min_length=1, max_length=16)
class IdentityAttachTelegram(BaseModel):
tg_id: int = Field(...)
class IdentitySessionItem(BaseModel):
id: str
device_label: str | None = None
ip: str | None = None
created_at: datetime
last_seen_at: datetime
expires_at: datetime | None = None
is_current: bool = False
class IdentitySessionsResponse(BaseModel):
sessions: list[IdentitySessionItem]
-32
View File
@@ -1,32 +0,0 @@
from typing import Any
from pydantic import BaseModel, Field
class PaymentLinkCreateRequest(BaseModel):
tg_id: int | None = Field(None, description="Telegram ID пользователя (если не задан identity_id)")
identity_id: str | None = Field(None, description="ID идентичности; tg_id будет взят из привязки")
amount: int | float = Field(..., gt=0, description="Сумма оплаты")
currency: str = Field(default="RUB", description="Валюта (например RUB)")
provider_id: str | None = Field(
default=None,
description="Идентификатор кассы: ROBOKASSA, FREEKASSA, YOOKASSA, YOOMONEY, KASSAI_CARDS, KASSAI_SBP, HELEKET и др. Если не задан — берётся первый доступный.",
)
success_url: str | None = Field(None, description="URL перенаправления после успешной оплаты")
failure_url: str | None = Field(None, description="URL перенаправления после неуспешной оплаты")
metadata: dict[str, Any] | None = Field(None, description="Дополнительные данные")
class PaymentLinkCreateResponse(BaseModel):
success: bool
payment_id: str | None = None
payment_url: str | None = None
error: str | None = None
class PaymentLinkStatusResponse(BaseModel):
success: bool
payment_id: str
status: str | None = None
completed: bool = False
paid: bool = False
-25
View File
@@ -1,25 +0,0 @@
from pydantic import BaseModel
class TariffGroup(BaseModel):
"""Группа тарифов (group_code) для выбора в лендинге и др."""
group_code: str
class TariffPublic(BaseModel):
"""Публичный список тарифов (без авторизации)."""
id: int
name: str
group_code: str
duration_days: int
price_rub: int
traffic_limit: int | None
device_limit: int | None
subgroup_title: str | None
sort_order: int | None
vless: bool = False
configurable: bool = False
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
-125
View File
@@ -1,125 +0,0 @@
import json
from typing import Any
from pydantic import BaseModel, Field, model_validator
_MAX_BLOCK_DATA_SIZE = 4 * 1024 * 1024
class WebBlockBase(BaseModel):
type: str = Field(..., max_length=64)
order: int
data: dict[str, Any]
@model_validator(mode="after")
def _check_data_size(self) -> "WebBlockBase":
if len(json.dumps(self.data, ensure_ascii=False)) > _MAX_BLOCK_DATA_SIZE:
raise ValueError(f"Размер data блока не должен превышать {_MAX_BLOCK_DATA_SIZE // 1024} КБ")
return self
class WebBlockResponse(WebBlockBase):
id: str
class Config:
from_attributes = True
class WebTheme(BaseModel):
tokens: dict[str, Any]
class WebPageThemeResponse(BaseModel):
slug: str
variant_key: str = "default"
tokens: dict[str, Any] = Field(default_factory=dict)
class WebPageThemeUpdate(BaseModel):
tokens: dict[str, Any]
class WebPageVariantSummary(BaseModel):
key: str = Field(..., max_length=64)
name: str = Field(..., max_length=255)
is_active: bool = False
class WebPageSaveResponse(BaseModel):
slug: str
variant_key: str = "default"
active_variant_key: str = "default"
variants: list[WebPageVariantSummary] = Field(default_factory=list)
class WebPageResponse(BaseModel):
slug: str
blocks: list[WebBlockResponse]
theme: WebTheme | None = None
variant_key: str = "default"
active_variant_key: str = "default"
variants: list[WebPageVariantSummary] = Field(default_factory=list)
class WebPageUpdate(BaseModel):
blocks: list[WebBlockBase]
theme: WebTheme | None = None
class WebPageVariantCreate(BaseModel):
key: str | None = Field(default=None, max_length=64)
name: str | None = Field(default=None, max_length=255)
from_variant_key: str | None = Field(default=None, max_length=64)
class WebPageVariantUpdate(BaseModel):
name: str | None = Field(default=None, max_length=255)
make_active: bool | None = None
class WebPageVariantsResponse(BaseModel):
slug: str
active_variant_key: str = "default"
current_variant_key: str = "default"
variants: list[WebPageVariantSummary] = Field(default_factory=list)
class WebUploadResponse(BaseModel):
url: str
class FlowStepConfig(BaseModel):
provider_ids: list[str] | None = None
tariff_group_code: str | None = None
tariff_ids: list[int] | None = None
display_mode: str | None = None
skippable: bool = False
auto_advance_if_single: bool = False
class FlowStepSchema(BaseModel):
id: str = Field(..., max_length=64)
type: str = Field(..., max_length=32)
label: str = Field(..., max_length=255)
label_en: str | None = Field(default=None, max_length=255)
enabled: bool = True
page_slug: str | None = Field(default=None, max_length=64)
config: FlowStepConfig = Field(default_factory=FlowStepConfig)
class FlowDefinitionSchema(BaseModel):
id: str = Field(..., max_length=64)
name: str = Field(..., max_length=255)
steps: list[FlowStepSchema] = Field(default_factory=list)
version: int = 1
class FlowDefinitionResponse(FlowDefinitionSchema):
pass
class FlowDefinitionUpdate(BaseModel):
name: str | None = Field(default=None, max_length=255)
steps: list[FlowStepSchema] = Field(default_factory=list)
-479
View File
@@ -1,479 +0,0 @@
from pydantic import BaseModel, Field
class AccountSummaryResponse(BaseModel):
identity_id: str
email: str | None = None
tg_id: int | None = None
linked_telegram: bool = False
created_at: str | None = None
password_set: bool = False
referral_code: str = ""
balance: float = 0.0
trial_status: int = 0
keys_total: int = 0
referrals_total: int = 0
referrals_active: int = 0
referral_bonus_total: float = 0.0
gifts_sent: int = 0
gifts_claimed: int = 0
coupons_used: int = 0
partner_enabled: bool = False
partner_code: str = ""
partner_balance: float = 0.0
partner_percent: float = 0.0
partner_percent_custom: bool = False
partner_referred_total: int = 0
partner_referred_paid: int = 0
partner_payout_method: str | None = None
unread_notifications: int = 0
class AccountKeyActionsAvailability(BaseModel):
can_connect_device: bool = False
can_connect_router: bool = False
can_connect_tv: bool = False
can_renew: bool = False
can_addons: bool = False
can_reset_hwid: bool = False
can_qr: bool = False
can_delete: bool = False
can_change_location: bool = False
class AccountKeyDetailsResponse(BaseModel):
client_id: str
email: str
alias: str | None = None
expiry_time: int = 0
is_frozen: bool = False
tariff_name: str = ""
subgroup_title: str = ""
traffic_limit_gb: int = 0
used_traffic_gb: float | None = None
device_limit: int = 0
connected_devices: int = 0
is_tariff_configurable: bool = False
addons_devices_enabled: bool = False
addons_traffic_enabled: bool = False
class AccountKeyResponse(BaseModel):
email: str
alias: str | None = None
client_id: str
tariff_id: int | None = None
server_id: str
created_at: int = 0
expiry_time: int = 0
key: str | None = None
remnawave_link: str | None = None
is_frozen: bool = False
actions: AccountKeyActionsAvailability | None = None
class AccountKeyAliasUpdateRequest(BaseModel):
alias: str = Field(..., min_length=1, max_length=10)
class AccountKeyActionResponse(BaseModel):
ok: bool = True
message: str = ""
class AccountKeyRenewRequest(BaseModel):
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
coupon_code: str | None = None
class AccountKeyRenewResponse(AccountKeyActionResponse):
client_id: str
tariff_id: int
charged_rub: int = 0
balance_rub: float = 0.0
base_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class AccountKeyResetHwidResponse(AccountKeyActionResponse):
total_devices: int = 0
reset_devices: int = 0
class AccountKeyQrResponse(AccountKeyActionResponse):
link: str = ""
image_data_url: str = ""
class AccountKeyLocationOptionResponse(BaseModel):
server_name: str
class AccountKeyLocationsResponse(BaseModel):
client_id: str
current_server: str = ""
locations: list[AccountKeyLocationOptionResponse] = []
class AccountKeyChangeLocationRequest(BaseModel):
server_name: str = Field(..., min_length=1)
class AccountKeyChangeLocationResponse(AccountKeyActionResponse):
client_id: str
server_id: str = ""
link: str = ""
remnawave_link: str | None = None
class AccountKeyAddonOptionResponse(BaseModel):
value: int
label: str = ""
class AccountKeyAddonsPreviewRequest(BaseModel):
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
include_device: bool | None = None
include_traffic: bool | None = None
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
coupon_code: str | None = None
class AccountKeyAddonsPreviewResponse(BaseModel):
client_id: str
tariff_id: int
addons_mode: str = ""
has_device_option: bool = False
has_traffic_option: bool = False
current_device_limit: int | None = None
current_traffic_gb: int | None = None
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
device_options: list[AccountKeyAddonOptionResponse] = []
traffic_options: list[AccountKeyAddonOptionResponse] = []
total_price_rub: int = 0
extra_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
balance_rub: float = 0.0
class AccountKeyApplyAddonsResponse(AccountKeyActionResponse):
client_id: str
tariff_id: int
total_price_rub: int = 0
extra_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
charged_rub: int = 0
balance_rub: float = 0.0
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class AccountKeyActionsConfigResponse(BaseModel):
renew_enabled: bool = True
delete_enabled: bool = False
qr_enabled: bool = False
hwid_reset_enabled: bool = False
country_change_enabled: bool = False
instructions_enabled: bool = False
addons_enabled: bool = False
addons_mode: str = ""
tv_connect_enabled: bool = False
class AccountKeyConnectionResponse(BaseModel):
client_id: str
online: bool = False
is_frozen: bool = False
expiry_time: int = 0
expires_in_days: int = 0
server_name: str = ""
cluster_name: str = ""
panel_type: str = ""
protocol: str = ""
class AccountSearchHit(BaseModel):
kind: str
label: str
sublabel: str = ""
href: str = ""
meta: str = ""
class AccountSearchResponse(BaseModel):
query: str
hits: list[AccountSearchHit] = []
total: int = 0
class TariffConfigPriceResponse(BaseModel):
price_rub: int
class TariffPurchaseRequest(BaseModel):
tariff_id: int = Field(..., ge=1)
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
coupon_code: str | None = None
class TariffPurchaseResponse(BaseModel):
ok: bool = True
message: str = ""
key_email: str | None = None
charged_rub: int | None = None
base_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class GiftCreateRequest(BaseModel):
tariff_id: int = Field(..., ge=1)
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
class GiftCreatePreviewResponse(BaseModel):
ok: bool = True
price_rub: int = 0
balance_rub: float = 0.0
sufficient_funds: bool = True
tariff_name: str = ""
duration_days: int = 0
class GiftCreateResponse(BaseModel):
ok: bool = True
message: str = ""
gift_id: str = ""
site_gift_link: str = ""
tariff_name: str = ""
duration_days: int = 0
price_charged: int = 0
balance_rub: float = 0.0
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class GiftUsageEntry(BaseModel):
user_id: int
used_at: str | None = None
class MyGiftItem(BaseModel):
gift_id: str
tariff_name: str = ""
duration_days: int = 0
price_rub: int = 0
created_at: str | None = None
expiry_time: str | None = None
is_used: bool = False
is_unlimited: bool = False
max_usages: int | None = None
site_gift_link: str = ""
usages: list[GiftUsageEntry] = []
class MyGiftsResponse(BaseModel):
ok: bool = True
gifts: list[MyGiftItem] = []
total: int = 0
limit: int = 20
offset: int = 0
class GiftRedeemRequest(BaseModel):
gift_code: str = Field(..., min_length=1)
class GiftRedeemResponse(BaseModel):
ok: bool = True
message: str = ""
gift_id: str = ""
tariff_id: int = 0
duration_days: int = 0
class ReferralApplyRequest(BaseModel):
referrer_code: str | None = Field(None, min_length=1)
referrer_tg_id: int | None = Field(None, ge=1)
class ReferralApplyResponse(BaseModel):
ok: bool = True
message: str = ""
referrer_code: str = ""
referrer_user_id: int = 0
referrer_tg_id: int | None = None
referred_user_id: int = 0
referred_tg_id: int | None = None
class ReferralTopEntryResponse(BaseModel):
position: int
referrer_user_id: int
referrals_count: int
display_id: str
class ReferralTopResponse(BaseModel):
user_referrals_count: int = 0
user_position: int | None = None
top: list[ReferralTopEntryResponse] = []
class ReferralListEntry(BaseModel):
referred_user_id: int
referred_tg_id: int | None = None
display_id: str = ""
reward_issued: bool = False
class ReferralListResponse(BaseModel):
total: int = 0
items: list[ReferralListEntry] = []
class ReferralQrResponse(BaseModel):
ok: bool = True
link: str = ""
image_data_url: str = ""
class ReferralConditionsResponse(BaseModel):
title: str = ""
summary: str = ""
bonus_mode: str = ""
bonus_mode_label: str = ""
level_lines: list[str] = []
rules: list[str] = []
class PartnerConditionsResponse(BaseModel):
title: str = ""
summary: str = ""
bonus_mode: str = ""
bonus_mode_label: str = ""
level_lines: list[str] = []
rules: list[str] = []
examples: list[str] = []
min_payout_rub: float = 0.0
payout_methods: list[str] = []
custom_amount_enabled: bool = False
class PartnerQrResponse(BaseModel):
ok: bool = True
link: str = ""
image_data_url: str = ""
class PartnerApplyRequest(BaseModel):
partner_code: str | None = Field(None, min_length=1)
partner_tg_id: int | None = Field(None, ge=1)
class PartnerApplyResponse(BaseModel):
ok: bool = True
message: str = ""
partner_code: str = ""
partner_user_id: int = 0
partner_tg_id: int | None = None
joined_user_id: int = 0
joined_tg_id: int | None = None
class PartnerTopEntryResponse(BaseModel):
position: int
partner_user_id: int
referred_count: int
display_id: str
class PartnerTopResponse(BaseModel):
user_referred_count: int = 0
user_position: int | None = None
top: list[PartnerTopEntryResponse] = []
class PartnerInvitedEntry(BaseModel):
tg_id: int
joined_at: str | None = None
balance: float = 0.0
keys_count: int = 0
payments_count: int = 0
class PartnerInvitedResponse(BaseModel):
total: int = 0
items: list[PartnerInvitedEntry] = []
class CouponApplyRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=128)
class CouponApplyResponse(BaseModel):
ok: bool = True
message: str = ""
coupon_code: str = ""
amount: int = 0
balance: float = 0.0
class PartnerPayoutRequestCreate(BaseModel):
amount_rub: float = Field(..., gt=0)
class PartnerPayoutRequestResponse(BaseModel):
ok: bool = True
message: str = ""
request_id: int | None = None
amount_rub: float = 0.0
status: str = "pending"
balance_rub: float = 0.0
class PartnerPayoutEntryResponse(BaseModel):
id: int
amount_rub: float = 0.0
status: str = ""
created_at: str | None = None
method: str | None = None
destination: str | None = None
class PartnerPayoutHistoryResponse(BaseModel):
total: int = 0
items: list[PartnerPayoutEntryResponse] = []
+114
View File
@@ -0,0 +1,114 @@
CREATE TABLE IF NOT EXISTS users
(
tg_id BIGINT PRIMARY KEY NOT NULL,
username TEXT,
first_name TEXT,
last_name TEXT,
language_code TEXT,
is_bot BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS connections
(
tg_id BIGINT PRIMARY KEY NOT NULL,
balance REAL NOT NULL DEFAULT 0.0,
trial INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS payments
(
id SERIAL PRIMARY KEY,
tg_id BIGINT NOT NULL,
amount REAL NOT NULL,
payment_system TEXT NOT NULL,
status TEXT DEFAULT 'success',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tg_id) REFERENCES users (tg_id)
);
CREATE TABLE IF NOT EXISTS keys
(
tg_id BIGINT NOT NULL,
client_id TEXT NOT NULL,
email TEXT NOT NULL,
created_at BIGINT NOT NULL,
expiry_time BIGINT NOT NULL,
key TEXT NOT NULL,
server_id TEXT NOT NULL DEFAULT 'cluster1',
notified BOOLEAN NOT NULL DEFAULT FALSE,
notified_24h BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (tg_id, client_id)
);
CREATE TABLE IF NOT EXISTS referrals
(
referred_tg_id BIGINT PRIMARY KEY NOT NULL,
referrer_tg_id BIGINT NOT NULL,
reward_issued BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS coupons
(
id SERIAL PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
amount INTEGER NOT NULL,
usage_limit INTEGER NOT NULL DEFAULT 1,
usage_count INTEGER NOT NULL DEFAULT 0,
is_used BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS coupon_usages
(
coupon_id INTEGER NOT NULL REFERENCES coupons (id) ON DELETE CASCADE,
user_id BIGINT NOT NULL,
used_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (coupon_id, user_id)
);
CREATE TABLE IF NOT EXISTS notifications
(
tg_id BIGINT NOT NULL,
last_notification_time TIMESTAMP NOT NULL DEFAULT NOW(),
notification_type TEXT NOT NULL,
PRIMARY KEY (tg_id, notification_type)
);
CREATE TABLE IF NOT EXISTS servers
(
id SERIAL PRIMARY KEY,
cluster_name TEXT NOT NULL,
server_name TEXT NOT NULL,
api_url TEXT NOT NULL,
subscription_url TEXT NOT NULL,
inbound_id TEXT NOT NULL,
UNIQUE (cluster_name, server_name)
);
CREATE TABLE IF NOT EXISTS gifts
(
gift_id TEXT PRIMARY KEY NOT NULL,
sender_tg_id BIGINT NOT NULL,
selected_months INTEGER NOT NULL,
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
gift_link TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_used BOOLEAN NOT NULL DEFAULT FALSE,
recipient_tg_id BIGINT,
CONSTRAINT fk_sender FOREIGN KEY (sender_tg_id) REFERENCES users (tg_id),
CONSTRAINT fk_recipient FOREIGN KEY (recipient_tg_id) REFERENCES users (tg_id)
);
CREATE TABLE IF NOT EXISTS temporary_data (
tg_id BIGINT PRIMARY KEY NOT NULL,
state TEXT NOT NULL,
data JSONB NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS blocked_users (
tg_id BIGINT PRIMARY KEY,
blocked_at TIMESTAMP DEFAULT NOW()
);
-1156
View File
File diff suppressed because it is too large Load Diff
-450
View File
@@ -1,450 +0,0 @@
from __future__ import annotations
from collections.abc import Iterable
def _get_bot_webhook_path() -> str:
"""Путь вебхука бота из конфига (для исключения из шага «успешная оплата»)."""
try:
from config import WEBHOOK_PATH
return ((WEBHOOK_PATH or "").strip().lower()) or ""
except ImportError:
return ""
def _is_bot_webhook_path(path: str) -> bool:
"""True только если path — именно вебхук бота (точное совпадение сегмента пути), не касса."""
bot_path = _get_bot_webhook_path()
if not bot_path:
return False
p = (path or "").strip().lower()
path_segment = p.split(" ", 1)[1] if " " in p else p
return path_segment == bot_path or path_segment.rstrip("/") == bot_path.rstrip("/")
AUDIT_STEP_LABELS: dict[str, str] = {
"start": "Старт",
"start_coupon": "Старт: купон",
"start_gift": "Старт: подарок",
"start_referral": "Старт: рефералка",
"start_utm": "Старт: UTM",
"profile": "Профиль",
"about": "О VPN",
"instructions": "Инструкции",
"balance": "Баланс / история оплат",
"view_keys": "Мои ключи",
"buy_entry": "Оформление: вход",
"tariff_config": "Оформление: выбор тарифа/конфига",
"key_create": "Подписка оформлена (ключ создан)",
"pay_start": "Оплата: вход / создание ссылки",
"pay": "Успешная оплата (пополнение)",
"key_view": "Ключ (карточка)",
"connect": "Подключение: экран / инструкции / QR",
"key_manage": "Управление подпиской",
"renew": "Продление",
"addons": "Аддоны",
"referral": "Рефералы",
"coupons": "Купоны",
"register": "Регистрация (API)",
"login": "Вход (API)",
"api_other": "API прочее",
"admin": "Админ-панель",
"other": "Прочее",
}
DEFAULT_FUNNEL_STEPS = (
"start",
"profile",
"view_keys",
"buy_entry",
"tariff_config",
"key_create",
"pay_start",
"pay",
"key_view",
"connect",
)
_CALLBACK_EXACT: dict[str, str] = {
"start": "start",
"profile": "profile",
"about_vpn": "about",
"instructions": "instructions",
"view_keys": "view_keys",
"create_key": "buy_entry",
"buy": "buy_entry",
"pay": "pay_start",
"balance": "balance",
"balance_history": "balance",
"activate_coupon": "coupons",
"cancel_coupon_activation": "coupons",
"exit_coupon_input": "coupons",
"invite": "referral",
"top_referrals": "referral",
"check_subscription": "start",
"back_to_tariff_group_list": "tariff_config",
"back_to_subgroup_tariffs": "tariff_config",
"cancel_and_back_to_view_keys": "view_keys",
"fastflow_coupon": "coupons",
"fastflow_coupon_back": "coupons",
"fastflow_back": "pay_start",
"pay_kassai": "pay_start",
"pay_kassai_cards": "pay_start",
"pay_kassai_sbp": "pay_start",
"pay_heleket_crypto": "pay_start",
"pay_freekassa": "pay_start",
"pay_robokassa": "pay_start",
}
_CALLBACK_PREFIX: list[tuple[str, str]] = [
("view_key|", "key_view"),
("view_keys|", "view_keys"),
("show_referral_qr|", "referral"),
("tariff_subgroup_user|", "tariff_config"),
("select_tariff_plan|", "tariff_config"),
("cfg_user_devices|", "tariff_config"),
("cfg_user_traffic|", "tariff_config"),
("rename_key|", "key_manage"),
("reset_hwid|", "key_manage"),
("delete_key|", "key_manage"),
("confirm_delete|", "key_manage"),
("update_subscription|", "key_manage"),
("change_location|", "key_manage"),
("select_country|", "key_manage"),
("connect_device|", "connect"),
("connect_router|", "connect"),
("connect_tv|", "connect"),
("connect_pc|", "connect"),
("connect_ios|", "connect"),
("connect_android|", "connect"),
("show_qr|", "connect"),
("continue_tv|", "connect"),
("pay_currency|", "pay_start"),
("choose_payment_currency|", "pay_start"),
("cfg_user_confirm|", "key_create"),
("choose_payment_provider|", "pay_start"),
("kassai_method|", "pay_start"),
("kassai_cards_amount|", "pay_start"),
("kassai_sbp_amount|", "pay_start"),
("kassai_custom_amount|", "pay_start"),
("heleket_method|", "pay_start"),
("heleket_crypto_amount|", "pay_start"),
("heleket_custom_amount|", "pay_start"),
("freekassa_amount|", "pay_start"),
("robokassa_", "pay_start"),
("cfg_renew", "renew"),
("key_addons", "addons"),
("extend_key", "coupons"),
]
_CALLBACK_CONTAINS: list[tuple[str, str]] = [
("connect_", "connect"),
("renew", "renew"),
("addon", "addons"),
("referral", "referral"),
("invite", "referral"),
("coupon", "coupons"),
("users_audit", "admin"),
("users_editor", "admin"),
("search_user", "admin"),
("admin_panel", "admin"),
]
_MESSAGE_STEP_BY_COMMAND: dict[str, str] = {
"/start": "start",
"start": "start",
"/buy": "buy_entry",
"buy": "buy_entry",
"/subs": "view_keys",
"subs": "view_keys",
"/profile": "profile",
"profile": "profile",
"/invite": "referral",
"invite": "referral",
"/instructions": "instructions",
"instructions": "instructions",
"/activate_coupon": "coupons",
"activate_coupon": "coupons",
}
_START_PAYLOAD_MAX_LEN = 256
_START_PAYLOAD_MAX_PARTS = 20
_IGNORED_CALLBACK_EXACT: set[str] = {
" ",
"back",
"cancel",
"back_to_pay",
"back_to_currency",
"back_to_tariff_group_list",
"back_to_subgroup_tariffs",
"cancel_and_back_to_view_keys",
"cancel_coupon_activation",
"exit_coupon_input",
"fastflow_back",
"fastflow_coupon_back",
"cfg_back_menu",
"cfg_cancel_input",
"cancel_broadcast",
}
_IGNORED_CALLBACK_PREFIX: tuple[str, ...] = (
"back:",
"back_to_",
"cancel_",
"gifts_page|",
)
_IGNORED_CALLBACK_EXACT_RULES: dict[str, str] = dict.fromkeys(_IGNORED_CALLBACK_EXACT, "ignore")
_IGNORED_CALLBACK_PREFIX_RULES: tuple[tuple[str, str], ...] = tuple(
(prefix, "ignore") for prefix in _IGNORED_CALLBACK_PREFIX
)
_HANDLER_CONTAINS: list[tuple[str, str] | tuple[str, str, str]] = [
("process_start", "start"),
("start_entry", "start"),
("show_start_menu", "start"),
("process_callback_view_profile", "profile"),
("handle_about_vpn", "about"),
("send_instructions", "instructions"),
("process_callback_or_message_view_keys", "view_keys"),
("key_view", "key_view", "key_create"),
("handle_user_config_confirm", "key_create"),
("finalize_config_and_purchase", "key_create"),
("proceed_purchase_with_values", "tariff_config"),
("key_create", "buy_entry"),
("handle_key_creation", "buy_entry"),
("complete_key_renewal", "renew"),
("handle_connect_device", "connect"),
("process_connect_", "connect"),
("process_callback_connect", "connect"),
("process_continue_tv", "connect"),
("show_qr_code", "connect"),
("balance_history", "balance"),
("balance_handler", "balance"),
("pay", "pay_start"),
("choose_payment_provider", "pay_start"),
("kassai_", "pay_start"),
("heleket_", "pay_start"),
("freekassa_", "pay_start"),
("robokassa_", "pay_start"),
("rename_key", "key_manage"),
("reset_hwid", "key_manage"),
("delete_key", "key_manage"),
("change_location", "key_manage"),
("select_country", "key_manage"),
("renew", "renew"),
("addon", "addons"),
("referral", "referral"),
("refferal", "referral"),
("coupon", "coupons"),
("admin_panel", "admin"),
("users_audit", "admin"),
("users_editor", "admin"),
("search_user", "admin"),
("auth/register", "register"),
("auth/login", "login"),
("auth/send-login", "login"),
("auth/login-by-code", "login"),
("auth/login-telegram", "login"),
("auth/set-password", "login"),
("auth/change-password", "login"),
("auth/request-password-reset", "login"),
("auth/confirm-password-reset", "login"),
("auth/summary", "login"),
("site-config", "api_other"),
("tariffs/purchase", "pay_start"),
("tariffs/config-price", "tariff_config"),
("gifts/redeem", "key_create"),
("referrals/apply", "referral"),
]
_API_CONTAINS: list[tuple[str, str]] = [
("auth/register", "register"),
("auth/login", "login"),
("auth/send-login", "login"),
("auth/login-by-code", "login"),
("auth/login-telegram", "login"),
("auth/set-password", "login"),
("auth/change-password", "login"),
("auth/request-password-reset", "login"),
("auth/confirm-password-reset", "login"),
("auth/summary", "login"),
("site-config", "api_other"),
("tariffs/purchase", "pay_start"),
("tariffs/config-price", "tariff_config"),
("gifts/redeem", "key_create"),
("referrals/apply", "referral"),
("/keys/create", "key_create"),
]
def _match_step_rules(
value: str,
*,
exact: dict[str, str] | None = None,
prefixes: Iterable[tuple[str, str]] | None = None,
contains: Iterable[tuple[str, str] | tuple[str, str, str]] | None = None,
) -> str | None:
"""Универсальный matcher шага по exact/prefix/contains правилам."""
normalized = (value or "").lower().strip()
if not normalized:
return None
if exact:
step = exact.get(normalized)
if step:
return step
if prefixes:
for prefix, step in prefixes:
if normalized.startswith(prefix):
return step
if contains:
for item in contains:
substr, step = item[0], item[1]
exclude = item[2] if len(item) > 2 else None
if substr in normalized and (exclude is None or exclude not in normalized):
return step
return None
def _is_ignored_analytics_event(path: str) -> bool:
"""Исключает чисто навигационные события из аналитики шагов и воронки."""
p = (path or "").lower().strip()
if not p.startswith("callback:"):
return False
callback_data = p.split(":", 1)[-1]
return (
_match_step_rules(
callback_data,
exact=_IGNORED_CALLBACK_EXACT_RULES,
prefixes=_IGNORED_CALLBACK_PREFIX_RULES,
)
is not None
)
def _message_command_step(path: str) -> str | None:
"""Определяет шаг только по точной команде/первому токену сообщения."""
steps = _message_command_steps(path)
return steps[0] if steps else None
def _message_command_steps(path: str) -> list[str]:
"""Определяет один или несколько шагов из текстового сообщения."""
p = (path or "").strip()
if not p.lower().startswith("message:"):
return []
text = (p.split(":", 1)[-1] or "").strip().lower()
if not text:
return []
parts = text.split(None, 1)
token = parts[0]
if token.startswith("/"):
token = token.split("@", 1)[0]
if token in ("/start", "start"):
steps = ["start"]
payload = parts[1].strip() if len(parts) > 1 else ""
if payload:
if len(payload) > _START_PAYLOAD_MAX_LEN:
payload = payload[:_START_PAYLOAD_MAX_LEN]
payload_parts = payload.split("-")
if len(payload_parts) > _START_PAYLOAD_MAX_PARTS:
payload_parts = payload_parts[:_START_PAYLOAD_MAX_PARTS]
for part in payload_parts:
part = part.strip()
if not part:
continue
if "coupons" in part:
steps.append("start_coupon")
continue
if "gift" in part:
steps.append("start_gift")
continue
if "referral" in part:
steps.append("start_referral")
continue
if "utm" in part:
steps.append("start_utm")
continue
normalized_payload = "-".join(payload_parts).strip().lower()
if normalized_payload in _MESSAGE_STEP_BY_COMMAND:
steps.append(_MESSAGE_STEP_BY_COMMAND[normalized_payload])
return list(dict.fromkeys(steps))
return steps
step = _MESSAGE_STEP_BY_COMMAND.get(token)
return [step] if step else []
def _callback_step(path: str) -> str | None:
callback_data = path.split(":", 1)[-1]
callback_key = callback_data.split("|")[0]
return _match_step_rules(
callback_key,
exact=_CALLBACK_EXACT,
) or _match_step_rules(
callback_data,
prefixes=_CALLBACK_PREFIX,
contains=_CALLBACK_CONTAINS,
)
def _api_step(path: str) -> str:
step = _match_step_rules(path, contains=_API_CONTAINS)
if step:
return step
if "payment-links" in path or ("payment" in path and "webhook" not in path):
return "pay_start"
return "api_other"
def _handler_step(path: str) -> str | None:
return _match_step_rules(path, contains=_HANDLER_CONTAINS)
def _funnel_step_counts(path: str, result: str, step: str) -> bool:
"""Решает, считать ли событие достижением шага воронки."""
if result != "success":
return False
p = (path or "").lower()
if step == "pay_start":
return True
if step == "pay":
return p.startswith("payment_success:")
if step == "key_create":
return "cfg_user_confirm" in p or "/keys/create" in p
return True
def _normalize_path_to_step(path: str) -> str:
"""Сводит path_or_handler к шагу по правилам из маппингов."""
steps = _normalize_path_to_steps(path)
return steps[0] if steps else "other"
def _normalize_path_to_steps(path: str) -> list[str]:
"""Сводит path_or_handler к одному или нескольким шагам по правилам аудита."""
if not path:
return ["other"]
p = path.lower().strip()
if p.startswith("payment_success:"):
return ["pay"]
if p.startswith("callback:"):
return [_callback_step(p) or "other"]
message_steps = _message_command_steps(path)
if message_steps:
return message_steps
if p.startswith("message:"):
return ["other"]
if p.startswith(("post ", "get ")):
return [_api_step(p)]
return [_handler_step(p) or "other"]
+164
View File
@@ -0,0 +1,164 @@
import os
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Tuple, Union
import aiofiles
from aiogram.types import BufferedInputFile
from bot import bot
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT
from logger import logger
async def backup_database() -> Exception | None:
"""
Создает резервную копию базы данных и отправляет ее администраторам.
Returns:
Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
"""
backup_file_path, exception = _create_database_backup()
if exception:
logger.error(f"Ошибка при создании бэкапа базы данных: {exception}")
return exception
try:
await _send_backup_to_admins(backup_file_path)
exception = _cleanup_old_backups()
if exception:
logger.error(f"Ошибка при удалении старых бэкапов базы данных: {exception}")
return exception
return None
except Exception as e:
logger.error(f"Ошибка при отправке бэкапа базы данных: {e}")
return e
def _create_database_backup() -> tuple[str | None, Exception | None]:
"""
Создает резервную копию базы данных PostgreSQL.
Returns:
Tuple[Optional[str], Optional[Exception]]: Путь к файлу бэкапа и исключение (если произошла ошибка)
"""
date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S")
# Создаем директорию для бэкапов, если она не существует
backup_dir = Path(BACK_DIR)
backup_dir.mkdir(parents=True, exist_ok=True)
filename = backup_dir / f"{DB_NAME}-backup-{date_formatted}.sql"
try:
# Устанавливаем пароль PostgreSQL через переменную окружения
os.environ["PGPASSWORD"] = DB_PASSWORD
# Запускаем pg_dump для создания бэкапа
subprocess.run(
[
"pg_dump",
"-U",
DB_USER,
"-h",
PG_HOST,
"-p",
PG_PORT,
"-F",
"c",
"-f",
str(filename),
DB_NAME,
],
check=True,
capture_output=True,
text=True,
)
logger.info(f"Бэкап базы данных создан: {filename}")
return str(filename), None
except subprocess.CalledProcessError as e:
logger.error(f"Ошибка при выполнении pg_dump: {e.stderr}")
return None, e
except Exception as e:
logger.error(f"Непредвиденная ошибка при создании бэкапа: {e}")
return None, e
finally:
# Удаляем переменную окружения с паролем
if "PGPASSWORD" in os.environ:
del os.environ["PGPASSWORD"]
def _cleanup_old_backups() -> Exception | None:
"""
Удаляет бэкапы старше 3 дней.
Returns:
Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
"""
try:
backup_dir = Path(BACK_DIR)
if not backup_dir.exists():
return None
# Вычисляем дату, старше которой нужно удалить файлы
cutoff_date = datetime.now() - timedelta(days=3)
# Находим и удаляем старые файлы бэкапов
for backup_file in backup_dir.glob("*.sql"):
if backup_file.is_file():
file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
if file_mtime < cutoff_date:
backup_file.unlink()
logger.info(f"Удален старый бэкап: {backup_file}")
logger.info("Очистка старых бэкапов завершена")
return None
except Exception as e:
logger.error(f"Ошибка при удалении старых бэкапов: {e}")
return e
async def create_backup_and_send_to_admins(client) -> None:
"""
Создает бэкап и отправляет администраторам через переданный клиент.
Args:
client: Клиент для работы с базой данных
"""
await client.login()
await client.database.export()
async def _send_backup_to_admins(backup_file_path: str) -> None:
"""
Отправляет файл бэкапа всем администраторам через Telegram.
Args:
backup_file_path: Путь к файлу бэкапа
Raises:
Exception: При ошибке отправки файла
"""
if not backup_file_path or not os.path.exists(backup_file_path):
raise FileNotFoundError(f"Файл бэкапа не найден: {backup_file_path}")
try:
async with aiofiles.open(backup_file_path, "rb") as backup_file:
backup_data = await backup_file.read()
filename = os.path.basename(backup_file_path)
backup_input_file = BufferedInputFile(file=backup_data, filename=filename)
# Отправляем файл каждому администратору
for admin_id in ADMIN_ID:
try:
await bot.send_document(chat_id=admin_id, document=backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
except Exception as e:
logger.error(f"Не удалось отправить бэкап админу {admin_id}: {e}")
except Exception as e:
logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
raise
+68 -46
View File
@@ -1,69 +1,91 @@
from importlib import import_module
version = "0.5.3"
import traceback
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import ExceptionTypeFilter
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import BufferedInputFile, ErrorEvent
from aiogram.utils.markdown import hbold
from config import API_TOKEN, REDIS_URL
from database import async_session_maker
from config import ADMIN_ID, API_TOKEN
from filters.private import IsPrivateFilter
from utils.button_icons import apply_button_icons_patch, set_button_icon_config
from utils.custom_emojis import initialize_custom_emojis
from utils.errors import setup_error_handlers
from utils.modules_loader import load_modules_from_folder, modules_hub
apply_button_icons_patch()
from logger import logger
from middlewares import register_middleware
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
RedisStorage = import_module("aiogram.fsm.storage.redis").RedisStorage
redis_from_url = import_module("redis.asyncio").from_url
redis = redis_from_url(
REDIS_URL,
encoding="utf-8",
decode_responses=True,
max_connections=64,
health_check_interval=30,
socket_connect_timeout=5,
socket_timeout=5,
retry_on_timeout=True,
)
storage = RedisStorage(redis=redis)
storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
dp.include_router(modules_hub)
version = "4.0"
load_modules_from_folder()
from handlers.buttons import BUTTON_ICON_CONFIG
set_button_icon_config(BUTTON_ICON_CONFIG)
register_middleware(dp)
dp.message.filter(IsPrivateFilter())
dp.callback_query.filter(IsPrivateFilter())
async def _on_dispatcher_startup(*_args, **_kwargs):
from core.tasks import ensure_periodic_task_manager_started
@dp.errors(ExceptionTypeFilter(Exception))
async def errors_handler(
event: ErrorEvent,
bot: Bot,
) -> bool:
if isinstance(event.exception, TelegramForbiddenError):
logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
return True
await ensure_periodic_task_manager_started(bot, async_session_maker)
if isinstance(event.exception, TelegramBadRequest):
error_message = str(event.exception)
if (
"query is too old and response timeout expired or query ID is invalid" in error_message
or "message can't be deleted for everyone" in error_message
or "message to delete not found" in error_message
):
logger.warning("Отправляем стартовое меню.")
async def _on_dispatcher_shutdown(*_args, **_kwargs):
from core.tasks import ensure_periodic_task_manager_stopped
try:
from handlers.start import handle_start_callback_query, start_command
await ensure_periodic_task_manager_stopped()
if event.update.message:
await start_command(
event.update.message, state=dp.storage, session=None, admin=False, captcha=False
)
elif event.update.callback_query:
await handle_start_callback_query(
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
return True
logger.exception(f"Update: {event.update}\nException: {event.exception}")
if not ADMIN_ID:
return True
try:
for admin_id in ADMIN_ID:
await bot.send_document(
chat_id=admin_id,
document=BufferedInputFile(
traceback.format_exc().encode(),
filename=f"error_{event.update.update_id}.txt",
),
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
)
try:
from handlers.start import handle_start_callback_query, start_command
dp.startup.register(_on_dispatcher_startup)
dp.shutdown.register(_on_dispatcher_shutdown)
setup_error_handlers(dp)
initialize_custom_emojis()
if event.update.message:
await start_command(event.update.message, state=dp.storage, session=None, admin=False, captcha=False)
elif event.update.callback_query:
await handle_start_callback_query(
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
except TelegramBadRequest as exception:
logger.warning(f"Failed to send error details: {exception}")
except Exception as exception:
logger.error(f"Unexpected error in error handler: {exception}")
return True
-2820
View File
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
from dataclasses import dataclass
from typing import Any
import py3xui
from config import LIMIT_IP, SUPERNODE
from logger import logger
@dataclass
class ClientConfig:
"""Конфигурация клиента для добавления/обновления."""
client_id: str
email: str
tg_id: str
limit_ip: int
total_gb: int
expiry_time: int
enable: bool
flow: str
inbound_id: int
sub_id: str
async def add_client(xui: py3xui.AsyncApi, config: ClientConfig) -> dict[str, Any]:
"""
Добавляет клиента на сервер через 3x-ui.
Args:
xui: Экземпляр API клиента
config: Конфигурация клиента
Returns:
Dict[str, Any]: Результат операции в формате
{'status': 'success'|'failed'|'duplicate', 'error': str, 'email': str}
"""
try:
await xui.login()
client = py3xui.Client(
id=config.client_id,
email=config.email.lower(),
limit_ip=config.limit_ip,
total_gb=config.total_gb,
expiry_time=config.expiry_time,
enable=config.enable,
tg_id=config.tg_id,
sub_id=config.sub_id,
flow=config.flow,
)
response = await xui.client.add(config.inbound_id, [client])
logger.info(f"Клиент {config.email} успешно добавлен с ID {config.client_id}")
return response if response else {"status": "failed"}
except Exception as e:
error_message = str(e)
if "Duplicate email" in error_message:
logger.warning(f"Дублированный email: {config.email}. Пропуск. Сообщение: {error_message}")
return {"status": "duplicate", "email": config.email}
logger.error(f"Ошибка при добавлении клиента {config.email}: {error_message}")
return {"status": "failed", "error": error_message}
async def extend_client_key(
xui: py3xui.AsyncApi, inbound_id: int, email: str, new_expiry_time: int, client_id: str, total_gb: int, sub_id: str
) -> bool | None:
"""
Обновляет срок действия ключа клиента.
Args:
xui: Экземпляр API клиента
inbound_id: ID входящего соединения
email: Email клиента
new_expiry_time: Новое время истечения
client_id: ID клиента
total_gb: Общий объем трафика
sub_id: ID подписки
Returns:
Optional[bool]: True если успешно, False если ошибка, None если клиент не найден
"""
try:
await xui.login()
client = await xui.client.get_by_email(email)
if not client:
logger.warning(f"Клиент с email {email} не найден")
return None
if not client.id:
logger.warning(f"Ошибка: клиент {email} не имеет действительного ID")
return None
logger.info(f"Обновление ключа клиента {email} с ID {client.id} до {new_expiry_time}")
client.id = client_id
client.expiry_time = new_expiry_time
client.flow = "xtls-rprx-vision"
client.sub_id = sub_id
client.total_gb = total_gb
client.enable = True
client.limit_ip = LIMIT_IP
client.inbound_id = inbound_id
await xui.client.update(client.id, client)
await xui.client.reset_stats(inbound_id, email)
logger.info(f"Ключ клиента {email} успешно продлён до {new_expiry_time}")
return True
except Exception as e:
logger.error(f"Ошибка при обновлении клиента с email {email}: {e}")
return False
async def delete_client(
xui: py3xui.AsyncApi,
inbound_id: int,
email: str,
client_id: str,
) -> bool:
"""
Удаляет клиента с сервера 3x-ui.
Args:
xui: Экземпляр API клиента
inbound_id: ID входящего соединения
email: Email клиента
client_id: ID клиента
Returns:
bool: True если удаление успешно, False в противном случае
"""
try:
await xui.login()
if SUPERNODE:
await xui.client.delete(inbound_id, client_id)
logger.info(f"Клиент с ID {client_id} был удален успешно (SUPERNODE)")
return True
client = await xui.client.get_by_email(email)
if not client:
logger.warning(f"Клиент с email {email} и ID {client_id} не найден")
return False
client.id = client_id
await xui.client.delete(inbound_id, client.id)
logger.info(f"Клиент с ID {client_id} был удален успешно")
return True
except Exception as e:
logger.error(f"Ошибка при удалении клиента с ID {client_id}: {e}")
return False
async def get_client_traffic(xui: py3xui.AsyncApi, client_id: str) -> dict[str, Any]:
"""
Получает информацию о трафике пользователя по client_id.
Args:
xui: Экземпляр API клиента
client_id: UUID клиента
Returns:
dict[str, Any]: Информация о трафике пользователя или ошибка
"""
try:
await xui.login()
traffic_data = await xui.client.get_traffic_by_id(client_id)
if not traffic_data:
logger.warning(f"Трафик для клиента {client_id} не найден.")
return {"status": "not_found", "client_id": client_id}
logger.info(f"Трафик для клиента {client_id} успешно получен.")
return {"status": "success", "client_id": client_id, "traffic": traffic_data}
except Exception as e:
logger.error(f"Ошибка при получении трафика клиента {client_id}: {e}")
return {"status": "error", "error": str(e)}
async def toggle_client(xui: py3xui.AsyncApi, inbound_id: int, email: str, client_id: str, enable: bool = True) -> bool:
"""
Функция для включения/отключения клиента на сервере 3x-ui.
Args:
xui: Экземпляр API клиента
inbound_id: ID инбаунда
email: Email клиента
client_id: UUID клиента
enable: True для включения, False для отключения
Returns:
bool: True при успешном выполнении, False при ошибке
"""
try:
await xui.login()
# Получаем клиента по email
client = await xui.client.get_by_email(email)
if not client:
logger.warning(f"Клиент с email {email} и ID {client_id} не найден.")
return False
# Обновляем параметры клиента
client.enable = enable
client.id = client_id
client.flow = "xtls-rprx-vision"
client.limit_ip = LIMIT_IP
client.inbound_id = inbound_id
# Обновляем клиента
await xui.client.update(client.id, client)
status = "включен" if enable else "отключен"
logger.info(f"Клиент с email {email} и ID {client_id} успешно {status}.")
return True
except Exception as e:
status = "включении" if enable else "отключении"
logger.error(f"Ошибка при {status} клиента с email {email} и ID {client_id}: {e}")
return False
-1
View File
@@ -1 +0,0 @@
Binary file not shown.

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