Nexom 1.0.4__tar.gz → 1.0.6__tar.gz

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.
Files changed (81) hide show
  1. {nexom-1.0.4/src/Nexom.egg-info → nexom-1.0.6}/PKG-INFO +6 -5
  2. {nexom-1.0.4 → nexom-1.0.6}/README.md +5 -4
  3. {nexom-1.0.4 → nexom-1.0.6}/pyproject.toml +1 -1
  4. {nexom-1.0.4 → nexom-1.0.6/src/Nexom.egg-info}/PKG-INFO +6 -5
  5. {nexom-1.0.4 → nexom-1.0.6}/src/Nexom.egg-info/SOURCES.txt +1 -0
  6. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/__init__.py +1 -1
  7. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/app/__init__.py +1 -1
  8. nexom-1.0.6/src/nexom/app/auth.py +451 -0
  9. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/app/db.py +34 -6
  10. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/app/path.py +2 -2
  11. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/app/response.py +12 -3
  12. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/app/template.py +21 -17
  13. nexom-1.0.6/src/nexom/assets/app/__pycache__/__init__.cpython-313.pyc +0 -0
  14. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/app/pages/__pycache__/__init__.cpython-313.pyc +0 -0
  15. nexom-1.0.6/src/nexom/assets/app/pages/_templates.py +7 -0
  16. nexom-1.0.6/src/nexom/assets/app/pages/default.py +25 -0
  17. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/app/router.py +6 -3
  18. nexom-1.0.6/src/nexom/assets/app/static/github.png +0 -0
  19. nexom-1.0.6/src/nexom/assets/app/static/style.css +686 -0
  20. nexom-1.0.6/src/nexom/assets/app/templates/default.html +18 -0
  21. nexom-1.0.6/src/nexom/assets/app/templates/document.html +115 -0
  22. nexom-1.0.6/src/nexom/assets/app/templates/footer.html +3 -0
  23. nexom-1.0.6/src/nexom/assets/app/templates/header.html +9 -0
  24. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/auth/__pycache__/__init__.cpython-313.pyc +0 -0
  25. nexom-1.0.6/src/nexom/assets/auth_page/login.html +235 -0
  26. nexom-1.0.6/src/nexom/assets/auth_page/signup.html +321 -0
  27. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/buildTools/build.py +7 -1
  28. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/core/error.py +125 -32
  29. nexom-1.0.6/src/nexom/templates/auth.py +138 -0
  30. nexom-1.0.4/src/nexom/app/auth.py +0 -323
  31. nexom-1.0.4/src/nexom/assets/app/__pycache__/__init__.cpython-313.pyc +0 -0
  32. nexom-1.0.4/src/nexom/assets/app/pages/_templates.py +0 -7
  33. nexom-1.0.4/src/nexom/assets/app/pages/default.py +0 -10
  34. nexom-1.0.4/src/nexom/assets/app/static/style.css +0 -39
  35. nexom-1.0.4/src/nexom/assets/app/templates/default.html +0 -7
  36. nexom-1.0.4/src/nexom/assets/app/templates/document.html +0 -169
  37. nexom-1.0.4/src/nexom/assets/app/templates/footer.html +0 -3
  38. nexom-1.0.4/src/nexom/assets/app/templates/header.html +0 -3
  39. nexom-1.0.4/src/nexom/assets/auth_page/login.html +0 -95
  40. nexom-1.0.4/src/nexom/assets/auth_page/signup.html +0 -106
  41. nexom-1.0.4/src/nexom/templates/auth.py +0 -72
  42. {nexom-1.0.4 → nexom-1.0.6}/LICENSE +0 -0
  43. {nexom-1.0.4 → nexom-1.0.6}/setup.cfg +0 -0
  44. {nexom-1.0.4 → nexom-1.0.6}/src/Nexom.egg-info/dependency_links.txt +0 -0
  45. {nexom-1.0.4 → nexom-1.0.6}/src/Nexom.egg-info/entry_points.txt +0 -0
  46. {nexom-1.0.4 → nexom-1.0.6}/src/Nexom.egg-info/requires.txt +0 -0
  47. {nexom-1.0.4 → nexom-1.0.6}/src/Nexom.egg-info/top_level.txt +0 -0
  48. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/__main__.py +0 -0
  49. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/app/cookie.py +0 -0
  50. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/app/http_status_codes.py +0 -0
  51. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/app/middleware.py +0 -0
  52. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/app/request.py +0 -0
  53. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/app/user.py +0 -0
  54. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/app/__init__.py +0 -0
  55. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/app/config.py +0 -0
  56. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/app/gunicorn.conf.py +0 -0
  57. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/app/pages/__init__.py +0 -0
  58. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/app/pages/document.py +0 -0
  59. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/app/static/dog.jpeg +0 -0
  60. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/app/templates/base.html +0 -0
  61. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/app/wsgi.py +0 -0
  62. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/auth/__init__.py +0 -0
  63. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/auth/config.py +0 -0
  64. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/auth/gunicorn.conf.py +0 -0
  65. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/auth/wsgi.py +0 -0
  66. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/error_page/error.html +0 -0
  67. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/gateway/apache_app.conf +0 -0
  68. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/assets/gateway/nginx_app.conf +0 -0
  69. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/buildTools/__init__.py +0 -0
  70. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/buildTools/run.py +0 -0
  71. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/core/__init__.py +0 -0
  72. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/core/log.py +0 -0
  73. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/core/object_html_render.py +0 -0
  74. {nexom-1.0.4 → nexom-1.0.6}/src/nexom/templates/__init__.py +0 -0
  75. {nexom-1.0.4 → nexom-1.0.6}/tests/test_buildtools.py +0 -0
  76. {nexom-1.0.4 → nexom-1.0.6}/tests/test_http_status_codes.py +0 -0
  77. {nexom-1.0.4 → nexom-1.0.6}/tests/test_middleware.py +0 -0
  78. {nexom-1.0.4 → nexom-1.0.6}/tests/test_path_routing.py +0 -0
  79. {nexom-1.0.4 → nexom-1.0.6}/tests/test_request.py +0 -0
  80. {nexom-1.0.4 → nexom-1.0.6}/tests/test_response.py +0 -0
  81. {nexom-1.0.4 → nexom-1.0.6}/tests/test_static.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Nexom
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: Lightweight Python Web Framework (WSGI)
5
5
  Author: TouriAida
6
6
  License: MIT License
@@ -69,11 +69,12 @@ pip install
69
69
  ```
70
70
  **プロジェクトのビルド**
71
71
 
72
- プロジェクトディレクトリ上で、以下のコマンドを実行してください(名前は自由)
72
+ プロジェクトディレクトリ上で、以下のコマンドを実行してください
73
+
73
74
  もしNginxもしくはApacheを使用する場合 --gateway オプションにどちらか入力してください
74
75
 
75
76
  ```
76
- $ python -m nexom start-project
77
+ $ python -m nexom start-project # --gateway nginx or apache
77
78
  ```
78
79
 
79
80
  以下の構成でプロジェクトが生成されます。
@@ -117,7 +118,7 @@ $ python -m nexom run
117
118
  ブラウザからアクセスできるようになります。
118
119
  デフォルトのポートは8080です。
119
120
 
120
- [https://localhost:8080](https://localhost:8080)
121
+ [http://localhost:8080](http://localhost:8080)
121
122
 
122
123
  ポートなどの設定は `config.py` から変更してください。
123
124
 
@@ -191,4 +192,4 @@ sudo systemd start banana-project@banana2
191
192
  sudo systemd start banana-project@banana3
192
193
  ```
193
194
 
194
- 2026 1/25
195
+ 2026 1/28
@@ -32,11 +32,12 @@ pip install
32
32
  ```
33
33
  **プロジェクトのビルド**
34
34
 
35
- プロジェクトディレクトリ上で、以下のコマンドを実行してください(名前は自由)
35
+ プロジェクトディレクトリ上で、以下のコマンドを実行してください
36
+
36
37
  もしNginxもしくはApacheを使用する場合 --gateway オプションにどちらか入力してください
37
38
 
38
39
  ```
39
- $ python -m nexom start-project
40
+ $ python -m nexom start-project # --gateway nginx or apache
40
41
  ```
41
42
 
42
43
  以下の構成でプロジェクトが生成されます。
@@ -80,7 +81,7 @@ $ python -m nexom run
80
81
  ブラウザからアクセスできるようになります。
81
82
  デフォルトのポートは8080です。
82
83
 
83
- [https://localhost:8080](https://localhost:8080)
84
+ [http://localhost:8080](http://localhost:8080)
84
85
 
85
86
  ポートなどの設定は `config.py` から変更してください。
86
87
 
@@ -154,4 +155,4 @@ sudo systemd start banana-project@banana2
154
155
  sudo systemd start banana-project@banana3
155
156
  ```
156
157
 
157
- 2026 1/25
158
+ 2026 1/28
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "Nexom"
7
- version = "1.0.4"
7
+ version = "1.0.6"
8
8
  description = "Lightweight Python Web Framework (WSGI)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Nexom
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: Lightweight Python Web Framework (WSGI)
5
5
  Author: TouriAida
6
6
  License: MIT License
@@ -69,11 +69,12 @@ pip install
69
69
  ```
70
70
  **プロジェクトのビルド**
71
71
 
72
- プロジェクトディレクトリ上で、以下のコマンドを実行してください(名前は自由)
72
+ プロジェクトディレクトリ上で、以下のコマンドを実行してください
73
+
73
74
  もしNginxもしくはApacheを使用する場合 --gateway オプションにどちらか入力してください
74
75
 
75
76
  ```
76
- $ python -m nexom start-project
77
+ $ python -m nexom start-project # --gateway nginx or apache
77
78
  ```
78
79
 
79
80
  以下の構成でプロジェクトが生成されます。
@@ -117,7 +118,7 @@ $ python -m nexom run
117
118
  ブラウザからアクセスできるようになります。
118
119
  デフォルトのポートは8080です。
119
120
 
120
- [https://localhost:8080](https://localhost:8080)
121
+ [http://localhost:8080](http://localhost:8080)
121
122
 
122
123
  ポートなどの設定は `config.py` から変更してください。
123
124
 
@@ -191,4 +192,4 @@ sudo systemd start banana-project@banana2
191
192
  sudo systemd start banana-project@banana3
192
193
  ```
193
194
 
194
- 2026 1/25
195
+ 2026 1/28
@@ -32,6 +32,7 @@ src/nexom/assets/app/pages/default.py
32
32
  src/nexom/assets/app/pages/document.py
33
33
  src/nexom/assets/app/pages/__pycache__/__init__.cpython-313.pyc
34
34
  src/nexom/assets/app/static/dog.jpeg
35
+ src/nexom/assets/app/static/github.png
35
36
  src/nexom/assets/app/static/style.css
36
37
  src/nexom/assets/app/templates/base.html
37
38
  src/nexom/assets/app/templates/default.html
@@ -16,4 +16,4 @@ __all__ = [
16
16
  "__version__",
17
17
  ]
18
18
 
19
- __version__ = "0.1.2"
19
+ __version__ = "1.0.6"
@@ -16,7 +16,7 @@ from .response import (
16
16
  )
17
17
 
18
18
  # ---- Routing ----
19
- from .path import Path, Static, Pathlib
19
+ from .path import Path, Static, Router
20
20
 
21
21
  # ---- Cookie ----
22
22
  from .cookie import Cookie, RequestCookies
@@ -0,0 +1,451 @@
1
+ # src/nexom/app/auth.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+ import secrets
7
+ import time
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ import sqlite3
12
+ from urllib.request import Request as UrlRequest, urlopen
13
+ from urllib.error import URLError, HTTPError
14
+
15
+ from .request import Request
16
+ from .response import JsonResponse
17
+ from .db import DatabaseManager
18
+ from .path import Path, Router
19
+ from ..core.log import AuthLogger
20
+
21
+ from ..core.error import (
22
+ NexomError,
23
+ AuthMissingFieldError, # A01
24
+ AuthUserIdAlreadyExistsError, # A02
25
+ AuthInvalidCredentialsError, # A03
26
+ AuthUserDisabledError, # A04
27
+ AuthTokenMissingError, # A05
28
+ AuthTokenInvalidError, # A06
29
+ AuthTokenExpiredError, # A07
30
+ AuthTokenRevokedError, # A08
31
+ AuthServiceUnavailableError, # A09
32
+ _status_for_auth_error,
33
+
34
+ DBError,
35
+ DBMConnectionInvalidError,
36
+ DBOperationalError,
37
+ DBIntegrityError,
38
+ DBProgrammingError,
39
+ )
40
+
41
+ # --------------------
42
+ # utils
43
+ # --------------------
44
+
45
+ def _now() -> int:
46
+ return int(time.time())
47
+
48
+
49
+ def _rand(nbytes: int = 24) -> str:
50
+ return secrets.token_urlsafe(nbytes)
51
+
52
+
53
+ def _make_salt(nbytes: int = 16) -> str:
54
+ return secrets.token_hex(nbytes)
55
+
56
+
57
+ def _hash_password(password: str, salt_hex: str) -> str:
58
+ salt = bytes.fromhex(salt_hex)
59
+ dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 200_000)
60
+ return dk.hex()
61
+
62
+
63
+ def _token_hash(token: str) -> str:
64
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
65
+
66
+
67
+ # --------------------
68
+ # variables (internal)
69
+ # --------------------
70
+
71
+ KEY_NAME = "_nxt"
72
+
73
+
74
+ # --------------------
75
+ # models (internal)
76
+ # --------------------
77
+
78
+ @dataclass
79
+ class LocalSession:
80
+ sid: str
81
+ uid: str
82
+ user_id: str
83
+ public_name: str
84
+ token: str
85
+ expires_at: int
86
+ revoked_at: int | None
87
+ user_agent: str | None
88
+
89
+ @dataclass
90
+ class Session:
91
+ pid: str
92
+ user_id: str
93
+ public_name: str
94
+ token: str
95
+ expires_at: int
96
+ user_agent: str | None
97
+
98
+
99
+ # --------------------
100
+ # AuthService (API only)
101
+ # --------------------
102
+
103
+ class AuthService:
104
+ """
105
+ Auth API service (JSON only).
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ db_path: str,
111
+ log_path: str,
112
+ *,
113
+ ttl_sec: int = 60 * 60 * 24 * 7,
114
+ prefix: str = "",
115
+ ) -> None:
116
+ self.dbm = AuthDBM(db_path)
117
+ self.ttl_sec = ttl_sec
118
+
119
+ p = prefix.strip("/")
120
+
121
+ def _p(x: str) -> str:
122
+ return f"{p}/{x}".strip("/") if p else x
123
+
124
+ self.routing = Router(
125
+ Path(_p("signup"), self.signup, "AuthSignup"),
126
+ Path(_p("login"), self.login, "AuthLogin"),
127
+ Path(_p("logout"), self.logout, "AuthLogout"),
128
+ Path(_p("verify"), self.verify, "AuthVerify"),
129
+ )
130
+
131
+ self.logger = AuthLogger(log_path)
132
+
133
+ def handler(self, environ: dict) -> JsonResponse:
134
+ req = Request(environ)
135
+ try:
136
+ route = self.routing.get(req.path)
137
+ return route.call_handler(req)
138
+
139
+ except NexomError as e:
140
+ # error code -> proper HTTP status
141
+ status = _status_for_auth_error(e.code)
142
+ return JsonResponse({"ok": False, "error": e.code}, status=status)
143
+
144
+ except Exception as e:
145
+ return JsonResponse({"ok": False, "error": "InternalError"}, status=500)
146
+
147
+ # ---- handlers ----
148
+
149
+ def signup(self, request: Request, args: dict[str, Optional[str]]) -> JsonResponse:
150
+ if request.method != "POST":
151
+ return JsonResponse({"ok": False, "error": "MethodNotAllowed"}, status=405)
152
+
153
+ data = request.json() or {}
154
+ user_id = str(data.get("user_id") or "").strip()
155
+ public_name = str(data.get("public_name") or "").strip()
156
+ password = str(data.get("password") or "")
157
+
158
+ self.dbm.signup(user_id=user_id, public_name=public_name, password=password)
159
+ return JsonResponse({"ok": True}, status=201)
160
+
161
+ def login(self, request: Request, args: dict[str, Optional[str]]) -> JsonResponse:
162
+ if request.method != "POST":
163
+ return JsonResponse({"ok": False, "error": "MethodNotAllowed"}, status=405)
164
+
165
+ data = request.json() or {}
166
+ user_id = str(data.get("user_id") or "").strip()
167
+ password = str(data.get("password") or "")
168
+
169
+ lsess = self.dbm.login(
170
+ user_id,
171
+ password,
172
+ user_agent=request.headers.get("user-agent"),
173
+ ttl_sec=self.ttl_sec,
174
+ )
175
+
176
+ return JsonResponse(
177
+ {
178
+ "ok": True,
179
+ "pid":lsess.uid,
180
+ "user_id": lsess.user_id,
181
+ "public_name":lsess.public_name,
182
+ "token": lsess.token,
183
+ "expires_at": lsess.expires_at,
184
+ "user_agent": lsess.user_agent
185
+ }
186
+ )
187
+
188
+ def logout(self, request: Request, args: dict[str, Optional[str]]) -> JsonResponse:
189
+ if request.method != "POST":
190
+ return JsonResponse({"ok": False, "error": "MethodNotAllowed"}, status=405)
191
+
192
+ token = str((request.json() or {}).get("token") or "")
193
+ if token:
194
+ self.dbm.logout(token)
195
+ return JsonResponse({"ok": True})
196
+
197
+ def verify(self, request: Request, args: dict[str, Optional[str]]) -> JsonResponse:
198
+ if request.method != "POST":
199
+ return JsonResponse({"ok": False, "error": "MethodNotAllowed"}, status=405)
200
+
201
+ token = str((request.json() or {}).get("token") or "")
202
+ lsess = self.dbm.verify(token)
203
+ if not lsess:
204
+ return JsonResponse({"active": False}, status=200)
205
+
206
+ return JsonResponse(
207
+ {
208
+ "active": True,
209
+ "pid":lsess.uid,
210
+ "user_id": lsess.user_id,
211
+ "public_name":lsess.public_name,
212
+ "expires_at": lsess.expires_at,
213
+ "user_agent": lsess.user_agent
214
+ },
215
+ status=200,
216
+ )
217
+
218
+
219
+ # --------------------
220
+ # AuthClient (App側)
221
+ # --------------------
222
+
223
+ class AuthClient:
224
+ """AuthService を HTTP で叩くクライアント"""
225
+
226
+ def __init__(self, auth_url: str, *, timeout: float = 3.0) -> None:
227
+ base = auth_url.rstrip("/")
228
+ self.signup_url = base + "/signup"
229
+ self.login_url = base + "/login"
230
+ self.logout_url = base + "/logout"
231
+ self.verify_url = base + "/verify"
232
+ self.timeout = timeout
233
+
234
+ def _post(self, url: str, body: dict) -> dict:
235
+ payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
236
+ req = UrlRequest(
237
+ url,
238
+ data=payload,
239
+ headers={
240
+ "Content-Type": "application/json; charset=utf-8",
241
+ "Accept": "application/json",
242
+ },
243
+ method="POST",
244
+ )
245
+
246
+ try:
247
+ with urlopen(req, timeout=self.timeout) as r:
248
+ raw = r.read()
249
+ text = raw.decode("utf-8", errors="replace")
250
+ return json.loads(text) if text else {}
251
+
252
+ except HTTPError as e:
253
+ try:
254
+ raw = e.read()
255
+ text = raw.decode("utf-8", errors="replace")
256
+ return json.loads(text) if text else {"ok": False, "error": f"HTTP_{e.code}"}
257
+ except Exception:
258
+ return {"ok": False, "error": f"HTTP_{e.code}"}
259
+
260
+ except (URLError, TimeoutError):
261
+ raise AuthServiceUnavailableError()
262
+
263
+ except json.JSONDecodeError:
264
+ raise AuthServiceUnavailableError()
265
+
266
+ def signup(self, *, user_id: str, public_name: str, password: str) -> None:
267
+ d = self._post(
268
+ self.signup_url,
269
+ {"user_id": user_id, "public_name": public_name, "password": password},
270
+ )
271
+ if d.get("ok"):
272
+ return
273
+ self._raise_from_error_code(str(d.get("error") or ""))
274
+
275
+ def login(self, *, user_id: str, password: str) -> Session:
276
+ d = self._post(self.login_url, {"user_id": user_id, "password": password})
277
+ if not d.get("ok"):
278
+ self._raise_from_error_code(str(d.get("error") or ""))
279
+
280
+ return Session(str(d["pid"]), str(d["user_id"]), str(d["public_name"]), str(d["token"]), int(d["expires_at"]), str(d["user_agent"]))
281
+
282
+ def verify_token(self, token: str) -> Session | None:
283
+ d = self._post(self.verify_url, {"token": token})
284
+
285
+ if d.get("active") is True:
286
+ return Session(str(d["pid"]), str(d["user_id"]), str(d["public_name"]), token, int(d["expires_at"]), str(d["user_agent"]))
287
+
288
+ return None
289
+
290
+ def logout(self, *, token: str) -> None:
291
+ d = self._post(self.logout_url, {"token": token})
292
+ if d.get("ok"):
293
+ return
294
+ self._raise_from_error_code(str(d.get("error") or ""))
295
+
296
+ def _raise_from_error_code(self, code: str) -> None:
297
+ if code == "A01":
298
+ raise AuthMissingFieldError("unknown")
299
+ if code == "A02":
300
+ raise AuthUserIdAlreadyExistsError()
301
+ if code == "A03":
302
+ raise AuthInvalidCredentialsError()
303
+ if code == "A04":
304
+ raise AuthUserDisabledError()
305
+ if code == "A05":
306
+ raise AuthTokenMissingError()
307
+ if code == "A06":
308
+ raise AuthTokenInvalidError()
309
+ if code == "A07":
310
+ raise AuthTokenExpiredError()
311
+ if code == "A08":
312
+ raise AuthTokenRevokedError()
313
+ if code == "A09":
314
+ raise AuthServiceUnavailableError()
315
+
316
+ # 想定外レスポンス
317
+ raise AuthServiceUnavailableError()
318
+
319
+
320
+ # --------------------
321
+ # DB
322
+ # --------------------
323
+
324
+ class AuthDBM(DatabaseManager):
325
+ def _init(self) -> None:
326
+ self.execute_many(
327
+ [
328
+ (
329
+ """
330
+ CREATE TABLE IF NOT EXISTS users (
331
+ uid TEXT PRIMARY KEY,
332
+ user_id TEXT UNIQUE NOT NULL,
333
+ public_name TEXT NOT NULL,
334
+ password_hash TEXT NOT NULL,
335
+ password_salt TEXT NOT NULL,
336
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
337
+ is_active INTEGER NOT NULL DEFAULT 1
338
+ );
339
+ """,
340
+ (),
341
+ ),
342
+ (
343
+ """
344
+ CREATE TABLE IF NOT EXISTS sessions (
345
+ sid TEXT PRIMARY KEY,
346
+ uid TEXT NOT NULL REFERENCES users(uid),
347
+ token_hash TEXT UNIQUE NOT NULL,
348
+ expires_at INTEGER NOT NULL,
349
+ revoked_at INTEGER,
350
+ user_agent TEXT
351
+ );
352
+ """,
353
+ (),
354
+ ),
355
+ ]
356
+ )
357
+
358
+ def signup(self, user_id: str, public_name: str, password: str) -> None:
359
+ if not user_id:
360
+ raise AuthMissingFieldError("user_id")
361
+ if not public_name:
362
+ raise AuthMissingFieldError("public_name")
363
+ if not password:
364
+ raise AuthMissingFieldError("password")
365
+
366
+ salt = _make_salt()
367
+ uid = _rand()
368
+
369
+ try:
370
+ self.execute(
371
+ "INSERT INTO users VALUES(?,?,?,?,?,?,?)",
372
+ uid,
373
+ user_id,
374
+ public_name,
375
+ _hash_password(password, salt),
376
+ salt,
377
+ None,
378
+ 1,
379
+ )
380
+ except DBIntegrityError:
381
+ raise AuthUserIdAlreadyExistsError()
382
+ except Exception as e:
383
+ raise AuthServiceUnavailableError()
384
+
385
+ def login(self, user_id: str, password: str, *, user_agent: str | None, ttl_sec: int) -> LocalSession:
386
+ if not user_id:
387
+ raise AuthMissingFieldError("user_id")
388
+ if not password:
389
+ raise AuthMissingFieldError("password")
390
+
391
+ rows = self.execute(
392
+ "SELECT uid, user_id, public_name, password_hash, password_salt, is_active FROM users WHERE user_id=?",
393
+ user_id,
394
+ )
395
+ if not rows:
396
+ raise AuthInvalidCredentialsError()
397
+
398
+ uid, user_id, public_name, pw_hash, salt, active = rows[0]
399
+ if not active:
400
+ raise AuthUserDisabledError()
401
+
402
+ if not hmac.compare_digest(_hash_password(password, str(salt)), str(pw_hash)):
403
+ raise AuthInvalidCredentialsError()
404
+
405
+ token = _rand()
406
+ exp = _now() + ttl_sec
407
+ sid = _rand()
408
+
409
+ self.execute(
410
+ "INSERT INTO sessions VALUES(?,?,?,?,?,?)",
411
+ sid,
412
+ uid,
413
+ _token_hash(token),
414
+ exp,
415
+ None,
416
+ user_agent,
417
+ )
418
+
419
+ return LocalSession(sid, uid, user_id, public_name, token, exp, None, user_agent)
420
+
421
+ def logout(self, token: str) -> None:
422
+ if not token:
423
+ raise AuthMissingFieldError("token")
424
+
425
+ self.execute(
426
+ "UPDATE sessions SET revoked_at=? WHERE token_hash=?",
427
+ _now(),
428
+ _token_hash(token),
429
+ )
430
+
431
+ def verify(self, token: str | None) -> LocalSession | None:
432
+ if not token:
433
+ return None
434
+
435
+ rows = self.execute(
436
+ """
437
+ SELECT s.sid, s.uid, u.user_id, u.public_name, s.expires_at, s.revoked_at, s.user_agent
438
+ FROM sessions s
439
+ JOIN users u ON u.uid=s.uid
440
+ WHERE s.token_hash=?
441
+ """,
442
+ _token_hash(token),
443
+ )
444
+ if not rows:
445
+ return None
446
+
447
+ sid, uid, user_id, public_name, exp, rev, ua = rows[0]
448
+ if rev or int(exp) <= _now():
449
+ return None
450
+
451
+ return LocalSession(str(sid), str(uid), str(user_id), str(public_name), str(token), int(exp), None, ua)
@@ -1,9 +1,35 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from typing import Any, Iterable
4
- from sqlite3 import connect, Connection, Cursor, Error
5
-
6
- from ..core.error import DBMConnectionInvalidError, DBError
4
+ from sqlite3 import (
5
+ connect,
6
+ Connection,
7
+ Cursor,
8
+
9
+ Error,
10
+ OperationalError,
11
+ IntegrityError,
12
+ ProgrammingError
13
+ )
14
+
15
+ from ..core.error import (
16
+ DBError,
17
+
18
+ DBMConnectionInvalidError,
19
+ DBOperationalError,
20
+ DBIntegrityError,
21
+ DBProgrammingError
22
+ )
23
+
24
+ def _call_error_handler(e: Error):
25
+ if isinstance(e, OperationalError):
26
+ raise DBOperationalError(str(e))
27
+ elif isinstance(e, ProgrammingError):
28
+ raise DBProgrammingError(str(e))
29
+ elif isinstance(e, IntegrityError):
30
+ raise DBIntegrityError(str(e))
31
+ else:
32
+ raise DBMConnectionInvalidError(str(e))
7
33
 
8
34
 
9
35
  class DatabaseManager:
@@ -39,7 +65,7 @@ class DatabaseManager:
39
65
 
40
66
  self.commit()
41
67
  except Error as e:
42
- raise DBMConnectionInvalidError(str(e))
68
+ _call_error_handler(e)
43
69
 
44
70
  def rip_connection(self) -> None:
45
71
  if self._conn is None:
@@ -70,7 +96,8 @@ class DatabaseManager:
70
96
 
71
97
  except Error as e:
72
98
  self._conn.rollback()
73
- raise DBError(str(e))
99
+ _call_error_handler(e)
100
+
74
101
 
75
102
  def execute_many(self, sql_inserts: Iterable[ list[ tuple[str, tuple] ] ]) -> None:
76
103
  if self._conn is None or self._cursor is None:
@@ -85,4 +112,5 @@ class DatabaseManager:
85
112
 
86
113
  except Error as e:
87
114
  self._conn.rollback()
88
- raise DBError(str(e))
115
+ _call_error_handler(e)
116
+
@@ -84,7 +84,7 @@ class Path:
84
84
  return res
85
85
 
86
86
  except TypeError as e:
87
- # handler の引数不足だけは明示的に
87
+ # handler の引数不足
88
88
  if re.search(r"takes \d+ positional arguments? but \d+ were given", str(e)):
89
89
  raise PathHandlerMissingArgError()
90
90
  raise
@@ -148,7 +148,7 @@ class Static(Path):
148
148
  # Pathlib
149
149
  # ====================
150
150
 
151
- class Pathlib(list[Path]):
151
+ class Router(list[Path]):
152
152
  """Collection of Path objects with middleware support."""
153
153
 
154
154
  def __init__(self, *paths: Path) -> None: