paskia 0.9.1__py3-none-any.whl → 0.10.2__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.
Files changed (71) hide show
  1. paskia/_version.py +2 -2
  2. paskia/bootstrap.py +8 -7
  3. paskia/db/__init__.py +2 -0
  4. paskia/db/background.py +5 -8
  5. paskia/db/jsonl.py +2 -2
  6. paskia/db/logging.py +130 -45
  7. paskia/db/operations.py +25 -4
  8. paskia/db/structs.py +3 -2
  9. paskia/fastapi/__main__.py +33 -19
  10. paskia/fastapi/admin.py +2 -2
  11. paskia/fastapi/api.py +7 -3
  12. paskia/fastapi/authz.py +11 -9
  13. paskia/fastapi/logging.py +64 -21
  14. paskia/fastapi/mainapp.py +8 -5
  15. paskia/fastapi/remote.py +11 -37
  16. paskia/fastapi/user.py +22 -0
  17. paskia/fastapi/ws.py +12 -35
  18. paskia/fastapi/wschat.py +55 -2
  19. paskia/fastapi/wsutil.py +2 -7
  20. paskia/frontend-build/auth/admin/index.html +7 -6
  21. paskia/frontend-build/auth/assets/{AccessDenied-DPkUS8LZ.css → AccessDenied-CVQZxSIL.css} +1 -1
  22. paskia/frontend-build/auth/assets/AccessDenied-Licr0tqA.js +8 -0
  23. paskia/frontend-build/auth/assets/{RestrictedAuth-CvR33_Z0.css → RestrictedAuth-0MFeNWS2.css} +1 -1
  24. paskia/frontend-build/auth/assets/{RestrictedAuth-DsJXicIw.js → RestrictedAuth-DWKMTEV3.js} +1 -1
  25. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js +33 -0
  26. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css +1 -0
  27. paskia/frontend-build/auth/assets/{admin-DzzjSg72.css → admin-B1H4YqM_.css} +1 -1
  28. paskia/frontend-build/auth/assets/admin-CZKsX1OI.js +1 -0
  29. paskia/frontend-build/auth/assets/{auth-C7k64Wad.css → auth-B4EpDxom.css} +1 -1
  30. paskia/frontend-build/auth/assets/auth-Pe-PKe8b.js +1 -0
  31. paskia/frontend-build/auth/assets/forward-BC0p23CH.js +1 -0
  32. paskia/frontend-build/auth/assets/{pow-2N9bxgAo.js → pow-DUr-T9XX.js} +1 -1
  33. paskia/frontend-build/auth/assets/reset-B8PlNXuP.css +1 -0
  34. paskia/frontend-build/auth/assets/reset-CkY9h28U.js +1 -0
  35. paskia/frontend-build/auth/assets/restricted-C9cJlHkd.js +1 -0
  36. paskia/frontend-build/auth/assets/theme-C2WysaSw.js +1 -0
  37. paskia/frontend-build/auth/index.html +8 -7
  38. paskia/frontend-build/auth/restricted/index.html +7 -6
  39. paskia/frontend-build/int/forward/index.html +6 -6
  40. paskia/frontend-build/int/reset/index.html +4 -4
  41. paskia/frontend-build/paskia.webp +0 -0
  42. paskia/util/__init__.py +0 -0
  43. paskia/util/apistructs.py +110 -0
  44. paskia/util/frontend.py +75 -0
  45. paskia/util/hostutil.py +75 -0
  46. paskia/util/htmlutil.py +47 -0
  47. paskia/util/passphrase.py +20 -0
  48. paskia/util/permutil.py +43 -0
  49. paskia/util/pow.py +45 -0
  50. paskia/util/querysafe.py +11 -0
  51. paskia/util/sessionutil.py +38 -0
  52. paskia/util/startupbox.py +103 -0
  53. paskia/util/timeutil.py +47 -0
  54. paskia/util/useragent.py +10 -0
  55. paskia/util/userinfo.py +63 -0
  56. paskia/util/vitedev.py +71 -0
  57. paskia/util/wordlist.py +54 -0
  58. {paskia-0.9.1.dist-info → paskia-0.10.2.dist-info}/METADATA +14 -11
  59. paskia-0.10.2.dist-info/RECORD +78 -0
  60. paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +0 -8
  61. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -1
  62. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +0 -2
  63. paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +0 -1
  64. paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +0 -1
  65. paskia/frontend-build/auth/assets/forward-DmqVHZ7e.js +0 -1
  66. paskia/frontend-build/auth/assets/reset-Chtv69AT.css +0 -1
  67. paskia/frontend-build/auth/assets/reset-s20PATTN.js +0 -1
  68. paskia/frontend-build/auth/assets/restricted-D3AJx3_6.js +0 -1
  69. paskia-0.9.1.dist-info/RECORD +0 -60
  70. {paskia-0.9.1.dist-info → paskia-0.10.2.dist-info}/WHEEL +0 -0
  71. {paskia-0.9.1.dist-info → paskia-0.10.2.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,75 @@
1
+ import asyncio
2
+ import mimetypes
3
+ import os
4
+ from importlib import resources
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+
9
+ __all__ = ["path", "file", "read", "is_dev_mode"]
10
+
11
+
12
+ def _get_dev_server() -> str | None:
13
+ """Get the dev server URL from environment, or None if not in dev mode."""
14
+ return os.environ.get("FASTAPI_VUE_FRONTEND_URL") or None
15
+
16
+
17
+ def _resolve_static_dir() -> Path:
18
+ # Try packaged path via importlib.resources (works for wheel/installed).
19
+ try: # pragma: no cover - trivial path resolution
20
+ pkg_dir = resources.files("paskia") / "frontend-build"
21
+ fs_path = Path(str(pkg_dir))
22
+ if fs_path.is_dir():
23
+ return fs_path
24
+ except Exception: # pragma: no cover - defensive
25
+ pass
26
+ # Fallback for editable/development before build.
27
+ return Path(__file__).parent.parent / "frontend-build"
28
+
29
+
30
+ path: Path = _resolve_static_dir()
31
+
32
+
33
+ def file(*parts: str) -> Path:
34
+ """Return a child path under the static root."""
35
+ return path.joinpath(*parts)
36
+
37
+
38
+ def is_dev_mode() -> bool:
39
+ """Check if we're running in dev mode (Vite frontend server)."""
40
+ return bool(_get_dev_server())
41
+
42
+
43
+ async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
44
+ """Read file content and return response tuple.
45
+
46
+ In dev mode, fetches from the Vite dev server.
47
+ In production, reads from the static build directory.
48
+
49
+ Args:
50
+ filepath: Path relative to frontend root, e.g. "/auth/index.html"
51
+
52
+ Returns:
53
+ Tuple of (content, status_code, headers) suitable for
54
+ FastAPI Response(*args) or Sanic raw response.
55
+ """
56
+ if is_dev_mode():
57
+ dev_server = _get_dev_server()
58
+ async with httpx.AsyncClient() as client:
59
+ resp = await client.get(f"{dev_server}{filepath}")
60
+ resp.raise_for_status()
61
+ mime = resp.headers.get("content-type", "application/octet-stream")
62
+ # Strip charset suffix if present
63
+ mime = mime.split(";")[0].strip()
64
+ return resp.content, resp.status_code, {"content-type": mime}
65
+ else:
66
+ # Production: read from static build
67
+ file_path = path / filepath.lstrip("/")
68
+ content = await _read_file_async(file_path)
69
+ mime, _ = mimetypes.guess_type(str(file_path))
70
+ return content, 200, {"content-type": mime or "application/octet-stream"}
71
+
72
+
73
+ async def _read_file_async(file_path: Path) -> bytes:
74
+ """Read file asynchronously using asyncio.to_thread."""
75
+ return await asyncio.to_thread(file_path.read_bytes)
@@ -0,0 +1,75 @@
1
+ """Utilities for determining the auth UI host and base URLs."""
2
+
3
+ import json
4
+ import os
5
+ from functools import lru_cache
6
+ from urllib.parse import urlparse, urlsplit
7
+
8
+
9
+ @lru_cache(maxsize=1)
10
+ def _load_config() -> dict:
11
+ """Load PASKIA_CONFIG JSON."""
12
+ config_json = os.getenv("PASKIA_CONFIG")
13
+ if not config_json:
14
+ return {}
15
+ return json.loads(config_json)
16
+
17
+
18
+ def is_root_mode() -> bool:
19
+ return _load_config().get("auth_host") is not None
20
+
21
+
22
+ def dedicated_auth_host() -> str | None:
23
+ """Return configured auth_host netloc, or None."""
24
+ auth_host = _load_config().get("auth_host")
25
+ if not auth_host:
26
+ return None
27
+
28
+ parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
29
+ return parsed.netloc or parsed.path or None
30
+
31
+
32
+ def ui_base_path() -> str:
33
+ return "/" if is_root_mode() else "/auth/"
34
+
35
+
36
+ def auth_site_url() -> str:
37
+ """Return the base URL for the auth site UI (computed at startup)."""
38
+ cfg = _load_config()
39
+ return cfg.get("site_url", "https://localhost") + cfg.get("site_path", "/auth/")
40
+
41
+
42
+ def reset_link_url(token: str) -> str:
43
+ """Generate a reset link URL for the given token."""
44
+ return f"{auth_site_url()}{token}"
45
+
46
+
47
+ def normalize_origin(origin: str) -> str:
48
+ """Normalize an origin URL by adding https:// if no scheme is present."""
49
+ if "://" not in origin:
50
+ return f"https://{origin}"
51
+ return origin
52
+
53
+
54
+ def reload_config() -> None:
55
+ _load_config.cache_clear()
56
+
57
+
58
+ def normalize_host(raw_host: str | None) -> str | None:
59
+ """Normalize a Host header preserving port (exact match required)."""
60
+ if not raw_host:
61
+ return None
62
+ candidate = raw_host.strip()
63
+ if not candidate:
64
+ return None
65
+ # urlsplit to parse (add // for scheme-less); prefer netloc to retain port.
66
+ parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
67
+ netloc = parsed.netloc or parsed.path or ""
68
+ # Strip IPv6 brackets around host part but retain port suffix.
69
+ if netloc.startswith("["):
70
+ # format: [ipv6]:port or [ipv6]
71
+ if "]" in netloc:
72
+ host_part, _, rest = netloc.partition("]")
73
+ port_part = rest.lstrip(":")
74
+ netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "")
75
+ return netloc.lower() or None
@@ -0,0 +1,47 @@
1
+ """Utility functions for HTML manipulation."""
2
+
3
+ import re
4
+
5
+
6
+ def patch_html_data_attrs(html: bytes, **data_attrs: str) -> bytes:
7
+ """Patch HTML by adding data attributes to the <html> tag.
8
+
9
+ If an <html> tag exists, adds data attributes to it.
10
+ If no <html> tag exists, prepends one with the data attributes.
11
+
12
+ Args:
13
+ html: The HTML content as bytes
14
+ **data_attrs: Key-value pairs for data attributes (e.g., mode='reauth')
15
+
16
+ Returns:
17
+ Modified HTML as bytes
18
+
19
+ Examples:
20
+ >>> patch_html_data_attrs(b'<html><body>test</body></html>', mode='reauth')
21
+ b'<html data-mode="reauth"><body>test</body></html>'
22
+
23
+ >>> patch_html_data_attrs(b'<body>test</body>', mode='reauth')
24
+ b'<html data-mode="reauth"><body>test</body>'
25
+ """
26
+ if not data_attrs:
27
+ return html
28
+
29
+ html_str = html.decode("utf-8")
30
+
31
+ # Build the data attributes string
32
+ attrs_str = " ".join(f'data-{key}="{value}"' for key, value in data_attrs.items())
33
+
34
+ # Check if there's an <html> tag (case-insensitive, may have existing attributes)
35
+ html_tag_pattern = re.compile(r"<html([^>]*)>", re.IGNORECASE)
36
+ match = html_tag_pattern.search(html_str)
37
+
38
+ if match:
39
+ # Insert data attributes into existing <html> tag
40
+ existing_attrs = match.group(1)
41
+ new_tag = f"<html{existing_attrs} {attrs_str}>"
42
+ html_str = html_tag_pattern.sub(new_tag, html_str, count=1)
43
+ else:
44
+ # Prepend <html> tag with data attributes
45
+ html_str = f"<html {attrs_str}>" + html_str
46
+
47
+ return html_str.encode("utf-8")
@@ -0,0 +1,20 @@
1
+ import secrets
2
+
3
+ from paskia.util.wordlist import words
4
+
5
+ N_WORDS = 5
6
+ N_WORDS_SHORT = 3
7
+
8
+ wset = set(words)
9
+
10
+
11
+ def generate(n=N_WORDS, sep="."):
12
+ """Generate a password of random words without repeating any word."""
13
+ wl = words.copy()
14
+ return sep.join(wl.pop(secrets.randbelow(len(wl))) for i in range(n))
15
+
16
+
17
+ def is_well_formed(passphrase: str, n=N_WORDS, sep=".") -> bool:
18
+ """Check if the passphrase is well-formed according to the regex pattern."""
19
+ p = passphrase.split(sep)
20
+ return len(p) == n and all(w in wset for w in passphrase.split("."))
@@ -0,0 +1,43 @@
1
+ """Minimal permission helpers with '*' wildcard support (no DB expansion)."""
2
+
3
+ from collections.abc import Sequence
4
+ from fnmatch import fnmatchcase
5
+
6
+ from paskia import db
7
+ from paskia.util.hostutil import normalize_host
8
+
9
+ __all__ = ["has_any", "has_all", "session_context"]
10
+
11
+
12
+ def _match(perms: set[str], patterns: Sequence[str]):
13
+ return (
14
+ any(fnmatchcase(p, pat) for p in perms) if "*" in pat else pat in perms
15
+ for pat in patterns
16
+ )
17
+
18
+
19
+ def _get_effective_scopes(ctx) -> set[str]:
20
+ """Get effective permission scopes from context.
21
+
22
+ Returns scopes from ctx.permissions (filtered by org) if available,
23
+ otherwise falls back to ctx.role.permissions for backwards compatibility.
24
+ """
25
+ if ctx.permissions:
26
+ return {p.scope for p in ctx.permissions}
27
+ # Fallback for contexts without effective permissions computed
28
+ return set(ctx.role.permissions or [])
29
+
30
+
31
+ def has_any(ctx, patterns: Sequence[str]) -> bool:
32
+ return any(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
33
+
34
+
35
+ def has_all(ctx, patterns: Sequence[str]) -> bool:
36
+ return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
37
+
38
+
39
+ async def session_context(auth: str | None, host: str | None = None):
40
+ if not auth:
41
+ return None
42
+ normalized_host = normalize_host(host) if host else None
43
+ return db.data().session_ctx(auth, normalized_host)
paskia/util/pow.py ADDED
@@ -0,0 +1,45 @@
1
+ """
2
+ Proof of Work utility using PBKDF2-SHA512.
3
+
4
+ The PoW requires finding nonces where PBKDF2(challenge, nonce) produces
5
+ output with a zero first byte. Each work unit requires finding one such nonce.
6
+ All valid nonces are concatenated into a solution for server verification.
7
+ """
8
+
9
+ import hashlib
10
+ import secrets
11
+
12
+ EASY = 2 # Around 0.25s
13
+ NORMAL = 8 # Around 1s
14
+ HARD = 32 # Around 4s
15
+
16
+
17
+ def generate_challenge() -> bytes:
18
+ """Generate a random 8-byte challenge."""
19
+ return secrets.token_bytes(8)
20
+
21
+
22
+ def verify_pow(challenge: bytes, solution: bytes, work: int = NORMAL) -> None:
23
+ """Verify a Proof of Work solution.
24
+
25
+ Args:
26
+ challenge: 8-byte server-provided challenge
27
+ solution: Concatenated 8-byte nonces (8 * work bytes)
28
+ work: Number of work units expected
29
+
30
+ Raises:
31
+ ValueError: If the solution is invalid
32
+ """
33
+ if len(challenge) != 8:
34
+ raise ValueError("Invalid challenge length")
35
+
36
+ if len(solution) != 8 * work:
37
+ raise ValueError("Invalid solution length")
38
+
39
+ # Verify each work unit - check that PBKDF2 output starts with 0x00
40
+ for i in range(work):
41
+ nonce = solution[i * 8 : (i + 1) * 8]
42
+ # Require first byte of PBKDF2-SHA512 to be zero
43
+ result = hashlib.pbkdf2_hmac("sha512", challenge, nonce, 128, 2)
44
+ if result[0] or result[1] & 0x07:
45
+ raise ValueError("Invalid PoW solution")
@@ -0,0 +1,11 @@
1
+ import re
2
+
3
+ _SAFE_RE = re.compile(r"^[A-Za-z0-9:._~-]+$")
4
+
5
+
6
+ def assert_safe(value: str, *, field: str = "value") -> None:
7
+ if not isinstance(value, str) or not value or not _SAFE_RE.match(value):
8
+ raise ValueError(f"{field} must match ^[A-Za-z0-9:._~-]+$")
9
+
10
+
11
+ __all__ = ["assert_safe"]
@@ -0,0 +1,38 @@
1
+ """Utility functions for session validation and checking."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from paskia.authsession import EXPIRES
6
+ from paskia.db import SessionContext
7
+ from paskia.util.timeutil import parse_duration
8
+
9
+
10
+ def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
11
+ """Check if a session satisfies the max_age requirement.
12
+
13
+ Uses the credential's last_used timestamp to determine authentication age,
14
+ since session renewal can happen without re-authentication.
15
+
16
+ Args:
17
+ ctx: The session context containing session and credential info
18
+ max_age: Maximum age string (e.g., "5m", "1h", "30s") or None
19
+
20
+ Returns:
21
+ True if authentication is recent enough or max_age is None, False if too old
22
+
23
+ Raises:
24
+ ValueError: If max_age format is invalid
25
+ """
26
+ if not max_age:
27
+ return True
28
+
29
+ max_age_delta = parse_duration(max_age)
30
+
31
+ # Use credential's last_used time if available, fall back to session renewed time
32
+ if ctx.credential and ctx.credential.last_used:
33
+ auth_time = ctx.credential.last_used
34
+ else:
35
+ auth_time = ctx.session.expiry - EXPIRES
36
+
37
+ time_since_auth = datetime.now(UTC) - auth_time
38
+ return time_since_auth <= max_age_delta
@@ -0,0 +1,103 @@
1
+ """Startup configuration box formatting utilities."""
2
+
3
+ import os
4
+ import re
5
+ from sys import stderr
6
+ from typing import TYPE_CHECKING
7
+
8
+ from paskia._version import __version__
9
+
10
+ if TYPE_CHECKING:
11
+ from paskia.config import PaskiaConfig
12
+
13
+ BOX_WIDTH = 60 # Inner width (excluding box chars)
14
+
15
+ # ANSI color codes
16
+ RESET = "\033[0m"
17
+ YELLOW = "\033[33m" # Dark yellow
18
+ BRIGHT_YELLOW = "\033[93m" # Bright yellow
19
+ BRIGHT_WHITE = "\033[1;37m" # Bold bright white
20
+
21
+
22
+ def _visible_len(text: str) -> int:
23
+ """Calculate visible length of text, ignoring ANSI escape codes."""
24
+ return len(re.sub(r"\033\[[0-9;]*m", "", text))
25
+
26
+
27
+ def line(text: str = "") -> str:
28
+ """Format a line inside the box with proper padding, truncating if needed."""
29
+ visible = _visible_len(text)
30
+ if visible > BOX_WIDTH:
31
+ text = text[: BOX_WIDTH - 1] + "…"
32
+ visible = BOX_WIDTH
33
+ padding = BOX_WIDTH - visible
34
+ return f"┃ {text}{' ' * padding} ┃\n"
35
+
36
+
37
+ def top() -> str:
38
+ return "┏" + "━" * (BOX_WIDTH + 2) + "┓\n"
39
+
40
+
41
+ def bottom() -> str:
42
+ return "┗" + "━" * (BOX_WIDTH + 2) + "┛\n"
43
+
44
+
45
+ def print_startup_config(config: "PaskiaConfig") -> None:
46
+ """Print server configuration on startup."""
47
+ # Key graphic with yellow shading (bright for highlights, dark for body)
48
+ Y = YELLOW # Dark yellow for main body
49
+ B = BRIGHT_YELLOW # Bright yellow for highlights/edges
50
+ W = BRIGHT_WHITE # Bold white for URL
51
+ R = RESET
52
+
53
+ lines = [top()]
54
+ lines.append(line(f" {B}▄▄▄▄▄{R}"))
55
+ lines.append(line(f"{B}█{Y} {B}█{R} Paskia " + __version__))
56
+ lines.append(line(f"{B}█{Y} {B}█{Y}▄▄▄▄▄▄▄▄▄▄▄▄{R}"))
57
+ lines.append(
58
+ line(
59
+ f"{B}█{Y} {B}█{Y}▀▀▀▀{B}█{Y}▀▀{B}█{Y}▀▀{B}█{R} {W}"
60
+ + config.site_url
61
+ + config.site_path
62
+ + R
63
+ )
64
+ )
65
+ lines.append(line(f" {Y}▀▀▀▀▀{R}"))
66
+
67
+ # Format auth host section
68
+ if config.auth_host:
69
+ lines.append(line(f"Auth Host: {config.auth_host}"))
70
+
71
+ # Show frontend URL if in dev mode
72
+ devmode = os.environ.get("FASTAPI_VUE_FRONTEND_URL")
73
+ if devmode:
74
+ lines.append(line(f"Dev Frontend: {devmode}"))
75
+
76
+ # Format listen address with scheme
77
+ if config.uds:
78
+ listen = f"unix:{config.uds}"
79
+ elif config.host:
80
+ listen = f"http://{config.host}:{config.port}"
81
+ else:
82
+ listen = f"http://0.0.0.0:{config.port} + [::]:{config.port}"
83
+ lines.append(line(f"Backend: {listen}"))
84
+
85
+ # Relying Party line (omit name if same as id)
86
+ rp_id = config.rp_id
87
+ rp_name = config.rp_name
88
+ if rp_name and rp_name != rp_id:
89
+ lines.append(line(f"Relying Party: {rp_id} ({rp_name})"))
90
+ else:
91
+ lines.append(line(f"Relying Party: {rp_id}"))
92
+
93
+ # Format origins section
94
+ allowed = config.origins
95
+ if allowed:
96
+ lines.append(line("Permitted Origins:"))
97
+ for origin in sorted(allowed):
98
+ lines.append(line(f" - {origin}"))
99
+ else:
100
+ lines.append(line(f"Origin: {rp_id} and all subdomains allowed"))
101
+
102
+ lines.append(bottom())
103
+ stderr.write("".join(lines))
@@ -0,0 +1,47 @@
1
+ """Utility functions for parsing time durations."""
2
+
3
+ import re
4
+ from datetime import timedelta
5
+
6
+
7
+ def parse_duration(duration_str: str) -> timedelta:
8
+ """Parse a duration string into a timedelta.
9
+
10
+ Supports units: s, m, min, h, d
11
+ Examples: "30s", "5m", "5min", "2h", "1d"
12
+
13
+ Args:
14
+ duration_str: A string like "30s", "5m", "2h"
15
+
16
+ Returns:
17
+ A timedelta object
18
+
19
+ Raises:
20
+ ValueError: If the format is invalid
21
+ """
22
+ duration_str = duration_str.strip().lower()
23
+
24
+ # Pattern matches: number + unit
25
+ # Units: s (seconds), m/min (minutes), h (hours), d (days)
26
+ pattern = r"^(\d+(?:\.\d+)?)(s|m|min|h|d)$"
27
+ match = re.match(pattern, duration_str)
28
+
29
+ if not match:
30
+ raise ValueError(
31
+ f"Invalid duration format: '{duration_str}'. "
32
+ "Expected format like '30s', '5m', '5min', '2h', or '1d'"
33
+ )
34
+
35
+ value = float(match.group(1))
36
+ unit = match.group(2)
37
+
38
+ if unit == "s":
39
+ return timedelta(seconds=value)
40
+ elif unit in ("m", "min"):
41
+ return timedelta(minutes=value)
42
+ elif unit == "h":
43
+ return timedelta(hours=value)
44
+ elif unit == "d":
45
+ return timedelta(days=value)
46
+ else:
47
+ raise ValueError(f"Unsupported time unit: {unit}")
@@ -0,0 +1,10 @@
1
+ import user_agents
2
+
3
+
4
+ def compact_user_agent(ua: str | None) -> str:
5
+ if not ua:
6
+ return "-"
7
+ u = user_agents.parse(ua)
8
+ ver = u.browser.version_string.split(".")[0]
9
+ dev = u.device.family if u.device.family not in ["Other", "Mac"] else ""
10
+ return f"{u.browser.family}/{ver} {u.os.family} {dev}".strip()
@@ -0,0 +1,63 @@
1
+ """User information formatting and retrieval logic."""
2
+
3
+ from paskia import aaguid, db
4
+ from paskia.authsession import EXPIRES
5
+ from paskia.db import SessionContext
6
+ from paskia.util import hostutil, permutil
7
+ from paskia.util.apistructs import ApiSession
8
+
9
+
10
+ def build_session_context(ctx: SessionContext) -> dict:
11
+ """Build session context dict from SessionContext."""
12
+ result = {
13
+ "user": {"uuid": ctx.user.uuid, "display_name": ctx.user.display_name},
14
+ "org": {"uuid": ctx.org.uuid, "display_name": ctx.org.display_name},
15
+ "role": {"uuid": ctx.role.uuid, "display_name": ctx.role.display_name},
16
+ "permissions": [p.scope for p in ctx.permissions],
17
+ }
18
+ if ctx.user.theme:
19
+ result["user"]["theme"] = ctx.user.theme
20
+ return result
21
+
22
+
23
+ async def build_user_info(
24
+ *,
25
+ user_uuid,
26
+ auth: str,
27
+ session_record,
28
+ request_host: str | None,
29
+ ) -> dict:
30
+ """Build user info dict for authenticated users."""
31
+ ctx = await permutil.session_context(auth, request_host)
32
+ user = db.data().users[user_uuid]
33
+ normalized_host = hostutil.normalize_host(request_host)
34
+
35
+ credentials = sorted(user.credentials, key=lambda c: c.created_at)
36
+ return {
37
+ "ctx": build_session_context(ctx),
38
+ "created_at": ctx.user.created_at,
39
+ "last_seen": ctx.user.last_seen,
40
+ "visits": ctx.user.visits,
41
+ "credentials": [
42
+ {
43
+ "credential": c.uuid,
44
+ "aaguid": c.aaguid,
45
+ "created_at": c.created_at,
46
+ "last_used": c.last_used,
47
+ "last_verified": c.last_verified,
48
+ "sign_count": c.sign_count,
49
+ "is_current_session": session_record.credential == c.uuid,
50
+ }
51
+ for c in credentials
52
+ ],
53
+ "aaguid_info": aaguid.filter(c.aaguid for c in credentials),
54
+ "sessions": [
55
+ ApiSession.from_db(
56
+ s,
57
+ current_key=auth,
58
+ normalized_host=normalized_host,
59
+ expires_delta=EXPIRES,
60
+ )
61
+ for s in user.sessions
62
+ ],
63
+ }
paskia/util/vitedev.py ADDED
@@ -0,0 +1,71 @@
1
+ """Vite dev server proxy for fetching frontend files during development.
2
+
3
+ In dev mode (FASTAPI_VUE_FRONTEND_URL set), fetches files from Vite.
4
+ In production, reads from the static build directory.
5
+
6
+ This complements fastapi_vue.Frontend which handles static file serving
7
+ but doesn't provide server-side fetching of HTML content.
8
+ """
9
+
10
+ import asyncio
11
+ import mimetypes
12
+ import os
13
+ from importlib import resources
14
+ from pathlib import Path
15
+
16
+ import httpx
17
+
18
+ __all__ = ["read"]
19
+
20
+
21
+ def _get_dev_server() -> str | None:
22
+ """Get the dev server URL from environment, or None if not in dev mode."""
23
+ return os.environ.get("FASTAPI_VUE_FRONTEND_URL") or None
24
+
25
+
26
+ def _resolve_static_dir() -> Path:
27
+ """Resolve the static files directory."""
28
+
29
+ # Try packaged path via importlib.resources (works for wheel/installed).
30
+ try: # pragma: no cover - trivial path resolution
31
+ pkg_dir = resources.files("paskia") / "frontend-build"
32
+ fs_path = Path(str(pkg_dir))
33
+ if fs_path.is_dir():
34
+ return fs_path
35
+ except Exception: # pragma: no cover - defensive
36
+ pass
37
+ # Fallback for editable/development before build.
38
+ return Path(__file__).parent.parent / "frontend-build"
39
+
40
+
41
+ _static_dir: Path = _resolve_static_dir()
42
+
43
+
44
+ async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
45
+ """Read file content and return response tuple.
46
+
47
+ In dev mode, fetches from the Vite dev server.
48
+ In production, reads from the static build directory.
49
+
50
+ Args:
51
+ filepath: Path relative to frontend root, e.g. "/auth/index.html"
52
+
53
+ Returns:
54
+ Tuple of (content, status_code, headers) suitable for
55
+ FastAPI Response(*args).
56
+ """
57
+ dev_server = _get_dev_server()
58
+ if dev_server:
59
+ async with httpx.AsyncClient() as client:
60
+ resp = await client.get(f"{dev_server}{filepath}")
61
+ resp.raise_for_status()
62
+ mime = resp.headers.get("content-type", "application/octet-stream")
63
+ # Strip charset suffix if present
64
+ mime = mime.split(";")[0].strip()
65
+ return resp.content, resp.status_code, {"content-type": mime}
66
+ else:
67
+ # Production: read from static build
68
+ file_path = _static_dir / filepath.lstrip("/")
69
+ content = await asyncio.to_thread(file_path.read_bytes)
70
+ mime, _ = mimetypes.guess_type(str(file_path))
71
+ return content, 200, {"content-type": mime or "application/octet-stream"}