Nexom 0.1.3__py3-none-any.whl → 1.0.1__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.
- nexom/__init__.py +2 -2
- nexom/__main__.py +111 -17
- nexom/app/__init__.py +62 -0
- nexom/app/auth.py +322 -0
- nexom/{web → app}/cookie.py +4 -2
- nexom/app/db.py +88 -0
- nexom/app/path.py +195 -0
- nexom/app/request.py +267 -0
- nexom/{web → app}/response.py +13 -3
- nexom/{web → app}/template.py +1 -1
- nexom/app/user.py +31 -0
- nexom/assets/app/__init__.py +0 -0
- nexom/assets/app/__pycache__/__init__.cpython-313.pyc +0 -0
- nexom/assets/app/config.py +28 -0
- nexom/assets/app/gunicorn.conf.py +5 -0
- nexom/assets/app/pages/__pycache__/__init__.cpython-313.pyc +0 -0
- nexom/assets/app/pages/_templates.py +7 -0
- nexom/assets/{server → app}/pages/default.py +2 -2
- nexom/assets/{server → app}/pages/document.py +2 -2
- nexom/assets/app/router.py +12 -0
- nexom/assets/app/wsgi.py +64 -0
- nexom/assets/auth/__init__.py +0 -0
- nexom/assets/auth/__pycache__/__init__.cpython-313.pyc +0 -0
- nexom/assets/auth/config.py +27 -0
- nexom/assets/auth/gunicorn.conf.py +5 -0
- nexom/assets/auth/wsgi.py +62 -0
- nexom/assets/auth_page/login.html +95 -0
- nexom/assets/auth_page/signup.html +106 -0
- nexom/assets/error_page/error.html +3 -3
- nexom/assets/gateway/apache_app.conf +16 -0
- nexom/assets/gateway/nginx_app.conf +21 -0
- nexom/buildTools/__init__.py +1 -0
- nexom/buildTools/build.py +274 -54
- nexom/buildTools/run.py +185 -0
- nexom/core/__init__.py +2 -1
- nexom/core/error.py +81 -3
- nexom/core/log.py +111 -0
- nexom/{engine → core}/object_html_render.py +4 -1
- nexom/templates/__init__.py +0 -0
- nexom/templates/auth.py +72 -0
- {nexom-0.1.3.dist-info → nexom-1.0.1.dist-info}/METADATA +75 -50
- nexom-1.0.1.dist-info/RECORD +56 -0
- {nexom-0.1.3.dist-info → nexom-1.0.1.dist-info}/WHEEL +1 -1
- nexom/assets/server/config.py +0 -27
- nexom/assets/server/gunicorn.conf.py +0 -16
- nexom/assets/server/pages/__pycache__/__init__.cpython-313.pyc +0 -0
- nexom/assets/server/pages/_templates.py +0 -11
- nexom/assets/server/router.py +0 -18
- nexom/assets/server/wsgi.py +0 -30
- nexom/engine/__init__.py +0 -1
- nexom/web/__init__.py +0 -5
- nexom/web/path.py +0 -125
- nexom/web/request.py +0 -62
- nexom-0.1.3.dist-info/RECORD +0 -39
- /nexom/{web → app}/http_status_codes.py +0 -0
- /nexom/{web → app}/middleware.py +0 -0
- /nexom/assets/{server → app}/pages/__init__.py +0 -0
- /nexom/assets/{server → app}/static/dog.jpeg +0 -0
- /nexom/assets/{server → app}/static/style.css +0 -0
- /nexom/assets/{server → app}/templates/base.html +0 -0
- /nexom/assets/{server → app}/templates/default.html +0 -0
- /nexom/assets/{server → app}/templates/document.html +0 -0
- /nexom/assets/{server → app}/templates/footer.html +0 -0
- /nexom/assets/{server → app}/templates/header.html +0 -0
- {nexom-0.1.3.dist-info → nexom-1.0.1.dist-info}/entry_points.txt +0 -0
- {nexom-0.1.3.dist-info → nexom-1.0.1.dist-info}/licenses/LICENSE +0 -0
- {nexom-0.1.3.dist-info → nexom-1.0.1.dist-info}/top_level.txt +0 -0
nexom/__init__.py
CHANGED
|
@@ -7,8 +7,8 @@ WSGI-based web applications with minimal overhead.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from nexom.
|
|
11
|
-
from nexom.
|
|
10
|
+
from nexom.app.request import Request
|
|
11
|
+
from nexom.app.response import Response
|
|
12
12
|
|
|
13
13
|
__all__ = [
|
|
14
14
|
"Request",
|
nexom/__main__.py
CHANGED
|
@@ -4,13 +4,18 @@ import argparse
|
|
|
4
4
|
import sys
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
-
from nexom.buildTools.build import
|
|
8
|
-
|
|
7
|
+
from nexom.buildTools.build import (
|
|
8
|
+
create_app,
|
|
9
|
+
create_auth,
|
|
10
|
+
start_project,
|
|
11
|
+
AppBuildOptions,
|
|
12
|
+
)
|
|
13
|
+
from nexom.buildTools.run import run_project
|
|
9
14
|
|
|
10
15
|
|
|
11
16
|
def main(argv: list[str] | None = None) -> None:
|
|
12
17
|
parser = argparse.ArgumentParser(
|
|
13
|
-
prog="
|
|
18
|
+
prog="naxom",
|
|
14
19
|
description="Nexom Web Framework CLI",
|
|
15
20
|
)
|
|
16
21
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
@@ -18,21 +23,63 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
18
23
|
# test
|
|
19
24
|
subparsers.add_parser("test", help="Test Nexom installation")
|
|
20
25
|
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
# run
|
|
27
|
+
pr = subparsers.add_parser("run", help="Run WSGI apps found in the current project directory")
|
|
28
|
+
pr.add_argument("apps", nargs="*", help="App directory names to run (default: all detected apps)")
|
|
29
|
+
pr.add_argument("--root", default=".", help="Project root directory (default: .)")
|
|
30
|
+
pr.add_argument("--dry-run", action="store_true", help="Print commands only (do not start processes)")
|
|
31
|
+
|
|
32
|
+
# start-project
|
|
33
|
+
sp = subparsers.add_parser(
|
|
34
|
+
"start-project",
|
|
35
|
+
help="Initialize a Nexom project in the current directory (creates app/auth/data[/gateway])",
|
|
25
36
|
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
sp.add_argument("--root", default=".", help="Project root directory (default: .)")
|
|
38
|
+
sp.add_argument("--main-name", default="app", help="Main app directory name (default: app)")
|
|
39
|
+
sp.add_argument("--auth-name", default="auth", help="Auth app directory name (default: auth)")
|
|
40
|
+
sp.add_argument(
|
|
41
|
+
"--gateway",
|
|
42
|
+
choices=["none", "nginx", "apache"],
|
|
43
|
+
default="none",
|
|
44
|
+
help="Create gateway/ and put a template config (default: none)",
|
|
31
45
|
)
|
|
46
|
+
sp.add_argument("--domain", default="", help="Domain for gateway template (default: placeholder text)")
|
|
47
|
+
|
|
48
|
+
# ports etc (main)
|
|
49
|
+
sp.add_argument("--address", default="0.0.0.0", help="Bind address for main app (default: 0.0.0.0)")
|
|
50
|
+
sp.add_argument("--port", type=int, default=8080, help="Bind port for main app (default: 8080)")
|
|
51
|
+
sp.add_argument("--workers", type=int, default=4, help="Gunicorn workers for main app (default: 4)")
|
|
52
|
+
sp.add_argument("--reload", action="store_true", help="Enable auto-reload for main app (development)")
|
|
53
|
+
|
|
54
|
+
# ports etc (auth)
|
|
55
|
+
sp.add_argument("--auth-address", default="127.0.0.1", help="Bind address for auth app (default: 0.0.0.0)")
|
|
56
|
+
sp.add_argument("--auth-port", type=int, default=7070, help="Bind port for auth app (default: 7070)")
|
|
57
|
+
sp.add_argument("--auth-workers", type=int, default=4, help="Gunicorn workers for auth app (default: 4)")
|
|
58
|
+
sp.add_argument("--auth-reload", action="store_true", help="Enable auto-reload for auth app (development)")
|
|
59
|
+
|
|
60
|
+
# create-auth
|
|
61
|
+
pa = subparsers.add_parser("create-auth", help="Create a Nexom auth app project")
|
|
62
|
+
pa.add_argument("--out", default=".", help="Output directory (default: current directory)")
|
|
63
|
+
pa.add_argument("--address", default="0.0.0.0", help="Bind address (default: 0.0.0.0)")
|
|
64
|
+
pa.add_argument("--port", type=int, default=7070, help="Bind port (default: 7070)")
|
|
65
|
+
pa.add_argument("--workers", type=int, default=4, help="Gunicorn workers (default: 4)")
|
|
66
|
+
pa.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
|
|
67
|
+
|
|
68
|
+
# create-app
|
|
69
|
+
p = subparsers.add_parser("create-app", help="Create a Nexom app project")
|
|
70
|
+
p.add_argument("app_name", help="App project name")
|
|
71
|
+
p.add_argument("--out", default=".", help="Output directory (default: current directory)")
|
|
32
72
|
p.add_argument("--address", default="0.0.0.0", help="Bind address (default: 0.0.0.0)")
|
|
33
73
|
p.add_argument("--port", type=int, default=8080, help="Bind port (default: 8080)")
|
|
34
74
|
p.add_argument("--workers", type=int, default=4, help="Gunicorn workers (default: 4)")
|
|
35
75
|
p.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
|
|
76
|
+
p.add_argument(
|
|
77
|
+
"--gateway-config",
|
|
78
|
+
choices=["nginx", "apache"],
|
|
79
|
+
default="",
|
|
80
|
+
help="If gateway/ exists, write a config template for this app (nginx/apache)",
|
|
81
|
+
)
|
|
82
|
+
p.add_argument("--domain", default="", help="Domain for gateway template (default: placeholder text)")
|
|
36
83
|
|
|
37
84
|
args = parser.parse_args(argv)
|
|
38
85
|
|
|
@@ -40,22 +87,69 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
40
87
|
print("Hello Nexom Web Framework!")
|
|
41
88
|
return
|
|
42
89
|
|
|
43
|
-
if args.command == "
|
|
44
|
-
|
|
90
|
+
if args.command == "run":
|
|
91
|
+
run_project(Path(args.root), list(args.apps), dry_run=bool(args.dry_run))
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if args.command == "start-project":
|
|
95
|
+
main_opt = AppBuildOptions(
|
|
96
|
+
address=args.address,
|
|
97
|
+
port=args.port,
|
|
98
|
+
workers=args.workers,
|
|
99
|
+
reload=args.reload,
|
|
100
|
+
)
|
|
101
|
+
auth_opt = AppBuildOptions(
|
|
102
|
+
address=args.auth_address,
|
|
103
|
+
port=args.auth_port,
|
|
104
|
+
workers=args.auth_workers,
|
|
105
|
+
reload=args.auth_reload,
|
|
106
|
+
)
|
|
107
|
+
out = start_project(
|
|
108
|
+
project_root=Path(args.root),
|
|
109
|
+
main_name=args.main_name,
|
|
110
|
+
auth_name=args.auth_name,
|
|
111
|
+
main_options=main_opt,
|
|
112
|
+
auth_options=auth_opt,
|
|
113
|
+
gateway=args.gateway,
|
|
114
|
+
domain=args.domain,
|
|
115
|
+
)
|
|
116
|
+
print(f"Initialized Nexom project at: {out}")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
if args.command == "create-app":
|
|
120
|
+
options = AppBuildOptions(
|
|
121
|
+
address=args.address,
|
|
122
|
+
port=args.port,
|
|
123
|
+
workers=args.workers,
|
|
124
|
+
reload=args.reload,
|
|
125
|
+
)
|
|
126
|
+
out_dir = create_app(
|
|
127
|
+
Path(args.out),
|
|
128
|
+
args.app_name,
|
|
129
|
+
options=options,
|
|
130
|
+
gateway_config=(args.gateway_config or None),
|
|
131
|
+
domain=args.domain,
|
|
132
|
+
)
|
|
133
|
+
print(f"Created Nexom app project at: {out_dir}")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
if args.command == "create-auth":
|
|
137
|
+
options = AppBuildOptions(
|
|
45
138
|
address=args.address,
|
|
46
139
|
port=args.port,
|
|
47
140
|
workers=args.workers,
|
|
48
141
|
reload=args.reload,
|
|
49
142
|
)
|
|
50
|
-
out_dir =
|
|
51
|
-
print(f"Created Nexom
|
|
143
|
+
out_dir = create_auth(Path(args.out), options=options)
|
|
144
|
+
print(f"Created Nexom auth app project at: {out_dir}")
|
|
52
145
|
return
|
|
53
146
|
|
|
54
147
|
|
|
55
148
|
if __name__ == "__main__":
|
|
56
149
|
try:
|
|
57
150
|
main()
|
|
151
|
+
except KeyboardInterrupt:
|
|
152
|
+
sys.exit(130)
|
|
58
153
|
except Exception as e:
|
|
59
|
-
# CLI では stacktrace よりまずメッセージ優先(必要なら後で --verbose とか足す)
|
|
60
154
|
print(f"Error: {e}", file=sys.stderr)
|
|
61
155
|
sys.exit(1)
|
nexom/app/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nexom application layer public API.
|
|
3
|
+
|
|
4
|
+
This module exposes the stable interfaces intended for application developers.
|
|
5
|
+
Internal implementation details should NOT be imported directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# ---- Request / Response ----
|
|
9
|
+
from .request import Request
|
|
10
|
+
from .response import (
|
|
11
|
+
Response,
|
|
12
|
+
HtmlResponse,
|
|
13
|
+
JsonResponse,
|
|
14
|
+
Redirect,
|
|
15
|
+
ErrorResponse,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# ---- Routing ----
|
|
19
|
+
from .path import Path, Static, Pathlib
|
|
20
|
+
|
|
21
|
+
# ---- Cookie ----
|
|
22
|
+
from .cookie import Cookie, RequestCookies
|
|
23
|
+
|
|
24
|
+
# ---- Templates ----
|
|
25
|
+
from .template import ObjectHTMLTemplates
|
|
26
|
+
|
|
27
|
+
# ---- Auth ----
|
|
28
|
+
from .auth import AuthService, AuthClient
|
|
29
|
+
|
|
30
|
+
# ---- Middleware ----
|
|
31
|
+
from .middleware import Middleware, MiddlewareChain
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
# request / response
|
|
36
|
+
"Request",
|
|
37
|
+
"Response",
|
|
38
|
+
"HtmlResponse",
|
|
39
|
+
"JsonResponse",
|
|
40
|
+
"Redirect",
|
|
41
|
+
"ErrorResponse",
|
|
42
|
+
|
|
43
|
+
# routing
|
|
44
|
+
"Path",
|
|
45
|
+
"Static",
|
|
46
|
+
"Pathlib",
|
|
47
|
+
|
|
48
|
+
# cookie
|
|
49
|
+
"Cookie",
|
|
50
|
+
"RequestCookies",
|
|
51
|
+
|
|
52
|
+
# templates
|
|
53
|
+
"ObjectHTMLTemplates",
|
|
54
|
+
|
|
55
|
+
# auth
|
|
56
|
+
"AuthService",
|
|
57
|
+
"AuthVerify",
|
|
58
|
+
|
|
59
|
+
# middleware
|
|
60
|
+
"Middleware",
|
|
61
|
+
"MiddlewareChain",
|
|
62
|
+
]
|
nexom/app/auth.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# src/nexom/app/auth.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional, override
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
from urllib.request import Request as UrlRequest, urlopen
|
|
12
|
+
from urllib.error import URLError, HTTPError
|
|
13
|
+
|
|
14
|
+
from .request import Request
|
|
15
|
+
from .response import JsonResponse
|
|
16
|
+
from .db import DatabaseManager
|
|
17
|
+
from .path import Path, Pathlib
|
|
18
|
+
from ..core.log import AuthLogger
|
|
19
|
+
|
|
20
|
+
from ..core.error import (
|
|
21
|
+
NexomError,
|
|
22
|
+
AuthMissingFieldError,
|
|
23
|
+
AuthInvalidCredentialsError,
|
|
24
|
+
AuthUserDisabledError,
|
|
25
|
+
AuthTokenInvalidError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --------------------
|
|
30
|
+
# utils
|
|
31
|
+
# --------------------
|
|
32
|
+
|
|
33
|
+
def _now() -> int:
|
|
34
|
+
return int(time.time())
|
|
35
|
+
|
|
36
|
+
def _rand(nbytes: int = 24) -> str:
|
|
37
|
+
return secrets.token_urlsafe(nbytes)
|
|
38
|
+
|
|
39
|
+
def _make_salt(nbytes: int = 16) -> str:
|
|
40
|
+
return secrets.token_hex(nbytes)
|
|
41
|
+
|
|
42
|
+
def _hash_password(password: str, salt_hex: str) -> str:
|
|
43
|
+
salt = bytes.fromhex(salt_hex)
|
|
44
|
+
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 200_000)
|
|
45
|
+
return dk.hex()
|
|
46
|
+
|
|
47
|
+
def _token_hash(token: str) -> str:
|
|
48
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
49
|
+
|
|
50
|
+
# --------------------
|
|
51
|
+
# models (internal)
|
|
52
|
+
# --------------------
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Session:
|
|
56
|
+
sid: str
|
|
57
|
+
uid: str
|
|
58
|
+
user_id: str
|
|
59
|
+
token: str
|
|
60
|
+
expires_at: int
|
|
61
|
+
revoked_at: int | None
|
|
62
|
+
user_agent: str | None
|
|
63
|
+
|
|
64
|
+
# --------------------
|
|
65
|
+
# AuthService (API only)
|
|
66
|
+
# --------------------
|
|
67
|
+
|
|
68
|
+
class AuthService:
|
|
69
|
+
"""
|
|
70
|
+
Auth API service (JSON only).
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, db_path: str, log_path: str, *, ttl_sec: int = 60 * 60 * 24 * 7, prefix: str = "") -> None:
|
|
74
|
+
self.dbm = AuthDBM(db_path)
|
|
75
|
+
self.ttl_sec = ttl_sec
|
|
76
|
+
|
|
77
|
+
p = prefix.strip("/")
|
|
78
|
+
def _p(x: str) -> str:
|
|
79
|
+
return f"{p}/{x}".strip("/") if p else x
|
|
80
|
+
|
|
81
|
+
self.routing = Pathlib(
|
|
82
|
+
Path(_p("signup"), self.signup, "AuthSignup"),
|
|
83
|
+
Path(_p("login"), self.login, "AuthLogin"),
|
|
84
|
+
Path(_p("logout"), self.logout, "AuthLogout"),
|
|
85
|
+
Path(_p("verify"), self.verify, "AuthVerify"),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
self.logger = AuthLogger(log_path)
|
|
89
|
+
|
|
90
|
+
def handler(self, environ: dict) -> JsonResponse:
|
|
91
|
+
req = Request(environ)
|
|
92
|
+
try:
|
|
93
|
+
route = self.routing.get(req.path)
|
|
94
|
+
return route.call_handler(req)
|
|
95
|
+
except NexomError as e:
|
|
96
|
+
return JsonResponse({"ok": False, "error": e.code}, status=400)
|
|
97
|
+
except Exception:
|
|
98
|
+
return JsonResponse({"ok": False, "error": "InternalError"}, status=500)
|
|
99
|
+
|
|
100
|
+
# ---- handlers ----
|
|
101
|
+
|
|
102
|
+
def signup(self, request: Request, args: dict[str, Optional[str]]) -> JsonResponse:
|
|
103
|
+
if request.method != "POST":
|
|
104
|
+
return JsonResponse({"ok": False}, status=405)
|
|
105
|
+
|
|
106
|
+
data = request.json() or {}
|
|
107
|
+
self.dbm.signup(
|
|
108
|
+
user_id=str(data.get("user_id") or "").strip(),
|
|
109
|
+
public_name=str(data.get("public_name") or "").strip(),
|
|
110
|
+
password=str(data.get("password") or ""),
|
|
111
|
+
)
|
|
112
|
+
return JsonResponse({"ok": True}, status=201)
|
|
113
|
+
|
|
114
|
+
def login(self, request: Request, args: dict[str, Optional[str]]) -> JsonResponse:
|
|
115
|
+
if request.method != "POST":
|
|
116
|
+
return JsonResponse({"ok": False}, status=405)
|
|
117
|
+
|
|
118
|
+
data = request.json() or {}
|
|
119
|
+
sess = self.dbm.login(
|
|
120
|
+
str(data.get("user_id") or "").strip(),
|
|
121
|
+
str(data.get("password") or ""),
|
|
122
|
+
user_agent=request.headers.get("user-agent"),
|
|
123
|
+
ttl_sec=self.ttl_sec,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return JsonResponse({
|
|
127
|
+
"ok": True,
|
|
128
|
+
"user_id": sess.user_id,
|
|
129
|
+
"token": sess.token,
|
|
130
|
+
"expires_at": sess.expires_at,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
def logout(self, request: Request, args: dict[str, Optional[str]]) -> JsonResponse:
|
|
134
|
+
if request.method != "POST":
|
|
135
|
+
return JsonResponse({"ok": False}, status=405)
|
|
136
|
+
|
|
137
|
+
token = str((request.json() or {}).get("token") or "")
|
|
138
|
+
if token:
|
|
139
|
+
self.dbm.logout(token)
|
|
140
|
+
return JsonResponse({"ok": True})
|
|
141
|
+
|
|
142
|
+
def verify(self, request: Request, args: dict[str, Optional[str]]) -> JsonResponse:
|
|
143
|
+
if request.method != "POST":
|
|
144
|
+
return JsonResponse({"ok": False}, status=405)
|
|
145
|
+
|
|
146
|
+
token = str((request.json() or {}).get("token") or "")
|
|
147
|
+
sess = self.dbm.verify(token)
|
|
148
|
+
if not sess:
|
|
149
|
+
return JsonResponse({"active": False})
|
|
150
|
+
|
|
151
|
+
return JsonResponse({
|
|
152
|
+
"active": True,
|
|
153
|
+
"user_id": sess.user_id,
|
|
154
|
+
"expires_at": sess.expires_at,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
# --------------------
|
|
158
|
+
# AuthClient (App側)
|
|
159
|
+
# --------------------
|
|
160
|
+
|
|
161
|
+
class AuthClient:
|
|
162
|
+
"""AuthService を HTTP で叩くクライアント"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, auth_url: str, *, timeout: float = 3.0) -> None:
|
|
165
|
+
base = auth_url.rstrip("/")
|
|
166
|
+
self.signup_url = base + "/signup"
|
|
167
|
+
self.login_url = base + "/login"
|
|
168
|
+
self.logout_url = base + "/logout"
|
|
169
|
+
self.verify_url = base + "/verify"
|
|
170
|
+
self.timeout = timeout
|
|
171
|
+
|
|
172
|
+
def _post(self, url: str, body: dict) -> dict:
|
|
173
|
+
payload = json.dumps(body).encode("utf-8")
|
|
174
|
+
req = UrlRequest(
|
|
175
|
+
url,
|
|
176
|
+
data=payload,
|
|
177
|
+
headers={"Content-Type": "application/json"},
|
|
178
|
+
method="POST",
|
|
179
|
+
)
|
|
180
|
+
try:
|
|
181
|
+
with urlopen(req, timeout=self.timeout) as r:
|
|
182
|
+
data = json.loads(r.read().decode("utf-8"))
|
|
183
|
+
except (HTTPError, URLError, json.JSONDecodeError) as e:
|
|
184
|
+
print(str(e))
|
|
185
|
+
raise AuthTokenInvalidError()
|
|
186
|
+
return data
|
|
187
|
+
|
|
188
|
+
def signup(self, *, user_id: str, public_name: str, password: str) -> bool:
|
|
189
|
+
return bool(self._post(self.signup_url, {
|
|
190
|
+
"user_id": user_id,
|
|
191
|
+
"public_name": public_name,
|
|
192
|
+
"password": password,
|
|
193
|
+
}).get("ok"))
|
|
194
|
+
|
|
195
|
+
def login(self, *, user_id: str, password: str) -> tuple[str, str, int]:
|
|
196
|
+
d = self._post(self.login_url, {"user_id": user_id, "password": password})
|
|
197
|
+
return d["token"], d["user_id"], d["expires_at"]
|
|
198
|
+
|
|
199
|
+
def verify_token(self, *, token: str) -> tuple[bool, Optional[str], Optional[int]]:
|
|
200
|
+
d = self._post(self.verify_url, {"token": token})
|
|
201
|
+
if not d.get("active"):
|
|
202
|
+
return False, None, None
|
|
203
|
+
return True, d["user_id"], d["expires_at"]
|
|
204
|
+
|
|
205
|
+
def logout(self, *, token: str) -> bool:
|
|
206
|
+
return bool(self._post(self.logout_url, {"token": token}).get("ok"))
|
|
207
|
+
|
|
208
|
+
# --------------------
|
|
209
|
+
# DB
|
|
210
|
+
# --------------------
|
|
211
|
+
|
|
212
|
+
class AuthDBM(DatabaseManager):
|
|
213
|
+
@override
|
|
214
|
+
def _init(self) -> None:
|
|
215
|
+
self.execute_many(
|
|
216
|
+
[
|
|
217
|
+
(
|
|
218
|
+
"""
|
|
219
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
220
|
+
uid TEXT PRIMARY KEY,
|
|
221
|
+
user_id TEXT UNIQUE NOT NULL,
|
|
222
|
+
public_name TEXT NOT NULL,
|
|
223
|
+
password_hash TEXT NOT NULL,
|
|
224
|
+
password_salt TEXT NOT NULL,
|
|
225
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
226
|
+
is_active INTEGER NOT NULL DEFAULT 1
|
|
227
|
+
);
|
|
228
|
+
""",
|
|
229
|
+
(),
|
|
230
|
+
),
|
|
231
|
+
(
|
|
232
|
+
"""
|
|
233
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
234
|
+
sid TEXT PRIMARY KEY,
|
|
235
|
+
uid TEXT NOT NULL REFERENCES users(uid),
|
|
236
|
+
token_hash TEXT UNIQUE NOT NULL,
|
|
237
|
+
expires_at INTEGER NOT NULL,
|
|
238
|
+
revoked_at INTEGER,
|
|
239
|
+
user_agent TEXT
|
|
240
|
+
);
|
|
241
|
+
""",
|
|
242
|
+
(),
|
|
243
|
+
)
|
|
244
|
+
]
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def signup(self, user_id: str, public_name: str, password: str) -> None:
|
|
248
|
+
if not user_id:
|
|
249
|
+
raise AuthMissingFieldError("user_id")
|
|
250
|
+
if not public_name:
|
|
251
|
+
raise AuthMissingFieldError("public_name")
|
|
252
|
+
if not password:
|
|
253
|
+
raise AuthMissingFieldError("password")
|
|
254
|
+
|
|
255
|
+
salt = _make_salt()
|
|
256
|
+
self.execute(
|
|
257
|
+
"INSERT INTO users VALUES(?,?,?,?,?,?,?)",
|
|
258
|
+
_rand(),
|
|
259
|
+
user_id,
|
|
260
|
+
public_name,
|
|
261
|
+
_hash_password(password, salt),
|
|
262
|
+
salt,
|
|
263
|
+
None,
|
|
264
|
+
1,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def login(self, user_id: str, password: str, *, user_agent: str | None, ttl_sec: int) -> Session:
|
|
268
|
+
rows = self.execute(
|
|
269
|
+
"SELECT uid, user_id, password_hash, password_salt, is_active FROM users WHERE user_id=?",
|
|
270
|
+
user_id,
|
|
271
|
+
)
|
|
272
|
+
if not rows:
|
|
273
|
+
raise AuthInvalidCredentialsError()
|
|
274
|
+
|
|
275
|
+
uid, uid_text, pw_hash, salt, active = rows[0]
|
|
276
|
+
if not active:
|
|
277
|
+
raise AuthUserDisabledError()
|
|
278
|
+
if not hmac.compare_digest(_hash_password(password, salt), pw_hash):
|
|
279
|
+
raise AuthInvalidCredentialsError()
|
|
280
|
+
|
|
281
|
+
token = _rand()
|
|
282
|
+
exp = _now() + ttl_sec
|
|
283
|
+
|
|
284
|
+
self.execute(
|
|
285
|
+
"INSERT INTO sessions VALUES(?,?,?,?,?,?)",
|
|
286
|
+
_rand(),
|
|
287
|
+
uid,
|
|
288
|
+
_token_hash(token),
|
|
289
|
+
exp,
|
|
290
|
+
None,
|
|
291
|
+
user_agent,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
return Session("", uid, uid_text, token, exp, None, user_agent)
|
|
295
|
+
|
|
296
|
+
def logout(self, token: str) -> None:
|
|
297
|
+
self.execute(
|
|
298
|
+
"UPDATE sessions SET revoked_at=? WHERE token_hash=?",
|
|
299
|
+
_now(),
|
|
300
|
+
_token_hash(token),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def verify(self, token: str | None) -> Session | None:
|
|
304
|
+
if not token:
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
rows = self.execute(
|
|
308
|
+
"""
|
|
309
|
+
SELECT s.sid, s.uid, u.user_id, s.expires_at, s.revoked_at, s.user_agent
|
|
310
|
+
FROM sessions s JOIN users u ON u.uid=s.uid
|
|
311
|
+
WHERE s.token_hash=?
|
|
312
|
+
""",
|
|
313
|
+
_token_hash(token),
|
|
314
|
+
)
|
|
315
|
+
if not rows:
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
sid, uid, user_id, exp, rev, ua = rows[0]
|
|
319
|
+
if rev or exp <= _now():
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
return Session(sid, uid, user_id, token, exp, None, ua)
|
nexom/{web → app}/cookie.py
RENAMED
|
@@ -69,5 +69,7 @@ class RequestCookies(dict[str, str | None]):
|
|
|
69
69
|
super().__init__(kwargs)
|
|
70
70
|
self.default: str | None = None
|
|
71
71
|
|
|
72
|
-
def get(self, key: str) -> str | None:
|
|
73
|
-
|
|
72
|
+
def get(self, key: str, default: str | None = None) -> str | None:
|
|
73
|
+
if default is None:
|
|
74
|
+
default = self.default
|
|
75
|
+
return super().get(key, default)
|
nexom/app/db.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable
|
|
4
|
+
from sqlite3 import connect, Connection, Cursor, Error
|
|
5
|
+
|
|
6
|
+
from ..core.error import DBMConnectionInvalidError, DBError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DatabaseManager:
|
|
10
|
+
def __init__(self, db_file: str, auto_commit: bool = True):
|
|
11
|
+
self.db_file: str = db_file
|
|
12
|
+
self.auto_commit: bool = auto_commit
|
|
13
|
+
|
|
14
|
+
self._conn: Connection | None = None
|
|
15
|
+
self._cursor: Cursor | None = None
|
|
16
|
+
|
|
17
|
+
self.start_connection(auto_commit=auto_commit)
|
|
18
|
+
self._init()
|
|
19
|
+
|
|
20
|
+
def _init(self) -> None:
|
|
21
|
+
"for override"
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def start_connection(self, auto_commit: bool = True) -> None:
|
|
25
|
+
try:
|
|
26
|
+
self.auto_commit = auto_commit
|
|
27
|
+
self._conn = connect(self.db_file)
|
|
28
|
+
self._cursor = self._conn.cursor()
|
|
29
|
+
|
|
30
|
+
# ---- SQLite safety / performance defaults ----
|
|
31
|
+
# foreign keys are OFF by default in SQLite
|
|
32
|
+
self._cursor.execute("PRAGMA foreign_keys = ON")
|
|
33
|
+
# better concurrency
|
|
34
|
+
self._cursor.execute("PRAGMA journal_mode = WAL")
|
|
35
|
+
# avoid immediate 'database is locked'
|
|
36
|
+
self._cursor.execute("PRAGMA busy_timeout = 3000")
|
|
37
|
+
# reasonable durability vs speed (WAL推奨とセット)
|
|
38
|
+
self._cursor.execute("PRAGMA synchronous = NORMAL")
|
|
39
|
+
|
|
40
|
+
self.commit()
|
|
41
|
+
except Error as e:
|
|
42
|
+
raise DBMConnectionInvalidError(str(e))
|
|
43
|
+
|
|
44
|
+
def rip_connection(self) -> None:
|
|
45
|
+
if self._conn is None:
|
|
46
|
+
raise DBMConnectionInvalidError()
|
|
47
|
+
self._conn.close()
|
|
48
|
+
self._conn = None
|
|
49
|
+
self._cursor = None
|
|
50
|
+
|
|
51
|
+
def commit(self) -> None:
|
|
52
|
+
if self._conn is None or self._cursor is None:
|
|
53
|
+
raise DBMConnectionInvalidError()
|
|
54
|
+
self._conn.commit()
|
|
55
|
+
|
|
56
|
+
# ---- new canonical names ----
|
|
57
|
+
|
|
58
|
+
def execute(self, sql: str, *args: Any) -> list[tuple] | None:
|
|
59
|
+
if self._conn is None or self._cursor is None:
|
|
60
|
+
raise DBMConnectionInvalidError()
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
self._cursor.execute(sql, tuple(args))
|
|
64
|
+
if self.auto_commit:
|
|
65
|
+
self._conn.commit()
|
|
66
|
+
|
|
67
|
+
if sql.lstrip().upper().startswith("SELECT"):
|
|
68
|
+
return self._cursor.fetchall()
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
except Error as e:
|
|
72
|
+
self._conn.rollback()
|
|
73
|
+
raise DBError(str(e))
|
|
74
|
+
|
|
75
|
+
def execute_many(self, sql_inserts: Iterable[ list[ tuple[str, tuple] ] ]) -> None:
|
|
76
|
+
if self._conn is None or self._cursor is None:
|
|
77
|
+
raise DBMConnectionInvalidError()
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
for sql, values in sql_inserts:
|
|
81
|
+
self._cursor.execute(sql, values)
|
|
82
|
+
|
|
83
|
+
if self.auto_commit:
|
|
84
|
+
self._conn.commit()
|
|
85
|
+
|
|
86
|
+
except Error as e:
|
|
87
|
+
self._conn.rollback()
|
|
88
|
+
raise DBError(str(e))
|