evlet 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.
- evlet/__init__.py +48 -0
- evlet/__main__.py +4 -0
- evlet/app.py +221 -0
- evlet/auth.py +224 -0
- evlet/business.py +402 -0
- evlet/cli.py +147 -0
- evlet/connector.py +161 -0
- evlet/database.py +199 -0
- evlet/deployment.py +63 -0
- evlet/discovery.py +125 -0
- evlet/events.py +65 -0
- evlet/fields.py +331 -0
- evlet/jobs.py +245 -0
- evlet/mcp.py +198 -0
- evlet/metadata.py +64 -0
- evlet/migrations.py +326 -0
- evlet/models.py +121 -0
- evlet/object_storage.py +111 -0
- evlet/permissions.py +108 -0
- evlet/postgres.py +212 -0
- evlet/postgres_schema.py +202 -0
- evlet/query.py +132 -0
- evlet/serverless.py +141 -0
- evlet/storage.py +134 -0
- evlet/tenancy.py +284 -0
- evlet-0.1.0.dist-info/METADATA +290 -0
- evlet-0.1.0.dist-info/RECORD +31 -0
- evlet-0.1.0.dist-info/WHEEL +5 -0
- evlet-0.1.0.dist-info/entry_points.txt +2 -0
- evlet-0.1.0.dist-info/licenses/LICENSE +21 -0
- evlet-0.1.0.dist-info/top_level.txt +1 -0
evlet/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Public APIs for models, persistence, queries, and explicit migrations."""
|
|
2
|
+
|
|
3
|
+
from .app import Application
|
|
4
|
+
from .database import ConflictError, Database, NotFoundError
|
|
5
|
+
from .fields import (Boolean, Currency, Date, DateTime, Decimal, Email, Integer,
|
|
6
|
+
Link, LongText, Select, Text, URL)
|
|
7
|
+
from .migrations import AddField, AddIndex, CreateModel, Migration, RenameField, SchemaError
|
|
8
|
+
from .models import Model, Registry, ValidationError
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AddField", "AddIndex", "Application", "Boolean", "ConflictError", "CreateModel",
|
|
12
|
+
"Currency", "Database", "Date", "DateTime", "Decimal", "Email", "Integer", "Link",
|
|
13
|
+
"LongText", "Migration", "Model", "NotFoundError", "Registry", "RenameField",
|
|
14
|
+
"SchemaError", "Select", "Text", "URL", "ValidationError",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
from .auth import LocalAuth, User
|
|
18
|
+
from .permissions import Policy, Rule
|
|
19
|
+
from .business import Action, ChildTable
|
|
20
|
+
|
|
21
|
+
__all__ += ["Action", "ChildTable", "LocalAuth", "Policy", "Rule", "User"]
|
|
22
|
+
|
|
23
|
+
from .storage import LocalFileStorage, SQLiteStorage, StorageError
|
|
24
|
+
|
|
25
|
+
__all__ += ["LocalFileStorage", "SQLiteStorage", "StorageError"]
|
|
26
|
+
|
|
27
|
+
from .tenancy import Tenant, TenantApplication, TenantRegistry
|
|
28
|
+
|
|
29
|
+
__all__ += ["Tenant", "TenantApplication", "TenantRegistry"]
|
|
30
|
+
|
|
31
|
+
from .events import EventStore
|
|
32
|
+
from .jobs import JobContext, JobQueue, JobReceipt, Task
|
|
33
|
+
|
|
34
|
+
__all__ += ["EventStore", "JobContext", "JobQueue", "JobReceipt", "Task"]
|
|
35
|
+
|
|
36
|
+
from .object_storage import S3Storage
|
|
37
|
+
from .serverless import LambdaHTTP
|
|
38
|
+
|
|
39
|
+
__all__ += ["LambdaHTTP", "S3Storage"]
|
|
40
|
+
|
|
41
|
+
from .connector import ConnectorError, HTTPClient
|
|
42
|
+
from .mcp import MCPServer
|
|
43
|
+
|
|
44
|
+
__all__ += ["ConnectorError", "HTTPClient", "MCPServer"]
|
|
45
|
+
|
|
46
|
+
from .postgres import PostgreSQLDatabase
|
|
47
|
+
|
|
48
|
+
__all__ += ["PostgreSQLDatabase"]
|
evlet/__main__.py
ADDED
evlet/app.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Portable ASGI HTTP and lifespan support without a web framework dependency."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import sqlite3
|
|
7
|
+
from urllib.parse import parse_qs
|
|
8
|
+
|
|
9
|
+
from .database import ConflictError, NotFoundError
|
|
10
|
+
from .models import ValidationError
|
|
11
|
+
from .auth import AccessError
|
|
12
|
+
from .business import Business, Download
|
|
13
|
+
from .storage import StorageError
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
MAX_BODY_BYTES = 64 * 1024
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HTTPError(Exception):
|
|
20
|
+
def __init__(self, status, message, headers=()):
|
|
21
|
+
self.status = status
|
|
22
|
+
self.message = message
|
|
23
|
+
self.headers = headers
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Application:
|
|
27
|
+
def __init__(self, database, *, auth=None, policy=None, actions=(), children=(), attachments=(), storage=None, jobs=None, events=None):
|
|
28
|
+
self.database = database
|
|
29
|
+
self.registry = database.registry
|
|
30
|
+
if auth is None and (policy is not None or actions or children or attachments or storage is not None or jobs is not None or events is not None):
|
|
31
|
+
raise ValueError("Business features require authentication")
|
|
32
|
+
self.business = None if auth is None else Business(database, auth, policy=policy, actions=actions, children=children, attachments=attachments, storage=storage, jobs=jobs, events=events)
|
|
33
|
+
|
|
34
|
+
async def __call__(self, scope, receive, send):
|
|
35
|
+
if scope["type"] == "lifespan":
|
|
36
|
+
await self._lifespan(receive, send)
|
|
37
|
+
return
|
|
38
|
+
if scope["type"] == "websocket":
|
|
39
|
+
await send({"type": "websocket.close", "code": 1008})
|
|
40
|
+
return
|
|
41
|
+
if scope["type"] != "http":
|
|
42
|
+
raise ValueError("Unsupported ASGI scope")
|
|
43
|
+
headers = []
|
|
44
|
+
try:
|
|
45
|
+
if scope['path'] == '/api/_events/stream' and self.business:
|
|
46
|
+
await self._event_stream(scope, receive, send)
|
|
47
|
+
return
|
|
48
|
+
body = None
|
|
49
|
+
if scope["method"] in ("POST", "PATCH"):
|
|
50
|
+
content_type = dict(scope.get("headers", [])).get(b"content-type", b"")
|
|
51
|
+
if content_type.split(b";", 1)[0].strip().lower() != b"application/json":
|
|
52
|
+
raise HTTPError(415, "Send application/json")
|
|
53
|
+
data = bytearray()
|
|
54
|
+
while True:
|
|
55
|
+
event = await receive()
|
|
56
|
+
if event["type"] == "http.disconnect":
|
|
57
|
+
return
|
|
58
|
+
data.extend(event.get("body", b""))
|
|
59
|
+
maximum = 1500000 if self.business and scope['path'].endswith('/attachments') else MAX_BODY_BYTES
|
|
60
|
+
if len(data) > maximum:
|
|
61
|
+
raise HTTPError(413, "Request body exceeds the allowed size")
|
|
62
|
+
if not event.get("more_body", False):
|
|
63
|
+
break
|
|
64
|
+
try:
|
|
65
|
+
body = json.loads(data, parse_constant=self._invalid_constant)
|
|
66
|
+
except (ValueError, UnicodeError, RecursionError) as exc:
|
|
67
|
+
raise HTTPError(400, "Malformed JSON") from exc
|
|
68
|
+
if not isinstance(body, dict):
|
|
69
|
+
raise HTTPError(422, "Expected a JSON object")
|
|
70
|
+
status, payload, headers = await asyncio.to_thread(self._dispatch, scope, body)
|
|
71
|
+
except AccessError as exc:
|
|
72
|
+
status, payload = exc.status, {"error": exc.message}
|
|
73
|
+
except HTTPError as exc:
|
|
74
|
+
status, payload, headers = exc.status, {"error": exc.message}, list(exc.headers)
|
|
75
|
+
except ValidationError as exc:
|
|
76
|
+
status, payload = 422, {"error": "Validation failed", "fields": exc.errors}
|
|
77
|
+
except NotFoundError:
|
|
78
|
+
status, payload = 404, {"error": "Record not found"}
|
|
79
|
+
except ConflictError as exc:
|
|
80
|
+
status, payload = 409, {"error": str(exc)}
|
|
81
|
+
except StorageError:
|
|
82
|
+
logger.exception("Attachment storage failed")
|
|
83
|
+
status, payload = 503, {"error": "Attachment temporarily unavailable"}
|
|
84
|
+
except sqlite3.OperationalError:
|
|
85
|
+
logger.exception("Database operation failed")
|
|
86
|
+
status, payload = 503, {"error": "Database temporarily unavailable"}
|
|
87
|
+
except Exception:
|
|
88
|
+
logger.exception("Unhandled request failure")
|
|
89
|
+
status, payload = 500, {"error": "Internal server error"}
|
|
90
|
+
download = isinstance(payload, Download)
|
|
91
|
+
encoded = payload.content if download else b"" if status == 204 else json.dumps(payload, ensure_ascii=True, allow_nan=False).encode("utf-8")
|
|
92
|
+
response_headers = [(b"x-content-type-options", b"nosniff"), (b"cache-control", b"no-store"), *headers]
|
|
93
|
+
if status != 204:
|
|
94
|
+
if not download:
|
|
95
|
+
response_headers.append((b"content-type", b"application/json; charset=utf-8"))
|
|
96
|
+
response_headers.append((b"content-length", str(len(encoded)).encode()))
|
|
97
|
+
await send({"type": "http.response.start", "status": status, "headers": response_headers})
|
|
98
|
+
await send({"type": "http.response.body", "body": encoded})
|
|
99
|
+
|
|
100
|
+
async def _event_stream(self, scope, receive, send):
|
|
101
|
+
page = await asyncio.to_thread(self.business.event_page, scope)
|
|
102
|
+
await send({'type': 'http.response.start', 'status': 200, 'headers': [
|
|
103
|
+
(b'content-type', b'text/event-stream'), (b'cache-control', b'no-store'),
|
|
104
|
+
(b'x-accel-buffering', b'no'), (b'x-content-type-options', b'nosniff')]})
|
|
105
|
+
async def disconnected():
|
|
106
|
+
while (await receive())['type'] != 'http.disconnect':
|
|
107
|
+
pass
|
|
108
|
+
disconnect = asyncio.create_task(disconnected())
|
|
109
|
+
try:
|
|
110
|
+
for iteration in range(25):
|
|
111
|
+
encoded = ('id: ' + page['cursor'] + '\nevent: evlet\ndata: ' + json.dumps(page) + '\n\n').encode()
|
|
112
|
+
await send({'type': 'http.response.body', 'body': encoded, 'more_body': True})
|
|
113
|
+
done, _ = await asyncio.wait({disconnect}, timeout=0.05 if page['more'] else 1)
|
|
114
|
+
if done:
|
|
115
|
+
return
|
|
116
|
+
headers = [(key, value) for key, value in scope.get('headers', []) if key != b'last-event-id']
|
|
117
|
+
headers.append((b'last-event-id', page['cursor'].encode()))
|
|
118
|
+
try:
|
|
119
|
+
page = await asyncio.to_thread(self.business.event_page, {**scope, 'headers': headers})
|
|
120
|
+
except AccessError:
|
|
121
|
+
await send({'type': 'http.response.body', 'body': b'event: unavailable\ndata: {}\n\n', 'more_body': True})
|
|
122
|
+
return
|
|
123
|
+
except Exception:
|
|
124
|
+
logger.exception('Event stream failed')
|
|
125
|
+
return
|
|
126
|
+
except OSError:
|
|
127
|
+
return
|
|
128
|
+
finally:
|
|
129
|
+
disconnect.cancel()
|
|
130
|
+
await asyncio.gather(disconnect, return_exceptions=True)
|
|
131
|
+
try:
|
|
132
|
+
await send({'type': 'http.response.body', 'body': b'', 'more_body': False})
|
|
133
|
+
except OSError:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _invalid_constant(value):
|
|
138
|
+
raise ValueError(f"Non-finite JSON number: {value}")
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _method(method, allowed):
|
|
142
|
+
if method not in allowed:
|
|
143
|
+
raise HTTPError(405, "Method not allowed", [
|
|
144
|
+
(b"allow", ", ".join(allowed).encode())
|
|
145
|
+
])
|
|
146
|
+
|
|
147
|
+
def _dispatch(self, scope, body):
|
|
148
|
+
path, method = scope["path"], scope["method"]
|
|
149
|
+
if path == "/health":
|
|
150
|
+
self._method(method, ["GET"])
|
|
151
|
+
self.database.check()
|
|
152
|
+
return 200, {"status": "ok"}, []
|
|
153
|
+
if self.business:
|
|
154
|
+
return self.business.dispatch(scope, body)
|
|
155
|
+
if path == "/api/schema":
|
|
156
|
+
self._method(method, ["GET"])
|
|
157
|
+
return 200, self.registry.ui_schema(), []
|
|
158
|
+
parts = path.split("/")
|
|
159
|
+
if len(parts) not in (3, 4) or parts[:2] != ["", "api"]:
|
|
160
|
+
raise HTTPError(404, "Route not found")
|
|
161
|
+
model = self.registry.models.get(parts[2])
|
|
162
|
+
if model is None:
|
|
163
|
+
raise HTTPError(404, "Model not found")
|
|
164
|
+
if method in ('POST', 'PATCH'):
|
|
165
|
+
errors = {name: 'This field is read-only' for name in body
|
|
166
|
+
if name in model.__fields__ and model.__fields__[name].read_only}
|
|
167
|
+
if errors:
|
|
168
|
+
raise ValidationError(errors)
|
|
169
|
+
if len(parts) == 4:
|
|
170
|
+
self._method(method, ["GET", "PATCH", "DELETE"])
|
|
171
|
+
if not parts[3].isascii() or not parts[3].isdigit() or len(parts[3]) > 19:
|
|
172
|
+
raise HTTPError(404, "Record not found")
|
|
173
|
+
record_id = int(parts[3])
|
|
174
|
+
if method == "DELETE":
|
|
175
|
+
self.database.delete(model, record_id)
|
|
176
|
+
return 204, None, []
|
|
177
|
+
record = (self.database.update(model, record_id, **body) if method == "PATCH"
|
|
178
|
+
else self.database.get(model, record_id))
|
|
179
|
+
return 200, record.to_dict(), []
|
|
180
|
+
self._method(method, ["GET", "POST"])
|
|
181
|
+
if method == "POST":
|
|
182
|
+
record = self.database.create(model, **body)
|
|
183
|
+
location = f"/api/{parts[2]}/{record.id}".encode()
|
|
184
|
+
return 201, record.to_dict(), [(b"location", location)]
|
|
185
|
+
try:
|
|
186
|
+
if len(scope.get("query_string", b"")) > 8192:
|
|
187
|
+
raise ValueError("Query exceeds 8 KiB")
|
|
188
|
+
query = parse_qs(scope.get("query_string", b"").decode("ascii"),
|
|
189
|
+
keep_blank_values=True, max_num_fields=10)
|
|
190
|
+
if set(query) - {"limit", "offset", "filters", "order_by", "q"} or any(len(v) != 1 for v in query.values()):
|
|
191
|
+
raise ValueError("Unknown or repeated query parameters")
|
|
192
|
+
limit = int(query.get("limit", ["50"])[0])
|
|
193
|
+
offset = int(query.get("offset", ["0"])[0])
|
|
194
|
+
filters = json.loads(query["filters"][0], parse_constant=self._invalid_constant) if "filters" in query else None
|
|
195
|
+
order_by = query["order_by"][0].split(",") if "order_by" in query else None
|
|
196
|
+
except (ValueError, UnicodeError, RecursionError) as exc:
|
|
197
|
+
raise HTTPError(422, "Use valid limit, offset, filters JSON, and order_by parameters") from exc
|
|
198
|
+
records, total = self.database.page(model, limit=limit, offset=offset, filters=filters, order_by=order_by, search=query.get("q", [""])[0])
|
|
199
|
+
return 200, {"items": [record.to_dict() for record in records],
|
|
200
|
+
"limit": limit, "offset": offset, "total": total}, []
|
|
201
|
+
|
|
202
|
+
def initialize(self):
|
|
203
|
+
self.database.initialize()
|
|
204
|
+
if self.business:
|
|
205
|
+
self.business.initialize()
|
|
206
|
+
|
|
207
|
+
async def _lifespan(self, receive, send):
|
|
208
|
+
while True:
|
|
209
|
+
event = await receive()
|
|
210
|
+
if event["type"] == "lifespan.startup":
|
|
211
|
+
try:
|
|
212
|
+
await asyncio.to_thread(self.initialize)
|
|
213
|
+
except Exception:
|
|
214
|
+
logger.exception("Database initialization failed")
|
|
215
|
+
await send({"type": "lifespan.startup.failed", "message": "Database initialization failed"})
|
|
216
|
+
return
|
|
217
|
+
await send({"type": "lifespan.startup.complete"})
|
|
218
|
+
elif event["type"] == "lifespan.shutdown":
|
|
219
|
+
await asyncio.to_thread(self.database.close)
|
|
220
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
221
|
+
return
|
evlet/auth.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Local accounts and revocable cookie sessions for a single application database."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
from http.cookies import SimpleCookie, CookieError
|
|
7
|
+
import ipaddress
|
|
8
|
+
import re
|
|
9
|
+
import secrets
|
|
10
|
+
import time
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
from .models import ValidationError
|
|
14
|
+
|
|
15
|
+
ROLES = ('admin', 'editor', 'viewer')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AccessError(Exception):
|
|
19
|
+
def __init__(self, status, message):
|
|
20
|
+
self.status, self.message = status, message
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class User:
|
|
25
|
+
id: int
|
|
26
|
+
username: str
|
|
27
|
+
role: str
|
|
28
|
+
|
|
29
|
+
def public(self):
|
|
30
|
+
return {'id': self.id, 'username': self.username, 'role': self.role}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def password_hash(password, salt=None):
|
|
34
|
+
if not isinstance(password, str) or not 15 <= len(password) <= 128:
|
|
35
|
+
raise ValidationError({'password': 'Use between 15 and 128 characters'})
|
|
36
|
+
try:
|
|
37
|
+
encoded = password.encode('utf-8')
|
|
38
|
+
except UnicodeError:
|
|
39
|
+
raise ValidationError({'password': 'Use valid Unicode text'})
|
|
40
|
+
salt = secrets.token_hex(16) if salt is None else salt
|
|
41
|
+
digest = hashlib.scrypt(encoded, salt=bytes.fromhex(salt),
|
|
42
|
+
n=2**17, r=8, p=1, maxmem=256 * 1024 * 1024).hex()
|
|
43
|
+
return f'scrypt:{salt}:{digest}'
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LocalAuth:
|
|
47
|
+
def __init__(self, database, *, origins, secure_cookies=True, allow_setup=False, cookie_name='evlet_session'):
|
|
48
|
+
if cookie_name not in ('evlet_session', '__Host-evlet_session') or (cookie_name.startswith('__Host-') and not secure_cookies):
|
|
49
|
+
raise ValueError('Invalid session cookie configuration')
|
|
50
|
+
self.cookie_name = cookie_name
|
|
51
|
+
self.database = database
|
|
52
|
+
self.origins = frozenset(origins)
|
|
53
|
+
if not self.origins:
|
|
54
|
+
raise ValueError('Configure explicit browser origins')
|
|
55
|
+
for origin in self.origins:
|
|
56
|
+
url = urlsplit(origin)
|
|
57
|
+
if url.scheme not in ('http', 'https') or not url.netloc or url.path or url.query or url.fragment or url.username:
|
|
58
|
+
raise ValueError('Use exact HTTP origins without paths')
|
|
59
|
+
if not secure_cookies and url.hostname not in ('localhost', '127.0.0.1', '::1') and not (url.hostname or '').endswith('.localhost'):
|
|
60
|
+
raise ValueError('Insecure cookies are limited to loopback development origins')
|
|
61
|
+
self.hosts = {urlsplit(origin).netloc.encode() for origin in self.origins}
|
|
62
|
+
self.secure_cookies = secure_cookies
|
|
63
|
+
self.allow_setup = allow_setup
|
|
64
|
+
|
|
65
|
+
def initialize(self):
|
|
66
|
+
with self.database.transaction() as tx:
|
|
67
|
+
tx.connection.execute('CREATE TABLE IF NOT EXISTS evlet_users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, role TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1)')
|
|
68
|
+
tx.connection.execute('CREATE TABLE IF NOT EXISTS evlet_sessions (token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES evlet_users(id) ON DELETE CASCADE, csrf TEXT NOT NULL, expires INTEGER NOT NULL)')
|
|
69
|
+
tx.connection.execute('CREATE TABLE IF NOT EXISTS evlet_login_limits (client TEXT PRIMARY KEY, attempts INTEGER NOT NULL, resets INTEGER NOT NULL)')
|
|
70
|
+
|
|
71
|
+
tx.connection.execute('CREATE INDEX IF NOT EXISTS evlet_session_user ON evlet_sessions(user_id)')
|
|
72
|
+
tx.connection.execute('CREATE INDEX IF NOT EXISTS evlet_session_expiry ON evlet_sessions(expires)')
|
|
73
|
+
tx.connection.execute('CREATE INDEX IF NOT EXISTS evlet_login_reset ON evlet_login_limits(resets)')
|
|
74
|
+
|
|
75
|
+
def guard(self, scope):
|
|
76
|
+
protected = {b'host', b'origin', b'cookie', b'x-csrf-token', b'x-evlet-request', b'sec-fetch-site'}
|
|
77
|
+
seen = set()
|
|
78
|
+
for key, _ in scope.get('headers', []):
|
|
79
|
+
key = key.lower()
|
|
80
|
+
if key in protected and key in seen:
|
|
81
|
+
raise AccessError(403, 'Repeated security header')
|
|
82
|
+
seen.add(key)
|
|
83
|
+
headers = dict(scope.get('headers', []))
|
|
84
|
+
if headers.get(b'host') not in self.hosts:
|
|
85
|
+
raise AccessError(403, 'Unrecognized host')
|
|
86
|
+
origin = headers.get(b'origin')
|
|
87
|
+
if origin is not None and origin.decode('latin1') not in self.origins:
|
|
88
|
+
raise AccessError(403, 'Unrecognized origin')
|
|
89
|
+
if headers.get(b'sec-fetch-site') == b'cross-site':
|
|
90
|
+
raise AccessError(403, 'Cross-site request rejected')
|
|
91
|
+
if scope['method'] not in ('GET', 'HEAD') and headers.get(b'x-evlet-request') != b'1':
|
|
92
|
+
raise AccessError(403, 'Missing request protection header')
|
|
93
|
+
|
|
94
|
+
def token(self, scope):
|
|
95
|
+
cookie = SimpleCookie()
|
|
96
|
+
try:
|
|
97
|
+
cookie.load(dict(scope.get('headers', [])).get(b'cookie', b'').decode('latin1'))
|
|
98
|
+
value = cookie.get(self.cookie_name)
|
|
99
|
+
token = value.value if value else ''
|
|
100
|
+
return hashlib.sha256(token.encode()).hexdigest()
|
|
101
|
+
except (CookieError, UnicodeError):
|
|
102
|
+
return ''
|
|
103
|
+
|
|
104
|
+
def session(self, scope):
|
|
105
|
+
with self.database._connection() as connection:
|
|
106
|
+
row = connection.execute('SELECT u.id, u.username, u.role, s.csrf FROM evlet_sessions s JOIN evlet_users u ON u.id=s.user_id WHERE s.token=? AND s.expires>? AND u.active=1',
|
|
107
|
+
(self.token(scope), int(time.time()))).fetchone()
|
|
108
|
+
if row is None:
|
|
109
|
+
raise AccessError(401, 'Sign in to continue')
|
|
110
|
+
if scope['method'] not in ('GET', 'HEAD'):
|
|
111
|
+
supplied = dict(scope.get('headers', [])).get(b'x-csrf-token', b'')
|
|
112
|
+
if not hmac.compare_digest(supplied, row['csrf'].encode('ascii')):
|
|
113
|
+
raise AccessError(403, 'Invalid request token')
|
|
114
|
+
return User(row['id'], row['username'], row['role']), row['csrf']
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def account_values(body):
|
|
118
|
+
if set(body) - {'username', 'password', 'role'}:
|
|
119
|
+
raise ValidationError({'_form': 'Unknown account field'})
|
|
120
|
+
name = body.get('username', '')
|
|
121
|
+
role = body.get('role', 'viewer')
|
|
122
|
+
if not isinstance(name, str) or not re.fullmatch(r'[a-zA-Z0-9_.-]{3,64}', name):
|
|
123
|
+
raise ValidationError({'username': 'Use 3 to 64 letters, numbers, dots, hyphens, or underscores'})
|
|
124
|
+
if role not in ROLES:
|
|
125
|
+
raise ValidationError({'role': 'Choose admin, editor, or viewer'})
|
|
126
|
+
return name.lower(), password_hash(body.get('password')), role
|
|
127
|
+
|
|
128
|
+
def create_user(self, **values):
|
|
129
|
+
name, password, role = self.account_values(values)
|
|
130
|
+
with self.database.transaction() as tx:
|
|
131
|
+
user_id = tx.connection.execute('INSERT INTO evlet_users(username,password,role) VALUES (?,?,?)', (name, password, role)).lastrowid
|
|
132
|
+
return User(user_id, name, role)
|
|
133
|
+
|
|
134
|
+
def setup_needed(self):
|
|
135
|
+
with self.database._connection() as connection:
|
|
136
|
+
return not connection.execute('SELECT 1 FROM evlet_users LIMIT 1').fetchone()
|
|
137
|
+
|
|
138
|
+
def setup(self, scope, body):
|
|
139
|
+
try:
|
|
140
|
+
loopback = ipaddress.ip_address(scope.get('client', ('',))[0]).is_loopback
|
|
141
|
+
except ValueError:
|
|
142
|
+
loopback = False
|
|
143
|
+
if not self.allow_setup or not loopback or not self.setup_needed():
|
|
144
|
+
raise AccessError(403, 'Initial setup is unavailable')
|
|
145
|
+
name, password, _ = self.account_values({**body, 'role': 'admin'})
|
|
146
|
+
with self.database.transaction() as tx:
|
|
147
|
+
if tx.connection.execute('SELECT 1 FROM evlet_users LIMIT 1').fetchone():
|
|
148
|
+
raise AccessError(409, 'An administrator already exists')
|
|
149
|
+
tx.connection.execute('INSERT INTO evlet_users(username,password,role) VALUES (?,?,?)', (name, password, 'admin'))
|
|
150
|
+
|
|
151
|
+
def login(self, scope, body):
|
|
152
|
+
if set(body) != {'username', 'password'} or not all(isinstance(body[k], str) for k in body):
|
|
153
|
+
raise AccessError(401, 'Invalid username or password')
|
|
154
|
+
now = int(time.time())
|
|
155
|
+
client = hashlib.sha256(scope.get('client', ('unknown',))[0].encode()).hexdigest()
|
|
156
|
+
with self.database.transaction() as tx:
|
|
157
|
+
tx.connection.execute('DELETE FROM evlet_login_limits WHERE resets<=?', (now,))
|
|
158
|
+
tx.connection.execute('DELETE FROM evlet_sessions WHERE expires<=?', (now,))
|
|
159
|
+
row = tx.connection.execute('SELECT attempts FROM evlet_login_limits WHERE client=?', (client,)).fetchone()
|
|
160
|
+
if row and row['attempts'] >= 10:
|
|
161
|
+
raise AccessError(429, 'Too many sign-in attempts. Try again in 15 minutes.')
|
|
162
|
+
tx.connection.execute('INSERT INTO evlet_login_limits VALUES (?,1,?) ON CONFLICT(client) DO UPDATE SET attempts=evlet_login_limits.attempts+1', (client, now + 900))
|
|
163
|
+
account = tx.connection.execute('SELECT * FROM evlet_users WHERE username=?', (body['username'].lower(),)).fetchone()
|
|
164
|
+
stored = account['password'] if account else 'scrypt:' + '00' * 16 + ':' + '00' * 64
|
|
165
|
+
try:
|
|
166
|
+
candidate = password_hash(body['password'], stored.split(':')[1])
|
|
167
|
+
except (ValueError, UnicodeError):
|
|
168
|
+
candidate = ''
|
|
169
|
+
if not hmac.compare_digest(candidate, stored) or not account or not account['active']:
|
|
170
|
+
raise AccessError(401, 'Invalid username or password')
|
|
171
|
+
token, csrf = secrets.token_urlsafe(32), secrets.token_hex(32)
|
|
172
|
+
with self.database.transaction() as tx:
|
|
173
|
+
# A password/role change during hashing must not revive an old login.
|
|
174
|
+
current = tx.connection.execute('SELECT * FROM evlet_users WHERE id=?', (account['id'],)).fetchone()
|
|
175
|
+
if not current or not current['active'] or current['password'] != stored or current['role'] != account['role']:
|
|
176
|
+
raise AccessError(401, 'Invalid username or password')
|
|
177
|
+
tx.connection.execute('DELETE FROM evlet_login_limits WHERE client=?', (client,))
|
|
178
|
+
tx.connection.execute('DELETE FROM evlet_sessions WHERE token=?', (self.token(scope),))
|
|
179
|
+
tx.connection.execute('INSERT INTO evlet_sessions VALUES (?,?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), account['id'], csrf, now + 28800))
|
|
180
|
+
return {'user': User(account['id'], account['username'], account['role']).public(), 'csrf': csrf}, [self.cookie(token)]
|
|
181
|
+
|
|
182
|
+
def cookie(self, token):
|
|
183
|
+
value = f'{self.cookie_name}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={28800 if token else 0}'
|
|
184
|
+
if self.secure_cookies:
|
|
185
|
+
value += '; Secure'
|
|
186
|
+
return b'set-cookie', value.encode()
|
|
187
|
+
|
|
188
|
+
def logout(self, scope):
|
|
189
|
+
with self.database.transaction() as tx:
|
|
190
|
+
tx.connection.execute('DELETE FROM evlet_sessions WHERE token=?', (self.token(scope),))
|
|
191
|
+
return [self.cookie('')]
|
|
192
|
+
|
|
193
|
+
def users(self):
|
|
194
|
+
return self.user_page()['items']
|
|
195
|
+
|
|
196
|
+
def user_page(self, offset=0):
|
|
197
|
+
if type(offset) is not int or not 0 <= offset <= 2**63-1:
|
|
198
|
+
raise ValidationError({'offset': 'Use a nonnegative offset'})
|
|
199
|
+
with self.database._connection() as connection:
|
|
200
|
+
connection.execute('BEGIN')
|
|
201
|
+
items = [dict(row) for row in connection.execute('SELECT id,username,role,active FROM evlet_users ORDER BY id LIMIT 100 OFFSET ?', (offset,))]
|
|
202
|
+
total = connection.execute('SELECT count(*) FROM evlet_users').fetchone()[0]
|
|
203
|
+
return {'items': items, 'total': total, 'offset': offset, 'limit': 100}
|
|
204
|
+
|
|
205
|
+
def update_user(self, user_id, body):
|
|
206
|
+
if not body or set(body) - {'role', 'active', 'password'}:
|
|
207
|
+
raise ValidationError({'_form': 'Use role, active, or password'})
|
|
208
|
+
if 'role' in body and body['role'] not in ROLES:
|
|
209
|
+
raise ValidationError({'role': 'Unknown role'})
|
|
210
|
+
if 'active' in body and type(body['active']) is not bool:
|
|
211
|
+
raise ValidationError({'active': 'Use true or false'})
|
|
212
|
+
changes = dict(body)
|
|
213
|
+
if 'password' in changes:
|
|
214
|
+
changes['password'] = password_hash(changes['password'])
|
|
215
|
+
with self.database.transaction() as tx:
|
|
216
|
+
row = tx.connection.execute('SELECT * FROM evlet_users WHERE id=?', (user_id,)).fetchone()
|
|
217
|
+
if not row:
|
|
218
|
+
raise AccessError(404, 'Account not found')
|
|
219
|
+
if row['role'] == 'admin' and row['active'] and (changes.get('role', 'admin') != 'admin' or not changes.get('active', True)):
|
|
220
|
+
if tx.connection.execute("SELECT count(*) FROM evlet_users WHERE role='admin' AND active=1").fetchone()[0] == 1:
|
|
221
|
+
raise AccessError(409, 'Keep at least one active administrator')
|
|
222
|
+
for key, value in changes.items():
|
|
223
|
+
tx.connection.execute(f'UPDATE evlet_users SET {key}=? WHERE id=?', (value, user_id))
|
|
224
|
+
tx.connection.execute('DELETE FROM evlet_sessions WHERE user_id=?', (user_id,))
|