bwsgi 0.1.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.
- bwsgi-0.1.0.dist-info/METADATA +162 -0
- bwsgi-0.1.0.dist-info/RECORD +11 -0
- bwsgi-0.1.0.dist-info/WHEEL +5 -0
- bwsgi-0.1.0.dist-info/licenses/LICENSE +21 -0
- bwsgi-0.1.0.dist-info/top_level.txt +1 -0
- vinod/__init__.py +16 -0
- vinod/auth.py +37 -0
- vinod/core.py +440 -0
- vinod/db.py +67 -0
- vinod/decorators.py +34 -0
- vinod/py.typed +1 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bwsgi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ultra-lightweight, zero-dependency Python WSGI framework
|
|
5
|
+
Author-email: Baraa Ahmed <baraa.runtime@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/BaraaByte/bwsgi
|
|
8
|
+
Project-URL: Repository, https://github.com/BaraaByte/bwsgi
|
|
9
|
+
Project-URL: Issues, https://github.com/BaraaByte/bwsgi/issues
|
|
10
|
+
Keywords: wsgi,framework,zero-dependency,serv00,freebsd,flask-like
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: POSIX :: BSD :: FreeBSD
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# Vinod
|
|
27
|
+
|
|
28
|
+
Ultra-lightweight, **zero-dependency** Python WSGI framework.
|
|
29
|
+
Flask-style routing. Django-style auth. Built on the Python Standard Library.
|
|
30
|
+
|
|
31
|
+
## Why Vinod?
|
|
32
|
+
|
|
33
|
+
- **Zero dependencies** — pure Python stdlib
|
|
34
|
+
- **~5-8 MB idle RAM** — runs happily on 512MB hosts like Serv00
|
|
35
|
+
- **Flask-style routing** — decorator-based, familiar syntax
|
|
36
|
+
- **Secure auth built in** — PBKDF2 password hashing + DB-backed sessions
|
|
37
|
+
- **WSGI native** — works with Passenger, Gunicorn, uWSGI
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install vinod
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from vinod import Vinod, Response, login_required
|
|
49
|
+
|
|
50
|
+
app = Vinod(db_path="app.db")
|
|
51
|
+
|
|
52
|
+
@app.route("/")
|
|
53
|
+
def home(req):
|
|
54
|
+
return Response.html("<h1>Hello from Vinod</h1>")
|
|
55
|
+
|
|
56
|
+
@app.route("/dashboard")
|
|
57
|
+
@login_required
|
|
58
|
+
def dashboard(req):
|
|
59
|
+
return Response.html(f"<h1>Hi {req.user['username']}</h1>")
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
app.run()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Routing and responses
|
|
66
|
+
|
|
67
|
+
Routes accept named path parameters and one or more HTTP methods:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
@app.route("/users/<username>", methods=("GET",))
|
|
71
|
+
def user(req, username):
|
|
72
|
+
return Response.json({"username": username})
|
|
73
|
+
|
|
74
|
+
profile_url = app.url_for("user", username="Jane Doe")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Path values generated by `url_for` are URL-encoded. Requests to a known path
|
|
78
|
+
with an unsupported method receive `405 Method Not Allowed` and an `Allow`
|
|
79
|
+
header. Static files are served from `static/` at `/static/`; files are
|
|
80
|
+
streamed in chunks and paths are checked against the configured static root.
|
|
81
|
+
|
|
82
|
+
`Request` provides `query`, `headers`, `cookies`, `body`, `form()`, and `json()`.
|
|
83
|
+
Malformed JSON request bodies receive `400 Bad Request`.
|
|
84
|
+
`Response` provides `text()`, `html()`, `json()`, `redirect()`, and `file()`.
|
|
85
|
+
|
|
86
|
+
## JSON request validation
|
|
87
|
+
|
|
88
|
+
Use `Request.validate_json()` to require a JSON object with specific required
|
|
89
|
+
fields and Python types. Malformed JSON and invalid values return a JSON
|
|
90
|
+
`400 Bad Request` response:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
@app.route("/api/items", methods=("POST",))
|
|
94
|
+
def create_item(req):
|
|
95
|
+
item = req.validate_json({"name": str, "quantity": int})
|
|
96
|
+
return Response.json(item, "201 Created")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Sessions and CSRF
|
|
100
|
+
|
|
101
|
+
`req.session` is a server-side dictionary persisted in SQLite and identified by
|
|
102
|
+
a signed, HTTP-only cookie. Set a stable secret key when creating the app so
|
|
103
|
+
session cookies survive process restarts:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
app = Vinod(db_path="app.db", secret_key="load-this-from-your-environment")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Protect state-changing routes with `@csrf_protect`. Render `req.csrf_token()`
|
|
110
|
+
into a hidden form field named `_csrf_token`, or send it in the
|
|
111
|
+
`X-Csrf-Token` header for JSON requests:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
@app.route("/submit", methods=("POST",))
|
|
115
|
+
@csrf_protect
|
|
116
|
+
def submit(req):
|
|
117
|
+
req.session["submitted"] = True
|
|
118
|
+
return Response.json({"ok": True})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Safe methods (`GET`, `HEAD`, `OPTIONS`, and `TRACE`) do not require a token.
|
|
122
|
+
Keep state-changing actions on non-safe methods and use CSRF protection for
|
|
123
|
+
browser-session routes.
|
|
124
|
+
|
|
125
|
+
## Templates, middleware, and errors
|
|
126
|
+
|
|
127
|
+
Templates are UTF-8 files under `templates/` by default. `{{ name }}` values
|
|
128
|
+
are HTML-escaped automatically; this intentionally supports variable
|
|
129
|
+
substitution only, not template logic:
|
|
130
|
+
|
|
131
|
+
```html
|
|
132
|
+
<h1>Hello, {{ username }}</h1>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
return app.render_template("hello.html", username="Ada")
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Register standard WSGI middleware with a factory that accepts and returns a
|
|
140
|
+
WSGI callable:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
def request_id_middleware(next_app):
|
|
144
|
+
def middleware(environ, start_response):
|
|
145
|
+
environ["HTTP_X_REQUEST_ID"] = "example"
|
|
146
|
+
return next_app(environ, start_response)
|
|
147
|
+
return middleware
|
|
148
|
+
|
|
149
|
+
app.add_middleware(request_id_middleware)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Error handlers may be registered for HTTP status codes or exception classes:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
@app.errorhandler(ValueError)
|
|
156
|
+
def invalid_value(req):
|
|
157
|
+
return Response.json({"error": "invalid value"}, "400 Bad Request")
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
bwsgi-0.1.0.dist-info/licenses/LICENSE,sha256=S_fbX1OLfL7GYfxafKhn4E6-9cDMpbxy3-OgQisZr5A,1087
|
|
2
|
+
vinod/__init__.py,sha256=I_yncKbAAbA8w7n53HxzSXu6TF73XVakf2ELMtlQJrM,554
|
|
3
|
+
vinod/auth.py,sha256=AgVAgUJNul9BpO-lHkQ6uvykIxv8ZkUpvtP9Jn-4LpE,1019
|
|
4
|
+
vinod/core.py,sha256=ON_6DcyRkisbyX5wwgga0N8yLTa0cVhP6V8xJglRyHY,16333
|
|
5
|
+
vinod/db.py,sha256=ajV8qkLulei4f1wku4Z78M3Rzn564WT6cdS46pd3rnw,2433
|
|
6
|
+
vinod/decorators.py,sha256=MGCEn-uDFmH4voVNCL2ImGb1KGZV9aKp-Om11YoJq-U,1114
|
|
7
|
+
vinod/py.typed,sha256=a99mtb8qROZYvqLuhmlasVCgbmAL9nzVzOJFrVSWLGE,3
|
|
8
|
+
bwsgi-0.1.0.dist-info/METADATA,sha256=U4m56Wws51eYznUiqUJCeH7tiQwEEfJ4VUllH1Anpow,5071
|
|
9
|
+
bwsgi-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
bwsgi-0.1.0.dist-info/top_level.txt,sha256=IYWQTN9LiU9DRWo5VTyR9tyMaKTrx85SZv3f6WdDb7A,6
|
|
11
|
+
bwsgi-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vinod
|
vinod/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Vinod - ultra-lightweight zero-dependency WSGI framework."""
|
|
2
|
+
from .core import Request, RequestValidationError, Response, Vinod
|
|
3
|
+
from .decorators import csrf_protect, login_required
|
|
4
|
+
from .auth import (
|
|
5
|
+
hash_password, verify_password,
|
|
6
|
+
new_session_token, session_expiry,
|
|
7
|
+
)
|
|
8
|
+
from .db import Database
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Vinod", "Request", "Response", "Database",
|
|
13
|
+
"login_required", "csrf_protect", "RequestValidationError",
|
|
14
|
+
"hash_password", "verify_password",
|
|
15
|
+
"new_session_token", "session_expiry",
|
|
16
|
+
]
|
vinod/auth.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Password hashing and session token management. Stdlib only."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import secrets
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
PBKDF2_ITERATIONS = 260_000
|
|
9
|
+
SESSION_DAYS = 7
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def hash_password(password: str, salt=None):
|
|
13
|
+
"""Return (hash_hex, salt_hex). PBKDF2-HMAC-SHA256."""
|
|
14
|
+
if salt is None:
|
|
15
|
+
salt = secrets.token_hex(16)
|
|
16
|
+
dk = hashlib.pbkdf2_hmac(
|
|
17
|
+
"sha256",
|
|
18
|
+
password.encode("utf-8"),
|
|
19
|
+
bytes.fromhex(salt),
|
|
20
|
+
PBKDF2_ITERATIONS,
|
|
21
|
+
)
|
|
22
|
+
return dk.hex(), salt
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def verify_password(password: str, stored_hash: str, salt: str) -> bool:
|
|
26
|
+
"""Constant-time comparison."""
|
|
27
|
+
computed, _ = hash_password(password, salt)
|
|
28
|
+
return hmac.compare_digest(computed, stored_hash)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def new_session_token() -> str:
|
|
32
|
+
return secrets.token_urlsafe(32)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def session_expiry(days: int = SESSION_DAYS) -> str:
|
|
36
|
+
dt = datetime.now(timezone.utc) + timedelta(days=days)
|
|
37
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
vinod/core.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Vinod core - WSGI application, router, request, response."""
|
|
2
|
+
import re
|
|
3
|
+
import json
|
|
4
|
+
import traceback
|
|
5
|
+
import mimetypes
|
|
6
|
+
import secrets
|
|
7
|
+
import copy
|
|
8
|
+
import hmac
|
|
9
|
+
import hashlib
|
|
10
|
+
import html
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from http.cookies import SimpleCookie
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import parse_qs, quote, urlparse
|
|
15
|
+
|
|
16
|
+
from .auth import session_expiry
|
|
17
|
+
from .db import Database
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RequestValidationError(ValueError):
|
|
21
|
+
"""Raised when parsed request data does not satisfy a declared shape."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, errors):
|
|
24
|
+
self.errors = errors
|
|
25
|
+
super().__init__("Request validation failed")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Request:
|
|
29
|
+
"""Lightweight request context passed to every view."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, environ, db, app):
|
|
32
|
+
self.environ = environ
|
|
33
|
+
self.db = db
|
|
34
|
+
self.app = app
|
|
35
|
+
self.method = environ["REQUEST_METHOD"].upper()
|
|
36
|
+
parsed = urlparse(environ.get("PATH_INFO", "/"))
|
|
37
|
+
self.path = parsed.path
|
|
38
|
+
self.query = {k: v[0] for k, v in
|
|
39
|
+
parse_qs(environ.get("QUERY_STRING", "")).items()}
|
|
40
|
+
self.headers = {
|
|
41
|
+
k[5:].replace("_", "-").title(): v
|
|
42
|
+
for k, v in environ.items() if k.startswith("HTTP_")
|
|
43
|
+
}
|
|
44
|
+
self.cookies = SimpleCookie()
|
|
45
|
+
if "HTTP_COOKIE" in environ:
|
|
46
|
+
self.cookies.load(environ["HTTP_COOKIE"])
|
|
47
|
+
self.user = None
|
|
48
|
+
self._body = None
|
|
49
|
+
self.session = {}
|
|
50
|
+
self._session_id = None
|
|
51
|
+
self._session_snapshot = {}
|
|
52
|
+
|
|
53
|
+
def load_session(self):
|
|
54
|
+
self.session, self._session_id = self.app._load_session(self)
|
|
55
|
+
self._session_snapshot = copy.deepcopy(self.session)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def body(self) -> bytes:
|
|
59
|
+
if self._body is None:
|
|
60
|
+
try:
|
|
61
|
+
length = int(self.environ.get("CONTENT_LENGTH") or 0)
|
|
62
|
+
except ValueError:
|
|
63
|
+
length = 0
|
|
64
|
+
self._body = (self.environ["wsgi.input"].read(length)
|
|
65
|
+
if length else b"")
|
|
66
|
+
return self._body
|
|
67
|
+
|
|
68
|
+
def json(self) -> Any:
|
|
69
|
+
if not self.body:
|
|
70
|
+
return {}
|
|
71
|
+
return json.loads(self.body.decode("utf-8"))
|
|
72
|
+
|
|
73
|
+
def validate_json(self, schema: dict[str, type]) -> dict:
|
|
74
|
+
"""Parse an object and require each key to have the declared type."""
|
|
75
|
+
data = self.json()
|
|
76
|
+
if not isinstance(data, dict):
|
|
77
|
+
raise RequestValidationError({"$": "Expected a JSON object"})
|
|
78
|
+
|
|
79
|
+
errors = {}
|
|
80
|
+
for key, expected_type in schema.items():
|
|
81
|
+
if key not in data:
|
|
82
|
+
errors[key] = "Required field is missing"
|
|
83
|
+
elif (not isinstance(data[key], expected_type)
|
|
84
|
+
or (expected_type is int and isinstance(data[key], bool))):
|
|
85
|
+
errors[key] = f"Expected {expected_type.__name__}"
|
|
86
|
+
if errors:
|
|
87
|
+
raise RequestValidationError(errors)
|
|
88
|
+
return data
|
|
89
|
+
|
|
90
|
+
def csrf_token(self) -> str:
|
|
91
|
+
token = self.session.get("_csrf_token")
|
|
92
|
+
if token is None:
|
|
93
|
+
token = secrets.token_urlsafe(32)
|
|
94
|
+
self.session["_csrf_token"] = token
|
|
95
|
+
return token
|
|
96
|
+
|
|
97
|
+
def form(self) -> dict:
|
|
98
|
+
return {k: v[0] for k, v in
|
|
99
|
+
parse_qs(self.body.decode("utf-8")).items()}
|
|
100
|
+
|
|
101
|
+
def url_for(self, name: str, **params) -> str:
|
|
102
|
+
return self.app.url_for(name, **params)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def response(self):
|
|
106
|
+
return Response
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Response:
|
|
110
|
+
"""Convenience response builders."""
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def text(body, status="200 OK", headers=None):
|
|
114
|
+
h = [("Content-Type", "text/plain; charset=utf-8")]
|
|
115
|
+
if headers:
|
|
116
|
+
h.extend(headers)
|
|
117
|
+
return status, h, [body.encode("utf-8")]
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def html(body, status="200 OK", headers=None):
|
|
121
|
+
h = [("Content-Type", "text/html; charset=utf-8")]
|
|
122
|
+
if headers:
|
|
123
|
+
h.extend(headers)
|
|
124
|
+
return status, h, [body.encode("utf-8")]
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def json(data, status="200 OK", headers=None):
|
|
128
|
+
h = [("Content-Type", "application/json")]
|
|
129
|
+
if headers:
|
|
130
|
+
h.extend(headers)
|
|
131
|
+
return status, h, [json.dumps(data).encode("utf-8")]
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def redirect(location, status="302 Found", headers=None):
|
|
135
|
+
h = [("Location", location)]
|
|
136
|
+
if headers:
|
|
137
|
+
h.extend(headers)
|
|
138
|
+
return status, h, [b""]
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def file(path, status="200 OK", headers=None):
|
|
142
|
+
mime, _ = mimetypes.guess_type(path)
|
|
143
|
+
h = [("Content-Type", mime or "application/octet-stream")]
|
|
144
|
+
if headers:
|
|
145
|
+
h.extend(headers)
|
|
146
|
+
|
|
147
|
+
def chunks():
|
|
148
|
+
with open(path, "rb") as f:
|
|
149
|
+
while True:
|
|
150
|
+
chunk = f.read(64 * 1024)
|
|
151
|
+
if not chunk:
|
|
152
|
+
break
|
|
153
|
+
yield chunk
|
|
154
|
+
|
|
155
|
+
return status, h, chunks()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Vinod:
|
|
159
|
+
"""The main WSGI application."""
|
|
160
|
+
|
|
161
|
+
def __init__(self, db_path="app.db", static_dir="static",
|
|
162
|
+
static_url="/static", debug=False, secret_key=None,
|
|
163
|
+
template_dir="templates"):
|
|
164
|
+
self.db = Database(db_path)
|
|
165
|
+
self.routes = []
|
|
166
|
+
self.before_hooks = []
|
|
167
|
+
self.after_hooks = []
|
|
168
|
+
self.error_handlers = {}
|
|
169
|
+
self.extensions = {}
|
|
170
|
+
self._wsgi_app = self._dispatch
|
|
171
|
+
self.config = {
|
|
172
|
+
"DEBUG": debug,
|
|
173
|
+
"SECRET_KEY": secret_key or secrets.token_hex(32),
|
|
174
|
+
"SESSION_DAYS": 7,
|
|
175
|
+
"STATIC_DIR": static_dir,
|
|
176
|
+
"STATIC_URL": static_url,
|
|
177
|
+
"TEMPLATE_DIR": template_dir,
|
|
178
|
+
"SESSION_COOKIE_NAME": "vinod_session",
|
|
179
|
+
"DB_PATH": db_path,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
def route(self, path, methods=("GET",)):
|
|
183
|
+
def decorator(view):
|
|
184
|
+
regex = self._compile_path(path)
|
|
185
|
+
for m in methods:
|
|
186
|
+
self.routes.append(
|
|
187
|
+
(m.upper(), regex, view, path, view.__name__))
|
|
188
|
+
return view
|
|
189
|
+
return decorator
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _compile_path(path):
|
|
193
|
+
parts = re.split(r"(<[A-Za-z_]\w*>)", path)
|
|
194
|
+
pattern = "".join(
|
|
195
|
+
f"(?P<{part[1:-1]}>[^/]+)" if part.startswith("<") else re.escape(part)
|
|
196
|
+
for part in parts
|
|
197
|
+
)
|
|
198
|
+
return re.compile(f"^{pattern}$")
|
|
199
|
+
|
|
200
|
+
def _match(self, method, path):
|
|
201
|
+
for m, regex, view, _, _ in self.routes:
|
|
202
|
+
if m != method:
|
|
203
|
+
continue
|
|
204
|
+
match = regex.match(path)
|
|
205
|
+
if match:
|
|
206
|
+
return view, match.groupdict()
|
|
207
|
+
return None, None
|
|
208
|
+
|
|
209
|
+
def url_for(self, name, **params):
|
|
210
|
+
for _, _, _, path, route_name in self.routes:
|
|
211
|
+
if route_name == name:
|
|
212
|
+
return re.sub(
|
|
213
|
+
r"<([A-Za-z_]\w*)>",
|
|
214
|
+
lambda match: quote(str(params[match.group(1)]), safe=""),
|
|
215
|
+
path,
|
|
216
|
+
)
|
|
217
|
+
raise KeyError(f"No route named {name!r}")
|
|
218
|
+
|
|
219
|
+
def before_request(self, f):
|
|
220
|
+
self.before_hooks.append(f)
|
|
221
|
+
return f
|
|
222
|
+
|
|
223
|
+
def after_request(self, f):
|
|
224
|
+
self.after_hooks.append(f)
|
|
225
|
+
return f
|
|
226
|
+
|
|
227
|
+
def add_middleware(self, factory):
|
|
228
|
+
"""Wrap the WSGI application with a middleware factory."""
|
|
229
|
+
wrapped = factory(self._wsgi_app)
|
|
230
|
+
if not callable(wrapped):
|
|
231
|
+
raise TypeError("Middleware factory must return a WSGI callable")
|
|
232
|
+
self._wsgi_app = wrapped
|
|
233
|
+
return factory
|
|
234
|
+
|
|
235
|
+
def errorhandler(self, code):
|
|
236
|
+
def decorator(f):
|
|
237
|
+
self.error_handlers[code] = f
|
|
238
|
+
return f
|
|
239
|
+
return decorator
|
|
240
|
+
|
|
241
|
+
def render_template(self, template_name: str, **context) -> tuple:
|
|
242
|
+
"""Render a UTF-8 HTML template with HTML-escaped {{ name }} values."""
|
|
243
|
+
root = Path(self.config["TEMPLATE_DIR"]).resolve()
|
|
244
|
+
path = (root / template_name).resolve()
|
|
245
|
+
try:
|
|
246
|
+
path.relative_to(root)
|
|
247
|
+
except ValueError as exc:
|
|
248
|
+
raise ValueError("Template path must stay inside TEMPLATE_DIR") from exc
|
|
249
|
+
source = path.read_text(encoding="utf-8")
|
|
250
|
+
|
|
251
|
+
def replace(match):
|
|
252
|
+
key = match.group(1)
|
|
253
|
+
if key not in context:
|
|
254
|
+
raise KeyError(f"Missing template value {key!r}")
|
|
255
|
+
return html.escape(str(context[key]), quote=True)
|
|
256
|
+
|
|
257
|
+
body = re.sub(r"{{\s*([A-Za-z_]\w*)\s*}}", replace, source)
|
|
258
|
+
return Response.html(body)
|
|
259
|
+
|
|
260
|
+
def register(self, extension):
|
|
261
|
+
"""Register a Vinod extension. Extension must expose init_app(app)."""
|
|
262
|
+
name = extension.__class__.__name__.lower()
|
|
263
|
+
if hasattr(extension, "init_app"):
|
|
264
|
+
extension.init_app(self)
|
|
265
|
+
self.extensions[name] = extension
|
|
266
|
+
return extension
|
|
267
|
+
|
|
268
|
+
def _sign_session_id(self, session_id):
|
|
269
|
+
return hmac.new(
|
|
270
|
+
self.config["SECRET_KEY"].encode("utf-8"),
|
|
271
|
+
session_id.encode("ascii"),
|
|
272
|
+
hashlib.sha256,
|
|
273
|
+
).hexdigest()
|
|
274
|
+
|
|
275
|
+
def _load_session(self, req):
|
|
276
|
+
cookie = req.cookies.get(self.config.get("SESSION_COOKIE_NAME", "vinod_session"))
|
|
277
|
+
if not cookie:
|
|
278
|
+
return {}, None
|
|
279
|
+
try:
|
|
280
|
+
session_id, signature = cookie.value.split(".", 1)
|
|
281
|
+
except ValueError:
|
|
282
|
+
return {}, None
|
|
283
|
+
if (re.fullmatch(r"[A-Za-z0-9_-]{43}", session_id) is None
|
|
284
|
+
or re.fullmatch(r"[a-f0-9]{64}", signature) is None):
|
|
285
|
+
return {}, None
|
|
286
|
+
if not hmac.compare_digest(signature, self._sign_session_id(session_id)):
|
|
287
|
+
return {}, None
|
|
288
|
+
row = self.db.fetchone(
|
|
289
|
+
"SELECT data FROM web_sessions "
|
|
290
|
+
"WHERE id = ? AND expires_at > datetime('now')",
|
|
291
|
+
(session_id,),
|
|
292
|
+
)
|
|
293
|
+
if row is None:
|
|
294
|
+
return {}, None
|
|
295
|
+
data = json.loads(row["data"])
|
|
296
|
+
if not isinstance(data, dict):
|
|
297
|
+
raise ValueError("Stored session data must be a JSON object")
|
|
298
|
+
return data, session_id
|
|
299
|
+
|
|
300
|
+
def _save_session(self, req, result):
|
|
301
|
+
if req.session == req._session_snapshot:
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
session_id = req._session_id or secrets.token_urlsafe(32)
|
|
305
|
+
expires_at = session_expiry(self.config["SESSION_DAYS"])
|
|
306
|
+
self.db.execute(
|
|
307
|
+
"DELETE FROM web_sessions WHERE expires_at <= datetime('now')"
|
|
308
|
+
)
|
|
309
|
+
self.db.execute(
|
|
310
|
+
"INSERT INTO web_sessions (id, data, expires_at) VALUES (?, ?, ?) "
|
|
311
|
+
"ON CONFLICT(id) DO UPDATE SET data = excluded.data, "
|
|
312
|
+
"expires_at = excluded.expires_at",
|
|
313
|
+
(session_id, json.dumps(req.session), expires_at),
|
|
314
|
+
)
|
|
315
|
+
signature = self._sign_session_id(session_id)
|
|
316
|
+
secure = "; Secure" if req.environ.get("wsgi.url_scheme") == "https" else ""
|
|
317
|
+
cookie = (
|
|
318
|
+
f"{self.config['SESSION_COOKIE_NAME']}={session_id}.{signature}; "
|
|
319
|
+
f"Path=/; HttpOnly; SameSite=Lax; "
|
|
320
|
+
f"Max-Age={self.config['SESSION_DAYS'] * 86400}{secure}"
|
|
321
|
+
)
|
|
322
|
+
status, headers, body = result
|
|
323
|
+
return status, headers + [("Set-Cookie", cookie)], body
|
|
324
|
+
|
|
325
|
+
def _load_user(self, req):
|
|
326
|
+
cookie = req.cookies.get("sessionid")
|
|
327
|
+
if not cookie:
|
|
328
|
+
return
|
|
329
|
+
row = self.db.fetchone(
|
|
330
|
+
"SELECT u.id, u.username FROM sessions s "
|
|
331
|
+
"JOIN users u ON u.id = s.user_id "
|
|
332
|
+
"WHERE s.token = ? AND s.expires_at > datetime('now')",
|
|
333
|
+
(cookie.value,),
|
|
334
|
+
)
|
|
335
|
+
if row:
|
|
336
|
+
req.user = {"id": row["id"], "username": row["username"]}
|
|
337
|
+
|
|
338
|
+
def _serve_static(self, req, start_response):
|
|
339
|
+
static_url = self.config["STATIC_URL"].rstrip("/") or "/"
|
|
340
|
+
rel = (req.path.lstrip("/") if static_url == "/"
|
|
341
|
+
else req.path[len(static_url):].lstrip("/"))
|
|
342
|
+
root = Path(self.config["STATIC_DIR"]).resolve()
|
|
343
|
+
full = (root / rel).resolve()
|
|
344
|
+
try:
|
|
345
|
+
full.relative_to(root)
|
|
346
|
+
except ValueError:
|
|
347
|
+
return self._emit(Response.text("403 Forbidden", "403 Forbidden"),
|
|
348
|
+
start_response)
|
|
349
|
+
if not full.is_file():
|
|
350
|
+
return self._emit(Response.text("404 Not Found", "404 Not Found"),
|
|
351
|
+
start_response)
|
|
352
|
+
return self._emit(Response.file(full), start_response)
|
|
353
|
+
|
|
354
|
+
def __call__(self, environ, start_response):
|
|
355
|
+
return self._wsgi_app(environ, start_response)
|
|
356
|
+
|
|
357
|
+
def _dispatch(self, environ, start_response):
|
|
358
|
+
req = None
|
|
359
|
+
try:
|
|
360
|
+
req = Request(environ, self.db, self)
|
|
361
|
+
|
|
362
|
+
static_url = self.config["STATIC_URL"].rstrip("/") or "/"
|
|
363
|
+
if (static_url == "/" or req.path == static_url
|
|
364
|
+
or req.path.startswith(static_url + "/")):
|
|
365
|
+
return self._serve_static(req, start_response)
|
|
366
|
+
|
|
367
|
+
req.load_session()
|
|
368
|
+
self._load_user(req)
|
|
369
|
+
|
|
370
|
+
for hook in self.before_hooks:
|
|
371
|
+
result = hook(req)
|
|
372
|
+
if result is not None:
|
|
373
|
+
return self._emit_result(result, req, start_response)
|
|
374
|
+
|
|
375
|
+
view, kwargs = self._match(req.method, req.path)
|
|
376
|
+
if view is None:
|
|
377
|
+
allowed = sorted({
|
|
378
|
+
route_method for route_method, regex, _, _, _ in self.routes
|
|
379
|
+
if regex.match(req.path)
|
|
380
|
+
})
|
|
381
|
+
if allowed:
|
|
382
|
+
result = Response.text(
|
|
383
|
+
"405 Method Not Allowed",
|
|
384
|
+
"405 Method Not Allowed",
|
|
385
|
+
headers=[("Allow", ", ".join(allowed))],
|
|
386
|
+
)
|
|
387
|
+
return self._emit_result(result, req, start_response)
|
|
388
|
+
handler = self.error_handlers.get(404)
|
|
389
|
+
result = (handler(req) if handler
|
|
390
|
+
else Response.text("404 Not Found", "404 Not Found"))
|
|
391
|
+
return self._emit_result(result, req, start_response)
|
|
392
|
+
|
|
393
|
+
result = view(req, **kwargs) if kwargs else view(req)
|
|
394
|
+
return self._emit_result(result, req, start_response)
|
|
395
|
+
|
|
396
|
+
except Exception as exc:
|
|
397
|
+
handler = next((
|
|
398
|
+
callback for key, callback in self.error_handlers.items()
|
|
399
|
+
if isinstance(key, type) and isinstance(exc, key)
|
|
400
|
+
), None)
|
|
401
|
+
if handler and req is not None:
|
|
402
|
+
return self._emit(handler(req), start_response)
|
|
403
|
+
if isinstance(exc, RequestValidationError):
|
|
404
|
+
return self._emit(Response.json(
|
|
405
|
+
{"error": "Invalid request", "details": exc.errors},
|
|
406
|
+
"400 Bad Request",
|
|
407
|
+
), start_response)
|
|
408
|
+
if isinstance(exc, (json.JSONDecodeError, UnicodeDecodeError)):
|
|
409
|
+
return self._emit(Response.json(
|
|
410
|
+
{"error": "Invalid request body"},
|
|
411
|
+
"400 Bad Request",
|
|
412
|
+
), start_response)
|
|
413
|
+
tb = traceback.format_exc() if self.config["DEBUG"] else ""
|
|
414
|
+
handler = self.error_handlers.get(500)
|
|
415
|
+
if handler and req is not None:
|
|
416
|
+
result = handler(req)
|
|
417
|
+
else:
|
|
418
|
+
body = "<h1>500 Internal Server Error</h1>"
|
|
419
|
+
if tb:
|
|
420
|
+
body += f"<pre>{html.escape(tb)}</pre>"
|
|
421
|
+
result = Response.html(body, "500 Internal Server Error")
|
|
422
|
+
return self._emit(result, start_response)
|
|
423
|
+
|
|
424
|
+
def _emit_result(self, result, req, start_response):
|
|
425
|
+
for hook in self.after_hooks:
|
|
426
|
+
result = hook(req, result) or result
|
|
427
|
+
result = self._save_session(req, result)
|
|
428
|
+
return self._emit(result, start_response)
|
|
429
|
+
|
|
430
|
+
@staticmethod
|
|
431
|
+
def _emit(result, start_response):
|
|
432
|
+
status, headers, body = result
|
|
433
|
+
start_response(status, headers)
|
|
434
|
+
return body
|
|
435
|
+
|
|
436
|
+
def run(self, host="127.0.0.1", port=8000):
|
|
437
|
+
from wsgiref.simple_server import make_server
|
|
438
|
+
print(f"Vinod running on http://{host}:{port}")
|
|
439
|
+
with make_server(host, port, self) as httpd:
|
|
440
|
+
httpd.serve_forever()
|
vinod/db.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Thread-safe SQLite connection manager. One connection per thread."""
|
|
2
|
+
import sqlite3
|
|
3
|
+
import threading
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Database:
|
|
8
|
+
def __init__(self, db_path: str = "app.db"):
|
|
9
|
+
self._memory_uri = (
|
|
10
|
+
f"file:vinod-{id(self)}?mode=memory&cache=shared"
|
|
11
|
+
if db_path == ":memory:"
|
|
12
|
+
else None
|
|
13
|
+
)
|
|
14
|
+
self.db_path = self._memory_uri or str(Path(db_path).resolve())
|
|
15
|
+
self._local = threading.local()
|
|
16
|
+
self._init_schema()
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def conn(self) -> sqlite3.Connection:
|
|
20
|
+
if not hasattr(self._local, "conn"):
|
|
21
|
+
self._local.conn = sqlite3.connect(
|
|
22
|
+
self.db_path,
|
|
23
|
+
timeout=10.0,
|
|
24
|
+
uri=self._memory_uri is not None,
|
|
25
|
+
)
|
|
26
|
+
self._local.conn.row_factory = sqlite3.Row
|
|
27
|
+
self._local.conn.execute("PRAGMA journal_mode=WAL;")
|
|
28
|
+
self._local.conn.execute("PRAGMA foreign_keys=ON;")
|
|
29
|
+
return self._local.conn
|
|
30
|
+
|
|
31
|
+
def execute(self, sql, params=()):
|
|
32
|
+
cur = self.conn.execute(sql, params)
|
|
33
|
+
self.conn.commit()
|
|
34
|
+
return cur
|
|
35
|
+
|
|
36
|
+
def fetchone(self, sql, params=()):
|
|
37
|
+
return self.conn.execute(sql, params).fetchone()
|
|
38
|
+
|
|
39
|
+
def fetchall(self, sql, params=()):
|
|
40
|
+
return self.conn.execute(sql, params).fetchall()
|
|
41
|
+
|
|
42
|
+
def _init_schema(self):
|
|
43
|
+
self.conn.executescript("""
|
|
44
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
45
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
+
username TEXT UNIQUE NOT NULL,
|
|
47
|
+
password_hash TEXT NOT NULL,
|
|
48
|
+
salt TEXT NOT NULL,
|
|
49
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
53
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
54
|
+
token TEXT UNIQUE NOT NULL,
|
|
55
|
+
user_id INTEGER NOT NULL,
|
|
56
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
57
|
+
expires_at TIMESTAMP NOT NULL,
|
|
58
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS web_sessions (
|
|
62
|
+
id TEXT PRIMARY KEY,
|
|
63
|
+
data TEXT NOT NULL,
|
|
64
|
+
expires_at TIMESTAMP NOT NULL
|
|
65
|
+
);
|
|
66
|
+
""")
|
|
67
|
+
self.conn.commit()
|
vinod/decorators.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Auth decorators."""
|
|
2
|
+
from functools import wraps
|
|
3
|
+
import hmac
|
|
4
|
+
|
|
5
|
+
from .core import Response
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def login_required(view):
|
|
12
|
+
"""Redirect unauthenticated users to /login."""
|
|
13
|
+
@wraps(view)
|
|
14
|
+
def wrapper(req, *args, **kwargs):
|
|
15
|
+
if req.user is None:
|
|
16
|
+
return Response.redirect("/login")
|
|
17
|
+
return view(req, *args, **kwargs)
|
|
18
|
+
return wrapper
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def csrf_protect(view):
|
|
22
|
+
"""Require a session CSRF token for requests that can change state."""
|
|
23
|
+
@wraps(view)
|
|
24
|
+
def wrapper(req, *args, **kwargs):
|
|
25
|
+
if req.method not in _SAFE_METHODS:
|
|
26
|
+
supplied = req.headers.get("X-Csrf-Token")
|
|
27
|
+
if supplied is None:
|
|
28
|
+
supplied = req.form().get("_csrf_token")
|
|
29
|
+
expected = req.session.get("_csrf_token")
|
|
30
|
+
if (not supplied or not expected
|
|
31
|
+
or not hmac.compare_digest(supplied, expected)):
|
|
32
|
+
return Response.text("CSRF token missing or invalid", "403 Forbidden")
|
|
33
|
+
return view(req, *args, **kwargs)
|
|
34
|
+
return wrapper
|
vinod/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|