Files
mini/mini.py
T
2026-10-18 20:58:22 +00:00

726 lines
23 KiB
Python

from __future__ import annotations
import asyncio as aio, base64 as b64, concurrent.futures as cf, datetime as dt, hashlib as hl, html as h, json as j, mimetypes as mt, re as r, sqlite3 as s3, time as t, urllib.parse as up
from pathlib import Path as P
class Time:
@staticmethod
def now():
return dt.datetime.now(dt.timezone.utc)
class Ids:
@staticmethod
def uuid7():
import uuid as u
ms = int(t.time() * 1000)
b = u.uuid4().bytes
return str(u.UUID(bytes=(ms.to_bytes(6, "big") + b"\x70" + b[1:2] + b"\x80" + b[3:])[:16]))
class Esc:
@staticmethod
def x(s):
return h.escape(str(s), quote=True)
class Field:
def __init__(
self, required=False, regex=None, type_=str, default=None, min_len=None, max_len=None
):
self.required = required
self.rx = r.compile(regex) if regex else None
self.type_ = type_
self.default = default
self.min_len = min_len
self.max_len = max_len
def clean(self, name, val):
if val is None or val == "":
if self.required:
raise ValueError(f"{name} is_required")
return self.default
v = self.type_(val)
s = str(v)
if self.min_len is not None and len(s) < self.min_len:
raise ValueError(f"{name} too_short")
if self.max_len is not None and len(s) > self.max_len:
raise ValueError(f"{name} too_long")
if self.rx and not self.rx.search(s):
raise ValueError(f"{name} invalid")
return v
class Validator:
def __init__(self, schema: dict[str, Field]):
self.schema = schema
def openapi(self):
t = {str: "string", int: "integer", float: "number", bool: "boolean"}
ps = {}
rq = []
for k, f in self.schema.items():
mp = {str: "string", int: "integer", float: "number", bool: "boolean"}
o = {"type": mp.get(f.type_, "string")}
if f.rx is not None:
o["pattern"] = f.rx.pattern
if f.min_len is not None:
o["minLength"] = f.min_len
if f.max_len is not None:
o["maxLength"] = f.max_len
if f.default is not None:
o["default"] = f.default
ps[k] = o
if f.required:
rq.append(k)
o = {"type": "object", "properties": ps}
if rq:
o["required"] = rq
return o
def clean(self, data: dict):
out = {}
errs = {}
for k, f in self.schema.items():
try:
out[k] = f.clean(k, data.get(k))
except Exception as e:
errs[k] = str(e)
if errs:
raise ValueError(errs)
return out
class Request:
def __init__(
self,
method,
path,
query,
headers,
cookies,
path_params,
query_params,
form,
body,
json_data,
):
self.method = method
self.path = path
self.query = query
self.headers = headers
self.cookies = cookies
self.path_params = path_params
self.query_params = query_params
self.form = form
self.body = body
self.json = json_data
def param(self, name, default=None):
if name in self.path_params:
return self.path_params[name]
if name in self.query_params:
return self.query_params[name]
if name in self.form:
return self.form[name]
if isinstance(self.json, dict) and name in self.json:
return self.json[name]
return default
@property
def all_params(self):
d = {}
d.update(self.query_params)
d.update(self.form)
if isinstance(self.json, dict):
d.update(self.json)
d.update(self.path_params)
return d
class Response:
def __init__(self, body=b"", status=200, ctype="text/plain", headers=None):
self.body = body if isinstance(body, bytes) else str(body).encode()
self.status = status
self.ctype = ctype
self.headers = headers or {}
@staticmethod
def text(s, st=200):
return Response(s, st, "text/plain")
@staticmethod
def html(s, st=200):
return Response(s, st, "text/html")
@staticmethod
def json(o, st=200):
return Response(j.dumps(o), st, "application/json")
@staticmethod
def redirect(u):
return Response(b"", 302, "text/plain", {"Location": u})
class Template:
def __init__(self, dir="templates"):
self.dir = P(dir)
def render(self, name, ctx: dict):
s = (self.dir / name).read_text()
for k, v in ctx.items():
s = s.replace("{{" + k + "}}", Esc.x(v))
return Response.html(s)
class DB:
def __init__(self, path="./data/app.db"):
self.path = P(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._w = aio.Lock()
self._idx = set()
self._queue = aio.Queue()
self.con = s3.connect(self.path, check_same_thread=False, detect_types=s3.PARSE_DECLTYPES)
self.con.row_factory = s3.Row
def _cols(self, t):
return [x[1] for x in self.con.execute(f"PRAGMA table_info({t})")]
def _ensure(self, t, row: dict):
try:
self.con.execute(
f"CREATE TABLE IF NOT EXISTS {t} (id INTEGER PRIMARY KEY AUTOINCREMENT,uid TEXT,created_at TEXT,updated_at TEXT,deleted_at TEXT)"
)
except Exception:
pass
cols = self._cols(t)
for k in list(row.keys()):
if k not in cols:
try:
self.con.execute(f"ALTER TABLE {t} ADD COLUMN {k} TEXT")
except Exception:
pass
def _stamp(self, row):
n = Time.now().isoformat()
row.setdefault("uid", Ids.uuid7())
row.setdefault("created_at", n)
row["updated_at"] = n
return row
def _maybe_index(self, t, keys):
if len(keys) > 1:
k = (t, tuple(sorted(keys)))
if k not in self._idx:
try:
self.con.execute(
f"CREATE INDEX IF NOT EXISTS idx_{t}_{'_'.join(sorted(keys))} ON {t} ({','.join(sorted(keys))})"
)
self.con.commit()
except Exception:
pass
self._idx.add(k)
async def insert(self, t, **kw):
async with self._w:
kw = self._stamp(dict(kw))
for _ in range(6):
try:
self._ensure(t, kw)
c = ",".join(kw.keys())
q = ",".join(["?"] * len(kw))
cur = self.con.execute(
f"INSERT INTO {t} ({c}) VALUES ({q})",
tuple(str(v) if v is not None else None for v in kw.values()),
)
self.con.commit()
kw["id"] = cur.lastrowid
return kw
except s3.OperationalError:
self._ensure(t, kw)
async def update(self, t, where, what):
async with self._w:
w = {where: [where]} if isinstance(where, list) else where
what = dict(what)
what["updated_at"] = Time.now().isoformat()
for _ in range(6):
try:
self._ensure(t, {**w, **what})
self._maybe_index(t, list(w.keys()))
self.con.execute(
f"UPDATE {t} SET {','.join(f'{k}=?' for k in what)} WHERE {' AND '.join(f'{k}=?' for k in w)} AND deleted_at IS NULL",
tuple([str(v) for v in what.values()] + [str(v) for v in w.values()]),
)
self.con.commit()
return True
except s3.OperationalError:
self._ensure(t, {**w, **what})
async def upsert(self, t, match: dict, **kw):
async with self._w:
pass
rows = await self.find(t, match)
if rows:
await self.update(t, match, kw)
return {**rows[0], **kw}
return await self.insert(t, **{**match, **kw})
async def delete(self, t, where):
return await self.update(t, where, {"deleted_at": Time.now().isoformat()})
async def find(self, t, where=None):
async with self._w:
try:
if not where:
return [
dict(x)
for x in self.con.execute(f"SELECT * FROM {t} WHERE deleted_at IS NULL")
]
self._maybe_index(t, list(where.keys()))
return [
dict(x)
for x in self.con.execute(
f"SELECT * FROM {t} WHERE {' AND '.join(f'{k}=?' for k in where)} AND deleted_at IS NULL",
tuple(str(v) for v in where.values()),
)
]
except Exception:
return []
async def insert_many(self, t, rows: list[dict]):
return [await self.insert(t, **x) for x in rows]
async def update_many(self, t, items: list[tuple[dict, dict]]):
return [await self.update(t, w, x) for w, x in items]
async def upsert_many(self, t, items: list[tuple[dict, dict]]):
return [await self.upsert(t, m, **x) for m, x in items]
def _later_wrap(self, fn):
async def w(*a, **k):
opt = dict(k.get("_opt", {})) if isinstance(k.get("_opt"), dict) else {}
preview = dict(k)
preview.pop("_opt", None)
if fn.__name__ in ("insert", "upsert"):
preview = self._stamp(preview) if fn.__name__ == "insert" else preview
await self._queue.put((fn, a, k))
return preview
return w
def __getattr__(self, n):
if n.endswith("_later") and n[:-6] in (
"insert",
"update",
"upsert",
"delete",
"insert_many",
"upsert_many",
"update_many",
):
return self._later_wrap(getattr(self, f"{n[:-6]}"))
raise AttributeError(n)
async def drain(self):
while True:
fn, a, k = await self._queue.get()
try:
await fn(*a, **{x: y for x, y in k.items() if x != "_opt"})
except Exception:
pass
self._queue.task_done()
class Sessions:
def __init__(self, db: DB, cookie="sid", cap=50 * 1024 * 1024):
self.cookie = cookie
self.db = db
self.cache = {}
self.size = 0
self.cap = cap
def _sid(self, cookies):
return cookies.get(self.cookie) or Ids.uuid7()
async def load(self, sid):
if sid in self.cache:
return self.cache[sid]
r = await self.db.find("sessions", {"uid": sid})
d = j.loads(r[0].get("data", "{}")) if r else {}
self.cache[sid] = d
return d
async def save(self, sid, data):
self.cache[sid] = data
b = j.dumps(data).encode()
self.size += len(b)
r = await self.db.find("sessions", {"uid": sid})
if r:
await self.db.update("sessions", {"uid": sid}, {"data": j.dumps(data)})
else:
(
await self.db.con.execute(
"INSERT INTO sessions (uid,data) VALUES (?,?)", (sid, j.dumps(data))
)
if False
else await self.db.insert("sessions", uid=sid, data=j.dumps(data))
)
if self.size > self.cap:
self.size = 0
class PubSub:
def __init__(self):
self.topics: dict[str, set] = {}
def sub(self, topic, q):
self.topics.setdefault(topic, set()).add(q)
def unsub(self, topic, q):
self.topics.get(topic, set()).discard(q)
async def pub(self, topic, msg):
for q in list(self.topics.get(topic, [])):
try:
q.put_nowait(msg)
except Exception:
pass
class Route:
def __init__(self, method, pattern, handler, auth=False, schema_in=None, schema_out=None):
self.method = method
self.handler = handler
self.auth = auth
self.schema_in = schema_in
self.schema_out = schema_out
self.schema_in = schema_in
self.schema_out = schema_out
rx = r.sub(r"\{(\w+)\}", r"(?P<\1>[^/]+)", pattern)
self.pattern = pattern
self.rx = r.compile(f"^{rx}$")
class Mini:
def __init__(self, data_dir="./data", version="v1"):
self.root = P(data_dir)
self.root.mkdir(parents=True, exist_ok=True)
self.version = version
self.routes: list[Route] = []
self.db = DB(str(self.root / "app.db"))
self.pubsub = PubSub()
self.tpl = Template(str(self.root / "templates"))
self.pool = cf.ThreadPoolExecutor(max_workers=32)
mt.init()
e = self._env_load()
import uuid as _uu
sc = e.get("session_cookie")
xx = None
yy = None
zz = None
if not sc:
sc = f"sid_{_uu.uuid4().hex[:8]}"
e["session_cookie"] = sc
self._env_save(e)
self.session_cookie = sc
self.sessions = Sessions(self.db, cookie=sc)
def route(self, path, method="GET", auth=False, schema_in=None, schema_out=None):
def d(fn):
self.routes.append(Route(method, path, fn, auth, schema_in, schema_out))
try:
self.openapi()
except Exception:
pass
return fn
return d
def get(self, path, **k):
return self.route(path, "GET", **k)
def put(self, path, **k):
return self.route(path, "PUT", **k)
def post(self, path, **k):
return self.route(path, "POST", **k)
def static(self, url_prefix="/static", dir="static"):
d = self.root / dir
d.mkdir(parents=True, exist_ok=True)
async def h(req: Request):
rel = up.unquote(req.path_params.get("_p", ""))
f = (d / rel).resolve()
if d.resolve() not in f.parents and f != d.resolve():
return Response.text("forbidden", 403)
if not f.exists():
return Response.text("nf", 404)
c, enc = mt.guess_type(str(f))
c = c or "application/octet-stream"
rng = req.headers.get("range", "")
data = f.read_bytes()
if rng.startswith("bytes="):
try:
s, e = rng[6:].split("-")
s = int(s or 0)
e = int(e or len(data) - 1)
return Response(
data[s : e + 1],
206,
c,
{"Content-Range": f"bytes {s}-{e}/{len(data)}", "Accept-Ranges": "bytes"},
)
except Exception:
pass
return Response(
data,
200,
c,
{
"Accept-Ranges": "bytes",
"Cache-Control": (
f"public,max-age=31536000"
if f"/{self.version}/" in req.path
else "no-cache"
),
},
)
self.routes.append(Route("GET", f"{url_prefix}/{{_p}}", h))
def openapi(self):
pp = {}
rr = None
oo = None
dd = None
for rt in self.routes:
doc = (rt.handler.__doc__ or "").strip()
summ = doc.splitlines()[0] if doc else f"{rt.method} {rt.pattern}"
op = {"summary": summ, "description": doc, "responses": {"200": {"description": "ok"}}}
if rt.schema_in:
op["requestBody"] = {
"content": {"application/json": {"schema": rt.schema_in.openapi()}}
}
if rt.schema_out:
op["responses"]["200"]["content"] = {
"application/json": {"schema": rt.schema_out.openapi()}
}
pp.setdefault(rt.pattern, {}).setdefault(rt.method.lower(), op)
self._spec = {
"openapi": "3.0.0",
"info": {"title": "mini", "version": self.version},
"paths": pp,
}
try:
(self.root / "openapi.json").write_text(j.dumps(self._spec, indent=2))
except Exception:
pass
return self._spec
def _parse(self, raw: bytes):
head, _, body = raw.partition(b"\r\n\r\n")
lines = head.decode("iso-8859-1").split("\r\n")
m, p, _ = lines[0].split(" ", 2)
path, q = p.split("?", 1) if "?" in p else (p, "")
hd = {}
for l in lines[1:]:
if ":" in l:
k, v = l.split(":", 1)
hd[k.strip().lower()] = v.strip()
ck = {}
for c in hd.get("cookie", "").split(";"):
if "=" in c:
k, v = c.strip().split("=", 1)
ck[k] = up.unquote(v)
qp = {
k: v[0] if len(v) == 1 else v for k, v in up.parse_qs(q, keep_blank_values=True).items()
}
fm = {}
jd = None
ct = hd.get("content-type", "")
if m in ("POST", "PUT", "PATCH"):
if "application/json" in ct:
try:
jd = j.loads(body.decode() or "null")
except Exception:
jd = None
elif "application/x-www-form-urlencoded" in ct:
fm = {
k: v[0] if len(v) == 1 else v
for k, v in up.parse_qs(body.decode(), keep_blank_values=True).items()
}
return m, up.unquote(path), q, hd, ck, qp, fm, body, jd
def _check_auth(self, hd):
a = hd.get("authorization", "")
if not a.startswith("Basic "):
return None
try:
u, p = b64.b64decode(a[6:]).decode().split(":", 1)
return {"user": u, "pass": p}
except Exception:
return None
async def _handle_conn(self, r, w):
try:
raw = await aio.wait_for(r.read(65536), timeout=10)
if not raw:
w.close()
return
m, path, q, hd, ck, qp, fm, body, jd = self._parse(raw)
for rt in self.routes:
if rt.method != m:
continue
mo = rt.rx.match(path)
if not mo:
continue
if rt.auth and not self._check_auth(hd):
return await self._send(
w,
Response(
b"auth", 401, "text/plain", {"WWW-Authenticate": 'Basic realm="mini"'}
),
None,
)
pp = {k: up.unquote(v) for k, v in mo.groupdict().items()}
req = Request(m, path, q, hd, ck, pp, qp, fm, body, jd)
sid = ck.get(self.session_cookie) or Ids.uuid7()
new = self.session_cookie not in ck
req.session = await self.sessions.load(sid)
req.validated = {}
vv = None
ee = None
if rt.schema_in:
try:
req.validated = rt.schema_in.clean(req.all_params)
except Exception as ex:
return await self._send(
w, Response.json({"ok": False, "errors": str(ex)}, 422), None
)
res = rt.handler(req)
if aio.iscoroutine(res):
res = await res
else:
res = (
await aio.get_event_loop().run_in_executor(self.pool, rt.handler, req)
if callable(rt.handler) and res is None
else res
)
if isinstance(res, (dict, list)):
res = Response.json(res)
elif isinstance(res, str):
res = Response.html(res)
await self.sessions.save(sid, req.session)
extra = (
{}
if not new
else {"Set-Cookie": f"{self.session_cookie}={sid}; Path=/; HttpOnly"}
)
return await self._send(w, res, extra)
await self._send(w, Response.text("nf", 404), None)
except Exception as e:
try:
await self._send(w, Response.text("err", 500), None)
except Exception:
pass
finally:
try:
w.close()
except Exception:
pass
async def _send(self, w, res: Response, extra):
hds = {
"Content-Type": res.ctype,
"Content-Length": str(len(res.body)),
"Connection": "close",
**res.headers,
**(extra or {}),
}
w.write(
f"HTTP/1.1 {res.status} OK\r\n{''.join(f'{k}: {v}\r\n' for k,v in hds.items())}\r\n".encode()
+ res.body
)
await w.drain()
async def _run(self, port):
aio.create_task(self.db.drain())
s = await aio.start_server(self._handle_conn, "0.0.0.0", port)
print(f"mini on {port}")
async with s:
await s.serve_forever()
def _env_path(self):
return self.root / ".env.json"
def _env_load(self):
try:
import json as _j
return _j.loads((self.root / ".env.json").read_text())
except Exception:
return {}
def _env_save(self, d):
try:
import json as _j
(self.root / ".env.json").write_text(_j.dumps(d, indent=2))
except Exception:
pass
def _auto_port(self):
e = self._env_load()
last = None
try:
last = int(e.get("port", 0))
except Exception:
pass
cands = []
if last and 22000 <= last <= 24000:
cands.append(last)
import random as rn
rest = list(range(22000, 24001))
rn.shuffle(rest)
for x in rest:
if x not in cands:
cands.append(x)
import socket as so
for x in cands:
try:
q = so.socket()
q.bind(("0.0.0.0", x))
q.close()
e["port"] = x
self._env_save(e)
return x
except Exception:
continue
return 23000
def serve(self, port=None):
import sys as _s
if port is None:
port = int(_s.argv[1]) if len(_s.argv) > 1 else self._auto_port()
aio.run(self._run(port))
if __name__ == "__main__":
import sys
app = Mini()
app.static()
@app.get("/")
async def idx(req):
return {"ok": True, "v": app.version}
app.serve(int(sys.argv[1]) if len(sys.argv) > 1 else None)