feat(web): add shared pagination and CSRF helpers

Generate filter-preserving page links and validate tokens on web forms.
This commit is contained in:
rekushi committed 2026-10-29 04:41:32 +00:00
1 parent 3c70f364f8
commit 5ded340997
1 file changed
+67
+67
View File
@@ -0,0 +1,67 @@
"""Small web controls adapted from RexiMemo's paged settings and lists."""
from __future__ import annotations
from html import escape
from http.cookies import SimpleCookie, CookieError
from urllib.parse import urlencode
import math
import re
import secrets
def page_number(value, total, per_page):
try:
page = int(value)
except (ValueError, TypeError):
page = 1
return max(1, min(page, max(1, math.ceil(total / per_page))))
def pagination(page, total, per_page, path, **params):
pages = max(1, math.ceil(total / per_page))
if pages <= 1:
return ""
def link(number, label, title):
target = path + "?" + urlencode({**params, "page": number})
return f'<a href="{escape(target, quote=True)}" aria-label="{title}">{label}</a>'
first = '<svg viewBox="0 0 20 16" aria-hidden="true"><path d="m9 3-5 5 5 5m7-10-5 5 5 5"/></svg>'
last = '<svg viewBox="0 0 20 16" aria-hidden="true"><path d="m4 3 5 5-5 5m7-10 5 5-5 5"/></svg>'
parts = [link(1, first, "First page"), link(page-1, "‹", "Previous page")] if page > 1 else []
for number in range(max(1, page-2), min(pages, page+2)+1):
parts.append(f'<span aria-current="page">{number}</span>' if number == page else link(number, str(number), f"Page {number}"))
if page < pages:
parts.extend([link(page+1, "›", "Next page"), link(pages, last, "Last page")])
return '<nav class="pagination" aria-label="Pages">' + ''.join(parts) + '</nav>'
def csrf_token(request, create=True):
if getattr(request, "_ose_csrf", ""):
return request._ose_csrf
headers = request.getAllHeaders()
cookie = next((value for key, value in headers.items() if str(key.decode() if isinstance(key, bytes) else key).lower() == "cookie"), "")
jar = SimpleCookie()
try:
jar.load(cookie.decode() if isinstance(cookie, bytes) else cookie)
token = jar["ose_csrf"].value if "ose_csrf" in jar else ""
except CookieError:
token = ""
if not re.fullmatch(r"[a-f0-9]{64}", token):
if not create:
return ""
token = secrets.token_hex(32)
secure = "; Secure" if getattr(request, "isSecure", lambda: False)() else ""
request.setHeader(b"Set-Cookie", f"ose_csrf={token}; Path=/; HttpOnly; SameSite=Lax{secure}".encode())
request._ose_csrf = token
return token
def protect_forms(request, markup):
token = csrf_token(request)
field = f'<input type="hidden" name="csrf" value="{token}">'
return re.sub(r'(<form\b[^>]*\bmethod=["\']post["\'][^>]*>)', lambda match: match[1] + field, markup, flags=re.I)
def valid_csrf(request, fields):
expected = csrf_token(request, create=False)
supplied = str(fields.get("csrf") or "")
return bool(expected and re.fullmatch(r"[a-f0-9]{64}", supplied) and secrets.compare_digest(expected, supplied))