pyferro 0.5.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyferro/__init__.py +67 -0
- pyferro/__main__.py +5 -0
- pyferro/app.py +549 -0
- pyferro/cli.py +164 -0
- pyferro/compat.py +112 -0
- pyferro/core/__init__.py +22 -0
- pyferro/core/backplane.py +76 -0
- pyferro/core/constants.py +20 -0
- pyferro/core/http.py +118 -0
- pyferro/core/lib/config.py +195 -0
- pyferro/core/lifecycle.py +79 -0
- pyferro/core/logger.py +177 -0
- pyferro/core/router.py +18 -0
- pyferro/core/security.py +155 -0
- pyferro/core/session.py +532 -0
- pyferro/core/tabular.py +121 -0
- pyferro/plugins/__init__.py +26 -0
- pyferro/plugins/auth/__init__.py +86 -0
- pyferro/plugins/auth/config.py +46 -0
- pyferro/plugins/auth/core.py +131 -0
- pyferro/plugins/auth/discovery.py +152 -0
- pyferro/plugins/auth/handlers.py +895 -0
- pyferro/plugins/auth/models.py +510 -0
- pyferro/plugins/auth/oauth.py +25 -0
- pyferro/plugins/auth/permission_handlers.py +422 -0
- pyferro/plugins/auth/permissions.py +65 -0
- pyferro/plugins/auth/security.py +193 -0
- pyferro/plugins/broadcast/__init__.py +182 -0
- pyferro/plugins/crud/__init__.py +88 -0
- pyferro/plugins/crud/admin_handlers.py +196 -0
- pyferro/plugins/crud/auth.py +95 -0
- pyferro/plugins/crud/coerce.py +126 -0
- pyferro/plugins/crud/events.py +65 -0
- pyferro/plugins/crud/handlers.py +825 -0
- pyferro/plugins/crud/identity.py +241 -0
- pyferro/plugins/crud/meta.py +279 -0
- pyferro/plugins/crud/policy.py +140 -0
- pyferro/plugins/crud/registry.py +89 -0
- pyferro/plugins/crud/static/assets/index-BIwvZwye.js +2 -0
- pyferro/plugins/crud/static/assets/index-BpxHDOGr.css +1 -0
- pyferro/plugins/crud/static/index.html +13 -0
- pyferro/plugins/crud/static_handler.py +135 -0
- pyferro/plugins/db/__init__.py +22 -0
- pyferro/plugins/db/session.py +161 -0
- pyferro/plugins/files/__init__.py +36 -0
- pyferro/plugins/files/handlers.py +162 -0
- pyferro/plugins/files/models.py +62 -0
- pyferro/plugins/files/service.py +195 -0
- pyferro/plugins/files/upload.py +515 -0
- pyferro/plugins/raw_ws/__init__.py +28 -0
- pyferro/plugins/raw_ws/id_gen.py +26 -0
- pyferro/plugins/raw_ws/manager.py +126 -0
- pyferro/plugins/raw_ws/router.py +54 -0
- pyferro/plugins/raw_ws/session.py +136 -0
- pyferro/plugins/seo/__init__.py +72 -0
- pyferro/plugins/seo/config.py +178 -0
- pyferro/plugins/seo/detector.py +103 -0
- pyferro/plugins/seo/indexnow.py +294 -0
- pyferro/plugins/seo/renderer.py +259 -0
- pyferro/plugins/seo/router.py +189 -0
- pyferro/plugins/seo/schemas.py +34 -0
- pyferro/plugins/seo/sitemap.py +303 -0
- pyferro/plugins/smart_cache/__init__.py +24 -0
- pyferro/plugins/smart_cache/engine.py +353 -0
- pyferro/plugins/smart_cache/handlers.py +54 -0
- pyferro-0.5.6.dist-info/METADATA +651 -0
- pyferro-0.5.6.dist-info/RECORD +70 -0
- pyferro-0.5.6.dist-info/WHEEL +5 -0
- pyferro-0.5.6.dist-info/licenses/LICENSE +21 -0
- pyferro-0.5.6.dist-info/top_level.txt +1 -0
pyferro/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
pyferro: High-performance reactive fullstack Python framework on Rust RSGI (Granian)
|
|
4
|
+
with symmetric WSRPC (JSON-RPC 2.0) and Tabular Data Compression (RFC 0002).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Автоматическая активация моста обратной совместимости для плоских импортов core и plugins
|
|
8
|
+
from pyferro.compat import install_compat
|
|
9
|
+
install_compat()
|
|
10
|
+
|
|
11
|
+
from pyferro.core.session import (
|
|
12
|
+
rpc_method, RPCError, JsonRpcSession,
|
|
13
|
+
current_user_ctx, current_session_ctx, current_rpc_id_ctx, current_transport_ctx,
|
|
14
|
+
ACTIVE_SESSIONS_SET
|
|
15
|
+
)
|
|
16
|
+
from pyferro.core.tabular import pack_tabular, unpack_tabular, tabular_response, is_tabular
|
|
17
|
+
from pyferro.core.constants import ADMIN_ROLE, DEFAULT_USER_ROLE, SYSTEM_ROLES
|
|
18
|
+
from pyferro.core.router import http_route, HTTP_ROUTES
|
|
19
|
+
from pyferro.core.http import extract_header, extract_query_params
|
|
20
|
+
from pyferro.core.lifecycle import (
|
|
21
|
+
on_startup, on_shutdown, run_startup_callbacks, run_shutdown_callbacks
|
|
22
|
+
)
|
|
23
|
+
from pyferro.core.logger import logger, setup_logging
|
|
24
|
+
from pyferro.core.lib.config import configure, get_config
|
|
25
|
+
from pyferro.plugins.auth.oauth import VkOAuth, YandexOAuth
|
|
26
|
+
from pyferro.app import FerroApp
|
|
27
|
+
|
|
28
|
+
from pyferro import core
|
|
29
|
+
from pyferro import plugins
|
|
30
|
+
|
|
31
|
+
__version__ = "0.5.6"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"FerroApp",
|
|
35
|
+
"VkOAuth",
|
|
36
|
+
"YandexOAuth",
|
|
37
|
+
"rpc_method",
|
|
38
|
+
"RPCError",
|
|
39
|
+
"JsonRpcSession",
|
|
40
|
+
"current_user_ctx",
|
|
41
|
+
"current_session_ctx",
|
|
42
|
+
"current_rpc_id_ctx",
|
|
43
|
+
"current_transport_ctx",
|
|
44
|
+
"ACTIVE_SESSIONS_SET",
|
|
45
|
+
"pack_tabular",
|
|
46
|
+
"unpack_tabular",
|
|
47
|
+
"tabular_response",
|
|
48
|
+
"is_tabular",
|
|
49
|
+
"ADMIN_ROLE",
|
|
50
|
+
"DEFAULT_USER_ROLE",
|
|
51
|
+
"SYSTEM_ROLES",
|
|
52
|
+
"http_route",
|
|
53
|
+
"HTTP_ROUTES",
|
|
54
|
+
"extract_header",
|
|
55
|
+
"extract_query_params",
|
|
56
|
+
"on_startup",
|
|
57
|
+
"on_shutdown",
|
|
58
|
+
"run_startup_callbacks",
|
|
59
|
+
"run_shutdown_callbacks",
|
|
60
|
+
"logger",
|
|
61
|
+
"setup_logging",
|
|
62
|
+
"configure",
|
|
63
|
+
"get_config",
|
|
64
|
+
"core",
|
|
65
|
+
"plugins",
|
|
66
|
+
"install_compat",
|
|
67
|
+
]
|
pyferro/__main__.py
ADDED
pyferro/app.py
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Главный класс приложения FerroApp.
|
|
4
|
+
Предоставляет готовую точку входа для Granian RSGI, инкапсулирует конфигурацию,
|
|
5
|
+
маршрутизацию HTTP/WebSocket, раздачу статики, CORS и управление жизненным циклом.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import asyncio
|
|
11
|
+
import inspect
|
|
12
|
+
import mimetypes
|
|
13
|
+
from typing import Optional, List, Dict, Any, Union, Callable
|
|
14
|
+
|
|
15
|
+
from .core.lib.config import configure, settings, Settings
|
|
16
|
+
from .core.session import JsonRpcSession, rpc_method, RPC_REGISTRY, ACTIVE_SESSIONS_SET
|
|
17
|
+
from .core.router import http_route, HTTP_ROUTES
|
|
18
|
+
from .core.lifecycle import on_startup, on_shutdown, run_startup_callbacks, run_shutdown_callbacks
|
|
19
|
+
from .core.logger import logger, setup_logging
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _SessionCounter:
|
|
23
|
+
"""Сериализуемый счетчик сессий (совместим с multiprocessing spawn / pickle в Python 3.14)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, start: int = 0):
|
|
26
|
+
self._val = start
|
|
27
|
+
|
|
28
|
+
def __iter__(self):
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def __next__(self) -> int:
|
|
32
|
+
val = self._val
|
|
33
|
+
self._val += 1
|
|
34
|
+
return val
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FerroApp:
|
|
38
|
+
"""
|
|
39
|
+
Полнофункциональное RSGI-приложение PyFerro.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
# Безопасность и ядро
|
|
46
|
+
secret_key: Optional[str] = None,
|
|
47
|
+
database_url: Optional[str] = None,
|
|
48
|
+
db_echo: bool = False,
|
|
49
|
+
files_path: Optional[str] = None,
|
|
50
|
+
max_upload_size: Optional[int] = None,
|
|
51
|
+
login_rpc: Optional[str] = None,
|
|
52
|
+
auth_timeout: Optional[int] = None,
|
|
53
|
+
guest_idle_timeout: Optional[int] = None,
|
|
54
|
+
user_idle_timeout: Optional[int] = None,
|
|
55
|
+
session_idle_timeout: Optional[int] = None,
|
|
56
|
+
allow_guests: Optional[bool] = None,
|
|
57
|
+
password_iterations: Optional[int] = None,
|
|
58
|
+
token_expire_hours: Optional[int] = None,
|
|
59
|
+
max_message_size: Optional[int] = None,
|
|
60
|
+
# HTTP и CORS
|
|
61
|
+
cors: bool = True,
|
|
62
|
+
cors_origins: Union[str, List[str]] = "*",
|
|
63
|
+
cors_headers: str = "content-type, authorization",
|
|
64
|
+
cors_methods: str = "GET, POST, PUT, DELETE, OPTIONS",
|
|
65
|
+
# Статика и UI
|
|
66
|
+
static_dir: Optional[str] = None,
|
|
67
|
+
static_prefix: str = "/static",
|
|
68
|
+
index_file: Optional[str] = None,
|
|
69
|
+
# Внешняя авторизация и плагины
|
|
70
|
+
oauth: Optional[Union[List[Any], Dict[str, Any]]] = None,
|
|
71
|
+
backplane_url: Optional[str] = None,
|
|
72
|
+
enable_seo: bool = False,
|
|
73
|
+
sitemap_host: Optional[str] = None,
|
|
74
|
+
auto_auth_ws: bool = True,
|
|
75
|
+
auto_auth_models: bool = True,
|
|
76
|
+
custom_settings: Optional[Dict[str, Any]] = None,
|
|
77
|
+
**extra_settings: Any
|
|
78
|
+
):
|
|
79
|
+
# 1. Применяем конфигурацию к ядру
|
|
80
|
+
config_kwargs: Dict[str, Any] = {}
|
|
81
|
+
if secret_key is not None:
|
|
82
|
+
config_kwargs["secret_key"] = secret_key
|
|
83
|
+
if database_url is not None:
|
|
84
|
+
config_kwargs["database_url"] = database_url
|
|
85
|
+
if db_echo:
|
|
86
|
+
config_kwargs["db_echo"] = db_echo
|
|
87
|
+
if files_path is not None:
|
|
88
|
+
config_kwargs["files_path"] = files_path
|
|
89
|
+
if max_upload_size is not None:
|
|
90
|
+
config_kwargs["max_upload_size"] = max_upload_size
|
|
91
|
+
if backplane_url is not None:
|
|
92
|
+
config_kwargs["backplane_url"] = backplane_url
|
|
93
|
+
if sitemap_host is not None:
|
|
94
|
+
config_kwargs["sitemap_host"] = sitemap_host
|
|
95
|
+
if login_rpc is not None:
|
|
96
|
+
config_kwargs["login_rpc"] = login_rpc
|
|
97
|
+
if auth_timeout is not None:
|
|
98
|
+
config_kwargs["auth_timeout"] = auth_timeout
|
|
99
|
+
if guest_idle_timeout is not None:
|
|
100
|
+
config_kwargs["guest_idle_timeout"] = guest_idle_timeout
|
|
101
|
+
if user_idle_timeout is not None:
|
|
102
|
+
config_kwargs["user_idle_timeout"] = user_idle_timeout
|
|
103
|
+
if session_idle_timeout is not None:
|
|
104
|
+
config_kwargs["session_idle_timeout"] = session_idle_timeout
|
|
105
|
+
if allow_guests is not None:
|
|
106
|
+
config_kwargs["allow_guests"] = allow_guests
|
|
107
|
+
if password_iterations is not None:
|
|
108
|
+
config_kwargs["password_iterations"] = password_iterations
|
|
109
|
+
if token_expire_hours is not None:
|
|
110
|
+
config_kwargs["token_expire_hours"] = token_expire_hours
|
|
111
|
+
if max_message_size is not None:
|
|
112
|
+
config_kwargs.setdefault("custom", {})["max_message_size"] = max_message_size
|
|
113
|
+
|
|
114
|
+
if oauth:
|
|
115
|
+
oauth_dict: Dict[str, Any] = {}
|
|
116
|
+
if isinstance(oauth, list):
|
|
117
|
+
for prov in oauth:
|
|
118
|
+
p_name = getattr(prov, "provider_name", None) or prov.__class__.__name__.lower().replace("oauth", "")
|
|
119
|
+
oauth_dict[p_name] = {
|
|
120
|
+
k: v for k, v in prov.__dict__.items() if not k.startswith("_")
|
|
121
|
+
}
|
|
122
|
+
elif isinstance(oauth, dict):
|
|
123
|
+
oauth_dict = oauth
|
|
124
|
+
config_kwargs["oauth"] = oauth_dict
|
|
125
|
+
|
|
126
|
+
if custom_settings:
|
|
127
|
+
config_kwargs.setdefault("custom", {}).update(custom_settings)
|
|
128
|
+
if extra_settings:
|
|
129
|
+
config_kwargs.setdefault("custom", {}).update(extra_settings)
|
|
130
|
+
|
|
131
|
+
self.settings: Settings = configure(**config_kwargs)
|
|
132
|
+
|
|
133
|
+
# 2. Настройки HTTP / CORS
|
|
134
|
+
self.cors_enabled = cors
|
|
135
|
+
self.cors_origins = cors_origins if isinstance(cors_origins, str) else ", ".join(cors_origins)
|
|
136
|
+
self.cors_headers = cors_headers
|
|
137
|
+
self.cors_methods = cors_methods
|
|
138
|
+
|
|
139
|
+
# 3. Настройки статических файлов
|
|
140
|
+
self.static_dir = os.path.abspath(static_dir) if static_dir else None
|
|
141
|
+
self.static_prefix = "/" + static_prefix.strip("/") if static_prefix else "/static"
|
|
142
|
+
self.index_file = None
|
|
143
|
+
if index_file:
|
|
144
|
+
if os.path.isabs(index_file):
|
|
145
|
+
self.index_file = index_file
|
|
146
|
+
elif self.static_dir:
|
|
147
|
+
self.index_file = os.path.join(self.static_dir, index_file)
|
|
148
|
+
else:
|
|
149
|
+
self.index_file = os.path.abspath(index_file)
|
|
150
|
+
|
|
151
|
+
self.enable_seo = enable_seo
|
|
152
|
+
self._session_counter = _SessionCounter()
|
|
153
|
+
self._on_connect_callbacks: List[Callable] = []
|
|
154
|
+
|
|
155
|
+
self.auto_auth_ws = auto_auth_ws
|
|
156
|
+
|
|
157
|
+
# Автоматическая регистрация стандартных моделей в CRUD
|
|
158
|
+
if auto_auth_models:
|
|
159
|
+
try:
|
|
160
|
+
from pyferro.plugins.crud.registry import ModelRegistry
|
|
161
|
+
ModelRegistry.register_auth_models()
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
# Граниан связывает __rsgi_init__ и __rsgi_del__ на самом объекте приложения
|
|
166
|
+
self.__rsgi_init__ = self._rsgi_init_handler
|
|
167
|
+
self.__rsgi_del__ = self._rsgi_del_handler
|
|
168
|
+
|
|
169
|
+
async def set_admin_password(self, login: str = "admin", password: Optional[str] = None) -> str:
|
|
170
|
+
"""
|
|
171
|
+
Устанавливает или генерирует новый пароль для указанного пользователя в БД.
|
|
172
|
+
Строго DML (не создает таблицы или схему БД во избежание версионных проблем).
|
|
173
|
+
"""
|
|
174
|
+
import secrets
|
|
175
|
+
import string
|
|
176
|
+
from sqlalchemy import select
|
|
177
|
+
from pyferro.plugins.db import async_session
|
|
178
|
+
from pyferro.plugins.auth import User, system_bypass_ctx
|
|
179
|
+
|
|
180
|
+
if not password:
|
|
181
|
+
chars = string.ascii_letters + string.digits + "!@#$%^&*"
|
|
182
|
+
password = "".join(secrets.choice(chars) for _ in range(16))
|
|
183
|
+
|
|
184
|
+
token = system_bypass_ctx.set(True)
|
|
185
|
+
try:
|
|
186
|
+
async with async_session() as db:
|
|
187
|
+
user = (await db.execute(select(User).where(User.login == login))).scalar_one_or_none()
|
|
188
|
+
if not user:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"Пользователь с логином '{login}' не найден в базе данных. "
|
|
191
|
+
f"Схема данных и пользователи должны быть предварительно инициализированы приложением."
|
|
192
|
+
)
|
|
193
|
+
user.password_hash = User._hash_password(password)
|
|
194
|
+
await db.commit()
|
|
195
|
+
return password
|
|
196
|
+
finally:
|
|
197
|
+
system_bypass_ctx.reset(token)
|
|
198
|
+
|
|
199
|
+
def handle_cli(self, argv: Optional[List[str]] = None) -> bool:
|
|
200
|
+
"""
|
|
201
|
+
Обрабатывает служебные CLI-флаги, такие как --set-admin-password.
|
|
202
|
+
Возвращает True, если была выполнена служебная команда (сервер запускать не нужно).
|
|
203
|
+
"""
|
|
204
|
+
import sys
|
|
205
|
+
args = argv if argv is not None else sys.argv[1:]
|
|
206
|
+
if not args:
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
for i, arg in enumerate(args):
|
|
210
|
+
if arg in ("--set-admin-password", "--set-password"):
|
|
211
|
+
custom_password = None
|
|
212
|
+
if i + 1 < len(args) and not args[i + 1].startswith("-"):
|
|
213
|
+
custom_password = args[i + 1]
|
|
214
|
+
|
|
215
|
+
login = "admin"
|
|
216
|
+
for j, a in enumerate(args):
|
|
217
|
+
if a == "--login" and j + 1 < len(args):
|
|
218
|
+
login = args[j + 1]
|
|
219
|
+
|
|
220
|
+
db_url = self.settings.get("database_url", "sqlite+aiosqlite:///app.db")
|
|
221
|
+
try:
|
|
222
|
+
new_pwd = asyncio.run(self.set_admin_password(login=login, password=custom_password))
|
|
223
|
+
print("=" * 64)
|
|
224
|
+
print(" 🛡️ PyFerro: Пароль успешно обновлен в базе данных!")
|
|
225
|
+
print(f" 🗄️ База данных: {db_url}")
|
|
226
|
+
print(f" 👤 Логин: {login}")
|
|
227
|
+
print(f" 🔑 Пароль: {new_pwd}")
|
|
228
|
+
print("=" * 64)
|
|
229
|
+
print(" 👉 Войдите через форму авторизации вашего приложения.")
|
|
230
|
+
return True
|
|
231
|
+
except Exception as e:
|
|
232
|
+
print("=" * 64, file=sys.stderr)
|
|
233
|
+
print(f" ❌ Ошибка смены пароля: {e}", file=sys.stderr)
|
|
234
|
+
print("=" * 64, file=sys.stderr)
|
|
235
|
+
sys.exit(1)
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
def _auto_authenticate_ws(self, session: JsonRpcSession) -> None:
|
|
239
|
+
"""Автоматически авторизует WebSocket по Cookie (ferro_crud_session, ferro_session, rpc_jwt)."""
|
|
240
|
+
if session.data is not None:
|
|
241
|
+
return
|
|
242
|
+
cookies = session.cookies
|
|
243
|
+
if not cookies:
|
|
244
|
+
return
|
|
245
|
+
token = (
|
|
246
|
+
cookies.get("ferro_crud_session")
|
|
247
|
+
or cookies.get("ferro_session")
|
|
248
|
+
or cookies.get("rpc_jwt")
|
|
249
|
+
or cookies.get("token")
|
|
250
|
+
)
|
|
251
|
+
if not token:
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
user_info = None
|
|
255
|
+
try:
|
|
256
|
+
from pyferro.plugins.crud.auth import get_crud_session
|
|
257
|
+
user_info = get_crud_session(token)
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
if not user_info:
|
|
262
|
+
try:
|
|
263
|
+
from pyferro.core.security import decode_access_token
|
|
264
|
+
payload = decode_access_token(token)
|
|
265
|
+
if payload:
|
|
266
|
+
roles = payload.get("roles", [])
|
|
267
|
+
role = payload.get("role") or (roles[0] if roles else "user")
|
|
268
|
+
is_admin = bool("admin" in roles or "ADMIN" in roles or "superadmin" in roles or payload.get("is_superadmin") or role in ("admin", "ADMIN", "superadmin"))
|
|
269
|
+
raw_sub = payload.get("sub", "1")
|
|
270
|
+
uid = int(raw_sub) if str(raw_sub).isdigit() else 1
|
|
271
|
+
user_info = {
|
|
272
|
+
"uid": uid,
|
|
273
|
+
"user_id": uid,
|
|
274
|
+
"username": payload.get("username", "admin" if is_admin else "user"),
|
|
275
|
+
"role": "admin" if is_admin else role,
|
|
276
|
+
"roles": roles if roles else [role],
|
|
277
|
+
"is_superadmin": is_admin,
|
|
278
|
+
"perms_dict": payload.get("permissions") or payload.get("perms_dict") or {},
|
|
279
|
+
"allowed_rpc_methods": {"*"} if is_admin else set(payload.get("allowed_rpc_methods", [])),
|
|
280
|
+
}
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
if user_info:
|
|
285
|
+
uid = user_info.get("uid") or user_info.get("user_id", 1)
|
|
286
|
+
role = user_info.get("role", "user")
|
|
287
|
+
roles = user_info.get("roles") or [role]
|
|
288
|
+
is_superadmin = bool(
|
|
289
|
+
role in ("admin", "ADMIN", "superadmin")
|
|
290
|
+
or "admin" in roles
|
|
291
|
+
or "ADMIN" in roles
|
|
292
|
+
or "superadmin" in roles
|
|
293
|
+
or user_info.get("is_superadmin")
|
|
294
|
+
)
|
|
295
|
+
user_ctx = type("UserCtx", (), {
|
|
296
|
+
"is_superadmin": is_superadmin,
|
|
297
|
+
"user_id": uid,
|
|
298
|
+
"team_ids": user_info.get("team_ids", []),
|
|
299
|
+
"perms_dict": user_info.get("perms_dict", {}),
|
|
300
|
+
"allowed_rpc_methods": {"*"} if is_superadmin else set(user_info.get("allowed_rpc_methods", [])),
|
|
301
|
+
})()
|
|
302
|
+
|
|
303
|
+
from pyferro.plugins.auth.core import AuthSession
|
|
304
|
+
session.data = AuthSession(
|
|
305
|
+
uid=uid,
|
|
306
|
+
user=None,
|
|
307
|
+
user_name=user_info.get("username", "user"),
|
|
308
|
+
user_role=role,
|
|
309
|
+
user_roles=roles,
|
|
310
|
+
session_db_id=0,
|
|
311
|
+
user_ctx=user_ctx,
|
|
312
|
+
allowed_rpc_methods={"*"} if is_superadmin else set(user_info.get("allowed_rpc_methods", [])),
|
|
313
|
+
send_request_cb=getattr(session, "send_request", None),
|
|
314
|
+
send_stream_cb=getattr(session, "send_stream_chunk", None),
|
|
315
|
+
close_cb=getattr(session, "close", None),
|
|
316
|
+
)
|
|
317
|
+
logger.info(f"[AutoAuth] WebSocket #{session.session_id} автоматически авторизован по Cookie как {user_info.get('username')} ({role})")
|
|
318
|
+
|
|
319
|
+
# --- ДЕКОРАТОРЫ ---
|
|
320
|
+
|
|
321
|
+
def rpc(self, name: Optional[str] = None, role: Optional[Any] = None, http: bool = False, public: bool = False, description: Optional[str] = None):
|
|
322
|
+
"""Декоратор для регистрации JSON-RPC метода."""
|
|
323
|
+
return rpc_method(name=name, role=role, http=http, public=public, description=description)
|
|
324
|
+
|
|
325
|
+
def route(self, path: str, methods: Optional[List[str]] = None):
|
|
326
|
+
"""Декоратор для регистрации HTTP-обработчика."""
|
|
327
|
+
return http_route(path=path, methods=methods)
|
|
328
|
+
|
|
329
|
+
def on_startup(self, func: Callable):
|
|
330
|
+
"""Регистрирует хук запуска приложения."""
|
|
331
|
+
return on_startup(func)
|
|
332
|
+
|
|
333
|
+
def on_shutdown(self, func: Callable):
|
|
334
|
+
"""Регистрирует хук завершения приложения."""
|
|
335
|
+
return on_shutdown(func)
|
|
336
|
+
|
|
337
|
+
def on_connect(self, func: Callable):
|
|
338
|
+
"""Регистрирует асинхронный хук при подключении новой WebSocket-сессии (session: JsonRpcSession)."""
|
|
339
|
+
self._on_connect_callbacks.append(func)
|
|
340
|
+
return func
|
|
341
|
+
|
|
342
|
+
# --- ЖИЗНЕННЫЙ ЦИКЛ RSGI ---
|
|
343
|
+
|
|
344
|
+
def _rsgi_init_handler(self, loop):
|
|
345
|
+
"""Инициализация при старте воркера Granian."""
|
|
346
|
+
try:
|
|
347
|
+
loop.run_until_complete(run_startup_callbacks())
|
|
348
|
+
except Exception as e:
|
|
349
|
+
logger.critical(f"[FerroApp] Сбой при startup: {e}", exc_info=True)
|
|
350
|
+
sys.exit(1)
|
|
351
|
+
|
|
352
|
+
def _rsgi_del_handler(self, loop):
|
|
353
|
+
"""Очистка при завершении воркера Granian."""
|
|
354
|
+
try:
|
|
355
|
+
# Закрываем оставшиеся WebSocket-сессии
|
|
356
|
+
async def _cleanup():
|
|
357
|
+
await run_shutdown_callbacks()
|
|
358
|
+
close_tasks = [s.close() for s in list(ACTIVE_SESSIONS_SET)]
|
|
359
|
+
if close_tasks:
|
|
360
|
+
await asyncio.gather(*close_tasks, return_exceptions=True)
|
|
361
|
+
|
|
362
|
+
loop.run_until_complete(_cleanup())
|
|
363
|
+
logger.info("[FerroApp] Корректно завершен (graceful shutdown).")
|
|
364
|
+
except Exception as e:
|
|
365
|
+
logger.error(f"[FerroApp] Ошибка при shutdown: {e}", exc_info=True)
|
|
366
|
+
|
|
367
|
+
# --- ОБРАБОТКА RSGI ВХОДЯЩИХ ЗАПРОСОВ ---
|
|
368
|
+
|
|
369
|
+
async def __call__(self, scope, proto):
|
|
370
|
+
"""
|
|
371
|
+
Главный диспетчер Granian RSGI: разделение HTTP и WebSocket.
|
|
372
|
+
"""
|
|
373
|
+
proto_type = getattr(scope, "proto", "")
|
|
374
|
+
|
|
375
|
+
if proto_type == "http":
|
|
376
|
+
await self._handle_http(scope, proto)
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
if proto_type in ("websocket", "ws"):
|
|
380
|
+
await self._handle_websocket(scope, proto)
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
# Неизвестный протокол
|
|
384
|
+
logger.warning(f"[FerroApp] Неизвестный тип протокола: {proto_type}")
|
|
385
|
+
|
|
386
|
+
async def _handle_http(self, scope, proto):
|
|
387
|
+
method = getattr(scope, "method", None) or (scope.get("method", "GET") if isinstance(scope, dict) else "GET")
|
|
388
|
+
path = getattr(scope, "path", None) or (scope.get("path", "/") if isinstance(scope, dict) else "/")
|
|
389
|
+
|
|
390
|
+
# 1. CORS Preflight (OPTIONS)
|
|
391
|
+
if self.cors_enabled and method == "OPTIONS":
|
|
392
|
+
proto.response_str(
|
|
393
|
+
status=204,
|
|
394
|
+
headers=[
|
|
395
|
+
("access-control-allow-origin", self.cors_origins),
|
|
396
|
+
("access-control-allow-methods", self.cors_methods),
|
|
397
|
+
("access-control-allow-headers", self.cors_headers),
|
|
398
|
+
("access-control-max-age", "86400"),
|
|
399
|
+
],
|
|
400
|
+
body=""
|
|
401
|
+
)
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
# 2. Опциональный перехват SEO-ботов
|
|
405
|
+
if self.enable_seo:
|
|
406
|
+
try:
|
|
407
|
+
from .plugins.seo.router import handle_bot_http
|
|
408
|
+
if await handle_bot_http(scope, proto):
|
|
409
|
+
return
|
|
410
|
+
except ImportError:
|
|
411
|
+
pass
|
|
412
|
+
except Exception as e:
|
|
413
|
+
logger.error(f"[FerroApp] Ошибка SEO перехвата: {e}", exc_info=True)
|
|
414
|
+
|
|
415
|
+
# 3. Маршрутизация по зарегистрированным HTTP_ROUTES
|
|
416
|
+
for route_path, methods, handler in HTTP_ROUTES:
|
|
417
|
+
if method in methods:
|
|
418
|
+
if path == route_path:
|
|
419
|
+
await handler(scope, proto)
|
|
420
|
+
return
|
|
421
|
+
elif route_path.endswith("/*"):
|
|
422
|
+
prefix = route_path[:-1] # e.g. "/crud/"
|
|
423
|
+
if path.startswith(prefix) or path == route_path[:-2]: # "/crud/" or "/crud"
|
|
424
|
+
await handler(scope, proto)
|
|
425
|
+
return
|
|
426
|
+
elif path.rstrip("/") == route_path.rstrip("/"):
|
|
427
|
+
await handler(scope, proto)
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
# 4. Отдача index.html на корневой GET /
|
|
431
|
+
if method == "GET" and path == "/" and self.index_file and os.path.exists(self.index_file):
|
|
432
|
+
self._serve_static_file(proto, self.index_file)
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
# 5. Раздача статики из static_dir
|
|
436
|
+
if method == "GET" and self.static_dir:
|
|
437
|
+
# 1. Попытка с префиксом static_prefix (если он задан)
|
|
438
|
+
if self.static_prefix and path.startswith(self.static_prefix):
|
|
439
|
+
rel_path = path[len(self.static_prefix):].lstrip("/")
|
|
440
|
+
if rel_path:
|
|
441
|
+
safe_full_path = os.path.abspath(os.path.join(self.static_dir, rel_path))
|
|
442
|
+
# Защита от Path Traversal
|
|
443
|
+
if (safe_full_path == self.static_dir or safe_full_path.startswith(self.static_dir + os.sep)) and os.path.isfile(safe_full_path):
|
|
444
|
+
self._serve_static_file(proto, safe_full_path)
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
# 2. Прямая раздача из static_dir (например, /assets/..., /favicon.ico)
|
|
448
|
+
rel_path = path.lstrip("/")
|
|
449
|
+
if rel_path:
|
|
450
|
+
safe_full_path = os.path.abspath(os.path.join(self.static_dir, rel_path))
|
|
451
|
+
# Защита от Path Traversal
|
|
452
|
+
if (safe_full_path == self.static_dir or safe_full_path.startswith(self.static_dir + os.sep)) and os.path.isfile(safe_full_path):
|
|
453
|
+
self._serve_static_file(proto, safe_full_path)
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
# 6. 404 Not Found
|
|
457
|
+
proto.response_str(
|
|
458
|
+
status=404,
|
|
459
|
+
headers=[("content-type", "text/plain; charset=utf-8")],
|
|
460
|
+
body="404 Not Found"
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def _serve_static_file(self, proto, file_path: str):
|
|
464
|
+
"""Отдает статический файл через нативный proto.response_file (Zero-Copy)."""
|
|
465
|
+
content_type, _ = mimetypes.guess_type(file_path)
|
|
466
|
+
content_type = content_type or "application/octet-stream"
|
|
467
|
+
if content_type.startswith("text/") or content_type in ("application/javascript", "application/json"):
|
|
468
|
+
content_type += "; charset=utf-8"
|
|
469
|
+
|
|
470
|
+
headers = [
|
|
471
|
+
("content-type", content_type),
|
|
472
|
+
("cache-control", "public, max-age=3600"),
|
|
473
|
+
]
|
|
474
|
+
if self.cors_enabled:
|
|
475
|
+
headers.append(("access-control-allow-origin", self.cors_origins))
|
|
476
|
+
|
|
477
|
+
if hasattr(proto, "response_file"):
|
|
478
|
+
proto.response_file(status=200, headers=headers, file=file_path)
|
|
479
|
+
else:
|
|
480
|
+
# Fallback для тестовых моков
|
|
481
|
+
with open(file_path, "rb") as f:
|
|
482
|
+
data = f.read()
|
|
483
|
+
if hasattr(proto, "response_bytes"):
|
|
484
|
+
proto.response_bytes(status=200, headers=headers, body=data)
|
|
485
|
+
else:
|
|
486
|
+
proto.response_str(status=200, headers=headers, body=data.decode("utf-8", errors="replace"))
|
|
487
|
+
|
|
488
|
+
async def _handle_websocket(self, scope, proto):
|
|
489
|
+
"""Принимает входящее WebSocket-соединение и запускает WSRPC JsonRpcSession."""
|
|
490
|
+
try:
|
|
491
|
+
ws = await proto.accept()
|
|
492
|
+
session_id = next(self._session_counter)
|
|
493
|
+
|
|
494
|
+
# Извлекаем IP адрес клиента из scope
|
|
495
|
+
client_ip = "0.0.0.0"
|
|
496
|
+
client = getattr(scope, "client", None)
|
|
497
|
+
if client and isinstance(client, (list, tuple)) and len(client) > 0:
|
|
498
|
+
client_ip = str(client[0])
|
|
499
|
+
|
|
500
|
+
session = JsonRpcSession(ws, session_id, ip=client_ip, scope=scope)
|
|
501
|
+
if self.auto_auth_ws:
|
|
502
|
+
self._auto_authenticate_ws(session)
|
|
503
|
+
for cb in self._on_connect_callbacks:
|
|
504
|
+
try:
|
|
505
|
+
if inspect.iscoroutinefunction(cb):
|
|
506
|
+
await cb(session)
|
|
507
|
+
else:
|
|
508
|
+
cb(session)
|
|
509
|
+
except Exception as e:
|
|
510
|
+
logger.error(f"[FerroApp on_connect] Ошибка в хуке подключения: {e}", exc_info=True)
|
|
511
|
+
await session.start()
|
|
512
|
+
except (asyncio.CancelledError, ConnectionResetError):
|
|
513
|
+
pass
|
|
514
|
+
except Exception as e:
|
|
515
|
+
if "RSGI transport is closed" in str(e) or "ProtocolClosed" in type(e).__name__:
|
|
516
|
+
logger.debug(f"[FerroApp WS] Клиент отключился (сессия #{session.session_id})")
|
|
517
|
+
else:
|
|
518
|
+
logger.error(f"[FerroApp WS] Ошибка сокет-сессии: {e}", exc_info=True)
|
|
519
|
+
|
|
520
|
+
def run(self, host: str = "127.0.0.1", port: int = 8080, workers: int = 1, **granian_kwargs: Any):
|
|
521
|
+
"""
|
|
522
|
+
Удобный запуск сервера через Granian в коде.
|
|
523
|
+
"""
|
|
524
|
+
if self.handle_cli():
|
|
525
|
+
return
|
|
526
|
+
if workers > 1:
|
|
527
|
+
from .core.backplane import get_backplane, MemoryBackplane
|
|
528
|
+
if isinstance(get_backplane(), MemoryBackplane):
|
|
529
|
+
logger.warning(
|
|
530
|
+
f"[Multi-Worker] Внимание: сервер запускается с workers={workers}, "
|
|
531
|
+
"но используется локальный MemoryBackplane. "
|
|
532
|
+
"WebSocket-сессии и Smart Cache изолированы внутри каждого процесса воркера! "
|
|
533
|
+
"Для синхронизации подключите распределенный Backplane (Redis/PG) или используйте workers=1."
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
try:
|
|
537
|
+
from granian import Granian
|
|
538
|
+
except ImportError:
|
|
539
|
+
raise RuntimeError("Для запуска через app.run() установите granian: pip install granian")
|
|
540
|
+
|
|
541
|
+
server = Granian(
|
|
542
|
+
target=self,
|
|
543
|
+
address=host,
|
|
544
|
+
port=port,
|
|
545
|
+
interface="rsgi",
|
|
546
|
+
workers=workers,
|
|
547
|
+
**granian_kwargs
|
|
548
|
+
)
|
|
549
|
+
server.serve()
|