veil-api-kit 0.10.0__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.
veil_kit/security.py ADDED
@@ -0,0 +1,377 @@
1
+ """Authentication with the classes and behaviour of ``fastapi.security``.
2
+
3
+ Each scheme is a dependency (``Depends(scheme)`` or ``Security(scheme, scopes=[...])``) that reads the credentials
4
+ from the request and appears in OpenAPI ``securitySchemes``. With ``auto_error=False`` a missing credential gives
5
+ ``None`` instead of a 401.
6
+
7
+ .. code-block:: python
8
+
9
+ from veil_kit.security import OAuth2PasswordBearer, JWT
10
+
11
+ oauth2 = OAuth2PasswordBearer(tokenUrl="/token")
12
+ tokens = JWT(secret="change-me", algorithms=["HS256"]) # needs "veil-api-kit[jwt]"
13
+
14
+ def current_user(token: str = veil.Depends(oauth2)):
15
+ return tokens.decode(token)["sub"]
16
+
17
+ @app.get("/me")
18
+ def me(user: str = veil.Depends(current_user)):
19
+ return {"user": user}
20
+ """
21
+
22
+ # F-268: fastapi.security と同じ名前・同じ振る舞い(401 と WWW-Authenticate)
23
+
24
+ import base64
25
+ import binascii
26
+ import hmac
27
+
28
+ import veil
29
+ from veil import HTTPException
30
+
31
+ from .cookies import _request_cookies
32
+
33
+ __all__ = [
34
+ "HTTPBasic",
35
+ "HTTPBasicCredentials",
36
+ "HTTPBearer",
37
+ "HTTPDigest",
38
+ "HTTPAuthorizationCredentials",
39
+ "APIKeyHeader",
40
+ "APIKeyCookie",
41
+ "APIKeyQuery",
42
+ "OAuth2PasswordBearer",
43
+ "OAuth2AuthorizationCodeBearer",
44
+ "OAuth2PasswordRequestForm",
45
+ "OAuth2PasswordRequestFormStrict",
46
+ "SecurityScopes",
47
+ "JWT",
48
+ "compare_digest",
49
+ "requires_scopes",
50
+ ]
51
+
52
+ SecurityScopes = veil.SecurityScopes
53
+
54
+
55
+ def compare_digest(a, b):
56
+ """Compare two strings in constant time."""
57
+ return hmac.compare_digest(a.encode() if isinstance(a, str) else a, b.encode() if isinstance(b, str) else b)
58
+
59
+
60
+ def _unauthorized(detail="Not authenticated", scheme="Bearer"):
61
+ return HTTPException(401, detail, {"WWW-Authenticate": scheme})
62
+
63
+
64
+ class _Scheme:
65
+ # 要求ごとにスレッドを跨がず、その場で呼んでよい(軽い)
66
+ _veil_inline = True
67
+ scheme_name = None
68
+
69
+ def openapi_scheme(self):
70
+ """``(name, Security Scheme Object)`` for OpenAPI."""
71
+ return self.scheme_name or type(self).__name__, self.model
72
+
73
+
74
+ class HTTPAuthorizationCredentials:
75
+ """``scheme`` and ``credentials`` of an ``Authorization`` header."""
76
+
77
+ def __init__(self, scheme, credentials):
78
+ self.scheme = scheme
79
+ self.credentials = credentials
80
+
81
+ def __repr__(self):
82
+ return "HTTPAuthorizationCredentials(scheme=%r)" % (self.scheme,)
83
+
84
+
85
+ class HTTPBasicCredentials:
86
+ """``username`` and ``password`` of HTTP Basic authentication."""
87
+
88
+ def __init__(self, username, password):
89
+ self.username = username
90
+ self.password = password
91
+
92
+ def __repr__(self):
93
+ return "HTTPBasicCredentials(username=%r)" % (self.username,)
94
+
95
+
96
+ def _authorization(request):
97
+ v = request.headers.get("authorization")
98
+ if not v:
99
+ return None, None
100
+ scheme, _, cred = v.partition(" ")
101
+ return scheme, cred.strip()
102
+
103
+
104
+ class HTTPBasic(_Scheme):
105
+ """HTTP Basic authentication; gives :class:`HTTPBasicCredentials`."""
106
+
107
+ def __init__(self, *, scheme_name=None, realm=None, description=None, auto_error=True):
108
+ """``realm`` goes to ``WWW-Authenticate``."""
109
+ self.scheme_name = scheme_name
110
+ self.realm = realm
111
+ self.auto_error = auto_error
112
+ self.model = {"type": "http", "scheme": "basic"}
113
+ if description:
114
+ self.model["description"] = description
115
+
116
+ def __call__(self, request: veil.Request):
117
+ """Read the credentials (401 with ``WWW-Authenticate: Basic`` when absent or malformed)."""
118
+ challenge = 'Basic realm="%s"' % self.realm if self.realm else "Basic"
119
+ scheme, cred = _authorization(request)
120
+ if not scheme or scheme.lower() != "basic":
121
+ if self.auto_error:
122
+ raise _unauthorized(scheme=challenge)
123
+ return None
124
+ try:
125
+ user, sep, pw = base64.b64decode(cred).decode("utf-8").partition(":")
126
+ except (ValueError, UnicodeDecodeError, binascii.Error):
127
+ raise _unauthorized("Invalid authentication credentials", challenge)
128
+ if not sep:
129
+ raise _unauthorized("Invalid authentication credentials", challenge)
130
+ return HTTPBasicCredentials(user, pw)
131
+
132
+
133
+ class HTTPBearer(_Scheme):
134
+ """``Authorization: Bearer <token>``; gives :class:`HTTPAuthorizationCredentials`."""
135
+
136
+ def __init__(self, *, bearerFormat=None, scheme_name=None, description=None, auto_error=True): # noqa: N803
137
+ """``bearerFormat`` (such as ``"JWT"``) goes to OpenAPI."""
138
+ self.scheme_name = scheme_name
139
+ self.auto_error = auto_error
140
+ self.model = {"type": "http", "scheme": "bearer"}
141
+ if bearerFormat:
142
+ self.model["bearerFormat"] = bearerFormat
143
+ if description:
144
+ self.model["description"] = description
145
+
146
+ def __call__(self, request: veil.Request):
147
+ """Read the token (401 with ``WWW-Authenticate: Bearer`` when absent)."""
148
+ scheme, cred = _authorization(request)
149
+ if not scheme or not cred or scheme.lower() != "bearer":
150
+ if self.auto_error:
151
+ raise _unauthorized()
152
+ return None
153
+ return HTTPAuthorizationCredentials(scheme, cred)
154
+
155
+
156
+ class HTTPDigest(_Scheme):
157
+ """``Authorization: Digest ...`` (the header is passed through; checking it is up to you)."""
158
+
159
+ def __init__(self, *, scheme_name=None, description=None, auto_error=True):
160
+ """Declare the scheme."""
161
+ self.scheme_name = scheme_name
162
+ self.auto_error = auto_error
163
+ self.model = {"type": "http", "scheme": "digest"}
164
+ if description:
165
+ self.model["description"] = description
166
+
167
+ def __call__(self, request: veil.Request):
168
+ """Read the header (401 when absent)."""
169
+ scheme, cred = _authorization(request)
170
+ if not scheme or scheme.lower() != "digest":
171
+ if self.auto_error:
172
+ raise _unauthorized(scheme="Digest")
173
+ return None
174
+ return HTTPAuthorizationCredentials(scheme, cred)
175
+
176
+
177
+ class _APIKey(_Scheme):
178
+ where = None
179
+
180
+ def __init__(self, *, name, scheme_name=None, description=None, auto_error=True):
181
+ self.name = name
182
+ self.scheme_name = scheme_name
183
+ self.auto_error = auto_error
184
+ self.model = {"type": "apiKey", "in": self.where, "name": name}
185
+ if description:
186
+ self.model["description"] = description
187
+
188
+ def _value(self, request):
189
+ raise NotImplementedError
190
+
191
+ def __call__(self, request: veil.Request):
192
+ """The key (401 when absent)."""
193
+ v = self._value(request)
194
+ if not v:
195
+ if self.auto_error:
196
+ raise _unauthorized(scheme="APIKey")
197
+ return None
198
+ return v
199
+
200
+
201
+ class APIKeyHeader(_APIKey):
202
+ """An API key in a header (``name`` is the header name)."""
203
+
204
+ where = "header"
205
+
206
+ def _value(self, request):
207
+ return request.headers.get(self.name)
208
+
209
+
210
+ class APIKeyCookie(_APIKey):
211
+ """An API key in a cookie."""
212
+
213
+ where = "cookie"
214
+
215
+ def _value(self, request):
216
+ return _request_cookies(request).get(self.name)
217
+
218
+
219
+ class APIKeyQuery(_APIKey):
220
+ """An API key in the query string."""
221
+
222
+ where = "query"
223
+
224
+ def _value(self, request):
225
+ return request.query_params.get(self.name)
226
+
227
+
228
+ class OAuth2PasswordBearer(_Scheme):
229
+ """OAuth2 password flow: reads the bearer token; ``tokenUrl`` is where clients get it (for OpenAPI)."""
230
+
231
+ def __init__(self, tokenUrl, scheme_name=None, scopes=None, description=None, auto_error=True, # noqa: N803
232
+ refreshUrl=None): # noqa: N803
233
+ """``scopes`` maps scope names to descriptions."""
234
+ self.scheme_name = scheme_name
235
+ self.auto_error = auto_error
236
+ flow = {"tokenUrl": tokenUrl, "scopes": dict(scopes or {})}
237
+ if refreshUrl:
238
+ flow["refreshUrl"] = refreshUrl
239
+ self.model = {"type": "oauth2", "flows": {"password": flow}}
240
+ if description:
241
+ self.model["description"] = description
242
+
243
+ def __call__(self, request: veil.Request):
244
+ """The token (``str``; 401 with ``WWW-Authenticate: Bearer`` when absent)."""
245
+ scheme, cred = _authorization(request)
246
+ if not scheme or not cred or scheme.lower() != "bearer":
247
+ if self.auto_error:
248
+ raise _unauthorized()
249
+ return None
250
+ return cred
251
+
252
+
253
+ class OAuth2AuthorizationCodeBearer(OAuth2PasswordBearer):
254
+ """OAuth2 authorization code flow (``authorizationUrl`` and ``tokenUrl`` for OpenAPI)."""
255
+
256
+ def __init__(self, authorizationUrl, tokenUrl, refreshUrl=None, scheme_name=None, scopes=None, # noqa: N803
257
+ description=None, auto_error=True):
258
+ """Declare the flow."""
259
+ super().__init__(tokenUrl, scheme_name, scopes, description, auto_error)
260
+ flow = {"authorizationUrl": authorizationUrl, "tokenUrl": tokenUrl, "scopes": dict(scopes or {})}
261
+ if refreshUrl:
262
+ flow["refreshUrl"] = refreshUrl
263
+ self.model = {"type": "oauth2", "flows": {"authorizationCode": flow}}
264
+
265
+
266
+ class OAuth2PasswordRequestForm:
267
+ """The form of the OAuth2 password flow (``username``, ``password``, ``scope`` → ``scopes``, ``grant_type``,
268
+ ``client_id``, ``client_secret``); use as ``form: OAuth2PasswordRequestForm = Depends()``."""
269
+
270
+ _veil_inline = True
271
+
272
+ def __init__(
273
+ self,
274
+ grant_type: str = veil.Form(None),
275
+ username: str = veil.Form(),
276
+ password: str = veil.Form(),
277
+ scope: str = veil.Form(""),
278
+ client_id: str = veil.Form(None),
279
+ client_secret: str = veil.Form(None),
280
+ ):
281
+ self.grant_type = grant_type
282
+ self.username = username
283
+ self.password = password
284
+ self.scopes = scope.split()
285
+ self.client_id = client_id
286
+ self.client_secret = client_secret
287
+
288
+
289
+ class OAuth2PasswordRequestFormStrict(OAuth2PasswordRequestForm):
290
+ """Like :class:`OAuth2PasswordRequestForm`, with ``grant_type`` required to be ``"password"``."""
291
+
292
+ def __init__(
293
+ self,
294
+ grant_type: str = veil.Form(),
295
+ username: str = veil.Form(),
296
+ password: str = veil.Form(),
297
+ scope: str = veil.Form(""),
298
+ client_id: str = veil.Form(None),
299
+ client_secret: str = veil.Form(None),
300
+ ):
301
+ if grant_type != "password":
302
+ raise HTTPException(422, "grant_type must be 'password'")
303
+ super().__init__(grant_type, username, password, scope, client_id, client_secret)
304
+
305
+
306
+ def requires_scopes(scheme, verify):
307
+ """A dependency checking the scopes of a :class:`veil.Security` declaration.
308
+
309
+ ``verify(token)`` returns the scopes the credential grants (an iterable) or raises; the dependency answers 401
310
+ when one of the required scopes is missing, with the scopes in ``WWW-Authenticate``.
311
+ """
312
+
313
+ def check(security_scopes: veil.SecurityScopes, token=veil.Depends(scheme)):
314
+ granted = set(verify(token) or ())
315
+ missing = [s for s in security_scopes.scopes if s not in granted]
316
+ if missing:
317
+ raise HTTPException(401, "Not enough permissions",
318
+ {"WWW-Authenticate": 'Bearer scope="%s"' % security_scopes.scope_str})
319
+ return token
320
+
321
+ check._veil_inline = True
322
+ return check
323
+
324
+
325
+ class JWT:
326
+ """Issue and verify JSON Web Tokens with PyJWT (``pip install "veil-api-kit[jwt]"``).
327
+
328
+ ``key`` is the secret (HMAC) or the public key (RSA, EC, EdDSA) used to verify; ``signing_key`` signs (defaults to
329
+ ``key``). ``audience``, ``issuer`` and ``leeway`` are checked on :meth:`decode`. As in PyJWT, a token with an
330
+ ``aud`` claim is rejected when no ``audience`` is given (it was issued for another service);
331
+ ``verify_audience=False`` turns the ``aud`` check off.
332
+ """
333
+
334
+ # B-201: aud の検査は PyJWT の既定どおり(audience 無しで aud 付きのトークンは拒否)。外すときは明示する
335
+ def __init__(self, key=None, algorithms=("HS256",), *, secret=None, signing_key=None, audience=None, issuer=None,
336
+ leeway=0, jwks_url=None, verify_audience=True):
337
+ """Create a verifier (``jwks_url`` fetches the keys from a JWK Set by ``kid``)."""
338
+ import jwt # noqa: PLC0415
339
+
340
+ self._jwt = jwt
341
+ self.key = key if key is not None else secret
342
+ self.signing_key = signing_key if signing_key is not None else self.key
343
+ self.algorithms = list(algorithms)
344
+ self.audience = audience
345
+ self.issuer = issuer
346
+ self.leeway = leeway
347
+ self.verify_audience = verify_audience
348
+ self._jwks = jwt.PyJWKClient(jwks_url) if jwks_url else None
349
+
350
+ def encode(self, claims, algorithm=None, headers=None):
351
+ """Sign ``claims`` (a dict; ``exp`` may be a ``datetime``)."""
352
+ return self._jwt.encode(dict(claims), self.signing_key, algorithm=algorithm or self.algorithms[0],
353
+ headers=headers)
354
+
355
+ def decode(self, token, *, verify_exp=True):
356
+ """Verify ``token`` and return its claims; raise ``HTTPException(401)`` when it is not valid."""
357
+ jwt = self._jwt
358
+ try:
359
+ key = self._jwks.get_signing_key_from_jwt(token).key if self._jwks is not None else self.key
360
+ return jwt.decode(token, key, algorithms=self.algorithms, audience=self.audience, issuer=self.issuer,
361
+ leeway=self.leeway, options={"verify_exp": verify_exp,
362
+ "verify_aud": self.verify_audience})
363
+ except jwt.PyJWTError as e:
364
+ raise HTTPException(401, "Could not validate credentials: %s" % e,
365
+ {"WWW-Authenticate": 'Bearer error="invalid_token"'})
366
+
367
+ def dependency(self, scheme=None):
368
+ """A dependency returning the verified claims of the bearer token (``Depends(tokens.dependency())``)."""
369
+ scheme = scheme or HTTPBearer(bearerFormat="JWT")
370
+ me = self
371
+
372
+ def claims(credentials=veil.Depends(scheme)):
373
+ token = credentials.credentials if isinstance(credentials, HTTPAuthorizationCredentials) else credentials
374
+ return me.decode(token)
375
+
376
+ claims._veil_inline = True
377
+ return claims
veil_kit/sessions.py ADDED
@@ -0,0 +1,243 @@
1
+ """Sessions.
2
+
3
+ Without a store the session is a signed cookie in the format of Starlette's ``SessionMiddleware`` (base64 JSON signed
4
+ with itsdangerous' ``TimestampSigner``), so both can read each other's cookies. With a store (:class:`MemoryStore`,
5
+ :class:`RedisStore` or your own :class:`SessionStore`) the cookie holds a signed random id and the data stays on the
6
+ server.
7
+
8
+ A session is a dependency (veil has no ASGI middleware for native routes):
9
+
10
+ .. code-block:: python
11
+
12
+ from veil_kit.sessions import Session, Sessions
13
+
14
+ sessions = Sessions(secret_key="change-me")
15
+
16
+ @app.get("/count")
17
+ def count(session: Session = veil.Depends(sessions)):
18
+ session["n"] = session.get("n", 0) + 1
19
+ return {"n": session["n"]}
20
+
21
+ The cookie is written only when the session changed (``Set-Cookie`` on the response of that request).
22
+ """
23
+
24
+ # F-268: Starlette の SessionMiddleware と同じ Cookie の形式。依存(Depends)として使う。
25
+
26
+ import base64
27
+ import json
28
+ import secrets
29
+ import threading
30
+ import time
31
+
32
+ import veil
33
+
34
+ from .cookies import BadSignature, Signer, _request_cookies
35
+
36
+ __all__ = ["Session", "Sessions", "SessionStore", "MemoryStore", "RedisStore"]
37
+
38
+
39
+ class Session(dict):
40
+ """The data of one session (a ``dict``). ``clear()`` empties it; :meth:`regenerate` gives it a new id."""
41
+
42
+ def __init__(self, data=None, sid=None):
43
+ super().__init__(data or {})
44
+ self.sid = sid
45
+ self.modified = False
46
+ self.new_id = False
47
+
48
+ def _touch(self):
49
+ self.modified = True
50
+
51
+ def __setitem__(self, k, v):
52
+ super().__setitem__(k, v)
53
+ self._touch()
54
+
55
+ def __delitem__(self, k):
56
+ super().__delitem__(k)
57
+ self._touch()
58
+
59
+ def clear(self):
60
+ """Remove every key (the cookie is deleted at the end of the request)."""
61
+ super().clear()
62
+ self._touch()
63
+
64
+ def pop(self, *a):
65
+ """Like ``dict.pop``."""
66
+ r = super().pop(*a)
67
+ self._touch()
68
+ return r
69
+
70
+ def popitem(self):
71
+ """Like ``dict.popitem``."""
72
+ r = super().popitem()
73
+ self._touch()
74
+ return r
75
+
76
+ def setdefault(self, k, default=None):
77
+ """Like ``dict.setdefault``."""
78
+ if k not in self:
79
+ self._touch()
80
+ return super().setdefault(k, default)
81
+
82
+ def update(self, *a, **kw):
83
+ """Like ``dict.update``."""
84
+ super().update(*a, **kw)
85
+ self._touch()
86
+
87
+ def regenerate(self):
88
+ """Give the session a new id (after a login; only with a store)."""
89
+ self.new_id = True
90
+ self._touch()
91
+
92
+
93
+ class SessionStore:
94
+ """Server-side storage of session data (implement ``load``, ``save`` and ``delete``; they may be called from
95
+ several threads)."""
96
+
97
+ def load(self, sid):
98
+ """Return the data of ``sid`` (a dict) or ``None``."""
99
+ raise NotImplementedError
100
+
101
+ def save(self, sid, data, max_age):
102
+ """Store ``data`` for ``sid`` for ``max_age`` seconds."""
103
+ raise NotImplementedError
104
+
105
+ def delete(self, sid):
106
+ """Forget ``sid``."""
107
+ raise NotImplementedError
108
+
109
+
110
+ class MemoryStore(SessionStore):
111
+ """Sessions in the process memory (one process; lost on restart)."""
112
+
113
+ def __init__(self):
114
+ """Create an empty store."""
115
+ self._data = {}
116
+ self._lock = threading.Lock()
117
+
118
+ def load(self, sid):
119
+ """Return the data of ``sid`` unless it expired."""
120
+ with self._lock:
121
+ v = self._data.get(sid)
122
+ if v is None:
123
+ return None
124
+ data, until = v
125
+ if until is not None and until < time.time():
126
+ del self._data[sid]
127
+ return None
128
+ return dict(data)
129
+
130
+ def save(self, sid, data, max_age):
131
+ """Store a copy of ``data``."""
132
+ with self._lock:
133
+ self._data[sid] = (dict(data), time.time() + max_age if max_age else None)
134
+ if len(self._data) > 1024 and len(self._data) % 1024 == 0:
135
+ now = time.time()
136
+ for k in [k for k, (_, u) in self._data.items() if u is not None and u < now]:
137
+ del self._data[k]
138
+
139
+ def delete(self, sid):
140
+ """Forget ``sid``."""
141
+ with self._lock:
142
+ self._data.pop(sid, None)
143
+
144
+
145
+ class RedisStore(SessionStore):
146
+ """Sessions in Redis (``pip install "veil-api-kit[redis]"``); ``client`` is a ``redis.Redis``."""
147
+
148
+ def __init__(self, client=None, url="redis://localhost:6379/0", prefix="veil:session:"):
149
+ """Use ``client``, or connect to ``url``."""
150
+ if client is None:
151
+ import redis # noqa: PLC0415
152
+
153
+ client = redis.Redis.from_url(url)
154
+ self.client = client
155
+ self.prefix = prefix
156
+
157
+ def load(self, sid):
158
+ """Return the data of ``sid``."""
159
+ raw = self.client.get(self.prefix + sid)
160
+ return None if raw is None else json.loads(raw)
161
+
162
+ def save(self, sid, data, max_age):
163
+ """Store ``data`` with an expiry of ``max_age`` seconds."""
164
+ self.client.set(self.prefix + sid, json.dumps(data), ex=int(max_age) if max_age else None)
165
+
166
+ def delete(self, sid):
167
+ """Forget ``sid``."""
168
+ self.client.delete(self.prefix + sid)
169
+
170
+
171
+ class Sessions:
172
+ """The session dependency: ``session: Session = Depends(sessions)``.
173
+
174
+ Arguments are those of Starlette's ``SessionMiddleware``: ``secret_key``, ``session_cookie`` (``"session"``),
175
+ ``max_age`` (14 days; ``None`` makes a browser-session cookie), ``path``, ``same_site`` (``"lax"``),
176
+ ``https_only`` (adds ``Secure``) and ``domain``; plus ``store`` for server-side data.
177
+ """
178
+
179
+ _veil_inline = True
180
+
181
+ def __init__(self, secret_key, session_cookie="session", max_age=14 * 24 * 60 * 60, path="/", same_site="lax",
182
+ https_only=False, domain=None, store=None):
183
+ """Create the dependency (see the class documentation)."""
184
+ self.signer = Signer(secret_key)
185
+ self.cookie = session_cookie
186
+ self.max_age = max_age
187
+ self.path = path
188
+ self.same_site = same_site
189
+ self.secure = https_only
190
+ self.domain = domain
191
+ self.store = store
192
+
193
+ def _load(self, request):
194
+ raw = _request_cookies(request).get(self.cookie)
195
+ if raw is None:
196
+ return Session()
197
+ try:
198
+ value = self.signer.unsign(raw, self.max_age)
199
+ except BadSignature:
200
+ return Session()
201
+ if self.store is None:
202
+ try:
203
+ return Session(json.loads(base64.b64decode(value)))
204
+ except ValueError:
205
+ return Session()
206
+ sid = value.decode("ascii", errors="replace")
207
+ data = self.store.load(sid)
208
+ return Session(data, sid) if data is not None else Session()
209
+
210
+ def _cookie_options(self):
211
+ opts = {"path": self.path, "httponly": True, "samesite": self.same_site, "secure": self.secure}
212
+ if self.domain:
213
+ opts["domain"] = self.domain
214
+ if self.max_age is not None:
215
+ opts["max_age"] = int(self.max_age)
216
+ return opts
217
+
218
+ def _write(self, session, response):
219
+ if not session.modified:
220
+ return
221
+ if self.store is None:
222
+ if not session:
223
+ response.delete_cookie(self.cookie, path=self.path, domain=self.domain)
224
+ return
225
+ value = base64.b64encode(json.dumps(dict(session)).encode("utf-8"))
226
+ response.set_cookie(self.cookie, self.signer.sign(value).decode("ascii"), **self._cookie_options())
227
+ return
228
+ if session.sid and (session.new_id or not session):
229
+ self.store.delete(session.sid)
230
+ session.sid = None
231
+ if not session:
232
+ response.delete_cookie(self.cookie, path=self.path, domain=self.domain)
233
+ return
234
+ if session.sid is None:
235
+ session.sid = secrets.token_urlsafe(32)
236
+ self.store.save(session.sid, dict(session), self.max_age)
237
+ response.set_cookie(self.cookie, self.signer.sign(session.sid).decode("ascii"), **self._cookie_options())
238
+
239
+ def __call__(self, request: veil.Request, response: veil.Response):
240
+ """Load the session of ``request``; after the handler, write the cookie to ``response`` if it changed."""
241
+ session = self._load(request)
242
+ yield session
243
+ self._write(session, response)
@@ -0,0 +1,36 @@
1
+ """Static files with the arguments of Starlette's ``StaticFiles``.
2
+
3
+ .. code-block:: python
4
+
5
+ from veil_kit.staticfiles import StaticFiles
6
+
7
+ app.mount("/static", StaticFiles(directory="public"), name="static")
8
+
9
+ The directory is served by the server's file backend (Rust): ``sendfile``, kTLS, Range and ``ETag`` work as for the
10
+ proxy, and requests do not reach Python. Precompressed ``.br`` / ``.gz`` files are not used. ``html=True`` serves ``index.html`` for directories.
11
+ """
12
+
13
+ # F-268: 本体の File バックエンドのルートを注入する(app.serve_dir と同じ)
14
+
15
+ import os
16
+
17
+ __all__ = ["StaticFiles"]
18
+
19
+
20
+ class StaticFiles:
21
+ """A directory to mount with ``app.mount(path, StaticFiles(directory=...))``."""
22
+
23
+ def __init__(self, *, directory, html=False, check_dir=True, follow_symlink=False, cache=True, **route_options):
24
+ """``directory`` is served below the mount path; ``cache=False`` turns off the open-file and content caches;
25
+ ``route_options`` are extra ``[[route]]`` keys."""
26
+ if check_dir and not os.path.isdir(directory):
27
+ raise RuntimeError("Directory '%s' does not exist" % directory)
28
+ self.directory = os.path.abspath(directory)
29
+ self.html = html
30
+ self.cache = cache
31
+ self.route_options = route_options
32
+
33
+ def _veil_mount(self, app, path):
34
+ """Called by ``app.mount``."""
35
+ app.serve_dir(path, self.directory, index="index.html" if self.html else None, cache=self.cache,
36
+ **self.route_options)